什么是系统调用机制?结合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 CPU
中Ring2和Ring3
在操作系统里安全情况没有区别,Ring1
下的系统权限又需要经常调用Ring0
特权指令,频繁切换特权级成本过高,操作系统不如将Ring2
合并到Ring3
,将Ring1
划入Ring0
特权级
另一方面不是每种处理器都像x86
一样支持4个权限级别,有些处理器可能只支持2个级别,更少的特权级别,便于移植其他处理器架构上
我们再来看下linux的体系架构图:
我们可以发现Linux系统从整体上看,被划分为用户态和内核态
- 内核态
内核态是处于操作系统的最核心处,Ring0
特权级,拥有操作系统的最高权限,能够控制所有的硬件资源,掌控各种核心数据,并且能够访问内存中的任意地址;由内核态统一管理这些核心资源,减少有限资源的访问和使用冲突;在内核里发生的任何程序异常都是灾难性的,会导致整个操作系统的奔溃
- 用户态
用户态,就是我们通常编写程序的地方,处于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 | // lib/write.c |
write.c
这个文件主要是定义write的实现,_syscall3(*,write,*)
函数的主要功能是,向文件描述符fd指定的文件写入count个字节的数据到缓冲区buf中
需要注意一下#define __LIBRARY__
这个宏定义,这里定义直接原因是为了包括在unistd.h
中的内嵌汇编代码
库函数扩展汇编宏
因为_syscall3
这个函数定义在/include/unistd.h
中,来看下源码:
1 | // /include/unistd.h |
只有在lib/write.c
中先定义了#define __LIBRARY__
,那么才能在/include/unistd.h
中,找到系统调用号和内嵌汇编_syscall3()
;不然就代表它不需要进行系统调用,这样就可以忽略unistd.h
中和系统调用相关的宏定义,非常的优雅
其实我们可以把write.c中的write函数再重新整合一下:
1 | int write(int fd,const char* buf,off_t count) \ |
这样大家就能更容易明白#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 | // /kernel/sched.c |
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 | // /kernel/sys_call.s |
其中 _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 | // /include/linux/sys.h |
可以知晓这里的call _sys_call_table(,%eax,4)
就是调用系统调用号所对应的内核系统调用函数sys_write
最终执行sys_write
sys_write
在fs下的read_write.c
:
1 | // /fs/read_write.c |
至此库函数write,进行系统调用,最终调用了sys_write
这个函数
我们再通过下图回顾一下,整个系统调用的过程:
内核态与用户态数据交互
到这里我们已经了解了系统调用的过程,还遗留一个问题需要去解决一下,就是内核态与用户态如何进行数据交互?
回顾系统调用过程中,我们可以发现寄存器在其中起到了不可或缺的作用,linus在linux0.12
中也是采用类似的方法来进行数据交互
我们这里继续以sys_write
函数为例,来看看里面的file_write(inode,file,buf,count);
1 | // /fs/file_dev.c |
我们这里不展开讲了,得后面讲完磁盘和文件系统再回过头来讲讲这块,把目光聚焦于get_fs_byte
函数,我们来看下其源码:
1 | // include/asm/segment.h |
get_fs_byte
函数是从用户态拷贝一个字节的数据到内核态,而put_fs_byte
则恰恰相反,从内核态拷贝一个字节的数据到用户态
在系统调用运行整个过程中,DS和ES段寄存器指向内核数据空间,而FS段寄存器被设置为指向用户数据空间,这可能有人会问为啥?
别忘了在/kernel/sys_call.s
中_system_call
中的这段:
1 | _system_call: |
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[转发],你的支持会激励我输出更高质量的文章,感谢!