# [特殊字符]️ 嵌入式调试从入门到进阶 —— ARM 架构(二)
🛠️ 嵌入式调试从入门到进阶 —— ARM 架构(二)
三分写,七分调。调试是嵌入式开发中最核心、最不可替代的能力。
📖 目录
- 一、前言
- 二、从代码到硬件——一条语句的执行之旅
- 2.1 Flash 存指令,RAM 存数据
- 2.2 内存映射与片选
- 2.3 CPU 眼中的世界——地址和数据
- 2.4 CPU 内部寄存器
- 2.5 a = a + 1 的三步执行
- 2.6 反汇编解读——从 C 到机器码
一、前言
学习 ARM 架构,不是让你天天写汇编。而是让你理解 C 代码在硬件上到底是怎么执行的——这样当你调试时,看反汇编、看寄存器、看栈回溯,才知道它们在说什么。
本章围绕一个最简单的问题展开:
“a = 1;” 这条语句,在单片机里到底发生了什么?
二、单片机硬件系统_
2.1 Flash 存指令,RAM 存数据
回到最基础的问题:
“为什么我的板子上电的时候,可以使得内存的某一块区域为 1 呢?”
关键区分:
| 存储介质 | 存什么 | 断电后 |
|---|---|---|
| Flash( ROM 类) | 程序指令(代码) | ✅ 不丢失 |
| RAM(内存) | 变量/数据 | ❌ 丢失 |
inta=1;// "a = 1" 这条指令 → 保存到 Flash// 执行结果(内存中 a 的值=1)→ 写在 RAM过程:
a = 1;这条指令编译成机器码,保存在Flash中- CPU 上电后从 Flash 读取指令
- 执行指令,结果写入RAM
代码(指令)保存在 Flash,变量(数据)保存在 RAM。运行时代码从 Flash 读取,数据写入 RAM
2.2 内存映射与片选
CPU 发出地址和数据,但这些信息要去哪个设备(Flash、RAM、GPIO、UART)呢?
答案:内存控制器 + 片选信号
CPU 发出地址 ↓ 内存控制器(MCU 厂家实现,各不相同) ↓ 根据地址范围判断 → 选中对应外设的片选引脚(CS) ↓ 被选中的外设:可以收发数据 没被选中的外设:相当于不存在,不影响总线同一时间只能有一个片选有效,否则总线冲突。
CPU 发出的地址经过内存控制器,根据地址范围激活对应外设的片选引脚
2.3 CPU 眼中的世界——地址和数据
在 CPU 眼里,它只知道两件事:
- 发出去一个地址
- 从这个地址读数据,或者往这个地址写数据
它不关心这个地址对应的是 Flash、RAM 还是 GPIO。它只做读写操作。
CPU 看到的是统一编址的地址空间——它不管背后是什么设备,只管发地址、读写数据
2.4 CPU 内部寄存器
CPU 读到的数据放在哪里?写数据的数据从哪里来?
答案:CPU 内部有寄存器(R0, R1, R2, …)
读取:外设 → 地址总线 → CPU → 存入 R0(举例) 写入:R1(举例) → CPU → 地址总线 → 外设举个例子:
// 外设某个寄存器的值// 可能被 CPU 读到 R0 或 R1// 也可能把 R2 的值写入某个外设CPU 内部有多个寄存器(R0~R15),读到的数据暂存在寄存器中,要写的数据也从寄存器发出
2.5 a = a + 1 的三步执行
a=a+1;这条语句不是一步完成的。CPU 内部执行时拆成了三步操作:
| 步骤 | 操作 | 说明 |
|---|---|---|
| ① 读 | a的值 → 读到 R0 | 从 RAM 读取变量 a 到寄存器 |
| ② 加 | R0 = R0 + 1 | CPU 内部 ALU 执行加法(CPU 不是只能读和写,它内部还有计算单元) |
| ③ 写 | R0 的新值 → 写回 RAM | 把计算结果写回 a 的内存地址 |
重点:CPU 内部一定有加法器/ALU(算术逻辑单元),否则没法做加减法。
CPU 执行
a = a + 1的完整过程:读 → 加 → 写
扩展到循环执行:
for (int i = 0; i < 100; i++) { AA(); // 反复调用 test_num++; }每次循环,CPU 都要反复经历:去Flash取指令 → 执行指令 → 去RAM读写数据。
CPU 反复从 Flash 取指令、执行、从 RAM 读写。红线表示取指令路径,蓝线表示数据读写路径
2.6 反汇编解读——从 C 到机器码
fromelf --text -a -c --output=uart_poll\uart_poll.dis uart_poll\uart_poll.axf
这条命令把 .axf(ELF 格式)反汇编成可读的 .dis 文件
反汇编文件内容,展示了 C 语句对应的汇编指令
以a = 1为例,看反汇编中的两条指令:
| 汇编指令 | 含义 | 对应 C 的哪部分 |
|---|---|---|
MOV R0, #1 | 把立即数 1 加载到 R0 | a = 1中的1 |
STR R0, [R1] | 把 R0 的值写入 R1 指向的内存地址 | a =(赋值操作) |
完整过程:
MOV R0, #1—— 从Flash读取这条指令 → CPU 执行 → R0 变为 1STR R0, [R1]—— 将 R0 的值写入RAM中 a 的地址
这就解释了最初的问题:a = 1这个数值保存在 Flash 上,执行 MOV 和 STR 两条指令,使得 RAM 里面某一位变为 1。
总结:看似简单的一句
a = 1;,背后涉及 Flash 存储、CPU 取指译码执行、寄存器暂存、内存控制器片选、RAM 写入这一整套流程。把这些搞懂,后面的 ARM 汇编、中断、栈回溯才能真的理解。
三、汇编指令——读写内存:LDR / STR
说句实话,汇编指令不用全背。
我干了这么多年,也没记住多少条。遇到看不懂的汇编指令怎么办?就两个字——百度,一搜就明白了,真不用硬记。
那写不出复杂的汇编程序咋整?——没必要写多复杂啊。你把栈初始化好,剩下的直接用 C 写函数就完事了。
我们平时打交道最多的,翻来覆去就这几类指令:
| 指令类别 | 常用指令 |
|---|---|
| 读写内存 | LDR、STR、PUSH、POP |
| 算术运算 | ADD、SUB |
| 位运算 | AND、ORR、TST |
| 移位运算 | 左移、右移 |
| 跳转 | B、BL、BX、BLX |
| 比较 | CMP |
比较指令后面还能跟条件后缀,只有条件成立才执行。比如:
CMP R0, R1 @ 比较 R0 和 R1 BEQ XXX @ 如果 R0 == R1(Equal),跳转到 XXXEQ(相等)、NE(不相等)这些后缀贴在指令后面,就成了条件执行指令。
数据传输三要素:源、目的、长度
拿LDR来举例。每次用LDR,脑子里要过三个东西:
- 目的:读到的数放哪?——必须放进 CPU 内部的某个寄存器(比如
R0)。CPU 读到的数据一定要先进寄存器,没别的路。 - 源:从哪读?——一个内存地址。比如
[R1, #val],意思是以 R1 的值加上偏移 val,算出最终地址,去那里取数据。 - 长度:读几个字节?
LDR默认 4 字节;读 2 字节用LDRH;读 1 字节用LDRB。
记住:LDR 只能在 CPU 寄存器和内存之间搬数据。你想访问外设的地址,也得先把地址存到寄存器里(比如 R1 里放个地址值),再用
LDR R0, [R1]去读。
CPU 执行LDR R0, [R1, #val]时,它会发出地址(R1 + val),通过数据总线去内存里把那个值取回来,放到 R0 里。
上手实操:rw_ram 函数逐行拆解
光说理论不过瘾,直接上能跑的代码。下面这个rw_ram函数,干的事很简单——读写 RAM 的第一个地址:
rw_ram PROC EXPORT rw_ram [WEAK] mov r1, #0x20000000 LDR r0, [r1] ENDP END一行一行拆开看:
①rw_ram PROC
PROC= Procedure(子程序开始)。意思是:我来定义一个叫rw_ram的汇编函数了。
②EXPORT rw_ram [WEAK]
EXPORT把这个函数导出,C 代码才能调用它。后面的[WEAK]是弱属性——如果你在 C 文件里也写了一个同名的rw_ram函数,C 版本优先,汇编这个就自动作废。这么设计是为了避免冲突。
③mov r1, #0x20000000
mov:赋值指令,把0x20000000这个数放进 R1。0x20000000就是 ARM 芯片内部 RAM 的起始地址(也就是片内 SRAM 的首地址)。
执行完这句,R1 里就存了 RAM 的基地址。
④LDR r0, [r1]
LDR:从内存读数。[R1]的意思是:取出 R1 里存的地址,去那个地址指向的内存拿数据。
翻译成人话:把地址 0x20000000 上存的值读出来,放到 R0 里。
⑤ENDP
End Procedure——函数到此结束。
⑥END
整个汇编文件的句号。汇编器读到这里就收工下班了。
这六行串起来,一句话总结就是:
定义一个可被 C 调用的弱函数:把 RAM 首地址存入 R1,读该地址的值,放到 R0 后返回。
调试里走一趟:眼见才为实
代码看完了,咱们进调试器实际跑一遍,亲眼看看这些指令到底干了什么。
在main.c里找到rw_ram();这一行,下个断点,然后全速运行。程序跑到这个断点就停住了。这时候Step Into(单步跟进去),进到rw_ram函数体里面。
看到 R1 没有?执行mov r1, #0x20000000之前它还是个上电没初始化的乱值,但执行完这条指令后,R1 稳稳地变成了0x20000000!
这就是mov的作用——把立即数送进寄存器,立竿见影。
再点一次单步,执行LDR r0, [r1]。盯住 R0:
R0 从0x00000000一下子变成了0x12345678!这个值就是 RAM 地址0x20000000上原来存的数据——前面代码里定义了一个全局变量g_val = 0x12345678,它就被放在 RAM 的开头位置。
继续往下单步走:
每一步寄存器都跟着变——你亲眼能看到"LDR 把内存里的值读到了寄存器里"这个过程。这种体验比光看书上写一百遍"LDR 是读内存指令"都来得直接。
这就是调试的魅力:一行反汇编,一个寄存器变化,比背十遍指令格式都好使。
四、汇编指令——PUSH / POP:读写栈
PUSH 的本质:就是写内存
别把 PUSH 想得多玄乎,它的本质就是一条 STR 指令——往 SP 指向的地址写数据。
- 源:要压栈的寄存器(比如
R0、R1、LR) - 目的:SP(栈指针)当前指向的位置
- 操作:把寄存器的值写到 SP 指向的内存
PUSH 一次能压多个寄存器,压的顺序也有规矩——编号大的先压,先存到高地址,再依次往低地址存。
POP 的本质:就是读内存
POP 反过来,本质就是一条 LDR 指令——从 SP 指向的地址读数据,弹回寄存器。
- 源:SP 指向的栈内存
- 目的:要弹出的寄存器
- 操作:从 SP 指向的位置读数据,存入寄存器
POP 的顺序和 PUSH 相反——先弹出编号小的,从低地址往高地址取。
先认识几个寄存器的别名
写汇编的时候,寄存器叫法有些约定俗成的规矩:
| 寄存器 | 别名 | 作用 |
|---|---|---|
| R13 | SP(Stack Pointer) | 栈指针,指向当前栈顶位置 |
| R14 | LR(Link Register) | 链接寄存器,存函数返回地址 |
| R15 | PC(Program Counter) | 程序计数器,指向当前执行的指令 |
记住:R13(SP)就是栈的"光标"——它指到哪,PUSH 就往那写,POP 就从那读。
举个具体的例子
假设现在 RAM 的范围是0x20000000~0x2001C000,SP 初始指向0x20001000。
PUSH {R0, R1, R2, R3}
执行 PUSH 时,CPU 会从高地址往低地址依次存:
- 存 R3→ SP 指向的位置写入 R3 的值,SP -= 4
- 存 R2→ 新位置写入 R2 的值,SP -= 4
- 存 R1→ 新位置写入 R1 的值,SP -= 4
- 存 R0→ 新位置写入 R0 的值,SP -= 4
压完 4 个寄存器,SP 一共减了 16 字节(4 × 4),现在指向0x20000FF0。
POP {R4, R7, R8, R10}
执行 POP 时,从低地址往高地址依次取:
- 弹到 R4→ 从 SP 位置读数据 → 存入 R4,SP += 4
- 弹到 R7→ 新位置读数据 → 存入 R7,SP += 4
- 弹到 R8→ 新位置读数据 → 存入 R8,SP += 4
- 弹到 R10→ 新位置读数据 → 存入 R10,SP += 4
结果:R4 = 原来 R0 的值,R7 = 原来 R1 的值,R8 = 原来 R3 的值,R10 = 原来 R4 的值(注意 R10 拿到的是原来第 4 个压进去的,不是 R4)
弹完 4 个,SP 回到最初的0x20001000。
简单说:PUSH 是"存了往后挪",POP 是"取了往前赶"。
调试里看 SP 的变化
理论讲完了,进调试器亲眼看看。
这张图里,ABC函数刚被调用,还没执行第一条指令。看寄存器面板:
- SP(R13)=
0x20000538 - LR(R14)=
0x08001F37(返回地址) - PC(R15)=
0x08000314(指向PUSH {r3, lr}这条指令)
反汇编窗口显示的就是ABC函数的完整指令序列:
PUSH {r3,lr} @ 保存 R3 和返回地址到栈上 MOVS r0,#0x02 @ a = 2 STR r0,[sp,#0] @ 把 a 的值存到栈上局部变量位置 POP {r3,pc} @ 恢复 R3,把 LR 弹回 PC——返回!单步执行完 POP 后,再看 SP:
- SP 从
0x20000538变成了0x20000530——这就是 POP 执行后 SP 回退了 8 字节(因为 PUSH 时压了 R3 和 LR 两个寄存器) - 内存窗口中也能看到栈上存的返回地址
0x08001F37
PUSH 时 SP 减少(栈生长方向是向下的),POP 时 SP 增加(恢复回去)——SP 就是栈的"水位线",PUSH 涨潮,POP 退潮。
延伸:函数调用时寄存器的保存规则
PUSH/POP 学完了,下一个自然的问题是:函数调用时,哪些寄存器要压栈保存?谁负责保?
ARM 有个标准约定叫AAPCS,把寄存器的保存责任分得清清楚楚。
咱们从 main 调 Add 函数看起:
C 代码大概是这样的:
Add(100,1);BB(100,'A');intval=CC(1,2,3,4);编译后,调用 Add 之前先设置参数:
MOVS r1, #1 @ 第二个参数 = 1 MOVS r0, #0x64 @ 第一个参数 = 100 BL Add @ 调用 Add 函数函数的参数通过 R0~R3 传递,返回值通过 R0 传递。
那问题来了——寄存器在函数调用过程中被修改了怎么办?
ARM 把寄存器分成了两类:
| 寄存器 | 分类 | 谁负责保存 | 说明 |
|---|---|---|---|
| R0~R3 | 调用者保存 | 调用者(Caller) | 传参数/返回值,被调函数可能随意改 |
| R4~R11 | 被调用者保存 | 被调函数(Callee) | 要借用的话,必须先存再恢复 |
| R12 | 临时寄存器 | 调用者 | 也叫 IP,随意用 |
| R13(SP) | 栈指针 | — | 栈指针,谁都不能乱动 |
| R14(LR) | 链接寄存器 | — | 存返回地址,BL 自动设置 |
| R15(PC) | 程序计数器 | — | 指向当前指令 |
用人话说:
- R0~R3:函数 A 调函数 B,B 改不改它不管。如果 A 觉得 R0~R3 很重要,A 自己负责在调用前保存。
- R4~R11:B 想借用的话,必须先把原值压栈,返回前恢复。A 可以放心,调完 B 后 R4~R11 还是原来的值。
Add 函数的栈帧结构:
PUSH {r0, r1, lr} @ 保存参数和返回地址到栈上 SUB sp, sp, #4 @ 为局部变量腾空间 LDRD r0, r1, [sp, #4] @ 从栈上加载参数到寄存器 ADD r0, r0, r1 @ r0 = a + b(核心计算) STR r0, [sp, #0] @ 把结果存回栈上局部变量 POP {r1-r3, pc} @ 恢复寄存器,返回再看 BB 函数的反汇编:
PUSH {r0, r1, lr} @ 保存现场 SUB sp, sp, #0x68 @ 分配局部变量空间 ... @ 函数体干活 ADD sp, sp, #0x70 @ 回收栈空间 POP {pc} @ 返回一句话总结:R0~R3 是"用完不管"的,调用者自己备份;R4~R11 是"借用必还"的,被调函数负责保存恢复。
五、汇编指令——ADD / SUB:算术运算
CPU 内部有个专门干苦力活的模块——ALU(算术逻辑单元),加减法都是它来算。
ADD 和 SUB 的语法格式很灵活,总结下来就这几种:
ADD 指令的写法
ADD R0, #1 @ R0 = R0 + 1 ADD R0, R0, #1 @ R0 = R0 + 1(同上,完整写法) ADD R0, R1, #1 @ R0 = R1 + 1 ADD R0, R1, R2 @ R0 = R1 + R2注意看规律:第一个是目的地,后面的都是参与者。不管是加立即数、加寄存器、还是寄存器加寄存器,格式都一样。
SUB 指令——看个实例
光列语法太干,直接上代码和反汇编对照着看。
C 代码:
voidABC(intv){volatileinta=1;a=v-a;}经编译器优化后,v - a(也就是v - 1)变成了下面的汇编:
ABC 0x08000314: b508 PUSH {r3,lr} @ 保存现场 0x08000316: 1e40 SUBS r0,r0,#1 @ r0 = v - 1 0x08000318: 9000 STR r0,[sp,#0] @ 存到局部变量 a 0x0800031a: bd08 POP {r3,pc} @ 恢复现场,返回关键指令:SUBS r0, r0, #1
这里有几个细节值得说:
SUBS的后缀S:表示这次减法会更新标志位(比如结果是不是 0、有没有借位)。如果写成SUB不带 S,就不影响标志位。大多数时候我们写的是SUBS。- 操作数顺序:
SUBS r0, r0, #1相当于r0 = r0 - 1。ARM 的语法是目的寄存器放最前面,这一点跟 ADD 完全一样。
所以整段汇编翻译成人话就是:
- 把 R3 和返回地址(LR)压栈→ 保存现场
r0 = v - 1→ 算出差值- 把结果存到栈上变量 a 的位置 → 写回内存
- 恢复 R3,返回到调用者
调试里走一趟
假设你在 Keil 里调用了ABC(100),在a = v - a这一行下个断点。
命中断点后,打开反汇编窗口,看到的正是上面那 4 条指令。此时R0 = 100(参数 v 的值)。
单步执行SUBS r0, r0, #1后,盯住 R0 寄存器:
R0 从 0x64(100)变成了 0x63(99)——减 1 操作完成了!
再把这一条指令和后面的STR r0, [sp, #0]连起来看就明白了:SUBS 只改了寄存器里的值,STR 才把这个值写回内存中变量 a 的地址。这就是寄存器操作和内存操作的分工。
一句话总结:ADD 和 SUB 的格式完全一样,区别只在于一个是加一个是减。S 后缀表示会更新标志位,写不写看你的需求。
六、汇编指令——AND / ORR / TST:位运算
位运算也是 ALU 的拿手好戏。AND(按位与)和 ORR(按位或)的写法和 ADD 一模一样:
AND R0, #1 @ R0 = R0 & 1 AND R0, R1, #1 @ R0 = R1 & 1 AND R0, R1, R2 @ R0 = R1 & R2 ORR R0, R1, R2 @ R0 = R1 | R2AND 常用于"屏蔽"某些位——你想知道一个数的某一位是 0 还是 1,就和对应位为 1 的掩码做 AND。
举个例子:
voidABC(intv){volatileinta;//if (v == 1)if(v&0x30)/* 检查 v 的 bit4 或 bit5 是否为 1 */a=1;elsea=2;}v & 0x30的意思是:只关心 v 的第 4 位和第 5 位(0x30 = 0011 0000),其他位全屏蔽掉。只要这两位的任意一个是 1,条件就成立。
TST——位测试指令
编译成汇编后,if (v & 0x30)会变成这样:
TST R0, #0x30 @ 测试 R0 的 bit4 或 bit5 是否为 1 BNE XXX @ 如果非零(有一位是1),跳转TST 本质上就是 AND,但它的特殊之处在于:计算结果不要了,只更新 Z 标志位。
| TST 结果 | Z 标志 | 含义 |
|---|---|---|
| 所有测试位都是 0 | Z = 1 | 条件不成立 → 走 else |
| 至少有一位是 1 | Z = 0 | 条件成立 → 走 if |
记住:AND 是"又算又存",TST 是"只算不存"。你想检查某些位但不关心具体值,就用 TST。
调试里看 Z 标志变化
在if (v & 0x30)这一行下断点,假设 v = 0x35(二进制 0011 0101)。
v 的 bit4 = 1(因为 0x35 的 bit4 是 1),所以v & 0x30的结果非零。
单步执行 TST 后看 xPSR 寄存器:Z 标志位 = 0——因为非零,条件成立。
再把 v 改成 0x0F(二进制 0000 1111,bit4 和 bit5 都是 0),重新跑一次:
Z 标志位变成 1——AND 结果为 0,条件不成立,走 else。
画个对应关系:
| 条件 | v & 0x30 结果 | Z 标志 | 执行分支 |
|---|---|---|---|
| v = 0x35(有 bit 置 1) | 非零 | Z = 0 | a = 1(if) |
| v = 0x0F(没有 bit 置 1) | 零 | Z = 1 | a = 2(else) |
一句话总结:AND 和 ORR 做位运算,TST 做位测试。它们都是 ALU 的基本操作,区别在于要不要保存结果。
七、汇编指令——CMP:比较与标志位
CMP——比较两个数
CMP R0, R1 @ 计算 R0 - R1,只更新标志位CMP 本质上就是做一次减法,减完的结果不要了,但标志位(N、Z、C、V)会记录下来:
- Z(Zero)= 1→ 说明 R0 == R1(相减为 0)
- N(Negative)= 1→ 说明 R0 < R1(结果为负)
- C(Carry)= 1→ 说明 R0 >= R1(无借位)
然后根据标志位来决定后续指令是否执行:
CMP R0, R1 BEQ Label @ 如果 Z=1(相等),跳转 BNE Label @ 如果 Z=0(不相等),跳转 BGT Label @ 如果 N=0 & Z=0(大于),跳转 BLT Label @ 如果 N=1(小于),跳转动手看一下标志位
在调试器里单步执行,随时可以查看 xPSR 寄存器的标志位状态:
上图中:
- N = 0:结果为正
- Z = 1:结果为零(这一位被红框标出,说明上一条指令的结果是 0)
- C = 1:有进位/无借位
- V = 0:无溢出
执行MOVS R0, #0后,Z 标志位变为 1(因为 R0 变成了 0)。这就是带 S 后缀的指令更新标志位的直接证据。
再看这两条指令:
MOVS R0, #0 @ R0 = 0,更新标志位 → Z = 1 MOVS R0, #1 @ R0 = 1,更新标志位 → Z = 0第一次 MOVS 把 0 赋给 R0,结果为零,Z 自动置 1。第二次 MOVS 把 1 赋给 R0,结果非零,Z 自动清 0。这一切都是 CPU 自动完成的,不需要你手动干预。
一句话总结:CMP 和 TST 只改标志位不留结果。标志位(N/Z/C/V)就是 CPU 的"便签纸",后面的条件跳转指令全靠它做决策。
八、汇编指令——移位运算:左移 / 右移
移位操作也是 ALU 的基本功。ARM 里有三条移位指令:
| 指令 | 全称 | 行为 | 适用场景 |
|---|---|---|---|
| LSL | Logical Shift Left | 左移,低位补 0 | 无符号数 ×2ⁿ |
| LSR | Logical Shift Right | 右移,高位补 0 | 无符号数 ÷2ⁿ |
| ASR | Arithmetic Shift Right | 右移,高位补符号位 | 有符号数 ÷2ⁿ |
基本写法
LSL R0, R0, #2 @ R0 = R0 << 2(左移 2 位) LSL R0, R1, #2 @ R0 = R1 << 2 LSR R0, R0, #1 @ R0 = R0 >> 1(右移 1 位) ASR R0, R0, #1 @ R0 = R0 >> 1,高位补符号位语法和 ADD 完全一样:第一个是目的地,第二个是源,第三个是移位数。
左移一位 = 乘以 2
MOVS R0, #3 @ R0 = 3 LSL R0, R0, #1 @ R0 = 3 << 1 = 63 的二进制是...0000 0011,左移一位变成...0000 0110= 6。左移 n 位等于乘以 2ⁿ。
反过来:
MOVS R0, #8 @ R0 = 8 LSR R0, R0, #2 @ R0 = 8 >> 2 = 28 的二进制...0000 1000,右移两位变成...0000 0010= 2。右移 n 位等于除以 2ⁿ(对无符号数)。
算术右移 vs 逻辑右移——有什么区别?
假设 R0 = -8(有符号数,二进制补码表示为...1111 1000):
LSR R0, R0, #1 @ 逻辑右移:高位补 0 → 结果变成 0x7FFFFFFC(正数!不对了) ASR R0, R0, #1 @ 算术右移:高位补符号位 1 → 结果是 -4(正确)对有符号数做右移,必须用 ASR(算术右移),它保留了符号位。
对无符号数做右移,用 LSR,高位补 0 就行。
一句话总结:左移 = 乘 2ⁿ,右移 = 除 2ⁿ(无符号用 LSR,有符号用 ASR)。移位比乘除法快得多,编译器经常用移位优化乘除法。
九、汇编指令——跳转:B / BL / BX / BLX
程序默认是一条一条往下执行的。跳转指令就是用来"岔路"的——要么跳到别的代码段,要么调用函数。
B——无条件跳转
B Label @ 直接跳转到 Label最简单的跳转,不带返回值。相当于 C 里的goto。
BL——跳转并保存返回地址
BL Add @ 调用 Add 函数BL 比 B 多干了一件事:跳转之前,先把下一条指令的地址(返回地址)存到LR(R14)里。被调函数执行完,用BX LR就能回来。
这就是函数调用的底层原理。
BX——跳转并返回
BX LR @ 返回到调用者把 LR 的值赋给 PC,程序就跳回去了。这是 ARM 里"函数返回"的标准写法。
BLX——调用+切换指令集
BLX R0 @ 调用R0指向的函数,自动切换ARM/Thumb模式BLX 是 BL + BX 的结合体——既保存返回地址,又跳转,还能在 ARM 和 Thumb 指令集之间自动切换。
条件跳转——配合 CMP 使用
跳转指令后面可以加条件后缀,决定"满不满足条件才跳":
CMP R0, R1 BEQ Equal @ R0 == R1 时跳转(Z = 1) BNE NotEqual @ R0 != R1 时跳转(Z = 0) BGT Greater @ R0 > R1 时跳转(N = 0 & Z = 0) BLT Less @ R0 < R1 时跳转(N = 1) BGE Geq @ R0 >= R1 时跳转 BLE Leq @ R0 <= R1 时跳转调试里看 BL 的执行
上图是调试中执行OLED_Clear()时的状态:
- 当前正在执行的指令是
BL.W OLED_Clear(PC = 0x08001F5E) - 执行这条 BL 指令时,CPU 会:
- 把下一条指令的地址存入 LR
- 跳转到
OLED_Clear函数的入口地址 - 函数执行完后,遇到
BX LR就回到原来的位置继续
再看一个循环调用的例子:
for (m = 0; m < 100; m++) { AA(); test_num++; }的循环中:
- 每次循环都会执行
BL.W AA来调用 AA 函数 - 可以看到反汇编窗口中的
BL.W 0x080002E4 AA指令 - AA 函数内部有
PUSH {r3,pc}和POP {r3,pc}来保存和恢复现场
寄存器传参回顾
结合第五节补充的 AAPCS 规则,函数调用的完整流程是:
- 参数:通过 R0~R3 传递
- 跳转:
BL FuncName— 把返回地址存到 LR,跳到函数入口 - 被调函数:
PUSH {寄存器, lr}保存现场 → 执行函数体 →POP {寄存器, pc}恢复并返回 - 返回:
POP {pc}把之前存的 LR 值弹给 PC,程序回到调用处
一句话总结:B 是 jump,BL 是 call(有去有回),BX LR 是 return。配合 CMP 和条件后缀(EQ/NE/GT/LT),就能实现 C 语言里的 if/else/for/while 所有控制流。
📖 完整指令索引
本文涉及的所有汇编指令,一张表总结:
| 分类 | 指令 | 作用 |
|---|---|---|
| 读内存 | LDR/LDRH/LDRB | 读 4/2/1 字节到寄存器 |
| 写内存 | STR/STRH/STRB | 将寄存器值写入内存 |
| 入栈 | PUSH {regs} | 将寄存器压栈(写 SP 指向的内存) |
| 出栈 | POP {regs} | 从栈弹出到寄存器(读 SP 指向的内存) |
| 加法 | ADD/ADDS | 加法运算,带 S 更新标志位 |
| 减法 | SUB/SUBS | 减法运算,带 S 更新标志位 |
| 按位与 | AND | 位与运算 |
| 按位或 | ORR | 位或运算 |
| 位测试 | TST | 位与测试,只改 Z 标志 |
| 左移 | LSL | 左移 n 位(×2ⁿ) |
| 右移 | LSR/ASR | 逻辑/算术右移 n 位(÷2ⁿ) |
| 比较 | CMP | 减法比较,只改 N/Z/C/V 标志 |
| 无条件跳转 | B label | 直接跳转(goto) |
| 调用函数 | BL func | 跳转 + 存返回地址到 LR |
| 函数返回 | BX LR | 从 LR 恢复 PC |
| 调用+切换 | BLX Rn | 调用 + 自动切换指令集 |
| 条件后缀 | EQ/NE/GT/LT/GE/LE | 贴在指令后面,条件成立才执行 |