系统调用
有了操作系统后,程序员写的程序已经不能称之为一个完整的程序了,因为操作系统帮我们做了很多事,我们在写一个只有一个输出语句的main程序时,虽然代码只有一行,但是实际运行却不止一行。因为我们输出语句调用了库函数,而这个库函数是编程语言统一规定的,只要在相同的编程环境中,程序员都可以使用,避免程序员重复造轮子,极大方便了开发。
库函数实际上就是操作系统实现一个功能对程序员开放的接口,只要传入参数就可以执行,操作系统就是那个默默付出的人。如果没有系统调用,我们想要在屏幕上输出一个文字需要做很多的工作,因为屏幕属于系统共享资源,归操作系统管理,用户想要直接控制屏幕首先要将用户态下的代码段和数据段的选择子都修改为内核态下的选择子,进入内核态后获取文本适配器的内存地址、获取光标信息、设置文字模式、是否清屏等等,如果每实现一个功能就要完整写一遍这个逻辑那还得了,一定会逼疯程序员的。
操作系统为了让程序员更加注重功能逻辑上的开发在背后默默做了很多事情,操作系统提供了很多系统调用的接口,一方面避免重复造轮子,提升开发效率;另一方面很好的保护了计算机资源,让计算机资源合理分配。
在开发的过程当中更多的是关注与一个函数怎么使用,传什么样的参数等,但是很少去关注这个函数背后的实现原理,打开一个源码看起来就会头大,所以系统调用更像是黑盒,传入他需要的参数就能返回我想要的结果。那么系统调用的背后逻辑到底是怎么实现的呢?
Linux系统调用的做法
Linux的系统调用是通过中断门实现的,通过软中断int指令发送中断信号。Linux只占用一个中断向量号,就是0x80,为了区分调用哪个系统调用,通过在eax寄存器中写入子功能号确定。执行int 0x80时处于用户态,所以0x80中断号对应的中断描述符的DPL = 3.
Linux的系统调用只有0x80号才能触发是因为操作系统中有非常多的系统调用,如果每个系统调用都需要占用一个中断号,intel支持的256个向量号根本不够用。所以Linux采用int 0x80触发系统调用,使用eax提供具体的系统调用子功能号。创建一个地址数组,按序存放系统调用的函数地址,通过eax索引这个系统调用数组去执行。
Linux系统下的部分系统调用编号及对应的系统函数

系统调用的实现
流程逻辑实现
①用中断门实现系统调用,仿照Linux用0x80号中断作为系统调用的入口
②在IDT中安装0x80号中断号对应的中断描述符,在这个中断描述符中注册系统调用对应的中断处理例程
②建立系统调用子功能表syscall_table和系统调用的子功能号,利用eax中的子功能号索引syscall_table(eax的值就是syscall_table数组的索引下标)
③用宏实现用户空间系统调用接口_syscall,最大支持3个参数的系统调用。寄存器传参,eax为子功能号,ebx保存第一个参数,ecx保存第二个参数,edx保存第三个参数。
④如果函数有返回值,也会存储在eax中返回的
具体过程实现
1. 增加0x80号中断描述符
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| #define IDT_DESC_CNT 0x81 // 目前总共支持的中断数
/*中断门描述符结构体*/ struct gate_desc { uint16_t func_offset_low_word; uint16_t selector; uint8_t dcount; //此项为双字计数字段,是门描述符中的第4字节。此项固定值,不用考虑 uint8_t attribute; uint16_t func_offset_high_word; };
/* 创建中断门描述符 */ static void make_idt_desc(struct gate_desc* p_gdesc, uint8_t attr, intr_handler function) { p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000FFFF; p_gdesc->selector = SELECTOR_K_CODE; p_gdesc->dcount = 0; p_gdesc->attribute = attr; p_gdesc->func_offset_high_word = ((uint32_t)function & 0xFFFF0000) >> 16; }
/*初始化中断描述符表*/ static void idt_desc_init(void) { int i, lastindex = IDT_DESC_CNT - 1; for (i = 0; i < IDT_DESC_CNT; i++) { make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); } /* 单独处理系统调用,系统调用对应的中断门dpl为3, * 中断处理程序为单独的syscall_handler */ make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler); put_str(" idt_desc_init done\n"); }
/* 完成一般中断处理函数注册及异常名称注册 */ static void exception_init(void) { // 完成一般中断处理函数注册及异常名称注册 int i; for (i = 0; i < IDT_DESC_CNT; i++) {
/* idt_table数组中的函数是在进入中断后根据中断向量号调用的, * 见kernel/kernel.S的call [idt_table + %1*4] */ idt_table[i] = general_intr_handler; // 默认为general_intr_handler。 // 以后会由register_handler来注册具体处理函数。 intr_name[i] = "unknown"; // 先统一赋值为unknown } intr_name[0] = "#DE Divide Error"; intr_name[1] = "#DB Debug Exception"; intr_name[2] = "NMI Interrupt"; intr_name[3] = "#BP Breakpoint Exception"; intr_name[4] = "#OF Overflow Exception"; intr_name[5] = "#BR BOUND Range Exceeded Exception"; intr_name[6] = "#UD Invalid Opcode Exception"; intr_name[7] = "#NM Device Not Available Exception"; intr_name[8] = "#DF Double Fault Exception"; intr_name[9] = "Coprocessor Segment Overrun"; intr_name[10] = "#TS Invalid TSS Exception"; intr_name[11] = "#NP Segment Not Present"; intr_name[12] = "#SS Stack Fault Exception"; intr_name[13] = "#GP General Protection Exception"; intr_name[14] = "#PF Page-Fault Exception"; // intr_name[15] 第15项是intel保留项,未使用 intr_name[16] = "#MF x87 FPU Floating-Point Error"; intr_name[17] = "#AC Alignment Check Exception"; intr_name[18] = "#MC Machine-Check Exception"; intr_name[19] = "#XF SIMD Floating-Point Exception";
}
|
2. 增加0x80号中断处理程序
在中断描述符表IDT中安装0x80号中断对应的中断描述符,因为调用int 0x80时仍然处于用户态,所以中断描述符的DPL要为3。注册0x80号中断向量对应的中断处理程序,0x80号中断处理程序的名称记为syscall_handler
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
| ;;;;;;;;;;;;;;;; 0x80号中断 ;;;;;;;;;;;;;;;; [bits 32] extern syscall_table section .text global syscall_handler syscall_handler: ;1 保存上下文环境 push 0 ; 压入0, 使栈中格式统一
push ds push es push fs push gs pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是: ; EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI push 0x80 ; 此位置压入0x80也是为了保持统一的栈格式
;2 为系统调用子功能传入参数 push edx ; 系统调用中第3个参数 push ecx ; 系统调用中第2个参数 push ebx ; 系统调用中第1个参数
;3 调用子功能处理函数 call [syscall_table + eax*4] ; 编译器会在栈中根据C函数声明匹配正确数量的参数 add esp, 12 ; 跨过上面的三个参数
;4 将call调用后的返回值存入待当前内核栈中eax的位置 mov [esp + 8*4], eax jmp intr_exit ; intr_exit返回,恢复上下文
|
2.创建中断处理程序syscall_handler
***【保存上下文环境】:***因为系统调用由中断触发,中断号是0x80,执行系统调用是在0x80号中断处理程序中调用执行的,所以在syscall_handler中要保护程序的上下文环境(进程/线程的上下文环境),保存的有4个段寄存器和8个通用寄存器。执行流被中断时需要进行断点保护操作,保存什么数据在哪存放看《深入理解中断机制》那篇文章。
***【压入系统调用参数】:***系统调用时是需要参数的,而Linux下的系统调用参数传递使用的是寄存器而不是栈,原因在上面已解释。所以在syscall_handler中还要压入参数(ebx存放第一个参数,ecx存放第二个参数,edx存放第三个参数)。
***【调用系统调用具体函数】:***创建一个地址数组,这个地址数组中存放的是每种系统调用的具体函数,通过eax子功能号索引对应的系统调用函数去执行。
***【恢复上下文环境】:***执行完成后退出中断,恢复上下文环境(将4个段寄存器+8个通用寄存器全部弹出到对应的寄存器中)
1
| jmp intr_exit ; intr_exit返回,恢复上下文
|
3.创建系统调用程序
创建系统调用处理数组,void* syscall_table[syscall_nr],然后创建系统调用对应的函数,例如创建sys_getpid()系统调用,将sys_getpid注册到syscall_table中。其中syscall_nr表示最大支持的系统调用子功能个数。
4.在用户程序中实现系统调用
创建用户系统调用接口,实际上就是对sys_getpid()的封装。
例如:uint32_t getpid(){
//使用宏定义实现系统调用
return _syscall0(SYS_GETPID);
}
总结:以获取进程pid为例子创建系统调用为例其详细的系统调用过程
创建中断门描述符,DPL=3,让用户程序通过int 8位立即数调用
创建中断处理程序:保存程序的上下文环境(第一部分:进入中断时由硬件自动保存断点; 第二部分,手动压入程序的上下文环境,包括4个段寄存器和8个通用寄存器),在中断处理程序中通过call [系统调用处理数组syscall_table起始地址* 子功能号eax*4]
创建系统调用函数:
uint32_t sys_getpid(void){ return running_thread()->pid }
- 添加系统调用子功能号
enum SYSCALL_NR {
SYS_GETPID;
}
- 将系统函数注册到系统子数组中
syscall_table[SYS_GETPID] = sys_getpid;
- 创建用户接口
*通过宏定义系统调用*
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| #define _syscall0(NUMBER)({ // 定义一个局部变量 retval,用于存储系统调用的返回值 int retval;
asm volatile( // 触发软中断 0x80,进入内核态执行系统调用 "int $0x80" // 输出部分:将 eax 寄存器的值存储到 retval 中 :"=a"(retval)
// 输入部分:将系统调用号 NUMBER 放入 eax 寄存器 :"a"(NUMBER)
// 破坏部分:告诉编译器内存可能被修改 :"memory" ); retval; // 返回 retval 的值 }) uint32_t getpid(){ return _syscall0(SYS_GETPID); }
|
Linux的系统调用使用的是栈还是寄存器
使用的是寄存器。
eax用来保存子功能号
ebx保存第一个参数
ecx保存第二个参数
esi保存第三个参数
因为寄存器快:根据C调用约定(调用者把参数从右向左的顺序压入栈中,并且由调用者清理堆栈中的参数 ),系统调用所用的参数会被压入用户栈中,当执行0x80后,陷入内核态,此时需要使用0特权级的栈,但系统调用使用的参数还在用户栈中,为了获取用户栈地址,还得在特权级栈中获取进入中断时处理器自动压入的ss_old和esp_old,然后再次从用户栈中获取系统调用参数。这比直接使用寄存器要慢很多。