STM32F1与DHT11实战:从裸机编程到传感器驱动

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

如果你最近在电子论坛或者开源硬件社区里逛,会发现一个很有意思的现象:一边是各种高性能MCU层出不穷,另一边STM32F1系列的讨论热度始终没降下来。尤其是“DHT11温湿度传感器+STM32F1”这个组合,几乎成了很多人入门嵌入式开发的第一套练手项目。我自己也是从这个组合开始真正理解什么叫“裸机编程”的,所以这篇内容就围绕STM32F1系列,把它的定位、核心架构、典型应用场景,以及DHT11这类外设的驱动思路,完整地聊一遍。

STM32F1是意法半导体(ST)推出的基于ARM Cortex-M3内核的32位微控制器系列。它最早在2007年前后进入市场,凭借72MHz主频、丰富的外设资源、相对亲民的价格,迅速取代了大量8位和16位MCU的应用场景。你可能听过STM32F103C8T6这个型号,也就是常说的“蓝板”或“最小系统板”,十几块钱就能买到一块,板载了基本的晶振、复位电路和调试接口,插上电脑就能开始写代码。对于学生、电子爱好者、刚转行做嵌入式的工程师来说,STM32F1提供了一条从寄存器操作到标准外设库、再到HAL库的完整学习路径。

这个系列能做什么?简单说,凡是需要实时控制、数据采集、通信交互、电机驱动、人机界面的场景,它基本都能覆盖。工业控制里的PLC模块、消费电子里的智能小家电、物联网终端里的传感器节点、教学实验里的各种开发板,背后都有STM32F1的身影。它解决的核心问题是:用一颗芯片替代过去需要多颗芯片才能完成的控制任务,同时把开发门槛降到普通人能接受的程度。适合谁来参考?如果你是电子类专业的学生、刚入行的嵌入式工程师、或者想自己动手做点智能硬件的爱好者,STM32F1都是一个非常扎实的起点。

我见过太多人一上来就冲着高性能芯片去,结果连GPIO的推挽输出和开漏输出都分不清。STM32F1的好处在于,它的资料足够多、坑足够浅、社区足够活跃,你遇到的问题大概率别人已经踩过并写成了帖子。接下来我会从整体设计思路、核心细节、实操过程、常见问题几个维度,把STM32F1和DHT11这套组合拳拆开来讲。

2. STM32F1系列的整体架构与选型思路

2.1 内核与总线结构到底意味着什么

STM32F1用的是ARM Cortex-M3内核,这是理解它一切行为的基础。Cortex-M3是哈佛架构,指令总线和数据总线分开,取指令和取数据可以同时进行,所以它在72MHz下的实际运算效率比同频的8位机高出好几倍。内核里集成了嵌套向量中断控制器(NVIC),支持最多240个中断,每个中断有16级可编程优先级。这意味着你可以把紧急的事件(比如电机过流保护)设成高优先级,把不紧急的(比如按键扫描)设成低优先级,系统响应会非常干脆。

总线结构上,STM32F1采用了多层AHB总线矩阵,把内核、DMA、SRAM、Flash和外设连接起来。DMA控制器有7个通道,每个通道可以独立配置源地址、目标地址和传输长度。举个例子,你要用ADC连续采集1000个数据点,如果不走DMA,CPU就得在每次转换完成后进中断读数据,1000次中断的开销相当可观;配置好DMA之后,ADC转换完直接往内存里写,CPU全程不用管,等1000个点采完了DMA再产生一个中断通知你。这个机制在高速数据采集和串口通信里非常关键。

2.2 型号命名规则与选型避坑

STM32F1的型号命名有一套固定规则,拿STM32F103C8T6来说:STM32代表产品家族,F代表基础型,103是子系列,C是引脚数(48脚),8是Flash容量(64KB),T是封装(LQFP),6是温度范围(-40到85摄氏度)。很多人买芯片的时候只看价格,结果买回来发现Flash不够用或者引脚不够焊,这就是没吃透命名规则。

选型的时候有几个硬指标必须提前算清楚。第一是Flash和RAM,STM32F103C8T6有64KB Flash和20KB RAM,跑一个带DHT11驱动和串口打印的程序绰绰有余,但如果你要加文件系统或者图形界面,就得换CBT6(128KB Flash)或者RBT6(128KB Flash、20KB RAM)。第二是外设数量,比如你要同时用3个串口、2个SPI、2个I2C,那就得选引脚多的型号,因为有些外设是复用引脚,不能同时使用。第三是封装,LQFP比QFN好焊,手工焊接首选LQFP。

注意:STM32F103系列里有一个“中容量”和“大容量”的区分,中容量产品(如C8、CB)的Flash最大128KB,大容量产品(如ZE、ZC)的Flash从256KB起步,外设资源也更丰富。选型时不要只看价格,先确认你的代码量和外设需求。

2.3 最小系统板为什么是入门首选

最小系统板把STM32F1芯片、8MHz晶振、32.768kHz晶振(用于RTC)、复位电路、BOOT选择跳线、SWD调试接口、电源稳压芯片全部集成在一块小PCB上,你只需要通过Micro USB或者排针供电就能开始工作。这种板子的价值在于,它把硬件设计的门槛降到了最低,让你可以把精力集中在代码和逻辑上。

我自己用的第一块板子就是STM32F103C8T6最小系统板,当时花了不到二十块钱。板子上的LED接在PC13引脚,按键接在PA0,这两个简单的器件后来成了我调试所有程序的“探针”。比如写DHT11驱动的时候,我会先用LED闪烁确认程序在跑,再用串口打印确认数据读到了,最后才去分析温湿度值对不对。这种分步验证的思路,比一上来就盯着温湿度数值看要高效得多。

3. DHT11温湿度传感器与STM32F1的配合逻辑

3.1 DHT11的通信协议拆解

DHT11是一款数字温湿度传感器,内部集成了电阻式湿度敏感元件和NTC温度敏感元件,还有一个8位单片机负责信号处理。它输出的是单总线数字信号,也就是说,一根数据线既传命令又传数据。这个协议看起来简单,但时序要求非常严格,差几微秒就可能读失败。

一次完整的通信过程是这样的:STM32F1先拉低数据线至少18毫秒,然后拉高20到40微秒,接着释放总线。DHT11检测到这个起始信号后,会拉低总线80微秒,再拉高80微秒,表示准备发送数据。之后DHT11连续发送40位数据,每一位都以50微秒的低电平开始,然后高电平持续26到28微秒表示“0”,持续70微秒表示“1”。40位数据依次是:湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节相加的低8位。

这个协议里最容易出错的地方是微秒级延时。STM32F1的SysTick定时器可以配置成1微秒中断一次,但中断本身有开销,实际延时可能不准。更可靠的做法是用__NOP()空指令或者硬件定时器做精确延时。我试过用HAL_Delay()来读DHT11,结果十次有八次失败,因为HAL_Delay()的最小单位是毫秒,根本满足不了微秒级时序要求。

3.2 GPIO模式配置的关键细节

DHT11的数据线需要双向通信,所以STM32F1的GPIO必须能在输出和输入模式之间切换。具体操作是:发送起始信号时,把引脚配置成推挽输出;释放总线后,立刻切换成浮空输入或者上拉输入,等待DHT11响应。这里有一个坑:如果配置成上拉输入,而外部没有上拉电阻,内部上拉电阻大约40kΩ,可能会导致信号上升沿变缓,影响时序判断。所以实际电路中,DHT11的数据线和VCC之间通常要接一个4.7kΩ到10kΩ的上拉电阻。

我在早期项目中偷懒没加上拉电阻,结果DHT11偶尔能读出来,偶尔读出来全是0。后来用示波器一看,数据线的高电平只有2.8V左右,而且上升沿明显变圆,DHT11根本识别不了。加上4.7kΩ上拉电阻之后,波形立刻变得干净利落。这个细节在DHT11的数据手册里其实有写,但很多人只看代码不看手册,就会踩这个坑。

3.3 供电与信号完整性的实际考量

DHT11的工作电压是3.3V到5.5V,STM32F1的IO口输出高电平是3.3V,所以直接连接没有问题。但要注意,DHT11在转换数据的时候电流会突然增大,如果电源走线太细或者滤波电容不够,会导致电压跌落,进而影响通信。我的做法是在DHT11的VCC和GND之间并一个100nF的陶瓷电容,再并一个10μF的钽电容,前者滤高频噪声,后者提供瞬时电流。

信号线方面,如果DHT11和STM32F1之间的距离超过20厘米,最好用屏蔽线或者双绞线,并且在接收端加一个小电容(比如100pF)到地,用来滤除毛刺。我做过一个项目,DHT11装在机箱外面,引线大概30厘米,一开始读数据总是随机出错,后来换成双绞线并加上100pF电容,连续运行72小时没有再出现异常。

4. 从零搭建DHT11驱动程序的完整实操

4.1 工程框架与延时函数的实现

我习惯用Keil MDK或者STM32CubeIDE来开发STM32F1。新建工程后,第一步是配置系统时钟。STM32F103C8T6的外部晶振是8MHz,经过PLL倍频到72MHz作为系统时钟。这个配置在CubeMX里点几下就能完成,但如果你用标准外设库,就需要手动写RCC配置代码。不管用哪种方式,时钟配置错了,后面所有延时都会错。

延时函数我推荐用SysTick来实现微秒级延时。具体做法是:把SysTick的重装载值设为72(因为72MHz下,72个时钟周期就是1微秒),然后写一个delay_us()函数,每次调用时启动SysTick,等待计数到0。这个函数不需要开中断,轮询计数标志位就行。代码大概长这样:

void delay_us(uint32_t us) { uint32_t i; for(i = 0; i < us; i++) { SysTick->LOAD = 72 - 1; SysTick->VAL = 0; SysTick->CTRL = 1; // 使能,时钟源为HCLK,不产生中断 while(!(SysTick->CTRL & (1 << 16))); // 等待计数到0 SysTick->CTRL = 0; } }

这个函数的精度取决于系统时钟是否准确。如果你用的是内部RC振荡器,误差可能有百分之几,读DHT11就会不稳定。所以外部晶振是必须的,不要为了省两个电容而因小失大。

4.2 DHT11起始信号与响应检测

起始信号的代码逻辑是:把数据线配置为推挽输出,拉低至少18毫秒,然后拉高20到40微秒,再切换为浮空输入,等待DHT11拉低总线。这里有一个细节:拉高之后不要立刻切换成输入,因为STM32F1的GPIO模式切换需要几个时钟周期,如果切换太快,DHT11可能还没检测到高电平。我的做法是拉高之后延时30微秒,再切换模式。

等待DHT11响应的时候,要加超时判断。如果DHT11没接好或者坏了,总线会一直保持高电平,程序就会死循环。我一般设一个100微秒的超时计数器,超时了就返回错误码。这个错误码在调试的时候非常有用,可以区分是“传感器没响应”还是“数据校验失败”。

uint8_t DHT11_Start(void) { uint8_t timeout = 0; GPIO_InitTypeDef GPIO_InitStruct; // 配置为推挽输出 GPIO_InitStruct.GPIO_Pin = DHT11_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &GPIO_InitStruct); GPIO_ResetBits(DHT11_PORT, DHT11_PIN); // 拉低 delay_ms(20); // 至少18ms GPIO_SetBits(DHT11_PORT, DHT11_PIN); // 拉高 delay_us(30); // 20-40us // 切换为浮空输入 GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_PORT, &GPIO_InitStruct); // 等待DHT11拉低 while(GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)) { timeout++; delay_us(1); if(timeout > 100) return 1; // 超时 } // 等待DHT11拉高 timeout = 0; while(!GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)) { timeout++; delay_us(1); if(timeout > 100) return 2; } // 等待DHT11再次拉低 timeout = 0; while(GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)) { timeout++; delay_us(1); if(timeout > 100) return 3; } return 0; // 成功 }

4.3 40位数据的读取与校验

读取40位数据的核心是判断每一位的高电平持续时间。DHT11在每一位开始时都会拉低50微秒,然后拉高。如果高电平持续26到28微秒,这一位是0;如果持续70微秒,这一位是1。我的做法是:先等待低电平结束,然后延时40微秒,再读引脚电平。如果这时候是低电平,说明高电平已经结束了,这一位是0;如果是高电平,说明还在高电平期间,这一位是1。

这个方法的原理是:40微秒正好落在26到28微秒和70微秒之间。对于0,高电平在28微秒就结束了,40微秒时肯定是低电平;对于1,高电平要到70微秒才结束,40微秒时还是高电平。这个方法比用定时器捕获更简单,而且实测很稳定。

uint8_t DHT11_ReadByte(void) { uint8_t i, byte = 0; for(i = 0; i < 8; i++) { // 等待50us低电平结束 while(!GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)); // 延时40us delay_us(40); // 读取电平 byte <<= 1; if(GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)) { byte |= 1; } // 等待高电平结束 while(GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)); } return byte; }

读完整40位之后,把前四个字节相加,取低8位,和第五个字节比较。如果相等,说明数据有效;如果不等,丢弃这次数据,重新读取。我一般会连续读三次,取校验通过的那一次,如果三次都失败,就报错。

4.4 数据格式化与串口输出

读到的温湿度数据是整数形式,湿度范围20%到90%RH,温度范围0到50摄氏度。DHT11的精度是±5%RH和±2摄氏度,所以小数部分其实没有实际意义,通常直接忽略。我会把数据格式化成字符串,通过串口打印出来,方便观察。

void DHT11_Print(void) { uint8_t temp, humi; char buf[50]; if(DHT11_ReadData(&temp, &humi) == 0) { sprintf(buf, "Temperature: %d C, Humidity: %d %%\r\n", temp, humi); USART_SendString(USART1, buf); } else { USART_SendString(USART1, "DHT11 read error\r\n"); } }

串口配置成115200波特率、8位数据位、1位停止位、无校验。这个配置在大多数串口助手里都能直接识别。我习惯在main函数的while循环里每2秒读一次DHT11,因为DHT11的采样周期不能低于1秒,读太快数据不会更新。

5. 调试过程中最容易踩的五个坑

5.1 时序偏差导致读取失败

这是最常见的问题,表现是串口打印出来的数据全是0或者255,或者校验和一直不对。根本原因通常是延时函数不准。我遇到过一种情况:用SysTick做微秒延时,但系统时钟配置成了64MHz而不是72MHz,结果所有延时都偏长了约12%,DHT11的时序完全乱套。排查方法是拿示波器或者逻辑分析仪抓一下数据线的波形,看看起始信号的低电平是不是18毫秒,高电平是不是30微秒。如果没有仪器,可以写一个测试程序,让GPIO输出固定频率的方波,用示波器看频率对不对。

另一个原因是编译器优化。Keil MDK在优化级别较高的时候,可能会把delay_us()里的循环优化掉,导致延时消失。解决办法是把delay_us()函数用volatile修饰,或者在工程设置里把优化级别调到Level 0。我一般会在调试阶段用Level 0,发布的时候再调到Level 2。

5.2 上拉电阻缺失或阻值不当

前面提过,DHT11的数据线需要上拉电阻。如果完全不加,数据线在释放状态是浮空的,电平不确定,DHT11可能识别不到起始信号。如果上拉电阻太大(比如100kΩ),上升沿太慢,DHT11可能把高电平误判为低电平。如果上拉电阻太小(比如1kΩ),DHT11拉低总线的时候电流太大,可能损坏传感器。4.7kΩ到10kΩ是经过验证的可靠范围。

我建议在PCB设计阶段就把上拉电阻画上,不要想着靠STM32F1的内部上拉。内部上拉电阻的阻值范围是30kΩ到50kΩ,而且一致性不好,不同芯片之间差异很大。用外部电阻更可控。

5.3 电源噪声干扰通信

DHT11对电源噪声比较敏感。如果STM32F1和DHT11共用一组电源,而STM32F1的电流波动又比较大(比如同时驱动LED和串口),DHT11的供电电压可能会瞬间跌落,导致通信失败。我的做法是给DHT11单独走一条电源线,并且在靠近DHT11的地方加滤波电容。如果条件允许,用LDO单独给DHT11供电,和STM32F1的电源分开。

还有一种情况是电机或者继电器和DHT11共用电源,电机启动时的浪涌电流会让DHT11直接复位。这种场景下,除了加电容,还要在电机两端加续流二极管,在电源入口加TVS管。

5.4 读取间隔过短导致数据不更新

DHT11的内部采样周期是1秒,如果你每100毫秒读一次,读到的可能是上一次的缓存数据,或者直接读失败。我试过在while循环里不加延时连续读DHT11,结果串口打印出来的数据几乎不变,而且偶尔会卡死。后来改成每2秒读一次,就非常稳定了。

如果你需要更快的采样率,DHT11不适合,应该换DHT22或者SHT30。DHT22的采样周期是2秒,但精度更高;SHT30是I2C接口,采样率可以到10Hz以上,但价格也贵一些。

5.5 校验和计算错误

校验和的计算规则是:湿度整数+湿度小数+温度整数+温度小数,取低8位。很多人会忘记加湿度小数和温度小数,或者把校验和本身也加进去。DHT11的小数部分通常是0,所以加不加好像没区别,但如果你换用DHT22,小数部分就有值了,不加就会出错。我建议从一开始就按完整规则写,养成好习惯。

还有一个细节:DHT11返回的40位数据里,第五个字节是校验和,但有些资料说第八位是校验和,这是错误的。正确的顺序是:湿度整数(8位)、湿度小数(8位)、温度整数(8位)、温度小数(8位)、校验和(8位)。如果你按错误的顺序解析,数据会完全乱掉。

6. 从DHT11延伸出去的STM32F1学习路径

6.1 用定时器中断替代阻塞式延时

我上面写的DHT11驱动是阻塞式的,也就是说,在读DHT11的时候,CPU什么也干不了。这在简单的项目里没问题,但如果你要同时处理按键、串口、显示屏,阻塞式就会导致其他任务响应变慢。进阶的做法是用定时器中断来驱动DHT11的状态机,把读数据的40位拆成40个状态,每个状态在定时器中断里执行。这样CPU在等待DHT11响应的时候可以去处理其他任务。

具体实现思路是:配置一个1微秒或者10微秒的定时器中断,在中断里维护一个状态变量。状态0发送起始信号,状态1等待响应,状态2到41读取40位数据,状态42校验和输出。每个状态里根据当前电平决定下一步动作。这个方案代码量会大一些,但实时性更好,适合多任务场景。

6.2 把数据上传到上位机或显示屏

读完DHT11的数据之后,你可以通过串口上传到电脑,用串口助手或者Python脚本接收并画曲线。我常用Python的pyserial库写一个简单的接收程序,把温湿度数据存到CSV文件里,然后用matplotlib画图。这样可以看到温湿度随时间的变化趋势,比单纯看数值有意思得多。

如果想脱离电脑,可以加一个OLED显示屏(比如SSD1306),用I2C接口和STM32F1连接,把温湿度直接显示在屏幕上。SSD1306的驱动比DHT11复杂一些,但网上有很多现成的库,移植过来改改引脚就能用。我做过一个桌面温湿度计,STM32F1+DHT11+OLED,成本不到三十块钱,放在桌上很实用。

6.3 低功耗模式的探索

STM32F1支持睡眠、停止、待机三种低功耗模式。如果你用电池供电,可以让STM32F1在两次DHT11读取之间进入停止模式,把功耗从几十毫安降到几十微安。具体做法是:配置一个RTC闹钟或者外部中断唤醒,进入停止模式,等唤醒之后重新初始化时钟和GPIO,再读DHT11。

这里有一个坑:进入停止模式后,SysTick会停止,所以delay_us()会失效。唤醒之后需要重新配置SysTick。另外,停止模式下GPIO的状态会保持,但如果你把DHT11的数据线配置成输出低电平,DHT11可能会一直耗电。所以进入低功耗之前,要把DHT11的数据线配置成浮空输入,并且给DHT11断电(如果电路上有控制MOS管的话)。

7. 一些实测数据和经验参数

我在室温25摄氏度的环境下,用STM32F103C8T6和DHT11连续跑了72小时,每2秒读一次,总共约13万次读取。统计结果如下:

指标数值
总读取次数129600
成功次数129412
失败次数188
成功率99.85%
平均温度读数25.3摄氏度
平均湿度读数48%RH
温度波动范围24到26摄氏度
湿度波动范围45%到52%RH

失败的188次里,大部分发生在空调启停的时候,推测是电源波动导致的。后来我在DHT11的电源脚加了100nF和10μF电容,失败次数降到了个位数。这个数据说明,只要硬件设计到位,DHT11的稳定性是可以接受的。

关于延时精度,我用逻辑分析仪抓过delay_us(40)的实际波形,实测是39.8到40.3微秒之间,误差在1%以内。这个精度对于DHT11来说足够了。如果你用内部RC振荡器,误差可能到5%,那就比较危险了。

8. 写在最后的一些个人体会

STM32F1和DHT11这套组合,看起来简单,但真正把每一个细节都做对,需要你对时序、GPIO模式、电源设计、编译器行为都有基本的理解。我见过很多人代码抄过去能跑,但换个板子就不行,或者过几天就出问题,根本原因就是没有理解背后的原理。

我的建议是,不要满足于“能跑就行”。读DHT11的时候,拿示波器看看波形;写延时函数的时候,算一算时钟周期;选上拉电阻的时候,查一查数据手册。这些习惯一旦养成,后面学更复杂的传感器和通信协议(比如I2C、SPI、CAN)就会轻松很多。

另外,STM32F1的社区资源非常丰富,遇到问题先搜再问。但要注意,网上的代码质量参差不齐,有些甚至是错的。判断代码对不对,最可靠的方法是看数据手册和参考手册,而不是看别人的博客。数据手册里有时序图,参考手册里有寄存器说明,这两份文档才是最终的裁判。

最后分享一个小技巧:如果你手头没有逻辑分析仪,可以用STM32F1的另一个定时器来测量DHT11的高电平持续时间。把数据线接到定时器的输入捕获通道,配置成上升沿和下降沿都捕获,然后计算两次捕获之间的差值。这个方法可以帮你验证延时函数是否准确,而且不需要额外买仪器。