当前位置:首页 > 技术学院 > 技术前线
[导读]当我们在代码里调用read读取文件,调用malloc分配内存,调用socket创建网络连接的时候,最终都会落到系统调用上。但很多开发者只知道系统调用是用户程序请求内核服务的接口,却说不清系统调用到底是怎么实现的:为什么用户程序不能直接访问内核?从用户态到内核态的切换到底发生了什么?不同架构下系统调用的实现有什么区别?其实系统调用的实现逻辑,恰恰是理解操作系统隔离设计的核心入口,把这个流程拆解清楚,就能明白用户程序和内核的交互本质。

当我们在代码里调用read读取文件,调用malloc分配内存,调用socket创建网络连接的时候,最终都会落到系统调用上。但很多开发者只知道系统调用是用户程序请求内核服务的接口,却说不清系统调用到底是怎么实现的:为什么用户程序不能直接访问内核?从用户态到内核态的切换到底发生了什么?不同架构下系统调用的实现有什么区别?其实系统调用的实现逻辑,恰恰是理解操作系统隔离设计的核心入口,把这个流程拆解清楚,就能明白用户程序和内核的交互本质。

先搞懂:为什么需要系统调用,它解决了什么问题

CPU本身就有特权等级的区分,以常见的x86架构为例,CPU分为四个特权级,Linux用了最高的0级(内核态)和最低的3级(用户态):内核代码运行在内核态,拥有所有硬件权限,可以直接访问所有内存、操作所有硬件;用户程序运行在用户态,权限受限,不能直接操作硬盘、网卡,也不能直接访问内核地址空间。

这种隔离设计是为了稳定性:如果用户程序出错就能直接修改内核数据,或者随便操作硬件,整个系统很容易崩溃,一个程序出错就会拖垮整个系统。但用户程序又确实需要内核提供服务,比如读写文件、申请内存、发送网络包,这些都需要内核协助完成,所以必须有一个可控的通道,让用户程序主动请求内核服务——‌这个通道就是系统调用,它是用户态程序主动进入内核态的唯一合法入口。‌

简单来说,系统调用要做的就是三件事:让用户程序能主动切入内核,把参数正确传递给内核函数,内核执行完之后再切回用户态,把结果返回给用户程序,整个过程安全可控,不会破坏系统隔离。

系统调用实现的核心流程:五个关键步骤

不管是哪个CPU架构,系统调用的实现逻辑大同小异,都要经过五个核心步骤,我们以Linux x86_64架构为例,一步步拆解:

第一步:用户程序准备参数,触发系统调用

当用户程序调用read(int fd, void *buf, size_t count)这个系统调用的时候,首先会按照系统调用的约定,把参数放到指定的位置:x86_64架构下,系统调用的前六个参数会放到rdi、rsi、rdx、r10、r8、r9六个寄存器中,超过六个的参数才放到栈上。

除了参数,还要指定你要调用哪个系统调用:内核里面有上百个系统调用,每个系统调用都有一个唯一的编号,叫做syscall number,比如read的编号是0,write的编号是1,这个编号会放到rax寄存器中。

准备好参数和系统调用编号之后,用户程序执行一条特殊的汇编指令syscall,这条指令就是触发系统调用,主动切入内核态的入口。

第二步:CPU完成特权级切换,跳转到内核入口

syscall指令是CPU提供的特权指令,执行之后会自动完成几件事:

把当前的CPU状态(用户态的栈指针rsp、标志寄存器rflags、返回地址rip)保存起来,等下返回用户态的时候需要恢复;

切换CPU特权级,从3级用户态变成0级内核态;

切换栈指针,从用户态栈切换到当前进程的内核栈(就是我们之前说的每个线程独立的内核栈);

跳转到内核中预先定义好的系统调用入口地址,开始执行内核的系统调用处理逻辑。

这一步完全是CPU硬件自动完成的,不需要内核做额外的操作,硬件就帮我们完成了特权级和栈的切换,非常高效。

第三步:内核根据编号找到对应的系统调用函数,保存现场

进入内核之后,首先要做的就是确认系统调用编号合法,然后根据编号找到对应的内核处理函数。

内核里面维护了一个系统调用表sys_call_table,这是一个数组,数组的下标就是系统调用编号,数组里面存的就是每个系统调用处理函数的地址,比如sys_call_table就是sys_read函数的地址,所以拿到rax里的编号,直接查表就能找到处理函数。

找到处理函数之后,内核会把当前所有通用寄存器的值都保存到内核栈上,因为系统调用执行完返回用户态的时候,所有寄存器的值要和进入内核之前一模一样,不能乱,所以必须提前保存下来。

第四步:执行系统调用处理函数,返回结果

准备工作完成之后,就调用对应的内核处理函数,比如read就调用sys_read,内核函数会完成实际的工作:检查用户传入的参数是否合法,操作对应的文件,把数据拷贝到用户指定的缓冲区,最后把执行结果(成功返回长度,失败返回错误码)放到rax寄存器中,因为返回用户态的时候rax就是用来存返回值的。

第五步:恢复现场,返回用户态

系统调用函数执行完成之后,内核会把之前保存在内核栈上的寄存器都恢复回去,然后执行sysret指令,再次切换CPU状态:

切回用户态特权级;

切回用户态栈;

跳回用户程序中系统调用的下一条指令继续执行;

用户程序从rax寄存器拿到返回值,继续往后执行,整个系统调用流程就完成了。

整个流程看起来步骤很多,但是实际执行非常快,通常只需要几百个CPU周期,几纳秒到几十纳秒就能完成,对程序性能的影响很小。

不同时代的实现方式:从软中断到快速系统调用

刚才说的是x86_64架构下现在常用的syscall指令实现,其实在不同架构、不同时期,系统调用的触发方式不一样,我们梳理一下常见的实现方案:

早期方案:int 0x80软中断

在32位x86时代,Linux最早用的是int 0x80软中断来触发系统调用。int指令是软件触发中断的指令,0x80就是中断号,内核预先注册了0x80中断的处理程序,用户程序执行int 0x80就会触发中断,切入内核,后续流程和我们上面说的差不多,查表找系统调用函数,执行完返回。

软中断的问题是什么呢?它走的是通用中断处理流程,需要做很多中断相关的检查和处理,开销比专门的系统调用指令大,性能不如现在的方案,所以32位后期就慢慢被更快的方案取代了,现在只有兼容老程序的时候还会用到。

中间优化:sysenter/sysexit

后来Intel推出了专门的快速系统调用指令sysenter和sysexit,专门用来实现用户态到内核态的切换,不需要走软中断的流程,切换速度比int 0x80快很多,所以32位Linux后来就改用sysenter来触发系统调用了。

但sysenter也有一些问题,它是Intel专属的,AMD早期的实现不兼容,后来AMD推出了自己的syscall指令,最终x86_64架构统一用了syscall/sysret作为标准的系统调用触发方式,一直用到现在。

其他架构:ARM的系统调用实现

在ARM架构上,系统调用的触发方式也不一样:32位ARM用的是swi #0软中断指令,和x86的int 0x80逻辑类似;64位ARM(AArch64)用的是svc #0指令,原理和syscall差不多,触发之后切换到内核态,跳转到系统调用入口,流程和x86_64基本一致,只是指令和寄存器约定不一样而已。

不管用哪种触发方式,核心逻辑都是一样的:用户触发 -> 硬件切特权 -> 内核处理 -> 返回用户态,万变不离其宗。

系统调用的核心细节:那些容易被忽略的设计点

理解了整体流程,我们再看几个系统调用实现中关键的设计细节,这些细节恰恰体现了系统调用设计的巧思:

1. 参数检查:内核必须验证用户参数的合法性

这是内核安全非常重要的一步:用户程序传入的指针、地址都是用户空间的,内核必须检查这些地址是不是真的属于用户空间,有没有越权访问,如果允许内核直接读写用户没有权限的地址,就会出现安全漏洞。

比如read系统调用,用户传入一个缓冲区地址buf,让内核把读到的数据写到这个地址,内核必须先检查这个buf是不是在当前进程的用户地址空间范围内,是不是有写权限,如果用户故意传入一个内核地址,内核检查不通过就会直接返回错误,不会往里写数据,避免破坏内核数据。

2. 为什么用寄存器传参,不用栈?

系统调用很少用用户栈传参,大部分参数都用寄存器传递,核心原因是效率:如果用用户栈传参,内核需要额外把参数从用户栈拷贝到内核栈,还需要做地址检查,开销更大;用寄存器传参,进入内核的时候参数已经在寄存器里了,内核直接用就行,更快更高效。

3. 系统调用和普通函数调用的开销差在哪?

很多人知道系统调用比普通函数调用慢,但不知道慢在哪,其实慢的核心原因就是两次特权级切换:普通函数调用完全在用户态,只需要压栈跳地址,就能完成调用,不需要切换特权级和栈,开销只有几个CPU周期;而系统调用需要从用户态切到内核态,再切回来,还要保存恢复所有寄存器,开销比普通函数调用高一个数量级,虽然现在已经很快了,但还是比普通函数调用慢。

这也是为什么现在很多性能敏感的场景会减少不必要的系统调用,比如用IO多路复用(epoll)代替大量accept/read的系统调用,就是为了减少特权级切换的开销,提升性能。

4. 中断触发的内核切入和系统调用有什么区别?

很多人会问:系统调用是软中断触发,硬件中断也是切入内核,两者有什么区别?核心区别是‌主动切入和异步切入‌:系统调用是用户程序主动请求内核服务,是同步的,用户程序等着内核返回结果,所以用当前进程的内核栈;而硬件中断是异步发生的,打断当前正在运行的进程,所以用专门的中断栈,不占用进程的内核栈,避免栈溢出。

封装与抽象:C库为什么要封装系统调用?

我们写C语言的时候,从来不需要自己写汇编指令触发系统调用,都是直接调用read、open这些函数,这是因为glibc等C标准库已经帮我们做了封装:把参数按照约定放到寄存器,触发系统调用,处理返回值,把内核返回的错误码设置到errno,用户直接用就行,不需要管底层的汇编细节。

所以我们平时说的read函数,其实是C库封装的封装函数,真正和内核交互的系统调用是内核的sys_read,这种封装既简化了用户开发,也隐藏了底层架构的差异,同样的C代码,在x86和ARM上编译之后,自动会用对应的系统调用指令,不需要用户改代码,可移植性更好。

还有一些系统调用,内核提供了,但是C库会封装成更友好的接口,比如fork系统调用,C库的封装会帮我们处理一些用户空间的资源,然后再调用内核的系统调用,让用户用起来更简单。

实际开发中,系统调用相关的常见问题

我们结合开发中常见的问题,再加深一下对系统调用的理解:

问题一:为什么strace能跟踪程序的系统调用?

strace是Linux下常用的调试工具,能打印出程序所有的系统调用,它是怎么做到的?其实strace用了ptrace系统调用,ptrace允许一个进程跟踪另一个进程的系统调用,拦截每个系统调用的进入和退出,拿到系统调用编号、参数和返回值,然后打印出来,本质就是利用了系统调用的拦截机制,这也是调试工具的核心基础。

问题二:为什么频繁系统调用会影响性能?

刚才说过,每个系统调用都要做两次特权级切换,还要保存恢复寄存器,哪怕每个系统调用只需要100纳秒,一秒钟调用一百万次,就是100毫秒,占了十分之一的CPU时间,自然会影响性能。所以高并发服务都会尽量减少系统调用次数,比如用readv/writev批量读写,用epoll一次等待多个IO事件,都是为了减少系统调用的次数,提升性能。

问题三:系统调用会不会被中断?

早期Linux系统调用是可以被中断的,如果一个系统调用在阻塞等待,来了信号,系统调用会提前返回,报错EINTR,所以很多老的代码都会处理EINTR错误,重新发起系统调用。现在有些系统调用改成了自动重启,不需要用户处理,但网络编程中还是要注意EINTR的处理,避免出错。

系统调用设计的本质:隔离与协作的平衡

绕了一圈回来,我们会发现系统调用的整个设计,核心就是在隔离和协作之间找平衡:CPU的特权级隔离保证了系统的稳定性,不让用户程序随便乱碰内核,而系统调用给了用户程序一个合法的入口,既能让用户程序获得内核提供的服务,又能保证所有请求都在内核的控制之下,不会破坏隔离性。

从最早的软中断int 0x80,到后来专门的syscall快速指令,整个发展过程都是在优化性能,减少切换的开销,让这个通道变得更快,更高效。而C库的封装,又把底层的汇编细节隐藏起来,让开发者不需要关心架构差异,就能轻松使用内核服务。

结语

系统调用的实现看起来是底层内核的细节,和普通应用开发关系不大,但理解了它,我们就能明白很多性能问题的根源:为什么系统调用多了会慢,为什么IO多路复用能提升高并发性能,为什么strace能跟踪程序调用,这些问题的答案都在系统调用的实现逻辑里。

从用户态主动触发,到硬件切换特权级,再到内核查表执行,最后返回用户态,整个流程清晰简洁,每一步设计都有明确的目的,这恰恰体现了操作系统设计的美感:用清晰的层次,合理的隔离,实现了用户程序和内核的高效协作,支撑起了所有应用程序的运行。理解了系统调用的实现,我们对操作系统的理解,就又进了一步。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

在工业自动化现场,我们时常听到这样的抱怨:"明明 Linux 上跑个 EtherCAT 主站协议栈很简单,可一到多轴联动、精密组装这类场景,周期一不小心就'飘'了,轨迹抖得让人心慌。" 问题就出在"硬实时"三个字上。要在...

关键字: 工业自动化 Linux 半导体

在云原生技术蓬勃发展的今天,容器凭借轻量、高效、可移植的特性,成为构建现代应用的核心载体。然而,容器并非绝对安全的“隔离堡垒”——当内核存在漏洞时,攻击者可通过容器逃逸突破隔离限制,直接获取宿主机的控制权,进而威胁整个集...

关键字: 内核 Linux

在探讨Linux可执行文件如何装载进虚拟内存之前,我们首先需要理解虚拟内存这一核心概念。虚拟内存是计算机系统内存管理的一种关键技术,它为应用程序构建了一个看似连续完整的地址空间,让程序认为自己拥有一块独立且连续的内存区域...

关键字: Linux 虚拟内存

在软件开发过程中,调试是定位和解决问题的关键环节。GDB(GNU Debugger)作为Linux平台下最常用的调试工具,支持对C、C++等多种语言程序的调试,能够帮助开发者监控程序执行、检查变量值、定位崩溃原因。然而,...

关键字: GDB Linux

随着嵌入式Linux系统的复杂度不断增加,设备驱动开发面临着新的挑战。传统的内核编码方式已难以满足现代SoC平台硬件配置的灵活性和可维护性需求,而设备树(Device Tree)技术的引入,彻底改变了Linux内核与硬件...

关键字: Linux 驱动开发

在Zynq/SoC异构计算平台开发中,PS(Processing System)端运行Linux系统与PL(Programmable Logic)端自定义IP核之间的高速数据交互是核心挑战之一。DMA(直接内存访问)技术...

关键字: Zynq/SoC开发 Linux

在Linux环境下的C/C++开发中,程序调试是排查问题、优化性能的核心环节。GDB(GNU Debugger)作为一款功能强大的命令行调试工具,凭借其精细的控制能力和丰富的功能,成为开发者不可或缺的利器。然而,GDB的...

关键字: GDB Linux

在Linux程序开发与运行的链条中,链接是衔接编译与执行的关键环节。它将编译器生成的目标代码、系统库函数等资源整合为可执行程序,直接决定了程序的资源占用、维护成本与运行效率。静态链接曾是早期系统的主流选择,但随着软件规模...

关键字: Linux 静态链接

在Linux操作系统中,进程管理是核心功能之一,而进程调度与切换则是保障系统高效、稳定运行的关键机制。它们决定了CPU资源如何分配给各个进程,直接影响着系统的响应速度、吞吐量和公平性。

关键字: Linux CPU

在Zynq MPSoC开发中,实现PS端Linux与PL端自定义IP核的AXI互联是构建高性能异构系统的关键环节。这种互联方式充分发挥了ARM处理器的软件优势与FPGA的硬件加速能力,为复杂应用提供了强大的计算平台。

关键字: Zynq MPSoC Linux
关闭