上个月调一段嵌入式启动代码,CPU流水线老是在同一个地方打嗝。翻看反汇编,发现里面全是MOVNE、ADDGT、LDRLE这种指令——没有跳转,分支却实现了。这其实就是ARM汇编里分支结构最有特色的玩法。工作里天天和汇编打交道,从启动代码到设备驱动,见过太多分支相关的疑难杂症,这篇就把ARM分支结构怎么实现这件事讲透,顺带把那些手册里不会写的坑也扒一遍。
这篇文章适合三类人看:刚接触ARM汇编、被一堆条件后缀搞到头晕的新手;写C语言但想知道if语句在CPU层到底怎么翻译的嵌入式开发者;以及做逆向、读固件时被条件执行指令绕晕的调试者。看完你会明白一件事:ARM的分支结构,远不止“跳转”这么简单,它背后还有一整套条件执行机制,理解它之后再看反汇编,很多代码逻辑一眼就能顺下来。
1. 分支结构在ARM里到底是怎么实现的
1.1 程序的“分岔路口”在CPU层面对应什么
先聊个底层问题。CPU本身没有任何“判断能力”,它只会做一件事:按顺序从内存取指令、执行、再取下一条。那if-else怎么办?只能靠改PC指针。PC就是程序计数器,它指向下一条要执行的指令地址。你把它改了,CPU就跑到别的地方继续执行,这就是“跳转”。
但ARM对跳转的态度和x86不太一样。x86遇到if-else通常是先比较,再根据结果跳走——跳或者不跳,二选一。ARM则多了一种选择:不跳,而是让指令“看眼色行事”。看什么眼色?看CPSR寄存器里的条件标志位。条件满足,这条指令正常执行;条件不满足,这条指令就当没看见,原地变成一条空操作。这就是ARM分支结构一个非常核心的设计:条件执行。
为什么要在意这个区别?因为跳转是有代价的。现代ARM核流水线都很深,五级算浅的,十级以上也不稀奇。指令流水线像高速公路收费站的多个车道,跳转命令等于让整个车队突然换道,后面的指令全得重来。条件执行则是原地决定去留,不打断流水线。早期的ARM处理器没有复杂的分支预测器,条件执行的价值非常大,所以ARM花了极大的力气把条件后缀做进了几乎所有指令的编码里。这个设计从ARMv4一直延续到ARMv8,哪怕到64位的AArch64,条件执行和分支预测依然并存。
所以你看到一段ARM反汇编里没有B开头指令,却逻辑完整,多半就是条件执行在起作用。Byte、Halfword、Word级的搬运,算术逻辑,甚至LDR/STR访存指令,都能加条件后缀。这让ARM的分支结构呈现出一种和其他架构很不一样的面貌:分支不一定是跳转,跳转也不一定是朴素的无条件转移。
1.2 四种跳转指令有个明确的分工
ARM的跳转指令就四个:B、BL、BX、BLX。名字看着像,但每一个的用途完全不同,我用最常见的场景一个个说。
B(Branch)是最直接的无条件跳转。它让PC跳到一个指定的地址偏移处,跳过去就完了,没有任何往回找的记录。if-else、循环、switch这类代码块之间的跳转多数用它。注意B是相对跳转,指令里存的是一个偏移量,而不是绝对地址,这一点在后面的偏移量章节会展开。
BL(Branch with Link)比B多了一步:跳转之前, CPU会把当前下一条指令的地址保存到LR寄存器(R14)里。这样被调用方执行完,只要MOV PC, LR(或者BX LR)就能回到调用点,继续往下走。这本质上就是函数调用。编译器把C语言里的函数调用翻译成汇编时,绝大多数情况下用的就是BL,因为一个函数可以被很多地方调用,每个调用点都要记住自己该回哪里。
BX(Branch and Exchange)做两件事:跳转,同时切换ARM状态和Thumb状态。ARM处理器从ARMv4T开始支持Thumb指令集,Thumb指令只有16位(后来有Thumb-2,变长但这里先按下不表),同一个功能代码更紧凑。ARM状态和Thumb状态不能混着乱跳,BX就是专门用来切换状态的桥。切换的玄机藏在目标地址的最低位:地址最低位是0,切到ARM;是1,切到Thumb。实际地址会被当作对齐后的地址处理,最低位只做标记,不参与寻址。
BLX(Branch with Link and Exchange)就是BL和BX的结合体:既保存返回地址到LR,又切换指令集状态。在需要从ARM代码调用Thumb函数、或者从Thumb代码调用ARM函数的混合工程里,你会频繁看到它。
这四种指令各管一摊,别混。我在实际调试中见过有人写裸机程序时,用B调用子函数,结果子函数执行完直接跑飞了——因为没有保存返回地址,跳到哪就回不来了。反而是LR里存着上一个函数调用的残留地址,程序乱跳半天才发现不对劲。做汇编的第一课就是分清:裸跳用B,要回来的用BL/BLX,要切状态的用BX/BLX。
1.3 条件执行:用“先执行、后选择”代替跳转
ARM最独特的做法,是允许几乎任何指令带上条件码执行。比如MOVNE R0, R1,意思就是“如果上一次比较结果不等于,就把R1赋给R0”。不满足条件时,这条指令在流水线里直接变成空操作,CPU照样往前走。
条件码一共16种,挂在助记符后面。最常用的几个:EQ(等于)、NE(不等于)、GT(大于)、LT(小于)、GE(大于等于)、LE(小于等于),再加上无符号比较用的HI(高于)、LS(低于等于)、CS/HS(大于等于/无符号进位)、CC/LO(小于)。这些条件码对应CPSR里的四个标志位N、Z、C、V,往深了说就是它们之间的逻辑组合。
这套设计带来一个很直观的好处:很多短小的分支,根本不需要B指令。比如判断两个数谁大,CMP比较完后用MOVGT和MOVLE各取所需,两三条指令就结束,完全没有跳转带来的流水线冲刷风险。我在实际项目里见到的编译器,对这种几个指令就能搞定的短分支,都会倾向用条件执行而不是生成跳转。
条件执行也不是没有代价。每个条件不成立的指令在译码阶段就被丢弃了,虽然不执行,但占了一个发射位置,相当于白白消耗一拍流水线资源。如果分支内部逻辑很长,继续用条件执行反而浪费大量指令槽位,这时候老老实实跳转更划算。所以ARM的优化策略有个不成文的经验法则:分支体少于3~5条指令,用条件执行;再长就跳转。
2. 条件码与CPSR:分支判断的“咽喉要道”
2.1 CPSR里的NZCV怎么来的
CPSR(Current Program Status Register)是ARM的“天气表”,四个最重要的标志位是N、Z、C、V。N表示上一条指令的结果是负数;Z表示结果是零;C表示运算产生了进位,或者减法没有借位;V表示有符号数运算发生了溢出。条件码之所以能工作,全靠这个寄存器里的几个bit。
有一点特别容易翻车:不是所有指令都会更新这些标志位。ARM的数据处理指令一般不会动标志位,得在指令后面加S后缀才更新,比如ADD R0, R1, R2不碰标志位,ADDS才碰。但CMP、CMN、TST、TEQ这几个比较类指令是例外,它们算术上干了活,却不写结果寄存器,专为更新标志位而生。
我把常见指令对标志位的影响整理成一张表,照这个记基本不会错:
| 指令类别 | 指令示例 | 是否更新标志位 |
|---|---|---|
| 普通数据处理 | ADD、MOV、AND | 否 |
| 带S的数据处理 | ADDS、MOVS、ANDS | 是 |
| 比较类 | CMP、CMN | 是 |
| 测试类 | TST、TEQ | 是 |
| 乘法 | MUL、MLA | 一般不影响,但UMULL/SMULL等特殊形式会部分影响 |
| 加载/存储 | LDR、STR | 否(除非LDR后缀带条件但语义不更新标志) |
我在带新人时常让他们背一句话:你想让一个算术结果影响后续条件码,就加S;你想单纯比较两个数又不污染寄存器,就CMP。这两条记牢,后面90%的标志位问题都不是问题。
2.2 条件码表:EQ、NE、GT、LT怎么查怎么用
条件码助记符不是乱起的,每个都有精确的标志位组合。整理成表直观一点:
| 条件码 | 含义 | 标志位条件 |
|---|---|---|
| EQ | 等于 | Z == 1 |
| NE | 不等于 | Z == 0 |
| CS / HS | 无符号大于等于 / 进位 | C == 1 |
| CC / LO | 无符号小于 / 借位 | C == 0 |
| MI | 负数 | N == 1 |
| PL | 正数或零 | N == 0 |
| VS | 溢出 | V == 1 |
| VC | 无溢出 | V == 0 |
| HI | 无符号大于 | C == 1 && Z == 0 |
| LS | 无符号小于等于 | C == 0 || Z == 1 |
| GE | 有符号大于等于 | N == V |
| LT | 有符号小于 | N != V |
| GT | 有符号大于 | Z == 0 && N == V |
| LE | 有符号小于等于 | Z == 1 || N != V |
重点解释一下为什么GT会有“Z==0 && N==V”这么诡异的组合。有符号比较时,结果的正负可能被溢出迷惑:两个正数相加超过上限溢出成了负数,N标志会说结果小于0,但实际上原数值是正的。所以判断“大于”必须把溢出情况也考虑进去。N==V的意思就是“如果没溢出,结果为正;如果有溢出,结果是负——但实际上这个负是被溢出扭曲出来的,真实结果依然大”。这套逻辑初看绕,多画几张数轴就通了。用它判断两个变量大小,先CMP,再按需使用GE、GT、LT、LE就完事。
条件码还会出现在一种特殊写法里:LDR/STR、甚至SWI都可以挂条件后缀,例如LDRNE R0, [R1]。这意味着整个访存本身也能被条件过滤。做操作系统异常处理和状态轮询时,这种写法能把代码压得非常紧凑。
2.3 比较指令CMP、CMN、TST、TEQ怎么选
条件码背后是四个比较类指令,选错一个,逻辑就歪了。
CMP是最常用的,它做的是减法:拿第一个操作数减第二个,结果不写回任何寄存器,只更新标志位。判断两个寄存器是否相等、判断一个数和立即数之间的大小关系,都用它。
CMN是反着来的:拿第一个操作数和第二个操作数的相反数相加。比如CMN R0, #1,实际算的是R0 + 1,根据结果更新标志。它的价值在于某些边界比较很好用,比如判断一个数是不是-1,用CMN R0, #1再看EQ,比先取反再CMP省一条指令。但对新手来说,先老老实实用CMP,碰到瓶颈再回来挖CMN。
TST做按位与,不写结果,只看“这些位是否全为0或者某一位是否为1”。判断一个寄存器的bit3是不是1:TST R0, #0x08,然后看NE(结果非零说明该位是1)或者EQ(结果是0说明该位是0)。做外设寄存器状态判断时非常高频。
TEQ做按位异或,专门用来比较两个值是否完全相同,并且可以选择性地忽略某些位。比如想判断两个地址是否落在同一个页内(即高位相同),先构造一个高位的掩码,TEQ加上掩码之后按EQ判断,一条指令解决。
选型逻辑很简单:比大小用CMP,比正负相加用CMN,测某位用TST,测相等且要屏蔽位用TEQ。实际工程里CMP占了95%的出场率,剩下三个属于关键时候能救命的选手。
3. 实操:把“取最大值”翻译成ARM汇编
3.1 教科书式分支:CMP+B跳转
光讲原理不过瘾,直接上一个实际例子。C语言的经典片段:
int max2(int a, int b) { if (a < b) return b; else return a; }先写出最“教科书”的ARM汇编版本,逻辑和C代码一字不差。
@ r0 = a, r1 = b,返回值放在 r0 CMP r0, r1 @ 计算 a - b,更新标志位 BGE .Lkeep_a @ 如果 a >= b,跳去保留 r0 MOV r0, r1 @ 否则(a < b),把 b 放到 r0 .Lkeep_a: MOV pc, lr @ 返回这里CMP r0, r1先比较出大小标在CPSR里;BGE在a>=b时跳转,从而跳过MOV R0, R1,保留原来的a;反之则执行MOV R0, R1,把b复制过去。最后MOV PC, LR返回到调用者。这个版本用的是“真实跳转”,BGE一旦命中,流水线里已经取出来的后面那些指令全部作废,要重新取指。
还有个细节值得提:C代码里写的是“if (a < b) return b; else return a;”,翻译到汇编时我选择用BGE跳走。这样跳转的方向是整个分支里更“常见”的那条路,在某些分支预测器上能拿到更好的效果。
3.2 条件执行优化版:MOVNE、MOVGT
再看ARM特色的条件执行版本。同样是max2,可以写得完全没有跳转指令:
@ r0 = a, r1 = b,返回值放在 r0 CMP r0, r1 @ 比较 a - b MOVLT r0, r1 @ 如果 a < b,就把 r1 赋给 r0 MOV pc, lr @ 返回两行核心就完了。CMP更新完标志位之后,MOVLT会根据标志位决定自己是否真的执行:条件满足就赋值,不满足就空转。整个过程没有产生任何跳转,流水线没有任何冲刷,代码密度也高。
编译器确实会这么干。在开-O2优化时,ARM编译器面对这种短逻辑,倾向于生成条件执行版本。我在实际反汇编里见过很多次:一个if(a<b) {...},反汇编出来根本没有B指令,就是MOVGE、MOVLT这种组合。读这种代码一开始容易懵,习惯之后就很快。
条件执行版本也不是万能。如果分支体里面有函数调用、循环、复杂计算,条件执行的代价就会爆炸式上升,因为每条指令都要挂条件,且不满足条件的指令同样占流水线槽位。所以编译器优化的选择逻辑大致是:分支体短、执行时间短、代码紧凑,用条件执行;否则用真实跳转。
3.3 把它封装成子程序:BL调用、LR返回
真实代码里不会把max2写到主逻辑里,而是封装成子程序,用BL调用,用LR返回。完整一点的例子:
_start: @ 准备两个测试数 LDR r0, =0x00000005 @ a = 5 LDR r1, =0x00000007 @ b = 7 BL max2 @ 调用 max2,返回地址自动存入 LR @ 此时 r0 里就是 max(5, 7) 的结果 @ 例如后续可以把 r0 写入某个内存地址等待查看 LDR r2, =result_addr STR r0, [r2] B . @ 死循环,模拟程序终止 max2: CMP r0, r1 @ 比较 a 和 b MOVLT r0, r1 @ 如果 a < b,r0 = r1 MOV pc, lr @ 用 LR 中的地址返回BL max2执行时,CPU先把当前指令的下一条地址(也就是LDR R2那一行)存进LR,再跳去max2。max2最后用MOV PC, LR把LR塞给PC,回到调用点继续。这套“BL进入、MOV PC, LR退出”的配对是整个ARM函数调用的基石。
有个小坑:如果max2内部又调用了别的函数,LR就会被覆盖。解决办法是函数入口先把LR压栈,比如PUSH {LR},返回前POP {PC}。这也是为什么你在编译生成的高优化代码里常看到PUSH {R4, LR}的原因——不只是保存局部变量要用到的寄存器,更多是要把返回地址保护好。
3.4 在QEMU上跑起来:验证与观察标志位
光看代码不跑总差点意思。本地没有开发板的话,直接用QEMU的user mode模拟一个ARM程序,完全够用。步骤很简单。
把上一节的汇编存成max2.s,然后编译链接。我用的工具链是arm-none-eabi系列:
arm-none-eabi-as -o max2.o max2.s arm-none-eabi-ld -o max2.elf max2.o接着用QEMU跑:
qemu-arm ./max2.elf这个程序没有println函数,肉眼看不到输出。想看结果,我有两个常用办法。第一个是用调试器观察寄存器。先启动带调试端口的QEMU:
qemu-arm -g 1234 ./max2.elf另开一个终端,用gdb连接:
arm-none-eabi-gdb ./max2.elf target remote :1234 break max2 continue info registers这时能看到进入max2时各个寄存器的值,继续单步,你会亲眼看到CMP之后CPSR的Z、N、V怎么变,MOVLT到底有没有执行。第二个更朴素:把结果写到一个已知的内存地址,然后让QEMU结束后用gdb读那个内存。无论哪种,跑通一次之后,之前对条件码的所有疑惑都会落回实处。
4. 分支相关坑位实录:从标志位到指令集切换
4.1 更新标志位:ADD和ADDS的天壤之别
第一个坑在分支判断里非常高频:写汇编时以为加法会更新标志位,结果后面的条件码判断完全不对。
看这段代码:
ADD r0, r1, #1 BLE .do_something如果r1 + 1结果是负数,按我的预期应该触发BLE。但ADD不会改标志位,CPSR还是老的,BLE判断的是上一次指令留下的标志,逻辑直接歪掉。改成ADDS R0, R1, #1,标志位才会跟新结果走。
为什么ARM要这么设计?这是有意为之。更新标志位在硬件上意味着额外的依赖和写回开销,如果每一条指令都自动更新标志位,流水线里后面的指令就得等前面的结果,性能会受拖累。再加上条件执行本身已经够复杂,程序员和编译器都想精确控制哪些指令需要影响标志位。所以ARM选择了“显式更新”策略:你明确加S,我才更新。x86则相反,每条算术指令都更新标志位,这是两种不同的设计哲学,谈不上谁更好,但用ARM的思维方式去写代码,必须养成检查S后缀的习惯。
4.2 立即数编码:MOV R0, #0x12345678为什么报错
分支判断里经常要把立即数和寄存器比较,比如CMP R0, #4096。ARM的立即数可不是随便给个32位数就能用的,它要经过特殊的编码:指令里有8位立即数字段和4位移位字段,立即数实际是“8位数字循环右移偶数位”。这意味着只有部分32位值可以当成立即数直接使用。很多新手写MOV R0, #0x12345678,汇编器直接报错,就是这个原因。
解决方法是换用LDR伪指令,它不限制立即数范围,汇编器会帮你把大立即数放到一个文字池里,或者拆成几条指令拼出来:
LDR r0, =0x12345678同理,CMP想跟一个大立即数比较,也建议先把立即数加载到寄存器再CMP,别硬编码到指令里。这个坑在分支判断里很容易被忽略——你以为只是判断式子写错了,实际是立即数编码把整条指令都撑爆了。
4.3 B指令偏移量只有±32MB,代码太大怎么办
B指令用24位有符号偏移量表示跳转目标和当前PC的相对距离,算上ARM的对齐要求,实际可用范围大约±32MB。看着不小,但大型嵌入式工程、复杂的系统固件里,代码段轻松超过这个范围。跳转目标超出范围时,链接器会报错说relocation out of range。
解决思路分几层。最简单的是改用LDR PC, =target这样的伪指令,直接把目标地址放进文字池,从内存里加载到PC,保护范围扩大到整个地址空间。代价是多一次内存访问,速度不如直接跳转。也可以用BX/BLX在很近的范围内中转,或者调整链接脚本把相关的代码段放在更靠近的位置。实际工程里,遇到这种错误不要硬加跳转,先看看是不是链接脚本的段布局不合理,把两个频繁互相调用的函数拆到了相距几十MB的地址上。
还有个隐藏知识点:B是相对偏移跳转,所以代码整体被重定位到别的地儿,偏移关系不变,跳转依然有效。这也是为什么很多Bootloader里的早期汇编可以“位置无关地”运行。如果到处用绝对地址跳转,搬一次家就全废了。
4.4 Thumb模式下条件执行被砍了一大半
ARMv4T时代出现的Thumb指令集,为了让代码密度更高,指令编码普遍紧缩到16位,代价之一就是条件执行没法像ARM状态那样随意。最早的Thumb里,只有条件跳转指令(BEQ、BNE这类)可以带条件,普通数据处理/LDR/STR几乎都不能挂条件后缀。想在Thumb里写“如果相等就执行一条MOV”这种东西,写不出来。
ARMv7的Thumb-2改进了这个情况,加入了IT指令块(If-Then)。IT后面跟一串条件,它能把接下来1到4条指令标成同一个条件块。比如:
ITTE EQ @ 三条指令块:第一条和第二条条件为EQ,第三条为NE MOVEQ r0, #1 @ if (eq) r0 = 1 MOVEQ r1, #2 @ if (eq) r1 = 2 MOVNE r2, #3 @ if (ne) r2 = 3有这套IT机制,Thumb-2才勉强找回了ARM状态的条件执行能力。但IT块本身要占一条指令的开销,且现代编译器会谨慎使用它。在Cortex-M这类只支持Thumb状态的处理器上调试时,看到IT开头的指令别惊讶,它就是Thumb-2版的“条件前缀”。另外,IT块的语法和条件判断逻辑在Cortex-M3/M4的时代就已经很成熟,但手动写时还是容易把IT条件后缀和后面的指令后缀搞混——我自己就吃过一次亏:ITTTE后面三条指令写成MOVNE、MOVLT,编译器不报错但逻辑全错。
4.5 条件执行的代价:不是所有if都能优化
把分支都用条件执行替代,听上去很美好,实际有明确的性能边界。核心矛盾在于:条件不成立的那条指令,虽然不写寄存器,但它依然占用流水线的执行槽位。现代ARM核普遍有分支预测器,预测对了跳转只有极小的代价,放弃跳转改成条件执行,可能反而白白浪费很多执行带宽。
举个例子,一个分支体有几十条指令,如果用条件执行实现,条件为假时这几十条指令全部在流水线里“空转”,等于给CPU塞了一堆不干活的任务。这种情况下,用B跳转跳过这个分支体,才是合理的。
所以编译器对分支优化的真实策略是分层级的。短分支(几条指令以内):条件执行;中等分支:可能两者结合,用跳转跳过一部分,保留部分条件指令;大分支:纯跳转。我用一个简单基准测试实测过,在某个Cortex-A系列核上,分支体只有两三条指令时,条件执行版本比跳转版本快大约10%~20%;到十条指令以上,跳转版本反超。这个数字不能直接套用到所有ARM核上,但“短分支适合条件执行”的结论是通用的。
真正的调优方法是看实际数据和perf工具输出,别凭感觉。写代码时优先保证逻辑清晰,交给编译器去做大部分分支优化决策,手写汇编时只在明确的热点上动手脚。
5. 写在最后的一些体会
在底层摸爬滚打这么久,我最大的感受是:ARM的分支结构不是“跳转指令的集合”,而是一整套“条件判断+执行控制”的体系。B、BL、BX、BLX解决了去哪里的问题,条件码解决了要不要去的问题,二者结合才形成了ARM在高密度代码和低功耗场景下的独特风格。读别人写的汇编时,遇到带条件后缀的指令不要急着跳过,多想一步“为什么编译器会在这里用条件执行”,往往就能还原出源码里的if逻辑,这在逆向和调试里特别有用。
另外一个很实际的建议:如果你刚开始写ARM汇编,不要一上来就追求用条件执行炫技。先把B跳转版本写对,跑通,观察标志位,再用条件执行优化。跳转版本是最容易对着C代码逐行核对的写法,条件执行版本才是体现ARM精髓的进阶形态。等你能熟练地在两种写法之间切换,并且说得清每种写法的流水线代价时,ARM汇编的分支结构就算是真正吃透了。