状态估计与导航滤波:EKF与四元数姿态解算实战
发布时间:2026/10/1 2:26:15来源:尧图网络
1. 状态估计与导航滤波到底在解决什么问题如果你正在做无人机飞控、机器人定位、自动驾驶或者任何需要“知道自己在哪里、朝向哪”的项目状态估计与导航滤波就是那个绕不过去的核心环节。简单说它要回答三个问题我现在在哪、我要去哪、我怎么知道我现在在哪。前两个是规划问题第三个才是状态估计要啃的硬骨头。传感器给的数据永远是“脏”的。加速度计有噪声和零偏陀螺仪会漂移磁力计容易被电机和金属干扰GPS更新频率低还偶尔跳变。你不可能直接拿这些原始数据去控制电机或者做路径规划否则系统要么抖成筛子要么慢慢跑偏。导航滤波的作用就是把多路传感器的信息融合起来用概率的方式估计出系统真实的状态——位置、速度、姿态甚至传感器的零偏。这一章标题叫“状态估计与导航滤波”关键词里出现了EKF2、扩展卡尔曼滤波、四元数还有eigen库四元数、四元数姿态解算、利用四元数反解欧拉角这些热词。这说明实际工程中大家最关心的就是两件事一是怎么用扩展卡尔曼滤波把非线性系统搞定二是怎么用四元数把三维姿态表示清楚、算明白。我做过几个飞控和云台项目踩过的坑基本都集中在这两个点上下面就把我的理解和实操经验拆开讲。2. 核心思路拆解为什么是EKF加四元数2.1 从卡尔曼滤波到扩展卡尔曼滤波的必然选择标准卡尔曼滤波KF只适用于线性系统而且要求噪声是高斯的。但现实中的导航系统状态转移和观测模型几乎都是非线性的。比如你用一个加速度计去观测姿态观测方程里会出现重力在机体轴上的投影这本身就是三角函数不是线性的。再比如用四元数做姿态递推四元数乘法对状态是非线性的。扩展卡尔曼滤波EKF的思路很直接既然系统是非线性的那我就在当前估计点附近做一阶泰勒展开把非线性函数线性化然后套用标准卡尔曼滤波的框架。具体来说就是对状态转移函数和观测函数分别求雅可比矩阵用雅可比矩阵代替KF里的状态转移矩阵和观测矩阵。这个选择不是没有代价的。线性化会引入误差如果系统非线性很强或者初始估计离真值太远EKF可能发散。但在导航领域只要更新频率够高状态变化足够平滑EKF的线性化误差是可以接受的。PX4飞控里的EKF2就是典型代表它把IMU、磁力计、气压计、GPS等数据融合在一起输出高频的姿态和位置估计实际飞行表现相当稳。注意EKF不是唯一选择。无迹卡尔曼滤波UKF通过sigma点传播均值和协方差不需要求雅可比对强非线性系统更友好。但UKF计算量更大在嵌入式平台上跑起来吃力。所以飞控里EKF2仍然是主流UKF更多用在算力充裕的场景。2.2 四元数为什么比欧拉角和旋转矩阵更合适表示三维姿态有三种常见方式欧拉角、旋转矩阵、四元数。欧拉角最直观roll、pitch、yaw三个角人一看就懂。但它有两个致命问题万向节死锁和三角函数计算量大。万向节死锁发生在pitch等于正负90度时roll和yaw的自由度重合你没法唯一确定姿态。旋转矩阵没有死锁问题但9个参数里有6个是冗余的而且正交性会随着数值积分慢慢被破坏需要定期重新正交化。四元数用4个参数表示旋转只有一个约束条件——模长为1。它没有死锁计算效率高四元数乘法只需要16次乘法和12次加法比旋转矩阵的27次乘法少得多。更重要的是四元数的微分方程是线性的递推起来很干净。热词里提到“eigen库四元数”这个很实际。Eigen是一个C模板库做线性代数运算非常方便。它的Quaterniond类封装了四元数的基本操作包括乘法、共轭、归一化、转旋转矩阵等。我在项目里用Eigen处理四元数代码量比手写少一半而且不容易出错。2.3 姿态解算的完整链路一个典型的姿态解算链路是这样的陀螺仪测量角速度积分得到姿态变化量加速度计测量重力方向用来修正roll和pitch的漂移磁力计测量地磁方向用来修正yaw的漂移。EKF把这三路信息融合起来输出一个最优的姿态估计。这里的关键在于陀螺仪的积分会漂移但短期精度高加速度计和磁力计没有漂移但噪声大、动态响应差。EKF通过协方差矩阵自动调节两者的权重当加速度计数据可信时就多信加速度计当系统在做剧烈机动时就多信陀螺仪。这种自适应加权是EKF的核心价值。3. 核心细节解析与实操要点3.1 状态向量的选取与维度权衡状态向量选什么、选多少维直接决定了滤波器的性能和计算量。对于姿态估计最简的状态向量是四元数4维。但实际工程中你往往需要把陀螺仪的零偏也估计出来否则零偏会一直积分到姿态里导致漂移。所以常见的选择是7维状态四元数4维加陀螺仪零偏3维。如果你还要做位置和速度估计状态向量会扩展到16维甚至更多位置3维、速度3维、四元数4维、加速度计零偏3维、陀螺仪零偏3维。PX4的EKF2状态向量就是24维左右包含了大量传感器零偏和地球磁场参数。维度不是越高越好。每增加一维协方差矩阵就多一行一列计算量按平方增长。在STM32F4这种主频168MHz的芯片上24维EKF的预测和更新加起来大概要几百微秒如果IMU更新频率是1kHz留给滤波器的时间只有1毫秒余量并不充裕。所以选状态向量时要问自己这个状态我真的需要估计吗能不能用其他方式补偿实操心得陀螺仪零偏一定要估计。我早期做云台时偷懒没估计零偏结果开机十分钟后画面就慢慢歪了。后来把零偏加入状态向量漂移问题立刻消失。加速度计零偏在低成本IMU上也很明显建议一并估计。3.2 四元数微分方程的离散化四元数的运动学方程是q_dot 0.5 * q ⊗ ω其中q是四元数ω是角速度机体坐标系⊗是四元数乘法。这个方程是连续的实际实现时要离散化。最简单的一阶近似是q(k1) q(k) 0.5 * q(k) ⊗ ω * dt然后归一化。但一阶近似在角速度较大时误差明显。更好的做法是用指数映射q(k1) q(k) ⊗ exp(0.5 * ω * dt)其中exp是四元数的指数映射可以展开成exp(θ) [cos(|θ|/2), sin(|θ|/2) * θ/|θ|]这样即使角速度很大姿态递推也是精确的。我在做穿越机飞控时对比过两种方法一阶近似在角速度超过500度/秒时姿态误差能到好几度指数映射基本没有误差。3.3 雅可比矩阵的推导与计算EKF的核心是雅可比矩阵。对于姿态估计状态转移函数对四元数的雅可比是4x4矩阵对陀螺仪零偏的雅可比是4x3矩阵。观测函数对状态的雅可比取决于你用什么观测。以加速度计观测为例观测方程是a_meas R(q)^T * g b_a n_a其中R(q)是四元数对应的旋转矩阵g是重力向量b_a是加速度计零偏n_a是噪声。对四元数求导需要用到旋转矩阵对四元数的导数这个推导比较繁琐但结果是固定的。我的建议是不要手推雅可比容易出错。可以用Eigen的自动微分或者用数值差分近似。数值差分的精度虽然不如解析雅可比但在嵌入式平台上足够用而且代码简单不容易出bug。具体做法是对每个状态分量施加一个小扰动计算观测函数的变化量除以扰动值就得到雅可比的一列。注意数值差分的扰动值不能太大也不能太小。太大线性化误差大太小会被浮点精度淹没。一般取1e-6左右比较合适。我用float类型时取1e-5double类型取1e-7。3.4 观测更新的顺序与门限EKF的更新步骤里观测数据的处理顺序会影响最终结果。如果同时有加速度计和磁力计数据先更新哪个我的经验是先更新加速度计再更新磁力计。因为加速度计修正roll和pitch磁力计修正yaw先修正roll和pitch能让磁力计的观测模型更准确。另外观测数据不能无条件信任。加速度计在剧烈运动时测的不是重力而是重力和运动加速度的合力这时候如果还用它修正姿态反而会把姿态带偏。所以需要加一个门限当加速度计模值偏离重力加速度太多时降低它的权重或者直接跳过这次更新。磁力计也一样当磁场强度异常时说明受到了干扰应该跳过。这个门限的设定很讲究。太松了起不到保护作用太紧了会导致滤波器长时间不更新姿态漂移。我一般设加速度计模值在0.8g到1.2g之间才更新磁力计模值在0.5到1.5倍当地磁场强度之间才更新。具体数值要根据实际传感器和场景调。4. 实操过程与核心环节实现4.1 环境搭建与Eigen库的引入我用的是C开发环境是Ubuntu加CMake。Eigen库直接下载源码放到工程目录里在CMakeLists.txt里加一行include_directories就行不需要编译安装非常方便。include_directories(${PROJECT_SOURCE_DIR}/eigen)Eigen是纯头文件库用起来没有链接负担。四元数用Eigen::Quaterniond向量用Eigen::Vector3d矩阵用Eigen::MatrixXd。注意Eigen的Quaterniond内部存储顺序是[x, y, z, w]w是实部。这一点和有些教材里的[w, x, y, z]不一样写代码时容易搞混。我建议统一用Eigen的接口不要自己去操作内部数组。4.2 状态向量与协方差矩阵的初始化状态向量我定义为16维位置3、速度3、四元数4、加速度计零偏3、陀螺仪零偏3。协方差矩阵初始化为对角阵对角元素是各状态的初始方差。Eigen::VectorXd x(16); x.setZero(); x.segment4(6) 1, 0, 0, 0; // 四元数初始为单位四元数 Eigen::MatrixXd P Eigen::MatrixXd::Identity(16, 16); P.diagonal() 1, 1, 1, // 位置方差 1, 1, 1, // 速度方差 0.01, 0.01, 0.01, 0.01, // 四元数方差 0.1, 0.1, 0.1, // 加速度计零偏方差 0.01, 0.01, 0.01; // 陀螺仪零偏方差初始方差的选择影响收敛速度。位置和速度方差可以设大一点让滤波器快速收敛。四元数方差要小因为初始姿态通常是已知的。零偏方差根据传感器手册的零偏稳定性来设一般陀螺仪零偏稳定性在0.01度/秒左右对应方差就是0.01的平方。4.3 预测步骤的完整实现预测步骤分两部分状态递推和协方差传播。状态递推先做姿态更新。用陀螺仪测量值减去零偏估计得到角速度然后更新四元数Eigen::Vector3d omega gyro - x.segment3(13); Eigen::Quaterniond q(x(6), x(7), x(8), x(9)); Eigen::Quaterniond dq; double angle omega.norm() * dt; if (angle 1e-8) { Eigen::Vector3d axis omega.normalized(); dq Eigen::Quaterniond(Eigen::AngleAxisd(angle, axis)); } else { dq Eigen::Quaterniond::Identity(); } q q * dq; q.normalize(); x.segment4(6) q.x(), q.y(), q.z(), q.w();位置和速度更新用加速度计。注意加速度计测量的是比力要减去重力在机体轴上的投影再旋转到世界系Eigen::Vector3d a_body accel - x.segment3(10); Eigen::Vector3d g_body q.conjugate() * Eigen::Vector3d(0, 0, -9.81); Eigen::Vector3d a_world q * (a_body g_body); x.segment3(0) x.segment3(3) * dt 0.5 * a_world * dt * dt; x.segment3(3) a_world * dt;协方差传播需要状态转移矩阵F。F是16x16的矩阵大部分元素是0只有少数非零。手写F容易出错我建议用数值差分对每个状态分量加一个小扰动计算状态递推后的变化除以扰动值得到F的一列。这样虽然计算量大一点但代码简单可靠。Eigen::MatrixXd F Eigen::MatrixXd::Identity(16, 16); for (int i 0; i 16; i) { Eigen::VectorXd x_pert x; x_pert(i) 1e-6; Eigen::VectorXd x_pred_pert predictState(x_pert, gyro, accel, dt); F.col(i) (x_pred_pert - x_pred) / 1e-6; } P F * P * F.transpose() Q;Q是过程噪声协方差需要根据IMU的噪声密度来设。陀螺仪噪声密度一般在0.01度/秒/√Hz左右加速度计在100微g/√Hz左右。Q的对角元素就是噪声密度乘以采样时间的平方。4.4 观测更新与姿态修正观测更新以加速度计为例。观测方程是a_meas R(q)^T * (0, 0, -g) b_a n_a观测矩阵H是3x16的矩阵只有对四元数和加速度计零偏的偏导非零。同样用数值差分计算Eigen::Vector3d h q.conjugate() * Eigen::Vector3d(0, 0, -9.81) x.segment3(10); Eigen::MatrixXd H(3, 16); H.setZero(); for (int i 0; i 16; i) { Eigen::VectorXd x_pert x; x_pert(i) 1e-6; Eigen::Quaterniond q_pert(x_pert(6), x_pert(7), x_pert(8), x_pert(9)); Eigen::Vector3d h_pert q_pert.conjugate() * Eigen::Vector3d(0, 0, -9.81) x_pert.segment3(10); H.col(i) (h_pert - h) / 1e-6; }然后计算卡尔曼增益Eigen::Matrix3d R Eigen::Matrix3d::Identity() * accel_noise; Eigen::MatrixXd S H * P * H.transpose() R; Eigen::MatrixXd K P * H.transpose() * S.inverse();状态更新和协方差更新Eigen::Vector3d y accel - h; x K * y; P (Eigen::MatrixXd::Identity(16, 16) - K * H) * P;更新完四元数后要重新归一化否则模长会慢慢偏离1。4.5 利用四元数反解欧拉角滤波器输出的是四元数但用户界面或者控制逻辑往往需要欧拉角。从四元数反解欧拉角的公式是Eigen::Quaterniond q(x(6), x(7), x(8), x(9)); Eigen::Vector3d euler q.toRotationMatrix().eulerAngles(2, 1, 0); // euler[0]是yaweuler[1]是pitcheuler[2]是roll注意Eigen的eulerAngles返回的顺序和参数有关(2,1,0)表示先绕Z轴转yaw再绕Y轴转pitch最后绕X轴转roll。这个顺序对应的是ZYX欧拉角也是航空航天领域最常用的顺序。但eulerAngles有个坑当pitch接近正负90度时返回值会跳变。因为欧拉角本身在万向节死锁点附近不连续。如果你需要显示姿态建议在pitch接近90度时做特殊处理或者干脆显示四元数。如果只是做控制用旋转矩阵或者四元数直接算不要转欧拉角。实操心得我在做云台控制时一开始用欧拉角做PID结果在pitch接近90度时云台会突然翻转。后来改成用四元数误差做控制问题解决。四元数误差的定义是q_err q_target * q_current.conjugate()然后取q_err的向量部分作为误差输入这样在整个姿态空间都是连续的。5. 常见问题与排查技巧实录5.1 滤波器发散从现象到根因的排查路径滤波器发散是最常见也最头疼的问题。现象是姿态估计越来越偏最后完全失控。排查时按以下顺序检查第一检查协方差矩阵是否变成非正定。EKF的协方差更新公式在数值上不稳定长时间运行后P可能失去正定性导致卡尔曼增益计算异常。解决办法是用Joseph形式更新协方差P (I - K * H) * P * (I - K * H).transpose() K * R * K.transpose();这个形式在数值上更稳定能保证P始终正定。第二检查观测数据的有效性。如果加速度计或磁力计数据异常但滤波器仍然用它更新就会把错误信息融入状态。加门限判断异常时跳过更新。第三检查过程噪声Q和观测噪声R的比例。Q太小会导致滤波器过度信任模型对观测不敏感Q太大会导致滤波器过度信任观测输出抖动。一般Q和R的比例决定了滤波器的带宽Q/R越大带宽越高响应越快但噪声越大。5.2 姿态漂移零偏估计与磁干扰的博弈姿态漂移通常表现为yaw慢慢转动或者roll和pitch在静止时缓慢变化。yaw漂移的主要原因是陀螺仪零偏没有被正确估计或者磁力计受到干扰。如果磁力计受到干扰滤波器会把干扰当成真实磁场导致yaw估计错误。解决办法是加磁力计门限当磁场模值偏离当地磁场强度太多时跳过磁力计更新。当地磁场强度可以查表得到也可以开机时自动校准。如果陀螺仪零偏估计收敛太慢可以增大零偏对应的过程噪声Q让滤波器更快地调整零偏估计。但Q太大会导致零偏估计抖动反而引入噪声。我一般把零偏的Q设为陀螺仪噪声密度的1/10左右。5.3 四元数归一化小问题引发大故障四元数归一化看起来是个小问题但不做归一化会导致严重后果。四元数的模长会随着数值积分慢慢偏离1如果模长变成1.1旋转矩阵就会包含缩放因子姿态估计完全错误。归一化的时机很重要。每次状态更新后都要归一化包括预测步骤和观测更新步骤。归一化就是除以模长q.normalize();Eigen的normalize()函数会原地归一化。注意如果四元数模长接近0归一化会出问题但正常情况下不会发生。5.4 常见问题速查表问题现象可能原因排查方法解决方案姿态缓慢漂移陀螺仪零偏未估计静止时观察零偏估计是否收敛增大零偏Q检查零偏是否在状态向量中yaw突然跳变磁力计受干扰检查磁场模值是否异常加磁力计门限异常时跳过更新滤波器发散协方差矩阵非正定打印P的特征值用Joseph形式更新协方差姿态抖动大Q/R比例过大观察卡尔曼增益大小减小Q或增大Rpitch接近90度时异常欧拉角万向节死锁检查是否用欧拉角做控制改用四元数误差做控制加速度计修正导致姿态偏移剧烈运动时加速度计测的不是重力检查加速度计模值加加速度计门限模值异常时跳过更新5.5 独家避坑技巧第一个技巧开机时先做静止校准。让设备静止几秒钟用这段时间的陀螺仪数据估计零偏用加速度计数据估计初始roll和pitch用磁力计数据估计初始yaw。这样滤波器从一个较好的初始状态开始收敛更快。第二个技巧用双精度浮点。嵌入式平台上float和double的性能差距没有想象中大但double能显著减少数值误差。我在STM32F4上对比过double版本的EKF只比float慢20%左右但稳定性好很多。第三个技巧定期检查协方差矩阵的对角元素。如果某个状态的方差变得非常大说明这个状态不可观测或者观测数据有问题。比如磁力计长时间不更新yaw的方差会一直增大这时候应该提醒用户或者自动切换到尾磁力计模式。第四个技巧用Eigen的Map功能直接映射传感器数据避免拷贝。Eigen::Map Eigen::Vector3d 可以直接把float数组映射成Vector3d省去手动赋值。Eigen::MapEigen::Vector3d gyro(gyro_data);这个技巧在处理大量传感器数据时能省不少CPU时间。6. 从理论到落地的几个关键决策6.1 更新频率与计算负载的平衡IMU的更新频率通常是1kHz但EKF不需要每毫秒都跑一次。我的做法是预测步骤每毫秒跑一次观测更新每10毫秒跑一次。这样既保证了姿态的平滑性又降低了计算负载。加速度计和磁力计的更新频率本来就低10毫秒一次足够了。如果CPU负载还是太高可以降低预测频率到500Hz但姿态控制的频率不能低于200Hz否则会有明显的延迟感。6.2 传感器时间同步的处理多传感器融合的前提是时间对齐。如果加速度计和陀螺仪的数据时间戳差了几毫秒融合结果就会有偏差。我的做法是在驱动层给每个数据打上时间戳在EKF里根据时间戳做插值或者外推。如果传感器支持硬件同步尽量用硬件同步软件同步的精度有限。6.3 参数整定的经验法则EKF的参数主要是Q和R。整定时先固定R调Q。Q越大滤波器越信任观测响应越快但噪声越大。Q越小滤波器越信任模型输出越平滑但响应越慢。我的经验是先让滤波器在静止时输出稳定然后逐渐增大Q直到动态响应满足要求。R的设定相对固定根据传感器手册的噪声密度来设。如果手册没给可以采集静止数据计算方差作为R的近似值。6.4 代码结构与模块化EKF的代码最好分成几个模块状态定义、预测、观测更新、参数配置。这样调试时能单独测试每个模块。我习惯把状态向量和协方差矩阵封装成一个类预测和更新作为成员函数。参数用结构体传入方便调整。struct EkfParams { double gyro_noise; double accel_noise; double mag_noise; double gyro_bias_noise; double accel_bias_noise; }; class Ekf { public: void predict(const Eigen::Vector3d gyro, const Eigen::Vector3d accel, double dt); void updateAccel(const Eigen::Vector3d accel); void updateMag(const Eigen::Vector3d mag); Eigen::Quaterniond getQuaternion() const; private: Eigen::VectorXd x_; Eigen::MatrixXd P_; EkfParams params_; };这种结构清晰也方便单元测试。我一般会写一个简单的测试程序喂入模拟数据检查滤波器输出是否符合预期。6.5 实际飞行测试的注意事项实验室里跑通不代表实际飞行没问题。实际飞行时振动大、磁场干扰强、GPS信号时好时坏。我的经验是先在实验室做静态测试和转台测试确认姿态估计准确然后做系留飞行检查振动对滤波器的影响最后才做自由飞行。系留飞行时重点观察姿态估计是否跟得上遥控器输入yaw是否漂移高度估计是否稳定。如果发现问题先检查传感器数据质量再调滤波器参数。很多时候问题不在滤波器本身而在传感器数据。振动是EKF的大敌。振动会让加速度计数据充满高频噪声如果滤波器带宽太高这些噪声会进入姿态估计。解决办法是在IMU数据进入EKF之前先做低通滤波截止频率设在50Hz左右。但低通滤波会引入相位延迟所以截止频率不能太低否则影响动态响应。我个人在实际操作中的体会是状态估计与导航滤波这件事理论推导只是入场券真正决定成败的是对传感器特性的理解和对参数的精细调整。同样一套EKF代码参数调得好能飞得很稳参数调不好就会炸机。多飞、多记录、多分析日志比看多少篇论文都管用。
网站建设高端定制企业官网