搞完STM32基础外设之后最想做的事往往不是继续点灯而是找一个真实又便宜的外部传感器把学到的GPIO、定时器、串口串起来。DHT11温湿度传感器正好满足这个需求几块钱一片带单总线时序有校验和读取过程足够复杂又不至于难到让人放弃。这篇文章我把自己用STM32F103驱动DHT11的完整过程、硬件接线、时序拆解、底层驱动代码、调试方法和踩过的坑都整理了一遍。适合已经会GPIO输出、串口打印想自己动手写传感器驱动的朋友。如果你只是想要一个能跑的例程代码可以直接抄但如果你想知道为什么这么写、为什么读出来是错的那最好把后面的时序部分也看完。1. 从点灯到读传感器DHT11应用的整体设计思路1.1 这个项目到底要解决什么问题表面上这是一个“读取温度和湿度”的小项目本质上却是在解决嵌入式开发里非常典型的一个问题怎么从一个只有一根数据线的传感器里把数字信号稳定地读出来。DHT11只有三个引脚其中真正通信只有DATA一根线。单片机不能像读按键一样直接读引脚电平因为传感器不是把数据放在一个寄存器里等你去读而是通过电平脉冲的持续时间来编码数据。这就要求单片机必须精确控制时序包括拉低多长时间、释放多长时间、什么时刻采样以及在每个阶段做超时保护。如果这些做不好读出的数据就是乱的。所以这个项目的主线很清楚先搞懂DHT11的数据手册时序再用STM32的GPIO模拟这套时序最后把读取封装成底层驱动应用层只需要调用一个函数就能拿到温度和湿度。这也是很多嵌入式项目里驱动层和应用层分离的雏形。1.2 为什么选DHT11而不是DHT22、SHT30DHT11的精度确实不高温度误差±2℃湿度误差±5%RH测量范围也只有0到50℃、20%到90%RH。很多人第一次看到这个参数会劝退但如果目标是学习单总线协议和传感器驱动开发DHT11反而是最合适的入门选手。它便宜坏了不心疼模块多随便一块开发板都能接通信时序经典数据格式也很简单40位数据里甚至包含校验位适合练习容错判断。相比之下DHT22精度更好但时序和DHT11类似不如直接学DHT11再迁移SHT30用的是I2C接口虽然使用简单却学不到“用GPIO抠时序”的核心内容。如果你后面是做产品我会建议直接上SHT30或者AHT20这类数字传感器精度和稳定性都好很多。但作为进阶练习DHT11的意义不在性能在于让你真正理解底层时序是怎么一回事。把DHT11吃透再去看DS18B20、DHT22、甚至各种自定义单总线协议思路都会顺很多。1.3 先看数据手册而不是先抄代码很多人在拿到DHT11后第一件事是上网找代码其实应该先看数据手册哪怕只看清楚以下几点后面能少踩一半的坑。第一是供电范围。DHT11支持3.3V到5.5V供电这意味着可以直接用3.3V供电不需要为了兼容电平去额外处理。第二是通信时序。手册里写着主机拉低总线至少18ms然后释放DHT11收到起始信号后会先拉低80us作为响应再拉高80us准备发送数据。接下来输出40位数据湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位高位在前。第三是测量周期。DHT11典型采集周期是2秒也就是说你1秒读一次可能拿到的是旧数据读太快反而容易触发传感器内部处理忙。所以实际项目中读取间隔至少留1到2秒。第四是总线状态。空闲时DATA线是被上拉电阻拉高的传感器通过拉低和释放来产生电平变化。如果总线上没有上拉电阻信号根本没法建立这就是很多人代码明明没问题却读不出来的一大原因。2. 硬件准备与接线上拉电阻和电平匹配不能跳过2.1 最小硬件清单和接线顺序驱动DHT11不需要复杂外设一个STM32最小系统板、一个DHT11模块、几根杜邦线就够了。如果手头的是裸传感器而不是模块还需要准备一个4.7kΩ到10kΩ的上拉电阻以及面包板或洞洞板。我这边使用的是一块STM32F103核心板传感器数据引脚接在PB0上接线顺序如下DHT11引脚连接到VCC3.3VGNDGNDDATASTM32 PB0上拉电阻接在DATA和VCC之间这里有个细节要特别注意DHT11模块一般已经把上拉电阻焊在板子上了裸传感器才需要外接。很多同学买的是模块却以为还要再外接一个4.7k电阻结果接了也没事但没接也不是一定不行要看模块上的丝印和原理图。稳妥做法是不确定模块是否自带上拉时直接量一下DATA对VCC之间的电阻如果有几千欧说明已经有上拉了。2.2 上拉电阻为什么一定需要DHT11的DATA引脚并不是推挽输出而是类似开漏结构。它只能主动把总线拉低不能主动输出高电平。要让总线回到高电平必须依靠外部上拉电阻把电压拉上去。上拉电阻的取值会直接影响时序。阻值太大比如100kΩ总线上的寄生电容会使上升沿变慢高电平脉宽被拉长单片机采样时容易误判阻值太小比如100Ω灌电流太大传感器低电平可能拉不下来甚至损坏器件。常用的4.7kΩ到10kΩ是一个很稳妥的范围在3.3V供电下4.7kΩ电阻产生的电流大约0.7mA足够驱动信号变化也不会给传感器造成负担。我在第一次调试时偷懒没有接上拉电阻只靠STM32内部上拉结果起始信号之后总线电平一直不稳定读回来的数据不是全0就是校验失败。后来接上4.7kΩ电阻问题立刻消失。这个坑不是个例建议所有人在检查代码之前先确认硬件。2.3 3.3V主控和不同供电方案的取舍DHT11允许3.3V到5.5V供电但供电方式会影响信号电平进而影响和STM32的接口兼容性。如果给DHT11用5V供电那么它输出的高电平最高会接近5V。虽然STM32F103部分引脚是5V容忍的但不是所有引脚都支持而且PB0这类引脚是否支持需要查具体型号的数据手册。为了省事最简单可靠的做法是给DHT11也用3.3V供电这样它的输出高电平和STM32完全一致不需要任何电平转换。实测下来DHT11在3.3V供电下工作正常温度和湿度读数与5V供电差别不大。如果你的模块上印着5V供电也请先看模块内部有没有稳压或者电平转换设计。大多数通用模块直接兼容3.3V和5V但裸传感器接到5V再连3.3V单片机就需要加一个分压电阻或者电平转换器。我的建议就一句话能用3.3V就用3.3V不要给自己找麻烦。3. DHT11单总线时序读懂通信才是进阶的关键3.1 单总线协议一条线上谁说话靠时间区分单总线协议的核心思想是主机和从机共用一根数据线分时通信通过电平高低和持续时间来区分数据。空闲时总线为高主机发起通信时主动拉低从机再通过拉低和释放来回复。可以把这条总线理解成两个人用一根电话线平时线路空闲是高电平说话前先抢线拉低然后通过“短音”和“长音”区分内容。DHT11的0和1区别并不在电平方向而在高电平持续的时间长短。这种设计省掉了时钟线代价就是通信双方必须严格遵守时间窗口任何一方响应慢了数据就废了。需要注意的是DHT11这个“单总线”和DS18B20那种严格单总线协议并不完全一样。DHT11不能像DS18B20那样多个传感器并联在一条总线上寻址读取它更多是借鉴了单总线的思想但协议上不支持多设备挂接。如果你想接多个DHT11就得用多个GPIO分别读取。3.2 起始信号、响应信号和40位数据一次完整的DHT11读取过程分成三个阶段。第一阶段是主机发起起始信号。主机把总线拉低并保持至少18ms让DHT11检测到这次通信请求然后释放总线上拉电阻使总线恢复高电平。释放后主机要等20到40usDHT11会在这段时间内响应。第二阶段是DHT11响应。DHT11先拉低总线约80us表示“我收到请求了”然后再释放总线约80us表示“我准备发送数据了”。主机在这个阶段要能正确判断出这两个80us的脉冲不能把响应信号当成数据位也不能漏掉。第三阶段是数据发送。DHT11连续输出40位数据第1到8位是湿度整数部分第9到16位是湿度小数部分第17到24位是温度整数部分第25到32位是温度小数部分第33到40位是校验和字节。所有数据都是高位在前和串口通信中的低字节在前完全相反解析时要注意移位方向。3.3 0和1的判别别被时序图骗了DHT11发送每一个数据位时都会先把总线拉低约50us然后释放总线发送高电平。区别就在这个高电平的持续时间数据0的高电平持续约26到28us数据1的高电平持续约70us。很多初学者被这张时序图绕晕总觉得应该用测脉宽的方式去读。理论上的确可以直接测出高电平持续了多少us大于50us就是1小于40us就是0。但在STM32上用GPIO轮询做这个测量既要开定时器又要精确计时实现起来比较繁琐。更常用的方法是先等到这个位起始的低电平结束也就是总线从低变高然后延时40us再采样一次。由于数据0的高电平只有26到28us40us之后总线已经回到低电平而数据1的高电平长达70us40us之后总线仍然保持高电平。这样一次电平采样就能区分0和1完全不需要测量精确脉宽。这个40us的采样点非常关键。如果延时时间太短比如只有20us那么数据0的高电平可能还没有结束会被误判成1如果延时太长接近70us数据1的高电平可能刚好结束也会误判成0。实际调试时延时函数必须先校准建议用定时器或者DWT实现而不是靠几个for循环盲猜。3.4 校验和多出来的第5个字节怎么用DHT11发出的40位数据里的最后8位是校验和作用是检查前4个字节在传输过程中有没有出错。规则很简单前4个字节相加取低8位如果结果等于校验字节就认为本次读到的数据有效。用C语言实现时直接把4个uint8_t相加再和校验字节比较即可。因为uint8_t本身就是8位相加时如果溢出编译器会把结果截断成低8位所以不需要手动做取余或者位与操作。伪代码如下if ((buf[0] buf[1] buf[2] buf[3]) buf[4]) { // 数据有效 }校验和不能保证数据绝对正确但能挡住大部分传输错误。如果你发现串口打印的数据偶尔正常偶尔乱跳但校验和又通过了那大概率不是通信问题而是传感器本身的一致性差或者环境温度超出了DHT11的测量范围。校验和只对通信误码负责对传感器测量误差不负责任。4. STM32驱动代码实现从GPIO到稳定读取4.1 延时函数准备us级延时是DHT11驱动的命根子DHT11时序里的关键时间单位是us起始信号要求18ms数据位等待和采样则是几十us。任何一处延时不准都会让读到的位出错。所以写驱动之前必须先把us级延时函数准备好。这里推荐用DWT调试监视跟踪单元实现它是Cortex-M内核自带的一个32位周期计数器精度高也不占额外定时器资源。初始化代码很简单static void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void delay_us(uint32_t us) { uint32_t ticks us * (SystemCoreClock / 1000000U); uint32_t start DWT-CYCCNT; while ((DWT-CYCCNT - start) ticks); }使用时要确认SystemCoreClock和实际系统主频一致。比如STM32F103默认外部8MHz晶振倍频到72MHz这个值就是72000000。如果主频配置不对时间戳就会按比例偏移读DHT11时很容易出现校验失败但不稳定复现的怪问题。ms级延时可以直接用delay_us累加实现或者用HAL_Delay、SysTick都行。读取DHT11时只有起始信号需要18ms其余地方都是us级所以us级是正确的核心。4.2 引脚配置和电平控制细节我选用的引脚是PB0配置为开漏输出。为什么不用推挽输出因为开漏输出在写1时并不会主动驱动高电平而是把输出驱动器断开让外部上拉电阻把总线拉高。这样单片机既可以主动拉低总线又能在释放总线时保持高阻态最接近DHT11时序图里“主机释放总线”的描述。初始化代码使用标准外设库GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);开漏输出模式下GPIO_SetBits输出1等于释放总线GPIO_ResetBits输出0等于拉低总线。读引脚电平用GPIO_ReadInputDataBitSTM32的输入数据寄存器会实时反映引脚状态即使当前配置是开漏输出也能读到外部电平变化。需要注意的是如果某个引脚的内部上拉很强而外部上拉电阻又没接总线可能会被内部上拉拉到一个不确定的状态。所以开漏模式必须配合外部上拉电阻否则高电平是靠不住。内部上拉只是软件配置不能替代物理电阻。4.3 读取位、字节和完整数据帧的实现先定义基本操作和数据结构#define DHT11_PORT GPIOB #define DHT11_PIN GPIO_Pin_0 #define DHT11_LOW() GPIO_ResetBits(DHT11_PORT, DHT11_PIN) #define DHT11_RELEASE() GPIO_SetBits(DHT11_PORT, DHT11_PIN) #define DHT11_READ() GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) typedef struct { uint8_t humi_int; uint8_t humi_dec; uint8_t temp_int; uint8_t temp_dec; uint8_t checksum; } DHT11_Data;起始信号的函数如下。主机拉低20ms后释放然后检测DHT11的响应低电平再等待响应高电平结束。static uint8_t DHT11_Start(void) { uint16_t timeout; DHT11_LOW(); delay_ms(20); DHT11_RELEASE(); delay_us(30); if (DHT11_READ() 1) { return 1; // 没有响应总线一直为高 } timeout 1000; while (DHT11_READ() 0 timeout--) ; if (timeout 0) return 1; timeout 1000; while (DHT11_READ() 1 timeout--) ; if (timeout 0) return 1; return 0; }读取单个bit时先等待位起始低电平出现再等待低电平结束然后延时40us采样。采样到高就是1低就是0static uint8_t DHT11_ReadBit(void) { uint16_t timeout; timeout 1000; while (DHT11_READ() 1 timeout--) ; if (timeout 0) return 0; timeout 1000; while (DHT11_READ() 0 timeout--) ; if (timeout 0) return 0; delay_us(40); if (DHT11_READ() 1) { return 1; } else { return 0; } }读取一个字节时连续读8个bit高位在前static uint8_t DHT11_ReadByte(void) { uint8_t i; uint8_t data 0; for (i 0; i 8; i) { data 1; if (DHT11_ReadBit() 1) { data | 1; } } return data; }完整读取函数uint8_t DHT11_ReadData(DHT11_Data *d) { uint8_t buf[5] {0}; uint8_t i; if (DHT11_Start() ! 0) { return 1; // 无响应 } for (i 0; i 5; i) { buf[i] DHT11_ReadByte(); } if ((buf[0] buf[1] buf[2] buf[3]) ! buf[4]) { return 2; // 校验失败 } d-humi_int buf[0]; d-humi_dec buf[1]; d-temp_int buf[2]; d-temp_dec buf[3]; d-checksum buf[4]; return 0; }主函数里按2秒间隔调用一次通过串口打印结果。第一次调用前最好先延时1秒等DHT11上电稳定。4.4 如何让代码更健壮超时保护与返回码上面的代码已经加了超时保护这是DHT11驱动里最容易忽略的部分。如果不加超时一旦传感器没接好或者损坏程序会卡死在某个while循环里整个单片机看上去就像死机了。加了超时后读取失败就返回错误码主程序可以继续运行这是判断一个驱动能不能实际用的关键。我在实际项目中会把返回值定义成枚举#define DHT11_OK 0 #define DHT11_ERR_NO_RESPONSE 1 #define DHT11_ERR_CHECKSUM 2这样应用层就可以根据返回值做不同处理比如连续3次读取失败后报警而不是闷头重试导致系统卡顿。还要提醒一句DHT11的时序对中断很敏感。如果系统里开了多个定时器中断读取数据位时刚好发生优先级较高的中断中断处理时间一旦超过几十us采样点就会偏移轻则数据错乱重则校验失败。最粗暴的做法是在读取DHT11期间暂时屏蔽中断读完后恢复。如果系统实时性要求比较高建议用定时器输入捕获方式去测量脉宽而不是靠阻塞式轮询。5. 调试实测与常见问题排查实录5.1 逻辑分析仪抓时序一眼看出问题驱动写完后不要急着看串口打印先用逻辑分析仪抓一下DATA引脚的波形。逻辑分析仪很便宜采样率选1MHz以上就够了。把逻辑分析仪的通道0接到PB0GND和系统共地设置下降沿触发然后开始采集同时让程序执行一次DHT11读取。正常波形应该满足一个20ms左右的低电平紧接着一个80us的低脉冲响应再一个80us的高脉冲然后是40个数据位。每个数据位必然先出现50us低电平后面的高电平要么短0要么长1。看到这个波形你的硬件和起始时序基本就没问题了。如果波形里根本没有20ms低电平说明程序可能压根没执行DHT11_Start如果20ms低电平之后总线一直为高没有响应脉冲说明DHT11没收到信号重点检查供电、接地和上拉电阻如果有响应脉冲但40个数据位里有些位明显变形说明延时函数不准或者中断干扰。逻辑分析仪的另一个好处是能直接量出每个高电平的实际时间。用游标量一下第1个数据位的高电平宽度是26us左右还是70us左右立刻就能判断你的采样点设得对不对。5.2 读数全0或者不变最常见的三个原因串口打印出来全是0或者每次打印都是同一个固定值这类问题我见过太多次了其实绝大多数不是代码问题而是下面三个原因之一。第一个原因是接线不正确或者接触不良。杜邦线插得不紧、模块引脚氧化、面包板弹片松动都可能导致DATA线在某个瞬间断开。DHT11对这种瞬间断路很敏感一旦DATA线断开总线上只有上拉电阻电平永远是高读回来的位全是1校验大概率失败程序只能不断报错。第二个原因是上拉电阻缺失。这个问题前面已经提过没有上拉电阻时总线空闲电平不确定DHT11无法正确表达高电平读回的数据当然不对。用万用表量一下DATA对VCC的电阻如果没有几千欧就把4.7kΩ电阻补上。第三个原因是读取间隔太短。DHT11测量周期约2秒如果你在主循环里不加延时每秒读很多次传感器可能还在忙着测量输出的数据帧本身就不完整。建议每次读取之间至少延时2秒宁可少读不可读错。5.3 校验和失败的定位方法校验和失败是一个很好的排查线索因为它说明单片机确实从DHT11拿到了数据但某些位读错了。这时候不需要怀疑传感器根本没工作而是要把注意力放在时序细节上。先检查延时函数。如果你用的是简单的for循环做us延时一旦编译器开了-O2优化循环可能被优化掉导致实际延时远小于预期。使用DWT或者定时器后这类问题基本消失。再检查采样点。40us这个值不是拍脑袋定的它落在数据0和数据1的高电平之间。如果传感器个体差异比较大有些DHT11的高电平宽度会偏长或偏短这时可以适当把采样延时长一点或短一点比如从40us改成45us观察校验失败率是否有变化。但不要一次改太多通常上下浮动5到10us就够了。最后检查中断。如果校验失败是周期性出现的比如每隔几次失败一次很可能就是中断打扰了某一位的读取。可以把串口中断、定时器中断暂时关掉测试如果失败率明显下降就确定是中断影响。解决方法前面已经说过了读取期间关闭中断或者改用输入捕获。5.4 多传感器接入与后续扩展DHT11的单总线协议不支持多个传感器并联在一条总线上这和DS18B20完全不同。想读取多路温湿度只能一个传感器占用一个GPIO每个引脚独立读取。代码上可以把引脚和端口抽象成参数写一个带参数的结构体驱动这样每路调用DHT11_ReadData时传入不同引脚配置即可。后续如果想做一个小型环境监测节点可以把读到的数据通过串口转发给上位机或者用LCD显示屏展示再或者接入无线模块做远程上报。DHT11本身精度不高做精度要求不高的室内环境监测完全够用。如果对精度有要求可以考虑用I2C接口的AHT20或者SHTC3它们的驱动方式和DHT11完全不同但有了DHT11这个基础理解I2C会更容易。也可以尝试DS18B20它同样是单总线但是真正的总线式结构支持多点挂接学习价值更高。6. 我的实操体会与最后建议6.1 几个容易被忽略的小细节我在实际使用DHT11的过程中总结出几个常规教程里不太会写的小细节每一个都让调试省了不少事。第一个细节是上电后不要立刻读。DHT11上电后需要几百毫秒甚至1秒才能进入稳定工作状态。很多人在下载程序后马上按复位然后看到第一次读取失败就以为代码有问题其实只要等上1到2秒再读第一次读取往往就正常了。第二个细节是传感器不要紧靠单片机或者发热元件。STM32F103虽然功耗不高但如果单片机持续运行板载稳压芯片也有一定热量。DHT11测的是它周围小环境的温度紧挨着板子读数会比实际室温高1到2℃。做温度测试时把传感器用杜邦线延长出去读数会更可信。第三个细节是湿度小数位通常为0。DHT11的分辨率是1%RH湿度小数部分很多情况下读出来就是0这不是传感器坏了而是量化和协议输出导致的现象。温度的小数位也不是高精度小数只是把一位二进制小数转成十进制显示实际精度有限。第四个细节是不要长时间连续高频读取。DHT11内部用NTC热敏电阻测温度持续高频读取会让传感器自身轻微发热影响测量结果。一般2秒到5秒读一次已经足够既省电又延长传感器寿命。6.2 后续可以怎么玩驱动稳定跑通之后可以做的事情其实不少。我建议别急着换传感器先把手上的DHT11用到“有余量”的程度。可以做一个滑动平均滤波连续读5次去掉最大最小值再取平均值能明显减少偶发跳变。可以做一个环形缓冲区存储温湿度历史数据配合按键翻页显示这就是一个简易环境记录仪。也可以把读取封装成独立模块接口只暴露一个DHT11_ReadData函数后面再换传感器时只需要改驱动应用层基本不用动。如果手边有FreeRTOS还可以把DHT11读取放到一个独立任务里用消息队列把数据发送给显示任务。DHT11阻塞式读取其实不适合在实时性要求严格的系统里频繁调用通过任务调度可以避开中断干扰这也是一个进阶探索方向。最后分享一个我自己的习惯每次拿到一个新传感器我会先把数据手册里的时序图画到纸上标注每个时间参数的最小值和最大值再开始写代码。不要以为数据手册很难懂DHT11这个级别的手册往往只有几页把时序图看明白比抄一百行代码都有用。后面你再去碰别的传感器靠的都是这套读手册、拉时序、抓波形、定位问题的能力。