简介:这份资源面向嵌入式开发初学者与STM32实践者,聚焦如何借助W25Q128串行闪存存储汉字字库,并在LCD上完成汉字显示。项目以SPI通信读取字库数据,配合LCD驱动解析与显示,涉及字库烧录地址0x1000(即4096字节起始位置)的规划,涵盖C/C++编程、SPI协议、LCD驱动设计与字库管理等知识点,适合作为嵌入式存储与外设显示的练手案例。资源包共192个文件,以36个h头文件、33个c源文件为核心,另有32个o目标文件、32个d依赖文件、31个crf交叉引用文件及Keil工程相关配置,压缩包约4.6MB,目录包含Output、User、Listing、Libraries、Doc、Project等模块,结构完整便于直接编译与二次开发。目前已有1971人学习下载,读者可据此掌握字库从烧录到显示的完整链路,理解地址规划与驱动解析思路,快速复现LCD汉字显示效果。
1. 从一块 16MB 的 Flash 说起:W25Q128 存字库驱动 LCD 显示汉字
手头有一块 2.4 寸或 3.5 寸的 TFT 屏,主控是 STM32 或 GD32,屏幕能点亮、能刷色块、能显示英文和数字,但一到显示汉字就卡住了——要么把整个字库数组塞进内部 Flash,编译出来几百 KB,芯片根本装不下;要么用 SD 卡挂载字库文件,读一次汉字要等半天,刷新率惨不忍睹。这个项目就是冲着这个场景来的:用一颗 W25Q128 SPI Flash(16MB 容量)存放完整的中文字库,MCU 通过 SPI 接口按需读取点阵数据,再送到 LCD 上渲染出汉字。W25Q128 的 16MB 空间足够放下 GB2312 全字库(约 256KB 的 16×16 点阵)甚至更大的 24×24、32×32 点阵字库,还能顺带存图片、图标和配置文件。适合正在做嵌入式 GUI、需要本地化中文显示、又不想外挂 SD 卡的开发者。下面把整个链路拆开讲清楚:字库怎么烧进去、SPI 怎么读、LCD 怎么画。
2. 硬件链路与存储规划:W25Q128 怎么和 MCU、LCD 串起来
2.1 硬件连接与 SPI 模式选择
W25Q128 是 Winbond 的 SPI NOR Flash,128Mbit 也就是 16MB,支持标准 SPI、Dual SPI 和 Quad SPI。在这个项目里,MCU 和 LCD 通常共用一组 SPI 总线或者各用一组,取决于引脚资源。常见接法是:
| 信号 | W25Q128 引脚 | MCU 引脚(以常见 STM32F103 为例) | 说明 |
|---|---|---|---|
| CS | 1 | PA4(GPIO 手动控制) | 片选,低有效 |
| DO | 2 | PA6(SPI1_MISO) | 数据输出(从 Flash 到 MCU) |
| WP | 3 | 接 3.3V | 写保护,不用时拉高 |
| GND | 4 | GND | 地 |
| VCC | 8 | 3.3V | 电源 |
| CLK | 6 | PA5(SPI1_SCK) | 时钟 |
| DI | 5 | PA7(SPI1_MOSI) | 数据输入(从 MCU 到 Flash) |
| HOLD | 7 | 接 3.3V | 保持,不用时拉高 |
SPI 模式方面,W25Q128 支持 Mode 0(CPOL=0,CPHA=0)和 Mode 3(CPOL=1,CPHA=1),实际用 Mode 0 最稳。时钟频率在初始化阶段建议先降到 1MHz 以下,等确认芯片 ID 读到了再往上拉到 18MHz 甚至更高。我一般会在初始化函数里先读一次 JEDEC ID(命令 0x9F),确认返回值是0xEF4018才继续往下走,否则直接报错卡住,免得后面读出一堆乱码还找不到原因。
LCD 这边,如果是 ILI9341 或 ST7789 这类控制器,通常也是 SPI 接口,可以挂在同一组 SPI 上,用不同的 CS 引脚区分。但要注意:Flash 读操作和 LCD 写操作不能同时进行,如果共用总线,每次切换设备都要重新配置 CS 和时序。更稳妥的做法是 LCD 用 FSMC 并口驱动,Flash 单独用 SPI,两者互不干扰。
2.2 字库文件的生成与烧录
字库不是凭空来的,得先有字模数据。常见做法是用 PC 端工具(比如字模提取软件)生成字库文件,格式一般是按区位码或 Unicode 顺序排列的二进制点阵数据。以 GB2312 的 16×16 点阵为例,每个汉字占 32 字节(16 行 × 2 字节/行),整个一级字库 3755 个汉字大约 120KB,加上二级字库 3008 个汉字,总共约 216KB。如果做 24×24 点阵,每个汉字 72 字节,全字库约 487KB。W25Q128 的 16MB 空间放这些绰绰有余。
生成字库文件后,需要把它烧录到 W25Q128 里。有两种方式:一种是用编程器直接烧录整颗 Flash;另一种是在 MCU 端写一个串口接收程序,通过串口把字库文件分块发过去,MCU 再调用 Flash 写函数逐页写入。第二种方式更灵活,不用拆板子。烧录时要注意 W25Q128 的页编程限制:每页 256 字节,跨页写入必须分多次调用写命令,否则数据会回卷覆盖。下面是一段典型的页写入代码:
// W25Q128 页写入,addr 必须 256 字节对齐,len 不超过 256 void W25Q128_PageWrite(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q128_WriteEnable(); // 发送 0x06 写使能 W25Q128_CS_Low(); SPI_ReadWriteByte(0x02); // 页编程命令 SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { SPI_ReadWriteByte(buf[i]); } W25Q128_CS_High(); W25Q128_WaitBusy(); // 等待写入完成,读状态寄存器 }这段代码里,W25Q128_WriteEnable()发送 0x06 命令置位 WEL,否则写操作会被忽略。地址是 24 位,因为 16MB 空间需要 24 根地址线。W25Q128_WaitBusy()轮询状态寄存器的 BUSY 位,确保上一次写入完成再发下一次。如果连续写入大量数据,建议每写 4KB 擦除一个扇区(扇区擦除命令 0x20),因为 Flash 只能把 1 写成 0,不能把 0 写成 1,必须先擦后写。
2.3 字库索引与寻址方式
字库烧进去之后,怎么根据一个汉字找到它在 Flash 里的偏移?常见做法是用 GB2312 编码计算:汉字在 GB2312 里用两个字节表示,第一个字节范围 0xB0-0xF7,第二个字节范围 0xA1-0xFE。偏移公式是:
// 计算 GB2312 汉字在字库中的偏移(16x16 点阵,每字 32 字节) uint32_t GetGB2312Offset(uint8_t *gbCode) { uint8_t qh = gbCode[0] - 0xB0; // 区号 uint8_t wh = gbCode[1] - 0xA1; // 位号 uint32_t index = qh * 94 + wh; // 区位索引 return index * 32; // 每个汉字 32 字节 }这里qh是区号,wh是位号,GB2312 每区 94 个位,所以索引是qh * 94 + wh。如果字库文件是从一级字库开始排列的,这个偏移就能直接用于 Flash 读取。如果字库包含二级字库或者按 Unicode 排列,公式要相应调整。我一般会在字库文件头部加一个索引表,记录每个区的起始偏移,这样查找更快,也不用每次算。
注意:GB2312 和 GBK 的编码范围不同,GBK 扩展了更多汉字。如果项目需要显示生僻字,字库要按 GBK 或 Unicode 生成,寻址方式也要改。别拿 GB2312 的公式去查 GBK 字库,出来的全是乱码。
3. 核心驱动拆解:SPI 读 Flash 到 LCD 画点的完整链路
3.1 SPI 底层读写与 Flash 读取函数
整个链路的第一步是能从 W25Q128 里把数据读出来。SPI 底层读写函数是基础,通常用 MCU 的硬件 SPI 外设,配置好时钟极性、相位、数据位宽和波特率预分频。下面是一个典型的 SPI 收发函数:
// SPI1 收发一个字节,返回接收到的数据 uint8_t SPI_ReadWriteByte(uint8_t txData) { uint8_t rxData = 0; while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 等发送缓冲区空 SPI_I2S_SendData(SPI1, txData); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); // 等接收缓冲区非空 rxData = SPI_I2S_ReceiveData(SPI1); return rxData; }这个函数是阻塞式的,每发一个字节等一次标志位。在 18MHz 时钟下,读 32 字节的汉字点阵大约需要 20 微秒左右,对于刷一整屏汉字(假设 20 个汉字)也就 400 微秒,完全够用。如果嫌慢,可以改成 DMA 传输,但代码复杂度会上升,一般项目没必要。
Flash 读取函数在此基础上封装:
// 从 W25Q128 指定地址读取 len 字节到 buf void W25Q128_Read(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q128_CS_Low(); SPI_ReadWriteByte(0x03); // 读数据命令 SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buf[i] = SPI_ReadWriteByte(0xFF); // 发送哑元,接收数据 } W25Q128_CS_High(); }0x03是标准读命令,支持任意地址连续读取,没有页限制。发送完 24 位地址后,继续发0xFF哑元就能把数据时钟出来。CS 拉高结束传输。这个函数是整个字库读取的核心,后面所有汉字显示都靠它。
3.2 汉字点阵解析与 LCD 绘制
拿到 32 字节的点阵数据后,需要逐位解析并画到 LCD 上。16×16 点阵的存储格式通常是每行 2 字节,共 16 行。每个字节的 8 个位对应 8 个像素,位为 1 画前景色,位为 0 画背景色(或者不画,保留背景)。下面是一个典型的绘制函数:
// 在 LCD 指定位置显示一个 16x16 汉字 // x, y: 起始坐标; gbCode: 汉字 GB2312 编码; fc, bc: 前景色和背景色 void LCD_ShowChinese16(uint16_t x, uint16_t y, uint8_t *gbCode, uint16_t fc, uint16_t bc) { uint8_t dotBuf[32]; uint32_t offset = GetGB2312Offset(gbCode); W25Q128_Read(FONT_BASE_ADDR + offset, dotBuf, 32); // 从 Flash 读点阵 for (uint8_t row = 0; row < 16; row++) { uint8_t highByte = dotBuf[row * 2]; uint8_t lowByte = dotBuf[row * 2 + 1]; for (uint8_t col = 0; col < 8; col++) { // 高字节对应左边 8 列 if (highByte & (0x80 >> col)) { LCD_DrawPoint(x + col, y + row, fc); } else { LCD_DrawPoint(x + col, y + row, bc); } // 低字节对应右边 8 列 if (lowByte & (0x80 >> col)) { LCD_DrawPoint(x + 8 + col, y + row, fc); } else { LCD_DrawPoint(x + 8 + col, y + row, bc); } } } }这段代码的逻辑很直白:先算偏移,从 Flash 读 32 字节,然后双重循环逐位判断。0x80 >> col是从高位到低位依次取位,因为字模数据通常是高位在前。LCD_DrawPoint是 LCD 底层画点函数,不同屏幕控制器实现不同,但接口一致。如果觉得逐点画太慢,可以优化成按行填充:把一行 16 个像素拼成两个字节的颜色数据,直接通过 LCD 的连续写函数发送,速度能快好几倍。
3.3 字符串混合显示与自动换行
实际项目里不会只显示单个汉字,通常是一串中英文混合的字符串。这时候需要判断当前字节是 ASCII 还是汉字:ASCII 字符范围 0x20-0x7E,占 1 字节,用 8×16 字库;汉字两个字节都大于 0x80,用 16×16 字库。下面是一个混合显示函数的框架:
// 显示中英文混合字符串,支持自动换行 void LCD_ShowString(uint16_t x, uint16_t y, char *str, uint16_t fc, uint16_t bc) { uint16_t curX = x, curY = y; while (*str) { if (*str < 0x80) { // ASCII 字符,显示 8x16 字模 LCD_ShowChar8x16(curX, curY, *str, fc, bc); curX += 8; str++; } else { // 汉字,显示 16x16 字模 LCD_ShowChinese16(curX, curY, (uint8_t *)str, fc, bc); curX += 16; str += 2; } // 自动换行:超出屏幕宽度就换到下一行 if (curX + 16 > LCD_WIDTH) { curX = x; curY += 16; } } }这里的关键是判断字节大小来区分中英文,以及维护curX和curY两个光标变量。换行逻辑是当剩余宽度不够一个汉字时换行。如果项目需要更复杂的排版(比如居中、右对齐),可以在此基础上加一个预扫描函数先算总宽度,再决定起始x坐标。
提示:ASCII 字符用 8×16 字模时,如果和 16×16 汉字混排,基线可能对不齐。常见做法是把 ASCII 字模也做成 16×16 的,或者调整
curY偏移。我一般会在 ASCII 显示函数里加一个yOffset参数,方便微调。
4. 避坑与排查:字库显示翻车现场记录
4.1 现象:屏幕显示乱码或方块,但英文正常
原因通常是字库偏移算错了,或者字库文件本身不是按 GB2312 顺序排列的。有些字模软件默认按 Unicode 排序,而代码里用的是 GB2312 公式,两者对不上。解决方法是先用一个已知汉字(比如“中”)手动计算偏移,从 Flash 里读出 32 字节,和字模软件生成的数据对比,确认排列顺序一致。如果不一致,要么改字库生成设置,要么改寻址公式。
4.2 现象:Flash 读出来的数据全是 0xFF 或 0x00
先检查 SPI 通信是否正常:读 JEDEC ID 能不能返回0xEF4018。如果 ID 都不对,说明硬件连接或 SPI 模式有问题。常见原因是 CS 引脚没有正确拉低,或者 SPI 时钟极性设错。另外,W25Q128 的 HOLD 和 WP 引脚如果悬空,也可能导致通信异常,建议都拉高到 3.3V。如果 ID 正常但读数据不对,检查地址是否超出了字库烧录的范围,或者字库根本没烧进去。
4.3 现象:写入字库时部分数据丢失或回卷
这是典型的页编程跨页问题。W25Q128 每页 256 字节,如果一次写入超过 256 字节或者地址没有页对齐,多出来的数据会回到页首覆盖。解决方法是把写入数据按 256 字节分块,每块地址对齐到页边界。另外,每次写入前必须发写使能命令,写入后必须等 BUSY 位清零,否则下一次写入会被忽略。
4.4 现象:汉字显示速度慢,刷屏有拖影
逐点画的方式在 16×16 汉字下每个字要画 256 个点,如果 LCD 的DrawPoint函数效率低(比如每次都设坐标、发命令),刷一屏会明显卡顿。优化方向有两个:一是把DrawPoint改成按行连续写,利用 LCD 控制器的自动地址递增;二是把字库读到 RAM 后先解析成颜色数组,再一次性发送。我一般会优先改 LCD 驱动,把画点函数优化成区域填充,效果立竿见影。
4.5 现象:换了一块 Flash 或换了批次,字库读出来不对
不同批次的 W25Q128 在擦除和写入时序上可能有细微差异,尤其是等待 BUSY 的时间。如果代码里用的是固定延时而不是轮询 BUSY 位,换芯片后可能因为时序不匹配导致写入失败。解决方法是统一用W25Q128_WaitBusy()轮询状态寄存器,不要用delay_ms硬等。另外,有些兼容芯片的 JEDEC ID 不是0xEF4018,如果代码里硬编码了这个 ID 做判断,换芯片后会直接卡在初始化。建议把 ID 判断改成可配置或者只做警告不做拦截。
5. 二次开发与扩展:从单字库到多字号、多字库的动态切换
项目跑通之后,下一步通常是扩展。比如产品需要同时支持 16×16 和 24×24 两种字号,或者需要中英文以外的第三语言字库。这时候如果每次切换字号都重新烧录整颗 Flash,效率太低。我的做法是在 W25Q128 里划分多个区域,每个区域放一种字库,用一个配置表记录每种字库的起始地址、单字字节数、宽高信息。下面是一个配置表的结构示例:
typedef struct { uint32_t baseAddr; // 字库在 Flash 中的起始地址 uint8_t width; // 汉字宽度(像素) uint8_t height; // 汉字高度(像素) uint8_t bytesPerChar; // 每个汉字占用的字节数 uint8_t encodeType; // 编码类型:0=GB2312, 1=GBK, 2=Unicode } FontInfo; // 字库配置表,放在 RAM 或 Flash 的固定位置 const FontInfo fontTable[] = { {0x000000, 16, 16, 32, 0}, // 16x16 GB2312 字库 {0x040000, 24, 24, 72, 0}, // 24x24 GB2312 字库 {0x0C0000, 32, 32, 128, 0}, // 32x32 GB2312 字库 };这样切换字号时只需要改一个索引,显示函数根据FontInfo里的宽高和字节数动态计算偏移和绘制循环。绘制函数也要相应改成通用版本,不再硬编码 16×16:
// 通用汉字显示函数,根据 FontInfo 动态绘制 void LCD_ShowChinese(uint16_t x, uint16_t y, uint8_t *gbCode, FontInfo *font, uint16_t fc, uint16_t bc) { uint8_t dotBuf[128]; // 最大支持 32x32 点阵(128 字节) uint32_t offset = GetFontOffset(gbCode, font); W25Q128_Read(font->baseAddr + offset, dotBuf, font->bytesPerChar); uint8_t bytesPerRow = font->width / 8; // 每行字节数 for (uint8_t row = 0; row < font->height; row++) { for (uint8_t col = 0; col < font->width; col++) { uint8_t byte = dotBuf[row * bytesPerRow + col / 8]; if (byte & (0x80 >> (col % 8))) { LCD_DrawPoint(x + col, y + row, fc); } else { LCD_DrawPoint(x + col, y + row, bc); } } } }这个通用函数里,bytesPerRow根据宽度动态算,col / 8定位到字节,col % 8定位到字节内的位。dotBuf大小按最大字号开,32×32 点阵是 128 字节,栈上开这个数组没问题。如果以后要支持 48×48 甚至 64×64,把dotBuf改大或者改成动态分配就行。
另一个扩展方向是字库压缩。16MB 看着大,但如果要存多国语言、多字号,再加上图片和音频,空间也会紧张。常见做法是对点阵数据做 RLE 压缩或者用稀疏存储只存有笔画的区域。不过压缩会增加解码时间,在 MCU 上要权衡。我一般会先算一下实际用量:GB2312 全字库 16×16 才 216KB,24×24 约 487KB,32×32 约 866KB,三种加起来不到 1.6MB,W25Q128 完全放得下,没必要压缩。真正占空间的是图片资源,那个另说。
最后说一个我踩过的坑:字库烧录时一定要留一份原始 bin 文件的备份,并且记录烧录的起始地址和长度。有一次我调偏移公式调了半天,最后发现是烧录地址写错了,字库整体偏移了 4KB,所有汉字都显示成隔壁的字。从那以后我每次烧录字库都强制走一遍校验流程:烧完后随机读三个汉字的数据,和 PC 端文件对比,确认无误再往下调显示代码。希望帮到你。
本文还有配套的精品资源,点击获取