文章目录

  • 项目目录
  • 1. 实验介绍
    • 1.1 生成可执行文件
    • 1.2 创建 map 文件
    • 1.3 制作硬盘镜像文件
  • 2. 加载可执行文件
  • 3. 重新初始化 GDT
  • 4. 初始化任务1
    • 4.1 设置当前任务号
    • 4.2 加载 TSS
    • 4.3 加载 LDT
  • 5. 运行任务1
    • 5.1 从内核态切换到用户态
    • 5.2 任务的栈
    • 5.3 设置数据段
  • 6. 系统调用
    • 6.1 初始化系统调用中断
    • 6.2 调用系统调用
    • 6.3 系统调用中断响应
    • 6.4 系统调用中断处理
    • 6.5 move 系统调用
    • 6.6 move_msg 函数
    • 6.7 系统调用中断返回
  • 7. delay 系统调用
  • 8. 任务切换
    • 8.1 TSS
    • 8.2 从任务 1 切换到任务 2
    • 8.3 从任务 2 切换到任务 1

项目目录

root:[.]
+--.DS_Store
+--bochsrc.bxrc
+--print_tree.py
+--run.sh
+--test4
|      +--.DS_Store
|      +--bootsect.s
|      +--clear_test4.sh
|      +--kernel
|      |      +--.DS_Store
|      |      +--driver
|      |      |      +--8253.s
|      |      |      +--8259A.s
|      |      |      +--display.s
|      |      +--head.s
|      |      +--interrupt.s
|      |      +--segment.s
|      |      +--system_call.s
|      |      +--task.s
|      +--run_test4.sh
|      +--user_task
|      |      +--task1.s
|      |      +--task2.s

1. 实验介绍

1.1 生成可执行文件

本章一共有 11 个汇编程序,它们被汇编、链接为 4 个可执行文件:bootsect.bin、kernel.bin、task1.bin 和 task2.bin:

  • 可执行文件 bootsect.bin 的大小为 512B,由汇编程序 bootsect.s 汇编、链接得到,命令如下所示:
# 汇编、链接bootsect.bin
as -o bootsect.o bootsect.s
ld --oformat binary -Ttext=0 -o bootsect.bin bootsect.o
  • 可执行文件 kernel.bin 的大小为 3459B,由 kernel 目录中的汇编程序 head.s、interrupt.s、segment.s、system_call.s 和 task.s,kernel/driver 目录中的汇编程序 display.s 、8253.s 和 8259A.s 汇编、链接得到,命令如下所示:
# 汇编、链接kernel.bin
as -g --32 -o head.o kernel/head.s
as -g --32 -o interrupt.o kernel/interrupt.s
as -g --32 -o segment.o kernel/segment.s
as -g --32 -o system_call.o kernel/system_call.s
as -g --32 -o task.o kernel/task.s
as -g --32 -o display.o kernel/driver/display.s
as -g --32 -o 8259A.o kernel/driver/8259A.s
as -g --32 -o 8253.o kernel/driver/8253.s
ld -m elf_i386 -Ttext=0 -o kernel.tmp head.o interrupt.o segment.o system_call.o task.o display.o 8259A.o 8253.o
objcopy -O binary -S kernel.tmp kernel.bin
  • 可执行文件 task1.bin 的大小为 50B,由 user_task 目录中的汇编程序 task1.s 汇编、链接得到,命令如下所示:
# 汇编、链接task1.bin
as --32 -o task1.o user_task/task1.s
ld -m elf_i386 --oformat binary -Ttext=0 -o task1.bin task1.o
  • 可执行文件 task2.bin 的大小为 42B,由 user_task 目录中的汇编程序 task2.s 汇编、链接得到,命令如下所示:
# 汇编、链接task2.bin
as --32 -o task2.o user_task/task2.s
ld -m elf_i386 --oformat binary -Ttext=0 -o task2.bin task2.o

1.2 创建 map 文件

在 test4 目录下,使用 nm 命令将 32 位 ELF 格式的可执行文件 kernel.map 中的所有符号信息,导出到上层目录 test 中的 kernel.map 文件中,命令如下所示:

# 创建map文件
nm -v kernel.tmp > kernel.map

由 kernel.map 文件可知可执行文件 kernel.bin 中的所有符号信息如表 1.2.1 所示:

表 1.2.1 符号信息

1.3 制作硬盘镜像文件

首先,在 test4 目录下,向上层目录 test 中创建一个大小为 1MB 的硬盘镜像文件 c.img,命令如下:

dd if=/dev/zero of=../c.img bs=512 count=2048

然后,将可执行文件 bootsect.bin 、kernel.bin、task1.bin 和 task2.bin 分别写到硬盘的 0 号扇区、第 1~7 号扇区、第 8 号扇区和第 9 号扇区中,命令如下:

dd if=bootsect.bin of=../c.img bs=512 seek=0 conv=notrunc
dd if=kernel.bin of=../c.img bs=512 seek=1 conv=notrunc
dd if=task1.bin of=../c.img bs=512 seek=8 conv=notrunc
dd if=task2.bin of=../c.img bs=512 seek=9 conv=notrunc

此时,硬盘中的内容如图1.3.1所示:

图 1.3.1 扇区内容

2. 加载可执行文件

如图 2.1 所示,在实验 test4 中,在 cpu 运行的第 2 阶段,引导任务需要将硬盘的 1~9 号扇区中的可执行文件 kernel.bin、task1.bin 和 task2.bin 加载到物理内存的物理地址空间 0x07e00~0x8fff。加载这 3 个可执行文件的汇编指令如 bootsect.s 第 8~13 行所示。其中在第 21 行中,传递给 0x13 号中断处理程序为定义在 3~5 行中的符号 KERNELSEG、TASK1LEN、TASK2LEN 的值的和 9。

图 2.1 加载内存 bootsect.s

    .code16
BOOTSEG = 0x7c0
KERNELLEN = 7
TASK1LEN = 1
TASK2LEN = 1ljmp  $BOOTSEG, $go
go:movw  %cs, %axmovw  %ax, %dsmovb  $0x42, %ahmovb  $0x80, %dlmovw  $parameters, %siint   $0x13clilgdt  gdt_48movw  $1, %axlmsw  %axljmp  $8, $0
parameters:.word 0x0010.word KERNELLEN+TASK1LEN+TASK2LEN.long 0x07e00000.quad 1
gdt_48:.word (gdt_end-gdt)-1.long 0x7c00+gdt
gdt:.quad 0 .quad 0x00409a007e000dff.quad 0x004092007e000dff.quad 0x0040920b80000f9f
gdt_end:.org  0x1fe.word 0xaa55

因为内核的可执行文件 kernel.bin 加载到虚拟内存中的线性地址空间为 0x07e00~0x8fff,大小为 0xe00,所以如 bootsect.s 第 29~30 行所示,cpu 在运行内核时,内核的代码段和数据段 1 的段边界为 0xdff。

3. 重新初始化 GDT

因为汇编程序 head.s 被汇编、链接在可执行文件 kernel.bin 的起始处,所以在实验 test4 中,进入第 3 阶段后,cpu 首先运行的是内核的可执行文件 kernel.bin 中的汇编程序 head.s 中汇编指令对应的机器指令.

head.s

kernel_start:movw  $0x10, %axmovw  %ax, %dsmovw  %ax, %ssmovw  $0x18, %axmovw  %ax, %es movl  $init_stack_end, %espcall  init_gdtljmp  $8, $go
go:movw  $0x10, %axmovw  %ax, %dsmovw  %ax, %ssmovw  $0x18, %axmovw  %ax, %es movl  $init_stack_end, %espcall  init_8253call  init_8259Acall  mask_8259Acall  init_idtsticall  init_task1
move_to_user:pushl $0xfpushl $0x200pushfpushl $0x7pushl $0iret
init_stack:.fill 50, 4, 0
init_stack_end:

因为在本章 test4 中,在内核的可执行文件 kernel.bin 运行的过程中,需要向可执行文件 bootsect.bin 中的 GDT 中添加任务管理相关的描述符,但是通常一个可执行文件中的机器指令只访问本可执行文件中的数据,因此在可执行文件 kernel.bin 中访问可执行文件 bootsect.bin 中初始化的 GDT 是不合理的。

在本章实验中,在进入保护模式,设置好数据段和栈段后,需要在可执行文件 kernel.bin 中初始化 GDT。在函数 init_gdt 中对新 GDT 进行初始化,函数 init_gdt 定义在汇编程序 segment.s 中,如 segment.s 第 4~6 行。

    .globl init_gdt
LDT1_SEL = 0x28
LDT2_SEL = 0X38
init_gdt:lgdt  gdt_48ret
gdt_48:.word (gdt_end-gdt)-1.long 0x7e00+gdt
gdt:.quad 0.quad 0x00409a007e000dff.quad 0x004092007e000dff.quad 0x0040920b80000f9f.word 0x0067, 0x7e00+tss1, 0x8900, 0x0000.word 0x000f, 0x7e00+ldt1, 0x8200, 0x0000.word 0x0067, 0x7e00+tss2, 0x8900, 0x0000.word 0x000f, 0x7e00+ldt2, 0x8200, 0x0000
gdt_end:
tss1:.long 0.long kernel_stack1_end, 0x10.long 0, 0, 0, 0.long 0.long 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.long 0, 0, 0, 0, 0, 0.long LDT1_SEL.long 0
tss1_end:
ldt1:.quad 0x0040fa008c0001ff.quad 0x0040f2008c0001ff
ldt1_end:
tss2:.long 0.long kernel_stack2_end, 0x10.long 0, 0, 0, 0.long 0.long 0, 0x200, 0, 0, 0, 0, 512, 0, 0, 0.long 0xf, 0x7, 0xf, 0xf, 0xf, 0xf.long LDT2_SEL.long 0
tss2_end:
ldt2:.quad 0x0040fa008e0001ff.quad 0x0040f2008e0001ff
ldt2_end:
kernel_stack1:.fill 50, 4, 0
kernel_stack1_end:
kernel_stack2:.fill 50, 4, 0
kernel_stack2_end:

第 10~19 行,定义新 GDT 的内容,总共 8 项,前 4 项中的内容和 bootsect.bin 中的旧 GDT 的内容完全一样,后 4 项在本章的后续内容中进行详细介绍。

第 5 行,将新 GDT 的线性起始地址(第9行)和边界值(第8行)赋值到寄存器 GDTR 中。

在汇编程序 head.s 中,调用 init_gdt 函数初始化新 GDT。新 GDT 初始化后,GDTR 寄存器、GDT、段描述符、段选择符和各种“段”之间的关系如图3.1 所示。

图3.1 GDTR 寄存器、GDT、段描述符、段选择符和各种“段“之间的关系

4. 初始化任务1

重新设置好代码段、数据段和栈段后、对外设和中断系统进行初始化,并打开中断。至此系统初始化工作已经完成,接下来需要初始化系统中运行的第一个任务——任务 1 。

任务 1 的初始化工作在函数 init_task1 中完成,init_task1 函数定义在汇编文件 task.s 中。如 task.s 第 5~11 行。

task.s

    .globl init_task1, switch_task
TSS1_SEL = 0x20
LDT1_SEL = 0x28
TSS2_SEL = 0x30
init_task1:movb  $1, current_taskmovl  $TSS1_SEL, %eaxltr   %axmovl  $LDT1_SEL, %eaxlldt  %axret
switch_task:cmpb  $1, current_taskje    1fmovb  $1, current_taskljmp  $TSS1_SEL, $0jmp   2f
1:  movb  $2, current_taskljmp  $TSS2_SEL, $0
2:  ret
current_task:.byte 0

4.1 设置当前任务号

每个任务都有一个唯一的编号——任务号,本章一共有 2 个任务:任务 1 和任务 2,其中任务 1 的任务号为 1 ,任务 2 的任务号为 2。

因为一个 cpu 在某一时刻只能运行一个任务,所以 task.s 中定义了一个用于保存当前任务的任务号的变量 current_task,此变量在可执行文件 kernel.bin 中的偏移地址保存在符号 current_task 中。初始化任务 1 时,将 current_task 变量的值赋值为任务 1 的任务号 1,赋值的汇编指令如 task.s 第 6 行所示。

4.2 加载 TSS

每个任务都有一个任务状态段(TSS),用于保存该任务运行到某一时刻的状态。任务 1 的 TSS 定义在汇编文件 segment.s 中,如第 20~29 行所示。关于 tss1 的详细介绍在 8.1 节。

由符号 tss1 和 tss1_end 的值可知 tss1 在可执行文件 kernel.bin 中占用的偏移地址空间为 0xa1b~0xa82,因此,当可执行文件kernel.bin 加载到虚拟内存的线性地址空间 0x7e00~0x8fff 后,如图 3.1 所示,tss1 在虚拟内存中占用的线性段的线性地址空间为 0x881b~0x8882。

类似于在虚拟内存中每一个占用一段线性段的代码段和数据段都有一个段描述符,在虚拟内存中占用一段线性段的 TSS 也有一个对应的段描述符,TSS 的段描述符如图 4.2.1 所示。

图4.2.1 TSS 段描述符

TSS 段描述符中最重要的字段是 TSS 在虚拟内存中占用的线性段的段起始地址,类似于代码段和数据段的段描述符,这个字段由 3 部分组成。另外一个字段是 TSS 段描述符的段边界,类似于代码段和数据段的段描述符,这个字段同样由 3 部分组成。DPL 字段中的值通常为 0。所有的段描述符必须要保存在段描述符表中,如 segment.s 第 15 行所示,tss1 的段描述符存放在 GDT 的第 4 项中。在 x86 架构的 cpu 中有一个专门的寄存器用来指定当前任务的 TSS 在虚拟内存中的位置,这个寄存器叫做任务寄存器(TR 寄存器),TR 寄存器保存的是当前任务的 TSS 的段选择符。在任务运行过程中,cpu 通过 TR 寄存器中存放的 TSS 的段选择符可以从 GDT 中获取 TSS 的段描述符,从而从 TSS 的段描述符中获取当前任务的 TSS 在虚拟内存中的线性起始地址。第 7 行,符号 TSS1_SEL 的值 0x20 表示的是 tss1 对应的段选择符,因为 tss1 的段描述符保存在 GDT 的第 4 项中,所以 tss1 的段描述符对应的段选择符为 0x20。

使用 ltr 指令寄存器 ax 中的 tss1 的段选择符 0x20 加载到 TR 寄存器中。

4.3 加载 LDT

任务的目的是在 cpu 上运行用户编写的汇编程序经过汇编、链接后的可执行文件,在本章中,任务 1 运行的是可执行文件 task1.bin,任务 2 运行的是可执行文件 task2.bin。

在初始化任务时,需要为任务在虚拟内存中分配一段线性段,用于”存放“任务的可执行文件。在任务运行过程中,通常任务的代码段和数据段都指向该线性段。

类似于内核的代码段和数据段的段描述符保存在全局段描述符表 GDT 中,任务的代码段和数据段的段描述符保存在局部段描述符表(LDT)中,和内核中只有一个 GDT 不同,在内核中每个任务都有一个 LDT。任务 1 的 LDT 定义在汇编程序 segment.s 第 40~43 行中。

由符号 ldt1 和 ldt1_end 的值可知 ldt1 在可执行文件 kernel.bin 中占用的偏移地址空间为 0x16d~0x1d4,因此当可执行文件 kernel.bin 加载到虚拟内存的线性地址空间为 0x07e00~0x8fff 后,ldt1 在虚拟内存中占用的线性段的线性地址空间为 0x07f6d~0x07fd4。

类似于每个任务的 TSS 都有一个段描述符,在虚拟内存中占用一段线性段的 LDT 也有一个对应的段描述符,LDT 的段描述符如图 4.3.1 所示。

图 4.3.1 LDT 段描述符

  • LDT 段描述符中最重要的字段是 LDT 在虚拟内存中占用的线性段的段起始地址,这个字段由 3 部分组成。
  • 另外一个字段是 LDT 段描述符的段边界,这个字段同样由 3 部分组成。
  • DPL 字段中的值通常为 0。
  • ldt1 在虚拟内存中占用的线性段的线性地址空间为 0x07f6d~0x07fd4,即线性段起始地址为 0x0000,所以 ldt1 段描述符中的段起始地址 [0:15] 字段中的值为 0x0000,段起始地址 [16:23] 字段和段起始地址 [24:31] 字段中的值都为0。
  • LDT 中只有 2 项:任务代码段的段描述符和任务数据段的段描述符,即 LDT 的长度为固定值 0x10,所以 LDT 的段边界 [0:15] 字段中的值为 0x0f,段边界 [16:19] 字段中的值为 0,G 字段中的值也为 0(1B)。

图4.3.2 ldt1 段描述符

因为所有的段描述符必须要保存在段描述符表中,所以如代码 segment.s 第 16 行所示,ldt1 的段描述符放在 GDT 的第 5 项中。

类似于保存当前任务的 TSS 的段选择符的 TR 寄存器,在 x86 架构的 cpu 中有一个专门的寄存器用来指定当前任务的 LDT 在虚拟内存中的位置,这个寄存器叫做局部段描述符表寄存器(LDTR),LDTR 寄存器保存的是当前任务的 LDT 的段选择符。在任务运行过程中,cpu 通过 LDTR 寄存器中存放的 LDTR 的段选择符可以从 GDT 中获取 LDT 的段描述符,从而从 LDT 的段描述符中获取当前任务的 LDT 在虚拟内存中的线性起始地址。

在初始化任务 1 是,需要将任务 1 的 ldt1 的段选择符 0x28 赋值给 LDTR 寄存器,赋值的汇编指令如 task.s 第 9~10 行所示。第 9 行:符号 LDT1_SEL 的值 0x28 表示的是 ldt1 对应的段选择符,因为 ldt1 的段描述符保存在 GDT 的第 5 项中,所以,ldt1 对应的段选择符为 0x28。

使用 lldt 指令将寄存器 ax 中的 ldt1 的段选择符 0x28加载到 LDTR 寄存器中。

图4.3.3 任务1

5. 运行任务1

x86 架构的 cpu 支持 4 个 cpu 特权级:特权级 0、特权级 1 、特权级 2 和 特权级 3,其中数字越大,表示权限越低。

  • 当 cpu 运行内核的可执行文件 kernel.bin 时, cpu 的特权级为 0,cpu 处于 内核态;
  • 当 cpu 运行任务的可执行文件 task1.bin(或task2.bin)时,cpu 的特权级为 3,cpu 处于用户态。

cs 寄存器中的代码段的段选择符的最后两位表示的是 cpu 的当前特权级(CPL),若 CPL 的值为 0,表示 cpu 当前特权级为 0,cpu 处于内核态,当前在 cpu 上运行的是内核的可执行文件 kernel.bin;若 CPL 的值为 3,表示 cpu 的当前特权级为 3,cpu 处于用户态,当前在 cpu 上运行的是任务的可执行文件。

例如,从进入保护模式到初始化任务 1 完成为止,cs 寄存器中保存的代码段的段选择符一直为 0x08,由 cs 寄存器中的段选择符 0x08 中的 CPL 的值为 0,可知 cpu 的特权级一直为 0,即 cpu 一直处于内核态,在 cpu 上一直运行的是内核的可执行文件 kernel.bin。接下来需要在 cpu 上运行任务 1 的可执行文件 task1.bin,因此 cpu 的特权级需要从特权级 0 变为特权级 3,即 cpu 需要从内核态切换到用户态。

5.1 从内核态切换到用户态

中断可以发生在内核态(即 cpu 正在运行内核的可执行文件 kernel.bin 时发生中断),也可以发生在用户态(即 cpu 正在运行任务的可执行文件 task1.bin 或者 task2.bin 时发生中断),但是所有的中断处理程序都存放在内核的可执行文件 kernel.bin 中,因此:若中断发生在内核态,因为在中断发生的前后,在 cpu 上运行的都是内核的可执行文件 kernel.bin,即在中断发生的前后 cpu 都处于内核态。在上一章介绍的中断发生在内核态,在中断响应阶段依次将寄存器 eflags、cs 和 eip 中的值压入内核栈 init_stack 保存起来,在中断返回阶段,即 cpu 运行 iret 指令时,再从内核栈 init_stack 依次出栈恢复这 3 个寄存器的值。若中断发生在用户态,因为在中断发生前,在 cpu 上运行的是当前任务的可执行文件,而在中断发生后,运行的是中断处理程序所在的内核的可执行文件 kernel.bin,即 cpu 必须从中断发生前的用户态切换到中断发生后的内核态。和中断发生在内核态不同,当中断发生在用户态时,在中断响应阶段需要依次压入当前任务的内核栈保存起来的寄存器有 5 个:ss、esp、cs 和 eip。在中断返回阶段,即 cpu 运行 iret 指令时,再从当前任务的内核栈依次出栈恢复这 5 个寄存器的值。当中断返回后,cpu 从当前任务的可执行文件中的中断发生前的位置继续运行,即 cpu 从内核态切换到用户态。因此,可以通过虚拟一个中断返回的过程,来实现 cpu 从内核态切换到用户态。

要想虚拟一个中断返回过程(从内核态进入用户态),必须首先虚拟一个中断响应过程(从用户态进入内核态),即往内核栈 init_stack 中依次压入 5 个值,然后再虚拟一个中断返回的过程,运行 iret 指令,将之前压入内核栈 init_stack 的 5 个值依次出栈“恢复”到寄存器 eip、cs、eflags、esp 和 ss 中。在整个虚拟的中断响应和返回过程中,cpu 中寄存器 [ss:esp] 和寄存器 [cs:eip] 的变化如表 5.1.1 所示。

表5.1.1 寄存器 [ss:esp] 和寄存器 [cs:eip] 的变化

当虚拟中断返回(运行完 iret 指令)之后,寄存器 [ss:eip] 中保存的 cpu 下一条需要运行的机器指令的逻辑地址由 [0x08:0x58] 变为 [0x07:0],即寄存器 cs 中存放的段选择符由 0x08 变为 0x07,其中,CPL 的值由段选择符 0x08 中的 0 变为段选择符 0x07 中的 3,因此,cpu 从内核态进入了用户态,开始运行任务 1 的可执行文件 task1.bin。段选择符 0x07 的 TI 字段的值分别为 1 和 0,表示任务 1 代码段的段描述符位于当前任务的 LDT(ldt1)中的第 0 项(项号从 0 开始),通过 LDTR 寄存器中存放的 ldt1 段描述符对应的段选择符 0x28,cpu 从 GDT 中获取 ldt1 的段描述符,从而从 ldt1 的段描述符中得到 ldt1 的线性起始地址 0x8883,再加上任务 1 代码段的段描述符在 ldt1 中的偏移 0,从而得到任务 1 的代码段的段描述符的线性起始地址 0x8883,最终从任务 1 的代码段的段描述符中获取任务 1 的代码段的虚拟内存中的线性起始地址 0x8883,最终从任务 1 的代码段的段描述符中获取任务 1 的代码段的在虚拟内存中的线性起始地址为 0x8c00,再加上逻辑地址 [0x07:0] 中的偏移地址 0,最终得到逻辑地址 [0x07:0] 对应的线性地址为 0x8c00,即 cpu 下一条运行的机器指令是可执行文件 task1.bin 中的第一条机器指令。

5.2 任务的栈

至此,cpu 运行的第 3 阶段运行结束,cpu 的运行进入第 4 阶段 —— 任务运行阶段。这一阶段在 cpu 上运行的可执行文件包括两部分:

  • 当前任务的可执行文件。当 cpu 运行当前任务的可执行文件时,cpu 处于用户态,cpu 访问的的栈是当前任务的用户栈。当虚拟中断返回之后,寄存器 [ss:esp] 中保存的当前 cpu 使用的栈的栈顶逻辑地址由内核栈 init_stack 的栈顶的逻辑地址 [0x10:0x10d],变为任务 1 的用户栈的栈顶的逻辑地址 [0x0f:0x200]。段选择符 0x0f 的 TI 字段和 index 字段的值分别为 1 和 1,表示任务 1 的用户栈所在的任务 1 的数据段的段描述符位于当前任务的 LDT 中的第 1 项,通过 LDTR 寄存器中存放的 ldt1 的段描述符对应的段选择符 0x28,cpu 从 GDT 中获取 ldt1 的段描述符,从而从 ldt1 的段描述符中得到 ldt1 的线性起始地址 0x8883,再加上任务 1 数据段的段描述符在 ldt1 中的偏移 8,从而得到任务 1 的数据段的段描述符,最终从任务 1 的数据段的段描述符中获取任务 1 的数据段的在虚拟内存中的线性起始地址为 0x8c00,再加上逻辑地址 [0x0f:0x200] 中的偏移地址 0x200,最终得到逻辑地址 [0x0f:0x200] 对应的线性地址为 0x8e00,即寄存器 [ss:esp] 指向的任务 1 的用户栈的栈顶在虚拟内存中的线性地址为 0x8e00。综上,当运行完 iret 指令,cpu 从内核态进入用户态后,cpu 访问的栈由内核栈变为任务 1 的用户栈。
  • 内核的可执行文件 kernel.bin。在第 3 阶段中,cpu 运行内核的可执行文件 kernel.bin 时,使用的栈是内核栈 init_stack,当第三阶段结束后,内核栈 init_stack 不会再被使用。cpu 进入第四阶段后,若在 cpu 运行当前任务的可执行文件的过程中发生了中断,则 cpu 进入内核态开始运行内核的可执行文件 kernel.bin,此时 cpu 访问的栈是当前任务的内核栈。例如,若当前任务是任务 1,则cpu 进入内核态后,使用的栈就是任务 1 的内核栈。

5.3 设置数据段

cpu 从内核态切换到用户态的过程中,cs 寄存器中的段选择符由内核代码段的段选择符 0x8 变为任务 1 代码段的段选择符 0x7,ss 寄存器中的段选择符由内核数据段的段选择符 0x10 变为任务 1 数据段的段选择符 0xf。但是 ds 和 es 寄存器的值没有发生变化仍为 0x10 和 0x18,然而在用户态下,cpu 无法访问内核数据段中的数据,而只能访问当前任务数据段中的数据,因此,需要将任务 1 数据段的段选择符 0x0f 赋值给 ds 和 es 寄存器。

user_task/task1.s

DELAY = 0
MOVE = 1
task1_start:movw  $0xf, %axmovw  %ax, %dsmovw  %ax, %es
1:  movl  $MOVE, %eaxmovl  move_len, %ebxint   $0x80call  delayjmp   1b
delay:movl  $DELAY, %eaxmovl  delay_time, %ebxint   $0x80ret
move_len:.long 1
delay_time:.long 100

6. 系统调用

在引导任务运行阶段,因为在可执行文件 bootsect.bin 中没有实现具有向显示器的屏幕上打印字符串的功能的函数,但是在 BIOS 中实现了,并且还提供了调用接口(0x10 号中断),所以在可执行文件 bootsect.bin 中,可以通过调用 BIOS 中的 0x10 号中断处理程序,来实现显示器的屏幕上打印字符串的功能。可以通过写显存的方式向显示器的屏幕上打印字符,但是在用户状态下无法直接写显存,即在内核的可执行文件 kernel.bin 中可以实现向显示器的屏幕上打印字符串的函数。因此只要在内核的可执行文件 kernel.bin 中实现向显示器的屏幕上打印字符串的函数,并且在用户态下运行的任务的可执行文件提供调用接口,那么在可执行文件 task1.bin 中,通过调用内核的可执行文件 kernel.bin 提供的接口,来实现向显示器的屏幕上打印字符串的功能。类似 BIOS 通过中断的方式提供接口,在用户态下也是通过中断的方式调用内核态下的具有向显示器的屏幕上打印字符串的功能的函数。理论上,除了调用向显示器的屏幕上打印字符串的函数外,在任务的可执行文件中可以通过中断的方式调用内核的可执行文件 kernel.bin 中的任意函数,只要内核提供函数的接口。因此为了向任务的可执行文件提供大量的、数目不确定的具有某种功能的函数的调用,内核提供了一个统一的中断——系统调用中断(0x80)。

6.1 初始化系统调用中断

类似于初始化时钟中断过程,初始化系统调用中断的过程如代码 task.s 第 12~19 行所示,其中:

第 12~15 行,定义系统调用中断的中断描述符。根据给 eax 寄存器和 edx 赋的值,系统调用中断的中断描述符如图 6.1.1 所示。

图6.1.1 系统调用中断描述符

  • 系统调用中断的中断描述符中的 DPL 字段的值必须为 3。
  • 系统调用中断的中断处理程序 system_call_interrupt 定义在汇编程序 interrupt.s 中,如 40~52 行所示,由符号 system_call_interrupt 的值可知系统调用中断处理程序 system_call_interrupt 的入口逻辑地址为 [0x9:0x1a5]。
  • 系统调用中断的中断描述符中的 IF字段必须为 1。

第 16~19 行,将系统调用中断的中断描述符注册到 IDT 中的指定位置。其中,第 16~17 行中根据系统调用中断的终端号 0x80 计算出系统调用中断的中断描述符在可执行文件 kernel.bin 中的起始偏移地址为 0x5cd(符号 idt 的值 0x1cd + 寄存器 ecx 中的值 0x80 x 中断描述符的大小 8B)。

如图6.1.2 所示,在第 18 行中,将系统调用中断的中断描述符的低 32 位(保存在 eax 寄存器中)“写”到虚拟内存的线性地址 0x83cd(内核数据段 1 的起始线性地址 0x7e00 + 系统调用中断描述符在内核数据段 1 中的起始偏移地址 0x5cd)处,在第 19 行中,将系统调用中断的中断描述符的高 32 位(保存在寄存器 edx 中)“写”到虚拟内存的线性地址 0x83d1 处。

图6.1.2 IDT interrupt.s

    .globl init_idt, delay_count
COUNT = 1000
init_idt:movl  $timer_interrupt, %edx movw  $0x8e00, %dxmovl  $0x00080000, %eaxmovw  $timer_interrupt, %ax movl  $0x20, %ecxlea   idt(,%ecx,8), %esimovl  %eax, (%esi)movl  %edx, 4(%esi)movl  $system_call_interrupt, %edx movw  $0xef00, %dxmovl  $0x00080000, %eaxmovw  $system_call_interrupt, %ax movl  $0x80, %ecxlea   idt(, %ecx, 8), %esimovl  %eax, (%esi)movl  %edx, 4(%esi)lidt  idt_48ret
timer_interrupt:pushl %dspushl %espushl %eaxmovw  $0x10, %axmovw  %ax, %dsmovw  $0x18, %axmovw  %ax, %escall  enable_8259Adecl  delay_countsubl  $1, timer_countjne   1fmovl  $COUNT, timer_countcall  switch_task
1:  popl  %eaxpopl  %espopl  %dsiret
system_call_interrupt:pushl %dspushl %espushl %eaxmovw  $0x10, %axmovw  %ax, %dsmovw  $0x18, %axmovw  %ax, %es    popl  %eaxcall  *sys_call_table(,%eax,4)popl  %espopl  %dsiret
delay_count:.long 0
timer_count:.long COUNT
idt_48:.word (idt_end-idt)-1.long 0x7e00+idt
idt:.fill 256, 8, 0
idt_end:

6.2 调用系统调用

在内核的可执行文件 kernel.bin 中实现了一些在任务的可执行文件中无法实现的或者必须由内核实现的,具有某种功能的函数,我们可以把这些函数叫做系统调用。每个系统调用都有一个唯一的编号 —— 系统调用号。在内核的可执行文件 kernel.bin 中实现了 2 个系统调用:delay 系统调用(系统调用号为 0)和 move 系统调用(系统调用号为 1),它们对应的函数的函数名分别为:sys_delay 和 sys_move。

在内核的可执行文件 kernel.bin 中,在汇编程序 head.s 中调用 init_idt 函数完成中断系统的初始化工作,并如 21 行所示,打开中断后,在任务 1 的可执行文件 task1.bin 运行的过程中,可以通过系统调用 move 系统调用,汇编指令如 task1.s 第 7~9 行所示,第 7 行,将 move 系统调用的系统调用号 1 赋值给 eax 寄存器,作为系统调用中断的中断处理程序 system_call_interrupt 的参数;第 8 行,将数字 1 赋值给寄存器 ebx,作为 move 系统调用的参数;第 9 行,分别设置好传递给中断处理程序 system_call_interrupt (eax 寄存器中) 和 move 系统调用(寄存器 ebx 中)的参数后,使用 int 指令调用 0x80 号系统调用中断的中断处理程序 system_call_interrupt。

6.3 系统调用中断响应

系统调用中断的产生和时钟中断的产生过程不同:时钟中断是由 cpu 外部的外设 8253 产生的中断,叫做硬件中断,也叫外部中断;而系统调用中断是在 cpu 运行 int 指令的时候产生的中断,叫做软中断。因此,硬中断和软中断产生后,cpu 获取中断号的过程不同:

  • cpu 从 8259A 中获取硬中断的中断号。
  • cpu 从 int 指令中获取软中断的中断号。如 task1.s 第 9 行所示,cpu 从int 指令中获取系统调用中断的中断号 0x80。

在用户态下,cpu 运行代码 task1.s 第 9 行中的汇编指令对应的机器指令的过程就是 cpu 响应系统调用中断的过程:

第一步,获取任务 1 的内核栈的栈顶的位置。

若 cpu 在用户态下运行任务 1 的可执行文件 task1.bin 的过程发生了系统调用中断,则 cpu 需要从用户态进入内核态,运行可执行文件 kernel.bin 中的系统调用中断处理程序 system_call_interrupt。与此同时,cpu 访问的栈也需要从任务 1 的用户栈切换为任务 1 的内核栈。因此,在系统调用中断的响应阶段首先需要获取任务 1 的内核栈的栈顶的位置。

任务 1 的内核栈在虚拟内存中占用的起始地址空间为 0x890b~0x89d2,并且任务 1 的内核栈的栈顶的逻辑地址的过程为:首先,cpu 通过 TR 寄存器中保存的当前任务的 TSS 的段选择符 0x20,从 GDT 的第 4 项中获取 tss1 的段描述符,从而得到 tss1 在虚拟内存中的起始线性地址 0x881b,然后,再加上 ss0 和 esp0 字段在 tss1 中偏移地址 8 和 4,最终 cpu 得到存放在 tss1 中的 ss0 和 esp0 字段中的任务 1 的内核栈的栈顶的逻辑地址 [0x10:0xbd3],对应的线性地址为 0x89d3,如图6.3.1(1)所示。

第二步,保存发生系统调用中断时用户态下的中断现场。

cpu 在获取任务 1 的内核栈的位置后,依次将当前寄存器 ss、esp、efalgs、cs 和 eip 中的值压入任务 1 的内核栈保存起来。

  • 寄存器 [ss:esp] 中保存的是任务 1 的用户栈的栈顶的逻辑地址 [0xf:0x200]。
  • 寄存器 [cs:eip] 中保存的是 task1.s 第 9 行汇编指令的下一条汇编指令(第 10 行)对应的机器指令的逻辑地址 [0x7:0x15],即从系统调用中断返回到用户态后,cpu 运行的第 1 条机器指令的逻辑地址。

把 5 个寄存器的值入栈后,如图6.3.1(2)所示,就可以用任务 1 的内核栈的栈顶的当前位置的逻辑地址 [0x10:0xbbf] ,替换寄存器 [ss:esp] 中原来保存的任务 1 的用户栈的栈顶的逻辑地址 [0xf:0x200]。之后,cpu 访问的栈由任务 1 的用户栈切换为任务 1 的内核栈。

图6.3.1 任务 1 内核栈变化过程

第三步,进入系统调用中断处理程序 system_call_interrupt。

cpu 从 DITR 寄存器中获取 IDT 的线性起始地址 0x7fcd,再加上系统调用中断的中断描述符在 IDT 中的偏移 0x400 (时钟中断的中断号 0x80 x 中断描述符的长度 8B),从而得到系统调用中断的中断描述符的线性地址 0x83cd。

cpu 从虚拟内存 0x083cd 处取得系统调用中断的中断描述符之后,将系统调用中断的中断描述符中的系统调用中断处理程序 system_call_interrupt 的入口逻辑地址 [0x8:0x1a5],赋值给寄存器 [cs:eip],替换掉寄存器 [cs:eip] 中原来保存的 task1.s 第 9 行汇编指令的下一条汇编指令(第 10 行)对应的机器指令的逻辑地址 [0x7:0x0],即 cpu 从任务 1 的用户态进入任务 1 的内核态。cpu 根据逻辑地址 [0x8:0x1a5] 对应的线性地址 0x07fa5(内核代码段的起始线性地址 0x7e00 + 系统调用中断处理程序 system_call_interrupt 的入口逻辑地址中断偏移地址 0x1a5),从虚拟内存中“读取”系统调用中断处理程序的第一条机器指令,进入系统调用中断处理阶段。

6.4 系统调用中断处理

对系统调用中断的处理由系统调用中断处理程序 system_call_interrupt 完成, system_call_interrupt 定义在汇编程序 interrupt.s 中,如第 40~52 行所示:

第 41~42 行,因为在 system_call_interrupt 中,需要修改数据段寄存器 ds 和 es 的值,所以在进入 system_call_interrupt 后,首先将寄存器 ds 和 es 的值压入任务 1 的内核栈保存起来,并且在 system_call_interrupt 返回前,将它们依次出栈恢复到寄存器 es 和 ds 中。

第 43~ 48 行,cpu 运行 iret 指令从内核态进入用户态过程中,代码段寄存器 cs 和栈段寄存器 ss 的值由 cpu 自动赋值,但是数据段寄存器 ds 和 es 的值在任务 1 的可执行文件 task1 中赋值。同理,cpu 运行 int 指令,从用户态进入内核态的过程中,代码段寄存器 cs 和栈段寄存器 ss 的值由 cpu 自动赋值,但是数据段寄存器 ds 和 es 的值需要在内核的可执行文件 kernel.bin 中的 system_call_interrupt 中赋值。因为此时 eax 寄存器中保存的是 move 系统调用的系统调用号 1,但是在第 第44~47 行中需要使用 eax 寄存器,所以必须在使用 eax 寄存器前,先将 eax 寄存器的值压入任务 1 的内核栈保存起来,并在 eax 寄存器使用完后,出栈恢复到 eax 寄存器中。将 ds、es 和 eax 寄存器的值压入任务 1 的内核栈(第 41~43 行)后,寄存器 [ss:esp] 的位置如图 6.3.1(3) 所示,出栈恢复 eax 的值后,寄存器 [es:esp] 的位置如图 6.3.1(4) 所示。

第 49 行,将虚拟内存中逻辑地址 [ds:sys_call_table(,%eax,4)] 对应的线性地址处的 4 个字节大小的数据赋值给寄存器 eip。 sys_call_table 的值为 0xc9b,(0xc9b+eax 的值 1 x 4)。因此,逻辑地址[ds:sys_call_table(,%eax,4)] 的值为 [0x10:0xc9f],即赋值给寄存器 eip 中的 4 个字节大小的数据存放在虚拟内存中的线性地址 0x8a9f。move 系统调用对应的 sys_move 函数和 sys_delay 函数的入口偏移地址存放在系统调用表 sys_call_table 中,它定义在汇编程序 system_call.s 中。其中 sys_move 函数的偏移地址为 4,sys_delay 函数的偏移地址为 0。系统调用表 sys_call_table 和 sys_move 函数及 sys_delay 函数在虚拟内存中的位置如图6.4.1 所示,其中虚拟内存中的线性地址 0x8a9f 处存放的 4 个字节大小的数据是 sys_move 函数的入口偏移地址 0xcb3,因此,将偏移地址 0xcb3 赋值给 eip 寄存器后,寄存器 [cs:eip] 的值为 [0x8:0xcb3] ,即 cpu 下一条运行的机器指令是 sys_move 函数的第一条机器指令。因此,cpu 开始运行 sys_move 函数。

call *sys_call_table(,%eax,4) : 调用 move 系统调用对应的函数 sys_move 函数,并将 sys_move 函数的返回地址 0x01bc 压入任务 1 的内核栈。

图6.4.1 系统调用表 kernel/system_call.s

    .globl sys_call_table
sys_call_table:.long sys_delay.long sys_move
sys_delay:movl  %ebx, delay_count
1:  cmpl  $0, delay_countjg    1bret
sys_move:pushl %ebxcall  move_msgaddl  $4, %espret

6.5 move 系统调用

sys_move 函数定义在汇编程序 system_call.s 中,如第 10~14 行所示。在 sys_move 函数中,制作了一个工作 —— 调用 move_msg 函数移动显示器的屏幕上的字符串。

第 11 行,把在任务 1 的可执行文件 task1.bin 中赋值给寄存器 ebx,并通过寄存器 ebx 传递给 move 系统调用的参数 1 压入任务 1 的内核栈,作为 move_msg 函数的参数。入栈后寄存器 [ss:esp] 的位置如图6.3.1(6) 所示。

第 12 行,将 move_msg 函数函数的参数压入任务 1 的内核栈后,调用 move_msg 函数,并将 move_msg 函数的返回地址 0xcb9 压入任务 1 的内核栈,入栈后寄存器 [ss:esp] 的位置如图6.3.1(7) 所示。

第 13 行,move_msg 函数返回后,将任务 1 的内核栈的栈顶的偏移地址加 4,即将在第 11 行中入栈的 move_msg 函数的参数,从任务 1 的内核栈上删除。

6.6 move_msg 函数

move_msg 函数的作用是:根据传递给 move_msg 函数的参数(任务 1 传递的参数是 1),将显示屏幕上的字符串移动相应的位置,move_msg 函数定义在汇编程序 display.s 中。

图6.6.1 kernel/driver/display.s

    .global move_msg
move_msg:pushl %ebpmovl  %esp, %ebppushl %eaxpushl %ecxpushl %edipushl %esimovl  8(%ebp), %eaxsall  %eaxaddl  %eax, vmem_offsetmovl  $msg, %esimovl  vmem_offset, %edimovl  msg_len, %ecx        movb  $0x2, %alcld
1:  movsbstosbloop  1bpopl  %esipopl  %edipopl  %ecxpopl  %eaxpopl  %ebpret
msg:.ascii " hello, world. "
msg_len:.long .-msg
vmem_offset:.long 0

第 3 行,在 x86 架构的 cpu 中有一个专门的寄存器用于在函数运行过程中获取函数的参数,这个寄存器叫 ebp 寄存器。在进入 move_msg 函数之后,因为需要修改 ebp 寄存器的值,所以需要先将 ebp 寄存器中的值压入任务 1 的内核栈保存起来,在 move_msg 函数返回前,再将任务 1 的内核栈中出栈恢复 ebp 寄存器的值。

第 4 行,如图6.3.1(7) 所示,因为进入 move_msg 函数后,任务 1 的内核栈的栈顶的线性地址 0x89ab 对应的逻辑地址为 [0x10:0xbab],即 esp 寄存器的值为 0xbab,所以 move_msg 函数的参数 1 的线性地址 0x89af 对应的逻辑地址 [0x10:0xbaf] 中的参数 1 在内核数据段 1 中的偏移地址 0xbaf(参数 1 的线性地址 0x89af-内核数据段 1 的其实线性地址 0x7e00)可以表示为 4(%esp) (esp 寄存器的值 0xbab + 4)。如图6.3.1(8) 所示,当 ebp 寄存器入栈后,任务 1 的内核栈的栈顶的线性地址 0x89a7 对应的逻辑地址为 [0x10:0xba7],即 esp 寄存器的值变为 0xba7,因此,参数 1 的偏移地址 0xbaf 可以表示为 8(%esp) (esp 寄存器的值 0xba7 + 8)。在第 4 行中将 esp 寄存器的值 0xba7 赋值给 ebp 寄存器后,利用 ebp 寄存器中的值 0xba7 表示参数 1 的偏移地址 0xbaf 为:8(%ebp)(ebp 寄存器的值 0xba7+8)。之后,在 move_msg 函数运行的过程中,esp 寄存器的值会随着出栈和入栈而变化,但是 ebp 寄存器的值一直是 0xba7,不会发生变化。因此,参数 1 的偏移地址 0xbaf 可以用 8(%ebp) 恒定的表示。

第 5~ 8 行,依次将 eax 寄存器、ecx、edi 和 esi 中的值压入任务 1 的内核栈保存起来,入栈后寄存器 [ss:esp] 的位置如图6.3.1(8) 所示。在 move_msg 函数返回前,再从任务 1 的内核栈依次出栈恢复这 4 个寄存器的值(第 20~23 行)。

第 9~ 11 行,在第 30~31 行中定义了一个用于指定将字符串 “hello,world.” 拷贝到显存中后,首字符 ‘h’ 在显存中的偏移地址的变量(vmem_offset 变量),vmem_offset 变量在可执行文件 kernel.bin 中的偏移地址保存在符号 vmem_offset 中。即字符串"hello,world." 在显示器位置由 vmem_offset 变量决定。这三行汇编指令的作用是:首先,将 move_msg 函数的参数 1 从任务 1 的内核栈中取出保存到 eax 寄存器中,如前所述,参数 1 在内核数据段 1 中的偏移地址为 8(%ebp);然后,将 eax 寄存器中的值 1 算数左移 1 位,作用等同于给 1 乘 2,即 eax 寄存器中的值变为 2;最后,将 eax 寄存器中的值 2 与 vmem_offset 变量的值相加后再赋值给 vmem_offset 变量。

第 12~19 行,设置好字符串再显存中的位置后,将第 26~27 行中定义的字符串 “hello,world.” 拷贝到显存中由 vmem_offset 变量指定的位置。

第 20~24 行,从任务 1 的内核依次出栈恢复 esi、edi、ecx、eax 和 ebp 寄存器的值,出站后寄存器 [ss:esp] 的位置如图6.3.1(7) 所示。

6.7 系统调用中断返回

move_msg 函数将字符串拷贝到显存后,cpu 运行 move_msg 函数中的函数返回指令,从 move_msg 函数返回到 sys_move 函数,函数返回前寄存器 [ss:esp] 的位置如图6.3.1(7) 所示,函数返回后寄存器 [ss:esp] 的位置如图6.3.1(6) 所示。

在 sys_move 函数中删除 move_msg 函数的参数 1 后,cpu 运行 sys_move 函数中的函数返回指令,从 sys_move 函数返回到 system_call_interrupt ,函数返回后寄存器 [ss:esp] 的位置如图6.3.1(4) 所示。

返回到 system_call_interrupt 后,首先依次出栈恢复寄存器 es 和 ds 的值,出栈后寄存器 [ss:esp] 的位置如图6.3.1(2) 所示;然后通过中断返回指令从 system_call_interrupt 返回到任务 1 的用户态。因为 cpu 运行 iret 指令前任务 1 的内核栈如图6.7.1 所示,所以当 cpu 运行完 iret 指令后,寄存器 [ss:esp] 保存的是任务 1 的用户栈的栈顶的逻辑地址 [0xf:0x200];寄存器 [cs:eip] 中保存的是任务 1 的可执行文件 task1.bin 中的机器指令的逻辑地址 [0x7:0x15] 。并且任务 1 的内核栈被清空。

图6.7.1 move 系统调用函数调用

7. delay 系统调用

任务 1 的工作由一个循环组成:在调用 move 系统调用将显示器的屏幕上的字符串向前移动 1 个字符之后,调用 delay 函数延时 1s,之后再循环调用 move 系统调用向前移动字符串。

delay 函数定义在汇编文件 task1.s 中。在 delay 函数中制作了一个工作——调用 delay 系统调用延时 1s,其中,第 13 行,将 delay 系统调用的调用号 0 赋值给 eax 寄存器,作为系统调用中断的中断处理程序 system_call_interrupt 的参数;第 14 行,将数字 100 赋值给寄存器 ebx,作为 delay 系统调用的参数;第 15 行,分别设置好传递给中断处理程序 system_call_interrupt(eax 寄存器中)和 delay 系统调用(寄存器 ebx 中)的参数后,使用 int 调用 0x80 号系统调用中断的中断处理程序 system_call_interrupt。

cpu 运行代码 user_task/task1.s 第 15 行中的汇编指令对应的机器指令的过程类似于运行第 9 行中的汇编指令对应的机器指令的过程。

在任务 1 调用 delay 系统调用和调用 move 系统调用后,系统调用中断处理程序 system_call_interrupt 处理两者的过程几乎相同,唯一不同的地方是:因为调用 delay 系统调用时,传递给 system_call_interrupt 的参数为 delay 系统调用的系统调用号 0(保存在 eax 寄存器中),所以 kernel/interrupt.s 第 49 行中的 sys_delay 函数的入口偏移地址 sys_call_table(,%eax,4) 经过计算后得到的值为 0xc9b。因此,sys_delay 函数的入口逻辑地址为 [0x10:0xc9b],即赋值给寄存器 eip 中的 4 个字节大小的数据存放在虚拟内存中的线性地址为 0x8a9b。

如图6.4.1所示,虚拟内存中的线性地址 0x8a9b 处存放的 4 个字节大小的数据是 sys_delay 函数的入口偏移地址 0xca3,因此,将偏移地址 0xca3 赋值给 eip 寄存器后,寄存器 [cs:eip] 的值为 [0x8:0xca3] ,即 cpu 下一条运行的机器指令是 sys_delay 函数的第一条机器指令。因此 cpu 开始运行 sys_delay 函数。sys_delay 函数定义在汇编程序 system_call.s 中。

第 6 行,如 interrupt.s 第 53~54 行所示,在汇编程序 interrupt.s 中定义了一个用于保存计数的变量,delay_count 变量在可执行文件 kernel.bin 中的偏移地址保存在符号 delay_count 中。进入 sys_delay 函数之后,将在任务 1 的可执行文件 task1.bin 中赋值给寄存器 ebx,并通过寄存器 ebx 传递给 delay 系统调用的参数 100 赋值给 delay_count 变量。

第 7~9 行,sys_delay 函数将 delay_count 变量初始为 100 之后开始一个循环:循环判断 delay_count 变量的值,若 delay_count 变量的值大于 0,则继续循环判断 delay_count 变量的值;若小于或者等于 0,则跳出循环。

外设 8253 每隔 10ms 会产生一次时钟中断,在时钟中断处理程序 timer_interrupr 中,第 32 行所示,delay_count 变量的值被减去 1。因此 delay_count 变量中的值每隔 10ms 会减少 1。所以在 sys_delay 函数中,将 delay_count 变量的值初始化为 100 后,经过 1s 的时间后,delay_count 变量中的值才能被递减为 0,即 sys_delay 函数中的循环运行的总时间为 1s。经过 1s 循环结束后,从 sys_delay 函数返回到 system_call_interrupt 中通过返回指令从 system_call_interrupt 返回到任务 1 的用户态。

8. 任务切换

在 cpu 运行的第 4 个阶段,在内核之上运行着 2 个任务:任务 1 和任务 2。任务 1 其中每隔 1s 将显示器的屏幕上的字符串向右移动一个字符,任务 2 每隔 1s 将字符串向左移动一个字符。任务 1 和任务 2 可以通过 0x80 号系统调用中断,调用内核中的 move 系统调用对应的 sys_move 函数,而在 sys_move 函数中,通过调用往显存中写数据的显示器的驱动程序 move_msg 函数,最终实现将显示器的屏幕上的字符串移动的功能。

图8.1 任务和 os

一个 cpu 在某一段时间内只能运行一个任务,因此,任务 1 和任务 2 需要交替着在 cpu 上运行。每个任务在 cpu 上只能连续运行 10s,即任务 1 在 cpu 上连续运行 10s 后,切换到任务 2,任务 2 在 cpu 上连续运行 10s 后,即任务 1 在 cpu 连续运行 10s 后,切换到任务 2,同理,任务 2 在 cpu 上连续运行 10s 后,再切换到 任务 1。

在任务运行阶段,在某个任务运行的过程中,cpu 可以运行的可执行文件包括:该任务的可执行文件和内核可执行文件 kernel.bin。例如,当 cpu 上运行任务 1 的过程中,cpu 在用户态下运行任务 1 的可执行文件 task1.bin,在内核态下运行内核可执行文件 kernel.bin;当在 cpu 上运行任务 2 的过程中,cpu 在用户态下运行任务 2 的可执行文件 task2.bin,在内核态下运行内核可执行文件 kernel.bin。

任务之间的切换发生在时钟中断处理程序 timer_interrupt 中,timer_interrupt 定义在汇编程序 interrupt.s 中。timer_interrupt 的流程图如图8.2 所示。其中 timer_interrupt 中关于 ds、es 和 eax 寄存器的操作类似于在 system_call_interrupt 中的操作。

图8.2 timer_interrupt

如 kernel/interrupt.s 第 55~56 行所示,在汇编程序 interrupt.s 中定义了一个用于保存计数的变量,timer_count 变量的偏移地址保存在符号 timer_count 中,其中 timer_count 变量的初始值 1000 定义在第 2 行中。

  • 若 timer_count 变量的值变为 0,则说明从开中断开始已经经过了 10s,那么将 timer_count 变量重新初始化为 1000 后,调用任务切换函数 switch_task 函数,从任务 1 切换到任务 2。
  • 若 timer_count 变量的值不为 0,则说明距离上次任务切换还不到 10s,即不用进行任务切换,那么 cpu 跳转到第 36 行汇编指令对应的机器指令。

switch_task 函数定义在汇编程序 task.s 中,如 kernel/task.s 第 12~21 行所示。switch_task 函数的流程图如图8.3 所示。switch_task 函数的主体是一个条件语句:先将 current_task 变量的值与 1 进行比较(第 13 行),然后对比结果进行判断(第 14 行):

  • 若 current_task 变量的值为 1,则表示 cpu 上运行的当前任务是任务 1,那么将任务 2 的任务号 2 赋值给 current_task 变量(第 18 行)后,切换到任务 2(第 19 行)。

  • 若 current_task 变量的值不为 1,则表示 cpu 上运行的当前任务是任务 2,那么将任务 1 的任务号 1 赋值给 current_task 变量(第 15 行)后,切换到任务 1(第 16 行)。

图8.3 switch_task 流程图

8.1 TSS

任务 1 在运行过程中使用的资源包括 2 部分:

  • **存放在虚拟内存中的内容。**任务 1 的可执行文件和任务 1 的用户栈,存放在任务 1 的代码段和数据所在的线性段(0x8c00~0x8e00)。内核可执行文件 kernel.bin 中的内容,包括:tss1、ldt1 和任务 1 的内核栈,它们是任务运行过程中必须使用到的内容。因为从任务 1 切换到任务 2 后,在任务 2 运行的过程中不会修改存放在虚拟内存中的任务 1 的内容,所以从任务 1 切换到任务 2 的过程中不需要保存在虚拟内存中任务 1 的内容。
  • **cpu 中的寄存器。**在 cpu 中每个寄存器只有 1 个,即任务 1 和任务 2 分别在 cpu 上运行的过程中使用相同的寄存器。因此,从任务 1 切换到任务 2 的过程中,需要将任务 1 使用的寄存器 [cs:eip] 中的值保存起来,将任务 2 运行需要的值加载到寄存器 [cs:eip] 中。

类似 cs 和 eip 这样的需要在任务切换过程中保存的寄存器总共有 16 个,在某一时刻,cpu 中的这 16 个寄存器中的值表示这一时刻在 cpu 上运行的当前任务的状态,某个任务的某一个时刻的状态可以保存在该任务的 TSS 中。

图8.1.1 TSS

TSS 的结构如图8.1.1 所示,其中除了本书所有实验中未使用的字段(用 0 填充),根据在任务切换过程中是否用于保存任务的状态,TSS 中的字段分为 2 类:

  • 不用于保存任务的状态的字段,即这些字段在任务切换过程中不发生改变,即它们是固定值。任务的内核栈的栈顶的逻辑地址[ss0:esp0];任务的 LDT 的段描述符;cr3 寄存器(开启分页机制后使用)。
  • 用于保存任务的状态的字段,设计在任务切换过程中需要保存的 cpu 中的 16 个寄存器对应的字段,16 个寄存器(cs、ds、es、fs、gs 和 ss),4 个通用寄存器(eax、edx、ecx 和 edx),5 个偏移地址寄存器(eip、esp、esi、edi 和 ebp)和标志寄存器 eflags。

事实上,两个任务切换的过程就是这两个任务的状态切换的过程,即 cpu 中的这 16 个寄存器的值保存和重新加载的过程。

图8.1.2 任务切换

如前所述每个任务都有一个 TSS,任务 1 的 TSS(tss1)定义在汇编文件 segment.s 中,如第 20~29 行所示,tss1 初始化后,部分字段的值如表8.1.1所示。

  • 不用于保存任务 1 的状态的字段:ldt1 段描述符 0x28 和任务 1 的内核栈的栈顶的逻辑地址 [0x10:0xbd3]。
  • 用于保存任务 1 的状态的字段:16 个寄存器都从初始化为 0。因为 tss1 第一次从任务 1 切换到任务 2 的过程中被使用的 —— 用于保存任务 1 的状态,所以无论将 tss1 中的用于保存任务 1 的状态的字段初始化为何值,在第 1 次从任务 1 切换到任务 2 的过程中都会被覆盖掉。

任务 2 的 TSS 也定义在汇编文件 segment.s 中,如 34~43 行所示。tss2 初始话后,部分字段的值如表8.1.1所示,其中 ldt2 段描述符为 0x38,任务 2 的内核栈的栈顶的逻辑地址为 [0x10:0xc9b]。

表8.1.1 tss1 和 tss2 的变化情况

8.2 从任务 1 切换到任务 2

在任务 1 的内核态下,使用 ljmp 指令实现从任务 1 切换到任务 2 的切换。其中,操作数是0x30(符号 TSS2_SEL 的值)是任务 2 的 TSS 的段选择符。操作数 0 没有任何作用,但是不能省略。用于保存当前任务的任务号的 current_task 变量的值在代码在第 18 行中已经由任务 1 的任务号 1 修改为任务 2 的任务号 2。

第 1 步,将任务 1 的当前的状态保存的 tss1

要想把任务 1 的当前的状态保存到 tss1 中,cpu 需要获取 tss1 在虚拟内存中的位置。首先,由 TR 寄存器中存放的 tss1 的段选择符 0x20 的 TI 字段和 index 字段的值分别为 0 和 4,表示 tss1 的段描述符位于 GDT 中的第 4 项,然后,由 GDTR 寄存器中存放的 GDT 的线性起始地址 0x87db,再加上 tss1 段描述符在 GDT 中的偏移 32(项号 4 x 段描述符的长度 8B),从而得到 tss1 段描述符的线性起始地址 0x87fb,最终从 tss1 段描述符中获取 tss1 的在虚拟内存中的线性起始地址 0x881b。

获取 tss1 在虚拟内存中的线性起始地址之后,cpu 将保存有任务 1 的当前任务的状态的 cpu 中的 16 个寄存器的值,保存到 tss1 的对应字段中,最终完成对任务 1 的当前的状态的保存。

第 2 步,加载 TR 和 LDTR 寄存器

任务 1 的任务的当前的状态的保存工作完成后,需要将 TR 和 LDTR 寄存器中保存的任务 1 的 tss1 和 ldt1 的段选择符,替换为任务 2 的 tss2 和 ldt2 的段选择符。

  • TR 寄存器,task.s 第 19 行 ljmp 指令中的 tss2 的段选择符 0x30,替换 TR 寄存器中的原来的 tss1 的段选择符 0x20。
  • LDTR 寄存器。某个任务的 LDT 的段描述符存放在该 TSS 中的 LDT 段选择符字段中。因此,首先 cpu 需要使用第 19 行 ljmp 指令中的 tss2 的段选择符 0x30,获取 tss2 在虚拟内存中的位置,cpu 获取 tss2 的线性起始地址的过程和在第 1 步中获取 tss1 的线性起始地址的过程相似。获取 tss2 在虚拟内存中线性起始地址之后,cpu 用存放在 tss2 中的 ldt2 的段选择符 0x38,替换 LDTR 寄存器中的原来的 ldt1 的段选择符 0x28。

不同于初始化任务 1 时,需要手动分别将 tss1 和 ldt1 的段选择符 0x20 和 0x28 加载到 TR 和 LDTR 寄存器中,第 1 次从任务 1 切换到任务 2 时,有 cpu 自动分别将 tss2 和 ldt2 的段选择符 0x30 和 0x28 加载到 TR 和 LDTR 寄存器中。

第 3 步,将任务 2 的 TSS 中的内容加载到寄存器

将任务 1 当前状态保存到 tss1 中的对应字段中之后,就可以将 tss2 中的手动设置的任务 2 的起始运行状态,加载到 cpu 中的这 16 个寄存器中。

到此为止,cpu 上运行的当前任务已经由任务 1 切换到任务 2。寄存器 [cs:eip] 中的值已经由保存到 tss1 中的 [0x08:0xcfc] 便从 tss2 中加载的 [0x07:0] ,寄存器 cs 中存放的任务 2 的代码段的段选择符 0x07 的 TI 字段和 index 字段的值分别为 1 和 0,表示任务 2 的代码段的段描述符位于当前任务的 LDT 中的第 0 项,通过 LDTR 寄存器中存放的 ldt2 的段描述符对应的段选择符 0x38,cpu 从 GDT 中获取 ldt2 的段描述符,从而从 ldt2 的段描述符中得到 ldt2 的线性起始地址 0x0000,再加上任务 2 代码段的段描述符在 ldt2 中的偏移 0,从而得到任务 2 的代码段的段描述符的线性起始地址 0x0000,最终从任务 2 的代码段描述符中获取任务 2 的代码段的虚拟内存中的线性起始地址 0x8e00,再加上逻辑地址 [0x07:0] 中的偏移地址 0,最终得到逻辑地址 [0x07:0] 对应的线性地址为 0x8e00,即 cpu 下一条运行的机器指令是可执行文件 task2.bin 中的第一条机器指令。因此,cpu 开始运行任务 2 的。

同理,寄存器 [ss:esp] 的值由保存到 tss1 中的 [0x10:0xb97] 便从 tss2 中加载的 [0x0f:0x200],寄存器 ss 中存放的任务 2 的栈段的段选择符 0x0f 的 TI 字段和 index 字段的值分别为 1 和 1,表示任务 2 的用户栈所在的任务 2 的数据段的段描述符位于当前任务的 LDT中的第 1 项,通过 LDTR 寄存器中存放的 ldt 2 的段描述符对应的段选择符 0x38,cpu 从 GDT 中获取 ldt2 的段描述符,从而从 ldt2 的段描述符中得到 ldt2 的线性起始地址 0x0000,再加上任务 2 数据段的段描述符在 ldt2 中的偏移 8 ,从而得到任务 2 的数据段的段描述符,最终从任务 2 的数据段的段的偏移地址 0x200,最终得到逻辑地址 [0x0f:0x200] 对应的线性地址为 0x9000,即寄存器 [ss:esp] 指向任务 2 的用户栈的栈顶在虚拟内存中的线性地址为 0x9000。

8.3 从任务 2 切换到任务 1

任务 2 的可执行文件 task2.bin 由汇编程序 task.s 汇编、链接得到,汇编程序如 task2.s 所示。任务 2 在调用 move 系统调用时,传递给 sys_move 函数的参数为 -1,即每个 10s 将显示器屏幕上的字符串向左移动 1 个字符的位置。

user_task/task2.s

DELAY = 0
MOVE = 1
task2_start:movl  $MOVE, %eaxmovl  move_len, %ebxint   $0x80call  delayjmp   task2_start
delay:movl  $DELAY, %eaxmovl  delay_time, %ebxint   $0x80ret
move_len:.long -1
delay_time:.long 100

在第 1 次从任务 1 切换到任务 2 时,寄存器 ds 和 es 中的值 0x0f 是从 tss2 中的 ds 和 es 字段中加载的,因此,在汇编程序 task2.s 中,不用像在汇编程序 task1.s 中那样初始化寄存器 ds 和 es 的值。

当任务 2 连续运行 10s 后,在时钟中断处理程序 timer_interrupt 中,调用任务切换函数 switch_task 函数第 1 次从任务 2 切换到任务 1。在第 1 次从任务 1 切换到任务 2 的过程中,保存到 tss1 中的任务 1 的状态中的 cs 和 eip 字段的值分别为 0x08 和 0xcfc ,所以在第 1 次从任务 2 切换回到任务 1 的过程中,将任务 2 的状态保存到 tss2 后,将 tss1 中的 16 个寄存器的值加载到 cpu 中的对应的寄存器后,寄存器 [cs:eip] 中保存的 cpu 下一条运行的机器指令的逻辑地址为 [0x08:0xcfc]。

switch_task 函数中所有汇编指令如表8.3.1 所示,其中,第 20 行中的汇编指令对应的机器指令对应的机器指令的线性地址为 0x8afc。因此,第 1 次从任务 2 切换回到任务 1 后,cpu 运行的第 1 条机器指令是 switch_task 函数中的第 20 行汇编指令对应的机器指令,即第 19 行第 1 次从任务 1 切换到 任务 2 的汇编指令的下一条汇编指令对应的机器指令。实际上,每次从任务 2 切换到任务 1 后,cpu 运行的第 1 条机器指令都是 switch_task 函数中的第 20 行中汇编指令对应的机器指令。

表8.3.1 switch_task 函数中所有汇编指令的信息

同理,当第 1 次从任务 2 切换到任务 1 后,在任务 1 连续运行 10s 后,在时钟中断处理程序 timer_interrupt 中,调用任务切换函数 switch_task 函数第 2 次从任务 1 切换到任务 2 ,在第 1 次从任务 2 切换到任务 1 的过程中,保存到 tss2 中的任务 2 的状态中的 cs 和 eip 字段的值分别为 0x08 和 0xcec ,在第 2 次从任务 1 切换到任务 2 的过程中,将任务 1 的状态保存到 tss1 后,将 tss2 中的 16 个寄存器的值加载到 cpu 中的对应的寄存器后,寄存器 [cs:eip] 中保存的 cpu 下一条运行的机器指令的逻辑地址为 [0x08:0xcec]。

第 17 行中的汇编指令对应的机器指令的线性地址为 0x8aec。因此,第 2 次从任务 1 切换到任务 2 后,cpu 运行的第 1 条机器指令是 switch_task 函数中第 17 行中汇编指令对应的机器指令,即第 16 行第 1 次从任务 2 切换到任务 1 的汇编指令的下一条汇编指令对应的机器指令。实际上,从第 2 次开始,每次从任务 1 切换到 任务 2 后,cpu 运行的第 1 条机器指令都是 switch_task 函数中的第 17 行中汇编指令对应的机器指令。

Linux内核——任务管理相关推荐

  1. Linux内核:一文读懂文件系统、缓冲区高速缓存和块设备、超级块

    目录 前言 第一部分 Linux文件系统堆栈 VFS数据结构 文件系统初始化顺序 Dentries 打开文件-说起来容易做起来难! 虚拟文件系统 前言 第二部分 Linux文件系统堆栈 当我们键入&q ...

  2. Linux内核变迁杂谈——感知市场的力量

    Jack:什么是操作系统? 我:你买了一台笔记本,然后把整块硬盘彻底格式化,然后再自己编译出一块代码,这块代码能让这台笔记本具备任务(task)管理或者文件管理功能.或者两者兼而有之--这段代码就是操 ...

  3. 《Linux内核剖析》(Yanlz+VR云游戏+Unity+SteamVR+云技术+5G+AI+Makefile+块设备驱动+字符设备驱动+数学协处理器+文件系统+内存管理+GDB+立钻哥哥+==)

    <Linux内核剖析> <Linux内核剖析> 版本 作者 参与者 完成日期 备注 YanlzLinux_Kernel0.12_V01_1.0 严立钻 2020.02.06 # ...

  4. Linux 内核,30 年C 语言将升级至 C11

    Linux 内核,30 年C 语言将升级至 C11 还在使用 89 年版 C 语言的 Linux 内核,现在终于要做出改变了.今天,Linux 开源社区宣布,未来会把内核 C 语言版本升级到 C11, ...

  5. linux内核开机显示企鹅logo,批改linux内核kernel开机logo(小企鹅)

    修改linux内核kernel开机logo(小企鹅) 修改linux内核kernel的开机图片(原为小企鹅图片). 转载请注明出处:http://blog.csdn.net/wang_zheng_ka ...

  6. linux内核内存管理(zone_dma zone_normal zone_highmem)

    Linux 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据,因为Linux使用的虚拟内存机制,用户空间的数据可能被换出,当内核空间使用用户空间指针时,对应的数 ...

  7. Linux内核分析——可执行程序的装载

    链接的过程 首先运行C预处理器cpp,将C的源程序(a.c)翻译成ASCII码的中间文件(a.i) 接着C编译器ccl,将a.i翻译成ASCII汇编语言文件a.s 接着运行汇编器as,将a.s翻译成可 ...

  8. 【内核】嵌入式linux内核的五个子系统

    Perface Linux内核主要由进程调度(SCHED).内存管理(MM).虚拟文件系统(VFS).网络接口(NET)和进程间通信(IPC)5个子系统组成,如图1所示. 图1 Linux内核的组成部 ...

  9. 如何安装新linux内核,详解Debian系统中安装Linux新内核的流程

    一直对Linux内核很有兴趣,但苦于入门不易,认真看了ldd前5章突然就来感觉了,光看不练不顶用,首先就需要环境搭建. 使用的是Debian 5.0,内核2.6.26,欲安装的新内核为2.6.28,这 ...

最新文章

  1. 给Ubuntu 开启 root 帐号并可 SSH 登录
  2. 申请重新邮寄CCNA证书成功!!!!!(转)
  3. 加工中心局部坐标系g52设定_CNC加工中心程序代码大全,数控加工必备!
  4. Stack Overflow 2016 最新架构探秘
  5. 关于OpenMesh在Vs2008下编译与安装
  6. iOS App启动流程
  7. 关于页面的多种自适应布局——三列布局
  8. 优秀自我简介200字_急需稿件,稿费200元起/篇 | 公众号【深夜秘杏酱】长期征稿(可签约)...
  9. python 线程通信 会涉及到拷贝吗_Python如何实现线程间通信
  10. Innodb中自增长值的列
  11. babel工作笔记001---babel从入门到入门
  12. leetcode 368
  13. anaconda pycharm_使用Pycharm在anaconda环境下安装pygame库
  14. java分页 添加序号_java 分页
  15. 激励机制:区块链的幕后英雄
  16. 【大厂面试必备系列】滑动窗口协议
  17. Python沪深300成分股价值分析
  18. matlab如何写一个循环,matlab中for循环怎么写
  19. (随笔)区块链是什么??
  20. 黑客来势汹汹,受害者能以牙还牙“黑回去”吗

热门文章

  1. android 高德地图poi搜索周边
  2. canvas模拟实现高德地图的部分功能
  3. [Docer]docker镜像操作
  4. 北京交通大学c语言作业,北京交通大学c语言综合程序设计(黄宇班).doc
  5. 如何用PHP写webshell,phpAdmin写webshell的方法
  6. 如何下载B站视频,解决视频没有声音/音画分离问题(IDM+Potplayer)
  7. 电脑 蓝屏报错:SYSTMEM SCAN AT RAISED IRQL CAUGHT IMPROPER DRIVER UNLOAD
  8. 【Visual C++】游戏开发笔记四十三 浅墨DirectX教程十一 为三维世界添彩:纹理映射技术(二)
  9. Xorg屏幕旋转实现方式
  10. Vue与React的异同