STM32F103 OLED驱动:IIC协议、SSD1306初始化与波形显示

STM32F103 OLED驱动:IIC协议、SSD1306初始化与波形显示 简介这份资源是为STM32F103开发者准备的OLED显示功能代码包面向嵌入式入门及中级学习者解决OLED屏幕驱动与基础图形、波形绘制的快速上手问题。资源包内共2个文件分别为oled.c源码与oled.h头文件代码结构简洁可直接移植到基于HAL或LL库的工程中使用。压缩包大小仅5KB轻量易读适合作为学习SPI/I2C接口驱动、坐标映射及像素绘制的参考资料。目前已有815人学习凭借清晰的函数分层与波形显示示例开发者可快速实现文本显示、直线绘制及ADC数据波形可视化。代码预留了功能扩展空间便于结合具体OLED分辨率与刷新率进行调优是理解嵌入式图形界面和实时数据展示的实用入门素材。1. 看到“STM32F103 OLED 显示函数”这个包先想清楚驱动代码为什么不是复制粘贴完事一个带 OLED 显示函数的 STM32F103 工程包解压后一般就是OLED.c、OLED.h、字模数组和一张连线图。有人直接拖进自己工程结果不是白屏就是程序跑飞。问题多半不在文件本身而在你复制的“OLED 连线函数”和你板子的引脚、时钟、GPIO 模式不匹配。OLED 驱动本质是两件事用 IIC 把字节送到 SSD1306 控制器再把像素按页写入显存。接线错了是白屏初始化序列错了是花屏命令字和数据字搞混是乱码刷新策略不对就是“波形像慢镜头”。这篇沿着 IIC 连线、SSD1306 初始化、波形显示这条线展开把接线和参数讲细让“显示函数”真正变成你能改、能查、能迁移的代码。2. OLED 连线函数背后IIC 通信协议 OLED 的接线、上拉与电平时序2.1 IIC 通信协议 OLED 的引脚定义SCL/SDA 该接哪、上拉为什么省不了市面上常见的 0.96 寸 OLED 模块IIC 模式引出四根线VCC、GND、SCL、SDA。这里的 OLED 通信协议 IIC 只有两根信号线时钟线 SCL 由主机STM32F103产生数据线 SDA 双向传输。SSD1306 的 IIC 地址默认是 0x78写地址读地址 0x79但实际驱动里我们只写不读。接线表如下OLED 引脚STM32F103 引脚说明VCC3.3V部分模块标 5V长期使用建议仍接 3.3VGNDGND共地必须接否则时序参考电平不一致SCLPB6 / 任意推挽或开漏 IO建议选带 5V 容忍且不占用调试口的引脚SDAPB7与 SCL 同组方便代码统一操作注意 F103 最小系统板的 3.3V 通常由 AMS1117 从 5V 转出OLED 全屏点亮时电流约 15–25mA对板载 LDO 来说压力不大但如果你同时驱动多个外设建议用万用表量一下 VCC 对地电压是否掉到 3.0V 以下。模块上一般已经焊了 4.7kΩ 上拉电阻到 VCC所以 SCL 和 SDA 不需要再外接上拉。若你用的是裸屏或自己画的转接板必须在两根线上各加一个 4.7kΩ 上拉。这是因为 IIC 协议要求引脚在空闲时为高电平开漏输出配合上拉才能正确产生高电平。2.2 模拟 IIC 的连线函数实现起始信号、结束信号与字节电平STM32F103 有硬件 IIC 外设但硬件 IIC 在标准外设库时代调试比较麻烦工程里最常见的是 GPIO 模拟 IIC。模拟的好处是引脚随便换时序亲眼可见而且没有硬件 IIC 的忙标志卡死问题。用 PB6/PB7 模拟代码量很小#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_High() OLED_SCL_PORT-BSRR OLED_SCL_PIN #define OLED_SCL_Low() OLED_SCL_PORT-BRR OLED_SCL_PIN #define OLED_SDA_High() OLED_SDA_PORT-BSRR OLED_SDA_PIN #define OLED_SDA_Low() OLED_SDA_PORT-BRR OLED_SDA_PIN void OLED_IIC_Delay(void) { uint8_t i 4; while (i--); } void OLED_IIC_Start(void) { OLED_SDA_High(); OLED_SCL_High(); OLED_IIC_Delay(); OLED_SDA_Low(); // SCL 高电平期间 SDA 拉低产生起始信号 OLED_IIC_Delay(); OLED_SCL_Low(); // 拉低 SCL为发送数据做准备 } void OLED_IIC_Stop(void) { OLED_SDA_Low(); OLED_SCL_High(); OLED_IIC_Delay(); OLED_SDA_High(); // SCL 高电平期间 SDA 拉高产生停止信号 } void OLED_IIC_SendByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) OLED_SDA_High(); else OLED_SDA_Low(); dat 1; OLED_IIC_Delay(); OLED_SCL_High(); // SCL 高电平期间数据必须稳定 OLED_IIC_Delay(); OLED_SCL_Low(); OLED_IIC_Delay(); } }这段代码里最关键的是时序起始信号要求 SCL 在高电平时把 SDA 拉低字节发送要求 SCL 上升沿之前把 SDA 电平摆好数据在 SCL 高电平期间保持不变。OLED_IIC_Delay是软件延时F103 主频 72MHz 时循环 4 次大约产生 1–2μs 的延时正好把 IIC 时钟压到 400kHz 以内。写字节时不需要检测 ACK。SSD1306 在收到每个字节后会拉低 SDA 作为应答但很多 OLED 模块的应答引脚没有真正接出来你如果写代码去等 ACK 超时程序就会卡死在 IIC 发送函数里。这也是“显示函数加进工程就卡死”的一个隐藏原因工程包里现成的 IIC 发送函数一般不会带 ACK 等待别自己加上。2.3 stm32f103 上 HAL 库与标准库的 GPIO 初始化差异标准外设库 v3.5 和 HAL 库的 GPIO 初始化写法不同但开漏输出、上拉、50MHz 速度这三项要一致。用标准库写GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);如果工程是用 STM32CubeMX 生成的 HAL 库工程则对应写成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);模式必须选开漏输出不能选推挽输出。推挽输出会把 SDA 强制拉高或拉低如果 OLED 模块同时也在驱动 SDA两个输出源打架轻则波形畸变重则引脚发热甚至损坏。内部上拉只是兜底模块自带 4.7k 上拉时Pull选GPIO_PULLUP也没有副作用。3. OLED 显示函数的封装顺序从 SSD1306 初始化到汉字与字符串3.1 SSD1306 初始化命令序列先开电荷泵再谈点亮 oled 屏幕全流程点亮 oled 屏幕全流程的第一步不是写像素而是给 SSD1306 发初始化序列。OLED 驱动芯片上电后默认处于关闭状态必须按顺序设置时钟、复用比、电荷泵、对比度最后用0xAF打开显示。初始化命令用前面写的OLED_IIC_SendByte发送但要注意每个命令前面要加一个控制字节0x00表示后面跟的是命令0x40表示后面跟的是显示数据。void OLED_WriteCmd(uint8_t cmd) { OLED_IIC_Start(); OLED_IIC_SendByte(0x78); // 器件地址 写标志 OLED_IIC_SendByte(0x00); // 控制字节后续是命令 OLED_IIC_SendByte(cmd); OLED_IIC_Stop(); }初始化命令序列可以放在一个数组里逐个发送static const uint8_t OLED_INIT_CMDS[] { 0xAE, // 关闭显示初始化期间保持黑屏 0xD5, 0x80, // 显示时钟分频0x80 是常见默认值 0xA8, 0x3F, // 复用比 1/64128x64 屏固定值 0xD3, 0x00, // 显示偏移 0 0x40, // 显示起始行 0 0x8D, 0x14, // 电荷泵开启0x14 必须设置否则白屏 0x20, 0x00, // 水平地址模式 0xA1, // 段重映射配合 0xC8 修正左右镜像 0xC8, // 扫描方向从下到上 0xDA, 0x12, // COM 引脚配置128x64 用 0x12 0x81, 0xCF, // 对比度0x00~0xFF0xCF 偏亮 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 电压 0xA4, // 从显存内容显示 0xA6, // 正常显示0xA7 是反色 0xAF // 打开显示 }; void OLED_Init(void) { uint8_t i; OLED_GPIO_Init(); OLED_IIC_Delay(); for (i 0; i sizeof(OLED_INIT_CMDS); i) OLED_WriteCmd(OLED_INIT_CMDS[i]); }初始化序列里最容易出错的是0x8D 0x14这一组。SSD1306 内部电荷泵默认关闭如果不通过命令打开屏幕连背光都不亮。另外0xA1和0xC8要配对出现单独设置会导致显示内容左右或上下镜像。3.2 显示函数分层画点、字符、字符串与 OLED 显示汉字显示函数不要全部堆在main.c里常见的做法是分三层底层是 IIC 写字节中间层是 SSD1306 命令和显存操作上层才是字符、字符串、汉字、波形这些业务显示函数。先在内存里维护一个 8 行 × 128 列的显存数组static uint8_t OLED_Buffer[8][128]; // 8 页每页 128 字节对应 128x64 void OLED_Refresh(void) { uint8_t page, col; for (page 0; page 8; page) { OLED_WriteCmd(0xB0 page); // 设置页地址 OLED_WriteCmd(0x00); // 列地址低 4 位 OLED_WriteCmd(0x10); // 列地址高 4 位 OLED_IIC_Start(); OLED_IIC_SendByte(0x78); OLED_IIC_SendByte(0x40); // 控制字节后续是数据 for (col 0; col 128; col) OLED_IIC_SendByte(OLED_Buffer[page][col]); OLED_IIC_Stop(); } }显示字符时先取字模6×8 字体每个字符占 6 个字节每字节对应一列低位在上void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t i; if (x 127 || y 7 || ch ) return; for (i 0; i 6; i) OLED_Buffer[y][x i] OLED_Font6x8[ch - ][i]; }y 坐标是页号范围 0~7对应屏幕从上到下的 8 个 8 像素高的条带。OLED 显示汉字用 16×16 点阵每个汉字占 32 字节一页放不下需要跨两页写入void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index) { uint8_t i; if (x 127 || y 6) return; for (i 0; i 16; i) OLED_Buffer[y][x i] OLED_Font16[index][i]; // 上半部分 for (i 0; i 16; i) OLED_Buffer[y 1][x i] OLED_Font16[index][16 i]; // 下半部分 }字符串函数就是循环调用OLED_ShowChar遇到\n换行即可。这个分层的意义在于应用层永远只操作OLED_Buffer不关心底层是 IIC 还是 SPI也不关心引脚接在 PB6 还是 PC13。字模数据用取模软件生成取模方式选“列行式、字节倒序”和上面代码的字节位序保持一致。3.3 加了 OLED 函数就卡死排查 IIC 等待、时钟与复位很多人在工程里加了 OLED 显示函数后程序卡死在初始化位置常见的现象和排查方向如下现象可能原因检查方法卡死在OLED_InitGPIO 时钟没打开或引脚被其他外设占用检查 RCC 时钟使能确认 PB6/PB7 没接按键或串口卡死在发数据循环IIC 发送函数里加了 ACK 等待删掉 ACK 相关代码OLED 模块不回 ACK 很常见屏幕亮但全屏雪花初始化序列缺了0xA4或显存未清初始化后全屏写 0 再刷新一次程序能跑但 OLED 没反应SCL/SDA 接反或控制字节写错用逻辑分析仪看 SCL/SDA 波形确认命令字是 0x00按键等外设同时失灵PB6/PB7 被复用成其他功能F103 的 PB6/PB7 默认是 I2C1检查是否被重映射矩阵按键在 OLED 上没反应多半不是 OLED 的问题而是按键扫描和 OLED 共用了同一个引脚。F103 的 PB6/PB7 也是 I2C1 引脚如果你用标准库开启了 I2C1 外设GPIO 模拟函数就会和硬件外设抢引脚控制权。拿到工程包后第一步不是编译而是全局搜索GPIO_Pin_6和GPIO_Pin_7确认没有第二个外设同时操作它们。4. STM32F103 波形显示ADC 采样、DMA 搬运与 OLED 滚动映射4.1 波形显示的三段式结构采样、映射、刷新OLED 上做波形显示核心不是“画线”而是“数据链路”。完整结构是ADC 采集模拟信号 → DMA 搬运到内存 → OLED 把采样点按列刷新。很多人的误区在于用HAL_Delay控制采样间隔或直接在 ADC 中断里刷屏导致采样时间被 IIC 刷新阻塞。stm32f103 的 ADC 是 12 位输入范围 0~3.3V采集一个 0~3.3V 的正弦波信号时采样值范围是 0~4095需要映射到 OLED 的 0~63 像素高度。用 DAC 输出正玄波作为测试信号是最方便的F103 的 DAC 通道输出接 ADC 输入不需要外接信号发生器。映射公式一句话讲清#define WAVE_TO_Y(val) (63 - ((val) * 60 12))12 位最大值 4095乘以 60 再右移 12 位结果范围是 0~59留出上下各 2 像素的边距。63 -是为了把高电平显示在上面和示波器习惯一致。这个映射宏放在头文件里采样位数变了只改这一处。4.2 用定时器触发 ADC DMA避免波形采集拖慢 OLED 刷新采集阶段如果直接在while(1)里轮询 ADCOLED 刷新时采集就会断档。常见做法是定时器触发 ADC 转换DMA 自动把结果搬运到缓冲区CPU 只在 DMA 传输一半和完成时拿到通知。这里用 TIM2 的通道 2 触发 ADC1DMA1 通道 1 搬运#define ADC_SAMPLE_NUM 64 uint16_t adc_buf[ADC_SAMPLE_NUM]; volatile uint8_t dma_half_done 0; void Waveform_ADC_Init(void) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; DMA_InitTypeDef dma; TIM_TimeBaseInitTypeDef tim; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_AIN; // PA0 做模拟输入 GPIO_Init(GPIOA, gpio); dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)adc_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize ADC_SAMPLE_NUM; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; dma.DMA_Priority DMA_Priority_High; dma.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, dma); adc.ADC_Mode ADC_Mode_Independent; adc.ADC_ScanConvMode DISABLE; adc.ADC_ContinuousConvMode DISABLE; // 由定时器触发不连续转换 adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_CC2; // TIM2 通道 2 触发 adc.ADC_DataAlign ADC_DataAlign_Right; adc.ADC_NbrOfChannel 1; ADC_Init(ADC1, adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); tim.TIM_Prescaler 72 - 1; // 72MHz / 72 1MHz tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 1000 - 1; // 1MHz / 1000 1kHz 触发频率 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); // 更新事件作为触发源 DMA_ITConfig(DMA1_IT_TC1, ENABLE); // 传输完成中断 NVIC_EnableIRQ(DMA1_Channel1_IRQn); TIM_Cmd(TIM2, ENABLE); }注意 TIM2 的更新事件需要作为触发源输出实际触发 ADC 的是 TIM2 的 TRGO不是 PWM 输出。采样频率由TIM_Period决定这里 1kHz 对应每毫秒采一个点。DMA 工作在循环模式缓冲区采满 64 个点后自动从头开始传输完成中断里只翻转标志位void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { dma_half_done 1; DMA_ClearITPendingBit(DMA1_IT_TC1); } }如果要做多路捕获把ADC_NbrOfChannel改成 2在ADC_RegularChannelConfig里配置两个通道DMA 缓冲区大小翻倍数据按通道交替排列。stm32f103 多路 ADC 采集常见的坑是 DMA 只搬了一个通道的数据原因多半是忘了开扫描模式。4.3 OLED 列滚动刷新与采样率换算波形显示不能每帧全屏清除再重画那样闪烁会非常明显。常见做法是整屏显存向左滚动一列新采样点画在最后一列。显存按字节组织向左滚动一列等价于按位右移void OLED_ScrollLeft(void) { uint8_t page, col; uint8_t carry 0; for (page 0; page 8; page) { for (col 0; col 128; col) { uint8_t val OLED_Buffer[page][col]; uint8_t new_carry (val 0x01) ? 0x80 : 0; // 本字节最低位移到下一字节 OLED_Buffer[page][col] (val 1) | carry; carry new_carry; } } }这里carry在页内循环时传递但页与页之间不传递因为不同页对应屏幕不同高度的 8 行水平滚动时进位只发生在同一页内。每次 DMA 传输完成把 adc_buf 里的 64 个点依次画到缓冲区的最后一列然后整体左移void Waveform_UpdateDisplay(void) { uint8_t i; static uint8_t col 0; if (!dma_half_done) return; dma_half_done 0; for (i 0; i ADC_SAMPLE_NUM; i) { uint8_t y WAVE_TO_Y(adc_buf[i]); OLED_Buffer[y / 8][col] | (1 (y % 8)); col; if (col 128) { OLED_ScrollLeft(); col 127; // 下一轮继续画最后一列 } } OLED_Refresh(); }OLED_Refresh会把整个 8×128 显存刷到屏幕软件 IIC 下大约需要 20–30ms所以波形刷新率理论极限在 30Hz 左右。ADC 采样率可以很高但显示端跟不上时波形看到的是“抽稀”后的结果。采样率和横向时间窗口的关系如下采样频率128 列对应时间窗可观察信号频率建议1kHz128ms低于 100Hz2kHz64ms低于 200Hz5kHz25.6ms低于 500Hz10kHz12.8ms低于 1kHz这个表格的前提是每采样点占一列。如果采样率提高但列数不变每个像素列代表的时间变短波形横向被拉伸。要让低频信号完整显示需要降低采样率或把多个采样点做峰值保持后再画一列否则波形会看起来“断断续续”。5. 验证与进阶逻辑分析仪看时序、标准库到 HAL 库迁移的落地细节5.1 用逻辑分析仪验证 IIC 时序与初始化序列OLED 不上电、花屏、乱码时先别怀疑芯片型号把逻辑分析仪的通道 0 接 SCL、通道 1 接 SDA、地线接 GND采样率设 1MHz 以上抓一段初始化波形。重点看三处起始信号是不是 SCL 高电平时 SDA 拉低地址字节是不是 0x78命令前有没有 0x00 控制字节。如果波形上能看到 0x78 后面跟着 0x00、0xAE说明 IIC 层已经通了问题在初始化命令本身。如果 SDA 上数据是 0x40 开头的说明你把数据字节当命令发出去了屏幕会显示乱码。抓一次全屏刷新还能数出0xB0到0xB7共 8 次页切换次数不对说明刷新不完整。5.2 从标准库到 HAL 库迁移 OLED 代码的注意点F103 的工程越来越多用 CubeMX 生成 HAL 库迁移 OLED 代码时不要直接复制标准库的 GPIO 操作。标准库用GPIO_SetBits和GPIO_ResetBitsHAL 库用HAL_GPIO_WritePin替换关系如下// 标准库 GPIO_SetBits(GPIOB, GPIO_Pin_6); GPIO_ResetBits(GPIOB, GPIO_Pin_7); // HAL 库 HAL_GPIO_WritePin(GPIOB, GPIO_Pin_6, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_Pin_7, GPIO_PIN_RESET);如果 OLED 代码文件里到处是GPIOB-BSRR那恭喜你这套代码本身就是寄存器操作标准库和 HAL 库都能用。DMA 中断在 HAL 库里有自己的处理流程标准库的DMA1_Channel1_IRQHandler写法在 HAL 库里仍能直接用中断函数名但如果同时用了HAL_DMA_IRQHandler注意不要把同一个 DMA 通道回调函数覆盖掉。迁移后第一件事是打开 DMA 中断优先级分组NVIC_PriorityGroup_2否则中断不响应。5.3 让 OLED 波形显示变成可复用的驱动模块把代码按驱动层、硬件抽象层、应用层拆开后续换屏或换单片机只改一层。驱动层就是OLED_WriteCmd、OLED_Refresh和底层 IIC硬件抽象层负责引脚宏定义和 GPIO 初始化应用层只调用OLED_ShowString、OLED_ShowChinese、Waveform_UpdateDisplay。如果换 0.91 寸 128×32 屏就把OLED_Buffer[8][128]改成OLED_Buffer[4][128]页循环上限从 8 改成 4初始化命令里0xA8 0x3F改成0xA8 0x1F。如果你的板子只有 SPI 接口的 OLED驱动层换成 SPI 发送函数OLED_Buffer的组织方式可以完全不变。判断驱动函数写没写对的快速方法是显示一个十字线四条边各画一条线如果上下左右都对得上说明段重映射和扫描方向设置正确如果左右反了把0xA1改成0xA0上下反了把0xC8改成0xC0。把这条验证步骤写进项目的 README下次换屏不用再从头查一遍。本文还有配套的精品资源点击获取