ARTICLE DETAIL

资讯详情

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

LVGL中文显示乱码?从TTF到SD卡外挂字库的完整嵌入式方案

LVGL中文显示乱码?从TTF到SD卡外挂字库的完整嵌入式方案 做嵌入式GUI开发尤其是用过LVGL的朋友十有八九都经历过那个让人抓狂的时刻界面逻辑调得丝滑流畅布局配色折腾得差不多了结果一显示中文屏幕上不是你想要的汉字而是一排排整整齐齐的豆腐块或者干脆是乱码、空白、错位符号。更崩溃的是有些字能显示有些字显示不出来有些字在电脑上明明是好的烧到板子上就变了样。这个问题的根源不在你的界面代码而在字体链路。很多人一上来就直接在LVGL里加载中文字体或者干脆全项目内置一个巨大的字库数组结果Flash告急、编译报错、加载卡顿折腾一整天还是没弄明白到底怎么把中文干净利落地显示出来。这篇内容我想把整套思路捋清楚从TTF字体文件开始怎么转换出LVGL能用的字库怎么把字库外挂到SD卡上动态加载以及中间会遇到的各种编码、格式、内存、性能问题。这套方案我在实际项目里反复用过跑过STM32FreeRTOS的组合也跑过PC模拟器属于那种改一改就能直接抄作业的路线。1. 先弄明白中文显示乱码到底卡在哪一环很多人在“乱码”这个问题上浪费了大量时间因为根本没定位清楚问题出在哪个环节。乱码不是某一个原因导致的而是整条字体链路里任何一个环节出了岔子都会表现成同样的症状显示异常。所以先别急着改代码先搞清楚LVGL渲染一个中文汉字中间经历了什么。1.1 从“豆腐块”说起字体渲染链路的四个环节LVGL要成功显示一个字符比如“中”背后要经过四个环节。第一个环节是字符编码。你写的字符串在代码里到底是UTF-8还是GBK还是别的什么编码。LVGL内部统一使用UTF-8编码来处理字符串如果你用Keil写代码工程文件默认可能用的是GB2312/GBK编码或者你的字符串是从某个串口、网络接口、文件系统读进来的编码不是UTF-8那LVGL拿到这个字符串后内码解析出来就是错的后面全盘皆输。第二个环节是字库文件里有没有这个字的字形数据。字库文件本质上是一张“字符码→字形数据”的映射表。如果你的字库只收录了常用汉字或者转换的时候只选了一个很小的字符子集那遇到生僻字、特殊符号时就查不到对应字形。第三个环节是字形数据的读取与解码。LVGL根据字符的内码从字库的映射关系中查到这个字对应的位图数据然后做解码、缩放。这里牵扯到字库格式、字节序、位深、抗锯齿等等问题。第四个环节才是渲染。把这解码出来的位图数据按照坐标画到屏幕上。到这个环节如果还出问题通常是缓存没管理好、渲染区域不对、或者内存溢出导致绘制数据被破坏。你看任何一个环节出问题屏幕上表现出来的都是“显示不对”。很多人一遇到乱码就怀疑是LVGL没配置好或者怀疑自己移植的显示驱动有问题其实大概率是前面的编码或字库文件环节出了岔子。1.2 为什么我劝你别走“内置字库”这条路LVGL官方demo里有个常见操作用lv_font_simsun_16_cjk这个内置字体或者把转换好的中文字体C文件直接include进工程编译成一个巨大的const数组。这种做法在小项目、验证阶段没问题一旦到真实产品里问题就来了。一个16像素大小的中文字形如果用4bpp每像素4bit抗锯齿编码一个字大约需要16×16×0.5128字节如果包含24×24之类的扩展范围会更大。常用汉字按GB2312一级字库3755字来算光汉字部分就要接近500KB的Flash空间。如果把这个上限放宽到6763个常用汉字再加上英文、数字、常用符号一个完整的字库C数组往少了说也要700KB到1MB。很多MCU的Flash总共就512KB或1MB你光字库就占掉一大半代码还放不放更别说UI资源、协议栈、日志系统这些都得吃Flash。哪怕你咬咬牙把字库编译进固件后续想换字体风格、想增加字号、想补充生僻字都得重新编译整个固件再烧录这在产品迭代和现场维护阶段极其痛苦。所以对于带SD卡、带Flash文件系统的设备把字库外挂到SD卡是一个更舒服的方案字体文件放在SD卡里启动时动态加载想换字体就换文件想加字就换字库完全不用动固件。代价是需要处理文件系统接入和字体动态加载这两件事而这两件事恰恰是本文的核心。2. 从TTF到LVGL字库这一步做不好后面全白搭要说整个流程里最容易让人踩坑的肯定是字体转换这一步。很多人以为把TTF文件扔给转换工具就完事了结果转出来的文件要么在LVGL里加载报错要么字体发虚发花要么体积大得离谱。这背后其实是“TTF矢量字体”和“LVGL位图字库”两种完全不同的技术体系在打架。2.1 TTF与LVGL字库的本质差异以及三种转换路线TTFTrueTypeFont是矢量字体里面存的是字形轮廓的数学描述说白了是一堆曲线和节点的坐标信息。它的优点是任何字号都能无损缩放缺点是需要解析和光栅化计算量大不适合MCU直接干这事儿。LVGL用的是位图字体就是提前把每个字符在特定字号下画好存成像素点阵数据。渲染的时候直接拷贝像素速度快内存消耗可控。代价是一个字号一套数据换字号就得另准备一份字库。所以从TTF到LVGL字库本质上就是“矢量光栅化数据打包”的过程。实际项目里我常用的有三种路线第一种是用FontForge等桌面字体工具把TTF手动转成BDF或PBM格式的位图字体再用LVGL的官方转换工具转成LVGL字库。这个流程的好处是可控可以做字符范围裁剪可以逐字段调整字体参数适合对字体质量有要求、需要定制字符集的场景。第二种是直接用命令行工具如otf2bdf或freetype工具链把TTF转成BDF。这个适合有批量转换需求的场景脚本化操作可重复执行。第三种也是最省事的直接用LVGL官方的在线字体转换工具lvgl.io/tools/fontconverter。选好字体文件、字号、字符范围一键生成C文件或者BIN文件拿来就能用。2.2 FontForge转BDF命令行操作与参数说明我自己在需要精确控制字体质量的时候更喜欢走FontForge命令行路线。下面是一段可以直接跑的脚本逻辑把思源黑体的“中”字范围提取出来转成BDF# 打开字体文件并生成BDF假设字体文件是SourceHanSansCN-Regular.otf fontforge -langff -c Open($1); Generate($2); SourceHanSansCN-Regular.otf SourceHanSansCN_16.bdf不过实际用FontForge直接生成有个很关键的问题默认生成的是完整的全字符集BDF体积巨大而且像素尺寸如果不指定会按照字体文件自带的点阵尺寸来生成。想控制像素大小需要额外处理fontforge -langff -c Open($1); SelectAll(); ScaleToEm(1024); BitmapsValid(); SetFontOrder(2); Generate($2); SourceHanSansCN-Regular.otf SourceHanSansCN_16.bdf这里ScaleToEm(1024)的意思是统一UPEMUnits Per Em让字体在转换时使用一致的度量基准。之后再拿这个BDF文件到LVGL在线工具里做二次转换。如果不想折腾FontForge这种高阶操作用otf2bdf是更直接的办法otf2bdf -p 16 -r 96 SourceHanSansCN-Regular.otf -o SourceHanSansCN_16.bdf-p参数指定像素大小这里是16像素-r参数指定分辨率DPI。生成完BDF后还有一步很重要检查BDF文件里的字符范围。用文本编辑器打开BDF文件看FONTBOUNDINGBOX和STARTCHAR这些字段如果发现全字符集都转出来了需要用工具裁剪只保留常用字符。这一步能有效控制最终字库体积。2.3 LVGL官方转换工具的关键参数与典型坑不管用哪种方式拿到BDF或TTF最后一般都要走一遍LVGL官方转换工具。打开lvgl.io/tools/fontconverter传上字体文件你会看到一堆参数需要设置这几个是关键中的关键字号Size要和你实际界面设计对齐。16px界面就选1624px界面就选24。有人图省事转一个32px的字库然后让LVGL自动缩放结果边缘发虚、性能下降。因为LVGL对位图字体的缩放效果并不好它更适合用“刚刚好”的位图。BppBits Per Pixel要按需选择想要平滑抗锯齿效果就选4bpp对显示效果要求不高、追求省内存就选1bpp或2bpp。注意4bpp的位图体积是1bpp的四倍但视觉效果差距很大。灰阶边缘靠这个参数体现。字符范围Range一定要手动指定不要默认全选。LVGL工具里可以勾选“常用ASCII”、“拉丁文”、“中文”等预设范围。中文范围通常是用Unicode码位表示的比如0x4E00-0x9FA5是一段常用汉字区域。想进一步压缩体积的话可以只保留自己项目里用到的字符集合。我这里有个比较实用的字符范围组合适合绝大多数商业设备界面ASCII可见字符0x20-0x7E中文常用3500字范围0x4E00-0x9FEF的部分子集再加上中文标点符号0x3000-0x303F、0xFF00-0xFFEF。如果能在工具里直接粘贴自定义字符列表那就把界面代码里所有中文字符串全部提取出来这样做出来的字库最精准一个多余的字都没有。说完参数再说一个非常经典的坑转换工具的输出格式新老版本LVGL差异很大。LVGL 8.x时代转换工具可以直接输出LVGL字体C文件一个.h一个.c用起来很方便include进去就能用。到了LVGL 9.x底层字体结构变化比较大官方推荐的做法变成了输出BIN格式的二进制字库文件配合lv_font加载。如果你还在用旧的转换方式拿到一个8.x的C文件直接塞进9.x工程里编译大概率会报各种类型不匹配、字段缺失的错误。所以先确认你的LVGL版本再用对应版本的转换方式这一步能省下你很长的排查时间。3. SD卡外挂字库从文件系统到动态加载的完整链路字库转好了接下来最关键的一步就是让LVGL能从SD卡里把字库读出来。这一步牵扯到三个层面的东西底层SD卡驱动与文件系统、LVGL的文件系统抽象层lv_fs、以及LVGL字体系统的动态加载回调。很多人卡在这里是因为分不清这三个层面各管什么。3.1 SD卡接入与FATFS挂载硬件与驱动的准备工作先说硬件和驱动这层。SD卡有两种常见接口模式SDIO模式和SPI模式。SDIO模式速度快4bit数据线并行传输实测连续读取速度能到几MB每秒到十几MB每秒适合频繁读取的大字库缺点是引脚多、驱动复杂。SPI模式引脚少、驱动简单但速度慢SPI时钟一般也就18MHz~36MHz实际吞吐量打折扣。很多追求极致速度的项目宁可用SDIO也不用SPI但代价是PCB布线和驱动调试成本明显上升。不管用哪种模式SD卡硬件设计有几个共性要求。SD卡供电要干净电源引脚附近一定要放10uF~100uF的电容否则卡在瞬间大电流读写时容易掉电复位表现就是用着用着文件系统突然报错。信号线上要有上拉电阻一般10kΩ尤其是CMD和Clock否则电气特性不稳SD卡兼容性差的时候会出现间歇性读取失败。电平问题也要注意MCU如果是3.3V I/OSD卡可以直接用3.3V供电如果MCU是5V系统或者用ESP32这类3.3V但I/O耐压有限的芯片一定要检查电平匹配别直接用5V怼上去。驱动层面常用的方案是FatFS文件系统。主要工作是注册底层读写的接口函数比如disk_initialize、disk_read、disk_status这些。如果你用的是带SDIO外设的MCU还会有中断和DMA的处理。这一步跑通之后用f_mount挂载然后f_open能创建文件f_read能读出数据就说明文件系统这层没问题了。LVGL这边需要确保开启文件系统支持。在lv_conf.h里有LV_USE_FS_FATFS这个宏打开它然后调用lv_fs_fatfs_init()LVGL就能通过它定义的统一接口去访问SD卡上的文件了。这一步做完LVGL就有了文件读写能力但还没有和字体系统打通。3.2 lv_fs接口注册让LVGL认识你的SD卡很多人搞不懂lv_fs到底是个什么机制。可以这么理解LVGL不知道世界上有SD卡、有Flash、有内存盘它只知道一套统一的文件操作接口——open、read、write、seek、close。这些接口在lv_fs_drv_t这个结构体里定义好了你要做的就是把FatFS的具体操作函数封装成这个结构体要求的格式然后调用lv_fs_drv_register注册进去。在LVGL 8.x和9.x里这套接口的写法有些差异。8.x时代lv_fs_drv_t里需要你提供read_cb、write_cb、open_cb、close_cb、seek_cb这一组回调注册方式是static lv_fs_drv_t fs_drv; lv_fs_drv_init(fs_drv); fs_drv.letter S; // 盘符之后用 S:/ 开头就能访问SD卡 fs_drv.open_cb fs_open; fs_drv.read_cb fs_read; fs_drv.write_cb fs_write; fs_drv.seek_cb fs_seek; fs_drv.close_cb fs_close; lv_fs_drv_register(fs_drv);9.x里面lv_fs_drv_t的字段名和回调签名有了调整但核心思路不变你把FatFS的f_open封装成fs_open把f_read封装成fs_read等等。这里面有一个非常容易踩坑的细节LVGL的回调函数里文件路径参数是LVGL格式的以盘符开头比如S:/FONT/ALIBABA_16.bin。你在封装open_cb的时候要把这个路径解析出来去掉盘符前缀转换成FatFS能识别的路径格式。很多人第一次写这里的时候直接拿LVGL的路径往f_open里塞结果文件系统永远打不开。static void * fs_open(lv_fs_drv_t * drv, const char * path, lv_fs_mode_t mode) { FIL file; // 跳过盘符S:/FONT/A.BIN - /FONT/A.BIN 或者 FONT/A.BIN? 看你的FatFS配置 const char * real_path path 2; if (f_open(file, real_path, FA_READ) ! FR_OK) { return NULL; } // 这里要小心lv_fs的返回值是一个文件对象指针你需要维护一个文件句柄表 // 或者把一个FIL结构体拷贝到堆上不能直接返回栈上地址 FIL * pfile lv_malloc(sizeof(FIL)); if (pfile) { *pfile file; } return pfile; }上面这个例子还隐藏着一个更深的坑lv_fs的open_cb返回值要求是void*类型的句柄而FatFS的FIL结构体比较大通常在几百字节不能直接作为返回值塞进去。你需要把它指向一个长期有效的内存区域。如果只用固定一块静态内存遇到同时打开多个文件的场景就冲突了。一个可行的方案是维护一个小的文件句柄池每次open分配一个空位close时释放。注册完成后你可以用lv_fs_open、lv_fs_read等接口手动测试一下确认LVGL层面能读文件了再往下走。3.3 动态加载字库文件lv_font_t回调是实现核心文件系统打通了字体系统也准备好了现在把两者对接起来。这里要先弄明白LVGL的字体机制。LVGL里的每个字体lv_font_t不只是一堆静态数据它还包含两个关键的函数指针get_glyph_dsc和get_glyph_bitmap。当一个字符要显示时LVGL会调用get_glyph_dsc去查询这个字符的元信息比如宽度、高度、x偏移、advance宽度如果查询成功了再调用get_glyph_bitmap去获取真正的位图数据。这就是外挂字库的突破口你不一定非要把整个字库一次性load进内存只需要在这两个回调函数里按需从SD卡读取对应字符的数据就行。思路是这样预先在内存里放一份“字符索引表”这张表记录了字库文件里每个字符的Unicode码和它在文件中的偏移量。当LVGL要显示字符“中”时get_glyph_dsc先查索引表找到“中”对应的数据偏移然后get_glyph_bitmap拿着这个偏移去SD卡里f_open、f_seek、f_read把字形数据读进一块缓存返回给LVGL渲染。这块缓存很关键。SD卡读取是慢操作而且每次都要f_open、f_read如果每个字都实时读性能肯定崩。所以缓存至少要能存下几个字符的字形数据。LVGL本身有字位图缓存机制LV_USE_FONT_PLACEHOLDER之类的配置和内部的cache但外挂字库的场景下这个缓存不一定完全够用需要自己设计缓存策略读过的字形放进内存缓存下次再遇到同一个字直接从缓存取只有缓存没命中才去读SD卡。这样可以大幅减少SD卡访问频率。索引表本身也占内存。每个字符的索引项按8字节计算4字节Unicode码4字节偏移6763个汉字的索引表也就50多KB。这比直接缓存整个字库要省太多内存了在大多数MCU上都能接受。所以我的建议是启动时只把索引表加载到内存字形位图数据按需从SD卡读取并缓存。4. 性能调优与内存管理外挂字库不是能显示就完事了很多人的字库在SD卡上能读、能显示出来了结果一拖动界面、快速切换页面屏幕上的文字要么闪烁、要么出现残缺、要么隔几秒卡顿一下。这些都是性能和内存管理没做到位。既然走了外挂字库这条路就得接受“比内存字库慢”的现实但可以通过合理的缓存在体验上拉回来。4.1 缓存策略设计用空间换时间还是用时间换空间前面提到字形位图数据按需读取这里再深入说一下缓存设计的问题。LVGL内部针对字体位图有一个缓存区域默认是用LVGL的内存分配器管理的。当字库是内置C数组时这个缓存几乎没什么存在感因为读取速度极快。但换成SD卡之后读一个字符的位图可能要几十微秒甚至几百微秒取决于SD卡速度、文件系统开销、数据长度如果每个字符都现场读界面渲染速度肉眼可见地掉下来。针对这个瓶颈我的做法是设计一个简单的LRU缓存缓存池里固定存放最近使用过的N个字符位图每个位图有对应的Unicode码和访问计数。每次get_glyph_bitmap被调用时先查缓存命中就直接返回位图指针没命中则从SD卡读取放到缓存池中如果缓存满了就把最久没用的那个位图替换出去。缓存池多大合适这取决于你的字号和bpp设置。16px的4bpp字符位图一个字符大约占128字节16*16/2。你分配32个槽位也就4KB内存已经能覆盖大部分界面切换时重复出现的字符了。实际测试中普通界面比如设置页、状态页文字重复率很高32个槽位的缓存命中率能到90%以上体感上基本不卡。4.2 LV_MEM_SIZE与内存池配置微调这些参数改善显示稳定性外挂字库场景下LVGL内部的内存池配置直接影响稳定性。因为get_glyph_bitmap返回的位图数据原则上不能直接指向SD卡读取的临时缓冲区这个缓冲区在函数返回后就被释放了必须拷贝到LVGL可控的内存里。这个拷贝就是内存池的大头消耗。如果LV_MEM_SIZE配得不够整个界面渲染过程中字位图反复分配释放会出现两种症状界面切换时字显示不全、中间某几个字变空白长时间运行后内存碎片化界面越来越卡。我的建议是在原有LVGL内存池的基础上额外增加30%~50%的余量。比如你原来用内置字库的时候LV_MEM_SIZE是16KB外挂字库后建议调到24KB或32KB。当然这不是拍脑袋定的最好通过实际压测来摸内存池水位。LVGL有现成的内存监控接口你可以周期性地打印lv_mem_monitor的信息看used和free的变化趋势再决定要不要加内存池大小。4.3 分场景加载什么时候用内置子集什么时候依赖外挂外挂字库并不是唯一的选择很多时候更务实的方案是“内置子集外挂全量”的组合。我做过一个带信息发布功能的设备界面常用词汇就是“温度、湿度、电量、开启、关闭、设置”这一二十个词但用户上传的内容可能包含各种生僻字。这种场景下我选择把常用汉字子集大概800~1000字编译成内置C数组保证启动后的秒开和流畅同时把全量GB2312字库放在SD卡上当内置字库查不到某个字时再走外挂加载。这样界面主体渲染极快生僻字也不至于显示成豆腐块。这个“内置子集兜底外挂补全”的组合思路在性能和完整性之间找到了一个比较好的平衡点建议有类似需求的可以尝试。5. 常见问题与排查技巧实录最后这部分我把这些年做外挂字库遇到的典型问题整理成一份问题对照表再配上排查思路。如果你在做的过程中遇到了奇怪的现象先对着表看一下大概率能省下一半排查时间。5.1 豆腐块、空白、错码三种症状的根因对照屏幕全部显示为豆腐块字库文件本身没有收录这些字符或者get_glyph_dsc查询失败LVGL认为这个字符不存在。重点检查字库转换时的字符范围。部分字符显示为豆腐块其他正常字库字符集覆盖不全缺少生僻字或特殊符号。提取全部界面字符串对比字库索引找出缺字范围。显示成完全不同的文字或乱码符号字符串编码不是UTF-8。检查源码文件编码和串口/网络输入编码转换成UTF-8再说。同一段文字时好时坏要么是缓存命中问题要么是内存分配失败。优先查LVGL内存池水位确认没有被挤压到极限。显示位置偏移、字体重叠get_glyph_dsc里返回的advance宽度或者bpp位深和实际位图数据不匹配导致LVGL计算下一个字符的起始位置出错。检查字库转换时的参数是否是按同一个字体生成的。5.2 工具链与排查步骤遇到问题先从这几步查起当你对着满屏豆腐块束手无策时按这个顺序排查确认编码环节把要显示的中文写死在代码里确认源码文件是UTF-8编码。然后用串口打印或者调试器查看字符串的十六进制值确认是E4B8AD“中”的UTF-8编码还是D6D0“中”的GBK编码。这一步一分钟就能做完能排除掉大量编码维度的问题。确认字库文件内容用一个PC端的LVGL模拟器加载同一个字库文件尝试显示同样的字符串。PC模拟器跑一遍就能确认是字库本身的问题还是嵌入式环境的问题。检查get_glyph_dsc的返回值在回调函数里加日志或者断点看LVGL请求的Unicode码是否和你预期一致索引表是否查得到。查不到就说明字符集覆盖不全查到了但返回失败说明是文件读取或索引解析的问题。确认文件系统读取正常先用lv_fs_read手动读取字库文件前几十字节对比转换工具输出的文件头数据确认读取内容一致。5.3 一个小技巧预加载与预校验启动时先不要急着进主界面。可以先做一次字库的预校验打开字库文件读取文件头或者固定偏移处的数据校验和预期值是否一致。这样能提前发现SD卡文件损坏、文件路径拼错、文件系统挂载失败这一类问题。否则你辛辛苦苦进到界面发现文字全空还得再回头排查是哪一层出了问题。另外一个小技巧是把字库文件放在SD卡的固定目录下比如/FONT/目录然后在代码里把路径定义成一个宏。后续想换字体只要保证文件名不变直接替换SD卡里的文件就行完全不用改固件重烧。我用这个方式给客户迭代过好几版字体整个过程只动了SD卡上的bin文件固件一次没重新编译过。这套从TTF到SD卡外挂字库的方案跑通一次之后后续做任何带屏设备都会轻松很多。我个人的建议是第一次尝试的时候先在LVGL的PC模拟器上把整套流程跑通包括字体转换、lv_fs注册、动态加载回调、LRU缓存全部在PC上调试稳定后再移植到STM32或者其他MCU平台上。PC模拟器调试起来要方便太多不用反复烧录出问题还能直接断点调试。等你把这套流程在模拟器上跑顺了再往板子上搬整个过程就会顺畅不少那些坑基本也都在模拟器阶段踩完了。
返回列表