Linux操作系统学习笔记(二)内核运行

一. 前言

  上文中,我们分析了从按下电源键到BootLoader完成加载的过程。加载完成之后,就要正式启动Linux内核了,而在这之前首先要完成从实模式到保护模式的切换。本文主要分析以下几部分内容

  • 新旧中断的交替
  • 打开A20
  • 进入main函数
  • 内核初始化

  其实整个过程中还有很多内容,比如检查各种硬件设备等,在此略过不提。下面就开始潜入Linux源码的海洋畅游啦。

二. 新旧中断的交替

  在实模式下的中断显然不可以和保护模式的中断同日而语,因此我们需要关闭旧的中断(cli)并确立新的中断(sti)。main函数能够适应保护模式的中断服务体系被重建完毕才会打开中断,而那时候响应中断的服务程序将不再是BIOS提供的中断服务程序,取而代之的是由系统自身提供的中断服务程序。

  cli、sti总是在一个完整操作过程的两头出现,目的是避免中断在此期间的介入。接下来的代码将为操作系统进入保护模式做准备。此处即将进行实模式下中断向量表和保护模式下中断描述符表(IDT)的交接工作。试想,如果没有cli,又恰好发生中断,如用户不小心碰了一下键盘,中断就要切进来,就不得不面对实模式的中断机制已经废除、保护模式的中断机制尚未完成的尴尬局面,结果就是系统崩溃。cli、sti保证了这个过程中,IDT能够完整创建,以避免不可预料中断的进入造成IDT创建不完整或新老中断机制混用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#boot/setup.s
……
do _move:
mov es,ax!destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax!source segment
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
jmp do_move

  如上代码主要完成了一项工作:将位于0x10000处的内核程序复制至内存地址起始位置0x00000处。在上一节我们分析了实模式中的存储分布图,在此位置原来存放着由BIOS建立的中断向量表及BIOS数据区。这个复制动作将BIOS中断向量表和BIOS数据区完全覆盖,使它们不复存在。这样做的好处如下:

  1. 废除BIOS的中断向量表,等同于废除了BIOS提供的实模式下的中断服务程序。
  2. 收回刚刚结束使用寿命的程序所占内存空间。
  3. 让内核代码占据内存物理地址最开始的、天然的、有利的位置。

  此时,重要角色要登场了,他们就是中断描述符表IDT和全局描述符表GDT

  • GDT(Global Descriptor Table,全局描述符表),在系统中唯一的存放段寄存器内容(段描述符)的数组,配合程序进行保护模式下的段寻址。它在操作系统的进程切换中具有重要意义,可理解为所有进程的总目录表,其中存放每一个任务(task)局部描述符表(LDT, Local Descriptor Table)地址和任务状态段(TSS, Task Structure Segment)地址,完成进程中各段的寻址、现场保护与现场恢复。GDTR是GDT基地址寄存器,当程序通过段寄存器引用一个段描述符时,需要取得GDT的入口,GDTR标识的即为此入口。在操作系统对GDT的初始化完成后,可以用LGDT(Load GDT)指令将GDT基地址加载至GDTR。

  • IDT(Interrupt Descriptor Table,中断描述符表),保存保护模式下所有中断服务程序的入口地址,类似于实模式下的中断向量表。IDTR(IDT基地址寄存器),保存IDT的起始地址。

  32位的中断机制和16位的中断机制,在原理上有比较大的差别。最明显的是16位的中断机制用的是中断向量表,中断向量表的起始位置在0x00000处,这个位置是固定的;32位的中断机制用的是中断描述符表(IDT),位置是不固定的,可以由操作系统的设计者根据设计要求灵活安排,由IDTR来锁定其位置。GDT是保护模式下管理段描述符的数据结构,对操作系统自身的运行以及管理、调度进程有重大意义。

  此时此刻内核尚未真正运行起来,还没有进程,所以现在创建的GDT第一项为空,第二项为内核代码段描述符,第三项为内核数据段描述符,其余项皆为空。IDT虽然已经设置,实为一张空表,原因是目前已关中断,无需调用中断服务程序。此处反映的是数据“够用即得”的思想。

创建这两个表的过程可理解为是分两步进行的:

  1. 在设计内核代码时,已经将两个表写好,并且把需要的数据也写好。此处的数据区域是在内核源代码中设定、编译并直接加载至内存形成的一块数据区域。专用寄存器的指向由程序中的lidt和lgdt指令完成
  2. 将专用寄存器(IDTR、GDTR)指向表。

三. A20

  A20启用是一个标志性的动作,由上文提到的lzma_decompress.img 调用 real_to_prot启动。打开A20,意味着CPU可以进行32位寻址,最大寻址空间为4 GB。注意图1-19中内存条范围的变化:从5个F扩展到8个F,即0xFFFFFFFF(4 GB)。

  实模式下,当程序寻址超过0xFFFFF时,CPU将“回滚”至内存地址起始处寻址(注意,在只有20根地址线的条
件下,0xFFFFF+1=0x00000,最高位溢出)。例如,系统的段寄存器(如CS)的最大允许地址为0xFFFF,指令指针(IP)的最大允许段内偏移也为0xFFFF,两者确定的最大绝对地址为0x10FFEF,这将意味着程序中可产生的实模式下的寻址范围比1 MB多出将近64 KB(一些特殊寻址要求的程序就利用了这个特点)。这样,此处对A20地址线的启用相当于关闭CPU在实模式下寻址的“回滚”机制。如下所示为利用此特点来验证A20地址线是否确实已经打开。注意此处代码并不在此时运行,而是在后续head运行过程中为了检测是否处于保护模式中使用。

1
2
3
4
5
6
7
8
#boot/head.s
……
xorl %eax,%eax
1:incl%eax#check that A20 really IS enabled
movl %eax,0x000000#loop forever if it isn't
cmpl %eax,0x100000
je 1b
……

  A20如果没打开,则计算机处于20位的寻址模式,超过0xFFFFF寻址必然“回滚”。一个特例是0x100000会回滚到0x000000,也就是说,地址0x100000处存储的值必然和地址0x000000处存储的值完全相同。通过在内存0x000000位置写入一个数据,然后比较此处和1 MB(0x100000,注意,已超过实模式寻址范围)处数据是否一致,就可以检验A20地址线是否已打开。

四. 进入main函数

  这里涉及到一个硬件知识:在X86体系中,采用的终端控制芯片名为8259A,此芯片,是可以用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断,在不增加其他电路的情况下,最多可以级联成64级的向量优先级中断系统。CPU在保护模式下,int 0x00~int 0x1F被Intel保留作为内部(不可屏蔽)中断和异常中断。如果不对8259A进行重新编程,int 0x00~int 0x1F中断将被覆盖。例如,IRQ0(时钟中断)为8号(int 0x08)中断,但在保护模式下此中断号是Intel保留的“Double Fault”(双重故障)。因此,必须通过8259A编程将原来的IRQ0x00~IRQ0x0F对应的中断号重新分布,即在保护模式下,IRQ0x00~IRQ0x0F的中断号是int 0x20~int 0x2F。

  setup程序通过下面代码将CPU工作方式设为保护模式。这里涉及到一个CR0寄存器:0号32位控制寄存器,放系统控制标志。第0位为PE(Protected Mode Enable,保护模式使能)标志,置1时CPU工作在保护模式下,置0时为实模式。将CR0寄存器第0位(PE)置1,即设定处理器工作方式为保护模式。CPU工作方式转变为保护模式,一个重要的特征就是要根据GDT决定后续执行哪里的程序。前文提到GDT初始时已写好了数据,这些将用来完成从setup程序到head程序的跳转。

1
2
3
4
#boot/setup.s
mov ax,#0x0001!protected mode(PE)bit
lmsw ax!This is it!
jmpi 0,8!jmp offset 0 of segment 8(cs)

  head程序是进入main之前的最后一步了。head在空间创建了内核分页机制,即在0x000000的位置创建了页目录表、页表、缓冲区、GDT、IDT,并将head程序已经执行过的代码所占内存空间覆盖。这意味着head程序自己将自己废弃,main函数即将开始执行。具体的分页机制因为较为复杂,所以打算放在后续介绍内存管理的部分再单独介绍

  head构造IDT,使中断机制的整体架构先搭建起来(实际的中断服务程序挂接则在main函数中完成),并使所有中断服务程序指向同一段只显示一行提示信息就返回的服务程序。从编程技术上讲,这种初始化操作,既可以防止无意中覆盖代码或数据而引起的逻辑混乱,也可以对开发过程中的误操作给出及时的提示。IDT有256个表项,实际只使用了几十个,对于误用未使用的中断描述符,这样的提示信息可以提醒开发人员注意错误。

  除此之外,head程序要废除已有的GDT,并在内核中的新位置重新创建GDT。原来GDT所在的位置是设计代码时在setup.s里面设置的数据,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,将来GDT的内容肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存中唯一安全的地方就是现在head.s所在的位置了。

  下来步骤主要包括

  1. 初始化段寄存器和堆栈

    主要包括将DS和ES寄存器指向相同的地址,并将DS和CS设置为相同的值。

  2. 清零eflag寄存器以及内核未初始化数据区

  3. 调用decompress_kernel()解压内核映像并跳转至0X00100000处。

  4. 段寄存器初始化为最终值并填充BSS字段为0

  5. 初始化临时内核页表

  最终完成了分页机制初始化后,PG(Paging) 标志位将会置1,表示地址映射模式采取分页机制,最终跳转至main函数,内核开始初始化工作。

五. 内核初始化

  注意,至此为止,我们尚未打开中断,而必须通过main函数完成一系列的初始化后才会打开新的中断,从而使内核正式运行起来。该部分主要包括:

  1. 为进程0建立内核态堆栈

  2. 清零eflags寄存器

  3. 调用setup_idt()用空的中断处理程序填充IDT

  4. 把BIOS中获得的参数传递给第一个页框

  5. 用GDT和IDT表填充寄存器

  完成这些之后,内核就正式运行,开始创建0号进程了。

六. 总结

  本文介绍了实模式到保护模式的整个切换过程,完成了内核的加载并开始正式准备创建0号进程。后续将继续分析启动内核创建0号、1号、2号进程的整个过程。本文介绍过程中忽略了很多汇编代码以及一些虽然很重要但是不属于基本流程的知识,有兴趣了解的可以根据文中链接、文末的源码和参考资料进行更深入的学习研究。

源码资料

[1] GURB 2

[2] syslinux

参考资料

[1] Linux-insides

[2] 深入理解Linux内核

[3] Linux内核设计的艺术

[4] 极客时间 趣谈Linux操作系统

坚持原创,坚持分享,谢谢鼓励和支持