让小屏也能有“高级感”:ST7735上实现专业级字体渲染实战
你有没有遇到过这样的情况?辛辛苦苦做好的智能温湿度计,硬件精致、代码稳健,可一打开屏幕——满屏锯齿横飞的“火柴人”字母,瞬间拉低整机档次。明明是科技产品,却像十年前的工控板。
这背后,往往就是默认点阵字体在拖后腿。
在如今连电子秤都讲UI设计的时代,嵌入式设备的文字显示早已不能将就。尤其是使用广泛但“颜值平平”的ST7735 驱动 1.8 寸 TFT 屏,如何让它显示清晰、美观、甚至带抗锯齿的文字?答案只有一个:自定义字体渲染。
这不是炫技,而是现代嵌入式 UI 的基本功。本文不玩虚的,带你从零开始,一步步把.ttf字体文件变成能在 STM32 或 ESP32 上流畅运行的中文英文混合界面,真正实现“低成本屏幕 + 高品质视觉”的组合。
为什么 ST7735 值得我们认真对待?
别看 ST7735 是个“老面孔”,它至今仍是许多电池供电设备的首选屏幕方案。原因很简单:
- 便宜:十几块钱就能拿下一块带驱动的小彩屏;
- 小巧:QSPI-40 封装,轻松塞进手表、传感器外壳;
- 省电:支持睡眠模式,待机电流低至几微安;
- 接口简单:四线 SPI 即可驱动,主控资源占用少。
但它也有硬伤:没有内置字库,也不支持矢量渲染。所有内容都得靠 MCU 自己画出来。
换句话说,你想让它显示什么文字、用什么风格,全得你自己“一笔一划”准备好数据,再通过 SPI 一点点“灌”进去。
这就引出了一个关键问题:我们能不能不用原厂那套丑陋的 6x8 点阵字?当然可以——只要我们愿意花点功夫,把喜欢的字体“翻译”成 MCU 能读懂的格式。
自定义字体的本质:一场“离线编译”的艺术
MCU 没有操作系统,更没有 FreeType 库来实时解析 TTF 文件。所以这条路走不通。
但我们有另一条路:提前把字体转成位图数组,固化到 Flash 中,运行时直接查表绘制。
这个过程就像给每个字符拍一张“像素快照”,然后编号存档。当你想显示'A',系统就去翻档案:“哦,第 65 号,宽 12px、高 16px,数据从第 1024 字节开始……”
整个流程分为三步:
- 字体提取:选好字体(比如 Noto Sans),设定字号、是否抗锯齿;
- 数据打包:生成 C 头文件,包含字符信息结构体和像素数据;
- MCU 绘制:写一个高效的
draw_char()函数,按坐标“贴图”。
听起来复杂?其实核心逻辑非常清晰。下面我们拆开来看。
字模怎么来?手动生成不如自动化
手动做一个字符的字模或许可行,但要做几十上百个?不可能。
推荐工具链:
-FontConverter(开源 Python 工具)
-LCD Assistant(经典 GUI 工具,仅英文)
- 或自己写脚本调用Pillow+fonttools
以 Python 脚本为例,你可以这样定义输出格式:
from PIL import Image, ImageDraw, ImageFont def char_to_bitmap(font_path, char, size=16): fnt = ImageFont.truetype(font_path, size) # 获取实际边界(不是方框) bbox = fnt.getbbox(char) width, height = bbox[2] - bbox[0], bbox[3] - bbox[1] # 创建图像并绘制字符(居中) img = Image.new('L', (width, height), 0) # 灰度图 draw = ImageDraw.Draw(img) draw.text((-bbox[0], -bbox[1]), char, font=fnt, fill=255) # 转为字节数组(每行8像素压缩为1字节) pixels = [] for y in range(height): byte_val = 0 for x in range(width): bit = img.getpixel((x, y)) > 128 byte_val |= (bit << (7 - (x % 8))) if x % 8 == 7: pixels.append(byte_val) byte_val = 0 if width % 8 != 0: pixels.append(byte_val) return { 'char': char, 'width': width, 'height': height, 'xoffset': bbox[0], 'yoffset': -bbox[1], # 相对于基线向上偏移 'advance': fnt.getlength(char), # 下一字起点距离 'data': bytes(pixels) }这段代码不仅能提取黑白位图,还能记录精确的xOffset/yOffset,避免字符“飘在空中”或“踩空底线”。
最终生成的.h文件长这样:
const uint8_t font_noto_16_bitmap[] PROGMEM = { 0xFF, 0xC0, 0x3F, ... // 所有字符拼接后的像素数据 }; const font_char_t font_noto_16_chars[95] PROGMEM = { { 6, 16, 0, -14, 7, &font_noto_16_bitmap[0] }, // ' ' { 8, 16, 1, -15, 9, &font_noto_16_bitmap[12] }, // '!' ... };注意关键字PROGMEM—— 这是为了告诉编译器:这些数据别放 RAM!全都扔 Flash 里去。
渲染函数怎么写?效率决定流畅度
有了数据,下一步就是让屏幕“动起来”。
先看关键结构体设计,这是整个系统的骨架:
typedef struct { uint8_t width; uint8_t height; int8_t xOffset; int8_t yOffset; uint8_t advance; const uint8_t *data; // 指向Flash中的位图 } font_char_t; typedef struct { const font_char_t *chars; uint16_t first_char; uint16_t char_count; uint8_t line_height; } font_t;结构简洁,但每一项都有讲究:
xOffset/yOffset:控制字符在基线上的精确定位;advance:不是字符宽度!而是到下一个字符起点的距离,用于自然排版;const+PROGMEM:确保数据不加载进RAM,节省宝贵内存。
接下来是最核心的绘图函数:
void draw_glyph(int16_t x, int16_t y, const font_char_t *g, uint16_t color) { int16_t xo = x + g->xOffset; int16_t yo = y + g->yOffset; uint8_t w = g->width; uint8_t h = g->height; // 设置ST7735显示窗口(批量写入优化) st7735_set_addr_window(xo, yo, xo + w - 1, yo + h - 1); for (int row = 0; row < h; row++) { for (int col = 0; col < w; col += 8) { uint8_t byte = pgm_read_byte(&g->data[row * ((w + 7)/8) + col/8]); for (int b = 0; b < 8 && (col + b) < w; b++) { if (byte & (0x80 >> b)) { st7735_spi_write_color(color); } else { st7735_spi_write_color(BLACK); } } } } }这里有几个性能关键点:
- 使用
st7735_set_addr_window()提前划定区域,避免重复发送命令; - 利用
pgm_read_byte()安全访问 Flash 数据; - 内层循环按字节处理,减少 SPI 通信次数;
- 若启用SPI DMA,可进一步降低 CPU 占用率。
最后是字符串绘制封装:
void draw_string(int16_t x, int16_t y, const char *str, const font_t *font, uint16_t color) { while (*str) { unsigned char c = *str++; if (c < font->first_char) continue; uint16_t idx = c - font->first_char; if (idx >= font->char_count) continue; const font_char_t *g = &font->chars[idx]; draw_glyph(x, y, g, color); x += g->advance; } }就这么简单?没错。但别急,实战中还有不少“坑”等着填。
实战避坑指南:那些文档不会告诉你的事
❌ 问题1:文字边缘发虚、颜色错乱?
原因:RGB565 写入未对齐或命令/数据混淆。
解决:检查底层驱动是否正确切换DC引脚(数据/命令选择)。建议封装为:
#define WRITE_CMD(c) { DC_LOW(); SPI_WRITE(c); } #define WRITE_DATA(d) { DC_HIGH(); SPI_WRITE(d); }❌ 问题2:显示慢如幻灯片?
原因:逐像素写入太耗时!
提速方案:
- 启用硬件 SPI(>10MHz)
- 改用DMA 批量传输
- 使用局部刷新,只更新变化区域
- 避免频繁调用set_window(),缓存上一次区域
❌ 问题3:中文显示不了?
因为 ASCII 只覆盖 0~127。要支持中文,必须:
- 使用 Unicode 编码(如 UTF-8 输入)
- 预生成含常用汉字的子集(如 GB2312 前 3000 字)
- 修改查找逻辑为双字节匹配或哈希索引
小技巧:优先做数字+符号+英文字母,体积小见效快;中文可用外部 SPI Flash 存储,按需加载。
✅ 最佳实践清单
| 项目 | 推荐做法 |
|---|---|
| 字体选择 | 无衬线体(Noto Sans / Roboto / Wqy Micro Hei) |
| 字号范围 | 8~24px,<12px 不建议开启抗锯齿 |
| 存储策略 | 全部放入 Flash,使用const和PROGMEM |
| 性能优化 | 开启 SPI DMA、局部刷新、禁用 Framebuffer(除非动画多) |
| 开发效率 | 写脚本自动转换字体,一键生成头文件 |
更进一步:不只是“能用”,还要“好用”
当你已经实现了基础渲染,就可以考虑升级体验了:
🔹 抗锯齿灰度字体(8bpp → RGB565 映射)
普通黑白字体边缘生硬。我们可以生成8位灰度图,然后映射为半透明效果:
uint8_t gray = pgm_read_byte(ptr); uint16_t blended = blend_color(BLACK, color, gray); // 简单插值 st7735_write_pixel(blended);虽然仍无法真正实现 Alpha 混合(受限于硬件),但已有明显改善。
🔹 图标字体(Icon Font)集成
把常用图标(WiFi、电池、设置)做成字符形式,插入到特殊编码位置:
#define ICON_WIFI '\x01' #define ICON_BAT '\x02' draw_string(10, 10, "Signal: " ICON_WIFI " Level: 78%", &ui_font, WHITE);类似 Font Awesome 的思路,在嵌入式端照样可行。
🔹 动态换行与文本框
结合line_height和最大宽度判断,实现自动折行:
if (current_x + next_advance > max_width) { current_x = start_x; current_y += font->line_height; }适合显示说明文字或菜单项。
结语:让每一个像素都为用户体验服务
很多人觉得:“不就是几个字嘛,能看清就行。”
但用户的第一印象,往往就在打开电源的那一秒被决定了。
你在 ST7735 上多花两小时优化字体,换来的是产品气质的跃迁——从“能用”到“好用”,再到“愿意多看一眼”。
这项技术本身并不复杂,也没有专利壁垒。它的价值不在炫技,而在于对细节的尊重。
下次当你拿起一块 1.8 寸彩屏,请记住:它不只是一个状态指示器,也可以是一个有温度的交互窗口。而这一切,始于你愿意为它换上一套漂亮的字体。
如果你正在开发智能仪表、便携设备或创客项目,不妨试试把默认字体换成 Noto Sans 16pt,你会发现——原来小屏幕,也能有高级感。
欢迎在评论区分享你的字体渲染实践,或者你最常用的嵌入式 UI 技巧。我们一起把“难看”的嵌入式界面,彻底扫进历史。