简介:《ARM汇编基础教程1》是面向ARM平台开发与逆向工程初学者的系统入门资料,围绕处理器版本与指令集关系、汇编与链接流程、数据类型与字节序、寄存器功能及函数调用约定等基础主题展开,能够帮助读者系统梳理ARM汇编的底层运行逻辑。内容整理为Word文档,共1个文件,压缩包约2.65MB,适合按章节阅读并随时标注疑点。文档详细解释了“.s”汇编文件经as汇编为.o目标文件、再由ld链接生成可执行程序的过程,并对比有符号/无符号的字、半字、字节类型与大端/小端字节序;同时梳理了ARMv6及以上版本中R0-R15、CPSR等寄存器的分工,以及前四个参数经R0-R3传递、SP管理栈空间、LR保存返回地址、PC指向执行流的调用约定。目前已有209人学习下载。教程规划了从ARM汇编简介、数据类型与寄存器,到指令集、内存读写、条件分支、栈和函数使用的系列路径,并附有拓展阅读和树莓派实践参考,既适合零基础入门,也可作为后续逆向工程与漏洞利用学习的衔接材料。
1. ARM汇编基础:为什么说寄存器比指令更需要先搞懂
ARM 汇编入门教程并不少,但大量资料要么是芯片手册的翻译腔,要么一上来就甩指令表,读者看完还是不知道寄存器之间怎么协作、一条指令执行时 CPU 内部到底发生了什么。这份《ARM汇编基础教程1》的价值在于它把 R0-R15 每一个寄存器的用途、CPSR 标志位的行为、栈帧从创建到销毁的完整生命周期都拆开讲了一遍,而且带真实的 GDB 调试数据,不是照着架构手册念定义。适合两类人:一是做嵌入式底层开发、想看懂反汇编输出和调用栈的开发者;二是刚进入二进制安全方向、需要在 ARM 平台写 shellcode 或做漏洞利用的初学者。教程里的坑——比如 PC 寄存器“加 8”现象、CMP 指令隐式减法对标志位的影响——都是实际调试中一定会撞上的问题,提前知道能省很多排查时间。
2. 寄存器与数据类型:R0-R15 的分工和边界条件
2.1 数据类型与符号扩展:载入存储时的“-h/-sh/-b/-sb”后缀到底怎么选
ARM 支持的数据类型和高级语言类似,按宽度分为字(Word,32 位)、半字(Halfword,16 位)、字节(Byte,8 位)。但和 x86 的一个关键差异是:ARM 在ldr(load)和str(store)指令中,对半字和字节的载入做了非常明确的符号扩展区分,写错了数据直接不对。
先看基础映射关系:-h表示无符号半字,-sh表示有符号半字;-b表示无符号字节,-sb表示有符号字节。字类型没有后缀扩展,因为 32 位刚好装满一个寄存器,不存在扩展问题。有符号类型可以表示正数和负数,所以取值范围相对小;无符号类型只含非负值,上限翻倍。举个例子,一个 8 位的有符号数范围是 -128 到 127,而无符号数是 0 到 255。这个差异在载入时会被放大——从内存加载一个字节到 32 位寄存器,无符号扩展会高位补零,有符号扩展则做符号位填充。
@ 假设内存地址 r0 处存放了一个字节 0x80 ldrb r1, [r0] @ 无符号载入:r1 = 0x00000080,高位补零 ldrsb r2, [r0] @ 有符号载入:r2 = 0xFFFFFF80,符号位扩展到高位 strh r1, [r0, #4] @ 存储半字:把 r1 的低 16 位写入 r0+4 处ldrb和ldrsb的差别是新手最容易忽略的:同样是读一个字节,寄存器里高 24 位的值是 0 还是0xFFFFFF,直接决定了后续算术运算和比较的结果是否和预期一致。做缓冲区处理时,如果源数据是uint8_t却用ldrsb载入,负数会被当成一个很大的正数参与计算,这个 bug 很难凭肉眼在反汇编里看出来。strh和strb一般不需要担心扩展问题,因为截断是明确的,但如果存进去的是有符号负数,再配合大小端字节序,读出来时就要注意字节排列方向了。
2.2 字节序由谁决定:CPSR 的 E 位和 ARMv6 的双端支持
字节序在 ARM 里不是写死的小端,它由程序状态寄存器 CPSR 的第 9 位(E 位)控制。E 位为 0 时使用小端序(little-endian),为 1 时使用大端序(big-endian)。注意 ARMv6 之前的处理器不支持运行时切换端序,E位在 ARMv6 开始才真正可写。调试时看到数据反了,第一反应不应该是怀疑编译器,而是先查当前处理器是否处于大端模式。
大端和小端的核心区别在于:一个 32 位字0x12345678,如果是小端序,在内存中是78 56 34 12;大端序则是12 34 56 78。这影响的是你从内存 dump 里手动还原数据结构时的阅读习惯。在 ARM 的反汇编调试中,GDB 默认按小端显示内存,如果你切到了大端模式,所有xd输出的字节序都会反过来,这时候不是内存坏了,是端序变了。
2.3 R0-R15 寄存器分工:通用、专用和函数调用约定
ARM 有 30 个 32 位通用寄存器,但基础编程能直接用的主要是 R0-R15。R0-R11 是真正意义上的通用寄存器,用来存临时数据、指针、中间计算结果。R12 是 IP(Intra-Procedure Call)寄存器,R13 是 SP(栈指针),R14 是 LR(连接寄存器),R15 是 PC(程序计数器)。CPSR 独立于这 16 个寄存器,存的是程序状态标志。
AAPCS(ARM 过程调用标准)规定了传参规则:函数的前 4 个参数依次放在 R0-R3 里,超过 4 个的参数压栈传递。返回值放 R0,64 位返回值用 R0-R1 组合。这个约定在写汇编和读反汇编时都要牢记——看到一个函数调用的反汇编里先mov r0, #1、mov r1, #2,再bl max,说明传给max两个参数分别是 1 和 2,函数内部肯定从 R0 取出结果。
R11 有双重身份:一是系统调用时存放系统调用号,二是作为栈帧指针(FP)追踪栈帧边界。R13 SP 指向栈顶,栈往下增长时 SP 做减法;想要在栈上分配 32 位空间,让 SP 减去 4 即可。R14 LR 在函数调用发生时更新为函数调用指令的下一条地址,子函数返回时用这个地址回到父函数的调用点。R15 PC 会在指令执行时自动增加,ARM 状态下每条指令 4 字节,Thumb 状态下每条 2 字节。
注意:ARM 的 PC 和 x86 的 EIP 有一个根本区别——x86 的 PC 永远指向下一条待执行指令,而 ARM 的 PC 在指令执行时实际指向当前指令地址加 8 字节(ARM 状态)或加 4 字节(Thumb 状态)。
2.4 PC 的“加 8”现象:三级流水线造成的阅读偏差
教程给了一个非常经典的例子:把 PC 的值存进 R0。代码写成这样:
.text .globl main main: mov r0, pc @ 把当前 PC 值存入 R0 mov r1, #2 add r2, r1, r1 bx lr用 GDB 单步执行时,GDB 显示的这条mov r0, pc指令地址是0xaaad43d8,但执行完这条指令后 R0 里的值却是0xaaad43e0,正好比显示的地址大了 8 字节。第一次看到这个结果,你可能会反复检查代码,甚至怀疑是不是处理器 bug。这不是 bug,而是三级流水线的历史设计。
ARM 的取指、解码、执行是三个独立硬件单元并行工作的。当 CPU 执行0xaaad43d8处的指令时,流水线已经把后面两条指令都取进队列了,PC 寄存器在硬件上实际指向的是0xaaad43d8 + 0x8。为了兼容老处理器,这个定义一直保留了下来。GDB 显示 PC 时做了逻辑修正,显示的是当前正在执行的指令地址,如果你按 GDB 显示的地址去算分支跳转偏移,结果会对不上。
3. ARM 与 Thumb 指令集:宽度、条件执行和栈的复杂性
3.1 ARM 状态与 Thumb 状态:32 位指令与 16 位指令怎么切换
ARM 处理器有两种工作状态:ARM 状态和 Thumb 状态。这不是运行模式,跟用户态/内核态没关系。ARM 状态下指令固定 32 位宽,Thumb 状态下指令是 16 位(Thumb-2 扩展后也有 32 位指令)。对写 shellcode 的人来说,Thumb 的价值在于:16 位指令编码里往往不容易出现\x00字节,可以把合法的 null 字节截断问题压到最低。
Thumb 有几个版本需要注意:Thumb-1 是 16 位指令,用于 ARMv6 及更早;Thumb-2 在 ARMv6T2 和 ARMv7 上引入,支持 16 位和 32 位混合指令并增加了条件执行;ThumbEE 则是在 Thumb-2 基础上针对动态代码生成场景的扩展。切换状态的机制是分支指令 BX 或 BLX:把目标寄存器地址的 LSB 置 1,处理器进入 Thumb 状态。你可能会担心这个+1会破坏对齐,不用担心,处理器取指时会忽略地址的最低位。
@ 切换到 Thumb 状态并跳转 adr r0, thumb_code @ 获取 thumb_code 的地址 add r0, r0, #1 @ 设置 LSB=1,表示目标状态是 Thumb bx r0 @ 分支并切换状态 .thumb @ 声明以下代码为 Thumb 指令 thumb_code: mov r1, #1 @ 16 位 Thumb 指令 bx lrARM 状态下的所有指令都支持条件执行,这是 ARM 指令集的一个显著优势。条件执行可以在不跳转的情况下根据标志位决定一条指令是否真正生效,比“比较后跳转”省了一个分支,也就少了分支预测失败的惩罚。Thumb-1 不支持条件执行,Thumb-2 通过 IT 指令(如果-则)弥补了这个能力,但 IT 指令后来在某些新架构中又被简化掉了——这增加了兼容层的复杂度。我的建议是:不要试图把所有版本的指令集变体背下来,只需要知道目标设备的 ARM 版本,去查对应手册确认 Thumb 支持情况。
3.2 指令模板和可变操作数:第二个操作数其实可以“先算再用”
ARM 指令的基本模板是:助记符 {S} {条件} Rd, Rn, Operand2。S 表示这条指令是否更新 CPSR 标志位(比如ADDS、SUBS),条件是条件执行后缀(EQ、NE、GT等),Rd 是目的寄存器,Rn 是第一操作数寄存,Operand2 是第二操作数,它可以是立即数、寄存器,也可以是经过桶形移位器处理后的寄存器。
桶形移位器是 ARM 特有的功能,它允许在一条指令内部完成移位和算术的组合,不需要单独的移位指令。最典型的用法是MOV R1, R0, LSL #1,这条指令把 R0 的值左移 1 位后存入 R1,效果等同于把 R0 乘 2。这种写法不只是少写一条指令,而是省掉了一次读取和一次写入,对循环高频段能显著减少指令周期数。移位操作有五种:LSL(逻辑左移)、LSR(逻辑右移)、ASR(算术右移,保留符号位)、ROR(循环右移)、RRX(带扩展位的循环右移 1 位)。
@ 用桶形移位器实现乘以 3 mov r0, #5 add r1, r0, r0, LSL #1 @ r1 = r0 + (r0 << 1) = 5 + 10 = 15 @ 等效于 r1 = r0 * 3 @ 用桶形移位器实现除以 4(无符号) mov r2, #16 mov r3, r2, LSR #2 @ r3 = 16 >> 2 = 4 @ 算术右移保留符号位 mov r4, #-8 @ r4 = 0xFFFFFFF8 mov r5, r4, ASR #1 @ r5 = 0xFFFFFFFC = -4逻辑左移和算术左移在左移方向上没有区别,低位都补零;但逻辑右移和算术右移不一样——逻辑右移高位补零,算术右移高位补符号位。处理有符号负数时如果错误使用LSR,-8 右移一位会变成 0x7FFFFFFC,而不是 -4,结果差得很远。这组指令在编写手写汇编算法代码时非常常用,编译器也会大量生成这类带移位的单条指令替代多条基础指令。
3.3 栈的四种实现:full/empty 和 ascending/descending 的组合
栈在 ARM 里不只是“一块内存”,它有两种维度的实现选择:栈指针指向当前数据还是下一个空位(full 或 empty),以及栈地址增长方向(ascending 或 descending)。组合出来就是四种栈模型:FA、FD、EA、ED。ARM 上最常用的是 FD(满递减栈)——SP 指向栈中最后一个有效数据,地址向下生长。push和pop是 STM/LDM 指令的别名,是为了方便阅读而存在的助记符,底层其实就是“多寄存器存储/载入”。
.text .global main main: mov r0, #2 @ r0 = 2 push {r0} @ 把 r0 压栈,SP -= 4,数据写入 [SP] mov r0, #3 @ r0 = 3,此时栈内容不变 pop {r0} @ 从栈顶载入 4 字节到 r0,SP += 4 bx lr这段代码执行push {r0}时,SP 从0xfffefac0减少到0xfffefabc,数据写入新 SP 指向的地址;执行pop {r0}时,从0xfffefabc读取数据恢复 R0,SP 回到0xfffefac0。注意 GDB 里查看栈空间的习惯和 x86 相反——x86 下压栈后新的数据在更小的地址,GDB 打印栈时你会看到地址从上往下减小,ARM 的 FD 栈也是同样的方向,但你在 GDB 里做栈回溯时,返回地址链是通过 FP 寄存器串起来的,而不是像 x86 那样跟着 ESP 附近的返回地址找。
栈模型不是随便选一个就能用。虽然 ARM 默认是 FD,但有些嵌入式 RTOS 或裸机环境会配置成 Empty Ascending 栈。处理器的硬件模式和编译器生成的代码必须匹配——编译器按 FD 模型生成 push/pop,操作系统按 EA 模型初始化栈,崩溃就是必然的。系统启动汇编里初始化 SP 寄存器时,一定要确认软件栈模型和硬件配置是一致的方向。
4. 函数与栈帧:prologue、body、epilogue 的完整生命周期
4.1 栈帧的结构:从 LR 保存到局部变量释放
函数用栈来保存局部变量和寄存器状态,而栈帧是栈中划分出的一个专属内存区域。栈帧的创建发生在函数的 prologue 中,销毁发生在 epilogue 中。栈帧指针 FP(也就是 R11)在函数开始时被设置为栈帧的底部,后续访问局部变量都基于 FP 的偏移量来定位,而不是基于不断变化的 SP。栈帧里通常包含:返回地址 LR、前一个帧的 FP 值、需要保存的寄存器、超过 4 个的参数、局部变量。
栈帧存在的意义是让每个函数拥有一片独立的“工作台”,互不干扰。非叶函数调用子函数时,如果不保存 LR,子函数内部的bl会把 LR 覆盖,返回时就会跳到错误的地方。所以 prologue 里push {r11, lr}是标准动作。
4.2 prologue 的设计:保存现场和为局部变量分配空间
标准 prologue 分三步:先把 FP 和 LR 压栈,把 SP 的当前值赋给 FP,再从 SP 减掉局部变量所需的大小。看一个具体模板:
push {r11, lr} @ 保存 FP 和 LR 到栈 add r11, sp, #0 @ 设置栈帧底部,r11 = sp sub sp, sp, #16 @ 分配 16 字节局部变量空间这里add r11, sp, #0虽然没有改变任何数值,但它建立了一个稳定的栈帧基准点。之后 SP 会因为函数内进一步的压栈操作不断变化,但只要 R11 不变,[r11, #-N]就能稳定访问到局部变量。sub sp, sp, #16分配了 16 字节的局部缓存区。设计 prologue 时要注意:分配的栈空间大小应该按 8 字节对齐,ARMv7 架构上 ABI 要求栈保持 8 字节对齐,如果局部变量加参数需要 20 字节,应该分配 24 字节。对齐不正确会在调用的底层库中使用ldrd/strd这类需要双字对齐的指令时产生对齐异常。
4.3 body 的执行逻辑和参数传递
函数体负责实际任务。最常见的模式是设置参数后分支调用其他函数:
mov r0, #1 @ max 函数的第一个参数 mov r1, #2 @ max 函数的第二个参数 bl max @ 调用 max,LR 被更新为下一条指令地址这段代码展示了两件事:一是调用前参数如何放入 R0-R3,二是bl指令怎么为返回做好准备。bl max执行时会把下一条指令的地址写入 LR,然后跳转到max的入口。被调用函数返回时,会使用保存在栈上的 LR 值做恢复——这就是为什么 prologue 里push {r11, lr}一定要包含 LR。超过 4 个的参数用栈传递,调用方在bl之前把第 5 个及之后的参数压栈,被调用方通过栈帧里的偏移量访问。返回值在 R0,如果返回 64 位数值,则 R0 存低 32 位,R1 存高 32 位。
4.4 epilogue 的反向工程:栈帧销毁与 PC 跳转
epilogue 是 prologue 的镜像操作。它要做的事是:恢复 SP 到栈帧真正的底部,恢复之前保存的寄存器值,最后把存储在栈上的 LR 弹入 PC 实现跳转返回。
sub sp, r11, #0 @ 把 SP 恢复到栈帧底部 pop {r11, pc} @ 恢复 FP,同时把 LR 值弹给 PC这里pop {r11, pc}是 ARM 汇编里一个技巧性的简写:弹出的第一个值恢复 R11,弹出的第二个值不再放回 LR,而是直接写入 PC 完成跳转。之所以能做到,是因为硬件允许在 LDM/POP 指令中写 PC。如果不这样写,你就得pop {r11, lr}之后再来一条bx lr,多执行一条指令。直接pop {r11, pc}是编译器和手写汇编常用优化,它的效果完全等于“LR 恢复到寄存器,然后跳转到 LR 指向的地址”。
4.5 叶函数和非叶函数的区别:谁的 LR 必须保存
写 ARM 汇编时判断一个函数是否需要保存 LR,看它是不是叶函数。叶函数就是函数体内没有任何调用其他函数的指令的函数;非叶函数则至少有一条bl或bx调用。区别决定了 prologue 和 epilogue 的写法。
叶函数不调用其他函数,所以 LR 在函数执行期间不会被覆盖,通常可以省略push {r11, lr}。很多编译器对叶函数只生成bx lr结尾,完全不碰栈。非叶函数必须保存 LR,否则内部的一次bl就会把唯一的返回地址抹掉。教程里说的动画演示,其实就是这个差异:叶函数可以在 prologue 里不保存 LR,非叶函数必须保存。看一个具体的非叶函数例子:
.text .globl caller caller: push {r11, lr} @ 保存 FP 和 LR add r11, sp, #0 @ 新栈帧底部 sub sp, sp, #8 @ 分配局部空间 mov r0, #3 @ 给 callee 的第一个参数 bl callee @ 调用 callee,此时 LR 被覆盖 mov r3, r0 @ 把 callee 的返回值移到 r3 sub sp, r11, #0 @ 恢复 SP pop {r11, pc} @ 恢复 FP,LR 弹入 PC 完成返回如果这个caller函数忘了在 prologue 里保存 LR,bl callee执行后 LR 指向的就是callee内部的返回地址,而caller自己的返回地址已经丢失了。这个 bug 特别隐蔽,因为单步调试时你不一定每次都走进callee,只有当整个调用链跑完时程序才在返程路上崩掉。
5. 常见问题排查:条件标志位、PC 偏移和 Thumb 切换的实操坑
5.1 N、Z、C、V 标志位的置位条件:CMP 指令为什么能影响 Carry
CPSR 寄存器里的 N、Z、C、V 四个标志位是条件执行的基础。N 位在操作结果为负数时置 1;Z 位在结果为零时置 1;C 位是进位标志,加法结果大于等于 2^32 时置 1,减法的处理规则则相反——相减结果为正或零时置 1,有借位时置 0;V 位在结果超出 32 位有符号数能表示的范围时置 1。最容易误判的是 C 位在减法里的逻辑:很多人只记得“加法进位置 1”,一遇到CMP、SUBS就懵了。
CMP指令不保存计算结果,只根据隐式减法更新 CPSR。举例:
mov r1, #1 mov r2, #2 cmp r1, r2 @ 计算 1-2=-1,N 位置 1,C 位清零(有借位) cmp r2, r1 @ 计算 2-1=1,Z 清零,C 位置 1(结果非负) cmp r2, r2 @ 计算 2-2=0,Z 位置 1,C 位置 1(没有借位)注意第三行的输出:cmp r2, r2计算结果是 0,此时 Z 位和 C 位同时为 1。这符合 C 位“减法结果为正或零时置 1”的定义,但因为 Z 位也为 1,所以条件中最常使用的是EQ(相等)和NE(不等),而不是单独的 C 位。如果你要用无符号数比较大小跳转,记得用BHI(高于)和BLS(低于或相同),它们分别对应C=1 and Z=0与C=0 or Z=1的组合条件。用有符号数比较则要关注GT、LT这类组合条件,它们同时检查 N 位和 V 位。
5.2 调试器显示的 PC 值和真实 PC 值不一致
这是 ARM 调试中最常见的“玄学”问题之一。现象是:GDB 里的info registers pc显示的是当前执行指令的地址,但你在指令里读 PC(比如mov r0, pc)后得到的值,却比 GDB 显示的地址大 8 个字节。原因我在第 2 章已经解释过——ARM 的三级流水线使硬件 PC 总是指向当前指令 + 8 的位置。
实操中这个坑会在两种情况下咬人:一是你用 PC 做位置无关代码(PIC)的地址计算,比如adr r0, symbol或add r0, pc, #offset时,忘记加 8 会导致算出来的地址偏了两个指令的长度;二是逆向分析时手工计算分支目标——反汇编工具显示的 BL 指令目标地址是已经调整好的,但如果你从原始字节里手动算偏移,就必须把 PC + 8 这个常量代入公式。常见的解决习惯是:不要直接读 PC 来取当前指令地址,改用adr指令,它是由汇编器自动处理偏移修正的伪指令,能帮你避开这个硬件偏移。
@ 错误做法:从 PC 直接推断地址 mov r0, pc @ r0 = 当前指令地址 + 8 @ 推荐做法:让汇编器处理偏移 adr r0, my_label @ 汇编器自动计算含流水线偏移的地址5.3 状态切换导致的分支地址没加 1
用 BX 或 BLX 从 ARM 态切到 Thumb 态时,目标地址的 LSB 必须置 1。漏掉这个 1 的后果是:处理器不会切到 Thumb 态,而是直接进入 ARM 态,把 Thumb 指令流当 32 位 ARM 指令解码,执行结果全是乱码,GDB 里看到 PC 停在完全离谱的地址上。这类问题有一种典型的表现:代码看起来逻辑完全正确,但一跑到切换点就SIGILL。排查顺序是:先看bx的目标寄存器值最低位是不是 1,再确认目标代码段确实汇编成了 Thumb 指令。有些汇编器在处理函数指针时要你手动| 1,有些会自动加,前者的代码在后者的环境下会因为你多写了or 1而出问题——注意不同汇编器的差异。
此外,如果用adr获取 Thumb 标签的地址,标签在.thumb段内时汇编器会自动置 1,但你手动用movw/movt构造地址就必须自己加。这就是 shellcode 里经常出现各种偏移错半拍的原因。检查时用好 GDB 的disassemble /m查看指令编码宽度,可以快速确认当前模式。
6. 进阶实践:用 GDB 的一组分步命令验证寄存器行为和栈帧变化
6.1 用 GDB 命令序列复现 PC 偏移和标志位变化
写汇编入门时只看书不调试等于白看,因为寄存器行为只有通过单步观察才能真正形成直觉。我建议你把教程里的三段代码分别存成.s文件,用as和ld编出可执行文件之后,用下面这组命令逐条验证寄存器:
as pc_example.s -o pc_example.o ld pc_example.o -o pc_example gdb ./pc_example # 进入 GDB 后执行: # (gdb) break main # (gdb) run # (gdb) info registers pc r0 sp lr cpsr # (gdb) stepi # (gdb) info registers r0逻辑说明:break main定位到入口,info registers同时看 PC 和 CPSR 两个关键寄存器,每次stepi后都对比 R0 的变化。我在验证 PC 加 8 时,习惯在mov r0, pc前后各打印一次pc和r0,能明确看到从0x...d8变成0x...e0的过程。GDB 里cpsr的显示是一组标志位粗体字,N、Z、C、V哪个为 1 一眼就能看出来,这比手动算两个数的加减快得多。
tui reg命令可以开启寄存器实时窗口:
tui reg layout asm si这样每次单步一行汇编,右边寄存器窗口同步刷新,R0-R15 的当前值全在眼前。验证标志位时注意cpsr位布局:N 在 31 位、Z 在 30 位、C 在 29 位、V 在 28 位。手动计算时可以直接p/x $cpsr看整数值,比如0x60000000说明 N 和 Z 都被置位。
6.2 追踪一个带局部变量函数从 prologue 到 epilogue 的全过程
为了完整验证第 4 章讲的栈帧生命周期,我建议你编译下面这个小例子,逐条观察:
.text .globl add_and_return add_and_return: push {r11, lr} @ prologue:保存 FP 和 LR add r11, sp, #0 @ 设置 FP sub sp, sp, #8 @ 分配 8 字节局部空间 str r0, [r11, #-4] @ 把第一个参数存入局部变量槽 ldr r1, [r11, #-4] @ 载回 R1 add r0, r1, #5 @ r0 = r1 + 5 sub sp, r11, #0 @ epilogue:恢复 SP pop {r11, pc} @ 恢复 FP 并从 LR 地址返回 .size add_and_return, .-add_and_return编译链接后,在 GDB 里break add_and_return,然后分别在这个函数的每一行单步。运行到str r0, [r11, #-4]后打印x/8wx $r11-8,能看到栈上的值是传入的参数。等pop {r11, pc}执行完,info registers pc会显示当初bl的下一条指令地址,这时就明白为什么pop {pc}能同时完成“恢复寄存器”和“跳转返回”两件事。整个流程中 SP 先减后加,FP 保持恒定,这是栈帧设计的核心思想——校验一个函数是否正确地保存和恢复现场,就盯着 R11 的值是否在 prologue 之后直到 epilogue 之前从未改变过。
我当时第一次调 ARM 汇编时,就是靠这一组命令把一个非叶函数的整个生命周期摸熟的。从那以后,每次写 ARM 汇编,无论代码多简单,我都会强制走一遍 GDB 的tui reg+stepi流程确认寄存器状态,因为汇编的坑往往出现在“你觉得自己知道”而实际上寄存器没有按预期工作的地方。希望这份调试习惯能帮到你少走弯路。
本文还有配套的精品资源,点击获取