如果你问一个做了三五年嵌入式的朋友:“你第一颗正经入门的32位单片机是什么?”十有八九会得到同一个答案:STM32F1,更具体点,很可能是那块蓝色的小板子——F103C8T6。今天这篇不聊虚的,就拿STM32F1系列配合最常见的DHT11温湿度传感器,从选型、环境、时序原理、手写驱动到踩坑实录,完整走一遍。整个过程都是以我实际做项目、带新人的经验为底子写的,适合刚学完51、想跳32位MCU的同学,也适合已经买了开发板但卡在“能点灯却读不到传感器”阶段的弟兄们。
网上关于DHT11的例程其实多如牛毛,但大多数只有代码没有为什么,出了问题只能干瞪眼。这篇文章我把涉及的关键细节全部展开:单总线时序到底长什么样、为什么读数据之前要把IO切回输入、微秒延时为什么不能用空循环凑合,以及实测中让人崩溃的首读失败和长线干扰。换句话说,你把这篇文章从头到尾跟一遍,能跑通不说,以后碰到类似时序型传感器(DS18B20、DHT22)也能自己排查问题。
1. 为什么过了这么多年,STM32F1依然是入门32位MCU的首选
1.1 F1系列的江湖地位和它解决的核心问题
先给完全没接触过的朋友补个背景:STM32F1系列是意法半导体在Arm Cortex-M3内核上推出的主力产品线,最经典的就是F103,主频72MHz,虽然放到今天看指标平平,但它的意义在于把“32位MCU的门槛”拉到了白菜价。一个F103C8T6芯片,几块钱到十几块钱,一块完整的蓝色“蓝丸”最小系统板也就十几块,再加上十几块钱的ST-Link下载器,总共不到一杯奶茶钱,就能拥有一套完整的32位开发环境。
这在嵌入式圈子里有多重要?回想一下51单片机时代,8位机学完之后,很多人想接触RTOS、复杂外设、DMA这些概念,就会发现在8位平台上这些事做起来非常别扭。STM32F1的出现,相当于给学习者准备了一把标准钥匙:Cortex-M3内核统一了中断控制器(NVIC)、SysTick定时器、内存映射这些底层概念,你只要在F1上把这些玩明白,后面换F4、G0、G4甚至H7,核心思路完全打通,无非是外设寄存器和库函数的差异。
1.2 围绕DHT11这个经典练习,F103的资源到底够不够
说到DHT11温湿度传感器,它几乎和STM32F1是同一个年代的“黄金搭档”。DHT11用的是单总线协议,一条数据线既要发命令又要传数据,靠的是精确到微秒级的时序。这种传感器对于单片机有三个要求:GPIO能快速切换输入输出模式、延时能达到微秒级精度、引脚能正确处理外部上拉电平。这三个要求STM32F1全部轻松满足,72MHz的主频意味着每条指令大约13.9纳秒,微秒级操作对于它来说游刃有余。
而且用STM32F1来调DHT11,还有一个隐形的好处:F1的外设模型比较简单,GPIO就是纯粹的GPIO,笔直的寄存器操作路径让你一眼能看穿数据流动。相比之下,H7这种带D-Cache的高端内核,做位级协议反而会因为缓存一致性问题多出很多麻烦。所以F1加DHT11这个组合,并不是因为“这两样都老”,而是因为它们在复杂度上刚刚好匹配,是练时序协议理解能力的完美起步项目。同时还能配合OLED屏、串口、定时器,组成一个“传感器采集+显示+上传”的完整体验闭环,这也是我特别推荐新手拿它当第一个实战项目的原因。
2. 搭好开发环境三件套:板子、IDE和下载器的选型逻辑
2.1 开发板怎么选:蓝丸、Nucleo还是国内教学板
经常有新手私信我问:“买哪块板子好?”我的答案通常是:看你想怎么学。如果打算跟着视频课一套流程走下来,那直接买课程配套的教学板最省心,比如正点原子的mini板或精英板、野火的指南者,这些板子资料极其详尽,每个外设都有配套例程,DHT11这种小传感器更是随手一搜就有对应工程。缺点嘛,就是板子体积大、价格稍贵,外围电路多,有些新手反而会被板载资源搞得眼花缭乱。
如果喜欢自己折腾、追求极简,那蓝丸F103C8T6绝对是最佳选择。整个板子就一颗芯片、一个USB口、两块晶振和若干排针,没有多余的板载外设。DHT11实验只需要把传感器模块插到面包板上,再用三根杜邦线接到PB1、3.3V和GND,干干净净。官方还有个Nucleo-F103RB,自带ST-Link调试器,推免驱动,适合想规范化学习的人,不过价格是国产蓝丸的几倍。三个选择我统一做了个对比:
| 开发板 | 价格 | 调试器 | 配套资料 | 适合人群 |
|---|---|---|---|---|
| 蓝丸最小系统板 | 十几元 | 需另购ST-Link | 通用例程多,需自己接线 | 喜欢动手、预算有限 |
| 正点原子/野火教学板 | 百元级 | 板载或另购 | 从入门到进阶非常系统 | 跟视频课走的新手 |
| Nucleo-F103RB | 百元级 | 板载ST-Link | ST官方例程 | 偏爱官方生态的人 |
2.2 Keil MDK还是STM32CubeIDE,标准库还是HAL库
IDE方面,国内老教程的主力是Keil MDK,你随手在网上搜“STM32F1 DHT11例程”,十个里有九个是Keil工程。Keil的界面确实老气,但胜在稳定、占用资源小,调试起来很顺手。STM32CubeIDE是ST官方的免费IDE,基于Eclipse,配合CubeMX图形化配置工具能够自动生成初始化代码,新用户上手很友好。但这里有个实际问题:老例程的Keil工程文件在CubeIDE里没法直接打开,你得自己重新建工程、移植代码,对于只想快速把DHT11跑通的新手来说,迁移成本偏高。
库的选择比IDE更关键。F1有两套主要固件库:标准外设库(StdPeriph)和HAL库。标准库已经在官方停更,但它在F1项目中的使用量依然庞大,API粒度细,比如读一个引脚电平就是GPIO_ReadInputDataBit一次调用到位。HAL库是ST当前的亲儿子,配合CubeMX用起来确实省事,但它封装层次多,切换GPIO模式要调一堆结构体和函数,微秒级时序操作时代码噪音非常大,还容易因为函数调用开销影响时序节奏。我的建议非常直接:如果你是在F1上做DHT11这种位级时序协议,跟着老教程用标准库最合理;如果已经习惯了HAL,那读取DHT11的关键代码段也请直接用寄存器操作,别在中断里反复调HAL库的大函数。
2.3 下载器选择与最容易忽略的连线细节
下载器现在的主流是ST-Link V2,淘宝十几块包邮,SWD模式只需要接四根线:3.3V、SWDIO、SWCLK、GND。CMSIS-DAP协议的下载器(很多自制调试器用的就是它)也能在Keil里用,和ST-Link操作差不多。串口ISP下载是更老的方式,要把BOOT0跳帽拉高再复位,下载慢还得手动切换,只适合作应急方案。
连线细节是最容易翻车的地方。我见过很多新手折腾半小时下载不了程序,最后发现是下载器没跟板子共地,SWDIO和SWCLK两根信号线是参考GND电平的,不共地,时序信号根本没法定。实际操作时最好养成习惯:先把下载器GND和板子GND用杜邦线连好,再接SWDIO和SWCLK。另外,如果供电靠ST-Link输出3.3V,点亮LED和运行DHT11这种微功耗负载完全没问题,但别指望它给大功率外设供电,老老实实用USB给板子供电更稳。
3. DHT11单总线时序拆解:想写出稳定驱动必须先看懂这张图
3.1 单总线协议的特点:省了引脚,费了耐心
DHT11和MCU之间只有一根数据线,没有SCL时钟线,也没有MOSI/MISO之分,所有命令和数据都靠这根线上的电平变化和时间长度来传递。这就是单总线协议,名字很直白。它和I2C这类有时钟线的同步协议最大的区别在于:I2C的时序由主机控制的时钟决定,从机只要跟着时钟走就行;单总线则全靠时间窗口来区分逻辑0和逻辑1,任何一段时序的偏差都可能让整包数据解错。
DHT11的电气结构有个关键点:它的数据引脚是开漏输出,自己不能主动输出高电平,必须靠外部上拉电阻把数据线拉高。这就是为什么裸传感器比模块难调——模块上已经焊好了上拉电阻和滤波电容,而裸传感器只有三根脚,你得上拉处理。数据线在空闲状态下被上拉电阻维持在高电平,发送起始信号、传输数据时再通过拉低来产生脉冲。理解了这一点,后面看时序图就顺了。
3.2 起始信号、响应信号和40位数据位的完整过程
一次完整的DHT11通信流程可以拆成五步:
- 主机拉低数据线,持续至少18毫秒,这是DHT11规定的启动信号,时间太短传感器不响应;
- 主机拉高数据线,维持20到40微秒,然后释放总线,同时把引脚从输出模式切成输入模式;
- DHT11检测到启动信号后,先拉低总线80微秒作为响应,再拉高80微秒,告诉主机“我准备好了”;
- DHT11开始逐位发送40位数据,每一位都由一段约50微秒的低电平开始,随后根据数据是高电平26到28微秒(逻辑0)还是高电平70微秒(逻辑1)来区分;
- 数据发送完毕,DHT11释放总线,数据线回到高电平空闲状态。
这40位数据的排布是固定的:湿度整数部分8位、湿度小数部分8位、温度整数部分8位、温度小数部分8位、校验和8位。前四个字节的数值相加,取低8位,应该正好等于校验和字节。DHT11的湿度小数位和温度小数位在实际器件中通常恒为0,但读回来的流程是完整的,别自己省掉这两字节的解析。
3.3 采样窗口的选择:为什么大家都在40微秒处读引脚
理解了位时序之后,最关键的问题就是:怎么区分一个位是0还是1?我们发现每一位都是从50微秒低电平开始,但高电平持续时长不同。如果我从低电平结束那一刻起,延迟大约40微秒再去读数据线:对于逻辑0,它的高电平只维持26到28微秒,40微秒时早就变回低电平了,读回0;对于逻辑1,它的高电平依然维持在高状态,读回1。这个40微秒的采样点就落在了0和1的判定区间正中间,留足了余量。
很多初版代码用“检测到高电平后计时,看高电平持续了多久再判断0/1”的思路,理论上也对,但实现起来要开一个高精度计时器,代码复杂不少。40微秒采样法简单直接,只要你的微秒延时精度够,几十位数据读下来基本不会出错。这也是我推荐新手先按采样窗口思路写驱动的原因——代码量小,逻辑清晰,出问题时也方便用逻辑分析仪定位。
4. 手写DHT11驱动:GPIO切换、微秒延时和校验和
4.1 引脚选择与GPIO初始化
先定引脚。我这里用PB1做示范,原因很简单:它没有被默认占用,和开发板上的其他外设冲突概率小,引出也方便。在选择引脚时记得避开PB2(它和BOOT1复用)、PA13/PA14(SWD调试口),也不要用已经被板载LED、按键占用的引脚,否则调试时容易互相干扰。
GPIO初始化分两个函数来写,一个配成推挽输出,一个配成浮空输入。因为DHT11通信前主机要发起始信号,这时引脚必须是输出模式;等起始信号发完,要读DHT11的响应和数据,又必须切成输入模式。F1的GPIO模式切换速度是纳秒级的,对微秒级时序几乎没有影响,放心切:
#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_1 #define DHT11_RCC_GPIO RCC_APB2Periph_GPIOB void DHT11_GPIO_Init_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_RCC_GPIO, ENABLE); GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); } void DHT11_GPIO_Init_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); }这里要说明一个细节:输入模式用的是浮空输入,因为DHT11模块上已经有上拉电阻,外部已经把电平拉高了;如果你用的是无上拉的裸传感器,这里改成带上拉输入模式,或者自己在外部加一个4.7到10千欧的上拉电阻到3.3V,否则引脚电平悬空,读回来的数据必然出错。
4.2 微秒延时:SysTick定时器的正统做法
DHT11时序全是微秒级,代码里的delay_us必须足够精确。我见过太多人在这一步图省事,写一个for循环空转当作延时函数,结果编译优化等级一变,整个时序彻底乱掉。正确做法是利用Cortex-M3内核自带的SysTick定时器。
SysTick就是一个24位递减计数器,主频72MHz时每计数72次正好1微秒。我们配置它每秒触发一次中断,在中断里递减一个全局计数器,delay_us就变成查询这个计数器:
static volatile uint32_t sysTickCounter = 0; void SysTick_Handler(void) { if (sysTickCounter > 0) { sysTickCounter--; } } void delay_init(void) { if (SysTick_Config(72)) { while (1); } } void delay_us(uint32_t us) { sysTickCounter = us; while (sysTickCounter > 0); } void delay_ms(uint32_t ms) { while (ms--) { delay_us(1000); } }SysTick_Config(72)的意思是把重装载值设为72,72MHz时钟除以72得到1MHz,也就是1微秒中断一次。这个方案的好处是不依赖任何外设定时器,每个Cortex-M3内核都有SysTick,代码移植到其他F1型号甚至其他厂家的M3芯片上都能直接用。
4.3 读一个字节的完整逻辑与超时保护
有了GPIO切换和延时函数,就可以写核心读取代码了。读一个字节的循环逻辑如下:等待每位的低电平结束,延时40微秒到采样点,读引脚电平判断0或1,最后等待该位的高电平结束再进入下一位,避免把上一位的尾巴误判成下一位的低电平。
uint8_t DHT11_ReadByte(void) { uint8_t i, data = 0; for (i = 0; i < 8; i++) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_RESET); delay_us(40); if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_SET) { data = (data << 1) | 0x01; } else { data = (data << 1); } while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_SET); } return data; }注意这里的两个while循环,理论上是等DHT11把电平拉高或拉低,但万一传感器没接好、断线了,程序会卡在这些循环里出不来。我的建议是给等待加上超时保护,比如用一个变量递减,减到0就返回一个异常值,避免整个系统假死。特别是在现场调试阶段,这个保护能大大节省你排查问题的时间。
4.4 完整读取函数:发起通信、读取五字节、校验
读取一次完整数据的函数要完成四件事:切输出模式、拉低20毫秒发起起始信号、拉高30微秒后切输入模式、等待DHT11响应、连续读5个字节、最后把引脚恢复成输出高电平。恢复成输出高这一步很多人会漏掉,其实很有必要:一方面为下一次通信做准备,另一方面避免引脚长时间处于浮空状态引入干扰。
uint8_t DHT11_ReadData(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec, uint8_t *checksum) { uint8_t buf[5]; uint8_t i; uint16_t timeout; DHT11_GPIO_Init_Output(); GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(20000); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); DHT11_GPIO_Init_Input(); timeout = 1000; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_SET) { if (--timeout == 0) return 1; } timeout = 1000; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_RESET) { if (--timeout == 0) return 1; } timeout = 1000; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_SET) { if (--timeout == 0) return 1; } for (i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } DHT11_GPIO_Init_Output(); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) == buf[4]) { *humi_int = buf[0]; *humi_dec = buf[1]; *temp_int = buf[2]; *temp_dec = buf[3]; *checksum = buf[4]; return 0; } return 2; }响应等待那段我写了三段带超时的循环,分别对应:等待总线被DHT11拉低、等待低电平结束、等待高电平结束。这样的写法即使哪一步时序没对上,程序也会在几毫秒内退出,而不是永远卡在while里。这也是“能跑的例程”和“好用的例程”之间最大的区别。
4.5 主函数:正确接入串口打印
把上面这些组合起来,主程序就非常简单了。这里串口打印用printf重定向到USART1,波特率9600或者115200都行。注意DHT11的官方手册明确规定,两次读取间隔不能小于1秒,因为传感器本身的采样周期就是1秒左右,读太快只会浪费CPU而且数据不更新。我用2秒间隔:
int main(void) { uint8_t humi_int, humi_dec, temp_int, temp_dec, checksum; delay_init(); USART1_Init(115200); delay_ms(1000); while (1) { if (DHT11_ReadData(&humi_int, &humi_dec, &temp_int, &temp_dec, &checksum) == 0) { printf("Humi: %d.%d%% Temp: %d.%d C\r\n", humi_int, humi_dec, temp_int, temp_dec); } else { printf("Read DHT11 failed\r\n"); } delay_ms(2000); } }printf重定向到串口的实现方式很简单,重写fputc函数就行:
int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }5. 实测中真正折磨人的几个坑:首读失败、长线和优化等级
5.1 首次上电读取失败:不是驱动写错了
我调试DHT11时遇到最多的现象是:程序刚烧录完,第一次读取就失败,串口打印Read DHT11 failed,但过一会儿自动恢复或者复位一次就好了。很多人以为这是驱动代码有bug,拼了命调时序参数,结果越调越乱。真实原因是DHT11上电后需要至少1秒的稳定时间,在这个窗口期内它自己都还没准备好,自然没法响应主机请求。
对策很简单:main函数里初始化外设之后,先延时1秒再进主循环。另一个更通用的思路是设计重试机制,第一次读取失败没关系,循环里隔1秒再试一次,连续读几次成功后就正常了。我实际测试过,加了启动延时之后,首次读取成功率几乎百分之百。这里忍不住插一句:遇到“一次失败但复位就好”的问题,先别急着怀疑代码逻辑,去查硬件上电时序和稳定时间,往往比抠代码快得多。
5.2 编译优化等级:篡改空循环时序的隐形杀手
这个坑极其隐蔽,症状也很迷惑人:同一个工程,在开发板上能跑,换了台电脑、改了优化等级之后,数据突然全是校验错误。问题出在那个被当成延时用的for循环。Keil默认的-O0优化不会动空循环,但一旦开到-O2甚至-O3,编译器会把这个空循环优化成一条跳转指令或者直接删掉,延时时间变成原来的几十分之一甚至归零。DHT11对微秒级时序敏感,延时一错,读回来的40位数据基本全是0或者全乱。
解决办法有两个,从根上杜绝:一是用SysTick或定时器的延时函数替代空循环,这个我前面代码里已经做了,不受编译优化影响;二是如果你确实要用循环延时,必须把循环变量声明为volatile,并且用汇编级别的空指令卡时间。但说实话,嵌入式调试里这种问题没必要自己硬扛,上SysTick才是正路。如果你已经写了空循环延时,又碰到“优化一开就挂”的情况,基本可以直接锁定是这个原因。
5.3 长引线和上拉电阻:单总线经不起折腾
DHT11特别怕线长。单总线协议本身是为短距离通信设计的,在我的实际测试中,用杜邦线连接DHT11模块和STM32F1,线长超过20到30厘米之后,校验错误就开始零星出现;超过半米,基本能稳定复现读取失败。原因也不难理解:数据线上的分布电容变大,脉冲沿变缓,MCU在40微秒采样点读到的电平可能已经偏离了真实状态。
如果实在迫不得已要用长线,有几个实操技巧可以缓解:数据线尽量短,宁可绕远也不要贴着电源线走;在数据线对地并一个10纳法的小电容,可以滤掉部分高频干扰,但注意电容太大反而会让脉冲沿更缓,适得其反;上拉电阻也可以适当减小,比如用2.2千欧,增强驱动能力。如果这些做完了还是不稳定,那就老老实实换方案,要么换成更耐长线的I2C接口温湿度传感器(比如SHT30),要么加一个缓冲芯片。
5.4 裸传感器与模块的区别:上拉电阻不能没有
很多新手第一次用DHT11,买的是那种五毛钱一颗的裸传感器,往面包板上一插,三根线一接,就指望它能跟模块一样正常工作。结果当然是失败。区别就在于模块上焊好了上拉电阻和滤波电容,裸传感器全靠你自己补。DHT11数据引脚是开漏结构,必须靠外部上拉电阻才能把空闲状态维持在高电平,这一点在原理上是硬要求,不是可选项。
如果你用裸传感器,正确的接法是:VCC接3.3V,GND接地,数据脚接MCU的GPIO,同时在数据脚和VCC之间焊一个4.7到10千欧的上拉电阻,最好再在VCC和GND之间并一个0.1微法的陶瓷电容。还有一点要注意,DHT11的供电范围是3.3到5.5V,但STM32F1的GPIO引脚虽然大部分是5V容忍的,为了简单稳妥还是统一用3.3V供电比较好,这样传感器输出的高电平就是3.3V,和MCU电平完美匹配。
6. 从采集到展示:OLED显示和串口上传的扩展思路
6.1 I2C OLED显示温湿度
DHT11读到的数据如果只在串口助手里看,总感觉少了点什么。接一块0.96寸的I2C OLED屏(SSD1306驱动芯片),整个项目瞬间就变成一个独立的桌面温湿度计。OLED模块只有四根线:VCC、GND、SCL、SDA,用杜邦线接到F103的PB6(SCL)和PB7(SDA)就能开始干活。
我在F1上用OLED一般建议直接软件模拟I2C,两个GPIO改改翻转逻辑就行,不用调硬件I2C那堆烦人的超时和中断配置。关于F1硬件I2C的传言很多,实际用下来并不是不能用,但软件I2C对新手来说调试更友好,出问题也好排查。
char str[20]; sprintf(str, "Humi:%d.%d%%", humi_int, humi_dec); SSD1306_ShowString(0, 0, str); sprintf(str, "Temp:%d.%dC", temp_int, temp_dec); SSD1306_ShowString(0, 2, str);需要注意OLED屏内部上电初始化需要一小段时间,主程序里给SSD1306_Init()之后最好也加个100毫秒左右的延时,再开始刷屏,否则第一屏可能显示乱码或者空白。
6.2 用定时器中断替代阻塞式延时
前面的主循环代码用了delay_ms(2000),简单是简单,但有个问题:LED闪烁、其他传感器采集这类并发任务全部被阻塞了,属于“点灯思维”阶段的写法。稍微进阶一点,就会想到用定时器中断来生成时间基准。比如用TIM2配置1秒中断,在中断里置一个标志位,主循环检测到标志位才去读DHT11,其余时间可以处理别的事情。
volatile uint8_t one_second_flag = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); one_second_flag = 1; } } while (1) { if (one_second_flag) { one_second_flag = 0; DHT11_ReadData(&humi_int, &humi_dec, &temp_int, &temp_dec, &checksum); } // 其他任务... }这个转变虽然只是一个小技巧,但思路价值很大:从“死等”到“事件驱动”,是从单片机小白走向工程化开发的必经一步。
6.3 串口上传到上位机和小型IoT
串口打印已经能解决调试问题,但如果想把DHT11数据变成一个长期的记录系统,可以接个ESP8266模块,用AT指令通过串口把温湿度数据发到局域网里的MQTT服务器,再接Home Assistant之类的平台。STM32F1的串口外设足够可靠,唯一的建议是串口通信务必加简单的协议头校验,别裸发裸收,否则数据错位排查起来比代码本身还费劲。
我更推荐的进阶方向是用Python写一个简单的串口上位机,pyserial读取串口数据、matplotlib实时画温湿度曲线。这样你不仅练了MCU端的数据采集,还顺带接触了上位机数据处理,整个系统的完整性一下就上来了。DHT11的精度作为环境监测够用,但你要是做科研级别的测量,趁早换SHT30或SHT40这类数字传感器。
说了这么多,最后分享一个我调试时序外设时最受益的小习惯:花几十块钱买一个逻辑分析仪,不用多高级,8通道、24MHz采样率就够用。DHT11通信无法用示波器直观观察,但逻辑分析仪可以完整抓下起始信号、响应信号和40位数据的波形,一眼就能看出哪一位的时序偏了,哪些地方需要调整延时。我当年调DS18B20时被时序折磨了整整两天,最后用逻辑分析仪一抓波形,问题瞬间现出原形。现在但凡碰到类似单总线、一线协议的传感器,第一反应永远是先抓波形,再改代码。这套思路,相信对你也一样好用。