资讯动态

ARM汇编函数调用实战:用斐波那契数列啃透递归与栈帧

发布时间:2026/10/14 21:45:04 来源:尧图企业网站定制
1. 为什么用斐波那契数列来啃ARM汇编函数调用老读者都知道我写ARM汇编系列从来不搞花架子。这次选择斐波那契数列做函数调用实战不是因为算起来多复杂恰恰是因为它的逻辑足够简单、但覆盖的函数调用知识点足够全面。递归调用、参数传递、栈帧管理、返回地址保存、现场恢复这些ARM汇编里最核心、最容易踩坑的东西全都压在一个只有几十行的程序里。标题里写“第20部分”说明前面已经铺垫了不少基础内容。如果你是从零开始看这个系列建议先把寄存器命名、基本寻址方式、LDR/STR指令搞清楚再来不然直接上手递归会比较吃力。当然如果你只是想看看函数调用这块到底怎么运作那这一篇完全可以当作主线教程来看。先交代一下我这边的运行环境一台x86_64的Linux机器安装了交叉编译工具链目标架构是ARMv732位ARM用QEMU模拟器跑可执行文件用GDB配合调试器做单步跟踪。之所以选32位ARM而不是ARM64是因为32位ARM的调用约定在寄存器数量、参数传递规则上更直观教学价值更高。后面的示例代码全部按照这个环境来写。斐波那契数列本身不需要多解释F(0)0F(1)1F(n)F(n-1)F(n-2)。用递归实现几乎是所有编程课的标准练习但绝大多数人只在C语言里写过递归很少有机会把递归一层层拆开盯着栈帧看它到底是怎么生长的。这次我们就干这件事。2. ARM函数调用的核心机制2.1 参数的搬运工寄存器传递规则看汇编代码之前必须先搞清楚32位ARM下函数调用的约定。这部分的依据是ARM官方发布的AAPCSProcedure Call Standard for ARM ArchitectureARM架构过程调用标准它是整个ARM函数调用的“交通规则”。AAPCS规定函数的前4个参数分别使用r0、r1、r2、r3这四个寄存器传递。超过4个参数时多余的参数通过栈传递。返回值放在r0中。这就意味着如果一个函数接收两个参数调用者需要把第一个参数放进r0第二个参数放进r1然后执行BL指令跳转。被调函数在入口处直接使用r0和r1根本不需要从栈上取参数。这套规则和x86那套“全部压栈再调用”的体系完全不同。x86的调用者要把参数一个个push进栈被调函数通过EBP加偏移量访问而ARM在寄存器够用的情况下压根不走内存一个周期就完成参数传递效率高得多。这也是当年ARM能用更低的功耗跑出够用性能的重要原因之一。这里埋着一个新手容易踩的坑r0到r3是“调用者保存”的意味着如果你在一个函数里调用了其他函数你自己的r0到r3会被内层调用覆盖。如果外层函数在被调用之后还需要使用这些寄存器的值就必须在调用之前先把它们保存到栈上。这个规则在整个函数调用的实现里极其关键后面写代码的时候会反复碰到。2.2 栈帧与寄存器使用规则除了r0到r3这组“临时寄存器”AAPCS还定义了其他寄存器的分工寄存器在函数调用中的角色谁负责保存r0-r3参数传递、返回值、临时使用调用者负责保存r4-r11函数内部的局部变量、计算中间值被调函数负责保存r12跨调用临时寄存器IP调用者负责保存r13栈指针SP保持不变r14链接寄存器LR保存函数返回地址被调函数负责保存r15程序计数器PC特殊寄存器这里做个生活化类比r0-r3就像办公室里的公共会议室谁开会谁用开完会别人随时可能进去用r4-r11是私人办公桌谁分配到就是谁的但用完必须整理干净恢复原值否则下一个使用的人会“继承”一堆脏数据r13是保安永远站在栈顶不能乱动r14是“返程车票”BL跳转时自动把下一条指令的地址放进去。有一个细节值得注意BL指令的功能是“跳转并保存返回地址”它会把BL下一条指令的地址写入LR寄存器然后跳转到目标地址执行。这意味着被调函数执行完毕后只需要“MOV PC, LR”或者“BX LR”就能回到调用点。但问题来了如果一个函数内部还要继续调用别的函数LR就会被覆盖。所以任何需要嵌套调用的函数第一件事就是先把LR压栈保存。这正好解释了为什么斐波那契数列是完美的教学案例它的递归调用会在每个函数体内再次触发BLLR的保存与恢复是必须处理的躲都躲不掉。3. 斐波那契数列的汇编实现拆解3.1 从C语言到汇编的算法映射先用C语言把逻辑定下来作为汇编实现的地图int fib(int n) { if (n 0) return 0; if (n 1) return 1; return fib(n - 1) fib(n - 2); }如果输入参数n等于0直接返回0n等于1直接返回1否则递归调用自身分别求n-1和n-2的结果再相加。搬运到汇编里需要做几件事第一函数入口处保存LR和需要使用的r4-r11寄存器第二用条件比较指令判断n的值第三递归调用前把参数准备好第四处理返回值。这里的重点在于递归调用里对“n的保存”。假设当前函数的n存在r4里调用fib(n-1)之前n-1会被放进r0作为参数传递但调用完成后r0会被返回值覆盖。如果后面还要用原来的n去计算n-2就必须保证n在递归调用期间还活着。这就是为什么要把n保存到栈上或者保存到一个“被调函数负责保存”的寄存器里。3.2 完整的汇编代码与逐行注释下面是我用的完整实现文件名为fib.s汇编器语法是GNU as的ARM汇编格式.syntax unified .arch armv7-a .text .global fib .type fib, %function fib: 函数入口保存LR和r4到栈上 push {r4, lr} 计算逻辑开始 mov r4, r0 r4 n把输入参数n暂存到r4 cmp r4, #0 比较n和0 beq .return_0 如果n 0跳转到返回0的分支 cmp r4, #1 比较n和1 beq .return_1 如果n 1跳转到返回1的分支 递归调用fib(n-1) sub r0, r4, #1 r0 n - 1 bl fib 调用fib(n-1) mov r5, r0 r5 fib(n-1)保存第一次递归的结果 递归调用fib(n-2) sub r0, r4, #2 r0 n - 2 bl fib 调用fib(n-2) 合并结果 add r0, r5, r0 r0 fib(n-1) fib(n-2) pop {r4, lr} 恢复LR和r4 bx lr 返回 .return_0: mov r0, #0 返回0 pop {r4, lr} bx lr .return_1: mov r0, #1 返回1 pop {r4, lr} bx lr .size fib, .-fib主程序部分我用一个简单的启动代码负责调用fib(5)然后把结果写到某个内存位置以便观察.syntax unified .arch armv7-a .text .global _start .type _start, %function _start: mov r0, #5 计算fib(5) bl fib 调用fib函数 mov r4, r0 保存返回值 把返回值写到内存地址0x10000 ldr r5, 0x10000 str r4, [r5] 退出程序Linux ARM系统调用exit mov r7, #1 mov r0, #0 svc #0 .size _start, .-_start这里特别说明一下push {r4, lr}这条指令。它是一条伪指令实际展开为两条存储指令的简写先调整SP再把r4和LR分别存到栈上。要注意的是压栈顺序和寄存器列表的书写顺序无关ARM规定编号大的寄存器先入栈在高地址编号小的后入栈在低地址SP最终指向低地址。弹栈时pop {r4, lr}会把栈顶数据按编号从小到大弹回寄存器。搞反的话要么r4和lr的值互换位置要么整个程序崩掉。3.3 为什么r5保存fib(n-1)而不是直接加我在代码里用了一个r5来保存第一次递归的返回值可能有人会问为什么不直接add r0, r0, r0这样省个寄存器关键在于第二次调用fib(n-2)执行完成后r0里放的是fib(n-2)的结果而fib(n-1)的结果早就被覆盖了。如果不先把fib(n-1)存到一个安全的地方后续就没法完成加法。为什么选r5而不是直接存在栈上AAPCS规定r4-r11由被调函数保存这意味着任何被调函数使用这些寄存器时都必须先保存原值、用完恢复。fib是我们自己写的函数内部使用r4来保存当前n、用r5保存中间结果但fib在递归调用时又会调用自身——那么内层的fib执行时它也会用r4和r5这就产生了嵌套覆盖问题。这个问题的解法就是前面提到的压栈技巧。外层fib在调用bl fib之前r4和r5里保存的数据必须存在于某个不会被内层调用的地方。在我们的实现中因为外层fib使用的是自己的r4和r5内层fib也会使用同样的寄存器内层fib的push和pop操作已经把问题解决了内层fib用r4和r5之前先把它们的旧值压栈保存调用结束后再恢复。所以外层fib的r4和r5在内层调用前后保持不变。不过要提醒一点在我们的代码里外层fib第一次调用fib(n-1)之后把返回值放进了r5紧接着第二次调用fib(n-2)这个过程中内层fib也会使用r5但因为内层fib遵守“被调函数保存r4-r11”的规则所以r5在外层第二次BL之前和之后的值是不变的。这套规则环环相扣凡是遵守AAPCS的代码就不会互相破坏现场不守规矩的代码早晚会程序崩溃。4. 编译、运行与堆栈观测4.1 从汇编到可执行文件我这里用的是标准工具链组合交叉编译器、汇编器、链接器都来自GNU工具链。在x86主机上编译ARM汇编需要指定目标架构前缀。假设工具链名为arm-linux-gnueabihf完整的编译流程如下# 第一步汇编 arm-linux-gnueabihf-gcc -c fib.s -o fib.o # 第二步链接 arm-linux-gnueabihf-ld fib.o -o fib.elf # 第三步用QEMU运行 qemu-arm ./fib.elf注意如果主程序和fib函数写在同一个.s文件里第二步直接链接即可如果分成两个文件需要把两个.o文件一起传给链接器。我第一次写的时候把fib函数单独放一个文件主程序放另一个文件链接命令是arm-linux-gnueabihf-ld fib.o start.o -o fib.elf链接顺序无所谓链接器会通过符号表解析引用。还有一种更省事的方式直接用交叉编译器一步到位arm-linux-gnueabihf-gcc -nostdlib -static fib.s -o fib.elf我这里贴出的启动代码没有依赖任何C库函数所以不需要标准库。如果你用的是带_start符号的独立汇编程序加-nostdlib避免链接器去找C启动代码。以前有人问过我为什么不用GCC直接编译C文件然后反汇编看生成的汇编当然可以但反汇编出来的代码是编译器生成的经常包含一些编译器特有的优化和调度技巧不适合当入门教程逐行讲。手写的汇编虽然性能未必比编译器的好但每一行的意图都清清楚楚。4.2 单步追踪观察栈的呼吸运行只能看到结果想真正理解递归还得靠调试器。QEMU可以模拟ARM用户态程序GDB通过远程协议连上去就能单步调试。启动方式qemu-arm -g 1234 ./fib.elf另一个终端执行arm-linux-gnueabihf-gdb ./fib.elf target remote :1234然后就可以用b fib设置断点用si单步执行指令用info registers查看寄存器状态。我最常用的操作是在fib函数入口处打断点然后执行continue每停一次就记录SP的值和当前的n。你跑一遍就会发现SP每次进入fib时递减8字节因为push了r4和lr返回时又恢复。递归深度越大栈越往下长像湖水一层层往下钻。用GDB跑fib(5)的时候断点会触发多少次答案是15次。因为fib(0)一次、fib(1)一次递归树的叶子节点10个加上内部节点5个正好15次函数调用。这个数字可以用来核对你的递归调用计数逻辑是否正确。4.3 一个最容易被忽略的细节BL与BX的配对在整个实现里函数入口用push {r4, lr}保存返回地址出口用pop {r4, lr}再bx lr。这个“BL跳入、BX返回”的配对是ARM函数调用的固定动作。有人会问为什么不用mov pc, lr而用bx lr在现代ARM处理器上bx lr除了跳转还会根据目标地址的bit 0切换ARM/Thumb指令集状态。如果你在某处用Thumb模式编译了一个函数返回时只用mov pc, lr可能因为地址bit 0不正确导致模式错乱、直接异常。bx lr能自动识别当前指令集状态安全得多。这也算是ARM架构向下的一个“版本兼容性陷阱”。另外注意我们的启动代码用了svc #0来触发Linux系统调用通过寄存器r7传入系统调用号。ARM Linux的exit系统调用号是1所以mov r7, #1然后svc #0退出。有些资料里写的是swiARM里svc和swi指令助记符等价新工具链推荐用svc。5. 常见问题与排查技巧实录5.1 递归深度过大时的栈溢出斐波那契数列求第30项就已经需要fib(29)fib(28)这样大规模的计算了但如果只是求fib(10)、fib(15)栈空间还不至于吃紧。真正的问题是递归实现的时间复杂度是O(2^n)n40时函数调用次数已经超过3亿次即使栈空间够运行时间也让人无法接受。我在测试时用n30跑了一次qemu花费的时间明显变长。这提醒我用汇编做演示可以真正做计算必须换成非递归方案。如果只是做栈空间验证可以故意缩小栈空间看程序崩溃ulimit -s 128然后把n调大到20以上程序几乎立刻段错误。这种崩溃排查起来很容易GDB会告诉你栈指针跑到非法地址。但有一个隐蔽的坑是如果溢出发生在最深处的fib调用里未必会直接报段错误而可能先破坏相邻内存导致后续返回地址错乱报出一个莫名其妙的反汇编地址。这种时候别慌查看LR寄存器的值往回推算调用链就能定位。5.2 寄存器冲突的隐蔽bug如果执行结果完全错误比如fib(5)算出来是10而不是5不用怀疑算法逻辑问题一定出在寄存器保存上。最典型的错误是在fib函数里把n保存在r3里。r3属于r0-r3这一组“调用者保存”寄存器它会被内层调用覆盖。当你执行bl fib去算n-1时内层fib一入口就直接把输入参数当作它的n同时可能把r3用作临时变量。外层fib回来后发现r3已经不是原来的n后面算n-2时全乱了。另一个常见错误使用了r0-r3中的寄存器保存跨调用数据。我之前写过一版把fib(n-1)的结果保存在r2里第二次递归返回后发现r2已经被污染结果完全不正确。排查这种问题的技巧很简单不要猜直接调试。在两次递归调用之间打断点分别打印r0-r5和栈上的值。只要在BL之前和BL之后对照一遍寄存器立刻能找到是哪个寄存器被覆盖了。5.3 常见问题速查表现象可能原因排查方法程序崩溃PC异常LR未正确保存/恢复检查函数入口是否push lr出口是否正确bx lr返回值偏大或偏小中间结果寄存器被覆盖检查是否用了r0-r3保存跨调用数据死循环递归终止条件写错检查cmp和beq的跳转目标qemu运行无输出退出系统调用参数不对检查r7是否为exit系统调用号链接报undefined reference全局符号未声明.global检查是否写了.global fib6. 性能追踪与优化思路6.1 用汇编代码统计函数调用次数既然都写了汇编顺手做一个“调用次数统计”也不难还能直观感受到递归的爆炸性增长。思路是在fib函数入口放一个计数器变量.data call_count: .word 0 .text fib: ldr r0, call_count ldr r1, [r0] add r1, r1, #1 str r1, [r0] 原始fib逻辑...注意这里有个问题我在fib入口处直接用了r0和r1但r0是当前函数的参数n。必须先把这个参数保存到安全寄存器里比如r4再做计数逻辑。所以正确顺序是fib: push {r4, lr} mov r4, r0 先保存参数 ldr r0, call_count 再用r0做计数操作 ldr r1, [r0] add r1, r1, #1 str r1, [r0] mov r0, r4 恢复参数实测fib(10)调用次数为177次fib(20)为21891次fib(30)为2692537次。这个增长速度就是O(2^n)的直观解释。统计完记得在退出之前把call_count写到内存或通过其他方式输出不然白算。6.2 为什么不建议用递归写斐波那契写汇编教程归教程但实际工程中真用递归算斐波那契等于在浪费CPU周期。同一个n会被反复计算无数次这种“重叠子问题”正是动态规划擅长解决的场景。换成迭代写法一个循环就能在O(n)时间内算出第n项而且完全不需要递归栈。ARM汇编里用循环实现也很简洁只需要两个寄存器做递推、一个寄存器做计数 迭代版fib(n)输入r0n输出r0result fib_iter: cmp r0, #0 beq .fib_zero mov r1, #0 fib(0) mov r2, #1 fib(1) subs r0, r0, #1 剩余循环次数 .fib_loop: beq .fib_done 循环结束 add r3, r1, r2 r3 r1 r2 mov r1, r2 前一项 mov r2, r3 当前项 subs r0, r0, #1 减计数 b .fib_loop .fib_done: mov r0, r2 bx lr .fib_zero: mov r0, #0 bx lr这个版本的栈使用是O(1)执行时间是O(n)和递归版完全不是一个量级。测试fib(30)时迭代版在QEMU里基本秒出递归版要好几秒。不过递归版承载的教学价值依然是不可替代的只有递归才能把函数调用的机制完整体现出来迭代版根本用不到栈帧、LR保存这些东西。所以我的建议是学习阶段用递归版把机制吃透工程阶段用迭代版把事情做对。7. 最后再分享一点实际操作中的体会写完这一整套实现和调试记录我自己最大的收获其实不在斐波那契本身而在于通过它把AAPCS这套规则从纸面变成了肌肉记忆。以前看文档里写“r4-r11是被调函数保存”说实话印象并不深刻。但当我真的在栈上push、pop、看着SP一点点下移又恢复r4和r5在内层调用之间稳定存活的时候这套规则才真正变成了我写汇编时的默认思维方式。现在写任何ARM汇编函数入口第一行条件反射就是“这个函数会不会调别的函数LR要不要压栈我后续要用的寄存器会不会被覆盖”如果你也想练透这个知识点我建议你做三件事第一亲手把上面的代码敲一遍不要复制粘贴第二用GDB单步跟踪到fib(3)或者fib(4)把每一次进栈出栈的SP变化都记录下来第三试着改一版用循环实现的斐波那契对比两个版本的调用次数。做完这三件事ARM函数调用这块基本就拿下了一大半。另外一个非常实用的小技巧是调试汇编程序时不要只盯着C语言调试器那种“断点加观察变量”的用法。汇编调试最有效的工具是单步执行加随时查看寄存器。GDB里layout regs命令可以把寄存器窗口常驻在屏幕上方每执行一条指令寄存器值实时刷新栈指针的变化一目了然。我第一次用这个功能观察递归返回值时整个调用过程瞬间通透。如果后续还有精力可以接着往深了挖比如尝试用BLX指令实现间接调用函数指针、实现多文件编译、或者把fib改成C语言调用汇编函数、汇编调用C函数混合编程。ARM汇编的函数调用是个大话题从斐波那契这个入口进去沿途能学到的远比数列本身多得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑