NJUOS-16-什么是可执行文件
本文最后更新于:1 年前
本次课回答的问题
- Q: 可执行文件到底是什么?
- Q: 可执行文件是如何在操作系统上被执行的?
本次课主要内容
- 可执行文件
- 解析可执行文件
- 链接和加载
- 今天假设只有静态链接
可执行文件
RTFM
本次课涉及的手册
- System V ABI: System V Application Binary Interface (AMD64 Architecture Processor Supplement) (repo)
- 和更多 refspecs
(不用听了,可以下课了)
- 课堂要回答的问题
- 为什么 f**king manual 是 f**king 的?
- 应该怎么读手册?
可执行文件:状态机的描述
可执行文件:描述了一个状态机。状态?寄存器+内存(地址空间)
操作系统 “为程序 (状态机) 提供执行环境”
- 可执行文件 (状态机的描述) 是最重要的操作系统对象!
一个描述了状态机的初始状态 + 迁移的数据结构 -> 可执行文件就是一个数据结构!!!描述了状态机!!!
- 寄存器
- 大部分由 ABI 规定,操作系统负责设置
- 例如初始的 PC
- 地址空间
- 二进制文件 + ABI 共同决定
- 例如 argv 和 envp (和其他信息) 的存储
- 其他有用的信息 (例如便于调试和 core dump 的信息)
例子:操作系统上的可执行文件
需要满足若干条件
- 具有执行 (x) 权限
- 加载器能识别的可执行文件
1 |
|
是谁决定了一个文件能不能执行?
操作系统代码 (execve) 决定的。
动手试一试
- strace ./a.c
- 你可以看到失败的 execve!
- 没有执行权限的 a.c: execve = -1, EACCESS
- 有执行权限的 a.c: execve = -1, ENOEXEC
先玩一玩,有个基本的认识!!!
- 再读一遍 execve (2) 的手册
- 读手册的方法:先理解主干行为、再查漏补缺
- “ERRORS” 规定了什么时候不能执行
手册读不下去了 -> 玩过一些小例子,慢慢理解,就慢慢能读懂,慢慢能深入啦!!!
常见的可执行文件
就是操作系统里的一个普通对象
绿导师原谅你了.avi
Windows 95/NT+, UEFI
- PE (Portable Executable), since Windows 95/NT+
UNIX/Linux
- a.out (deprecated)
- ELF (Executable Linkable Format)
- She-bang
- 我们可以试着 She-bang 一个自己的可执行文件!
- She-bang 其实是一个 “偷换参数” 的 execve
She-bang:
二进制文件,本质上就是一个可以被execve接受,并且翻译成状态机状态的数据结构嘛!!!
偷换参数!!!
#!会被在执行execve的时候,偷偷替换掉!!!
偷偷把argv换掉,再去执行execve。(这里只有一个argv[1]哈,历史遗留问题!!!)
比如#! xxx python3,本质上状态机会被变为python3, python3会去拿出来这个文件,解析并且进行执行昂!
- 再来看手册:
解析可执行文件
Binutils - Binary Utilities
- 生成可执行文件
- ld (linker), as (assembler)
- ar, ranlib
- 分析可执行文件
- objcopy/objdump/readelf (计算机系统基础)
- addr2line, size, nm
可执行文件的运行时状态
segfault.c - 为什么 gdb 知道出错的位置?
1 |
|
- gcc (-static -g)
1 |
|
gdb调试!!!gdb为什么可以打印程序的backtrace?
应用程序的二进制文件里面,有一些额外信息,帮助我们的debugger解析运行时的状态,addr2line。
调试信息
编译器会在编译过程中,自动帮助我们完成addr2line的映射!!!
gcc -static segfault.c -g -S
,编译器会在汇编里面,自动帮助我们生成很多调试信息!!!
将一个 assembly (机器) 状态映射到 “C 世界” 状态的函数
- The DWARF Debugging Standard
- 定义了一个 Turing Complete 的指令集
DW_OP_XXX
- 可以执行 “任意计算” 将当前机器状态映射回 C
- RTFM
- 定义了一个 Turing Complete 的指令集
在对应执行的内存中,通过我们自定义的Turing Complete,就可以打印出来内存中的一些信息(便于调试)!!!
相当于做了一个,编译状态到C状态的映射。Debug Info就是一个函数,帮我们把汇编机器下的状态,映射为C语言里面的状态!!!但是可以遇见的是,由于中间的翻译和优化,上下要对应起来,十分困难 -> 不完美!!!
- 但非常不完美
- 对现代语言支持有限 (C++ templates)
- 还是因为编程语言太复杂了
- 编译器也没法保证就做得对(摆烂了orz)
- 各种令人生气的
<optimized out>
- 各种不正确的调试信息
- 各种令人生气的
- 对现代语言支持有限 (C++ templates)
例子:寄存器分配
1 |
|
- 优化的编译器 (-O2)
优化之后的值都和原来的值对不上了……
- 使用工具查看
- -g -S 查看嵌入汇编的 debug info
- readelf -w 查看调试信息
- gdb 调试
s=<optimized out>
- 呃……离谱,这看个🔨orz……
例子:Stack Unwinding
1 |
|
- 思路:
函数调用无非就是栈帧嘛!!!我们顺着存栈帧的指针,把对应的数据结构struct frame取出来,然后像链表遍历一样,从目前遍历到一开始,就能够backtrace()出来最终的结果哇!
- 需要的编译选项
- -g (生成调试信息)
- -static (静态链接)
- -fno-omit-frame-pointer (总是生成 frame pointer)
- 可以尝试不同的 optimization level
- 再试试 gdb
O2状态下产生了内联呢,O0就是正常的,没有啥优化。
因此调试逻辑错误都是用O0的昂!!!
这里也看到,就算是O2,gdb也能找出调用信息。本质上栈帧优化之后,是找不到调用了。但是GDB可以根据这些Debug Info,给“虚构”出来调用的链路,这事儿没我们想的那么容易昂!!!
没有 frame pointer 的时候呢?
- Linus 锐评 Kernel backtrace unwind support
- Reliable and fast DWARF-based stack unwinding (OOPSLA’19)
- 一般问题:Still open (有很多工作可以做)
逆向工程 (Reverse Engineering)
得到 “不希望” 你看到的商业软件代码 (然后就可以分析漏洞啥了)
- 调试信息 (代码) 是绝对不可能了
- 连符号表都没有 (stripped)
- 看起来就是一串指令序列 (可以执行)
编译和链接
从 C 代码到二进制文件
被《计算机系统基础》支配的恐惧?
1 |
|
1 |
|
- 从main函数里面调用一个它不知道的hello,main.c编译的时候,声明了hello的存在。编译main的时候,还没有解析hello,不知道在哪儿,在链接的时候再决定昂!但是编译可以正常完成,这样就可以在我们的一个文件不知道其他部分的情况下,完成编译。 -> 大的项目拆小
其实不难
gcc -S -c main.c
:
可以看到底层就是编译成了一条call指令而已捏!!!
编译器生成文本汇编代码 → 汇编器生成二进制指令序列
1 |
|
但有些地址编译的时候不知道啊 (比如 hello) -> 目标文件执行不了,必须经过链接,才能够生成可执行文件!!!绝大多数代码都生成完成了,但是call的是hello,不知道hello是啥呀,因此摆烂了,全是0……
这里是成立的!!!
前面还好。这里我不明白为什么要+offset。这个offset的值是多少? -> offset是从地址p读出来的一个偏移(因为马上要函数调用了,要跳转到别的地方,这里是相对地址),判断函数调用之后的结果,是不是hello,就是酱!
main.o
- 就先填个 0 吧
重定位 (Relocation)
但这 4-bytes 最终是需要被填上的(被真正的hello填上!),使得 assertion 被满足:
1 |
|
这个要求也要被写在文件里
ELF 文件:部分状态机的 “容器”
1
2Offset Type Sym. Name + Addend
00000000000b R_X86_64_PLT32 hello - 4- 重填 32-bit value 为 “S + A - P” (P = main + 0xb)
- 如何理解?考虑 call “S + A - P” 的行为 -> S+A-P就是(char *)main + 0xf + *(int32_t *)((uintptr_t)main + 0xb)
S+A-P=S-(P-A) P-A就是现在的PC值,就是跳转的目标地址减去现在的PC。这边感觉确实讲的复杂了些,其实就是求hello的第一条指令的起始地址和call指令的下一条指令的起始地址的偏移,就是一个跳转时的偏移量,如果用汇编来写的话就很简单了,call hello;1:;hel
重新理解编译、链接流程
编译器 (gcc)
- High-level semantics (C 状态机) → low-level semantics (汇编)
汇编器 (as)
- Low-level semantics → Binary semantics (状态机容器)
- “一一对应” 地翻译成二进制代码
- sections, symbols, debug info
- 不能决定的要留下 “之后怎么办” 的信息
- relocations
- “一一对应” 地翻译成二进制代码
链接器 (ld)
- 合并所有容器,得到 “一个完整的状态机”
- ldscript (
-Wl,--verbose
); 和 C Runtime Objects (CRT) 链接 - missing/duplicate symbol 会出错
- ldscript (
奇怪,我们完全没有讲 ELF 的细节?
ELF 就是一个 “容器数据结构”,包含了必要的信息
- 你完全可以试着自己定义二进制文件格式 (dump it to disk)!
1 |
|
当然,这有很多缺陷
- “名字” 其实应该集中存储 (
const char *
而不是char[]
) - 慢慢理解了 ELF 里的各种设计 (例如 memsz 和 filesz 不一样大) -> 例如symbol里面的name,如果每个都分配32个字节,太大了!!!可能有一个buffer,里面装了全部的字符串常量,存储的时候,就存一个便宜量。诶嘿,方便小巧,取出来的时候再构成上面的数据结构嘛!!!
- “名字” 其实应该集中存储 (
如何理解:
- a.out就是一个数据结构,描述的状态机的初始状态和转换。
References
- System V ABI: System V Application Binary Interface (AMD64 Architecture Processor Supplement) (repo),应用和二进制的Interface。
- 和更多 refspecs
- GNU binutils -> 二进制文件的工具集合(ld啊,readelf啊,objdump啊之类的昂!!!) -> 本质上就是二进制文件这种数据结构的查看 or 修改的工具哇!!!
- file指令,可以查看elf文件的类型和基本信息之类的哇!!!
- readelf可以查看elf文件(最后可执行的二进制文件格式)
- CSAPP很重要,链接章节再看一遍,会有额外收获昂!!!