十三. 实现系统调用

系统调用简介

关于系统调用前面有过简单的介绍,这里将真正实现系统调用

现代的操作系统中,用户的权限是有限的,它不能随意的访问系统中的资源。操作系统屏蔽了用户直接访问硬件的能力,这样做的原因主要是为了安全考虑。

但是如果我们想控制显卡打印字符怎么呢,那就需要通过操作系统提供的接口来完成了,我们调用操作系统提供的接口,然后操作系统去操控硬件,比如说这里的显卡,打印出字符来。我们使用的c语言里面的printf函数,其真实调用的是系统调用接口中的write,操作系统提供的这一系列接口就是系统调用接口。

总结来说,系统调用就是用户进程申请操作系统的帮助,让操作系统帮其完成某项工作,也就相当于用户进程调用了操作系统的功能。

系统调用的实现原理

系统调用主要是通过中断门实现的,通过软中断int发出中断信号。由于要支持的系统功能很多,不可能每个系统调用就占用一个中断向量。所以规定了0x80为系统调用的中断向量号,在进行系统调用之前,向eax中写入系统调用的子功能号,再进行系统调用的时候,系统就会根据eax的值来决定调用哪个中断处理例程。

linux中系统调用的实现

在完善我们的系统调用之前,先看看linux中是如何实现系统调用的。

通过man syscall查看系统调用的文档

linux syscall

这里面可以看到他的原型是

1
long syscall(long number, ...);

该函数接收一个系统调用号,由于不同的子功能所需的参数都是不同的,所以该函数需要支持可变参数。

这里可以看一个例子

mark

这里调用了系统调用SYS_gettid,这个SYS_gettid定义在

mark

继续向后找,可以看到最终的定义就是一个数值,也就是SYS_gettid的子功能号。

mark

syscall其实是一个间接的系统调用,它是一个库函数,不是操作系统直接提供的。

linux中也有直接提供的系统调用,就是_syscall。

_syscall

这种系统调用方式是通过宏机制来实现的,参数个数不同就对应不同的宏,但是最大支持的参数只有6个,而且会引发安全问题,所以它已经被废弃了。

而我们的kernel就打算用这种方式来实现系统调用,因为相对来说简单

实现系统调用

实现系统调用的流程如下

  1. 用中断门实现系统调用,通过0x80中断作为系统调用的入口
  2. 在IDT中安装0x80号中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程
  3. 建立系统调用子功能表,利用eax寄存器中的子功能号在该表中索引相应的处理函数
  4. 用宏实现用户空间系统调用接口_syscall,最大只支持3个参数,ebx保存第一个参数,ecx保存第二个参数,edx保存第三个参数

就按照这个步骤一步步完成代码

在idt中添加0x80的描述符,该描述符必须要在3特权级下能够访问,否则用户就无法调用系统调用接口了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extern uint32_t syscall_handler();

static void idt_desc_init(void)
{
int lastindex = IDT_DESC_CNT - 1;
for (int i = 0; i < IDT_DESC_CNT - 1; i++)
{
make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);
}

make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler);

put_str(" idt_desc_init done\n");
}

系统调用宏的实现

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
/* 无参数的系统调用 */
#define _syscall0(NUMBER) \
({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER) \
: "memory"); \
retval; \
})

/* 一个参数的系统调用 */
#define _syscall1(NUMBER, ARG1) \
({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1) \
: "memory"); \
retval; \
})

/* 两个参数的系统调用 */
#define _syscall2(NUMBER, ARG1, ARG2) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1), "c"(ARG2) \
: "memory"); \
retval; \
})

/* 三个参数的系统调用 */
#define _syscall3(NUMBER, ARG1, ARG2, ARG3) ({ \
int retval; \
asm volatile( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER), "b"(ARG1), "c"(ARG2), "d"(ARG3) \
: "memory"); \
retval; \
})

完成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
[bits 32]
extern syscall_table
section .text
global syscall_handler
syscall_handler:
; 保存上下文环境
push 0
push ds
push es
push fs
push gs
pushad

push 0x80

push edx
push ecx
push ebx

// 调用相应的处理程序
call [syscall_table + 4 * eax]
add esp, 12 ; 跨过上面的三个参数

; 将 call 调用后的返回值存入当前内核栈中 eax 的位置
mov [esp + 4 * 8], eax
jmp intr_exit

初始化系统调用子功能对应的处理程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define syscall_nr 32
typedef void *syscall;
syscall syscall_table[syscall_nr];

/* 返回当前任务的pid */
uint32_t sys_getpid(void)
{
return running_thread()->pid;
}

/* 初始化系统调用 */
void syscall_init(void)
{
put_str("syscall_init start\n");
syscall_table[SYS_GETPID] = sys_getpid;
put_str("syscall_init done\n");
}

提供用户使用的系统调用接口

1
2
3
4
5
/* 返回当前任务pid */
uint32_t getpid()
{
return _syscall0(SYS_GETPID);
}

当前就有了一个getpid的系统调用接口

系统调用write以及printf函数的实现

目前这个write只是一个简易版,它的主要功能是为printf函数提供支持。哈哈,终于要实现自己的printf函数了。有了前面的基础,再添加一个系统调用就很简单了。

添加SYS_WRITE的处理程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t sys_write(char *str)
{
console_put_str(str);
return strlen(str);
}

/* 初始化系统调用 */
void syscall_init(void)
{
put_str("syscall_init start\n");
syscall_table[SYS_GETPID] = sys_getpid;
syscall_table[SYS_WRITE] = sys_write;
put_str("syscall_init done\n");
}

提供用户调用接口

1
2
3
4
uint32_t write(char *str)
{
return _syscall1(SYS_WRITE, 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
2
3
4
5
6
7
8
// 这个宏的作用相当于初始化ap指针,使其执行v的地址
va_start(ap,v)

//ap是指向可变参数指针变量,t是可变参数的类型,该函数的作用是使ap指向栈中下一个参数的地址并返回其值
va_arg(ap,t)

//清空ap
va_end(ap)

我们通过对printf函数中format的解析,每找到一个 % 通过 % 后面接的字符来判断参数的类型,知道该参数的类型之后,就可以调用va_arg找到该参数在栈中的地址,也就可以顺利的实现printf了。下面看一下具体的解析format的过程

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
/* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */
uint32_t vsprintf(char *str, const char *format, va_list ap)
{
char *buf_ptr = str;
const char *index_ptr = format;
char index_char = *index_ptr;
int32_t arg_int;
char *arg_str;
while (index_char)
{
if (index_char != '%')
{
*(buf_ptr++) = index_char;
index_char = *(++index_ptr);
continue;
}
index_char = *(++index_ptr); // 得到%后面的字符
switch (index_char)
{
case 's':
arg_str = va_arg(ap, char *);
strcpy(buf_ptr, arg_str);
buf_ptr += strlen(arg_str);
index_char = *(++index_ptr);
break;

case 'c':
*(buf_ptr++) = va_arg(ap, char);
index_char = *(++index_ptr);
break;

case 'd':
arg_int = va_arg(ap, int);
/* 若是负数, 将其转为正数后,再正数前面输出个负号'-'. */
if (arg_int < 0)
{
arg_int = 0 - arg_int;
*buf_ptr++ = '-';
}
itoa(arg_int, &buf_ptr, 10);
index_char = *(++index_ptr);
break;

case 'x':
arg_int = va_arg(ap, int);
itoa(arg_int, &buf_ptr, 16);
index_char = *(++index_ptr); // 跳过格式字符并更新index_char
break;
}
}
return strlen(str);
}

这个函数的作用就是对format进行解析,这里只处理了 % 后面接单个字符的情况,总共解析了字符串,字符,整数,16进制整数这些类型。

解析的过程其实就是将原先%x替换成栈中的数据。

比如

1
2
char *str = "bbb";
printf("aaa %s", str);

在找到%s后,知道了参数的类型为char*,就调用va_arg得到该参数在栈中的地址,然后将%s替换成栈中的数据。

这三个宏的实现如下。

1
2
3
4
5
typedef char* va_list;

#define va_start(ap, v) ap = (va_list)&v // 把ap指向第一个固定参数v
#define va_arg(ap, t) *((t *)(ap += 4)) // ap指向下一个参数并返回其值
#define va_end(ap) ap = NULL // 清除ap

解析完了之后就可以直接通过printf函数调用输出了,顺带也罢sprintf实现了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 同printf不同的地方就是字符串不是写到终端,而是写到buf中 */
uint32_t sprintf(char *buf, const char *format, ...)
{
va_list args;
uint32_t retval;
va_start(args, format);
retval = vsprintf(buf, format, args);
va_end(args);
return retval;
}

/* 格式化输出字符串format */
uint32_t printf(const char *format, ...)
{
va_list args;
va_start(args, format); // 使args指向format
char buf[1024] = {0}; // 用于存储拼接后的字符串
vsprintf(buf, format, args);
va_end(args);
return write(buf);
}

可以看到printf函数确实是通过系统调用write实现的。