Keil5下STM32汇编工程创建与Hex文件深度解析

📅 2026/7/30 7:56:41
Keil5下STM32汇编工程创建与Hex文件深度解析
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大小为64KBRAM为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 (MODE00b10) 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放到Flash0x08000000开始把变量RWZI放到RAM0x20000000开始。并且特别指定了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第一行:020000040800F2022个数据字节。0000地址为0。04扩展线性地址记录。0800数据表示高16位地址是0x0800。F2校验和 (0x100 - (0x02 0x00 0x00 0x04 0x08 0x00) 0xFF 0xF2)。解读这一行设定了后续所有数据记录的基地址高16位为0x0800。左移16位后得到0x0800 0000这正是STM32 Flash的起始地址。第二行:1000000000005000200100000809000008000000AC1016个数据字节即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的指令编码。最后一行:00000001FF000个数据字节。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 --outputlist.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中进入调试模式CtrlF5。查看反汇编窗口在调试界面打开Disassembly窗口。这里会显示从当前PC地址开始的反汇编代码。你应该能看到和你写的汇编高度相似的指令可能有些指令被编译器等价替换了。确认程序是否真的从Reset_Handler开始执行。查看寄存器窗口观察R0-R15特别是R13 (SP)和R15 (PC)。上电后SP是否被正确加载为0x20004FFCPC是否指向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, PSRISR开头需要保存其他可能被破坏的寄存器如果需要结尾使用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。这需要了解AAPCSARM架构过程调用标准处理好函数调用时的参数传递R0-R3、返回值R0、栈对齐、寄存器保存等约定。亲手走通一遍从汇编源码到Hex文件再到芯片执行的完整流程哪怕只是一个点亮LED的小程序你对“程序如何运行”的理解也会深刻得多。下次当你用HAL库HAL_GPIO_TogglePin()时你会知道它背后大概经历了多少层抽象最终变成了对ODR寄存器某一位的读写操作。这份底层的掌控感是应对复杂系统调试和性能优化的宝贵基础。