个人学习的笔记,希望帮助能到和我一样阅读Cortex-M3权威指南Cn遇到困难的人。
强烈建议先阅读Cortex-M3权威指南Cn第十章在来观看笔记
以下为Cortex‐M3 权威指南原文:
C 程序模块与汇编程序模块互相交互时,参数是如何传递的,以及值是如何返回的:
不过,在大多数场合下的情况都比较简单:当主调函数需要传递参数(实参)时,它们使用R0‐R3。其中R0传递第一个,R1传递第2个......在返回时,把返回值写到R0中。在子程序中,可以随心所欲地使用R0‐R3,以及R12(回顾第9章,想想为什么会PUSH它们)。但若使用R4‐R11,则必须在使用之前先PUSH它们,使用后POP回来。
补充:
txt
; AAPCS(ARM架构过程调用标准)规定:
; - 前4个参数:R0, R1, R2, R3
; - 返回值:R0(32位)或R0+R1(64位)
; - 被调用者保存:R4-R11(如果使用)
; - 调用者保存:R0-R3, R12, LR(如果需要在调用后保留)
;
; 示例:调用Putc前,字符必须放在R0
; Putc内部可以随意使用R0-R3,但必须保存R4-R11(如果使用)
可见,汇编程序使用R0‐R3, R12时会很舒服。但是如果换个立场------汇编要呼叫C函数,则考虑问题的方式就有所不同:必须意识到子程序可以随心所欲地改写R0‐R3, R12,却决不会改变R4‐R11。因此,如果在调用后还需要使用R0‐R3,R12,则在调用之前,必须先PUSH,从C函数返回后再POP它们,对R4‐R11则不用操心。在本章的示例程序中,绝大多数只是调用汇编子程序,它们只影响少量寄存器,或者会在返回前恢复寄存器的内容,所以往往没有严格遵守AAPCS。这主要是为了突出其它重点,简化程序,请读者不要钻牛角尖。
本章为提供了若干汇编写的例子,在实际应用中,这些程序都会用C写。但是以汇编的方式呈现,有助于让读者更深更好地理解CM3的工作内幕,以便在以后用C开发时心里更有底。这里给的程序都用ARM的汇编器(armasm)来汇编,其它工具可能对语法格式有些不同的要求。而且实际上,开发工具几乎都会把启动工作为你做好,让你根本不用去想还有启动代码的事(不过,这也妨碍了我们学习得更深入)。下面,就隆重请出本书第一个完整的示例程序(请参考向量表来阅读):
txt
STACK_TOP EQU 0x20002000 ; 定义常量 STACK_TOP = 0x20002000,用作复位后 MSP(主栈指针)的初始值
AREA |Header Code|, CODE ; 定义一个代码段(Section),名字为 "Header Code",属性为 CODE(可执行代码/只读代码)
DCD STACK_TOP ; 向量表第0项:初始主栈指针 MSP 的值(CPU 复位后会从 0x00000000 读取它并装入 MSP)
DCD Start ; 向量表第1项:复位向量 Reset_Handler 的入口地址(CPU 复位后会从 0x00000004 读取并跳转执行)
ENTRY ; 声明入口点,便于工具链/调试器识别(不改变CPU运行逻辑)
Start ; 标签:复位入口(相当于 Reset_Handler),复位后从这里开始执行
MOV r0, #10 ; r0 = 10,作为循环变量/当前要累加的数
MOV r1, #0 ; r1 = 0,作为累加器/保存求和结果
loop ; 标签:循环开始位置
ADD r1, r0 ; r1 = r1 + r0,把当前 r0 的值累加到结果里(此 ADD 不带 S,不更新标志位)
SUBS r0, #1 ; r0 = r0 - 1,并更新条件标志位(带 S:会更新 N/Z/C/V;当结果为0时 Z=1)
BNE loop ; 若 Z==0(即 r0 != 0),则跳回 loop 继续循环;否则退出循环
deadloop ; 标签:死循环入口(程序结束后停在这里,防止跑飞)
B deadloop ; 无条件跳转到自身,形成无限循环
END ; 汇编文件结束标记,告诉汇编器到此结束
这个例子非常简单,它只初始化了SP以及PC,以及初始化了需要使用的寄存器,然后就执行连加循环中。
txt
1) STACK_TOP EQU 0x20002000
- 类型:伪指令(汇编期生效,不生成CPU执行指令)
- 作用:定义一个符号常量 STACK_TOP,其值恒为 0x20002000
- 结果:后续凡是写 STACK_TOP,汇编器都会用 0x20002000 替换
- 用途:作为向量表的第0项,用来给 MSP(主栈指针)提供复位初值
2) AREA |Header Code|, CODE
- 类型:伪指令(组织段/节,不是CPU指令)
- 作用:声明一个名为 "Header Code" 的代码段(section),属性为 CODE
- 结果:该段内的内容会被放入同一节,最终由链接脚本/散装文件决定装载地址
- 用途:常用于放置向量表和启动入口代码
3) DCD STACK_TOP
- 类型:伪指令/数据定义(生成数据,不是CPU执行指令)
- 作用:在当前位置写入一个 32-bit 常量(一个word)
- 写入内容:0x20002000(即 STACK_TOP 的值)
- 用途:向量表第0项:复位后CPU从 0x00000000 读取该值并装入 MSP
4) DCD Start
- 类型:伪指令/数据定义(生成数据,不是CPU执行指令)
- 作用:在当前位置写入一个 32-bit 常量(一个word)
- 写入内容:标签 Start 的地址(链接后确定)
- 用途:向量表第1项:复位向量。CPU从 0x00000004 读取该地址并跳转执行
5) ENTRY
- 类型:伪指令(工具链/调试信息相关)
- 作用:标记入口点,供汇编器/链接器/调试器识别
- 结果:一般不改变CPU运行,仅影响符号与入口识别(实际复位入口仍由向量表决定)
6) Start
- 类型:标签(符号地址,不是指令)
- 作用:定义一个地址标号,供跳转/向量表引用
- 结果:Start 表示"从这一地址开始"的位置(复位后会跳到这里)
7) MOV r0, #10
- 类型:CPU指令(数据传送)
- 作用:把立即数 10 装入寄存器 r0
- 执行后:r0 = 10
- 标志位:本写法不带 'S',通常不更新条件标志位(N/Z/C/V)
8) MOV r1, #0
- 类型:CPU指令(数据传送)
- 作用:把立即数 0 装入寄存器 r1
- 执行后:r1 = 0
- 标志位:不带 'S',通常不更新条件标志位
9) loop
- 类型:标签(符号地址,不是指令)
- 作用:定义循环起点的地址,供分支指令跳转回来
10) ADD r1, r0
- 类型:CPU指令(算术加法)
- 作用:把 r0 加到 r1 上,并把结果写回 r1
- 执行前后:r1 = r1 + r0
- 标志位:不带 'S',不更新条件标志位(N/Z/C/V)
11) SUBS r0, #1
- 类型:CPU指令(算术减法 + 更新标志位)
- 作用:r0 自减 1,并更新条件标志位(因为带 S)
- 执行前后:r0 = r0 - 1
- 标志位更新(关键是 Z):
* Z(Zero):若结果为 0 -> Z=1,否则 Z=0
* N(Negative):若结果最高位为1 -> N=1,否则 N=0
* C(Carry/No borrow):与减法借位相关
* V(Overflow):有符号溢出相关
12) BNE loop
- 类型:CPU指令(条件分支)
- 条件:NE = Not Equal,本质判断 Z == 0
- 作用:如果 Z == 0,则跳转到标签 loop;否则顺序执行下一条
- 依赖:Z 标志位来自上一条 SUBS 的结果
- 本程序语义:当 r0 != 0 时继续循环;当 r0 == 0 时退出循环
13) deadloop
- 类型:标签(符号地址,不是指令)
- 作用:定义死循环入口地址
14) B deadloop
- 类型:CPU指令(无条件分支)
- 作用:无条件跳转到标签 deadloop
- 结果:跳到自身形成无限循环,程序停在这里不再向下执行
15) END
- 类型:伪指令(汇编源文件结束)
- 作用:告诉汇编器源文件到此结束
- 结果:不生成CPU指令,仅影响汇编器处理流程
补充:
txt
; 完整向量表结构(前16项为系统异常):
; 偏移 内容 说明
; 0x00 MSP初始值 复位后自动加载到MSP
; 0x04 Reset_Handler 复位异常处理程序
; 0x08 NMI_Handler NMI异常处理程序
; 0x0C HardFault_Handler 硬错误处理程序
; 0x10 MemManage_Handler 内存管理错误
; 0x14 BusFault_Handler 总线错误
; 0x18 UsageFault_Handler用法错误
; 0x1C 保留
; 0x20 保留
; 0x24 保留
; 0x28 SVCall_Handler SVC系统调用
; 0x2C 保留
; 0x30 保留
; 0x34 PendSV_Handler PendSV异常
; 0x38 SysTick_Handler SysTick异常
; 0x3C 开始外部中断向量 IRQ0, IRQ1, ...
以下为Cortex‐M3 权威指南原文:
让我们看看一个简单的字符输出子程是啥模样:
txt
UART0_BASE EQU 0x4000C000 ; UART0 外设基地址
UART0_FLAG EQU UART0_BASE+0x018 ; UART0 标志寄存器地址(用于判断发送缓冲/FIFO 状态)
UART0_DATA EQU UART0_BASE+0x000 ; UART0 数据寄存器地址(写入此寄存器即发送数据)
Putc ; 子程序:使用 UART 发送 1 个字符
; 入口条件:R0 = 需要发送的字符(通常只使用低 8 位)
; 返回:无(字符已写入 UART 发送缓冲区/发送 FIFO)
PUSH {R1,R2, LR} ; 保存现场:保护 R1、R2,并保存返回地址 LR
LDR R1, =UART0_FLAG ; R1 = UART0_FLAG 寄存器地址(0x4000C018)
PutcWaitLoop ; 等待循环起点:直到发送缓冲区不满
LDR R2, [R1] ; 读取 UART0_FLAG 寄存器的当前状态到 R2
TST R2, #0x20 ; 测试 bit5(0x20):检查"发送缓冲满/发送FIFO满"标志位是否为 1
BNE PutcWaitLoop ; 若该位为 1(缓冲满),则继续等待(一直轮询,可能死循环)
LDR R1, =UART0_DATA ; 发送缓冲有空位:R1 = UART0_DATA 寄存器地址(0x4000C000)
STRB R0, [R1] ; 写入 1 字节:把 R0 的低 8 位写到 DATA 寄存器,推进 UART 发送缓冲/发送 FIFO
POP {R1,R2, PC} ; 恢复现场并返回:弹出 R1、R2,并把返回地址装入 PC(等价于函数返回)
txt
LDR
- 格式:LDR Rd, [Rn] / LDR Rd, [Rn, #imm] / LDR Rd, =imm_or_label
- 功能:
* LDR Rd, [Rn] :从内存地址 Rn 读取 32-bit 到 Rd
* LDR Rd, [Rn, #imm] :从 (Rn+imm) 读取 32-bit 到 Rd
* LDR Rd, =... :把常量值或符号地址装入 Rd(汇编器展开的伪指令)
STRB
- 格式:STRB Rt, [Rn] / STRB Rt, [Rn, #imm]
- 功能:把 Rt 的低 8 位写入内存地址 (Rn 或 Rn+imm)
TST
- 格式:TST Rn, Operand2
- 功能:做按位与测试 (Rn & Operand2),不保存结果,只更新标志位(主要是 Z)
B
- 格式:B label
- 功能:无条件跳转到 label
BNE
- 格式:BNE label
- 功能:当 Z=0(不等/结果非0)时跳转到 label
PUSH
- 格式:PUSH {reglist}
- 功能:把寄存器列表压栈保存,更新 SP
POP
- 格式:POP {reglist}
- 功能:从栈中弹出恢复寄存器列表,更新 SP;若包含 PC 则实现跳转/返回
以下为Cortex‐M3 权威指南原文:
在这里的UART是虚构的,其寄存器的地址和位定义都只是为了演示,抛砖引玉。你还需要根据自己使用的UART来重塑代码,有些UART还要求更精密地检查状态位。另外,还需要一个用于初始化UART的子程------至少得设置波特率吧。我们为了突出主题,这些细节就不多谈了。在第20章中,有一个具体的例子。
现在,我们就可以通过这个基础设施一般的子程,来构造一系列的消息显示函数,它们都与输出字符的具体硬件无关了。
txt
Puts ; 子程序:通过 UART 输出一个以 0 结尾的字符串(C 风格)
; 入口条件:R0 = 字符串起始地址(指向第一个字符)
; 要求:字符串必须以 0x00 结尾,否则会一直读下去导致越界/死循环
; 依赖:内部调用 Putc(Putc 负责发送单个字符,且为阻塞式轮询发送)
PUSH {R0, R1, LR} ; 保存现场:保护 R0/R1,并保存返回地址 LR
; - 因为本函数会改动 R0/R1
; - BL Putc 会改 LR,所以先保存 LR
MOV R1, R0 ; R1 = R0,把字符串指针备份到 R1
; - 后续需要用 R0 作为 Putc 的参数(字符值)
; - 所以用 R1 来持续保存/更新字符串地址
PutsLoop ; 循环起点:逐字节读取并输出
LDRB R0, [R1], #1 ; 从 [R1] 读取 1 字节到 R0,然后 R1 = R1 + 1
; - 读取到的字符放入 R0(正好作为 Putc 的入参)
; - 后缀自增 #1:读完自动指向下一个字符
CBZ R0, PutsLoopExit ; 若 R0 == 0(遇到字符串结束符 '\0'),跳转到退出位置
; - CBZ = Compare and Branch on Zero(为 0 则跳转)
; - 依赖字符串必须以 0 结尾
BL Putc ; 调用 Putc 发送 1 个字符(R0 作为参数传入)
; - BL 会把返回地址写入 LR
; - Putc 内部通常会等待发送缓冲不满再写入 DATA(阻塞式)
B PutsLoop ; 无条件跳回循环,继续处理下一个字符
PutsLoopExit ; 退出点:已经输出完毕(遇到 0 结束符)
POP {R0, R1, PC} ; 恢复现场并返回
; - 恢复原来的 R0、R1
; - 弹出的 PC 值来自之前保存的 LR,实现函数返回
有了这个Puts,现在终于可以正式请大牌出场了------"Hello World"主程序:
txt
;======================
; 常量/外设地址定义
;======================
STACK_TOP EQU 0x20002000 ; 定义主栈指针 MSP 的初始值(栈顶地址)
UART0_BASE EQU 0x4000C000 ; UART0 外设基地址
UART0_FLAG EQU UART0_BASE+0x018 ; UART0 标志寄存器地址(用于判断发送缓冲/FIFO 状态)
UART0_DATA EQU UART0_BASE+0x000 ; UART0 数据寄存器地址(写入数据即发送)
;======================
; 向量表 + 入口段
;======================
AREA | Header Code|, CODE ; 定义代码段(该段应被链接到向量表所在地址处)
DCD STACK_TOP ; 向量表第0项:复位后 CPU 从 0x00000000 取值装入 MSP
DCD Start ; 向量表第1项:复位向量 Reset_Handler(复位后跳转到 Start)
ENTRY ; 标记入口(工具链识别用)
;======================
; 主程序入口
;======================
Start ; 复位后执行的入口点(Reset_Handler)
MOV r0, #0 ; 清零寄存器 r0(演示/初始化用,实际不一定必须)
MOV r1, #0 ; 清零寄存器 r1
MOV r2, #0 ; 清零寄存器 r2
MOV r3, #0 ; 清零寄存器 r3
MOV r4, #0 ; 清零寄存器 r4
BL Uart0Initialize ; 调用 UART0 初始化子程序(配置波特率/使能发送等)
; BL:把返回地址写入 LR,然后跳转到 Uart0Initialize
LDR r0, =HELLO_TXT ; r0 = 字符串 HELLO_TXT 的地址(作为 Puts 的参数)
; 注意:LDR r0, =label 是"装载地址/常量"的写法
BL Puts ; 调用 Puts:输出以 0 结尾的字符串
deadend ; 死循环标签:程序结束后停在这里
B deadend ; 无条件跳转到自身,形成无限循环
;-------------------------------------------------------------
; 子程序区:Puts / Putc / Uart0Initialize
;-------------------------------------------------------------
;======================
; Puts:输出字符串(0 结尾)
;======================
Puts ; 子程序:通过 UART 输出一个以 0 结尾的字符串
; 入口条件:r0 = 字符串起始地址(指向第一个字符)
; 要求:字符串必须以 0x00 结尾,否则会越界一直读下去
PUSH {R0, R1, LR} ; 保存现场:保护 r0、r1,并保存 LR(返回地址)
; 因为函数里会改 r0/r1,且 BL Putc 会覆盖 LR
MOV R1, R0 ; r1 = r0,把字符串指针保存到 r1
; 目的:r0 之后要用来装"字符值"传给 Putc
PutsLoop ; 循环起点:逐字节读取并输出
LDRB R0, [R1], #1 ; 从 [r1] 读取 1 字节到 r0,然后 r1 自增 1
; 读出的字符在 r0(正好作为 Putc 的入参)
CBZ R0, PutsLoopExit ; 若 r0 == 0(遇到 '\0' 结束符),跳转到退出位置
; CBZ:Compare and Branch on Zero(为 0 则跳)
BL Putc ; 发送 1 个字符(r0 为字符参数)
B PutsLoop ; 跳回循环继续输出下一个字符
PutsLoopExit ; 退出点:字符串输出完毕
POP {R0, R1, PC} ; 恢复 r0、r1,并把栈中保存的返回地址弹到 PC 直接返回
;======================
; Putc:输出单字符(阻塞轮询)
;======================
Putc ; 子程序:通过 UART 发送 1 个字符
; 入口条件:r0 = 需要发送的字符(通常只用低 8 位)
PUSH {R1, R2, LR} ; 保存现场:保护 r1、r2,并保存返回地址 LR
LDR R1, =UART0_FLAG ; r1 = UART0_FLAG 寄存器地址(0x4000C018)
PutcWaitLoop ; 等待发送缓冲区有空位
LDR R2, [R1] ; 读取 UART0_FLAG 寄存器到 r2(状态标志)
TST R2, #0x20 ; 按位测试 r2 的 bit5(0x20)
; 若该位为1:表示"发送缓冲满/发送FIFO满"(含义取决于具体芯片手册)
; TST 不保存结果,只更新标志位(尤其 Z)
BNE PutcWaitLoop ; 若 Z=0(测试结果非0,即 bit5=1),则继续等待
; 风险:若 UART 异常一直满,会卡死在这里
LDR R1, =UART0_DATA ; r1 = UART0_DATA 寄存器地址(0x4000C000)
STRB R0, [R1] ; 写 1 字节到 DATA:把 r0 低 8 位送入发送缓冲/FIFO
POP {R1, R2, PC} ; 恢复 r1、r2,并返回(弹出 PC 实现返回)
;======================
; Uart0Initialize:初始化 UART0
;======================
Uart0Initialize ; UART0 初始化函数
; 这里应完成:
; - 使能 UART0 时钟
; - 配置波特率、数据位、停止位、校验
; - 使能 UART 发送功能、配置 FIFO 等
; 当前代码未实现,仅做占位
BX LR ; 返回到调用点(跳转到 LR 保存的返回地址)
;-------------------------------------------------------------
; 数据区:字符串常量
;-------------------------------------------------------------
HELLO_TXT ; 标签:字符串起始位置
DCB "Hello world\n", 0 ; 定义字节串:Hello world + 换行 + 结束符 0
END ; 汇编源文件结束
本示例代码在各CM3单片机之间都是高度可移植,高度与硬件无关的。事实上,你只需要自己写Uart0Initialize子程,并调整Putc。之所以日子这么好过,是因为Putc与Puts已经完成了实质的工作。为了锦上添花,最好再提供几个子程,用于输出寄存器的值。首先是输出16进制数的子程。
txt
PutHex ; 子程序:以 16 进制形式输出一个 32-bit 寄存器值(格式:0xXXXXXXXX)
; 入口条件:R0 = 要显示的 32 位数值
; 依赖:Putc(发送单个字符到 UART)
PUSH {R0-R3, LR} ; 保存现场:保护 R0~R3 和 LR(函数内会改这些寄存器,且会调用 Putc)
MOV R3, R0 ; R3 = R0,备份原始数值
; 目的:后续要用 R0 作为 Putc 的参数(字符),所以先把数值放到 R3
MOV R0, #'0' ; R0 = 字符 '0',准备输出前缀 "0x"
BL Putc ; 输出 '0'
MOV R0, #'x' ; R0 = 字符 'x'
BL Putc ; 输出 'x' --> 此时已输出 "0x"
MOV R1, #8 ; R1 = 8,循环计数器:32 位数一共 8 个十六进制字符(8 个 nibble)
MOV R2, #28 ; R2 = 28,旋转位数:每次把目标 nibble 旋到最低 4 位
; 28 的意义:把最高 4 位旋到最低 4 位(相当于左移 4 或右移 28 的旋转关系)
PutHexLoop ; 循环起点:每次输出 1 个十六进制字符
ROR R3, R2 ; 将 R3 循环右移 28 位(ROR 28)
; 效果:原来的"最高 4 位"被旋转到"最低 4 位"
; 也可理解为:等价于"循环左移 4 位",让下一组 4 位来到最低位便于提取
AND R0, R3, #0xF ; R0 = R3 & 0xF,只保留最低 4 位(提取一个 nibble:0~15)
CMP R0, #0xA ; 比较 R0 与 10
; 目的:判断该 nibble 是 0~9 还是 10~15(A~F)
ITE GE ; If-Then-Else 条件块(基于上条 CMP 的结果)
; GE:Greater or Equal(>=,有符号比较条件)
; 接下来第一条指令在 GE 条件下执行,第二条在 LT 条件下执行
ADDGE R0, #55 ; 若 R0 >= 10:转换成 'A'~'F'
; 因为 'A' 的 ASCII 是 65
; 当 nibble=10 时需要输出 'A':10 + 55 = 65
; 所以这里用 +55 实现 10->'A', 11->'B' ... 15->'F'
ORRLT R0, #0x30 ; 若 R0 < 10:转换成 '0'~'9'
; '0' 的 ASCII 是 0x30
; 对 0~9 来说,nibble 的高位为 0,ORR 0x30 等价于加 0x30
; (很多代码也写成 ADDLT R0, #0x30,效果一样)
BL Putc ; 输出当前计算出的十六进制字符(ASCII)
SUBS R1, #1 ; R1 = R1 - 1,并更新标志位(用于判断是否结束循环)
BNE PutHexLoop ; 若 R1 != 0(Z=0),继续循环,总共执行 8 次
POP {R0-R3, PC} ; 恢复现场并返回:弹出 R0~R3,并把保存的返回地址装入 PC 实现返回
txt
PutDec ; 子程序:以 10 进制形式输出一个 32-bit 数值(无符号)
; 入口条件:R0 = 要显示的值(这里按无符号数处理)
; 依赖:Puts(输出 0 结尾字符串)、Putc(由 Puts 间接使用)
; 说明:32 位无符号最大值 0xFFFF_FFFF = 4294967295,需要 10 位十进制字符
; 再加上结尾 '\0',共 11 字节;这里额外对齐/留出 12 字节作为缓冲区空间
PUSH {R0-R5, LR} ; 保存现场:保护 R0~R5 以及 LR(函数内会改这些寄存器并调用子程序)
MOV R3, SP ; R3 = 当前 SP(作为"缓冲区写入指针/基准"使用)
; 注意:此时 SP 指向的是 PUSH 之后的栈顶位置
SUB SP, SP, #12 ; 在栈上再开辟 12 字节空间作为临时字符串缓冲区
; 由于栈向低地址增长:SP 减小表示"分配空间"
; 实际需要 11 字节(10 位数字 + '\0'),这里留 12 字节更方便/对齐
MOV R1, #0 ; R1 = 0,准备作为字符串结束符 '\0'
STRB R1, [R3, #-1]! ; 先写入 '\0' 到缓冲区末尾,并把 R3 预减 1 后更新
; 这是"预索引 + 写回"寻址方式:
; R3 = R3 - 1
; *(uint8_t *)R3 = R1
; 目的:后续把数字字符倒序写入(从末尾往前填)
MOV R5, #10 ; R5 = 10,十进制除数常量,用于反复除 10 取余
PutDecLoop ; 循环:每次求出最低一位(个位),然后把数字写入缓冲区(倒序)
UDIV R4, R0, R5 ; R4 = R0 / 10(无符号除法),得到商
; 例如 R0=123 -> R4=12
MUL R1, R4, R5 ; R1 = R4 * 10,用商乘回去
; 例如 R4=12 -> R1=120
SUB R2, R0, R1 ; R2 = R0 - (R0/10)*10 = R0 % 10(余数)
; 例如 123 - 120 = 3(个位)
ADD R2, #0x30 ; 把 0~9 转成 ASCII '0'~'9'
; 因为 '0' 的 ASCII 是 0x30
STRB R2, [R3, #-1]! ; 把这一位数字字符写入缓冲区,并让 R3 再向前移动 1 字节
; 同样是预减写回:
; R3 = R3 - 1
; *(uint8_t *)R3 = (uint8_t)R2
MOVS R0, R4 ; R0 = 商(下一轮继续处理更高位)
; 并更新标志位:若 R0==0,则 Z=1;若非0,则 Z=0
BNE PutDecLoop ; 若商 != 0(Z=0),继续循环
; 若商 == 0,说明所有十进制位都已求出,退出循环
MOV R0, R3 ; R0 = 字符串起始地址(此时 R3 指向缓冲区中最前面的数字字符)
; 因为我们是倒序填充,所以 R3 最终会停在"字符串起点"
BL Puts ; 输出字符串(以 '\0' 结尾),打印十进制结果
ADD SP, SP, #12 ; 释放临时缓冲区:SP 恢复到进入 PutDecLoop 前的位置
; 与 SUB SP, SP, #12 成对出现
POP {R0-R5, PC} ; 恢复现场并返回:弹出 R0~R5,并把返回地址装入 PC 实现返回
重温一下我们的第一个例子:在我们做到程序连接这一步时,我们手工指定了读/写区的位置。那么我们该如何把数据放到那里呢?正点的解决方法是:在汇编源文件中定义一个相应的数据区。让连接器把数据区中的内容分派到我们指定的位置------从0x2000_0000(SRAM区的起始)处开始的内存。
txt
STACK_TOP EQU 0x20002000 ; 定义常量:复位后 MSP(主栈指针)的初始值(栈顶地址)
AREA |Header Code|, CODE ; 定义代码段(放向量表与可执行代码)
DCD STACK_TOP ; 向量表第0项:初始 MSP 值(复位时从 0x00000000 取出装入 MSP)
DCD Start ; 向量表第1项:复位向量(复位时从 0x00000004 取出并跳转到 Start)
ENTRY ; 标记入口点(供工具链/调试器识别)
Start ; 复位后执行入口(相当于 Reset_Handler)
;-------------------------------
; 初始化寄存器
;-------------------------------
MOV r0, #10 ; r0 = 10:循环变量/当前要累加的值(从 10 开始)
MOV r1, #0 ; r1 = 0 :累加器/保存求和结果
;-------------------------------
; 计算 10+9+8+...+1
;-------------------------------
loop ; 循环起点标签
ADD r1, r0 ; r1 = r1 + r0:把当前 r0 累加到结果中(ADD 不带 S,通常不更新标志位)
SUBS r0, #1 ; r0 = r0 - 1,并更新标志位(Z=1 表示结果为 0)
BNE loop ; 若 Z=0(r0 != 0),继续循环;若 r0==0 则退出循环
;-------------------------------
; 此时 r1 为最终结果(55)
; 将结果写入数据区 MyData1
;-------------------------------
LDR r0, =MyData1 ; r0 = MyData1 的地址(装载符号地址,供后续存储使用)
STR r1, [r0] ; 把 r1 的 32 位结果写入内存 *(uint32_t*)MyData1 = r1
deadloop ; 死循环入口:程序结束后停在这里,防止跑飞
B deadloop ; 无条件跳转到自身,形成无限循环
;===============================
; 定义数据区
;===============================
AREA | Header Data|, DATA ; 定义数据段(用于存放变量/常量等数据)
ALIGN 4 ; 4 字节对齐(保证后续 DCD 数据按 32-bit 对齐,便于 STR/LDR 访问)
MyData1 ; 标签:MyData1 的地址(用于存放计算结果)
DCD 0 ; 分配 1 个 32-bit 空间并初始化为 0(结果写入目标)
MyData2 ; 标签:MyData2 的地址(额外预留的 32-bit 数据单元)
DCD 0 ; 分配 1 个 32-bit 空间并初始化为 0
END ; 源文件结束标记(汇编器处理到此结束)
在连接阶段,连接器要把DATA区放入读/写存储器中,因此MyData1的地址就将是我们指定的0x2000_0000。
使用互斥访问实现信号量操作:
互斥访问是新出来的,并且专门用于信号量的操作中。最常见的用途,就是确保需要互斥使用的共享资源只被一个任务拥有。
让我们举个例子。记DeviceALocked是一个位于内存中的R/W变量,用于指示设备A是否已经在使用中。任何一个任务,若欲使用设备A,都必须先检查这个变量的值。如果它的值为零,则表示设备可以使用。在任务获取到设备A后,它要把DeviceALocked的值改为1,表示设备A已经被占用。在设备A使用完毕后,该任务通过重新清零DeviceALocked来释放设备A,从而使其它任务可以使用此设备。
看起来这是个如意算盘。不过可否想过,如果两个任务都想访问设备A,是否有潜在的危险?比如,在任务1读取了DeviceALocked后,发现是零于是准备使用此设备,但还没来得及把它改为1,就不巧被调度器切出(比如,轮转调度),然后调度器让任务2执行,于是任务2也读到零,从而它使用设备A。但是在任务2在用完设备A之前,调度器又切回任务1。由于任务1早先读回来的是零,所以它认为设备A是空闲的,于是使用设备A,这时就违背了设备A必须互斥访问的限制,使系统出现紊乱危象!如果设备A是台打印机,则把两个文档的内容打在了一起;如果设备A是油门控制器,则可能使汽车失控或熄火,后果不堪设想。
为避免此问题,必须也保证DeviceALocked的互斥访问。回顾一下第5章,STREX指令是有返回值的,指示访问是成功还是被"驳回"。接上例,如果任务#1和任务#2都使用STREX,则任务#1的STREX将被驳回------返回1,从而任务1知道这期间已经发生了很多事,设备A已被他人占有,就避免了紊乱危象。
txt
LockDeviceA ; 子程序:尝试获取设备A的互斥锁(基于 LDREX/STREX)
; 返回值:R0=0 表示成功;R0=1 表示失败
; 成功时效果:把 DeviceALocked 从 0 改为 1(表示已上锁)
; 说明:这是典型的"先独占读,再独占写"的原子尝试加锁流程
PUSH {R1, R2, LR} ; 保存现场:保护 R1、R2,并保存返回地址 LR
TryToLockDeviceA ; 标签:尝试加锁流程起点(可用于重试逻辑扩展)
LDR R1, =DeviceALocked ; R1 = DeviceALocked 的地址(锁变量的内存地址)
LDREX R2, [R1] ; 独占读:R2 = *DeviceALocked
; 同时在本核上建立"独占监视器"状态
; 作用:标记"我准备对该地址进行原子更新"
; 注意:如果期间有别的核/中断/总线主设备写了该地址,独占状态会被清除
CMP R2, #0 ; 比较当前锁值是否为 0
; 约定:0=未上锁,1=已上锁(或非0=已上锁)
BNE LockDeviceAFailed ; 若 R2 != 0,说明已被锁住,直接失败返回
DeviceAIsNotLocked ; 标签:表示当前看到的是"未上锁"状态
MOV R0, #1 ; R0 = 1,准备写入锁变量(想把它置 1 表示加锁)
STREX R2, R0, [R1] ; 独占写:尝试执行 *DeviceALocked = R0(即写 1)
; 写入是否成功由独占监视器决定:
; - 若从 LDREX 到现在期间该地址未被他人写过:写成功,R2=0
; - 若期间被写过/独占状态丢失:写失败,R2!=0(通常为1),且不会写入
CMP R2, #0 ; 检查 STREX 返回值
; R2==0 表示写入成功(加锁成功)
; R2!=0 表示写入失败(可能被别人抢先锁住/中断破坏了独占状态)
BNE LockDeviceAFailed ; 若 STREX 失败,则加锁失败返回
; (很多实现会在这里加一个重试循环,本例直接失败)
LockDeviceASucceed ; 标签:加锁成功路径
MOV R0, #0 ; 返回值 R0=0 表示成功
POP {R1, R2, PC} ; 恢复现场并返回:弹出 R1、R2,并把 LR 弹到 PC 实现返回
LockDeviceAFailed ; 标签:加锁失败路径
MOV R0, #1 ; 返回值 R0=1 表示失败
POP {R1, R2, PC} ; 恢复现场并返回
补充:
txt
; LDREX/STREX 状态机:
; 1. LDREX: 设置"独占监视器"为OPEN状态,标记该地址
; 2. 中间操作: 如果发生以下事件,独占状态被清除:
; - 任何STR/STM指令(即使访问其他地址)
; - 异常进入/退出
; - CLREX指令
; 3. STREX: 检查独占状态
; - 如果为OPEN → 写入成功,返回0
; - 如果为IDLE → 写入失败,返回1
;
; 关键:这是硬件实现的"乐观锁",假设通常不会冲突
如果返回的是1,则为了避免紊乱危象,任务必须重试。在单处理机系统中,互斥访问主要用在ISR与主程序之间,用以保护它们共享却需要互斥访问的资源(如,一块内存,一个外设)。此时,引起互斥写失败的唯一原因,就是在读写期间曾响应过中断。如果代码在特权级下运行,还可以通过设置PRIMASK,在"测试------置位"期间暂时把中断给掐了。
在多处理机系统中,情况会变得更复杂。此时,除了本机的中断,其它处理机对同一块内存的访问也可以使互斥写操作失败。为了检测到其它处理机对内存的访问,总线系统中必须加入一个"互斥访问监视"的硬件基础设施。它负责检测在互斥读写期间,总线上是否有其它主机访问了互斥锁及其临近的"高危地带"。事实上,在绝大多数低成本的CM3单片机中,都只包含了一个核,因此无需此监视器。
有了这个机制,我们就可以确信共享资源一定能互斥地使用,不会发生紊乱危象。如果一个共享资源在多次尝试时依然无法获取,则可能必须放弃对此资源的请求,有可能先前锁住该资源的任务已经崩溃了。
使用位带实现互斥锁操作:
如果存储器系统支持"锁定传送"(locked transfers),或者总线上只有一个主机,还可以使用CM3的位带功能来实现互斥锁的操作。通过使用位带,则可以在C程序中实现互斥锁,但是操作过程与互斥访问是不同的。在使用位带来做资源分配的控制机制时,需要使用位带存储区的内存单元(比如,一个字),该内存单元的每个位表示资源正被特定的任务使用。
在位带别名区的读写实质上是锁定的"读‐改‐写"(在传送期间总线不能被其它主机占有)。因此,只要每个任务都仅修改分配给它们自己的锁定位,其它任务锁定位的值就不会丢失,即使是两个任务同时写自己的锁定位也不怕。
注意:
txt
忘记保存LR
; 错误
MyFunc
BL OtherFunc ; 覆盖了LR
BX LR ; 返回地址错误!
; 正确
MyFunc
PUSH {LR} ; 保存返回地址
BL OtherFunc
POP {PC} ; 直接返回到调用者
误用标志位
; ADD不会更新Z标志(错误)
ADD R0, R1
BEQ label ; 不会按预期跳转
; 使用ADDS(正确)
ADDS R0, R1
BEQ label ; 正确判断结果是否为0
数据对齐问题*
; 非对齐访问(在有些区域会导致HardFault)(错误)
LDR R0, [R1, #1] ; 地址0x20000001不是4的倍数
; 保证地址对齐(正确)
MOV R2, R1
ADD R2, #4
LDR R0, [R2] ; 地址0x20000004是4的倍数
互斥访问的错误理解
; 错误:以为LDREX/STREX是配对原子指令
LDREX R0, [R1]
; ... 很多其他操作 ...
STREX R2, R0, [R1] ; 可能因中断而失败
; 正确:紧凑使用
TryLock
LDREX R0, [R1]
CMP R0, #0
BNE Fail
MOV R0, #1
STREX R2, R0, [R1] ; 尽快执行,减少被打断机会
CMP R2, #0
BNE TryLock ; 失败重试
总结:
txt
MOV
- 格式:MOV Rd, #imm | MOV Rd, Rn
- 字段含义:Rd=目的寄存器(被写入);#imm/Rn=源操作数(提供数据)
- 作用:把源操作数写到 Rd(通常不更新标志位)
MOVS
- 格式:MOVS Rd, #imm | MOVS Rd, Rn
- 字段含义:Rd=目的寄存器;#imm/Rn=源操作数;S=更新标志位(N/Z/C/V)
- 作用:写入 Rd,并更新标志位(常用 Z 判断是否为0)
ADD / ADD{cond}
- 格式:ADD Rd, Rn, Rm | ADD Rd, Rn, #imm | ADD{cond} Rd, Rn, #imm
- 字段含义:Rd=结果;Rn=第1操作数;Rm/#imm=第2操作数;{cond}=条件后缀
- 作用:加法写回 Rd;带 cond 时条件满足才执行(如 GE/LT 等)
SUBS
- 格式:SUBS Rd, Rn, #imm | SUBS Rd, Rn, Rm
- 字段含义:Rd=结果;Rn=被减数;Rm/#imm=减数;S=更新标志位
- 作用:减法写回 Rd 并更新标志位(常配合 BNE/BEQ 做循环)
AND
- 格式:AND Rd, Rn, #imm | AND Rd, Rn, Rm
- 字段含义:Rd=结果;Rn=第1操作数;Rm/#imm=掩码/第2操作数
- 作用:按位与(常用于提取位域/低4位等)
ORR / ORR{cond}
- 格式:ORR Rd, Rn, #imm | ORR Rd, Rn, Rm | ORR{cond} Rd, Rn, #imm
- 字段含义:Rd=结果;Rn=第1操作数;Rm/#imm=置位掩码/第2操作数;{cond}=条件后缀
- 作用:按位或;带 cond 时条件满足才执行(如 LT 分支下转换ASCII)
TST
- 格式:TST Rn, #imm | TST Rn, Rm
- 字段含义:Rn=被测试值;#imm/Rm=掩码(选定位)
- 作用:做 AND 测试,不写回;只更新标志位(Z=1 表示选中的位全为0)
CMP
- 格式:CMP Rn, #imm | CMP Rn, Rm
- 字段含义:Rn=左操作数;#imm/Rm=右操作数
- 作用:比较(更新标志位供条件跳转/ITE/条件执行使用)
LDR
- 格式:LDR Rd, [Rn] | LDR Rd, [Rn, #imm] | LDR Rd, =imm_or_label
- 字段含义:Rd=装载目标;[Rn]=从Rn指向地址读32位;[Rn,#imm]=从Rn+imm读32位;=...=把常量/符号地址装进Rd
- 作用:读32-bit内存/寄存器到 Rd;或获取某变量/外设寄存器的"地址/常量值"
LDRB
- 格式:LDRB Rd, [Rn] | LDRB Rd, [Rn, #imm] | LDRB Rd, [Rn], #imm
- 字段含义:Rd=装载目标;[Rn]=读8位;[Rn,#imm]=读Rn+imm的8位;[Rn],#imm=读完后Rn自增imm
- 作用:读8-bit(常用于字符串逐字节取字符,并自动移动指针)
STR
- 格式:STR Rt, [Rn] | STR Rt, [Rn, #imm]
- 字段含义:Rt=要写出的数据寄存器;[Rn]/[Rn,#imm]=写入地址(Rn 或 Rn+偏移)
- 作用:写32-bit到内存/外设寄存器(如保存计算结果)
STRB
- 格式:STRB Rt, [Rn] | STRB Rt, [Rn, #imm]
- 字段含义:Rt=数据源(只取低8位);[Rn]/[Rn,#imm]=写入地址
- 作用:写8-bit到内存/外设寄存器(如写UART DATA寄存器)
PUSH
- 格式:PUSH {reglist}
- 字段含义:{reglist}=要保存的寄存器列表(如 {R1,R2,LR})
- 作用:压栈保存现场,更新 SP(函数入口常用)
POP
- 格式:POP {reglist} | POP {..., PC}
- 字段含义:{reglist}=要恢复的寄存器列表;PC=若弹到PC则立即跳转
- 作用:出栈恢复现场;包含 PC 时常用于函数返回(把保存的LR弹到PC)
B
- 格式:B label
- 字段含义:label=跳转目标标签
- 作用:无条件跳转(死循环/回到循环起点)
BNE
- 格式:BNE label
- 字段含义:label=跳转目标;NE=条件"Z==0"(不等/非零)
- 作用:当 Z=0 时跳转(常配合 SUBS/CMP/TST 做循环或等待)
BL
- 格式:BL label
- 字段含义:label=被调用子程序入口;LR=自动写入返回地址
- 作用:调用子程序(跳转并保存返回地址到 LR)
BX
- 格式:BX Rm (常用:BX LR)
- 字段含义:Rm=提供目标地址的寄存器(LR时即返回地址)
- 作用:跳转到寄存器地址;BX LR 常用于子程序返回
CBZ
- 格式:CBZ Rn, label
- 字段含义:Rn=被检查寄存器;label=跳转目标
- 作用:若 Rn==0 则跳转(常用于检测字符串结束符 '\0')
ROR
- 格式:ROR Rd, Rn, #imm | ROR Rd, Rn, Rm
- 字段含义:Rd=结果;Rn=被旋转值;#imm/Rm=旋转位数
- 作用:循环右移(位旋转),常用于把高位4bit旋到低位提取十六进制字符
ITE
- 格式:ITE cond
- 字段含义:cond=条件(来自上一条 CMP/TST 设置的标志位)
- 作用:Thumb条件块:紧随其后1条按 cond 执行(Then),下一条按反条件执行(Else)
UDIV
- 格式:UDIV Rd, Rn, Rm
- 字段含义:Rd=商;Rn=被除数;Rm=除数
- 作用:无符号除法(Rd = Rn / Rm),用于十进制转换
MUL
- 格式:MUL Rd, Rn, Rm
- 字段含义:Rd=乘积;Rn/Rm=两个乘数
- 作用:乘法(Rd = Rn * Rm),配合 UDIV 求余数
LDREX
- 格式:LDREX Rt, [Rn]
- 字段含义:Rt=读出的值;[Rn]=独占访问的地址
- 作用:独占读并建立独占监视器(原子操作/加锁基础)
STREX
- 格式:STREX Rd, Rt, [Rn]
- 字段含义:Rd=状态返回(0成功,非0失败);Rt=要写入的值;[Rn]=目标地址(需与之前LDREX配对)
- 作用:独占写尝试:独占状态有效则写入成功;否则不写入并返回失败