简介:面向ARMv8汇编语言学习者的PDF电子书,适合嵌入式、移动设备与服务器底层开发方向的初学者及进阶读者,用于系统理解ARMv8-A架构与汇编编程。压缩包内共1个PDF文件,约7.22MB,内容为Daniel Kusswurm所著《Modern Arm Assembly Language Programming》,围绕ARMv8-A的32位、64位和SIMD指令集展开。全书覆盖ARMv8架构概述、Thumb-2与ARM指令集、AArch64以及SIMD编程等知识,并结合操作系统、驱动程序和应用开发场景,帮助读者建立从指令集到汇编实现的整体认识,便于对照实践与查漏补缺。目前已有298人学习。
1. 知识地图:Armv8 汇编到底该先记什么
学 Armv8 汇编最容易踩的坑,是把指令当成单词表来背——今天记十条,明天忘八条。真正决定你写不写得出来的,是三张地图:寄存器地图、内存地图和标志位地图。这份资料按 Armv8-A 的 32 位执行状态展开,从寄存器组、数据类型和寻址方式讲起,再进入整数算术、栈帧、条件分支,最后落到浮点与 SIMD 打包运算。它适合已经学过 C 语言、想看清“一条语句编译后到底变成什么”的人,也适合正在复习体系结构、需要把零散指令串成体系的人。先有地图,再填指令,顺序反了就会一直在原地打转。
2. Armv8-32 架构底座:寄存器、数据类型与寻址方式
这一章解决的是“站在哪里写代码”的问题。很多人一上来就抄指令,结果连 R13 和 SP 是同一个东西都没意识到,调试时看到栈指针乱跳只能干瞪眼。梳理下来会发现,Armv8-A 的 32 位执行状态有一套非常规整的硬件约定,先把寄存器、数据类型、寻址这三块钉死,后面的算术和栈帧才有落脚点。
2.1 三种子架构与两套执行状态:先分清你站在哪一层
Armv8 在文档里被切成 A、R、M 三块,乍看只是应用场景不同,真正写代码时才发现三者的寄存器模型和指令集差得远。A 系列面向应用处理器,带完整异常等级;R 系列面向实时系统,强调确定性响应;M 系列面向微控制器,寄存器组和指令集都做过精简。这份资料的主线是 A 系列,而且主要落在 32 位执行状态上,R 和 M 只需要知道定位,不必展开。
| 子架构 | 定位 | 与本资料的关系 |
|---|---|---|
| Armv8-A | 应用处理器,支持 AArch32 与 AArch64 | 主线,寄存器与指令讲解都基于它 |
| Armv8-R | 实时系统 | 背景知识,不展开 |
| Armv8-M | 微控制器 | 背景知识,不展开 |
同一颗 A 系列核可以运行在 AArch32 或 AArch64 两种执行状态。AArch32 下通用寄存器是 32 位宽,指令可以是固定 4 字节的 A32,也可以是 16/32 位混合的 T32;AArch64 下通用寄存器扩展到 64 位,指令固定 4 字节,可用寄存器变成 31 个。资料里“Armv8-32”的章节按 AArch32 展开,但寄存器编号、寻址模式、条件执行这些概念在 64 位下仍然成立,只是宽度和默认约定变了。复习时先把这条分界线画出来,后面看指令才不会把 W0 和 R0 混成一回事。
2.2 寄存器组:R0–R15、APSR 与浮点/SIMD 视图
AArch32 的通用寄存器一共 16 个,编号 R0 到 R15,其中三个有固定别名:R13 是栈指针 SP,R14 是链接寄存器 LR,R15 是程序计数器 PC。R0 到 R3 用于传参和返回值,R4 到 R11 属于被调用者保存寄存器,R12 是临时寄存器。这套分工不是建议,是调用约定,写函数时不遵守,调用方和被调用方就会互相踩数据。
| 寄存器 | 别名 | 典型用途 |
|---|---|---|
| R0–R3 | 无 | 参数 1–4、返回值 |
| R4–R11 | 无 | 被调用者保存的局部变量 |
| R12 | IP | 过程调用中的临时寄存器 |
| R13 | SP | 栈指针,必须保持对齐 |
| R14 | LR | 保存返回地址 |
| R15 | PC | 程序计数器 |
程序状态寄存器 APSR 里放着 N、Z、C、V、Q、GE 这几组标志位。N 表示结果为负,Z 表示结果为零,C 表示进位或借位,V 表示有符号溢出,Q 表示 SIMD 饱和发生过。条件分支能不能跳,全看这几位。APSR 不能像普通寄存器那样直接 MOV,要借助 MRS 和 MSR 读写。
浮点与 SIMD 寄存器有三套重叠视图:S0–S31 是 32 位单精度视图,D0–D31 是 64 位双精度视图,Q0–Q15 是 128 位向量视图。它们不是三块独立空间,而是同一块物理寄存器堆的不同切法:Q0 等于 D0 和 D1 拼起来,D0 又等于 S0 和 S1 拼起来。写 Q0 会覆盖 D0、D1,也会覆盖 S0 到 S3。最容易忽略的是这种重叠关系,改一个通道结果把相邻数据冲掉,调试时往往找不到原因。
MRS r0, APSR // 把条件标志读进通用寄存器 r0 MSR APSR_nzcvq, r1 // 只写 N/Z/C/V/Q 位,GE 位不动 VMOV s0, r2 // 通用寄存器到单精度寄存器的搬运上面三条指令分别对应“读状态”“写状态”“跨寄存器堆搬数据”。MRS/MSR 是访问特殊寄存器的唯一入口,不能写成 MOV APSR, r0。MSR 的目标带后缀 nzcvq,表示只更新这五位,避免误改其他控制位。VMOV 在通用寄存器和浮点寄存器之间搬 32 位数据,是标量运算和整数运算之间的桥。
2.3 数据类型与内存寻址:从字节到双字,从偏移到后索引
AArch32 把内存数据按宽度分成字节、半字、字、双字四级,加载指令的后缀直接对应宽度:LDRB 读字节,LDRH 读半字,LDR 读字,LDRD 读双字。有符号数还要多一个 S 后缀,比如 LDRSB 是把字节读进来后做符号扩展。类型选错不会报错,只会让高位莫名其妙变成 0 或者 0xFF,这类问题在调试器里看寄存器值是看不出来的,只能回去查指令后缀。
| 类型 | 宽度 | 典型指令后缀 |
|---|---|---|
| 字节 | 8 位 | B,如 LDRB、STRB |
| 半字 | 16 位 | H,如 LDRH、STRH |
| 字 | 32 位 | 无后缀,如 LDR、STR |
| 双字 | 64 位 | D,如 LDRD、STRD |
SIMD 数据类型则是另一套记法:8B 表示 8 个字节通道,16B 表示 16 个字节通道,4H 是 4 个半字,8H 是 8 个半字,2S 是两个单字,4S 是四个单字,1D 是一个双字,2D 是两个双字。这个记法在向量指令里会反复出现,比如 VADD.I8 就是按字节通道做回绕加法。
寻址模式是这一节的重点。AArch32 支持偏移寻址、前索引寻址、后索引寻址和寄存器偏移寻址,区别只在一个感叹号和一个逗号的位置,但行为完全不同。
LDR r0, [r1] // 偏移 0,r1 不变 LDR r2, [r1, #4] // 正偏移 4 字节,r1 不变 LDR r3, [r1, #-8] // 负偏移 8 字节,r1 不变 LDR r4, [r1, r2, LSL #2] // 地址 = r1 + (r2 << 2) LDR r5, [r1, #16]! // 前索引:先 r1 += 16,再加载,r1 被更新 LDR r6, [r1], #4 // 后索引:先加载,再 r1 += 4偏移寻址适合访问结构体字段,基址不动,只改偏移。前索引适合“先移动指针再取值”,比如往下遍历数组时先跳一格;后索引适合“取完当前值再移动”,比如拷贝循环里取一个字节、指针自动加一。寄存器偏移带 LSL #2,等于把索引乘以 4,正好对应 32 位整数数组的元素宽度,这条写法在数组遍历里出现频率极高。记住一个判断标准:感叹号在括号里是前索引,逗号在括号外是后索引,两者都会改基址寄存器,只有普通偏移不改。
3. 整数核心编程:算术、栈帧与分支循环的复习重点
寄存器地图画完,接下来是真正写代码的部分。这一章覆盖资料里整数编程的三块内容:算术指令、栈的使用、条件标志与循环。它们的共同点是“看起来简单,写起来到处是细节”。加法谁都会,但进位链断掉、栈对齐破坏、有符号和无符号条件混用,这三类错误能吃掉调试时间的一大半。
3.1 整数算术:进位链比乘法更容易翻车
AArch32 的整数算术分四组:加减、乘法、乘加、除法。加减有 ADD、SUB、ADC、SBC、RSB,其中 ADC 和 SBC 是带进位/借位的版本,专门用来拼多字长运算。乘法有 MUL、MLA、MLS,MLA 是乘加,MLS 是乘减。长乘法有 UMULL、SMULL、UMLAL、SMLAL,结果写到两个寄存器里。除法有 SDIV 和 UDIV,分别对应有符号和无符号。
ADDS r0, r1, r2 // r0 = r1 + r2,同时更新 N/Z/C/V ADC r3, r4, r5 // r3 = r4 + r5 + C,把低位进位接上来 UMULL r6, r7, r2, r3 // r7:r6 = r2 * r3(无符号 64 位结果) MLA r8, r9, r10, r11 // r8 = r9 * r10 + r11 SDIV r0, r1, r2 // r0 = r1 / r2,有符号除法ADDS 和 ADC 必须成对出现,这是多字长加法的固定套路:低位用 ADDS 产生进位,高位用 ADC 把进位接上。只写 ADDS 不写 ADC,低位的进位就丢了,32 位以上的累加结果会整体偏小。UMULL 的低 32 位放在第一个目标寄存器,高 32 位放在第二个,顺序写反了结果直接错位。SDIV 除数为 0 时不会触发异常,结果未定义,调用前必须自己判零,这是硬件行为,不是汇编器的锅。
梳理下来会发现,乘法指令本身反而不容易错,因为目标寄存器写错会立刻得到离谱结果;真正隐蔽的是进位链和标志位。写多精度运算时,中间任何一条指令如果误加了 S 后缀,都可能把 C 标志冲掉,后面的 ADC 就接错了进位。血泪经验是:进位链中间只允许 ADDS/ADC 这一对出现,别插其他带 S 的指令。
3.2 加载、移动与位逻辑:桶形移位器是隐藏的免费操作
加载指令负责内存到寄存器,存储指令负责寄存器到内存,这两组是访存的主力。移动指令里最值得注意的是 MOVW 和 MOVT:A32 指令只有 32 位,留给立即数的空间有限,一条 MOV 装不下任意 32 位常量,于是用 MOVW 装低 16 位、MOVT 装高 16 位,两条拼出一个完整常量。位逻辑有 AND、ORR、EOR、BIC、ORN,其中 BIC 是“位清除”,把指定位清零,比先 MVN 再 AND 少一条指令。
MOVW r0, #0x1234 // r0 低 16 位 = 0x1234 MOVT r0, #0xABCD // r0 高 16 位 = 0xABCD,拼成 0xABCD1234 ORR r1, r2, r3, LSL #4 // r1 = r2 | (r3 << 4) BIC r4, r5, #0x0F // 清掉 r5 的低 4 位后写入 r4 LDR r6, [r7, #12] // 从 r7+12 读一个字 STRB r8, [r9], #1 // 存一个字节,然后 r9 自增 1第二操作数可以带移位,这是 AArch32 的一个便利设计:ORR 的第三个操作数写 r3, LSL #4,硬件会在运算前先把 r3 左移 4 位,不额外占指令周期。很多人不知道这个用法,先写一条 LSL 再写 ORR,白白多一条指令。BIC 的立即数 0x0F 表示低 4 位清零,比 AND 配 0xFFFFFFF0 更直观,也不容易把掩码写反。STRB 的后索引写法在字节拷贝循环里非常常见,存完自动前移,循环体里不用再单独加指针。
3.3 栈参数、局部存储与帧指针:函数调用的三件套
AArch32 的栈是满递减栈:栈向低地址增长,SP 指向最后一个压入的元素。函数调用时,前四个参数走 R0 到 R3,多出来的参数压栈;返回值放 R0。R4 到 R11 如果被调用函数要用,必须先保存再恢复;R12 是临时寄存器,调用方不指望它保持不变。这套规则就是 AAPCS 的核心,写汇编函数时把它当合同看,别当参考。
sum3: PUSH {r4-r6, lr} // 压 4 个寄存器 = 16 字节,SP 保持 8 字节对齐 MOV r4, r0 // 参数 1 MOV r5, r1 // 参数 2 ADD r6, r4, r5 ADD r0, r6, r2 // 加上参数 3,结果放 r0 POP {r4-r6, pc} // 弹栈并直接返回这个函数演示了最干净的一套现场保护:PUSH 压入 R4、R5、R6 和 LR,一共 4 个寄存器,16 字节,正好是 8 的倍数,SP 对齐不会被破坏。返回时 POP 到 PC,等于“恢复 LR 并跳回去”,省掉一条 BX LR。如果只压 3 个寄存器,SP 会变成 4 字节对齐,调用方一旦用到需要 8 字节对齐的库函数,就可能随机崩溃,这是最容易忽略的坑之一。
局部数组或临时变量多的时候,还要在 PUSH 之后用 SUB SP 分配空间,并在返回前用 ADD SP 还回去。需要调试回溯时,再把 R11 当帧指针用:进入函数先 PUSH {r11, lr},然后 MOV r11, sp,之后所有局部变量用相对 R11 的负偏移访问。帧指针的价值在于调用链可追溯,调试器能顺着 R11 一层层往上走;代价是占一个寄存器、多两条指令。资料里把“带局部存储”和“带帧指针”分开讲,就是在提醒这两种栈布局要能分别画出来。
func: PUSH {r11, lr} // 保存帧指针和返回地址 MOV r11, sp // 建立当前帧的基准 SUB sp, sp, #16 // 分配 16 字节局部空间 STR r0, [r11, #-4] // 局部变量 1 STR r1, [r11, #-8] // 局部变量 2 LDR r0, [r11, #-4] ADD r0, r0, #1 MOV sp, r11 // 直接收回局部空间 POP {r11, pc}这段代码的关键在最后两行:MOV SP, R11 把栈指针拨回帧底,比逐条 ADD 更不容易算错;POP 到 PC 同时完成帧指针恢复和返回。局部变量用 [r11, #-4] 这种负偏移访问,偏移量由分配空间的大小决定,分配 16 字节就用 -4、-8、-12、-16 这几个位置,别写到 -20 去,那是调用方的栈。
3.4 条件标志与循环:CMP 之后到底发生了什么
条件执行是 AArch32 的招牌特性。CMP、CMN、TST、TEQ 这四条指令不写目标寄存器,只更新标志位:CMP 做减法,CMN 做加法,TST 做按位与,TEQ 做按位异或。后面的条件分支根据 N、Z、C、V 的组合决定跳不跳,EQ 看 Z,NE 看 Z 取反,GT 看 Z 和 N/V 的关系,HI 看 C 和 Z。有符号比较和无符号比较用的条件码不一样,这是数组边界检查里最常见的翻车点。
| 条件码 | 含义 | 典型场景 |
|---|---|---|
| EQ / NE | 相等 / 不等 | 判空、判零 |
| GT / GE | 有符号大于 / 大于等于 | 有符号数组边界 |
| LT / LE | 有符号小于 / 小于等于 | 有符号循环终止 |
| HI / HS | 无符号大于 / 大于等于 | 无符号长度比较 |
| LO / LS | 无符号小于 / 小于等于 | 无符号循环终止 |
MOV r0, #0 // sum = 0 MOV r1, #1 // i = 1 loop: CMP r1, r2 // 比较 i 和 n BGT done // 有符号比较:i > n 则跳出 ADD r0, r0, r1 // sum += i ADD r1, r1, #1 // i++ B loop done:循环的骨架就是“比较、条件跳、改计数器、无条件跳回”。这里用 BGT 是因为 i 和 n 都按有符号数处理;如果它们是无符号长度,就该换成 BHI。混用的后果是负数被当成巨大正数,循环要么一次不执行,要么跑到越界。另一个细节是计数器更新放在循环体末尾,如果用 SUBS 代替 ADD 加 CMP,还能把“减一并更新标志”合成一条,循环体少一条指令。资料里把比较和循环放在同一节,用意就是让读者把“标志位从哪来、到哪去”连成一条线。
3.5 数组、矩阵与结构体:地址算错,后面全错
数组访问的核心是地址公式:元素地址等于基址加索引乘元素大小。32 位整数数组的元素大小是 4,所以索引要左移 2 位;半字数组左移 1 位;字节数组不用移。这个乘法可以直接塞进寻址模式的寄存器偏移里,一条 LDR 完成“取基址、算偏移、读内存”三件事。
// r0 = 数组基址,r1 = 索引,元素为 32 位整数 LDR r2, [r0, r1, LSL #2] // r2 = base[index] // 矩阵按行主序存放:addr = base + (row * cols + col) * 4 MLA r3, r1, r4, r2 // r3 = row * cols + col LDR r5, [r0, r3, LSL #2] // 读取 matrix[row][col]矩阵比数组多一层乘法:行号乘列数再加列号,得到线性索引。MLA 正好是“乘加”,一条指令算完 row * cols + col,比先 MUL 再 ADD 少一条。行主序意味着同一行的元素在内存里连续,按行遍历对缓存友好,按列遍历会跳着访问,数据量大时性能差距明显。结构体则是另一套算法:每个字段有固定偏移,访问时基址不变、偏移写死,嵌套结构体的偏移要逐层累加。资料里把数组反转和结构体放在“高级编程”里,练的就是地址计算和循环控制的组合。
数组反转的典型写法是双指针向中间靠:一个指向头,一个指向尾,交换后各自前移,直到相遇。汇编里没有“交换”指令,要用两次加载加两次存储,中间借助一个临时寄存器。这里最容易错的是指针更新顺序:先交换再移动,还是先移动再交换,写反了会把刚换好的元素又换回去。梳理下来,数组、矩阵、结构体这三类数据结构的汇编访问,考的都是同一件事——把高级语言的索引表达式翻译成基址加偏移,翻译对了,指令就是体力活。
4. 浮点与 SIMD:从标量运算到打包向量
整数部分打牢之后,资料转向浮点和 SIMD。这两块在考试和面试里出现频率不低,在实际代码里更是图像处理、科学计算的入口。浮点的难点在状态寄存器和转换方向,SIMD 的难点在通道布局和“回绕还是饱和”的选择。把这两条线分开看,会比混在一起记清楚得多。
4.1 浮点架构:FPSCR 里藏着舍入与异常
AArch32 的浮点单元有自己的一套寄存器:S0 到 S31 是单精度,D0 到 D31 是双精度,两者重叠,S0 和 S1 拼成 D0。浮点状态与控制寄存器 FPSCR 保存舍入模式、异常标志和刷新到零等控制位。舍入模式有就近舍入、向正无穷、向负无穷、向零四种,默认是就近舍入。异常标志记录无效运算、除零、上溢、下溢、不精确结果这几类事件,做数值稳定性排查时,这些标志比打印结果更有用。
VMRS r0, FPSCR // 读浮点状态到通用寄存器 BIC r0, r0, #0xC00000 // 清掉舍入模式两位 ORR r0, r0, #0x400000 // 设置为向零舍入 VMSR FPSCR, r0 // 写回FPSCR 不能直接当通用寄存器用,必须经 VMRS/VMSR 中转。上面这段先把舍入模式两位清零,再写入目标编码,其他位保持不变。改舍入模式会影响后续所有浮点运算的结果,测试完记得改回默认值,否则同一段代码在不同函数里跑出不同结果,很容易被当成“玄学”。资料里把浮点架构单独列一章,就是先把寄存器模型和状态寄存器讲清楚,再进算术指令。
4.2 浮点算术、比较与转换:VCVT 的方向别搞反
浮点算术指令以 V 开头:VADD、VSUB、VMUL、VDIV、VABS、VNEG、VSQRT,后缀 .F32 或 .F64 指定精度。比较用 VCMP 或 VCMPE,结果写进 FPSCR 的 N、Z、C、V 位,但不会自动搬到 APSR,所以要条件跳转必须先 VMRS 把标志转过去。转换用 VCVT,方向由后缀决定:VCVT.S32.F32 是浮点转有符号整数,VCVT.F32.S32 是整数转浮点,写反了编译器不会提醒,运行结果直接错。
VLDR s0, [r0] // 加载第一个单精度数 VLDR s1, [r0, #4] // 加载第二个 VADD.F32 s2, s0, s1 // s2 = s0 + s1 VCMP.F32 s2, #0.0 // 与 0 比较,更新 FPSCR 标志 VMRS APSR_nzcv, FPSCR // 把浮点标志搬到 APSR BLT negative // 小于 0 则跳转 VCVT.S32.F32 s3, s2 // 浮点转整数,向零截断 VSTR s3, [r1] // 存回内存VCMP 之后不能直接 BLT,因为 BLT 看的是 APSR,而 VCMP 写的是 FPSCR,中间必须有一条 VMRS 做搬运。这是浮点分支最常见的漏写,漏了之后跳转结果完全看上一次整数运算留下的标志,属于典型的“看起来能跑、结果随机”。VCVT 的截断方向也要注意:默认是向零截断,不是四舍五入;需要四舍五入时得先加 0.5 再转,或者改舍入模式。浮点数组和矩阵的访问方式与整数一致,只是元素宽度变成 4 或 8 字节,索引移位的位数要跟着改。
4.3 SIMD 寄存器布局:Q、D、S 是同一块空间的三副面孔
SIMD 的寄存器就是前面提到的 Q0 到 Q15,每个 128 位。同一
本文还有配套的精品资源,点击获取