搞嵌入式这几年,我见过太多新手在调OLED上栽跟头。明明屏幕点亮了、初始化代码也对、I2C时序看起来没问题,结果屏幕上要么一片空白,要么满屏乱码,折腾两三天最后发现——字模取错了。OLED显示和普通LCD最大的区别就在于,它没有内置字库,所有字符、汉字、甚至是图片,本质上都是一张一张的“点阵位图”,而位图怎么从文字转化成单片机认识的数组,全靠字模提取这一步。这篇文章要聊的,就是基于PCtoLCD2013这个经典软件,把OLED字模提取这件事从头到尾捋一遍:从软件原理、参数配置、实操步骤,到数据格式和代码对接,再到我踩过的各种坑,适合刚接触STM32或ESP32、被OLED显示折磨得怀疑人生的朋友,也适合想系统搞懂字模底层逻辑的老手。
1. 字模到底在取什么:理解OLED显示的最小单元
很多人拿到PCtoLCD2013就急着点“生成字模”,结果参数全是默认的,取出来的数据放进代码里,显示效果奇奇怪怪。你要知道,字模提取不是“把字变成一个数组”那么简单,它的本质是把字符点阵化之后,按特定扫描顺序转换成二进制位图数据,这个顺序错了、位序反了、取模方向不对,显示出来就是乱的。
1.1 一块OLED屏幕是怎么显示文字的
拿最常见的0.96寸OLED(SSD1306控制器,分辨率12864)来说,屏幕上的每个像素点要么亮、要么灭,没有任何中间状态(OLED不像LCD有灰度,除非你用PWM去模拟)。要让屏幕显示一个“A”,本质上是控制一块区域里哪些点亮、哪些点灭,比如816的点阵,A字的笔画覆盖到的点就点亮,其余点熄灭。
这就引出一个问题:单片机怎么知道“哪些点该亮”?答案就是点阵字模数据。我们用一个字节的8个bit代表一行里的8个像素点,bit为1表示点亮,bit为0表示熄灭。比如16*16的汉字,就是16行,每行2个字节,总共32个字节。PCtoLCD2013干的事情,就是把你输入的字符转换成一串“0和1”的数据,对应到屏幕上就是字。
1.2 为什么必须用专门的取模软件
有人问,能不能自己写程序算字模?当然能,但你得先搞到字符的点阵数据,而点阵数据本身就是一堆坐标,存在字库文件里。PCtoLCD2013这类软件的价值在于,它内置了多种字库和字体,能直接把系统字体或者自定义字库的点阵信息提取出来,并且允许你配置提取方向、位序极性、数据格式等关键参数。这些参数直接决定你取出来的数组能不能被你的显示驱动正确解析。
我早期偷懒用过在线取模工具,说实话,网页版大多只支持标准参数,遇到非主流分辨率或者需要纵向取模的时候,完全没法调。本地软件就不一样,所有参数都是可视化的,而且支持批量生成,做项目时效率高得多。这也是为什么时至今日PCtoLCD2013仍然是很多嵌入式工程师电脑里的常驻工具,它轻量、离线、参数可调,而且对中文字库的支持特别友好。
2. 参数配置是取模的灵魂:逐行式、阴码、高位在前到底怎么选
PCtoLCD2013的界面看着简单,但上面的参数每一个都直接影响最终数据。我见过太多人卡在这里,因为网上教程各说各话,有的说用“逐行式”,有的说用“列行式”,还有人说阴码阳码无所谓——这全是没搞清楚原理就瞎传。这一节我把每个关键参数逐一拆开讲。
2.1 取模走向:逐行式还是列行式
“取模走向”指的是扫描点阵时的顺序,也就是你要把这16*16个点按什么顺序排成一串bit。PCtoLCD2013提供了逐行式、列行式、行列式、逐列式等几种走向模式,其中最常见的是逐行式和列行式。
逐行式,字面意思就是按行扫描:第一行从左到右取8个点组成一个字节,再取第二行……直到全部取完。这种方式适合大多数OLED驱动库,因为它和显存的物理布局一致(SSD1306的页地址模式就是先把一整行的数据写入对应的GRAM地址)。
列行式则是按列扫描,适合那种显存按列组织的控制器,或者你显示图片、特殊字体时想要竖排效果。这里有个关键经验:用别人的显示驱动代码之前,先看它内部是怎么把数据写到GRAM的。比如江协科技的OLED驱动,底层是按页写入的,它要求字模是逐行式;如果你用列行式取模,就会出现文字上下“撕裂”、左右错位的现象。
2.2 阴码和阳码:0亮还是1亮
阴码表示“有笔画的地方bit为0,空白处bit为1”,也就是反色显示;阳码则正好相反,笔画处为1,空白处为0。绝大多数OLED驱动库用的是阳码,因为SSD1306的GRAM写入1代表点亮像素。但有些图形库为了叠加显示方便,会用阴码。
我的建议很简单:默认选阳码。如果显示出来发现文字是反白的(亮底黑字),再把取模方式改成阴码重新生成就行。这里不需要背规则,看到实际效果反了,直接改参数重取,比在代码里做位取反处理要省事得多。
2.3 高位在前还是低位在前:最容易翻车的一个设置
这个参数我需要重点强调一下。它的含义是:一个字节的8个bit中,最左边(屏幕最左或最上方)的像素点对应的是bit7还是bit0。
- 高位在前(MSB first):第一个像素点对应bit7,第二个对应bit6,依此类推;
- 低位在前(LSB first):第一个像素点对应bit0,往上递增。
SSD1306逐行显示时,如果驱动代码在写一字节数据时是从bit0开始往右铺,但你取模设置的是高位在前,数据就会左右镜像。很多人调了半天发现文字是反的,问题就出在这里。江协科技的代码和大多数STM32教程里的OLED驱动,用的是高位在前;但也有一批现成库用低位在前,所以最稳妥的办法是:先看驱动代码里像素拼接的写法,再定这个参数。
注意:如果你在取模软件里选的列行式,那这个“高低位”对应的就是上下方向而不是左右方向了。这也是为什么我不建议新手一上来就各种组合参数乱试,先固定“逐行式+阳码+高位在前”这一套标准组合,确认显示正常后,再去调其他模式。
2.4 自定格式和生成C文件的方式
PCtoLCD2013里“自定格式”可以控制最终生成的数组是0xXX, 0xXX还是带u8前缀,还能加注释。我一般会把“每行显示数据个数”设为16或32,这样生成的代码直接复制就能用,不需要自己手工调换行。另外“数据前缀”我习惯留空,然后在代码里统一用uint8_t类型的数组来存,这样不管换屏幕还是换芯片都不用改字模文件。
3. 保姆级实操:从安装到生成可用的字模全流程
参数原理讲完了,现在进入正题,我用一个实际案例带大家走一遍完整的取模流程。假设我们要在0.96寸OLED(12864, SSD1306, I2C接口)上显示中文“电”字,大小为1616,配合STM32 HAL库的I2C驱动。
3.1 软件安装与界面初识
PCtoLCD2013是个Windows下的绿色小软件,下载后解压就能运行,不需要安装。打开后界面分成几个区域:左侧是预览窗格,显示你当前取模字符的字形;右侧是参数配置区;顶部是菜单栏,“选项”菜单里可以调字宽字高、字体、取模方式等等。
初次打开先别急着输入汉字,先把基本参数设好:在“选项”里把“字宽”和“字高”分别设为16,然后在左侧输入框里输入“电”字,点“生成字模”按钮(有的版本是菜单栏里的“取模”),预览窗格就会显示这个字的16*16点阵图形,下面的输出区自动生成一串十六进制字节。
这里有个小细节:预览窗格里每个小方格对应一个像素点,黑色方块代表点亮。你切换阴码/阳码时,预览窗格的颜色会跟着变化,这就是为什么我喜欢先在预览窗格里确认字形——如果字形左右颠倒,那就是高低位问题;如果整体像摔碎了一样错位,那就是走向问题。
3.2 标准配置组合实操:16*16中文
下面是我实测稳定的配置组合,按这个来基本不会出错:
- 菜单“选项”→“字宽和字高”设为16和16;
- “取模走向”选逐行式;
- “每行显示数据”选16(这样每个汉字生成16行,每行正好16bit即2字节);
- “取模走向”下方的“字节位数”选8位;
- “阴码/阳码”选阳码;
- “高位/低位在前”选高位在前;
- 点击“生成字模”。
生成出来的数据大概是这样的(示例):
0x00,0x00,0x00,0x08,0x10,0x10,0x10,0x10, 0x10,0x10,0x10,0x10,0x10,0x08,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,这只是示意数据,真实生成的数据根据字体不同会有差异。这里必须提醒一句:不同字体(宋体、黑体、楷体)生成的字模数据完全不同,但只要你取模参数一致,显示上不会出问题,最多就是字形风格不同。
3.3 ASCII字符的取模:68还是816
英文和数字字符一般用816或68点阵。在PCtoLCD2013里,输入字符后同样要把字宽字高改成对应值。我平时显示数字、字母用的最多的是816和68两种:
- 816:字符宽8像素,高16像素,竖直方向上能跟1616汉字对齐,适合做标题、大字读数;
- 6*8:字符宽6像素,高8像素,行距更小、显示密度高,适合做列表、菜单文字。
6*8取模时有个小坑:宽度是6,不满8的倍数,软件会怎么处理?PCtoLCD2013会把6个像素凑成一个字节,剩余2位补0。你拿到数组后,显示驱动需要按照“每行1字节、共8行”的格式写。要注意的是,由于不满8位,这个字节的实际有效数据只有低6位(低位在前时),或者高6位(高位在前时),驱动代码里一定要按每行1字节处理,不要按8的整数倍去对齐。
3.4 批量取模:做字库表时能省一半时间
很多项目需要显示几十个汉字,比如“设置”、“返回”、“确定”、“取消”这些菜单词。逐个复制数据实在太蠢了。PCtoLCD2013支持批量操作:在左侧输入框把要取模的汉字直接打在同一行,比如“设置返回确定取消”,设置好字宽字高后点生成,软件会一次性把所有字符的数据排列出来。
你需要做的事情是在代码里给每个汉字加注释,或者干脆把取模结果按顺序存成一个数组,然后用一个索引表去查。我习惯把字模数据按“字符在字符串中的顺序”直接排进一个大数组,另外再维护一个const char* menu[]和对应的索引表,这样可以实现用文字索引直接找到字模地址。
需要注意的是,批量取模时所有字符的字宽字高必须一致。你不可能在一个1616的配置里混入一个816的字符,所以如果你项目里中英文混排,建议分开取模,英文单独做一套8*16的ASCII字库。
3.5 图片字模:不只是文字
PCtoLCD2013还能把一张BMP图片转成字模数据,这在做logo显示、开机动画时特别有用。操作方式基本一样:打开图片文件,然后设置取模方式和颜色阈值。OLED是单色的,所以图片会被二值化成黑白图,白色通常对应1(点亮),黑色对应0。
图片取模最容易出问题的还是分辨率匹配。12864的屏幕,你的BMP图片最好就是12864,或者宽正好是8的倍数,高任意。如果图片宽不是8的倍数,软件会在右侧补齐空白位,你的显示代码就得处理这部分无效数据,很麻烦。我都是先用画图工具把图片精确裁剪到目标尺寸,然后再导入取模,省去很多后续调试。
4. 字模数据写进代码之后:从数组到屏幕像素的完整链路
取模只是第一步,把数组正确显示到屏幕上,中间还涉及显存操作、坐标计算、驱动函数封装这一整套链路。这一节我从“显示一个汉字的位置”角度,把数据到底怎么流进屏幕讲透。
4.1 坐标计算:你要把字放在哪一页第几列
SSD1306的GRAM是一个128*64的位图矩阵,但它内部并不是按行连续存储的,而是分成了8页(Page),每页8个像素高。所以一页能显示8行像素,你写一列数据时,实际上是同时写这列中这8个点。这就是为什么很多驱动库里写汉字要用两层循环:外层控制列,内层控制页。
举个具体例子。要在屏幕左上角显示一个16*16的“电”字:
- 左上角的横坐标x从0开始;
- 纵坐标y从0开始,那么这个字占用第0页和第1页(分别对应该字上半部分8行和下半部分8行);
- 横向跨度16列,即列地址0到15。
如果要把字放在屏幕中间,横坐标就要计算:(128 - 16) / 2 = 56,纵坐标同理:(64 - 16) / 2 = 24,那么起始页是24/8=3,起始列是56,跨两页共2字节纵向需要循环16列。
4.2 驱动代码里怎么显示字模数据
传统的直接写GRAM方式,大致长这样(以HAL库的I2C为例,驱动函数我简写处理):
void OLED_ShowChinese(uint8_t x, uint8_t y, const uint8_t *font_data, uint8_t dot_size) { uint8_t i, j; uint8_t page_start = y / 8; uint8_t page_num = dot_size / 8; // 16*16字模占2页 uint8_t bytes_per_row = dot_size / 8; for (j = 0; j < page_num; j++) { OLED_SetCursor(x, page_start + j); for (i = 0; i < bytes_per_row; i++) { OLED_WR_Byte(font_data[j * bytes_per_row + i], OLED_DATA); } } }取模数据在数组里的排列顺序是:第一行的2字节、第二行的2字节……一直到第16行的2字节。而上述代码是“先写第0页16列,再写第1页16列”,正好和取模数据的“逐行式”排列对应上了——前16字节的每两个字节正好就是上半部分的每一行。
这个对应关系一旦搞清楚,后面不管用哪个取模软件、什么驱动库,你都能自己把数据“翻译”过去,不会被别人的代码牵着鼻子走。
4.3 使用显存缓冲区的注意点
如果你的项目用了显存缓冲区(FrameBuffer),也就是先把所有内容画到内存里,再一次性刷到屏幕,那么字模数据的拼接逻辑也是一样的,只不过最终是把数据往buffer的对应位置搬运。这种方式的好处是杜绝闪烁、支持局部刷新,代价是占用1KB内存(128*64/8)。在STM32F103这类芯片上无所谓,但在ESP32-C3这种资源紧张的芯片上,你要注意内存规划。
用缓冲区时还要留意一个坑:如果你的代码是用SPI/DMA刷新显存,那缓冲区数据必须严格按显存页排布(从第0页第0列到第7页第127列),否则屏幕会整个乱掉。这也是为什么我强调字模走向要选“逐行式”,因为绝大多数OLED的GRAM扫描顺序就是逐行逐列来的,跟缓冲区的线性排布完全一致。
4.4 自己写一个带偏移的显示函数
显示文字往往不会总从(0,0)开始,所以驱动代码里要有坐标偏移能力。SSD1306本身支持设置列地址和页地址,你可以通过SetCursor(x, page)来定位。但要注意,列地址0~127对应屏幕左到右,页地址0~7对应屏幕上到下,这个对应关系是固定的。
我习惯在驱动函数里加一个uint8_t y_offset参数,用于整行文字的上下微调。因为不同字号的ASCII和中文混排时,基线的对齐经常需要手动挪1~2个像素,这时候改取模数据不现实,直接在显示函数里做偏移最方便。
5. 常见问题排查:乱码、卡死、反字、缺笔画一次说清
这一节集中写我这些年遇到过的高频问题,很多都是从新手提问里总结出来的,按影响范围和排查优先级排个序。
5.1 文字乱码、花屏:先怀疑取模参数,再怀疑坐标越界
屏幕能亮、也能看到有东西显示但完全不是字,通常是取模参数和驱动代码不匹配。我建议排查顺序是:
- 确认取模走向和驱动代码的GRM写入顺序一致;
- 确认阴码/阳码是否反了(直接看有没有“反白”效果就能判断);
- 确认高低位是否颠倒(字会左右镜像或者每个字节的像素点错位);
- 确认坐标有没有越界,比如x到了120再显示16像素宽的汉字,超出128列的像素会被驱动自动丢弃或者回绕,表现就是那行末尾出现杂乱的横线或残影。
如果你用的是我上面说的“逐行式+阳码+高位在前”标准组合,驱动代码又是江协科技那一套,那大概率一次就能亮。
5.2 加了OLED函数之后程序卡死:八成是I2C等待回应出了死循环
这是新手最容易崩溃的问题——OLED单独测试好好的,一加进整个工程就卡死。大多数情况下,卡死发生在I2C发送函数里的等待ACK步骤。OLED的I2C没有准备好、接线接触不良、地址错误、SCL/SDA引脚被其他外设占用,都会导致总线没有ACK回包,如果你的发送函数写了“等待ACK,没等到就死等”的逻辑,整个系统就被挂死了。
排查手法很简单:在I2C发送函数里加一个超时计数,比如while(...) { if (timeout++ > 1000) break; },然后看还卡不卡。如果不卡了,就说明确实是I2C通信有问题,接下来去查地址(常见的是0x78或0x7A,取决于SA0引脚的电平)和接线。
5.3 矩阵按键在OLED没反应:不是OLED的锅,是轮询机制互相挤占了
有个热词提到“矩阵按键在oled没有反应”,这种问题本质上不是OLED显示失效,而是你的主循环里OLED刷新太频繁、耗时太长,导致按键扫描得不到及时执行。OLED虽然耗时不长(纯刷一屏大概十几毫秒到几十毫秒),但如果你在主循环里高频刷屏,按键的消抖和状态读取就会被严重延迟,看起来就是“没反应”。
解决办法:一是降低OLED刷新频率,只有数据变化时才刷新;二是把按键扫描放到定时器中断里做;三是用标志位控制,按键按下置一个标志,主循环检测到标志后再去OLED显示,而不是每次循环都刷新。
5.4 字模取出来比预想的多了一倍数据
这种情况一般是字宽字高设置错了。比如你要显示8*16的ASCII,结果字宽填了16,出来的数组长度直接翻倍。PCtoLCD2013里字宽字高是每个字符独立生效的,批量取模前务必确认配置。另外,检查一下“每行显示数据”是不是设成了16,如果设成16但字宽只有8,软件会用0填充后半段,数据长度也变大。
5.5 0.96寸和1.3寸OLED的驱动兼容性
0.96寸一般是SSD1306控制器,1.3寸有的也是SSD1306,有的则是SH1106。两者的I2C地址一样、指令集大致兼容,但SH1106的GRAM是从列地址2开始的,也就是横向偏移2个像素。如果你拿驱动SSD1306的代码直接驱动SH1106,屏幕内容会整体往右偏2列,看起来最左边有一条黑边。解决方案是在初始化时把显示偏移设置指令处理好,或者干脆用不同驱动文件,别混用。
写到这里,我想起一个特别典型的案例:之前有个朋友用1.3寸OLED,字模参数完全正确,但左边总有一条异常像素带,折腾一晚上,最后发现控制器是SH1106,跟SSD1306接口兼容但显存地址映射不同。从那以后我拿到屏的第一件事,就是先看控制器型号再选驱动代码。
5.6 排查技巧:万能的最小字模验证法
当你怀疑取模配置有问题时,不要直接取汉字,先取一个简单的“1”或“一”字测试。因为简单字符的像素结构肉眼就能判断方向:你很快能看出来它是不是反了、是不是错位了。我通常会在屏幕上同时显示一个“1”、一个“A”、一个“中”,分别对应816 ASCII和1616汉字,三个字同时正确,才能确认取模参数是整体可用的。
6. 项目里的实践经验:一套配置组合建议
最后分享一套我在小项目里长期使用的字模参数组合,用这套配置搭配大多数HAL库OLED驱动(尤其是江协科技风格代码),基本能做到“取模即用”。
| 参数项 | 推荐值 | 适用场景 |
|---|---|---|
| 取模走向 | 逐行式 | SSD1306/SH1106等常见OLED控制器 |
| 阴码/阳码 | 阳码 | 大多数驱动库默认写1点亮 |
| 高低位 | 高位在前 | 江协科技、中景园等常见驱动 |
| 字节位数 | 8位 | 标准字节数组 |
| 每行显示数据 | 16(汉字1616)/ 8(ASCII 816)/ 6(ASCII 6*8) | 按目标字符宽度填 |
| 数据前缀 | 留空 | 代码里统一用uint8_t声明 |
| 生成方式 | C文件导出 | 直接#include进工程 |
这个表格不是万能法则,但它是一个经过多项目验证的“起跑配置”。你用了这套配置,再配合标准驱动代码,如果你的屏幕还显示不正常,那问题基本在接线或驱动代码本身,而不是取模参数。
如果屏幕显示反白,把阴码改成阳码(或反过来)重新取模一次;如果字是镜像的,把高低位反过来;如果字是斜着断开的,把取模走向从逐行式换成列行式或者反向再试一次。这些都是实际调试时的快速通道,比在代码里大量做位变换要直观得多。
另外提一句,PCtoLCD2013支持保存配置方案。把上面这套参数存成一个配置,换新项目直接加载,不用每次重新设一遍,省心太多。我电脑里至今留着一个“OLED_12864_Standard.conf”,从大学做智能小车到现在做手持仪器,一直在用。