1. 如果应用不能控制硬件,它们如何打开文件?
在上一篇教程中,我们介绍了用户态与内核态。普通应用程序在受限权限下运行,而内核作为操作系统的核心,负责管理受保护的资源。
但这就带来了一个实际问题:如果应用程序不能随意控制存储设备,文本编辑器是如何打开文档的?浏览器又是如何从网络接收数据的?
答案是:应用程序向操作系统请求服务。而实现这些请求的一项关键机制,就是系统调用(System Call)。
这一篇,我们将以读取文件为例,跟随一个请求,从应用程序进入内核,再返回应用程序,看看整个过程如何完成。
2. 什么是系统调用?
系统调用是程序通过受控方式,向内核请求服务的机制。
这些服务包括读取文件、进行网络通信、创建进程,以及修改内存映射等。内存映射是指将一段虚拟地址与内存或文件等资源建立对应关系。
应用程序说明自己需要什么,内核解释请求、检查相关条件,然后执行被允许的操作。
系统调用不会让应用程序获得不受限制的计算机访问权限。它通过系统规定的入口机制,将执行流程转移到内核代码。
可以把它想象成在服务窗口办理业务:你告诉工作人员需要办理什么,但这并不意味着你有权进入所有受限区域。
在 Linux 中,系统调用构成了应用程序与内核之间的基本接口。应用程序通过这个接口,请求由内核管理的服务。
3. 为什么程序需要内核?
程序执行的许多操作并不需要系统调用。
例如,将两个数字相加、比较数值,或者重新排列已经位于应用程序内存中的数据,都可以完全在用户态完成。
但是,打开文件涉及操作系统管理的资源。系统需要解释文件名称、找到文件,并判断请求的访问是否被允许。
进程是程序运行起来之后的一个实例,同时拥有地址空间、已打开的文件等资源。地址空间是指该进程可以使用的虚拟内存地址集合。
如果每个进程都能随意修改存储设备,或者访问其他进程的私有内存,那么一个出错的程序就可能破坏其他程序正在进行的工作。
系统调用在这条保护边界上提供了明确的操作接口。应用程序提出请求,内核负责执行相关规则。
4. 库函数并不一定是系统调用
程序员通常通过调用函数来完成任务,而不是直接编写进入内核所需的机器指令。
函数是一段具有名称、用于完成特定任务的代码。库则是供程序重复使用的代码集合。
有些库函数完全在用户态完成工作,另一些库函数则需要请求内核服务。
封装函数是指替调用者处理另一个接口的调用细节的函数。在 Linux 中,程序通常通过 C 语言库中的封装函数,使用许多常见的系统调用接口。
调用这样的封装函数时,最开始的过程与普通函数调用相似。随后,函数内部的相关指令会安排进入内核的过程。
不过,一次高级函数调用,并不总是对应一次系统调用。
例如,库可能先把输出内容暂存在缓冲区中,等积累到一定数量后,再交给操作系统。缓冲区就是用于临时保存数据的一块内存。
因此,多次库函数调用,可能最终只触发一次稍后执行的系统调用。
5. 首先,打开文件
假设一个 Linux 程序需要读取文本文件。
在读取之前,程序先请求以只读方式打开文件。请求中包含文件的路径,也就是文件的名称和位置,以及希望采用的访问方式。
内核解析路径,并检查是否允许以这种方式打开文件。
如果操作成功,程序会获得一个文件描述符(File Descriptor)。它是一个较小的非负整数,用于在当前进程中标识一个已经打开的资源。
文件描述符不是文件内容,也不是文件的内存地址,而是供程序在后续请求中使用的引用标识。
例如,一个程序可能获得文件描述符 3。另一个进程也可能拥有文件描述符 3,但两者指向的可能是完全不同的文件。
文件打开之后,程序就可以通过这个描述符请求读取数据。
6. 读取请求需要提供哪些信息?
以 Linux 中常见的调用形式 read(fd, buffer, count) 为例。
其中,fd 是文件描述符,用于标识已经打开的文件;buffer 指定读取到的数据应当放入应用程序内存中的什么位置;count 指定最多希望读取多少字节。
**字节(Byte)**是数据的计量单位,一个字节包含 8 个二进制位。
传递给函数或系统调用的这些值,称为参数,用于说明这次操作需要处理什么。
假设程序希望读取最多 100 个字节,就需要提供一个足以容纳这些数据的目标缓冲区。
内核必须谨慎处理这些信息。应用程序提供的内存地址,不能直接被视为安全、有效的数据写入位置。
同时,文件描述符必须有效,并且支持读取操作。
这并不意味着每一次读取,都要重新执行打开文件时的完整路径查找和权限检查。不同阶段负责不同的检查。
7. 执行流程如何进入内核?
系统调用的封装函数会按照当前平台的规则,准备好请求。
通常,它会将系统调用号和相关参数放入指定的 CPU 寄存器。寄存器是处理器内部用于保存少量数据的高速存储位置。
系统调用号用于标识要执行哪一种操作。具体数值取决于操作系统和处理器接口,并不是所有平台都相同。
接下来,代码执行一条用于触发内核入口机制的指令。在 64 位 x86 Linux 中,通常使用 syscall 指令;在 RISC-V Linux 中,则使用 ecall 指令。
处理器通过受控入口转移执行流程,内核入口代码则保存处理请求和安全返回所需的状态。
这一转换由处理器硬件与内核软件共同完成。不同处理器架构中,硬件和软件各自承担的具体工作有所不同。
最关键的一点是:接下来执行的是内核代码,而不是应用程序自己的代码突然获得了内核权限。
8. 内核内部会发生什么?
内核根据请求信息,选择相应的系统调用处理函数。处理函数就是负责处理某一类请求或事件的代码。
对于读取请求,内核会识别已经打开的资源,并调用相应的文件系统代码。文件系统提供了组织和访问文件所需的规则与数据结构。
对于普通文件的常规缓冲读取,所需数据可能已经位于**页缓存(Page Cache)**中。页缓存是操作系统用于保存文件数据的一部分内存,可以减少不必要的存储设备访问。
如果数据已经在页缓存中,内核就可以直接使用这些数据,无须再次从存储设备读取相同内容。
如果数据尚未准备好,内核可能需要安排存储读取操作。这个过程会涉及设备驱动程序,也就是知道如何与某种设备或某一类设备通信的软件。
数据准备好之后,常规缓冲读取路径会将相应数据复制到应用程序提供的缓冲区中。
因此,执行一次 read 系统调用,并不一定意味着发生了一次物理磁盘读取。 内核可能直接利用已经保存在内存中的数据完成请求。
9. 如果数据还没有准备好,会怎么样?
有些请求可以立即完成,另一些则必须等待。
如果读取操作需要的数据尚未从存储设备到达,发起请求的线程可能会进入阻塞状态。线程是进程内部的一条执行序列;阻塞意味着它暂时无法继续执行,必须等待所需条件满足。
这时,调度器可以选择另一个可运行的线程使用 CPU。调度器是操作系统中负责决定哪个可运行线程获得 CPU 时间的部分。
将执行从一个线程切换到另一个线程,称为上下文切换。系统需要保存原线程的执行状态,并恢复新线程继续运行所需的状态。
这与模式切换不同。模式切换改变的是处理器执行代码时的权限级别。
系统调用会进入内核态,但不一定切换到另一个线程。如果请求很快完成,同一个线程就可以直接返回用户态。
如果线程发生阻塞,系统可以在它等待期间运行其他工作。等数据准备好后,等待的线程重新具备运行条件,但何时获得 CPU,仍然取决于调度安排。
10. 结果如何返回应用程序?
对于能够正常完成并返回的读取操作,内核会准备好结果,然后让执行流程返回用户态。
应用程序接着从调用之后的位置继续执行。
一次成功的读取会报告实际读取了多少字节。这个数量可能少于请求的数量,因此程序必须检查返回结果。
当读取普通文件、请求的字节数大于零时,如果返回值是 0,表示当前读取位置已经到达文件末尾。
通过通常使用的 Linux C 语言库接口,发生错误时会返回 -1,并通过 errno 提供错误代码,帮助程序判断出了什么问题。
需要区分的是:返回的字节数与读取到的数据,是两回事。 字节数作为函数结果返回,文件数据则放入程序提供的缓冲区。
随后,程序就可以在用户态处理这些数据。文件使用完毕后,程序关闭文件描述符,释放对该资源的这一个引用。
11. Windows 也有系统调用
系统调用并不是 Linux 独有的机制。
Windows 同样区分普通应用程序的执行与具有更高权限的操作系统代码执行。应用程序通过系统接口,请求受保护的服务。
Windows 程序员通常使用 Windows API。API 是 Application Programming Interface 的缩写,中文称为应用程序编程接口,指软件与其他组件交互时使用的一组函数和规则。
并不是每一个 API 函数都需要系统调用。有些工作完全可以在用户态完成,另一些操作则通过系统库进一步请求内核服务。
Windows 与 Linux 的接口和内部实现不同,但它们都需要通过受控方式,让应用程序请求受保护的操作。
库的名称有时也容易造成误解。例如,Windows 中的 kernel32.dll 虽然名称包含 kernel,但它在用户态运行,并不是 Windows 内核本身。这里的 DLL 指动态链接库,是一种可供程序加载和调用的代码库。
12. 系统调用与硬件中断
系统调用和硬件中断都可能使内核代码开始执行,但它们的起点不同。
系统调用源于当前正在执行的程序主动请求服务。
硬件中断则是通知处理器响应某个硬件事件的信号。例如,存储设备可以通过中断报告某项操作已经完成。
在前面的文件读取例子中,应用程序通过系统调用发起请求。如果需要访问存储设备,稍后发生的硬件中断可能帮助操作系统得知数据传输已经完成。
发起请求与通知完成,是整个过程中的两个不同环节。
另外,系统调用通常也不意味着把工作发送给一个独立的“内核进程”。内核代码经常在发起调用的线程上下文中运行,也就是利用该线程的执行环境处理请求。不过,某些工作也可能交给其他执行单元继续处理。
13. 为什么系统调用有开销?
与调用一个完全在用户态运行的小函数相比,系统调用需要完成更多工作。
系统必须跨越权限边界、保存必要状态、解释请求、执行检查,并最终返回。
总开销很大程度上取决于请求做了什么。直接利用内存中的数据完成请求,与等待存储设备返回数据,所需时间可能差别很大。
这也是库使用缓冲机制的一个原因。程序可以一次请求较大的一块数据,然后在本地处理,而不是每次只请求一个字节。
缓冲能够减少请求次数,不过最合适的方式仍然取决于应用程序的需求。
理解这些开销,有助于程序在需要操作系统服务时,更合理地组织请求。
14. 总结
系统调用为程序提供了一种受控方式,使其能够向操作系统内核请求服务。
我们跟随了一次文件读取过程:打开文件、获得文件描述符、准备读取请求、进入内核、取得数据,再将结果返回应用程序。
在这个过程中,我们看到了几个重要区别:库函数不一定是系统调用,系统调用不一定访问物理设备,模式切换也不一定伴随上下文切换。
最关键的是,应用程序能够请求所需操作,却不会因此获得对计算机不受限制的控制权。
这正是保护边界能够发挥作用的原因:程序可以使用需要的服务,同时由操作系统管理背后的资源并执行访问规则。