
我刚入行做信号处理那阵子被“指数移动平均”EMA和“一阶低通滤波”这两个名词折腾得不轻。做控制系统的人天天把“一阶惯性环节”“截止频率”挂嘴边做数据分析的同事一口一个“EMA”“平滑系数”等我翻开算法一看好家伙递推公式长得一模一样就是符号习惯不同。这个发现帮我省了后面好几年的沟通成本——这两东西本来就是同一只猫只是不同行业的人叫了不同的名字。这篇内容不打算堆公式吓人而是想把这层窗户纸捅破从数学本质、物理意义、实际调参到工程落地的坑一次性讲透。无论你是写单片机滤波、调PID、做量化交易信号还是训练深度学习模型时见到优化器里的momentum只要搞懂EMA和低通滤波的关系很多参数就不再是玄学。1. 内容整体设计与思路拆解1.1 为什么两个名字一个本质先看两个最常见的公式写法。做数据分析或深度学习的同学大概率见过这种形式[ y_t \alpha x_t (1 - \alpha) y_{t-1} ]这里 ( \alpha ) 叫平滑因子取值范围0到1( y_t ) 是当前时刻的输出( x_t ) 是当前时刻的输入( y_{t-1} ) 是上一时刻的输出。这个式子就是标准的指数移动平均。搞控制或信号处理的人则更习惯写成[ Y(s) \frac{1}{Ts 1} X(s) ]或者用差分方程表达[ y_k \frac{T_s}{T T_s} x_k \frac{T}{T T_s} y_{k-1} ]这里 ( T ) 是时间常数( T_s ) 是采样周期。如果把这个式子里的系数和上面EMA的公式比对一下会发现[ \alpha \frac{T_s}{T T_s} ]本质上就是同一个递推式。数字世界里没有微积分一切连续域的概念都要通过离散化落地而“一阶低通滤波”的离散形式恰恰好就是EMA。一个是统计学的视角——对历史数据加权平均越近的数据权重越大一个是信号系统的视角——对高频分量进行衰减。两者描述的物理过程是同一个只是各自的切入点不同。我当年踩过的坑是在MATLAB里用filter函数做低通滤波调了半天截止频率又在Python里用ewm做数据平滑调了半天alpha最后发现这两个东西画出来的波形几乎重合。那时候才意识到跨领域的工具其实在背后共享同一套数学。1.2 这套算法到底解决了什么问题在实际工程里我们经常面临两种看起来很矛盾的需求既要响应快又要输出稳。比如说读一个温度传感器的数据原始信号里既有真实的温度变化趋势又叠加了工频干扰和随机噪声。如果不做任何处理直接用原始数据做控制决策PID控制器会被噪声逼得频繁输出如果滤波太狠真实温度已经变化了输出却还慢吞吞地跟着系统就会产生滞后。EMA/一阶低通滤波的核心就是提供了一个用“历史平滑度”换“噪声抑制”的旋钮转动旋钮的那一个参数就是 ( \alpha ) 或 ( T )。和更复杂的滤波器比如巴特沃斯、卡尔曼相比EMA的实现成本低得惊人——只需要存一个上一次的输出值做一次乘法和一次加法几行C代码就搞定没有状态矩阵没有协方差更新。这在单片机、FPGA、实时控制系统里有着天然的落地优势。1.3 选型的边界在哪里必须说清楚EMA不是万能的。它本质上只有一阶的衰减特性高频段每倍频程只衰减6dB。如果噪声和信号在频带上离得不够远单靠EMA很难做到既保留信号细节又滤干净噪声。这时候你可能需要二阶或高阶滤波或者上卡尔曼滤波。另外EMA会引入相位滞后滞后量和选择的平滑强度直接相关对于那些对相位敏感的场景——比如电机编码器的速度环反馈、医疗信号中的波形对齐——要特别注意这个滞后带来的副作用。所以正确的选型思路是先搞清楚信号的频谱分布噪声在哪个频段有用信号在哪个频段两者的重叠程度如何。如果重叠小一阶滤波完全够用如果重叠大再加阶数或换算法。2. 核心细节解析与实操要点2.1 关键参数之间的换算关系这是很多人容易蒙圈的地方。不同场景下大家用的参数名不同——有的用 ( \alpha )有的用时间常数 ( T )有的用截止频率 ( f_c )有的直接说“取最近N个点的平均”。这些说法之间怎么换算我直接给结论先说 ( \alpha ) 和截止频率 ( f_c ) 的关系。一阶低通滤波器的幅频响应为[ |H(f)| \frac{1}{\sqrt{1 (f / f_c)^2}} ]当 ( f f_c ) 时增益下降到 ( 1/\sqrt{2} \approx 0.707 )也就是 -3dB 点。对于离散化的EMA这个截止频率可以近似写成[ \alpha 1 - e^{-2\pi f_c T_s} ]或者反过来[ f_c -\frac{\ln(1 - \alpha)}{2\pi T_s} ]当 ( \alpha ) 比较小滤波较弱或采样率比较高时有一个更简洁的近似[ \alpha \approx \frac{2\pi f_c T_s}{1 2\pi f_c T_s} ]而“取最近N点做移动平均”和EMA的等效关系经验上大约有[ \alpha \approx \frac{2}{N 1} ]或者说等效N大约为[ N \approx \frac{2}{\alpha} - 1 ]这里我建议你把 EMA 和简单移动平均SMA的阶跃响应都画出来看一下——EMA 的等效记忆长度大约就是 ( 1/\alpha ) 个采样点而SMA的等效记忆长度是N/2个采样点。两个对比一下下回别人再跟你说“我用的是N点平均”你就知道该换算成多大的 ( \alpha )。做个实际换算例子。假设采样率 ( F_s 1000\text{Hz} )希望截止频率为 ( f_c 20\text{Hz} )那么[ \alpha 1 - e^{-2\pi \times 20 / 1000} 1 - e^{-0.1257} \approx 0.118 ]也就是说在这个条件下把平滑系数设成0.118左右就能得到大约20Hz的一阶低通效果。2.2 初始值怎么设置新手最容易忽略的问题是初始值 ( y_0 ) 怎么定。理论上EMA的递推式需要一个初值工程上常见做法有三类第一类把初值设为第一次采样值 ( y_0 x_0 )这适合系统启动后信号本身就比较平稳的场景第二类把初值设为0适合传感器输出本身以0为基线的场景第三类做“预热”——先空转一定数量的采样点比如用前50个点的均值作为初值然后再进入正式滤波输出。我个人的经验是对于绝大多数工业场景第一类做法就够了实现最简单启动瞬间的阶跃误差通常可以接受。但对于那些开机瞬间就要求滤波输出必须准确的应用比如电池管理系统的电流采样预热初始化更稳妥。这里要强调的是EMA的初值效应不会永久存在它按 ( (1-\alpha)^k ) 的速率衰减过了大约 ( 3/\alpha ) 个采样点之后就基本可以忽略。2.3 浮点还是定点这是一个问题写嵌入式实现的时候遇到一个非常实际的问题用浮点运算还是定点运算在PC或应用处理器上直接用float毫无压力。但在8位/32位单片机——尤其是没有FPU的Cortex-M0——浮点运算会占用大量CPU周期。我记得有一次在STM32F103上同时做了8路ADC滤波一开始全用float结果ADC采样率一提高中断处理时间直接超预算。解决办法是采用定点数实现。把递推式两侧同时放大 ( 2^n ) 倍用整数运算代替浮点。比如取 ( \alpha 1/256 )代码可以写成// 定点EMAFilterQ16格式 int32_t filtered 0; int32_t input_q16 adc_value 16; // 输入转Q16 // 每次采样调用一次 filtered ((input_q16 - filtered) 8); // 等效 alpha 1/256这段代码的意思是 ( y y (x - y) / 256 )先计算误差再按比例修正。整个运算只需要一次移位、一次加减、一次乘法效率极高。选择 ( 1/256 ) 这种2的幂次作为 ( \alpha )就可以把除法变成移位这是嵌入式实现里最常用的技巧。如果 ( \alpha ) 不是2的幂次就需要先乘以一个整数系数再移位。比如想设 ( \alpha 0.1 )可以用 ( \alpha_{fixed} \lfloor 0.1 \times 256 \rfloor 25 )然后filtered (((input_q16 - filtered) * 25) 8);这里需要注意溢出问题input_q16 - filtered是两个16位数的差最多需要17位才能表示再乘25结果需要大约22位。所以中间变量至少要用32位整数。在实现精度和性能之间做个取舍这是嵌入式滤波的必修课。3. 实操过程与核心环节实现3.1 Python端快速验证在把算法搬进嵌入式或生产环境之前我建议先在Python里把参数和效果验证一遍。这里分享一套我常用的验证流程。步骤一造一段带噪声的测试信号。import numpy as np import matplotlib.pyplot as plt fs 1000 # 采样率 Hz t np.arange(0, 5, 1/fs) # 真实信号低频正弦 阶跃 true_signal np.where(t 2.5, 1.0, 0.0) 0.5 * np.sin(2 * np.pi * 1 * t) # 噪声高频正弦 白噪声 noise 0.2 * np.sin(2 * np.pi * 120 * t) np.random.randn(len(t)) * 0.1 measured true_signal noise plt.figure(figsize(10, 4)) plt.plot(t, measured, alpha0.5, labelmeasured) plt.plot(t, true_signal, k--, labeltrue) plt.legend()步骤二实现EMA滤波器并测试不同 ( \alpha ) 的效果。def ema_filter(data, alpha): y np.zeros_like(data) y[0] data[0] for i in range(1, len(data)): y[i] alpha * data[i] (1 - alpha) * y[i-1] return y for alpha in [0.05, 0.2, 0.8]: y ema_filter(measured, alpha) plt.plot(t, y, labelfalpha{alpha})运行结果会让你直观感受到( \alpha ) 越大输出越接近原始信号但噪声残留也多( \alpha ) 越小曲线越平滑但滞后越明显。这里注意观察阶跃响应的上升时间( \alpha0.05 ) 时阶跃大约要过几十毫秒才能跟上真实信号。步骤三使用SciPy的一阶低通滤波接口验证两者一致。from scipy.signal import butter, lfilter def butter_lowpass(data, cutoff, fs, order1): nyq 0.5 * fs normal_cutoff cutoff / nyq b, a butter(order, normal_cutoff, btypelow, analogFalse) return lfilter(b, a, data) y_lp butter_lowpass(measured, 10, fs) # 10Hz低通 alpha_equivalent 1 - np.exp(-2*np.pi*10/fs) y_ema ema_filter(measured, alpha_equivalent) plt.plot(t, y_lp, labelscipy butter(10Hz)) plt.plot(t, y_ema, --, labelfema alpha{alpha_equivalent:.3f})两画出来曲线几乎是重合的。这就是我在文章开头说的“同一个东西”最直观的证明。3.2 C语言嵌入式实现在单片机上实现EMA代码短到不需要额外的头文件。下面是我在多个项目里用过的一套模板带有上下限保护防止整型溢出。typedef struct { int32_t output_q16; // Q16格式的滤波输出 uint32_t alpha_q16; // Q16格式的平滑系数 } EMAFilter; int32_t EMAFilter_Update(EMAFilter *f, int32_t input_q16) { // 注意使用64位中间变量避免溢出 int64_t diff (int64_t)input_q16 - f-output_q16; f-output_q16 (int32_t)((diff * f-alpha_q16) 16); return f-output_q16; } // 初始化alpha0.1 0.1*655366554 void EMAFilter_Init(EMAFilter *f, uint32_t alpha_q16, int32_t init_q16) { f-alpha_q16 alpha_q16; f-output_q16 init_q16; }这段代码把系数统一放大了65536倍Q16格式。好处是用户侧只需要关心真实 ( \alpha )内部运算自动处理小数。注意中间变量用了int64_t这是必须的否则diff * alpha_q16的结果很容易溢出32位范围。选择Q16是因为它覆盖了16位ADC输入的范围ADC值左移16位后变成约 ( \pm 2^{31} ) 量级正好落在32位带符号整数内。如果你的输入不是Q16格式想直接吃原始ADC值也可以把接口改一下内部统一左移到Q16再处理。关键是输入输出要一致别混着来。3.3 参数标定的实操方法实际调参时我不太建议大家对着公式硬算 ( \alpha )更推荐用阶跃响应标定法。方法很简单让传感器或系统输入一个阶跃信号比如给电机一个固定占空比、给温控加热器一个目标功率记录滤波后的输出波形。然后数一下从阶跃开始到输出达到最终值的63.2%用了多少时间这个时间就是一阶系统的时间常数 ( \tau )。再根据 ( T_s ) 反推[ \alpha \frac{T_s}{\tau T_s} ]举个例子我用一个光电传感器测距离采样周期10ms给系统一个阶跃位置变化看滤波输出发现约120ms后达到63.2%的目标值。那 ( \tau 120\text{ms} )( \alpha 10 / (120 10) 0.077 )。直接用这个值滤波效果八九不离十。为什么是63.2%因为一阶系统的阶跃响应是 ( y(t) 1 - e^{-t/\tau} )当 ( t \tau ) 时输出正好是稳态的 ( 1 - e^{-1} \approx 0.632 )。这比记公式直观得多而且在现场调试的时候没有MATLAB也能算。如果想更精确一点可以取两个时间点做拟合。典型做法是取25%和75%两个响应时刻利用 ( t_{25} 0.288\tau )( t_{75} 1.386\tau )两个相减得到 ( t_{75} - t_{25} 1.098\tau )从而反推 ( \tau )。这个方法能减小单个测量点引入的读数误差。顺便提一下( \alpha ) 的取值通常都在0.01到0.5之间。小于0.01系统输出会迟钝到难以接受大于0.5滤波效果就非常微弱了。大部分工业现场我最后落定的值都在0.05到0.2这个区间。4. 常见问题与排查技巧实录4.1 滤波输出震荡或超调有朋友找我调一个液位检测系统说加了低通滤波之后输出反而比原始数据还“抖”。我一看代码问题出在把EMA用成了“多次叠加”。他写的伪代码是这样的for (int i 0; i 10; i) { y alpha * x (1 - alpha) * y; }每次采样来了对同一个点做了10次迭代。这相当于等效 ( \alpha 1 - (1-\alpha)^{10} )滤波强度大幅增强同时相位滞后也成倍增加。关键是这样的“重复迭代”会让系统对噪声产生一种过阻尼响应看起来就是输出在目标值附近黏滞、震荡。正确的做法是每个采样周期只做一次递推。如果你想更强滤波应该调 ( \alpha ) 本身不要靠循环次数硬凑。4.2 启动阶段输出异常前面说过了初值设置不当会导致启动阶段滤波输出和真实信号偏差较大。有一种特殊情况特别坑传感器在开机瞬间会先输出一个短暂的“饱和毛刺”比如上电瞬间电流尖峰导致的误读数如果把这个毛刺作为初值y0 x0滤波输出会带着这个尖峰值慢慢衰减后面几百毫秒的输出都会被抬高。针对这种情况我的做法是上电后头50个采样点先做简单平均等系统稳定后再切入EMA。代码实现也很简单#define WARMUP_SAMPLES 50 void UpdateWithWarmup(EMAFilter *f, int32_t input_q16) { static uint32_t warmup_count 0; static int64_t warmup_sum 0; if (warmup_count WARMUP_SAMPLES) { warmup_sum input_q16; warmup_count; if (warmup_count WARMUP_SAMPLES) { f-output_q16 (int32_t)(warmup_sum / WARMUP_SAMPLES); } } else { EMAFilter_Update(f, input_q16); } }这样启动瞬间的毛刺被前50个点的均值稀释掉了EMA的初值也处在一个合理的基线附近整个启动曲线干净很多。4.3 整型截断导致的零点死区嵌入式用整型运算时有个特别隐蔽的坑。看下面这段代码filtered ((input - filtered) 8);当input - filtered的绝对值小于256时右移8位的结果是0滤波输出就卡住不动了。这在稳态附近是个“死区”——输入已经小幅变化输出却完全没有跟随表现出来就是输出有肉眼可见的台阶。这个问题的本质是量化误差被右移放大了。解决办法很直接加大中间计算精度或者改用带舍入的右移。// 四舍五入式右移 int32_t rounded (diff 0) ? ((diff 128) 8) : ((diff - 128) 8);或者更彻底一点把状态变量保留更高精度的格式比如Q24而不是Q16只在输出时才转换。这样即使输入变化很小状态量经过多次递推也不会丢失。我在一个压力传感器项目里遇到过这个问题稳态时压力本来应该是恒定值但数据显示输出偶尔会缓慢漂移一格两格。排查了很久最终定位到就是这里——ADC读数差小于256滤波结果被截断了。改用Q24格式后问题彻底消失。4.4 (\alpha) 动态切换时的不连续有些应用希望滤波强度能实时变化信号平稳时用大 ( \alpha ) 保证响应快信号毛刺多时自动调小 ( \alpha ) 增强平滑。这就面临一个问题——( \alpha ) 突变时滤波输出会产生一个跳变。原因很好理解EMA的状态变量 ( y_{t-1} ) 是按旧 ( \alpha ) 累积出来的历史“记忆”突然切换到新 ( \alpha )整个递推尺度和历史状态不匹配输出自然跳一下。解法有两个。方案一是做 ( \alpha ) 的缓变每次只让 ( \alpha ) 朝目标值移动一小步比如限制每次变化量不超过0.001方案二是切换的时候重新初始化状态把当前的输入直接作为新状态初值。前者平滑但响应稍慢后者干脆利落但会丢掉历史记忆。具体选哪个取决于你的应用更在乎输出连续性还是跟踪速度。我自己在实际代码里更常用方案一因为它能保持滤波器内部状态的一致性二来输出不会出现人为的“骤跳”。4.5 时间常数和采样周期的匹配陷阱还有一个高频坑就是采样周期 ( T_s ) 不固定。很多系统采样周期并不严格恒定比如操作系统调度抖动、中断嵌套等如果你用固定 ( \alpha )等效的截止频率会随采样率漂移。比如前面那个例子设计时采样率1000Hz选定 ( \alpha 0.118 )截止频率20Hz。如果某段时间采样率掉到500Hz同一 ( \alpha ) 对应的截止频率大约是10Hz滤波响应变钝如果采样率反而升高到2000Hz截止频率变成约40Hz噪声抑制变差。严格的做法是每次采样时根据实际时间间隔重新计算 ( \alpha )// dt为实际采样间隔单位秒 // tau为期望时间常数 float alpha dt / (tau dt);这需要调用浮点运算但在大多数场合一帧数据刷新一次的时间算一次这个公式绰绰有余。这个方法在电机FOC控制里尤其重要因为PWM中断的执行时间往往有微小抖动。5. 进阶玩法与跨界启发5.1 高阶扩展串联EMA等于二阶低通如果单级EMA不够陡不需要上什么复杂算法把两级EMA串起来就等效于二阶低通滤波[ y_1[k] \alpha x[k] (1-\alpha) y_1[k-1] ] [ y_2[k] \alpha y_1[k] (1-\alpha) y_2[k-1] ]这样的二阶结构在远离截止频率的地方衰减斜率从6dB/oct变成12dB/oct。代价是相位滞后更大而且可能出现过冲阻尼比由两级系数决定。如果你想让二阶系统临界阻尼两个级联的 ( \alpha ) 应该取不同的值而不是同样。具体推导不再展开了你只要知道EMA的“级联”思路和控制理论里的“高阶系统”是相通的。很多时候我们不需要真去记一堆波特图知识理解了这个思路扩展方向就很清晰。5.2 非线性变体自适应EMA前面提到动态切换 ( \alpha ) 容易跳变但如果设计得好自适应EMA的效果比固定系数强得多。一个简单的思路是根据输入变化率来调整 ( \alpha )。如果变化率很大说明可能是真信号而非噪声就加大 ( \alpha ) 让滤波快速跟上如果变化率小大概率是平稳段噪声就减小 ( \alpha ) 增强平滑。def adaptive_ema(x, alpha_min0.02, alpha_max0.5): y np.zeros_like(x) y[0] x[0] for i in range(1, len(x)): diff abs(x[i] - x[i-1]) # 根据一阶差分幅度动态调整alpha alpha alpha_min (alpha_max - alpha_min) * np.tanh(diff / 10.0) y[i] alpha * x[i] (1 - alpha) * y[i-1] return y这种思路在运动控制、姿态解算、传感器融合里都非常常见。注意tanh只是一个可选的映射函数实际使用时可以替换成任何你把“变化率”映射到“平滑强度”的单调函数。项目的核心参数是上下限的设定——上限决定响应极限下限决定平稳时的噪声底限。5.3 深度学习和优化器视角聊到最后必须提一个跨界得非常远的地方深度学习中优化器的动量项本质上就是一阶低通滤波。SGD with momentum的更新公式是[ v_t \beta v_{t-1} \nabla L_t ] [ \theta_{t1} \theta_t - \eta v_t ]这里的 ( \beta )通常取0.9或0.99就是平滑系数梯度序列被做了一次低通滤波高频震荡被压平优化方向变得更稳定。这和ADC低通滤波用的是同一个数学。再比如Adam里的一阶矩估计和二阶矩估计就是两个不同衰减系数的EMA——( \beta_10.9 ) 管梯度均值( \beta_20.999 ) 管梯度方差。理解了这层关系之后调优化器参数就不再是纯背经验值( \beta0.9 ) 意味着等效记忆长度为 ( 1/(1-\beta) 10 ) 步( \beta0.99 ) 则是100步。这和滤波器的记忆深度是完全对应的。5.4 扩展阅读建议如果对滤波器设计的深度感兴趣建议按这个顺序往下走第一步把SMA、EMA、加权移动平均WMA放在同一组数据上做对比感受记忆长度和响应速度之间的权衡。第二步用波特图工具比如Python的scipy.signal.bode画出EMA的频响曲线把“截止频率”这个概念具象化。第三步尝试二阶巴特沃斯和二阶EMA级联的对比观察频响陡峭程度和相位差异。第四步接触卡尔曼滤波器你会发现卡尔曼的稳态就是一个动态调整 ( \alpha ) 的自适应EMA——那个“卡尔曼增益”K本质上就是不断在线优化的平滑系数。我自己走到第四步的时候回头看那些天天在用的滤波器公式脑子里已经不再是孤立的工具碎片而是一张互相关联的网。6. 个人经验总结做了这么多年信号处理和数据平滑我的体会是指数移动平均和一阶低通滤波是这个领域里性价比最高的算法。它简单到一行代码能写完却能在80%的工程场景里解决问题。而理解它的深度决定了你是把它当成一个死板的公式去套用还是能理解参数背后的物理含义真正根据信号特性和系统要求来调出最优响应。再分享一个习惯我在设计滤波器的初期从来不在代码里直接写“magic number”。我会在注释里标明这个 ( \alpha ) 对应的截止频率是多少、时间常数是多少、在什么采样率下有效。哪怕过三个月回来看一眼就能明白当初为什么选这个值。这一点建议送给所有踩过“自己写代码结果自己看不懂”坑的朋友。最后动手永远比看文章有效。找一段真实含噪数据写一个三行版的EMA把 ( \alpha ) 从0.01一点点调到0.9观察输出的变化。你会发现这个最“简单”的滤波器藏着很多用一次就忘不掉的直觉。