0%

计算机是怎样跑起来的-linux0.11源代码学习

而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。

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个字节的最后两个字节是0x550xAA,表明这个设备可以用于启动;如果不是,表明设备不能用于启动,控制权于是被转交给”启动顺序”中的下一个设备。

👋对于硬盘而言,一个扇区可能的字节数为。大多数情况下取2,即一个扇区(sector)的大小为512字节。

在使用“主引导记录”(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.

Master Boot Record

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SETUPLEN = 4				! nr of setup-sectors
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = 0x9000 ! we move boot here - out of the way
SETUPSEG = 0x9020 ! setup starts here
SYSSEG = 0x1000 ! system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE ! where to stop loading

! ROOT_DEV: 0x000 - same type of floppy as boot.
! 0x301 - first partition on first drive etc
ROOT_DEV = 0x306

entry _start
_start:
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep
movw

2.1.2分区表(Partition Table)

分区表的长度只有64个字节,里面又分成四项,每项16个字节。所以,一个硬盘最多划分四个一级分区,又叫做“主分区”。一个主分区的扇区总数最多不超过

2.1.3主引导记录签名

系统在对硬盘做初始化时写入的一个标签,它是MBR不可或缺的一个组成部分。系统依靠这个签名来识别硬盘,如果硬盘的签名丢失,系统就会认为该硬盘没有初始化。0x550xAA就是签名。

现在BIOS已经把MBR的512字节都装入内存了,执行完BIOS整个代码后,要做的事情是:将控制权交给硬盘的某个分区。

2.2 linux_0.11源码讲解

1
2
3
4
5
6
entry _start
_start:
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax

硬盘第一扇区也就是MBR加载进入内存后,要将内存地址0x7c00处开始往后512字节的数据原封不动复制到内存的0x90000地址处。

1
2
3
4
mov cx,#256
sub si,si
sub di,di
rep movw ;重复执行movw指令256次

现在,操作系统最开头的代码,已经被挪到了 0x90000 这个位置了。

再往后是一个跳转指令。

1
2
3
4
5
6
7
jmpi go,0x9000  ;跳转到 0x90000 + go 这个内存地址处执行
go: ;go标签编译为机器码,是一个值,是go在文件内的偏移地址
mov ax,cs ;0x90000 + go刚好是这句代码所在的内存地址
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0xFF00

数据段寄存器 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
2
3
4
5
6
7
8
9
10
11
12
13
14
load_setup:
mov dx,#0x0000 ; drive 0, head 0
mov cx,#0x0002 ; sector 2, track 0
mov bx,#0x0200 ; address = 512, in 0x9000
mov ax,#0x0200+4 ; service 2, nr of sectors
int 0x13 ; read it
jnc ok_load_setup ; ok - continue
mov dx,#0x0000
mov ax,#0x0000 ; reset the diskette
int 0x13
jmp load_setup

ok_load_setup:
...

int 0x13:发起0x13号中断,上面的通用寄存器ax,bx,cx,dx都是这个中断指令的参数。

用途:将指定扇区的代码加载到内存的指定位置,它可以完成磁盘(包括硬盘和软盘)的复位, 读写, 校验, 定位, 诊断, 格式化等功能。int 0x13采用的就是CHS寻址方式(柱面Cylinders,磁头Headers,扇区Sectors),因此最大识能访问8 GB左右的硬盘。

上面代码的作用是:

将硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区

1
2
3
4
5
6
7
ok_load_setup:
...
mov ax,#0x1000
mov es,ax ; segment of 0x10000
call read_it
...
jmpi 0,0x9020

这段代码作用是把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处。至此,整个操作系统的全部代码,就已经全部从硬盘中,被搬迁到内存来了。

下面是最后一次折腾内存,我们这部分不用细究代码,知道最后的结果就可以。整个OS的编译过程,是通过Makefilebuild.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。段值存入段寄存器,而该值作为索引,用于在GDTGlobal 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
2
lidt  idt_48
lgdt gdt_48

现在的代码仍然是setup.s中的:

1
2
3
4
mov al,#0xD1        ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al

这段代码的意思是,打开 A20 地址线,进入保护模式。

下面有一大坨是专门对8259芯片的编程,微机原理讲过8259,去复习一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
; well, that went ok, I hope. Now we have to reprogram the interrupts :-(
; we put them right after the intel-reserved hardware interrupts, at
; int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
; messed this up with the original PC, and they haven't been able to
; rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,
; which is used for the internal hardware interrupts as well. We just
; have to reprogram the 8259's, and it isn't fun.

mov al,#0x11 ; initialization sequence
out #0x20,al ; send it to 8259A-1
.word 0x00eb,0x00eb ; jmp $+2, jmp $+2
out #0xA0,al ; and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 ; start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ; start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ; 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 ; 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ; 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ; mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al

对于上面这段程序的解读,请教了CXQ老师:

JMP SHORT $+2 指令的机器码是 00EBH 。
功能:跳转到顺序的下一条指令。
作用:清空指令预取队列、延时。
现代快速CPU初始化慢速接口时,两个动作之间要加延时,这个JMP指令比NOP指令的延时时间更长(因为会清空指令队列)。还有一个原因,清空指令队列,也可以防止CPU乱序执行指令,而写8259初始化命令字是有严格顺序的。

代码中的.word语句相当于直接用机器码写程序

接下来的一步,就是真正切换模式的一步了,从代码上看就两行。

1
2
3
mov ax,#0x0001  ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)

前两行,将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。

跳转指令 jmpi,后面的 8 表示 cs(代码段寄存器)的值,0 表示偏移地址。请注意,此时已经是保护模式了,之前也说过,保护模式下内存寻址方式变了,段寄存器里的值被当做段选择子

8 用二进制表示是:

1
0000,0000,0000,1000

可以知道描述符索引值是 1,也就是要去全局描述符表(gdt)中找第1项段描述符。

1
2
3
4
5
6
7
8
9
10
11
12
gdt:
.word 0,0,0,0 ; dummy

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9A00 ; code read/exec
.word 0x00C0 ; granularity=4096, 386

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9200 ; data read/write
.word 0x00C0 ; granularity=4096, 386

第 0 项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。

所以,这里取的就是这个代码段描述符,段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行。

0地址处就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码编译并链接在一起而成的,可以理解为操作系统的全部核心代码编译后的结果。启动模块最后还剩一个文件,它是正式进入c语言main.c前的汇编文件:header.s,文件比较短。

1
2
3
4
5
6
7
8
9
_pg_dir:	;表示页目录,后续设置分页机制时,页目录存放此处并覆盖原有代码
_startup_32:
mov eax,0x10
mov ds,ax
mov es,ax
mov fs,ax ;fs是标志段寄存器
mov gs,ax ;fs是全局段寄存器
;段寄存器赋值为 0x10,根据段描述符结构解析,表示这几个段寄存器的值为指向全局描述符表中的第二个段描述符,也就是数据段描述符。
lss esp,_stack_start ;让 ss:esp 这个栈顶指针指向了 _stack_start 这个标号的位置,0x9FF00

👋拓展:段寄存器的诞生

段寄存器的产生源于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
2
3
4
5
6
7
8
   call setup_idt ;设置 [中断描述符表]
call setup_gdt ;设置 [全局描述符表]
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

中断描述符表 idt 里面存储着一个个中断描述符,每一个中断号就对应着一个中断描述符(一一映射),而中断描述符里面存储着主要是中断程序的地址,这样一个中断号过来后,CPU 就会自动寻找相应的中断程序,然后去执行它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
setup_idt:
lea edx,ignore_int
mov eax,00080000h
mov ax,dx
mov dx,8E00h
lea edi,_idt
mov ecx,256
rp_sidt:
mov [edi],eax
mov [edi+4],edx
add edi,8
dec ecx
jne rp_sidt
lidt fword ptr idt_descr
ret

idt_descr:
dw 256*8-1
dd _idt

_idt:
DQ 256 dup(0)

这段程序的作用就是,设置了 256 个中断描述符,并且让每一个中断描述符中的中断程序例程都指向一个 ignore_int 的函数地址,这个是个默认的中断处理程序,之后会逐渐被各个具体的中断程序所覆盖。比如之后键盘模块会将自己的键盘中断处理程序,覆盖过去。现在,产生任何中断都会指向这个默认的函数 ignore_int,也就是说现在这个阶段你按键盘还不好使

1
2
3
4
5
6
7
;设置好的GDT
_gdt:
DQ 0000000000000000h ;/* NULL descriptor */
DQ 00c09a0000000fffh ;/* 16Mb */
DQ 00c0920000000fffh ;/* 16Mb */
DQ 0000000000000000h ;/* TEMPORARY - don't use */
DQ 252 dup(0)

原来已经设置过一遍了,这里又要重新设置一遍,因为原来设置的 gdt 是在 setup 程序中,之后这个地方要被缓冲区覆盖掉,所以这里重新设置在 head 程序中,这块内存区域之后就不会被其他程序用到并且覆盖了。

1
2
3
4
5
6
7
8
9
10
11
12
;开启分页机制,并且跳转到 main 函数
jmp after_page_tables
...
after_page_tables:
push 0
push 0
push 0
push L6
push _main ;main函数地址压栈
jmp setup_paging
L6:
jmp L6

开启分页机制,并跳转到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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
setup_paging:
mov ecx,1024*5
xor eax,eax
xor edi,edi
pushf
cld
rep stosd
mov eax,_pg_dir ;将页目录表放在内存地址的最开头

mov [eax],pg0+7
mov [eax+4],pg1+7
mov [eax+8],pg2+7
mov [eax+12],pg3+7
mov edi,pg3+4092
mov eax,00fff007h
std
L3: stosd
sub eax,00001000h
jge L3
popf
;设置页表具体数据
xor eax,eax
mov cr3,eax
;告诉CPU的cr3寄存器:0 地址处就是页目录表,再通过页目录表可以找到所有的页表
mov eax,cr0
or eax,80000000h
mov cr0,eax
ret

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void main(void) {
ROOT_DEV = ORIG_ROOT_DEV;//根设备号 -> ROOT_DEV
drive_info = DRIVE_INFO;
//此时中断仍然被禁止,做完必要的设置后开启
memory_end = (1<<20) + (EXT_MEM_K<<10);
// 内存大小=1Mb 字节+扩展内存(k)*1024 字节
memory_end &= 0xfffff000;
// 忽略不到 4Kb(1 页)的内存数
if (memory_end > 16*1024*1024)
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024)
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;

mem_init(main_memory_start,memory_end);
trap_init(); // 中断初始化
blk_dev_init(); // 块设备初始化
chr_dev_init(); // 字符设备初始化
tty_init();
time_init(); // 设置开机启动时间Îstartup_time
sched_init(); // 调度程序初始化(加载了任务 0 的 tr, ldtr)
buffer_init(buffer_memory_end);// 内存管理初始化(内存链表等)
hd_init(); // 硬盘初始化
floppy_init(); // 软驱初始化
sti(); // 所有初始化工作都做完了,开启中断
move_to_user_mode();//切换用户模式
if (!fork()) { /* we count on this going ok */
init();
}
for(;;) pause();
}

整个操作系统也会最终停留在最后一行死循环中,永不返回,直到关机。

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
2
3
4
5
void main(void){
ROOT_DEV = ORIG_ROOT_DEV; //系统根文件设备号
drive_info = DRIVE_INFO; //存储在内存0x90000处设备信息
···
}

下面分析下面的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main(void) {
...
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
if (memory_end > 16*1024*1024)
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024)
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;
...
}

上面这段代码做的事:计算3个变量

main_memory_start

buffer_memory_end

memory_end

这么多代码,判断的标准都是memory_end,其实就是根据内存大小设置不同边界值。假设内存共8M,那么memory_end就是,根据上面的逻辑算出buffer_memory_end为,最后把buffer_memory_end的值赋给main_memory_start,将两块内存衔接在一起,图如下:

2.各种初始化

(1)主内存区管理分配 mem_init

1
2
3
4
5
void main(void) {
...
mem_init(main_memory_start, memory_end);
...
}

mem_init函数源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define LOW_MEM 0x100000
#define PAGING_MEMORY (15*1024*1024)
//15MB
#define PAGING_PAGES (PAGING_MEMORY>>12)
//右移12位是把偏移地址弄走,处理后每个元素都代表一个4K内存
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
#define USED 100

static long HIGH_MEMORY = 0;
static unsigned char mem_map[PAGING_PAGES] = { 0, };

// start_mem = 2 * 1024 * 1024
// end_mem = 8 * 1024 * 1024
void mem_init(long start_mem, long end_mem)
{
int i;
HIGH_MEMORY = end_mem;
for (i=0 ; i<PAGING_PAGES ; i++)
mem_map[i] = USED;//内存被占用
i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;
while (end_mem-->0)
mem_map[i++]=0;
}

所谓的内存管理,就是准备了一张表,记录哪些内存被占用,哪些没有被占用。

1M 以下的内存这个数组干脆没有记录,这里的内存是无需管理的,或者换个说法是无权管理的,也就是没有权利申请和释放,因为这个区域是内核代码所在的地方,不能被“污染”。

1M 到 2M 这个区间是缓冲区,2M 是缓冲区的末端,缓冲区的开始在哪里之后再说,这些地方不是主内存区域,因此直接标记为 USED,产生的效果就是无法再被分配了。

2M 以上的空间是主内存区域,而主内存目前没有任何程序申请,所以初始化时统统都是零,未来等着应用程序去申请和释放这里的内存资源。

(2)中断初始化 trap_init

中断部分讲解以键盘为例:键盘的本质是I/O接口。按下键盘后会触发中断,CPU接收到中断后,根据中断号寻找由OS写好的键盘中断处理程序。键盘驱动才是真正意义上的中断:硬中断,是由硬件实实在在发起的中断。

我们为某一设备建立中断机制的标准流程:

​ 1.初始化8259

​ 2.建立IDT表(中断描述符表)

​ 3.编写中断服务程序

我们以 Linux 0.11 源码为例,发现进入内核的 main 函数后不久,有这样一行代码:

1
2
3
4
5
void main(void) {
...
trap_init();
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//这段代码作用:设置中断表
void trap_init(void) {
int i;
//set_xxx_gate最终的效果是:在中断描述符表中插入了一个中断描述符
set_trap_gate(0,&divide_error);
//设置0号中断,对应中断处理程序是divide_error(除法异常)
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
//5号边界中断
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
//17 到 48 号中断都批量设置为了 reserved 函数,这是暂时的
//后面各个硬件初始化时要重新设置好这些中断,把暂时的这个给覆盖掉
//此时留个印象就行
set_trap_gate(45,&irq13);
set_trap_gate(39,&parallel_interrupt);
}

键盘产生的中断的中断号是 0x21,此时这个中断号还仅仅对应着一个临时的中断处理程序 &reserved,我们接着往后看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void main(void) {
...
trap_init();
...
tty_init();
...
sti();
...
}

void tty_init(void) {
rs_init();
con_init();
}

void con_init(void) {
...
set_trap_gate(0x21,&keyboard_interrupt);
...
}

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
2
3
4
5
6
7
void blk_dev_init(void) {
int i;
for (i=0; i<32; i++) {
request[i].dev = -1;
request[i].next = NULL;
}
}

易知,对devnext分别赋值为-1和NULL就是初始化,没有任何作用,下面我们看一下request的结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
* Ok, this is an expanded form so that we can use the same
* request for paging requests when that is implemented. In
* paging, 'bh' is NULL, and 'waiting' is used to wait for
* read/write completion.
*/
struct request {
int dev; /* -1 if no request */
int cmd; /* READ or WRITE */
int errors;
unsigned long sector;
unsigned long nr_sectors;
char * buffer;
struct task_struct * waiting;
struct buffer_head * bh;
struct request * next;
};

request结构体的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct request {
int dev; //设备号,-1表示空闲
int cmd; //命令
int errors; //错误次数
unsigned long sector; //起始磁盘扇区
unsigned long nr_sectors; //扇区数量
char * buffer; //数据缓冲区
struct task_struct * waiting; //进程 或 哪个进程发起请求
struct buffer_head * bh; //缓冲区头指针
struct request * next; //指向下一个请求项
};
//举例简单理解
request:
什么设备:硬盘
什么操作:读
从哪里读:2-5扇区
读到哪里:内存0x90000

所以上面的代码完成以下工作:

request[32]数组后面读磁盘会具体阐释。

(4)控制台初始化 tty_init

我们通常使用 tty(Teletype) 来简称各种类型的终端设备。

1
2
3
4
5
void main(void){
...
tty_init();
...
}

这个函数执行后,我们会具备键盘输入到显示器输出字符这个最常用的功能。

1
2
3
4
5
6
//函数原型
void tty_init(void)
{
rs_init();
con_init();
}

rs_init()

1
2
3
4
5
6
7
8
void rs_init(void)
{
set_intr_gate(0x24,rs1_interrupt);
set_intr_gate(0x23,rs2_interrupt);
init(tty_table[1].read_q.data);
init(tty_table[2].read_q.data);
outb(inb_p(0x21)&0xE7,0x21);
}

开启串口中断并设置对应的中断处理程序,串口目前PC机很少使用,不做讲解,知道rs_init干了什么就可以。

con_init()

Linux中计算机显示器通常被称为控制台终端或控制台(Console)。

函数大体框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//console.c文件
//根据系统初始化获得的系统信息
//设置有关屏幕的一些基本参数值
//用于con_write()函数的操作
#define ORIG_VIDEO_MODE ((*(unsigned short *)0x90006) & 0xff) //显示模式
#define ORIG_VIDEO_EGA_BX (*(unsigned short *)0x9000a)//显示内存大小和色彩模式
/*
* void con_init(void);
*
* This routine initalizes console interrupts, and does nothing
* else. If you want the screen to clear, call tty_write with
* the appropriate escape-sequece.
*
* Reads the information preserved by setup.s to determine the current display
* type and sets everything accordingly.
*/
void con_init(void) {
...
if (ORIG_VIDEO_MODE == 7) {
...
if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
else {...}
} else {
...
if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
else {...}
}
...
}

如此多的if-else是为了应对不同的显示模式,从而分配不同的变量。

👋显示模式

字符如何显示在屏幕上?先看内存给图形的缓冲区。

往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。

1
2
3
4
5
6
7
;向屏幕输出'hello'

mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'

con_init可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define ORIG_X          (*(unsigned char *)0x90000) //光标位置
#define ORIG_Y (*(unsigned char *)0x90001)
void con_init(void) {
register unsigned char a;
// 第一部分 获取显示模式相关信息
video_num_columns = (((*(unsigned short *)0x90006) & 0xff00) >> 8);
video_size_row = video_num_columns * 2;
video_num_lines = 25;
video_page = (*(unsigned short *)0x90004);
video_erase_char = 0x0720;
// 第二部分 显存映射的内存区域 ,我们现在假设是文本模式
//所以内存是从0xB800到0xBA00
video_mem_start = 0xb8000;
video_port_reg = 0x3d4;
video_port_val = 0x3d5;
video_mem_end = 0xba000;
// 第三部分 滚动屏幕操作时的信息
//设置顶行和底行
origin = video_mem_start;
scr_end = video_mem_start + video_num_lines * video_size_row;
top = 0;
bottom = video_num_lines;
// 第四部分 定位光标并开启键盘中断
//光标位置取内存地址0x90000处的数据
gotoxy(ORIG_X, ORIG_Y); //x表示列,y表示行
set_trap_gate(0x21,&keyboard_interrupt);
outb_p(inb_p(0x21)&0xfd,0x21);
a=inb_p(0x61);
outb_p(a|0x80,0x61);
outb(a,0x61);
}

现在我们可以通过键盘键入在显示屏显示,下面继续深入看gotoxy函数,定位当前光标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define ORIG_X          (*(unsigned char *)0x90000)
#define ORIG_Y (*(unsigned char *)0x90001)
void con_init(void) {
...
// 第四部分 定位光标并开启键盘中断
gotoxy(ORIG_X, ORIG_Y);
...
}

static inline void gotoxy(unsigned int new_x,unsigned int new_y) {
...
x = new_x;
y = new_y;
pos = origin + y*video_size_row + (x<<1);
}

pos根据行号和列号算出来内存指针,往pos指向地址处写数据,相当于往控制台的写入字符,所以按下键盘后,触发键盘中断后的程序调用链是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
_keyboard_interrupt:
...
call _do_tty_interrupt
...

void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty) {
...
tty->write(tty);
...
}

// 控制台时 tty 的 write 为 con_write 函数
void con_write(struct tty_struct * tty) {
...
__asm__("movb _attr,%%ah\n\t"
"movw %%ax,%1\n\t"
::"a" (c),"m" (*(short *)pos)
:"ax");
pos += 2;
x++;
...
}

我们看最后的con_write函数关键内联汇编代码做的事:

把键盘输入的字符写入pos指针指向的内存,相当于往屏幕输出,pos+=2x++就是调整光标,所以调整光标的本质就是改变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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#define CMOS_READ(addr) ({ \
outb_p(0x80|addr,0x70); \
inb_p(0x71); \
})

#define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10)

static void time_init(void) {
struct tm time;
do {
time.tm_sec = CMOS_READ(0);
time.tm_min = CMOS_READ(2);
time.tm_hour = CMOS_READ(4);
time.tm_mday = CMOS_READ(7);
time.tm_mon = CMOS_READ(8);
time.tm_year = CMOS_READ(9);
} while (time.tm_sec != CMOS_READ(0));
BCD_TO_BIN(time.tm_sec);
BCD_TO_BIN(time.tm_min);
BCD_TO_BIN(time.tm_hour);
BCD_TO_BIN(time.tm_mday);
BCD_TO_BIN(time.tm_mon);
BCD_TO_BIN(time.tm_year);
time.tm_mon--;
startup_time = kernel_mktime(&time);
//计算从 1970 年 1 月 1 日 0 时起到开机当时经过的秒数
//存储在 startup_time变量中
}

获取时间主要是2个函数:

CMOS_READBCD_TO_BIN,所以我们弄懂这两个函数即可。

CMOS_READ

1
2
3
4
#define CMOS_READ(addr) ({ \
outb_p(0x80|addr,0x70); \
inb_p(0x71); \
})

对一个端口先 out 写一下,再 in 读一下。

CPU 与外设交互的一个基本玩法:CPU 与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干什么,然后从另一些端口读值来接受外设的反馈。CPU要打交道的是CMOS外设,它是主板上一个可读写的RAM芯片,代码的操作就是按照CMOS手册要求的读写指定端口,知道是这么回事就行。

BCD_TO_BIN

BCD码值转BIN码值:CMOS上获取的年月日等都是BCD码值,要转换为二进制数值存储。

操作系统很多都是很繁琐地读硬件手册获取信息并使用。

(6)进程调度初始化 sched_init

1
2
3
4
5
void sched_init(void) {
set_tss_desc(gdt+4, &(init_task.task.tss));
set_ldt_desc(gdt+5, &(init_task.task.ldt));
...
}

分别对 TSS 和 LDT 初始化。

TSS(Task State Segment):任务状态段,占104字节,x86架构中保存任务信息的数据结构,用于任务管理 和 存储大部分寄存器的值。CPU中只有任务的概念(任务对应 OS 的线程概念),通过 tr 寄存器(只有1个 tr 寄存器)来确定 TSS 位置。

虽然 Intel 设计的初衷是用TSS来做任务切换,然而,在现代操作系统中(无论是 Windows 还是 Linux),都没有使用这种方式来执行任务切换,而是自己实现了线程。主要原因是这种切换速度非常慢,一条指令要消耗200多个时钟周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct tss_struct{
long back_link;
long esp0;
long ss0;
long esp1;
long ss1;
long esp2;
long ss2;
long cr3;
long eip;
long eflags;
long eax, ecx, edx, ebx;
long esp;
long ebp;
long esi;
long edi;
long es;
long cs;
long ss;
long ds;
long fs;
long gs;
long ldt;
long trace_bitmap;
struct i387_struct i387;
};

LDT(Local Descriptor Table):与GDT对应,CPU厂商为在硬件一级原生支持多任务而创建的表,按照设想,一个任务对应一个LDT。但现代操作系统中很少使用LDT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct desc_struct {
unsigned long a,b;
}

struct task_struct * task[64] = {&(init_task.task), };
//给一个长度为 64 ,结构为 task_struct 的数组 task 附上初始值
//代表每一个进程的信息 ,管理全部进程的结构。

void sched_init(void) {
...
int i;
struct desc_struct * p;
p = gdt+6;
//给 gdt 剩下的位置填充上 0,也就是把剩下留给 TSS 和 LDT 的描述符都先附上空值。
//以后每创建一个新进程,就会在后面添加一组 TSS 和 LDT 表示这个进程的任务状态段以及局部描述符表信息。
for(i=1;i<64;i++) {
task[i] = NULL;
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};

初始化一组 TSS 和 LDT ,作为未来进程 0 的 任务状态段 和 局部描述符表 的信息,往下看:

1
2
3
4
5
6
7
8
9
#define ltr(n) __asm__("ltr %%ax"::"a" (_TSS(n)))
#define lldt(n) __asm__("lldt %%ax"::"a" (_LDT(n)))

void sched_init(void) {
...
ltr(0);//给 tr 寄存器赋值,告诉 CPU 任务状态段 TSS 在内存的位置
lldt(0);//给 ldt 寄存器赋值,告诉 CPU 局部描述符 LDT 在内存的位置
...
}
1
2
3
4
5
6
7
8
9
10
void sched_init(void) {
...
outb_p(0x36,0x43); /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);
...
}

四行端口写代码,两行设置中断代码。

这次交互的外设是一个可编程定时器的芯片(比如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct buffer_head {
char *b_data; // 指向缓存的数据块的指针
unsigned long b_blocknr; // 逻辑块号
unsigned short b_dev; // 设备号
unsigned char b_uptodate; // 缓存中的数据是否是最新的
unsigned char b_dirt; // 缓存中数据是否为脏数据
unsigned char b_count; // 这个缓存块被引用的次数
unsigned char b_lock; // b_lock表示这个缓存块是否被加锁
struct task_struct *b_wait; // 等待在这个缓存块上的进程
struct buffer_head *b_prev; // 指向缓存中相同hash值的下一个缓存块
struct buffer_head *b_next; // 指向缓存中相同hash值的上一个缓存块
struct buffer_head *b_prev_free; // 缓存块空闲链表中指向下一个缓存块
struct buffer_head *b_next_free; // 缓存块空闲链表中指向上一个缓存块
};

extern int end;
//end不是OS写的,而是由链接器链接整个程序时的一个外部变量
struct buffer_head * start_buffer = (struct buffer_head *) &end; //缓冲区开始位置

void buffer_init(long buffer_end) {
struct buffer_head * h = start_buffer;
void * b = (void *) buffer_end;
while ( (b -= 1024) >= ((void *) (h+1)) ) {
h->b_dev = 0; // 使用该缓冲区的设备号
h->b_dirt = 0; // 缓冲区修改标志
h->b_count = 0; // 该缓冲区引用计数
h->b_lock = 0; // 缓冲区锁定标志
h->b_uptodate = 0; // 缓冲区更新标志(数据有效标志)
h->b_wait = NULL; // 指向等待该缓冲区解锁的进程
h->b_next = NULL; // 指向具有相同hash值的下一个缓冲头
h->b_prev = NULL; // 指向具有相同hash值的前一个缓冲头
h->b_data = (char *) b;
h->b_prev_free = h-1;
h->b_next_free = h+1;
h++;
}
h--; // 让 h 指向最后一个有效缓冲头
free_list = start_buffer; // 让空闲链表头指向头一个缓冲区头
free_list->b_prev_free = h;
h->b_next_free = free_list;//h的下一项指针指向第一项,从而形成一个环链
for (int i=0;i<307;i++)
hash_table[i]=NULL;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void buffer_init(long buffer_end) {
struct buffer_head * h = start_buffer;
void * b = (void *) buffer_end;
// b 表示缓冲块 ,h 表示缓冲头 , h+1 表示缓冲头末端
//为了保证有足够长度的内存来存储一个缓冲头结构,需要 b 所指向的内存块
//地址 >= h 缓冲头的末端,也即要>=h+1。
while ( (b -= 1024) >= ((void *) (h+1)) ) {
// 1024 是每页的值
...
h->b_data = (char *) b;// 指向对应缓冲区数据块(1024 字节)
h->b_prev_free = h-1; // 指向链表中前一项
h->b_next_free = h+1; // 指向链表中下一项
h++; // h 指向下一新缓冲头位置
}
...
}

对于数据结构 : 缓冲头 h 的所有 next 和 prev 指针都指向彼此时,就构成了一个双向链表。

1
2
3
4
5
6
7
void buffer_init(long buffer_end) {
...
free_list = start_buffer;
free_list->b_prev_free = h;
h->b_next_free = free_list;
...
}

free_list 指向了缓冲头双向链表的第一个结构,然后就可以顺着这个结构,从双向链表中遍历到任何一个缓冲头结构了,而通过缓冲头又可以找到这个缓冲头对应的缓冲块

1
2
3
4
5
6
7
void buffer_init(long buffer_end) {
...
for (i=0;i<307;i++)
hash_table[i]=NULL;
}
// 初始化 hash 表(哈希表、散列表),置表中所有的指针为 NULL
// 此段代码可以更有助于 管理 和 查找

buffer_initbuffer.c文件中,这个c文件属于fs文件系统,是为后面的文件系统服务的,具体来讲:内核程序如果访问块设备中的数据,就要经过缓冲区来间接操作。

那么,怎么知道缓冲区已经有了要读取的块设备中的数据?

一种思路:遍历上面那个双向链表,但是效率太低

所以,创建了hash_table的结构来方便快速查找,后面想读取某个块设备上的数据时,首先要搜索相应的缓冲块,getblk函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define _hashfn(dev,block) (((unsigned)(dev^block))%307)
#define hash(dev,block) hash_table[_hashfn(dev,block)]

// 搜索合适的缓冲块
struct buffer_head * getblk(int dev,int block) {
...
struct buffer_head bh = get_hash_table(dev,block);
...
}

struct buffer_head * get_hash_table(int dev, int block) {
...
find_buffer(dev,block);
...
}

static struct buffer_head * find_buffer(int dev, int block) {
...
hash(dev,block);
...
}

计算hash值的散列函数表达式为:

1
2
(b_dev ^ b_blocknr) % NR_HASH
// 其中NR_HASH是散列表的条目总数

(8)硬盘初始化 hd_init

1
2
3
4
5
6
void main(void) {
...
hd_init();
floppy_init(); // 软盘初始化,但是2011年软盘就被淘汰了,没必要学
...
}

hd_init()hd.c文件中,属于块设备驱动程序(block driver)。

1
2
3
4
5
6
7
8
9
10
11
12
void hd_init(void) 
{
blk_dev[3].request_fn = DEVICE_REQUEST;
// do_hd_request()。
set_intr_gate(0x2E,&hd_interrupt);
//设置硬盘中断向量 int 0x2E(46)。
// hd_interrupt 在(kernel/system_call.s,221)。
outb_p(inb_p(0x21)&0xfb,0x21);
//复位接联的主 8259A int2 的屏蔽位,允许从片发出中断请求信号。
outb(inb_p(0xA1)&0xbf,0xA1);
//复位硬盘的中断请求屏蔽位(在从片上),允许硬盘控制器发送中断请求信号。
}

对于硬件的初始化都是一个套路:

①往某些 IO 端口上读写一些数据,表示开启它

②再向中断向量表中添加一个中断,使得 CPU 能够响应这个硬件设备的动作

③最后初始化一些数据结构来管理

1
2
3
4
void hd_init(void) {
blk_dev[3].request_fn = do_hd_request;
...
}

内核使用blk_dev[]来管理块设备,每一个索引表示一个块设备。

1
2
3
4
5
6
7
8
9
struct blk_dev_struct blk_dev[NR_BLK_DEV] = {
{ NULL, NULL }, /* no_dev */
{ NULL, NULL }, /* dev mem */
{ NULL, NULL }, /* dev fd */ //<==3号索引
{ NULL, NULL }, /* dev hd */
{ NULL, NULL }, /* dev ttyx */
{ NULL, NULL }, /* dev tty */
{ NULL, NULL } /* dev lp */
};

每个块设备执行读写请求都有自己的函数实现,在上层看来都是一个统一函数 request_fn 即可,具体实现各有不同,对于硬盘来说,这个实现就是 do_hd_request 函数。

1
2
3
4
5
void hd_init(void) {
...
set_intr_gate(0x2E,&hd_interrupt);
...
}

设置一个中断,中断号是 0x2E,中断处理函数是 hd_interrupt,也就是说硬盘发生读写时,硬盘会发出中断信号给 CPU,之后 CPU 进入中断处理程序,即:执行 hd_interrupt 函数。

1
2
3
4
5
6
7
8
9
10
11
_hd_interrupt:
...
xchgl _do_hd,%edx
...

// 如果是读盘操作,这个 do_hd 是 read_intr
static void read_intr(void) {
...
do_hd_request();
...
}

操作系统就是一个靠中断驱动的死循环而已,如果不发生任何中断,操作系统会一直在一个死循环里等待。换句话说,让操作系统工作的唯一方式,就是触发中断。

1
2
3
4
5
void hd_init(void) {
...
outb_p(inb_p(0x21)&0xfb,0x21);
outb(inb_p(0xA1)&0xbf,0xA1);
}

往对应的 I/O端口写数据,作用是允许硬盘控制器发送中断请求信号,导致硬盘开启中断。

(9)打开中断 sti

本质上是将 eflags 寄存器里的中断允许标志位 IF 位 置 1

直到此时,CPU才可以接收并处理中断信号,我们可以按键盘,硬盘可以读写,时钟开始计时,系统调用也开始生效,OS具备控制台交互、硬盘读写、进程调度、响应用户进程等能力。

0x05 进程0

(1)切换用户态

Linux 将操作系统特权级分为用户态与内核态两种,之前都处于内核态,现在要先转变为用户态,一旦转变为了用户态,那么之后的代码将一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态。

内核态切换为用户态:

1
move_to_user_mode;

内核态与用户态的本质-特权级

处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段;代码跳转只能同特权级,数据访问只能高特权级访问低特权级。

所以用户态和内核态之间的转换本质上就是特权级的转换,下面阐述特权级转换的方式:中断 和 中断返回

系统调用就是通过中断实现的,比如用户通过int 0x80中断指令触发中断,CPU切换至内核态,执行中断处理程序,然后中断返回切换回用户态。实际上,没有中断也可以中断返回,很奇怪是不是?但Intel的CPU就是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \ ;给 ss 赋值
_asm push eax \
_asm pushfd \
_asm push 0000000fh \ ;给 cs 赋值
_asm push offset l1 \ ;设置标号为 l1 的位置
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}

为什么之前进行了一共五次的压栈操作呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断才行。其实就把栈做做工作就好了,中断发生时,CPU 会自动帮我们做如下的压栈操作。而中断返回时,CPU 又会帮我们把压栈的这些值返序赋值给响应的寄存器。

此时内核态变为了用户态,顺便设置了栈段、代码段和数据段的基地址。

(2)fork创建进程

fork()系统调用用于创建子进程。Linux 中所有进程都是进程 0(任务 0)的子进程。

操作系统只有一个执行流,就是我们一直看过来的所有代码,就是进程 0,只不过我们并没有意识到它也是一个进程。调用完 fork 之后,现在又多了一个进程,叫做进程 1。

进程调度的本质,就是一会执行这个,再一会执行那个,如果依靠程序自己来进行中断不靠谱,所以要设计一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。

每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断

这个特殊的程序,就是具体的进程调度函数

首先,我们需要一个数据结构

1
2
3
struct task_struct {
?
}

这个结构要记录各个进程信息,比如:上次执行到哪里,下次要到哪一行运行等等。

上下文环境 tss

每个程序最终的本质就是执行指令。这个过程会涉及寄存器内存外设端口。不过寄存器一共就那么点,肯定做不到互不干扰,可能一个进程就把寄存器全用上了,那其他进程怎么办?

最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。linux0.11中,每个进程的结构 task_struct 里面,有一个叫 tss 的结构,存储的就是 CPU 这些寄存器的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct task_struct {
...
struct tss_struct tss;
}

struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};

cr3是指向页目录表首地址的寄存器。指向不同的页目录表,整个页表结构就是完全不同的一套,那么线性地址到物理地址的映射关系就有能力做到不同。

运行时间信息 counter

剩余时间片:每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。linux0.11中,这个属性是 counter

1
2
3
4
5
6
struct task_struct {
...
long counter;
...
struct tss_struct tss;
}

用法也非常简单,就是每次中断都判断一下counter是否到 0,如果到0就开始进程的调度。

1
2
3
4
5
6
7
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}

优先级 priority

上面有counter,但是counter初始化怎么办?这就是涉及到优先级的问题,也要有一个属性来记录这个值。counter值越大,每次轮到这个进程时,它在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。

我们把这个具体的数值叫做优先级

1
2
3
4
5
6
7
struct task_struct {
...
long counter;
long priority;
...
struct tss_struct tss;
}

进程状态 state

我们知道进程是代码运行的实体,而进程有可能是正在运行的,也可能是已经停止的,这就是进程的状态。

1
2
3
4
5
6
7
8
9
10
11
12
struct task_struct {
long state;
long counter;
long priority;
...
struct tss_struct tss;
}
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4

现在我们有了 上下文环境、时间片、优先级、进程状态这些基础,我们可以进行进程调度,下面讲一下进程调度全过程。

定时器与进程调度

定时器每间隔 10 ms向CPU发起中断信号,即 100 Hz。发起的中断叫时钟中断,其中断向量号被设置为了 0x20

1
2
3
schedule.c

#define HZ 100

当时钟中断也就是 0x20中断来时候,CPU查找中断向量表中 0x20 处的函数地址,即中断处理函数,并跳过去执行。

1
2
3
4
5
6
7
8
9
10
system_call.s

_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void schedule(void) {
int i, next, c;
struct task_struct ** p;
...
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}

schedule进行简化:

1
2
3
4
5
void schedule(void) {
int next = get_max_counter_and_runnable_thread();
refresh_all_thread_counter();
switch_to(next);
}

这个函数就做了三件事:

1.拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。

2.如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable 的进程)的 counter 重新赋值(counter = counter/2 + priority),然后再次执行步骤 1。

3.最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sched.h

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,_current\n\t" \
"ljmp %0\n\t" \
"cmpl %%ecx,_last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}

主要就干了一件事,就是 ljmp 到新进程的 tss 段处。CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。即:保存当前进程上下文,恢复下一个进程的上下文,跳过去

进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里。switch_to 这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处。接着,这个进程就舒舒服服地运行了起来,等待着下一次时钟中断的来临。

前面讲的进程调度相关数据结构和函数,都是为了下面讲解fork()做铺垫。

正式进入 fork()

fork()系统调用用于创建子进程。Linux 中所有进程都是进程 0(任务 0)的子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static _inline _syscall0(int,fork)

#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}

我们把宏定义展开,就相当于定义了一个函数

1
2
3
4
5
6
7
8
9
10
11
12
int fork(void) {
volatile long __res;
_asm {
_asm mov eax,__NR_fork ;__NR_fork的值是2
_asm int 80h
_asm mov __res,eax
}
if (__res >= 0)
return (void) __res;
errno = -__res;
return -1;
}

其中 0x80 号软中断是在 sched_init 中设置的

1
set_system_gate(0x80, &system_call);

👋软中断:执行中断指令产生

👋硬中断:由外设引发

1
2
3
4
_system_call:
...
call [_sys_call_table + eax*4]
...

eax 寄存器里的值是 2,所以这个就是在这个 sys_call_table 表里找下标 2 位置处的函数,然后跳转过去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

各种函数指针组成的一个数组,说白了就是个系统调用函数表,标号2处正好是sys_fork()函数。

所以fork()的流程就是:OS通过中断int 0x80使用系统调用_system_call,根据eax_sys_call_table的值找系统调用函数表和对应位置的功能函数sys_fork()fork()的本质就是:一个包装好的方法,通过int 0x80, 赋值给 eax ,使用系统调用。

系统调用就这么个流程,后面很多函数都是这个流程。

那么,sys_fork()函数都做了什么?

1.找到空闲的进程位置

2.复制进程

1
2
3
4
5
6
7
8
9
10
11
12
_sys_fork:
call _find_empty_process
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
1: ret

我们前面学过,存储进程的数据结构是一个 task[64] 数组,我们要先在这个数组中找一个空闲的位置,准备存一个新的进程的结构:

1
2
3
4
5
6
7
8
struct task_struct{
long state;
long counter;
long priority;
...
struct tss_struct tss;
};
// long: 8Byte x 8Bit = 64Bit

1.先来找空闲位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
long last_pid = 0;

int find_empty_process(void) {
int i;
repeat:
if ((++last_pid)<0) last_pid=1; //保护作用
for(i=0 ; i<64 ; i++) //如果task[i]被进程占用,则继续寻找,直到找到一个pid号没有被任何进程使用为止。
if (task[i] && task[i]->pid == last_pid) goto repeat; // :)?Linus用的是goto语句
for(i=1 ; i<64; i++) //找到了空闲项
if (!task[i])
return i;
return -EAGAIN;
}
// 最后返回数组索引,表示找到了空闲项,在对应位置塞一个进程

2.找到空闲位置后,我们要复制进程,但是现在只有一个进程,就是数组中位置 0 处的 init_task.init,也就是零号进程。上面的方法运行后,last_pid是1,即:新进程被分配的pid就是1,加入到task[]数组索引位置就是1。

下面看一下如何把进程结构塞到 1 这个索引位置的task[]中。

函数去掉tss结构的复制和一些无关紧要的分支后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*这个是主要的fork子程序,复制系统进程信息并设置必要的寄存器*/
/*还整个复制数据段*/
int copy_process(int nr, ...) {
struct task_struct p = (struct task_struct *) get_free_page(); // 为新任务数据结构分配内存
task[nr] = p;// nr为任务号,由上面的find_empty_process返回
*p = *current;/* 注意!这样做不会复制超级用户的堆栈 */

p->state = TASK_UNINTERRUPTIBLE; // 将新进程的状态先置为不可中断等待状态
p->pid = last_pid; // 新进程号。由前面调用 find_empty_process()得到
p->counter = p->priority;
..
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
...
copy_mem(nr,p);
...
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING;
return last_pid;
}

👋👋这段函数是fork()的重难点👋👋

1
struct task_struct p = (struct task_struct *) get_free_page();

遍历 mem_map[] 数组,找出值为 0 的项,就找到了空闲的一页内存,再把对应值设置为 1 ,表示这一页已经被使用,最后算出这个页的起始内存地址,返回,赋值给 p。

这时,一个进程的结构task_struct就在内存中有了一块空间。

1
2
3
task[nr] = p;
*p = *current;
/*把当前进程,即0号进程的 task_struct 全部值赋值给进程p*/

进程 1 和进程 0 目前是完全复制的关系,但有一些值是需要个性化处理的,下面的代码就是把这些不一样的值覆盖掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int copy_process(int nr, ...) {
...
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->counter = p->priority;
..
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
...
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
...
}

现在进程1在内存中已经占上一个坑了,下面要规划这个进程的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int copy_process(int nr, ...) {
...
copy_mem(nr,p);
...
}

int copy_mem(int nr,struct task_struct * p) {
// 局部描述符表 LDT 赋值
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;
code_limit = get_limit(0x0f);
data_limit = get_limit(0x17);
new_code_base = nr * 0x4000000;
new_data_base = nr * 0x4000000;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
// 拷贝页表
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
copy_page_tables(old_data_base,new_data_base,data_limit);
return 0;
}
LDT赋值

32 位的 CPU 线性地址空间应为 4G,前 16M 内存线性地址空间已经与 16M 物理地址空间对应起来了。

我们目前已知的:进程0准备好了LDT的代码段和数据段,段基址都为0,段限长为640K。我们现在要做的:给进程1的代码段和数据段赋值。

1
2
3
4
5
6
int copy_mem(int nr,struct task_struct * p) {
...
code_limit = get_limit(0x0f);
data_limit = get_limit(0x17);
...
}

段基址是取决于当前是几号进程,也就是 nr 的值。

1
2
3
4
5
6
7
int copy_mem(int nr,struct task_struct * p) {
...
new_code_base = nr * 0x4000000;
new_data_base = nr * 0x4000000;
...
}
// 0x4000000就是 64M

也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 64M 的空间,并且紧挨着。

然后把 LDT 设置进入 LDT 表中。

1
2
3
4
5
6
int copy_mem(int nr,struct task_struct * p) {
...
set_base(p->ldt[1],new_code_base);// 设置代码段描述符中基址域
set_base(p->ldt[2],new_data_base);// 设置数据段描述符中基址域
...
}

经过以上的步骤,就通过分段的方式,将进程映射到了相互隔离的线性地址空间里,这就是段式管理。

页表拷贝
1
2
3
4
5
int copy_mem(int nr,struct task_struct * p) {
...
// old=0, new=64M, limit=640K
copy_page_tables(old_data_base,new_data_base,data_limit)
}

进程 0 有一个页目录表四个页表,将线性地址空间的 0-16M 原封不动映射到了物理地址空间的 0-16M。

现在进程 0 的线性地址空间是 0 - 64M,进程 1 的线性地址空间是 64M - 128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0 最终被映射到的物理空间都是 0 - 64M,这样进程 1 才能顺利运行起来,不然就乱套了。最后,将新老进程的页表都变成只读状态,为后面写时复制的缺页中断做准备。

我们知道最后这段代码实现了上面的功能即可,代码中的计算非常绕。

把进程的基本信息、内存规划都完成后,设置状态为TASK_RUNNING,表明该进程允许被调度。

拓展:fork()编程相关拓展1

拓展:fork()编程相关拓展2

(3)pause 死循环

当没有任何可运行的进程时,操作系统会悬停在这里。

1
for(;;) pause();

对于任何其它的任务, pause() 将意味着我们必须等待收到一个信号才会返回就绪运行态,但任务 0(task0)是唯一的意外情况,因为任务 0 在任何空闲时间里都会被激活(当没有其它任务在运行时),因此对于任务 0 ,pause() 仅意味着我们返回来查看是否有其它任务可以运行,如果没有的话我们就回到这里,一直循环执行 pause() 。

0x06 Shell 登场 - init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void init(void) {
int pid,i;
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
if (!(pid=fork())) {
open("/etc/rc",O_RDONLY,0);
execve("/bin/sh",argv_rc,envp_rc);
}
if (pid>0)
while (pid != wait(&i))
/* nothing */;
while (1) {
if (!pid=fork()) {
close(0);close(1);close(2);
setsid();
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
_exit(execve("/bin/sh",argv,envp));
}
while (1)
if (pid == wait(&i))
break;
sync();
}
_exit(0); /* NOTE! _exit, not exit() */
}

1.硬盘信息获取

1
2
3
4
5
6
7
8
struct drive_info { char dummy[32]; } drive_info;

// drive_info = (*(struct drive_info *)0x90080);

void init(void) {
setup((void *) &drive_info);
...
}

0x90080setup.s程序将硬盘 1 的参数信息放在了这里,包括:

·柱面数 Cylinders

·磁头数 Headers

·扇区数 Sectors

sys_setup

setup 是系统调用,通过中断最终调用 sys_setup 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int sys_setup(void * BIOS) {

hd_info[0].cyl = *(unsigned short *) BIOS;
hd_info[0].head = *(unsigned char *) (2+BIOS);
hd_info[0].wpcom = *(unsigned short *) (5+BIOS);
hd_info[0].ctl = *(unsigned char *) (8+BIOS);
hd_info[0].lzone = *(unsigned short *) (12+BIOS);
hd_info[0].sect = *(unsigned char *) (14+BIOS);
BIOS += 16;

hd[0].start_sect = 0;
hd[0].nr_sects =
hd_info[0].head * hd_info[0].sect * hd_info[0].cyl;

struct buffer_head *bh = bread(0x300, 0);
struct partition *p = 0x1BE + (void *)bh->b_data;
for (int i=1;i<5;i++,p++) {
hd[i].start_sect = p->start_sect;
hd[i].nr_sects = p->nr_sects;
}
brelse(bh);

rd_load();
mount_root();
return (0);
}
(1)硬盘基本信息赋值
1
2
3
4
5
6
7
8
9
10
int sys_setup(void * BIOS) {
hd_info[0].cyl = *(unsigned short *) BIOS;
hd_info[0].head = *(unsigned char *) (2+BIOS);
hd_info[0].wpcom = *(unsigned short *) (5+BIOS);
hd_info[0].ctl = *(unsigned char *) (8+BIOS);
hd_info[0].lzone = *(unsigned short *) (12+BIOS);
hd_info[0].sect = *(unsigned char *) (14+BIOS);
BIOS += 16;
...
}

(2)硬盘分区表设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static struct hd_struct {
long start_sect;
long nr_sects;
} hd[5] = {}

int sys_setup(void * BIOS) {
...
hd[0].start_sect = 0;
hd[0].nr_sects =
hd_info[0].head * hd_info[0].sect * hd_info[0].cyl;
struct buffer_head *bh = bread(0x300, 0);
struct partition *p = 0x1BE + (void *)bh->b_data;
for (int i=1;i<5;i++,p++) {
hd[i].start_sect = p->start_sect;
hd[i].nr_sects = p->nr_sects;
}
brelse(bh);
...
}

最终效果,就是给 hd 数组的五项附上了值,表示硬盘的分区信息,每个分区用 start_sectnr_sects,也就是开始扇区和总扇区数来记录。分区信息在硬盘 0x1BE 偏移处,拿到这部分信息即可。

1
2
3
4
5
6
struct buffer_head *bh = bread(0x300, 0);
// 从硬盘读取数据
// 0x300是第一块硬盘的主设备号
// 0 表示第一块
struct partition *p = 0x1BE + (void *)bh->b_data;
// 再取 0x1BE 偏移处,拿到分区信息

(3)虚拟内存盘执行
1
rd_load();

有虚拟内存盘时候才会执行。

(4)根目录
1
mount_root();

加载根文件系统,有根文件系统后,OS才能从根开始找到所有存储在硬盘中的文件。

2.加载根文件系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void mount_root(void) {
int i,free;
struct super_block * p;
struct m_inode * mi;

for(i=0;i<64;i++)
file_table[i].f_count=0;

for(p = &super_block[0] ; p < &super_block[8] ; p++) {
p->s_dev = 0;
p->s_lock = 0;
p->s_wait = NULL;
}
p=read_super(0);
mi=iget(0,1);

mi->i_count += 3 ;
p->s_isup = p->s_imount = mi;
current->pwd = mi;
current->root = mi;
free=0;
i=p->s_nzones;
while (-- i >= 0)
if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data))
free++;

free=0;
i=p->s_ninodes+1;
while (-- i >= 0)
if (!set_bit(i&8191,p->s_imap[i>>13]->b_data))
free++;
}

(1)硬盘中的文件系统格式

linux0.11的文件系统是 MINIX 文件系统,与标准 UNIX 文件系统基本相同。

·引导区:启动区,预留出来,保持格式统一。

·超级块:描述文件系统整体信息。

·inode位图和块位图:位图基本操作与使用。

·块:

​ 普通文件二进制信息

​ 目录类型目录文件、inode索引等信息

每一个块的结构大小是1024字节,即 1KB。写好操作系统后,给一个硬盘做某种文件系统类型的格式化,比如在安装Arch Linux时候,对硬盘格式化。这样就得到一个有文件系统的硬盘,然后OS可以成功启动。

(2)内存用于文件系统的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct file {
unsigned short f_mode;
unsigned short f_flags;
unsigned short f_count;
struct m_inode * f_inode;
// 包含文件大小、类型、所在硬盘块号
off_t f_pos;
};

void mount_root(void) {
for(i=0;i<64;i++)
file_table[i].f_count=0;
// file_table 表示进程使用的文件
// f_count 表示被引用的次数,初始化为 0
struct super_block * p;
for(p = &super_block[0] ; p < &super_block[8] ; p++) {
p->s_dev = 0;
p->s_lock = 0;
p->s_wait = NULL;

}

super_block存在的意义:操作系统与一个设备以文件形式进行读写访问时,就需要把这个设备的超级块信息放在这里。通过这个超级块,可以掌握设备文件系统全局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void mount_root(void) {
...
p=read_super(0);
// 读取硬盘的超级块信息到内存中
mi=iget(0,1);
// 从设备上读取指定节点号的 i 节点
//读取根 inode 信息,0是dev,1是nr(节点号)1
current->pwd = mi;
//inode设置为当前进程的工作目录
current->root = mi;
//inode设置为当前进程的根目录
i=p->s_nzones;
while (-- i >= 0)
set_bit(i&8191, p->s_zmap[i>>13]->b_data);
//记录块位图信息
i=p->s_ninodes+1;
while (-- i >= 0)
set_bit(i&8191, p->s_imap[i>>13]->b_data);
//记录 inode 位图信息
...
}

整体上就是把硬盘中文件系统的各个信息,搬到内存中。

此时setup函数也结束,调用链如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void main(void) {
...
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}

void init(void) {
setup((void *) &drive_info);
// setup 加载根文件系统,顺着根 inode 可以找到所有文件,就是为了下面 open 函数通过文件路径,从硬盘中拿到文件
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
...
}

int sys_setup(void * BIOS) {
...
mount_root();
}

3.打开终端设备文件

1
2
3
4
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
}

open函数触发 0x80 中断,转到 sys_open 系统调用函数。

结论:进程 1 通过 open 函数建立了与外设交互的能力,具体其实就是打开了 tty0 这个设备文件,并绑定了标准输入 0,标准输出 1 和 标准错误输出 2 这三个文件描述符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
open.c

struct file file_table[64] = {0};

int sys_open(const char * filename,int flag,int mode) {
struct m_inode * inode;
struct file * f;
int i,fd;
mode &= 0777 & ~current->umask;
//1.在进程文件描述符数组 filp 中找到一个空闲项,把空闲项的索引值记为 fd,每个进程最多打开 20 个文件
for(fd=0 ; fd<20; fd++)
if (!current->filp[fd])
break;
if (fd>=20)
return -EINVAL;

current->close_on_exec &= ~(1<<fd);
//2.在系统文件表 file_table 中找到空闲项,每个系统最多打开64个文件
f=0+file_table;
for (i=0 ; i<64 ; i++,f++)
if (!f->f_count) break;
if (i>=64)
return -EINVAL;
//3.将进程的文件描述符数组项和系统的文件表项对应起来
(current->filp[fd]=f)->f_count++;
//4.根据文件名从文件系统中找到对应文件
//相当于找到了 filename 文件对应的 inode 信息
i = open_namei(filename,flag,mode,&inode);
//判断是否是字符设备
if (S_ISCHR(inode->i_mode))
if (MAJOR(inode->i_zone[0])==4) {
if (current->leader && current->tty<0) {
current->tty = MINOR(inode->i_zone[0]);
tty_table[current->tty].pgrp = current->pgrp;
}
} else if (MAJOR(inode->i_zone[0])==5)
if (current->tty<0) {
iput(inode);
current->filp[fd]=NULL;
f->f_count=0;
return -EPERM;
}
if (S_ISBLK(inode->i_mode))
check_disk_change(inode->i_zone[0]);
//5.填充 file 数据,即:初始化
f->f_mode = inode->i_mode;
f->f_flags = flag;
f->f_count = 1;//引用次数为1
f->f_inode = inode;
f->f_pos = 0;//文件读写指针为0
return (fd);//返回文件描述符
}

文件描述符

文件描述符是一个用于表述指向文件引用的抽象化概念,在形式上是一个非负整数。 实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。 在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。在UNIX、Linux的系统调用中,大量的系统调用都是依赖于文件描述符。

文件描述符也有缺点:

在非UNIX/Linux 操作系统上(如Windows),无法基于这一概念进行编程——事实上,Windows下的文件描述符和信号量、互斥锁等内核对象一样都记作HANDLE。

由于文件描述符在形式上不过是个整数,当代码量增大时,会使编程者难以分清哪些整数意味着数据,哪些意味着文件描述符。因此,完成的代码可读性也就会变得很差,这一点一般通过使用名称有文字意义的 magic number 进行替换来解决。

摘自维基百科

1
2
3
4
5
6
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
}

open函数返回文件描述符为0号,作为标准输入设备:stdin

复制文件描述符,产生文件描述符1号,作为标准输出设备:stdout

复制文件描述符,产生文件描述符2号,作为标准错误输出设备:stderr

dup函数:通过系统调用,到sys_dup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 从进程的 filp 中找到下一个空闲项,然后把要复制的文件描述符 fd 的信息,统统复制到这里。
int sys_dup(unsigned int fildes) {
return dupfd(fildes,0);
}

// fd 是要复制的文件描述符
// arg 是指定新文件描述符的最小数值
static int dupfd(unsigned int fd, unsigned int arg) {
...
while (arg < 20)
if (current->filp[arg])
arg++;
else
break;
...
(current->filp[arg] = current->filp[fd])->f_count++;
return arg;
}

进程 0 是不具备与外设交互的能力的,进程 1 刚刚创建的时候,是 fork 的进程 0,所以也不具备这样的能力,而通过 setup 加载根文件系统,open 打开 tty0 设备文件等代码,使得进程 1 具备了与外设交互的能力,同时也使得之后从进程 1 fork 出来的进程 2 也天生拥有和进程 1 同样的与外设交互的能力。具体可以体现为调用 printf 函数可以往屏幕上打印字符串了。

4.进程2的创建

1
2
3
4
5
6
7
8
9
10
11
12
void init(void) {
...
if (!(pid=fork())) {//1.创建进程2
close(0); //2.关闭 0 号文件描述符
open("/etc/rc",O_RDONLY,0);
//3.打开 /etc/rc 文件,占据了 0 号文件描述符的位置
//rc表示配置文件,与OS内核无关
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
...
}

5.execve 加载并执行 shell 程序

1
2
3
4
#include <unistd.h> 

int execve(const char *filename, char *const argv[],
char *const envp[]);

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
2
#!/bin/sh
#!/usr/bin/python

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
2
3
4
5
6
7
8
void do_page_fault(..., unsigned long error_code) {
...
if (error_code & 1)
do_wp_page(error_code, address, current, user_esp);
else
do_no_page(error_code, address, current, user_esp);
...
}

当页表项 P 为0时,表示缺页,走do_no_page逻辑,程序简化后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// memory.c
// address 缺页产生的线性地址 0x8000000(假设的)
// 也就是进程 2 自己线性地址空间的起始处 128M 这个位置
// 参数 error_code 是由 CPU 自动产生,address 是页面线性地址
void do_no_page(unsigned long address) {
// 1.线性地址的页面地址 0x8000000
// 页表映射以页(4KB)为单位,进行内存对齐
address &= 0xfffff000;

// 2.计算 [相对] 于 [进程基址] 的偏移
// 对于进程2自己的偏移地址,要去掉段基址部分
unsigned long tmp = address - current->start_code;

// 3.寻找空闲的一页内存
// 如果已经没有内存了,则返回 0。
unsigned long page = get_free_page();

// 4.计算这个地址在文件中的哪个数据块 1
int block = 1 + tmp/BLOCK_SIZE;

// 5.一个数据块 1024 字节,一页内存 4096 字节
// 所以一页内存需要读 4 个数据块
// 把硬盘中的数据加载进内存
int nr[4];
for (int i=0 ; i<4 ; block++,i++)
nr[i] = bmap(current->executable,block);
// bmap 负责将相对于文件的数据块转换为相对于整个硬盘的数据块
// 比如这个文件的第 1 块数据,可能对应在整个硬盘的第 24 块的位置
bread_page(page,current->executable->i_dev,nr);
...
// 6.完成页表的映射,即建立相关页表
put_page(page,address);
}

本质上就是加载硬盘对应位置的数据,然后建立页表的过程。

execve 函数返回后,CPU 就跳转到 /bin/sh 程序的第一行开始执行,但由于跳转到的线性地址不存在,所以引发缺页中断,把硬盘里 /bin/sh 所需要的内容加载到了内存,此时缺页中断返回。

缺页中断返回后,CPU 会尝试跳转到对应的线性地址,此时该线性地址已有对应的页表进行映射,所以顺利地映射到了物理地址,即/bin/sh的代码部分,可以开始执行shell程序。

7.Shell 启动

Shell = fork + execve

Shell代码不在 linux0.11 中,所以用MIT的XV6源代码学习一下原理即可。代码简化后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// xv6-public sh.c
int main(void) {
static char buf[100];
// 读取命令
while(getcmd(buf, sizeof(buf)) >= 0){
// 创建新进程
if(fork() == 0)
// 执行命令
runcmd(parsecmd(buf));
// 等待进程退出
wait();
}
}

在死循环里面,shell 就是不断读取(getcmd)我们用户输入的命令,创建一个新的进程(fork),在新进程里执行(runcmd)刚刚读取到的命令,最后等待(wait)进程退出,再次进入读取下一条命令的循环中。

现在shell就已经启动了。

shell 程序有个特点,就是如果标准输入为一个普通文件,比如 /etc/rc,那么文件读取后就会使得 shell 进程退出,如果是字符设备文件,比如由我们键盘输入的 /dev/tty0,则不会使 shell 进程退出。

到此为止,整个OS就像这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// main.c
void main() {
// 初始化环境
...
// 外层操作系统大循环
while(1) {
// 内层 shell 程序小循环
while(1) {
// 读取命令 read
...
// 创建进程 fork
...
// 执行命令 execve
...
}
}
}

OS的本质就是靠各种中断驱动的死循环。

到此为止,OS启动完毕,本身设置好中断处理程序,随时等待中断到来,同时运行了shell程序用来接受用户的命令进行交互。

后记

后续要继续对Shell、execve等底层内容进行深入理解,到时候会新开post。

附录 参考资料

主引导记录

Master Boot Record

你管这破玩意叫操作系统源码 — 像小说一样品读 Linux 0.11 核心代码

OSDEV

Intel手册Vol.3A

-------------本文结束感谢您的阅读-------------
请作者喝一杯蜜雪冰城吧!