Linux0.00 源码分析

基于赵炯博士《Linux内核完全注释3.0》第四章一个简单的多任务内核实例,对Linux0.00要点进行分析

Posted by Surflyan on 2018-01-25

由于Linux Towards 最初完成的Linux 0.00已经丢失,本文所述的Linux 0.00 版本是赵炯博士根据Linux的时候描述的还原之作。

博主所用Linux 0.00 代码为 intel 汇编语法,采用NASM汇编器,源码可在此处下载

阅读本文,最好一边阅读,一边调试代码,一边观察各寄存器、表、栈的值,一边思考!


1. Linux0.00 源码构成

此内核实例包含两个源码文件:

  • 启动引导程序 boot.s :主要将操作系统的内核代码装入内存,并进入内核代码执行。

  • 保护模式多任务内核程序head.s: 其中实现了两个运行在特权级3 上的任务在时钟中断控制下相互切换运行,并且还实现了在屏幕上显示字符的系统调用。这两个任务A和任务B会调用显示系统在屏幕上显示出字符‘A’和‘B’,10毫秒切换到另一个任务。


2.Linux0.00 是如何启动工作的

当PC的电源打开后,80x86 结构的CPU自动进入实模式,并从地址 0xFFFF0 开始自动执行程序 代码,此地址通常是ROM-BIOS中的地址。BIOS 将执行某些系统包括硬件的检测,并在内存的物理地址为0处开始初始化中断向量。此后,它将可启动设备的第一个扇区(磁盘引导扇区, 512 字节)读入内存物理地址 0x7C00 处,并跳转到这个地方。启动设备通常是软驱或是硬盘。

对应于Linux 0.00, boot.s 程序编译后的代码共512字节,刚好占据一个扇区的位置,并将其放置在映像文件的第一个扇区中,PC加电启动,BIOS自检后,将启动盘的第一个扇区加载到内存的 0x7C00 处,然后跳转到引导程序即物理地址 0x7C00开始执行引导程序。

引导程序boot.s 将映像文件中的 head.s 内核代码加载到内存的 0x10000,然后再将head 代码移动到内存的 0x0000 开始处,加载好临时GDT表等信息后,进入保护模式(设CR0的PE位=1),然后跳转到内核代码中执行。

为什么需要移动或者为什么不一开始就加载内核代码到0x0000呢?将内核代码放置到物理内存0的地方主要是为了设置GDT表简单。而不一开始加载到0x0000是因为加载过程需要BIOS 提供中断调用,而且后期的Linux 版本还需要BIOS的中断调用来获取机器的一些参数。而BIOS的中断向量表被初始化在物理内存的起始处,大小为0x400(1kB)字节,因此只能在加载完成之后才能覆盖掉此位置。

图片来源:赵炯. Linux内核完全注释

3. head.s 工作原理

3.1 初始化设置

在新的位置重新设置了IDT和GDT表,将IDT表第8项和第128项设置为定时中断门描述符和系统调用陷阱门描述符。设置8253定时芯片,使之每隔10ms发送一个中断请求。另分别初始化了任务0和任务1的LDT表内容和TSS段内容。

3.2 从内核程序进入任务0

从内进入任务0执行由 head.s 如下几行(Linux 完全注解:一个简单多任务内核示例48-62行)的完成:

pushf
and dword[esp],0xffffbfff
popf
mov eax,TSS0_SEL
ltr ax
mov eax,LDT0_SEL
lldt ax
mov dword[current],0
sti
push long 0x17
push long init_stack
pushf
push long 0x0f
push long task0
iret

内核程序CPL为0,而任务0的DPL为3,从高特权级到低特权级不能直接切换,但中断返回可以做到,于是我们利用专断返回IRET来进入到任务0。具体的,在初始堆栈 init_satck 中人工设置一个返回的环境。复位EFALGS的任务嵌套标志,把任务0的TSS段选择符加载到TR中,把LDT0加载到LDTR中,之后将任务0的栈指针 SS:ESP(0x17:init_stack) 和的代码指针 CS:EIP(0x0f:task0) 以及标志寄存器 EFLAGS入栈,然后执行中断返回指令IRET。弹出堆栈上的堆栈指针作为任务0的用户栈指针,恢复假设的任务0的标志寄存器的内容,弹出代码指针放入相应寄存器,从而进入任务0开始执行。
根据iret返回后代码段选择子变为 0x0f, 0x0f 为LDT的第二个描述符,根据LDTR中的LDT基地址找到LDT的第二个描述符为任务0的代码段描述符 0x00c0fa00000003ff, 解析此描述符得到任务0代码段线性基地址为 0x00,再和iret 返回后得到的eip的值为 0x10e0,和线性基地址相加得到pc的下一条指令地址 0x10E0,此地址存放的是任务0的第一条指令。

iret执行前栈的内容:

iret执行前栈的内容:

3.3 打印字符

在执行每一个任务时,都会把一个字符的ASCII码放入寄存器AL中,然后调用系统中断(int 0x80),而该系统中断又会调用write_char函数,将字符打印到屏幕上。执行中断完成后,继续返回相应任务循环执行,直到到10ms,时钟中断产生,发生任务切换。任务0存入AL的时字符A,任务B存入的是字符B。

3.4 系统调用

当任务进行系统调用,即进入int 0x80中断处理程序,执行system_interrupt 函数。

3.4.1 进入系统中断

从任务0和任务1都可进入int 0x80,下面以从task0进入系统中断说明。

执行int 0x80 由任务0的用户栈进入任务0的内核栈。

  • 系统调用前:栈空间 ss = 0x17,esp = 0x0bd8
  • 系统调用后:栈空间 ss = 0x10, esp = 0x0e4c

进入 system_interrupt 时,中断代码段的DPL为0,而现在的特权级CPL为3,程序转移到一个特权级更高的代码段,会发生堆栈切换。切换过程如下:

  1. 使用system_interrupt代码段的DPL, 从当前的TSS0中选择中取出新的特权级为0的栈段选择符0x10,栈指针krn_stk0。
  2. 临时保存当前的 SS(0x17)和 ESP(0x0bd8), 将新栈的段选择符0x10和栈指针krn_stk0加载到SS和ESP,然后将临时保存的之前的栈段选择符 0x17 和栈指针 0x0bd8 压入新栈中;
  3. 将EFLAGS压入新栈中;
  4. 把返回指令指针也就是当前的 CS(0x0f)和 EIP(0x10eb) 压入新栈。
  5. 将 system_interrupt 代码段选择符(门描述符中的选择符)加载到CS中,同时将门描述符中偏移值也就是新指令的指针加载到EIP中。
  6. 开始执行系统中断程序。

3.4.2 退出系统中断

退出system_interrupt, 执行iret时,由任务0的内核栈进入任务0的用户栈。

  • 退出前:ss = 0x10, esp = 0x0e4c
  • 推出后:ss = 0x17,esp = 0x0bd8

退出system_interrupt,当前中断代码段CPL=0,要返回的task的代码段DPL=3,从高特权级到低特权级,使用iret指令。会发生堆栈转换。执行iret指令,将当前栈中的保存的CS、EIP、EFLAGS、SS、ESP加载到相应的寄存器中,从而切回任务0,堆栈也切回之前。

3.5 任务切换

初始时设置了定时器芯片8253每隔10ms向中断控制芯片8259A发送一个时钟中断请求。时钟中断请求在BIOS开机时就已经被8259A设置为中断向量8,因此执行第8号中断向量,而在内核进行初始设置时,就设置了8号中断向量门描述符。当时中断发生时,timer_interrupt代码段选择符(中断门描述符中的选择符)加载到CS中,同时将门描述符中偏移值也就是新指令的指针加载到EIP中。从而进入timer_interrupt执行。查看current变量,current保存的时当前执行的任务号,根据current,切换到另一个任务。若current是0,则执行 ljmp $TSS1_SEL $0,将任务1的TSS选择符作为远跳转指令的操作数,转入任务1。具体来看如何切换。

3.5.1 第一次从任务0切换到任务1

切换前任务0 TSS0的值,即初始在内核代码中定义的TSS0的值为:

切换到任务1 TSS1的值,即初始在内核代码中定义的TSS1的值为:

在任务0执行10ms后,时钟中断产生,与系统中断类似,由于当前代码段和要进入的时钟中断特权级不同,发生栈切换。将当前的CS,EIP,EFLAGS,SS,ESP压入从TSS0取出的应特权级的栈,加载好CS,EIP,SS,ESP后,进入时钟中断代码段执行。
保护好用到的寄存器、加载内核数据段DS、开中断后,判断当前任务为0,执行 ljmp TSS1_SEL指令,进行任务切换。过程如下:

  1. 根据jmp指令取得任务1的TSS段选择符TSS1_SEL(0x30),在gdt找到该描述符(0x0000e9000e780068),检查CPL=DPL(3), 并且新任务也就是任务1的描述符标注为存在(P=1),TSS段长度有效(0x68>0x67);6
  2. 由JMP引起的任务切换,将当前任务0的TSS描述符的忙标志B复位;
  3. 从TR取得当前任务0的TSS的基地址,并且把现在的所有通用寄存器、段寄存器中的选择符、标志寄存器EFLAGS以及指令指针EIP复制到TSS0;
  4. 由JMP引起的任务切换,将任务1的TSS描述符的忙标志B置位;
  5. 将任务1的段选择符和描述符加载到TR寄存器;
  6. 将任务1的TSS状态也就是各寄存器的值加载进处理器;
  7. 开始任务1。

3.5.2 第一次从任务1切换到任务0

切换前任务1 TSS1的值,与进入TSS1时值相同,执行切换前当前的各个寄存的值还未写入。
切换到任务0 TSS0的值,即上次离开任务0 时,保存的值:

从任务1切回任务0,进入时钟中断timer_interrupt, 与之前处理过程类似,不同的是在判断当前执行任务为1时,跳转到指令ljmp $TSS0_SEL,$0 执行,同样将当前各寄存器写到任务1的TSS中,然后找到任务0的TSS描述符,将任务0的TSS加载到处理器中,接着之前任务1执行到的地方开始执行。任务1在上次跳转前执行到 jmp 2f 指令,继续执行到iret 从时钟中断返回。将当前任务0的内核栈之前执行时钟中断压入的CS,EIP,SS,ESP等寄存器的值重新加载到相应寄存器,返回任务0时钟中断前执行的地方,由于在任务
中设置了延时指令,所以极大的概率上次是执行到延时指令被中断,于是假设上次就是从延时指令中断的。所有中断返回继续从延时指令之后进行执行。

3.5.3 第二次从任务0进入任务1

执行任务1又到了10ms,进入时钟中断,切换到任务1,重新加载任务0的TSS到寄存器,任务1上次从时钟中断 pop1 %eax被切走,返回到该处继续执行。


4. head.s 内存分布

图片来源:赵炯.Linux 内核完全注释

值得注意的是,在这个内核实例中,根据段描述符的定义,”内核”的代码段和数据段与两个任务的代码段和数据段都是从线性地址0开始的,但是实际在内存的分布是依次各占据一段内存。

具体的内存分布博主整理如下(博主使用的 Linux0.00 intel 语法版本采用 NASM汇编器):

代码段 名称 起始地址 终止地址
1 starup_32 0x0000 0x00AC
2 setup_gdt 0x00AD 0x00B4
3 setup_idt 0x00B5 0x00E3
4 write_char 0x00E4 0x0113
5 ignore_int 0x0114 0x0129
5 timer_interrupt 0x012C 0x0167
6 system_interrupt 0x0168 0x017E
7 task0 0x10E0 0x10F2
8 task1 0x10F4 0x10FF
数据段 名称 起始地址 终止地址
1 current 0x017F 0x0182
2 scr_loc 0x0183 0x0186
3 lidt_opcode 0x0188 0x018D
4 lgdt_opcode 0x018E 0x0193
5 idt 0x0198 0x0997
6 gtd 0x0998 0x09D7
7 ldt0 0x0BE0 0x0BF7
8 tss0 0x0BF8 0x0C57
9 ldt1 0x0E60 0x0E77
10 tss1 0x0E78 0x0EDF
堆栈段 名称 起始地址 终止地址
1 Init_stack(User_stk0) 0x9d8 0x0BD7
2 Krn_stk0 0xC60 0xe0E5F
3 Krn_stk1 0x0EE0 0x10DF
4 User_stk1 0x1101 0x1300

Reference

  1. Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B & 3C)
  2. 赵炯,Linux 内核完全注释

请多多指教!