STM32 IIC总线实战:从时序上拉到SHT20与OLED显示驱动
简介基于IIC驱动0.96寸OLED显示屏实时显示SHT20温湿度数据的嵌入式工程Demo面向正在学习STM32、IIC通信或传感器数据采集的开发者能够帮助解决温湿度采集与OLED显示之间的软硬件衔接问题。工程以STM32为主控完整演示IIC初始化、OLED配置、SHT20读取与数据换算、界面刷新等关键环节包含IIC起始与停止时序、应答位处理、SHT20校验解析以及OLED绘图函数代码结构清晰可直接烧录运行。资源包为zip格式共149个文件、大小3.45MB内容以c/h源码文件为主同时包含Keil工程配置文件、编译中间文件d/o/crf以及最终生成的hex/axf固件目录归类清晰便于对照源码理解工程结构。目前已有847人浏览学习压缩包内保留了完整的Keil MDK链接文件、启动文件与烧录文件省去环境搭建时间且源码注释详细可快速查看底层驱动与上层显示逻辑既适合入门学习也可作为温湿度显示类项目的二次开发模板用于课堂实验或毕业设计参考。1. 一块屏、一颗传感器为什么 IIC 是这里的唯一选择手里一份 STM32F103 DEMO编译烧录后 OLED 上两行数字跳着走温度、湿度。文件列表里是stm32f10x_i2c.c、stm32f10x_rcc.c、stm32f10x_usart.c意图很清楚用 IIC 把 SHT20 和 0.96 寸 OLED 搭在同一条总线上一个做数据源一个做界面。OLED 是自发光器件模块出厂就带 I2C 接口SHT20 把模拟量换成了数字量。对嵌入式初学者这 DEMO 是入门 IIC 的好样例对 5 年以上的人价值不在点亮屏幕而在时序管理、上拉电阻、重复起始和地址冲突这些总线细节。2. IIC 总线时序与上拉电阻选型先解决能不能通的问题IIC 是半双工的两线制同步串行总线SCL 提供时钟SDA 传数据空闲时两根线都被外部上拉电阻拉到高电平。任何设备都可以拉低总线所以一旦 SDA 被某个从机锁死波形上就是一根贴近 0V 的直线。这类问题不是因为命令写错而是物理层没调对。调总线时要先理解时序边界再看“为什么在这个电阻值下数据会错”。2.1 起始、停止与数据位用 GPIO 模拟一套可读的时序标准库工程里stm32f10x_i2c.c存在但很多 DEMO 实际用 GPIO 做软件模拟。原因有两个STM32F1 的硬件 I2C 在中断和错误标志处理上容易绕晕软件模拟可以直接用逻辑分析仪看电平变化每一条IIC_Delay自己都能预估结果。下面是我在这个 DEMO 里最常用的计时时序核心是 SCL 高电平时 SDA 必须稳定。#define SCL_H GPIO_SetBits(GPIOB, GPIO_Pin_6) #define SCL_L GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define SDA_H GPIO_SetBits(GPIOB, GPIO_Pin_7) #define SDA_L GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define SDA_READ GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) void IIC_Delay(uint16_t t) { for (volatile uint16_t i 0; i t; i); } void IIC_Start(void) { SDA_H; IIC_Delay(5); SCL_H; IIC_Delay(5); SDA_L; // SCL 为高时 SDA 由高变低产生起始条件 IIC_Delay(5); SCL_L; IIC_Delay(5); } void IIC_Stop(void) { SDA_L; IIC_Delay(5); SCL_H; IIC_Delay(5); SDA_H; // SCL 为高时 SDA 由低变高产生停止条件 IIC_Delay(5); } uint8_t IIC_WaitAck(void) { uint8_t timeout 200; SDA_H; IIC_Delay(2); SCL_H; IIC_Delay(2); while (SDA_READ timeout--) { IIC_Delay(1); } SCL_L; return timeout ? 0 : 1; // 0 表示收到应答1 表示超时 }IIC_Start和IIC_Stop必须满足“SCL 为高电平时 SDA 翻转”这个边界。数据位也一样SCL 为高期间 SDA 必须保持不变所以发每一位时先放数据再拉高 SCL。调不通时先抓波形如果看到 SDA 在 SCL 高电平时跳动通常是 GPIO 配置成了推挽输出而不是开漏。开漏模式下输出低时拉低输出高时靠外部上拉电阻完成多个设备才能共享同一条总线。2.2 上拉电阻4.7k 还是 10k取决于上升时间和总线电容很多 DEMO 板载 10k 上拉用短线接传感器还能跑线一长就随机丢字节。问题不在代码而在上升时间超过了 IIC 协议允许的上限。IIC 快速模式400kHz要求上升时间不超过 300ns标准模式100kHz不超过 1000ns。上拉电阻越大RC 充电时间越长SDA/SCL 从低到高爬坡越慢。最大上拉电阻由总线电容决定Rmax tr / (0.8473 * Cbus)。Cbus 包括引脚电容、走线电容和杜邦线电容估算 200pF 时400kHz 下 Rmax 约 1.8k10k 基本只能在 100kHz 下稳定。最小电阻则由器件拉低能力决定Rmin (VDD - VOL) / IOL3.3V / 1.7mA 约 1.2k。总线模式频率上限上升时间上限3.3V、200pF 推荐上拉适用场景标准模式100kHz1000ns4.7k~10k短连线、单从机快速模式400kHz300ns2.2k~4.7k本项目推荐快速模式1MHz120ns1.2k~2.2k高吞吐、短距离本项目里 SHT20 和 OLED 都工作在 400kHz 以下我一般直接选 4.7k 上拉到 3.3V。模块上如果已经带了上拉电阻先确认不是 5V 电平避免 STM32F103 的引脚长期过压。2.3 硬件 I2C 和软件模拟怎么选文件列表里有stm32f10x_i2c.c说明工程本来就支持硬件 I2C 外设。但我在这个 DEMO 中更倾向于软件模拟IIC_Start、IIC_Stop、IIC_ReadByte 都是独立函数以后换屏幕、换传感器只要改寄存器地址和命令字节不需要和 I2C 外设的状态寄存器纠缠。HAL 库版本里同样有这个问题。用HAL_I2C_Mem_Write封装 OLED一旦出错总线状态卡在HAL_BUSY上常见现象就是“加了 OLED 函数卡死”。软件模拟不会永久卡死总线异常时把 SCL 翻转 9 个时钟、发一个 STOP 就能恢复。硬件 I2C 的价值在于 DMA 和 FIFO 配合大批量读写一个 DEMO 里没有必要。如果项目后续要接多个 I2C 从机做批量数据搬运再迁移到硬件外设也不迟。3. SHT20 温湿度读取命令字节和 CRC 校验才是核心SHT20 是 Sensirion 的数字温湿度传感器7 位 I2C 地址固定为 0x40。读数据不是读寄存器而是主机先发触发命令传感器测量完成后返回 3 个字节MSB、LSB、CRC-8。很多 DEMO 会把第三个字节丢弃对这个传感器来说勉强能用但量产或者现场长时间运行CRC 校验必须做否则偶发跳变很难解释。3.1 命令、精度和转换时间SHT20 常用命令包括 0xF3 触发温度测量、0xF5 触发湿度测量、0xE7 读用户寄存器、0xE6 写用户寄存器。默认 12 位温度和 12 位湿度可以通过修改用户寄存器把分辨率降到 11/10/8 位来换取更短的转换时间。命令功能转换时间典型/最大返回字节数0xE7读用户寄存器—10xF3触发温度测量50ms / 85ms30xF5触发湿度测量25ms / 48ms30xFE软复位15ms无读取时必须等转换完成后发重复起始条件。直接用 STOP 再 START 也可以但重复起始是和 SHT20 保持主机连接的标准做法兼容性最好。写地址是 0x80读地址是 0x81。#define SHT20_ADDR_W 0x80 #define SHT20_ADDR_R 0x81 #define SHT20_CMD_TEMP 0xF3 #define SHT20_CMD_HUMI 0xF5 uint16_t SHT20_ReadRaw(uint8_t cmd, uint8_t *crc_out) { uint8_t msb, lsb, crc; IIC_Start(); IIC_SendByte(SHT20_ADDR_W); // 从机地址 写位 if (IIC_WaitAck()) goto err; IIC_SendByte(cmd); // 触发测量命令 if (IIC_WaitAck()) goto err; IIC_Delay(90); // 等待转换完成12bit 需要 85ms IIC_Start(); // 重复起始 IIC_SendByte(SHT20_ADDR_R); // 从机地址 读位 if (IIC_WaitAck()) goto err; msb IIC_ReadByte(1); // 主机返回 ACK lsb IIC_ReadByte(1); // 再 ACK因为后面还要读 CRC crc IIC_ReadByte(0); // 最后一个字节发 NACK IIC_Stop(); if (crc_out) *crc_out crc; return ((uint16_t)msb 8) | lsb; err: IIC_Stop(); return 0xFFFF; }IIC_ReadByte(1)表示读到这个字节之后发 ACKIIC_ReadByte(0)表示最后一个字节发 NACK。OLED 显存读取和 SHT20 的 CRC 读取都依赖这个 ACK/NACK 语义顺序不能反。3.2 温湿度换算用整数运算避免浮点开销原始值换算公式固定温度 -46.85 175.72 * raw / 65535湿度 -6.0 125.0 * raw / 65535OLED 上只显示一位或两位小数因此可以在 MCU 里全部用整数。下面两个函数分别返回“温度 × 100”和“湿度 × 10”显示时再拆整数部分和小数部分。int16_t SHT20_CalcTempX100(uint16_t raw) { return -4685 (int32_t)17572 * raw / 65535; } int16_t SHT20_CalcHumiX10(uint16_t raw) { return -60 (int32_t)1250 * raw / 65535; }raw必须提升到int32_t再乘否则 16 位乘法直接溢出。这是一个很常见的 DEMO 翻车点读回来的数是对的一换算就成了负值。3.3 CRC-8 校验用移位实现的最后一层防护SHT20 返回的第三个字节是 CRC-8多项式x^8 x^5 x^4 1即 0x31初始值 0MSB 先传。用法是把 MSB 和 LSB 作为输入计算出的结果和第三个字节比较。uint8_t SHT20_CRC8(uint8_t *data, uint8_t len) { uint8_t crc 0; for (uint8_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x80) crc (crc 1) ^ 0x31; else crc 1; } } return crc; }实际测试中50cm 劣质杜邦线会让 CRC 错误率到 2% 左右湿度可能凭空跳 5%RH。加上 CRC 校验后丢掉错误帧并重读数据比之前稳得多。读完整数据时把三个字节读出来计算 CRC 后比较不相等就重试超过三次再报传感器异常。4. SSD1306 OLED 驱动显存、初始化序列和汉字显示0.96 寸 OLED 基本都走 SSD1306分辨率 128x64I2C 地址由 SA0 决定SA0 接低为 0x3C接高为 0x3D。这个 DEMO 里 SHT20 占 0x40OLED 默认 0x3C两条设备不冲突。驱动 OLED 的关键在于理解显存映射和初始化序列而不是一上来就画图形。4.1 硬件连线与地址检测模块引脚通常标 GND、VCC、SCL、SDA对应 STM32F103 上的 PB6、PB7。如果模块带 RESET可以用一个空闲 GPIO 控制也可以直接接高电平。用 CubeMX 生成工程时I2C 引脚要选择开漏输出、速度 50MHz并开启内部上拉外部再把 4.7k 上拉到 3.3V。初始化前先用地址扫描确认总线是否通。下面代码可以放在 main 函数开头for (uint8_t addr 0x78; addr 0x80; addr 2) { IIC_Start(); IIC_SendByte(addr); if (IIC_WaitAck() 0) { printf(found at 0x%02X\r\n, addr 1); } IIC_Stop(); }这里的扫描范围 0x78~0x80 对应 7 位地址 0x3C~0x40。正常情况下 OLED 出现在 0x78SHT20 出现在 0x80。如果只能扫到其中一个优先检查供电和上拉电阻而不是怀疑代码。4.2 初始化序列和显存刷新方式SSD1306 在 I2C 设备地址之后必须跟一个控制字节0x00 表示后续是命令0x40 表示后续是显存数据。初始化序列各家屏厂的推荐值略有差别但核心步骤一致关闭显示、设置显存寻址模式、设置多路复用比、打开电荷泵、开启显示。步骤命令十六进制作用10xAE关显示20x20 0x00水平寻址模式30xA8 0x3F多路复用比 6440xD3 0x00显示偏移50x40显示起始行 060xA1段重定义0xA0 为镜像70xC8扫描方向0xC0 为反方向80x81 0x7F对比度90xD5 0x80时钟分频100x8D 0x14开启电荷泵110xAF开显示使用一块 8×128 字节的显存缓存是最常见做法。刷新时按页写入每页写完 128 字节不需要每列都重新设置地址。static uint8_t g_oled_buf[8][128]; void OLED_WriteCmd(uint8_t cmd) { IIC_Start(); IIC_SendByte(OLED_ADDR_W); IIC_WaitAck(); IIC_SendByte(0x00); IIC_WaitAck(); IIC_SendByte(cmd); IIC_WaitAck(); IIC_Stop(); } void OLED_Refresh(void) { for (uint8_t page 0; page 8; page) { IIC_Start(); IIC_SendByte(OLED_ADDR_W); IIC_WaitAck(); IIC_SendByte(0x00); IIC_WaitAck(); IIC_SendByte(0xB0 page); // 设置页地址 IIC_SendByte(0x00); // 列地址低 4 位 IIC_SendByte(0x10); // 列地址高 4 位 IIC_Stop(); IIC_Start(); IIC_SendByte(OLED_ADDR_W); IIC_WaitAck(); IIC_SendByte(0x40); // 数据控制字节 IIC_WaitAck(); for (uint8_t col 0; col 128; col) { IIC_SendByte(g_oled_buf[page][col]); IIC_WaitAck(); } IIC_Stop(); } }每个命令字节单独发一个事务虽然慢但最稳定。如果追求速度可以把命令连续发先发 0x00再连续跟多个命令字节。数据写入可以一次 I2C 事务写 128 字节比每个字节独立事务快得多。实际卡顿通常不是屏幕硬件慢而是 I2C 事务太碎导致每毫秒都在启动和停止。4.3 汉字显示取模方式和字库排列英文字符用 8×6 或 6×8 字库足够。汉字在 128×64 屏幕上推荐 16×16一个字占两页、16 列。取模软件选择“列行式”排列生成的数据正好按上下两页存放。const uint8_t hanzi_temp[32] { 0x00, 0x10, 0x18, ... // 上半部分 16 字节 0x20, 0x30, 0x18, ... // 下半部分 16 字节 }; void OLED_ShowHanzi(uint8_t x, uint8_t page, const uint8_t *mb) { for (uint8_t col 0; col 16; col) { g_oled_buf[page][x col] mb[col]; g_oled_buf[page 1][x col] mb[col 16]; } }这里的 page 是字符上半部分所在页范围 0~6。如果 y 坐标不是 8 的倍数汉字会跨页错位。温度显示放页 0/1湿度放页 2/3中间留一页做间隔屏幕底部可以放状态或单位。5. DEMO 工程整合与排错卡死、花屏、数据跳变的处理技巧把 SHT20 和 OLED 拼到一起核心问题不是驱动代码而是主循环不能被单次传感器测量阻塞太久。SHT20 在 12 位精度下最长转换 85ms每次都DelayMs(85)的话OLED 刷新也被拖住。更好的是把流程拆成状态机触发温度测量等待完成再触发湿度测量最后统一显示。typedef enum { ST_TEMP_TRIG, ST_TEMP_WAIT, ST_HUMI_TRIG, ST_HUMI_WAIT, ST_DISPLAY } sht20_state_t; sht20_state_t state ST_TEMP_TRIG; uint16_t temp_raw 0, humi_raw 0; void Demo_Task(void) { switch (state) { case ST_TEMP_TRIG: IIC_Start(); IIC_SendByte(SHT20_ADDR_W); IIC_WaitAck(); IIC_SendByte(0xF3); IIC_WaitAck(); IIC_Stop(); state ST_TEMP_WAIT; break; case ST_TEMP_WAIT: if (TickAfter(85)) { // 非阻塞延时 temp_raw SHT20_ReadRaw(0, NULL); state ST_HUMI_TRIG; } break; case ST_HUMI_TRIG: IIC_Start(); IIC_SendByte(SHT20_ADDR_W); IIC_WaitAck(); IIC_SendByte(0xF5); IIC_WaitAck(); IIC_Stop(); state ST_HUMI_WAIT; break; case ST_HUMI_WAIT: if (TickAfter(85)) { humi_raw SHT20_ReadRaw(0, NULL); state ST_DISPLAY; } break; case ST_DISPLAY: OLED_Refresh(); state ST_TEMP_TRIG; break; } }TickAfter是基于系统时钟的非阻塞延时里面只判断 ms 计数差。这样主循环在等待期间还可以继续处理其他任务按键扫描、串口打印都不会被传感器延时拖住。遇到卡死先检查 SDA 是不是被拉死。可以用一个总线恢复函数把 SCL 翻转 9 次并发送 STOP让总线回到空闲态。这一步在很多 I2C 异常恢复流程里都有效。void IIC_ResetBus(void) { SDA_H; SCL_H; for (uint8_t i 0; i 9; i) { SCL_L; IIC_Delay(5); SCL_H; IIC_Delay(5); } IIC_Start(); IIC_Stop(); }排错顺序建议先量波形再改代码。逻辑分析仪看 SDA/SCL 空闲时是否都为高起始和停止是否干净。如果波形正常但屏幕白屏检查 0x8D 0x14 电荷泵有没有开如果屏幕有内容但温度固定不变检查 SHT20 读取流程是否缺了重复起始条件。花屏和镜像问题几乎都出在 0xA1/0xC8 这两个命令上逐位对照取模软件里的扫描方向。最容易被忽略的是 4.7k 上拉电阻没焊或者模块跳线切断了上拉波形上 SDA 低电平后回不到高地址扫描自然找不到设备。生产环境里还可以加一个轻量自检连续三次读 SHT20 的 CRC 失败就重置总线并显示错误码而不是让屏幕永远停留在上次数值上。现场人员看到错误码能立刻判断是传感器掉了、总线断了还是供电不足。先让总线上有正确的波形再谈其他。本文还有配套的精品资源点击获取