本文主要分析 Linux 0.11 内核引导程序head.s
关键部分。《Linux 内核完全注释》和《Linux 内核设计的艺术》两书将作为主要参考资料。
我们从上一博文 Linux 内核学习笔记:内核引导程序之“setup.s”已知指令指针已跳转至内存最初始处(0x0000 0000),从 head 程序开始执行。需要注意的是,head 程序用 AT&T 汇编语法写成,并工作在保护模式下。
设置 DS、ES、FS、GS
这部分代码如下:
1 | .text |
因为处理器已经工作在保护模式下,所以这些段寄存器都表示段选择子。0x10 写成 16 位二进制形式为 0b0000 0000 0001 0000,所以值为该数的段选择子:请求特权级为 0(RPL=00)、所指向的描述符存放在 GDT (TI=0)、所指向的描述符索引为 2(DI=0000 0000 0001 0)。
DS、ES、FS、GS 段选择子所指向的段描述符可用图表示如下:
图片来源:《Linux 内核设计的艺术》
设置系统栈
1 | _pg_dir: |
lss
是 Load Stack Segment Register 的缩写,跟上一博文 Linux 内核学习笔记:内核引导程序之“setup.s”的 lds 类似,是将操作数的值传送给指定寄存器ss:esp
。
stack_start 定义在 kenel/sched.c 文件中:
1 | long user_stack [ PAGE_SIZE>>2 ] ; |
这段代码有两个重要的点:
- stack_start 经 C 语言编译器编译后,会被符号修饰为
_stack_start
,这样 head.s 和 sched.c 各自编译后的文件就能够链接在一起 - &user_stack [PAGE_SIZE>>2] 实际上是越界访问,但这样是能够通过编译的。之所以这样写,是考虑到栈“从高地址向低地址增长”的特性。&user_stack [PAGE_SIZE>>2] 的值实际为 &user_stack [PAGE_SIZE>>2 - 1] + 1,代表的是栈的
边界值
。
lss _stack_start,%esp
是将结构体 stact_start 的值传送到ss:esp
,即令 ss=0x10(段选择子)和 esp=& user_stack [PAGE_SIZE>>2]。
栈设置结果如下图所示:
图片来源:《Linux 内核设计的艺术》
设置 IDT
设置 IDT 部分代码如下:
1 | _pg_dir: |
IDT 设置结果如下图所示:
图片来源:《Linux 内核设计的艺术》
另外,有两点需要注意:
- IDT 的基地址(由 lidt 加载到 IDTR)实际为内存对齐后(.align 3,2^3,按 8 字节方式对齐内存)的 _idt 在 head 模块中的偏移值。
- 整个 IDT 都是空的(.fill 256,8,0)。
注:中断描述符格式(也可参考之前博文 Linux 内核学习笔记:预备知识之“存储器管理基础”的“门描述符”小节)
图片来源:《Linux 内核设计的艺术》
设置 GDT
设置 IDT 部分代码如下:
1 | _pg_dir: |
IDT 设置结果如下图所示:
图片来源:《Linux 内核设计的艺术》
另外,有几点需要注意:
跟设置 IDT 的不一样,GDT 共设置了 4 个项,余下的 252 个项初始化为 0。
GDT 第 1、2 个描述符指向的段的段限长由原来的 8MB 扩展为现在的 16MB,如下图所示:
图片来源:《Linux 内核设计的艺术》之前在
startup_32
开始处设置了 ds、es、fs、gs 和 ss,但它们所指向的原描述符所指向的段的段限长为 8MB,所以当访问 8MB 以上的地址空间时,将会产生段限长超限报警。为了防止这类可能发生的情况,在这里需要对段寄存器(段选择子)重新设置:1
2
3
4
5
6
7
8
9
10
11_pg_dir:
startup_32:
......
call setup_idt
call setup_gdt
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp # 重新设置 ss
检查 A20 是否已打开
A20 是否已打开影响到保护模式是否有效。检查代码如下:
1 | _pg_dir: |
《Linux 内核完全注释》对这段程序有一个简明解释:
采用的方法是向内存地址 0x000000 处写入任意一个数值,然后看内存地址 0x100000 (1M)处是否也是这个数值。如果一直相同的话,就一直比较下去,即死循环、死机,表示 A20 线没有选通,结果内核就不能够使用 1MB 以上内存。
检查 x87 协处理器
这部分代码为:
1 | _pg_dir: |
《Linux 内核设计的艺术》对 x87 协处理器有一段简要说明:
为了弥补 x86 系列在进行浮点计算时的不足,Intel 于 1980 年推出了 x87 系列数学协处理器,那时是一个外置的、可选的芯片。1989 年,Intel 发布了 486 处理器。自从 486 开始,以后的 CPU 一般都内置了协处理器。这样,对于 486 以前的计算机而言,操作系统检验 x87 协处理器是否存在就非常有必要了。
设置分页管理系统
这部分代码为:
1 | _pg_dir: /* 标识页目录表存储位置(0x0000 0000,因为 _pg_dir 在 head 模块头部) */ |
这部分程序要分成一下几点进行解读:
页表目录、页表建立
内存清零
首先对 5 页内存(1页目录 + 4 页页表)清零:
1 | .align 2 /* 按 4 (2^2)字节方式对齐内存地址边界 */ |
其中 stosl 解释如下:
stosl
:每次保存的是 4 个字节。- ecx 控制循环次数
- 每次循环将 eax 的值保存到 es:edi (es 为段选择子)指向的内存
- 若 EFLAGS 中的方向标志位 DF=0 (使用 cld 指令),则 edi 自增 4 (因为比较的是 Long,所以递增 4);若 DF=1(使用 std 指令),则 edi 自减 4
- rep 表示当 ecx>0 时,循环继续;反之停止
- 在这个程序中,每循环 1024 次,清零的内存范围是 1024*4=4096 字节,恰好是一个页。
- 注意:xorl %eax,%eax 和 xorl %edi,%edi 只在最循环最开始执行一次
页表目录填充
接下来是将 4 个页表注册到页表目录中:
1 | .org 0x1000 |
因为一个页表目录项为 32 位,所以 _pg_dir 每次递增 4 个字节。
页表填充
页表填充代码如下:
1 | .align 2 |
有几点需要解释下:
- $pg3+4092:表示第 4 个页表的最后一个页表项的起始位置(1023*4)。
- $0xfff007:存储到第 4 个页表的最后一个页表项的内容。这里 7 表示页属性,0xfff000 为该页表项所指向的页基址(也称为页号)。 该地址刚好是 16MB 内存的最后一页的地址。
填充结果如下图所示:
图片来源:《Linux 内核设计的艺术》
另外,每次循环,
- eax(初始值为 $0xfff007,7 为页属性) 递减 $0x1000 (4KB,一个页大小),
- edi(初始值为 $pg3+4092) 按 4 (std,表示 4 个字节)递减
- 将 eax 内容(即页表项)存储到内存 edi 处
这样直到循环结束,刚好能将 4 个页表填满。每个页表有 4KB/4B=1024 个页表项。4 个页表支持的寻址范围为 4 * 1024 * 4KB = 16MB,恰好是 Linux 0.11 支持的寻址范围。
4 个页表最终填充完毕结果如下图所示:
图片来源:《Linux 内核设计的艺术》
设置 CR3 并启动分页机制
这部分代码如下:
1 | .align 2 |
关于 CR0 和 CR3 请参考之前博文 Linux 内核学习笔记:预备知识之“寄存器模型”。
设置完成后结果如下:
图片来源:《Linux 内核设计的艺术》
到这里为止,system 模块在内存中的映像如下图所示:
图片来源:《Linux 内核完全注释》
跳转至 main 函数
这部分代码如下:
1 | after_page_tables: |
分页系统设置完成后,就开始返回(ret
),将 main 函数地址弹出给 EIP:
图片来源:《Linux 内核设计的艺术》
注意:main 函数经 C 语言编译器编译后被符号修饰为_main
。
接下来就开始执行 main 函数
了。敬请查阅接下来博文!
需要注意的是,系统中断现在还是关闭的!