Skip to content

studyztp/webots-gem5-example

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Webots + gem5 — Multi-Robot Simulation Example

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│     │
              │   └──────────┘   │      │   └──────────┘     │
              └──────────────────┘      └────────────────────┘

Table of contents

  1. Quick start
  2. First-time setup
  3. Running the example — step by step
  4. Repository layout
  5. How the pieces fit together
  6. The bridge protocol
  7. The MProfileBridgeIO peripheral
  8. Firmware side
  9. Webots-controller side
  10. gem5-script side
  11. Per-step data flow
  12. Recipe — adding your own robot

Quick start (TL;DR)

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/webots

The full step-by-step walkthrough below explains each prerequisite, what to expect, and where the logs land.


First-time setup

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.)

1. Create a Python venv and install the bridge package

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/python

Verify 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.

2. Build gem5

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 STM32G474RETimingBoard

gem5.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.

3. (Optional) Webots & arm-none-eabi-gcc

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

Running the example — step by step

Prerequisites

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.

Step 1 — build the firmware

From the project root (this directory):

cd gem5/firmware
make

Expected 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 rebuild

Step 2 — activate the Python venv

The 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).

Step 3 — start the example

From this directory, with the venv still active:

python3 run_example.py \
    --gem5-path   /path/to/gem5.opt \
    --webots-path /path/to/webots

Default-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 ... --headless

Step 4 — what you should see

The 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.

Step 5 — stopping the simulation

Press Ctrl+C in the launcher terminal. The launcher will:

  1. Catch the KeyboardInterrupt.
  2. terminate() the Webots and both gem5 subprocesses (with a 5 s grace period before kill()).
  3. 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.

Step 6 — what gets written under output/

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.

Step 7 — re-running cleanly

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").


Repository layout

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

How the pieces fit together

The simulation has three independent processes per robot, glued by the bridge framework:

  1. 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.
  2. gem5 — one cycle-accurate simulation per robot. It instantiates a virtual STM32G474RE (the STM32G474RETimingBoard), attaches an MProfileBridgeIO peripheral, and loads the firmware ELF.
  3. 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 protocol

The bridge framework lives in the bridge Python package (import bridge._bridge as br). It exposes:

From the launcher (matchmaker)

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.

From the gem5 script (server side)

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.

From the Webots controller (client side)

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.

Naming & matchmaking

  • The string the controller passes to bridge_setup_client(...) is the Webots robot name — i.e. the name "..." field of the robot in the world file, accessed via robot.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_server dict 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 up client_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.


The MProfileBridgeIO peripheral

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.

Register map (offsets from pio_addr)

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.

Python (gem5-script) API

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]

How the firmware reads/writes it

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_num entries — for IRQ 101, that's 118 vector words; the unused slots default to Default_Handler).
  • A handler at that slot — see Bridge_IRQ_Handler in leader_app.c. Annotate it with __attribute__((interrupt("IRQ"))) so GCC ensures 8-byte stack alignment on entry and emits bx lr for the exception return (instead of the default pop {pc}; both are valid on real Cortex-M hardware, but bx lr is 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() calls exitSimLoopNow("MProfileBridgeIO signaled done."). That returns control from m5.simulate(ticks) back to the Python script, which reads the output, calls clearInterrupt(), 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.

Why SysTick is enabled

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.


Firmware side

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:

  1. gem5 publishes a new input window via updateInputData() and pulses NVIC IRQ 101 via raiseInterrupt().
  2. The CPU in WFI wakes, the NVIC dispatches IRQ 101 → enters Bridge_IRQ_Handler.
  3. The handler reads input, runs the controller, writes output.
  4. The handler writes 1 to BRIDGE_IO_REG_DONEMProfileBridgeIO::write() calls exitSimLoopNow(...) → control returns to the gem5 Python script (the handler is paused mid-execution).
  5. Python reads the output, calls clearInterrupt(), sends the reply to Webots, gets the next sensor frame, calls updateInputData() + raiseInterrupt(), and resumes m5.simulate(...).
  6. 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.

Build infrastructure

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.py

Hardware FPU + libm

The 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.


Webots-controller side

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-script side

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:

  1. Builds the STM32G474RETimingBoard (Cortex-M4, ART caches, real bus topology — see gem5/src/python/gem5/prebuilt/cortexm/boards/stm32g474re_board.py in your gem5 tree).
  2. Enables semihosting (board.semihosting = ArmSemihosting()).
  3. Attaches an MProfileBridgeIO at 0x88000000:
 board.bridge_io = MProfileBridgeIO(
     pio_addr=0x88000000,
     scs=board.platform.scs,
     irq_num=101,
 )
 board.bridge_io.pio = board.system_bus.mem_side_ports
  1. Loads the firmware ELF (board.set_workload(elf)).
  2. Connects to the bridge as bridge_setup_server(server_name) and reads the SETUP message (the controller's timestep).
  3. Boots the firmware (one m5.simulate(run_ahead_ticks) — the firmware reaches its WFI loop and waits for the bridge IRQ).
  4. 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.


Per-step data flow

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.


Recipe — adding your own robot

To add a robot whose firmware runs on its own gem5 instance:

1. Decide your data contract

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.

2. Write the firmware

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.

3. Write the Webots controller

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

4. Add the robot to the world

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/.

5. Wire the launcher

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.

6. (Optional) Use a different gem5 board

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 MEMORY block to your chip's flash/SRAM layout (and _estack to the top of contiguous SRAM).
  • Rebuild the firmware against the new linker.
  • If your chip's NVIC IRQ count differs, the irq_num you pass to MProfileBridgeIO must be < scs.num_irqs for the chosen platform.

Reference: source files in this directory

  • [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 (routes printf through bkpt #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)

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors