简介:《现代汇编语言ARMv8》配套电子书是一份聚焦Armv8-A汇编编程的系统学习材料,面向具备一定计算机体系结构基础、希望深入理解32位、64位与SIMD指令集的开发者、嵌入式工程师及高校学生。内容以Daniel Kusswurm所著《Modern Arm Assembly Language Programming》为主线,从ARMv8架构概述切入,依次展开AArch32/Thumb-2、AArch64及SIMD指令集,并结合汇编语言编程场景说明其在操作系统、驱动程序和性能敏感应用中的使用方式,帮助读者建立从指令编码到实际编程的完整认知。压缩包内共1个PDF文件,大小约7.22MB,单文件结构便于集中检索与通读。目前已有298人学习,适合作为ARMv8汇编入门与查阅手册,也可配合实验对照指令集差异、理解SIMD并行计算思路,逐步补齐底层开发所需的知识拼图。
1. 一处最容易踩空的断层:条件执行在 AArch32 与 A64 里根本不是一回事
翻现代汇编语言ARMv8这份资料的目录时,前八章讲的全是 Armv8-A 的 32 位编程:寄存器、栈、数组、矩阵、浮点、SIMD。很多人以为把同一段循环从 32 位版本改写为 64 位版本只是很简单的替换,结果编译能通过,跑起来结果全错。问题根本不在指令记错,而在于 AArch32 中“每条数据指令后面都能挂一个条件码”这套机制,在 A64 里被整体移除了。这个改动背后是寄存器文件从 16 个扩张到 31 个、32 位定长编码位不够分的结果。把这条线搞清楚,后面栈帧、SIMD、浮点调用约定才有统一的解释框架。
2. 寄存器模型:为什么 AArch32 与 AArch64 不能互相翻译
2.1 从 R0-R15 到 X0-X30:多出来的寄存器改变了什么
AArch32 的通用寄存器是 16 个 32 位寄存器,编号 R0 到 R15,其中 R13 约定为栈指针 SP,R14 是链接寄存器 LR,R15 是程序计数器 PC。这三个角色都是“兼职”——它们同时还是普通的数据寄存器,可以被绝大多数数据处理指令直接读写。
AArch64 把这个模型拆掉了。X0 到 X30 是 31 个 64 位通用寄存器,低 32 位用 W0 到 W30 访问。X30 专职做链接寄存器,但 SP 被拎出来成为独立寄存器,PC 也不再是可寻址的通用寄存器,另外还有一个零寄存器 XZR / WZR,读出来永远是 0,写进去直接丢弃。
数量变化只是表象,真正的差异在于“哪些寄存器可以作为指令的哪个操作数”。A64 里 SP 只能出现在有限的几个位置:ADD/SUB 的立即数形式、LDR/STR 的基址、以及 MOV 到/从 SP 的少数几条。它不能作为第二个源寄存器参与任意算术。
// AArch32:SP 就是 R13,几乎任何数据处理指令都能拿它当普通寄存器用 add r0, r13, #16 // 合法:SP 的当前值加 16 写进 r0 ldr r1, [r13, #-4]! // 前索引:先 r13 -= 4,再从新地址读 // AArch64:SP 的使用位置被编码空间限制住了 add x0, sp, #16 // 合法,但立即数只有 12 位,范围 0..4095 ldr x1, [sp, #-16]! // 前索引回写仍然保留 mov x0, sp // 合法:SP 到通用寄存器 // add x0, x1, sp // 非法:SP 不能作为第二个源寄存器第一段代码里add r0, r13, #16之所以能成立,是因为 A32 的数据处理指令编码里三个操作数各占 4 位寄存器编号,R13 与 R0 在编码层面没有任何区别。A64 把寄存器编号扩到 5 位(31 个寄存器 + 零寄存器),操作码位被挤压,SP 只能退化成“特例编码”——在某些指令里,编码值 31 表示零寄存器,在另一些指令里表示 SP,由指令自己决定。这就是为什么同一条ADD,换个操作数位置就编不过。
我一般会这么理解:A32 是“寄存器人人平等”的模型,硬件把 SP、LR、PC 当作约定而非结构;A64 是“角色分离”的模型,把特殊用途显式编码进指令格式里。前者灵活但容易写出依赖副作用的代码,后者约束强但行为可预测。
2.2 APSR 与 NZCV:条件标志的存放位置变了,语义也变了
AArch32 里有一个应用层可见的状态寄存器 APSR,里面装着 N(负)、Z(零)、C(进位)、V(溢出)四个条件标志,外加 Q 和 GE 位。关键在于:A32 的绝大多数数据处理指令会选择性地更新这四个标志,而且每条指令后面可以附加一个 4 位的条件码后缀,形成所谓的“条件执行”。
// AArch32:一段求绝对值的代码,靠条件执行做到零分支 cmp r0, #0 // 更新 N/Z/C/V rsblt r0, r0, #0 // 仅当上一次比较结果为「小于」时才执行 // AArch64:同样的语义没有条件后缀可用 cmp x0, #0 cneg x0, x0, lt // LT 为真时 x0 = -x0,否则保持原值CNEG属于 A64 的“条件选择”家族,同族还有CSEL(条件选值)、CSINC(条件选值后加一)、CSINV(条件选值后按位取反)。这四条指令加上CCMP/CCMN(条件比较),构成了 A64 里仅存的无分支条件逻辑。除此之外,所有条件判断都得落到B.cond分支上。
更要命的一处细节是标志更新规则。A32 里add r0, r1, r2默认就会更新 APSR 的 N/Z/C/V;A64 里add x0, x1, x2完全不碰标志,必须写成adds才会更新。这个差异导致一段从 A32 翻译过来的循环,在 A64 上第一次执行时b.eq就永远不跳——因为前面那条add根本没有设置 Z 标志。
// AArch64:计数循环必须用 SUBS,而不是 SUB mov x0, #10 loop: sub x0, x0, #1 // 错误写法:标志不变,下一行分支行为不可预期 b.ne loop mov x0, #10 loop2: subs x0, x0, #1 // 正确写法:SUBS 更新 Z 标志 b.ne loop2SUBS里的 S 后缀就是“set flags”的意思。这条规则在 A64 里是强制的显式声明,编译器生成的代码里几乎每个循环都能看到它。写汇编时如果忘了这个 S,调试器里看到的现象就是“循环只跑一次”或者“循环跑满寄存器位宽”,非常难定位。
2.3 寻址模式:从九种收敛到四种
A32 的 LDR/STR 寻址模式出了名的多:立即数偏移、寄存器偏移、寄存器缩放偏移(带 shift)、前索引回写、后索引回写、PC 相对字面量加载,再叠加各种 shift 组合。A64 把它压缩成了四类:
// AArch64 的四种基本寻址 ldr x0, [x1] // 基址 ldr x0, [x1, #16] // 基址 + 立即数偏移(无符号 12 位,按访问宽度缩放) ldr x0, [x1, x2, lsl #3] // 基址 + 寄存器偏移(可带 LSL 移位) ldr x0, [x1, #16]! // 前索引:先算地址,再回写基址 ldr x0, [x1], #16 // 后索引:先用原地址访问,再回写基址立即数偏移这条有个容易忽略的细节:偏移量的编码是“无符号 12 位,并且按访问宽度缩放”。访问 8 字节的LDR X0时,编码值 1 代表偏移 8 字节;访问 1 字节的LDRB时,编码值 1 代表偏移 1 字节。所以理论上限都是 4095 个元素,但换算成字节数各不相同。前索引和后索引的偏移是 9 位有符号,范围 -256 到 255,这个范围很小,一旦越界就得先用ADD把地址算到临时寄存器里。
PC 相对加载也变了。A32 的LDR Rt, [PC, #imm]范围是 ±4095 字节;A64 有专门的LDR Xt, label形式,范围扩大到 ±1MB。这个变化在位置无关代码(PIC)里影响很大——A32 里加载一个远处的常量必须先经过字面量池中转,A64 里大多数情况可以一条指令直达。
2.4 数据类型:8/16/32/64 位与 SIMD 元素宽度的映射关系
这份资料在第 1 章就把数据类型拆成三组:基本类型、数值类型、SIMD 类型。
基本类型按位宽分:byte(8 位)、halfword(16 位)、word(32 位)、doubleword(64 位),每种都有有符号和无符号两个解释。数值类型是整数和浮点的组合:有符号/无符号整数,加单精度(32 位)和双精度(64 位)浮点。SIMD 类型则是按“通道(lane)”划分的向量视图:16B(16 个字节)、8H(8 个半字)、4S(4 个字)、2D(2 个双字)。
AArch32 的 SIMD 体系是 32 个 64 位 D 寄存器,可以两两拼成 16 个 128 位 Q 寄存器。AArch64 简化成 32 个 128 位的 V 寄存器,B/H/S/D/Q 只是同一个物理寄存器的不同宽度视图。这个统一带来一个隐含后果——浮点寄存器和 SIMD 寄存器在 A64 里是同一套物理资源,浮点代码和向量代码会互相踩寄存器,这一点在第 4 章会展开。
3. 栈帧与调用约定:为什么一条 frame pointer 决定了你能不能调试
3.1 参数传递:前四个寄存器,第五个开始往后放到栈上
调用约定是整个汇编编程里最“不可协商”的部分。AArch32 遵循的整数参数序列是 R0 到 R3,AArch64 是 X0 到 X7。浮点参数独立编号:A32 用 S0 到 S15 或 D0 到 D7,A64 用 V0 到 V7。
关键点在于“独立编号”这四个字。整数参数和浮点参数各自维护自己的游标,互不占用对方的位置。一个函数如果签名是double f(int a, double b, int c, double d),那么 a 走 R0/X0,c 走 R1/X1,b 走 D0,d 走 D1,两条序列并行推进。
// AArch32:int + double 混合参数,整数和浮点各自计数 // 原型: double mix(int a, double b, int c, double d) mov r0, #1 // 第 1 个整数参数 -> r0 mov r1, #3 // 第 2 个整数参数 -> r1,不是 r2 vmov d0, r2, r3 // 第 1 个浮点参数 -> d0 vmov d1, r4, r5 // 第 2 个浮点参数 -> d1 bl mix如果按“参数出现顺序”去分配寄存器,b 会被错误地塞进 R1,整个调用立刻错位。混合参数是汇编里最常见的翻车点之一,因为编译器在背后默默做了这件事,只有手写汇编或者反汇编时才会暴露出来。
整数参数超过 4 个(A32)或 8 个(A64)时,多余的参数从右往左压栈。这里有个细节:压栈顺序和栈的生长方向决定了参数在内存里的排列,A32 和 A64 在这件事上的规则是一致的——最后一个参数在最低地址。
3.2 局部存储与帧指针:SP 一直在动,为什么还需要 FP
函数内部要放局部变量,最直接的做法是用 SP 相对偏移访问。问题在于 SP 不是常量:函数中间只要再调用别的函数、或者动态调整栈空间,SP 就会移动,之前算好的偏移全部作废。
帧指针(A32 的 R11、A64 的 X29)解决的就是这个问题。序言里把当前 SP 抄一份到 FP,之后所有局部变量都用 FP 相对偏移访问,SP 怎么动都不影响。尾声里把 SP 恢复到 FP 的位置,再弹栈返回。
// AArch64:标准的函数序言与尾声 func: stp x29, x30, [sp, #-16]! // 前索引:SP -= 16,然后存入 FP 和 LR mov x29, sp // 建立新的帧指针,指向保存区顶部 sub sp, sp, #32 // 为 4 个 8 字节局部变量预留空间 // ... 函数体,局部变量用 [x29, #-8]、[x29, #-16] 访问 mov sp, x29 // 恢复 SP 到帧指针位置 ldp x29, x30, [sp], #16 // 后索引:先弹出 FP/LR,再 SP += 16 retSTP和LDP是 A64 特有的成对存储/加载指令,一次能搬运两个寄存器,配合前索引和后索引可以把“调整 SP”和“保存寄存器”合并成一条指令。[sp, #-16]!里的感叹号表示回写,[sp], #16没有感叹号但偏移写在方括号外,表示后索引。这两种写法的语义差别是:前索引先改 SP 再访问,后索引先访问再改 SP。
为什么要强制用STP而不是两次STR?因为 A64 的 SP 在函数边界处必须 16 字节对齐,成对操作天然满足这个约束,而且一条指令完成两件事,减少了序言/尾声的指令数。
帧指针真正的价值在崩溃现场。有了 FP 链,栈回溯工具可以沿着[x29]指向的上一个 FP 一路往上走,把整条调用链还原出来。如果没有 FP,回溯就得依赖调试信息里的 CFI 描述,一旦遇到手写汇编或者优化过的代码,回溯经常断在半路。这就是我常说的:省掉一条 FP 省下的是几个字节的栈空间,丢掉的是出问题时的后悔药。
3.3 APSR 条件标志与循环:CMP 之后跟哪条分支
循环是条件标志最密集的使用场景。A32 和 A64 的写法在结构上一致,但条件码的选择上有一个反复出现的坑:有符号比较和无符号比较用的是不同的后缀。
// AArch64:遍历一个 int32 数组求和 mov x2, #0 // 累加器 mov x3, #0 // 索引 loop: cmp x3, x1 // 比较索引和长度 b.hs done // HS = 无符号「大于等于」,长度是 unsigned ldr w4, [x0, x3, lsl #2] // 索引乘 4 作为字节偏移,加载一个 word add x2, x2, w4 // 累加到 64 位累加器 add x3, x3, #1 b loop done:b.hs里的 HS 是“higher or same”的缩写,等价于 CS(carry set),对应无符号比较的>=。如果误写成b.ge(有符号>=),在长度正常时看不出来,一旦长度被传成负数(比如从size_t强转过来),有符号比较会认为负数小于任何正数,循环直接跑飞,读到数组边界之外。
A64 的条件分支家族有b.eq、b.ne、b.hs、b.lo、b.hi、b.ls、b.ge、b.lt、b.gt、b.le、b.mi、b.pl、b.vs、b.vc。前六个是无符号语义,中间四个是有符号语义,最后四个看 N 和 V 标志。这个划分在 A32 里也是一样的,只是 A32 还能把这些条件码挂在非分支指令上。
3.4 为什么 AAPCS 要求 16 字节对齐,而不是 8 字节
AArch64 的调用约定强制要求 SP 在任何函数边界处 16 字节对齐。理由不复杂:STP和LDP一次访问 16 个字节,如果 SP 只保证 8 字节对齐,成对访问就可能跨越缓存行甚至触发对齐异常。
这条约束在写“手工调整 SP”的代码时最容易踩。比如为了临时腾出 24 字节空间写了sub sp, sp, #24,函数返回前又add sp, sp, #24恢复,中间如果调用了别的函数,被调用方看到的是一个 8 字节对齐的 SP,成对访存的性能直接掉一个档次,某些严格的运行环境甚至会直接报错。正确的做法永远是按 16 的倍数调整。
4. 浮点与 SIMD:为什么寄存器共享会改变整个数据通路
4.1 单精度双精度共用一套寄存器:VMOV 与转换的代价
AArch32 的浮点体系里,S0 到 S31 是 32 位单精度视图,D0 到 D31 是 64 位双精度视图,Q0 到 Q15 是 128 位向量视图。三者不是三套独立的寄存器,而是同一批物理寄存器的不同切片:D0 由 S0 和 S1 拼成,Q0 由 D0 和 D1 拼成。
AArch64 把这套视图统一到了 V0 到 V31 上,B/H/S/D/Q 后缀决定访问宽度。这个设计的好处是寄存器数量翻倍、视图一致,坏处是生命周期管理全落在程序员肩上。
// AArch64:单精度与双精度的视图重叠 fmov s0, #1.0 // 把 1.0f 写进 V0 的低 32 位 fmov d1, #2.0 // 把 2.0 写进 V1 的 64 位,与 S0/S1 无冲突 fcvt d2, s0 // S0 -> D2,单精度扩展成双精度 scvtf d3, x0 // 有符号整数 -> 双精度浮点 fcvtzs x1, d2 // 双精度 -> 有符号整数,向零舍入这里的关键是fcvt和scvtf这两个转换族。FCVT负责浮点精度之间的转换(单到双、双到单),SCVTF/UCVTF负责整数到浮点,FCVTZS/FCVTZU负责浮点到整数。后缀里的 Z 表示“向零舍入”,S/U 表示有符号/无符号。这四组指令在 A32 和 A64 里的助记符几乎一样,但寄存器名不同,移植时最容易漏改。
浮点到整数的转换有一个常被忽略的行为:溢出时结果饱和到目标类型的边界值,而不是回绕。FCVTZS W0, D0在 D0 是 1e20 时给出的是 0x7FFFFFFF,不是随机值。这个行为和 C 语言里的未定义行为不一样,写汇编时必须清楚。
4.2 FPSCR:舍入模式、异常标志与“为什么结果每次都不一样”
浮点状态与控制寄存器 FPSCR 是浮点行为的黑匣子。它管着四件事:舍入模式(RN 就近、RP 向上、RM 向下、RZ 向零)、flush-to-zero 模式(把非规格化数当零处理)、默认 NaN 模式(quiet NaN 还是 signaling NaN)、以及各种异常标志位。
// AArch64:读取 FPSCR 并提取舍入模式 mrs x0, fpscr // FPSCR 是系统寄存器,用 MRS 读 ubfx w1, w0, #22, #2 // 舍入模式在 bit 22-23 // w1 = 0 表示 RN(就近舍入),1 表示 RP,2 表示 RM,3 表示 RZUBFX是无符号位域提取,从第 22 位开始取 2 位。这段代码的实用价值在于排查“同一段浮点代码在两台设备上结果末位不同”这类问题——十有八九是舍入模式或者 FZ 位被运行环境改过。
还有一个更隐蔽的来源:融合乘加(FMA)。FMADD把乘法和加法合成一条指令,中间结果不截断,精度比“先乘后加”高,但结果也就不同了。同一段算法用 FMA 和不用 FMA 编译出来,末位差异是常态,不是 bug。做数值比对时必须用容差,不能直接比相等。
4.3 SIMD 打包:一次算四个,省掉的不是算术而是循环
SIMD 的直观理解是“一条指令算多个数据”,但真正的收益不在算术吞吐,而在循环开销。一个处理 1024 个 int32 的循环,标量版本要执行 1024 次加载、1024 次加法、1024 次存储、1024 次索引递增和条件判断;向量版本每次处理 4 个元素,循环体只跑 256 次,索引递增和分支判断的开销直接砍到四分之一。
// AArch64:两个 int32 数组逐元素相加 // x0 = 数组 A 基址,x1 = 数组 B 基址,x2 = 结果基址,x3 = 元素个数 mov x4, #0 vec_loop: cmp x4, x3 b.hs vec_done ldr q0, [x0, x4, lsl #2] // 一次加载 4 个 int32 到 Q0 ldr q1, [x1, x4, lsl #2] add v2.4s, v0.4s, v1.4s // 4 条通道并行相加 str q2, [x2, x4, lsl #2] add x4, x4, #4 // 索引一次前进 4 b vec_loop vec_done:v2.4s里的.4s是“4 个 32 位有符号整数”的通道描述符。这个后缀不是可选的装饰,它决定了指令如何解释 128 位寄存器里的数据:.16b是 16 个字节,.8h是 8 个半字,.4s是 4 个字,.2d是 2 个双字。同一个ADD指令配不同的后缀,行为完全不同。
这里有个结构性限制值得注意:A64 的 SIMD 加法是逐通道独立的,通道之间不会进位。这一点和标量加法有本质区别,也是下一节要讲的“回绕与饱和”问题的根源。
4.4 回绕与饱和:像素加法为什么必须用 UQADD
逐通道独立意味着每个 8 位通道的加法结果被截断在 8 位内。250 加 10 等于 260,截断成 8 位就是 4。在图像处理里,这个行为叫回绕(wraparound),视觉效果是高亮区域突然变成黑色,非常刺眼。
饱和算术(saturating arithmetic)解决的就是这个问题:结果超出类型范围时钳到边界值,260 变成 255。A64 的饱和指令用 Q 前缀标识,UQADD是无符号饱和加,SQADD是有符号饱和加。
// AArch64:两个像素向量的逐通道加法,对比回绕与饱和 // v0.16b 和 v1.16b 各含 16 个 8 位像素值 add v2.16b, v0.16b, v1.16b // 回绕:250 + 10 = 4,图像出现暗点 uqadd v3.16b, v0.16b, v1.16b // 饱和:250 + 10 = 255,视觉正确除了加法和减法,饱和还出现在乘法和移位里。SQDMULH是有符号饱和倍增乘法(结果取高半部分),SQSHL是饱和左移。这些指令在定点数信号处理里用得很多。
顺便说一句,饱和算术并不是“更安全所以更慢”的选择。现代 Arm 核心上UQADD和ADD的吞吐是一样的,都是单周期,选哪个纯看语义需要。
4.5 图像处理三件套:min/max、均值、阈值的向量化写法
资料第 8 章把 SIMD 整数编程落到三个图像处理原语上:像素最大最小、平均强度、阈值化。这三个例子选得很典型,覆盖了 SIMD 编程的三种模式。
第一种是逐通道比较取极值,用UMIN/UMAX:
// AArch64:两个图像逐像素取较亮者 ldr q0, [x0], #16 ldr q1, [x1], #16 umax v2.16b, v0.16b, v1.16b // 逐字节取无符号最大值 str q2, [x2], #16第二种是求平均强度。这里有个陷阱:8 位通道直接累加会溢出,必须先扩宽到 16 位再累加,最后除以元素个数:
// AArch64:计算 16 个 8 位像素的平均值 ldr q0, [x0] // 16 个字节 uxtl v1.8h, v0.8b // 低 8 字节零扩展到 8 个半字 uxtl2 v2.8h, v0.16b // 高 8 字节零扩展到 8 个半字 add v1.8h, v1.8h, v2.8h // 16 位通道相加,不会溢出 uaddlv s3, v1.8h // 水平累加:8 个半字求和到一个 32 位 // s3 里就是 16 个像素的总和,再除以 16 得到均值UXTL是零扩展长指令,把 8 位通道扩成 16 位;UXTL2处理高半部分。UADDLV是水平累加,把一个向量里所有通道加起来。这套组合是“先扩宽再归约”的标准套路。
第三种是阈值化,这里展示了 SIMD 里实现无分支条件的标准手法——用比较指令生成全 1/全 0 的掩码,再和原值做按位与:
// AArch64:8 位像素阈值化,保留超过阈值的像素 // v0.16b = 输入像素,v1.16b = 阈值 cmhi v2.16b, v0.16b, v1.16b // v2 = (v0 > v1) ? 0xFF : 0x00,逐通道 and v3.16b, v0.16b, v2.16b // 掩码与:超过阈值的保留,其余清零CMHI是无符号大于比较,结果是掩码而不是布尔值。这个模式和标量代码里的if完全不同——没有分支,没有预测失败,所有通道同时完成判断。这也是 SIMD 在图像处理里提速明显的真正原因:不是算术快了多少,而是把分支彻底消掉了。
5. 避坑与排查:五条在真机上翻车的记录
5.1 循环在正数场景正常,负数直接越界
现象:一段遍历数组的循环,传入正常长度时结果正确,传入从size_t强转来的负长度时循环不退出,最后触发段错误。
原因:循环条件用了b.ge(有符号比较)而不是b.hs(无符号比较)。长度在语义上是无符号量,一旦解释成有符号,任何“负数长度”都会小于当前索引,循环条件永远成立。
解决:遍历长度、偏移、容量这类量,一律用无符号比较b.hs/b.lo/b.hi/b.ls;只有真正做有符号算术(比如差分、坐标)时才用b.ge/b.lt。判断标准是“这个变量在语义上能不能为负”,不是“它的寄存器位宽是多少”。
5.2 同样的浮点算法,换一台设备结果末位不同
现象:一段求和代码在开发机上跑出的结果和测试机上差最后一位,逐元素打印却完全一致。
原因:两台设备的 FPSCR 舍入模式或 FZ 位不同,或者其中一台的编译器启用了 FMA 融合。逐元素结果一致但累加结果不同,说明差异发生在中间结果的舍入上。
解决:在关键路径开始前显式读取 FPSCR 确认舍入模式;比对结果时用相对容差而不是相等判断;如果算法对精度敏感,用FMUL+FADD分开写,避开FMADD的隐式融合。这类问题的排查顺序永远是“先看状态寄存器,再看指令选择”。
5.3 SIMD 优化后图像出现黑白反转
现象:把标量的像素加法换成 SIMD 版本后,整体亮度正常,但高光区域变成纯黑。
原因:用了ADD而不是UQADD,8 位通道回绕。250 加 10 得到 4,视觉上就是亮部突然塌成暗部。
解决:图像处理里的加、减、乘一律优先考虑饱和指令族。判断方法是问自己“这个运算在数学上会不会超出通道范围”,会超就用UQADD/UQSUB/SQADD/SQSUB。回绕只在明确需要模运算语义的地方才用,比如哈希或者 CRC。
5.4 函数返回后崩溃,崩溃点每次都不同
现象:一个带局部数组的函数,返回后主调函数崩溃,地址每次都不一样,调试器里栈回溯断在半路。
原因:SP 在函数内部被调整成非 16 字节对齐,或者STP/LDP的偏移量写错导致保存区和恢复区错位,或者漏保存了 callee-saved 寄存器(A64 里是 X19 到 X28,以及 V8 到 V15 的低 64 位)。
解决:先检查所有调整 SP 的指令,确保总调整量是 16 的倍数;再检查STP/LDP是否成对、立即数是否一致;最后对照调用约定把 callee-saved 清单过一遍。这类问题的特点是崩溃点随机,因为破坏的是返回地址或者调用者的栈帧,只有沿着 FP 链手动回溯才能定位到真正的出错函数。
5.5 A64 上ADD之后B.EQ永远不跳
现象:从 A32 移植过来的循环,在 A64 上第一次执行分支就跳错方向,像是标志位没更新。
原因:A64 的ADD/SUB默认不更新条件标志,必须写成ADDS/SUBS。A32 里默认更新,移植时漏掉了 S 后缀。
解决:凡是“算术之后紧跟条件分支”的位置,都检查一遍有没有 S 后缀。更稳妥的写法是用CMP明确表达比较意图,而不是依赖算术指令的副作用——CMP在 A64 里就是SUBS的别名,但读代码时意图清楚得多。
6. 设计取舍:为什么从 32 位、栈、浮点一路讲到 SIMD
这份资料的编排顺序值得琢磨:先 A32 的寄存器与寻址,再栈帧与调用约定,再浮点,最后 SIMD。看起来是“从简单到复杂”,实际是按“依赖关系”排的。
栈帧依赖寄存器模型和寻址模式,浮点依赖栈帧(因为浮点参数也走调用约定),SIMD 依赖浮点(因为两者共享寄存器文件)。任何一环跳过去,后面的内容都会变成死记硬背。比如不理解 V0 到 V31 的统一视图,就看不懂为什么浮点代码里保存 V8 到 V15 只需要存低 64 位;不理解 AAPCS 的参数分配规则,就看不懂 SIMD 函数调用时向量参数为什么从 V0 开始编号。
先讲 A32 再讲 A64 也是个有意的选择。A64 的寄存器模型更规整、约束更强,直接学 A64 会觉得“本来就该这样”,缺少对照组。先看 A32 的 16 个寄存器、条件执行、九种寻址,再看 A64 的 31 个寄存器、条件选择、四种寻址,才能体会到每一次简化背后的编码空间压力。
验证这套理解是否到位,有个简单方法:拿一段 C 函数,用编译器生成 A32 和 A64 两份汇编,逐行对照。重点看三处——序言尾声的寄存器保存清单、循环里的标志更新指令、混合参数函数的寄存器分配。三处都能对上,说明寄存器模型和调用约定这两块地基打牢了。
我自己的习惯是:每次手写汇编函数,序言和尾声一定先用STP/LDP写成固定模板,中间的函数体再往里填。这个模板救过我很多次——栈对齐、FP 链、callee-saved 三件事一次性解决,不用每次重新推。从那次因为漏保存 V8 导致浮点结果在调用后变成垃圾值之后,我每次写跨函数调用的汇编都强制走一遍模板,不再图省事直接SUB SP。希望帮到你。
配套的示例工程覆盖了从整数运算、栈参数、浮点数组到 SIMD 图像处理的完整链路,每章都有可编译的 C++ 与汇编混合工程,拿来对照上面的机制逐条验证,比单纯读指令表有效得多。
本文还有配套的精品资源,点击获取