
做STM32H7项目时很多人的第一反应是要上滤波就用CMSIS-DSP软件库反正H7主频高、带FPU跑个128阶FIR不是问题。但等你真的把采样率提到几百kHz再把UI、协议栈、存储、控制算法堆到一起你会发现CPU占用率像坐火箭一样往上蹿。更麻烦的是软件滤波的时序会受中断抢占影响实时性不好保证。其实H7的Cortex-M7内核旁边挂着一颗几乎被遗忘的硬件外设——FMAC滤波数学加速器它天生就是干乘加和滤波的活能把“滤波”这块从CPU身上完全摘下来。这篇文章就围绕STM32H7的FMAC从原理到寄存器配置再到接DMA、DMAMUX做实时信号处理链路最后给一个DDS波形生成中的平滑滤波实战希望能帮正在做实时信号处理的朋友少走弯路。1. FMAC的本质为什么说它是一台藏在MCU里的微型DSP1.1 滤波的本质就是大量的乘加数字滤波器无论FIR还是IIR在数学上都逃不过乘法和加法。FIR的公式是 y[n] Σ b[k]·x[n-k]128阶FIR意味着每个输出样本要计算128次乘加IIR虽然阶数少但有反馈项每个输出同样需要一组乘加。普通做法是CPU用指令去循环执行这些运算CMSIS-DSP也只是把循环优化得更好——用单周期乘加指令、循环展开、甚至SIMD并行。但底层逻辑没变CPU的ALU和FPU在干活D-Cache和总线带宽被占满中断一来滤波过程还会被打断。FMAC的思路完全不一样。它在Cortex-M7内核外面单独放了乘加运算单元和专用存储区CPU只需要把系数配好、把数据喂进去FMAC内部的状态机就会自动完成滤波算完再通知你取结果。整个过程中CPU可以去做别的事。用一句话概括FMAC就是一颗阉割版专用DSP专精滤波。1.2 FMAC和CMSIS-DSP在实时系统里的定位差异我见过不少人在选型时纠结“有了FMAC是不是就不需要CMSIS-DSP了”这两个东西其实不冲突定位完全不同。我整理过一张对比表基本能把适用边界说清楚。维度FMAC硬件加速器CMSIS-DSP软件库运算载体独立硬件专用乘加单元CPU内核 FPU/SIMDCPU占用滤波器运行时不占CPU每个滤波样本都占CPU周期时序确定性固定状态机受中断影响小受中断和总线调度影响灵活性主要是FIR/IIR结构固定支持FFT、矩阵、PID等大量算法接入方式寄存器/FIFO/DMA/中断函数调用适合场景持续、高速、确定性的滤波流偶尔算一次、算法种类多核心优势卸载CPU、功耗更优、时序稳生态成熟、调试方便在一个大型实时系统里FMAC适合做那些“一直在跑”的固定滤波任务比如ADC采样流的前端滤波、音频流的均衡、电机电流环的谐波过滤CMSIS-DSP更适合做“偶尔来一次”的复杂分析比如FFT频谱计算、矩阵求逆、PID参数整定。两者混用才是常态。1.3 怎么确认你的H7带不带FMAC这里必须先泼一盆冷水不是所有STM32H7都有FMAC。FMAC和CORDIC一样是Cortex-M7衍生外设H743、H753、H750、H7A3、H7B3、H723、H725、H730、H735这些常见型号基本都集成了。但个别低配型号或者特定封装可能没有引出或裁剪了这部分功能。最可靠的办法有两个一是打开STM32CubeMX选中你的具体型号在“Computing”分类下看有没有FMAC和CORDIC二是直接查该型号的参考手册PDF搜索“FMAC”关键词如果找不到对应章节那就是不带。我以前就吃过亏拿了个OPN带FMAC的型号做设计结果PCB用了精简封装外设还在但引脚复用不对绕了不少弯路——所以说硬件选型阶段就要确认好FMAC是否可用别等固件写了一半再发现。2. 从寄存器到工作流FMAC内部到底怎么转2.1 两组存储器和两组FIFOFMAC的寄存器模型中有两块关键存储区X1和X2用于存放输入数据还有FIRC和FMRC分别存放FIR和IIR的系数。寄存器侧则是通过两个FIFO对外交换数据WFIFO写FIFO你把输入样本写入 FMAC_ALIM 数据寄存器样本进WFIFO供内部状态机取走参与运算RFIFO读FIFO滤波结果算完后放进RFIFO你读 FMAC_AFDATA 数据寄存器就能取出来。这两个FIFO是FMAC能实现流水线的基础。WFIFO只要不空FMAC就持续计算RFIFO只要不满FMAC就能持续写结果。如果你的数据进出速度跟不上FMAC会插入等待周期但不会算错。用生活化的方式理解WFIFO是传送带入口RFIFO是传送带出口。你往入口不停放零件出口不停取走成品中间的加工机器就不会停。机器是“每周期完成一次乘加”的流水线积压多了它会原地等待但不会把零件搞丢。2.2 FIR和IIR两种工作模式FMAC支持FIR和IIR细分下来有三类典型用法单次FIR模式配置好系数和输入长度启动后一次性算完一批数据适合帧处理。比如你采集了256点想整批做一次128阶低通滤波就用这个模式。流水线FIR模式配置好系数后不指定批次长度数据持续从WFIFO进来持续从RFIFO出去适合连续数据流。只要WFIFO一直有数据FMAC会一直算下去。IIR模式FMAC的IIR是直接I型结构支持二阶节biquad级联适合需要高Q值带通、陷波、或者系数比较少的场合。由于存在反馈项IIR吞吐率一般不如流水线FIR但相比软件实现仍然快一个量级。我实际项目里用得最多的是流水线FIR因为它最能发挥FMAC“边进边出”的特性。单次FIR适合做块处理你会发现启动、等完成、取结果这套流程其实和DMA搬运不太容易配合好流水线模式天然适合挂DMA跑无限流。2.3 数据位宽与Q15定标FMAC的数据位宽支持8位、16位、24位、32位等几种配置系数也有16位和32位之分。但工程中最常用的是16位有符号定点数也就是Q15格式。Q15是什么简单说16位有符号数表示范围是-32768到32767Q15把整个范围映射到-1.0到0.999969。换算公式是浮点值 × 32767 后四舍五入比如0.5 → 16384-0.25 → -8192。输入信号从ADC出来如果是16位带符号直接就能对接系数在Q15下必须保证绝对值不超过1.0否则会溢出。如果你对精度要求更高可以用32位数据和32位系数此时中间累加器还有64位作保护基本不会溢出。但代价是寄存器操作数翻倍DMA带宽占用也增大。我个人的建议是绝大多数传感器信号处理用16位足够只有音频高端应用和高精度测量才值得上32位。3. 动手配置FMACFIR滤波从初始化到跑通3.1 时钟和HAL初始化FMAC挂在AHB总线上使用前必须使能时钟。用标准HAL库初始化代码大致是这个样子#include stm32h7xx_hal.h FMAC_HandleTypeDef hfmac; void Fmac_Init(void) { __HAL_RCC_FMAC_CLK_ENABLE(); hfmac.Instance FMAC; HAL_FMAC_Init(hfmac); /* 滤波参数配置流水线FIR16位数据16位系数 */ FMAC_FilterConfigTypeDef cfg {0}; cfg.FilterType FMAC_FIR_PIPELINED; cfg.FilterDataSize FMAC_DATA_SIZE_16BIT; cfg.CoefficientDataSize FMAC_COEFF_SIZE_16BIT; cfg.PdmDataSize FMAC_PDM_SIZE_16BIT; cfg.TwoMemoryUnits DISABLE; HAL_FMAC_FilterConfig(hfmac, cfg); }这里要提醒一句不同版本的STM32CubeH7固件包HAL_FMAC结构体和函数名可能略有差异。如果你用的是较老的Cube包看到编译报错不要慌去安装路径下的stm32h7xx_hal_fmac.h里查一下实际的结构体和函数签名代码流程基本不变。3.2 FIR系数设计与归一化滤波器系数我一般用Python的SciPy设计再转成C数组。以128阶、采样率100kHz、截止频率10kHz的低通为例import numpy as np from scipy.signal import firwin fs 100_000 cutoff 10_000 taps 128 # 用Hamming窗设计低通FIR h firwin(taps, cutoff, fsfs, windowhamming) # 归一化让系数总和等于1.0保证通带增益为0dB h h / np.sum(h) # 转Q15注意需要裁到int16范围 h_q15 np.round(h * 32767).astype(np.int16) # 输出C数组格式 print(, .join(f{v} for v in h_q15))归一化很多人容易漏。如果系数和不为1滤波后的信号幅度会发生整体缩放如果系数峰值超过1.0在Q15定标下直接截顶输出波形会失真。我之前做过一个带通滤波器忘了归一化结果输出幅度只有理论的70%排查了半天才发现是系数缩放问题。系数设计完加载到FMACextern int16_t h_q15[128]; void Fmac_LoadFirCoeffs(void) { FMAC_FIRParamTypeDef fir {0}; fir.pCoeff h_q15; fir.CoeffSize 128; HAL_FMAC_FilterConfigFIR(hfmac, fir); }3.3 单次模式跑一批数据配置完系数就可以启动滤波了。先看轮询方式这能帮助你理解FMAC的基本行为#define BLOCK_SIZE 256 int16_t input[BLOCK_SIZE]; int16_t output[BLOCK_SIZE]; void Fmac_ProcessBlock(void) { uint16_t i; HAL_FMAC_FilterStart(hfmac); /* 写入输入数据到WFIFO */ for (i 0; i BLOCK_SIZE; i) { /* 等待WFIFO有空间实际项目可改成中断或DMA */ HAL_FMAC_FilterInData(hfmac, (uint8_t *)input[i], 1); } /* 从RFIFO读取滤波结果 */ for (i 0; i BLOCK_SIZE; i) { HAL_FMAC_FilterOutData(hfmac, (uint8_t *)output[i], 1); } }注意HAL_FMAC_FilterInData和FilterOutData在某些Cube版本里接收的是字节指针这里为了直观我传了int16_t类型地址。如果你希望按16位样本为单位操作最好逐样本调用或者在库封装层做一层适配。更底层的写法是直接操作寄存器#define FMAC_WFIFO_FULL (FMAC-STAT 0x00000002UL) #define FMAC_RFIFO_EMPTY (FMAC-STAT 0x00000008UL) void Fmac_WriteSample(int16_t sample) { while (FMAC_WFIFO_FULL); /* 等待WFIFO有空间 */ FMAC-ALIM (uint32_t)(int32_t)sample; } int16_t Fmac_ReadResult(void) { while (FMAC_RFIFO_EMPTY); /* 等待RFIFO有数据 */ return (int16_t)(FMAC-AFDATA 0xFFFFUL); }这里的状态寄存器位名在不同参考手册版本里可能有细微差异正式写代码前务必以你自己手上的参考手册为准。但逻辑是通用的写前查WFIFO满标志读前查RFIFO空标志。3.4 流水线模式的连续处理流水线模式启动后FMAC不会“一次性算完”然后停掉而是WFIFO来一个数据就吞一个RFIFO吐一个结果。你不需要指定数据长度只要保证数据供得上、结果取走得及时。这个模式下有两个关键注意点启动前一定要先加载好系数而且运行过程中不建议再改系数否则硬件状态机取到的系数可能新旧混杂。连续运行时CPU轮询WFIFO和RFIFO的负担不小所以流水线模式几乎总是配DMA用。我第一次做流水线时就踩过坑启动后立刻往WFIFO猛塞数据结果发现输出比输入延迟了大约N个采样N等于FIR阶数这是FIR的固有群延迟不是错误。做同步算法时要把这段延迟补偿掉不然你的控制系统相位裕量会受影响。4. 接入DMA与DMAMUX让滤波变成一条自动流水线4.1 不用DMA的FMAC是不完整的FMACFMAC的硬件设计者早就考虑到了连续流场景所以专门为WFIFO和RFIFO设计了DMA请求线。简单说当WFIFO有空位时FMAC可以向DMA发出写请求DMA自动把内存中的数据搬到FMAC_ALIM;当RFIFO有数据时FMAC可以向DMA发出读请求DMA自动把FMAC_AFDATA的数据搬到内存。数据搬运完全不经过CPU。你要做的只是配置好两个DMA流——一个负责喂数据一个负责收结果。4.2 输入侧和输出侧DMA配置输入DMA的方向是内存到外设Memory-to-Peripheral外设地址是FMAC的ALIM寄存器输出DMA方向是外设到内存Peripheral-to-Memory外设地址是FMAC的AFDATA寄存器。数据宽度都要和FMAC配置匹配16位就用半字Half Word。伪代码框架如下DMA_HandleTypeDef hdma_fmac_in; DMA_HandleTypeDef hdma_fmac_out; void FmacDma_Init(void) { /* 输入DMA内存 - FMAC-ALIM */ hdma_fmac_in.Instance DMA1_Stream0; hdma_fmac_in.Init.Request DMA_REQUEST_FMAC_WRITE; hdma_fmac_in.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_fmac_in.Init.PeriphInc DMA_PINC_DISABLE; hdma_fmac_in.Init.MemInc DMA_MINC_ENABLE; hdma_fmac_in.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_fmac_in.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_fmac_in.Init.Mode DMA_NORMAL; hdma_fmac_in.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_fmac_in); __HAL_LINKDMA(hfmac, hdmaIn, hdma_fmac_in); /* 输出DMAFMAC-AFDATA - 内存 */ hdma_fmac_out.Instance DMA1_Stream1; hdma_fmac_out.Init.Request DMA_REQUEST_FMAC_READ; hdma_fmac_out.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_fmac_out.Init.PeriphInc DMA_PINC_DISABLE; hdma_fmac_out.Init.MemInc DMA_MINC_ENABLE; hdma_fmac_out.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_fmac_out.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_fmac_out.Init.Mode DMA_NORMAL; hdma_fmac_out.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_fmac_out); __HAL_LINKDMA(hfmac, hdmaOut, hdma_fmac_out); }具体使用哪个DMA控制器、哪个流要看你的系统总线规划和CubeMX生成的引脚/请求映射。FMAC的DMA请求号在不同型号上可能挂在DMA1或DMA2上用CubeMX图形界面配置最省心。4.3 用DMAMUX做双缓冲和触发源路由STM32H7的DMA请求都经过DMAMUX这意味着你可以做很多灵活的“外设联动”。在实时信号处理里最实用的两个能力是把定时器或ADC事件路由为DMA触发源比如定时器更新事件触发ADC采集ADC转换完成事件触发DMA把结果搬到内存。这样采样和搬运节奏完全由硬件决定CPU零参与。给DMA流配置双缓冲。比如ADC采集数据准备好后DMA交替写入缓冲区A和缓冲区B。当DMA正在写A时CPU或另一路DMA可以从B搬数据给FMAC反过来再交换。这就是热词里“双缓冲”的真正意义所在采样的同时可以处理上一帧数据彻底隐藏搬运时间。FMAC的流水线FIR配合双缓冲可以做到真正的“不间断”实时滤波。一个典型的ADC → FMAC → DAC链路可以这样搭定时器触发ADC ↓ ADC转换完成事件触发DMA1把采样数据写入Buffer A/B双缓冲 ↓ Buffer B 中的数据通过DMA2送往FMAC-ALIM ↓ FMAC完成滤波结果进入RFIFO ↓ DMA3把FMAC-AFDATA结果送往DAC或内存整个链路中CPU只需要在帧切换时处理一下边界条件比如同步滤波结果的时间戳、做阈值判断、更新滤波器系数等。滤波本身完全不占CPU。4.4 启动DMA滤波的时序细节启动顺序是很多人忽略的重点。正确的顺序建议是先把输出DMA准备好也就是让FMAC的结果能及时被搬走再启动输入DMA开始喂数据最后调用HAL_FMAC_FilterStart启动FMAC状态机。如果顺序反了FMAC启动后WFIFO还没有数据它会空转等待这时候RFIFO也没有数据输出DMA也在等待系统一样能跑起来但功耗和总线占用会有轻微浪费。更危险的是如果输入DMA配置错误恰好在启动瞬间WFIFO已空可能的错误计数会让人排查到怀疑人生。5. 实战DDS波形生成中的FMAC平滑滤波5.1 场景设定查表DDS的阶梯波问题我最近做一个高精度信号源用的DDS思路相位累加器按固定步进递增高若干位作为查表索引从正弦表取出幅度输出。逻辑很简单但直接输出有个问题——正弦表的点数有限输出波形会呈现明显阶梯状体现在频谱上就是高次谐波镜像分量很多THD指标难看。通常的解法是在DAC后面加模拟低通滤波器但模拟滤波器的阶数、截止频率、相位一致性都不好做尤其是要求输出频率大范围可调时模拟滤波器还得跟着切换非常痛苦。更好的思路是在数字域先做一次零插值把每个表值后面补几个0等效提高采样率然后让FMAC跑FIR低通把零插值带来的镜像分量滤掉。于是你会发现FMAC在这里的角色是数字抗镜像滤波器和DDS完美配合能显著降低THD。5.2 相位累加器与FMAC滤波代码以产生一个20kHz正弦波、输出更新率1Msps为例DDS核心代码如下#define PHASE_BITS 32 #define TABLE_SIZE 16384 #define SAMPLE_RATE 1000000u #define TARGET_FREQ 20000u int16_t sine_table[TABLE_SIZE]; uint32_t phase 0; void Dds_InitTable(void) { for (uint32_t i 0; i TABLE_SIZE; i) { double v sin(2.0 * M_PI * (double)i / (double)TABLE_SIZE); sine_table[i] (int16_t)(v * 32767.0); } } void Dds_GenerateFrame(int16_t *buf, uint32_t len) { uint32_t phase_inc (uint32_t)((uint64_t)TARGET_FREQ * ((uint64_t)1 PHASE_BITS) / SAMPLE_RATE); for (uint32_t i 0; i len; i) { uint32_t idx (phase (PHASE_BITS - 14)) (TABLE_SIZE - 1); buf[i] sine_table[idx]; phase phase_inc; } }得到DDS原始样本后做零插值和FIR平滑。假设我们做4倍零插值原始样本率是250kHz插值后等效1Msps再用128阶FIR平滑镜像频率。核心思路是生成一个长度等于原始数据4倍的数组每4个点放1个真实样本、3个0然后交给FMAC滤波#define RAW_LEN 256 #define UPSAMPLE 4 #define FRAME_LEN (RAW_LEN * UPSAMPLE) int16_t raw_buf[RAW_LEN]; int16_t upsample_buf[FRAME_LEN]; int16_t filter_out[FRAME_LEN]; void Dds_ZeroInsertFrame(void) { uint16_t i; for (i 0; i RAW_LEN; i) { upsample_buf[i * UPSAMPLE] raw_buf[i]; upsample_buf[i * UPSAMPLE 1] 0; upsample_buf[i * UPSAMPLE 2] 0; upsample_buf[i * UPSAMPLE 3] 0; } } void Dds_FirSmoothFrame(void) { /* 单次FIR一次处理整个插值后帧 */ HAL_FMAC_FilterStart(hfmac); HAL_FMAC_FilterInData(hfmac, (uint8_t *)upsample_buf, FRAME_LEN); HAL_FMAC_FilterOutData(hfmac, (uint8_t *)filter_out, FRAME_LEN); }这里FMAC的FIR系数要用“低通 通带增益1.0”的设计截止频率放在原始信号最高频率附近大致在0.5倍原始采样率以内这样信号本身不会被明显衰减而零插值产生的镜像被压制。实测下来单纯把DDS输出从16000点表直接给DACTHD大概在0.5%量级加上FMAC平滑后只要FIR阶数够THD能压到0.1%以下而且不需要换模拟滤波器。5.3 实时升级DMA双缓冲驱动DDS上面那段代码是“先生成一帧-处理-输出”的块模式实时性已经不错了。但如果你想做成无限流输出可以套用上一节的DMA框架定时器触发DMA周期性地从当前正弦表读数据送给FMACFMAC输出通过另一路DMA送到DAC。这样DDS FMAC DMA三个硬件同时工作CPU只在需要改频率时更新一下相位步进。改频率的操作也简单DDS频率 相位步进 × 时钟频率 / 2^32想从20kHz跳到50kHz只需要更新phase_inc变量并且在更新时停一下FMAC输入DMA等当前帧处理完再改避免频率切换瞬间出现半个周期的杂散波形。这个小细节是调试DDS时踩坑总结出来的不加这个同步机制示波器上会看到偶尔多出的小毛刺。6. 实测中绕不开的坑与处理办法6.1 系数溢出、增益异常和截顶Q15系数最大不能超过1.0设计宽带通、带阻滤波器时部分中间系数很容易超过这个限制。超过之后转成Q15就会饱和到32767实际上改变了滤波器幅频响应。我遇到过的情况是用Python设计一个窄带通滤波器系数峰值有2.8直接乘32767后全部截顶滤波出来的波形完全不对。处理办法很简单归一化时不光要让系数和等于1还要保证所有系数绝对值不超过1。可以用h h / max(abs(h))先做峰值归一化再乘32767转Q15。代价是通带增益不再是0dB需要你在后面用软件再补一个常数增益。6.2 WFIFO/RFIFO背压导致的数据错位轮询模式下如果写数据太快WFIFO满的标志还没清除就往里写数据会丢读数据太慢RFIFO满了FMAC只能停下来等待输出时间戳就会乱。这个问题在纯轮询代码里特别容易发生。建议是如果不是极低速率不要用轮询直接上DMA。DMA会对背压做硬件缓冲时序可靠得多。如果非要用中断记得优先级要配成高于其他可以打断它的外设中断并且中断服务函数里只做读写寄存器和清标志不做耗时的业务逻辑。6.3 开启D-Cache后的缓存一致性问题这是H7用户常踩的重灾区。H7内部有D-Cache虽然地址空间自身是一致的但DMA访问SRAM时CPU看到的数据可能还停留在Cache里。FMAC的DMA读取输入缓冲区时如果缓冲区数据改了但没回写CacheDMA读到的可能是旧数据输出缓冲区被DMA写了新数据CPU如果直接读可能读到的是过期数据。正确做法是在启动输入DMA前做Cache回写在输出DMA传输完成中断里做Cache失效操作/* DMA写数据前确保缓冲数据从Cache刷到SRAM */ SCB_CleanDCache_by_Addr((uint32_t *)dds_in, sizeof(dds_in)); /* DMA传输完成后让CPU重新从SRAM读取最新数据 */ SCB_InvalidateDCache_by_Addr((uint32_t *)fir_out, sizeof(fir_out));另外要注意地址对齐Cache操作要求地址32字节对齐缓冲区定义时用__ALIGNED(32)修饰。6.4 运行中改系数的不确定性FMAC允许你在滤波器运行过程中更新系数寄存器但硬件无法保证新旧系数切换瞬间的输出一致性。如果一个输出样本混了一半旧系数、一半新系数波形会出现一个尖刺。对于音频、信号源这种对杂散敏感的场景强烈建议不要运行中改系数。如果一定要在线切换我在项目里用的方法是先停掉输入DMA等当前所有数据清空然后加载新系数再重新启动DMA。这样会损失几个采样周期的滤波输出但至少不会出现毛刺。6.5 HAL库版本差异和寄存器名核对最后这一点可能有人觉得没必要提但真的坑了不少人。STM32CubeH7不同版本的HAL库FMAC函数签名有变化。比如某些早期版本里HAL_FMAC_FilterConfigFIR的参数结构体内容不一样HAL_FMAC_FilterInData对数据长度的解释也有差异。我建议是不要死记API直接打开你的Cube包源码对照stm32h7xx_hal_fmac.c里的实现来写调用代码。寄存器级操作也一样每个型号的参考手册才是最高权威。FMAC的STAT寄存器各标志位名虽然基本一致但确认后写代码心里才有底。从我个人的使用体验来说FMAC是H7上最被低估的外设之一。你把它跑起来之后会发现滤波从“每天要处理的负担”变成了“一条自动运行的硬件流水线”CPU终于能腾出来做真正需要智能处理的工作。如果你手头的H7项目正好有FIR或者IIR滤波需求真的建议花半天时间把FMAC摸一遍这个外设带来的性能和架构收益会很直观地体现在系统实时性和代码复杂度上。