简介:一套基于 STM32 与 W25Q128 串行闪存的汉字字库存储及 LCD 显示工程,面向嵌入式开发初学者和需要自行管理外部 Flash 字库的工程师。工程采用 Keil 环境,包含 SPI 驱动、LCD 驱动、HZLIB.bin 字库文件及烧录地址说明(字库从 4096 即 0x1000 地址开始),可帮助理解从 Flash 读取点阵数据到屏幕渲染的完整链路。压缩包共 192 个文件,以 33 个 .c 源码和 36 个 .h 头文件为主,同时保留 .o、.crf、.axf、.hex、.map 等编译中间产物,便于逆向分析与二次移植;附带 keilkill.bat 批处理用于清理 Keil 中间文件,工程目录包含 Output、User、Libraries 等典型模块,整体仅 4.6MB。已有 1971 人学习浏览,适合快速上手 SPI Flash 存储字库与 LCD 汉字显示,也可直接移植到 STM32 项目中作为实验参考。
1. 几块钱的 SPI Flash 让 MCU 不再为汉字库存放发愁
刚接手一个工业仪表项目时,甲方要求在菜单里显示完整中文,MCU 内部那点 Flash 存完固件后剩不到 200KB,一张 GB2312 全量字库就要 200 多 KB,程序一加字库就直接溢出。换带字库的串口屏要贵出好几倍,而且协议被屏厂锁死,后期想改 UI 布局还得求人。后来用一片 W25Q128 SPI Flash 把 16x16 点阵字库全部装进去,MCU 通过 SPI 按需读字模、再交给 LCD 渲染,整体成本只多了一块多钱,汉字显示效果和灵活性反而比串口屏更可控。这篇文章就把这套"W25Q128 存储字库 + LCD 显示汉字"的方案,从硬件连线、字库制作到核心代码完整拆一遍,适合被汉字显示存储问题卡住的嵌入式开发者参考,也适合想自己掌控显示链路的人拿来当模板。
2. 硬件与存储设计:W25Q128、MCU、LCD 三者怎么分工不打架
2.1 选型对比:SPI Flash 相比 SD 卡和 MCU 内置 Flash 强在哪
做汉字字库存放,候选方案无非三条路:内置 Flash 直接存、SD 卡存、外挂 SPI Flash 存。内置 Flash 容量通常只有几十到几百 KB,固件本身占掉大头,再塞一张 16x16 全字库几乎是走钢丝,而且频繁擦写内置 Flash 还有磨损固件区段的风险。SD 卡虽然容量大到用不完,但驱动得挂文件系统,上电初始化慢,SPI 模式兼容性还参差不齐,工业环境里接触不良的坑一踩一个准。
W25Q128 是 128Mbit(16MB)的 SPI NOR Flash,一片几块钱,容量是 16x16 点阵 GB2312 字库(约 216KB)的七十多倍,放完字库还能塞图标、开机 LOGO 和配置参数。读取走标准 SPI,串行时钟几十兆赫兹很常见,MCU 随便一个硬件 SPI 外设就能驱动,不需要文件系统,上电后直接按地址把字模读出来,逻辑清爽。最关键的是它支持按 4KB 扇区擦除,固件升级时字库区的数据可以原地保留,不会像 SD 卡那样动不动就掉文件。
选型时还有一个容易忽略的点:W25Q128 的工作电压是 2.7V 到 3.6V,正好和常见 MCU 的 3.3V 电平对接,不用额外做电平转换。如果板子上还有 5V 外设,一定要先确认 SPI 引脚是不是开漏上拉到 3.3V,否则直接 5V 灌进 Flash 的 CLK 和 DI 引脚,轻则读数据错位,重则烧片子。常见做法是在 SPI 信号线上串 33Ω 电阻,兼顾限流和信号完整性,走线超过 5cm 时这个电阻能明显减少反射造成的误码。
2.2 引脚连接与 SPI 速率选择:一块板子上的实际接法
W25Q128 标准 8 脚 SOP 封装,板级连接并不复杂。核心是四根 SPI 信号线加两根控制线,下面这张表是我在项目中实际用的接法,MCU 用的是某款国产 Cortex-M0 内核芯片,硬件 SPI 外设刚好够用。
| W25Q128 引脚 | 功能 | MCU 引脚分配 | 备注 |
|---|---|---|---|
| 1 CS | 片选 | PB12 | 低电平有效,必须由 GPIO 控制 |
| 2 DO | MISO | PB14 | Flash 输出,MCU 输入 |
| 3 WP | 写保护 | 直接接 3.3V | 低电平禁止写,平时拉高 |
| 4 GND | 地 | GND | 与 MCU 共地 |
| 5 DI | MOSI | PB15 | MCU 输出,Flash 输入 |
| 6 CLK | 时钟 | PB13 | SPI 时钟,速率建议 ≤ 20MHz |
| 7 HOLD | 保持 | 直接接 3.3V | 低电平暂停通信,平时拉高 |
| 8 VCC | 电源 | 3.3V | 并联 100nF 去耦电容 |
WP 和 HOLD 这两个脚是最容易翻车的地方。很多人觉得没用就不接,结果 Flash 莫名其妙进入写保护或者保持状态,读 ID 正常、写数据却全失败。正确做法是这两个脚直接拉高到 3.3V,保证芯片永远处于可操作状态。如果板上空间紧张,也可以把 WP 用 10kΩ 电阻上拉、HOLD 直接接 VCC,这样至少不会悬空。
SPI 速率方面,W25Q128 数据手册标称时钟可以到 104MHz,那是在理想信号质量下的极限值。实际开发中,我这里用的是 18MHz 分频,因为板子上 LCD 和 Flash 共用了同一个 SPI 总线,LCD 的输入时钟太高容易花屏,折中下来 18MHz 两边的时序余量都够。如果 LCD 用并行接口、SPI 只挂 Flash 一个设备,把时钟干到 36MHz 甚至 54MHz 都没问题,但要注意杜邦线连接时高速时钟容易受干扰,还是那句老话:稳定优先,别把手册最大值当默认值用。
2.3 Flash 地址分区:字库、图标、配置互不挤占的布局表
16MB 空间看着大,但不做规划就乱套。我习惯在工程文档里维护一张 Flash 地址映射表,所有写入操作前先查表,避免字库和配置参数互相覆盖。下面是针对 16x16 GB2312 字库做的默认分区:
| 起始地址 | 结束地址 | 大小 | 内容 |
|---|---|---|---|
| 0x000000 | 0x0007FF | 2KB | ASCII 8x16 字模,128 字符 |
| 0x000800 | 0x00355F | 约 216KB | GB2312 汉字 16x16 点阵字库 |
| 0x003560 | 0x04FFFF | 约 300KB | 图标、开机 LOGO 位图 |
| 0x050000 | 0x0FFFFF | 约 700KB | 预留扩展字库(24x24 等) |
| 0xD00000 | 0xDFFFFF | 1MB | 配置参数、日志记录 |
为什么要从 0xD00000 开始放配置参数?因为 W25Q128 底部的扇区常被用来放厂商信息或安全寄存器,普通读没问题,但某些批次芯片对底部扇区的写保护处理不太一样,为了省心,配置区直接挪到高位。日志记录用循环覆盖的方式写,每次擦掉最老的扇区,1MB 空间能记录好几万条日志,对现场问题回溯非常有用。
分区表定下来后,字库区用的是固定偏移,也就是说改字号或者加字库,不能随便动前面的布局。最好是每种字库单独生成一个 bin 文件,烧录时从对应起始地址写入,互不干扰。后面章节讲核心代码时你会看到,地址换算函数里引用的就是这张表的基地址,改布局只要同步改宏定义就行。
3. 字库制作与烧录:把 16x16 点阵字模写进 Flash 的全流程
3.1 字模格式:一个汉字 32 字节是怎么排出来的
16x16 点阵字模是嵌入式里最经典的汉字显示格式,一个汉字用 16 行、每行 16 个像素的点阵表示,每一行拆成 2 个字节,高字节管左边 8 个点,低字节管右边 8 个点,总共 16 行乘 2 字节等于 32 字节。比如"显"字的某一行,如果左边第 1、3、5 个点有墨,那高字节的 bit7、bit5、bit3 就是 1,其余位是 0。
实际项目里最常踩的坑是取模方向不一致。取模软件里通常有两种模式:横向取模(行优先)和纵向取模(列优先),对应到数据就是"逐行 2 字节"还是"逐列 2 字节"。如果生成字库时用的是横向取模,但显示驱动按纵向解析,出来的汉字就是撕裂并且旋转了 90 度的。我一般直接用脚本生成字库,脚本里写死取模逻辑,这样工程里所有参与的人拿到的都是同一格式,省去软件设置不一致带来的灾难。
另外还有阴码和阳码的区别:有的取模工具把有墨的点记为 1(阳码),有的记为 0(阴码)。这个无所谓对错,关键是生成和渲染必须统一。你要在取模脚本里明确注释"1 表示显示点,0 表示背景",这样三个月后回来看代码不至于靠猜。
3.2 用 Python 脚本生成全量 GB2312 字库 bin
手工从取模软件一个个导出汉字不现实,GB2312 汉字区有 6763 个汉字,点一遍就废了。正确姿势是用脚本批量渲染 TTF 字体、自动排列点阵、直接输出二进制文件。下面这段 Python 脚本我实际用在一个模拟项目 X 上,从"某中文字体"文件渲染出 16x16 点阵全字库,代码可以直接改字体路径和字号复用。
from PIL import Image, ImageDraw, ImageFont FONT_PATH = "simsun.ttc" FONT_SIZE = 16 OUTPUT = "font16_gb2312.bin" font = ImageFont.truetype(FONT_PATH, FONT_SIZE) with open(OUTPUT, "wb") as fp: # GB2312 汉字区:第一字节 0xB0~0xF7,第二字节 0xA1~0xFE for hi in range(0xB0, 0xF8): for lo in range(0xA1, 0xFF): try: char = bytes([hi, lo]).decode("gb2312") except UnicodeDecodeError: continue # 部分码位在 GB2312 中未定义,跳过 # 在 16x16 位图上渲染 img = Image.new("1", (FONT_SIZE, FONT_SIZE), 0) draw = ImageDraw.Draw(img) draw.text((0, 0), char, font=font, fill=1) # 横向取模:每行 16 像素拆成高低两个字节 for y in range(FONT_SIZE): high = 0 low = 0 for x in range(8): if img.getpixel((x, y)): high |= 1 << (7 - x) for x in range(8, 16): if img.getpixel((x, y)): low |= 1 << (7 - (x - 8)) fp.write(bytes([high, low])) print("字库生成完成,文件大小:", len(open(OUTPUT, "rb").read()), "字节")脚本的核心逻辑分三段:外层循环遍历 GB2312 所有汉字编码,中间层用 PIL 把单个汉字渲染到 16x16 的 1 位位图上,内层把每一行像素按"左 8 点高字节、右 8 点低字节"打包成两个字节写入文件。注意Image.new("1", ...)的 1 位模式,像素值非 0 即 1,这样getpixel拿到的就是干净的 0 或 1。
跑完后可以数一下文件大小,正常应该在 216KB 附近(6763 字乘以 32 字节,加上部分空白区保留)。如果文件大很多,检查是不是把未定义码位也写进去了;如果小很多,多半是UnicodeDecodeError跳过逻辑出了问题。生成完毕后,可以用十六进制编辑器看一眼偏移 0x00 处前 32 字节,对照"啊"这个字(GB2312 编码 0xB0A1)验证是不是和取模软件里看到的点阵一致。
3.3 烧录进 W25Q128:在线升级和烧录器直写两条路
字库 bin 生成后要写进 Flash,根据手头工具不同有两条路。第一条是烧录器直写,把 W25Q128 从板上拆下来或者用测试座接上,用 SPI Flash 编程器把font16_gb2312.bin从地址 0x000800 烧进去。这种方式简单粗暴,适合产线和实验室,但每次改字库都要拆芯片,项目调试阶段太折腾。
第二条是 MCU 在线升级,我自己一般用这种方式:MCU 上电先跑一段 BootLoader,通过串口接收 PC 端发送的 bin 文件,然后按扇区擦除、逐页写入 Flash。关键代码是擦写流程,W25Q128 的写入有页限制,一页是 256 字节,跨页写要拆开处理,不然数据会回绕到页开头,造成字模错乱。下面这段是扇区擦除和单页写入的核心驱动:
void SPI_Flash_EraseSector(uint32_t addr) { CS_LOW(); SPI_WriteByte(0x06); // 写使能命令 CS_HIGH(); CS_LOW(); SPI_WriteByte(0x20); // 扇区擦除命令 SPI_WriteByte((addr >> 16) & 0xFF); SPI_WriteByte((addr >> 8) & 0xFF); SPI_WriteByte(addr & 0xFF); CS_HIGH(); while (SPI_Flash_ReadStatus() & 0x01); // 等待 BUSY 位清零 } void SPI_Flash_ProgramPage(uint32_t addr, const uint8_t *buf, uint16_t len) { CS_LOW(); SPI_WriteByte(0x02); // 页编程命令 SPI_WriteByte((addr >> 16) & 0xFF); SPI_WriteByte((addr >> 8) & 0xFF); SPI_WriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { SPI_WriteByte(buf[i]); } CS_HIGH(); while (SPI_Flash_ReadStatus() & 0x01); // 等写完成 }EraseSector里的0x06是写使能命令,NOR Flash 的特性是每次写操作前必须发写使能,否则命令被忽略。擦除命令0x20针对 4KB 扇区,执行时间比写入长不少,所以后面用一个忙等待循环。ProgramPage里的0x02是页编程命令,len最大只能传 256,超过 256 就得改地址重新发命令,这个限制在 BootLoader 里尤其要注意,稍不注意就会发生写入回绕。
在线升级时建议一页一页校验,每写完一页就读回来和缓冲区对比。虽然会拖慢速度,但能第一时间发现地址错位或者 SPI 时序问题。批量烧录时很少有人用这套流程,都是烧录器直写后整板测试,但我个人经验是:调试阶段宁可慢一点,把校验逻辑写好,能把后面排查问题的时间省回来好几倍。
4. 核心代码拆解:从 SPI 读字模到 LCD 上渲染出一个汉字的完整链路
4.1 SPI Flash 读取驱动:最常用的 03h 连续读命令
读取 W25Q128 最常用的是0x03命令,也叫 Read Data。它不需要写使能,发完地址后直接从当前地址开始连续输出数据,MCU 只要持续提供时钟就能不断读出字节,非常适合一次读 32 字节字模的场景。下面是一个标准的多字节读取函数:
void SPI_Flash_Read(uint32_t addr, uint8_t *buf, uint32_t len) { CS_LOW(); // 片选拉低,选中 Flash SPI_WriteByte(0x03); // Read Data 命令 SPI_WriteByte((addr >> 16) & 0xFF);// 24 位地址,先发高字节 SPI_WriteByte((addr >> 8) & 0xFF); SPI_WriteByte(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = SPI_ReadByte(); // 每读一个字节,SPI 时钟推进一步 } CS_HIGH(); // 片选拉高,结束本次传输 }这里有个细节:地址是 24 位的,因为 W25Q128 容量 16MB,刚好 2 的 24 次方。发送顺序一定是高字节在前,如果发反了,Flash 会把低 8 位当成地址高位,读出来的数据就完全错位。SPI_WriteByte和SPI_ReadByte是 HAL 层的封装,底层就是往 SPI 数据寄存器写一个字节同时触发时钟,具体实现取决于你用标准外设库还是 HAL 库,接口不变。
还有一个实际优化点:如果 LCD 显示大量文字,每次读一个汉字调一次SPI_Flash_Read,片选频繁拉上拉下会有额外开销。常见做法是先把一行要显示的所有汉字的字模连续读出来,再统一交给 LCD 渲染。比如一行能显示 10 个汉字,那就一次读出 320 字节,分 10 次渲染,SPI 总线效率能提升不少。这个优化在首版跑通之后再考虑,别一开始就把逻辑搞复杂。
4.2 GB2312 内码到 Flash 地址的换算公式
字库存进去只是第一步,关键是怎么由字符串里的汉字编码换算到 Flash 里的偏移地址。GB2312 编码规则是:汉字内码高字节从 0xB0 到 0xF7,低字节从 0xA1 到 0xFE,按区位排列。设计字库 bin 时我们从 0xB0A1(也就是"啊"字)开始顺序存放,所以换算公式就是先算区位偏移、再乘以每个汉字 32 字节:
#define FONT_ASCII_BASE 0x000000 // ASCII 字库起始地址 #define FONT_GB2312_BASE 0x000800 // 汉字字库起始地址 #define GB2312_CHAR_SIZE 32 // 16x16 点阵 32 字节 uint32_t GB2312_GetFontAddr(uint16_t gb_code) { uint8_t hi = gb_code >> 8; uint8_t lo = gb_code & 0xFF; if (hi == 0) { // 当函数名是 UTF-16 编码时会走到这里,实际调用前已转成 GB2312 return FONT_ASCII_BASE + (uint32_t)lo * 16; } // 汉字区偏移 = (区号 - 16) * 94 + (位号 - 1) uint32_t index = ((uint32_t)(hi - 0xB0) * 94U) + (uint32_t)(lo - 0xA1); return FONT_GB2312_BASE + index * GB2312_CHAR_SIZE; }// 调用示例:显示字符串时逐个取编码 const char *str = "你好"; while (*str) { if ((uint8_t)*str < 0x80) { // ASCII 字符处理略 str++; } else { uint16_t code = ((uint16_t)(uint8_t)str[0] << 8) | (uint8_t)str[1]; uint32_t addr = GB2312_GetFontAddr(code); // 从 addr 读取 32 字节,渲染 16x16 字模 str += 2; } }公式里的hi - 0xB0计算的是区偏移,因为 GB2312 汉字从 16 区开始,编码 0xB0 对应 16 区;lo - 0xA1是位偏移,每区最多 94 个汉字。两个偏移组合成线性索引,再乘 32 字节就是汉字在字库 bin 中的相对位置,加上FONT_GB2312_BASE就是 Flash 里的绝对地址。这里最容易写错的是区偏移要不要减 1,我的经验是抓住一个基准验证:0xB0A1 是第一个汉字,代入公式算出来 index 必须是 0,任何减了 16 又减 1 的写法都会让小字偏移 32 字节。
4.3 LCD 渲染:16x16 点阵的描点与缓存刷新
字模地址算出来之后,剩下的活就是 SPI 读 32 字节、然后把每个为 1 的像素点画到 LCD 上。不同的 LCD 驱动和接口差异很大,但渲染逻辑是通用的。下面这段代码假设 LCD 是带显存、支持窗口刷新模式的 SPI 显示屏,核心是把字模按行扫描拆成高低字节:
void LCD_Draw16x16(uint16_t x, uint16_t y, const uint8_t *font) { for (uint16_t row = 0; row < 16; row++) { uint8_t high = font[row * 2]; // 左 8 像素 uint8_t low = font[row * 2 + 1]; // 右 8 像素 for (uint8_t bit = 0; bit < 8; bit++) { if (high & (0x80 >> bit)) { LCD_SetPixel(x + bit, y + row); // 左半区 } if (low & (0x80 >> bit)) { LCD_SetPixel(x + 8 + bit, y + row); // 右半区 } } } LCD_RefreshWindow(x, y, 16, 16); }LCD_SetPixel具体实现取决于屏的控制芯片,常见做法是更新显存数组里对应的字节位置,比如 1bit 模式的屏,就是把显存数组第y * width / 8 + x / 8字节的某个 bit 置 1 或清零。LCD_RefreshWindow是窗口刷新,把从 (x, y) 开始的 16x16 矩形区域数据一次性推到屏幕,这样比全屏刷新省流量,连续显示多个汉字时不会闪烁。
整条链路的调用关系是:应用层拿到 UTF-8 字符串,先转成 GB2312,逐个字符取编码;GB2312_GetFontAddr算出 Flash 地址;SPI_Flash_Read读出 32 字节字模;LCD_Draw16x16渲染到屏幕。这个链路非常规整,每个环节都是独立函数,调试时可以从后往前验证:先在固定地址读字模,直接在屏幕上画一个已知汉字,确认显示驱动 OK;再验证编码换算,确认字符串转地址 OK;最后串起来跑通完整流程。
设计上我习惯把"取字模"和"显示字模"拆成两个模块,方便以后换字库格式或者换屏。比如后续想支持 24x24 字号,只需在取字模模块里加一个字号分支,显示模块的渲染循环增加一个LCD_Draw24x24,两个模块的接口不变。这种松耦合的做法规避了以后重新整体测试的麻烦。
5. 常见问题排查:读回 0xFF、乱码和烧录翻车的处理记录
5.1 SPI 读回清一色 0xFF
现象:写了个读 ID 的小测试,调用 SPI Flash 的 0x9F 命令读 JEDEC ID,返回永远是 0xFF 0xFF 0xFF,或者读任何地址的数据都是 0xFF。
原因:最常见的是片选根本没拉低,或者 MISO 线没接对。W25Q128 的 MISO 引脚悬空时,SPI 从设备读到的一直是高电平,也就是 0xFF。还有一个隐蔽原因是 SPI 模式配置错误,Flash 支持 Mode 0 和 Mode 3,如果 MCU 配成了 Mode 1 或 Mode 2,时钟相位不匹配,数据采样点落在错误的边沿上,读出来的全是乱码甚至恒定 0xFF。
解决:先不要读字库,直接读 JEDEC ID,命令是 0x9F,正常返回值前三个字节应该是 0xEF 0x40 0x18。如果读不对,用示波器量 CS 和 CLK 引脚,确认片选确实拉低了;再用万用表通断档量 MCU 的 MISO 引脚和 Flash 的 DO 引脚是不是真的连通。SPI 模式把 CPOL 和 CPHA 都置 0,试完还不行就都置 1,两种模式 Flash 都支持。
5.2 汉字乱码、ASCII 正常
现象:英文字母和数字显示完全正常,但凡是中文,显示出来的点阵乱七八糟,像是一堆随机像素。
原因:ASCII 正常说明 SPI 通路、LCD 渲染逻辑都没问题,问题出在字库地址换算或者字库 bin 的内容上。一个非常典型的翻车场景是:生成字库脚本里用的字体文件缺字,某个汉字渲染位图是空的,写进 bin 的是全 0 点阵,显示出来自然是空白或乱码。另一个更隐蔽的是脚本循环范围写错,把 0xF8 开头的码位也写进去,导致后续所有汉字的地址整体偏移 32 字节的整数倍,显示全乱。
解决:先固定一个汉字验证。比如在代码里写死读取 0xB0A1(啊字)的字模地址,用十六进制打印前 32 字节,对比取模软件里"啊"字的正确点阵。如果数据对不上,说明字库 bin 生成环节有问题,回头检查脚本的码位范围和字体文件;如果数据对了但显示还是乱,说明渲染逻辑或取模方向不匹配,重点检查LCD_Draw16x16里的高低字节拆解方式。
5.3 字模整体偏移导致字迹错位
现象:汉字能认出来,但线条上下断裂,或者点左右镜像,有些笔画跑到不该在的位置。
原因:这是典型的取模方向不一致。生成脚本里是按"横向取模、每行高低两字节"排列的,但显示端可能按"每列上下两字节"解析,或者字节内 bit 顺序反了。比如显示端把高字节的 bit0 当成最左边的列,而生成端把 bit7 当成最左边,那整个字的每一行都会左右翻转。
解决:在脚本里加上点阵打印函数,把字节转换成 16x16 的 0/1 矩阵,用 ASCII 打印出来,立刻能看出取模方向对不对。确认生成侧是标准的"行优先、高位在左"之后,再去检查渲染侧的像素映射,两边对齐就能消除错位。这类问题别靠猜,写个自检函数在串口终端打印点阵,一目了然。
5.4 断电丢字库与烧录不校验
现象:烧录完当时显示正常,板子断电重启后某些汉字变成空白或者乱码,重复烧录几次,出问题的汉字位置还不固定。
原因:多半是烧录过程本身有概率性失败。在线升级时如果擦除和写入分开执行,中途断电就会留下半擦半写的扇区;另一个常见原因是页编程长度的越界问题,数据跨过 256 字节页边界时没有换页,Flash 会把数据回绕到页开头,写进去的内容就是错位的。
解决:烧录逻辑必须做"擦除后校验、写入后读回校验"两个步骤。每次扇区擦除后读回确认是全 0xFF,每次页编程后逐字节比对。哪怕慢一点,也要保证数据完全落盘再执行下次操作。从那以后我做任何 Flash 写入流程,都会强制加一条读回校验的断言,出问题能立刻定位到是擦除还是写入阶段。
5.5 读字模速度慢导致 LCD 刷新拖影
现象:显示一段长文本时,能明显看到字是一个一个蹦出来的,滚动显示时拖影严重。
原因:每次显示一个汉字就单独发起一次 SPI 传输,片选拉高拉低的开销加上 Flash 命令字节和 3 字节地址的传输,实际有效数据率只有 70% 左右。文本越长,开销越明显。
解决:把一行要显示的所有字模连续读进缓冲区,再一次给 LCD 送去渲染。比如一行 10 个汉字,一次读 320 字节,平均分摊到每个字上的命令开销就少了很多。实测在 18MHz SPI 时钟下,优化后整行刷新时间能缩短 30% 以上。另外能启用 Flash 的 Fast Read 命令(0x0B)就启用,虽然要多发一个 dummy 字节,但在高速时钟下时序更稳定。
6. 二次开发思路:把固定字库改造成多字号、多语言的通用显示组件
6.1 字号扩展:24x24、32x32 字库的共存方案
项目做到后期,需求往往不只是 16x16 一种字号。我一般把每个字号生成一个独立的 bin 文件,在 Flash 地址分区表里给每个字号固定一段区域,取字模模块里加一个参数来切换字号。核心改动是地址换算函数增加一个字号枚举:
typedef enum { FONT_16 = 0, FONT_24 = 1, FONT_32 = 2 } FontSize_t; uint32_t Font_GetAddr(FontSize_t size, uint16_t gb_code) { uint32_t base = (size == FONT_24) ? FONT_24_BASE : (size == FONT_32) ? FONT_32_BASE : FONT_GB2312_BASE; uint32_t char_size = (size == FONT_24) ? 72 : // 24x24 = 72 字节 (size == FONT_32) ? 128 : 32; // 32x32 = 128 字节 uint32_t index = ((uint32_t)(gb_code >> 8) - 0xB0) * 94U + ((uint32_t)(gb_code & 0xFF) - 0xA1); return base + index * char_size; }生成 24x24 字库时脚本只要把FONT_SIZE改成 24,输出文件名同步修改,烧录到对应的FONT_24_BASE。显示端需要对应的LCD_Draw24x24渲染函数,循环从 16 行变成 24 行,其余逻辑完全一致。字号共存的核心经验是:保持地址公式的线性结构不变,字号参数只影响基地址和单个字符字节数,这样后续加 48x48 甚至 64x64 也只是一行宏定义的事。
6.2 UTF-8 输入源的编码转换
现代项目里字符串常常是从上位机或者文件系统里读来的 UTF-8 编码,直接拿char *里的字符去算 GB2312 地址,肯定全乱。我在显示组件里加了一层统一入口,显示字符串之前先做一次编码检测加转换:
uint16_t UTF8_To_GB2312(const uint8_t *str) { if (str[0] < 0x80) return str[0]; // ASCII if ((str[0] & 0xE0) == 0xC0 && (str[1] & 0xC0) == 0x80) { // 2 字节 UTF-8,转为 Unicode 再映射 GB2312 uint16_t unicode = ((str[0] & 0x1F) << 6) | (str[1] & 0x3F); return Unicode_To_GB2312(unicode); } if ((str[0] & 0xF0) == 0xE0) { // 3 字节 UTF-8,常用汉字基本都在这一档 uint16_t unicode = ((str[0] & 0x0F) << 12) | ((str[1] & 0x3F) << 6) | (str[2] & 0x3F); return Unicode_To_GB2312(unicode); } return 0x20; // 转不了就返回空格 }Unicode_To_GB2312本质上是一张映射表,可以在 PC 端提前生成 C 语言数组,烧进 MCU 的常量区。整表大概几千条,存在内置 Flash 里完全放得下。转换层的好处是上层代码永远只跟 UTF-8 打交道,底层字库仍是 GB2312,兼容性不变。
6.3 自定义图标与字模混排
状态栏里的电池图标、信号图标、箭头符号,用取模软件导成点阵数据,放到 Flash 预留的图标区。显示组件里约定一个私有编码区间,比如从 0x00 到 0x1F 这 32 个编码,上层字符串里出现这些编码时,直接跳到图标区读取,不走 GB2312 查字流程。这个办法不需要改核心链路,只需要在取字模函数里加一个分支判断,数据流完全统一,混排时也不会出现显示状态切换的割裂感。
开发完这套组件之后,最大的教训是:字库这类基础模块,一定要把"数据生成—烧录—读取—渲染"四个环节做成可验证的闭环。每个环节都有独立的回归测试,哪怕只是上电自检时打印几个已知字的点阵,也能在后续几个月里省下大把定位问题的时间。希望这些拆解的思路和踩过的坑,能帮你在自己的板子上少走一圈弯路。
本文还有配套的精品资源,点击获取