
简介一套完整的STM32F103嵌入式工程资源面向准备动手实践传感器驱动的开发者可解决DHT22温湿度与BH1750光照强度数据采集、协议解析以及在TFTLCD屏上实时显示的问题。压缩包共150个文件总大小3.08MB其中.h/.c文件为完整驱动与主程序源码.uvprojx/.uvoptx为Keil MDK工程配置.axf/.hex为编译后的可执行与烧录文件另有.o目标文件、.htm说明文档、链接脚本与辅助清理脚本便于直接打开工程学习、修改和验证。目前已有8865人浏览学习。资源内置空气温湿度与光照强度功能的全部源码和接线说明覆盖单总线时序读取、I2C命令配置、数据校验处理以及SPI接口LCD驱动等关键实现逐段阅读代码可理解DHT22脉冲宽度解析、BH1750光强数值转换以及STM32F103的GPIO、定时器、I2C、SPI外设配置方法整套工程也适合作为嵌入式课程设计、单片机入门进阶及传感器应用开发的参考案例。1. 一块F103同时盯温湿度和光照这不是玩具是能上线的组合温室大棚、宿舍环境监测、机房服务器微环境巡检这些场景里最常出现的三样数据就是温度、湿度和光照强度。STM32F103作为主控同时接一个DHT22温湿度传感器和一个BH1750光照强度传感器是很多项目起步时的标配组合。但这两个传感器并不是“把代码拼在一起”就能跑的DHT22走单总线时序钳得极死BH1750走I2C地址和测量模式有自己的一套规则。它们的供电、上拉电阻、初始化顺序、滤波策略每一个环节都会直接影响读数。这篇笔记把我实际调过的完整驱动、接线参数和典型踩坑记录整理出来新手照着复现能一次点亮熟手也可以在换芯片或者扩展更多传感器时避开我已经踩过的坑。2. 接线之前先算账DHT22与BH1750的供电、电平匹配和上拉电阻很多教程让你直接按网上的电路图接但实际项目里最容易出问题的不是代码而是电气连接。DHT22和BH1750看起来都是宽电压传感器但它们的上拉策略完全不同直接接到F103引脚上轻则读数漂重则GPIO被拉死。2.1 两路传感器各自需要什么DHT22是单总线传感器数据线既要接收主机指令又要输出数据所以必须配上拉电阻。上拉电阻的取值取决于总线长度和引脚电容典型值是4.7kΩ。如果杜邦线超过20cm上升沿会变慢位时序可能直接错乱这时候就要把电阻降到2.2kΩ。我实际量过同样的代码4.7kΩ在30cm飞线下偶尔读到校验失败换2.2kΩ后就稳定了。BH1750走的是标准I2C地址引脚ADDR接地时7位地址是0x23接高电平时是0x5C。我习惯把ADDR直接接地保持默认减少后面代码里换算地址的步骤。BH1750的数据线SCL和SDA也需要上拉电阻但绝大多数现成模块上已经给你贴好了不需要额外焊。供电方面DHT22数据手册给的是DC 3.3V到5.5VBH1750是2.4V到3.6V。这里有一个容易踩的坑很多BH1750模块板上自带一颗3.3V稳压芯片和I2C上拉电阻如果你给模块供5VSDA和SCL上拉到的还是3.3V这没问题但如果你用裸芯片直接接5VI2C引脚会被拉到5V长期顶着跑对引脚寿命不好。我一般统一用3.3V供电省掉电平转换这一层。DHT22的情况反过来。数据线在上拉到3.3V时驱动能力足够但如果你从5V取电然后模块上的上拉电阻也接到5V那信号高电平就是5V直接进F103引脚会有隐患。买模块的时候一定要确认电路图上上拉电阻是接到了VCC还是3.3V很多廉价模块会把上拉电阻和VCC接在一起看起来能跑实际上是在拿引脚寿命赌。2.2 电平匹配和上拉电阻计算F103普通GPIO输出高电平是3.3V但输入引脚不全是5V容忍类型。PA4、PB6、PB7这几个引脚在F103系列里属于FT引脚可以接受5V输入所以很多人直接接5V传感器临时测一下确实还能读。但5V高电平在FT引脚上会增加漏电流电气噪声大的环境还会引起误触发我自己的板子从不让传感器侧高电平超过3.6V。上拉电阻的取值可以粗略估算。设总线寄生电容约15pFDHT22位周期的低电平时间约50us但信号上升沿必须足够陡否则读bit时的高电平持续时间判断会失真。RC时间常数要小于1us用R乘以15pF小于1us来算R要小于66kΩ这个限制很宽。真正卡住的是DHT22从机灌电流能力上拉太小会让拉低电平时的低电平电压抬高所以4.7kΩ是典型值最大不超过10kΩ最小不要低于2.2kΩ。BH1750的I2C速率可以跑到400kHz上拉电阻通常在2.2kΩ到4.7kΩ之间。如果你用3.3V供电模块已集成上拉可以直接用如果是裸片在SCL和SDA各接一个4.7kΩ到VCC就行。要留意的是不要在模块已有上拉电阻的SDA/SCL上再并联一个4.7kΩ两个电阻并联后阻值减半会增大信号线上的电流导致波形过冲。2.3 实物接线清单和检查顺序我固定采用这一组引脚后面代码都是按它写的传感器引脚MCU引脚电平/备注DHT22VCC3.3V不要用5VDHT22GNDGND与MCU共地DHT22DATAPA4外接4.7kΩ上拉到3.3VBH1750模块VCC3.3V模块自带稳压可接5V但不推荐BH1750模块GNDGND与MCU共地BH1750模块SCLPB6模块自带2.2kΩ上拉BH1750模块SDAPB7模块自带2.2kΩ上拉焊完或者插好杜邦线后先别急着下载代码用万用表做三件事。第一量数据线对GND的电阻DHT22的DATA对地应该接近4.7kΩBH1750的SDA和SCL对地也应该有2.2k到4.7kΩ如果接近0说明短路。第二上电后量传感器VCC引脚对GND电压DHT22侧应该是3.3V左右。第三拿示波器或逻辑分析仪看DHT22数据线的静态电平正常情况下应该被上拉到3.3V如果量出来是0V说明模块上的上拉电阻没接好需要外接。检查完再写代码能省掉大量“读不出来”的排查时间。尤其是BH1750如果SDA没有上拉I2C读回来的ACK永远是1代码看起来再对也没用。3. 让空气开口说话DHT22单总线时序与CRC校验的完整实现DHT22的“单总线”和DS18B20那种带ROM指令的协议不太一样它的全部时序就是主机拉低总线一段时间作为启动信号传感器自动回一段80us低电平和一段80us高电平然后连续输出40位数据。整个过程只用一根线但代价是每一位的读时序都要靠延时去卡而且对中断特别敏感。3.1 单总线协议的“土办法”实现我用F103的标准外设库不跑RTOS用一个基础的微秒延时函数。关键在于延时函数的精度。很多人图省事用空循环做延时编译器优化等级从O0改成O2整个时序就变了。我建议用定时器或者至少把空循环参数标成volatile。DHT22引脚需要频繁切换输入输出模式我把这一层封装成一个独立函数方便换引脚。// file: dht22.c #include dht22.h #include delay.h // 提供 delay_us() 和 delay_ms() #define DHT_GPIO_PORT GPIOA #define DHT_GPIO_PIN GPIO_Pin_4 #define DHT_RCC_PORT RCC_APB2Periph_GPIOA static GPIO_InitTypeDef GPIO_InitStructure; static void DHT_Pin_Mode(uint8_t mode) { if (mode 0) { // 输出模式用于拉低启动信号 GPIO_InitStructure.GPIO_Pin DHT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT_GPIO_PORT, GPIO_InitStructure); } else { // 输入模式用于读取数据位 GPIO_InitStructure.GPIO_Pin DHT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT_GPIO_PORT, GPIO_InitStructure); } }这段代码把GPIO在推挽输出和浮空输入之间切换。注意我用了浮空输入而不是上下拉输入因为DHT22本身靠外部上拉电阻把总线拉高不需要MCU内部上拉。如果你发现读回来的电平始终是0先查一下外部上拉是不是没焊。3.2 启动信号和读位时序DHT22的启动时序是主机先拉低至少1ms我直接拉到18ms给传感器足够的复位时间然后拉高20到40us紧跟着切输入模式等待从机响应。从机响应表现为总线先被拉低80us再释放并被上拉拉高80us之后开始连续输出40位数据。每一位的数据结构是总线先低50us然后高电平持续时间决定bit值。高电平持续26到28us表示0持续70us表示1。读位的标准做法是等待引脚变高后延时40us再读一次电平如果还是高就是1。不过不同批次传感器时序有偏差我更习惯记录高电平持续时长再判断。uint8_t DHT_Read_Bytes(uint8_t *buf) { uint8_t i, j; uint16_t high_time; uint8_t temp 0; // 1. 主机拉低至少18ms确保传感器复位 DHT_Pin_Mode(0); GPIO_WriteBit(DHT_GPIO_PORT, DHT_GPIO_PIN, Bit_RESET); delay_ms(18); // 2. 拉高20-40us准备读取从机响应 GPIO_WriteBit(DHT_GPIO_PORT, DHT_GPIO_PIN, Bit_SET); delay_us(30); DHT_Pin_Mode(1); // 3. 等待从机低电平响应约80us while (GPIO_ReadInputDataBit(DHT_GPIO_PORT, DHT_GPIO_PIN) Bit_SET); // 4. 等待从机高电平响应约80us while (GPIO_ReadInputDataBit(DHT_GPIO_PORT, DHT_GPIO_PIN) Bit_RESET); // 5. 连续读取5个字节共40位 for (j 0; j 5; j) { temp 0; for (i 0; i 8; i) { // 每个位周期以50us低电平开始 while (GPIO_ReadInputDataBit(DHT_GPIO_PORT, DHT_GPIO_PIN) Bit_RESET); // 记录高电平持续时间 high_time 0; while (GPIO_ReadInputDataBit(DHT_GPIO_PORT, DHT_GPIO_PIN) Bit_SET) { high_time; delay_us(1); if (high_time 100) break; // 超时保护 } if (high_time 40) { temp | (0x80 i); } } buf[j] temp; } return 1; }这段代码里最重要的是超时保护。等待低电平和等待高电平时都加了上限否则DHT22掉线或者接线断开程序会卡死在while里这在定时采集系统里等于整个任务停摆。判断bit值时高电平超过40us记成1这个阈值需要按你手上的传感器批次微调。我在测试中1位高电平约70us0位约26us40us正好在中间如果你遇到读数全是0或全是255优先怀疑这个阈值。3.3 数据帧格式和CRC校验DHT22输出的5个字节含义是湿度高8位、湿度低8位、温度高8位、温度低8位、校验和。校验和等于前四个字节相加后取低8位。温度数据最高位表示负数因为DHT22能测到零下。拼装数据的代码int16_t DHT_Get_Temperature(uint8_t *buf) { int16_t temp; temp (int16_t)(buf[2] 0x7F) 8 | buf[3]; if (buf[2] 0x80) // 高位最高位是符号位 temp -temp; return temp; } uint16_t DHT_Get_Humidity(uint8_t *buf) { return (uint16_t)buf[0] 8 | buf[1]; }注意DHT22的分辨率是0.1%拼出来的整数是真实值的10倍。比如返回520实际湿度是52.0%。我一般主程序里做成float再除以10串口打印时直接显示一位小数。读完后总线会被DHT22拉低一段时间所以要等它释放。我在下一次读之前固定延时2秒因为DHT22的采样间隔最低要求2秒。读太频繁会导致数据不更新甚至不响应这是DHT系列共有的脾气别指望靠提高轮询频率获得更平滑的曲线。4. 光照也要数字化BH1750的I2C配置与连续测量模式BH1750本身是个I2C从机比DHT22少了那些诡异的位时序难点在I2C配置、地址规则和测量模式的取舍。F103自带的硬件I2C在标准外设库上偶尔会遇到Busy信号卡死的毛病所以很多开发者宁可砍掉硬件外设用GPIO模拟。我这里给模拟I2C的实现因为这种方式可以随意换引脚排查逻辑也更直接。4.1 地址选择和指令选择BH1750的7位地址由ADDR引脚决定拉低是0x23拉高是0x5C。I2C写地址等于7位地址左移一位加0所以写地址是0x46读地址是0x47。前面接线时已经把ADDR接地这里直接定义成常量。指令集里最常用的是0x10连续高分辨率模式测量时间约120ms分辨率为1 lx。还有0x13连续低分辨率模式分辨率4 lx测量速度8ms适合快速看趋势。我建议首次调通用0x10因为1 lx分辨率对室内和温室都够120ms的测量周期也适合和DHT22的2秒读取对齐。还有一个0x20是连续高分辨率模式2配合MTreg命令可以提高灵敏度但需要额外写寄存器低光环境才用得上。如果你是在夜间监测可以再研究这层。4.2 模拟I2C的底层实现用GPIO模拟I2C时SCL和SDA要配置成开漏输出并借助外部上拉电阻把电平拉高。我默认用PB6和PB7代码里先实现四个基础函数。#define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_PORT GPIOB #define I2C_SCL_HIGH() GPIO_SetBits(I2C_PORT, I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_PORT, I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_PORT, I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_PORT, I2C_SDA_PIN) void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_LOW(); delay_us(5); I2C_SCL_LOW(); } void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_HIGH(); delay_us(5); }I2C的开始信号是SCL高电平时SDA由高变低停止信号是SCL高电平时SDA由低变高。模拟I2C最怕延时太短SCL高电平持续时间不够从机采样不稳定。我习惯每步至少5us整体跑在100kHz左右这个速度对BH1750足够了。写字节的代码要特别注意ACK位uint8_t I2C_WriteByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) I2C_SDA_HIGH(); else I2C_SDA_LOW(); data 1; I2C_SCL_HIGH(); delay_us(2); I2C_SCL_LOW(); delay_us(2); } // 释放SDA读取从机的ACK应答 GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(I2C_PORT, GPIO_InitStructure); I2C_SCL_HIGH(); delay_us(2); i GPIO_ReadInputDataBit(I2C_PORT, I2C_SDA_PIN); // 0 表示ACK I2C_SCL_LOW(); GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_PORT, GPIO_InitStructure); return i; }注意读ACK之前必须先释放SDA切换到输入模式让从机能拉低总线。很多人模拟I2C发送没问题读回ACK一直不对就是因为第9个时钟周期里SDA还停留在输出状态。读数据字节时也要在每位SCL高电平期间去读SDA。4.3 连续测量模式的代码实现与换算初始化时先写0x10指令然后等120ms以上再连续读两个字节。BH1750输出是大端格式高字节在前。测量结果按公式除以1.2就是实际的lx值因为高分辨率模式每个LSB代表1.2 lx。float BH1750_Read_Lux(void) { uint8_t reg_high, reg_low; uint16_t raw; I2C_Start(); I2C_WriteByte(0x46); // 0x23 1 | 0 I2C_WriteByte(0x10); // 连续高分辨率模式 I2C_Stop(); delay_ms(180); // 给足测量时间 I2C_Start(); I2C_WriteByte(0x47); // 0x23 1 | 1 reg_high I2C_ReadByte(1); // 还有下一字节返回ACK reg_low I2C_ReadByte(0); // 最后一个字节返回NACK I2C_Stop(); raw ((uint16_t)reg_high 8) | reg_low; return (float)raw / 1.2f; }这里关键点在于连续模式下可以直接接着读上次结果不需要重发测量指令。但我上面的代码每次读之前都重新写一次0x10这样保证返回的是当前测量值而不是上一次的缓存值。代价是多花几ms但值得。I2C_ReadByte(1)和I2C_ReadByte(0)的区别是读完一个字节后主机是否发送ACK。读寄存器时如果还没读完必须回ACK从机才会继续输出下一字节最后一字节回NACK从机知道你不需要了。模拟I2C里这个实现就是在第9个时钟周期让主机控制SDA高或低。BH1750还有一个容易误判的特性当光照强度超过寄存器上限65535数据会溢出变成0。在阳光直射下室外正午照度可能超过10万lx这时候传感器会读回0。我在主程序里加一个溢出判断if (raw 60000) { return -1; // 溢出不采信 }返回-1表示量程超限显示层可以把它标成“溢出”而不是上报一个错误的0。5. 联调避坑全记录错误读数、总线卡死和“温度永远不变”把两路驱动都写好之后联调才是真正花时间的地方。下面五条是我自己实际遇到过并且解决掉的记录按现象、原因、解决三行写清你遇到类似问题时可以直接对号入座。5.1 现象DHT22湿度跳到99%或温度恒为0刚上电时读到的湿度经常是99.9%温度0.1℃过几秒才正常。原因是传感器上电后需要一段自检过程内部没有稳定之前会输出一个不确定的数据组合。解决方法是上电后等2秒再读第一次并且如果读出来的校验和不对直接丢弃这次结果等下个周期再读不要用上一次的值填充。5.2 现象BH1750地址应答失败调试时写地址后读ACK一直返回1说明没有设备应答。常见原因有两个一是模块ADDR引脚悬空导致地址不稳定二是SDA上拉电阻缺失总线高电平不足。解决方法是确认ADDR接地并用万用表量SDA对地电阻没有上拉就在模块上焊一个2.2kΩ到3.3V别指望F103内部上拉能搞定。5.3 现象DHT22上电后一直没响应程序卡死在等待低电平响应的while循环里。最直接的原因是上一次读取结束后DHT22还没释放总线你就立刻发下一次启动信号传感器根本没进入接收状态。解决每次读取函数返回后至少延时1秒再触发下一轮同时给每个while加超时退出。我这里把超时上限设成100us如果超过直接返回失败值0。5.4 现象光照数值看起来合理但偏低一半BH1750读出的lx比实际感觉暗很多排查后发现传感器表面有一层不透光的保护标签没撕掉几乎堵死了透光窗口。解决办法是撕掉透光处的贴纸如果是外壳设计问题要给传感器留出开窗位置。还有一次是传感器贴在板子边缘被螺丝支架挡住了换了安装方向才解决。这种问题不属于代码但最容易误导后面的自动控制逻辑。5.5 现象传感器工作一会儿就“死掉”DHT22原本正常连续采集几分钟后返回值变成0复位后又能恢复。我用万用表测到传感器供电只有2.9V原因是板上的3.3V LDO压降大DHT22启动瞬间电流和BH1750测量同时发生时电压被拉低。解决换用输出能力500mA以上的独立LDO给传感器供电MCU和传感器分开供电并在每个传感器电源引脚对地加100nF去耦电容。第5条最隐蔽因为不是每次都在同一时刻出问题故障是概率性的。我后来养成的习惯是所有外设电源都从主电源单独走线不在板上串联飞线尤其是传感器这种瞬态电流大的器件。6. 把这套采集做得更皮实滑动平均与应对电压跌落的初始化习惯两路驱动稳定跑起来后接下来要做的是数据质量和长期稳定性。我给出两个值得直接抄的改进。6.1 滑动平均滤掉风扇带来的瞬时波动机房或温室通常有风扇气流经过DHT22时湿度读数会瞬间跳几个百分点。我为每个传感器维护一个长度为8的环形缓冲区每次读取后计算平均值。#define BUFFER_SIZE 8 static float temp_buf[BUFFER_SIZE] {0}; static uint8_t buf_index 0; void Temp_Update_Buffer(float temp) { temp_buf[buf_index] temp; buf_index (buf_index 1) % BUFFER_SIZE; } float Temp_Get_Average(void) { float sum 0; uint8_t i; for (i 0; i BUFFER_SIZE; i) sum temp_buf[i]; return sum / BUFFER_SIZE; }缓冲长度选8是因为DHT22采样间隔是2秒8个点覆盖16秒能滤掉风扇的秒级波动又不会把真实的温度变化拖得太钝。注意缓冲区初始值都是0系统启动后前8个点会拉低均值所以要在填充满有效数据之后再显示。BH1750的读数本身已经比较平滑不需要额外滤波但如果你的环境有灯具频闪可以把两次测量结果做一次一阶滞后滤波系数取0.7左右。这比环形缓冲区更简单也够用。6.2 复位后重新初始化传感器单片机意外复位后DHT22可能还停留在上一次读取的释放状态BH1750则可能正处于测量周期中间。我在main函数开头强制走一遍冷启动流程先延时500ms让电源稳定然后DHT22先做一次空读并丢弃结果BH1750先写0x00进入掉电模式再写0x10唤醒进入连续测量。这个流程能避免复位后第一次读数全为0或者校验失败。从那以后我每次换传感器或换板子都强制把时序逻辑和电源初始化放在一起重新检查而不是只盯着算法。希望这套驱动和避坑记录能帮你少走点弯路。本文还有配套的精品资源点击获取