1. 为什么寻址方式值得单独拎出来讲1.1 从一条指令的困惑说起刚接触ARM汇编那会儿我盯着一条指令看了半天LDR R0, [R1, #4]当时脑子里全是问号——这个方括号是什么意思#4是加到R1上还是加到R0上为什么有时候写[R1]有时候写[R1, #4]还有时候写[R1, #4]!后来才明白这些方括号里的花样就是ARM的寻址方式。说白了寻址方式就是告诉CPU去哪里找操作数。你可以把它理解成快递地址的写法有的写“某某路100号”有的写“某某路100号3单元”还有的写“某某路100号3单元到了之后把门牌号改成101”。地址写法不同快递员CPU跑的路和干的事就不一样。ARM处理器之所以要设计这么多种寻址方式核心原因就一个在有限的指令编码空间里尽可能高效地支持高级语言的各种数据访问模式。ARM指令是32位定长编码不像x86那样指令长度可变所以每条指令能塞进去的信息量是有限的。寻址方式的设计本质上是在“指令编码复杂度”和“编程灵活性”之间找平衡。1.2 九种寻址方式的分类逻辑ARM的九种寻址方式如果按“操作数从哪里来”来分可以归成三大类立即寻址操作数直接写在指令里CPU不用去内存或寄存器找寄存器寻址操作数在寄存器里CPU直接读寄存器内存寻址操作数在内存里CPU需要根据地址去内存取。这一类下面又细分成七种包括寄存器间接寻址、基址加偏移寻址、前索引寻址、后索引寻址、基址加变址寻址、多寄存器寻址、堆栈寻址这九种寻址方式覆盖了从最简单的常量操作到复杂的数据块搬运的所有场景。你写C语言里的数组访问、指针解引用、结构体成员读取、函数调用时的参数传递编译成ARM汇编之后底层用的就是这些寻址方式的组合。1.3 学寻址方式到底有什么用有人可能会问现在都用C、C、Rust写代码了谁还看汇编学寻址方式是不是浪费时间我的看法是你不一定要手写汇编但一定要能看懂汇编。原因有三个第一调试崩溃和性能瓶颈时汇编是最后的真相。你写的高级代码经过编译器优化后实际执行的指令序列可能和你想象的完全不一样。一个看似简单的数组遍历编译器可能用了多寄存器寻址一次加载8个数据一个你以为会走缓存的指针访问编译器可能生成了带偏移的基址寻址。看不懂寻址方式你就看不懂编译器到底干了什么。第二嵌入式开发中启动代码和中断向量表必须用汇编写。这些代码里大量使用寄存器间接寻址和堆栈寻址。不理解这些寻址方式启动流程就是一团黑。第三理解寻址方式能帮你写出对编译器更友好的代码。比如你知道ARM支持基址加偏移寻址那么用结构体数组比用指针链表更容易生成高效指令你知道多寄存器寻址一次能搬多个寄存器那么把频繁访问的变量放在连续的寄存器里就有意义。我个人的经验是花一个下午把九种寻址方式彻底搞明白后面看任何ARM汇编代码都不会再发怵。这个时间投入的回报率极高。2. 九种寻址方式逐个拆解2.1 立即寻址操作数就写在指令里立即寻址是最简单的一种。操作数直接以常量的形式出现在指令中CPU从指令编码里就能拿到数据不需要访问寄存器或内存。MOV R0, #0x1234 ; 把十六进制数0x1234放入R0 ADD R1, R2, #100 ; R1 R2 100这里的#0x1234和#100就是立即数。#是ARM汇编中立即数的前缀符号用来和地址、寄存器区分开。但ARM的立即寻址有一个非常重要的限制并不是所有32位常量都能直接作为立即数。ARM指令是32位定长的指令里要留出空间给操作码和寄存器编号留给立即数的位数通常只有12位。为了扩大立即数的表示范围ARM采用了一种“8位数值4位循环右移”的编码方式。也就是说一个合法的ARM立即数必须能表示成“一个8位数字循环右移偶数位”的形式。举个例子#0xFF是合法的因为8位可以表示0xFF移位0次#0x104是合法的因为0x41循环右移30位可以得到0x104#0x101是不合法的因为没有任何8位数字经过偶数位循环右移能得到0x101那如果确实需要一个不合法的立即数怎么办编译器通常有两种处理方式一是把常量放在文字池Literal Pool里然后用内存寻址去加载二是用多条指令拼出来比如先MOV低16位再ORR高16位。实操心得手写汇编时如果遇到“immediate out of range”的报错先别急着换指令算一下这个数能不能表示成8位循环右移的形式。很多时候换个写法就能解决比如用MOV加ADD组合代替一个大的立即数。2.2 寄存器寻址操作数在寄存器里寄存器寻址就是操作数存放在寄存器中指令直接指定寄存器编号。MOV R0, R1 ; R0 R1 ADD R2, R3, R4 ; R2 R3 R4这种寻址方式执行速度最快因为寄存器就在CPU内部不需要访问内存。ARM有16个通用寄存器R0-R15其中R13通常用作堆栈指针SPR14用作链接寄存器LRR15是程序计数器PC。寄存器寻址的一个关键点是寄存器编号的编码。在32位ARM指令中每个寄存器编号占4位所以一条指令最多能直接指定3个寄存器两个源操作数加一个目标寄存器。这也是为什么ARM指令通常采用三操作数格式ADD Rd, Rn, Rm。注意R15PC虽然可以作为通用寄存器使用但在现代ARM架构中直接读写PC会导致流水线刷新性能损失很大。除非你在写非常底层的跳转代码否则不要用PC作为普通寄存器。2.3 寄存器间接寻址寄存器里存的是地址寄存器间接寻址是指寄存器中存放的不是数据本身而是数据在内存中的地址。指令通过这个地址去内存取数据。LDR R0, [R1] ; R0 *R1把R1指向的内存内容加载到R0 STR R2, [R3] ; *R3 R2把R2的值存储到R3指向的内存方括号[]在ARM汇编中表示“内存访问”里面的寄存器是地址寄存器。这种寻址方式等价于C语言里的指针解引用。寄存器间接寻址的典型应用场景是遍历数组。假设R1指向数组首元素每次循环后给R1加4假设是32位整数数组就能依次访问每个元素。MOV R1, #0x1000 ; R1指向数组首地址 MOV R2, #0 ; 循环计数器 LOOP: LDR R0, [R1] ; 加载当前元素 ADD R0, R0, #1 ; 元素值加1 STR R0, [R1] ; 写回内存 ADD R1, R1, #4 ; 指针后移4字节 ADD R2, R2, #1 ; 计数器加1 CMP R2, #10 ; 比较是否到10 BNE LOOP ; 没到就继续这段代码就是典型的“指针遍历”模式。寄存器间接寻址的优点是灵活地址可以在运行时动态计算缺点是每次访问都要先读寄存器拿地址再访问内存比寄存器寻址多一步。2.4 基址加偏移寻址最常用的内存访问方式基址加偏移寻址是在寄存器间接寻址的基础上给地址加上一个偏移量。这个偏移量可以是立即数也可以是另一个寄存器。LDR R0, [R1, #4] ; R0 *(R1 4) LDR R2, [R3, R4] ; R2 *(R3 R4) LDR R5, [R6, #-8] ; R5 *(R6 - 8)这种寻址方式在访问结构体成员和数组元素时特别有用。比如一个结构体有多个字段每个字段偏移固定就可以用同一个基址寄存器加上不同的立即数偏移来访问。; 假设R0指向一个结构体字段偏移如下 ; 0: id (4字节) ; 4: value (4字节) ; 8: flag (4字节) LDR R1, [R0, #0] ; 读取id LDR R2, [R0, #4] ; 读取value LDR R3, [R0, #8] ; 读取flag基址加偏移寻址的偏移量范围是12位有符号立即数也就是-4095到4095。如果偏移超出这个范围编译器会先把偏移加载到寄存器然后用寄存器偏移的形式。实操心得写结构体时把最常访问的字段放在偏移量小的位置这样编译器可以用一条指令直接访问。如果字段偏移超过4095就需要额外指令来构造地址性能会下降。2.5 前索引寻址先算地址再访问顺便更新基址前索引寻址是基址加偏移寻址的变体特点是在访问内存之前先更新基址寄存器。语法上是在方括号后面加一个感叹号!。LDR R0, [R1, #4]! ; 先执行R1 R1 4然后R0 *R1注意这里的执行顺序CPU先计算R1 4把这个结果写回R1然后用新的R1值去访问内存。所以这条指令执行后R1的值已经变了。前索引寻址的典型应用是遍历数组时自动递增指针。对比一下用普通基址加偏移寻址遍历数组; 方式一普通基址加偏移 MOV R1, #0x1000 MOV R2, #0 LOOP1: LDR R0, [R1, R2, LSL #2] ; 用R2作为索引 ADD R2, R2, #1 CMP R2, #10 BNE LOOP1 ; 方式二前索引寻址 MOV R1, #0x1000 MOV R2, #0 LOOP2: LDR R0, [R1], #4 ; 这是后索引下面会讲 ...前索引寻址把“更新指针”和“访问内存”合并成一条指令减少了指令数量。在循环中使用前索引寻址可以省掉单独的指针递增指令。注意前索引寻址的!表示“写回基址寄存器”。如果基址寄存器是R15PC写回会导致跳转这是非常危险的操作。另外如果基址寄存器和目标寄存器相同比如LDR R0, [R0, #4]!执行后R0的值会被内存内容覆盖基址更新就丢失了。这种写法虽然合法但容易让人困惑建议避免。2.6 后索引寻址先访问内存再更新基址后索引寻址和前索引寻址正好相反先访问内存然后再更新基址寄存器。语法上偏移量写在方括号外面。LDR R0, [R1], #4 ; 先执行R0 *R1然后R1 R1 4执行顺序是CPU先用R1的当前值访问内存把结果加载到R0然后计算R1 4写回R1。所以这条指令执行后R0拿到的是旧地址的内容R1指向下一个位置。后索引寻址在遍历数组时特别自然因为它的语义就是“取当前值然后移动到下一个”。用后索引寻址重写前面的数组遍历MOV R1, #0x1000 MOV R2, #0 LOOP: LDR R0, [R1], #4 ; 取当前元素R1自动加4 ADD R0, R0, #1 ; 元素值加1 STR R0, [R1, #-4] ; 写回原位置因为R1已经加了4 ADD R2, R2, #1 CMP R2, #10 BNE LOOP这里有个细节因为后索引寻址在加载后已经把R1加了4所以写回时要用[R1, #-4]来回到原来的位置。这是后索引寻址的一个小陷阱不熟悉的话很容易写错。实操心得后索引寻址最适合“读-处理-写回”的模式但写回时要注意地址已经偏移了。如果觉得容易搞混可以在循环里用两个指针一个读一个写或者干脆用前索引寻址。2.7 基址加变址寻址偏移量来自另一个寄存器基址加变址寻址是基址加偏移寻址的扩展偏移量不是立即数而是来自另一个寄存器。而且这个寄存器还可以在参与计算前进行移位操作。LDR R0, [R1, R2] ; R0 *(R1 R2) LDR R3, [R4, R5, LSL #2] ; R3 *(R4 R5 * 4) LDR R6, [R7, R8, ASR #1] ; R6 *(R7 R8 1)支持的移位操作包括LSL #n逻辑左移n位相当于乘以2的n次方LSR #n逻辑右移n位ASR #n算术右移n位保留符号位ROR #n循环右移n位RRX带进位循环右移1位基址加变址寻址最典型的应用是数组索引访问。在C语言中array[i]编译成ARM汇编时如果i是变量通常就会生成LDR R0, [Rbase, Rindex, LSL #2]这样的指令。其中LSL #2是因为int类型占4字节索引要乘以4才能得到字节偏移。; 假设R0 数组首地址R1 索引i LDR R2, [R0, R1, LSL #2] ; R2 array[i]这种寻址方式把“索引乘以元素大小”和“基址加偏移”合并成一条指令效率很高。如果元素大小不是2的幂次方比如结构体大小是12字节那就不能直接用移位需要先用乘法指令算出偏移。注意基址加变址寻址的移位量是有限的LSL和LSR支持0-31位ASR支持1-32位ROR支持1-31位。如果需要的移位量超出范围就要分多条指令完成。2.8 多寄存器寻址一次搬多个寄存器多寄存器寻址是ARM非常有特色的一种寻址方式它允许一条指令同时加载或存储多个寄存器。语法上用花括号列出寄存器列表。LDMIA R0!, {R1-R4} ; 从R0指向的内存连续加载4个字到R1-R4R0自动更新 STMIA R5!, {R1-R4} ; 把R1-R4的值连续存储到R5指向的内存R5自动更新LDM是Load MultipleSTM是Store Multiple。后面的IA表示Increase After即每次传输后地址递增。ARM支持四种地址模式模式含义地址变化时机IAIncrement After每次传输后地址增加IBIncrement Before每次传输前地址增加DADecrement After每次传输后地址减少DBDecrement Before每次传输前地址减少多寄存器寻址最大的优势是高效的数据块搬运。比如保存和恢复寄存器现场; 保存现场 STMDB SP!, {R0-R12, LR} ; 把R0-R12和LR压入堆栈 ; 恢复现场 LDMIA SP!, {R0-R12, PC} ; 从堆栈恢复R0-R12和PC实现返回这里STMDB SP!表示堆栈向下增长Decrement BeforeLDMIA SP!表示从堆栈弹出Increment After。这是ARM函数调用标准中保存和恢复寄存器的标准写法。实操心得多寄存器寻址的寄存器列表顺序不影响传输顺序传输顺序永远是从低地址到高地址对应寄存器编号从小到大。比如{R3, R1, R5}和{R1, R3, R5}效果完全一样。但为了代码可读性建议按编号从小到大写。2.9 堆栈寻址多寄存器寻址的特化堆栈寻址其实是多寄存器寻址的一种特殊应用专门用于堆栈操作。ARM的堆栈可以是满堆栈或空堆栈可以是递增堆栈或递减堆栈组合起来有四种模式模式全称含义FAFull Ascending满递增SP指向最后一个压入的元素压栈时地址增加FDFull Descending满递减SP指向最后一个压入的元素压栈时地址减少EAEmpty Ascending空递增SP指向下一个空位压栈时地址增加EDEmpty Descending空递减SP指向下一个空位压栈时地址减少ARM架构默认使用**满递减FD**堆栈也就是STMDB SP!和LDMIA SP!的组合。在汇编中可以直接写PUSH和POP它们会被汇编器翻译成对应的多寄存器指令。PUSH {R0-R3} ; 等价于 STMDB SP!, {R0-R3} POP {R0-R3} ; 等价于 LDMIA SP!, {R0-R3}堆栈寻址在函数调用、中断处理、局部变量分配中无处不在。理解堆栈寻址的关键是搞清楚SP指向哪里以及地址变化方向。满递减堆栈的SP总是指向最后一个有效数据压栈时先减SP再存数据弹栈时先取数据再增SP。注意如果在一个工程中混用了不同的堆栈模式会导致灾难性的后果。比如启动代码用FD模式某个汇编函数用FA模式堆栈就会错乱。所以一定要在工程初期就确定堆栈模式并在所有汇编代码中保持一致。3. 寻址方式在真实代码中的组合应用3.1 函数调用的完整寻址过程一个标准的ARM函数调用从调用者到被调用者再到返回涉及多种寻址方式的配合。下面用一个简单的例子来拆解; 调用者代码 MOV R0, #10 ; 立即寻址第一个参数 MOV R1, #20 ; 立即寻址第二个参数 BL add_func ; 跳转到add_funcLR保存返回地址 ; 被调用者代码 add_func: PUSH {R2, LR} ; 堆栈寻址保存R2和返回地址 ADD R2, R0, R1 ; 寄存器寻址R2 R0 R1 MOV R0, R2 ; 寄存器寻址返回值放在R0 POP {R2, PC} ; 堆栈寻址恢复R2PC从堆栈恢复实现返回这个过程里参数传递用了立即寻址和寄存器寻址保存现场用了堆栈寻址多寄存器寻址的特化实际计算用了寄存器寻址返回用了堆栈寻址通过恢复PC实现跳转3.2 数组遍历的多种寻址方式对比同一个数组遍历可以用不同的寻址方式实现性能和代码量各有差异。假设要计算一个长度为N的整数数组之和; 方式一基址加变址寻址 MOV R0, #0 ; 累加和 MOV R1, #0 ; 索引 MOV R2, #N ; 数组长度 LDR R3, array ; 数组首地址 LOOP1: LDR R4, [R3, R1, LSL #2] ; 基址加变址 ADD R0, R0, R4 ADD R1, R1, #1 CMP R1, R2 BNE LOOP1 ; 方式二后索引寻址 MOV R0, #0 MOV R1, #N LDR R3, array LOOP2: LDR R4, [R3], #4 ; 后索引自动递增 ADD R0, R0, R4 SUBS R1, R1, #1 BNE LOOP2 ; 方式三多寄存器寻址每次处理4个 MOV R0, #0 MOV R1, #N LDR R3, array LOOP3: LDMIA R3!, {R4-R7} ; 一次加载4个 ADD R0, R0, R4 ADD R0, R0, R5 ADD R0, R0, R6 ADD R0, R0, R7 SUBS R1, R1, #4 BNE LOOP3方式一最直观但每次循环要额外维护索引寄存器方式二省掉了索引寄存器但写回时要注意地址偏移方式三效率最高但要求数组长度是4的倍数且需要更多寄存器。实操心得在性能敏感的代码中如果数组长度较大且对齐优先考虑多寄存器寻址。实测下来多寄存器寻址比单寄存器加载能减少30%以上的循环开销因为指令数量少了流水线利用率更高。3.3 结构体访问中的偏移计算结构体访问是基址加偏移寻址的典型场景。假设有这样一个结构体struct student { int id; // 偏移0 int score; // 偏移4 char name[20]; // 偏移8 int age; // 偏移28 };访问不同字段时编译器生成的指令不同; R0指向student结构体 LDR R1, [R0, #0] ; 读取id偏移0 LDR R2, [R0, #4] ; 读取score偏移4 ADD R3, R0, #8 ; name数组首地址偏移8 LDR R4, [R0, #28] ; 读取age偏移28注意name是字符数组访问时通常用ADD算出首地址然后用字节加载指令LDRB逐个读取。如果偏移超过4095比如一个很大的结构体编译器会先把偏移加载到寄存器然后用寄存器偏移形式。注意结构体的字段顺序会影响访问效率。把最常访问的字段放在前面偏移小可以用一条指令直接访问把不常访问的大字段放在后面即使偏移超出立即数范围影响也小。4. 常见问题与排查技巧实录4.1 立即数非法报错怎么排查问题现象汇编时报错“immediate out of range”或“invalid constant”。排查思路把立即数转成二进制看是否能表示成8位有效位加偶数位循环右移如果不能考虑用MOV加ORR组合或者把常量放到文字池检查是否可以用移位操作代替乘法比如#1024可以写成#1, LSL #10速查表立即数是否合法原因0xFF合法8位可表示0x104合法0x41循环右移30位0x101非法无法用8位循环右移表示0xFFFFFFFF非法超出12位编码范围0x80000000合法0x2循环右移2位4.2 前索引和后索引写反了怎么办问题现象数组遍历结果不对或者指针越界。排查思路检查方括号的位置[R1, #4]!是前索引[R1], #4是后索引前索引是“先更新地址再访问”后索引是“先访问再更新地址”如果写回时地址不对检查是否因为后索引已经偏移了地址避坑技巧在循环中同时有读和写时建议用前索引寻址因为地址更新发生在访问之前写回时用同一个基址寄存器加偏移即可。后索引寻址更适合只读遍历。4.3 多寄存器寻址的寄存器列表顺序问题问题现象保存和恢复的寄存器值对不上。排查思路多寄存器寻址的传输顺序永远是从低地址到高地址对应寄存器编号从小到大寄存器列表的书写顺序不影响传输顺序如果保存和恢复用了不同的寄存器列表检查列表内容是否一致速查表保存指令恢复指令是否正确STMDB SP!, {R0-R3, LR}LDMIA SP!, {R0-R3, PC}正确STMDB SP!, {R0-R3, LR}LDMIA SP!, {R0-R3, LR}错误LR不会恢复PCSTMDB SP!, {R3, R1, R2}LDMIA SP!, {R1-R3}正确顺序不影响4.4 堆栈模式不匹配导致崩溃问题现象函数调用后程序跑飞或者中断处理返回后死机。排查思路检查所有汇编代码中的堆栈操作是否使用同一种模式检查C编译器的堆栈模式设置和汇编代码是否一致用调试器观察SP的变化确认压栈和弹栈的地址变化方向一致避坑技巧在工程初期就确定堆栈模式通常用FD满递减并在链接脚本和编译选项中明确指定。如果必须混用在汇编代码中用注释标明当前使用的模式。4.5 基址加变址寻址的移位量超范围问题现象汇编报错“shift amount out of range”。排查思路LSL和LSR支持0-31位ASR支持1-32位ROR支持1-31位如果需要的移位量超出范围先用MOV指令单独移位再用寄存器偏移检查是否可以用乘法指令代替移位速查表移位类型合法范围超范围处理LSL0-31用MOV单独移位LSR0-31用MOV单独移位ASR1-32用MOV单独移位ROR1-31用MOV单独移位5. 从寻址方式看ARM的设计哲学5.1 定长指令下的灵活取舍ARM指令是32位定长的这意味着每条指令能携带的信息量是固定的。寻址方式的设计本质上是在这个约束下做取舍。比如基址加偏移寻址的偏移量只有12位这是为了给操作码和寄存器编号留出空间。如果偏移量需要更大就要用寄存器偏移多占一个寄存器编号。这种取舍的结果是ARM指令的编码效率很高但单条指令的能力有限。复杂操作需要多条指令组合完成。这和x86的设计哲学正好相反——x86指令长度可变单条指令能做很复杂的事但编码效率低译码复杂。5.2 寻址方式对编译器的影响编译器在生成ARM汇编时会尽量利用寻址方式的特性来减少指令数量。比如数组索引访问优先用基址加变址寻址把索引乘以元素大小合并到移位操作里结构体成员访问优先用基址加偏移寻址把字段偏移直接编码到指令里函数调用保存现场优先用多寄存器寻址一次保存多个寄存器理解这些模式就能理解编译器为什么生成某些指令序列。反过来写代码时如果顺着这些模式来编译器就能生成更高效的代码。5.3 寻址方式与性能的关系不同寻址方式的性能差异主要来自两个方面指令数量和内存访问次数。立即寻址和寄存器寻址不需要访问内存速度最快寄存器间接寻址和基址加偏移寻址需要访问内存但地址计算在指令内部完成不需要额外指令多寄存器寻址一次访问多个内存位置虽然单次访问慢但总体指令数量少适合批量操作在性能敏感的代码中减少内存访问次数和指令数量是优化的关键。选择合适的寻址方式往往比微调算法更有效。我个人的体会是寻址方式的选择不是孤立的它和寄存器分配、指令调度、流水线优化紧密相关。真正写好ARM汇编需要把这些因素放在一起考虑。但作为起点先把九种寻址方式搞清楚后面的路就好走多了。
阅读完成 · 觉得有帮助?