A worked example of running multiple Webots robots whose firmware executes inside cycle-accurate gem5 STM32G474RE simulations. Sensor data flows out of Webots, through a small bridge framework, into the firmware running on simulated hardware, and motor commands flow back the same way every simulation step.
This directory is a self-contained reference you can fork to build your own multi-robot experiments — your own robots, your own firmware, your own gem5 board.
The example: a leader drone flies a fixed rectangular patrol while a chaser drone tracks the leader's LED beacon using its own light sensors. Both drones' flight controllers run as bare-metal C firmware on separate simulated STM32G474RE chips (one gem5 process each).
┌───────────────────────────┐
│ Webots (1 process) │
│ ┌────────┐ ┌────────┐ │
│ │ Leader │ │ Chaser │ │
│ │ ctrl │ │ ctrl │ │
│ └───┬────┘ └────┬───┘ │
└──────┼────────────┼───────┘
│ │ AF_UNIX sockets
┌───────▼────────────▼───────┐
│ Bridge helper (this proc)│
│ matches name → server │
└───────┬────────────┬───────┘
│ │
┌────────────────▼─┐ ┌───▼────────────────┐
│ gem5 (leader) │ │ gem5 (chaser) │
│ STM32G474RE │ │ STM32G474RE │
│ + MProfBridgeIO │ │ + MProfBridgeIO │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │leader.elf│ │ │ │chaser.elf│ │
│ └──────────┘ │ │ └──────────┘ │
└──────────────────┘ └────────────────────┘
- Quick start
- First-time setup
- Running the example — step by step
- Repository layout
- How the pieces fit together
- The bridge protocol
- The MProfileBridgeIO peripheral
- Firmware side
- Webots-controller side
- gem5-script side
- Per-step data flow
- Recipe — adding your own robot
After First-time setup (build gem5 in
external/gem5/, install the bridge package into a venv):
# 1. Build firmware
cd gem5/firmware && make && cd ../..
# 2. Activate the venv that has the `bridge` package
source .venv/bin/activate
# 3. Run
python3 run_example.py \
--gem5-path external/gem5/build/ARM/gem5.opt \
--webots-path /path/to/webotsThe full step-by-step walkthrough below explains each prerequisite, what to expect, and where the logs land.
This directory expects two external repos to live under external/:
webots-gem5-example/
└── external/
├── gem5/ # gem5 source tree (the iiswc fork — adds STM32G474RETimingBoard etc.)
└── bridge/ # AF_UNIX bridge framework (provides the `bridge` Python package)
These are git submodules in the released form of this repo. After cloning, populate them with:
git submodule update --init --recursive(If external/gem5 and external/bridge are already present from a
manual clone, you can skip this.)
Do this before building gem5 — gem5's build uses Python (scons + its own requirements), and keeping everything in one venv avoids mixing system Python with the venv that the launcher / controllers / gem5 script will all share at run time.
The launcher, the gem5 script, and every Webots controller all
import bridge._bridge. That package isn't on PyPI — install it
editable from the bundled source:
python3 -m venv .venv
source .venv/bin/activate
pip install -e external/bridge/pythonVerify the install:
python3 -c "import bridge._bridge as b; print('bridge ok:', b)"
# bridge ok: <module 'bridge._bridge' from '...'>The same venv must stay active for step 2 (gem5 build) and for
every later run — Webots launches its controllers under whatever
Python is on PATH at the moment Webots itself is started, and they
all need to find bridge._bridge.
With the venv from step 1 still active:
The STM32G474RETimingBoard, MProfileBridgeIO, and ArmMSystem
semihosting support all live inside this gem5 fork. Build the ARM
target:
cd external/gem5
pip install -r requirements.txt # gem5's Python build deps (into the same venv)
scons build/ARM/gem5.opt -j$(nproc)
cd ../..The result is external/gem5/build/ARM/gem5.opt — pass this as
--gem5-path when running run_example.py.
How gem5_script.py finds the board:
from gem5.prebuilt.cortexm.boards.stm32g474re_board import STM32G474RETimingBoardgem5.opt automatically puts its bundled src/python/ tree on
sys.path, so this import resolves to
[external/gem5/src/python/gem5/prebuilt/cortexm/boards/stm32g474re_board.py](external/gem5/src/python/gem5/prebuilt/cortexm/boards/stm32g474re_board.py)
without any explicit PYTHONPATH setup.
These two come from your distro / Cyberbotics; nothing to install
under external/. Versions known to work:
arm-none-eabi-gcc --version # 10.x or newer
webots --version # R2023a or newer| Tool | What it must contain | How to check |
|---|---|---|
arm-none-eabi-gcc |
GNU ARM Embedded toolchain (any 10.x or newer) | arm-none-eabi-gcc --version |
gem5.opt (ARM target) |
A gem5 build that includes STM32G474RETimingBoard, MProfileBridgeIO, ArmMSystem semihosting — the build at external/gem5/build/ARM/gem5.opt after step 1 of First-time setup |
` --help |
| Webots executable | Webots R2023a or later | <webots-path> --version |
| Python 3 venv | bridge package (provides bridge._bridge) installed in editable mode — see step 2 of First-time setup |
python3 -c "import bridge._bridge as b; print(b)" |
If bridge._bridge isn't on sys.path, the launcher and every
Webots controller will crash with ModuleNotFoundError: bridge when
they try to import it.
From the project root (this directory):
cd gem5/firmware
makeExpected output: a few arm-none-eabi-gcc lines and no errors. After
the build you should have:
gem5/firmware/build/leader.elf
gem5/firmware/build/chaser.elf
gem5/firmware/build/leader.sections # objdump -h dump (for sanity-check)
gem5/firmware/build/chaser.sections
Sanity check the layout:
arm-none-eabi-readelf -h build/leader.elf | grep Entry
# Entry point address: 0x8000041 (anything in 0x080000xx with Thumb LSB set)
arm-none-eabi-objdump -d --section=.isr_vector build/leader.elf | head -8
# Expect first word = 0x20018000 (initial MSP at top of contiguous SRAM)
# Second word = 0x080000xx (Reset_Handler with Thumb LSB)To rebuild after editing firmware sources:
make # incremental
make clean && make # full rebuildThe launcher and the Wheaebots controllers all import bridge._bridge.
Both must run inside the same venv that has it installed:
source <path-to-venv-with-bridge>/bin/activate
python3 -c "import bridge._bridge as b; print('bridge ok:', b)"
# bridge ok: <module 'bridge._bridge' from '...'>If the import fails, find the venv where bridge was installed (look
for a directory containing pyvenv.cfg near the gem5 source you built
from).
From this directory, with the venv still active:
python3 run_example.py \
--gem5-path /path/to/gem5.opt \
--webots-path /path/to/webotsDefault-derived paths (used if you don't pass the corresponding flag):
| Flag | Default |
|---|---|
--gem5-script |
gem5/gem5_script.py |
--gem5-leader-binary |
gem5/firmware/build/leader.elf |
--gem5-chaser-binary |
gem5/firmware/build/chaser.elf |
--webots-world |
webots/worlds/crazyflie_dual_gem5.wbt |
--output-dir |
output/ |
So once the firmware is built, the only required arguments are
--gem5-path and --webots-path.
Headless / fast options (forwarded to Webots):
| Flag | Effect |
|---|---|
--headless |
Shortcut: --webots-mode=fast --no-rendering --batch. Maximum throughput, no GUI interaction. Webots still needs an X display — wrap with xvfb-run for a truly headless host. |
--webots-mode=MODE |
realtime (default), fast (no realtime sync, implies no rendering), or pause. |
--no-rendering |
Skip 3D rendering; physics + controllers still tick. Big speed win. |
--no-minimize |
Don't start the Webots window minimized (the default is minimized). |
--batch |
Non-interactive: no save dialogs / quit prompts. |
Example:
python3 run_example.py --gem5-path ... --webots-path ... --headless
# or for a truly headless host:
xvfb-run -a python3 run_example.py --gem5-path ... --webots-path ... --headlessThe launcher's stdout, in order:
[launcher] Starting bridge helper server...
Server socket setup at /tmp/bridge_socket.sock
[launcher] Helper listening on fd 3
[launcher] Starting webots: ...
[launcher] Starting gem5-leader: ...
[launcher] Starting gem5-chaser: ...
[launcher] All processes started. Entering bridge helper loop...
WARNING: Accepted new connection: 4
Received command 1 from fd 4
Registered server gem5-leader with pid <PID1>
WARNING: Accepted new connection: 4
Received command 1 from fd 4
Registered server gem5-chaser with pid <PID2>
INFO: supervisor_dual: Starting controller: ...
INFO: crazyflie_leader_gem5: Starting controller: ...
INFO: crazyflie_chaser_gem5: Starting controller: ...
WARNING: Accepted new connection: 4
Received command 2 from fd 4
Sent server pid <PID1> to client Crazyflie_Leader
WARNING: Accepted new connection: 4
Received command 2 from fd 4
Sent server pid <PID2> to client Crazyflie_Chaser
[launcher] Bridge matchmaking done. Keeping launcher alive...
[Supervisor] Monitoring leader and chaser drones...
[Leader-gem5] Connecting to gem5 bridge as 'Crazyflie_Leader'...
[Leader-gem5] Connected to gem5 server (pid=<PID1>, fd=6)
[Leader-gem5] Sent SETUP_TIMESTEP (32 ms)
[Leader-gem5] Beacon LED ON. All compute via gem5. Taking off...
[Chaser-gem5] Connecting to gem5 bridge as 'Crazyflie_Chaser'...
[Chaser-gem5] Connected to gem5 server (pid=<PID2>, fd=7)
[Chaser-gem5] Sent SETUP_TIMESTEP (32 ms)
[Chaser-gem5] All compute via gem5. Taking off...
Then, every ~1 second of simulated time, you should see lines like:
[Supervisor] t=1.0s Leader=(+2.00, +0.00, 0.74) Chaser=(+0.00, +0.00, 0.71) Dist=2.000m
[Leader-gem5] t=1.0 pos=(2.00,0.00,0.74) yaw=0.00
[Chaser-gem5] t=1.0 pos=(0.00,0.00,0.71) light F=0.0 L=0.0 R=0.0 B=0.0
Per-firmware progress lives in output/gem5-<server>-m5out/simout.txt:
[leader-fw] Starting firmware (float, IRQ-driven, waypoint patrol)
[crazyflie-gem5] Firmware booted (exit: simulate() limit reached)
Dumped stats at tick 32007544138
Dumped stats at tick 64007071110
Dumped stats at tick 96007080406
...
[leader-fw] Takeoff complete. Starting patrol.
Each Dumped stats at tick N = one Webots step where the firmware
finished compute in time and signalled DONE. The deltas between dumps
should be ~32 ms (the basicTimeStep in the world file). After about
1 second of simulated time both drones cross the takeoff threshold and
their respective firmwares print their Takeoff complete lines.
Press Ctrl+C in the launcher terminal. The launcher will:
- Catch the
KeyboardInterrupt. terminate()the Webots and both gem5 subprocesses (with a 5 s grace period beforekill()).- Print exit codes and
[launcher] Done.
Webots' GUI also has a stop button — using it shuts Webots down, and both gem5 instances will then exit cleanly when their controllers disconnect.
output/
├── gem5-leader-m5out/
│ ├── simout.txt # firmware semihosting prints + per-step "Dumped stats" markers
│ ├── simerr.txt # gem5 warnings / panics
│ ├── stats.txt # gem5 performance counters (per m5.stats.dump())
│ ├── config.{ini,json,dot,svg,pdf}
│ └── citations.bib
└── gem5-chaser-m5out/
└── (same)
Plus, in each Webots controller directory (because the controllers log locally):
webots/controllers/crazyflie_leader_gem5/leader_gem5_log.csv
webots/controllers/crazyflie_chaser_gem5/chaser_gem5_log.csv
Each row in those CSVs is one Webots step with sensor inputs, motor outputs, wall-clock compute time, and the gem5 tick count the firmware took.
The launcher creates output/ on demand and overwrites the m5out
files inside it. No manual cleanup needed between runs, but you can
zap the directory if you want a fresh slate:
rm -rf output/The CSVs in the controller directories are also overwritten each run
(the controllers open them with "w").
webots-gem5-example/
├── run_example.py # launcher — spawns webots + N gem5 procs + bridge helper
├── external/ # git submodules — see "First-time setup"
│ ├── gem5/ # iiswc gem5 fork: STM32G474RETimingBoard, MProfileBridgeIO, ArmMSystem semihosting
│ └── bridge/ # AF_UNIX bridge framework + `bridge` Python package
├── gem5/
│ ├── gem5_script.py # one parameterised gem5 config — used by every gem5 instance
│ └── firmware/
│ ├── Makefile # builds leader.elf + chaser.elf
│ ├── linker/cortexm4.ld # STM32G474RE memory map (also exports `end` for newlib's _sbrk)
│ ├── startup/startup.c # vector table + Reset_Handler + FPU enable (pure C)
│ ├── common/ # shared firmware code
│ │ ├── cmsis.h # minimal CMSIS-Core API: __WFI, NVIC_EnableIRQ, SysTick_Config, ...
│ │ ├── semihosting.c # _write etc. — routes printf through bkpt #0xab to gem5 stdout
│ │ └── pid.{c,h} # velocity-fixed-height PID (float)
│ ├── leader/leader_app.c # waypoint patrol logic
│ └── chaser/chaser_app.c # light-tracking chase logic
└── webots/
├── worlds/crazyflie_dual_gem5.wbt
├── protos/ # Crazyflie_Patrol, Crazyflie_Chaser
├── meshes/ # Collada / texture assets the PROTOs reference
└── controllers/
├── crazyflie_leader_gem5/ # bridge client → gem5-leader
├── crazyflie_chaser_gem5/ # bridge client → gem5-chaser
└── supervisor_dual/ # logging only
The simulation has three independent processes per robot, glued by the bridge framework:
- Webots — the physics simulator. It spawns one Python controller per robot. The controller reads the robot's sensors, sends them to gem5, gets motor commands back, and writes them to the actuators.
- gem5 — one cycle-accurate simulation per robot. It instantiates
a virtual STM32G474RE (the
STM32G474RETimingBoard), attaches anMProfileBridgeIOperipheral, and loads the firmware ELF. - The bridge helper — a tiny matchmaker. It runs in this directory's launcher process. It accepts connections from both gem5 servers and Webots clients and pairs them up by name.
After matchmaking, the bridge helper's job is done — Webots controller and gem5 talk to each other directly over a per-pair AF_UNIX socket.
The bridge is not the simulation clock. The clock is Webots — it calls
robot.step(timestep), the controller blocks until gem5 has replied, gem5 blocks until the controller has sent input. The two sims advance in lockstep one ~32 ms window at a time.
The bridge framework lives in the bridge Python package
(import bridge._bridge as br). It exposes:
listen_fd = br.bridge_setup_helper_server_socket()
br.bridge_helper_server_loop(listen_fd, client_to_server)
br.bridge_close_helper_server_socket(listen_fd)client_to_server is a dict keyed by Webots robot name and valued by
the gem5 --server-name:
client_to_server = {
"Crazyflie_Leader": "gem5-leader",
"Crazyflie_Chaser": "gem5-chaser",
}See run_example.py for the working version.
import bridge._bridge as b
client_pid, listen_fd = b.bridge_setup_server("gem5-leader")
msg = b.bridge_wait_for_message(listen_fd, -1) # SETUP_TIMESTEP
# … later, every step:
arr = array.array('B', msg.data) # raw input bytes
# (drive the firmware via MProfileBridgeIO — see below)
b.bridge_send_message(listen_fd, response_msg)See gem5/gem5_script.py lines 75–96 and the main loop at the bottom.
import bridge._bridge as br
server_pid, sock_fd = br.bridge_setup_client(robot.getName()) # e.g. "Crazyflie_Leader"
# one-time setup message:
ts = br.Message()
ts.command = br.COMMAND.SETUP_TIMESTEP
ts.data = struct.pack('<i', timestep)
br.bridge_send_message(sock_fd, ts)
# per step:
req = br.Message()
req.command = br.COMMAND.COMPUTE_REQUEST
req.data = packed_sensor_bytes
resp = br.bridge_send_and_wait_for_response(sock_fd, req, -1) # blocks
motors = struct.unpack('<4I', resp.data[:16])See webots/controllers/crazyflie_leader_gem5/crazyflie_leader_gem5.py.
- The string the controller passes to
bridge_setup_client(...)is the Webots robot name — i.e. thename "..."field of the robot in the world file, accessed viarobot.getName(). - The string the gem5 script passes to
bridge_setup_server(...)is the server name — the launcher passes this to gem5 via--server-name <name>. - The launcher's
client_to_serverdict maps robot name → server name. The bridge helper accepts both a registration from gem5 (I'm server X) and a connection from Webots (I'm client Y), looks upclient_to_server[Y], and if it equals X, it links them.
So adding a robot is: pick a name, put it in the world, put it in the
controller's bridge_setup_client(...) call, and add an entry to the
launcher's client_to_server dict.
MProfileBridgeIO is a memory-mapped device added to the simulated
SoC by the gem5 script. It exposes 6 control registers plus
input and output byte buffers, all in one PIO window.
| Offset | Reg | Direction | Purpose |
|---|---|---|---|
| 0x00 | go |
Python → firmware | Non-zero ⇒ device is enabled. Latched at construction. |
| 0x04 | done |
firmware → gem5 | Firmware writes 1 ⇒ gem5 calls exitSimLoopNow. |
| 0x08 | in_addr |
read-only | Absolute address of the input buffer. |
| 0x0C | in_size |
both ways | gem5 sets via updateInputData(); firmware can clear. |
| 0x10 | out_addr |
read-only | Absolute address of the output buffer. |
| 0x14 | out_size |
firmware → gem5 | Firmware writes the produced byte count. |
board.bridge_io.updateInputData(arr) # writes input buffer + sets in_size
board.bridge_io.raiseInterrupt() # pulses NVIC IRQ on the simulated CPU
board.bridge_io.clearInterrupt() # acks the IRQ
board.bridge_io.getOutputData() # → bytes from output buffer
board.bridge_io.getOutputDataSize() # → reg[5]The firmware sees the device as 6 MMIO registers + two contiguous
buffers. Each step it copies from *in_addr, writes its result to
*out_addr, sets out_size, then writes done = 1 to hand control
back to gem5.
The example firmware is interrupt-driven: the bridge IRQ
(NVIC IRQ 101 on STM32G474RE) is enabled at boot, and raiseInterrupt()
calls from the gem5 side wake the CPU out of WFI and jump straight
into Bridge_IRQ_Handler. All per-step work happens inside that
handler.
This requires three things in the firmware:
- A vector table large enough to hold the IRQ slot (
16 + irq_numentries — for IRQ 101, that's 118 vector words; the unused slots default toDefault_Handler). - A handler at that slot — see
Bridge_IRQ_Handlerin leader_app.c. Annotate it with__attribute__((interrupt("IRQ")))so GCC ensures 8-byte stack alignment on entry and emitsbx lrfor the exception return (instead of the defaultpop {pc}; both are valid on real Cortex-M hardware, butbx lris what gem5's BxMProfile path validates). - One-time NVIC enable at boot:
NVIC_ISER(101 / 32) = 1u << (101 % 32); // ISER[3] |= (1<<5) __asm__ volatile ("cpsie i"); // PRIMASK = 0
The end-of-step handshake. When the firmware writes 1 to
done(offset 0x04),MProfileBridgeIO::write()callsexitSimLoopNow("MProfileBridgeIO signaled done."). That returns control fromm5.simulate(ticks)back to the Python script, which reads the output, callsclearInterrupt(), replies to Webots, then raises the IRQ for the next step and resumes simulation. The handler resumes from after the DONE write, returns, and the NVIC tail-chains straight into the next handler invocation if the next step's IRQ is already pending.
main() does nothing but WFI after setup. When the CPU enters
WFI, gem5 stops scheduling CPU instructions and waits for the next
event on its event queue. If the event queue ever drains to
empty, gem5's main loop exits and the whole gem5 process terminates —
which from the launcher's perspective looks like the gem5 process
disappearing mid-run, killing the bridge connection.
That can happen in our setup: between Webots steps the CPU is in WFI, the bridge IRQ has been serviced and cleared, and there's no other periodic work scheduled. Without something to keep the queue populated, gem5 exits before Python publishes the next input.
To prevent it, the firmware enables SysTick at ~1 kHz via
SysTick_Config() from cmsis.h. As long as SysTick is enabled,
there is always a "next SysTick exception" sitting on gem5's event
queue, so the queue can't drain and the process stays alive between
Webots steps.
The SysTick handler must return (do nothing and bx lr), not
spin — otherwise the very first tick would trap the CPU and the
bridge IRQ would never be serviced. In
startup.c SysTick_Handler is a
weak alias to Empty_Handler (__attribute__((interrupt("IRQ"))),
empty body → bx lr). Faults like HardFault_Handler weak-alias
to Default_Handler (which spins) so a real fault is obvious in
trace.
The firmware is interrupt-driven. After one-time setup, main()
just sleeps in WFI; all real work happens inside the bridge IRQ
handler. Look at leader_app.c:
__attribute__((interrupt("IRQ")))
void Bridge_IRQ_Handler(void)
{
/* Read input from `(float *)BRIDGE_IO_REG_INPUT_START`,
* run your controller, write 4 floats to
* `(float *)BRIDGE_IO_REG_OUTPUT_START`, set OUTPUT_SIZE.
* That's the entire bridge interface from the firmware's side.
*/
/* ... your control logic here ... */
BRIDGE_IO_REG_DONE = 1u; /* → exitSimLoopNow on the gem5 side */
}
int main(void)
{
printf("[leader-fw] Starting firmware ...\n");
init_pid();
/* SysTick at ~1 kHz so gem5 always has a scheduled event while
* the CPU is in WFI (otherwise its event queue could drain to
* empty between Webots steps and the simulator process would
* exit). */
SysTick_Config(170000);
/* Enable bridge IRQ in NVIC and unmask interrupts. */
NVIC_EnableIRQ(101);
__enable_irq();
for (;;) __WFI();
}SysTick_Config, NVIC_EnableIRQ, __enable_irq and __WFI come
from common/cmsis.h — the same
function names you'd use with the official CMSIS-Core headers.
The full sequence per Webots step:
- gem5 publishes a new input window via
updateInputData()and pulses NVIC IRQ 101 viaraiseInterrupt(). - The CPU in WFI wakes, the NVIC dispatches IRQ 101 → enters
Bridge_IRQ_Handler. - The handler reads input, runs the controller, writes output.
- The handler writes
1toBRIDGE_IO_REG_DONE→MProfileBridgeIO::write()callsexitSimLoopNow(...)→ control returns to the gem5 Python script (the handler is paused mid-execution). - Python reads the output, calls
clearInterrupt(), sends the reply to Webots, gets the next sensor frame, callsupdateInputData()+raiseInterrupt(), and resumesm5.simulate(...). - The handler resumes from the instruction after the DONE write, returns; the NVIC sees the new pending IRQ and tail-chains straight into the next handler invocation (no extra wake-up from WFI required).
If no Webots step is in flight, the CPU spends its time in WFI, woken once per millisecond by SysTick (whose handler returns immediately). This keeps the gem5 event queue from going empty — see "Why SysTick is enabled" below.
Bare-metal STM32G4 firmware in this project has three sources:
| File | Role |
|---|---|
| linker/cortexm4.ld | Memory map: 256K flash @ 0x08000000, 96K SRAM @ 0x20000000, MSP at top of SRAM (0x20018000). Also exports end (after .bss) so newlib's _sbrk knows where the heap starts — newlib-nano's printf allocates a small buffer there. |
| startup/startup.c | M-profile vector table (16 system + 102 NVIC IRQ slots, IRQ 101 wired to Bridge_IRQ_Handler) as a C array via designated initialisers. Reset_Handler enables the FPU, copies .data, zeroes .bss, calls main(). Default handlers come in two flavours: Default_Handler (spin — for unexpected faults) and Empty_Handler (bx lr — for SysTick and any benign periodic exception). All C — no .S file. |
| common/cmsis.h | Minimal subset of CMSIS-Core: __WFI, __BKPT, __enable_irq, __DSB/__ISB, NVIC_EnableIRQ, SysTick_Config, plus SCB_CPACR for the FPU enable. Self-contained; no external CMSIS tree required. |
| common/semihosting.c | Provides _write (and a few other newlib syscalls) on top of bkpt #0xab. Lets you call printf / puts / fprintf(stderr, ...) from app code; gem5's ArmSemihosting routes the output to the host's stdout. |
Your app.c |
main() plus Bridge_IRQ_Handler (must be marked __attribute__((interrupt("IRQ"))) so GCC emits bx lr for the return). |
Compiler flags (in the Makefile):
-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
-specs=nano.specs -specs=nosys.specs # newlib-nano + weak syscall stubs
-lm # libm: sqrtf, sinf, cosf, ...
nano.specs pulls in newlib-nano (small printf), and nosys.specs
provides weak stubs for the syscalls we don't override. We override
_write ourselves in
common/semihosting.c so that
calls like printf("hello\n") reach the gem5 host's stdout via
ARM semihosting (bkpt #0xab with op SYS_WRITEC = 0x03):
ssize_t _write(int fd, const void *buf, size_t len)
{
if (fd != 1 && fd != 2) { errno = EBADF; return -1; }
const char *p = buf;
for (size_t i = 0; i < len; i++) {
register int r0 __asm__("r0") = 0x03; /* SYS_WRITEC */
register const char *r1 __asm__("r1") = &p[i];
__asm__ volatile ("bkpt #0xab" :: "r"(r0), "r"(r1) : "memory");
}
return len;
}That single hook + linking with -specs=nano.specs is enough for
printf, puts, fprintf(stderr, ...) etc. to work.
For the output to actually reach the host, the gem5 board must have semihosting enabled:
board.semihosting = ArmSemihosting() # in gem5_script.pyThe Cortex-M4 in STM32G474RETimingBoard has a single-precision FPU
(VFPv4-SP). Building with -mfloat-abi=hard -mfpu=fpv4-sp-d16
generates native vmul.f32/vadd.f32/vsqrt.f32 instructions and
passes float arguments in FP registers.
The FPU must be enabled at boot. Reset_Handler does this by
setting CPACR (0xE000ED88) bits 20–23 to grant CP10/CP11 full
access — see startup/startup.c.
If you forget this, the first FP instruction faults to
UsageFault_Handler and the firmware spins.
For sqrtf/sinf/cosf the firmware links libm (-lm). Lazy FP
state stacking is enabled by default (FPCCR.LSPEN = 1), so exception
entry only spills FP registers if the handler actually uses them — in
our case the bridge IRQ handler does, so it pays a one-time
~80-byte FP-context stack push per invocation.
A controller is a normal Webots Python script. Its job has three parts: connect to the bridge once, then per-step send sensors and receive motors.
Skeleton (extracted from crazyflie_leader_gem5.py):
import struct
import bridge._bridge as br
from controller import Robot
robot = Robot()
timestep = int(robot.getBasicTimeStep())
imu, gps, gyro = robot.getDevice("inertial_unit"), robot.getDevice("gps"), robot.getDevice("gyro")
for d in (imu, gps, gyro): d.enable(timestep)
m1, m2, m3, m4 = (robot.getDevice(f"m{i}_motor") for i in (1, 2, 3, 4))
for m in (m1, m2, m3, m4):
m.setPosition(float('inf'))
# 1. Connect to the bridge using THIS ROBOT's name (matches launcher dict).
server_pid, sock = br.bridge_setup_client(robot.getName())
# 2. One-time SETUP_TIMESTEP message tells the gem5 side what dt to expect.
ts = br.Message()
ts.command = br.COMMAND.SETUP_TIMESTEP
ts.data = struct.pack('<i', timestep)
br.bridge_send_message(sock, ts)
# 3. Per-step: pack sensors as float32, send, wait, unpack motors.
while robot.step(timestep) != -1:
payload = struct.pack('<10f', *sensor_floats)
req = br.Message(); req.command = br.COMMAND.COMPUTE_REQUEST; req.data = payload
resp = br.bridge_send_and_wait_for_response(sock, req, -1)
motors = struct.unpack('<4f', resp.data[:16])
m1.setVelocity(-motors[0]); m2.setVelocity(motors[1])
m3.setVelocity(-motors[2]); m4.setVelocity(motors[3])bridge_send_and_wait_for_response(...) blocks until gem5 has finished
its run-ahead window and replied — that's what makes Webots and gem5
move in lockstep.
The payload contract (what each float means) is between the
controller and the firmware. You must keep the order and field count
in sync between the two. Both sides use little-endian IEEE-754 single
precision (<f in struct, float in C).
gem5/gem5_script.py is firmware-agnostic —
the same script runs the leader and the chaser. It takes
--binary <elf> and --server-name <name> and:
- Builds the
STM32G474RETimingBoard(Cortex-M4, ART caches, real bus topology — seegem5/src/python/gem5/prebuilt/cortexm/boards/stm32g474re_board.pyin your gem5 tree). - Enables semihosting (
board.semihosting = ArmSemihosting()). - Attaches an
MProfileBridgeIOat0x88000000:
board.bridge_io = MProfileBridgeIO(
pio_addr=0x88000000,
scs=board.platform.scs,
irq_num=101,
)
board.bridge_io.pio = board.system_bus.mem_side_ports- Loads the firmware ELF (
board.set_workload(elf)). - Connects to the bridge as
bridge_setup_server(server_name)and reads the SETUP message (the controller's timestep). - Boots the firmware (one
m5.simulate(run_ahead_ticks)— the firmware reaches itsWFIloop and waits for the bridge IRQ). - Enters the main loop:
while exit_message != "exiting with last active thread context":
if exit_message == BRIDGE_DONE_MSG:
# firmware just signalled DONE → read its output, send to Webots
bridge_io_interrupt_work_done()
else:
# tick limit hit → no DONE; wait for the next Webots input
run_ahead_ended()
exit_event = m5.simulate(tick_left)
exit_message = exit_event.getCause()Look at the helpers run_ahead_ended() (publishes a Webots message
into the device with updateInputData(arr) + raiseInterrupt()) and
bridge_io_interrupt_work_done() (reads getOutputData() and replies
to the controller).
The pio_addr 0x88000000 is the firmware-side contract — it must
match the BRIDGE_IO_BASE in your app.c. Pick anything in the SoC's
unmapped peripheral region; just keep both sides in sync.
One simulation step from Webots' perspective:
Webots controller Bridge (sockets) gem5 script Firmware (in gem5)
───────────────── ──────────────── ─────────── ──────────────────
robot.step(timestep) ▲ │ │
│ │ │ │
sensor reads │ │ │
│ │ │ │
pack 10×float32 ──── COMPUTE_REQUEST ────────► recv() │
│ │
bridge_io.updateInputData(arr) ──────────► writes registers[3] = N
bridge_io.raiseInterrupt() ──────────► sets pending bit on NVIC IRQ 101
│ │
m5.simulate(run_ahead_ticks) │
│
CPU exits WFI; NVIC dispatches IRQ 101
Bridge_IRQ_Handler entered
├ decode 10 floats from INPUT
├ run waypoint nav + PID
├ write 4 floats to OUTPUT
├ OUTPUT_SIZE = 16
└ DONE = 1 ──┐
│
exitSimLoopNow ◄────────────────┘
cause = "MProfileBridgeIO signaled done."
│ (handler is paused inside the m5.simulate call)
out = bridge_io.getOutputData()
bridge_io.clearInterrupt()
│
send COMPUTE_RESPONSE ── COMPUTE_RESPONSE ────► recv()
│
unpack motors
m_i.setVelocity(...)
│
back to robot.step()
Next step: Python publishes new input + raiseInterrupt before resuming sim.
When sim resumes, the handler returns; the NVIC tail-chains immediately into
a new Bridge_IRQ_Handler call. The CPU never goes back to WFI between steps
while the simulation is keeping up.
If the firmware doesn't finish within run_ahead_ticks, gem5 hits
the tick limit instead. The script's run_ahead_ended() then sends
the last-known motor values to Webots, fetches the next input from
Webots, publishes it into the device, and resumes the simulation.
That keeps Webots advancing even when gem5 misses a deadline.
To add a robot whose firmware runs on its own gem5 instance:
What sensors will your firmware see, and what does it produce?
For the leader (10 in / 4 out):
Input: x, y, altitude, roll, pitch, yaw, yaw_rate, vx_body, vy_body, dt
Output: m1, m2, m3, m4
This goes in two places: the firmware decoder (a int32_t * cast)
and the controller packer (struct.pack('<10I', ...)). Keep them
in sync.
Copy gem5/firmware/leader/leader_app.c to a new directory and change
the body of Bridge_IRQ_Handler to whatever your robot does (decode
input, run logic, write output, set DONE). Keep the
__attribute__((interrupt("IRQ"))) annotation on the handler. Update
the Makefile to add a new ELF target:
NEW_OBJS = $(COMMON_OBJS) $(BUILD)/new_app.o
$(BUILD)/new.elf: $(NEW_OBJS)
$(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)The main() boilerplate (SysTick + NVIC enable + WFI) and the
BRIDGE_IO_* constants stay the same.
Copy webots/controllers/crazyflie_leader_gem5/crazyflie_leader_gem5.py
to a new controller dir. Change:
bridge_setup_client(robot.getName())(no code change — but pick a unique name in the world file)- the
struct.pack('<10f', ...)to your input shape (count of floats) - the
struct.unpack('<4f', ...)to your output shape
In webots/worlds/crazyflie_dual_gem5.wbt:
DEF MYROBOT_DEF Some_Crazyflie_Variant {
translation 0 -2 0.015
name "MyRobot"
controller "my_robot_gem5"
}
The name is what the controller's bridge_setup_client(...) returns
(via robot.getName()). The controller is the directory name under
webots/controllers/.
In run_example.py:
client_to_server = {
"Crazyflie_Leader": "gem5-leader",
"Crazyflie_Chaser": "gem5-chaser",
"MyRobot": "gem5-myrobot", # add
}
for server_name, binary in [
("gem5-leader", args.gem5_leader_binary),
("gem5-chaser", args.gem5_chaser_binary),
("gem5-myrobot", args.gem5_my_binary), # add
]:
...…and add the corresponding CLI flag. No changes are needed to
gem5_script.py — it's already firmware-agnostic.
If your robot's chip isn't an STM32G474RE, swap the board class in
gem5/gem5_script.py:
from gem5.prebuilt.cortexm.boards.stm32f405_board import STM32F405Board
board = STM32F405Board()You'll also need to:
- Adjust the linker script's
MEMORYblock to your chip's flash/SRAM layout (and_estackto the top of contiguous SRAM). - Rebuild the firmware against the new linker.
- If your chip's NVIC IRQ count differs, the
irq_numyou pass toMProfileBridgeIOmust be< scs.num_irqsfor the chosen platform.
[run_example.py](run_example.py)— launcher[gem5/gem5_script.py](gem5/gem5_script.py)— gem5 board + bridge wiring[gem5/firmware/Makefile](gem5/firmware/Makefile)— firmware build[gem5/firmware/linker/cortexm4.ld](gem5/firmware/linker/cortexm4.ld)— STM32G474RE memory map[gem5/firmware/startup/startup.c](gem5/firmware/startup/startup.c)— vector table (16 system + 102 IRQs), Reset_Handler, FPU enable — pure C[gem5/firmware/common/cmsis.h](gem5/firmware/common/cmsis.h)— minimal CMSIS-Core API (__WFI,NVIC_EnableIRQ,SysTick_Config, ...)[gem5/firmware/common/semihosting.c](gem5/firmware/common/semihosting.c)—_write+ minimal newlib syscall stubs (routesprintfthroughbkpt #0xab)[gem5/firmware/common/pid.{c,h}](gem5/firmware/common/pid.c)— PID (float)[gem5/firmware/leader/leader_app.c](gem5/firmware/leader/leader_app.c)— leader logic (waypoint patrol, IRQ-driven)[gem5/firmware/chaser/chaser_app.c](gem5/firmware/chaser/chaser_app.c)— chaser logic (light tracking, IRQ-driven)[webots/worlds/crazyflie_dual_gem5.wbt](webots/worlds/crazyflie_dual_gem5.wbt)— world[webots/controllers/crazyflie_leader_gem5/](webots/controllers/crazyflie_leader_gem5/)— leader controller[webots/controllers/crazyflie_chaser_gem5/](webots/controllers/crazyflie_chaser_gem5/)— chaser controller[webots/controllers/supervisor_dual/](webots/controllers/supervisor_dual/)— supervisor (logging only)