1. 为什么STM32F1到今天还值得花时间学
1.1 一颗老芯片的生存逻辑
STM32F1系列是ST在2007年前后推出的基于ARM Cortex-M3内核的32位微控制器,代表作STM32F103C8T6,也就是圈子里常说的"蓝板"或"最小系统板"。十几年过去了,市面上F4、F7、H7、G0、G4各种新系列层出不穷,但F1的出货量和教程数量依然稳居前列。原因很实在:便宜、资料多、够用。
一块STM32F103C8T6核心板,零售价通常在十元上下,72MHz主频、64KB Flash、20KB SRAM、37个GPIO、3个USART、2个SPI、2个I2C、2个12位ADC、4个16位定时器,还带CAN和USB。这个配置放在今天做温湿度采集、继电器控制、串口屏驱动、小型数据记录仪,绰绰有余。很多工业现场的板子跑了十年还在服役,替换成本高,维护需求一直存在。
我个人的判断是:如果你要入门嵌入式,F1是最不容易劝退的起点;如果你要做量产项目,F1是BOM成本最容易压下来的选择之一。这两个理由足够支撑你认真对待它。
1.2 从"点灯"到"读温湿度"的典型路径
绝大多数人接触F1的第一件事是点灯,第二件事是串口打印,第三件事就是接传感器。而DHT11温湿度传感器几乎是绕不开的一站——它便宜(几块钱)、单总线协议简单、数据直观,非常适合用来练手GPIO时序控制和微秒级延时。
但恰恰是这个"简单"的传感器,坑了不少人。网上大量教程直接给一段代码让你复制,能跑就完事,跑不通就换传感器。实际上DHT11读不出来的原因可能出在延时精度、上拉电阻、供电电压、时序容错、甚至编译器优化等级上。这篇文章我就围绕STM32F1驱动DHT11这条主线,把背后的原理、实操细节、踩坑经验完整拆一遍,同时把F1开发中那些通用的底层知识串起来讲透。
适合谁看:刚上手F1、想搞明白GPIO和延时到底怎么回事的初学者;做过一些项目但对时序敏感器件心里没底的中级开发者;以及需要快速复现一套稳定温湿度采集方案的人。
2. 整体设计思路与方案选型
2.1 为什么选DHT11而不是DHT22或SHT30
先做个横向对比,把选型逻辑说清楚。
| 传感器 | 温度范围/精度 | 湿度范围/精度 | 接口 | 采样率 | 单价区间 |
|---|---|---|---|---|---|
| DHT11 | 0~50°C / ±2°C | 20~90%RH / ±5%RH | 单总线 | 1Hz | 2~5元 |
| DHT22 | -40~80°C / ±0.5°C | 0~100%RH / ±2%RH | 单总线 | 0.5Hz | 15~30元 |
| SHT30 | -40~125°C / ±0.3°C | 0~100%RH / ±2%RH | I2C | 可调 | 10~20元 |
DHT11的精度确实一般,湿度±5%RH在精密场合不够看。但它有两个无法替代的优势:一是单总线协议只需要一根数据线,不占用I2C或SPI外设资源;二是价格极低,适合对成本敏感的批量产品。如果你的项目只是做室内环境监测、农业大棚粗略监控、或者教学演示,DHT11完全够用。要精度就上SHT30,要宽温区就上DHT22,这是我一贯的建议。
2.2 单总线协议的时序本质
DHT11用的是自定义的单总线协议,数据线在空闲时被上拉电阻拉高。通信过程分三步:主机发送起始信号、从机响应、从机发送40位数据。
起始信号:主机把数据线拉低至少18ms,然后拉高20~40us,等待从机响应。从机检测到起始信号后,拉低数据线80us表示响应,再拉高80us准备发送数据。
数据位格式:每一位数据都以50us低电平开始,随后高电平的持续时间决定数据是0还是1。高电平持续26~28us表示0,持续70us表示1。40位数据依次是:湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。校验和等于前四个字节相加取低8位。
这里的关键点在于:判断0和1靠的是高电平持续时间,而26us和70us的差距需要微秒级的测量精度。这就是为什么延时函数和GPIO读取速度直接决定成败。
2.3 硬件连接方案
接线本身很简单,但细节决定稳定性。
- VCC接3.3V或5V都可以,DHT11工作电压3.3~5.5V。我一般接3.3V,和F1的IO电平一致,省去电平转换的顾虑。
- GND共地,这个不用多说。
- DATA接任意GPIO,我习惯用PA0或PB12这类方便接线的引脚。
- 上拉电阻:DHT11模块通常自带4.7k~10k上拉电阻,如果你买的是裸传感器(4针或3针),必须在DATA和VCC之间外接一个4.7k~10k的电阻。我实测4.7k响应更快,10k在长导线时抗干扰稍好。
注意:如果你用的是裸传感器且没加上拉电阻,数据线会浮空,读出来的全是0或者直接超时。这是新手最常犯的错误之一。
3. 核心细节解析与实操要点
3.1 GPIO模式切换:开漏与推挽的取舍
单总线器件的GPIO配置是第一个容易出错的地方。DHT11的DATA线是双向的:主机发送起始信号时要输出,接收数据时要输入。所以代码里必须动态切换GPIO方向。
在STM32F1的标准外设库或HAL库里,有两种做法:
第一种是切换输入/输出模式。发送起始信号时配置为推挽输出,接收时切换为浮空输入或上拉输入。这种做法直观,但每次切换都要调用初始化函数,有额外开销。
第二种是配置为开漏输出,配合外部上拉电阻。开漏模式下,输出0时拉低,输出1时释放总线由上拉电阻拉高,同时可以直接读取引脚电平。这样不用切换模式,代码更简洁。我推荐第二种,前提是外部有上拉电阻。
用HAL库配置开漏输出的关键代码:
GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);读取时直接调HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0)即可,开漏模式下读的是引脚实际电平。
3.2 微秒级延时:SysTick还是DWT
DHT11的时序要求微秒级精度,HAL库自带的HAL_Delay()是毫秒级,不够用。常见方案有三种:
方案一:SysTick定时器。配置SysTick每1us中断一次,在中断里累加计数。缺点是中断频繁,1us一次中断会占用大量CPU时间,影响其他任务。
方案二:DWT(Data Watchpoint and Trace)。Cortex-M3内核自带DWT周期计数器,可以直接读取CPU运行的周期数,配合系统时钟频率换算出微秒。这个方案精度最高,不占中断资源。F1的SystemCoreClock通常是72MHz,1us等于72个周期。
// DWT初始化 void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } // 微秒延时 void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }方案三:简单的for循环空转。通过实测校准循环次数。这种方案移植性差,换个优化等级就失效,不推荐。
我一般用DWT方案,精度稳定,代码量小。但要注意:DWT在调试器连接时才能用,脱机运行需要确保DEMCR寄存器的TRCENA位被正确设置,这个在初始化里已经处理了。
3.3 时序容错:别把延时卡得太死
DHT11的时序虽然规定了典型值,但实际器件有±10%~20%的偏差。如果你把延时写成精确的26us判断阈值,遇到偏差大的传感器就会误判。
我的做法是:判断数据位时,先等待低电平结束(高电平到来),然后延时约40us再采样。因为0的高电平是26~28us,40us时已经变低;1的高电平是70us,40us时还是高。这样采样点落在中间,容错空间大。
// 等待低电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 延时40us后采样 delay_us(40); uint8_t bit = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); // 等待高电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET);这个40us的采样点是我反复实测后确定的,比精确测量高电平宽度更稳。
实操心得:加超时机制。如果等待电平变化时超过一定时间(比如100us)还没变化,直接返回错误。否则传感器坏了或者没接好,程序会死循环卡住。
4. 完整实操流程与代码实现
4.1 工程搭建与时钟配置
我用STM32CubeMX生成基础工程,芯片选STM32F103C8T6,时钟配置为外部晶振8MHz经PLL倍频到72MHz。这个配置是F1的经典满速运行方案,APB1为36MHz,APB2为72MHz。
GPIO配置:PA0为开漏输出、上拉、高速。USART1配置为115200波特率,用于打印温湿度数据。SysTick保持默认1ms中断。
生成代码后,把DWT初始化加到main函数开头。注意DWT初始化必须在SystemClock_Config之后,因为要用到SystemCoreClock变量。
4.2 DHT11驱动完整代码
下面是我实际项目中用的驱动,经过多个批次传感器验证。
#include "dht11.h" #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 // 微秒延时(DWT方案) static void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); } // 设置GPIO为输出(开漏) static void set_output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } // 设置GPIO为输入(上拉) static void set_input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } // 读取一个字节 static uint8_t read_byte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { // 等待低电平结束 uint32_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) { if (++timeout > 1000) return 0; } delay_us(40); byte <<= 1; if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { byte |= 1; } // 等待高电平结束 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++timeout > 1000) return 0; } } return byte; } // 读取温湿度 uint8_t DHT11_Read(float *temp, float *humi) { uint8_t data[5] = {0}; // 发送起始信号 set_output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); delay_us(30); set_input(); // 等待从机响应 uint32_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++timeout > 1000) return 1; } timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) { if (++timeout > 1000) return 2; } timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++timeout > 1000) return 3; } // 读取40位数据 for (int i = 0; i < 5; i++) { data[i] = read_byte(); } // 校验 if (data[4] != (uint8_t)(data[0] + data[1] + data[2] + data[3])) { return 4; } *humi = data[0] + data[1] * 0.1f; *temp = data[2] + data[3] * 0.1f; return 0; }4.3 主循环与串口输出
主函数里每2秒读一次,通过串口打印。DHT11采样率是1Hz,读太快没意义。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Init(); float temp, humi; char buf[64]; while (1) { uint8_t ret = DHT11_Read(&temp, &humi); if (ret == 0) { int len = snprintf(buf, sizeof(buf), "Temp: %.1f C, Humi: %.1f %%RH\r\n", temp, humi); HAL_UART_Transmit(&huart1, (uint8_t*)buf, len, 100); } else { int len = snprintf(buf, sizeof(buf), "DHT11 error: %d\r\n", ret); HAL_UART_Transmit(&huart1, (uint8_t*)buf, len, 100); } HAL_Delay(2000); } }4.4 参数计算过程说明
DWT延时里的SystemCoreClock / 1000000,当SystemCoreClock为72000000时,结果是72,即1us对应72个CPU周期。这个计算在编译时完成,不占运行时间。
起始信号拉低时间用HAL_Delay(20),20ms大于手册要求的18ms,留了余量。拉高后延时30us,落在20~40us的推荐区间内。
数据位采样点选40us,前面解释过,是为了避开0和1的高电平边界。如果你用的传感器高电平时间偏短,可以适当减小到35us;偏长则加到45us。这个值需要根据实际传感器微调。
5. 常见问题与排查技巧实录
5.1 读出来全是0或者校验失败
这是最高频的问题。排查顺序如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无响应,超时返回1 | 上拉电阻缺失、接线错误、供电不足 | 万用表测DATA线空闲时是否为高电平 |
| 响应后数据全0 | 延时精度不够、采样点错误 | 用示波器看DATA波形,确认高电平宽度 |
| 校验和偶尔失败 | 时序抖动、电源纹波 | 增加读取间隔,VCC并0.1uF电容 |
| 温度正常湿度异常 | 传感器受潮或老化 | 更换传感器对比 |
我遇到最多的是上拉电阻问题。很多人买的是三针模块,以为自带上拉,实际上有些廉价模块省掉了这个电阻。用万用表量一下DATA对VCC的电阻,如果是无穷大,就得自己加。
5.2 编译器优化导致的时序错乱
这个坑很隐蔽。当你把优化等级调到-O2或-O3时,编译器可能把delay_us里的循环优化掉,或者重排GPIO读写顺序,导致时序完全乱掉。
解决办法有两个:一是把delay_us函数用__attribute__((optimize("O0")))修饰,禁止优化;二是把DWT计数变量声明为volatile。我一般两个都做,保险。
__attribute__((optimize("O0"))) static void delay_us(uint32_t us) { volatile uint32_t start = DWT->CYCCNT; volatile uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }注意:DWT->CYCCNT本身是硬件寄存器,读取时不会被优化,但中间的算术运算可能被重排。加volatile是防止编译器假设变量不变。
5.3 中断干扰导致的偶发失败
如果你的系统里有其他中断(比如串口接收中断、定时器中断),在DHT11通信的几毫秒内如果发生中断,时序就会被拉长,导致读取失败。
我的处理方式是在DHT11_Read函数开头关全局中断,读完再开。DHT11一次通信大约4~5ms,关中断这么久对大多数应用可以接受。如果系统对中断实时性要求高,那就把DHT11读取放在低优先级任务里,或者用状态机方式分步执行。
__disable_irq(); uint8_t ret = DHT11_Read(&temp, &humi); __enable_irq();5.4 长导线导致的信号劣化
传感器离主板超过半米时,导线电容和干扰会让波形边沿变缓,读出来的数据错误率上升。解决办法:缩短导线、用屏蔽线、降低上拉电阻到4.7k、在传感器端VCC和GND之间并一个0.1uF陶瓷电容。我实测导线1米以内用4.7k上拉基本没问题,超过2米就建议改用I2C的SHT30了。
5.5 常见问题速查表
| 错误码 | 含义 | 优先排查 |
|---|---|---|
| 1 | 等待从机响应超时 | 上拉电阻、接线、供电 |
| 2 | 从机响应低电平超时 | 传感器损坏、时序过快 |
| 3 | 从机响应高电平超时 | 延时精度、中断干扰 |
| 4 | 校验和错误 | 采样点、电源纹波、导线长度 |
6. 从DHT11延伸出去的F1开发经验
6.1 单总线协议的通用套路
DHT11只是单总线的一个例子,DS18B20温度传感器、WS2812灯带用的也是类似思路。掌握了一套,其他的触类旁通。核心就三点:GPIO方向切换、微秒级延时、超时保护。把这三点封装成通用函数,换个协议只需要改时序参数。
我一般会写一个onewire.c,把复位、写位、读位、写字节、读字节封装好,DHT11和DS18B20共用底层。这样代码复用率高,维护也方便。
6.2 F1的资源分配建议
STM32F103C8T6只有64KB Flash和20KB SRAM,资源紧张。我的分配习惯是:HAL库占15~20KB,驱动代码控制在5KB以内,剩下留给应用逻辑。如果用了RTOS,FreeRTOS内核大约6~8KB,要提前算好。
SRAM方面,20KB看着少,但DHT11这种应用只用了不到1KB。真正吃内存的是缓冲区,比如你要做数据记录,存1000条温湿度记录,每条8字节就是8KB,得精打细算。
6.3 低功耗场景的处理
如果项目是电池供电,DHT11的功耗要考虑。它工作时电流约1mA,待机约50uA。F1本身可以进Stop模式,电流降到几十uA。做法是:读一次温湿度,然后进Stop模式,用RTC定时唤醒,每2秒或更久读一次。这样平均电流可以压到100uA以下,两节5号电池能撑几个月。
但要注意:进Stop模式前要把GPIO配置好,避免漏电流。DHT11的DATA线在待机时应该保持高电平,否则传感器可能被反向供电。
6.4 从标准库到HAL库的迁移
网上很多F1教程用的是标准外设库,而ST现在主推HAL库。两者在GPIO操作上差异不大,但HAL库多了初始化和时钟使能的封装。如果你从标准库代码迁移,主要改这几个地方:GPIO_Init换成HAL_GPIO_Init,GPIO_ReadInputDataBit换成HAL_GPIO_ReadPin,延时用HAL_Delay或自己实现。整体工作量不大,半天能搞定。
我个人建议新项目直接用HAL库,配合CubeMX生成代码,效率高很多。老项目维护就继续用标准库,没必要为了迁移而迁移。
6.5 调试工具的选择
调DHT11这种时序敏感的器件,逻辑分析仪比示波器更实用。一个几十块的8通道逻辑分析仪,配合开源软件,能直接解码单总线协议,把每一位的0和1都标出来。我第一次调DHT11就是靠它发现采样点偏了5us。
如果没有逻辑分析仪,退而求其次用示波器看波形,或者用IO翻转法:在关键节点翻转一个空闲GPIO,用示波器测翻转间隔,间接验证延时精度。这个方法土但有效。
7. 写在最后的几句实在话
DHT11这个传感器,说简单也简单,说坑也多。我见过太多人卡在"读不出来"这一步就放弃了,其实问题往往就出在上拉电阻、延时精度、中断干扰这三个点上。把这三个点吃透,不光DHT11,其他时序敏感的器件你也能搞定。
STM32F1这颗芯片,放在今天看确实老,但它的价值不在于性能,而在于它是一块"透明"的芯片——外设简单、手册清晰、社区资料丰富,你能把每一个寄存器、每一条时序都搞明白。这种"搞明白"的感觉,是直接上H7或者用Arduino体会不到的。
最后分享一个小技巧:调时序的时候,把关键波形用逻辑分析仪抓下来,和手册上的时序图叠在一起对比,差多少一目了然。这比盯着代码猜要快十倍。我现在的习惯是,任何单总线器件上手第一件事就是抓波形,确认时序对了再写业务逻辑,能省掉大量返工时间。