
应用视角的操作系统
AI summaryAI generated, for a quick overview
文章从应用视角讨论操作系统,把程序视为状态机,用栈帧描述函数调用与递归,指出编译正确性以输出、终止和系统调用等可观察行为等价为准,允许死代码消除。沿 _start、main 到 exit 说明最小程序的启动与退出,指出系统调用是应用与操作系统的权限边界,ELF 描述初始状态,浏览器、编译器等复杂应用本质仍是计算与系统调用的组合。
1. 本讲要回答的问题
- 程序到底是什么?
- 函数调用、递归和程序运行状态如何精确描述?
- 编译器把程序转换成什么?什么叫“编译正确”?
- 一个最小的操作系统应用如何启动和退出?
- 浏览器、编译器、窗口管理器等复杂应用,是否都建立在同一组系统调用之上?
- 如何用工具观察程序和操作系统之间的交互?
2. 核心知识地图
flowchart TD S[源代码 C] --> T[简化为 SimpleC] T --> I[解释执行:逐条语句改变状态] T --> C[编译:转换为机器指令] C --> M[CPU 状态机:取指、译码、执行] M -->|syscall / ecall / svc| OS[操作系统] OS --> O[文件、进程、内存、设备等对象] OS -->|返回结果或改变状态| M M --> X[exit / exit_group]3. 程序:一个状态机
3.1 什么是“状态”
程序的状态不是简单的“变量值”。在本讲的模型中,状态主要包括:
- 全局变量;
- 当前调用栈中的所有栈帧;
- 每个栈帧中的参数、局部变量和下一步执行位置(PC)。
执行一条语句,状态就发生一次迁移。程序运行,就是不断重复“读取当前状态 → 执行一步 → 得到新状态”。
初始状态 → 执行语句 → 新状态 → 执行语句 → 新状态 → …3.2 函数调用与栈帧
调用函数时,程序会:
- 记录当前函数下一步应该返回的位置;
- 创建新的栈帧;
- 把参数放入新栈帧;
- 从被调用函数的入口开始执行。
函数返回时,新栈帧被弹出,程序回到保存的位置继续运行。
所以递归本质上不是“函数自己调用自己”这么一句话,而是调用栈不断压入新的函数实例。
3.3 为什么函数调用顺序很重要
数学函数通常只关心输入和输出;C/C++ 函数还可能产生副作用,例如打印、写文件、修改全局变量。存在副作用时,调用顺序可能改变最终结果。
例如:
printf("A");printf("B");交换两句,输出就从 AB 变成 BA。因此,程序的“语义”不仅是算出了什么,还包括对外部世界做了什么、按什么顺序做。
4. 解释执行、编译与编译正确性
4.1 SimpleC:把复杂语句拆小
C 语言包含循环、分支、函数调用和复杂表达式。为了更容易观察程序运行,可以把它改写成每行只做一件事的简化形式,例如把循环改写为条件跳转,把函数调用拆成明确的保存和跳转。
这样可以像模拟 CPU 一样单步执行源程序:
fetch_statement()decode_and_execute(statement)4.2 编译器做什么
编译器把高级语言程序转换成更低级的程序表示,最终得到 CPU 能执行的机器指令。
编译后的程序不需要保留源代码的结构。只要对于相同输入,它的可观察行为与原程序等价,就可以认为编译是正确的。
可观察行为包括:
- 输出结果;
- 是否终止;
- 系统调用和其他外部 API 的行为;
- 对文件、设备、网络等外部对象的影响。
4.3 优化与死代码消除
如果某段计算不会影响任何外部可观察结果,编译器可以删除它,这叫 DCE(Dead Code Elimination,死代码消除)。
因此,“编译正确”不等于“机器代码看起来像逐行翻译”;正确性关注的是外部行为,而不是内部写法。
5. 最小应用程序:从 _start 到 exit
5.1 _start 与 main
普通 C 程序通常从 main 开始,但操作系统加载可执行文件后,首先进入的往往是 _start。运行库会在 _start 中完成初始化,再调用 main。
操作系统加载 ELF ↓进入 _start ↓运行库初始化 ↓调用 main ↓调用 exit / _exit5.2 程序如何退出
CPU 的普通指令没有“把当前程序从系统中删除”这一能力。程序要退出,必须请求操作系统服务:
- 把系统调用编号放入规定的寄存器;
- 把参数放入规定的寄存器;
- 执行系统调用指令;
- 由操作系统终止进程并回收相关资源。
常见系统调用指令:
架构 指令 x86-64 syscall RISC-V ecall AArch64 svc
6. 系统调用:应用与操作系统的边界
系统调用不是普通的库函数调用,而是一次控制权和权限的交接。
sequenceDiagram participant P as 应用程序 participant CPU as CPU participant K as 操作系统 P->>CPU: 执行 syscall / ecall / svc CPU->>K: 切换到操作系统入口 K->>K: 检查参数、访问对象、执行服务 K-->>CPU: 返回结果或改变进程状态 CPU-->>P: 应用继续运行、阻塞或终止系统调用可以用于:
fork、execve、exit:进程管理;open、close、read、write:文件和设备访问;mmap、brk:内存和地址空间管理;send、recv:网络通信;ptrace:观察或控制其他进程。
本讲的直观理解是:应用程序大部分时间在自己的状态机中计算;遇到系统调用时,暂时把控制权交给操作系统。

配图提示:小黑卡在“系统调用”的服务舱口,把请求递给操作系统,再接回返回结果。它表达的是权限和控制权的交接,不是普通函数调用。
7. 应用世界:程序看起来不同,本质相同
7.1 三类程序
- Applications:浏览器、编辑器、播放器、IDE;
- Utilities:Shell、
gcc、vim、curl、ffmpeg、包管理器; - Daemons:后台服务,如网络服务、设备管理、安全组件和定时任务。
它们都可以抽象成:
计算 + 调用操作系统 API + 继续计算 + 退出7.2 ELF:可执行文件是初始状态的描述
Linux 原生程序通常使用 ELF 格式。ELF 文件中包含:
- 程序入口地址;
- 机器指令;
- 初始数据;
- 加载信息;
- 各内存区域的权限等。
因此,ELF 不是“只有指令的二进制文件”,而是操作系统建立程序初始运行状态所需的信息集合。
7.3 复杂应用也只是 API 的组合
- 窗口管理器:管理屏幕和窗口对象,进行进程间通信;
- 任务管理器:读取进程相关信息;
- 杀毒软件:读取文件进行扫描,并观察或控制其他进程;
- 编译器:启动多个子进程,读取输入文件并生成输出文件。
它们的功能虽然复杂,但底层仍然由计算和操作系统 API 组合而成。
8. 用工具观察程序
8.1 strace
strace 用来观察程序执行过哪些系统调用,是理解“应用—操作系统边界”的重要工具。
strace ./a.outstrace -f gcc hello.cstrace ls |& grep -e read -e write看到 openat、read、write、mmap、exit_group 等调用时,可以把它们理解成程序和操作系统之间的一次次对话。
8.2 工具分工
工具 主要用途 gcc / clang 编译程序 gdb 单步执行、查看栈和变量 strace 查看系统调用 readelf 查看 ELF 文件结构 objdump 反汇编和查看二进制内容 profiler 分析时间和性能开销
9. 术语速查:用一句话理解
术语 通俗解释 状态机 有明确状态和变化规则的运行模型 状态迁移 执行一步后从旧状态到新状态 栈帧 一次函数调用保存参数、局部变量和返回位置的区域 调用栈 当前还没有返回的函数调用层层叠加形成的结构 PC Program Counter,下一步执行位置 形式语义 用严格规则说明程序“到底是什么意思” 解释器 直接读取并执行程序表示的软件 编译器 把程序转换成另一种等价表示的软件 Transpiler 在相近语言之间转换的程序,例如 C → SimpleC 副作用 除了返回值,还改变了外部可观察状态 指令集 CPU 支持的机器指令及其规则 寄存器 CPU 内部少量但高速的存储单元 系统调用 应用请求操作系统提供服务的入口 API 应用可以使用的接口和约定 进程 一个正在运行的程序实例 ELF Linux 常见的可执行文件格式 _start 可执行文件通常实际开始执行的位置 main C 运行库通常交给程序员使用的入口 execve 用新程序替换当前进程的内容 daemon 在后台长期运行并提供服务的程序 Trace 记录程序从开始到结束的执行过程 DCE 删除不会影响结果的代码
10. 最容易混淆的地方
程序、可执行文件、进程
- 程序:抽象的代码和逻辑;
- 可执行文件:保存在磁盘上的程序初始状态描述;
- 进程:可执行文件被加载后的一次实际运行。
同一个可执行文件可以同时启动多个进程。
API、库函数、系统调用
API 是接口约定;库函数是对接口的某种封装;系统调用是进入操作系统的机制。一个库函数可能执行一次、多个或零个系统调用。
编译不是“逐行翻译”
编译器可以重排、合并或删除内部计算。只要外部可观察行为保持等价,内部结构不同也属于正确编译。
系统调用不是普通函数调用
普通函数调用仍在应用自己的权限和地址空间中进行;系统调用会切换到操作系统控制的执行路径,并可能改变进程状态。
11. 检查
- 为什么递归调用会自然地对应到栈帧?
- 为什么带有打印操作的两个函数不能随意交换调用顺序?
- 为什么程序退出必须依赖系统调用?
_start和main分别处在启动流程的什么位置?- 编译器为什么可以删除一段“看起来执行了计算”的代码?
strace观察到的是程序的哪些行为?- 浏览器、编译器、任务管理器为什么都可以被看成“计算 + 系统调用”?
12. 实验
# 1. 编译一个最小 C 程序gcc hello.c -o hello
# 2. 观察它与操作系统的交互strace ./hello
# 3. 查看可执行文件的 ELF 信息readelf -h ./hello
# 4. 查看反汇编结果objdump -d ./hello建议重点观察:程序如何被加载、何时申请内存、怎样写出 Hello World,以及最后由哪个退出系统调用结束。
13. 资料来源
Comments