而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。
0x00 boot的含义
先问一个问题,”启动”用英语怎么说?
回答是:boot。可是,boot原来的意思是靴子,”启动”与靴子有什么关系呢? 原来,这里的boot是bootstrap(鞋带)的缩写,它来自一句谚语:”pull oneself up by one’s bootstraps”
字面意思是”拽着鞋带把自己拉起来“,这当然是不可能的事情。最早的时候,工程师们用它来比喻,计算机启动是一个很矛盾的过程:必须先运行程序,然后计算机才能启动,但是计算机不启动就无法运行程序!
早期真的是这样,必须想尽各种办法,把一小段程序装进内存,然后计算机才能正常运行。所以,工程师们把这个过程叫做”拉鞋带”,久而久之就简称为boot了。
计算机的整个启动过程分成 [四个阶段] 。
0x01 BIOS
为了把一小段程序装入内存,ROM(Read-Only Memory)被发明,在ROM上的启动阶段也叫ROM Stage,在这个阶段 [没有内存] ,需要在 [ROM] 上运行代码。这时因为没有内存,没有C语言运行需要的栈空间,开始往往是汇编语言直接在ROM上运行,所以ROM Stage也是最困难的阶段。计算机通电后,第一件事情就是读取在ROM里提前写好的汇编程序,这段程序就叫BIOS。
CPU运行于 [实模式] 工作环境中,数据位宽为16位,最大物理地址寻址范围是1MB,现在你按下了电源的开机键发电源信号:给CPU加电!BIOS在CPU硬件加电瞬间强行将CS值置为0xFFFF
,IP的值置为0x0000
,这样CS:IP就指向0xFFFF0这个地址位置,CPU跳到 0xFFFF0 处执行程序,一般情况下这里是一条跳转指令,CPU根据这个跳转指令跳到真正的BIOS入口地址执行,BIOS就开始做下面的事。
step1 加电自检
1.加电自检(POST,Power On Self Test),检测 [关键设备] 是否正常工作,如果有故障,喇叭就会叫唤。
2.初始化 [显示设备] 并显示显卡信息。
3.检测 [CPU和内存] 并显示检测结果。
4.检测 [标准设备],如硬盘、光驱、串口设备、并口设备等。
5.检测 [即插即用设备],并为这些设备分配 中断号、I/O端口和DMA通道 等资源。
6.如果硬件配置发生变化,那么这些变化的配置将更新到 CMOS 中。
step2 启动顺序
7.根据配置的启动顺序引导设备启动,通过BIOS中断将设备的引导程序读入内存。
注👋BIOS中断可以理解为BIOS系统为用户提供的一些封装好了的“API”而已,它之所以也被叫中断,原因在于这部分“API”的运转模式是采用的“中断机制”:中断向量+中断服务程序,也叫软中断。
8.将处理器的控制权交给引导程序,最终引导进入操作系统。
到此处CPU已经 [初始化] 并 [指向BIOS],下一步要做的:BIOS读取MBR。
0x02 主引导记录(MBR)
主引导记录(Master Boot Record,缩写:MBR),又叫做主引导扇区,是计算机开机后访问硬盘时所必须要读取的首个扇区,它在硬盘上的三维地址(也叫3D参数)为(柱面Cylinders,磁头Headers,扇区Sectors)=(0,0,1)。主引导扇区记录着硬盘本身的相关信息以及硬盘各个分区的大小及位置信息,是数据信息的重要入口,它告诉CPU到硬盘的哪个位置去寻找操作系统。
BIOS按照”启动顺序”,把控制权转交给排在第一位的硬盘。
这时,BIOS读取该硬盘的第一个扇区(主引导扇区),也就是读取最前面的512个字节。如果这512个字节的最后两个字节是0x55和0xAA,表明这个设备可以用于启动;如果不是,表明设备不能用于启动,控制权于是被转交给”启动顺序”中的下一个设备。
👋对于硬盘而言,一个扇区可能的字节数为
在使用“主引导记录”(MBR)这个术语的时候,需要根据具体情况判断其到底是指整个主引导扇区,还是主引导扇区的前446字节。
The MBR is stored on the first sector of the hard disk and is created along with the first partition on the drive. It is loaded into memory as one of the first actions during system start up.
David Day, in Cyber Crime and Cyber Terrorism Investigator’s Handbook, 2014
2.1 MBR结构
2.1.1启动代码(调用OS的机器码)
linux0.11
最开始的代码bootsect.s
编译为二进制文件后存放在启动区第一扇区(MBR),然后由BIOS搬运至内存的0x7c00
位置,CPU也从此处开始不断往后一条一条指令执行。下面贴一小段源代码:
1 | SETUPLEN = 4 ! nr of setup-sectors |
2.1.2分区表(Partition Table)
分区表的长度只有64个字节,里面又分成四项,每项16个字节。所以,一个硬盘最多划分四个一级分区,又叫做“主分区”。一个主分区的扇区总数最多不超过
2.1.3主引导记录签名
系统在对硬盘做初始化时写入的一个标签,它是MBR不可或缺的一个组成部分。系统依靠这个签名来识别硬盘,如果硬盘的签名丢失,系统就会认为该硬盘没有初始化。0x55和0xAA就是签名。
现在BIOS已经把MBR的512字节都装入内存了,执行完BIOS整个代码后,要做的事情是:将控制权交给硬盘的某个分区。
2.2 linux_0.11源码讲解
1 | entry _start |
硬盘第一扇区也就是MBR加载进入内存后,要将内存地址0x7c00
处开始往后512字节的数据原封不动复制到内存的0x90000
地址处。
1 | mov cx,#256 |
现在,操作系统最开头的代码,已经被挪到了 0x90000 这个位置了。
再往后是一个跳转指令。
1 | jmpi go,0x9000 ;跳转到 0x90000 + go 这个内存地址处执行 |
数据段寄存器 ds和代码段寄存器 cs 此时都被设置为了 0x9000,也就为跳转代码和访问内存数据,奠定了同一个内存的基址地址,因为仅仅需要指定偏移地址,方便了跳转和内存访问。
栈顶地址被设置为了 0x9FF00,具体表现为栈段寄存器 ss 为 0x9000,栈基址寄存器 sp 为 0xFF00。栈是向下发展的,这个栈顶地址 0x9FF00 要远远大于此时代码所在的位置 0x90000,所以栈向下发展就很难撞见代码所在的位置,也就比较安全。
现在在做的事情,就是给如何访问代码,如何访问数据,如何访问栈进行了一下内存的初步规划。其中访问代码和访问数据的规划方式就是设置了一个基址而已,访问栈就是把栈顶指针指向了一个远离代码位置的地方而已。
下面要做的就是把磁盘中其余部分也拿到内存来,即:硬盘载入。
0x03 硬盘载入
根据分区,讨论3种情况:
3.1 启动管理器(boot loader)
计算机读取MBR前446字节的机器码后,不再把控制权转交给某一个分区,而是运行事先安装的启动管理器,由用户选择启动哪个OS。Linux环境中,目前最流行的启动管理器是Grub
。
3.2 卷引导记录(Volume boot record)
计算机四个主分区里面只有一个是激活的,计算机会读取激活分区的第一个扇区,叫做“卷引导记录”(Volume boot record),缩写为VBR。
VBR主要作用是:告诉计算机,操作系统在这个分区里的位置。然后,计算机就会加载操作系统了。
3.3 拓展分区
很少以拓展分区方式来启动OS。
如果有拓展分区,一般也用boot loader
方式启动。
3.4 linux_0.11源码讲解
1 | load_setup: |
int 0x13:发起0x13号中断,上面的通用寄存器ax,bx,cx,dx都是这个中断指令的参数。
用途:将指定扇区的代码加载到内存的指定位置,它可以完成磁盘(包括硬盘和软盘)的复位, 读写, 校验, 定位, 诊断, 格式化等功能。int 0x13采用的就是CHS寻址方式(柱面Cylinders,磁头Headers,扇区Sectors),因此最大识能访问8 GB左右的硬盘。
上面代码的作用是:
将硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区。
1 | ok_load_setup: |
这段代码作用是把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处。至此,整个操作系统的全部代码,就已经全部从硬盘中,被搬迁到内存来了。
下面是最后一次折腾内存,我们这部分不用细究代码,知道最后的结果就可以。整个OS的编译过程,是通过Makefile和build.c配合完成的,最终结果是:
1. 把 bootsect.s 编译成 bootsect 放在硬盘的 1 扇区。
2. 把 setup.s 编译成 setup 放在硬盘的 2~5 扇区。
3. 把剩下的全部代码(head.s 作为开头)编译成 system 放在硬盘的随后 240 个扇区。
下面依次进行:实模式转为保护模式、分页机制,再跳转到内核。
Intel CPU 一般可以在两种模式下运行,即 实模式 和 保护模式。早期的 Intel CPU(8088/8086) 只能工作在实模式下,某一时刻只能运行单个任务。对于 Intel 80386 以上的芯片则还可以运行在 32 位 保护模式下。在保护模式下运行可以支持多任务;支持 4G 的物理内存;支持虚拟内存;支持内存的页式 管理和段式管理;支持特权级。
实模式:8086(微机原理)。后面8086发展到了80286,此时址总线已有24根,但是在实模式下,为了向下兼容,系统表现的行为又应同8086一样,即仿佛“只有20根地址总线”。为了能够自由选择实模式下寻址能力的大小,便出现了A20 Gate。
A20 Gate是第21根地址总线,它有一个开关,保护模式下打开,突破地址信号线 20 位的宽度,变成 32 位可用。
为了从实模式转为保护模式,OS设置全局描述符表GDT。段值存入段寄存器,而该值作为索引,用于在GDT(Global Descriptor Table)中寻找到对应的一个表项(段描述符),该表项中含有段地址、段大小、访问控制等信息,得到其中的段地址后再加上合法的段内偏移,即可访问到对应的物理地址。
总结就是:保护模式下寻址方式为
GDT的本质就是个规定好格式的数据结构而已。
✍GDT存在的意义:
全局描述符表GDT(Global Descriptor Table)在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。
80286之前处理器只有实模式,GDT是提供内存保护,限制非法访问内存的一种方式。
保护模式下,物理地址并不是直接暴露在程序员面前了,寻址有了更多的检查步骤,这是属于虚拟内存的范畴了,在此就不再深入。
此时内存是这样:
其中GDTR寄存器存储了GDT结构,即:GDT description structure
GDT description structure描述了GDT的位置及大小(并非GDT的一部分)。
可通过LGDTR指令从内存中往GDTR中加载GDT description structure。
GDTR、IDTR、TR、LDTR都是内存管理寄存器。
GDTR:存放全局描述符表GDT的线性基地址和表长度。处理器加电或复位后,基地址默认为0,表长度默认为0xFFFF; 在保护模式初始化过程中,必须给GDTR加载一个新值。
IDTR:存放中断描述符表IDT的线性基地址和表长度。处理器加电或复位后,基地址默认为0,表长度默认为0xFFFF。
首先配置全局描述符表 gdt 和中断描述符表 idt:
1 | lidt idt_48 |
现在的代码仍然是setup.s中的:
1 | mov al,#0xD1 ; command write |
这段代码的意思是,打开 A20 地址线,进入保护模式。
下面有一大坨是专门对8259芯片的编程,微机原理讲过8259,去复习一下
1 | ; well, that went ok, I hope. Now we have to reprogram the interrupts :-( |
对于上面这段程序的解读,请教了CXQ老师:
JMP SHORT $+2 指令的机器码是 00EBH 。
功能:跳转到顺序的下一条指令。
作用:清空指令预取队列、延时。
现代快速CPU初始化慢速接口时,两个动作之间要加延时,这个JMP指令比NOP指令的延时时间更长(因为会清空指令队列)。还有一个原因,清空指令队列,也可以防止CPU乱序执行指令,而写8259初始化命令字是有严格顺序的。代码中的.word语句相当于直接用机器码写程序。
接下来的一步,就是真正切换模式的一步了,从代码上看就两行。
1 | mov ax,#0x0001 ; protected mode (PE) bit |
前两行,将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。
跳转指令 jmpi,后面的 8 表示 cs(代码段寄存器)的值,0 表示偏移地址。请注意,此时已经是保护模式了,之前也说过,保护模式下内存寻址方式变了,段寄存器里的值被当做段选择子。
8 用二进制表示是:
1 | 0000,0000,0000,1000 |
可以知道描述符索引值是 1,也就是要去全局描述符表(gdt)中找第1项段描述符。
1 | gdt: |
第 0 项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。
所以,这里取的就是这个代码段描述符,段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行。
0地址处就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码编译并链接在一起而成的,可以理解为操作系统的全部核心代码编译后的结果。启动模块最后还剩一个文件,它是正式进入c语言main.c
前的汇编文件:header.s,文件比较短。
1 | _pg_dir: ;表示页目录,后续设置分页机制时,页目录存放此处并覆盖原有代码 |
👋拓展:段寄存器的诞生
段寄存器的产生源于Intel 8086 CPU体系结构中数据总线与地址总线的宽度不一致。数据总线的宽度,即ALU(算数逻辑单元)的宽度,平常说一个CPU是“16位”或者“32位”指的就是ALU的宽度。
地址总线的宽度不一定要与ALU的宽度相同。因为ALU的宽度是固定的,它受限于当时的工艺水平,当时只能制造出16位的ALU;但地址总线不一样,它可以设计得更宽。8086中数据总线16位,地址总线20位,地址总线宽度大于数据总线会带来一些麻烦,ALU无法在单个指令周期里完成对地址数据的运算。有一些容易想到的可行的办法,比如定义一个新的寄存器专门用于存放地址的高4位,但这样增加了计算的复杂性,程序员要增加成倍的汇编代码来操作地址数据而且无法保持兼容性。
Intel想到了一个折中的办法:把内存分段,并设计了4个段寄存器,CS(Code Segment),DS(Data Segment),SS(Stack Segment),ES(Extra Segment),把内存分为很多段,每一段有一个段基址,当然段基址也是一个20位的内存地址。不过段寄存器仍然是16位的,它的内容代表了段基址的高16位,这个16位的地址后面再加上4个0就构成20位的段基址。而原来的16位地址只是段内的偏移量。
这样,一个完整的物理内存地址就由两部分组成,高16位的段基址和低16位的段内偏移量,才共同构成完整的物理地址。
1 | call setup_idt ;设置 [中断描述符表] |
中断描述符表 idt 里面存储着一个个中断描述符,每一个中断号就对应着一个中断描述符(一一映射),而中断描述符里面存储着主要是中断程序的地址,这样一个中断号过来后,CPU 就会自动寻找相应的中断程序,然后去执行它。
1 | setup_idt: |
这段程序的作用就是,设置了 256 个中断描述符,并且让每一个中断描述符中的中断程序例程都指向一个 ignore_int 的函数地址,这个是个默认的中断处理程序,之后会逐渐被各个具体的中断程序所覆盖。比如之后键盘模块会将自己的键盘中断处理程序,覆盖过去。现在,产生任何中断都会指向这个默认的函数 ignore_int,也就是说现在这个阶段你按键盘还不好使。
1 | ;设置好的GDT |
原来已经设置过一遍了,这里又要重新设置一遍,因为原来设置的 gdt 是在 setup 程序中,之后这个地方要被缓冲区覆盖掉,所以这里重新设置在 head 程序中,这块内存区域之后就不会被其他程序用到并且覆盖了。
1 | ;开启分页机制,并且跳转到 main 函数 |
开启分页机制,并跳转到main函数
In fact, segmentation and paging are somewhat redundant since both can be used to separate the physical address spaces of processes :
segmentation can assign a different linear address space to each process, while paging can map the same linear address space into different physical address spaces.
✍虚拟内存、分页与分段机制
这三个技术出现的目的:管理好计算机的内存
早期的计算机是直接使用实际的内存地址的,但是面临3个问题:
①内存使用效率低
很多情况下,有大量的数据都在装入装出内存,效率低下。
②进程地址空间不隔离
程序直接访问物理内存,恶意程序可以随意修改其它进程的内存数据,达到破坏目的;非恶意程序会导致计算机运行异常。
③程序运行地址不确定
当内存中剩余空间可以满足程序的要求后,OS会在剩余空间中随机分配一段连续的地址空间给程序使用。
为了解决上述问题,人们想到一种方法:增加一个中间层,利用一种间接的地址访问方法访问物理内存,按照这种方法,程序中访问的内存地址不再是实际物理内存地址,而是虚拟地址,然后由操作系统将这个虚拟地址映射到对应的物理内存地址上,只要处理好虚拟地址到内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同区域,达到内存地址空间隔离的效果。
怎样实现映射机制?人们想到分段(Segmentation)
Segmentation是虚拟地址空间和内存空间做一一映射,是一种线性地址映射,和高中学的内容很像,对吧?
分段只解决了进程地址不隔离和进程地址随机这2个问题,内存的效率问题没有得到解决,为了把内存效率问题解决,人们想到使用粒度更小的内存分割与映射方法,即:分页Paging
分页的基本方法是,将地址空间分成许多的页。每页的大小由CPU决定,然后由操作系统选择页的大小。目前Inter系列的CPU支持 4KB 或 4MB 的页大小,而PC上目前都选择使用 4KB 。
分页的思想是:程序运行时用到哪页,就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
分段机制目的:为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。
分页机制目的:可以按需使用物理内存,同时也可以在多任务时起到隔离的作用,开机后分页机制默认是关闭状态,需要我们手动开启,并且设置好页目录表(PDE)和页表(PTE)。
在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在,而分页机制是可以选择开启或关闭的。
所以如果有人和你说,它实现了一个没有分段机制的操作系统,那一定是个外行。
分页地址变换举例:
经过分段机制后的线性地址是:15M,
用二进制表示就是:
CPU看到我们给出的内存地址后,把线性地址拆分为:
高 10 位负责在页目录表中找到一个页目录项,这个页目录项的值加上中间 10 位拼接后的地址去页表中去寻找一个页表项,这个页表项的值,再加上后 12 位偏移地址,就是最终的物理地址。
而这一切的操作,都由计算机的一个硬件叫 MMU,中文名字叫内存管理单元,有时也叫 PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址。
作为操作系统这个软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫页目录表 PDE,第二级叫页表 PTE。
✍如何开启分页机制?
开启分页机制的开关:cr0寄存器中的31号位(从左往右第1位)写为1即可:
这段代码,就是帮我们把页表和页目录表在内存中写好,之后开启 cr0 寄存器的分页开关:
1 | setup_paging: |
linux0.11认为,总共可以使用的内存不超过16M,即:最大地址空间为:0xFFFFFH
。
而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。
注👋 4K 内存通常叫做 1 页内存
最终将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制。此时内存中的页表相关的布局如下:
这些页目录表和页表放到了整个内存布局中最开头的位置,就是覆盖了开头的 system 代码了,不过被覆盖的 system 代码已经执行过了,所以无所谓。
👋几个地址:
逻辑地址:程序员写代码开发时候给出的地址,包括 段选择子 和 偏移地址 两部分。
线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。
物理地址:真正在内存中的地址
跳到内核中的main:进入main.c
这个由C语言写的主函数:
1 | void main(void) { |
整个操作系统也会最终停留在最后一行死循环中,永不返回,直到关机。
3.5 拓展:x86架构的控制寄存器
控制寄存器保存自身关键信息,用于控制和确定处理器的操作模式以及当前执行任务的特性。
32位CPU有CR0-CR4,64位CPU增加了CR8。
CR0(尤其重要)
存储了CPU控制标记和工作状态。
Bit | Label | Description |
---|---|---|
0 | PE | Protected Mode Enable(是否开启保护模式) |
1 | MP | Monitor co-processor(控制与设定了TS标志的WAIT(或FWAIT)指令的的交互) |
2 | EM | x87 FPU Emulation(标示处理器是否有x87 FPU) |
3 | TS | Task switched(当执行一个新任务时,直到x87 FPU/MMX/SSE/SSE2/SSE3/SSSE3/SSE4指令执行完才保存其上下文) |
4 | ET | Extension type(是否支持Intel 387 DX math coprocessor指令) |
5 | NE | Numeric error(是否开启x87 FPU错误报告机制) |
16 | WP | Write protect(是否开启内存写保护) |
18 | AM | Alignment mask(是否启用内存对齐自动检查) |
29 | NW | Not-write through(控制当写操作命中缓冲的行为) |
30 | CD | Cache disable |
31 | PG | Paging(是否启用内存分页) |
CR1(未使用)
CR2
存储引起缺页的线性地址。(
CR3
存储了当前进程的虚拟地址空间的重要信息——页目录地址,可以说是整个虚拟地址翻译中的顶级指挥棒,在进程空间切换的时候,CR3也将同步切换。
PCD:
PCD=1:禁止某个页写入Cache,直接写内存。
PWT:
PWT=1:写数据到Cache的时候也要将数据写入内存。
CR4
存储了CPU工作相关以及当前人任务的一些信息。
CR8
64位新增拓展使用,用于读写Task Priority Register(TPR),它指定了外部中断生效的优先级阈值。CR8只对Intel 64架构可用。
0x04 OS内核载入
大致过程如下:
控制权转交给操作系统后,操作系统的内核首先被载入内存。以Linux系统为例,先载入
/boot
目录下面的kernel。内核加载成功后,第一个运行的程序是/sbin/init
。它根据配置文件(Debian系统是/etc/initab
)产生init进程。这是Linux启动后的第一个进程,pid进程编号为1,其他进程都是它的后代。然后,init线程加载系统的各个模块,比如窗口程序和网络程序,直至执行
/bin/login
程序,跳出登录界面,等待用户输入用户名和密码。至此,全部启动过程完成。摘自 阮一峰的博客
目前的内存布局图:
系统在执行完 boot/
目录中的 head.s
程序后就将 执行权交给了 main.c
。该程序虽然不长,但却包括了内核初始化的所有工作。因此在阅读该程序的代码时 需要参照很多其它程序中的初始化部分。
main.c
主要分为四大部分:
①一些参数的取值和计算
②各种初始化
③切换到用户态模式,并在一个新的进程中做一个最终的初始化 init
④死循环
下面分模块品读linux0.11源码:
1.参数取值与计算
1 | void main(void){ |
下面分析下面的参数:
1 | void main(void) { |
上面这段代码做的事:计算3个变量
main_memory_start
buffer_memory_end
memory_end
这么多代码,判断的标准都是memory_end
,其实就是根据内存大小设置不同边界值。假设内存共8M,那么memory_end就是
2.各种初始化
(1)主内存区管理分配 mem_init
1 | void main(void) { |
mem_init函数源码:
1 |
|
所谓的内存管理,就是准备了一张表,记录哪些内存被占用,哪些没有被占用。
1M 以下的内存这个数组干脆没有记录,这里的内存是无需管理的,或者换个说法是无权管理的,也就是没有权利申请和释放,因为这个区域是内核代码所在的地方,不能被“污染”。
1M 到 2M 这个区间是缓冲区,2M 是缓冲区的末端,缓冲区的开始在哪里之后再说,这些地方不是主内存区域,因此直接标记为 USED,产生的效果就是无法再被分配了。
2M 以上的空间是主内存区域,而主内存目前没有任何程序申请,所以初始化时统统都是零,未来等着应用程序去申请和释放这里的内存资源。
(2)中断初始化 trap_init
中断部分讲解以键盘为例:键盘的本质是I/O接口。按下键盘后会触发中断,CPU接收到中断后,根据中断号寻找由OS写好的键盘中断处理程序。键盘驱动才是真正意义上的中断:硬中断,是由硬件实实在在发起的中断。
我们为某一设备建立中断机制的标准流程:
1.初始化8259
2.建立IDT表(中断描述符表)
3.编写中断服务程序
我们以 Linux 0.11 源码为例,发现进入内核的 main 函数后不久,有这样一行代码:
1 | void main(void) { |
1 | //这段代码作用:设置中断表 |
键盘产生的中断的中断号是 0x21,此时这个中断号还仅仅对应着一个临时的中断处理程序 &reserved,我们接着往后看。
1 | void main(void) { |
trap_init 后有个 tty_init,最后根据调用链,会调用到一行添加 0x21 号中断处理程序的代码,就是刚刚熟悉的 set_trap_gate,而后面的 keyboard_interrupt 根据名字也可以猜出,就是键盘的中断处理程序,从这行代码:
1 | set_trap_gate(0x21,&keyboard_interrupt); |
开始,我们的键盘开始生效,但是现在中断处于禁用状态,所有中断都不好使。
我们注意到sti();
,最终对应一个同名的汇编指令sti
,表示允许中断。
然后中断才允许使用,键盘开始生效!
(3)块设备请求项初始化 blk_dev_init
读取硬盘中的数据到内存,是OS的基础功能。
读取硬盘需要有块设备驱动程序,如果以文件形式读取还要有一层文件系统。
块设备请求项的初始化,是读取 块设备 与 内存缓冲区 之间的桥梁。
1 | void blk_dev_init(void) { |
易知,对dev
和next
分别赋值为-1和NULL就是初始化,没有任何作用,下面我们看一下request
的结构体。
1 | /* |
对request
结构体的解释:
1 | struct request { |
所以上面的代码完成以下工作:
request[32]
数组后面读磁盘会具体阐释。
(4)控制台初始化 tty_init
我们通常使用 tty(Teletype) 来简称各种类型的终端设备。
1 | void main(void){ |
这个函数执行后,我们会具备键盘输入到显示器输出字符这个最常用的功能。
1 | //函数原型 |
rs_init()
1 | void rs_init(void) |
开启串口中断并设置对应的中断处理程序,串口目前PC机很少使用,不做讲解,知道rs_init
干了什么就可以。
con_init()
Linux中计算机显示器通常被称为控制台终端或控制台(Console)。
函数大体框架:
1 | //console.c文件 |
如此多的if-else
是为了应对不同的显示模式,从而分配不同的变量。
👋显示模式
字符如何显示在屏幕上?先看内存给图形的缓冲区。
往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。
1 | ;向屏幕输出'hello' |
con_init可以简化为:
1 |
|
现在我们可以通过键盘键入在显示屏显示,下面继续深入看gotoxy
函数,定位当前光标。
1 |
|
pos
根据行号和列号算出来内存指针,往pos
指向地址处写数据,相当于往控制台的
1 | _keyboard_interrupt: |
我们看最后的con_write
函数关键内联汇编代码做的事:
把键盘输入的字符写入pos指针指向的内存,相当于往屏幕输出,pos+=2
和x++
就是调整光标,所以调整光标的本质就是改变x,y,pos
这3个变量。其余的定位光标、滚屏、删除一行等操作在源代码中都有,不难但内容很多,理解基本原理即可。
到此,控制台初始化结束,tty_init()
结束。
✍终端与Shell区分
终端:终端是人与机器交互的接口,是连接到计算机上的一种带输入输出功能的外设。在计算机领域,终端是一种用来让用户输入数据至计算机,以及显示其计算结果的机器。随着个人计算机的普及,控制台 (Console) 与终端 (Terminal) 的概念已经逐渐模糊。在现代,我们的键盘与显示器既可以认为是控制台,也可以认为是普通的终端。当你在管理系统时,它们是控制台;当你在做一般的工作时(浏览网页、编辑文档等),它们就是终端。我们自己既是一般用户,也是系统管理员。因此,现在 控制台 与 终端 基本被看作是同义词。
Shell:提供用户界面的程序。接受用户输入的命令,然后帮我们与内核沟通,最后让内核完成我们的任务。这个提供用户界面的程序被叫做 Shell (壳层)。
比如说我们想要知道一个文件的内容,我们会在 Shell 中输入命令
cat file.txt
,然后 Shell 会帮我们运行cat
这个程序,cat
再去调用内核提供的open
等系统调用来获取文件的内容。虽然并不是 Shell 直接去与内核交互,但广义上可以认为是 Shell 提供了与内核交互的用户界面。
(5)时间初始化 time_init
1 |
|
获取时间主要是2个函数:
CMOS_READ
和BCD_TO_BIN
,所以我们弄懂这两个函数即可。
CMOS_READ
1 |
对一个端口先 out 写一下,再 in 读一下。
CPU 与外设交互的一个基本玩法:CPU 与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干什么,然后从另一些端口读值来接受外设的反馈。CPU要打交道的是CMOS外设,它是主板上一个可读写的RAM芯片,代码的操作就是按照CMOS手册要求的读写指定端口,知道是这么回事就行。
BCD_TO_BIN
BCD码值转BIN码值:CMOS上获取的年月日等都是BCD码值,要转换为二进制数值存储。
操作系统很多都是很繁琐地读硬件手册获取信息并使用。
(6)进程调度初始化 sched_init
1 | void sched_init(void) { |
分别对 TSS 和 LDT 初始化。
TSS(Task State Segment):任务状态段,占104字节,x86架构中保存任务信息的数据结构,用于任务管理 和 存储大部分寄存器的值。CPU中只有任务的概念(任务对应 OS 的线程概念),通过 tr
寄存器(只有1个 tr 寄存器)来确定 TSS 位置。
虽然 Intel 设计的初衷是用TSS来做任务切换,然而,在现代操作系统中(无论是 Windows 还是 Linux),都没有使用这种方式来执行任务切换,而是自己实现了线程。主要原因是这种切换速度非常慢,一条指令要消耗200多个时钟周期。
1 | struct tss_struct{ |
LDT(Local Descriptor Table):与GDT对应,CPU厂商为在硬件一级原生支持多任务而创建的表,按照设想,一个任务对应一个LDT。但现代操作系统中很少使用LDT。
1 | struct desc_struct { |
1 | struct task_struct { |
初始化一组 TSS 和 LDT ,作为未来进程 0 的 任务状态段 和 局部描述符表 的信息,往下看:
1 |
|
1 | void sched_init(void) { |
四行端口写代码,两行设置中断代码。
这次交互的外设是一个可编程定时器的芯片(比如 8253),这四行代码就开启了这个定时器,之后这个定时器变会持续的、以一定频率的向 CPU 发出中断信号。
设置两个中断:
第一个就是时钟中断,中断处理程序为 timer_interrupt,中断号 0x20。那么每次定时器向 CPU 发出中断后,便会执行这个函数。
第二个设置的中断叫系统调用 system_call,中断号 0x80,这个中断又是个非常重要的中断,所有 [用户态程序] 想要调用 [内核] 提供的方法,都需要基于这个系统调用来进行。
比如 Java 程序员写一个 read,底层会执行汇编指令 int 0x80,这就会触发系统调用这个中断,最终调用到 Linux 里的 sys_read 方法。
(7)缓冲区初始化 buffer_init
👋本部分涉及到大量链表和哈希表的知识,建议好好学习数据结构。
1 | buffer_init(buffer_memory_end); |
这个 buffer_memory_end
是很早之前就设置好的,是指定的缓冲区内存末端,对于 8MB 内存系统,缓冲区末端设置 2MB。
1 | struct buffer_head { |
1 | void buffer_init(long buffer_end) { |
对于数据结构 : 缓冲头 h 的所有 next 和 prev 指针都指向彼此时,就构成了一个双向链表。
1 | void buffer_init(long buffer_end) { |
free_list
指向了缓冲头双向链表的第一个结构,然后就可以顺着这个结构,从双向链表中遍历到任何一个缓冲头结构了,而通过缓冲头又可以找到这个缓冲头对应的缓冲块。
1 | void buffer_init(long buffer_end) { |
buffer_init
在buffer.c
文件中,这个c文件属于fs
即文件系统,是为后面的文件系统服务的,具体来讲:内核程序如果访问块设备中的数据,就要经过缓冲区来间接操作。
那么,怎么知道缓冲区已经有了要读取的块设备中的数据?
一种思路:遍历上面那个双向链表,但是效率太低
所以,创建了hash_table
的结构来方便快速查找,后面想读取某个块设备上的数据时,首先要搜索相应的缓冲块,getblk
函数:
1 |
|
计算hash值的散列函数表达式为:
1 | (b_dev ^ b_blocknr) % NR_HASH |
(8)硬盘初始化 hd_init
1 | void main(void) { |
hd_init()
在hd.c
文件中,属于块设备驱动程序(block driver)。
1 | void hd_init(void) |
对于硬件的初始化都是一个套路:
①往某些 IO 端口上读写一些数据,表示开启它
②再向中断向量表中添加一个中断,使得 CPU 能够响应这个硬件设备的动作
③最后初始化一些数据结构来管理
1 | void hd_init(void) { |
内核使用blk_dev[]
来管理块设备,每一个索引表示一个块设备。
1 | struct blk_dev_struct blk_dev[NR_BLK_DEV] = { |
每个块设备执行读写请求都有自己的函数实现,在上层看来都是一个统一函数 request_fn
即可,具体实现各有不同,对于硬盘来说,这个实现就是 do_hd_request
函数。
1 | void hd_init(void) { |
设置一个中断,中断号是 0x2E,中断处理函数是 hd_interrupt,也就是说硬盘发生读写时,硬盘会发出中断信号给 CPU,之后 CPU 进入中断处理程序,即:执行 hd_interrupt
函数。
1 | _hd_interrupt: |
操作系统就是一个靠中断驱动的死循环而已,如果不发生任何中断,操作系统会一直在一个死循环里等待。换句话说,让操作系统工作的唯一方式,就是触发中断。
1 | void hd_init(void) { |
往对应的 I/O端口写数据,作用是允许硬盘控制器发送中断请求信号,导致硬盘开启中断。
(9)打开中断 sti
本质上是将 eflags 寄存器里的中断允许标志位 IF 位 置 1。
直到此时,CPU才可以接收并处理中断信号,我们可以按键盘,硬盘可以读写,时钟开始计时,系统调用也开始生效,OS具备控制台交互、硬盘读写、进程调度、响应用户进程等能力。
0x05 进程0
(1)切换用户态
Linux 将操作系统特权级分为用户态与内核态两种,之前都处于内核态,现在要先转变为用户态,一旦转变为了用户态,那么之后的代码将一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态。
内核态切换为用户态:
1 | move_to_user_mode; |
内核态与用户态的本质-特权级
处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段;代码跳转只能同特权级,数据访问只能高特权级访问低特权级。
所以用户态和内核态之间的转换本质上就是特权级的转换,下面阐述特权级转换的方式:中断 和 中断返回
系统调用就是通过中断实现的,比如用户通过int 0x80
中断指令触发中断,CPU切换至内核态,执行中断处理程序,然后中断返回切换回用户态。实际上,没有中断也可以中断返回,很奇怪是不是?但Intel的CPU就是这样。
1 | #define move_to_user_mode() \ |
为什么之前进行了一共五次的压栈操作呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断才行。其实就把栈做做工作就好了,中断发生时,CPU 会自动帮我们做如下的压栈操作。而中断返回时,CPU 又会帮我们把压栈的这些值返序赋值给响应的寄存器。
此时内核态变为了用户态,顺便设置了栈段、代码段和数据段的基地址。
(2)fork创建进程
fork()系统调用用于创建子进程。Linux 中所有进程都是进程 0(任务 0)的子进程。
操作系统只有一个执行流,就是我们一直看过来的所有代码,就是进程 0,只不过我们并没有意识到它也是一个进程。调用完 fork 之后,现在又多了一个进程,叫做进程 1。
进程调度的本质,就是一会执行这个,再一会执行那个,如果依靠程序自己来进行中断不靠谱,所以要设计一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。
每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断。
这个特殊的程序,就是具体的进程调度函数。
首先,我们需要一个数据结构
1 | struct task_struct { |
这个结构要记录各个进程信息,比如:上次执行到哪里,下次要到哪一行运行等等。
上下文环境 tss
每个程序最终的本质就是执行指令。这个过程会涉及寄存器,内存和外设端口。不过寄存器一共就那么点,肯定做不到互不干扰,可能一个进程就把寄存器全用上了,那其他进程怎么办?
最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。linux0.11中,每个进程的结构 task_struct 里面,有一个叫 tss 的结构,存储的就是 CPU 这些寄存器的信息。
1 | struct task_struct { |
cr3是指向页目录表首地址的寄存器。指向不同的页目录表,整个页表结构就是完全不同的一套,那么线性地址到物理地址的映射关系就有能力做到不同。
运行时间信息 counter
剩余时间片:每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。linux0.11中,这个属性是 counter
。
1 | struct task_struct { |
用法也非常简单,就是每次中断都判断一下counter
是否到 0,如果到0就开始进程的调度。
1 | void do_timer(long cpl) { |
优先级 priority
上面有counter,但是counter初始化怎么办?这就是涉及到优先级的问题,也要有一个属性来记录这个值。counter值越大,每次轮到这个进程时,它在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。
我们把这个具体的数值叫做优先级。
1 | struct task_struct { |
进程状态 state
我们知道进程是代码运行的实体,而进程有可能是正在运行的,也可能是已经停止的,这就是进程的状态。
1 | struct task_struct { |
现在我们有了 上下文环境、时间片、优先级、进程状态这些基础,我们可以进行进程调度,下面讲一下进程调度全过程。
定时器与进程调度
定时器每间隔 10 ms向CPU发起中断信号,即 100 Hz。发起的中断叫时钟中断,其中断向量号被设置为了 0x20。
1 | schedule.c |
当时钟中断也就是 0x20中断来时候,CPU查找中断向量表中 0x20 处的函数地址,即中断处理函数,并跳过去执行。
1 | system_call.s |
1 | void schedule(void) { |
对schedule
进行简化:
1 | void schedule(void) { |
这个函数就做了三件事:
1.拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。
2.如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable
的进程)的 counter 重新赋值(counter = counter/2 + priority
),然后再次执行步骤 1。
3.最后拿到了一个进程号 next,调用了 switch_to(next)
这个方法,就切换到了这个进程去执行了。
1 | sched.h |
主要就干了一件事,就是 ljmp 到新进程的 tss 段处。CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。即:保存当前进程上下文,恢复下一个进程的上下文,跳过去。
进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to
函数的入参里。switch_to
这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处。接着,这个进程就舒舒服服地运行了起来,等待着下一次时钟中断的来临。
前面讲的进程调度相关数据结构和函数,都是为了下面讲解fork()
做铺垫。
正式进入 fork()
fork()系统调用用于创建子进程。Linux 中所有进程都是进程 0(任务 0)的子进程。
1 | static _inline _syscall0(int,fork) |
我们把宏定义展开,就相当于定义了一个函数
1 | int fork(void) { |
其中 0x80 号软中断是在 sched_init 中设置的
1 | set_system_gate(0x80, &system_call); |
👋软中断:执行中断指令产生
👋硬中断:由外设引发
1 | _system_call: |
eax 寄存器里的值是 2,所以这个就是在这个 sys_call_table 表里找下标 2 位置处的函数,然后跳转过去。
1 | fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, |
各种函数指针组成的一个数组,说白了就是个系统调用函数表,标号2处正好是sys_fork()
函数。
所以fork()
的流程就是:OS通过中断int 0x80
使用系统调用_system_call
,根据eax
和_sys_call_table
的值找系统调用函数表和对应位置的功能函数sys_fork()
。fork()
的本质就是:一个包装好的方法,通过int 0x80
, 赋值给 eax
,使用系统调用。
系统调用就这么个流程,后面很多函数都是这个流程。
那么,sys_fork()
函数都做了什么?
1.找到空闲的进程位置
2.复制进程
1 | _sys_fork: |
我们前面学过,存储进程的数据结构是一个 task[64] 数组,我们要先在这个数组中找一个空闲的位置,准备存一个新的进程的结构:
1 | struct task_struct{ |
1.先来找空闲位置:
1 | long last_pid = 0; |
2.找到空闲位置后,我们要复制进程,但是现在只有一个进程,就是数组中位置 0 处的 init_task.init,也就是零号进程。上面的方法运行后,last_pid是1,即:新进程被分配的pid就是1,加入到task[]数组索引位置就是1。
下面看一下如何把进程结构塞到 1 这个索引位置的task[]中。
函数去掉tss结构的复制和一些无关紧要的分支后如下:
1 | /*这个是主要的fork子程序,复制系统进程信息并设置必要的寄存器*/ |
👋👋这段函数是fork()的重难点👋👋
1 | struct task_struct p = (struct task_struct *) get_free_page(); |
遍历 mem_map[] 数组,找出值为 0 的项,就找到了空闲的一页内存,再把对应值设置为 1 ,表示这一页已经被使用,最后算出这个页的起始内存地址,返回,赋值给 p。
这时,一个进程的结构task_struct
就在内存中有了一块空间。
1 | task[nr] = p; |
进程 1 和进程 0 目前是完全复制的关系,但有一些值是需要个性化处理的,下面的代码就是把这些不一样的值覆盖掉。
1 | int copy_process(int nr, ...) { |
现在进程1在内存中已经占上一个坑了,下面要规划这个进程的内存。
1 | int copy_process(int nr, ...) { |
LDT赋值
32 位的 CPU 线性地址空间应为 4G,前 16M 内存线性地址空间已经与 16M 物理地址空间对应起来了。
我们目前已知的:进程0准备好了LDT的代码段和数据段,段基址都为0,段限长为640K。我们现在要做的:给进程1的代码段和数据段赋值。
1 | int copy_mem(int nr,struct task_struct * p) { |
段基址是取决于当前是几号进程,也就是 nr 的值。
1 | int copy_mem(int nr,struct task_struct * p) { |
也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 64M 的空间,并且紧挨着。
然后把 LDT 设置进入 LDT 表中。
1 | int copy_mem(int nr,struct task_struct * p) { |
经过以上的步骤,就通过分段的方式,将进程映射到了相互隔离的线性地址空间里,这就是段式管理。
页表拷贝
1 | int copy_mem(int nr,struct task_struct * p) { |
进程 0 有一个页目录表和四个页表,将线性地址空间的 0-16M 原封不动映射到了物理地址空间的 0-16M。
现在进程 0 的线性地址空间是 0 - 64M,进程 1 的线性地址空间是 64M - 128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0 最终被映射到的物理空间都是 0 - 64M,这样进程 1 才能顺利运行起来,不然就乱套了。最后,将新老进程的页表都变成只读状态,为后面写时复制的缺页中断做准备。
我们知道最后这段代码实现了上面的功能即可,代码中的计算非常绕。
把进程的基本信息、内存规划都完成后,设置状态为TASK_RUNNING
,表明该进程允许被调度。
(3)pause 死循环
当没有任何可运行的进程时,操作系统会悬停在这里。
1 | for(;;) pause(); |
对于任何其它的任务, pause() 将意味着我们必须等待收到一个信号才会返回就绪运行态,但任务 0(task0)是唯一的意外情况,因为任务 0 在任何空闲时间里都会被激活(当没有其它任务在运行时),因此对于任务 0 ,pause() 仅意味着我们返回来查看是否有其它任务可以运行,如果没有的话我们就回到这里,一直循环执行 pause() 。
0x06 Shell 登场 - init
1 | void init(void) { |
1.硬盘信息获取
1 | struct drive_info { char dummy[32]; } drive_info; |
0x90080
是setup.s
程序将硬盘 1 的参数信息放在了这里,包括:
·柱面数 Cylinders
·磁头数 Headers
·扇区数 Sectors
sys_setup
setup 是系统调用,通过中断最终调用 sys_setup 函数
1 | int sys_setup(void * BIOS) { |
(1)硬盘基本信息赋值
1 | int sys_setup(void * BIOS) { |
(2)硬盘分区表设置
1 | static struct hd_struct { |
最终效果,就是给 hd 数组的五项附上了值,表示硬盘的分区信息,每个分区用 start_sect 和 nr_sects,也就是开始扇区和总扇区数来记录。分区信息在硬盘 0x1BE 偏移处,拿到这部分信息即可。
1 | struct buffer_head *bh = bread(0x300, 0); |
(3)虚拟内存盘执行
1 | rd_load(); |
有虚拟内存盘时候才会执行。
(4)根目录
1 | mount_root(); |
加载根文件系统,有根文件系统后,OS才能从根开始找到所有存储在硬盘中的文件。
2.加载根文件系统
1 | void mount_root(void) { |
(1)硬盘中的文件系统格式
linux0.11的文件系统是 MINIX 文件系统,与标准 UNIX 文件系统基本相同。
·引导区:启动区,预留出来,保持格式统一。
·超级块:描述文件系统整体信息。
·inode位图和块位图:位图基本操作与使用。
·块:
普通文件
目录类型
每一个块的结构大小是1024字节,即 1KB。写好操作系统后,给一个硬盘做某种文件系统类型的格式化,比如在安装Arch Linux
时候,对硬盘格式化。这样就得到一个有文件系统的硬盘,然后OS可以成功启动。
(2)内存用于文件系统的数据结构
1 | struct file { |
super_block存在的意义:操作系统与一个设备以文件形式进行读写访问时,就需要把这个设备的超级块信息放在这里。通过这个超级块,可以掌握设备文件系统全局。
1 | void mount_root(void) { |
整体上就是把硬盘中文件系统的各个信息,搬到内存中。
此时setup函数也结束,调用链如下
1 | void main(void) { |
3.打开终端设备文件
1 | void init(void) { |
open函数触发 0x80 中断,转到 sys_open 系统调用函数。
结论:进程 1 通过 open 函数建立了与外设交互的能力,具体其实就是打开了 tty0 这个设备文件,并绑定了标准输入 0,标准输出 1 和 标准错误输出 2 这三个文件描述符。
1 | open.c |
文件描述符
文件描述符是一个用于表述指向文件引用的抽象化概念,在形式上是一个非负整数。 实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。 在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。在UNIX、Linux的系统调用中,大量的系统调用都是依赖于文件描述符。
文件描述符也有缺点:
在非UNIX/Linux 操作系统上(如Windows),无法基于这一概念进行编程——事实上,Windows下的文件描述符和信号量、互斥锁等内核对象一样都记作HANDLE。
由于文件描述符在形式上不过是个整数,当代码量增大时,会使编程者难以分清哪些整数意味着数据,哪些意味着文件描述符。因此,完成的代码可读性也就会变得很差,这一点一般通过使用名称有文字意义的 magic number 进行替换来解决。
摘自维基百科
1 | void init(void) { |
open函数返回文件描述符为0号,作为标准输入设备:stdin
复制文件描述符,产生文件描述符1号,作为标准输出设备:stdout
复制文件描述符,产生文件描述符2号,作为标准错误输出设备:stderr
dup函数:通过系统调用,到sys_dup
1 | // 从进程的 filp 中找到下一个空闲项,然后把要复制的文件描述符 fd 的信息,统统复制到这里。 |
进程 0 是不具备与外设交互的能力的,进程 1 刚刚创建的时候,是 fork 的进程 0,所以也不具备这样的能力,而通过 setup 加载根文件系统,open 打开 tty0 设备文件等代码,使得进程 1 具备了与外设交互的能力,同时也使得之后从进程 1 fork 出来的进程 2 也天生拥有和进程 1 同样的与外设交互的能力。具体可以体现为调用 printf 函数可以往屏幕上打印字符串了。
4.进程2的创建
1 | void init(void) { |
5.execve 加载并执行 shell 程序
1 |
|
execve() executes the program pointed to by filename. filename must be either a binary executable, or a script starting with a line of the form “#! interpreter [arg]”.
On success, execve() does not return, on error -1 is returned, and errno is set appropriately.
execve()在当前进程的上下文中加载并运行一个新的程序,会覆盖当前进程的地址空间,但没有创建新进程。新程序仍然有相同的pid,并且继承了调用 execve 时已经打开的所有文件描述符。
写 linux 脚本时候,通常可以看到:
1 | #!/bin/sh |
execve()判断前面两个字符是不是 #!,如果是的话,就走脚本文件的执行逻辑。
1 | execve("/bin/sh",argv_rc,envp_rc); |
进程 2 通过 execve 函数,将自己摇身一变成为 /bin/sh 程序,也就是 shell 程序开始执行,下一条 CPU 指令会执行到 /bin/sh
程序所在的内存起始位置处,即:/bin/sh
头部结构中 a_entry
所描述的地址。我们在 Linux 里执行一个程序,比如在命令行中 ./xxx
,其内部实现逻辑都是 fork + execve
这个原理。(shell就是这个原理)。
👋execve 是利用了中断、内存管理、文件系统、进程管理、可执行文件结构等多种底层知识结合起来的产物。先挖个坑
6.缺页中断
linux0.11中,每个进程是通过不同的局部描述符在线性地址空间中瓜分出不同的空间,一个进程占 64M。
缺页中断(Page fault)是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由 CPU 的 MMU 所发出的中断。
Page-Fault 在很多情况都会触发,具体是因为什么情况触发的,CPU 会帮我们保存在中断的出错码 Error Code 里。
触发缺页中断后,就会进入 Linux 0.11 源码中的 page_fault 方法,为了便于理解,选用 linux1.0 的代码,因为0.11的 page_fault 是用汇编写的,很不直观。
1 | void do_page_fault(..., unsigned long error_code) { |
当页表项 P 为0时,表示缺页,走do_no_page
逻辑,程序简化后如下:
1 | // memory.c |
本质上就是加载硬盘对应位置的数据,然后建立页表的过程。
execve 函数返回后,CPU 就跳转到 /bin/sh
程序的第一行开始执行,但由于跳转到的线性地址不存在,所以引发缺页中断,把硬盘里 /bin/sh
所需要的内容加载到了内存,此时缺页中断返回。
缺页中断返回后,CPU 会尝试跳转到对应的线性地址,此时该线性地址已有对应的页表进行映射,所以顺利地映射到了物理地址,即/bin/sh
的代码部分,可以开始执行shell
程序。
7.Shell 启动
Shell = fork + execve
Shell代码不在 linux0.11 中,所以用MIT的XV6源代码学习一下原理即可。代码简化后如下:
1 | // xv6-public sh.c |
在死循环里面,shell 就是不断读取(getcmd)我们用户输入的命令,创建一个新的进程(fork),在新进程里执行(runcmd)刚刚读取到的命令,最后等待(wait)进程退出,再次进入读取下一条命令的循环中。
现在shell就已经启动了。
shell 程序有个特点,就是如果标准输入为一个普通文件,比如 /etc/rc,那么文件读取后就会使得 shell 进程退出,如果是字符设备文件,比如由我们键盘输入的 /dev/tty0,则不会使 shell 进程退出。
到此为止,整个OS就像这个样子:
1 | // main.c |
OS的本质就是靠各种中断驱动的死循环。
到此为止,OS启动完毕,本身设置好中断处理程序,随时等待中断到来,同时运行了shell程序用来接受用户的命令进行交互。
后记
后续要继续对Shell、execve等底层内容进行深入理解,到时候会新开post。