A board's files are compiled by a real clang and a real lld, running as
WebAssembly inside the page. There is no build server, nothing is uploaded, and
the ELF that comes out is the same kind of image firmware/build.sh produces:
it is the same compiler driven by the same command list.
The proof that this works, with the numbers that justified building it, is
proofs/compile/REPORT.md. This document is how the product does it.
The shape of it
web/src/compile/
asm-check.ts the AVR operand checks avr-as makes and LLVM's assembler does not
asm-help.ts plain-English help for an assembler or linker message
bundles.ts which toolchain and which runtime each board family needs
cache.ts the 74 MB download, and the Cache API entry that avoids it
client.ts the page's half: one worker, and the rule for "does this need building?"
diagnostics.ts clang's stderr -> { file, line, col, severity, message }
disasm.ts AVR and Thumb disassembly for the debugger, in llvm-objdump's spelling
hash.ts a cheap content hash for the two cache keys
protocol.ts the messages between the page and the worker
toolchain.ts the command list, and one wasm instantiation per tool run
wasi.ts a WASI preview 1 host with an in-memory filesystem
worker.ts the Web Worker that runs all of the above
web/src/ui/build-panel.ts the steps, the diagnostics and the one success line
web/scripts/bundle-runtime.mjs firmware/ -> web/public/firmware/runtime/<family>.json
Everything except the build panel runs in the worker, so a four second first build never blocks a frame of the canvas.
What has to reach the browser
Two things, from two places.
The toolchain is one clang.wasm (clang, ld.lld and llvm-objcopy in a
single LLVM driver that dispatches on argv[0]) plus a bundle.json whose
/usr/include/... entries are clang's own resource headers, pruned to the ten a
freestanding build opens. Both come from the Cloudflare R2 bucket
mokxi-toolchain, served by the Worker at /toolchain/<version>/<file>. In
development deploy/fetch-toolchain.sh puts the same files under
web/public/toolchain/<version>/ (gitignored) and Vite serves the same paths,
so the client code is identical either way.
The runtime is our own firmware: the sources under firmware/src, the
headers, crt0.S and the linker script, together with the exact flags
firmware/build.sh compiles them with. It also carries firmware/lib, the
shared header-only drivers (mokxi_i2c.h, mokxi_ssd1306.h, mokxi_max7219.h,
and the rest): one copy, mounted at /firmware/lib for every family with
-I/firmware/lib on the command line, exactly where firmware/build.sh and
firmware/uno/build.sh already point their own -I at the same directory. A
sketch that includes one of these headers (every OLED, SPI display, WS2812,
tone or joystick example) compiles on any board because of this, not despite
it. web/scripts/bundle-runtime.mjs packs all of it into
web/public/firmware/runtime/<family>.json, which is committed. Regenerate it
whenever the firmware (including firmware/lib) changes:
node web/scripts/bundle-runtime.mjs # write the files
node web/scripts/bundle-runtime.mjs --check # fail if they are out of date
src/compile/bundles.test.ts runs the --check form, so a change to
firmware/ that nobody carried over fails the test suite rather than a user's
build.
Every family's firmware is in the tree now, so the script writes every
bundle.
It still holds the rule it was written with: a family whose firmware is not
there (the script looks for its link.ld) is skipped with a note, not an
error, so neither a developer's build nor CI fails while the next family is
being written. esp32c3 and uno name their sources explicitly, in the order
build.sh links them, and so does attiny, whose sources are the Uno's list
on a different -mmcu. pico, stm32, bluepill and microbit use
discover, which
reads the lists off the directory: every .S at the family root with crt0
first, then src/*.c, then src/*.cpp with main.cpp last, everything else
alphabetical, which is the order their build.sh links in, so nothing has had
to be pinned. Pin explicit lists in the script if a link order ever has to be
something else.
The ESP32, ESP32-S3 and ESP8266 bundles are written too. Their flags are the
clang translation of their GCC build.sh lists (see "Proven natively" below),
and they are what clang-v3 compiles in the browser.
The toolchain version is pinned per board family
The version is a property of the family, not of the app, because the two builds
are not one compiler: clang-rv32-v1 was configured with
LLVM_TARGETS_TO_BUILD=RISCV alone, so it can compile for the ESP32-C3 and it
physically cannot compile for the Uno. clang-v2 is that same build script with
LLVM_TARGETS_TO_BUILD=RISCV;AVR;ARM (same layout, same bucket, same URL
shape), and every family that compiles today is pinned to it (DECISIONS.md,
2026-09-14: verified on all three backends and uploaded to R2). Its RISC-V
output is byte-identical to clang-rv32-v1's, so the ESP32-C3 moved onto it
too and every such board shares one download. The Xtensa families are pinned
to clang-v3 (XTENSA_TOOLCHAIN), published 2026-09-28:
| family | catalog program |
target | toolchain | ready |
|---|---|---|---|---|
esp32c3 |
elf32 riscv32imc |
riscv32-unknown-elf -march=rv32imc -mabi=ilp32 |
clang-v2 |
yes |
c6 |
elf32 riscv32imc esp32c6 |
riscv32-unknown-elf -march=rv32imc -mabi=ilp32 |
clang-v2 |
yes |
uno |
elf32 avr atmega328p |
avr -mmcu=atmega328p |
clang-v2 |
yes |
nano |
elf32 avr atmega328p nano |
avr -mmcu=atmega328p |
clang-v2 |
yes |
mega2560 |
elf32 avr atmega2560 |
avr -mmcu=atmega2560 |
clang-v2 |
yes |
leonardo |
elf32 avr atmega32u4 |
avr -mmcu=atmega32u4 |
clang-v2 |
yes |
attiny |
elf32 avr attiny85 |
avr -mmcu=attiny85 |
clang-v2 |
yes |
pico |
elf32 arm rp2040 |
thumbv6m-none-eabi -mcpu=cortex-m0plus -mthumb |
clang-v2 |
yes |
xiao |
elf32 arm samd21 |
thumbv6m-none-eabi -mcpu=cortex-m0plus -mthumb |
clang-v2 |
yes |
stm32 |
elf32 arm stm32f411 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
bluepill |
elf32 arm stm32f103 |
thumbv7m-none-eabi -mcpu=cortex-m3 -mthumb -mfloat-abi=soft |
clang-v2 |
yes |
microbit |
elf32 arm nrf52833 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
unor4 |
elf32 arm ra4m1 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
esp32 |
elf32 xtensa lx6 esp32 |
xtensa-esp-elf -mcpu=esp32 -fintegrated-as |
clang-v3 |
yes |
esp32s3 |
elf32 xtensa lx7 esp32s3 |
xtensa-esp-elf -mcpu=esp32s3 -fintegrated-as |
clang-v3 |
yes |
esp8266 |
elf32 xtensa lx106 esp8266 |
xtensa-esp-elf -mcpu=esp8266 -fintegrated-as |
clang-v3 |
yes |
The last three rows are the next section. web/src/compile/bundles.ts is the
list this table is read from, and bundles.test.ts keeps them in step.
The Xtensa boards, on clang-v3
The classic ESP32 is an Xtensa LX6, the ESP8266 NodeMCU an Xtensa LX106 and the
ESP32-S3 an Xtensa LX7. All three compile in the browser on clang-v3 since
2026-09-28 ("Published and checked" below). Until then they ran prebuilt ELFs
and said so. The rest of this section is why it took a fork, and what the
proof found.
Why it is a separate toolchain (checked 2026-09-27):
clang-v2has no Xtensa back end. It was built withLLVM_TARGETS_TO_BUILD=RISCV;AVR;ARM(DECISIONS.md, 2026-09-14). The wasm the browser downloads physically cannot emit an Xtensa object.- Upstream LLVM has half of what is needed, and the wrong half. Clang has
carried an Xtensa front end since LLVM 20
(Phoronix), and
LLVM 22's
XtensaProcessors.tdknowsesp32andesp8266(upstreammainaddsesp32s2andesp32s3). But upstreamld.lldhas no Xtensa target at all: there is nolld/ELF/Arch/Xtensa.cpponmainor onrelease/22.x, so nothing upstream can link an ESP32 image. The upstream backend also still emits literal pools inline in.text, which breaksl32ralignment under-ffunction-sections(llvm/llvm-project#190204, open). Xtensa is still inLLVM_ALL_EXPERIMENTAL_TARGETSupstream. - Espressif's fork has all of it.
esp-clang(espressif/llvm-project, latestesp-22.1.4_20260825, LLVM 22.1.4, Apache-2.0 with LLVM exception) carries the Xtensa front end with-mcpu=esp32,esp8266,esp32s2andesp32s3, and anlld/ELF/Arch/Xtensa.cppthat handlesR_XTENSA_32,R_XTENSA_SLOT0_OP(L32R,CALL8, branches) and theDIFF/ASM_EXPANDrelocations. Xtensa is experimental there too, so it has to go inLLVM_EXPERIMENTAL_TARGETS_TO_BUILD;build-wasm-clang.shnow splits that out of the target list by itself. The fork does not carry the WASI portability commit, soproofs/compile/patches/llvm-wasi-portability.patch(the same commit clang-v2 is built with, from YoWASP) is applied on top. It applies cleanly toesp-22.1.4_20260825(one hunk at an offset of one line). That commit's WASIclose()returns a staleerrnoand fails clean writes ("IO failure on output stream: No such file or directory"), sobuild-wasm-clang.shalways appliesproofs/compile/patches/llvm-wasi-close-errno.patchafter it, on either tree. The clang-v2 served today predates that fix;ERRNO_RESET_HEADERandruntimeFilesinweb/src/compile/toolchain.tswork around it until it is rebuilt (docs/clang-v2-rebuild.mdhas the plan).
So this is a fork to track rather than a flag to add, but the fork is
Espressif's own, released every few months, and under the same license as the
LLVM clang-v2 comes from.
The prebuilt ELFs are still built by Espressif's GCC, which
deploy/fetch-xtensa.sh puts in tools/xtensa/: xtensa-esp32-elf-gcc 8.4.0
for the LX6, xtensa-esp32s3-elf-gcc 8.4.0 for the LX7 and
xtensa-lx106-elf-gcc 8.4.0 for the LX106. The S3's runtime reuses the
classic ESP32's crt0.S and support64.c: for code a compiler emits from C
the LX7 is the LX6, and the S3 compiler's output for the same test program is
byte for byte the ESP32 compiler's. That directory is gitignored. GCC is GPL, and nothing it builds here links against libgcc
(the runtimes are -nostdlib with their own support.c, and the ESP8266's
support32.c supplies the 32-bit divides the LX106 has no instruction for), so
no GPL code reaches a shipped ELF.
Proven natively
Before any wasm exists, the compiler side is proven with the native Linux
build of the same fork (clang-esp-22.1.4_20260825-x86_64-linux-gnu.tar.xz,
411 MB, sha256 4700ed4e...eb0a1, unpacks to 2.7 GB with ld.lld in it):
proofs/compile/xtensa/run-native.sh /some/scratch/dir
It fetches that release, then runs proofs/compile/xtensa/plan.ts --native,
which does not transcribe any commands: it takes runtimeSteps and
projectPlan from web/src/compile/toolchain.ts and the committed runtime
bundles web/public/firmware/runtime/esp32.json, esp32s3.json and
esp8266.json, so what
runs is the browser's command list, crt0.S split into its preprocess and
assemble halves, ld.lld called directly, llvm-objcopy to stdout. Result on
2026-09-27:
- every example of every board compiles and links with
-Wall -Wextra -Werror: nine for the ESP32 (19 steps each), eleven for the ESP32-S3 (21 steps,rainbowandusbserialincluded) and nine for the ESP8266 (20 steps), about 1 s each natively; ld.lldresolves every relocation (in the ESP32 objects: 3,407R_XTENSA_32and 430R_XTENSA_SLOT0_OP);MOKXI_ESP32_ELF_DIR=... cargo test -p esp32 --test runtime --test wifi: 11 + 4 passed;MOKXI_ESP32S3_ELF_DIR=... cargo test -p esp32s3 --test runtime --test wifi: 13 + 4 passed;MOKXI_ESP8266_ELF_DIR=... cargo test -p esp8266 --test runtime --test wifi: 11 + 4 passed. These are the same tests the GCC ELFs pass: blink's 500 ms half period, LEDC and software PWM at 1 kHz,millis()against the kernel clock, UART0 bit spacing, the ADC, the S3's WS2812 frames on its RGB LED (white, then the rainbow green first on the wire) and its USB serial packets, WiFi connect and the lamp's web page. The three environment variables only point the existing tests at another directory; a corrupt file there makes them panic, so they really loaded what was built.
Three things the proof found, all handled in the bundles:
-fintegrated-asis required. For Xtensa the Espressif driver hands a.sfile to an external GNU assembler (xtensa-esp32-elf-clang-as) by default, which WASI cannot spawn. With-fintegrated-asclang's own assembler takes crt0.S as written. It is in the family's target flags, so the assemble half gets it too. Espressif turns it off for assembly input on purpose (EspBareMetal.cpp: "TODO: Add full support for Xtensa to integrated asm, LLVM-290, LLVM-291"), so it is not complete for every hand-written file; it is for both of ourcrt0.Sfiles, which is what the simulator tests show. It is also why a project's own.Sand.sfiles are not offered on the Xtensa boards: only.c,.cppand.inoare compiled there, where every other family assembles the project's own files too (see Assembly below). C and C++ always go through the integrated assembler, in this fork as upstream.- No multilib warning in the wasm. The native install warns that it has no
esp8266 multilib (an error under
-Werror) because itsmultilib.yamlhas no such entry.BareMetal::findMultilibsonly reads amultilib.yamlthat exists, and the wasm filesystem has none, so only the native run needs-Wno-missing-multilib, and onlyplan.ts --nativepasses it. - GCC's
-mlongcallsand-mtext-section-literalsgo. Clang reports-mlongcallsas an unused argument, an error under-Werror. Without-mtext-section-literalsclang emits.literal.<function>sections, which all threelink.ldfiles already put at the start of.text, well insideL32R's 256 KB reach.
Code size, the executable segment of every example against the shipped GCC
image (same sources, both -Os):
| board | GCC, all examples | clang | per example |
|---|---|---|---|
| ESP32 | 29,984 bytes | 37,760 bytes | +15% (fade) to +37% (wifilamp), +26% overall |
| ESP32-S3 | 41,584 bytes | 53,920 bytes | +18% (fade) to +38% (wifilamp), +30% overall |
| ESP8266 | 34,592 bytes | 49,104 bytes | +33% (button) to +49% (wifilamp), +42% overall |
Data is within a few dozen bytes either way. The largest clang image is the
ESP8266's wifilamp at 8,944 bytes of code, against a 32 KB IRAM and the
24 KB limit its elfcheck.py holds examples to, so the growth costs nothing a
sketch here would notice. Where the bytes go has not been broken down.
Published and checked
- Built by
.github/workflows/clang-v3.yml, run 36358476287 (attempt 3,mainat4578344f):espressif/llvm-projectatesp-22.1.4_20260825withRISCV;AVR;ARM;Xtensa, both WASI patches, thenplan.ts --wasmfor all three boards and the simulator's tests on what the wasm built. Artifactclang-v3, sha256 of the zipef27b7aa…3eafd;clang.wasmis 80,735,841 bytes, sha2567546c54c07c392e231ea450f8e3aaa6680346ffeb08e13d97d970b26d88666e5. - Published 2026-09-28: the artifact unzipped into
web/public/toolchain/clang-v3/, thendeploy/fetch-toolchain.sh --publish clang-v3.https://mokxi.com/toolchain/clang-v3/servesclang.wasm,bundle.jsonandinclude.tgzwith the right types, the right lengths and the same sha256 as the artifact. Thebundle.jsonis unpruned (16 MB, every clang resource header); only its/usr/includeentries are used, and pruning it the way clang-v2's was would save most of that download. - Checked in Chromium (headless, against
npm run devwith the published files inweb/public/toolchain/clang-v3/): the real editor opened a diagram with its own sketch, pressed Run, and watched the build panel, the serial monitor and the LED. The ESP32 sketch joined the simulated WiFi (192.168.4.2) and blinked GPIO 4; the ESP32-S3 sketch blinked GPIO 2 and printed; the ESP8266 sketch blinked D1 and printed a 32-bit divide each loop; the ESP8266's own Blink example, edited, built and blinked too. The first build of a board took 5 to 7 s including the runtime, a later one about 1.5 s. The ELF the page built for the ESP8266's Blink is byte for byte the workflow'sproof/esp8266/blink.elf. - The ESP8266 check found an engine bug, fixed in the same change. The
clang Blink writes its pins in the same slice it parks in, so the pin's net
moves inside time the core has already run, and the board part nudged the
parked core before the end of that slice.
CCOUNTwent backwards, the runtime's 64-bit cycle count read that as a wrap of 2^32 cycles, and everydelay()after the first returned at once: the LED lit and stayed lit. GCC's Blink never writes a pin in the slice it parks in, which is why no shipped example showed it, and the native tests never showed it because they run the SoC without a circuit. The ESP32, ESP32-S3 and ESP8266 board parts now never run the core earlier than where its last slice ended (ran_toincrates/parts/src/parts/esp8266.rsand its two siblings), andblink_keeps_its_half_period_when_a_pin_moves_as_the_core_parksholds it (run it withMOKXI_ESP8266_ELF_DIRpointing at the clang ELFs). - Floating point is soft float.
-mcpu=esp32and-mcpu=esp32s3turn on the single-precision FPU, which the model does not have, so at first afloatmultiply, compare or the DHT tutorial'sisnan(h) || isnan(t)compiled to an FPU instruction and the board stopped. The bundles now pass-Xclang -target-feature -Xclang -fp(the Xtensa driver has no-mfloat-abi), so floats become calls intomokxi_float.cas on every other soft-float board and give the right answers. The ESP8266's LX106 has no FPU to turn off and links the samemokxi_float.c.
Next steps
- Decide whether every family moves to clang-v3. Until they do, a
user who builds for an ESP32 and any other board keeps two 75 MB-plus
toolchains in the Cache API:
isStaleKey(cache.ts) keeps every version a family names (TOOLCHAINS_IN_USE) and drops the other one only when the disk is full. Before 2026-10-17 it dropped every version but the one in use, so each switch downloaded 20 MB again and failed offline. Moving them all is one string per family, as the move to clang-v2 was, but it is LLVM 22.1.4 from a fork rather than 22.1.0, so runcrash.test.tsand the stock-library tests against it first, and do not assume byte-identical output. - Check the stock libraries on the Xtensa families.
firmware/libis on their include path as on every board, butstock-libraries.test.tsdoes not list them, soServo.h,Wire.hand the rest are untested there. - Prune
bundle.jsonto the headers a freestanding build opens.
Never claim an in-browser compile that does not work: a build button that silently ran yesterday's binary would be the worst thing in this product.
clang-rv32-v1 is still the value of RV32_TOOLCHAIN and no family names it.
It stays as the way back: bumping a family's toolchain string is the whole of
rolling a build out or back, since the URLs, the module cache key and the
runtime object cache all hang off it.
ready is not dead code either. Two things have to be true before a family can
carry it, and bundles.test.ts checks the second of them:
- The toolchain has the back end, is published, and a sketch it built has run on the simulator.
- The family has a runtime bundle, which means its firmware directory
exists and
bundle-runtime.mjshas been run.
A family that is not ready is not an error: chooseProgram sends its boards
to the prebuilt ELF from web/public/firmware/, the build panel and the line
beside the board picker say why, and nothing fails. No family takes that path
today; the Xtensa families did until clang-v3 was published.
The STM32 and the micro:bit are soft float on purpose: DECISIONS.md, 2026-09-14, puts the FPU and the M4 DSP out of scope until an M4F board is wanted, so neither runtime may emit VFP instructions or a hard-float ABI.
What happens when you press Run
App.buildPrograms asks chooseProgram (src/compile/client.ts) about each
board, and gets one of three answers:
elf: run the image the Firmware panel names and compile nothing. An uploaded ELF was not built from these files; an untouched built-in example is already built. A family whose toolchain is unpublished lands here too.reuse: run the ELF already built from exactly these files. The decision is a hash of every file name and every byte in it, so a keystroke and an undo leave you back onreuse.build: compile, and run the result.
A build that fails stops the run: the button stays on Run, the previous ELF is dropped rather than quietly used, and the diagnostics stay in the panel. The built ELF lives in memory for the session only. It is a fact about this session, not part of the document, so it is never saved, exported or shared.
The command list
The same fourteen commands firmware/build.sh runs, with two differences that
WASI forces (both worked out in the proof):
-c crt0.Smakes clang want two jobs, a preprocess and an assemble, and clang turns its integrated cc1 off as soon as there is more than one, which on WASI is "unable to execute command: WASI does not support spawning subprocesses". The two halves are listed separately.- The link goes through
ld.llddirectly rather than the clang driver, so the ESP32-C3 link flags lose their-Wl,prefixes.firmware/uno/build.shalready callsld.lldfor the same reason.
The filesystem the tools see:
| path | what |
|---|---|
/usr/include |
clang's resource headers, from the toolchain bundle |
/firmware for the ESP32-C3, /firmware/<family> for the rest |
our runtime sources, headers, crt0.S, link.ld |
/firmware/lib |
the shared header-only drivers, same path for every family |
/project |
the board's files, under their own names, so #include "pins.h" works |
/build |
objects, the wrapped sketch, the ELF |
Assembly
A project's own .S and .s files are built here too, as arduino-cli builds a
.S beside a sketch, and a board can be assembly and nothing else.
Four pieces, all in web/src/compile/:
- The plan.
projectPlan(toolchain.ts) adds a step pair for each.S: a preprocess with the sketch's own include path, quote path and defines (so#include <avr/io.h>gives the register names and__ASSEMBLER__is set), then an assemble of the preprocessed copy. It is two runs for the reasonruntimeStepsalready gives:-c foo.Smakes clang want two jobs, and clang turns its integrated cc1 off as soon as there is more than one, which on WASI is "unable to execute command: WASI does not support spawning subprocesses". A.sis assembled directly, with no preprocess step. The preprocessor's# <line> "<file>"markers carry through, so a diagnostic and a DWARF line are the tab's own line rather than the line of the preprocessed copy, which is what lets the debugger break on a.Sline. The assemble step passes only the flags the assembler itself reads, plus-g -gdwarf-4. - The operand checks.
asm-check.tsreads every AVR.Sand.sof the project before any tool runs, and reports in clang's ownfile:line:col: error:shape soparseDiagnosticsand the build panel need to know nothing new. It makes the checksavr-asmakes and LLVM's AVR assembler silently does not:ldi r16, 300(kept as 0x2C),sbi PORTB, 1(a data address where an I/O address goes),add r16, 5(read asadd r16, r5),out PORTB, r16without__SFR_OFFSET 0(a warning: it reaches a different register),inandoutpast 63,sbiwandadiwpast 63, and a bit past 7. An operand is judged only when it can be worked out: numbers, the file's own.equ,.setand#defines, and the register and bit names of the board's header read out of the runtime bundle, through_SFR_IO_ADDR(),lo8(),hi8()and the C operators. Anything else is left to the assembler and the linker, which are right about those. An error stops the build before a tool starts (plan.checkFailed). - The link, when there is no sketch.
isAssemblyProject(bundles.ts) is a project whose files are assembly only;assemblyLinkInputssays what it links besides its own objects. On an AVR board that is the runtime'scrt0.Sobject and nothing else, which is what avr-libc'sgcrt1.ois toavr-gcc main.S: the vector table with every__vector_<n>a weakreti,r1cleared, the stack at RAMEND,.datacopied,.bsscleared, and a call to the project'smain. A project whose file defines its own.vectorssection or__vectorslabel (definesVectors) gets nothing, and so does every ARM board, where the file is the vector table. No Arduino core either way: nosetup(), nomillis()tick, noSerial. - The disassembler.
disasm.tsis ours, because the browser's LLVM carries clang,ld.lldandllvm-objcopyand nollvm-objdump. It reads the executable segments of the ELF the app already keeps, so the Debug tab's Disassembly list and its Step instruction button need no round trip. The AVR decoder covers the whole instruction set; the Thumb decoder covers every 16-bit encoding,BL, and the 32-bit Thumb-2 forms a compiler or a course writes most, and shows anything else as.inst.w 0x…rather than a wrong guess. The text isllvm-objdump's, anddisasm.test.tsholds it there: every instruction of real builds (the four assembly starters, and a sketch with floating-point math,Serialand pins) for the Uno, the Mega, the Pico and the Black Pill has to read exactly as the machine'sllvm-objdumpreads it.
Which boards offer an assembly project with no core is src/asm/boards.ts:
the Uno, the Nano, the Pro Mini, the Pico and the STM32F411. Every other board
that compiles still takes .S tabs beside a sketch, except the Xtensa boards
(see above). asm.test.ts builds and runs all of it on the simulator.
A tool that traps
clang and lld both run down an abort path on some fatal errors (a missing
#include, mainly) that prints the diagnostic to stderr and then hits
unreachable instead of calling exit. Inside WASI that surfaces as a real
WebAssembly.RuntimeError, not a proc_exit, and it would otherwise escape as
an uncaught rejection with no diagnostic attached to it: a crash, not a build
error.
Toolchain.run (src/compile/toolchain.ts) catches exactly that: a trap after
the tool has already written something to stderr comes back as an ordinary
failed step, result.trapped set and result.code non-zero, with everything
written before the trap still in result.err. runSteps
(src/compile/worker.ts) then treats it like any other non-zero exit:
whatever parseDiagnostics found is what the build panel shows, clickable
like every other diagnostic, and only falls back to "the compiler crashed
(<tool> on <step>)" when the trap carried nothing to say. Each tool run
already gets its own fresh WebAssembly.Instance from the one compiled
Module (toolchainFor compiles the module once; Toolchain.run instantiates
it every call), so a trap in one run has nothing to leave behind for the next
one. There is no instance to poison and nothing here re-instantiates the
module itself.
The .ino tabs are joined and wrapped the way the Arduino IDE does it
(src/compile/prototypes.ts): #include "arduino.h", then the main tab and the
other .ino tabs alphabetically, each behind #line 1 "<tab>.ino", with a
prototype for every function the tabs define written just ahead of the first
function, each behind a #line pointing at its definition, and a #line after
the block. So a sketch that calls a function defined below loop() builds, and
the compiler still reports a problem at sketch.ino:12:3 so the build panel can
jump the editor there. Templates, class members, anything in a namespace, a
function the sketch declares itself and a function whose signature names
something declared further down get no prototype; a function inside #if
blocks gets its prototype inside a copy of the same conditions; default
arguments move into the prototype and are blanked, column for column, out of
the definition. .c compiles as C11, .cpp/.cc/.ino as C++17; anything
else is written into /project for the includes and not compiled.
The user's files are compiled with the runtime's warning flags minus -Werror
(userWarnings in bundles.ts): -Wall -Wextra warnings are reported in the
build panel, and a cached object keeps its warnings so they are shown again on
the next Run, but a warning does not stop the build, as in the Arduino IDE. The
runtime itself, and projectPlan(..., { strict: true }) (the Xtensa proof, the
stock-library and communication-module tests), keep -Werror, which is what
holds Mokxi's own code and examples warning-clean.
What is cached, and where
| what | where | key | lost when |
|---|---|---|---|
clang.wasm bytes |
Cache API, mokxi-toolchain-v1 |
/toolchain/<version>/clang.wasm |
no family uses the version any more, the disk is full, storage is cleared |
| the runtime's object files | Cache API, same store | /toolchain/<version>/runtime/<family>-<bundle hash>.objects |
the version changes, the firmware changes, storage is cleared |
the compiled WebAssembly.Module |
the worker, in memory | toolchain version | the page closes |
| the runtime's object files | the worker, in memory | toolchain version + family | the page closes |
| each project source's object file | the worker, in memory | source text + the exact command | the page closes |
| the built ELF | the app, in memory | board id + file signature | the page closes |
The runtime objects are a dozen files of a few kilobytes each, packed into one
cache entry rather than a dozen (MOKO, a header length, a JSON index of
[path, length] pairs, then the bytes end to end), so a hit is one match and
one arrayBuffer instead of twelve of each. The key carries a hash of the
runtime bundle exactly as it was fetched, every source and header and flag in
it, so editing firmware/src/gpio.c and regenerating produces a different key
rather than a stale object file. Anything that cannot be unpacked is deleted and
rebuilt, because a blob we cannot read is worse than no blob.
What is no longer reachable goes on the way past: every entry of an older toolchain, and a runtime for a family whose sources have moved on. A family this page says nothing about is left alone: a user with an Uno runtime cached who spends today on an ESP32-C3 keeps it.
Every part of this degrades: no Cache API, a cache that throws, a put that
hits a quota: each means "do the work again", never "no compiler".
Measured
In Chromium 141 on the build container, against npm run dev on loopback
(which serves clang.wasm uncompressed, so the download is the pessimistic
case; production serves it from R2 with Content-Encoding: br):
| click to running | what it did | |
|---|---|---|
| first build, cold | 6.1 to 7.8 s | 74 MB download, WebAssembly.compile, 12 runtime objects, sketch, link, strip |
| second visit and after | 1.5 to 1.8 s | no clang.wasm request; runtime out of the Cache API in 3 to 9 ms |
| edit and Run again, same session | 0.21 s | sketch.ino 51 ms, link 10 ms, strip 5 ms |
| Run with no edit | no compile at all | |
| the ELF | 1.5 KB | blinks GPIO 8 at the rate the sketch asks for, on the real engine at 1.00x |
Four visits in one browser profile made one request for clang.wasm, and
the second and every later one skipped the runtime entirely:
visit 1: 7758 ms Building… -> compiling the esp32c3 runtime… -> compiling your files…
crt0.S 124+54 ms | gpio.c 826 | time.c 454 | uart.c 195 | interrupt.c 148
| support.c 429 | format.c 483 | math.c 80 | print.cpp 498 | main.cpp 58
| sketch.ino 65 | link 39 | strip 11
visit 2: 1754 ms loading the compiler… -> compiling your files…
esp32c3 runtime (cached) 9 ms | sketch.ino 676 | link 35 | strip 11
visit 3: 1464 ms esp32c3 runtime (cached) 3 ms | sketch.ino 550 | link 43 | strip 11
visit 4: 1540 ms esp32c3 runtime (cached) 6 ms | sketch.ino 514 | link 38 | strip 14
What is left of a return visit is two things, neither of them the runtime: about
0.7 s to read 74 MB back out of the Cache API and compileStreaming it, and
about 0.5 s for the first tool run in a freshly compiled module, which is the
engine warming up; the second edit in the same session is the 51 ms above.
The build panel shows the download percentage, every step with its timing, and
one line at the end: Built esp1.elf, 1.5 KB, 0.21 s.
What the Worker has to serve
/toolchain/<version>/<file> must come from the R2 bucket mokxi-toolchain
(objects <version>/<file>), with a long Cache-Control (the path is
versioned, so the objects are immutable) and Content-Encoding: br for the
wasm. The response needs Content-Type: application/wasm and a
Content-Length: the panel's progress bar reads the length, and
WebAssembly.compileStreaming refuses anything that is not application/wasm.
ST's CMSIS and HAL on the STM32 boards
The Nucleo-F411RE, the Black Pill and the Blue Pill also build code written
for ST's own headers: register code that includes stm32f4xx.h (or
stm32f1xx.h, or the old stm32f10x.h) and STM32CubeIDE projects on the
STM32Cube HAL. ST's files are vendored in firmware/stm32cube/ (see its
README for the upstream commits and licenses) and packed by
web/scripts/bundle-stm32cube.mjs into web/public/firmware/stm32cube/f4.json
and f1.json, about 0.47 and 0.40 MB with Brotli. They are not in the
runtime bundles and not on the editor's first load: the worker fetches one
only when a project includes one of ST's headers, or a main.h it does not
have (wantsCube in src/compile/stm32cube.ts), and keeps it for the page.
cubePlan decides the rest, and projectPlan follows it:
- the pack is mounted at
/stm32cube, its include directories go on the path, with-DSTM32F411xEor-DSTM32F103xB,-DUSE_HAL_DRIVERwhen the HAL is used,-DUSE_FULL_LL_DRIVERwhen LL is, and-DMOKXI_ST_CMSIS, which makes the runtime's own board header defer to ST's in a sketch; - the HAL modules the project's
stm32f?xx_hal_conf.henables, the LL files it includes and the system file are compiled with-wand linked as library members, so a first HAL build compiles about a dozen of ST's files (9 to 15 seconds on clang-v2's wasm in the tests) and the next one reuses their objects; - a project with its own
main()is linked bare (bareMetalLinkInputs): the runtime's startup code and vector table as they are, every other runtime object a library member, andmokxi_cube_bare.croutingprintf()through the project's_write()or__io_putchar(). A sketch keeps the Arduino core, andmokxi_cube_sketch.cmakes the HAL's tickmillis(); - whatever a pasted project lacks is written with CubeIDE's defaults for the
board (
main.h, the HAL configuration, the SysTick handler, an MSP file), ST's startup file and CubeIDE'ssyscalls.candsysmem.care left out, and each of those is a note in the build log (ProjectPlan.notes); - a project that calls a peripheral the simulated chip lacks (SPI, I2C, DMA, the RTC and others) gets a note saying so; the build still succeeds.
The simulated chips have no flash interface, power controller or bit-band
region, and the HAL writes all three in HAL_Init() and in every CubeIDE
SystemClock_Config(). The pack builder adds one include to ST's device
header (mokxi_cube_regs.h: FLASH and PWR become RAM of the same layout)
and one to stm32f?xx_hal.h (mokxi_cube_hal.h: the RCC macros' bit-band
writes become ordinary register writes); the vendored files are unchanged.
src/compile/stm32cube.test.ts builds and runs every example in
firmware/stm32cube/examples/, and /learn/stm32-hal-register-code is the
page for users.
Limits
- C, C++ and assembly (
.Sand.s, see above). No MicroPython and no other language: the Pico's MicroPython and a Raspberry Pi'smain.pyrun a firmware image rather than going through this path. - A project is flat: file names carry no directories, so the include path is one directory deep.
- A board whose catalog
programstring names no family has no family at all, sofamilyForfinds nothing and it runs its prebuilt ELF. A board whose family is notreadydoes the same, with the family'snoteshown in the panel. - A return visit still pays about 0.7 s to get 74 MB back out of the Cache API
and compile it.
compileStreamingfrom a cachedResponseis what lets the engine reuse its own code cache, but nothing here can make that a certainty. - In development the toolchain is served by Vite out of
web/public/toolchain/; in production the Worker serves it from R2.worker/assets.tsanswers 404 for/toolchain/on purpose, so the dev middleware inweb/vite.config.tshas to leave that prefix alone. It does, and breaking that is how the compiler ends up downloading the 404 page.