ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ARM汇编入门:R0-R15寄存器、CPSR与最小程序实战

ARM汇编入门:R0-R15寄存器、CPSR与最小程序实战

简介:这份 ARM 汇编基础教程面向嵌入式开发与逆向工程入门者,从零梳理 ARM 汇编的核心概念,帮助读者建立处理器版本、指令集与寄存器体系的整体认知。教程依次讲解 ARM 处理器版本与指令集的映射关系、.s 源文件经 as 汇编与 ld 链接生成可执行程序的完整流程、有符号与无符号的字/半字/字节数据类型以及由 CPSR 的 E 位控制的大小端字节序;随后逐一说明 R0–R15 与 CPSR 的职责,包括 R0 作累加器与返回值、R11 作栈帧指针、R13 栈指针、R14 保存返回地址、R15 程序计数器,并结合三级流水线解释读取 PC 时出现地址偏移的原因,末尾补充前四个参数经 R0–R3 传递的调用约定与延伸阅读书目。压缩包共 1 个 docx 文档,约 2.65MB,按篇组织、便于对照查阅。已有 209 人学习,可作为 ARM 平台开发与漏洞分析入门的起点,也为内存读写、条件分支、栈和函数等后续篇目做好铺垫。

1. 新手第一眼:ARM 汇编基础教程1 到底是个啥

你第一次打开调试器里的反汇编窗口是什么感觉?我猜和我差不多:满屏的 mov、push、pop、bx,像在看一门没人教过的外语。其实它们没那么玄,说白了就是 CPU 一条一条真正在执行的“指令条”。ARM 汇编基础教程1讲的就是这些指令条长什么样、怎么读、怎么写,以及一个后缀为 .s 的文本文件,怎么被 as 和 ld 一步步加工成能跑起来的程序。它属于 ARM 汇编入门的第一站,内容覆盖处理器版本与指令集的对应关系、数据类型、R0 到 R15 寄存器、CPSR 状态位,以及一点栈和函数调用的影子。适合刚接触底层、想搞懂程序在真机上到底干了什么事的新手。读完这一篇,你至少能对着一条 mov 指令说出它在动哪个寄存器,不再被调试器吓退。

2. 为什么要学 ARM 汇编:三个场景让你觉得它真用得上

很多新手听到“汇编”两个字就头大,心里想的是:我平时写 Python、写 Java,连指针都不太碰,学这个干嘛。这个反应很正常,我一开始也这么想。但有几类时刻,汇编会突然从“选修课”变成“必修课”,你躲都躲不掉。下面这三个场景,是我自己以及身边人真实遇到过的,不是吓唬人。

2.1 场景一:程序在真机上崩了,C 代码什么线索都没给

你写了一段 C 程序,在自己的电脑上跑得好好的,一放到目标设备上就挂了。设备只返回一个崩溃地址,加一段栈回溯,行号、函数名全是问号。你打开源码左看右看,找不到问题在哪。这时候唯一的线索,就是那段地址对应的反汇编。你得能看懂ldr r3, [r0, #4]是在从一个结构体里取第四个字节之后的字段,才能反推出是哪个指针为空、哪次访问越界。看不懂汇编,这条线索就等于没有,你只能靠猜,或者靠加一堆 printf 去试,这在嵌入式环境里往往连 printf 都没有。

这个场景不是只有做底层的人才会遇到。做后端性能分析、做崩溃日志归类、做安全审计,只要涉及“没有源码或源码不可信”的现场,汇编就是唯一能用的语言。学它的目的不是让你天天手写汇编,而是让你在关键时刻有工具可查。

提示:新手最容易犯的错是“等遇到了再学”。真到线上崩溃那一刻,你连寄存器哪个是参数、哪个是返回值都分不清,根本来不及。

2.2 场景二:看逆向分析、安全研究时,ARM 是绕不开的语言

手机、路由器、各种带算力的小设备,绝大多数跑在 ARM 架构上。你做协议分析、做固件检查、做恶意样本的静态阅读,拿到的往往是一坨二进制,没有符号表,没有源码。你要判断这段代码在干什么,只能从入口点开始,一条指令一条指令地读。读得懂的人,十分钟能画出调用关系;读不懂的人,盯着十六进制发呆。ARM 汇编基础教程1 这种入门材料,价值就在于它先把“指令集长什么样、寄存器怎么用”这层窗户纸捅破,让你后面读真实样本时不至于从零开始。

这里我要提醒一句:别一上来就去啃厚厚的架构手册。新手最容易被那种几百页的文档劝退。先跟着教程把常用指令认熟,把 R0-R15 的职责记牢,比背完整本手册有用得多。

2.3 场景三:嵌入式与底层调优,每一条指令都算数

在资源紧张的设备上,代码大小和周期都是硬指标。同一段逻辑,用不同的写法编译出来,可能差几十个字节,也可能差几十个时钟周期。你如果只看 C 代码,是感受不到这些差异的;只有看反汇编,才能发现编译器把你的循环展开成了什么样、有没有偷偷插入栈保护、某个判断是不是被优化成了条件执行。知道mov和ldr在字节数上的差别,你才知道为什么固件会超容量,为什么某个中断响应慢了一拍。这类知识,在纯高级语言的世界里是学不到的。

说了这么多“为什么”,接下来该动手了。新手最先要做的不是写代码,而是把工具装好。如果你本来就在一块 ARM 开发板或者树莓派上工作,系统里通常已经带了 binutils,直接用就行;如果你在普通的 x86 电脑上,需要一套交叉工具链,再加一个能在本机模拟运行 ARM 程序的工具。

# 在 Debian/Ubuntu 系主机上安装交叉汇编、链接、调试和模拟运行工具 sudo apt update sudo apt install -y gcc-arm-linux-gnueabihf qemu-user gdb-multiarch

这三样东西各管一段:gcc-arm-linux-gnueabihf这个包里带着arm-linux-gnueabihf-as和arm-linux-gnueabihf-ld,负责把 .s 变成目标文件和可执行文件;qemu-user让你在 x86 机器上直接跑编译出来的 ARM Linux 程序,不需要真的买一块板子;gdb-multiarch是能调试多种架构的 GDB,后面看寄存器全靠它。装完之后可以用arm-linux-gnueabihf-as --version验证一下,能打印出版本信息就说明装好了。

3. 第一个例子怎么跑起来:从 .s 文件到可执行程序的全过程

工具装好之后,最怕的就是“不知道从哪下手”。我的建议是:第一个例子越小越好,小到只剩三条指令,跑通之后再往上加。这一章就带你走一遍完整流程,包括写文件、汇编、链接、运行,以及每一步到底发生了什么。别急着背指令,先让程序跑起来,成就感比什么都重要。

3.1 写一个最小的 .s 文件,并弄懂每一行

新建一个文件,命名成first.s,注意后缀是小写的.s,大写.S会被 C 预处理器先处理一遍,新手很容易在这里翻车。文件内容如下:

@ first.s:最小的 ARM Linux 可执行程序 .text @ 告诉汇编器:下面是代码段 .global _start @ 把 _start 导出为全局符号,链接器靠它找入口 _start: mov r0, #42 @ r0 放退出码,42 这个数字随便换 mov r7, #1 @ r7 放系统调用号,1 在 ARM Linux 上代表 exit svc #0 @ 触发软中断,把控制权交给内核,程序结束

逐行解释一下。.text是段声明,表示后面的内容放进代码段;不写它,汇编器也能过,但产物结构会乱,新手最好养成习惯。.global _start把_start这个标签暴露给链接器,链接器默认就是找这个名字当程序入口,名字写错会报 “cannot find entry symbol”。mov r0, #42是把立即数 42 放进 r0,在 Linux 的系统调用约定里,r0 既是第一个参数,也是返回值。mov r7, #1把系统调用号 1 放进 r7,1 就是 exit。最后svc #0是软中断指令,CPU 从这里跳进内核,内核看到 r7 是 1,就按退出处理,把 r0 的值当作退出码返回给 shell。

为什么不用bx lr结尾?因为_start是程序入口,没有“调用者”给你返回,lr 里装的值不可靠,直接 bx lr 基本就是段错误。新手第一个例子跑出 Segmentation fault,十有八九是这里用错了。用 exit 系统调用收尾,是最稳的写法。

3.2 汇编、链接两步走:as 和 ld 各自做了什么

接下来执行两条命令。注意如果你用的是交叉工具链,命令名前面要带前缀,下面用arm-linux-gnueabihf-举例:

# 第一步:汇编,把 .s 文本翻译成 .o 目标文件 arm-linux-gnueabihf-as -o first.o first.s # 第二步:链接,把 .o 组织成可执行文件 arm-linux-gnueabihf-ld -o first first.o

第一条命令调的是汇编器 as,它做的事很单纯:把每条汇编指令翻译成对应的机器码,输出一个.o文件。这个阶段还不会解析外部符号,也不会决定代码最终放在内存的哪个地址。第二条命令调的是链接器 ld,它负责把.o里各个段拼起来,决定入口地址,处理符号引用,最后产出可执行文件first。

有新手会问:我平时都直接gcc hello.c -o hello,为什么这里要拆成两步?原因很实际。gcc 一步到位会顺手塞进启动代码、C 库、一堆你没见过的段,新手看不到中间产物,出了问题也不知道是哪一步坏的。拆成 as 和 ld,你能清楚地看到每一步产出了什么,first.o和first各是什么,哪个阶段报错就查哪一步。这种“看得见每一步”的感觉,对入门特别重要。

3.3 在 x86 主机上跑起来:qemu 的用法和退出码检查

可执行文件有了,但你的主机是 x86,直接./first会报“无法执行二进制文件”。这时候 qemu-user 就派上用场了:

# 用 qemu 模拟运行 ARM 程序,注意路径要写对 qemu-arm ./first # 紧接着打印上一条命令的退出码 echo $?

正常的话,第二条命令会输出 42。这个 42 就是你写在 r0 里的值,经过 exit 系统调用传给了 shell。看到这个数字,说明整条链路都通了:汇编没报错、链接找到了入口、程序按预期退出。这是新手最该先拿下的一个里程碑。

如果你手上正好有 ARM 设备,或者本来就在 ARM 的 Linux 环境里,可以跳过 qemu,直接./first,效果一样。工具链前缀也去掉,用系统自带的as和ld即可。

注意:echo $?必须在运行程序的下一条命令里执行,中间插了别的命令,拿到的就是别的命令的退出码。这个小细节坑过不少人。

把这三步走通,你对“汇编程序是怎么来的”就有了体感。后面所有复杂的例子,都是在这个最小骨架上加寄存器、加内存访问、加分支。骨架稳了,加东西才不会慌。

4. 数据类型和寄存器:R0 到 R15 与 CPSR 到底装了什么

第一个例子跑通之后,接下来要补的是“词汇量”。汇编里翻来覆去就那几样东西:数据有哪几种宽度、放在哪里;寄存器有几个、各管什么;状态位怎么表示上一次运算的结果。这一章把这几件事讲清楚,后面读指令才不会一头雾水。

4.1 数据宽度与符号扩展:-h、-sh、-b、-sb 到底差在哪

ARM 支持对几种宽度的数据进行载入和存储,按大小分成字、半字、字节。字是 32 位,半字是 16 位,字节是 8 位。指令里用后缀来区分宽度:半字用-h,字节用-b,字不加后缀。有符号和无符号的区别在于,有符号能表示负数,取值范围小一些;无符号只表示 0 和正数,取值范围大一些。带符号的版本在后缀前面加s,也就是-sh和-sb。

这几个后缀不是装饰,它们决定了数据从内存读进寄存器时怎么扩展。举个例子,内存里有一个字节0xFF。用无符号方式读,寄存器里得到0x000000FF,也就是 255;用有符号方式读,最高位被当成符号位,得到0xFFFFFFFF,也就是 -1。同一个字节,读法不同,结果差了十万八千里。新手写解析代码时如果漏了这个s,很容易出现“明明内存里是 255,怎么算出来是 -1”的困惑。

另一个跟数据有关的概念是字节序。ARM 处理器可以工作在小端序,也可以工作在大端序,具体由程序状态寄存器 CPSR 里的 E 位控制:E 位为 0 用小端,为 1 用大端。小端的意思是低位字节放在低地址,大端反过来。新手不用一上来就死记,只要记住“同一个 32 位数,在不同字节序下,内存里看到的字节顺序是反的”,调试时用 gdb 看内存就能对上。

4.2 通用寄存器 R0-R12:临时工、参数和返回值

ARM 的寄存器数量跟具体版本有关,入门阶段只需要盯住 R0 到 R15 这 16 个可以直接访问的寄存器就够。其中 R0 到 R11 是通用寄存器,R12 是内部程序调用寄存器,R13 是栈指针 SP,R14 是连接寄存器 LR,R15 是程序计数器 PC。另外还有一个 CPSR,记录当前程序状态。用一张表把它们摆清楚:

寄存器别名主要用途
R0-R3参数/返回值函数调用的前四个参数,返回值默认放 R0
R4-R10通用存局部变量、指针、临时数据
R11FP栈帧指针,帮忙定位当前函数的栈边界
R12IP内部程序调用用的临时寄存器
R13SP栈指针,指向栈顶
R14LR连接寄存器,函数调用时保存返回地址
R15PC程序计数器,指向下一条要执行的指令

对新手来说,最需要先形成肌肉记忆的是两条规则:第一,函数的前四个参数依次放在 R0 到 R3;第二,函数的返回值默认放在 R0。这两条规则在后面读任何函数调用时都会反复出现。R0 还经常被当作累加器用,算数运算的中间结果放它那里很顺手。R11 在系统调用场景下会存调用号,同时它作为栈帧指针,帮你在栈上划出当前函数的地盘,后面讲栈的时候还会遇到它。

4.3 SP、LR、PC 和 CPSR:进程状态的三块仪表盘

SP 指向栈顶。栈是内存里划出来的一块区域,用来存临时数据、保存返回地址、传递超过四个的参数。往栈上要空间的方式很直接:想让 SP 往下挪 4 个字节,就减 4。SP 一挪,栈就“长”出来一块。

LR 在函数调用时被更新成返回地址——也就是调用指令的下一条指令的地址。被调用的函数执行完之后,把 LR 里的值装回 PC,程序就回到调用点继续往下走。这个机制是函数能“去了还能回来”的基础。

PC 是最容易让新手困惑的一个。ARM 状态下一条指令 4 个字节,Thumb 状态下 2 个字节。当一条分支指令执行时,PC 保存目标地址;平时读 PC 的时候,拿到的值往往比当前正在执行的指令地址大 8。为什么是 8?一种解释是历史兼容:老款 ARM 处理器取指时就是超前两条指令;另一种解释是三级流水线——取指、解码、执行三个硬件部件并行工作,执行当前指令时,PC 已经在取下下一条指令了。调试器为了让你看着顺眼,通常显示的是“当前指令地址”,跟硬件里真实的 PC 值差一截。新手在 gdb 里单步时如果发现 PC 对不上,先别怀疑自己算错了,多半是这个显示差异。

CPSR 里最值得先认识的是 N、Z、C、V 四个标志位,它们分别对应“结果为负”“结果为零”“有进位或借位”“溢出”。这四位不是给你直接读的,而是给条件执行和循环指令用的。看个例子:

@ flags.s:比较 1 和 2,观察 CPSR 标志位变化 .text .global _start _start: mov r1, #1 @ r1 = 1 mov r2, #2 @ r2 = 2 cmp r1, r2 @ 做一次隐式减法:1 - 2 = -1 mov r0, #0 @ 退出码 0 mov r7, #1 @ exit 系统调用号 svc #0

cmp r1, r2不改变 r1 和 r2 的值,只根据减法结果去改 CPSR。1 减 2 等于 -1,是负数,所以 N 位被置 1。如果你把两条 mov 的顺序换一下再比较,结果为正,C 位会置 1。如果比较的是两个相等的数,减法结果为 0,Z 位被置 1。想看实际效果,用 gdb 在 cmp 后面下个断点,执行info registers和p/x $cpsr就能看到标志位。

# 启动调试器,加载刚才编译出来的程序 gdb-multiarch ./flags # 在 gdb 里依次执行: # (gdb) break _start # (gdb) run # (gdb) stepi # 单步执行一条指令,连按几次走到 cmp 之后 # (gdb) info registers # (gdb) p/x $cpsr # 看 CPSR 的十六进制值,对照 N/Z/C/V 位

把这几步走一遍,你对“寄存器是活的”这件事就有感觉了。后面读条件分支、读循环,看到某条指令“有条件地执行”,你就知道它是在查 CPSR 的哪一位。

5. 新手最常见的 5 个坑:现象、原因、排查一遍

入门阶段踩的坑,翻来覆去就那么几个。我把它们集中放在这一章,按“现象—原因—解决”写清楚。你遇到报错时先来这里对一遍,比漫无目的地搜要快得多。

5.1 汇编和链接阶段就翻车:两条最常见的报错

坑一:汇编器报 “Error: bad instruction”。现象是你照着教程敲的指令,as 死活不认。原因通常有三种:文件后缀写成了大写的.S,被 C 预处理器先扫了一遍,把@注释或者某些符号吃掉了;指令拼写错了一个字母,比如把mov写成mvo;或者你用的架构版本不支持这条指令。解决办法是先确认文件后缀是小写.s,再对着指令表逐个字母核一遍,最后确认工具链的架构参数匹配目标设备。

坑二:链接器报 “warning: cannot find entry symbol _start”。现象是 as 通过了,ld 给出这个警告,产出的程序跑起来行为诡异。原因是链接器默认找_start当入口,你要么忘了写.global _start,要么标签名拼成了start或者_main。解决方法是补上全局声明,或者在链接时用-e参数显式指定入口名字。我自己的习惯是每个 .s 文件开头都先把.global _start写好,再填内容,这样不容易漏。

5.2 跑起来才翻车:崩溃、PC 对不上、栈方向搞反

坑三:程序一跑就 Segmentation fault,代码看起来没问题。现象是 qemu 或者真机直接崩溃,用 gdb 看停在一条莫名的指令上。最常见的起因是用bx lr结尾——_start是入口,没有调用者,LR 里的值不可控,跳过去就是崩溃。解决办法是把结尾改成 exit 系统调用,或者一开始就用 C 库的启动流程。这类崩溃还有一个变种:忘了给栈分配空间就 push,SP 指向非法地址,照样崩。

坑四:在 gdb 里读 PC,算出来的地址跟预期差 8。现象是你明明在执行地址 A 的指令,读 PC 却读到 A+8,怀疑自己算错了。原因是 ARM 的流水线设计和历史兼容约定,PC 在硬件上确实超前,调试器显示的是处理过的值。解决办法是记住这条规则:在 ARM 状态下读 PC,默认按“当前指令地址 + 8”理解;在 Thumb 状态下按“+ 4”理解。写位置无关代码或者算偏移时,把这条规则考虑进去,就不会对不上。

坑五:push 之后 SP 变小了,搞不清栈是往上长还是往下长。现象是你以为 push 会让 SP 变大,结果 gdb 里看到 SP 减了 4。原因是 ARM 常用的是“满降序”栈:栈向低地址方向生长,SP 指向栈中最后一个已用项。push 的动作是先减 SP,再存数据;pop 是先读数据,再加 SP。解决办法是记住“满降序”这三个字,写代码时先想清楚当前 SP 指向哪里,再决定减多少。拿一张纸画一下内存地址的变化,比盯着屏幕硬想快得多。

提示:这五个坑里,前两个在汇编链接阶段就能发现,后三个要跑起来或者调试时才暴露。养成“先跑最小例子,再加功能”的习惯,能把排查范围缩到最小。

6. 下一步学什么:从看懂第一条指令到读懂一个函数栈帧

第一个例子跑通、五个坑对过一遍之后,你其实已经站在门口了。接下来最忌讳的是“什么都想学”,正确做法是按一条线往下走,每走一步都动手验证。下面这张表是我建议的顺序,左边是主题,右边是学完之后你应该能回答的问题:

顺序主题学完能回答的问题
1数据类型和寄存器一个有符号字节读进寄存器,值是多少
2ARM 指令集与 Thumb同一条逻辑,ARM 和 Thumb 编译出来差多少字节
3内存读写ldr和str的偏移量怎么写,什么时候用后索引
4高级内存读写多寄存器读写、块拷贝怎么写更省指令
5条件分支一段 if-else 编译出来长什么样,CPSR 哪一位在起作用
6栈和函数一个函数的序言、主体、尾声各干了什么,栈帧怎么长出来

前两步是这一篇已经打下的底子,第三、四步开始接触内存,第五步开始读控制流,第六步是把前面所有东西串起来——你会看到一个函数怎么用push {r11, lr}开场,怎么用sub sp, sp, #16给自己划地方,怎么在结尾用pop {r11, pc}收场。到那一步,你再回头看崩溃日志里的栈回溯,就不再是天书了。

具体到每天怎么练,我给一个很土但有效的办法:每学一条新指令,就在 gdb 里单步走一遍,执行前后各看一次info registers,把变化的那几个寄存器记下来。不要贪多,一天吃透三条指令,一周就是二十一条,足够覆盖入门阶段的高频指令。遇到看不懂的反汇编,先猜,再单步验证,猜错了印象反而更深。

我自己的血泪经验是:第一遍学的时候总想“先看懂再动手”,结果看了一堆文档还是不会写。后来改成“先让最小例子跑起来,再回头补概念”,进度反而快了很多。从那以后,我每次碰新架构都强制自己先写一个只有三条指令的退出程序,跑通再往下加。这个习惯帮我少走了很多弯路。希望帮到你。

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

RELATED READING

延伸阅读

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