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

ItemTarget
Flight controllerPX4 FMU v6C with HITL firmware, SYS_AUTOSTART=1001
USBHost owns the serial device and forwards it to the Ubuntu VM over TCP
Gazebo ClassicRuns hitl_iris.world and talks to the board through /tmp/ttyPX4
QGroundControlConnects to the HITL vehicle on UDP 14550
Health checksEKF 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 itemRecommendation
PixhawkOff
SiK RadioOff
LibrePilotOff
UDPOn
Zero-ConfOptional
RTKOff
NMEA GPSDisabled
Check USB ownership:
lsof /dev/ttyACM0
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:
ProcessPurpose
host serial bridgeOpens $PX4_USB, listens on TCP 5760
VM socatConnects to host 5760, creates /tmp/ttyPX4
VM QGC relayListens on VM 14551, forwards to host 14551
host QGC relayListens on host 14551, forwards to QGroundControl 14550
gzserverRuns 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:
SymptomCauseFix
Found 0 compassSYS_HAS_MAG=1 but no healthy sensor_magStart sensor_mag_sim
No heading referenceEKF has no yaw referenceKeep SYS_HAS_MAG=1 and start simulated mag
XY_POSITION_CONTROL health=falseHorizontal EKF fusion is not readyWait for HITL data; check simulated mag and GPS
No manual control inputNo RC/Joystick inputUse 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
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.