1. 从零开始:为什么你需要高精度脉宽检测?
大家好,我是老李,一个在嵌入式领域摸爬滚打了十多年的工程师。今天想和大家聊聊一个在项目里特别常见,但又让不少新手朋友头疼的问题:怎么精确地测量一个脉冲信号的高电平到底持续了多久?比如,你想知道一个按键被按下的确切时长,或者一个红外接收头收到的信号脉宽,又或者是旋转编码器输出的脉冲宽度。这些场景下,你需要的工具就是输入捕获。
你可能听说过用外部中断加定时器计数的方法,我也试过,但实测下来,这种方法在信号频率稍高或者需要同时处理其他任务时,精度和稳定性就很难保证了,代码写起来也啰嗦。而STM32片内自带的通用定时器,其输入捕获功能就是为这种“掐表”任务量身定做的硬件外设。它能在信号边沿到来的瞬间,自动“冻结”当前定时器的计数值,几乎不占用CPU资源,精度直接取决于你的系统时钟,轻松达到微秒甚至纳秒级。
对于咱们STM32开发者来说,STM32CubeMX这个图形化配置工具简直是福音。它把复杂的定时器寄存器配置变成了直观的勾选和填参数,大大降低了入门门槛。但工具好用,不等于没有坑。我见过不少朋友在CubeMX里配好了定时器,代码也生成了,但就是抓不到正确的脉宽,或者数值跳得厉害,最后只能对着代码干瞪眼。
这篇文章,我就以最常用的通用定时器TIM2/TIM3/TIM4/TIM5为例,手把手带你用STM32CubeMX配置输入捕获功能,实现一个高精度的脉宽测量“仪表”。我会把配置里每个参数的含义、中断回调函数里每一行代码的逻辑,还有我实际调试中踩过的那些坑,都掰开揉碎了讲清楚。目标就一个:让你看完就能在自己的板子上跑起来,真正理解原理,而不是仅仅照抄一遍代码。
2. 硬件连接与CubeMX工程搭建
2.1 硬件准备与信号接入
动手之前,咱们先得把硬件理清楚。我这次用的是STM32F103C8T6核心板,它自带一个用户按键(KEY),通常连接在PA0引脚上。巧的是,TIM5的通道1(CH1)的输入引脚正好也是PA0。所以,我们这次实验的硬件连接简单到令人发指:什么都不用接!直接用手指去按那个按键,产生的电平跳变信号就会送入TIM5进行捕获。这非常适合新手快速验证。
当然,在实际项目中,你的信号可能来自传感器、通信模块或者其他MCU。这时,你只需要记住一个原则:将待测的脉冲信号线,连接到你所选定时器对应通道的GPIO引脚上。怎么查对应关系呢?有两个好方法:一是查阅你所使用STM32型号的官方数据手册(Datasheet)中的“Pinouts and pin description”章节;二是在CubeMX的引脚分配图上,直接搜索定时器名(如TIM5),它会高亮显示所有相关的引脚,你选择一个标有“CHx”的即可。
这里有个小经验分享:如果待测信号来自板外,电平标准是3.3V吗?如果不是(比如5V TTL),务必注意电平转换,别把MCU的IO口烧了。另外,对于长导线引入的噪声,可以在引脚附近增加一个几十皮法的小电容到地,做简单的滤波。
2.2 创建工程与时钟树配置
打开STM32CubeMX,点击“New Project”,在芯片选择器里输入你的型号(比如STM32F103C8),选中并开始项目。
第一个关键步骤来了:配置时钟。很多时序问题,根子都出在时钟没配对。对于F1系列,我们通常使用外部高速晶振(HSE)。在“Pinout & Configuration”界面,找到“RCC”设置,将“High Speed Clock (HSE)”设置为“Crystal/Ceramic Resonator”。
然后点击上方“Clock Configuration”标签页,进入时钟树图。这里看起来复杂,但有个诀窍:在“HCLK”输入框里直接键入你想要的系统主频,比如72(代表72MHz),然后回车。CubeMX会自动帮你计算并填充其他分频系数,确保整个时钟树合法。你会看到,APB1总线时钟(PCLK1)被设置为36MHz。这里有个非常重要的知识点:STM32的定时器时钟源,如果它的APB预分频器系数不是1,那么定时器实际得到的时钟是APB时钟的2倍。因为PCLK1这里是2分频(36MHz),所以挂载在APB1下的TIM2~TIM5,其内部时钟(CK_INT)实际上是72MHz。这个72MHz,就是我们后续计算时间基准的源头。
配置完时钟,记得在“SYS”调试设置里,把“Debug”选为“Serial Wire”。这步非常重要,否则用ST-Link烧录一次程序后,芯片可能就被锁住,无法再次下载和调试了,很多人都在这里栽过跟头。
3. 深入核心:定时器输入捕获参数详解
3.1 定时器模式与通道配置
在“Pinout & Configuration”界面的左侧,找到“Timers”并展开,选择你要用的定时器,比如TIM5。在右侧的配置面板中,首先需要选择时钟源。对于基本的输入捕获功能,我们使用内部时钟即可,所以“Clock Source”选择“Internal Clock”。
接下来是关键操作:配置通道功能。在“TIM5”的通道1(Channel1)下拉菜单中,选择“Input Capture direct mode”(输入捕获直接模式)。这个“直接模式”意味着捕获信号直接连接到定时器的输入部分,没有经过任何额外的交叉连接,是最常用也最简单的模式。选择之后,你会发现原理图上PA0引脚被自动配置为复用功能(Alternate Function),并且旁边出现了“TIM5_CH1”的标签,这说明硬件关联已经建立好了。
3.2 参数设置:精度与量程的权衡
点击“Parameter Settings”标签,这里面的每一个参数都直接影响着测量的精度和范围。咱们一个一个来啃。
首先看“Prescaler (PSC - 16 bits value)”,这是预分频器。它的值决定了定时器计数时钟(CK_CNT)的频率。计算公式是:CK_CNT = 定时器时钟源 / (PSC + 1)。我们的定时器时钟源是72MHz。如果我们想让计数器每1微秒计一个数,那么CK_CNT就需要是1MHz。所以,PSC应该设置为(72MHz / 1MHz) - 1 = 71。填入71。这样,计数器每增加1,就代表时间过去了1微秒,非常直观。
然后是“Counter Mode”,计数模式。选择“Up”,即向上计数。计数器从0开始,一直加到我们设定的重装载值,然后产生溢出更新事件,再从0开始。
接着是“Counter Period (AutoReload Register - 16 bits value)”,自动重装载值(ARR)。这个值决定了计数器在溢出前能计多少个数。对于16位定时器,最大值是65535。我们这里就填65535。结合PSC=71,我们可以算一下定时器溢出一次的时间:T_out = (ARR+1) * (1 / CK_CNT) = 65536 * 1us = 65536 us ≈ 65.5 ms。这意味着,在不发生溢出的情况下,我们单次能测量的最大脉宽是65.5毫秒。如果脉宽超过这个值,我们就需要在代码中处理溢出计数。
下面“auto-reload preload”选项,建议先设置为“Disable”。这个功能是让ARR值在下次更新事件时才生效,对于简单的输入捕获,不使能更简单直接。
现在,滚动到下方的“Input Capture Channel 1”配置区。
- Polarity Selection:捕获极性。这是决定在哪种信号边沿触发捕获的关键。我们先设置为“Rising Edge”,即上升沿捕获。这意味着当PA0引脚上的信号从低电平跳到高电平时,定时器会瞬间把当前计数器的值锁存到捕获/比较寄存器(CCR1)中。
- IC Selection:保持“Direct”即可,与我们之前选择的模式对应。
- IC Prescaler:输入捕获预分频器。这个和上面的定时器预分频器(PSC)是两回事!它决定每隔多少个有效边沿才触发一次捕获。比如设置为“Every 2 events”就是每2个边沿捕获一次。我们做精确脉宽测量,需要捕获每一个边沿,所以选“No division”。
- IC Filter:输入滤波器。这个功能非常实用,可以设置一个数字滤波器,只有当信号稳定连续若干个时钟周期后,才认为边沿有效,能有效滤除高频毛刺。值越大,滤波效果越强,但会引入微小的延迟。对于机械按键这类抖动的信号,可以设置为4或8。对于干净的方波信号,设为0即可。我们先设为0。
3.3 开启中断:让CPU知道“抓到了”
硬件配置好了,但抓到的数据怎么通知CPU呢?靠中断。在“NVIC Settings”标签页(通常和参数设置在同一面板),找到“TIM5 global interrupt”,勾选“Enabled”。这样,当捕获事件或者定时器溢出更新事件发生时,就会触发中断,我们就能在中断服务函数里处理数据了。
至此,CubeMX的图形化配置全部完成。点击“Project Manager”标签,给工程起个名字,选好保存路径和IDE(比如MDK-ARM),在“Code Generator”里务必勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这样每个外设的代码会独立生成,结构清晰。最后,点击右上角的“GENERATE CODE”,生成工程代码。
4. 代码实战:中断逻辑与脉宽计算
4.1 理解CubeMX生成的代码框架
用Keil或你喜欢的IDE打开生成的工程。你会发现,tim.c文件里已经完整地初始化了TIM5,包括时钟、模式、通道和中断。main.c里也调用了MX_TIM5_Init()。我们需要做的,就是在合适的地方启动定时器,然后编写中断处理逻辑。
首先,在main.c的/* USER CODE BEGIN 2 */部分,启动输入捕获中断和更新(溢出)中断。
HAL_TIM_IC_Start_IT(&htim5, TIM_CHANNEL_1); // 启动TIM5通道1的输入捕获中断模式 __HAL_TIM_ENABLE_IT(&htim5, TIM_IT_UPDATE); // 使能TIM5的更新中断 printf("TIM Input Capture Test Start...\r\n");第一行函数不仅启动了捕获功能,还使能了捕获中断。第二行是单独使能更新中断,这个很重要,因为我们需要靠它来计数定时器溢出的次数,以测量长脉宽。
4.2 设计状态机与全局变量
输入捕获测量脉宽,本质是一个状态机:等待上升沿 -> 记录上升沿时刻 -> 等待下降沿 -> 记录下降沿时刻 -> 计算差值。为了在中断函数间传递信息,我们需要定义几个全局变量。我习惯在main.c或专门的文件里定义。
在main.c的/* USER CODE BEGIN PV */区域定义:
// 输入捕获状态机变量 [7]:完成标志 [6]:捕获到高电平标志 [5:0]:高电平期间的溢出次数 volatile uint8_t g_tim5_cap_sta = 0; // 捕获到的计数器值 (CCR1) volatile uint32_t g_tim5_cap_val = 0; // 高电平期间的总溢出次数 (用于扩展测量范围) volatile uint32_t g_tim5_overflows = 0;这里我用了volatile关键字,因为它会在中断中被修改,防止编译器做优化导致数据错误。g_tim5_cap_sta是一个状态寄存器,用位来标识状态,非常节省内存且高效。bit7为1表示一次完整的脉宽捕获完成;bit6为1表示当前已捕获到上升沿,正在等待下降沿;低6位(bit5~bit0)用来记录在等待下降沿期间,定时器溢出了多少次(最大63次)。
4.3 编写更新中断回调函数(处理溢出)
当计数器从ARR值翻转到0时,会触发更新中断。我们在其中处理溢出计数。这个函数需要我们自己实现。在stm32f1xx_it.c文件的末尾,/* USER CODE BEGIN 1 */区域添加:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM5) { // 如果已经成功捕获到上升沿,并且还没有完成一次完整捕获 if ((g_tim5_cap_sta & 0x40) && !(g_tim5_cap_sta & 0x80)) { // 溢出次数计数器加1 g_tim5_overflows++; // 如果溢出次数超过低6位能记录的最大值(63次),则认为高电平过长,强制标记完成 if ((g_tim5_cap_sta & 0x3F) == 0x3F) { g_tim5_cap_sta |= 0x80; // 标记完成(虽然可能是超时) g_tim5_cap_val = 0xFFFF; // 赋予一个最大值 } else { // 否则,将状态变量的低6位溢出计数加1 g_tim5_cap_sta++; } } } }这个函数只做一件事:如果当前正处于“已捕获上升沿,等待下降沿”的状态,那么每次定时器溢出,就给溢出计数器加1。同时,用一个6位的计数器做备份和超时判断。当溢出超过63次(约65.5ms * 64 ≈ 4.2秒),我们就认为信号可能出问题了,强制结束本次捕获。
4.4 编写输入捕获中断回调函数(处理边沿)
这是最核心的函数,负责在上升沿和下降沿到来时执行操作。在同一个区域继续添加:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM5 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { // 如果还没有完成一次完整的捕获 if ((g_tim5_cap_sta & 0x80) == 0) { // 情况1:当前是下降沿捕获(意味着之前已经捕获到上升沿) if (g_tim5_cap_sta & 0x40) { // 标记捕获完成 g_tim5_cap_sta |= 0x80; // 读取下降沿到来时捕获/比较寄存器(CCR1)的值 g_tim5_cap_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 重要:将捕获极性切换回上升沿,为下一次测量做准备 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); } // 情况2:当前是上升沿捕获(第一次捕获或新一轮开始) else { // 清空状态和值,准备新的测量 g_tim5_overflows = 0; g_tim5_cap_sta = 0; g_tim5_cap_val = 0; // 标记已捕获到上升沿 g_tim5_cap_sta |= 0x40; // 为了精确计时,在上升沿到来时,先将计数器清零 __HAL_TIM_SET_COUNTER(htim, 0); // 关键:将捕获极性设置为下降沿,这样下次中断就是下降沿触发的了 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } } } }这个函数的逻辑是状态机的核心。第一次进入(上升沿):清空历史数据,标记状态,清零计数器,然后立即将极性改为下降沿捕获。这样,当信号变成低电平时,会自动触发第二次中断。第二次进入(下降沿):读取此刻的计数器值,标记完成,并将极性改回上升沿,等待下一个脉冲。这个过程完全由硬件自动响应边沿,精度极高。
4.5 主循环中的脉宽计算与输出
最后,我们在main.c的while循环中,检查捕获完成标志,并计算最终的脉宽。
extern volatile uint8_t g_tim5_cap_sta; // 声明外部变量 extern volatile uint32_t g_tim5_cap_val; extern volatile uint32_t g_tim5_overflows; while (1) { HAL_Delay(100); // 适当延时,不必太快 if (g_tim5_cap_sta & 0x80) { // 检查是否完成一次捕获 uint32_t total_ticks; // 计算总的计数值 = 溢出周期数 * 一个周期的计数值 + 下降沿时的计数值 // 注意:g_tim5_overflows 记录的是上升沿到下降沿之间完整的溢出次数 // g_tim5_cap_sta & 0x3F 记录的是最后一次溢出后,到下降沿之间可能的额外溢出(由更新中断回调处理) // 这里为了简化,我们用一个扩展的32位溢出计数器。更严谨的做法是合并处理。 total_ticks = (uint32_t)g_tim5_overflows * 65536UL + g_tim5_cap_val; // 将计数值转换为时间(微秒)。因为我们设置PSC=71,CK_CNT=1MHz,1个tick就是1us。 // 如果你的时钟配置不同,这里需要换算。例如:time_us = total_ticks * (1.0 / 定时器频率_MHz); printf("Pulse Width: %lu us\r\n", total_ticks); // 重置状态,准备下一次测量 g_tim5_cap_sta = 0; g_tim5_overflows = 0; } }编译、下载到开发板,打开串口助手。每当你按下并松开连接在PA0上的按键,串口就会打印出这次按键高电平持续的微秒数。你可以尝试快速点按和长按,看看测量的数值是否准确。
5. 精度提升与常见问题排查
5.1 如何进一步提高测量精度?
做到上面那步,你已经得到了一个可用的脉宽测量工具。但如果你对精度有极致要求,比如要测几百纳秒的脉冲,或者长时间测量的稳定性,下面这些技巧就很有用了。
第一,优化时钟源。系统时钟的稳定性是根本。如果使用内部RC振荡器(HSI),精度和温漂相对较差。尽量使用外部晶振(HSE)。对于需要极高时间基准的应用,甚至可以考虑外接温补晶振(TCXO)或恒温晶振(OCXO)。
第二,减小中断延迟的影响。我们的测量中,在上升沿中断里做了“清零计数器”的操作。从边沿触发到CPU执行__HAL_TIM_SET_COUNTER这条指令,是有微小延迟的(中断响应时间+指令执行时间)。对于非常窄的脉冲,这个误差比例就大了。一种高级的用法是定时器的“从模式”中的“复位模式”,可以将输入信号配置为触发源,让硬件在上升沿自动清零计数器,完全绕过软件中断延迟,精度最高。但这需要更复杂的配置。
第三,使用更高位的定时器或拼接。STM32有些型号带有32位定时器(如L4系列的LPTIM),其ARR值巨大,单次不溢出的测量范围很广。如果没有,可以用两个16位定时器主从级联,形成一个32位定时器。
第四,注意GPIO速度与输入滤波的平衡。在CubeMX的GPIO配置中,可以设置引脚速度。对于高速脉冲信号,将速度设置为“High”可以减少信号传输延迟。但同时,要合理使用输入捕获通道的滤波器(IC Filter),滤除毛刺,但需知滤波会引入几个时钟周期的延迟,这个延迟在数据手册里有公式可以计算,在要求绝对时间间隔的测量中需要扣除。
5.2 调试中遇到的坑与解决方案
问题一:根本进不了捕获中断。
- 检查:NVIC中断是否使能?
HAL_TIM_IC_Start_IT函数调用了吗?GPIO引脚模式是否正确配置为复用模式(AF)?可以用示波器或逻辑分析仪看看信号是否真的送到了引脚上。 - 我的经验:曾经因为贪图方便,在CubeMX里配置了定时器,但后来又在代码里手动初始化了GPIO,把引脚模式覆盖成了普通输出,导致信号进不来。务必保证CubeMX生成后,除非在
/* USER CODE BEGIN */区域内,否则不要动底层硬件初始化代码。
问题二:测量的脉宽值总是少了几微秒。
- 检查:这很可能就是上面提到的“中断延迟”和“滤波器延迟”。测量一个已知频率的精准方波(比如由另一个定时器PWM生成),看误差是否固定。如果是固定的,可以在计算结果中加上这个偏差值进行软件补偿。查看数据手册中输入滤波器的延迟公式。
问题三:长脉宽测量不准,数值跳动大。
- 检查:溢出处理逻辑是否正确?
g_tim5_overflows变量是否被声明为volatile?在更新中断和捕获中断中是否都正确更新了它?对于非常长的脉宽(秒级),32位的g_tim5_overflows也可能溢出,这时可以考虑用64位变量,或者在主循环中更频繁地读取并累积。 - 我的经验:在早期版本代码中,我曾把溢出计数放在更新中断里自增,但读取在主循环。如果脉宽期间有更高优先级的中断长时间阻塞,可能导致主循环读取的溢出次数少于实际次数。确保中断服务函数执行时间尽可能短。
问题四:同时测量多个通道脉宽,数据混乱。
- 检查:每个捕获通道是否使用了独立的全局状态变量?在中断回调函数中,是否通过
htim->Channel准确判断了是哪个通道触发的中断?STM32的同一个定时器的不同通道,中断服务函数是同一个,必须靠软件区分。
最后,拿出你的开发板,打开CubeMX和IDE,跟着步骤一步步操作、调试。遇到问题,先看硬件(信号、连线),再看软件(配置、中断开关、变量作用域)。脉宽检测是嵌入式系统感知外部世界时间信息的基础技能,把它吃透,以后玩编码器、红外遥控、PWM解码都会轻松很多。