ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C语言在STM32上实现数字锁相放大器:从原理到实战

用C语言在STM32上实现数字锁相放大器:从原理到实战 数字锁相放大器这个东西做微弱信号检测的人应该都不陌生。以前想要一台性能过得去的锁相放大器少说也得几万块而且体积不小只能放在实验室里。后来我开始用C语言自己在嵌入式MCU上写数字锁相放大器才发现只要把数字混频、低通滤波、复数运算这几块整明白一块几十块钱的STM32就能干出以前一台仪器才能干的活。这篇东西就把我完整实现一遍的过程写出来从最底层的复信号数学原理到C代码怎么写再到怎么从PC仿真移植到真正的MCU板上全程只讲实操把踩过的坑全摊开说。不管你是做光电检测、荧光测量、电阻抗谱还是单纯对锁相放大这个技术感兴趣只要手里有一块带ADC和DAC的MCU这套方案你都能直接参考着抄。代码量不算大核心算法全部用标准C写不依赖特定厂商的库除了底层ADC/DAC驱动之外算法部分换到任何平台都能跑。1. 数字锁相放大器到底在干什么1.1 从模拟锁相放大到C语言实现的思路转变先理清楚锁相放大器的工作原理否则后面写代码全是迷迷糊糊的。传统模拟锁相放大器的结构大致是这样信号进来之后先过一个低噪声放大器然后送进一个模拟乘法器和参考信号相乘之后接一个低通滤波器把高频分量滤掉剩下的直流分量就反映被测信号的幅值和相位。整个过程在频域上看相当于把目标频率附近的一小段频谱搬移到直流附近然后用窄带低通滤出这一小段能量。这个思路学名叫“相干解调”意思是我用一种载波和已知频率去“擦”信号擦完之后只剩下和这个载波同频的部分。数字锁相放大器本质上就是把这个模拟链路全部搬到数字域。输入信号经过ADC变成采样序列参考信号直接由程序生成标准正弦/余弦乘法用浮点乘法完成低通滤波器用IIR或FIR实现。这一步改变带来的好处非常明显模拟器件有温漂、有老化、有容差但数字滤波器一旦代码写定10000次运行结果完全一致。而且数字实现天然支持多通道你要同时测3个频率只需要把同一份采样数据分别乘上3组不同频率的参考信号各走各的低通滤波器出来就是3个结果这在模拟域几乎是不可能的——每一路都得单独搭一套硬件。用C语言实现这件事最核心的是要有一个清晰的模型每一个采样点进入之后经过一次混频、一次滤波状态更新一次多出来的数据就能持续读到被测信号的幅度和相位。整个算法可以在PC上先用普通C编译跑通验证完数值逻辑再往MCU上搬。1.2 复数域表示为什么同时算I和Q这么重要刚开始做数字锁相放大器的时候很容易犯一个直觉性错误既然要测和参考信号同频的分量那直接用采样信号乘以一个和参考同频的余弦波然后低通得到的不就是幅度了吗。确实能拿到幅度但这里有个巨大的麻烦——你根本不知道被测信号和参考信号之间的相位差是多少。万一两个信号相差了90度理论上乘余弦的结果是零你直接读不到任何信号。解决办法很简单也很优雅同时乘两个正交的参考信号一个余弦一个正弦分别得到I分量和Q分量然后把它们看成复平面的实部和虚部。这样无论输入信号和参考信号之间相位差是多少幅度总是等于2乘以sqrt(I^2Q^2)相位等于atan2(Q, I)。这个思想就是复数域表示。锁相放大器在数学上等价于计算输入信号在参考频率处的复数傅里叶系数只不过它不是一个扫描式的频谱分析而是只盯着某一个频率实时地、每时每刻地输出该频率下信号的幅度和相位。用复数表示还有一个好处是方便分析。假设输入信号是A·sin(2πf_ref·t φ)混频之后会得到和频分量与差频分量低通滤除和频后I路留下来的是(A/2)·cosφQ路留下来的是(A/2)·sinφ。之所以有个1/2的系数是因为正弦信号和参考信号相乘后差频项本身带一个0.5的因子。实际使用中这个2倍系数可以留着不管在校准环节统一标定就行。1.3 参考频率、采样率、时间常数怎么选才稳很多人做数字锁相放大器代码很快写完结果一测数据各种漂第一反应是滤波器有问题其实大部分情况下是参数选得不对。我最常用的三个参数就是参考频率、采样率、时间常数它们之间的约束关系必须先想清楚。参考频率指你关心的被测信号频率比如你要测一个工作在10kHz的光电信号参考频率就是10kHz。采样率要满足奈奎斯特条件但实际工程上我一般会选参考频率的8到16倍以上。为什么不是刚好2倍因为数字混频会产生和频分量如果采样率太低和频分量会叠回到低频区把低通滤波器输出的直流信号污染掉。举个例子参考10kHz、采样率100kHz混频后和频是20kHz奈奎斯特频率是50kHz20kHz没有混叠但如果采样率只有30kHz奈奎斯特频率15kHz20kHz的和频混叠到10kHz附近这个10kHz正好落进你的测量带宽里灾难性后果。时间常数和低通滤波器的截止频率直接相关它决定了你把测量带宽收得多窄。时间常数大带宽窄噪声滤得干净但响应慢时间常数小响应快但输出噪声大。一阶低通滤波器的时间常数τ和等效噪声带宽的关系大约是Δf1/(4τ)如果你用1ms的时间常数等效噪声带宽大约250Hz。这个数在工程上足够用来估算系统能抑制多少噪声。做实验时我会先按被测信号能容忍的响应速度定时间常数比如光电检测里信号变化很慢就可以用50ms甚至100ms的时间常数把噪声压到uv级别。2. C语言实现状态机结构与数字低通滤波器的细节2.1 状态机与数据结构设计单片机上的主循环是高速运转的不可能像PC上一样定义一个大数组然后把所有数据先存下来再一次性处理所以数字锁相放大器的C语言实现必须做成一个逐点调用的状态机结构。每个采样值进来调用一次处理函数函数内部更新所有状态输出一个新的幅度和相位结果然后返回等待下一个采样点。我把状态用一个结构体封装起来每个通道独立一个实例。结构体里主要包含参考信号的当前相位、相位增量、I路滤波器状态、Q路滤波器状态、低通滤波系数。为什么要用结构体而不是全局变量因为你可能同时要测3个频率那就得实例化3个通道每个通道有自己独立的相位和滤波状态如果全用全局变量多通道根本没法写。而且结构体语义明确移植到任何平台都方便。核心处理函数的逻辑我写成这样typedef struct { float phase; // 当前参考信号相位 float phase_inc; // 每个采样周期的相位增量 float alpha; // 低通滤波系数 float i_state; // I路滤波状态 float q_state; // Q路滤波状态 float i_out; // 当前I分量 float q_out; // 当前Q分量 } lia_state_t; void lia_init(lia_state_t *lia, float ref_freq, float sample_freq, float tau) { lia-phase 0.0f; lia-phase_inc 2.0f * 3.14159265358979f * ref_freq / sample_freq; lia-alpha 1.0f - expf(-1.0f / (sample_freq * tau)); lia-i_state 0.0f; lia-q_state 0.0f; lia-i_out 0.0f; lia-q_out 0.0f; } float lia_process(lia_state_t *lia, float sample) { float i_ref cosf(lia-phase); float q_ref sinf(lia-phase); float i_mix sample * i_ref; float q_mix sample * q_ref; // 一阶IIR低通等效于时间常数为tau的低通滤波 lia-i_state lia-alpha * (i_mix - lia-i_state); lia-q_state lia-alpha * (q_mix - lia-q_state); lia-i_out lia-i_state; lia-q_out lia-q_state; // 更新参考相位保持数值在0~2π之间循环 lia-phase lia-phase_inc; if (lia-phase 6.28318530717959f) { lia-phase - 6.28318530717959f; } return 2.0f * sqrtf(lia-i_out * lia-i_out lia-q_out * lia-q_out); }这个函数就是整个数字锁相放大器的全部核心加起来不到20行。每来一个采样点调用一次lia_process返回当前时刻的幅度估计。你用示波器看这个返回值会发现它慢慢逼近真实的信号幅度然后稳定在那里。稳定需要的时间大概就是3到5倍时间常数这个响应特性要心里有数不是调用一次就能出结果的。2.2 参考信号生成查表法还是实时调用sin/cos在上面的代码里我直接调用了cosf和sinf。这个写法在PC上、在带FPU和数学库加速的MCU上确实没问题但如果你用的是M0这种没有硬件浮点单元、也没有优化过的libm的小芯片那么每一个采样周期调用两次三角函数可能就要消耗上百微秒采样率稍微高一点就直接扛不住。我的做法是分平台处理如果MCU是M4F及以上级别直接用自带的arm_sin_f32、arm_cos_f32甚至直接用libm的cosf和sinf速度都够用。如果是M0这种弱鸡平台换成查表法。查表的关键不是简单地查一个周期256个点而是要做线性插值否则相位量化误差会引入额外噪声。我实测过一个周期512个点加线性插值生成的参考信号总谐波失真基本在0.005%以下足以满足绝大多数锁相放大场景。查表法实现的时候要注意表长得按相位索引不要用浮点相位直接索引数组否则每次转换都要花不少时间。常规做法是维护一个32位的相位累加器高位用来查表低位用来做插值。这个技巧在DDS信号发生器里是基本操作搬到锁相放大器的参考信号生成上同样适用。不过大多数中高端MCU项目里我推荐直接用数学库省事且代码可读性强等你确定性能不够了再去优化。2.3 数字低通滤波器把时间常数换算成代码低通滤波器是整个锁相放大器性能的关键它的截止频率决定了系统带宽。我上面代码里用的是最经典的一阶IIR滤波器公式是y[n] y[n-1] alpha * (x[n] - y[n-1])这个滤波器实现特别简单只有一行但它对应的模拟时间常数和alpha有个精确的数学关系。理想的一阶低通滤波器它的冲激响应是指数衰减h(t) (1/τ)·e^(-t/τ)对应数字域的alpha 1 - exp(-1/(fs·τ))。这里的τ就是时间常数单位是秒。很多人直接从截止频率fc算alpha网上一搜一大堆公式但如果你先关心时间常数而不是频率整体思路会更顺。为什么我推荐先设时间常数因为在锁相放大器应用里你知道的是“我希望系统在多久之内响应信号变化”这是时域需求不是频域需求。比如做慢速温度检测你希望1秒之内稳定下来时间常数就设200ms到500ms换算出的截止频率大约是0.3Hz到0.8Hz等效噪声带宽大约0.6Hz到1.6Hz对噪声的抑制效果一目了然。反过来如果你先设截止频率再做往往会在脑子里来回换算容易搞乱。如果是一阶滤波不够需要更陡的滚降可以做二阶。我一般采用两个一阶IIR串联把alpha调成不一样的系数一个粗滤、一个精滤或者直接上双二阶滤波器biquad。但需要注意串联二阶的低通滤波器和一阶相比阶跃响应会有轻微超调对某些不允许超调的精细测量应用来说可能反而是个坑。所以我平时默认一阶能不用二阶就不用二阶。3. 嵌入式MCU移植从PC仿真到STM32实战全程记录3.1 采样链路改造定时器触发DMA才是正解我在PC上把算法调通之后往STM32上移植的时候第一个掉进去的坑就是ADC采样方式。刚开始偷懒直接在while(1)里写adc_value HAL_ADC_GetValue(hadc1)然后用这个值去跑lia_process。结果输出幅度抖动非常明显尤其是在采样率比较高的时候相位抖动直接导致解调结果波动。问题的根源在于采样时刻的不确定性。MCU从主循环判断到真正执行HAL_ADC_GetValue中间可能有几个甚至几十个时钟周期的不确定延迟这个延迟就是采样相位抖动。锁相放大器的本质是相关运算对相位抖动极其敏感哪怕只有1个百分比的相位误差对窄带测量的影响也相当可观。正确做法是使用定时器触发ADC采样然后用DMA搬运结果。具体配置是定时器更新事件触发ADC启动转换ADC转换完成后通过DMA把结果写入内存缓冲区整个采样过程没有任何CPU参与采样时刻完全由定时器决定抖动低到可以忽略。我通常用DMA双缓冲模式一个缓冲区在搬运的时候另一个缓冲区的数据就在中断里处理。这样即使主循环被其他任务阻塞采样也不会断流。3.2 发射端同步参考信号必须和采样共用时钟源如果你做的是主动测量比如用锁相放大器测阻抗你需要一块发射端——通常是一个DAC输出正弦波去激励被测对象。这里有个关键要求发射信号的频率和接收端的参考信号频率必须严格同源否则频谱上任何细微偏差都会导致测量结果振荡因为解调后差频不是零而是一个慢变的低频信号低通滤波器滤不干净。我在第一版设计里犯过这种错误发射端用一个定时器产生中断接收端用另一个定时器触发采样两边频率不同步结果屏幕上看到的是一串正弦波动而且波动周期会随机漂移。后来把发射端和接收端改为同一个定时器的时间基准发射端在定时中断里更新DAC数据接收端用另一个通道触发ADC两边相位锁定问题瞬间解决。这样做的本质就是系统级同步发射通道和接收通道共享同一个时钟源从根源上消除了频率偏差。如果你的系统里只需要外部激励信号可以直接用定时器的PWM输出加RC滤波成正弦波也可以用DMA播放预先算好的正弦波表核心原则只有一个采样触发信号和发射参考信号必须来自同一个上游时钟。3.3 性能估算与优化浮点开销、定点化时机很多人担心MCU跑浮点能不能跟上。以STM32F407为例主频168MHz带FPU我把算法逐点算了一遍每个采样点需要两次三角函数或者查表、两次乘法、两次一阶滤波、一次开方。实测单点处理时间大约在600ns到1us之间。如果你设置采样率96kHz周期约10.4usCPU占用率不到10%完全没有任何压力。如果平台是Cortex-M0没有FPU软件浮点运算会非常慢。这时候有两种选择一是把全部运算改成定点Q15或Q31格式代价是代码复杂度和动态范围问题二是干脆降低采样率或者把滤波器阶数减少。我的建议是除非你非常熟悉定点数运算否则优先换一颗带FPU的M4芯片省下来的时间去做其他功能比在M0上死磕定点化划算得多。毕竟一颗M4F的芯片和M0的差价也就是几块钱而收益是开发和调试时间的大幅缩短。真正需要优化的时候优先用编译器自带的数学库加速函数比如ARM CMSIS-DSP里的arm_sin_f32、arm_cos_f32、arm_sqrt_f32速度比标准libm快不少。再不够再用查表法最后才考虑定点化。这是性价比递减的顺序。4. 常见问题排查与避坑记录4.1 工频干扰“拆不掉”怎么办实际测量中最常碰到的鬼问题是50Hz工频干扰。数字锁相放大器理论上只响应参考频率附近一小段频率的信号但如果被测信号本身就是直流或者低频信号参考频率又恰好接近工频的整数倍工频干扰就会混进来。有一次我测一个低频信号参考设成190Hz理论上有源锁相应该把190Hz以外全滤掉但输出还是有明显的50Hz波动。排查半天发现是ADC前端没有做抗混叠滤波工频的谐波在高频段折叠进来落到了190Hz附近正好通过低通滤波器进入解调结果。解决办法是双管齐下硬件上前端加一个简单的RC低通截止频率设在采样率的一半以下确保没有高频分量混叠软件上在解调之前加一个直流和工频陷波器。如果是直流偏置导致的问题直接先对采样数据做高通或减去均值就能解决。4.2 输出幅度随参考信号相位变化这个坑比较隐蔽症状是系统输出的幅度值会随着参考信号相位缓慢变化有时变大有时变小。最开始我以为是滤波器问题折腾了很久后来发现是参考信号本身的正交性出了问题。如果我使用的余弦和正弦参考信号不是严格正交的比如查表时两个表起点不对齐那么I路和Q路失衡解调出来的幅度就不稳定。验证方法很简单给系统输入一个固定幅度的同频正弦波然后人为改变参考信号的初始相位观察输出幅度是否恒定。如果幅度随相位变化那一定是I/Q正交性坏了。标准正弦和余弦函数本身没有这个问题当你自己生成正余弦表时一定要保证两点表长是4的整数倍且正弦表的起点索引和余弦表的起点索引相差四分之一周期。这是个小细节但直接影响测量准确度。4.3 时间常数设小后测量值乱跳另一个常见现象时间常数往小了调输出波动反而变大看起来像“乱跳”。这是完全正常的物理结果系统带宽变宽了噪声进入的就多了。你要先区分是正常的噪声粒度变粗还是异常的跳变。如果只是噪声变大那是预期内的换更大的时间常数就能改善噪声如果是间歇性的尖峰很可能是有外部干扰比如变频电机启动、继电器开关、电源负载突变。这时候不要一味依赖软件滤波先从信号链路找源头或者做中值滤波先剔除尖峰。4.4 多通道复用时的相位延迟补偿同时测量多个频率时除了参考信号频率不同还有一个容易忽略的问题信号链路对不同频率有不同的相位延迟。ADC前端的模拟滤波器、信号调理电路、抗混叠RC网络都会对不同频率的信号引入不同的相位延迟。频率偏差不大时这个影响可以忽略但如果测量频率范围很宽比如从1kHz到100kHz都要测不考虑相位延迟补偿的话测出来的相位值就是不准的。我做的补偿方法是预先在代码里存一张相位校准表给每个测量频率注入一个已知相位的信号记录系统测量相位与真实相位的差存起来实际测量时做一个相减。这本质上是一个频率相关的相位校准实现不复杂但能显著提升多频率锁相测量的相位准确度。4.5 快速排查对照表现象可能原因处理方案输出幅度始终为0参考信号与输入频率偏差大检查参考频率和采样率配置输出为缓慢正弦波动发射与采样不同步统一时钟源定时器触发DMA采样幅度随相位漂移正余弦参考不正交检查正弦余弦表起点和表长配置输出噪声颗粒感明显时间常数过小增大时间常数输出有尖峰电磁干扰或电源噪声硬件滤波、中值滤波剔除尖峰采样数据偶发丢失DMA配置错误或缓冲区太小检查DMA双缓冲配置增加缓冲区深度多频测量相位不准模拟链路相位延迟未补偿建立频率相位校准表5. 校准与验证用已知信号给算法“验货”5.1 注入信号的校准方法算法写完了代码移植跑通了接下来不是直接上真实验收而是先做注入测试。我用信号源生成一个已知频率、已知幅度的正弦波直接输入MCU的ADC引脚然后观察锁相放大器的输出是否和设定值一致。这里有一个关键步骤先消除系统固有增益偏置。MCU的ADC参考电压、前端运放的增益误差、混频时的2倍系数都会导致最终输出的幅度值与理论值有偏差。简单校准方法就是记住一个标定点在已知1V峰峰值输入下记录输出值K以后所有测量结果乘以1/K就完成了归一化。校准的时候要注意信号源的精度普通教学级信号源本身的幅度误差能有百分之几与其较真到小数点后三位不如先确认算法逻辑的正确性。我发现最好的办法是用示波器同时观察输入信号和锁相放大器的输出看输出是否经由低通滤波器的过渡过程后稳定在一个预期值并且稳定值确实符合输入信号的幅度。5.2 与商用仪器交叉对比的经验如果你手头有商用锁相放大器哪怕是一台老旧的SR830之类拿它和你的数字实现做一次交叉对比是最有说服力的验证方式。我在做光电检测项目时把同一路信号同时接进自制仪器和商用仪器对比最终的幅度和相位输出。相似度极高唯一区别是噪声底栏不完全一样——商业仪器在模拟前端和接地设计上做过大量优化而MCU开发板的ADC参考噪声本身就比较大。但纯算法层面二者在输入信号信噪比不太差的情况下结果一致性完全可以接受。这次对比给我的启示是数字锁相放大器的主要瓶颈往往是ADC和模拟前端的信噪比而不是算法本身。算法部分只要数学上正确数值上稳定性能就能达到仪器的基本要求。真正拉开差距的是电源噪声抑制、模拟信号链设计、屏蔽和接地这些“硬功夫”。最后再分享一个我在实际过程中总结的小技巧调试阶段把参考频率改成和输入信号频率有微小偏差比如1%的差频输出的幅度曲线会变成一条随差频振荡的波形这能直观显示数字低通滤波器的带宽特性。通过观察这个波形的包络衰减速度你能快速测量出自己滤波器的等效时间常数是否符合设定从而验证整个链路是不是真的按照你理解的原理在工作。这个小办法帮我排查了至少两三个隐藏在参数配置里的低级错误建议你自己搭系统的时候也试一下。
返回列表