工程实践指南)
简介这是一套基于STM32微控制器的频率测量与相位跟踪工程资源面向嵌入式系统开发初学者及进阶工程师适用于电机控制、逆变器同步、振动监测等需要实时信号的场合。工程利用定时器输入捕获和内置ADC采样实现对周期性信号周期计数、频率换算以及多路相位差计算相关代码和配置文档覆盖定时器预分频、自动重装载寄存器设置、捕获中断服务程序、频率计算公式推导、多通道ADC采样、DMA连续读取以及相位差动态调整等关键环节。压缩包共316个文件仅512KB以C语言源码为主包含74个.c源文件与80个.h头文件同时提供Keil工程配置文件、编译生成的hex/axf/map等调试输出、备份文件与一键清理脚本目录结构清晰便于直接导入工程查看硬件初始化和中断处理逻辑。这套资源已有1391人浏览学习适合希望在真实工程中理解STM32定时器与ADC协同工作的读者参考。1. 频率测量与相位跟踪在STM32上到底解决什么问题做过逆变器并网或者电机转速闭环的人应该都遇到过信号频率漂移带来的麻烦输入频率在 49.8Hz 到 50.2Hz 之间缓慢波动如果控制环路的参考信号还死板地停在 50Hz相位误差就会越积越大最终导致输出电流畸变甚至保护跳闸。这个标题本质上是在 STM32 上做一个数字锁相环DPLL用定时器输入捕获测量待测信号的频率再用 PI 控制器加数控振荡器NCO持续跟踪它的相位让测量和输出始终咬合在一起。适合做仪器仪表、电机驱动、电源同步采样、超声换能器驱动的开发者。它能解决的关键问题不是“测一个频率值”而是让系统在频率实时变化时依然保持相位同步这比单纯测频难一个量级。2. 测量方案选型输入捕获、外部计数与PWM输入怎么选2.1 三种测频路径的原理对比STM32 的定时器外设提供了多条测频路线每条路线的硬件资源消耗和适用频段完全不同。我在实际项目中常用到的是输入捕获、外部时钟计数和 PWM 输入三种模式它们本质上的差异如下表方案硬件机制典型频率范围精度影响因素CPU 开销输入捕获边沿触发把 CNT 锁存到 CCR相邻两次差值即周期1Hz ~ 100kHz 左右计数时钟频率、量化误差每个周期进一次中断外部时钟计数待测信号直接作为定时器时钟源CNT 由信号边沿驱动10kHz ~ 数十 MHz计数窗口精度窗口定时中断PWM 输入模式两个捕获通道同时捕获周期和占空比1Hz ~ 50kHz与输入捕获相同每个周期进一次中断输入捕获适合低频到中频信号因为它把信号周期映射为计数值频率越低计数值越大相对精度反而越高。外部时钟计数适合高频信号因为高频信号每个周期都能驱动 CNT 递增配合一个固定窗口比如 1ms读差值再除以窗口时间就能得到频率。PWM 输入模式本质上是输入捕获的一个变种只是同时占用了两个通道它能同时拿到周期和占空比适合电机霍尔信号、遥控器 PWM 解码这类需要二维信息的场景。2.2 根据信号特征选择测频方案我一般会先问三个问题再定方案。第一个问题是信号最高频率是多少如果几百 kHz 以内输入捕获足够如果到了 MHz 级别就必须用外部时钟计数因为此时信号一个周期可能只对应几十个计数 tick输入捕获的量化误差已经大到没法看。第二个问题是信号是否干净如果现场有电机、继电器这类干扰源输入捕获模式里一定要配输入滤波器而外部计数模式对毛刺更敏感因为每一个上升沿都会使 CNT 加一一个毛刺就是一次虚假计数。第三个问题是相位跟踪对频率估计的实时性要求锁相环需要每个输入周期都能拿到一次频率更新输入捕获天然满足这个节奏外部时钟计数则需要自己设计窗口定时。以我做过的一个模拟项目 X 为例被测信号来自一个光电编码器的测速输出频率范围 200Hz ~ 5kHz同时对相位锁定有硬要求。当时选了输入捕获方案没有用外部计数理由很简单——200Hz 已经低到外部计数在一个窗口内可能只有 200 个脉冲计数误差大而且每个窗口才更新一次频率锁相环的环路带宽被窗口频率卡死动态响应太慢。输入捕获则每个周期都能触发一次中断更新频率环路可以做得更激进。2.3 从CubeMX到寄存器定时器捕获的基础配置不管选哪条路输入捕获的底层配置都绕不开时基、捕获通道和中断这三块。先用 CubeMX 把定时器时钟配好比如 APB1 定时器时钟 72MHz预分频器设为 71 得到 1MHz 计数时钟这样每个计数 tick 对应 1 微秒周期计数值直接就是微秒数调试时很好换算。但预分频会牺牲时间分辨率被测频率 1kHz 时周期 1000 个 tick频率分辨率约 1Hz如果想提高分辨率就得不分频用 72MHz 直采但此时 ARR 最大只能到 65535对应最低可测频率约 1.1kHz。要做到宽范围测量常见做法是让预分频可切换低频段用低分频保精度高频段用高分频防溢出。我在代码里会保留一个全局变量记录当前分频值溢出或者周期值接近 ARR 上限时自动切换切换瞬间丢弃一次测量结果。这个逻辑看起来简单但第一次做的人往往漏了“丢弃切换瞬间数据”这一步导致频率曲线上出现一个突兀的跳点后面排查半天。3. 用输入捕获实现频率测量最小代码与精度提升三板斧3.1 输入捕获测频公式与量化误差边界输入捕获测频的核心公式只有一个相邻两次上升沿捕获的 CCR 差值就是信号周期对应的计数个数。设定时器计数时钟为 f_tick相邻捕获值为 C1 和 C2则信号频率为f_signal f_tick / (C2 - C1)这里的 f_tick 等于定时器时钟除以预分频 1。量化误差来自计数本身的分辨率测量结果可能偏差 ±1 个 tick。对 1kHz 信号、计数时钟 1MHz 来说周期 1000 个 tick误差约千分之一提高到 72MHz 计数时钟后周期 72000 个 tick误差降到十万分之一级别。所以结论很简单想要精度先把计数时钟拉高。但量化误差不是唯一的误差源。捕获中断里的处理延时会导致一个问题如果你在中断服务函数里花了几百个周期才读出 CCR虽然 CCR 是硬件锁存的不受软件延时影响但如果下一次捕获事件发生时中断还没处理完捕获标志会被覆盖吗实际上 STM32 的捕获事件会把新的 CCR 覆盖旧值同时置位 CC1IF 标志。如果你没来得及读旧值就会丢失一次捕获数据周期计算结果变成一个异常大或异常小的值。所以输入捕获中断服务函数里第一件事就是读 CCR其他计算全部放到中断外做。3.2 最小可运行代码单通道上升沿捕获下面这段代码是一个最小可运行的输入捕获配置基于 HAL 库。它完成的事只有一件TIM2 通道 1 在上升沿捕获计数时钟的 CNT 值中断里计算相邻两次捕获的差值作为周期。/* TIM2 通道1 输入捕获初始化计数时钟 1MHzARR 最大 0xFFFF */ void MX_TIM2_Init(void) { TIM_IC_InitTypeDef sConfigIC {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance TIM2; htim2.Init.Prescaler 71; /* 72MHz/(711)1MHz一个tick1us */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; /* 16位计数最长时间 65.535ms */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(htim2); sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; /* 上升沿触发 */ sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; /* 直连通道 */ sConfigIC.ICPrescaler TIM_ICPSC_DIV1; /* 每次边沿都捕获 */ sConfigIC.ICFilter 0; /* 先不滤波后文讲 */ HAL_TIM_IC_ConfigChannel(htim2, sConfigIC, TIM_CHANNEL_1); HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(TIM2_IRQn); } /* 启动捕获中断 */ HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);/* 中断回调计算相邻捕获周期 */ volatile uint32_t g_last_cnt 0; volatile uint32_t g_period_ticks 0; volatile uint8_t g_first_cap 1; /* 第一次捕获没有参考值 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { uint32_t cnt HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); if (g_first_cap) { g_first_cap 0; /* 丢弃第一次否则周期值是垃圾 */ } else { g_period_ticks cnt - g_last_cnt; /* 无符号减天然处理回绕 */ } g_last_cnt cnt; /* 此时频率 1000000.0f / g_period_ticks建议在主循环算 */ } }这里有几个关键点。一是 g_first_cap 标志必须有第一个捕获值之前没有 g_last_cnt 参考直接相减会算出一个无效的周期。二是cnt - g_last_cnt必须用无符号变量计算这样当 CNT 从 65535 回绕到 0 时差值仍然是正确的这是 C 语言无符号整数的回绕特性很多新手在这里写成了有符号数结果每溢出一次就出现一个负数周期。三是频率换算不要放在中断里做浮点除法耗时太大把 g_period_ticks 留给主循环去算就行。3.3 精度提升三板斧多周期平均、输入滤波与DMA捕获单周期测量在信号不干净时很容易出现抖动。我常用的第一招是多周期平均连续捕获 N 个周期累加后除以 N等效于把量化误差从 ±1 tick 摊薄到 ±1/N tick。N 取值多少取决于你的响应速度要求电机测速 N 取 8~16 比较合适锁相环应用 N 取 4~8因为平均窗口太长会把真实频率变化也给抹平。第二招是输入滤波。TIM2 的捕获通道自带一个数字滤波器原理是对输入信号以某个采样时钟连续采样只有连续 N 次采样到同一电平才认为边沿有效。这能滤掉窄毛刺但也会引入边沿检测延迟相位跟踪应用里要权衡。配置方法是在刚才的 sConfigIC 结构体里修改两个字段ICPrescaler 设成 TIM_ICPSC_DIV2 可以降低采样频率ICFilter 设 4~8 代表连续采样点数。毛刺严重的现场我一般设到 8再往上就会明显影响相位环路的响应。第三招是针对高频信号的 DMA 捕获。如果信号频率到了几十 kHz每个周期都进中断会导致 CPU 占用过高。可以把捕获事件通过 DMA 搬到内存数组里攒够一批再处理。代码上需要把 HAL_TIM_IC_Start_IT 换成带 DMA 的版本同时配置 DMA 循环模式/* 用 DMA 捕获 32 个周期值到数组 */ #define BUF_SIZE 32 uint32_t cap_buf[BUF_SIZE]; HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t *)cap_buf, BUF_SIZE); /* DMA 半满/全满中断里批量计算周期 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { /* DMA 模式由独立回调处理这里略 */ }DMA 模式的问题在于你拿到的是一串 CCR 值计算相邻差值需要自己做循环而且首次差值同样要丢弃。适合对 CPU 占用敏感的中高频测量但相位跟踪场景我不太推荐因为它破坏了每个周期都更新环路的实时性。4. 在STM32上做数字锁相环相位跟踪的工程实现4.1 为什么相位跟踪用锁相环而不是过零检测很多人第一反应是做过零检测每次上升沿到来时对齐输出信号的相位把输出周期的起点重置一下。这确实能“对上相位”但它有两个致命问题。一是抗噪差输入信号一个毛刺就会让输出相位跳变一次最终表现为输出频率抖动。二是它本质上是逐周期硬对齐没有任何记忆和预测能力当输入信号短暂丢失时输出立刻失控。锁相环的思路不一样它不直接“重置”输出相位而是通过负反馈持续调整输出频率让输出相位慢慢追上输入相位。即使输入相位突变环路给出的响应也是平滑的、有阻尼的不会出现输出信号跳变沿。这一点在驱动超声波换能器或伺服电机时非常重要因为负载端承受不了相位硬跳。数字锁相环在 STM32 上通常由三块组成鉴相器PD、环路滤波器LF、数控振荡器NCO。鉴相器就是输入捕获中断里把输入信号的上升沿时刻与 NCO 当前相位做差环路滤波器用 PI 控制器把相位差换算成频率修正量NCO 则是一个频率可调的定时器。这三块首尾相接就形成了一个完整的闭环。4.2 数字锁相环的结构与环路参数整定从控制理论的角度看这个环路的被控对象是 NCO输入是参考边沿输出是 NCO 相位。PD 输出的是相位误差PI 控制器把它变成频率修正量NCO 对频率积分得到相位。整个环路的开环传递函数包含一个积分环节NCO如果 PD 本身只是比例环节那就是一型环路对相位阶跃的稳态误差为零但对频率阶跃会有固定相位差。为了消除频率阶跃误差必须在 PI 里加入积分项构成二型环路。实际工程里我一般不跑仿真直接用试凑法整定。步骤是先把 Ki 置零只保留 Kp从一个小值开始缓慢加大观察示波器上输出和输入能否锁定锁不住就说明 Kp 太小或相位误差符号反了。等比例项能让系统锁定后再加入 Ki一点点加直到静态相差明显减小。整定过程中最需要盯着的是环路是否振荡输出频率出现周期性起伏说明 Kp 或 Ki 过大已经越过稳定边界。我习惯把 PI 的输出做限幅这个环节不能省。设想输入信号突跳 10%未限幅的积分项可能瞬间把输出频率推到极限造成所谓的“飞车”。限幅的合理范围是 ±20% 额定频率具体看应用容忍度。4.3 捕获中断里的相位误差计算与NCO更新代码下面这段代码是一个完整的锁相环中断实现。TIM2 负责输入捕获TIM3 作为 NCO 输出同频方波。每次 TIM2 捕获到输入信号的上升沿中断里就锁存一次 TIM3 的 CNT 值作为当前的 NCO 相位算出与期望相位 0 的差经过 PI 后更新 TIM3 的 ARR 来调整输出频率。/* 锁相环状态量 */ volatile float g_freq_in 1000.0f; /* 输入频率估计值 */ volatile float g_phase_err 0.0f; /* 相位误差单位 tick */ volatile float g_integral 0.0f; /* PI 积分项 */ volatile float g_freq_out 1000.0f; /* NCO 输出频率 */ volatile float g_kp 0.2f; /* 比例系数先调这个 */ volatile float g_ki 2.0f; /* 积分系数锁定后加 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { /* 第一步测频读捕获值并算周期 */ uint32_t cnt __HAL_TIM_GET_COUNTER(htim2); uint32_t period (cnt - g_last_cnt) 0xFFFF; g_last_cnt cnt; if (period 0) { g_freq_in 1000000.0f / (float)period; } /* 第二步鉴相读取 NCO 相位 */ uint32_t nco_phase __HAL_TIM_GET_COUNTER(htim3); uint32_t nco_period __HAL_TIM_GET_AUTORELOAD(htim3) 1; /* 期望输入上升沿对齐 NCO 周期的起点误差是 (0 - nco_phase) */ uint32_t err_raw (0 - nco_phase) 0xFFFF; if (err_raw (nco_period / 2)) { err_raw err_raw - nco_period; /* 映射到 (-半周期, 半周期] */ } g_phase_err (float)err_raw; /* 第三步PI 控制器输出频率修正量 */ g_integral g_ki * g_phase_err; if (g_integral 5000.0f) g_integral 5000.0f; if (g_integral -5000.0f) g_integral -5000.0f; float df g_kp * g_phase_err g_integral; /* 第四步更新 NCO 频率 */ g_freq_out g_freq_in df; if (g_freq_out 100.0f) g_freq_out 100.0f; if (g_freq_out 20000.0f) g_freq_out 20000.0f; uint32_t arr (uint32_t)(1000000.0f / g_freq_out) - 1; if (arr 10) arr 10; /* 防止 ARR 过小导致异常 */ __HAL_TIM_SET_AUTORELOAD(htim3, arr); } }这段代码有三处容易看错的地方。一是相位误差符号err_raw 是“期望相位减当前相位”如果 err_raw 为正说明 NCO 落后了此时 PI 输出正的频率修正量ARR 减小NCO 周期变短相位追赶上来负反馈方向正确。二是 err_raw 到对称区间的映射当 NCO 落后超过半个周期时把它减掉一整周期相当于从另一个方向追避免环路绕远路。三是 PI 系数没有归一化g_kp 的单位是“频率修正量/相位误差 tick”换了一个输入频率范围后需要重新整定这是我后面要说的边界。代码里直接用__HAL_TIM_GET_COUNTER(htim3)读取 TIM3 的 CNT 作为相位前提是 TIM3 工作在向上计数模式输出 PWM 的频率由 ARR 决定。ARR 变化时 NCO 周期同步变化所以相位误差的单位tick在不同频率下对应的电角度不同。严格来说应该按“相位角度”归一化后再进 PI但工程上只要频率不跨量级变化这种简化没问题。如果应用需要宽频段我会把它改成 32 位相位累加器方案误差单位恒定是角度环路参数不随频率漂移。5. 调试与避坑频率测量和相位锁定最容易翻车的五个地方5.1 捕获计数值突然跳变毛刺与输入滤波现象频率显示稳定在 1kHz 左右但偶尔冒出一个 5kHz 或 200Hz 的异常值用示波器看波形却是干净的方波。原因大概率是输入信号在上升沿附近有窄毛刺或者信号源与 STM32 共地不良导致边沿被触发两次或提前触发。解决路径先在示波器上用更快的时基看上升沿细节确认是否存在振铃然后配置 ICFilter 字段代码里把 sConfigIC.ICFilter 设为 6 到 8同时把 ICPrescaler 设为 TIM_ICPSC_DIV2 降低采样频率毛刺宽度小于采样窗口的会被滤掉。极端的现场环境里我会在信号输入脚加一个 RC 低通或施密特触发器整形。5.2 相位锁定后静态相差不为零现象示波器双通道同时看输入信号和 NCO 输出两个波形频率一致、形状一致但就是差一个固定角度怎么调 Kp 都消不掉。原因如果你的环路里 PI 积分项没有真正参与或者积分限幅太早进入饱和它就退化成只有比例控制的一型环路。一型环路对频率阶跃输入天然存在稳态相差因为只有靠这个相位差才能产生维持频率差所需要的控制量。解决确认 Ki 不是 0并且积分限幅要放宽到不至于在正常工作时饱和如果已经在用 PI 还有相差检查相位误差符号是否反了符号反了会表现为输出频率一直在输入频率附近振荡而不是恒定的相位差。5.3 PI系数一调大环路就振荡现象Kp 从 0.2 加到 0.8 后NCO 输出频率开始周期性起伏示波器上看到相位误差在不断正负摆荡幅度越来越大。原因环路带宽随 Kp 和 Ki 同步增大当带宽接近输入采样频率的某比例后相位裕度耗尽系统临界稳定甚至不稳定。数字锁相环的输入采样率就是输入信号的频率信号频率越低可用环路带宽越小。解决一是降低 Kp 和 Ki按先比例后积分的顺序调二是对相位误差做一阶低通滤波相当于在环路里加一个高频衰减极点三是检查中断里是否做了太多浮点运算导致相位误差更新不及时这一步影响很大。我实际遇到过一次把频率换算从中断挪到主循环后就稳定了。5.4 高频信号下捕获溢出与ARR越界现象输入频率从 1kHz 往上调到 50kHz 附近时周期值突然变得很小且跳动100kHz 时直接乱套。原因计数时钟 1MHz 时50kHz 信号周期只有 20 个 tick量化误差已经达到 5%几 MHz 信号一个周期连 1 个 tick 都不到CNT 可能连续两次捕获到同一个值。解决高频段把定时器预分频降为零使用 72MHz 计数时钟如果仍然不够就要切换到外部时钟计数模式或者用定时器级联扩展计数器位宽。另一个隐蔽问题是 ARR 更新到很小值后如果 NCO 的 ARR 设成了 1 或 0定时器行为异常。代码里专门加了一个if (arr 10) arr 10;的保护就是为这个坑准备的。5.5 相位误差在0与满量程之间跳变环形回绕的坑现象锁相环锁定后输出频率偶尔抖一下监控 g_phase_err 发现它在 0 附近和 65535 附近来回跳。原因相位误差表达式(0 - nco_phase) 0xFFFF本身是正确的环形运算但如果直接用这个原始值进 PI当 nco_phase 在 0 左右时误差会在 0 和 65535 之间来回横跳PI 输出随之大幅波动。解决误差必须映射到对称区间(-半周期, 半周期]也就是前面代码里的那一段判断。这是一个典型的“看起来正确、实际会翻车”的边界情况不做对称映射环路永远锁不稳。6. 进阶验证用DAC把环路状态拉出来看调锁相环最难受的地方是中断里跑的变量看不见摸不着。我常用的做法是让一个空闲 DAC 通道实时输出 g_phase_err 或 g_freq_out示波器探头夹在 DAC 引脚上就能直观看到环路的捕获过程和稳态表现。相位误差映射成电压后锁定状态下是一条平的直线收敛过程是一个衰减振荡曲线这样的信息比任何串口打印都直观。/* DAC 输出相位误差范围 ±1000 tick 映射到 0~3.3V */ void Update_DAC_Output(void) { float voltage (g_phase_err 1000.0f) / 2000.0f * 3.3f; if (voltage 0.0f) voltage 0.0f; if (voltage 3.3f) voltage 3.3f; uint16_t dac_val (uint16_t)(voltage / 3.3f * 4095.0f); HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dac_val); }DAC 更新代码放在主循环里就行频率不需要太快因为环路本身也就几百 Hz 量级。做完这一步后做两个现场测试一是频率阶跃测试用信号发生器把输入从 1kHz 突然切到 1.2kHz观察 DAC 波形的收敛时间和超调量二是相位跳变测试在信号源上加一个固定相移突变观察相位误差能否在一个周期内回中。这两个测试能定量验证环路参数是否合理。我自己的教训是每次改完 Kp/Ki都先跑相位跳变测试看收敛轨迹而不是直接在真实负载上试因为负载端的异常抖动很难分清是环路问题还是机械问题。这套方法帮我少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取