简介:这份资源面向嵌入式开发初学者与STM32实践者,聚焦如何借助W25Q128串行闪存存储汉字字库,并在LCD上完成汉字显示。项目以SPI通信读取字库数据,配合LCD驱动解析与解码,将字库烧录至0x1000起始地址,覆盖C/C++编程、SPI协议、字库管理与微控制器编程等知识点,适合作为外置存储与显示驱动的练手案例。压缩包共192个文件,约4.6MB,以36个h头文件、33个c源文件为主,另有32个o、32个d、31个crf等编译中间文件,以及uvprojx、uvoptx工程配置、hex与axf固件、map与lst映射清单、bat批处理脚本和字库地址说明文本,完整保留Keil工程结构。目前已有1971人学习下载。读者可据此理清字库从烧录到显示的完整链路,掌握SPI读写、地址规划与LCD驱动调试思路,并借助现成工程快速复现与二次开发。
1. 从一块 16MB 的 Flash 说起:W25Q128 存字库到底解决了什么问题
做嵌入式 UI 的朋友大概率遇到过这种尴尬:MCU 内部 Flash 只有 512KB 甚至 256KB,程序本身已经吃掉大半,再想塞一套完整的中文字库(GB2312 一级+二级汉字约 6763 个,16×16 点阵就要 200KB 以上,24×24 直接飙到 500KB+),根本放不下。更别提还要留空间给图片、图标和配置参数。这时候外挂一颗 W25Q128 就成了最省事的方案——16MB 容量,SPI/QSPI 接口,几块钱的成本,足够把常用字库、多套字号甚至整套 UI 资源都塞进去。
这篇笔记拆的就是「W25Q128 存储字库 + LCD 显示汉字」这条链路。它解决的核心问题很明确:把字库从 MCU 内部搬到外部 Flash,通过 SPI 按需读取点阵数据,再交给 LCD 驱动刷到屏幕上。适合谁?正在做串口屏、工控面板、智能家居中控、仪器仪表这类需要中文显示但主控资源紧张的开发者。新手能照着把链路跑通,熟手可以关注后面的取模方式、地址映射和读取优化。
先说结论:这条路能走通,而且成本极低,但坑集中在三个地方——字库怎么烧进 Flash、点阵数据怎么按字节对齐、SPI 读取怎么不拖慢刷新。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆开讲。
2. W25Q128 与字库的存储结构:地址怎么算、数据怎么排
2.1 W25Q128 的物理布局与访问方式
W25Q128 是 128Mbit(16MB)的 SPI NOR Flash,内部按 4KB 扇区、64KB 块、16MB 整片组织。擦除最小单位是 4KB 扇区,写入前必须先擦除,这是 NOR Flash 的铁律,很多人第一次用就栽在这——直接往有数据的地址写,结果读出来全是 0xFF 或者乱码。
访问方式上,标准 SPI 用 0x03 指令读数据,支持最高约 50MHz(具体看时序),QSPI 可以到 80MHz 以上。对字库读取来说,如果只是按需取几十个字节的点阵,标准 SPI 完全够用;但如果你要做整屏刷新或者滚动显示,QSPI 的带宽优势就体现出来了。
地址空间上,我一般这样规划:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 字库区 | 0x000000 | 4MB | 16×16、24×24、32×32 点阵 |
| 图片区 | 0x400000 | 8MB | UI 图标、背景图 |
| 参数区 | 0xC00000 | 4KB | 配置参数、校准数据 |
| 预留 | 0xC01000 | 剩余 | 日志、升级备份 |
这个划分不是固定的,但原则是:字库放最前面,因为读取最频繁;参数区单独占一个扇区,方便整扇区擦除重写。
2.2 汉字点阵的取模方式与字节对齐
汉字在字库里的存储方式,本质就是「点阵按某种顺序排成一维字节数组」。以 16×16 汉字为例,每个字 32 字节。取模方式常见的有三种:
- 纵向取模、字节倒序:这是最常用的,PCtoLCD2002 等工具默认输出。每列 16 个点分两个字节,低位在前。
- 横向取模:按行扫描,每行 16 个点分两个字节。
- 行列式:少见,一般用于特定 LCD 驱动。
关键点在于:取模方式必须和你的显示驱动代码一致。我见过太多人字库烧进去了,显示出来是「阴阳字」或者「错位字」,排查半天发现是取模时选了「纵向取模」但驱动按「横向」解析。
以纵向取模、16×16 为例,一个汉字的 32 字节排列如下:
Byte 0: 第1列低8位 Byte 1: 第1列高8位 Byte 2: 第2列低8位 Byte 3: 第2列高8位 ... Byte 30: 第16列低8位 Byte 31: 第16列高8位显示时,驱动需要按列取出,再逐位判断点亮。这个顺序如果搞反,字就是镜像或者上下颠倒的。
2.3 字库索引:怎么从汉字找到地址
GB2312 编码的汉字,区位码和地址有固定换算关系。一级汉字从 0xB0A1 开始,二级从 0xD8A1 开始。假设字库按 GB2312 顺序排列,16×16 点阵,每个字 32 字节,那么:
offset = ((区码 - 0xB0) * 94 + (位码 - 0xA1)) * 32其中区码 = 高字节 - 0xA0,位码 = 低字节 - 0xA0。这个公式是很多教程里写的,但实际用的时候要注意:如果你的字库只包含一级汉字,二级汉字的地址就要单独处理,否则会读到错误数据。
更稳妥的做法是建一张索引表,把常用汉字的 Unicode 或 GB2312 编码映射到 Flash 地址。索引表本身也可以放在 Flash 里,启动时加载到 RAM。对于 6763 个汉字,索引表大约 13KB(每个条目 2 字节地址偏移),对大多数 MCU 来说可以接受。
提示:如果字库包含多套字号,索引表要按字号分开建,或者用「字号 + 编码」联合索引。
3. 把字库烧进 W25Q128:三种可行路径与实操
3.1 路径一:MCU 在线写入(适合小批量、开发阶段)
这是最直接的方式:MCU 通过 SPI 驱动 W25Q128,把字库数据从某个来源(串口、SD 卡、上位机)写入 Flash。开发阶段我一般用串口传,因为不用拆机。
流程是:擦除目标扇区 → 按页(256 字节)写入 → 校验。W25Q128 的页编程指令是 0x02,每次最多写 256 字节,跨页要分多次。
// 伪代码:通过串口接收字库数据并写入 W25Q128 // 假设已实现 spi_flash_erase_sector() 和 spi_flash_write_page() #define FLASH_FONT_ADDR 0x000000 #define SECTOR_SIZE 4096 #define PAGE_SIZE 256 void flash_write_font_from_uart(void) { uint8_t buf[PAGE_SIZE]; uint32_t addr = FLASH_FONT_ADDR; uint32_t total = 0; // 先擦除整个字库区(假设 4MB) for (uint32_t i = 0; i < 1024; i++) { // 4MB / 4KB = 1024 个扇区 spi_flash_erase_sector(FLASH_FONT_ADDR + i * SECTOR_SIZE); } // 循环接收并写入 while (total < FONT_TOTAL_SIZE) { uint16_t len = uart_receive(buf, PAGE_SIZE); // 阻塞接收一页 if (len == 0) break; spi_flash_write_page(addr, buf, len); addr += len; total += len; } }逻辑说明:先整片擦除字库区,再按页写入。参数上,FLASH_FONT_ADDR是字库起始地址,FONT_TOTAL_SIZE是字库总大小,根据你实际烧录的字库确定。注意擦除很慢,4MB 全擦可能要几十秒,开发阶段可以只擦要用的部分。
3.2 路径二:编程器离线烧录(适合量产、字库固定)
量产时在线写入太慢,我一般用编程器(比如 CH341A 这类)直接把字库 bin 文件烧到 W25Q128,再贴片。步骤:
- 用取模软件生成字库 bin 文件,确认取模方式和驱动一致。
- 编程器连接 W25Q128,选择芯片型号,加载 bin 文件。
- 设置起始地址 0x000000,执行烧录。
- 烧录后读回校验,确认数据一致。
这种方式快,但缺点是字库固定后要改就得拆下来重烧。所以量产前一定要把字库确认死。
3.3 路径三:SD 卡 + FatFS 中转(适合现场升级)
如果设备已经装好,不方便拆,可以留一个 SD 卡接口或者 USB 接口,通过 FatFS 读取字库文件,再写入 W25Q128。这种方式适合现场升级字库或者换字体。
// 伪代码:从 SD 卡读取字库文件写入 Flash FRESULT flash_update_font_from_sd(const char *path) { FIL fil; FRESULT res; UINT br; uint8_t buf[PAGE_SIZE]; uint32_t addr = FLASH_FONT_ADDR; res = f_open(&fil, path, FA_READ); if (res != FR_OK) return res; // 擦除 for (uint32_t i = 0; i < 1024; i++) { spi_flash_erase_sector(FLASH_FONT_ADDR + i * SECTOR_SIZE); } while (1) { res = f_read(&fil, buf, PAGE_SIZE, &br); if (res != FR_OK || br == 0) break; spi_flash_write_page(addr, buf, br); addr += br; } f_close(&fil); return FR_OK; }参数说明:path是 SD 卡上的字库文件路径,PAGE_SIZE要和 Flash 页大小一致。注意 SD 卡读取速度可能成为瓶颈,4MB 字库可能要十几秒,界面上最好给个进度提示。
3.4 三种路径怎么选
| 路径 | 速度 | 灵活性 | 适用阶段 |
|---|---|---|---|
| MCU 在线写入 | 慢 | 高 | 开发调试 |
| 编程器离线烧录 | 快 | 低 | 量产 |
| SD 卡中转 | 中 | 高 | 现场升级 |
我一般开发阶段用串口在线写,量产用编程器,产品留 SD 卡升级接口作为售后手段。
4. 从 Flash 读点阵到 LCD 刷汉字:驱动链路与性能优化
4.1 读取流程:从汉字编码到屏幕像素
完整链路是:输入汉字编码(GB2312 或 Unicode)→ 查索引得到 Flash 地址 → SPI 读取点阵数据 → 解析成像素 → 写入 LCD 显存 → 刷新屏幕。
以 16×16 汉字为例,读取 32 字节后,按纵向取模解析:
// 伪代码:从 Flash 读取 16x16 汉字点阵并显示 // 假设 lcd_draw_pixel(x, y, color) 已实现 void lcd_show_hanzi_16x16(uint16_t x, uint16_t y, uint16_t gb_code, uint16_t color) { uint8_t font_data[32]; uint32_t addr = get_font_addr(gb_code); // 根据编码算地址 spi_flash_read(addr, font_data, 32); // 一次性读 32 字节 for (uint8_t col = 0; col < 16; col++) { uint8_t low = font_data[col * 2]; uint8_t high = font_data[col * 2 + 1]; for (uint8_t row = 0; row < 8; row++) { if (low & (1 << row)) lcd_draw_pixel(x + col, y + row, color); if (high & (1 << row)) lcd_draw_pixel(x + col, y + row + 8, color); } } }逻辑说明:get_font_addr根据 GB2312 编码算出偏移,spi_flash_read一次读 32 字节。解析时按列取两个字节,低位对应上半部分,高位对应下半部分。参数color是前景色,背景色一般先填充或者用透明方式处理。
这个写法简单,但效率不高——每个像素都调一次lcd_draw_pixel,16×16 就是 256 次调用。如果一屏显示几十个汉字,刷新会明显卡顿。
4.2 优化一:批量读取与缓冲
SPI 读取本身有开销,每次读 32 字节如果分多次读,指令开销占比很高。我一般一次读多个汉字的数据,比如一次读 10 个汉字的点阵(320 字节),放到缓冲区再解析。
// 伪代码:批量读取多个汉字点阵 void lcd_show_string_16x16(uint16_t x, uint16_t y, const char *str, uint16_t color) { uint8_t buf[32 * 16]; // 一次最多 16 个汉字 uint16_t count = 0; uint32_t addrs[16]; // 先收集地址 while (*str && count < 16) { if (*str > 0x7F) { // 汉字 addrs[count] = get_font_addr((str[0] << 8) | str[1]); str += 2; count++; } else { str++; // 跳过 ASCII,用自带字库 } } // 批量读取(假设 Flash 支持连续读) for (uint16_t i = 0; i < count; i++) { spi_flash_read(addrs[i], buf + i * 32, 32); } // 解析显示 for (uint16_t i = 0; i < count; i++) { lcd_draw_hanzi_16x16(x + i * 16, y, buf + i * 32, color); } }参数说明:buf大小根据一次显示的汉字数调整,addrs存每个汉字的 Flash 地址。注意这里假设 Flash 支持任意地址连续读,W25Q128 是支持的,但如果你用的是带缓存的驱动,要确认缓存策略。
4.3 优化二:DMA 与 QSPI
如果 MCU 支持 SPI DMA,把读取操作交给 DMA,CPU 可以去处理其他任务。QSPI 则直接把带宽拉高,适合大字号或者整屏刷新。
我实测过,标准 SPI 50MHz 下,读 32 字节大约 6-7 微秒(含指令开销),QSPI 80MHz 可以降到 2 微秒以内。对于 16×16 汉字,一屏 20 个字,差距就是 100 微秒 vs 40 微秒,肉眼不一定看得出来,但滚动显示时差距明显。
4.4 优化三:显存与局部刷新
如果 LCD 驱动带显存(比如 ILI9341 这类),可以直接把点阵数据转成显存格式,用 DMA 刷到 LCD,而不是逐像素画。这样 CPU 占用更低。
常见做法是:在 RAM 里开一块小缓冲,把汉字点阵按 LCD 的像素格式(RGB565)填充好,再一次性写入 LCD 的 GRAM 区域。这样 SPI 到 Flash 和 SPI 到 LCD 可以流水线进行。
5. 避坑与排查:那些让我熬夜的 W25Q128 字库问题
5.1 现象:字库烧进去,读出来全是 0xFF
原因:写入前没擦除,或者擦除不完整。NOR Flash 只能把 1 写成 0,不能把 0 写成 1,所以写入前必须擦除成 0xFF。
解决:确认擦除指令和扇区地址正确。W25Q128 扇区擦除是 0x20,块擦除是 0xD8,整片擦除是 0xC7。擦除后读一下确认是 0xFF 再写。
5.2 现象:汉字显示错位、镜像或者上下颠倒
原因:取模方式和驱动解析不一致。常见的是取模选了「纵向取模、字节倒序」,但驱动按「纵向取模、低位在前」解析,结果就是每个字节的位顺序反了。
解决:用取模软件生成一个已知汉字(比如「中」)的点阵,手动对照驱动解析结果。确认取模设置里的「字节倒序」「低位在前」等选项。我一般会在代码里留一个测试函数,显示一个固定汉字,方便对照。
5.3 现象:SPI 读取偶尔返回错误数据
原因:SPI 时序问题,或者 Flash 忙状态没等待。W25Q128 在擦除和写入后会进入忙状态,此时读状态寄存器(0x05)的 bit0 是 1,必须等它变 0 才能发下一条指令。
解决:每次擦除或写入后,轮询状态寄存器直到忙位清零。另外检查 SPI 时钟极性、相位是否和 Flash 要求一致(W25Q128 支持模式 0 和模式 3)。
// 等待 Flash 空闲 void spi_flash_wait_busy(void) { uint8_t status; do { spi_flash_read_status(&status); } while (status & 0x01); }5.4 现象:显示速度慢,刷新一屏要几百毫秒
原因:逐像素绘制 + 单字节读取。每次读 1 字节就发一次 SPI 指令,开销巨大。
解决:批量读取 + 批量绘制。一次读多个汉字的点阵,然后在 RAM 里拼好再刷 LCD。如果 LCD 支持 DMA,用 DMA 刷。
5.5 现象:字库地址算错,显示的是别的字
原因:GB2312 区位码换算公式用错,或者字库排列顺序和公式不匹配。有些字库是按 Unicode 排列的,就不能用 GB2312 公式。
解决:确认字库的编码方式。如果是 Unicode 字库,需要先查表把 Unicode 转成字库内的索引。我一般会在 PC 上写个小脚本,随机抽几个汉字,算出地址,和字库文件里的数据对照,确认无误再烧录。
6. 进阶技巧:用 Python 预处理字库并生成索引表
前面讲的都是 MCU 端的操作,但字库本身怎么来、怎么组织,其实可以在 PC 端用 Python 预处理,省去很多手工活。我现在的习惯是:用 Python 生成字库 bin 文件和索引表,再烧录。
6.1 从 TTF 生成点阵字库
# Python: 从 TTF 生成 16x16 点阵字库 bin # 依赖: pip install pillow from PIL import Image, ImageDraw, ImageFont import struct def generate_font_bin(ttf_path, output_path, font_size=16): font = ImageFont.truetype(ttf_path, font_size) data = bytearray() # GB2312 一级汉字: 区码 0xB0-0xD7, 位码 0xA1-0xFE for qu in range(0xB0, 0xD8): for wei in range(0xA1, 0xFF): char = bytes([qu, wei]).decode('gb2312', errors='ignore') if not char: continue # 渲染到 16x16 图像 img = Image.new('1', (16, 16), 0) draw = ImageDraw.Draw(img) draw.text((0, 0), char, font=font, fill=1) # 纵向取模、字节倒序 for col in range(16): low = 0 high = 0 for row in range(8): if img.getpixel((col, row)): low |= (1 << row) if img.getpixel((col, row + 8)): high |= (1 << row) data.append(low) data.append(high) with open(output_path, 'wb') as f: f.write(data) print(f"生成 {len(data)} 字节, 共 {len(data)//32} 个汉字") generate_font_bin("simhei.ttf", "font_16x16.bin")逻辑说明:遍历 GB2312 一级汉字区,用 PIL 渲染成 16×16 二值图像,再按纵向取模、字节倒序输出。参数font_size要和点阵大小匹配,ttf_path是字体文件路径。注意不同字体的渲染效果有差异,建议用黑体或宋体这类笔画清晰的。
6.2 生成索引表
如果字库不是按 GB2312 顺序排列,或者包含多套字号,就需要索引表。索引表可以用简单的「编码 → 偏移」映射:
# Python: 生成索引表 def generate_index(font_bin_path, index_path): # 假设字库按 GB2312 顺序,每个字 32 字节 # 索引表存每个字的偏移(4 字节) with open(font_bin_path, 'rb') as f: data = f.read() count = len(data) // 32 with open(index_path, 'wb') as f: for i in range(count): f.write(struct.pack('<I', i * 32)) print(f"索引表 {count} 条") generate_index("font_16x16.bin", "font_16x16.idx")这个索引表在 MCU 端可以加载到 RAM,也可以留在 Flash 里按需读取。对于 6763 个汉字,索引表约 27KB,放 Flash 里完全没问题。
6.3 验证方法
生成后,我一般会做两个验证:
- 用十六进制编辑器打开 bin 文件,找到「中」字(GB2312 编码 0xD6D0),算一下偏移,看看数据是不是符合预期。
- 在 MCU 端写一个测试函数,显示「中」字,对照 PC 上的渲染结果。
注意:Python 生成的字节序要和 MCU 端解析一致。x86 是小端,但如果你用 struct.pack('>I') 就是大端,MCU 端读的时候要对应。
从那以后我每次生成字库,都会先用 Python 脚本跑一遍,把「中」「文」「测」「试」四个字的数据打印出来,和 MCU 端读到的对照,确认无误再批量烧录。这个习惯帮我省了很多次拆机重烧的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取