STM32F103C8T6驱动DHT11温湿度传感器实战:从选型到代码调试

发布时间:2026/10/28 9:11:59
STM32F103C8T6驱动DHT11温湿度传感器实战:从选型到代码调试

1. 为什么STM32F1到今天还值得折腾

我手头这块STM32F103C8T6最小系统板,算下来已经在我抽屉里躺了快四年。中间换过F4、换过G0、也玩过一阵子ESP32,但每次要做个什么小玩意儿,手还是会先伸向这块蓝色小板子。原因很简单:便宜、资料多、踩坑有人填。一块核心板不到十块钱,配上DHT11这种几块钱的温湿度传感器,就能搭出一套能跑、能看、能改的环境监测节点。这篇文章就是把这套组合从头到尾捋一遍,包括为什么这么选、代码怎么写、坑在哪里,以及我实际调试过程中遇到的那些文档里不会写的东西。

STM32F1系列是意法半导体基于ARM Cortex-M3内核的微控制器产品线,最早2007年前后推出,到现在快二十年了还在大量出货。它的核心价值不在于性能多强——72MHz主频、64KB到512KB Flash、20KB SRAM,放在今天看确实寒酸——而在于它的生态成熟度。你随便搜一个外设配置问题,大概率能在论坛里找到十年前就有人问过、并且被解答过的帖子。这种“前人已经把路踩平了”的感觉,是很多新芯片给不了的。

DHT11温湿度传感器则是另一个极端:便宜到离谱,精度也低到离谱,湿度±5%RH、温度±2℃,采样周期还不能低于1秒。但它胜在接口简单,单根数据线就能读出温湿度,不需要额外的ADC或者I2C地址冲突问题。对于学习STM32的GPIO输入输出、定时器延时、单总线协议来说,DHT11是一个非常好的练手对象。你不需要理解复杂的寄存器映射,只需要把时序搞对,就能看到数据从传感器里一个个bit地蹦出来。

这套组合适合谁?如果你是刚接触STM32的新手,想找一个“有实际反馈”的项目来练手,DHT11加F103是最合适的起点之一。如果你是有经验的开发者,想快速搭一个环境监测原型,这套方案也能在半小时内跑通。下面我会从整体设计思路开始,一步步拆到代码和调试细节。

2. 整体方案设计与选型逻辑

2.1 为什么选STM32F103C8T6而不是其他型号

STM32F1系列内部也有细分,从F100到F107,区别主要在Flash大小、封装形式、外设数量和是否带USB/CAN等。我选F103C8T6的理由很实际:它是整个F1系列里最便宜、最容易买到、资料最全的型号。C8代表64KB Flash,T6代表LQFP48封装,48个引脚足够引出两个USART、两个SPI、两个I2C、三个通用定时器和一个高级定时器。对于DHT11这种单总线设备,随便找一个GPIO就能驱动,根本用不到什么高级外设。

有人会问为什么不选F030或者G030这些更新的低端型号。我的经验是:新芯片的参考手册虽然写得更规范,但社区积累的实战案例少。你在调试时遇到一个诡异现象,搜F103能搜到几十个相关帖子,搜F030可能只有两三个,而且还不一定和你的问题匹配。对于学习和快速验证来说,资料密度比芯片性能重要得多。

另一个考虑是供电。F103C8T6的工作电压范围是2.0V到3.6V,典型3.3V。DHT11的工作电压是3.3V到5.5V,两者可以直接共用一个3.3V电源,不需要电平转换。如果你用5V的Arduino来读DHT11,反而要注意数据线电平匹配的问题。这一点在选型时容易被忽略,但实际接线时会省很多事。

2.2 DHT11的通信协议为什么用单总线

DHT11用的是单根数据线完成双向通信,这种方案在低成本传感器里很常见。它的原理是:主机先拉低数据线至少18毫秒作为起始信号,然后释放;DHT11检测到起始信号后,拉低80微秒作为响应,再拉高80微秒,之后开始传输40位数据。每一位数据都以50微秒的低电平开始,然后高电平的持续时间决定是0还是1——26到28微秒表示0,70微秒表示1。

这种协议对时序要求比较严格,但也不是不能容忍误差。我实测下来,只要高低电平的持续时间在合理范围内,DHT11都能正确解析。问题在于STM32的GPIO操作速度很快,如果你用库函数去读引脚状态,函数调用本身的开销就可能超过一个微秒,导致采样点偏移。所以读DHT11的时候,我一般直接操作寄存器或者用__NOP()做精确延时,而不是调用HAL_GPIO_ReadPin()。

还有一个细节:DHT11的数据线需要接一个4.7kΩ到10kΩ的上拉电阻。很多模块已经自带了,但如果你买的是裸传感器,一定要自己加。没有上拉电阻的话,数据线在释放后无法快速回到高电平,通信会直接失败。这个坑我踩过,当时以为是代码问题,查了半天才发现是硬件少了电阻。

2.3 系统整体框架

整个方案的核心逻辑很简单:STM32F103通过一个GPIO引脚与DHT11通信,读取40位数据,解析出湿度和温度,然后通过USART串口发送到电脑显示。如果你想加个OLED屏幕本地显示,也可以再挂一个I2C的0.96寸屏,但那是另一个话题了。

我建议初学者先跑通“读传感器+串口打印”这个最小闭环,因为它的变量最少:只有一个GPIO和一个USART,出了问题容易定位。等这个跑通了,再加屏幕、加按键、加报警逻辑都不迟。很多人一上来就想做完整的环境监测站,结果传感器还没读通就开始调屏幕,最后两个问题搅在一起,根本分不清是谁的错。

软件层面,我习惯用STM32CubeMX生成初始化代码,然后手动写DHT11的驱动。CubeMX的好处是时钟树和引脚配置可视化,不容易配错;坏处是生成的HAL库代码比较臃肿,对于时序敏感的DHT11读取,我还是会绕开HAL,直接用寄存器操作。这种“混合写法”在实际项目中很常见,没必要强迫自己全部用库或者全部用寄存器。

3. 核心细节解析与实操要点

3.1 GPIO模式切换的时机与陷阱

DHT11的单总线通信需要在不同阶段切换GPIO模式:发送起始信号时是推挽输出,读取数据时要切换成浮空输入或上拉输入。这个切换时机很关键,切早了数据还没读完,切晚了总线状态不对。

我的做法是:起始信号发出后,先把引脚设为浮空输入,然后等待DHT11的响应。这里有个细节:F103的GPIO在从输出切换到输入时,如果外部没有上拉电阻,引脚会短暂浮空,可能读到随机值。所以上拉电阻不仅是通信需要,也是模式切换时的稳定保障。

具体寄存器操作是这样的:配置CRL寄存器中对应引脚的4个bit,MODE位设为00表示输入,CNF位设为01表示浮空输入,或者10表示上拉/下拉输入。如果设成上拉输入,还需要配置ODR寄存器对应位为1来启用上拉。我一般用浮空输入加外部上拉电阻,这样最稳定。

注意:切换GPIO模式后,不要立刻读取引脚状态,最好等几个微秒让电平稳定。我试过切换后马上读,偶尔会读到错误的值,加一个__NOP()延时后就正常了。

3.2 微秒级延时的实现方式

DHT11的时序要求微秒级延时,而HAL库的HAL_Delay()最小单位是毫秒,根本不够用。常见的做法有三种:用定时器计数、用系统滴答定时器Systick、用__NOP()空指令循环。

我推荐用Systick或者定时器,因为__NOP()循环的延时时间受编译器优化和主频影响,换个编译选项就可能不准。Systick的配置方法是:设置重装载值为72-1(72MHz主频下,每个计数周期约1.39微秒),然后通过读取当前值来计算经过的时间。不过Systick通常被HAL库用来做毫秒延时,直接改它的配置可能会影响其他功能。

更稳妥的方案是用一个通用定时器,比如TIM2,配置成1MHz的计数频率,这样每个计数就是1微秒。需要延时的时候,读取当前计数值,然后循环等待直到差值达到目标。这种方法的精度取决于定时器时钟的准确性,F103的外部晶振一般是8MHz,经过PLL倍频到72MHz,精度足够DHT11使用。

我实际用的是TIM3,配置成向上计数模式,预分频器设为71,这样72MHz除以72就是1MHz。延时函数大概长这样:

void delay_us(uint16_t us) { uint16_t start = TIM3->CNT; while ((uint16_t)(TIM3->CNT - start) < us); }

注意这里用uint16_t做减法,是因为TIM3是16位定时器,CNT寄存器会在0到65535之间循环。用无符号减法可以自动处理溢出情况,不需要额外判断。

3.3 数据位解析的逻辑与容错

DHT11的40位数据格式是:8位湿度整数+8位湿度小数+8位温度整数+8位温度小数+8位校验和。校验和等于前四个字节相加的低8位。对于DHT11来说,小数部分通常都是0,因为它的分辨率就是1%RH和1℃。但有些兼容传感器可能会返回非零小数,所以解析时还是按完整格式处理比较稳妥。

读取每一位数据的逻辑是:先等待低电平结束(50微秒),然后延时30微秒左右,再读取引脚状态。如果此时是高电平,说明这一位是1;如果是低电平,说明是0。这个30微秒的采样点很关键——太早可能还在低电平阶段,太晚可能高电平已经结束。我实测下来,28到32微秒之间读取都比较稳定。

容错方面,我加了两个保护:一是超时检测,如果等待电平变化超过一定时间(比如100微秒),就认为通信失败,直接返回错误;二是校验和验证,如果校验不通过,丢弃这次数据,重新读取。DHT11的采样周期不能低于1秒,所以两次读取之间要加足够的延时,否则传感器可能不响应。

实操心得:如果你发现读出来的数据偶尔跳变很大,先检查供电是否稳定。DHT11对电源纹波比较敏感,用USB供电时如果电脑本身电源质量不好,数据会飘。我后来在VCC和GND之间并了一个100nF的电容,情况改善很多。

4. 完整实操流程与代码实现

4.1 硬件接线与电源处理

先列一下我用的物料清单:STM32F103C8T6最小系统板一块、DHT11模块一个(带板载上拉电阻的那种)、USB转TTL串口模块一个、杜邦线若干。总成本不到二十块钱。

接线方式如下:DHT11的VCC接3.3V,GND接GND,DATA接PA0。为什么选PA0?因为它在最小系统板上位置方便,而且PA0本身没有复用功能冲突,拿来当普通GPIO最省心。串口方面,USART1的TX是PA9,RX是PA10,分别接串口模块的RX和TX。注意这里是交叉连接,TX接RX,RX接TX,不要接反了。

电源处理上,我建议DHT11的供电直接从最小系统板的3.3V引脚取,不要从串口模块取。因为串口模块的3.3V输出电流能力有限,DHT11在通信瞬间的电流波动可能导致电压跌落,影响读取稳定性。如果最小系统板是通过USB供电的,那3.3V来自板载的稳压芯片,驱动DHT11绰绰有余。

4.2 CubeMX配置要点

打开CubeMX,选STM32F103C8T6,然后按以下步骤配置:

第一步,配置时钟。在RCC里把HSE设为Crystal/Ceramic Resonator,然后在Clock Configuration里把HCLK拉到72MHz。CubeMX会自动计算PLL参数,你只需要确认最终频率是72MHz就行。

第二步,配置GPIO。把PA0设为GPIO_Output,初始电平设为High,输出模式设为Open-Drain还是Push-Pull?这里有个讲究:因为DHT11是单总线双向通信,理论上应该用开漏输出加外部上拉。但实际用推挽输出也能工作,因为我们在读取时会切换成输入模式,不会出现两个输出打架的情况。我一般用推挽输出,简单直接。

第三步,配置USART1。模式选Asynchronous,波特率设为115200,其他默认。记得在NVIC里使能USART1的中断,不过如果你只用轮询发送,不使能中断也行。

第四步,配置TIM3。时钟源选Internal Clock,预分频器设为71,计数器周期设为65535,这样就是1MHz的计数频率。不需要开中断,我们只读CNT值。

生成代码时,工具链选MDK-ARM或者STM32CubeIDE都行,看你习惯哪个。我一般用Makefile加VSCode,但那是个人偏好,不影响功能。

4.3 DHT11驱动代码逐段解析

先定义引脚操作宏,这样切换模式和读写电平都方便:

#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_0 #define DHT11_OUT() do { \ GPIOA->CRL &= ~(0xF << 0); \ GPIOA->CRL |= (0x3 << 0); \ } while(0) #define DHT11_IN() do { \ GPIOA->CRL &= ~(0xF << 0); \ GPIOA->CRL |= (0x4 << 0); \ } while(0) #define DHT11_HIGH() (GPIOA->BSRR = GPIO_PIN_0) #define DHT11_LOW() (GPIOA->BRR = GPIO_PIN_0) #define DHT11_READ() ((GPIOA->IDR & GPIO_PIN_0) ? 1 : 0)

这里CRL寄存器的低4位控制PA0。0x3表示推挽输出,最大速度50MHz;0x4表示浮空输入。BSRR和BRR是位设置和位清除寄存器,比直接写ODR更高效,因为它们是原子操作,不会被中断打断。

起始信号的发送逻辑:

void DHT11_Start(void) { DHT11_OUT(); DHT11_HIGH(); delay_us(10); DHT11_LOW(); delay_us(18000); // 拉低至少18ms DHT11_HIGH(); delay_us(30); DHT11_IN(); }

拉低18毫秒是DHT11协议要求的,我一般给20毫秒留点余量。拉高后等30微秒再切换成输入,这个时间是为了让DHT11有时间检测到起始信号并准备响应。

等待响应和读取数据:

uint8_t DHT11_ReadByte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { while (!DHT11_READ()); // 等待50us低电平结束 delay_us(30); byte <<= 1; if (DHT11_READ()) { byte |= 1; while (DHT11_READ()); // 等待高电平结束 } } return byte; }

注意这里的while循环没有超时保护,如果传感器没接好或者坏了,程序会卡死。实际产品代码里应该加超时计数,但学习阶段可以先这样,方便调试。

完整的读取函数:

uint8_t DHT11_Read(float *temp, float *humi) { uint8_t buf[5]; DHT11_Start(); if (DHT11_READ()) return 1; // 没有响应 while (!DHT11_READ()); // 等待响应低电平 while (DHT11_READ()); // 等待响应高电平 for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } if (buf[4] != (buf[0] + buf[1] + buf[2] + buf[3])) { return 2; // 校验失败 } *humi = buf[0] + buf[1] * 0.1f; *temp = buf[2] + buf[3] * 0.1f; return 0; }

4.4 主循环与串口输出

主函数里初始化完外设后,进入一个简单的循环:

while (1) { float temp, humi; uint8_t ret = DHT11_Read(&temp, &humi); if (ret == 0) { printf("Temp: %.1f C, Humi: %.1f %%\r\n", temp, humi); } else { printf("DHT11 error: %d\r\n", ret); } HAL_Delay(2000); }

printf需要重定向到USART1,在STM32CubeIDE里可以重写_write函数,在MDK里可以重写fputc。这个网上教程很多,不展开说了。2秒的延时是因为DHT11采样周期不能低于1秒,给2秒更稳妥。

5. 常见问题与排查技巧实录

5.1 读取一直失败或数据全零

这是最常见的问题,原因通常有三个:接线错误、上拉电阻缺失、时序不对。排查顺序建议从硬件到软件。

先用万用表量一下DHT11的VCC和GND之间是不是3.3V,如果电压不对,检查最小系统板的供电。然后量DATA线在空闲时是不是高电平,如果是低电平,说明上拉电阻没起作用或者传感器坏了。如果硬件没问题,再用示波器或者逻辑分析仪看起始信号和数据波形。没有仪器的话,可以在代码里加打印,看程序卡在哪个while循环里。

我遇到过一次数据全零的情况,最后发现是杜邦线接触不良。那种便宜的杜邦线用久了会松,插在面包板上稍微碰一下就断。后来我直接焊线,问题就没了。

5.2 数据偶尔跳变或校验失败

如果数据大部分时候正常,偶尔跳变,优先检查电源纹波和采样周期。DHT11对电源质量比较敏感,尤其是通信瞬间的电流突变。在VCC和GND之间并一个100nF到10uF的电容,能明显改善。

另一个原因是采样太快。DHT11内部有一个电容式湿度敏感元件,每次采样后需要时间恢复。如果你1秒内读两次,第二次的数据可能不准。我一般设2秒间隔,既满足要求又不至于太慢。

还有一个小概率情况是中断干扰。如果你开了其他中断,比如Systick或者串口接收中断,它们可能在DHT11通信过程中打断时序。解决办法是在读取DHT11时关掉全局中断,读完再开。不过这样会影响系统实时性,只适合对时序要求极高的场景。

5.3 程序卡死在while循环里

前面提到过,等待电平变化的while循环没有超时保护,传感器没接好就会卡死。改进方法是加一个计数器:

uint32_t timeout = 0; while (!DHT11_READ()) { if (++timeout > 10000) return 1; }

timeout的阈值需要根据你的延时精度调整。如果每次循环大概1微秒,10000次就是10毫秒,足够覆盖DHT11的响应时间。超过这个时间还没等到电平变化,基本可以确定是硬件问题。

5.4 串口打印乱码

串口乱码通常是波特率不匹配或者时钟配置错误。先确认串口模块的波特率和代码里设的一样,都是115200。如果一样还乱码,检查系统时钟是不是真的跑在72MHz。有时候CubeMX里配置了72MHz,但外部晶振没起振,系统会自动切换到内部8MHz RC振荡器,导致所有时序都慢了9倍。

判断方法很简单:在main函数开头翻转一个GPIO,用示波器量频率。如果翻转频率是预期的九分之一,那就是时钟问题。检查晶振焊接和负载电容,F103的8MHz晶振一般配20pF电容。

5.5 常见问题速查表

现象可能原因排查方法解决措施
数据全零接线错误或传感器损坏量VCC电压和DATA空闲电平重新接线或更换传感器
数据跳变电源纹波大示波器看VCC波形加滤波电容
校验失败采样周期太短检查读取间隔改为2秒以上
程序卡死无超时保护看卡在哪个循环加超时计数器
串口乱码时钟配置错误量GPIO翻转频率检查晶振和PLL配置
偶尔失败中断干扰关中断测试读取时关全局中断

最后分享一个我踩过的坑:DHT11的DATA引脚在切换输入输出模式时,如果外部上拉电阻太大(比如用了100kΩ),电平上升沿会变缓,导致读取时序偏移。我试过用100kΩ,失败率大概三成;换成4.7kΩ后,连续读几百次都没问题。所以上拉电阻不要随便选,4.7k到10k之间最稳。

这套方案我前后搭过五六次,每次都有新发现。最近一次是用它做了一个温室育苗箱的监测节点,连续跑了三个月没断电,数据通过串口记录到树莓派上。中间只遇到过一次传感器失效,换了一个就好了。整体来说,STM32F103加DHT11这个组合,虽然技术上没什么新意,但胜在可靠、便宜、随时能复现。如果你手头正好有这两样东西,不妨花半小时接起来跑跑看,比看十篇教程都管用。