本节分支:printk
系统调用与API
之前很长一段时间,笔者都将系统调用和 API 函数混为一谈,实际上两者有较大区别。
API (Application Programming Interface,应用程序接口) ,其主要功能是提供通用功能集 ,程序员通过调用 API 对应用程序进行开发,可以减轻编程任务。
API 可以简单的理解为一个通道或者桥梁,是一个程序和其他程序进行沟通的媒介,本质上一个函数。比如我们想往屏幕上打印字符,显然,如果自己从头实现,则需要了解显卡、汇编等知识,无疑相当麻烦。而且 C 库中早就为我们准备了打印函数,即 printf,你只需要按它的要求传入参数就行,无需了解 printf 内部实现。所以 printf 也可以称为 API 。说白了,接口,就是指两个不同程序之间交互的地方 ,就这么简单。
而系统调用是一种特殊的接口,通过这个接口,用户可以访问内核空间,进而实现一些只有内核才能完成的操作,比如屏幕打印、内存申请(malloc)等。
那么这两者有什么区别呢?严格来说,两者没有直接关系,但一般而言,系统调用一般封装在 API 中,但不是所有 API 内部都会进行系统调用。API 的提供者是运行库,运行库则使用操作系统提供的系统调用接口 ,如果再往下,内核则调用驱动程序,由驱动程序来和硬件打交道。
系统调用实现原理
系统调用的直接目的是进入 ring0,以便进行一些只有 ring0 才能完成的工作。我们之前说过,想要从低特权级进入高特权级,则只能通过门完成。由于调用门开销较大,Linux 选择通过中断门进入高特权级,并进行系统调用。Linux 系统调用的中断号为 0x80,子功能号存入 eax,而 ebx、ecx、edx、esi 和 edi 则依次传递最多五个参数,当系统调用返回时,返回值存放在 eax 中 。
如果要传入五个以上的参数,则需要使用栈传递参数,后文将演示这一过程。
如果细分,Linux 系统调用可以分为三种方式:
通过 glibc 提供的库函数
glibc 是 Linux 下使用的开源的标准 C 库。glibc 为程序员提供丰富的 API,除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了系统调用。比如通过 glibc 提供的 chmod
函数来改变文件 etc/passwd
的属性为 444:
1 2 3 4 5 6 7 8 9 10 int main () { int rc; rc = chmod("/etc/passwd" , 0444 ); if (rc == -1 ) fprintf (stderr , "chmod failed, errno = %d\n" , errno); else printf ("chmod success!\n" ); return 0 ; }
使用syscall "
syscall 也由库函数提供,但相比于其他调用方式, syscall 则更加灵活,比如你通过编译内核增加了一个系统调用,这时 glibc 不可能有你新增系统调用的封装 API,所以你可以利用 glibc 提供的 syscall
函数直接调用,其原型如下:
1 long int syscall (long int sysno, ...)
其中 sysno 是系统调用号(子功能号),每个系统调用都有唯一的系统调用号来标识;...
则是可变参数列表,根据系统调用的不同,可带0~5个不等的参数,如果超过特定系统调用能带的参数,多余的参数被忽略。
通过int指令调用
直接通过内联汇编进行系统调用:
1 2 3 4 5 6 7 8 9 10 11 12 int main () { long rc; char *file_name = "/etc/passwd" ; unsigned short mode = 0444 ; asm ( "int $0x80" :"=a" (rc) :"0" (SYS_chmod), "b" ((long )file_name), "c" ((long )mode) ); }
容易知道,这三种方式最终都会使用 int
指令进行系统调用 。
实现系统调用
添加_syscallX
实际上,库函数也是通过操作系统提供的 _syscallX 宏来进行系统调用,其中 X 是参数个数,以 _syscall3 举例,其定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 #define _syscall3(type, name, atype, a, btype, b, ctype, c) \ type name(atype a,btype b,ctype c){ \ long __res; \ asm volatile \ ("int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))) \ if (__res>=0) \ return (type) __res; \ errno=-__res; \ return -1; \ }
各位无需了解以上代码的含义,咋们会契合自己的操作系统,使用更简单的方式实现。另外,此 _syscallX 已经被 Linux 废弃,但为了简单,我们仍模仿 _syscallX 进行系统调用。
由于我们的操作系统最多只会使用三个参数的系统调用,所以这里咋们只实现 0~3 个参数的系统调用,如下:
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 static uint32_t callno_ret;static uint32_t arg1;static uint32_t arg2;static uint32_t arg3;uint32_t _syscall0(uint32_t no){ callno_ret = no; asm ("mov eax,callno_ret" ); asm ("int 0x80" ); asm ("mov callno_ret,eax" ); return callno_ret; } uint32_t _syscall1(uint32_t no, uint32_t _arg1){ callno_ret = no; arg1 = _arg1; asm ("mov eax,callno_ret" ); asm ("mov ebx,arg1" ); asm ("int 0x80" ); asm ("mov callno_ret,eax" ); return callno_ret; } uint32_t _syscall2(uint32_t no, uint32_t _arg1, uint32_t _arg2){ callno_ret = no; arg1 = _arg1; arg2 = _arg2; asm ("mov eax,callno_ret" ); asm ("mov ebx,arg1" ); asm ("mov ecx,arg2" ); asm ("int 0x80" ); asm ("mov callno_ret,eax" ); return callno_ret; } uint32_t _syscall3(uint32_t no, uint32_t _arg1, uint32_t _arg2, uint32_t _arg3){ callno_ret = no; arg1 = _arg1; arg2 = _arg2; arg3 = _arg3; asm ("mov eax,callno_ret" ); asm ("mov ebx,arg1" ); asm ("mov ecx,arg2" ); asm ("mov edx,arg3" ); asm ("int 0x80" ); asm ("mov callno_ret,eax" ); return callno_ret; }
关于为什么要使用静态变量,这已在之前的文章多次提及,不再说明。
啊哈,很简单吧!这里只解释 mov callno_ret,eax
:因为系统调用也遵循 ABI 规范,即,将返回值存入 eax 中,所以我们还要将 eax 转移到静态变量 callno_ret 中,并将其返回(callno_ret 即说明它既用来存放调用号,也用来作为返回值)。
编写中断入口函数
进入 0x80 中断例程后,代码会根据传入的调用号跳转到相应的函数,函数执行完毕后回到中断,再通过 iret 返回到用户态。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 ;文件说明:interrupt.s ;......上文忽略....... ;;;;;;;;;;;;;;;; 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,EID 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返回,恢复上下文
以上代码的格式和之前中断处理的格式完全相同,不再赘述。
syscall_handler
为系统调用的入口,所有系统调用都会通过该入口函数进入到指定的子功能处理函数。
第 25 行, syscall_table
是在 syscall_init.c
中定义的指针数组,该数组中存放的是各个系统调用的指针。
第 28 行,将存放返回值的 eax 存入内核栈的相应位置。为什么要这样呢?因为从用户态进入中断时,保存现场,存放调用号的 eax 被存入中断栈;所以从中断返回,恢复现场时,调用号重新被放入 eax;但 eax 必须用来存放返回值,所以必须将返回值提前放入中断栈的相应位置处,这样才能在返回用户态后从 eax 取得返回值。
为 0x80 中断例程建立中断描述符
想要通过 0x80 正确进入到相应例程,就必须建立相应的中断描述符。
1 2 3 4 5 6 7 8 9 10 11 12 13 #define IDT_DESC_CNT 0x81 void idt_desc_init () { for (int i = 0 ; i < IDT_DESC_CNT; i++) { make_idt_desc(&idt[i], IDT_DESC_DPL0, interrupt_entry_table[i]); } make_idt_desc(&idt[0x80 ],IDT_DESC_DPL3,syscall_handler); put_str("idt is done\n" ,BG_BLACK+FT_YELLOW); }
现在完事具备,就差一个具体的系统调用啦!为了让用户进程能够说话,咋们先实现 write 系统调用,该调用可以在屏幕上打印文字。
加入write系统调用
write系统调用相当简单,不过是对 console_put_str 的封装:
1 2 3 4 5 6 uint32_t sys_write (char * str) { console_put_str(str,DEFUALT); return strlen (str); }
注意,这是实际的子功能函数,是通过 syscall_handler
中断入口函数调用的,而不是被用户直接调用。用户调用的 write 如下:
1 2 3 4 5 uint32_t write (const char * str) { return _syscall1(SYS_WRITE,(uint32_t )str); }
其中 SYS_WRITE
为调用号,定义在 syscall.h 中:
1 2 3 4 enum SYSCALL_NR { SYS_WRITE };
如此一来,整个系统调用的流程就清晰的呈现在我们眼前:
graph LR
B(write)-->L(_syscall1)-->G(int 0x80)-->K(syscall_handler)-->A(sys_write)
最后,别忘了初始化系统调用:
1 2 3 4 5 6 7 void syscall_init (void ) { console_put_str("syscall_init start\n" ,DEFUALT); syscall_table[SYS_WRITE] = sys_write; console_put_str("syscall_init done\n" ,DEFUALT); }
1 2 3 4 5 6 7 8 9 10 11 12 void init_all () { put_str("init_all\n" ,DEFUALT); idt_init(); timer_init(); thread_init(); mem_init(); console_init(); tss_init(); syscall_init(); }
之前用户进程无法直接调用 print 系列函数进行打印(否则发生 0xd 号异常),现在实现了 write 系统调用,就可以让它说话啦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int kernel_main (void ) { init_all(); process_execute(u_prog_a,"proa" ); while (1 ) { printf ("Hi,man\n" ); } return 0 ; } void u_prog_a (void ) { while (1 ) write("hello\n" ); }
栈传递参数
前文说到,如果参数超过五个,那么寄存器就不够用了,此时只能通过栈来传递。其实通过栈传递参数是调用门的原生做法,这点在特权级剖析 一文中有提到过。对于中断门而言,使用栈传递需要手动实现,但也很简单:进入中断时,处理器自动压入旧栈的 ss 和 esp,由于段基址都为 0,所以我们就能直接根据该 esp 定位到旧栈中的参数(因为旧栈压入参数后,调用中断,旧栈的 ss 和 esp 紧接着就被自动压栈,参见中断剖析 ),图示如下:
根据上图,就很容易知道如何从旧栈获取参数啦,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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 获取当前栈中esp的值 mov ebx,[esp+4+48+4+12] ;3 再将参数压入当前栈中 push dword [ebx+12] ; 系统调用中第3个参数 push dword [ebx+8] ; 系统调用中第2个参数 push dword [ebx+4] ; 系统调用中第1个参数 mov eax,[ebx] ; 子功能号 ;4 调用子功能处理函数 call [syscall_table + eax*4] ; 编译器会在栈中根据C函数声明匹配正确数量的参数 add esp, 12 ; 跨过上面的三个参数 ;5 将call调用后的返回值存入待当前内核栈中eax的位置 mov [esp + 8*4], eax jmp intr_exit ; intr_exit返回,恢复上下文
思考
你可能会问,为什么不直接在 API 中进行系统调用呢,如下:
1 2 3 4 5 6 7 8 9 10 uint32_t write (const char * str) { callno_ret = SYS_WRITE arg1 = _arg1; asm ("mov eax,callno_ret" ); asm ("mov ebx,arg1" ); asm ("int 0x80" ); asm ("mov callno_ret,eax" ); return callno_ret; }
上面这种方式不是更直接吗?为啥还要通过 syscallX 来进行系统调用?答案是代码复用。系统调用有上百上千个,而它们的调用代码都像上面这样相似,如果每个函数都采用这种方式,无疑是相当冗余的。若参数个数相同的系统调用都使用同一种 syscall,如 syscall3,这样不就大大减少了代码量吗?