做飞控、做机器人、做自动驾驶的人,大概率在某个文档里见过这么一行定义:状态变量 [x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r]。我第一次看到时觉得这就是往数组里塞了几个量而已,真到调飞机、调小车的时候才发现,这一行简短的声明背后,是一整套运动模型、坐标系约定、估计器设计和代码实现逻辑的浓缩。位置、速度、姿态角、角速度,12个量两两一组,正好把刚体在三维空间里的"平移运动"和"旋转运动"描述得清清楚楚。这篇文章我就围绕这个状态向量展开,聊聊它为什么长这样、背后牵扯到哪些关键概念、代码里该怎么更新它、估计器怎么把它算出来,以及在实机调试时我踩过哪些坑。不管你是刚入门的学生,还是已经在做飞控、导航或机器人项目的开发者,这篇文章都值得花十分钟读一遍。它不会帮你直接写出一个完整的飞控,但能让你拿到任何一套系统时,第一时间看懂它脑袋里的"状态"到底是什么。1. 这套状态变量到底在说什么:12维向量物理含义拆解1.1 前半段是位置速度,后半段是姿态角速率先把这12个状态量分个组。x, y, z是物体在三维空间中的位置,vx, vy, vz是沿三个轴的速度分量,这六个量描述的是"质心在哪里、往哪个方向跑得多快",在物理上属于刚体运动的第一部分:平移运动。后六个量roll, pitch, yaw是欧拉角形式的姿态,p, q, r是机体坐标系下的三轴角速度。欧拉角告诉你飞机当前是"什么姿势"——是歪了、抬头了还是转了个方向,而p, q, r告诉你这个姿势正在以多快的速度变化。这两组量合在一起,描述刚体运动的第二部分:旋转运动。所以[x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r]的本质就是一个12维的刚体运动状态。之所以是12,是因为三维空间里位置有3个自由度、速度有3个自由度、姿态有3个自由度、角速度有3个自由度,4乘3恰好等于12。这是描述一个刚体完整运动状态的最小编制,一个维度都没浪费。1.2 为什么位置速度是"地面系",而角速度是"机体系"这个状态向量最容易让人懵的地方在于:x, y, z和vx, vy, vz通常是在地面导航坐标系(或者叫惯性系、世界系)下定义的,而roll, pitch, yaw描述的是机体系相对地面系的角度关系,最后的p, q, r却是在机体坐标系下测量的。也就是说,这一个向量里,前6个量是地面系视角,中间3个量是两坐标系间的角度关系,最后3个量又是机体系视角。为什么最后3个角速度要放在机体系?因为陀螺仪固定在机身上,它测到的角速度天然就是机体坐标系下的。如果你想知道这个角速度在地面系下的投影,还得再用姿态矩阵做一个坐标变换,工程上完全没必要。反过来,位置和速度如果放在机体系下也挺膈应——飞机转个身,位置坐标全变了,导航和控制用起来很不方便。所以这个看似混合的排列,其实是工程妥协下的最优解。我在实际项目中见过不少大一新生写代码,把这个向量里的角速度拿来当欧拉角的导数用,结果输出曲线完全对不上。这里强调一下:p, q, r是机体角速度,roll_dot, pitch_dot, yaw_dot是欧拉角变化率,这俩之间隔着一个坐标变换矩阵,不能直接划等号。后面第三章我会把这个变换写出来。1.3 命名习惯与顺序陷阱状态向量的排列顺序在不同代码库里并不总是一样,有的系统喜欢[x, y, z, roll, pitch, yaw, vx, vy, vz, p, q, r],有的把四元数放在中间,还有的把速度排在地面系前两个轴后跟角速度。标题里这个是[x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r],看起来是"平动在前,转动在后"的逻辑,理解起来挺顺。但我要提醒所有做集成的人:拿到一段别人写的状态向量定义,先别急着抄,一定把每一项的单位、坐标参考、正负方向搞清楚。我就遇到过某个项目里z轴居然朝上,另一套代码里yaw反而是绕 z 轴逆时针为正,两边对接起来,控制器输出直接反了,飞机差点拍地上。这种坑不是环境造成的,是状态向量定义不统一造成的。所以每读一套状态量,第一件事就是找它的坐标约定文档。2. 坐标系与姿态约定:先把这个搞定,后边全是顺水推舟2.1 NED坐标系与ENU坐标系的区分讨论12维状态向量时,坐标系选择是第一个分岔路口。无人机、飞行器行业最常用的是 NED(北-东-地),x指北,y指东,z指向下方。因为它符合航空领域的直觉——高度越低z值越小,下面就是地面。而机器人和视觉SLAM圈更爱用 ENU(东-北-天),z轴朝上,高度越高z值越大,和正数学坐标系一致。如果你在两个约定之间来回切换,麻烦就来了。NED 和 ENU 之间差了两次翻转:绕水平轴转 180 度再绕竖直轴转 90 度,而且旋转顺序不同,姿态角直接搬过去是错的。我建议大家在选状态向量之前,先把主机领域定下来:做飞机用 NED,做地面机器人优先用 ENU,做水下航行器通常也跟 NED 走。定了坐标系之后,后面的方向余弦矩阵、姿态更新公式才谈得上。因为 NED 的z轴朝下,很多从视觉处理出身的朋友一上手就懵,觉得高度成负的了。我自己习惯的做法是在所有对外接口和日志里保留"海拔高度"这个语义字段,它等于-z(NED下),但内部计算一律用z本身。这样既保持代码与坐标系的严格一致,又不至于跟用户解释半天"为什么高度是个负数"。2.2 机体坐标系的正方向定义说完了地面系,再看机体系。无人机、固定翼、直升机行业一般统一用"前-右-下"约定:机头方向为机体x轴,右翼方向为机体y轴,机腹方向为机体z轴。这个坐标系和 NED 坐标系在姿态角为零时正好是完全对齐的。在这个约定下,姿态角的正方向就有了明确直觉:roll绕机体x轴,右侧机翼下沉为正,也就是说飞机向右倾斜,roll是正的。pitch绕机体y轴,机头向上抬为正。yaw绕机体z轴,从 NED 上方俯视,机头从北方向东方转向为正,这个定义等同于航向角的顺时针为正。这里面yaw和数学里那个逆时针正方向不一样,是历史上遗留的"航向角"习惯,很多Matlab工具箱和机器人库的欧拉角默认逆时针为正,直接用就很容易把偏航方向弄反。我的经验是写代码时不要依赖任何隐式默认,明确定义正方向并留一个自检注释在那儿,两个月后回来看还能看懂。2.3 欧拉角的适用边界与万向锁问题roll, pitch, yaw作为状态变量有一个天然的软肋:当pitch接近正负90度时,姿态表达退化,俗称"万向锁"问题。这个问题我下面还会详细说,先记住一个大原则:欧拉角适合人类观察和任务层描述,不适合作为控制器和估算器的内部状态长期积分。很多飞控代码里,真正的姿态状态用的是四元数,而传给外层的roll, pitch, yaw只是四元数换算出来的观察值。标题里这个状态向量写出了欧拉角,并不代表代码里必须把欧拉角当积分状态。理解这层"接口约定"和"内部实现"的区别,是读懂状态向量工程代码的关键。如果说12维状态向量是骨架,那么姿态表示方式就是骨架最关键的关节,这里选错型号,整个运动链都会卡壳。3. 状态更新实操:从连续方程到离散代码3.1 位置与速度的更新:比力、重力项和 dt先把平移部分的连续方程写出来。假设a_x, a_y, a_z是地面系下去掉重力后的加速度,那位置和速度的微分关系非常直白:x_dot = vx y_dot = vy z_dot = vz vx_dot = ax vy_dot = ay vz_dot = az难的地方在于ax, ay, az怎么来。IMU里的加速度计测的并不是平移加速度,而是"比力"——物体受到的除重力以外的作用力与质量的比值。也就是在地面系下,真实加速度等于比力加速度向量加上重力加速度向量。在 NED 坐标系下,重力加速度向量是[0, 0, +g](因为z轴朝下)。所以由机体系测到的比力fb要先旋转到地面系,再加一个重力项:a_ned = R_body_to_ned * fb + [0, 0, g]这个式子很多刚写飞控代码的人会算错。悬停状态,飞行器加速度计读到的比力大概是一个 g 指向上方,经过旋转之后地球框下的真实加速度正好是零。如果你忘了加重力项,积分出来的垂直速度会一路漂,这就是很多自研飞控悬停时高度发散的原因之一。反过来,如果重力项符号给反了,那问题更明显——飞机一离地,垂直速度就开始放飞自我。离散化的时候,位置速度更新采用最简单的一阶欧拉法就够了,前提是积分频率足够高。我的习惯是控制在 500Hz 到 1kHz 之间,每个循环固定dt = 0.002或0.001。也有用二阶中点法或 RK4 的,但对位置和速度这种平滑量,一阶欧拉在高频下已经足够稳,没必要让 MCU 白烧算力。3.2 姿态运动学方程:从机体系角速度到欧拉角速率下面就是标题里p, q, r最关键的部分:由机体角速度推算欧拉角的变化率。如果姿态用 ZYX 内旋欧拉角(工程里最常见的约定),那么从p, q, r到roll_dot, pitch_dot, yaw_dot的变换是:roll_dot = p + q * sin(roll) * tan(pitch) + r * cos(roll) * tan(pitch) pitch_dot = q * cos(roll) - r * sin(roll) yaw_dot = (q * sin(roll) + r * cos(roll)) / cos(pitch)这个矩阵是很多姿态解算代码的核心,也是新手最容易抄错的公式。注意它的前提是 ZYX 内旋顺序,如果你用了别的旋转顺序,这个公式的形状会变。而且式子末尾那个1 / cos(pitch)在pitch为 ±90° 时会爆炸,这正是欧拉角做内部状态的硬伤。顺带说一句,如果只是观察roll, pitch, yaw的缓慢变化,可以直接用小角度近似roll_dot ≈ p之类,但一旦飞行器做大机动,近似误差大得离谱,机体会快速振荡甚至发散。凡是需要全姿态控制的场合,还是老老实实把完整的变换矩阵用上。3.3 为什么工程代码里姿态状态更多用四元数尽管状态向量名义上写的是roll, pitch, yaw,我建议做状态更新和滤波的时候,把姿态用一个四元数q = [qw, qx, qy, qz]来维护。四元数的更新公式比欧拉角简洁得多:q_dot = 0.5 * q ⊗ [0, p, q, r]这里的 ⊗ 是四元数乘法。角速度向量[p, q, r]是机体系下的,直接参与运算,不需要任何三角函数,也没有万向锁问题。每次写完四元数更新再归一化,然后想输出欧拉角的时候用一个转换函数算出来即可。13个状态(位置3 + 速度3 + 四元数4 + 角速度3)在内部流转,12个状态的接口对外展示,这是非常成熟的做法。为什么很多资深工程师都这么干?因为只要你的飞行器敢做大角度俯冲或滚筒机动,pitch就会扫过 ±90°,欧拉角的方程当场爆炸,轻则姿态输出乱跳,重则控制环跟着乱掉。而四元数永远不会遇到奇点。这是经验之谈,不是理论吹嘘,我在仿真里做筋斗机动时,用欧拉角积分到 89.9° 附近的数值误差已经大到不可接受,换四元数后一路滚过来毫发无伤。3.4 一个小例子:一段12维状态量的离散更新代码我写一段 Python 示例,展示用标题里的 12 维状态量做一次离散更新的完整流程。这里姿态更新故意用四元数做中间量,最后换算回欧拉角,正好演示上面说的工程做法。import numpy as np def state_update_12(states, fb, dt): """ states: 12维数组 [x, y, z, vx, vy, vz, roll, pitch, yaw, p, q, r] fb: 机体系三轴比力,单位 m/s^2 dt: 积分步长,单位 s """ x, y, z, vx, vy, vz = states[0:6] roll, pitch, yaw = states[6:9] p, q, r = states[9:12] # 1. 就地更新位置(一阶欧拉) x += vx * dt y += vy * dt z += vz * dt # 2. 机体系比力转到地面系,再补重力项 # 这里是简化的 ZYX 旋转,真实代码建议用四元数代替 cR, sR = np.cos(roll), np.sin(roll) cP, sP = np.cos(pitch), np.sin(pitch) cY, sY = np.cos(yaw), np.sin(yaw) Rbn = np.array([ [cP*cY, sR*sP*cY - cR*sY, cR*sP*cY + sR*sY], [cP*sY, sR*sP*sY + cR*cY, cR*sP*sY - sR*cY], [-sP, sR*cP, cR*cP], ]) a_ned = Rbn @ fb a_ned[2] += 9.80665 # NED 下 z 轴向下,重力项为正 vx += a_ned[0] * dt vy += a_ned[1] * dt vz += a_ned[2] * dt # 3. 姿态更新:临时转成四元数,积完再换回来 yaw_half = yaw * 0.5 pitch_half = pitch * 0.5 roll_half = roll * 0.5 qw = np.cos(roll_half)*np.cos(pitch_half)*np.cos(yaw_half) \ + np.sin(roll_half)*np.sin(pitch_half)*np.sin(yaw_half) qx = np.sin(roll_half)*np.cos(pitch_half)*np.cos(yaw_half) \ - np.cos(roll_half)*np.sin(pitch_half)*np.sin(yaw_half) qy = np.cos(roll_half)*np.sin(pitch_half)*np.cos(yaw_half) \ + np.sin(roll_half)*np.cos(pitch_half)*np.sin(yaw_half) qz = np.cos(roll_half)*np.cos(pitch_half)*np.sin(yaw_half) \ - np.sin(roll_half)*np.sin(pitch_half)*np.cos(yaw_half) # 四元数微分 dq = 0.5 * np.array([ -qx*p - qy*q - qz*r, qw*p - qz*q + qy*r, qz*p + qw*q - qx*r, -qy*p + qx*q + qw*r, ]) qw += dq[0]*dt qx += dq[1]*dt qy += dq[2]*dt qz += dq[3]*dt # 归一化 norm = np.sqrt(qw*qw + qx*qx + qy*qy + qz*qz) qw, qx, qy, qz = qw/norm, qx/norm, qy/norm, qz/norm # 转回欧拉角 new_roll = np.arctan2(2*(qw*qx + qy*qz), 1 - 2*(qx*qx + qy*qy)) new_pitch = np.arcsin(np.clip(2*(qw*qy - qz*qx), -1.0, 1.0)) new_yaw = np.arctan2(2*(qw*qz + qx*qy), 1 - 2*(qy*qy + qz*qz)) return np.array([x, y, z, vx, vy, vz, new_roll, new_pitch, new_yaw, p, q, r])这段代码够你在桌面环境里快速复现一个12维刚体运动的状态预测。真实飞控里还要处理陀螺零偏、时间戳抖动、非均匀dt等问题,但骨架就是这个样子。注意我最后没有修改p, q, r,因为它们是测量值,可以直接取自陀螺仪,在滤波里往往把它们当输入而非积分状态。3.5 时间步长与数值稳定性离散更新里dt的选择直接关系稳定性。位置速度更新对dt没那么敏感,但姿态更新的dt如果太大,四元数积分误差会明显累积,飞一会儿就发飘。无人机常用的陀螺输出频率是 1kHz,这时dt=0.001s,对姿态更新一点问题没有。如果陀螺只有 200Hz,dt=0.005s,那就要考虑用二阶积分或者对陀螺输出做上采样。还有一个经常被忽略的细节:实时系统的dt不是恒定不变的,调度抖动会导致每次循环间隔有微小差异。我在工程里从来不用固定dt,每次循环都用高精度时钟量出实际间隔再参与积分。你的状态量更新公式写得再准,dt用错了依然是白搭。4. 状态怎么来:传感器融合与估计器选型4.1 传感器测量的不完整性与噪声设备上不会直接印着"当前状态量"四个大字。GPS 给位置,但频率低、有延迟;加速度计给比力,但噪声大,不能直接积分;陀螺给角速度,平滑,但是会漂移;磁力计给航向,可受磁场干扰严重。单一传感器根本无法提供可靠的 12 维状态,所以才需要状态估计器把多传感器信息融合起来,估算出最接近真值的状态向量。我在刚学飞控时犯过一个错误:把加速度计两次积分当速度,三次积分当位置,陀螺角速度直接积分当姿态。仿真里跑得挺好,一上真机三秒内姿态就漂得找不到北。这不怪传感器,是我自己忽略了噪声和偏置的累积效应。真实世界的状态量,几乎永远是从一个可观测系统里"估计"出来的,不是"测量"出来的。4.2 互补滤波与扩展卡尔曼滤波怎么选针对姿态,最轻量级的方法是互补滤波:把陀螺积分得到的姿态作为高速可信信号,把加速度计和磁力计推导的姿态作为低速修正信号,两者按频率特性互补融合。优点是计算量小、调参简单,STM32 上几行代码就能跑,适合做四轴玩具或入门项目。缺点是没有显式的协方差,也没有办法融合 GPS 位置得到完整位置速度估计。一旦你需要完整的 12 维状态向量,这事儿就交给扩展卡尔曼滤波(EKF)了。EKF 把标题里的 12 维向量直接作为系统状态,预测步使用运动学方程,更新步用传感器模型把预测状态映射到量测空间再校正。GPS、气压计、加速度计、磁力计甚至光流传感器都可以作为观测源。相比互补滤波,EKF 的工程实现复杂得多,但鲁棒性和可扩展性完全不在一个量级。工业上还有一种很流行的思路是:姿态用四元数 EKF 或互补滤波算好,然后再用一个单独的 EKF 估计位置和速度。这种"分而治之"的组合能用更少代码量达到工程可用的精度。但当你看到某套系统里有完整的 12 维状态向量时,大概率背后是一个统一的 EKF,而不是分块滤波。4.3 初始化和静止校准流程状态估计器启动时,不能胡乱猜初始状态。我的标准流程是:把设备放在水平静止平面上,采集一批加速度计均值,利用roll = atan2(ay, az)、pitch = atan2(-ax, sqrt(ay^2 + az^2))得到初始姿态(NED约定下)。用磁力计在水平姿态下测磁向量,计算初始yaw,同时最好对磁力计硬磁干扰做一次椭圆拟合校准。位置初始值可以从 GPS 获取,也可以先置零再放开。速度初始置零,同时在一次长时间的静止采集里估计出陀螺和加速度计的零偏,这个零偏之后会作为 EKF 的附加状态持续修正。这套流程听着简单,工程里坑很多。最常见的是静止校准没做完就起飞了,零偏偏了不少,状态估计器几十秒内就开始漂。还有一些系统把初始yaw直接设成 0,结果 GPS 往东北方向拉,控制环直接和真实航向打架。凡是多旋翼、固定翼飞控,上电后我都建议强制触发一次几秒钟的静止校准,用日志里的"calibration done"标志来拦截起飞指令。4.4 从12维状态扩展到更多维度的趋势标题里的12维状态向量只是"核心",现代飞控的估计器状态往往不止 12 维。我见过的某开源飞控内部有 24 维状态:除位置、速度、四元数、角速度外,还有陀螺零偏、加速度计零偏、气压计高度偏置、以及地速里的风场分量。风速为什么也进状态?因为固定翼在空中飞行,地速和空速之间的差异就是风速,把它估计出来对导航和路径规划都很关键。所以不要看到 12 维就觉得完整了。数字大小只是系统设计的选择,更大状态向量带来更强的可观测性和自校准能力,但是也带来更大的计算量、更难调的噪声矩阵和更差的收敛性。如果项目只是室内无人机,12 到 16 维足够;做高可靠性长航时固定翼,至少 21 维起步。理解状态的"最小集"之后,扩展只是往下午加维度的事。5. 常见故障排查与状态量调试速查表5.1 悬停漂移、角度跳变、数值NaN的典型原因真机上最常遇到的“状态量异常”就那几种,我用一张表格总结出来,每个都对应到我的实操排查经验。症状可能原因排查方向悬停时 roll/pitch 缓慢漂移陀螺零偏未校准或估计器没收敛静止状态下看零偏估计值,重新校准后再起飞高度缓慢下降并越来越快重力项符号/数值错误检查 NED 下重力项是否+g,是否用了-gyaw 缓慢打转磁力计受电机磁场干扰/磁偏角未补偿上电校准磁力计,换机臂布局或加磁屏蔽roll/pitch 抖动很大加速度计噪声大,估计器增益太高检查机架共振频率,在估计器里降低量测噪声权重大机动后角度瞬间翻转180度欧拉角做内部状态遇到万向锁内部姿态改用四元数积分,欧拉角只做输出数值出现 NaN除零/四元数未归一化/状态发散打印 NaN 出现前一帧的状态,找第一个发散量记录的姿态角一直在 ±180 来回跳输出没有做wrap_pi处理在日志和接口层统一用atan2形式的归一化角度其中 "数值 NaN" 是最让人头大的。我在一次实验里发现姿态估计器输出变成 NaN,后来逐行排查发现是pitch短时间冲过了 90 度,欧拉角微分方程里的1/cos(pitch)爆炸了。从那个项目之后,我的内部代码全面切到了四元数,就再没遇到过这个毛病。5.2 角度归一化:yaw从179度到-179度不是“转了一整圈”做上层控制的朋友很爱在状态量的角度上栽跟头。yaw习惯上被表示在-180度~180度之间,当飞机从179度继续右转1度,输出会跳成 -179度。如果你直接用error = target_yaw - current_yaw去算偏差,会得到一个 -358度的错误误差,控制器以为要逆时针猛打,于是姿态瞬间振荡。正确做法是写一个角度归一化函数:把角度差映射回-π~π。比如:def wrap_pi(a): while a > np.pi: a -= 2.0 * np.pi while a < -np.pi: a += 2.0 * np.pi return a error = wrap_pi(target_yaw - current_yaw)这样179度到-179度实际只差2度,控制器输出才正确。这个小函数价值极高,我几乎所有涉及yaw的工程代码里都会挂着一个wrap_pi。千万不要小看这个细节,很多自研飞控“原地转圈”的bug,追根到底就是没做角度包裹。5.3 日志里的时间戳和单位问题调姿态估计器时,最忌讳的是日志里只记录状态量不记录时间戳。没有时间戳,离线回放时你不知道每个数据点的真实时刻,无法对齐 IMU 数据和状态量的因果关系。我把所有状态量日志都打上微秒/毫秒时间戳,并且每帧都会记录循环间隔dt,回放时才能正确复现当时的数据流。还有一个单位问题容易在对接外部库时翻车:都是角速度,有的库用rad/s,有的库用deg/s;都是加速度,有的用g,有的用m/s^2。我一般严格规定内部状态统一用 SI 单位制:米、米每秒、弧度、弧度每秒。外部输入输出层再做单位转换。如果直接把一个标着 deg/s 的数组塞进状态向量,你在滤出来的姿态里会看到各种诡异的高频振荡,排查半天才发现是单位错了。5.4 调试工具与观察技巧排查状态向量异常,我有一套固定流程:先把状态量和传感器原始量画在同一张图上,看是传感器坏了还是估计器坏了。比如 roll 角度在漂移,同时看陀螺原始角速度是否有零偏趋势,如果陀螺原始数据有常值漂移,那是零偏校准问题;如果陀螺数据很干净但估计器输出还在漂,再看加速度计是否受振动影响,振动大到一定程度,EKF 会把重力方向估计错,姿态自然跟着歪。画图工具我用过不少,但最常用的还是把日志导成 CSV 后用 Python 的 matplotlib 快速画线。角度、角度速率、估计协方差全部叠图,一眼就能看出问题。高阶一点的做法是在仿真环境里注入已知传感器故障,观察估计器状态是否会按预期发散,用这种方式验证自己的排查逻辑对不对。反正记住一句话:状态向量是系统的“内观”,任何异常最终都会在它上面留下痕迹。6. 不同机型的变体与扩展思路6.1 多旋翼、固定翼、无人车各自的状态侧重标题里的12维状态向量更像一个通用底座,不同平台会用不同的加减法来适配自己的运动特性。多旋翼是最贴近这套向量的。悬停和低速飞行下,机体受力相对简单,位置速度姿态角速率这12个量直接进入控制器就能飞得很好。固定翼就要额外关心空速、攻角、侧滑角这些与气动力紧密相关的量——同样的地速,逆风和顺风条件下飞行状态完全不同。无人车则简单很多,roll和pitch通常被约束在极小范围,很多运动规划里干脆把它们忽略,只保留二维位置、二维速度和偏航角,状态向量降成 5~7 维,跑起来一样顺畅。水下机器人就更特殊了,浮力会影响垂向运动模型,而且没有 GPS,只能靠 DVL、惯导和压力计融合。它的状态向量可能除了位置速度姿态外,还要加一个浮力偏差项,这是水面飞行器完全不需要的。所以每到一个新平台,我都会从这12个状态出发,问三个问题:哪些状态自由度是本质的?哪些传感器能约束这些状态?哪些扰动需要显式建模?答案出来,状态向量自然就有了。6.2 加上偏置和扰动:状态向量从12维变24维我在第4节提过状态向量可以扩展。最常见的是把传感器偏置加进来:陀螺三轴零偏、加速度计三轴零偏、气压计高度偏置,这又是7个维度。再有就是风场:固定翼在三维修正里估计三维风速,又加3维。有的系统为了估计地磁偏角和尺度因子,还能再加维度。这个时候,标题里那12个状态只占整个估计器状态的一小块,但它们的物理含义和更新方式是老大哥,其他状态都是围绕它们补丁式加进去的。这也带出了状态估计的黄金法则:加状态维度的前提是它可观测,且观测来源足够冗余。如果盲目把不可能的维度塞进状态向量,滤波器不但估计不准,还会拖垮原本健康的核心状态。我听某位做导航的朋友说过一句很实在的话:状态向量里的每一个数字,背后都得有一组传感器为它负责。6.3 模型预测控制与“状态”的新理解在 MPC(模型预测控制)里,状态向量的含义比“当前估计值”更宽。控制器需要在一个预测时域内反复积分状态方程,所以它不仅要当前状态,还要预测未来若干步的状态轨迹。标题里的12维状态在 MPC 中通常被当作预测起点,经过优化器反复推演后,输出一串控制量,使得未来状态逐步逼近目标。我第一次接触 MPC 时最大的观念转变就是:状态不只是“现在多少”,而是“模型推演的所有可能未来”。这套12维骨架在预测步里会被反复调用成千上万次,所以状态更新代码的数值求解效率,在 MPC 场景下比在普通 PID 控制器里重要得多。如果你要在这套状态上做模型预测控制,一定先把状态更新函数优化到最精简,并考虑用解析雅可比矩阵,而不是数值差分。6.4 未来还有多少种“状态变量”等着你这几年从事机器人行业的开发者越来越多,状态向量的表现形式也从单纯的线性数组,变成了带语义标签的数据结构。比如在一些新型代码架构里,位置、速度、姿态、角速度分散为不同子模块,但物理模型没有任何变化——刚体运动永远是那12个自由度。底层不管怎么包装,标题里这12个量的数学关系是绕不开的。所以我的建议是:与其追着各种新框架新库跑,不如把这12个状态量以及它们之间的微分方程吃透,这是所有机器人和飞行器系统里最稳的一块基石。框架会变,接口会换,但这 12 个自由度背后的运动学,五十年后还是这个样子。最后分享一个小经验:当你拿到一个新系统的状态向量定义时,不要急着看代码,先把 12 个量的单位、坐标系、正方向手写一页纸,然后对着运动学方程推一遍。我每次这么做完,看后续代码都轻松很多。状态向量看起来只是括号里一行字,但它决定了整个系统的世界观,理解得越早,后面省的时间越多。
做飞控、做机器人、做自动驾驶的人,大概率在某个文档里见过这么一行定义:状态变量 [x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r]。我第一次看到时觉得这就是往数组里塞了几个量而已,真到调飞机、调小车的时候才发现,这一行简短的声明背后,是一整套运动模型、坐标系约定、估计器设计和代码实现逻辑的浓缩。位置、速度、姿态角、角速度,12个量两两一组,正好把刚体在三维空间里的"平移运动"和"旋转运动"描述得清清楚楚。这篇文章我就围绕这个状态向量展开,聊聊它为什么长这样、背后牵扯到哪些关键概念、代码里该怎么更新它、估计器怎么把它算出来,以及在实机调试时我踩过哪些坑。不管你是刚入门的学生,还是已经在做飞控、导航或机器人项目的开发者,这篇文章都值得花十分钟读一遍。它不会帮你直接写出一个完整的飞控,但能让你拿到任何一套系统时,第一时间看懂它脑袋里的"状态"到底是什么。1. 这套状态变量到底在说什么:12维向量物理含义拆解1.1 前半段是位置速度,后半段是姿态角速率先把这12个状态量分个组。x, y, z是物体在三维空间中的位置,vx, vy, vz是沿三个轴的速度分量,这六个量描述的是"质心在哪里、往哪个方向跑得多快",在物理上属于刚体运动的第一部分:平移运动。后六个量roll, pitch, yaw是欧拉角形式的姿态,p, q, r是机体坐标系下的三轴角速度。欧拉角告诉你飞机当前是"什么姿势"——是歪了、抬头了还是转了个方向,而p, q, r告诉你这个姿势正在以多快的速度变化。这两组量合在一起,描述刚体运动的第二部分:旋转运动。所以[x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r]的本质就是一个12维的刚体运动状态。之所以是12,是因为三维空间里位置有3个自由度、速度有3个自由度、姿态有3个自由度、角速度有3个自由度,4乘3恰好等于12。这是描述一个刚体完整运动状态的最小编制,一个维度都没浪费。1.2 为什么位置速度是"地面系",而角速度是"机体系"这个状态向量最容易让人懵的地方在于:x, y, z和vx, vy, vz通常是在地面导航坐标系(或者叫惯性系、世界系)下定义的,而roll, pitch, yaw描述的是机体系相对地面系的角度关系,最后的p, q, r却是在机体坐标系下测量的。也就是说,这一个向量里,前6个量是地面系视角,中间3个量是两坐标系间的角度关系,最后3个量又是机体系视角。为什么最后3个角速度要放在机体系?因为陀螺仪固定在机身上,它测到的角速度天然就是机体坐标系下的。如果你想知道这个角速度在地面系下的投影,还得再用姿态矩阵做一个坐标变换,工程上完全没必要。反过来,位置和速度如果放在机体系下也挺膈应——飞机转个身,位置坐标全变了,导航和控制用起来很不方便。所以这个看似混合的排列,其实是工程妥协下的最优解。我在实际项目中见过不少大一新生写代码,把这个向量里的角速度拿来当欧拉角的导数用,结果输出曲线完全对不上。这里强调一下:p, q, r是机体角速度,roll_dot, pitch_dot, yaw_dot是欧拉角变化率,这俩之间隔着一个坐标变换矩阵,不能直接划等号。后面第三章我会把这个变换写出来。1.3 命名习惯与顺序陷阱状态向量的排列顺序在不同代码库里并不总是一样,有的系统喜欢[x, y, z, roll, pitch, yaw, vx, vy, vz, p, q, r],有的把四元数放在中间,还有的把速度排在地面系前两个轴后跟角速度。标题里这个是[x,y,z, vx,vy,vz, roll,pitch,yaw, p,q,r],看起来是"平动在前,转动在后"的逻辑,理解起来挺顺。但我要提醒所有做集成的人:拿到一段别人写的状态向量定义,先别急着抄,一定把每一项的单位、坐标参考、正负方向搞清楚。我就遇到过某个项目里z轴居然朝上,另一套代码里yaw反而是绕 z 轴逆时针为正,两边对接起来,控制器输出直接反了,飞机差点拍地上。这种坑不是环境造成的,是状态向量定义不统一造成的。所以每读一套状态量,第一件事就是找它的坐标约定文档。2. 坐标系与姿态约定:先把这个搞定,后边全是顺水推舟2.1 NED坐标系与ENU坐标系的区分讨论12维状态向量时,坐标系选择是第一个分岔路口。无人机、飞行器行业最常用的是 NED(北-东-地),x指北,y指东,z指向下方。因为它符合航空领域的直觉——高度越低z值越小,下面就是地面。而机器人和视觉SLAM圈更爱用 ENU(东-北-天),z轴朝上,高度越高z值越大,和正数学坐标系一致。如果你在两个约定之间来回切换,麻烦就来了。NED 和 ENU 之间差了两次翻转:绕水平轴转 180 度再绕竖直轴转 90 度,而且旋转顺序不同,姿态角直接搬过去是错的。我建议大家在选状态向量之前,先把主机领域定下来:做飞机用 NED,做地面机器人优先用 ENU,做水下航行器通常也跟 NED 走。定了坐标系之后,后面的方向余弦矩阵、姿态更新公式才谈得上。因为 NED 的z轴朝下,很多从视觉处理出身的朋友一上手就懵,觉得高度成负的了。我自己习惯的做法是在所有对外接口和日志里保留"海拔高度"这个语义字段,它等于-z(NED下),但内部计算一律用z本身。这样既保持代码与坐标系的严格一致,又不至于跟用户解释半天"为什么高度是个负数"。2.2 机体坐标系的正方向定义说完了地面系,再看机体系。无人机、固定翼、直升机行业一般统一用"前-右-下"约定:机头方向为机体x轴,右翼方向为机体y轴,机腹方向为机体z轴。这个坐标系和 NED 坐标系在姿态角为零时正好是完全对齐的。在这个约定下,姿态角的正方向就有了明确直觉:roll绕机体x轴,右侧机翼下沉为正,也就是说飞机向右倾斜,roll是正的。pitch绕机体y轴,机头向上抬为正。yaw绕机体z轴,从 NED 上方俯视,机头从北方向东方转向为正,这个定义等同于航向角的顺时针为正。这里面yaw和数学里那个逆时针正方向不一样,是历史上遗留的"航向角"习惯,很多Matlab工具箱和机器人库的欧拉角默认逆时针为正,直接用就很容易把偏航方向弄反。我的经验是写代码时不要依赖任何隐式默认,明确定义正方向并留一个自检注释在那儿,两个月后回来看还能看懂。2.3 欧拉角的适用边界与万向锁问题roll, pitch, yaw作为状态变量有一个天然的软肋:当pitch接近正负90度时,姿态表达退化,俗称"万向锁"问题。这个问题我下面还会详细说,先记住一个大原则:欧拉角适合人类观察和任务层描述,不适合作为控制器和估算器的内部状态长期积分。很多飞控代码里,真正的姿态状态用的是四元数,而传给外层的roll, pitch, yaw只是四元数换算出来的观察值。标题里这个状态向量写出了欧拉角,并不代表代码里必须把欧拉角当积分状态。理解这层"接口约定"和"内部实现"的区别,是读懂状态向量工程代码的关键。如果说12维状态向量是骨架,那么姿态表示方式就是骨架最关键的关节,这里选错型号,整个运动链都会卡壳。3. 状态更新实操:从连续方程到离散代码3.1 位置与速度的更新:比力、重力项和 dt先把平移部分的连续方程写出来。假设a_x, a_y, a_z是地面系下去掉重力后的加速度,那位置和速度的微分关系非常直白:x_dot = vx y_dot = vy z_dot = vz vx_dot = ax vy_dot = ay vz_dot = az难的地方在于ax, ay, az怎么来。IMU里的加速度计测的并不是平移加速度,而是"比力"——物体受到的除重力以外的作用力与质量的比值。也就是在地面系下,真实加速度等于比力加速度向量加上重力加速度向量。在 NED 坐标系下,重力加速度向量是[0, 0, +g](因为z轴朝下)。所以由机体系测到的比力fb要先旋转到地面系,再加一个重力项:a_ned = R_body_to_ned * fb + [0, 0, g]这个式子很多刚写飞控代码的人会算错。悬停状态,飞行器加速度计读到的比力大概是一个 g 指向上方,经过旋转之后地球框下的真实加速度正好是零。如果你忘了加重力项,积分出来的垂直速度会一路漂,这就是很多自研飞控悬停时高度发散的原因之一。反过来,如果重力项符号给反了,那问题更明显——飞机一离地,垂直速度就开始放飞自我。离散化的时候,位置速度更新采用最简单的一阶欧拉法就够了,前提是积分频率足够高。我的习惯是控制在 500Hz 到 1kHz 之间,每个循环固定dt = 0.002或0.001。也有用二阶中点法或 RK4 的,但对位置和速度这种平滑量,一阶欧拉在高频下已经足够稳,没必要让 MCU 白烧算力。3.2 姿态运动学方程:从机体系角速度到欧拉角速率下面就是标题里p, q, r最关键的部分:由机体角速度推算欧拉角的变化率。如果姿态用 ZYX 内旋欧拉角(工程里最常见的约定),那么从p, q, r到roll_dot, pitch_dot, yaw_dot的变换是:roll_dot = p + q * sin(roll) * tan(pitch) + r * cos(roll) * tan(pitch) pitch_dot = q * cos(roll) - r * sin(roll) yaw_dot = (q * sin(roll) + r * cos(roll)) / cos(pitch)这个矩阵是很多姿态解算代码的核心,也是新手最容易抄错的公式。注意它的前提是 ZYX 内旋顺序,如果你用了别的旋转顺序,这个公式的形状会变。而且式子末尾那个1 / cos(pitch)在pitch为 ±90° 时会爆炸,这正是欧拉角做内部状态的硬伤。顺带说一句,如果只是观察roll, pitch, yaw的缓慢变化,可以直接用小角度近似roll_dot ≈ p之类,但一旦飞行器做大机动,近似误差大得离谱,机体会快速振荡甚至发散。凡是需要全姿态控制的场合,还是老老实实把完整的变换矩阵用上。3.3 为什么工程代码里姿态状态更多用四元数尽管状态向量名义上写的是roll, pitch, yaw,我建议做状态更新和滤波的时候,把姿态用一个四元数q = [qw, qx, qy, qz]来维护。四元数的更新公式比欧拉角简洁得多:q_dot = 0.5 * q ⊗ [0, p, q, r]这里的 ⊗ 是四元数乘法。角速度向量[p, q, r]是机体系下的,直接参与运算,不需要任何三角函数,也没有万向锁问题。每次写完四元数更新再归一化,然后想输出欧拉角的时候用一个转换函数算出来即可。13个状态(位置3 + 速度3 + 四元数4 + 角速度3)在内部流转,12个状态的接口对外展示,这是非常成熟的做法。为什么很多资深工程师都这么干?因为只要你的飞行器敢做大角度俯冲或滚筒机动,pitch就会扫过 ±90°,欧拉角的方程当场爆炸,轻则姿态输出乱跳,重则控制环跟着乱掉。而四元数永远不会遇到奇点。这是经验之谈,不是理论吹嘘,我在仿真里做筋斗机动时,用欧拉角积分到 89.9° 附近的数值误差已经大到不可接受,换四元数后一路滚过来毫发无伤。3.4 一个小例子:一段12维状态量的离散更新代码我写一段 Python 示例,展示用标题里的 12 维状态量做一次离散更新的完整流程。这里姿态更新故意用四元数做中间量,最后换算回欧拉角,正好演示上面说的工程做法。import numpy as np def state_update_12(states, fb, dt): """ states: 12维数组 [x, y, z, vx, vy, vz, roll, pitch, yaw, p, q, r] fb: 机体系三轴比力,单位 m/s^2 dt: 积分步长,单位 s """ x, y, z, vx, vy, vz = states[0:6] roll, pitch, yaw = states[6:9] p, q, r = states[9:12] # 1. 就地更新位置(一阶欧拉) x += vx * dt y += vy * dt z += vz * dt # 2. 机体系比力转到地面系,再补重力项 # 这里是简化的 ZYX 旋转,真实代码建议用四元数代替 cR, sR = np.cos(roll), np.sin(roll) cP, sP = np.cos(pitch), np.sin(pitch) cY, sY = np.cos(yaw), np.sin(yaw) Rbn = np.array([ [cP*cY, sR*sP*cY - cR*sY, cR*sP*cY + sR*sY], [cP*sY, sR*sP*sY + cR*cY, cR*sP*sY - sR*cY], [-sP, sR*cP, cR*cP], ]) a_ned = Rbn @ fb a_ned[2] += 9.80665 # NED 下 z 轴向下,重力项为正 vx += a_ned[0] * dt vy += a_ned[1] * dt vz += a_ned[2] * dt # 3. 姿态更新:临时转成四元数,积完再换回来 yaw_half = yaw * 0.5 pitch_half = pitch * 0.5 roll_half = roll * 0.5 qw = np.cos(roll_half)*np.cos(pitch_half)*np.cos(yaw_half) \ + np.sin(roll_half)*np.sin(pitch_half)*np.sin(yaw_half) qx = np.sin(roll_half)*np.cos(pitch_half)*np.cos(yaw_half) \ - np.cos(roll_half)*np.sin(pitch_half)*np.sin(yaw_half) qy = np.cos(roll_half)*np.sin(pitch_half)*np.cos(yaw_half) \ + np.sin(roll_half)*np.cos(pitch_half)*np.sin(yaw_half) qz = np.cos(roll_half)*np.cos(pitch_half)*np.sin(yaw_half) \ - np.sin(roll_half)*np.sin(pitch_half)*np.cos(yaw_half) # 四元数微分 dq = 0.5 * np.array([ -qx*p - qy*q - qz*r, qw*p - qz*q + qy*r, qz*p + qw*q - qx*r, -qy*p + qx*q + qw*r, ]) qw += dq[0]*dt qx += dq[1]*dt qy += dq[2]*dt qz += dq[3]*dt # 归一化 norm = np.sqrt(qw*qw + qx*qx + qy*qy + qz*qz) qw, qx, qy, qz = qw/norm, qx/norm, qy/norm, qz/norm # 转回欧拉角 new_roll = np.arctan2(2*(qw*qx + qy*qz), 1 - 2*(qx*qx + qy*qy)) new_pitch = np.arcsin(np.clip(2*(qw*qy - qz*qx), -1.0, 1.0)) new_yaw = np.arctan2(2*(qw*qz + qx*qy), 1 - 2*(qy*qy + qz*qz)) return np.array([x, y, z, vx, vy, vz, new_roll, new_pitch, new_yaw, p, q, r])这段代码够你在桌面环境里快速复现一个12维刚体运动的状态预测。真实飞控里还要处理陀螺零偏、时间戳抖动、非均匀dt等问题,但骨架就是这个样子。注意我最后没有修改p, q, r,因为它们是测量值,可以直接取自陀螺仪,在滤波里往往把它们当输入而非积分状态。3.5 时间步长与数值稳定性离散更新里dt的选择直接关系稳定性。位置速度更新对dt没那么敏感,但姿态更新的dt如果太大,四元数积分误差会明显累积,飞一会儿就发飘。无人机常用的陀螺输出频率是 1kHz,这时dt=0.001s,对姿态更新一点问题没有。如果陀螺只有 200Hz,dt=0.005s,那就要考虑用二阶积分或者对陀螺输出做上采样。还有一个经常被忽略的细节:实时系统的dt不是恒定不变的,调度抖动会导致每次循环间隔有微小差异。我在工程里从来不用固定dt,每次循环都用高精度时钟量出实际间隔再参与积分。你的状态量更新公式写得再准,dt用错了依然是白搭。4. 状态怎么来:传感器融合与估计器选型4.1 传感器测量的不完整性与噪声设备上不会直接印着"当前状态量"四个大字。GPS 给位置,但频率低、有延迟;加速度计给比力,但噪声大,不能直接积分;陀螺给角速度,平滑,但是会漂移;磁力计给航向,可受磁场干扰严重。单一传感器根本无法提供可靠的 12 维状态,所以才需要状态估计器把多传感器信息融合起来,估算出最接近真值的状态向量。我在刚学飞控时犯过一个错误:把加速度计两次积分当速度,三次积分当位置,陀螺角速度直接积分当姿态。仿真里跑得挺好,一上真机三秒内姿态就漂得找不到北。这不怪传感器,是我自己忽略了噪声和偏置的累积效应。真实世界的状态量,几乎永远是从一个可观测系统里"估计"出来的,不是"测量"出来的。4.2 互补滤波与扩展卡尔曼滤波怎么选针对姿态,最轻量级的方法是互补滤波:把陀螺积分得到的姿态作为高速可信信号,把加速度计和磁力计推导的姿态作为低速修正信号,两者按频率特性互补融合。优点是计算量小、调参简单,STM32 上几行代码就能跑,适合做四轴玩具或入门项目。缺点是没有显式的协方差,也没有办法融合 GPS 位置得到完整位置速度估计。一旦你需要完整的 12 维状态向量,这事儿就交给扩展卡尔曼滤波(EKF)了。EKF 把标题里的 12 维向量直接作为系统状态,预测步使用运动学方程,更新步用传感器模型把预测状态映射到量测空间再校正。GPS、气压计、加速度计、磁力计甚至光流传感器都可以作为观测源。相比互补滤波,EKF 的工程实现复杂得多,但鲁棒性和可扩展性完全不在一个量级。工业上还有一种很流行的思路是:姿态用四元数 EKF 或互补滤波算好,然后再用一个单独的 EKF 估计位置和速度。这种"分而治之"的组合能用更少代码量达到工程可用的精度。但当你看到某套系统里有完整的 12 维状态向量时,大概率背后是一个统一的 EKF,而不是分块滤波。4.3 初始化和静止校准流程状态估计器启动时,不能胡乱猜初始状态。我的标准流程是:把设备放在水平静止平面上,采集一批加速度计均值,利用roll = atan2(ay, az)、pitch = atan2(-ax, sqrt(ay^2 + az^2))得到初始姿态(NED约定下)。用磁力计在水平姿态下测磁向量,计算初始yaw,同时最好对磁力计硬磁干扰做一次椭圆拟合校准。位置初始值可以从 GPS 获取,也可以先置零再放开。速度初始置零,同时在一次长时间的静止采集里估计出陀螺和加速度计的零偏,这个零偏之后会作为 EKF 的附加状态持续修正。这套流程听着简单,工程里坑很多。最常见的是静止校准没做完就起飞了,零偏偏了不少,状态估计器几十秒内就开始漂。还有一些系统把初始yaw直接设成 0,结果 GPS 往东北方向拉,控制环直接和真实航向打架。凡是多旋翼、固定翼飞控,上电后我都建议强制触发一次几秒钟的静止校准,用日志里的"calibration done"标志来拦截起飞指令。4.4 从12维状态扩展到更多维度的趋势标题里的12维状态向量只是"核心",现代飞控的估计器状态往往不止 12 维。我见过的某开源飞控内部有 24 维状态:除位置、速度、四元数、角速度外,还有陀螺零偏、加速度计零偏、气压计高度偏置、以及地速里的风场分量。风速为什么也进状态?因为固定翼在空中飞行,地速和空速之间的差异就是风速,把它估计出来对导航和路径规划都很关键。所以不要看到 12 维就觉得完整了。数字大小只是系统设计的选择,更大状态向量带来更强的可观测性和自校准能力,但是也带来更大的计算量、更难调的噪声矩阵和更差的收敛性。如果项目只是室内无人机,12 到 16 维足够;做高可靠性长航时固定翼,至少 21 维起步。理解状态的"最小集"之后,扩展只是往下午加维度的事。5. 常见故障排查与状态量调试速查表5.1 悬停漂移、角度跳变、数值NaN的典型原因真机上最常遇到的“状态量异常”就那几种,我用一张表格总结出来,每个都对应到我的实操排查经验。症状可能原因排查方向悬停时 roll/pitch 缓慢漂移陀螺零偏未校准或估计器没收敛静止状态下看零偏估计值,重新校准后再起飞高度缓慢下降并越来越快重力项符号/数值错误检查 NED 下重力项是否+g,是否用了-gyaw 缓慢打转磁力计受电机磁场干扰/磁偏角未补偿上电校准磁力计,换机臂布局或加磁屏蔽roll/pitch 抖动很大加速度计噪声大,估计器增益太高检查机架共振频率,在估计器里降低量测噪声权重大机动后角度瞬间翻转180度欧拉角做内部状态遇到万向锁内部姿态改用四元数积分,欧拉角只做输出数值出现 NaN除零/四元数未归一化/状态发散打印 NaN 出现前一帧的状态,找第一个发散量记录的姿态角一直在 ±180 来回跳输出没有做wrap_pi处理在日志和接口层统一用atan2形式的归一化角度其中 "数值 NaN" 是最让人头大的。我在一次实验里发现姿态估计器输出变成 NaN,后来逐行排查发现是pitch短时间冲过了 90 度,欧拉角微分方程里的1/cos(pitch)爆炸了。从那个项目之后,我的内部代码全面切到了四元数,就再没遇到过这个毛病。5.2 角度归一化:yaw从179度到-179度不是“转了一整圈”做上层控制的朋友很爱在状态量的角度上栽跟头。yaw习惯上被表示在-180度~180度之间,当飞机从179度继续右转1度,输出会跳成 -179度。如果你直接用error = target_yaw - current_yaw去算偏差,会得到一个 -358度的错误误差,控制器以为要逆时针猛打,于是姿态瞬间振荡。正确做法是写一个角度归一化函数:把角度差映射回-π~π。比如:def wrap_pi(a): while a > np.pi: a -= 2.0 * np.pi while a < -np.pi: a += 2.0 * np.pi return a error = wrap_pi(target_yaw - current_yaw)这样179度到-179度实际只差2度,控制器输出才正确。这个小函数价值极高,我几乎所有涉及yaw的工程代码里都会挂着一个wrap_pi。千万不要小看这个细节,很多自研飞控“原地转圈”的bug,追根到底就是没做角度包裹。5.3 日志里的时间戳和单位问题调姿态估计器时,最忌讳的是日志里只记录状态量不记录时间戳。没有时间戳,离线回放时你不知道每个数据点的真实时刻,无法对齐 IMU 数据和状态量的因果关系。我把所有状态量日志都打上微秒/毫秒时间戳,并且每帧都会记录循环间隔dt,回放时才能正确复现当时的数据流。还有一个单位问题容易在对接外部库时翻车:都是角速度,有的库用rad/s,有的库用deg/s;都是加速度,有的用g,有的用m/s^2。我一般严格规定内部状态统一用 SI 单位制:米、米每秒、弧度、弧度每秒。外部输入输出层再做单位转换。如果直接把一个标着 deg/s 的数组塞进状态向量,你在滤出来的姿态里会看到各种诡异的高频振荡,排查半天才发现是单位错了。5.4 调试工具与观察技巧排查状态向量异常,我有一套固定流程:先把状态量和传感器原始量画在同一张图上,看是传感器坏了还是估计器坏了。比如 roll 角度在漂移,同时看陀螺原始角速度是否有零偏趋势,如果陀螺原始数据有常值漂移,那是零偏校准问题;如果陀螺数据很干净但估计器输出还在漂,再看加速度计是否受振动影响,振动大到一定程度,EKF 会把重力方向估计错,姿态自然跟着歪。画图工具我用过不少,但最常用的还是把日志导成 CSV 后用 Python 的 matplotlib 快速画线。角度、角度速率、估计协方差全部叠图,一眼就能看出问题。高阶一点的做法是在仿真环境里注入已知传感器故障,观察估计器状态是否会按预期发散,用这种方式验证自己的排查逻辑对不对。反正记住一句话:状态向量是系统的“内观”,任何异常最终都会在它上面留下痕迹。6. 不同机型的变体与扩展思路6.1 多旋翼、固定翼、无人车各自的状态侧重标题里的12维状态向量更像一个通用底座,不同平台会用不同的加减法来适配自己的运动特性。多旋翼是最贴近这套向量的。悬停和低速飞行下,机体受力相对简单,位置速度姿态角速率这12个量直接进入控制器就能飞得很好。固定翼就要额外关心空速、攻角、侧滑角这些与气动力紧密相关的量——同样的地速,逆风和顺风条件下飞行状态完全不同。无人车则简单很多,roll和pitch通常被约束在极小范围,很多运动规划里干脆把它们忽略,只保留二维位置、二维速度和偏航角,状态向量降成 5~7 维,跑起来一样顺畅。水下机器人就更特殊了,浮力会影响垂向运动模型,而且没有 GPS,只能靠 DVL、惯导和压力计融合。它的状态向量可能除了位置速度姿态外,还要加一个浮力偏差项,这是水面飞行器完全不需要的。所以每到一个新平台,我都会从这12个状态出发,问三个问题:哪些状态自由度是本质的?哪些传感器能约束这些状态?哪些扰动需要显式建模?答案出来,状态向量自然就有了。6.2 加上偏置和扰动:状态向量从12维变24维我在第4节提过状态向量可以扩展。最常见的是把传感器偏置加进来:陀螺三轴零偏、加速度计三轴零偏、气压计高度偏置,这又是7个维度。再有就是风场:固定翼在三维修正里估计三维风速,又加3维。有的系统为了估计地磁偏角和尺度因子,还能再加维度。这个时候,标题里那12个状态只占整个估计器状态的一小块,但它们的物理含义和更新方式是老大哥,其他状态都是围绕它们补丁式加进去的。这也带出了状态估计的黄金法则:加状态维度的前提是它可观测,且观测来源足够冗余。如果盲目把不可能的维度塞进状态向量,滤波器不但估计不准,还会拖垮原本健康的核心状态。我听某位做导航的朋友说过一句很实在的话:状态向量里的每一个数字,背后都得有一组传感器为它负责。6.3 模型预测控制与“状态”的新理解在 MPC(模型预测控制)里,状态向量的含义比“当前估计值”更宽。控制器需要在一个预测时域内反复积分状态方程,所以它不仅要当前状态,还要预测未来若干步的状态轨迹。标题里的12维状态在 MPC 中通常被当作预测起点,经过优化器反复推演后,输出一串控制量,使得未来状态逐步逼近目标。我第一次接触 MPC 时最大的观念转变就是:状态不只是“现在多少”,而是“模型推演的所有可能未来”。这套12维骨架在预测步里会被反复调用成千上万次,所以状态更新代码的数值求解效率,在 MPC 场景下比在普通 PID 控制器里重要得多。如果你要在这套状态上做模型预测控制,一定先把状态更新函数优化到最精简,并考虑用解析雅可比矩阵,而不是数值差分。6.4 未来还有多少种“状态变量”等着你这几年从事机器人行业的开发者越来越多,状态向量的表现形式也从单纯的线性数组,变成了带语义标签的数据结构。比如在一些新型代码架构里,位置、速度、姿态、角速度分散为不同子模块,但物理模型没有任何变化——刚体运动永远是那12个自由度。底层不管怎么包装,标题里这12个量的数学关系是绕不开的。所以我的建议是:与其追着各种新框架新库跑,不如把这12个状态量以及它们之间的微分方程吃透,这是所有机器人和飞行器系统里最稳的一块基石。框架会变,接口会换,但这 12 个自由度背后的运动学,五十年后还是这个样子。最后分享一个小经验:当你拿到一个新系统的状态向量定义时,不要急着看代码,先把 12 个量的单位、坐标系、正方向手写一页纸,然后对着运动学方程推一遍。我每次这么做完,看后续代码都轻松很多。状态向量看起来只是括号里一行字,但它决定了整个系统的世界观,理解得越早,后面省的时间越多。