如果你已经能在 Keil 里用 C 语言点灯那我建议你找个下午把手里的工程放到一边用纯汇编再写一遍。这个建议听起来有点“复古”但我在教新人做 RTOS 移植、调启动代码、查 HardFault 异常时发现很多人对“上电之后程序到底从哪一行开始执行”这件事其实并不清楚。这次我以 STM32F103C8T6 最小系统板为例完整走一遍从 Keil MDK 环境搭建到新建空工程再到手写向量表和启动代码最后编译、烧录、用调试器观察寄存器变化。整个过程不依赖 ST 标准库、不依赖任何 HAL 代码只用一个汇编源文件把板载 LED 点亮。不管是刚入门单片机还是想彻底搞懂启动流程的开发者这篇内容应该都能帮上忙。1. 写汇编之前先把这几件事弄清楚1.1 为什么我都用 C 点灯了还要学汇编很多人会觉得汇编离现在的嵌入式开发太远了平时写业务逻辑、调外设驱动C 语言甚至 C 都够用。但我个人强烈建议至少在入门阶段亲手写一遍汇编程序。原因很简单C 语言的每一行最后都会被翻译成汇编指令汇编指令再被翻译成机器码。你对汇编越熟悉看 C 代码时就越能看穿底层。举几个实际场景。第一是启动文件无论你用 STM32CubeMX 还是 Keil 新建工程都会自动带一个 startup 文件里面全是汇编。这个文件负责设置栈指针、建立向量表、调用 SystemInit最后进入 C 的 main 函数。不理解汇编就很难理解程序是怎么“跑起来”的。第二是 RTOS 移植像 FreeRTOS 里的 PendSV_Handler、SysTick_Handler、SVC_Handler底层往往是汇编宏或者内嵌汇编你抄过来没问题一旦出问题就抓瞎。第三是性能敏感或安全要求高的场景比如临界区保护、寄存器原子操作、精确延时汇编仍然是绕不开的工具。所以这篇文章不是让你以后都用汇编开发而是让你在 C 语法的“舒适区”之外亲手打开程序执行的底层黑盒子。1.2 选 Keil MDK 还是 Keil C51搜索“Keil”时最常见的是两个名字Keil MDK 和 Keil C51。这两个是不同产品线千万别搞混。Keil MDKMDK-ARM面向 ARM 内核的单片机比如 STM32、GD32、NXP 的 Cortex-M 系列。下载安装包的时候官网叫 MDK-ARM打开之后的 IDE 是 μVision。Keil C51面向 8051 内核的单片机比如经典的 STC89C52、AT89S52。它同样基于 μVision 界面但编译器和调试组件完全不同。本文的例子是基于 ARM Cortex-M3 内核的 STM32F103C8T6所以必须用 Keil MDK。如果你手里只有 8051 开发板思路完全一样区别只在于指令集和寄存器地址新建工程、汇编源文件、编译调试这些操作流程是共通的。另外要说一下 Arm Compiler 的问题。Keil MDK 5.36 之前的版本默认带的是 Arm Compiler 5AC5汇编器和 ARM 开发文档里传统的 armasm 语法一致。新版本默认慢慢转向 Arm Compiler 6AC6AC6 的集成汇编器更接近 GNU 风格。为了避免一开始就被语法问题劝退本文示例使用 AC5 的 armasm 语法这也是绝大多数网上教程和 Keil 官方启动文件使用的风格。后面我会专门讲怎么在工程里切到 AC5。1.3 一套最省心的硬件与资料准备做这个实验我需要的东西并不多一块 STM32F103C8T6 最小系统板板载 LED 通常接在 PC13 或 PA1不同板子有差异。我这边用的是最常用的蓝色开发板LED 在 PC13。一个 ST-Link 或 J-Link 调试器用来下载和调试。没有独立调试器的话很多开发板也自带板载 ST-LinkUSB 线连上电脑就行。Keil MDK 5.x 环境版本不要太老我用的是 MDK 5.36 左右整个过程没有遇到版本导致的问题。资料方面核心是两本ST 官方的《STM32F103xxx 参考手册》编号 RM0008以及 ARM 公司的《Cortex-M3 权威指南》。前者用来查外设寄存器地址和位定义后者用来理解向量表、栈、异常机制这些内核概念。我说实话网上很多教程直接给了寄存器代码但不告诉你寄存器为什么是这个地址、为什么这个位要置 1补上这两份资料后很多疑问都会迎刃而解。2. 环境搭建与新建汇编工程2.1 安装 Keil MDK 并补上 STM32F1 器件包安装 Keil MDK 本身不复杂官网下载安装包后一路 Next 就行。但有两个细节必须注意第一安装路径不要带中文。Keil 官方没有把中文路径完全测透后续编译、链接、烧录时可能出现奇怪问题尤其是 fromelf 工具生成 hex 文件时我曾遇到过中文路径导致找不到输出文件的情况。第二装完后要打开 Pack Installer 安装对应芯片的器件支持包。Pack Installer 是 Keil 管理芯片型号、外设描述文件、Flash 算法的地方。对于 STM32F103C8 这颗芯片需要安装的是 Keil::STM32F1xx_DFP 这个器件包。如果你新建工程时器件列表里找不到 STM32F103C8十有八九就是 DFP 没装好。安装 DFP 的常见问题是网络慢或者 Pack Installer 提示索引异常。遇到这种情况我建议直接去官网 Packs 页面下载对应的 .pack 文件然后双击安装这比在软件里等它慢慢刷新更稳。还有一点容易被忽略如果系统提示某些组件安装失败可以试试右键以管理员身份运行 Keil μVision。我碰到过一次“硬件错误”类提示最后发现是安装目录权限不够Pack 文件根本没写进去。2.2 从零新建一个 μVision 工程新建汇编工程的核心流程和新建 C 工程差不多最关键的差别在于“不要自动添加启动文件”。打开 μVision菜单栏选Project - New μVision Project输入工程名选择保存路径。在弹出的Select Device窗口里依次展开STMicroelectronics - STM32F1 Series - STM32F103C8选中它点 OK。这时候 Keil 会弹出一个对话框问你是否要把启动文件startup_stm32f103xb.s添加到工程里。这里一定选否。因为我们自己写启动文件和向量表如果再自动加一份链接时会出现重复符号反而报错。在左侧 Project 窗口里右键Source Group 1选择Add New Item to Group在弹窗里选Asm File (.s)文件名我建议叫main.s。如果需要拆成多个汇编文件可以继续添加。我在这篇博文里为了减少新人踩坑的概率只用一个源文件向量表、启动代码、主循环都在里面。完成之后整个工程结构非常干净一个目标芯片、一个 Source Group、一个 main.s 文件。这个结构比自动生成的 C 工程更清楚也更适合用来理解编译链接过程。2.3 工程选项里几个必须改的开关写代码之前先把工程的几个关键选项设置好否则编译、烧录时会各种碰壁。快捷键是AltF7打开 Options for Target 窗口。Target 选项卡在ARM Compiler下拉框里如果有Use default compiler version 5.06之类的选项选它。如果你看到的只有 AC6那你的汇编代码必须写成 GNU 风格本文示例是 armasm 风格所以需要使用 AC5。Output 选项卡勾选Create HEX File。如果不勾工程能编译出 AXF 调试文件但不会生成给烧录器用的 hex 文件。Debug 选项卡右边选Use再在下拉框里选择ST-Link Debugger或CMSIS-DAP Debugger。如果用的是 GD-Link、DAP-Link 这类调试器通常选CMSIS-DAP Debugger。Utilities 选项卡点击Settings在Flash Download里点击 Add添加STM32F10x Med-density 128K Flash烧录算法并把Reset and Run勾上。这一步不做后面烧录大概率会报 Flash Download failed。这些设置为什么重要Create HEX File 决定最终产物调试器选择决定烧录工具Flash 算法决定写入时使用什么方式擦除和编程。任何一环缺失结果都会反映在报错信息里。所以第一次做的时候不要嫌烦一次性配好后面能省很多时间。3. 汇编程序核心代码逐段拆解3.1 向量表程序真正的入口很多人以为程序的入口是 main 函数。严格来说对于 Cortex-M 处理器程序真正入口是向量表。处理器复位后会从 Flash 起始地址读取第一个 32 位数据作为主栈指针MSP读取第二个 32 位数据作为复位处理函数Reset_Handler的地址然后跳过去执行。向量表就是一张“中断处理函数地址表”每个异常或中断占用一个 32 位字。我写的向量表如下AREA RESET, DATA, READONLY __Vectors DCD 0x20005000 ; 初始栈指针指向 SRAM 顶部 DCD Reset_Handler ; 复位异常入口 DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler DCD 0 DCD 0 DCD 0 DCD 0 DCD SVC_Handler DCD DebugMon_Handler DCD 0 DCD PendSV_Handler DCD SysTick_Handler第一项DCD 0x20005000这个值不是随便写的。STM32F103C8T6 有 20KB SRAM地址范围是 0x20000000 到 0x20004FFF所以初始化栈指针指向 0x20005000也就是 SRAM 顶端的下一个地址。Cortex-M 的栈是满递减栈压栈时先减地址再存数据初始指针设在最高地址1 是标准做法。从第三项开始是各种异常入口。这里我只有 Reset_Handler 是真正要实现的核心逻辑其他的异常处理函数都写成死循环。如果程序跑飞、越界、触发 HardFault就会陷入死循环方便调试时发现问题。向量表里那些 0 是保留项不要乱填。你可能会想只保留 Reset 和 HardFault 行不行可以但完整的向量表看着更规范而且后面学中断时也会用到。3.2 Reset_Handler上电后我做了什么复位之后处理器跳转进入 Reset_Handler。我们的程序要做的事情很直白把系统时钟切到 72MHz打开 GPIOC 外设时钟配置 PC13 为推挽输出然后主循环里不断翻转 PC13 电平让 LED 闪烁。AREA |.text|, CODE, READONLY Reset_Handler ; 1. 设置 FLASH 等待周期为 2因为 72MHz 下必须 2 LDR R0, 0x40022000 ; FLASH_ACR MOV R1, #0x00000012 ; PRFTBE1, LATENCY2 STR R1, [R0] ; 2. 开启 HSE 外部晶振 LDR R0, 0x40021000 ; RCC_CR LDR R1, [R0] ORR R1, R1, #0x00010000 ; HSEON 1 STR R1, [R0] Wait_HSE LDR R1, [R0] TST R1, #0x00020000 ; 检查 HSERDY BEQ Wait_HSE ; 3. 配置 PLLHSE 作为输入9 倍频APB1 分频 2 LDR R0, 0x40021004 ; RCC_CFGR LDR R1, 0x001D0400 STR R1, [R0] ; 4. 开启 PLL 并等待就绪 LDR R0, 0x40021000 ; RCC_CR LDR R1, [R0] ORR R1, R1, #0x01000000 ; PLLON 1 STR R1, [R0] Wait_PLL LDR R1, [R0] TST R1, #0x02000000 ; 检查 PLLRDY BEQ Wait_PLL ; 5. 切换系统时钟到 PLL LDR R0, 0x40021004 ; RCC_CFGR LDR R1, [R0] ORR R1, R1, #0x00000002 ; SW 10选择 PLL STR R1, [R0] Wait_SWS LDR R1, [R0] TST R1, #0x00000008 ; 检查 SWS 是否等于 10 BEQ Wait_SWS ; 6. 使能 GPIOC 时钟 LDR R0, 0x40021018 ; RCC_APB2ENR LDR R1, [R0] ORR R1, R1, #0x00000010 ; IOPCEN 1 STR R1, [R0] ; 7. 配置 PC13 为推挽输出50MHz LDR R0, 0x40011004 ; GPIOC_CRH LDR R1, [R0] BIC R1, R1, #0x00F00000 ; 清除 PC13 相关位 ORR R1, R1, #0x00200000 ; MODE1310, CNF1300 STR R1, [R0] Main_Loop LDR R0, 0x4001100C ; GPIOC_ODR LDR R1, [R0] EOR R1, R1, #0x00002000 ; 翻转 bit13 STR R1, [R0] BL Delay B Main_Loop Delay LDR R2, 0x001F0000 Delay_Loop SUBS R2, R2, #1 BNE Delay_Loop BX LR END这段代码有几个值得展开的点。第一步为什么要先设置 Flash 等待周期因为系统时钟提到 72MHz 之后Flash 读取速度可能跟不上 CPU这时必须增加等待周期。这是 STM32F1 在超频到高主频时必须做的事。很多人直接把别人的时钟初始化代码抄过来少了这一步程序偶尔跑飞或者 HardFault就是这个原因。第二步到第五步是标准 PLL 配置顺序先开启 HSE等 HSE 稳定再配置 PLL 的分频倍频开启 PLL等 PLL 稳定最后把系统时钟源切换到 PLL。顺序不能乱尤其不能在 HSE 还没就绪时就开启 PLL。寄存器操作上我用的是“读-改-写”方式先 LDR 读出当前值再 ORR 或 BIC 修改对应位最后 STR 写回。这样做能避免影响同一个寄存器里其他已经配置好的位。还有一类写法是直接给寄存器赋一个完整的新值虽然也能跑但维护起来容易踩坑。第六步和第七步操作的是外设时钟和外设寄存器。STM32 的外设复位后时钟默认是关闭的所以要先打开 GPIOC 的时钟配置 GPIO 模式时PC13 属于高寄存器 GPIOC_CRH 的 bit[23:20]我要把 MDOE 和 CNF 位清掉然后设置成推挽输出 50MHz。这些位定义的细节参考手册里都有初学阶段建议每个引脚都去查一遍比直接抄代码理解更深。3.3 把代码写进 Keil需要注意的语法细节以上代码在 Keil 的 AC5 环境中可以直接编译但有几个语法细节容易绊倒新人标签顶格写指令前面用 Tab 或空格缩进。汇编器对空格不敏感但这个书写习惯能让你一眼分清“标签”和“指令”。立即数前面要加#。比如MOV R1, #2这里的 # 不能省略。注释用分号;不能用 C 语言的//。ARM 指令不区分大小写但为了可读性我习惯指令用大写、标签用驼峰。文件末尾必须有END。如果缺少 END汇编器会报“意料之外的符号”或“文件未结束”之类的错误。如果想让一个符号可以被其他文件引用必须使用EXPORT反过来使用外部符号时必须IMPORT。本文代码都在同一个文件里所以只需要对Reset_Handler等被向量表引用的符号进行EXPORT。如果你用的 Keil 只有 AC6那上面的 armasm 语法会报一堆错。这时候有两个方案一个是像我前面说的切换到 AC5 编译器另一个是改用 GNU 汇编语法比如用.syntax unified、.cpu cortex-m3、.thumb、.section .text这类伪指令标签后面加冒号注释用或//。两种风格都能写汇编但混着用一定会出问题所以在动手前先确认自己当前处于哪个编译器环境。最简单的判断方法新建工程后看 Keil 自动生成的启动文件如果文件里是.syntax unified说明你用的是 AC6如果是AREA RESET说明是 AC5。4. 编译链接与常见报错排查4.1 汇编器、链接器在后台干了什么我以前用 Keil 的时候只知道点 Build 然后看有没有 error并不清楚背后发生了什么。直到有一次需要排查“Why vector table 没放到 Flash 开头”才认真研究了一下工具链的流水线。Keil 的构建过程大致分为三步汇编器汇编把.s汇编源文件转换成目标文件.o。这一步会把每一条汇编指令翻译成对应的机器码同时生成一个符号表记录标签的地址。比如Reset_Handler在这个阶段已经有了一个“未定地址”只是还不能直接填到向量表里。链接器链接把汇编器生成的目标文件以及可能存在的库文件组合成最终的映像文件。链接器要干的事很多包括把不同的段分配到 Flash 和 RAM 的指定地址、解析符号引用、重定位地址等。我们向量表里的DCD Reset_Handler就是典型的“等待链接器回填地址”的条目。链接器最终生成.axf文件里面既包含烧录数据也包含调试信息。格式转换工具Keil 会调用 fromelf把.axf转换成.hex或.bin烧录文件。这就是为什么 Output 选项卡要勾选 Create HEX File不勾的话链接器生成的 axf 也能 debug但没有 hex 文件可以用来烧录。链接器的“工作原理”关键词本质上就是符号解析 段合并 地址重定位。你可以把链接器想象成一个装修项目经理施工队汇编器各自建好了墙体和家具目标文件段项目经理负责把它们按图纸放到房间合适的位置同时把所有插座符号引用接到配电箱最终地址上。任何一方出错整个房子都交付不了。4.2 编译报错速查与解决思路汇编代码在编译阶段最容易出错的点我用表格整理一下报错信息可能原因解决办法A1167E: Invalid line指令拼写错误、立即数格式不对、漏了 #、标签和指令混在一起回到报错行检查指令和标签确认立即数前有 #A1261E: Missing END directive源文件末尾缺少 END在文件最后补上ENDL6218E: Undefined symbol XXX向量表里引用了一个未定义或未 EXPORT 的符号确认符号拼写一致确认定义处有EXPORTL6220E: Region exceeded代码段大小超过了 Flash/RAM 配置的范围检查 Target 里的 IROM/IRAM 尺寸设置或精简代码Target not created编译链接过程中出现终止性错误先解决前面的 error看 Build Output 窗口顶部错误main.o: cant open file工程路径里有中文或特殊字符把工程移动到纯英文路径这里最容易被忽略的是EXPORT/IMPORT。在单个汇编文件内部标签当然可以直接使用但如果把向量表放在一个文件、Reset_Handler 放在另一个文件就必须在 Reset_Handler 的定义文件里EXPORT Reset_Handler在向量表所在文件里IMPORT Reset_Handler。忘了任何一端链接器都会在最后阶段给你一个 Undefined symbol。Keil 的 Build Output 窗口里点错误信息会在编辑区跳到对应行所以排查时先看行号。如果错误信息特别长不必全部读懂重点看开头的错误编号和末尾的符号名一般就能定位。4.3 烧录时常见的坑编译通过只是第一步烧录阶段同样有一堆坑。我把自己踩过的和帮别人排查过的问题列一下。提示 No ULINK Device Found / No ST-Link detected首先检查调试器 USB 有没有被电脑识别然后看 Options for Target 的 Debug 选项卡里选的调试器是不是你手上那个。如果使用 ST-Link还要在 Settings 里把 Port 改成 SW不是 JTAG因为 STM32F103 最小系统板默认只引出 SWDIO 和 SWCLK。接线问题SWD 只需要 GND、SWDIO、SWCLK 三根线有条件再加一根 3.3V。如果不共地怎么连都识别不到。这个问题看起来低级但真的非常常见。Flash Download failed多半是烧录算法没添加或者选错了容量版本。STM32F103C8T6 是 64KB Flash需要在 Utilities 的 Flash Download 里添加STM32F10x Med-density 128K Flash这个算法。能烧录但程序不运行检查 Utilities - Settings - Flash Download 里有没有勾选Reset and Run。没勾的话下载完程序就停在那里必须手动按一下复位键。另外提一句如果你用的是 GD32 替代 STM32 的开发板或者用了 GD-Link 调试器在 Keil 的 Debug 下拉框里通常选CMSIS-DAP Debugger就能被识别。GD32 和 STM32F103 虽然寄存器基本兼容但个别芯片型号如果 Keil 里找不到可以临时用对应容量的 STM32F103 型号代替调试功能上大部分场景没问题。5. 用调试器验证汇编程序的执行过程5.1 单步执行看程序怎么走程序烧录进去之后别急着全速运行。进入 Keil 的 Debug 模式按CtrlF5我们会在软件里看到程序停在 Reset_Handler 的第一条指令上。这本身就是对“程序入口是向量表”这个知识点最好的验证。接着按F10单步执行观察左上角的寄存器窗口。你会看到 PC程序计数器从 Flash 里的地址一步步往下走SP栈指针初始化成 0x20005000。等到执行完 RCC 初始化的几个循环打开Peripherals - General Purpose I/O - GPIOC窗口会看到 ODR 寄存器的 bit13 在变化。按全速运行F5LED 就会闪起来。很多人在这一步最大的收获是原来点灯的整个过程并不只是“main 里一行 GPIO 翻转代码”。在此之前有 flash 初始化、时钟初始化、引脚模式配置等等。C 语言把这些细节藏在了库函数背后汇编让我们亲眼看到它们的执行顺序。5.2 从反汇编窗口看机器码Debug 模式下打开View - Disassembly Window能看到 Flash 上真正的机器码和它对应的汇编指令。比如ORR R1, R1, #0x00010000这条指令反汇编窗口里会显示它对应的机器码是类似F441 6180这样的十六进制数值。你可以把任意一条汇编指令和它的机器码对照着看理解“汇编语言和机器指令之间不是一对一直觉翻译那么简单”比如立即数编码方式、条件位等会有差异但这些细节不影响我们今天的功能。同时我会看一眼生成的.map文件这个文件在工程的 Listings 文件夹里。打开后搜索Reset_Handler能看到链接器把这段代码放到了哪个地址搜索__Vectors能看到向量表确实处于 0x08000000 附近。这个地址和我们 Target 选项卡里设置的 IROM1 起始地址是一致的。5.3 我给初学者的几条实操建议写汇编、编汇编、调汇编这一套走下来我有几个比较深的体会第一不要急着写复杂功能。点灯是嵌入式世界里的 Hello World看起来简单但它把系统时钟、GPIO、链接、调试全部串起来了。如果你一上来就想写 UART 或中断结果大概率是寄存器配置出错反而失去信心。第二注释里写上寄存器地址和位定义。过三个月再回头看代码你会非常感激当时多写的那几行注释。我现在的习惯是每一个寄存器后面都注明“寄存器名 简单作用”查手册时能省一半时间。第三拿标准启动文件对照着自己的写。Keil 新建 C 工程时自动添加的startup_stm32f103xb.s就是一份非常优秀的汇编学习教材。你可以逐行读它看看它是如何分配栈空间、如何处理向量表、如何在进入 main 之前调用 SystemInit 的。读懂了它也就读懂了绝大多数基于 ARM Cortex-M 的嵌入式工程的起点。第四把延时函数从软件循环改成 SysTick再重写一遍。软件延时虽然能用但不够精确而且阻塞 CPU。等你写完 SysTick 延迟再去理解 FreeRTOS 里的系统节拍就顺理成章了。汇编底子的价值往往在这些“再进一步”的地方体现得最明显。从环境搭建到手工点灯这篇文章里给出的每一步我都尽量还原了实际操作时会遇到的问题和解决思路。没有库函数帮我们遮遮掩掩也没有 IDE 自动生成的启动代码帮我们兜底恰恰这种“裸奔”的过程能让你对 Keil 的工程机制、汇编语言的执行方式以及 STM32 的启动链路产生真正的肌肉记忆。写一遍汇编比翻十遍理论书有用得多。