南京大学 操作系统 (JYY) 学习笔记:可执行文件、链接器与 Shebang 的彩蛋

📅 2026/7/29 0:41:33 👁️ 阅读次数 📝 编程学习
南京大学 操作系统 (JYY) 学习笔记:可执行文件、链接器与 Shebang 的彩蛋

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

我们已经知道,进程从execve后的初始状态开始,可以通过mmap改变自己的地址空间,通过fork创建新的进程。有了系统调用和libc,我们真的可以实现“任何程序”了。

但在此之前,我们一直默认了编译器工具链(gcc/ld)可以奇迹般地帮我们实现从高级语言到可执行文件的翻译。今天,是时候“打开”这个黑盒了:什么是可执行文件?静态链接到底做了什么?以及,大名鼎鼎的#!(Shebang) 究竟藏着怎样的内核魔法?

课前小插曲:Testkit 的修复与反思

关于之前的 Testkit,如果工程实现里有多个.c文件,执行顺序和快照机制往往让人摸不着头脑。(没听懂没关系,以后有机会让豆包/Kimi 详细教一下怎么用)。

核心回顾:操作系统的对象

  • 进程:状态机。管理 API:fork,execve,exit
  • 连续的内存段:可以被看作一个能在进程间共享、或映射到文件的对象。管理 API:mmap,munmap,mprotect

什么是可执行文件?

  • 学习操作系统前:那个鼠标“双击可以弹出窗口的东西”。
  • 学习操作系统后:
    • 它是一个操作系统中的对象(文件)。
    • 它是一个字节序列(可以用十六进制编辑器直接编辑)。
    • 它是一个描述了状态机初始状态的数据结构(听起来开始让人头秃了)。

ELF (Executable and Linkable Format)

在《计算机系统基础》中,我们已经接触过 ELF(可执行和可链接格式)。这是一种标准的文件格式,用于存储可执行文件、目标文件和共享库。

  • 我们常用binutils中的工具(如readelfobjdump)来查看其中的信息。
  • 如果想用更现代、更友好的工具,可以尝试elfcat

无论工具怎么变,可执行文件在底层永远只是一段字节序列


可执行文件:进程初始状态的描述

根据 AMD64 架构的圣经《System V ABI》规范,操作系统只规定了部分寄存器和栈的初始状态,而其他的状态(主要是内存布局)全权由可执行文件来指定。

一个合格的可执行文件需要包含什么?(假设没有动态链接)

  • 基本信息:版本、体系结构。
  • 内存布局:哪些段放代码(.text),哪些段放数据(.data,.bss)。
  • 其他信息:调试信息、符号表等。

极简主义:可执行文件其实不需要那么复杂

只要满足最基本的要求,我们甚至可以手捏一个最小的可执行文件:

  • 一个合法的头(Magic Number:7f 45 4c 46,也就是\x7fELF)。
  • 一段蹦床代码(Trampoline Code)。
# 最小的 ELF 实验,用 Python 打印字节流即可生成 a.outhex_data=''' 457f 464c 0102 0001 0000 0000 0000 0000 0002 003e 0001 0000 0078 0040 0000 0000 0040 0000 0000 0000 0000 0000 0000 0000 0000 0000 0040 0038 0001 0000 0000 0000 0001 0000 0007 0000 0078 0000 0000 0000 0078 0040 0000 0000 0000 0000 0000 0000 0007 0000 0000 0000 0007 0000 0000 0000 1000 0000 0000 0000 3c6a 3158 0fff 0005 '''

strace运行它,你会发现它确实能被execve成功加载,然后下一秒直接exit(0)

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/elf-play# strace ./a.outexecve("./a.out",["./a.out"], 0x7fff138033d0 /*36vars */)=0exit(0)=? +++ exited with0+++

和 ELF 搏斗的每一年

ELF 是一个经典且极其稳固的设计。
E = Executable,L = Linkable。它统一了工具链,甚至连 Core Dump(核心转储)用的也是 ELF 格式。

但是,它也是折磨历届 CS 学生的元凶!

  • 同学 A:期末复习看吐了。
  • 老师 A:第一次打开 PPT 真的有点吐血的感觉。
  • 老师 C (JYY 自己):太好了,以后再也不用教了!

为什么 ELF 这么难懂?

反思一下:ELF 根本不是一个“人类友好”的数据结构描述。
为什么不用 JSON?因为为了极致的加载性能和空间利用率,ELF 彻底违背了可读性(信息局部性)原则。

它到处都是 offset(偏移量),几乎是让你直接去大脑里人肉解析一棵内存里的树!当你听到“程序头表”、“节头表”,看到隐晦的R_X86_64_32标记和无尽的指针时,人类的大脑是抗拒的。

顺带一提:Core Dump 的本质

  • Segmentation fault (core dumped)—— 为什么叫“核心转储”?
  • 地狱笑话:今天的 core dump 就是一个 ELF 文件。
  • 它的本质是操作系统在程序崩溃的瞬间,拍下的一张“状态快照” (内存 + 寄存器)。Everything is a state machine!
  • 如果配合ulimit -c unlimited生成 Core Dump,再用 GDB 挂载,你可以精确查看到程序死前的遗言。
  • 想要不仅能看,还能让状态机“后退”?可以使用CRIU工具。在智能 Agent 时代,这类工具可以完美实现复杂应用状态的冻结与恢复。

重新设计:Funny Little Executable (FLE)

既然 ELF 不当人,那我们就自己设计一个对人类直接可读的可执行文件格式:FLE。

  • 核心设计思路:越“平坦”,越容易理解。所有需要的信息都立即可见,不要用指针跳来跳去。
  • 回归链接和加载的核心概念:代码、符号、重定位

FLE 的三要素

  • 代码 (emoji1)
  • 符号 (emoji2)
  • 重定位 (emoji3)

凑齐这三要素,我们就可以完成链接到加载的全流程!这就是 DSL (Domain Specific Language,领域特定语言) 的魅力。做了减法,去掉了不必要的复杂特性,反而能让人更专注底层的本质。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# make# 编译阶段:将 .c 编译成自定义的 .fle (目标文件)./cc-Wall-g-Osfoo.c-ofoo.o...# 链接阶段:自定义的 ld 将多个 .fle 合并成最终的 hello 可执行文件./ld foo.fle libc.fle main.fle-ohello root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# ./helloMessage: Hello World!... root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# echo $?42

从源码到加载:全流程拆解

1. 预编译 -> 编译 (.c->.s)

  • 预处理:处理#include(其实就是无情的 Ctrl-C & Ctrl-V) 和宏替换。
  • 编译:将“高级状态机” (C语言) 翻译成“低级状态机” (汇编),最终生成带标注的指令序列。

2. 汇编 (.s->.o)

  • 将汇编指令打包成 sections (.text,.data,.bss等)。
  • 为每个 section 记录核心三要素:代码(字节序列)、符号(标记当前位置)、重定位(暂时不能确定的地址,留给链接器填空)。

3. 静态链接 (.o->a.out)

  • 合并所有目标文件的 sections。把代码“平铺”成一整块字节序列。
  • 确定所有全局符号的绝对物理地址。
  • 解析全部重定位(填空),最终得到一个可以直接运行的可执行文件

4. 加载 (Loader)

  • 把生成的“字节序列”用mmap搬到内存。
  • 设置好寄存器和 PC,开始运行!
# 极简版加载器伪代码mem=mmap.mmap(fileno=-1,length=len(bs),prot=mmap.PROT_READ|mmap.PROT_WRITE|mmap.PROT_EXEC,flags=mmap.MAP_PRIVATE|mmap.MAP_ANONYMOUS,)mem.write(bs)mem.flush()call_pointer(mem,fle['symbols']['_start'])

操作系统内核与 Shebang (#!) 的浪漫彩蛋

加载器其实是操作系统内核实现的一部分。当你调用execve(path, argv, envp)时,内核(Linux 中的binfmt_elf.c)会亲自解析可执行文件,扫描PT_LOAD段并完成内存映射。

但是,等等!运行程序的两种方法:

  • /bin/ls(这是一个标准的 ELF 二进制文件)
  • ./a.py(这明明是个纯文本啊!操作系统怎么懂 Python?)

Shebang 机制的内核魔术

这是 UNIX 给程序员留下的最浪漫的彩蛋:#注释的“妙用”

如果一个文本文件的第一行是:#!A B C
操作系统在execve尝试加载它时,会发现开头的 Magic Number 不是 ELF 的\x7fELF,而是#!(十六进制为0x23 0x21)。

此时,内核(binfmt_script.c)会立刻心领神会,把执行命令偷偷狸猫换太子,转换为:
execve(A, ["A", "B C", "any_file"], envp)

/* Linux 内核源码片段:检查是否以 "#!" 开头 */if((bprm->buf[0]!='#')||(bprm->buf[1]!='!'))return-ENOEXEC;...file=open_exec(i_name);

这就是为什么你在 Python 脚本的第一行写上#!/usr/bin/env python3后,这个普通的文本文件就拥有了像二进制程序一样直接运行的魔法。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/shebang# ./goodargv[0]=A argv[1]=B C argv[2]=./good root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/shebang# ./bad-bash: ./bad:..: bad interpreter: Permission denied

计算机系统里没有魔法,所有的神奇现象背后,都是一行行清晰可查的 C 语言代码!