
简介这是一套面向STM32标准库开发者的OLED显示驱动代码适用于需要快速实现I2C或SPI接口屏显的嵌入式项目。资料基于标准外设库编写覆盖GPIO、I2C/SPI初始化命令发送字模提取与显示刷新等关键环节可直接移植到常见STM32F1系列工程。压缩包共234个文件以C源文件、头文件、编译中间文件、Hex烧录文件及工程配置文件为主另有映射表与列表文件便于检查链接和符号分配总体积5.64MB目录结构清晰可用于快速检索。目前已吸引181人学习下载。对于正在学习STM32显示控制或调试OLED时序的开发者这套代码既提供了可直接运行的标准库示例也通过模块化结构清晰分离驱动层与应用层有助于深入理解底层寄存器配置、显示缓冲区管理和刷新策略与低功耗唤醒机制可显著缩短项目开发周期也为二次开发提供清晰参考。1. 写在前面为什么我还在用标准库写OLED如果你刚接触STM32大概率会听到两种声音一种说标准库已经过时了HAL库才是未来另一种说标准库逻辑清晰、读代码就像读课本特别适合学习底层原理。我属于后者并且直到现在很多量产的工程我都还在用标准库。原因很简单标准库的代码是“看得见摸得着”的每个寄存器操作都摆在明面上出了问题你能顺着库函数一路查到寄存器级排查起来心里非常有底。这篇博文要聊的是基于STM32F103C8T6使用标准库StdPeriph_Lib V3.5驱动0.96寸I2C接口的OLED显示屏实现字符、汉字、图片的显示并把整套代码结构整理成可直接复用的模板。OLED这种外设在嵌入式项目里太常用了——做温湿度计、智能台灯、宿舍打卡器、心率血氧仪几乎都需要一块屏来显示数据。而且I2C接口的OLED只需要4根线VCC、GND、SCL、SDA硬件连接极其简单软件上只要把底层I2C时序写好上层想显示什么都是“填空”的事。如果你正在学STM32标准库或者手里有一个OLED模块但不知道怎么驱动起来这篇文章会给你一套开箱即用的方案。我尽量把每一段代码的“为什么这么做”讲清楚而不是丢给你一堆能跑但看不懂的代码。因为在实际项目里你大概率会改显示内容、换引脚、适配不同分辨率的屏只有真正理解了驱动原理改起来才不慌。2. 整体设计思路标准库、I2C与OLED是怎么配合的2.1 方案选型为什么选软件I2C而不是硬件I2C市面上的0.96寸OLED绝大多数是SSD1306主控支持I2C、SPI、并行三种接口方式。我推荐用I2C接口而且是“软件模拟I2C”不是STM32自带的硬件I2C外设。可能有朋友会质疑芯片明明有硬件I2C为什么不用这里有一个很现实的坑STM32F1系列的硬件I2C在工程实践中经常出现异常——比如总线忙标志无法清除、通信偶尔卡死尤其在不同批次芯片上表现还不一致网上戏称其为“硬件I2C玄学问题”。虽然经过合理配置和错误处理也能稳定使用但对初学者来说排查这些问题的成本远高于收益。软件模拟I2C就不存在这些隐患你只需要两个GPIO引脚按I2C协议手动拉高拉低时钟线和数据线时序完全由自己控制怎么都不会卡死。而且软件I2C的代码可以无缝移植到任何单片机平台——我后来把同一套OLED驱动代码移植到STM32F407、甚至国产GD32上基本就是改一下引脚定义的事省心得很。2.2 SSD1306驱动的核心知识你想要显示得先跟它“对话”SSD1306是一个专门驱动OLED屏的芯片内部有一块GRAM图形显存大小为128×64位也就是128列、64行每一位对应屏幕上的一个像素点写1点亮写0熄灭。你没法直接往屏幕上画点要做的是把数据写进SSD1306内部的GRAM里SSD1306会自动把GRAM的内容刷新到屏幕上。SSD1306的显存被分成8页Page0~Page7每页8个像素高。屏幕的128列对应显存的128个字节列地址而每一列的数据低3位A0~A2在控制字节中指定页地址。所以一次I2C写操作的基本单位是“先发控制字节再发数据字节”。控制字节有两种0x00表示后续字节是命令0x40表示后续字节是数据。这个区分是整个驱动代码的命脉初始化、清屏、显示图片全靠它。I2C通信的物理层也有讲究SCL和SDA都需要上拉电阻。大多数OLED模块板上已经自带了4.7kΩ上拉电阻所以用杜邦线连接STM32时不需要额外接上拉。但如果你用的是自己画的板子切记加上上拉电阻否则通信会不稳定出现花屏或完全无显示。3. 硬件连接与基础工程配置3.1 引脚分配怎么接为什么这么接我习惯用PB8、PB9作为软件I2C的SCL和SDA引脚原因很朴素这两个引脚在最小系统板上引出来位置顺手而且不占I2C1/I2C2的硬件外设引脚完全靠GPIO模拟引脚分配自由度非常高。你也可以用到PC13、PA2之类的任意普通GPIO只要代码里改一下宏定义即可。OLED模块与STM32的接线表如下OLED模块引脚STM32引脚说明VCC3.3V供电注意不要接5V经典烧屏原因GNDGND共地SCLPB8时钟线SDAPB9数据线特别注意OLED屏的工作电压是3.3V虽然很多模块标注支持3.3~5V但5V供电时间一长屏体容易发烫亮度也会漂移。尤其是0.96寸的小屏我建议一律接3.3V。还有极少数劣质模块的稳压电路设计有问题5V直接进主控屏上会出现永久性亮斑这种损坏是不可逆的。3.2 标准库新建工程KEIL5环境下的最小配置如果你还没有ST标准库的工程模板可以按下面几步快速搭一个。这里默认你用的是Keil MDK5目标芯片是STM32F103C8T6库版本是标准的V3.5。# 第一步下载标准外设库 # 老版本标准库可以去ST官网找“STM32F10x Standard Peripheral Library” # 也可以直接搜“STM32F10x_StdPeriph_Lib_V3.5.0”工程里必须要有的文件包括启动文件startup_stm32f10x_md.sF103C8T6属于中容量产品选md版本内核相关core_cm3.c、core_cm3.h标准库外设驱动stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_usart.c等用到哪个加哪个系统时钟配置system_stm32f10x.c新建工程后记得在C/C选项卡的Define框里填上“STM32F10X_MD”和“USE_STDPERIPH_DEVICE”这两个宏是库函数能否正常编译的关键。填错或漏填编译时会蹦出一大堆找不到结构体定义的错误看起来非常吓人实际上就是宏没定义。工程配置好之后下一步就到最重要的事写代码让“点”亮起来。OLED驱动核心说破天就是三件事——初始化、点亮像素、把像素拼成字形。4. OLED驱动代码实现从底层时序到上层显示4.1 软件I2C的底层时序GPIO怎么模拟出波形既然是用GPIO模拟I2C那I2C协议最基本的四个时序就要自己写起始信号、停止信号、发送字节、接收应答。我直接把代码贴出来这一段是整个驱动的地基。#define OLED_SCL_PIN GPIO_Pin_8 #define OLED_SCL_PORT GPIOB #define OLED_SDA_PIN GPIO_Pin_9 #define OLED_SDA_PORT GPIOB #define OLED_SCL_H() GPIO_SetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SCL_L() GPIO_ResetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SDA_H() GPIO_SetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define OLED_SDA_L() GPIO_ResetBits(OLED_SDA_PORT, OLED_SDA_PIN) // 起始信号SCL高电平期间SDA从高拉低 void OLED_I2C_Start(void) { OLED_SDA_H(); OLED_SCL_H(); OLED_SDA_L(); OLED_SCL_L(); } // 停止信号SCL高电平期间SDA从低拉高 void OLED_I2C_Stop(void) { OLED_SDA_L(); OLED_SCL_H(); OLED_SDA_H(); } // 发送一个字节高位先出 void OLED_I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) OLED_SDA_H(); else OLED_SDA_L(); data 1; OLED_SCL_H(); OLED_SCL_L(); } // 第9个时钟释放SDA由从机拉低应答 OLED_SDA_H(); OLED_SCL_H(); OLED_SCL_L(); }这段代码的逻辑不难但有两个细节值得说。第一为什么发送完字节后要“释放SDA”因为I2C协议规定从机接收完一个字节后会在第9个时钟周期把SDA拉低作为应答信号。如果你在写字节后马上开始下一个操作没有留出应答时钟可能会造成总线状态错乱。很多同学直接把应答检测省了也能跑但严谨起见至少要把第9个时钟走完让从机有应答的机会。第二引脚模式要配成开漏输出还是推挽输出I2C规范上要求开漏但OLED模块上已经有上拉电阻了所以用推挽输出也没问题而且推挽输出在高电平时的驱动能力更强波形质量更好。我在实际工程里直接用的推挽复用开漏初始化代码里用GPIO_Mode_Out_PP跑得很稳。4.2 SSD1306初始化序列为什么是这一串“魔法数字”刚接触OLED的时候很多人对初始化序列嗤之以鼻不就是一堆看不懂的十六进制数吗抄就是了。这么想也能用但一旦屏幕显示异常你就只能干瞪眼。我挑了初始化阶段几个关键的指令来解释一下这样你以后看到别的型号OLED屏也能举一反三。void OLED_Init(void) { // 1. 关闭显示 OLED_WriteCmd(0xAE); // 2. 设置显示时钟分频因子/振荡器频率 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 3. 设置 multiplex ratio 63 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 4. 设置显示偏移为0 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 5. 设置显示起始行 OLED_WriteCmd(0x40); // 6. 设置电荷泵使能 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 7. 设置内存地址模式为页地址模式 OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); // 8. 设置列地址范围 OLED_WriteCmd(0xA1); // 9. 设置行扫描方向 OLED_WriteCmd(0xC8); // 10. 设置对比度 OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); // 11. 设置预充电周期 OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 12. 设置VCOMH deselect level OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // 13. 设置显示模式为正常 OLED_WriteCmd(0xA6); // 14. 清屏 OLED_Clear(); // 15. 开启显示 OLED_WriteCmd(0xAF); }这个序列里最核心的是三条指令。第一条0x8D配合0x14开启内部电荷泵。OLED屏的工作电压比3.3V高模块上有一个电荷泵电路负责升压如果你不开启它屏幕就是全黑的一点反应都没有。这是“上电后屏幕不亮”最常见的软件原因。第二条0x20配合0x02设置内存地址模式为页地址模式。SSD1306支持页地址、水平地址、垂直地址三种模式我们选用页地址模式原因是它最直观——每写一次数据列地址自动加1从头到尾一行行往下灌数据非常符合“把显存当作数组”的编程思维。第三条0xA1和0xC8设置列/行扫描方向。这两条的效果是让屏幕的第0列对应屏体实际的某一侧如果显示内容是镜像翻转的多半就是这两条指令设置反了改回0xA0和0xC0就能恢复正常。4.3 把像素点亮写命令、写数据、坐标变换初始化之后就是OLED的三个基本操作写命令、写数据、设置坐标。写命令和写数据的区别说穿了就是控制字节不同。命令是0x00数据是0x40。I2C从机地址在0.96寸模块上默认是0x78这里0x78是7位地址0x3C左移一位得到的8位地址如果你的模块背面有电阻可以改地址那写命令时的首字节地址也要跟着改。#define OLED_ADDR 0x78 void OLED_WriteCmd(uint8_t cmd) { OLED_I2C_Start(); OLED_I2C_SendByte(OLED_ADDR); // 从机地址 写标志 OLED_I2C_SendByte(0x00); // 表示后续是命令 OLED_I2C_SendByte(cmd); OLED_I2C_Stop(); } void OLED_WriteData(uint8_t data) { OLED_I2C_Start(); OLED_I2C_SendByte(OLED_ADDR); OLED_I2C_SendByte(0x40); // 表示后续是数据 OLED_I2C_SendByte(data); OLED_I2C_Stop(); }设置坐标其实就是告诉SSD1306接下来要写的显存数据从哪一页、哪一列开始。需要先选页0xB0到0xB7对应第0页到第7页再选列地址高低字节void OLED_SetPos(uint8_t page, uint8_t col) { OLED_WriteCmd(0xB0 page); // 页地址 OLED_WriteCmd(((col 0xF0) 4) | 0x10); // 列地址高4位 OLED_WriteCmd(col 0x0F); // 列地址低4位 }这里有个小坑SSD1306的列地址分开两个命令来设置先高4位后低4位而且高4位命令需要或上0x10作为特征标记范围是0x10~0x1F低4位命令则是0x00~0x0F。如果你漏掉或忘记或运算坐标就会错乱显示出来的字形会分裂成奇怪的碎片。4.4 显示字符和汉字字模是核心资产OLED点阵的显示原理是用8×16或者16×16的位图数组来表示一个字符或汉字。所谓“字模”就是把字形的每个像素抽象成二进制位——1亮、0灭按字节排列后存入数组。以显示ASCII字符为例我用的是8×16字模一个字符占16个字节分上下两半。上半部8个字节对应字符上8行的像素下半部8个字节对应下8行的像素。显示的时候先设置坐标到某个页和列然后把上半部数据写进去再把坐标切到下一页写入下半部数据这样就能在屏幕上拼出一个完整的字符。void OLED_ShowChar(uint8_t page, uint8_t col, char c) { uint8_t i; OLED_SetPos(page, col); for (i 0; i 8; i) OLED_WriteData(F8X16[(c - ) * 16 i]); OLED_SetPos(page 1, col); for (i 0; i 8; i) OLED_WriteData(F8X16[(c - ) * 16 8 i]); }F8X16是一个const数组里面存了从空格开始的ASCII字符的字模。代码里(c - )是为了把字符转换为字模数组的索引偏移量因为空格是ASCII码32数组索引从0开始。汉字显示则麻烦一些。一个汉字在16×16点阵下需要32个字节显示时要分左右两半或者按左上、右上、左下、右下四个区块写入。以“我”字为例取模软件生成的字模是16行数据每行两个字节高字节在左、低字节在右。显示时先用OLED_SetPos设置到page, col连续写16个字节这是左半部分再设置到page, col8的位置再写16个字节这是右半部分。void OLED_ShowChinese(uint8_t page, uint8_t col, const uint8_t *c) { uint8_t i; OLED_SetPos(page, col); for (i 0; i 16; i) OLED_WriteData(c[i]); OLED_SetPos(page 1, col); for (i 0; i 16; i) OLED_WriteData(c[i 16]); }注意这个函数一次只能显示一个汉字。要显示字符串就需要在调用层遍历字符串数组每显示完一个字符或汉字后让列坐标往前移动8或16个像素。OLED屏幕宽128像素一屏最多显示16个ASCII字符8×16字体或8个16×16的汉字显示前先算好排版不然很容易出现越界写入把前面的内容冲掉。4.5 显示图片整屏刷新其实最简单如果你手头有一张128×64分辨率的单色位图那显示起来反而最简单——因为图片不需要逐字定位直接把1024个字节全部灌进显存就行。前提是图片数据要经过取模默认高位在前第一字节对应左上角。void OLED_DrawBMP(const uint8_t *bmp) { uint8_t page, col; for (page 0; page 8; page) { OLED_SetPos(page, 0); for (col 0; col 128; col) OLED_WriteData(bmp[page * 128 col]); } }图片数据的取模方式和字模一样用取模软件比如PCtoLCD2002生成C语言数组就行。5. 在标准库工程中集成代码模块化写法让复用变得简单5.1 文件组织与函数接口设计我不建议把所有代码塞在main.c里哪怕项目很小。一旦后续加按键、传感器、通信协议main.c会膨胀到几千行维护起来难受。我习惯把OLED驱动拆成两个文件oled.c和oled.h。oled.h里只暴露必要的接口#ifndef __OLED_H #define __OLED_H #include stm32f10x.h void OLED_Init(void); void OLED_Clear(void); void OLED_ShowChar(uint8_t page, uint8_t col, char c); void OLED_ShowString(uint8_t page, uint8_t col, char *str); void OLED_ShowChinese(uint8_t page, uint8_t col, const uint8_t *c); void OLED_ShowNum(uint8_t page, uint8_t col, uint32_t num, uint8_t len); void OLED_DrawBMP(const uint8_t *bmp); #endifmain.c里面就是纯粹的调用逻辑。比如显示温湿度OLED_ShowString(0, 0, Temp:); OLED_ShowNum(0, 48, temperature, 4); OLED_ShowString(2, 0, Humi:); OLED_ShowNum(2, 48, humidity, 4);这样写的好处是上层逻辑完全不用关心OLED底层怎么写以后想换SPI接口的屏幕只要替换驱动文件、保持接口不变上层的显示逻辑一行都不用改。5.2 main.c初始化的完整流程标准库工程中进入main函数后第一件事要配置系统时钟——虽然启动文件里已经调用了SystemInit但有些时候还需要手动确认时钟源是否正确。然后是GPIO初始化、OLED初始化int main(void) { GPIO_InitTypeDef GPIO_InitStructure; // 1. 开启GPIOB时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 2. 配置PB8、PB9为推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_8 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 3. 初始化OLED OLED_Init(); // 4. 清屏并显示内容 OLED_Clear(); OLED_ShowString(0, 0, Hello STM32!); while (1) { // 主循环 } }有一个很容易被忽略的坑OLED_Clear()在初始化序列里已经被调用过一次但如果你在调用OLED_Init之后、显示之前不多调用一次OLED_Clear()屏幕上偶尔会出现上电残留的噪点。原因不是清屏没生效而是初始化过程中SSD1306内部RAM的默认值不是全0有一小部分位随机置1。所以稳妥起见初始化后立刻清一次屏。6. 常见问题与排查技巧实录6.1 屏幕完全不亮硬件和软件双排查这是最让人头大的问题。我的排查顺序是固定的第一量供电。用万用表量OLED模块的VCC和GND引脚之间电压必须稳定在3.0~3.6V。如果电压正常再看模块上的亮点OLED上电后即使没有软件初始化屏幕一般也会有微弱变化最典型的是屏幕上半部有一点点偏暗但不是死黑。如果完全死黑先怀疑模块本身。第二确认I2C地址。0.96寸OLED的从机地址常见有两种0x78和0x7A。如果代码里是0x78但实际模块是0x7A那所有命令都发不进去。判断方法也很简单写命令时在OLED_I2C_SendByte(OLED_ADDR)后面加一个IO翻转用示波器看SDA线上有没有ACK低电平。没有示波器的话就把两个地址都试一遍哪个能显示就用哪个。第三确认初始化序列顺序。SSD1306的初始化命令必须从关闭显示0xAE开始以开启显示0xAF结束中间命令顺序最好不要调整。特别要注意电荷泵命令0x8D/0x14如果这两条没写在最前面屏幕大概率不亮。6.2 显示漂移、花屏、字体重影出现这种情况先不要怀疑屏坏了八成是坐标计算出了问题。最常见的原因是OLED_SetPos时列地址高4位和低4位搞混了导致每页数据写到了错误列上。其次如果你用的字模是16×16汉字的但显示时却按8×16字符的列宽去递增那第二个字就会和第一个字叠在一起。做一个简单的验证调用OLED_ShowChinese(0, 0, 汉字)只显示一个字如果这个字本身都花屏说明字模数组和取模方式不匹配如果单个字正常、第二个字花屏那基本就是坐标递增的步长不对。还有一种特殊的花屏是硬件I2C模式下总线速率不匹配导致的软件I2C一般不会遇到。但如果你把软件I2C的延时去掉太多时钟频率跑到了几百kHz以上个别模块的I2C时序跟不上也会出现偶发花屏。我的经验是软件I2C的每个时钟周期保持在4~10微秒比较稳妥太快反而容易出问题。6.3 OLED和中断的冲突共享引脚和原子操作如果你的项目里启用了定时器中断、外部中断而中断回调函数里也碰巧操作了OLED的I2C引脚那就要小心了软件I2C在传输过程中是连续操作几个引脚的如果中途被中断打断另一个中断服务函数又把SDA或SCL的电平改了等中断返回时I2C时序已经乱了后续所有显示都会错乱。我的建议是 OLED显示函数不要在中断服务函数里调用。如果非要实时刷新数据可以在中断里只置标志位主循环检测到标志位后再刷新显示。这个方法看起来平平无奇但能省掉你排查“显示偶尔乱码”的大量时间。6.4 通用问题速查表现象可能原因解决方案屏幕完全不亮供电异常/电荷泵未开启检查3.3V供电确认初始化序列含0x8D 0x14屏幕亮但全黑I2C地址不对尝试0x78和0x7A显示内容镜像扫描方向设置问题修改0xA1/0xA0或0xC8/0xC0字体偏高或偏低页地址设置错误检查OLED_SetPos的页参数是否在0~7范围内显示内容抖动闪烁主循环反复整屏刷新改成按页刷新或加入刷新间隔内容局部残留清屏不彻底显示前调用OLED_Clear或只清除目标区域7. 我的几点实操心得这套OLED标准库驱动代码我在十来个项目里用过——从最简单的温湿度显示到带菜单界面的智能鱼缸控制器再到和ESP8266配合的宿舍灯光控制系统基本没在显示层面出过大问题。如果要总结几条实际工程中的经验我会说先用最小代码让屏幕亮起来再谈花活。哪怕只是显示一个点也能验证硬件连接、I2C地址、初始化序列三个最关键环节。字模文件尽量独立成数组放到一个专门的.c文件里不要和主驱动代码混在一起。中文字库文件动辄几千字节放到驱动文件里会严重影响阅读体验。OLED刷新率并不高软件I2C跑起来整屏刷新差不多要上百毫秒。如果你要显示动态变化的数据比如ADC采样的波形不要整屏刷只刷新变化的区域会流畅很多。如果你准备量产或者项目规模变大最后还是要评估一下硬件I2C或者SPI接口的OLED——SPI屏幕刷新速度和稳定性都远优于I2C但接线多两根。鱼和熊掌不可兼得。最后再说一个很多人都会踩的小坑标准库V3.5的GPIO初始化结构体里GPIO_Speed最好配置成50MHz而不是2MHz或10MHz。如果配的太低I2C的上升沿会变缓偶尔会出现第一个字节发不出去的情况。这个坑极其隐蔽我当年排查了整整一个下午最后拿示波器比对波形才发现是速度等级的问题。你如果也遇到了“代码完全一样但屏幕偶尔亮偶尔不亮”可以先看看这个配置。本文还有配套的精品资源点击获取