很多人第一次拿到0.96寸OLED,第一件事就是点亮它、显示几行英文和数字。这个不难,驱动库里自带的ASCII字符集足够你玩半天。但等你想把项目名、菜单项、提示语换成汉字的时候,屏幕上一排排的乱码和方块马上就能让你冷静下来。原因很简单:OLED本身不认识汉字,它只认像素点阵,而汉字点阵并没有内置在SSD1306里,得靠你自己把它“翻译”成一串数组,这个翻译过程就是取模。这篇文章要解决的就是这个环节——用PCtoLCD2002把汉字转换成0.96寸OLED能直接显示的取模数据,再配合一套HAL库的驱动代码,让你从取模配置到屏幕显示一次跑通。
我默认你手头是一块常见的128x64分辨率、SSD1306控制器的0.96寸OLED,接口是I2C,MCU这边用STM32F103C8T6和HAL库,硬件I2C和软件I2C我们都会提到。如果你用的是其他MCU平台也没关系,取模部分是通用的,驱动代码的逻辑也完全照搬。
1. 先搞懂你的屏幕:0.96寸OLED到底怎么“画”出来的
1.1 SSD1306控制器的基本工作方式
0.96寸OLED的屏幕分辨率是128x64,意思是横向128列、纵向64行,一共128*64=8192个像素点。每个像素点只有亮和灭两种状态,就像一张棋盘,你要显示什么内容,本质上就是把对应位置的“棋子”放上去——点亮或不点亮。
这块屏幕的所有像素点都归SSD1306这颗驱动芯片管。它内部有一块1KB的显存,叫做GDDRAM,或者说是一块“画布”。你往画布上写1,对应的像素就亮;写0就灭。注意,这块画布的排列方式不是我们习惯的从左到右、从上到下的连续二维数组,而是被分成了8个“页”(Page),每一页8个像素点高。64行像素正好分成64/8=8页,也就是Page0到Page7。
每一页里,一个字节控制8个像素点,这个字节的每一位对应屏幕上一列里的8个纵向像素。一个字节有8位,一页横向有128列,所以一页就是128字节。这也是为什么OLED驱动里最常见的操作是“先选页,再选列,然后连续写数据”——你是在按页、按列填这块画布,而不是按行填。
1.2 页、列与坐标:为什么16x16汉字要占“两页”
搞清楚页的概念之后,汉字的显示逻辑就清晰了。一个16x16汉字,也就是宽16像素、高16像素。屏幕每页只有8像素高,所以一个16像素高的汉字,天然就要被拆成上下两段:上半段放在某一页,下半段放在下一页。
这就是为什么PCtoLCD2002取模时,一个16x16汉字的32字节数据通常是按“第一行8个点一个字节、第二行再一个字节……一直数到第16行”来排布的,也就是前16个字节是上半部分,后16个字节是下半部分。你在代码里显示汉字的时候,程序逻辑往往就是:先定位到第n页,把前16字节写进去;再定位到第n+1页,把后16字节写进去。
理解这一点之后,你以后再换24x24、32x32的汉字或者图形,计算思路完全一样:高除以8,就知道占多少页。这个换算关系是所有OLED显示汉字的基础。
1.3 I2C接口与常见接线
0.96寸OLED有I2C和SPI两种接口版本,引脚数量不太一样。I2C版一般只引出4个引脚:VCC、GND、SCL、SDA,部分模块还会带RES和DC,但I2C模式下这两个脚一般可以悬空或者接高电平。
在STM32F103C8T6上,我的习惯是把SCL接到PB8、SDA接到PB9,也就是I2C1的默认引脚。如果你用的是硬件I2C,在CubeMX里把I2C1配成Standard Mode、100kHz或者400kHz都行。如果用的是软件I2C模拟,那这两个GPIO随便选,只要代码里对应改一下引脚号即可。
这里有个特别坑的细节:I2C从机地址。SSD1306的7位I2C地址默认是0x3C,但很多模块的地址引脚(SA0)状态不同,实际地址可能是0x3D。更坑的是,不同库和不同HAL代码对地址的写法不统一,有的直接写0x3C,有的写0x78(含读写位的8位地址)。我的建议是:先按0x3C试,屏幕没反应就把地址改成0x3D或者0x78再试,这几个取值对初学者来说属于“必踩一脚”的经典坑。
2. 字模是怎么算出来的:PCtoLCD2002核心配置
2.1 从像素到字节:16x16汉字为什么是32字节
取模工具做的事,本质就是把一个字在屏幕上显示出来的像素点,转成一串二进制数字。汉字以16x16点阵为例,横着16个点、竖着16个点。因为OLED是按字节扫描的,8个点为一个字节,所以16个点宽一行就需要2个字节。16行乘以2字节,一共32字节。这就是“32字节”这个数字的来源。
放到PCtoLCD2002的预览界面里看更直观:你在左侧输入一个汉字,右侧就是它的像素放大图,黑色点就是要点亮的像素。取模就是把这张像素图按你设定的规则,从上到下、从左到右扫一遍,用1和0记录下每个位置“亮”还是“灭”,最后再按每8个点一组拼成一个字节。
这里有一个初学者最容易误解的点:你以为取模工具扫出来的顺序,和SSD1306画布自动匹配。实际上不是,你必须保证取模工具的扫描方向和你在代码里填充GDDRAM的方向一致,否则就会出现镜像、颠倒、花屏。
2.2 四个关键选项:阴码/阳码、顺向/逆向、逐行/逐列、每行点数
打开PCtoLCD2002,你会看到一堆选项,新手很容易懵。其实真正影响显示效果的核心选项就四个,我逐个讲明白。
第一,点阵格式选“阴码”。对SSD1306这种屏幕来说,写入1对应像素点亮,所以要选阴码,也就是“有笔画的地方是1,没笔画的地方是0”。如果选了阳码,结果正好反过来,汉字会变成黑底白字的反显效果,很多时候也是能用的,但第一次尝试不要自己加戏,就用阴码。
第二,取模走向选“顺向”。这个选项决定你从像素图的哪个方向开始扫描。我的经验是:SSD1306配合“顺向”基本能直接显示正常,但如果你发现汉字整体左右镜像了,不用改代码,回到PCtoLCD2002把“顺向”切换成“逆向”重新取模即可。这就好比你把一张透明的字帖翻了个面,字就反了。
第三,取模方式选“逐行式”。这个选项决定一个字节里的8个点是按行排列还是按列排列。对于OLED,我强烈建议选逐行式,因为SSD1306的GDDRAM本身一列就是8个纵向像素点,你按“行”逐行扫描出来的字节排列,正好和屏幕的页结构一一对应,代码写起来最顺手。选“逐列式”也有对应的玩法,但发送数据时的逻辑要跟着变,初学者没必要在这里自己为难自己。
第四,每行显示点数选16。这个选项的意思是,生成的字模每“行”数据按16个点来切割。因为16x16汉字一行正好16个点宽,所以选16。如果你做的是8x16的ASCII字符,这里就选8;如果是32x32的图片,这里就选32。它决定了生成数组分组的视觉格式,对数据内容没有本质影响,但选对了数组检查起来舒服很多。
2.3 一套稳定配置:怎么设置才能让OLED和代码正好对上
针对0.96寸OLED,我长期使用且已验证无数次的PCtoLCD2002配置组合如下:
- 字体:宋体,16号(也就是16x16点阵)
- 字宽/字高:16、16
- 点阵格式:阴码
- 取模走向:顺向
- 取模方式:逐行式
- 每行显示点数:16
- 自定义格式:勾选“C51格式”,去掉其他不必要的前后缀
这套配置产出的数组,就是标准的{0x00,0x00,...}形式,直接粘贴到C代码里就能用。如果你的屏幕显示出来镜像,先把“顺向”改成“逆向”;如果显示出来是上下颠倒,检查一下你代码里定位页的顺序;如果显示出来是一堆杂乱的点阵,十有八九是你“逐行式”和“逐列式”搞混了,或者代码里按页填数据的顺序和取模顺序不一致。
记住这套组合拳,以后不管取多少字都不会出大问题。这也是为什么我建议初学阶段不要乱调参数,先原样照抄这套配置跑通一遍,再考虑个性化。
3. 手把手取模实操:从输入汉字到生成C数组
3.1 打开软件、输入汉字、调整字体的完整流程
PCtoLCD2002这个软件界面比较复古,但功能打磨得很成熟。第一步,打开软件后,在左侧输入区域输入你要显示的汉字。一次可以输入多个,软件会逐个取模,但我建议第一次操作一次只输入一个“中”字,方便核对数据。
输入完成后,点击上方菜单里的“选项”,进入设置界面。先把字体设置成宋体,大小选择16。注意这里有个隐蔽的问题:如果你用系统字体选“宋体”,同一个16点阵在不同电脑上的渲染结果可能略有差异,但一般不影响使用。如果你需要标准国标点阵,可以在“选项”里选用内置的HZK16字库文件。这个方案生成的字模更接近传统汉字库效果,但我个人在实际项目里更喜欢系统字体,因为它能直接看到预览效果,所见即所得。
字体设置好之后,右侧预览窗口会显示放大后的像素点阵。这时候你要检查一下,这个字有没有超出16x16的方框范围。如果笔画明显顶格或者超出,后面取出来的模就是残缺的。
3.2 自定义输出格式:生成可以直接粘贴的数组
接下来是输出格式的设置。在“选项”菜单里找到“自定义格式”,这地方决定生成的数据长什么样,非常关键。
推荐设置格式为:前缀空,后缀用,或者,加换行,每个字节用0x%02X输出。最常用的格式是{0x%02X},,生成效果就是{0x10,0x08,...},这种标准C数组风格,复制到代码里直接用。
设置里还有个“索引”选项,如果你勾选了,生成的数据前面会出现{0}这种占位编号,一般不用。我习惯把所有额外信息都去掉,只保留纯数据,这样粘贴进C文件最干净。
设置好之后,回到主界面,点击“生成字模”或者“取模”按钮,下方就会输出一行行十六进制数据。以“中”字为例,取模出来是32个字节,正好排成两行也行,一行排开也行,因为每行显示点数设成16,所以通常是前16个字节一行、后16个字节一行,方便你对照检查。
3.3 取模过程中最容易翻车的三个细节
第一个细节是字体带锯齿。如果你在系统字体里选的不是16号而是其他带抗锯齿效果的字体,取出来的模边缘会有大量低频杂点,显示在OLED上就是“毛刺”。解决办法是在字体设置里尽量选择不带抗锯齿的宋体,或者关闭平滑效果。
第二个细节是输入法状态。某些输入法在输入汉字时会自动带上全角空格或者拼音候选状态下的特殊字符,你看着是一个汉字,实际可能混入了不可见字符,取模出来的数组长度就不对。我的习惯是在输入区只打纯汉字,生成前数一眼“字高”和“字宽”是不是16,输出字节数是不是32的整数倍。
第三个细节是标点符号和数字。如果你要显示“年月日”这种中间夹着数字和汉字的字符串,中文取模和ASCII取模是两套流程。ASCII字符可以用8x16点阵,直接查库函数里自带的字符表,不占用你的字模空间。但数字如果和汉字混排,尺寸比例要自己调试。这个没有捷径,只能多试几次。
4. HAL库工程里的完整OLED驱动代码
4.1 工程配置:I2C引脚、时钟与地址选择
在STM32CubeMX里新建一个STM32F103C8T6工程,把SDA配到PB9,SCL配到PB8,选择I2C1,Mode选I2C,Speed Mode选Standard Mode(100kHz)或者Fast Mode(400kHz)都行。400kHz在短线上通常没问题,但如果你的杜邦线很长,我建议先跑100kHz,稳一点再提速。
生成代码之前,记一下I2C1的从机地址宏。在main.h或者i2c.h里加一行:
#define OLED_I2C_ADDR 0x3C // 根据你模块实际地址调整,备用0x3D或0x78这部分要注意,HAL库的HAL_I2C_Mem_Write函数第2个参数DevAddress用的是7位地址,而很多旧示例代码用的是8位地址0x78。如果你用的是HAL库,写0x3C通常是对的;如果你在某份代码里看到0x78,那不是错了,而是那套代码已经帮你把移位处理好了。这两者混用是移植OLED代码最常见的错误来源。
4.2 底层函数:写命令、写数据、设置坐标
先看底层发送函数。SSD1306通过一字节的控制前缀来区分后续字节是命令还是数据,所以封装两个函数:
void OLED_WrCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_I2C_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); } void OLED_WrDat(uint8_t dat) { HAL_I2C_Mem_Write(&hi2c1, OLED_I2C_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, &dat, 1, 100); }这里核心就是Mem_Write里的第三个参数,0x00代表写命令,0x40代表写数据。有人会把控制字节一起打包成2字节的数组发送,效果一样。
接下来是设置坐标。SSD1306页地址模式下,要先选页,再选列,列地址是分两次发送的低位和高位:
void OLED_SetPos(uint8_t x, uint8_t page) { OLED_WrCmd(0xB0 + page); // 页地址,0~7 OLED_WrCmd(x & 0x0F); // 列地址低4位 OLED_WrCmd(0x10 | (x >> 4)); // 列地址高4位 }这里的page是页号,不是像素行号。如果你想在屏幕上第16行开始显示,因为每页8行,所以page应该是2。x是列号,取值范围0到127。
4.3 显示汉字:把字模数据填进GDDRAM的完整函数
下面这个是核心函数。它接收起始列x、起始页page、以及字模数组的指针:
void OLED_ShowChinese(uint8_t x, uint8_t page, const uint8_t *data) { uint8_t i; OLED_SetPos(x, page); for (i = 0; i < 16; i++) // 写上半部分,16字节 { OLED_WrDat(data[i]); } OLED_SetPos(x, page + 1); for (i = 16; i < 32; i++) // 写下半部分,16字节 { OLED_WrDat(data[i]); } }这个函数之所以能这么简单,完全依赖前面选定的“逐行式”取模方式:数组前16字节是汉字上半页的16列点阵,后16字节是下半页的。如果你在PCtoLCD2002里换成了“逐列式”,这里的发送逻辑就得整个反过来,变成先发上半页第1字节、下半页第1字节、上半页第2字节……那种循环方式。
注意位置和边界的组合,如果汉字放在x=120这个位置,它实际要占16列,最后一列会超出屏幕边界,超出去的部分会被SSD1306丢弃,显示出来就是半个字。所以x最大不要超过128-16=112。
4.4 一个完整的最小测试工程示例
把上面这些函数组合起来,再加一个清屏函数和初始化函数,就能组成最小工程。初始化序列网上到处都是,我贴一段稳定版本:
void OLED_Init(void) { HAL_Delay(100); OLED_WrCmd(0xAE); // 关闭显示 OLED_WrCmd(0x20); // 设置内存寻址模式 OLED_WrCmd(0x02); // 页寻址模式 OLED_WrCmd(0xB0); // 设置起始页 OLED_WrCmd(0xC8); // 从上到下扫描 OLED_WrCmd(0x00); // 列地址低四位 OLED_WrCmd(0x10); // 列地址高四位 OLED_WrCmd(0x40); // 起始行 OLED_WrCmd(0x81); // 对比度设置 OLED_WrCmd(0x7F); OLED_WrCmd(0xA1); // 段重映射 OLED_WrCmd(0xA6); // 正常显示 OLED_WrCmd(0xA8); // 多路复用比 OLED_WrCmd(0x3F); OLED_WrCmd(0xD3); // 显示偏移 OLED_WrCmd(0x00); OLED_WrCmd(0xD5); // 时钟分频 OLED_WrCmd(0x80); OLED_WrCmd(0xD9); // 预充电周期 OLED_WrCmd(0xF1); OLED_WrCmd(0xDA); // COM硬件配置 OLED_WrCmd(0x12); OLED_WrCmd(0xDB); // VCOMH OLED_WrCmd(0x40); OLED_WrCmd(0x8D); // 电荷泵 OLED_WrCmd(0x14); OLED_WrCmd(0xAF); // 开启显示 }这段初始化和网上流传的版本差不太多,只是我删掉了一些对0.96寸屏幕来说可有可无的指令。如果你是从别的工程移植过来的初始化序列,只要没乱改,通常也能点亮。
测试代码只需要三行:
OLED_Init(); OLED_Clear(); OLED_ShowChinese(0, 0, HZ_ZHONG); // HZ_ZHONG 是“中”字的字模数组名main函数的while(1)里什么都不用做。屏幕左上角应该出现一个完整的16x16“中”字。
5. 常见问题与排查实录
5.1 屏幕全黑或无反应
全黑是最常见也最让人崩溃的问题。排查顺序我建议按这个来:先测供电,0.96寸OLED模块供电3.3V和5V都行,但VCC和GND接反会直接烧模块,所以第一步就是确认引脚没接错。第二步确认I2C地址,0x3C和0x3D来回换着试。第三步确认SCL和SDA有没有接反。
这里要提一个容易被忽略的情况:硬件I2C有时候初始化有问题,HAL库的HAL_I2C_Mem_Write会一直返回超时,但代码没有报错机制,看起来就是屏幕全黑。这时候你在OLED_WrCmd里加一个返回值判断,打印错误码,或者干脆换软件I2C模拟。我本身并不排斥硬件I2C,但在STM32F103上它偶尔会有Busy位卡死的小毛病,尤其是在调试器反复复位的场景下。如果你遇到这个情况,最简单的办法是给I2C外设加一个DeInit再Init,或者直接改成软件I2C。
5.2 字形镜像、倒置、花屏
字形左右镜像,几乎可以肯定是“顺向”和“逆向”的问题,回到PCtoLCD2002切一下重新取模就行。注意取模方向同时会影响高低位的排列,有时候你切换“顺向”之后,不仅字正了,连字节的位序也正了。
字形上下倒置,通常是初始化序列里扫描方向和扫描起始行设置不匹配。修改0xC8为0xC0试试,或者查一下0x40起始行指令是否和你的COM输出方向一致。不过多数情况下,0.96寸OLED用了0xC8+A1这套组合就是正确的,你最好先检查页定位逻辑而不是急着改初始化。
花屏这种问题最头疼,因为成因太多。最常见的一种是:你用的是逐列式取模,代码却按逐行式发送;或者反过来。这类问题只能对比字模数据和实际点阵来排查,没有捷径。
5.3 显示位置错乱与上下半字分离
显示出来的汉字,上半部分在屏幕顶部、下半部分在屏幕中间,中间隔着一行空白,或者上下两部分错开。这种问题的根源是上下两页的起始列没对齐。看OLED_ShowChinese函数,第一次OLED_SetPos(x, page)和第二次OLED_SetPos(x, page+1),两次的x必须是同一个值,而且page+1的范围不能超过7。
还有一个容易踩的坑是,有些人在不同函数之间共享了一个全局的当前坐标变量,显示完一个汉字后坐标自动加16,但页面坐标没有跟着更新,导致下一个汉字显示位置错乱。我的建议是:坐标参数全部显式传递,不要用隐式全局状态,排查起来省力很多。
5.4 批量点不亮与供电问题
如果你是一次点亮十块屏幕,发现有部分屏幕不亮或者亮度明显偏低,大概率是供电问题而不是代码问题。SSD1306的电荷泵需要一定的电流余量,几块屏堆在一起,稳压模块输出能力不够,就出现“批量点不亮”的现象。
解决思路很简单:单独给屏幕供电,或者加大电源容量。另外,I2C总线上如果挂了多块OLED,地址又相同,通信会冲突,需要给每块屏幕配不同的SA0地址,或者在硬件上做I2C地址扩展。这个属于工程化的问题,做样品阶段很多教程不会提,但批量制作的时候几乎必现。
6. 还能怎么玩:图片、动画和更多扩展
6.1 用PCtoLCD2002给图片取模
PCtoLCD2002不仅能给汉字取模,也能给图片取模。操作方式是准备一张BMP格式的图片,大小建议控制在128x64以内,然后在PCtoLCD2002里选择导入图片,软件会把图片转换成像素点阵。取模参数和汉字一样,依然是阴码、顺向、逐行式,这样生成出来的数组可以直接用OLED的整屏填充函数刷上去。
显示图片的重点是取模前对图片做预处理。0.96寸OLED是单色屏,所以图片在导入前最好先在画图软件里转成黑白两色,不然取模出来会有大面积的噪声点。我的习惯是用Photoshop或者画图,把图片改成128x64像素,然后灰度模式转位图,再保存成BMP。纯黑背景、白色前景的图片显示效果最好。
6.2 多帧动画与动态显示
既然能做到整屏图片显示,动画的思路就很顺了。把动画拆成多帧图片,每一帧单独取模,生成多个数组,然后在主循环里按一定的时间间隔切换显示。
比如做一个简单的移动方块动画,你可以准备4帧图片,每帧里方块位置不同。代码逻辑就是每200ms切换一帧,用OLED_ShowImage整屏刷新。这里的坑在于刷新速度:SSD1306的I2C带宽有限,如果每帧128x64的满屏数据,在400kHz I2C下大约需要10多毫秒,动画帧率不用期望太高,做简单UI动效完全够用。
还有一种常见做法是只更新局部区域,比如一个16x16的图标移动,你只需要重绘它经过的那一小块区域,不刷新整屏,这样动效流畅度会好很多。PCtoLCD2002取模多帧图片的工作量是最烦人的部分,建议写个小脚本批量处理,或者直接在代码里偏移数组指针实现简单动画。
6.3 字库整合与更高分辨率屏的迁移思路
当你要显示大量汉字时,一个一个取模显然不现实。这时候的思路是做一个完整的字库文件,把常用汉字按GB2312编码顺序取模,生成一个数组索引文件。显示函数先查拼音或汉字内码,再定位到对应字模数据,这其实就是迷你版字库引擎。
迁移到1.3寸、1.54寸等更大尺寸OLED时,核心原理完全没变。分辨率高了,比如128x64变成128x128,页数从8页变成16页,坐标范围扩大,其他都一样。取模工具、取模配置、发送逻辑完全可以复用。如果你一开始就把驱动函数写得足够抽象,换屏幕时只需要改分辨率和初始化参数,代码框架几乎不动。
最后分享一个我自己的习惯:每次新建OLED工程,我都会先做一个自检函数,在屏幕上依次显示全0、全1、横线、竖线、棋盘格,确认基础显示正常之后再开始跑汉字和图片。这个自检过程看起来多花一分钟,但能帮你把“取模问题”和“硬件/驱动问题”快速分开,省下的排查时间远不止一分钟。玩OLED显示这块,耐心比技巧重要,配置比代码重要,方向比速度重要——希望你也能一次点亮。