资讯动态

模型预测控制MPC从原理到MATLAB与C++实现:从二次规划到滚动时域

发布时间:2026/10/20 7:50:25 来源:尧图企业网站定制
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(k1), ..., u(kN-1)只执行第一步u(k)然后到下一时刻拿到新的真实状态重新预测、重新优化。这就是“滚动时域”或“后退时域”的本质每一步都在翻新计划所以即使模型有误差、干扰进来了控制器也能不断修正。正是“预测 优化 滚动”这三个动作让MPC在面对约束和未来信息时天然比PID从容。1.3 什么场景值得用MPC以我个人的经验出现下面任何一个信号就该考虑MPC了系统的输入或状态存在硬约束违反就会出安全问题或设备损坏高动态场景比如四轴飞行器的轨迹跟踪、无人车的路径跟踪需要提前规划动作多输入多输出系统通道之间强耦合PID串级调起来极其痛苦。换个角度说如果你的系统单输入单输出、没有约束、模型还很不准那还是PID性价比最高。MPC的强项建立在“模型可用”这个前提上模型太差再好的预测也是白搭。2. MPC原理推导从状态空间到标准二次规划这一节是整个项目的核心我会把推导过程完整走一遍并且每一步都标注“这个矩阵在后面代码里对应什么”。很多教程跳过了中间的矩阵化过程导致读者看懂了公式但写不出代码这里我们一步步来。2.1 预测模型离散状态空间的递推MPC的预测需要一个模型。工程上最常用的是线性时不变离散状态空间模型x(k1) 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(k1); ...; u(kN-1)]逐次代入状态方程x(k1|k) A x(k) B u(k) x(k2|k) A x(k1|k) B u(k1) A^2 x(k) A B u(k) B u(k1) x(k3|k) A^3 x(k) A^2 B u(k) A B u(k1) B u(k2) ...规律已经出来了第j步的预测状态由“x(k)的j次转移”加上“前j个输入各自的卷积贡献”组成。写成矩阵形式X F x(k) G U其中X是所有预测状态的堆叠X [x(k1|k); x(k2|k); ...; x(kN|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 Σ_{i1..N} x(ki|k)^T Q x(ki|k) Σ_{i0..N-1} u(ki)^T R u(ki)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(ki) ≤ u_max x_min ≤ x(ki|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(k1)和预测的x(k1|k)必然偏差滚动更新能把这个偏差时刻拉回来。所以MPC本质上是“短视地执行、远视地规划”规划时看全局执行时只看眼前一步。3. MATLAB实现用quadprog最快跑通MPCMATLAB里实现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)*nx1 : 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)*nx1 : i*nx, (j-1)*nu1 : 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构建后要强制对称因为数值计算中GQbarG由于浮点误差可能不完全对称quadprog对这种矩阵会报错。第二状态约束的右端项b_xineq必须在每个采样周期重算因为它含F*x(k)项x(k)每步都在变。第三我特意用了interior-point-convex算法对于中小规模QP它比active-set更加稳不容易因为初始点问题挂掉。3.3 参数调试心得Q、R、N怎么配我自己的经验排序是先定N再定Q/R量级最后微调。N太短比如N3MPC看不到足够远的未来约束处理能力接近退化性能跟LQR差不多N太长比如N50求解变慢而且远期的预测根本没意义模型误差早就累积得不像话了。一般系统我习惯从N10起步。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二次规划求解用OSQPOperator Splitting QP Solver。选OSQP而不是自己写有效集法原因很实际OSQP是ADMM算法实现对小规模、稠密QP求解速度快支持热身启动warm start而且接口简单。你不一定非得在“理解ADMM原理”之后才能用它——先用起来再深入原理学习效率高得多。Eigen负责所有矩阵操作包括构建F、G、H、cOSQP只吃标准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::Mapconst 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::SparseMatrixdouble 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直接代入QPH矩阵条件数可能大到10的10次方以上。求解器在数值上是“能解”但解出来的控制量会在几个控制周期之间剧烈跳变甚至出现小的振荡。解决方式一句话在构建QP之前把所有状态和控制量归一化到差不多的量级。具体做法是把状态方程改写为使用归一化变量z D_x xv D_u u其中D_x和D_u是对角缩放矩阵每个元素取对应变量量级倒数的数量级比如用1除以典型工作点幅度。所有Q、R权重也基于归一化变量设定。这个操作在MATLAB原型阶段看起来“多此一举”但到C上真机部署后你会感谢当初做了这一步。实测归一化前后同一个QP的求解精度和稳定性差别非常明显。5.3 模型失配与状态估计MPC的预测全靠模型一旦模型偏差大预测的“最优”轨迹实际上并不是真实系统的最优轨迹。我用过的几个方案按实用程度排序反馈修正不直接用预测状态作为下一步的初值而是用真实测量状态更新x(k)配合状态观测器卡尔曼滤波抑制噪声。这一步是底线必须有。输出误差修正如果系统有稳态误差可以在参考轨迹上叠加输出误差的积分补偿相当于在MPC外面又套了一层慢速PI处理模型失配引起的稳态偏差很见效。扰动估计把常值扰动扩进状态向量比如d(k1) d(k)变成增广状态空间模型MPC自动会预测并补偿这个常值扰动。这个技巧我是强烈推荐掌握的对摩擦、重力偏置等问题非常有效。5.4 求解失败的兜底策略无论你多么小心非线性系统工作点偏移太大、或者传感器数据异常QP求解器仍可能返回不可行。工程上必须做兜底检测求解器状态OSQP返回的状态码MATLAB的exitflag失败时切换到上一时刻的控制量保持输出记录失败日志把当时的x(k)、H、c、约束矩阵存下来离线复现分析而不是在线上临时猜给控制量差异加限幅防止热启动初值异常导致这一步解出的u和上一步偏差过大。我在C版本里加了一个“安全降落”逻辑如果连续3个周期QP都失败控制器会主动把系统引导到安全状态比如以设定的最大减速度停车而不是僵在原地不动。这个逻辑几乎不会触发但一旦该触发而没有后果可能是整台设备报废。最后再分享一点体会如果让我给新手一个路径建议我会说不要一上来就上工具箱也不要一上来就啃最优控制理论。先用MATLAB手写一个N10的二阶系统MPC跑通仿真然后加入约束、加入软约束感受参数变化对响应的影响最后再迁移到C。这条路径每一步的反馈都非常快不容易中途放弃。我在实际项目里最受益的一个习惯是每一次调试都要保留一份“失败案例”记录当时的状态、参数和现象。很多去年看不懂的异常今年回头看都有明确的解释。MPC的门槛不在于某个公式难而在于公式、代码、硬件三者之间的衔接点太多任何一个环节没对齐都会导致整体失败。希望这篇整理能帮你少走我走过的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑