2026/10/26 8:54:03

W25Q128外挂字库:STM32嵌入式LCD汉字显示方案

W25Q128外挂字库:STM32嵌入式LCD汉字显示方案 简介这份资源面向嵌入式开发初学者与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 怎么把汉字塞进 LCDW25Q128 字库方案落地记手上有一块 128×64 的单色 LCD主控是常见的 STM32F103RAM 只有 20KBFlash 也就 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 走标准四线 SPICS、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 上通常 72MHzBR_Div8得到 9MHz是调试阶段最稳的档位等通信验证通过可以改到BR_Div236MHz甚至更高来提速。SSM|SSI是软件片选模式因为 CS 用普通 GPIO 控制比硬件 NSS 灵活。片选拉高是必须的W25Q128 在 CS 为低时才会响应命令空闲态拉高能避免误触发。2.3 字库在 Flash 里的地址规划字库不是随便往 Flash 里一扔就行得先定好地址布局后面取模代码全靠这套约定。我习惯把 16MB 空间切成几块0x000000 起放 ASCII 8×16 半角字模0x010000 起放 GB2312 16×16 汉字0x100000 起放 24×240x400000 起放 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 写入前必须先发WriteEnable0x06否则写命令被忽略擦除按 4KB 扇区SectorErase0x20后要等忙标志清零写入按 256 字节页跨页必须拆成多次PageProgram0x02否则地址会在页内回卷数据写错位置。参数addr一定要扇区对齐len控制单次写入量实际烧字库时循环调用即可。3.3 按 GB2312 编码取一个汉字的点阵这是整套方案最核心的一段代码给一个汉字的内码算出它在 Flash 里的偏移读出 32 字节点模。// 从 W25Q128 读取一个 16x16 汉字的点阵存入 buf32 字节 // 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*21]是下半页。LCD_SetPos负责发页地址和列地址命令具体命令字取决于你的屏控制器。参数x是起始列0~127page是起始页0~7注意page1不能越界显示在最后一行时要提前判断。4.2 中英文混排的字符串显示函数实际界面里中英文是混在一起的得写个能自动识别编码的显示函数遇到 ASCII0x80按半角 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应返回 0xEF4018ID 对说明通信正常再读字库基址前几个字节全 0xFF 说明没烧数据。SPI 模式 W25Q128 支持模式 0 和模式 3配错就通信失败。5.4 显示到一半花屏或死机现象显示几个字后花屏、卡死。原因page1越界写到了屏控制器非法地址或者 SPI 读函数没等忙标志就发下一条命令。解决显示前判断page 616×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 大字号改一处漏一处调试了一整天才发现是某个分支的乘数没改。从那以后我每次做字库方案都强制把「基址、每字字节数、字号」做成一张配置表取模函数只查表不写死加字号就是加一行配置。希望这个习惯能帮到你少走那段弯路。本文还有配套的精品资源点击获取