逆向工程基本原理02
分支循环的机器级表示
分支指令
直接跳转
1 | B <target_address> |
主要用于函数内跳转
间接跳转
1 | BR Xm |
跳转到寄存器Xm中存储的地址
带链接跳转的分支指令
函数调用
1 | BL <target_address> |
将当前指令地址+4(next pc)存储到链接寄存器LR中,然后跳转到目标地址,一般为CPU自己执行,不需要程序员关心
函数返回
1 | RET (Xm) |
若未指定 Xm ,则将 LR 寄存器中的地址作为返回地址跳转;若指定了 Xm ,则将 Xm 寄存器中的地址作为返回地址跳转
带条件的分支指令
B/BL <cond> <target>
根据 cmp 的结果来决定是否跳转到对应 label
1 | CMP X0, #5 |
CBNZ/CBZ
根据给定寄存器是否为0来来判断是否跳转
1 | CBNZ X0, label1 ;如果 X0 != 0 则跳转到 label1 |
TBZ/TBNZ
根据给定寄存器的某一位是否为0来判断是否跳转
1 | TBZ X0, #3, label1 ;如果 X0 的第3位 == 0 则跳转到 label1 |
所有的条件后缀:
| 条件后缀 | 含义(整数) | 含义(浮点数) |
|---|---|---|
| EQ | 等于 | 等于 |
| NE | 不等于 | 不等于、无序 |
| CS | 进位设置 | 大于、等于、无序 |
| CC | 进位清除 | 小于 |
| MI | 负数 | 小于 |
| PL | 正数、零 | 大于、等于、无序 |
| VS | 溢出 | 无序 |
| VC | 无溢出 | 非无序 |
| HI | 无符号大于 | 大于、无序 |
| LS | 无符号小于等于 | 小于、等于 |
| GE | 有符号大于等于 | 大于、等于 |
| LT | 有符号小于 | 小于、无序 |
| GT | 有符号大于 | 大于 |
| LE | 有符号小于等于 | 小于、等于、无序 |
| AL | 总是 | 总是 |
带指令集切换的分支指令
BX/BLX <cond> <target>
带指令集跳转的切换,在 A32 和 T32 之间切换,根据目标地址的最后一位判断指令集
- 如果 bit[0] == 0 则切换到 A32
- 如果 bit[0] == 1 则切换到 T32
如果同时也是带链接的跳转,在发生跳转前会将当前指令地址+4(next pc)存储到链接寄存器 LR 中
if 语句的机器级表示
if 语句通常会被翻译为条件跳转语句
给出一个简单的示例
1 | int max(int a, int b) { |
wsl ubantu24.04 arm64 编译汇编结果:
1 | aarch64-linux-gnu-g++ -O0 -S if.cpp -o if.s |
1 | _Z3maxii: |
switch 语句的机器级表示
多个条件跳转1
当 case 较少时会这么做
1 | void process_command(int cmd, int& x) { |
汇编如下:
1 | _Z15process_commandiRi: |
跳转表
有时间补上,试了很多次都是条件分支/决策树,开 -O1 -O2 -O3 都一样,就算使用了 -fno-tree-switch-conversion 也没生成跳转表,有点诡异
多个条件跳转2
当switch case较多但case值不连续时,翻译为多个条件跳转
1 |
|
汇编如下
1 | _Z15get_http_statusi: |
位图实现
这个我看没人讲过,但实际上是存在这种优化的
1 | int get_month_days(int month) { |
汇编如下:
1 | _Z14get_month_daysi: |
这个没有开任何优化选项,位图在 -O0 就已经实现了,这个优化位于编译器前端
可以看到,编译器首先将输入的 month 转换为一个位图,1月对应 bit0,2月对应 bit1,…,12月对应 bit11,然后通过位运算判断输入的 month 是否在对应的 case 中,最后根据结果返回对应的天数
这种优化大大减少了条件跳转的数量,提升了性能
其他优化
- 决策树,类似二分查找
- 范围折叠,比如 case 1-10 执行一样的代码,编译器直接生成
x - 1 <= 9 - …
来看一段代码
1 | int classify(int x) { |
开启了 -O2 优化
汇编如下:
1 | _Z8classifyi: |
while 语句的机器级表示
while 循环存在模板如下:
1 | do sth... |
1 | int while_loop(){ |
汇编如下:
1 | _Z10while_loopv: |
可以看到,.L2是 while 循环的条件检查部分,.L3 是循环体部分,循环体执行完后无条件跳转回条件检查部分
for 语句的机器级表示
for 和 while 在大多数情况下可以相互转换
1 | int for_loop(){ |
汇编如下:
1 | _Z8for_loopv: |
可以看到,.L6 是 for 循环的条件检查部分,.L7 是循环体部分,循环体执行完后无条件跳转回条件检查部分,而 for 循环的 i++ 操作被放在了循环体的末尾
do-while 语句的机器级表示
1 | int dowhile_loop(){ |
汇编如下
1 | _Z12dowhile_loopv: |
可以看到,.L10 是 do-while 循环的循环体部分,循环体执行完后会进行条件检查,如果条件满足则跳转回循环体部分继续执行,否则退出循环
函数调用的机器级表示
寄存器使用规范
- 参数寄存器 X0-X7 用于传递函数参数,X0 储存函数返回值
- X8 是 间接结果寄存器,通常用于储存间接结果的地址(比如返回一个很大的结构体)
- 调用者保存寄存器 X9-X15 用于保存函数调用过程中需要保留的值
- X16 和 X17 是函数内暂存寄存器(IP0,IP1),例程和它调用的任何子例程之间的暂存寄存器
- X18 是平台寄存器 PR,通常用于指向线程局部存储(TLS)或其他平台特定数据结构
- 被调用者保存寄存器 X19-X28 是保存应在调用之间保留的长期使用的寄存器
- X29 是帧指针寄存器 FP,在打开tail call优化时为普通寄存器
- X30 是链接寄存器 LR,保存函数调用结束时的返回地址
- SP 是栈指针寄存器
浮点寄存器:
- V0 储存函数返回值
- V1-V7 是调用者保存寄存器
- V8-V15 是被调用者保存寄存器
- V16-V31 是临时寄存器
栈空间约定
函数中的局部变量
参数传递过程中参数较多X0-X7/V0-V7不足以存放所有寄存器时,将参数存放到栈上
存放返回地址
每个函数仅应该使用自己的栈空间,当前函数的栈空间范围由FP、SP规定
栈空间的申请在被调用函数开头完成
栈空间从高地址向低地址生长
函数调用规范与机器级表示
调用者函数
- 保存caller-saved registers(根据具体情况,并非所有调用都需保存)
- 按照参数传递约定设置参数
- 将返回地址存放到LR寄存器中(通过branch-with-link跳转实现)
被调用者函数
- 申请callee的栈空间
- 将FP和LR保存到栈上(非叶子函数)
- 将参数存放到栈上
- 保存callee-saved registers(根据具体情况,并非所有调用都需保存)
- 执行callee函数逻辑
函数返回规范与机器级表示
调用者函数
- 恢复callee-saved registers
- 将函数返回值保存到X0/V0
- 恢复FP和LR(非叶子函数)
- 释放申请的栈空间
- 根据LR返回caller
被调用者函数
- 保存X0/V0中的函数返回值
- 恢复caller-saved registers
C++成员函数调用
对象成员函数
需要将当前对象的地址放到第一个参数寄存器,即 this->func(xxx) 调用语句实际实现为 func(this, xxx),其中 this 是一个指向当前对象的指针,存放在 X0 寄存器中
类成员函数
和普通函数调用一样,参数按照约定传递即可
普通函数和虚函数调用的区别:
静态绑定与动态绑定:
在继承场景下,父类指针调用普通函数和虚函数时会产生差异,普通函数调用时会根据指针类型进行静态绑定,调用父类的函数实现;而虚函数调用时会根据对象实际类型进行动态绑定,调用子类的函数实现
- 静态绑定:若父类指针调用的是非虚函数,在编译的时候直接调用这个指针声明类型的类函数,即使实际对象是子类
- 动态绑定:当调用的是虚函数时,在运行时判断指针指向的对象,并根据虚函数表调用相应的函数
C++ 函数重载
C++支持函数重载,即相同的函数名可以根据函数参数的不同来决定调用哪个函数,C++ 中引入 Name Mangling(名称修饰)机制来实现函数重载的支持,编译器会根据函数的参数类型、数量等信息对函数名进行修饰,使得每个重载的函数在编译后的符号表中具有唯一的名称,从而实现函数重载的功能
GCC/Clang 的 Name Mangling 规则
遵循 Itanium C++ ABI 规范(所有字符均为 ASCII 可打印字符,以 _Z 开头)
基础规则:
1 | _Z <qualified-name> <type-info> |
基本上为:
1 | 函数名 命名空间/类 参数类型 模板参数 CV限定符 |
限定名编码
普通函数
1 | void fn(int) |
编码为:
1 | → _Z2fni |
命名空间/类嵌套
使用 N...E 包裹
嵌套名用 N 开头、E 结束,每段由 长度+名字 拼接:
1 | namespace foo { class Bar { void fn(); }; } |
编码为:
1 | _ZN3foo3Bar2fnEv |
类型编码
| C++ 类型 | 编码 | C++ 类型 | 编码 |
|---|---|---|---|
void | v | bool | b |
int | i | unsigned int | j |
long | l | unsigned long | m |
long long | x | unsigned long long | y |
float | f | double | d |
char | c | unsigned char | h |
signed char | a | wchar_t | w |
...(varargs) | z |
指针与引用在类型前加前缀:
1 | * → P |
重复类型压缩:
1 | 第1次重复 → S_ |
函数重载
函数重载时,编译器会根据函数参数的类型、数量等信息对函数名进行修饰,使得每个重载的函数在编译后的符号表中具有唯一的名称,从而实现函数重载的功能
1 | void fn(int) → _Z2fni |
CV 限定符
成员函数的 const 和 volatile 限定符追加在参数列表之后
1 | K = const |
1 | struct Foo { |
模板
模板函数不编码返回类型
函数模板
模板参数放在 I...E 块中
1 | template<typename T> |
编码为
1 | _Z2fnIiEvi |
类模板
1 | template<typename T> |
C++ 不允许仅凭返回类型重载,所以对于普通函数,返回类型对于区分符号没有任何作用,Itanium ABI 直接省略它以缩短符号长度。模板函数则不同——不同的模板参数可以推导出不同的返回类型,编译器需要把它编进符号里才能正确区分特化版本
1 | 非模板:_ZN3foo3Bar2fnEi → 无返回类型段 |
特殊成员函数
构造函数、析构函数和运算符有专用编码:
| 特殊函数 | 编码 | 示例 |
|---|---|---|
| 构造函数(完整对象) | C1 | _ZN3FooC1Ev |
| 构造函数(基类子对象) | C2 | _ZN3FooC2Ev |
| 析构函数(完整对象) | D1 | _ZN3FooD1Ev |
| 析构函数(基类子对象) | D2 | _ZN3FooD2Ev |
| 析构函数(删除对象) | D0 | _ZN3FooD0Ev |
operator+ | pl | _ZplRKFooS0_ |
operator<< | ls | |
operator[] | ix | |
operator new | nw | _ZnwmPv |
operator delete | dl | |
| 类型转换运算符 | cv <type> | _ZN3FoocvdEv → operator double |
extern "C" 与跨语言链接
1 | extern "C" void fn(int); // → fn(不加任何修饰,C 语言链接) |
extern "C" 块内的函数完全绕过 mangling,使用 C 链接规则,这是 C/C++ 互操作的基础机制。
std 命名空间压缩
std 命名空间有一套固定缩写:
| 展开 | 压缩 |
|---|---|
std:: | St |
std::string | Ss |
std::istream | Si |
std::ostream | So |
std::iostream | Sd |
std::allocator<T> | Sa |
lambda 表达式
Lambda 被编译器命名为 <lambda N>,N 是出现顺序:
1 | auto lam = [](int x){ return x; }; |
Ul...E_ 是 lambda 的编码块:U = unnamed type,l = lambda,参数列表,E_ 结束。
虚表、RTTI 相关符号
| 实体 | 前缀 | 示例 |
|---|---|---|
| vtable | _ZTV | _ZTV3Foo |
| VTT(虚表表) | _ZTT | _ZTT3Foo |
| typeinfo | _ZTI | _ZTI3Foo |
| typeinfo name | _ZTS | _ZTS3Foo |
| thunk | _ZTh | 虚继承调整 |
例子:
可以使用 c++filt 工具来解码 mangled name:
先举一个编码例子:
1 | _ZNK3foo3Bar2fnIiEERKdSs |
解码如下:
1 | _Z 全局前缀 |
1 | # c++filt _ZNK3foo3Bar2fnIiEERKdSs |
由于
1 | typedef std::basic_string<char, std::char_traits<char>, std::allocator<char>> string; |
所以其实是对的
编译器在 mangle 之前就已经把所有 typedef 展开成底层类型了,符号里根本不存在 string 这个名字,Itanium ABI 把这个常用的完整模板实例列为特殊缩写
1 | 源码: std::string |
MSVC 的 Name Mangling 规则
MSVC 使用完全独立的一套规则,设计哲学与 Itanium 截然不同:名字反向排列、调用约定内嵌、类型用单字母大写编码,所有符号以 ? 开头,@ 作为各段分隔符
基础规则
1 | ? <name> @ <scope...> @ <calling-conv> <return-type> <params> @Z |
限定名编码
顺序和 Itanium 相反,名字在前,限定符在后,使用 @ 分隔
1 | void foo::Bar::fn(int) |
即:
1 | ?fn@Bar@foo@@QAEHH@Z |
类型编码
MSVC 用大写字母,与 Itanium 的小写体系完全不一样
| C++ 类型 | MSVC 编码 | Itanium 编码 |
|---|---|---|
void | X | v |
int | H | i |
unsigned int | I | j |
long | J | l |
unsigned long | K | m |
long long | _J | x |
float | M | f |
double | N | d |
bool | _N | b |
char | D | c |
unsigned char | E | h |
wchar_t | _W | w |
指针与引用前缀
1 | * → P |
例:
1 | int* → PAH (P = pointer, A = no CV, H = int) |
类型:
1 | class → ?A V name @@ |
函数重载
和 Itanium 一样,参数类型序列决定最终符号,但用的是 MSVC 的类型字母
1 | void fn(int) → ?fn@@YAXH@Z |
CV 限定符
MSVC 将 const/volatile 编码在调用约定字段内,而不是独立前缀
1 | Q = public |
例子:
1 | void Foo::fn() → QAE(public, non-const) |
模板
MSVC 的模板参数用 $ 和尖括号风格的特殊序列编码,比 Itanium 的 I...E 更复杂:
1 | template<typename T> |
编码如下:
1 | ??$fn@H@@YAXH@Z |
类模板成员函数
1 | template<typename T> |
非类型模板函数
1 | template<int N> |
特殊成员函数
| 特殊函数 | MSVC 编码 | 示例 |
|---|---|---|
| 构造函数 | ?0 | ??0Foo@@QAE@XZ |
| 析构函数 | ?1 | ??1Foo@@QAE@XZ |
operator new | ?2 | ??2@YAPAXI@Z |
operator delete | ?3 | ??3@YAXPAX@Z |
operator= | ?4 | ??4Foo@@QAEAAV0@ABV0@@Z |
operator== | ?8 | |
operator[] | ?A | |
operator() | ?R | |
operator<< | ?6 | |
类型转换 operator double | ?Bdouble 前缀 |
构造函数和析构函数不单独写名字,而用 ?0 / ?1 这类数字编号代替:
1 | ??0Foo@@QAE@XZ |
extern "C" 与跨语言链接
行为和 GCC 完全一致
名称压缩:反向引用表
MSVC 也有类似 Itanium 的压缩机制,但方式不同——维护一张最多 10 个槽位(0~9)的反向引用表,已出现的类型/名称用 0~9 引用
1 | void fn(Foo, Foo); → ?fn@@YAXVFoo@@0@Z |
超过 10 个不重复类型就不再压缩,直接写全名
这比 Itanium 的 S_/S0_ 机制容量小
虚表、RTTI 相关符号
| 实体 | MSVC 符号前缀 | 示例 |
|---|---|---|
| vtable | ??_7 | ??_7Foo@@6B@ |
| typeinfo | ??_R0 | ??_R0?AVFoo@@@8 |
| RTTI 完整对象定位器 | ??_R4 | |
| 动态 atexit 析构 | ??__F |
调用约定
MSVC 与 Itanium 差异最大的地方之一,调用约定被直接编进符号里,不同约定产生不同符号
| 访问权限 + 调用约定 | 编码 | 说明 |
|---|---|---|
public __cdecl | YA | 普通全局函数默认 |
public __stdcall | YG | Win32 API 常用 |
public __fastcall | YI | 寄存器传参 |
public __thiscall | QAE | 成员函数默认(32位) |
public __thiscall(64位) | QEA | x64 成员函数 |
static __cdecl 成员 | SA | 静态成员 |
private __thiscall | AAE | |
protected __thiscall | IAE |
32位和64位的编码也不同,因为 x64 只有一种调用约定(__fastcall 变体),MSVC 用 E 后缀区分
其实是 Windows 的⑩山的一种表现形式罢了
例子:
https://www.demangler.com/
这个网站是一个在线的 C++ name demangler,可以输入 mangled name 来查看它的解码结果,支持 Itanium 和 MSVC 两种 mangling 规则
举个例子
1 | ?fn@Bar@foo@@QBE?ANH@Z |
分析如下:
1 | ? 开头 |
用刚刚那个网站验证一下:
1 | double foo::Bar::fn(int)const |
一些碎碎念
编译器和工具的文档往往少得可耻
其实可以看出,Itanium 的 ABI 相对来说更可读,也更适合流式解析,设计干净,毕竟原来设计的初衷就是跨平台一致;而 Microsoft ABI 是没有官方文档的,虽然有社区逆向吧,但没有官方文档始终是一件很麻烦的事,并且模板代码生成的符号极长。Windows 为了兼容性做出了太多的妥协,这个我认为的设计比较混乱 Name Mangling 的规则其实只是 Windows ABI 复杂性的一个缩影
调用约定其实体现了两套 ABI 设计哲学最不同的一面:
Itanium 认为调用约定是平台/编译器的事,为了跨平台,不编进符号,同一个函数无论用什么调用约定,mangled name 不变
Microsoft 认为调用约定是函数类型的一部分,必须编进符号,主要还是Win32 历史上 __cdecl、__stdcall、__fastcall 并存,同一个名字不同调用约定是不同的函数,链接器必须能区分,但是,x64 时代只有一种调用约定,为了兼容,这部分的历史包袱没办法丢掉,于是现在的 mangled name 又臭又长
下面给出 Itanium ABI 官方文档链接:
LLVM 为了兼容 Microsoft ABI 整理过一份实现
llvm-project/clang/lib/AST/MicrosoftMangle.cpp at main · llvm/llvm-project
还有一份丹麦技术大学的研究员 Agner Fog 整理的一分文档,值得收藏:
浮点指令集
VFP(Vector Floating Point)向量浮点运算单元
- 支持常见的浮点计算,如加法、减法、乘法、除法、比较
- 支持向量 (Vector) 计算功能
- 在不支持 VFP 的处理器上,编译器会使用浮点支持软件库fplib实现CPU指令模拟浮点计算
SIMD 指令集
NEON 是 ARM 的 SIMD(Single Instruction, Multiple Data)指令集扩展,提供了对向量化计算的支持,适用于多媒体处理、信号处理、机器学习等领域
- NEON计算库:如Arm Compute Library、Arm Performance Libraries等
- 编译器支持:通过编译器的自动向量化,将C/C++代码自动编译为SIMD指令
- 内嵌函数:编译器会将NEON内嵌函数转换成使用NEON指令的汇编代码,并自动进行寄存器分配等处理
- 汇编代码:直接编写使用SIMD指令集的汇编代码
编译器优化会单独开一篇来讲