第一次打开MobileManipulatorDummyMRT.cpp,大部分人估计跟我一样:先被文件名吓住,接着被头文件列表里各种ocs2_前缀的包整懵。其实这个文件的定位非常朴素——OCS2 框架里一个最小可用的移动机械臂闭环控制示例。它的目的不是展示多么复杂的算法,而是让第一次接触这套框架的人,能把“MPC 求解器 + MRT 接口”这个最基本的运行链路看清楚、跑通。我当初在它身上花了一整天,把每一行都读懂之后,才敢动后面的实时示例和硬件代码。如果你也准备用 OCS2 做移动机械臂的最优控制,或者只是好奇“最优控制框架的示例到底长什么样”,这篇文章值得你跟着读到最后。
为了避免一上来就被各种模板元编程和配置结构体淹没,下文展示的源码是经过我手动简化过的“讲解版”:去掉了不影响流程的宏定义、结构体字段和部分错误处理细节,保留真实项目里最核心的调用链。所有出现过的类名和方法名,都是 OCS2 体系里真实存在的名字,你在实际工程中打开同名文件时会看到非常接近的结构。简化不影响理解主线,反而更容易让你把注意力放在“哪一行在干什么”上。
1. 在逐行读代码前,先搞明白这个示例到底想让你看懂什么
1.1 移动机械臂不是“底盘加个臂”那么简单
如果只看结构,移动机械臂就是“轮式底盘 + 机械臂”,好像把两个成熟模块拼在一起就行。但控制起来不是这样。底盘移动时,机械臂的关节会因为惯性产生扰动;机械臂伸手抓东西时,底盘也会因为反作用力出现偏移。这两个子系统在动力学上是强耦合的。
OCS2 这类最优控制框架处理这件事的做法是:把整个移动机械臂当成一个完整的非线性系统建模,状态里同时包含底盘位置/姿态、机械臂各个关节的角度和角速度,控制输入通常是底盘轮子的力和机械臂关节的力矩。MPC 求解器在一个预测时域里同时优化这些输入,所以它天然考虑到了底盘和机械臂的互相影响。MobileManipulatorDummyMRT.cpp示例使用的正是这种整体建模思路。
对这个示例本身来说,它不追求动力学仿真有多精确,也不追求把某个任务做得漂亮。它更像一个“接线演示”:让你看到系统模型如何构建、MPC 如何被调用、MRT 接口如何把优化轨迹一条条取出来。理解了这些,之后再换任何真实的移动机械臂,你都能找到应该改哪一行。
1.2 Dummy 和 MRT 分别是什么意思
“Dummy”在工程里不是贬义词,它表示“最小可用、用来跑通流程的占位实现”。在这份代码里,Dummy 体现在:它没有接入真实硬件,也没有外部的仿真器,机器人的当前状态来自 MPC 自己上一轮预测的结果。换句话说,这个程序在“自己跟踪自己”,以此把整个闭环控制的数据流打通。你暂时不用管别的,只需要记住 Dummy 只是反馈来源的占位,整个调用链是真实可用的。
“MRT”来自 OCS2 里的 MRT 接口,全称可以理解为 MPC 与机器人之间的实时轨迹接口。这个接口的作用,是把 MPC 持续计算出来的整段优化轨迹缓存起来,并对外提供“当前时刻应该用哪个状态、哪个控制输入”的查询方法。打个比方:MPC 像后厨里不停研究未来菜品的厨师,MRT 像传菜员,他知道现在该上哪道菜。
Dummy 示例的核心价值,就是用最少的代码让你同时看明白这两个角色分别做了什么。读完之后你会发现,真正要接硬件时,主循环里的大部分代码都不用变,只要把反馈来源和执行动作替换成真实传感器与真实底盘/机械臂。
1.3 整个程序的三段式骨架:构建模型、解算、取轨迹
我把整个文件的主线提前放在这里,后面逐行解析时你可以随时回看定位。
| 阶段 | 对应代码位置 | 作用 |
|---|---|---|
| 参数与节点初始化 | main 开头 | 拿到 taskFile 和 urdfFile 路径,初始化 ROS 节点 |
| 构建系统接口 | MobileManipulatorInterface | 读入 URDF 和任务参数,生成动力学、代价、约束等全套组件 |
| 构建 MPC 求解器 | MPC_Solver | 准备滚动最优控制问题的求解器 |
| 构建 MRT 接口 | MPC_MRT_Interface | 在 MPC 和主循环之间建立缓存与时钟对齐机制 |
| 后台持续求解 | 子线程调用setObservation+advanceMpc | 每次拿到最新观测就重新算一遍最优轨迹 |
| 主闭环运行 | 主循环取getOptimizedState | 模拟机器人反馈,循环推进 |
这张表就是整个程序的“地图”。读代码遇到迷路时,回到这张表定位一下自己在哪一步,比硬看下去轻松得多。接下来我们正式进入源码。
2. 从 include 到 main:第一眼看到的是头文件和参数入口
2.1 OCS2 头文件的分工与版本差异
源码的 include 区通常长这样(讲解版):
#include <ros/ros.h> #include <thread> #include <chrono> #include <ocs2_core/Types.h> #include <ocs2_core/misc/LoadData.h> #include <ocs2_mpc/MPC_Solver.h> #include <ocs2_mpc/MPC_MRT_Interface.h> #include <ocs2_mobile_manipulator/MobileManipulatorInterface.h> #include <ocs2_robotic_assets/package_path.h>这里每一行都有明确分工:ros/ros.h不用多说,OCS2 示例大多依赖 ROS,因为任务文件和 URDF 路径经常通过 ROS 参数服务器下发;<thread>和<chrono>是 C++ 标准库,用来起后台求解线程和控制循环的休眠时间;ocs2_core/Types.h提供scalar_t、vector_t这类基础类型定义;ocs2_core/misc/LoadData.h是 OCS2 自己的配置文件读取工具,很多任务参数就是通过它从.info文件里读出来的。
ocs2_mpc/MPC_Solver.h和ocs2_mpc/MPC_MRT_Interface.h是核心。前者是滚动优化求解器,后者是我们前面说到的 MRT 接口。ocs2_mobile_manipulator/MobileManipulatorInterface.h则是移动机械臂的“系统接口”,负责把 URDF 和 taskFile 变成最优控制问题所需要的数学模型。
这里提醒新手一句:不同版本的 OCS2,头文件路径会有变化。早期版本可能用别的包组织方式,有的版本把接口类放在不同的目录下,还有的会加一些辅助工具包。你如果在仓库里找不到完全一样的路径,先用grep -r "MobileManipulatorInterface"全局搜一下,会比在网上乱猜快得多。
2.2 main 函数入口与 ros 参数读取
main 开头几行通常是这样的:
int main(int argc, char** argv) { // 1. 初始化 ROS 节点 ros::init(argc, argv, "mobile_manipulator_dummy_mrt"); ros::NodeHandle nh; // 2. 读取两个关键参数 std::string taskFile, urdfFile; nh.getParam("taskFile", taskFile); nh.getParam("urdfFile", urdfFile); if (taskFile.empty() || urdfFile.empty()) { ROS_ERROR("Please provide taskFile and urdfFile via ROS parameters."); return -1; }ros::init第一行先给节点起名字。这个节点名在之后用rosrun或roslaunch启动时会出现在话题和日志前缀里。ros::NodeHandle nh;不需要传命名空间参数,默认就会去/根命名空间查参数,这样 launch 文件里设的全局参数就能被读到。
nh.getParam("taskFile", taskFile)是从 ROS 参数服务器读取字符串。很多第一次接触的人会卡在这里:明明命令行里传了参数,为什么程序说读不到?因为 OCS2 示例约定俗成用 ROS 参数,而不是标准argv。你需要在 launch 文件里写<param name="taskFile" value="$(find 包名)/config/task.info"/>,或者用rosparam set设置之后再运行节点。
检查empty()那一段很关键:路径缺失时直接报错退出,而不是带着空字符串继续走,否则接下来构造接口时会引发难以定位的崩溃。这种“入口校验”虽然不起眼,但值得学习,尤其是后续要自己写节点的人。
2.3 taskFile 和 urdfFile 到底长什么样
taskFile通常是 OCS2 风格的任务配置文件,扩展名可能是.info,里面用类似键值对的形式存放求解器设置、初始状态、权重、约束边界等。下面是我根据常见工程习惯总结出的简化示例:
initialState -0.3 0.4 1.2 0.0 0.0 0.0 0.0 0.0 0.0 ... mpc_dt 0.05 mpcHorizon 20 Q_state 1.0 R_input 0.01urdfFile就是机器人的 URDF 文件路径,里面描述底盘、机械臂连杆、关节、惯性参数。OCS2 会解析 URDF,提取关节自由度数量和连接关系,再结合 taskFile 里的控制参数生成动力学模型。
为什么任务参数要单独放一个文件而不是硬编码?因为移动机械臂的自由度、初始姿态、控制权重,每台机器都不一样。写死在代码里,每次换实验都得重新编译;放到配置文件里,改一行重启节点就行。这也是 Dummy 示例能通用于不同移动机械臂的底层原因。
3. 构建移动机械臂模型:MobileManipulatorInterface 帮你把最复杂的事做了
3.1 一个接口类背后有多少东西
继续往后看,核心的一行是:
// 3. 构造移动机械臂系统接口 MobileManipulatorInterface interface(taskFile, urdfFile);这一行看着人畜无害,实际上它是整个文件里“含金量”最高的地方。OCS2 会通过这个接口去完成:解析 URDF、组装运动学与动力学、构造系统代价、生成状态与输入约束、确定参考轨迹管理器、创建最优控制问题所需的全部组件。这个接口对象内部通常持有多个成员,例如动力学模型、代价函数、约束集合、参考管理器等。你后续调用的 MPC,本质上就是在这一整套组件定义的问题上做求解。
打个比方,你告诉它“我要考虑一个三轮底盘加六轴机械臂”,它立刻给你生成一套完整的“最优控制问题定义书”:状态是什么、输入是什么、每一步有多少代价、哪些状态不能越过边界。你不需要自己推导动力学方程,也不需要自己实现代价函数与约束的符号表达,这正是 OCS2 这类框架的价值所在。
如果你熟悉 ROS,可以把MobileManipulatorInterface理解成一个负责“把机器人描述文件转成控制问题”的工厂。后面想换成别的移动机械臂,只需更换 URDF 和 taskFile,这一行基本不用改动。
3.2 从 taskFile 读取初始状态
构建完接口后,Dummy 示例通常会这样取初始状态:
// 4. 获取初始状态和 MPC 配置 vector_t initState = interface.getInitialState(); scalar_t mpc_dt = interface.getMPCConfig().timeStep_; size_t mpcHorizon = interface.getMPCConfig().N_;getInitialState()返回一个列向量,里面是系统所有状态的初始值:底盘 x、y、航向角、机械臂每个关节的角度、对应速度等。初始状态是 MPC 第一次求解的起点,也是 Dummy 闭环里第一个“观测值”。如果你的机器人初始状态和 taskFile 里写的不一致,就会出现“MPC 以为自己在 A 点,实际机器人在 B 点”的情况,最优控制再强也救不回来。
mpc_dt和mpcHorizon决定预测时域的时间跨度:例如dt=0.05秒、N=20,则 MPC 每次求解未来 1 秒的轨迹。这两个值不是越大越好:时域太长,求解变慢,实时性下降;太短,预测信息不够,控制效果会短视。实际工程里一般根据机械臂的动力学时间常数和算力折中取,Dummy 示例里直接沿用 taskFile 的配置是最合理的。
3.3 构建接口时最容易遇到的静默错误
很多新手在这一步遇到的现象是:程序启动后没有任何提示,然后卡死,或者直接段错误。第一反应往往会去查 MPC,其实问题多半出在接口构建阶段的输入数据上。
最常见的三个坑,我用表格列出来:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动就段错误 | URDF 路径指向不存在的文件 | 确认urdfFile绝对路径,先手动cat一下 |
| 自由度维度报错 | URDF 里漏了某些关节或额外多了虚拟关节 | 打印接口模型信息,检查stateDim |
| 初始状态维度不匹配 | taskFile 的initialState行数少于/多于模型维度 | 按模型状态维度补齐初始值,末尾补零也可 |
关于第三点,OCS2 在读取初始状态时如果遇到维度不足,某些版本会直接赋默认值,某些版本会直接断言失败。这个依赖版本的行为很隐蔽。我的建议是:第一次跑通以后,立刻在初始化代码后面加一行ROS_INFO_STREAM("stateDim: " << initState.rows());,养成看维度日志的习惯,能少踩很多坑。
4. MPC 求解器和 MRT 接口:控制解算的发动机与传送带
4.1 MPC 求解器:滚动优化到底在算什么
示例接着创建求解器:
// 5. 创建 MPC 求解器 mpc::Settings mpcSettings; loadData::loadCppDataTypeFromFile(taskFile, mpcSettings, "mpc_settings"); mpc::MPC_Solver mpc(interface, mpcSettings);这里mpc::Settings是一个大结构体,里面包含求解器类型、迭代次数、QP 求解器类型、线搜索参数等一堆配置。loadCppDataTypeFromFile从 taskFile 里读取mpc_settings这一段。真正的工程代码里并不是直接MPC_Solver mpc(interface, dt, N)这么简单,而是把所有配置打包进 Settings 再传进去。我简化掉细节是为了突出主线,实际跑的时候你需要复制对应版本示例里的写法。
MPC 每次求解,做的事情可以这样理解:以当前状态为起点,在未来 N 个时刻中找一条最优的状态轨迹和输入序列,让总代价最小。代价里通常包含“希望末端到达目标位置”“希望关节运动别太激烈”“希望远离障碍”等项,权重定义在 taskFile 里。算完之后,控制器不会马上把未来 N 个输入全执行,而是只执行下一小段的输入,等下一次观测到来后重新求解。这就是“滚动优化”。
4.2 MRT 接口:为什么不能直接拿最新解去执行
如果没有 MRT,一个很自然的想法是:MPC 算完了,把当前时刻的输入直接发给电机。但问题在于,MPC 求解需要时间,可能在计算期间机器人状态已经变了。而且求解器返回的是整段轨迹,不是单一控制量。如果每次都取最新结果中的第一个值直接执行,控制频率和求解频率一旦不同步,就会出现抖动、空档甚至错误控制量。
MRT 接口就是为了解决“求解频率”和“执行频率”不一致的问题。它内部维护了一段 MPC 刚算出来的未来轨迹,并且根据系统当前时间对外提供该时刻对应的状态和输入。主循环可以以固定频率不断询问“现在该给什么”,后台 MPC 则可以异步地刷新这段轨迹。
// 6. 创建 MRT 接口 MPC_MRT_Interface mrt(mpc);这个接口连接起 MPC 和主循环,是整个 Dummy 示例最接近真实机器人工程的结构。你后续要接硬件,控制的架构不再是一个大循环里同步求解同步执行,而是“后台持续优化 + 前台定时取样”。
4.3 独立线程与实时性
接下来的代码是 Dummy 示例最有借鉴意义的点之一:
// 7. 后台持续求解 std::thread mpcThread([&]() { while (ros::ok()) { SystemObservation observation; observation.time = currentTime; observation.state = currentState; mrt.setObservation(observation); mrt.advanceMpc(); } });这里把setObservation和advanceMpc放进了子线程。为什么?因为 MPC 求解很耗时,哪怕是很小的问题,一次求解也可能需要几十毫秒;而主循环要以固定周期执行控制,不能等求解完成。把两者拆开,主循环按控制周期跑,子线程按“求解完成就立刻再算下一次”的节奏跑,就能在保证控制频率的同时让 MPC 尽量多刷新。
用真实机器人时的思路完全一样:主循环由定时器触发,读取状态估计、调用 MRT 取出控制量、发送给执行器;后台线程则不断接收最新观测并触发 MPC 重新求解。Dummy 示例虽然简单,但它把这个最重要的架构先搭建出来了。这也是为什么我建议初学者即便不做移动机械臂,也值得把这个线程模型看懂。
5. 主循环逐行拆解:观测、advanceMpc、getOptimized* 的顺序是生命线
5.1 SystemObservation 结构体
后台线程里出现的这段代码:
SystemObservation observation; observation.time = currentTime; observation.state = currentState; mrt.setObservation(observation);setObservation接收的是一个观测结构体,至少在接口层可以看成包含时间和状态:time是当前时刻,state是当前状态向量。真实结构里可能还有input、mode等字段,Dummy 示例通常只填时间和状态就够了。
这里有一个初学者常忽略的点:currentState和currentTime是主线程和后台线程共享的变量,需要保证它们代表“最新的观测”。在 Dummy 示例里,这种共享变量因为只有一个写者、一个读者,简单变量很难出问题;但真正接硬件时,我建议直接用互斥锁保护,或者把观测打包成独立的共享指针。否则传感器回调线程和主控制线程同时读写,偶尔就会拿到撕裂的状态,控制效果会变得很奇怪。
5.2 setObservation / advanceMpc / getOptimizedState 的顺序为什么不能乱
主循环的核心调用链是:
// 8. 获取当前时刻的最优状态,作为反馈 currentState = mrt.getOptimizedState(); currentTime += control_dt;再结合子线程里的两个调用,完整顺序是:setObservation->advanceMpc->getOptimizedState。
为什么不能颠倒?setObservation是把当前观测喂给 MPC,让它知道“我从哪里出发”;advanceMpc是在这个观测基础上完成一次新的求解,更新内部轨迹;只有求解完成后,getOptimizedState才能拿到基于最新观测的那条轨迹里、当前时刻对应的状态。
如果你反过来先getOptimizedState再advanceMpc,取到的很可能是上一轮观测对应的旧轨迹。如果advanceMpc之后忘了setObservation,MPC 会一直用旧观测反复求解,结果预测轨迹会随着时间越来越偏。Dummy 示例里因为反馈来自自身预测,可能暂时看不出大问题;换成真实机器人,乱序调用的后果往往是“控制量滞后半拍”“末端振荡”这种难查的 bug。
5.3 Dummy 为什么敢用自己的预测结果当反馈
很多人看主循环会有一个疑问:getOptimizedState()返回的是预测状态,拿它当反馈,岂不是自己编自己?没错,这正是 Dummy 的含义。
因为没有真实硬件也没有外部仿真器,程序无法获得“真实状态”。为了让这套接口层闭环能跑起来,它把 MPC 预测出来的状态当作机器人的当前状态,相当于用上一轮预测代替测量。这在逻辑上是不自洽的,但作为接口演示是有效的:你可以通过它验证“观测如何传入”“MPC 如何被触发”“轨迹如何取出”这条链路完全正确,而不用先折腾一台真实机器人。
如果你想让这个 Dummy 更有意义,可以把这一行改成一次简单的动力学积分:用优化输入去更新状态,而不是直接取预测状态。那样就是一个最小的仿真闭环了。Dummy 示例为了精简通常不这么写,但你在自己的实验中完全可以扩展。
5.4 循环频率、延时和日志
主循环通常还会控制执行频率:
std::this_thread::sleep_for(std::chrono::duration<double>(control_dt));这里control_dt就是控制周期。控制周期一般和mpc_dt一致,这样 MRT 在时间对齐上最直观。睡眠只是简单的节流方式;真实工程建议用定时器或条件变量,否则一旦循环体耗时波动,实际周期会漂移。
日志输出我建议保留但别刷屏。Dummy 里打印currentTime和状态向量可以方便确认程序在推进;如果状态一直是同一行,多半是卡在构建接口或者观测没更新。我从实际调试经验出发,最推荐的日志方式是在主循环外打印一次stateDim、inputDim,然后在主循环里以低频打印时间标签,既能快速定位问题,又不至于让终端滚屏。
6. 从编译到跑通:把 Dummy 示例在自己电脑上跑起来
6.1 CMakeLists 至少要链接哪些库
这个示例通常作为一个 ROS 包编译,CMakeLists.txt里至少需要找到:
find_package(catkin REQUIRED COMPONENTS roscpp ocs2_core ocs2_mpc ocs2_mobile_manipulator ocs2_robotic_assets )如果有缺失,编译时会报找不到头文件。另外,不同 OCS2 版本的库名可能有调整,例如一些辅助包也会被间接依赖。我的建议是先把自己机器上装好的 OCS2 包目录看一眼:rospack list | grep ocs2,把列出来的相关包加到COMPONENTS里,不要照抄网上旧教程。
6.2 运行命令与参数获取方式
启动文件通常用 launch 传参数,典型如下:
<launch> <node name="dummy_mrt" pkg="ocs2_robotic_examples" type="MobileManipulatorDummyMRT" output="screen"> <param name="taskFile" value="$(find ocs2_robotic_examples)/config/mobile_manipulator/task.info"/> <param name="urdfFile" value="$(find ocs2_robotic_examples)/config/mobile_manipulator/robot.urdf"/> </node> </launch>如果你没有 launch 文件,也可以直接用rosparam set:
rosparam set /taskFile /path/to/task.info rosparam set /urdfFile /path/to/robot.urdf rosrun 你的包名 MobileManipulatorDummyMRT注意rosrun后面的包名和可执行文件名要和编译产物一致。如果启动后没有任何输出,先rostopic list看看节点是否在,再rosnode ping一下,排查是否启动即崩溃。
6.3 启动后看哪些日志,怎么看实时性
程序正常起来后,你会看到类似这样的日志:
[ INFO] t=0.05 state=... [ INFO] t=0.10 state=... ...如果时间戳稳定递增,说明主循环在推进。要判断 MPC 是否跟得上,可以观察advanceMpc所在线程是否有滞后。Dummy 示例没有专门打印求解耗时,但可以自己加一行记录begin和end的时间差。如果耗时长时间大于mpc_dt,说明预测时域太大或求解器配置偏重,需要调小N或换更轻的 QP 求解选项。
7. 把这个示例改成你自己的移动机械臂控制入口
7.1 换机器人模型和任务参数时检查哪些字段
读懂了 Dummy,下一步往往是把它往自己的移动机械臂上套。此时最需要检查的不是代码,而是两个配置文件。
第一,URDF 里关节数量和类型必须和 taskFile 中的状态维度一致。移动机械臂常见的状态维度是:底盘 3 个位置/姿态自由度乘 2 的速度,加上机械臂每个关节的角度和角速度。具体数值以系统接口打印为准。第二,taskFile 里的initialState、mpc_dt、mpcHorizon、权重系数都需要根据新机器人重新标定。尤其是权重:底盘的权重和机械臂关节的权重如果差一个数量级,MPC 可能会做出“底盘不动、全靠手臂伸”或者相反的奇怪动作。
7.2 把假反馈换成真实传感器状态估计
真实机器人的反馈来源不再是预测状态,而是传感器和状态估计器。你需要把主循环开头改成类似:
currentState = stateEstimator.getState(); currentTime = clock.now();OCS2 的状态向量顺序由模型接口定义,可能是底盘位置在前、关节角度在后。状态估计器输出之前,务必先做维度裁剪和顺序对齐。我见过很多人在仿真里用得好好的,一上真机就乱抖,最后发现是估计器输出的速度量纲是deg/s,而 OCS2 内部用的是rad/s。这种单位问题在实机阶段至少要留半天时间专门排查。
7.3 把优化输入交给执行器之前要补的几道保险
主循环里拿到getOptimizedInput()后,不应该直接发给电机。第一道保险是限幅:把 MPC 给出的力矩或速度截断到执行器允许范围。第二道保险是平滑:如果前后两个周期的控制量跳变过大,做一阶低通或斜坡限制。第三道保险是安全监控:当 MRT 长时间没有刷新轨迹,或者 MPC 解算失败时,要切换到安全停止模式,绝不能继续执行过期控制量。
这些保险在 Dummy 里一个都没有,因为它们属于“真实执行”层面的问题。但你在 Dummy 代码上扩展时,要把它们当成必修课补上。否则你很快会发现,最优控制算得再漂亮,执行端一点小毛病都能让整个实验翻车。
到这里,整个MobileManipulatorDummyMRT.cpp的主链路就打通了。我自己的习惯是:跑通 Dummy 后,先别急着上复杂算法,把setObservation、advanceMpc、getOptimizedState这个三角关系反复改几遍,比如打乱顺序观察现象、故意给错观测观察结果,再回到真机。这套“折腾”比看十遍文档都有用,也是我至今仍在用的学习方法。