
在嵌入式开发里问“ARM汇编的分支结构如何实现”的人多半已经看过一堆指令手册但脑子里还缺一条从C代码到汇编跳转的线。分支的本质其实就两步先用某条指令修改状态标志位再根据标志位决定是否跳转。把这两条线理清楚if、switch、while都能顺利写出来看反汇编也能一眼识别。这篇文章不会从“什么是寄存器”开始讲默认你至少知道r0到r15、内存读写、加减指令这些基本概念。我会把比较指令、条件码、跳转指令、if-else/switch/while的常见翻译模式、Thumb-2的IT块以及几个特别容易踩的坑都过一遍。看完你就能拿着汇编器实际动手写也能对照编译器生成的代码验证自己的想法。1. 分支结构的地基标志位与比较指令1.1 CMP和TST到底做了什么分支要回答的第一个问题是“条件成不成立”而ARM处理器回答这个问题的方式不是直接比较两个寄存器而是通过算术逻辑单元做一次隐含的计算然后把计算结果的状态更新到APSRApplication Program Status Register里的标志位。最常用的是CMP指令。CMP r0, r1做的事情是计算r0 - r1但结果不写回任何寄存器只更新N、Z、C、V这4个标志位。N是结果的最高位表示负数Z是结果是否为零C是无符号减法的借位状态V是有符号减法溢出。它之所以不保存结果是因为我们只需要“大小/相等”这种关系不关心差值本身。ARM里还有一个TST指令它执行按位与操作同样只更新标志位。TST r0, #1之后如果结果是0Z标志会置1那么就可以用BNE判断这个数的bit0是否为1。这在状态位检测里特别常用比如寄存器寄存器的某个标志位决定要不要执行某段代码。注意CMP、CMN、TST、TEQ这四条指令隐含更新标志位不需要加S后缀而普通的MOV、ADD、SUB默认不更新标志位想要更新标志位必须写成MOVS、ADDS、SUBS。很多人刚上手时会把CMP和SUBS搞混。SUBS r0, r1, r2会把结果写到r0如果后边代码还需要原来的r0那就完蛋了。CMP是专门为了“只比较不保存结果”设计的语义清晰还不会破坏操作数。1.2 条件码和ARM的条件后缀ARM体系结构把分支条件编码成4位条件码放在每条ARM指令的高4位。后来演进出的Thumb-2也保留了这套条件命名体系。条件码决定了哪些标志位组合算“条件成立”。条件码含义标志位条件EQ相等Z1NE不相等Z0CS/HS无符号大于等于C1CC/LO无符号小于C0MI负数N1PL正数或零N0VS有符号溢出V1VC无有符号溢出V0HI无符号大于C1且Z0LS无符号小于等于C0或Z1GE有符号大于等于NVLT有符号小于N!VGT有符号大于Z0且NVLE有符号小于等于Z1或N!VAL无条件总是执行对比有符号和无符号要特别小心。C语言里int变量用有符号条件码unsigned int变量用无符号条件码。最常见的错误是拿BGT去比较无符号数因为GT关心的是N和V而无符号大小比较根本不该看N、V应该看C标志。为什么GE是NV可以这么理解减法运算后N是“数学上结果的正负号”但如果有符号溢出符号位会被反转所以必须结合V标志一起判断。这个细节不必死记真用的时候查一下表就行但要知道它背后的原因是溢出导致符号位失真。ARM还允许把条件后缀加到大部分数据处理指令上比如MOVEQ表示“如果相等才执行MOV”ADDNE表示“如果不相等才执行ADD”。一个指令是否执行完全由当前标志位决定这样就能在一个小范围内写出一段没有跳转的分支逻辑。这个特性是很多ARM优化代码的基石后面章节会重点展开。2. 跳转指令B、BL、BX与条件分支2.1 最简单的无条件跳转BB label就是直接跳到label处继续执行。它保存的编码不是绝对地址而是当前指令地址到目标地址的相对偏移。汇编器会帮你算偏移所以写代码时只需要关心标签名。链接器负责把不同段缝合起来最终生成合适偏移。在ARM指令集下B指令的偏移量有正负约32MB的可达范围实际计算时PC有流水线偏移但通常够用。如果你的代码段特别大或者跳转目标在另一个段编译器/链接器可能会报范围错误这时可以考虑BX配合绝对地址或者调整代码布局。条件分支就是B加条件后缀例如CMP r0, r1 BGT greater_label这条指令会先检查上面的CMP留下的标志位如果r0 r1有符号就跳转到greater_label否则顺序执行下一条指令。条件分支是最常见的分支形式它本身并没有更新标志位所以和前面的比较指令组合使用没有冲突。2.2 BL、BX以及函数调用返回BL label是“带链接的跳转”它会先把下一条指令的地址保存到LRr14再跳到label。这条指令设计出来就是为了调用子程序。函数结尾用BX LR回到调用点因为LR里存的是正确的返回地址。BX reg是跳转到寄存器里的地址还可以同时切换ARM/Thumb指令集。ARM体系结构有个约定目标地址的bit0如果是0代表ARM指令如果是1代表Thumb指令。当你从Cortex-M上的Thumb代码用BX跳转到另一个Thumb函数时目标地址bit0通常是1没问题。但在老式ARM内核上混合ARM和Thumb代码时必须保证这个bit正确。从C语言角度理解BL好比func()BX LR好比return。如果是嵌套调用比如函数A调用函数BA在执行过程中也要调用C那么A自身的返回地址会被覆盖所以在进入B之前要把LR压栈返回之前从栈里恢复LR再BX LR。my_func: PUSH {LR} ; 如果后面要调用其他函数先保存返回地址 BL another_func POP {LR} ; 恢复LR BX LR ; 返回调用者2.3 跳转目标地址与PC偏移的坑用标签写跳转时普通人不会意识到一个隐蔽问题ARM处理器取指令时PC通常已经指向当前指令后面几条的地址。ARM态下执行到某条指令时PC往往等于“该指令地址8”Thumb态下则可能是“该指令地址4”。汇编器计算相对偏移时会把流水线偏移算进去所以正常情况下你不需要手动减8。但当你手工维护跳转表、计算地址数组时PC偏移就会冒出来捣乱。比如你想在Cortex-M上用一个addr数组存跳转目标用索引取出目标地址放进寄存器再BX这样是不受PC偏移影响的因为用的是绝对地址。如果你用相对表项加PC的方式跳转就必须考虑当前指令的PC值到底在哪。见过太多人在跳转表里把地址算错几个字节表现就是偶尔跳到一个奇怪的指令上反射出奇怪的中断。后面第5节会专门讲如何避开这类问题。3. 三种常见分支结构的实现套路3.1 if-else直接分支与条件执行看一段最基础的C代码if (a 0) { b 1; } else { b 2; }假设a在r0b最后也放r0。用“直接分支法”是这样CMP r0, #0 BNE else_label MOV r0, #1 B done else_label: MOV r0, #2 done: ...这种方法逻辑非常直白等于0则走then分支不等于0就跳到else分支then执行完要跳过else部分所以加一条无条件B done。代价是最多会出现两次跳转一次条件跳转一次无条件跳转。跳转本身在现代高性能处理器上可能不用太多周期但在简单的Cortex-M0这类核心里跳转会冲刷流水线浪费几个周期。如果使用ARM条件执行短小的if-else可以完全去掉分支CMP r0, #0 MOVEQ r0, #1 MOVNE r0, #2这条写法意味着如果相等执行第一条MOV如果不相等执行第二条MOV。没有跳转执行路径线性周期更短。但前提是每条指令都可以加条件后缀这是ARM模式才有的能力。到了Cortex-M使用的Thumb状态下大部分16位指令不能直接加条件后缀必须借助IT指令组织条件块。等价的写法是CMP r0, #0 ITE EQ MOVEQ r0, #1 MOVNE r0, #2ITE EQ就是说后面跟一个三指令条件块第一条条件成立EQ时执行第二条条件不成立NE时执行。这里MOVEQ和MOVNE都是合法的条件化写法。具体规则见第4节。3.2 switch-case比较链与跳转表switch-case的实现策略与case的分布密切相关。只有两三个case时编译器多半生成一串比较加条件分支类似一个if-else链。case特别稀疏时也是用比较树一个一个比下去。但如果case值是从0开始密集排列的整数编译器更倾向于生成跳转表。手工实现跳转表的一种方式是准备一段内存里面按顺序存放每个case处理代码的绝对地址。然后用索引寄存器从表中取出目标地址跳过去。CMP r0, #3 BHI default_case ; 索引大于3直接走默认 LDR r3, jump_table LDR r4, [r3, r0, LSL #2] BX r4 jump_table: .word case0 .word case1 .word case2 .word case3关于LDR r3, jump_table这是伪指令汇编器会把它变成一条加载常量到寄存器的指令常量通常放在文字池literal pool里。它加载的是跳转表的基地址。然后用r0作为索引左移2位变成字节偏移取出表项里的函数地址放到r4再BX r4完成跳转。这里有几个非常关键的地方索引范围要先检查。否则r0是很大一个数时会从表外读取到垃圾地址。表项是32位绝对地址在Thumb代码中每个函数地址的bit0应当为1表示Thumb目标。汇编器在处理.word case0时如果case0被声明为thumb_func会自动设置bit0。如果你手动把函数地址换算成数字很容易忘掉这个bit。跳转表基础地址必须足够对齐最好4字节对齐保证LDR读取不会出问题。有些旧资料会展示LDR PC, [r3, r0, LSL #2]这种写法直接在ARM态把加载到的地址写进PC省一条指令。但在Thumb-2上这样写不够保险而且BX r4还能顺带处理指令集切换更稳妥。如果case不是从0开始比如5、6、7可以先减去最小case值再做索引。如果case值跨度很大比如0和1和999跳转表会浪费大量内存这时用比较树更合适。这也是为什么编译器会根据“密度”选择方案而不是无脑用跳转表。3.3 while与for循环的翻译与优化循环本质上是“判断条件跳回循环体开头”的组合。看一个简单求和循环int i 0; int sum 0; while (i 10) { sum i; i; }把它翻译成汇编MOV r0, #0 ; i MOV r1, #0 ; sum loop: CMP r0, #10 BGE loop_end ; i 10 就退出 ADD r1, r1, r0 ADD r0, r0, #1 B loop loop_end: ...这里用的是有符号比较因为C语言里i默认是int所以i 10的退出条件是i 10对应BGE。如果i是unsigned int就应该用BHS或无符号大于等于的别名。很多初学者在这类循环里用了BGT结果在i 10时没有退出多转一圈或者正好相反。这个写法的毛病在于循环尾部要无条件跳回loop加上头部条件跳转每一轮循环会有两次转移。更高效的经典写法是把条件判断挪到循环末尾改成do-while形态MOV r0, #0 MOV r1, #0 B enter_loop loop: ADD r1, r1, r0 ADD r0, r0, #1 enter_loop: CMP r0, #10 BLT loop但这种写法在第一次进入前需要一条B enter_loop循环内部每轮只剩一次条件跳转。如果它能保证至少执行一次循环体直接从入口进入也行如果原逻辑可能一次都不执行就需要前置判断。编译器在优化时经常做这种“旋转”把while变成do-while来减少跳转。再看for循环for (i 0; i 10; i) { sum arr[i]; }翻译时循环计数器和数组索引可以共用一个寄存器。假设数组基址在r2r0是ir1是sumMOV r0, #0 MOV r1, #0 loop: CMP r0, #10 BGE done LDR r3, [r2, r0, LSL #2] ADD r1, r1, r3 ADD r0, r0, #1 B loop done:LDR r3, [r2, r0, LSL #2]这种带偏移的寻址方式特别适合访问数组一个周期加载还不用手动乘4。但是要注意r0是循环变量左移两位乘以4后不能超过表范围。如果你用字节数组就不用LSL #2而是直接LDRB r3, [r2, r0]。小型循环还可以用循环展开减少跳转次数。比如循环10次如果展开成5个2次一组的块跳转次数能少一半。不过展开会增大代码体积是否划算要具体场景具体分析。4. ARM条件执行与Thumb-2的IT块4.1 条件执行的设计初衷老牌ARM设计师有个很聪明的想法与其每次判断完再跳转不如让指令自己决定要不要执行。ARM模式下的普通数据指令带条件后缀后变成了“条件指令”。比如ADDEQ r0, r1, r2意思是只有Z标志等于1时才执行r0 r1 r2否则就当一条空指令。这样处理短小的if-else时没有分支没有跳转预测的失败惩罚执行时间是确定的特别适合对时间要求严格的驱动逻辑。有人会问现代处理器都有很强的分支预测这招还有用吗实际在Cortex-M系列里分支预测能力很弱甚至没有条件执行仍然有价值。但条件执行也有代价那些直接加在普通指令后面的条件码会让每条指令无法被乱序执行或使用流水线优化且在64位ARM新架构里多数条件执行被取消只保留少量带条件的select指令。所以在ARMv7时代条件执行是一个重要的优化手段但不是万能神药。4.2 IT指令的语法与使用约束Cortex-M没有完整ARM模式运行的是Thumb-2指令集。Thumb-2里普通16位指令不能随意加条件后缀于是ARM引入了IT指令If-Then。IT指令本身不做什么事它声明后面跟着多少条指令以及这些指令分别是“Then”还是“Else”。IT指令的完整语法由IT加条件码再加若干T/E字母组成。最多可以控制4条指令。比如ITTE EQ ADDEQ r0, r1, r2 MOVEQ r0, #0 MOVNE r0, #1ITTE EQ表示后面三条指令中前两条使用EQ条件第三条使用NE条件。T代表Then条件与IT指令声明的条件相同E代表Else条件与声明条件相反。使用IT块时有一套容易犯错的规则IT块内不能出现跳转指令比如B、BX、BL等不能把IT条件块和跳转混合。IT块内每条指令的条件后缀必须与IT指令声明的T/E序列一致。汇编器通常能帮你检查但某些混乱的写法会产生不可预测行为。IT块内指令通常应该是16位Thumb指令而不是32位Thumb-2宽指令因为寄存器压力或特殊指令限制可能造成fault。不允许嵌套IT块。一旦进入IT块内部不能再出现IT指令。如果IT块内的条件不成立指令会被当作NOP执行但指令仍然占取指周期。在实际项目中除非你有明确的优化需求否则不要手写大量IT块。让编译器去生成尤其是在-O2优化下编译器会自己判断要不要用IT。你只需要会用IT块阅读编译器输出就行。5. 调试分支代码时最容易踩的五个坑5.1 CMP后标志位被悄悄覆盖我调试过一个很隐蔽的问题某个中断服务程序里比较完之后想在等待状态里做一个简单的自增于是写了CMP r0, #10 ADDS r1, r1, #1 BEQ done结果发现BEQ判断的并不是r0 10而是r1 1是否等于0。原因就是ADDS更新了标志位把CMP留下的结果覆盖了。解决办法很简单尽量让比较指令后面紧跟条件分支或条件执行指令。如果中间一定要插入其他操作选那些不带S后缀的指令比如ADD、MOV。或者把比较挪到插入指令之后。这个坑在编译器生成的代码里很少见因为编译器不会这么傻。但手写汇编时稍不留神就犯。5.2 有符号无符号条件码混用无符号数和有符号数在比较时使用完全不同的条件码。举例来说两个32位数一个是0xFFFFFFFF一个是1。从处理器的角度看如果用无符号比较0xFFFFFFFF 1如果用有符号比较0xFFFFFFFF表示-1-1 1。同一组位模式结果相反。我见过一个项目的数据包长度判断写成了CMP r0, r1 BGT too_large这里r0和r1其实都是无符号长度值。当r1很大大到最高位为1时BGT会错误地认为r1是一个负数导致长度判断完全反掉。正确写法应该是BHI。判断方法C语言里unsigned就是无符号对应CS/CC/HI/LSint才用GE/LT/GT/LE。不要想当然。5.3 跳转表索引越界和地址bit0问题跳转表常见故障有两类。第一类是索引越界。如果r0不是预期的0到N-1程序会从表外加载一个随机地址然后跳进去这时调试器看到的现象是“跳到奇怪位置”。解决办法是在索引查表之前先做范围检查超过上限就跳default。第二类是地址bit0。Cortex-M上函数标号如果使用.thumb_func声明汇编器会把地址bit0设为1。但如果你手工构建跳转表或把函数地址当成普通数来写很可能bit0为0导致BX任意目标时无法进入Thumb状态触发异常。检查跳转表内容时用调试器看每个表项是否都比实际地址多1。多出来才是正常的Thumb位。另外还要注意跳转表的文字池位置。LDR r3, jump_table这条伪指令可能把常量层层放置到离当前指令较远的位置如果链接后距离太远某些架构上的加载范围有限。解决方案是把文字池放在距离开头较近的地方或者改用ADR伪指令生成相对地址。5.4 在Cortex-M上错误地使用B跳寄存器Cortex-M的代码是纯Thumb/Thumb-2。B label这种机器码只能跳到相对偏移范围内的标签不能用来跳转寄存器里的地址。如果你看到一个函数指针存在r0里想B r0那是错的应该用BX r0。BX是专门做寄存器间接跳转/返回的指令。同样函数返回应该用BX LR而不是B LR。因为LR保存的是绝对返回地址不是标签偏移。用B LR会被解释成一个相对跳转跳到一个完全错误的位置。这类错误很好排查但初次接触时容易中招。5.5 IT块的T/E顺序写反IT块的语法缩写很容易看迷糊比如ITTE EQ与ITT EE字母数量不同含义不同。写之前先把想要的执行序列列出来比如“如果相等执行A如果不等执行B”对应“IT字母序列”第一条是T第二条是E所以写成ITE EQ。我还见过一种情况IT块里使用了ITE NE却把随后指令写成MOVEQ和MOVNE顺序反了。汇编器可能根据条件后缀与IT声明不一致而报错但有些旧工具链只给警告。这种代码在硬件上执行是不可预测的可能默默跳过指令或触发异常。建议用较新的工具链开启-Wall -Wextra并且每次在调试器里单步执行观察程序是否有意外跳过。在IT块内部单步时条件不成立的指令会被跳过这容易让人怀疑是不是代码丢了。此时要重点看标志位和ITSTATE而不是只盯PC。6. 手写分支还是交给编译器从反汇编学优化6.1 编译器生成的一个典型分支片段现代编译器早已把你平常会手写的那套东西优化得非常精细。以C语言的max函数为例int max(int a, int b) { return a b ? a : b; }使用ARM的某个编译目标打开-O2后生成的汇编可能只有三条指令max: CMP r0, r1 MOVLE r0, r1 BX LRCMP比较a和bMOVLE在a b时把b写入r0否则r0保持a不变。没有分支跳转干净利落。这就是条件执行的价值。在Thumb-2目标下同样的优化可能生成max: CMP r0, r1 ITE LE MOVLE r0, r1 BX LR注意这里多了ITE LE因为普通MOV在Thumb下需要IT块才能有条件执行。这告诉我们现代编译器已经把条件执行和IT块用得很熟练手写代码想超过它并不容易。6.2 什么情况下值得手写分支代码虽然编译器很好但有些场景手写仍然是必要的甚至更优启动代码上电后要先设置栈指针、清BSS、拷贝数据段这些步骤必须精确控制。上下文切换保存和恢复寄存器时编译器不会为你自动生成某些CPU特性相关的操作。锁和临界区需要原子操作、内存屏障或关中断时序极其敏感。访问特殊功能寄存器当外设寄存器的读写顺序和条件判断需要精确控制时手写比内联汇编更清晰。代码尺寸极度受限有时一个小小的函数用手写能比编译器省几条指令比如利用条件后缀合并多个分支。除了这些场景业务逻辑部分我还是推荐用C语言写然后把生成的汇编拿来看。如果你能读懂编译器生成了什么你自然就知道它哪里聪明、哪里笨。看到它用了没必要的跳转表或者多了一条无谓的B你可以针对性修改C代码而不是直接重写汇编。6.3 用工具链观察不同优化级别下的分支差异如果你有ARM GCC工具链可以自己做一个实验感受编译器对分支的处理写一个带多个if和switch的C文件。分别用-O0、-O1、-O2、-Os编译。反汇编对比生成的指令。-O0下基本都是直接的分支跳转因为编译速度快、调试体验好代码没有做太多优化。-O2才会有条件执行、循环展开、跳转表等技术。-Os会把代码体积放在第一位可能把跳转表变成比较链减少内存占用。反汇编命令大致是这样arm-none-eabi-gcc -O2 -mthumb -mcpucortex-m4 -c max.c -o max.o arm-none-eabi-objdump -d max.o我在学习时最喜欢做的是把C源代码里一个if改成三目运算、把switch增减case、把循环里的累加变量换成局部指针然后反复看汇编输出变化。这种“编译器教学”比看十篇文档都有效因为你会逐渐理解什么样的C代码能翻译成漂亮的分支指令。最后分享一个我的个人习惯调试分支逻辑时不要急着在脑子里逐条模拟运行。先在纸上列出“条件表达式 - 使用的标志位 - 选择的条件码 - 跳转目标”然后对照调试器里的APSR值验证。把CMP和条件分支当成一对来记忆把IT块当成一个整体来阅读思路会一下子就通。我踩过最久的跟分支相关的坑几乎都出在无符号/有符号条件码选择、跳转表地址bit0、以及IT块条件序列这三件事上。只要提前检查这三项大多数疑难问题都能化解。希望这篇梳理能帮你少走这些弯路。