Linux中断工作流程
-
中断处理过程:
-
将所有寄存器的值入栈
-
将异常码(中断号)入栈
-
将当前的函数返回值入栈(为了能够在中断执行完成后恢复到被中断的地方)
-
-
中断执行过程: 调用对应的中断执行函数
-
中断恢复过程
- 函数返回值出栈
- 寄存器值出栈
Linux0.11的内核版本还没有中断上下半部的产生,内核代码非常精简,其设计哲学就是简单直接 。它会在中断处理函数(如 do_divide_error)中,一次性完成所有相关工作,处理完后再返回被中断的进程。
原文链接:Linux内核-0.1版本的中断流程 -- kidwjb的小站
对应源码
| 中断前和中断后的过程 | 中断执行过程 | |
|---|---|---|
| 硬件中断处理过程 | asm.s | traps.c |
| 软件及系统调用的处理过程 | system_call.s | fork.c signal.c exit.c sys.c |
asm.s
8086CPU寄存器
8086CPU中寄存器总共14个,均为16位
分别是AX,BX,CX,DX,SP,BP,SI,DI,IP,FLAG,CS,DS,SS,ES
其中AX,BX,CX,DX称为数据寄存器
SP,BP称为指针寄存器
SI,DI称为变址寄存器
IP,FLAG成为控制寄存器
CS,DS,SS,ES是段寄存器
中断时栈图
这里使用的是IA32处理器,几个寄存器变量名字不一样,但是本质功能都是相同的

这里以没有错误码的no_error_code为例
_divide_error:
pushl $_do_divide_error
no_error_code:
xchgl %eax,(%esp)
pushl %ebx
pushl %ecx
pushl %edx
pushl %edi
pushl %esi
pushl %ebp
push %ds
push %es
push %fs
pushl $0 # "error code"
lea 44(%esp),%edx
pushl %edx
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
mov %dx,%fs
call *%eax
addl $8,%esp
pop %fs
pop %es
pop %ds
popl %ebp
popl %esi
popl %edi
popl %edx
popl %ecx
popl %ebx
popl %eax
iret
硬件压栈
在上面代码中可以看到在最开始就直接pushdo_divide_error函数地址了,所以根据栈图前面的ss,ESP,EFLAGSCS,以及EIP(程序断点地址)都是中断发生时硬件自动压栈
软件压栈
1.在_divide_error一开始可以看到是先把do_divide_error函数地址压栈,然后进入了no_error_code流程,此时栈顶指针sp指向了这块内存。
2.然后xchgl命令是交换ax和sp指向地址的值:
-
%eax寄存器 :拿到了_do_divide_error的函数入口地址 。这样后续执行call *%eax时,就能正确跳转到 C 语言写的除法异常处理函数。 -
栈顶
(%esp):被写入了 原始 EAX 寄存器的值。这样原始 EAX 的数据就被安全地保存在栈里了,后续和其他寄存器(EBX、ECX 等)一起,作为"现场"被保存下来。
3.然后正常push其余各个寄存器
4.当前由于没有错误码,所以直接push一个0,作为占位
5.接下来有一个指令:lea 44(%esp),%edx,这是++取sp指针加44的内存位置,存放到dx寄存器当中++ ,而sp在执行命令时正指向错误码,他与EIP正好相差44个,所以也就是获取得到EIP值在内存中的位置 ,并且后面push将EIP的地址放入栈。后续会把这个地址作为参数传递给中断处理函数
4.准备内核数据:
movl $0x10, %edx是准备内核数据段选择子:
-
0x10在 Linux 0.11 的内核中,是内核数据段(Kernel Data Segment)的选择子 (也就是KERNEL_DS)。 -
在 i386 保护模式下,段寄存器存放的是描述符索引。
0x10对应的二进制是0001 0000(索引为 2,RPL=0),它指向 GDT(全局描述符表)中的内核数据段描述符,该段的基址是 0,大小是 4GB。
mov %dx, %ds / es / fs是去替换段寄存器,在这几行代码执行之前CPU一直处于用户态,指向用户进程自己的数据空间。DS、ES、FS 里存的是用户数据段的选择子 (比如 0x17,即 USER_DS),指向用户进程自己的数据空间。
而接下来马上要执行 call *%eax,这会跳转到 C 语言编写的内核函数(如 do_divide_error)。C 函数编译后的代码,默认假设 DS 和 ES 指向内核数据段,因为:
-
它要访问内核全局变量(比如
current进程结构)。 -
它要访问内核栈上的数据。
-
它不能依赖用户态的段寄存器,否则会寻址错误,甚至导致 CPU 产生保护异常。
所以,在调用 C 函数之前 ,必须强制把 DS、ES、FS 切换为内核数据段 0x10。
5.调用中断处理函数call *%eax,这里ax存放的就是中断处理函数的地址
出栈
在调用完成中断处理函数并且切换回用户态之后,就要开始进行数据出栈
addl $8,%esp这里是把sp指针加8回到存放FS寄存器的地方
然后依次pop出栈数据,最后调用iret返回到用户进程
trap.c
die函数
仍然以上面divide_error为例,调用到trap.c中的do_divide_error
void do_divide_error(long esp, long error_code)
{
die("divide error",esp,error_code);
}
这个函数里面只做了一件事,就是调用die函数
/**
* @brief 该子程序用来打印出错中断的名称、出错号、调用程序的 EIP、EFLAGS、ESP、fs 段寄存器值、
* 段的基址、段的长度、进程号 pid、任务号、10 字节指令码。如果堆栈在用户数据段,则还
* 打印 16 字节的堆栈内容 display segment exception
*
* @param str 提示字符串
* @param esp_ptr ESP(Extended Stack Pointer)为扩展栈指针寄存器,存放函数栈顶指针;EBP(Extended Base Pointer)用于存放函数栈底指针
* @param nr error code
*/
static void die(char * str,long esp_ptr,long nr)
{
long * esp = (long *) esp_ptr;
int i;
printk("%s: %04x\n\r",str,nr&0xffff);
printk("EIP:\t%04x:%p\nEFLAGS:\t%p\nESP:\t%04x:%p\n",
esp[1],esp[0],esp[2],esp[4],esp[3]);
printk("fs: %04x\n",_fs());
printk("base: %p, limit: %p\n",get_base(current->ldt[1]),get_limit(0x17)); //get_base is in sched.h
if (esp[4] == 0x17) {
printk("Stack: ");
for (i=0;i<4;i++)
printk("%p ",get_seg_long(0x17,i+(long *)esp[3]));
printk("\n");
}
str(i);
printk("Pid: %d, process nr: %d\n\r",current->pid,0xffff & i);
for(i=0;i<10;i++)
printk("%02x ",0xff & get_seg_byte(esp[1],(i+(char *)esp[0])));
printk("\n\r");
do_exit(11); /* play segment exception */
}
die函数主要是用来打印出错中断名称,出错号,调用程序的EIP,寄存器,段等值,其中esp_ptr就是前面传递近来的参数指向的EIP在内存中的地址
在 die 中,long * esp = (long *) esp_ptr; 让 esp 指向了这个位置。从这个地址开始,从低地址到高地址的内存布局如下:
| 数组下标 | 内存地址(相对) | 存储内容 | 解释 |
|---|---|---|---|
esp[0] |
+0 |
原始 EIP |
被中断时的指令指针(偏移) |
esp[1] |
+4 |
原始 CS |
被中断时的代码段选择子 |
esp[2] |
+8 |
原始 EFLAGS |
被中断时的标志寄存器 |
esp[3] |
+12 |
原始 ESP(用户态) |
被中断时的栈指针(偏移) |
esp[4] |
+16 |
原始 SS(用户态) |
被中断时的栈段选择子 |
trap_init
中断程序初始化子程序
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);
outb_p(inb_p(0x21)&0xfb,0x21);
outb(inb_p(0xA1)&0xdf,0xA1);
set_trap_gate(39,¶llel_interrupt);
}
这里有两个函数set_trap_gate和set_system_gate,他们都是内核初始化阶段**设置中断描述符表(IDT,Interrupt Descriptor Table)**的核心操作
// 伪代码逻辑
#define set_trap_gate(n, addr) _set_gate(&idt[n], 15, 0, addr)
#define set_system_gate(n, addr) _set_gate(&idt[n], 15, 3, addr)
它们往 IDT 表中写入一个门描述符(8 字节),包含:
-
处理函数地址 (
addr,如÷_error); -
段选择子 (固定为内核代码段
0x08); -
门类型 (
15表示陷阱门,不屏蔽外部中断); -
DPL(描述符特权级)------这就是两者的关键差异。
二者的不同在于:
| 函数 | DPL(特权级) | 谁可以调用 int 触发它? |
典型用途 |
|---|---|---|---|
set_trap_gate |
0(内核态) | 仅内核代码 可以触发。用户态程序执行 int 0 会触发GPF(通用保护错)。 |
内核专用的严重异常(除零、页错误、NMI 等)。 |
set_system_gate |
3(用户态) | 用户态和内核态都可以触发。 | 允许用户程序主动调用的陷阱(调试断点 int3、溢出 into、以及系统调用 int 0x80)。 |
-
普通异常(如除零、缺页) :默认设为
set_trap_gate,只允许内核触发。用户态若发生,CPU 会先陷入内核,由内核的异常处理函数来"杀死"该进程,而不会被恶意利用。 -
故意留给用户的接口(如断点调试、系统调用) :必须设为
set_system_gate,否则用户程序根本无权执行int 3或int 0x80。