如果你调过空调温控,一定见过温度数值在设定点附近反复横跳、压缩机频繁启停的场面。面板上显示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 < τ/10),e^(-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 | 时间常数τ | α(精确式) | 典型场景 |
|---|---|---|---|---|
| 10ms | 0.5Hz | 0.318s | 0.031 | 室温控制 |
| 10ms | 1Hz | 0.159s | 0.061 | 空调出风温控 |
| 10ms | 5Hz | 0.0318s | 0.270 | 电机转速采 |
| 1ms | 10Hz | 0.0159s | 0.061 | 电池电压监测 |
| 1ms | 100Hz | 0.00159s | 0.468 | 音频信号预滤波 |
| 100ms | 0.2Hz | 0.796s | 0.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; // T=10ms, fc=1Hz 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µs(ADC转换本身就要花这么多),而一次浮点加乘组合运算大约耗时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_Start+HAL_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-4095,float的有效数字足够精确表示这些值,误差远小于量化误差,所以直接用float完全没有问题。
我自己的项目里,F103上用浮点版,主循环算完一阶低通再跑一个PID,CPU占用率依然很低。只有在F103上同时跑4路ADC采集、LCD刷新、Modbus通信时,我才会把滤波器改成Q12定点,省出的时间留给协议栈。
5. Arduino与STM32的对比复盘:什么场景用什么方案
5.1 平台能力与滤波效果对照
做一个主观对比,方便你在立项的时候快速判断:
| 对比维度 | Arduino Uno (ATmega328P) | STM32F103 | STM32F4系列 |
|---|---|---|---|
| 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左右就足够,α在T=10ms下大约是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; // 基准参数:T0=0.01s, alpha0=0.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"有更直观的体会。