ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F1驱动DHT11实战:从时序解析到串口打印的避坑指南

STM32F1驱动DHT11实战:从时序解析到串口打印的避坑指南

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

STM32F1系列,具体点说就是STM32F103这颗芯片,在嵌入式圈子里属于那种“你绕不过去”的存在。我接触过的项目里,从学生做的温湿度采集小盒子,到工厂里跑的简易PLC控制器,再到各种电机驱动板,STM32F103的身影到处都是。它用的是ARM Cortex-M3内核,主频常见72MHz,片上Flash从16KB到1MB不等,SRAM从6KB到96KB,封装形式覆盖LQFP48、LQFP64、LQFP100甚至BGA。这些参数放到今天看确实不算亮眼,但它的外设资源极其均衡:多路USART、SPI、I2C、CAN、USB、ADC、DAC、定时器,几乎你能想到的嵌入式基础外设它都有。

那为什么现在还要聊它?因为对于绝大多数入门和中级嵌入式项目来说,STM32F1的性价比和生态成熟度依然是碾压级的。你花十几块钱买一片STM32F103C8T6最小系统板,插上电脑就能开始写代码,标准外设库、HAL库、LL库三种开发方式任选,社区里随便搜一个报错信息都能找到十年前就有人踩过的坑和解决方案。更关键的是,它的资料多到令人发指——中文参考手册、固件库使用手册、各种开发板的原理图,全部公开可查。这意味着你遇到问题时,几乎不可能成为“第一个遇到这个问题的人”。

这篇文章我打算围绕一个非常具体的场景来展开:用STM32F1驱动DHT11温湿度传感器,把采集到的数据通过串口打印出来,同时把整个开发流程中那些“教程里不会写但实际一定会遇到”的细节全部摊开讲。DHT11这个传感器便宜、简单、单总线通信,但它的时序要求极其严格,微秒级的延时偏差就会导致读取失败。很多新手照着教程接线、抄代码,结果串口助手上一片乱码或者干脆读不出数据,然后就卡住了。我会从工程搭建、时钟配置、GPIO模式选择、微秒延时实现、DHT11时序解析、数据校验、串口重定向这几个维度,把每一步的“为什么”讲清楚。

适合谁看?如果你刚拿到一块STM32F103开发板,想从点灯进阶到实际传感器采集,这篇文章就是为你写的。如果你已经用过DHT11但总是读失败,我也会把常见故障的排查路径整理成表格,方便你对照定位。如果你是想做温湿度监控、农业大棚采集、机房环境监测这类项目,这套代码可以直接拿去改。我不打算用那种“第一步打开Keil,第二步新建工程”的流水账写法,而是把每个关键决策背后的逻辑讲透,让你看完之后能自己判断遇到的新问题该怎么解。

2. 开发环境搭建与工程结构设计

2.1 工具链选型:Keil、STM32CubeMX还是PlatformIO

开发STM32F1,工具链的选择直接决定了你后面调试的效率。我试过三种主流方案,各有各的适用场景。

Keil MDK是传统嵌入式开发者的首选,配合ST-Link调试器,断点、单步、寄存器查看都很顺手。它的优势在于编译器优化成熟,生成的代码体积小,而且网上绝大多数STM32F1的例程都是Keil工程,直接拿来就能编译。缺点是Keil的编辑器体验一般,代码补全和跳转不如现代IDE流畅,而且需要手动管理工程文件结构。

STM32CubeMX是ST官方推出的图形化配置工具,你可以通过点选界面配置时钟树、引脚复用、外设参数,然后一键生成初始化代码。对于STM32F1来说,CubeMX支持生成HAL库和LL库两种代码。HAL库抽象层次高,移植方便,但代码效率略低;LL库更接近寄存器操作,执行效率高,但可读性稍差。我个人的习惯是:新手用CubeMX生成HAL库框架,快速把外设跑通;等项目稳定后,如果对性能有要求,再把关键部分换成LL库或直接操作寄存器。

PlatformIO是近几年流行起来的嵌入式开发平台,基于VS Code,用platformio.ini文件管理工程配置。它的优势是跨平台、依赖管理清晰、支持多种框架(Arduino、HAL、libopencm3等)。如果你习惯用VS Code写代码,PlatformIO的体验非常好。但它对STM32F1的调试支持不如Keil原生,需要额外配置OpenOCD。

我最终选择的是Keil MDK + STM32CubeMX的组合。CubeMX负责生成时钟和GPIO初始化代码,Keil负责编写业务逻辑和调试。这样既保证了初始化代码的可靠性,又保留了调试的便利性。

2.2 工程目录结构:别把所有文件堆在根目录

很多新手拿到例程后,所有.c和.h文件都堆在一个文件夹里,工程稍微大一点就乱成一锅粥。我建议从一开始就建立清晰的目录结构,后面加外设、加功能模块时你会感谢自己。

我的标准目录结构是这样的:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f1xx_it.c │ └── system_stm32f1xx.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── BSP/ │ ├── Inc/ │ │ ├── dht11.h │ │ ├── delay.h │ │ └── usart.h │ └── Src/ │ ├── dht11.c │ ├── delay.c │ └── usart.c └── App/ ├── Inc/ │ └── app_main.h └── Src/ └── app_main.c

Core放CubeMX生成的初始化代码,Drivers放HAL库和CMSIS核心文件,BSP放板级支持包(DHT11驱动、延时函数、串口驱动),App放应用层逻辑。这样分层之后,换一块板子只需要改BSP层,应用层代码几乎不用动。

2.3 时钟树配置:72MHz不是随便设的

STM32F103的最高主频是72MHz,但并不是所有型号都能跑到这个频率。比如STM32F103C8T6可以,但某些低功耗型号最高只有48MHz或36MHz。配置时钟树之前,先确认你手上芯片的数据手册。

CubeMX里配置时钟的步骤:选择HSE(外部高速时钟)为Crystal/Ceramic Resonator,然后在Clock Configuration标签页里,把PLL Source选为HSE,PLL Mul设为9倍频。假设你的外部晶振是8MHz,那么8MHz × 9 = 72MHz,这就是SYSCLK。然后AHB Prescaler设为1,APB1 Prescaler设为2(APB1最高36MHz),APB2 Prescaler设为1(APB2最高72MHz)。

这里有个细节:APB1上的外设(如USART2、USART3、I2C1、SPI2)时钟频率是36MHz,而APB2上的外设(如USART1、SPI1、ADC1)时钟频率是72MHz。如果你用USART1做串口打印,波特率计算时的时钟源就是72MHz;如果用USART2,时钟源就是36MHz。这个区别在计算波特率寄存器值时必须搞清楚,否则串口输出就是乱码。

注意:如果你用的是STM32F103C8T6最小系统板,板载晶振通常是8MHz,但有些廉价板子用的是12MHz晶振。买之前一定看清楚商品描述,或者拿到板子后用示波器测一下晶振引脚。晶振频率不对,时钟配置全错,后面所有外设都不工作。

3. DHT11驱动核心细节与时序解析

3.1 DHT11的单总线协议到底特殊在哪

DHT11用的是单根数据线完成双向通信,这根线既要输出又要输入,所以GPIO的模式必须动态切换。通信过程是这样的:主机(STM32)先拉低总线至少18毫秒,然后释放总线,DHT11检测到这个起始信号后,会拉低总线80微秒作为响应,接着拉高80微秒,然后开始传输40位数据。每一位数据都以50微秒的低电平开始,后面的高电平持续时间决定数据是0还是1:高电平持续26到28微秒表示0,持续70微秒表示1。

这个时序的容错窗口非常窄。以判断0和1为例,分界点大约在40微秒左右。如果你的延时函数误差超过10微秒,就可能把1读成0,或者把0读成1。更麻烦的是,DHT11对起始信号的响应时间也有要求,主机拉低总线的时间必须在18毫秒到30毫秒之间,太短了传感器不响应,太长了传感器可能进入异常状态。

我实测下来,用HAL_Delay()做毫秒级延时没问题,但微秒级延时绝对不能依赖HAL库。HAL库的微秒延时通常用SysTick或者DWT实现,但SysTick默认配置是1毫秒中断,改成1微秒中断会严重影响系统性能。所以必须自己写一个基于DWT(Data Watchpoint and Trace)或者定时器的微秒延时函数。

3.2 微秒延时函数:DWT方案与定时器方案对比

DWT是Cortex-M3内核自带的一个调试组件,其中有一个32位的循环计数器CYCCNT,每经过一个CPU时钟周期就加1。在72MHz主频下,CYCCNT每增加72个数就是1微秒。用DWT做微秒延时的代码如下:

// delay.c #include "delay.h" static uint32_t fac_us = 0; void delay_init(void) { // 使能DWT外设 CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 计算每微秒的时钟周期数 fac_us = SystemCoreClock / 1000000; } void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * fac_us; while ((DWT->CYCCNT - start) < ticks); }

这个方案的优点是精度高、不占用定时器资源、代码简单。缺点是DWT在部分低端调试器上可能不可用,而且如果系统进入低功耗模式,DWT计数器会停止。

另一种方案是用一个通用定时器(比如TIM4)做微秒延时。配置定时器预分频器为71,这样计数器每72个时钟周期溢出一次,也就是1微秒。然后通过读取计数器的值来计算延时。这种方案不依赖调试组件,但占用一个定时器资源,而且中断优先级配置不当可能影响其他外设。

我最终选择DWT方案,因为STM32F103的DWT功能稳定,而且不占用宝贵的外设资源。但要注意,DWT的CYCCNT是32位的,在72MHz下大约59.6秒就会溢出一次。对于DHT11这种单次通信不超过5毫秒的场景,完全不用担心溢出问题。

3.3 GPIO模式切换:开漏输出与浮空输入的配合

DHT11的数据线在通信过程中需要在输出和输入之间切换。STM32的GPIO有四种输出模式和四种输入模式,选错了会导致通信失败。

主机拉低总线时,GPIO配置为推挽输出,输出低电平。主机释放总线时,如果配置为推挽输出并输出高电平,那么总线会被强行拉高,但DHT11在响应阶段也会拉低总线,这时候如果STM32还在输出高电平,就会和DHT11形成电平冲突,严重时可能损坏引脚。

正确的做法是:主机释放总线时,把GPIO切换为浮空输入模式(或者上拉输入模式),让总线由上拉电阻拉到高电平。DHT11拉低总线时,STM32读取到低电平;DHT11释放总线时,上拉电阻把总线拉高,STM32读取到高电平。这样就不会有电平冲突。

具体代码实现:

// dht11.c #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 // 配置为推挽输出 static void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } // 配置为浮空输入 static void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); }

提示:DHT11的数据线必须接一个4.7kΩ到10kΩ的上拉电阻到3.3V。很多DHT11模块自带上拉电阻,但如果你买的是裸传感器,一定要自己加。没有上拉电阻,总线释放后无法回到高电平,通信必然失败。

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

4.1 硬件接线与供电检查

先确认硬件连接。DHT11有三个引脚(有些型号是四个引脚,其中一个悬空):VCC、DATA、GND。VCC接3.3V,GND接GND,DATA接STM32的PA1(或者其他任意GPIO,代码里改一下宏定义就行)。

供电电压很关键。DHT11的工作电压范围是3.3V到5.5V,但如果你用5V供电,而STM32的GPIO是3.3V电平,那么DHT11输出的高电平是5V,直接接到STM32引脚上可能损坏芯片。所以要么用3.3V给DHT11供电,要么在数据线上加电平转换电路。我建议直接用3.3V供电,简单省事。

接线完成后,先用万用表测一下DHT11的VCC和GND之间是不是3.3V,确认供电正常。然后测一下DATA线对GND的电压,应该是3.3V左右(上拉电阻把总线拉高了)。如果测出来是0V,检查上拉电阻是否接好,或者DHT11是否已经损坏。

4.2 DHT11读取函数的完整实现

下面是DHT11读取的完整代码,我加了详细的注释说明每一步的意图:

// dht11.c #include "dht11.h" #include "delay.h" #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 // 读取一个字节 static uint8_t DHT11_ReadByte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { // 等待50us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 延时40us后采样 delay_us(40); byte <<= 1; if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { byte |= 1; } // 等待高电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); } return byte; } // 读取温湿度 uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; // 主机拉低总线至少18ms DHT11_SetOutput(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 主机释放总线,切换为输入模式 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DHT11_SetInput(); // 等待DHT11响应低电平 uint32_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++timeout > 10000) return 1; // 超时 } // 等待响应低电平结束 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) { if (++timeout > 10000) return 2; } // 等待响应高电平结束 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++timeout > 10000) return 3; } // 读取40位数据 for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } // 校验:前四个字节之和等于第五个字节 if (buf[4] != (buf[0] + buf[1] + buf[2] + buf[3])) { return 4; // 校验失败 } *humi = buf[0]; *temp = buf[2]; return 0; // 成功 }

这段代码里有几个关键点需要展开说。

第一个是超时处理。DHT11如果没接好或者损坏,总线可能一直保持高电平或低电平,如果没有超时退出机制,程序就会死循环。我在每个等待循环里都加了timeout计数,超过阈值就返回错误码。这个阈值10000是我实测出来的,在72MHz主频下,10000次循环大约对应几毫秒,足够覆盖DHT11的正常响应时间。

第二个是采样时机。DHT11的每一位数据都以50微秒低电平开始,然后高电平持续26到28微秒表示0,持续70微秒表示1。我在等待低电平结束后,延时40微秒再采样。这个40微秒是分界点:如果高电平持续26微秒,40微秒后已经变成低电平,读到0;如果高电平持续70微秒,40微秒后还是高电平,读到1。这个采样点选在40微秒,容错窗口大约有12微秒,对于DWT延时来说完全够用。

第三个是校验。DHT11的40位数据是:湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节之和的低8位。如果校验失败,说明数据传输过程中出现了位错误,这次读取的数据不可信,应该丢弃并重新读取。

4.3 串口重定向与数据打印

读取到温湿度后,需要把数据打印到串口助手。STM32的HAL库默认没有实现printf,需要重定向fputc函数:

// usart.c #include "usart.h" #include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }

然后在main函数里初始化USART1,波特率设为115200,8位数据位,无校验,1位停止位。主循环里每隔2秒读取一次DHT11,打印格式如下:

// main.c while (1) { uint8_t temp, humi; uint8_t ret = DHT11_ReadData(&temp, &humi); if (ret == 0) { printf("Temperature: %d C, Humidity: %d %%\r\n", temp, humi); } else { printf("DHT11 read error: %d\r\n", ret); } HAL_Delay(2000); }

注意:DHT11的采样周期不能小于1秒。如果你连续快速读取,传感器可能还没完成上一次测量,返回的数据会是上一次的旧值或者直接失败。我建议采样间隔至少2秒,这样数据稳定可靠。

5. 常见问题排查与避坑经验实录

5.1 读取失败问题速查表

现象可能原因排查方法解决方案
串口无输出串口未初始化或波特率不对检查USART配置和串口助手设置确认波特率115200,TX/RX接线正确
一直返回错误码1DHT11未响应起始信号测DATA线电压,检查上拉电阻加上拉电阻,确认DHT11供电正常
一直返回错误码4校验失败,数据位错误检查延时函数精度用示波器测微秒延时,换DWT方案
温度湿度值固定不变传感器未更新数据检查采样间隔采样间隔改为2秒以上
数据偶尔正确偶尔错误时序抖动或电源噪声检查电源纹波,缩短杜邦线加100nF去耦电容,线长不超过20cm
上电后第一次读取失败DHT11上电需要稳定时间上电后延时1秒再读取在初始化后加HAL_Delay(1000)

5.2 我踩过的三个坑

第一个坑是杜邦线太长。我一开始用20cm的杜邦线连接DHT11,读取成功率只有50%左右。后来把线缩短到10cm以内,成功率立刻提升到95%以上。原因是单总线协议对信号完整性要求高,长线带来的分布电容会导致上升沿变缓,DHT11可能识别不到正确的电平。如果你必须用长线,建议在DATA线和GND之间加一个100pF的小电容,可以改善信号质量。

第二个坑是电源噪声。我用USB供电给STM32和DHT11,同时USB还在给其他设备供电,结果DHT11读取频繁失败。后来在DHT11的VCC和GND之间加了一个100nF的陶瓷电容,问题解决。DHT11内部有振荡电路,电源噪声会影响它的时序精度。这个电容在DHT11模块上通常已经焊好了,但如果你买的是裸传感器,一定要自己加。

第三个坑是HAL_Delay的精度问题。HAL_Delay依赖SysTick中断,如果中断优先级配置不当,或者有其他高优先级中断频繁触发,HAL_Delay的实际延时时间会偏长。DHT11的起始信号要求拉低18到30毫秒,我用HAL_Delay(20)本来以为够了,结果因为系统里有其他中断,实际拉低时间可能超过30毫秒,导致DHT11不响应。后来我改用DWT做毫秒延时,问题解决。所以对于时序敏感的场合,不要依赖HAL_Delay。

5.3 进阶优化建议

如果你想让这个项目更稳定,可以考虑以下几个优化方向。

第一,给DHT11读取加互斥锁。如果你的系统里有多个任务或中断可能调用DHT11读取函数,必须加互斥保护,否则两个任务同时操作GPIO会导致时序混乱。最简单的做法是用一个全局标志位,读取前检查标志位,读取过程中置位,读取完成后清零。

第二,增加数据滤波。DHT11的精度是±2°C和±5%RH,单次读取的值可能有波动。你可以连续读取三次,取中间值作为最终结果。这样能有效滤除偶发的异常值。

第三,把数据上传到上位机。串口打印只是最基础的展示方式,你可以把数据通过ESP8266传到手机APP,或者存到SD卡里做长期记录。STM32F1的SPI接口接SD卡模块很方便,FATFS文件系统也有现成的移植方案。

第四,低功耗改造。如果你的项目是电池供电,可以把STM32F1配置为Stop模式,每隔一段时间用RTC唤醒,读取一次DHT11后继续休眠。STM32F103在Stop模式下的电流大约20微安,用两节AA电池可以撑好几个月。

6. 从DHT11延伸到STM32F1的通用开发思路

DHT11这个项目虽然小,但它涵盖了STM32F1开发的几个核心技能:GPIO模式切换、微秒级延时、单总线协议解析、串口通信、错误处理。把这几个点吃透之后,你再去看其他单总线设备(比如DS18B20温度传感器)或者I2C设备(比如OLED屏幕),会发现套路是相通的。

我个人的经验是,学STM32F1不要贪多,先把一个外设玩透。比如你搞定了DHT11,就理解了GPIO的输入输出切换、时序的精确控制、超时机制的设计。然后你再去看SPI通信,会发现无非是多了时钟线和片选线,数据采样时机从软件延时变成了硬件时钟。再去看I2C,无非是多了起始条件和应答机制。底层逻辑都是一样的:配置外设、等待标志位、读写数据、处理错误。

另外,STM32F1的参考手册一定要放在手边。很多问题你在网上搜半天,不如直接翻手册看寄存器描述。比如GPIO的CRL和CRH寄存器,每个引脚占4个位,MODE位和CNF位组合决定了引脚的工作模式。你把这些寄存器的含义搞清楚,配置GPIO就是几行代码的事,根本不需要HAL库。

最后说一个我自己的习惯:每做一个新项目,我都会在BSP层写一个通用的错误处理框架。每个驱动函数返回一个错误码,0表示成功,非0表示不同类型的错误。应用层根据错误码决定是重试、报警还是忽略。这样代码的可维护性会好很多,调试的时候也能快速定位问题出在哪个环节。DHT11的读取函数里我返回了1到4四种错误码,分别对应不同的失败原因,就是这个思路的体现。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进