10|进入 main 函数前的最后一跃!
你好,我是闪客。
这一讲,我们将欣赏从汇编语言跳转到 C 语言的最后几行代码,并且对整个第一部分做个回顾。
这样就跳跃到了主函数
上一讲我们设置好了idt、gdt、页表,还开启了保护模式。那么接下来,我们怎么跳到主函数呢?
来看看设置分页代码的那个地方(head.s 里),后面这个操作就是用来跳转到 main.c的。
after_page_tables:
push 0
push 0
push 0
push L6
push _main
jmp setup_paging
...
setup_paging:
...
ret
直接解释起来非常简单。
push 指令就是压栈,五个 push 指令过去后,栈会变成这个样子。

然后你要注意,setup_paging 最后一个指令是 ret,也就是我们上一讲设置分页的代码里的最后一个指令,形象地说它叫返回指令。不过 CPU 可没有那么聪明,它并不知道该返回到哪里执行,只是很机械地把栈顶的元素值当做返回地址,跳转去那里执行。
我再描述一下具体细节,CPU会把 esp 寄存器(栈顶地址)所指向的内存处的值,赋值给 eip 寄存器,而 cs:eip 就是 CPU 要执行的下一条指令的地址。而此时栈顶刚好是 main.c 里写的 main 函数的内存地址,是我们刚刚特意压入栈的,所以 CPU 就自然而然跳过来了。
当然 Intel CPU 是设计了 call 和 ret 这一配对儿的指令,意为调用函数和返回。具体可以阅读 Intel 手册 Volumn 1 Chapter 6.4 CALLING PROCEDURES USING CALL AND RET。
压栈和出栈的具体过程,手册上面写得清清楚楚,还非常友好地放了张图。

我们这一讲用的是 Near Call,也就是左半边的图。
回到正题,继续看我们的压栈内容。

除了 main 函数的地址压栈外,其他压入栈中的数据(比如 L6),是 main 函数返回时的跳转地址,但由于在操作系统层面的设计上,main 是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0,本意是作为 main 函数的参数,但实际上似乎也没有用到,所以你也不必关心。
主函数内容先睹为快
总之,经过这一个小小的骚操作,程序终于跳转到 main.c 这个由 C 语言写就的主函数 main 里了!让我们一睹为快:
void main(void) {
ROOT_DEV = ORIG_ROOT_DEV;
drive_info = DRIVE_INFO;
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;
mem_init(main_memory_start,memory_end);
trap_init();
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init();
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}
没错,这就是这个 main 函数的全部了。
从代码行数来说,最多的就是各种 init 函数,即各个模块的初始化函数;再之后的 fork 会创建一个新的进程,这个过程也是操作系统的一大难点。再往后的 init 操作,最终会启动一个 shell 程序与用户进行交互,这标志着操作系统建立完毕,达到了一个用户可用的状态。而整个操作系统也会最终停留在最后一行死循环中,永不返回,直到关机。
这些算是这个大系列后面内容的预告,内容会比第一季更加精彩,敬请期待。
好了,至此,整个第一部分就圆满结束了,因为这些都是为了跳进 main 函数的准备工作,所以我称之为进入内核前的苦力活,就完成了!来看看我们都做了什么。

我把这些称为进入内核前的苦力活,经过这样的流程,内存变成了后面这样:

之后,main.c 里的 main 函数就开始执行了,靠着我们辛辛苦苦建立起来的内存布局,向崭新的未来前进!至此,整个第一部分也就结束了!
第一部分总结与回顾
最后,我们回顾一下10讲的全部内容。
把操作系统请到内存中
当你按下开机键的那一刻,在主板上提前写死的固件程序 BIOS 会将硬盘中启动区的 512 字节的数据,原封不动复制到内存中的 0x7c00 这个位置,并跳转到那个位置去执行:
有了这个步骤之后,我们就可以把代码写在硬盘第一扇区,让 BIOS 帮我们加载到内存并由 CPU 去执行,我们不用操心这个过程。
而这一个扇区的代码,就是操作系统源码中最最最开始的部分,它可以执行一些指令,也可以把硬盘的其他部分加载到内存,其实本质上也是执行一些指令。这样,整个计算机今后如何运作,就完全掌控在我们自己手中,想怎么玩就怎么玩了。
接下来,直到第4讲,我们才讲到整个操作系统的编译和加载过程的全貌,就是下面这张图:

而我们这一季全部内容,其实就在讲 boot 文件夹下的三个汇编文件的内容,bootsect.s,setup.s 以及后面要和其他全部操作系统代码做链接的 head.s。
前5讲一直在调整内存的布局,把这块内存复制到那块,又把那块内存复制到这块,所以在第5讲的结尾,我让你记住这样一张图,在很长一段时间里,这个内存布局的大体框架都不会再变了。只要记住这张图,前5讲你抛在脑后也没关系。

保护模式与内存管理
从第6讲往后,操作系统逐渐进入保护模式,并设置分段、分页、中断等机制的地方。最终的内存布局变成了这个样子。

你看,idtr 寄存器指向了 idt,这个就是中断的设置;gdtr 寄存器指向了 gdt,这个就是全局描述符表的设置,可以简单理解为分段机制的设置;cr3 寄存器指向了页目录表的位置,这个就是分页机制的设置。
中断的设置,就引出了 CPU 与操作系统处理中断的流程。

分段和分页的设置,就引出了逻辑地址到物理地址的转换。

具体来说,逻辑地址到线性地址的转换,依赖 Intel 的分段机制。

而线性地址到物理地址的转换,依赖 Intel 的分页机制。

分段和分页,就是 Intel 管理内存的两大利器,也是内存管理最最最底层的支撑。
而 Intel 本身对于访问内存就分成三类:代码、数据、栈。而 Intel 也提供了三个段寄存器来分别对应这三类内存:代码段寄存器(cs)、数据段寄存器(ds)、栈段寄存器(ss)。
具体来说:
- cs:eip 表示了我们要执行哪里的代码。
- ds:xxx 表示了我们要访问哪里的数据。
- ss:esp 表示了我们的栈顶地址在哪里。
而第一部分的代码,也做了如下工作:
- 将 ds 设置为了 0x10,表示指向了索引值为 2 的全局描述符,即数据段描述符。
- 将 cs 通过一次长跳转指令设置为了 8,表示指向了索引值为 1 的全局描述符,即代码段描述符。
- 将 ss:esp 这个栈顶地址设置为 user_stack 数组的末端。
你看,分段和分页,以及这几个寄存器的设置,其实本质上就是安排我们今后访问内存的方式,做了一个初步规划,包括去哪找代码、去哪找数据、去哪找栈,以及如何通过分段和分页机制将逻辑地址转换为最终的物理地址。
而上面说的这一切,和 Intel CPU 这个硬件打交道比较多,设置了一些最基础的环境和内存布局,为之后进入 main 函数做了充分的准备。因为虽然 C 语言很底层了,但它也有自己不擅长的事情,需要交给汇编语言来做,这些工作就是进入内核前的苦力活。
参考资料
你可能好奇,同样是看源码,为啥课程里我好像“知道很多”?其实这离不开边看源码,边研读手册的习惯。
和第一季内容强相关的内容,我已经为你做了整理:
Intel手册下载地址:https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
有关寄存器的详细信息,可以参考 Intel 手册:
Volume 1 Chapter 3.2 OVERVIEW OF THE BASIC EXECUTION ENVIRONMEN
如果想了解计算机启动时详细的初始化过程,可以参考 Intel 手册:
Volume 3A Chapter 9 PROCESSOR MANAGEMENT AND INITIALIZATION
如果想了解汇编指令的信息,可以参考 Intel 手册:
Volume 2 Chapter 3 ~ Chapter 5
保护模式下逻辑地址到线性地址(不开启分页时就是物理地址)的转化,看 Intel 手册:
Volume 3 Chapter 3.4 Logical And Linear Addresses
关于逻辑地址-线性地址-物理地址的转换,可以参考 Intel 手册:
Volume 3A Chapter 3 Protected-Mode Memory Management
段描述符结构和详细说明,看 Intel 手册:
Volume 3 Chapter 3.4.5 Segment Descriptors
页目录表和页表的具体结构,可以看 :
Volume 3A Chapter 4.3 32-bit paging
关于 ret 指令,其实 Intel CPU 是配合 call 设计的,有关 call 和 ret 指令,即调用和返回指令,可以参考 Intel 手册:
Volume 1 Chapter 6.4 CALLING PROCEDURES USING CALL AND RET
Linux 0.11 的代码,就是照着 Intel 手册敲出来的,我们不看手册去研究代码,就和不看产品设计文档就去敲代码一样。你浏览到网络资料后,感觉到云里雾里的很多知识点,在 Intel 手册里都可以找到详细且严谨的答案。
最后,希望你再看看 Linux 0.11 最开始的这一大段代码,就像看自己的亲儿子一样熟悉。欢迎你在留言区说说你的学习体会,或者聊聊你的心得与建议!