news 2026/10/22 4:37:03

一阶低通滤波的位移法实现:单片机ADC去毛刺的轻量级方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一阶低通滤波的位移法实现:单片机ADC去毛刺的轻量级方案

最近在调一个传感器数据采集的小项目,原始ADC值里总有几组毛刺,试过滑动平均、中值滤波,最后还是觉得一阶低通滤波最顺手。不过能在单片机上落地,关键一步是把系数改成“位移法”——也就是把滤波系数设计成 1/2^N,用右移运算替代浮点乘法。这个方法在单片机这种乘法资源金贵、浮点库又特别奢侈的环境里,性价比高到让人上瘾。这篇文章我会从数学原理、系数推导、C语言实现到调参踩坑,一次性讲清楚,适合正在被ADC噪声折磨、或者想把数字滤波真正落到MCU上的开发者。

1. 先搞明白一阶低通滤波在数学上做了什么

1.1 从RC电路到递推公式,大家都在用同一套东西

一阶低通滤波的“祖师爷”是RC电路:电阻电容串联,输入电压变化时,电容充放电,输出电压永远追着输入走,但追不上。它的连续时间方程很简单:

[ \tau \frac{dy(t)}{dt} + y(t) = x(t) ]

这里 (\tau) 是时间常数,越大,输出跟随越慢,滤波越强。单片机没法处理连续微分,所以第一步是离散化。用一阶向后差分近似微分,能得到下面这个经典递推式:

[ y[k] = y[k-1] + \alpha \cdot (x[k] - y[k-1]) ]

也可以写成:

[ y[k] = (1-\alpha) \cdot y[k-1] + \alpha \cdot x[k] ]

两种写法完全等价。(\alpha) 是一个 0 到 1 之间的系数,代表“新样本挤进输出状态的比例”。(\alpha) 越大,新数据权重越高,输出越激进,噪声抑制能力越差;(\alpha) 越小,输出越平滑,但信号变化被严重延迟。

这个公式所有教科书都会给,但真正做工程时,很多人忽略了一个重点:(\alpha) 不是一个拍脑袋定的数,它由目标截止频率 (f_c) 和采样频率 (f_s) 共同决定。

1.2 截止频率和 (\alpha) 到底怎么换算

假设输入信号频率远低于采样频率,可以推导出近似关系:

[ \alpha \approx \frac{2\pi f_c}{f_s} ]

更精确一点,需要解连续域到离散域的映射,常见的做法是用零阶保持或双线性变换,简化后常用:

[ \alpha = 1 - e^{-2\pi f_c / f_s} ]

两个式子在小 (\alpha) 时结果很接近。工程上如果只是选参数,直接用近似公式完全够用。

举个例子:采样率 (f_s = 1000Hz),想要截止频率 (f_c = 50Hz),那么 (\alpha \approx 2\pi \times 50 / 1000 \approx 0.314)。也就是说,每次更新时,把当前采样值和上一次输出差值的 31.4% 加进去。如果要求更平滑,比如 (f_c = 5Hz),那么 (\alpha \approx 0.0314)。

问题来了:0.0314 这种小数,在 8 位或者低端 MCU 上怎么乘?用浮点库?成本太高。写成定点 Q 格式?可以,但还有更骚的玩法——直接把 (\alpha) 锁死成二进制位移。

2. 为什么非要用位移法:单片机上的成本账实在太难看

2.1 一次浮点乘法在MCU上的真实开销

我最早用某款主频只有 8MHz 的 MCU 做ADC采样,原本滤波代码里写的是float alpha = 0.0314f; y += alpha * (x - y);。代码能跑,但总感觉哪里不对劲。后来一测,一次浮点乘法要调用软件浮点库,几十甚至上百个周期,再算上循环里的加减法,一个采样周期光滤波就吃掉几百个周期。

如果是带硬件浮点单元的 MCU 还好,但没有硬件 FPU 的入门级型号太多了。整数乘法倒是便宜,很多 Cortex-M 内核一个周期能完成,但遇到 0.0314 这种系数,先得把小数转换成整数,然后做乘法再移位,也就是定点运算。虽然可行,但写起来麻烦,且一不小心就会溢出。

相比之下,右移运算在绝大多数 MCU 上都是单周期指令,有些 8 位机可能要多几条指令,但整体开销依然远小于浮点乘法和整数乘法加定标。

2.2 系数取 1/2^N 后,乘法直接变成右移

位移法的核心思想特别简单:把 (\alpha) 固定成 (1 / 2^N),也就是二进制里 0.000...001 那种小数。比如:

  • (N=2):(\alpha = 1/4 = 0.25)
  • (N=4):(\alpha = 1/16 = 0.0625)
  • (N=5):(\alpha = 1/32 = 0.03125)
  • (N=8):(\alpha = 1/256 = 0.00390625)

这样递推式里的乘法alpha * (x - y)就变成了(x - y) >> N,整个滤波核心变成:

[ y = y + ((x - y) >> N) ]

一行代码解决。整数运算、无浮点、无乘法,速度飞快。

需要强调的是,这里的“位移法”不是“位移传感器”那种物理位移,而是“二进制移位运算”。标题里的“位移法实现”就是指用右移运算实现一阶低通滤波。第一次看到这个名字的人很容易误会,我先说清楚。

2.3 三种实现方式对比,别只盯着速度

实现方式核心操作典型开销代码复杂度缺点
浮点直接乘alpha * diff高(软件浮点库)低MCU资源紧张时扛不住
定点Q格式(diff * factor) >> Q中中需要精心定标,易溢出
位移法diff >> N极低极低系数只能取2的负幂次,频率分辨率受限

速度只是一方面,位移法最大的好处是代码可读性高、无溢出风险(前提是 diff 范围可控)。对于传感器数据这种信号范围明确的应用,用位移法几乎是最省心的选择。

3. 核心参数N怎么选:从目标截止频率反推出来

3.1 一个公式加一张表,搞定90%的场景

已知采样率 (f_s),想要目标截止频率 (f_c),先算:

[ \alpha = \frac{2\pi f_c}{f_s} ]

位移法要求 (\alpha = 1/2^N),所以:

[ N = \log_2\left(\frac{f_s}{2\pi f_c}\right) ]

取最接近的整数。比如 (f_s = 1000Hz),想要 (f_c \approx 5Hz),那么:

[ \alpha = \frac{2\pi \times 5}{1000} = 0.0314 ]

离 0.0314 最近的 (1/2^N) 是 (1/32 = 0.03125),所以 (N=5)。然后实际截止频率变成:

[ f_c' = \frac{f_s \cdot 2^{-N}}{2\pi} = \frac{1000 / 32}{2\pi} \approx 4.97Hz ]

跟目标 5Hz 差不到 1%,完全够用。

下面这张表是我自己常用的参数速查表,采样率按 1kHz 算,直接抄就行:

N等效 (\alpha)实际截止频率阶跃响应90%所需采样数适用场景
20.2539.8Hz约9噪声不大、需要快速跟随
30.12519.9Hz约18一般传感器信号
40.06259.95Hz约37噪声明显、信号变化不快
50.031254.97Hz约74慢速温度、液位等
60.0156252.49Hz约147强平滑,滞后严重

“阶跃响应90%所需采样数”是什么意思?就是输入突然跳变时,滤波输出从旧的稳态走到新稳态的 90%,需要多少个采样周期。用近似公式 (n \approx \ln(0.1) / \ln(1-\alpha)) 算出来。这张表能帮你在选参数时直观感受到“N越大,输出越肉”。

3.2 N只能取整数,这个限制到底是好事还是坏事

移位位数 N 只能是正整数,意味着 (\alpha) 不能随意连续变化。这看起来是缺点,但其实大多数应用根本不需要精确的频率响应曲线。

我见过有人为了把截止频率凑到整数,硬上 Q16 定点乘法,结果代码复杂、得时刻防溢出。真没必要。滤波器的本质是“把噪声压下去、把趋势留住”,截止频率差个几赫兹,肉眼根本分辨不出来。先选一个和需求同量级的 N,跑起来看波形再微调,比精算一百遍都管用。

如果非要更精确,可以把两个一阶滤波器串联,得到二阶滤波效果,但那是另一个话题了。

4. 单片机实战:一个干净的整型位移滤波模块

4.1 最简实现,五行代码跑通流程

先写一个最核心的版本。假设 ADC 输出是 12 位,范围 0~4095,滤波状态变量用 int32_t 保存,避免累加时溢出。

typedef struct { int32_t y; /* 当前滤波输出 */ uint8_t n; /* 右移位数 N, 对应 alpha = 1/2^N */ } lpf_shift_t; void lpf_shift_init(lpf_shift_t *f, int32_t x0) { f->y = x0; /* 用第一个采样值作为初始输出 */ f->n = 5; /* 先给一个默认值, 后面按需改 */ } int32_t lpf_shift_update(lpf_shift_t *f, int32_t x) { int32_t diff = x - f->y; f->y += diff >> f->n; /* 需要确认编译器对有符号数使用算术右移 */ return f->y; }

调用方式更简单,每采样到新值就塞进去:

lpf_shift_t filt; int32_t raw, smooth; lpf_shift_init(&filt, adc_read()); while (1) { raw = adc_read(); smooth = lpf_shift_update(&filt, raw); /* 用smooth做后续处理 */ }

这段代码跑在绝大多数 MCU 上都不会出问题。唯一需要注意diff >> f->n的行为:C语言标准规定,对有符号负数右移是实现定义的,不过主流编译器和 MCU 都做算术右移,也就是符号扩展。为了严格可移植,下面有一个更安全的写法。

4.2 防负数右移的可移植版本

如果你用的是编译器选项默认行为,diff >> n通常没问题。但如果你要把代码移植到行为不明确的工具链上,可以这样:

static int32_t int32_shift_right(int32_t v, int n) { if (v >= 0) { return v >> n; } return -((-(v + 1) >> n) + 1); }

这个写法避免了对负数右移行为的依赖,也规避了v = INT32_MIN时取反溢出的问题。不过实现稍绕,解释一下:负数 v 右移 n 位后,在算术右移下结果等于向下取整的除法v / 2^n。上面的表达式用补码性质换算出来,保证和算术右移一致。其实绝大部分场景不需要这么谨慎,但如果你在写跨平台库代码,这是个值得抄的片段。

4.3 固定采样率比代码本身还重要

位移法的 (\alpha) 隐含了采样周期,默认两次调用间隔相等。如果你在主循环里随手读ADC,间隔可能一会儿 10ms、一会儿 20ms,滤波的截止频率就在漂移,看起来就是“有时候平滑、有时候灵敏”。

我自己的做法是放在定时器中断里,比如定时器 1ms 触发一次中断,中断里读ADC并调用一次滤波。或者在 RTOS 里建一个周期任务,固定执行周期。这样滤波响应才和前面算出来的截止频率一致。

如果项目确实没法固定采样周期,也可以每次读一个高精度 tick,根据实际时间差调整 N。但那样就没有“位移法”的速度优势了,不如回到浮点/定点实现。所以还是老老实实固定采样率最舒服。

5. 实测调优:同一组数据,不同N的波形差多少

5.1 给ADC信号喂一个方波,肉眼可见滞后

我在测试平台上用信号发生器给ADC输入一个 1Hz 方波,采样率 1kHz,然后分别用 N=2、N=4、N=6 处理。现象很明显:

  • N=2:输出很“毛”,方波边沿基本跟得住,但平顶段噪声也很大。
  • N=4:噪声压下去不少,边沿略微圆滑,肉眼还能接受。
  • N=6:输出非常平滑,但方波的上升沿被拉成了一个缓慢爬坡,大约要几百个采样才能到新稳态。

这就是滤波的经典取舍:平滑和滞后两端不可兼得。位移法的 N 每增加 1,时间常数翻倍,滞后也翻倍;但高频噪声衰减得更狠。

5.2 最省事的调参方法:先粗选,再试相邻值

不要指望一次算对,我是这样做的:

  1. 根据目标截止频率,用前面的公式算出一个 N。
  2. 直接烧录进去,观察原始波形和滤波波形的重叠对比。
  3. 如果噪声还明显,把 N 加 1;如果信号响应太迟钝,把 N 减 1。
  4. 最终选一个肉眼最能接受的 N,然后固定下来。

这个“肉眼调参法”看起来不够严谨,但在工程里非常有效。真要量化评估,可以记录一段原始数据和滤波数据,在电脑上离线扫描不同 N 的均方误差或者信噪比,选出理论最优值,再回填到单片机里。

我自己的项目最后选的是 N=5。原因很简单:传感器有效信号的最高频率大概在 2Hz 左右,噪声集中在几十Hz以上,5Hz 截止频率刚好把两者分开。

5.3 位移法滤波不等于移动平均

有次和同事聊,他说“这不就是数字的滑动平均吗?”还真不是。滑动平均是 FIR 结构,窗口长度决定响应,数据要保存在环形缓冲区里;一阶低通是 IIR 结构,只依赖上一个输出,占用的内存小得多。在内存按字节算的单片机上,尤其看重这个差别。

位移法的一阶低通,状态量只有一个y,最多再加一个内部精度扩展量,内存开销几乎可以忽略。窗口再短的滑动平均,也得一个数组,这是本质区别。

6. 避坑记录:位移法里最容易翻车的三个细节

6.1 负数右移的“咬尾”问题,只能靠约定解决

前面简单提过,有符号右移在 C 标准里是实现定义的。实际上绝大多数编译器对负数采用算术右移,也就是空位补符号位,这样(-1) >> 5仍然是-1,逻辑上等价于向下取整除法。如果你不小心把diff定义成了uint32_t,负数就会被当成很大的正数,右移后滤波结果直接飞出天际。

所以要养成熟练:滤波差值一律用int32_t。如果代码是跨平台 C 库,最稳妥还是写一个可移植的int32_shift_right,或者直接用/ (1 << n)让编译器优化。特别提醒:/ (1 << n)对于负数除法和移位有区别,C 语言规定整数除法向零取整,不是向下取整。所以如果写diff / (1 << n),负数时行为会跟diff >> n不一致。这一点很多人没注意。

6.2 截断死区:N太大会让输出在输入缓慢变化时“不动”

最经典的坑是:当diff绝对值小于2^N时,diff >> N直接变成 0。比如 N=5,输入变化只有 15 个 LSB,15 >> 5 = 0,滤波输出纹丝不动。传感器缓慢漂移时,输出会变成阶梯状,甚至长时间卡在一个值上,看起来像“失效”。

解决办法是内部状态用更高精度表示,我用的是 QN 格式:把滤波输出 (y) 左移 N 位存放,真正输出时再右移回来。相当于把每次舍弃的小数余量保留下来,累积到下一次。

typedef struct { int32_t z; /* QN格式状态: z = y << N */ uint8_t n; /* N */ } lpf_qn_t; void lpf_qn_init(lpf_qn_t *f, int32_t x0, uint8_t n) { f->z = x0 << n; f->n = n; } int32_t lpf_qn_update(lpf_qn_t *f, int32_t x) { int32_t y = f->z >> f->n; /* 当前输出 */ f->z += x - y; /* 误差以1/2^N的权重进入状态 */ return f->z >> f->n; }

这个版本和普通版本的区别在于z不是直接维护输出值,而是维护“输出值和余量”的合体。每次更新时,即使本次误差小于 1 个 LSB,也会被存进z的低 N 位,累积一段时间后输出自然变化。实测下来,N=5 时原始版本在输入缓慢变化时输出死在一个值上,改进版能看到平滑的微小爬坡,效果非常接近浮点实现的滤波。

代价是z需要更大的位宽。如果原始信号范围是 0~4095,N 最大也别超过 12,否则x0 << n可能超出int32_t的可表示范围。计算上 (4095 << 12 = 16773120),int32 完全装得下;如果信号范围更大,就上int64_t或者换定点库。

6.3 采样周期抖动会改变截止频率,别把锅甩给滤波器本身

有一种“神秘故障”:同一份代码,在开发板上一切正常,放到现场设备上就变得忽快忽慢。最后查出来是系统主循环里还有其他耗时操作,滤波函数的调用间隔在 0.9ms 到 3ms 之间乱跳。位移法本质上假设每次调用间隔相等,间隔抖动会直接映射成截止频率抖动。

解决方式已经说过,用定时器中断或者固定周期任务。如果现场实在没有固定周期条件,可以用一个高精度计数器记录两次调用的实际间隔,然后动态选择不同的 N 来近似补偿,但那样复杂度会上升,并不划算。我更建议把滤波放在中断里,主循环只负责取结果。

最后顺手分享一个小扩展

如果后面要做更强的抑制,可以在位移法基础上串联两个不同 N 的一阶滤波,等效于二阶低通,截止带衰减更快。代码上就是两个结构体实例,依次调用两次lpf_qn_update。注意两个滤波器的 N 不要选成同一个,否则二阶响应会有一个双重极点,相位滞后更大,不如用一个较小 N 和一个较大 N 搭配,通常能兼顾响应和噪声抑制。

我现在所有传感器平滑逻辑都默认用这套 QN 位移滤波,只在初始化时改一下 N。先在电脑上离线模拟几组波形,再烧进 MCU 看实波形,基本一次就能调到位。位移法不是什么高深理论,但在资源有限的单片机上,它是真的省心。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:04:18

两节串联电池主动均衡:基于MP2642GR-Z与MK51的工程实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 16:03:25

字符串工程实战:LCP算法的生产级实现与Unicode安全设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 6:09:15

场景文字检测MATLAB实践:MSER参数调优与OCR识别完整流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 8:50:08

FPGA数字时钟设计:SOPC软核方案快速开发与调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 16:50:03

Apollo-11 制导软件深度拆解:极限资源下的实时调度与容错设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 22:11:48

头条小程序对接微信支付宝:H5跨容器支付中转全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华