☰
拆解LVGL lv_font:中文字库外部存储按需加载驱动
2026/11/3 17:11:23 网站建设 项目流程

做中文界面的人大概都撞过同一堵墙:UI 设计稿上随便一句“设置”“返回”“确认删除”,落到嵌入式屏幕上就是几千个汉字,选 16×16 的 4bpp 点阵,两千字左右的常用字表压完还有 1MB 上下,而目标板上的 Flash 总共就 512KB,还要给程序、图片、音效留位置。这时候多数人的第一反应是“再压一压 bpp”,从 4bpp 降到 1bpp,字变得像砂纸磨过一样,锯齿横生也就算了,遇到“圆”“赢”这种笔画密集的字直接糊成一坨。

真正该动的其实是LVGL 里 lv_font 这套东西的加载方式。很多人把字体当成一张“图片”在理解——转换工具吐出一个巨大的 C 数组,编译进固件,完事。但你把lv_font.h翻到lv_font_t的定义那里就会看到,它压根不是数据块,它是一组回调:你要什么字,我现告诉你这个字多宽多高、偏移多少,再给你一块位图。既然是一组接口,那数据放哪儿、什么时候读,就是你能自己决定的事。这篇就顺着这条线往下挖,从lv_font_t的内存布局、一次绘制的取字链路,一直讲到怎么手写一个把字库丢进外部存储的按需加载驱动。这是这个系列的第 7 篇,前面几篇讲的多是转换参数、bpp 选择、字库合并这类“上层用法”,这一篇往底层走一层,把 lv_font 的骨架拆开看。

适合谁看:已经能把 LVGL 跑起来、能把官方转换工具的输出塞进工程、但被字库体积或者中文显示效果卡住的人。纯新手也能看,因为我会把每个字段为什么存在讲清楚,只是代码部分需要一点 C 语言基础。

1. 拆开 lv_font_t:一个字体对象在内存里到底是什么

1.1 三个必填项和两个可选标签

先看骨架。以 8.x 这一代为例,lv_font_t的成员大致是这些东西:

typedef struct _lv_font_t { lv_coord_t line_height; /* 一行文字占多高 */ lv_coord_t base_line; /* 基线位置 */ uint8_t subpx : 2; /* 亚像素渲染的相位,一般填 0 */ uint8_t underline_position : 2; uint8_t underline_thickness : 4; uint8_t dsc : 1; /* 是否带 fmt_txt 描述结构 */ const struct _lv_font_t * fallback; /* 找不到字时去问谁 */ void * user_data; /* 你的私有上下文 */ bool (*get_glyph_dsc)(const struct _lv_font_t *, lv_font_glyph_dsc_t *, uint32_t letter, uint32_t letter_next); const uint8_t * (*get_glyph_bitmap)(const struct _lv_font_t *, uint32_t letter); } lv_font_t;

抛开下划线那几个装饰性字段不谈,真正撑起一个字体的只有三样:line_height、get_glyph_dsc、get_glyph_bitmap。剩下的是可选装饰——fallback用来做字库接力,user_data用来挂自己的上下文,subpx是给亚像素渲染留的口子。

这个结构说明了一件很关键的事:LVGL 渲染文字的时候,从来不知道也不关心你的字库是什么格式。它只会拿着一个 Unicode 码点去问get_glyph_dsc:这个字有吗?多宽多高?往左偏多少、往上偏多少?前进量多少?然后再问get_glyph_bitmap:把它的点阵给我。至于点阵是躺在内部 Flash 的 const 数组里,还是躺在 SPI Flash 的某个扇区里,还是一片用算法实时算出来的矢量轮廓,对渲染层来说完全一样。

这就解释了一个新手常犯的困惑:为什么有的工程里字体是.c文件,有的是.bin文件,都能跑?因为它们只是同一个接口的两种实现。官方转换工具吐.c,本质上是生成了一张lv_font_fmt_txt_dsc_t描述表加上位图数组,再生成两个现成的回调lv_font_get_glyph_dsc_fmt_txt和lv_font_get_glyph_bitmap_fmt_txt,最后拼出一个lv_font_t常量。你完全可以不生成这两个回调,自己写一套。

1.2 line_height 与 base_line:两个数字决定整段文字的垂直节奏

line_height好理解,就是行高。它不参与单个字形的绘制,只参与“多行文本怎么排”。lv_label在计算自己的高度时,大致是行数 * line_height,lv_textarea的光标移动、滚动范围也都依赖它。

而base_line影响的是单行文字在行框内的垂直位置。它的语义是基线相对于行框底部的距离。你可以拿一张横格纸想象:每行都有一条隐形的基准线,字母“a”“x”“m”坐在线上,字母“g”“p”“y”的尾巴垂到线下,字母“b”“d”“h”的竖笔冲上去。这条线就是基线。

问题在于,转换工具算出来的base_line和你在画面上看到的“居中”之间,往往有 1 到 3 个像素的偏差。原因不难猜:不同字符集里,最高点可能来自“Ä”也可能来自“|”,最低点可能来自“g”也可能来自“,”,转换工具只能按整个字库的极值来算,你实际用的那几十个字未必占满这个范围。所以真实项目里,我一般会保留工具算出来的值,然后在第一次上屏之后肉眼校准一次,如果发现文字整体偏上,就把base_line加 1 试试。这个值不需要精确到理论值,需要的是“看起来齐”。

提示:line_height改小会让多行文本挤在一起,但单行不受影响;base_line改大改小则单行和多行都会动。调字体垂直位置时,先确认你动的到底是哪一个。

还有一个坑:做中英文混排时,中文字库和英文字库的line_height通常不一致。中文字形普遍占满整个字身框,英文只有字母主体加上下伸部,所以英文的line_height往往比中文小 2 到 4 个像素。如果你用 fallback 链把两个字体接起来,行高以主字体为准,这时候英文会显得“浮”在行框上部,解决办法是给英文字体单独调base_line,或者干脆把两者用同一个line_height重新生成。

1.3 bpp 不是画质档位,它是位图的打包方式

bpp 这个东西最容易被误解成“画质等级”。1bpp 就是黑白,4bpp 就是 16 级灰,听起来像难度选择。它确实和画质有关,但它是存储格式的一部分,这一点更重要。

4bpp 意味着每个像素用 4 个二进制位表示,也就是一个字节塞两个像素。所以一行的字节数是(box_w * 4 + 7) / 8,注意是先乘后加 7 再除 8,因为每行都要按字节对齐。16 像素宽的一行刚好 8 个字节,不多不少;17 像素宽的一行就是(17*4+7)/8 = 9字节,最后那个字节只用了一半,高位空着。如果你按17*4/8 = 8去算,每行少读一个字节,整张图就会斜着错位——这是自己写字体驱动时最常见的一类花屏原因。

再说像素内部的排布。带透明度的位图(LVGL 管它叫 alpha 通道),对于 4bpp,约定是同一个字节里低位对应左边的像素,高位对应右边的像素。也就是说字节0x3A,左边像素的 alpha 是0xA,右边是0x3。这个顺序和不少人的直觉相反,如果不注意,本来该从左到右由浅入深的一个渐变,画出来会反过来。1bpp 同理,字节0b10100000,最左边的像素是最高位。

至于 2bpp、8bpp 这些,原理一致,只是每个像素占的位数不同。选哪个有明确的判断标准:字形尺寸小于 12 像素左右,4bpp 已经完全看不出层次差别,1bpp 太糙;12 到 24 像素,4bpp 是甜点区;再往上才考虑 8bpp。同样是 16 像素的汉字,1bpp 和 4bpp 的存储比是 1:4,两千字大概差出 900KB,这个账必须先算。

2. 一次文字绘制请求,LVGL 是怎么找到第 N 个字的

2.1 cmap:Unicode 到字形索引的那张映射表

UTF-8 字符串送进渲染层之后,第一件事是解码成一个一个的 Unicode 码点。注意这里拿到的是码点,不是字形编号。码点比如“中”是 U+4E2D,但字体文件里这个字排在第几个,是另一回事。

官方.c字体的做法是维护一张叫 cmap 的表,把“码点区间”映射到“字形索引区间”,中间可能有偏移,也可能直接在索引表里查。转换工具会把这些表压得很紧凑,常见的形式是三段:一段记录每个码点区间从哪儿开始、到哪儿结束、对应的字形索引起点;一段是可选的偏移列表;一段是字形索引数组。

自己写驱动的时候不需要搞得这么复杂,但必须解决同一个问题:怎么快速把码点变成“这个字的数据在哪”。最直接的方案是给每个字建一条记录,码点升序排列,用二分查找。听起来土,但实测比很多人想象中快:两千个字的表,二分只需要 11 次比较,每次比较就是读两个字节,比哈希表省内存,比线性扫描快几百倍。我做过对比,在 80MHz 级别的主控上,一次二分查找大约 1 到 2 微秒,相对于后续的位图读取(SPI 传输动辄几十微秒),完全可以忽略。

2.2 get_glyph_dsc 返回的六个数字,分别落在画布哪里

lv_font_glyph_dsc_t里的字段,是渲染层绘制一个字形所需的全部几何信息。我把它们逐个对应到画布坐标上,这是理解偏移的关键。

box_w和box_h是字形点阵的宽高,也就是这块“橡皮图章”的尺寸。注意它不包含左右空白,纯字形本体。ofs_x是这块图章的左边缘相对于“当前笔位置”的横向偏移,ofs_y是它的上边缘相对于基线的纵向偏移,向上为正。adv_w是画完这个字之后,笔往右移动多少,单位是 1/16 像素——也就是说,一个宽度为 8 像素的字符,adv_w填 128。

为什么adv_w要用 1/16 精度?因为排版需要处理字间距的微调,尤其是西文,字宽经常不是整数,i和m并排的时候如果只按整数像素推进,累积误差会很明显。用 1/16 精度可以让误差被摊薄。代价是你在自定义驱动里必须自己乘 16,忘了乘,字就会挤成一团;乘错了方向(比如写成除以 16),字会散开成一行稀稀拉拉的字符。

ofs_y的符号是最容易搞反的。它相对的是基线,而且向上为正,所以一个正常的大写字母,ofs_y是一个负数(因为它的上边缘在基线之上)。而小写字母“g”的下伸部,ofs_y会是正数。如果你全部按 0 处理,文字会整体往下掉,和别的元素对不齐。

bpp字段告诉渲染层怎么解包这块位图,bitmap_index在自定义驱动里可以忽略(官方的 fmt_txt 实现会拿它去算字节偏移,你自己管理偏移就不需要)。

2.3 get_glyph_bitmap 的返回值语义,以及最容易被写错的地方

这个回调的签名看起来极简:给一个码点,返回一个const uint8_t *。但这里藏着一个非常关键的隐含约定——返回的指针必须在你返回之后的整个绘制过程中保持有效,而且指向的必须是完整解包前的原始位图数据。

说人话就是:你不能返回一个“用完就释放”的堆内存,也不能返回一个会被下一次调用覆盖的临时缓冲区。因为渲染层拿到指针之后还要做展开、混合、写显存,中间可能穿插别的操作。

对于内部 Flash 里的 const 数组,这个约束天然满足。但对于外部存储,就麻烦了:你没法把 SPI Flash 的地址空间映射成指针(除非有内存映射模式)。所以必须读进 RAM。这时候那个缓冲区必须是静态的或者挂在 user_data 里的常驻缓冲区,不能是局部变量。

那问题来了:如果渲染层在拿到指针之后,又去处理另一个字,缓冲区被覆盖了怎么办?答案是——它不会。渲染是串行的,一个字形画完才会取下一个。但这并不意味着可以随便用同一个缓冲区,因为有些版本的实现会把指针缓存起来做复用判断。我一般的做法是保守一点:每个字体上下文配一块独立缓冲区,大小按字库中最大的box_w * box_h * bpp / 8来定,写死在初始化里。

返回值还有一个分支要处理:如果这个字确实没有点阵(比如空格、零宽字符),应该返回NULL。返回 NULL 不会崩,渲染层会跳过绘制,但adv_w仍然生效,所以空格依然能撑开间距。有些人为图省事给空格返回一个全零的小位图,其实没必要。

3. 字形缓存机制:为什么同一个字第二次绘制会快很多

3.1 缓存的键和容量

LVGL 在不同版本里对字形缓存的处理差别不小,8.x 早期是在 fmt_txt 实现里做了轻量的按索引缓存,9.x 则把它抽象成了更明确的字体缓存对象。但不管哪个版本,逻辑是同一个意思:同一个字形,短时间内会被反复绘制,没必要每次都跑一遍“查表 + 读 Flash + 解包”的全流程。

想想一个典型的界面:列表里十个条目,每个条目里都有“编辑”两个字。一次整屏刷新就是二十次“编”和“删”的绘制请求。如果没有缓存,外部 Flash 要读二十次,每次几百字节,SPI 上的时间就够呛。有了缓存,第一次读完放进 RAM,后面十九次直接命中。

缓存的键通常是“字体对象 + 字形索引”这一对。容量则取决于你的配置,小到几十个条目,大到几百个。判断容量够不够,看的是同一屏里不同字形的数量,而不是字符总数。一个中文菜单,就算有 200 个字,去重后可能只有 60 个不同字形,60 条缓存就够了。但如果是个滚动列表,一屏能刷出几百个不同汉字,缓存就会频繁淘汰,反而多出一层管理开销。

3.2 缓存抖动的现象和一次实测对比

怎么判断自己是不是撞上缓存抖动?方法很简单:在get_glyph_bitmap里加一个计数器,每被调用一次就加一,跑一帧之后把数字打印出来。如果这个数字远大于“本帧不同字形数量”,说明缓存没生效或者容量太小。

我在一块 100MHz 级别的主控上做过一组对比,场景是一个 240×320 的列表页,每屏 8 个条目,每个条目 12 个汉字。字库放在 SPI Flash(时钟 40MHz),字库规模 1800 字,16×16,4bpp。

配置单帧 get_glyph_bitmap 调用次数单帧耗时(含绘制)
缓存条目 0(模拟关闭)96约 21ms
缓存条目 3296(严重抖动)约 22ms
缓存条目 6440约 13ms
缓存条目 12836约 11ms
字库编译进内部 Flash,缓存 12836约 6ms

有几个结论值得记一下。第一,缓存条目数不足的时候,不只是没收益,反而因为多了一层淘汰逻辑,比直接关掉还慢一点点。第二,从 64 提到 128,收益已经很小了,因为实际去重后的字形数量就那么多。第三,即使缓存开满,外部 Flash 的方案仍然比内部 Flash 慢一倍左右——这是物理限制,绕不过去,但 11ms 对大多数 30fps 的界面够用了。

提示:如果你的场景是“静态页面,偶尔刷新”,外部字库完全没问题。如果是“高速滚动、每帧全变”,就得考虑把热点字(比如标题、按钮标签)单独做一个小子集编进内部 Flash,剩下的走外部,用 fallback 链串起来。

3.3 自绘字体该不该绕过缓存

自己写的字体驱动,有个选择:是让 LVGL 的缓存层来管,还是自己在user_data里做一层。

我倾向让上层管,理由是简单。但有一种情况例外——实时生成的矢量字形。比如接一个外部的矢量字体引擎,每次都要跑轮廓解析和光栅化,这时候如果用上层的缓存,你无法控制“哪些字形值得留”。常见做法是自己维护一个小的 LRU,把已经光栅化好的小位图存起来,然后让get_glyph_bitmap返回自己的缓冲。这样你还能顺便实现“常用字预加载”,开机时把界面里所有固定文案的字先烘一遍,第一帧就不会卡。

代价是要处理线程安全(如果有多任务刷新)和内存碎片。如果没有多任务,用固定大小的槽位数组,不要用动态分配,这一点在嵌入式上怎么强调都不过分。

4. 手写一个“字库放外部存储”的按需加载字体驱动

4.1 索引表设计:先定死两件事

动手之前先想清楚两件事:索引表放哪儿,位图放哪儿。

索引表一定要放内部 Flash。它小,而且访问频繁。粗略算一下:每个字一条记录,包含码点、宽高、偏移、推进量、位图偏移和长度,压缩后每条 10 到 16 字节。两千字就是 20 到 32KB,内部 Flash 完全吃得下。位图才是大头,4bpp 的 16×16 汉字平均 110 字节左右,两千字约 220KB,这部分丢外部。

记录结构我一般这么定:

typedef struct { uint32_t unicode; /* 码点,全表升序排列 */ uint32_t bitmap_ofs; /* 位图在字库分区内的字节偏移 */ uint16_t bitmap_len; /* 位图字节数 */ uint8_t box_w; uint8_t box_h; int8_t ofs_x; int8_t ofs_y; uint8_t adv_w_16; /* 推进量 * 16,注意别溢出 */ } ext_glyph_t;

这里有三个细节值得说。adv_w_16用uint8_t存,意味着推进量最大只能到 15.9 像素,16 像素的字宽勉强够,18 像素的字就溢出了。字号超过 16 的时候必须换成uint16_t。bitmap_len用uint16_t,够存 65535 字节的单个字形,正常字体不会超过这个数。另外这个结构体在 32 位对齐下实际占用是 16 字节而不是 15 字节,因为编译器会补一个填充字节——如果你要精确算表大小,记得按sizeof来,别手算。

排序这个要求必须严格遵守。二分查找的前提是升序,转换脚本输出的时候如果按字库里的原始顺序而不是按码点排序,查表会静默失败——不报错,就是某些字死活显示不出来,这种问题排查起来非常磨人。

4.2 两个回调的完整实现

先看上下文结构和查表函数:

typedef struct { const ext_glyph_t * table; uint32_t count; uint8_t bpp; uint8_t * read_buf; uint16_t read_buf_size; uint16_t last_len; /* 上次读到的长度,省一次查表 */ uint32_t last_unicode; void (*flash_read)(uint32_t ofs, void * dst, uint32_t len); } ext_font_ctx_t; static const ext_glyph_t * ext_lookup(const ext_font_ctx_t * ctx, uint32_t letter) { uint32_t lo = 0, hi = ctx->count; while(lo < hi) { uint32_t mid = lo + ((hi - lo) >> 1); uint32_t u = ctx->table[mid].unicode; if(u == letter) return &ctx->table[mid]; if(u < letter) lo = mid + 1; else hi = mid; } return NULL; }

二分写lo + (hi - lo) / 2而不是(lo + hi) / 2,纯粹是习惯,避免极端情况下的溢出,代价是零。

然后是描述回调:

static bool ext_get_glyph_dsc(const lv_font_t * font, lv_font_glyph_dsc_t * dsc, uint32_t letter, uint32_t letter_next) { (void)letter_next; ext_font_ctx_t * ctx = (ext_font_ctx_t *)font->user_data; const ext_glyph_t * g = ext_lookup(ctx, letter); if(g == NULL) return false; /* 返回 false,交给 fallback */ dsc->bitmap_index = 0; /* 自己管理偏移,用不到 */ dsc->adv_w = g->adv_w_16; /* 已经是 *16 的值,不要再乘 */ dsc->box_w = g->box_w; dsc->box_h = g->box_h; dsc->ofs_x = g->ofs_x; dsc->ofs_y = g->ofs_y; dsc->bpp = ctx->bpp; dsc->is_placeholder = false; return true; }

return false这一句是 fallback 链的关键。上层拿到 false 之后会自动去问font->fallback指向的字体。所以自定义字体的通用做法是:主字体只装中文,fallback挂一个官方内置的小 ASCII 字体,中文查不到就走英文,英文查不到就走内置默认。三级链条能覆盖绝大多数场景。

位图回调:

static const uint8_t * ext_get_glyph_bitmap(const lv_font_t * font, uint32_t letter) { ext_font_ctx_t * ctx = (ext_font_ctx_t *)font->user_data; if(letter != ctx->last_unicode) { const ext_glyph_t * g = ext_lookup(ctx, letter); if(g == NULL || g->bitmap_len == 0) return NULL; if(g->bitmap_len > ctx->read_buf_size) return NULL; /* 缓冲区太小,防御 */ ctx->flash_read(g->bitmap_ofs, ctx->read_buf, g->bitmap_len); ctx->last_unicode = letter; ctx->last_len = g->bitmap_len; } return ctx->read_buf; }

这里加了个last_unicode的小缓存,因为实际绘制流程是“先问描述,再要位图”,两次调用之间只隔了几微秒,码点大概率是同一个。省掉一次二分查找,两千字表大约省 1 到 2 微秒,占比不大,但这个优化是零成本的,顺手做了。

最后是字体对象的组装:

lv_font_t * ext_font_create(const ext_glyph_t * table, uint32_t count, uint8_t bpp, uint8_t * buf, uint16_t buf_size, lv_coord_t line_h, lv_coord_t base_line) { ext_font_ctx_t * ctx = lv_mem_alloc(sizeof(ext_font_ctx_t)); lv_font_t * font = lv_mem_alloc(sizeof(lv_font_t)); if(!ctx || !font) return NULL; ctx->table = table; ctx->count = count; ctx->bpp = bpp; ctx->read_buf = buf; ctx->read_buf_size = buf_size; ctx->last_unicode = 0xFFFFFFFF; ctx->last_len = 0; ctx->flash_read = my_flash_read; lv_memset_00(font, sizeof(lv_font_t)); font->line_height = line_h; font->base_line = base_line; font->subpx = 0; font->underline_position = 0; font->underline_thickness = 0; font->dsc = 0; font->fallback = &lv_font_montserrat_14; /* 换成你自己的 */ font->user_data = ctx; font->get_glyph_dsc = ext_get_glyph_dsc; font->get_glyph_bitmap = ext_get_glyph_bitmap; return font; }

注意lv_memset_00那一步不能省。位域成员如果不清零,subpx、underline_position这些字段会带着栈上的随机值,表现是文字莫名其妙带下划线或者亚像素偏移错乱,这属于“看一眼就知道是没初始化”的问题,但真发生时能让人盯半天。

另外fallback我挂的是内置字体,这里要确认你自己的配置里确实启用了那个字体,否则链接阶段直接报错。

4.3 读取对齐、字节序和那个 7 的加法

外部存储读取看着简单,坑集中在三个地方。

第一是对齐。某些 SPI Flash 控制器要求目标缓冲区按 4 字节对齐,某些 DMA 通道干脆要求按 32 字节。read_buf如果是lv_mem_alloc出来的,通常按字对齐,但不保证是 4 的倍数偏移。稳妥做法是把它定义成静态全局数组并加对齐属性,或者手动向上取整分配。我见过一个案例:非对齐读取时前 3 个字节正常,第 4 个字节开始变成随机数,最终效果是字的下半截花屏——因为位图数据被整体错位了。

第二是字节序。字库文件生成时是按小端写的,如果你的读取路径经过了某种转换(比如从 SD 卡读文件再写进 Flash,中间做过 16 位搬运),可能被大端化。表现是所有字都像被“打碎”了。判断方法是:取一个纯色的方块字符,看解出来的字节是不是单一的重复模式,如果不是,就是字节序或者位序反了。

第三就是那个(box_w * bpp + 7) / 8。这个公式在每个字形上都要用一次,但如果你的索引表里直接存了bitmap_len,就不用重算了。存长度比存行宽更稳妥,因为它把“行对齐”的责任交给了生成脚本,你只管按长度读。我强烈建议这么做,能少掉一整类 bug。

4.4 实测数据和几条真实取舍

同一块板子、同一个界面,我把三种方案都跑了一遍。

方案内部 Flash 占用外部占用首帧耗时稳态帧耗时
1800 字全量编进固件约 230KB0约 6ms约 6ms
常用 300 字内置 + 其余外部约 45KB约 190KB约 9ms约 7ms
全量外部按需加载约 24KB(索引表)约 230KB约 14ms约 11ms

第一种方案在大多数小屏幕、固定文案的产品上其实是最优解——体积换简单,何乐不为。第二种是我个人最推荐的折中:把 UI 上永远不会变的那些字(按钮标签、标题、单位符号)挑出来编进内部,剩下的走外部。首帧只慢 3ms,稳态几乎和全内置一样。第三种适合字库经常要换、或者文案完全由后台下发的场景,代价是首帧明显变慢,而且外部 Flash 的读取延迟会随温度、电压波动,需要留余量。

这里还有一个隐含的取舍:外部字库意味着升级字库要刷外部存储。如果你的产品有 OTA 能力,得把字库分区纳入升级包的管理,并且考虑版本不匹配——程序里索引表和外部位图对不上,会出现“字显示成别的字”这种诡异现象。我给索引表加过一个 4 字节的魔数加版本号,启动时校验,不匹配就退回内置字体。这几行代码救过一次现场。

5. 排查手册:缺字、错位、花屏分别指向哪一层

5.1 显示成方块、问号,或者干脆一片空白

先分清是“没找到字”还是“找到了但画不出来”。

如果显示的是一个空心方框或者固定占位符,说明get_glyph_dsc返回了 true 但is_placeholder被置位,或者上层用了占位逻辑——本质是“这个码点在你所有 fallback 字体里都查不到”。排查顺序:先把码点打出来,确认字符串解码没问题;再单独调用一次lv_font_get_glyph_dsc看返回值;如果返回 false,八成是索引表里没有这个字,或者表不是升序的,二分走空。

如果是完全空白但位置被占住了,说明描述拿到了,位图没拿到。最常见的原因是get_glyph_bitmap返回了 NULL 或者返回的缓冲区内容全零。也可能是bpp填错了——描述里写 4bpp,实际数据是 1bpp 的,解出来就是一堆看起来像噪点的东西,缩小看就像空白。

还有一种情况是字库生成时漏了字。转换脚本按你给的字符集生成,如果你的中文字符串是从别的地方复制来的,里面混进了全角空格、不换行空格、零宽字符,这些家伙可能不在字符集里,而且它们在编辑器里看不见。我一般会在生成前把文案过一遍,把所有非打印字符列出来人工确认。

5.2 整体上移、下移、行距怪异

垂直方向的问题,九成出在base_line和ofs_y这两个值上。

判断方法很直接:画两个字符,一个“H”一个“g”。“H”的底边应该正好落在基线上,“g”的尾巴应该垂到基线下面。如果“H”的底边浮在基线上面,说明ofs_y的符号反了,或者你把它当成“向下为正”在用。如果两个字的相对位置对,但整体偏移,那是base_line的问题,加减几个像素试出来。

行距问题是line_height的。注意一个细节:line_height和box_h是完全独立的两个东西。有些人在自定义字体里图省事,把line_height直接等于最大box_h,结果多行文本会贴得死死的,因为英文小写字母的行间本来就有空白。正确的line_height应该略大于最大字形高度,具体大多少看字体的设计,中文一般留 2 到 4 像素。

5.3 位图撕裂、右侧多出半列、下半截花掉

这类问题的指向非常明确:位图的字节布局算错了。

右侧多出半列,通常是行字节数算成了box_w * bpp / 8,丢掉了那个加 7。半截花掉,通常是缓冲区越界或者读取错位,前者表现为每行的尾部出现上一个字的残留,后者表现为整块数据偏移。

还有一个隐蔽的:某些字形的box_w是奇数,比如 15。4bpp 下每行 8 字节,最后一字节的高 4 位是空的。如果你的生成脚本在写入时没把这个空位清零,那 4 位就会带上内存里的脏数据,画出来是最右边一列有杂点。这个 bug 只在特定字符上出现,特别容易漏掉。

验证手段:挑三五个有代表性的字形,把解出来的位图用串口按位打印成 ASCII 艺术图,肉眼比对。听起来笨,但比对着屏幕猜快多了。

5.4 fallback 链的顺序陷阱

fallback是个单向链表,查找是“主字体查不到就往下走”。这里有个隐含的性能陷阱:把大字体放在链首。如果你的主字体是两千字的中文库,每个英文字母进来都要先在两千条记录里二分一遍,再落到后面的英文字体。一屏英文 200 个字符,就是 200 次无效查找。

正确顺序是按“命中率从高到低”排。纯中文界面,中文字体在前没问题;中英混排且英文占比大,就该把英文字体放前面。另外链条不要超过三级,每多一级就是一次失败查找的开销,而且调试时极难判断某个字到底是被谁接住的。

我还遇到过一个更阴的:两个 fallback 字体里都有同一个字,但字形尺寸不同。这时候显示的是链首那个,尺寸也是链首的,但如果链首那个的box_h更大,行高不会跟着变,结果就是文字溢出到上一行。所以接链之前,最好先确认相邻字体的度量参数接近。

6. 体积与性能的几条实战取舍

第一,先量化再优化。很多人一上来就要“把字库压到最小”,结果做完了发现瓶颈根本不在字库,而在图片资源或者刷屏策略。我习惯在动手前先看一眼固件里各段的占用,const段里字库通常占总常量数据的 30% 到 60%,如果没到 50%,先别急着折腾字体。

第二,字库裁剪的收益是非线性的。从 3000 字裁到 2000 字,省三分之一;从 2000 裁到 1000,省一半,但你会开始频繁撞到缺字。我的经验线是:菜单类产品 800 到 1200 字够用,带输入法的产品必须 2500 字以上,具体看业务。裁字的时候要从真实文案里统计字频,别凭感觉挑。

第三,bpp 往下降之前,先试试缩字号。16 像素 4bpp 换成 14 像素 4bpp,能省约 23% 的体积,视觉损失比换成 16 像素 1bpp 小得多。真到了必须 1bpp 的地步,务必打开反走样或者手工调阈值,纯二值化的汉字在 16 像素下笔画粘连很严重。

第四,缓存不是越大越好。前面那组数据已经说明,超过实际去重字形数之后,缓存只是白占 RAM。而且缓存条目多了之后,淘汰策略的链表操作本身也有开销。我一般按“单屏最大不同字形数 × 1.5”来定,然后实测再调。

第五,给字库留可替换的边界。哪怕现在决定全量编进固件,也建议把字体访问收敛到一个函数或者一个宏后面,将来要换成外部加载时只改一处。我见过太多项目把&my_font_16硬编码在几十个文件里,后面想换方案得全工程替换。

最后分享一个小技巧:调试字体的时候,把line_height临时改成box_h + 10,画一个带边框的背景,再加一个水平线画在基线位置。你会非常直观地看到每个字的上下伸部、基线、行框之间的关系,比看任何文档都快。等调好了再把这些调试绘制去掉。这招我在第一次接中文字库的时候用过,之后每次换字体都还会用一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询