ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式LCD中文显示:SD卡字库更新至SPI Flash的完整方案

嵌入式LCD中文显示:SD卡字库更新至SPI Flash的完整方案 最近在做一块带 LCD 屏的嵌入式板子功能倒不复杂核心就一句话支持中文显示。但真正动手做的时候才发现这个需求背后牵出一整条存储与显示的链路——中文字库往哪里放、怎么放进去、显示时怎么读每一步都有讲究。我最终实现的方案是从 SD 卡读取字库文件写入外部 SPI FLASH再由 LCD 从这个 FLASH 里读字模显示。这条链路做完之后板子既能离线更新字库显示速度也完全可接受。这篇文章就把我整个设计和踩坑过程完整记录下来给同样在折腾 SD、FLASH、LCD 和字库这块的朋友一个可以直接抄作业的参考。1. 为什么是“SD → FLASH → LCD”这条链路1.1 字库存放位置一开始就遇到的三选一做中文显示第一步不是写代码而是回答一个问题几十万个汉字的字模数据放在哪里以最常用的 16×16 点阵汉字来算一个字模是 32 字节。GB2312 编码的常用汉字和符号加起来有 7445 个左右满打满算也要 240KB如果换成 GBK 全集两万多个字符容量直逼 700KB。而很多 MCU 内部 Flash 总共才 512KB还要放代码、放协议栈、放界面资源根本挤不出这么大的空间给字库。于是我面前有三个选项放内部 Flash读取最快但容量太太太紧张而且每改一个字库都要重新烧录整个固件生产维护都痛苦。放外部 SPI Flash比如 W25Q648MB 容量绰绰有余但 Flash 写入需要先擦除而且出厂时字库怎么烧进去总不能每片板子都接烧录器单独烧。放 SD 卡容量大、可插拔、更新方便但 SD 卡读取速度不稳定而且用户如果拔卡或者换卡显示就挂了。单独选哪个都有短板。所以我把 SD 卡和 SPI Flash 组合起来用SD 卡当作“搬运工”SPI Flash 当作“仓库”。产品出厂或者需要更新字库时把字库文件放到 SD 卡根目录上电后系统自动检测并写入 SPI Flash正常工作时完全不用 SD 卡LCD 直接读 SPI Flash 里的字模。这样既躲开了 MCU 内部 Flash 的容量限制又不需要在产线上逐片烧录外部 Flash还保证了运行时的读取速度。1.2 这条链路解决的实际问题说完选型逻辑把链路拆开看它到底解决了哪些具体的痛点。第一是产线效率问题。如果字库只能通过烧录器烧到外部 Flash那我得在贴片完成后多一道工序每块板子都要人工操作烧录效率低还容易漏。现在只需要在出厂前把 SD 卡插上去上电自动完成字库更新一次可以批量处理。实测下来8MB 的 SPI Flash 写入几千字节字库文件也就是秒级的事产线完全等得起。第二是后期维护问题。产品已经卖出去了客户反馈说字库里的某个字显示不对或者想支持繁体字。你不可能让客户自己拆机接烧录器但可以让他把新字库文件拷到 SD 卡里插上板子开机系统自己更新。这是嵌入式产品很经典的“OTA 式”外设更新思路只不过走的是 SD 卡通道。第三是显示一致性体验。LCD 显示汉字时如果字模源不稳定——一会儿从 SD 卡读、一会儿从 Flash 读——那显示速度和画面效果都会飘。固定从 SPI Flash 读取时序可控读取速率稳定上层 GUI 代码也只需要对接一个统一的“取字模”接口逻辑清爽很多。所以这条链路本质上是用**“存储分级”**的思路把大容量字库的“存放”和“读取”两个动作分开优化这是我觉得整个方案里最值得借鉴的一点。2. 字库文件准备与 SD 卡侧工程搭建2.1 字库格式的选择GB2312 还是 GBK怎么定字库文件的格式直接决定了后续取模和显示代码的复杂度。我这次用的是16×16 点阵、GBK 编码的字库也就是常说的“HZK16 的 GBK 版”实际拿到的文件叫GBK16.FON大小约 700KB 左右。为什么不用 GB2312因为 GB2312 只覆盖 6763 个常用汉字很多生僻字、繁体字都没有做产品界面时容易踩坑。GBK 向下兼容 GB2312覆盖两万多个汉字基本不会再遇到“字库里没这个字”的尴尬情况。当然代价是文件体积涨了差不多三倍700KB 对 W25Q64 这种 8MB 的 Flash 来说毫无压力但如果你用的是 2MB 甚至 1MB 的小 Flash就要掂量一下了。字库文件本身是纯二进制点阵数据16×16 字模对应 32 字节这 32 字节不是随意排列的它按“行”组织每一行 16 个点用 2 字节表示一共 16 行。高位在前bit 为 1 表示该点亮。理解了这个布局后面写取模和显示代码才顺手。有一点要特别提醒务必确认你拿到的字库文件编码和你的编译器编码环境一致。比如你的源代码文件保存成 UTF-8代码里写的字符串常量也是 UTF-8 字节流那么你要先把 UTF-8 解码成 GBK 编码再去算字模偏移。如果直接拿 UTF-8 字节日过去的显示出来必然是乱码。这个坑我后面专门有一节细讲。2.2 SD 卡格式化与文件系统选型SD 卡侧的核心任务是让 MCU 能通过 FATFS 文件系统读到字库文件。这里面有两个前置条件一个是 SD 卡本身的格式一个是 FATFS 的移植。先讲 SD 卡格式。FATFS 支持 FAT12、FAT16 和 FAT32。我的字库文件 700KB还要留点余量放升级说明之类的文件直接选FAT32最省心。格式化工具我用的是 SD 卡官方出的SD Card Formatter不是 Windows 自带的右键格式化。官方工具对 SD 卡的分区表、引导扇区处理更规范实测下来兼容性更好。去年我就栽过一回用 Windows 格式化的一张卡在板子上 f_mount 就是返回 FR_NO_FILESYSTEM换成官方工具重新格式化后立竿见影。格式化的时候有几个参数值得注意文件系统选 FAT32或者容量小的话 FAT16 也行。分配单元大小保持默认。FATFS 是按扇区512 字节读写分配单元太大或太小一般不影响功能但默认值兼容性最好。不要勾选“快速格式化”以外的任何附加选项尤其不要做全盘低级格式化没必要还很慢。另外SD 卡容量不是越大越好。对 FATFS 来说大容量卡没问题但很多 STM32 的 SDIO 驱动和部分读卡器对大容量 SDXC 卡支持不好。我实测下来8GB~32GB 的 Class10 普通 SDHC 卡最稳64GB 以上的卡在部分板载读卡器上会出现初始化间歇性失败。2.3 FATFS 移植与文件读取的几个要点FATFS 本身是成熟开源库移植工作主要集中在底层接口disk_initialize、disk_read、disk_write、disk_status和disk_ioctl。我用的是SPI 模式驱动 SD 卡相比 SDIO 模式SPI 模式引脚少、代码简单速度虽然慢一些但读 700KB 字库也就几秒钟完全够用。这里给第一次搞 FATFS 的朋友几个经验f_mount 之后务必检查返回值不要假设挂载一定成功。我习惯在挂载失败后重试三次每次间隔 100ms因为 SD 卡上电瞬间还没准备好直接访问很容易失败。f_open 的打开模式读文件用 FA_READ 就够了不要画蛇添足加 FA_OPEN_ALWAYS否则可能不小心触发写操作。f_read 不要一次读整个文件。FATFS 内部有扇区缓冲但大文件一次性读入内存不现实——我的板子 RAM 只有 128KB700KB 的字库怎么可能整个装下。正确的姿势是分块读比如每次读 4KB边读边写入 Flash。代码上我用的 FATFS 是 R0.14b 版本挂载和读取核心代码大致是这样FATFS fs; FIL file; UINT bytes_read; uint8_t buf[4096]; // 挂载 if (f_mount(fs, , 1) ! FR_OK) { printf(Mount failed\n); return -1; } // 打开字库文件 if (f_open(file, GBK16.FON, FA_READ) ! FR_OK) { printf(Open font file failed\n); return -1; } // 循环读取每次 4KB while (f_read(file, buf, sizeof(buf), bytes_read) FR_OK bytes_read 0) { // 把 buf 里的 bytes_read 字节写入 SPI Flash spi_flash_write_buf(flash_addr, buf, bytes_read); flash_addr bytes_read; } f_close(file); f_mount(NULL, , 0); // 卸载后续不占用资源这段代码看着简单但里面有个容易忽略的点f_mount(NULL, , 0)这个卸载动作。如果升级完字库之后你不再需要 SD 卡一定要记得卸载并停掉 SD 卡电源否则 SD 卡会持续耗电而且 SPI 总线一直被占用影响 Flash 读取效率。3. FLASH 端把字库安全可靠地“搬”进去3.1 SPI Flash 的基本脾气先擦后写、按页编程我这边用的 SPI Flash 是W25Q648MB 容量这也是目前最普及的型号之一。要操作它得先接受它的“脾气”否则代码写出来各种诡异问题。第一个脾气是“先擦后写”。SPI Flash 的写入只能把 bit 从 1 变成 0不能从 0 变成 1。所以向某个区域写入数据之前必须先擦除把整块区域恢复成全 1即 0xFF。W25Q64 的最小擦除单位是 4KB 的扇区Sector还有 32KB 块Block和 64KB 块以及全片擦除。字库更新至少得擦掉几百 KB逐扇区擦是最合理的。第二个脾气是“按页编程”。写入数据时最多一次写 256 字节一页而且不能跨页。也就是说如果当前页只剩 200 字节空间你却想写 256 字节那就必须拆分先写 200 字节补齐当前页再从下一页开头写剩下的。很多新手在这里翻车写出来的数据莫名其妙丢了一段就是因为跨页了。第三个脾气是“状态寄存器要查询”。每次擦除或编程操作都要花时间几十毫秒到几百毫秒不等。操作完成后芯片 W25Q64 的 BUSY 位会自动清零必须轮询Read Status Register-1的 bit 0确认忙完才能进行下一步。我封装了一层spi_flash.c把上述操作全部包进去对外只暴露三个接口void spi_flash_init(void); int spi_flash_erase_sectors(uint32_t addr, uint32_t count); // 按扇区擦除 int spi_flash_write_buf(uint32_t addr, const uint8_t *buf, uint32_t len); // 自动处理跨页 void spi_flash_read_buf(uint32_t addr, uint8_t *buf, uint32_t len);重点说一下spi_flash_write_buf它内部必须处理跨页逻辑。我的实现思路是先计算当前地址在一页内的剩余字节数每次写入不超过这个剩余量然后把地址推进到下一页继续直到数据全部写完。每次编程前都要发Write Enable命令编程完成后等待 BUSY 清零。3.2 字库在 Flash 中的布局规划一个很容易被忽视的问题字库数据在 Flash 里不是随便找个位置一放就完事的。你得规划 Flash 地址空间因为这块 Flash 可能不只放字库还要放配置参数、升级标志、用户数据等。如果布局混乱以后扩展就是灾难。我这次的布局规划如下地址范围大小用途0x000000 - 0x000FFF4KB字库信息头版本号、日期、字符数等0x001000 - 0x0FFFFF1MB字库数据区GBK16 字模0x100000 - 0x17FFFF512KB预留字库区备用的 24×24 大字库0x180000 - 0x1FFFFF512KB用户参数区字体颜色、亮度等配置这个布局的用意很明显把信息头和数据区分开。信息头里存字库版本号和字符数每次更新时先读信息头判断是否需要更新数据区放在独立的 1MB 空间更新时整块擦除重写不影响其他区域。实测 W25Q64 擦除 1MB 大约需要 3~4 秒这个时间对开机升级来说可以接受。有条件的可以在信息头里加上 CRC32 校验值。每次写完字库数据后重新读出来计算 CRC跟文件头里存的原始 CRC 对比不一致就说明写入失败需要重新写入。这一步强烈建议加因为产线上一次擦写失败整块板子可能就得返工而多写几行校验代码能帮你把残次品挡在出厂之前。3.3 更新流程的状态机设计整个从 SD 卡到 Flash 的更新过程我把它设计成一个简单的状态机比线性代码健壮得多typedef enum { UPDATE_IDLE 0, UPDATE_CHECK_HEADER, UPDATE_ERASE_FLASH, UPDATE_WRITE_DATA, UPDATE_VERIFY, UPDATE_DONE, UPDATE_ERROR } update_state_t;流程是这样的上电后进入UPDATE_CHECK_HEADER读取 SD 卡上的GBK16.FON前 32 字节解析版本号和 CRC。如果版本号跟 Flash 里信息头记录的版本一致说明字库无需更新直接跳到正常显示流程不一致才继续执行擦除和写入。这么做的好处是避免重复擦写 Flash毕竟 Flash 有擦写寿命W25Q64 大约是 10 万次擦写能省则省。写入过程我用了一个双缓冲思路用两个 4KB 的 buffer 交替从 SD 卡读数据读满一个 buffer 就立刻写入 Flash同时另一个 buffer 继续读。这样 SD 卡读写的等待时间和 Flash 编程的等待时间可以重叠一部分大幅缩短整体更新时间。等整个文件写完再进行一次全量读回比对。不过要注意读回比对时数据量大如果板子 RAM 不够就分块比对每读 4KB 比对完后丢下一块。别想着一次性把全部字库读回内存再来比那会把 RAM 撑爆。4. LCD 显示端把字模变成屏幕上的汉字4.1 LCD 驱动与底层画点接口LCD 屏我用的是一块 4.3 寸 480×272 的 TFT 屏驱动 IC 是常见的 STM32 系列 MCU 加 RA8875 或者 ILI9341 一类芯片。接口是40 pin 的 RGB/并口具体是哪种要看屏的手册。我这次用的是一个 40 pin 并口屏数据线 16 位加上控制线和背光等正好 40 pin 一一对应。底层的驱动核心就两个函数设置显示窗口和写像素数据。窗口Window功能相当于告诉 LCD 控制器“接下来我要画的区域是哪里”设置好之后连续写数据就能连续填充效率很高。画一个 16×16 点阵汉字本质就是把这个窗口设为 16×16 大小然后按字模数据一行一行地把像素写进去。画点接口大概长这样void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); void lcd_write_data(uint16_t color);有这两个函数显示字体只是“体力活”根据字模字节里的 bit 位决定当前像素点亮还是不亮亮就用前景色不亮用背景色。如果要做反白、加下划线、描边等效果就是在这一步做文章。4.2 从 GBK 编码到字模地址的计算LCD 显示中文最核心的一步是把一个汉字的GBK 编码换算成它在字库文件中的偏移地址。这个计算公式如果搞错显示出来的字就是个鬼画符。以 GBK16 字库为例GBK 编码是双字节第一个字节高位范围是 0x81~0xFE第二个字节低位范围是 0x40~0xFE其中 0x7F 是无效的要跳过。字模偏移的计算公式是uint32_t get_gbk_offset(uint16_t gbk_code) { uint8_t high gbk_code 8; uint8_t low gbk_code 0xFF; uint32_t offset; if (high 0x81 high 0xFE) { // 计算区号和位号减去低位区 0x40 并跳过 0x7F uint32_t index (low 0x40) ? (low - 0x40) : (low - 0x40 - 1); if (low 0x7F) index - 1; offset ((high - 0x81) * 190 index) * 32; } else { offset 0; // 非法编码返回空白或替换符 } return offset; }注意这个公式里藏着一个著名的坑GBK 的低位字节从 0x40 开始但 0x7FDEL 字符是无效的所以实际每个区只有190个字符位而不是 0x41 到 0xFE 的 190 个那么简单。在减去偏移量时如果低位大于 0x7F要再减 1。如果代码里图省事直接用(low - 0x40)算那么所有低位在 0x7F 之后的字取到的字模全都错位一位。这个坑我当年调试了整整一个下午所以特意写在这里能救一个是一个。拿到 offset 之后从 SPI Flash 的对应地址连续读出 32 字节按行扫描显示即可void lcd_display_gbk_char(uint16_t x, uint16_t y, uint16_t gbk_code, uint16_t fg_color, uint16_t bg_color) { uint32_t offset get_gbk_offset(gbk_code); uint8_t font_data[32]; spi_flash_read_buf(FONT_DATA_BASE_ADDR offset, font_data, 32); for (int row 0; row 16; row) { for (int col 0; col 16; col) { int bit_on (font_data[row * 2 col / 8]) (0x80 (col % 8)); lcd_set_pixel(x col, y row, bit_on ? fg_color : bg_color); } } }显示字符串就外层再套一个循环逐个字符调用这个函数每个字符 x 坐标往后挪 16 像素。ASCII 字符用半宽字体x 坐标挪 8 像素这样英文和数字不会排得太散。4.3 显示性能优化能读快就别读慢直接按上面的代码逐像素画 16×16 汉字一个字符要画 256 个点调用 256 次lcd_set_pixel底层可能要多次 SPI 通信在 480×272 的屏上刷新一屏 30 个汉字会明显感觉到卡顿。我做了一次优化思路是减少像素级 API 调用。具体做法是先把一整个 16×16 字符的字模按“前景色、背景色”展开成 16×16 的 RGB 像素缓冲开一个 512 字节的小数组存这个内容然后用 LCD 控制器的一次性连续写命令把整个 16×16 窗口的数据一次性刷过去。这样原来要 256 次写数据现在变成一次窗口设置加 256 次连续写LCD 控制器内部会按行自动排列速度提升非常明显实测全屏刷新时间从秒级降到几百毫秒已经完全感觉不到卡顿。另外把 SPI Flash 的读取时钟频率调到最高稳定值也很有帮助。W25Q64 标准模式支持最高 104MHz 时钟但 MCU 的 SPI 外设未必跟得上我实测在 STM32F4 上 SPI 时钟分频设为 2 分频约 21MHz因为 F4 的 APB2 最高 84MHz最稳定再高偶尔出现读回数据错误。稳定压倒一切显示性能够用就行。5. 实操中踩过的坑与排查技巧5.1 SD 卡初始化的那些“玄学”SD 卡初始化失败是 FATFS 方案里最高频的问题而且往往是间歇性的。我遇到过三种典型场景场景一上电立即初始化失败。SD 卡上电瞬间需要几毫秒稳定时间如果代码立刻发 SD 卡初始化命令卡还没准备好自然失败。解决办法是初始化前延时 100~200ms或者初始化失败后延时重试三次。我最终采用“先 f_mount 一次失败则延时 100ms 再试最多试 5 次”的策略实测成功率从 80% 提升到近乎 100%。场景二SPI 模式下的时钟极性配置错误。SD 卡在 SPI 模式下要求 SPI Mode 0CPOL0CPHA0或 Mode 3不同卡兼容性略有差异。如果初始化指令发出去卡完全无响应先检查 SPI 的极性相位配置。我用的是 Mode 0配合 STM32 的硬件 SPI稳定运行。场景三使用了盗版或劣质 SD 卡。这个真不是玄学实验室里有一批所谓“扩容卡”标称 32GB 实际只有 4GBFATFS 读到后半段数据全错。排查方法是拿SD Card Formatter做一次全容量校验或者干脆换一张正规品牌卡做对照测试。5.2 Flash 写入慢和数据错位写 Flash 慢的根源通常是擦除太频繁。我第一次实现时每读 4KB 数据就擦一个扇区结果整个更新过程大部分时间都耗在擦除上。后来改成先集中擦除整个 1MB 字库区再开始写入速度提升非常明显。虽然擦除期间 SD 卡数据切口有风险但配合前面说的双缓冲方案整体时间压缩到原来的三分之一。数据错位问题除了前面说的 Flash 跨页写还有一种情况是Flash 地址溢出。计算地址时如果用 16 位变量保存超过 65535 就溢出了。W25Q64 的地址是 24 位务必用uint32_t。这种低级错误在 debug 时很难一眼看出来因为表现的症状是“数据写到一半内容全乱”排查思路只能是从代码逐行找。5.3 汉字乱码大概率是编码问题显示出来的汉字要么是空格、要么是“口”通常不是 Flash 读写坏了而是编码不匹配。我整理了一个快速排查表现象可能原因排查方法英文数字正常中文全部空白字库文件里没有该汉字GB2312 缺生僻字查字符是否在 GB2312 范围内中文是乱码方块取模地址算错offset 公式不对单步调试打印 offset 对比文件偏移某几个字错其他正常GBK 低位 0x7F 的处理遗漏重点检查低位大于 0x7F 的分支编译环境下中文正常板上乱码源文件编码与字库编码不一致把源代码另存为 GBK/ANSI 编码或代码内做 UTF-8 转 GBK特别说一下UTF-8 转 GBK这个点。现在很多 IDE 默认保存源码为 UTF-8代码里写你好编译后字符串常量是 UTF-8 编码。而字库是 GBK 编码。直接拿 UTF-8 字节去查 GBK 字库必然错。我最终的方案是写了一个utf8_to_gbk转换函数把所有中文串先转成 GBK 再取字模。如果你用的编译器支持指定编码比如 Keil 的源文件编码选项也可以直接把源码保存成 GB2312/ANSI但字符串字面量在跨平台时又有隐患。最稳妥的做法还是显式做编码转换。5.4 字库更新中断后的“半吊子”状态这是一个产品化必须考虑的问题如果在擦写 Flash 的过程中突然断电Flash 里可能就是一片空白或者写了一半的残缺字库。如果不做处理下次开机 LCD 全显示乱码产品直接变砖。我处理的方法是双备份 启动自检。字库数据区规划了两份一份是“当前生效”区一份是“待更新”区。正常工作时读 A 区更新时先写 B 区等 B 区数据校验通过再把信息头里的“生效区指针”从 A 切换到 B。如果更新中途断电B 区校验失败系统自动回退读取 A 区用户根本感知不到异常。这个思路和固件升级的 A/B 分区策略如出一辙嵌入式系统里“升级断点保护”是标配思维。我这次因为 Flash 容量足够才敢做双份备份。如果你的 Flash 腾不出 1.4MB 空间至少也要在信息头里保存“字库就绪标志”每次开机检查标志是否合法不合法就提示重新升级而不是拿残缺数据硬着头皮显示。6. 一些后续可以再优化的方向写完这套链路之后我复盘了一下还有几个方向值得再做深一步。一个方向是显示特效的扩展。现在只能显示单色 16×16 汉字如果产品对 UI 有更高要求可以加 24×24、32×32 的字库Flash 布局我已经提前留了空间地址规划稍作调整就能兼容。再往上叠一些简单的抗锯齿灰度字模显示效果会好一个档次代价是 Flash 占用和计算量翻几倍要结合实际 MCU 性能掂量。另一个方向是字库从 SD 卡升级的自动化流程。现在出厂前需要人工插卡、开机、等待升级、确认完成。如果产量大可以做一个产测工具通过串口或者以太网直接把字库下发到板子绕开 SD 卡这个物理介质。我预留的串口刷字库接口其实已经能跑通只是考虑到现场布线麻烦才选择了 SD 卡方案作为主力。根据我个人经验这套“SD 更新字库到 FLASH再用 LCD 显示”的方案核心收益不是某一个单一环节有多高级而是它把字库的可维护性和显示的可靠性这两件事解耦了。以后不管是换字体、加字库、还是修复生僻字都只需要做一张 SD 卡插卡开机完事。做产品这种省心比什么都重要。
返回列表