ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

W25Q128存GB2312字库:SPI驱动与LCD中文渲染

W25Q128存GB2312字库:SPI驱动与LCD中文渲染

简介:围绕W25Q128串行闪存存储汉字字库并在LCD上完成汉字显示,这份工程资料面向具备STM32与C语言基础的嵌入式学习者及项目开发者,可用于理解SPI读写外部Flash、字库烧录地址规划与LCD驱动解码的完整链路。压缩包共192个文件,约4.6MB,以36个h头文件、33个c源文件为核心,配合32个o、32个d、31个crf等编译中间文件,以及uvprojx工程、hex与axf固件、map映射、bat清理脚本和记录字库起始地址的txt说明,较完整地保留了从源码到构建产物的现场。已有1971人学习下载。读者可据此对照SPI通信配置、0x1000地址处的字库布局、汉字点阵数据读取与LCD显示解析,快速复现实验并排查显示异常,省去从零搭建工程与整理字库的时间。

1. 一块 16MB 的 SPI Flash 怎么塞下整套中文字库:从一行乱码说起

前阵子调一块 3.5 寸并口屏,界面上要显示“运行中”“故障复位”这类中文提示。主控内部 Flash 只剩两百多 KB,8×16 的 ASCII 字库够用,汉字一个都塞不下。临时拿软件描点画了几个字,改一次文案就得重新编译一次,维护起来很别扭。后来把整套 GB2312 点阵字库烧进外挂的 W25Q128——128Mbit、16MB、SPI 四线接口,按需取字模再送屏,显示内容从代码变成可替换的配置。这篇记录的就是这条路:字库字节数怎么算、16MB 空间怎么切、驱动怎么写、内码怎么变成像素、哪里会翻车。适合做小屏 HMI、手持仪表、工控面板的嵌入式开发者,也适合手头有一块 W25Q128 但只拿来存图片、没把字库用起来的人。

2. 字模、区位码与分区表:先把「一个字占多少字节」算准

动手写驱动之前,先把地址算清楚。字库类项目里绝大多数“显示乱码”“取出来的字缺半边”,根子都不在 SPI,而是偏移算错了一两个字节。这一节把 GB2312 的组织方式、字模长度和 16MB 的切法一次定死,后面所有代码都建立在这几个常量上。

2.1 GB2312 内码换算与点阵字节数

GB2312 把汉字和符号排成 94 区 × 94 位的矩阵。一级汉字在 16~55 区,按拼音排序共 3755 个;二级汉字在 56~87 区,按部首排序 3008 个;1~9 区是标点、数字、拉丁字母和制表符。屏幕上拿到的是内码,两个字节,高字节范围 0xA1~0xF7,低字节 0xA1~0xFE。减去 0xA0 得到人们习惯说的“区号、位号”(从 1 开始),减去 0xA1 得到从 0 开始的数组下标。

很多老教材写(区号-1)*94 + (位号-1),这和(高字节-0xA1)*94 + (低字节-0xA1)完全等价。我一般直接用字节减 0xA1,少一层概念转换,也顺便把范围校验写在一起:高字节小于 0xA1 说明是 ASCII,应该走另一套字库;超过 0xF7 说明不是 GB2312 字符,直接返回错误,别让非法内码算出个野地址来。

字号每字字节数全库大小(94×94)典型用途
12×1224212,064 B(约 207 KB)密集参数列表
16×1632282,752 B(约 276 KB)主界面正文、菜单
24×2472636,192 B(约 621 KB)标题、按键文字
32×321281,131,008 B(约 1.08 MB)大字号数值、告警

字节数公式是行数 × 每行字节数,且每行字节数向上取整到整字节:16 点宽每行 2 字节,24 点宽每行 3 字节,32 点宽每行 4 字节,都是整除的,算起来省心。真正容易出事的是索引乘法的位宽——8835 × 128 = 1,130,880,这个数早就超过 16 位了。源码里如果写成uint16_t idx再乘,高位被截断,表现就是“前面的字对、后面的字全乱”,而且这种错只在大字号、靠后的区才暴露,查起来非常费时间。

2.2 16MB 空间怎么切:四个分区与 4KB 对齐

W25Q128 的容量是 16MB,地址 0x000000~0xFFFFFF,共 4096 个 4KB 扇区、256 个 64KB 块,页大小 256 字节。擦除的最小单位是扇区(4KB),写入的最小单位是页(256 字节),读没有限制。分区时只要记住一条:每个分区的起始地址必须 4KB 对齐,结束地址向上取整到扇区边界,中间不留半截扇区。

我给这套方案定的分区表长这样,直接写进头文件当宏用:

分区起始地址大小内容
头信息区0x0000004 KB魔数、字库版本、各分区 CRC32、写入日期
ASCII 区0x00100060 KB8×16、8×8、12×12 三套西文字库
HZK160x010000276 KB(对齐至 0x060000)16×16 汉字
HZK240x060000621 KB(对齐至 0x100000)24×24 汉字
HZK320x1000001.08 MB(对齐至 0x220000)32×32 汉字
图片区0x22000013.8 MB图标、开机画面、日志、备用

留出对齐间隙不是浪费。烧写阶段如果某次升级只换了 HZK32,其它分区原地不动,对齐边界能让你放心地只擦 HZK32 覆盖到的扇区,不会误伤邻居。头信息区那 4KB 尤其值得留,塞一个魔数加版本号,上电读一次就知道字库到底烧没烧、烧的是哪一版,比对着屏幕猜“这是不是新字库”靠谱得多。

2.3 为什么是 SPI NOR 而不是 SD 卡或内部 Flash

内部 Flash 首先被排除。主控片上 Flash 通常 256KB~1MB,程序本身就要占掉大半,而且字库一旦编进固件,改一个字就要整片重烧,产线升级成本高。片上 Flash 的擦写寿命也一般,虽然只读不擦,但和代码共用同一块存储,分区链接脚本稍不注意就把字库段覆盖了。

SD 卡看着便宜大碗,实操里问题不少:要带文件系统、要处理挂载失败、要处理热插拔和掉电导致的 FAT 表损坏,消费级卡的一致性也参差。设备震动大的场合还容易接触不良。一个只读的字库,用文件系统去访问属于自找麻烦。

并口 NOR 速度快、可以 XIP 直接寻址,代价是引脚太多,小封装主控根本引不出来。SPI NOR 四五根线就够,W25Q128 支持到 104MHz 时钟,标准读命令下单线也能跑到 40MHz 以上,读 32 字节字模的时间在微秒量级,对刷屏完全不是瓶颈。擦写寿命 10 万次,数据保持 20 年,做只读字库属于大材小用,也因此格外稳。真要说缺点,就是它只能按扇区擦、按页写,写之前必须擦,这个特性在后面烧录和升级环节会反复咬人。

3. W25Q128 的 SPI 驱动:ID 自检、扇区擦除与跨页写

字库是只读资源,按说只需要实现读就行。但烧录、升级、校验都离不开写和擦,驱动还是一步到位写全比较省事。这一节的代码按“能直接抄进工程”的标准写,函数名保持通用,主控的 SPI 收发用一层薄封装隔开,换平台只改spi_xfer一个函数。

3.1 连线与 SPI 模式:模式 0 还是模式 3

四根信号线:CS、CLK、MOSI、MISO。WP# 和 HOLD# 不用的话必须上拉到 3.3V,悬空会导致偶发写入失败,而且这种失败没有规律,很容易被当成“芯片坏了”。电源脚旁边放 0.1μF 去耦,走线长的板子在 CLK 上串 22~33Ω 电阻抑制过冲,我吃过一次 60MHz 下时钟振铃导致读 ID 时好时坏的亏,后来不管什么板子都留这个位置。

W25Q128 同时支持 SPI 模式 0(CPOL=0、CPHA=0)和模式 3(CPOL=1、CPHA=1),选一个从头用到尾就行。混用是最常见的翻车点:底层初始化配的是模式 0,某个第三方例程抄过来一段模式 3 的读函数,单独测能读、跑起来偶发错位。CS 的时序也要注意,拉低到第一个时钟沿之间要有建立时间,最后一个时钟沿到拉高之间要有保持时间,主控硬件 SPI 一般自动满足,软件模拟 SPI 就必须自己插延时。

调试阶段的时钟先压到 1~5MHz,等整个链路跑通再往上提。一上来就 80MHz,读出来全是 0xFF,你不知道是接线错、模式错还是时序不够。

3.2 上电自检:读 JEDEC ID 和状态寄存器

上电第一件事是读 ID,读不到就别往下走了。命令 0x9F 返回三个字节:厂商 ID、存储类型、容量。W25Q128 期望值是 0xEF4018,第三个字节 0x18 表示 2 的 24 次方字节,即 16MB。读到 0x000000 通常是 MISO 没接好或被拉低,读到 0xFFFFFF 通常是 CS 没生效或者模块根本没供电。

/* 全双工单字节交换,模式 0,MSB 先行。换平台只改这一个函数 */ static uint8_t spi_xfer(uint8_t tx) { uint8_t rx = 0; spi_transfer_byte(tx, &rx); /* 主控 SPI 发一字节收一字节 */ return rx; } /* 读 JEDEC ID:0x9F + 3 字节,W25Q128 期望 0xEF4018 */ uint32_t w25q_read_id(void) { uint32_t id = 0; CS_LOW(); spi_xfer(0x9F); id |= (uint32_t)spi_xfer(0xFF) << 16; /* Manufacturer */ id |= (uint32_t)spi_xfer(0xFF) << 8; /* Memory Type */ id |= (uint32_t)spi_xfer(0xFF); /* Capacity */ CS_HIGH(); return id; } /* 读状态寄存器 1,bit0 = BUSY,bit1 = WEL,bit2~4 = BP 写保护位 */ static uint8_t w25q_read_sr1(void) { uint8_t sr; CS_LOW(); spi_xfer(0x05); sr = spi_xfer(0xFF); CS_HIGH(); return sr; }

状态寄存器里 bit1 是写使能锁存位(WEL),每次擦除或编程之前必须先发 0x06 把这一位置 1,芯片执行完命令后自动清零。bit2~bit4 是块保护位,出厂默认全 0,如果读出来不是 0,说明之前被误设过写保护,这时候怎么写都不生效,只能先发 0x01 写状态寄存器清掉。

3.3 擦除、写入、回读校验的三段式封装

NOR Flash 的规则很硬:写入只能把 1 变 0,要把 0 变 1 必须擦除,而擦除的最小单位是整个 4KB 扇区。所以“改一个字库文件里的第 100 字节”这种操作,落到 Flash 上就是“读出整个扇区到 RAM、改完、擦掉、写回”。字库是整块烧的,不涉及单字节修改,但升级和打补丁时躲不开。

擦除命令有三个粒度:0x20 擦 4KB 扇区,0xD8 擦 64KB 块,0xC7 整片擦除。整片擦 W25Q128 典型要几十秒,产线烧录能用,现场升级绝对不能用,一定要按扇区来。

/* 轮询 BUSY,超时返回 -1。4KB 扇区擦除典型 45ms,最大 400ms */ static int w25q_wait_idle(uint32_t timeout_ms) { while (timeout_ms--) { if ((w25q_read_sr1() & 0x01) == 0) return 0; delay_ms(1); } return -1; } /* 4KB 扇区擦除,addr 必须 4KB 对齐 */ int w25q_erase_sector(uint32_t addr) { if (addr & 0x0FFF) return -2; /* 未对齐直接拒绝,别让芯片背锅 */ CS_LOW(); spi_xfer(0x06); CS_HIGH(); /* 写使能 */ CS_LOW(); spi_xfer(0x20); spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer( addr & 0xFF); CS_HIGH(); return w25q_wait_idle(600); /* 留余量,别卡着 400ms 设 */ }

地址是 24 位的,分三次发,高字节在前。这一步出错的表现很典型:擦除返回成功,读回来还是老数据,因为擦的是别的地址。判断方法很简单,擦完立即回读那 4KB,全是 0xFF 才算真擦掉了。

3.4 跨页写与 4KB 扇区的边界

页编程命令 0x02 一次最多写 256 字节,且不能跨页。如果从地址 0x0000FE 开始写 10 个字节,芯片内部地址计数器到页尾会自动回卷到本页开头,把后 8 个字节覆盖到 0x000000 上去——数据没丢,但位置全错,而且函数返回值是“成功”。这是 NOR Flash 最经典的坑,没有之一。

处理办法是把长写拆成按页循环,每次算出“当前页还能写多少”:

/* 页编程,自动拆页。调用前目标区域必须已擦除 */ int w25q_write(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len) { uint32_t page_left = 256 - (addr & 0xFF); /* 本页剩余空间 */ uint32_t n = (len < page_left) ? len : page_left; CS_LOW(); spi_xfer(0x06); CS_HIGH(); /* 每页都要重新写使能 */ CS_LOW(); spi_xfer(0x02); spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer( addr & 0xFF); for (uint32_t i = 0; i < n; i++) spi_xfer(buf[i]); CS_HIGH(); if (w25q_wait_idle(50) < 0) return -1; /* 单页编程典型 0.7ms */ addr += n; buf += n; len -= n; } return 0; }

拆页只解决“不覆盖”,不解决“不擦除”。写入前目标扇区必须已经是 0xFF,否则新数据和老数据按位与,读出来是一堆四不像。字库烧录时通常是整片先擦,这部分没问题;后期打补丁改界面文字,就得老老实实“读扇区→改缓冲区→擦扇区→写回→回读校验”走一遍。我一般会把这个流程封装成w25q_update_region(),内部自己判断是否跨扇区,调用方不用关心。

写完立刻回读比对是最后一道保险。SPI 线上任何一次干扰、任何一次 CS 抖动,都可能导致个别字节写错,而这种错在屏幕上可能一个月才暴露一次。

/* 写后回读校验,返回第一个不一致的偏移,-1 表示全对 */ int w25q_write_verify(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t tmp[64]; for (uint32_t i = 0; i < len; i += sizeof(tmp)) { uint32_t n = (len - i < sizeof(tmp)) ? (len - i) : sizeof(tmp); w25q_read(addr + i, tmp, n); if (memcmp(tmp, buf + i, n) != 0) return (int)i; } return -1; }

4. 把 HZK 字库搬进 Flash:三条烧写路径与一次 CRC 校验

驱动通了,接下来是“字库文件怎么进芯片”。这一步没有标准答案,取决于你手上有什么:调试阶段图快,产线图省事,量产图一致。三条路径我都用过,各有各的适用场景,也各有各的坑。

4.1 取模参数先对齐:阴码、逐行、高位在前

烧之前必须确认字库文件和驱动里的展开逻辑是同一套参数。取模软件里那几个勾选项,差一个,屏幕上就是花屏或者镜像。

参数推荐取值说明
取模方式阴码1 表示点亮,0 表示背景,和bit ? fg : bg对应
取模走向逐行式横向扫完一行再下一行,和按行展开的循环一致
字节内位序顺向(高位在前)bit7 对应行内最左像素
输出格式二进制 bin烧录用 bin,调试可另存 C 数组
字库编码GB2312与内码换算公式匹配,别用 Unicode 库

市面上的 HZK16、HZK24、HZK32 文件基本都遵循这套约定,但“基本”不等于“一定”。拿到一个来路不明的字库文件,先做一次小验证:取“啊”字(内码 B0A1,区位 1601,索引 0),读 32 字节,逐位展开画到屏幕上,能认出是“啊”就说明参数对上了。这五分钟的验证能省掉后面两小时的排查。

4.2 路径一:串口分包下发,适合调试阶段

上位机把 bin 文件切成小包,每包带上目标地址和校验,MCU 收到写进 Flash。协议不用复杂,够用就行:

/* 帧格式:0xAA | len_hi | len_lo | addr[3] | data[len] | crc16_hi | crc16_lo */ typedef struct __attribute__((packed)) { uint8_t head; uint16_t len; uint8_t addr[3]; /* 24 位地址,高字节在前 */ } flash_pkt_hdr_t; void on_flash_packet(const uint8_t *frame, uint16_t total) { const flash_pkt_hdr_t *h = (const flash_pkt_hdr_t *)frame; if (h->head != 0xAA) return; uint32_t addr = ((uint32_t)h->addr[0] << 16) | ((uint32_t)h->addr[1] << 8) | (uint32_t)h->addr[2]; uint16_t len = (h->len >> 8) | (h->len << 8); /* 协议定的是大端 */ /* 1. 目标扇区若未擦除,先擦(按 4KB 边界判断) */ if (addr % 4096 == 0) w25q_erase_sector(addr); /* 2. 写入并回读校验 */ if (w25q_write(addr, frame + sizeof(*h), len) < 0) { nack(); return; } if (w25q_write_verify(addr, frame + sizeof(*h), len) >= 0) { nack(); return; } ack(); }

用这条路径时,我习惯让上位机按 4KB 对齐切包,每包正好一个扇区,MCU 侧收到就擦、写完就回 ACK,逻辑最简单。速度取决于波特率,921600 下 1MB 字库大约两分钟,调试够用。注意接收缓冲要留够,串口中断里千万别直接调 Flash 写函数——一次页编程要等 0.7ms,一次扇区擦除几十毫秒,在中断里干这个必丢包。

4.3 路径二与路径三:SD 卡中转、编进固件一次性搬运

板子上有 SD 卡槽的话,把 bin 拷到卡里,MCU 挂载文件系统后分块读、分块写 Flash,全程不用电脑。适合产线或者现场没有上位机的场合。注意读文件也用 4KB 对齐的缓冲,文件系统层一次读 512 字节效率不高,攒够一个扇区再写更合适。

量产最省事的是路径三:把字库 bin 用工具转成 C 数组,或者链接脚本里单独划一个段,编进固件。第一次上电检测到头信息区魔数不对,就从固件里把整块搬进 Flash,搬完写魔数和 CRC。代价是固件体积涨 1MB 多,好处是产线只烧一次固件,不用管字库,也不会有“漏烧字库”这种批量事故。

还有一个细节:搬运过程要能断电续传。头信息区最后写,只有整块搬完、CRC 校验通过,才写入魔数。下次上电发现魔数不对就重搬一遍,宁可多花几秒,也别让半截字库流到客户手里。

4.4 烧完怎么验:CRC32 与抽样回读

眼睛看屏幕只能证明“有几个字显示正常”,证明不了 1MB 数据全对。上电自检里加一段 CRC32 最实在:

/* 对指定区域算 CRC32,多项式 0xEDB88320,与上位机工具保持一致 */ uint32_t w25q_crc32(uint32_t addr, uint32_t len) { uint32_t crc = 0xFFFFFFFF; uint8_t buf[256]; while (len) { uint32_t n = (len < sizeof(buf)) ? len : sizeof(buf); w25q_read(addr, buf, n); crc = crc32_update(crc, buf, n); addr += n; len -= n; } return crc ^ 0xFFFFFFFF; }

上位机烧录时同步算一遍,把结果写进头信息区;设备启动时对同一区域再算一遍,比对不上就报“字库校验失败”,界面上直接提示重新烧录,而不是让用户面对一屏方块自己猜。抽样回读是补充手段:在“啊”“中”“龘”这几个位置上各读 32 字节比对,覆盖一级汉字开头、中间和二级汉字末尾,能抓到大部分偏移算错的问题。

5. 从内码到像素:LCD 上的汉字渲染链路

数据都在 Flash 里躺好了,剩下的事是把它变成屏幕上的点。这条链路看着短,实际有三个地方决定最终效果:字模读取效率、位展开的正确性、以及刷屏时到底推多少数据。

5.1 一次取模 32 字节:读函数与 RAM 缓存

最直接的实现是每次显示都去 Flash 读一次。单字 32 字节,SPI 40MHz 下连命令带数据不到 10 微秒,刷一屏 20 个字也就 200 微秒,完全够用。真正拖慢的是“每次读都要发命令 + 拉 CS”,以及主控 SPI 用轮询方式阻塞等待。

按字号分派地址的那层要写得足够防御:

#define FLASH_ASCII_BASE 0x001000UL #define FLASH_FONT16_BASE 0x010000UL #define FLASH_FONT24_BASE 0x060000UL #define FLASH_FONT32_BASE 0x100000UL /* 返回 0 成功。out 缓冲按字号至少 32/72/128 字节 */ int font_read(uint16_t gb_code, uint8_t size, uint8_t *out) { uint8_t hi = gb_code >> 8, lo = gb_code & 0xFF; /* 非 GB2312 区间一律拒绝,避免算出野地址 */ if (hi < 0xA1 || hi > 0xF7 || lo < 0xA1 || lo > 0xFE) return -1; uint32_t zone = hi - 0xA1; /* 0 基区号 */ uint32_t pos = lo - 0xA1; /* 0 基位号 */ uint32_t idx = zone * 94 + pos; /* 最大 8835,别用 uint16 */ uint32_t bytes, base; switch (size) { case 16: bytes = 32; base = FLASH_FONT16_BASE; break; case 24: bytes = 72; base = FLASH_FONT24_BASE; break; case 32: bytes = 128; base = FLASH_FONT32_BASE; break; default: return -1; } w25q_read(base + idx * bytes, out, bytes); return 0; }

界面里重复出现的字就那么几十个,加一层小缓存收益明显。64 个槽、每槽 128 字节,占 8KB RAM,用“命中计数 + 最久未用淘汰”就够,不必上链表。实测在滚动列表场景,Flash 读取次数能降一个数量级,主循环抖动也小很多。

5.2 逐位展开成 RGB565:三种字号的绘制函数

展开逻辑本质是一个二维查位:行号决定取哪几个字节,列号决定取第几位。16×16 每行 2 字节,32×32 每行 4 字节,写成通用循环反而慢,我一般按字号各写一个。

/* 16x16 点阵,字节高位在左,阴码(1 点亮) */ void lcd_draw_glyph16(int x, int y, const uint8_t *glyph, uint16_t fg, uint16_t bg) { for (int row = 0; row < 16; row++) { uint8_t b0 = glyph[row * 2]; uint8_t b1 = glyph[row * 2 + 1]; for (int col = 0; col < 16; col++) { uint8_t bit = (col < 8) ? ((b0 >> (7 - col)) & 1) : ((b1 >> (15 - col)) & 1); lcd_put_pixel(x + col, y + row, bit ? fg : bg); } } }

逐点调用lcd_put_pixel在并口屏上还能接受,在 SPI 屏上就慢了,因为每个像素都可能触发一次窗口设置。改成行缓冲,一次拼一整行再整块推:

/* 32x32 行缓冲版,每行 4 字节点阵拼成 32 个 RGB565 像素后整行 DMA */ void lcd_draw_glyph32_fast(int x, int y, const uint8_t *glyph, uint16_t fg, uint16_t bg) { static uint16_t line[32]; for (int row = 0; row < 32; row++) { const uint8_t *p = glyph + row * 4; for (int col = 0; col < 32; col++) { uint8_t bit = (p[col >> 3] >> (7 - (col & 7))) & 1; line[col] = bit ? fg : bg; } lcd_blit_line(x, y + row, 32, line); /* 一次推 64 字节 */ } }

这里有个隐蔽的坑:RGB565 的高低字节序。并口屏按 16 位总线送没问题,SPI 屏要拆成两个字节发,顺序反了就是整体偏色。判断方法很简单,画纯红0xF800,屏幕变蓝就是字节序反了,lcd_blit_line里交换一下即可,不用动上层。

5.3 提速:批量预取、脏矩形与时钟档位

整屏重绘在参数页不常见,真正影响体验的是局部刷新。做法是记录每块区域的“脏标记”,只重绘变化的那几十个像素行。文本滚动时更明显:把整行字的字模一次性预读进 RAM 缓冲,再自上而下搬移,比逐字重读快得多。

SPI 时钟也要分档。读 ID、擦写这些低频操作跑 20MHz 就够,读字模刷屏时切到 60~80MHz。切换要等当前传输完成再改分频,中途改会出半个字节的错位,屏幕上表现为某些字的最后几位随机。

还有一个容易被忽略的:不要在中断里做显示。一次 32×32 汉字的展开要遍历 1024 个像素,加上 SPI 传输,在 100MHz 主频上也要几十微秒,中断里干这个会打乱整个系统的节拍。我一般把待显示内容丢进环形队列,主循环统一出队渲染。

6. 避坑与排查:五条血泪记录

下面这几条都是实际项目里踩出来的,现象、原因、解决按顺序写,对照着看能省不少时间。

6.1 读得到 ID 却显示方块

现象:上电读 ID 返回 0xEF4018,一切正常,但屏幕上一个汉字都不显示,读出来的字模全是 0xFF。

原因:字库根本没烧进去,或者烧到了别的地址。最常见的是烧录脚本里地址写成了 0x000000,把整个字库写进了头信息区和 ASCII 区,而font_read去 0x010000 取,自然全是擦除后的 0xFF。

解决:加一条启动自检,读头信息区魔数,再对字库区算 CRC32,与头信息里记录的值比对。不匹配就停在错误页,别让系统带着空字库跑起来。烧录工具里把分区基址做成下拉框而不是手填,能挡住大部分手误。

6.2 换行就花屏、靠后的字整体错位

现象:第一行显示正常,第二行开始花屏;或者前几百个字正常,后面的字全是乱码。换了字库文件也一样。

原因:索引乘法位宽不够。zone * 94 + pos的结果最大 8835,乘 128 得 1,130,880,用uint16_t存中间结果直接溢出,高位被截断后指到字库开头附近,展开出来自然是别的字。另一种是内码换算写成-0xA0却按-0xA1去算索引,整体偏移一个字,前几个字看着“差不多像”所以没被发现。

解决:索引和地址一律用uint32_t,编译时开-Wconversion让隐式截断报警。校验方法很直接:取“啊”(索引 0)、“中”(B0D6,索引 1×94+21)、二级汉字最后一个(索引 8835)三个点各读 32 字节,肉眼比对取模软件生成的数组。

6.3 上电偶发读失败、擦除超时看门狗复位

现象:冷启动有 5% 概率读 ID 失败,重上电又好了;或者批量擦除时看门狗把 MCU 复位,日志停在擦除。

原因:读 ID 失败多半是电源爬升慢,Flash 还没准备好就被访问,或者 WP#、HOLD# 悬空导致状态不定。擦除复位则是超时算错了:4KB 扇区擦除最大 400ms,整片擦除几十秒,轮询里如果没喂狗,几百毫秒的擦除就能把看门狗触发。

解决:上电延时 10~50ms 再初始化 SPI,初始化后先读三次 ID,两次一致才认为稳定。轮询 BUSY 的循环里插入喂狗,或者干脆在擦除前临时关掉看门狗、擦完再开。超时阈值按数据手册的最大值再留 50% 余量,别卡着典型值设。

6.4 个别字是乱码、某些字缺半边

现象:绝大多数汉字正常,偶尔有几个字显示成完全不相关的字,或者右半边被截掉。

原因:取模参数和展开代码不一致。阴码配了阳码的库,或者逐行式配了列行式的库,或者字节内位序是低位在前而代码按高位在前展开。缺半边通常是每行字节数算错,24×24 每行 3 字节,有人按 4 字节取了。

解决:固定一套取模参数写进项目文档,字库文件随工程一起归档,别从网上随便下一个就用。验证方法是拿“一”“十”“口”这种结构简单的字做逐位对照,能快速定位是位序问题还是走向问题。

6.5 升级后掉电,字库变成半截

现象:现场升级字库,烧到一半断电,重新上电后部分字正常、部分字乱码,而且设备认为自己“有字库”,不触发重烧。

原因:头信息区的版本号和 CRC 在烧录一开始就写了,后面数据没写完,头信息却已经是“有效”状态。这是升级流程设计的顺序问题。

解决:头信息区最后写,且写入前先把魔数清成 0xFFFFFFFF,整块烧完、CRC 校验通过,再写魔数和 CRC。启动自检发现魔数无效就重烧。这套“先写数据、后写标记”的顺序,在任何带升级功能的存储设计里都适用,值得当成硬规矩。

7. 进阶:多字号混排、灰度字库与一次上电自检

字号混排是界面好看的关键。告警弹窗用 32×32,列表正文用 16×16,同一个屏幕上两种字号并存,切换逻辑就是font_read的 size 参数。要注意的是缓存要按字号分开存,否则 16 点阵的 32 字节和 32 点阵的 128 字节混在一个槽里,取出来一半是

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进