操作系统的功能是替用户进程管理资源,用户进程会要求获得运行、要求获得内存、要求打开文件等。这些代码本质上是用户想执行的操作,但是由于涉及系统资源而归入到内核中,并通过系统调用而得以执行,代表着用户进程在内核态执行。
这部分代码是 “用户进程” 调用的,代表着用户进程的行为(实际上由内核执行)的代码,构成了 XV6 中的所有系统调用的各种 sys_XXX
代码。而 XV6 操作系统的 ”主动代码“ 主要就是各种初始化代码(一次性的执行)和周期性执行的 scheduler()
。而 Linux 设计的要复杂得多(还有页面清洗与回收、磁盘 IO 刷出等操作)。
注意:无论是使用老式的 PIC 还是新的 APIC 作为中断控制器,都不影响 CPU 内部对中断响应的处理过程。
在 kernel 的 main()
中将执行 tvint() 进行中断向量表的初始化。
X86 的 IDT 表共有 256 项,有部分项是与确定事件(除 0、非法指令等异常)相关联的, 它们占据了 0~31 项,剩余的表项可以自由使用。XV6 将 32~63 共 32 个表项与 32 个硬件外设中断相关联,另一些与系统调用的 INT
指令相关联。
IDT 表的内容是由 vectors.pl 生成的 vectors.S
定义的。可以看到不同的中断最后对转入到公共入口的 alltraps。不同中断将压入不同的中断号 trapno 以便相互区分,因此在执行 alltraps
之前堆栈中有相应的中断号。
alltraps
作为公共入口继续会调用 trap(),而 trap()
将会根据中断号而做不同的分发处理,也许转入 syscall() 完成系统调用,或者是个硬件中断服务程序(例如键盘中断对应的 kbdintr())。
与 X86 上的 Linux 实现类似,XV6 每个系统调用在 IDT 表中占一项,而 Linux 所有系统共用一个入口 0x80
项,XV6 和 Linux 都通过一个 IDT 表项的跳转后再经过公共处理代码区分不同功能的系统调用。其实 XV6 系统调用数量较少,也可以考虑每个系统调用占用一个 IDT 表。 但 Linux 有一两百个系统调用,如果每一个占 IDT 中的一项可能会不够用,也无法扩展到超过 256 个以上的系统调用。
IDT 表仅用于进入中断服务程序,而从中断返回的 iret
指令则不依赖于 IDT 表,而是依赖于内核栈中的数据。
从 traps.h 可以看到前 32 个中断向量号用于内部异常事件,外设 IO 中断使用 T_IRQ0 以后的向量号。
vectors.pl 生成的 perl
程序是用来生成 vectors.S
文件的。
vectors.S
的内容分成两部分:
vector0/1/2…/255
行号记录其起始地址,代码内容则是简单地将对应的编号压入堆栈(内核栈)后跳转到公共入 口 alltraps
去处理。vector0/1/2…/255
对应的地址)所构成的 IDT 表。其他 C 语言代码在引用该表时使用的是 vectors[]
变量,例如 trap.c#L13 声明为外部变量 extern uint vectors[];
,链接时将匹配本文件 vectors.S
中的 vectors
符号。
XV6 在系统启动之后,在 main.c#L30 执行 tvinit() 完成 IDT 表的初始化。
既然系统调用将执行内核代码,因此和普通函数的调用必不相同。应用程序调用 XV6 系统调用时使用的是 usys.S
汇编程序中定义的调用代码。usys.S 定义了 XV6 所提供的全部系统调用,每个系统调用的入口代码都用 SYSCALL
宏来声明,应用程序中可以直接用类似 fork()
、wait()
的形式发出系统调用。
我们用 gcc -E usys.S
进行预处理,展开所有的宏定义。可以看出系统调用是通过 int $64
指令发出的。
# 1 "usys.S"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "usys.S"
# 1 "syscall.h" 1
# 2 "usys.S" 2
# 1 "traps.h" 1
# 3 "usys.S" 2
# 11 "usys.S"
.globl fork; fork: movl $1, %eax; int $64; ret
.globl exit; exit: movl $2, %eax; int $64; ret
.globl wait; wait: movl $3, %eax; int $64; ret
.globl pipe; pipe: movl $4, %eax; int $64; ret
.globl read; read: movl $5, %eax; int $64; ret
.globl write; write: movl $16, %eax; int $64; ret
.globl close; close: movl $21, %eax; int $64; ret
.globl kill; kill: movl $6, %eax; int $64; ret
.globl exec; exec: movl $7, %eax; int $64; ret
.globl open; open: movl $15, %eax; int $64; ret
.globl mknod; mknod: movl $17, %eax; int $64; ret
.globl unlink; unlink: movl $18, %eax; int $64; ret
.globl fstat; fstat: movl $8, %eax; int $64; ret
.globl link; link: movl $19, %eax; int $64; ret
.globl mkdir; mkdir: movl $20, %eax; int $64; ret
.globl chdir; chdir: movl $9, %eax; int $64; ret
.globl dup; dup: movl $10, %eax; int $64; ret
.globl getpid; getpid: movl $11, %eax; int $64; ret
.globl sbrk; sbrk: movl $12, %eax; int $64; ret
.globl sleep; sleep: movl $13, %eax; int $64; ret
.globl uptime; uptime: movl $14, %eax; int $64; ret
用 objdump -d usys.o
得到的结果和刚才分析的一致,可以看到各个系统调用的入口如 <fork>
、<exit>
等等。
usys.o: file format elf32-i386
Disassembly of section .text:
00000000 <fork>:
0: b8 01 00 00 00 mov $0x1,%eax
5: cd 40 int $0x40
7: c3 ret
...
...
...
00000098 <sleep>:
98: b8 0d 00 00 00 mov $0xd,%eax
9d: cd 40 int $0x40
9f: c3 ret
000000a0 <uptime>:
a0: b8 0e 00 00 00 mov $0xe,%eax
a5: cd 40 int $0x40
a7: c3 ret
读者需要理清以下细节(我们以 sleep
系统调用为例):用户态代码是通过类似于 sleep()
等形式以函数调用的方式进行调用,参数压入本进程用户态堆栈,然后用 call
指令跳转到全局符号 sleep()
对应的地址处,执行上述信息 sleep
对应的三条指令,其中 int $64
将引发软中断再次根据中断向量表转向到内核代码处运行,并伴随从用户态堆栈切换到内核堆栈。此时,貌似 sleep()
的参数已经无法直接从堆栈找到了,因为堆栈指针已经指向内核栈。但是不要紧,我们仍可以找回用户态堆栈从而可以获得调用参数。
在 kernel 执行 main()->tvinit()
时完成设置中断向量表,用 SETGATE() 宏将 vectors.S
中定义的 vector[]
填写到 0~255 号中断向量。并用 SETGATE()
对系统调用 T_SYSCALL
单独处理,使得用户态代码(优先级 3)可以通过 INT
指令穿过系统调用门。对于中断使用的中断门,而对系统调用使用的是陷阱门 (不关中断)。
但是直到各处理器启动过程中执行到 mpamin()->idtinit()
的时候,才会将 IDT 表装入到 IDTR 寄存器而生效。
然后可以通过外部设备事件、CPU 内部事件、 INT 指令或 CALL 指令通过 IDT 表而产生中断的响应处理过程。
不同原因引起的中断经过 IDT 表跳转到 vector0/1/2…255
中某一个代码, 这些代码简单地压入中断号之后就跳转到 alltraps
公共处理代码处。
trapasm.S#L6 将完成 Trap Frame 的构建。其 中 pushal
将压入 8 个寄存器的值到堆栈中。trapasm.S#L13 将段寄存器 DS、ES 设置为 SEG_KDATA
。
trap() 函数首先判断 tf->trapno
是否为系统调用 T_SYSCALL
,如果是则转到 syscall()。
如果中断是硬件引起的,则调用相应的硬件中断处理函数。否则,XV6 认为是发生了错误的操作(例如除以 0 的操作),此时如果是用户态代码引起的则将当前进程 cp->killed
标志置位,否则是内核态代码只能 panic()
给出警告提示。
发生中断后如果确定是系统调用,则会执行 syscall()。根据调用号(proc->tf->eax
),从 syscalls[]
数组对应位置取出函数地址,并跳转执行。
编号为 n 的系统调用的处理函数 syscalls[n]
, 还需要获得系统调用参数,这涉及到 argint()
、argptr()
和 argstr()
等函数。这些函数也定义在 syscall.c
中,供具体的系统调用代码用以获取调用参数。XV6 使用 trapframe
里的 esp
来确定参数,argint()
进一步调用 fetchint()
从用户态堆栈读入参数并写入到 *ip
里。内核还需要确保指针在进程空间内部,虽然非法地址会引起进程的撤销,但是内核可以访问该进程以外的地址(进程传递了非法的地址指针)。
syscall()
返回到 trap()
,进而返回到 alltraps
代码段,最后通过 iret
完成系统调用的返回。
中断的公共入口代码 alltraps 和公共返回代码 trapret 都在 trapasm.S
中以汇编形式实现。 需要注意返回代码 trapret
的最后一个操作时 iret
,如果 iret
返回到另一个特权级别(将堆栈中返回地址 CS 的 RPL 和当前的 CPL 比较而判定),则在恢复程序执行之前 iret
指令还从堆栈弹出堆栈指针 ESP 与堆栈段寄存器 SS。
tvinit() 用 vectors[]
中的中断服务程序入口地址加上访问权限形成 “门” 的结构,填写到 IDT 表(idt[]
)里面。除了其中对应系统调用的 idt[T_SYSCALL]
项填写的访问权限是 DPL_USER
,表明可以从用户态通过该门,其他的 IDT 表的门都不能在用户态调用。其中 vectors[]
数组定义在生成的 vectors.S
末尾,实际上是标号地址 vector0~vector255
。而真正将 IDT 装入到 IDTR 要等待 mpamin()->idtinit()
的时候。
对于每个外设的中断,在调用相应的处理函数(例如磁盘的 ideintr()
)之后,还需要执行 lapiceoi()
向 lapic
芯片通知中断处理已经完成。
该文件定义了 XV6 系统调用的编号,一共有 21 个。
应用程序直接按照的 C 函数的形式进行系统调用,因此所有的参数都按照函数的参数传递方式,存放在函数的栈帧结构中。但是系统调用中,我们就需要从栈帧中定位参数的位置, 并设法读取相应的参数值。argint()
将系统调用的第 n 个参数作为整数指针返回,它从 trapframe
中定位第 n 个参数的位置,然后通过 fetchint()
检查地址范围并完成参数的值的读取。
保存在 eax
中。
使用 copyout() 拷贝数据到用户进程,copyout()
用 ua2ka()
将用户地址映射到内核地址。
有关进程管理的系统调用基本上都在 sysproc.c,只是简单地用 sys_XXX()
形式的函数封装了一下各种具体系统服务函数,或者就直接实现简单功能的系统调用服务。
usys.S 给出了用户态可以执行的、对应所有系统调用的函数,其作用相当于 C 语言库对系统调用的封装。例如 SYSCALL(fork)
则定义了可供用户调用的 fork()
函数,应用程序执行 fork()
将会引发 sys_fork()
系统调用。编写应用程序的时候,需要借助这些函数来进行系统调用。