☰
STM32频率测量与相位跟踪:数字锁相环(DPLL)工程实践指南
2026/10/21 1:24:38 网站建设 项目流程

简介:这是一套基于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 输入捕获初始化:计数时钟 1MHz,ARR 最大 0xFFFF */ void MX_TIM2_Init(void) { TIM_IC_InitTypeDef sConfigIC = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 71; /* 72MHz/(71+1)=1MHz,一个tick=1us */ 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,都先跑相位跳变测试看收敛轨迹,而不是直接在真实负载上试,因为负载端的异常抖动很难分清是环路问题还是机械问题。这套方法帮我少走了很多弯路,希望帮到你。

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

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

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

立即咨询