脉宽测量这话题,做嵌入式的十有八九都碰过。遥控器信号解析、舵机占空比检测、PWM 输出回读校验,甚至测电机转速的脉冲宽度,说到底都是在问同一个问题:这个高电平到底持续了多久。做方案的人也不少,有人直接拿外部中断加一个定时器软计时,也有人直接上逻辑分析仪,但在 MCU 内部用定时器的硬件输入捕获,才是精度和实时性都划算的做法。这篇是输入捕获系列的第六篇,专门把“脉宽测量”这件事掰开讲,从输入捕获原理到 CubeMX 参数计算,再到 HAL 库中断回调里的状态机实现,最后把我实际调试中踩过的坑提前给你排一遍。适合手里有 STM32F4 开发板、想用正经方式测信号时间的同学,也适合已经跑通例程但搞不清楚参数怎么调的人。
1. 输入捕获测脉宽,到底测的是什么
1.1 一句话讲清楚输入捕获
定时器里最核心的就是 CNT(计数器)、PSC(预分频器)和 ARR(自动重载值)这三个寄存器。CNT 按预分频之后的时钟不断加一,ARR 决定加到哪里再翻回零重新来。拿生活里的事情打比方,ARR 就是表盘的刻度上限,CNT 就是当前指针指到哪。
输入捕获干的事情,就是在 CNT 正常跑数的过程中,给计数器装了一个“按快门”的功能:当输入引脚检测到指定边沿时,硬件立刻把 CNT 的当前值复制一份到捕获/比较寄存器 CCR,同时通知 CPU。注意一个关键点:这个“复制”动作是纯硬件完成的,不需要 CPU 参与,所以锁存的时刻非常准,误差只来自时钟本身的精度和边沿检测电路做同步化时引入的微小延迟。
很多人一上来就纠结中断里代码是不是够快,其实输入捕获和这个关系不大。硬件锁存发生在边沿瞬间,中断只是来通知“你赶紧把 CCR 里的数据取走”,你早几微秒取、晚几微秒取,都不影响锁存那一刻对应的真实时间点。这一点是整个测量方案的地基,后边所有精度分析都围绕它展开。
1.2 和外部中断软计时法有什么区别
有朋友会问:那我用 EXTI 外部中断,在中断里读 HAL_GetTick(),不是也能测脉宽吗?能测,但硬伤在于中断响应的不确定性。外部中断进入中断服务函数、读取时间戳,这中间隔着中断延迟、压栈、总线访问时间,每次都未必一样,误差几微秒起步。信号快一点、频率高一点,这个方案就明显吃力了。
输入捕获则没有这个问题。硬件在边沿那一刻就把计数器拍进 CCR,中断闹钟响了之后你去取,取到的是早就锁存好的“历史快照”。所以哪怕系统负载很高、中断被稍微延迟,只要别延迟到定时器溢出好几轮,测量数据本身依然是准的。这就是为什么正经测量类项目都优先选输入捕获。
1.3 测脉宽最常见的两条技术路线
实际写代码时,测高电平脉宽有两种主流做法,我先做个对比,后边代码部分会按路线 A 展开。
路线 A 是两个边沿各自捕获、后值减前值。上升沿来的时候读一次 CCR,记成 t1;下降沿来的时候再读一次 CCR,记成 t2;高电平时间就等于 t2 减 t1。因为两次读数都是硬件在边沿瞬间锁存的,中断延迟不会影响结果,精度最好。代价是要处理两次捕获之间定时器溢出的问题。
路线 B 是“清零法”。上升沿触发中断后,CPU 手动把 CNT 清零,等下降沿来的时候,CCR 里的值直接就是高电平时长。这样中短代码非常直观,省去了相减的步骤。但要注意,手动清零这个动作发生在中断响应之后,也就是说清零的时刻比真实上升沿晚了零点几到几微秒,测出来的值会系统性偏小这么一点。对舵机信号这种毫秒级别的东西无所谓,做微秒级精密测量就不太合适了。
| 对比项 | 路线A(两次读取相减) | 路线B(捕获后清零) |
|---|---|---|
| 精度 | 高,硬件锁存边沿瞬间 | 略低,中断清零有系统性偏差 |
| 代码复杂度 | 稍复杂,需处理溢出 | 简单直观 |
| 适用场景 | 高频、高精度测量 | 毫秒级常规 PWM 测量 |
1.4 分辨率和量程,本质上是一对矛盾
配置输入捕获之前,最好先算一笔账。定时器计数频率越高,每个 tick 代表的时间越短,分辨率越高,但 ARR 是固定的话溢出就越快,单次测量能覆盖的时间范围就越短。
两个公式是绕不开的:
- 计数频率 = 定时器时钟 /(PSC + 1)
- 单个 tick 时间 = 1 / 计数频率
- 最大单帧时间 =(ARR + 1)× 单个 tick 时间
以 84MHz 定时器时钟为例。PSC 设 83,计数频率就是 1MHz,一个 tick 是 1us,16 位定时器 ARR=65535 时最大能测 65.535 毫秒;PSC 设 0,计数频率 84MHz,一个 tick 约 11.9 纳秒,但最大测量范围只剩约 780 微秒。
所以千万别指望一套参数通吃所有信号。测窄脉冲、高频信号,尽量拉高计数频率;测 50Hz 舵机信号这种周期 20 毫秒的东西,就得压低计数频率或者换 32 位定时器。真要在一条白纸上写结论:先确认你要测的脉宽大概是什么量级,再定 PSC,顺序不能反。
2. CubeMX 配置与参数计算
2.1 选定时器前先搞清楚时钟树
这次用的板子是 STM32F407VET6,HAL 库配套 CubeMX。先把时钟树捋一遍:SYSCLK 168MHz,AHB 分频后 168MHz,APB1 是 42MHz,APB2 是 84MHz。
很多人在这里栽过跟头:明明 PSC 写的是 83,怎么计数频率不是自己想当然的那个数?原因出在定时器时钟的倍频规则上。STM32 的规则是,当 APB 分频系数不等于 1 时,挂在对应总线上的定时器时钟会翻倍。APB1 分频 /4,所以挂在 APB1 上的 TIM3~TIM7 实际时钟是 42×2=84MHz;APB2 分频 /2,TIM1、TIM8 的实际时钟是 84×2=168MHz。
所以你在 CubeMX 的 Clock Configuration 页面里会看到 Timer3 clock 显示成 84MHz。这个数字是后面计算 PSC 的地基,搞错了全盘皆输。
2.2 参数计算:1us 分辨率的完整推导
做一个“测遥控器 PWM 高电平时长”的经典需求,这类信号高电平一般 1 到 2 毫秒,周期 20 毫秒。我想让分辨率达到 1us,量程至少要覆盖 10 毫秒。
定时器时钟 84MHz,要达到 1us 一个 tick,计数频率就得是 1MHz,所以 PSC = 84 ÷ 1 - 1 = 83。ARR 用 16 位定时器的最大值 65535,最大单帧测量时间就是 65536us,约 65.5 毫秒,覆盖 20 毫秒的周期绰绰有余。
CubeMX 里 TIM3 的配置这样填:
- Clock Source 选 Internal Clock
- Channel1 选 Input Capture Direct Mode
- Prescaler 填 83
- Counter Period 填 65535
- Auto-Reload Preload 选 Enable
- Input Filter 先填 0,不加滤波
- 初始极性选 Rising,上升沿开始
为什么不直接选 PWM Input 模式?PWM Input 是硬件上把一个输入同时映射到两个通道,专门用来测周期和占空比,确实方便。但这次是输入捕获专题的第六篇,而且手动状态机可以自由决定测高电平、测低电平、测周期、测单次脉冲,灵活性高得多。PWM Input 模式适合标准占空比检测,做通用测量工具还是状态机更顺手。
2.3 GPIO 和中断配置的细节
选好定时器之后,引脚是自动分配出来的。TIM3_CH1 对应 PA6,复用功能是 AF2。CubeMX 里把 PA6 点成绿色 Timer 功能即可,GPIO 模式会变成 Alternate Function。
上下拉要不要配,取决于外部信号源。如果输入信号是推挽输出的方波,连接关系干净,No Pull 就行;如果信号源是开漏输出,或者杜邦线比较长、环境有干扰,配一个 Pull-Up 能减少悬空误触发。输入模式下的 GPIO 速度不用管,没有意义。
NVIC 设置里要记得把 TIM3 global interrupt 勾上,否则捕获中断根本不会触发。优先级我给 Preemption=2、Sub=0,不跟 SysTick 抢,也不用刻意给到最高。真到了需要极端实时性的场景,更应该想的是怎么减少中断里的工作量,而不是单纯把优先级调到最高。
3. HAL 库代码实现:启动、回调、状态机
3.1 全局变量和启动函数
在 main.c 的 USER CODE 区域定义一组全局变量。注意加到回调里会被中断上下文访问的变量,声明成 volatile,防止编译器优化出问题。
volatile uint32_t g_rise_tick = 0; volatile uint32_t g_fall_tick = 0; volatile uint32_t g_high_ticks = 0; volatile uint8_t g_edge_state = 0; volatile uint8_t g_measure_ready = 0;启动函数这样写。核心是先把计数器清回零,再从上升沿开始等:
void InputCapture_Start(void) { __HAL_TIM_SET_COUNTER(&htim3, 0); g_edge_state = 0; g_measure_ready = 0; __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); HAL_TIM_IC_Start_IT(&htim3, TIM_CHANNEL_1); }这里最容易错的是 HAL 函数选错。要启动的是 HAL_TIM_IC_Start_IT,不是 HAL_TIM_IC_Start。前者是“启动捕获同时开中断”,后者只启动捕获、不开中断,回调永远不会被调用。我见过好几个朋友在这俩函数上卡了半小时。
3.2 捕获回调:一个简单的两态状态机
HAL 库里 HAL_TIM_IC_CaptureCallback 是一个弱函数,CubeMX 生成的代码不会占用这个名字,直接复制到 main.c 里重定义即可。
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; if (htim->Channel != HAL_TIM_ACTIVE_CHANNEL_1) return; if (g_edge_state == 0) { /* 上升沿到达:记录当前计数器值 */ g_rise_tick = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); /* 立刻切换极性,等待下降沿 */ __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); g_edge_state = 1; } else { /* 下降沿到达:记录当前计数器值 */ g_fall_tick = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); /* 相减得到高电平 tick 数,1MHz 时就是微秒 */ g_high_ticks = g_fall_tick - g_rise_tick; g_measure_ready = 1; /* 极性切回上升沿,准备测下一轮 */ __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); g_edge_state = 0; } }第一次进入回调时,捕获到的是上升沿,HAL_TIM_ReadCapturedValue 读回来的其实是硬件在上升沿瞬间锁存的 CNT 值。紧接着把捕获极性切到下降沿,状态置 1。等到下一次捕获中断进来,必然是下降沿,读出的值就是边沿对应的 CNT,两者相减就是高电平持续时间。
主循环里消费测量结果就行:
if (g_measure_ready) { g_measure_ready = 0; printf("high = %lu us\r\n", (unsigned long)g_high_ticks); }这里的限制也顺便说清楚:如果被测脉宽太短,比如小于 1us,下降沿可能在中端处理上升沿的过程中就到了,极性还没切过去,下降沿就被漏掉了。这种极端窄脉冲,要么用直接寄存器操作减少中断里的指令数,要么干脆换双通道方案,不要硬扛。
3.3 周期和占空比同时测量怎么扩展
很多时候不只要测高电平,还要同时测出周期和占空比。那就把状态机从两态扩成三态:等第一个上升沿、等下降沿、等第二个上升沿。
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; if (htim->Channel != HAL_TIM_ACTIVE_CHANNEL_1) return; switch (g_edge_state) { case 0: /* 等第一个上升沿 */ g_t1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); g_edge_state = 1; break; case 1: /* 等下降沿 */ g_t2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); g_edge_state = 2; break; case 2: /* 等第二个上升沿 */ g_t3 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); g_high_ticks = g_t2 - g_t1; g_period_ticks = g_t3 - g_t1; g_duty_percent = (uint32_t)((uint64_t)g_high_ticks * 100u / g_period_ticks); g_measure_ready = 1; g_edge_state = 0; break; } }占空比计算时注意两个坑。一是先乘后除,防止小数部分被吞掉;二是在做乘法时用 uint64_t 中转,避免整数溢出。比如某个定时器配置下计数范围比较大,65536 乘以 100 已经有 6 位数量级了,再大就容易超过 32 位上限。这个习惯养成了,以后换 32 位定时器也不会踩雷。
3.4 计数器溢出:突破 65.5ms 上限
前面的代码里,如果高电平时间超过定时器最大量程,计数器就会在两次边沿之间溢出翻零,导致 g_fall_tick 小于 g_rise_tick,计算结果直接变成一坨乱码。解决办法有三个,按省事程度排序。
第一推荐换 32 位定时器。STM32 的 TIM2 和 TIM5 是 32 位计数器,84MHz 时钟下最大量程约 51 秒,绝大多数应用根本用不到溢出处理,代码量最少。第二是把 PSC 调大,牺牲分辨率换量程。第三才是写溢出计数逻辑。
如果一定要在 16 位定时器上做,可以在更新中断里维护一个溢出计数器:
volatile uint32_t g_tim3_overflow = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { g_tim3_overflow++; } }然后在捕获回调两边沿分别记录当时的溢出计数值。计算时取值范围加上溢出补偿:
uint32_t delta = g_fall_tick - g_rise_tick; uint32_t ovfs = g_ovf_fall - g_ovf_rise; g_high_ticks = ovfs * 65536u + (delta & 0xFFFFu);这个写法在“下降沿 CNT 小于上升沿 CNT、中间刚好溢出一次”的情况下也是正确的。真正较真的场景,比如边沿和溢出恰好挤在同一个时钟周期,还要去处理 UIF 标志的竞态,这里不展开,实际工程很少触发到这么极端的时序。
4. 实测数据与精度分析
4.1 信号发生器实测记录
配置跑通之后,我用信号发生器输出方波,分别测了 100Hz、1kHz、10kHz、100kHz 四个频率点,占空比 50%。用 1MHz 和 84MHz 两种计数频率做了对比,实测数据如下:
| 信号设定 | 理论高电平 | 1MHz 计数实测 | 84MHz 计数实测 |
|---|---|---|---|
| 100Hz / 50% | 5000us | 4999~5001us | 5000.03us 附近 |
| 1kHz / 50% | 500us | 499~501us | 500.02us 附近 |
| 10kHz / 50% | 50us | 50~51us | 50.01us 附近 |
| 100kHz / 50% | 5us | 5~6us | 5.00us 附近 |
可以看到 1MHz 分辨率下误差基本被 ±1us 的量化误差主导,而 84MHz 分辨率下读数稳定很多。这个对比很有参考价值:不要一上来就把 PSC 调到最大,先看看你的信号频率再决定分辨率。
4.2 误差到底从哪里来
把误差拆开看,主要有四个来源。
时钟源误差是最容易被忽略的一个。芯片内部 HSI 振荡器在不同温度下误差可能到 1%~3%,也就是说测 500us 的脉宽,光因为时钟不准就可能偏好几个微秒。这不是测量逻辑的问题,是时钟本身的系统性误差。换外部晶振 HSE 之后,误差可以降到几十 ppm 级别。
量化误差是绕不开的。计数频率 1MHz 时,一个 tick 就是 1us,测量结果天然会有 ±1us 的量化偏差。这个误差和信号相位有关,没办法消除,只能通过提高计数频率来减小。
边沿同步延迟是硬件层面的细节。输入信号要先经过引脚同步电路,定时器时钟的上升沿去采样这个信号,所以检测到边沿的实际时刻可能比真实边沿晚了 0 到 1 个时钟周期。84MHz 时钟下最多 12ns 左右,大多数场景可以忽略,做极端高精度测量时心里有数就好。
最后就是外部信号本身的问题。信号发生器输出干净方波当然好,实际项目里的信号可能带振铃、带噪声、接触不良,边沿附近来回跳变。这种情况测量值会忽大忽小,需要配合施密特整形电路,或者在 CubeMX 的 Input Filter 里设置滤波档位。
4.3 让测量更稳的几个实操建议
第一,能用外部晶振就别用 HSI。成本上只多一个晶振,精度却差了几十倍,这笔账怎么都划算。
第二,计数频率不要拍脑袋填。先拿示波器看一眼信号,估算脉宽范围,再根据公式反推 PSC。信号是微秒级就上 84MHz,信号是毫秒级就用 1MHz,量程和精度两头兼顾。
第三,多次测量取平均或者取中值。偶然的干扰脉冲对单次测量影响很大,连续采 8 次去掉最大最小再平均,数据立刻变得好看。
第四,中断里只做“读寄存器、切状态、置标志”,把计算、打印、存储全部挪到主循环。这个原则能省掉 90% 的“数据偶尔跳一下”问题。
5. 常见问题速查与避坑清单
5.1 捕获中断进不去、回调不执行
这一类问题占初学者踩坑的一半以上,排查顺序很重要。
先查 NVIC 使能。CubeMX 里如果没勾选 TIM3 global interrupt,后面无论怎么调代码都不会进中断。再查启动函数用的是不是 HAL_TIM_IC_Start_IT,而不是 HAL_TIM_IC_Start,这两个接口功能差了一个“中断开关”。再查回调函数是不是写对了位置,HAL 库里它是弱函数,你自己重定义的时候签名一个字母都不能错。
然后是 GPIO。PA6 必须配置成复用功能 AF2 才能连接到 TIM3_CH1。如果 CubeMX 里被配置成了普通输出或者外部中断,捕获链路是断的,信号进不去定时器。最后检查信号电平,PA6 是 3.3V 逻辑,别的设备输出 5V 甚至 12V,轻则测不到,重则烧引脚。
5.2 数值偶尔跳变、明显不合理
先看信号本身。杜邦线悬空、面包板接触不良、电机干扰耦合,都会让边沿附近出现毛刺。有条件就上示波器看波形,没条件就在 CubeMX 里把 Input Filter 设成 2 或 3,能滤掉一部分窄毛刺,但代价是边沿会有轻微延迟。如果信号源太脏,还是老老实实加硬件整形。
再看中断里的工作量。如果在回调里直接做浮点运算或调用 printf,中断时间一长,下一次边沿可能就错过了。漏掉一次下降沿的结果是,下一次捕获到的下降沿其实是下一个周期的,算出来的“脉宽”会大得离谱。代码规范做法是回调里只置标志,测量结果的计算放主循环。
如果用的是清零法,还会遇到一个“貌似正常但永远偏小”的现象:因为清零指令比真实边沿晚执行了几微秒,测出来的高电平时间系统性偏小。这种问题改路线 A 就好了。
5.3 计算结果翻车,多半是单位或类型问题
单位换算错误是最高频的翻车现场。1MHz 计数频率下,tick 数就是微秒数,没问题;但你把 PSC 改成别的值以后,别忘了一个 tick 已经不再是 1us。84MHz 时每 tick 约 11.9ns,直接拿 tick 数当微秒,误差会放大到几十倍。
类型问题也很隐蔽。虽然 HAL_TIM_ReadCapturedValue 返回的是 uint32_t,但如果你在中间步骤把它存进了一个 uint16_t 变量,高位被截断,结果自然不对。特别是 16 位定时器,CCR 本身是 16 位,但差值计算和溢出补偿必须用 32 位来做。
Keil 用户注意 printf 浮点输出。默认情况下不开启 MicroLIB 的话,%f 可能输出空或者乱码,而且浮点打印本身极慢。测出来的时间想带小数点,建议先输出整数 tick,再在协议层做换算,不要直接在主循环里 %f。
5.4 排查工具与调试顺序
我的习惯是自下而上验证。第一步先用信号发生器输出一个已知频率的方波接到 PA6,排除外部信号源的问题。第二步用逻辑分析仪确认信号确实到了引脚,排除接线问题。第三步在回调里置一个全局标志位,主循环轮询看标志有没有变化,排除中断链路问题。第四步直接打印原始 CCR 值,手动算一遍,和工具读数对照。
这套顺序走完,大多数“测不到”和“测不准”都能定位到具体环节。没有信号发生器的话,就用板子上另一个定时器输出一路标称 1kHz 的 PWM,接到 PA6 上自测。这个土办法我至今还在用,十分钟内就能把测量代码验证得明明白白。
6. 工程扩展:多通道、DMA 与串口上报
6.1 同一定时器两个通道分别测脉宽
一块板子上经常要同时测多路信号。同一定时器的不同通道共享同一个 CNT 和 ARR,但捕获和极性设置是各自独立的,所以完全可以在 TIM3_CH1 和 TIM3_CH2 上分别跑自己的状态机。
回调里用 htim->Channel 区分来源:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { /* CH1 的状态机 */ } else if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_2) { /* CH2 的状态机 */ } }要注意两个通道共用计数频率,如果两路信号的脉宽量级差异太大,比如一个几微秒、一个几十毫秒,同一个 PSC 很难同时兼顾两者的分辨率和量程。这种场景建议拆成两个定时器分别处理,各自配参数。
6.2 DMA 方式连续采集
HAL 还有一组 HAL_TIM_IC_Start_DMA 接口,可以让每次捕获的 CCR 值自动搬进内存缓冲区,不占用 CPU 时间。它的局限性在于,DMA 是按照同一个捕获极性连续采的,适合测连续上升沿的周期,不能自动区分高低电平。
要做真正的“周期+脉宽”连续采集,更合适的搭配是 PWM Input 模式配合 DMA,或者用两个通道分别配置上升沿和下降沿捕获。这套玩法以后有机会单独写一篇,这里先记住一个结论:中断方案适合单点测量和低频场景,DMA 方案适合高速连续采集。
6.3 用串口把测量结果发出来
实际产品里测量数据最终要给别人看,最常见的是串口输出。主循环里加几行打印:
printf("CH1: high=%lu us, period=%lu us, duty=%lu%%\r\n", (unsigned long)g_high_ticks, (unsigned long)g_period_ticks, (unsigned long)g_duty_percent);前提是先做好 printf 重定向,把 fputc 指向你的串口外设。数据量大的时候,建议用带帧头的二进制协议,比如“帧头 + 长度 + 数据 + 校验”,比 ASCII 文本稳定得多,解析也快。上位机那边用串口助手或者 Python 脚本都能处理。
最后分享一个我自己的习惯:凡是测脉宽、测频率、测占空比这类时序量,先用 HAL 把整条链路跑通,再去想怎么用寄存器抠那几微秒。真正对性能敏感的部分,优先选硬件方案而不是优化中断代码。手里没示波器时,用另一个定时器输出标称 1kHz 的 PWM 接到输入捕获引脚,能让你的测量代码在十分钟内被验证得明明白白——这个土办法我用了很多年,一直没淘汰。