ROS 2 Learning Path  ยท  Illustrated Educational Book

Chapter 4: The ARCHO Body with URDF and Xacro

From an empty XML file to a simulation-ready differential-drive robot
Prerequisite: Chapters 1 to 3
Ongoing project: the ARCHO robot
Tools: Xacro, check_urdf
Study time: 120 to 150 minutes
What's in this chapter 4.1Before the code: ARCHO's full architecture 4.2One project, five packages 4.3What URDF and Xacro are 4.4base_footprint and base_link 4.5Building the body: Visual, Collision, Inertial 4.6Drive wheels with a macro 4.7Caster wheel, LiDAR, and IMU 4.8Converting and validating the model 4.9Summary, glossary, and exercises

4.1Before the code: ARCHO's full architecture

Until now, ARCHO existed only in our minds and in a few simple nodes. From this chapter onward, we give it a physical body for the first time. But before opening any editor, let's sidestep a common beginner mistake: rushing to write code. A professional robotics engineer, before writing a single line of code, asks a simple question: "What parts do I have, and how do these parts connect to each other?"

ARCHO is a Differential Drive robot: two main wheels, each independently controlled, and one caster wheel for balance. If the speeds of the two wheels are equal, the robot goes straight; if one is faster, it turns; and if one goes forward while the other goes backward, the robot rotates almost about its own axis โ€” exactly what's needed to maneuver through the narrow aisles of a warehouse.

Differential Drive Motion Model Both wheels equal Straight Right wheel faster โ†– Turn left Wheels opposite โ†ป Spin in place
Figure 4.1 โ€” The three basic behaviors of a differential-drive robot, achieved just by changing the speed ratio of the two wheels.

The complete ARCHO project is made up of eight layers, each built on top of the previous one:

flowchart TB A["URDF / Xacro
Robot Body"] --> B["Gazebo
Physical Simulation"] B --> C["ros2_control
Commanding Motion"] A --> D["TF2
Component Positions"] D --> E["Odometry
Motion Estimation"] E --> F["SLAM / AMCL
Mapping and Localization"] F --> G["Navigation2
Autonomous Path Planning"] style A fill:#eef0ff,stroke:#3d4bf5,color:#211f1a,font-weight:bold style G fill:#eafaf3,stroke:#0e9e6e,color:#211f1a,font-weight:bold
๐Ÿง  The golden rule of robotics projects

Each layer must be tested on its own before being connected to the next layer. If one day ARCHO doesn't move in Nav2, the problem could be a wrong URDF, a wrong wheel direction, an incomplete TF, incorrect odometry, a wrong LiDAR, or a poorly tuned controller. If you build everything at once, finding the cause is nearly impossible; if you move forward step by step and verify each stage, the problem is always exactly identifiable. That's why this chapter and the following ones each end with a defined checkpoint.

4.2One project, five packages

In Chapter 2, a single package was enough for learning purposes. But a real robot project is usually split across several packages โ€” exactly the way a factory is divided into several specialized departments, rather than one big, crowded hall.

archo_ws/
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ archo_description/   # Robot body: URDF/Xacro, meshes, RViz
    โ”œโ”€โ”€ archo_gazebo/        # Simulation world, worlds
    โ”œโ”€โ”€ archo_bringup/       # Main launch that starts everything
    โ”œโ”€โ”€ archo_slam/          # SLAM settings and maps
    โ””โ”€โ”€ archo_navigation/    # Nav2 settings
Benefit of splitting into multiple packagesExplanation
Easier maintenanceEach package has only one responsibility
Easier debuggingThe problem is immediately confined to a specific area
ReusabilityFor example, archo_description can also be used in a different project
Industry alignmentAlmost all open-source and industrial ROS 2 projects follow this same pattern

In this chapter, we only work with archo_description โ€” the package responsible for answering the question: "What does the robot look like, what parts does it have, and where is each part relative to the others?"

cd ~/archo_ws/src
ros2 pkg create archo_description --build-type ament_cmake
cd archo_description
mkdir -p urdf meshes launch rviz config
๐Ÿ“– Why ament_cmake instead of ament_python?

Packages that mainly contain URDF, Xacro, launch, and config files โ€” not executable Python code โ€” are usually built with ament_cmake. This build model is better suited to installing and managing non-executable files. Packages that contain Python nodes (like archo_bringup in the previous chapter) are usually ament_python.

4.3What URDF and Xacro are

๐Ÿ“– Definition

URDF (Unified Robot Description Format) is the standard format for describing a robot's physical structure in ROS: what parts it has, where the joints are, what each part's visual appearance and physical collision shape are, and what its mass and moment of inertia are. Xacro is an added layer on top of URDF that allows defining variables, calculations, macros, and splitting a file into multiple parts โ€” because writing raw URDF for a robot with dozens of parts quickly becomes unmanageable.

Xacro โ†’ URDF โ†’ robot_state_publisher โ†’ TF + RobotModel

Let's create the main file:

touch ~/archo_ws/src/archo_description/urdf/archo.urdf.xacro
<?xml version="1.0"?>
<robot
  xmlns:xacro="http://www.ros.org/wiki/xacro"
  name="archo">
</robot>

The line xmlns:xacro="..." enables Xacro features such as <xacro:property> and <xacro:macro>. Every link, joint, and macro must be placed between this same opening and closing <robot> tag.

4.4base_footprint and base_link

The first frame we create is not the robot's actual body โ€” it's a hypothetical frame on the ground surface:

<link name="base_footprint"/>
๐Ÿง  Why not place the body directly on the ground?

The center of the body is usually above the ground โ€” for example, if the body height is 0.18 meters, its center is about 0.09 meters above the ground. But Navigation2 likes to have a frame exactly on the ground, with no roll or pitch. That's why we create two frames: base_footprint on the ground, and base_link, which is the body's main physical frame and the one to which all other parts (wheels, LiDAR, IMU) are attached.

ARCHO's Two Base Frames base_link (body center) Fixed joint โ€” half the body height base_footprint Ground surface
Figure 4.2 โ€” base_footprint sits exactly on the ground; base_link is at the geometric center of the body, connected to it with a fixed joint.
๐Ÿ”ง ROS axis convention

In ROS, it's always: X = forward, Y = robot's left, Z = up. You must keep this convention in mind from the very start โ€” if you get the directions wrong, the robot might drive backward, the LiDAR might read reversed, or rotations might become inconsistent with TF.

4.5Building the body: Visual, Collision, Inertial

First, instead of hardcoded numbers, we define the body dimensions as properties โ€” exactly the same parameter philosophy we learned in Chapter 1, this time in the world of URDF:

<xacro:property name="base_length" value="0.50"/>
<xacro:property name="base_width"  value="0.36"/>
<xacro:property name="base_height" value="0.18"/>
<xacro:property name="base_mass"   value="12.0"/>
โš ๏ธ All dimensions are in meters

URDF uses SI units. Writing <box size="50 36 18"/> means a body 50 by 36 by 18 meters โ€” a building, not a robot! The correct value is 0.50 0.36 0.18.

Now let's build base_link itself with a custom color:

<material name="archo_blue">
  <color rgba="0.12 0.35 0.70 1.0"/>
</material>

<link name="base_link">
  <visual>
    <origin xyz="0 0 0" rpy="0 0 0"/>
    <geometry>
      <box size="${base_length} ${base_width} ${base_height}"/>
    </geometry>
    <material name="archo_blue"/>
  </visual>

  <collision>
    <origin xyz="0 0 0" rpy="0 0 0"/>
    <geometry>
      <box size="${base_length} ${base_width} ${base_height}"/>
    </geometry>
  </collision>

  <inertial>
    <origin xyz="0 0 0" rpy="0 0 0"/>
    <mass value="${base_mass}"/>
    <inertia
      ixx="${base_mass * (base_width*base_width + base_height*base_height) / 12.0}"
      ixy="0.0" ixz="0.0"
      iyy="${base_mass * (base_length*base_length + base_height*base_height) / 12.0}"
      iyz="0.0"
      izz="${base_mass * (base_length*base_length + base_width*base_width) / 12.0}"/>
  </inertial>
</link>
PartWhat question it answers
visualWhat should it look like in RViz/Gazebo?
collisionWhat shape should the physics engine use for collisions?
inertialWhat are the mass, center of mass, and resistance to rotation?
๐ŸŒ Why we keep collision simple

The visual appearance of the body can be a complex, beautiful mesh from a CAD program, but collision is usually a simple shape like a box. If the physics engine had to compute collisions on a mesh with millions of faces, the simulation would become severely slow. This is exactly the trade-off between visual fidelity and computational performance that we'll return to in the next chapter when working with Gazebo.

Finally, we connect the two frames with a fixed joint:

<joint name="base_footprint_joint" type="fixed">
  <parent link="base_footprint"/>
  <child link="base_link"/>
  <origin xyz="0 0 ${base_height / 2.0}" rpy="0 0 0"/>
</joint>

The value ${base_height / 2.0} is not arbitrary: since the body's frame is at the center of the box, for the bottom of the body to sit exactly on the ground, its center must be half the height above base_footprint.

โš ๏ธ Common mistake: half the body below ground

If you write this origin as xyz="0 0 0", the center of the body ends up exactly on the ground, and as a result half the body sinks into the ground โ€” the most common mistake the first time you write this joint.

Easy exercise

Write out the full file above, then change the body dimensions to 0.55 ร— 0.40 ร— 0.20 meters. What should the new origin value in the joint be?

4.6Drive wheels with a macro

ARCHO's wheels are built as cylinders. But there's a subtle point: in URDF, a cylinder's long axis is by default along Z, whereas the wheel needs to rotate about the Y axis. So we rotate the geometry 90 degrees about X.

<xacro:property name="pi" value="3.141592653589793"/>
<xacro:property name="wheel_radius" value="0.09"/>
<xacro:property name="wheel_width" value="0.04"/>
<xacro:property name="wheel_mass" value="0.8"/>
<xacro:property name="wheel_y_offset" value="0.20"/>

Instead of writing a repetitive block for the left wheel and again for the right wheel, we create a macro โ€” exactly the same "don't repeat yourself" principle we follow in programming:

<xacro:macro name="drive_wheel" params="prefix y_position">
  <link name="${prefix}_wheel_link">
    <visual>
      <origin xyz="0 0 0" rpy="${pi/2.0} 0 0"/>
      <geometry>
        <cylinder radius="${wheel_radius}" length="${wheel_width}"/>
      </geometry>
      <material name="wheel_black"/>
    </visual>
    <collision>
      <origin xyz="0 0 0" rpy="${pi/2.0} 0 0"/>
      <geometry>
        <cylinder radius="${wheel_radius}" length="${wheel_width}"/>
      </geometry>
    </collision>
    <inertial>
      <mass value="${wheel_mass}"/>
      <origin xyz="0 0 0" rpy="0 0 0"/>
      <inertia
        ixx="${wheel_mass * (3*wheel_radius*wheel_radius + wheel_width*wheel_width) / 12.0}"
        ixy="0" ixz="0"
        iyy="${wheel_mass * wheel_radius * wheel_radius / 2.0}"
        iyz="0"
        izz="${wheel_mass * (3*wheel_radius*wheel_radius + wheel_width*wheel_width) / 12.0}"/>
    </inertial>
  </link>

  <joint name="${prefix}_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="${prefix}_wheel_link"/>
    <origin xyz="0 ${y_position} 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
  </joint>
</xacro:macro>

<xacro:drive_wheel prefix="left"  y_position="${wheel_y_offset}"/>
<xacro:drive_wheel prefix="right" y_position="${-wheel_y_offset}"/>
QuestionAnswer
Why type="continuous" and not revolute?revolute has an angle limit (e.g. ยฑ90ยฐ); the wheel needs to be able to rotate infinitely
Why axis xyz="0 1 0"?It means the wheel rotates about the Y axis; if it's wrong, the wheel spins like a coin or the robot doesn't move
Why a macro instead of copy-paste?A single change (e.g. wheel radius) is applied in one place and both wheels update automatically
Medium exercise

Suppose you want to build a larger version of ARCHO with a wheel radius of 0.12 meters and a wheel separation of 0.30 meters. By changing only the properties (not the macro itself), how would this be done?

4.7Caster wheel, LiDAR, and IMU

Two drive wheels alone cannot keep the body balanced; an auxiliary caster wheel is needed. For simplicity, we build it as a sphere โ€” a sphere has less lateral friction and makes the simulation more stable:

<link name="caster_link">
  <visual><geometry><sphere radius="0.04"/></geometry></visual>
  <collision><geometry><sphere radius="0.04"/></geometry></collision>
  <inertial>
    <mass value="0.15"/>
    <inertia ixx="0.000096" ixy="0" ixz="0" iyy="0.000096" iyz="0" izz="0.000096"/>
  </inertial>
</link>

<joint name="caster_joint" type="fixed">
  <parent link="base_link"/>
  <child link="caster_link"/>
  <origin xyz="-0.18 0 -0.05" rpy="0 0 0"/>
</joint>

Now the LiDAR and IMU โ€” the same two sensors whose glossary entries we saw in Chapter 1 โ€” get physical frames here for the first time:

<link name="laser_link">
  <visual><geometry><cylinder radius="0.045" length="0.04"/></geometry></visual>
  <collision><geometry><cylinder radius="0.045" length="0.04"/></geometry></collision>
  <inertial>
    <mass value="0.2"/>
    <inertia ixx="0.0002" ixy="0" ixz="0" iyy="0.0002" iyz="0" izz="0.0002"/>
  </inertial>
</link>

<joint name="laser_joint" type="fixed">
  <parent link="base_link"/>
  <child link="laser_link"/>
  <origin xyz="0.12 0 0.16" rpy="0 0 0"/>
</joint>

<link name="imu_link">
  <visual><geometry><box size="0.04 0.03 0.015"/></geometry></visual>
</link>

<joint name="imu_joint" type="fixed">
  <parent link="base_link"/>
  <child link="imu_link"/>
  <origin xyz="0 0 0.11" rpy="0 0 0"/>
</joint>
๐Ÿง  Why is the IMU mounted near the center of mass?

When the IMU is far from the robot's center of rotation, the accelerations caused by rotation also enter its measurements, making the data noisier. That's why the IMU is usually mounted as close as possible to the geometric/mass center of the body.

Our TF tree structure now looks like this:

graph TD F["base_footprint"] --> B["base_link"] B --> LW["left_wheel_link"] B --> RW["right_wheel_link"] B --> C["caster_link"] B --> L["laser_link"] B --> I["imu_link"] style F fill:#eafaf3,stroke:#0e9e6e style B fill:#eef0ff,stroke:#3d4bf5

This is exactly the same TF tree we'll discuss in full detail in Chapter 5.

4.8Converting and validating the model

A Xacro file is only useful once it's converted to pure URDF and validated. First, source the workspace:

dev@archo:~$ xacro ~/archo_ws/src/archo_description/urdf/archo.urdf.xacro -o /tmp/archo.urdf dev@archo:~$ check_urdf /tmp/archo.urdf robot name is: archo ---------- Successfully Parsed XML --------------- root Link: base_footprint has 1 child child(1): base_link child(1): left_wheel_link child(2): right_wheel_link child(3): caster_link child(4): laser_link child(5): imu_link

To see the full structure diagram:

cd /tmp && urdf_to_graphiz archo.urdf && xdg-open archo.pdf
Common errorSymptomSolution
Unclosed XML tagmismatched tagCheck the number of opening and closing tags
Undefined property (e.g. base_lenght)Xacro error during conversionCompare the property's spelling with its definition
Zero massGazebo becomes unstable in the next chaptermass must always be positive
Wrong units (centimeters instead of meters)Giant or microscopic robotURDF always works in meters

Finally, set up the files to be installed during build โ€” add this section to CMakeLists.txt:

install(
  DIRECTORY urdf meshes launch rviz config
  DESTINATION share/${PROJECT_NAME}
)
cd ~/archo_ws
colcon build --symlink-install --packages-select archo_description
source install/setup.bash
ros2 pkg prefix archo_description
โœ… Chapter 4 checkpoint
  • xacro archo.urdf.xacro -o /tmp/archo.urdf runs without errors.
  • check_urdf outputs the message Successfully Parsed XML.
  • The link tree includes base_footprint, base_link, two wheels, caster, laser, and imu.
  • ros2 pkg prefix archo_description returns the path of the installed package.

4.9Chapter 4 summary

For the first time, ARCHO went from being a name on paper to a valid geometric model: a body with correct mass and inertia, two drive wheels connected with continuous-type joints, a caster wheel, and defined mounting locations for the LiDAR and IMU. The robot still doesn't move at all and doesn't exist in any virtual world yet โ€” that's exactly what we'll do in the next chapter with Gazebo.

Connection to the main project

ARCHO project now has a valid, validated archo.urdf.xacro file that will be used as the robot's physical foundation in all subsequent chapters โ€” Gazebo, ros2_control, TF2, SLAM, and Nav2.

What the next chapter adds

In Chapter 5, we'll take a close look at the TF tree we just built with URDF: how ROS 2 tracks the position of each robot part relative to the others โ€” and relative to the world.

Chapter 4 glossary

URDF
Unified Robot Description Format; the standard format for describing a robot's physical structure in ROS.
Xacro
A layer on top of URDF that adds variables (properties), calculations, and macros.
Link
A rigid part of the robot (body, wheel, sensor).
Joint
The kinematic or fixed relationship between two links; common types: fixed, continuous, revolute.
base_footprint
A hypothetical frame on the ground surface, with no roll/pitch; the main reference for Navigation.
base_link
The robot body's main physical frame; the attachment point for other parts.
check_urdf
A command-line tool for validating the XML structure and link/joint tree of a URDF file.

Chapter 4 common mistakes โ€” summary