10 - Executable files

10 - 可执行文件

有了系统调用和 libc,我们就真的可以实现 “任何程序” 了——例如,你可以想一想,如果要实现一个 MicroPython 解释器,我们需要实现什么,又需要借助哪些系统调用?当然,我们需要编译器帮我们编译 python.c 到可执行文件——我们一直以来都 “默认” 了编译器工具链可以帮助我们实现高级语言到可执行文件的翻译。今天是时候 “打开” 这部分内容了。

操作系统的核心我们已经理解得差不多了...

本讲内容:(静态链接) 可执行文件的概念和基本原理;我们如何自己动手构建一个可执行文件格式。

1. 可执行文件

1.1. 什么是可执行文件

学习操作系统前

  • 那个 “双击可以弹出窗口的东西”

...

学习操作系统后

  • 一个操作系统中的对象 (文件)

  • 一个字节序列 (我们可以把它当字符串编辑)

  • 一个描述了状态机初始状态的数据结构 (打扰了)

1.2. ELF: Executable and Linkable Format

ELF 是“Executable and Linkable Format(可执行和可链接格式)”的缩写。它是一种标准的文件格式,主要用于存储可执行文件、目标文件(如编译后的中间文件)和共享库等。

我们在《计算机系统基础》中学到的

  • binutils 中的工具可以让我们查看其中的重要信息

    • 《计算机系统基础》常备工具

      • readelf & objdump

    • binutils 里原来还有不少宝藏!

我们在《计算机系统基础》里没学到的

  • 如果我们想了解关于 ELF 文件的信息,有什么不那么 “原始” 的现代工具可用?

    • 哦,有 elfcat,以及更多

1.3. 可执行文件:字节序列

无论如何,可执行文件都是字节序列

我们能不能得到一个最小的可执行文件 a.out

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

回顾:System V ABI

  • Section 3.4: “Process Initialization”

    • 只规定了部分寄存器和栈

    • 其他状态 (主要是内存) 由可执行文件指定

这份文档是关于AMD64架构的System V应用二进制接口(ABI)的详细规范。简单来说,它定义了在AMD64架构(也就是64位x86架构)上,程序如何运行、如何与操作系统交互,以及如何在不同程序之间共享数据。

“可执行文件” 需要包含什么?

  • (今天先假设不依赖 “动态链接库”)

  • 基本信息 (版本、体系结构……)

  • 内存布局 (哪些部分是什么数据)

  • 其他 (调试信息、符号表……)

1.5. 可执行文件其实不需要那么复杂

只需要

  • 一个头 (类似 7f 45 4c 46; 这是一个 jg 71)

  • 一段 Trampoline Code (PIC)

    • 操作系统会给一个文件描述符 (指向文件本身)

    • 这段代码直接执行 hardcoded mmap

  • (就能实现 “加载” 的功能)

剩下的信息都可以用更友好的方式表示

  • 反正是数据结构,一个 json

    • 符号表,调试信息,……

  • 最小的 ELF 实验...

  • 确实可以execve 但是 下一步就是 exit...

hex_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
'''
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/elf-play# strace ./a.out
execve("./a.out", ["./a.out"], 0x7fff138033d0 /* 36 vars */) = 0
exit(0)                                 = ?
+++ exited with 0 +++

2. 和 ELF 的搏斗的每一年

2.1. ELF 不仅仅是可执行文件

一个经典、没有任何问题的设计

  • E = Executable

  • L = Linkable

    • 一个标准,管好所有相关工具链

    • 甚至还有 Core Dump

但是

  • ……

2.2. 搏斗!

你期末复习的时候吐血了吗?

  • 同学 A: 吐了

  • 同学 B: 吐了

    • “连 CSAPP 这一章都讲得不怎么样”

不只是你们,我们也在吐血

  • 任课教师 A: 第一次打开 ppt 真的有点吐血的感觉

  • 任课教师 B: 根本讲不完,我估计听懂的人也不多

  • 任课教师 C (我自己):太好了,再也不用教了

我们面对的挑战:到底要不要读手册呢?

2.3. 为什么

反思

  • ELF 不是一个人类友好的 “状态机数据结构描述”

为什么没有用 JSON?

ELF 到处都是 offset...这是一个极其反人类的东西...

    • 为了性能,彻底违背了可读 (“信息局部性”) 原则

几乎让你直接去读一个内存里的二叉树

  • “Core dump”

    • Segmentation fault (core dumped)

    • (为什么叫 “core dump”?)

  • 地狱笑话:今天的 core dump 是个 ELF 文件

    • 内存状态...

    • “状态快照”——Everything is a state machine

      • 只是一个状态!而且不能向前后退

2.4. Core Dump

Core Dump(核心转储)是一种在程序运行过程中,由于某些错误或异常导致程序崩溃时,操作系统生成的文件。这个文件包含了程序崩溃时的内存映像、寄存器状态、堆栈信息等详细信息,用于帮助开发者调试程序。

在不那么方便 gdb 的时候

  • ulimit -c unlimited (或者一个你认为合理的值)

  • Crash (SIGSEGV, SIGABRT, SIGILL, ...) 时会做 core dump

    • 适合于 production systems

但 gdb 竟然不允许我继续执行

只是一个瞬间快照

  • 明明状态里该有的都有啊 (内存 + 寄存器)

    • 往回走呢?2018 年你还可以发一篇 OSDI

  • 想要继续执行?可以用 CRIU

    • 今天多了一个应用:实现 Agent..

    • 实现保存聊天的功能

    • 智能 Agent 时代,这玩意可好用了

2.5. 曾经不是这样

UNIX a.out “assembler output”

  • 一个相对平坦的数据结构

struct exec {
    uint32_t  a_midmag;  // Machine ID & Magic
    uint32_t  a_text;    // Text segment size
    uint32_t  a_data;    // Data segment size
    uint32_t  a_bss;     // BSS segment size
    uint32_t  a_syms;    // Symbol table size
    uint32_t  a_entry;   // Entry point
    uint32_t  a_trsize;  // Text reloc table size
    uint32_t  a_drsize;  // Data reloc table size
};
  • 功能太少 (不支持动态链接、调试信息、内存对齐、thread-local……),自然被淘汰

2.6. 换句话来说

支持的特性越多,越不人类友好

  • 听到 “程序头表”、“节头表”,大脑需要额外转译

    • “程序头表”(Program Header Table)

    • “节头表”(Section Header Table)

  • 含义隐晦的 R_X86_64_32, R_X86_64_PLT32

  • 大量的 “指针” (人类无法阅读的偏移量)

    • (我竟然已经被训练成基本可以正常阅读了 )

    • 卷王们在考试周经历了同样的魔鬼训练

      • 这就是大学需要改变的地方

人类友好的方式

  • 越 “平坦”,越容易理解

  • 所有需要的信息都立即可见

2.7. 重新设计的机会

那就设计一个 FLE 吧

  • Funny (Fluffy) Linkable Executable

  • AIGC

    • Friendly Learning Executable (GPT-4o)

    • Fast Learner's Executable (DeepSeek-r1)

核心设计思路

  • 一切都对人类直接可读 (所有信息都在局部)

    • 所有需要的东西不要以指针形式...

    • 所有东西都在手边...

  • 回归链接和加载中的核心概念:代码、符号、重定位

    • 你们会怎么设计?

3. Funny Little Executable

3.1. 代码 - 符号

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

  • 凑齐这三要素,我们就可以完成链接到加载的全流程了!

3.2. Demo

编译 - 连接

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# make

# 编译过程 ./cc 是编译器程序
# -Os 优化目标文件的大小。这会生成更小的目标文件,但可能会牺牲一些性能
# *.o 输出文件
./cc -Wall -g -Os foo.c -o foo.o
./cc -Wall -g -Os libc.c -o libc.o
./cc -Wall -g -Os main.c -o main.o

# 链接阶段 ./ld 是链接器程序
# foo.fle libc.fle main.fle 这些是目标文件或库文件 自定义的文件,用于链接
# hello 输出文件
./ld foo.fle libc.fle main.fle -o hello
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# ./hello
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
Message: Hello World!
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/fle/demo# echo $?
42

成功执行...

3.3. DSL: Domain Specific Language

我们为什么需要 DSL?

  • 只要领域内的表达能力

    • 做了减法,就可以更专注

    • 更简洁、更 natural

DSL 的例子

  • 正则表达式

    • 省略了关于字符串...扫描还要找子串的复杂度

  • Markdown, TeX, ...

  • abcjs 代码 - 乐谱???

只要你有问题,就有答案...

3.4. 实现 ELF Binutils

实现的工具集

  • objdump/readfle/nm (显示)

  • cc/as (编译)

  • ld (链接)

大部分都复用自 GNU binutils

  • elf_to_fle

3.5. 生成可执行文件 - 预编译 - 编译

源代码 (.c) → 源代码 (.i)

  • Ctrl-C & Ctrl-V (#include)

  • 字符串替换

  • 今天:我们有过程宏

源代码 (.i) → 汇编代码 (.s)

  • “高级状态机” 到 “低级状态机” 的翻译

  • 最终生成带标注的指令序列

3.6. 生成可执行文件 - 汇编

汇编代码 (.s) → 目标文件 (.o)

  • 文件 = sections (.text, .data, .rodata.str.1, ...)

    • 对于 ELF,每个 section 有它的权限、内存对齐等信息

  • section 中的三要素

    • 代码 (字节序列)

    • 符号:标记 “当前” 的位置

    • 重定位:暂时不能确定的数值 (链接时确定)

      • Quick Quiz: ELF 中全局和局部符号有什么区别?还有其他类型的符号吗?

3.7. 生成可执行文件 - 静态 链接

多个目标文件 (.o) → 可执行文件 (a.out)

  • 合并所有的 sections

    • 分别合并 .text, .data, .bss 中的代码

    • 把 sections “平铺” 成字节序列

    • 确定所有符号的位置

    • 解析全部重定位

  • 得到一个可执行文件

    • (程序初始内存状态的描述)

3.8. 最后一步:加载

把 “字节序列” 搬到内存

  • 没错,就只做这一件事

  • 然后设置正确的 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'])

4. 操作系统和加载器

4.1. 加载器是内核实现的一部分

execve(path, argv, envp)

  • 操作系统内核解析 path、完成加载

    • 可以在源码里找到手册 (System V ABI)中的概念

      • argc, argv, envp:

4.2. 等等

运行程序的两种方法

  • /bin/ls (这是一个 ELF) v.s. ./a.py (这可是文本啊!)

#include <unistd.h>
#include <stdio.h>

int main() {
    char *args[] = {"a.py", NULL};
    extern char **environ;

    execve(args[0], args, environ);

    // 如果execve失败,执行到这里
    perror("execve");
    return 1;
}

5. #! - Shebang

UNIX 对 # 注释的 “妙用”

  • any_file: #!A B C

    • 操作系统会执行 execve(A, ["A", "B C", "any_file"], envp)

    • (我们的 FLE 文件是可以直接执行的)

  • Linux 的实现:binfmt_script.c

    • 优先使用 #! 作为解释器 (Magic Number)

/* Not ours to exec if we don't start with "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
        return -ENOEXEC;
...
file = open_exec(i_name);

操作系统的彩蛋...

Shebang机制,是一个隐藏在操作系统中的有趣特性。

它允许用户通过简单的文本标记( #! )来指定脚本的解释器,从而使脚本文件可以直接像可执行文件一样运行。

Shebang

#!/usr/bin/env python3

"""这行代码告诉操作系统
这个脚本应该使用 /usr/bin/env 这个程序
并将 python3 作为参数传递给它。"""
  • 如果一个文件的第一行是 #!A B C ,操作系统会将这个文件视为一个脚本文件,并尝试执行它。

  • 操作系统会调用 execve 系统调用,将 A 作为解释器程序,B C 作为参数,any_file 作为脚本文件名传递给解释器。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec10/shebang# ./good
argv[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

Last updated