2、字体渲染原理:点阵字体与矢量字体、字符编码、字体文件格式
做嵌入式GUI,说白了就是在跟「字」打交道。你想想看,一个屏幕亮起来,用户第一眼看到的就是文字。文字清不清晰、响应快不快,直接决定了这个产品给人的第一印象。
我刚开始做嵌入式UI那会儿,总觉得字体嘛,不就是把字画上去?结果被坑了好几次。有一次产品都量产了,客户反馈说日文显示全是乱码……嗯,从那以后我再也不敢轻视字体渲染这块了。
这一章,咱们就把字体渲染的底裤扒干净。从最底层的点阵和矢量,到字符编码的来龙去脉,再到常见的文件格式,一条线捋清楚。
2.1 点阵字体 vs 矢量字体
先说最直观的区别。点阵字体,就是用一个一个像素点拼出字符。矢量字体,是用数学曲线来描述字符轮廓。
核心区别一句话:点阵字体是「画出来」的,矢量字体是「算出来」的。
2.1.1 点阵字体
点阵字体把每个字符定义在一个固定大小的网格里。比如一个16x16的点阵字,就是256个像素点,每个点要么亮要么灭。
// 一个简单的 'A' 字符 8x8 点阵定义
// 1 表示点亮,0 表示熄灭
const uint8_t font_A[8] = {
0b00011000, // **
0b00100100, // * *
0b01000010, // * *
0b01111110, // ******
0b01000010, // * *
0b01000010, // * *
0b01000010, // * *
0b00000000 //
};
优点很明显:渲染速度极快,直接往显存里填数据就行,不需要任何计算。缺点也致命:放大就糊,锯齿严重。
我个人习惯在低分辨率屏幕(比如128x64的OLED)上用点阵字体。那种屏幕像素本身就大,矢量字体反而看不清。
我的经验:在STM32这类MCU上做菜单,用12x12或16x16的点阵字就够了。再大就考虑矢量,否则Flash吃不消。
2.1.2 矢量字体
矢量字体用贝塞尔曲线描述字符轮廓。TrueType用的就是二次贝塞尔,OpenType用三次贝塞尔。
说白了,矢量字体存的是「怎么画这个字」的数学公式,而不是「这个字长什么样」的像素点。所以它天然支持任意缩放,而且边缘光滑。
代价呢?渲染需要计算。每次显示都要把曲线转换成像素点,这个过程叫「光栅化」。在资源受限的嵌入式设备上,这可不是闹着玩的。
| 对比项 | 点阵字体 | 矢量字体 |
|---|---|---|
| 存储方式 | 像素点阵 | 数学曲线 |
| 缩放效果 | 锯齿严重 | 平滑无损 |
| 渲染速度 | 极快(直接拷贝) | 较慢(需光栅化) |
| Flash占用 | 固定大小,多字号翻倍 | 一套数据,任意字号 |
| 适用场景 | 低分辨率屏、简单UI | 高分辨率屏、复杂界面 |
2.2 字符编码:ASCII、Unicode、UTF-8
字体文件里存的是「字形」,但怎么把「字符」映射到「字形」?这就靠字符编码了。这块我曾经踩过大坑,必须好好说说。
2.2.1 ASCII
ASCII是最基础的编码,用7位表示128个字符。包括英文字母、数字、标点和控制字符。
// ASCII 编码示例
'A' -> 0x41 (65)
'a' -> 0x61 (97)
'0' -> 0x30 (48)
'\n' -> 0x0A (10)
够用吗?显然不够。中文怎么办?日文怎么办?阿拉伯文呢?
2.2.2 Unicode
Unicode的目标是给全世界所有字符一个唯一的编号。目前已经定义了超过14万个字符,每个字符有一个码点(Code Point),通常写成 U+XXXX 的格式。
// Unicode 码点示例
'A' -> U+0041
'中' -> U+4E2D
'文' -> U+6587
'😊' -> U+1F60A
注意,Unicode只是规定了编号,没规定怎么存储。这就引出了UTF-8。
2.2.3 UTF-8
UTF-8是Unicode的一种存储方式,也是嵌入式系统里最常用的。它的特点是变长编码,兼容ASCII。
| 码点范围 | UTF-8编码(二进制) | 字节数 |
|---|---|---|
| U+0000 ~ U+007F | 0xxxxxxx | 1字节 |
| U+0080 ~ U+07FF | 110xxxxx 10xxxxxx | 2字节 |
| U+0800 ~ U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 3字节 |
| U+10000 ~ U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 4字节 |
我曾经踩过的坑:在一个项目里,我用GB2312编码存中文字符串,但字体文件用的是Unicode索引。结果所有中文都显示成方框。后来统一成UTF-8才解决。记住:编码不一致,显示必出问题。
在嵌入式系统里,我建议统一使用UTF-8。原因有三:
- 兼容ASCII,英文字符不浪费空间
- 没有字节序问题(不像UTF-16分大端小端)
- C标准库的字符串函数基本都能直接处理
2.3 字体文件格式:BDF、FNT、TTF
搞清楚了编码,咱们来看看字体文件本身长什么样。嵌入式里常见的格式就这三种。
2.3.1 BDF(Bitmap Distribution Format)
BDF是点阵字体的文本格式,说白了就是纯文本描述。每个字符用ASCII文本定义,可读性很好。
STARTCHAR 中
ENCODING 20013
SWIDTH 1000 0
DWIDTH 16 0
BBX 16 16 0 -2
BITMAP
0000
0000
7FFE
4002
...
ENDCHAR
优点:可读性强,容易解析。缺点:文件体积大,解析慢。
我记得在做一个Linux嵌入式项目时,系统启动时加载一个GB2312的BDF字体,光解析就花了3秒多。后来果断转成了二进制格式。
2.3.2 FNT(Windows FNT格式)
FNT是Windows早期用的点阵字体格式,也是二进制格式。结构简单,解析快。
// FNT文件头结构(简化版)
typedef struct {
uint16_t version; // 版本号
uint32_t fileSize; // 文件大小
uint8_t copyright[60]; // 版权信息
uint16_t type; // 字体类型
uint16_t pointSize; // 字号
uint16_t vertRes; // 垂直分辨率
uint16_t horizRes; // 水平分辨率
uint8_t ascent; // 上升高度
uint8_t descent; // 下降高度
// ... 更多字段
} FNT_HEADER;
FNT格式在嵌入式里用得不多,主要是历史遗留。但它的结构设计值得学习——头信息+索引表+位图数据,这种三段式结构在嵌入式字体引擎里很常见。
2.3.3 TTF(TrueType Font)
TTF是矢量字体的代表格式,由Apple和Microsoft联合开发。它包含:
- 字符到字形映射表(cmap)
- 字形轮廓数据(glyf)
- 字体度量信息(hhea、hmtx等)
- 提示信息(hint),用于小字号时保持清晰
TTF的渲染流程大致是:
- 从cmap表找到字符对应的字形索引
- 从glyf表读取轮廓数据(贝塞尔曲线控制点)
- 根据字号缩放轮廓
- 应用hint信息优化小字号显示
- 光栅化:把轮廓转换成像素
我的建议:在MCU上做矢量字体渲染,别自己从头写。用FreeType或者MicroPython的font库,它们已经优化得很好了。自己写的话,光一个hint处理就能让你怀疑人生。
2.4 知识体系总览
下面这张图把本章的核心脉络串起来了。从字符编码到字体格式,再到渲染方式,一条线走通。
看到这张图,你应该能明白:字体渲染不是孤立的技术,而是一条完整的链路。从字符编码到字体文件,再到渲染方式,每一层都有它的取舍和考量。
我个人做选型时,会先问三个问题:屏幕分辨率多少?MCU主频多少?需要支持几种语言?答案出来了,选型方案也就八九不离十了。
本章核心要点:
- 点阵字体快但占Flash,矢量字体灵活但吃算力
- 编码不一致是乱码的根源,嵌入式首选UTF-8
- BDF可读性强,FNT解析快,TTF功能全
- 选型要在速度、质量、存储三者间找平衡