Blog ·
A compiler in a browser tab: building ESP32-S3 firmware with Espressif's LLVM in WebAssembly
Sergey Alekseev
PCBKit builds a board’s firmware in the browser. There is no build server: the page runs Clang and LLD compiled to WebAssembly in workers, links the program into the image the chip boots, and runs it on a model of the chip. For RISC-V and Arm that was already working. The ESP32-S3 was next, and it matters for one silly reason: among Doom handhelds people actually build, it is the most popular platform. Two of our eleven Doom boards use it.
The ESP32-S3 has an Xtensa LX7 core, and Xtensa is different. It has a windowed register ABI (calls rotate a window over 64 registers, and the hardware raises an exception when the window overflows or underflows), zero-overhead loops, booleans and its own FPU. So before anything ran in a browser, we had to decide which compiler to trust.

s3-handheld: an ESP32-S3 module with 8 MB of octal PSRAM under a 2.8″ ILI9341 screen.
A judge program
We wrote one freestanding C program to judge every candidate. It covers integer and bit
operations, 32-bit and 64-bit division, deep recursion (including a 400-deep chain and an
11-argument call mixing int64_t, float and double), a 36 KB stack frame, single and double
precision, structures by value, varargs and bit-fields. Each section prints a checksum. The expected
output comes from a native build, and every compiler’s binary runs under Espressif’s own QEMU
9.2.2, an emulator we didn’t write.
Each passing run raised about 20,440 window-overflow and as many window-underflow exceptions, so the windowed ABI and its handlers were exercised, not only compiled.
| Compiler | Result under QEMU |
|---|---|
| Espressif GCC 15.3.0, -O2 / -O0 / -Os | match ×3 |
| Espressif LLVM esp-22.1.4, -O2 / -O0 / -Os, integrated assembler | match ×3 |
Espressif LLVM with our C library (printf) |
match |
| Espressif LLVM, the driver’s default assembler | no output at all |
| Upstream clang 23.1.4 -O2 (built with Xtensa) | differs in two lines |
Upstream LLVM is not there yet
The official LLVM 23.1.4 release binaries have no Xtensa target at all: 48 targets, none of them
Xtensa. Built from source with the experimental backend (414 s on 32 cores), its code generator knows
esp32 but not esp32s3 (and clang accepts neither), it defaults to the non-windowed ABI, and miscompiles varargs: a minimal
ints(3, 1, 2, 3) returned 0x7df instead of 0x402, with results changing between optimisation
levels. Upstream lld cannot link Xtensa at all (unsupported e_machine value: 94), and upstream
compiler-rt has no Xtensa either. So upstream was out.
Espressif’s GCC produced the smallest and most idiomatic code. Running the judge on our own Xtensa core, GCC’s -O2 build took 1.36 million instructions and Espressif’s clang 2.39 million (+76 %); LLVM’s code was also about 48 % larger, with no hardware loops. But GCC is GPL and we keep it outside PCBKit, as the reference for differential tests. Espressif’s LLVM is Apache-2.0 with the LLVM exception, the same as the RISC-V Clang we already ship, so that’s the one.
An assembler trap
One row of the table above says “no output at all”, and it took a while to understand. When a
build includes any .S file, Espressif’s clang driver switches to the bundled GNU assembler for
every file. GNU as stores some relocation addends inside the section, and Espressif’s ld.lld only
applies the addend in the relocation record. The result links without a single warning, and every
literal points at the start of its section: the judge’s main “called” memcpy instead of each
test and exited silently. The fix is to always use the integrated assembler, and we added a script
that fails the build if such relocations appear in a library.
A C library for Xtensa
The firmware needs a C library. We built picolibc 1.8.10 and compiler-rt from Espressif’s fork at
the compiler’s tag. Two details: picolibc’s fallback configuration describes a core without
register windows, so we supply a header that maps the compiler’s own configuration macros; and
setjmp needs a window-spill routine that otherwise only exists in GCC’s libgcc, so we wrote our
own (six nested 12-register calls, 72 registers, more than the 64 the core has). The sysroot is
2.7 MB, builds in 6 s, and a second build is byte-identical. Under QEMU, setjmp out of deep
recursion, malloc and printf("%.3f %g") print the same as natively.
Espressif’s LLVM in WebAssembly
This turned out easier than expected. We built Espressif’s LLVM with the same recipe the browser’s RISC-V clang came from (Emscripten 6.0.13, native table generators first, then a size-optimised WebAssembly build), with the RISC-V and Xtensa targets. No patch was needed. Clang and lld took 861 s on a shared, busy dev host.
| Bytes | |
|---|---|
clang.wasm |
59,353,847 |
lld.wasm |
35,815,296 |
Almost the same size as the RISC-V tools we already load (59.7 and 36.2 MB), so the existing limits
fit. Compiling the judge in WebAssembly under Node took 2.2 s (about 0.3 s of it loading), against
0.11 s natively, and gave an object identical to the native compiler’s except for the version
string. Linking took 217 ms. A second clean build from a fresh clone produced byte-identical
clang.wasm and lld.wasm.
One thing to understand still: the browser link is deterministic and correct, but not byte-identical to the native link of the same objects (sections are placed in a different order). Both run the judge correctly.
The chip models
A compiler is half of it. The other half is a model of the chip that boots the image the way the real one does.
ESP32-S3. The model starts from the state the boot ROM leaves after a flash boot: the image checked and loaded, the CPU on the 40 MHz crystal, caches off, the MMU empty, three watchdogs running. Then the firmware has to do everything itself: watchdogs off, the PLL up and the CPU at 160 MHz the way ESP-IDF does it, caches on, flash pages mapped, the octal PSRAM brought up through the ROM’s commands. The model has the clocks, watchdogs, MMU, PSRAM controller, GPIO matrix, SPI, I2C and the LCD_CAM block that scans an RGB panel by DMA.
ESP32-C3. It is RISC-V, so it uses our RV32 core with Espressif’s interrupt controller added, the system timer, the PLL (which must be powered and configured over an internal I2C bus before the CPU may switch to it) and the same image loader. It builds with the shared Clang 19 profile and a new C library built without atomic instructions.

c3-pocket: an ESP32-C3 module, the game read in place from its 4 MB flash, a 1.3″ 240 × 240 screen.
Checked against the real ROMs. Espressif’s QEMU with each chip’s own boot ROM boots the same
flash image our models boot: the ROM accepts the header, checksum and SHA-256, loads the segments
and enters our start-up. On the C3, a bring-up program prints its clock, sleeps four times 1 ms on
the timer interrupt and reports done after 4006 us; our model says 4004. QEMU has no octal PSRAM
for the S3, so that part is checked only against ESP-IDF’s source.
| Doom, measured | s3-handheld | s3-rgb | c3-pocket |
|---|---|---|---|
| Build in the page | 5.7 s | 5.0 s | 8.3 s |
| Native, simulated / wall | 16 s in 11.8 s | 16 s in 11.9 s | 12 s in 7.4 s |
| Browser, vs. the real chip | 0.26x | 0.22x | 0.28x |
Build times are from one browser test of all eleven Doom boards (headless Chromium, dev-profile WebAssembly). The S3 image has 166 KB in internal RAM, 423 KB of statics in PSRAM and 4.5 MB of constants plus 170 KB of code in flash. A trick that helped: the core skips polling loops exactly, which took 30 s of Doom from 64 s to 26 s of wall time with the same instruction count.
Open s3-handheld, s3-rgb or c3-pocket in the app; the boards and their programs are in github.com/pcbkit-io/boards. The Doom engine there is GPL-2.0, which is why it lives in its own repository.
What failed on the way
_startcalledmainwith nothing above the stack top, so the first window overflow of that frame read a null stack pointer and wrote to 0xFFFFFFE0. The start-up now leaves 32 bytes above the stack.- The GPIO matrix register covered pins 0 to 31 only, and pin 47 (the touch controller’s chip select) stopped the model.
- The C3 display module has no chip select pin, and our display model showed a frame only when chip select went high. It showed the reset frame forever.
- A flag set in the load path instead of the store path quietly disabled the polling skip without changing any result. Its log found it.
What is not done
The compiler packages for both chips are published to PCBKit’s library, so the live site builds ESP32 firmware the same way; a page downloads them only when a board needs them. The octal PSRAM start-up has never run on a real ESP32-S3: the first board we order is its test. And GCC’s faster code (hardware loops, two-thirds the size) would only reach the browser as a GPL toolchain, so for now Doom on the S3 runs whatever LLVM gives it.