
从标题就能看出来这篇是写给真正要在嵌入式设备上做微弱信号测量的人看的。数字锁相放大器这个技术本身不新但网上能查到的资料十个里有八个是讲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。我用过的经验值典型场景f01kHzfs10kHzfc10Hz。这时候稳定时间约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采样率还是10kHzalpha约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每个中断周期都很短如果再叠加上系统里其他任务很容易出问题。我实际采用的方案是“定时器触发ADCDMA搬数乒乓缓冲”。具体就是定时器产生采样触发信号频率等于fsADC采集到数据后自动填入DMA缓冲不打扰CPUDMA缓冲分为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上搬。等你在板子上跑通、看到串口输出的幅值读数稳定在预期值的那一刻会觉得之前踩的那些坑都值了。