
简介围绕W25Q128串行闪存存储字库并在LCD上显示汉字的嵌入式实践工程面向已具备C语言与STM32基础、希望打通SPI通信、字库管理与LCD驱动链路的学习者。工程以Keil项目组织完整呈现从W25Q128读取字库数据、解析汉字点阵并送至LCD显示的软硬件思路对字库烧录起始地址0x1000十进制4096也有对应说明。压缩包共192个文件、约4.6MB以36个h头文件、33个c源文件为核心配合32个o、32个d、31个crf等编译中间文件以及uvprojx/uvoptx工程配置、map/hex/axf产物、bat清理脚本和txt地址记录便于直接打开工程对照结构与编译结果。已有1971人学习适合用作课程设计、毕业设计或嵌入式显示模块的参考底稿也可据此梳理SPI Flash读写、字库寻址与LCD汉字显示的实现要点。1. 一块 16MB 的 SPI Flash 装下整套字库这项目解决什么问题调一块 240×320 的小屏时踩过一次很典型的坑MCU 内部 Flash 只剩三十几 KB把 16×16 点阵的汉字库硬编成 const 数组塞进去链接器当场报 region overflow。W25Q128 这类 16MB 的 SPI NOR 就是为这种场面准备的——字库整块挪到外部存储MCU 只留一份读点阵的驱动汉字照样上屏。这套方案适合做小屏 HMI、仪器面板、老设备改造的人会配 SPI、能点亮 LCD、看得懂指针基本就能跑通。真正麻烦的不是接线而是地址怎么切、字库怎么烧、点阵怎么摆这三件事下面一条条拆。2. 硬件分工与地址规划MCU 管逻辑、Flash 管字库、LCD 管像素2.1 三个器件各干什么以及为什么这么选这套系统的分工非常清楚MCU 负责取内码、算偏移、发 SPI 命令W25Q128 负责把点阵字节原样吐出来LCD 控制器负责把 RGB565 塞进显存。三者之间只有两条数据通路——MCU 到 Flash 的 SPIMCU 到 LCD 的并口或 SPI。选型上MCU 一般用 Cortex-M3/M4 内核的片子主频 72MHz 起步SPI 挂在 APB2 上。原因很实际一屏 240×320 全刷是 76800 个像素点如果主频太低、又没有 DMA 或并口刷屏时间会肉眼可见。字库为什么放外部 Flash 而不是 MCU 内部内部 Flash 通常 64KB512KB一套 HZK16 完整字库87 区 × 94 位 × 32 字节就有 261,696 字节再叠一套 24×24 就彻底放不下了。更要命的是改字库就得重烧整个固件产品出厂后想补几个生僻字基本没戏。为什么不用 SD 卡SD 卡要挂文件系统上电枚举动辄几百毫秒工业现场插拔还容易接触不良。SPI NOR 不需要文件系统给地址就出数据掉电保存随机读延迟在微秒级这才是字库该待的地方。LCD 这边如果 MCU 带 FSMC/FMC优先用 8080 并口 16 位模式一个像素一次写刷一屏几十毫秒引脚紧张就用 SPI 屏代价是刷全屏要几百毫秒做文字界面够用做动画就翻车。提示W25Q128 单独占一路 SPI别和 LCD 共用。共用总线时每次刷屏都要频繁切片选Flash 的读时序会被挤乱偶发读到错字节排查起来非常玄学。2.2 接线与 SPI 模式确认W25Q128 是 8 脚封装真正要接的只有 6 根Flash 引脚接到 MCU说明CS任意 GPIO或硬件 NSS低有效一次命令拉低一次CLKSPI SCK主机输出DI(IO0)SPI MOSI主机输出DO(IO1)SPI MISO从机输出WP(IO2)10kΩ 上拉到 3.3V拉低会进写保护HOLD(IO3)10kΩ 上拉到 3.3V拉低会进半双工暂停WP 和 HOLD 这两根最容易被忽略。有人图省事直接悬空结果就是能读 ID、写不进去或者读出来数据隔几个字节错一次。我一般直接焊两个 10k 电阻到 3.3V省心。SPI 模式选 Mode 0CPOL0CPHA0空闲时时钟为低数据在上升沿采样MSB 先行。W25Q128 同时支持 Mode 0 和 Mode 3但 Mode 0 是绝大多数例程的默认值混用会读到全 0xFF。时钟速度要克制。F103 的 SPI1 挂 APB2 72MHz4 分频是 18MHz。芯片手册标称快速读能到 104MHz但那是 PCB 走线阻抗匹配良好的前提。面包板加杜邦线线长超过 10cm18MHz 就开始出玄学问题了建议直接降到 9MHz 甚至 4.5MHz 调通再往上加。2.3 16MB 地址空间怎么切W25Q128 的容量是 128Mbit 16MB地址 24 位范围 0x0000000xFFFFFF用标准三字节地址命令就够不需要开 4 字节地址模式。空间很富裕但一定要提前规划否则后面加字号时到处挪数据改一处崩三处。我一般是这么切的起始地址长度内容0x0000004KB参数区magic、版本号、字号表、校验和0x0010004KBASCII 8×16 半角字库95 字符 × 16 字节0x010000256KBHZK1616×1632 字节/字0x050000576KBHZK2424×2472 字节/字0x0E00001MBHZK3232×32128 字节/字0x1F0000剩余图标、图片、日志等资源参数区那 4KB 不是浪费。里面存一个字号表记录每种字号的基地址、单字字节数、取模方向逐行/逐列、高低位顺序。以后换字库只要改参数区显示函数一行不用动。这算是给未来的自己留的一颗后悔药。按 4KB 扇区对齐也是刻意的NOR Flash 擦除的最小单位就是扇区地址不对齐会导致擦一个字号把隔壁字号的开头一起擦掉。3. W25Q128 驱动层读 ID、读一页、擦一个扇区3.1 先读 JEDEC ID确认芯片活着任何 SPI Flash 调试的第一步都是读 ID。这条命令只读不写不依赖擦除状态是判断硬件通没通最干净的探针。/* 读 JEDEC ID0x9F 3 字节返回W25Q128 正常应为 EF 40 18 */ uint32_t w25q_read_id(void) { uint8_t cmd 0x9F; uint8_t id[3] {0}; W25Q_CS_LOW(); spi_transfer(cmd); /* 只发不收返回值丢弃 */ id[0] spi_transfer(0xFF); /* 厂商 IDW25Q128 是 0xEF */ id[1] spi_transfer(0xFF); /* 存储类型0x40 */ id[2] spi_transfer(0xFF); /* 容量码0x18 表示 16MB */ W25Q_CS_HIGH(); return ((uint32_t)id[0] 16) | ((uint32_t)id[1] 8) | id[2]; }三个返回字节各有含义最高字节是厂商码中间是存储类型最低字节是容量码0x18 就是 2 的 24 次方正好 16MB。返回值拼成 32 位只是为了打印方便实际判断可以只比最低字节。拿到 0xFFFFFF 说明 MISO 一直是被拉高的可能是片选没生效、MISO 接错、或者从机根本没供电。拿到 0x000000 则是 MISO 被拉低通常是短路或者引脚复用没配对。这两个结果指向的问题完全不同先分清能省很多时间。3.2 读数据与页编程256 字节是不能越的硬边界读命令 0x03 最简单发完命令和 24 位地址之后时钟继续打数据就一个个往外吐地址自动递增可以一直读到片尾。/* 从任意地址读任意长度内部自动递增不跨页限制 */ void w25q_read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q_CS_LOW(); spi_transfer(0x03); spi_transfer((addr 16) 0xFF); spi_transfer((addr 8) 0xFF); spi_transfer(addr 0xFF); while (len--) { *buf spi_transfer(0xFF); /* 发哑字节换数据 */ } W25Q_CS_HIGH(); }读没有边界问题。写就不一样了页编程命令 0x02 一次最多写 256 字节而且必须落在同一个 256 字节页内。如果从地址 0x0000F0 开始写 32 字节写到第 16 个字节时地址会回卷到本页开头 0x000000把前面的数据覆盖掉。这是 NOR Flash 的硬件行为不是 bug。/* 写使能任何写/擦操作之前都必须发一次发完自动清零 */ static void w25q_write_enable(void) { W25Q_CS_LOW(); spi_transfer(0x06); W25Q_CS_HIGH(); } /* 轮询状态寄存器 SR1 的 bit0WIP为 1 表示还在忙 */ static void w25q_wait_busy(void) { uint8_t sr; do { W25Q_CS_LOW(); spi_transfer(0x05); sr spi_transfer(0xFF); W25Q_CS_HIGH(); } while (sr 0x01); } /* 写一页长度被强制截断到本页剩余空间 */ void w25q_page_program(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t page_left 256 - (addr 0xFF); /* 本页还剩多少字节 */ if (len page_left) len page_left; /* 绝不跨页 */ w25q_write_enable(); W25Q_CS_LOW(); spi_transfer(0x02); spi_transfer((addr 16) 0xFF); spi_transfer((addr 8) 0xFF); spi_transfer(addr 0xFF); while (len--) spi_transfer(*buf); W25Q_CS_HIGH(); w25q_wait_busy(); }三个参数要盯住addr是 24 位绝对地址buf是待写数据len会被page_left截断。真要用的时候再包一层循环让它看起来能写任意长度/* 任意长度写内部循环调 page_program跨页自动切 */ void w25q_write(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len) { uint16_t n 256 - (addr 0xFF); if (n len) n len; w25q_page_program(addr, buf, n); addr n; buf n; len - n; } }擦除的最小单位是 4KB 扇区命令 0x20同样要先写使能、后等忙。擦完之后整个扇区全是 0xFF。NOR 的特性是只能把 1 写成 0想把 0 变回 1 必须擦除所以覆盖写在 NOR 上是不存在的必须先擦后写。3.3 字库偏移内码到 32 字节的算术字库文件本身是一维的字节流要定位某个汉字全靠一段算术。GB2312 用区位码编码高字节减 0xA0 得区码194低字节减 0xA0 得位码194汉字从第 16 区开始。/* 16x16 汉字内码 - 字库内字节偏移返回负数表示不是 GB2312 汉字 */ int32_t hzk16_offset(const uint8_t *gb) { uint8_t qh gb[0], wh gb[1]; /* 区码有效范围 0xB0~0xF7位码 0xA1~0xFE */ if (qh 0xB0 || qh 0xF7) return -1; if (wh 0xA1 || wh 0xFE) return -1; uint16_t qu qh - 0xA0; /* 区码汉字从 16 开始 */ uint16_t wei wh - 0xA0; /* 位码1~94 */ /* 完整 HZK16 文件从第 1 区开始含符号区故用 qu-1 */ return ((int32_t)(qu - 1) * 94 (wei - 1)) * 32; }32这个数字来自 16×16 点阵16 行每行 16 个像素每个像素 1 bit一行 2 字节共 32 字节。换成 24×24 就是 72 字节每行 3 字节 × 24 行32×32 是 128 字节。那个qu - 1是关键。市面上流通的 HZK16 文件有两种完整版从第 1 区开始含 19 区的标点符号共 87 × 94 × 32 261,696 字节精简版从第 16 区开始只有汉字共 72 × 94 × 32 216,576 字节。用精简版却照着完整版公式算每个字都会偏移 15 个区屏幕上显示的全是隔壁的字。血泪经验是拿文件大小除以 32得到的就是字符总数再反推从第几区开始。另外qh 0xF7那个判断不能省。GBK 扩展区的内码高字节可以到 0xFE如果串口传上来的是 GBK 编码的生僻字直接算下去会读到字库之外屏幕上就是一坨乱码。返回 -1 让它显示空格或者方框反而更好排查。3.4 烧录串口分包写入加整片校验字库文件几百 KB不可能手敲进 Flash得有烧录通道。常见做法有三种一是用烧录器直接对 SPI Flash 编程速度快但要把芯片吹下来二是走调试器的手动烧写插件依赖特定工具链三是在固件里留一个串口烧录模式上位机分包发、MCU 收一包擦一扇区写一扇区。第三种最通用现场升级也靠它。上位机这边用 Python 写几十行就够# 把 HZK16.bin 按 4KB 扇区切块通过串口发给 MCU # MCU 端落到 W25Q128 的 0x00010000 起始处 import serial, struct SECTOR 4096 BASE 0x00010000 data open(HZK16.bin, rb).read() print(字库大小 %d 字节共 %d 个字符 % (len(data), len(data) // 32)) ser serial.Serial(COM3, 115200, timeout3) for i in range(0, len(data), SECTOR): chunk data[i:i SECTOR].ljust(SECTOR, b\xFF) # 不足一扇区补 0xFF addr BASE i # 帧头 0x5A 4 字节地址 4 字节长度 数据 帧尾 0xA5 frame b\x5A struct.pack(II, addr, len(chunk)) chunk b\xA5 ser.write(frame) if ser.read(1) ! b\x06: # 等 MCU 回 ACK raise SystemExit(第 %d 扇区写入失败 % (i // SECTOR)) print(烧录完成)ljust那一步是必须的最后一个扇区往往不满 4KB补齐 0xFF 之后再擦写才能保证扇区里没有残留的旧数据。MCU 端收到一帧后要做四件事擦除该地址所在扇区0x20、写使能、分包页编程、回 ACK。烧完之后一定要读回来校验最省事的办法是读整个字库的前 32 字节和最后 32 字节跟本地文件比对再全片算一个累加和。校验不过就整片重烧别指望它下次就好了。4. 汉字上屏从 32 字节点阵到 LCD 像素4.1 取点阵一次读 32 字节别一个 bit 读一次显示一个汉字的第一步是把它那 32 个字节从 Flash 里取出来。这里最常见的性能翻车点是有人在双层循环里对每个像素都调一次w25q_read一个汉字 256 个像素就发 256 次 SPI 事务一屏 20 个字就是 5000 多次片选翻转刷屏慢到肉眼可见。正确做法是先整块读到 RAM 缓冲区再在缓冲里遍历#define HZK16_BASE 0x00010000UL int lcd_load_hz16(const uint8_t *gb, uint8_t *dot) { int32_t off hzk16_offset(gb); if (off 0) return -1; /* 非汉字交给 ASCII 分支 */ w25q_read(HZK16_BASE off, dot, 32); /* 一次 32 字节一次片选 */ return 0; }HZK16_BASE就是第 2 章地址表里定的基址改字号只改这个宏。dot是调用者传进来的 32 字节缓冲别在函数里用 static 局部数组——中断里也可能要刷字共用一块缓冲会互相覆盖。4.2 逐位展开与窗口优化点阵到手之后就是位操作。约定是逐行式、高位在前、1 表示亮点第 row 行的两个字节高字节管左边 8 列低字节管右边 8 列。/* 在 (x,y) 处画一个 16x16 汉字 */ void lcd_show_hz16(uint16_t x, uint16_t y, const uint8_t *gb, uint16_t fc, uint16_t bc) { uint8_t dot[32]; if (lcd_load_hz16(gb, dot) ! 0) return; /* 开一个 16x16 的显示窗口之后连续写显存即可地址自动递增 */ lcd_set_window(x, y, x 15, y 15); for (int row 0; row 16; row) { uint8_t hi dot[row * 2]; uint8_t lo dot[row * 2 1]; for (int col 0; col 16; col) { uint8_t bit; if (col 8) bit (hi (7 - col)) 0x01; else bit (lo (15 - col)) 0x01; lcd_write_color(bit ? fc : bc); /* RGB565前景/背景 */ } } }fc和bc都是 RGB565 的 16 位色值比如白色 0xFFFF、黑色 0x0000、蓝色 0x001F。lcd_set_window对应 ILI9341 的 0x2A列地址和 0x2B行地址两条命令设完之后连续发 0x2C 就能一个像素一个像素往里灌不用每次重设坐标。窗口这条优化很值钱。不开窗口的话每个像素都要发一次设坐标命令256 个像素就是 512 次命令开销开了窗口256 次纯数据写速度快三倍以上。4.3 字符串循环与中英混排单个汉字能画了接下去要把一串内码跑完。判断依据很简单GB2312/GBK 的内码高字节第 7 位一定是 1ASCII 一定是 0。/* 在 (x,y) 起画一串以 \0 结尾的 GB2312/GBK 字符串 */ void lcd_draw_text(uint16_t x, uint16_t y, const char *s, uint16_t fc, uint16_t bc) { while (*s) { if ((uint8_t)*s 0x80) { /* 高字节 bit71双字节 */ lcd_show_hz16(x, y, (const uint8_t *)s, fc, bc); x 16; s 2; /* 汉字占两个字节 */ } else { /* 半角 ASCII */ lcd_show_ascii(x, y, *s, fc, bc); x 8; /* 8x16 字模占 8 像素宽 */ s 1; } if (x 16 LCD_W) { /* 到行尾就换行 */ x 0; y 16; } } }x 16和x 8这两个步进值分别对应 16×16 汉字和 8×16 半角字符的宽度。中英混排时按各自的宽度推进字距才对得齐。如果嫌挨得太紧把步进各加 12 像素做字间距。换行判断用的是x 16按最宽字符算避免最后一个汉字被切掉半个。y 16是行高如果字号是 24 或 32这里要跟着改成对应的字高。真正做产品时这一层通常还会再包一层先用lcd_set_window把整行区域开出来再把这一行所有字的点阵先在 RAM 里拼好最后一次性 DMA 灌进显存。文字滚动、菜单高亮这类需求全靠这层缓冲撑着。5. 避坑与排查读不到 ID、字是乱码、屏上花屏的现场记录5.1 通信层ID 读不到、写进去读出来全是 0现象一w25q_read_id()返回 0xFFFFFF串口打印出来三个 FF。原因是 MISO 线上一直没有从机响应被上拉电阻拉住了。我遇到过三种具体情况片选脚配成了推挽输出但初值写成低芯片一直处于被选中状态第一条命令就错位SPI 的 GPIO 复用模式没配成复用推挽SCK 根本没波形还有一种最隐蔽的WP 和 HOLD 悬空芯片进了保护态命令能收但数据线不驱动。解决办法是按顺序查先用示波器或逻辑分析仪看 SCK 上有没有 8 个时钟有波形说明主机侧没问题再量片选在发命令期间是不是低电平最后确认 WP(IO2) 和 HOLD(IO3) 有没有上拉到 3.3V。这三步做完九成的 ID 问题就定位了。现象二ID 读得到写进去之后读回来全是 0x00。NOR Flash 只能 1 写 0不擦除直接写写进去的位只会越来越少最后变成全 0。另一种情况是写使能没发——每次页编程和扇区擦除之前都必须单独发一次 0x06而且它只对紧随其后的那一条命令生效做完自动清零不能发一次管一片。解决办法是在w25q_page_program里强制先擦后写算出地址所在扇区基址addr 0xFFFFF000调一次 0x20等忙再写。同时打印状态寄存器 SR1bit1WEL为 0 说明写使能没生效bit0WIP一直为 1 说明芯片卡在忙状态。5.2 数据层字是乱码、整体偏一个字、写一半丢一半现象三屏幕上每个字都能显示出点阵但没有一个是对的全是乱码方块。八成是偏移公式和字库文件对不上。常见错法有三种把内码直接当区码用少减了 0xA0用了精简版字库但公式按完整版算差 15 个区取模软件设的是逐列式或低位在前而显示代码按逐行式、高位在前解。解决办法是拿一个已知字做定点验证取啊字它的 GB2312 内码是 B0A1区码 16、位码 1完整字库里的偏移应该是(16-1)*94 0 1410乘 32 得 45120。用串口打印出这个位置的 32 个字节和取模软件里啊字的预览对比一致就说明算对了不一致就从公式和取模设置两头查。现象四字符串前半段正常后半段突然变成一片空白或者乱码。这是跨页写的典型症状。字库烧录时如果没做页边界处理一次写超过 256 字节地址会回卷覆盖本页开头被覆盖的那部分读出来就是错的。表现很有规律每隔 256 字节出现一段坏数据对应到屏幕上大概是每 8 个汉字坏一次。解决办法是烧录端强制按 256 字节对齐切包或者直接用w25q_write那层封装让它自动切。烧完之后一定要读回校验最省事的是全片算累加和和本地文件的累加和比一次比逐字节比对快得多。5.3 显示层花屏、错位、坐标越界现象五字能显示但位置对不上或者右边被切掉一截、下面多出一条横线。三个原因按概率排一是 LCD 窗口设置和取模方向不一致屏幕是横屏模式MADCTL 里 MX/MY 位改了代码却按竖屏坐标算结果行列互换二是坐标越界x 15超过了屏幕宽度控制器自动回卷到下一行看起来就是字跑到别的地方去了三是显存里残留了上一帧的数据新窗口没覆盖到的地方还是旧的。解决办法是在lcd_show_hz16开头加一句越界检查if (x 16 LCD_W || y 16 LCD_H) return;宁可画不出来也别画错位置。刷屏前用背景色填一遍窗口区域别指望上一次的内容自动清掉。横竖屏切换时把所有坐标宏集中在一个头文件里改别散落在各处。还有一个不那么容易复现的上电第一次显示正常运行几分钟后偶发花屏。这种多半是 SPI 时钟太快加上杜邦线太长信号完整性不行。先把时钟从 18MHz 降到 9MHz 观察如果问题消失就说明是时序裕量不够硬件该改板改板软件该降频降频。6. 二次开发多字号、编码转换与字库在线升级这套东西跑通之后真正花时间的都在加需求上。我把几个改造方向按投入产出比排一下。改造项难度改动位置收益增加 24×24 字号低地址表 偏移公式的字节数标题和正文区分层次UTF-8 输入转 GBK中显示函数入口兼容网络下发和 JSON 配置点阵 RAM 缓存中lcd_load_hz16前加一层常用字重复读刷屏快 23 倍字库在线升级高参数区 烧录协议出厂后补字、换字体不用返修多字号是最容易上手的。hzk16_offset里那个32抽成变量参数区里存一张表记录每种字号的基址和单字字节数显示函数多传一个字号参数就行。注意 24×24 是每行 3 字节逐位展开的循环要改成三层别直接拿 16 的代码复制粘贴。编码转换这块值得单独说。现在上位机、云端、配置文件基本都是 UTF-8而字库是 GB2312 索引中间必须转一道。我一般的做法是在显示函数入口加一个判断检测到 UTF-8 多字节序列就查表转成 GB2312 双字节再进原来的流程。转换表只做常用 3500 字能压到 20KB 左右放内部 Flash 完全没问题。生僻字转不出来就显示方框别硬算偏移否则 0xF7 以上那一片会读到字库外面去。字库在线升级要谨慎。我的做法是把字库分成两个区A 区在用、B 区写入写完校验通过再改参数区里的有效区标志最后擦掉旧区。中途掉电最多是升级失败不影响当前正在用的那一份。校验别用简单累加和加一个 CRC32 更稳。验证方法我固定成三步先写一个自检函数读参数区 magic、读 JEDEC ID、抽查字库首尾各 32 字节并和本地文件比对再跑一遍全字符遍历把 6763 个汉字依次显示并检查偏移是否连续最后做一次断电重启循环至少 20 次确认每次上电都能正常出字。从那以后我每次改字库地址或者换字号都强制走一遍这三步不再靠看起来没问题糊过去。希望帮到你。本文还有配套的精品资源点击获取