ROS 实战:从零搭建你的第一个机器人

ROS
11k 词

从”照着教程搭出能动的车”升级为”理解每个环节为什么这么配、出问题怎么查”。URDF 的物理正确性、Gazebo 仿真与真实的差距、ros_control 硬件接口的实时语义、TF 时间同步、以及 ROS 主控与 STM32 的串口协议设计——每个环节都给出工程级写法和真实踩坑。

一、URDF 建模:物理正确性优先

一个 link 有三个几何描述,用途完全不同,不能偷懒共用一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<link name="base_link">
<!-- visual:显示用,可以用精细的 STL 网格 -->
<visual>
<geometry><mesh filename="package://my_pkg/meshes/base.stl"/></geometry>
</visual>
<!-- collision:碰撞检测用,必须用简单几何(box/cylinder)! -->
<collision>
<geometry><box size="0.4 0.3 0.08"/></geometry>
</collision>
<!-- inertial:动力学用,质量与惯量必须真实 -->
<inertial>
<mass value="3.0"/>
<inertia ixx="0.02" ixy="0" ixz="0" iyy="0.015" iyz="0" izz="0.025"/>
</inertial>
</link>

三条铁律:

  1. collision 用简单几何。Gazebo 的碰撞检测是几何求交,一个几万面的 STL 会让仿真物理步长卡到没法跑;而且精细网格碰撞经常”卡住”——两个网格互相嵌进去就弹不出来
  2. inertial 不能是零。Gazebo 里质量/惯量为 0 的 link 会数值爆炸(除零),表现为”车一启动就飞上天”。惯量矩阵对角线元素至少给个合理小值,偷懒给 0.001 也比 0 强
  3. 惯量不是随便填的。实心长方体绕质心的惯量:$I_{zz} = \frac{1}{12}m(w^2 + l^2)$。填错惯量 → 仿真里转向/加速的动态响应失真 → 在仿真里调好的 PID 拿到真机上完全不是一回事

joint:类型选择与 MDH 语义

1
2
3
4
5
6
<joint name="joint_wheel_LF" type="continuous">
<parent link="base_link"/>
<child link="wheel_LF"/>
<origin xyz="0.15 0.12 -0.05" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
  • type 选型:轮子用 continuous(无限旋转)、机械臂关节用 revolute(有限位)、平移用 prismatic、底盘与世界的连接用 fixed/floating。joint 类型决定了 ros_control 里能用哪种控制器——continuous 关节没有位置上下限,位置控制会绕圈
  • origin 是子坐标系相对父坐标系的位姿——这正是改进 DH(MDH)的语义(详见《机器人运动学与动力学》篇)。你在纸上算好 SDH 参数表直接抄进 URDF,末端位姿必然差一个连杆偏移。验证方法:check_urdf my_robot.urdf + RViz 里看坐标系朝向
  • axis 是关节轴在父坐标系中的方向,写错轴机器人会”往反方向拧”

xacro 工程化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<xacro:macro name="wheel" params="name x y z">
<link name="wheel_${name}">
<visual>
<geometry><cylinder length="${wheel_length}" radius="${wheel_radius}"/></geometry>
</visual>
...
</link>
<joint name="joint_wheel_${name}" type="continuous">
<parent link="base_link"/>
<child link="wheel_${name}"/>
<origin xyz="${x} ${y} ${z}" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
</xacro:macro>

<xacro:property name="wheel_radius" value="0.05"/>
<xacro:wheel name="LF" x="0.15" y="0.12" z="-0.05"/>
<xacro:wheel name="RF" x="0.15" y="-0.12" z="-0.05"/>

xacro 不只是”宏复用”,还能做数学计算和条件分支:

1
2
3
4
5
6
7
8
9
<!-- 属性参与计算:四轮共用底盘尺寸,改一处全变 -->
<xacro:property name="wheelbase" value="0.30"/>
<xacro:property name="track" value="0.24"/>
<xacro:wheel name="LF" x="${wheelbase/2}" y="${track/2}" z="${-wheel_radius}"/>

<!-- 条件分支:仿真版带传感器、真机版不带 -->
<xacro:if value="${use_sensor}">
<xacro:include filename="sensor_laser.xacro"/>
</xacro:if>

CAD 导出 URDF 的坑:

  • 单位:SolidWorks/Fusion 导出时默认毫米,URDF 一律用米。忘换单位 = 机器人 1000 倍大小,Gazebo 里直接穿地
  • 导出插件(如 Fusion 的 urdf exporter)生成的 mesh 路径是绝对路径,要改成 package://my_pkg/meshes/... 形式,否则换机器就加载失败
  • 导出后再手改 xacro 会丢失——导出物当基准,所有修改在 xacro 层做,mesh 只做视觉

二、Gazebo:从”能加载”到”物理正常”

仿真与真实的差距,一开始就要知道

Gazebo 的物理引擎(ODE/Bullet)是离散时间步进的:默认 max_step_size=0.001(1ms)、real_time_update_rate=1000。三个最典型的”仿真能跑真机不行”:

  1. 摩擦模型是近似的:轮子与地面的接触摩擦系数、轮胎的柔度在仿真里都是常数,真机是速度和负载的函数。仿真里”急停不滑”不代表真机不滑
  2. 传感器无噪声(默认):Gazebo 的 laser/IMU 默认理想,导航代码在仿真里稳如老狗,真机上第一帧就抖——仿真必须开噪声(<noise><type>gaussian</type><stddev>0.01</stddev></noise>),噪声量级按真机手册标定
  3. 控制延迟为零:仿真里指令下发即刻生效,真机有 CAN 通信延迟、电机响应延迟。PID 在仿真里能上大增益,真机上必须先留裕量(详见《PID 控制的工程实践》篇)

ros_control 插件接入

URDF 里声明传动与硬件接口,仿真里才能用 ros_control:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<gazebo>
<plugin name="gazebo_ros_control" filename="libgazebo_ros_control.so">
<robotNamespace>/my_robot</robotNamespace>
<robotSimType>gazebo_ros_control/DefaultRobotHWSim</robotSimType>
</plugin>
</gazebo>

<transmission name="trans_wheel_LF">
<type>transmission_interface/SimpleTransmission</type>
<joint name="joint_wheel_LF">
<hardwareInterface>hardware_interface/VelocityJointInterface</hardwareInterface>
</joint>
<actuator name="motor_LF">
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>

仿真里的 PID 也要整定:gazebo_ros_control 的 DefaultRobotHWSim 内部对每个关节跑一个 PID,默认增益经常不合适(轮子转速跟不上指令、或震荡)。在 ros_control 的 yaml 里显式配:

1
2
3
gazebo_ros_control:
pid_gains:
joint_wheel_LF: {p: 10.0, i: 0.5, d: 0.1, i_clamp: 5.0}

整定顺序与真机完全一致:先 P(到 70% 临界震荡往回退)→ 加 I 消稳态误差 → 加 D 压超调(详见 PID 篇的量化方法)。仿真里整定好的相对关系(哪个关节需要更高增益)可以迁移到真机,绝对值不行。

三、ros_control:硬件接口与控制器

RobotHW:read/write 的实时语义

真机/仿真都通过 hardware_interface::RobotHW 接入。骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <hardware_interface/joint_command_interface.h>
#include <hardware_interface/joint_state_interface.h>
#include <hardware_interface/robot_hw.h>
#include <controller_manager/controller_manager.h>

class ArmHWInterface : public hardware_interface::RobotHW {
public:
ArmHWInterface();
void read(); // 从硬件/仿真读关节状态(位置/速度/力矩)
void write(); // 把控制指令下发到硬件/仿真
private:
hardware_interface::JointStateInterface jnt_state_interface;
hardware_interface::VelocityJointInterface jnt_cmd_interface;
double cmd_[6], pos_[6], vel_[6], eff_[6];
};

read()/write() 是被 controller_manager 的控制循环周期调用的(默认 1000Hz,可配 controller_manager 的 update_rate)。两个生产级要点:

  1. read/write 里只做数据搬运:真机场景下 read() 里是”从共享内存/CAN 接收缓冲取最新值”,write() 里是”把指令写进发送缓冲”——真正的 CAN 收发在独立线程。禁止在 read/write 里做分配、加锁、打印(实时性要求,与 ROS2 的 controller 限制同理)
  2. 指令与状态要配对:M3508/C620 电调通过 CAN 返回的状态(位置/转速/电流)和下发指令(电流/速度)是异步的,read 回来的状态是上一拍的——控制算法里用”当前指令对应的状态”做反馈才有意义,注意时序对齐(详见《ROS Control 深入》篇的电机对接)

控制器配置与生命周期

1
2
3
4
5
6
7
8
9
10
11
12
# config/controllers.yaml
controller_manager:
ros__parameters: # ROS1 写法:直接列控制器
update_rate: 100

joint_state_controller:
type: joint_state_controller/JointStateController
publish_rate: 50

wheel_vel_controller:
type: velocity_controllers/JointGroupVelocityController
joints: [joint_wheel_LF, joint_wheel_RF, joint_wheel_LR, joint_wheel_RR]
1
2
<node name="controller_spawner" pkg="controller_manager" type="spawner"
args="joint_state_controller wheel_vel_controller"/>
1
2
3
# 生命周期管理:控制器可以随时加载/卸载/切换,不用重启机器人
rosservice call /controller_manager/load_controller "name: 'wheel_vel_controller'"
rosservice call /controller_manager/switch_controller "start_controllers: ['wheel_vel_controller']"

多控制器切换是实际需求:底盘低速用速度控制、高速/云台用位置控制(RoboMaster 步兵的底盘跟随云台模式就是 velocity/position 控制器动态切换)。controller_manager 支持运行中切换,切换时注意指令平滑——直接从位置控制切到速度控制,速度指令突变会让电机瞬间过流。

四、TF2 坐标变换:正确用法与常见错误

广播与监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 广播:里程计 odom → base_link(每帧更新)
static tf2_ros::TransformBroadcaster br;
geometry_msgs::TransformStamped t;
t.header.stamp = ros::Time::now();
t.header.frame_id = "odom";
t.child_frame_id = "base_link";
t.transform.translation.x = x_; // 里程计积分位置
t.transform.rotation = quat_; // 航向(四元数!)
br.sendTransform(t);

// 监听:查任意两坐标系(带超时,避免"还没收到就查")
tf2_ros::Buffer buffer;
tf2_ros::TransformListener listener(buffer);
try {
auto tf = buffer.lookupTransform("base_link", "sensor_laser",
ros::Time(0), ros::Duration(1.0));
} catch (tf2::TransformException& e) {
ROS_WARN_THROTTLE(1.0, "%s", e.what());
}

静态变换(雷达相对底盘的安装位姿)用命令行一条搞定,不用写代码:

1
2
rosrun tf2_ros static_transform_publisher 0.15 0 0.05 0 0 0 \
base_link sensor_laser

三个高频坑

  1. TF 树必须是一棵树,不能成环。odom → base_link → sensor_laser 是链;如果两个节点同时广播 base_link → odom,TF 树成环,所有 lookup 失败。用 rosrun tf2_tools view_frames.py 生成树图,一眼看出谁在乱广播
  2. 时间戳不同步:lookupTransform 默认查 ros::Time(0)(最新可用),但传感器消息里 header.stamp 是采集时刻——如果 TF 广播滞后(比如 odom 10Hz、图像 30Hz),图像时间戳对应的 TF 还没到,报 extrapolation into the past。解法:lookupTransform(..., msg.header.stamp, timeout) 加超时等待,别用 Time(0) 硬查(那会拿到错误时刻的变换)
  3. 四元数 vs 欧拉角:yaw 转四元数用 tf::createQuaternionFromRPY(0,0,yaw),别手写 sin/cos——方向搞反(左手系)会让机器人”原地掉头跑”

为什么 odom 帧必须存在

move_base/AMCL/导航全套依赖 odom → base_link 变换。它的语义是”以起点为原点的里程计坐标系“:谁广播它?你的轮速里程计或 IMU 融合节点。没有 odom 帧,代价地图、路径规划全部罢工。调试顺序里第一步永远是看 TF 树:

1
2
rosrun tf2_tools view_frames.py   # 生成 frames.pdf 检查树结构
rosrun tf tf_echo odom base_link # 看变换是否持续更新

五、ROS 与 STM32 通信:串口协议设计

主控(NUC/树莓派)跑 ROS,STM32 跑底层电机控制(CAN 总线接 M3508/C620),二者之间是 UART 串口(CH340/CP2102 转 USB)。**”能通”和”可靠”之间隔着一整套协议设计**:

1
2
3
4
┌──────────┐   UART 115200   ┌──────────────┐   CAN 1Mbps   ┌──────────┐
│ ROS 主控 │ ←────────────→ │ 串口转 USB │ ←──────────→ │ STM32 │
│ (NUC/Pi) │ │ (CH340/CP2102)│ │ (电机控制)│
└──────────┘ └──────────────┘ └──────────┘

帧格式与带宽预算

1
2
// 自定义帧:帧头 + 长度 + 类型 + 数据 + CRC16 + 帧尾
// | 0xAA 0x55 | len | type | payload... | crc16(2B) | 0x0D 0x0A |

带宽预算先算清楚:115200 波特率 8N1 下有效速率 11520 B/s。一条电机指令帧约 20 字节(含开销)→ 11520/20 ≈ 576 帧/秒。四轮 + 云台两轴共 6 个电机,每个 100Hz 指令 = 600 帧/秒——正好卡在极限。结论:

  • 控制频率 100Hz×6 电机就必须上 460800 波特率,或把多条指令合并成一帧(批量下发)
  • 状态回报(位置/转速/电流)每条 15 字节 × 6 电机 × 100Hz = 9000 B/s,又是 80% 占用——主控和 STM32 双向都要算,别只算下发
  • 设计原则:下发合并、上报精简(状态只报需要的字段,别把调试数据全塞进主链路)

必须有的三个机制

  1. CRC16 校验:串口是裸字节流,电气噪声/线缆干扰会翻位。没有校验的协议在实验室好好的,上赛场第一天就随机乱动。校验不过的帧直接丢弃(丢一帧控制指令好过执行错误指令)
  2. 粘包/拆包处理:串口是流式的,一帧可能分两次到达、两帧可能粘在一起。收端必须做状态机缓冲:找帧头 → 收满 len 字节 → 校验 → 解析,任何一步失败回到找帧头。用 boost::asio::read 按字节读 + 自管缓冲区,或 serial::read 循环
  3. 心跳与断线重连:STM32 侧 100ms 收不到主控心跳就进入安全模式(电机电流清零/保持),主控侧 serial::open 失败要自动重试(USB 转串口会掉线,拔插后 /dev/ttyUSB0 重新出现,ros::serial 不自动重连——写一个 watchdog 定时器定期检查并重开)

自定义消息设计

1
2
3
4
5
# 电机指令(主控 → STM32)
std_msgs/Header header
uint8 mode # 0=电流 1=速度 2=位置
float32[6] cmd # 各电机指令值
uint8 enable # 使能位
1
2
3
4
5
6
# 电机状态(STM32 → 主控)
std_msgs/Header header
float32[6] position
float32[6] velocity
float32[6] current
uint8[6] online # 各电机在线标志(CAN 断线检测)

消息设计两个要点:带 header.stamp(时间戳对齐是 TF/控制的前提);带在线标志——电机掉线是常态(CAN 线松、电调保护),主控要能区分”电机没动”和”电机掉线”,这决定了控制算法是继续执行还是进安全模式。rm_pid_tuner 的 PidParams.msg 同样带 header + 明确的单位注释,消息字段自解释是 ROS 工程的硬规范。

六、从建模到运动:完整流程与调试顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. CAD 建模(SolidWorks/Fusion)→ 导出 STL + URDF(注意单位!)
↓
2. 手改 xacro:collision 简化、inertial 填真实值
↓
3. check_urdf + RViz 验证:模型显示正常、坐标系朝向正确
↓
4. Gazebo 加载:物理正常(不飞、不穿地、轮子能转)
↓
5. 加 transmission + gazebo_ros_control 插件
↓
6. 配置 controllers.yaml + spawner,rostopic pub 测试运动
↓
7. 写 TF 广播(odom → base_link),view_frames 验证 TF 树
↓
8. 接入真实硬件:STM32 串口协议 + 电机使能/心跳
↓
9. 闭环调试:先开环验证方向,再上 PID(见 PID 篇)

调试顺序铁律:先 TF、再开环、后闭环。顺序颠倒的典型翻车:直接上 PID 位置闭环,车原地打转——先查方向对不对(rostopic pub 单轴正转验证),再查 TF 对不对,最后才查控制参数。每换一层,先把上一层用最便宜的手段验证掉。

踩坑清单

  1. collision 用精细网格:碰撞检测卡爆 + 网格互嵌,一律用 box/cylinder 近似
  2. inertial 为零/不真实:Gazebo 物理爆炸(车飞上天);惯量按几何公式算,别瞎填
  3. CAD 导出单位错:毫米当米用,机器人 1000 倍大小;mesh 路径改 package:// 形式
  4. 仿真不开噪声:仿真稳如狗、真机第一帧崩;激光/IMU 噪声按真机手册配
  5. read/write 里做耗时操作:控制循环被拖垮,数据搬运与通信线程分离
  6. 控制器切换指令突变:position↔velocity 切换要做指令平滑,否则电机过流
  7. TF 成环/时间戳错位:view_frames 查树、lookupTransform 用消息时间戳 + 超时
  8. 串口无校验/无心跳:裸协议上赛场必翻车;CRC + 心跳 + 断线重连三件套
  9. 忘算带宽:115200 只够 6 电机×100Hz 的合并帧,双向都算,不够就提波特率