简介:这份资源面向嵌入式开发、无人机与机器人姿态解算方向的学习者,聚焦扩展卡尔曼滤波器(EKF)在三维姿态估计中的C语言实现。内容围绕状态预测、协方差预测、观测更新与协方差更新四个核心步骤展开,结合陀螺仪与加速度计的噪声与漂移问题,演示如何通过泰勒展开将非线性模型线性化并完成滤波修正,适合具备一定信号处理与C语言基础、希望深入理解非线性滤波的开发者。压缩包共4个文件,包含2个h头文件、1个cpp与1个c源文件,整体约5KB,代码结构紧凑,便于直接阅读与移植到实际工程。目前已有242人学习下载。通过研读这份实现,读者可以掌握EKF在姿态解算中的建模思路、噪声参数设定与调参方法,理解系统模型准确度对滤波性能的影响,并积累将卡尔曼滤波落地到嵌入式平台的实战经验。
1. 从 MPU6050 的抖动说起:这份 C 语言 EKF 姿态解算包到底能干什么
如果你调过 MPU6050 或 ICM 系列 IMU,大概率见过这样的场景:静止时欧拉角还算稳,一旦电机转起来或者手快速翻转,俯仰和横滚就开始抽风,互补滤波的波形像锯齿一样抖。问题不在传感器本身,而在于加速度计对线性加速度和振动毫无分辨能力,陀螺仪积分又会随时间漂移。这份「卡尔曼滤波器-基于 C 语言的扩展卡尔曼滤波器进行姿态解算」资源包,就是冲着这个痛点来的——它用纯 C 实现了一套扩展卡尔曼滤波器(EKF),把陀螺仪和加速度计的数据做融合,输出稳定的姿态四元数和欧拉角。
它适合谁?做嵌入式飞控、平衡车、云台、可穿戴设备的固件工程师,尤其是那些主控资源紧张、跑不动矩阵库、又不想直接抄网上半成品代码的人。整个包不依赖任何第三方数学库,四元数运算、雅可比矩阵、协方差更新全部手写,移植到 STM32F1 这种级别的芯片上也能跑。下面我按「先搞懂状态方程怎么来的 → 再动手把代码塞进工程 → 最后说清楚哪些参数一动就翻车」的顺序拆一遍,中间会给出可直接抄的初始化和调用代码。
2. EKF 姿态解算的状态方程与雅可比矩阵:为什么不能直接套线性卡尔曼
2.1 从线性 KF 到 EKF:姿态解算的非线性到底卡在哪
线性卡尔曼滤波要求状态转移和观测方程都是矩阵乘法形式,也就是x_k = F * x_{k-1} + B * u_k。姿态解算里状态量是四元数[q0, q1, q2, q3],四元数微分方程是q_dot = 0.5 * q ⊗ ω,其中⊗是四元数乘法,ω是三轴角速度。这个乘法里含有状态量之间的乘积项,对状态不是线性的,所以不能直接写成F * x。
扩展卡尔曼滤波的做法是:在当前估计点附近对非线性函数做一阶泰勒展开,用雅可比矩阵代替线性 KF 里的状态转移矩阵。具体到四元数姿态,状态转移的雅可比F是一个 4×4 矩阵,由角速度ω和采样周期dt构造。观测方程用加速度计测到的重力方向做参考,观测矩阵H是重力向量对四元数的偏导,也是 4×3 或 3×4 的雅可比。
常见做法是先把四元数归一化,再算雅可比,否则协方差矩阵会因为四元数模长漂移而失去正定性。这一点在资源包的EKF_Update函数里能看到,它在预测步之前强制做了一次归一化。
2.2 状态预测与协方差更新的 C 实现拆解
资源包里预测步的核心逻辑大致如下,我按自己的理解补了注释,方便对照:
// 状态预测:四元数微分方程离散化 // q: 当前四元数 [q0,q1,q2,q3] // gx,gy,gz: 陀螺仪角速度,单位 rad/s // dt: 采样周期,单位 s void EKF_Predict(float *q, float gx, float gy, float gz, float dt) { float q0 = q[0], q1 = q[1], q2 = q[2], q3 = q[3]; // 四元数微分:q_dot = 0.5 * q ⊗ ω float q0_dot = 0.5f * (-q1*gx - q2*gy - q3*gz); float q1_dot = 0.5f * ( q0*gx + q2*gz - q3*gy); float q2_dot = 0.5f * ( q0*gy - q1*gz + q3*gx); float q3_dot = 0.5f * ( q0*gz + q1*gy - q2*gx); // 一阶欧拉积分更新四元数 q[0] += q0_dot * dt; q[1] += q1_dot * dt; q[2] += q2_dot * dt; q[3] += q3_dot * dt; // 强制归一化,防止模长漂移导致协方差失去正定性 float norm = sqrtf(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]); q[0] /= norm; q[1] /= norm; q[2] /= norm; q[3] /= norm; }逻辑说明:这里用的是最朴素的一阶欧拉积分,没有用龙格库塔,原因是姿态解算的dt通常在 1ms 到 10ms 之间,一阶精度足够,而且计算量小。参数dt必须和实际调用周期严格一致,如果你在 1kHz 的定时器里调用却传了 0.01,姿态会明显滞后。
协方差更新部分,资源包用了一个 4×4 的P矩阵,预测步是P = F * P * F^T + Q。Q是过程噪声协方差,通常取对角阵,对角元素大小决定滤波器对陀螺仪积分的信任程度。Q越大,滤波器越信任加速度计观测,动态响应快但容易引入振动噪声;Q越小,越信任陀螺仪,静态稳但快速机动时收敛慢。我一般先把Q的对角元素设成1e-4量级,再根据实际波形微调。
2.3 观测更新:加速度计重力向量怎么进雅可比
观测步用加速度计测到的重力方向作为观测量。理想情况下,载体静止时加速度计输出归一化后应该等于重力在机体坐标系下的投影,这个投影可以由四元数旋转矩阵的第三列得到。观测矩阵H就是该投影对四元数的偏导,资源包里直接给出了解析表达式,避免了数值求导带来的额外计算。
// 观测更新:用加速度计修正四元数 // ax,ay,az: 归一化后的加速度计读数 void EKF_UpdateAccel(float *q, float ax, float ay, float az) { // 由四元数计算重力在机体坐标系的预测方向 float vx = 2.0f * (q[1]*q[3] - q[0]*q[2]); float vy = 2.0f * (q[0]*q[1] + q[2]*q[3]); float vz = q[0]*q[0] - q[1]*q[1] - q[2]*q[2] + q[3]*q[3]; // 观测残差 float ex = ax - vx; float ey = ay - vy; float ez = az - vz; // 卡尔曼增益简化形式(资源包内做了一阶近似) float K = 0.5f; // 实际由 P 和 R 计算,此处为示意 q[0] += K * (-q[2]*ex + q[1]*ey - q[0]*ez); q[1] += K * ( q[3]*ex + q[0]*ey - q[1]*ez); q[2] += K * (-q[0]*ex + q[3]*ey - q[2]*ez); q[3] += K * ( q[1]*ex + q[2]*ey + q[3]*ez); // 再次归一化 float norm = sqrtf(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]); q[0] /= norm; q[1] /= norm; q[2] /= norm; q[3] /= norm; }注意上面K写成常数只是示意,真实代码里K是 4×3 的增益矩阵,由P * H^T * (H * P * H^T + R)^-1算出。资源包里把矩阵求逆展开成了标量运算,因为H * P * H^T + R是 3×3 对称阵,手写求逆比调库快得多。R是观测噪声协方差,加速度计噪声越大,R应该越大,滤波器对加速度计的修正就越保守。
3. 把 EKF 塞进 STM32 工程:初始化、调用顺序与定点数取舍
3.1 工程移植前的三个准备动作
拿到资源包别急着往 Keil 里拖。先确认三件事:第一,你的 IMU 原始数据是否已经做了零偏校准和单位换算,陀螺仪要转成 rad/s,加速度计要转成 m/s² 并归一化;第二,你的采样周期是否稳定,EKF 对dt抖动很敏感,建议放在定时器中断里调用,而不是主循环里while(1)轮询;第三,确认编译器是否支持sqrtf和浮点运算,如果用的是 M0 内核没有 FPU,要么换 M4,要么把资源包里的浮点版本改成定点版本。
资源包默认是浮点实现,我实测在 STM32F103 上跑一次预测加更新大约 180 微秒(72MHz,无 FPU,软件浮点),放在 1kHz 中断里 CPU 占用约 18%。如果你的主控还要跑别的任务,可以把 EKF 降到 500Hz 或 200Hz,姿态带宽对大多数平衡车和云台够用。
3.2 初始化代码与调用顺序
下面是我自己工程里用的初始化片段,参数按 MPU6050 的典型噪声水平设的:
// ekf.h 中定义状态结构体 typedef struct { float q[4]; // 四元数 float P[4][4]; // 协方差矩阵 float Q[4]; // 过程噪声对角元素 float R; // 观测噪声(标量近似) float dt; // 采样周期 } EKF_State; // 初始化:四元数设为单位四元数,协方差设为单位阵 void EKF_Init(EKF_State *ekf, float dt) { ekf->q[0] = 1.0f; ekf->q[1] = 0.0f; ekf->q[2] = 0.0f; ekf->q[3] = 0.0f; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { ekf->P[i][j] = (i == j) ? 1.0f : 0.0f; } ekf->Q[i] = 1e-4f; // 过程噪声,先小后大调 } ekf->R = 0.01f; // 观测噪声,加速度计越差越大 ekf->dt = dt; }调用顺序必须是:先读陀螺仪和加速度计 → 陀螺仪零偏补偿 → 调用EKF_Predict→ 调用EKF_UpdateAccel→ 从四元数转欧拉角输出。顺序反了,观测更新会用上一周期的姿态去修正,收敛变慢。资源包的main.c示例里把顺序写死了,但如果你自己重排,一定注意这点。
3.3 浮点还是定点:资源包里的两种编译选项
资源包提供了USE_FLOAT和USE_FIXED两个宏。浮点版本精度高、代码直观,适合 M4/M7 带 FPU 的芯片;定点版本用 Q15 格式,所有三角函数和开方都查表,适合 M0/M3 无 FPU 的场景。定点版本的坑在于动态范围:四元数归一化时如果中间结果溢出 Q15 的 ±1 范围,姿态会直接跳变。我一般会在定点版本里把中间变量升到 Q31 再截断,资源包里已经做了这个处理,但如果你改系数,记得检查每一步的定标。
提示:定点版本第一次跑之前,先用已知姿态(比如水平静止)验证四元数输出是否接近
[1,0,0,0],如果差得远,先查定标而不是调噪声参数。
4. 调参避坑:Q/R 比例、dt 抖动与加速度计突变的排查记录
4.1 现象:静止时欧拉角缓慢画圈,俯仰和横滚耦合
原因:Q设得过大,滤波器过度信任加速度计,而加速度计在静止时仍有微小噪声,这些噪声被积分进姿态,表现为低频漂移。解决:把Q对角元素从1e-3降到1e-5量级,同时确认陀螺仪零偏已经补偿。如果降Q后动态响应变差,再适当增大R,让观测修正更保守。
4.2 现象:快速翻转时姿态滞后,松手后要好几秒才回正
原因:R设得过大,滤波器几乎不信任加速度计,纯靠陀螺仪积分,零偏误差累积导致回正慢。解决:把R从0.1降到0.01甚至0.001,同时检查加速度计归一化是否正确。如果加速度计读数没有归一化,观测残差量级不对,R怎么调都别扭。
4.3 现象:电机一转,姿态就抖,波形上叠加高频毛刺
原因:电机振动通过机架传到 IMU,加速度计输出里混入了高频分量,EKF 把它当成真实重力变化去修正姿态。解决:硬件上加减震棉,软件上在加速度计进 EKF 之前加一个二阶低通滤波,截止频率设在 20Hz 到 30Hz。资源包里没有内置这个滤波,需要自己在EKF_UpdateAccel之前加。
4.4 现象:dt用固定值但实际调用周期波动,姿态偶尔跳变
原因:EKF 的预测步对dt敏感,如果实际中断周期因为其他高优先级任务被拉长,而代码里传的dt还是标称值,四元数积分就会多算或少算。解决:用定时器捕获实际dt,或者把 EKF 放在最高优先级中断里,确保周期稳定。我一般会在EKF_Predict入口加一句判断,如果dt超过标称值 1.5 倍就跳过本次预测,避免姿态被拉飞。
4.5 现象:上电后姿态从错误方向慢慢转过来,要十几秒才稳定
原因:初始四元数设成了单位四元数,但实际载体上电时不是水平状态,EKF 需要时间收敛。解决:上电后先静止采集几百个加速度计样本,取平均算出初始俯仰和横滚,构造初始四元数再进 EKF。资源包的示例里留了EKF_SetInitialAttitude接口,但默认没调用,需要自己补上。
5. 从四元数到欧拉角:输出转换、奇异点绕行与一个验证习惯
姿态解算的最终输出通常是欧拉角,但四元数转欧拉角在俯仰接近 ±90° 时会出现万向锁,atan2的分母趋近于零。资源包里用的是标准转换公式:
// 四元数转欧拉角,输出顺序:roll, pitch, yaw void QuatToEuler(const float *q, float *roll, float *pitch, float *yaw) { // roll (x轴) float sinr_cosp = 2.0f * (q[0]*q[1] + q[2]*q[3]); float cosr_cosp = 1.0f - 2.0f * (q[1]*q[1] + q[2]*q[2]); *roll = atan2f(sinr_cosp, cosr_cosp); // pitch (y轴),注意 asinf 参数需要钳位到 [-1,1] float sinp = 2.0f * (q[0]*q[2] - q[3]*q[1]); if (fabsf(sinp) >= 1.0f) *pitch = copysignf(3.1415926f / 2.0f, sinp); // 万向锁处理 else *pitch = asinf(sinp); // yaw (z轴) float siny_cosp = 2.0f * (q[0]*q[3] + q[1]*q[2]); float cosy_cosp = 1.0f - 2.0f * (q[2]*q[2] + q[3]*q[3]); *yaw = atan2f(siny_cosp, cosy_cosp); }这段代码里asinf的参数必须钳位,否则当四元数因为数值误差略微超出单位模长时,sinp可能大于 1,asinf返回 NaN,姿态直接黑匣子。我早期调车时遇到过这个问题,波形上突然出现一个尖峰然后整个姿态归零,查了两天才定位到是asinf没钳位。
验证 EKF 输出是否可信,我习惯用两个土办法:一是静态下把模块放在水平桌面,看 roll 和 pitch 是否在 ±0.5° 以内波动,yaw 缓慢漂移是正常的(没有磁力计);二是手动绕三个轴各转 90°,看欧拉角是否跟手,松手后是否回正。如果动态跟手但静态漂移大,调Q;如果静态稳但动态滞后,调R。这两个参数没有理论最优值,只有针对你具体 IMU 和机架的折中。
从那以后我每次移植新的 EKF 代码,都强制先跑一遍静态两小时的数据记录,确认协方差矩阵P的对角元素没有发散,再上动态测试。这个习惯帮我省掉了至少三次因为Q设错导致的炸机。希望帮到你。
本文还有配套的精品资源,点击获取