STM32F1驱动DHT11实战:单总线时序与GPIO模式切换详解 1. 为什么STM32F1到今天还值得花时间学1.1 一颗老芯片的生存逻辑STM32F1系列是ST在2007年前后推出的基于ARM Cortex-M3内核的32位微控制器代表作STM32F103C8T6也就是圈子里常说的“蓝板”“最小系统板”。十几年过去了国产替代、RISC-V、各种新系列层出不穷但你去电子市场转一圈F103的出货量依然稳得离谱。原因不复杂便宜、资料多、生态成熟、供货稳定而且它的性能覆盖了绝大多数入门到中端的控制场景——跑个温湿度采集、驱动个屏幕、做个电机控制、搞个串口网关绰绰有余。我接触F1系列是从一块十几块钱的最小系统板开始的当时想的是“先点个灯再说”。结果一路做下来从GPIO到定时器、从ADC到I2C、从串口中断到DMA搬运几乎每一个外设都在F1上踩过坑。后来换过F4、换过G0、换过国产的GD32但每次遇到新项目要快速验证一个想法手边最顺手的还是那块F103。这不是情怀是效率——你不需要翻半天参考手册就能把外设配起来社区里随便一搜就有现成代码出了问题也大概率有人遇到过。这篇文章我想聊的不是“F1有多好”而是围绕一个非常具体的场景——用STM32F1驱动DHT11温湿度传感器——把F1系列的核心使用逻辑、外设配置思路、实操细节和常见坑一次性讲透。DHT11这个传感器便宜、简单、单总线协议看起来没什么技术含量但恰恰因为它“简单”很多人第一次做的时候反而容易翻车时序对不上、读出来全是0、偶尔成功偶尔失败、换块板子就不行了。这些问题背后其实是对F1的GPIO模式、延时精度、中断优先级、电源噪声等基础问题的理解不到位。所以这篇文章适合谁看如果你是刚拿到F103最小系统板的新手想找一个完整的、能跑通的项目来练手那DHT11是很好的起点。如果你已经用过F1但总觉得“能用但不知道为什么”那这篇文章会帮你把底层逻辑补齐。如果你是要做产品原型的工程师需要快速评估F1能不能扛住你的需求这里也有实际的参数和踩坑记录可以参考。1.2 DHT11为什么成了F1的经典搭档DHT11是一款含有已校准数字信号输出的温湿度复合传感器测量范围温度0到50摄氏度、湿度20%到90%RH精度分别是正负2摄氏度和正负5%RH。它的通信方式是单总线一根数据线完成所有交互不需要额外的时钟线。这个特性让它特别适合和F1搭配F1的GPIO资源丰富随便找一个空闲引脚就能用单总线协议对时序要求虽然严格但F1的主频通常72MHz完全能通过软件延时精确控制微秒级时序。更重要的是DHT11的价格极低模块形式通常自带上拉电阻和滤波电容直接插杜邦线就能用。对于学习F1的GPIO输入输出切换、微秒延时、中断与轮询的取舍来说这是一个成本几乎可以忽略的实验平台。你不需要担心烧坏传感器也不需要复杂的电路设计重点全在代码和时序上。但“简单”不等于“随便写写就能跑”。我见过太多人卡在第一步按照网上的代码抄了一遍串口打印出来永远是“读取失败”或者“温度0湿度0”。然后开始怀疑传感器坏了、板子坏了、杜邦线坏了换了一圈发现还是不行。问题往往出在几个非常具体的地方GPIO模式切换的时机不对、延时函数在72MHz下的实际精度不够、没有正确处理DHT11的响应信号、电源纹波导致传感器复位、或者中断打断了关键时序。这些细节在数据手册里不会手把手教你但在实际调试中每一个都能让你卡半天。2. 核心原理拆解单总线时序与F1的GPIO控制2.1 DHT11的单总线协议到底在干什么DHT11的通信过程可以拆成四个阶段主机发送起始信号、传感器响应、数据传输、结束释放。整个过程围绕一根数据线展开这根线在空闲时被上拉电阻拉高主机和传感器通过拉低或释放来传递信息。起始信号是主机主动拉低数据线至少18毫秒然后释放。这个18毫秒很关键它告诉传感器“我要开始读数据了”。传感器检测到这个低电平后会等待20到40微秒然后拉低总线80微秒作为响应再拉高80微秒表示“我准备好了”。接下来传感器连续输出40位数据每一位都以50微秒的低电平开始然后用高电平的持续时间来区分0和1高电平持续26到28微秒表示0持续70微秒表示1。40位数据依次是湿度整数、湿度小数、温度整数、温度小数、校验和校验和等于前四个字节相加的低八位。整个过程对时序的要求是微秒级的。F1在72MHz下一个机器周期大约是13.9纳秒一条简单的指令几个周期所以用软件延时循环来产生微秒级延时是完全可行的。但前提是你得知道你的延时函数在72MHz下到底延时了多久而不是随便写个for循环就觉得“差不多”。2.2 F1的GPIO模式切换为什么容易出错F1的GPIO有八种模式输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用、推挽复用。在DHT11的应用中数据线需要在输出和输入之间动态切换。主机发送起始信号时GPIO配置为推挽输出可以主动拉低和拉高发送完起始信号后GPIO要切换为输入模式通常是浮空输入或上拉输入让传感器接管总线。问题就出在这个切换上。很多人在初始化时把GPIO配成推挽输出发送完起始信号后忘记切换成输入结果传感器拉低总线时主机的推挽输出还在驱动高电平两边打架读出来的数据全是乱的。另一种情况是切换了但没切换干净比如只改了CRL寄存器的某几位导致模式处于不确定状态。正确的做法是写一个GPIO模式切换函数在需要输出时调用一次需要输入时再调用一次。切换的时候要注意F1的GPIO配置是通过CRL和CRH寄存器控制的每个引脚占4个位低8位在CRL高8位在CRH。配置输入模式时MODE位设为00CNF位根据是否需要上拉下拉来设置配置输出模式时MODE位设为1150MHz输出CNF位设为00推挽或01开漏。还有一个容易被忽略的点切换模式后最好加一个短暂的延时让总线电平稳定下来再继续操作。这个延时不需要很长几个微秒就够但在高速切换时能避免读到错误的电平。2.3 微秒延时在72MHz下的实际精度F1的SysTick定时器可以用来做精确延时但很多人图省事直接用for循环做软件延时。软件延时的好处是不占用定时器资源坏处是精度依赖于编译器的优化等级和指令周期。在72MHz下如果编译器开了优化一个简单的for循环可能被优化掉或者执行速度远超预期。我的做法是用SysTick做一个微秒级的延时基准。SysTick的时钟源可以选择HCLK的八分之一或者HCLK本身。如果选HCLK72MHz下每个计数周期约13.9纳秒重装载值设为72就能得到大约1微秒的延时。但要注意SysTick是24位的递减计数器重装载值不能超过0xFFFFFF。对于DHT11需要的几十微秒到几十毫秒的延时可以用SysTick配合循环来实现。如果你不想动SysTick也可以用定时器做一个微秒延时。比如用TIM4预分频器设为71这样计数器每72个时钟周期计一次数也就是1微秒计一次。然后通过读取计数器的值来判断延时是否到达。这种方法不占用中断也不影响其他定时器。实测下来软件延时在DHT11的应用中是可以接受的因为DHT11对时序的容忍度其实比数据手册上写的要宽一些。但如果你发现读出来的数据偶尔正确偶尔错误首先怀疑的就是延时精度。用示波器或者逻辑分析仪抓一下波形看看起始信号的低电平是不是真的到了18毫秒数据位的高电平持续时间是不是在预期范围内问题就一目了然了。3. 完整实操从零搭建DHT11采集系统3.1 硬件准备与接线要点你需要的东西不多一块STM32F103C8T6最小系统板、一个DHT11模块最好是带PCB和上拉电阻的那种、一根USB转串口模块用来打印数据、若干杜邦线。如果用的是裸传感器而不是模块还需要自己加一个4.7k到10k的上拉电阻接在数据线和VCC之间。接线方面DHT11模块通常有三个引脚VCC、DATA、GND。VCC接3.3V或5V都可以DHT11的工作电压范围是3.3V到5.5V。DATA接F1的任意一个GPIO我习惯用PA0或者PB12这两个引脚在最小系统板上引出方便。GND共地。这里有一个实际经验不要用太长的杜邦线。我试过用30厘米的杜邦线连接DHT11读出来的数据随机性很大换成10厘米的短线就稳定了。原因是长线引入了更大的分布电容和天线效应单总线的高频时序容易受到干扰。如果确实需要延长建议用屏蔽线或者在数据线和地之间加一个小电容比如100pF滤波。电源方面DHT11对电源纹波比较敏感。如果你用的是USB供电通常没问题但如果和电机、继电器等大功率设备共用电源最好给DHT11单独加一个LDO或者RC滤波。我遇到过一种情况DHT11和一个小型风扇共用5V电源风扇一转DHT11就读数失败。后来在DHT11的VCC和GND之间并了一个100微法的电解电容问题解决。3.2 GPIO初始化与模式切换函数先看GPIO的初始化。以PA0为例我们需要使能GPIOA的时钟然后配置PA0为推挽输出模式初始输出高电平。代码如下void DHT11_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_0); }然后是模式切换函数。输出模式用推挽输出输入模式用浮空输入。浮空输入的好处是不加内部上拉避免和外部上拉电阻冲突如果你没有外部上拉电阻可以用上拉输入但F1的内部上拉电阻阻值较大约40k可能不足以在高速时序下快速拉高总线所以还是建议加外部上拉。void DHT11_Set_Mode(uint8_t mode) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; if(mode 0) { GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; } else { GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; } GPIO_Init(GPIOA, GPIO_InitStructure); }注意每次切换模式后最好加一个几微秒的延时让总线电平稳定。这个延时可以用简单的for循环实现因为此时对精度的要求不高。3.3 微秒延时函数的实现与校准我用SysTick来实现微秒延时。首先配置SysTick的时钟源为HCLK也就是72MHz。然后写一个延时函数void delay_us(uint32_t us) { uint32_t temp; SysTick-LOAD 72 * us; SysTick-VAL 0; SysTick-CTRL 0x00000005; do { temp SysTick-CTRL; } while((temp 0x01) !(temp (1 16))); SysTick-CTRL 0x00000004; SysTick-VAL 0; }这个函数的原理是设置重装载值为72乘以微秒数启动定时器等待计数到0的标志位置位然后关闭定时器。注意SysTick的时钟源选择位是CTRL寄存器的第2位设为1表示使用HCLK。第0位是使能位第16位是计数到0的标志位。这个延时函数的精度取决于HCLK的实际频率。如果你的F1不是跑在72MHz比如超频到128MHz或者降频到48MHz需要相应调整重装载值。校准的方法很简单写一个循环调用delay_us(1000)一千次用示波器或者逻辑分析仪测量一个GPIO翻转的时间看看是不是接近1秒。如果有偏差按比例调整乘数。3.4 完整的读取流程与代码实现读取DHT11的完整流程分为五步发送起始信号、等待响应、读取40位数据、校验、返回结果。下面逐步说明。发送起始信号主机拉低总线至少18毫秒然后拉高20到40微秒然后切换为输入模式等待传感器响应。void DHT11_Start(void) { DHT11_Set_Mode(0); // 输出模式 GPIO_ResetBits(GPIOA, GPIO_Pin_0); // 拉低 delay_ms(20); // 至少18ms GPIO_SetBits(GPIOA, GPIO_Pin_0); // 拉高 delay_us(30); // 20-40us DHT11_Set_Mode(1); // 输入模式 }等待响应传感器会拉低总线80微秒然后拉高80微秒。我们需要检测这个响应信号。如果超过一定时间没有检测到就返回错误。uint8_t DHT11_Wait_Response(void) { uint32_t timeout 0; while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) timeout 100) { delay_us(1); timeout; } if(timeout 100) return 1; // 超时 timeout 0; while(!GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) timeout 100) { delay_us(1); timeout; } if(timeout 100) return 1; timeout 0; while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) timeout 100) { delay_us(1); timeout; } if(timeout 100) return 1; return 0; // 响应正常 }读取40位数据每一位都以50微秒的低电平开始然后高电平持续26到28微秒表示0持续70微秒表示1。我们在高电平期间读取总线状态如果高电平持续时间超过某个阈值比如40微秒就认为是1否则是0。uint8_t DHT11_Read_Byte(void) { uint8_t i, byte 0; for(i 0; i 8; i) { while(!GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0)); // 等待低电平结束 delay_us(40); byte 1; if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0)) { byte | 1; } while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0)); // 等待高电平结束 } return byte; }这里用delay_us(40)作为阈值判断。如果高电平在40微秒后仍然存在说明这一位是1如果已经变低说明是0。这个阈值的选择需要根据实际波形调整因为DHT11的0和1的高电平时间分别是26到28微秒和70微秒40微秒正好在中间。校验和计算前四个字节相加取低八位与第五个字节比较。uint8_t DHT11_Read_Data(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; DHT11_Start(); if(DHT11_Wait_Response() ! 0) return 1; for(i 0; i 5; i) { buf[i] DHT11_Read_Byte(); } if(buf[4] (buf[0] buf[1] buf[2] buf[3])) { *humi buf[0]; *temp buf[2]; return 0; } return 1; }这个代码在大多数情况下能正常工作但如果你发现读取成功率不高可以尝试在关键位置增加超时检测避免死循环。另外读取过程中最好关闭中断因为中断会打断时序导致数据位判断错误。4. 常见问题排查与实战避坑指南4.1 读取失败问题速查表现象可能原因排查方法解决方案串口一直打印读取失败起始信号低电平时间不够用示波器测PA0低电平持续时间确保delay_ms(20)准确检查系统时钟配置温度湿度都是0GPIO模式切换错误检查切换函数是否被调用确认发送起始信号后切换为输入模式偶尔成功偶尔失败延时精度不够测量delay_us的实际延时校准SysTick或改用定时器延时数据校验和错误中断打断了时序在读取期间关闭中断使用__disable_irq()和__enable_irq()换块板子就不行系统时钟频率不同检查HSE和PLL配置根据实际主频调整延时参数数据随机跳变电源纹波或线太长测量VCC纹波缩短杜邦线加滤波电容换短线4.2 中断干扰的实测与处理DHT11的读取过程大约需要4到5毫秒期间如果发生中断特别是耗时较长的中断比如串口接收中断、定时器中断就会打断时序导致数据位判断错误。我实测过在读取DHT11时如果串口以115200波特率持续接收数据读取失败率会明显上升。解决办法有两个一是在读取期间关闭全局中断读完再打开二是把DHT11的读取放在高优先级中断或者主循环中确保不会被其他任务打断。关闭全局中断的方法最简单__disable_irq(); status DHT11_Read_Data(temp, humi); __enable_irq();但要注意关闭中断的时间不能太长否则会影响其他实时性要求高的任务。DHT11的读取时间大约5毫秒对于大多数应用来说是可以接受的。如果你的系统有严格的中断响应要求可以考虑用定时器输入捕获的方式来读取DHT11把时序判断交给硬件但这样会增加代码复杂度。4.3 电源与布线的实战经验电源问题是我踩过最多的坑。有一次做了一个带DHT11和继电器的板子继电器一吸合DHT11就读数失败。用示波器看VCC发现继电器动作时电源上出现了几百毫伏的尖峰。后来在DHT11的VCC和GND之间加了一个100微法电解电容和一个0.1微法陶瓷电容问题解决。布线上DHT11的数据线尽量短并且远离高频信号线比如晶振、PWM输出线。如果必须长距离连接可以考虑用双绞线数据线和地线绞在一起减少干扰。另外DHT11的DATA引脚不要直接接到F1的5V容忍引脚上然后指望内部上拉外部上拉电阻是必须的4.7k到10k都可以我一般用4.7k。还有一个细节DHT11上电后需要等待1秒左右才能稳定不要一上电就立刻读取。我的做法是在初始化后加一个delay_ms(1000)然后再开始读取。这个等待时间在数据手册里没有明确写但实测下来不加的话第一次读取大概率失败。4.4 从DHT11延伸到其他单总线设备DHT11的读取逻辑其实可以复用到很多单总线设备上比如DS18B20温度传感器。DS18B20的时序和DHT11不同但核心思路是一样的主机发送复位脉冲、等待响应、读写数据位。掌握了DHT11的GPIO模式切换和微秒延时再去看DS18B20的手册就会觉得轻松很多。如果你想把DHT11的代码改造成更通用的单总线驱动可以把GPIO操作抽象成宏或者函数指针把延时函数也抽象出来这样换一个引脚或者换一个芯片只需要改几个宏定义。我在实际项目中就是这么做的一个dht11.c文件里把底层GPIO操作和上层协议分开移植到不同的F1板子上只需要改引脚定义和延时参数。最后分享一个小技巧如果你觉得软件延时的精度不好控制可以用F1的定时器输入捕获功能来测量DHT11的高电平持续时间。具体做法是把数据线接到一个定时器的输入捕获通道上配置为上升沿和下降沿捕获通过捕获值计算高电平时间然后判断是0还是1。这种方法不依赖软件延时抗中断干扰能力更强但需要占用一个定时器资源。对于DHT11这种低速传感器来说有点杀鸡用牛刀但如果你同时要读多个单总线设备或者系统中断负载很重这个方案值得考虑。