ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32F1驱动DHT11实战:微秒级延时与单总线时序解析

STM32F1驱动DHT11实战:微秒级延时与单总线时序解析 1. 为什么STM32F1到今天还值得折腾1.1 一颗老芯片的生存逻辑STM32F1系列是ST在2007年前后推出的基于ARM Cortex-M3内核的32位微控制器最经典的型号就是STM32F103C8T6也就是大家常说的“蓝板”或“最小系统板”。这颗芯片的主频72MHzFlash 64KB到512KB不等SRAM 20KB起步外设涵盖GPIO、USART、SPI、I2C、ADC、定时器、CAN、USB等基本上一个嵌入式项目需要的东西它都有。很多人会问现在都2025年了国产替代芯片满天飞为什么还要用STM32F1我的实际体会是三个字稳、熟、便宜。稳是因为这颗芯片被无数产品验证过工业现场跑了十几年的设备里到处都是它熟是因为资料多到离谱任何一个外设配置都能找到现成的参考代码便宜是因为国产替代和拆机件把价格压到了几块钱一片做小批量项目成本极低。1.2 这篇文章适合谁看如果你刚学完C语言想找一个能真正动手的32位平台入门嵌入式STM32F1是最合适的起点。如果你已经用过Arduino觉得库函数封装太深想往下挖一层STM32F1的标准外设库和HAL库能让你看清寄存器层面的东西。如果你在做一个小项目比如温湿度采集、电机控制、串口屏交互STM32F1的性能绰绰有余。这篇文章我会围绕一个具体的实战场景展开用STM32F1驱动DHT11温湿度传感器把数据通过串口打印出来。这个场景看起来简单但里面涉及GPIO时序控制、微秒级延时、单总线协议解析、串口通信等核心知识点把这一套跑通你对STM32F1的掌握就能上一个台阶。1.3 整体方案的设计思路做任何嵌入式项目第一步不是写代码而是想清楚数据怎么流。DHT11是单总线器件一根数据线既要发命令又要收数据时序要求严格到微秒级。STM32F1的GPIO可以配置为开漏输出和浮空输入正好满足单总线双向通信的需求。数据采集回来后通过USART1以9600或115200波特率发送到PC端串口助手显示。整个系统的核心难点不在DHT11本身而在于微秒级延时的实现。STM32F1的SysTick定时器可以产生1微秒的中断但中断本身有开销实际延时会有偏差。更可靠的做法是用定时器做硬件计数或者用__NOP()指令做粗略延时。我在实际项目中试过几种方案后面会详细对比。2. DHT11与STM32F1的硬件连接与底层原理2.1 DHT11到底是怎么工作的DHT11是一款含有已校准数字信号输出的温湿度复合传感器内部由一个电阻式感湿元件和一个NTC测温元件组成还有一个8位单片机负责采集和输出。它的测量范围是温度0到50摄氏度精度正负2摄氏度湿度20%到90%RH精度正负5%RH。采样周期不能低于1秒否则数据会不准。通信协议是单总线一次完整的数据传输是40位包含8位湿度整数、8位湿度小数、8位温度整数、8位温度小数、8位校验和。校验和的规则是前四个字节相加取低8位如果校验通过说明数据有效。DHT11的小数部分实际上一直是0这是它的硬件限制不是代码问题。单总线的时序是这样的主机拉低总线至少18毫秒作为起始信号然后拉高20到40微秒等待DHT11响应。DHT11检测到起始信号后会拉低总线80微秒再拉高80微秒作为响应。之后DHT11开始发送数据每一位数据都以50微秒的低电平开始然后高电平的持续时间决定数据是0还是1高电平持续26到28微秒表示0持续70微秒表示1。2.2 硬件连接与上拉电阻的选择DHT11一般有三针或四针封装三针的是VCC、DATA、GND四针的多一个NC空脚。DATA引脚必须接一个上拉电阻典型值是4.7K到10K我一般用4.7K。这个电阻的作用是在总线空闲时把电平拉高因为STM32的GPIO配置为开漏输出时无法主动输出高电平。连接方式很简单DHT11的VCC接STM32的3.3VGND接GNDDATA接STM32的某个GPIO比如PA0。上拉电阻一端接3.3V另一端接DATA线。如果你用的是DHT11模块而不是裸传感器模块上通常已经集成了上拉电阻直接插线就行。注意DHT11的工作电压是3.3V到5.5VSTM32F1的GPIO是3.3V电平直接连接没有问题。但如果你用5V给DHT11供电DATA线的上拉电阻也接到5V那么DATA线的高电平就是5V会超过STM32的耐压值。虽然STM32F1的很多引脚是5V容忍的但为了保险建议统一用3.3V供电。2.3 GPIO模式的切换逻辑单总线通信的关键在于GPIO要在输出和输入之间切换。发送起始信号时GPIO配置为推挽输出先输出低电平18毫秒再输出高电平20微秒然后立即切换为浮空输入或上拉输入等待DHT11的响应。这里有个细节切换模式时需要操作CRL或CRH寄存器标准外设库提供了GPIO_Init函数但频繁调用会有开销。我在实际项目中会直接操作寄存器来切换速度更快。比如把PA0切换为浮空输入就是清除GPIOA_CRL的低4位然后设置为0x4浮空输入。切换为推挽输出就是设置为0x3推挽输出50MHz。// 快速切换PA0为浮空输入 GPIOA-CRL 0xFFFFFFF0; GPIOA-CRL | 0x00000004; // 快速切换PA0为推挽输出 GPIOA-CRL 0xFFFFFFF0; GPIOA-CRL | 0x00000003;这种直接操作寄存器的方式比调用库函数快很多在微秒级时序要求下非常必要。3. 微秒级延时的三种实现方案对比3.1 为什么延时是核心难点DHT11的时序要求是微秒级的比如判断数据0还是1需要在高电平开始后30微秒左右采样。如果延时不准采样点就会偏移导致数据解析错误。STM32F1的SysTick可以配置为1毫秒中断但1微秒的中断太频繁CPU根本来不及处理。所以需要其他方案。我试过三种方案__NOP()空指令延时、SysTick计数延时、定时器硬件延时。每种方案都有适用场景下面逐一分析。3.2 方案一__NOP()空指令延时__NOP()是汇编指令执行一个时钟周期。STM32F1在72MHz下一个时钟周期约13.9纳秒。要延时1微秒大约需要72个__NOP()。但实际执行时循环本身也有开销所以需要实测校准。void delay_us(uint32_t us) { uint32_t i; for(i 0; i us; i) { __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); // 大约需要70个NOP实际根据编译优化调整 } }这种方案的优点是简单不需要配置任何外设。缺点是精度受编译器优化影响大如果开了-O2优化循环可能被优化掉。而且不同批次的芯片时钟可能有偏差。我一般只在时序要求不严格的场合用这个。3.3 方案二SysTick计数延时SysTick是一个24位的递减计数器可以配置为1微秒计数一次。通过读取当前计数值和重装载值可以计算出经过了多少微秒。void delay_us(uint32_t us) { uint32_t start SysTick-VAL; uint32_t ticks us * (SystemCoreClock / 1000000); uint32_t elapsed 0; while(elapsed ticks) { uint32_t current SysTick-VAL; if(current start) elapsed start - current; else elapsed start (SysTick-LOAD - current); } }这个方案的精度比NOP高但需要处理计数器翻转的情况。而且SysTick通常被用作系统滴答定时器如果被占用就不能用这个方案。3.4 方案三定时器硬件延时用TIM2或TIM3做微秒延时是最可靠的方案。配置定时器预分频为71计数频率就是1MHz每个计数就是1微秒。需要延时时重置计数器等待计数达到目标值。void delay_us(uint32_t us) { TIM2-CNT 0; while(TIM2-CNT us); }这个方案精度最高不占用CPU中断也不受编译器优化影响。缺点是需要占用一个定时器。我在实际项目中优先用这个方案因为DHT11的时序容错率不高延时不准会导致数据频繁校验失败。3.5 三种方案的实测对比方案精度CPU占用配置复杂度适用场景__NOP()正负0.5微秒高低时序宽松的场合SysTick正负0.2微秒中中SysTick未被占用时定时器正负0.1微秒低中时序严格的场合实测下来DHT11用定时器方案最稳连续读取100次校验失败次数为0。用NOP方案偶尔会失败尤其是编译优化等级变化时。4. 完整代码实现与逐行解析4.1 工程结构与依赖我用的开发环境是Keil MDK 5芯片型号STM32F103C8T6固件库用的是标准外设库V3.5.0。工程目录结构如下Project/ ├── CMSIS/ ├── FWlib/ ├── User/ │ ├── main.c │ ├── dht11.c │ ├── dht11.h │ ├── usart.c │ └── usart.h └── Output/标准外设库虽然ST已经不再维护但代码结构清晰适合学习。HAL库抽象层次更高但调试时不容易看清底层。我建议初学者先用标准库把外设搞明白再转HAL库。4.2 DHT11驱动代码先定义GPIO操作宏方便切换输入输出#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_Pin_0 #define DHT11_GPIO_CLK RCC_APB2Periph_GPIOA #define DHT11_OUT_HIGH() GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) #define DHT11_OUT_LOW() GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) #define DHT11_IN() GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)GPIO初始化函数void DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_GPIO_CLK, ENABLE); 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); DHT11_OUT_HIGH(); }复位DHT11并等待响应的函数uint8_t DHT11_Reset(void) { uint8_t retry 0; DHT11_OUT_LOW(); delay_ms(20); DHT11_OUT_HIGH(); delay_us(30); DHT11_Mode_IN(); while(DHT11_IN() retry 100) { retry; delay_us(1); } if(retry 100) return 1; retry 0; while(!DHT11_IN() retry 100) { retry; delay_us(1); } if(retry 100) return 1; return 0; }读一个字节的函数uint8_t DHT11_ReadByte(void) { uint8_t i, byte 0; for(i 0; i 8; i) { while(!DHT11_IN()); delay_us(40); byte 1; if(DHT11_IN()) byte | 1; while(DHT11_IN()); } return byte; }读温湿度的主函数uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; if(DHT11_Reset() 0) { for(i 0; i 5; i) buf[i] DHT11_ReadByte(); if(buf[0] buf[1] buf[2] buf[3] buf[4]) { *humi buf[0]; *temp buf[2]; return 0; } } return 1; }4.3 串口打印与主函数串口配置为115200波特率8位数据位1位停止位无校验void USART1_Init(uint32_t bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); 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); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate bound; 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); }主函数循环读取并打印int main(void) { uint8_t temp, humi; delay_init(); USART1_Init(115200); DHT11_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(Read Error\r\n); } delay_ms(2000); } }4.4 关键参数的计算过程串口波特率的计算STM32F1的USART1挂载在APB2总线上时钟为72MHz。波特率寄存器USART_BRR的值等于72MHz除以16倍波特率。以115200为例72,000,000 / (16 * 115200) 39.0625。整数部分是39小数部分0.0625乘以16等于1所以BRR的值是0x271。标准库的USART_Init函数会自动计算这个值不需要手动设置。DHT11起始信号的18毫秒低电平不能太短也不能太长。太短DHT11检测不到太长DHT11会进入休眠。我实测18到20毫秒都可以我用20毫秒。5. 常见问题排查与避坑经验5.1 数据一直校验失败怎么办这是最常见的问题原因通常有三个延时不准、GPIO模式切换太慢、上拉电阻不合适。排查顺序是先示波器看波形如果没有示波器就用串口打印每一步的retry值。延时不准的话把delay_us换成定时器方案。GPIO切换慢的话用直接操作寄存器的方式。上拉电阻的话试试4.7K和10K有些模块自带上拉再外接上拉会导致电平拉不低。5.2 读取间隔太短导致数据不变DHT11的采样周期不能低于1秒我一般用2秒。如果你发现温度湿度一直不变可能是读取太快了DHT11还没完成内部转换。另外DHT11的湿度分辨率是1%RH温度是1摄氏度小数部分永远是0这是正常的。5.3 串口打印乱码串口乱码一般是波特率不匹配。检查STM32的晶振频率是不是8MHz如果是12MHz或16MHzSystemInit函数里的PLL配置需要改。另外检查串口助手的波特率、数据位、停止位是否和代码一致。5.4 常见问题速查表现象可能原因解决方法校验一直失败延时不准改用定时器延时数据不变读取间隔太短间隔改为2秒以上串口乱码波特率不匹配检查晶振和BRR值读不到响应上拉电阻问题换4.7K或去掉外接上拉偶尔失败电源干扰加0.1uF去耦电容实操心得DHT11的DATA线尽量短超过20厘米就容易受干扰。如果必须延长用屏蔽线屏蔽层接地。我在一个项目里因为线太长数据每隔几次就失败一次换了短线之后一次都没失败过。5.5 从DHT11延伸到其他单总线器件学会DHT11之后DS18B20也是单总线时序类似但协议不同。DS18B20的精度更高可以达到0.0625摄氏度而且支持多个器件挂同一条总线。如果你想进阶可以试试用STM32F1驱动DS18B20把温度精度做到小数点后一位。另外DHT22也是常见选择精度比DHT11高湿度精度正负2%RH温度精度正负0.5摄氏度但价格贵一些时序和DHT11基本兼容代码改一下数据解析部分就行。6. 项目扩展与个人体会6.1 把数据上传到上位机串口打印只是第一步实际项目中通常需要把数据上传到PC或云平台。最简单的方式是用串口转USB模块在PC端用Python的pyserial库读取数据并保存到CSV文件。再进一步可以用ESP8266或ESP32做WiFi透传把数据发到MQTT服务器。import serial import csv from datetime import datetime ser serial.Serial(COM3, 115200, timeout1) with open(dht11_data.csv, w, newline) as f: writer csv.writer(f) writer.writerow([timestamp, temperature, humidity]) while True: line ser.readline().decode(utf-8).strip() if Temp in line: parts line.split(,) temp parts[0].split(:)[1].strip().split( )[0] humi parts[1].split(:)[1].strip().split( )[0] writer.writerow([datetime.now(), temp, humi]) f.flush()6.2 低功耗场景的优化如果项目是电池供电DHT11的功耗需要关注。DHT11工作电流约1mA待机电流约60微安。STM32F1可以进入Stop模式定时唤醒读取DHT11。唤醒后先稳定电源再读取数据读完立即回Stop模式。这样平均电流可以做到几百微安一节18650电池能用几个月。6.3 我个人在实际操作中的体会STM32F1这颗芯片我用了快八年从学生时代做课程设计到现在做产品原型它一直是我的首选。DHT11虽然便宜但它的时序确实挑剔我踩过的坑包括用NOP延时在-O2优化下失效、上拉电阻用了10K导致上升沿太慢、DATA线和电机线捆在一起导致干扰。后来我总结了一个原则单总线器件的时序调试示波器比代码重要。没有示波器的时候用逻辑分析仪也行几十块钱的就能用。看到波形之后问题一目了然比盲猜快十倍。最后再分享一个小技巧DHT11的驱动代码可以做成一个通用的单总线框架把延时函数、GPIO操作函数做成函数指针这样换到DS18B20或者其他单总线器件时只需要改底层函数上层协议不用动。这个思路在多个项目中帮我省了不少时间。
RELATED READING

延伸阅读

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