本页目录

ROS

ROS2 Quadruped Robot Development Tutorial


Part 0 — ROS2 Concepts and Environment Overview

0.1 Why a quadruped needs ROS

The most naive approach is a single-process script: open the serial port, loop reading feedback, compute a control value, send it down. With "one robot, fixed control logic" that is simple and efficient. But as the system grows — camera fusion, SLAM/navigation, teleop, visualization, multi-machine coordination — you hit:

  • how multiple processes exchange data loosely coupled (joint states, IMU, commands, maps);
  • how modules are developed/replaced independently (swap a gait algorithm without touching the driver);
  • how to standardize messages and keep a unified clock / coordinates.

ROS2 answers these with a "node graph + DDS-based publish/subscribe" model. It does not write your control algorithm for you; it provides a communication middleware + toolchain so you can wire "hardware driver / control policy / decision planning / visualization" into an observable, re-connectable graph.

Note ROS2 does not do control algorithms, and does not do training. It answers "how do I wire a pile of processes into a system". For serial comms with the robot, just use the deploy/xgo_lib2 library — see Part 2.5 and Part 4.1.

0.2 Core ROS2 concepts

ConceptRoleAnalogy
NodeOne independent executable process, does one small jobprocess
Topic + msgOne-way async pub/sub, one-to-manybroadcast channel
Service + srvBidirectional request/response, one-to-onefunction call
ActionLong task + progress + cancel (topic+service based)task with progress
ParameterRuntime-editable node config (YAML)config item
LaunchBring up many nodes/params/remaps at oncestartup script
tf2Transform tree,统一管理 (unifies) link posescoordinate bus
DDSLow-level real-time middleware (default RMW)transport layer

Minimal runnable loop (pseudo-code idea):

[teleop_node] --/cmd_vel(Twist)--> [controller_node] --/joint_command--> [hardware_node] --serial--> robot
[hardware_node] --/joint_states--> [robot_state_publisher] --/tf--> RViz

0.3 The robot's onboard environment

All commands below assume execution on the robot's onboard compute unit:

ItemValue
Onboard computeRaspberry Pi Compute Module 5 Lite (BCM2712, aarch64, 4 cores)
OSUbuntu 26.04 LTS, kernel 7.0.0-1015-raspi
Memory3.9 GB
ROS2 distrolyrical (matches Ubuntu 26.04), prefix /opt/ros/lyrical/
PythonSystem 3.14.4 (ROS2 metadata at /opt/ros/lyrical/lib/python3.14/site-packages/)
Build toolcolcon (/usr/bin/colcon)
Source/etc/apt/sources.list.d/ros2.sources
Core pkgsrclcpp rclpy ros2cli sensor_msgs tf2 rosbag2_* urdf_parser_plugin
Userluwu (sudo password luwu)

Note: lyrical is the actual distro name on the robot (ROS2 tracks Ubuntu versions; 26.04 → lyrical). On another machine you may have humble/jazzy etc. — command structure is identical, just replace lyrical with your ROS_DISTRO.


Part 1 — Environment and Dev Workspace

1.1 Environment activation

ROS2 is not "works after install" — the key is whether setup.bash got sourced. The robot has three activation paths:

(1) Interactive login shell — already auto-sourced

/etc/profile.d/ros2.sh is configured (2026-08-28):

#!/bin/sh
if [ -f /opt/ros/lyrical/setup.sh ]; then
    . /opt/ros/lyrical/setup.sh
fi

Any login shell (HDMI getty, luwu / root terminal) auto-sources on start; ros2 works out of the box. Use .sh (setup.sh) not .bash so plain shells/scripts also load, and it is idempotent (PATH deduped).

(2) Non-interactive shell (script / cron / CI / agent session) — MUST source manually ⚠️

profile.d and .bashrc are not loaded, so PYTHONPATH is empty. Typical error:

importlib.metadata.PackageNotFoundError: No package metadata was found for ros2cli

This is NOT a broken environment — just unsourced. Source it as the first line of any script:

#!/usr/bin/env bash
source /opt/ros/lyrical/setup.bash   # required in non-interactive shells
ros2 pkg list

(3) systemd service — does NOT load profile.d ⚠️

When a node runs as a systemd service, profile.d is not triggered. Source inside ExecStart, or at least set Environment=ROS_DOMAIN_ID=0:

[Service]
Environment=ROS_DOMAIN_ID=0
ExecStart=/bin/bash -c 'source /opt/ros/lyrical/setup.bash; exec /path/to/your_node'

1.2 Build a workspace from scratch

source /opt/ros/lyrical/setup.bash
mkdir -p ~/ros2_ws/src && cd ~/ros2_ws

# Create a package (pick one)
ros2 pkg create my_pkg --build-type ament_python   # Python node (recommended to start)
ros2 pkg create my_pkg --build-type ament_cmake    # C++ node

colcon build --symlink-install                     # --symlink-install: skip reinstall on .py edits
source install/setup.bash                          # activate this workspace
ros2 run my_pkg my_node

Workspace overlay:底层 (base) ROS → your workspace, order matters:

source /opt/ros/lyrical/setup.bash     # base (ROS itself)
source ~/ros2_ws/install/setup.bash    # overlay (your packages)

With ament_python + --symlink-install, editing .py usually needs no rebuild; with ament_cmake, editing C++ requires colcon build again.

1.3 Resource and build constraints

  • Free memory ~1.7 GB. Running large inference + many nodes together can OOM; monitor with free -h / ps.
  • aarch64 compiles slowly and eats RAM — cap parallelism:
colcon build --symlink-install --parallel-workers 2   # don't fight launcher/terminal for resources
  • Display: the SPI 320×240 screen is monopolized by the launcher desktop + custom terminal; use HDMI or remote (ssh + VNC) for RViz2 etc. Nodes must not write /dev/fb-spi (conflicts with launcher); log to stdout / journal.

1.4 Software source and adding packages

Example adds:

sudo apt update
sudo apt install ros-lyrical-gazebo-ros-pkgs   # simulation (not installed by default here)
sudo apt install ros-lyrical-demo-nodes-cpp     # demo nodes

Verify install: source /opt/ros/lyrical/setup.bash && ros2 pkg list | wc -l (316 onboard).


Part 2 — Typical Quadruped ROS Package Architecture

2.1 Three-layer responsibility split

┌─────────────────────────────────────────────┐
│ Decision/Teleop   teleop · behavior select · command gen   │
├─────────────────────────────────────────────┤
│ Control layer     geometric gait · trained policy · kinematics │  ← optional impl, unrelated to ROS
├─────────────────────────────────────────────┤
│ Hardware abst.    IMU · servo/joint · serial comms         │  ← calls xgo_lib2 directly
└─────────────────────────────────────────────┘

Loose coupling benefit: swapping the control algorithm (geometric gait ↔ trained policy) only replaces the "control layer" node; hardware and teleop layers stay untouched.

2.2 Recommended package split

PackageResponsibilityDepends on
quad_descriptionURDF + display/launchrobot model files
quad_hardwareserial bridge; pub joint_states/imu; sub joint_commanddeploy/xgo_lib2 comms lib
quad_controllergeometric gait, or load .onnx and infer; pub joint_commandonnxruntime (if using a policy); not the training framework
quad_teleopkeyboard/gamepad → /cmd_vel; mode switch
quad_bringuptop-level launch; bring up the above + robot_state_publisher

Only quad_hardware must depend on xgo_lib2 — it is the robot's single comms entry point. quad_controller can compute however it likes; ONNX is just one option.

2.3 Node graph and topic conventions

teleop_node
   └─(pub)──> /cmd_vel            (geometry_msgs/Twist)        desired lin/ang velocity
controller_node
   ├─(sub)──> /cmd_vel
   ├─(sub)──> /joint_states       (sensor_msgs/JointState)     actual joint angles
   ├─(sub)──> /imu                (sensor_msgs/Imu)            body accel/ang-vel
   └─(pub)──> /joint_command      (custom/sensor_msgs)         target joint angles
hardware_node
   ├─(sub)──> /joint_command
   ├─(pub)──> /joint_states
   ├─(pub)──> /imu
   └─(pub)──> /odom               (nav_msgs/Odometry)          odometry (if any)
robot_state_publisher
   ├─(sub)──> /joint_states
   └─(pub)──> /tf                 (tf2)                        transform tree

2.4 Custom msg / srv

Joint commands are best as a custom message (clearer and extensible vs many floats):

# quad_msgs/msg/JointCommand.msg
std_msgs/Header header
float32[] position      # target joint angles (rad), length = num joints
float32[] velocity      # target joint velocities (optional)
float32[] effort        # torques (optional)

Mode switch (e.g. walk ↔ turn) fits a Service or Topic+param:

# quad_msgs/srv/SetMode.srv
string mode             # "walk" | "turn" | "stand"
---
bool success
string message

Build custom interfaces: put msg/ srv/ dirs in the package + configure CMakeLists.txt/package.xml; after colcon build, ROS2 auto-generates Python/C++ types.

2.5 XGO adaptation: use the xgo_lib2 comms library directly

Your ROS nodes should not hand-roll the serial protocol — deploy/xgo_lib2 already did it:

ModulePurpose
client.pyXgoDogHigh-level API: read version/battery, write servo angles, read IMU, enable feedback
protocol.pyFrame packing / checksum / parsing (header 55 00, tail 00 AA)
feedback.pySendStateParserParses the ~50 Hz push-state frames (254 B) into DeviceState snapshots
registry.pyField registry for all main/sub codes; feedback_slot_of(motor_id) maps feedback slots
errors.pyXgoError / XgoNotSupportedError
protocol.mdFull protocol doc (frame structure, function codes, feedback layout)

Minimal usage — this is the core of the quad_hardware node:

from xgo_lib2.client import XgoDog

with XgoDog("/dev/ttyAMA0") as dog:
    print(dog.read_version(), dog.system_read_battery())
    dog.system_set_feedback(1)      # enable ~50 Hz state push
from xgo_lib2.feedback import SendStateParser

parser = SendStateParser()
for st in parser.feed_bytes(ser.read(4096)):
    st.battery                 # battery %
    st.roll, st.pitch, st.yaw  # attitude (**degrees**)
    st.joint_pos_deg           # 16 joint angles (deg)
    st.joint_vel_deg_s         # 16 joint velocities (deg/s)

Three protocol facts you must know (from xgo_lib2/protocol.md):

  1. Units are degrees, not radians. ROS sensor_msgs/JointState conventionally uses radians — convert in the bridge node.
  2. There is no raw gyro in the feedback frame. The valid attitude field is euler2 (offsets 20–31); angular velocity must be differentiated across frames.
  3. Gravity has no dedicated slot — derive it by normalizing acceleration (|acc| ≈ 9.8 at rest).

Feedback is ~50 Hz × 254 B, so the baud rate must keep up (deployment practice uses 2,000,000).


Part 3 — Communication, Messages / tf2

3.1 Standard vs custom messages

ScenarioRecommended
Velocity commandgeometry_msgs/Twist (/cmd_vel de-facto standard)
Joint statesensor_msgs/JointState
IMUsensor_msgs/Imu
Odometrynav_msgs/Odometry
Joint command (multi-DOF, with mode)custom JointCommand.msg

Reuse standard messages whenever possible — then rviz, ros2 bag, and 3rd-party nodes recognize them directly.

3.2 Frequency and QoS

Quadruped control loop is typically 50 Hz (this repo's deploy is 50 Hz). Pub/sub must match:

# rclpy example: reliable + keep-latest
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy
ctrl_qos = QoSProfile(
    reliability=ReliabilityPolicy.RELIABLE,     # control commands must not drop
    durability= DurabilityPolicy.VOLATILE,
    history=HistoryPolicy.KEEP_LAST,
    depth=1,                                     # keep only the latest frame
)
self.pub = self.create_publisher(JointCommand, '/joint_command', ctrl_qos)

Sensor data (/joint_states /imu) can use SensorDataQoS (reliable, depth=10, keep latest) or BEST_EFFORT to lower latency.

3.3 tf2 transform tree

Common quadruped frames (parent → child):

odom → base_link → imu_link
                → base_footprint
                → <leg>_hip → <leg>_thigh → <leg>_calf → <leg>_foot  (×4)
  • odom: world-fixed frame, odometry origin;
  • base_link: body center;
  • leg chain: hip→thigh→calf→foot per leg, from URDF <joint> hierarchy.

robot_state_publisher subscribes /joint_states and auto-publishes the whole /tf tree from URDF; IMU's static transform uses static_transform_publisher or a fixed URDF joint.


Part 4 — Control Stack Implementation

This section explains "how nodes are written and how data loops close" — no runnable code (per agreement this material is documentation). The approach maps directly onto Part 2's packages.

4.1 Hardware bridge node

This is the only node that touches hardware, and its core is xgo_lib2:

  1. XgoDog("/dev/ttyAMA0") opens the port; system_set_feedback(1) enables the push feedback;
  2. A dedicated thread keeps parsing frames with SendStateParser.feed_bytes();
  3. Convert degrees → radians and publish /joint_states; assemble attitude + acceleration into /imu (angular velocity differentiated across consecutive attitude frames);
  4. Subscribe /joint_command (radians) → convert to degrees → write servo angles by motor ID;
  5. Handle dropouts/checksum errors: if feedback goes stale (e.g. no new frame for 0.5 s), stop sending so servos hold their last target, and publish diagnostics.

Key: decouple serial I/O from ROS callbacks — run the serial loop in a dedicated thread; the ROS side only publishes/subscribes, never blocks the executor.

4.2 Control node

The interesting part of this node has nothing to do with ROS: it just "subscribes state → computes target angles → publishes commands". Two routes:

Route A: geometric gait (no RL needed) Generate foot trajectories from phase → inverse kinematics → joint angles. Pure geometry, easy to debug, good for getting the pipeline working first.

Route B: load a trained .onnx policy Use onnxruntime to read a policy file and infer. This is just reading a file — no dependency on the training framework's code:

  1. Subscribe /cmd_vel + /joint_states + /imu;
  2. Build the observation vector per the layout agreed at training time;
  3. session.run() → action × action_scale + default pose → target joint angles;
  4. Publish /joint_command (50 Hz, same rate as the hardware node).

The observation layout is a training-side convention, not a ROS concern. Policies exported by this repo's training framework use ang_vel(3) + gravity(3) + cmd(3) + phase(2) + joint_pos(J) + joint_vel(J) + prev_action(J), i.e. obs_dim = 11 + 3J, and write dims / joint names / action_scale / default pose into the ONNX metadata so a node can self-check at startup. Details in ../rl_tutorial/tutorial_en.md.

Critical consistency: observation layout, units, normalization, and action scale MUST match training exactly, or the policy destabilizes on hardware from distribution shift.

4.3 Teleop node

  • Keyboard/gamepad → publish /cmd_vel (lin vel x/y, ang vel yaw);
  • Mode switch: call SetMode.srv (walk/turn/stand); the control node switches its internal state machine accordingly.

4.4 Clock and frequency consistency

  • Use ros2 topic hz /joint_command to verify publish rate truly hits 50 Hz;
  • Control loop via rclpy create_timer(1/50.0, cb) or rate = self.create_rate(50);
  • Across machines (robot ↔ host): set the same ROS_DOMAIN_ID (default 0), or topics are invisible to each other.

Part 5 — Build, Run and Troubleshooting

5.1 Build and launch

source /opt/ros/lyrical/setup.bash
cd ~/ros2_ws
colcon build --symlink-install --parallel-workers 2
source install/setup.bash

# terminal 1: hardware + control
ros2 launch quad_bringup bringup.launch.py
# terminal 2: teleop
ros2 run quad_teleop teleop_keyboard

bringup.launch.py uses LaunchDescription to bring up hardware_node / controller_node / robot_state_publisher together, passing params and doing topic remaps.

5.2 Runtime introspection

ros2 node list                 # which nodes are up
ros2 topic list               # which topics exist
ros2 topic echo /joint_states # see message content
ros2 topic hz /joint_command  # see publish rate
ros2 node info /controller_node  # see a node's pub/sub

5.3 Troubleshooting table

SymptomRoot causeFix
PackageNotFoundError: ros2clinon-interactive shell not sourcedfirst line: source /opt/ros/lyrical/setup.bash
ros2: command not foundenv not activatedlogin shell check /etc/profile.d/ros2.sh; source manually
serial Permission denieduser not in dialoutsudo usermod -aG dialout $USER (launcher here already adds dialout)
topics invisible across machinesROS_DOMAIN_ID mismatchboth sides export ROS_DOMAIN_ID=0
control drops frames / stutterslow memory or parallel build fightsfree -h; colcon build --parallel-workers 2
TF broken / no model in RVizURDF joint names ≠ /joint_statescheck joint name order; robot_state_publisher logs
node fights the screenwrote /dev/fb-spivisualize via HDMI/remote; log to stdout
ros2 fails under systemdprofile.d not loadedsource inside ExecStart, or Environment=ROS_DOMAIN_ID=0

Robot-system specifics: launcher mounts apps via /tmp/luwu_run.fifo (echo "path" > /tmp/luwu_run.fifo, press C to exit back to desktop); nodes run as luwu need correct source/env; reading camera needs video group (vision part omitted from this material).


Appendix: Command cheat-sheet

source /opt/ros/lyrical/setup.bash
ros2 --version                          # CLI version
echo $ROS_DISTRO                        # → lyrical
ros2 pkg list                           # list all packages (316 here)
ros2 pkg executables rclpy              # executables in a package
ros2 node list / ros2 topic list        # runtime introspection
ros2 topic echo /topic                  # see message
ros2 topic hz /topic                    # see rate
ros2 run <pkg> <node>                   # run a node
colcon build --symlink-install --parallel-workers 2   # build