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_lib2library — see Part 2.5 and Part 4.1.
0.2 Core ROS2 concepts
| Concept | Role | Analogy |
|---|---|---|
| Node | One independent executable process, does one small job | process |
| Topic + msg | One-way async pub/sub, one-to-many | broadcast channel |
| Service + srv | Bidirectional request/response, one-to-one | function call |
| Action | Long task + progress + cancel (topic+service based) | task with progress |
| Parameter | Runtime-editable node config (YAML) | config item |
| Launch | Bring up many nodes/params/remaps at once | startup script |
| tf2 | Transform tree,统一管理 (unifies) link poses | coordinate bus |
| DDS | Low-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:
| Item | Value |
|---|---|
| Onboard compute | Raspberry Pi Compute Module 5 Lite (BCM2712, aarch64, 4 cores) |
| OS | Ubuntu 26.04 LTS, kernel 7.0.0-1015-raspi |
| Memory | 3.9 GB |
| ROS2 distro | lyrical (matches Ubuntu 26.04), prefix /opt/ros/lyrical/ |
| Python | System 3.14.4 (ROS2 metadata at /opt/ros/lyrical/lib/python3.14/site-packages/) |
| Build tool | colcon (/usr/bin/colcon) |
| Source | /etc/apt/sources.list.d/ros2.sources |
| Core pkgs | rclcpp rclpy ros2cli sensor_msgs tf2 rosbag2_* urdf_parser_plugin … |
| User | luwu (sudo password luwu) |
Note:
lyricalis the actual distro name on the robot (ROS2 tracks Ubuntu versions; 26.04 →lyrical). On another machine you may havehumble/jazzyetc. — command structure is identical, just replacelyricalwith yourROS_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.pyusually needs no rebuild; withament_cmake, editing C++ requirescolcon buildagain.
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
| Package | Responsibility | Depends on |
|---|---|---|
quad_description | URDF + display/launch | robot model files |
quad_hardware | serial bridge; pub joint_states/imu; sub joint_command | deploy/xgo_lib2 comms lib |
quad_controller | geometric gait, or load .onnx and infer; pub joint_command | onnxruntime (if using a policy); not the training framework |
quad_teleop | keyboard/gamepad → /cmd_vel; mode switch | — |
quad_bringup | top-level launch; bring up the above + robot_state_publisher | — |
Only
quad_hardwaremust depend onxgo_lib2— it is the robot's single comms entry point.quad_controllercan 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 + configureCMakeLists.txt/package.xml; aftercolcon 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:
| Module | Purpose |
|---|---|
client.py → XgoDog | High-level API: read version/battery, write servo angles, read IMU, enable feedback |
protocol.py | Frame packing / checksum / parsing (header 55 00, tail 00 AA) |
feedback.py → SendStateParser | Parses the ~50 Hz push-state frames (254 B) into DeviceState snapshots |
registry.py | Field registry for all main/sub codes; feedback_slot_of(motor_id) maps feedback slots |
errors.py | XgoError / XgoNotSupportedError |
protocol.md | Full 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):
- Units are degrees, not radians. ROS
sensor_msgs/JointStateconventionally uses radians — convert in the bridge node. - 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. - 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
| Scenario | Recommended |
|---|---|
| Velocity command | geometry_msgs/Twist (/cmd_vel de-facto standard) |
| Joint state | sensor_msgs/JointState |
| IMU | sensor_msgs/Imu |
| Odometry | nav_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:
XgoDog("/dev/ttyAMA0")opens the port;system_set_feedback(1)enables the push feedback;- A dedicated thread keeps parsing frames with
SendStateParser.feed_bytes(); - Convert degrees → radians and publish
/joint_states; assemble attitude + acceleration into/imu(angular velocity differentiated across consecutive attitude frames); - Subscribe
/joint_command(radians) → convert to degrees → write servo angles by motor ID; - 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:
- Subscribe
/cmd_vel+/joint_states+/imu; - Build the observation vector per the layout agreed at training time;
session.run()→ action ×action_scale+ default pose → target joint angles;- 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_commandto verify publish rate truly hits 50 Hz; - Control loop via
rclpycreate_timer(1/50.0, cb)orrate = 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
| Symptom | Root cause | Fix |
|---|---|---|
PackageNotFoundError: ros2cli | non-interactive shell not sourced | first line: source /opt/ros/lyrical/setup.bash |
ros2: command not found | env not activated | login shell check /etc/profile.d/ros2.sh; source manually |
serial Permission denied | user not in dialout | sudo usermod -aG dialout $USER (launcher here already adds dialout) |
| topics invisible across machines | ROS_DOMAIN_ID mismatch | both sides export ROS_DOMAIN_ID=0 |
| control drops frames / stutters | low memory or parallel build fights | free -h; colcon build --parallel-workers 2 |
| TF broken / no model in RViz | URDF joint names ≠ /joint_states | check joint name order; robot_state_publisher logs |
| node fights the screen | wrote /dev/fb-spi | visualize via HDMI/remote; log to stdout |
| ros2 fails under systemd | profile.d not loaded | source 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, pressCto exit back to desktop); nodes run asluwuneed correct source/env; reading camera needsvideogroup (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
