Skip to content

port/raspberrypi: add a bare metal BCM2711 port for the Raspberry Pi 4B - #1025

Closed
zmscode wants to merge 2 commits into
ZigEmbeddedGroup:mainfrom
zmscode:bcm2711-pi4b
Closed

port/raspberrypi: add a bare metal BCM2711 port for the Raspberry Pi 4B#1025
zmscode wants to merge 2 commits into
ZigEmbeddedGroup:mainfrom
zmscode:bcm2711-pi4b

Conversation

@zmscode

@zmscode zmscode commented Sep 4, 2026

Copy link
Copy Markdown

Adds an aarch64 target to microzig: a bare metal port for the Broadcom BCM2711, the SoC of the Raspberry Pi 4 Model B.

This is a different shape of port from the rest of the tree, so I would rather have the approach argued with than merged quietly. It is a bring up port: it boots, drives gpio, talks over the serial console and keeps time. Opening it as a draft partly because it is untested on hardware (see below) and partly to ask whether an application processor belongs in microzig at all.

Why it fits

Less had to change than I expected. Ports already supply their own cpu module (the esp port does), the core's cpu contract is small (export_startup_logic plus five interrupt functions, with InterruptOptions and CPU_Options optional), register_definition accepts a hand written zig file, and nothing in the build system gates on architecture. So this adds no core changes at all beyond registering the port.

Boot model

There is no flash to execute from and no reset vector table to place. The VideoCore, not the ARM cores, starts first: it reads config.txt, loads kernel8.img from the boot partition to 0x80000, and runs armstub8.bin, which drops to EL2, parks cores 1 to 3 on a spin table and enters the image at its first byte on core 0. A firmware here is therefore a plain ram image whose first byte is the entry point, which is what .ram_image = true and ld/kernel8_sections.ld express.

The cpu module parks anything that is not core 0 (so the image also survives being entered by all four), points sp at the top of ram, clears .bss and installs an exception vector table that reports the syndrome and the faulting address rather than locking up silently. It stays at EL2 rather than dropping to EL1, and brings up a flat identity mapping with the caches on (see Testing for why that is not optional).

_start has to be naked. As an ordinary function it emitted a frame push before the stack pointer was set, and then kept addressing its locals through a frame pointer into the stack it had just abandoned:

80000: sub  sp, sp, #0x170          <- frame on whatever stack the firmware left
80004: stp  x29, x30, [sp, #0x150]
...
80030: mov  sp, x8                  <- our stack only starts here
80034: sub  x0, x29, #0x40          <- still addressing the old frame

Peripherals

Broadcom publishes no SVD for this part, so src/chips/BCM2711.zig writes out the registers this port uses by hand from the peripherals datasheet, in the low peripheral view the cores see at 0xFE000000.

  • hal.gpio covers the 58 pins. Note the BCM2711 sets the pull resistors directly through GPIO_PUP_PDN_CNTRL_REG; the clocked GPPUD sequence of the BCM2835 and BCM2837 does not carry over.
  • hal.uart drives the PL011 and routes it onto GPIO14 and GPIO15, taking the header back from the mini uart and leaving bluetooth without a uart. That is the right trade here: unlike the mini uart, the PL011 baud rate does not move with the VideoCore core clock. It does assume the firmware default 48 MHz reference clock.
  • hal.time reads the ARM generic timer through CNTPCT_EL0, so it needs no peripheral setup and is not shared with the VideoCore.

A linker script note that may interest #857

The first flat image this produced was 134 MB. An orphan .got had been placed past the heap at the end of the ram region, so the binary spanned all of it; placing .got explicitly in the script fixed it.

That is not the same root cause as #857, but while checking I did reproduce that one. On blinky_ch32v003.elf the .heap gets its own program header:

type       offset       vaddr       filesz    memsz  flags
LOAD   0x00001000  0x00000000        3921     3921      5
LOAD   0x00001f54  0x00000f54        1188     1188      4
LOAD   0x00003000  0x20000000           0     2048      6   <- .heap

A zero filesz segment 512 MB up is what the flat binary is spanning. If that is worth fixing centrally rather than working around with GNU objcopy, tools/generate_linker_script.zig emitting an explicit PHDRS block, so the NOBITS heap does not get a PT_LOAD of its own, would do it for every port at once. Happy to open that separately if it sounds right.

Testing

Runs under QEMU, not yet on silicon — I do not have a Pi 4B, which is why this is a draft.

Unlike most targets in this tree, this one can be exercised without hardware, which turned out to matter:

qemu-system-aarch64 -M raspi4b -kernel zig-out/firmware/blinky.bin -display none -serial stdio
info: Hello from a Raspberry Pi 4B, running at EL2
info: generic timer runs at 62500000 Hz
info: led on
info: led off

uart_echo echoes what it is given. Also verified: entry is 0x80000 with _start first, reading MPIDR_EL1, parking the spare cores and pointing sp at the top of the declared ram region; the vector table is 2048 aligned with its sixteen entries 128 bytes apart.

That 62.5 MHz is QEMU's generic timer rate rather than the 54 MHz of real silicon, which is a good argument for hal.time reading CNTFRQ_EL0 rather than assuming.

The first QEMU run is also what caught the biggest bug in this branch. The port originally left the mmu off to keep the bring up small, and faulted inside std.fmt on the very first log line with esr=96000021 — EC 0x25, DFSC 0x21, an alignment fault. With the mmu off every access is Device-nGnRnE, and device memory does not permit unaligned accesses at all, so ordinary compiled code cannot run. It now identity maps the low four gigabytes with 1 GiB blocks, three of Normal memory and one of Device for the peripheral window, and turns the caches on with it. The exception path also needed a re-entry guard, since reporting a fault runs through the formatter and the uart and either can fault in turn.

What QEMU cannot tell us is anything about the real board's clocking. The most likely remaining first-boot problem is the PL011 reference clock, which the port assumes is the firmware default 48 MHz but which init_uart_clock in config.txt overrides.

Not here yet

The GIC-400 (so interrupts only mask globally), the mmu and caches, the firmware mailbox, cores 1 to 3, and i2c, spi, pwm, sd and usb. The mailbox is the highest leverage of those: it unlocks the framebuffer, the board revision, real clock rates instead of assumed ones, and the green ACT led, which on a Pi 4B hangs off the VideoCore gpio expander rather than a BCM gpio and so cannot be driven today.

Adds an aarch64 target to microzig. The BCM2711 is an application
processor rather than a microcontroller, so this is a bring up port: it
boots, drives gpio, talks over the serial console and keeps time.

The boot model is different from every other port. There is no flash to
execute from and no reset vector table to place: the VideoCore reads
kernel8.img off the card to 0x80000 and the armstub enters it at EL2 with
cores 1 to 3 parked. A firmware here is therefore a plain ram image whose
first byte is the entry point.

The cpu module parks anything that is not core 0 so the image also
survives being entered by all four, points sp at the top of ram, clears
.bss and installs an exception vector table that reports the syndrome and
the faulting address instead of locking up silently. It stays at EL2 and
leaves the mmu and the caches off, which keeps the bring up small at the
cost of speed. _start has to be naked: a normal function pushes a frame
onto whatever stack the firmware left and then keeps addressing its
locals through it after sp has moved.

Broadcom publishes no svd for this part, so the registers the port uses
are written out by hand from the peripherals datasheet, in the low
peripheral view the cores see at 0xFE000000. The gpio hal drives the pull
resistors through GPIO_PUP_PDN_CNTRL_REG, which replaces the clocked
GPPUD sequence of the older chips. The uart hal drives the PL011 and
routes it onto GPIO14 and GPIO15, taking the header back from the mini
uart, whose baud rate would otherwise move with the VideoCore core clock.
Timekeeping reads the ARM generic timer, which needs no peripheral setup.

The linker script places .got explicitly. Left as an orphan it lands past
the heap at the end of the ram region, and the flat image then spans all
of it.

Not here yet: the GIC-400, the mmu, the firmware mailbox (and with it the
ACT led, which hangs off the VideoCore gpio expander), cores 1 to 3, and
i2c, spi, pwm, sd and usb.
Running with the mmu off does not work past the simplest register poking.
Every access is then treated as Device-nGnRnE, and device memory does not
allow unaligned accesses at all, so compiled code faults as soon as it
touches an unaligned field. The first log line was enough: booting blinky
under QEMU faulted inside std.fmt with esr=96000021, an alignment fault
on a data abort.

So identity map the low four gigabytes with one 1 GiB block descriptor
each, the first three as Normal memory and the fourth, which holds the
peripheral window, as Device, and turn the caches on with it.

The exception path also needed a re-entry guard. Reporting a fault runs
through the formatter and the uart, either of which can fault in turn,
and the console filled with half printed messages instead of one.

With both fixed, blinky runs under `qemu-system-aarch64 -M raspi4b`:
reports EL2, reads the generic timer rate off CNTFRQ_EL0 and toggles at
the interval it asked for. uart_echo echoes. Both READMEs now document
the QEMU invocation.
@mattnite

mattnite commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

I don't see why application processors shouldn't be in MicroZig. Are you planning on buying the hardware so you can test this patch?

@mattnite mattnite closed this Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants