从零开始学习嵌入式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. 启动代码的主要任务

启动代码的主要任务可以归纳为五条:

  1. 初始化异常向量表;
  2. 初始化各工作模式的栈指针寄存器;
  3. 开启 ARM 内核中断允许;
  4. 将工作模式设置为 user 模式;
  5. 完成上述工作后,引导程序进入 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 决定

需要注意:

  1. 移位量 #<n> 或 <Rs> 的取值范围是 0 到 31;
  2. RRX 是扩展右移,它不需要移位量,固定只移一位,并且会把 CPSR 的 C 标志位带进最高位;
  3. 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)

关于书写格式有两点说明:

  1. {, <shift>} 中的 {} 表示这部分可以选择;, 表示使用时要在 Rm 后面加上逗号再写移位;
  2. 不存在 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. 三条指令的区别

指令格式作用是否保存返回地址
bB<c> <label>无条件或条件跳转不保存
blBL<c> <label>带链接的跳转,用于函数调用保存到 lr
bxBX<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. 循环三要素

任何循环都离不开三要素:

  1. 循环结束条件;
  2. 推动循环趋向终结的语句;
  3. 循环体。

用汇编写循环,就是把这三要素分别翻译成比较指令、条件跳转和指令序列。

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

这套结构的固定套路是:

  1. 进入 main 先初始化 sp;
  2. 每次 bl 之前用 stmfd sp!, {...} 保护现场;
  3. bl 返回后用 ldmfd sp!, {...} 恢复现场;
  4. 被嵌套调用的函数(如 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指定位清 0bic r0, r0, #(1 << 16)
orr指定位置 1orr 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调用,保存返回地址到 lrlr = 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。到这里,启动代码所需的指令储备基本齐了。下一篇我们将把这些指令组装成真正的启动代码,并继续学习异常处理与内存搬运相关的指令,让处理器从复位那一刻起就按我们设计的路线运行。

更多推荐