四元数入门到工程实践:姿态解算为何绕不开它

发布时间:2026/9/15 8:26:15
四元数入门到工程实践:姿态解算为何绕不开它 做姿态显示和飞控调试这几年我遇到过不少把欧拉角用得痛不欲生的场景俯仰角逼近90度的时候偏航角突然像疯了一样乱跳同一个姿态用不同的旋转顺序解出来完全不一样想把两个姿态平滑过渡插值出来的中间帧却扭曲得没法看。折腾到最后几乎所有和我一样踩过坑的人最后都回到了同一个工具上——四元数。四元数这个名词听起来像数学课本里某个高深莫测的抽象概念但它在今天的3D游戏、无人机飞控、机器人运动学、VR头显姿态跟踪里几乎是无处不在的基础设施。你要做姿态解算绕不开四元数你要在三维空间里做旋转插值也绕不开四元数。这篇文章不打算堆公式吓人我想从一个实际工程师的角度把这几个问题讲透欧拉角到底哪里不够用四元数凭什么能解决姿态解算里它又是怎么工作的以及我在实际工程里踩过的那些和四元数有关的坑。这篇文章适合谁看如果你在做飞控、做IMU惯性测量、做游戏开发、做三维渲染或者单纯对三维旋转为什么这么麻烦感到好奇这篇内容应该都能帮上忙。我会尽量用大白话解释数学原理同时把必要的公式和代码都给出来保证你既能看懂也能拿去用。1. 欧拉角为什么不够用一次万向锁事故背后的自由度退化几乎所有人刚开始接触三维旋转时都是从欧拉角入手的。我也不例外。Yaw、Pitch、Roll三个角度绕固定轴转三下看起来非常直观传感器输出的也是角度显示也容易理解。但当你真正把欧拉角用在工程里各种问题就会排队来找你。1.1 万向锁的本质不是锁是坐标轴的退化先说我印象最深的一次调试。当时在一套云台控制方案里我用欧拉角表示云台姿态预期功能是让云台在俯仰接近垂直时仍然能自由旋转。结果在实测中只要俯仰角超过85度偏航输出就开始震荡再往上推一点整个姿态数据直接没法看。我一开始怀疑是陀螺仪噪声又怀疑是控制PID参数的问题排查了整整两天最后才意识到这不是硬件bug是欧拉角数学上的死穴——万向锁。网上对万向锁有很多玄乎的解释什么失去一个自由度陀螺仪框架互相重叠听得人一头雾水。我用一个更直观的方式理解它欧拉角的本质是把一个三维旋转拆成绕三个轴的依次旋转。当其中两次旋转的转轴指向同一个方向时这两个轴就合并了原本三次旋转可以表达的二维空间退化成只有两个轴能起作用于是丢失了一个方向的调节能力。换句话说不是旋转真的卡死了而是旋转的自由度变少了控制系统在这个姿态附近对某个方向的指令响应会变得极差。更糟糕的是在万向锁附近欧拉角对微小姿态变化的响应会出现剧烈跳变。本来云台只动了0.1度但欧拉角的某个分量却跳了180度。这种非线性会让所有依赖角度反馈的控制算法发疯。我当时看到的数据曲线就是这种跳变的直观体现。1.2 欧拉角的另一个坑旋转顺序和坐标系定义多到令人崩溃就算没有万向锁欧拉角也还有一个防不胜防的问题——旋转顺序。同样三个轴先绕Z再绕Y最后绕X和先绕X再绕Y最后绕Z得到的结果是截然不同的。而工程上不同领域、不同厂商、不同算法库使用的欧拉角约定又不一样有ZYX、有YXZ、有ZYZ还有内在旋转和外在旋转的区别甚至还有左手坐标系和右手坐标系的区别。我在实际项目里遇到过最典型的问题就是我按某个开源算法把欧拉角转成旋转矩阵结果姿态反了或者传感器数据手册说这是roll但用某个软件库解析出来对不上。这种问题往往不是芯片坏了也不是算法错了而是两边的欧拉角定义不一样。排查起来特别消耗时间因为出错的位置根本不直观。如果用四元数所有坐标转换都统一成四元数乘法运算绕轴顺序、定义方式都内化在四元数本身的规则里不再有先转哪个轴的歧义。这个特性在跨平台、跨算法库协作时尤其宝贵。所以我对欧拉角的态度是用来显示可以用来做底层计算和控制趁早换掉。2. 四元数不是玄学从复数到超复数的直观理解要从根上理解四元数最好先看看二维旋转里的老朋友——复数。2.1 复数的旋转启发乘以一个单位复数就是在转平面上的点复数大家高中都学过形式是 a bi其中 i² -1。二维平面上的一个点 (x, y) 可以看作复数 z x yi。把一个复数 z 乘以一个单位复数 e^{iθ} cosθ i·sinθ结果就是把这个点绕原点旋转 θ 角度。这个性质非常漂亮它说明旋转可以用乘法来表示。但问题是三维空间的旋转需要三个自由度而复数只有两个实部和虚部不够用。数学家哈密顿当年想找一种三维的超复数来扩展复数的概念找了很久都没找到最后在都柏林的一座桥上突然想通了要用四个分量才行于是就有了四元数。2.2 四个基元和那三条铁律i² j² k² ijk -1四元数的一般形式是q w xi yj zk其中 w 是实部x、y、z 是虚部系数i、j、k 是三个虚数基元。它们满足下面这套运算规则i² j² k² ijk -1然后根据 ijk -1 可以推导出一整套乘法表ij k、jk i、ki j反过来 ji -k、kj -i、ik -j。注意看这里的关键ij ≠ ji四元数乘法不满足交换律。这个性质非常重要也非常直观地反映了三维旋转的真实特征——绕X轴再绕Y轴和绕Y轴再绕X轴结果确实不一样就像先向左迈一步再向前走和先向前走再向左迈一步落在不同的位置一样。旋转顺序会改变结果这件事被四元数乘法规则天然地表达了出来。很多初学者看到四元数乘法不满足交换律就觉得这数学工具太难用了。但你反过来想三维旋转本来就不满足交换律一辆飞机先翻滚再偏航和先偏航再翻滚最终朝向当然不一样。四元数只是诚实反映了这个现实而已。2.3 共轭、模长和逆四元数运算的基础工具和复数一样四元数也有共轭、模长和逆这些概念。四元数 q w xi yj zk 的共轭是 q* w - xi - yj - zk就是把虚部全部取反。模长的定义是 |q| sqrt(w² x² y² z²)几何上可以理解为这个四元数在四维空间里到原点的距离。四元数的逆 q⁻¹ 定义为 q* / |q|²。如果我们只用单位四元数模长为1的四元数那么 q⁻¹ q*计算就会简单很多。这个性质在姿态解算里特别常用因为姿态旋转用的四元数几乎总是单位四元数。从这里就能看出单位四元数的价值它本身只用一个约束模长为1加三个自由分量刚好对应三维旋转的三个自由度。所以四元数既能完整表示旋转又不会引入冗余参数。相比之下旋转矩阵9个分量却只对应3个自由度包含大量冗余约束存储和计算都不够优雅。3. 四元数旋转的本质半角转两倍的几何直觉前面那些都是四元数的基础运算真正的核心是它怎么表达三维旋转。3.1 从轴角到四元数q cos(θ/2) (u·i v·j w·k)·sin(θ/2)三维空间里任何一个旋转都可以描述成绕某个单位向量 (u, v, w) 旋转 θ 角度这叫做轴角表示。四元数和轴角之间的转换非常简洁q cos(θ/2) (u·i v·j w·k)·sin(θ/2)注意这个半角 θ/2。为什么是半角这背后有个重要原因因为四元数旋转一个向量 v 的公式是 v q·v·q*也就是这个向量要先被 q 前半段转一次再被 q* 后半段转回来总共经历两次变换叠加效果才是 q 代表的那个旋转角度。打个比方把旋转想象成把物体抬起来再放下。如果你直接抬的角度就是目标角度那抬起来就过头了。四元数的设计是先把物体转到目标角度的一半然后用共轭操作再转剩下的一半两次合起来正好是完整角度。这就是半角的由来也是为什么用四元数做旋转比直接用轴角计算更稳定、更优雅的原因。3.2 向量旋转公式 v qvq* 的直觉吃进去、转一圈、吐出来再来说 qvq* 这个公式很多教程直接甩出来但没讲为什么非要用两边夹击的方式。假设一个向量 v 可以表示成一个实部为0的四元数纯四元数也就是 v 0 xi yj zk。然后计算 q·v·q*。可以证明结果依然是一个纯四元数而且它的向量部分恰好是 v 绕着 q 的轴转 θ 后的结果。这背后的直觉可以这样理解左边的 q·v 相当于把 v 沿着旋转轴拧了一半右边的 ·q* 相当于再拧另一半但方向相反把四元数运算过程中产生的多余分量消掉只留下旋转后的向量。这个机制的微妙之处在于如果不做两边的共轭乘法只做单边 q·v得到的向量会被扭曲并且夹带实部根本不是正常的三维向量。两边夹击才是四元数旋转的完整动作。3.3 旋转合成q_total q_later · q_earlier前面提到四元数乘法不满足交换律这在旋转合成时体现得淋漓尽致。如果物体先做旋转 q1再做旋转 q2整体的旋转四元数是q_total q2 · q1注意顺序先执行的旋转在乘法中放在右边。也就是从右往左读就是时间上的先后顺序。这一点在使用过程中极其容易弄反几乎每个写姿态解算的人都在这里栽过跟头。具体来说我在调试云台时就遇到过俯仰和偏航合成后角度完全反了后来发现就是把乘法顺序写成了 q1 · q2 而不是 q2 · q1。这个错误只会在特定姿态下暴露非常隐蔽排查起来很费劲。表达方式存储量万向锁旋转插值计算稳定性适用场景欧拉角3有困难奇异点附近不稳显示、简单控制旋转矩阵9无困难稳健坐标变换、渲染管线轴角4无困难插值不平滑参数表示四元数4无平滑需归一化姿态解算、插值、控制从表里能看出来四元数在三维旋转和姿态描述这个场景下几乎是全能的这也是它成为行业标准的原因。4. 姿态解算中的四元数从陀螺仪角速度到姿态四元数的完整链路说完了数学原理接下来看四元数在姿态解算里到底是怎么用的。这是飞控、VR头显、机器人姿态估计算法里的核心环节也是对四元数这个主题最实际的应用检验。4.1 传感器各自的特点陀螺仪短期准、加速度计长期稳、磁力计定航向姿态解算通常依赖三种传感器陀螺仪、加速度计和磁力计有时候也叫磁罗盘。陀螺仪测量的是角速度也就是物体转得多快。它的优点是对快速旋转响应非常灵敏数据更新率高短期内推算的姿态很平滑。缺点是存在零偏漂移——它不是测量绝对角度而是测量变化率一旦有微小偏差积分出来的角度就会越来越不准时间一长姿态就飘走了。加速度计测量的是比力静止时能感知重力方向从而推算出倾角横滚和俯仰。它短期噪声大受运动加速度干扰强烈但长期来看重力方向是恒定的不会漂移可以用作低频参考。磁力计测量的是地磁场方向用于确定航向角偏航。它的缺点是容易受周围铁磁性物质干扰深层标定是另一门学问。三者单独用都不完美但它们的误差特性互补。这引出了姿态融合的核心思想用陀螺仪做高频姿态更新用加速度计和磁力计做低频修正把两者结合起来。4.2 四元数微分方程q_dot 0.5 · q ⊗ ω在姿态融合里四元数负责表示当前姿态陀螺仪的角速度负责驱动姿态变化。这个驱动过程用四元数微分方程来描述q_dot 0.5 · q ⊗ ω其中 ω 是角速度四元数实部为0虚部是陀螺仪测得的三轴角速度。符号 ⊗ 表示四元数乘法。这个方程的含义是当前姿态四元数随时间的变化率等于当前姿态乘以角速度的一半。这个方程是四元数姿态解算的基石。离散化后就可以在飞控里用代码实现Δq (cos(|ω|·dt/2), (ω_x/|ω|)·sin(|ω|·dt/2), (ω_y/|ω|)·sin(|ω|·dt/2), (ω_z/|ω|)·sin(|ω|·dt/2))q_new q ⊗ Δq这段代码里第一步把角速度 ω 和采样周期 dt 换算成这一段事件内的旋转增量四元数第二步把这个增量旋转复合到当前姿态上。看到没又是半角。|ω|·dt 就是这段时间内转过的总角度 θ然后半角的 sin 和 cos 再构成旋转四元数。整个链路用到的还是前面讲的半角原理。4.3 互补滤波和Mahony姿态解算的核心思路上面那种纯积分的方法有一个明显缺点陀螺仪的漂移会随积分不断累积。所以实际工程中还要加修正环节。目前最常用、最容易落地的方案是互补滤波尤其是Mahony算法。Mahony算法的核心流程是这样的用当前四元数推算理论上的重力方向和地磁方向与加速度计和磁力计实测的方向做叉积得到误差用P比例和I积分环节把这个误差变换成修正角速度把修正角速度叠加到陀螺仪角速度上再做四元数微分方程更新。这个流程和朴素的四元数积分只在最后一步相同前面的误差修正环节是它稳定运行的关键。我贴一段我在STM32上用的精简版Mahony姿态解算核心代码去掉了很多配置项保留主逻辑void mahony_update(float gx, float gy, float gz, float ax, float ay, float az) { float norm; float vx, vy, vz; float ex, ey, ez; float halfT dt * 0.5f; // 归一化加速度计数据 norm inv_sqrt(ax*ax ay*ay az*az); ax * norm; ay * norm; az * norm; // 由当前四元数推算期望的重力方向在机体坐标系下的分量 vx 2*(q1*q3 - q0*q2); vy 2*(q0*q1 q2*q3); vz q0*q0 - q1*q1 - q2*q2 q3*q3; // 叉积求误差 ex (ay*vz - az*vy); ey (az*vx - ax*vz); ez (ax*vy - ay*vx); // 误差积分 integralFBx ex * Ki * dt; integralFBy ey * Ki * dt; integralFBz ez * Ki * dt; // 陀螺仪角速度加上修正量 gx ex * Kp integralFBx; gy ey * Kp integralFBy; gz ez * Kp integralFBz; // 四元数微分方程更新 q0 halfT * (-q1*gx - q2*gy - q3*gz); q1 halfT * ( q0*gx q2*gz - q3*gy); q2 halfT * ( q0*gy - q1*gz q3*gx); q3 halfT * ( q0*gz q1*gy - q2*gx); // 归一化四元数 norm inv_sqrt(q0*q0 q1*q1 q2*q2 q3*q3); q0 * norm; q1 * norm; q2 * norm; q3 * norm; }注意看这段代码里有几个非常关键的细节归一化加速度计数据因为后面要算叉积误差向量长度必须一致才有意义。重力方向推算用的是当前四元数公式来自四元数的旋转矩阵表达不是随便写的。叉积误差的方向完全遵循右手定则跟坐标系的定义有关一错全错。最后一步对四元数做归一化这一步不能省原因后面详细说。这套算法里四元数起到的作用就是那个姿态缓存体——它同时承载了陀螺仪的积分结果和加速度计磁力计的修正结果。没有四元数用欧拉角做这个融合万向锁问题会直接让算法在特定姿态下失控。4.4 为什么要用四元数而不是欧拉角或旋转矩阵做融合有人可能会问用旋转矩阵或欧拉角做融合有什么区别我自己都试过对比非常明显用欧拉角做融合最头疼的就是角度更新方程里出现大量三角函数而且俯仰角在90度附近会出现分母为零的奇异点整个修正方程直接爆炸。你还需要根据当前角度判断选择哪套公式代码写起来冗长不说稳定性还差。用旋转矩阵表达姿态虽然避开万向锁但旋转矩阵有9个分量每一轮融合都要维护这9个数的正交性否则矩阵会慢慢变形变成非旋转矩阵姿态数据就废了。要强制约束回旋转矩阵计算开销远高于四元数归一化。用四元数就简洁很多4个分量做一次微分方程更新、做一次归一化一条链路清清爽爽。这在MCU资源受限的飞控场景下是非常实在的优势——计算量小、无奇异点、精度够用。5. 实操中防不胜防的四个坑归一化、坐标系、乘法顺序、插值方向数学上都说四元数好用但真到了工程里还是会有一堆预想不到的问题。我在不同项目里反复踩过一些坑这里把印象最深的四个梳理出来每个都是血泪教训。5.1 不归一化的四元数会癌变一次累计漂移的定位全程四元数用来表达旋转时必须是单位四元数模长为1。但在数值计算中由于浮点误差、积分误差的存在四元数的模长会慢慢偏离1比如变成1.0000001或者0.9999998。单看每一次偏离微不足道但姿态解算是迭代运算这些误差会累积长期运行后四元数的模长可能变成1.001甚至更大。模长偏离1会带来什么后果旋转不再保持向量长度三维模型会发生缩放变形飞控输出的姿态和实际姿态的有偏估计越来越远。我遇到过一次云台连续工作半天后姿态缓慢漂移了好几度最后排查到这个原因当时在算法里漏掉了一次归一化调用加上浮点误差累积半天就飘得没法用了。解决办法很简单每一轮更新后都对四元数做一次归一化norm sqrt(q0*q0 q1*q1 q2*q2 q3*q3); q0 / norm; q1 / norm; q2 / norm; q3 / norm;注意这里要开根号嵌入式里可以用快速平方根倒数算法加速效果差不太多。5.2 坐标系约定不一致一个符号错误让整个姿态反了四元数旋转公式本身是坐标系无关的数学工具但一旦和具体传感器数据结合起来坐标系的定义就直接影响公式的符号。最常见的是机体坐标系定义差异有的飞控把x轴定义为机头前方y轴定义为右翼z轴定义为向下NED北东地坐标系有的则定义z轴向上ENU东北天坐标系。两种定义下重力方向在机体坐标系的投影正负号完全相反叉积误差的方向也随之改变。如果你照着网上某篇文章的公式抄但坐标系定义和作者不一致姿态解算出来的结果就会翻跟头。另外四元数旋转公式 v qvq* 要求 v 和 q 在同一个坐标系下定义对机体角速度、重力参考向量、地磁参考向量的坐标系必须心里有数。我个人的习惯是在工程代码开头用宏定义明确标注当前使用的坐标系名称所有涉及坐标系的常量都通过这个宏切换避免不同模块各用一套约定导致数据对不上。5.3 乘法顺序写反q_total q2·q1 还是 q1·q2前面讲的旋转合成顺序在代码里特别容易写反。我自己的经验是最低级的错误往往最隐蔽。四元数乘法不满足交换律如果顺序写反结果相当于做了完全不同的旋转表现形式可能是姿态整体偏移了一个固定角度也可能是某些轴的动作反了。为了避免这类问题我给自己定了个死规矩在代码注释里把当前四元数复合的方式写清楚比如当前姿态 机体坐标系下的新旋转增量 ⊗ 上一帧姿态还是当前姿态 上一帧姿态 ⊗ 世界坐标系下的旋转增量。这两种写法都有可能出现在不同资料里选择哪一种取决于你的旋转增量是定义在机体坐标系还是世界坐标系。实际项目中不确定时先写一个固定姿态手动对比旋转前后的向量很快就能确认顺序对不对。5.4 Slerp插值时忽略负四元数等价导致绕远路四元数在游戏动画和关键帧插值中还有一个常用场景球形插值Slerp。它的作用是让物体在两个姿态之间沿最短弧线平滑过渡不会像欧拉角插值那样出现抖动和绕路。Slerp的基本公式是slerp(q1, q2, t) (sin((1-t)θ) / sinθ) · q1 (sin(tθ) / sinθ) · q2其中 θ 是 q1 和 q2 的夹角。这里有个特别容易踩的坑q 和 -q 表示同一个旋转但它们作为四元数向量时方向相反夹角差180度。如果你直接拿 q2 做插值而 q1 和 q2 的点积小于0插值路径就会绕一个大弧而不是走最短路径。解决办法特别简单插值之前先计算 q1 和 q2 的点积如果点积为负把 q2 取反或者把 q1 取反再插值。这一行判断能显著改善插值路径的平滑度。我见过有同事在动画系统里漏掉这一步角色在两帧之间会突然旋转360度看起来非常诡异。6. 用一个小Demo验证四元数旋转为什么我建议你自己写一遍说了这么多理论我还是建议你把四元数自己实现一遍用代码验证一下。数学公式看一百遍不如自己写一遍、算一遍、错一遍最后唉原来是这么回事来得深刻。下面这个Python小Demo实现了四元数的核心运算。import numpy as np class Quaternion: def __init__(self, w, x, y, z): self.w w self.x x self.y y self.z z def norm(self): return np.sqrt(self.w**2 self.x**2 self.y**2 self.z**2) def normalize(self): n self.norm() return Quaternion(self.w/n, self.x/n, self.y/n, self.z/n) def conjugate(self): return Quaternion(self.w, -self.x, -self.y, -self.z) def __mul__(self, other): # 四元数乘法注意顺序不满足交换律 return Quaternion( self.w*other.w - self.x*other.x - self.y*other.y - self.z*other.z, self.w*other.x self.x*other.w self.y*other.z - self.z*other.y, self.w*other.y - self.x*other.z self.y*other.w self.z*other.x, self.w*other.z self.x*other.y - self.y*other.x self.z*other.w ) def rotate_vector(self, v): # 用 q * v * q* 旋转一个三维向量 v_q Quaternion(0, v[0], v[1], v[2]) result self * v_q * self.conjugate() return np.array([result.x, result.y, result.z]) def axis_angle_to_quat(axis, angle_deg): # 轴角转四元数半角在这里 axis axis / np.linalg.norm(axis) angle np.radians(angle_deg) / 2.0 return Quaternion( np.cos(angle), axis[0] * np.sin(angle), axis[1] * np.sin(angle), axis[2] * np.sin(angle) ) def quat_to_euler(q): # 从四元数转ZYX欧拉角注意万向锁边界 q q.normalize() sin_pitch 2.0 * (q.w*q.y - q.x*q.z) if abs(sin_pitch) 1.0: pitch np.sign(sin_pitch) * np.pi / 2.0 yaw 0.0 else: pitch np.arcsin(sin_pitch) yaw np.arctan2(2.0*(q.w*q.z q.x*q.y), 1.0 - 2.0*(q.y*q.y q.z*q.z)) roll np.arctan2(2.0*(q.w*q.x q.y*q.z), 1.0 - 2.0*(q.x*q.x q.y*q.y)) return np.degrees(roll), np.degrees(pitch), np.degrees(yaw) # 测试绕Z轴转90度 q axis_angle_to_quat(np.array([0, 0, 1]), 90.0) v np.array([1, 0, 0]) v_rotated q.rotate_vector(v) print(旋转后向量:, v_rotated) print(转回欧拉角(roll, pitch, yaw):, quat_to_euler(q))这段代码跑出来的结果绕Z轴转90度(1,0,0) 会变成 (接近0, 接近1, 0)转回欧拉角会是 (0, 0, 90)。如果你顺手把 axis_angle_to_quat 里的半角去掉会怎么样向量不但不会转90度还会出现坐标分量异常。亲自动手验证一遍你对四元数半角机制的理解会陡增一个层次。我还建议你做两个小实验连续执行两个不同轴的旋转交换顺序观察结果差异体会不交换律的实际意义故意用非单位四元数旋转向量看看向量长度是不是变了加深对归一化必要性的印象。这些实验加起来写代码也就二十分钟但对理解四元数有很大帮助。最后说点我自己这几年摸索出来的切身体会姿态解算和三维旋转里工具选型先行。项目一开始就统一用四元数做底层数据表示、用欧拉角只做调试显示这个习惯帮我省下了大量排查问题的时间。四元数这玩意儿第一次接触确实觉得抽象但它绝不是数学家造出来为难工程师的反而是把旋转这件事从前到后理顺的最简工具。你现在花点时间把它啃下来后面做飞控、做渲染、做机器人都能一直受益。