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位设为00,CNF位根据是否需要上拉下拉来设置;配置输出模式时,MODE位设为11(50MHz输出),CNF位设为00(推挽)或01(开漏)。
还有一个容易被忽略的点:切换模式后最好加一个短暂的延时,让总线电平稳定下来再继续操作。这个延时不需要很长,几个微秒就够,但在高速切换时能避免读到错误的电平。
2.3 微秒延时在72MHz下的实际精度
F1的SysTick定时器可以用来做精确延时,但很多人图省事直接用for循环做软件延时。软件延时的好处是不占用定时器资源,坏处是精度依赖于编译器的优化等级和指令周期。在72MHz下,如果编译器开了优化,一个简单的for循环可能被优化掉或者执行速度远超预期。
我的做法是用SysTick做一个微秒级的延时基准。SysTick的时钟源可以选择HCLK的八分之一或者HCLK本身。如果选HCLK,72MHz下每个计数周期约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)准确,检查系统时钟配置 |
| 温度湿度都是0 | GPIO模式切换错误 | 检查切换函数是否被调用 | 确认发送起始信号后切换为输入模式 |
| 偶尔成功偶尔失败 | 延时精度不够 | 测量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这种低速传感器来说,有点杀鸡用牛刀,但如果你同时要读多个单总线设备,或者系统中断负载很重,这个方案值得考虑。