1. 项目概述:为什么要在Keil5里用汇编玩STM32?
如果你已经用C语言在STM32上点过灯、调过串口,可能会觉得汇编语言是上个时代的产物,既难写又难懂,何必自讨苦吃?我最初也是这么想的,直到有一次调试一个极其严苛的时序控制项目,C语言编译后的指令周期出现了几个时钟的抖动,导致整个外设通信失败。那一刻,我不得不翻开芯片手册,从汇编层面去理解每一条指令的执行代价,最终用几十行汇编代码解决了问题。这次经历让我意识到,对于嵌入式开发者而言,汇编不是用来写整个项目的,而是一把“手术刀”——在关键时刻,它能让你对机器拥有绝对的控制力。
基于Keil5创建STM32汇编语言工程,正是掌握这把“手术刀”的第一步。这个过程远不止是新建一个文件、写几行MOV、B那么简单。它迫使你直面芯片最底层的启动流程、内存布局和指令集架构。而最终生成的.hex文件,则是你编写的汇编指令经过编译、链接后,能被STM32内核直接“食用”的机器码大餐。分析这个.hex文件,就像在反编译一桌盛宴的食谱,你能清晰地看到你的代码被放在了Flash的哪个位置,数据又存到了RAM的何处,这对于理解程序如何真正在芯片上运行、进行深度调试和优化至关重要。
无论你是想夯实ARM Cortex-M体系结构的基础,还是为了在特定场景下进行极致优化,抑或是单纯出于对底层世界的好奇,这个从零搭建汇编工程并解析其产物的过程,都是一次极具价值的实践。接下来,我就带你走一遍这个流程,分享其中每一步的细节、坑点和我个人的实操心得。
2. 工程创建与环境配置核心解析
在Keil MDK(我们常说的Keil5)里创建汇编工程,和创建C语言工程在表面步骤上相似,但内核的配置逻辑有本质区别。C工程有标准外设库或HAL库帮你处理大量底层初始化,而汇编工程里,这一切都得你自己来。
2.1 工具链的选择与项目初始化
启动Keil5,通过Project -> New uVision Project创建新工程。第一个关键选择出现在选择设备型号时。例如,我手头有一块STM32F103C8T6,我就会在搜索框里输入并选中它。这里有一个重要细节:Keil会提示“Copy STM32 Startup Code to Project Folder?”,对于汇编工程,我们必须选择“否”。因为那个启动文件(通常是startup_stm32f103xb.s)是用汇编写的,但它是为C语言环境准备的,包含了中断向量表、堆栈初始化以及调用main函数等逻辑。我们的纯汇编工程需要自己从头构建启动流程,所以不能要它。
项目创建好后,右键点击Target 1,选择Options for Target ‘Target 1’,进入配置的核心。
Target标签页:
Xtal (MHz):这里填你板载晶振的频率,比如8MHz。这个值会影响调试器对时间计算的准确性,但对程序本身无影响。Use MicroLIB务必取消勾选!MicroLIB是Keil为C语言设计的一个简化标准库,我们的纯汇编工程用不到它,勾选反而可能引入链接错误。
Output标签页:
- 勾选
Create HEX File,这是我们最终要分析的文件。 Name of Executable:可以改成你的工程名,比如asm_blink。
- 勾选
C/C++ (AC6) 标签页:
- 这是最容易出错的地方。因为我们没有C文件,所以这个标签页的很多设置看似无关,但
Compiler control string(控制字符串)必须清理。确保里面没有--c99、-xc等指定C语言版本的标志。一个干净的项目,这里可能本身就是空的。如果有,请删除,防止链接器以C语言方式处理某些环节。
- 这是最容易出错的地方。因为我们没有C文件,所以这个标签页的很多设置看似无关,但
Asm标签页:
- 这里可以设置汇编器的特定选项。对于初学者,保持默认即可。但如果你需要生成详细的列表文件(
.lst),可以在这里添加--list选项,它会生成一个混合了源代码和机器码的文件,是学习的好材料。
- 这里可以设置汇编器的特定选项。对于初学者,保持默认即可。但如果你需要生成详细的列表文件(
Linker标签页:
Use Memory Layout from Target Dialog通常不勾选。我们需要自己定义或指定一个更精细的分散加载文件(Scatter File)。不过,对于最简单的、只使用芯片默认内存映射的项目,也可以先勾选它,让链接器使用内置的默认布局。但为了理解透彻,我建议从手动管理开始。- 取消勾选后,在
Scatter File框旁边点击Edit...,创建一个新的.sct文件。这是理解内存布局的关键。
2.2 手动创建与解析Scatter Load文件(.sct)
Scatter文件告诉链接器:把代码的哪一部分放到Flash(ROM)的哪个地址,把数据放到RAM的哪个地址。对于STM32F103C8T6,我们来看一个最基础的示例:
LR_IROM1 0x08000000 0x00010000 { ; 加载区域,起始地址0x08000000,大小64KB ER_IROM1 0x08000000 0x00010000 { ; 执行区域,地址同加载区域 *.o (RESET, +First) ; 首先放置RESET段(中断向量表) *(InRoot$$Sections) ; 放置库中的关键节(如__main初始化代码) .ANY (+RO) ; 放置所有只读内容(代码、常量) } RW_IRAM1 0x20000000 0x00005000 { ; 读写执行区域,起始0x20000000,大小20KB .ANY (+RW +ZI) ; 放置所有读写数据和零初始化数据 } }关键点解析:
LR_IROM1:定义一个加载区域(Load Region),名称随意。0x08000000是STM32 Flash的起始地址,0x00010000是64KB大小(对应C8T6的Flash容量)。ER_IROM1:在加载区域内定义一个执行区域(Execution Region)。对于Flash,加载地址和执行地址通常是相同的。*.o (RESET, +First):这是一个至关重要的指令。它要求链接器将所有目标文件(.o)中名为RESET的段(Section)放在这个执行区域的最前面(+First)。在汇编中,我们需要自己创建一个名为RESET的段,并把中断向量表(尤其是初始堆栈指针和复位向量)放在里面。这样,芯片上电后,才能从正确的位置开始执行。*(InRoot$$Sections):这是ARM编译器需要的特殊节,包含一些运行时库的初始化代码。即使在汇编工程中,链接器也可能需要它来处理一些底层事务,保留它是稳妥的做法。.ANY (+RO):放置所有其他的只读(Read-Only)内容,也就是我们的汇编代码编译后的机器指令。RW_IRAM1:定义RAM区域。0x20000000是STM32内核内置SRAM的起始地址,0x00005000是20KB大小(C8T6的RAM大小)。.ANY (+RW +ZI):放置所有读写(Read-Write)数据和零初始化(Zero-Initialized)数据。在纯汇编的简单项目中,可能没有显式初始化的数据,但链接器可能会生成一些内部使用的变量。
实操心得:很多初学者在纯汇编工程编译时遇到“
L6235E: More than one section matches selector”这类链接错误,根本原因就是Scatter文件配置不当,或者汇编源文件中的段定义与链接器的期望不匹配。务必确保你的汇编代码中,用AREA指令定义的段名(如RESET)与Scatter文件中的选择器(如(RESET))能对应上。
3. 汇编源码结构深度剖析
一个完整的、可运行的STM32汇编工程,至少需要两个核心部分:中断向量表和主程序。我们分别拆解。
3.1 构建中断向量表与启动代码
创建一个名为startup.s(或vectors.s)的文件。这是整个程序的入口。
; 文件名: startup.s ; 描述: STM32F103 汇编工程启动文件与中断向量表 PRESERVE8 ; 指示编译器保持8字节栈对齐 THUMB ; 指示使用Thumb指令集(Cortex-M必须) ; 定义栈大小,可根据需要调整,这里定义1KB Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 ; 定义一个未初始化的STACK段,8字节对齐 Stack_Mem SPACE Stack_Size ; 分配栈空间 __initial_sp ; 标号,指向栈顶(初始栈指针) ; 定义堆大小(如果不用动态内存,可设小或不用) Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 ; 定义堆段 __heap_base Heap_Mem SPACE Heap_Size __heap_limit ; --- 最关键的部分:中断向量表 --- AREA RESET, DATA, READONLY ; 定义一个只读的RESET段,必须与Scatter文件匹配 EXPORT __Vectors ; 导出向量表起始地址,供链接器使用 EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp ; 向量表第一项:初始栈指针地址(MSP) DCD Reset_Handler ; 向量表第二项:复位向量,指向复位处理程序 DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler DCD 0, 0, 0, 0 ; 保留位 DCD SVC_Handler DCD DebugMon_Handler DCD 0 ; 保留 DCD PendSV_Handler DCD SysTick_Handler ; ... 这里可以继续添加具体外设的中断向量,如USART1_IRQHandler等 __Vectors_End __Vectors_Size EQU __Vectors_End - __Vectors AREA |.text|, CODE, READONLY, ALIGN=2 ; 定义代码段 ; --- 弱定义默认中断处理程序 --- ; 当在别处没有定义这些处理程序时,这些弱符号的定义会被使用,陷入死循环。 NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP HardFault_Handler PROC EXPORT HardFault_Handler [WEAK] B . ENDP ; ... 其他默认中断处理程序定义方式类似 ; --- 复位处理程序 --- Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main ; 声明__main为外部符号(来自运行时库) IMPORT SystemInit ; 声明可选的系统初始化函数(如果需要) ; 可选:调用SystemInit初始化时钟等(需自己实现或从C库移植) ; LDR R0, =SystemInit ; BLX R0 LDR R0, =__main ; 跳转到运行时库的__main BX R0 ; __main会完成必要的运行时初始化,然后调用我们的主程序 ; 注意:对于纯汇编工程,如果我们不依赖库初始化,可以直接跳转到自己的_main ; 例如:B _main ENDP ; --- 用户主程序入口 --- ALIGN 4 ; 确保4字节对齐 _main PROC EXPORT _main ; 导出_main,作为程序入口 ; 你的主程序从这里开始 ; 例如:初始化外设,然后进入主循环 BL GPIO_Init loop BL LED_On BL Delay BL LED_Off BL Delay B loop ENDP ; --- 其他子程序,例如GPIO初始化、延时函数等 --- GPIO_Init PROC ; 使能GPIOA时钟 (APB2ENR bit2) LDR R0, =0x40021018 ; RCC_APB2ENR 地址 LDR R1, [R0] ORR R1, R1, #0x00000004 ; 设置IOPAEN位 STR R1, [R0] ; 配置PA1为推挽输出,最大速度50MHz (CRL寄存器) LDR R0, =0x40010800 ; GPIOA_CRL 地址 LDR R1, [R0] BIC R1, R1, #0x000000F0 ; 清除PA1的配置位(CNF1[1:0], MODE1[1:0]) ORR R1, R1, #0x00000030 ; 设置CNF1=00(推挽输出),MODE1=11(50MHz) STR R1, [R0] BX LR ENDP LED_On PROC LDR R0, =0x4001080C ; GPIOA_BSRR 地址 MOV R1, #0x00000002 ; 设置BS1位,使PA1输出高电平 STR R1, [R0] BX LR ENDP ; ... 其他函数 ALIGN 4 END ; 文件结束代码关键点解读:
PRESERVE8和THUMB:这是ARM汇编器的伪指令,不是CPU指令。PRESERVE8确保栈按8字节对齐,这是ARM AAPCS(过程调用标准)的要求。THUMB声明以下代码使用Thumb指令集,Cortex-M系列只支持Thumb。- 栈和堆的定义:我们通过
AREA和SPACE在内存中预留了空间。__initial_sp这个标号的值,就是栈的结束地址(因为栈是向下生长的),它被放在向量表的第一项。 AREA RESET, DATA, READONLY:这行定义了向量表所在的段。RESET这个名字必须与Scatter文件中的(RESET)选择器匹配。READONLY属性表明它将被放到Flash中。DCD指令:用于分配一个32位的字(Data Constant Double Word),并初始化一个值。向量表本质上就是一个32位地址数组。- 弱定义(
[WEAK]):EXPORT ... [WEAK]表示如果其他地方(比如你的主程序文件)定义了同名的、非弱定义的函数,链接器将优先使用那个强定义。这为我们提供了默认的中断处理(死循环),同时允许我们在需要时覆盖它。 __main的作用:这不是你写的C语言main函数。它是ARM C库中的一个初始化函数,负责初始化堆栈指针(从向量表加载)、复制初始化数据从Flash到RAM(如果有的话)、清零ZI段(BSS段),最后才跳转到你的_main或main函数。在纯汇编工程中,如果你没有使用任何需要初始化的全局变量,理论上可以绕过它,直接跳转到_main。但使用__main是最规范、兼容性最好的做法。- 地址的直接操作:在
GPIO_Init等函数中,我们直接使用外设寄存器的绝对内存地址(如0x40021018)。这是汇编编程的常态,你需要查阅《STM32参考手册》来获取这些地址。LDR(加载)、STR(存储)、ORR(或)、BIC(位清除)是操作寄存器的基本指令。
3.2 主程序与模块化编程实践
虽然可以把所有代码都写在startup.s里,但更好的做法是模块化。创建一个main.s或app.s。
; 文件名: app.s PRESERVE8 THUMB AREA |.text|, CODE, READONLY, ALIGN=2 IMPORT Delay ; 声明Delay函数在另一个文件 EXPORT _main ; 导出_main,被startup.s调用 _main PROC ; 主程序入口 BL SystemClock_Config ; 配置系统时钟(可选,但推荐) BL GPIO_Init BL USART1_Init ; 初始化串口用于调试输出 MOV R0, #'H' BL USART1_SendChar MOV R0, #'i' BL USART1_SendChar ; ... 发送更多字符 main_loop BL Read_Button CMP R0, #0 BNE button_pressed B main_loop button_pressed BL Toggle_LED B main_loop ENDP ; 简单的延时函数示例 (基于循环计数,不精确) Delay PROC EXPORT Delay PUSH {R4, LR} ; 保存寄存器 LDR R4, =1000000 ; 延时计数值,需根据时钟频率调整 delay_loop SUBS R4, R4, #1 BNE delay_loop POP {R4, PC} ; 恢复寄存器并返回 ENDP ALIGN 4 END模块化要点:
IMPORT和EXPORT:这是在不同汇编源文件之间共享函数和变量的关键。IMPORT告诉汇编器这个符号在其他文件定义,EXPORT告诉链接器这个符号可以被其他文件使用。- 函数调用约定:ARM架构使用寄存器R0-R3传递前四个参数,更多参数通过栈传递。返回值通常放在R0。在函数开头用
PUSH保存要使用的寄存器(除了R0-R3,如果函数会修改它们也需要保存),在结尾用POP恢复,这是一种良好的编程习惯。 - 子程序编写:每个功能独立的子程序(如
GPIO_Init,Delay,USART1_SendChar)都应该用PROC和ENDP包裹,并考虑好寄存器的保存与恢复。
4. 编译、链接与Hex文件生成全流程
代码写好后,在Keil的Project窗口中将startup.s和app.s(或其他你创建的源文件)添加到工程中。右键点击Target 1,选择Build Target(F7)或Rebuild。
4.1 编译过程与中间文件解读
点击编译后,Keil会依次调用:
- 汇编器(ArmAsm):将每个
.s文件编译成目标文件(.o或.obj)。这个阶段进行语法检查、指令翻译,并生成包含代码、数据段和重定位信息的.o文件。你可以查看Objects目录下的.o文件(但它们是二进制格式)。 - 链接器(ArmLink):这是核心环节。链接器根据Scatter文件(
.sct)的描述,将所有.o文件以及可能用到的库文件(如armlib.a)中的段(RESET,.text,.data等)合并,并为其分配最终的运行地址(在Flash或RAM中的绝对地址)。它还会解析所有IMPORT/EXPORT的符号,完成地址重定位(比如BL Reset_Handler指令中的Reset_Handler地址在链接时被确定)。
编译成功后,在工程目录下的Objects文件夹里,你会找到关键的输出文件:
.axf文件:这是ELF格式的可执行文件,包含完整的调试信息(符号表、地址映射等),用于调试。.hex文件:由fromelf工具从.axf转换而来,是一种Intel HEX格式的纯文本文件,只包含地址和机器码数据,用于烧录到芯片Flash。
4.2 Hex文件格式深度解析
让我们打开生成的.hex文件,它可能看起来像这样:
:020000040800F2 :1000000000002000D1000008B5000008B9000008A7 :10001000BD000008C1000008C5000008C900000840 :10002000000000000000000000000000D1000008E4 :1000300000000000D9000008BD000008C100000854 :1000400048B5041C...(更多数据行) :00000001FF这看起来像天书,但结构其实很规整。Intel HEX格式每一行称为一个“记录”(Record),格式如下::[长度][地址][类型][数据][校验和]
::每行起始标志。- 长度(1字节):表示本行数据字段的字节数。例如
10表示有16个字节的数据。 - 地址(2字节):数据要加载的起始地址的偏移量。注意,这个地址是相对于“基地址”的偏移。基地址由
04类型的记录设置。 - 类型(1字节):
00:数据记录。这是最常见的,包含实际的机器码或初始化数据。01:文件结束记录(:00000001FF)。04:扩展线性地址记录。这是关键!它设置高16位地址。例如:020000040800F2,04是类型,0800是数据,表示后续数据记录地址的高16位是0x0800。因此,下一条00类型记录的地址0x0000,实际对应的物理地址是(0x0800 << 16) | 0x0000 = 0x08000000,正是STM32 Flash的起始地址。05:开始线性地址记录(用于某些架构的入口地址,ARM Cortex-M较少用)。
- 数据(N字节):实际的机器码或数据,长度由“长度”字段指定。
- 校验和(1字节):从“长度”到“数据”最后一个字节的所有字节求和,取补码(即计算二进制和,然后取低8位,再计算其二进制补码)。校验和字节加上前面所有字节的和,低8位应为0。
结合我们的工程分析Hex文件:
- 第一行
:020000040800F2是扩展线性地址记录,将基地址设为0x0800,因此后续数据从0x08000000开始。 - 第二行
:1000000000002000D1000008...是第一个数据记录。地址是0x0000,所以物理地址是0x08000000。数据字段的前4个字节是00 00 20 00(小端格式),即0x20000000。这正是我们向量表的第一项——初始栈指针(MSP)的值。它指向了我们定义的栈顶__initial_sp(0x20000000+Stack_Size)。 - 接下来的4个字节是
D1 00 00 08(小端),即0x080000D1。这是向量表的第二项——复位向量。它指向Reset_Handler函数的地址。注意这个地址是Thumb指令地址,所以最低位是1(0x080000D0 | 1)。 - 后续的数据行依次存放了其他中断向量、以及我们编写的所有代码(
.text段)编译后的机器指令。
排查技巧:当你怀疑程序没有正确启动时,用文本编辑器或专门的Hex查看器检查
.hex文件的前8个字节(即第一个数据记录的前8个字节)。如果它们不是0x2000xxxx和0x0800xxxx这样的值,说明向量表可能没有正确链接到Flash起始位置,问题很可能出在Scatter文件或startup.s中RESET段的定义上。
5. 调试技巧与常见问题实录
即使一切编译通过,生成了Hex文件,烧录后芯片也可能没反应。这时就需要调试。
5.1 利用Keil调试器进行底层排查
- 连接调试器:确保ST-Link/V2等调试器连接正确,在
Options for Target -> Debug里选择对应的调试器和接口(SWD)。 - 加载AXF文件:点击
Debug按钮,Keil会加载.axf文件(而不是.hex),因为它包含符号信息。 - 查看反汇编窗口:在调试界面,打开
View -> Disassembly Window。这里会显示当前PC指针所在地址的反汇编指令。第一步就是检查复位后PC是否指向了Reset_Handler的地址(应该是0x080000D0或类似,且Thumb状态标志位T=1)。 - 查看内存窗口:打开
View -> Memory Window,输入0x08000000,查看Flash起始处的内容。你应该能看到与Hex文件前几行对应的数据,即正确的向量表。 - 单步执行:在
Reset_Handler处设置断点,然后复位(Reset按钮)并全速运行,看是否能停在这个断点。如果能,说明芯片已正确从Flash启动并跳转到复位处理程序。接下来可以单步(F11)跟踪汇编指令的执行,观察寄存器和内存的变化。
5.2 典型问题与解决方案速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
编译错误:L6235E: More than one section matches selector | Scatter文件中选择器匹配到了多个同名的段。 | 检查所有.s文件中的AREA定义,确保段名唯一,或使用.ANY选择器。重点检查RESET段是否只在startup.s中定义了一次。 |
链接错误:Undefined symbol __main (referred from startup.o). | 汇编工程试图调用C库的__main,但链接时没有链接相应的库。 | 在Options for Target -> Linker中,确保未勾选Use MicroLIB,并且Misc controls里没有--nostdlib这类禁止链接标准库的选项。或者,修改startup.s,不跳转到__main,直接跳转到你自己的_main。 |
| 程序烧录后无任何反应 | 1. 向量表位置错误。 2. 初始栈指针设置错误。 3. 时钟未初始化(某些芯片需要先使能时钟)。 4. 跳转指令错误(未使用Thumb跳转)。 | 1. 检查Hex文件前8字节,确认向量表在0x08000000。2. 检查 __initial_sp值是否在有效的RAM范围内。3. 在 Reset_Handler开头添加系统时钟配置代码(参考手册)。4. 确保使用 BX或BLX指令跳转时,目标地址最低位为1(Thumb状态),或使用BL指令(它自动处理状态)。 |
调试时无法命中Reset_Handler断点 | 1. 调试器连接/配置问题。 2. 芯片复位后未从主Flash启动(BOOT引脚设置)。 3. 程序在 __main初始化时卡住。 | 1. 检查调试器连接、供电、接口频率(可调低至100kHz试试)。 2. 检查开发板的BOOT0/BOOT1引脚,确保设置为从主Flash启动(通常都接地)。 3. 在 Reset_Handler中注释掉LDR R0, =__main和BX R0,改为直接B _main,看是否能进入你的主程序。 |
| 操作外设寄存器无效果 | 1. 未使能外设时钟。 2. 寄存器地址错误。 3. 操作顺序或值错误。 | 1.这是最常见的原因!务必在操作任何外设(GPIO, USART等)前,先设置RCC_xxxENR寄存器对应的时钟使能位。2. 核对《参考手册》中的寄存器绝对地址或偏移地址。 3. 使用 LDR/STR配合ORR/BIC/AND进行位操作,确保不会影响其他位。 |
个人踩坑心得:
- 关于
__main:在纯汇编的小项目中,我倾向于绕过它。直接在Reset_Handler末尾用B _main跳转。这样可以避免因C库初始化带来的任何不确定性,也让启动过程更清晰。但前提是你的程序没有需要从Flash复制到RAM的初始化数据(.data段)和需要清零的BSS段(.bss)。如果项目复杂了,还是老老实实用__main,让链接器帮你处理这些。 - 关于延时:汇编里写延时循环是最不准的,受编译器优化等级、中断影响很大。仅适用于对时间不敏感的简单演示。对于精确延时,必须使用SysTick定时器或通用定时器。
- 关于调试:当程序“跑飞”时,第一时间查看
HardFault_Handler是否被触发。你可以在HardFault_Handler里写一个死循环,并在此处设置断点。一旦中断,通过查看MSP(主栈指针)或PSP(进程栈指针),并结合内存窗口,分析栈回溯,能找到故障发生前的大致位置。虽然汇编调试更底层,但掌握了方法,定位问题反而更直接。
从创建一个干净的Keil5汇编工程,到编写严谨的启动代码和模块化程序,再到分析最终的Hex文件和在调试器中验证每一步,这个过程就像在亲手搭建一座大厦的地基。它可能不会直接用于生产所有项目,但这份对机器运行最底层的理解,会让你在遇到任何上层语言无法解决的诡异问题时,多一份从容和底气。当你下次再看到C代码编译后的反汇编,或者需要为某个关键循环节省几个时钟周期时,这段与汇编和Hex文件打交道的经历,将会是你工具箱里最锋利的武器。