简介本资源是一套基于STM32F1系列微控制器实现温湿度监测与OLED显示的完整嵌入式开发工程面向嵌入式初学者、电子类课程设计学生及STM32入门开发者解决传感器数据采集、I2C/OLED驱动、单总线通信DHT11及软硬件协同调试等典型实践问题。压缩包共205个文件含36个C源码如stm32f10x_i2c.c、stm32f10x_adc.c、36个头文件.h、38个编译中间文件.o/.d、37个代码覆盖率报告.crf以及Keil工程文件.uvprojx/.uvoptx、启动脚本、Hex固件与调试配置等整体7.67MB结构完整覆盖从CubeMX初始化到应用层显示逻辑的全链路代码。已有672人学习下载资源提供可直接编译运行的工程框架、DHT11时序解析与校验实现、OLED I2C驱动封装、异常处理机制及典型硬件连接说明助读者快速掌握传感器接口开发与嵌入式人机交互实现。1. 为什么用 STM32 DHT11 做温湿度检测反而比直接买成品模块更稳、更可控你是不是也试过插上某款“即插即用”的USB温湿度仪软件一打开就报错驱动不兼容或者在嵌入式项目里把现成的串口屏模块接上去结果发现它内部温湿度芯片采样周期固定、数据无法校准、断电后历史记录全丢——这恰恰是“黑匣子方案”最典型的翻车现场。而基于 STM32 和 DHT11 的温湿度检测显示本质不是“从零造轮子”而是把感知层、处理层、显示层全部握在自己手里你能精确控制 DHT11 的时序握手哪怕它玄学能用 STM32 的 ADC 或 GPIO 精确标定传感器偏移能把采集值实时打到 OLED 屏上还能顺手加个按键切换摄氏/华氏、存进 Flash 做掉电保存、甚至通过 UART 把原始波形发给上位机做时序诊断。这不是复古情怀是当项目进入调试深水区时你唯一能伸手进去拧螺丝的地方。适合正在做课程设计、毕业设计或工业边缘节点原型的开发者——尤其当你需要确认“到底是传感器坏了还是我的延时写错了”而不是对着成品模块的说明书干瞪眼。2. 从原理到选型为什么是 DHT11 而不是 DHT22为什么是 STM32F103C8T6DHT11 和 DHT22 都是单总线数字温湿度传感器但它们的底层协议、精度、响应速度和抗干扰能力差异极大直接决定你后续代码能不能跑通、要不要反复改延时。很多新手一上来就抄“DHT22 STM32”例程结果在 F103 上死在第 40 个 bit 的高电平超时——因为 DHT22 要求微秒级精准延时典型高电平持续 70μs ± 5μs而标准库 SysTick 定时器在 72MHz 主频下最小分辨率为 ~13.9ns看似够用但实际调度开销、中断嵌套、编译器优化都会让裸延时漂移。DHT11 则宽松得多它的数据位高电平持续时间为 70μs逻辑0或 28μs逻辑1低电平为 50μs整个帧长仅约 4ms对时序容忍度高一个数量级。更重要的是DHT11 输出的是整数型温湿度湿度 20–90% RH温度 0–50℃无小数位非常适合资源受限的 Cortex-M3 内核如 STM32F103C8T6做快速解析避免浮点运算拖慢主循环。再看 MCU 选型STM32F103C8T6 是“蓝 pill”开发板的核心芯片48MHz/72MHz 可配、64KB Flash、20KB RAM、2×SPI、3×USART、1×I²C、12-bit ADC ×16 —— 它不是性能最强的但却是生态最稳、资料最多、引脚复用冲突最少、且 GPIO 翻转速度足以满足 DHT11 单总线协议的黄金平衡点。实测中用GPIO_ResetBits()GPIO_SetBits()控制 PB1 做 DHT11 DATA 线在 Keil MDK 下开启 O2 优化后高低电平切换延迟稳定在 1.2μs 内完全覆盖 DHT11 所需的 ≥ 80μs 低电平启动信号与 ≥ 80μs 高电平响应窗口。相比之下某些国产 RISC-V MCU 虽然主频更高但 GPIO 寄存器操作未做位带映射一次置位需读-改-写耗时达 3~5μs极易触发 DHT11 的“无响应”错误。提示DHT11 的供电电压必须严格控制在 3.3V5.5V但其输出电平为 VDD 相关。若 STM32 使用 3.3V 供电DHT11 必须也接 3.3V不能直连 5V否则高电平可能超过 STM32 GPIO 输入耐压通常为 3.6V。实测中有开发者用 5V 供电 DHT11 后接 10kΩ 上拉至 3.3V结果在高温高湿环境下出现间歇性通信失败——根本原因是上拉电阻导致上升沿变缓DHT11 内部比较器误判逻辑电平。正确做法是DHT11 与 STM32 共用同一组 3.3V LDO如 AMS1117-3.3DATA 线直连无需上拉DHT11 内部已集成 5.1kΩ 上拉。2.1 电路连接三线制接法与关键滤波设计DHT11 为 4 引脚封装VCC, DATA, NC, GND其中 NC 悬空。实际仅需连接三根线DHT11 引脚接 STM32 引脚说明VCC3.3V非 5V必须由 STM32 板载 3.3V LDO 供电禁用 USB 5V 直供DATAPB1任意可复用推挽输出输入的 GPIO建议避开 JTAG/SWD 复用引脚如 PA13/PA14防止调试时冲突GNDGND必须与 STM32 共地不可经长导线分地关键滤波设计常被忽略的翻车点DHT11 对电源噪声极其敏感。实测中当 STM32 驱动 OLED 屏SSD1306或继电器动作时DHT11 常返回 0x0000 或 0xFF00 类错误码。根本原因是开关噪声通过共地阻抗耦合进传感器供电回路。解决方案是在 DHT11 的 VCC-GND 之间并联两级滤波100nF X7R 陶瓷电容贴片 0603就近焊接在 DHT11 底部10μF 钽电容耐压 6.3VESR 1Ω位置紧邻 DHT11注意该滤波电容必须焊在 DHT11 本体引脚处而非 STM32 开发板的 3.3V 排针上。曾有某高校课程设计组因将电容焊在板端导致 30% 样机在实验室空调启停瞬间失联排查三天才发现是地弹噪声未被本地去耦。2.2 协议时序精解DHT11 的“握手”为什么必须手撕不能靠库DHT11 采用单总线异步通信无时钟线所有时序均由主机STM32发起并严格控制。整个交互分为四阶段主机启动 → 传感器响应 → 数据传输 → 校验结束。其中最容易出错的是前两阶段的微秒级窗口匹配。阶段1主机启动关键STM32 必须先将 DATA 线拉低 ≥ 18ms典型值 20ms再拉高 20–40μs我们取 30μs此时 DHT11 才开始准备响应。若拉低时间 18msDHT11 视为无效请求若拉高时间 40μsDHT11 可能误判为下一帧起始。阶段2传感器响应玄学高发区DHT11 收到有效启动信号后会将 DATA 拉低 80μs 作为响应再拉高 80μs 表示“我准备好了”。注意这个 80μs 高电平不是数据位而是“准备就绪”标志。很多初学者在此处直接开始读数据导致后续所有 bit 解析错位。阶段3数据传输8bit × 4 32bit每位数据以 50μs 低电平起始随后是可变高电平若高电平 ≈ 28μs → 逻辑 0若高电平 ≈ 70μs → 逻辑 1每个 bit 总宽 ≈ 120μs相邻 bit 间隔 ≥ 50μs。阶段4校验最后 8bit 为前 32bit 的字节和低 8bit用于验证完整性。例如[RH_H][RH_L][T_H][T_L]四字节相加后取 0xFF应等于校验字节。提示DHT11 的时序参数允许 ±10% 容差但实测发现其内部 RC 振荡器受温度影响显著——在 0℃ 环境下其响应高电平可能缩短至 65μs在 45℃ 下则延长至 85μs。因此硬编码延时如for(i0;i100;i);必然失效。必须使用 SysTick 或定时器做基准且在初始化后执行一次“时序自适应校准”发送启动信号后用输入捕获测量 DHT11 返回的 80μs 响应高电平实际宽度据此动态调整后续数据位的采样窗口。3. Keil MDK 工程搭建从新建工程到 DHT11 初始化函数落地我们以标准外设库Standard Peripheral Library v3.5为基础在 Keil MDK-ARM v5.37 环境下构建最小可行工程。不使用 HAL 库因其抽象层引入额外中断延迟对 DHT11 这类时序敏感器件反而增加不确定性。3.1 创建工程与基础配置新建 uVision 工程Device 选择STM32F103C8在Manage Project Items中勾选CMSIS → CORE、Device → Startup、StdPeriph Drivers → Framework添加以下源文件到工程stm32f10x_conf.h启用#define USE_STDPERIPH_DRIVERstm32f10x_it.c保留空SysTick_Handlermain.c主逻辑dht11.c/dht11.h自定义驱动在Options for Target → C/C → Define中添加USE_STDPERIPH_DRIVER, STM32F10X_MD关键编译选项设置Optimization→ Level 2O2平衡速度与体积Misc Controls→--c99支持 C99 语法Plain char is→UnsignedDHT11 数据为无符号字节避免符号扩展错误3.2 DHT11 驱动核心手写时序控制函数dht11.h中定义关键结构与宏#ifndef __DHT11_H #define __DHT11_H #include stm32f10x.h // DHT11 连接引脚定义可按需修改 #define DHT11_PORT GPIOB #define DHT11_PIN GPIO_Pin_1 #define DHT11_CLK RCC_APB2Periph_GPIOB // 时序参数单位微秒基于 72MHz SysTick #define DHT11_START_LOW_US 20000 // 主机拉低 20ms #define DHT11_START_HIGH_US 30 // 主机拉高 30us #define DHT11_RESPONSE_LOW_US 80 // 传感器拉低 80us #define DHT11_RESPONSE_HIGH_US 80 // 传感器拉高 80us #define DHT11_BIT_LOW_US 50 // 每位起始低电平 50us #define DHT11_BIT_0_HIGH_US 28 // 逻辑0高电平 28us #define DHT11_BIT_1_HIGH_US 70 // 逻辑1高电平 70us typedef struct { uint8_t humidity_h; // 湿度高字节整数部分 uint8_t humidity_l; // 湿度低字节小数部分DHT11恒为0 uint8_t temp_h; // 温度高字节整数部分 uint8_t temp_l; // 温度低字节小数部分DHT11恒为0 uint8_t checksum; // 校验和 } DHT11_Data_TypeDef; uint8_t DHT11_Read_Data(DHT11_Data_TypeDef *data); void DHT11_Init(void); #endifdht11.c中实现核心时序控制关键用 SysTick 做微秒级延时#include dht11.h #include core_cm3.h // 用于 SysTick static __IO uint32_t uwTimingDelay 0; // SysTick 中断服务函数仅用于延时不处理其他逻辑 void SysTick_Handler(void) { if (uwTimingDelay ! 0x00) { uwTimingDelay--; } } // 微秒级延时SysTick 配置为 1us 基准 void Delay_us(__IO uint32_t nTime) { uwTimingDelay nTime; while (uwTimingDelay ! 0); } // 初始化 DHT11 引脚为推挽输出默认高电平 void DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin DHT11_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, GPIO_InitStructure); // 初始状态DATA 线为高电平DHT11 内部上拉生效 GPIO_SetBits(DHT11_PORT, DHT11_PIN); } // 主机启动拉低20ms → 拉高30us static uint8_t DHT11_Start(void) { // 1. 拉低至少 18ms GPIO_ResetBits(DHT11_PORT, DHT11_PIN); Delay_us(DHT11_START_LOW_US); // 2. 拉高 20~40us GPIO_SetBits(DHT11_PORT, DHT11_PIN); Delay_us(DHT11_START_HIGH_US); // 3. 切换为输入模式等待传感器响应 GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_PORT, GPIO_InitStructure); // 4. 检测传感器拉低 80us 响应超时 100us uint32_t timeout 0; while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) (timeout 100)); if (timeout 100) return 1; // 无响应 // 5. 等待传感器拉高 80us超时 100us timeout 0; while (!GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) (timeout 100)); if (timeout 100) return 1; // 响应异常 return 0; // 成功 } // 读取单个 bit在传感器拉高后延时 40us 采样 static uint8_t DHT11_Read_Bit(void) { uint32_t timeout 0; // 等待低电平结束即位起始 while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) (timeout 100)); if (timeout 100) return 0xFF; // 延时 40us进入高电平采样窗口 Delay_us(40); // 读取此时电平高则为1低则为0 if (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)) { // 再延时 30us确保跨过逻辑1的70us高电平避免误判 Delay_us(30); return 1; } else { // 逻辑0高电平仅28us40us后已回落此处应为低 return 0; } } // 主读取函数 uint8_t DHT11_Read_Data(DHT11_Data_TypeDef *data) { uint8_t i, j; uint8_t buf[5]; // 存储 RH_H, RH_L, T_H, T_L, Checksum // 1. 发送启动信号 if (DHT11_Start() ! 0) return 1; // 2. 读取 40bit 数据5字节 for (i 0; i 5; i) { buf[i] 0; for (j 0; j 8; j) { buf[i] 1; buf[i] | DHT11_Read_Bit(); } } // 3. 校验 uint8_t sum buf[0] buf[1] buf[2] buf[3]; if (sum ! buf[4]) return 2; // 校验失败 // 4. 赋值 >DHT11_Init(); Delay_ms(200); // 注意此为毫秒级延时需另行实现如 SysTick 1ms 中断4.2 现象在 OLED 显示刷新时DHT11 返回全 0xFF 或全 0x00原因OLEDSSD1306SPI 通信期间SCK 线高频翻转产生强电磁干扰耦合进 DHT11 的 DATA 线。尤其当 OLED 与 DHT11 共用同一块 PCB 且走线平行超过 2cm 时干扰强度足以让 DHT11 误触发。解决物理隔离DHT11 DATA 线走线远离 OLED SPI 信号线≥5mm并在 DHT11 附近铺铜接地时序错峰在DHT11_Read_Data()前关闭 OLED 刷新中断若使用 DMA 刷新则暂停 DMA 请求软件加固读取失败时自动重试 2 次且每次重试间隔 ≥ 1sDHT11 最小响应间隔为 1s。4.3 现象在高温35℃或高湿85%RH环境下读数跳变剧烈如 25→0→45℃ 乱跳原因DHT11 的感湿元件为湿敏电阻聚合物高温高湿下阻值变化非线性加剧且内部 ADC 参考电压受温漂影响。出厂校准值存储在 OTP 中在极端环境失效。解决实施两点温度校准无需拆芯片在 25℃ 恒温箱中读取 DHT11 值T25在 50℃ 恒温箱中读取T50计算线性校准系数T_real T25 (T_read - T25) * (50-25)/(T50-T25)将系数存入 STM32 Flash 的最后一页需解锁 Flash 编程开机时加载。4.4 现象使用 J-Link 调试时DHT11 通信完全中断原因JTAG/SWD 调试接口与 DHT11 共用 PA13/PA14SWDIO/SWCLK或 PB3/PB4JTDI/JTDO引脚调试器在后台频繁读取寄存器导致 GPIO 状态被意外修改。解决硬件层面将 DHT11 DATA 线坚决避开所有调试复用引脚查《STM32F103xx Datasheet》Table 9 “Alternate function mapping”软件层面在DHT11_Read_Data()前添加// 禁用 SWD 调试仅限调试阶段量产时删除 AFIO-MAPR ~AFIO_MAPR_SWJ_CFG; AFIO-MAPR | 0x02; // SWJ_CFG 010: JTAG-DP Disabled and SW-DP Enabled并确保DHT11_PORT未被调试器占用。提示以上四坑均有对应示波器波形证据。例如坑4用 Saleae Logic Pro 8 抓取 PA13 波形可见 J-Link 每 200ms 发送一次IDCODE指令导致 PA13 瞬间拉低 150ns恰好打断 DHT11 的 80μs 响应高电平造成握手失败。这不是玄学是电磁兼容EMC的硬约束。5. 实时显示与工程闭环把 DHT11 数据刷到 OLED 屏上并加入掉电保存光有传感器读取还不够真正的闭环是让用户“看见”数据、“信任”数据、“追溯”数据。我们选用 0.96 英寸 SSD1306 OLEDI²C 接口因其功耗低、对比度高、无需背光驱动且 I²C 协议比 SPI 更少干扰 DHT11。关键在于显示不能抢走 DHT11 的 CPU 时间历史数据必须在断电前安全落盘。5.1 OLED 驱动精简策略DMA 双缓冲防撕裂SSD1306 分辨率为 128×64共 1024 字节显存。若每次刷新都用 CPU 逐字节写 I²C需约 120ms按 100kHz I²C 计算远超 DHT11 的 1s 最小间隔导致传感器被饿死。解决方案是用 DMA 搬运显存CPU 只负责更新双缓冲中的前台帧。// oled.h 中定义双缓冲 #define OLED_BUFFER_SIZE 1024 extern uint8_t OLED_Buffer_A[OLED_BUFFER_SIZE]; // 前台缓冲DMA 正在刷 extern uint8_t OLED_Buffer_B[OLED_BUFFER_SIZE]; // 后台缓冲CPU 更新 // oled.c 中初始化 I²C DMA void OLED_Init_DMA(void) { I2C_InitTypeDef I2C_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // I2C1 配置为标准模式100kHz I2C_InitStructure.I2C_ClockSpeed 100000; I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress1 0x00; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); // DMA1 Channel6 用于 I2C1 TX需查《RM0008》Table 42 DMA_DeInit(DMA1_Channel6); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(I2C1-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)OLED_Buffer_A; // 初始指向 A DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize OLED_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel6, DMA_InitStructure); // 使能 DMA 传输完成中断用于缓冲区切换 DMA_ITConfig(DMA1_Channel6, DMA_IT_TC, ENABLE); NVIC_EnableIRQ(DMA1_Channel6_IRQn); }关键技巧在DMA1_Channel6_IRQHandler()中收到 TCTransfer Complete中断后立即切换 DMA 目标地址到另一缓冲区并触发 OLED 刷新命令void DMA1_Channel6_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC6) ! RESET) { // 切换缓冲区指针 if (DMA_GetCurrDataCounter(DMA1_Channel6) 0) { // 当前刷的是 Buffer_A则下次刷 Buffer_B DMA_SetMemoryBaseAddr(DMA1_Channel6, (uint32_t)OLED_Buffer_B); } else { DMA_SetMemoryBaseAddr(DMA1_Channel6, (uint32_t)OLED_Buffer_A); } // 发送 OLED 刷新指令写入显存起始地址 OLED_Write_Cmd(0x20); // Set Memory Addressing Mode OLED_Write_Cmd(0x00); // Horizontal Addressing Mode OLED_Write_Cmd(0x21); // Set Column Address OLED_Write_Cmd(0x00); // Start column 0 OLED_Write_Cmd(0x7F); // End column 127 OLED_Write_Cmd(0x22); // Set Page Address OLED_Write_Cmd(0x00); // Start page 0 OLED_Write_Cmd(0x07); // End page 7 DMA_ClearITPendingBit(DMA1_IT_TC6); } }这样CPU 可在任意时刻安全地往OLED_Buffer_B写文本如OLED_ShowString(2,2,Temp: 25C);而 DMA 在后台静默刷OLED_Buffer_A完全不抢占 DHT11 的时序窗口。5.2 掉电保存用 STM32F103 的最后 1KB Flash 存历史极值DHT11 本身无存储能力但用户常需知道“今天最高温多少度”。我们利用 STM32F103C8T6 的 64KB Flash将最后 1KB地址0x0800FC00–0x0800FFFF划为数据区存储max_tempuint16_t单位 0.1℃min_tempuint16_tmax_humiduint16_t单位 0.1%RHmin_humiduint16_tlast_update_timeuint32_tUnix 时间戳需外接 RTC 或用 SysTick 模拟Flash 操作要点Flash 写入前必须擦除整页1KB且擦除会清零所有位写入只能将 1→0不能 0→1故需用“标记位”管理有效数据为防突然断电采用“双页备份 原子写入”策略Page A 与 Page B 轮流使用。// flash_data.h #define FLASH_DATA_PAGE_A ((uint32_t)0x0800FC00) #define FLASH_DATA_PAGE_B ((uint32_t)0x0800F800) typedef struct { uint16_t max_temp; // ×10 uint16_t min_temp; // ×10 uint16_t max_humid; // ×10 uint16_t min_humid; // ×10 uint32_t last_update; // Unix timestamp uint8_t valid_flag; // 0xAA 表示有效 } Flash_Data_TypeDef; void Flash_Save_Data(const Flash_Data_TypeDef *data); void Flash_Load_Data(Flash_Data_TypeDef *data);Flash_Save_Data()实现核心逻辑省略擦除细节聚焦原子性void Flash_Save_Data(const Flash_Data_TypeDef *data) { FLASH_Status status; uint32_t addr; Flash_Data_TypeDef backup; // 1. 读取当前有效页假设 Page A 有效 if (*(uint8_t*)(FLASH_DATA_PAGE_A sizeof(Flash_Data_TypeDef)) 0xAA) { addr FLASH_DATA_PAGE_A; } else { addr FLASH_DATA_PAGE_B; } // 2. 将新数据写入另一页保证原页仍有效 uint32_t new_addr (addr FLASH_DATA_PAGE_A) ? FLASH_DATA_PAGE_B : FLASH_DATA_PAGE_A; // 擦除新页必须 FLASH_ErasePage(new_addr); // 写入数据按字写入每字需解锁/锁定 FLASH_Unlock(); status FLASH_ProgramHalfWord(new_addr, style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />