编译与二进制学习
一、从源码到机器码
我们写的是 C++ 源码,但 CPU 并不能直接执行这些代码。
它最终需要变成 CPU 能执行的机器指令。这个过程通常可以分成四个阶段:
这个过程通常分为四步。
预处理 → 编译 → 汇编(通常并入编译) → 链接。
预处理:Preprocessing
预处理器主要处理以 # 开头的预处理指令。
#include <iostream>
#define ADD(a, b) ((a) + (b))
int main() { int x = ADD(10, 20);}预处理主要做三件事:
#include:把头文件内容展开到当前文件。#define:进行宏替换。#if、#ifdef:根据条件决定哪些代码参与后续编译。- 编译后拿到
main.i,仍然是可读的文本。
比如我们利用下面的命令进行观察:
g++ -E main.cpp -o main.i-E 表示只进行预处理。
打开 main.i,你会发现它可能非常大,因为 <iostream> 等头文件被展开了。总共3完多行,而我们的源码就7行!
贴一点出来,如下
namespace std{
# 64 "D:/mingw64/lib/gcc/x86_64-w64-mingw32/15.2.0/include/c++/iostream" 3 extern istream cin; extern ostream cout; extern ostream cerr; extern ostream clog;
extern wistream wcin; extern wostream wcout; extern wostream wcerr; extern wostream wclog;# 87 "D:/mingw64/lib/gcc/x86_64-w64-mingw32/15.2.0/include/c++/iostream" 3
}# 2 "tmp.cpp" 2
# 5 "tmp.cpp"int main() { int x = ((10) + (20));}编译
编译阶段会分析 C++ 语法和语义,然后生成汇编代码。这时候编译器就会接手,然后做一些优化了。
g++ -S -O0 -fno-inline tmp.cpp -o tmp.s-S 表示生成汇编后停止。
产出的汇编如下
.file "tmp.cpp" .text .globl main .def main; .scl 2; .type 32; .endef .seh_proc mainmain:.LFB2190: pushq %rbp .seh_pushreg %rbp movq %rsp, %rbp .seh_setframe %rbp, 0 subq $48, %rsp .seh_stackalloc 48 .seh_endprologue call __main movl $30, -4(%rbp) movl $0, %eax addq $48, %rsp popq %rbp ret .seh_endproc .def __main; .scl 2; .type 32; .endef .ident "GCC: (x86_64-posix-seh-rev1, Built by MinGW-Builds project) 15.2.0"汇编Assembly
汇编器负责把 .s 中的汇编指令编码成机器码,并生成目标文件 .o。
g++ -c -O0 -fno-inline main.cpp -o main.o-c 表示只编译到目标文件,不进行最终链接。
最终会变成二进制字节。
这里要建立一个重要认识:
汇编代码是机器指令的可读表示;机器码才是 CPU 真正执行的内容。
链接
链接器(ld 或 link.exe)负责把多个目标文件以及所需的库“拼”在一起,解决所有未定义的符号引用。根据你想要的产物不同,链接方式也不同:
- 生成
.exe(可执行文件):链接器会把你的目标文件与启动代码(负责调用main函数)以及静态库(如.lib或.a)合并。如果使用静态链接,库代码会被直接复制进.exe里,文件较大但独立;如果使用动态链接,.exe里只保留一个指向外部.dll或.so的引用。 - 生成
.so(共享库/动态库):在 Linux 上通常用g++ -shared -fPIC来生成。-fPIC生成位置无关代码,使得这个.so文件可以被加载到进程内存的任意位置执行,从而能被多个程序共享。
二、.o,exe,so的区别
这三个文件是逆向工程中经常遇到的对象。
1. .o:目标文件
.o 是编译后的中间产物,通常还没有完成最终链接。
它可能包含:
- 已经生成的机器指令。
- 数据区。
- 符号表。
- 重定位表。
- 调试信息。
比如:
int add(int a, int b) { return a + b;}编译:
g++ -c math.cpp -o math.o此时 math.o 中已经有 add 的机器代码,但它不一定能独立运行。
为什么?
因为 .o 可能引用了其他目标文件或外部库中的函数、变量。这些引用还需要链接器解决。
2. 可执行文件:Executable
例如:
g++ main.cpp -o app链接器会把所需目标文件和库组织起来,生成可以被操作系统装载执行的文件。
Linux/Android 通常使用 ELF 格式。
它有程序入口、程序头表、加载所需信息等。
3. .so:共享库
.so 是 Shared Object,也就是共享库。
Android NDK 开发中经常生成它:
$CXX -std=c++17 -O0 -g -fno-inline \ -fPIC -shared native.cpp \ -o libnative.so几个参数:
| 参数 | 意义 |
|---|---|
-fPIC | 生成位置无关代码 |
-shared | 生成共享库 |
-O0 | 关闭常规优化 |
-g | 生成调试信息 |
-fno-inline | 尽量禁止内联 |
.so 通常不是直接由用户双击运行的程序,而是由其他程序或动态加载器装载。
| 对比 | .o | 可执行文件 | .so |
|---|---|---|---|
| 主要用途 | 链接输入 | 独立程序 | 共享库 |
| 是否通常已链接 | 否 | 是 | 已链接为共享对象 |
| 是否能被装载 | 通常作为链接输入 | 是 | 是 |
| 是否可被其他程序引用 | 可供链接器使用 | 通常不是库接口 | 是 |
| 常见 ELF 类型 | ET_REL | ET_EXEC 或 ET_DYN | ET_DYN |
注意:现代 Linux 的 PIE 可执行文件也常使用 ET_DYN,因此不能单凭 ET_DYN 就断定它一定是 .so。
三、ELF文件基础
ELF 是 Executable and Linkable Format,即可执行与可链接文件格式。
Linux 和 Android Native 环境都广泛使用 ELF。
你可以把 ELF 理解为一个有结构的二进制容器。它不仅存储机器码,还记录程序如何被链接和装载。
| 组成部分 | 英文名称 | 核心作用 | 关键内容 / 示例 |
|---|---|---|---|
| ELF 头 | ELF Header | 文件的“总目录”,描述整个 ELF 文件的全局信息,并指明两张表的位置 | 文件类型、目标架构、入口信息、程序头表/节区头表的位置与数量 |
| 程序头表 | Program Header Table | 告诉加载器哪些区域需要映射到内存,以及权限和对齐要求 | 段的文件偏移、虚拟地址、大小、权限(R/W/X)、对齐 |
| 节区头表 | Section Header Table | 描述文件中的各个节区,主要服务于链接和分析工具 | 各节区名称、类型、偏移、大小、链接信息 |
| 节区 / 段 | Sections / Segments | 实际存放代码、数据、符号、重定位等内容的数据区 | .text、.data、.bss、.rodata、.symtab、.dynsym、.rela.dyn 等 |
这里有两个特别容易混淆的概念:
- Section(节区):从链接、符号、代码与数据组织的角度描述文件。
- Segment(段):从程序装载的角度描述需要映射到内存的区域。
例如 .text 通常会被映射到可执行的 PT_LOAD Segment 中。
IDA 中你经常看到的 .text、.rodata、.data 等名称,大多就是 ELF 节区名称。
常见 Section
| Section | 主要用途 |
|---|---|
.text | 程序机器指令 |
.rodata | 只读数据,例如字符串常量 |
.data | 已初始化的全局变量、静态变量 |
.bss | 未初始化或零初始化的全局/静态变量 |
.symtab | 完整符号表,若未被剥离通常包含较多符号 |
.dynsym | 动态链接所需的符号表 |
.strtab | 普通符号表使用的字符串表 |
.dynstr | 动态符号表使用的字符串表 |
.rela.dyn | 动态重定位信息(常见于 AArch64 ELF) |
.rela.plt | PLT 相关重定位信息(常见于 AArch64 ELF) |
.plt | 过程链接表代码 |
.got | 全局偏移表相关数据 |
注意,具体 Section 是否存在、名称和组织形式会受编译器、链接器、链接选项和目标架构影响。
转成十六进制看看

我们可以看到前面是个.ELF,后面基本就看不懂了。
四、符号表
看下面代码:
int add(int a, int b) { return a + b;}
int global_value = 100;
int main() { return add(global_value, 20);}编译后,机器码本身只包含指令和地址,并不天然包含 add、global_value 这些源码名称。
符号表的作用之一,就是记录符号名称及其相关信息。
例如:
| 符号名称 | 类型 | 可能对应 |
|---|---|---|
add | FUNC | 函数 |
global_value | OBJECT | 全局变量 |
main | FUNC | 入口函数 |
符号表条目还可能记录:
- 符号值(Value)。
- 符号大小(Size)。
- 符号类型(FUNC、OBJECT 等)。
- 绑定属性(LOCAL、GLOBAL、WEAK)。
- 所属 Section。
LOCAL、GLOBAL、WEAK
这三个属性在逆向时经常遇到。
LOCAL:局部链接可见性,通常只在当前目标文件中使用。GLOBAL:全局符号,可以被其他目标文件引用。WEAK:弱符号,在符号解析时通常允许被强符号覆盖。
例如:
static int internal_func() { return 10;}
int public_func() { return internal_func();}这里的 internal_func 在文件作用域下具有内部链接属性,不作为普通全局符号导出。
注意:源码中的局部变量不一定作为普通 ELF 符号存在。编译器优化、调试信息和符号剥离都会影响你能否看到它们。
.symtab 和 .dynsym
两者区别:
.symtab:面向链接和分析的普通符号表,可能包含较完整的符号。.dynsym:动态链接器需要使用的动态符号表,通常只包含动态链接相关符号。
如果执行:
strip libnative.so普通符号信息可能被剥离。
但动态链接需要的符号信息通常仍然保留。
所以你会遇到这样的情况:
- IDA 看不到很多函数的源码名称。
- 但仍然能看到
malloc、memcpy、JNI_OnLoad等动态符号。
五、动态链接与重定位
为什么需要动态链接?
例如你的程序调用:
#include <cstring>
int main() { char a[] = "hello"; char b[16]{}; std::memcpy(b, a, sizeof(a));}memcpy 是 C 标准库提供的函数。
你的程序通常不需要把 memcpy 的完整实现复制进自己的二进制文件。
而是可以在运行时通过动态链接找到系统库中的实现。
libnative.so需要调用 memcpy |动态链接器解析依赖和符号 |libc.so提供 memcpy 的实现 |完成地址解析和必要的重定位 |程序可以调用目标函数通过相应的调用入口到达实际实现什么是重定位?
先看一个简单场景。
文件 A:
extern int global_value;
int get_value() { return global_value;}文件 B:
int global_value = 123;编译 a.cpp 时,编译器知道需要访问 global_value,但它还不知道这个变量最终会放在哪里。
因为真正的地址要等链接阶段才能确定。
所以目标文件中会保留:
- 一条需要修正的指令或数据引用。
- 一个指向
global_value的符号引用。 - 一条重定位记录,告诉链接器这个位置需要处理。
这就是重定位的核心思想:
把代码或数据中尚未确定的地址引用,修正为最终正确的地址或地址相关值。
静态重定位与动态重定位
可以先这样区分:
| 类型 | 主要发生阶段 | 解决的问题 |
|---|---|---|
| 静态链接重定位 | 链接阶段 | 把不同 .o 的符号引用连接起来 |
| 动态重定位 | 装载或运行阶段 | 修正共享库、外部符号等运行时地址引用 |
例如,链接器在生成 .so 时无法确定它未来会被映射到哪个进程虚拟地址。
动态体现在第一次运行或者加载时,动态链接器把libc.so加载到内存里,然后填充GOT表,把所需要的函数地址写进去,比如printf。
所以需要结合加载基址进行地址修正。
这就引出了 PIC(Position Independent Code,位置无关代码)。
为什么 .so 常使用 PIC?
假设一个共享库中有函数:
int global_value = 123;
int get_value() { return global_value;}如果机器代码直接写死:
global_value 的绝对虚拟地址 = 0x70001000那么这个 .so 换一个加载地址,代码就可能访问错误位置。
因此共享库通常采用位置无关代码。
ARM64 下常见的思路是通过 PC-relative 寻址访问附近的数据,或通过 GOT 间接访问全局符号。
概念示意:
adrp x8, :got:global_valueldr x8, [x8, :got_lo12:global_value]ldr w0, [x8]ret这只是说明一种典型的 GOT 访问模式,实际汇编会受到编译器、链接器、代码模型和符号可见性等影响。
PLT 和 GOT
先记住:
- PLT(Procedure Linkage Table):函数调用的跳转入口机制。
- GOT(Global Offset Table):保存运行时地址等信息的表。
1.业务代码调用 memcpy@plt2.PLT 跳转入口,间接跳转到 GOT 指定的地址3.GOT 表项,保存解析后的目标地址或解析入口4.ibc.so 中的 memcpy,实际执行函数在一些 ELF 环境中,第一次调用可能经过动态链接器的延迟绑定流程:
- 调用 PLT 入口。
- PLT 跳转到 GOT 中的初始解析入口。
- 动态链接器解析目标符号。
- 将解析出的地址写入相应 GOT 表项。
- 后续调用可以直接跳转到解析后的目标。
但要注意:
- 并不是所有函数调用都使用 PLT。
- 并不是所有平台都默认启用延迟绑定。
- Android Bionic 的链接行为和具体链接选项可能与常见 Linux glibc 环境不同。
- AArch64 常见的 PLT/GOT 代码形态与 x86-64 不同。
六、C++ 名称修饰(Name Mangling)
1. 为什么需要名称修饰?
先看两个函数:
int add(int a, int b) { return a + b;}
float add(float a, float b) { return a + b;}在 C++ 中,这两个函数可以同时存在,因为它们的参数类型不同。这叫函数重载,之前讲过的哦。
但问题来了:编译后,机器层面需要区分这两个函数。
如果它们都只叫 add,链接器就无法仅凭名字区分它们。
于是 C++ 编译器会对函数名进行编码,把:
- 命名空间
- 类名
- 函数名
- 参数类型
- 某些成员函数限定信息
编码进符号名称中。
这就是 Name Mangling。
比如上面两个函数就可能再GCC里被编译成
_Z3addii_Z3addff
_Z3addii│ │ │ └── i:int│ │ └──── i:int│ └────── 3add:长度为 3 的名字 add└──────── C++ 修饰符前缀命名空间被修饰
源码:
namespace NativeLab { int add(int a, int b) { return a + b; }}可能修饰成:
_ZN9NativeLab3addEii
N:嵌套名称。9NativeLab:长度为 9 的命名空间名。3add:长度为 3 的函数名。E:嵌套名称结束。ii:两个 int 参数。类成员函数被修饰
例如:
class User {public: void setAge(int age); int getAge() const;};成员函数的符号名称不仅需要包含函数名和参数类型,还需要包含类作用域和必要的成员函数限定信息。
典型 Itanium ABI 风格的反修饰结果可能是:
_ZN4User6setAgeEi_ZNK4User6getAgeEv七、C++ ABI:为什么不同模块可以互相调用?
1. ABI 到底是什么?
ABI,全称 Application Binary Interface,应用二进制接口。
你可以把它理解为:
已经编译成机器码的不同模块之间,必须遵守的一套底层约定。
例如,模块 A 编译了:
int add(int a, int b);模块 B 调用了:
int result = add(10, 20);它们要能够正常协作,就必须对很多事情达成一致:
- 函数符号名称是什么?
- 参数放在哪里?
- 返回值放在哪里?
- 栈如何使用?
- 类对象如何布局?
- 虚函数表如何组织?
- 异常如何传播?
- RTTI 如何表示?
下面是两个容易混淆的概念。
| 对比 | API | ABI |
|---|---|---|
| 全称 | Application Programming Interface | Application Binary Interface |
| 面向 | 源代码开发者 | 已编译的二进制模块 |
| 关注 | 函数、类、参数、接口 | 符号、寄存器、栈、对象布局 |
| 主要阶段 | 编译前/编译时 | 编译、链接和运行时 |
| 例子 | std::vector<int>::push_back | 参数传递、对象布局、符号修饰 |
ABI有价值的规定可以总结为下面几点
① Name Mangling
符号名称如何编码。比如 _ZN9NativeLab3addEii。
② Calling Convention
参数放在哪些寄存器或栈位置,返回值如何传递,谁负责保存寄存器。
③ Object Layout
类成员偏移、对齐、虚表指针、基类子对象位置等。
④ Virtual Dispatch
虚函数表、虚表指针、this 调整 thunk 等。
⑤ RTTI / Exception ABI
运行时类型信息、异常展开、构造和析构相关约定。
八、调用约定(Calling Convention)
调用约定规定了函数调用双方如何协作。
例如:
int add(int a, int b) { return a + b;}
int main() { return add(10, 20);}调用者需要把 10 和 20 交给 add。
add 执行完后,还要把结果交还给调用者。
那么问题来了:
- 参数放在哪?
- 返回值放在哪?
- 哪些寄存器允许被函数修改?
- 哪些寄存器必须恢复?
- 栈空间由谁维护?
- 调用前栈需要怎样对齐?
这些就是调用约定需要解决的问题。
在不同平台上,调用约定并不相同。
| 平台/约定 | 典型参数传递方式 |
|---|---|
| x86 32 位 cdecl | 常见情况下通过栈传递参数 |
| x86-64 System V | 整数/指针参数优先使用 RDI、RSI、RDX、RCX、R8、R9 |
| Windows x64 | 前四个整数/指针参数优先使用 RCX、RDX、R8、R9 |
| AArch64 AAPCS64 | 整数/指针参数通常优先使用 X0–X7 |
注意,这些是常见规则的概览。浮点参数、聚合类型、返回值和特殊场景有各自的分类规则
九、编译器如何优化
| 参数 | 含义 | 逆向时的特点 |
|---|---|---|
-O0 | 低优化/基本关闭常规优化 | 通常更接近源码结构 |
-O1 | 基础优化 | 可能出现内联、简化、删除无用代码 |
-O2 | 较积极优化 | 常见于实际发布构建 |
-O3 | 更积极优化 | 可能进行更激进的循环和向量化等优化 |
-Os | 优先减小代码体积 | 在性能与代码大小之间权衡 |
-Oz | 更激进地减小代码体积(Clang) | 可能进一步压缩代码 |
提醒:-O0 不是“完全不变”。即使使用 -O0,前端仍然会进行必要的语义处理、生成 ABI 需要的代码,汇编器和链接器也仍然会执行自己的处理。
我们看看常见的优化方法。
优化一:常量折叠(Constant Folding)
源码:
int calculate() { int a = 10; int b = 20; return a + b;}直觉上,你可能认为机器码应该有:
MOV W0, #10MOV W1, #20ADD W0, W0, W1RET但编译器发现:
10 + 20 = 30所以可能直接生成:
MOV W0, #30RET甚至如果调用结果也不会被观察到,整个函数都可能被删除。
这叫常量折叠。
逆向启示: 你可能根本看不到源码中的加法指令,因为编译器已经提前算完了。
优化二:死代码删除(Dead Code Elimination)
源码:
int test() { int a = 10; int b = 20; int c = a + b;
return 100;}变量 a、b、c 的结果都没有被使用。
因此编译器可能直接生成:
MOV W0, #100RET你在 IDA 里找不到:
int c = a + b;不是 IDA 漏掉了,而是它已经被编译器删除了。
优化三:函数内联(Inlining)
源码:
__attribute__((noinline))int add(int a, int b) { return a + b;}这里的 noinline 是 GCC/Clang 常见扩展,用于阻止该函数被内联。
去掉它:
int add(int a, int b) { return a + b;}
int main() { return add(10, 20);}优化后,调用点可能直接变成:
MOV W0, #30RET原来的:
BL add可能完全不存在。
这意味着:
- 源码中有函数。
- 但最终二进制中可能没有独立函数入口。
- 函数逻辑可能被直接合并进调用者。
优化四:循环展开(Loop Unrolling)
源码:
int sum() { int result = 0;
for (int i = 0; i < 4; i++) { result += i; }
return result;}原本是循环:
初始化 i判断 i < 4执行加法i++跳回判断编译器可能把循环展开:
int sum() { int result = 0; result += 0; result += 1; result += 2; result += 3; return result;}还可能继续常量折叠成直接返回 6。
优化五:寄存器分配(Register Allocation)
源码:
int calculate(int a, int b) { int x = a + b; int y = x * 2; return y;}源码有三个变量:
abxy
但机器代码不一定为它们分配四个独立的栈变量。
编译器可能让中间结果一直存在寄存器里:
ADD W0, W0, W1LSL W0, W0, #1RET这里甚至没有栈内存访问。
这就是寄存器分配和表达式简化的结果。
优化六:尾调用优化(Tail Call Optimization)
源码:
int add(int a, int b);
int wrapper(int a, int b) { return add(a, b);}普通理解是:
wrapper 调用 add add 返回 wrapper 返回但编译器可能优化为:
B add而不是:
BL addRET这叫尾调用优化。
原因是 wrapper 调用 add 后不再做任何工作,可以直接把控制权交给 add。
优化七:结构体和内存访问优化
源码:
struct Data { int a; int b;};
int get(Data* p) { return p->a + p->b;}在 IDA 中,你可能看到:
LDR W8, [X0]LDR W9, [X0,#4]ADD W0, W8, W9RET这时你可以推测:
X0是结构体地址。- offset
0处是a。 - offset
4处是b。
但如果编译器知道某些成员值是常量,或结构体对象只在局部范围内使用,优化可能进一步消除内存访问。
所以结构体布局分析最好结合:
- 源码(如果有)。
- 调试信息。
- 多处访问偏移。
- 构造函数和调用点。
- 汇编与数据交叉引用。
| 源码层面 | 优化后的可能表现 | IDA 分析时要注意 |
|---|---|---|
| 函数调用 | 内联 | 函数入口消失 |
| 常量计算 | 常量折叠 | 看不到原始运算 |
| 无用变量 | 死代码删除 | 源码变量不存在 |
| 循环 | 展开或向量化 | 控制流与源码不同 |
| 临时变量 | 寄存器分配 | 变量不一定有栈地址 |
| 简单包装函数 | 尾调用 | 可能只看到 B |
| 多次内存访问 | 合并或消除 | 访问次数与源码不一致 |
| 虚函数调用 | 去虚化 | 间接调用可能变成直接调用 |
还有两个需要补充的概念:
去虚化(Devirtualization)
例如:
Dog dog;Animal* p = &dog;p->speak();如果编译器能确定 p 一定指向 Dog,它可能直接调用 Dog::speak(),不再通过 vtable 间接调用。
向量化(Vectorization)
例如循环对数组逐项相加,编译器可能使用 SIMD 指令一次处理多个元素,而不是每次只处理一个元素。
这时 IDA 里可能看到 LD1、ADD V... 等向量指令,源码却只是一个普通的 for 循环。
extern "C":为什么 JNI 函数有时没有 C++ 名称修饰?
看这段代码:
int add(int a, int b) { return a + b;}
extern "C" int c_add(int a, int b) { return a + b;}第一种是普通 C++ 函数,通常会经过 C++ 名称修饰。
第二种使用了:
extern "C"它指定 C 语言链接属性。对于普通函数,这通常意味着使用 C 风格的符号名称,而不是 C++ 风格的重载名称修饰。
使用:
g++ -c -O0 test.cpp -o test.onm test.o可能看到类似:
_Z3addiic_add区别:
| 声明 | 常见符号形式 |
|---|---|
int add(int,int) | _Z3addii |
extern "C" int c_add(int,int) | c_add |
为什么 JNI 经常使用它?
例如:
extern "C"JNIEXPORT jint JNICALLJava_com_example_app_MainActivity_nativeAdd( JNIEnv* env, jobject thiz, jint a, jint b) { return a + b;}因为 JVM/Android 需要按照 JNI 规定的名称和接口找到 Native 函数。
这里有三个容易混淆的东西:
extern "C":控制语言链接属性和名称链接方式。JNIEXPORT:控制导出可见性等平台相关属性。JNICALL:指定 JNI 使用的调用约定宏。
它们不是同一回事。
部分信息可能已经过时









