☰
STM32F103C8T6接DHT11与OLED:温湿度采集到屏幕显示完整实战
2026/10/15 1:32:42 网站建设 项目流程

简介:STM32F103C8T6 最小系统板驱动 DHT11 温湿度传感器,并通过 IIC 接口在 0.96 寸 OLED 屏上实时显示温度与湿度,是嵌入式入门中集外设配置、协议通信与数据显示于一体的典型实践。资源面向使用 Keil MDK 开发环境的初学者,围绕 HSE 时钟配置、GPIO 模拟 IIC、DHT11 时序解析及 OLED 驱动等核心环节提供完整工程。压缩包共 227 个文件,包括 C 源码、头文件、编译中间文件(.o/.crf)及 Keil 工程配置等,总大小约 8.33MB,目录结构清晰,便于对照学习。目前已有 1525 人学习下载,适合希望结合实物快速掌握 STM32 基础外设驱动和 IIC 通信原理的开发者。通过该工程可省去从零搭建工程的步骤,直接查看外设初始化与数据处理逻辑,并参考启动文件、链接脚本等底层细节进行二次开发。

1. STM32F103C8T6 最小系统板接 DHT11 和 OLED:从传感器到屏幕的一次完整打通

STM32F103C8T6 最小系统板资源不算多,但用来做一块温湿度显示屏绰绰有余。DHT11 只管把温度和湿度变成一串串脉冲,OLED 屏幕不会自己知道要显示什么——把脉冲解读成数字,再把数字映射成像素,这中间恰恰是嵌入式开发最值得琢磨的部分:时序控制、协议解析和驱动初始化。这个项目适合刚学会点灯、想往“能采集能显示”方向迈一步的人,也适合手头只有一块板、想快速验证传感器和显示屏是否正常的工程师。代码量不大,但把单总线、I2C 和显示驱动三个模块拆开讲清楚后,你能带走一套可以复用到其他传感器项目里的方法论。

2. 硬件接线与通信原理:先看清时序约束,再决定引脚怎么选

2.1 最小系统板引脚盘点:为什么 DHT11 选 PA0、OLED 选 PB6/PB7

STM32F103C8T6 最小系统板把 MCU 所有 GPIO 都引出来了,引脚多但并不是随便接都能跑得顺。DHT11 用的是单总线,数据脚必须挂在普通 GPIO 上,因为它的时序极短,启动信号要求主机把总线拉低 18ms 以上,而位解析时又要精确到几十微秒。我一般把 DHT11 接到 PA0,一是位置靠边好操作,二是 PA0 在绝大多数最小系统板上不带特殊复用冲突,适合做纯 IO 操作。

OLED 屏(0.96 寸、SSD1306 控制器)走的是 I2C,标准引脚是 PB6(SCL)和 PB7(SDA),对应 MCU 的 I2C1 外设。不过这里有一个选择问题:硬件 I2C 能用,但中断、DMA、总线忙标志在入门阶段容易把人绕晕。我习惯用软件模拟 I2C,两个 GPIO 直接拉高拉低实现时序,这样逻辑完全透明,出了问题也能用示波器逐脚查。软件 I2C 对速度要求不高,SSD1306 的 I2C 时钟 400kHz 以下都能跑,GPIO 翻转完全够用。

接线表如下:

模块引脚接 STM32说明
DHT11VCC3.3V供电,避免数据线电平过高
DHT11DATAPA0单总线数据脚
DHT11GNDGND共地
OLEDVCC3.3V模块内部带稳压可接 3.3V
OLEDSCLPB6软件 I2C 时钟线
OLEDSDAPB7软件 I2C 数据线
OLEDGNDGND共地

DHT11 的 VCC 我建议接 3.3V 而不是 5V。很多模块标注供电范围 3.3~5.5V,接 5V 时数据线高电平也是 5V,直接进 STM32 的 PA0 存在逾越 IO 耐压的风险;接 3.3V 后,数据线高电平在 3.3V 以内,配合模块自带的板级上拉电阻,时序和电平都干净。如果你的模块没有板载上拉,就在 DATA 和 VCC 之间补一颗 4.7k~10k 的上拉电阻。

2.2 DHT11 单总线时序:40 位数据是怎么挤在一根线上的

DHT11 只有一根数据线,时钟由主机自己产生。整个过程分三个阶段:主机发送起始信号、从机响应、从机发送 40 位数据。每一位的“0”和“1”不是靠电平高低区分的,而是靠高电平持续的时间长短区分。

主机起始信号:主机先把数据线拉低,保持至少 18ms,然后释放。这个低电平要足够长,DHT11 才能识别到主机发起了一次通信,如果太短,从机根本不会理你。

从机响应:DHT11 检测到起始信号后,先把总线拉低 80us,再释放 80us。这两个 80us 组合起来,表示“我准备好了,开始接收数据”。读代码时你要做的就是等低电平结束、再等高电平结束,之后进入 40 位数据的读取。

数据位:每一位开始前,DHT11 会把总线拉低 50us,这是每一位的“前导低电平”,然后释放总线。释放之后的高电平时间就是信息本身——高电平持续 26~28us 表示“0”,持续约 70us 表示“1”。所以读位时你不需要测量完整脉宽,只需要等前导低电平结束后延时 30us 再采样:如果引脚是低,说明高电平已经结束,这是个“0”;如果引脚还是高,说明高电平还在持续,这是个“1”。这个 30us 采样点就是整个驱动的核心。

40 位数据排列如下:

字节内容
byte0湿度整数部分
byte1湿度小数部分(多数模块恒为 0)
byte2温度整数部分
byte3温度小数部分(多数模块恒为 0)
byte4校验和 = byte0+byte1+byte2+byte3 的低 8 位

整体数据帧长度大约 3~5ms,读取完成后总线恢复到空闲高电平,下一次通信要间隔至少 1 秒,这是 DHT11 内部采样周期的硬限制。

2.3 SSD1306 的 I2C 细节:地址、控制字节和显示 RAM 的关系

SSD1306 是一颗 128x64 的 OLED 驱动芯片,屏幕内部其实是一块显示 RAM,你在 RAM 里写 1,对应像素就点亮。I2C 只是搬运手段,所以驱动 OLED 要解决三件事:I2C 通信本身、SSD1306 的初始化配置、把要显示的字符点阵写进显示 RAM。

I2C 地址通常是 0x3C,也有少量模块是 0x3D。这个地址由模块上的电阻决定,买回来的模块绝大多数默认 0x3C。每次写操作,主机发出的首字节是(地址<<1)|0,也就是 0x78;SSD1306 还要求跟在地址字节后面发一个控制字节,0x00 表示后面跟的是命令,0x40 表示后面跟的是显示数据。这个控制字节很容易漏,漏了之后屏幕完全没反应。

初始化序列里有几个命令位置很关键:0x8D 配 0x14 是开启内部电荷泵,很多“屏幕不亮”的问题就是电荷泵没开;0x20 配 0x02 是页寻址模式,这决定了你写数据时地址是怎么自动递增的。初始化完成后显示 RAM 默认是全 0,也就是黑屏,这时候你再往 RAM 里写字模数据,字符就出现了。

显示字符的基本思路是:先把光标定位到某页(每页 8 行像素)的某列,然后把字符的字模数据一字节一字节写进去。一个 8x16 的 ASCII 字符拆成两页,每页写 8 个字节。这里不展开完整字模表,你只需要理解“显示 = 写 RAM”,后面代码里字模表直接用常见的 Font 8x16 即可。

3. 代码实现:从 DHT11 驱动到 OLED 刷屏的完整手写过程

3.1 工程骨架:时钟、延时函数和 GPIO 方向切换

标准外设库工程起步时,第一件事是确认系统时钟能跑到 72MHz。如果开发板带 8MHz 晶振,启动文件会自动配置 PLL;如果是无源晶振没起振,系统会退回内部 8MHz HSI,这时延时函数全部按 72MHz 算就全错了。

延时函数我用 SysTick 实现,不占用定时器外设:

// delay.h #ifndef __DELAY_H #define __DELAY_H #include "stm32f10x.h" void Delay_Init(void); void delay_us(uint32_t nus); void delay_ms(uint16_t nms); #endif
// delay.c #include "delay.h" static uint8_t fac_us = 0; static uint16_t fac_ms = 0; void Delay_Init(void) { // 配置 SysTick 时钟源为 HCLK,即 72MHz SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK); fac_us = 72; // 1us 需要 72 个时钟周期 fac_ms = (uint16_t)fac_us * 1000; // 1ms 需要 72000 个时钟周期 } void delay_us(uint32_t nus) { SysTick->LOAD = nus * fac_us - 1; // 装入计数值 SysTick->VAL = 0; // 清空当前计数值 SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; } void delay_ms(uint16_t nms) { while (nms--) delay_us(1000); }

这里的fac_us是时钟频率除以 1MHz 得到的,系统在 72MHz 时就是 72,8MHz 时就是 8。如果改了外部晶振频率,第一件事就是改这里,否则 DHT11 的 30us 采样点会整体偏移,读出来的位全是乱码。SysTick 的COUNTFLAG标志在计数值减到 0 时自动置位,读一次后会自动清零,所以每次调用前要先把它清掉(通过写VAL=0)。

接下来是 GPIO 初始化。DHT11 的数据脚需要在输出和输入两种模式间切换,因为起始信号要主动拉低总线,而读取时要释放总线并监听从机电平:

// dht11.h #ifndef __DHT11_H #define __DHT11_H #include "stm32f10x.h" // 数据脚定义:PA0 #define DHT11_DATA_PORT GPIOA #define DHT11_DATA_PIN GPIO_Pin_0 uint8_t DHT11_Read_Data(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec); #endif
// dht11.c #include "dht11.h" #include "delay.h" // 切换为推挽输出模式,用于发送起始信号 static void DHT11_Pin_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_DATA_PIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(DHT11_DATA_PORT, &GPIO_InitStructure); } // 切换为浮空输入模式,用于监听 DHT11 的电平变化 static void DHT11_Pin_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_DATA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_DATA_PORT, &GPIO_InitStructure); } void DHT11_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = DHT11_DATA_PIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(DHT11_DATA_PORT, &GPIO_InitStructure); GPIO_SetBits(DHT11_DATA_PORT, DHT11_DATA_PIN); }

输入模式选浮空而不是内部上拉,是因为 DHT11 模块板上一般已经有上拉电阻;如果你的模块是裸传感器,没有上拉,就改用GPIO_Mode_IPU,否则空闲电平不确定,后续读取会随机出错。

3.2 DHT11 驱动:起始信号、响应检测与逐位解析

读函数的核心是严格按照时序走,我把每段等待都拆开写,方便你用逻辑分析仪对照:

uint8_t DHT11_Read_Data(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec) { uint8_t buf[5] = {0}; uint8_t i, j; // 【阶段1】主机发送起始信号:拉低 20ms,再释放 DHT11_Pin_Output(); GPIO_ResetBits(DHT11_DATA_PORT, DHT11_DATA_PIN); delay_ms(20); GPIO_SetBits(DHT11_DATA_PORT, DHT11_DATA_PIN); // 释放后切换到输入模式,准备接收从机响应 DHT11_Pin_Input(); delay_us(30); // 让总线稳定,也给 DHT11 一点反应时间 // 【阶段2】检测从机响应:先低 80us,再高 80us // 如果总线一直为高,说明 DHT11 没有把总线拉低 if (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_SET) { return 1; // 无响应 } // 等响应低电平结束 while (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_RESET); // 等响应高电平结束 while (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_SET); // 【阶段3】读取 40 位数据,5 个字节 for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { // 每一位开始前,有一个 50us 的低电平时隙 // 这里就是等待这个低电平结束,进入高电平窗口 while (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_RESET); // 延时 30us 后采样: // “0”的高电平只有 26~28us,30us 处已经回到低电平 // “1”的高电平约 70us,30us 处仍然是高电平 delay_us(30); if (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_SET) { buf[i] |= (0x80 >> j); // 高位在前 // 等当前位的剩余高电平结束 while (GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == Bit_SET); } } } // 【阶段4】校验和检查:前 4 字节相加,取低 8 位与第 5 字节比较 if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) { return 2; // 校验失败 } *humi_int = buf[0]; *humi_dec = buf[1]; *temp_int = buf[2]; *temp_dec = buf[3]; return 0; }

这个驱动的关键参数就是delay_us(30)。如果你的延时函数基于 72MHz 计算但实际主频是 8MHz,这个 30us 实际变成 30*9=270us,读到的高电平窗口完全错了。出现这种情况时,先用调试器看RCC_CFGR寄存器的 PLL 配置,确认系统时钟真实频率,再回来调fac_us。

响应的两个while等待没有超时保护,如果 DHT11 损坏或接触不良,程序会死等在这个循环里。工程上可以给两个 while 各自加一个计数上限,比如while(... && timeout < 10000) timeout++;,超时直接返回错误。我下面的完整工程里保留了这种带超时保护的写法。

3.3 OLED 驱动:软件 I2C 与 SSD1306 初始化序列

OLED 我全部用软件 I2C 实现,不依赖硬件 I2C 外设。先定义 SCL 和 SDA 引脚,再实现起始、停止、写字节三个基础操作:

// oled.h #ifndef __OLED_H #define __OLED_H #include "stm32f10x.h" #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_ADDR 0x78 // 默认 0x3C 左移一位后的写地址 void OLED_I2C_Init(void); void OLED_Init(void); void OLED_Clear(void); void OLED_ShowString(uint8_t x, uint8_t y, char *str); #endif
// oled.c 部分代码 #include "oled.h" #include "delay.h" #define SCL_HIGH() GPIO_SetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define SCL_LOW() GPIO_ResetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define SDA_HIGH() GPIO_SetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define SDA_LOW() GPIO_ResetBits(OLED_SDA_PORT, OLED_SDA_PIN) void OLED_I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = OLED_SCL_PIN | OLED_SDA_PIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_Init(OLED_SCL_PORT, &GPIO_InitStructure); SCL_HIGH(); SDA_HIGH(); }

开漏输出的原因:I2C 协议要求设备只能把总线拉低、不能主动拉高,总线空闲电平靠上拉电阻维持。STM32 的 GPIO 配成开漏输出后,写 1 时引脚呈高阻态,由外部或内部上拉把电平抬到 3.3V。最小系统板上 PB6/PB7 内部已经有弱上拉,但不一定够稳,稳妥的做法是在这两条线上各加一颗 4.7k 的外部上拉到 3.3V。如果你不想加电阻,也可以把模式改成GPIO_Mode_Out_PP(推挽输出),大多数情况下也能工作,因为 OLED 模块本身是纯从机不抢总线,推挽并不会造成冲突。

I2C 基础时序:

static void I2C_Delay(void) { uint8_t i = 8; while (i--); } static void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); I2C_Delay(); SDA_LOW(); // SCL 高电平期间 SDA 产生下降沿 = 起始条件 I2C_Delay(); SCL_LOW(); // 释放时钟线,准备传输数据 } static void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); I2C_Delay(); SDA_HIGH(); // SCL 高电平期间 SDA 产生上升沿 = 停止条件 I2C_Delay(); } static void I2C_WriteByte(uint8_t byte) { uint8_t i; for (i = 0; i < 8; i++) { if (byte & 0x80) SDA_HIGH(); else SDA_LOW(); SCL_HIGH(); I2C_Delay(); SCL_LOW(); I2C_Delay(); byte <<= 1; } // 释放 SDA,等待从机应答 SDA_HIGH(); SCL_HIGH(); I2C_Delay(); SCL_LOW(); }

软件 I2C 的延时决定了通信速率,I2C_Delay()在 72MHz 下大约能产生 1~2us 的延时,SCL 周期约 4~8us,对应 125~250kHz 的 I2C 速率,完全满足 SSD1306 的要求。OLED 屏刷新一帧全屏数据约 8KB,按这个速率刷新一次不到 100ms,肉眼感觉不到延迟。

SSD1306 初始化序列我直接给出完整命令列表,每一步都有注释:

void OLED_Write_Cmd(uint8_t cmd) { I2C_Start(); I2C_WriteByte(OLED_ADDR); I2C_WriteByte(0x00); // 控制字节:后面是命令 I2C_WriteByte(cmd); I2C_Stop(); } void OLED_Init(void) { OLED_I2C_Init(); delay_ms(100); OLED_Write_Cmd(0xAE); // 关闭显示,配置期间避免出现杂色 OLED_Write_Cmd(0xD5); // 设置时钟分频因子 OLED_Write_Cmd(0x80); OLED_Write_Cmd(0xA8); // 设置多路复用比 OLED_Write_Cmd(0x3F); // 64 行 OLED_Write_Cmd(0xD3); // 设置显示偏移 OLED_Write_Cmd(0x00); OLED_Write_Cmd(0x40); // 显示起始行 0 OLED_Write_Cmd(0x8D); // 电荷泵设置 OLED_Write_Cmd(0x14); // 开启电荷泵,这是点亮屏幕的关键 OLED_Write_Cmd(0x20); // 内存寻址模式 OLED_Write_Cmd(0x02); // 页寻址模式 OLED_Write_Cmd(0xA1); // 段重映射,正常方向 OLED_Write_Cmd(0xC8); // COM 扫描方向,正常方向 OLED_Write_Cmd(0xDA); // COM 引脚配置 OLED_Write_Cmd(0x12); OLED_Write_Cmd(0x81); // 对比度设置 OLED_Write_Cmd(0xCF); OLED_Write_Cmd(0xD9); // 预充电周期 OLED_Write_Cmd(0xF1); OLED_Write_Cmd(0xDB); // VCOMH 电压选择 OLED_Write_Cmd(0x40); OLED_Write_Cmd(0xA4); // 全局显示开启 OLED_Write_Cmd(0xA6); // 正常显示模式(1=亮) OLED_Write_Cmd(0xAF); // 开启显示 }

命令 0x8D 配 0x14 最容易漏,很多移植例程把这句删了,结果电荷泵不工作,屏幕怎么都不亮。我还有一次遇到屏幕亮但是内容上下颠倒,就是因为 0xC8 写成了 0xC0,注意区分。

显示字符串的核心是把字符点阵写入对应页。以 8x16 字体为例,一个字符占 16 行像素,SSD1306 每页 8 行,所以一个字符要写两页。定位用两个命令:0xB0+页号设页地址,0x00+列低4位和0x10+列高4位设列地址。写入数据时控制字节用 0x40:

void OLED_SetPos(uint8_t page, uint8_t col) { OLED_Write_Cmd(0xB0 + page); // 页地址 OLED_Write_Cmd(0x00 + (col & 0x0F)); // 列低 4 位 OLED_Write_Cmd(0x10 + ((col >> 4) & 0x0F)); // 列高 4 位 } void OLED_ShowChar(uint8_t page, uint8_t col, char ch) { uint8_t i; const uint8_t *font = &Font8x16[(ch - 32) * 16]; // 字模表 OLED_SetPos(page, col); for (i = 0; i < 8; i++) { I2C_Start(); I2C_WriteByte(OLED_ADDR); I2C_WriteByte(0x40); // 控制字节:后面是数据 I2C_WriteByte(font[i]); I2C_Stop(); } OLED_SetPos(page + 1, col); for (i = 8; i < 16; i++) { I2C_Start(); I2C_WriteByte(OLED_ADDR); I2C_WriteByte(0x40); I2C_WriteByte(font[i]); I2C_Stop(); } }

这里每显示一个字节就做一次完整的 I2C 起始-停止时序,效率不高但代码直观。如果追求刷新速度,可以把一页的 8 个字节连续发送,只在第一个字节前发 0x40,后面直接跟数据,SSD1306 会自动递增列地址。我建议你在工程里把“重复起止”改成“连续发送”的版本,刷新整屏的时间能缩短一半以上。

3.4 主循环整合:采样节奏、校验与显示刷新

主循环的逻辑分三块:失败重试、数据格式化、刷新 OLED。采样节奏控制在 2 秒一次,满足 DHT11 最小采样周期 1 秒的要求:

// main.c #include "stm32f10x.h" #include "delay.h" #include "dht11.h" #include "oled.h" #include <stdio.h> void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); } int main(void) { uint8_t humi_int, humi_dec, temp_int, temp_dec; uint8_t retry; char line1[16], line2[16]; uint8_t ret; Delay_Init(); GPIO_Config(); DHT11_GPIO_Init(); OLED_Init(); OLED_Clear(); OLED_ShowString(0, 0, "Temp: "); OLED_ShowString(0, 32, "Humi: "); while (1) { retry = 0; // 连续读取,最多重试 3 次 do { ret = DHT11_Read_Data(&humi_int, &humi_dec, &temp_int, &temp_dec); retry++; } while (ret != 0 && retry < 3); if (ret == 0) { // DHT11 小数字节多数恒为 0,但保留参与格式化 sprintf(line1, "Temp: %2d.%d C", temp_int, temp_dec); sprintf(line2, "Humi: %2d.%d %%", humi_int, humi_dec); OLED_ShowString(0, 0, line1); OLED_ShowString(0, 32, line2); } else { // 连续失败:显示固定提示,不做无意义刷新 OLED_ShowString(0, 0, "Sensor Err "); } // 总采样周期 2s delay_ms(1800); } }

sprintf在这里用到浮点转换,如果你不想引入浮点库,完全可以只显示整数部分,把.%d去掉。显示刷新只在读取成功时执行,失败时不更新旧数据而是显示错误提示,这样不会出现“最后一位数据一直停在屏幕上”的误判。重试间隔靠do-while循环里的连续读取耗时和主循环末尾的delay_ms(1800)拉开,实际每次重试间隔大约 20ms 到 200ms。

4. 避坑与常见问题排查:五条实测翻车记录

4.1 湿度固定在 0 或者 255:读取失败后把脏数据刷上屏

现象:上电后温度湿度都正常显示一次,之后湿度永远是 0 或者 255,温度也偶尔跳到 -1。

原因:DHT11 读取失败时返回非 0,但代码没有判断返回值,直接用了 buf 里的初始值 0;或者校验失败后没有丢弃数据,把 buf[4] 的校验字节当成了湿度值。255 这个值说明 buf[0] 被填充成 0xFF,多半是位解析时把前导低电平等成了数据位,读到一长串 1。

解决:读取后必须判断返回值,非 0 不刷新显示。同时给两次读取之间留足间隔,DHT11 内部采样周期约 1 秒,连续读太快会直接不响应。我的处理是失败时显示 “Sensor Err” 而不是沿用旧值,这样至少能区分是传感器问题还是代码问题。

4.2 OLED 有背光但不出字:地址写错和电荷泵没开

现象:屏幕背光亮了,但无论怎么写都不出任何内容,偶尔出现一两个乱码点。

原因:I2C 地址不是 0x3C,而是 0x3D。很多模块的地址由背面电阻决定,默认 0x3C,但有一小部分是 0x3D。如果地址错,OLED 根本不会 ACK,你发的命令全部石沉大海。另外初始化序列漏了 0x8D 配 0x14,电荷泵不工作,屏幕逻辑在跑但像素驱动电压不够。

解决:先用万用表量模块背面的地址电阻,或者直接把 I2C 扫描程序烧进去,扫出从机地址再改OLED_ADDR。初始化序列不要精简,0x8D/0x14 必须保留。我遇到过被坑的情况是卖家把 0.96 寸 I2C 版发成了 SPI 版,这种只能看模块背面排针数量:I2C 版通常 4 针,SPI 版 6 针以上。

4.3 数据只刷新一次就卡死:等待循环没有超时保护

现象:程序运行后第一次读到了正确的温湿度,之后整机卡死,连主循环都不走了。

原因:DHT11 40 位数据传输中途,某个位的前导低电平一直没有结束,代码卡在while (GPIO_ReadInputDataBit(...) == Bit_RESET);这个死循环里。最常见的原因是 DHT11 电源不稳或者接触不良,从机中途放弃了传输,总线被外部干扰拉低再也回不来。

解决:给两个while等待加上超时计数。我的做法是每个等待循环加一个uint16_t timeout = 5000; while (... && timeout--),超时后直接返回 3,主循环继续走,不会让整机锁死。加上超时保护后,即使传感器坏掉,屏幕也会每秒刷新一次错误提示,不会卡住。

4.4 温度正常但湿度剧烈跳变:供电和上拉都没做好

现象:温度读数稳定,湿度在 30% 和 70% 之间来回跳,采样两次结果差很多。

原因:湿度传感部分对供电纹波更敏感,如果 VCC 直接取自开发板的 3.3V 引脚而这块板电源噪声大,湿度数据就会跟着抖。另一个因素是数据线上拉电阻偏小,导致高电平持续时间被压缩,位解析在 30us 采样点处正好处在临界区。

解决:DHT11 的 VCC 和 GND 之间加一颗 100nF 去耦电容,引脚越短越好。数据线上拉电阻换成 10k,既可以保持高电平时间稳定,又不至于在读取时拉不动总线。软件层面做两次采样取平均,也能把毛刺消掉大半。

4.5 上电就死机不运行:延时参数与实际主频不匹配

现象:程序下载后完全不跑,调试器停在启动文件里,或者跑到第一行delay_ms(20)就进入 HardFault。

原因:最小系统板如果没焊接 8MHz 晶振,MCU 一直跑内部 HSI 8MHz,但Delay_Init()里面的fac_us按 72MHz 算成 72。delay_us(1000)执行时装载的LOAD值变成 72000,而 SysTick 只有 24 位,最大计数值 16777215,单个 1ms 延时不会溢出,但delay_ms(20)循环 20 次就会让nus * fac_us超过长度限制吗?不会,我实际遇到的情况是LOAD在 8MHz 下按 72 装载,导致实际延时比预期长 9 倍,DHT11 的起始信号低电平变成了 180ms,传感器以为总线异常,时序彻底错乱。

解决:先检查板子上有没有焊接晶振,用调试器读RCC->CFGR看当前 PLL 状态。确认了实际主频后,把fac_us改成对应的值:8MHz 改 8,72MHz 保持 72。从那以后我每次拿到新板子,第一件事就是烧一个循环翻转 GPIO 的哑巴程序,用示波器量翻转频率来反推主频对不对,再决定延时参数。

5. 进阶验证与优化:让温湿度数据真正可信

5.1 用串口打印原始位流,把时序问题变成可视化数据

DHT11 驱动跑通只是第一步,数据到底准不准,很多人是靠“看湿度合不合理”来猜的。更可靠的做法是把读取到的 5 个字节和校验结果直接通过串口打印出来,用串口助手观察每次读取的完整位流:

// 串口调试代码示意 uint8_t buf[5]; ret = DHT11_Read_Raw(buf); // 读取原始 5 字节 if (ret == 0) { printf("raw: %02X %02X %02X %02X %02X\r\n", buf[0], buf[1], buf[2], buf[3], buf[4]); printf("temp=%d.%d humi=%d.%d\r\n", buf[2], buf[3], buf[0], buf[1]); } else { printf("read fail, ret=%d\r\n", ret); }

如果raw里某一位固定在 0 或者 1,说明位解析采样点偏移;如果校验和偶尔不通过,说明供电或接触问题;如果ret频繁为 3,说明超时保护被触发,硬件连接有断点。串口打印还能帮你确认一件事:DHT11 的小数字节到底支不支持。多数模块的 buf[1] 和 buf[3] 恒为 0,如果你买的模块小数字节能读出非 0 值,那就是带小数精度的版本,格式化时不用丢掉。

5.2 滑动平均滤波:让显示值稳定下来

湿度跳变在纯机械环境下也会出现,比如空调出风口突然吹到传感器。做一次简单的滑动平均,能显著改善显示体验。取最近 5 次有效读数求平均:

#define FILTER_N 5 static uint8_t humi_buf[FILTER_N]; static uint8_t temp_buf[FILTER_N]; static uint8_t filter_index = 0; // 在读取成功后调用 void Filter_Push(uint8_t humi, uint8_t temp) { humi_buf[filter_index] = humi; temp_buf[filter_index] = temp; filter_index = (filter_index + 1) % FILTER_N; } uint8_t Filter_Get_Humi(void) { uint16_t sum = 0; uint8_t i; for (i = 0; i < FILTER_N; i++) sum += humi_buf[i]; return (uint8_t)(sum / FILTER_N); }

注意滑动平均会让响应变慢,传感器周围温度突变时,显示值要 5 个采样周期才能跟到位。采样周期是 2 秒,也就是约 10 秒的滞后。如果项目对响应速度有要求,滤波窗口可以缩小到 3 次,或者只在湿度波动超过 5% 时才启用滤波,平时直接显示原始值。

5.3 把调试习惯焊在项目里

这个项目做完后,我对嵌入式调试有了一个固定习惯:任何新外设驱动,第一版代码一定只做一件事——把原始数据用串口或屏幕打出来,不做任何格式化处理。DHT11 就先打印 5 个字节和校验结果,OLED 就先画满屏测试点,确认底层没有问题了,再把逻辑叠加上去。从那以后我每次拿到一块新开发板,都会先跑一遍这种“裸数据验证”流程,把协议层和业务层分开调试,再也没被“规格明明对但就是跑不通”的玄学问题耽误过。希望这个项目能帮你把同样的一步走扎实。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询