ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于CAN总线的STM32F103 HD Bootloader在线升级方案

2026/9/2 14:51:24 拓冰建站 浏览量
基于CAN总线的STM32F103 HD Bootloader在线升级方案 简介面向STM32F103系列嵌入式开发者这份CAN bootloader固件升级方案利用CAN总线实现固件在线刷写无需连接JTAG/SWD调试接口适合车载电子、工业自动化等设备的现场维护与批量升级。压缩包共359个文件以145个头文件、133个C源文件和72个汇编启动文件为主配合Keil工程文件、说明文档与可执行工具整体仅1.27MB目录结构紧凑。资源完整覆盖boot程序启动流程、CAN报文收发与解析、固件完整性校验、Flash地址擦写、程序跳转以及传输加密等关键环节能够帮助开发者理清从CAN总线接收数据到最终运行新固件的完整链路。对于正在研究STM32 CAN通信、引导加载程序或远程固件更新机制的工程师这份工程源码可作为二次移植与学习参考。目前已有423人学习下载适合具备STM32基础、希望提升设备维护效率的开发者深入研究。 手上的项目有一个很常见的“土办法”通过调试器把固件直接烧进去设备装到机箱里之后再想升级就得拆壳子。这个CANbootloader_stm32f103_HD.rar对应的就是一套基于 CAN 总线的在线升级方案目标芯片是 STM32F103 的高密度HD型号典型容量 512KB Flash、64KB RAM。简单说就是让设备在出厂前烧好一段 Bootloader之后通过 CAN 总线收发固件包远程把应用程序刷进 Flash省去拆机、插调试器的麻烦。这套东西在汽车电子、工业控制器、分布式节点升级场景里非常常见想从零手写 CAN Bootloader或者正在被“跳转 App 不运行”“固件校验失败”这类问题折磨的朋友这篇内容应该能帮上忙。1. 项目整体设计与需求拆解1.1 为什么选 CAN 总线做固件升级很多第一次接触 CAN Bootloader 的人会问用串口UART做升级不香吗香串口协议简单、调试方便、工具链也成熟。但一到了现场问题就来了设备分布在长距离总线上节点之间隔着几十米甚至上百米UART 点对点传输根本拉不动工业现场强电磁干扰环境下RS485 虽然能拉距离但抗干扰能力和错误检测机制远不如 CAN。CAN 总线是差分信号自带 CRC 校验、位仲裁、错误恢复机制物理层抗干扰能力天然就比 UART 强一个档次。再往实际应用看很多做车载电子、BMS、电机控制器、PLC 扩展模块的团队系统里本来就已经有一张 CAN 网络节点平时就跑着传感器数据和控制报文。Bootloader 只是在这张现成的网络上加一种“特殊用途”的报文类型不需要额外布线、不需要拆机维护人员用一根 USB-CAN 工具接上总线就能远程刷固件。这就是 CAN Bootloader 的核心价值复用现有总线低成本远程升级。1.2 STM32F103 HD 型号的特点与选型原因标题里的 HD 容易被人忽略但它决定了整个 Flash 分区方案。STM32F103 家族里有 LDLow-Density16~32KB、MDMedium-Density64~128KB、HDHigh-Density256~512KB之分。HD 型号的 Flash 从 0x08000000 开始最大到 0x0807FFFF512KB页大小是 2KB这是我做分区设计的重要输入。选 HD 型号做 CAN Bootloader最直白的原因是用户应用程序体积大。中低密度型号装个裸机程序还好说一旦上了 RTOS、图形界面、协议栈Flash 空间就捉襟见肘。而 HD 型号的 512KB 空间可以很从容地把 Bootloader 和 App 拆开甚至还能再塞一个备份区进去。另外 HD 型号的 RAM 也比较充裕64KBCAN 接收缓冲、分包缓存、CRC 校验计算都可以放心用内存换速度。还有一个会被忽略的点HD 型号的 Flash 是 2KB 一页。计算一下就会发现擦除一个 App 区假设 256KB需要 128 次页擦除如果协议设计得不好每擦一页就让上位机等一次整个刷写过程会拖到天荒地老。所以后续协议设计里我会优先考虑“连续擦除再连续写入”的方式而不是边收边擦。1.3 Bootloader 方案的总体架构这套 CAN Bootloader 我拆成三部分下位机 Bootloader 程序、上位机刷写工具、CAN 通信协议。下位机 Bootloader 在芯片上电时先跑判断是否需要升级如果需要就进入 CAN 接收模式配合上位机完成擦除、写入、校验如果不需要直接跳转到 App。上位机负责读取待烧录的 bin 文件、按协议分包、发送、等待应答、校验结果。而协议是两者之间的契约必须明确定义帧格式、帧类型、时序和容错机制。从整体流程来看一次完整的 CAN 升级大概是这样的上位机发送握手帧 → Bootloader 回复版本和分区信息 → 上位机发送擦除指令 → Bootloader 擦除 App 区 → 上位机逐包发送数据帧 → Bootloader 写入 Flash 并回 ACK → 上位机发送校验指令 → Bootloader 回传 CRC 或逐包校验结果 → 上位机发送跳转指令 → Bootloader 跳转 App。这里有个容易踩的坑很多人会跳过握手和版本协商直接发数据帧。早期我用这种方法做原型一旦波特率或帧格式不匹配表现就是“数据发了板子没反应”然后开始怀疑接线、怀疑 CAN 收发器实际上就是协议缺了握手这一步。后来我把握手帧设计成固定 ID 固定数据内容上位机和下位机互相确认“我们能通信”再开始刷写排错难度直接降了一个量级。2. 核心细节解析与关键设计决策2.1 CAN 报文格式与 ID 分配CAN 2.0 标准帧有 11 位 ID扩展帧有 29 位 ID。在这个 Bootloader 项目里我建议用标准帧就够了一个节点升级场景最多十几个帧类型11 位 ID 完全装得下。用扩展帧反而增加了调试复杂度部分 CAN 分析工具对扩展帧的过滤设置也更繁琐。帧类型我分成了五类每类分配一个固定 ID帧类型ID示例方向数据内容握手帧0x100上位机→Bootloader魔数 固件版本 App 起始地址版本应答帧0x101Bootloader→上位机Bootloader 版本 分区大小 页大小擦除帧0x102上位机→Bootloader起始扇区 扇区数量数据帧0x103上位机→Bootloader序列号(2字节) 偏移(2字节) 数据(4字节)控制帧0x104上位机→Bootloader命令字校验/跳转/复位应答帧0x105Bootloader→上位机状态码 当前序列号每个 CAN 数据帧最多 8 字节数据帧里实际能装有效数据的只有 4 字节传输效率不算高但是换来的是协议简单、每包独立校验。工业现场升级200KB 固件按每包 4 字节计算需要 51200 包按 500Kbps 波特率跑加上帧间隔和应答时间实测大概 2~3 分钟这个时间在可接受范围内。如果想要更快的速度可以把数据帧改成“多发一帧少回一帧 ACK”的滑动窗口模式但协议复杂度会成倍上升初期不建议。2.2 CRC 校验策略分包校验 整体校验CAN 物理层自带 CRC15 校验但那是校验“这一帧传输过程中有没有出错”不能证明“收到的数据就是上位机想发的正确数据”。所以应用层必须再做一次校验。我用的是双层校验每包数据用一个字节的简单和校验对 8 字节数据求和取低 8 位上位机收到 ACK 才发下一包全部发完后Bootloader 对整个 App 区做一次 CRC32 计算回传给上位机比对。这里有个实际教训早期版本只做了逐包和校验觉得物理层 CRC15 已经够用了。后来有一次现场升级刷写完成、校验也通过但 App 启动后行为异常最后定位到是上位机读取 bin 文件时读错了一个字节这个字节恰好没被单包校验发现和校验冲突。从那以后我坚持在收完所有数据后再做一次整体 CRC32上位机也在本地先算好 CRC32两边比对一致才允许跳转。2.3 Flash 分区规划与地址映射分区规划是整个 Bootloader 最不能出错的部分。我用的方案是把 512KB Flash 切成三块分区起始地址大小说明Bootloader0x0800000064KB (0x10000)2KB 页 × 32 页实际上 20KB 就够留余量App0x08010000384KB (0x60000)应用程序区上电默认跳到这里备份区0x0807000064KB (0x10000)可选存回滚固件或升级缓存Bootloader 本身会占用中断向量表放在 0x08000000这部分不允许被擦除。App 的起始地址是 0x08010000注意这里必须至少按扇区对齐否则擦除时会误伤 Bootloader 的数据。我自己一般习惯留到足够大Bootloader 给 32KB 都绰绰有余但分 64KB 也没毛病反正是自己的地盘自己说了算。有一个细节HD 型号的页大小是 2KB但每两页组成一个 4KB 的扇区。不同的系列如 F105/F107扇区结构还不一样写代码前务必翻参考手册的 Flash 编程章节别拿 F1 的扇区结构套用其他型号。2.4 Bootloader 跳转 App 的核心逻辑跳转是整个 Bootloader 成败的关键点。很多人写完 Bootloader发现能收数据、能写 Flash但“跳转后 App 不运行”十有八九是跳转细节没处理好。上电后 Bootloader 的流程是这样初始化时钟、CAN、串口串口用于调试。检查是否需要进入升级模式通过一个标志位判断。这个标志位可以放在备份寄存器RTC Backup Register里也可以放在 Flash 末尾的一个固定地址。不需要升级时检查 App 区的启动标志我习惯在 App 起始地址写一个固定魔数比如 0xDEADBEEF魔数有效才跳转。跳转前必须关闭全局中断、关闭已经打开的外设时钟重点是 CAN、把 SysTick 停掉然后读取 App 区首字作为 MSP读取第二个字作为 PC设置向量表偏移SCB-VTOR 0x08010000最后用函数指针跳转。下面这个是跳转的核心代码我用标准外设库写的虽然现在很多人转 HAL 了但这个逻辑是通用的#define APP_ADDR 0x08010000U #define APP_MAGIC 0xDEADBEEFU typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t app_stack *(volatile uint32_t *)APP_ADDR; uint32_t app_pc *(volatile uint32_t *)(APP_ADDR 4); pFunction jump; // 检查 App 是否已经烧录 if (*(volatile uint32_t *)(APP_ADDR) ! APP_MAGIC) { return; // 没有有效 App继续留在 Bootloader } // 关闭全局中断确保跳转前没有中断打扰 __disable_irq(); // 如果有使用 RTOS这里还要挂起所有任务裸机则简单一些 // 关闭已打开的外设时钟尤其是 CAN避免跳转后外设状态残留 RCC_APB1PeriphResetCmd(RCC_APB1Periph_CAN1, ENABLE); RCC_APB1PeriphResetCmd(RCC_APB1Periph_CAN1, DISABLE); RCC_APB2PeriphResetCmd(RCC_APB2Periph_AFIO, ENABLE); RCC_APB2PeriphResetCmd(RCC_APB2Periph_AFIO, DISABLE); // 设置向量表 SCB-VTOR APP_ADDR; // 设置主栈指针跳转到 App Reset_Handler __set_MSP(app_stack); jump (pFunction)app_pc; jump(); }这段代码里最容易忽略的是SCB-VTOR的设置顺序必须先设置向量表再设置 MSP。如果搞反了中断一旦到来CPU 会去老的向量表找中断入口跑飞是必然的。另一个坑是__disable_irq()只能关掉 Cortex-M3 内核的全局中断如果 App 用到了某些外设中断但这些外设在跳转前没有复位App 初始化这些外设时可能会读到残留状态所以我跳转前会把用过的外设统一复位一遍图个干净。3. 实操过程与核心环节实现3.1 Bootloader 端代码结构Bootloader 工程我个人喜欢用标准外设库写原因很简单代码直接操作寄存器逻辑清晰占用的 Flash 也小。用 HAL 库写 Bootloader 不是不行但 HAL 库初始化的代码量大一个简单的 CAN 初始化就能吃掉好几 KB Flash对 Bootloader 这种追求精简的场景不太划算。整个 Bootloader 工程的文件结构大致如下CAN_Bootloader/ ├── Core/ │ ├── main.c // 主流程初始化、判断升级、进入升级循环 │ ├── can_boot.c // 协议处理核心状态机、帧解析、Flash 写入 │ └── can_boot.h ├── Hardware/ │ ├── bsp_can.c // CAN 外设初始化、过滤器配置 │ └── bsp_flash.c // Flash 擦除、写入、读取封装 └── MDK-ARM/ // 工程文件主流程的状态机是协议的精髓我这样定义typedef enum { BOOT_IDLE 0, // 空闲等待握手 BOOT_HANDSHAKE, // 已握手等待擦除或数据 BOOT_ERASING, // 正在擦除 BOOT_WRITING, // 正在接收数据并写入 BOOT_VERIFY, // 校验阶段 BOOT_JUMP // 跳转 App } boot_state_t;状态机的好处是逻辑清晰遇到非法状态可以直接复位回 BOOT_IDLE不会出现“收了一堆数据帧但 Bootloader 卡在不知道哪里”的情况。用状态机还有一个附加收益可以加超时看门狗。如果上位机在某个状态停了超过 5 秒没发后续帧Bootloader 自动复位回 Bootloader 模式避免设备“半升级”挂在现场——这个设计救过我一次有次上位机软件崩溃板子停在擦除状态如果没有超时现场就得到一台只能拆机救活的设备。3.2 CAN 初始化与波特率配置CAN 波特率看起来是常规操作但细算一下还挺容易出问题。我要求 500Kbps 的波特率STM32F103 的 CAN 外设挂载在 APB1 总线上APB1 时钟默认是 36MHz如果系统时钟是 72MHz。CAN 的波特率由分频寄存器 BRP 和时间段BS1、BS2共同决定波特率 APB1 时钟 / ((1 BS1 BS2) × BRP)我常用的配置是BRP 4BS1 7BS2 6采样点 (1 7) / (1 7 6) 50%。但实际 500Kbps 总线采样点推荐 75% 左右更稳。所以调整为 BRP 4BS1 9BS2 4采样点 (1 9) / (1 9 4) ≈ 71.4%。这个采样点兼容性比较好实测在较长总线下依然稳定。写配置的时候有个前提——APB1 时钟到底是不是 36MHz取决于系统时钟配置。如果用的外部 8MHz 晶振PLL 倍频到 72MHzAPB1 预分频是 2APB1 就是 36MHz。但如果你用的是内部 RC 或者其他晶振这个数字会变。所以我在初始化代码里直接调用RCC_GetClocksFreq()动态读取 APB1 时钟再反算 BRP 和 BS 段而不是写死RCC_ClocksTypeDef clocks; RCC_GetClocksFreq(clocks); uint32_t apb1 clocks.PCLK1_Frequency; uint32_t brp 0; uint8_t bs1 0, bs2 0; // 目标波特率 500Kbps // 先选 BS19, BS24总时间段 194 14 // BRP APB1 / (500000 * 14) brp apb1 / (500000U * 14U) - 1U;这个动态计算方法比直接写死寄存器强至少换晶振、换板子的时候不用重新查参考手册算半天。3.3 Flash 擦除与写入的细节Flash 操作在 STM32F103 上有几个铁律擦写前必须解锁写完必须加锁擦除和编程过程中不能有中断发生所以要在擦除和编程前关闭中断结束后再打开擦除操作必须按页或按扇区对齐。我的擦除函数这样处理void flash_erase_pages(uint32_t start_addr, uint32_t num_pages) { FLASH_Unlock(); __disable_irq(); for (uint32_t i 0; i num_pages; i) { if (FLASH_ErasePage(start_addr i * FLASH_PAGE_SIZE) ! FLASH_COMPLETE) { // 擦除失败建议记录错误并进入错误状态 break; } } __enable_irq(); FLASH_Lock(); }写入函数同理每写一个字32 位检查一次状态位遇到 FLASH_BUSY 需要等待如果连续超时就要报错。一个让我印象深刻的坑擦除过程中如果收到了 CAN 中断由于我正在关中断CAN 外设的 RX FIFO 会溢出等擦除结束再开中断已经丢了若干帧数据。这就是为什么协议上要“先擦除再开始接收数据”而不是边收边擦。3.4 上位机的实现思路上位机这块我常用脚本语言写原型工程上一般用 C#、Python 或者 LabVIEW。热词里提到了 LabVIEW 实现 Bootloader 上位机确实 LabVIEW 做这类工具可视化程度高但跨平台和版本管理比较痛苦。我的个人偏好是快速验证用 Python python-can 库面向现场交付用 C# WinForms/WPF。上位机的核心逻辑其实就是协议的对端读取 bin 文件按 4 字节一组分包不足补 0xFF。本地计算整个 bin 的 CRC32。发送握手帧等待 Bootloader 的版本应答。发送擦除帧等待擦除完成 ACK。逐包发送数据帧每包等待 ACK超时重发最多重发 3 次。发送校验帧等待 CRC32 回传与本地比对。比对通过发送跳转帧。这里有一个非常实用的技巧上位机发送数据包时不是每发一包就死等 ACK那太慢了。我会开启一个接收线程专门收 ACK主线程按固定周期比如 5ms持续发包如果某个序列号的 ACK 在超时时间内没收到就重发这个序列号的包。这样一个简单的“流水线”就能让刷写速度快不少而且代码不算太复杂。3.5 实测刷写流程我用一个 128KB 的 App bin 实测了一下500Kbps CAN 波特率整体刷写流程耗时约 2 分 30 秒。整个过程分成四个阶段阶段耗时说明握手协商约 10ms基本都是瞬时完成擦除约 0.5s128KB / 2KB 每页 64 页擦除数据写入约 2 分钟128KB / 4 字节每包 32768 包每包间隔 3~4ms整体校验约 10msBootloader 本地 CRC32刷完跳转后App 第一次启动会花一点点时间重新初始化外设这期间波形上能看到 CAN 总线停顿一会儿属于正常现象。如果 App 启动时还要和外部设备通信建议在 App 初始化时对外发一个“启动完成”的心跳帧方便现场确认升级成功。4. 常见问题与排查技巧实录4.1 跳转后 App 不运行这是出现频率最高的问题每次有人来问我“Bootloader 跳不过去”我基本按这个顺序排查先确认 App 工程的启动地址有没有改。很多人 Bootloader 写好了App 工程忘了在 MDK 或 IAR 里把 Linker 的起始地址改成 0x08010000结果 App 还是编译到 0x08000000和 Bootloader 冲突。跳转过去自然跑飞。这个错误在 HEX 文件里一眼就能看出来——打开 App 的 hex第一条记录地址如果还是 0x08000000那就没改对。再看SCB-VTOR是否设置在跳转前完成。HAL 库工程里SystemInit 函数会在 main 之前调用它会重置 VTOR 到默认值 0x08000000所以如果你在 Bootloader 跳转前设置了 VTOR但 App 的 SystemInit 又把它重置了App 的中断向量就会错位。解决办法是 App 在 main 开头重新设置SCB-VTOR 0x08010000或者在 SystemInit 里修改。最后检查中断向量表是否真的被映射到 App 区。可以在 App 的 main 最前面设置一个 GPIO 翻转然后用示波器看有没有波形有波形说明跳转成功后续死机就是 App 自身初始化的问题了。4.2 擦除失败或写 Flash 超时Flash 写入失败的常见嫌疑写入了非对齐地址、Flash 没有解锁、Flash 处于上锁状态、擦除期间总线上还在传数据导致的中断干扰。我的排查方式是先确认地址对齐——页擦除起始地址必须是 2KB 的整数倍半字编程地址必须是 2 字节对齐。其次是检查时钟树如果 Flash 等待周期配置有误72MHz 主频下要配 2 个等待周期Flash 操作也会异常。还有一种比较隐蔽的情况HSE 晶振启动失败导致系统跑到 HSI 上APB1 时钟不是 36MHzFlash 时序也跟着不对。当然这是整体系统问题但一旦发生表现就是“擦除偶尔失败时好时坏”。4.3 CAN 总线无应答上位机发握手帧Bootloader 没回包。先别查 Bootloader 代码先用 CAN 分析仪挂在总线上看看报文有没有到总线、Bootloader 有没有发任何数据。如果总线上什么都没有检查 CAN 收发器比如 TJA1050的供电和 STBY 引脚STBY 接高电平了芯片直接进入待机收发器根本不在总线状态——这个低级错误我犯过不止一次。如果总线能看到上位机的帧但 Bootloader 没回包多半是过滤器配置问题。F103 的 CAN 过滤器是 32 位屏蔽/列表模式配置不对会把所有帧都滤掉。我的建议是调试阶段把过滤器设成“接收所有帧然后在中断里按 ID 软件过滤”等协议稳定了再改硬件过滤排查问题会轻松很多。常见问题速查表现象优先级排查点具体动作握手无响应CAN 收发器状态检查供电和 STBY 引脚握手无响应过滤器配置先配置为接收所有帧握手无响应波特率不匹配用示波器/分析仪核对位宽擦除失败Flash 等待周期确保 FLASH_ACR 配置正确擦除失败地址对齐确认 2KB 页对齐写入不完整数据帧分包检查偏移量是否正确递增跳转后死机App 链接地址检查 App 工程 Linker 是否改为 0x08010000跳转后死机外设残留跳转前复位 CAN 和 AFIO 时钟校验不一致上位机补位值最后不足 4 字节时统一补 0xFF4.4 实测踩坑记录有一个坑我之前始终没想明白一套代码在开发板上跑得好好的拿到现场就出现“刷一半卡死”。后来用 CAN 分析仪抓包发现现场总线上有其他设备的报文ID 恰好和我的数据帧 I D冲突了。CAN 总线是仲裁机制ID 越小优先级越高我的数据帧 ID 是 0x103现场那台设备的某个报文 ID 是 0x100它一直在发周期报文导致我的数据帧经常抢不到总线。解决办法很简单给 Bootloader 报文分配一个比其他业务报文更低优先级ID 数值更大的 ID 段或者让上位机在刷写前通知其他节点暂停发送。另一个坑是 STM32F103 的 CAN 接收 FIFO 溢出。F103 的 CAN1 只有 3 个邮箱接收 FIFO如果上位机发得飞快Bootloader 中断里又在做 Flash 写入写入期间中断被关了FIFO 就会溢出。早期我用边收边写的方式结果溢出丢包率高达 5%。后来改成“FIFO 满了先回发送端暂停”的流控问题才缓解。前面提到的流水线重发机制也是针对这个问题的兜底方案。5. 关于升级安全和扩展方向5.1 让 Bootloader 更稳的几个改进基础版跑通之后往下走可以考虑这几点一是双分区A/B 分区备份升级Bootloader 总是先刷进备份区校验无误再切换激活分区A/B 分区方案最怕“升级到一半断电”的场景备份分区能保证至少有一个可用的固件。二是加密传输CAN 总线上抓包就能拿到你的固件如果对知识产权有要求可以在上位机做 AES-128 加密Bootloader 端解密再写入。三是增加版本回滚能力在 Flash 里存一个“上一次可用的固件版本号”新版固件启动后一定时间内上报健康状态超时未上报就回滚旧版——这个机制在远程升级大量车载节点时非常有用。5.2 在现有 Bootloader 上扩展其他总线支持CAN Bootloader 做出来后接其他传输通道就只是换一层“传输接口”的事。我把协议层和物理层做了分离底层是一组send_frame()和receive_frame()接口上层跑同一个状态机。后来项目里需要支持 RS485 升级我把 CAN 底层的收发函数换成 UART 的收发协议层几乎没动逻辑就复用了。这个设计让我在后来的 LIN 总线升级、以太网升级项目中省了至少两周的开发时间建议你在动手写 Bootloader 时就留好这个抽象层别把协议逻辑和 CAN 寄存器操作混在一堆。5.3 一些个人体会做了好几版 Bootloader我的体会是Bootloader 是典型的“看起来简单、实际上全是细节”的活。协议设计阶段多想一步后面能少排好几个通宵的错。尤其是握手、状态机、超时机制这三样一定不能图省事跳过去。很多项目出问题不是因为代码复杂而是因为没做握手就开始发数据没有状态就乱收帧没有超时就死等。另外如果你准备把这个方案用到正式产品里务必给 Bootloader 的版本号留个位置每次修改 Bootloader 后更新版本号并把它通过 CAN 报文上报给上位机。别小看这个版本号当现场有几百台设备其中十几台 Bootloader 版本不一致时这个字段能救你命。我在一个实际项目中就遇到过“同一批设备两种 Bootloader 版本”上位机协议不兼容一台一台拆机刷 Bootloader 升级那种痛苦经历过一次就不想再有第二次了。CAN Bootloader 说难不难说简单也不至于。把握手、分包、CRC、跳转这几件事做扎实一台原本需要拆机烧录的设备就能变成通过 CAN 总线远程升级的“云设备”。希望这篇内容能帮你少走点弯路把更多时间花在业务功能的迭代上而不是和 Flash 扇区、中断向量表死磕。本文还有配套的精品资源点击获取