Linux0.12内核源码解读(7)-陷阱门初始化

作者:小牛呼噜噜 | https://xiaoniuhululu.github.io
计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜

哈喽,大家好呀,我是呼噜噜,好久没有更新old linux了,本文接着上一篇文章Linux0.12内核源码解读(6)-main.c
,继续我们的内核探索之路

trap_init()

上一篇我们讲解了main.c中的mem_init(),我们接着来看看trap_init陷阱门初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mem_init(main_memory_start,memory_end);// 主内存区初始化
trap_init(); // 陷阱门(硬件中断向量)初始化 !!!
blk_dev_init(); // 块设备初始化
chr_dev_init(); //字符设备初始化
tty_init(); // tty 初始化
time_init(); //设置开机启动时间
sched_init(); //调度程序初始化(加载进程0 的 tr,ldtr)
buffer_init(buffer_memory_end); // 缓冲管理初始化,建内存链表等
hd_init(); // 硬盘初始化
floppy_init(); //软驱初始化
sti(); //开启中断
move_to_user_mode(); //移到用户模式下执行。
if (!fork()) { //永远不会退出,如果退出就死机了。
init(); //在新建子进程(进程 1)中执行,init() 会启动一个 shell
}

trap_init()具体实现是在kernel/traps.c中:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//函数定义
void page_exception(void);

void divide_error(void);//int0 中断处理,divide_error除以0时触发
void debug(void);//int1 中断处理
void nmi(void);//int2 中断处理
void int3(void);
void overflow(void);
void bounds(void);
void invalid_op(void);
void device_not_available(void);
void double_fault(void);
void coprocessor_segment_overrun(void);
void invalid_TSS(void);
void segment_not_present(void);
void stack_segment(void);
void general_protection(void); //int13 中断处理
void page_fault(void);// int14 中断处理
void coprocessor_error(void);// int16 中断处理
void reserved(void);// int15 中断处理
void parallel_interrupt(void);// int39 中断处理
void irq13(void);// int45 中断处理
void alignment_check(void);// int46 中断处理


void trap_init(void)
{
int i;

set_trap_gate(0,&divide_error);//设置陷阱门
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); //断点陷阱中断int3
set_system_gate(4,&overflow); //溢出中断overflow
set_system_gate(5,&bounds); //边界出错中断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);
set_trap_gate(17,&alignment_check);
for (i=18;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);
outb_p(inb_p(0x21)&0xfb,0x21);// 允许 8259A 主芯片的 IRQ2 中断请求
outb(inb_p(0xA1)&0xdf,0xA1); // 允许 8259A 从芯片的 IRQ13 中断请求。
set_trap_gate(39,&parallel_interrupt);//设置并行口1的中断0x27(=39)陷阱门描述符
}

上面这一大段是陷阱中断程序初始化的子程序,是不是看起来很多没有头绪

set_trap_gate和set_system_gate

别怕都是纸老虎,我们先来看第一个set_trap_gate(0,&divide_error)set_trap_gate()函数的具体实现是在/include/asm/system.h文件中,这部分的汇编还是写的非常绕的:

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
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ (
"movw %%dx,%%ax\n\t" \ //将偏移地址低字与选择符组合成描述符低4字节(eax)
"movw %0,%%dx\n\t" \ //将类型标志与偏移地址高字组合成描述符高4字节(edx)
"movl %%eax,%1\n\t" \ //设置门描述符idt[n]的低4字节,表示把32位寄存器eax的值给(*((char *) (gate_addr)))
"movl %%edx,%2" \ //设置门描述符idt[n]的高4字节,把32位寄存器edx的值给(*(4+(char *) (gate_addr)))
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ //0x8000对应P=1,P等于1表示描述符指向的内容存在内存中;dpl=0表示描述符特权级,type=15表示描述符类型
"o" (*((char *) (gate_addr))), \ //gate_addr表示描述符存储地址;这行表示gate_addr低4字节
"o" (*(4+(char *) (gate_addr))), \ //这行表示gate_addr高4字节
"d" ((char *) (addr)),"a" (0x00080000)) //addr表示偏移地址;把偏移地址放到edx,对应函数divide_error的地址,再把0x00080000放到eax

//设置中断门函数,特权级0
#define set_intr_gate(n,addr) \
_set_gate(&idt[n],14,0,addr)

//设置陷阱门函数,特权级0
#define set_trap_gate(n,addr) \
_set_gate(&idt[n],15,0,addr)

// 设置系统调用函数,特权级3
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)


_set_gate这个宏是内联汇编的写法,通俗点讲就是在C语言中嵌入汇编指令,将操作数统一编号来引用,比如这里的%0,%1,%2%%是因为GCC在编译时会将%视为特殊字符,%%仅仅是为了汇编的%不被GCC全部转译

另外i,o,d,a表示寄存器加载代码,”i”表示立即数,”o”表示使用内存地址并可以加偏移值,”d”使用寄存器edx,”a”使用寄存器eax,这段汇编参数如下:

1
2
3
4
5
6
7
8

参数:gate_addr -描述符地址;type -描述符类型;dpl -描述符特权级;addr -偏移地址

%0 - (由dpl,type 组合成的类型标志字);
%1 - (描述符低4 字节地址);
%2 - (描述符高4 字节地址);
%3 - edx(程序偏移地址,(char *) (addr));
%4 - eax(高字中含有段选择符,0x00080000)

其中&idt[n]不就是操作系统前面设置的中断描述符表IDT嘛,IDT 是一个 8 字节描述符数组(在保护模式下),最多有256个中断或异常向量,它记录着0~255的中断号和调用函数之间的关系

再结合上图是IDT的结构参数图,所以这里我们可以知道set_trap_gate函数中参数n代表中断向量号,参数addr代表中断服务函数的地址addr 是异常处理函数入口点的偏移地址,因为内核代码段的线性基址是0,所以偏移地址就是线性地址,我们知道1M内的低端内存线性地址就等于物理地址,所以addr就是中断服务函数的地址

movw %%dx,%%ax表示把低16位的dx值赋值给低16位的ax中,此时eax中的值就是0x0008+addr偏移地址0x0008是段选择符(选择子),二进制就是0b1000,索引是1(索引0默认是null), TI=0(GDT), 请求特权级别RPL=0,其中TI=0选择子是在GDT中,TI=1选择子是在LDT中;最终表示在GDT中查找,索引为1的值。

"a" (0x00080000)0x00080000放到eax;高16位0x0008是段选择符,低16位会被放在edx中的过程偏移低16位代替,目前就是0,那为什么设置这个值呢?

笔者翻了下之前的源码,发现在head.s中有这相关的参数设置,当时还不太理解movl $0x00080000,%eax,原来是和这样前后呼应了啊

1
2
3
4
5
6
7
8
9
10
11
12
setup_idt:
lea ignore_int,%edx #将 ignore_int 的有效地址(偏移值)值 赋值给 edx 寄存器

movl $0x00080000,%eax # 将段选择符 0x0008 置入 eax 的高 16 位中!!!

movw %dx,%ax /* selector = 0x0008 = cs */ # 偏移值的低 16 位置入 eax 的低 16 位中。此时 eax 含有门描述符低 4 字节的值

movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ #此时 edx 含有门描述符高 4 字节的值


lea _idt,%edi # _idt 是中断描述符表的地址, 取idt的偏移给edi
mov $256,%ecx #循环256

_set_gate(gate_addr,type,dpl,addr)此宏就是用于设置门描述符,即填写IDT数组中的一项;所以set_trap_gate(0,&divide_error)就是设置int0中断描述符,其对应的中断函数divide_error,divide_error函数我们暂时不讲,稍后下文再讲

我们再回到源码处,发现后面的代码其实都是类似的,例如

  1. 30~47行(这里的行数仅以本文截取的代码段行数为准): 依此设置IDT的某一项中断描述符,set_trap_gate、set_system_gate函数引用了同一个宏定义:_set_gate,它们的主要区别就是前者的特权级DPL = 0,后者的特权级DPL = 3,相同的是type=15表示陷阱门描述符,IDT还可以存放其他类型的门描述符,比如调用门、中断门、任务门等。这里我们先做简单介绍,后面系列文章我们再详细讲,敬请期待
  2. 48~49行:把int17 ~ int48的陷阱门先设置为reserved,后面各个硬件初始化时会重新设置自己的陷阱门
  3. 50~53行:设置协处理器 int45(0x20+13)陷阱门描述符,并允许其产生中断请求;0x21是 8259A 主片命令字OCW1的端口地址,用于对其中断屏蔽寄存器 IMR 进行读/写操作;0xA1则是 8259A 从片命令字OCW1的端口地址

当执行完trap_init(),我们来看下此时的内存分布图:

从divide_error来看中断处理函数的调用

当进行除以零的操作时产生中断,执行divide_error,我们再回到set_trap_gate(0,&divide_error)divide_error这个函数定义在/kernel/asm.s

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
.globl _divide_error,_debug,_nmi,_int3,_overflow,_bounds,_invalid_op
//.globl xx表示将符号标记为一个全局符号,以供其他文件访问!!!
.globl _double_fault,_coprocessor_segment_overrun
.globl _invalid_TSS,_segment_not_present,_stack_segment
.globl _general_protection,_coprocessor_error,_irq13,_reserved
.globl _alignment_check

_divide_error:
pushl $_do_divide_error # 首先把将要调用的函数地址入栈;_do_divide_error是C函数do_divide_error被编译后的名字
no_error_code:
# 保存被中断的进程的上下文
xchgl %eax,(%esp) #_do_divide_error的地址→eax,eax被交换入栈
pushl %ebx
pushl %ecx
pushl %edx
pushl %edi
pushl %esi
pushl %ebp
push %ds # 16 位的段寄存器入栈后也要占用 4 个字节
push %es
push %fs
pushl $0 # "error code",将数值 0 作为出错码入栈
lea 44(%esp),%edx # 取有效地址,即栈中原调用返回地址处的栈指针位置
pushl %edx # 并压入堆栈(即esp0 指针入栈)

# 所有段寄存器都设置为内核数据段选择符,设置好数据寻址的基址
movl $0x10,%edx # 初始化段寄存器ds、es和fs,加载内核数据段选择符
mov %dx,%ds
mov %dx,%es
mov %dx,%fs
call *%eax #* 号表示调用操作数指定地址处的函数,称为间接调用,这里就是执行C语言函数do_divide_error

# 恢复被中断进程的上下文
addl $8,%esp
pop %fs
pop %es
pop %ds
popl %ebp
popl %esi
popl %edi
popl %edx
popl %ecx
popl %ebx
popl %eax # 弹出原来eax中的内容
iret # 返回中断处理之前的程序,继续执行后续指令


C语言函数do_divide_errorkernel/traps.c,它的参数都在栈中, esp就是do_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
void do_divide_error(long esp, long error_code)//是asm.s中对应中断处理程序_do_divide_error调用的C函数
{
die("divide error",esp,error_code);//打印信息
}

//该子程序用来打印出错中断的名称、出错号、调用程序的EIP、EFLAGS、ESP、fs 段寄存器值、
// 段的基址、段的长度、进程号pid、任务号、10 字节指令码。如果堆栈在用户数据段,则还
// 打印16 字节的堆栈内容
static void die(char * str,long esp_ptr,long nr)
{
long * esp = (long *) esp_ptr;
int i;
// 打印语句显示当前调用进程的CS:EIP、EFLAGS和SS:ESP的值
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));
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");
//上面都是打印调试信息 这里才是真正的处理逻辑,在这里是直接退出,错误码为11
do_exit(11); /* play segment exception */
}

其他中断函数都是类似的,比如debug,nmi,breakpoint等,大家感兴趣地,可以自行去看看,这些中断处理函数的作用都列下图中:

根据上述源码,我们来总结一下linux内核是如何调用中断处理函数流程

需要注意的是,中断处理函数使用的栈都是内核的内存空间,而内核代码段的选择符值是0x08,早在前文gdt初始化中设置了; 若无出错码时就使用0;另外内核中的栈每次起始地址都是一样的,当函数调用结束后,栈又会变空,因此内核栈是可以重复利用的


参考资料:

https://elixir.bootlin.com/linux/0.12/source/init/main.c

https://elixir.bootlin.com/linux/0.12/source/kernel/traps.c

《Linux内核完全注释5.0》

英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A-英特尔®


系列文章目录

  1. 聊聊x86计算机启动发生的事?
  2. Linux0.12内核源码解读(2)-Bootsect.S
  3. Linux0.12内核源码解读(3)-Setup.S
  4. 图解CPU的实模式与保护模式
  5. Linux0.12内核源码解读(5)-head.s
  6. Linux0.12内核源码解读(6)-main.c

全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的,你的支持会激励我输出更高质量的文章,感谢!

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!