STM32实战:DS18B20温度采集与HC-SR04超声波测距Proteus仿真
2026/9/17 3:43:38 网站建设 项目流程

简介:面向STM32学习者的嵌入式综合实战资源,以ARM Cortex-M内核单片机为载体,完整呈现温度监测与超声波测距两个经典场景。工程包含DS18B20单总线驱动、HC-SR04回波定时捕获、定时器中断处理及多通道温度数据管理,适合想从基础GPIO、串口通信进阶到单总线协议与传感器融合的开发者对照练习。压缩包共146个文件,以65个h头文件与55个c源文件为主体,内含完整HAL库工程、Keil项目配置、Hex固件及辅助脚本,整体约850KB,结构紧凑,可直接打开工程查看或烧录验证。已有752人浏览学习;通过阅读源码和工程组织,能直观理解定时器中断在超声波测距时序中的关键作用,掌握DS18B20初始化、读写时序与CRC校验,学会设计多传感器轮询逻辑,并借助串口或LCD完成数据显示,是课设、竞赛与入门固件开发的高性价比参考。

1. 为什么温度和测距要先在仿真里跑通

我之前帮人调过一块F103的板子,HC-SR04接上去,串口打印的距离值总在跳,用逻辑分析仪抓回波引脚才发现是软件延时不准,时间基准差了十几个微秒。这种问题在Proteus仿真里反而好定位,因为虚拟示波器和虚拟终端都摆在桌面上,一个回波脉冲的宽度看得清清楚楚。这个项目把DS18B20温度采集和HC-SR04超声波测距放在同一个STM32工程里,数据通过串口输出到PC,正好覆盖了定时器中断、单总线协议、输入捕获这几个嵌入式开发里最常用的知识点。

适合两类人:刚入手STM32方向的学生,想看看GPIO模拟时序和硬件定时器怎么配合;已经在写业务代码、但对底层外设配置不熟的工程师,也可以拿这份工程当模板,改改引脚和参数就能移植到自己的板子上。仿真跑通之后再上实物,调试成本会低很多。

2. STM32定时器中断与微秒延时:测距的时间基准

2.1 为什么选 TIM2/TIM3 而不是系统滴答

超声波测距要测量的是回波高电平的宽度,也就是超声波从发射到碰到障碍物再返回的时间。常温下声速约343m/s,测1cm距离对应约58微秒的回波时间。如果系统滴答定时器配置成1ms中断一次,这个时间尺度根本量不了。HAL库自带的HAL_Delay同样只能保证毫秒级,所以必须用硬件定时器。

F1系列里有TIM1、TIM8这样的高级定时器和TIM2、TIM3、TIM4、TIM5通用定时器。高级定时器功能多,寄存器配置相对复杂,做输入捕获和计数延时用通用定时器就够了。这个工程里用TIM2做溢出中断,用来产生10微秒的超声波触发脉冲,用TIM3做输入捕获,记录ECHO引脚上升沿和下降沿的时刻。换句话说,TIM2管发射,TIM3管接收,分工明确。

2.2 定时器初始化:从72MHz到微秒计数

F103的APB1总线时钟是72MHz,把预分频器设为71,计数器每计一个数就是1微秒。

void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz,即1us计数一次 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFF; // 最大65535us,约65ms htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim2); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); }

预分频器为什么是72减1,因为定时器时钟源是72MHz,需要72个时钟周期产生1微秒,而计数器从0开始计,所以写72-1。Period设成0xFFFF时,定时器溢出周期约65毫秒,这个量级足够覆盖HC-SR04的测距范围;如果做超过10米的测距,就需要把Period调大或者改成级联计数。

启动定时器和中断的代码放在main函数里:

HAL_TIM_Base_Start_IT(&htim2); // 使能TIM2更新中断 HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); // 抢占优先级1,子优先级0 HAL_NVIC_EnableIRQ(TIM2_IRQn);

实际调试中发现,优先级配置要留意。DS18B20的单总线时序对时序连续性很敏感,如果定时器中断优先级太高,频繁打断温度读取过程,单总线的读时序就可能超时。我一般把TIM2的中断优先级设得比串口中断低,保证温度读取过程不被频繁打断。

2.3 微秒延时函数的两种实现

定时器中断里用的延时函数不用HAL_Delay,而是直接读计数器。常见做法是封装一个微秒延时函数:

void delay_us(uint32_t nus) { uint16_t start = TIM2->CNT; // 记录当前计数值 while ((uint16_t)(TIM2->CNT - start) < nus) { // 等待计数器差值达到目标 } }

这个函数的关键是读取TIM2->CNT的当前值,和初始值做差,差值达到目标微秒数就返回。因为计数器溢出后自动回绕,用uint16_t做差可以正确处理溢出场景。注意这个函数依赖TIM2已经启动,如果TIM2没使能,CNT一直是0,函数会卡死。所以调用delay_us之前必须先HAL_TIM_Base_Start,而不是Start_IT。

还有个细节:这段延时内中断仍然会进来,所以不能把它用在DS18B20最严格的写时序里。DS18B20的写时序要求总线拉低后15微秒内必须决定写0还是写1,这个过程中如果有高优先级中断插进来,时序就容易被打乱。工程里“7 定时器中断实验”那段代码,我建议把它和单总线驱动分开理解:定时器中断负责周期性任务,比如每500毫秒触发一次测距,单总线通信则关闭相关中断或者用关中断保护临界区。

3. DS18B20单总线驱动:从复位时序到温度解析

3.1 单总线协议和STM32的GPIO角色

DS18B20和STM32之间只有一根数据线,数据线上既传输时钟又传输数据,所以通信双方必须严格按时隙操作。它上电后的默认分辨率是12位,也就是0.0625℃的精度,这对环境温度监测已经够用。和I2C、SPI不同,单总线是异步半双工协议,没有时钟线,收发全靠控制时序长度。

GPIO需要配置成开漏输出模式,外部接一个4.7kΩ的上拉电阻。开漏的好处是,总线空闲时被上拉到高电平,任一方想拉低总线直接输出低电平即可,不会出现推挽输出下两边互相打架的情况。

void DS18B20_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉,外部可不接 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }

开漏模式下把PULL配置成上拉,理论上可以省掉外部上拉电阻,但Proteus仿真时最好还是放一个上拉电阻,仿真模型对内部上拉的处理和实物有差异,这样做出来的波形更接近真实硬件。引脚可以选PA0,后面超声波模块用PA1和PA2,三个引脚挨在一起,Proteus里连线也方便。

3.2 复位时序和存在脉冲

单总线通信的第一步是主机发送复位脉冲。主机把总线拉低480微秒以上,然后释放总线。DS18B20检测到这个下降沿后,会在60到240微秒内拉低总线,输出一个存在脉冲。

uint8_t DS18B20_Reset(void) { uint8_t presence = 0; DS18B20_MODE_OUTPUT(); DS18B20_LOW(); delay_us(480); // 复位脉冲宽度 DS18B20_HIGH(); // 释放总线 DS18B20_MODE_INPUT(); delay_us(70); presence = DS18B20_READ(); // 读取存在脉冲 delay_us(410); // 等待复位周期结束 return presence == 0 ? 1 : 0; }

这里DS18B20_MODE_OUTPUT和MODE_INPUT控制GPIO方向,用HAL_GPIO_WritePin配合重新初始化GPIO的方式实现。我实际用的时候是把寄存器的MODER位直接改了,比调用HAL_GPIO_Init快,时序不容易受函数调用开销影响。读取存在脉冲时,如果返回0说明DS18B20在线,返回1说明没检测到设备,这种情况在Proteus里通常是因为元件没摆放好或者上拉电阻阻值不对。

3.3 写时隙和读时隙的实现

DS18B20的写时隙分写0和写1两种。写0时隙是主机拉低总线60到120微秒,写1时隙是主机拉低总线1到15微秒后释放,然后保持到时隙结束。关键是15微秒这个分界点,所以写1时不能把总线拉低太久。

void DS18B20_WriteBit(uint8_t bit) { DS18B20_MODE_OUTPUT(); DS18B20_LOW(); delay_us(2); // 起始时间 if (bit) { DS18B20_HIGH(); // 写1:拉低后立即释放 delay_us(60); } else { delay_us(60); // 写0:持续拉低60us DS18B20_HIGH(); } delay_us(2); }

单总线的读时序和写时序不一样,主机拉低总线1到15微秒后释放,然后马上切换成输入模式,在15微秒内采样总线电平。DS18B20要写0时会把总线拉低,写1时保持高电平。

uint8_t DS18B20_ReadBit(void) { uint8_t bit = 0; DS18B20_MODE_OUTPUT(); DS18B20_LOW(); delay_us(2); DS18B20_MODE_INPUT(); delay_us(10); // 释放总线,等待从机驱动 bit = HAL_GPIO_ReadPin(DS18B20_GPIO_PORT, DS18B20_PIN); delay_us(50); return bit ? 1 : 0; }

读取函数里的delay_us(10)是采样点,这个值不同平台略有差异。F103在72MHz下运行,这个参数可以直接用;如果换到低速时钟或者跑在48MHz,采样点需要调大一些,我一般先用逻辑分析仪看波形,确认电平翻转位置再微调。

3.4 温度数据解析与CRC校验

读取温度完整流程是:复位,发0xCC跳过ROM匹配(单总线只挂一个传感器时用),发0x44启动温度转换,等待750毫秒转换完成,再复位,发0xCC,发0xBE读取暂存器,连续读9个字节。前两个字节是温度值,第9个字节是CRC校验值。

温度值是16位有符号数,低4位表示小数部分,1个LSB对应0.0625℃。

float DS18B20_GetTemperature(void) { uint8_t buf[9]; int16_t raw; float temp; DS18B20_Reset(); DS18B20_WriteByte(0xCC); // 跳过ROM DS18B20_WriteByte(0x44); // 启动温度转换 HAL_Delay(750); // 12位分辨率需要750ms DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0xBE); // 读取暂存器 for (int i = 0; i < 9; i++) { buf[i] = DS18B20_ReadByte(); } if (CRC8_Check(buf, 9)) { raw = (buf[1] << 8) | buf[0]; temp = raw * 0.0625f; // 1 LSB = 0.0625℃ return temp; } return -999.0f; // 错误返回值 }

负温度的处理要注意,如果测量温度低于0℃,raw是有符号数。int16_t定义好后直接乘0.0625f,编译器会按有符号数处理,如果这里写成uint16_t,-0.5℃就会解析成65535.9375℃,差得很远。CRC8校验多项式是X8+X5+X4+1,网上的DS18B20例程大多带现成实现,拷贝过来直接用就行。校验失败返回-999,在串口端好识别。

4. HC-SR04测距实现与声速温度补偿

4.1 触发信号和回波时序关系

HC-SR04模块的触发方式很简单,给TRIG引脚一个10微秒以上的高电平脉冲,模块内部就会发射8个40kHz的超声波脉冲,然后等待回波。当回波被接收器检测到后,模块会把ECHO引脚拉高,ECHO高电平的持续时间,就是超声波从发射到返回所花的时间。

编程上要做的就是三件事:给TRIG引脚输出10微秒高电平,测ECHO引脚高电平宽度,换算成距离。第一件事用TIM2配合delay_us就能办到,第二件事用TIM3输入捕获。

void HC_SR04_Trigger(void) { HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(15); // 10us以上即可,15us留余量 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); }

触发脉冲宽度不能太长,我见过有人用HAL_Delay(1)去拉高,也就是1毫秒,模块依然能工作,但ECHO输出的脉冲宽度会和理论值有偏差,因为模块内部的发射逻辑会被过宽的触发信号干扰。按手册来,10到20微秒最稳妥。

4.2 输入捕获配置:测量ECHO引脚脉宽

TIM3配置成输入捕获模式,通道1映射到ECHO引脚。先捕获上升沿,记录第一次计数器值,再把捕获极性切换成下降沿,记录第二次计数器值,两次差值就是高电平持续的微秒数。

void MX_TIM3_Init(void) { TIM_IC_InitTypeDef sConfigIC = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 1us计数一次 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 0xFFFF; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(&htim3); sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&htim3, &sConfigIC, TIM_CHANNEL_1); }

中断回调函数里做极性切换和脉宽计算:

uint32_t ic_rising_value = 0; uint32_t echo_width_us = 0; volatile uint8_t echo_done = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { if (__HAL_TIM_GET_IT_SOURCE(htim, TIM_IT_CC1)) { if (echo_done == 0) { ic_rising_value = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 切换为下降沿捕获 TIM_RESET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1); TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else { uint32_t falling = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); echo_width_us = falling - ic_rising_value; echo_done = 1; } } } }

TIM_RESET_CAPTUREPOLARITY这个宏在标准库里是TIM_RESET_CAPTUREPOLARITY,HAL库写法略有不同,如果编译报错就用__HAL_TIM_SET_CAPTUREPOLARITY来切换。实际调试时,我习惯在主循环里等待echo_done标志位,判断超时:如果ECHO引脚一直为高或者一直为低,说明前方没有障碍物或者模块没接好,此时定时器不会产生捕获中断,要加个超时保护,比如50毫秒内没捕获到下降沿就认为测距失败。

4.3 距离计算与温度补偿公式

声速不是固定值,冰点附近大约是331.4m/s,温度每升高1℃,声速约增加0.6m/s。如果项目只是做个测距演示,直接用343m/s也能用,但既然工程里已经有DS18B20测到的温度,把温度补偿加进去几乎不增加代码量。

float speed_of_sound = 331.4f + 0.6f * temperature; float distance_cm = (float)echo_width_us * speed_of_sound / 20000.0f;

换算逻辑是:echo_width_us是以微秒计的往返时间,除以1e6转成秒,乘以声速得到往返总距离,再除以2得到单程距离,最后乘100转成厘米。合并成公式就是上面这一行。假设温度25℃,声速346.4m/s,回波脉宽5800微秒,算出距离约100.4cm,和实际1米吻合。

湿度对声速的影响在常温下大约0.1%到0.3%的量级,家用测距和教学演示不用管。但如果做的是工业液位测量,温湿度补偿就需要同时上。

4.4 串口输出数据帧格式

调试阶段把温度、原始脉宽、补偿后的距离都打到串口,方便对比。

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; } printf("T:%.2f C Pulse:%luus Dist:%.2f cm\r\n", temperature, (unsigned long)echo_width_us, distance_cm);

fputc重定向之后,printf就能通过串口1输出。Keil工程里记得勾选MicroLIB,否则浮点printf会占很多Flash。数据帧里带单位是为了配合串口调试助手做图表解析,如果后续要接Python或LabVIEW做温度可视化,建议改成CSV格式,字段间用逗号分隔,上位机处理起来更方便。

5. Proteus仿真联调与六路温度扩展

5.1 仿真工程的基本连线

Proteus里放上STM32F103C8、DS18B20和HC-SR04模型。DS18B20的DQ接PA0,TRIG接PA1,ECHO接PA2。DS18B20数据线加一个4.7kΩ上拉电阻到3.3V。HC-SR04模型在Proteus元件库里不一定找得到同名组件,常见做法是用信号发生器模拟ECHO回波,或者用现成的第三方仿真库,我这边直接用虚拟仪器搭:触发信号用按钮加电阻模拟,回波用脉冲发生器给定宽度的方波,先验证定时器捕获逻辑对不对。

晶振按F103典型配置设为8MHz,Proteus里别忘了给芯片供电,NRST引脚悬空容易引起反复复位,我习惯把它直接通过10kΩ电阻接到3.3V,仿真和实物都这么做。

5.2 虚拟终端和printf重定向

Proteus的Virtual Terminal直接连到STM32的PA9和PA10,也就是USART1的TX和RX。如果打开终端没有数据,先检查波特率是否一致,工程里初始化为115200,终端也要设成115200。这类问题排查顺序是:看程序跑没跑、看串口配置对不对、看终端波特率对不对,不要一上来就怀疑代码逻辑。

现象可能原因处理方式
虚拟终端无输出USART未使能或引脚错误检查RCC和GPIO复用配置
温度数据为-999DS18B20未响应检查复位脉冲宽度和上拉电阻
距离值固定在某个值捕获极性配置错误核对上升沿/下降沿切换逻辑
程序跑飞中断优先级配置不当降低定时器中断优先级
编译报错找不到头文件HAL库路径未添加在C/C++ Include路径中添加对应目录

5.3 六路温度扩展的思路与资源占用

工程名里提到六路温度,DS18B20有单总线多点挂载的能力,每个传感器都有唯一64位ROM序列号,总线初始化后用0x55匹配ROM地址就能精确读到指定传感器。但六路全挂在一根总线上,搜索ROM的代码复杂度会明显上升,排错也更麻烦。我实际做的时候更推荐每个传感器独立占一个GPIO,代码写成一组查表驱动。

#define TEMP_CH_NUM 6 static uint16_t temp_pin[TEMP_CH_NUM] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3, GPIO_PIN_4, GPIO_PIN_5}; for (int ch = 0; ch < TEMP_CH_NUM; ch++) { DS18B20_SelectPin(GPIOA, temp_pin[ch]); float t = DS18B20_GetTemperature(); printf("CH%d:%.2f C\r\n", ch + 1, t); }

DS18B20_SelectPin这个函数每次切换后重新初始化GPIO,把对应的引脚配置成开漏输出。六路全读一遍需要6个750ms,也就是4.5秒左右,如果是轮询刷新,这个周期够用;如果要求秒级刷新,就把分辨率配置改成9位,转换时间降到93.75ms,代价是精度从0.0625℃变成0.5℃。测温和测距共用资源时要考虑时序冲突,超声波测距的回波捕获依赖TIM3中断,如果被六路温度读取阻塞,会丢回波,所以我一般把测温放在主循环低优先级位置,测距用定时器周期性触发。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询