ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F1驱动DHT11温湿度传感器:从时序原理到稳定采集实战

STM32F1驱动DHT11温湿度传感器:从时序原理到稳定采集实战 1. 为什么STM32F1到今天还值得花时间学1.1 一颗老芯片的生存逻辑STM32F1系列是ST在2007年前后推出的基于ARM Cortex-M3内核的32位微控制器代表作STM32F103C8T6也就是圈子里常说的蓝板或最小系统板。十几年过去了市面上F4、F7、H7、G0、G4各种新系列层出不穷但F1的出货量和教程数量依然稳居前列。原因很实在便宜、资料多、够用。一块STM32F103C8T6核心板零售价通常在十元上下72MHz主频、64KB Flash、20KB SRAM、37个GPIO、3个USART、2个SPI、2个I2C、2个12位ADC、4个16位定时器还带CAN和USB。这个配置放在今天做温湿度采集、继电器控制、串口屏驱动、小型数据记录仪绰绰有余。很多工业现场的板子跑了十年还在服役替换成本高维护需求一直存在。我个人的判断是如果你要入门嵌入式F1是最不容易劝退的起点如果你要做量产项目F1是BOM成本最容易压下来的选择之一。这两个理由足够支撑你认真对待它。1.2 从点灯到读温湿度的典型路径绝大多数人接触F1的第一件事是点灯第二件事是串口打印第三件事就是接传感器。而DHT11温湿度传感器几乎是绕不开的一站——它便宜几块钱、单总线协议简单、数据直观非常适合用来练手GPIO时序控制和微秒级延时。但恰恰是这个简单的传感器坑了不少人。网上大量教程直接给一段代码让你复制能跑就完事跑不通就换传感器。实际上DHT11读不出来的原因可能出在延时精度、上拉电阻、供电电压、时序容错、甚至编译器优化等级上。这篇文章我就围绕STM32F1驱动DHT11这条主线把背后的原理、实操细节、踩坑经验完整拆一遍同时把F1开发中那些通用的底层知识串起来讲透。适合谁看刚上手F1、想搞明白GPIO和延时到底怎么回事的初学者做过一些项目但对时序敏感器件心里没底的中级开发者以及需要快速复现一套稳定温湿度采集方案的人。2. 整体设计思路与方案选型2.1 为什么选DHT11而不是DHT22或SHT30先做个横向对比把选型逻辑说清楚。传感器温度范围/精度湿度范围/精度接口采样率单价区间DHT110~50°C / ±2°C20~90%RH / ±5%RH单总线1Hz2~5元DHT22-40~80°C / ±0.5°C0~100%RH / ±2%RH单总线0.5Hz15~30元SHT30-40~125°C / ±0.3°C0~100%RH / ±2%RHI2C可调10~20元DHT11的精度确实一般湿度±5%RH在精密场合不够看。但它有两个无法替代的优势一是单总线协议只需要一根数据线不占用I2C或SPI外设资源二是价格极低适合对成本敏感的批量产品。如果你的项目只是做室内环境监测、农业大棚粗略监控、或者教学演示DHT11完全够用。要精度就上SHT30要宽温区就上DHT22这是我一贯的建议。2.2 单总线协议的时序本质DHT11用的是自定义的单总线协议数据线在空闲时被上拉电阻拉高。通信过程分三步主机发送起始信号、从机响应、从机发送40位数据。起始信号主机把数据线拉低至少18ms然后拉高20~40us等待从机响应。从机检测到起始信号后拉低数据线80us表示响应再拉高80us准备发送数据。数据位格式每一位数据都以50us低电平开始随后高电平的持续时间决定数据是0还是1。高电平持续26~28us表示0持续70us表示1。40位数据依次是湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。校验和等于前四个字节相加取低8位。这里的关键点在于判断0和1靠的是高电平持续时间而26us和70us的差距需要微秒级的测量精度。这就是为什么延时函数和GPIO读取速度直接决定成败。2.3 硬件连接方案接线本身很简单但细节决定稳定性。VCC接3.3V或5V都可以DHT11工作电压3.3~5.5V。我一般接3.3V和F1的IO电平一致省去电平转换的顾虑。GND共地这个不用多说。DATA接任意GPIO我习惯用PA0或PB12这类方便接线的引脚。上拉电阻DHT11模块通常自带4.7k~10k上拉电阻如果你买的是裸传感器4针或3针必须在DATA和VCC之间外接一个4.7k~10k的电阻。我实测4.7k响应更快10k在长导线时抗干扰稍好。注意如果你用的是裸传感器且没加上拉电阻数据线会浮空读出来的全是0或者直接超时。这是新手最常犯的错误之一。3. 核心细节解析与实操要点3.1 GPIO模式切换开漏与推挽的取舍单总线器件的GPIO配置是第一个容易出错的地方。DHT11的DATA线是双向的主机发送起始信号时要输出接收数据时要输入。所以代码里必须动态切换GPIO方向。在STM32F1的标准外设库或HAL库里有两种做法第一种是切换输入/输出模式。发送起始信号时配置为推挽输出接收时切换为浮空输入或上拉输入。这种做法直观但每次切换都要调用初始化函数有额外开销。第二种是配置为开漏输出配合外部上拉电阻。开漏模式下输出0时拉低输出1时释放总线由上拉电阻拉高同时可以直接读取引脚电平。这样不用切换模式代码更简洁。我推荐第二种前提是外部有上拉电阻。用HAL库配置开漏输出的关键代码GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);读取时直接调HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0)即可开漏模式下读的是引脚实际电平。3.2 微秒级延时SysTick还是DWTDHT11的时序要求微秒级精度HAL库自带的HAL_Delay()是毫秒级不够用。常见方案有三种方案一SysTick定时器。配置SysTick每1us中断一次在中断里累加计数。缺点是中断频繁1us一次中断会占用大量CPU时间影响其他任务。方案二DWTData Watchpoint and Trace。Cortex-M3内核自带DWT周期计数器可以直接读取CPU运行的周期数配合系统时钟频率换算出微秒。这个方案精度最高不占中断资源。F1的SystemCoreClock通常是72MHz1us等于72个周期。// DWT初始化 void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } // 微秒延时 void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }方案三简单的for循环空转。通过实测校准循环次数。这种方案移植性差换个优化等级就失效不推荐。我一般用DWT方案精度稳定代码量小。但要注意DWT在调试器连接时才能用脱机运行需要确保DEMCR寄存器的TRCENA位被正确设置这个在初始化里已经处理了。3.3 时序容错别把延时卡得太死DHT11的时序虽然规定了典型值但实际器件有±10%~20%的偏差。如果你把延时写成精确的26us判断阈值遇到偏差大的传感器就会误判。我的做法是判断数据位时先等待低电平结束高电平到来然后延时约40us再采样。因为0的高电平是26~28us40us时已经变低1的高电平是70us40us时还是高。这样采样点落在中间容错空间大。// 等待低电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET); // 延时40us后采样 delay_us(40); uint8_t bit HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); // 等待高电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_SET);这个40us的采样点是我反复实测后确定的比精确测量高电平宽度更稳。实操心得加超时机制。如果等待电平变化时超过一定时间比如100us还没变化直接返回错误。否则传感器坏了或者没接好程序会死循环卡住。4. 完整实操流程与代码实现4.1 工程搭建与时钟配置我用STM32CubeMX生成基础工程芯片选STM32F103C8T6时钟配置为外部晶振8MHz经PLL倍频到72MHz。这个配置是F1的经典满速运行方案APB1为36MHzAPB2为72MHz。GPIO配置PA0为开漏输出、上拉、高速。USART1配置为115200波特率用于打印温湿度数据。SysTick保持默认1ms中断。生成代码后把DWT初始化加到main函数开头。注意DWT初始化必须在SystemClock_Config之后因为要用到SystemCoreClock变量。4.2 DHT11驱动完整代码下面是我实际项目中用的驱动经过多个批次传感器验证。#include dht11.h #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 // 微秒延时DWT方案 static void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); } // 设置GPIO为输出开漏 static void set_output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } // 设置GPIO为输入上拉 static void set_input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } // 读取一个字节 static uint8_t read_byte(void) { uint8_t byte 0; for (int i 0; i 8; i) { // 等待低电平结束 uint32_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET) { if (timeout 1000) return 0; } delay_us(40); byte 1; if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { byte | 1; } // 等待高电平结束 timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 1000) return 0; } } return byte; } // 读取温湿度 uint8_t DHT11_Read(float *temp, float *humi) { uint8_t data[5] {0}; // 发送起始信号 set_output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); delay_us(30); set_input(); // 等待从机响应 uint32_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 1000) return 1; } timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET) { if (timeout 1000) return 2; } timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 1000) return 3; } // 读取40位数据 for (int i 0; i 5; i) { data[i] read_byte(); } // 校验 if (data[4] ! (uint8_t)(data[0] data[1] data[2] data[3])) { return 4; } *humi data[0] data[1] * 0.1f; *temp data[2] data[3] * 0.1f; return 0; }4.3 主循环与串口输出主函数里每2秒读一次通过串口打印。DHT11采样率是1Hz读太快没意义。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Init(); float temp, humi; char buf[64]; while (1) { uint8_t ret DHT11_Read(temp, humi); if (ret 0) { int len snprintf(buf, sizeof(buf), Temp: %.1f C, Humi: %.1f %%RH\r\n, temp, humi); HAL_UART_Transmit(huart1, (uint8_t*)buf, len, 100); } else { int len snprintf(buf, sizeof(buf), DHT11 error: %d\r\n, ret); HAL_UART_Transmit(huart1, (uint8_t*)buf, len, 100); } HAL_Delay(2000); } }4.4 参数计算过程说明DWT延时里的SystemCoreClock / 1000000当SystemCoreClock为72000000时结果是72即1us对应72个CPU周期。这个计算在编译时完成不占运行时间。起始信号拉低时间用HAL_Delay(20)20ms大于手册要求的18ms留了余量。拉高后延时30us落在20~40us的推荐区间内。数据位采样点选40us前面解释过是为了避开0和1的高电平边界。如果你用的传感器高电平时间偏短可以适当减小到35us偏长则加到45us。这个值需要根据实际传感器微调。5. 常见问题与排查技巧实录5.1 读出来全是0或者校验失败这是最高频的问题。排查顺序如下现象可能原因排查方法完全无响应超时返回1上拉电阻缺失、接线错误、供电不足万用表测DATA线空闲时是否为高电平响应后数据全0延时精度不够、采样点错误用示波器看DATA波形确认高电平宽度校验和偶尔失败时序抖动、电源纹波增加读取间隔VCC并0.1uF电容温度正常湿度异常传感器受潮或老化更换传感器对比我遇到最多的是上拉电阻问题。很多人买的是三针模块以为自带上拉实际上有些廉价模块省掉了这个电阻。用万用表量一下DATA对VCC的电阻如果是无穷大就得自己加。5.2 编译器优化导致的时序错乱这个坑很隐蔽。当你把优化等级调到-O2或-O3时编译器可能把delay_us里的循环优化掉或者重排GPIO读写顺序导致时序完全乱掉。解决办法有两个一是把delay_us函数用__attribute__((optimize(O0)))修饰禁止优化二是把DWT计数变量声明为volatile。我一般两个都做保险。__attribute__((optimize(O0))) static void delay_us(uint32_t us) { volatile uint32_t start DWT-CYCCNT; volatile uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }注意DWT-CYCCNT本身是硬件寄存器读取时不会被优化但中间的算术运算可能被重排。加volatile是防止编译器假设变量不变。5.3 中断干扰导致的偶发失败如果你的系统里有其他中断比如串口接收中断、定时器中断在DHT11通信的几毫秒内如果发生中断时序就会被拉长导致读取失败。我的处理方式是在DHT11_Read函数开头关全局中断读完再开。DHT11一次通信大约4~5ms关中断这么久对大多数应用可以接受。如果系统对中断实时性要求高那就把DHT11读取放在低优先级任务里或者用状态机方式分步执行。__disable_irq(); uint8_t ret DHT11_Read(temp, humi); __enable_irq();5.4 长导线导致的信号劣化传感器离主板超过半米时导线电容和干扰会让波形边沿变缓读出来的数据错误率上升。解决办法缩短导线、用屏蔽线、降低上拉电阻到4.7k、在传感器端VCC和GND之间并一个0.1uF陶瓷电容。我实测导线1米以内用4.7k上拉基本没问题超过2米就建议改用I2C的SHT30了。5.5 常见问题速查表错误码含义优先排查1等待从机响应超时上拉电阻、接线、供电2从机响应低电平超时传感器损坏、时序过快3从机响应高电平超时延时精度、中断干扰4校验和错误采样点、电源纹波、导线长度6. 从DHT11延伸出去的F1开发经验6.1 单总线协议的通用套路DHT11只是单总线的一个例子DS18B20温度传感器、WS2812灯带用的也是类似思路。掌握了一套其他的触类旁通。核心就三点GPIO方向切换、微秒级延时、超时保护。把这三点封装成通用函数换个协议只需要改时序参数。我一般会写一个onewire.c把复位、写位、读位、写字节、读字节封装好DHT11和DS18B20共用底层。这样代码复用率高维护也方便。6.2 F1的资源分配建议STM32F103C8T6只有64KB Flash和20KB SRAM资源紧张。我的分配习惯是HAL库占15~20KB驱动代码控制在5KB以内剩下留给应用逻辑。如果用了RTOSFreeRTOS内核大约6~8KB要提前算好。SRAM方面20KB看着少但DHT11这种应用只用了不到1KB。真正吃内存的是缓冲区比如你要做数据记录存1000条温湿度记录每条8字节就是8KB得精打细算。6.3 低功耗场景的处理如果项目是电池供电DHT11的功耗要考虑。它工作时电流约1mA待机约50uA。F1本身可以进Stop模式电流降到几十uA。做法是读一次温湿度然后进Stop模式用RTC定时唤醒每2秒或更久读一次。这样平均电流可以压到100uA以下两节5号电池能撑几个月。但要注意进Stop模式前要把GPIO配置好避免漏电流。DHT11的DATA线在待机时应该保持高电平否则传感器可能被反向供电。6.4 从标准库到HAL库的迁移网上很多F1教程用的是标准外设库而ST现在主推HAL库。两者在GPIO操作上差异不大但HAL库多了初始化和时钟使能的封装。如果你从标准库代码迁移主要改这几个地方GPIO_Init换成HAL_GPIO_InitGPIO_ReadInputDataBit换成HAL_GPIO_ReadPin延时用HAL_Delay或自己实现。整体工作量不大半天能搞定。我个人建议新项目直接用HAL库配合CubeMX生成代码效率高很多。老项目维护就继续用标准库没必要为了迁移而迁移。6.5 调试工具的选择调DHT11这种时序敏感的器件逻辑分析仪比示波器更实用。一个几十块的8通道逻辑分析仪配合开源软件能直接解码单总线协议把每一位的0和1都标出来。我第一次调DHT11就是靠它发现采样点偏了5us。如果没有逻辑分析仪退而求其次用示波器看波形或者用IO翻转法在关键节点翻转一个空闲GPIO用示波器测翻转间隔间接验证延时精度。这个方法土但有效。7. 写在最后的几句实在话DHT11这个传感器说简单也简单说坑也多。我见过太多人卡在读不出来这一步就放弃了其实问题往往就出在上拉电阻、延时精度、中断干扰这三个点上。把这三个点吃透不光DHT11其他时序敏感的器件你也能搞定。STM32F1这颗芯片放在今天看确实老但它的价值不在于性能而在于它是一块透明的芯片——外设简单、手册清晰、社区资料丰富你能把每一个寄存器、每一条时序都搞明白。这种搞明白的感觉是直接上H7或者用Arduino体会不到的。最后分享一个小技巧调时序的时候把关键波形用逻辑分析仪抓下来和手册上的时序图叠在一起对比差多少一目了然。这比盯着代码猜要快十倍。我现在的习惯是任何单总线器件上手第一件事就是抓波形确认时序对了再写业务逻辑能省掉大量返工时间。
返回列表