南京大学 操作系统 (JYY) 学习笔记:Hello, OS World! (程序的机器视角)

发布时间:2026/7/25 20:29:26
南京大学 操作系统 (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, PC0)。全局变量全部被赋予初始值。操作系统最大的职责就是让我们在编程时感受不到操作系统的存在。我们在编程时想象程序“独占整个计算机逐条指令执行”。当系统调用Syscall发生时程序执行被完全暂停操作系统接管控制权——这就像做手术被全麻醒来后周围的环境变了但你完全感受不到时间的流逝。操作系统上的最小程序汉诺塔 (Hanoi) 是 C 课的噩梦它是一个典型的递归状态机。理解程序递归 vs 非递归从状态机视角看汉诺塔程序的主要逻辑是一个循环用于计算某个数n经过一系列操作后变为1的步数。每次调用都会生成一个新的栈帧状态。当条件满足时销毁栈帧返回上一个状态。汉诺塔执行过程的输出rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/hanoi# ./hanoi-rA -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中一个运行中的程序或者说整个计算机的状态仅仅只是几个变量的集合structCPUState{// 寄存器状态uint32_tregs[32],csrs[CSR_COUNT];// 内存状态uint8_t*mem;uint32_tmem_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: 0syscall);}编译命令gcc-nostdlib-Os-ominimal 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验明正身rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# file a.outa.out: ELF64-bit LSB pie executable, x86-64, version1(SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]2cd6cd98a1bb27d63fdde4ae355c361810ee8137,forGNU/Linux3.2.0, not strippedELF:表示这是一个 Linux 下的可执行和可链接格式。64-bit LSB:64位小端序。dynamically linked:动态链接。not stripped:没有被剥离调试信息。readelf解剖 ELF 头部结构rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# readelf -h a.outELF Header: Magic: 7f454c4602 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data:2s complement, little endian... Entry point address: 0x1080可以清晰地看到程序的入口点内存地址Entry point address。objdump反汇编大师rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# objdump -d a.outa.out:fileformatelf64-x86-64 Disassembly of section .init: 0000000000001000_init:1000: f3 0f 1e fa endbr641004:4883ec 08 sub$0x8,%rsp...直接把二进制机器码翻译成我们可以阅读的汇编指令。nm查看符号表rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/test_out# nm a.out0000000000003da8 d _DYNAMIC 0000000000004040 B _ZSt4coutGLIBCXX_3.4...d表示局部变量数据U表示未定义的引用需要外部库。strings提取全部明文字符串rootLAPTOP-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 系统编程的终极神器。它能拦截并打印出一个程序在运行期间调用的所有系统调用。rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/strace# ./minimalHello, World!rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec2/strace# strace ./minimalexecve(./minimal,[./minimal], 0x7ffe636aaf40 /*33vars */)0write(1,Hello, World!, 13Hello, World!)13exit(0)? exited with0在strace的上帝视角下你写的代码全成了浮云。你清晰地看到了底层的本质execve: 操作系统分配资源加载程序。write: 向文件描述符1(标准输出) 写入字节。exit: 退出程序。Computer Science 是一门没有门槛、可以无限复制的“人造科学”。在 AI 的辅助下只要你掌握了strace、gdb这种底层观测工具你与“顶尖黑客”的差距可以无限缩小课后作业大赏大模型的“翻车”现场课后作业有一道非常有意思的题将下面的互调递归函数f和g转换为非递归形式。intf(intn){return(n1)?1:f(n-1)g(n-2);}intg(intn){return(n1)?1:f(n1)g(n-1);}方法一手动维护状态栈 (正确但不够优雅)任何递归的本质都是栈Stack。我们可以自己开辟一个std::stack来模拟汇编层面的“压栈”和“出栈”行为#includeiostream#includestackusingnamespacestd;intf(intn){stackpairint,chars;// 用于模拟递归调用的栈s.push({n,f});// 初始调用 f(n)intresult0;while(!s.empty()){auto[current_n,func]s.top();s.pop();if(current_n1){result1;// 基本情况直接返回 1}else{if(funcf){s.push({current_n-1,f});// 模拟 f(n-1)s.push({current_n-2,g});// 模拟 g(n-2)}elseif(funcg){s.push({current_n1,f});// 模拟 f(n1)s.push({current_n-1,g});// 模拟 g(n-1)}}}returnresult;}评价这个解法完全正确。它把控制流转化为了数据流规避了函数调用的开销和 Stack Overflow 的风险。但有没有更偷懒的“动态规划迭代”解法呢方法二Kimi 给出的迭代解法 (AI 严重翻车 )当我追问 Kimi 能不能用迭代动态规划来做时它给出了以下代码// Kimi 给出的动态规划迭代代码intf(intn){if(n1)return1;vectorintf_values(n1,0);vectorintg_values(n1,0);// 初始化基本情况f_values[0]1;f_values[1]1;g_values[0]1;g_values[1]1;// 通过迭代计算 f(i) 和 g(i)for(inti2;in;i){f_values[i]f_values[i-1]g_values[i-2];g_values[i]f_values[i1]g_values[i-1];// ⚠️ 致命 Bug 在这里}returnf_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 就会将你吞噬。把程序看作严谨的数学状态机亲自推导依赖图才是操作系统课教会我们最宝贵的素养。

相关新闻

最新新闻

日新闻

周新闻

月新闻