巴特沃斯滤波器从MATLAB到C代码实现:完整工程实践指南

发布时间:2026/10/24 3:02:53
巴特沃斯滤波器从MATLAB到C代码实现:完整工程实践指南 做滤波器落到C代码这件事我前前后后帮朋友调过好几轮最大的感触是MATLAB里点两下出来的曲线很漂亮但把系数搬进C语言那一刻才是真正的开始。尤其是巴特沃斯滤波器很多人以为butter(n, Wn)拿到b和a就完事了结果往嵌入式里一跑波形不对、相位乱跳、甚至直接发散。这篇内容我就拿一个完整的例子把“设计指标 - MATLAB求系数 - 二阶节拆分 - C代码实现 - 验证排查”这条链路讲透重点说说那些文档里不会写、但实操里一定会遇到的事情。适合刚接触数字滤波的嵌入式工程师、自动化方向的学生以及任何想把MATLAB算法真正变成可运行C代码的人。1. 滤波方案选型为什么IIR偏偏用巴特沃斯1.1 巴特沃斯 vs 切比雪夫/贝塞尔工程选择的底层逻辑数字滤波器家族里IIR滤波器其实有不少分支巴特沃斯、切比雪夫I型/II型、椭圆滤波器、贝塞尔滤波器。为什么工程里默认首选巴特沃斯核心原因在于它的通带内幅度响应最平坦没有纹波。通带内有纹波意味着什么比如你在采集一个传感器信号原始信号里有一个50Hz的有效分量切比雪夫滤波器在通带边缘可能会让你这个分量幅度忽大忽小误差就直接带进测量结果。巴特沃斯不会这样它在通带内是一条非常平缓的曲线代价是过渡带比切比雪夫宽一些。如果你的应用是采集、控制和一般的数据分析没有极其苛刻的频率选择性要求巴特沃斯是最省心的选择。我做过一个对比测试同样是4阶低通滤波巴特沃斯的-3dB点在100Hz切比雪夫I型纹波1dB时相同的过渡带宽度可以做到大约70Hz开始衰减看起来切比雪夫更“陡”。但换来的结果是通带内幅度有正负0.5dB左右的起伏。对于闭环控制系统这种起伏会在特定频率上变成增益误差严重时影响稳定性。所以我的原则是追求“平坦”和“可靠”选巴特沃斯追求“极限陡峭”才去考虑切比雪夫或椭圆而且要提前想清楚纹波代价。1.2 指标分解通带、阻带、过渡带怎么从需求逆推阶数滤波器的设计不是直接拍脑袋定一个“几阶”就完了而是先有指标再由指标反推阶数。经典指标有三样通带上限频率、阻带下限频率、阻带最小衰减。举个具体例子。一个数据采集系统采样率fs 1000Hz需要提取100Hz以下的有效信号同时希望200Hz以上的干扰至少被压掉40dB。那么通带频率fp 100Hz阻带频率fr 200Hz通带最大允许衰减Ap 1dB这个可以理解成我允许100Hz以内的信号幅度稍微有点损失但控制在1dB以内约等于12%的幅度偏差阻带最小衰减As 40dB200Hz以上至少要衰减到1%在MATLAB里这段设计就是两行代码的事fs 1000; % 采样率 fp 100; % 通带频率 fr 200; % 阻带频率 Ap 1; % 通带最大衰减(dB) As 40; % 阻带最小衰减(dB) % 根据指标反推最小阶数 n 和实际截止频率 Wn [n, Wn] buttord(fp/(fs/2), fr/(fs/2), Ap, As); [b, a] butter(n, Wn, low);这里buttord返回的n就是满足指标的最小阶数。在这个例子里算出来通常是5阶。注意MATLAB里fp/(fs/2)和fr/(fs/2)使用的都是归一化频率分母是奈奎斯特频率也就是采样率的一半。这是因为数字滤波器的频率响应是周期性的设计时只看0到fs/2这一段把这段映射成0到1的归一化值MATLAB内部才好统一处理。不少人在这一步容易踩坑直接传了100和200进去没除以fs/2。这样出来的滤波器截止频率完全不是100Hz因为MATLAB会把输入当作归一化值0到1范围来处理。归一化频率这个概念是整个设计流程里第一个需要刻进脑子里的东西。2. MATLAB里把系数和前因后果搞明白2.1 归一化频率最容易翻车的第一个地方再单独说一次归一化因为它太容易错了。数字滤波器工作时每个采样点之间的时间间隔是1/fs对应的最高可表示频率就是fs/2。比如采样率1000Hz你最多能表示0到500Hz的信号超过500Hz的信号会混叠成别的频率这在信号采集阶段就该处理掉。因此MATLAB里的butter函数要求截止频率用“相对于奈奎斯特频率的比例”来表示。就是Wn fc / (fs/2);假如采样率1000Hz想要100Hz截止那Wn 100/500 0.2。如果你的采样率是2000Hz同样想要100Hz截止Wn 100/1000 0.1完全不一样。我实际遇到过这样一个问题某个开发者从别人的工程里复制了一段滤波代码参数还是按采样率1000Hz算的Wn 0.2但他的系统实际采样率是500Hz。结果Wn 0.2对应的实际截止频率就成了0.2 * 250 50Hz设计目标100Hz的信号被削掉了一截。所以无论你是自己写还是抄代码第一步先确认采样率对不对。这个错误最隐蔽因为它不会让程序报错曲线看起来也正常就是结果不对。2.2 直接型b/a与SOS到底选哪个butter(n, Wn, low)返回的b和a是直接型传递函数系数H(z) (b0 b1*z^-1 b2*z^-2 ...) / (1 a1*z^-1 a2*z^-2 ...)5阶的时候还行但如果阶数上到10阶、15阶直接用这个a向量在C代码里写差分方程数值敏感性会急剧上升。因为高阶IIR的直接型实现里滤波器极点对系数误差非常敏感你在C语言里用float类型存系数小数点后几位一截断极点可能就飘出单位圆了——滤波器直接不稳定输出乱飞。工程上更稳的做法是拆成二阶节级联SOSSecond-Order Sections。把一个高阶滤波器分解成好几个二阶滤波器串联每一级的系数都小得多量化误差的影响被限制在局部不会因为高阶传递函数的极点集中而放大。MATLAB里拿到SOS很简单[sos, g] tf2sos(b, a);或者一步到位z zpk(butter(n, Wn, low)); % 这个方法也行但不如直接点更干脆的是直接用butter的SOS输出[sos, g] butter(n, Wn, low, sos);butter本身就支持直接生成二阶节格式不需要先转到b/a再转回来。这样生成的sos是一个L x 6的矩阵每一行对应一个二阶节格式是[b0 b1 b2 a0 a1 a2]a0通常等于1C代码里可以不存也可以存下来当校验用。g是整个滤波器的增益通常在所有二阶节的前面统一乘一次。比如前面那个5阶滤波器拆出来后通常是3个二阶节5阶需要3级最后一级是一个一阶节加一个二阶节的等效拆分。sos矩阵里每一行代表一个独立的二阶滤波器y[n] b0*x[n] b1*x[n-1] b2*x[n-2] - a1*y[n-1] - a2*y[n-2]这个形式写C代码非常直观后面我会给完整实现。2.3 可视化验证别急着写C拿到系数后千万别立刻去写C先在MATLAB里把频率响应确认一遍。我习惯用freqz先看基本幅度谱再用fvtool打开图形化界面GUI里可以做更详细的检查freqz(sos, 2048, fs);freqz第一参数可以直接传SOS矩阵第二参数是计算频率响应的点数点数越多越平滑第三参数传采样率这样横轴显示的就是Hz而不是归一化弧度。看几个关键点100Hz处幅度是不是大约在-3dB位置通带内0到100Hz幅度是不是平直没有明显纹波200Hz处幅度是不是低于-40dB。如果这几个点都对再继续下一步。如果不对先回设计阶段检查buttord的指标参数不要在C代码里徒劳折腾。fvtool还能看零极点图、群延迟、阶跃响应。零极点图值得看一项所有极点是否都严格在单位圆内。有些系统边界情况下极点在单位圆上理论上稳定但量化后很容易越界看到这种情况直接加一点阻尼或者换更高精度存储。3. 从MATLAB到C最实用的代码化路线3.1 手写差分方程二阶节级联的C实现拿到SOS系数后我推荐手写C代码来做实时滤波因为生成的代码简洁可控没有一堆MATLAB Coder自动生成的中间变量。二阶节级联的C实现大概是这样的typedef struct { float b0, b1, b2; // 前向系数 float a1, a2; // 反馈系数a0永远1不存 float z1, z2; // 状态变量 } Biquad; float Biquad_Process(Biquad* f, float x) { float y f-b0 * x f-z1; f-z1 f-b1 * x - f-a1 * y f-z2; f-z2 f-b2 * x - f-a2 * y; return y; }这个实现对应的是转置直接II型Transposed Direct Form II。为什么选这个结构因为它用两个状态变量z1和z2就完成了滤波不需要额外数组缓存过去的输入和输出。在嵌入式DSP里这种结构计算效率高状态更新自然而且是业内默认做法。再串联所有二阶节做一个完整的滤波器#define NUM_SECTIONS 3 // 5阶3个二阶节 typedef struct { Biquad sections[NUM_SECTIONS]; float gain; // 总增益MATLAB里的g } BiquadFilter; float BiquadFilter_Process(BiquadFilter* filter, float x) { float y x * filter-gain; // 先乘总增益 for (int i 0; i NUM_SECTIONS; i) { y Biquad_Process(filter-sections[i], y); } return y; }这里有个容易忽略的点MATLAB的butter(..., sos)返回的sos矩阵里g是总增益可能在分解时已经乘进某些系数里也可能没有。取决于MATLAB版本和调用方式有的返回g有的会把g合并到第一个二阶节。稳妥的用法是显式在外面乘一次像我代码里写的那样。如果你不放心可以先用MATLAB的sosfilt和一个正弦波验证一下直接用系数整体滤波再看输出幅度对不对。初始化状态变量也很简单就是把所有z1、z2清零。一般来说全局变量在嵌入式C里默认清零但为了可移植性和明确性我还是建议写一个BiquadFilter_Initvoid BiquadFilter_Init(BiquadFilter* filter) { filter-gain 1.0f; for (int i 0; i NUM_SECTIONS; i) { filter-sections[i].b0 0.0f; filter-sections[i].b1 0.0f; filter-sections[i].b2 0.0f; filter-sections[i].a1 0.0f; filter-sections[i].a2 0.0f; filter-sections[i].z1 0.0f; filter-sections[i].z2 0.0f; } }然后从MATLAB里把系数抄到C数组里建议用一个const数组统一管理static const float FILTER_SOS[NUM_SECTIONS][6] { {1.000000f, 2.000000f, 1.000000f, 1.000000f, -0.400000f, 0.050000f}, // 示例值 // 真实值以MATLAB生成结果为准 };注意MATLAB的SOS顺序是[b0 b1 b2 a0 a1 a2]C数组里存的时候a0一般默认1可以不写如果你非要保留6个元素对齐那么代码里要跳过a0。3.2 利用MATLAB Coder半自动生成如果不想手写或者滤波器阶数很高、结构复杂可以用MATLAB Coder自动生成C代码。思路是写一个入口函数然后在Coder App里配置输入输出类型。先写一个可生成的函数比如function y filter_100hz(x) % x 是输入信号数组y 是滤波后的输出信号 persistent sos g if isempty(sos) [sos, g] butter(5, 0.2, low, sos); end y sosfilt(sos, x) * g; % 注意如果g已并入sos就别乘两次 end然后打开MATLAB Coder选择这个源文件作为入口函数指定输入x的类型比如double(1x1000)或single(1x1000)生成代码。生成出来的代码会自动包含二阶节状态管理等中间结构虽然不如手写那么精简但胜在不用自己抠细节适合快速原型验证。做这类生成时有两个经验persistent变量会被Coder静态化为函数内静态变量上电后保留上次状态。如果你的应用希望每次调用都从清零状态开始需要额外写一个reset函数或者在C接口里手动清结构体。输入信号类型建议直接用single生成这样生成出的C代码就是float运算在嵌入式上更友好。如果你在MATLAB里用的是double生成代码会是double在某些M0/M3内核上会带来额外计算开销。手写和自动生成的适用场景我做了个简单对比对比项手写C代码MATLAB Coder生成代码可读性高自己控制逻辑中等自动生成变量多嵌入式适配好可精确控制内存需额外配置有时需要裁剪开发速度慢要理解结构快写个入口就行系数可维护性纯手动填容易抄错自动嵌入一致性高适合场景长期维护、资源受限原型验证、快速交付我的习惯是先在MATLAB Coder里生成一版跑通算法确认滤波效果正确再手写一版干净代码做最终交付。这样两边一对比至少能确认手写代码里的系数顺序和状态更新逻辑没有错。3.3 把系数固化成头文件避免手动抄错手动抄系数最大的风险是抄错。多位小数在眼睛和手之间转一轮出错概率非常高。我一般用一个小脚本直接生成头文件num_sections size(sos, 1); fid fopen(biquad_coeffs.h, w); fprintf(fid, #ifndef BIQUAD_COEFFS_H\n); fprintf(fid, #define BIQUAD_COEFFS_H\n\n); fprintf(fid, #define NUM_SECTIONS %d\n\n, num_sections); fprintf(fid, static const float FILTER_SOS[%d][6] {\n, num_sections); for i 1:num_sections fprintf(fid, {%0.8ff, %0.8ff, %0.8ff, %0.8ff, %0.8ff, %0.8ff},\n, ... sos(i,1), sos(i,2), sos(i,3), sos(i,4), sos(i,5), sos(i,6)); end fprintf(fid, };\n\n); fprintf(fid, #endif // BIQUAD_COEFFS_H\n); fclose(fid);这样生成的.h文件里系数是8位小数精度直接能放进工程里。%0.8f这个精度对float来说其实已经超过单精度有效位数了写入时C编译器会做一次合法截断。如果你打算用双精度可以写到12位以上。注意头文件里的系数顺序和代码里的顺序一定要对应最好用一种带a0列的完整6元素数组保留a0只是为了对齐MATLAB的SOS格式代码里跳过它就行。生成完头文件后你可以在C代码里加一个编译期断言用_Static_assert或者直接在初始化函数里校验第一个系数不为0防止编译器在数组初始化时出错没发现。这些细节在项目大后期排查时会省很多时间。4. 常见坑与排查实录4.1 系数量化后性能劣化极点飘出单位圆前面提过高阶直接型系数容易量化敏感但哪怕拆成二阶节如果系数精度给得太低一样会出问题。我做过一个实验用MATLAB设计一个8阶巴特沃斯低通滤波器把SOS系数保留4位小数写入C在MATLAB里用同样的4位小数系数重新算频率响应发现阻带衰减从设计值的60dB退化到了45dB左右而且在某些频段出现了奇怪的隆起。解决办法有两个方向一是提高系数存储精度。能用float的尽量用float但打印系数时至少保留6到7位有效数字如果芯片资源允许直接上double省心很多。现代主流MCU如果带FPUdouble代价还是不小的建议先用float验证再用double对比。二是在MATLAB里提前模拟量化。你可以把系数转成single类型后重新做freqz:sos_s single(sos); freqz(double(sos_s), 2048, fs);如果曲线和原始设计几乎重叠说明单精度够用如果差别明显要么提高精度要么换结构。这个验证成本极低但很多人都跳过不做等嵌入式里跑出问题才回来查很浪费。4.2 初始化状态量没清零导致开机瞬间乱跳滤波器状态变量在第一次调用前必须是0。如果没清零输出会有一段莫名其妙的瞬态。物理上可以理解成滤波器在上电瞬间“看到”了一个巨大的虚假输入。这个坑常见于两种情况一是用RTOS滤波器实例是动态分配的内存malloc出来的内存内容未知直接当结构体用状态变量全是随机数二是有人在调试时手动改过状态变量没复位就继续跑。解决方法其实很简单每个滤波器定义的地方显式调用一次初始化函数。别依赖calloc或BSS段的自动清零因为不同平台行为不一样移植到别的平台可能就翻车。我在自己的代码里始终坚持写一个_Init函数即使初始化为全0也写清楚意图。4.3 采样率写错最隐蔽的错误这个前面在归一化频率部分提过但值得放在排查实录里单独说一次。症状是滤波器在MATLAB仿真里完全正常搬到C里以后输出波形幅度小了但看不出明显问题。用扫频仪或者手动输入不同频率正弦波检查时发现-3dB截止点跟设计要求差了整整一倍。八成原因是采样率不对。比如你设计时用的是fs1000Hz但嵌入式里ADC实际工作在fs500Hz那么归一化频率0.2对应的不是100Hz而是50Hz低频截止点整个左移了。排查方法也比较直接在代码里打印fs和Wn的值和MATLAB设计脚本对比一遍。如果要严谨还可以在嵌入式端产生一个精确的模拟方波通过计算滤波后输出波形过零点的位置来反推实际截止频率。这个方法的精度还可以能帮你验证整个链路的一致性。4.4 数据截断和中间变量溢出实时滤波C代码里每个二阶节输出对下一级来说就是输入。如果输入信号幅度接近满量程而滤波器又有DC增益中间节点的数值很可能超过1.0甚至更多特别是在级联结构里增益会逐级累积。如果你用的是16位定点处理器这个问题会极其明显中间变量一饱和滤波结果就畸形了。解决办法有二。一是在输入到滤波器之前做一次总的幅度归一化把总增益g先乘在输入上而不是放在每个节里。二是在每级之间做防饱和处理比如用饱和指令__SSAT或者手动加if语句限幅。对浮点MCU来说只要确认了总增益分摊合理基本不会溢出但对定点平台这一条必须当成核心问题处理。我在实际项目里还碰到过一个反例一个16位定点DSP上实现8阶巴特沃斯所有系数都用Q15格式存储当时只检查了系数本身不超过1忽略了中间状态可能超过1导致输出在高频段时有时无。最后是在每个二阶节后面加了一个移位和限幅才彻底解决问题。所以定点平台上的滤波器状态变量范围和中间节点范围必须单独计算不要想当然。4.5 用正弦扫频验证整个链条所有的排查技巧汇总起来最终还是要靠验证。我在交付一段滤波器C代码前一定会建立一个“测试台”在C代码里输入一个已知频率的正弦波同时用MATLAB做同样的输入然后把两者输出做对比。具体操作分三步在MATLAB里生成一个100Hz正弦波长度1秒用sosfilt滤波得到参考输出把同一个100Hz正弦波的C数组导入嵌入式工程用C代码滤波得到待测输出把两组数据拉到同一张图里对比或者计算误差均方根。如果误差小于千分之一基本说明代码实现正确。这样做最大的好处是如果C端输出和MATLAB参考对不上你立刻就知道不是算法问题而是实现问题。很多人在调试时纠结“滤波器是不是不稳定”其实只要对比这两条曲线问题一下就能定位到系数、状态初始化或数据类型上。5. 几点个人体会前面这些经验大多是我在一次次的折腾里攒出来的。滤波这件事理论上不难但把它从MATLAB搬到真实嵌入式环境中间隔着的不是代码而是采样率、量化精度、状态初始化、定点溢出这些细节问题。我个人的体会是设计参数一定要“先算后测”不要靠肉眼调验证一定要“MATLAB和嵌入式两条腿走路”不要只信其中一边。最后再分享一个小技巧如果你做的滤波器是要长期用在某个项目里的建议在MATLAB脚本里把设计、生成头文件、验证三件事写成一个完整的脚本保存下来。下次改采样率或者改截止频率只需要改最上面的几个参数一键重新生成C代码几乎不用动。这个习惯看起来不起眼但在项目迭代里能省下大量的重复劳动。做技术的人最值得花的功夫就是让“可复现”这件事变成默认选项。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询