1. 为什么STM32F1到今天还值得花时间学
1.1 一颗老芯片的生存逻辑
如果你最近在电子论坛或者开源硬件社区里逛,会发现一个很有意思的现象:一边是各种高性能MCU层出不穷,一边是STM32F1系列的开发板依然卖得火热,尤其是搭配DHT11温湿度传感器的入门套件,几乎成了很多电子爱好者的“第一课”。我从2013年前后开始用STM32F103C8T6做项目,到现在零零散散也经手过几十个基于F1的案子,说实话,这颗芯片能活这么久,靠的不是性能,而是生态惯性和学习曲线的平滑度。
STM32F1系列是ST公司基于ARM Cortex-M3内核推出的32位微控制器,最经典的型号就是F103系列,比如C8T6、RCT6、ZET6这些。它的主频通常跑在72MHz,Flash从16KB到1MB不等,SRAM从6KB到96KB,外设覆盖了GPIO、USART、SPI、I2C、ADC、定时器、CAN、USB等常用接口。这些参数放到今天看确实不算亮眼,但它的价值在于:资料多到离谱,踩坑的人多到离谱,解决方案也多到离谱。你遇到任何问题,几乎都能找到前人留下的答案。
那为什么DHT11温湿度传感器会和STM32F1绑得这么紧?因为DHT11是典型的单总线低速传感器,它不需要复杂的协议栈,只需要一个GPIO就能读取数据,非常适合用来练习GPIO输入输出控制、微秒级延时、时序分析这些嵌入式基本功。而且温湿度数据直观,做出来的东西能立刻看到效果,对初学者来说正反馈很强。我见过不少人的第一个嵌入式项目就是“STM32F1+DHT11+OLED显示温湿度”,虽然简单,但把GPIO、定时器、串口打印、I2C显示屏这些知识点全串起来了。
1.2 这套组合到底能做什么
基于STM32F1和DHT11,你可以做的东西其实比想象中多。最基础的是本地温湿度监测,用OLED或者LCD1602把数据显示出来;进阶一点可以加个ESP8266或者蓝牙模块,把数据传到手机或者云平台;再复杂一点可以做成闭环控制系统,比如温度超过阈值就启动风扇,湿度低于阈值就打开加湿器。我甚至还见过有人用它做孵化箱控制器和菌菇房环境监测,成本不到五十块钱,但解决的是实实在在的生产问题。
这套组合适合谁呢?如果你是电子、自动化、物联网相关专业的学生,它几乎是必修课;如果你是转行做嵌入式开发的工程师,用它来练手能快速建立对MCU的直觉;如果你只是业余爱好者,想做个智能家居的小玩意儿,它也完全够用。关键是,它的学习成本极低,一块核心板十几块钱,DHT11几块钱,加上杜邦线和USB转串口模块,总投入不超过三十块就能跑起来。
1.3 我为什么还在用它
有人可能会问,现在都有ESP32、RP2040这些更便宜、性能更强的芯片了,为什么还要折腾F1?我的理由很实际:当你需要一颗芯片稳定跑十年,而且不想在软件上花太多心思的时候,F1是经过验证的选择。它的参考手册、标准外设库、HAL库、LL库都很成熟,社区里关于它的讨论足够深,遇到问题不会卡住。而且很多工业设备、医疗仪器、电力监控终端里,F1还在批量出货,你学会了它,找工作或者接项目都用得上。
DHT11也是同样的道理。虽然它的精度不高(温度±2℃,湿度±5%RH),响应速度也慢(采样周期至少1秒),但它便宜、简单、可靠。在不需要高精度数据的场景里,比如农业大棚、仓库监控、室内环境记录,它完全够用。而且它的单总线协议非常适合用来学习时序敏感型通信,这个技能在嵌入式开发里是绕不开的。
2. 核心细节解析:DHT11与STM32F1的配合要点
2.1 DHT11的单总线协议到底怎么读
DHT11的通信协议看起来简单,但里面有几个坑,不踩一次很难记住。它的数据格式是40位:8位湿度整数+8位湿度小数+8位温度整数+8位温度小数+8位校验和。注意,DHT11的小数部分通常都是0,因为它的分辨率就是1%RH和1℃,所以实际上你只需要关心整数部分。校验和是前四个字节相加取低8位,用来判断数据是否有效。
通信过程是这样的:主机先拉低总线至少18毫秒,然后拉高20-40微秒,接着释放总线。DHT11检测到这个起始信号后,会拉低总线80微秒,再拉高80微秒,表示响应。之后就是连续的40位数据,每一位都以50微秒的低电平开始,然后高电平的持续时间决定数据是0还是1:26-28微秒表示0,70微秒左右表示1。最后DHT11会拉低总线50微秒,结束一次通信。
这里的关键是微秒级延时。STM32F1的SysTick定时器可以配置成1微秒中断一次,但中断本身有开销,实际延时可能不准。我通常的做法是用DWT(Data Watchpoint and Trace)或者定时器直接计数来做微秒延时,精度能控制在±1微秒以内。如果你用HAL库的HAL_Delay(),那只能做到毫秒级,读DHT11会直接失败。
2.2 GPIO模式切换的时机与陷阱
DHT11是单总线设备,一根线既要输出又要输入,所以GPIO的模式必须动态切换。STM32F1的GPIO有四种模式:输入浮空、输入上拉/下拉、推挽输出、开漏输出。读DHT11的时候,我一般这样配置:
- 主机发送起始信号时:配置为推挽输出,速度50MHz,先拉低,再拉高,再释放。
- 释放总线后:立刻切换为输入浮空或者输入上拉。注意,DHT11内部有上拉电阻,但阻值较大(通常4.7kΩ到10kΩ),如果线太长或者干扰大,最好在外部再并一个4.7kΩ的上拉电阻。
- 读取数据时:保持输入模式,通过读取IDR寄存器判断电平。
这里有个容易忽略的细节:切换模式后需要短暂延时,让GPIO稳定下来。我一般会加2-3微秒的延时,否则第一次读到的电平可能是错的。另外,如果你用的是开漏输出模式,必须确保外部有上拉电阻,否则总线永远拉不高。
2.3 时序容错与超时处理
DHT11的时序要求虽然不算苛刻,但也不是随便写写就能跑通的。我遇到过几种典型情况:第一种是延时不准,导致起始信号宽度不对,DHT11根本不响应;第二种是读取速度太慢,在等待高电平结束的时候错过了下一位的起始;第三种是干扰导致校验失败,数据偶尔出错。
解决这些问题的核心是超时机制。在等待DHT11响应的时候,不能死等,要设置一个最大等待时间,比如100微秒,超过就认为通信失败,直接返回错误码。在读取每一位数据的时候,也要设置超时,防止因为干扰导致程序卡死。我通常会在代码里加一个while循环配合计数器,比如:
uint32_t timeout = 0; while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) { timeout++; if (timeout > 1000) return ERROR_TIMEOUT; delay_us(1); }这样即使DHT11没接好或者坏了,程序也不会卡住,而是返回一个错误,让你知道问题出在哪里。
2.4 电源与布线的实际影响
DHT11的工作电压是3.3V到5.5V,STM32F1的GPIO是3.3V电平,所以直接连没问题。但如果你用5V给DHT11供电,而STM32F1的GPIO是3.3V,那数据线最好加一个电平转换,或者至少串一个1kΩ电阻保护IO口。我实测过,短距离(小于20厘米)直接连3.3V,DHT11也能正常工作,但数据偶尔会飘。
布线方面,数据线尽量短,最好不超过20厘米。如果必须延长,建议用屏蔽线,并且在DHT11端加一个0.1μF的去耦电容。电源线也要干净,如果和电机、继电器共用电源,最好加LC滤波。我做过一个项目,DHT11和继电器在同一块板上,继电器一吸合,温湿度数据就乱跳,后来在DHT11的电源脚并了一个100μF的电解电容和0.1μF的陶瓷电容,问题就解决了。
3. 实操过程:从零搭建温湿度监测系统
3.1 硬件准备与连接
先列一下我常用的物料清单:
| 物料 | 型号 | 数量 | 备注 |
|---|---|---|---|
| 核心板 | STM32F103C8T6最小系统板 | 1 | 蓝色药丸板即可 |
| 温湿度传感器 | DHT11 | 1 | 三针或四针模块都行 |
| 显示屏 | 0.96寸OLED(I2C接口) | 1 | SSD1306驱动 |
| 下载器 | ST-Link V2 | 1 | 用于烧录和调试 |
| 杜邦线 | 母对母 | 若干 | 建议用不同颜色区分 |
| USB转串口 | CH340模块 | 1 | 用于串口打印调试 |
连接方式如下:
- DHT11的VCC接3.3V,GND接GND,DATA接PA0(任意GPIO都可以,我习惯用PA0)。
- OLED的VCC接3.3V,GND接GND,SCL接PB6,SDA接PB7(这是I2C1的默认引脚)。
- ST-Link的SWDIO接PA13,SWCLK接PA14,GND接GND,3.3V接3.3V。
- USB转串口的TX接PA10(USART1_RX),RX接PA9(USART1_TX),GND接GND。
注意:DHT11的DATA引脚建议外接一个4.7kΩ上拉电阻到3.3V,虽然很多模块自带上拉,但外接一个更稳。
3.2 工程搭建与时钟配置
我用的是Keil MDK或者STM32CubeIDE,这里以CubeIDE为例。新建工程,选择STM32F103C8T6,然后配置时钟:
- RCC:HSE选择Crystal/Ceramic Resonator,LSE可以不选。
- Clock Configuration:HSE 8MHz,PLL倍频到72MHz,AHB不分频,APB1分频系数2(36MHz),APB2不分频(72MHz)。
- GPIO:PA0配置为GPIO_Input,初始状态为Pull-up;PB6和PB7配置为I2C1的SCL和SDA;PA9和PA10配置为USART1的TX和RX。
- I2C1:模式选择I2C,速度100kHz或者400kHz都可以。
- USART1:波特率115200,8位数据,无校验,1位停止位。
- SYS:Debug选择Serial Wire。
生成代码后,先写一个微秒延时函数。我用的是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); }这个延时函数的精度取决于SystemCoreClock,72MHz下每个计数是1/72微秒,误差很小。
3.3 DHT11驱动代码实现
先定义GPIO操作宏:
#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 #define DHT11_OUT_H() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET) #define DHT11_OUT_L() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET) #define DHT11_IN() HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } void DHT11_SetInput(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); }读取流程:
uint8_t DHT11_Read(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i, j; // 主机发送起始信号 DHT11_SetOutput(); DHT11_OUT_L(); HAL_Delay(20); // 至少18ms DHT11_OUT_H(); delay_us(30); DHT11_SetInput(); // 等待DHT11响应 if (DHT11_WaitForLevel(0, 100) != OK) return ERROR; if (DHT11_WaitForLevel(1, 100) != OK) return ERROR; if (DHT11_WaitForLevel(0, 100) != OK) return ERROR; // 读取40位数据 for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { if (DHT11_WaitForLevel(1, 100) != OK) return ERROR; delay_us(40); buf[i] <<= 1; if (DHT11_IN() == GPIO_PIN_SET) { buf[i] |= 1; if (DHT11_WaitForLevel(0, 100) != OK) return ERROR; } } } // 校验 if (buf[4] != (buf[0] + buf[1] + buf[2] + buf[3])) return ERROR_CHECKSUM; *humi = buf[0]; *temp = buf[2]; return OK; }其中DHT11_WaitForLevel是一个带超时的等待函数:
uint8_t DHT11_WaitForLevel(uint8_t level, uint32_t timeout_us) { uint32_t count = 0; while (DHT11_IN() != level) { if (++count > timeout_us) return ERROR_TIMEOUT; delay_us(1); } return OK; }这段代码我调过很多次,关键点是延时精度和超时保护。如果你发现读出来的数据一直是0或者校验失败,先检查延时函数准不准,可以用示波器或者逻辑分析仪抓一下波形,看看起始信号是不是20毫秒,响应信号是不是80微秒。
3.4 OLED显示与串口打印
OLED我用的是SSD1306驱动,I2C地址通常是0x78或者0x7A。初始化后,可以用ssd1306_DrawString之类的函数显示温湿度。我一般会显示三行:第一行“Temp: 25 C”,第二行“Humi: 60 %”,第三行“Status: OK”。如果读取失败,第三行显示错误码,方便排查。
串口打印更简单,重定向printf到USART1:
int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }然后在主循环里每2秒打印一次:
printf("Temp: %d C, Humi: %d %%\r\n", temp, humi);提示:DHT11的采样周期至少1秒,所以主循环里不要频繁读取,否则数据会不准。我一般用HAL_Delay(2000)或者定时器中断来触发读取。
3.5 完整主循环逻辑
主循环里我通常这样写:
while (1) { if (DHT11_Read(&temp, &humi) == OK) { OLED_ShowString(0, 0, "Temp: "); OLED_ShowNum(48, 0, temp, 2); OLED_ShowString(0, 2, "Humi: "); OLED_ShowNum(48, 2, humi, 2); printf("Temp: %d C, Humi: %d %%\r\n", temp, humi); } else { OLED_ShowString(0, 4, "Read Error"); printf("DHT11 Read Error\r\n"); } HAL_Delay(2000); }如果你想让系统更实时,可以用定时器中断每2秒置一个标志位,主循环检测标志位再读取,这样不会阻塞其他任务。
4. 常见问题与排查技巧实录
4.1 数据一直为0或者校验失败
这是最常见的问题,原因通常有三个:延时不准、GPIO模式切换不对、上拉电阻缺失。我的排查顺序是:先用示波器看起始信号是不是20毫秒,再看DHT11有没有拉低总线80微秒响应。如果没有响应,检查DATA线是不是接触不良,或者DHT11是不是坏了(换一个试试)。如果有响应但数据不对,检查延时函数的精度,特别是delay_us(40)那一段,40微秒是判断0和1的分界点,如果延时偏短,1会被读成0。
还有一个隐藏坑:编译器优化。如果你用-O2或者-O3优化,delay_us里的循环可能被优化掉,导致延时完全不准。解决办法是把延时函数用volatile修饰,或者直接嵌入汇编NOP指令。
4.2 读取间隔太短导致数据不变
DHT11的内部采样周期是1秒,如果你每100毫秒读一次,它会返回上一次的数据,看起来就像“数据不变”。我见过有人以为是传感器坏了,其实是读得太快。解决办法很简单:两次读取之间至少间隔1秒,最好2秒。
4.3 长线传输导致数据跳变
如果你把DHT11放在离STM32F1比较远的地方(比如1米以上),数据可能会跳变。这是因为单总线对分布电容很敏感,线越长,上升沿越缓,DHT11可能识别不到。解决办法是降低上拉电阻(比如从10kΩ降到4.7kΩ),或者用双绞线加屏蔽层。如果还不行,就只能把DHT11靠近MCU,或者改用I2C接口的温湿度传感器(比如SHT30)。
4.4 电源干扰导致复位
DHT11在发送数据时电流会突然增大,如果电源内阻大或者去耦不足,电压会瞬间跌落,导致DHT11复位或者STM32F1复位。我遇到过一次,DHT11和STM32F1共用USB供电,读数据时STM32F1偶尔重启。后来在DHT11的VCC和GND之间并了一个100μF的电解电容,问题消失。所以电源去耦不能省,尤其是电机、继电器、无线模块共存的时候。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 数据全0 | 延时不准 | 示波器看起始信号 | 用DWT或定时器做微秒延时 |
| 校验失败 | 干扰或时序偏差 | 多次读取看错误率 | 加超时重试,检查上拉电阻 |
| 数据不变 | 读取间隔太短 | 看代码延时 | 间隔至少1秒 |
| 偶尔复位 | 电源跌落 | 示波器看VCC波形 | 加100μF+0.1μF去耦电容 |
| 长线跳变 | 分布电容大 | 缩短线长测试 | 降低上拉电阻,用屏蔽线 |
| 编译后失效 | 编译器优化 | 对比-O0和-O2 | 延时函数加volatile |
4.6 我的独家避坑经验
第一个经验:不要用HAL_Delay做微秒延时。HAL_Delay的最小分辨率是1毫秒,而且它依赖SysTick中断,在中断里调用会死锁。我早期用HAL_Delay(1)想延时1毫秒,结果实际延时可能1.5毫秒,DHT11直接不响应。
第二个经验:DHT11的DATA线不要接在有复用功能的引脚上。比如PA0默认是WKUP,PA13和PA14是SWD,如果你不小心把DHT11接在这些引脚上,可能会和调试功能冲突。我一般选PA0到PA7这些纯GPIO。
第三个经验:读取失败不要立刻报错,连续读三次取成功的那次。DHT11偶尔会因为干扰返回错误数据,如果每次失败都报警,系统会很吵。我的做法是连续读三次,只要有一次成功就更新数据,三次都失败才报错。
第四个经验:OLED和DHT11不要共用电源线。OLED在刷新时电流波动较大,如果和DHT11共用一根细杜邦线,DHT11可能会被拉低。我一般给OLED单独走一根电源线,或者在OLED的VCC脚并一个10μF电容。
5. 从温湿度监测延伸到实际项目
5.1 加一个继电器做闭环控制
温湿度监测只是第一步,真正有用的是根据数据做控制。比如温度超过30℃就启动风扇,湿度低于40%就打开加湿器。继电器模块通常有光耦隔离,输入端接STM32F1的GPIO,输出端接风扇或加湿器。代码里加一个判断:
if (temp > 30) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // 开风扇 } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // 关风扇 }注意继电器吸合时会产生反向电动势,最好在继电器线圈两端并一个续流二极管(比如1N4148),否则可能干扰STM32F1。
5.2 通过串口屏做本地显示
如果你觉得OLED太小,可以换串口屏(比如陶晶驰的TJC系列)。串口屏通过USART和STM32F1通信,你只需要发送特定格式的指令就能更新显示。比如t0.txt="25"就是把t0控件的文本改成25。这种方式比OLED灵活,可以做按钮、曲线、报警提示。
5.3 数据上云与远程监控
如果你想远程看温湿度,可以加一个ESP8266模块,通过AT指令连上WiFi,然后走MQTT协议把数据发到服务器。STM32F1和ESP8266之间用USART通信,波特率通常115200。代码里需要解析AT指令的响应,处理连接、订阅、发布这些流程。这个部分稍微复杂,但网上有很多现成的例程可以参考。
5.4 低功耗改造
如果项目是电池供电,比如放在大棚里,那低功耗就很重要。STM32F1支持睡眠、停止、待机三种低功耗模式。我的做法是:平时让STM32F1进入停止模式,用RTC定时唤醒,醒来后读DHT11,通过无线模块发送数据,然后继续睡。这样平均电流可以降到几十微安,一节18650电池能撑几个月。注意,进入停止模式前要把GPIO配置成模拟输入或者下拉,避免漏电流。
5.5 从F1迁移到其他芯片的思路
如果你学会了F1+DHT11这套组合,以后换到STM32F4、G0、或者国产GD32、APM32,基本只需要改时钟配置和GPIO初始化,DHT11的驱动逻辑几乎不用动。因为单总线协议是纯软件时序,和芯片无关。这也是我建议新手从F1入手的原因:你学到的是通用技能,不是某颗芯片的专有知识。
6. 一些关于工具和调试的实在话
调试DHT11这种时序敏感的设备,逻辑分析仪比示波器更实用。示波器看模拟波形好,但逻辑分析仪能直接解码单总线协议,把40位数据直接显示出来,省得你手动数脉冲。我用的是一款几十块钱的8通道逻辑分析仪,配合开源软件,抓DHT11的波形一目了然。
如果你没有逻辑分析仪,也可以用串口打印调试法:在代码里把每一位的持续时间打印出来,看看0和1的宽度是不是符合预期。虽然麻烦,但也能定位问题。
还有一点:不要迷信模块。市面上很多DHT11模块自带上拉电阻和LED,但质量参差不齐。我买过一批模块,上拉电阻用的是10kΩ,读数据经常失败,后来自己换成4.7kΩ就稳了。所以如果你遇到奇怪的问题,先检查模块上的元件,别急着怀疑代码。
最后,关于代码风格,我建议把DHT11的驱动单独放在一个.c和.h文件里,对外只暴露DHT11_Read和DHT11_Init两个函数。这样以后换引脚或者换芯片,只需要改驱动文件,主程序不用动。这个习惯在项目变大之后会救你很多时间。