什么是系统调用机制?结合Linux0.12源码图解

作者:小牛呼噜噜 , 首发于公众号「小牛呼噜噜

哈喽,大家好呀,我是呼噜噜,好久没有更新old linux了,这样是2023年最后一篇文章,本文接着以前的系列打下的基础,来聊聊内核中一个非常重要的机制-系统调用,本文结合linux0.12源码来揭开早期操作系统中系统调用机制的神秘面纱

内核态与用户态

早期工程师们在操作系统上编写程序的时候,自己写个程序可以访问别人的程序地址,甚至是操作系统占用的地址,这样就很容易一不小心就直接把操作系统给干挂了,所以那个时候的程序员编写程序都得小心翼翼的

计算机核心的资源,一般有:内存,I/O端口,特殊机器指令等,这些资源必须得保护起来,规定哪些程序可以去访问,哪些程序不能去访问

所以引入了特权级别的概念,由硬件设备商直接来提供硬件级别的支持,最常见的就是给CPU指令集的权限分级来控制CPU的访问权限

比如 Intel CPU指令集操作的权限由高到低划为4级:Ring0、Ring1、Ring2、Ring3,其中Ring0权限最高,可以使用所有CPU指令集,Ring3权限最低,仅能使用部分CPU指令,比如不能使用操作硬件资源的CPU指令:I/O操作、内存分配等操作;另外CPU处于Ring3状态不能访问Ring0的地址空间,包括代码和数据

CPU指令集,就是CPU中用来计算和控制计算机系统的一套指令的集合,实现软件指挥硬件执行的媒介,常见的CPU指令集有X86、ARM、MIPS、Alpha、RISC等

那么CPU是如何记录这些特权级信息的?

我们这里以80386CPU为例,前文提到过CPU里面有许多段寄存器(CS、DS、SS、ES、FS、GS等)。这些段寄存器里面存放段选择符(也叫段选择子)


段选择符中包含请求特权级RPL(CPL)字段,通过段选择符可以去查找全局描述符表GDT、局部描述符表LDT中对应的项,需要先进行特权级检查;这些项中都包含DPL字段(规定访问该段的权限级别),只有DPL >= max {CPL, RPL},才允许访问

CPL很特殊,跟踪当前CPU正在执行的代码所在段的描述符中DPL的值,总是等于CPU的当前特权级

内核态与用户态都是操作系统的层面的概念,和CPU硬件没有必然的联系;由于硬件已经提供了一套特权级使用的相关机制,Linux操作系统没有必要重新”造轮子”,直接使用了硬件的Ring0和Ring3这两个级别的权限,也就是使用Ring3作为用户态,Ring0作为内核态

那么有人会问为什么Linux系统仅使用了Ring0和Ring3这两个级别?

因为CPU给的权限管理细度不够,比如Intel CPURing2和Ring3在操作系统里安全情况没有区别,Ring1下的系统权限又需要经常调用Ring0特权指令,频繁切换特权级成本过高,操作系统不如将Ring2合并到Ring3,将Ring1划入Ring0特权级

另一方面不是每种处理器都像x86一样支持4个权限级别,有些处理器可能只支持2个级别,更少的特权级别,便于移植其他处理器架构上

我们再来看下linux的体系架构图:


我们可以发现Linux系统从整体上看,被划分为用户态和内核态

  1. 内核态

内核态是处于操作系统的最核心处,Ring0特权级,拥有操作系统的最高权限,能够控制所有的硬件资源,掌控各种核心数据,并且能够访问内存中的任意地址;由内核态统一管理这些核心资源,减少有限资源的访问和使用冲突;在内核里发生的任何程序异常都是灾难性的,会导致整个操作系统的奔溃

  1. 用户态

用户态,就是我们通常编写程序的地方,处于Ring3特权级,权限较低;这一层次的程序没有对硬件的直接控制权限,也不能直接访问地址的内存。在这种模式下,即使程序发生崩溃也不会影响其他程序,可恢复

什么是系统调用

当计算机启动的时候,CPU处于Ring0状态,这个时候所有的指令都可以执行,通过主引导程序将磁盘扇区中的操作系统程序加载到内存中,从而启动操作系统(需要注意一下,本文的操作系统 以Linux0.12为例子)

也就是说当Linux0.12启动的时候,是在权限最高级别的内核态运行的;同时对内存进行划分,划出一部分(内核区)专门给内核使用,这部分内存只能被内核使用;主内存区域给其他应用软件使用。对这部分感兴趣地,可以看看笔者之前的文章Linux0.12内核源码解读(6)-main.c


当操作系统启动完成后,CPU就切换到Ring3级别上,操作系统同时进入用户态,之后的应用程序代码都运行在权限最低级别的用户态上,通常我们能编写的程序都运行在用户态上

需要格外注意一下,CPU特权级其实并不会对操作系统的用户造成什么影响!有人会和Linux的用户权限搞混淆,无论是根用户(root),管理员,访客还是一般用户,它们都属于用户;而所有的用户代码都在用户态Ring3上执行,所有的内核代码都在内核态Ring0上执行,和Linux用户的身份权限并没有关系

因为我们编写的程序都运行在用户态上,是无法对内存和I/O端口的访问,可以说基本上无法与外部世界交互,但是我们平时工作的时候访问磁盘、写文件,这些都是必要的需求,怎么办?

那就需要通过执行系统调用system call,操作系统会切换到内核态,由内核去统一执行相关操作(大哥帮小弟去执行);当执行完操作系统再切换回用户态。这样方便集中管理,减少有限资源的访问和使用冲突

系统调用是操作系统专门为用户态运行的进程与硬件设备之间进行交互提供了一组接口,是用户态主动要求切换到内核态的一种方式

系统调用是怎么实现的

接下来我们就结合Linux0.12的源码一起来看看系统调用是怎么实现的?

库函数write

本文以一个常见的库函数write函数为例来,来更方便大家理解,开始发车:

1
2
3
4
5
6
7
//  lib/write.c

#define __LIBRARY__
#include <unistd.h> //头文件

_syscall3(int,write,int,fd,const char *,buf,off_t,count) //定义write的实现,:fd - 文件描述符;buf - 写缓冲区指针;count - 写字节数

write.c这个文件主要是定义write的实现,_syscall3(*,write,*)函数的主要功能是,向文件描述符fd指定的文件写入count个字节的数据到缓冲区buf中

需要注意一下#define __LIBRARY__这个宏定义,这里定义直接原因是为了包括在unistd.h中的内嵌汇编代码

库函数扩展汇编宏

因为_syscall3这个函数定义在/include/unistd.h中,来看下源码:

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
//  /include/unistd.h


#ifdef __LIBRARY__ # 若提前定义__LIBRARY__,则以后内容被包含

...

#define __NR_write 4 //系统调用号,用作系统调用函数表中索引值

...

//定义有3个參数的, 定义系统调用嵌入式汇编宏函数
//%0 - eax(__res),%1 - eax(__NR_name),%2 - ebx(a),%3 - ecx(b),%4 - edx(c)。
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \ // 调用系统中断 0x80
: "=a" (__res) \ // 返回值eax(__res)
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \ //输入为:系统中断调用号__NR_name,还有另外3个参数
if (__res>=0) \ // 如果返回值>=0,则直接返回该值
return (type) __res; \
errno=-__res; \ // 否则置出错号,并返回-1
return -1; \
}

#endif /* __LIBRARY__ */

...

int write(int fildes, const char * buf, off_t count); //write系统调用的函数原型定义

...

只有在lib/write.c中先定义了#define __LIBRARY__,那么才能在/include/unistd.h中,找到系统调用号和内嵌汇编_syscall3();不然就代表它不需要进行系统调用,这样就可以忽略unistd.h中和系统调用相关的宏定义,非常的优雅

其实我们可以把write.c中的write函数再重新整合一下:

1
2
3
4
5
6
7
8
9
10
11
int write(int fd,const char* buf,off_t count) \
{ \
long __res; \
__asm__ volatile ( "int $0x80" \
: "=a" (__res) \
: "" (__NR_write), "b" ((long)(fd)), "c" ((long)(buf)), "d" ((long)(count))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}

这样大家就能更容易明白#define __LIBRARY__的作用

上面int $0x80"表示调用系统中断0x80 ** ,其实系统调用的本质还是通过中断(0x80)去实现的**!操作系统中真的是处处离不开中断。中断相关知识不了解的,可以看看笔者之前写过的一篇文章图解计算机中断

另外由于程序处于用户态无法直接操作硬件资源,所以需要进行系统调用,切换到内核态;也就是说用户程序如果使用库函数write,会进行系统调用

而系统调用,其实就是去调用int 0x80中断,然后把三个参数fd、buf、count依次存入ebx、ecx、edx寄存器

还有#define __NR_write 4 ,定义了系统调用号_NR_write会被存入eax寄存器;当调用返回后,从eax取出返回值,存入__res,建立了用户栈和内核栈的联系。至于__NR_write的作用下文再讲解

int 0x80中断 调用对应的中断处理函数

我们来看下中断是调用对应的中断处理函数的流程图:

当发生中断的时候,CPU获取到中断向量号后,通过IDTR,去查找IDT中断描述符表,得到相应的中断描述符;然后根据描述符中的对应中断处理程序的入口地址,去执行中断处理程序

早在linux0.12启动时,会进行调度程序初始化main.c/sched_init(),其源码:

1
2
3
4
5
6
7
8
9
10
11
//     /kernel/sched.c

...

void sched_init(void)
{
...
set_system_gate(0x80,&system_call);//设置系统调用中断门
}

...

set_system_gate在之前的文章Linux0.12内核源码解读(7)-陷阱门初始化讲解过,不再赘述

需要注意的是:在用户态和内核态运行的进程使用的栈是不同的,分别叫做用户栈和内核栈, 两者各自负责相应特权级别状态下的函数调用;所以当执行系统调用中断int 0x80从用户态进入内核态时,会从用户栈切换到内核栈,系统调用返回时,还要切换回用户栈,继续完成用户态下的函数调用(这也叫做被中断进程上下文的保存与恢复)

其中其关键作用的是,CPU会可以自动通过TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置,完成用户栈到内核栈的切换。先了解一下,这块等进程那块我们会再详细聊聊

set_system_gate(0x80,&system_call)这句整体作用是,设置系统调用中断门,将0x80中断和函数system_call绑定在一起,换句话说system_call就是0x80的中断处理函数

检索系统调用函数表

我们接着去看system_call函数的源码:

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
//    /kernel/sys_call.s

...

// int 0x80
_system_call:
push %ds # 压栈, 保存原段寄存器值
push %es
push %fs
pushl %eax # 保存eax原值
pushl %edx
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call, ebx,ecx,edx 中放着系统调用对应的C语言函数的参数
movl $0x10,%edx # ds,es 指向内核数据段
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs 指向当前局部数据段(局部描述符表中数据段描述符)
mov %dx,%fs
cmpl _NR_syscalls,%eax # 判断eax是否超过了最大的系统调用号,调用号如果超出范围的话就跳转!
jae bad_sys_call
call _sys_call_table(,%eax,4) # 间接调用指定功能C函数!
pushl %eax # 把系统调用的返回值入栈!

...

ret_from_sys_call: #当系统调用执行完毕之后,会执行此处的汇编代码,从而返回用户态
movl _current,%eax # 取当前任务(进程)数据结构指针->eax
cmpl _task,%eax # task[0] cannot have signals
...

其中 _sys_call_table(,%eax,4),这里的eax寄存器存放的就是_NR_write系统调用号,_sys_call_table是sys.h中的一个int (*)()类型的数组,里面存的是所有的系统调用函数地址,也叫做系统调用函数表,所以__NR_write也表示系统调用函数表中的索引值

那为什么%eax * 4乘上4呢?这是因为sys_call_table[]指针每项4 个字节,这样被调用处理函数的地址=[_sys_call_table + %eax * 4]

我们再来看下sys_call_table的定义:

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
//    /include/linux/sys.h

...
extern int sys_write();
...

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid, sys_sigsuspend, sys_sigpending, sys_sethostname,
sys_setrlimit, sys_getrlimit, sys_getrusage, sys_gettimeofday,
sys_settimeofday, sys_getgroups, sys_setgroups, sys_select, sys_symlink,
sys_lstat, sys_readlink, sys_uselib };

//系统调用总数目,注意一下:这里相较于linux0.11做了改进,新增系统调用不再需要手动调整该数目!
int NR_syscalls = sizeof(sys_call_table)/sizeof(fn_ptr);

可以知晓这里的call _sys_call_table(,%eax,4)就是调用系统调用号所对应的内核系统调用函数sys_write

最终执行sys_write

sys_write在fs下的read_write.c

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
//   /fs/read_write.c

// 写文件系统调用
int sys_write(unsigned int fd,char * buf,int count)
{
struct file * file;
struct m_inode * inode;

//判断函数参数的有效性
if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
return -EINVAL;
if (!count)
return 0;
// 取文件相应的i节点
inode=file->f_inode;
// 若是管道文件,并且是写管道文件模式,则进行写管道操作
if (inode->i_pipe)
return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
//如果是字符设备文件,则进行写字符设备操作
if (S_ISCHR(inode->i_mode))
return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
// 如果是块设备文件,则进行块设备写操作
if (S_ISBLK(inode->i_mode))
return block_write(inode->i_zone[0],&file->f_pos,buf,count);
// 若是常规文件,则执行文件写操作
if (S_ISREG(inode->i_mode))
return file_write(inode,file,buf,count);
printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}

至此库函数write,进行系统调用,最终调用了sys_write这个函数

我们再通过下图回顾一下,整个系统调用的过程:

内核态与用户态数据交互

到这里我们已经了解了系统调用的过程,还遗留一个问题需要去解决一下,就是内核态与用户态如何进行数据交互?

回顾系统调用过程中,我们可以发现寄存器在其中起到了不可或缺的作用,linus在linux0.12中也是采用类似的方法来进行数据交互

我们这里继续以sys_write函数为例,来看看里面的file_write(inode,file,buf,count);

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
//   /fs/file_dev.c

// 写文件函数 - 根据 i 节点和文件结构信息,将用户数据写入文件中
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
off_t pos;
int block,c;
struct buffer_head * bh;
char * p;
int i=0;

/*
* ok, append may not work when many processes are writing at the same time
* but so what. That way leads to madness anyway.
*/
//如果设置了追加标记位,则更新当前位置指针到文件最后一个字节
if (filp->f_flags & O_APPEND)
pos = inode->i_size;
else
pos = filp->f_pos;
// i为已经写入的长度,count为需要写入的长度
while (i<count) {
// 先取文件数据块号,如果没有则创建一个块
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
if (!(bh=bread(inode->i_dev,block)))
break;
c = pos % BLOCK_SIZE;
p = c + bh->b_data;// 开始写入数据的位置
bh->b_dirt = 1; //标记数据需要回写硬盘
c = BLOCK_SIZE-c; //算出能写的长度
if (c > count-i) c = count-i;
pos += c;
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
i += c;
while (c-->0)
*(p++) = get_fs_byte(buf++);//从用户态拷贝一个字节的数据到内核态
brelse(bh);
}
//当数据已经全部写入文件或者在写操作过程中发生问题时就会退出循环
inode->i_mtime = CURRENT_TIME;
if (!(filp->f_flags & O_APPEND)) {
filp->f_pos = pos;
inode->i_ctime = CURRENT_TIME;
}
return (i?i:-1);
}

我们这里不展开讲了,得后面讲完磁盘和文件系统再回过头来讲讲这块,把目光聚焦于get_fs_byte函数,我们来看下其源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//  include/asm/segment.h

// 读取 fs 段中指定地址处的字节。
// 参数:addr - 指定的内存地址。
// %0 - (返回的字节_v);%1 - (内存地址 addr)。
// 返回:返回内存 fs:[addr]处的字节。
// 第 3 行定义了一个寄存器变量_v,该变量将被保存在一个寄存器中,以便于高效访问和操作。
extern inline unsigned char get_fs_byte(const char * addr)
{
unsigned register char _v;

__asm__ ("movb %%fs:%1,%0":"=r" (_v):"m" (*addr));
return _v;
}

// 将一字节存放在 fs 段中指定内存地址处。
// 参数:val - 字节值;addr - 内存地址。
// %0 - 寄存器(字节值 val);%1 - (内存地址 addr)。
extern inline void put_fs_byte(char val,char *addr)
{
__asm__ ("movb %0,%%fs:%1"::"r" (val),"m" (*addr));
}

get_fs_byte函数是从用户态拷贝一个字节的数据到内核态,而put_fs_byte则恰恰相反,从内核态拷贝一个字节的数据到用户态

在系统调用运行整个过程中,DS和ES段寄存器指向内核数据空间,而FS段寄存器被设置为指向用户数据空间,这可能有人会问为啥?

别忘了在/kernel/sys_call.s_system_call中的这段:

1
2
3
4
5
6
7
8
_system_call:
...
movl $0x10,%edx # ds,es 指向内核数据段
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs 指向当前局部数据段(局部描述符表中数据段描述符)
mov %dx,%fs
...

0x10是全局描述符表GDT中内核数据段描述符的段值,0x17局部描述符表LDT中的任务的数据段描述符的段值

所以linux这里利用FS寄存器来完成内核数据空间与用户数据空间之间的数据复制,当进程从中断调用中退出时,寄存器会自动从内核栈弹出,快捷高效

本文完,感谢大家的阅读。2023年最后一篇文章,感觉还不错的话,希望大家多多点赞留言,最近数据实在是惨淡~~


参考资料:
《操作系统概念》
《Linux内核完全注释5.0》
https://www.howtogeek.com/251081/why-do-x86-cpus-only-use-two-out-of-four-rings


作者:小牛呼噜噜 ,首发于公众号「小牛呼噜噜

感谢阅读,原创不易,如果有收获的话,就点个免费的[]or[转发],你的支持会激励我输出更高质量的文章,感谢!