如果你搞嵌入式开发,不管做产品还是玩DIY,大概率绕不开STM32F1系列。这颗芯片最早上市已经十多年了,Cortex-M3内核跑到72MHz,听起来参数平平无奇,但直到今天它依旧是很多入门者的第一块开发板,也是不少量产产品的稳定之选。尤其是搭配DHT11温湿度传感器这类经典外设,一个最小系统加一个传感器,就能跑通“采集、处理、输出”的完整链路,对理解嵌入式系统非常有帮助。
这篇文章我打算从STM32F1本身讲起,聊一聊它为什么到现在还有生命力,再带你完整过一遍开发环境搭建、DHT11温湿度传感器的驱动原理、代码实现,以及我这些年踩过的坑。如果你正要开始学STM32F1,或者想在项目里快速接入DHT11,这篇内容可以直接照着操作。
1. STM32F1系列到底“强”在哪里
1.1 从核心资源看选型逻辑
STM32F1系列基于ARM Cortex-M3内核,主频最高72MHz,Flash从32KB到512KB,SRAM从6KB到64KB。放到今天看,这个性能连很多Cortex-M4都打不过,但它的定位从来不是跑复杂算法,而是做“稳定可靠的控制中枢”。
常见的型号里,STM32F103C8T6算是“网红”级的,20KB RAM、64KB Flash,价格低、资料多,淘宝上一块核心板十几块就能买到。再往上走,STM32F103ZET6有512KB Flash和64KB RAM,引脚拉到144个,可以接LCD屏、外部存储器、多路传感器,工业控制里也很常见。选择哪一颗,主要看引脚数量、Flash大小以及是否需要CAN、USB这类外设。
F1系列的外设规模和灵活度在当时非常激进一步。通用定时器和高级定时器加起来有好几个,支持PWM输出、输入捕获、编码器接口;USART、SPI、I2C都有;ADC是12位,有些型号还有DAC;部分型号集成了USB Device和CAN控制器。这意味着一个芯片能同时搞定传感器采集、电机控制、通信协议,非常适合做小型设备的主控。
1.2 生态和资料是它最大的护城河
STM32F1系列能火这么久,技术规格只是基础,真正重要的是围绕它建立的庞大生态。网上搜“STM32F1”,能找到开发板教程、开源项目、竞赛方案、量产产品例程,几乎任何一种应用场景都有人做过。你遇到问题,去论坛一搜,基本都能找到答案。
开发工具有Keil MDK、IAR、STM32CubeIDE,还有开源的GCC工具链。库函数的选择也丰富,早期是标准外设库,后来ST主推HAL库,现在CubeMX生成代码已经成了主流开发方式。再加上ST-Link调试器便宜好用,JTAG和SWD调试都非常方便。这些因素叠加在一起,让STM32F1的上手门槛极低,这也是它常年在入门教程里占据C位的原因。
2. 开发环境搭建与工程初始化
2.1 工具链怎么选
如果你刚接触STM32F1,我建议直接用Keil MDK加STM32CubeMX的组合。Keil是老牌IDE,编译、下载、调试一体化,很多老工程师和教程都基于它进行教学。CubeMX负责图形化配置引脚、时钟、外设,自动生成初始化代码,这样你就不用去手动翻寄存器手册,也不用管HAL库那几百个初始化函数怎么填参数。
也有朋友喜欢用STM32CubeIDE,这是一个基于Eclipse的免费IDE,内部集成了CubeMX,直接把配置和编译调试融合在一起。它的缺点是在部分低配置电脑上启动和编译稍慢,但胜在免费,没有Keil的License限制。
我个人在Windows上习惯是:CubeMX生成工程,Keil编译调试。如果你在Linux或Mac上开发,也可以用STM32CubeIDE,或者直接装arm-none-eabi-gcc配合Makefile,不过这样就要自己管理链接脚本和启动文件,对新手不太友好。
2.2 HAL库还是标准库
这个问题经常被问到。简单说,标准库在F1时代非常流行,代码直观,直接操作寄存器封装好的结构体,性能好、代码体积小,但ST已经不再维护,遇到新芯片就用不了。HAL库是ST当前主推的抽象层,API统一,支持全系列芯片,配合CubeMX生成代码非常便利,但会增加一点代码量和时延开销,对一些极端性能场景会有影响。
对大多数入门和产品开发来说,我推荐HAL库。原因很简单:开发效率高,可复用性强,ST也一直对HAL库进行bug修复。如果你追求极致实时性,比如硬实时控制里对延时、中断响应都精确到微秒,那可以部分用寄存器操作或直接写底层驱动。DHT11这类慢速传感器,完全用HAL库就够了。
2.3 用CubeMX生成一个最小工程
打开CubeMX,选择芯片型号,比如STM32F103C8T6。首先配置时钟源:外部高速时钟(HSE)选择Crystal/Ceramic Resonator,这样可以使用板子上的8MHz晶振。在Clock Configuration页面把PLL倍频到72MHz,注意系统时钟源要选PLL,总线分频按默认配置即可:AHB 72MHz,APB1 36MHz,APB2 72MHz。
接下来配置调试接口。很多开发板的SWD引脚是PB3、PA15、PB4等重映射引脚,如果不小心把它们占用了,会导致下载器连不上芯片。最安全的做法是在System Core -> SYS页面把Debug设为Serial Wire,同时保留PA13和PA14给SWD使用。
GPIO方面,如果只是跑DHT11,可以把一个普通引脚设为推挽输出,比如PA1。后续我们驱动DHT11时要在这根线上做输出和输入切换,所以CubeMX里先不设置得非常复杂,直接在代码里动态配置即可,工程骨架先搭起来。生成代码后,打开Keil工程,编译下载,一颗LED就能跑起来,说明环境已经通了。
3. STM32F1驱动DHT11温湿度传感器
3.1 DHT11的工作原理与单总线协议
DHT11是一款很常见的数字温湿度传感器,测量范围湿度20%-90%RH、温度0-50℃,精度不算高,湿度误差±5%RH、温度误差±2℃,但对环境监测、室内监控这类场景已经够用了。
它和数据线之间走的是单总线协议,这意味着同一根线上既传输数据也承担部分时序控制。整个通信过程由主机发起,传感器被动应答。DHT11上电后先稳定1到2秒,然后等待主机发送起始信号,起始信号是一个至少18ms的低电平,紧接着释放总线,主机把IO设置为输入模式读取从机的响应和数据。
DHT11返回的数据一共40位,格式如下:8位湿度整数部分、8位湿度小数部分、8位温度整数部分、8位温度小数部分、8位校验和。校验和的算法是前四个字节相加,取低八位,如果和第五个字节相等,就说明这次数据有效。在这需要注意,DHT11的小数部分在很多型号上恒为0,所以实际温度和湿度就是整数部分,但代码里还是要按协议解析。
单总线对时序要求比较严格,主机拉低起始信号的时间不够或者过长,都会导致传感器不响应。另外,每次读取之间必须间隔1秒以上,DHT11的采样本身就是1Hz,读得太频繁会读到上一次的缓存数据或直接失败。
3.2 DHT11读取时序详解
先把整个读取过程拆开看。完整时序由几部分组成:
主机起始信号:主机将数据线拉低,保持至少18毫秒,再释放总线。释放后微控制器将IO配置为输入模式,此时外部上拉电阻会把电平拉高。
传感器响应信号:DHT11检测到起始信号结束后,会把总线拉低约80微秒,再释放并拉高约80微秒,表示准备好发送数据。这一段响应时间很微妙,代码里一般用延时函数轮询等待。
数据位传输:每一位数据的开始都是一个约50微秒的低电平信号,此后高低电平的持续时间决定了该位是0还是1。如果高电平持续约26到28微秒,代表“0”;如果高电平持续约70微秒,则代表“1”。
这里有一个初学者容易犯的错:读数据时不是在低电平阶段判断,而是要等待低电平结束之后,开始测量高电平的宽度。很多代码里先等待引脚拉低,再等待引脚拉高,然后用循环测量高电平持续了多少微秒,超过40微秒就判定为1。
结束信号:40位数据全部传完后,传感器会释放总线,恢复到高电平空闲状态。
所以,整个驱动的核心就是三个定时能力:能精确拉低18ms、能检测80us级别的低电平响应、能测量几十微秒级别的高电平宽度。STM32F1的HAL库自带的HAL_Delay最小单位是毫秒,用来做起始信号没问题,后面微秒级的时序就得自己写延时或者用定时器。
我在实际项目里习惯用SysTick实现一个微秒级延时函数。简单方法是对系统时钟计数循环操作,72MHz下每条空指令大约几十纳秒,用DWT(Data Watchpoint and Trace)模块也可以更精确地计数,但在DHT11这种宽容度还算可以的传感器上,普通的循环延时加调整就能稳定工作。
3.3 STM32F1驱动DHT11的代码实现
直接讲代码。这里我先说思路:把PA1配置成输出,发送起始信号;然后切换成输入模式;等待响应信号,再逐位读取40位数据;最后做校验和判断。
HAL库的标准写法是这样。
#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 static void DHT11_Set_Pin_Output(GPIO_TypeDef *port, uint16_t pin) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(port, &GPIO_InitStruct); } static void DHT11_Set_Pin_Input(GPIO_TypeDef *port, uint16_t pin) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(port, &GPIO_InitStruct); } static void DHT11_Delay_us(uint32_t us) { // 在72MHz主频下,以下循环大约可以做到us级别延时 // 实际需要根据编译器优化级别做微调 uint32_t count = us * 36; // 近似值 while (count--) { __NOP(); } } static uint8_t DHT11_ReadByte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { // 等待50us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 高电平开始计时 DHT11_Delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { value |= (0x80 >> i); // 等待高电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); } } return value; } uint8_t DHT11_Read_Data(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; uint8_t checksum = 0; uint16_t timeout = 0; // 设置IO为输出模式 DHT11_Set_Pin_Output(DHT11_GPIO_PORT, DHT11_GPIO_PIN); // 起始信号:拉低至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 释放总线 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); // 切换为输入模式,等待传感器响应 DHT11_Set_Pin_Input(DHT11_GPIO_PORT, DHT11_GPIO_PIN); // 等待低电平响应信号 timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (--timeout == 0) return 1; } // 等待高电平响应信号结束 timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) return 1; } // 读取40位数据 for (int i = 0; i < 5; i++) { data[i] = DHT11_ReadByte(); } // 恢复总线空闲状态 DHT11_Set_Pin_Output(DHT11_GPIO_PORT, DHT11_GPIO_PIN); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); // 校验和 checksum = data[0] + data[1] + data[2] + data[3]; if (checksum != data[4]) return 2; *humidity = data[0]; *temperature = data[2]; return 0; }代码里有两处需要你自己实测微调。第一处是微秒延时函数中那个36的系数,我在Keil默认优化级别下测试是稳定的,但开了-O2优化之后,循环执行时间会明显缩短,你可能需要把系数调高一些,比如改成48或60。第二处是DHT11_ReadByte里延时40微秒后去读引脚,这个时间点是判断0和1的关键,早了会把0的高电平还当低电平,晚了又会错过1的高电平尾巴。
3.4 数据怎么转换成温度和湿度
DHT11返回的原始数据是这样用的:
- humidity = data[0],也就是湿度整数部分
- temperature = data[2],温度整数部分
- 如果想要小数部分,可以按 data[1] / 10 和 data[3] / 10 换算,但很多DHT11模块的小数部分固定为0
如果硬件上焊接了DHT12或更新的传感器,数据格式会有区别,但DHT11这套代码基本兼容。这里需要提醒一下:DHT11的温湿度精度本身就有限,数据如果出现了明显跳变,比如湿度从50%一下跳到90%,首先要怀疑传感器探头附近是否有水汽干扰、接线是否松动、供电电压是否稳定,而不是先怀疑代码。
读到的数值可以直接通过串口打印,或者驱动OLED显示。最常见的做法是把串口1接USB转TTL模块,然后使用printf重定向到串口,这样在电脑上就能直接看数据。
4. 串口调试和DHT11数据输出
4.1 配置UART并重定向printf
在CubeMX里把USART1使能,异步模式,波特率设115200,然后在代码里重定向printf。这里有个经典坑:Keil环境下需要包含stdio.h,并且实现fputc函数。
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }然后调用方式就很简单:
printf("Humidity: %d%% RH, Temperature: %d C\r\n", humidity, temperature);要注意,HAL_UART_Transmit是阻塞式发送,在低波特率、数据量较大时会占用CPU时间,比如每秒钟打印一次日志就会阻塞几毫秒到几十毫秒。对DHT11这个场景来说问题不大,但在做电机控制或通信协议时,建议改成中断方式或DMA方式,避免长时间卡住主循环。
4.2 用逻辑分析仪看DHT11时序
如果你把DHT11接到ST-Link或者任意一款逻辑分析仪上,抓到的波形会非常直观。图里你会看到一段很长的低电平,这是起始信号;随后是传感器返回的响应,再往后是一连串窄脉冲,每一个脉冲代表一个数据位。
看波形是排查DHT11时序问题最有效的手段。比如我一直读不到数据,用逻辑分析仪一看就明白了:起始信号被中断函数打断了,低电平只维持了几百微秒,根本没有达到18毫秒,传感器自然不响应。所以我在代码里读取DHT11时会临时关闭可能会打断延时的中断,或者把读取过程放在临界区里执行。
4.3 串口常见问题
串口打印出现乱码时,先确认两边的波特率、数据位、停止位是否一致,再看共地是否做好。USB转TTL模块和开发板之间如果参考地不一致,通信线上的电压差就会导致乱码,甚至损坏引脚。
还有一个容易忽略的是电源质量。如果你的开发板用USB供电,同时板子上还接了舵机、电机、继电器,启动瞬间电流会拉低VDD,MCU的ADC和串口电平都会受影响。我曾经遇到DHT11偶尔读到温度85℃的异常值,排查到最后发现是电源纹波干扰了传感器供电,加了电容滤波之后问题消失。
5. 常见问题与排查技巧实录
5.1 程序下载不进去怎么办
STM32F1最常见的问题之一就是“芯片连接不上”。出现这个情况先别急着怀疑芯片坏了,按照下面顺序排查。
第一步检查BOOT0引脚。大部分开发板BOOT0默认接地,这没问题,但如果BOOT0被拉高,芯片会进入系统存储器Bootloader模式,不再执行Flash里的用户程序,SWD和JTAG下载会被跳过或切入不同状态。把BOOT0跳线恢复为0,重新上电再试。
第二步检查SWD引脚是否被复用。前面说过,PA13、PA14默认是SWDIO和SWCLK,但如果你在代码里把它们配置成GPIO,而且在引脚初始化之前就把下载口占用了,那么连接工具会失败。解决办法是用“connect under reset”模式,或者按住复位键再点击下载,让芯片还没开始跑用户程序时就建立连接。
第三步检查供电和复位电路。STM32F1的NRST引脚如果被电容拉低,或者外部干扰导致复位不断,也会出现连接不稳定。按一下复位键看芯片是否异常复位,必要时在复位引脚上加一个10nF左右的电容滤除干扰。
5.2 DHT11一直读不到数据
这类问题出现频率最高,原因也很集中。
先检查硬件电路。DHT11模块一般是三根线:VCC、DATA、GND。DATA引脚需要一个上拉电阻,780欧姆到4.7K欧姆都常见,模块板载一般都有。但如果你自己用光板传感器焊接,一定要在数据线上外接一个4.7K欧姆左右的上拉电阻,否则高电平上不去,时序判断全部乱套。
再看供电。DHT11工作电压范围标称3.3V到5.5V,直接用3.3V供电是可行的,但要注意有些劣质模块内部稳压和电平转换电路不完善,3.3V下输出高电平只有2.8V左右,STM32F1引脚还能识别,但稳定性会差一些,走线较长时尤为明显。我自己的经验是,如果条件允许直接用5V给DHT11模块供电,数据线再串一个1K欧姆电阻接到STM32F1引脚,这样兼容性和稳定性都更好。
代码方面,检查延时是否准确。如果你的微秒延时函数慢了十倍或者快了三倍,读取高电平宽度的判断就会出错误。最典型的表现是读出来的数据全是0xFF或0x00,偶尔能有几次正确数据。这种情况直接调整延时系数,做到“延时后读引脚”时,恰好落在数据位的高电平中间位置。
另外,DHT11相邻两次读取之间的间隔必须大于1秒。如果你在主循环里不停调用读取函数,会发现第二次读取大概率失败。所以代码里要加一个简单的状态机或者时间锁存,例如记录上次读取的系统节拍,间隔达到1000毫秒以上才允许下一次读取。
5.3 数据校验失败如何处理
校验失败会返回一个错误码,代码里一般会丢弃这次数据,等待下一轮重新读取。不要试图在数据校验失败后强行使用已读到的部分数据,因为极有可能是时序错位,导致每一位的0和1都判断反了,此时数据完全是垃圾值。
想要减少校验失败,可以尝试以下几件事。第一,把IO翻转和延时函数封装成不会被中断打乱的临界区,可以用__disable_irq()和__enable_irq()保护整个读取过程。第二,给DHT11数据引脚加一个10nF到100nF的去耦电容,放在传感器引脚附近,能明显改善噪声环境下的信号质量问题。第三,检查地线连接是否可靠,特别是传感器和STM32F1之间距离偏长时,建议使用屏蔽线或绞合线,地线和数据线走在一起。
5.4 主循环跑飞和死机问题
STM32F1本身稳定性很好,程序死机大多来自自己的代码,比如数组越界、野指针、硬件卡死,或者看门狗没有喂狗。
典型场景是系统里接入DHT11后,同一个主循环里还跑着其他任务。如果其他任务里有阻塞延时或者等待事件,HAL_Delay内部用的SysTick会被占用,导致DHT11读取过程中的延时不准。解决办法是独立定时器或者使用非阻塞的软件延时,或者干脆把传感器读取放到定时器中断里,但是要注意中断服务程序不能执行过长的阻塞操作。
另一个常见问题是看门狗配置。如果开启了独立看门狗IWDG或窗口看门狗WWDG,主循环必须周期性喂狗。而DHT11读取一次往往耗时几十毫秒,如果喂狗时机不合理,很可能在传感器读取期间触发看门狗复位。我的做法是看门狗喂狗间隔放到50毫秒以上,并且把DHT11读取放到一个独立函数中,尽量压缩耗时到10毫秒以内。
5.5 提高系统稳定性的几条经验
这里分享几条我在实际产品里用到的经验。
第一,STM32F1的ADC引脚如果悬空,会采样到一个不定值,干扰程序判断。不需要使用的ADC引脚建议配置为模拟输入模式,如果引脚悬空又配置成浮空输入,芯片内部的寄生电压可能会导致误触发外部中断。
第二,所有未使用的GPIO引脚不要写成浮空输入,建议配置为模拟输入或者带上拉输入,这样可以避免引脚悬空引入多余功耗和噪声。
第三,DHT11这类单总线传感器的引线越短越好,超过20厘米后信号完整性会下降,极端情况下需要加缓冲器或者换用I2C接口的温湿度传感器,比如SHT30、AHT20。我之前一次项目里,DHT11引线布了40厘米,结果无论怎么调延时和上拉,读到的湿度值总是每隔几分钟跳一次,最后换成AHT20,问题彻底消失。
6. 从DHT11扩展到完整项目
6.1 把温湿度数据发布到云端或本地显示
当DHT11能稳定读取,你就已经跑通了传感器采集的完整流程。下一步可以做的很多:接一块0.96寸OLED显示温湿度;通过ESP8266或ESP32把数据发到MQTT服务器;在本地LCD屏上绘制温湿度趋势曲线;甚至加一个继电器,根据湿度值自动控制加湿器或排风扇。
这些扩展的核心逻辑都不复杂,无非是把DHT11_Read_Data读取到的变量,赋值给通信模块的数据缓冲区,然后按协议发送。很多项目卡住的点不是通信协议,而是传感器数据本身不稳定,导致发出去的数据忽高忽低。所以在做上位机或云平台之前,我建议先把采集端做成“连续采样5次,取中间值或平均值”的滤波逻辑。
6.2 STM32F1系列还能做什么
聊回STM32F1本身。这颗芯片虽然性能不拔尖,但外设组合非常实用。CAN总线让它成为汽车电子和工业设备的老常客;USB Device功能可以做键鼠、HID设备;多个定时器可以输出多路PWM,驱动机械臂或云台;ADC加DMA又能承担高速数据采集任务。
很多入门者会问,学STM32F1会不会过时。我的看法是,如果你把F1彻底搞懂了,F4、H7甚至其他厂商的Cortex-M芯片上手都不会太难。MCU开发的核心是理解外设、中断、时钟、DMA、通信协议,这套逻辑在F1上可以学得非常扎实。换个芯片,无非是寄存器地址变了、库函数封装变了,基本原理完全一致。
6.3 建议的学习路径
如果你是以学习为目的,我建议这样走:先用CubeMX生成工程,点灯和按键实验练熟;然后做串口收发,把printf重定向搞清楚;接下来就是本文的DHT11实战,学会单总线协议和时序驱动;再往上是I2C和SPI接口的传感器,以及PWM电机控制、ADC采集、定时器中断。等这些都会了,再回头看看STM32F1的参考手册,你会发现之前很多“背下来”的配置,自己已经能推导出来。
如果是做产品,选型时要把Flash和RAM余量留足,尤其是要跑RTOS或通信协议栈时,STM32F103C8T6的64KB Flash偶尔会显得紧张。这时候可以换STM32F103RCT6,256KB Flash和48KB RAM,引脚数量和资源都宽裕很多,但价格差异很小。不要为了省几块钱选容量正好的芯片,后期加功能时你会发现痛苦加倍。
我在实际项目里做产品迭代时,最深刻的感受是:STM32F1的设计虽然老,但设计文档极其完善,勘误手册、应用笔记、参考例程都是公开的,这比某些芯片厂商“资料缺失只能自己猜”的体验好太多了。就算遇到问题,按照手册排查,基本都能定位到原因。
最后再分享一个实用小技巧,DHT11读取成功后如果连续出现校验失败,先别急着改代码,看看传感器旁边的电源是不是有继电器或电机启停。电磁干扰对单总线协议的影响非常大,稍微处理一下供电和布线,问题往往就不治自愈了。如果你也想在板子上预留温湿度功能,建议优先考虑把DHT11换成I2C接口的AHT20,但如果你想彻底理解时序协议、学会单总线调试,DHT11依然是绝佳的训练场。