作为一直在做车辆运动控制、又对 CasADi 这个优化建模工具情有独钟的人,我拿到这个标题的第一反应是“这组合挺经典”:一方面,模型预测控制(MPC)是目前轨迹跟踪、路径规划落地里最主流的方案之一;另一方面,CasADi 提供了一个把“优化建模”这件事做得非常舒服的框架,尤其是配合 Matlab 原型验证。这篇文章我不打算停留在“介绍 MPC 原理有多高级”的层面,而是直接以“已实现的 Matlab + CasADi MPC 轨迹跟踪代码”为对象,把一个质点车辆模型从建模、约束设计、求解器配置到闭环调参的全过程拆开来讲。你会看到我踩过的坑、选型时的考量、以及每一步为什么要这么写——包括那些官方 demo 上根本不会告诉你的细节。
如果你是刚开始接触运动控制的学生、做无人车 demo 的工程师,或者手里有项目计划用 MPC 做轨迹跟踪但不知道怎么下手,这篇内容应该能直接省下你一周以上的摸索时间。我默认你有 Matlab 基础、知道什么是状态空间方程,但对 CasADi 的 Opti 堆栈和 MPC 的求解细节不完全熟悉。读完你能拿着代码跑起来,也能知道参数调不动的时候到底该去查哪里。
1. 整体设计与选型思路:为什么是 MPC、质点模型和 CasADi
1.1 轨迹跟踪问题为什么选 MPC
轨迹跟踪的本质是:给你一条参考轨迹(通常是一串带时间戳的参考状态序列),让车子沿着它走,走得越贴越稳越好。传统 PID 的问题是“看到误差再去纠正”,对曲率突变、速度变化大这些场景,往往反应慢一拍,容易超调甚至跑偏。纯前馈加反馈也能做,但受限于模型精度和约束处理能力。MPC 的思路完全不同——它在每个控制周期内,基于当前状态、未来一段时间的预测模型、以及你设定的约束条件,在线解一个有限时域的最优控制问题,把“未来会发生什么”提前算进去。
这一下就带来两个直接好处:一是前瞻性,参考轨迹的前方信息可以被主动纳入优化目标,预瞄前面是弯道就能提前打方向;二是约束处理的天然性,转向角限位、加速度范围、跟踪误差边界这些可以直接写进优化问题的约束里,求出的解本身就满足限制,不需要额外设计限幅逻辑。这两个特性恰好命中轨迹跟踪场景的刚需,也是我选 MPC 而不是调两天 PID 的根本原因。
1.2 质点车辆模型的定位与适用场景
标题里用的是“质点车辆模型”,这个选择很务实。车辆动力学模型从复杂度上大致分三档:质点模型只描述位置和速度,适用于算法研究和低速场景;自行车模型(Kinematic Bicycle)引入横摆角、前轮转角,适合中低速路径跟踪;更高阶的动力学模型包含轮胎侧偏、横摆角速度等,适合高速极限工况。质点模型虽然忽略了车辆的姿态和转向几何,但它的好处在于:状态少、运动学约束清晰、优化问题规模小、求解速度快。在一个算法原型阶段,先把 MPC 的核心逻辑跑通,把轨迹跟踪的“大骨架”搭好,后续再换成更复杂的模型时,整套 MPC 框架几乎不用变,只需要改状态转移函数和约束定义。
在 Matlab 里实现的时候,模型通常是这样的形式:
状态量取横纵坐标、纵横向速度,控制量取加速度,连续时间状态方程写为位置和速度的积分关系;离散化之后,就变成一个标准的“下一时刻状态 = 当前状态 + 控制量增量”的递推结构。质点模型也带来一个必须注意的弱点——它没有姿态信息,所以没办法直接处理“车头朝向对不对”的问题。在演示轨迹跟踪时,这个问题不致命,因为你跟踪的目标通常只是位置序列;但如果要做带航向约束的场景,就得在这个模型之上叠加更多状态量。这点我建议所有读者在心里有个预期:质点模型是合格的地基,但不是全功能的整车仿真。
1.3 为什么是 Matlab + CasADi 这套搭配
CasADi 本质是一个做非线性优化、尤其是最优控制问题的建模工具,它支持符号运算、自动微分和多种求解器接口,而 Matlab 的长处在于快速原型验证、数据处理以及和 Simulink 的无缝衔接。把两者组合起来,等于拿到了“柔性建模”和“快速迭代”两头的红利。我见过不少团队直接用 C++ 配 ACADO 或 CppAD,性能确实好,但调试周期长,改一版模型要重新编译半天;也见过纯用 Matlab 手写优化迭代的,又受限于工具箱的约束表达能力。CasADi 恰好站在一个中间点。
从建模表达力上看,CasADi 的 Opti 堆栈让人上瘾——它几乎还原了数学公式的写法:声明优化变量、写目标函数、写约束,然后调用 solve() 就行,不需要手推梯度、手写海森矩阵,也不需要搞清楚求解器底层的具体 API。这种“数学公式到代码”的映射能力,对做控制的人来说非常友好。而且 CasADi 的自动微分是符号级的,效率和可靠性都有保障。文章后面所有的代码,都是我实际测试过的写法,直接复用就能跑通。
2. 环境准备与问题建模:先把地基打好
2.1 CasADi 在 Matlab 下的安装与配置细节
CasADi 在 Matlab 里不是一个传统意义上的“工具箱”,而是一组预编译好的 MEX 接口文件。安装方式说不上复杂,但有几个细节会让新手卡住好几次。常见的坑包括:下载版本与 Matlab 版本不兼容,解压路径没有加入 Matlab 搜索路径,以及.mexa64文件被系统安全策略拦截。我把完整步骤整理成一套可复现的操作序列:
第一步,访问 CasADi 官方发布仓库,选择与你的操作平台和 Matlab 版本对应的压缩包。如果你用的是 Windows 下的 64 位 Matlab,优先选择matlab-win-x86_64这类名称的包,解压到一个不含中文和空格的路径。
第二步,在 Matlab 中添加路径。addpath(genpath('你的解压路径')),然后savepath保存。我习惯在startup.m里写死,省得每次重新设置。
第三步,验证安装是否成功。执行import casadi.*,然后跑一个非常简单的优化问题,比如求解x^2 + y^2的极值。如果能够返回结果,说明接口正常。这里特别提醒:CasADi 的 Matlab 接口里,casadi.Opti等类名从较新版本起已经可以不加前缀,但import casadi.*这行几乎是必须的。
第四步,检查版本匹配。CasADi 对 C++ 编译器的依赖比较敏感,尤其在新版 Matlab 上,老版本的 CasADi 可能直接报“MEX 文件无效”。我的经验是:不要盲目尝鲜最新版,选择稳定版通常更省心。
2.2 质点车辆模型的符号化推导与离散化
在 CasADi 里建立模型时,我习惯先用符号变量把状态和控制量声明出来,再构造状态转移函数。以这个项目为例,我定义状态向量为四个量:横坐标、纵坐标、横向速度、纵向速度;控制向量为两个加速度分量。之所以把横纵分量的加速度分别作为控制量,是因为横向和纵向的物理约束不同——纵向受发动机/刹车能力限制,横向受轮胎抓地力限制——分开写方便后续加不同的约束条件,虽然实际落地时两者不是完全独立的,但在算法原型阶段这个简洁性很有价值。
连续系统状态方程写出来后,下一步是离散化。MPC 本质上是在离散时间域上做滚动优化,所以必须把连续方程转化为离散递推形式。这里的关键选择是离散化方法。我在项目里用的是最简单的欧拉法,步长取采样周期。欧拉法的优点是实现和理解成本极低,对于毫秒级采样和控制周期,误差完全可以接受;如果你要求更高,可以用四阶龙格库塔法,但计算量会明显增加,对求解实时性有影响。
离散化之后的递推式长这样:
% 状态: [px; py; vx; vy] % 控制: [ax; ay] % 采样周期: Ts x_next = x + Ts * [x(3); x(4); u(1); u(2)];这一句几乎就是把“位置积分得到速度变化,速度积分得到位置变化”的物理含义直接翻译成了代码。我们建模的目的,是让优化器知道“如果我在这一时刻给了某个控制量,未来状态大致会变成什么样”,所以状态转移关系不需要太复杂,准确度够用即可。
2.3 约束、目标函数和变量边界的设计
MPC 的问题描述里,除了状态转移,还要做三件事:定义优化变量、定义代价函数、定义约束。用 CasADi 的 Opti 堆栈来表达这个过程,可以说是整个项目里最“解压”的部分——因为代码几乎就是论文公式的复写。
优化变量包括预测时域内每一步的状态量序列和控制量序列。我在定义时把状态和控制分开声明,这样写代价函数的时候可以很自然地做矩阵运算。代价函数的设计是这个项目的灵魂:我选的是标准的二次型形式,权重矩阵Q惩罚状态偏差(主要是位置偏差),R惩罚控制量大小(主要是加速度的“油耗”代价),再加上一个Q_rate惩罚控制变化率,用来抑制抖振、防止控制量忽大忽小。
约束方面,我设置了控制量的硬边界(横向加速度和纵向加速度的绝对上限),以及状态量的边界(比如位置不能超出一个设定的可行区域)。在 CasADi 中,硬约束直接构造opti.subject_to(...)就行。需要小心的是,约束如果设计得过紧,可能会让优化问题在某些场景下无解。一个实用的技巧是:不要把你希望“尽量满足”的规则和“必须满足”的物理极限混在一起,前者改成软约束(在代价函数上增加惩罚项),后者才保留为硬约束。我把轨迹跟踪偏差的边界做成了软约束,因为硬约束一旦与动力学极限冲突,就会出现求解失败的headache。
3. MPC 控制器实现:从公式到可运行代码
3.1 参数声明与初始化:预测时域、控制时域和权重的设置
MPC 代码的第一步是定义参数区和参数初值。预测时域N和控制时域M的选择,直接决定了问题的复杂度和控制效果。我在这套实现里把预测时域设为 20,控制时域设为 10,采样周期取 0.1 秒。这组参数在普通处理器上求解一个 4 状态、2 控制的优化问题,用时大概在 20 到 60 毫秒之间,基本满足实时性要求。
关于权重,Q矩阵我取对角元[200, 200, 10, 10],含义是位置偏差的惩罚远大于速度偏差,因为轨迹跟踪的核心是“贴合位置轨迹”,速度上的偏差相对来说允许更大弹性;R取diag([1, 1]),对加速度的惩罚远小于位置偏差的惩罚,防止为了“省油”而牺牲跟踪精度;Q_rate取diag([50, 50]),用于保证控制变化率不能太大。这样一套经验性权重并不能保证全局最优,但它让这个项目的效果非常直观——跟踪曲线贴合得很好,控制量输出也比较平滑。
如果你要针对不同场景调参,记住一个核心逻辑:跟踪误差大就增大Q,控制抖动厉害就增大Q_rate,控制过于“抠门”导致响应慢就减小R。这三条规则覆盖了绝大多数调参需求。
3.2 用 Opti 堆栈构建代价函数和约束条件
在 CasADi 里构建完整 MPC 优化问题,我推荐用 Opti 接口,而不是底层 NLPSol 接口,因为 Opti 在变量管理、约束拼接和结果提取上都要方便很多。核心代码框架如下:
% 优化变量声明 X = opti.variable(4, N+1); % 状态序列 U = opti.variable(2, N); % 控制序列 DU = opti.variable(2, N); % 控制增量序列 % 初始状态约束 opti.subject_to(X(:,1) == x0); % 状态转移约束(模型约束) for k = 1:N opti.subject_to(X(:,k+1) == X(:,k) + Ts * [X(3,k); X(4,k); U(1,k); U(2,k)]); end % 控制量边界 opti.subject_to(-u_max <= U <= u_max); % 控制增量更新关系 for k = 1:N if k == 1 opti.subject_to(DU(:,1) == U(:,1) - u_prev); else opti.subject_to(DU(:,k) == U(:,k) - U(:,k-1)); end end % 代价函数 cost = 0; for k = 1:N err = X(:,k) - X_ref(:,k); cost = cost + err' * Q * err ... + U(:,k)' * R * U(:,k) ... + DU(:,k)' * Q_rate * DU(:,k); end opti.minimize(cost);这里有两个容易被忽略但又很影响求解质量的细节。第一,控制增量的处理。如果我只惩罚控制量本身而不惩罚增量,那么 MPC 求出来的控制序列可能每步之间差异很大,反映到实际执行时就是车辆“哆嗦”。因此我把控制增量也作为优化变量并加入代价函数,这相当于给控制器加了一个“平滑约束”。第二,初始状态约束必须是一条等式约束,而且我建议把它写得尽量简单直接——即X(:,1) == x0,不要绕弯子。写复杂了不可读,而且会影响求解速度。
3.3 求解与结果提取
问题构建完成后,就是生成求解器。CasADi 的 Opti 堆栈在第一次调用.solve()时会自动生成一个 NLP 求解器实例,默认用的是 IPOPT。我一般会显式指定 IPOPT 的选项:
opti.solver('ipopt', struct(... 'print_level', 0, ... 'max_iter', 200, ... 'acceptable_tol', 1e-6, ... 'acceptable_obj_tol', 1e-6));把print_level设为 0 是必须的,否则在实时滚动求解时,终端会被 IPOPT 的迭代日志刷屏,根本没法看调试信息。max_iter设 200 是防止在某些病态工况下求解器陷入大量迭代而拖慢整个控制周期。这里还有个关键点:求解完成后,必须检查求解状态和退出标志。如果退出标志不是Success,那当前周期的控制量不能直接采用,应该回退到上一周期的最优控制,否则车辆会因给定一个异常控制量而危险。我在代码里专门加了一个if分支来处理这种情况,求出的解不可用时就把u_prev赋值给当前控制输出,保证系统不会突然“失智”。
还需要注意的一点是,在使用Opti时,每次循环新构建一个Opti实例是最直接、最不容易出错的写法,但代价是每一控制周期都要重新做符号图构建,会带来一定的性能开销。另一个高效做法是复用同一个Opti实例,利用参数变量来更新参考轨迹和初始状态,把符号构建只做一次。后者的实现复杂度更高,但求解总耗时明显下降。这个项目里我选择了每次重建的写法,因为质点模型的符号图很小,重建耗时几乎可忽略,而且代码可读性高;如果未来你换到高维动力学模型,一定要考虑复用例化方案。
4. 轨迹跟踪的完整闭环:参考轨迹生成、滚动优化与仿真验证
4.1 参考轨迹生成:从简单圆周到复杂起伏路径
轨迹跟踪要跑起来,第一步是生成一条参考轨迹。我在项目里设计了两种典型路径来验证控制器性能。第一种是半径 10 米的圆形轨迹,匀速跑,这可以检验稳态跟踪精度和控制平滑性;第二种是以正弦叠加构成的 S 形轨迹,曲率是连续变化的,用来检验控制器在动态工况下的适应能力。
% 圆形参考轨迹 t = 0:Ts:(T_total-Ts); ref_px = 10 * cos(0.2 * t); ref_py = 10 * sin(0.2 * t); % 对应速度设定为圆周运动切线速度 ref_vx = -2 * sin(0.2 * t); % 半径10角速度0.2 => v=2 ref_vy = 2 * cos(0.2 * t); % 参考控制量置零(匀速圆周运动,MPC会通过模型自行补偿向心加速度) ref_ax = zeros(size(t)); ref_ay = zeros(size(t));注意参考速度的计算要和参考位置保持一致,如果你的参考轨迹只给位置不给速度,MPC 在误差较小时可能无法产生合理速度,因为位置跟踪误差为零时,速度误差是唯一的驱动力。如果你从文本文件加载轨迹数据,我强烈建议在预处理阶段用数值微分算速度,并且做一次平滑滤波,否则微分的毛刺会被 MPC 直接“看见”,造成控制量高频抖动。
生成参考轨迹时,我习惯把它存成结构化数组,里面同时包含时间戳、位置、速度和控制量,这样后续在代价函数里调用非常方便。尤其是做矩阵索引时,二维索引和三维索引容易搞混,结构化存法虽然有内存开销,但能显著降低出错概率。
4.2 滚动优化闭环逻辑
MPC 和离线一次性求解的最优控制最大的区别,就是滚动时域(Receding Horizon)机制。完整闭环逻辑如下:
第一步,当车辆到达一个新的控制周期时,获取当前真实状态(在仿真里就是上一周期模型输出,真车上则是传感器反馈)。
第二步,把当前状态、参考轨迹切片、上一控制量传入优化问题,构建并求解当前周期的 NLP。
第三步,取出解序列中的第一个控制量作为实际控制输入,作用于被控对象(质点车辆模型),并记录仿真数据。
第四步,更新仿真时间,进入下一个周期,重复第一步。整个过程用 Matlab 的普通循环就可以实现。我给出的代码如下:
x_current = x0; u_prev = zeros(2,1); traj = []; for k = 1:N_sim % 获取参考轨迹切片: X_ref(:,1:N+1) ref_slice = get_ref_slice(ref_struct, k, N); % 构建并求解 MPC 问题 [u_opt, status] = solve_mpc(x_current, ref_slice, u_prev, params); % 取第一控制量 u_current = u_opt(:,1); if status ~= 'Success' u_current = u_prev; end % 应用控制量到被控对象,用欧拉法积分一步 x_next = x_current + Ts * [x_current(3); x_current(4); u_current(1); u_current(2)]; % 更新 u_prev 和 x_current u_prev = u_current; x_current = x_next; % 记录轨迹 traj = [traj, x_current]; end这段循环的代码不长,但在实现时我犯过几次糊涂,现在总结出来三个容易出错的点:第一,u_prev必须在每个周期更新为实际执行的控制量,否则控制增量惩罚项就失去了意义;第二,参考轨迹切片必须保证长度等于预测时域加 1——代价函数里要对N+1个状态误差求和,切片长度少一个就会索引越界;第三,被控对象的积分步长要和采样周期一致,如果你在仿真里用了更小的步长,则需要嵌套内循环来模拟真实车辆的动态响应。
4.3 仿真结果与三类典型现象
用上面的代码在圆形轨迹上跑,我观察到的第一个结果就是:初始阶段横向误差迅速收敛,稳态时误差能达到厘米级。这个效果主要归功于位置误差的大权重和 MPC 自身的模型预测能力。第二个典型现象是:起点离参考轨迹较远时,控制器给出的加速度会在前几步达到边界——这符合预期,因为初始误差大,优化问题为了尽快消除偏差,会倾向用满执行能力。第三个现象需要特别注意:当参考轨迹曲率突变时(例如从近似直线进入大转角),跟踪曲线会出现微小超调。这是预测时域有限带来的必然现象,是 MP C 的固有特性,不是 bug。如果想减轻超调,可以把Q_rate调大,但随之而来的是响应变慢,这个取舍很重要。
我还对比过把控制量边界从[-3, 3]放宽到[-8, 8]的效果:放宽后初始误差收敛更快,但稳态阶段控制量噪声明显变大,而且车辆轨迹在参考线附近出现微小的锯齿状波动。这个现象很好解释:过大的控制权限让优化器在稳态时对微小误差过度反应,同时因为控制增量的惩罚相对较小,控制动作频繁改变方向。所以我的调参建议是:控制边界要和实际执行器的物理能力匹配,不要为了“能力强”而设置明显过剩的上限。
5. 调试实录与常见问题速查
5.1 求解失败的典型原因与诊断路径
MPC 调试遇到最频繁、也最让新手心态崩掉的问题就是求解器报Infeasible Problem。这个问题在 CasADi/MPC 场景下几乎只有三类根源:约束过于紧张、权重不一致导致目标函数病态、或者参考轨迹本身物理不可达。
我处理这类问题时的排查顺序是这样的:第一步,把硬约束全部放宽,只保留控制边界和初始状态约束,如果问题就能求解,说明原来的某些约束与动力学冲突;第二步,检查代价函数里是否出现了数值量级差异过大的惩罚项——比如位置误差已经压到 1e-4 了,但控制量还是几十的量级,两者加在一起,目标函数中位置误差的梯度几乎消失,求解器很容易放弃对精度收敛;第三步,检查参考轨迹的速度是不是与模型匹配,比如圆周轨迹给定位置时,参考速度必须等于切线方向速度,否则 MPC 模型里位置和速度是耦合的,它无法同时满足位置和速度两个矛盾目标。
此外还有一个常见坑:代价函数里出现了NaN或Inf。比如参考轨迹里有空洞或异常大值,状态量在初始化时是zeros,这里不会出问题,但如果某一时刻求得的状态量数值爆掉,下一步代价函数就会出现Inf,求解器直接报错。在调试思路上,最好在调用solve()之后或者之前打几个关键变量的值,缩小范围。
5.2 实时性的观察和性能优化
我在一台普通的笔记本电脑上测试,Matlab 加 CasADi,默认 IPOPT 求解上面的问题,单个控制周期的耗时大约在 30 到 60 毫秒之间。这个数值能跑,但如果你真的要在硬件在环或真车实车上跑,这个耗时偏高了。有几个行之有效的优化方向:
第一,复用 Opti 实例——把参考轨迹、初始状态设成参数,只构建一次优化问题,之后每个周期只更新参数并求解,可以省掉符号图重建的耗时。第二,降低 IPOPT 的收敛精度要求,比如把acceptable_tol调到 1e-4,对控制性能的影响很小,但迭代次数能明显减少。第三,缩短预测时域或者减少控制时域,这能大幅减少优化变量数目。第四,给求解器提供一个“暖启动”——把上一周期的最优解作为当前周期的初值。Opti 提供了set_initial()方法,利用这些信息能让求解器在更少的迭代步内收敛,这是最实用的一条优化。
5.3 调参失败的经典现象,可能不是权重的问题
很多时候你调Q、R调了半天,曲线还是那个德行,这时候我建议你停下来想一下:是不是模型本身出了问题。我在调试中遇到过一个非常典型的案例:跟踪圆轨迹时,稳态误差怎么调都压不下去——所有方向的误差大约 0.5 米,曲线很平滑但不贴合。排查到最后,发现问题是参考轨迹里给的初始速度和模型的初始速度不一致,MPC 前几个周期为了修正这个矛盾浪费了预测时域内的自由度,后面就陷入了一个动态平衡。把参考轨迹的破坏性相位处理好之后,问题迎刃而解。
还有一个很容易误导人的场景:当你把位置误差权重调得非常大,确实能显著改善跟踪精度,但代价是控制幅度也变得很大,整个系统接近“bang-bang 控制”的形态。你会觉得这是参数没调好,但事实上是你的代价函数结构缺少了对控制变化率的约束。我给这个项目同时加入Q_rate惩罚之后,系统的微小抖动明显缓解。所以遇到“权重很敏感”的情况,要先去打量你的代价函数结构是否完整,而不是盲目加大某个权重值。
6. 扩展方向与实践心得
6.1 从质点模型升级到自行车模型:需要改动哪些地方
如果要把这套 MPC 搬到更真实的场景,我第一个建议是升级到自行车模型。与质点模型相比,它还包含横摆角、横摆角速度、前轮转角等状态量。这会引出几个新的问题:状态转移方程不再是简单的线性积分,控制量中也包括了转向角,状态约束里可能出现避障区域的非线性约束。好消息是,CasADi 的建模方式对这一切几乎不需要改变,你只需要改一下State转移的符号表达式,并更新状态向量维度和代价函数的索引即可。
换成自行车模型之后,最大的变化是求解难度陡然上升。对于包含更复杂状态约束的 MPC 问题,建议先用软约束代替硬约束,并增大惩罚力度来逼近硬约束效果,这样可以有效避免求解器频繁报不可行。另一个常见的处理是给横摆角速度、转向角速率等物理量增加变化率约束,这样控制器输出会更平滑,也更贴合真实执行器的表现。
6.2 套用到实际工程的三条执行准则
在真车或硬件平台上实现时,有三条执行准则是我个人实践过程中总结出来的。第一,MPC 求出的控制量是“建议值”,在发到执行器之前必须经过安全校验,比如与上一周期控制量差异是否过大、是否超出执行器物理范围、以及当前求解是否成功。第二,必须设计降级策略,当求解器超时或求解失败时,切换到预设的保守控制策略(比如保持上一帧控制或逐步减速停车)而不是继续用异常数据。第三,所有参考轨迹都必须经过平滑预处理,包括位置、速度、加速度的一致性校验和滤波,不要让 MPC 去为参考数据的物理不一致性买单。
6.3 一句话经验总结
这个项目从建模到闭环跑通,我最大的体悟是:MPC 的效果上限被模型精度和求解器性能锁死,但效果下限被你的代价值和约束定义决定。高深的优化算法救不了一个混乱的代价函数,强大的求解器也扛不住不可行的约束。所以你把时间花在建模、约束整理和调参方法论上,是最划算的投入。再补充一个小技巧:调试 MPC 时,永远先跑一个无约束或松约束的版本,把基础跟踪逻辑调通之后,再逐步添加约束和增加权重,这会帮你少走非常多的弯路。