南京大学 操作系统 (JYY) 学习笔记:Hello, OS World! (程序的机器视角)
写在前面:这是本系列的第二篇。
在导论中,我们了解了操作系统的历史。今天,我们将戴上极客的透视眼镜,抛开高级语言的滤镜,直视计算机最真实的本质:程序到底是什么?
从一个连
main函数都没有的最小程序,到剖析a.out二进制文件,再到拆解 C 语言的状态机模型。欢迎来到黑客帝国(The Matrix)的真实世界。

Hello, OS World! (操作系统 -> 程序)
计算机程序与无情的机器
计算机:无情地执行指令的机器。
- 机器永远是对的。
- 如果编译器没有开启优化,“我们写什么,机器就无脑地执行什么”。
- 遇到 Bug 时,永远不要怀疑机器,先怀疑自己的代码。
思考: 在 release 版本中,编译器会对代码做哪些优化?(可以通过生成汇编
assembly来观察)
编译器常见的四大优化魔法:
- 函数内联 (Inline): 当一个函数被频繁调用时,编译器会将该函数的代码直接插入到调用它的地方,消除函数调用指令(压栈/出栈)的开销。
- 循环展开 (Loop Unrolling): 循环的执行需要消耗时间来判断条件、更新变量。编译器会把循环体复制多份,减少判断次数。
- 常量传播 (Constant Propagation): 如果代码中有一些变量被赋了常量值,编译器会在编译期直接把它们算出结果,并替换到其他部分。
- 死代码删除 (Dead Code Elimination): 那些永远不会被执行的代码(比如
if(0)里面的代码)会被无情抹除。
程序就是状态机 (State Machine)
在 C 语言中,程序实际上就是一个状态机。
在某个状态下,GDB 可以帮你打印出当前所有变量的值。CPU 每执行一条汇编指令,都会改变程序的状态,推动状态机向下一个状态演进。
- 解释型语言和编译型语言在现代体系中,其实并没有绝对的边界。
C 语言的状态机模型包含两部分:
- StackFrame (栈帧):
[StackFrame, StackFrame, ...]记录了函数调用链和局部变量。 - 全局变量: 存放整个生命周期共享的数据。
初始状态:
- 仅有一个最基础的
StackFrame(main, argc, argv, PC=0)。 - 全局变量全部被赋予初始值。
操作系统最大的职责,就是让我们在编程时感受不到操作系统的存在。
我们在编程时想象程序“独占整个计算机,逐条指令执行”。当系统调用(Syscall)发生时,程序执行被完全暂停,操作系统接管控制权——这就像做手术被全麻,醒来后周围的环境变了,但你完全感受不到时间的流逝。
操作系统上的最小程序
汉诺塔 (Hanoi) 是 C++ 课的噩梦,它是一个典型的递归状态机。
理解程序:递归 vs 非递归
从状态机视角看汉诺塔,程序的主要逻辑是一个循环,用于计算某个数 n 经过一系列操作后变为 1 的步数。
- 每次调用都会生成一个新的栈帧(状态)。
- 当条件满足时,销毁栈帧,返回上一个状态。
汉诺塔执行过程的输出:
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/hanoi# ./hanoi-r
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
A -> B
C -> B
C -> A
B -> A
C -> B
A -> C
A -> B
C -> B
Hanoi(4, A, B, C) = 15
什么是程序?(底层数据结构)
在模拟器(如 NEMU)中,一个运行中的程序(或者说整个计算机的状态)仅仅只是几个变量的集合:
struct CPUState {
// 寄存器状态
uint32_t regs[32], csrs[CSR_COUNT];
// 内存状态
uint8_t *mem;
uint32_t mem_offset, mem_size;
};
空的 main() 是最小的程序吗?
实际上,程序的真正入口根本不是 main(),而是 _start。
就算你写了一个空的 main() 函数,编译器默认也会为你链接进庞大的 glibc 运行时库(用于准备 argc/argv、初始化垃圾回收等)。
如何实现一个真正意义上的最小 C 程序?
AI Prompt: 我即便写一个空的 main(),链接后依然生成了很大的代码。怎么才能实现最小的 C 程序呢?
我们要抛弃标准库,直接与操作系统对话:
- 使用最小运行时: 加上
-nostdlib编译选项,拒绝链接标准库。 - 手动定义入口点: 直接编写
_start函数。 - 手动系统调用: 因为没有了
exit()函数,我们需要直接用汇编触发syscall来告诉操作系统“我结束了”。
void _start() {
__asm__("mov $60, %eax\n" // syscall: exit (60 是 exit 的系统调用号)
"xor %edi, %edi\n" // status: 0
"syscall");
}
编译命令:
gcc -nostdlib -Os -o minimal minimal.c
知识点小结:
- 在 Linux 系统中,程序的入口点是
_start而不是main。 - 使用
-nostdlib后,你不仅不能用printf,连main都不认识,必须自己用汇编管理系统调用 (syscall) 的触发。
ABI (应用程序二进制接口)
系统调用到底长什么样?寄存器该怎么传参数?这些规定被称为 ABI (Application Binary Interface)。
平时说的“编译”其实包含了三个步骤:
- 编译 (Compile): C 代码 -> 汇编代码 (
.s) - 汇编 (Assemble): 汇编代码 -> 目标机器码 (
.o) - 链接 (Link): 将多个目标文件缝合成可执行文件 (
.out/.exe)
你可以通过 Linux 的 man 手册来查看系统调用 ABI,例如:man 2 syscalls。
探索操作系统中的可执行文件
在命令行里,一个看似普通的二进制文件,其实蕴藏着巨大的信息量。我们需要掌握一套强大的 GNU Binutils 探壳工具:
AI Prompt: 我有一个 a.out 文件,如何探索它里面有什么?
file:验明正身
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# file a.out
a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2cd6cd98a1bb27d63fdde4ae355c361810ee8137, for GNU/Linux 3.2.0, not stripped
- ELF: 表示这是一个 Linux 下的可执行和可链接格式。
- 64-bit LSB: 64位小端序。
- dynamically linked: 动态链接。
- not stripped: 没有被剥离调试信息。
readelf:解剖 ELF 头部结构
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
...
Entry point address: 0x1080
- 可以清晰地看到程序的入口点内存地址
Entry point address。
objdump:反汇编大师
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# objdump -d a.out
a.out: file format elf64-x86-64
Disassembly of section .init:
0000000000001000 <_init>:
1000: f3 0f 1e fa endbr64
1004: 48 83 ec 08 sub $0x8,%rsp
...
- 直接把二进制机器码翻译成我们可以阅读的汇编指令。
nm:查看符号表
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# nm a.out
0000000000003da8 d _DYNAMIC
0000000000004040 B _ZSt4cout@GLIBCXX_3.4
...
d表示局部变量数据,U表示未定义的引用(需要外部库)。
strings:提取全部明文字符串
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# strings a.out
/lib64/ld-linux-x86-64.so.2
Hello, World!
- 它可以暴力扫出文件里的所有可打印字符串,常用于黑客逆向分析。
动态观测:Trace (追踪) 的艺术
静态看完了,我们要看程序跑起来的样子。
strace (System call trace) 是 Linux 系统编程的终极神器。它能拦截并打印出一个程序在运行期间调用的所有系统调用。
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/strace# ./minimal
Hello, World!
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/strace# strace ./minimal
execve("./minimal", ["./minimal"], 0x7ffe636aaf40 /* 33 vars */) = 0
write(1, "Hello, World!", 13Hello, World!) = 13
exit(0) = ?
+++ exited with 0 +++
在 strace 的上帝视角下,你写的代码全成了浮云。你清晰地看到了底层的本质:
execve: 操作系统分配资源,加载程序。write: 向文件描述符1(标准输出) 写入字节。exit: 退出程序。
Computer Science 是一门没有门槛、可以无限复制的“人造科学”。 在 AI 的辅助下,只要你掌握了 strace、gdb 这种底层观测工具,你与“顶尖黑客”的差距可以无限缩小!
课后作业大赏:大模型的“翻车”现场
课后作业有一道非常有意思的题:
将下面的互调递归函数 f 和 g 转换为非递归形式。
int f(int n) { return (n <= 1) ? 1 : f(n - 1) + g(n - 2); }
int g(int n) { return (n <= 1) ? 1 : f(n + 1) + g(n - 1); }
方法一:手动维护状态栈 (正确但不够优雅)
任何递归的本质都是栈(Stack)。我们可以自己开辟一个 std::stack 来模拟汇编层面的“压栈”和“出栈”行为:
#include <iostream>
#include <stack>
using namespace std;
int f(int n) {
stack<pair<int, char>> s; // 用于模拟递归调用的栈
s.push({n, 'f'}); // 初始调用 f(n)
int result = 0;
while (!s.empty()) {
auto [current_n, func] = s.top();
s.pop();
if (current_n <= 1) {
result += 1; // 基本情况,直接返回 1
} else {
if (func == 'f') {
s.push({current_n - 1, 'f'}); // 模拟 f(n-1)
s.push({current_n - 2, 'g'}); // 模拟 g(n-2)
} else if (func == 'g') {
s.push({current_n + 1, 'f'}); // 模拟 f(n+1)
s.push({current_n - 1, 'g'}); // 模拟 g(n-1)
}
}
}
return result;
}
评价: 这个解法完全正确。它把控制流转化为了数据流,规避了函数调用的开销和 Stack Overflow 的风险。但有没有更偷懒的“动态规划(迭代)”解法呢?
方法二:Kimi 给出的迭代解法 (AI 严重翻车 🚨)
当我追问 Kimi 能不能用迭代(动态规划)来做时,它给出了以下代码:
// Kimi 给出的动态规划迭代代码
int f(int n) {
if (n <= 1) return 1;
vector<int> f_values(n + 1, 0);
vector<int> g_values(n + 1, 0);
// 初始化基本情况
f_values[0] = 1; f_values[1] = 1;
g_values[0] = 1; g_values[1] = 1;
// 通过迭代计算 f(i) 和 g(i)
for (int i = 2; i <= n; ++i) {
f_values[i] = f_values[i - 1] + g_values[i - 2];
g_values[i] = f_values[i + 1] + g_values[i - 1]; // ⚠️ 致命 Bug 在这里!
}
return f_values[n];
}
这是一次非常典型的 AI 逻辑幻觉!
仔细看 for 循环的这行代码:g_values[i] = f_values[i + 1] + g_values[i - 1];
由于是从小到大正向遍历 i,当你计算 g_values[i] 的时候,f_values[i + 1]** 根本还没有被计算出来!** 它此时依然是初始化的 0。所以,这个迭代跑出来的结果绝对是错误的。
JYY 语录再次应验:机器永远是对的,AI 只是一种辅助。
当你不动脑子完全轻信 AI 时,隐蔽的 Bug 就会将你吞噬。把程序看作严谨的数学状态机,亲自推导依赖图,才是操作系统课教会我们最宝贵的素养。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)