简介:面向嵌入式初学者的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只花了十分钟,改引脚宏定义和延时函数之后直接跑通。建议你把这份驱动代码留好,之后做温湿度计、电子钟、菜单界面,它都能当那款“万能调试面板”。
本文还有配套的精品资源,点击获取