简介:面向嵌入式系统与物联网设备开发者的LCD/OLED字符图像取模工具,用于将ASCII字符或BMP位图快速转换为适配低分辨率屏幕的点阵数据,减少手工取模误差。该工具支持字符与图像两类取模操作,可自定义字体样式,并可将彩色图转为单色/灰度格式,预览功能便于在烧录前确认显示效果。压缩包体积仅501KB,内容以C语言源码示例和CHM帮助文档为主,其中包含不同配置的取模参考代码,方便开发者阅读与二次集成;帮助文档提供中文与繁体中文版本,可指导用户快速上手。目前已有1150人学习下载,适合需要自制字库、处理图像显示或优化可视化方案的嵌入式软硬件工程师,能够有效提升开发效率并保障有限显存下的画面表现。
1. 取模工具解决的是什么:点阵屏显示的第一道坎
某开发者第一次把一段 OLED 驱动代码跑通,画点亮了,字符却乱了:一个“中”字在屏上横躺着,笔画拧成一片。查了半天初始化,最后发现是取模软件里默认选的“逐列式”和这块屏实际用的“逐行式”扫描对不上。类似的事在 LCD/OLED 开发里太常见了,而它和硬件设计无关,纯粹是“LCD/OLED 开发字符图像取模工具”用没用到位的差别。取模工具做的事情很简单——把文字和图片变成单片机直接烧录的十六进制点阵数组,但它的参数设置直接决定字符在屏上是正的、反的还是旋转 90 度的。本文就从原理、字模取模、图像取模到踩坑排查一次讲透,适合正在做显示项目的嵌入式开发者,尤其是第一次接触点阵屏、还没形成“取模方向直觉”的新手。
2. 取模原理:先搞懂行扫描、列扫描与字节位序
2.1 字模与图模的本质:点阵数据和显示驱动的中间格式
单色 LCD 和 OLED 的显示原理可以抽象成一句话:屏上有一个 M×N 的点阵,MCU 往控制器的显存里写字节,控制器再把字节里的每一位映射到对应像素。对应用层开发者来说,我们不关心显存物理位置,只关心“往哪个地址写什么数据、哪个像素会亮”。取模工具就是生成“什么数据”的中间环节。
一个汉字“中”在 16×16 点阵里,先被字体渲染引擎变成 256 个黑白点,再按每 8 个点合成一个字节,最后得到 32 字节的数组。这 32 字节就是字模,它和你手写在格子纸上的点阵图一一对应。图像取模同理,一张 48×48 的图标,点阵展开后是 2304 个点,按 8 点一字节打包成 288 字节。字模和图模的本质都是“点阵 + 打包规则”,区别只在于来源:字模由字体渲染出来,图模由图片二值化得到;字模按字符边界单独存储、按编码索引,图模按图像区域整块存储、按坐标定位。
这里有个新手最容易忽略的事实:取模后的数组只是一串普通字节,它本身不携带“我是字符还是图片”的语义。同一个数组,你用“行扫描”的驱动函数显示是一张正常的图,换成“列扫描”的驱动函数显示就是乱码。网上经常看到“同一段取模代码,在他的屏上正常,在我的屏上花屏”的求助帖,原因就在这里——不是代码错了,是屏的扫描方式和取模参数没对上。
为了让打包过程更直观,我写一个最小可运行的 C 代码,模拟一块 8×8 的点阵按行扫描、每行一个字节的打包结果:
// 8x8点阵,1表示该点需要点亮 uint8_t dot[8] = { 0b00011000, // 第0行:第3、4个像素亮 0b00011000, // 第1行 0b00011000, // 第2行 0b11111111, // 第3行:整行点亮 0b00011000, // 第4行 0b00011000, // 第5行 0b00011000, // 第6行 0b00011000, // 第7行 };上面这个数组每个字节的二进制位对应一行里从左到右的 8 个像素,dot[0] 的 00011000 表示第 0 行第 3、4 位亮,屏上显示的是一个竖直的十字。如果控制器按“低位在前”解析这个字节,亮点的位置会变成从右往左数第 3、4 位,十字的形状不变但位置会偏移。这个例子说明了一件事:位序错了不一定立刻“花屏”,有时只是像素位置错位,这也是它更难排查的原因。
2.2 取模方向与字节位序:同一个字为什么会有四种显示结果
同一个汉字,理论上至少有四种常见组合:逐行式或逐列式,再各配高位在前或低位在前。四组数据完全不同,但显示成点阵时只有一组是对的,取决于控制器的显存地址递增规则。不懂这个规则,就只能靠“试一遍、不行就换”的笨办法,浪费时间还在心流上留下阴影。
直接说显存组织。以常见的 128×64 OLED 控制器为例,显存被分成 8 个页,每页高 8 像素。写数据时先指定页地址和列地址,然后连续写入,每写一个字节列地址自动加 1。也就是说,控制器的自然扫描方向是:从上到下每 8 行一个页,页内从左到右逐字节填充。这种情况下取模软件要选“逐行式、高位在前”:逐行式匹配页内从上到下的 8 行,高位在前匹配字节内从左到右的位序。
但 LCD 控制器不完全按这套逻辑走,有的显存按行组织而不是按页组织,有的支持行列地址独立设置,有的干脆把扫描方向做成了寄存器配置项。当你把为 A 控制器取的模用在 B 控制器上,最常见的结果就是字符旋转 90 度或整体翻转。我在实际开发中养成了一个习惯:接到新屏,第一件事不是写功能代码,而是查数据手册里两个字段——显存组织方式(页式还是行式)和地址递增方向。根据这两个字段决定取模参数,基本不会翻车。
这里给一段验证位序的小 Python 代码,不接屏也能看出高低位的区别:
# 位序验证:同一个字节按两种方式解析 byte = 0b10010001 high_first = [(byte >> (7 - i)) & 1 for i in range(8)] # 从bit7开始读 low_first = [(byte >> i) & 1 for i in range(8)] # 从bit0开始读 print("高位在前:", high_first) # [1, 0, 0, 1, 0, 0, 0, 1] print("低位在前:", low_first) # [1, 0, 0, 0, 1, 0, 0, 1]运行后两份结果不同,说明同一个字节在两种位序下映射到屏上的是不同列。这类问题在字符上表现为左右镜像,在图案上表现为水平翻转。记住一个判断技巧:如果字符能认出来但方向反了,八成是位序问题;如果字符旋转了 90 度,是逐行/逐列问题。把这两类分开排查,比直接重取模高效得多。
取模方向与常见异常对应关系,我整理成一个表,排查时直接对照:
| 显示异常 | 优先检查的取模参数 |
|---|---|
| 字符左右镜像 | 字节内位序(高位在前/低位在前) |
| 字符整体倒置 | 顺向/逆向 |
| 字符旋转 90 度 | 逐行式/逐列式 |
| 整屏反白 | 阴码/阳码 |
| 字符间距错乱 | 字宽字高与像素布局不匹配 |
2.3 阴码阳码、字宽字高:参数含义与选择依据
阴码和阳码在字段名上就能劝退一批新手。不同取模软件对这两个词的定义甚至正好相反,有的软件里“阳码”表示笔画为 1,有的软件里“阴码”表示笔画为 1。所以别死记方向,记住判断点:看软件的点阵预览,预览里字体是不是亮的、背景是不是灭的,和屏上预期效果一致就说明阴阳码选对了。更稳的做法是生成一个测试字符,用工程里已有的画点函数验证一下,眼睛不会骗人。
字宽字高决定了每个字符占用的点阵大小,也直接决定了字模的字节数。以 16×16 为例,每个字模的字节数 = (16 / 8) × 16 = 32 字节;8×16 的半高字则是 (8 / 8) × 16 = 16 字节。常见组合有 8×16、12×24、16×16、24×24、32×32,选择依据就两条:屏的分辨率,以及字符的清晰度。128×64 的屏上,16×16 能显示 4 行每行 8 个汉字;24×24 显示效果更精致,但一屏放不了几个。
字宽必须能被 8 整除,这一点记住能少踩很多坑。如果一个字符的字宽是 18,每行点阵要拆成 3 个字节但只有 2.25 字节的数据,软件会在最后一个字节里补 0。问题是不同软件补位规则不一样,有的补在右边,有的按字节对齐后跳到下一行,驱动侧只要没按同样规则跳过补位,显示就会横向错位。所以字宽老老实实选 8、16、24、32,字高没有这个约束,奇数高度也常见。
顺向与逆向这个选项,普通开发基本用不上,它的定位是兼容地址反向递增的控制器。我见过有人在某些 LCD 屏上遇到图像整体水平翻转,改了半天的取模方式没效果,最后发现只是把顺向改成逆向就好了。如果你的屏显示出来的图像像“从背面看”,优先怀疑这个参数。总之,取模参数的每一档都不是随便设置的,它们一一对应控制器硬件里的某个行为,理解了硬件再看参数就顺了。
3. 字符取模实操:用经典取模软件生成汉字与 ASCII 字模
3.1 最小操作流程:从输入文字到生成 C 数组的七个步骤
目前主流的取模软件还是 Windows 下的小工具,单文档界面,左边是设置项,右边是输入区和点阵预览。不同软件命名略有差异,但“逐行式/逐列式、高位在前/低位在前、阴码/阳码”这套核心选项是通用的,学会一套换软件无成本。
假设要在 128×64 的 OLED 上做一个菜单页,需要显示“系统状态”四个汉字,操作为:
- 打开取模软件,在文字输入区输入“系统状态”。
- 设置字宽 16、字高 16,字体选黑体、字号 16。字号和字高要一致,字号大于字高时字形会被截断。
- 取模方式选“逐行式”,输出方式选“高位在前”,阴码/阳码先选阳码。
- 点预览,看右侧点阵图是否清晰、字形有没有被挤压或截断。
- 点“生成字模”,检查输出数组每个字的字节数是否为 32。
- 复制生成结果,粘贴到工程里的 font.h 头文件,用 const uint8_t 修饰。
- 写一个简单的显示函数,一次只显示一个字,确认方向正确后再批量接入界面逻辑。
生成结果是一个标准数组:
// 16x16 汉字“系”,逐行式,高位在前,共32字节 const uint8_t font_xi_16x16[32] = { 0x00, 0x00, 0x7F, 0xFC, 0x08, 0x04, 0x08, 0x04, 0x08, 0x04, 0x08, 0x04, 0x08, 0x04, 0x08, 0x04, 0x08, 0xE4, 0xFF, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x00 };数组结构和驱动函数的对应关系是:前 16 字节对应上半部分 8 行,后 16 字节对应下半部分 8 行,每一行内部的 2 个字节按从左到右排列。如果显示时发现上半部分和下半部分错位,多半是驱动里行列循环的顺序写反了,不是取模软件的问题。数组本身没有“正确”与“错误”,只有“与驱动是否匹配”。
3.2 字高字宽、字体与偏移参数怎么定
字体和字模大小的关系比看起来复杂。同样设置字宽 16、字高 16,用“黑体 16 号”和“黑体 24 号”生成的结果不一样:16 号直接按 16 像素网格渲染,24 号先渲染 24 像素再压缩回 16×16,笔画粗细和细节完全不同。所以在取模软件里,“字号”和“字宽/字高”必须配套选择。“字宽 16、字高 16、字号 24”的操作会得到笔画像被强行挤压的字形,这不是软件故障,是选项自相矛盾。
偏移量参数解决的是字形在点阵框里的位置问题。每个汉字的重心其实不完全居中,比如“一”字天然偏上,“口”字天然偏下,字体渲染后会有 1-2 像素的偏移。对 16×16 这样的小尺寸,1 像素偏移肉眼可见,会破坏整行文字的对齐感。遇到这种情况,我会选中该字单独调整纵向偏移,重新生成数组,而不是在屏幕上移动显示坐标——后者会把所有字符一起挪动,解决不了单字错位。
多字号管理是另一个容易踩的细节。一个项目里通常至少有两种字号:状态栏用 8×16 半高字,正文用 16×16 全高字。我的做法是把不同尺寸的字模分文件存储,比如 font_8x16.h、font_16x16.h,并在文件名里标注位序和扫描方式,例如 font_16x16_str.h 表示逐行式、高位在前。这样半年后再回来维护,一眼就知道哪个文件配哪块屏,不会出现“这个数组是给哪块板子用的”这种灵魂拷问。
3.3 把字模数组接进驱动:显示函数与数组索引对应
驱动侧的接入方式取决于屏的类型。字符模式 LCD 自带字符发生器,只需向控制器发送 ASCII 码即可,取模工具主要给自定义字符和汉字建模;图形模式 OLED/TFT 没有字符发生器,所有字符都要在 MCU 侧渲染,字模数组就是唯一的渲染素材。
ASCII 字模的索引规则很固定:可见字符从 0x20(空格)到 0x7E(波浪号),按升序排列,查找偏移 = (ascii - 0x20) × 每字字节数。汉字因为数量大,项目里通常只用到有限的几十个,我一般按“用到的文字”顺序排列数组,再写一个查找函数把汉字内码映射到数组下标。这种做法的好处是字模文件极小,缺点是新增汉字要重新排列数组,适合 UI 文字基本固定的产品。
显示函数要跟取模方向严格对应,这里给一个 16×16 汉字的完整示例:
// 在(x,y)处显示16x16汉字,font_idx为字模数组下标 void lcd_show_hanzi(uint8_t x, uint8_t y, uint8_t font_idx) { const uint8_t *glyph = hanzi_16x16 + font_idx * 32; for (uint8_t i = 0; i < 16; i++) { // 16行 lcd_set_cursor(x, y + i); // 定位到第i行 lcd_write_data(glyph[i * 2]); // 左半字节 lcd_write_data(glyph[i * 2 + 1]); // 右半字节 } }这个函数假设控制器支持按行设置坐标,且每行数据从左到右写入。如果你的 OLED 控制器是页模式,先设置页地址再逐列写,循环结构会变成两层。我建议把“坐标设置”和“数据写入”拆成两个独立函数,移植到不同控制器时只改坐标设置部分,取模数组可以原样保留。代码里 font_idx 的换算也要和取模软件的排列顺序一致:批量生成的字模按输入顺序排列,每个字符占用固定字节数,index 就是第几个字符。
4. 图像取模实操:BMP 预处理与 C 数组生成
4.1 图片转单色 BMP:尺寸、位深与阈值三件事
如果说字符取模是软件帮你渲染字形,图像取模就是纯粹的数据搬运。工具只认点阵位图,最通用的是 1 位位深的 BMP。很多新手直接拿 JPEG 或 PNG 丢进取模工具,要么打不开,要么生成的全是噪点,原因就是位深不符合要求。
尺寸是第一道关。图像取模生成数组的字节数 = (宽度 / 8 向上取整) × 高度。以 128×64 为例,数组是 (128 / 8) × 64 = 1024 字节。如果你的图片是 130×64,每行要凑 17 字节而不是 16 字节,数据和屏幕坐标直接对不上。我在预处理阶段就把宽度裁剪成 8 的倍数,128、96、64 是最常用的三档。高度没有这个限制,但尽量保持与屏的显示区域一致,可以减少很多坐标换算。
位深检查有个笨但有效的办法:用 Python 读一下图像模式,一目了然:
from PIL import Image img = Image.open("test.bmp") print(img.mode) # "1"单色 "L"灰度 "RGB"彩色 "P"索引色mode 为 "1" 才是单色位图,可以直接进取模工具。如果输出 "L" 或 "RGB",说明转换没做彻底。阈值的选择取决于原图内容:文字型 Logo 适合固定阈值 128,黑白分明;带照片质感的图片建议用 Otsu 自动阈值,避免人工反复试。阈值设高了,浅灰区域变白,图像显得稀疏;设低了,暗部噪声全保留,显示出来脏兮兮的。
4.2 用 Python 脚本做预处理:缩放、二值化与抖动
我处理图像取模输入时用一套固定脚本,核心是三个动作:缩放插值、灰度化、阈值化。对颜色层次复杂的图片,还可以加误差扩散抖动(dithering),它能在单色限制下保留灰度的视觉过渡,适合做开机画面这类需要表现立体感的场景。
from PIL import Image def to_mono_bmp(src, dst, width, height, threshold=128, dither=False): img = Image.open(src) # 1. 用LANCZOS插值缩放,边缘比最近邻平滑 img = img.resize((width, height), Image.LANCZOS) # 2. 转灰度 img = img.convert("L") # 3. 二值化:dither开启时用Floyd-Steinberg误差扩散 if dither: img = img.convert("1", dither=Image.Dither.FLOYDSTEINBERG) else: img = img.point(lambda p: 255 if p > threshold else 0) img = img.convert("1") img.save(dst, "BMP") # 图标场景:阈值处理,保证边缘整齐 to_mono_bmp("logo.png", "logo_mono.bmp", 128, 64, threshold=140) # 照片场景:抖动处理,保留灰度过渡 to_mono_bmp("photo.png", "photo_mono.bmp", 128, 64, dither=True)代码里 threshold 是灰度分界线:大于阈值变白,小于等于阈值变黑。dither 开启后不再简单二值化,而是把灰度误差扩散到邻近像素,单色图在视觉上保留更多层次。注意抖动更适合看整体氛围,不适合需要清晰边缘的图标和文字,图标务必用纯阈值方案。这段脚本每次生成两张图对比一下,选效果好的那张再进取模工具,比直接在工具里反复试参数快得多。
4.3 整屏图与局部图:按坐标裁剪再取模
整屏图和局部图的数据差异在于坐标对应关系。整屏图从 (0,0) 开始,数组顺序就是屏幕扫描顺序,直接使用;局部图要先确定图标要显示在屏的哪个区域,再按目标尺寸裁剪。比如要在屏的 (20,10) 处显示一个 48×48 的图标,你先要决定源图从哪里裁出这 48×48 的内容,再缩放或裁剪到位。
我的做法是:先决定图标显示位置和尺寸,再把原图缩放到对应尺寸后裁剪。缩放和裁剪的顺序影响结果——先缩放到目标尺寸再裁,还是先裁再缩放,最后得到的像素内容可能不同。对边缘清晰的图标,通常先缩放再裁剪更可控,因为裁剪框的坐标可以和屏上位置直接换算。如果有批量图标要处理,把显示区域坐标写进一个配置文件,脚本统一跑,避免每个图标手动裁。
裁剪后用取模软件生成数组时,务必确认软件里设置的尺寸与实际裁剪尺寸一致。生成之后我建议做一个验证:先在屏的 (0,0) 处显示这张数组,看左上角是否与源图一致。验证通过后再放到目标坐标。这样能把“取模问题”和“定位问题”分开排查,不会两边互相干扰。这个小习惯帮我在不少项目里省掉了整晚的调试时间。
5. 取模避坑指南:镜像、反白、偏移的排查与修正
5.1 显示镜像或倒置:取模方向与行列扫描不匹配
现象:屏上显示一个向右的箭头,实际显示成向左;显示“电”字,形状能认出来但整体翻转。原因:取模时选的扫描方向与控制器的显存递增方向不一致。排查方法:先查控制器数据手册里的显存结构图和写时序图,确认地址是行优先还是列优先,再回取模软件把“逐行式/逐列式”或“顺向/逆向”改过来。更快的土办法是在屏的左上角画一个“十”字,看十字的实际朝向就能判断扫描方向。这个十字验证法我每次拿到新屏都会先跑一遍,比对手册快很多,也避免了一次次烧录试错。
5.2 字节偏移一位导致乱码:MSB/LSB 位序反了
现象:图案大体能辨认,但所有字符像被水平剪切过,边缘不齐。原因:取模时高位在前,控制器按低位在前解析,或反过来。这不会导致完全乱码,而是字节内部位序反转,让对称字形产生镜像。排查方法:取一个只有最左侧一列点亮的简单图形,比如数组首字节 0x01,在屏上确认它出现在最左边还是最右边。出现在右边就是位序反了。解决办法是统一双方规则——我倾向于改驱动,在写入前对字节做一次位反转,比重新生成几百个数组更划算。位反转可以用查表法,256 字节的表换高速,划算得很。
5.3 整屏反白:阴码阳码设置反了
现象:背景全亮,笔画全灭,屏幕看起来像负片。OLED 上尤其明显,全亮不仅难看还费电。原因:取模时选的阴码/阳码和屏的初始化模式冲突。解决:回取模软件切换阴码阳码重新生成。如果数组已经在多个界面大量使用,也可以不改数组,在驱动写入函数里对显存数据做一次按位取反,一行代码的事。但要注意这只对整屏反白有效,如果只是局部区域反白,说明是区域坐标设置问题,和阴阳码无关。阴阳码的定义在不同软件里可能恰好相反,所以判断时只看预览和实屏效果,不要迷信字段名。
5.4 放大后锯齿明显、糊成一团:缩放插值没做对
现象:小图放大后边缘全是锯齿,笔画中间还有断点,显示在屏上很脏。原因:用画图工具放大时采用了最近邻插值,原图 1 个像素被硬拉成 2×2 或 3×3,单色图像素没有过渡,锯齿完全暴露。解决:用 4.2 的脚本,先转灰度、LANCZOS 缩放、再阈值化。如果原图不是位图而是矢量,直接按目标尺寸渲染后再取模,效果比任何插值都好。另一个建议:图标在设计阶段就要按目标尺寸来画,不要随手画个小图指望后期放大。
5.5 字模图模混用 API、字库更新不生效:存储格式与构建流程
两个容易混淆的问题放在一起说。第一,字模按字符边界存储,图模按图像区域存储,两者的数组索引逻辑不同。如果驱动里只写了一个通用的“写数据”函数,混用时一定乱码。我习惯在函数命名上做区分:显示字符用 lcd_show_char,显示图像用 lcd_show_image,底层共用同一条写数据链路,但上层绝不混调。第二,修改取模参数重新生成数组后,烧录到板子上还是旧效果,先检查头文件是否被工程正确引用,再检查构建系统有没有增量编译缓存。用 Makefile 管理的工程,可以在取模脚本的输出路径上声明依赖,让头文件每次都自动重建,这一点下一章会具体讲。
6. 进阶:写一个命令行取模脚本,把手动操作变成流水线
6.1 一个够用的 Python 取模脚本
GUI 取模软件适合少量字符和零星图片,但如果项目里有几十个图标、多套界面状态图,手动复制粘贴会让人崩溃。我自己习惯在项目根目录放一个 make_font.py,直接从单色 BMP 生成 C 数组头文件,省掉所有鼠标操作。
# 单色BMP -> C语言数组,输出为 .h 头文件 from PIL import Image def bmp_to_c_array(bmp_path, array_name="image_data"): img = Image.open(bmp_path).convert("1") w, h = img.size bytes_per_row = (w + 7) // 8 # 宽度不足8的部分补0 rows = [] for y in range(h): row = [] for bx in range(bytes_per_row): byte = 0 for bit in range(8): px = bx * 8 + bit if px < w: pixel = img.getpixel((px, y)) # 0黑 255白 if pixel == 0: byte |= (1 << (7 - bit)) # 高位在前 row.append(byte) rows.append(row) # 生成头文件 lines = ["#ifndef FONT_DATA_H", "#define FONT_DATA_H", "#include <stdint.h>", f"const uint8_t {array_name}[{h}][{bytes_per_row}] = {{"] for r in rows: lines.append(" " + ", ".join(f"0x{b:02X}" for b in r) + ",") lines.append("};", "#endif", "") with open("font_data.h", "w") as f: f.write("\n".join(lines)) if __name__ == "__main__": bmp_to_c_array("icon_48x48.bmp", "icon_48x48")脚本逻辑是逐行扫描,每行按 8 像素打包一个字节,黑色像素置 1,白色置 0,最终输出二维数组。默认按高位在前打包,如果你的控制器要求低位在前,把1 << (7 - bit)改成1 << bit即可,只动这一行就够。
6.2 接进编译流程:从脚本到自动生成头文件
脚本写完后可以接到构建流程里。我的做法是在 Makefile 里加一个 target,每次编译前自动执行脚本。这样任何人改动源图,重新编译时字模头文件会自动更新,不会出现“改了图忘了重新取模”的情况。核心思路是让 BMP 源文件成为唯一输入,头文件成为只读产物,不允许手工编辑。
# 取模脚本自动生成头文件 font_data.h: icons/icon_48x48.bmp python3 make_font.py这样配置之后,整个取模流程就变成:丢 BMP 到 icons 目录 → 编译 → 完成。如果换了控制器需要调整位序,只在 make_font.py 里改一处,比手工重新取模一百张图快得多。我自己保持的习惯是:GUI 取模软件只用于临时验证和调试预览,所有正式资源走脚本生成,保证每次构建结果一致。这个习惯帮我避开过不少“这次编译出的字模和上次不一样”的玄学问题,也让后来接手项目的人不用问就知道字模从哪来。希望帮到你。
本文还有配套的精品资源,点击获取