【Linux 0.12】内核代码——中断与异常处理

内核代码

在 init/main.c 的 main 函数中,调用了 trap_init() 函数对硬件中断向量进行初始化。trap_init() 位于 kernel/traps.c,内部负责设置向量号对应的处理程序。而处理程序的定义则位于 asm.s 中。

asm.s 程序

asm.c 程序包括大部分 CPU 探测到的异常故障处理的底层代码,也包括数学协处理器的异常处理。

应当重点关注故障处理时的内核堆栈变化。

divide_error 为例(divide_error 无出错号):

个人理解:出错号是传递给中断服务程序的一个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
_divide_error:
push dword ptr _do_divide_error ;// 首先把将要调用的函数地址入栈。这段程序的出错号为0。
no_error_code: ;// 这里是无出错号处理的入口处,见下面第55 行等。
xchg [esp],eax ;// _do_divide_error 的地址 -> eax,eax 被交换入栈。
push ebx
push ecx
push edx
push edi
push esi
push ebp
push ds ;// !!16 位的段寄存器入栈后也要占用4 个字节。
push es
push fs
push 0 ;// "error code" ;// 将出错码入栈。
lea edx,[esp+44] ;// 取原调用返回地址处堆栈指针位置,并压入堆栈。
push edx
mov edx,10h ;// 内核代码数据段选择符。
mov ds,dx
mov es,dx
mov fs,dx
call eax ;// 调用C 函数do_divide_error()。
add esp,8 ;// 让堆栈指针重新指向寄存器fs 入栈处。
pop fs
pop es
pop ds
pop ebp
pop esi
pop edi
pop edx
pop ecx
pop ebx
pop eax ;// 弹出原来eax 中的内容。
iretd
  1. 在开始执行相应的中断服务程序之前,堆栈指针 esp 指向中断返回地址。(类似函数调用)
  2. 然后压入处理程序地址 push dword ptr _do_divide_error,病调用 xchg 将处理程序地址保存至 eax,eax 的值则入栈,完美。
  3. 保存其他各种寄存器的值
  4. 调用中断服务程序
  5. 恢复寄存器,中断返回

事实上,中断服务程序的代码并没有显示,asm.c 似乎只定义了保存现场环境的功能。

trap.c 程序

主要包括一些 asm.c 中调用的相应的 C 函数。其中 die() 函数用于在中断处理中显示详细的出错信息。

trap_init() 函数负责初始化中断向量。

1
2
3
4
5
6
7
8
9
10
//// 设置陷阱门函数。
// 参数:n - 中断号;addr - 中断程序偏移地址。
// &idt[n]对应中断号在中断描述符表中的偏移值;中断描述符的类型是15,特权级是0。
#define set_trap_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,0,(unsigned long)addr)
//// 设置系统调用门函数。
// 参数:n - 中断号;addr - 中断程序偏移地址。
// &idt[n]对应中断号在中断描述符表中的偏移值;中断描述符的类型是15,特权级是3。
#define set_system_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,3,(unsigned long)addr)

trap_init() 函数里使用了上述两个宏定义来初始化中断向量。他们的区别在于特权级不同。set_system_gate 特权级是 3,可以由用户态程序发出。

sys_call.s 程序

系统调用使用中断 int 0x80 和功能号(保存在 eax) 来使用内核提供的各种功能服务。

对于所有系统调用的实现函数,内核将他们按照系统调用功能号按顺序排列成一张函数指针表,然后在 int 0x80 的处理过程中根据 eax 的功能号调用对应系统调用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 位于 include/linux/sys.h
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

sys_call.s 主要实现了:

  1. int 0x80 的入口处理过程以及信号检测处理
  2. 实现了两个系统功能的底层接口(sys_execve 和 sys_fork)
  3. 其他非系统调用的中断处理程序,包括 int16, int7, int32 等

对于软中断,主要是先为 C 函数处理程序做准备,压入参数,或者设置寄存器,然后调用对应的服务程序(C 语言编写)。处理完成后检测当前任务的信号位图,处理信号。

对于硬件中断, 。。。。。

对于系统调用,基本上对应的程序都是 C 语言程序:

  1. 首先检查系统调用号 (eax) 是否有效
  2. 将一些会用到的寄存器保存到栈中。(linux 内核默认把段寄存器 ds, es 用于内核数据段,fs 用于用户数据段)
  3. 通过一个地址跳转表 sys_call_table 调用对应的系统调用函数
  4. 函数返回后,将返回值压入堆栈保存起来
  5. 后续会执行调度程序,处理信号等等

附:系统调用发生的全过程

在系统启动时,会在 sched_init(void) 函数中调用 set_system_gate(0x80,&system_call) ,设置中断向量号0x80的中断描述符:

1
2
3
4
// kernel/sched.c sched_init 函数
#define set_system_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,3,(unsigned long)addr)
set_system_gate(0x80, &system_call);

system_call 函数则定义于 kernel/system_call.s

注意:在进入 system_call() 函数之前,eax,ebx,ecx,edx

  1. 首先检查系统调用号 (eax) 是否有效
1
2
3
_system_call:
cmp eax,nr_system_calls-1 ;// 调用号如果超出范围的话就在eax 中置-1 并退出。
ja bad_sys_call
  1. 将一些会用到的寄存器保存到栈中。(linux 内核默认把段寄存器 ds, es 用于内核数据段,fs 用于用户数据段)
1
2
3
4
5
6
push ds ;// 保存原段寄存器值。
push es
push fs
push edx ;// ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。
push ecx ;// push %ebx,%ecx,%edx as parameters
push ebx ;// to the system call
  1. 通过一个地址跳转表 sys_call_table 调用对应的系统调用函数
1
2
3
4
5
6
7
mov edx,10h ;// set up ds,es to kernel space
mov ds,dx ;// ds,es 指向内核数据段(全局描述符表中数据段描述符)。
mov es,dx
mov edx,17h ;// fs points to local data space
mov fs,dx ;// fs 指向局部数据段(局部描述符表中数据段描述符)。

call [_sys_call_table+eax*4]

为什么 0x10h 指向全局描述符表中的数据段描述符?

为什么 0x17h 指向局部描述符表中的数据段描述符?

  1. 函数返回后,将返回值压入堆栈保存起来
1
push eax ;// 把系统调用号入栈。
  1. 检查当前任务运行的状态。如果不在就绪状态的话,则需要执行调度程序,选取新的任务执行。如果已经是就绪态,但是时间片 counter 为 0,那也需要执行调度程序。
1
2
3
4
5
mov eax,_current 
cmp dword ptr [state+eax],0 ;// state
jne reschedule
cmp dword ptr [counter+eax],0 ;// counter
je reschedule
  1. 进行到这里,说明当前任务是活跃的,且还有时间片支持运行。但是在返回原程序执行之前,还需要处理信号

    1. 如果当前程序是初始任务 task[0],那么就直接返回

      1
      2
      3
      mov eax,_current ;// task[0] cannot have signals
      cmp eax,_task
      je l1 ;// 向前(forward)跳转到标号l1。l1是程序的返回代码,恢复了寄存器并执行 iretd
    2. 如果当前用户是超级用户,那么就直接返回到原程序执行。(超级用户不被抢占?内核态程序不抢占原则?)

      1
      2
      cmp word ptr [R_CS+esp],0fh ;// was old code segment supervisor ?
      jne l1
    3. 如果原堆栈段选择符不是 0x17(即原来的堆栈不在用户数据段),那么也直接返回

      1
      2
      cmp word ptr [OLR_DSS+esp],17h ;// was stack segment = 0x17 ?
      jne l1
    4. 如果不是上述三种情况,则需要处理信号

      获取信号位图,查看当前进程收到了哪些信号,然后获取信号屏蔽码,过滤不允许的信号,然后获取数值最小的被允许的信号。将该信号值作为参数之一,调用 do_signal()