上一篇(PID 工程实践)解决”控制器怎么写”,这一篇解决”控制器怎么跑在真实硬件上”。ros_control 的文档很多,但大部分停留在”照着模板抄 RobotHW”。这里从控制循环的实时性讲起,一路讲到 CAN 总线电机对接的实战细节。
一、先理解控制循环:50Hz 背后的实时性陷阱
ros_control 的核心是一个固定频率的控制循环,典型实现:
1 | ros::Rate rate(50); // 50Hz |
这个循环看起来简单,但真实工程里有三个大坑:
坑1:ros::Rate::sleep() 不保证周期精确
ros::Rate 基于 ros::Time(系统时钟),当系统负载高时,回调被推迟,实际控制周期会抖动。对 50Hz 速度环,周期抖动 5ms 就足以让 PID 微分项产生明显噪声。
解法:控制循环用 hardware_interface::RobotHW 的 getTime()/getPeriod()(内部用 ros::Time 但可替换为硬件时钟),更激进的做法是硬实时——用 RT 内核 + clock_nanosleep 或直接在 MCU 上跑控制环,ROS 只做通信。
坑2:read/write 阻塞会吃掉控制周期
read() 里如果用了阻塞式串口读取(等数据到达才返回),一旦硬件响应慢,整个循环被拖住,控制器频率掉到 10Hz 都没人知道。
解法:read/write 必须非阻塞或超时保护;硬件通信放独立线程,控制循环只读写共享缓冲区(注意加锁)。
坑3:时间戳对不齐
cm.update(now, dt) 的 now 必须与 read() 拿到的数据时刻一致。如果 read() 的数据是 20ms 前采的,而 now 是当前时刻,微分项会算出虚假的”大变化”。
二、RobotHW 的完整生命周期
一个生产级 RobotHW 不只是 read/write,还包括初始化、诊断、切换:
1 | class MyRobotHW : public hardware_interface::RobotHW { |
注意 ROS1 里 read()/write() 在 controller_manager 内部被调用,ROS2 中则是 read() 由实时控制循环(如 ros2_control 的 ControllerManager 组件)驱动,且 ROS2 的 hardware_interface 接口签名统一为带 time 和 period 参数。
为什么需要 prepareSwitch / doSwitch 两段式?
因为切换控制器是有状态转移的操作:先”准备”(所有控制器确认可以切换),再”执行”(统一切换)。如果直接切换,可能出现:轨迹控制器还没释放关节,位置控制器已经接管,两个控制器同时写一个 joint——指令冲突,电机抖动。两段式保证切换的原子性。
三、三种硬件接口:Effort / Velocity / Position 怎么选
| 接口 | 指令含义 | 适用场景 | RoboMaster 对应 |
|---|---|---|---|
| EffortJointInterface | 力矩/电流指令 | 需要精确力控、阻抗控制 | 电流环已由电调完成,一般不直接用 |
| VelocityJointInterface | 速度指令 | 速度伺服、移动底盘 | M3508/C620 电调速度环 |
| PositionJointInterface | 位置指令 | 位置伺服、关节角控制 | M2006 位置环 |
选型核心:ROS 层做哪一级闭环,取决于底层硬件已经完成了哪一级。
RoboMaster 的 C620 电调内置电流环(FOC)+ 速度环,你发给它的目标是”转速”——所以上层 ROS 应该用 VelocityJointInterface,再往上自己加位置环 PID(位置误差 → 目标速度)。如果电调只做电流环(如自研电调),ROS 层就得自己写速度环 PID(上一篇文章的内容),用 EffortJointInterface 发电流目标。
新手最常见的设计错误:买了带速度环的电调,还在 ROS 层写了个速度环 PID 发给电调”速度目标”——两层速度环串联,带宽互相打架,调参调到头秃。
四、真实电机对接:CAN 总线实战
以 RoboMaster M3508 + C620 为例,RobotHW 的 write() 本质是把速度指令打包成 CAN 帧:
1 | // C620 电调 CAN 发送格式(0x200 + 电机ID组) |
读状态(反馈转速)用 0x201 组帧,解析格式:[ID1_高, ID1_低, 角度_高, 角度_低, ID1转速_高, ID1转速_低, ...]。
几个实战细节:
- CAN 波特率:C620 默认 1Mbps,必须和电调拨码一致,否则全车静默
- ID 分配:电调 ID 通过拨码开关设置,代码里要能从参数服务器读 ID 而不是硬编码
- 数据溢出:-16384~+16384 对应 int16,符号位处理要小心
- 超时保护:CAN 总线断线时电调会锁死输出(C620 默认 1s 无指令自动停止),但 ROS 侧也要监控反馈超时,触发急停
1 | void read(const ros::Time&, const ros::Duration&) override { |
五、Transmission 的数学本质
Transmission 文档里只说了”SimpleTransmission 带减速比”,但它的本质是执行器空间与关节空间的双向变换:
$$\tau_{joint} = \frac{1}{n}\tau_{actuator}, \quad \dot{q}{actuator} = n \cdot \dot{q}{joint}$$
其中 n 是机械减速比。对应到代码:
1 | // 速度映射:关节速度 → 电机转速 |
多级减速(谐波/行星 + 带轮):SimpleTransmission 只支持单级,复杂传动链(如 RoboMaster 云台 yaw 轴:电机 1:359 谐波 + 同步带)需要自定义 Transmission 或直接在 RobotHW 里做变换。后者在多数 RoboMaster 工程中更常见——直接读电机反馈除以总减速比得到关节角,指令方向乘以总减速比。
六、控制器切换实战:spawner 背后的机制
spawner 命令的本质是调用 controller_manager 的 service:
1 | load → /controller_manager/load_controller (加载 .so 插件) |
启动顺序很重要:先 load 所有控制器 → 再 switch 启动。如果只 load 不 start,控制器被加载但不运行,可以用于”预加载后按需启动”。这在多模式机器人(底盘速度模式 / 云台位置模式切换)中很常用:
1 | # 预加载全部控制器 |
七、Joint Limits:最后的安全网
URDF/参数服务器配置的 joint_limits 不只是”文档”,它在 ros_control 内部作用于控制器输出——软限位(soft limits)会在接近极限时让控制器”温柔地”减速,而不是硬切:
1 | joint_limits: |
对于 RoboMaster 云台,yaw 轴是无限旋转的(滑环),绝对不能配位置硬限位,否则陀螺仪坐标系翻转时控制器会往极限方向猛打。这种”限位配置本身会引发事故”的案例,比限位不够更常见。
总结
- 控制循环是实时系统:周期抖动、阻塞 IO、时间戳对齐,三个坑都比算法本身更致命
- 接口选型看底层:电调有速度环就用 Velocity 接口,别叠两层速度环
- 两段式切换:prepareSwitch/doSwitch 保证控制器交接的原子性
- CAN 对接注意边界:波特率、ID、溢出、超时急停
- 限位是双刃剑:软限位 + 旋转轴特殊处理