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

发布时间:2026/7/30 12:00:48
Keil5 STM32汇编工程创建与Hex文件深度解析 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语言方式处理某些环节。Asm标签页这里可以设置汇编器的特定选项。对于初学者保持默认即可。但如果你需要生成详细的列表文件.lst可以在这里添加--list选项它会生成一个混合了源代码和机器码的文件是学习的好材料。Linker标签页Use Memory Layout from Target Dialog通常不勾选。我们需要自己定义或指定一个更精细的分散加载文件Scatter File。不过对于最简单的、只使用芯片默认内存映射的项目也可以先勾选它让链接器使用内置的默认布局。但为了理解透彻我建议从手动管理开始。取消勾选后在Scatter File框旁边点击Edit...创建一个新的.sct文件。这是理解内存布局的关键。2.2 手动创建与解析Scatter Load文件.sctScatter文件告诉链接器把代码的哪一部分放到FlashROM的哪个地址把数据放到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, ALIGN3 ; 定义一个未初始化的STACK段8字节对齐 Stack_Mem SPACE Stack_Size ; 分配栈空间 __initial_sp ; 标号指向栈顶初始栈指针 ; 定义堆大小如果不用动态内存可设小或不用 Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN3 ; 定义堆段 __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, ALIGN2 ; 定义代码段 ; --- 弱定义默认中断处理程序 --- ; 当在别处没有定义这些处理程序时这些弱符号的定义会被使用陷入死循环。 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 ; 设置CNF100推挽输出MODE11150MHz 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, ALIGN2 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 TargetF7或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位地址。例如:020000040800F204是类型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_sp0x20000000Stack_Size。接下来的4个字节是D1 00 00 08小端即0x080000D1。这是向量表的第二项——复位向量。它指向Reset_Handler函数的地址。注意这个地址是Thumb指令地址所以最低位是10x080000D0 | 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状态标志位T1。查看内存窗口打开View - Memory Window输入0x08000000查看Flash起始处的内容。你应该能看到与Hex文件前几行对应的数据即正确的向量表。单步执行在Reset_Handler处设置断点然后复位Reset按钮并全速运行看是否能停在这个断点。如果能说明芯片已正确从Flash启动并跳转到复位处理程序。接下来可以单步F11跟踪汇编指令的执行观察寄存器和内存的变化。5.2 典型问题与解决方案速查表问题现象可能原因排查步骤与解决方案编译错误L6235E: More than one section matches selectorScatter文件中选择器匹配到了多个同名的段。检查所有.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指令跳转时目标地址最低位为1Thumb状态或使用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文件打交道的经历将会是你工具箱里最锋利的武器。