版权归作者所有,如有转发,请注明文章出处:https://cyrus-studio.github.io/blog/

ARM64可执行程序的生成过程

根据 ARM64 可执行程序生成的四个主要步骤:预处理、编译、汇编、链接,我们可以详细分解整个过程如下

1. 预处理(Preprocessing)

预处理是源代码文件在正式编译前的准备工作,由预处理器完成。其主要任务包括:

  • 宏替换:处理 #define 定义的宏,将代码中出现的宏替换为实际值。

  • 文件包含:处理 #include 指令,将所需的头文件内容直接插入代码中,确保所有引用的函数和变量声明都在同一文件中。

  • 条件编译:处理 #ifdef、#ifndef 等条件编译指令,以控制代码中不同部分的编译。

预处理后的输出仍然是代码文件,但没有任何宏、条件编译等指令,通常以 .i 或 .ii 作为扩展名。

2. 编译(Compilation)

编译器(如 GCC 或 Clang)将预处理后的代码文件转换为汇编代码,产生汇编语言表示的文件。此阶段包括以下子步骤:

  • 词法分析:将源代码转化为基本的语法单元(token),如变量名、运算符、关键字等。

  • 语法分析:将代码组织成抽象语法树(AST),根据编程语言的语法规则生成层次结构。

  • 语义分析:检查语法树的正确性,如类型检查、作用域检查等,确保代码符合语言语义。

  • 中间代码生成:编译器生成与平台无关的中间代码,方便后续优化。

  • 优化:编译器对中间代码进行优化,如消除冗余代码、进行循环优化等,以提升程序效率。

  • 生成汇编代码:编译器将优化后的中间代码转换为特定平台(如 ARM64)的汇编代码,通常输出 .s 文件。

编译阶段的最终输出是汇编代码文件,包含了基于 ARM64 指令集的指令。

3. 汇编(Assemble)

汇编器(如 GNU Assembler,as)将汇编代码文件(.s 文件)转换为机器代码,生成二进制的目标文件(.o 文件)。目标文件包含了二进制的机器指令,但符号引用还未解析,因此目标文件本身并非独立的可执行文件。

汇编阶段的主要工作包括:

  • 指令翻译:将汇编语言指令转换为 ARM64 指令集对应的二进制机器指令。

  • 符号表生成:记录所有函数和变量的符号地址,以便链接阶段使用。

  • 机器码生成:生成目标文件(.o),将每条指令翻译成可执行的机器码。

目标文件是 ARM64 可执行程序生成过程中不可或缺的中间文件。

4. 链接(Linking)

链接器(如 GNU Linker,ld)将一个或多个目标文件链接在一起,并解决外部依赖,生成最终的可执行文件。链接的过程分为静态链接和动态链接两种:

  • 静态链接:将程序所需的库代码直接嵌入到可执行文件中,生成一个完全自包含的文件。

  • 动态链接:生成的可执行文件依赖外部共享库(如 .so 文件),在程序运行时加载这些共享库。

链接阶段的关键步骤包括:

  • 符号解析:将不同目标文件中的符号(如函数和变量)解析为对应的内存地址,解决跨文件调用。

  • 重定位:调整目标文件中指令和数据的地址,使得所有模块可以在一个统一的地址空间中正常运行。

  • 生成可执行文件:链接器根据 ELF(Executable and Linkable Format)等格式生成最终的可执行文件,包含程序代码段、数据段、以及其他加载器所需的元信息。

最终生成的文件就是 ARM64 平台上的可执行文件,它包含所有代码和数据,并可以被加载器直接加载到内存中运行。

使用 NDK 中的 clang 生成 ARM64 程序

环境准备

首先先安装 NDK https://developer.android.com/studio/projects/install-ndk?hl=zh-cn image.png

clang 就存在 $NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin 目录下,$HOST_TAG 就你系统的架构,比如 windows 下就是 windows-x86_64 image.png https://developer.android.com/ndk/guides/other_build_systems?hl=zh-cn

clang 适用于 C 编译,而 clang++ 专为 C++ 编译和链接 C++ 标准库设计。 image.png

打开命令行,把 clang 目录添加到环境变量

在 CMD 中

set PATH=D:\App\android\sdk\ndk\27.1.12297006\toolchains\llvm\prebuilt\windows-x86_64\bin;%PATH%

在 PowerShell 中

$env:PATH="D:\App\android\sdk\ndk\27.1.12297006\toolchains\llvm\prebuilt\windows-x86_64\bin;" + $env:PATH

编写源码

创建 hello.c,源码如下

#include <stdio.h>

int main() {
    printf("Hello, ARM64\n");
    return 0;
}

预处理(Preprocessing)

使用 -E 选项生成预处理后的文件:

  • -E:只进行预处理,不编译。

  • hello.i:生成的预处理文件,包含所有展开的宏和头文件内容。

aarch64-linux-android21-clang -E hello.c -o hello.i

hello.i 文件是预处理后的 C 文件,包含了展开的宏、包含的头文件和预处理指令。

1. 预处理器的行号指令

以 # 开头的行是预处理器的行号指令。

行号指令会指明代码行对应的源文件。编译器在遇到错误或警告时,可以正确地输出源文件名称,帮助开发者准确找到问题所在。例如,# 1 “hello.c” 表示以下代码行来自 hello.c 文件的第 1 行。 image.png

2. 头文件展开

#include <stdio.h> 会被完整展开,即 stdio.h 及其依赖的所有头文件内容被直接插入文件中。 image.png

image.png

3. 宏定义和类型声明

<stdio.h> 等头文件中定义的各种宏、类型和函数声明也在 hello.i 中展开。

image.png

image.png

4. 源代码内容

最后包含了源文件 hello.c 中的实际代码内容。 image.png

编译(Compilation)

使用 -S 选项生成汇编代码文件:

  • -S:将 C 源文件编译为汇编代码。

  • hello.s:输出的 ARM64 汇编代码文件。

aarch64-linux-android21-clang -S hello.c -o hello.s

汇编(Assemble)

使用 -c 选项将汇编代码转换为二进制目标文件:

  • -c:生成目标文件,不进行链接。

  • hello.o:生成的二进制目标文件,包含 ARM64 指令的机器码。

aarch64-linux-android21-clang -c hello.s -o hello.o

链接(Linking)

最终将目标文件链接成可执行文件:

  • hello.o:输入的目标文件。

  • -o hello_arm64:生成的最终可执行文件,适用于 ARM64 架构。

aarch64-linux-android21-clang hello.o -o hello_arm64

执行上述命令后,会得到以下文件:

  • hello.i:预处理文件

  • hello.s:汇编代码文件

  • hello.o:目标文件

  • hello_arm64:最终的 ARM64 可执行文件

推送到 android 设备中运行

将生成的 hello_arm64 可执行文件上传到 ARM64 设备(如 Android)并运行

adb push hello_arm64 /data/local/tmp/
adb shell chmod +x /data/local/tmp/hello_arm64
adb shell /data/local/tmp/hello_arm64

成功运行后,应在控制台上看到输出 截图.png

直接生成可执行程序

执行下面的命令自动完成预处理、编译、汇编和链接四个步骤,从源代码 hello.c 生成最终的可执行文件 hello_arm64。

aarch64-linux-android21-clang hello.c -o hello_arm64

ARM64 寄存器

1. 通用寄存器

ARM64 共有 31 个通用寄存器,分别为 X0 到 X30,它们都是 64 位(8 个字节)宽。每个寄存器的用途如下:

  • X0 - X7:用于函数调用的参数传递和返回值存储。通常,函数的前 8 个参数使用这几个寄存器传递,函数的返回值也存储在 X0 中。

  • X8:常被称为 “间接结果寄存器” 或 “平台寄存器”,用于存储系统调用号等特定平台数据。

  • X9 - X15:可以作为临时寄存器,由函数随意使用,通常不保存调用者的数据。

  • X16 - X17:又称 “跳板寄存器”,用于调用系统函数或执行函数跳转(如共享库调用)。

  • X18:线程寄存器,通常用于存储线程特定数据(TPIDR_EL1 寄存器也可用于类似目的)。

  • X19 - X28:保存调用者的数据,即调用方保存寄存器。函数调用时需要将这些寄存器保存到栈上,防止数据丢失。

  • X29(FP):帧指针(Frame Pointer),用于指向当前函数栈帧的基址。它使得调试器和编译器可以通过 X29 轻松访问栈帧内的局部变量和参数。。

  • X30(LR):链接寄存器(Link Register),保存函数调用的返回地址。

  • XZR/WZR:零寄存器(Zero Register)。读时总是返回 0,写时数据会被丢弃。

如果只使用下半部分(32 位),则用 W0 到 W30 表示。

2. 堆栈指针寄存器

SP:堆栈指针(Stack Pointer),指向当前栈顶的位置,负责栈的动态管理,随着函数调用和返回而变化。

在每次执行函数调用时,SP 会调整以分配或释放函数的栈帧空间。栈帧空间可以用于保存局部变量、传递参数和返回地址等。

以下面汇编代码为例

.text:0000000000025230                               var_18= -0x18     ; 局部变量 var_18 相对于 SP 的偏移量
.text:0000000000025230                               var_10= -0x10     ; 局部变量 var_10 相对于 SP 的偏移量
.text:0000000000025230                               var_8= -8         ; 局部变量 var_8 相对于 SP 的偏移量
.text:0000000000025230

.text:0000000000025230 FF 83 00 D1                   SUB             SP, SP, #0x20     ; 为当前栈帧分配 0x20 字节的空间
.text:0000000000025234 E0 0F 00 F9                   STR             X0, [SP,#0x20+var_8]    ; 将参数 X0 存储到 [SP - 0x8] 位置
.text:0000000000025238 E1 0B 00 F9                   STR             X1, [SP,#0x20+var_10]   ; 将参数 X1 存储到 [SP - 0x10] 位置
.text:000000000002523C E0 07 00 FD                   STR             D0, [SP,#0x20+var_18]   ; 将浮点数 D0 存储到 [SP - 0x18] 位置
.text:0000000000025240 E0 07 40 FD                   LDR             D0, [SP,#0x20+var_18]   ; 从 [SP - 0x18] 位置加载浮点数 D0
.text:0000000000025244 01 10 6E 1E                   FMOV            D1, #1.0                ; 将浮点数 1.0 赋给寄存器 D1
.text:0000000000025248 00 28 61 1E                   FADD            D0, D0, D1              ; D0 = D0 + D1,执行浮点数加法操作
.text:000000000002524C FF 83 00 91                   ADD             SP, SP, #0x20           ; 恢复栈帧,将 SP 加回 0x20 字节
.text:0000000000025250 C0 03 5F D6                   RET                                      ; 返回到调用函数

函数开始时的栈布局(分配栈帧后)

高地址
 ┌───────────────────┐
 │调用者的栈帧       │
 ├───────────────────┤
 │ D0 的值           │ <- SP + 0x20 - 0x18 (var_18)
 ├───────────────────┤
 │ X1 参数           │ <- SP + 0x20 - 0x10 (var_10)
 ├───────────────────┤
 │ X0 参数           │ <- SP + 0x20 - 0x08 (var_8)
 ├───────────────────┤
 │                  │
 └───────────────────┘ <- SP (当前函数栈帧的底部)
低地址

在ARM64的栈结构中,栈指针(SP)通常指向栈帧的底部(也就是较低地址的位置)。当函数分配栈帧时,比如 SUB SP, SP, #0x20,SP指针会向低地址方向移动,以创建一个新的栈帧空间。

在这个分配的栈帧中:

  • var_18 = -0x18 表示的是一个相对于当前 SP 的偏移量。例如,[SP, #0x20 + var_18] 实际上解读为 [SP - 0x18]。

  • 负偏移量是因为这些局部变量存储在 SP 之上(更高的地址),而 SP 的值始终指向栈帧的底部(低地址)。

在函数返回之前,ADD SP, SP, #0x20 恢复了堆栈指针,使SP回到调用函数之前的位置。这时的栈布局如下

高地址
 ┌───────────────────┐
 │调用者的栈帧       │ <- 恢复栈帧后的 SP
 ├───────────────────┤
 │                  │
 └───────────────────┘ 
低地址

3. 特殊寄存器

PC(程序计数器):指向当前正在执行的指令地址。

PSTATE:程序状态寄存器,包含条件标志、控制标志等(类似于 ARM 32 位架构中的 CPSR),影响程序的执行流程。

4. 浮点和 SIMD 寄存器

ARM64 还包含 32 个 128 位的浮点和 SIMD(Single Instruction, Multiple Data)寄存器。它们分别为:V0 - V31,用于存储浮点数、矢量数据。每个寄存器可以分成 128 位、64 位、32 位、16 位等不同大小的子寄存器,以适应不同的数据类型。

每个浮点/ SIMD 寄存器可以分为不同的宽度来操作:

  • Q0 - Q31:表示 128 位宽度。

  • D0 - D31:表示 64 位宽度(双精度浮点数)。

  • S0 - S31:表示 32 位宽度(单精度浮点数)。

  • H0 - H31 和 B0 - B31:分别表示 16 位和 8 位宽度,用于 SIMD 数据。

例如,下面的汇编代码中,FADD D0, D0, D1 执行的了浮点运算 ,结果存放在浮点寄存器 D0 中。而且由于这是一个浮点运算返回值,根据 ARM64 约定,浮点返回值保存在 D0 而不是 X0,所以无需将 D0 的结果移动到 X0。

.text:0000000000025230 FF 83 00 D1                   SUB             SP, SP, #0x20
.text:0000000000025234 E0 0F 00 F9                   STR             X0, [SP,#0x20+var_8]
.text:0000000000025238 E1 0B 00 F9                   STR             X1, [SP,#0x20+var_10]
.text:000000000002523C E0 07 00 FD                   STR             D0, [SP,#0x20+var_18]
.text:0000000000025240 E0 07 40 FD                   LDR             D0, [SP,#0x20+var_18]
.text:0000000000025244 01 10 6E 1E                   FMOV            D1, #1.0
.text:0000000000025248 00 28 61 1E                   FADD            D0, D0, D1
.text:000000000002524C FF 83 00 91                   ADD             SP, SP, #0x20 ; ' '
.text:0000000000025250 C0 03 5F D6                   RET

使用 GDB GEF 调试 C/C++ 程序

GDB(GNU Debugger)是一款功能强大的调试工具,用于调试 C、C++ 等语言的程序。

GDB 是一种命令行调试器,支持代码的断点设置、变量查看、内存查看、逐行跟踪执行等功能。然而,默认的 GDB 命令行界面和输出风格相对较为简单和基础,难以直观展示复杂数据结构或高级内存布局。

GEF 是 GDB Enhanced Features 的简称,它作为 GDB 的插件,为其添加了许多便捷功能和改进的界面。GEF 的功能涵盖内存管理、反汇编、寄存器状态查看、堆栈检查等。

GEF项目地址:https://github.com/hugsy/gef

1. 安装 GEF

先更新包管理器并安装 gdb 和 gcc

sudo apt update
sudo apt install gdb

使用以下命令来下载并安装 GEF

bash -c "$(wget https://gef.blah.cat/sh -O -)"

安装完成后,可以通过在终端中启动 GDB 来验证

cyrus@cyrus:/mnt/case_sensitive$ gdb
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
GEF for linux ready, type `gef' to start, `gef config' to configure
93 commands loaded and 5 functions added for GDB 12.1 in 0.00ms using Python engine 3.10
GEF for linux ready, type `gef' to start, `gef config' to configure
93 commands loaded and 5 functions added for GDB 12.1 in 0.00ms using Python engine 3.10
gef➤

2. 本地调试

获取当前系统架构

uname -m
x86_64

编写源码 hello_gdb_gef.c

#include <stdio.h>

int main() {
    printf("Hello, GDB GEF!\n");
    return 0;
}

安装 gcc 编译器

sudo apt install gcc

编译源码

gcc -o hello_gdb_gef hello_gdb_gef.c -no-pie

执行 hello_gdb_gef 输出正常

./hello_gdb_gef
Hello, GDB GEF!

使用 gdb 调试 hello_gdb_gef

# 启动 gdb
gdb -q hello_gdb_gef

# 在 main 函数下断点
b main

# 运行程序
run

# step over
ni

GEF中相关调试窗口介绍:

  • registers(寄存器窗口):显示当前 CPU 寄存器的内容。

  • stack(堆栈窗口):显示当前栈帧的内容。

  • code(代码窗口):显示当前执行位置附近的汇编指令。

  • arguments(参数窗口):显示当前函数调用的参数。

  • threads(线程窗口):显示当:前程序中的所有线程及其状态。

  • trace(跟踪窗口):显示调用堆栈的跟踪信息(函数调用的调用栈)。

image.png

3. 远程调试

由于 gdbserver 从 NDK r23 开始被移除(因为官方已经全面转向 LLDB 作为调试器)。可以从下面 Github 链接下载 gdbserver

https://github.com/topjohnwu/FrankeNDK/blob/master/prebuilt/android-arm64/gdbserver/gdbserver

把 gdbserver 推送到 android 设备并增加执行权限

# 把 gdbserver 推送到设备 /data/local/tmp 目录下
adb push "D:\App\android\sdk\ndk\android-arm64\gdbserver" /data/local/tmp/gs

# 添加可执行权限
adb shell chmod +x /data/local/tmp/gs

启动 gdbserver

# 启动 gdbserver 在自定义的 4321 端口调试 hello_arm64
adb shell /data/local/tmp/gs :4321 /data/local/tmp/hello_arm64

Process /data/local/tmp/hello_arm64 created; pid = 10866
Listening on port 4321

并通过 adb 转发设备端口到转本地

adb forward tcp:4321 tcp:4321

启动 gdb 并链接 gdb server

gdb
gef➤  target remote 127.0.0.1:4321

接下来就可以开始调试了。

3. 常用动态调试指令

# 反汇编 main 函数
disassemble main

# 在 main 函数入口下断点
break main
# 或者
b main

# 在指定地址下断点
b *0x401136

# 显示当前断点列表
info b

# 删除编号为 1 的断点
delete 1
# 或者
d 1

# 删除所有断点
d

# 运行程序
run 
        
# 继续执行
c

# 单步步入(汇编层)
si

# 单步步入(源码层)
s

# 单步步过(汇编层)
ni

# 单步步过(源码层)
n

# 取指定地址下的值并以十六进制打印
p/x *0x402004

# 打印字符串
x/s 0x402004

# 退出调试 
exit
# 或者
q