
简介面向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校验多项式是X8X5X41网上的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_CAPTUREPOLARITYHAL库写法略有不同如果编译报错就用__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接PA0TRIG接PA1ECHO接PA2。DS18B20数据线加一个4.7kΩ上拉电阻到3.3V。HC-SR04模型在Proteus元件库里不一定找得到同名组件常见做法是用信号发生器模拟ECHO回波或者用现成的第三方仿真库我这边直接用虚拟仪器搭触发信号用按钮加电阻模拟回波用脉冲发生器给定宽度的方波先验证定时器捕获逻辑对不对。晶振按F103典型配置设为8MHzProteus里别忘了给芯片供电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中断如果被六路温度读取阻塞会丢回波所以我一般把测温放在主循环低优先级位置测距用定时器周期性触发。本文还有配套的精品资源点击获取