本文接着上一博文 Linux 内核学习笔记:进程 1 创建及执行(第 2 部分)最后的“调用 sys_setup”继续写下去。
调用 sys_setup
根据 sys_setup 函数,我们分成几个部分来对其进行解释。
进程 1 设置硬盘的 hd_info
阅读这部分,请先参考以下两篇博文有关“硬盘”部分:
Linux 内核学习笔记:预备知识之“硬盘基础知识”
Linux 内核学习笔记:内核引导程序之“setup.s”
参考以下博文有关“CMOS”部分:
Linux 内核学习笔记:初始化程序(第 2 部分)
这部分代码如下:
1 | // kenel/blk_drv/hd.c -------------------------- |
读取硬盘引导块到缓冲区
在 Linux 0.11 中,硬盘最基础的信息就是分区表
,其他信息都可以从这个信息引导出来。这个信息所在的块就是引导块
。一块硬盘只有唯一的一个引导快,即硬盘的 0 号逻辑块。引导块占用两个扇区,真正有用的是第一个扇区。
一个硬盘分区的例子如下:
图片来源:《Linux 内核完全注释》(编辑过)
注:为了更好理解接下来内容,可以先查看之前博文 Linux 内核学习笔记:初始化程序(第 4 部分)“缓冲区结构初始化”部分。
调用 bread
下面程序把引导块读入缓冲区,以便后续程序解读引导块中的信息。这个工作是通过 bread 函数(bread 可理解为 block read)实现的:
1 | // kenel/blk_drv/hd.c -------------------------- |
bread 函数的执行流程框图如下所示:
图片来源:《Linux 内核完全注释》
调用 getblk
getblk 函数定义如下:
1 | // fs/buffer.c --------------------------------- |
getblk 函数执行流程图如下所示:
图片来源:《Linux 内核完全注释》
为了完整,特地将 getblk 函数调用的函数摘录:
1 | // 请参考之前博文: Linux 内核学习笔记:初始化程序(第 4 部分)“缓冲区结构初始化”部分以了解 Hash 表 |
调用 ll_rw_block
getblk 函数返回后,已经为“指定设备号(dev)、块号(block)”申请到一个缓冲块,但该缓冲块的数据还没更新。接下来开始执行下边代码:
1 | // fs/buffer.c --------------------------------- |
调用 make_request
make_request 函数定义如下:
1 | // kernel/blk_drv/ll_rw_block.c ---------------- |
调用 add_request
add_request 函数定义如下:
1 | // kernel/blk_drv/ll_rw_block.c ---------------- |
调用 do_hd_request
do_hd_request 函数定义如下:
1 | // kernel/blk_drv/hd.c ------------------------- |
调用 hd_out
hd_out 函数定义如下:
1 | // kernel/blk_drv/hd.c ------------------------- |
do_hd 定义如下:
1 | // kernel/system_call.s ------------------------ |
需要注意的是,**现在只是将硬盘中断服务程序挂接到 read_intr,同时给硬盘发命令,指明要读取的数据。接下来,硬盘将指定的数据读取到硬盘缓冲区。这个过程很耗时,而且不用 CPU 干预,所以 CPU 可以去做其他事情。
**
接下来,CPU 利用硬盘读取数据的空档,去做点其他事情。
题外:硬盘
一直以来对(单) CPU 能够在读取硬盘同时还能够去做其他事情感到很奇怪,但通过搜索提示,突然想通了:
- CPU 通过“硬盘驱动程序”给硬盘下达读数据的指令
- 硬盘开始寻找要读取的扇区并读取数据,且把这些数据存放在
硬盘缓冲区
- 第 2 步是一个很耗时而且
不用 CPU 干预
的过程,所以 CPU 在这段时间可以去做其他事情 - 硬盘把数据读取到
硬盘缓冲区
完毕后,给 CPU 发中断 - CPU 中断正在做的其他事,去把
硬盘缓冲区
的数据读取到”内存缓冲区”(如用 rep insw 指令),整个数据读取过程结束。
硬盘每次只读取一个扇区(512B),但这过程包括了硬盘的寻道(Seek time)、旋转延迟(Rotational latency time)的过程,对时间消耗远大于 CPU 执行执行的速度。
为了看看有没有更好的解释,还特地在 Quora 上提了一个问题:How can a CPU do other things while reading hard disk?。好快就有一个叫 Andrew-Daviel 解答了(太 Nice):
- A couple of ways. Modern operating systems are multitasking. They also have multiple cores (multiple CPUs), so not only can they appear to do two things at once by splitting time between two tasks (do a bit of one task, get a clock interrupt, switch to another task for a bit, get another interrupt, swap back etc.), they can do two things at once for real because one task can run on one CPU and another task on another.
- Also, disks use DMA (direct memory access). The disk controller is able to transfer data directly from the disk to the computer RAM and interrupt the CPU when it is finished. Computer buses are able to apparently do two things at ones since the different bus masters (CPU, disk controller, video controller, network interface etc.) negotiate for access to the bus and send data words or data bursts interleaved with other devices. Much the same as traffic-controlled lights in a city allow cars to cross an intersection. When you run a computer program, you are like the passenger on a bus - driving the bus and knowing when to stop for red lights is the driver’s problem, not yours.
切换到进程 0
如果程序继续执行,则需要对引导块中的数据进行操作。但因为硬盘的读写速度远低于 CPU 执行指令的速度(低 2~3 个数量级),所以在从硬盘读取数据过程中,会调用 wait_on_buffer 函数,挂起等待;然后 CPU 切换到其他进程执行:
1 | // fs/buffer.c --------------------------------- |
当执行到 sleep_on 函数时,将当前任务(进程 1)的状态设置为不可中断的等待状态,并调用 schedule 函数进行任务调度。但这里有个问题,就是系统目前只有进程 0 和进程 1,进程 1 已经被挂起,进程 0 处于 TASK_INTERRUPTIBLE 状态(见 sys_pause 函数),都不具备 schedule 函数中的条件 (*p)->state == TASK_RUNNING && (*p)->counter > c
,所以理论上无法从进程 1 切换到进程 0,也没有进程可以运行。
不过按照《Linux 内核设计的艺术》的说法,操作系统会强行切换到进程 0
。具体原因个人也不清楚。Linus 在 init/main.c 函数为 for(;;) pause();
所写的注释倒可以作为解释:
For any other task ‘pause()’ would mean we have to get a signal to awaken, but task0 is the sole exception (see ‘schedule()’) as task 0 gets activated at every idle moment (when no other tasks can run). For task0 ‘pause()’ just means we go check if some other task can run, and if not we return here.
在之前从进程 0 切换到进程 1 的时候,进程 0 的 TSS 会保存当时进程 0 运行时的寄存器的值,所以切换回进程 0 的时候便可以根据 TSS 来恢复当时进程 0 运行时的场景,即从 "cmpl %%ecx,_last_task_used_math\n\t"
这一句开始执行:
1 | // include/linux/sched.h ----------------------- |
在之前从进程 0 切换到进程 1 ,函数调用顺序是 pause -> sys_pause -> schedule -> switch_to(1)。现在已经执行完 switch_to(1) 后半部分,就应该返回到 sys_pause、for(;;) pause() 中执行了。
pause 这个函数在 for(;;) 这个循环中被反复调用,所以,会继续调用 schedule 函数进行进程切换。但系统现在仅有的两个进程 0 和 1 都不是处于就绪态,不过操作系统这时会强行切换至进程 0。需要注意的是在这循环的过程中,switch_to 也被反复调用,不过因为现在是进程 0 在运行,而且要“切换”到的也是进程 0,所以 switch_to 每次基本算是被跳过:
1 | // include/linux/sched.h ----------------------- |
硬盘中断
在循环 for(;;) 执行了一段时间后,硬盘在某一个时刻把一个扇区的数据读取到硬盘缓冲区
,产生硬盘中断。CPU 接到中断指令后,终止正在执行的程序。终止的位置肯定在 pause、sys_pause、schedule、switch_to(0) 某处。不过,中断会自动压栈(ss esp eflags cs eip),中断处理完之后就能够返回到原来程序中继续执行。
接着,开始处理中断,执行 do_hd 绑定的中断服务程序:
1 | // kernel/system_call.s ------------------------ |
调用 read_intr
read_intr 函数通过硬盘驱动程序,从硬盘缓冲区
将数据读取到内存缓冲区中。read_intr 函数定义如下:
1 | // kernel/blk_drv/hd.c ------------------------- |
- AT 硬盘控制器寄存器端口
上面程序 port_read 函数涉及到的AT 硬盘控制器寄存器端口
有如下这些:1
2
3
4
5
6
7
8
9
10
11
12
13
14// include/linux/hdreg.h -----------------------
/* Hd controller regs. Ref: IBM AT Bios-listing */
这些端口可用图表示如下:
图片来源:《Linux 内核完全注释》
其中,数据寄存器(HD_DATA,0x1f0)
是一对高速 PIO 数据传输器,用于扇区读、写和磁道格式化操作。CPU 通过该数据寄存器向硬盘写入或从硬盘读出 1 个扇区的数据,也即要使用命令 rep outsw
或 rep insw
重复读/写 cx = 256 字。
读取数据流方向:
read_intr -> hd_out -> do_hd_request -> add_request -> make_request -> ll_rw_block -> bread
。read_intr 读取完 512 字节就返回了,也即中断返回。接下来接着进程 0 被硬盘中断中断的地方(pause、sys_pause、schedule、switch_to(0))继续执行。
- 如此反复,最终将引导块完整读入内存缓冲区的缓冲块。
调用 end_request
此时,所有数据已经读取完毕,开始执行 end_request 函数:
1 | // kernel/blk_drv/hd.c ------------------------- |
需要注意的是,执行完 end_request 函数还会再次执行 do_hd_request 函数,以执行其它硬盘请求操作(因为同一设备现在可能有多个任务请求)。一个样例如下图所示:
图片来源:《Linux 内核设计的艺术》
不过在这里,因为现在只有进程 1 有硬盘请求操作,所以一旦进程 1 请求完毕,就没有其他任务的硬盘请求操作了。
切换到进程 1
read_intr 读取完所有数据(1024B,分两次读)就返回了,也即中断返回。接下来接着进程 0 被硬盘中断中断的地方(pause、sys_pause、schedule、switch_to(0))继续执行。进程 0 通过 for(;;) 循环,迟早会执行到 schedule 函数。而此时,进程 0 处于 TASK_INTERRUPTIBLE 状态,进程 1 处于就绪态 TASK_RUNNING。所以一旦进程 0 执行到 schedule 函数,就会切换到进程 1。
在从进程 1 切换到进程 0 的时候,进程 1 的 TSS 会保存当时进程 1 运行时的寄存器的值,所以切换回进程 1 的时候便可以根据 TSS 来恢复当时进程 1 运行时的场景,即从 “cmpl %%ecx,_last_task_used_math\n\t” 这一句开始执行:
1 | // include/linux/sched.h ----------------------- |
返回 bread
切换回进程 1 之后,根据进程 1 TSS 保存的寄存器值,(参考本文“切换到进程 0 前的进程 1 的执行过程”)执行路线是:switch_to(0) -> sleep_on -> wait_on_buffer -> bread:
1 | // fs/buffer.c --------------------------------- |
返回 sys_setup
1 | // kenel/blk_drv/hd.c -------------------------- |
进程 1 格式化虚拟盘并更换根设备为虚拟盘
以防博文过长,接下来内容请见续篇。