简介:这份资源面向嵌入式开发初学者与STM32实践者,围绕W25Q128串行闪存存储汉字字库并在LCD上显示这一典型场景,提供一套可直接编译运行的Keil工程。内容涵盖SPI通信读写、字库烧录地址规划(从0x1000即4096字节处开始)、LCD驱动解析与汉字取模显示等关键环节,帮助读者理解外部Flash存储字库的完整链路。压缩包共192个文件,以c源文件与h头文件为主体,配合Keil编译产生的o、d、crf等中间文件,以及uvprojx工程文件、hex烧录文件、map映射表和keilkill.bat清理脚本,整体约4.6MB,工程目录按Output、User、Libraries、Doc等模块划分,结构规整便于对照学习。目前已有1971人学习下载,适合作为字库存储与LCD汉字显示的入门参考工程。
1. 一块 16MB 的 Flash 怎么把汉字塞进 LCD:W25Q128 字库方案落地记
手上有一块 128×64 的单色 LCD,主控是常见的 STM32F103,RAM 只有 20KB,Flash 也就 64KB。想在上面显示「温度:26.5℃」这种带中文的界面,第一反应是把字库数组直接编进程序——GB2312 一级字库 3755 个汉字,16×16 点阵每个字 32 字节,光字模就 120KB 出头,链接器直接报 overflow。这个场景下 W25Q128 就是救命的那块料:16MB SPI Flash,按扇区擦写,把字库当外部只读存储挂上去,MCU 只留一个 32 字节的取模缓冲区,RAM 和片内 Flash 同时松绑。这套「W25Q128 存储字库 + LCD 显示汉字」的方案,适合所有片内资源吃紧、又要做中文人机界面的嵌入式项目,从工业仪表到智能家居面板都能套。下面按我实际拆过的顺序,把硬件接线、字库烧录、取模读取、LCD 刷屏这条链路一段段讲透,最后落到怎么换成自己的字体和字号。
2. 方案架构与硬件链路:SPI Flash 挂字库的整体设计
2.1 为什么把字库放到片外 Flash
先说清楚这个方案的取舍。汉字点阵数据是典型的「读多写少、体量大、访问有规律」的数据:一个 16×16 汉字 32 字节,一次显示一行 8 个汉字就是 256 字节的连续读取,访问模式非常固定。这种数据放在片内 Flash 会挤占代码空间,放在 RAM 更不现实,而 W25Q128 的 16MB 容量装下 GB2312 全集(6763 个汉字,16×16 点阵约 216KB)绰绰有余,还能顺带塞 24×24、32×32 多套字号和 ASCII 半角字模。
代价是访问速度。W25Q128 标准 SPI 读时钟常见跑到 40MHz 以上,单字节读取要发命令+地址+读数据,一次 32 字节的字模读取大概几十微秒,刷一屏 8 个汉字在毫秒级,人眼完全无感。真正要注意的是它按扇区(4KB)擦除、按页(256 字节)写入的特性——字库是烧一次读无数次,所以写入慢不是问题,读取路径才是优化重点。
| 对比项 | 片内 Flash 存字库 | W25Q128 外挂字库 |
|---|---|---|
| 占用片内资源 | 120KB+,多数小容量 MCU 放不下 | 仅占几十字节 RAM 缓冲 |
| 容量扩展 | 受芯片型号限制 | 16MB,可放多套字号 |
| 读取速度 | 极快,直接寻址 | 需 SPI 命令,微秒级 |
| 更新字库 | 需重新烧录固件 | 可单独烧写 Flash 区域 |
2.2 硬件连接与 SPI 配置
W25Q128 走标准四线 SPI:CS、CLK、MOSI、MISO,加上 VCC 和 GND。我一般把 CS 单独接一个 GPIO 方便软件控制片选,其余三线接 MCU 的 SPI1。注意 W25Q128 是 3.3V 器件,和 STM32 电平匹配,但如果你的主控是 5V 系统,中间必须加电平转换,否则通信会时好时坏——这个坑后面避坑章节还会提。
// W25Q128 SPI 初始化(以 STM32 标准库为例,HAL 库逻辑一致) void W25QXX_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // SCK(PA5) / MOSI(PA7) 复用推挽输出 gpio.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); // MISO(PA6) 上拉输入 gpio.GPIO_Pin = GPIO_Pin_6; gpio.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &gpio); // CS(PA4) 普通推挽输出,软件控制 gpio.GPIO_Pin = GPIO_Pin_4; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); SPI1->CR1 = 0; // 先清零 SPI1->CR1 |= SPI_CR1_MSTR; // 主机模式 SPI1->CR1 |= SPI_CR1_BR_Div8; // 72MHz/8 = 9MHz,先保守跑 SPI1->CR1 |= SPI_CR1_SSM | SPI_CR1_SSI; // 软件片选 SPI1->CR1 |= SPI_CR1_SPE; // 使能 SPI W25QXX_CS_HIGH(); // 片选拉高,空闲态 }这段初始化的关键参数是分频系数。SPI1 挂在 APB2 上通常 72MHz,BR_Div8得到 9MHz,是调试阶段最稳的档位;等通信验证通过,可以改到BR_Div2(36MHz)甚至更高来提速。SSM|SSI是软件片选模式,因为 CS 用普通 GPIO 控制,比硬件 NSS 灵活。片选拉高是必须的,W25Q128 在 CS 为低时才会响应命令,空闲态拉高能避免误触发。
2.3 字库在 Flash 里的地址规划
字库不是随便往 Flash 里一扔就行,得先定好地址布局,后面取模代码全靠这套约定。我习惯把 16MB 空间切成几块:0x000000 起放 ASCII 8×16 半角字模,0x010000 起放 GB2312 16×16 汉字,0x100000 起放 24×24,0x400000 起放 32×32。每块之间留足空隙,方便以后加字号不用挪数据。
GB2312 的寻址有个经典公式:汉字在字库里的偏移 = ((区码 - 0xA1) × 94 + (位码 - 0xA1)) × 每字字节数。区码位码来自汉字 GB2312 编码的高字节和低字节。这个公式是整套方案的核心索引逻辑,取模函数里会反复用到,务必记牢。
3. 字库烧录与读取:从 PC 端生成 bin 到 MCU 取模
3.1 用取模软件生成字库 bin 文件
字库数据本身得先在 PC 上生成。常见做法是用字模提取软件(比如 PCtoLCD2002 这类工具),设置好字体、字号、取模方式(阴码/阳码、逐行/逐列、顺向/逆向),导出成二进制文件。这里有个血泪经验:取模方式必须和 LCD 驱动的刷屏方式严格对应,否则显示出来是乱码或者镜像字。我一般统一用「阴码、逐行式、逆向(低位在前)」,因为多数单色 LCD 的页地址模式就是低位在前。
生成时按字号分文件:ascii8x16.bin、gb2312_16x16.bin、gb2312_24x24.bin。每个文件的大小要能对上账——16×16 汉字每字 32 字节,6763 个汉字约 216KB,文件大小不对说明取模参数错了,别急着烧。
3.2 把 bin 烧进 W25Q128 的两种路子
烧录有两条路。第一条是 PC 端用编程器直接烧 W25Q128 芯片,适合量产;第二条是 MCU 通过 SPI 自己写,适合开发阶段反复调。开发阶段我一般写个临时函数,把字库数组分段写进 Flash,验证完再删掉。
// 通过 SPI 把一段字库数据写入 W25Q128 指定地址 // addr 必须是 4KB 扇区对齐的起始地址,len 不超过扇区大小 void W25QXX_WriteFontLib(uint32_t addr, const uint8_t *buf, uint16_t len) { W25QXX_WriteEnable(); // 写使能,必须每次擦写前发 W25QXX_SectorErase(addr); // 先擦除整个 4KB 扇区 W25QXX_WaitBusy(); // 等待擦除完成,典型 45ms for (uint16_t i = 0; i < len; i += 256) { uint16_t chunk = (len - i > 256) ? 256 : (len - i); W25QXX_PageProgram(addr + i, buf + i, chunk); // 按页写,每页最多 256 字节 W25QXX_WaitBusy(); } }逻辑说明:W25Q128 写入前必须先发WriteEnable(0x06),否则写命令被忽略;擦除按 4KB 扇区,SectorErase(0x20)后要等忙标志清零;写入按 256 字节页,跨页必须拆成多次PageProgram(0x02),否则地址会在页内回卷,数据写错位置。参数addr一定要扇区对齐,len控制单次写入量,实际烧字库时循环调用即可。
3.3 按 GB2312 编码取一个汉字的点阵
这是整套方案最核心的一段代码:给一个汉字的内码,算出它在 Flash 里的偏移,读出 32 字节点模。
// 从 W25Q128 读取一个 16x16 汉字的点阵,存入 buf(32 字节) // hz_code: 汉字 GB2312 内码,高字节区码,低字节位码 void Font_GetHZ16(uint8_t *hz_code, uint8_t *buf) { uint8_t qu = hz_code[0] - 0xA1; // 区码,0~93 uint8_t wei = hz_code[1] - 0xA1; // 位码,0~93 uint32_t offset = ((uint32_t)qu * 94 + wei) * 32; // 每字 32 字节 uint32_t addr = FONT_HZ16_BASE + offset; // 基址 + 偏移 W25QXX_Read(addr, buf, 32); // 连续读 32 字节 }逻辑说明:qu和wei把内码映射到 0~93 的区/位索引,qu * 94 + wei得到该字在字库中的序号,乘 32 得到字节偏移,加上字库基址FONT_HZ16_BASE(前面规划的 0x010000)就是绝对地址。W25QXX_Read内部发0x03读命令+3 字节地址,然后连续读 32 字节。参数hz_code是两字节内码数组,buf至少 32 字节。ASCII 半角字模同理,只是索引直接用字符码减 0x20,每字 16 字节。
4. LCD 刷屏与显示封装:把点阵画到屏上
4.1 单色 LCD 的页地址写入
点阵读出来了,怎么画到 LCD 上取决于屏的驱动方式。128×64 单色屏常见 SSD1306 或 ST7565 这类控制器,采用页地址模式:屏幕纵向分 8 页,每页 8 行像素,横向 128 列。写一个 16×16 汉字要占 2 页 × 16 列,每列 2 字节(上下各一页)。刷屏时先设好页地址和列地址,然后连续写列数据。
// 在 (x, page) 位置显示一个 16x16 汉字 // buf 为 Font_GetHZ16 读出的 32 字节点阵 void LCD_ShowHZ16(uint8_t x, uint8_t page, uint8_t *buf) { for (uint8_t col = 0; col < 16; col++) { LCD_SetPos(x + col, page); // 设置列地址和第 page 页 LCD_WriteData(buf[col * 2]); // 上半页 8 行 LCD_SetPos(x + col, page + 1); // 下一页 LCD_WriteData(buf[col * 2 + 1]); // 下半页 8 行 } }逻辑说明:16×16 汉字纵向跨 2 页,所以每列要写 2 字节。buf[col*2]是该列上半页 8 个像素,buf[col*2+1]是下半页。LCD_SetPos负责发页地址和列地址命令,具体命令字取决于你的屏控制器。参数x是起始列(0~127),page是起始页(0~7),注意page+1不能越界,显示在最后一行时要提前判断。
4.2 中英文混排的字符串显示函数
实际界面里中英文是混在一起的,得写个能自动识别编码的显示函数:遇到 ASCII(<0x80)按半角 8×16 显示,遇到汉字(>=0x80)按 16×16 显示,并自动推进光标。
// 在 (x, page) 开始显示字符串,自动处理中英文混排 // 返回显示结束后的 x 坐标,方便续接 uint8_t LCD_ShowString(uint8_t x, uint8_t page, const uint8_t *str) { uint8_t buf[32]; while (*str) { if (*str < 0x80) { // ASCII 半角 Font_GetASCII16(*str, buf); // 读 16 字节半角字模 LCD_ShowASCII8x16(x, page, buf); x += 8; // 半角宽 8 像素 str++; } else { // 汉字,两字节内码 Font_GetHZ16((uint8_t *)str, buf); LCD_ShowHZ16(x, page, buf); x += 16; // 全角宽 16 像素 str += 2; } if (x > 120) break; // 简单防越界 } return x; }逻辑说明:判断首字节最高位区分 ASCII 和汉字,这是 GB2312 编码的基本特征。ASCII 推进 8 像素,汉字推进 16 像素并跳过 2 字节。buf复用同一块 32 字节缓冲,省 RAM。参数str是 GB2312 编码的字符串,注意源文件编码要存成 GB2312 而不是 UTF-8,否则内码对不上字库——这个坑非常常见,后面细说。
4.3 显示缓存的取舍
有人会问要不要在 RAM 里建一整屏显存(128×64/8 = 1KB),改完再整屏刷。我的建议是:RAM 紧张(20KB 级别)就别建,直接按需刷局部;RAM 宽裕或者要做动画、滚动,建显存更省心,因为局部刷屏频繁设地址本身也耗时。这套方案默认走「无显存、按需刷」路线,配合 W25Q128 的快速读取,实测刷一屏中文在 10ms 以内,够用。
5. 避坑与排查:这套方案最容易翻车的五个地方
5.1 显示乱码或方块
现象:汉字显示成乱码、方块或者完全不对的字。原因八成是源文件编码问题——Keil 或 IDE 默认把中文字符串按 UTF-8 存,而字库是按 GB2312 生成的,内码对不上自然取错字。解决:把源文件另存为 GB2312/ANSI 编码,或者在代码里用\xD5\xE2这种十六进制内码硬写,绕开编译器编码转换。
5.2 字是镜像的或者上下颠倒
现象:字能显示但左右镜像、上下翻转。原因:取模软件的「取模方向」和 LCD 的扫描方向不匹配,逐行/逐列、顺向/逆向四个参数组合错一个就翻。解决:别猜,拿一个「一」字或「口」字做测试字,四种组合各试一遍,对上就固定下来,写进项目文档。
5.3 SPI 读回全 0xFF 或全 0x00
现象:读字库全是 0xFF(擦除态)或 0x00。原因:CS 时序不对、SPI 模式(CPOL/CPHA)配错,或者 Flash 根本没烧字库。解决:先读 W25Q128 的 JEDEC ID(命令 0x9F,应返回 0xEF4018),ID 对说明通信正常;再读字库基址前几个字节,全 0xFF 说明没烧数据。SPI 模式 W25Q128 支持模式 0 和模式 3,配错就通信失败。
5.4 显示到一半花屏或死机
现象:显示几个字后花屏、卡死。原因:page+1越界写到了屏控制器非法地址,或者 SPI 读函数没等忙标志就发下一条命令。解决:显示前判断page <= 6(16×16 字占 2 页),SPI 读写的忙等待别省,while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);这类判断一个都不能少。
5.5 换大字号后地址算错
现象:16×16 正常,换 24×24 就错位。原因:不同字号的每字字节数不同(24×24 是 72 字节),偏移公式里的乘数没跟着改,或者字库基址没更新。解决:把「每字字节数」和「基址」做成宏,按字号分支处理,别在公式里写死 32。
6. 二次开发:换字体、加字号与字库在线更新
真正上手改的时候,最常做的是三件事:换字体风格、加字号、让字库能在线更新。换字体最简单,PC 端取模软件换个 TTF 字体重新导出 bin,按原地址烧进去即可,代码一行不用动——前提是取模参数和原来一致。加字号要动两处:一是在地址规划里给新字号划一块空间,二是把取模函数按字号参数化。
// 按字号取模的通用接口,size 支持 16/24/32 void Font_GetHZ(uint8_t *hz_code, uint8_t *buf, uint8_t size) { uint8_t qu = hz_code[0] - 0xA1; uint8_t wei = hz_code[1] - 0xA1; uint32_t per_char, base; switch (size) { case 16: per_char = 32; base = FONT_HZ16_BASE; break; case 24: per_char = 72; base = FONT_HZ24_BASE; break; case 32: per_char = 128; base = FONT_HZ32_BASE; break; default: return; } uint32_t addr = base + ((uint32_t)qu * 94 + wei) * per_char; W25QXX_Read(addr, buf, per_char); }这个通用接口把「每字字节数」和「基址」都收进 switch,加字号只加一个 case,调用方传 size 就行。注意 24×24 每字 72 字节、32×32 每字 128 字节,缓冲区要按最大字号开(128 字节),别还用 32 字节的 buf 去接 32×32 的数据,那是典型的缓冲区溢出,轻则花屏重则死机。
字库在线更新是进阶需求:设备出厂后想换字体或补字,可以让上位机通过串口把新 bin 分包发下来,MCU 收到后先擦扇区再写 Flash。这里的关键是「先擦后写、按页对齐、写完回读校验」三步,任何一步省了都可能写坏字库。我一般会保留一个「字库版本号」存在 Flash 最后一扇区,更新前比对版本,避免重复烧写。
验证方法也简单:写个自检函数,遍历 GB2312 常用区的前 100 个汉字,逐个取模并检查点阵非全 0、非全 0xFF,全过说明字库和读取链路都正常。这个自检我一般放在上电初始化里跑一次,出问题早发现。
说个我自己的教训:早期做这类项目,我图省事把字库地址和每字字节数直接写死在取模函数里,后来客户要加 32×32 大字号,改一处漏一处,调试了一整天才发现是某个分支的乘数没改。从那以后我每次做字库方案,都强制把「基址、每字字节数、字号」做成一张配置表,取模函数只查表不写死,加字号就是加一行配置。希望这个习惯能帮到你,少走那段弯路。
本文还有配套的精品资源,点击获取