写在前面:这是本系列的第二篇。

在导论中,我们了解了操作系统的历史。今天,我们将戴上极客的透视眼镜,抛开高级语言的滤镜,直视计算机最真实的本质:程序到底是什么?

从一个连 main 函数都没有的最小程序,到剖析 a.out 二进制文件,再到拆解 C 语言的状态机模型。欢迎来到黑客帝国(The Matrix)的真实世界。

在这里插入图片描述

Hello, OS World! (操作系统 -> 程序)

计算机程序与无情的机器

计算机:无情地执行指令的机器。

  • 机器永远是对的。
  • 如果编译器没有开启优化,“我们写什么,机器就无脑地执行什么”。
  • 遇到 Bug 时,永远不要怀疑机器,先怀疑自己的代码。

思考: 在 release 版本中,编译器会对代码做哪些优化?(可以通过生成汇编 assembly 来观察)

编译器常见的四大优化魔法:

  1. 函数内联 (Inline): 当一个函数被频繁调用时,编译器会将该函数的代码直接插入到调用它的地方,消除函数调用指令(压栈/出栈)的开销。
  2. 循环展开 (Loop Unrolling): 循环的执行需要消耗时间来判断条件、更新变量。编译器会把循环体复制多份,减少判断次数。
  3. 常量传播 (Constant Propagation): 如果代码中有一些变量被赋了常量值,编译器会在编译期直接把它们算出结果,并替换到其他部分。
  4. 死代码删除 (Dead Code Elimination): 那些永远不会被执行的代码(比如 if(0) 里面的代码)会被无情抹除。

程序就是状态机 (State Machine)

在 C 语言中,程序实际上就是一个状态机

在某个状态下,GDB 可以帮你打印出当前所有变量的值。CPU 每执行一条汇编指令,都会改变程序的状态,推动状态机向下一个状态演进。

  • 解释型语言和编译型语言在现代体系中,其实并没有绝对的边界。

C 语言的状态机模型包含两部分:

  1. StackFrame (栈帧): [StackFrame, StackFrame, ...] 记录了函数调用链和局部变量。
  2. 全局变量: 存放整个生命周期共享的数据。

初始状态:

  • 仅有一个最基础的 StackFrame(main, argc, argv, PC=0)
  • 全局变量全部被赋予初始值。

操作系统最大的职责,就是让我们在编程时感受不到操作系统的存在

我们在编程时想象程序“独占整个计算机,逐条指令执行”。当系统调用(Syscall)发生时,程序执行被完全暂停,操作系统接管控制权——这就像做手术被全麻,醒来后周围的环境变了,但你完全感受不到时间的流逝。


操作系统上的最小程序

汉诺塔 (Hanoi) 是 C++ 课的噩梦,它是一个典型的递归状态机。

理解程序:递归 vs 非递归

从状态机视角看汉诺塔,程序的主要逻辑是一个循环,用于计算某个数 n 经过一系列操作后变为 1 的步数。

  1. 每次调用都会生成一个新的栈帧(状态)。
  2. 当条件满足时,销毁栈帧,返回上一个状态。

汉诺塔执行过程的输出:

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 程序呢?

我们要抛弃标准库,直接与操作系统对话:

  1. 使用最小运行时: 加上 -nostdlib 编译选项,拒绝链接标准库。
  2. 手动定义入口点: 直接编写 _start 函数。
  3. 手动系统调用: 因为没有了 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 的辅助下,只要你掌握了 stracegdb 这种底层观测工具,你与“顶尖黑客”的差距可以无限缩小!


课后作业大赏:大模型的“翻车”现场

课后作业有一道非常有意思的题:
将下面的互调递归函数 fg 转换为非递归形式。

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 就会将你吞噬。把程序看作严谨的数学状态机,亲自推导依赖图,才是操作系统课教会我们最宝贵的素养。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐