应用视角的操作系统:程序到底是什么?——从状态机、syscall 到 strace 看懂应用如何运行

往期回顾

《指针合集》《c语言基础》《数据结构》《机器学习导论》《前端基础》

声明

这部分内容主要是笔者根据NJU的蒋炎岩老师的OS课程整理而成,大家如果感兴趣的话可以上课程的网站看看.

课程网站 https://jyywiki.cn


一、前言

学习操作系统时,我们经常一上来就会被一堆词砸晕:

  • 进程
  • 线程
  • 调度
  • 页表
  • 文件系统
  • 中断
  • 系统调用

这些概念当然重要,但如果一开始就站在“内核实现者”的角度看,初学者很容易迷路。

更自然的入口应该是:

我写的程序,究竟是怎么在操作系统上运行起来的?

比如我们写一个最简单的 C 程序:

#include <stdio.h>

int main()
{
    printf("hello, world\n");
    return 0;
}

表面上看,它只是打印了一句话。
但再往下追问,就会出现一连串问题:

  • main 是谁调用的?
  • printf 最后是怎么把字符显示到屏幕上的?
  • 程序为什么能访问文件、屏幕、键盘这些设备?
  • 程序执行结束时,谁负责把它从系统里清理掉?
  • 操作系统到底什么时候介入?

这篇文章就从“应用程序”的视角出发,来理解操作系统。

实际上对于所有的程序来说:

程序 = 状态机
应用程序 = 会调用操作系统 API 的状态机

二、程序不是一坨代码,而是一个正在运行的状态机

我们平时写的代码只是文本文件。

比如:

int x = 1;
x = x + 1;
printf("%d\n", x);

但程序一旦运行起来,它就不再只是文本,而是一个不断变化的系统。

可以把执行过程理解成:

状态 0:x 还不存在
   │ 执行 int x = 1;
   ▼
状态 1:x = 1
   │ 执行 x = x + 1;
   ▼
状态 2:x = 2
   │ 执行 printf("%d\n", x);
   ▼
状态 3:屏幕上出现 2

也就是说,程序运行的本质不是“代码躺在那里”,而是:

程序从一个状态,按照规则一步步转移到下一个状态。

这就是状态机。


三、什么是状态机?

状态机可以用一句话解释:

当前状态 + 当前输入 / 当前动作 -> 下一状态

比如地铁闸机就是一个状态机。

它有两个状态:

LOCKED    锁住
UNLOCKED  打开

它有两个输入:

刷卡
通过

那么这个系统的状态转移可以画成这样:

   刷卡
LOCKED ─────▶ UNLOCKED
  ▲              │
  │              │ 通过
  └──────────────┘

这张图的意思是:

  • 当前是 LOCKED,如果刷卡,就变成 UNLOCKED
  • 当前是 UNLOCKED,如果人通过,就变回 LOCKED
  • 如果没刷卡就想通过,仍然是 LOCKED

程序也是类似的。

只不过程序的状态更复杂,它包括:

变量
寄存器
栈帧
全局数据
堆区数据
程序计数器 PC
打开的文件
内存映射

所以,一个运行中的程序可以粗略看成:

程序状态
   │ 执行下一条语句 / 指令
   ▼
新的程序状态

这就是“程序 = 状态机”的基本含义。


四、C 函数不是数学函数:它会改变世界

当我们初学递归时会卡住,一个重要原因是把“数学函数”和“C 语言函数”混在了一起。

数学里的函数更像一种映射:

f(x) = x + 1

你关心的是:

输入 x
输出 x + 1

但 C 函数不只是映射。
它会真的执行动作。

例如:

void print_twice()
{
    printf("hello\n");
    printf("hello\n");
}

这个函数没有返回值,但它依然有行为:

向屏幕输出两行 hello

也就是说,C 函数可能会:

  • 创建新的栈帧
  • 修改局部变量
  • 修改全局变量
  • 申请内存
  • 打印内容
  • 读写文件
  • 调用其他函数
  • 调用操作系统 API

函数调用时,栈大致会变成这样:

调用前:

┌────────────┐
│ main frame │
└────────────┘


调用 f() 后:

┌────────────┐
│ f frame    │
├────────────┤
│ main frame │
└────────────┘


f() 返回后:

┌────────────┐
│ main frame │
└────────────┘

所以函数调用本质上也是状态机的一次状态变化:

当前栈状态
   │ call f()
   ▼
压入 f 的栈帧
   │ 执行 f
   ▼
弹出 f 的栈帧
   │
   ▼
回到调用点

这件事理解了,递归也会更容易理解。

递归不是魔法,只是:

一个函数不断创建新的栈帧
直到满足退出条件
再一层层返回

五、编译器做了什么:把一种状态机翻译成另一种状态机

我们写的是 C 代码,但 CPU 执行的是机器指令。

所以中间需要编译器。

很多教材会说:

编译器把高级语言翻译成机器语言。

这句话没错,但还可以说得更准确一点:

编译器把 C 程序这个状态机,翻译成机器指令组成的状态机。

图示如下:

C 程序状态机
      │
      │ 编译
      ▼
机器指令状态机

它们长得完全不一样,但行为应该等价。

比如 C 程序写的是:

printf("hello\n");

编译后可能变成一大堆指令和函数调用。
但只要最终效果是:

向标准输出写入 hello\n

那么从应用行为上看,它们就是等价的。

这也解释了为什么编译器可以优化代码。

例如:

int x = 1 + 2;
printf("%d\n", x);

编译器可能会直接优化成:

printf("%d\n", 3);

因为最终可观察行为没有改变。

这里有一个很重要的词:

可观察行为

什么叫可观察?

比如:

  • 输出到了屏幕
  • 写入了文件
  • 修改了网络连接
  • 返回了结果
  • 触发了系统调用

如果一段代码的计算结果永远不会被观察到,编译器就可能把它删掉。
这就是死代码消除。

没有被观察到的计算
       │
       ▼
可能被编译器优化掉

所以,学习操作系统时,我们要特别关注:

程序什么时候和外部世界发生交互?

答案通常是:

通过操作系统 API

六、普通计算和外部世界之间,有一道边界

程序内部可以自己做很多事情。

比如:

int a = 1;
int b = 2;
int c = a + b;

这些属于普通计算:

加法
减法
乘法
比较
跳转
函数调用
修改变量

但程序一旦想碰到外部世界,就不一样了。

比如:

printf("hello\n");

表面上看,这是一个 C 函数。
但屏幕不是你的程序自己拥有的。

再比如:

FILE* fp = fopen("data.txt", "r");

磁盘文件也不是你的程序自己拥有的。

你的程序不能直接对硬盘说:

把第几个扇区的数据给我

也不能直接对显示器说:

把这几个像素点亮

这些资源由操作系统管理。

所以应用程序想访问外部资源,就必须向操作系统请求服务。

这道边界可以画成这样:

用户程序
┌────────────────────┐
│ 普通计算            │
│ 变量 / 函数 / 算法   │
└─────────┬──────────┘
          │ 请求服务
          ▼
┌────────────────────┐
│ 操作系统 API        │
│ 文件 / 进程 / 内存   │
│ 网络 / 设备 / 权限   │
└─────────┬──────────┘
          ▼
        硬件

这就是应用视角下的操作系统。


七、系统调用 syscall:应用程序向 OS 求助的入口

系统调用,英文是:

system call
syscall

它是应用程序和操作系统之间最关键的接口。

可以这样理解:

普通函数调用,是程序内部找人帮忙;系统调用,是程序向操作系统求助。

普通函数调用:

main -> printf -> strlen

这些仍然发生在用户程序的世界里。

系统调用则不同:

用户程序
   │ syscall
   ▼
操作系统内核

更完整一点:

应用程序
   │
   │ 普通计算
   ▼
需要访问外部资源
   │
   │ syscall
   ▼
操作系统接管
   │
   ├── 读文件
   ├── 写屏幕
   ├── 创建进程
   ├── 分配内存
   ├── 发送网络数据
   └── 结束程序

常见系统调用包括:

read      读取
write     写入
open      打开文件
close     关闭文件
mmap      建立内存映射
brk       调整堆空间
fork      创建进程
execve    执行新程序
exit      退出进程

所以,一句话总结:

syscall = 应用程序进入操作系统服务区的入口

八、为什么说 syscall 像“全身麻醉”?

这个比喻很形象。

执行普通指令时,程序还在自己的世界里:

我自己算
我自己跳转
我自己改变量
我自己调用函数

但执行 syscall 时,程序主动把控制权交给操作系统。

这有点像:

我先暂停
请操作系统接管
处理完再把结果还给我

图示如下:

用户态程序
    │
    │ syscall
    ▼
内核态操作系统
    │
    ├── 检查权限
    ├── 检查参数
    ├── 操作文件 / 设备 / 进程
    ├── 修改进程状态
    └── 返回结果
    │
    ▼
用户态程序继续执行

为什么说像“全身麻醉”?

因为一旦控制权交出去,接下来发生什么,程序自己说了不算。

操作系统可能会:

  • 让你继续执行
  • 让你等待 I/O
  • 修改返回值
  • 改变内存映射
  • 唤醒其他进程
  • 终止当前进程

所以 syscall 是非常重要的边界:

syscall 前:程序自己执行
syscall 后:操作系统接管

九、程序为什么不能自己真正退出?

我们平时写:

return 0;

就觉得程序退出了。

但站在操作系统视角,这件事没有那么简单。

CPU 只会不断执行指令。
如果没有操作系统介入,程序执行完当前指令后,下一步执行哪里?

它不能凭空把自己从系统里删除。

进程退出需要操作系统完成一系列工作:

回收内存
关闭文件
释放进程资源
记录退出状态
通知父进程
从调度队列中移除

所以真正的退出也需要 syscall。

从应用程序角度看:

return 0
   │
   ▼
运行时库收尾
   │
   ▼
调用 exit / exit_group
   │
   ▼
操作系统回收进程

因此,退出程序不是一句“我不玩了”那么简单,而是:

程序请求操作系统把自己清理掉。


十、ELF:可执行文件不是乱码,而是程序初始状态说明书

在 Linux 上,一个常见的可执行文件格式叫 ELF。

我们看到的可执行文件可能像一堆乱码,但在操作系统眼里,它是一份结构化说明书。

它告诉操作系统:

  • 入口地址在哪里
  • 哪些内容是代码
  • 哪些内容是数据
  • 哪些部分要加载进内存
  • 每段内存的权限是什么
  • 是否需要动态链接器

可以粗略画成这样:

┌──────────────────────┐
│ ELF Header            │
│ 入口地址 / 文件类型等  │
├──────────────────────┤
│ Program Header Table  │
│ 描述如何加载到内存     │
├──────────────────────┤
│ .text                 │
│ 代码段                │
├──────────────────────┤
│ .rodata               │
│ 只读数据              │
├──────────────────────┤
│ .data                 │
│ 已初始化全局数据       │
├──────────────────────┤
│ .bss                  │
│ 未初始化全局数据       │
└──────────────────────┘

所以可执行文件可以理解成:

程序初始状态的描述。

当我们运行:

./hello

操作系统大致会做:

读取 ELF 文件
   │
   ▼
解析 ELF Header
   │
   ▼
把代码段 / 数据段加载到内存
   │
   ▼
建立进程初始地址空间
   │
   ▼
跳转到入口地址执行

用状态机的语言说:

ELF 文件
   │ execve
   ▼
进程初始状态
   │
   ▼
状态机开始运行

这也是为什么各种复杂程序从根上看都很相似:

浏览器
游戏
编辑器
命令行工具
杀毒软件
AI Agent

本质上都是:
可执行文件 -> 被 OS 加载 -> 成为进程 -> 执行指令和 syscall

十一、从 _start 到 main:main 并不是最开始的地方

很多人以为 C 程序从 main 开始。

这是对的,但不够底层。

从 C 语言学习角度,可以说:

程序从 main 开始执行

但从操作系统和可执行文件角度,更准确地说:

操作系统从 ELF 记录的入口地址开始执行

这个入口通常不是我们写的 main,而是运行时库中的启动代码,比如 _start

大致过程如下:

操作系统加载 ELF
   │
   ▼
跳转到入口地址 _start
   │
   ▼
运行时库初始化
   │
   ├── 准备 argc / argv / envp
   ├── 初始化 libc
   ├── 注册退出函数
   │
   ▼
调用 main
   │
   ▼
main 返回
   │
   ▼
调用 exit

所以:

main 不是宇宙大爆炸
main 是运行时库帮我们包装出来的入口

我们平时不用关心 _start,是因为编译器和运行时库帮我们处理了这些细节。

但学习操作系统时,知道这件事很重要。

因为它说明:

应用程序开始运行,也是一套由操作系统、可执行文件格式和运行时库共同完成的流程。


十二、strace:给程序装一个“系统调用摄像头”

学到这里,我们已经知道:

应用程序 = 普通计算 + 操作系统 API

但普通计算在程序内部发生,我们很难直接看到。

系统调用不一样。
它是程序和操作系统之间的交互边界。

所以如果我们能观察系统调用,就能看见程序什么时候向操作系统求助。

这就是 strace 的作用。

strace 可以追踪程序执行过程中发生的系统调用。

例如:

strace ./hello

可能会看到类似输出:

execve("./hello", ["./hello"], ...) = 0
write(1, "hello\n", 6)              = 6
exit_group(0)                       = ?

这些输出的意思是:

execve      执行这个程序
write       向文件描述符 1 写入 hello\n
exit_group  退出程序

其中:

文件描述符 1 = 标准输出 stdout

也就是说:

printf("hello\n");

最后很可能会变成类似:

write(1, "hello\n", 6)

因为它让我们第一次真正看到:

C 程序和操作系统之间到底发生了什么。


十三、用 strace 看 Hello World

假设我们写一个简单程序:

#include <stdio.h>

int main()
{
    printf("hello\n");
    return 0;
}

编译运行:

gcc hello.c -o hello
./hello

普通运行只能看到:

hello

但用 strace:

strace ./hello

我们就能看到它和操作系统之间的对话。

简化后大致是:

execve("./hello", ["./hello"], ...) = 0
...
write(1, "hello\n", 6)              = 6
exit_group(0)                       = ?

这就像给程序加了一副透视眼镜。

平时我们只看见:

hello

现在我们看见:

程序启动了
程序加载了库
程序写了标准输出
程序退出了

所以 strace 很适合用来回答:

这个程序到底向操作系统请求了什么服务?

十四、strace 它是调试工具

strace 不只是教学工具,也是实际开发和排错中很有用的工具。

比如一个程序打不开文件:

程序提示:No such file or directory

你不知道它到底在找哪个文件。

这时可以用:

strace -e openat ./program

你可能会看到:

openat(AT_FDCWD, "/wrong/path/config.json", O_RDONLY) = -1 ENOENT

这说明程序实际找的是:

/wrong/path/config.json

而这个文件不存在。

再比如程序卡住了,可以看它是不是一直在等待某个系统调用:

strace -p <pid>

可能看到:

read(3, 

说明它可能正在等文件、管道或网络数据。

这就是 strace 的实际价值:

它让黑盒程序变得可观察

十五、任何复杂应用,本质上都离不开 syscall

现在我们再看一些复杂程序。

15.1 编译器 gcc

我们运行:

gcc main.c -o main

表面上看是一个命令。

但实际上它可能会:

读取 main.c
启动预处理器
启动编译器
启动汇编器
启动链接器
读取头文件
读取库文件
生成临时文件
生成可执行文件

用 strace 看,会发现大量:

openat
read
write
execve
mmap
close

也就是说,gcc 不是一个“单体魔法程序”,而是大量工具和系统调用协作的结果。

15.2 浏览器

浏览器看起来是图形界面程序,但它仍然离不开系统调用:

读取配置文件
加载动态库
创建进程 / 线程
申请内存
访问网络
读写缓存
和显示系统通信
接收键盘鼠标事件

15.3 游戏

游戏也是一样:

读取资源文件
创建窗口
加载纹理
播放声音
读取输入设备
申请内存
进行网络通信

15.4 AI Agent

AI Agent 也是一样:

读取项目文件
执行命令
调用编译器
访问网络
写入结果
启动子进程
管理上下文

所以不管表面多复杂,从应用视角看,它们都可以统一理解成:

普通计算 + 操作系统 API

十六、任务管理器、杀毒软件、病毒,也都是 OS API 的组合

很多初学者觉得任务管理器、杀毒软件、病毒这些东西特别神秘。

但站在操作系统视角,它们仍然是程序。

只是它们调用的 API 更特殊,权限更敏感。

16.1 任务管理器

任务管理器能看到进程,是因为操作系统提供了进程信息。

它可能会做:

读取进程列表
读取 CPU 使用率
读取内存占用
显示进程状态
结束某个进程

在 Linux 中,很多进程信息可以通过 /proc 文件系统观察。

例如:

ls /proc

每个进程通常都有一个对应目录:

/proc/1
/proc/1000
/proc/2333

这些看起来像文件,实际上是操作系统暴露出来的进程信息接口。

16.2 杀毒软件

杀毒软件也不是魔法。

它可能做:

扫描文件内容
监控进程行为
检查可疑系统调用
拦截危险操作
分析网络连接

16.3 病毒

病毒也不是魔法。

它同样需要调用系统能力:

读文件
写文件
创建进程
修改配置
联网通信
注入其他进程
隐藏自身

区别在于:

正常软件:按用户意图使用 OS API
恶意软件:违背用户意图滥用 OS API

所以理解 OS API,不只是为了写正常程序,也能帮助我们理解系统安全。


十七、应用程序的共同结构

现在我们可以把应用程序的共同结构画出来:

ELF 可执行文件
      │
      │ execve
      ▼
进程初始状态
      │
      ▼
普通指令执行
      │
      ├── 普通计算
      │
      ├── read / write
      │
      ├── open / close
      │
      ├── mmap / brk
      │
      ├── fork / execve
      │
      └── exit / exit_group
      │
      ▼
进程结束

换一种角度:

应用程序
┌──────────────────────────┐
│ 普通计算                  │
│ 算法 / 数据结构 / 状态更新 │
├──────────────────────────┤
│ 操作系统 API 调用          │
│ 文件 / 进程 / 网络 / 内存   │
└──────────────────────────┘
             │
             ▼
          操作系统
             │
             ▼
            硬件

这个模型非常重要。

因为以后再看到复杂程序时,就可以先问:

它内部做了哪些普通计算?
它向操作系统请求了哪些服务?

只要抓住这两个问题,很多程序就没有那么神秘了。


十八、这一讲和上一篇的关系

上一篇的核心问题是:

为什么需要操作系统?

答案是:

复杂系统需要抽象层。

这一篇的核心问题是:

应用程序怎么使用操作系统?

答案是:

通过系统调用和操作系统 API。

两讲合起来,可以得到一条很清楚的主线:

第一讲:
操作系统为什么出现?
因为硬件、程序、用户、资源共享越来越复杂。

第二讲:
应用程序如何站在操作系统上运行?
它作为一个状态机,通过 syscall 请求 OS 服务。

十九、全文总结

19.1 程序是状态机

程序不是静态代码,而是运行时不断变化的状态。

状态 -> 执行一步 -> 新状态

19.2 C 函数不是数学函数

C 函数会创建栈帧,会产生副作用,会改变程序状态。

19.3 编译器是在翻译状态机

编译器把 C 程序翻译成机器指令程序,但要保持可观察行为一致。

19.4 syscall 是应用和 OS 的边界

普通计算在程序内部完成;
访问外部资源必须通过操作系统。

19.5 strace 是观察程序行为的显微镜

想知道程序和 OS 说了什么,可以用:

strace ./program

它能帮助我们看见程序背后的系统调用。


二十、结语:应用程序没那么神秘

浏览器、游戏、编辑器、编译器、任务管理器、杀毒软件、病毒、AI Agent,看起来千差万别。

但从应用视角看,它们都可以被还原成:

状态机 + 系统调用

或者更直接地说:

普通计算 + OS API

这就是“应用视角的操作系统”的核心价值。

它不是一上来就逼你钻进内核,而是先让你明白:

  • 程序如何开始?
  • 程序如何执行?
  • 程序如何请求系统服务?
  • 程序如何访问文件、设备和网络?
  • 程序如何退出?
  • 程序如何被工具观察?

当这些问题想清楚以后,再回头看进程、内存、文件系统、调度,就会自然很多。

因为你已经知道:

操作系统不是抽象名词,它就在每一个正在运行的程序背后。


参考资料

  1. 南京大学 2026 春《操作系统》第二讲:《应用视角的操作系统》
    https://jyywiki.cn/OS/2026/lect2.md

  2. OSTEP: Interlude - Process API
    https://pages.cs.wisc.edu/~remzi/OSTEP/cpu-api.pdf

  3. OSTEP: Operating Systems: Three Easy Pieces
    https://pages.cs.wisc.edu/~remzi/OSTEP/

  4. Linux man-pages: syscalls(2)
    https://man7.org/linux/man-pages/man2/syscalls.2.html

  5. Linux man-pages: strace(1)
    https://man7.org/linux/man-pages/man1/strace.1.html

  6. Linux man-pages: elf(5)
    https://man7.org/linux/man-pages/man5/elf.5.html

  7. Understanding system calls on Linux with strace
    https://opensource.com/article/19/10/strace

  8. University of Waterloo CS350 Operating Systems Slides: System Calls
    https://rcs.uwaterloo.ca/~ali/cs350-f19/processes3.pdf

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐