☰
嵌入式数字锁相放大器从浮点原型到MCU定点实现指南
2026/10/5 9:49:45 网站建设 项目流程

从标题就能看出来,这篇是写给真正要在嵌入式设备上做微弱信号测量的人看的。数字锁相放大器这个技术本身不新,但网上能查到的资料,十个里有八个是讲MATLAB仿真或者仿真模型,真的落到C语言、落到MCU上能跑的完整示例少得可怜。我自己前前后后调了快两个月,从浮点原型到STM32F103上的定点实现,中间踩过的坑,比如滤波器系数量化后输出漂移、参考信号幅度莫名其妙少了一半、相位读出来一直不对,每一个都能让人卡上好几天。

这篇文章就把整套东西从头拆到脚,先把原理简化成几句话,然后直接给C代码,最后讲清楚从浮点工程转移到嵌入式MCU时,那几条真正要命的边界是怎么处理的。如果你正准备在自己板子上实现锁相放大器,或者只是想把“复数运算”“DDS参考源”“IIR低通”这些概念串起来,这篇文章应该能省你不少时间。

1. 为什么是数字锁相放大器:原理、优势与方案选型

1.1 锁相放大器的核心数学原理

锁相放大器的物理本质,用一句话说就是“相关检测”。假设你面对的输入信号是:

x(t) = A * sin(ω0*t + φ) + n(t)

其中A是我们要测的幅值,φ是相位,n(t)是噪声和干扰。如果我知道参考频率ω0,就可以生成本地参考信号2cos(ω0t),然后把输入信号和它相乘:

I_raw = x(t) * 2*cos(ω0*t) = A * sin(2*ω0*t + φ) + A * sin(φ) + n(t) * 2*cos(ω0*t)

积化和差之后,第一项是二倍频分量,第三项是噪声与参考的乘积,它们统称高频项,经过低通滤波器后会被压掉。剩下来的就是直流项A * sin(φ)。再用一路正交参考2sin(ω0t)做同样的操作,得到:

Q_raw = x(t) * 2*sin(ω0*t) = -A * cos(2*ω0*t + φ) + A * cos(φ) + n(t) * 2*sin(ω0*t)

低通后剩A * cos(φ)。

到这里你会发现,I和Q其实就是信号在参考频率这个“复数平面”上的两个投影,I是虚部对应sin投影,Q是实部对应cos投影。最终:

幅值 A = sqrt(I² + Q²) 相位 φ = atan2(I, Q)

整个过程不需要相位对齐,不需要反馈环路,只要参考频率准确,I/Q同时跟着出来。这就是用复数运算眼光看锁相放大器最舒服的地方:你不是在测“一个正弦波”,你是在测“一个旋转矢量的模长和角度”。

1.2 数字实现比模拟实现强在哪

模拟锁相放大器直到今天依然有人用,尤其在射频和极高频场景。但在常规低频段,比如几百赫兹到几十千赫兹的频段,数字方案几乎全面胜出。原因其实很朴素:

一是元件误差和温漂。模拟方案里的乘法器、移相器、低通滤波器,全都依赖电阻电容的精度和温漂特性,想要做到1%以内幅度精度,成本很高。而数字方案里,参考波形、混频、低通全部用固定系数计算,只要晶振稳,测量结果就是稳的。

二是灵活性。模拟锁相放大器换一个参考频率,往往要重新调移相网络,麻烦到让人怀疑人生。数字方案改一个相位累加增量,频率就变了,甚至可以在同一套代码里并行做多个频率的锁相测量。这个特性在光谱检测、阻抗谱分析里非常香。

三是窄带能力。模拟低通想做到1Hz带宽,意味着要用大阻值电阻和大容值电容,不在电路板上专门腾一片地方根本放不下。数字一阶IIR滤波器,一行代码就能实现等效0.1Hz带宽,还不占PCB面积。

1.3 为什么选择正交解调而非锁相环跟踪

网上也有另一类锁相放大器设计,它用PLL锁定参考相位,然后只做一路混频。这种方式的问题是:当输入信号信噪比很差的时候,PLL的锁定本身就会受干扰,甚至失锁。而且PLL的环路滤波器参数要和信号动态匹配,调起来很考验经验。

正交解调则完全没有这个顾虑。我的参考信号是内部DDS生成的,锁定的是“我自己定义的频率基准”,而不是输入信号。哪怕输入信号被噪声完全淹没,只要频率已知,我照样能把幅值和相位提取出来。这在处理光电探测器输出、微弱磁信号、电力谐波检测等场景中是巨大的优势。

注意:正交解调的代价是它对参考频率的精确度很敏感。如果参考频率和实际信号频率偏差Δf,那么低通输出的就不是纯直流,而是一个频率为Δf的低频分量,幅度会被动态调制。所以实际应用中要么用高精度晶振,要么在软件里加频率估计/跟踪,这个后面细说。

2. 核心算法拆解:DDS参考源、混频器与低通滤波器

2.1 DDS参考信号生成:一个累加器和一张表搞定

在嵌入式MCU上,实时调用sinf()和cosf()的代价并不小。即使是带FPU的Cortex-M4F,一次sinf大约要消耗几百个周期,如果是没有FPU的M0/M3,代价直接翻十倍。而锁相放大器每采一个点至少要生成cos和sin两路参考,算下来CPU占用率很容易失控。

我推荐的方案是DDS,全称Direct Digital Synthesizer,直接数字频率合成。它的核心思想非常巧妙:把相位当成一个不断累加的整数,然后用查表把相位映射成正弦值。

typedef struct { uint32_t phase_accum; // 32位相位累加器 uint32_t phase_inc; // 每个采样周期的相位增量 const int16_t* table; // 正弦查找表,长度256 uint16_t table_mask; // 表长度掩码,256 -> 0xFF } dds_t; static inline int16_t dds_next(dds_t* d, int16_t* cos_out) { uint32_t ph = d->phase_accum; uint16_t idx = (ph >> 24) & d->table_mask; uint16_t idx_next = (idx + 1) & d->table_mask; int16_t frac = (ph >> 8) & 0xFF; // 线性插值,提高相位分辨率 int32_t s0 = d->table[idx]; int32_t s1 = d->table[idx_next]; int32_t sin_val = s0 + ((s1 - s0) * frac >> 8); int32_t cos_val = d->table[(idx + (d->table_mask >> 2)) & d->table_mask]; d->phase_accum += d->phase_inc; *cos_out = (int16_t)cos_val; return (int16_t)sin_val; }

相位累加器是32位的,高8位用来查表,次8位用来线性插值,低16位保留给长期频率精度。这样等效相位分辨率是2π/2^32,频率分辨率在10kHz采样率下小于0.00001Hz,实际使用完全够。

相位增量的计算:

double fs = 10000.0; double f0 = 1000.0; uint32_t phase_inc = (uint32_t)(f0 / fs * 4294967296.0);

这个增量一旦算好,整个系统就一直按它跑,不会漂移。查表长度我建议至少256点,配合线性插值后信号失真非常低,实测谐波分量大约在-80dB以下,对锁相测量来说完全无影响。

2.2 混频与低通滤波:逐点流式处理

混频在数字域就是一次乘法,没有悬念。真正决定系统性能的是低通滤波器。锁相放大器的低通滤波器有两个任务:一是把两倍频分量滤掉,二是把带外噪声压下去。

我推荐一阶IIR低通,结构简单、系数直观、状态量少,非常适合嵌入式。

typedef struct { float y; // 滤波器输出状态 float alpha; // 滤波系数 0 < alpha < 1 } lpf_iir1_t; static inline float lpf_iir1_process(lpf_iir1_t* f, float x) { f->y += f->alpha * (x - f->y); return f->y; }

alpha的计算公式:

float fs = 10000.0f; float fc = 10.0f; float alpha = 1.0f - expf(-2.0f * 3.14159265f * fc / fs);

还是那个熟悉的RC低通离散化公式。对于fc远小于fs的场景,可以直接近似为alpha ≈ 2π * fc / fs,简化计算。

那到底选一阶还是二阶?我的经验是:除非你对带外衰减斜率有硬性要求,否则先用一阶。一阶低通的-3dB带宽定义清晰,过渡带20dB/dec也够用。锁相测量的核心是把直流分量留下,把二倍频分量干掉——一阶滤波器在二倍频处的衰减大约是6dB,看起来不多,但别忘了你还可以在硬件上做抗混叠滤波,以及用更高的采样率让二倍频离截止频率更远。

如果你确实想要更陡的滚降,可以在软件里串联两级一阶IIR,等效二阶,代码只多用一组状态变量。注意每一级的系数要按“目标截止频率/级数”的等效单级截止频率重新计算,不能直接套同一个alpha串联,否则实际带宽会比预期低。

2.3 幅值与相位解算:sqrt和atan2的正确打开方式

当I和Q经过低通滤波稳定之后,就到了最后一步:算幅值和相位。

浮点版本非常直接:

float amplitude = 2.0f * sqrtf(I * I + Q * Q); float phase_rad = atan2f(I, Q);

看到那个2倍了吗?这是很多人第一个会踩的坑。前面原理里参考信号用的是2cos(ω0t),但DDS输出只能是[-1,1]范围内的值,没法直接输出2。所以我在参考表里实际存的是-32767到32767范围内的原始正弦值,对应数学上的[-1,1]。这样一来混频后低通输出的是A/2 * sin(φ)和A/2 * cos(φ),而不是A * sin(φ)和A * cos(φ),最终幅值必须乘2。

推荐的做法是在最后算幅值时乘2,而不是在混频时左移一位。因为混频后I/Q还带着噪声,提前把信号放大没有意义,最后一步乘2还能顺便避免中间过程溢出。

提示:float版本适合M4/M7或带硬件FPU的MCU。如果你的MCU没有FPU,或者你需要在实时性要求极高的中断里完成完整计算,那么请跳到第4节,我把定点化的完整方案放那里了。

3. 浮点原型完整实现:一次把链路跑通

3.1 整体结构设计

先不着急上STM32,把原型跑在PC或者带FPU的开发板上,用串口/以太网把I/Q值发出来,配合Python或者串口绘图工具验证整条链路。

我的推荐分层是这样的:

  • 底层:ADC采样,定时器触发,DMA搬运
  • 中间层:每来一个采样点,执行一次DDS取参考、混频、IIR低通
  • 上层:在非中断上下文读取I/Q,计算幅值和相位,输出结果

核心流程如下:

void on_sample(int16_t adc_code) { int16_t cos_ref, sin_ref; float I_in, Q_in; // 1. 减去直流偏置,把ADC原始码转换为有符号信号 float x = (float)(adc_code - 2048); // 2. 取DDS参考 sin_ref = dds_next(&dds, &cos_ref); // 3. 混频 I_in = x * (float)cos_ref * (1.0f / 32768.0f); Q_in = x * (float)sin_ref * (1.0f / 32768.0f); // 4. IIR低通 lpf_iir1_process(&lpf_I, I_in); lpf_iir1_process(&lpf_Q, Q_in); } // 上层循环定时读取 void app_loop(void) { float I = lpf_I.y; float Q = lpf_Q.y; float amp = 2.0f * sqrtf(I * I + Q * Q); float phase = atan2f(I, Q); }

这里的adc_code是12位ADC原始值,范围0~4095,减去2048后得到有符号信号。参考信号除以32768的原因是把Q15定点数映射回[-1,1]的浮点数。

3.2 采样率和截止频率怎么定

选参数的逻辑比公式重要。我的建议是:

把参考频率定为f0,采样率fs至少是f0的10倍。这个10倍不是随便拍的——DDS参考每周期的点数越多,混频后二倍频分量离直流越远,低通滤波器越好做。10倍时二倍频在2*f0 = fs/5的位置,一阶IIR在fs/5处的衰减大约10~15dB,配合后续平均已经能让系统稳定工作。

低通截止频率fc取决于你期望的响应速度。一阶IIR的时间常数是τ = 1/(2πfc),稳定时间是4~5个τ。如果你希望幅值读数在1秒内稳定到95%,fc至少要大于0.8Hz。我用过的经验值:

典型场景:f0=1kHz,fs=10kHz,fc=10Hz。这时候稳定时间约0.08秒,噪声等效带宽约15.7Hz(一阶低通的噪声带宽是π/2时间fc),相比原始10kHz采样带宽,信噪比提升约10log10(10000/(215.7)) ≈ 25dB。

如果你的信号变化非常慢,比如每分钟只更新一次读数,那fc可以直接压到0.5Hz,信噪比还能再提升。

3.3 为什么说“DDS天然免疫频谱泄漏”

有些朋友一开始会纠结:采样率和参考频率如果不成整数倍,会不会和FFT一样出现频谱泄漏?答案是块处理方式确实会,但流式DDS方式不会。

我们用FFT做锁相的时候,必须在整周期内采整数个点,否则栅栏效应和泄漏会污染结果。但DDS参考源不一样:它的相位是一个连续累加的变量,你每个采样点看到的参考相位永远是上一时刻的相位加一个增量,这个增量可以是任意实数截断后的整数。参考波形不会因为采样率与频率非整数倍而发生“跳变”,它就好像一个永远在转的复平面指针,ADC只是不断读取这个指针指向的位置。

所以只要你用DDS加逐点IIR的架构,非整数倍频率关系不会带来频谱泄漏,最多是需要接受一个固定的相位偏移,可以通过校准消除。

4. 嵌入式MCU移植实战:定点化改造与踩坑记录

4.1 先搞清楚你的MCU有没有FPU

嵌入式MCU移植最核心的问题只有一个:浮点运算够不够快。

以STM32F103为例,Cortex-M3内核,没有硬件浮点单元。所有float运算都由编译器的软浮点库模拟,一次乘法几十个周期,一次sqrtf几百个周期。如果采样率是10kHz,每个采样点的中断里要做:DDS增量、混频两次、IIR两次,光算数运算就得上百次float操作,CPU几乎被吃满,还谈什么做上层应用。

这时候有两个选择:换带FPU的芯片,或者把算法全部改写成定点数运算。如果你的产品已经定死芯片,定点化是唯一出路。

4.2 Q格式选择:Q15还是Q31

我用的方案是基于Q15的,也就是把[-1,1]范围的有符号数映射到[-32768,32767]的int16_t。这个格式的好处是占用内存小,乘法的中间结果用int32_t正好不会溢出,代价是动态范围和精度有限。

如果ADC是12位,信号调理得比较好,动态范围需求不大,Q15完全够。但如果你做的是高动态范围测量,比如需要同时测大信号里夹着的小信号,Q15的分辨率可能不够,需要考虑Q31,对应的就是int32_t做乘法和状态存储,代价是内存翻倍且乘法用64位中间变量,速度更慢。

ADC数值进来以后,先减1024或2048(取决于ADC位数),然后左移到满量程附近:

int16_t adc_to_q15(uint16_t adc_code, uint16_t mid, uint8_t shift) { int32_t x = (int32_t)adc_code - (int32_t)mid; x <<= shift; // 把12位信号左移到16位有符号范围 if (x > 32767) x = 32767; if (x < -32768) x = -32768; return (int16_t)x; }

参考表是int16_t型正弦表,值域[-32767,32767]。混频变成:

int32_t raw_I = (int32_t)sampled_signal * (int32_t)cos_ref; // 结果是Q30格式,右移15位回到Q15 int16_t I_mixer = (int16_t)(raw_I >> 15);

这里有个很多人忽略的细节:右移对负数来说是算术右移,大部分MCU上结果正确,但从C标准角度它依赖平台。为了避免潜在问题,可以写成交互安全的方式:

static inline int16_t sat16(int32_t x) { if (x > 32767) return 32767; if (x < -32768) return -32768; return (int16_t)x; } int16_t I_mixer = sat16(raw_I >> 15);

4.3 滤波器系数的定点量化坑

一阶IIR的alpha,浮点时是0.006283这种小数值。转成Q15就是:

int16_t alpha_q15 = (int16_t)(alpha * 32768.0f);

0.006283乘32768等于205.86,取整数206,实际对应浮点系数0.006286,误差不到0.05%,一般没问题。

真正的坑在alpha特别小的时候。如果你把截止频率压到0.1Hz,采样率还是10kHz,alpha约0.0000628,乘32768之后只有2.06,取2。这时实际滤波器和理论值的误差达到10%以上,截止频率完全偏了。更危险的是当alpha取整后变为0,滤波器直接变成纯积分器,输出会慢慢飘到溢出。

我的建议:fc/fs小于0.0001的场景,不要在Q15里做一阶IIR,要么换Q31格式,要么改用移动平均/滑动平均滤波器。移动平均本质上是FIR滤波器,不需要系数乘法,只需要一个环形缓冲区,代价是内存占用稍大但稳定性极高,而且对锁相测量这种慢速输出场景非常友好。

4.4 sqrt和atan2的定点替代方案

在无FPU芯片上,sqrtf和atan2f都不能用。我的工程实践方案:

求模长sqrt(I²+Q²),用整数牛顿迭代:

uint32_t isqrt_q15(uint32_t x) { uint32_t r = x; uint32_t prev = 0; if (x == 0) return 0; while (1) { r = (r + x / r) >> 1; if (r == prev) break; prev = r; } return r; }

在Q15域,I和Q的平方和最大约2^30,开方后结果最大约46340,刚好能放进uint32_t。实际幅值计算:

uint32_t i_sq = (uint32_t)(I * I); uint32_t q_sq = (uint32_t)(Q * Q); uint32_t mag = isqrt_q15(i_sq + q_sq); // 最终幅值 = 2 * mag,这里mag是Q15格式

相位atan2的定点实现,CORDIC是通用解法。CORDIC原理不复杂,通过旋转角度逼近目标向量,但代码偏长。如果只是简单显示,用一个基于|I|和|Q|比值的查表法就够。查表法思路:把第一象限的相位按1°精度做成反正切表,根据I和Q的绝对值查,然后根据I/Q符号修正象限。实测精度可以到0.5°以内,大多数嵌入式锁相应用够用。

4.5 中断里跑算法还是DMA批量处理

低采样率(1kHz以下)可以在ADC中断里直接跑完整个锁相算法,每周期占用CPU不到几十微秒。但采样率到几十kHz,每个中断周期都很短,如果再叠加上系统里其他任务,很容易出问题。

我实际采用的方案是“定时器触发ADC+DMA搬数+乒乓缓冲”。具体就是:

  • 定时器产生采样触发信号,频率等于fs
  • ADC采集到数据后自动填入DMA缓冲,不打扰CPU
  • DMA缓冲分为A/B两块,A块满时DMA切到B块,同时置位标志位
  • 主循环检测到标志位后,对A块里的所有采样点批量执行锁相算法,处理完再清标志

这个方案的好处是把数学计算从中断中挪出来,主循环有充分时间处理。代价是输出天然会延迟一个缓冲块的时间,但对锁相测量这种连续慢速输出,这个延迟完全可接受。

4.6 并发安全:读I/Q的时候要不要关中断

只要你是用“中断里算好,主循环读取”这种架构,就一定面临并发问题。主循环读lpf_I.y的时候,中断可能正在写这个变量,读到一半的值既不是旧值也不是新值,表现出来就是数据偶尔跳变。

我建议在结构体里放一个synchronized标志,或者干脆在主循环读之前先关中断、读完之后开中断:

__disable_irq(); float I_snapshot = lpf_I.y; float Q_snapshot = lpf_Q.y; __enable_irq();

如果用的是FreeRTOS,可以用taskENTER_CRITICAL和taskEXIT_CRITICAL包裹。千万别偷懒不处理,这种偶发跳变极其难排查。

5. 常见问题与排查技巧实录

5.1 幅值小了正好一半

症状:输入一个1Vrms正弦波,期望读数1V,实际输出0.5V。

原因几乎永远是参考信号数学上的“2”没补回来。检查自己的代码里,在最终幅值计算时是否乘了2。如果用的是DDS查表输出[-1,1]的参考,而数学推导里参考是2cos(ω0t),那必须乘2。还有一种变体:低通输出I/Q再求sqrt之后,结果能对上标定增益但差0.707倍,那往往是把有效值和幅值搞混了。正弦信号的幅值是峰值,是有效值的√2倍。

5.2 相位读数不对,而且频率越高越离谱

首先检查atan2的参数顺序。数学上常用atan2(虚部, 实部),但每个库的定义不完全一样,C语言的atan2f(y,x)是atan2(y,x),如果按atan2f(Q,I)算出来和数学推导对不上,颠倒一下参数试试。

如果参数顺序正确但偏差随频率变化,那就是信号链路的群延迟问题。ADC的采样保持、模拟前端的运放带宽、低通滤波器的相位响应,都会带来额外相移。校准方法很简单:输入一个已知幅值和相位的参考信号,记录当前相位读数,这个差值就是整个测量链路的固有相位偏移。系统起来之后用这个校准值做减法。

5.3 读数一直漂,稳定不下来

最大的嫌疑是低通滤波器的初始化状态。如果你写的代码每N个采样点重新归零滤波器,那每次重启都有一次从0到稳态的爬升过程,表现就是读数持续性抖动。锁相放大器的滤波器状态应该一直保持,从一个采样点到下一个采样点,y值连续计算,永远不要清零。

另一个可能是在定点实现中,Q15的alpha被取整成了0,滤波器退化成积分器,输出会随输入累积漂移。排查方法:用浮点原型把同样的输入跑一遍,对比输出,如果定点输出和浮点输出趋势一致但幅度不一致,多半是系数量化精度不足。

5.4 带内噪声明显,信噪比上不去

先看ADC前面有没有做抗混叠滤波。如果输入信号带宽远高于奈奎斯特频率,折叠噪声会占据带内,无论后面低通怎么压都压不掉。硬件上至少要加一阶RC低通,截止频率设在参考频率的5~10倍。不能设太低,否则参考信号本身的幅度也会被衰减。

如果硬件没问题,那就是低通带宽还太宽。把fc降下来,信噪比会按10*log10(fc降低倍数)提升。1Hz带宽在大多数场景下已经能获得非常干净的读数。

5.5 实测问题排查速查表

现象可能原因对策
幅值偏小一半参考信号的2倍没补最终幅值乘2
输出有直流偏置输入未去偏置导致ADC饱和前端隔直或数字减偏置
高频时相位偏差大模拟链路群延迟做固定相位标定
读数随时间漂移滤波器状态被错误清零保持IIR状态持续运行
噪声底盘高ADC前抗混叠不足硬件加RC低通
响应速度太慢fc设得过低按响应时间需求提高fc
偶发跳变中断与主循环并发读写关中断读I/Q快照
定点后输出不稳定alpha量化过小被截断为0改用Q31或移动平均

写在最后的一些个人经验

说实话,数字锁相放大器这套东西,真正难的不是算法本身,而是工程实现里那些一环扣一环的取舍。我在做第一版的时候,曾在定点滤波器的alpha上卡了整整一个晚上,第二天用Python脚本把浮点和定点结果叠在一起画出来,才意识到是系数量化把alpha截成了0。后来我养成了一个习惯:所有关键参数(参考增益、滤波器系数、输出比例)都单独设计算宏和注释,并在调试模式下通过串口输出中间量。这个习惯帮我省下了大量后续排查时间。

如果你打算直接抄作业,建议先按第3节的浮点版本在PC上写个最小工程,用生成的理想正弦信号加白噪声验证整条链路。确认数学正确后,再按第4节往MCU上搬。等你在板子上跑通、看到串口输出的幅值读数稳定在预期值的那一刻,会觉得之前踩的那些坑都值了。

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

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

立即咨询