
1. 项目概述与方案定位这块1.3寸OLED屏幕想必很多做嵌入式、玩单片机的人手里都有一片。它体积小、分辨率高、不需要背光、亮度足、功耗还低特别适合做状态显示、菜单界面、波形绘制这类任务。我这次把STM32和这块屏通过IIC接口I2C连起来完整的驱动代码经过几轮重构后现在拿出来讲讲整个思路和踩过的坑给后来者一个相对少走弯路的参考。这套驱动方案适合谁刚入门STM32想玩转OLED但又不想照抄稀里糊涂的网上的模板代码的人做项目需要在有限引脚约束下IIC只占SCL和SDA两根线驱动屏幕的人以及已经跑通过但总觉得代码不够规范化、想重构出清晰驱动结构的人。它解决的问题很直接在没有屏幕内置字库的前提下搭建一套可复用、可移植、不依赖特定开发板的OLED驱动框架涵盖初始化、像素操作、字符显示、图片显示四个层次。先说结论性的东西。1.3寸OLED上用的驱动芯片通常是两家的方案一个是SH1106一个是SSD1306两者在指令集上高度兼容最大的区别在于内存映射方式SSD1306的显存是128x64SH1106显存是132x64横向多了4个无效列。这块1.3寸屏默认分辨率是128x64IIC模式下驱动芯片从哪一个偏移地址开始显示取决于厂商默认的列偏移配置通常SH1106需要手动设置偏移而SSD1306不需要。这一点如果一开始没搞清楚后面显示出现“画面整体右移/左移半列”的问题时排查会浪费不少时间。我在项目中选用的是某块国产开发板上集成的1.3寸OLED模块接口引出VCC、GND、SCL、SDA四根排针模块上拉了上拉电阻。从一开始我就确定用软件模拟I2C的方式来做驱动而不是STM32自带的硬件I2C外设。原因后面详细展开这里先声明我的倾向对于玩屏幕这种速率要求不高的外设软件模拟时序足够兼容性还更强。硬件环境某开发板主控STM32F103RCT61.3寸OLED模块SH1106驱动ICIIC接口 软件环境标准外设库SPLKeil MDK代码整体采用模块化分层设计2. IIC接口与OLED的通信机理2.1 IIC总线的基本工作时序IIC总线本质上就两条线时钟线SCL和数据线SDA。所有的设备都挂在这两条线上通过设备地址区分谁和谁通信。通信起始时主机拉低SDA此时SCL为高电平发送一个起始信号结束时主机释放SDA让线上拉高同样SCL为高产生一个停止信号。每次传输一个字节时数据在SCL高电平期间必须保持稳定只有在SCL低电平期间允许SDA变化。这个相位关系是IIC时序的核心写代码时最需要小心的点就是这里——如果你在SCL还为高电平时就把SDA的电平给变了从机就会把这次变化误判成起始或停止信号通信随即出错。实际驱动中我用了标准外设库的GPIO翻转方式来模拟时序速率并没有跑满。根据驱动芯片的数据手册IIC模式下最大时钟频率一般是400kHz。我软件模拟出来的时序实测大概在100~200kHz之间完全够用。屏幕本身不要求高刷新率人眼也看不出和硬件I2C有什么区别。提示OLED模块上通常已经自带上拉电阻如果你的模块比较简陋或者你正在用自己画的板子请记得在SCL和SDA上各接一个4.7kΩ左右的上拉电阻到VCC否则通信会不稳定。2.2 OLED设备的从机地址与寄存器映射这个1.3寸OLED在IIC总线上扮演的是从机角色地址取决于模块上SA0引脚的电平。常见的模块引出的IIC地址有两种0x78写地址和0x7A写地址对应7位地址0x3C和0x3D。我的模块上SA0默认接地写地址就是0x78。IIC通信中每次写操作流程是起始信号发送设备地址字节含读写标志位发送控制字节随后发送若干个数据字节。这里有一个非常关键的细节OLED使用的是控制字节机制而不是裸传数据。控制字节的高位决定后续数据是命令还是显存数据0x00表示后续字节是命令0x40表示后续字节是显存数据。很多第一次接触的人在这里会犯迷糊为什么我发了地址之后屏幕没反应因为你漏了控制字节。从机在收到地址后得先等一个控制字节来区分接下来的字节是命令还是数据如果你直接把命令字节丢上去从机没法识别。2.3 SSD1306与SH1106的差异要点这两颗IC在指令上几乎通用但显存结构不一样。SSD1306内部是128x64的GRAM直接对应屏幕的每一列每一页SH1106则是132x64的GRAM多出来的4列在屏幕显示范围之外。我用的1.3寸屏是SH1106方案因此初始化时机需要在显示范围设置相关命令上稍作调整。翻阅芯片手册SH1106有一条专用命令0xD4Set Display Offset结合列地址范围设置可以修正起始列偏移。如果你初始化后文字整体偏右几个像素或者左侧有一条垂直的条纹通常就是这块偏移没设对。针对驱动为SH1106芯片的1.3寸OLED要在初始化序列中加入段重映射和列偏移修正才能保证64列到128列的每个像素点都正确定位。SSD1306的同类屏幕则不需要折腾这些初始化按常规序列就可以了。所以拿到屏幕先看主控丝印或者问卖家要规格书是第一步要做的功课。3. STM32驱动层的架构设计与代码实现3.1 为什么选软件模拟IIC而不是硬件I2C很多教材喜欢教人用STM32的硬件I2C外设但我实测下来标准外设库版本下的硬件I2C在主频配置不当、中断优先级错乱、或者总线上有其他设备的情况下容易出现Busy Flag卡死的现象。这个现象特别烦人——表现为程序运行一段时间后写入函数就永远停在那里不报错也不超时复位后才能恢复。软件模拟IIC虽然代码看起来多几句但可控性极高时钟极性、速率、时序延时的每一步都在自己手里掌握着出了问题可以直接用逻辑分析仪量波形定位速度快得多。对于OLED这种低速写入设备根本没必要用硬件I2C的DMA加中断那种高级玩法反而给自己增加排查难度。另外一点就是移植性。我经常在不同型号的单片机之间切换软件模拟IIC只需要改两个GPIO的宏定义不需要动协议层代码。硬件I2C则要根据不同的MCU库去调整结构体配置和中断函数麻烦得多。3.2 IIC驱动的代码结构驱动代码我分成了三层第一层是底层IO模拟层定义SCL和SDA引脚对应的GPIO端口和引脚号通过宏来实现高低电平翻转和输入读取。#define OLED_SCL_PORT GPIOC #define OLED_SCL_PIN GPIO_Pin_6 #define OLED_SDA_PORT GPIOC #define OLED_SDA_PIN GPIO_Pin_7 #define OLED_SCL_1() GPIO_SetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SCL_0() GPIO_ResetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SDA_1() GPIO_SetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define OLED_SDA_0() GPIO_ResetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define OLED_SDA_READ() GPIO_ReadInputDataBit(OLED_SDA_PORT, OLED_SDA_PIN)第二层是协议层实现起始、停止、发送一个字节、接收应答等IIC基本操作。static void IIC_Start(void) { OLED_SDA_1(); OLED_SCL_1(); delay_us(5); OLED_SDA_0(); delay_us(5); OLED_SCL_0(); } static void IIC_Stop(void) { OLED_SDA_0(); OLED_SCL_1(); delay_us(5); OLED_SDA_1(); delay_us(5); } static void IIC_SendByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) OLED_SDA_1(); else OLED_SDA_0(); OLED_SCL_1(); delay_us(2); OLED_SCL_0(); dat 1; } }第三层是OLED应用层封装了写命令、写数据、初始化、清屏、显示字符、显示图片等API。应用层的代码不直接操作GPIO而是通过调用第二层的IIC函数来完成与OLED的交互。这种分层带来的好处是显而易见的如果以后要把驱动移植到别的MCU上只需要改第一层的GPIO映射如果以后想用硬件I2C只需要重写第二层保证第三层的API签名不变上层代码完全不用动。3.3 初始化序列的深度解读OLED上电后并不会自动进入可显示状态需要主机发送一系列初始化命令让它开启内部DC-DC电荷泵、设置显示时钟分频比、调整对比度等。很多人直接把网上抄来的初始化数组贴进去也不管是哪颗IC最后显示不正常了也不知道怎么排查。我用的SH1106初始化序列经过反复验证每一段的用处都比较清楚static void OLED_Init_Commands(void) { IIC_Start(); IIC_SendByte(OLED_ADDR_WRITE); IIC_SendByte(0x00); // 控制字节后续全部是命令 IIC_SendByte(0xAE); // 关闭显示 IIC_SendByte(0xD5); // 设置显示时钟分频比 IIC_SendByte(0x80); IIC_SendByte(0xA8); // 设置多路复用比 1/64 IIC_SendByte(0x3F); IIC_SendByte(0xD3); // 设置显示偏移 IIC_SendByte(0x00); IIC_SendByte(0x40); // 设置显示起始行 IIC_SendByte(0x8D); // 电荷泵使能 IIC_SendByte(0x14); IIC_SendByte(0x20); // 设置内存寻址模式为页寻址 IIC_SendByte(0x02); IIC_SendByte(0xA1); // 段重映射列地址127映射到SEG0 IIC_SendByte(0xC0); // 扫描方向正常 IIC_SendByte(0xDA); // COM引脚配置 IIC_SendByte(0x12); IIC_SendByte(0x81); // 对比度设置 IIC_SendByte(0xCF); IIC_SendByte(0xD9); // 预充电周期 IIC_SendByte(0xF1); IIC_SendByte(0xDB); // VCOMH 电平 IIC_SendByte(0x40); IIC_SendByte(0xA6); // 正常显示非反显 IIC_SendByte(0xA4); // 显示全部点亮关闭 IIC_SendByte(0xAF); // 开启显示 IIC_Stop(); }一个容易被忽略的坑0x8D命令设置电荷泵在SH1106上如果设成禁止0x10屏幕不会亮只会显示非常淡的残影。在SSD1306上同样如此。很多屏幕不亮的案例最后追查到这个命令上。对比度0xCF在高亮度环境下可能太亮可以视情况调整这个值不是固定的根据你的供电电压和现场看感光环境来改。3.4 寻址模式与绘图的底层逻辑这块屏幕内置了64KB的GRAM分8页每页有128个字节空间对应屏幕上一整行的8个像素点一列一字节。以页寻址模式为例你写数据的时候写入地址由两个维度决定当前页地址0~7和当前列地址0~127。每写完一个字节列地址自动加1加满128后不自动跳页而是回到当前页的0列。理解这个结构是写驱动的基础因为你的像素操作本质上是在操作一个和屏幕像素一一对应的内存映射区。画一个点时你需要知道这个点落在第几页、该页的第几位BIT0~BIT7对应屏幕上的第0行到第7行然后把这个点的位与对应页的字节数据进行“或”操作最后整页刷新到屏幕上。void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t flag) { uint8_t page, bit_mask; uint8_t old_data; if (x 128 || y 64) return; page y / 8; bit_mask 1 (y % 8); // 读取当前页该列的数据在本地缓存中操作 old_data display_buffer[page][x]; if (flag) { display_buffer[page][x] old_data | bit_mask; } else { display_buffer[page][x] old_data ~bit_mask; } }这里我引入了一个显示缓冲区display_buffer在RAM里维护一份完整的显存拷贝。所有绘图操作先修改缓冲区然后通过一次全量刷新把缓冲区内容写入OLED的GRAM。这个做法的好处是避免频繁的页地址切换和列地址设置减少IIC通信次数同时避免在整屏刷新过程中出现闪烁。如果RAM吃紧也可以选择去掉缓冲区直接写屏幕但那样画图效率会降低不少每次改一个点都要发一遍地址命令性能差距是数量级的。4. 显示功能的实现与扩展4.1 字符与字符串显示的实现字符显示是所有显示功能的基础。得益于显存缓冲区方案字符显示的底层逻辑变成了把字模数组中的每个BIT映射到display_buffer对应的像素位置上。英文字符一般用8x16或者6x12的点阵。我使用8x16规格汉字用16x16规格。字模提取工具把字体转换成C语言数组每个字符占用16个字节8x16或者32个字节16x16。字符显示的代码结构如下void OLED_ShowChar(uint8_t x, uint8_t y, uint8_t ch, uint8_t size) { uint8_t i, j; uint8_t char_temp; if (x 128 - size / 2 || y 64 - size) return; // 根据字体大小选择字模数据来源 for (j 0; j size / 2; j) { if (size 16) { char_temp F8x16_Table[ch - ][j]; } else { char_temp F6x12_Table[ch - ][j]; } for (i 0; i size / 2; i) { if (char_temp (0x80 i)) { OLED_SetPixel(x i, y j, 1); } else { OLED_SetPixel(x i, y j, 0); } } } }当然这只是字模写入缓冲区的那一步缓冲区修改完了还要调用OLED_RefreshBuffer把缓冲区的数据整体发给屏幕。有个细节可以留意如果你想做光标闪烁效果也可以在缓冲区上创建一个低优先级的“光标层”每次刷新前把光标位置对应的数据或操作到缓冲区里。字符串显示则是以空格为结束符循环调用字符显示同时根据size和坐标做换行处理。这里有一个容易踩的坑中英混排时width的计算逻辑。我最终实现的是ASCII字符占half宽度中文按全角计算这样排版才不会乱。4.2 图片显示的实现图片显示的输入是取模软件生成的数据通常是横向取模还是竖向取模需要跟你的绘图代码匹配起来。我用的是BMP图片取模竖直扫描取模方式代码里直接读取const数组并按页写入缓冲区。void OLED_ShowImage(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t *image_data) { uint8_t i, j, k; uint16_t img_index 0; for (j 0; j height / 8; j) { // 页循环 for (i 0; i width; i) { // 列循环 for (k 0; k 8; k) { // 位循环 if (image_data[img_index] (1 k)) { OLED_SetPixel(x i, y j * 8 k, 1); } else { OLED_SetPixel(x i, y j * 8 k, 0); } } img_index; } } }图像数据量比较大一个128x64的图片全屏显示需要1KB的Flash空间。const修饰符在这里非常重要一方面是把数据放在Flash区不从RAM拷贝另一方面是防止代码意外修改数组内容。另外要注意取模时的方向设置如果取模时选择横向取模代码里的位映射方式就得反过来写。4.3 动态数据的实时刷新策略在项目上我遇到这样一个应用场景不断采集传感器的电压值并把数值实时刷新到OLED上显示。避不开的问题是快速刷新时数字重影或者闪烁。这是因为旧数字没有被擦除新数字叠加上去就会出现视觉上的“鬼影”。我采用的方案是在显示新数字之前先用背景色填充该数字所占的区域清除旧内容再绘制新数字。CPU对缓冲区的改写速度快一次性写完再刷新就不会有闪烁了。实现上类似这样void OLED_ClearArea(uint8_t x, uint8_t y, uint8_t width, uint8_t height) { uint8_t i, j; for (j 0; j height; j) { for (i 0; i width; i) { OLED_SetPixel(x i, y j, 0); } } }清屏和显示必须成对使用清屏函数操作缓冲区显示函数也操作缓冲区最后统一调用一次刷新。如果你清了缓冲区又忘了刷新屏幕上的旧内容当然不会消失反过来如果你直接改GRAM而不经过缓冲区则会出现局部数据错乱。这些问题在调试时很容易让人怀疑是IIC通信出了问题实际往往只是缓冲区同步的问题。4.4 显示界面上的菜单与光标导航做到这一步驱动已经可以承载实用的交互界面。我又实现了一个极简的菜单框架菜单项以字符串数组保存在Flash里通过一个全局索引来控制当前光标位置。按键改变索引然后调用菜单重绘函数。重绘时先把整个菜单区域清空再把当前光标光标闪烁显示在对应菜单项前面。实现核心是明白渲染的过程本质是“清空旧画面、绘制新画面、刷新”。屏幕刷新本身也就一二十毫秒的时间如果不要求动画效果不用做什么复杂的重绘优化。5. 实测效果与性能记录代码写完接好线上电实测的显示效果和预期基本一致。这里记录一些数据供参考。静态显示时整屏刷新一次所需时间调用OLED_RefreshBuffer函数发送1KB数据在软件IIC约200kHz时钟下实测耗时在45ms左右。这个速度对普通状态显示绰绰有余。若需要局部刷新只更新变化的区域耗时可以压缩到10ms以内。这个优化在做动态数值显示时尤其有价值。字符显示层面显示一个8x16字符的耗时几乎可以忽略不计瓶颈全在刷新环节。实际测试过程中我用逻辑分析仪抓了IIC波形确认应答位正常数据帧格式正确。看波形还有一个很重要的好处可以检查时序是否满足IIC协议的最小保持时间。虽然软件模拟IIC不像硬件外设那么严格但如果SCL高电平时长过短小于设备手册要求从机可能采样不到SDA上的正确数据。性能数据如下表所示操作内容发送字节数实测耗时备注设置像素单点约8字节约1ms主流耗时在IIC通信整屏图片刷新约1024字节45ms适合静态画面局部区域刷新64x16约128字节6ms适合动态数据更新显示字符串16字符约200字节约10ms含字符映射计算整体看来单色OLED在STM32上的驱动负担非常小即使软件模拟IIC也能轻松跑到人眼感知不到的刷新延迟。如果想进一步提高刷新率有三个方向提高IIC速率把延时缩小到1us、改为硬件I2CDMA直接把缓冲区通过DMA搬运完全不用CPU介入、或者直接改用SPI接口如果屏幕硬件支持。6. 常见问题与排查技巧6.1 屏幕完全不亮这是出现频率最高的问题。先别急着调代码首先用万用表确认供电电压1.3寸OLED一般支持3.3V或5V供电但必须确认模块上有没有稳压芯片。我的模块是3.3V的接到5V上虽然没有烧坏但部分模块可能会损坏。然后是确认SCL/SDA有没有接反这两个接反了的表现往往是完全没反应。再测一下上拉电阻是否正常很多卖家模块是内部不带上拉的。软件方面依次排查设备地址是否写对、控制字节是否有发送、电荷泵是否开启0x8D 0x14。这三个是最容易出错的点。如果你的地址是0x7ASA0接高记得把OLED_ADDR_WRITE改成0x7A我在一些示例代码里见到过有人把地址写死成0x78换了模块就黑屏的情况。注意排查问题时最好把初始化中0xAF开显示提到最前面临时试一下如果开显示后能看到淡淡的影子说明命令通路是好的问题大概率在电荷泵或者对比度设置。6.2 显示错位、乱码、字符重复显示乱码通常是控制字节和数据字节没有严格区分导致的。检查每一帧数据是不是在命令模式下只发命令、在数据模式下只发数据。有人图省事把命令和数据混在同一帧里发这在页寻址模式下非常容易错乱。字符显示乱码还可能是因为字模数据与取模方式不匹配。例如字模是行扫描方式生成从上到下逐行取8个点而代码但是按列扫描去读就会得到完全错位的图形。我建议统一使用列行式扫描每列从上到下取8个点再右移一列和OLED的GRAM结构最吻合。6.3 只显示半屏或者下半屏是黑的这个问题常见原因有两个一是初始化时多路复用比设置错误0xA8命令的参数应该是0x3F64路如果设成0x1F就只有32行显示二是页地址范围设置不对在页寻址模式下如果需要连续写完所有页必须在写下一页前重新发送页地址命令不能指望页地址自动递增。我调试时遇到过一次下半屏完全无显示的情况最后发现是初始化里漏了0xA8这条命令。屏幕默认的上电值是异常值有些芯片是0x1F所以屏幕只亮了上半部分下半部分全黑。这类问题用逻辑分析仪看不出波形问题必须回查命令序列。6.4 屏幕上有残留影像或者关屏后还有影子这个和电荷泵关闭顺序以及对比度设置有关。如果直接发0xAE命令关屏部分屏幕会有短暂残影正常的做法是先发0x8D 0x10关闭电荷泵再发0xAE关闭显示但要注意关闭电荷泵会让屏幕彻底掉电你要确认后续打开时重新使能电荷泵。另外如果调高对比度0x81设置的值关屏后的残影也会更明显。在低功耗应用场景中屏幕睡眠模式比单纯的黑屏更省电可以查阅数据手册中的Sleep Mode指令。6.5 画面整体偏移几个像素如前所述SH1106的GRAM宽度是132像素屏幕显示区域从第0列到第127列多出的4列需要通过段重映射和偏移命令来对齐。如果你设置段重映射为0xA0镜像画面会左右翻转。0xA1是正常的非镜像模式。如果画面右移了4列请检查初始化里是否包含了列偏移修正序列。我实测过一种情况初始化序列里没有设置列地址偏移时SH1106能显示但最右侧会露出一条竖线左侧缺失一条竖线的内容。加上偏移命令后恢复正常。某些模组出厂时已经在硬件上处理了偏移这时候再手动加偏移反而会错乱所以如果问题不是这种表现不要盲目改动。7. 迁移与扩展驱动的可移植性设计驱动的可移植性是我比较看重的一点。拿到一个新的STM32型号或者换用其他牌子的MCU比如GD32、N32等理论上只需要改引脚映射宏定义核心协议层和应用层代码不需要改动。引脚分配方面建议选用两个空闲的GPIO避开JTAG/SWD复用脚、避开I2C专用引脚如果选用硬件I2C外设引脚还需要考虑上拉配置。我用的是PC6和PC7这两个引脚在F103上属于普通IO不与其他功能冲突方便单独操作。代码层面需要特别注意的地方是延时函数的实现。在F103上我用的是简单的循环延时但如果换到更高主频的芯片比如F4系列168MHz原来的循环延时时间会显著缩短IIC时序可能会不合格。最好的做法是改用定时器延时或者SysTick延时确保时序的绝对时间与主频无关。typedef struct { void (*init)(void); void (*display_on)(void); void (*display_off)(void); void (*clear)(void); void (*show_string)(uint8_t x, uint8_t y, const char *str, uint8_t size); void (*show_image)(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *img); void (*show_pixel)(uint8_t x, uint8_t y, uint8_t color); void (*refresh)(void); } OLED_Driver;封装成这样一个驱动结构体之后上层应用代码只需要持有这个结构体指针调用其中的函数指针即可。如果想要接入GUI框架比如LittlevGL/LVGL只需要在底层实现画点函数和刷新函数剩下的交给GUI库处理。有朋友问我为什么不用现成的开源OLED库我的看法是如果你只是调通一个例程直接用开源库确实快但如果你是做项目需要深度定制比如常用的字库裁剪、自定义图标、局部刷新优化自己掌控驱动代码反而更靠谱。所谓“黑盒”只有在一切顺利时才黑出问题时你能做的只有解刨它。8. 一些实实在在的体会驱动这个1.3寸OLED的过程回头来看其实没有多复杂的技术难点真正的收获是让我把IIC协议、显存映射、底层绘图这几块知识串成了一个完整的体系。以前只是会用别人的库知道调用OLED_ShowString能显示字符串但不知道为什么它能显示以及为什么有时候不能显示。实际调试中最有用的工具是逻辑分析仪价格不贵十几块钱的就能用。抓到波形后对照数据手册一条条看基本没有排查不出来的通信问题。比对着代码发呆高效很多。还有一个细节值得说一下如果你发现屏幕在低温环境下显示变淡、响应迟钝不要怀疑驱动代码写错了物理属性的变化。适当提高对比度可以缓解但如果户外环境恶劣建议换用带温度补偿设计的显示方案。这套驱动做出来后我在另一个项目里又把同样的代码迁移到了一块0.96寸的OLED上因为0.96寸的驱动IC也是SSD1306/SH1106兼容方案改动量很小。从此手里有了一个万能驱动框架遇到同类型的屏幕都不慌了。如果你正在折腾类似的东西希望这篇记录能帮你省下一些时间。