news 2026/9/9 15:43:48

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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而不是硬件I2C

1.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配成HSE,PLL倍频9倍,SYSCLK=72MHz。如果你的最小系统板上没焊外部晶振,也可以把时钟源选成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 Drain,Pull-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 起始、停止、字节与ACK

I2C通信的四个基本动作必须亲手敲一遍:起始条件(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只花了十分钟,改引脚宏定义和延时函数之后直接跑通。建议你把这份驱动代码留好,之后做温湿度计、电子钟、菜单界面,它都能当那款“万能调试面板”。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 15:43:22

纯前端实现网页扫一扫:HTML+JS条形码二维码识别方案与实战

简介&#xff1a;这是一份基于 HTML5 与 JavaScript 的条形码和二维码扫描插件资源包&#xff0c;面向需要为网页快速接入摄像头扫码能力的前端开发者&#xff0c;解决浏览器端实时识别条码、解析二维码信息并与业务系统交互的问题。资源包共 88 个文件&#xff0c;约 9.27MB&a…

作者头像 李华
网站建设 2026/9/9 15:42:14

magnitude是什么?本地AI Agent的嵌入式推理服务内核

1. “magnitude”不是命令行工具&#xff0c;而是本地AI推理服务的底层度量引擎 你最近在GitHub、Hugging Face或各类Agent开发群聊里反复看到“magnitude”这个词&#xff0c;它常和 codex cli 、 trae cli 、 hermes agent 、 pi agent 混在一起出现&#xff0c;甚至…

作者头像 李华
网站建设 2026/9/9 15:41:45

AI元人文:跨文化共生与新契约下的人机协作之道

这几年&#xff0c;我经常在跨文化协作项目里观察到一个现象&#xff1a;同一个AI工具&#xff0c;在不同文化背景的同事手里&#xff0c;使用方式和心理预期截然不同。有人把它当成生产力倍增器&#xff0c;有人把它当作一个需要谨慎对待的共事者&#xff0c;还有人干脆拒绝在…

作者头像 李华
网站建设 2026/9/9 15:41:17

基于STM32与LabVIEW的海水盐度检测系统设计与实现

简介&#xff1a;面向单片机开发者和海洋监测方向学生的这套基于STM32的海水盐度检测系统&#xff0c;包含下位机嵌入式代码与LabVIEW上位机软件&#xff0c;提供从采集、显示到无线传输的完整链路。系统采用STM32F1作为主控&#xff0c;运行uCOSII操作系统&#xff0c;配合OLE…

作者头像 李华
网站建设 2026/9/9 15:39:45

Backbone.js轻量级前端框架深度解析:事件机制与云控制台实战

开头想让一个用了三年 React 的人回头去写 Backbone.js&#xff0c;他第一反应肯定是抗拒的。但如果你跟我一样做过云平台控制台、运维管理系统这类前端项目&#xff0c;就会明白一个扎心的现实&#xff1a;这类项目的页面不一定多炫&#xff0c;但要求加载得快、逻辑直接、老浏…

作者头像 李华
网站建设 2026/9/9 15:37:26

AE文字弹性入场动画:关键帧、速度曲线与表达式全解析

做短视频片头、字幕条、个人作品集开场时&#xff0c;很多人都想让标题文字“弹”出来。这个效果看起来高级&#xff0c;但大多数人第一次做的时候&#xff0c;得到的却是另一种结果&#xff1a;文字要么直愣愣地砸进画面&#xff0c;要么像果冻一样抖了半天停不下来。造成这种…

作者头像 李华