3. CMSIS-DSP库详解与信号处理

说到嵌入式AI推理,很多人第一反应是上Linux、上GPU。但我在Cortex-M上摸爬滚打这些年,发现一个事实:大部分传感器数据的预处理和特征提取,在MCU上就能搞定。而CMSIS-DSP库,就是ARM官方给我们准备的一把瑞士军刀。

这一章,咱们就把它拆开看看。

3.1 CMSIS架构:不只是个库

CMSIS全称是Cortex Microcontroller Software Interface Standard。说白了,ARM为了让不同厂商的Cortex-M芯片能共用一套软件接口,搞了个标准层。

我个人习惯把CMSIS分成三层看:

  • CMSIS-Core:最底层,封装了NVIC、SysTick、MPU这些内核外设的寄存器操作。换芯片时,这部分代码基本不用动。
  • CMSIS-DSP:中间层,就是我们这章的主角。提供数学、滤波、变换等信号处理函数。
  • CMSIS-NN:最上层,专门为神经网络推理优化的函数库。后面几章会重点讲。

关键点:CMSIS-DSP和CMSIS-NN都用了SIMD(单指令多数据)指令集。Cortex-M4/M7/M33以上内核才有硬件支持。如果你用M0/M0+,部分函数会回退到纯软件实现,性能差不少。

我曾经在一个项目里,把STM32F4上的CMSIS-DSP代码直接移植到GD32F4上,编译通过,跑起来结果一致。这就是CMSIS的价值——跨厂商兼容。

3.2 基础数学函数:别小看这些“简单”操作

很多初学者觉得求模、点积太简单,不屑于用库函数。嗯,这里要注意:CMSIS-DSP里的基础函数,都是经过汇编级优化的。你自己手写的for循环,大概率跑不过它。

3.2.1 向量求模

求模就是计算向量的欧几里得长度。在传感器融合中,经常用来计算加速度计或陀螺仪的合成矢量。

// 计算三维向量的模
float32_t vec[3] = {1.0f, 2.0f, 3.0f};
float32_t result;

arm_sqrt_f32(vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2], &result);
// 或者直接用 arm_power 和 arm_sqrt 组合

但CMSIS-DSP提供了更高效的方式:

float32_t pSrc[3] = {1.0f, 2.0f, 3.0f};
float32_t pDst[3];  // 存储每个元素的平方
float32_t sum;

// 第一步:计算每个元素的平方
arm_mult_f32(pSrc, pSrc, pDst, 3);
// 第二步:求和
arm_add_f32(&pDst[0], &pDst[1], &sum, 1);  // 实际用 arm_power 更直接
// 第三步:开方
arm_sqrt_f32(sum, &result);

我的经验:在Cortex-M4上,arm_mult_f32一次能处理两个float32数据(SIMD)。如果你手动写循环,编译器不一定能自动向量化。所以,能用库函数就别自己造轮子。

3.2.2 点积

点积在AI推理中太常见了——全连接层、卷积层的核心计算就是点积。CMSIS-DSP提供了arm_dot_prod_f32:

float32_t pSrcA[4] = {1.0f, 2.0f, 3.0f, 4.0f};
float32_t pSrcB[4] = {5.0f, 6.0f, 7.0f, 8.0f};
float32_t result;

arm_dot_prod_f32(pSrcA, pSrcB, &result, 4);
// result = 1*5 + 2*6 + 3*7 + 4*8 = 70.0f

你想想看,如果这个点积用在神经网络的一层里,输入是128维,输出是64维,那就要算64×128=8192次乘加。CMSIS-DSP用SIMD指令,一次能算两个乘加,速度直接翻倍。

3.3 统计函数:均值、方差与数据预处理

传感器数据进AI模型前,通常要做归一化。均值和方差就是归一化的基础。

函数 功能 典型应用
arm_mean_f32 计算向量均值 传感器校准、直流偏置去除
arm_var_f32 计算方差 信号能量评估、异常检测
arm_std_f32 计算标准差 Z-score归一化
arm_min_f32 / arm_max_f32 找极值 Min-Max归一化
// 计算均值和标准差,用于Z-score归一化
float32_t sensor_data[128];
float32_t mean, std;

arm_mean_f32(sensor_data, 128, &mean);
arm_std_f32(sensor_data, 128, &std);

// 归一化:x' = (x - mean) / std
for(int i = 0; i < 128; i++) {
    sensor_data[i] = (sensor_data[i] - mean) / std;
}

避坑指南:我曾经在电池供电的设备上,用arm_std_f32处理512点数据,发现它内部会先算均值再算方差,两次遍历。如果内存够,可以自己写一次遍历的版本,省一半时间。但CMSIS-DSP的代码是经过严格测试的,稳定性优先。

3.4 FIR/IIR滤波器实现:给传感器信号“洗澡”

传感器原始数据通常很“脏”——有噪声、有工频干扰、有高频毛刺。滤波器就是给信号洗澡的工具。

3.4.1 FIR滤波器

FIR(有限脉冲响应)滤波器最大的优点是线性相位,不会造成信号畸变。代价是阶数通常比较高。

实现步骤:

  1. 用MATLAB或Python设计滤波器系数(b0, b1, ..., bN)
  2. 在代码中初始化FIR结构体
  3. 逐点处理数据
#define FIR_NUM_TAPS 32

float32_t fir_coeffs[FIR_NUM_TAPS] = {
    // 从MATLAB导出的低通滤波器系数
    // ...
};

float32_t fir_state[FIR_NUM_TAPS + BLOCK_SIZE - 1];
arm_fir_instance_f32 S;

// 初始化
arm_fir_init_f32(&S, FIR_NUM_TAPS, fir_coeffs, fir_state, BLOCK_SIZE);

// 处理数据块
float32_t input[BLOCK_SIZE];
float32_t output[BLOCK_SIZE];
arm_fir_f32(&S, input, output, BLOCK_SIZE);

我的习惯:BLOCK_SIZE一般取32或64。太小了,函数调用开销占比大;太大了,状态缓冲区占用内存多。在STM32F4上,我常用BLOCK_SIZE=32,兼顾速度和内存。

3.4.2 IIR滤波器

IIR(无限脉冲响应)滤波器用更少的阶数达到同样的滤波效果,但存在稳定性问题和非线性相位。

CMSIS-DSP实现了直接II型转置结构,数值稳定性更好:

#define IIR_STAGES 4  // 二阶节级联数

float32_t iir_coeffs[5 * IIR_STAGES];  // 每个二阶节5个系数
float32_t iir_state[4 * IIR_STAGES];
arm_biquad_cascade_df2T_instance_f32 S;

// 初始化
arm_biquad_cascade_df2T_init_f32(&S, IIR_STAGES, iir_coeffs, iir_state);

// 处理
arm_biquad_cascade_df2T_f32(&S, input, output, BLOCK_SIZE);

为什么会用级联二阶节?因为高阶IIR直接实现,系数量化误差会被放大,甚至导致振荡。级联之后,每个二阶节独立,稳定性好得多。

3.5 FFT变换与频谱分析:时域到频域的“翻译官”

很多传感器特征在时域看不出来,一转到频域就一目了然。比如电机振动信号,时域上乱糟糟的,频域上能看到明显的基频和倍频。

3.5.1 实数FFT vs 复数FFT

传感器数据都是实数,但FFT本质上是复数运算。CMSIS-DSP提供了arm_rfft_f32,专门针对实数输入做了优化:

  • 实数FFT:输入N点实数,输出N/2点复数(利用共轭对称性,只存一半)
  • 复数FFT:输入N点复数,输出N点复数

对于传感器信号,用实数FFT就够了,内存省一半。

#define FFT_SIZE 256

float32_t input[FFT_SIZE];      // 时域数据
float32_t output[FFT_SIZE];     // 频域结果(实部+虚部交错)
arm_rfft_fast_instance_f32 S;

// 初始化FFT实例
arm_rfft_fast_init_f32(&S, FFT_SIZE);

// 执行FFT
arm_rfft_fast_f32(&S, input, output, 0);  // 0表示正变换

// 计算幅值谱
float32_t magnitude[FFT_SIZE/2];
arm_cmplx_mag_f32(output, magnitude, FFT_SIZE/2);

性能数据:在Cortex-M4 @168MHz上,256点实数FFT大约需要0.2ms。这意味着你可以用5kHz的采样率做实时频谱分析,完全没问题。

3.5.2 频谱分析实战:找出异常振动

我曾经做一个电机预测性维护项目,需要在MCU上实时检测轴承故障。思路很简单:

  1. 以2kHz采样率采集加速度计数据
  2. 每256点做一次FFT
  3. 在频域中查找特定频段的能量是否超标
// 计算指定频段的能量
float32_t band_energy(float32_t *magnitude, int fft_size, float32_t fs,
                      float32_t f_low, float32_t f_high) {
    int idx_low = (int)(f_low * fft_size / fs);
    int idx_high = (int)(f_high * fft_size / fs);
    float32_t energy = 0.0f;

    for(int i = idx_low; i <= idx_high; i++) {
        energy += magnitude[i] * magnitude[i];
    }
    return energy;
}

这个方法简单可靠,在Cortex-M4上跑得飞起。后来客户反馈,提前两周预测到了轴承故障,避免了产线停机。

3.6 本章知识体系

下面这张图,把CMSIS-DSP的核心模块和它们之间的关系梳理清楚了:

CMSIS-DSP知识体系结构图 CMSIS-DSP 核心模块与数据流 CMSIS-DSP 库 基础数学 求模 · 点积 · 乘加 统计函数 均值 · 方差 · 极值 滤波器 FIR · IIR · 双二阶 变换 RFFT · CFFT · DCT 典型应用场景 传感器校准 噪声滤除 特征提取 异常检测 💡 所有模块均支持 SIMD 指令加速(Cortex-M4/M7/M33) 数据流方向:传感器 → 预处理(统计/滤波)→ 变换(FFT)→ 特征 → AI推理

3.7 避坑与建议

避坑1:浮点运算 vs 定点运算

CMSIS-DSP同时支持f32(浮点)和q31/q15(定点)版本。在Cortex-M4上,浮点运算有FPU硬件加速,速度反而比定点快。但在M0/M3上,浮点运算全靠软件模拟,一定要用定点版本。我曾经在M3上误用了f32函数,FFT慢了10倍。

避坑2:状态缓冲区不能共用

FIR和IIR滤波器都有内部状态缓冲区。如果你有两个滤波器实例,必须各自分配独立的状态缓冲区。我见过有人图省事,两个滤波器共用同一个状态数组,结果输出全乱了。

避坑3:FFT大小必须是2的幂

CMSIS-DSP的FFT只支持2的幂次长度:64、128、256、512、1024、2048、4096。如果你需要其他长度,要么补零到下一个2的幂,要么自己实现混合基FFT。我个人建议补零,简单且不影响频谱分辨率太多。

好了,这一章的内容就是这些。CMSIS-DSP是嵌入式AI推理的基石,后面的神经网络推理、传感器融合,都离不开这些基础操作。建议你把每个函数都跑一遍示例代码,看看实际效果。