mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
5422 字
14 分钟
从源码到二进制
2026-11-06

编译与二进制学习#

一、从源码到机器码#

我们写的是 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 main
main:
.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 是编译后的中间产物,通常还没有完成最终链接。

它可能包含:

  • 已经生成的机器指令。
  • 数据区。
  • 符号表。
  • 重定位表。
  • 调试信息。

比如:

math.cpp
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_RELET_EXEC 或 ET_DYNET_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 等
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4fd', 'primaryBorderColor': '#1a5276', 'primaryTextColor': '#000000', 'lineColor': '#34495e', 'fontSize': '15px', 'edgeLabelBackground': '#ffffff' }}}%% flowchart TB subgraph ELF["📦 ELF 文件整体结构"] direction TB EH["<b style='color:#000000'>🧭 ELF Header</b><br/><span style='font-size:13px;color:#000000'>文件类型 · 目标架构 · 入口地址<br/>程序头表 / 节区头表的位置与数量</span>"] subgraph TABLES["📋 两张描述表"] direction LR PHT["<b style='color:#000000'>🗺️ Program Header Table</b><br/><span style='font-size:13px;color:#000000'>面向 <b style='color:#000000'>加载器</b><br/>哪些区域映射到内存<br/>权限 · 对齐 · 虚拟地址</span>"] SHT["<b style='color:#000000'>🔗 Section Header Table</b><br/><span style='font-size:13px;color:#000000'>面向 <b style='color:#000000'>链接器 / 分析工具</b><br/>各节区名称 · 类型 · 偏移 · 大小</span>"] end subgraph DATA["🗂️ Sections / Segments 数据区"] direction LR subgraph SEC["🔹 节区(链接视图)"] direction TB S1["<b style='color:#000000'>.text</b><span style='color:#000000'> — 代码</span>"] S2["<b style='color:#000000'>.data</b><span style='color:#000000'> — 已初始化全局变量</span>"] S3["<b style='color:#000000'>.bss</b><span style='color:#000000'> — 未初始化全局变量</span>"] S4["<b style='color:#000000'>.rodata</b><span style='color:#000000'> — 只读数据</span>"] S5["<b style='color:#000000'>.symtab / .dynsym</b><span style='color:#000000'> — 符号表</span>"] S6["<b style='color:#000000'>.rela.dyn</b><span style='color:#000000'> — 重定位表</span>"] end subgraph SEG["🔸 段(执行视图)"] direction TB G1["<b style='color:#000000'>代码段</b><span style='color:#000000'> R-X<br/>含 .text 等</span>"] G2["<b style='color:#000000'>数据段</b><span style='color:#000000'> RW-<br/>含 .data / .bss 等</span>"] G3["<b style='color:#000000'>只读段</b><span style='color:#000000'> R--<br/>含 .rodata 等</span>"] end end end EH ==>|"定位"| PHT EH ==>|"定位"| SHT PHT -.->|"描述并映射"| SEG SHT -.->|"描述并合并"| SEC SEC ==>|"多个节合并为一个段"| SEG style ELF fill:#ffffff,stroke:#1a5276,stroke-width:2.5px,color:#000000 style EH fill:#d6eaf8,stroke:#1a5276,stroke-width:3px,color:#000000 style TABLES fill:#fff8e1,stroke:#b7950b,stroke-width:2px,color:#000000 style PHT fill:#fef5e7,stroke:#b9770e,stroke-width:2px,color:#000000 style SHT fill:#fef5e7,stroke:#b9770e,stroke-width:2px,color:#000000 style DATA fill:#eafaf1,stroke:#1e8449,stroke-width:2px,color:#000000 style SEC fill:#d5f5e3,stroke:#145a32,stroke-width:2px,color:#000000 style SEG fill:#d6eaf8,stroke:#1a5276,stroke-width:2px,color:#000000 style S1 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style S2 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style S3 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style S4 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style S5 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style S6 fill:#ffffff,stroke:#145a32,stroke-width:1.5px,color:#000000 style G1 fill:#ffffff,stroke:#1a5276,stroke-width:1.5px,color:#000000 style G2 fill:#ffffff,stroke:#1a5276,stroke-width:1.5px,color:#000000 style G3 fill:#ffffff,stroke:#1a5276,stroke-width:1.5px,color:#000000

这里有两个特别容易混淆的概念:

  • 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.pltPLT 相关重定位信息(常见于 AArch64 ELF)
.plt过程链接表代码
.got全局偏移表相关数据

注意,具体 Section 是否存在、名称和组织形式会受编译器、链接器、链接选项和目标架构影响。

转成十六进制看看

image-20261105203914129

我们可以看到前面是个.ELF,后面基本就看不懂了。

四、符号表#

看下面代码:

int add(int a, int b) {
return a + b;
}
int global_value = 100;
int main() {
return add(global_value, 20);
}

编译后,机器码本身只包含指令和地址,并不天然包含 add、global_value 这些源码名称。

符号表的作用之一,就是记录符号名称及其相关信息。

例如:

符号名称类型可能对应
addFUNC函数
global_valueOBJECT全局变量
mainFUNC入口函数

符号表条目还可能记录:

  • 符号值(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:

a.cpp
extern int global_value;
int get_value() {
return global_value;
}

文件 B:

b.cpp
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_value
ldr x8, [x8, :got_lo12:global_value]
ldr w0, [x8]
ret

这只是说明一种典型的 GOT 访问模式,实际汇编会受到编译器、链接器、代码模型和符号可见性等影响。

PLT 和 GOT#

先记住:

  • PLT(Procedure Linkage Table):函数调用的跳转入口机制。
  • GOT(Global Offset Table):保存运行时地址等信息的表。
1.业务代码调用 memcpy@plt
2.PLT 跳转入口,间接跳转到 GOT 指定的地址
3.GOT 表项,保存解析后的目标地址或解析入口
4.ibc.so 中的 memcpy,实际执行函数

在一些 ELF 环境中,第一次调用可能经过动态链接器的延迟绑定流程:

  1. 调用 PLT 入口。
  2. PLT 跳转到 GOT 中的初始解析入口。
  3. 动态链接器解析目标符号。
  4. 将解析出的地址写入相应 GOT 表项。
  5. 后续调用可以直接跳转到解析后的目标。

但要注意:

  • 并不是所有函数调用都使用 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 如何表示?

下面是两个容易混淆的概念。

对比APIABI
全称Application Programming InterfaceApplication 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, #10
MOV W1, #20
ADD W0, W0, W1
RET

但编译器发现:

10 + 20 = 30

所以可能直接生成:

MOV W0, #30
RET

甚至如果调用结果也不会被观察到,整个函数都可能被删除。

这叫常量折叠。

逆向启示: 你可能根本看不到源码中的加法指令,因为编译器已经提前算完了。

优化二:死代码删除(Dead Code Elimination)#

源码:

int test() {
int a = 10;
int b = 20;
int c = a + b;
return 100;
}

变量 a、b、c 的结果都没有被使用。

因此编译器可能直接生成:

MOV W0, #100
RET

你在 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, #30
RET

原来的:

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;
}

源码有三个变量:

  • a
  • b
  • x
  • y

但机器代码不一定为它们分配四个独立的栈变量。

编译器可能让中间结果一直存在寄存器里:

ADD W0, W0, W1
LSL W0, W0, #1
RET

这里甚至没有栈内存访问。

这就是寄存器分配和表达式简化的结果。

优化六:尾调用优化(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 add
RET

这叫尾调用优化。

原因是 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, W9
RET

这时你可以推测:

  • 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.o
nm test.o

可能看到类似:

_Z3addii
c_add

区别:

声明常见符号形式
int add(int,int)_Z3addii
extern "C" int c_add(int,int)c_add

为什么 JNI 经常使用它?

例如:

extern "C"
JNIEXPORT jint JNICALL
Java_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 使用的调用约定宏。

它们不是同一回事。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从源码到二进制
https://fatdog.20060113.xyz/posts/binary-elf/
作者
神秘大胖狗
发布于
2026-11-06
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00