
简介这是一套基于STM32的温湿度与距离测量综合项目资源面向嵌入式初学者、单片机课程设计及STM32开发者。项目以STM32为主控整合DHT11温湿度传感器、HC-SR04超声波传感器、LCD显示屏和LED报警模块实现了温湿度实时采集、距离检测以及参数超限报警功能覆盖GPIO配置、定时器计时、串行通信和中断处理等常见嵌入式开发环节。压缩包共203个文件大小3.37MB以C语言源文件46个.c和46个.h为主同时包含Keil工程配置、编译生成文件.hex/.axf/.map/.o/.d以及少量辅助文档便于查看代码、直接烧录或二次开发。已有526人学习浏览该资源。通过这套工程读者可系统理解传感器驱动时序、STM32外设初始化流程、LCD显示与报警联动逻辑还能借助编译中间文件追踪程序构建过程是课程设计或入门嵌入式开发的实用参考资料。1. 一块 STM32 板子挂两个传感器先看信号怎么串起来把一块 STM32F103C8T6 最小系统板、一个 DHT11、一个 HC-SR04 和一块 1602 LCD 拼在一起能做什么最简单也最典型的玩法就是做成一个桌面气象哨兵LCD 上轮流显示温湿度和前方障碍物距离温度超过 30℃ 或者距离小于 20cm 时 LED 亮。这个项目看起来是“传感器读取 显示”的入门组合但真正动手会发现DHT11 的单总线时序和 HC-SR04 的回波宽度测量才是决定系统稳不稳的分水岭。同一个 GPIO 口开漏读数据和输入捕获读回波期间还夹着一块 LCD 的初始化顺序没理清就会出现要么屏幕白板要么距离跳变到 999。适合正在做课程设计、或者想从点灯过渡到传感器采集的人复刻也能给准备做 STM32 项目设计的人一个可扩展的骨架。2. 硬件接口设计单总线、超声波回波与 LCD 的引脚分工2.1 DHT11 为什么必须用开漏输出加外部上拉DHT11 只有一根数据线空闲时由外部上拉电阻拉到高电平STM32 要先把它拉低超过 18ms 再释放让 DHT11 产生响应信号。如果 GPIO 配置成推挽输出会在释放瞬间与外部上拉电阻形成短暂短路虽然电流不大但会让电平翻转沿变缓读到的位就容易错。常规做法是开漏输出数据线外部接 4.7kΩ 上拉到 3.3V。每次读取前要把引脚重新配置成输入模式读完再切回开漏输出这样一个 pin 就完成了双向半双工通信。在 Keil 里如果用标准库方向切换是GPIOA-CRL改 CNF1 和 MODE1用 HAL 可以直接GPIO_InitTypeDef重新初始化。表 1 是我这版项目的引脚分配。外设引脚模式说明DHT11 DATAPA0开漏输出 / 输入外接 4.7kΩ 上拉到 3.3VHC-SR04 TrigPA1推挽输出10us 以上高电平触发HC-SR04 EchoPA2TIM2_CH3 输入捕获回波高电平宽度LCD1602 RSPA8推挽输出寄存器选择LCD1602 RWPA9推挽输出直接接地LCD1602 EPA10推挽输出使能脉冲LCD1602 D0-D7PB0-PB7推挽输出8 位并行数据报警 LEDPC13推挽输出低电平点亮DHT11 上电后至少等 1 秒才能稳定我在主循环启动后加了 1.5s 延时再发起始信号。这里有个容易忽略的点开漏模式下的输入读取必须关闭内部上拉否则读回的电平会被 STM32 内部上拉抬高与外部上拉叠加后逻辑还是高但响应信号的低电平持续时间会缩短数据校验容易失败。方向切换用寄存器操作比 HAL 快一个数量级void DHT11_SetDir(uint8_t dir) { GPIOA-CRL ~(GPIO_CRL_CNF0 | GPIO_CRL_MODE0); if (dir DHT11_IN) { GPIOA-CRL | GPIO_CRL_CNF0_1; // 浮空输入 } else { GPIOA-CRL | GPIO_CRL_CNF0_0 | GPIO_CRL_MODE0_1; // 开漏输出, 2MHz } }这个函数把 PA0 的 CNF0 设为10即浮空输入MODE0 设为00输出方向时 CNF0 设为01开漏MODE0 设为10对应 2MHz 输出速度。2MHz 的沿速度对单总线足够而且比 50MHz 模式的下冲更小适合在杜邦线上跑 5cm 左右短距离。如果你拿逻辑分析仪抓到的波形边沿过缓可以改成 10MHz 输出代价是轻微振铃。2.2 HC-SR04Trig 触发与 Echo 高电平宽度HC-SR04 靠超声波脉冲测距Trig 拉高 10us 以上模块发出 8 个 40kHz 脉冲Echo 会输出一个高电平宽度等于声波从发出到碰到障碍物再回来的往返时间。常规公式是距离(cm) 高电平时间(us) / 58因为 1cm 往返需要约 58us。如果按定义算1cm 往返时间是100cm * 2 / 34300cm/s约 58.3us所以这个经验系数很接近实际声速。Echo 信号是 5V 逻辑STM32F103 的 GPIO 标称可容忍 5V但我不喜欢长期直接怼加一个 10k/20k 分压到 3.3V 以下再给 PA2。时间测量有两种常见接法一种是把 Echo 接到外部中断引脚每次上升沿打开定时器、下降沿读 CNT另一种是直接上定时器输入捕获把 PA2 复用为 TIM2_CH3配置上升沿和下降沿都捕获。我选后者因为输入捕获由硬件完成时间戳主循环不会被微秒级中断打爆而且后续要改成 DMA 或 FIFO 也方便。配置 TIM2 时让内部时钟保持 72MHz预分频设成 71计数频率变成 1MHz计数器每加 1 就是 1us。CubeMX 里只需要把 Channel3 设成 Input Capture direct modePrescaler 填 71Period 填 0xFFFF。生成后的初始化核心是这段htim2.Init.Prescaler 71; // 72MHz / 72 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; // 最大 65.535ms约 1114cm HAL_TIM_IC_Init(htim2);这个 1MHz 计数频率是后面所有距离计算的基础也是联调时最先确认的参数。如果预分频写错或者 APB1 时钟没配好计数器频率就不是 1MHz读回来的距离会整体偏移一个系数。STM32F1 的 TIM2 挂在 APB1 上当 APB1 分频为 2 时定时器时钟自动倍频到 72MHzCubeMX 会处理好但手动建工程时很容漏掉这个倍频。2.3 LCD1602 并行接口与 LED 报警的引脚复用LCD1602 用 8 位并口模式需要 11 个引脚本项目里数据线全占 PB0-PB7。RS 接 PA8E 接 PA10RW 直接接地因为正常使用只写不读。Vo 脚接一个 10k 电位器抽头调整到约 1.5V 左右才能稳定显示否则上电后第一行容易烧出方块。LCD 初始化的关键不是每个引脚配置而是上电等待先后拉低 E延时 15ms写 0x38、0x0C、0x01、0x06之后才能连续写数据。我用一个简单的LCD_WriteChar逐字节发送使能脉冲用 delay_us(1) 拉宽不能省。LED 报警如果接 PC13 板载 LED注意很多蓝板 LED 是低电平点亮直接置高反而灭了。更稳妥的是接一个外部 LED正极接 330Ω 到 PB11负极接地GPIO 置高点亮。驱动电流不到 10mA推挽输出足够。报警要不要加锁存取决于场景如果只是提示“超限”直接反应阈值即可如果临界点附近LED 会闪第 4 章会加迟滞处理。3. 从 STM32CubeMX 到 Keil驱动代码与主循环落地3.1 先用 CubeMX 生成工程再决定用标准库还是 HAL时钟树错误是新手第一个翻车点。STM32F103 最高 72MHz一般开发板外部晶振 8MHzPLL 倍频 9。在 CubeMX 里把 HSE 设成 Crystal/Ceramic ResonatorPLL Source 选 HSEPLL Mul 选 x9系统时钟就能到 72MHz。如果用的是 12MHz 晶振这里就要选 x6不能照抄 9。晶振负载电容可以按C 2 * (CL - Cstray)估算但对普通板子 20pF 贴片电容都够用调试时更值得关注的是时钟树里 APB1 是否被降频到 36MHz因为 TIM2 挂在 APB1 上。工程生成后我建议保留 HAL 库做外设初始化但传感器位时序里直接操作寄存器这样两边都舒服。如果你更习惯标准库也不用先 CubeMXKeil 里手动新建工程把标准库的stm32f10x_gpio.c、stm32f10x_tim.c加进去即可。关键是 Keil5 安装 STM32 芯片包后Device 里能选到 STM32F103C8否则编译直接报 device not found。下面这段是 CubeMX 生成的定时器初始化它说明了为什么预分频必须由HAL_TIM_IC_Init真正下发htim2.Instance TIM2; htim2.Init.Prescaler 71; // PSC 71分频系数 72 htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(htim2);htim2.Init.Prescaler只是结构体字段必须调用HAL_TIM_IC_Init才能写入寄存器。我调试时踩过CubeMX 界面里 PSC 写 71但重新生成后又在别处改了结构体却没重新 init计数器频率变成 720kHz所有距离偏大约 10%。所以只要动过时钟树或者 PSC就要重新执行 init 语句。3.2 DHT11 读取函数位时序、校验与边界处理DHT11 的流程是拉低至少 18ms 释放等待 20-40us 响应低电平再等 80us 响应高电平然后连续读 40 位。下面是串行读取函数static uint8_t DHT11_ReadByte(void) { uint8_t byte 0; for (uint8_t i 0; i 8; i) { while (DHT11_DQ_IN() 0); // 等待位起始低电平结束 delay_us(40); // 延时到高电平中间 if (DHT11_DQ_IN() 1) { byte | (uint8_t)(0x80 i); while (DHT11_DQ_IN() 1); // 等待数据位高电平结束 } } return byte; }这里有两个细节while (DHT11_DQ_IN() 0)有卡死风险如果 DHT11 没有拉低响应必须加超时delay_us(40)的精度决定 0 和 1 的判别。DHT11 数据位是 50us 低电平开始之后 26-28us 高表示 0约 70us 高表示 1。我在高电平稳定后 40us 采样只要还没降到低就认为 1这样可以容忍一定时序抖动。读完 40 位后做校验uint8_t DHT11_Read(float *temp, float *hum) { uint8_t data[5]; // 发送起始信号并读取 40 位省略中间过程 // 校验湿度整数 湿度小数 温度整数 温度小数 校验和 if ((data[0] data[1] data[2] data[3]) ! data[4]) return 1; *hum data[0] data[1] * 0.1f; // 整数直接加 0.1 分辨率 *temp data[2] data[3] * 0.1f; // 温度符号位在 data[2] bit7 return 0; }注意 40 位数据顺序是湿度整数、湿度小数、温度整数、温度小数、校验和。小数字节的高位通常是 0按 0.1 分辨率处理即可。温度负值时 data[2] 的 bit7 为 1课程设计可以忽略但实际项目必须判符号。这种逐位轮询写法在 72MHz 下受中断影响很大。我在调 STM32 串口调试时深有体会USART1 中断一开DHT11 读取失败率明显上升。所以读取期间最好用__disable_irq()关闭中断读完再开或者保证高优先级中断处理极短。3.3 HC-SR04 回波测量阻塞版与输入捕获版先用阻塞版跑通逻辑float HC_SR04_Measure(void) { uint32_t count 0; uint32_t timeout 0; HAL_GPIO_WritePin(HC_TRIG_GPIO_Port, HC_TRIG_Pin, GPIO_PIN_SET); delay_us(20); HAL_GPIO_WritePin(HC_TRIG_GPIO_Port, HC_TRIG_Pin, GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(HC_ECHO_GPIO_Port, HC_ECHO_Pin) GPIO_PIN_RESET) if (timeout 100000) return -1; // 无回波超时 __HAL_TIM_SET_COUNTER(htim2, 0); timeout 0; while (HAL_GPIO_ReadPin(HC_ECHO_GPIO_Port, HC_ECHO_Pin) GPIO_PIN_SET) if (timeout 100000) return -1; count __HAL_TIM_GET_COUNTER(htim2); return count * 0.0172f; // count 单位 us按 343m/s 声速 }timeout上限 100000 在 72MHz 下大约是几十毫秒能覆盖 4m 以上测距也能防止 Echo 悬空时死等。count * 0.0172f的 0.0172 来自343m/s * 100cm/m / 2 / 1e6是固定声速下的换算系数。这个版本会阻塞主循环所以正式版用输入捕获中断。中断版的核心是在回调里记录两次边沿的计数器值并在边沿间切换捕获极性void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t rise_time 0; if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_3) { if (__HAL_TIM_GET_CAPTUREPOLARITY(htim, TIM_CHANNEL_3) TIM_INPUTCHANNELPOLARITY_RISING) { rise_time __HAL_TIM_GET_COUNTER(htim); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_3, TIM_INPUTCHANNELPOLARITY_FALLING); } else { uint32_t fall_time __HAL_TIM_GET_COUNTER(htim); echo_us fall_time rise_time ? fall_time - rise_time : 0; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_3, TIM_INPUTCHANNELPOLARITY_RISING); } } }每次捕获到上升沿就记录时间并切换到下降沿捕获下降沿到来时差值就是 Echo 高电平脉宽。echo_us是全局变量在主循环里直接用。注意如果回波宽度超过 65.5ms定时器会溢出实际中 10m 内不会出现但我在应用层仍会判断echo_us 38000以过滤失效值。3.4 LCD 显示与报警整合把传感器数据放进同一个调度循环while (1) { if (DHT11_Read(temp, hum) 0) { LCD_SetCursor(0, 0); LCD_Printf(T:%5.1fC H:%4.1f%%, temp, hum); } else { LCD_SetCursor(0, 0); LCD_Printf(DHT11 Err! ); } float dist HC_SR04_Measure(); if (dist 0 dist 400) { LCD_SetCursor(0, 1); LCD_Printf(D:%6.1fcm, dist); } alarm (temp TEMP_HIGH_LIMIT) || (hum HUM_HIGH_LIMIT) || (dist DIST_MIN dist 0); HAL_GPIO_WritePin(ALARM_LED_GPIO_Port, ALARM_LED_Pin, alarm ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_Delay(100); }这个循环的节奏是 100ms 一次LCD 两行分别刷新温湿度和距离。TEMP_HIGH_LIMIT、DIST_MIN这类阈值在下一章会改成可配置变量。HC-SR04 连续测量间隔建议 60ms 以上否则余波会影响下一次回波100ms 的HAL_Delay正好满足。LCD 打印函数如果直接用sprintf64KB Flash 的 F103C8 还放得下但建议写个精简版LCD_Printf避免链接时爆 ROM。4. 数据异常与测距漂移联调中的时序和补偿4.1 DHT11 全 0 或者全 FF先查上拉和方向切换现象是屏幕显示T:0.0 H:0.0或者乱码。第一种原因是外部上拉电阻没焊或者虚焊DHT11 在响应时把数据线拉低释放后线浮空STM32 读到的随机电平被判成 0 或 1。第二种原因是 GPIO 方向切换的寄存器写错如果对 PA0 误操作成GPIOA-CRHPA0 的 CNF/MODE 完全没变读出来始终是高或低。可以用逻辑分析仪抓 DHT11 DATA 的响应低电平宽度正常应是 80us 左右。另一个实际问题delay_us(40)被中断打断。如果使用 SysTick 延时且 SysTick 优先级高于当前线程40us 延时期间发生定时器中断采样点会漂移到数据位结束之后导致所有位都被判 0。处理办法是在 DHT11 读取函数入口__disable_irq()完全关中断。注意如果用了 FreeRTOS需要改用临界区保护不能裸关。4.2 Echo 一直高或者一直低检查捕获映射和供电如果HC_SR04_Measure总是返回 -1先用万用表量 HC-SR04 的 Vcc 是不是 5V。超声波模块用 3.3V 虽然也能工作但回波幅度明显降低长距离容易丢。确认供电后看 PA2 有没有被正确配置成 TIM2_CH3。STM32F1 的 PA2 默认是 USART2_TX需要GPIO_Mode GPIO_Mode_AF_PP并调用GPIO_PinAFConfig缺少 AF 配置时 Echo 根本进不了定时器。如果 Echo 一直高先量 Trig 是否真有 20us 高电平。很多开发板 GPIO 初始化顺序不对Trig 引脚在进入主循环前被其他代码拉低。阻塞版里的超时自增次数与主频相关建议改成基于HAL_GetTick()的绝对超时uint32_t start HAL_GetTick(); while (HAL_GPIO_ReadPin(ECHO_GPIO_Port, ECHO_Pin) GPIO_PIN_RESET) { if (HAL_GetTick() - start 100) return -1; }这样无论主频 72MHz 还是 48MHz超时时间都是 100ms行为一致。4.3 声速温度补偿与阈值迟滞count * 0.0172f是按 25℃ 声速 343m/s 算的。如果你拿卷尺量28℃ 和 15℃ 下同一位置读数会差 0.5-1cm这不是模块坏了。声速和温度近似线性关系v(m/s) 331.4 0.6 * T。既然 DHT11 已经在读温度补偿很自然float speed_mps 331.4f 0.6f * temp; // temp 来自 DHT11单位 ℃ float dist_cm (float)echo_us * speed_mps / 20000.0f;echo_us是微秒速度是米每秒乘以echo_us * 1e-6得到秒再乘速度得到米换算成厘米并除以 2合并系数就是 20000。这个公式在 10℃ 到 40℃ 之间比固定 0.0172 大约修正 1.7% 的距离1m 处约 1.7cm。注意 DHT11 温度本身误差 ±2℃所以补偿只能把系统误差降到 ±0.6% 左右。阈值迟滞这样写if (alarm_on) { if (temp TEMP_HIGH_LIMIT - HYST) alarm_on 0; } else { if (temp TEMP_HIGH_LIMIT HYST) alarm_on 1; }HYST取 1℃ 或 1cmLED 不会在临界点每秒闪很多次。4.4 用串口打印原始数据辅助调试STM32 串口调试 PID 时通常要输出曲线这个项目没有 PID但调试传感器同样需要看数值。开一个 USART1每 500ms 打印一行printf(T:%.1f H:%.1f Echo:%dus Dist:%.1fcm\r\n, temp, hum, echo_us, dist);串口工具里能直接看到 DHT11 偶发读取失败时的变化。如果Echo固定在 0说明触发器没发出去如果Echo在 5000us 和 200us 之间跳变说明目标物表面反射不稳定用一张 A4 纸贴在测试板上可提高反射率。串口输出在最终演示前还能快速确认报警阈值是否真的生效不用反复烧录。5. 进阶把报警阈值和声速补偿接成一个可配置系统5.1 串口指令修改阈值把TEMP_HIGH_LIMIT、DIST_MIN从宏改成全局变量通过串口中断接收命令。我用一个 64 字节缓冲积累字符收到回车或换行后sscanf解析void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { HAL_UART_Receive_IT(huart1, rx_byte, 1); if (rx_byte \r || rx_byte \n) { cmd_buf[cmd_idx] 0; if (sscanf(cmd_buf, SET_TEMP %d, temp_limit) 1) { if (temp_limit 50) temp_limit 50; if (temp_limit 10) temp_limit 10; } else if (sscanf(cmd_buf, SET_DIST %d, dist_limit) 1) { if (dist_limit 300) dist_limit 300; } cmd_idx 0; } else if (cmd_idx sizeof(cmd_buf) - 1) { cmd_buf[cmd_idx] rx_byte; } } }sscanf会拖大 Flash但 F103C8 的 64KB 放得下。若在意资源可以手写strstr加atoi。命令格式是SET_TEMP 28波特率用 115200 8N1和串口调试参数保持一致即可。5.2 把补偿距离和迟滞报警接进主循环最终主循环里先读 DHT11温度读取成功就更新speed_mps再把echo_us转换为补偿后的距离如果 DHT11 读失败保留上次速度避免距离跟着温度错误跳变。报警阈值已经变成变量迟滞比较直接引用当前阈LED 状态保存在alarm_on中。再往后可以扩展的方向很多把阈值写到 STM32 内部 Flash 尾部实现断电保存或者把 LCD 显示改成四个独立的两位数菜单按键。这里我更建议先做滤波echo_us偶尔一个异常值会直接让 LED 闪烁用一个长度为 5 的环形缓冲取中值比平均值更能过滤尖峰。中值滤波只需在每次捕获回调里把新值写入数组然后简单排序取echo_buf[2]代码量不超过 20 行却能让距离显示稳定到不再乱跳。本文还有配套的精品资源点击获取