从0开始的操作系统学习之路(2)
上一篇我们从“AI 时代为什么还要学操作系统”开始,看到了操作系统的来路:从 ENIAC、Fortran、CTSS 到 Unix,操作系统不是人拍脑袋发明出来的,而是复杂度一步步“逼出来”的。不要先站在内核视角看操作系统,而是先站在应用程序视角看操作系统。内核怎么调度?页表怎么映射?文件系统怎么实现?中断怎么切换?我写的程序,到底是怎么在操作系统上跑起来的?程序 = 状态机;应用程序 = 计算 +
应用视角的操作系统:程序到底是什么?——从状态机、syscall 到 strace 看懂应用如何运行
目录
- 应用视角的操作系统:程序到底是什么?——从状态机、syscall 到 strace 看懂应用如何运行
-
- 一、前言
- 二、程序不是一坨代码,而是一个正在运行的状态机
- 三、什么是状态机?
- 四、C 函数不是数学函数:它会改变世界
- 五、编译器做了什么:把一种状态机翻译成另一种状态机
- 六、普通计算和外部世界之间,有一道边界
- 七、系统调用 syscall:应用程序向 OS 求助的入口
- 八、为什么说 syscall 像“全身麻醉”?
- 九、程序为什么不能自己真正退出?
- 十、ELF:可执行文件不是乱码,而是程序初始状态说明书
- 十一、从 _start 到 main:main 并不是最开始的地方
- 十二、strace:给程序装一个“系统调用摄像头”
- 十三、用 strace 看 Hello World
- 十四、strace 它是调试工具
- 十五、任何复杂应用,本质上都离不开 syscall
- 十六、任务管理器、杀毒软件、病毒,也都是 OS API 的组合
- 十七、应用程序的共同结构
- 十八、这一讲和上一篇的关系
- 十九、全文总结
- 二十、结语:应用程序没那么神秘
- 参考资料
往期回顾
《指针合集》《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
这就是“应用视角的操作系统”的核心价值。
它不是一上来就逼你钻进内核,而是先让你明白:
- 程序如何开始?
- 程序如何执行?
- 程序如何请求系统服务?
- 程序如何访问文件、设备和网络?
- 程序如何退出?
- 程序如何被工具观察?
当这些问题想清楚以后,再回头看进程、内存、文件系统、调度,就会自然很多。
因为你已经知道:
操作系统不是抽象名词,它就在每一个正在运行的程序背后。
参考资料
-
南京大学 2026 春《操作系统》第二讲:《应用视角的操作系统》
https://jyywiki.cn/OS/2026/lect2.md -
OSTEP: Interlude - Process API
https://pages.cs.wisc.edu/~remzi/OSTEP/cpu-api.pdf -
OSTEP: Operating Systems: Three Easy Pieces
https://pages.cs.wisc.edu/~remzi/OSTEP/ -
Linux man-pages: syscalls(2)
https://man7.org/linux/man-pages/man2/syscalls.2.html -
Linux man-pages: strace(1)
https://man7.org/linux/man-pages/man1/strace.1.html -
Linux man-pages: elf(5)
https://man7.org/linux/man-pages/man5/elf.5.html -
Understanding system calls on Linux with strace
https://opensource.com/article/19/10/strace -
University of Waterloo CS350 Operating Systems Slides: System Calls
https://rcs.uwaterloo.ca/~ali/cs350-f19/processes3.pdf
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)