Skip to main content
应用视角的操作系统

应用视角的操作系统

Sep 19, 2026
2848words · 11minutes
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 函数调用与栈帧

调用函数时,程序会:

  1. 记录当前函数下一步应该返回的位置;
  2. 创建新的栈帧;
  3. 把参数放入新栈帧;
  4. 从被调用函数的入口开始执行。

函数返回时,新栈帧被弹出,程序回到保存的位置继续运行。

所以递归本质上不是“函数自己调用自己”这么一句话,而是调用栈不断压入新的函数实例。

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 / _exit

5.2 程序如何退出

CPU 的普通指令没有“把当前程序从系统中删除”这一能力。程序要退出,必须请求操作系统服务:

  1. 把系统调用编号放入规定的寄存器;
  2. 把参数放入规定的寄存器;
  3. 执行系统调用指令;
  4. 由操作系统终止进程并回收相关资源。

常见系统调用指令:

架构 指令 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.out
strace -f gcc hello.c
strace 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. 检查

  1. 为什么递归调用会自然地对应到栈帧?
  2. 为什么带有打印操作的两个函数不能随意交换调用顺序?
  3. 为什么程序退出必须依赖系统调用?
  4. _start 和 main 分别处在启动流程的什么位置?
  5. 编译器为什么可以删除一段“看起来执行了计算”的代码?
  6. strace 观察到的是程序的哪些行为?
  7. 浏览器、编译器、任务管理器为什么都可以被看成“计算 + 系统调用”?

12. 实验

# 1. 编译一个最小 C 程序
gcc hello.c -o hello
# 2. 观察它与操作系统的交互
strace ./hello
# 3. 查看可执行文件的 ELF 信息
readelf -h ./hello
# 4. 查看反汇编结果
objdump -d ./hello

建议重点观察:程序如何被加载、何时申请内存、怎样写出 Hello World,以及最后由哪个退出系统调用结束。

13. 资料来源

‍


Thanks for reading!

应用视角的操作系统

Sep 19, 2026
2848 words · 11 minutes

Comments