
简介这是一套面向STM32F103单片机学习者的实战项目包围绕DHT22温湿度传感器和BH1750光照强度传感器展开演示了单总线与I2C两种通信协议的处理流程最终把实时数据展示在液晶显示屏上对于想理解传感器驱动、准备课程设计或环境监测类小项目的开发者具有直接参考价值。压缩包共150个文件整体约3.08MB主要包含C语言源文件与头文件、Keil工程配置文件、链接脚本和编译生成的AXF、HEX烧录文件另有TXT说明和HTM文档辅助阅读源码、工程与固件齐全可对应学习从初始化到上板运行的完整链路。已有8865人学习下载热度较高。通过研读工程读者能掌握GPIO模拟时序、定时器脉冲宽度解析、I2C读写光强芯片、LCD绘图显示等关键操作作者提供的接线说明还能帮助排查常见硬件错误适合边看边练为后续接入更多传感器打下基础。1. STM32F103驱动DHT22和BH1750这套环境监测组合值得直接抄作业做嵌入式项目最烦的不是写业务逻辑而是传感器数据读不准。DHT22温湿度传感器和BH1750光照强度传感器是低成本环境监测里最常见的搭配前者用单总线协议后者走I2C正好覆盖了两种最典型的低速通信方式。这套组合能解决小型气象站、智能家居中控、大棚监测这类场景里的核心数据采集问题而且STM32F103的普通GPIO就能同时搞定两个协议不必额外买硬件I2C模块。我拆过不少用这两颗传感器的项目发现大部分翻车都发生在时序细节上比如DHT22的起始信号宽度、BH1750的测量模式选择这些在数据手册里写得很含糊实际调试时全靠示波器和反复试错。这篇文章把从选型、接线、驱动到排错的完整链路拉通给出可以直接编译运行的代码框架你会很清楚每一行在干什么以及改哪些参数能适配自己的板子。2. 硬件选型与接线为什么是DHT22而不是DHT11光照用BH1750的I2C优势2.1 传感器选型对比DHT22的精度边界与BH1750的量程设计很多入门教程首选DHT11因为它便宜、代码网上到处都是。但DHT11的湿度精度只有±5%RH温度精度±2℃而且采样周期必须大于1秒这对需要做趋势判断的场合完全不够用。DHT22又称AM2302的湿度精度做到±2%RH温度精度±0.5℃分辨率是0.1℃采样周期可以缩短到2秒一次。价格大概贵一倍换来的是数据可用性大幅提升。我做环境监测项目时DHT11的数据波动大到没法直接画曲线必须做滑动平均才能看DHT22的读数就比较平稳原始数据稍微滤波就能用。BH1750这颗光照传感器有个很大的优点内部集成ADC和光敏二极管输出直接是勒克斯lux值不需要自己做线性校准。它的量程分三档通过指令寄存器切换测量模式最低分辨率可以做到0.5 lux最高量程可达65535 lux。相比之下用光敏电阻分压再查表换算的方案虽然更便宜但每个器件的一致性差批量生产时每个都要单独标定很不划算。BH1750还有一个特性是光谱响应接近人眼这个特性对智能照明控制特别重要因为我们要的是“人感觉有多亮”而不是红外光有多强。从通信接口角度选型也值得说。DHT22的单总线协议对时序要求苛刻但GPIO模拟完全可行BH1750的I2C接口电平兼容3.3V和5VSTM32F103的PB6、PB7正好是硬件I2C1引脚不过很多人宁愿用普通GPIO模拟I2C后面我会解释为什么。这两颗传感器组合在一起数字接口都不需要ADC外设所以哪怕用最便宜的STM32F103C6T6也能跑得动。2.2 接线表与原理图理解上拉电阻和电源去耦怎么处理接线本身并不复杂但有两个容易忽略的点上拉电阻和电源去耦。DHT22的数据线是开漏输出必须在数据线和VCC之间接一个4.7kΩ到10kΩ的上拉电阻否则高电平根本拉不起来。BH1750的I2C数据线SDA和时钟线SCL同样需要上拉一般接4.7kΩ。如果这两颗传感器同时接到同一个3.3V电源轨建议在它们的VCC和GND之间各加一个100nF的陶瓷电容位置尽量靠近传感器引脚用来滤除电机、继电器这类负载带来的电源毛刺。下面这张接线表是我常用的一套分配用的是STM32F103C8T6最小系统板PB6和PB7留给硬件I2C的备用方案但驱动代码里用的是GPIO模拟所以随便选两个推挽输出引脚就行传感器引脚STM32F103引脚说明DHT22 VCC3.3V供电范围3.3V~5V但5V供电时输出高电平可能超过MCU耐压建议统一3.3VDHT22 GNDGND共地DHT22 DATAPA0配置为开漏输出或推挽输出外部上拉BH1750 VCC3.3V供电2.4V~3.6VBH1750 GNDGND共地BH1750 SCLPB10GPIO模拟时钟线推挽输出BH1750 SDAPB11GPIO模拟数据线开漏输出外部上拉这里特别提一下DHT22的数据引脚配置。很多人的代码把DHT22的DATA引脚设置成推挽输出这个能工作但存在风险如果传感器内部上拉到VCC而你输出低电平外部电路倒灌电流可能超出GPIO的灌电流能力。我一般用开漏输出模式在外部加上拉电阻这样读数据时把引脚切换成输入模式逻辑上跟标准单总线协议完全一致而且更安全。STM32的GPIO配置成开漏输出时需要把输出数据寄存器置1否则引脚会一直保持低电平。2.3 硬件清单与注意事项除了传感器和单片机还需要准备面包板、杜邦线若干、一个10kΩ电位器用来调试ADC时可选的但这里不一定用得上、以及一个逻辑分析仪或示波器。逻辑分析仪是强烈建议的价格不高但能直接看到DHT22的脉冲宽度和I2C波形排查时序问题比靠猜快十倍。我最初调DHT22时就是靠逻辑分析仪发现起始信号宽度差了10微秒导致传感器不响应。接线时还要注意总线长度。DHT22的单总线协议规定线缆长度不要超过20米但实际使用中超过2米就可能因为寄生电容导致边沿变缓。BH1750的I2C总线在100kHz标准模式下总电容最好控制在400pF以内所以传感器到单片机的连线越短越好。如果项目需要远距离布置传感器建议改用RS485透传模块而不是硬拉长杜邦线。3. 驱动层实现单总线时序和I2C读取的代码级拆解3.1 DHT22单总线驱动时序、校验和与超时处理DHT22通信协议是单总线只有一根数据线所有通信由主机发起。一次完整的读时序分三个阶段主机发送起始信号、传感器响应、传感器发送40位数据。每个位由一段低电平加一段高电平组成高电平持续26~28微秒表示“0”持续70微秒表示“1”。难点在于起始信号和读取时序里延时必须要精确到微秒级。先给出最常用的阻塞式读取代码基于STM32标准外设库// DHT22_Read: 从DHT22读取一次温湿度数据成功返回0失败返回-1 // data数组中data[0]湿度高字节 data[1]湿度低字节 data[2]温度高字节 data[3]温度低字节 data[4]校验和 // 使用GPIOA Pin0需要先初始化 uint8_t DHT22_Read(uint8_t data[5]) { uint8_t i, j; uint8_t last_state 1; uint8_t counter 0; // 主机拉低起始信号至少18ms GPIO_ResetBits(GPIOA, GPIO_Pin_0); Delay_Ms(20); // 拉高并延时20~40us等待传感器响应 GPIO_SetBits(GPIOA, GPIO_Pin_0); Delay_Us(30); // 切换为输入模式 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 等待传感器拉低响应信号开始 // 超时判断循环100000次大约等于几十毫秒 for (i 0; i 100000; i) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) break; } // 等待传感器拉高响应信号结束 for (i 0; i 100000; i) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 1) break; } // 开始读取40位数据 for (j 0; j 5; j) { data[j] 0; for (i 0; i 8; i) { // 等待低电平结束 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0); // 延时40us如果仍为高电平则记为1 Delay_Us(40); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 1) { data[j] | (uint8_t)(0x80 i); } // 等待高电平结束 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 1); } } // 恢复输出模式用于下次通信 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 校验和前四个字节相加取低8位应等于第五字节 if ((uint8_t)(data[0] data[1] data[2] data[3]) ! data[4]) { return 1; // 校验失败 } return 0; }这段代码的逻辑是经典的主机读时序。Start信号拉低至少18ms这个时间必须足够长DHT22才能识别为一次新的读取请求。拉高后延时30us正好避开传感器响应信号的前沿抖动。之后传感器会主动拉低约80us再拉高约80us表示“我准备好发数据了”。接下来40个数据位每个位都以50us低电平开始然后是高电平的持续时间决定0还是1。这里最关键的是Delay_Us(40)。我在多个型号的单片机上实测过这个40us不能减到35us以下否则容易把时间长的“1”误判成“0”也不能加大到50us否则“1”的高电平还没走完就变成读取下一个位。STM32F103主频72MHz下这个延时用简单的for循环就能得到较好精度但如果你换了其他主频的MCU必须重新校准Delay_Us函数最好用定时器或者SysTick来实现。另外注意这个代码用了浮空输入模式。因为外部已经有上拉电阻DHT22拉低时能可靠读到0不拉时靠上拉读到1所以用浮空输入没问题。3.2 BH1750的I2C驱动地址、测量模式与连续读取BH1750的I2C地址由ADDR引脚决定。ADDR接地时地址是0x237位地址接VCC时是0x5C。很多模块上ADDR引脚已经有默认拉低处理所以初始代码一般用0x23。I2C通信时7位地址左移一位得到8位地址写操作是0x46读操作是0x47。BH1750支持多种测量模式区别在于分辨率、测量时间和功耗。我常用的是连续高分辨率模式指令码0x10分辨率1 lux测量时间典型值120ms。如果需要更低的功耗可以用一次测量模式指令码0x20每次测量完自动掉电。要得到0.5 lux的分辨率用连续高分辨率模式2指令码0x11但测量时间会翻倍。下面是用GPIO模拟I2C的读取代码只展示核心部分。完整代码里需要I2C_Start、I2C_Stop、I2C_WriteByte、I2C_ReadByte四个基础函数。// BH1750_ReadLux: 读取光照强度返回lux值 // 返回-1表示通信失败否则返回实际光照值 int32_t BH1750_ReadLux(void) { uint8_t buf[2]; uint16_t raw; // 发送连续高分辨率测量指令 I2C_Start(); if (I2C_WriteByte(0x46)) { // 写地址0x231 0x46 I2C_Stop(); return -1; } if (I2C_WriteByte(0x10)) { // 连续H-分辨率模式 I2C_Stop(); return -1; } I2C_Stop(); // 等待测量完成典型120ms这里等待150ms确保稳定 Delay_Ms(150); // 读取2字节测量结果 I2C_Start(); if (I2C_WriteByte(0x47)) { // 读地址 I2C_Stop(); return -1; } buf[0] I2C_ReadByte(1); // 第一个字节后面还有数据 buf[1] I2C_ReadByte(0); // 第二个字节主机发送NACK I2C_Stop(); raw ((uint16_t)buf[0] 8) | buf[1]; // 分辨率为1 lux/LSB所以直接除以1 return (int32_t)raw; }这里有一点要解释操作指令是写操作地址后面跟的0x10是命令不是数据。BH1750没有寄存器地址的概念它通过命令码来切换模式。读取时先发读地址然后连续读两个字节第一个字节是结果的高8位第二个是低8位。I2C读取最后一个字节时主机要回NACK否则传感器会继续发送数据导致总线冲突。为什么不用STM32硬件I2C硬件I2C确实更省CPU但STM32F103的硬件I2C在总线繁忙恢复、错误标志处理上经常出问题尤其遇到传感器模块上的滤波电容影响边沿时容易出现超时或忙等待。GPIO模拟I2C代码量不大时序完全可控出问题也好查逻辑分析仪波形。如果你的项目里I2C设备很多或者需要高速传输再考虑硬件I2C并善用超时机制。3.3 主循环调度两个传感器不互相拖累的轮询策略两个传感器的最大问题在于读取时间差。DHT22一次读取需要约20ms的起始信号加上数据位的读取整个读时序大约25ms而且DHT22要求两次读取间隔至少2秒否则传感器数据不更新。BH1750在连续模式下每次读取前需要等待测量完成至少120ms。如果把两个传感器放在同一个主循环里顺序调用那么总耗时大约是150ms左右对于环境监测来说完全够用但最好加一个简单的调度策略让DHT22严格保持2秒间隔。我一般用状态机方式管理采集任务而不是在主循环里用Delay阻塞// 简单的非阻塞调度结构 typedef struct { uint32_t next_time; // 下次采集时间戳单位ms uint32_t interval; // 采集间隔 void (*task_func)(void); // 采集函数 } Task_t; Task_t tasks[] { {0, 2000, DHT22_Task}, // DHT22每2秒读一次 {0, 1000, BH1750_Task}, // BH1750每1秒读一次 }; void Main_Loop(void) { uint32_t now Get_TickMs(); for (int i 0; i sizeof(tasks)/sizeof(tasks[0]); i) { if (now tasks[i].next_time) { tasks[i].task_func(); tasks[i].next_time now tasks[i].interval; } } // 其他业务代码 }这个调度器的核心在于用Get_TickMs()获取系统毫秒时间戳而不是靠delay死等。DHT22内部传感器采样周期是2秒如果你读得太频繁它会返回上一次的数据造成同样的值被重复读取但至少不会报错。BH1750则是每次测量需要时间还没等转换完成就读取的话得到的是上一次的值。所以这里的间隔设置宁可偏大一点。要注意的是状态机不能解决DHT22读时序里的阻塞问题因为读DHT22的40个位需要微秒级的精确延时这个过程中CPU不能被打断去做别的否则时序就乱了。所以严谨的做法是读DHT22时关中断或者用后面第6章讲的DMA加定时器的非阻塞方案。对于简单项目关中断几十微秒是可以接受的。4. 数据处理与显示从原始寄存器到可用的温度湿度光照值4.1 DHT22数据转换16位温湿度拼接与符号位处理DHT22的40位数据中湿度占16位温度占16位校验和占8位。湿度数据最高位是湿度整数部分的最高位但实际上DHT22内部把湿度放大10倍输出所以原始值除以10才是实际湿度百分比。例如原始值0x0258十进制600实际湿度60.0%RH。温度同样放大10倍且最高位为1表示零下温度。温度数据处理时需要注意负数情况。数据手册说温度是16位有符号数但实际发送的是二进制补码最高位为1时表示负温度。有的人直接把高字节赋值给有符号char这样会得到错误的负数正确做法是先拼成uint16_t再进行符号扩展// DHT22数据解析 // data数组来自DHT22_Read包含5字节 float DHT22_GetHumidity(uint8_t data[5]) { uint16_t raw_humi ((uint16_t)data[0] 8) | data[1]; return (float)raw_humi / 10.0f; } float DHT22_GetTemperature(uint8_t data[5]) { uint16_t raw_temp ((uint16_t)data[2] 8) | data[3]; // 处理零下温度最高位为1 if (raw_temp 0x8000) { raw_temp 0x7FFF; // 去掉符号位 return -(float)raw_temp / 10.0f; } return (float)raw_temp / 10.0f; }这段代码把温度高位字节左移8位拼上低位得到一个无符号数然后检查最高位。如果为1先去掉符号位再取负值。这个处理方式比直接强制转换成int16_t更稳妥因为在不同编译器下从uint16_t转int16_t是标准行为但并不是所有人都记得补码规则。很多项目里还会对湿度做饱和处理。DHT22的湿度测量范围是0~100%RH但有时读出来会略超过100比如100.1%这通常是传感器个体差异可以直接用if (humi 100.0f) humi 100.0f;截断。4.2 BH1750光照换算分辨率模式与光强计算公式BH1750的原始值是一个16位整数它跟实际光照的关系取决于当前测量模式。高分辨率模式下每个LSB等于1 lux高分辨率模式2下每个LSB等于0.5 lux低分辨率模式下每个LSB约等于4 lux实际是3.6左右但手册中把它定义为4 lux。如果使用默认的连续高分辨率模式不需要任何换算原始值就是勒克斯数。但BH1750还有一个人为可调的“测量时间寄存器”。通过改变测量时间可以调整灵敏度和测量范围。指令码0x40可以设置测量时间。测量时间缩短分辨率降低但测量范围变大测量时间加长分辨率提高但测量范围变小。这个参数在实际项目中很少调整但如果你的应用环境有强光比如太阳直射超过60000 lux原始值可能饱和在65535这时可以缩短测量时间或切换为低分辨率模式来扩大量程。换算代码很简单// 根据模式计算光照值 // mode: 0高分辨率1x1高分辨率2x2低分辨率 uint32_t BH1750_CalculateLux(uint16_t raw, uint8_t mode, uint8_t meas_time) { float lux; switch (mode) { case 0: lux (float)raw; break; // 1 lux/LSB case 1: lux (float)raw * 0.5f; break; // 0.5 lux/LSB case 2: lux (float)raw * 4.0f; break; // 约4 lux/LSB default: lux (float)raw; break; } // 测量时间修正默认69如果改了meas_time要按比例修正 lux * (69.0f / meas_time); return (uint32_t)lux; }注意最后一行如果调整了测量时间光照值会跟测量时间成反比。比如你设定测量时间为138默认的2倍那么同样的亮度下原始值翻倍所以要以默认时间69为基准做比例缩放。这个特性很多人忽视导致换了测量时间后读出的lux值偏大一倍。4.3 串口输出与OLED显示的可选接入数据解析完之后最直接的验证方式是通过串口把数据打印出来。DHT22的数据帧是5字节建议打印时带上时间戳和校验状态。BH1750是2字节原始值加上解析后的lux。串口波特率我一般用115200环境监测数据每秒一次完全不会拥堵。如果需要OLED显示最常见的方案是0.96寸I2C接口OLED模块SSD1306驱动也挂在I2C总线上。这时要注意地址冲突BH1750默认地址0x23SSD1306默认地址0x3C两者地址不同可以共享同一条I2C总线各用一个GPIO模拟即可或者用一个模拟I2C轮询访问两个设备。OLED刷新频率建议5Hz以下太快可能导致DHT22单总线时序被打断因为OLED刷新一屏需要发送1KB大小的缓冲区耗时几十毫秒这期间不能关中断太久。下面是一个简单的串口输出拼接示例用sprintf格式化浮点数char buf[64]; sprintf(buf, Temp: %.1f C, Humi: %.1f %%, Lux: %d lx\r\n, temp, humi, lux); UART_SendString(USART1, buf);注意单片机里用sprintf带浮点会增大代码体积如果flash紧张可以把浮点乘以10转成整数后再拆分输出。我一般习惯用整数方式格式化成字符串既省flash又避免了某些编译器下浮点printf库不支持的坑。5. 避坑与常见问题排查我调这两颗传感器时踩过的五个坑5.1 现象DHT22老是读到0或超时典型现象是代码执行到读时序时一直卡在while(GPIO_ReadInputDataBit(...)0)死循环或者读出的数据全部为0偶尔校验通过但值明显不对。原因通常是起始信号宽度不够或者上拉电阻太大。DHT22要求起始低电平至少18ms才能唤醒有些人的代码只延时了2ms传感器根本没响应。另外如果上拉电阻用了100kΩ数据线上升沿会非常缓慢传感器输出高电平的脉冲宽度被拉宽导致位判断错乱。解决方法是先用万用表确认传感器供电正常然后用逻辑分析仪看数据线波形。如果抓不到任何波形说明传感器没被唤醒检查起始信号。如果有波形但数据乱检查上拉电阻是否在4.7kΩ~10kΩ范围内。另外有一种隐蔽问题有些DHT22模块板上已经带了上拉电阻如果你外部又加了一个实际阻值变成两个并联可能小于2kΩ加重了传感器输出级负担。我后来养成习惯模块上自带上拉的就不再外接。5.2 现象BH1750读出来固定为65535读回原始值一直是0xFFFFlux计算出来是65535。一般不是传感器坏了而是I2C读取时序有问题。常见原因是主机在读取第二个字节时没有发NACK。BH1750的规则是主机读最后一个字节后必须发送NACK然后发STOP。如果发送了ACK传感器会认为你还想继续读总线状态异常导致后续读错或总线卡死。另一个原因是在发送测量指令后等待时间不够。有些快速代码只等了50ms但高分辨率模式需要120ms还没转换完就读读到的是旧值或者上电默认值0xFFFF。解决办法是严格按手册等待或者实现一个“测量完成”查询但BH1750没有状态寄存器只能等固定时间。还有一个很少被提到的问题SDA和SCL接反。如果模块上丝印模糊容易把两个线接反I2C地址和所有命令都会失效。用万用表量一下模块上的引脚定义或者换两块模块交叉验证。5.3 现象温湿度数据跳动超过规格书标称DHT22在静止空气中的湿度读数应该有较好的稳定性但实际使用中振幅超过±3%RH温度波动超过±0.5℃。原因之一是传感器距离发热元件太近或者封装外壳导热点到了电路板。DHT22对气流比较敏感空调风口、手呼出的气、甚至人的体温辐射都会影响读数。另一个常见原因是STM32的GPIO配置问题。如果读时序中把引脚设置成带上拉输入模式而外部上拉电阻又存在读到的低电平阈值可能不准确。但更隐蔽的是DHT22输出数据时电流很小如果信号线走线过长且旁边有大电流线路会受到干扰。解决办法是硬件上让传感器离开MCU板至少几厘米用短的杜邦线连接传感器感应部分朝向空气流通稳定的一侧。软件上可以做滑动滤波比如连续取5次去掉最大值最小值后取平均。但要注意DHT22本身采样周期2秒滤波窗口不能设置太短否则平均来平均去全是同一个值。5.4 现象两个传感器挂同一个I2C总线上互相干扰BH1750和OLED或者其他I2C设备共用一个总线时读取其中一个会导致另一个通信异常尤其是加了GPIO模拟I2C后容易出现总线忙死。原因多半是I2C通信过程中被中断打断。GPIO模拟I2C的时序依赖精准延时如果中断里做了耗时操作比如OLED刷新、DHT22读取I2C的时钟高电平和低电平时间会被拉长但大部分从设备对时钟延时不敏感问题在于如果时间过长超过了从设备超时阈值从设备会复位导致总线状态错乱。解决方法是GPIO模拟I2C的每个字节之间检查一下SDA线状态如果为低说明从设备还想继续传输需要发送额外的时钟脉冲让它释放总线。另一个办法是将BH1750和OLED放在不同的“虚拟I2C总线”上即用两对GPIO分别驱动物理上不共用。这样虽然多占两个引脚但排查问题简单两个设备之间的电气影响几乎为零。我在原型阶段就是这么做的成本多两根杜邦线。5.5 现象单片机复位后传感器偶发无响应DHT22在上电后需要1~2秒稳定时间BH1750也一样。如果复位后立刻执行第一次读取DHT22可能响应正常但BH1750可能返回全0或者超时因为它的内部状态机还没初始化完成。解决方法是上电后先延时500ms再初始化传感器第一次读取尽量只做一次空读丢弃结果让传感器进入正常状态。很多BSP代码里都有这个习惯。另外如果单片机频繁复位建议在驱动层加一个“连续失败计数器”连续失败3次后重新初始化GPIO而不是一直卡在while循环里。这可以避免因为传感器故障导致整个系统挂死。还有一个细节DHT22读时序里如果芯片在读取过程中发生中断中断处理时间过长会导致位采样点偏移。如果你开了多个串口中断或者定时器中断建议在DHT22读取的整个函数里关中断或者至少把优先级设到最低。我见有人把DHT22读取放在定时器中断里这是大忌因为DHT22一次读取需要几十微秒在中断里阻塞太长时间会破坏系统实时性。6. 进阶用DMA加定时器把单总线读时序变成不阻塞的完整方案6.1 核心思路状态机替代延时等待在上面所有代码里DHT22读取都是阻塞的CPU在采样脉冲时要一直死等。这在简单轮询系统里没问题但只要项目里还有电机控制、无线通信或者界面响应阻塞式读取就会造成明显的卡顿。进阶方案是用输入捕获DMA一次性记录整段波形然后离线解析。思路是这样让STM32的定时器工作在输入捕获模式捕获引脚DHT22_DATA的每个上升沿和下降沿用DMA把捕获到的计数器值搬运到内存数组。每当GPIO产生一次跳变定时器捕获当前计数值DMA自动存储。这样采集完一整个40位数据帧后CPU再去解析数组里相邻两个计数值的差值换算成时间宽度从而判断每位是0还是1。这样做的优势是CPU真正占用时间从几十微秒级降到几个微秒只要启动DMA采集后就可以去干别的事等数据采集完成产生中断后再处理。缺点是占用的定时器和DMA通道资源较多而且代码复杂度上升。但在一套正式的嵌入式系统里这个方案才算“稳”。6.2 实现要点输入捕获与脉冲宽度解析以STM32F103的TIM2为例通道1捕获PA0引脚的上升沿和下降沿。配置要点如下// 配置TIM2输入捕获两个通道分别捕获上升沿和下降沿 // 这里使用同一个通道的映射通过CCER配置极性切换 TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; // 先捕获上升沿 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0; // 不滤波保证精度 TIM_ICInit(TIM2, TIM_ICInitStructure);捕获到的每次跳变对应的定时器计数器值会被DMA搬到一个数组里比如volatile uint16_t capture_buf[64]。DMA配置为循环模式每次捕获产生DMA请求。解析时需要先把这次捕获序列中的第一个下降沿之前的数据丢掉然后识别起始信号、响应信号最后对每个数据位测量高电平宽度。高电平宽度的计算方法是对每一对相邻捕获值后一个计数器值减前一个计数器值乘以定时器时钟周期就是时间。TIM2挂载在APB1上时钟为72MHz所以计数器每计数一次是13.89ns。如果测得高电平宽度在50us~100us之间判定为“1”在20us~40us之间判定为“0”。通过这种方式比延时采样更抗干扰而且不会受中断影响。解析函数大致如下// 解析DMA捕获数组返回温湿度数据 // cap_buf: 捕获计数器值数组cap_cnt: 有效捕获次数 uint8_t DHT22_ParseCapture(uint16_t *cap_buf, uint16_t cap_cnt, uint8_t data[5]) { uint32_t prev cap_buf[0]; uint32_t diff, high_time; uint8_t bit_val; int bit_count 0; for (int i 1; i cap_cnt; i) { diff cap_buf[i] - prev; // 相邻两次跳变的时间差 // 交替上升/下降沿这里取高电平的时间差 // 因为捕获极性在初始化时固定所以偶数是上升沿到下降沿的时间 if (i % 2 1) { // 高电平持续时间 diff * (1/72MHz) usdiff单位是计数器刻度 high_time diff * 1000000 / 72000000; // 单位us近似计算 if (high_time 50 high_time 100) { bit_val 1; } else { bit_val 0; } data[bit_count / 8] | (bit_val (7 - (bit_count % 8))); bit_count; if (bit_count 40) break; } prev cap_buf[i]; } // 校验逻辑与阻塞版本相同 ... }注意这个解析代码里high_time的计算方式是用计数器差值乘以1us对应的计数个数来近似。72MHz下1us是72个计数所以diff / 72就是微秒数。如果直接用diff * 1000000 / 72000000在整数运算下精度足够。如果定时器时钟不是72MHz这个参数要替换。这个方案的另一个好处是如果DHT22数据线上的毛刺导致捕获数量异常DMA的循环模式会自动覆盖旧数据不会造成内存溢出。解析前先检查捕获次数是否在合理范围比如40位数据需要大约40次高电平捕获加上起始和响应的几次总数应该在60以内超过就丢弃。6.3 验证方法连续运行24小时的数据漂移检查驱动写完怎么验证是真的稳定我习惯把设备放在一个固定环境中连续运行24小时记录每5分钟打印一次温湿度和光照值最后对比参考仪表比如水银温度计和标准湿度计。如果DHT22读数与参考值偏差在±0.5℃和±2%RH以内且24小时内的波动没有呈现周期性突变说明驱动时序基本正确。更重要的测试是复位稳定性人为用看门狗复位单片机100次每次复位后传感器数据都能恢复正确才算过关。对于BH1750验证时可以用手机手电筒在不同距离照射观察lux值是否可以用照度计对比或者用已知的灯泡功率和距离粗略估算。BH1750的测量值一般会比较准确但要注意玻璃遮挡对光线影响很大测试时不要隔着玻璃。从那以后我每次做环境传感器驱动都强制走一遍“逻辑分析仪抓波形 → 关中断拉高优先级 → 连续运行看漂移”这三步看似麻烦但能省掉后面现场调试的无数个夜晚。希望这篇拆解能帮你在自己的项目里少踩几个坑。本文还有配套的精品资源点击获取