本节分支: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
//文件说明:syscall.c
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
//文件说明:idt.c
#define IDT_DESC_CNT 0x81 //修改为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); //为0x80建立中断描述符
put_str("idt is done\n",BG_BLACK+FT_YELLOW);
}
//........

现在完事具备,就差一个具体的系统调用啦!为了让用户进程能够说话,咋们先实现 write 系统调用,该调用可以在屏幕上打印文字。

加入write系统调用

write系统调用相当简单,不过是对 console_put_str 的封装:

1
2
3
4
5
6
//文件说明:syscall_init.c
uint32_t sys_write(char* str)
{
console_put_str(str,DEFUALT);
return strlen(str);
}

注意,这是实际的子功能函数,是通过 syscall_handler 中断入口函数调用的,而不是被用户直接调用。用户调用的 write 如下:

1
2
3
4
5
//文件说明:syscall.c
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
};

如此一来,整个系统调用的流程就清晰的呈现在我们眼前:

最后,别忘了初始化系统调用:

1
2
3
4
5
6
7
//文件说明:syscall_init.c
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
//文件说明:init.c
void init_all()
{
put_str("init_all\n",DEFUALT);
idt_init(); // 初始化中断
timer_init(); // 初始化PIT
thread_init(); // 初始化线程相关结构
mem_init(); // 初始化内存管理系统
console_init();// 初始化终端输出
tss_init(); // 初始化tss
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,这样不就大大减少了代码量吗?