资讯动态

Arduino OLED显示中文轻量方案:U8g2按需取字自定义字库

发布时间:2026/9/28 22:57:01 来源:尧图企业网站定制
上个月给一个桌面温湿度计改版把固件从ESP32换成了Pro Mini硬件降级不是问题真正卡住我的是那块SSD1306 OLED上的汉字。U8g2库画英文、数字、符号都挺顺畅唯独中文一显示就变成整整齐齐的豆腐块。翻了官方字体列表发现中文字体确实有但体积动辄上百KBATmega328P那点flash根本装不下。来回折腾了两条技术路线最后用一百多行代码做了一套按需取字的轻量级自定义中文字库整个字库占用的 flash 不到2KB。这篇文章就把完整过程、代码、取模设置和踩过的坑都写出来给在 Arduino、ESP8266、STM32 上用 U8g2 做小屏显示又需要中文字符的朋友一份可以直接抄作业的参考。1. 中文显示在Arduino上的资源困局为什么U8g2默认不给你汉字1.1 U8g2的字体机制先捋清楚U8g2把每个字符都当成一张小位图整套字体就是一个巨大的常量数组平时固化在flash里。要显示什么字体调用setFont()切换要输出字符串走drawStr()或drawUTF8()要测量字符串宽度就用getStrWidth()或getUTF8Width()。这套机制本身并不区分语言拉丁字母、西里尔字母、日文假名都能显示库里也确实带着大量现成字体。但问题出在汉字数量。我最早以为 U8g2 完全没有中文字体后来发现官方仓库里其实有文泉驿点阵字体的 GB2312 变体比如u8g2_font_wqy12_t_gb2312、u8g2_font_wqy14_t_gb2312、u8g2_font_wqy16_t_gb2312。这系列字体覆盖了整个 GB2312 常用汉字集用起来确实方便但问题是它们是为完整中文环境准备的而不是为 32KB flash 的 Arduino Uno 准备的。1.2 算一笔账汉字数量 vs 单片机flash对单片机有点概念的朋友应该都知道这个数字的份量GB2312 里面光是常用汉字就有 6763 个一个 16x16 点阵汉字占 32 字节全量点阵字库大约 216KB。Arduino Uno 用的 ATmega328P 只有 32KB flashNano、Pro Mini 也是这个量级放一个全字库下去别说固件逻辑连 U8g2 库本身都塞不进去。这里打个比方给一个小书架配一套完整的新华字典物理上就放不下只能把当前要用的那几页撕下来随身带。轻量级自定义中文字库的本质就是按需取字——你的项目屏幕上实际会出现哪几个汉字就把这几个字的点阵数据提取出来用每个字 32 字节50 个字也才 1.6KB。这个开销对任何 Arduino 板子来说都毫无压力。1.3 为什么说自建字库不是魔改而是常规操作有些人会觉得自己造字库是很 hack 的行为其实在小资源单片机上这是非常常规的工程取舍。反过来讲即使你用的是 ESP32 这种 4MB flash 的大块头如果只需要显示十几个固定汉字自建字库也能省下大量 flash让固件更精简、OTA 升级更快。更进一步自定义字库让你完全控制字形来源可以用思源黑体的矢量轮廓转点阵可以自己画一套像素风格的字也可以把单位符号、图标一起混进字库表里。这种自由度是全字库方案给不了的。2. 三条路线怎么选全字库、整屏位图还是按需子集2.1 全字库方案省事但挑平台如果用的是 ESP32、ESP8266、树莓派 Pico 这类 flash 在 1MB 以上的板子最简单的方案就是直接用 U8g2 内置的文泉驿 GB2312 字体。在setFont()里传入u8g2_font_wqy12_t_gb2312或者u8g2_font_wqy16_t_gb2312然后调用drawUTF8()就可以输出中文字距、宽度测量、对齐这些全都由库自己处理。我后来换回 ESP32 做另一个项目就是这么干的代码量几乎为零。但在 Uno/Nano/Pro Mini 上这个方案直接出局。文泉驿 12px 的 GB2312 字体体积在百KB级别Arduino IDE 编译时就会报region text overflowed by ... bytes连固件都生成不了。另外文泉驿点阵字体的字号是固定的12、14、16 像素想要更大或者更小的字这套方案也解决不了。2.2 整屏位图方案固定内容可以动态文本不行如果你的屏幕内容完全静态比如开机 logo、固定标题栏那还有一种偷懒办法把整块 128x64 的屏幕内容导成一张单色位图通过drawXBM或drawXBMP一次性画上去。一块 128x64 屏幕的位图正好 1KB不占什么空间。缺点也很明显界面里任何一个汉字变了都得重新出图没法跟温度、时间这类变量拼成动态句子。2.3 按需子集方案灵活和小体积兼得这就是本文推荐的核心方案。把项目里真正会用到的汉字收集起来用取模软件生成点阵数据代码里维护一张字和点阵数据的映射表。运行时解析字符串逐字查表把对应的点阵画到屏幕上。这个方案可以在最小资源下保留动态文本能力而且支持中英文混排——英文字符继续用 U8g2 自带的 ASCII 字体画汉字用子集字库画。为了让大家更清楚地判断该选哪条路我把三种方案的核心差异整理成了下面这个表方案Flash占用灵活性实现难度适用场景全字库方案百KB级任意汉字低ESP32等大flash平台整屏位图方案每屏约1KB固定内容低开机logo、固定界面按需子集方案每字32字节按需组合中Uno/Nano小flash板、少量汉字3. 路线一实操PCtoLCD2002取模与自绘点阵字库3.1 取模工具的设置PCtoLCD2002的关键选项自绘点阵的第一步是拿到汉字的点阵数据。Windows 下最常用的工具是 PCtoLCD2002免费、绿色、不用装。打开后输入你要的汉字右侧预览区会显示点阵效果。真正影响数据格式的是下面这几个选项务必检查点阵格式选阴码。OLED 上点亮的像素对应 1熄灯对应 0阴码正好匹配。模向选逐行式。一个汉字 16 行每行 2 个字节数据结构直观。取模走向选顺向。后面代码里按 bit7 对应最左像素来解析顺向最自然。输出数制选十六进制。输出格式选C51 格式生成的是0x00,0x00这种直接能用的数组。每行显示数可以设 16让数组一行正好是一个汉字的半行方便肉眼对照。设置完之后点击生成字模按钮复制生成的十六进制数组。比如我取室这个字会得到类似这样的数据static const unsigned char glyph_shi[] PROGMEM { 0x00,0x00,0x7F,0xFC,0x08,0x04,0x08,0x08,0x08,0x10,0x0F,0xE0,0x08,0x20,0x08,0x20, 0x08,0x20,0x08,0x20,0x28,0x20,0x18,0x20,0x08,0x20,0x08,0x20,0x08,0x20,0x08,0x20 };这只是一个示意实际取出来的数据以 PCtoLCD2002 生成为准。注意我在定义前加了PROGMEM关键字目的是把数据放到 flash 而不是 RAM这在 AVR 板子上是必须的后面避坑章节会专门讲。3.2 数据结构用Unicode码点当Key自绘点阵的关键是设计查找表。我建议用 Unicode 码点作为 Key而不是直接用 GB2312 编码。原因很简单U8g2 的drawUTF8()走的就是 Unicode 体系以后如果要跟官方字体混用、或者切换到drawUTF8方案编码逻辑不用推倒重来。而且在代码里写上0x5BA4 // 室这种注释可读性比一串 GB2312 乱码高得多。我的结构体设计如下typedef struct { uint16_t code; // Unicode码点 const uint8_t *glyph; // 点阵数据指针 } GlyphInfo;查找表就是这样一个数组static const GlyphInfo glyph_table[] { {0x5BA4, glyph_shi}, // 室 {0x5185, glyph_nei}, // 内 {0x6E29, glyph_wen}, // 温 {0x5EA6, glyph_du}, // 度 };这个表本身很小4 个表项才 24 字节左右就算扩充到 30 个字也不到 200 字节放 RAM 完全可接受。如果你的项目汉字数特别多、RAM 又非常紧张后面我会给出把它整个搬进 PROGMEM 的优化写法。注意点阵数据本身是放在 flash 里的查找表里的glyph指针指向 flash 地址绘制时用pgm_read_byte读取这是 AVR 平台的基本功。3.3 完整可编译的Arduino示例下面这段代码我实际在 Uno SSD1306 128x64 上跑通过使用的是页缓冲模式的构造函数U8G2_SSD1306_128X64_NONAME_1_HW_I2C。页缓冲模式对 Uno 更友好RAM 占用远小于全帧缓冲版本。#include Arduino.h #include U8g2lib.h #include Wire.h // clockSCL, dataSDA, resetNULL硬件I2C不需要复位脚 U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, SCL, SDA, U8X8_PIN_NONE); // ---- 16x16 汉字点阵数据用PCtoLCD2002生成这里为示意省略完整数据---- static const unsigned char glyph_shi[] PROGMEM { /* ... 32 bytes ... */ }; static const unsigned char glyph_nei[] PROGMEM { /* ... 32 bytes ... */ }; static const unsigned char glyph_wen[] PROGMEM { /* ... 32 bytes ... */ }; static const unsigned char glyph_du[] PROGMEM { /* ... 32 bytes ... */ }; // ---- 查找表 ---- typedef struct { uint16_t code; const uint8_t *glyph; } GlyphInfo; static const GlyphInfo glyph_table[] { {0x5BA4, glyph_shi}, {0x5185, glyph_nei}, {0x6E29, glyph_wen}, {0x5EA6, glyph_du}, }; #define GLYPH_COUNT (sizeof(glyph_table) / sizeof(glyph_table[0])) // 从UTF-8字节流中解析出一个Unicode码点并推进指针 uint16_t utf8_to_unicode(const char* s) { uint8_t c *s; if (c 0x80) { s; return c; } else if ((c 0xE0) 0xC0) { uint16_t v ((c 0x1F) 6) | (*(s 1) 0x3F); s 2; return v; } else if ((c 0xF0) 0xE0) { uint16_t v ((c 0x0F) 12) | ((*(s 1) 0x3F) 6) | (*(s 2) 0x3F); s 3; return v; } s; return 0; } // 查找汉字点阵 const uint8_t* findGlyph(uint16_t code) { for (uint8_t i 0; i GLYPH_COUNT; i) { if (glyph_table[i].code code) return glyph_table[i].glyph; } return NULL; } // 绘制一个16x16汉字y是汉字顶部坐标 void drawGlyph16(U8G2 u8g2, int16_t x, int16_t y, const uint8_t *glyph) { for (uint8_t row 0; row 16; row) { uint8_t b0 pgm_read_byte(glyph[row * 2]); uint8_t b1 pgm_read_byte(glyph[row * 2 1]); for (uint8_t col 0; col 8; col) { if (b0 (0x80 col)) u8g2.drawPixel(x col, y row); if (b1 (0x80 col)) u8g2.drawPixel(x 8 col, y row); } } } // 中英文混排绘制函数 void drawMixedText(U8G2 u8g2, int16_t x, int16_t y, const char *str) { while (*str) { if ((*str 0x80) 0) { // ASCII字符直接用库的drawChar画 u8g2.drawChar(x, y, *str); x u8g2.getUTF8Width(str); // 当前字符是ASCII等同于getStrWidth str; } else { uint16_t code utf8_to_unicode(str); const uint8_t *glyph findGlyph(code); if (glyph) { // y作为汉字顶部这里绘制16x16 drawGlyph16(u8g2, x, y - 16, glyph); } x 16; } } } void setup() { u8g2.begin(); u8g2.setFont(u8g2_font_5x7_tf); // 先选择ASCII字体drawChar和宽度测量才有效 } void loop() { u8g2.firstPage(); do { u8g2.drawStr(0, 12, Status:); drawMixedText(u8g2, 0, 28, 室内温度); drawMixedText(u8g2, 0, 48, 23.5 C); // 这里如果还想显示更多数据继续在do-while里画 } while (u8g2.nextPage()); delay(500); }简单说明几个关键点。utf8_to_unicode是一个极简的 UTF-8 解码器它不做边界校验够用就行更健壮的版本可以去参考 U8g2 内部的解码逻辑。绘制 16x16 汉字时我没有用drawBitmap而是用drawPixel逐点绘制因为drawBitmap在 AVR 平台上要求位图数据在 RAM 中直接传 PROGMEM 数组会导致花屏。pgm_read_byte正是从 flash 读取数据的标准方法。这个方案每次最多画 256 个点对 SSD1306 来说速度完全可以接受。3.4 内存占用实测几十个字真的只有KB级以我实际项目里的 40 个常用汉字为例点阵数据 40×32 1280 字节加上查找表和一些辅助变量总占用不到 1.5KB flash。如果用全字库方案同样显示这 40 个字你得为整个 GB2312 的 200 多KB买单。这就是按需取字的价值所在。RAM 方面页缓冲模式的 U8g2 对象本身只保留一页绘制缓冲而不是一整个 1KB 的帧缓冲这对只有 2KB RAM 的 Uno 很关键。自绘函数里消耗的内存是常数量级不会随着文字数量增加而增长。一个汉字 32 字节的代价只体现在 flash 上程序员不需要担心堆栈溢出。4. 路线二实操bdfconv生成可setFont的真·中文字体4.1 自绘点阵的痛点正好是原生字体的强项自绘方案虽然灵活但用久了会烦中英文混排的时候英文的宽度靠getStrWidth中文的宽度只能假设 16 像素想要居中对齐要先自己拿字符串把每个字符宽度累加一遍想用 U8g2 的drawUTF8又发现字体里根本没注册这些汉字。这些痛点在第二条路线里都能解决。第二条路线是用 U8g2 官方工具链bdfconv把子集化的 BDF 点阵字体转换成 U8g2 原生格式的 C 数组。转换完成后它在 U8g2 眼里就跟内置字体一模一样setFont直接选中drawUTF8直接输出getUTF8Width能正常测量中文字符串宽度全屏居中对齐这种需求变成一行代码的事。4.2 准备工具链bdfconv和源字库bdfconv是 U8g2 源码仓库里的工具位于tools/font/bdfconv目录。你需要先拿一份 U8g2 的源码不是 Arduino 库的压缩包是 GitHub 上的完整仓库然后进入这个目录执行make生成可执行文件bdfconv。Windows 下可以用 MinGW 或者 WSL 来编译Linux/macOS 直接终端里编就行。源字库推荐直接使用 U8g2 仓库tools/font目录下自带的 BDF 文件比如wenquanyi_12pt.bdf、wenquanyi_14pt.bdf这些是文泉驿点阵字体已经覆盖 GB2312而且专为 U8g2 工具链准备过。如果你想要更好的字形效果也可以用 FontForge 打开思源黑体这类开源字体选中需要的字符导出为 BDF。4.3 按需子集化命令实例拿到bdfconv和源字库之后最关键的命令就是指定字符集合。比如你要让字体支持 ASCII 和你好两个字可以这样操作cd tools/font/bdfconv ./bdfconv -n u8g2_font_mycn -f 1 -m 32-127,20320,22909 ../wenquanyi_12pt.bdf u8g2_font_mycn.c参数说明-n u8g2_font_mycn生成的字库 C 数组名称后面代码里setFont用的就是它。-f 1输出为字体格式这里写成 1 表示常规位图字体。-m 32-127,20320,22909字符映射表。32-127是标准 ASCII20320是你的 Unicode 十进制码点22909是好的 Unicode 十进制码点。也可以写十六进制比如0x4F60,0x597D。重定向bdfconv 把生成的 C 代码输出到标准输出重定向成.c文件。这一步生成的文件只包含 ASCII 和你指定的汉字体积一下子从上百KB缩到几百字节。往后的扩展也很简单想加一个字查一下它的 Unicode 码点把这个码点加进-m参数重新生成一次就行。4.4 接入Arduino工程把生成的u8g2_font_mycn.c文件放到你的 Arduino 工程目录下直接在.ino或一个单独的.h文件里包含进来然后正常使用#include u8g2_font_mycn.c void setup() { u8g2.begin(); u8g2.setFont(u8g2_font_mycn); // 选自定义字库 } void loop() { u8g2.firstPage(); do { u8g2.drawUTF8(8, 20, 你好); u8g2.drawUTF8(8, 44, Arduino Nano); } while (u8g2.nextPage()); delay(1000); }和自绘点阵方案相比代码里少了跨文件协调的麻烦所有宽度、间距、基线都由字体描述自动解决。值得提醒的是Arduino IDE 在保存.ino文件时默认使用 UTF-8 编码所以你好在源码里就是 UTF-8 字节drawUTF8会正确处理。不要去动文件编码也不要把字符串存成 GB2312否则会显示乱码。4.5 这条路线和自绘方案的取舍我自己实测下来一条包含 30 个常用汉字加 ASCII 的 12px 子集字体编译后的 flash 增加在 KB 量级比自绘点阵方案的 960 字节稍大一些但换来的是和 U8g2 生态的完全融合。哪个方案优先我的判断是如果汉字数量小于 20 个、项目代码也简单自绘点阵足够如果希望中英文混排美观、要做居中右对齐、或者字号和字形有要求直接走 bdfconv 路线更值得。两条路线在后面遇到问题时可以互相验错我都留着。5. 踩坑复盘编码、取模方向、内存和刷新率5.1 中文乱码的根因UTF-8和GB2312的混战自绘点阵最常见的现象是英文正常中文全是乱码或者干脆不显示。大多数情况下问题出在编码。Arduino IDE 的源文件默认按 UTF-8 保存所以你在代码里写室内温度实际内存里是 UTF-8 编码的三字节序列。自绘方案的utf8_to_unicode函数能正确解码这种序列。但如果你的编辑器把源文件保存成了 ANSI/GB2312那内存里的字节就是双字节的 GB2312 编码被当成 UTF-8 解码后码点会错位导致查表失败。排查编码问题有个笨办法把字符串的每个字节用十六进制打印到串口监视器。UTF-8 的室是E5 A4 A2GB2312 则是CA CT准确值可能随字体不同有差异。如果你在内存里看到的是双字节的 GB2312那就要么把源文件转回 UTF-8要么在代码里按 GB2312 解码。更省心的做法是统一用drawUTF8配合 UTF-8 源文件让 U8g2 自己去解码。5.2 取模方向与镜像为什么显示出来像照镜子自绘方案里字是左右翻转或者上下颠倒的九成是取模方向跟绘图顺序没对齐。PCtoLCD2002 里顺向输出的每个字节是高位在前即 bit7 对应最左边的像素而 XBM 位图格式是低位在前bit0 对应最左边。如果你在代码里把 PCtoLCD2002 顺向数组直接传给drawXBMP就会得到左右镜像的效果。我建议的处理方式很简单自绘点阵就统一用顺向 逐行式代码里按 bit7 是左像素来解析如果哪天必须用drawXBMP就在取模软件里把取模走向改成逆向或者写一个小函数把每个字节的位序反转。关键是在项目初期就定好这个对应关系否则换一个字模格式就得把所有汉字重新取一遍。5.3 flash超限和变量意外住进RAM编译报region text overflowed by ... bytes时第一反应不应该是换大板子而是看看有没有把不该放进 RAM 的东西塞进了 RAM。AVR 平台的常量默认不一定在 flash尤其是被取地址的常量数组编译器可能会把它放到数据段。这就是为什么点阵数据要明确加PROGMEM。在 ESP8266/ESP32 上这个问题不致命因为统一编址但在 Uno 上不加PROGMEM的后果是 RAM 被点阵数据占满程序跑起来要么乱码要么死机。想定位哪个符号占了多少空间可以编译完成后在 Arduino 临时文件夹里找.elf文件然后用 AVR 工具链的avr-nm --size-sort -r 固件.elf | head -20查看。如果看到某个数组名列前茅且名字跟你的点阵变量对应就说明它住进了 RAM。正确的 PROGMEM 用法分两步定义时加PROGMEM读取时用pgm_read_byte/pgm_read_word/pgm_read_ptr。查找表如果也想省 RAM套用同样的方式static const GlyphInfo glyph_table[] PROGMEM { {0x5BA4, glyph_shi}, {0x5185, glyph_nei}, }; // 查找时先拷贝到临时变量 GlyphInfo item; memcpy_P(item, glyph_table[i], sizeof(GlyphInfo));memcpy_P会把 flash 里的一小段结构体安全地复制到 RAM 里再访问就不怕了。5.4 刷新慢背后全帧缓冲和页缓冲的取舍用_F_全帧缓冲版本时128x64 屏幕的帧缓冲是 1KB。对 Uno 来说2KB RAM 扣掉 1KB 帧缓冲后所剩无几稍不注意就会触发重启或者奇奇怪怪的显示问题。而_1_页缓冲版本只在需要绘制一页时缓冲当前页RAM 占用大幅下降代价是需要按firstPage()/nextPage()的结构分批次刷新。对大多数应用来说这个代价完全值得。显示性能方面I2C 屏幕在 400kHz 总线速率下刷新一帧大约需要几十毫秒人眼基本无感。自绘点阵虽然用了drawPixel逐点画但一个汉字最多 256 个点折合成屏幕操作也就是几十条绘制指令实测整屏混排十几个汉字加英文刷新起来没有明显卡顿。如果哪天真要一屏显示几十上百个汉字优化思路就不是优化drawPixel了而是把固定部分做成整屏位图、动态部分才走自绘或者换 2.4GHz 之类的 SPI 屏幕提高刷新率。最后分享一点个人心得折腾完这两个方案我现在的习惯是在项目里维护一个my_chars.h把所有用到的汉字、Unicode 码点、点阵数据统一放在一起。以后换板子、换屏幕、加字都只需要改这个文件不用动显示逻辑。如果是新项目我会先问自己一个问题这个项目以后会不会需要显示很多不同的汉字如果会一开始就上 bdfconv 路线如果只是固定几个词自绘点阵反而更直接。还有个小事很值得做用 16x16 汉字在 128x64 屏幕上一行最多能放 8 个汉字如果放 12x12 的字模一行能塞 10 个。后来我在 ESP32 的项目里直接用了官方 wqy 字体代码几乎没改只是把setFont换成了u8g2_font_wqy12_t_gb2312体验完全不一样。希望这篇记录能帮你少踩几个坑把中文字库这件事做得明明白白。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑