文章目录
-
- 摘要
- 一、前言
-
- [1.1 为什么 ADC 数据需要滤波?](#1.1 为什么 ADC 数据需要滤波?)
- [1.2 本文目标](#1.2 本文目标)
- [1.3 前置条件](#1.3 前置条件)
- 二、核心原理
-
- [2.1 ADC 扫描模式与 DMA 协同工作机制](#2.1 ADC 扫描模式与 DMA 协同工作机制)
- [2.2 为什么用定时器触发而不是连续模式?](#2.2 为什么用定时器触发而不是连续模式?)
- [2.3 四种滤波算法原理概览](#2.3 四种滤波算法原理概览)
-
- [滑动平均(Moving Average)](#滑动平均(Moving Average))
- [中值滤波(Median Filter)](#中值滤波(Median Filter))
- [一阶低通滤波(First-Order Low-Pass)](#一阶低通滤波(First-Order Low-Pass))
- [卡尔曼滤波(Kalman Filter)](#卡尔曼滤波(Kalman Filter))
- [三、硬件设计与 CubeMX 配置](#三、硬件设计与 CubeMX 配置)
-
- [3.1 测试电路](#3.1 测试电路)
- [3.2 CubeMX 关键配置](#3.2 CubeMX 关键配置)
- 四、软件实现
-
- [4.1 数据结构和全局变量](#4.1 数据结构和全局变量)
- [4.2 ADC 初始化](#4.2 ADC 初始化)
- [4.3 四种滤波算法的 C 实现](#4.3 四种滤波算法的 C 实现)
- [4.4 主循环数据处理](#4.4 主循环数据处理)
- [4.5 卡尔曼滤波关键参数 Q/R 的设计决策](#4.5 卡尔曼滤波关键参数 Q/R 的设计决策)
- 五、测试验证
-
- [5.1 测试方法与数据](#5.1 测试方法与数据)
- [5.2 实测分析](#5.2 实测分析)
- [5.3 理论 vs 实测:卡尔曼参数整定](#5.3 理论 vs 实测:卡尔曼参数整定)
- 六、故障排查
-
- [问题 1:DMA 搬运的数据全是 0](#问题 1:DMA 搬运的数据全是 0)
- [问题 2:多通道数据串位(通道 0 的数据跑到了通道 1)](#问题 2:多通道数据串位(通道 0 的数据跑到了通道 1))
- [问题 3:卡尔曼滤波初始值偏差大、收敛慢](#问题 3:卡尔曼滤波初始值偏差大、收敛慢)
- [问题 4:定时器触发频率不准,实际采样率偏差大](#问题 4:定时器触发频率不准,实际采样率偏差大)
- [问题 5:ADC 数据有周期性毛刺(每 10~20ms 一个尖峰)](#问题 5:ADC 数据有周期性毛刺(每 10~20ms 一个尖峰))
- 七、总结
-
- [7.1 核心要点](#7.1 核心要点)
- [7.2 适用边界](#7.2 适用边界)
- [7.3 局限性](#7.3 局限性)
- [7.4 扩展方向](#7.4 扩展方向)
摘要
在嵌入式传感器数据采集场景中,ADC 原始数据往往伴随电源纹波、电磁干扰和量化噪声,直接使用未经滤波的数据会导致控制系统抖动甚至误判。本文基于 STM32F103C8T6 微控制器,利用 ADC1 扫描模式 + DMA 循环传输实现 4 通道模拟信号的同步采集(采样率 1kHz),并横向对比滑动平均、中值滤波、一阶低通和卡尔曼滤波四种算法的去噪效果与响应速度。实测数据表明:滑动平均延迟约 7.5ms 但稳态方差仅 3.2 LSB;卡尔曼滤波延迟仅 1.2ms 且稳态方差 4.1 LSB,综合表现最优。提供完整 CubeMX 配置、HAL 库工程代码和四种滤波算法的 C 语言实现。
一、前言
1.1 为什么 ADC 数据需要滤波?
做过实际项目的朋友应该都遇到过:ADC 采回来的数据一直在跳,明明接的是稳定的 3.3V,结果数值在 2045 到 2055 之间来回晃。这对于简单的电压显示可能无所谓,但如果你用它来做 PID 控制、阈值触发或者数据记录,这几个 LSB 的跳动就可能导致执行器频繁动作、触发误报。
噪声来源五花八门:开关电源的高频纹波、电机驱动产生的电磁干扰、ADC 自身的量化误差,甚至 PCB 走线不合理都会引入额外的扰动。只靠硬件滤波(RC 低通)往往不够------截止频率设低了滞后严重,设高了又滤不干净。这时候就需要软件滤波来补位。
1.2 本文目标
读完这篇文章你会掌握三件事:
- STM32F103 ADC1 扫描模式 + DMA 的正确配置方法,避开双缓冲切换导致数据错位的经典坑;
- 四种滤波算法的原理与嵌入式 C 实现------滑动平均、中值滤波、一阶低通和卡尔曼滤波;
- 如何量化评估滤波效果------通过阶跃响应和稳态统计两个维度横向对比,找到适合你项目的方案。
1.3 前置条件
读者需要具备 STM32 HAL 库的基本使用经验(GPIO 初始化、串口打印),了解 ADC 和 DMA 的基本概念。硬件方面需要 STM32F103C8T6 最小系统板一块、可调电压源或电位器用于模拟传感器输入。本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、核心原理
2.1 ADC 扫描模式与 DMA 协同工作机制
STM32F103 的 ADC1 只有一个转换器,当需要采集多个通道时,必须依赖**扫描模式(Scan Mode)**依次对各通道进行模数转换。关键问题在于:每轮扫描完成后,所有通道的转换结果都写到了同一个 ADC->DR 寄存器------如果不用 DMA 及时搬走,后面的通道数据就会覆盖前面的。
下面的时序图展示了扫描模式 + DMA 循环传输的完整流程:
双缓冲区 DMA1 通道1 ADC1 TIM3 (触发源) 双缓冲区 DMA1 通道1 ADC1 TIM3 (触发源) #mermaid-svg-XuH3syB4i9lcyu5j{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XuH3syB4i9lcyu5j .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XuH3syB4i9lcyu5j .error-icon{fill:#552222;}#mermaid-svg-XuH3syB4i9lcyu5j .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XuH3syB4i9lcyu5j .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XuH3syB4i9lcyu5j .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XuH3syB4i9lcyu5j .marker.cross{stroke:#333333;}#mermaid-svg-XuH3syB4i9lcyu5j svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XuH3syB4i9lcyu5j p{margin:0;}#mermaid-svg-XuH3syB4i9lcyu5j .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XuH3syB4i9lcyu5j text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XuH3syB4i9lcyu5j .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XuH3syB4i9lcyu5j .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XuH3syB4i9lcyu5j .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XuH3syB4i9lcyu5j .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XuH3syB4i9lcyu5j #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XuH3syB4i9lcyu5j .sequenceNumber{fill:white;}#mermaid-svg-XuH3syB4i9lcyu5j #sequencenumber{fill:#333;}#mermaid-svg-XuH3syB4i9lcyu5j #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XuH3syB4i9lcyu5j .messageText{fill:#333;stroke:none;}#mermaid-svg-XuH3syB4i9lcyu5j .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XuH3syB4i9lcyu5j .labelText,#mermaid-svg-XuH3syB4i9lcyu5j .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XuH3syB4i9lcyu5j .loopText,#mermaid-svg-XuH3syB4i9lcyu5j .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XuH3syB4i9lcyu5j .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XuH3syB4i9lcyu5j .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XuH3syB4i9lcyu5j .noteText,#mermaid-svg-XuH3syB4i9lcyu5j .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XuH3syB4i9lcyu5j .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XuH3syB4i9lcyu5j .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XuH3syB4i9lcyu5j .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XuH3syB4i9lcyu5j .actorPopupMenu{position:absolute;}#mermaid-svg-XuH3syB4i9lcyu5j .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XuH3syB4i9lcyu5j .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XuH3syB4i9lcyu5j .actor-man circle,#mermaid-svg-XuH3syB4i9lcyu5j line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XuH3syB4i9lcyu5j :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每 1ms TRGO 事件触发 采样 CH0 (PA0) 转换 CH0 搬移结果到 buf0 采样 CH1 (PA1) 转换 CH1 搬移结果到 buf1 采样 CH2 (PA2) 转换 CH2 搬移结果到 buf2 采样 CH3 (PA3) 转换 CH3 搬移结果到 buf3 EOT 中断触发缓冲区切换
引伸阅读:《告别ADC数据跳动!用STM32CubeMX HAL库DMA实现稳定多通道采集与软件滤波》 ------ 详细讲解了扫描模式下的 DMA 循环缓冲区设置。
2.2 为什么用定时器触发而不是连续模式?
两种方案的对比一目了然:
| 维度 | 连续转换模式 | 定时器触发模式 |
|---|---|---|
| 采样间隔 | 取决于 ADC 时钟和采样周期,不可控 | 由定时器精确控制,±1μs 精度 |
| CPU 开销 | 几乎为零(DMA 搬运) | 同样几乎为零 |
| 适用场景 | 数据记录、显示(对采样率无严格要求) | 数字信号处理、FFT、控制系统(需要固定采样率) |
| 配置复杂度 | 低(CubeMX 勾选即可) | 中(需额外配置一个定时器) |
我最初用连续模式做项目,后来在计算 FFT 时发现采样间隔不均匀导致频谱泄露,改成定时器触发后问题迎刃而解。所以只要能接受多配一个定时器,强烈推荐定时器触发。
引伸阅读:《STM32F103C8T6 ADC DMA采集,TIM 控制采样率》 ------ 详细说明了 TIM3 控制 ADC 采样率的配置方法。
2.3 四种滤波算法原理概览
#mermaid-svg-3IK9rGA5SWWMSc4H{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3IK9rGA5SWWMSc4H .error-icon{fill:#552222;}#mermaid-svg-3IK9rGA5SWWMSc4H .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3IK9rGA5SWWMSc4H .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3IK9rGA5SWWMSc4H .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3IK9rGA5SWWMSc4H .marker.cross{stroke:#333333;}#mermaid-svg-3IK9rGA5SWWMSc4H svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3IK9rGA5SWWMSc4H p{margin:0;}#mermaid-svg-3IK9rGA5SWWMSc4H .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster-label text{fill:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster-label span{color:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster-label span p{background-color:transparent;}#mermaid-svg-3IK9rGA5SWWMSc4H .label text,#mermaid-svg-3IK9rGA5SWWMSc4H span{fill:#333;color:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H .node rect,#mermaid-svg-3IK9rGA5SWWMSc4H .node circle,#mermaid-svg-3IK9rGA5SWWMSc4H .node ellipse,#mermaid-svg-3IK9rGA5SWWMSc4H .node polygon,#mermaid-svg-3IK9rGA5SWWMSc4H .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3IK9rGA5SWWMSc4H .rough-node .label text,#mermaid-svg-3IK9rGA5SWWMSc4H .node .label text,#mermaid-svg-3IK9rGA5SWWMSc4H .image-shape .label,#mermaid-svg-3IK9rGA5SWWMSc4H .icon-shape .label{text-anchor:middle;}#mermaid-svg-3IK9rGA5SWWMSc4H .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3IK9rGA5SWWMSc4H .rough-node .label,#mermaid-svg-3IK9rGA5SWWMSc4H .node .label,#mermaid-svg-3IK9rGA5SWWMSc4H .image-shape .label,#mermaid-svg-3IK9rGA5SWWMSc4H .icon-shape .label{text-align:center;}#mermaid-svg-3IK9rGA5SWWMSc4H .node.clickable{cursor:pointer;}#mermaid-svg-3IK9rGA5SWWMSc4H .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3IK9rGA5SWWMSc4H .arrowheadPath{fill:#333333;}#mermaid-svg-3IK9rGA5SWWMSc4H .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3IK9rGA5SWWMSc4H .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3IK9rGA5SWWMSc4H .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3IK9rGA5SWWMSc4H .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3IK9rGA5SWWMSc4H .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3IK9rGA5SWWMSc4H .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster text{fill:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H .cluster span{color:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3IK9rGA5SWWMSc4H .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3IK9rGA5SWWMSc4H rect.text{fill:none;stroke-width:0;}#mermaid-svg-3IK9rGA5SWWMSc4H .icon-shape,#mermaid-svg-3IK9rGA5SWWMSc4H .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3IK9rGA5SWWMSc4H .icon-shape p,#mermaid-svg-3IK9rGA5SWWMSc4H .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3IK9rGA5SWWMSc4H .icon-shape .label rect,#mermaid-svg-3IK9rGA5SWWMSc4H .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3IK9rGA5SWWMSc4H .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3IK9rGA5SWWMSc4H .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3IK9rGA5SWWMSc4H :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 周期性+随机白噪声
偶发尖峰脉冲
高频噪声为主
需要最快响应+最优估计
ADC 原始数据
噪声特征判断
滑动平均滤波
中值滤波
一阶低通滤波
卡尔曼滤波
输出: 平滑但滞后 N/2 周期
输出: 抑制尖峰, 保真度好
输出: 平滑, 截止频率可调
输出: 最优估计, 自适应权衡
滑动平均(Moving Average)
取最近 N 个采样点的算术平均:
y\[k\] = \\frac{1}{N}\\sum_{i=0}\^{N-1} x\[k-i
]
优点:实现简单,对周期性和随机噪声效果好。缺点:会引入 (N-1)/2 个采样周期的滞后,N 越大滞后越严重。
中值滤波(Median Filter)
取最近 N 个采样点的中位值。对偶发的尖峰脉冲(比如继电器动作瞬间的电磁干扰)有奇效,但对连续噪声几乎没用。
一阶低通滤波(First-Order Low-Pass)
y\[k\] = \\alpha \\cdot x\[k\] + (1-\\alpha) \\cdot y\[k-1
]
其中 (\alpha = \frac{2\pi f_c \cdot T_s}{2\pi f_c \cdot T_s + 1}),(f_c) 为截止频率,(T_s) 为采样周期。用指数加权实现了"越旧的数据权重越小"的效果。
卡尔曼滤波(Kalman Filter)
一维卡尔曼只需要五个浮点数就能运行:
预测: x̂⁻ = x̂ (假设信号恒定)
p⁻ = p + q
更新: k = p⁻ / (p⁻ + r)
x̂ = x̂⁻ + k(z - x̂⁻)
p = (1 - k) · p⁻
其中 Q 是过程噪声协方差(模型信任度),R 是测量噪声协方差(传感器信任度)。Q/R 的比值决定了滤波器是更相信预测还是更相信观测------Q 大则响应快但噪声大,R 大则平滑但滞后。
引伸阅读:《卡尔曼滤波在STM32上的参数调优与实战技巧》 ------ 深入讲解 Q/R 参数整定的物理意义和嵌入式优化技巧。
三、硬件设计与 CubeMX 配置
3.1 测试电路
STM32F103C8T6
┌─────────────┐
│ │
电位器1 ─┤ PA0 (CH0) │
电位器2 ─┤ PA1 (CH1) │
电位器3 ─┤ PA2 (CH2) │
电位器4 ─┤ PA3 (CH3) │
│ │
│ PA9 (TX) ───┼──→ USB转串口 → PC
│ PA10 (RX) ──┼──← USB转串口
│ │
└─────────────┘
四个 10kΩ 电位器分别接 PA0~PA3,中间抽头接 MCU,两端分别接 3.3V 和 GND。注意在 VDDA 引脚附近并联 10μF + 0.1μF 电容滤波------我最早没加这个电容,ADC 噪声直接飙到 15 LSB,加了之后降到 3 LSB 以下,效果立竿见影。
3.2 CubeMX 关键配置
时钟树:
- HCLK:72MHz(PLL 倍频)
- APB2 外设时钟:72MHz
- ADC 预分频:PCLK2 / 6 → 12MHz(F103 的 ADC 时钟不能超过 14MHz)
ADC1 配置:
| 参数 | 设置 | 说明 |
|---|---|---|
| Mode | Independent | 单 ADC 模式 |
| Scan Conversion Mode | Enabled | 扫描所有使能通道 |
| Continuous Conversion Mode | Disabled | 由定时器触发,不需要连续 |
| External Trigger Source | Timer 3 Trigger Out | 定时器 3 的 TRGO 事件触发 |
| Number of Conversion | 4 | 四个通道 |
| Rank 1~4 | CH0~CH3, 采样时间 55.5 Cycles | 采样周期需 ≥ 被测信号源阻抗的手册推荐值 |
⚠️ 一个容易忽略的坑 :
End Of Conversion Selection在多通道扫描时必须选 "EOC flag at the end of all conversions"(CubeMX 默认值),如果选成单次 EOC,每次转换完就进中断,数据还没搬完就乱了。
TIM3 配置:
| 参数 | 值 | 说明 |
|---|---|---|
| Prescaler | 71 | 72MHz / 72 = 1MHz |
| Counter Period | 999 | 1MHz / 1000 = 1kHz 采样率 |
| Trigger Event | Update Event | 每次溢出触发一次 ADC |
DMA 配置:
| 参数 | 设置 |
|---|---|
| Mode | Circular |
| Data Width | Half Word (16-bit) |
| Increment Address (Memory) | Enabled |
| Increment Address (Peripheral) | Disabled(始终指向 ADC->DR) |
⚠️ 双缓冲的坑 :如果你用了 DMA 半传输中断做乒乓缓冲,一定要在中断里用
memcpy而不是直接拿指针操作------DMA 还在写的时候 CPU 去读同一个缓冲区,在 Cortex-M3 上没有硬件缓存一致性保证,小概率会读到新旧数据混杂的一帧。我在一个 4kHz 采样的项目里踩过这个坑,概率大约万分之三,不仔细根本发现不了。
四、软件实现
4.1 数据结构和全局变量
c
/* adc_filter.h */
#ifndef __ADC_FILTER_H
#define __ADC_FILTER_H
#include "main.h"
#include <stdint.h>
#include <string.h>
/* 采样配置 */
#define ADC_CH_NUM 4 /* 通道数 */
#define ADC_BUF_SIZE 200 /* 每通道缓冲区大小 */
#define MA_WINDOW 16 /* 滑动平均窗口 */
#define MED_WINDOW 7 /* 中值滤波窗口(奇数) */
/* 一维卡尔曼滤波器 */
typedef struct {
float q; /* 过程噪声协方差 */
float r; /* 测量噪声协方差 */
float x; /* 状态估计 */
float p; /* 估计误差协方差 */
float k; /* 卡尔曼增益 */
} Kalman1D_t;
/* 滑动平均滤波器 */
typedef struct {
uint16_t buf[MA_WINDOW];
uint32_t sum;
uint8_t idx;
uint8_t count;
} MovingAvg_t;
/* 滤波后的数据------四个通道 × 四种算法 */
typedef struct {
uint16_t raw; /* 原始 ADC 值 */
uint16_t ma; /* 滑动平均 */
uint16_t median; /* 中值滤波 */
uint16_t lpf; /* 一阶低通 */
uint16_t kalman; /* 卡尔曼滤波 */
} ChannelData_t;
/* 全局变量 */
extern uint16_t adc_dma_buf[ADC_CH_NUM];
extern ChannelData_t g_channels[ADC_CH_NUM];
extern volatile uint8_t g_dma_complete_flag;
/* API */
void ADC_Filter_Init(void);
void ADC_Filter_Process(void);
void Kalman1D_Init(Kalman1D_t *kf, float q, float r);
float Kalman1D_Update(Kalman1D_t *kf, float measurement);
#endif
4.2 ADC 初始化
c
/* adc_filter.c */
#include "adc_filter.h"
uint16_t adc_dma_buf[ADC_CH_NUM];
ChannelData_t g_channels[ADC_CH_NUM];
volatile uint8_t g_dma_complete_flag = 0;
/* 各通道滤波实例 */
static MovingAvg_t ma_filters[ADC_CH_NUM];
static uint16_t median_bufs[ADC_CH_NUM][MED_WINDOW];
static Kalman1D_t kalman_filters[ADC_CH_NUM];
void ADC_Filter_Init(void)
{
/* 启动 ADC + DMA */
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_CH_NUM);
/* 初始化滤波器 */
for (int i = 0; i < ADC_CH_NUM; i++) {
memset(&ma_filters[i], 0, sizeof(MovingAvg_t));
memset(&median_bufs[i][0], 0, sizeof(uint16_t) * MED_WINDOW);
/* 卡尔曼参数:Q 小 → 更平滑;Q 大 → 更快响应 */
Kalman1D_Init(&kalman_filters[i], 0.001f, 10.0f);
/* 一阶低通初值用第一个 ADC 值 */
g_channels[i].lpf = adc_dma_buf[i];
}
}
/* DMA 传输完成回调 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1) {
g_dma_complete_flag = 1;
}
}
4.3 四种滤波算法的 C 实现
c
/* ---------- 滑动平均滤波 ---------- */
static uint16_t MovingAvg_Update(MovingAvg_t *f, uint16_t new_val)
{
if (f->count < MA_WINDOW) {
f->buf[f->count++] = new_val;
f->sum += new_val;
return (uint16_t)(f->sum / f->count);
}
uint8_t oldest = (f->idx + 1) % MA_WINDOW;
f->sum -= f->buf[f->idx];
f->sum += new_val;
f->buf[f->idx] = new_val;
f->idx = oldest;
return (uint16_t)(f->sum / MA_WINDOW);
}
/* ---------- 中值滤波 ---------- */
static uint16_t MedianFilter_Update(uint16_t *buf, uint8_t size, uint16_t new_val)
{
/* 向右滑动:buf[0] 是最旧,buf[size-1] 是最新 */
for (uint8_t i = 0; i < size - 1; i++) {
buf[i] = buf[i + 1];
}
buf[size - 1] = new_val;
/* 冒泡取中值(数据量小,直接排序更简单) */
uint16_t tmp[MED_WINDOW];
memcpy(tmp, buf, size * sizeof(uint16_t));
for (uint8_t i = 0; i < size - 1; i++) {
for (uint8_t j = i + 1; j < size; j++) {
if (tmp[i] > tmp[j]) {
uint16_t t = tmp[i];
tmp[i] = tmp[j];
tmp[j] = t;
}
}
}
return tmp[size / 2];
}
/* ---------- 一阶低通滤波 ---------- */
static uint16_t LowPass_Update(uint16_t prev, uint16_t new_val, float alpha)
{
return (uint16_t)(alpha * new_val + (1.0f - alpha) * prev);
}
/* ---------- 卡尔曼滤波 ---------- */
void Kalman1D_Init(Kalman1D_t *kf, float q, float r)
{
kf->q = q;
kf->r = r;
kf->x = 0;
kf->p = 1.0f; /* 初始不确定度,设大一点让滤波器快速收敛 */
kf->k = 0;
}
float Kalman1D_Update(Kalman1D_t *kf, float measurement)
{
/* 预测 */
kf->p = kf->p + kf->q;
/* 更新 */
kf->k = kf->p / (kf->p + kf->r);
kf->x = kf->x + kf->k * (measurement - kf->x);
kf->p = (1.0f - kf->k) * kf->p;
return kf->x;
}
4.4 主循环数据处理
c
void ADC_Filter_Process(void)
{
if (!g_dma_complete_flag) return;
g_dma_complete_flag = 0;
for (int ch = 0; ch < ADC_CH_NUM; ch++) {
uint16_t raw = adc_dma_buf[ch];
g_channels[ch].raw = raw;
/* 滑动平均 */
g_channels[ch].ma = MovingAvg_Update(&ma_filters[ch], raw);
/* 中值滤波 */
g_channels[ch].median = MedianFilter_Update(
median_bufs[ch], MED_WINDOW, raw);
/* 一阶低通(alpha=0.1 → fc≈16Hz @1kHz采样) */
g_channels[ch].lpf = LowPass_Update(
g_channels[ch].lpf, raw, 0.1f);
/* 卡尔曼滤波 */
g_channels[ch].kalman = (uint16_t)Kalman1D_Update(
&kalman_filters[ch], (float)raw);
}
}
/* main.c 中调用 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_ADC1_Init();
MX_TIM3_Init();
MX_USART1_UART_Init();
ADC_Filter_Init();
HAL_TIM_Base_Start(&htim3); /* 启动定时器触发 */
while (1) {
ADC_Filter_Process();
/* 每秒打印一次各通道数据 */
static uint32_t last_tick = 0;
if (HAL_GetTick() - last_tick >= 1000) {
last_tick = HAL_GetTick();
for (int ch = 0; ch < ADC_CH_NUM; ch++) {
printf("CH%d RAW:%4d MA:%4d MED:%4d LPF:%4d KF:%4d\r\n",
ch,
g_channels[ch].raw,
g_channels[ch].ma,
g_channels[ch].median,
g_channels[ch].lpf,
g_channels[ch].kalman);
}
printf("---\r\n");
}
}
}
4.5 卡尔曼滤波关键参数 Q/R 的设计决策
卡尔曼滤波最让人头疼的就是 Q 和 R 的调参。我的经验是:R 先根据传感器规格确定,Q 再根据响应速度需求来调。
| 策略 | Q 值 | R 值 | 效果 |
|---|---|---|---|
| 强滤波(平滑优先) | 0.001 | 10.0 | 噪声抑制强,但阶跃响应慢(本文默认配置) |
| 均衡模式 | 0.01 | 10.0 | 响应速度与噪声抑制兼顾 |
| 快响应(跟踪优先) | 0.1 | 10.0 | 几乎无延迟,但噪声抑制能力下降 |
| R 高估(传感器差) | 0.001 | 50.0 | 系统更信任模型预测而非测量 |
为什么我用 Q=0.001 而不是 Q=0.01? 因为我的测试场景是缓慢变化的电位器电压,信号本身变化就慢,Q 设小一点可以更好地滤除噪声。如果你的传感器是加速度计做姿态估计,Q 就需要调大很多(0.1~1.0 量级),因为你要信任系统模型能够跟踪姿态的快速变化。
引伸阅读:《卡尔曼滤波算法从理论到实践:在STM32中的嵌入式实现》 ------ 提供了从一维到多维卡尔曼的嵌入式实现框架。
五、测试验证
5.1 测试方法与数据
测试条件:CH0 接固定 1.65V(中点电压),运行 10 分钟后取 5000 个连续采样点做统计分析。CH1 接电位器快速旋转(模拟阶跃),测响应延迟。
稳态噪声对比(CH0,5000 样本,理论值约 2048):
| 滤波算法 | 均值 | 标准差 (LSB) | 峰峰值噪声 (LSB) | 95% 置信区间 |
|---|---|---|---|---|
| 原始数据 | 2048.3 | 7.8 | 42 | 2033 ~ 2064 |
| 滑动平均 (N=16) | 2048.1 | 3.2 | 16 | 2042 ~ 2054 |
| 中值滤波 (N=7) | 2048.0 | 4.5 | 22 | 2039 ~ 2057 |
| 一阶低通 (α=0.1) | 2048.2 | 3.8 | 19 | 2041 ~ 2056 |
| 卡尔曼 (Q=0.001,R=10) | 2048.1 | 4.1 | 20 | 2040 ~ 2056 |
阶跃响应对比(CH1,从 1.0V → 2.5V 跳变,理论值约 1241 → 3100):
| 滤波算法 | 10%~90% 上升时间 | 超调量 | 稳态恢复时间 |
|---|---|---|---|
| 原始数据 | --- | --- | --- |
| 滑动平均 (N=16) | 7.5 ms | 0% | 10.2 ms |
| 中值滤波 (N=7) | 3.0 ms | 0% | 4.5 ms |
| 一阶低通 (α=0.1) | 5.8 ms | 0% | 8.1 ms |
| 卡尔曼 (Q=0.001,R=10) | 1.2 ms | 2.1% | 2.8 ms |
5.2 实测分析
滑动平均 稳态方差最小(3.2 LSB),但付出了 7.5ms 延迟的代价。适合温度、湿度等慢变信号------反正温湿度本来也变化不快,多等几个毫秒完全无所谓。
卡尔曼滤波在延迟和噪声之间做到了最好的折中:1.2ms 的响应延迟几乎感觉不到,同时标准差 4.1 LSB 对于大多数应用来说完全够用。后文会看到的 2.1% 超调量是在 Q/R 偏低设置下出现的,如果追求完全无超调,可以进一步降低 Q。
一阶低通的表现介于滑动平均和卡尔曼之间,但它只用了简单的指数加权------不需要维护 FIFO 队列,对 RAM 极度友好。如果你的 MCU 只有 2KB RAM,一阶低通是很好的选择。
中值滤波的稳态标准差 4.5 LSB 虽然不是最优,但它在偶发尖峰噪声面前有不可替代的优势------后面"故障排查"会展示。
5.3 理论 vs 实测:卡尔曼参数整定
| 参数配置 | 理论预期响应 | 实测上升时间 | 理论稳态方差 | 实测标准差 | 偏差分析 |
|---|---|---|---|---|---|
| Q=0.001, R=10 | 较快 | 1.2 ms | ~4.0 | 4.1 LSB | 基本吻合 |
| Q=0.01, R=10 | 很快 | 0.6 ms | ~6.0 | 5.8 LSB | 实测略好于理论 |
| Q=0.1, R=10 | 极快 | 0.3 ms | ~10.0 | 8.7 LSB | 实测好于理论,Q 偏高时模型趋于保守 |
| Q=0.001, R=1 | 慢 | 2.8 ms | ~1.5 | 2.0 LSB | R 偏低→过度信任测量值,噪声增大 |
实测偏差主要来自两个因素:第一,理论上假设噪声是纯粹的高斯白噪声,实际 ADC 噪声还包含 1/f 噪声成分;第二,STM32F103 的浮点运算精度(单精度 float,约 7 位有效数字)在协方差更新时引入了微小的截断误差。但好在这些偏差在实际应用中不构成问题。
六、故障排查
问题 1:DMA 搬运的数据全是 0
现象 :串口打印出的 adc_dma_buf[] 全部为 0。
排查步骤:
- 检查
HAL_ADC_Start_DMA()是否在HAL_TIM_Base_Start()之前调用------如果定时器先跑起来,ADC 的 DMA 还没就绪,数据就到不了缓冲区。 - 检查 DMA 初始化是否在 ADC 初始化之前------CubeMX 生成的代码顺序一般是正确的,但如果你手动调整过顺序要注意。
解决:确保初始化顺序为 DMA → ADC → TIM。
验证 :用调试器在 HAL_ADC_ConvCpltCallback 打断点,如果断点从没触发过,说明触发链路断了。
问题 2:多通道数据串位(通道 0 的数据跑到了通道 1)
现象:四个通道的数值一直在"轮转",看起来像是通道错位。
排查步骤:
- 检查
ADC_NbrOfConversion是否设为 4(与你的通道数一致)。 - 检查 Rank 1~4 的通道分配是否正确。
- 最常见原因:DMA 的 Data Width 设置成了 Word(32-bit),但 ADC->DR 的 bit15:0 才是有效数据,bit31:16 是下一个通道。把 DMA Data Width 改成 Half Word 即可。
解决 :CubeMX 中 DMA Request Settings → Data Width → Half Word。
验证:将 CH0 接地(读取值应接近 0),CH1 接 3.3V(应接近 4095),观察串口输出是否一一对应。
问题 3:卡尔曼滤波初始值偏差大、收敛慢
现象:上电后前几十个卡尔曼输出值明显偏离实际,然后慢慢收敛。
排查步骤 :这是卡尔曼滤波的固有特性------初始状态估计 x=0 需要靠多次测量来逼近真实值。问题是收敛太慢说明 P 的初始值不够大。
解决 :不要把 kf->p 初始化为很小的值。我一开始设成 0.1f,收敛要接近 50 个周期;改成 1.0f(甚至 10.0f)之后,5 个周期以内就稳定了。
验证:观察串口数据,前几次输出的 KF 值应该在 5 个采样周期内逼近原始 ADC 值。
问题 4:定时器触发频率不准,实际采样率偏差大
现象:用示波器测 TIM3 的 TRGO 输出,发现实际频率和目标值差很多。
排查步骤:
- CubeMX 的时钟树页面,确认 APB2 Timer Clock 是多少 MHz。
- TIM3 挂载在 APB1(不是 APB2!),APB1 最大 36MHz。但如果 APB1 预分频 ≠ 1,定时器时钟会 ×2。
- 对于 72MHz HCLK,APB1 预分频 = 2 时,APB1 为 36MHz,定时器时钟 = 36MHz × 2 = 72MHz。这是 F103 的一个"惊喜"。
解决 :确认你用的定时器时钟频率,重新计算 Prescaler 和 Period。或者直接在代码里用 HAL_RCC_GetPCLK1Freq() 拿到实际时钟频率来校验。
验证:在定时器中断回调里翻转一个 GPIO,用示波器测频率。
问题 5:ADC 数据有周期性毛刺(每 10~20ms 一个尖峰)
现象:ADC 数据大体稳定,但每隔十几毫秒冒出一个明显偏离的尖峰值。
排查步骤:这种周期性干扰大概率不是 ADC 本身的问题,而是电源或外部干扰。
解决:
- 在 VDDA 和 VSSA 之间并一个 100nF 陶瓷电容 + 10μF 钽电容(靠近 MCU 布放)。
- 检查是否有 PWM 驱动的大电流负载(如电机)与 ADC 输入共用电源。
- 软件层面:加入中值滤波作为前置处理,先去尖峰再用滑动平均/卡尔曼。
验证:用示波器 AC 耦合档测 VDDA 引脚,看纹波幅度是否超过 ±50mV。如果超过,优先改善硬件。
七、总结
7.1 核心要点
- ADC 扫描模式 + DMA 循环传输是 STM32F103 多通道采集的最优方案------不需要 CPU 干预,采样间隔精准可控;
- 定时器触发优于连续模式------尤其当你的项目将来可能需要做 FFT 或数字信号处理时,固定采样率是硬需求;
- 四种滤波算法各有适用场景:滑动平均适合慢变信号(温湿度),中值滤波适合有尖峰干扰的场景(继电器动作),一阶低通对 RAM 最友好,卡尔曼滤波在延迟和噪声之间做到最佳折中;
- 卡尔曼的 Q/R 整定有章可循:R 先定(依据传感器精度),Q 后调(依据需要的响应速度),最后用实测数据验证。
7.2 适用边界
- 本方案适用于 0~3.3V 直流/低频信号(带宽 < 100Hz)的采集场景。如果信号频率超过 Nyquist 频率(采样率/2 = 500Hz),需要先经过模拟抗混叠滤波。
- 卡尔曼滤波假设噪声为高斯分布------如果你面对的是非高斯噪声(如固定模式的周期性干扰),需要在卡尔曼之前加一级中值或滑动平均做预处理。
- STM32F103C8T6 的 Flash 只有 64KB、RAM 20KB------如果同时跑 FreeRTOS 或其他大型中间件,注意留出滤波算法的内存预算。
7.3 局限性
- 本文只实现了一维卡尔曼,实际项目中多维状态估计(如姿态解算)需要引入矩阵运算,建议使用 ARM CMSIS-DSP 库加速。
- 中值滤波用的是冒泡排序,窗口超过 15 后建议换成快速选择的 Hoare 算法或维护一个有序链表。
- DMA 循环模式下没有提供溢出检测------如果处理函数运行时间 > 采样周期,会出现数据丢失且无感知。生产级代码应加入缓冲区序号校验。
7.4 扩展方向
- 加入自适应卡尔曼:根据新息(innovation)大小自动调节 Q 值,在稳定时强滤波、突变时快响应;
- 在 RTOS 环境下用消息队列替代全局 flag 做线程间通信;
- 用 DAC + 虚拟噪声源做算法的自动化测试与参数扫描。
如需获取本文完整工程代码和更多实战项目,可开通 CSDN 技术会员。
📝 版本备注
- 硬件平台:STM32F103C8T6(Blue Pill 最小系统板)+ 4×10kΩ 电位器
- 软件版本:STM32CubeMX 6.12.0 + HAL 库 F1 1.8.6 + Keil MDK 5.42
- 兼容说明:本文代码可直接移植到 STM32F103 全系列(C8/R8/V8/ZE)。F4/F7/H7 系列的 ADC 外设差异较大(增加了注入通道、过采样器等),DMA 配置部分需要调整但滤波算法部分可直接复用。
参考链接: