STM32F103+HAL库模拟I2C驱动0.96寸OLED(SSD1306)教程

发布时间:2026/9/9 15:43:57
STM32F103+HAL库模拟I2C驱动0.96寸OLED(SSD1306)教程 简介面向嵌入式初学者的STM32F103C8T6开发例程演示如何基于HAL库与模拟I2C驱动0.96英寸OLED显示屏。资源聚焦GPIO引脚模拟I2C时序、OLED初始化序列及字符/图形显示适用于硬件I2C被占用或需灵活调整引脚的场景适合正在学习STM32、I2C通信协议和HAL库的开发者对照实践。压缩包共159个文件约4.83MB包含工程配置.ioc、.uvprojx、HAL库驱动源文件.c/.h、编译输出.o/.axf/.hex、文档说明.md/.htm及调试文件.map/.lst等目录结构清晰便于定位核心代码与工程配置。已有3082人学习下载。通过该例程读者可掌握模拟I2C的软件实现方法理解OLED驱动流程并学会将HAL库底层初始化与实际显示逻辑相结合为后续扩展传感器读取、菜单交互等嵌入式项目打下基础。 第一次把0.96寸OLED接到STM32F103C8T6最小系统板上的时候我以为最麻烦的是屏幕驱动本身结果真正坑人的是I2C总线。网上搜到的HAL库例程清一色硬件I2C代码看起来很正规可一旦遇到总线锁死或者引脚冲突就只能干瞪眼。后来我把驱动全部改成模拟I2C用最朴素的GPIO翻转去拉时序反而什么问题都没有了。这篇文章就是当时从踩坑到跑通的全过程记录核心思路一句话STM32F103C8T6 HAL库 CubeMX用软件模拟I2C驱动0.96寸SSD1306 OLED实现字符、数字、汉字和图片显示。全程不依赖硬件I2C外设引脚任意挑代码可移植性极强。刚入门的朋友跟着走一遍能把I2C协议、显存结构、字模原理一次全搞明白已经点亮过屏幕的老手重点看后面关于缓冲区刷新和时序处理的章节这部分对做动态菜单和动画很有用。1. 方案选型为什么是模拟I2C而不是硬件I2C1.1 硬件I2C容易卡住而且引脚不自由STM32F103的硬件I2C模块口碑一直很微妙。虽然HAL库已经有超时机制比标准库时代好用不少但实际项目里I2C外设一旦进入BUSY状态经常要复位整个外设才能恢复。我遇到过好几次程序跑着跑着I2C就锁死了调试了一天最后发现是时序冲突导致的BUSY标志卡死只能复位芯片。这种问题对新手来说几乎无解。另一个限制是引脚。F103的硬件I2C1只能映射到PB6/PB7或PB8/PB9如果你的板子这两个引脚已经被其他外设占用了就得重新规划整个原理图。而模拟I2C完全绕开这个问题任意GPIO都能当SCL和SDA改代码里的两个宏定义就能换引脚PCB布线立刻自由了。1.2 模拟I2C的真实优势模拟I2C的本质就是用GPIO的高低电平变化去手动实现I2C协议中的每一个时序细节起始条件、停止条件、字节发送、应答位。代码是自己一行行写的每一步在干什么完全透明出问题也容易排查。它的优势可以总结成四点引脚自由任意GPIO都能用不占用固定复用脚。移植性强换成F407、G031甚至Arduino只需改宏定义和延时函数。逻辑可控出了问题可以直接用示波器或逻辑分析仪量波形也可以加打印调试。学习价值高写一遍模拟I2C你对I2C协议的理解会超过看十遍数据手册。代价是占用CPU和速度不如硬件I2C。但实际上0.96寸OLED整屏刷新一次也才几十毫秒模拟I2C跑100kHz标准模式完全够用肉眼看到的刷新效果和硬件I2C几乎没有区别。引脚我习惯选PB6做SCL、PB7做SDA。倒不是模拟I2C需要这两个脚而是万一以后想换成硬件I2C引脚不用重新接线。换成PA5、PA6之类的组合也完全没问题。2. 用CubeMX搭出最小工程2.1 系统时钟先把主频定在72MHz打开CubeMX新建STM32F103C8T6工程后第一步不是配置引脚而是先把时钟树理顺。模拟I2C对系统时钟不敏感OLED屏幕也不挑剔但72MHz主频是后续所有外设的基础建议一开始就调好。我用的配置是外部8MHz晶振进来RCC配成HSEPLL倍频9倍SYSCLK72MHz。如果你的最小系统板上没焊外部晶振也可以把时钟源选成HSI内部8MHz再通过PLL倍频上去同样能跑到72MHz。这个操作去Clock Configuration页面里点几下就完成了不需要手动算CubeMX会自动检查配置是否越界。2.2 GPIO初始化推挽还是开漏这是模拟I2C里最容易被忽略的细节。GPIO输出模式选推挽输出Output Push Pull还是开漏输出Output Open Drain直接影响代码写法和稳定性。配置方案空闲电平ACK读取适用场景推挽输出 无上拉写1即为高电平需要切换输入模式单主单从、短距离开漏输出 内部上拉释放后由电阻拉高开漏下直接读IDR即可I2C标准用法推荐我的建议是直接用开漏输出并打开内部上拉。原因很简单I2C总线协议本身就要求开漏结构所有设备共享一条SDA线谁拉低谁说了算。OLED模块板载一般也有上拉电阻双保险不会出问题。CubeMX里的配置要点就两个PB6和PB7都设为Output Open DrainPull-up上拉开启Speed选High。生成的初始化代码核心部分长这样GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct);这样配置之后代码里写GPIO_PIN_SET实际上是“释放引脚”写GPIO_PIN_RESET是“拉低到GND”。后面读ACK应答位的时候不需要切换引脚模式直接读输入寄存器就能知道从机有没有应答代码会简洁很多。3. SSD1306初始化屏幕从“不亮”到“亮”的关键序列3.1 显存结构与页寻址0.96寸OLED的分辨率是128x64内部SSD1306控制器自带一块1KB的GDDRAM显存正好128列x64行/8位 1024字节。每个字节的8个bit对应一列上的8个像素点最上面一位是这一列的第0行依次往下。SSD1306把64行分成8个页Page每页对应8行像素。写入数据时先指定页地址0xB0~0xB7和列地址0x00~0x7F然后连续写字节就能填充显存。理解了这个结构后面写坐标定位函数就很容易。顺便说一句很多淘宝模块的卖家资料里初始化代码都是直接能用的但新手往往不知道那串看起来像魔法的数字在干什么。其实这些命令无非是在配置屏幕时钟分频、电荷泵开关、对比度、显示起始行、段重映射、COM扫描方向。3.2 初始化命令逐条拆解我整理了一份缩写版初始化序列加了注释每个命令都能一眼看懂void OLED_Init(void) { OLED_WR_Byte(0xAE, OLED_CMD); // 关闭显示此时GDDRAM数据不会显示 OLED_WR_Byte(0xD5, OLED_CMD); // 设置时钟分频因子 OLED_WR_Byte(0x80, OLED_CMD); // 默认分频值 OLED_WR_Byte(0xA8, OLED_CMD); // 设置多路复用比 OLED_WR_Byte(0x3F, OLED_CMD); // 64行输出 OLED_WR_Byte(0xD3, OLED_CMD); // 显示偏移 OLED_WR_Byte(0x00, OLED_CMD); // 偏移量0 OLED_WR_Byte(0x40, OLED_CMD); // 显示起始行0 OLED_WR_Byte(0x8D, OLED_CMD); // 电荷泵设置 OLED_WR_Byte(0x14, OLED_CMD); // 开启电荷泵这是点亮的关键 OLED_WR_Byte(0x20, OLED_CMD); // 内存地址模式 OLED_WR_Byte(0x02, OLED_CMD); // 页寻址模式 OLED_WR_Byte(0xA1, OLED_CMD); // 段重映射列地址127映射到SEG0 OLED_WR_Byte(0xC8, OLED_CMD); // COM扫描方向从上到下 OLED_WR_Byte(0xDA, OLED_CMD); // COM引脚配置 OLED_WR_Byte(0x12, OLED_CMD); // 顺序配置 OLED_WR_Byte(0x81, OLED_CMD); // 对比度设置 OLED_WR_Byte(0xCF, OLED_CMD); // 对比度值 OLED_WR_Byte(0xD9, OLED_CMD); // 预充电周期 OLED_WR_Byte(0xF1, OLED_CMD); OLED_WR_Byte(0xDB, OLED_CMD); // VCOMH电平选择 OLED_WR_Byte(0x40, OLED_CMD); OLED_WR_Byte(0xA4, OLED_CMD); // 恢复显示GDDRAM内容原样输出 OLED_WR_Byte(0xA6, OLED_CMD); // 正常显示非反显 OLED_WR_Byte(0xAF, OLED_CMD); // 打开显示 OLED_Clear(); // 上电显存可能是随机值先清屏 }整个序列里最关键的其实是两处0x8D 0x14是打开内部电荷泵不执行这两条屏幕就是一块白板一点反应都没有0xAF是最后的总开关打开之后屏幕才真正开始显示。很多人的屏幕“通电没反应”排除接线之后最先要查的就是这两处。4. 手写模拟I2C时序与底层发送函数4.1 起始、停止、字节与ACKI2C通信的四个基本动作必须亲手敲一遍起始条件START、停止条件STOP、发送字节SEND BYTE、等待应答ACK/NACK。时序上有条铁律SCL高电平期间SDA电平不能变化。SDA只有在SCL低电平的时候才允许翻转否则会被从机误判为起始或停止条件。起始条件是SCL高电平期间SDA从高变低停止条件正好相反SCL高电平期间SDA从低变高。我用开漏配置实现这四个动作完整代码如下#define OLED_SCL_PORT GPIOB #define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SDA_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_7 #define OLED_SCL_H() HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET) #define OLED_SCL_L() HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET) #define OLED_SDA_H() HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET) #define OLED_SDA_L() HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_RESET) #define OLED_SDA_READ() HAL_GPIO_ReadPin(OLED_SDA_PORT, OLED_SDA_PIN) static void delay_us(uint32_t us) { uint32_t n us * 8; // 72MHz下大约8条空指令≈1us while (n--) { __NOP(); } } static void i2c_start(void) { OLED_SDA_H(); OLED_SCL_H(); delay_us(5); OLED_SDA_L(); // SCL高电平期间SDA跳低起始 delay_us(5); OLED_SCL_L(); } static void i2c_stop(void) { OLED_SDA_L(); OLED_SCL_H(); delay_us(5); OLED_SDA_H(); // SCL高电平期间SDA跳高停止 delay_us(5); } static void i2c_send_byte(uint8_t dat) { for (uint8_t i 0; i 8; i) { if (dat 0x80) OLED_SDA_H(); else OLED_SDA_L(); dat 1; OLED_SCL_H(); delay_us(5); OLED_SCL_L(); delay_us(2); } // 读取ACK释放SDA从机会主动拉低表示应答 OLED_SDA_H(); delay_us(1); OLED_SCL_H(); delay_us(5); // 这里可以读 OLED_SDA_READ() 判断从机是否应答例程里暂不处理 OLED_SCL_L(); }这段代码里的延时参数我故意放宽了SCL高电平持续5us一个bit周期大约10us正好落在标准模式100kHz以内。OLED这种设备根本不挑剔慢宁慢勿快是模拟I2C的调试原则。4.2 底层写入封装的巧妙之处有了起始、停止、发字节三个函数向OLED写命令和写数据就变成了一个非常优雅的小函数。这里有个细节很多人不看数据手册根本不知道OLED的I2C地址之后需要跟一个控制字节0x00表示“后面是命令”0x40表示“后面是数据”。#define OLED_CMD 0x00 #define OLED_DATA 0x40 void OLED_WR_Byte(uint8_t dat, uint8_t mode) { i2c_start(); i2c_send_byte(0x78); // 7位地址0x3C左移1位SA0接GND为0x78 i2c_send_byte(mode); // 控制字节0x00命令 / 0x40数据 i2c_send_byte(dat); // 真正要写入的指令或数据 i2c_stop(); }如果你的模块SA0引脚接的是VCC那么地址要改成0x7A。绝大多数0.96寸模块默认SA0接GND也就是0x78。这也是很多“屏幕毫无反应”问题的根源地址写错了。5. 显示驱动封装字符、数字、汉字与图片5.1 坐标定位从页和列开始要在指定位置显示内容必须先告诉SSD1306从哪个页、哪一列开始写。页地址就是0xB0加上0~7列地址拆成高4位和低4位分别发送void OLED_SetPos(uint8_t x, uint8_t y) // y是页号0~7 { OLED_WR_Byte(0xB0 y, OLED_CMD); OLED_WR_Byte(((x 0xF0) 4) | 0x10, OLED_CMD); OLED_WR_Byte(x 0x0F, OLED_CMD); }这里y是页号而不是像素行。想象成一张A4纸被横向切成了8条每条里是8行像素。写字符时6x8的ASCII字符只占一页8x16的字符就要占相邻两页先写上半页再写下半页这样才不会出现字符上下断裂。5.2 字模从哪里来取模工具的正确设置OLED不能像调用printf那样直接显示汉字所有字符、汉字、图片的本质都是点阵数据。ASCII字符和常用汉字的字模可以用PCtoLCD2002或Image2Lcd生成关键是取模参数要对否则显示出来全是乱码或者镜像。我测试下来最稳的组合是这样的取模方式选“逐列式”纵向取模阴码字节正序。因为SSD1306的显存就是一个字节对应一列的8个像素逐列式取模出来的数据正好可以逐字节直接写入。设置错成“逐行式”显示出来上下颠倒或者左右镜像排查起来特别费时间。以16x16汉字为例每个汉字是16列乘16行需要32字节存储。显示时分成上下两半各占一页每个半字16字节刚好对应16列。网上很多汉字字模库都是按这个格式排的例程已经帮你排好了。5.3 显示字符串、数字和图片显示字符串的核心思想是循环调用单个字符显示函数void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size) { OLED_SetPos(x, y); // 从字模表里取6x8或8x16的数据逐字节写入 // 8x16的字符需要先写上面8行再用SetPos把页号加1写下面8行 } void OLED_ShowStr(uint8_t x, uint8_t y, char *str, uint8_t size) { while (*str) { OLED_ShowChar(x, y, *str, size); x (size 16) ? 8 : 6; if (x 120) { x 0; y; } // 超过一屏宽度自动换行 } }数字显示更简单用sprintf格式化成字符串再走ShowStr就行void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len, uint8_t size) { char buf[12]; sprintf(buf, %0*d, len, num); OLED_ShowStr(x, y, buf, size); }如果你只是临时打印个变量调试程序这个函数直接够用。但如果你要做动态菜单或者动画我强烈建议升级成“本地显存缓冲”的方案。也就是在C8T6的20KB RAM里划出1KB做显存副本所有画点、写字操作都先改这个数组最后用一次批量刷新把整个副本推到OLED。这样能完美避免边修改边显示带来的闪烁问题。uint8_t oled_gram[8][128]; // 页号8列数128正好1KB void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t dot) { if (x 127 || y 63) return; uint8_t page y / 8; uint8_t bit y % 8; if (dot) oled_gram[page][x] | (1 bit); else oled_gram[page][x] ~(1 bit); } void OLED_Refresh(void) { OLED_WR_Byte(0x20, OLED_CMD); OLED_WR_Byte(0x01, OLED_CMD); // 水平寻址模式从第0页连续写完1024字节 OLED_WR_Byte(0xB0, OLED_CMD); OLED_WR_Byte(0x00, OLED_CMD); OLED_WR_Byte(0x10, OLED_CMD); i2c_start(); i2c_send_byte(0x78); i2c_send_byte(0x40); // 数据模式 for (uint16_t i 0; i 1024; i) { i2c_send_byte(oled_gram[i / 128][i % 128]); } i2c_stop(); // 刷新完切回页寻址避免影响后续按坐标写入的接口 OLED_WR_Byte(0x20, OLED_CMD); OLED_WR_Byte(0x02, OLED_CMD); }主函数里的调用方式非常清爽int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); OLED_Init(); OLED_ShowStr(0, 0, Hello STM32, 16); OLED_ShowNum(0, 2, 2024, 4, 16); OLED_ShowStr(0, 4, OLED I2C Demo, 16); OLED_Refresh(); while (1) { } }如果不用缓冲区方案把OLED_Refresh去掉每个Show函数内部直接写OLED就行。两种方式各有各的适用场景调试期随便写正式项目推荐缓冲区。6. 常见问题与排查技巧实录我整理了调试OLED过程中遇到频率最高的几类问题做成速查表现象可能原因排查办法屏幕完全不亮、无任何反应电源没接好或模块损坏量模块VCC和GND电压白屏但屏幕供电正常I2C地址错误核对0x78还是0x7A白屏但代码看起来正常SCL/SDA接反两根线互换试试杂牌模块丝印可能印反屏幕亮但全是雪花/乱码初始化时序不对或字模取模方向错重新核对初始化序列和取模参数显示内容闪烁清屏全刷太频繁改用缓冲区增量刷新汉字左右颠倒/上下错位取模方式不对换成逐列式、纵向取模除了表格还有三个我实测出来的独家经验。第一OLED模块的VCC和GND最好接3.3V而不是5V。虽然很多模块板载了稳压芯片接5V也能工作但注意I2C逻辑电平会参考模块VCC你MCU是3.3V电平模块跑5V逻辑可能识别异常。长期用下来还是3.3V最稳妥。第二屏幕通电之后用手背轻轻摸一下背面如果微微发热说明电荷泵在正常工作。这是一个非常快的硬件自检方法。如果完全不热十有八九是0x8D 0x14这两条命令没生效或者模块本身已经烧了。第三模拟I2C的延时宁多勿少。我见过有人贪快把延时砍到1us结果屏幕偶尔显示错乱查了半天是线材过长加上拉不够造成的边沿过缓。把SCL周期保持在10us以上用杜邦线连接15cm以内基本不会出问题。如果你第一次调试始终点不亮建议按这个顺序排查先量供电再交换SDA/SCL试一次然后检查地址是0x78还是0x7A最后确认初始化序列里确实执行了0x8D 0x14和0xAF。这一套走完绝大多数问题都能解决。最后再分享一个我现在还在用的习惯凡是新项目上手第一件事就是在OLED上显示“Hello”和系统时间把它当成嵌入式世界的printf。模拟I2C这套代码从STM32F103移植到F407只花了十分钟改引脚宏定义和延时函数之后直接跑通。建议你把这份驱动代码留好之后做温湿度计、电子钟、菜单界面它都能当那款“万能调试面板”。本文还有配套的精品资源点击获取

相关新闻