ARTICLE DETAIL

资讯详情

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

一阶RC低通滤波器在Arduino与STM32上的实现与调参指南

一阶RC低通滤波器在Arduino与STM32上的实现与调参指南 如果你调过空调温控一定见过温度数值在设定点附近反复横跳、压缩机频繁启停的场面。面板上显示25.8℃还是26.3℃完全取决于那一瞬间ADC采到了什么——问题的根源往往不是传感器坏了而是原始信号里混着各种噪声。这时候一阶RC低通滤波器是性价比最高的解法原理简单、运算量极小、效果立竿见影。这篇文章记录了我先后在Arduino和STM32上用C语言实现一阶RC低通滤波器的完整过程从模拟电路原理到差分方程推导再到参数设计、代码实现和项目中的真实踩坑适合正在做传感器采集、温控系统或者准备用滤波算法给信号降噪的嵌入式开发者参考。1. 为什么空调温控会抖ADC噪声与滤波器的定位1.1 噪声从哪里来先搞清楚你要对付的是什么先说一个最基础的认知ADC采回来的数字量不等于真实物理量。中间隔着一整套噪声链路。我用NTC热敏电阻测室温时Arduino的analogRead()返回的数值在稳态下能在±5个LSB甚至更多范围内跳。5个LSB相当于多少温度如果是10位ADC、5V参考、用10k NTC加10k分压电阻一个LSB大约对应0.0049V在25℃附近换算成温度差异是0.1℃到0.2℃的量级。用于工业温控这个波动已经能让继电器或压缩机频繁动作了。噪声来源大致有三类传感器和分压电阻自身的热噪声电阻内部自由电子的无规则热运动产生的随机电压带宽很宽数值很小但经过高增益放大后不可忽略。空间电磁耦合市电50Hz及谐波通过空间辐射耦合进传感器长引线这是最常见的低频干扰源。空调压缩机启动瞬间、继电器吸合瞬间电流突变产生的电磁脉冲也会串进来。电源纹波和地弹Arduino的5V/3.3V供电本身就带纹波AVR内部ADC的VREF如果直接接电源纹波会直接反映在转换结果里。STM32如果VDDA滤波没做好同样会出现采样值周期性的规律跳变。这三种噪声叠加在慢变信号上就成了你看到的毛刺。1.2 为什么要用一阶RC低通系统的滞后需求说了算在嵌入式世界里降噪手段很多硬件RC滤波、软件滑动平均、一阶低通、二阶低通、卡尔曼滤波。但用在空调温控这类场景一阶RC低通滤波器几乎是最优解中的最优解。原因在于被测量的物理过程本身变化极其缓慢。室温的变化时间常数是分钟级别风机出风口温度的变化是秒级。你要保留的有效信号频率极低0.01Hz到0.5Hz而噪声频率基本在几十Hz以上。信号和噪声在频谱上有极宽的空白地带一个斜率只有-20dB/dec的简单滤波器就能把噪声压下去。用卡尔曼滤波模型不准确时反而振铃而且运算量大得多。用FIR滑动平均存储开销和响应延迟都不理想。一阶RC低通的好处是无历史数据存储、每次采样只需要一次乘法和一次加法、调节一个α参数就能同时改变截止频率和响应速度而且它天然贴合物理系统的惯性——很多传感器信号本来就是一阶惯性环节的输出再级联一个一阶滤波器不会破坏系统的物理特性。打个比方你手里有个剧烈晃动的温度计读数一阶低通做的事情就是每次只往新读数方向挪一小步步子小到什么程度由α决定。噪声是高频抖动平均下来互相抵消真实温度变化是缓慢移动滤波输出就一直追着它走。2. 一阶RC低通滤波器的数学原理与参数设计2.1 模拟域的截止频率从RC电路说起一阶RC低通滤波器的模拟电路就是电阻R和电容C串联信号从R进来输出取在C两端。电容的容抗是1/(2πfC)频率越高、容抗越低高频分量通过C被短接到地剩下的低频分量保留在C上。这个电路的传递函数是H(s) 1 / (1 sRC) 1 / (1 sτ)其中τ RC被称为时间常数。这个参数是理解整个滤波器的钥匙。截止频率fc定义为信号衰减到-3dB即0.707倍时的频率fc 1 / (2πRC) 1 / (2πτ)截止频率的意义是高于它的频率成分会被越来越大幅度地衰减低于它的频率成分基本保真通过。注意不是完全滤掉高于fc的成分而是每十倍频程衰减20dB。这意味着频率恰好是10倍fc的噪声幅度会被压到原来的1/10是100倍fc的压到1/100。对ADC噪声这种宽带信号来说这个衰减已经足够用了。2.2 数字域的离散化差分方程是怎么来的模拟RC电路好理解但单片机不能直接解微分方程需要把连续的传递函数离散成差分方程。怎么离散工程上最直观、也最常用的方法不是查表做Z变换而是直接从一阶系统的阶跃响应特性出发。一个时间常数为τ的一阶系统在输入从u1突变为u2时输出从当前值向新值逼近的规律是指数曲线y(t) y(∞) [y(0) - y(∞)] * e^(-t/τ)采样间隔T下每个周期跨越的剩余距离的比例是e^(-T/τ)。那么每次更新时输出向当前输入靠近的比例就是α 1 - e^(-T/τ)于是得到Y[n] Y[n-1] α * (X[n] - Y[n-1])等价写成Y[n] α * X[n] (1 - α) * Y[n-1]这就是C语言里写的那一行公式。理解这个推导过程很重要因为很多人直接把α当成滤波强度乱调而不知道α和采样周期、截止频率之间是有严格换算关系的。当采样周期T远小于时间常数τ时通常T τ/10e^(-T/τ) ≈ 1 - T/τ所以α ≈ T/τ。这个近似式在参数设计时非常好用手算也很快。2.3 滤波系数α的计算方法与参数表实际设计滤波器一般流程是先定采样周期T取决于你的代码主循环周期或定时器周期再定期望的截止频率fc算出τ然后算α。假设采样周期T 10ms希望截止频率fc 1Hzτ 1 / (2π * 1) ≈ 0.159sα 1 - e^(-0.01/0.159) ≈ 1 - e^(-0.0629) ≈ 0.061如果要求不高用近似式 α ≈ T/τ 0.01/0.159 ≈ 0.063也差不多。我在实际项目中常用几个参数组合先列个表给你参考采样周期T截止频率fc时间常数τα精确式典型场景10ms0.5Hz0.318s0.031室温控制10ms1Hz0.159s0.061空调出风温控10ms5Hz0.0318s0.270电机转速采1ms10Hz0.0159s0.061电池电压监测1ms100Hz0.00159s0.468音频信号预滤波100ms0.2Hz0.796s0.118缓慢变化的液位看到规律了吗α越小滤得越狠但响应也越慢。α 0.06说明每次只向新读数靠近6%一个有突变的跳变信号需要经过大约1/α ≈ 16个采样周期才能到达真实值的63%左右。调参数的本质就是在滤波干净程度和响应速度之间找平衡。3. Arduino平台实现从模拟滤波到C语言的翻译3.1 最简单能跑的浮点版本在Arduino Uno上做NTC温度采集代码可以简单到令人发指#define SENSOR_PIN A0 #define SAMPLE_INTERVAL_MS 10 float alpha 0.061f; // T10ms, fc1Hz float filteredValue 0.0f; unsigned long lastSampleTime 0; void setup() { Serial.begin(115200); filteredValue analogRead(SENSOR_PIN); // 用第一次采样做初值 } void loop() { if (millis() - lastSampleTime SAMPLE_INTERVAL_MS) { lastSampleTime millis(); int rawValue analogRead(SENSOR_PIN); filteredValue alpha * rawValue (1.0f - alpha) * filteredValue; float voltage filteredValue * 5.0f / 1023.0f; // NTC查表或Steinhart-Hart公式换算温度这里略过 Serial.print(rawValue); Serial.print(, ); Serial.println(filteredValue); } }核心就一行filteredValue alpha * rawValue (1.0f - alpha) * filteredValue;这里有两个细节值得说。第一filteredValue的初值直接取了第一次采样的analogRead()值而不是初始化为0。否则滤波输出要从0慢慢爬到真实值附近温度显示会起飞好一阵。第二millis() - lastSampleTime SAMPLE_INTERVAL_MS的判断方式天然规避了millis()在运行约49.7天后归零的问题——用差值比较而不是直接比较绝对大小。3.2 浮点版的隐藏成本AVR上的一次大工程Arduino Uno用的是ATmega328P主频16MHz不带硬件浮点单元。这意味着每一行float运算编译器都会把它展开成几十上百条整数运算指令由软件模拟IEEE 754浮点。我对这个滤波函数做了一个粗略的性能测试在16MHz主频下一次analogRead()大约耗时100µsADC转换本身就要花这么多而一次浮点加乘组合运算大约耗时10-20µs。乍看不慢但如果你在同一个循环里还要做显示刷新、按键扫描、PID计算、串口日志CPU的有效利用率会很难看。尤其是当你有多个滤波通道时比如需要同时采集温度、湿度、电流三路信号每路一个浮点滤波每路每秒100次采样那光是滤波本身就能吃掉相当于100个采样点的CPU时间。3.3 定点数版本没有FPU时的高效替代解决方案是用定点整数运算替代浮点。核心思路是把α放大2^N倍用整数乘法和移位代替小数乘法。我常用的Q8格式就是把所有小数放大256倍#define ALPHA_Q8 16 // 实际alpha 16/256 0.0625, 近似0.061 #define SHIFT_BITS 8 int32_t filteredValueQ8 0; void setup() { int raw analogRead(SENSOR_PIN); filteredValueQ8 (int32_t)raw SHIFT_BITS; // 初值也转成Q8格式 } // 在采样循环中 int rawValue analogRead(SENSOR_PIN); // 核心更新Y alpha * (X - Y) filteredValueQ8 ((int32_t)rawValue SHIFT_BITS - filteredValueQ8) * ALPHA_Q8 SHIFT_BITS; int filteredValue (int)(filteredValueQ8 SHIFT_BITS);注意我把公式变成了Y α * (X - Y)这种形式而不是Y α*X (1-α)*Y。这是定点实现的关键前者只需要用一个α后者需要同时处理α和(1-α)两组系数而且整型运算中的截断误差更容易管理。这里有一个运算顺序的陷阱必须强调((int32_t)rawValue SHIFT_BITS - filteredValueQ8)计算的是当前的误差即输入和上一时刻输出的差值它是一个有符号数范围可能在-260000到260000之间。乘以ALPHA_Q8最大256后约6.6千万还在int32_t范围内不会溢出。但如果我把SHIFT_BITS设成16这个中间结果最大就能到1.7亿离int32_t的溢出上限就更近了。所以Q8是AVR上的安全选择Q16在中间乘法时需要特别小心。实测下来定点版本比浮点版本快了至少4-5倍代码量几乎没增加代价是灵活性略低——换α的时候要重新换算一次定点系数。我在智能小车循迹项目里把8路红外传感器的ADC读数全部用定点Q8滤波ATmega328P依然能跑完PID控制器和舵机PWM更新完全不卡。4. STM32平台实现从HAL库ADC到工程化封装4.1 定时器触发采样让滤波周期真正稳定在STM32上写滤波函数本身不复杂难的是采样节拍的控制。Arduino的loop()天然是能跑多快跑多快需要自己用millis()限制采样频率。但实际上millis()本身精度受中断影响周期性任务和中断处理重叠时采样间隔会有jitter反映在滤波结果上就是微小的A/D量化误差增大。STM32的推荐做法是用定时器触发ADC转换。我以STM32F103C8T6为例用TIM2产生10ms更新中断在回调里启动单次ADC转换// 涉及的全局变量 float alpha 0.061f; float filteredValue_y 0.0f; uint16_t raw_adc 0; // 在main.c里初始化TIM2定时10ms中断, 启用中断回调 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); raw_adc HAL_ADC_GetValue(hadc1); // 一阶RC低通滤波 filteredValue_y alpha * ((float)raw_adc - filteredValue_y); } }这里我用HAL_ADC_StartHAL_ADC_PollForConversion的模式代码简单但有一个问题PollForConversion是阻塞的它会等ADC转换完成如果在等待期间来了其他中断可能会引入微小的时序毛刺。对一阶低通这种对相位不敏感的场景其实无伤大雅但如果你在同一个定时器里还要处理编码器计数最好用DMA把ADC从CPU循环中解放出来。4.2 DMA批量采样先平均再滤波的两级降噪STM32的ADC另一个优势是支持DMA传输和多通道扫描。我的常用做法是每个滤波周期用DMA连续采样N次先算算术平均再走一阶低通。以采样周期10ms、内部DMA连续采样16次为例#define DMA_BUF_SIZE 16 uint16_t adc_buf[DMA_BUF_SIZE]; volatile uint8_t dma_conversion_done 0; // 初始化时启动一次DMA HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, DMA_BUF_SIZE); // ADC采样完成回调 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { dma_conversion_done 1; } } // 主循环里这样处理 float y_filtered 0.0f; float alpha 0.2f; while (1) { if (dma_conversion_done) { uint32_t sum 0; for (uint8_t i 0; i DMA_BUF_SIZE; i) { sum adc_buf[i]; } float avg (float)sum / DMA_BUF_SIZE; // 一级平均已经滤掉了部分高频噪声此时α可以适当调大 y_filtered alpha * (avg - y_filtered); dma_conversion_done 0; HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, DMA_BUF_SIZE); } }这个两级方案在实际效果上比单纯增大一阶α要平滑得多因为16次采样的均值已经把ADC本身的均匀量化噪声洗掉了一部分。特别是STM32F103的12位ADC在低电压信号源时最后的1-2位经常不稳定DMA平均能显著改善稳定性。二级方案下α可以适当调大到0.2~0.3响应速度也没有牺牲太多。我在做一个电机电流采样保护电路时用3.3kΩ采样电阻采集0-3A电流DMA 20kHz采样、平均16次再一阶滤波到1kHz带宽输出波形已经非常干净可以直接用来做软件过流保护判断。4.3 STM32上的定点实现有没有必要STM32F103是Cortex-M3内核没有硬件FPU但它的32位硬件乘法器比AVR快得多软件浮点大约需要几十个周期。F4系列带FPU浮点乘法单周期完成。所以定点优化在F1上还有一定价值在F4上基本没必要。但即使不用定点也要注意float的精度问题。STM32的ADC是12位的数值范围0-4095float的有效数字足够精确表示这些值误差远小于量化误差所以直接用float完全没有问题。我自己的项目里F103上用浮点版主循环算完一阶低通再跑一个PIDCPU占用率依然很低。只有在F103上同时跑4路ADC采集、LCD刷新、Modbus通信时我才会把滤波器改成Q12定点省出的时间留给协议栈。5. Arduino与STM32的对比复盘什么场景用什么方案5.1 平台能力与滤波效果对照做一个主观对比方便你在立项的时候快速判断对比维度Arduino Uno (ATmega328P)STM32F103STM32F4系列ADC精度10位12位12位/16位ADC采样率约10k-15k样本/s可达1Msps受DMA限制可达数Msps浮点运算软件模拟慢软件模拟但硬件乘法快硬件FPU快定时采样稳定性依赖millis()精度一般定时器硬触发稳定定时器硬触发稳定DMA支持无有有典型应用场景原型验证、低速率传感工业控制、中速采集高性能数据采集、音频在滤波算法层面一阶RC低通本身的数学和最终滤波效果在两个平台上是等价的。差异不在滤波器在采样链路。Arduino一次analogRead()从启动到返回要100µs如果你用10ms周期采样这100µs对系统的冲击很小但如果你想把采样周期压到1ms以下AVR就非常吃力了而STM32可以轻松跑100kHz采样再软件滤波。5.2 选型边界一阶RC低通用到什么时候该换算法我在实际项目中积累了一个粗略的判断标准供你参考信号有效带宽和噪声带宽差距很大且噪声是宽带随机噪声一阶RC就够了。需要更陡的衰减特性比如要滤掉某个特定频率的窄带干扰又不想衰减太快要的有用信号换二阶巴特沃斯或Butterworth的数字化实现。信号含明显的周期性窄带干扰比如50Hz工频用陷波器notch filter比低通更合适因为低通会把50Hz附近的有效信号也压掉。信号是非线性的、且你对实时性要求高考虑卡尔曼滤波或互补滤波。卡尔曼模型建立得好在惯性导航、IMU姿态解算里明显优于一阶低通。采样周期不固定一阶RC假设固定采样周期如果任务循环不稳定需要根据实际间隔动态计算α。这个我在后面踩坑部分会展开。有一种情况要特别注意一阶RC低通对脉冲噪声的抑制能力有限。一个幅度极大的尖峰脉冲一阶滤波器会在接下来若干个采样周期内持续吐出它的残余影响表现为一个拖尾衰减。如果你的噪声是继电器打火那种偶发大脉冲建议先做一个中值滤波取连续5次采样的中值再进低通效果会好得多。6. 实测中踩过的坑系数、延迟、初值与溢出6.1 系数取太大会抖动取太小会变傻我第一次用这个滤波器时随手把α设成0.5结果是温度数值依然抖得厉害——因为0.5的α对应截止频率太高了10ms采样下fc大约8Hz而噪声很多都在这个频率附近滤不干净。后来查了资料才意识到α必须和采样周期、期望截止频率联动不能凭感觉定。反过来我也见过朋友把α设到0.001温度确实平滑得像一条直线但开机升温时滤波输出要整整几十秒才能跟踪到真实值控制系统的调节速度被拖垮了。一个稳妥的做法是先用串口把原始ADC值和滤波值同时打印出来在电脑上画曲线肉眼观察两个输出的滞后时间。温控系统一般能接受几秒到十几秒的滞后如果滞后超过这个范围就逐步调大α。用示波器或逻辑分析仪测量滤波前的信号确认主要噪声的频率范围再反推fc。比如你发现噪声主要是100Hz整流纹波和300Hz开关电源谐波期望保留的信号在1Hz以内那fc选10Hz左右就足够α在T10ms下大约是0.86——等等这里α就很大了因为10ms采样、10Hz截止频率τ只有0.0159s一个采样周期就有63%的更新比例所以会显得滤波很轻。物理规律没办法采样周期越短同等截止频率下α就越小采样周期越长同等截止频率下α越大。如果你发现α偏大但噪声滤不掉说明应该缩短采样周期而不是继续调大α——采样周期变短后噪声被采样混叠的频率被推高滤波器的衰减效果更明显。6.2 初值不处理滤波输出要半天才跟上这个坑几乎每个人都会踩。很多示例代码把filteredValue初始化为0导致上电后滤波输出从0慢慢向真实ADC值攀升。对NTC测温来说如果真实ADC值是600初始化为0滤波输出需要经过约1/α个周期才能靠近600α0.06时大约需要16个周期即160ms勉强凑合但如果采样周期是100ms那就是1.6秒显示才稳定。在电池电压监测场景这1.6秒可能让用户误以为设备没电。解决方法就是我前面代码里写的初始化时直接读一次ADC把原始值作为滤波初值。如果是多通道每个通道都这样处理。这在工业设备里还有个附加好处上电瞬间如果传感器读数异常偏高程序能立刻发现并报警而不会因为滤波器初始化为0而把异常吞掉。6.3 定点运算的截断误差与溢出用定点版本时要注意右移截断。C语言对有符号整数右移是算术右移但丢掉低位小数之后会产生一个向零方向截断的误差。每次运算误差不大但如果在高闭环系统里持续累加可能形成静差。我处理的办法把滤波器输出再额外保存一个不带截断的高精度变量。比如Q8格式下我不用filteredValueQ8 8来更新初值而是让filteredValueQ8始终保留全部小数部分只在输出显示时才移位截断。这样误差只在最后一次显示量化时产生不会进入闭环累计。关于溢出再强调一次中间变量一定要用int32_t不要省空间用int16_t。AVR的int是16位ADC原始值最大1023放大256倍后约26万已经超过16位有符号数上限。我见过有人用int存Q8格式滤波值到300多就变成负数排查了很久才发现是溢出。还有一个容易忽视的点(1 - alpha)在浮点版本中没有问题但在定点版本中如果你直接算int16_t beta_q8 256 - ALPHA_Q8;那么更新公式必须是两步乘法和一次加法计算量翻倍。所以我才反复推荐用Y α*(X-Y)的结构它天然只需要一个系数。6.4 采样周期不固定时的动态α调整最后分享一个进阶技巧如果你的程序因为任务调度原因两次采样之间的间隔不稳定一阶RC滤波的系数需要动态调整。原理还是那个指数逼近关系。假设基准采样周期是T0基准系数是α0实际这次采样距离上次的时间是T_actual那么本次实际系数α_actual 1 - (1 - α0)^(T_actual / T0)在代码里可以用powf函数但STM32上float的powf开销稍大。工程上常用近似处理测量实际间隔然后按比例插值uint32_t now micros(); float dt (float)(now - lastSampleTime) / 1000.0f * 0.001f; // ms转s lastSampleTime now; // 基准参数T00.01s, alpha00.061 float alpha_dynamic 1.0f - powf(1.0f - 0.061f, dt / 0.01f); y alpha_dynamic * (x - y);如果你的系统里存在大量RTOS任务抢占导致采样间隔忽长忽短这个方法能保住滤波器的时域特性。把它用在FreeRTOS的传感器采集任务里滤波效果跟固定采样周期几乎一致。一阶RC低通滤波器的实现本身不复杂但越是简单的东西越要彻底搞懂它的物理含义。因为我见过太多项目滤波算法堆了一大堆最后定位下来问题全出在采样节拍不稳、初值没处理好、参数和采样周期不匹配这些周边环节上。所以这篇文章花了大量篇幅讲采样链路和参数换算因为这些才是决定滤波效果的关键。如果你看完能独立算出一组α在Arduino或STM32上跑通并画出平滑曲线那这套滤波器公式基本就吃透了。接下来想加深的话可以试试对比二阶低通、滑动平均和卡尔曼滤波在同一组噪声数据上的表现你会对为什么系统里最常用的还是一阶RC有更直观的体会。
返回列表