1. 为什么还要在Keil5里写STM32汇编?
现在一提到STM32开发,大家脑子里蹦出来的第一个词肯定是“CubeMX”,然后是“HAL库”。C语言几乎成了嵌入式开发的唯一选择,以至于很多刚入行的朋友可能都没见过用汇编写的STM32工程。那么,在2024年的今天,我们为什么还要费劲去Keil5里创建一个纯汇编语言的STM32工程,并且去分析它生成的hex文件呢?
这绝不是为了复古或者炫技。对于真正想深入理解单片机,尤其是ARM Cortex-M内核运行机制的人来说,汇编语言是一把无法绕开的钥匙。当你用C语言写GPIO_SetBits(GPIOA, GPIO_Pin_0);时,你知道这条语句最终是如何在芯片的寄存器层面被执行的吗?编译器帮你做了哪些优化?中断发生时,现场是如何被自动保存和恢复的?这些问题的答案,都藏在汇编指令和最终生成的机器码里。
通过亲手搭建一个最精简的汇编工程,你可以最直观地看到:
- 启动流程的“裸奔”真相:没有启动文件(startup.s)的自动拷贝和跳转,你的程序第一条指令从哪开始执行?堆栈指针(SP)是如何被初始化的?
- 内存空间的绝对掌控:你写的每一条指令,最终存放在Flash的哪个绝对地址?变量(如果算的话)又放在了RAM的哪里?Hex文件就是这份“内存布局图”的文本化呈现。
- 编译-链接过程的透明化:你将清晰地看到,一个
.s文件是如何经过汇编器变成.o,再通过链接器根据分散加载文件(scatter file)的安排,最终生成可执行映像的。这个过程在集成度高的IDE里被隐藏得太好了。
所以,这篇内容的目标,就是带你从零开始,在Keil MDK v5(我们常说的Keil5)环境下,创建一个“麻雀虽小,五脏俱全”的STM32汇编工程,并像法医解剖一样,仔细分析其产出的hex文件,让你对嵌入式程序的生与死,有一个最底层的认识。
2. 工程创建:从空白到第一个汇编指令
很多人用Keil5创建过C语言工程,向导点点点就完成了。但创建纯汇编工程,很多选项的意味就完全不同了。我们以最常见的STM32F103C8T6(蓝色药丸核心板)为例。
2.1 项目初始化与关键设备选择
打开Keil5,点击Project -> New uVision Project...。选择一个空文件夹,给你的工程起个名字,比如STM32_ASM_LED。
接下来是第一个关键点:选择设备。在搜索框里输入STM32F103C8,选择STM32F103C8。这里务必注意,不要直接点“OK”。先点一下这个设备,右边会弹出它的描述,确认Flash大小为64KB,RAM为20KB,和你手头的芯片一致。然后点击“OK”。
这时会弹出一个非常著名的对话框:“Copy ‘STM32F103C8 Startup’ to your project and add file to your project?”。对于C语言工程,我们通常选“是”,让Keil帮我们拷贝一份启动汇编文件。但今天,我们要说“否”。因为我们就是要自己从头写所有的汇编代码,包括启动代码。点击“否”。
现在,你的工程管理窗口里应该只有一个Target 1,下面空空如也。右键点击Source Group 1,选择Add New Item to Group 'Source Group 1'...。在弹出窗口中,选择Asm File (.s),命名为main.s。注意,Keil的汇编源文件后缀是.s,而不是.asm。
2.2 编写最简化的启动与主程序汇编
在main.s文件中,我们将写入最核心的两部分代码:启动设置和主循环。这里先给出一个能让程序“跑起来”的最小框架。
; STM32F103C8T6 最小汇编工程 - main.s ; 作者:一个想理解底层的人 AREA RESET, DATA, READONLY ; 定义一个名为RESET的只读数据区 EXPORT __Vectors ; 导出向量表符号,供链接器使用 __Vectors DCD 0x20005000 ; 初始堆栈指针值 (栈顶地址,RAM末尾) DCD Reset_Handler ; 复位向量,指向复位处理程序 ; 这里可以继续添加其他中断向量,如NMI、HardFault等,本例暂略 AREA |.text|, CODE, READONLY ; 定义一个名为.text的只读代码区 ENTRY ; 标记程序入口点(对于汇编器) EXPORT Reset_Handler ; 导出复位处理程序符号 Reset_Handler PROC ; 复位处理程序开始 ; 1. 初始化关键系统时钟?对于最小化工程,我们暂时依赖内部HSI RC 8MHz时钟。 ; 2. 设置向量表偏移寄存器(VTOR)?如果向量表不是从0x08000000开始则需要。 ; 本例向量表就在Flash开头,Cortex-M3默认从0x08000000取向量,所以不用设置。 ; 3. 跳转到主程序 LDR R0, =__main ; 加载主程序入口地址(由链接器决定) BX R0 ; 跳转并切换指令集(如果需要) ENDP ; 复位处理程序结束 ; 主程序 AREA MAIN, CODE, READONLY EXPORT __main ; 导出main符号,供启动代码调用 __main PROC ; 主循环开始 ; 示例:配置PA0为推挽输出(非常简化的版本,未考虑时钟使能) LDR R0, =0x40010800 ; GPIOA 基地址 (APB2) LDR R1, [R0, #0x00] ; 读取GPIOA_CRL寄存器 BIC R1, R1, #0x0000000F ; 清除PA0模式位(CNF0[1:0] & MODE0[1:0]) ORR R1, R1, #0x00000002 ; 设置PA0为通用推挽输出,最大速度2MHz (MODE0=0b10) STR R1, [R0, #0x00] ; 写回GPIOA_CRL Loop ; 点亮LED(假设LED阴极接PA0,低电平点亮) LDR R1, =0x4001080C ; GPIOA_ODR 寄存器地址 MOV R2, #0x00 ; 准备数据:PA0输出0 STR R2, [R1] ; 输出低电平,LED亮 BL Delay ; 调用延时函数 ; 熄灭LED MOV R2, #0x01 ; 准备数据:PA0输出1 STR R2, [R1] ; 输出高电平,LED灭 BL Delay ; 调用延时函数 B Loop ; 无限循环 ENDP Delay PROC ; 一个简单的软件延时子程序 PUSH {R4, LR} ; 保存R4和链接寄存器LR LDR R4, =0x000FFFFF ; 延时计数值,根据CPU频率调整 Delay_Loop SUBS R4, R4, #1 ; 计数值减1 BNE Delay_Loop ; 如果不为0,继续循环 POP {R4, PC} ; 恢复R4,并通过PC(LR)返回 ENDP ALIGN ; 对齐到字边界 END ; 文件结束这段代码做了以下几件事:
- 定义向量表:在
RESET区域,首先放置了初始栈顶地址(我们设为RAM末尾0x20005000,因为STM32F103C8的RAM是20KB,即0x5000字节,起始于0x20000000),然后是复位向量Reset_Handler的地址。 - 复位处理程序:
Reset_Handler过程非常简单,只是加载__main的地址并跳转过去。在实际复杂的启动文件中,这里会进行复制.data段(初始化全局变量)、清零.bss段(未初始化全局变量)、初始化系统时钟、设置VTOR等一系列操作。我们这里极度简化。 - 主程序:
__main过程里,我们直接操作寄存器来配置PA0并控制其输出高低电平,实现LED闪烁。注意,我们没有使能GPIOA的时钟(APB2外设时钟使能寄存器RCC_APB2ENR)。在真正的项目中,这是必须的,否则对GPIOA的配置不会生效。这里为了聚焦汇编流程,我们假设时钟已经默认开启(实际上复位后大部分外设时钟是关闭的)。这是一个重要的“坑”,我们后面会讲。 - 延时函数:一个用循环实现的简单延时。
2.3 配置目标选项与生成Hex文件
代码写好了,但直接编译链接肯定会出错,因为内存布局还没告诉链接器。
右键点击Target 1,选择Options for Target 'Target 1'...,打开配置对话框。
Target 标签页:
Xtal (MHz):改成8.0(我们使用内部8MHz RC振荡器)。Use MicroLIB:勾选上。对于纯汇编小工程,MicroLIB这个精简C库几乎不增加开销,而且它提供了__main的初始化代码(负责调用我们写的__main吗?不完全是,关系微妙,见下文分析)。如果不勾选,链接时可能会报错找不到__main相关的库函数。
Output 标签页:
- 确保
Create HEX File被勾选。这就是我们最终要分析的文件。
- 确保
Linker 标签页:
- 这是最核心的配置。取消
Use Memory Layout from Target Dialog的勾选。 - 点击
Edit...按钮,会打开一个文本编辑器,让我们编辑分散加载文件(scatter file,.sct)。 - 将内容替换为以下内容:
LR_IROM1 0x08000000 0x00010000 { ; 加载区域,起始地址0x08000000,大小64KB ER_IROM1 0x08000000 0x00010000 { ; 执行区域,地址同加载区域 *.o (RESET, +First) ; 首先放置RESET区域的内容(即我们的向量表) * (+RO) ; 然后放置所有只读(代码和常量)内容 } RW_IRAM1 0x20000000 0x00005000 { ; 读写区域(RAM),起始0x20000000,大小20KB * (+RW +ZI) ; 放置所有读写数据和零初始化数据 } }
这个文件明确告诉链接器:把代码(RO)放到Flash(0x08000000开始),把变量(RW+ZI)放到RAM(0x20000000开始)。并且特别指定了
RESET区域要放在最前面,这保证了向量表位于Flash开头。- 这是最核心的配置。取消
保存所有配置,点击Build(F7) 按钮。如果一切顺利,你会在Objects文件夹下找到STM32_ASM_LED.hex文件。
注意:你可能会遇到一个警告
Warning: L6314W: No section matches pattern *(RESET, +First).。这是因为我们的分散加载文件指定了*.o (RESET, +First),但链接器在处理时,RESET区域可能被放在了其他段名里。一个更稳妥的写法是使用* (RESET)或者直接指定*.o (RESET)。不过只要生成的hex文件正确,这个警告有时可以暂时忽略。你也可以尝试将main.s中AREA RESET, DATA, READONLY改为AREA |.vectors|, DATA, READONLY,并在scatter文件中对应修改,这样可以消除警告。
3. Hex文件剖析:映像的“基因序列”
生成了hex文件,它看起来是一堆十六进制数字。我们用文本编辑器打开它,它的内容格式是Intel HEX格式。理解这个格式,你就拿到了程序映像的“地图”。
3.1 Intel HEX格式详解
Hex文件不是二进制机器码的直接拷贝,而是一种带有地址信息的文本记录格式。每一条记录(一行)的结构如下::llaaaatt[dd...]cc
:: 每行起始标志。ll: 本行数据字节数(16进制),表示dd部分有多少个字节的数据。aaaa: 本行数据的起始地址(16进制)。注意:这个地址是“偏移地址”,需要结合记录类型来解读。tt: 记录类型。00:数据记录。这是最常见的,aaaa就是数据要加载到的绝对地址(对于STM32 Flash,通常是0x08000000开始的偏移)。01:文件结束记录。标志Hex文件结束。02:扩展段地址记录。当地址超过16位(64KB)时,用此记录提供高16位段地址。aaaa为0000,数据字段包含段地址。后续数据记录的地址是偏移地址,需要加上这个段地址左移4位(乘以16)才是绝对地址。04:扩展线性地址记录。这是ARM芯片最常用的。当绝对地址超过16位时,用此记录提供高16位地址。aaaa为0000,数据字段包含高16位地址。后续数据记录的地址是偏移地址,需要加上这个高16位地址左移16位才是绝对地址。
[dd...]: 实际的数据字节,长度为ll。cc: 校验和。计算方式为:从ll到最后一个数据字节的所有字节值求和,取结果的低8位,然后计算其二进制补码(即0x100 - (sum & 0xFF))。
3.2 对照我们的工程分析Hex
让我们看一个生成的hex文件片段(地址和数据是示例):
:020000040800F2 :1000000000005000200100000809000008000000AC :1000100000000000000000000000000000000000E0 :1000200000000000000000000000000000000000D0 ... :080030004840B508F0BDE870 :00000001FF第一行
:020000040800F2:02:2个数据字节。0000:地址为0。04:扩展线性地址记录。0800:数据,表示高16位地址是0x0800。F2:校验和 (0x100 - (0x02 + 0x00 + 0x00 + 0x04 + 0x08 + 0x00) & 0xFF = 0xF2)。- 解读:这一行设定了后续所有数据记录的基地址高16位为
0x0800。左移16位后得到0x0800 0000,这正是STM32 Flash的起始地址。
第二行
:1000000000005000200100000809000008000000AC:10:16个数据字节(即8个字,32位ARM的一个字是4字节)。0000:偏移地址0x0000。00:数据记录。00005000 20010000 08090000 08000000:这是前4个字(16字节)的数据。AC:校验和。- 解读:结合第一行的基地址
0x08000000和本行偏移0x0000,这16个字节将被烧录到Flash的0x08000000地址处。对照我们的汇编代码:0x08000000:0x00005000-> 这是我们向量表的第一个条目:初始栈顶指针0x20005000。注意,ARM是小端模式,存储在Flash里是00 50 00 20,但我们写的是DCD 0x20005000,所以看到的是00005000。0x08000004:0x20010000-> 这是向量表的第二个条目:复位向量地址。Reset_Handler的地址。链接器计算出了Reset_Handler的绝对地址是0x08000100(示例),所以这里存储的是00 01 00 08(小端)。- 后续的
0x08000008和0x0800000C目前是0,因为我们没有定义更多中断向量。
后续数据行:它们会按照地址递增,填充我们编写的代码(
.text段)和只读数据。倒数第二行
:080030004840B508F0BDE870:- 这可能是我们主程序或延时函数的一部分机器码。例如,
4840可能对应LDR R0, =0x40010800的指令编码。
- 这可能是我们主程序或延时函数的一部分机器码。例如,
最后一行
:00000001FF:00:0个数据字节。0000:地址0。01:文件结束记录。FF:校验和 (0x100 - (0x01) & 0xFF = 0xFF)。
通过分析Hex,你可以验证:
- 向量表是否确实放在了
0x08000000。 - 复位向量的地址是否正确指向了你的
Reset_Handler。 - 代码段是否紧随其后。
- 整个映像的大小。
3.3 从Hex反推汇编:一个实用技巧
有时候,你想知道某段内存里的机器码到底是什么指令。你可以利用Hex文件结合反汇编工具。在Keil中,编译链接后,会生成一个.axf或.elf文件(包含完整的调试信息)。你可以使用fromelf工具来生成反汇编列表。
在Keil的User标签页(Options for Target里),可以添加构建后命令。例如:fromelf --text -c -d --output=list.txt Objects\STM32_ASM_LED.axf
这条命令会从.axf文件生成一个list.txt文本文件,里面包含了地址、机器码和对应的汇编指令的对照表,比直接看Hex直观得多。这对于调试、分析编译器优化行为、或者逆向工程自己的代码都非常有帮助。
4. 链接脚本的深层作用与常见陷阱
我们之前简单配置了分散加载文件(.sct),它被称为链接脚本。它的作用远不止指定内存区域。
4.1 链接脚本如何决定程序布局
链接器的工作是解决“符号重定位”。在main.s中,我们写了LDR R0, =__main,这里的__main是一个符号,它的值(地址)在汇编阶段是未知的。汇编器生成目标文件(.o)时,会在这个地方留下一个“重定位条目”。
链接器读取所有.o文件和库文件,并依据链接脚本的指示:
- 收集所有输入段(如我们的
RESET,.text,.data,.bss)。 - 将它们归类到输出段(如
ER_IROM1,RW_IRAM1)。 - 在输出段内,按照脚本描述的规则(如
+First,+RO)排列这些输入段。 - 为每个符号(如
__main,Reset_Handler,Delay)计算最终的绝对地址。 - 用计算出的地址去填充所有重定位条目。
我们的脚本*.o (RESET, +First)确保了向量表在Flash最前端。* (+RO)则收集了所有只读内容(主要是代码)紧随其后。
4.2 未初始化数据段(.bss)与零初始化
在我们的简单工程里,没有定义任何全局变量。如果我们在汇编中定义了一个未初始化的变量,比如:
AREA MyBss, DATA, READWRITE my_var SPACE 4 ; 预留4字节空间这个my_var会被归类到.bss段(或类似段)。在链接脚本中,* (+ZI)就是用来放置这些零初始化数据的。关键点在于:这些变量在Hex文件里不占空间!Hex文件只包含需要被烧录到Flash的数据(即初始化数据)。.bss段的内容全是0,烧录进去没有意义。
那么芯片上电后,.bss段是如何被清零的呢?这通常是启动代码(__main或__scatterload)的责任。在更完整的启动流程中,在跳转到用户的main函数之前,会有一段代码循环将.bss段的起始和结束地址之间的内存全部写0。我们的极度简化版启动代码省略了这一步,所以如果使用了未初始化全局变量,其值将是随机的(RAM上电后的残留值)。
4.3 初始化数据段(.data)的搬运
同理,如果定义了已初始化的全局变量,如:
AREA MyData, DATA, READWRITE init_var DCD 0x12345678这个init_var的初始值0x12345678需要存储在Flash里(因为它是一个确定的初始值),但变量本身在运行时位于RAM。链接器会把它放在.data段。在启动时,同样需要一段代码将Flash中.data段的初始值拷贝到RAM中对应的地址。这个过程称为“数据搬运”(Data Copy)。我们的简化启动代码同样省略了这一步。
这就是为什么我们的工程能“跑”,但不是一个“健壮”工程的原因。它依赖于没有使用全局变量,并且外设时钟可能处于未知状态。MicroLIB提供的__main初始化序列可能会处理一些基础的复制和清零,但对于纯汇编工程,它的行为可能不完整或不符合预期。
5. 调试与排错:当LED没有闪烁时
按照上述步骤,把生成的hex文件用ST-Link Utility或STM32CubeProgrammer烧录到芯片后,你可能会发现LED并没有闪烁。别急,这才是学习的开始。我们一起来排查。
5.1 排查链路的建立:从硬件到软件
- 硬件连接:确认LED电路是否正确。是低电平点亮还是高电平点亮?GPIO口是PA0吗?限流电阻是否合适?用万用表测量一下PA0在程序运行时的电压是否有变化。
- 时钟问题:这是最可能的原因。在STM32中,任何外设(包括GPIO)在使用前,必须使能其对应的时钟。复位后,除了少数系统时钟,大部分外设时钟是关闭的。
- 解决方案:在
Reset_Handler中,或__main的一开始,添加使能GPIOA时钟的代码。需要操作RCC_APB2ENR寄存器(地址0x40021018)的第2位(IOPAEN)。
; 使能GPIOA时钟 LDR R0, =0x40021018 ; RCC_APB2ENR 寄存器地址 LDR R1, [R0] ORR R1, R1, #0x00000004 ; 设置第2位为1 STR R1, [R0] ; 可能需要插入几个NOP指令等待时钟稳定,但对于GPIO通常不需要 - 解决方案:在
- 向量表地址:确认芯片启动模式是否正确(BOOT0/BOOT1引脚)。对于从主Flash启动(通常模式),CPU会从
0x08000000读取初始SP和PC。我们的Hex文件是否正确烧录到了这个地址?用烧录工具读取验证。 - 堆栈指针(SP):我们设置的初始SP是
0x20005000。确保这个地址在RAM的有效范围内(STM32F103C8 RAM: 0x20000000 ~ 0x20004FFF)。我们的设置0x20005000刚好超出了1个字(4字节),虽然对于简单程序可能不会立即出错,但这是一个隐患。更安全的做法是设为0x20004FFC(向下对齐到4字节边界)。修改向量表第一项为DCD 0x20004FFC。 - 延时时间:
Delay子程序中的循环次数0x000FFFFF对于8MHz主频来说可能太短或太长,导致闪烁频率超出人眼识别范围。可以尝试调整这个值,或者用调试器单步执行,观察GPIO输出是否按预期变化。
5.2 利用调试器进行指令级跟踪
如果以上都检查无误,最强大的工具就是调试器。将ST-Link连接到板子,在Keil中进入调试模式(Ctrl+F5)。
- 查看反汇编窗口:在调试界面,打开
Disassembly窗口。这里会显示从当前PC地址开始的反汇编代码。你应该能看到和你写的汇编高度相似的指令(可能有些指令被编译器等价替换了)。确认程序是否真的从Reset_Handler开始执行。 - 查看寄存器窗口:观察
R0-R15,特别是R13 (SP)和R15 (PC)。上电后,SP是否被正确加载为0x20004FFC?PC是否指向Reset_Handler? - 查看内存窗口:输入
0x40010800查看GPIOA的CRL寄存器,输入0x4001080C查看ODR寄存器。单步执行你的配置代码,看这些寄存器的值是否被正确写入。 - 查看外设寄存器:Keil的
Peripherals->System Viewer菜单下,可以选择GPIOA,以更友好的图形化方式查看寄存器状态,比直接看内存更方便。 - 设置断点:在
__main和Delay函数入口设置断点,看程序流是否按预期执行。
通过调试器,你可以精确地看到每一条指令执行后,芯片状态的变化,这是学习汇编和底层硬件交互无可替代的方式。
6. 从最小工程到实用化:还需要补充什么?
我们这个“最小工程”为了突出主线,省略了很多必要环节。一个实用的、可维护的汇编工程(哪怕很小)应该考虑补充以下几点:
完整的启动序列:
- 初始化堆栈指针(我们做了)。
- 复制
.data段从Flash到RAM。 - 清零
.bss段。 - 初始化系统时钟(配置PLL,将时钟升到72MHz等)。
- 如果需要,设置向量表偏移寄存器(VTOR),特别是如果使用了Bootloader或将中断向量表放在RAM中时。
- 最后跳转到
__main(对于MicroLIB,它会调用_main初始化,再调用我们的__main。对于纯汇编,我们可以直接跳转到自己的主循环入口,比如Main_Loop)。
中断服务程序(ISR):
- 在向量表中为需要的中断(如SysTick定时器中断)预留位置,填入对应ISR的地址。
- 编写ISR汇编程序。注意,在ARM Cortex-M中,中断发生时硬件会自动保存部分寄存器(R0-R3, R12, LR, PC, PSR),ISR开头需要保存其他可能被破坏的寄存器(如果需要),结尾使用
BX LR返回(LR在中断进入时被硬件特殊处理为EXC_RETURN值)。
使用EQU定义常量:提高代码可读性。例如:
GPIOA_BASE EQU 0x40010800 GPIOA_CRL EQU GPIOA_BASE + 0x00 GPIOA_ODR EQU GPIOA_BASE + 0x0C RCC_APB2ENR EQU 0x40021018 IOPAEN EQU (1 << 2)这样在代码中就可以写
LDR R0, =GPIOA_CRL。模块化:将启动代码、外设驱动、主程序分别放在不同的
.s文件中,通过EXPORT和IMPORT来管理符号。与C语言混合编程:真正的项目很少全部用汇编。更常见的场景是用汇编写关键的启动代码、中断向量表或极少数性能敏感的函数,主逻辑用C。这需要了解AAPCS(ARM架构过程调用标准),处理好函数调用时的参数传递(R0-R3)、返回值(R0)、栈对齐、寄存器保存等约定。
亲手走通一遍从汇编源码到Hex文件,再到芯片执行的完整流程,哪怕只是一个点亮LED的小程序,你对“程序如何运行”的理解也会深刻得多。下次当你用HAL库HAL_GPIO_TogglePin()时,你会知道它背后大概经历了多少层抽象,最终变成了对ODR寄存器某一位的读写操作。这份底层的掌控感,是应对复杂系统调试和性能优化的宝贵基础。