Gazebo Classic HITL
This page documents the PX4 HITL runtime path: a real flight controller is connected over USB, Gazebo Classic runs in an Ubuntu VM, and QGroundControl connects over UDP for telemetry, arming, mode changes, and missions.
This flow can arm a real flight controller. Remove propellers, disconnect motor power, and make sure QGroundControl does not connect to both the USB flight controller and the HITL UDP path at the same time.
Target State
| Item | Target |
|---|
| Flight controller | PX4 FMU v6C with HITL firmware, SYS_AUTOSTART=1001 |
| USB | Host owns the serial device and forwards it to the Ubuntu VM over TCP |
| Gazebo Classic | Runs hitl_iris.world and talks to the board through /tmp/ttyPX4 |
| QGroundControl | Connects to the HITL vehicle on UDP 14550 |
| Health checks | EKF has attitude, position, and heading; a normal arm command is accepted |
Runtime Topology
If the USB serial device is visible directly inside the Ubuntu VM, the host serial bridge and socat PTY are optional. Point iris_hitl.sdf at the real serial device instead. In Orb/VM environments where USB is not exposed to the VM, use the bridge topology above.
1. QGroundControl Settings
QGroundControl should not take the flight controller USB port during HITL runtime. Open Application Settings -> Comm Links:
| AutoConnect item | Recommendation |
|---|
| Pixhawk | Off |
| SiK Radio | Off |
| LibrePilot | Off |
| UDP | On |
| Zero-Conf | Optional |
| RTK | Off |
| NMEA GPS | Disabled |
Check USB ownership:
Use the actual serial device name if it differs from /dev/ttyACM0. During this runtime, QGroundControl should connect through UDP 14550.
2. Prepare Gazebo Classic
Build the Gazebo Classic plugins in the Ubuntu VM:
export PX4_DIR="$HOME/src/PX4-Autopilot"
cd "$PX4_DIR"
export PATH="$HOME/.local/bin:$PATH"
DONT_RUN=1 make px4_sitl_default gazebo-classic -j2
Verify the plugins:
test -f "$PX4_DIR/build/px4_sitl_default/build_gazebo-classic/libgazebo_mavlink_interface.so"
test -f "$PX4_DIR/build/px4_sitl_default/build_gazebo-classic/libgazebo_motor_model.so"
3. Start the HITL Runtime
Use the helper script from the Open Platform docs:
export PX4_DIR="$HOME/src/PX4-Autopilot"
export ORB_VM="px4-hitl-classic-amd64"
export PX4_USB="/dev/ttyACM0"
./scripts/px4/start-gazebo-classic-hitl.sh
The script starts:
| Process | Purpose |
|---|
| host serial bridge | Opens $PX4_USB, listens on TCP 5760 |
VM socat | Connects to host 5760, creates /tmp/ttyPX4 |
| VM QGC relay | Listens on VM 14551, forwards to host 14551 |
| host QGC relay | Listens on host 14551, forwards to QGroundControl 14550 |
gzserver | Runs hitl_iris.world |
The script copies the model to /tmp/px4-hitl-models and patches only the temporary copy:
<serialDevice>/tmp/ttyPX4</serialDevice>
<qgc_udp_port>14551</qgc_udp_port>
4. PX4 Parameters and Simulated Magnetometer
Open the PX4 shell:
MAVLINK20=1 python3 "$PX4_DIR/Tools/mavlink_shell.py" udp:127.0.0.1:14550
Set and save the key parameters:
param set SYS_AUTOSTART 1001
param set SYS_HAS_MAG 1
param set COM_RC_IN_MODE 1
param save
reboot
Do not write integer parameters with a hand-written MAVLink PARAM_SET script. SYS_AUTOSTART, SYS_HAS_MAG, and COM_RC_IN_MODE are integer parameters, and a wrong float/bit encoding can produce values such as 1082130432. Use the PX4 shell and run param save.
After reboot, reconnect the runtime and start the simulated magnetometer:
sensor_mag_sim start
sensor_mag_sim status
listener sensor_mag 1
Expected:
sensor_mag_sim status
running
TOPIC: sensor_mag
device_id: ... SIMULATION:1
Why this matters:
| Symptom | Cause | Fix |
|---|
Found 0 compass | SYS_HAS_MAG=1 but no healthy sensor_mag | Start sensor_mag_sim |
No heading reference | EKF has no yaw reference | Keep SYS_HAS_MAG=1 and start simulated mag |
XY_POSITION_CONTROL health=false | Horizontal EKF fusion is not ready | Wait for HITL data; check simulated mag and GPS |
No manual control input | No RC/Joystick input | Use COM_RC_IN_MODE=1; autonomous modes do not require RC |
5. Verify Flyable State
Check health and try a normal arm command:
/usr/bin/python3 - <<'PY'
from pymavlink import mavutil
import time
m = mavutil.mavlink_connection("udp:127.0.0.1:14550", source_system=243, source_component=201)
m.wait_heartbeat(timeout=8)
end = time.time() + 8
last_sys = None
last_est = None
while time.time() < end:
msg = m.recv_match(type=["SYS_STATUS", "ESTIMATOR_STATUS", "STATUSTEXT"], blocking=True, timeout=1)
if not msg:
continue
if msg.get_type() == "SYS_STATUS":
last_sys = msg
elif msg.get_type() == "ESTIMATOR_STATUS":
last_est = msg
elif msg.get_type() == "STATUSTEXT":
print("STATUSTEXT", msg.severity, repr(msg.text))
print("SYS_STATUS", last_sys)
print("ESTIMATOR_STATUS", last_est)
m.mav.command_long_send(
m.target_system,
m.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0,
1,
0,
0,
0,
0,
0,
0,
)
end = time.time() + 7
while time.time() < end:
msg = m.recv_match(type=["COMMAND_ACK", "STATUSTEXT", "HEARTBEAT"], blocking=True, timeout=1)
if not msg:
continue
print(msg)
PY
Success:
COMMAND_ACK {command : 400, result : 0, ...}
HEARTBEAT ... base_mode : 189 ...
result : 0 means the normal arm command was accepted. When base_mode includes the armed bit, QGroundControl should leave Not Ready.
6. Troubleshooting
QGroundControl is connected but still shows Not Ready
Do not rely only on old vehicle messages. Trigger one arm attempt and capture the latest reason:
/usr/bin/python3 - <<'PY'
from pymavlink import mavutil
import time
m = mavutil.mavlink_connection("udp:127.0.0.1:14550")
m.wait_heartbeat(timeout=8)
m.mav.command_long_send(m.target_system, m.target_component, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, 0, 1, 0, 0, 0, 0, 0, 0)
end = time.time() + 8
while time.time() < end:
msg = m.recv_match(type=["COMMAND_ACK", "STATUSTEXT"], blocking=True, timeout=1)
if msg:
print(msg)
PY
If PX4 only reports Resolve system health failures first, decode SYS_STATUS and focus on 3D_MAG, RC_RECEIVER, and XY_POSITION_CONTROL.
QGroundControl does not show a vehicle
Check host UDP ports:
lsof -nP -iUDP:14550 -iUDP:14551
Expected:
QGroundControl ... UDP *:14550
Python ... UDP 127.0.0.1:14551
Inside the VM:
ss -lunp | grep 14551
ps -eo pid,comm,args | grep -E 'gzserver|socat|python3' | grep -v grep
The link breaks after a flight controller reboot
USB CDC re-enumeration invalidates the old serial file descriptor. Restart the bridge, PTY, and Gazebo in order:
pkill -f px4-hitl-bridge || true
orb -m "$ORB_VM" bash -lc "pkill -f 'PTY,link=/tmp/ttyPX4' || true; pkill -x gzserver || true; rm -f /tmp/ttyPX4"
./scripts/px4/start-gazebo-classic-hitl.sh
Then start the simulated magnetometer again:
sensor_mag_sim start
sensor_mag_sim status
EKF2_MAG_TYPE invalid, resetting to default
EKF2_MAG_TYPE=0 is the default Automatic mode and is acceptable for this bench. If you see abnormal huge integer values in param show COM_RC_IN_MODE, fix them from the PX4 shell:
param set COM_RC_IN_MODE 1
param save
reboot
7. Stop the Runtime
pkill -f px4-hitl-bridge || true
pkill -f px4-qgc-host-relay || true
orb -m "$ORB_VM" bash -lc "pkill -f qgc-relay-vm || true; pkill -f 'PTY,link=/tmp/ttyPX4' || true; pkill -x gzserver || true"
A successful HITL arm only proves the bench link, parameters, and simulation loop. Before real flight, complete sensor calibration, orientation checks, safety switch checks, failsafe configuration, mission boundaries, and field safety checks.