系统调用简介
关于系统调用前面有过简单的介绍,这里将真正实现系统调用
现代的操作系统中,用户的权限是有限的,它不能随意的访问系统中的资源。操作系统屏蔽了用户直接访问硬件的能力,这样做的原因主要是为了安全考虑。
但是如果我们想控制显卡打印字符怎么呢,那就需要通过操作系统提供的接口来完成了,我们调用操作系统提供的接口,然后操作系统去操控硬件,比如说这里的显卡,打印出字符来。我们使用的c语言里面的printf函数,其真实调用的是系统调用接口中的write,操作系统提供的这一系列接口就是系统调用接口。
总结来说,系统调用就是用户进程申请操作系统的帮助,让操作系统帮其完成某项工作,也就相当于用户进程调用了操作系统的功能。
系统调用的实现原理
系统调用主要是通过中断门实现的,通过软中断int发出中断信号。由于要支持的系统功能很多,不可能每个系统调用就占用一个中断向量。所以规定了0x80为系统调用的中断向量号,在进行系统调用之前,向eax中写入系统调用的子功能号,再进行系统调用的时候,系统就会根据eax的值来决定调用哪个中断处理例程。
linux中系统调用的实现
在完善我们的系统调用之前,先看看linux中是如何实现系统调用的。
通过man syscall查看系统调用的文档
这里面可以看到他的原型是
1 | long syscall(long number, ...); |
该函数接收一个系统调用号,由于不同的子功能所需的参数都是不同的,所以该函数需要支持可变参数。
这里可以看一个例子
这里调用了系统调用SYS_gettid,这个SYS_gettid定义在
继续向后找,可以看到最终的定义就是一个数值,也就是SYS_gettid的子功能号。
syscall其实是一个间接的系统调用,它是一个库函数,不是操作系统直接提供的。
linux中也有直接提供的系统调用,就是_syscall。
这种系统调用方式是通过宏机制来实现的,参数个数不同就对应不同的宏,但是最大支持的参数只有6个,而且会引发安全问题,所以它已经被废弃了。
而我们的kernel就打算用这种方式来实现系统调用,因为相对来说简单
实现系统调用
实现系统调用的流程如下
- 用中断门实现系统调用,通过0x80中断作为系统调用的入口
- 在IDT中安装0x80号中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程
- 建立系统调用子功能表,利用eax寄存器中的子功能号在该表中索引相应的处理函数
- 用宏实现用户空间系统调用接口_syscall,最大只支持3个参数,ebx保存第一个参数,ecx保存第二个参数,edx保存第三个参数
就按照这个步骤一步步完成代码
在idt中添加0x80的描述符,该描述符必须要在3特权级下能够访问,否则用户就无法调用系统调用接口了
1 | extern uint32_t syscall_handler(); |
系统调用宏的实现
1 | /* 无参数的系统调用 */ |
完成0x80的处理例程
1 | [32] |
初始化系统调用子功能对应的处理程序
1 |
|
提供用户使用的系统调用接口
1 | /* 返回当前任务pid */ |
当前就有了一个getpid的系统调用接口
系统调用write以及printf函数的实现
目前这个write只是一个简易版,它的主要功能是为printf函数提供支持。哈哈,终于要实现自己的printf函数了。有了前面的基础,再添加一个系统调用就很简单了。
添加SYS_WRITE的处理程序
1 | uint32_t sys_write(char *str) |
提供用户调用接口
1 | uint32_t write(char *str) |
可变参数的原理
我们平时使用的函数中,大多数参数的个数都是已知的。函数占用的是静态内存,也就是说再编译期就要确定为其分配多大的空间。这个空间的大小在函数声明的时候就已经确定了。比如
1 | int func(int,char); |
编译器会自动的根据函数参数的类型在栈中分配好空间。那么问题来了,对于这种参数确定的函数,编译器能够知道为其分配多少空间。那么在参数可变的情况下,编译器有是如何为其分配空间的呢
1 | int printf(const char *format, ...); |
printf函数的原型如上,通常我们是这样调用它的
1 | printf("aaa%s %c", str, ch); |
从这种调用方式来看,虽然说他的参数是可变的,同样也可以说他的参数是固定的。因为在调用它的时候这个format是固定的,format就是指 aaa%s %c这段内容,每一个 % 后面就带表需要一个参数,这里就固定了它有两个参数。每个 % 便是在栈中寻找可变参数的依据。
gcc对于可变参数的支持是通过 va_start, va_end, va_arg 这三个宏来实现的。
1 | // 这个宏的作用相当于初始化ap指针,使其执行v的地址 |
我们通过对printf函数中format的解析,每找到一个 % 通过 % 后面接的字符来判断参数的类型,知道该参数的类型之后,就可以调用va_arg找到该参数在栈中的地址,也就可以顺利的实现printf了。下面看一下具体的解析format的过程
1 | /* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */ |
这个函数的作用就是对format进行解析,这里只处理了 % 后面接单个字符的情况,总共解析了字符串,字符,整数,16进制整数这些类型。
解析的过程其实就是将原先%x替换成栈中的数据。
比如1
2char *str = "bbb";
printf("aaa %s", str);
在找到%s后,知道了参数的类型为char*,就调用va_arg得到该参数在栈中的地址,然后将%s替换成栈中的数据。
这三个宏的实现如下。1
2
3
4
5typedef char* va_list;
解析完了之后就可以直接通过printf函数调用输出了,顺带也罢sprintf实现了
1 | /* 同printf不同的地方就是字符串不是写到终端,而是写到buf中 */ |
可以看到printf函数确实是通过系统调用write实现的。