Linux0.12内核源码解读(5)-head.s

作者:小牛呼噜噜 | https://xiaoniuhululu.github.io
计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜

大家好,我是呼噜噜,好久没有更新old linux了,本文接着上一篇文章图解CPU的实模式与保护模式,继续向着操作系统内核的世界前进,一起来看看heads.s

as86 与GNU as

首先我们得了解一个事实,在Linux0.12内核源码中,其实是使用了2套汇编器Assembler的,一种是Intel8086汇编编译器as86和配套的链接器ld86,并一种就是GNU as(gas),使用 GNU ld 链接器来链接产生的目标文件。

为什么使用了2套汇编器?

我们知道Linux0.12bootsect.s和setup.s实模式下运行的16位代码程序,而那个时候的GNU as 汇编编译器无法支持16位实模式代码程序编译,所以Linus不得不使用as86和ld86,其语法近似Intel语法

而从head.s开始的,内核完全都是在保护模式下运行了,操作系统system模块中其余所有汇编语言程序(包括 C 语言产生的汇编程序)都是使用GNU as 汇编编译器,使用的是AT&T语法。直到Linux内核2.4.x后,bootsect.s和head.s程序才完全使用统一的GNU as 来编写

2种语法虽然是有所区别,但其实都是类似的,需要注意的最基本的区别是,AT&T语法中,mov赋值的方向是从左到右

Linux0.12内核源码解读(3)-Setup.S中,最后我们说到CPU 进入了 32 位保护模式,跳到了内存零地址处开始执行代码。先来回顾一下执行完setup.S时的内存分布情况:


此时从内存零地址处存放的system模块,其首部是head.s代码,即head.s代码从地址0处开始存放,因此setup结束后执行的就是head.s文件

head.s主要是进入进行保护模式之后的初始化,主要初始化些什么呢?呼噜噜,画了个流程图,建议大家跟着下面流程图,阅读以下全文

如果有人对本文中操作系统一系列初始化操作,感到疑惑,比如为什么要设置的话等之类的问题,建议先看笔者前一篇文章图解CPU的实模式与保护模式

设置段寄存器和系统堆栈

1
2
3
4
5
6
7
8
_pg_dir: # 页目录将会存放在这里
startup_32:
movl $0x10,%eax # 32位ax寄存器赋值0x10
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp #设置栈(系统栈)

我们可以看到上面这段源代码中_pg_dir,这个很重要,和分页机制有关,主要是标识内核分页机制完成后的内核起始地址(零地址),页目录将会存放在这里,这个我们下文再讲。

movl $0x10,%eax,将32位ax寄存器赋值0x10,MOV类指令是最简单的数据传送指令,这类指令把数据从源位置复制到目的位置,需要声明要传送的数据元素的长度,一般有以下几种:

指令 描述 位数
movb 传送字节 8位
movw 传送字 16位
movl 传送双字 32位
movq 传送四字 64位

对于 GNU 汇编,每个直接操作数要以$开始,否则表示地址。每个寄存器名都要以%开头,eax 表示是 32 位的 ax 寄存器。

如果面试官提问head.s中0x10这个地址具体是指向哪呢?

这个是虽然简单,但很有迷惑性的,首先我们得知道当操作系统执行head.s的时候,已经进入了保护模式,此时段寄存器不再表示段的基地址,而是表示段选择符(也叫段选择子)

段选择符 描述
b1-b0 请求特权级(RPL)
b2 0:全局描述符表 1:局部描述符表
b15-b3 描述符表项的索引, 指出选择第几项描述符(从0开始)

所以我们需要先0x10写成16位二进制形式(高位补零)0b0000 0000 0001 0000,所以对应的段选择符:请求特权级为 0(RPL=00)、所指向的描述符存放在GDT(T1=0)、所指向的描述符索引为2(DI=0000 000000010),也就是指向GDT全局段描述符表第3项(从0开始)

接着分别给 ds、es、fs、gs 这几个段寄存器赋值为0x10,让这些寄存器都指向GDT的第3项

lss _stack_start,%esp主要作用是设置系统栈,汇编指令lss会分别给一个段寄存器和一个16位通用寄存器赋值,那么也就是说将操作数_stack_start的值传送给指定ss:esp,其中ss就是堆栈寄存器,存放堆栈段的段基址(实模式),保护模式下存放的就是段选择符,只能存放16位的数据,esp是指向栈顶的通用寄存器,能够存放32位的数据

stack_start是一个标号,它定义在kernel/sched.c文件中:

1
2
3
4
5
6
7
#定义用户堆栈, PAGE_SIZE=4096,所以user_stack长度为1024
long user_stack [PAGE_SIZE=4096>>2 ] ;

struct {
long * a;
short b;
} stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };

我们可以发现这是一个结构体,将stack_start的值传给ss:esp,lss指令会把stack_start指向的内存地址的前四字节(32位)装入ESP寄存器,后两字节(16位)装入SS段寄存器,即ss=0x10,esp=& user_stack [1024]

设置IDT

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
call setup_idt #设置IDT


setup_idt:
lea ignore_int,%edx #将 ignore_int 的有效地址(偏移值)值 赋值给 edx 寄存器

movl $0x00080000,%eax # 将段选择符 0x0008 置入 eax 的高 16 位中

movw %dx,%ax /* selector = 0x0008 = cs */ # 偏移值的低 16 位置入 eax 的低 16 位中。此时 eax 含有门描述符低 4 字节的值

movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ #此时 edx 含有门描述符高 4 字节的值


lea _idt,%edi # _idt 是中断描述符表的地址, 取idt的偏移给edi
mov $256,%ecx #循环256次
rp_sidt:
movl %eax,(%edi) # 将哑中断门描述符存入表中
movl %edx,4(%edi) # eax 内容放到 edi+4 所指内存位置处。
addl $8,%edi # edi 指向表中下一项
dec %ecx # 循环减1
jne rp_sidt jne 表示zf=0跳转
lidt idt_descr # 加载IDTR !!!
ret

idt_descr:
.word 256*8-1 # idt contains 256 entries ,共 256 项,是CPU寄存器中的值
.long _idt
.align 2
.word 0


_idt: .fill 256,8,0 # idt is uninitialized,这个是在内存中的

IDT,Interrupt Descriptor Table,即中断描述符表,记录着0~255的中断号和调用函数之间的关系,与中段向量表有些相似,但要包含更多的信息。

不知道大家还记不记得,在setup.S中临时将IDT临时设置为一个空表,自此int n 不再是DOS中断了,而是去IDT表中找到中断函数的地址,再执行

上面这段代码实现了256 个中断描述符的设置,各个中断描述符表项都指向一个ignore_int的函数地址,其中ignore_int是一个只报错误的哑中断子程序,内核在随后的初始化过程中,会替换覆盖那些真正实用的中断描述符项

我们查看ignore_int,会发现它就是去打印一串字符Unknown interrupt,提示报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int_msg:
.asciz "Unknown interrupt\n\r"
.align 2
ignore_int:
pushl %eax
pushl %ecx
pushl %edx
push %ds ## 注意!!ds,es,fs,gs 等虽然是 16 位的寄存器,但入栈后仍然会以 32 位的形式入栈,也即需要占用 4 个字节的堆栈空间
push %es
push %fs
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
pushl $int_msg # 把调用 printk 函数的参数指针(地址)入栈
call _printk
popl %eax
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret # 中断返回(把中断调用时压入栈的 CPU 标志寄存器(32 位)值也弹出)

中断对操作系统来说非常重要,可以跟硬件(例如键盘鼠标显卡等)产生交互,没有中断操作系统就缺胳膊少腿,当中断发生时,CPU获取到中断向量后,通过IDTR的值,去查找IDT中断描述符表,得到相应的中断描述符,再根据中断描述符记录的信息来作权限判断,运行级别转换,最终调用相应的中断处理程序

设置GDT

我们来看下其相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
call setup_gdt #设置GDT

setup_gdt:
lgdt gdt_descr # 加载全局描述符表寄存器(内容已设置好)
ret

gdt_descr:
.word 256*8-1 # so does gdt (not that that's any
.long _gdt # magic number, but it works for me :^)

.align 3


_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */

这段代码就是重新设置GDT,其实这里和我们在Setup.S设置的GDT是一样的,笔者这里再贴一下之前的代码,比较一下发现是初始化出来的GDT是基本是一模一样的,除了此时段限长不是原来的8MB,而是现在的16MB

1
2
3
4
5
6
7
8
9
10
11
12
gdt:              ! 描述符表由多个8字节长的描述符项组成。这里给出了 3 个描述符项。
.word 0,0,0,0 ! dummy 第1个为空描述符,无用,但必须存在

.word 0x07FF ! 段界限为 8M,limit=2047 (2048*4096=8Mb) 第2个为空描述符
.word 0x0000 ! 段基址为 0
.word 0x9A00 ! code read/exec P=1, DPL=00, S=1, 代码段,只读,可执行
.word 0x00C0 ! granularity=4096, 386

.word 0x07FF ! 段界限为 8M - limit=2047 (2048*4096=8Mb) 第3个为空描述符
.word 0x0000 ! 段基址为 0
.word 0x9200 ! P=1, DPL=00, S=1, 数据段,可读可写
.word 0x00C0 ! granularity=4096, 386

这里主要是为了防止GDT这块内存区域被其他程序覆盖使用,head废除Setup.S设置的GDT,并在内存中重新创建一个全新的全局描述符表

重复设置段寄存器与系统堆栈

1
2
3
4
5
6
movl $0x10,%eax		# reload all the segment registers
mov %ax,%ds # after changing gdt. CS was already
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp

这里重复设置段寄存器与系统堆栈,也是为了安全起见,因为它们所指向的原描述符所指向的段的段限长为 8MB,而刚刚在setup_gdt** 修改了 GDT,段限长已经变为 16MB,所以当访问 8MB 以上的地址空间时,有可能会产生段限长超限报警。为了防止这类可能发生的情况,在这里重载刷新所有的段寄存器**

检查A20是否打开

1
2
3
4
5
xorl %eax,%eax  #清零,xorl只需要2个字节,而是用movl实现清零需要5个字节!
1: incl %eax # 检查A20是否开启
movl %eax,0x000000 # 如果不是,则永远循环
cmpl %eax,0x100000
je 1b # '1b'表示向后(backward)跳转到标号 1 去

引入A20是为解决80286的一个bug而引入的,什么bug?请移步看前文Linux0.12内核源码解读(3)-Setup.S

在A20关闭的情况下,系统仍然使用8086/8088的方式,计算机处于20位的寻址模式,访问超过0xFFFFF=2^20=1MB内存时,会自动回卷,比如0x100000会回卷到0x000000;当在A20打开的情况下,才会突破地址信号线20位的宽度,变成32位可用,实现最大寻址空间4GB

所以这部分代码,是通过在内存0x000000处写入任意数据,并和0x100000处比较是否一致,来检查A20是否打开。如果一直相同的话,说明内存回卷, A20没有打开,然后就会一直比较下去,即死循环。

检查x87协处理器是否存在

为了弥补 x86 系列在进行浮点计算时的不足,Intel 于 1980 年推出了 x87 系列数学协处理器,那时是一个外置的、可选的芯片。1989 年,Intel 发布了 486 处理器。自从 486 开始,以后的 CPU 一般都内置了协处理器。这样,对于 486 以前的计算机而言,操作系统检验 x87 协处理器是否存在就非常有必要了

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
/*
* NOTE! 486 should set bit 16, to check for write-protect in supervisor
* mode. Then it would be unnecessary with the "verify_area()"-calls.
* 486 users probably want to set the NE (#5) bit also, so as to use
* int 16 for math errors.
*注意! 在下面这段程序中,486 应该将位 16 置位,以检查在超级用户模式下的写保护,
* 此后 "verify_area()" 调用就不需要了。486 的用户通常也会想将 NE(#5)置位,以便
* 对数学协处理器的出错使用 int 16
*/

movl %cr0,%eax # 校验数学芯片
andl $0x80000011,%eax # Save PG,PE,ET

orl $2,%eax # set MP
movl %eax,%cr0
call check_x87
jmp after_page_tables

check_x87:
fninit # 向协处理器发出初始化命令
fstsw %ax # 取协处理器状态字到 ax 寄存器中
cmpb $0,%al # 初始化后状态字应该为 0,否则说明协处理器不存在
je 1f /* no coprocessor: have to set bits */
movl %cr0,%eax # 如果存在则向前跳转到标号 1 处,否则改写 cr0
xorl $6,%eax /* reset MP, set EM */
movl %eax,%cr0
ret
.align 2 # align 是一汇编指示符。其含义是指存储边界对齐调整,"2"表示把随后的代码或数据的偏移位置
# 调整到地址值最后 2 比特位为零的位置(2^2),即按 4 字节方式对齐内存地址
1: .byte 0xDB,0xE4 /* fsetpm for 287, ignored by 387 */
ret

这部分源码主要是,用于检查数学协处理器芯片是否存在。方法是修改控制寄存器 CR0,在假设存在协处理器的情况下执行一个协处理器指令,如果出错的话则说明协处理器芯片不存在, 需要设置 CR0 中的协处理器仿真位 EM(位 2),并复位协处理器存在标志 MP(位 1),这部分简单了解一下即可

构建分页管理机制

检查完数学协处理器芯片是否存在,紧接着就执行jmp after_page_tables跳转after_page_tables这个标号处:

1
2
3
4
5
6
7
8
9
10
11
12
after_page_tables:
# 先将main函数参数,L6标号和main函数入口地址压栈
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $_main

jmp setup_paging
L6:
jmp L6 # main应该永远不会回到这里,但以防万一,我们需要知道发生了什么

先将main函数参数,L6标号和main函数入口地址压入栈中,等待被使用,我们这里先卖个关子,讲完分页再讲解

jmp setup_paging 跳到分页设置,想要理解这部分,你得先了解什么是段页机制,详情见图解CPU的实模式与保护模式


记住这张图的分页机制,理解线性地址前10位,中间10位,后12位分别代表什么,CR3指向哪边,分页机制的原理,我们接着阅读以下部分

内存页清零

1
2
3
4
5
setup_paging:
movl $1024*5,%ecx
xorl %eax,%eax # 清零
xorl %edi,%edi # 清零,并让页目录从 0x000 地址开始
cld;rep;stosl # eax 内容存到 es:edi 所指内存位置处,且 edi 增 4

其中:

  1. ecx是计数器, 是重复(rep)前缀指令和loop指令的内定计数器,表示控制循环次数
  2. cld相对应的指令是std,二者均是用来操作方向标志位DF(Direction Flag)。cld使DF 复位,即是让DF=0,std使DF置位,即DF=1.这两个指令用于串操作指令中。通过执行cld或std指令可以控制方向标志DF,决定内存地址是增大(DF=0,向高地址增加)还是减小(DF=1,向地地址减小)
  3. rep 表示当 ecx>0 时,循环继续;反之停止
  4. stosl指令相当于将eax中的值保存到es:edi指向的地址中,若设置了EFLAGS中的方向位置位(即在STOSL指令前使用STD指令)则EDI自减4,否则(使用CLD指令)EDI自增4

这一小段代码连起来就是按4字节的速度循环清空内存,每次循环清空的内存范围** **1024*4=4096字节,恰好是一个页,也就是最终清空5页内存(1 页目录 + 4 页页表)

设置页目录表、页表

因为我们(内核)共有 4 个页表,所以只需设置 4 项。

1
2
3
4
5
 # 分别设置4个页表
movl $pg0+7,_pg_dir /* set present bit/user r/w */
movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
movl $pg2+7,_pg_dir+8 /* --------- " " --------- */
movl $pg3+7,_pg_dir+12 /* --------- " " --------- */

可能就有人会问了,为啥就只有 4 个页表?不是可以设置1024项嘛?

Linx0.12 当时规定最大寻址空间0xFFFFFF,也就是16M,而1个页目录表或者一个页表最多有1024 个项,页的大小固定为4KB,4(页表数)* 1024* 4KB= 16MB,所以只需前4个页表就能够支持16M寻址

咳咳,还记得我们本文一开始讲的_pg_dir,表示页目录表将会存放在这里(零地址处),紧挨着的其实还有4个页表

1
2
3
4
5
6
7
8
9
10
11
12
13
.org 0x1000 # .ORG伪指令用来表示起始的偏移地址,紧接着ORG的数值就是偏移地址的起始值
pg0:

.org 0x2000
pg1:

.org 0x3000
pg2:

.org 0x4000
pg3:

.org 0x5000

页目录项的结构与页表中项的结构一样,4 个字节为 1 项。
我们简单举个例子:

  1. 这里的$pg0+7其实就表示0x00001007,是页目录表中的第 1 项,我们按线性地址转换为对应的0b0000000000 0000000001 000000000111
  2. 按照页目录和页表的结构,我们知晓第 1 个页表所在的地址 =0000000001= 0x1000
  3. 第 1 个页表的属性标志 =000000000111=0x07,在二进制下,根据这3个1分别表示:页存在P=1、用户可读写RW=1、特权为用户态US=1,表示该页存在、用户可读写

原本页表0到页表3处的代码(也就是head.s17行到114行之间所有执行过的代码),全部清空,此时页目录表和页表在内存的分布情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
+
| ...
+——————— 0x5000
| 页表3
+——————— 0x4000
| 页表2
+——————— 0x3000
| 页表1
+——————— 0x2000,页的大小4K
| 页表0
+——————— 0x1000
| 页目录表
+——————— 0x0000

接着就是填充4个页表中所有项的内容,下面是从最后一个页表的最后一项开始按倒退顺序填充数据

1
2
3
4
5
6
7
8
	movl $pg3+4092,%edi   # edi最后一页表的最后一项

movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */

std #方向位置位,edi 值递减(4 字节)。
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax # 每填写好一项,物理地址值减 0x1000。
jge 1b /*1b 表示向后跳转到标号1处,如果小于 0 则说明全添写好了*/

设置CR3和CR0

接着设置页目录表基址寄存器cr3,指向页目录表。cr3中保存的是页目录表的物理内存地址,然后设置启动使用分页处理(cr0 的 PG 标志),cr0中含有控制处理器操作模式和状态的系统控制标志

1
2
3
4
5
6
xorl %eax,%eax		# 页目录表在 0x0000 处。
movl %eax,%cr3 # 设置页目录基址寄存器CR3的值,指向页目录表。页目录表在0x0000处
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* 设置启动使用分页处理,CR0的PG标志置位 */
ret /* this also flushes prefetch-queue */

需要注意的是,当执行完这行代码movl %eax,%cr0后,标志着操作系统正式开启分页,此时段部件产生的地址就不再被看成物理地址,被称为线性地址,而是要送往页部件进行变换,以得到真正的物理地址。

最后ret指令很重要,它这里有2个作用:

  1. 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret。
  2. 将之前压入栈中的 main()程序入口地址弹出,并跳转到 init/main.c 程序去运行。

乍眼一看ret指令怎么就和main函数联系到一起了?我们马上详细来聊聊其中的缘由

跳转至main函数

跳转至main函数的准备工作其实在head.s的早就开始了,但最后一步由ret指令执行的

1
2
3
4
5
6
7
8
9
10
11
12

after_page_tables:
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $_main

...
setup_paging:
...
ret

after_page_tables标号处,先将main函数参数,L6标号和main函数入口地址压入中,等待被使用。这些参数比如3个0,后续实际上也没有用到。 L6标号是main函数返回时的跳转地址。

汇编中的参数一般是通过寄存器传递的,而C语言中的参数一般是通过栈来传递

直到setup_paging标号处的ret指令,正好将之前压入栈中的 main()程序入口地址弹出,这个时候CPU会把esp寄存器(始终指向栈顶地址)指向的内存地址处的值,赋值给eip寄存器

eip指令指针寄存器存储着下一条指令的地址,通过CS:EIP联合指向即将执行的下一条指令。对于顺序执行的指令,EIP从前一条指令边界移到下一条指令边界上;对于控制转移指令,例如JMP,JCC, CALL,RET和IRET指令,EIP会向前或先后跳跃数条指令。

一般情况下,程序是不能直接读取或修改EIP寄存器的值,但是可以隐式地通过控制转移指令(JMP,J,CALL和RET),中断,和异常来间接控制EIP。要想读取到EIP寄存器的值,唯一的手段是执行CALL指令,然后从程序栈中读取返回指令指针。这里是通过修改程序栈中返回指令指针的值,然后执行RET指令,间接的加载EIP寄存器

最终CPU跳转到 init/main.c处去运行程序代码。

当执行完ret指令,标志着head.s程序到此就真正结束了!

后续就进入了我们倍感亲切的C程序世界,我们下期再见~~


参考资料:

https://elixir.bootlin.com/linux/0.12/source/boot/head.s

英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A-英特尔®

《Linux内核完全注释5.0》


全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的,你的支持会激励我输出更高质量的文章,感谢!

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!