简介:acados 是一套面向非线性最优控制与嵌入式优化的高效求解器,聚焦模型预测控制(MPC)与最优控制问题(OCP)的实时求解,适合从事机器人、自动驾驶、电力电子等领域的算法工程师与科研人员。资源提供 C、Python、MATLAB、Octave 等接口,并包含完整的安装与构建配置,可快速集成到不同开发环境。 整个压缩包共 1227 个文件,约 21.72MB。其中 371 个 C 源码与 122 个头文件构成求解器核心,296 个 .m 文件是 MATLAB/Octave 接口与示例,88 个 Python 脚本覆盖 Python 端用法,另有 78 个 txt 说明、42 个 shell 脚本、31 个 C++ 文件及 CMake/Makefile 构建配置,便于理解工程结构、二次开发与跨平台编译。 已有 1979 人浏览学习。代码工程还保留了 include、examples、interfaces 等典型目录形态,从底层求解算法到上层接口演示均有涉及,并附有安装说明、引用信息与示例用例,适合需要快速搭建非线性优化控制原型或深入研究 acados 实现细节的开发者,可作为非线性最优控制与嵌入式优化的参考实现。
1. 非线性最优控制的嵌入式求解:为什么「每次只解一个 QP」反而更可靠
非线性模型预测控制(NMPC)在很长一段时间里被默认成「上层优化」的事:每次更新控制量都要把非线性规划(NLP)迭代到收敛,桌面机上都要几十毫秒,放上嵌入式目标板更是想都不敢想。acados 把这个习惯反过来——它默认不把 NLP 收敛,每个采样周期只做一次 SQP 迭代:在当前工作点把动力学、目标函数和约束线性化,解一个 QP,立刻输出控制量。这个看似「偷懒」的做法,恰好因为上一拍留下的解、对偶乘子和 KKT 残差就是当前问题的最佳初值,在采样控制语境下比「每次迭代到收敛」更稳。值得搞透的不是它快在哪,而是它凭什么敢只解一个 QP,又是怎样把这条链路在嵌入式 C 环境里变成可预测的计算量和固定内存。
2. 从 NLP 到单个 QP:RTI 是把「在线最优控制」变「例行公事」的机制
非线性最优控制问题的难点在于:系统动力学是非线性的,约束也是非线性的,每一拍的控制更新都要面对一个全新的非线性规划。acados 的核心策略是用「实时迭代」(Real-Time Iteration,RTI)把这个问题拆成可预测的几步。这一章先把这条链路的起点讲清楚:连续问题怎么变成 NLP,NLP 的 Hessian 为什么可以只算一阶导,以及为什么只迭代一步就够用。
2.1 直接多段打靶:连续最优控制怎么变成有限维 NLP
acados 处理的对象是标准的连续时间最优控制问题:给定状态方程 ẋ = f(x, u, p),在时间窗 [0, T] 上最小化
min J = φ(x(T)) + ∫₀ᵀ L(x(t), u(t), p) dt
同时满足 x(0) = x₀,路径约束 g(x,u) ≤ 0 和状态/控制的上下界。直接法的思路是:把连续问题离散成有限维 NLP 再求解。acados 用的是直接多段打靶(direct multiple shooting)——把时间轴切成 N 段,每一段用 Runge-Kutta 积分器把动力学「压缩」成一步映射 Φ_k(x_k, u_k),于是优化变量变成每个打靶节点上的状态 x_k 和控制 u_k:
min Σₖ₌₀^{N-1} l_k(x_k, u_k) + m(x_N) s.t. x_{k+1} = Φ_k(x_k, u_k), k = 0 … N-1 x₀ = x(t₀), g_k(x_k, u_k) ≤ 0
选多段打靶而不是单段打靶,是刻意的:N 个积分区间各自有独立的状态初值,只在一步映射处耦合。即便开环动力学不稳定、某个 xi 猜错,误差也只被限制在对应区间里,不会沿着整条轨迹放大。更重要的是,每个 Φ_k 的积分和求导彼此独立,天然适合并行,也为后面 RTI 的「提前准备」留了口子。
积分器本身也有讲究。acados 同时提供显式 Runge-Kutta(ERK)和隐式 Runge-Kutta(IRK,支持 Gauss-Legendre 与 Radau 配置),刚性系统用 IRK,弱非线性、采样快的问题用 ERK。每个积分区间内部还要解一个非线性方程(对 IRK 而言),这个 Newton 迭代的次数和容差直接决定 Φ_k 的精度,也决定后面雅可比是否与积分轨迹自洽——这个坑在第 5 章会专门讲。
2.2 GGN 近似:为什么最常见的目标函数形式能绕开二阶导数
NLP 用 SQP 求解时,QP 子问题需要目标函数的 Hessian。如果目标函数是「任意非线性函数」,Hessian 要么用有限差分、要么用自动微分去算二阶导,代价都不小,在嵌入式上根本不现实。acados 的默认选择是 Gauss-Newton 近似(GGN),前提是代价写成最小二乘形式:
l_k(x_k, u_k) = ½ (y_k − y_ref)ᵀ W (y_k − y_ref), y_k = h_k(x_k, u_k)
其中 y_k 是输出(可以是状态的任意非线性函数),y_ref 是参考轨迹,W 是权重矩阵。在当前迭代点把 y_k 线性化,Hessian 的近似就是 J_yᵀ W J_y,其中 J_y = ∂h/∂(x_k, u_k)。这样一来,只需要一阶灵敏度——也就是雅可比——就能得到半正定的凸 QP 子问题。
这里有个经常被误解的点:GGN 不是「偷工减料地省略二阶项」,而是在利用最小二乘结构的性质。如果残差 y − y_ref 在解处接近零,GGN 的局部收敛速度和精确二阶法一样快;即便残差不小,只要问题本身是「追踪型」的,GGN 在实践中的表现也足够可靠。acados 里代价类型有 LINEAR_LS、NONLINEAR_LS 和 EXTERNAL 三种:前两种走 GGN,EXTERNAL 才需要你显式选择 BFGS 或精确 Hessian。对嵌入式场景,把代价改写成最小二乘形式,几乎总是最划算的一步。
2.3 RTI 的单次迭代:为什么「上一拍的解」是最优初值
完整 SQP 的流程是:线性化、解 QP、更新变量、检查 KKT 残差,不达标就再来一轮,直到收敛。对时变系统,这意味着在每个采样周期里可能要连续解多个 QP,计算量既不可控也无法预算。RTI 只做一件事:把这套 SQP 迭代压缩成「每拍恰好一次 QP」。
为什么一步就够?关键在于采样控制的时序特性。相邻两个采样周期里,测量值的变化、参考轨迹的移动、扰动的累积,相对于系统带宽通常都是小量。也就是说,前后两拍的 NLP 解在解空间中离得很近。上一拍留下的解 x、对偶乘子 π 和控制 u,本身就是这一拍问题的一个好初值。SQP 本质是 Newton 型迭代,在解附近有超线性收敛性质——初值误差小,一次迭代就能把残差压回和「解的移动速度」同一个量级。用控制语言说:一步 Newton 迭代的收缩速度,追得上解在采样间隔内的漂移速度。
RTI 的每次迭代可以拆成固定的几件事:在当前状态和上一拍的 (x, u, π) 处求雅可比、组装 QP、解 QP、把第一个控制分量施加给被控对象。这个流程没有「循环直到收敛」的退出条件,所以每拍耗时天然确定。它牺牲的那点最优性,在反馈回路的噪声和扰动面前根本不可分辨——这就是「每次只解一个 QP 反而更可靠」的机制根源。至于怎么把这几步调度到采样周期的空隙里,第 4 章再展开。
3. 结构是关键:condensing、Riccati 递归与免动态内存
RTI 把 NLP 压成了单次 QP,但 QP 本身仍可能很大:N 段状态 × 每段 n_x 维。如果不利用问题结构,一个 n_x·N 维的稠密 QP 在嵌入式上依然跑不动。acados 的下一步是用三层结构手段把 QP 压到可执行:condensing 降维度、HPIPM 的 Riccati 递归利用带状结构、免动态内存设计让一切在执行前就定好。
3.1 Condensing:消元状态,把问题压成控制量维度
在 SQP 迭代里,动力学约束被线性化成仿射形式:
x_{k+1} = A_k x_k + B_k u_k + d_k
注意这组等式是顺序耦合的:x_k 由 x_{k-1} 决定。所以每个 x_k 都能写成 x₀ 和 u₀ … u_{k-1} 的仿射函数。把这些表达式代入代价和约束,消掉全部状态变量,得到一个只含控制量 u₀ … u_{N-1} 的稠密 QP——这一步就是 condensing。消元之后问题维度从 n_x·N + n_u·N 降到 n_u·N,对「状态多、控制少」的典型机械系统(比如 n_x=10、n_u=2)收益非常可观。
但全量 condensing 有个代价:消元过程本身是 O(N²) 量级的递归运算,得到的稠密 QP 求解复杂度随 N 近似立方增长,而且稠密矩阵占内存。acados 因此提供 partial condensing,用qp_solver_cond_N控制折中程度:
# acados Python 接口:condensing 与 QP 求解器设置 ocp.solver_options.qp_solver = 'PARTIAL_CONDENSING_HPIPM' ocp.solver_options.qp_solver_cond_N = 20 # 把 N 段折成 20 个块 ocp.solver_options.qp_solver_iter_max = 15 # 内点法固定迭代次数这里的cond_N的意思是:把 N 个打靶区间分组成cond_N个「元区间」,每组内部做全量消元,组与组之间保留状态耦合的带状结构。取 cond_N = N 就是完全稠密,取 cond_N = 1 就是完全稀疏。实际调参时,它是「内存占用、消元开销、QP 求解开销」三者间的旋钮——不是越大越好,也不是越小越好,要在目标板上实测决定。
3.2 HPIPM 的 Riccati 递归:带状 KKT 为什么能线性时间求解
partial condensing 之后,QP 的 KKT 系统保留了一个关键性质:状态方程按时间顺序耦合,海森矩阵和控制/状态约束组合起来是块三对角的带状矩阵。通用稀疏求解器会把它当成普通稀疏阵做符号分解,分解代价随 N 增长很快,且需要动态内存。acados 自带的 HPIPM 求解器则走一条完全不同的路:Riccati 递归。
常见的实现思路是:对 KKT 系统做分块高斯消元,从最后一拍开始逐步消去 x_k,每消一步只对一个 (n_x + n_u) 大小的小稠密矩阵做一次分解,再把结果回代到前一步。整个求解过程是前向-后向两趟扫描,总计算量 O(N · (n_x+n_u)³),随 N 线性增长。这个复杂度只有在动力学呈顺序结构时才成立——这就是为什么 acados 愿意把所有宝都押在「最优控制问题」这个结构假设上。
HPIPM 的另一个嵌入式友好特性是固定迭代次数。内点法本质是迭代法,但 acados 允许你设定qp_solver_iter_max,到点就停。对 RTI 来说这不是坏事:QP 解只是整个反馈回路的一环,这一拍误差大一点,下一拍 RTI 的线性化会帮你补回来。实践中常见设定 10~20 次内点迭代,配合上一拍的乘子做 warm start,KKT 残差通常已经足够低。
3.3 免动态内存:一次 create、零 malloc 的运行时
嵌入式实时系统最怕的不是慢,而是「不可预测」:不知道这段代码要分配多少内存、会不会触发缺页、最坏执行时间是多少。acados 在架构上把这个问题从根上消掉——问题建立和求解分离。
在 Python/MATLAB 接口里配置好 OCP 之后,AcadosOcpSolver的创建过程会精确算出每个工作区缓冲区的大小:状态、控制、灵敏度、QP 数据、乘子、松弛变量,全部在一次create里分配完毕。运行时热路径上的solve()只读写在预先分配好的内存里,全程不调用 malloc/free。对裸机环境,甚至可以把整个工作区放进静态内存段,编译期就锁定 footprint。
注意:这意味着问题规模(N、n_x、n_u、约束个数)必须在创建求解器时就定死。想运行时动态改预测时域?做不到,也不该做——这正是换取 WCET(最坏执行时间)可分析性的代价。
4. 代码生成与求解器栈:从符号模型到 C 内核的编译链路
前面讲的是算法机制,这一章回答「怎么落到能跑在板子上的 C 代码」。acados 的嵌入式属性来自一条完整的编译链路:符号建模 → 代码生成 → 静态库 → 实时循环调用。链路里每一步都在为「可预测、无分配、可上板」服务。
4.1 CasADi 代码生成:雅可比如何变成纯 C 函数
模型在 Python 或 MATLAB 里用符号变量定义,比如 CasADi 的 SX 符号:状态、控制、参数、输出函数、约束函数全部符号化。acados 调用代码生成器把这些符号函数翻译成纯 C 代码,自动做公共子表达式消除(CSE),并保留函数内的稀疏性。生成结果像下面这样:
/* 示意:acados 实际生成的模型函数签名(各版本细节略有差异) */ int model_dynamics_expl_vde_forw( const real_t *x, const real_t *u, const real_t *p, real_t *xdot, real_t *Sx, real_t *Su);这里的vde_forw是 variational differential equations 的前向模式:在积分名义轨迹的同时,把变分方程 d/dt δx = ∂f/∂x · δx + ∂f/∂u · δu 一起积分。返回值 Sx 和 Su 就是一步映射对状态和控制的灵敏度,即 RTI 线性化需要的 A_k、B_k。用同一次积分同时得到 Φ_k 和它的雅可比,既省计算又保证了线性化和轨迹预测自洽——这一点对 RTI 至关重要。
生成后的 C 函数不分配任何内存,输入输出都走调用方提供的缓冲区,且数据布局对缓存友好。acados 还允许你用model选项选择前向还是伴随(adjoint)灵敏度:状态多、控制少用前向划算;反之用伴随更省。
4.2 求解器栈:SQP-RTI 为何默认、什么时候退回完整 SQP
acados 的求解器是分层可插拔的:外层是 NLP 求解器(SQP 或 SQP-RTI),内层是 QP 求解器(默认 HPIPM,也支持 qpOASES 等)。外层和内层的搭配决定了每一拍算多狠。
| 外层求解器 | 每拍解 QP 次数 | 适用场景 |
|---|---|---|
| SQP-RTI | 恰好 1 次 | 在线反馈控制,默认模式 |
| SQP(完整迭代) | 多次直到 KKT 收敛 | 启动阶段、轨迹规划、离线仿真 |
为什么默认是 RTI?因为它把算法结构和硬件时序对齐了:单次 QP + 固定迭代次数,耗时可以预算。但 RTI 有个前提——初值要足够接近解。冷启动、参考突变、任务切换这些情况会让上一拍的解失效,这时应该先跑几轮完整 SQP,把解拉回跟踪状态,再切回 RTI 模式。实践里常见的手法是:启动时用 full SQP 跑一次,进入稳态后切 RTI。
Hessian 的选择也在这里:默认 GGN,但如果代价不是最小二乘形式,就得选 BFGS(只需要梯度,用迭代维护的近似 Hessian)或 EXTERNAL(用户提供精确二阶导)。BFGS 在强非线性代价下比 GGN 更稳,代价是多一份内存和每拍额外的更新计算。
4.3 准备与反馈的调度:怎么把「算一半」留到上一个周期
RTI 每拍的工作量并不均匀。求雅可比、condensing、QP 分解这些「准备」阶段和最终的回代求解除 QP 相比,前者重得多。acados 把 solve 显式拆成两个相位:
# RTI 相位拆分(acados Python 接口示意,不同版本写法略有差异) acados_ocp_solver.set('rti_phase', 'preparation') acados_ocp_solver.solve() # 提前完成求导、condensing 与 QP 分解 # …… 等新测量值到来 …… acados_ocp_solver.set('rti_phase', 'feedback') acados_ocp_solver.solve() # 只做稀疏回代,输出当前控制量逻辑很直接:上一拍发完控制量到下一拍测量到来之间,有一段硬件空闲。这段时间足够完成准备相位,QP 的 KKT 矩阵已经被分解好了。新测量一到,反馈相位只是把新的 x₀ 代入已分解的 KKT 系统做回代,毫秒级甚至微秒级就能出控制量。这让整个回路的输入输出延迟从「一个完整 SQP」压缩到「一次稀疏回代」,对不稳定系统的稳定化是质的差别。
提示:RTI 调度只在硬件确认「准备相位能在测量间隔内完成」时才成立。上板前务必用示波器或计时器量出两个相位各自的实际耗时。
5. 移植避坑:嵌入式 NMPC 最容易翻车的 5 个问题
把 acados 从桌面仿真搬到板子上,翻车点几乎全集中在「时序、数值、结构」三个词上。以下 5 条是实践中最高频的踩坑记录,每条都按「现象 → 原因 → 解决」拆开讲。
5.1 采样周期内算不完:看似 CPU 不够快,其实调度没拆开
现象:控制循环在示波器上频繁超时,RTI 模式下solve()依然吃掉整个采样周期。
原因:绝大多数情况不是 CPU 不够快,而是把准备相位和反馈相位合在一起放到测量之后执行;或者qp_solver_cond_N设得太大,QP 实际规模虚高;再或者内点法没有设迭代上限,遇到病态问题迭代次数失控。
解决:先用rti_phase把准备相位挪到上一拍;然后把qp_solver_cond_N从 N 降到 10~20;再给 HPIPM 设qp_solver_iter_max(10~20 次足够)。最后用求解器返回的stat字段分别读两个相位的耗时,确认瓶颈在求导、condensing 还是 QP 求解,逐个击破。
5.2 状态量纲悬殊:QP 病态让每个控制量都在抖
现象:模型里某个状态是 1e6 量级、另一个是 1e-4 量级,求解结果高频抖动,甚至直接报不可行。
原因:KKT 矩阵里各块的数值尺度差几个数量级,条件数爆炸。固定迭代次数下内点法残差根本压不下去,返回的解在数值上不可信。
解决:给状态和控制做归一化,等价于对变量做对角缩放后重写模型;或者把权重矩阵 W 改成按量纲归一后的对角阵。这是典型的「改模型比改求解器参数有用」的场景。从stat里看残差下降曲线——如果每拍都停在同一个高位上下不去,十有八九是尺度问题。
5.3 积分容差设得不对:Φ 和雅可比对不上,线性化就是错的
现象:解析雅可比单独验证全对,但 RTI 控制效果差,预测轨迹和实际开环轨迹明显偏离。
原因:IRK 积分器内部的 Newton 迭代没收敛就提前返回,或者容差设得太松,导致一步映射 Φ_k 和灵敏度 Sx、Su 不是从同一次内部解得到的。QP 基于这套不一致的线性化预测,自然一路偏。
解决:把 integrator 的newton_tol和最大迭代次数调到一个折中档——容差太松线性化失真,太紧又拖慢准备相位。上板前做一次开环预演:用生成的 Φ 前向推一条轨迹,再用 A_k、B_k 递推同样轨迹,两条线在预测时域内必须重合。
5.4 软约束太「软」:QP 工作集每拍都在换,控制量来回抽动
现象:没有真正触碰硬约束,但控制序列高频切换,看起来像抖振。
原因:软约束惩罚权重设得太小,约束可以随意进出边界;同时上一拍的对偶乘子没有被正确 warm start,QP 每拍都从零乘子出发,活动集判别完全随机化。
解决:提高软约束权重,或者把关键约束改成硬约束;在每拍结束时显式保存乘子和松弛变量,下一拍通过set写回去。记得看stat里的 QP 迭代次数——如果每拍迭代次数波动很大,多半是 warm start 丢了。
5.5 换用通用 QP 求解器:把嵌入式最值钱的结构优势扔了
现象:把 QP 求解器从 HPIPM 换成某个通用稀疏求解器后,内存占用上一个量级,最坏执行时间无法保证。
原因:通用求解器面对的是「任意稀疏矩阵」,必须做符号分解、动态分配工作区,无法利用状态方程的顺序耦合结构——它们把 O(N) 的 Riccati 递归变成了 O(N³) 的普通稀疏分解。
解决:嵌入式目标上优先保留 HPIPM。若确实要用 qpOASES 这类主动集方法,先确认问题规模小、活动集变化不频繁,并在目标板上做一轮完整的 WCET 测试。别在仿真环境里换求解器,仿真里的「快」和板子上的「可预测」是两回事。
6. 设计取舍:acados 敢把宝押在结构假设上
acados 的一切速度都来自一个赌注:你手里的问题真的是「最优控制问题」——状态方程按时间顺序耦合、代价能写成(非线性)最小二乘、控制维数远小于状态维数、问题规模在创建求解器时固定。这个假设一旦成立,condensing 的维度压缩、HPIPM 的 Riccati 递归、免动态内存的架构就全部成立;假设不成立,它就退化为一个不伦不类的通用求解器,既没有 IPOPT 那样处理任意稀疏结构的鲁棒性,也没有手写专用代码的极致性能。
这个取舍最明显的地方在约束上。Riccati 递归只认「顺序耦合」的动力学,遇到跨所有打靶节点的全局耦合约束(比如整条轨迹的燃料积分上限),带状结构立刻被破坏。常见的处理方法是把积分量补成一个额外状态,让耦合重新变成局部的。代价是状态维数 +1,换来结构不变。这不是 acados 的缺陷,而是它「选择性放弃通用性」的必然结果——每个商业级嵌入式求解器都得选一个主攻结构,选完就要拒绝不属于这个结构的问题。
GGN 和 RTI 的搭配同样是一场交易。GGN 在残差小的追踪问题里表现出二阶收敛的潜力,但目标函数如果是强非凸的(比如障碍规避里的排斥势场),一阶近似可能把 QP 引向错误方向;RTI 一步迭代的前提是解的漂移速度小于 Newton 收缩速度,参考轨迹阶跃这类场景会让它失效。这些都不是 bug,是「为速度预设的容差」。真正的工程能力体现在:什么情况下切 full SQP,什么情况下把容差放宽,什么时候干脆把模型改写成结构友好的形式。
验证方法也很朴素:上板之前先在桌面环境把 RTI 相位拆分、固定迭代次数、workspace 大小这三个数字写进设计文档,再用stat里的 KKT 残差做闭环监控,最后在目标板上测两个相位的 WCET。这三件事做完,acados 对你就不再是黑匣子。
从某次把完整 QP 求解塞进反馈时刻、结果在示波器上看到整整一拍延迟之后,我每接一个嵌入式 NMPC 任务,都强制自己在第一周内先跑一遍「相位拆分 + 固定迭代 + 内存统计」三板斧,再谈调参。希望帮到你。
本文还有配套的精品资源,点击获取