简介:围绕W25Q128串行闪存存储字库并在LCD上显示汉字的嵌入式工程资料,面向具备C语言与STM32基础、希望打通外部Flash读写、字库管理与液晶显示链路的开发者。内容以Keil工程为组织核心,涵盖SPI通信、字库数据烧录与寻址、LCD驱动及汉字解码等环节,对理解从0x1000起始地址读取点阵并转换为屏幕图像这一流程较有参考价值。压缩包共192个文件,约4.6MB,以h头文件、c源文件、o目标文件、d依赖文件及crf编译索引为主,另含工程配置、映射与烧录输出等,目录保留完整开发痕迹,便于对照编译与排查。已有1971人学习下载,适合用作课程设计、毕业设计或嵌入式显示实验的参考工程,也能帮助读者梳理驱动分层、字库定位和显示验证思路。
1. 字库外置:把 16MB 的 SPI Flash 变成汉字的仓库
手上这块板子的主控片内 Flash 只有 64KB,跑完串口协议和按键扫描就剩不到 30KB,可界面偏偏要显示几十个汉字。把 GB2312 的 16 点阵字库整个编进固件?267KB,链接器第一个不答应。这时候 W25Q128 存储字库、LCD 显示汉字的组合就非常自然了:128Mbit 的 SPI Flash 单独挂在 SPI 总线上,只负责存字模;主控算好地址,读出 32 字节点阵,展开成像素推到屏上。
这套思路最适合做小家电面板、仪器仪表、工业手持终端的开发者——片内资源紧、界面文字固定、成本敏感。也适合刚学完 SPI 驱动、想找个能跑通的综合练手项目的同学,因为它把总线时序、存储寻址、编码转换、显示刷新四件事串成了一条完整链路,哪一环掉链子都看得见。
2. 硬件与存储规划:128Mbit 空间怎么切、字库怎么放
动手写代码之前,先把两件事定死:Flash 怎么接,地址怎么分。这两步后面几乎不会再改,改一次所有偏移量都得跟着重算,属于典型的"前期省十分钟,后期返工两天"。
2.1 一颗 SPI Flash 挂上总线
接线不复杂,但有几个细节决定后面调不调得通。W25Q128 是标准 SPI 器件,支持模式 0(CPOL=0、CPHA=0)和模式 3,实际用模式 0 最省心。四根线:SCK、MOSI、MISO、CS。CS 一定要用 GPIO 单独控制,别指望硬件 NSS 自动管理——多从机场景下自动 NSS 很容易出玄学问题。
几个我每次都会加的硬件动作:
- CS 上拉 10K 到 VCC,防止主控复位瞬间引脚悬空,Flash 收到半截命令进入未知状态;
- VCC 与 GND 之间并 0.1uF 加 10uF,SPI 时钟拉到 18MHz 以上时,电源纹波会直接体现成读回数据偶发错误;
- MISO 线上不要串太大电阻,超过 100 欧在高频下会拖慢上升沿。
如果 LCD 和 Flash 共用一条 SPI 总线(资源紧张时很常见),必须保证两者的 CS 不会同时有效。常见做法是把 LCD 的 CS 和 Flash 的 CS 都做成 GPIO 推挽输出,初始化时全部拉高,谁被访问谁拉低,访问完立刻拉高。这条规则听起来像废话,但真正翻车的时候往往就是某次异常分支里忘了拉高。
时钟频率要按主控来算。以 72MHz 主频的 Cortex-M3 为例,SPI 分频到 18MHz(4 分频)是稳妥值,再往上要看 PCB 走线和 Flash 型号的时序余量。W25Q128 本身支持更高的读时钟,瓶颈通常在主板布线和排线长度上。串口屏、杜邦线连接的原型板,建议先降到 9MHz 调通,再往上提。
2.2 128Mbit 怎么切:分区表与字库布局
16MB 空间看着大,但字号一多就不够用了。我一般按扇区(4KB)对齐来切,因为擦除的最小单位就是扇区,不齐的话改一个区会连带擦掉邻居。
| 区域 | 起始地址 | 建议大小 | 用途 |
|---|---|---|---|
| 头信息区 | 0x000000 | 64KB | 魔数、版本号、各字库偏移、CRC |
| 12 点阵字库 | 0x010000 | 192KB | HZK12,约 6763 字 × 24 字节 |
| 16 点阵字库 | 0x040000 | 320KB | HZK16,约 6763 字 × 32 字节 |
| 24 点阵字库 | 0x090000 | 640KB | HZK24,约 6763 字 × 72 字节 |
| 图符/图标区 | 0x130000 | 512KB | 电量、信号、菜单图标位图 |
| 预留/日志 | 0x1B0000 | 320KB | 参数备份、字库增量更新暂存 |
这张表的关键不在数字,而在"每个区起始地址都能被 4096 整除"。这样任何一个区单独擦写都不会误伤别的区,后面做字库在线升级时能省掉一大堆边界判断。
头信息区那 64KB 不是浪费。里面放一个结构体,记录各字库的真实偏移、字号、每字字节数、总字数、CRC32。主控上电先读它,校验通过才认为字库有效。没有这层校验,字库烧了一半就上电,屏幕会显示成一堆随机噪点,你还得怀疑是不是 SPI 时序有问题——这类黑匣子式的排查最耗时间。
2.3 字库文件的来路与格式核对
字库文件本身不是这个项目产出的,是用字模提取工具从系统字体导出的。以 HZK16 为例,它的组织方式是按 GB2312 区位顺序平铺:第 1 区第 1 位开始,每区 94 个位,每个汉字固定 32 字节。文件总大小约 262KB 到 267KB,取决于工具是否包含符号区。
拿到文件第一件事不是烧,是核对。用十六进制编辑器打开,跳到偏移 0x0000 附近,应该能看到"啊"字的点阵;再算一下文件大小能不能被 32 整除。这两个检查花不了一分钟,但能挡掉后面 80% 的"显示乱码到底是编码错了还是存储错了"的争论。
常见的格式坑还有两个:一是有些工具导出的字库把 12 点阵按每行 2 字节补齐成 24 字节,有些按 12 位紧凑排列成 18 字节,两者只差文件名,不打开看不出来;二是少数工具导出的字模是"横向取模",跟常规的"纵向取模、高位在左"正好错开 90 度。烧录前用一个小工具把第一个字渲染出来看看方向,比上板之后再猜省事得多。
3. 驱动层落地:JEDEC ID 自检、快速读与字模寻址
驱动层要干的活其实只有三件:确认芯片在、按地址读出正确字节、把字符编码换算成地址。三件事分开写、分开测,出问题能快速定位到是哪一层。
3.1 SPI 底层读写与 JEDEC ID 自检
先给 Flash 定义一套命令常量和片选宏,后面所有函数都基于它:
/* w25q128.h —— 命令与片选,地址统一用 24 位 */ #ifndef __W25Q128_H #define __W25Q128_H #include "spi.h" #include "gpio.h" #define W25Q_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define W25Q_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define CMD_WRITE_ENABLE 0x06 #define CMD_READ_STATUS1 0x05 #define CMD_READ_DATA 0x03 /* 普通读,最高约 50MHz */ #define CMD_FAST_READ 0x0B /* 快速读,需 1 字节 dummy */ #define CMD_PAGE_PROGRAM 0x02 /* 页编程,单页 256 字节 */ #define CMD_SECTOR_ERASE 0x20 /* 扇区擦除,4KB */ #define CMD_JEDEC_ID 0x9F uint32_t W25Q_ReadID(void); void W25Q_Read(uint32_t addr, uint8_t *buf, uint32_t len); uint8_t W25Q_IsBusy(void); void W25Q_WaitReady(void); #endifCMD_FAST_READ那条注释是重点:0x0B 命令在发完 24 位地址后,必须再补一个 dummy 字节,时钟才会把数据推出来。少了这个 dummy,读回的数据整体左移一个字节,表现出来就是字模全部错位、显示成横向条纹。这是 SPI Flash 驱动里最高频的低级错误之一。
上电第一步永远是读 ID:
/* 返回 0xEF4018 表示容量 128Mbit,读不到就说明硬件或时序有问题 */ uint32_t W25Q_ReadID(void) { uint32_t id = 0; W25Q_CS_LOW(); SPI_WriteByte(CMD_JEDEC_ID); id |= (uint32_t)SPI_WriteByte(0xFF) << 16; /* 厂商代码 */ id |= (uint32_t)SPI_WriteByte(0xFF) << 8; /* 存储类型 */ id |= (uint32_t)SPI_WriteByte(0xFF); /* 容量代码 0x18 = 16MB */ W25Q_CS_HIGH(); return id; }SPI_WriteByte是一个"发一字节、同时收一字节"的全双工函数,主机发 0xFF 是为了产生 8 个时钟让从机把数据吐出来。这个函数在 SPI 主机模式下必须写成"先写再读"的顺序,只写不读会丢数据。
上电流程固定成三步:初始化 SPI 和 CS 引脚、调W25Q_ReadID()比对 0xEF4018、比对不上就点错误灯或走降级路径(比如从片内 Flash 读一套最小字符集)。这一步做了,后面所有异常至少能排除"芯片根本没通"这一项。
3.2 快速读与跨页读的实现细节
读函数是整个项目调用最频繁的底层,写好了能省很多事:
/* 从任意地址读 len 字节,内部不处理跨页,因为读操作本身可以跨页 */ void W25Q_Read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q_CS_LOW(); SPI_WriteByte(CMD_FAST_READ); SPI_WriteByte((addr >> 16) & 0xFF); SPI_WriteByte((addr >> 8) & 0xFF); SPI_WriteByte( addr & 0xFF); SPI_WriteByte(0xFF); /* dummy 字节,不能省 */ while (len--) { *buf++ = SPI_WriteByte(0xFF); } W25Q_CS_HIGH(); }读操作没有页边界限制,一次 CS 拉低可以连续读完整片 16MB,这是它比写操作简单的地方。真正需要小心的是写和擦:页编程命令一次最多写 256 字节,且必须在一页内,跨页会自动回卷到页首覆盖数据;扇区擦除最小 4KB,擦之前要确认这一扇区里的其他数据不需要保留。
字库烧录通常一次性完成,所以写侧的逻辑可以写得糙一点,但生产环境或者后续做字库增量更新时,就得老老实实按"写使能 → 页编程 → 等 busy → 下一页"循环,并且在跨页时重新发一次页编程命令。
W25Q_IsBusy()读状态寄存器 1 的 bit0,这个位在擦除和编程期间为 1。4KB 扇区擦除典型耗时 45ms、最大 400ms,如果不轮询直接写下一段,数据会被静默丢弃——不报错,但读回来是 0xFF。这个坑我踩过一次,排查了整整一个下午。
3.3 区位码到地址:一行公式和它的边界
把汉字变成 Flash 地址,靠的是 GB2312 的区位结构。GB2312 编码分高字节和低字节,各减 0xA1 得到区号和位号(0 基),再乘每字字节数:
#define FONT16_BASE 0x00040000UL /* 16 点阵字库起始地址 */ #define FONT16_BYTES 32 /* 16x16 点阵,每字 32 字节 */ /* gb[0]、gb[1] 是 GB2312 双字节编码;成功返回 1,越界返回 0 */ uint8_t Font16_GetGlyph(const uint8_t *gb, uint8_t *buf) { uint32_t index, offset; if (gb[0] < 0xA1 || gb[1] < 0xA1) { return 0; /* 单字节字符,交给 ASCII 字库处理 */ } index = (uint32_t)(gb[0] - 0xA1) * 94 + (gb[1] - 0xA1); offset = FONT16_BASE + index * FONT16_BYTES; if (offset + FONT16_BYTES > FONT16_BASE + 0x50000UL) { return 0; /* 超出字库文件范围,防止读到图符区 */ } W25Q_Read(offset, buf, FONT16_BYTES); return 1; }这段代码里有两个参数值得说清。94是 GB2312 每区的位数,写死不要用变量,编译器会直接算成常量乘法。FONT16_BYTES是每字字节数,16 点阵是 32、12 点阵是 24、24 点阵是 72,换字号只需要改这个宏和基址。
边界判断那行是后加的,起因是字符集里混进了 GBK 扩展区的生僻字,高字节可以到 0xFE,算出来的 offset 已经跑到图符区,屏上就出现了莫名其妙的图标。加上上界检查之后,超范围的字直接返回失败,由上层决定显示成方框还是跳过,行为可控得多。
举一个具体例子验证:汉字"啊"的 GB2312 编码是 0xB0A1,区号 0xB0-0xA1 = 15,位号 0,index = 15×94 = 1410,偏移 = 1410×32 = 45120,即 0xB040。拿十六进制编辑器跳到 HZK16 的 0xB040,看到的应该是一个"口"字旁加"阿"的 16×16 点阵。这一步对上了,编码换算就不会有大问题。
4. 显示层:从 32 字节点阵到 16×16 个像素
到了这一步,手里已经有 32 字节的字模,要把它们变成屏幕上的亮点,中间还隔着位序、颜色、窗口、刷新方式四道关。这一层的代码量不大,但踩坑密度最高,因为出错的表现往往都是"花了",而花的原因有七八种。
4.1 字模位序与 LCD 窗口的对应
16×16 点阵的标准排布是:每行 16 个像素占 2 字节,共 16 行,总 32 字节;每字节内高位对应左边的像素。所以第 r 行第 c 列(c 从 0 到 15)的像素,落在glyph[r*2 + c/8]这个字节的第7 - (c%8)位。
这个映射关系必须先在小本子上画一遍再写代码,凭感觉写基本都会左右翻转。同理,遇到字模上下颠倒,说明取模工具用的是"底行在前"的顺序;遇到整字旋转 90 度,说明用的是横向取模。这几种情况改代码都不划算,直接换取模工具的设置重新导出字库最快。
LCD 这一侧,用 ILI9341 这类带窗口命令的驱动芯片时,流程是:设定显示窗口(起始列、起始行、结束列、结束行)→ 发写显存命令 → 连续推像素数据。窗口设好之后,每推一个 16 位 RGB565 像素,硬件自动向右推进,到行尾自动换行。所以显示一个汉字只需要设一次窗口,然后推 256 个像素,中间不用再管地址。
如果屏用的是并口 8080 或者 SPI 接口,窗口设定的寄存器命令可能略有差别,但"设窗口 + 连续写"这个模式是通用的。单色 OLED 则是另一套:按页(8 行)组织,每字节 8 个纵向像素,这时候字模的位序要重新映射一遍。
4.2 从逐点写到缓冲区整块推
最直观的写法是逐点判断、逐点写:
/* 逐点版:逻辑最直白,适合调试,不适合量产 */ void LCD_ShowHanzi16_Slow(uint16_t x, uint16_t y, const uint8_t *gb, uint16_t fc, uint16_t bc) { uint8_t glyph[32]; uint8_t high, low; int row, col; if (!Font16_GetGlyph(gb, glyph)) return; LCD_SetWindow(x, y, x + 15, y + 15); for (row = 0; row < 16; row++) { high = glyph[row * 2]; low = glyph[row * 2 + 1]; for (col = 0; col < 8; col++) { LCD_WriteData16((high & (0x80 >> col)) ? fc : bc); } for (col = 0; col < 8; col++) { LCD_WriteData16((low & (0x80 >> col)) ? fc : bc); } } }这段代码的参数含义:x、y是汉字左上角坐标,fc是前景色(字的颜色),bc是背景色,都是 RGB565 格式。0x80 >> col依次取出第 7 位到第 0 位,对应从左到右 8 个像素。
问题在于效率。每个像素一次LCD_WriteData16,函数调用加 GPIO 翻转的开销,比数据本身还长。显示一行 10 个汉字是 2560 次调用,如果屏是 SPI 接口,一次调用里还要拉低 CS、发命令、拉高 CS,帧率直接掉到个位数。
改进版是把整个汉字先渲染到内存缓冲区,再一次性推送:
/* 缓冲版:一次 SPI 事务推完 512 字节,配合 DMA 可以完全不占 CPU */ void LCD_ShowHanzi16(uint16_t x, uint16_t y, const uint8_t *gb, uint16_t fc, uint16_t bc) { static uint8_t glyph[32]; static uint16_t pixels[16 * 16]; /* 256 像素 × 2 字节 = 512B */ int i; if (!Font16_GetGlyph(gb, glyph)) return; for (i = 0; i < 256; i++) { uint8_t bit = (glyph[i >> 3] >> (7 - (i & 7))) & 0x01; pixels[i] = bit ? fc : bc; } LCD_SetWindow(x, y, x + 15, y + 15); LCD_WritePixels(pixels, 256); /* 内部可以接 DMA */ }i >> 3得到字节下标(0 到 31),7 - (i & 7)得到字节内的位位置,这个公式把 32 字节字模线性展开成 256 个像素,不需要嵌套循环,也不需要中间的高低位变量。
缓冲区用static是为了避免在栈上开 512 字节——主控 RAM 紧张时,一个 512 字节的局部数组很容易把栈打穿,表现是程序跑到显示汉字就进 HardFault,查起来毫无头绪。改成静态区或者全局区,问题消失,代价是多占 512 字节固定 RAM。
LCD_WritePixels内部如果接了 DMA,CPU 在传输期间可以去解析下一个字的编码、准备下一个缓冲区,双缓冲做起来就更顺。不过要注意 DMA 和 LCD 的 CS 必须等传输完成才能释放,用 DMA 传输完成中断里拉高 CS,别在主循环里提前拉。
4.3 中英混排与字符串游标
真正做界面的时候,一行文字里中英文是混着的,比如"温度 25℃"。这时候需要维护一个游标,遇到 ASCII 就按 8×16 半角字模画、游标加 8,遇到双字节就按 16×16 画、游标加 16:
/* 逐字推进,返回绘制结束后的横坐标 */ uint16_t LCD_DrawString(uint16_t x, uint16_t y, const uint8_t *str, uint16_t fc, uint16_t bc) { while (*str) { if (*str < 0x80) { /* ASCII 半角 */ LCD_ShowAscii8x16(x, y, *str, fc, bc); x += 8; str += 1; } else { /* GB2312 双字节 */ LCD_ShowHanzi16(x, y, str, fc, bc); x += 16; str += 2; } } return x; }*str < 0x80这个判断依赖字符串以 GB2312 编码存储。如果源文件是 UTF-8 存的,中文字符会变成 3 字节,这个分支就完全错了,屏上会出现一堆半个汉字的碎片。工程设置里把源文件编码统一成 GB2312,或者在编译选项里加-fexec-charset=GB2312,能省掉后面所有的编码困惑。
返回值返回结束位置,方便接在后面继续画图标或者换行。这个设计在做右对齐文本、居中标题时特别顺手——先算好整体宽度,再反推起始 x。
5. 避坑排查:读回 0xFF、显示花屏、刷几个字就卡住
这一章是血泪经验的浓缩。下面这几条都是"代码看着没问题、现象很玄学"的类型,每条按现象、原因、解决三层写,方便对着症状查。
5.1 通信与存储侧的三个坑
坑一:读 ID 一直返回 0xFFFFFF 或 0x000000。
现象是W25Q_ReadID()怎么调都不对,但接线用万用表量都是通的。原因通常有两个:CS 引脚的方向配置没设成推挽输出(配成了浮空输入,拉不低),或者 SPI 的模式和 Flash 不匹配(写成了模式 1 或模式 2)。还有一种情况是 SPI 时钟分频设得太高,杜邦线一长,采样点就跑偏了。解决办法:先把 SPI 降到 1MHz 以下试,通了再往上加;CS 用示波器看一眼有没有真的拉到 0V;模式固定为 0,CPOL=0、CPHA=1Edge。
坑二:字库烧进去后,读出来全是 0xFF。
这个现象说明读时序对了,但那个地址上没有数据。原因分两种:一是烧录时没擦除就写,Flash 的编程只能把 1 变 0,不能把 0 变 1,覆盖写会得到一堆乱码或全 0;二是烧录程序算的地址和读取程序算的地址差了一个基址偏移。解决办法:写之前强制按扇区擦除,写完回读比对一遍;读取端的基址宏和烧录端的基址宏放在同一个头文件里,两边都引用它,杜绝手抄不一致。
坑三:连续显示十几个汉字之后程序卡死或屏闪。
现象是前几个字正常,越往后越慢,最后彻底不动。原因多半是 SPI 总线争用——LCD 和 Flash 共用一条 SPI,某次中断里抢先访问了 LCD,把正在进行的 Flash 读事务打断了,CS 状态错乱。另一个可能是 DMA 缓冲区还在传输时被主循环改写,数据错乱引发总线错误。解决办法:给 SPI 总线加一把简单的互斥标志,访问前检查、访问后释放;DMA 传输用双缓冲加完成回调,主循环只改空闲那一块。
5.2 编码与显示侧的三个坑
坑四:所有汉字都显示成方块,或者显示成相邻的两个乱字。
方块说明字模读出来了但内容全 0,通常是地址算错落到了空白区。显示成相邻乱字则更典型:字符串被当成了单字节处理,一个汉字的两个字节被拆成两个 ASCII 各画了 8 像素。原因在于字符串编码和判断逻辑不匹配。解决办法:确认源文件编码,用*str < 0x80判断 ASCII,双字节字符一次前进 2;调试时先把读到的字模 32 字节打印到串口,人工比对是不是"啊"字的点阵,能一次排除编码和寻址两类问题。
坑五:汉字显示出来了,但是上下颠倒或者左右镜像。
这是取模顺序和显示顺序对不上。上下颠倒说明字模工具用的是"从底行开始"取模;左右镜像说明字节内的位序被反了(用了0x01 << col而不是0x80 >> col)。解决办法:优先改取模工具的设置重新导出,改代码的话就在展开循环里换个方向。判断方向有个小技巧:找一个明显不对称的字,比如"上"或者"小",一眼就能看出是哪种翻转。
坑六:换了字号之后,字库整体错位,越往后越歪。
从 16 点阵切到 24 点阵时,每字字节数从 32 变成 72,如果只改了基址没改步长,前面几个字还能对上,越往后偏移越大。原因是FONT16_BYTES这种宏被复制粘贴到了新函数里没改。解决办法:把"基址 + 每字字节数 + 每行字节数"打包成一个结构体,每个字号一个实例,取字模的函数只接收结构体指针。这样加字号只是多一个初始化项,不会漏改。
6. 二次开发:多字号切换与字库在线更新
基础版本跑通之后,接下来最值得做的两件事是多字号支持和字库在线更新。前者决定界面好不好看,后者决定产品出厂后还能不能补救。
多字号的做法在前面提过一半:把字库参数抽成结构体。
typedef struct { uint32_t base; /* Flash 中的起始地址 */ uint8_t bytes; /* 每字字节数 */ uint8_t width; /* 字宽,像素 */ uint8_t height; /* 字高,像素 */ uint16_t count; /* 收录字数,用于上界检查 */ } FontInfo; /* 12 / 16 / 24 三种字号共用一套取模与显示逻辑 */ static const FontInfo g_fonts[3] = { { 0x00010000UL, 24, 12, 12, 6763 }, { 0x00040000UL, 32, 16, 16, 6763 }, { 0x00090000UL, 72, 24, 24, 6763 }, };显示函数只多一个参数fontIdx,取模、展开、设窗口三步全部用结构体里的值算,加字号不用动显示代码。这套结构还有个附带好处:上界检查可以精确到count,超出范围的生僻字直接跳过,不会读到隔壁区去。
字库在线更新要更谨慎一些。我的做法是:新字库先整包写到预留区(分区表最后那 320KB),写完全区 CRC32 校验,校验通过才把头信息区的"当前字库指针"改指向新地址,然后复位生效。这样更新中途断电,旧字库还在,设备照样能开机。如果空间够,保留两套字库做 A/B 分区是最稳的,代价是空间翻倍,16MB 的容量撑两套 16 点阵字库完全没问题。
最后说一个验证习惯。每次改完字库相关的代码,我会强制跑一个三项检查:读 ID 对不对、取"啊"和"中"两个字模打串口比对、在屏幕四个角各显示一个汉字看边界有没有越界。这三项跑完,八成的问题当场就露头了,不用等到整机联调再猜。这套习惯是被一次"字库看着正常、翻到第 300 页菜单突然花屏"折腾出来的——那次查到最后发现是上界少写了 32 字节,读到了图符区的数据。从那以后我每次动字库地址都强制走一遍这三项。希望帮到你。
本文还有配套的精品资源,点击获取