引言
- 一个系统功能调用分为两部分,一部分是暴露给用户进程的接口函数,它属于用户空间,此部分只是用户进程使用系统调用的途径,只负责发需求。另一部分是与之对应的内核具体实现,它属于内核空间,此部分完成的是功能需求,就是我们一直所说的系统调用子功能处理函数
- 前面我们也提到过系统调用,但是由于当时应用与内核并未分离,所以并没有做过多介绍,现在我们就来深入的了解一下系统调用吧
浅析系统调用
- 系统调用就是让用户进程申请操作系统的帮助,让操作系统帮其完成某项工作,也就是相当于用户进程调用了操作系统的功能,因此“系统调用”准确地来说应该被称为“操作系统功能调用”
- Linux 系统调用是用软中断来实现的,通过软中断指令 int 来主动发起中断信号。由于要支持的系统功能很多,总不能一个系统功能调用就占用一个中断向量,真要是这样的话整个中断描述符表都不够用呢
- Linux 只占用一个中断向量号,即 0x80,处理器执行指令 int 0x80 时便触发了系统调用
- 为了让用户程序可以通过这一个中断门调用多种系统功能,在系统调用之 前,Linux 在寄存器 eax 中写入子功能号,例如系统调用 open 和 close 都是不同的子功能号,当用户程序通过 int 0x80 进行系统调用时,对应的中断处理例程会根据 eax 的值来判断用户进程申请哪种系统调用
- 了解 Linux 系统调用实现机制,我们才能更好的实现我们自己的系统调用
系统调用实现框架
- 系统调用流程的看上去挺简单的,首先在用户进程中使用 int 0x80 指令触发软中断,然后在对应的 Int0x80_Entry 中断入口根据 eax 跳转到不同的子功能函数执行,示意图如下:
系统调用如何传参
- 系统调用传参方式还用特别说明吗,像普通函数调用一样用栈方式传参不行吗?
- 仔细想一想,用户进程在触发 int 0x80 时还处在用户态,根据 C 调用约定,那么参数肯定会被压到用户栈空间中,然而,当 int 0x80 执行后,程序陷入内核态,此时用的就是 0 特权级的内核栈了,想要获取用户态的任务栈中数据是不是就很麻烦了
- 于是,我们采用寄存器方式进行传参。其中,寄存器 eax 用来保存子功能号,ebx 保存第 1 个参数,ecx 保存第 2 个参数,edx 保存第 3 个参数,esi 保存第 4 个参数,edi 保存第 5 个参数,当然,寄存器数量始终是有限的,如果参数过多,那么多出来的参数我们也可以采用内存方式传参
用户接口层组织结构设计
- 首先,用户接口代码就不应该与内核代码放在一起了,类比应用程序单独创建的 “app” 文件夹,我们也可以专门为接口层创建一个 “user” 文件夹,接口层相关代码都放到这个目录下
- 先设计一个基本的组织结构
user |--- BUILD.json |--- include | |--- common.h | |--- syscall.h |--- lib | |--- BUILD.json | |--- syscall.c
- 其中 “BUILD.json” 文件就不需要再细说了吧,用来管理工程文件;“common.h” 文件就是从内核头文件目录中复制过来的,为什么要再复制一份,因为 user 工程单独编译需要;syscall 用于用户接口层的代码,内核里也有 “syscall.h” 和 “syscall.c” 文件,这两个虽然名字一样,但确实完全独立的两个东西,user 中的 syscall 是给 app 调用的,其中函数并没有实际实现功能,而是通过 int 0x80 调用到内核中,再由内核中具体实现对应的功能
- 这个接口层代码不应该同内核编译在一起,而是应该单独编译,以供应用程序链接使用
- 所以我们得单独为其实现一个编译脚本,注意,这个脚本仅仅只有编译功能,没有链接又或者其它功能。脚本内容见:UserBuild.py
实现系统调用接口
- 接口千千万,我们先准备一些通用的代码,用户进程可以通过调用宏 _SYS_CALL0、_SYS_CALL1、_SYS_CALL2、_SYS_CALL3、_SYS_CALL4、_SYS_CALL5 这几个宏进行系统调用,具体详见:syscall.h
// 无参数的系统调用 #define _SYS_CALL0(_NR) ({ \ U32 retval; \ asm volatile( \ "int $0x80" \ : "=a" (retval) \ : "a" (_NR) \ : "memory" \ ); \ retval; \ }) // 一个参数的系统调用 #define _SYS_CALL1(_NR, ARG1) ({ \ U32 retval; \ asm volatile( \ "int $0x80" \ : "=a" (retval) \ : "a"(_NR), "b"(ARG1) \ : "memory" \ ); \ retval; \ }) ...(省略)
- 目前仅提供最多 5 个参数的系统调用,如果传参多余 5 个,可以把多余的参数拼成一个结构体变量,然后将该结构体变量的首地址传递给一个寄存器,内核就可以通过这个寄存器获得结构体变量所属的内存的首地址,找到地址了,那么其后的数据自然就可以使用了
- 多余 5 个参数的情况我就说一下实现方法,具体代码不想写了,5 个参数目前应该够用了
内核 syscall 的实现
- 为了区分内核空间与用户空间,一般情况下我们给内核空间的函数加上 “SYS_” 前缀,我们知道 TaskDestory 函数的作用是销毁当前任务,为了作区分,我们把内核中的 TaskDestory 改名为 SYS_TaskDestory,而在用户空间 user 中实现的销毁任务函数名为 TaskDestory
- 优化一下 “interrupt.asm” 0x80 号中断入口代码,根据 C 调用约定,将 5 个参数从右向左入栈,然后根据 eax 跳转到 syscall_table 中对应的子功能函数执行
extern syscall_table Int0x80_Entry: BeginISR push edi ; 第 5 个参数 push esi ; 第 4 个参数 push edx ; 第 3 个参数 push ecx ; 第 2 个参数 push ebx ; 第 1 个参数 call [syscall_table + eax*4] add esp, 20 ; 跨过上面 5 个参数 EndISR
- 同样的,“syscall.c” 也跟着改动,取消原先 Int0x80Handle 这种写法
// void Int0x80Handle(U32 eax) // { // if(0 == eax) // { // TaskDestory(); // } // } extern E_RET SYS_TaskDestory(void); // 任务销毁 typedef E_RET (*SYSCALL_FUNC)(); SYSCALL_FUNC syscall_table[] = { SYS_TaskDestory, };
- 接下来如果有新加的系统调用接口,只需要将函数放到 syscall_table 数组中就可以了,当然了,必须与 user 中 syscall 对应
完整版系统调用初体验
- 代码见:u_syscall.c、u_syscall.h、app.c
- 目前 syscall_table 仅有一个系统调用接口,那就是销毁任务,接下来我们要在应用程序 app 中调用到该系统调用实现销毁任务
- 由于目前 app 代码中包含的依旧包含了内核头文件,内核中头文件目录与 user 头文件目录下都有 “syscall.h” ,为了避免编译错误,暂时把 user 中的 “syscall.h” 改名为 “u_syscall.h”, “syscall.c” 改名为 “u_syscall.c”
- “app” 目录下的 “BUILD.json” 文件中 “inc” 项需要增加 user 头文件的路径 "../user/include",这个头文件路径是相对于编译脚本 “AppBuild.py” 的路径。其实 app 本不应该包含内核头文件路径的,不过现在应用程序代码还没完全拆分,将就一下吧,等后面再完全拆分
- 前面不是实现了几个 _SYS_CALLx 宏嘛,具体用法如下,由于销毁当前任务函数不需要传参,所以调用 _SYS_CALL0 即可,“u_syscall.c” 中目前仅有这一个接口函数,当然,现在接口函数太少,所以目前以及接下来我们的用户接口函数都写到这个文件中,等以后接口丰富了,再分类出来
E_RET TaskDestory(void) { return _SYS_CALL0(0); }
- 先进入 “user” 文件夹下,执行 “python UserBuild.py”,编译出临时文件 “u_syscall.o”,这个文件在 “output/user/” 目录下,把它复制到 “output/app/” 目录下
- 准备工作已经完成,是不是感觉还缺点什么?显然,我们在应用程序 app 中还没有调用 TaskDestory 这个接口
- 修改 “app.c” 中 TASK B,让其在执行一段时间后销毁
void TaskBFunc(void) // 任务执行函数 { static U32 count = 0; while(1) { ... if(count > 100000) { printk("TaskDestory\n"); TaskDestory(); } } }
- 切回工程根目录,执行 “python Build.py” 命令,接下来流程就不说了,bochs 运行起来,成功实现了系统调用,成果展示
参数个数问题思考
- 不管系统调用中的参数是几个,中断入口 “Int0x80_Entry” 处始终压入 5 个参数,也许你会对此感到奇怪
- 是这个样子的,子功能函数都有自己的函数声明,声明中包括参数个数及类型,编译时编译器会根据函数声明在栈中匹配处正确的参数数量,进入函数体后,根据 C 调用约定,栈顶的 4 字节是函数返回地址,往上(高地址栈底方向)的 4 字节是第一个参数,再往上的 4 字节是第二个参数,依此类推。在函数体中,编译器生成的取参指令是从栈顶往上(跨过栈顶的返回地址)获取参的,参数个数是通过函数声明事先确定好的,因此不会出现获取到错误的参数,从而保证了多余的参数用不上,因此,即便我们压入了 5 个参数,但对于少于 5 个参数的函数也不会出错,仅仅只是浪费一点点栈空间而已
- 实际体验一下吧,本次改动代码见:u_syscall.c、u_syscall.h、syscall.c、app.c
- 在 user 中的 “u_syscall.c” 中写一个用于测试的系统调用函数,其函数声明放到 “u_syscall.h” 中,函数内容如下
void TestFunc(U32 arg1, U08 arg2, U32 arg3, U16 arg4) { _SYS_CALL4(1, arg1, arg2, arg3, arg4); }
- 在内核中的 “syscall.c” 中也对应实现其对应的 “SYS_TestFunc” 函数并放到 syscall_table 数组中
void SYS_TestFunc(U32 arg1, U08 arg2, U32 arg3, U16 arg4) { printk("arg1 = %d; arg2 = %d; arg3 = %d; arg4 = %d\n", arg1, arg2, arg3, arg4); } ... SYSCALL_FUNC syscall_table[] = { SYS_TaskDestory, SYS_TestFunc, };
- 进入 “user” 文件夹下,执行 “python UserBuild.py”,编译出中间文件 “u_syscall.o”,这个文件在 “output/user/” 目录下,把它复制到 “output/app/” 目录下
- 把上一步放到 “Build.py” 脚本中吧,具体见:Build.py
- 在应用程序 “app.c” 中调用一下 “TestFunc” 系统调用函数
void TaskDFunc(void) // 任务执行函数 { static U32 count = 0; TestFunc(1, 2, 3, 4); while(1) { ... } }
- 最后再切回工程根目录,执行 “python Build.py” 命令,编译运行,看看结果
系统调用返回值
- 函数调用都有返回值,那么系统调用呢?肯定也要有返回值呀
- 我们知道,根据 C 调用约定 函数调用的返回值是存储到 eax 寄存器中的
- 我们再看一下 0x80 中断入口,在执行完 call [syscall_table + eax*4] 后,根据 C 调用约定,系统调用的返回值已经存储到 eax 寄存器中了,然而,在接下来的步骤 EndISR 中,经过恢复上下文后,eax 又会被恢复到之前的 eax 的值
Int0x80_Entry: BeginISR ... call [syscall_table + eax*4] add esp, 20 ; 跨过上面 5 个参数 EndISR
- 所以解决办法就是把 eax 寄存器中的值写到任务上下的 eax 元素中,具体操作如下:
%macro EndISR 0 mov esp, [current_reg] ; 使栈顶 esp 指向上下文数据结构 reg 的起始位置 mov [esp + 11*4], eax ; 把 eax 寄存器中的值写到任务上下文 eax 元素中 ; 恢复上下文 pop gs pop fs pop es pop ds popad ; 恢复通用寄存器 add esp, 4 ; 跳过 err_code iret ; 恢复 ss esp eflags cs eip 这 5 个寄存器 %endmacro
- 内核中代码改动如下,随便给个返回值 7
U32 SYS_TestFunc(U32 arg1, U08 arg2, U32 arg3, U16 arg4) { printk("arg1 = %d; arg2 = %d; arg3 = %d; arg4 = %d\n", arg1, arg2, arg3, arg4); return 7; }
- user 用户接口层代码修改如下
U32 TestFunc(U32 arg1, U08 arg2, U32 arg3, U16 arg4) { return _SYS_CALL4(1, arg1, arg2, arg3, arg4); }
- 应用程序 app 中代码改动如下,调用 TestFunc,并打印出调用后的返回值
void TaskDFunc(void) // 任务执行函数 { ... ret = TestFunc(1, 2, 3, 4); printk("ret = %d\n", ret); while(1) { ... } }
- 改动不多,代码就不单独提供了,直接看一下运行效果吧
- 补充:由于 EndISR 在别的中断中也会用到,然而目前只有 0x80 号中断需要将 eax 寄存器的值写到任务上下文 eax 元素中 ,为了不影响其它中断,最好复制一份 EndISR ,改名为 EndISR1,在 EndISR1 中有把 eax 寄存器中的值写到任务上下文 eax 元素中这一步操作,而在 EndISR 中没有这一步操作,其它中断依旧使用 EndISR 恢复上下文
用户打印 print
- 改动代码见:u_syscall.c、u_syscall.h、syscall.c、print.c、print.h、app.c
- 系统调用的基本流程我们也已经熟悉了,稍微拓展提升一下,遇到可变参数函数又如何实现系统调用呢?参数个数都不固定,那么系统调用怎么传参呢?
- 方法很多种,比如我们可以把要用户打印的各种格式的数据拼成一个字符串数组,计算出拼出来的字符串长度,然后把这个字符串首地址和长度传递给内核,再由内核实现打印到屏幕上,这时候不就固定只传 2 个参数了嘛
- 接下来我们将实现用户打印 print 函数,print 函数为可变参数函数
- 由于目前 user 接口层代码较少,暂时把接口实现都放到 “u_syscall.c” 文件中吧
- 首先定义一个全局数组用于打印,将要打印的内容统统转为字符存入该数组中,最大打印字符数 BUFF_LEN
staticU08print_buff[BUFF_LEN]={0};
- print 函数实现与内核 printk 实现基本一致,只不过内核 prntk 是直接打印出来,而 print 是把所有要打印的数据都转成字符存到 print_buff 中,还有一点区别就是 print 最后调用 _SYS_CALL2 宏将字符数组首地址和字符串长度这两个参数传递到内核 SYS_print 函数
U32 print(const char * fmt, ...) { ... va_start(args, fmt); // 遍历字符串 fmt for(; *fmt; ++fmt) { // 寻找格式转换字符 '%', 如果不是 '%', 则将其转存到 print_buff 中 if(*fmt != '%') { if(index < BUFF_LEN) ptr[index++] = *fmt; continue; } // 如果找到了 '%', 则取 '%' 后的第一个字符 ++fmt; switch (*fmt) // 根据 '%' 后的字符类型采取不同的打印接口 { case 'c': // 字符 ... break; case 's': // 字符串 ... break; case 'x': // 十六进制 ... break; case 'd': // 十进制 ... break; default: break; } } va_end(args); return _SYS_CALL2(1, ptr, index); }
- 真正实现打印功能函数 SYS_print 在内核 “print.c” 中,其内容如下:
U32 SYS_print(U08* buf, U16 len) { U32 ret = 0; U16 i = 0; for(i = 0; i < len; i++) { PrintChar(buf[i]); ret += 1; } return ret; }
- 接下来肯定就是将系统调用函数 SYS_print 放到 “syscall.c” 的 syscall_table 数组中了,注意其在数组中的位置要与 user 中函数调用接口一致
- 打印功能系统调用实现已经完成了,肯定想尝试一下吧,别急,顺便把 SetCursorPos 也改成系统调用形式吧,内核中改名为 SYS_SetCursorPos,具体实现就不再介绍了,对大家来说肯定小意思
- 既然打印已经实现了系统调用,那么在工程上我们就要实现内核代码与应用代码的完全分离了,首先 “app” 文件夹下的 “BUILD.jspn” 文件就不能涉及任何内核中的代码了,其中头文件只能包含自己目录下的以及 “user” 目录下的,但是 app 的代码编译又需要包含 “app.h” 这个头文件,于是,我们把内核 “include” 下的 “app.h” 文件复制一份到 “user/include” 目录下;回想一下,之前我们是不是把 kernel 编译生成的 “output/” 目录下的 “print.o” 中间文件复制一份到到 “output/app/” 目录下了,因为当时 app 程序中用到的打印依旧使用内核实现的代码,现在改为了系统调用方式,于是要删除 “output/app/” 目录下的 “print.o” 文件
- 差点漏了一点,由于用户打印 print 为可变参数函数,其参数获取也要使用到 “stdarg.h”, 所以要把内核 “include” 目录下的 “stdarg.h” 复制一份到 “user/include/” 目录下
- 其实,从此刻开始,内核与应用才彻底分离
- 最终成果肯定是要展示一下的,虽然效果跟没分离前的并无两样
任务创建系统调用实现
- 前面我们都是通过共享内存的方式向内核传递任务创建所需的参数的,现在有了系统调用后,当然需要把任务创建改成系统调用实现啦
- 然而我懒得写了,就这样吧,有空再优化这里
新增系统调用后的架构
- 总结一下当前的系统架构,如下图所示
- 应用程序 app 目前已与内核 kernel 完全分离
- 应用程序 app 不可直接使用内核 kernel 中的任何东西
- 应用程序 app 想要使用内核 kernel 中的功能,必须通过中间接口层 user 实现,user 中通过 int 0x80 号软中断实现系统调用
- 本质上讲 user 和 app 都属于 3 特权级的应用程序,user 中编译实现的代码编译形成的中间 ".o" 文件最终还是被 app 链接到一起形成应用程序