ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

零基础入门ARM汇编:从工具链、寄存器到栈与函数调用

零基础入门ARM汇编:从工具链、寄存器到栈与函数调用

简介:《ARM汇编基础教程1》是一份面向ARM汇编初学者与逆向工程入门者的基础文档,围绕处理器版本与指令集映射、汇编链接流程、数据类型与字节序、寄存器组织及函数调用约定展开,帮助读者建立阅读和编写ARM汇编代码所需的知识框架。压缩包内共1个docx文件,约2.65MB,内容按七篇教程的脉络整理,涵盖R0-R15、SP、LR、PC与CPSR等寄存器的用途,大端小端与E位控制,前置参数存入R0-R3、栈帧管理和程序计数器流水线偏移等关键细节,并附拓展阅读链接便于延伸学习。文中结合示例说明as汇编与ld链接的产物关系,也点出调试器显示PC值与实际流水线行为的差异,适合对照实验加深理解。目前已有211人学习,可作为后续内存读写、条件分支、栈与函数等进阶主题的入门铺垫。

1. 先搞懂一件事:ARM 汇编到底是在跟谁打交道

很多人第一次听到「ARM 汇编」这四个字,脑子里浮现的是满屏看不懂的指令和一堆 R0、R1、R2 的编号,然后默默关掉页面。其实换个角度就通了:你写的 C 语言最后都要被翻译成 CPU 能听懂的一句一句话,ARM 汇编就是这种「一句话」的原始形态,它没有发明任何新东西,只是把 CPU 每天干的活原原本本摊开给你看。学它有很实在的理由——手机、开发板、路由器里跑着大量 ARM 芯片,你想知道一段程序到底动了哪块内存、函数怎么进去又怎么出来,只有站到这一层才看得清。这篇写给完全没碰过汇编的人,不要求你懂 ARM 架构,也不要求手上有板子,读完能把第一个例子跑起来,就算跨过门槛了。

2. 为什么零基础也值得学:一段 .s 文件背后的三个理由

在真正动手敲第一行指令之前,先花几分钟弄清楚「我为什么要花时间在这上面」以及「一个 .s 文件从写完到能跑,中间隔了几道工序」。这两件事想明白了,后面认寄存器、抄指令的时候就不容易半路放弃。很多人卡住不是因为难,而是因为不知道自己在流程的哪一步。

2.1 汇编不是玄学:它只是把「搬数据」写成了人话

CPU 内部其实只认得几类操作:把某个数搬到某个地方、把两个数加一加、比较两个数谁大、跳转到别的地方继续执行。汇编语言就是给这几件事各起了一个名字。比如你写的一行 C 语言a = b + c,编译器大概率会把它拆成四条汇编:把 b 从内存读进寄存器、把 c 读进另一个寄存器、两个寄存器相加、把结果写回内存。

第一次看到会觉得这也太啰嗦了,一行变四行。但反过来想,当程序在设备上莫名其妙崩溃时,你能拿到的往往就是这几行汇编。你能看懂它,就知道崩溃那一刻寄存器里装的是什么、栈被踩到了哪里;看不懂,那就只能面对一个黑匣子。这就是为什么做底层调试、嵌入式开发、逆向分析的人,最后都会被推回到汇编这一层。

还有两个更实际的理由。第一,ARM 芯片的出货量极大,你手上任何一个智能设备里几乎都能找到它,了解这套指令集等于给自己多开一扇门。第二,汇编会逼你正视「内存是有限的、寄存器是有限的」这件事,写过一阵子再回头写高级语言,你会对指针、栈溢出、性能开销这些东西明显更有感觉——这是很多人的血泪经验,光靠看文章补不上。

所以别把汇编当成一门「要背下来」的课,它更像一张地图。你不需要把每条街都记住,但要知道从哪里拐弯。

2.2 第一个例子怎么跑:从 .s 到可执行文件只要两条命令

新手最容易产生的误解是:汇编写完直接就能运行。其实中间要过两道手。汇编源码默认存成.s后缀,它不是可执行文件,只是文本;先用汇编器as把它翻译成目标文件.o,再用链接器ld把目标文件拼成一个能加载进内存跑的程序。

先看一个最小到不能再小的汇编文件,把它存成program.s:

@ program.s —— 一个什么都不做、立刻返回的最小 ARM 程序 .text @ 代码段:告诉汇编器下面的内容是可执行指令 .global main @ 把 main 声明成全局符号,链接器才找得到它 main: @ 标签,本质上是这一行在内存里的地址的名字 mov r0, #0 @ 按函数调用约定,返回值放在 r0,这里返回 0 bx lr @ 跳回调用者,相当于高级语言里的 return

逐行说一下。.text和.global都以点开头,它们不是指令,是给工具链看的指示,不会变成机器码。main:后面带冒号,是一个标签,汇编器会记住这个位置。mov r0, #0里的#是立即数的标记,#0就是数字 0 本身;很多人第一次看到#以为是注释,其实 ARM 汇编里@才是注释符。bx lr里的lr是链接寄存器,函数跳进来的时候返回地址就自动存在里头,跳回去靠的就是它。

然后打开终端,两条命令:

# 第一步:把汇编源码翻译成目标文件,-o 指定输出文件名 as program.s -o program.o # 第二步:把目标文件链接成可执行文件 program ld program.o -o program

as这条命令的输入是.s,输出是.o。.o里已经是机器码了,但地址还没最终确定,所以它还不能直接跑,需要一个链接器来「拍板」每个符号最后落在哪里。ld干的第二件事是确定程序入口,也就是加载后从哪条指令开始执行。

提示:如果你把上面这段原样链接后直接运行,有可能一执行就报段错误。原因是链接器默认的入口点不一定是main,而bx lr又需要lr里有合法返回地址。入门阶段两个省事的做法:一是链接时用ld program.o -o program -e main显式把入口点指到main;二是先用gcc program.s -o program带着启动代码一起链接,等熟悉了再回到纯净的as+ld。

跑通这一步的意义比看起来大。你会亲眼看到「文本 → 中间产物 → 可执行文件」这条链,以后任何一次报错,你都能立刻判断它出在哪一环:是as说语法不对,还是ld说找不到符号。这个判断力,比多背十条指令有用得多。

2.3 为什么建议新手先啃工具链,再啃指令集

刚上手的人常有一种冲动:跳过工具链,直接去背指令。结果往往是背了一堆mov、add,真拿到一个能跑的项目还是不知道从哪下手。

按「工具链先行」的顺序学,好处有三个。第一个是反馈快,你今天写三行汇编就能看到它跑起来或者崩掉,这种即时反馈会推着你往下走。第二个是错误信息看得懂,as报的错大多和写法有关,ld报的错大多和符号、入口有关,两类错误分清楚了,排查效率会高出一大截。第三个是它逼你面对真实环境,你不是在纸上写伪代码,而是在一台真机器上生成真的二进制文件。

顺着这条线,后面几章会依次把寄存器、数据类型、指令模板、栈和函数补齐。每一块都尽量配一个能自己动手改的小例子,改参数、看结果、再改回来,这比通读一遍文档管用。

3. 上手前先认人:16 个寄存器就是你的工作台

写汇编和写高级语言最大的手感差异在于,你没有变量名可用,能用的就是那么十几个格子。所以第一件事是把这些格子认全,知道每个大致负责什么。认不全也没关系,先把最常用的几个记住,剩下的遇到再查。

3.1 R0 到 R15 各管什么:一张表认全

ARM 芯片的寄存器数量跟具体版本有关,除了一些精简型号,通常有三十个左右的 32 位通用寄存器。但入门阶段真正要打交道的,是在任意特权模式下都能访问的 R0 到 R15,外加一个状态寄存器 CPSR。把它们分成两组看会清楚很多。

寄存器常见别名它主要负责什么
R0–R3无传参数、接返回值。函数前四个参数默认放这里,返回值默认放 R0
R4–R10无通用,函数内部存临时数据、指针
R11FP帧指针,标记当前函数栈帧的底边
R12IP内部程序调用寄存器,链接器做中间跳转时用
R13SP栈指针,指向栈顶
R14LR链接寄存器,函数调用时存返回地址
R15PC程序计数器,指向下一条要执行的指令
CPSR无状态寄存器,存标志位和处理器当前模式

这张表里最需要形成肌肉记忆的是 R0、SP、LR、PC 这四个。R0 是「传出去」和「接回来」的默认通道;SP 决定栈长什么样,你什么时候动它、动了多少,直接决定程序会不会踩到别的内存;LR 决定函数能不能回得去;PC 决定下一条执行哪。

R11 单独说一句。它在系统调用场景里会存调用的编号,同时又被当作帧指针用来划定栈帧的边界。一个寄存器身兼两职,看代码时要结合上下文判断它此刻代表哪一种。ARM 的函数调用约定还规定了前四个参数放在 R0 到 R3,超出的部分才走栈——这条约定你在读任何一段 ARM 汇编的时候都会反复遇到。

3.2 数据类型与大小端:一个数在内存里是怎么躺着的

ARM 支持的数据类型和高级语言大体对应:字节、半字、字,每一种又分有符号和无符号。有符号能表示正数和负数,代价是正数部分的范围变小;无符号只能表示零和正数,好处是正数范围更大。这个区别不是概念游戏,它直接体现在指令怎么写上——加载半字用-h,加载有符号半字用-sh,加载字节用-b,加载有符号字节用-sb,而字没有这种扩展后缀,因为它本身就占满了一个寄存器。

LDRH R0, [R1] @ 从 R1 指向的地址读一个半字(16 位)到 R0,高位置零 LDRSH R0, [R1] @ 读一个有符号半字,如果最高位是 1,高 16 位全部补 1 LDRB R0, [R1] @ 读一个字节(8 位),高 24 位置零 LDRSB R0, [R1] @ 读一个有符号字节,负数会被正确扩展成 32 位

差别就在补位上。同样是读两个字节0xFF 0xFF,当成无符号半字读进来是 65535;当成有符号半字读进来是 -1,寄存器里高 16 位会被全部填成 1。如果你在调试时发现某个值莫名其妙变成一大串 F,八成就是符号扩展没对上。

再说字节序。一个 32 位的数在内存里占四个字节,这四个字节谁在前谁在后,就是大小端。ARM 提供了一个开关:程序状态寄存器 CPSR 里的第 9 位,也叫 E 位,置 0 用小端序,置 1 用大端序。早期某些版本是固定小端的,后来的版本允许数据访问时切换。入门阶段你只要记住一件事:小端序下低位字节放在低地址,读出来的字节顺序和你肉眼看到的书写顺序是反的。调试器里看到0x12345678在内存里排成78 56 34 12,不是出错,是正常现象。

3.3 PC 为什么总比你以为的多走两步

这是新手最容易困惑的一个点,值得单独拿出来说。

先看一段代码:

.text .global main main: mov r0, pc @ 把 PC 的当前值读进 r0 mov r1, #2 @ 一条随意的后续指令 add r2, r1, r1 @ r2 = r1 + r1 = 4 bx lr @ 返回

假设调试器显示,执行到mov r0, pc这一行时,PC 的值是0xaaad43d8。按直觉,执行完这条指令后 R0 里应该是0xaaad43d8。但实际看到的是0xaaad43e0,正好多了 8。

原因有两层。表层原因是流水线:CPU 取指令、解码指令、执行指令用的是不同的硬件部件,这几步可以同时进行。因为 ARM 状态下每条指令固定 4 字节,CPU 在解码当前指令的时候就能顺手把下一条取回来,执行当前指令的时候已经在解码下一条、取再下一条了。这就是常说的三级流水线。所以当你执行地址0xaaad43d8处的指令时,PC 实际上已经指向了0xaaad43d8 + 0x8。

深一层的原因是历史包袱:老款 ARM 处理器在设计上就规定,读取 PC 得到的是当前指令往后两条的地址,后来的版本为了兼容一直保留着这个行为。所以你在手册上看到的「PC 指向下一条指令」,和你在调试器里读到的值,本来就不是同一个东西。调试器为了让显示更有逻辑,往往做了一层处理,把 PC 显示成当前正在执行的指令地址。

注意:这一条和 x86 的习惯完全相反。x86 上 PC 永远指向下一条要执行的指令,如果你是带着 x86 的经验过来的,这里最容易翻车。写 shellcode 或者做精确地址计算的时候,多算或少算 8 个字节,程序就是跑不通,而且报错信息往往毫无提示。

入门阶段的实用建议是:不要依赖「读 PC 得到某个确定值」这种写法。真要定位当前代码位置,用标签、用adr这类伪指令,比直接读 PC 稳得多。等到你需要写位置无关代码的时候,再回来仔细算这个偏移量。

4. 写出第一段能跑的汇编:mov、cmp、push 三个小实验

认完寄存器,接下来就是把指令拼起来。这一章挑三个最有代表性的动作:搬数据、做比较、动栈。三个都亲手跑一遍,你对 ARM 汇编的基本手感就建立起来了。

4.1 指令的基本模板:助记符、条件、目的、操作数

ARM 指令有一个相当规整的模板,大部分指令都能套进去:

助记符{S}{执行条件} 目的寄存器, 第一个操作数, 第二个操作数

拆开看。助记符就是这条指令干什么,比如mov是搬、add是加、cmp是比较。带S后缀表示执行完要更新 CPSR 里的标志位,比如adds和add的差别就在这。执行条件是一个可选项,写了就表示「只有 CPSR 的标志位满足这个条件,这条指令才执行」——这是 ARM 很有特色的一个设计,能省掉不少跳转。目的寄存器很好理解,结果放哪。第一个操作数一般是个寄存器,第二个操作数则灵活得多,业内管它叫可变操作数。

第二个操作数可以写成这几种形式:

#123 立即数,直接写死的数值 R1 另一个寄存器 R1, ASR #2 把 R1 的值算术右移 2 位后参与运算 R1, LSL #1 把 R1 的值逻辑左移 1 位后参与运算 R1, LSR #3 逻辑右移 3 位 R1, ROR #4 循环右移 4 位 R1, RRX 带扩展的循环右移 1 位

这个「移位跟着操作数走」的设计叫桶形移位器,是 ARM 模式比较有代表性的特性。它能把两步并成一步。举个最直观的例子:

@ 写法一:两条指令 mov r1, r0 @ 先把 r0 复制到 r1 lsl r1, r1, #1 @ 再把 r1 左移一位 @ 写法二:一条指令搞定同样的事 mov r1, r0, lsl #1 @ r1 = r0 * 2

第二种写法把移位直接塞进了mov里,一条顶两条。这就是为什么读 ARM 汇编时经常看到「一个操作数后面还挂着一串移位」,别被吓到,它只是在同一个位置多干了点活。

4.2 用 cmp 和 CPSR 标志位看懂「条件」是怎么回事

cmp是理解条件执行的关键。它做的事很简单:把两个操作数相减,但不保存结果,只更新 CPSR 里的标志位。也就是说,cmp r1, r2相当于做了一次r1 - r2的试探,然后根据结果置位。

.text .global main main: mov r1, #1 @ r1 = 1 mov r2, #2 @ r2 = 2 cmp r1, r2 @ 1 - 2 = -1,结果为负,N 标志位置 1 cmp r2, r1 @ 2 - 1 = 1,相减不借位,C 标志位置 1 cmp r2, r2 @ 2 - 2 = 0,Z 和 C 同时置 1 bx lr

三个cmp依次执行,你可以用调试器一步步看 CPSR 的变化。理解的关键是记住:cmp不会修改 R1 和 R2,动的只有 CPSR。

标志位里最常打交道的四个是 N、Z、C、V,它们和 x86 里的 SF、ZF、CF、OF 一一对应:

标志位全称什么时候置 1
NNegative运算结果为负数
ZZero运算结果为 0
CCarry加法有进位,或减法不需要借位
VOverflow结果超出 32 位补码能表示的范围

C 位值得多解释一句,因为它和很多人的直觉反着来。加法产生进位时 C 置 1,这个好理解;但减法在「不借位」的时候 C 置 1,也就是说减出来的结果是正数或零时 C 才是 1。所以2 - 1会让 C 置 1,1 - 2反而不会。V 位则是在结果超出 32 位补码范围时置位,具体说就是加法、减法或比较的结果大于等于 2 的 31 次方,或者小于负的 2 的 31 次方。

除了这四个,CPSR 里还有几个位值得认识。E 位控制大小端,前面说过。T 位置 1 表示当前处于 Thumb 模式,置 0 是 ARM 模式。M 位一共 5 位,表示处理器运行在哪个模式。J 位在某些处理器上和硬件执行 Java 字节码有关,入门阶段可以先跳过。

CPSR 的布局是从高位到低位排的,最左边是最高有效位。你在调试器里看到的那些加粗的标志位字母,就是这几位当前的状态。

4.3 栈和函数:push 和 pop 到底挪了什么

栈是内存里一块专门用来存临时数据的区域。操作它靠两个指令:push压进去,pop弹出来。这里有个容易忽略的事实——push和pop其实不是真正的指令,它们是一组内存读写指令的别名,只是写起来方便。

栈顶的位置由 SP 寄存器记录。ARM 的栈有几种实现风格,入门的教程通常用「满递减」这一种:SP 指向栈里最后一个有数据的格子,压栈时 SP 先减 4,再往里写。看例子:

.text .global main main: mov r0, #2 @ r0 = 2 push {r0} @ 把 r0 压栈,SP 减 4 mov r0, #3 @ r0 改成 3,把刚才的值覆盖掉 pop {r0} @ 从栈顶弹回 r0,SP 加 4 bx lr

一步步跟。执行mov r0, #2之后,SP 假设是0xfffefac0,R0 是 2。执行push {r0},SP 先减 4 变成0xfffefabc,然后把 R0 的值写进这个地址。接着mov r0, #3把 R0 改成 3,但栈里那份 2 还留着。最后pop {r0},从 SP 指向的地址读出 4 个字节写回 R0,R0 又变回 2,同时 SP 加 4 回到0xfffefac0。

这就是压栈弹栈的本质:挪指针,读写内存。理解这一点,后面看函数调用就不会觉得神秘了。

函数在栈上占的那一片区域叫栈帧。一个 ARM 函数基本由三段组成:序言、主体、尾声。序言负责把当前状态保存下来、给局部变量腾地方;主体干活;尾声把状态恢复回去,让程序能接着往下走。

@ 序言:进入函数时先做的事 push {r11, lr} @ 保存旧的帧指针和返回地址 add r11, sp, #0 @ 把帧指针钉在栈帧底部 sub sp, sp, #16 @ 给局部变量留出 16 字节空间 @ 主体:这里放真正的逻辑 mov r0, #1 mov r1, #2 bl max @ 调用另一个函数,结果会放在 r0 里 @ 尾声:离开函数前把现场恢复 sub sp, r11, #0 @ 把 SP 拉回栈帧底部,回收局部变量空间 pop {r11, pc} @ 恢复帧指针,并把保存的返回地址直接送进 PC

尾声最后那条pop {r11, pc}有点讲究:它把之前存下来的返回地址直接弹进了 PC,等于一步完成「恢复现场」和「跳回去」两件事,所以后面不需要再写一条bx lr。

最后说一个概念:叶函数和非叶函数。叶函数自己不调用别的函数,非叶函数会。两者的序言尾声写法略有差别,主要差在要不要保存 LR 上——叶函数因为没有下一层调用会覆盖 LR,理论上可以不存;非叶函数必须存,否则调完子函数回来就找不到回家的路了。

5. 新手最容易翻车的五个坑:现象、原因、解决

前面几章的例子都尽量写成了能跑的样子,但真到你自己动手改的时候,大概率还是会卡几次。这一章把最常见的几个卡点列出来,每条都按「看到什么现象 → 为什么会这样 → 怎么处理」写,遇到问题时可以直接对照。

5.1 汇编和链接阶段的坑

坑一:终端说as: command not found。

现象很直白,敲as --version没有任何输出,或者提示找不到命令。原因是你机器上没装针对 ARM 的汇编工具链,系统自带的as可能只认本机架构。解决办法是先确认自己装的是哪个目标平台的工具链,装好之后用as --version看输出里的目标三元组,确认它写的是 ARM 相关的架构,再回来跑命令。

坑二:as过了,ld报undefined reference to 'main'。

现象是汇编这一步成功了,生成.o文件,但链接时报找不到符号。原因通常是源码里漏了.global main这一行。这个指示的作用是把main声明成全局符号,链接器只认全局符号。标签main:只是给汇编器自己看的,不写.global,它在符号表里就是本地的,外面看不见。解决很简单,在标签前面补一行.global main,重新汇编链接。

坑三:链接成功,一运行就段错误。

现象是文件生成了,ls能看到,但一执行立刻崩。原因有两个常见来源。一是入口点不对,链接器默认的入口不一定是你的main,程序可能从一段没有指令的地方开始跑。二是bx lr执行的时候 LR 里是垃圾值,跳到了一个非法地址。处理办法:先试试ld program.o -o program -e main显式指定入口;如果还崩,就把bx lr临时换成一段死循环,先确认程序起码能进到你的代码里,再逐步加回返回逻辑。

5.2 寄存器和调试阶段的坑

坑四:mov r0, #0x1234汇编器报错。

现象是写了一个看起来完全正常的立即数,汇编器说这个立即数不合法。原因是 ARM 指令长度固定,立即数只有有限的编码空间,不是任意 32 位数都能直接写进指令里。解决办法有两种:用ldr r0, =0x1234让汇编器把它放到附近的常量池里再读进来;或者拆成两步,先mov一个能表示的近似值,再用移位或加法凑出目标值。

坑五:调试时读 PC,算出来的地址总差 8。

现象是你按「PC 指向下一条指令」去推算,怎么算都对不上,日志打出来的地址比预期大了 8。原因就是前面讲过的流水线加历史兼容:ARM 上读 PC 拿到的往往是当前指令地址加 8。解决办法是别依赖读 PC 来定位代码,需要当前地址时用标签配合adr这类伪指令;确实要手算偏移时,先确认当前是 ARM 状态还是 Thumb 状态,两者的偏移量不一样,ARM 加 8,Thumb 加 4。

对照这五条排查,大部分入门阶段的问题都能自己定位。真遇到卡死的,最有效的手段还是单步调试,一条指令一条指令地看寄存器变化,比对着代码空想快得多。

6. 下一步学什么:从看懂一段汇编到能自己写

跑通第一个例子之后,最怕的是不知道该往哪走。下面这条路是按「先能看懂、再能改、最后能写」的顺序排的,每一段都配了一个自检标准,达到了再往下走。

阶段要练的东西自检标准
第一段数据类型、寄存器、寻址方式能说清LDRB和LDRSB读同一块内存为什么结果不同
第二段指令集与条件执行能自己写出一段用条件跳转实现的简单判断逻辑
第三段内存读写与高级寻址能读懂带偏移和写回的加载存储指令,比如LDR R0, [R1, #4]!
第四段条件分支与循环能把一段 C 语言的 for 循环手工翻译成汇编
第五段栈与函数调用看到一个函数的序言尾声,能立刻指出栈帧里每一格是什么

按这个顺序走,最容易犯的错误是跳步。比如第一段还没弄明白符号扩展,就急着去看栈帧布局,结果就是每一段都似懂非懂。我第一次学的时候也这么干过,来回折腾好几遍才发现,最省时间的做法反而是回头把数据类型和寻址方式老老实实重看一遍。

具体到每天怎么练,我的建议是:找一段十几行的 C 代码,编译成汇编,然后逐行对着看,猜每一行在干什么,猜完再去查。猜错的地方就是你真正需要补的地方,比漫无目的地读文档精准得多。这个习惯我从入门一直保持到现在。

再给两个进阶方向。一是把 ARM 和 Thumb 两种状态的差别搞清楚,尤其是 Thumb 状态下指令长度变成 16 位之后,很多原来能用的写法要跟着调整,切换状态靠BX或BLX并把目标寄存器最低位置 1,这些细节在真正写位置无关代码时必须过一遍。二是学着看反汇编,随便挑一个自己写的小程序,用工具把它反汇编出来,对照源码看编译器做了什么优化,这是提升最快的一条路。

提示:不要一上来就追求写出「完整可运行的程序」。先把单个函数、单段逻辑写对,再考虑整体拼装。汇编里任何一个地址算错都会立刻反映成崩溃,模块化是唯一的后悔药。

从那以后我每次摸一个新架构,都强制走一遍这个流程:先写最小可运行的例子,再逐个往上加功能,每加一步验证一次。绕开这个流程直接写大段代码,翻车几乎是必然的。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进