MPC(模型预测控制)这个话题我拖了很久才决定动笔。原因很实在:网上讲MPC原理的文章一抓一大把,但大多数都停在“摆出推导公式 + 一张滚动优化示意图”这一步,真正能把矩阵推导一路落到MATLAB和C++代码、还能稳定跑通的教程,少之又少。我自己从PID切到MPC,前前后后踩了两个月坑才把第一版控制器跑起来,中间踩过的坑包括:预测矩阵维度搞错、约束矩阵写反、数值病态导致求解器直接罢工。这篇文章我想把从原理到实现的完整链路写清楚,重点放在“推导出来的公式到底怎么变成代码里的矩阵”,以及MATLAB和C++两套实现的核心差异。适合正在做机器人运动控制、自动驾驶轨迹跟踪、过程控制,或者单纯被“MPC太难”劝退过的朋友。
1. 为什么我最终选了MPC:它到底比PID强在哪
先说一个可能让你不舒服的结论:如果你的系统没有约束,干扰也不大,PID完全够用,没必要上MPC。MPC的高昂计算代价是实打实的,别为了简历上多一行字硬上。
1.1 PID的软肋:约束和预见性
PID本质上是“看当前误差,打反馈”。它有两个天然短板:
- 它不知道未来。误差变大的趋势已经出现了,PID只能等误差真的变大之后才加大输出,永远在“追”而不是“防”。
- 它管不了约束。执行器有饱和、状态有安全边界,PID只能靠人为限幅,限幅之后积分项还会继续积累,导致经典的“积分饱和”振荡。加抗饱和逻辑能缓解,但本质问题没有解决。
我做第一版倒立摆控制的时候,用PID能把摆立住,但一旦摆角被推得太大,恢复过程会撞上电机的速度上限,整条轨迹直接失控。这就是约束在作祟。
1.2 MPC的核心思想:三句话讲完
MPC的思路其实非常直白,你可以把它理解成一个“会算计的人”:
- 拿当前状态x(k),用模型预测未来N步的系统轨迹;
- 在预测的N步内,解一个带约束的最优化问题,找出一串最优控制量u(k), u(k+1), ..., u(k+N-1);
- 只执行第一步u(k),然后到下一时刻拿到新的真实状态,重新预测、重新优化。
这就是“滚动时域”或“后退时域”的本质:每一步都在翻新计划,所以即使模型有误差、干扰进来了,控制器也能不断修正。正是“预测 + 优化 + 滚动”这三个动作,让MPC在面对约束和未来信息时天然比PID从容。
1.3 什么场景值得用MPC
以我个人的经验,出现下面任何一个信号,就该考虑MPC了:
- 系统的输入或状态存在硬约束,违反就会出安全问题或设备损坏;
- 高动态场景,比如四轴飞行器的轨迹跟踪、无人车的路径跟踪,需要提前规划动作;
- 多输入多输出系统,通道之间强耦合,PID串级调起来极其痛苦。
换个角度说,如果你的系统单输入单输出、没有约束、模型还很不准,那还是PID性价比最高。MPC的强项建立在“模型可用”这个前提上,模型太差,再好的预测也是白搭。
2. MPC原理推导:从状态空间到标准二次规划
这一节是整个项目的核心,我会把推导过程完整走一遍,并且每一步都标注“这个矩阵在后面代码里对应什么”。很多教程跳过了中间的矩阵化过程,导致读者看懂了公式但写不出代码,这里我们一步步来。
2.1 预测模型:离散状态空间的递推
MPC的预测需要一个模型。工程上最常用的是线性时不变离散状态空间模型:
x(k+1) = A x(k) + B u(k) y(k) = C x(k)其中A是系统矩阵,B是输入矩阵,C是输出矩阵。连续系统要先做离散化,零阶保持法是最常用的:A_d = e^(A_c·Ts),B_d = ∫e^(A_c·τ)dτ · B_c,在MATLAB里直接用c2d函数,在C++里可以用Padé近似或者精确矩阵指数。
现在做核心推导:假设当前时刻是k,我已知状态x(k),想预测未来N步。我定义预测时域N内的一系列输入:
U = [u(k); u(k+1); ...; u(k+N-1)]逐次代入状态方程:
x(k+1|k) = A x(k) + B u(k) x(k+2|k) = A x(k+1|k) + B u(k+1) = A^2 x(k) + A B u(k) + B u(k+1) x(k+3|k) = A^3 x(k) + A^2 B u(k) + A B u(k+1) + B u(k+2) ...规律已经出来了:第j步的预测状态,由“x(k)的j次转移”加上“前j个输入各自的卷积贡献”组成。写成矩阵形式:
X = F x(k) + G U其中X是所有预测状态的堆叠:
X = [x(k+1|k); x(k+2|k); ...; x(k+N|k)]F和G长这样:
[ A ] [ A^2 ] F = [ A^3 ] [ ... ] [ A^N ] [ B 0 ... 0 ] [ AB B ... 0 ] G = [ A^2B AB ... 0 ] [ ... ... ... ... ] [ A^(N-1)B A^(N-2)B ... B ]注意这里G是下三角块矩阵,这是因果性的体现:未来的输入不可能影响过去的状态。很多人在代码里维度对不上,基本都是G的块结构没搞对。
2.2 代价函数:把控制目标翻译成二次型
MPC的“优化”需要一个目标函数。最常见的二次型代价函数:
J = Σ_{i=1..N} x(k+i|k)^T Q x(k+i|k) + Σ_{i=0..N-1} u(k+i)^T R u(k+i)Q衡量状态偏离0的程度,R衡量控制量的大小。Q大,控制器倾向于快速把状态拉回零;R大,控制器倾向于少动作。把X = F x(k) + G U代进去,展开后是个关于U的二次函数:
J(U) = (F x(k) + G U)^T Qbar (F x(k) + G U) + U^T Rbar U其中Qbar = blkdiag(Q, Q, ..., Q),N块;Rbar = blkdiag(R, R, ..., R),N块。
展开、丢掉与U无关的常数项后,可以得到标准二次规划形式:
min (1/2) U^T H U + c^T U其中:
H = 2 (G^T Qbar G + Rbar) c = 2 G^T Qbar F x(k)H是对称正定矩阵(R正定、G满秩的前提下),这正是二次规划能高效求解的保证。c是线性项,它携带了当前状态x(k)的信息,这是MPC能“随状态滚动”的关键——每步重新算c,解就跟着变了。
2.3 约束条件的矩阵化
如果系统没有约束,上面这个二次规划可以直接求解析解,那就是LQR。MPC之所以强,就是因为它把约束也塞进了优化问题里。
工程上最常见的约束是输入约束和状态约束:
u_min ≤ u(k+i) ≤ u_max x_min ≤ x(k+i|k) ≤ x_max注意两条都要写成关于U的线性不等式。输入约束是直接的:
[ I ] [ u_min ] [-I ] U ≤ [ -u_max ] (逐块堆叠)状态约束要借助x = Fx(k) + GU,把预测状态代进去:
x_min ≤ F x(k) + G U ≤ x_max => G U ≤ x_max - F x(k) -G U ≤ -x_min + F x(k)所有约束堆在一起,统一写成:
A_ineq U ≤ b_ineq这就是我们要喂给求解器的标准约束形式。很多新手在这里犯迷糊:约束是写给当前时刻的,还是写给预测未来每一步的?答案是预测时域内每一步都要约束。比如你限制电机电压,那你给未来N步规划出的N个控制量,每一步都得在限幅范围内。
2.4 滚动时域:为什么只用第一步
解出最优U*之后,MPC只取第一个元素u(k|k)施加到系统上,下一时刻丢弃剩余所有解,重新测状态、重算。这就是“滚动”。
这么设计的原因有两个:
- 模型永远有误差,预测N步远没有预测1步准,所以“计划赶不上变化”,与其硬撑着执行原计划,不如每步重来;
- 系统有干扰,真实状态x(k+1)和预测的x(k+1|k)必然偏差,滚动更新能把这个偏差时刻拉回来。
所以MPC本质上是“短视地执行、远视地规划”:规划时看全局,执行时只看眼前一步。
3. MATLAB实现:用quadprog最快跑通MPC
MATLAB里实现MPC有两条路:用官方Model Predictive Control Toolbox,或者自己写。我的建议是:学习阶段一定要自己写一遍,工具箱给你封得太干净,原理根本学不深。下面是我用纯MATLAB写法跑通的一个二阶系统示例。
3.1 问题设定
以一个简化的角速度控制系统为例,连续时间模型:
dx1/dt = x2 dx2/dt = u离散化后(Ts = 0.05s)得到A、B矩阵。状态必须是可测的,或者你已经有状态估计器——这一步先假设状态全测。
3.2 完整实现代码
%% MPC参数设置 Ts = 0.05; % 采样周期 N = 10; % 预测时域 nx = 2; % 状态维度 nu = 1; % 输入维度 %% 系统模型(离散化后的二阶积分器) A = [1 Ts; 0 1]; B = [0; Ts]; C = [1 0]; %% 代价权重 Q = diag([10, 1]); % 状态权重 R = 0.1; % 输入权重 %% 构建预测矩阵 % F: nx*N 行, nx 列 F = zeros(nx*N, nx); for i = 1:N F((i-1)*nx+1 : i*nx, :) = A^i; end % G: nx*N 行, nu*N 列(下三角块) G = zeros(nx*N, nu*N); for i = 1:N for j = 1:i % 第i行第j列块 = A^(i-j) * B G((i-1)*nx+1 : i*nx, (j-1)*nu+1 : j*nu) = A^(i-j) * B; end end %% 代价函数矩阵 Qbar = kron(eye(N), Q); Rbar = kron(eye(N), R); H = 2 * (G' * Qbar * G + Rbar); H = (H + H') / 2; % 强制对称,防止数值误差 %% 约束 u_max = 5; u_min = -5; x_max = [10; 10]; x_min = [-10; -10]; % 输入约束 A_ineq = [eye(N); -eye(N)]; b_ineq = [u_max*ones(N,1); -u_min*ones(N,1)]; % 状态约束:G U <= x_max - F x(k) % -G U <= -x_min + F x(k) A_xineq = [G; -G]; %% 主循环 x = [5; 0]; % 初始状态 u_hist = []; x_hist = x; T_total = 5; % 仿真时长(秒) steps = round(T_total / Ts); for k = 1:steps % 线性项:依赖当前状态 c = 2 * G' * Qbar * F * x; % 当前状态对应的状态约束右端项 b_xineq = [x_max_full - F*x; -x_min_full + F*x]; % 其中 x_max_full = kron(ones(N,1), x_max),x_min_full 同理 A_full = [A_ineq; A_xineq]; b_full = [b_ineq; b_xineq]; % 求解QP opts = optimoptions('quadprog', 'Display', 'none', ... 'Algorithm', 'interior-point-convex'); U_opt = quadprog(H, c, A_full, b_full, [], [], [], [], [], opts); % 只执行第一步 u = U_opt(1:nu); % 系统状态更新(真实系统) x = A*x + B*u; u_hist = [u_hist, u]; x_hist = [x_hist, x]; end这段代码里有几个细节值得解释。第一,H构建后要强制对称,因为数值计算中G'QbarG由于浮点误差可能不完全对称,quadprog对这种矩阵会报错。第二,状态约束的右端项b_xineq必须在每个采样周期重算,因为它含F*x(k)项,x(k)每步都在变。第三,我特意用了interior-point-convex算法,对于中小规模QP它比active-set更加稳,不容易因为初始点问题挂掉。
3.3 参数调试心得
Q、R、N怎么配?我自己的经验排序是:先定N,再定Q/R量级,最后微调。
- N太短(比如N=3),MPC看不到足够远的未来,约束处理能力接近退化,性能跟LQR差不多;N太长(比如N=50),求解变慢,而且远期的预测根本没意义,模型误差早就累积得不像话了。一般系统我习惯从N=10起步。
- Q和R的比值决定“激进程度”。比值大控制器很猛,容易激励出高频抖动;比值小则动作平滑但收敛慢。一个实用的诀窍是:先把R设成输入量级的平方倒数,把Q设成状态量级的平方倒数,这样各通道量纲一致,再统一乘一个系数调快慢。
- 如果发现相邻两步u(k)跳变得厉害,多半是Q太大或者R太小,控制器在过度压榨执行器。
4. C++实现:从数学到工程化落地的关键一跃
MATLAB跑通之后,真正的考验是把算法搬进C++。这不仅是语言翻译,还涉及求解器选择、实时性设计、数值健壮性问题。
4.1 为什么必须上C++
我项目里的控制器需要跑在嵌入式板卡上,采样周期做到10毫秒以内,而且不能让系统的控制环有任何停顿。MATLAB prototype验证算法没问题,但部署到目标机、衔接底层驱动、保证cmake构建的可移植性,最终还是得C++。
4.2 依赖选择:Eigen + OSQP
矩阵运算用Eigen,二次规划求解用OSQP(Operator Splitting QP Solver)。选OSQP而不是自己写有效集法,原因很实际:OSQP是ADMM算法实现,对小规模、稠密QP求解速度快,支持热身启动(warm start),而且接口简单。你不一定非得在“理解ADMM原理”之后才能用它——先用起来,再深入原理,学习效率高得多。
Eigen负责所有矩阵操作,包括构建F、G、H、c,OSQP只吃标准QP形式的稀疏矩阵数据。注意OSQP要求的是稀疏矩阵(csc格式),但如果你的预测时域N在10到20之间,直接传稠密矩阵转成稀疏也可以,性能损耗可接受。
4.3 核心代码骨架
#include <Eigen/Dense> #include <osqp.h> struct MPCConfig { int N; // 预测时域 int nx, nu; // 状态、输入维度 double Ts; Eigen::MatrixXd A, B, Q, R; Eigen::VectorXd u_min, u_max, x_min, x_max; }; class MPCController { public: MPCController(const MPCConfig& cfg) : cfg_(cfg) { buildPredictionMatrices(); buildCostMatrices(); buildConstraintMatrices(); setupOSQP(); } Eigen::VectorXd solve(const Eigen::VectorXd& x) { // 更新线性项 c = 2 * G' * Qbar * F * x c_.noalias() = 2.0 * G_.transpose() * Qbar_ * F_ * x; // 更新状态约束右端项 b_ineq_.head(u_ineq_rows_) = ...; // 输入约束固定 b_ineq_.tail(x_ineq_rows_) = ...; // 状态约束随x变化 osqp_update_lin_cost(solver_, c_.data()); osqp_update_bounds(solver_, l_.data(), u_.data()); osqp_solve(solver_); Eigen::Map<const Eigen::VectorXd> u_opt( solver_->solution->x, cfg_.nu * cfg_.N); return u_opt.head(cfg_.nu); } private: MPCConfig cfg_; Eigen::MatrixXd F_, G_; Eigen::MatrixXd Qbar_, Rbar_; Eigen::MatrixXd H_; Eigen::SparseMatrix<double> H_sparse_, A_constraint_; Eigen::VectorXd c_; Eigen::VectorXd l_, u_; // OSQP不等式上下界 OSQPWorkspace* solver_ = nullptr; int u_ineq_rows_, x_ineq_rows_; };关键点在于solve()函数要极简:只更新线性项和约束右端项,然后直接调用OSQP。预测矩阵F、G、H、约束矩阵在构造函数里只构建一次,不要在控制循环里重复构建——这是我见过C++实现里最常见的性能浪费。
4.4 热启动:实时性的核心秘诀
OSQP最强的地方是支持热启动。所谓热启动,就是上一时刻的最优解作为这一时刻求解的初值。因为MPC的优化问题在相邻两个采样周期里变化很小,初值接近最优解,ADMM迭代收敛极快,实测往往3到5次迭代就能达到足够的精度。
osqp_warm_start_x(solver_, u_prev_.data()); osqp_warm_start_z(solver_, z_prev_.data());代价是代码里要多维护一组u_prev_,但换来的是求解耗时从几十毫秒降到几毫秒,这点复杂度完全值得。
4.5 一个容易被忽略的坑:松约束
如果你把输入约束、状态约束全部设为硬约束,初始状态稍微不在可行域内,QP直接无解,整个控制器崩溃。工程上最常见的处理是让约束带松弛变量:
x_min - ε ≤ x ≤ x_max + ε, ε ≥ 0在代价函数里加一项大权重的M·ε²。这样即使MPC找不到严格可行解,也会给出一个“尽量靠近约束边界”的解,而不是直接崩溃。我的经验是:输入约束保持硬约束(执行器物理上限不能突破),状态约束用软约束(安全边界允许短暂越界但惩罚很重)。
5. 实测中踩过的坑与解决思路
最后这部分是我最想写的,因为代码跑通只是开始,真正让MPC稳定工作在工程现场,靠的是下面这些细节。
5.1 离散化方式的选择
同一个连续系统,Ts取0.01和取0.1,离散化后的模型特性差异巨大。Ts太大,离散模型丢失了连续系统的快速动态,预测失真;Ts太小,预测时域N相同的情况下覆盖的未来时间窗口变短,且求解频率压力变大。经验法则是Ts取系统最快动态时间常数的1/10到1/5。二阶积分器这类纯积分系统看起来没有时间常数,但实际上受执行器响应和采样硬件约束,Ts太小会导致G矩阵中的数值非常小,H矩阵条件数变差,求解器精度下降。
5.2 数值病态:量纲归一化不是可选项
这是我在实际项目中踩得最深的一个坑。如果状态里既有角度(量级0.01)又有速度(量级100),还有控制量(量级0.001),直接代入QP,H矩阵条件数可能大到10的10次方以上。求解器在数值上是“能解”,但解出来的控制量会在几个控制周期之间剧烈跳变,甚至出现小的振荡。
解决方式一句话:在构建QP之前,把所有状态和控制量归一化到差不多的量级。具体做法是把状态方程改写为使用归一化变量:
z = D_x x,v = D_u u其中D_x和D_u是对角缩放矩阵,每个元素取对应变量量级倒数的数量级(比如用1除以典型工作点幅度)。所有Q、R权重也基于归一化变量设定。这个操作在MATLAB原型阶段看起来“多此一举”,但到C++上真机部署后,你会感谢当初做了这一步。实测归一化前后,同一个QP的求解精度和稳定性差别非常明显。
5.3 模型失配与状态估计
MPC的预测全靠模型,一旦模型偏差大,预测的“最优”轨迹实际上并不是真实系统的最优轨迹。我用过的几个方案,按实用程度排序:
- 反馈修正:不直接用预测状态作为下一步的初值,而是用真实测量状态更新x(k),配合状态观测器(卡尔曼滤波)抑制噪声。这一步是底线,必须有。
- 输出误差修正:如果系统有稳态误差,可以在参考轨迹上叠加输出误差的积分补偿,相当于在MPC外面又套了一层慢速PI,处理模型失配引起的稳态偏差很见效。
- 扰动估计:把常值扰动扩进状态向量,比如
d(k+1) = d(k)变成增广状态空间模型,MPC自动会预测并补偿这个常值扰动。这个技巧我是强烈推荐掌握的,对摩擦、重力偏置等问题非常有效。
5.4 求解失败的兜底策略
无论你多么小心,非线性系统工作点偏移太大、或者传感器数据异常,QP求解器仍可能返回不可行。工程上必须做兜底:
- 检测求解器状态(OSQP返回的状态码,MATLAB的exitflag),失败时切换到上一时刻的控制量保持输出;
- 记录失败日志,把当时的x(k)、H、c、约束矩阵存下来,离线复现分析,而不是在线上临时猜;
- 给控制量差异加限幅,防止热启动初值异常导致这一步解出的u和上一步偏差过大。
我在C++版本里加了一个“安全降落”逻辑:如果连续3个周期QP都失败,控制器会主动把系统引导到安全状态(比如以设定的最大减速度停车),而不是僵在原地不动。这个逻辑几乎不会触发,但一旦该触发而没有,后果可能是整台设备报废。
最后再分享一点体会
如果让我给新手一个路径建议,我会说:不要一上来就上工具箱,也不要一上来就啃最优控制理论。先用MATLAB手写一个N=10的二阶系统MPC,跑通仿真;然后加入约束、加入软约束,感受参数变化对响应的影响;最后再迁移到C++。这条路径每一步的反馈都非常快,不容易中途放弃。我在实际项目里最受益的一个习惯是:每一次调试都要保留一份“失败案例”,记录当时的状态、参数和现象。很多去年看不懂的异常,今年回头看都有明确的解释。MPC的门槛不在于某个公式难,而在于公式、代码、硬件三者之间的衔接点太多,任何一个环节没对齐都会导致整体失败。希望这篇整理能帮你少走我走过的弯路。