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的渲染流程大致是:

  1. 从cmap表找到字符对应的字形索引
  2. 从glyf表读取轮廓数据(贝塞尔曲线控制点)
  3. 根据字号缩放轮廓
  4. 应用hint信息优化小字号显示
  5. 光栅化:把轮廓转换成像素

我的建议:在MCU上做矢量字体渲染,别自己从头写。用FreeType或者MicroPython的font库,它们已经优化得很好了。自己写的话,光一个hint处理就能让你怀疑人生。

2.4 知识体系总览

下面这张图把本章的核心脉络串起来了。从字符编码到字体格式,再到渲染方式,一条线走通。

字体渲染知识体系 字体渲染知识体系总览 字符编码层 ASCII(7位,128字符) → Unicode(码点体系) → UTF-8(变长存储) 字体文件格式层 BDF(点阵·文本格式) FNT(点阵·二进制格式) TTF(矢量·轮廓格式) 渲染方式层 点阵渲染(直接拷贝) → 矢量光栅化(贝塞尔→像素) 最终输出:屏幕上的清晰文字 渲染速度 × 显示质量 × 存储开销 三者平衡 💡 嵌入式选型建议:低资源用点阵+BDF/FNT,高资源用矢量+TTF,编码统一用UTF-8

看到这张图,你应该能明白:字体渲染不是孤立的技术,而是一条完整的链路。从字符编码到字体文件,再到渲染方式,每一层都有它的取舍和考量。

我个人做选型时,会先问三个问题:屏幕分辨率多少?MCU主频多少?需要支持几种语言?答案出来了,选型方案也就八九不离十了。

本章核心要点:

  • 点阵字体快但占Flash,矢量字体灵活但吃算力
  • 编码不一致是乱码的根源,嵌入式首选UTF-8
  • BDF可读性强,FNT解析快,TTF功能全
  • 选型要在速度、质量、存储三者间找平衡