想象你走进一家热闹的咖啡店:你对店员说“我要一杯拿铁”,店员在便签上写下“3号桌”贴在咖啡机旁,然后转身去做咖啡。做完后,店员喊一声“3号桌的拿铁好了”,你取走咖啡——这整个“下单一制作一交付”的流程,恰恰就是CPU执行函数调用的方式。在ARM处理器世界里,每一次函数调用都像一次点单:BL指令就是那句“我要一杯拿铁”,LR寄存器就是那张写着“3号桌”的便签,而函数返回就是店员喊出的那声“好了”。如果顾客中途又加了一块蛋糕,店员会不会手忙脚乱地忘了咖啡?ARM处理器又是怎么记住“做了一半的事情”、在嵌套调用后原路返回的?这篇文章就用这家咖啡店的日常,带你彻底看懂ARM函数调用与返回的底层机制。

岗位关联

掌握ARM调用与返回机制,是理解嵌入式系统、移动端性能优化与底层调试的基石。无论你未来从事编译器开发、芯片验证还是AI推理框架优化,这份“底层直觉”都会让你在分析调用栈、定位崩溃、权衡性能时比他人看得更深一层。

目录
  1. 可视化演示
  2. BL指令与LR寄存器
  3. 函数返回机制
  4. 嵌套调用与压栈
  5. AAPCS调用约定
  6. 栈8字节对齐
  7. 工程实践要点
  8. 常见错误分析
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示BL指令保存返回地址到LR并跳转、嵌套调用LR压栈保护、AAPCS约定R0-R3传参R0返回的完整流程,切换步骤查看。

本节配有交互动效(进入视口后自动加载,约 3KB)。

BL调用与BX返回 BL func: LR←PC+4, PC←func BX LR: PC←LR 调用时LR保存返回地址,返回时PC恢复

BL指令与LR寄存器

假如你走进咖啡店,对店员说:“请给我做一杯拿铁。”这句话本身包含了两层意思:发生了“跳转”——店员的注意力从门口转向了操作台;同时约定了“回头”——做完后要把咖啡送到你手里。ARM的BL指令(Branch with Link,带链接的跳转)也是这样一举两得的指令:它既把程序计数器 PC 改成目标函数地址,完成跳转;又把“回来后要做的事”——也就是下一条指令的地址——记在了LR寄存器(Link Register,链接寄存器)里。

LR在ARM体系里是R14的别名,专门用来存放“从调用中返回后继续执行的地址”。这是硬件设计的巧妙之处:跳转只是改变PC,但如果没有LR记录归途,CPU就会像忘了把咖啡送给谁的店员,做完就愣在原地。所以你只需要记住这个“点单动作”的核心:

BL指令在反汇编里长这样:BL 0x0001084c,其中十六进制数就是目标函数入口。与普通跳转B(Branch)相比,BL多加了一个“记便签”的动作,这就是它名字里“Link“的含义——把调用者和被调函数”链接”起来。理解这个动作,你就掌握了函数调用的第一块基石。

LR←PC+4,PC←目标函数地址\text{LR} \leftarrow \text{PC} + 4, \qquad \text{PC} \leftarrow \text{目标函数地址}
第一个等式表示“记下回来的路”,第二个表示“现在出发去干活”。
  1. 返回地址写入LRBL执行时,硬件自动取当前PC的下一条指令地址(Thumb模式下为PC+4,ARM模式下考虑流水线为PC+8)存入LR,保证回来后知道继续执行哪条指令。
    LR←PC+4\text{LR} \leftarrow \text{PC} + 4
  2. PC切换目标指令中携带的目标地址被载入PC,下一条要取的指令就从被调函数入口开始,即“跳”到了新代码。
    PC←目标函数地址\text{PC} \leftarrow \text{目标函数地址}

函数返回机制

咖啡做好后,店员喊一声“3号桌的拿铁好了”,顾客取走咖啡,一切回到正轨。对ARM处理器来说,“回到正轨”意味着:把LR里存的那个返回地址,放回PC。这样CPU就知道该继续执行调用者后面的哪条指令了。这个动作在ARM世界里有两个主流实现方式:BX LR 和 POP {PC}。

方式一:BX LR(Branch and Exchange,带状态切换的跳转)。它把LR的值复制到PC,同时能自动切换ARM/Thumb指令集状态(根据地址最低位判断)。这是编译生成的函数最常见的一条返回指令,相当于店员做完咖啡后,看着便签准确找到你。

方式二:POP {PC}。如果函数在入口处把LR压进了栈,那么返回时直接把它弹回PC,一步完成“恢复现场+返回”。为什么可以弹到PC?因为PC本质也是个寄存器(R15),往它里面写值就等于跳转。这条指令相当于店员把便签从备忘架上取下来,看一眼,然后照做。

对比来说,BX LR适合没有压栈的“轻量级”函数,短小直接;POP {PC}则和入口的PUSH {LR}成对出现,适合需要保存现场的函数。这两者就像店员的不同工作习惯——有人总把便签捏在手里(寄存器里),有人喜欢贴到墙上再操作(栈里),最终都能把咖啡准确送到。

PC←LR\text{PC} \leftarrow \text{LR}
PC←[SP],SP←SP+4\text{PC} \leftarrow [\text{SP}], \qquad \text{SP} \leftarrow \text{SP} + 4
  1. 读出便签把栈顶保存的返回地址读出,准备写入PC。
    PC←[SP]\text{PC} \leftarrow [\text{SP}]
  2. 栈指针上移SP加4,表示这个便签已被取走,栈顶指向下一个元素。
    SP←SP+4\text{SP} \leftarrow \text{SP} + 4

嵌套调用与压栈

如果顾客点完拿铁又说:“再加一块提拉米苏。”店员就得停下咖啡,先去冰箱拿蛋糕。问题来了:他还能记得咖啡做了一半吗? 聪明的店员会把写着“3号桌拿铁”的便签贴到墙上,等蛋糕给完,回头看一眼便签,继续做咖啡。ARM的嵌套调用(函数A调用函数B,B又调用函数C)正是这样:LR寄存器只有一个,当内层函数再次执行BL时,外层函数的返回地址就会被覆盖——便签被新单盖住了。所以在调用别人之前,先把当前的LR压入栈。

这个“压栈”动作在ARM汇编里写作PUSH {LR},返回时用POP {PC}配对称地弹出来。栈是一种后进先出的存储结构,就像店员贴便签时总往最上面贴,取的时候也先取最上面的——这保证了层层嵌套的函数能以完全逆序的次序返回。我们来看看嵌套调用的内存变化:

PUSH {LR}执行后,内层函数再怎么调用别人、覆盖LR,都影响不到墙上那张旧便签了。等到内层函数返回,POP {PC}把栈顶的值直接弹进PC,CPU就沿着“最上层便签 → 下一层便签 → 最初的便签”一层层走回最初的调用现场。这就是“栈帧”的雏形:每次函数调用,都会在栈上占一小块区域存放返回地址和局部变量,调用结束区域自动销毁。

[SP]←LR,SP←SP−4[\text{SP}] \leftarrow \text{LR}, \qquad \text{SP} \leftarrow \text{SP} - 4
  1. LR值落栈把当前LR的值(也就是“做到一半的位置”)存入栈顶单元。
    [SP]←LR[\text{SP}] \leftarrow \text{LR}
  2. SP向下生长ARM栈是向低地址增长的,压入4字节后SP减4,便签“贴”到了栈顶。
    SP←SP−4\text{SP} \leftarrow \text{SP} - 4

AAPCS调用约定

咖啡店总要有点规矩:点单要说清楚杯型、温度,取餐要报桌号。如果每个顾客各说各话,店里早就乱成一锅粥。ARM世界也有一套这样的“行业规矩”,叫AAPCS(Procedure Call Standard for the ARM Architecture,ARM架构过程调用标准)。它规定了函数之间怎么传参数、怎么收返回值、哪些寄存器谁负责保存——只要汇编和C代码都遵守这个约定,互调就不会出岔子。

AAPCS对寄存器的分工非常明确。R0-R3是“传话员”:前四个参数按顺序放进这几个寄存器,多了的参数放栈上。R0同时兼职“回报员”:函数返回值放在R0里。R4-R11是“长工”:被调用者(函数自己)必须保证返回时这些寄存器的值跟进来时一样,所以如果在函数里用了它们,得先压栈保存,返回前恢复。R12是临时寄存器(IP),R13就是SP,R14是LR,R15是PC。

这套约定,相当于店里挂着的“服务规范牌”。C语言编译器生成的代码严格遵守它,你的手写汇编如果要和C函数互调,也必须入乡随俗。比如一个C函数int add(int a, int b),它对应的汇编入口就知道:a在R0、b在R1,算完把结果写进R0再返回。你说,这不就是“桌号+订单纸”的汇编版吗?

f(R0,R1,R2,R3)→R0f(R_0, R_1, R_2, R_3) \rightarrow R_0
  1. 形参映射函数入口处,第一个实参在R0,第二在R1,依此类推直到R3;第5个及以后的参数按顺序压入栈。
    R0←arg1, R1←arg2, R2←arg3, R3←arg4R_0 \leftarrow \text{arg1}, \ R_1 \leftarrow \text{arg2}, \ R_2 \leftarrow \text{arg3}, \ R_3 \leftarrow \text{arg4}
  2. 返回值定向函数返回时,把返回值写入R0,调用者随后从R0中读取。
    R0←return_valueR_0 \leftarrow \text{return\_value}

栈8字节对齐

店里的货架每个格子都严丝合缝,纸箱只有对准卡槽才能平稳摞上去。ARM的栈也是这样:AAPCS规定,在函数调用发生的那一瞬间,SP必须保持8字节对齐。也就是说,SP的值应当是8的倍数。为什么这么较真?因为ARM处理器有些“双字加载”指令(比如LDRD、STRD,一次读写8字节)要求地址必须8字节对齐,否则会触发异常或者结果不可预期。只要栈始终8字节对齐,编译器就可以放心大胆地用这些高效指令。

那如果一个函数只是PUSH {LR},压了4字节,栈不就从8字节对齐变成4字节对齐了吗?是的,所以编译器在需要的时候会多压4字节“垫场”——比如PUSH {R4, LR}压了两项共8字节,恰好保住了对齐;如果只压LR一项,有的编译器会故意改成PUSH {R0, LR}来维持8字节边界。这就像店员往货架上放纸箱,发现差一个卡槽,就顺手放个空纸箱垫整齐。

为什么这个约定如此重要?因为C库、操作系统的动态链接器、第三方库全都按AAPCS编译,栈对齐一旦被破坏,任何一次库函数调用都可能触发“非对齐访问”崩溃。简而言之,8字节对齐是ARM世界所有软件协作的公共卡槽——卡槽对准了,整个货架才稳。

SP调整后≡0(mod8)\text{SP}_{\text{调整后}} \equiv 0 \pmod{8}
  1. 保证整除AAPCS约定在函数调用边界上,SP对8取模必须等于0。
    SP mod 8=0\text{SP} \bmod 8 = 0
  2. 压实双满足入口处如果只压了1个寄存器(4字节),SP会变成4字节对齐;此时再补压一个寄存器,总共压8字节,SP恢复8对齐。
    PUSH{R4,LR}: SP←SP−8\text{PUSH} \{R_4, LR\}: \ \text{SP} \leftarrow \text{SP} - 8

工程实践要点

咖啡店高峰期,订单成摞:有人要十杯美式,有人要五杯拿铁加两块蛋糕。店员要冷静地一层层处理,每接一单就把便签贴墙上,做完一单撕一张——这正是递归函数的工作方式。递归调用和普通嵌套调用没有本质区别,每次递归都会产生一个新的栈帧,LR照例被压栈保护。但栈空间不是无限的,递归深度太大就会“栈溢出”,就像墙上便签贴得太满,再也没地方贴了。

工程上还有两个场景值得注意。中断处理:中断发生时,硬件会自动把返回地址保存到专用寄存器(或栈)中,同时会用到LR,所以中断服务程序里如果调用了普通函数,同样要先保存LR。调试技巧:绝大多数调试器的“调用栈回溯(Call Stack)”功能,就是顺着栈帧里保存的返回地址,一层层还原出“谁调用了谁”,简直像店员根据墙上的便签还原整个点单流程。另外一个常见的优化叫尾调用优化(Tail Call Optimization):如果函数最后一步就是调用另一个函数,并直接返回其结果,编译器干脆复用当前栈帧,只发一条跳转指令,连LR都不覆盖——省事又高效。

可递归深度≈栈区大小单帧大小\text{可递归深度} \approx \frac{\text{栈区大小}}{\text{单帧大小}}
  1. 栈区总量一块栈内存总容量(例如嵌入式系统里常见的8KB)。
    Stack_Size\text{Stack\_Size}
  2. 单帧开销每次递归调用占用的栈空间(返回地址LR + 局部变量 + 暂存寄存器)。
    Frame_Size\text{Frame\_Size}
  3. 深度上限两者相除得到理论上最多能递归多少层,超出即溢出。
    Depth=Stack_SizeFrame_Size\text{Depth} = \frac{\text{Stack\_Size}}{\text{Frame\_Size}}

常见错误分析

店员也有手忙脚乱的时候:新单来了,他忘了把旧便签先贴墙就去做新单,等回来时,那单拿铁就这么凭空消失了。ARM编程里最常见的灾难性错误,正是这个。“LR丢失”——在需要嵌套调用时忘了PUSH {LR},内层BL把外层返回地址冲掉了,函数返回时跳到一个完全陌生的地址,程序瞬间跑飞(通常表现为HardFault异常)。

第二个常见错误是栈不平衡:入口处PUSH了两个寄存器,返回时只POP了一个,或者忘记在POP后把参数占用的栈空间回收。SP一旦没有回到原位,后续所有栈操作都会错位——函数返回值取错、局部变量被覆盖,调试时往往非常诡异。

第三个错误是寄存器冲突:你的汇编函数用R0做临时计算,却没意识到R0存放着调用者传来的参数或被调用者承诺的返回值,随意改写导致接口约定被破坏。最后还有对齐错误:函数入口只顾压一个LR,破坏了8字节对齐,随后调用printf这类C库函数时莫名崩溃。

想避开这些坑,核心就一条:把每次函数调用都当作严格的一进一出。PUSH和POP成对,BL和返回指令成对,使用寄存器前想清楚它是“谁的地盘”。就像成熟的店员,不管多忙,接新单前永远先把旧单贴到墙上——这是职业素养,也是ARM函数调用的生存法则。

平衡条件: SP入口=SP出口\text{平衡条件: } \text{SP}_{\text{入口}} = \text{SP}_{\text{出口}}
  1. 入口现场记录进入函数时的栈指针值(压栈前)。
    SPentry\text{SP}_{entry}
  2. 出口现场返回前栈指针必须恢复原值,说明所有PUSH都已配平POP。
    SPexit=SPentry\text{SP}_{exit} = \text{SP}_{entry}

AI 应用场景

嵌入式AI编译器中的函数调用优化

阶段:模型部署与编译优化

解决的问题:AI模型推理在ARM Cortex-M这类资源受限的单片机上运行时,函数调用的寄存器分配和返回方式直接影响推理延迟。传统手写代码缺乏统一约定,导致C函数与汇编算子无法安全互调,且调用开销不可控。基于AAPCS编译后,编译器能自动决定哪些函数用BX LR快速返回、哪些需要压栈保存现场,还能在RP2040等双核MCU上安全地做任务切换。

具体案例:在CMSIS-NN或TFLite Micro的卷积算子实现中,内层循环函数通常用汇编手写,外部框架用C调用。正是因为双方遵守AAPCS,R0-R3传参、R0返回、栈8字节对齐,框架才能放心地把张量指针和步长参数交给汇编函数,算子返回后整个推理流程无缝衔接。

移动端推理引擎的底层算子优化

阶段:端侧推理性能优化

解决的问题:在手机端的ARM CPU(如Cortex-A78)上运行AI模型时,深度可分离卷积等算子需要把Python层的一层层计算映射到高效的汇编级实现。每次算子内部的微内核函数调用,若返回地址保存不当或栈未对齐,轻则性能骤降,重则闪退。理解BL/LR机制就能看懂优化后的汇编代码为何大量使用尾调用和POP {PC}来减少栈帧开销。

具体案例:MNN或NCNN在ARM64上优化gemv算子时,将内层计算写成叶函数,直接用BX LR返回,省去压栈/弹栈的全部开销,使得单次算子调用减少数十纳秒——在每次推理上千次算子调用的场景里,累计节省可观的延迟。

固件安全分析的调用链追踪

阶段:AI辅助的二进制安全分析

解决的问题:在固件逆向中,AI模型用于识别函数边界,而人工需要理解调用链来确认漏洞路径。熟悉ARM的返回机制后,分析人员能借助栈上的返回地址还原出完整的调用链,明确哪些函数在攻击者可控的路径上。

具体案例:在分析某IoT设备固件的缓冲区溢出漏洞时,安全工程师利用调试器打印栈回溯,发现memcpy的调用者是通过BL进入的,而该函数入口忘了压LR,导致溢出后返回地址被覆盖——这正好印证了“嵌套调用不保存LR”的典型错误。修复建议:在函数入口PUSH {R4, LR},出口POP {R4, PC}。

Python 演示

python62 行
class ArmCallSimulator:    """ARM函数调用与返回的迷你模拟器"""     def __init__(self):        self.regs = {'pc': 0, 'lr': 0, 'r0': 0, 'sp': 0}        self.stack = []        self.insns = []        self.trace = []     def load(self, insns):        self.insns = insns     def run(self):        self.pc = 0        while self.pc < len(self.insns):            op = self.insns[self.pc][0]            self.trace.append('PC={:<3} 执行 {:<16} 栈={}'.format(                self.pc, op, list(self.stack)))            arg = self.insns[self.pc][1:]            if op == 'MOV':                self.regs['r0'] = arg[0]                self.pc += 1            elif op == 'BL':                self.regs['lr'] = self.pc + 1                self.pc = arg[0]            elif op == 'PUSH':                self.stack.append(self.regs['lr'])                self.pc += 1            elif op == 'POP':                self.regs['pc'] = self.stack.pop()                self.pc = self.regs['pc']            elif op == 'ADD':                self.regs['r0'] += self.regs['r0']                self.pc += 1            elif op == 'HALT':                break            else:                raise ValueError('未知指令: ' + op)     def report(self):        for line in self.trace:            print(line)        print('最终 R0 =', self.regs['r0'])  if __name__ == '__main__':    sim = ArmCallSimulator()    program = [        ('MOV', 3),        ('BL', 3),      # 调用 add_func(入口地址3)        ('HALT',),        ('PUSH',),      # add_func: 保存调用者返回地址        ('BL', 7),      # 调用 double_func(入口地址7)        ('POP',),       # 恢复并返回 main        ('HALT',),        ('PUSH',),      # double_func: 保存调用者返回地址        ('ADD',),       # R0 = R0 + R0        ('POP',),       # 返回 add_func    ]    sim.load(program)    sim.run()    sim.report()
目的:用Python模拟ARM处理器执行一段含嵌套调用的指令序列,观察BL如何更新LR、PUSH如何把返回地址压栈、POP又如何逐层恢复。
  1. self.regs['lr'] = self.pc + 1模拟BL的行为:先把下一条指令地址存入LR,再跳转。
  2. self.stack.append(self.regs['lr'])模拟PUSH {LR}:嵌套调用前,把外层返回地址压入栈保护。
  3. self.regs['pc'] = self.stack.pop()模拟POP {PC}:从栈顶弹出返回地址,写入PC实现返回。
  4. ('BL', 3),指令元组里第二个元素是目标地址,HALT是程序结束标记。
  5. self.pc = arg[0]BL的目标地址从指令参数中读取,模拟跳转。
  6. while self.pc < len(self.insns)主循环按PC依次取指、执行,直到停机。

常见误区

误区学习ARM汇编时,觉得`R14`(LR)和其他通用寄存器没什么两样,反正都是32位的寄存器,临时拿来存个数、算个加法应该也没问题吧?

正解LR是硬件层面的“专用”寄存器,BL指令会自动往里面写返回地址。一旦你用普通数据覆盖了它,函数返回时BX LR就会跳到乱七八糟的地址上。就像店员手里的便签,你拿它记快递单号,真正的外卖订单反而丢了。非要复用LR,必须先用PUSH {LR}保存,用完再恢复。

为何常见:新手寄存器表里看到14个“都差不多”的寄存器,且通用寄存器在这个语境下确实可以自由用,容易忽略LR在调用机制里的特殊地位。

误区既然`BL`和`BX LR`是一对,那所有函数都应该在出口写`BX LR`,`POP {PC}`是什么花哨写法?肯定不是主流。

正解BX LR只适合没有压栈的“轻量级”叶函数。一旦函数入口执行了PUSH {LR},出口就是POP {PC}——它把返回地址从栈里弹出来直接塞给PC,一步实现返回,优雅且高效。哪种方式取决于你入口做了什么,讲究的是“对称”。

为何常见:教材常以最简单的例子展示BL/BX LR对,让人误以为是唯一范式。等遇到PUSH/POP版本的真实编译产物时,反而认不出来。

误区我手写汇编,不连任何C库、不调外部函数,那我`SP`想怎么对齐就怎么对齐,8字节对齐是那些用高级语言的人才需要考虑的事吧?

正解只要你调用了任何符合AAPCS的函数,或者你的代码被C函数调用,栈对齐就必须守规矩。即使是手写汇编,如果在函数入口压一个4字节的LR导致SP从8对齐变成4对齐,再调用某个用双字加载指令的C库函数,轻则性能受损,重则直接HardFault。AOSP的Bionic库、FreeRTOS的可移植层都会强校验栈对齐。

为何常见:栈指针的错误不会立刻暴露,往往在系统里深埋好一阵才炸,这让“对齐无所谓”的错觉有了滋生的土壤。

面试题

Q1. 执行BL指令时,硬件自动把什么值写入LR?

单选题 · easy

  • A.当前指令的地址
  • B.下一条指令的地址← 正确答案
  • C.目标函数入口地址
  • D.栈顶SP的值

答案:B。BL带“链接”功能,先把PC的下一条指令地址存入LR作为返回地址,再跳转到目标函数。A保存的是当前执行指令,不是返回的位置;C是写入PC的目标地址而非LR;D是栈指针,与LR无关。

Q2. ARM函数返回时,BX LR指令的本质是?

单选题 · easy

  • A.跳转到LR指向的地址← 正确答案
  • B.清空LR寄存器
  • C.把LR压入栈
  • D.交换R0和LR的值

答案:A。BX LR将LR中的返回地址送入PC,CPU接着从那里取指令,同时根据地址最低位自动切换ARM/Thumb状态。B会丢失返回地址;C是PUSH的作用;D与返回机制完全无关。

Q3. 函数A调函数B,A内部在BL B之前插入了PUSH {LR},这样做的主要原因是?

单选题 · mid

  • A.让B能用LR保存局部变量
  • B.防止B执行BL时覆盖A的返回地址← 正确答案
  • C.把A的参数传给B
  • D.为了满足栈8字节对齐

答案:B。LR只有一个,A调用B时B内部的BL会把A的返回地址冲掉。先把LR压栈,等B返回后再POP {PC}就能找回原来的返回地址。A是错误理解;C靠R0-R3传参;D虽然是PUSH的效果之一但不是核心原因。

Q4. 按照AAPCS约定,前四个函数参数应依次放入哪些寄存器?

单选题 · mid

  • A.R0-R3← 正确答案
  • B.R4-R7
  • C.R8-R11
  • D.R12-R14

答案:A。AAPCS规定R0-R3作为参数寄存器,存放前4个参数,超出的参数压栈传递。R4-R11是被调用者保存寄存器,R12是临时寄存器,R13-R15依次是SP、LR、PC,都不是传参寄存器。

Q5. 一个ARM函数入口为PUSH {R4, LR},入口处SP为0x20000800(8字节对齐),压栈后SP变为?

单选题 · mid

  • A.0x200007F8← 正确答案
  • B.0x200007FC
  • C.0x20000804
  • D.0x20000808

答案:A。压两个寄存器共8字节,ARM栈向下生长,所以SP=0x20000800-8=0x200007F8,且仍为8的倍数(保持对齐)。B是只压一个寄存器的结果;C和D方向错误,栈向下生长不可能是增加。

Q6. 关于POP {PC}返回方式,下列说法正确的是?

单选题 · mid

  • A.把LR的值复制到PC
  • B.从栈顶弹出数值并写入PC← 正确答案
  • C.只能用于Thumb模式
  • D.会同时修改SP和LR

答案:B。POP {PC}从栈顶弹出返回地址直接写入PC,同时SP自动加4。它通常与入口的PUSH {LR}成对使用。A是BX LR的行为;C错误,ARM模式同样可用;D中LR不会被修改。

Q7. 为什么AAPCS强制要求函数调用边界上SP保持8字节对齐?

单选题 · hard

  • A.为了加快PUSH和POP的速度
  • B.为了支持LDRD/STRD双字访问并保证ABI互操作← 正确答案
  • C.为了防止栈溢出
  • D.为了让调试器更容易回溯调用栈

答案:B。LDRD/STRD等双字指令要求8字节对齐,否则结果不可预期;此外C库和动态链接器都按AAPCS编译,SP不对齐会破坏ABI兼容。A不是指令集的原因;C靠栈大小限制;D是栈回溯的辅助效果而非对齐的动机。

Q8. 手写汇编函数中,哪种情况发生在入口处必须保存LR?

单选题 · hard

  • A.函数内没有调用任何其他函数
  • B.函数内使用BL调用了另一个函数← 正确答案
  • C.函数只用R0做加法
  • D.函数内没有使用局部变量

答案:B。一旦函数内部执行BL,当前LR里的返回地址就会被覆盖,所以必须在嵌套调用前PUSH {LR}。A是叶函数,可以直接BX LR返回;C和D都不涉及LR被破坏。

Q9. 以下哪些做法不符合AAPCS调用约定?

多选题 · mid

  • A.用R0-R3传递前4个参数
  • B.用R4-R7传递第5、6个参数← 正确答案
  • C.返回值统一放在R0
  • D.被调用者修改R9后不做恢复← 正确答案

答案:BD。AAPCS规定第5个及以后的参数应压栈传递,而不是用R4-R7,所以B错;R4-R11属于被调用者保存寄存器,修改后必须恢复原值,所以D错。A和C严格符合AAPCS约定。

Q10. 关于LR丢失问题,以下哪些场景会造成返回地址不可恢复?

多选题 · hard

  • A.嵌套调用前忘记PUSH LR← 正确答案
  • B.用PUSH和POP保存了LR但中间执行中断
  • C.内层函数执行BL修改LR后直接BX LR返回
  • D.函数返回用BX LR但LR已被临时数据覆盖← 正确答案

答案:AD。A场景中内层BL直接覆盖外层LR,栈上没有备份,地址彻底丢失;D场景把LR当普通寄存器使用,返回时跳飞。B中中断有独立的现场保存机制,不破坏LR;C中内层BL后LR指向内层返回地址,直接BX LR是正确的内层返回行为。