简介面向STM32与嵌入式开发者的W25Q128字库应用工程解决利用SPI接口闪存存储汉字点阵字库并在LCD上显示的问题。项目基于Keil MDK开发代码分层清晰涵盖SPI驱动、字库读取、LCD显示、地址映射等核心环节适合需要实现中文字库显示或了解外部Flash存储管理的读者也可作为STM32外设驱动的学习样例。压缩包共192个文件约4.6MB以h头文件、c源码、uvprojx/uvoptx工程文件为主同时包含o、d、crf等编译中间文件及说明文档便于直接查看完整工程结构。资源中特别记录了字库起始地址0x10004096的配置说明并提供keilkill.bat辅助脚本方便清理中间文件。目前已有1965人学习下载工程源码、可执行文件及调试信息均包含在内可辅助理解SPI通信、Flash分区管理和LCD显示驱动的实际应用。1. 为什么中文字库非要外挂一颗Flash先聊点实在的你用STM32驱动一块LCD屏显示英文和数字通常很简单——ASCII字符集总共就95个可见字符一个8x16的字模才16字节整个ASCII字库撑死也就2KB不到直接塞进单片机内部Flash或者干脆做成const数组,一点压力都没有。可一旦切到中文事情就完全变了。中文显示的基本单位是汉字常用国标GB2312字符集光一级汉字就有3755个加上二级汉字一共6763个常用的16x16点阵字模每个汉字占32字节整个字库算下来就是6763乘以32大约216KB。如果用的是24x24点阵单个字模72字节整个字库奔着500KB去。这还没算ASCII字符、全角标点、特殊符号占的空间。普遍的做法有两种一种是直接把取模好的汉字数组直接编进固件里比如const uint8_t hanzi_zhong[] {0x00, 0x80, ...}这种方案在只需要显示几十个固定汉字的场景下非常实用比如温度计显示“温度”“湿度”这几个字选这个方案没有任何问题。但缺点也很明显每增加一个字都要重新取模、改数组、重新编译固件显示“星期几”要加七个字显示菜单要加几十个字长期维护非常痛苦而且字库一扩充内部Flash就告急。另一种思路就是把字库外置。W25Q128是一颗16MB128Mbit的SPI NOR Flash存GB2312全字库绰绰有余空余空间还能顺便放下几张图片资源或者开机动画。运行时需要显示什么字算好偏移地址通过SPI接口读取对应的32字节点阵数据再送往LCD的GRAM整个过程对主控来说毫无压力。我自己第一次做这个项目的时候用的主控是STM32F103C8T6内部Flash只有64KB编译一个带完整固件的工程已经占了30多KB如果再把几百KB的中文字库塞进去直接编译报错。外挂Flash之后64KB的Flash装固件完全够用W25Q128存字库、图片各司其职这个方案我后来在多个项目里反复使用稳定性和扩展性都非常好。如果你手里的主控是ESP32、GD32、CH32这类芯片思路完全一样无非是SPI引脚不同、DMA通道不同核心逻辑完全通用。2. 字库从哪来烧录格式与文件系统折腾记录先别急着写代码最棘手的往往不是读取逻辑而是字库是怎么进到W25Q128里面的。2.1 字库bin文件的生成首先你要有一份中文字库的bin文件。这个文件可以从PC端用工具生成比如PCtoLCD2002、字模精灵、Img2Lcd这类的取模软件都支持批量生成配置好字体大小、取模方式逐行式还是逐列式、字节顺序就能导出一份直接从偏移地址开始排列的字模数据文件。这里有个特别容易踩的坑取模方式和LCD屏的扫描方向必须匹配。如果你的LCD是常见的1.8寸ST7735或者2.4寸ILI9341通常用的是列行式扫描即先扫描高字节低字节取模工具里对应的就是“阴码、逐列式、顺向”这类选项。如果取模时用的是逐行式屏幕显示出来的汉字就是偏转90度或者完全镜像的。这个我实测过第一次做的时候取模方式选错了整个汉字看起来像是被横着拍扁了后来重新取模才好。一份GB2312全字库的bin文件大小大概是2.3MB到2.7MB取决于你用的是16x16还是16x24。这里我强烈建议你把文件系统一起考虑进去如果只在Flash起始地址整块存字库读取逻辑很简单但后续如果你还打算存图片、存日志、甚至通过OTA升级字库没有文件系统会非常痛苦。我的做法是给W25Q128格式化成FATFS文件系统字库存成一个名为GB2312_16.FON的文件运行时用f_open按需打开读取。FATFS是开源的STM32移植时用fatfs官方代码把底层disk_read、disk_write、disk_initialize几个接口对接好就可以。2.2 烧录方式对比字库bin文件拿到手之后怎么进到W25Q128这里有三种方案我分别说下适用情况脱机烧录器如果你是量产阶段用脱机烧录器比如CH341A编程器或者专门的SPI Flash烧录座直接烧录速度快、不用写代码。缺点是每片板子都要拿出Flash来烧或者预留烧录座占PCB面积。串口ISP下载设备通过串口接收PC端下发的bin文件由单片机程序将数据写入W25Q128。适合前期调试和少量生产我建议在bootloader里写一段“字库升级”逻辑设备上电时检查一个标志位如果是升级模式就进入串口接收字库文件并写入Flash的SPI地址区域写完再跳转App。这个方案非常灵活后续字库想换版本不用拆机。SD卡拷贝如果设备本身预留SD卡接口把字库文件放在SD卡里上电后程序自动检测W25Q128里的字库版本发现SD卡里有新版本就直接拷贝过去。这是我目前最推荐的方式后期运维成本最低。我第一次做的时候用的是串口ISP下载为了省事直接在App里加了一个“字库升级”菜单按一下按键通过串口用XMODEM协议接收PC软件发来的GB2312_16.FON接收完写入Flash然后校验一遍读回来的数据。整个过程走通了之后再切换到SD卡方案就非常顺手。2.3 W25Q128的Flash特性要先了解W25Q128容量是16MB128Mbit分成256个64KB的块Block每个块分成16个4KB的扇区Sector扇区又由16页组成每页256字节。SPI协议支持标准SPI、双线SPI、四线SPI最高时钟可以到104MHz具体看型号后缀某些版本支持更高。实际项目里我一般跑在36MHz或者54MHzSTM32F103的SPI1最高18MHz再快受限于主控外设时钟不过读字库这个量级的操作完全够用。读数据的命令是0x03后面跟24位地址可以连续读取任意长度不需要考虑页边界对齐的问题——这个特性对字库读取太重要了。写数据就麻烦一些必须在页边界对齐且每次最多写256字节所以如果你要从串口接收字库再写入Flash需要自己拼接缓冲凑满一页再发写命令注意先发0x06写使能。3. 中文字符到点阵的换算逻辑GB2312区位码计算这是整个项目里最有技术含量的一步也是无数新手卡壳的地方。你拿到一个汉字比如“中”怎么知道它在字库bin文件里面的偏移地址是多少GB2312编码的汉字在计算机里是以两个字节存储的第一个字节称为“区”码范围0xB0到0xF7对应区号16到87第二个字节称为“位”码范围0xA1到0xFE对应位号1到94。要转换成区位码区号 字节1 - 0xA0 位号 字节2 - 0xA0然后汉字在字库文件中的偏移地址就是offset ((区号 - 1) * 94 (位号 - 1)) * 单字模大小这里单字模大小是3216x16点阵一行两个字节共16行。拿“中”字举例它的GB2312编码是0xD6D0区号就是0xD6-0xA054位号就是0xD0-0xA048偏移算出来是((54-1)*94 (48-1))*32 (53*94 47)*32 (4982 47)*32 5029*32 160928字节。如果要读取就从W25Q128的0x000274A0地址开始连续读32字节得到的就是“中”字的点阵数据。ASCII字符的读取逻辑更简单ASCII字符在字库文件里一般排在前面比如0x20到0x7E共95个字符每个16字节偏移就是(ascii - 0x20) * 16。这里有一个坑必须提醒很多网上流传的取模软件在导出GB2312字库时区号和位号的排列顺序并不统一。有的软件是直接从0xA1A1开始的所有汉字按区位递增排列有的软件却会把16x16的字模和12x12的字模混在一起甚至文件头带有N字节的保留字段。所以拿到bin文件后先验证用Windows记事本输入“中”和“国”两个字GB2312编码分别是D6D0和B9FA在bin文件里搜一下这两个编码对应的字模数据是否在预期位置确认无误再写代码。我还见过一个更隐蔽的问题有些LCD的GRAM是16位色RGB565如果直接往显存里塞1位深度的点阵数据屏幕只会显示一团颜色。所以写入LCD前1位点阵要扩展成16位色值前景色和背景色分别映射到RGB565值。4. 汉字上屏过程与显示驱动衔接细节前面的逻辑都搞清楚了剩下的就是把字模数据变成屏幕上看得见的文字。这里我以常见的16位色SPI接口LCDST7735/ILI9341为例说下完整流程。4.1 点阵数据到像素数据的转换从W25Q128读回来的32字节是1位深度的点阵数据——每个bit代表一个像素1是前景色字颜色0是背景色底色。但LCD的GRAM需要的是16位RGB565数据所以每个bit要扩展成2字节的颜色值。这一步写出来很简单for (uint8_t row 0; row 16; row) { uint8_t byteL font_data[row * 2]; uint8_t byteH font_data[row * 2 1]; for (uint8_t col 0; col 8; col) { uint16_t color (byteL (0x80 col)) ? fg_color : bg_color; draw_pixel(x col, y row, color); } for (uint8_t col 0; col 8; col) { uint16_t color (byteH (0x80 col)) ? fg_color : bg_color; draw_pixel(x 8 col, y row, color); } }这段代码在逻辑上没有任何问题但如果逐像素调用draw_pixel性能会非常感人。一屏显示几十个汉字每个汉字32次循环每次循环画16个点全屏刷下来CPU占用率极高。实际项目里要分两步优化。4.2 显存缓冲与DMA加速很多ST7789/ILI9341驱动方案会在单片机里开一整块uint16_t lcd_buffer[128*160]作为GRAM镜像如果是RGB565格式128*160约40KBSTM32F103C8T6的20KB RAM不够用可以换用STM32F103RCT6的48KB RAM或者直接减少buffer到单行/半屏。我的习惯是在内存里维护一个较大缓冲然后通过DMASPI整块刷到LCD避免每一个点都占用CPU。不过更常见的做法是汉字显示函数先把点阵转换为颜色数据写入一个临时行缓冲攒够一整行或半屏之后调用LCD的LCD_Address_Set设定窗口再把整块缓冲通过SPI DMA刷过去。ST7735这种小屏面板一屏只有128*160个像素全屏也就40KB字节DMA刷一次在36MHz SPI下大约11ms完全能接受。如果你的主控RAM紧张也可以用“画点法”直接从Flash点阵展开每次画完一个点就通过SPI发一个像素色值但这种方式会反复设置坐标窗口效率很低。我的经验是RAM够就开缓冲不够就缩小缓冲到一行或一个汉字大小分块刷新。4.3 中英文混排的坐标计算中英文混排是个容易被忽略的细节ASCII字符通常是8x16或8x8汉字是16x16行高取16像素时英文宽度只有8像素所以计算下一字符的x坐标时要区分中英文if (字符是中文) x 16; else x 8;如果统一按16像素推进英文之间会显得很稀疏。另外换行时y坐标要加上16如果行高16像素同时x重新回到左边距。这些细节看着琐碎但直接影响显示效果。4.4 读取性能优化从W25Q128读字模一次读取32字节如果每次读32字节都发一次SPI读命令0x03 3字节地址有开销但是不大。真正影响效率的是频繁切换片选和等待时间。W25Q128读操作有大概几十纳秒到微秒级的延迟连续读取时如果使用Fast Read0x0B并开启Dummy Cycle吞吐率会更高。我的优化建议是把常用汉字菜单、状态栏里出现频率很高的字缓存到RAM里像“电压”“温度”“设置”这些第一次读取后存下来后续直接命中缓存减少Flash访问次数。这个方案在STM32F103的20KB RAM下完全可行缓存100个汉字也就3.2KB。5. 排错实录从花屏到随机乱码的完整排查链路写代码一时爽调bug火葬场。这部分我把实际项目中遇到的几个经典问题完整记录下来按排查顺序排列如果你也遇到类似的显示异常可以顺着这个思路走一遍。5.1 现象一汉字整体花屏颜色错乱排查步骤先用单色填充测试LCD本身的初始化是否正常。如果在LCD_Clear(RED)之后屏幕显示的是绿色问题出在RGB565的颜色字节顺序上——LCD的RGB565是高字节在前还是低字节在前直接决定了颜色值的高低字节是否要交换。ST7735这类屏幕通常需要把颜色值高低字节对调。显示ASCII字符如果ASCII显示正常而汉字花屏问题多半在字库读取或取模方式。单独打印从W25Q128读出的32字节数据和PC端bin文件里对应字的一套数据做比对。如果比对了前5个字节就不同大概率是偏移计算公式写错如果完全相同问题在LCD写入逻辑。检查LCD的扫描方向和取模方向是否匹配。如果汉字上下颠倒、左右颠倒或者旋转了90度直接去PC端重新取模把取模方式改成逐列式和/或反向。5.2 现象二部分汉字正常部分汉字显示成乱码这是最典型的GB2312编码处理bug。我遇到过两种具体情况一是字符串本身是UTF-8编码而不是GB2312/GBK编码。UTF-8的中文字符是3字节拿去做(byte1-0xA0)*94 (byte2-0xA0)计算偏移完全错乱。排查方法很简单在MDK或者IAR的调试界面里查看字符串缓冲区的原始字节“中”的GB2312编码是D6 D0如果看到的却是E4 B8 AD说明编译器把源码当成了UTF-8需要在编译器里把源文件编码改成GB2312或者做一次UTF-8到GB2312的转换。二是字库文件本身只有一级汉字3755个没有包含二级汉字而你要显示的字恰好是二级汉字比如“玥”“昶”这类生僻字读出来的偏移指向的数据是空的或者杂乱数据。解决方法是换用GB2312全字库或者直接用GBK全字库GBK包含了20902个汉字覆盖面更广。5.3 现象三首次上电正常运行几分钟后出现随机花屏这个坑我排查了很久最终定位到SPI总线上。W25Q128和LCD如果挂在同一个SPI总线上片选信号CS管理不当就会互相干扰。更隐蔽的问题是STM32的SPI从设备在CS拉高后如果SCK上有毛刺Flash的状态机可能进入错误状态之后读取的地址就不对了。解决方法是Flash和LCD分别用独立的SPI外设或者至少保证软件上严格控制片选时序在SPI初始化完成后先读一次Flash的JEDEC ID0x9F命令复位Flash状态如果系统中有高功耗外设比如射频模块频繁开关导致电源波动检查SPI供电的纹波必要时在Flash电源引脚加10uF100nF去耦电容。5.4 现象四字库写进去之后读出来全0xFF全0xFF意味着Flash处于擦除状态而W25Q128擦除后所有位都是1。出现这个现象通常是写入失败常见原因有三个没发送0x06写使能命令、写地址跨页了而程序没有处理跨页、在写状态寄存器或读取状态寄存器时时序不匹配。排查时先在Flash开头地址手动写256字节全0x00然后读回来看是否成功缩小问题范围。6. 一个完整的显示流程代码骨架理论说了一大堆直接上代码骨架。我以STM32标准外设库或者HAL库均可为例省略SPI底层初始化细节重点看显示逻辑// 读取字模根据GB2312编码读取对应点阵 void load_gb2312_font(uint16_t code, uint8_t *buffer) { uint8_t hi (code 8) 0xFF; uint8_t lo code 0xFF; uint32_t area hi - 0xA0; uint32_t pos lo - 0xA0; uint32_t offset ((area - 1) * 94 (pos - 1)) * 32; // 跳过ASCII区段的长度如果你的bin文件ASCII在前面 offset ASCII_FONT_SIZE; w25q128_read(buffer, offset, 32); } // 显示一个汉字在指定坐标 void lcd_show_chinese(uint16_t x, uint16_t y, uint16_t code, uint16_t fg, uint16_t bg) { uint8_t font_buf[32]; load_gb2312_font(code, font_buf); for (uint8_t row 0; row 16; row) { uint8_t byte_l font_buf[row * 2]; uint8_t byte_h font_buf[row * 2 1]; for (uint8_t col 0; col 8; col) { lcd_draw_pixel(x col, y row, (byte_l (0x80 col)) ? fg : bg); lcd_draw_pixel(x col 8, y row, (byte_h (0x80 col)) ? fg : bg); } } } // 从字符串中提取GB2312编码并显示 void lcd_show_string(uint16_t x, uint16_t y, const char *str, uint16_t fg, uint16_t bg) { while (*str) { uint8_t c *str; if (c 0x80) { // ASCII lcd_show_ascii(x, y, c, fg, bg); x 8; str; } else { // 中文字符两个字节合成一个GB2312编码 uint16_t code ((uint16_t)(*str) 8) | (uint8_t)(*(str 1)); lcd_show_chinese(x, y, code, fg, bg); x 16; str 2; } } }这段代码清晰展示了整个链路的骨架lcd_show_string逐字符解析ASCII直接显示中文字符取两个字节合成GB2312编码load_gb2312_font根据区位码计算偏移读取32字节字模最后lcd_show_chinese逐行逐位写入LCD。实际项目中把lcd_draw_pixel换成带缓冲的写入函数即可。7. 进阶玩法缓存、动态加载与多语言扩展项目跑通之后还可以继续往这几个方向扩展。热点字缓存前面提到过菜单、状态栏里大量重复的汉字把它们缓存到RAM可以显著减少Flash读取次数。实现方式不复杂开辟一个cache[64][32]数组用一个LRU链表管理每次加载字模时先查缓存命中直接复制未命中再从Flash读取并更新缓存。效果在STM32F103上非常明显菜单切换的卡顿感减少了。动态分页字库如果你的Flash容量已经塞满了图片资源没地方放全字库可以只放高频汉字的前1000个再加上一个“未命中回退表”——遇到不认识的编码通过串口或者SD卡实时加载。这个方案适合Flash极小的低成本方案。多语言支持把字库文件分成多个比如中文、英文、特殊符号各一个文件在文件系统上按语言类型加载和切换。这对于出口设备很有用切换语言时只需要改变字库文件的打开路径。字模压缩W25Q128的空间虽然大但如果还想再塞更多内容可以考虑用RLE或者LZ4压缩字模数据。16x16汉字点阵的压缩率通常在30%-50%代价是解码耗时读取后需要花几毫秒解压。在CPU性能充足的场景下这个方案很值得尝试。我自己在实际项目中最后用到的组合是W25Q12816MB FATFS文件系统 GB2312全字库bin文件 热点缓存 DMA刷新LCD。整套方案跑下来主控的负载很低菜单切换流畅字库升级只需要替换SD卡里的文件就够了真正做到了“一劳永逸”。如果你正准备做带中文菜单的嵌入式产品强烈建议一开始就把字库外置这条路走通后面会省心太多。本文还有配套的精品资源点击获取