从零开始学习嵌入式P39----汇编基础
从零开始学习嵌入式P39----汇编基础
前面我们用 C 语言驱动了 51 单片机的各种外设,程序从
main函数开始执行,一切看起来顺理成章。但换一个角度想:处理器上电复位之后,main函数是谁帮我们找到的?栈是谁建立的?C 语言运行需要的环境是谁搭建的?这些工作 C 语言自己做不了,必须由汇编语言编写的启动代码完成。这一篇我们正式进入 ARM 汇编的世界:先搞清楚为什么学汇编,再学习汇编文件的格式和伪操作,然后逐条掌握数据处理指令、跳转指令、条件执行,接着用汇编写出循环和函数,理解栈和现场保护,最后实现汇编与 C 语言的互相调用,并初步接触 CPSR 工作模式切换和异常向量表。
本篇目标
学完这一篇,我们应该能够:
- 说清学习 ARM 汇编的目的和启动代码的主要任务;
- 看懂汇编源文件的基本结构,说清
area、code32、entry、end等伪操作的作用; - 使用
mov、mvn、add、sub、bic、orr完成数据传送和算术逻辑运算; - 说清什么是 12 位立即数,并能判断一个数是不是合法的立即数;
- 说清 CPSR 中
N、Z、C、V四个条件标志位分别在什么情况下被置位; - 使用
cmp和条件码实现分支,用汇编写出do...while和while循环; - 说清
b、bl、bx三条跳转指令的区别,理解pc和lr的配合; - 说清栈的四种类型,知道 ARM 采用满减栈,会用
stmfd、ldmfd保护和恢复现场; - 在汇编中调用 C 语言函数,也在 C 语言中调用汇编函数,并说清参数和返回值如何传递;
- 使用
mrs、msr读写 CPSR,修改处理器工作模式。
一、为什么学习 ARM 汇编
1. 学习汇编的主要目的
学习 ARM 汇编的主要目的是为了编写 ARM 启动代码。启动代码运行起来以后,把程序引导到 C 语言环境下运行。换句话说,启动代码的目的就是在处理器复位以后,搭建 C 语言运行所需要的最基本环境。
C 语言的运行并不是“零成本”的,它至少需要:
- 一个确定的程序入口,知道第一条指令在哪里;
- 一块可用的栈空间,函数调用、局部变量、现场保护都依赖它;
- 正确的工作模式和合理的中断设置。
这些环境在处理器刚复位时都不存在,必须用汇编指令亲手搭建。
2. 启动代码的主要任务
启动代码的主要任务可以归纳为五条:
- 初始化异常向量表;
- 初始化各工作模式的栈指针寄存器;
- 开启 ARM 内核中断允许;
- 将工作模式设置为 user 模式;
- 完成上述工作后,引导程序进入 C 语言主函数执行。
因此汇编指令的学习也主要围绕这几个目的展开,重点学习与这些任务相关的指令,而不是把 ARM 指令集手册从头背到尾。本篇涉及的知识面向 ARMv7-A/R 架构,配套的开发环境是 Keil,参考文档是 ARM 官方的《ARMv7-A/R Architecture Reference Manual》。
二、汇编源文件的基本格式
1. 什么是伪操作
先看一个最小的 ARM 汇编源文件骨架:
area reset, code, readonly
code32
entry
; 这里写汇编指令
end
这里的 area、code32、entry、end 都不是 ARM 处理器实际执行的指令,它们是伪操作。伪操作是写给汇编器看的命令,用来指导汇编器如何工作,比如代码放在哪个段、使用哪套指令集、程序入口在哪里。
2. area:定义一个段
area 是最重要的一个伪操作,用于定义一个段。程序、数据、堆栈等都需要被组织在不同的段中。
area reset, code, readonly
逐个字段看:
reset:为这个段起的名字。reset这个名字具有很强的暗示性,通常用于表示复位向量段,即 CPU 上电或复位后首先执行的第一段代码所在的位置;code:指定该段的属性为代码,意味着这个段包含可执行的指令;readonly:指定该段的属性为只读。对于代码段来说,这通常是默认且必须的。
3. code32 和 thumb:选择指令集
code32
code32:表示后续指令使用 32 位的 ARM 指令集;thumb:表示后续指令使用 16 位的 Thumb 指令集。
本篇全部使用 32 位 ARM 指令集。
4. entry 和 end:入口与结束
entry:标记程序的入口点;end:告诉汇编器源文件到此结束,它必须出现在文件末尾。
5. 注释和标号
ARM 汇编中用分号 ; 表示注释,从 ; 到行尾的内容都会被汇编器忽略:
mov r0, #0x0F ; r0 = 0x0F
标号由顶格书写的标识符构成,用来标记一个地址,跳转指令和函数调用都要用到它:
loop
add r1, r1, r0
b loop
注意标号要顶格写,指令前面要缩进,这是很多汇编器的书写约定。
三、数据传送指令:mov 和 mvn
1. mov 的两种基本形式
mov 是最常用的数据传送指令,基本形式有两种:
MOV{S}<c> <Rd>, #<const> ; 把立即数送入寄存器
MOV{S}<c> <Rd>, <Rm> ; 把寄存器的值送入另一个寄存器
举例:
mov r0, #0x8 ; r0 = 0x8
mov r1, r0 ; r1 = r0
mov r3, #31 ; r3 = 31
可以和 C 语言中的赋值运算对比理解:mov r0, #0x8 相当于 r0 = 0x8,目标寄存器在左,相当于“左值”;源操作数在右,相当于“右值”。ARM 汇编绝大多数指令都遵循“目标在前,源在后”的顺序。
2. 带移位的 mov
mov 的第二操作数可以先做移位再传送:
MOV{S} <Rd>, <Rm>, LSL #<n> ; 逻辑左移
MOV{S} <Rd>, <Rm>, LSR #<n> ; 逻辑右移
MOV{S} <Rd>, <Rm>, ASR #<n> ; 算术右移
MOV{S} <Rd>, <Rm>, ROR #<n> ; 循环右移
MOV{S} <Rd>, <Rm>, RRX ; 扩展右移
移位量可以是立即数,也可以是寄存器:
mov r0, #1
mov r6, r0, lsl #31 ; r6 = r0 << 31,即 0x80000000
mov r7, r0, lsl r3 ; r7 = r0 << r3,移位量由 r3 决定
需要注意:
- 移位量
#<n>或<Rs>的取值范围是 0 到 31; RRX是扩展右移,它不需要移位量,固定只移一位,并且会把 CPSR 的C标志位带进最高位;MOV{S} <Rd>, <Rm>, LSL #<n>这种形式在指令集手册中的规范写法其实就是LSL{S} <Rd>, <Rm>, #<n>,也就是说带移位的mov可以看作是移位指令的别名。
还有一点非常重要:在计算机中只识别二进制数据,计算机本身没有有符号、无符号、浮点这些概念。同样一个 32 位的二进制串,把它当作有符号数还是无符号数,是程序员和指令共同决定的,这一点在后面讲 C 标志和 V 标志时还会用到。
3. mvn:按位取反后传送
mvn 与 mov 类似,但先把源操作数按位取反再送入目标寄存器:
MVN{S}<c> <Rd>, #<const>
MVN{S}<c> <Rd>, <Rm>
mvn r0, r1 ; r0 = ~r1
mvn r0, #0 ; r0 = ~0 = 0xFFFFFFFF
mvn r0, #0 是得到 0xFFFFFFFF 的常用技巧。为什么不能直接 mov r0, #0xFFFFFFFF?这涉及立即数的编码问题,下一节详细讲。
四、立即数:不是想写多少就写多少
1. 什么是 12 位立即数
mov、add、sub、bic、orr 等指令都允许把一个立即数作为第二操作数,例如:
add r6, r0, #0xF0
但这个立即数不是任意 32 位数都可以。ARM 指令本身是 32 位定长的,指令编码中要留给操作码、寄存器编号、条件码等字段,留给立即数的只有 12 位,所以准确地说,这里能直接编码的是 12 位立即数 imm12。
2. imm12 的内部结构
这 12 位并不是直接存放数据,而是拆成两部分:
| 4 位旋转位 N | 8 位有效位 imm8 |
- 低 8 位是有效数据 imm8,取值范围 0 到 255;
- 高 4 位是旋转位 N,取值范围 0 到 15;
- 最终得到的 32 位数据 = imm8 循环右移 2N 位,也就是右移 0、2、4 …… 30 位中的一个偶数位数。
处理器内部会把 imm8 先扩展成 32 位(高 24 位补 0),再经过一个桶形移位器做循环右移,得到真正的操作数。
3. 判断一个数是不是合法立即数
判断标准一句话:
把某个数展开成二进制,必须存在一种循环右移(偶数位),使得移位后高 24 位全为 0,低 8 位恰好是这个数的全部有效位。
换句话说,这个 32 位数的所有为 1 的位,必须能被压缩进某个 8 位窗口里,而且这个窗口通过偶数位循环右移能回到 bit0 到 bit7 的位置。
看几个例子:
0x1FB0 0000 0000 0000 0000 0001 1111 1011 0000
0xA000000A 1010 0000 0000 0000 0000 0000 0000 1010
0x1FB0:有效位是1 1111 1011,共 8 位,把它循环左移 4 位(等价于右移 28 位)后落在低 8 位,所以它是合法立即数;0xA000000A:有效位可以看作1010 0000加最低位的1010,合起来是1010 0000 0000 0000 0000 0000 1010,循环右移 4 位后得到1010 1010,8 位,高 24 位全 0,所以它也是合法立即数;0xFFFFFFFF:32 位全是 1,无论如何移位都不可能把高 24 位变成 0,所以它不是合法立即数,mov r0, #0xFFFFFFFF会报错。
4. 遇到非法立即数怎么办
有两个常用办法。
办法一:换一条指令。 比如要得到 0xFFFFFFFF,用 mvn:
mvn r0, #0 ; r0 = ~0 = 0xFFFFFFFF
办法二:使用 ldr 伪指令。
ldr r0, =0x1FB0
ldr sp, =0x40001000
ldr <寄存器>, =<数值> 是一条伪指令,汇编器会把数值放进文字池,再生成一条真正的加载指令。它能加载任意 32 位数,不受立即数编码限制。
一个典型的例子是初始化栈指针:
mov sp, #0x40001000 ; 错误:0x40001000 不是合法立即数
ldr sp, =0x40001000 ; 正确:用 ldr 伪指令加载
五、算术指令:add 和 sub
1. add 加法指令
add 有三种形式,区别在于第二操作数的来源:
ADD{S}<c> <Rd>, <Rn>, #<const> ; 立即数作为第二操作数
ADD{S}<c> <Rd>, <Rn>, <Rm>{, <shift>} ; 寄存器作为第二操作数,可选移位
ADD{S}<c> <Rd>, <Rn>, <Rm>, <type> <Rs> ; 移位量由寄存器给出
举例:
mov r0, #0x0F
mov r1, #0xF0
mov r2, #1
add r6, r0, #0xF0 ; r6 = r0 + 0xF0
add r7, r0, r1 ; r7 = r0 + r1
add r7, r0, r1, lsl #1 ; r7 = r0 + (r1 << 1)
add r8, r0, r1, lsl r2 ; r8 = r0 + (r1 << r2)
关于书写格式有两点说明:
{, <shift>}中的{}表示这部分可以选择;,表示使用时要在Rm后面加上逗号再写移位;- 不存在
add r0, #3, #2这种两个操作数都是立即数的形式。因为像 C 语言的int a = 1 + 2;,编译器在编译阶段就能算出结果,根本不需要在机器指令中体现这个计算过程。
2. sub 减法指令
sub 的形式与 add 完全对称:
SUB{S}<c> <Rd>, <Rn>, #<const>
SUB{S}<c> <Rd>, <Rn>, <Rm>{, <shift>}
SUB{S}<c> <Rd>, <Rn>, <Rm>, <type> <Rs>
mov r0, #0xFF
mov r1, #0xF0
mov r2, #1
sub r6, r0, #0xF0 ; r6 = r0 - 0xF0
sub r7, r0, r1 ; r7 = r0 - r1
sub r7, r0, r1, lsl #1 ; r7 = r0 - (r1 << 1)
sub r8, r0, r1, lsl r2 ; r8 = r0 - (r1 << r2)
对照 C 语言就很好理解:
sub r0, r1, #0x0F ; r0 = r1 - 0x0F
sub r0, r0, #1 ; r0 -= 1
sub r2, r0, r1 ; r2 = r0 - r1
六、位操作指令:bic 和 orr
在单片机和裸机开发中,经常需要“把某个寄存器的指定位清 0”或“指定位置 1”,bic 和 orr 就是干这个的。
1. bic:指定位置清 0
bic 是 bit clear 的缩写,效果是 Rd = Rn AND (NOT 第二操作数),即第二操作数中为 1 的位,在结果中被清 0:
BIC{S}<c> <Rd>, <Rn>, #<const>
BIC{S}<c> <Rd>, <Rn>, <Rm>{, <shift>}
BIC{S}<c> <Rd>, <Rn>, <Rm>, <type> <Rs>
mov r0, #0xFFFFFFFF ; 实际写法是 mvn r0, #0
mov r1, #1
bic r2, r0, r1, lsl #31 ; 把 r0 的 bit31 清 0,结果存入 r2
bic r3, r0, #(1 << 31) ; 汇编器允许直接写 (1 << 31),更直观
2. orr:指定位置 1
orr 是按位或,效果是 Rd = Rn OR 第二操作数,第二操作数中为 1 的位,在结果中被置 1:
ORR{S}<c> <Rd>, <Rn>, #<const>
ORR{S}<c> <Rd>, <Rn>, <Rm>{, <shift>}
ORR{S}<c> <Rd>, <Rn>, <Rm>, <type> <Rs>
mov r0, #0x00
mov r1, #1
mov r2, #31
orrs r6, r0, #0x80000000 ; 直接给出立即数形式的掩码
orrs r7, r0, #(1 << 31) ; 用移位表达式写出同样的掩码
orrs r8, r0, r1, lsl #31 ; 用寄存器加立即数移位构造掩码
orrs r9, r0, r1, lsl r2 ; 移位量由寄存器 r2 给出
这四种写法效果相同,都是把 r0 的 bit31 置 1。实际编程时按可读性选择即可。
3. 后缀 S 是干什么的
上面的 orrs 比 orr 多了一个 S。指令格式中的 {S} 表示可选的 S 后缀:
- 不带
S:只计算结果,不影响 CPSR 的条件标志位; - 带
S:计算结果的同时,根据结果更新N、Z等条件标志位。
这就引出了下一节的主角:条件标志位。
七、条件标志位 NZCV 与条件执行
1. CPSR 中的四个条件标志位
CPSR 是当前程序状态寄存器,它的高 4 位是条件判断标志位,合称 NZCV:
| 标志位 | 名称 | 置位条件 |
|---|---|---|
N | 符号标志位 | 上条指令执行结果的最高位 bit31 为 1,则 N = 1,结果按有符号数解释时为负值 |
Z | 零值标志位 | 上条指令执行结果为 0(bit0 到 bit31 全为 0),则 Z = 1 |
C | 进位标志位 | 按无符号数解读:加法产生进位(最高位向更高位进位),或者减法没有借位,则 C = 1,否则 C = 0 |
V | 溢出标志位 | 按有符号数解读:结果超出 -2^31 到 2^31 - 1 的范围(两个正数相加得负数,或两个负数相加得正数),则 V = 1 |
注意 C 和 V 的区别恰好印证了“计算机只识别二进制”那句话:同一串二进制结果,按无符号数解读看 C,按有符号数解读看 V。
看两个例子:
ldr r0, =0x7FFFFFFF
adds r0, r0, #1 ; 正数 + 正数 = 负数,有符号溢出,V = 1,N = 1
ldr r0, =0xFFFFFFFF
subs r0, r0, r0 ; 结果为 0,Z = 1
2. 条件码
指令格式中的 <c> 表示条件码。ARM 几乎每条指令都可以带条件码,只有条件满足时才真正执行。常用条件码有:
| 条件码 | 含义 | 判断依据 |
|---|---|---|
eq | 等于(equal) | Z = 1 |
ne | 不等于(not equal) | Z = 0 |
ge | 有符号大于等于 | N = V |
gt | 有符号大于 | Z = 0 且 N = V |
le | 有符号小于等于 | Z = 1 或 N != V |
lt | 有符号小于 | N != V |
al | 无条件执行 | 默认情况,通常省略不写 |
例如:
movge r2, r0 ; 只有当条件满足 ge 时,才执行这次传送
movlt r2, r1
3. cmp:比较指令
cmp 用来比较两个数,格式与 sub 类似:
CMP<c> <Rn>, #<const>
CMP<c> <Rn>, <Rm>{, <shift>}
CMP<c> <Rn>, <Rm>, <type> <Rs>
cmp r0, r1
cmp r0, r1 本质上等价于 subs r0, r1 但不保存结果,它只做一次减法并更新 NZCV 标志位,为后面的条件执行提供依据。
4. 实战:比较两个数的最大值
mov r0, #100
mov r1, #200
cmp r0, r1 ; 比较 r0 和 r1
movgt r3, r0 ; 如果 r0 > r1,r3 = r0
movle r3, r1 ; 如果 r0 <= r1,r3 = r1
这段代码没有跳转,靠条件执行就实现了 C 语言的:
if (r0 > r1)
r3 = r0;
else
r3 = r1;
5. 练习:比较三个数的最大值
思路是先比出前两个数的最大值,再拿它和第三个数比:
mov r0, #100
mov r1, #200
mov r2, #56
; 第一步:比较 r0 和 r1
cmp r0, r1
movgt r3, r0 ; r3 = max(r0, r1)
movle r3, r1
; 第二步:比较 r3 和 r2
cmp r3, r2
movgt r4, r3 ; r4 = max(r3, r2),即三个数的最大值
movle r4, r2
八、跳转指令:b、bl、bx
1. 三条指令的区别
| 指令 | 格式 | 作用 | 是否保存返回地址 |
|---|---|---|---|
b | B<c> <label> | 无条件或条件跳转 | 不保存 |
bl | BL<c> <label> | 带链接的跳转,用于函数调用 | 保存到 lr |
bx | BX<c> <Rm> | 跳转到寄存器指定的地址 | 不保存,常用于函数返回 |
2. b:普通跳转
b fun
b fun 的效果相当于 ldr pc, =fun,即直接把 fun 的地址装进程序计数器 pc,处理器下一条指令就从 fun 处取。跳过去之后,处理器不记得自己从哪里来。
3. bl:函数调用跳转
bl fun
bl 在跳转之前,会先把返回地址(当前 pc 的值)保存到链接寄存器 lr,然后再跳转。也就是同时完成两件事:
lr = pc ; 保存返回地址
pc = fun ; 跳转到函数入口
4. bx:函数返回
bx lr
bx lr 的效果相当于 mov pc, lr,把 lr 里保存的返回地址送回 pc,程序回到调用点继续执行。
5. 一个完整的调用与返回
b main
func
mov r0, #1
mov r1, #2
add r3, r0, r1
bx lr ; 返回主调函数
main
mov r0, #100
mov r1, #200
bl func ; lr 保存返回地址,跳转到 func
mov r3, #300 ; func 返回后从这里继续执行
执行过程是 pc => lr and lr => pc:调用时 pc 的值进了 lr,返回时 lr 的值回到 pc。
九、用汇编写循环
1. 循环三要素
任何循环都离不开三要素:
- 循环结束条件;
- 推动循环趋向终结的语句;
- 循环体。
用汇编写循环,就是把这三要素分别翻译成比较指令、条件跳转和指令序列。
2. do…while 结构
C 语言:
int i = 0;
int sum = 0;
do {
sum += i;
i++;
} while (i <= 100);
汇编实现:
mov r0, #0 ; int i = 0
mov r1, #0 ; int sum = 0
loop
add r1, r1, r0 ; sum += i
add r0, r0, #1 ; i++
cmp r0, #100 ; 比较 i 和 100
ble loop ; i <= 100 则继续循环
do...while 的特点是循环体先执行一次,再判断条件。汇编上体现为:先写循环体,cmp 和条件跳转放在最后。
3. while 和 for 结构
C 语言:
int i = 0;
int sum = 0;
while (i <= 100)
{
sum += i;
i++;
}
等价的 for 写法:
int sum = 0;
for (int i = 0; i <= 100; i++)
{
sum += i;
}
汇编实现:
mov r0, #0 ; int i = 0
mov r1, #0 ; int sum = 0
loop
cmp r0, #100 ; 先判断条件
bgt finish ; i > 100 则跳出循环
add r1, r1, r0 ; sum += i
add r0, r0, #1 ; i++
b loop ; 无条件跳回判断处
finish
b finish ; 原地死循环,相当于 while(1)
while 的特点是先判断、后执行,所以 cmp 放在循环开头,条件不满足时用相反的条件码跳出。结尾的 finish: b finish 是裸机程序的常见收尾方式,让处理器停在原地,对应 C 语言里的 while(1);。
两种结构对比着记:
do...while:循环体在前,cmp+ 条件跳回在后;while:cmp+ 条件跳出在前,循环体居中,末尾无条件跳回开头。
十、栈与现场保护
1. 函数调用带来的两个问题
只用 bl 和 bx 写函数,很快就会撞上两个问题:
问题一:被调函数修改了主调函数的寄存器。 主调函数在 r0 里存了重要数据,调用函数后 r0 被改掉,回来就用错了。
问题二:函数嵌套时无法正确返回。 bl 会把返回地址写入 lr。如果 func0 调用 func1,func1 的 bl 会覆盖 func0 存在 lr 里的返回地址,func0 就找不到回家的路了。
这两个问题的统一解法就是栈:把需要保护的寄存器先压进栈里,用完再弹出来。
2. 栈的四种类型
栈由两个独立的维度组合而成:
- 满栈 / 空栈:栈顶指针
sp指向的位置是否已经存有数据。满栈指向最后压入的数据,空栈指向下一个空闲位置; - 增栈 / 减栈:压栈时
sp向高地址移动还是向低地址移动。
组合起来就是四种:满增、满减、空增、空减。
用 C 语言的指针操作来体会区别(以一维数组模拟栈):
// 空增栈:先写数据,再移动指针
*sp = 100;
sp++;
// 满增栈:先移动指针,再写数据
sp++;
*sp = 100;
另外两个要点:
- 栈底是固定的,栈顶是浮动的。 栈底地址在初始化时确定,之后不变;
sp随压栈、弹栈不断变化; - ARM 采用满减栈(例如 S3C2440 等经典 ARM9 平台)。满减栈的含义是:
sp始终指向最后压入的数据,压栈时sp先向低地址移动再存数据。
3. 初始化栈指针
假设片内 RAM 是 4KB,起始地址 0x40000000,那么这块内存的范围是 0x40000000 到 0x40000FFF。满减栈从高处向低处生长,所以栈底设在 RAM 顶端之外的一个位置:
ldr sp, =0x40001000
注意这里必须用 ldr 伪指令,因为 0x40001000 不是合法立即数,mov sp, #0x40001000 会报错。
在 Keil 中,这片 RAM 对应工程设置里的魔术棒 -> Target -> IRAM1,起始地址 0x40000000,大小 0x1000(即 4KB,4096 字节,最后一个字节地址是 0x40000FFF,也就是 4096 减 1)。
4. stmfd:保护现场(压栈)
STMDB<c> <Rn>{!}, <registers>
stmfd 是这条指令的栈视角别名,四个字母各代表一层含义:store(存储)、multiple(多个)、full(满)、decrease(减),即按满减栈的方式批量存储。
各字段含义:
<Rn>:栈顶指针寄存器,通常就是sp;{!}:带!表示入栈或出栈完成后,栈顶指针寄存器自动更新(自减或自增);<registers>:要入栈的寄存器列表。
典型写法:
stmfd sp!, {r0-r12, lr}
一句话把 r0 到 r12 以及 lr 全部压进栈里。
5. ldmfd:恢复现场(弹栈)
LDMFD<c> <Rn>{!}, <registers>
ldmfd:load(加载)、multiple(多个)、full(满)、decrease(减),按满减栈的方式批量加载:
ldmfd sp!, {r0-r12, lr}
一个非常有用的技巧:弹栈时可以直接把保存的返回地址弹进 pc,一条指令同时完成“恢复现场 + 函数返回”:
ldmfd sp!, {r0-r12, pc}
注意压栈时保存的是 lr,弹栈时弹给 pc,这一入一出就完成了返回。
6. 嵌套调用完整示例
按 GNU 体系的约定,保护现场和恢复现场由主调函数完成:
b main
func1
mov r0, #10
mov r1, #20
cmp r0, r1
movge r2, r0 ; r2 = max(r0, r1)
movlt r2, r1
bx lr
func0
mov r0, #1
mov r1, #2
add r3, r0, r1
stmfd sp!, {r0-r12, lr} ; 调用前保护现场
bl func1
ldmfd sp!, {r0-r12, pc} ; 恢复现场并返回
;bx lr ; 已经被上一句的弹栈到 pc 替代
main
ldr sp, =0x40001000 ; 初始化满减栈栈顶
mov r0, #100
mov r1, #200
stmfd sp!, {r0-r12, lr} ; 调用前保护现场
bl func0
ldmfd sp!, {r0-r12, lr} ; 恢复现场
mov r3, #300
finish
b finish
end
这套结构的固定套路是:
- 进入
main先初始化sp; - 每次
bl之前用stmfd sp!, {...}保护现场; bl返回后用ldmfd sp!, {...}恢复现场;- 被嵌套调用的函数(如
func0)也要保护自己的现场,尤其是lr。
十一、汇编与 C 语言互相调用
1. 在汇编中调用 C 语言函数
步骤如下:
第一步:创建 main.c,并在其中实现 C 函数。
int c_add(int a, int b, int c, int d, int e)
{
return a + b + c + d + e;
}
第二步:在汇编文件中声明并导入该函数。 Keil 环境下要求用 import 伪操作导入外部符号:
import c_add
第三步:保护现场、传递参数、调用、恢复现场。
ARM 的调用约定是:前 4 个参数依次通过 r0 到 r3 传递,第 5 个及以后的参数通过栈传递,返回值通过 r0 返回。
stmfd sp!, {r0-r12, lr} ; 保护现场
mov r0, #1 ; 第 1 个参数
mov r1, #2 ; 第 2 个参数
mov r2, #3 ; 第 3 个参数
mov r3, #4 ; 第 4 个参数
mov r4, #5 ; 第 5 个参数先放进 r4
stmfd sp!, {r4} ; 第 5 个参数压栈传递
bl c_add ; 调用 C 函数,返回值在 r0
ldmfd sp!, {r4} ; 参数出栈,恢复栈平衡
ldmfd sp!, {r0-r12, lr} ; 恢复现场
第四步:处理栈 8 字节对齐问题。 直接调用可能遇到这样的链接错误:
asm.axf: Error: L6238E: start.o(reset) contains invalid call from '~PRES8'
function to 'REQ8' function c_add.
这个错误的含义是:C 代码要求函数调用时栈指针保持 8 字节对齐,而汇编代码没有声明自己会保证这一点。解决办法是在汇编文件中加入栈对齐伪指令:
preserve8
preserve8 用于声明本文件的代码会确保函数调用时栈指针保持 8 字节对齐。
Keil 工程配置注意事项:
- 创建工程时如果自动添加了启动代码导致报错,可以重建覆盖工程,删除
.sct文件,再添加自己的start.s和main.c,并重设软件配置; - 魔术棒 -> Debug -> Use Simulator,取消 Run to main;
- 魔术棒 -> Linker -> 勾选 Use Memory Layout from Target Dialog;
- 魔术棒 -> Target -> ROM1 -> Start 设为
0x0,Size 设为0x2000。
2. 在 C 语言中调用汇编函数
方向反过来,步骤更简单:
第一步:在汇编文件中用 export 导出函数符号。
export func1
func1
; r0 = a, r1 = b
add r0, r0, r1 ; 返回值通过 r0 返回
bx lr
第二步:在 C 文件中用 extern 声明后正常调用。
extern int func1(int a, int b);
int result = func1(1, 2);
无论哪个方向,参数和返回值的处理规则都是一样的:前 4 个参数走 r0 到 r3,更多参数走栈,返回值走 r0。掌握这一条,汇编和 C 之间就能自由往返。
十二、CPSR 与工作模式切换
1. mrs 和 msr:读写程序状态寄存器
CPSR 是特殊寄存器,不能用 mov 直接读写,必须使用专门的两条指令:
MRS<c> <Rd>, <spec_reg> ; 读:把状态寄存器的值读到通用寄存器
MSR<c> <spec_reg>, #<const> ; 写:把立即数写入状态寄存器
MSR<c> <spec_reg>, <Rn> ; 写:把通用寄存器的值写入状态寄存器
助记:mrs 里的 r 对应 read,msr 里的 s 对应写入状态寄存器(spec_reg)。
2. 修改工作模式
CPSR 的低 8 位中包含 M 域(模式位,bit0 到 bit4),决定处理器当前处于哪种工作模式。启动代码的任务之一就是最后把模式切换为 user 模式(模式字段值 0x10)。
标准的修改套路是“读、改、写”三步:
mrs r0, cpsr ; 第一步:读出 CPSR
bic r0, r0, #(0x1F << 0) ; 第二步:把 M 域清 0
orr r0, r0, #(0x10 << 0) ; 第二步:写入 user 模式编码 0x10
msr cpsr, r0 ; 第三步:写回 CPSR
这里又一次用到了 bic 和 orr 的组合:先清掉要修改的位,再置入新的值,其他位不受影响。这和 C 语言里操作寄存器时“先与后或”的思路完全一致。
3. 异常向量表
启动代码的另一项任务是初始化异常向量表。异常向量表是一块固定地址的跳转表,每种异常对应一个入口:复位、未定义指令、软中断、预取中止、数据中止、IRQ、FIQ。
软中断异常可以在汇编里这样触发:
swi #7
swi 指令会让处理器切换到管理模式并跳转到软中断异常入口执行。异常向量表的每个入口通常只放一条跳转指令,跳到真正的处理函数。关于异常和中断的完整内容,后续篇章会展开,这里先建立“启动代码必须布置好这张表”的概念。
十三、完整工程代码解析
下面是一份能在 Keil 中实际运行的启动代码,它把本篇的知识点串在了一起:段定义、函数定义与调用、栈初始化、现场保护、条件执行、死循环收尾。
area reset, code, readonly
code32
entry
b asm_main
; 求两个数中的较大者(演示用,参数固定)
asm_twoNumMax
mov r0, #100
mov r1, #200
mov r0, #0
bx lr
; 求三个数中的最大值,演示函数嵌套调用
asm_threeNumMax
stmfd sp!, {lr} ; 嵌套调用前先保存 lr
bl asm_twoNumMax
mov r3, r0
ldmfd sp!, {lr} ; 恢复 lr
; 三个数中找最大值
stmfd sp!, {lr}
mov r0, r3
mov r1, r2
bl asm_twoNumMax
mov r12, r0
ldmfd sp!, {lr}
mov r0, r12
bx lr
asm_main
ldr sp, =0x40001000 ; 初始化满减栈栈顶
mov r0, #200
mov r1, #100
add r2, r0, r1
stmfd sp!, {r0-r12, lr} ; 调用前保护现场
mov r0, #100
mov r1, #200
mov r3, #300
bl asm_threeNumMax
ldmfd sp!, {r0-r12, lr} ; 调用后恢复现场
finish
b finish ; while(1); 停机
end
逐段看这份代码:
文件头。 area reset, code, readonly 定义了名为 reset 的只读代码段;code32 声明使用 32 位 ARM 指令集;entry 标记程序入口。处理器复位后从段首开始执行,第一条指令 b asm_main 跳过中间的函数定义,直奔主流程。
嵌套调用中的 lr 保护。 asm_threeNumMax 内部要两次调用 asm_twoNumMax。如果不在调用前把 lr 压栈,bl asm_twoNumMax 就会覆盖掉 asm_main 存在 lr 里的返回地址,asm_threeNumMax 执行完就回不去了。所以每次 bl 之前都有 stmfd sp!, {lr},调用返回后紧跟 ldmfd sp!, {lr}。
主流程的现场保护。 asm_main 一进来先初始化 sp,没有这一步 stmfd 就是往未知地址写数据。调用 asm_threeNumMax 前后分别用 stmfd 和 ldmfd 保护、恢复 r0 到 r12 和 lr。
收尾。 finish: b finish 让处理器在原地打转,这是裸机程序的标准结尾,防止 pc 跑出有效代码区域。
调试时可以在 Keil 模拟器中单步执行这份代码,重点观察三件事:sp 从 0x40001000 开始随压栈不断减小,lr 在 bl 时被写入返回地址,以及弹栈后寄存器值被精确还原。
十四、常见错误
1. 以为立即数随便写
mov sp, #0x40001000 ; 报错:非法立即数
凡是看到“非法立即数”报错,先按 12 位立即数的判断标准检查:这个数的二进制形式能否通过偶数位循环右移,使高 24 位全 0?不行就改用 ldr r0, =数值 伪指令,或者换 mvn 等指令凑出目标值。
2. 用 mov 给 sp 装大地址
栈初始化是最常见的踩坑点:
ldr sp, =0x40001000 ; 正确写法
3. 混淆 b、bl、bx
- 要跳转且需要返回:用
bl; - 只跳转不返回:用
b; - 函数返回:用
bx lr。
写成 b func 调用函数,跳过去就回不来了。
4. 嵌套调用忘记保存 lr
只要在函数内部还要使用 bl,就必须先把 lr 压栈:
stmfd sp!, {lr}
bl 子函数
ldmfd sp!, {lr}
否则子函数返回后,lr 里已经是子函数调用时覆盖的地址,当前函数无法正确返回。
5. 调用函数前忘记初始化 sp
stmfd、ldmfd 都依赖 sp。在使用任何压栈指令之前,必须先执行:
ldr sp, =栈顶地址
6. 压栈和弹栈不对称
stmfd 压了几个寄存器,ldmfd 就要弹几个,列表的“形状”要对应。压了 {r0-r12, lr} 却弹 {r0-r12},sp 就错位了,后续所有栈操作都会读到错误的数据。
7. 弹栈返回时把 lr 弹给 lr
想一条指令完成返回,弹栈列表的最后一个寄存器要写 pc:
ldmfd sp!, {r0-r12, pc} ; 恢复现场并返回
写成 {r0-r12, lr} 只是恢复 lr,还需要再来一条 bx lr。
8. 汇编调 C 报 L6238E 错误
忘记在汇编文件中写 preserve8,或者压栈后 sp 没有保持 8 字节对齐。先检查有没有 preserve8,再检查每次 bl 到 C 函数时栈的对齐情况。
9. 直接给 CPSR 赋值
mov cpsr, r0 ; 错误:CPSR 不能用 mov 访问
读写 CPSR 只能用 mrs 和 msr。
十五、指令与格式速查
1. 伪操作
| 伪操作 | 作用 |
|---|---|
area 名字, code, readonly | 定义一个只读代码段 |
code32 | 后续使用 32 位 ARM 指令集 |
thumb | 后续使用 16 位 Thumb 指令集 |
entry | 标记程序入口 |
end | 源文件结束 |
import 符号 | 导入外部符号(Keil 环境) |
export 符号 | 导出本文件符号 |
preserve8 | 声明保证栈 8 字节对齐 |
2. 数据处理指令
| 指令 | 含义 | 示例 |
|---|---|---|
mov | 数据传送 | mov r0, r1, lsl #2 |
mvn | 按位取反后传送 | mvn r0, #0 |
add | 加法 | add r2, r0, r1, lsl #4 |
sub | 减法 | sub r2, r0, #1 |
bic | 指定位清 0 | bic r0, r0, #(1 << 16) |
orr | 指定位置 1 | orr r0, r0, r1, lsl #15 |
cmp | 比较,只更新标志位 | cmp r0, #100 |
ldr(伪指令) | 加载任意 32 位数 | ldr sp, =0x40001000 |
指令通用形式:
指令{S}<c> <Rd>, <Rn>, 第二操作数
{S}:可选,带 S 则更新条件标志位;<c>:可选条件码;- 第二操作数可以是立即数、寄存器加可选移位、或寄存器加寄存器移位。
3. 12 位立即数
结构:| 4 位旋转位 N | 8 位有效位 imm8 |
结果 = imm8 循环右移 2N 位(N 取 0 到 15,右移 0 到 30 的偶数位)
判断:存在一种偶数位循环右移,使高 24 位全 0
4. 条件标志位
| 标志 | 置位条件 |
|---|---|
N | 结果 bit31 为 1(有符号解释为负) |
Z | 结果全为 0 |
C | 无符号解读:加法进位或减法无借位 |
V | 有符号解读:溢出(正加正得负,负加负得正) |
常用条件码:eq、ne、ge、gt、le、lt、al。
5. 跳转指令
| 指令 | 作用 | 等价理解 |
|---|---|---|
b label | 跳转 | ldr pc, =label |
bl label | 调用,保存返回地址到 lr | lr = pc 且 pc = label |
bx lr | 返回 | mov pc, lr |
6. 栈操作
ARM 采用满减栈:sp 指向最后压入的数据,压栈向低地址生长。
ldr sp, =栈顶地址 ; 初始化
stmfd sp!, {r0-r12, lr} ; 保护现场
ldmfd sp!, {r0-r12, lr} ; 恢复现场
ldmfd sp!, {r0-r12, pc} ; 恢复现场并返回
7. 函数调用约定
参数 1 到 4:r0、r1、r2、r3
参数 5 及以后:压栈传递
返回值:r0
汇编调 C:import 函数名 + preserve8
C 调汇编:export 函数名 + extern 声明
8. 状态寄存器
mrs r0, cpsr ; 读 CPSR
msr cpsr, r0 ; 写 CPSR
修改工作模式标准三步:
mrs r0, cpsr
bic r0, r0, #(0x1F << 0)
orr r0, r0, #(0x10 << 0) ; user 模式
msr cpsr, r0
小结
这一篇我们从“为什么学汇编”出发,明确了目标:编写启动代码,在处理器复位后搭建 C 语言运行的基本环境,包括初始化异常向量表、初始化各模式栈指针、开启中断允许、切换到 user 模式,最后跳进 C 语言主函数。围绕这个目标,我们先认识了汇编文件的骨架:area、code32、entry、end 这些伪操作写给汇编器看,真正的指令写给处理器看。指令部分,我们用“目标在前、源在后”的赋值视角掌握了 mov、mvn、add、sub,用“先清后置”的思路掌握了 bic、orr,并弄清了 12 位立即数的编码规则:8 位有效数据加 4 位旋转位,判断标准是看这个数能否通过偶数位循环右移使高 24 位全 0,不能就用 ldr 伪指令。程序结构部分,我们借助 CPSR 的 NZCV 标志位和条件码,用 cmp 加条件执行实现了分支,写出了 do...while 和 while 两种循环。函数部分,我们用 bl、bx 和 lr、pc 的配合完成调用与返回,并针对“寄存器被改”和“嵌套无法返回”两大问题,引入 ARM 的满减栈,用 stmfd、ldmfd 保护和恢复现场。最后,我们打通了汇编与 C 的双向调用,约定了参数走 r0 到 r3、更多参数走栈、返回值走 r0 的规则,学会了用 mrs、msr 读写 CPSR 切换工作模式,并初步认识了异常向量表和 swi。到这里,启动代码所需的指令储备基本齐了。下一篇我们将把这些指令组装成真正的启动代码,并继续学习异常处理与内存搬运相关的指令,让处理器从复位那一刻起就按我们设计的路线运行。
更多推荐

所有评论(0)