STM32F103内部Flash存储字库方案:串口下载与Bootloader实现

📅 发布时间:2026/8/5 1:41:28
STM32F103内部Flash存储字库方案:串口下载与Bootloader实现 1. 项目概述为什么要在Flash里存字库做嵌入式显示项目尤其是用到TFT屏、OLED屏显示中文的时候很多新手朋友都会遇到一个头疼的问题字库放哪儿直接编译进代码一个16x16的点阵中文字库动辄几百KB直接就把STM32F103那本就紧张的Flash通常是64KB或128KB给占满了程序稍微复杂点就捉襟见肘。更别提显示多种字体或者大字号了。我最早的做法是把字库放在外部的SPI Flash或者SD卡里需要显示时再去读取。这确实解决了内部Flash容量的问题但带来了新的麻烦读取速度受限于外部存储器的接口速度在需要快速刷新或滚动显示文本时容易造成卡顿而且硬件上需要多接一颗芯片增加了BOM成本和PCB面积。后来我琢磨出了一个更“优雅”的方案利用STM32F103内部Flash未使用的空间通过串口将字库文件“下载”进去让字库常驻MCU内部。这样做的好处非常明显首先读取速度极快直接从内部Flash取数据和读程序代码一样没有速度瓶颈其次不占用宝贵的RAM字库在Flash里只有需要显示的字符数据才会被加载到内存最后硬件上无需任何改动仅用已有的串口比如调试用的USART1就能完成字库的更新非常方便。这个方案的核心就是理解并操作STM32F103的内部Flash。它不是简单的“存储器”而是一块需要特定操作才能写入的区域。我们需要写一个运行在MCU上的“引导程序”Bootloader通过串口接收来自上位机如串口调试助手发送的原始字库数据然后按照Flash的编程规范将这些数据正确地写入到我们预先划分好的Flash扇区中。整个过程就像给MCU“烧录”了一个特殊的数据文件。2. 核心思路与方案设计2.1 整体架构设计整个系统涉及两端上位机PC和下位机STM32F103。它们通过串口连接协同完成字库的传输与存储。上位机端负责打开并读取标准的字库文件如.bin或.dat格式。通过串口按照约定的通信协议将字库数据分块发送给下位机。通常需要一个简单的串口工具或者自己用Python、C#等写一个小工具。下位机端STM32F103负责运行一个“字库更新模式”的程序。这个程序可以是你主程序的一个功能模式也可以是一个独立的Bootloader。监听串口解析上位机的指令和数据包。解锁、擦除指定的内部Flash扇区。将接收到的字库数据按Flash编程的要求半字/字为单位写入目标地址。提供校验机制如CRC32确保数据传输的完整性。通信协议设计 一个简单可靠的协议是关键。我常用的帧结构如下[帧头2字节如0xAA 0x55] [命令字1字节] [数据长度2字节] [数据载荷N字节] [校验和1字节简单累加和] [帧尾1字节如0x0D 0x0A]命令字可以定义0x01启动传输、0x02数据包、0x03结束传输并校验、0x04应答ACK、0x05应答NAK。2.2 Flash存储规划STM32F103的Flash从0x0800 0000开始。我们需要在主程序代码之后找一块空闲的、足够大的连续扇区来存放字库。以STM32F103C8T664KB Flash为例主程序假设用了前40KB占用地址0x0800 0000~0x0800 9FFF。Flash扇区分布该型号的Flash分为若干页每页1KB具体需查数据手册。我们可以从0x0800 A000开始使用后续的24KB空间即0x0800 A000~0x0800 FFFF来存储字库。关键点必须确保你定义的存储起始地址是扇区页的起始地址。Flash擦除操作的最小单位是一个扇区如果你从非扇区起始地址开始写必须先擦除整个扇区这可能会破坏该扇区内已有的其他数据如果你的程序跨扇区存放。字库文件处理 常见的点阵字库如HZK16 16x16点阵是一个巨大的二进制数组按照汉字机内码GB2312/GBK排序。我们需要将这个文件通过上位机工具原样发送出去。在MCU端我们将其原样写入Flash。当需要显示某个汉字时根据其机内码计算出该字模数据在Flash中的偏移地址然后直接读取。注意在规划存储地址时一定要在链接脚本如Keil中的.sct文件或STM32CubeIDE的.ld文件中预留出这块区域防止编译器将程序变量或代码分配到这块地址上导致字库被覆盖。通常的做法是在链接脚本中定义一个不用于加载程序NOLOAD的段SECTION。3. 关键技术与实现细节3.1 STM32内部Flash操作详解操作内部Flash不像操作RAM那么简单必须遵循严格的流程。主要涉及以下几个库函数以标准外设库为例解锁FLASH_UnlockFlash默认是锁定的防止误写。写入前必须先解锁。FLASH_Unlock(); // 解锁Flash操作擦除FLASH_ErasePage写入前必须先擦除目标扇区擦除后该扇区所有位变为10xFF。// 擦除从0x0800A000开始的扇区需要计算具体页号 FLASH_Status status FLASH_ErasePage(0x0800A000); if(status ! FLASH_COMPLETE) { // 擦除失败处理 }重要心得擦除操作耗时较长典型值几十ms期间必须关闭所有中断尤其是SysTick定时器中断否则可能导致系统时序异常或看门狗复位。我的做法是__disable_irq();执行擦除__enable_irq();。编程FLASH_ProgramHalfWord / FLASH_ProgramWordSTM32F1的Flash编程以半字16位为单位。即使你想写一个8位数据也必须以16位形式写入。uint32_t Address 0x0800A000; uint16_t Data 0xABCD; FLASH_Status status FLASH_ProgramHalfWord(Address, Data);为什么是半字这是由STM32F1系列Flash存储器的硬件结构决定的。其Flash是并行接口一次写入操作的最小数据宽度就是16位。试图以8位写入会导致操作失败或数据错误。上锁FLASH_Lock所有操作完成后重新上锁Flash确保安全。完整的写入流程伪代码void Write_Data_To_Flash(uint32_t addr, uint8_t *pdata, uint32_t size) { FLASH_Unlock(); __disable_irq(); // 关键步骤关闭中断 // 检查地址是否对齐到半字2字节边界STM32F1要求必须对齐 if((addr 0x01) ! 0) { // 地址不对齐错误处理 return; } // 将字节数据转换为半字数据写入 uint16_t *pSrc (uint16_t*)pdata; uint32_t half_word_cnt (size 1) / 2; // 计算需要写入的半字数 for(uint32_t i 0; i half_word_cnt; i) { FLASH_ProgramHalfWord(addr i*2, pSrc[i]); // 可选立即读回验证 if(*(volatile uint16_t*)(addr i*2) ! pSrc[i]) { // 写入验证失败 break; } } __enable_irq(); // 重新开启中断 FLASH_Lock(); }3.2 串口通信与数据分包策略串口通信的稳定性直接决定了字库下载的成败。STM32F103的USART最高波特率可以到4.5Mbps但为了兼容性和稳定性通常使用115200或921600。数据分包策略 Flash编程操作尤其是擦除比较慢且MCU的RAM有限STM32F103C8T6只有20KB我们不能一次性接收整个字库文件可能几百KB。必须采用分包接收-写入的策略。包大小定义定义一个合适的包大小如512字节或1024字节。这个大小要综合考虑串口缓冲区、RAM临时缓冲区大小以及Flash写入效率。我通常用512字节在20KB RAM的系统中比较安全。双缓冲区Ping-Pong Buffer为了不因Flash写入而阻塞串口接收可以采用双缓冲区。当缓冲区A正在接收数据时缓冲区B的数据可以写入Flash完成后交换角色。这需要配合DMA或中断高效处理串口数据。流控与超时协议中应包含每包应答。上位机发送一包后等待下位机的ACK确认信号再发送下一包。如果下位机没有在规定时间内如100ms收到完整的一包数据或校验失败应发送NAK请求重传本包。一个简单的串口中断DMA接收配合Flash写入的流程上位机发送“开始传输”命令附带字库总大小和CRC校验值。MCU收到后擦除目标Flash扇区回复ACK。上位机开始发送数据包每包包含包序号和本包数据。MCU使用DMA将一包数据接收到缓冲区A接收完成后触发DMA完成中断。在中断服务函数中校验本包数据。如果正确将缓冲区A的地址和数据长度传递给一个后台任务或直接在中断中如果时间允许进行Flash写入同时立即启动DMA接收下一包到缓冲区B。Flash写入任务完成后通过串口发送本包的ACK给上位机。如此循环直至所有包传输完成。最后MCU可以根据收到的所有数据计算一次总CRC与上位机发送的CRC对比完成最终校验。4. 完整实现步骤与代码解析4.1 下位机Bootloader程序实现这里给出一个基于STM32标准外设库的简化版核心代码框架省略了错误处理的细节以突出重点。步骤1定义字库存储区域在头文件中定义// flash_ziku.h #define FLASH_ZIKU_START_ADDR ((uint32_t)0x0800A000) // 字库起始地址 #define FLASH_ZIKU_SECTOR (FLASH_Sector_10) // 根据实际地址计算出的扇区号 #define FLASH_ZIKU_SIZE (24 * 1024) // 预留24KB空间步骤2串口协议解析在串口中断服务函数或IDLE中断中解析数据帧// usart.c uint8_t uart_rx_buffer[512]; uint8_t uart_rx_len 0; void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART1); // 简单的状态机解析帧头、长度、数据、校验和 static enum {STATE_HEAD1, STATE_HEAD2, STATE_CMD, STATE_LEN_H, STATE_LEN_L, STATE_DATA, STATE_CHECKSUM} state STATE_HEAD1; static uint16_t data_index 0, data_length 0; static uint8_t cmd, checksum_calc; switch(state) { case STATE_HEAD1: if(ch 0xAA) state STATE_HEAD2; break; case STATE_HEAD2: if(ch 0x55) state STATE_CMD; else state STATE_HEAD1; break; case STATE_CMD: cmd ch; checksum_calc ch; state STATE_LEN_H; break; case STATE_LEN_H: data_length ch 8; checksum_calc ch; state STATE_LEN_L; break; case STATE_LEN_L: data_length | ch; checksum_calc ch; data_index 0; if(data_length sizeof(uart_rx_buffer)) { state STATE_DATA; } else { // 长度错误 state STATE_HEAD1; } break; case STATE_DATA: uart_rx_buffer[data_index] ch; checksum_calc ch; if(data_index data_length) { state STATE_CHECKSUM; } break; case STATE_CHECKSUM: if(checksum_calc ch) { // 校验通过处理有效帧 Process_Frame(cmd, uart_rx_buffer, data_length); } state STATE_HEAD1; // 重置状态机 break; } } }步骤3Flash操作核心函数// flash_ziku.c #include stm32f10x_flash.h FLASH_Status ZIKU_Flash_Erase(void) { FLASH_Status status FLASH_COMPLETE; FLASH_Unlock(); __disable_irq(); // 擦除期间关闭中断 status FLASH_ErasePage(FLASH_ZIKU_START_ADDR); // 如果需要擦除多个连续扇区这里需要循环 __enable_irq(); FLASH_Lock(); return status; } FLASH_Status ZIKU_Flash_Write(uint32_t offset, uint8_t *pdata, uint32_t size) { // offset: 相对于FLASH_ZIKU_START_ADDR的偏移量 FLASH_Status status FLASH_COMPLETE; uint32_t write_addr FLASH_ZIKU_START_ADDR offset; uint16_t *p_halfword (uint16_t*)pdata; uint32_t halfword_size (size 1) / 2; // 字节数转半字数 FLASH_Unlock(); __disable_irq(); // 写入期间也建议关闭中断 for(uint32_t i 0; i halfword_size; i) { status FLASH_ProgramHalfWord(write_addr, p_halfword[i]); if(status ! FLASH_COMPLETE) { break; } write_addr 2; // 地址增加2字节 // 可选每写一个半字或一定数量后进行读回验证 // if(*(volatile uint16_t*)(write_addr - 2) ! p_halfword[i]) {...} } __enable_irq(); FLASH_Lock(); return status; }步骤4主控逻辑在Process_Frame函数中根据命令字执行不同操作static uint32_t ziku_total_size 0; static uint32_t ziku_received_size 0; static uint32_t ziku_crc32_expected 0; static uint32_t ziku_crc32_calculated 0; void Process_Frame(uint8_t cmd, uint8_t *data, uint16_t len) { switch(cmd) { case CMD_START: // 0x01 // 解析数据获取总大小和CRC ziku_total_size (data[0]24)|(data[1]16)|(data[2]8)|data[3]; ziku_crc32_expected (data[4]24)|(data[5]16)|(data[6]8)|data[7]; ziku_received_size 0; ziku_crc32_calculated 0xFFFFFFFF; // CRC32初始值 // 擦除Flash if(ZIKU_Flash_Erase() FLASH_COMPLETE) { Send_Ack(CMD_START, SUCCESS); } else { Send_Ack(CMD_START, FAIL); } break; case CMD_DATA: // 0x02 { uint16_t pkg_seq (data[0]8) | data[1]; // 包序号 uint16_t pkg_len len - 2; // 数据长度 uint8_t *pkg_data data[2]; // 1. 更新CRC32 (使用软件或硬件CRC计算) ziku_crc32_calculated Calculate_CRC32(pkg_data, pkg_len, ziku_crc32_calculated); // 2. 写入Flash if(ZIKU_Flash_Write(ziku_received_size, pkg_data, pkg_len) FLASH_COMPLETE) { ziku_received_size pkg_len; // 3. 回复ACK携带包序号 Send_Ack_With_Seq(CMD_DATA, SUCCESS, pkg_seq); } else { Send_Ack_With_Seq(CMD_DATA, FAIL, pkg_seq); } } break; case CMD_END: // 0x03 // 最终CRC校验 ziku_crc32_calculated ^ 0xFFFFFFFF; // CRC32最终异或值 if((ziku_received_size ziku_total_size) (ziku_crc32_calculated ziku_crc32_expected)) { Send_Ack(CMD_END, SUCCESS); // 字库更新成功可以跳转到主程序或重启 } else { Send_Ack(CMD_END, FAIL); // 发送错误原因大小不匹配或CRC错误 } break; } }4.2 上位机工具实现思路上位机工具可以用任何你熟悉的语言编写如Python使用pyserial、C#使用SerialPort控件或LabVIEW。核心逻辑如下打开串口设置正确的波特率、数据位、停止位、校验位。打开字库文件以二进制模式rb打开。计算文件CRC32读取整个文件内容计算CRC32校验值。发送开始帧构造包含文件总大小和CRC32值的“开始传输”命令帧发送给MCU等待ACK。分包发送数据将文件按预定包大小如500字节分片为每片数据加上包序号构造“数据包”命令帧依次发送。每发送一包等待MCU回复的ACK如果收到NAK或超时则重发本包。发送结束帧所有数据包发送完毕后发送“结束传输”命令帧等待MCU回复最终的校验结果。Python示例片段使用pyserialimport serial import struct import zlib def send_ziku(port, baudrate, file_path): ser serial.Serial(port, baudrate, timeout1) with open(file_path, rb) as f: data f.read() total_size len(data) crc32_val zlib.crc32(data) 0xFFFFFFFF # 发送开始帧 start_frame struct.pack(BBHIIB, 0xAA, 0x55, 0x01, total_size, crc32_val, calc_checksum(...)) ser.write(start_frame) if wait_for_ack(ser, 0x01): print(Start ACK received.) # 分包发送 pkg_size 500 seq 0 for i in range(0, total_size, pkg_size): pkg_data data[i:ipkg_size] # 构造数据帧帧头命令(0x02)长度(2len(pkg_data))包序号(2字节)数据校验和 data_frame construct_data_frame(0x02, seq, pkg_data) ser.write(data_frame) if not wait_for_ack_with_seq(ser, 0x02, seq): print(fPackage {seq} failed, retrying...) # 重试逻辑 seq 1 # 发送结束帧 end_frame struct.pack(BBHB, 0xAA, 0x55, 0x03, calc_checksum(...)) ser.write(end_frame) if wait_for_ack(ser, 0x03): print(Ziku download successfully!) ser.close()5. 常见问题、调试技巧与避坑指南在实际操作中你几乎一定会遇到下面这些问题。我把我的踩坑经验和解决方法都列在这里。5.1 Flash操作失败Flash Download Failed这是最令人崩溃的错误之一通常发生在你尝试用调试器ST-Link J-Link下载程序时而你的代码里包含了Flash操作。原因1Flash被锁住或未正确解锁。在调用FLASH_Unlock()后最好检查一下Flash状态寄存器FLASH-SR。解锁后需要等待FLASH_SR_BSY位清零才能进行下一步操作。原因2写保护WRP或读保护RDP生效。如果你之前设置过Flash保护新的程序可能无法写入。解决办法是通过STM32 ST-LINK Utility等工具**全片擦除Mass Erase**芯片这会同时解除保护。在程序初始化时尝试解除保护但如果是RDP Level 1代码是无法解除的必须Mass Erase。原因3目标地址非法或未对齐。确保你写入的地址在Flash地址范围内并且是**半字对齐地址最低位为0**的。FLASH_ProgramHalfWord要求地址必须2字节对齐。原因4在中断服务程序ISR中执行耗时Flash操作。Flash擦除/写入时间很长如果在高优先级中断如SysTick中执行可能导致其他中断无法及时响应看门狗超时等。务必在操作前后关中断。调试方法单步调试在调用Flash操作函数前后设置断点观察函数返回值FLASH_Status和Flash控制状态寄存器FLASH-CR,FLASH-SR的值。5.2 字库显示乱码或错位下载成功后主程序读取Flash中的字库显示却发现文字是乱码。原因1Flash中的字库数据本身错误。这是最可能的原因。你需要验证Flash里的数据是否和原始文件一致。验证方法写一个简单的测试函数读取Flash字库区域的数据通过串口打印出来HEX格式和原始字库文件的二进制内容对比。或者在Flash写入函数中每写一个数据后立即读回比较即前面代码中的“读回验证”。原因2地址计算错误。主程序读取字库时根据汉字机内码计算偏移地址的算法有误。确保计算公式与字库文件格式GB2312/GBK完全匹配。例如对于GB2312的HZK16文件区位码计算公式为offset ((区码-1)*94 (位码-1)) * 32。这里的32是因为一个16x16的点阵汉字占用32字节。原因3字节序Endian问题。如果你在写入和读取时对多字节数据如uint16_t, uint32_t的处理方式不一致就可能出现字节序问题。在STM32小端模式上直接按字节流写入和读取通常没问题。但要确保你的上位机生成的数据包和MCU理解的数据包格式在字节序上一致。原因4字库文件格式不匹配。确认你下载的字库文件格式正是你显示驱动所期望的格式。例如有的是横向取模左高位有的是纵向取模上高位。格式不对显示必然乱码。5.3 串口通信不稳定丢包严重原因1波特率误差。115200及以下波特率通常很稳定。如果使用更高波特率如921600需要检查MCU和PC端串口工具的时钟精度和波特率设置是否一致。STM32F103的USART波特率由APB时钟分频产生可能存在微小误差。原因2未使用流控或缓冲区溢出。如果MCU处理Flash写入太慢而串口数据源源不断就会导致接收缓冲区溢出。解决方案使用硬件流控RTS/CTS如果硬件支持这是最好的办法。使用软件流控XON/XOFF协议复杂一些。使用ACK/NAK协议并降低发送速度这就是我们前面设计的方案。上位机每发一包必须等到MCU的ACK后才发下一包。可以在MCU处理完一包并回复ACK后上位机加一个小的延时如10ms再发下一包。原因3中断优先级与处理时间。如果串口中断优先级太低可能被其他长时间中断打断导致数据丢失。确保串口接收中断有足够高的优先级。同时串口中断服务函数要尽可能短只做最必要的操作如将数据存入缓冲区复杂的协议解析和Flash操作放到主循环或低优先级任务中。调试技巧让上位机在发送每个数据包时也同时在屏幕上打印出包序号和内容HEX。让MCU在收到包后不仅回复ACK也把收到的包序号和校验和通过另一个串口或同样的串口在非传输模式时打印出来。两边日志对比很容易定位是发送端、传输过程还是接收端的问题。5.4 程序跑飞或HardFault在加入了Flash操作代码后程序可能莫名其妙地复位或进入HardFault。原因1访问了非法内存地址。确保你的Flash操作地址绝对在物理Flash范围内对于STM32F103C8T6是0x0800 0000 ~ 0x0800 FFFF。如果你错误地计算了一个超出范围的地址进行读写操作就会触发总线错误导致HardFault。原因2在Flash编程期间发生了中断且中断服务程序试图从Flash取指。这是非常隐蔽的一个坑。当CPU正在向Flash写入数据时Flash总线是被占用的。如果此时发生中断CPU去Flash中读取中断向量或ISR代码就会失败。这就是为什么必须在擦除和写入Flash前关闭所有中断__disable_irq()的根本原因。但注意关闭中断的时间不能太长否则会影响系统实时性。对于擦除几十ms时间较长需要评估系统能否接受。对于写入每个半字约几十us如果连续写很多数据关闭中断的总时间也可能很长。可以考虑分批写入每写一小段如128字节就开启一次中断。原因3堆栈溢出。Flash操作函数可能使用了较大的局部变量或者递归调用。检查你的堆栈Stack大小是否足够。在启动文件startup_stm32f10x_xx.s或IDE配置中增大堆栈。排查HardFault在HardFault中断服务函数中读取相关寄存器如SCB-CFSR,SCB-HFSR,SCB-MMFAR,SCB-BFAR可以分析故障原因。网上有很多关于如何分析STM32 HardFault的详细教程。5.5 字库更新后主程序无法启动如果你把Bootloader和主程序做成了两个独立的工程通过跳转方式启动可能会遇到此问题。原因1中断向量表未重映射。Bootloader运行在Flash起始地址0x0800 0000。当你跳转到主程序假设在0x0800 4000时CPU仍然以为中断向量表在0x0800 0000。一旦发生中断就会跑到Bootloader的中断向量表去导致程序混乱。必须在跳转到主程序前重新配置中断向量表偏移寄存器SCB-VTOR。// 在Bootloader中跳转到主程序前 void JumpToApplication(uint32_t app_addr) { typedef void (*pFunction)(void); pFunction Jump_To_Application; // 检查栈顶地址是否合法主程序栈顶位于app_addr if(((*(__IO uint32_t*)app_addr) 0x2FFE0000) 0x20000000) { // 设置主堆栈指针 __set_MSP(*(__IO uint32_t*)app_addr); // 设置中断向量表位置对于Cortex-M3 SCB-VTOR app_addr; // 跳转到主程序复位地址app_addr 4 Jump_To_Application (pFunction)*(__IO uint32_t*)(app_addr 4); Jump_To_Application(); } }原因2主程序初始化时破坏了Bootloader或字库数据。检查主程序的链接脚本确保其代码和变量区域没有覆盖Bootloader和字库存储的区域。同时主程序初始化时不要对Flash字库区域进行任何擦写操作。把这个方案走通一遍你对STM32的Flash特性、串口可靠通信以及嵌入式系统的存储管理会有非常深刻的理解。它不仅仅是一个字库存储的技巧更是一个通用的“通过串口更新MCU内部数据”的框架完全可以应用到存储配置参数、校准数据、图片资源等场景中。