☰
STM32F1入门实战:DHT11温湿度传感器驱动与单总线时序详解
2026/10/27 18:08:01 网站建设 项目流程

1. 为什么STM32F1至今仍是嵌入式入门的首选平台

搞嵌入式这行十几年,我见过太多人一上来就冲着最新最强的芯片去,结果连最基本的GPIO都没玩明白。STM32F1系列,尤其是经典的STM32F103C8T6,这颗Cortex-M3内核的芯片从2007年发布到现在,依然是各大高校实验室、电子竞赛和中小企业产品开发的主力。原因很简单:资料多到泛滥、价格低到离谱、生态成熟到几乎不需要动脑子选型。

STM32F1系列覆盖了从36脚到144脚、从16KB到1MB Flash的完整产品线,核心频率72MHz,外设包括USART、SPI、I2C、ADC、定时器、CAN等,基本上你能想到的嵌入式基础外设它都有。很多人拿它做温湿度采集、电机控制、OLED显示、串口通信这些入门项目,其中最典型的就是搭配DHT11温湿度传感器做环境监测。这个组合几乎是每个嵌入式新手的“第一课”,因为它同时涉及了GPIO时序控制、单总线协议解析、串口打印输出这三个核心技能点。

这篇文章面向的是刚接触STM32或者正在做DHT11项目的朋友,我会从选型思路、开发环境搭建、DHT11时序解析、代码实现、常见问题排查这几个维度,把整个流程拆开揉碎讲清楚。不管你是学生做课设,还是工程师做快速原型验证,这套东西都能直接拿去用。

2. STM32F1系列的核心架构与选型逻辑

2.1 Cortex-M3内核到底强在哪里

STM32F1用的是ARM Cortex-M3内核,这是一颗哈佛架构的32位RISC处理器,带三级流水线,支持Thumb-2指令集。说人话就是:它能在72MHz主频下做到1.25 DMIPS/MHz的性能,同时功耗控制得相当不错。跟8位机比起来,它的优势不在于绝对算力,而在于中断响应速度和外设的丰富程度。

Cortex-M3内置了嵌套向量中断控制器(NVIC),支持最多240个中断,每个中断有16级优先级。这意味着你在做DHT11这种时序敏感的操作时,可以合理配置中断优先级,避免被其他外设打断导致数据读取失败。我实测下来,如果用SysTick做延时基准,把DHT11的读取放在主循环里,只要不同时跑太多高优先级中断,稳定性是完全没问题的。

另一个关键点是它的存储器映射。STM32F1的Flash起始地址是0x08000000,SRAM起始地址是0x20000000,外设寄存器从0x40000000开始映射。这种统一编址的方式让你可以用指针直接操作寄存器,也是为什么很多老工程师喜欢直接写寄存器而不是用库函数的原因。

2.2 型号命名规则与选型建议

STM32F1的命名规则其实很有规律,拿STM32F103C8T6举例:STM32代表产品线,F代表基础型,103是子系列,C代表48脚,8代表64KB Flash,T代表LQFP封装,6代表工业级温度范围-40到85度。

对于DHT11温湿度采集这类项目,我一般推荐这几种型号:

型号FlashRAM引脚数适用场景
STM32F103C8T664KB20KB48最小系统板,性价比最高
STM32F103RCT6256KB48KB64需要多路外设或较大缓存
STM32F103ZET6512KB64KB144复杂项目,带FSMC可驱动LCD
STM32F103C6T632KB10KB48极简应用,成本敏感

C8T6之所以最火,是因为它的Flash和RAM对于大多数入门项目绰绰有余,而且市面上“蓝板”和“黑板”最小系统板基本都是这个型号,价格在10块钱左右。你拿它跑DHT11加OLED显示加串口输出,资源占用不到30%,完全够用。

2.3 开发工具链的选择与取舍

开发STM32F1有几种主流方案:Keil MDK、STM32CubeIDE、IAR EWARM、PlatformIO。我个人的建议是,如果你刚开始学,直接用Keil MDK加标准外设库(SPL),因为网上90%的教程都是这个组合,遇到问题搜一下就能找到答案。虽然ST官方现在主推HAL库和CubeMX,但标准库对于理解底层寄存器操作更有帮助。

Keil MDK的安装就不多说了,重点提一下器件支持包的安装。你需要下载Keil.STM32F1xx_DFP这个Pack,安装之后才能在新建工程时选到STM32F103C8。另外,调试器我推荐用ST-Link V2,便宜好用,支持SWD接口,只需要SWCLK、SWDIO、GND、3.3V四根线就能下载和调试。

如果你习惯用开源工具,PlatformIO加VSCode也是很好的选择,它的库管理更方便,而且跨平台。不过对于DHT11这种需要精确微秒级延时的场景,PlatformIO默认的Arduino框架可能会引入额外开销,建议还是用STM32CubeMX生成底层初始化代码,然后在VSCode里写业务逻辑。

3. DHT11温湿度传感器的工作原理解析

3.1 单总线协议的本质

DHT11用的是单根数据线完成双向通信,这种协议的好处是省引脚,坏处是对时序要求极其严格。它的通信过程是这样的:主机先拉低总线至少18毫秒作为起始信号,然后释放总线,DHT11检测到起始信号后拉低总线80微秒作为响应,接着拉高80微秒准备发送数据,之后就是40位数据的传输。

每一位数据的格式是:50微秒低电平加26到28微秒高电平表示“0”,50微秒低电平加70微秒高电平表示“1”。注意这里的高电平持续时间是区分0和1的关键,而不是低电平。很多新手在这里翻车,就是因为把判断逻辑搞反了。

40位数据的排列顺序是:湿度整数部分8位、湿度小数部分8位、温度整数部分8位、温度小数部分8位、校验和8位。校验和等于前四个字节相加取低8位。DHT11的小数部分实际上一直是0,这是它的精度决定的,温度精度±2度,湿度精度±5%RH。

3.2 为什么微秒级延时如此关键

STM32F1的SysTick定时器可以产生精确的微秒级延时,但前提是你要正确配置。系统时钟72MHz的情况下,SysTick的LOAD寄存器设置为72-1,就能得到1微秒的计数周期。但这里有个坑:如果你用了RTOS或者频繁进出中断,SysTick的延时可能会被拉长。

我在实际项目中的做法是,DHT11的读取函数执行期间关闭全局中断,用__disable_irq()和__enable_irq()包起来。整个读取过程大概需要4到5毫秒,关中断这么久对大多数应用来说是可以接受的。如果你的系统对中断响应要求极高,那就需要考虑用定时器输入捕获的方式来做,不过复杂度会上升不少。

另外,DHT11的供电电压是3.3V到5.5V,STM32F1的GPIO是3.3V电平,所以直接连接没问题。但数据线需要接一个4.7K到10K的上拉电阻,否则总线释放后无法恢复到高电平。很多模块自带上拉电阻,如果你买的是裸传感器,这个电阻必须自己加。

3.3 数据读取的完整时序拆解

我把整个读取过程分成五个阶段,每个阶段的时序要求都列清楚:

  1. 起始信号:主机拉低总线至少18ms,然后拉高20-40us,切换到输入模式等待DHT11响应。
  2. 响应信号:DHT11检测到起始信号后,拉低总线80us,再拉高80us,表示准备发送数据。
  3. 数据位低电平:每位数据都以50us低电平开始,这是数据位的起始标志。
  4. 数据位高电平:低电平结束后,高电平持续26-28us表示0,持续70us表示1。
  5. 结束信号:40位数据发送完毕后,DHT11拉低总线50us,然后释放总线进入空闲状态。

这里有个细节需要注意:在读取每一位数据时,你要先等待低电平结束,然后延时30us左右再采样总线电平。如果此时是高电平,说明这一位是1;如果是低电平,说明这一位是0。这个30us的采样点刚好落在0和1的高电平持续时间之间,是最稳妥的判断时机。

4. 从零搭建DHT11采集系统的完整实操

4.1 硬件连接与最小系统搭建

先列一下需要的物料:STM32F103C8T6最小系统板一块、DHT11传感器一个(裸传感器或模块都行)、ST-Link V2调试器一个、杜邦线若干、4.7K电阻一个(如果传感器不带模块板)、USB转TTL模块一个(用于看串口输出)。

接线方式如下:

  • DHT11的VCC接STM32的3.3V
  • DHT11的GND接STM32的GND
  • DHT11的DATA接STM32的PA0(或者其他任意GPIO)
  • DATA和VCC之间接4.7K上拉电阻
  • ST-Link的SWCLK接PA14,SWDIO接PA13,GND和3.3V对应连接
  • USB转TTL的RX接PA9(STM32的TX),TX接PA10(STM32的RX),GND共地

这里提醒一句:DHT11的DATA引脚在空闲时应该被上拉电阻拉高,如果你测量到的是低电平,说明传感器可能坏了或者接线有问题。我遇到过好几次因为杜邦线接触不良导致读取失败的情况,建议用万用表确认一下连接可靠性。

4.2 工程配置与时钟树设置

用Keil新建工程,选择STM32F103C8器件,然后在工程选项里勾选Use MicroLIB(方便printf重定向)。时钟配置方面,我用的是外部8MHz晶振,通过PLL倍频到72MHz。具体寄存器配置如下:

// 使能外部高速晶振 RCC->CR |= RCC_CR_HSEON; while(!(RCC->CR & RCC_CR_HSERDY)); // 配置PLL:HSE * 9 = 72MHz RCC->CFGR |= RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9; // 使能PLL RCC->CR |= RCC_CR_PLLON; while(!(RCC->CR & RCC_CR_PLLRDY)); // 配置Flash等待周期 FLASH->ACR |= FLASH_ACR_LATENCY_2; // 切换系统时钟到PLL RCC->CFGR |= RCC_CFGR_SW_PLL; while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL); // 配置APB1为36MHz,APB2为72MHz RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;

如果你用标准库,这些配置可以用SystemInit()函数自动完成,但理解底层原理对排查问题很有帮助。比如有一次我遇到串口波特率不对,最后发现是APB1时钟配置错了,导致USART2的时钟源频率不对。

4.3 SysTick微秒延时函数的实现

DHT11的时序要求微秒级精度,所以必须自己实现一个可靠的延时函数。我用的是SysTick轮询方式,不开启中断:

void delay_us(uint32_t us) { uint32_t temp; SysTick->LOAD = 72 * us; // 72MHz下1us需要72个计数 SysTick->VAL = 0; SysTick->CTRL = 0x01; // 使能,不开启中断,使用HCLK do { temp = SysTick->CTRL; } while((temp & 0x01) && !(temp & (1 << 16))); // 等待计数到0 SysTick->CTRL = 0x00; SysTick->VAL = 0; }

这个函数的原理很简单:设置LOAD值为72乘以微秒数,启动定时器,然后轮询COUNTFLAG标志位。注意这里没有用中断,所以不会影响其他中断的响应。但如果你在中断服务函数里调用这个延时,要小心SysTick被其他代码修改。

实测下来,这个延时函数的误差在1%以内,对于DHT11的时序要求来说完全够用。如果你需要更精确的延时,可以用定时器输入捕获来测量实际延时时间,然后做补偿。

4.4 DHT11数据读取函数的完整实现

下面是我用了很多年的DHT11读取函数,代码结构清晰,容错处理也做得比较到位:

#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_Pin_0 // 配置GPIO方向 void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); } void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); } // 读取一个字节 uint8_t DHT11_ReadByte(void) { uint8_t i, byte = 0; for(i = 0; i < 8; i++) { while(!GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)); // 等待50us低电平结束 delay_us(30); // 延时30us后采样 byte <<= 1; if(GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)) { byte |= 1; } while(GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)); // 等待高电平结束 } return byte; } // 读取温湿度 uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; __disable_irq(); // 关中断,保证时序 // 起始信号 DHT11_SetOutput(); GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(18000); // 拉低至少18ms GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); DHT11_SetInput(); // 等待DHT11响应 if(!GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)) { while(!GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)); // 等待80us低电平结束 while(GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)); // 等待80us高电平结束 // 读取40位数据 for(i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } __enable_irq(); // 校验 if(buf[0] + buf[1] + buf[2] + buf[3] == buf[4]) { *humi = buf[0]; *temp = buf[2]; return 0; // 成功 } } __enable_irq(); return 1; // 失败 }

这段代码有几个关键点:第一,起始信号拉低18ms,这个时间不能太短,否则DHT11检测不到;第二,读取字节时先等待低电平结束,再延时30us采样,这个采样点刚好在0和1的高电平持续时间之间;第三,校验和验证是必须的,如果校验失败说明数据不可信,应该丢弃重读。

4.5 串口输出与数据格式化

为了能看到温湿度数据,我们需要把读取结果通过串口打印出来。STM32F1的USART1挂在APB2上,时钟72MHz,配置波特率115200的代码如下:

void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 TX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 RX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // printf重定向 int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

主循环里每隔2秒读取一次DHT11,然后打印:

int main(void) { uint8_t temp, humi; delay_init(); USART1_Init(); printf("DHT11 Test Start\r\n"); while(1) { if(DHT11_ReadData(&temp, &humi) == 0) { printf("Temp: %d C, Humi: %d %%RH\r\n", temp, humi); } else { printf("DHT11 Read Error\r\n"); } delay_ms(2000); // DHT11采样周期不能小于1秒 } }

注意DHT11的采样周期不能小于1秒,否则传感器内部可能还没完成上一次转换,导致读取失败。我一般用2秒,既保证数据刷新率,又不会给传感器太大压力。

5. 常见问题排查与实战避坑指南

5.1 读取失败的五种典型原因

在实际项目中,DHT11读取失败是最常见的问题。我整理了一个排查表,按概率从高到低排列:

故障现象可能原因排查方法解决方案
完全无响应接线错误或传感器损坏测量DATA引脚电压检查VCC、GND、DATA连接
响应但数据全0上拉电阻缺失测量空闲时DATA电平加4.7K上拉电阻
数据偶尔错误时序被中断打断检查是否有高优先级中断读取期间关中断
校验和始终失败采样点偏移用示波器看波形调整延时参数
读取间隔太短采样周期不足检查延时时间间隔至少1秒

我最常遇到的是第一种和第二种。特别是用裸传感器的时候,忘记加上拉电阻导致总线无法拉高,读取函数会一直卡在等待高电平的循环里。这时候你可以用万用表测一下DATA引脚,正常空闲时应该是3.3V,如果是0V或者浮空,那就是上拉电阻的问题。

5.2 时序调试的实用技巧

如果你有示波器或者逻辑分析仪,调试DHT11会轻松很多。把探头接到DATA线上,触发方式设为下降沿,你就能看到完整的通信波形。正常的波形应该是:主机拉低18ms,然后释放,DHT11拉低80us响应,再拉高80us,接着是40位数据,每位以50us低电平开始。

如果没有示波器,可以用一个笨办法:在读取函数的每个关键节点翻转一个空闲GPIO,然后用逻辑分析仪或者另一个STM32来捕获。比如在起始信号开始时拉高PB0,在响应信号检测到时拉低PB0,这样就能大致判断时序是否正常。

还有一个技巧是,把DHT11的读取函数放在定时器中断里,用定时器的输入捕获功能来测量高电平持续时间。如果测得的高电平在26-28us之间就是0,在70us左右就是1。这种方式不依赖延时函数,抗干扰能力更强,但实现起来复杂一些。

5.3 电源噪声与长线传输的影响

DHT11对电源噪声比较敏感,如果你用开关电源供电,建议在VCC和GND之间加一个100nF的陶瓷电容。我遇到过好几次因为电源纹波太大导致读取失败的情况,加了电容之后问题就消失了。

另外,如果传感器离STM32比较远(超过20厘米),建议用屏蔽线或者双绞线,并且在接收端加一个100欧姆的电阻做阻抗匹配。长线传输会导致信号边沿变缓,影响时序判断。如果条件允许,尽量把DHT11靠近STM32放置。

5.4 多传感器组网的注意事项

有些项目需要多个DHT11同时工作,这时候不能把它们都挂在同一根总线上,因为单总线协议不支持多设备寻址。我的做法是用多个GPIO分别连接不同的DHT11,然后分时读取。每个传感器的读取间隔还是保持2秒,轮流读取的话,4个传感器8秒完成一轮,对于环境监测来说完全够用。

如果你需要更多传感器,可以考虑用I2C接口的温湿度传感器,比如SHT30或者AHT20,它们支持多设备挂载在同一总线上,通过地址区分。不过那是另一个话题了,DHT11的优势就是便宜和简单,适合入门和快速验证。

6. 从DHT11项目延伸出的STM32学习路径

6.1 下一步可以玩什么

DHT11项目跑通之后,你已经掌握了GPIO操作、微秒延时、单总线协议、串口通信这几个核心技能。接下来可以尝试把这些数据通过OLED显示出来,用I2C接口的0.96寸OLED,SSD1306驱动,网上有现成的库可以用。这样你就又学会了I2C通信。

再进一步,可以把温湿度数据通过ESP8266上传到云平台,或者用NRF24L01做无线传输。如果你对RTOS感兴趣,可以试试在FreeRTOS里创建多个任务,一个任务读DHT11,一个任务刷新OLED,一个任务处理串口命令。这样能学到任务调度和信号量的使用。

6.2 我踩过的那些坑

最后分享几个我在DHT11项目里踩过的坑,希望能帮你省点时间。第一个坑是用了HAL库的HAL_Delay()做微秒延时,结果发现最小延时是1毫秒,完全没法用。后来才知道HAL库的延时是基于SysTick中断的,精度只有毫秒级。第二个坑是在中断里调用DHT11读取函数,结果因为中断嵌套导致时序错乱,数据全是乱的。第三个坑是忘了DHT11上电后需要1秒的稳定时间,上电立刻读取会失败,加个延时就好了。

还有一个比较隐蔽的坑:如果你用Keil的软件仿真来调试DHT11,会发现读取永远失败,因为软件仿真无法模拟外部传感器的响应。DHT11的调试必须用真实硬件,配合串口打印或者示波器来观察。

6.3 关于代码风格的建议

写STM32代码,我建议把硬件相关的宏定义都放在头文件里,比如引脚号、端口号、延时参数这些。这样以后换引脚或者换芯片,只需要改头文件就行。另外,读取函数返回错误码而不是直接返回数据,这样调用者可以决定是重试还是报错。

中断优先级配置也要养成好习惯,NVIC的优先级分组建议用NVIC_PriorityGroup_2,这样抢占优先级和响应优先级各有2位,比较均衡。DHT11读取期间关中断虽然简单粗暴,但在大多数应用里是可行的,只要你的中断服务函数执行时间不太长就行。

如果你正在做DHT11相关的项目,或者刚开始学STM32F1,希望这篇文章能帮你少走点弯路。嵌入式这东西,看再多教程不如自己动手焊一块板子,代码跑起来的那一刻,所有困惑都会迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询