STM32F1系列在嵌入式圈子里属于那种绕不开的存在。你翻任何一家电子元器件商城F103C8T6这颗料永远在热销榜前列你去任何一个嵌入式学习社区用F1系列入门的人占了大半。原因很直接性能够用、资料够多、价格够低、供货够稳。但资料多这件事是双刃剑——网上关于F1的教程铺天盖地质量却参差不齐很多内容停留在点个灯、串口打印个hello的层面真正到了做项目的时候时钟树配错、中断优先级打架、DMA传输丢数据这些问题照样让人抓耳挠腮。这篇内容我想聊的不是F1怎么入门而是围绕一个非常具体、非常高频的实战场景展开用STM32F1驱动DHT11温湿度传感器并且把这件事做扎实。DHT11这个传感器便宜、好买、接线简单几乎是每个嵌入式新手第一个接触的真实世界传感器。但恰恰因为它简单很多人只停留在能读出数就行的阶段忽略了时序精度、单总线协议的鲁棒性、数据校验、异常重试这些真正决定项目能不能稳定跑下去的东西。如果你正在做环境监测、智能家居节点、农业大棚采集这类项目或者单纯想把F1的GPIO和定时器用明白这篇内容应该能帮你少走不少弯路。1. 为什么DHT11配STM32F1是个看起来简单实则讲究的组合1.1 DHT11到底是个什么样的器件先把DHT11这个东西说清楚。它是一颗集成了电阻式湿敏元件和NTC测温元件的复合传感器内部还带了一个8位单片机做信号处理对外只输出一根数据线上的数字信号。供电范围3.3V到5.5V湿度测量范围20%到90%RH精度±5%RH温度范围0到50摄氏度精度±2摄氏度。采样周期建议不低于1秒实际上 datasheet 里写的是不低于2秒更稳妥。它的通信协议是单总线1-Wire风格但不是标准的Maxim 1-Wire协议而是DHT系列自己的一套时序。一次完整通信大概4毫秒左右主机先拉低总线至少18毫秒作为起始信号然后释放DHT11响应后再连续输出40位数据8位湿度整数、8位湿度小数DHT11恒为0、8位温度整数、8位温度小数同样恒为0、8位校验和。校验和等于前四个字节相加取低8位。关键点在于这40位数据里每一位0和1的区别不是电平高低而是高电平持续的时间长短。高电平持续26到28微秒表示0持续70微秒左右表示1。这就意味着读取这个传感器本质上是一个微秒级的脉宽测量任务而不是简单的GPIO高低电平判断。1.2 为什么很多人用F1读DHT11会翻车我见过太多人用F1读DHT11失败的案例总结下来无非几类原因。第一类是时序精度不够。有人用HAL_Delay()去做微秒级延时这基本等于自杀。HAL_Delay的最小分辨率是1毫秒而DHT11的位判别窗口只有几十微秒差之毫厘谬以千里。正确的做法是用定时器做微秒延时或者用DWTData Watchpoint and Trace周期计数器再或者用NOP空指令循环做粗略延时。第二类是中断干扰。DHT11一次通信虽然只有4毫秒但这4毫秒里如果被SysTick中断或者其他高优先级中断打断时序就乱了。很多人读出来的数据永远是校验失败查半天代码逻辑没问题最后发现是中断在捣乱。第三类是GPIO模式切换不当。单总线要求同一根引脚在输出和输入之间切换。输出时推挽输入时很多人忘了改成浮空或上拉输入导致读不到DHT11拉低总线的动作。第四类是没有做异常处理和重试。DHT11本身不是工业级器件偶尔读失败很正常。如果代码里读一次失败就卡死或者返回错误值整个系统就不稳。1.3 这个组合适合什么样的项目场景DHT11加STM32F1这套组合最适合的是对成本敏感、对精度要求不极端、采样频率不高的场景。比如室内环境监测节点每分钟采集一次温湿度通过串口或者无线模块上报农业大棚的辅助监测点配合土壤湿度传感器一起用教学实验平台用来讲解单总线协议和微秒级时序控制智能家居里的温湿度显示终端配个OLED屏做本地显示如果你的项目需要±0.5摄氏度以内的温度精度或者需要零下几十度的工作范围那DHT11不合适应该考虑SHT30、SHT31或者DS18B20这类器件。选型这件事先看需求再看价格别反过来。2. 硬件连接与GPIO配置里那些容易忽略的细节2.1 接线不是随便插上就行DHT11通常有3针或4针封装。3针的是裸传感器4针的模块板上一般已经焊好了上拉电阻和滤波电容。我强烈建议用模块板原因后面会说。引脚定义VCC接3.3V或5VGND接地DATA接MCU的一个GPIO。如果是4针裸传感器其中一个引脚是NC悬空。这里有个细节DHT11的数据线需要上拉电阻典型值4.7k到10k接到VCC。模块板通常自带了裸传感器就必须自己加。没有上拉电阻的话总线在空闲状态不是稳定的高电平DHT11拉低之后释放线可能浮空读出来的时序完全乱套。我早期用裸传感器的时候就是因为忘了这个上拉调了一下午以为代码有问题。另外数据线的走线长度建议不要超过20厘米太长的话分布电容会影响上升沿导致时序判断出错。如果确实需要延长考虑加缓冲或者降低上拉电阻值但不要低于2.2k否则DHT11拉低时灌电流可能超标。2.2 GPIO模式切换的正确姿势在F1上我一般选一个普通的GPIO比如PA0或者PB12。配置分两个状态输出状态主机拉低总线时模式通用推挽输出速度50MHz足够初始电平高输入状态读取DHT11响应和数据时模式浮空输入或者上拉输入如果外部已经有上拉电阻用浮空输入即可如果没有外部上拉用上拉输入但F1内部上拉约40k偏弱还是建议外部加切换的代码大概是这样// 切换到输出模式 void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStruct; GPIO_InitStruct.GPIO_Pin DHT11_PIN; GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, GPIO_InitStruct); } // 切换到输入模式 void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStruct; GPIO_InitStruct.GPIO_Pin DHT11_PIN; GPIO_InitStruct.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_PORT, GPIO_InitStruct); }注意每次切换都要重新调用GPIO_Init不能只改寄存器某一位就完事因为GPIO_Init会处理CNF和MODE位的组合。当然你也可以直接操作寄存器效率更高但可读性差一些。2.3 微秒延时怎么做才靠谱这是整个项目最核心的技术点之一。F1上做微秒延时常见方案有三种方案一定时器延时。用一个基本定时器比如TIM6或TIM7配置预分频器让计数频率为1MHz也就是每计数一次1微秒。然后写一个delay_us函数启动定时器等待计数到目标值。优点是精确缺点是占用一个定时器而且频繁启停有一定开销。方案二DWT周期计数器。Cortex-M3内核自带DWT可以读取CPU周期数。F1主频72MHz时一个周期约13.9纳秒。用DWT-CYCCNT做延时精度很高不占用外设。但需要先使能DWT而且如果主频变了要重新算。方案三NOP循环。最简单但最不精确。因为编译器优化、Flash等待周期、中断都会影响。如果非要用循环体里加__NOP()并且用volatile变量防止被优化掉。我个人的选择是如果项目里已经有定时器在用就用DWT如果对精度要求极高且不想折腾就用一个专用定时器。NOP循环只适合对时序要求不严格的场合DHT11勉强能用但不够稳。用DWT的初始化代码void DWT_Delay_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); }这里SystemCoreClock是72MHz所以ticks us * 72。注意DWT-CYCCNT是32位溢出周期约59.6秒72MHz下单次延时不会超过这个范围所以不用担心溢出问题。3. 单总线时序的逐段拆解与代码实现3.1 起始信号主机拉低至少18毫秒通信开始时主机把数据线拉低持续至少18毫秒然后释放。DHT11检测到这个低电平后会等待20到40微秒然后拉低总线80微秒作为响应再拉高80微秒之后开始输出数据。代码实现void DHT11_Start(void) { DHT11_SetOutput(); DHT11_LOW(); delay_ms(20); // 拉低20ms留余量 DHT11_HIGH(); delay_us(30); // 主机释放后等30us DHT11_SetInput(); // 切换输入准备读响应 }这里delay_ms(20)比datasheet要求的18ms多2ms是为了留余量。DHT11对起始信号的下限是18ms上限没有严格规定但也不要太长否则传感器可能进入其他状态。3.2 响应信号等待DHT11拉低再拉高主机释放总线后DHT11会在20到40微秒内拉低总线80微秒然后拉高80微秒。我们需要检测这两个电平变化。uint8_t DHT11_WaitResponse(void) { uint32_t timeout 0; // 等待DHT11拉低 while (DHT11_READ() 1) { if (timeout 100) return 0; // 超时 delay_us(1); } timeout 0; // 等待DHT11拉高 while (DHT11_READ() 0) { if (timeout 100) return 0; delay_us(1); } timeout 0; // 等待DHT11再次拉低准备输出数据 while (DHT11_READ() 1) { if (timeout 100) return 0; delay_us(1); } return 1; }超时判断很重要。如果传感器没接好或者坏了没有超时的话程序会死循环在这里。100微秒的超时对于80微秒的响应来说够用但如果你发现经常超时可以适当放宽到150。3.3 40位数据的读取区分0和1的关键每一位数据都以50微秒的低电平开始然后是26到28微秒的高电平表示070微秒的高电平表示1。所以读取逻辑是先等低电平结束然后延时30微秒左右采样如果还是高电平就是1否则是0。uint8_t DHT11_ReadByte(void) { uint8_t byte 0; uint8_t i; for (i 0; i 8; i) { // 等待低电平结束 while (DHT11_READ() 0); delay_us(30); // 延时30us后采样 byte 1; if (DHT11_READ() 1) { byte | 1; // 等待高电平结束 while (DHT11_READ() 1); } } return byte; }这里delay_us(30)的选择有讲究。26到28微秒是0的高电平时间70微秒是1的高电平时间。30微秒正好卡在两者之间采样时0已经变低1还是高。如果你用20微秒可能0还没结束用50微秒1可能已经结束了。30到40微秒是比较安全的窗口。但这里有个隐患while循环等待低电平结束如果传感器故障一直拉低就会死循环。实际项目中应该加超时。我在正式代码里会加一个计数器超过一定次数就返回错误。3.4 校验和与数据组装读完40位后前4个字节是数据第5个是校验和。校验规则是前4字节相加取低8位等于第5字节。uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; DHT11_Start(); if (!DHT11_WaitResponse()) return 0; for (i 0; i 5; i) { buf[i] DHT11_ReadByte(); } // 校验 if (buf[4] ! (buf[0] buf[1] buf[2] buf[3])) { return 0; } *humi buf[0]; *temp buf[2]; return 1; }注意DHT11的小数字节恒为0所以直接取整数部分就行。如果你用的是DHT22AM2302小数字节是有意义的需要组合成16位数据再除以10。4. 让读取稳定下来的工程化处理4.1 中断干扰的规避策略前面提到DHT11通信的4毫秒内如果被中断打断时序就可能出错。F1上默认开启的中断有SysTick1毫秒一次如果你还开了串口接收中断、定时器中断那干扰概率更高。处理方式有几种第一种在读取DHT11前后关中断和开中断。用__disable_irq()和__enable_irq()或者操作PRIMASK寄存器。但这样会影响系统实时性4毫秒的中断关闭对于大多数应用可以接受但如果你的系统有严格的中断响应要求就要慎重。__disable_irq(); ret DHT11_ReadData(temp, humi); __enable_irq();第二种提高DHT11读取任务的优先级或者把读取放在中断之外的主循环里避开高优先级中断的密集期。第三种如果用的是RTOS把DHT11读取放在一个高优先级任务里并且在读取期间挂起其他可能干扰的任务。但RTOS的任务切换本身也可能带来微秒级抖动需要评估。我一般用第一种简单直接。关中断的时间控制在5毫秒以内对绝大多数项目没影响。4.2 重试机制与数据有效性判断DHT11读失败是常态不是异常。我的做法是连续读3次只要有一次成功且数据在合理范围内就采用。如果3次都失败返回上一次的有效值或者标记为无效。uint8_t DHT11_ReadWithRetry(uint8_t *temp, uint8_t *humi) { uint8_t i; for (i 0; i 3; i) { if (DHT11_ReadData(temp, humi)) { // 合理性检查 if (*temp 50 *humi 90 *humi 20) { return 1; } } delay_ms(100); // 重试间隔 } return 0; }合理性检查的范围根据DHT11的规格来温度0到50湿度20到90。超出这个范围的数据即使校验通过也要怀疑可能是时序抖动导致的位错误。4.3 采样周期的控制DHT11的datasheet建议采样周期不低于1秒实际上我建议不低于2秒。因为DHT11内部需要时间完成一次测量和转换如果读得太频繁传感器可能返回上一次的旧数据或者直接不响应。在代码里用一个时间戳记录上次读取的时间主循环里判断距离上次超过2秒才发起新的读取。uint32_t last_read_time 0; void DHT11_Task(void) { if (HAL_GetTick() - last_read_time 2000) return; last_read_time HAL_GetTick(); uint8_t temp, humi; if (DHT11_ReadWithRetry(temp, humi)) { // 更新显示或上报 } }用HAL_GetTick()做时间基准精度1毫秒对于2秒的周期来说绰绰有余。4.4 电源与信号完整性的实战经验有几个硬件层面的坑我踩过值得说一下。第一DHT11的供电电压影响时序。3.3V供电时高电平的上升沿比5V慢如果上拉电阻太大比如10k上升时间可能超过1微秒导致位判断出错。我用3.3V供电时会把上拉降到4.7k实测稳定很多。第二DHT11和MCU共地一定要做好。如果两者地线之间有压差信号电平判断就会漂移。尤其是DHT11用独立电源的时候地线要粗、要短。第三如果DHT11的线缆旁边有大电流走线或者电机干扰会很明显。我做过一个项目DHT11的线和继电器控制线捆在一起每次继电器动作温湿度就跳变。后来把线分开走问题消失。第四DHT11刚上电时需要1秒左右的稳定时间不要一上电就立刻读取。我的做法是初始化后延时1到2秒再开始第一次采集。5. 从能读到好用数据上报与显示的实际处理5.1 串口打印的格式化技巧调试阶段用串口打印温湿度是最方便的。但很多人打印出来的格式很乱或者用printf导致代码体积暴涨。我的建议是如果只是调试用printf没问题但要注意在Keil里勾选Use MicroLIB否则printf可能无法正常工作。另外printf的重定向代码int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }如果代码空间紧张可以用sprintf把数据格式化到缓冲区然后用串口发送函数发出去避免引入完整的printf库。打印格式建议带上时间戳和状态标记[12345ms] Temp: 25C, Humi: 60%, OK [14345ms] Temp: 25C, Humi: 60%, OK [16345ms] Read failed, using last value这样一眼就能看出读取是否稳定。5.2 OLED本地显示的数据刷新策略如果项目带OLED屏温湿度的刷新不要每次读完就全屏重绘。全屏重绘会导致屏幕闪烁而且浪费时间。正确的做法是只更新变化的数字区域或者用双缓冲机制。我用SSD1306的时候会把温湿度显示区域固定每次只更新那几个字符的位置。如果数值没变甚至可以不刷新。这样屏幕看起来稳定MCU负担也小。另外OLED的I2C通信和DHT11的单总线读取不要放在同一个时间片里密集执行否则可能互相干扰。我的做法是DHT11读完数据处理完再统一刷新显示。5.3 数据上报到上位机的协议设计如果要把数据传到上位机或者云平台建议设计一个简单的帧协议而不是直接发ASCII字符串。比如帧头(0xAA 0x55) 温度(1字节) 湿度(1字节) 校验(1字节) 帧尾(0x0D 0x0A)校验可以用简单的累加和或者异或。这样上位机解析起来方便也不容易因为数据里的特殊字符导致解析错误。如果走无线模块比如通过串口透传还要考虑丢包和重传。我的经验是温湿度数据不是关键数据丢一两包无所谓所以不做重传但上位机要做超时判断超过一定时间没收到就标记设备离线。5.4 低功耗场景下的采集策略如果项目是电池供电DHT11的功耗要考虑。DHT11工作电流约1mA待机电流约60微安。虽然不大但配合F1的功耗整体续航还是受限。低功耗策略采集间隔拉长到30秒或1分钟采集完成后让F1进入Stop模式用RTC唤醒DHT11的电源可以用GPIO控制不采集时断电但注意DHT11断电后重新上电需要稳定时间所以如果频繁断电采集间隔不能太短。我一般建议采集间隔大于10秒才考虑断电控制。6. 常见故障的排查链路与修复方案6.1 读出来永远是255或者0这是最常见的故障现象。排查顺序第一步用示波器或者逻辑分析仪看数据线波形。如果没有仪器用万用表测数据线在空闲时是不是高电平。如果空闲时是低电平说明上拉电阻没接或者DHT11坏了。第二步检查起始信号是否发出。在DHT11_Start()之后加个延时用万用表测数据线应该能看到短暂的低电平。如果一直是高说明GPIO输出配置有问题。第三步检查响应信号。如果主机拉低后DHT11没有响应可能是传感器供电不足或者已经损坏。换一个传感器试试。第四步如果响应有但数据全是1或全是0检查delay_us的精度。用示波器测delay_us(30)实际延时是多少偏差超过20%就会导致位判断错误。6.2 数据偶尔正确偶尔错误这种间歇性故障最头疼。常见原因中断干扰关中断再试电源纹波在DHT11的VCC和GND之间加100nF电容线缆接触不良换杜邦线或者直接焊接采样太快把间隔拉到2秒以上环境湿度极高DHT11在90%RH以上时读数可能异常这是器件特性我的经验是如果间歇性错误率在5%以内通过重试机制可以掩盖如果超过20%必须从硬件和时序上找原因。6.3 校验和总是差1或者差某个固定值校验和错误说明数据传输过程中有位的翻转。如果总是差同一个值比如总是差1可能是最低位判断有问题检查delay_us(30)是否偏短。如果错误随机检查电源和干扰。还有一种可能DHT11的响应信号还没结束主机就开始读数据了。检查DHT11_WaitResponse()的第三个等待循环是否正确。6.4 上电后第一次读取总是失败这通常是DHT11上电稳定时间不够。DHT11 datasheet要求上电后等待1秒以上再通信。我的做法是在初始化函数里加delay_ms(1500)然后再开始第一次采集。另外第一次采集失败后不要立刻重试等2秒再试。6.5 用HAL库和标准库的差异网上很多DHT11代码是基于标准库的如果你用HAL库GPIO操作方式不同。HAL库的GPIO读写// 读引脚 uint8_t DHT11_READ(void) { return HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN); } // 写引脚 void DHT11_HIGH(void) { HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); } void DHT11_LOW(void) { HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); }模式切换也要用HAL_GPIO_Init配置结构体是GPIO_InitTypeDef成员是Pin、Mode、Pull、Speed。注意HAL库的输入模式要指定Pull比如GPIO_NOPULL或GPIO_PULLUP。HAL库的delay_us需要自己实现HAL_Delay只有毫秒级。可以用上面的DWT方案或者用定时器。7. 把DHT11驱动封装成可复用模块的思路7.1 接口设计让驱动和业务分离一个好的驱动应该只负责读数据不负责怎么用数据。所以接口设计成typedef struct { uint8_t temperature; uint8_t humidity; uint8_t valid; } DHT11_Data_t; uint8_t DHT11_Init(void); uint8_t DHT11_Read(DHT11_Data_t *data);业务层拿到DHT11_Data_t之后自己决定是显示、上报还是存储。这样驱动可以复用到不同项目不用改代码。7.2 超时机制的统一处理驱动里所有的while等待都要有超时。我习惯定义一个宏#define DHT11_TIMEOUT 200 uint8_t DHT11_WaitLevel(uint8_t level, uint32_t timeout_us) { while (DHT11_READ() ! level) { if (timeout_us-- 0) return 0; delay_us(1); } return 1; }这样代码更简洁也不会死循环。7.3 调试开关与日志输出在驱动里加一个编译开关调试时打开日志发布时关掉#ifdef DHT11_DEBUG #define DHT11_LOG(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DHT11_LOG(fmt, ...) #endif这样调试的时候能看到每一步的执行情况发布的时候不占用串口资源。7.4 移植到其他MCU的注意事项这套代码移植到其他MCU主要改三个地方GPIO读写宏、微秒延时函数、关中断和开中断的宏。协议层和数据处理层不用动。这也是为什么要把驱动分层的原因。比如移植到STM32F4主频168MHzDWT延时的ticks计算要改成us * 168。GPIO操作如果还是用HAL库基本不用改。关中断用__disable_irq()F4也支持。移植到GD32或者国产替代芯片GPIO寄存器可能不同但HAL库风格类似改起来也不难。8. 一些实战中攒下来的零碎经验DHT11的datasheet里写湿度精度±5%RH但实际用下来同一批传感器之间的一致性大概在±3%RH以内不同批次可能差到±8%。如果项目对湿度一致性有要求要么做单点校准要么选更贵的传感器。温度读数在25摄氏度附近比较准偏离越远误差越大。0到10度和40到50度这两个区间误差可能到±3度。如果项目工作温度范围宽建议用DS18B20做温度DHT11只做湿度。DHT11的响应时间大概2秒也就是说环境湿度突变后它需要2秒左右才能输出新的稳定值。所以采集间隔不要低于2秒否则读到的可能是过渡值。如果DHT11和无线模块比如WiFi、蓝牙共用一块板子无线模块发射时的电流突变可能干扰DHT11。我的做法是在DHT11的电源脚旁边放一个10uF的钽电容效果比100nF的陶瓷电容好。最后说一个代码风格的事DHT11的驱动代码里delay_us的调用很密集如果编译器优化等级太高可能把一些看似无用的循环优化掉。建议把delay_us函数放在单独的.c文件里并且用volatile修饰循环变量或者加__attribute__((optimize(O0)))禁止优化。我在Keil里遇到过O2优化导致延时不准的情况改成O1或者给延时函数单独降级就好了。这套东西跑通之后你会发现DHT11虽然简单但把时序、中断、电源、重试这些细节都处理好其实是一个很好的嵌入式工程训练。F1的资源用来做这件事绰绰有余剩下的算力还可以跑显示、通信、控制逻辑。如果你正在做类似的项目希望这些经验能帮你省下几个晚上的调试时间。
阅读完成 · 觉得有帮助?