
简介围绕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 器件支持模式 0CPOL0、CPHA0和模式 3实际用模式 0 最省心。四根线SCK、MOSI、MISO、CS。CS 一定要用 GPIO 单独控制别指望硬件 NSS 自动管理——多从机场景下自动 NSS 很容易出玄学问题。几个我每次都会加的硬件动作CS 上拉 10K 到 VCC防止主控复位瞬间引脚悬空Flash 收到半截命令进入未知状态VCC 与 GND 之间并 0.1uF 加 10uFSPI 时钟拉到 18MHz 以上时电源纹波会直接体现成读回数据偶发错误MISO 线上不要串太大电阻超过 100 欧在高频下会拖慢上升沿。如果 LCD 和 Flash 共用一条 SPI 总线资源紧张时很常见必须保证两者的 CS 不会同时有效。常见做法是把 LCD 的 CS 和 Flash 的 CS 都做成 GPIO 推挽输出初始化时全部拉高谁被访问谁拉低访问完立刻拉高。这条规则听起来像废话但真正翻车的时候往往就是某次异常分支里忘了拉高。时钟频率要按主控来算。以 72MHz 主频的 Cortex-M3 为例SPI 分频到 18MHz4 分频是稳妥值再往上要看 PCB 走线和 Flash 型号的时序余量。W25Q128 本身支持更高的读时钟瓶颈通常在主板布线和排线长度上。串口屏、杜邦线连接的原型板建议先降到 9MHz 调通再往上提。2.2 128Mbit 怎么切分区表与字库布局16MB 空间看着大但字号一多就不够用了。我一般按扇区4KB对齐来切因为擦除的最小单位就是扇区不齐的话改一个区会连带擦掉邻居。区域起始地址建议大小用途头信息区0x00000064KB魔数、版本号、各字库偏移、CRC12 点阵字库0x010000192KBHZK12约 6763 字 × 24 字节16 点阵字库0x040000320KBHZK16约 6763 字 × 32 字节24 点阵字库0x090000640KBHZK24约 6763 字 × 72 字节图符/图标区0x130000512KB电量、信号、菜单图标位图预留/日志0x1B0000320KB参数备份、字库增量更新暂存这张表的关键不在数字而在每个区起始地址都能被 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位号 0index 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 到 317 - (i 7)得到字节内的位位置这个公式把 32 字节字模线性展开成 256 个像素不需要嵌套循环也不需要中间的高低位变量。缓冲区用static是为了避免在栈上开 512 字节——主控 RAM 紧张时一个 512 字节的局部数组很容易把栈打穿表现是程序跑到显示汉字就进 HardFault查起来毫无头绪。改成静态区或者全局区问题消失代价是多占 512 字节固定 RAM。LCD_WritePixels内部如果接了 DMACPU 在传输期间可以去解析下一个字的编码、准备下一个缓冲区双缓冲做起来就更顺。不过要注意 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-charsetGB2312能省掉后面所有的编码困惑。返回值返回结束位置方便接在后面继续画图标或者换行。这个设计在做右对齐文本、居中标题时特别顺手——先算好整体宽度再反推起始 x。5. 避坑排查读回 0xFF、显示花屏、刷几个字就卡住这一章是血泪经验的浓缩。下面这几条都是代码看着没问题、现象很玄学的类型每条按现象、原因、解决三层写方便对着症状查。5.1 通信与存储侧的三个坑坑一读 ID 一直返回 0xFFFFFF 或 0x000000。现象是W25Q_ReadID()怎么调都不对但接线用万用表量都是通的。原因通常有两个CS 引脚的方向配置没设成推挽输出配成了浮空输入拉不低或者 SPI 的模式和 Flash 不匹配写成了模式 1 或模式 2。还有一种情况是 SPI 时钟分频设得太高杜邦线一长采样点就跑偏了。解决办法先把 SPI 降到 1MHz 以下试通了再往上加CS 用示波器看一眼有没有真的拉到 0V模式固定为 0CPOL0、CPHA1Edge。坑二字库烧进去后读出来全是 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 字节读到了图符区的数据。从那以后我每次动字库地址都强制走一遍这三项。希望帮到你。本文还有配套的精品资源点击获取