简介:面向嵌入式初学者的STM32温湿度与距离测量完整工程源码,适合单片机开发者学习如何集成DHT11和HC-SR04传感器,实现环境参数采集、LCD显示及超限LED报警。资源共203个文件,以C源码、头文件及Keil工程文件为主,包含可烧录的hex、调试映射map、配置备份及多版本工程记录,整体仅3.37MB,便于快速导入并对照源码学习。目前已有526人学习,可用于课程设计、毕业设计或实战练手。工程中详细展示了STM32的GPIO、定时器、串行通信及中断配置方法,覆盖DHT11单总线时序解析与HC-SR04回波时间测量,并包含LCD显示屏驱动和阈值比较后点亮LED的完整逻辑。内容预览中还可见多个.bak备份和调试输出文件,能够帮助读者了解开发排错过程,提升基于ARM Cortex-M内核的嵌入式系统设计能力。
1. 从“测得到”到“测得稳”:STM32温湿度距离测量的三个核心问题
一块屏幕同时显示温度、湿度和小风扇到水箱液面的距离,听起来只是把 DHT11 和 HC-SR04 接上同一块 STM32 而已。实际把两个模块放到同一个工程后你会发现,真正的难点不在“读数据”,而在“谁先读、读多快、超时怎么办”。温湿度传感器是单总线时序,对 GPIO 翻转时机极其敏感;超声波模块靠回波脉宽换算距离,一旦被中断或调度延迟,测出来就是跳变。STM32 在两个模块之间协调时序、处理超时和滤波,才是这个选题真正要解决的问题。这篇内容按“硬件选型 → 温湿度采集 → 距离采集 → 融合调度”推进,用 HAL 库代码把每一步落到命令行和函数级别,新手能照着接线上板,熟手可以直接跳到第 4 章的定时器捕获实现和第 5 章的排错表。
2. 硬件选型与电路:温湿度传感器与超声波模块的搭配逻辑
2.1 DHT11 / DHT22 / SHT30 怎么选
标题写的是“温湿度”,能接的传感器不少,但不同传感器的接口决定了你在 STM32 上要写多少代码。DHT11 是 1-Wire 单总线,数据脚既当输入又当输出,时序在微秒级,代码里全是循环翻转 GPIO;DHT22(AM2302)同样走单总线,数据更准,但采样间隔要求不低于 2 秒;SHT30 走 I2C,硬件时序由外设接管,代码简洁,缺点是价格是前者的好几倍。
| 传感器 | 接口 | 精度(温度/湿度) | 采样间隔要求 | 代码成本 | 典型场景 |
|---|---|---|---|---|---|
| DHT11 | 单总线 | ±2℃ / ±5%RH | 1s 以上 | 高,GPIO 模拟时序 | 低成本环境监测、鱼缸、宿舍小制作 |
| DHT22 | 单总线 | ±0.5℃ / ±2%RH | 2s 以上 | 高,时序更严格 | 需要相对准确数据的室内监a测 |
| SHT30 | I2C | ±0.3℃ / ±2%RH | 无强制 | 低,HAL I2C 直接调 | 项目型产品、批量产线 |
我一般给“毕业设计型”项目推荐 DHT11,因为后续章节要讲的单总线时序解析,本身就是 STM32 GPIO 操作的重要练习;如果标题背后是产品预研,直接上 SHT30,把 I2C 时序交给硬件外设。判断依据只有一个:你愿不愿意为准确度付出把微秒级时序调到稳定的调试时间。
2.2 HC-SR04 与更短距离的取舍
距离测量这边最常用的是 HC-SR04,工作电压 5V,测量范围 2cm 到 400cm,精度标称 3mm,实际上受温度影响明显。它只需要一根 Trigger 输入、一根 Echo 输出,原理是测回波脉宽,代码量小。缺点是它有一个 2cm 左右的盲区,且回波接收角度窄,对着曲面物体读数会漂。
如果测量距离在 1m 以内且要求连续可调,很多人改用激光测距模块(如 VL53L0X),走 I2C,没有盲区问题,但成本上了一个台阶。这里我按最常见、最便宜的方案处理:HC-SR04 负责距离,STM32 的定时器负责精确计时,后面第 4 章会展开。
2.3 接线与供电:先解决电平匹配再写代码
接线上容易犯的第一个错是:STM32F103C8T6 的 GPIO 容忍 5V 输入,部分板子的 ADC 引脚却不耐受;HC-SR04 的 Echo 输出高电平是 5V,直接进 STM32 引脚多数情况能工作,因为很多 STM32 引脚标注为 5V 容忍,但这不是所有型号都成立。更稳妥的接法是用两个 10kΩ 电阻分压,或者用一个 1kΩ+2kΩ 电阻串联把 Echo 拉到 3.3V。后面排错表里我会再强调一次,因为这是“时好时坏”的经典来源。
| 信号 | DHT11 | HC-SR04 | STM32 引脚(示例) |
|---|---|---|---|
| 电源 | 3.3V-5V | 5V | 5V/3.3V 对应接入 |
| 地 | GND | GND | GND 共地 |
| 数据 | DATA | 无 | PB0(上拉电阻 4.7k) |
| 触发 | 无 | Trigger | PB1 |
| 回波 | 无 | Echo | PB2(经分压) |
DHT11 的数据线需要一个 4.7kΩ 上拉电阻到 VCC,这几乎是单总线器件的标配。上拉电阻缺失时,数据线靠内部弱上拉也能出数,但传输距离稍长或线缆有一点容性就会连续丢帧,表现为读一次成功、读两次超时。
2.3.1 供电与滤波的细节
两个模块放在同一块面包板上没问题,但如果电机、继电器在同一电源上,强烈建议给 STM32 板子单独供电。超声波发射瞬间电流可达几十毫安,锂电池供电时电压跌落会导致 STM32 复位。常见做法是在模块电源引脚两侧各加一个 100nF 去耦电容,靠近模块放置;如果电源线超过 20cm,再并一个 100μF 电解电容在板端。
3. 基于 STM32 读 DHT11:单总线时序的 HAL 实现
3.1 单总线时序拆解:起始、响应与 40bit 数据
DHT11 读时序的关键词是“拉低再拉低,然后等释放”。主机先把数据线拉低至少 18ms,这是起始信号;释放后等待传感器响应,DHT11 会先把线拉低 80μs,再拉高 80μs,这是响应信号。随后每个 bit 的格式都一样:先拉低 50μs,再拉高,高电平持续 26-28μs 表示 0,持续 70μs 表示 1。
这 40bit 数据分别是:8bit 湿度整数、8bit 湿度小数、8bit 温度整数、8bit 温度小数、8bit 校验和。校验和方法是前四个字节相加,取低 8 位,与第五个字节比较。
| DHT11 时序参数 | 数值 | 说明 |
|---|---|---|
| 起始信号拉低 | 18ms | 最小 18ms,可放宽到 20ms |
| 传感器响应低 | 80μs | 自动产生,无需干预 |
| Bit 0 高电平 | 26-28μs | 读回时用于判断 0/1 |
| Bit 1 高电平 | 70μs | 超过 60μs 视为 1 |
| 读取间隔 | 1s 以上 | 连续读会读到上次缓存数据 |
3.2 用 HAL_GPIO 模拟单总线读取代码
下面按 HAL 库写,Keil5 里新建标准工程后直接复制调整即可。核心思路:GPIO 输出模式发送起始信号,立刻切换为输入模式,用HAL_GetTick()做毫秒级超时,用DWT->CYCCNT或简单for循环做微秒级延时。
/* DHT11 引脚定义,假设接在 PB0 */ #define DHT11_PORT GPIOB #define DHT11_PIN GPIO_PIN_0 /* 微秒级阻塞延时,依赖系统时钟 72MHz */ static void delay_us(uint32_t us) { uint32_t ticks = us * 72; while (ticks--) { __NOP(); } } /* 将引脚切换为输出且输出低电平 */ static void dht11_set_output_low(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &gpio); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); } /* 将引脚切换为输入模式 */ static void dht11_set_input(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, &gpio); } /* 读取 40bit,返回 0 表示成功 */ uint8_t dht11_read(float *humidity, float *temperature) { uint8_t data[5] = {0}; uint8_t bit = 0, byte_idx = 0; uint32_t timeout = 0; /* 发送起始信号 */ dht11_set_output_low(); HAL_Delay(20); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); dht11_set_input(); /* 等待传感器拉低响应信号,超时 100ms */ timeout = 100000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (--timeout == 0) return 1; } /* 等待响应信号结束 */ timeout = 100000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) return 2; } /* 读取 40bit 数据 */ for (byte_idx = 0; byte_idx < 5; byte_idx++) { for (bit = 0; bit < 8; bit++) { timeout = 100000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) return 3; } delay_us(40); /* 在这个位置判断高低电平宽度 */ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { data[byte_idx] |= (0x80 >> bit); timeout = 100000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (--timeout == 0) return 4; } } } } if (((data[0] + data[1] + data[2] + data[3]) & 0xFF) != data[4]) { return 5; } *humidity = data[0] + data[1] / 10.0f; *temperature = data[2] + data[3] / 10.0f; return 0; }代码里的关键点有两个。第一个是delay_us(40)的位置:它在每一位数据的前一个低电平结束时开始计时,如果 40μs 后引脚仍为高,说明该位高电平宽度超过 60μs,判定为 1;否则为 0。第二个是每个超时循环都返回不同错误码,方便你根据返回值判断是“无响应”还是“数据中断”。实际调试中,返回 1 或 2 最常见,原因基本都是起始信号拉低时间不足,或者传感器供电电压低于 3.3V。
3.3 校验、超时与错误重试策略
DHT11 的校验和只能发现错误,不能纠正。我的处理方式是连续读三次,至少两次校验通过才采用,否则报错。还有一种常见做法是把读取失败当作“数据无效”,在 OLED 或上位机上保留上次正确值并标记刷新时间,这样 UI 不会闪。
3.3.1 从 DHT11 切换到 DHT22
DHT22 的时序和 DHT11 几乎一致,区别是数据格式里温度高字节的最高位表示负温,且湿度分辨率到 0.1。代码只需要修改数据拼接部分:
uint8_t sign = 0; if (data[2] & 0x80) { sign = 1; data[2] &= 0x7F; } *temperature = sign ? -(data[2] + data[3] / 10.0f) : (data[2] + data[3] / 10.0f); *humidity = data[0] + data[1] / 10.0f;因为 DHT22 要求两次读取间隔至少 2 秒,所以第 5 章的调度循环里会把它的采样周期单独设置,而不是和超声波一样每 200ms 跑一次。
4. 距离测量:HC-SR04 回波计时与定时器输入捕获实现
4.1 从 Trigger 到 Echo:超声波测距的物理基础
HC-SR04 的测量流程是:STM32 给 Trigger 引脚一个 10μs 以上的高电平,模块内部发射 8 个 40kHz 的超声波脉冲,同时把 Echo 引脚拉高;当模块接收到回波,Echo 拉低。Echo 高电平的持续时间就是声波往返的总时间。距离计算公式是:
距离(cm) = Echo 高电平时间(μs) / 58
58 这个系数的来源是声速 340m/s 时,往返 1cm 约需 58μs。但声速会随温度变化,公式的精确版本在 4.4 节给出。
4.2 阻塞式 delay 测量与它的局限
最常见的教学写法是:拉高 Trigger 10μs,然后用HAL_GetTick()或 micros 函数循环等待 Echo 拉高,再等待 Echo 拉低,中间用delay_us计时。这个写法在单独测距时没问题,一旦和 DHT11 的单总线读取放在同一个主循环,问题就暴露了:Echo 拉高期间如果来了定时器中断,中断服务函数里的代码会推迟你读取电平的时机,测出来的时间偏长;如果用了HAL_Delay,误差更大。
/* 阻塞式写法,只适合单独测距,不推荐用在多传感器场景 */ HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); delay_us(15); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_RESET); uint32_t start = DWT->CYCCNT; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_SET); uint32_t time_us = (DWT->CYCCNT - start) / 72;这段代码把时间测量建立在 DWT 计数器上,不依赖定时器,精度尚可。但阻塞期间任何中断都会造成测量误差,且如果超声波模块损坏或没有回波,while循环会卡死主线程。
4.3 定时器 + 外部中断的非阻塞实现
推荐做法:用定时器产生微秒级时间基准(例如 TIM2 以 1MHz 计数),Echo 引脚配置为外部中断,拉高时记录当前计数值,拉低时再记录一次,差值就是高电平持续时间。这个方案的好处是测量期间 CPU 完全释放,可以同时执行 OLED 刷新或其他逻辑。
/* TIM2 已初始化为 1MHz 递增计数,即每 1μs 加 1 */ volatile uint32_t echo_start_tick = 0; volatile uint32_t echo_end_tick = 0; volatile uint8_t echo_ready = 0; /* 触发一次测量,Trigger 高电平持续 15μs */ void hcsr04_trigger(void) { echo_ready = 0; HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); delay_us(15); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); } /* Echo 引脚的外部中断回调,放在 HAL_GPIO_EXTI_Callback 里 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == ECHO_PIN) { if (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_SET) { echo_start_tick = __HAL_TIM_GET_COUNTER(&htim2); } else { echo_end_tick = __HAL_TIM_GET_COUNTER(&htim2); if (echo_end_tick >= echo_start_tick) { echo_ready = 1; } else { /* 计数器回绕,TIM2 为 32 位,几乎不会发生,保留处理逻辑 */ echo_ready = 1; } } } } /* 在定时器中断或超时判断中检查回波超时 */ uint8_t hcsr04_get_distance_cm(uint32_t *distance_cm) { hcsr04_trigger(); uint32_t timeout = HAL_GetTick() + 100; while (!echo_ready && HAL_GetTick() < timeout); if (!echo_ready) return 1; uint32_t diff_us = echo_end_tick - echo_start_tick; *distance_cm = diff_us / 58; return 0; }这段代码有几个参数必须说清。TIM2 的计数频率必须是 1MHz,也就是 72MHz 时钟下预分频为 71;如果预分频不对,diff_us算出来的就不是微秒。超时时间设置 100ms,对应最大测量距离约 1.7m,如果你要测满 4m,把超时放到 250ms。还有一个细节:多次测量之间要留至少 50ms 的间隔,否则超声波余波会干扰下一轮。
4.4 声速温度补偿与多探头防串扰
声速在空气中随温度变化,0℃ 时约 331m/s,30℃ 时约 349m/s,如果你在恒温室内做测试差异不大,但用在户外或鱼缸边,误差能到好几厘米。精确的换算公式是:
距离(cm) = Echo 高电平时间(μs)×(331.45 + 0.607 × 温度℃)/ 10000 / 2
也就是说,把第 3 章读到的温度直接代进来。我的建议是测距代码里读一个全局温度变量,每秒钟更新一次,不要每次测距都去读 DHT11,因为单总线读取本身耗时。
| 温度 | 声速 m/s | 无补偿时 1m 处误差 |
|---|---|---|
| 0℃ | 331.45 | 约 2.5cm |
| 20℃ | 343.46 | 基准 |
| 40℃ | 355.46 | 约 2.8cm |
多模块同时测距时,两个 HC-SR04 会互相收对方的回波。解决方法是让模块 A 触发后先等 60ms 让余波消失,再触发模块 B;如果项目要求更高,用不同频率的收发一体式换能器,但成本翻倍,一般不需要。
5. 传感器融合调度与几个值得记录的踩坑点
5.1 单线程多传感器的调度顺序
主循环里常见的错误是“每轮循环把温湿度和距离都读一遍”。DHT11 要求两次读取间隔不小于 1s,HC-SR04 测距最好 50ms 以上一次,如果把两个读操作叠在同一个周期,只会让 DHT11 读到缓存旧值。我一般用一个 100ms 的软件定时器做调度基准:每 5 个 tick 读一次 DHT11,每 2 个 tick 触发一次超声波,每个 tick 刷新一次屏幕。这样所有传感器都有各自的采样节拍,互不阻塞。
uint32_t tick_10ms = HAL_GetTick() / 10; if (tick_10ms % 5 == 0) { dht11_read(&hum, &temp); } if (tick_10ms % 2 == 0) { hcsr04_get_distance_cm(&dist); }5.2 数据平滑与异常值丢弃
超声波在风扇叶片或水面波纹上会得到跳动很大的回波,第 5.1 节的调度配上滤波才能输出稳定数据。最简单有效的是滑动窗口中值滤波:保留最近 5 个有效距离值,排序后取中间值。
uint32_t dist_buf[5] = {0}; uint8_t dist_idx = 0; dist_buf[dist_idx++] = dist; if (dist_idx >= 5) dist_idx = 0; /* 排序取中值 */检查异常值的另一个经验:如果连续两次测量差超过 50cm,大概率是回声缺失或外部干扰,直接丢弃本次结果而不是把跳变送进滤波队列。
5.3 排查表:信号对但数据不对怎么查
| 现象 | 原因 | 处理 |
|---|---|---|
| DHT11 时而返回 1 或 2 | 起始信号时间不足/供电偏低 | 拉低时间改到 30ms,3.3V 供电时检查电压 |
| 距离读数固定为 0 | Echo 未分压导致引脚损坏 | 换板子,加两个电阻分压 |
| 距离读数周期性漂移 | 声速未做温度补偿 | 代入第 4.4 节公式 |
| 超声波和 DHT11 同时运行时读数错乱 | DHT11 的单总线阻塞循环打断了 Echo 中断回调处理 | 用第 4.3 节非阻塞测距,缩短 DHT11 超时时间 |
最后一个可以立刻验证的技巧:在 OLED 上把声速温度补偿后的距离和未补偿的并排显示,比较它们在不同温度下的差值。看到差值随温度变化,说明时序和数据链路基本正确,剩下的是算法层面的打磨。
本文还有配套的精品资源,点击获取