在STM32上用u8g2绘制中文字符:从零构建高效嵌入式HMI
你有没有遇到过这样的场景?项目明明功能都实现了,客户却皱着眉头说:“界面全是英文,我们工人看不懂啊。”——于是原本“完工”的状态瞬间被打回“待优化”。这正是许多嵌入式开发者在工业控制、医疗设备或智能家居产品开发中面临的现实问题:如何让资源有限的MCU也能显示清晰可读的中文?
今天我们就来解决这个痛点。主角是轻量级图形库u8g2和广泛使用的STM32平台。目标很明确:不加外部Flash、不用RTOS、不换大容量芯片,照样把“设置”、“报警”、“温度”这些常用汉字稳稳地画在OLED屏幕上。
这不是理论推演,而是基于多个量产项目的实战总结。我们将一步步拆解字体定制、内存管理、硬件对接和性能调优的关键技巧,最终实现一个既省资源又流畅可用的中文人机界面(HMI)系统。
为什么选u8g2?因为它够“瘦”
先说结论:如果你正在为STM32这类资源紧张的MCU做本地显示,而且需要支持非ASCII字符,那u8g2 几乎是最优解之一。
它不像LVGL那样功能丰富但动辄占用几十KB RAM和上百KB Flash,也不依赖操作系统或动态内存分配。相反,它的设计哲学是“最小化运行时开销,最大化编译期确定性”。
比如一块常见的0.96英寸SSD1306 OLED屏(128×64分辨率),使用u8g2的Page Mode模式,RAM占用仅约128字节,而全缓冲模式也才1KB出头。相比之下,LVGL在这种小屏上光帧缓冲就要吃掉至少8KB RAM。
更关键的是,u8g2对中文的支持不是靠内置庞大全字库,而是通过高度可裁剪的自定义字体机制来实现。这意味着你可以只包含你需要的那几十个汉字,而不是塞进几万个用不到的字符。
📌一句话定位:
u8g2 = 轻量绘图引擎 + 可定制字体系统 + 硬件无关接口
这种“按需加载”的思路,正是我们在资源受限环境下破局的核心武器。
中文显示的本质:不是“渲染”,而是“查表”
很多人一开始会误以为u8g2像手机系统一样能“自动显示任何Unicode字符”。其实不然。u8g2本身并不解析UTF-8或多语言编码,它只是个位图搬运工。
你要让它显示“中文”,就得提前准备好一张“字典”——也就是一个包含了这些汉字点阵数据的C数组文件。每次调用u8g2_DrawString()时,库函数就在这个字典里查找对应字符的位图,然后复制到显示缓冲区。
所以问题就转化了:
如何生成这张只包含你需要汉字的小型字典,并且尽可能压缩体积?
答案就是工具链:FontForge + bdfconv
字体提取实战流程
假设你的温控仪只需要显示以下词汇:
开机 停机 加热 冷却 温度 设定 当前 报警 故障 模式 手动 自动 返回 确认共14个词,28个不同汉字。我们只需要这28个字就够了,其余4万多个统统不要。
第一步:用 FontForge 导出BDF格式
# 使用开源字体 SourceHanSansSC(思源黑体简体) fontforge -lang=ff -c " Open('SourceHanSansSC-Regular.otf'); Select(unescape('\\\\\\u5F00\\u673A\\u505C\\u673A...')); // Unicode转义序列 Generate('chinese_12px.bdf'); "这一步将指定字符导出为标准的BDF(Bitmap Distribution Format)位图字体文件。
第二步:用 bdfconv 转成u8g2兼容的C数组
java -jar bdfconv.jar \ -v -f 0 \ -n 2 \ # 启用哈夫曼压缩 -M 0,0,255,255 \ # 映射范围(灰度→单色) -d 0 \ # 单色输出 -e UTF8 \ # 输入编码 -o u8g2_font_chinese_12x12.c \ chinese_12px.bdf执行后你会得到两个文件:.c和.h。其中.c文件里就是一个巨大的const uint8_t[]数组,包含了头部信息、哈夫曼码表、字符索引和压缩后的位图数据。
🔍实测数据参考:
- 12×12像素,28个汉字
- 原始位图大小:28 × 12×12 / 8 ≈ 504 字节
- 经过RLE+哈夫曼压缩后:约1.2KB
- 若扩展至128个常用字,仍可控制在4.8KB以内
这个量级完全可以塞进STM32F103C8T6这种只有64KB Flash的入门级MCU中,无需外挂存储。
如何接入STM32?只需写好两个回调
u8g2的跨平台能力来自于其精巧的回调驱动模型。你不需要修改库代码,只要实现几个底层函数,告诉它“怎么发数据”、“怎么延时”即可。
以最常见的I²C接口SSD1306为例,我们需要提供两个回调:
1. I²C通信回调
uint8_t u8x8_stm32_i2c_cb(u8x8_t *u8g2, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_BYTE_SEND: HAL_I2C_Master_Transmit(&hi2c1, SLAVE_ADDR, (uint8_t*)arg_ptr, arg_int, 10); break; case U8X8_MSG_BYTE_INIT: MX_I2C1_Init(); // 初始化I2C外设 break; case U8X8_MSG_BYTE_SET_DC: // I²C协议无DC线,忽略 break; case U8X8_MSG_BYTE_START_TRANSFER: u8x8_SetI2CAddress(u8g2, u8g2_GetI2CAddress(u8g2)); break; default: return 0; } return 1; }这里的关键是HAL_I2C_Master_Transmit的超时时间不能设太长(建议10ms),否则会影响系统响应。
2. GPIO与延时回调(通用)
int u8x8_gpio_and_delay_cb(u8x8_t *u8g2, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_DELAY_NANO: delay_nano(arg_int); break; case U8X8_MSG_DELAY_10MICRO: delay_micro(10*arg_int); break; case U8X8_MSG_DELAY_100NANO: delay_nano(100); break; case U8X8_MSG_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_GPIO_I2C_CLOCK: break; // 忽略模拟时钟 case U8X8_MSG_GPIO_I2C_DATA: break; default: return 0; } return 1; }这两个回调注册之后,就可以初始化整个显示系统了:
u8g2_t u8g2; void display_init(void) { u8g2_Setup_ssd1306_i2c_128x64_noname_f(&u8g2, U8G2_R0, // 正常方向 u8x8_stm32_i2c_cb, u8x8_gpio_and_delay_cb); u8g2_InitDisplay(&u8g2); u8g2_SetPowerSave(&u8g2, 0); // 唤醒屏幕 }注意函数名中的_f表示 Full Buffer 模式;如果是_p则代表 Page Mode。后者更适合RAM极小的系统。
实际UI绘制:混合字体与布局技巧
现在我们已经可以画中文了,但真正的挑战在于如何让界面好看又好用。
来看一个典型的双行数据显示界面:
void draw_temperature_screen(void) { u8g2_ClearBuffer(&u8g2); // 使用中文字体 u8g2_SetFont(&u8g2, &u8g2_font_chinese_12x12); u8g2_DrawString(&u8g2, 0, 12, "当前温度"); u8g2_DrawString(&u8g2, 0, 28, "设定温度"); // 切换为等宽英数字体(如u8g2_font_6x10) u8g2_SetFont(&u8g2, u8g2_font_6x10_tf); u8g2_DrawString(&u8g2, 80, 12, temp_cur_str); // "25.6" u8g2_DrawString(&u8g2, 80, 28, temp_set_str); // "30.0" u8g2_SendBuffer(&u8g2); // 刷新屏幕 }你会发现一个问题:中文字体12像素高,英文字体只有10像素,baseline对不齐怎么办?
解决方案一:手动调整Y坐标偏移
u8g2_DrawString(&u8g2, 80, 14, temp_cur_str); // Y+2补偿解决方案二:选用基线一致的字体组合
推荐搭配:
- 中文:12×12 或 16×16 等宽点阵
- 英文:u8g2_font_6x12、u8g2_font_8x13等相近高度字体
也可以自己微调字体生成参数,在bdfconv中加入-b参数强制对齐基线。
性能与稳定性优化:避开那些“坑”
即使功能跑通了,实际部署中仍可能遇到各种诡异问题。以下是几个高频“踩坑点”及应对策略。
❌ 问题1:屏幕偶尔花屏或通信失败
原因分析:I²C总线受干扰或地址冲突。
解决方案:
- 添加上拉电阻(通常模块已有,但长线需额外加强)
- 在SCL/SDA线上各串接100Ω小电阻抑制振铃
- 电源端加0.1μF陶瓷电容去耦
- 设置I²C速率不超过400kHz(尤其在板子较复杂时)
❌ 问题2:频繁刷新导致主循环卡顿
典型场景:每100ms刷新一次屏幕,结果发现按键响应变慢。
根本原因:u8g2_SendBuffer()是同步阻塞操作,一次传输可能耗时数毫秒。
优化手段:
- 改用Page Mode,每次只更新变化的部分页
- 将刷新操作放在低优先级任务中(如空闲循环)
- 引入“脏区域”标记机制,仅当数据变化时才重绘
static uint8_t need_refresh = 1; if (need_refresh) { draw_ui(); u8g2_SendBuffer(&u8g2); need_refresh = 0; }❌ 问题3:堆栈溢出导致程序跑飞
隐藏风险:u8g2内部有较多递归调用和局部数组,特别是在处理复杂字体时。
建议做法:
- 在启动文件中将main栈(MSP)设为至少512~1024字节
- 避免在中断服务程序中调用绘图函数
- 使用arm-none-eabi-size工具检查栈使用情况
最佳实践清单:让你的HMI更健壮
最后分享一套经过验证的工程级最佳实践,帮助你在未来项目中快速复用这套方案。
| 类别 | 推荐做法 |
|---|---|
| ✅字体管理 | 按功能划分字体文件,如font_menu_12x12,font_num_8x10 |
| ✅命名规范 | 自定义字体变量名统一前缀u8g2_font_xxx |
| ✅链接优化 | 将字体放入独立section,避免挤占代码区 |
.fontram : { *(.u8g2_font*) } > FLASH| ✅构建自动化| 将字体生成脚本纳入Makefile或CI流程,支持一键更新 |
| ✅调试开关| Release版本关闭U8G2_USE_PINS宏,减少GPIO模拟开销 |
| ✅功耗控制| 在待机模式下调用u8g2_SetPowerSave(&u8g2, 1)关闭显示 |
此外,还可以考虑将部分静态文本预渲染为XBM位图,进一步提升绘制速度。对于固定菜单项尤其有效。
结语:以软补硬,才是嵌入式开发的艺术
回到最初的问题:能不能在STM32上流畅显示中文?
答案不仅是“能”,而且可以做得很好——只要你愿意花点时间理解底层机制,合理裁剪资源,精细调优流程。
我们已经在智能电表、PLC调试器、便携医疗设备等多个项目中成功应用这一方案。最大的收获不是技术本身,而是那种“用软件智慧弥补硬件局限”的思维方式。
当你看到一台没有操作系统、只有几KB内存的小设备,却能清晰地显示出“系统正常”四个字时,那种成就感,远胜于堆砌高性能芯片带来的即时满足。
如果你也在为类似需求苦恼,不妨试试这条路。
从裁剪第一个汉字开始,一步步搭建属于你的本土化HMI系统。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。