Embedded system: processor and logic together
The FPGA generation (Embedded FPGA) turns a state machine the model gives to programmable logic into VHDL in fixed point, proves it in GHDL against a bit-exact C reference, and synthesises it alone. This part puts it where it runs: one SoC, a VexRiscv processor running the Zephyr application with the generated blocks on its bus, used two ways from one description:
- simulated cycle by cycle (Verilator): the processor, the bus and the blocks together, each test case run through the driver and judged as
arclang hdljudges the VHDL; - built into one bitstream for a board (Yosys, nextpnr-ecp5, ecppack): the same SoC, the same Verilog of each block, the same Zephyr image in its RAM.
arclang soc model.arc # each case in the simulated SoC
arclang soc model.arc --board ulx3s # and the bitstream for the board
arclang soc model.arc --board ulx3s --no-sim # the bitstream only
arclang soc model.arc --port /dev/ttyUSB0 # each case on the board, which holds the bitstreamThe SoC
arclang soc writes soc.json (what the model says) and runs soc.py (fixed, part of ArcLang: src/compiler/embedded/system/soc.py) with LiteX:
| Part | What |
|---|---|
| CPU | VexRiscv (LiteX's standard variant), reset into the RAM: no BIOS, no ROM |
| RAM | 64 KiB of block RAM at 0x40000000, holding the Zephyr application from reset |
| Console | LiteX's UART (in the simulator: a TCP port; on the ULX3S: the FTDI USB serial port, 115200 bauds) |
| Timer | LiteX's timer0, Zephyr's system clock |
| Blocks | each allocated machine's wrapper <model>_<machine>_wb on the Wishbone bus at 0xB0000000 + 0x1000 k, uncached: where its driver looks for it (<BLOCK>_HW_BASE) |
| Clock | the node's clock: (25 MHz on the ULX3S: its oscillator; another frequency: the ECP5's PLL) |
The steps:
- the Zephyr application of the model (
export -f zephyr), each allocated machine called through its driver, built for the step() API (pil_direct); - each block's VHDL turned into Verilog by GHDL's synthesis (
ghdl --synth --out=verilog): one netlist for the simulator and the bitstream; - the SoC's map (
csr.json) and from it Zephyr's devicetree overlay (LiteX'slitex_json2dts_zephyr, and the board's other nodes disabled): the application knows where the UART and the timer are; west buildforlitex_vexriscvwith that overlay:zephyr.bin(24 KB for the charger);- the SoC with
zephyr.binin its RAM, compiled by Verilator (a few seconds) or synthesised for the board.
The SoC in the bitstream and in the simulator is one Python class; only the platform differs (LiteX's simulator, or radiona_ulx3s).
In the simulated SoC
$ tools/embedded/system/run.sh arclang soc examples/energy/battery-charger/battery_charger.arc
SoC examples/energy/battery-charger/battery_charger.arc — LiteX 2026.8 (VexRiscv standard); Verilator 5.006 2023-01-22 rev (Debian 5.006-3); GHDL 7.0.0-dev (6.0.0.r615.gc9043fcc4) [Dunoon edition]; cc (Debian 12.2.0-14+deb12u1) 12.2.0
✓ pass TC-CHG-003
verifies SYS-CHG-004, SYS-CHG-005 · machine Charger · block battery_charger_charger
model: pass · SoC: pass
timing: step() took at most 296.6 us in the simulated SoC at 25 MHz (the driver, the bus and the block)
SoC vs fixed-point C: 61 steps × 34 outputs, largest difference 0 (bit-exact)
fixed point vs model: largest difference 1.788e-7 on charger.setpoint (tolerance 1.586e-6, derived from the formats)
...
ok SYS-CHG-004: at(300 ms, "Charger".target) == 16 A model 16 A · SoC 16 A
ok final("Charger".charge) > 0.0001 Ah model 0.001375 Ah · SoC 0.001375 Ah
...
3 SoC verdict(s) cached in examples/energy/battery-charger/.arclang/sim-cacheFor each execute: case of an allocated machine:
- the model runs and its expectations are judged; the fixed-point C reference is compiled and run on this computer (as in
arclang hdl); - the simulator starts (a fresh reset), Zephyr boots on the simulated VexRiscv; the bench connects to the console and sends each step's inputs as one line (bit for bit, as PIL does); the application converts them to words (
arc_fx_from, in the VexRiscv's soft floating point), writes them to the block's registers, starts the step, waits for DONE, reads the outputs back and reports them; - the block's outputs must be the fixed-point C's word for word (difference 0), within the quantisation tolerance of the model, the expectations judged alike.
So the conversions on the processor, the driver, the bus, the register map and the block are proved together, cycle by cycle. The time reported is step() on the target's clock: the conversions, the bus transactions and the block's own clocks (the charger: about 180 to 300 us at 25 MHz, mostly the soft floating point of the conversions; the window, all integers: 23 us).
The verdicts are kept as <case>.soc.json (kind soc) beside the hdl ones; check, gate and the tables read them as they read PIL verdicts, keyed by the HDL key, the SoC script, the RAM size and every file of the application.
The console link. The simulator boots before the bench connects, a board before its port is opened: the ARCLANG READY of the boot is missed. The PIL application now answers a line ? with ARCLANG READY; the bench asks once a second until it is answered (the Renode bench of processor in the loop never sends ?: unchanged).
In the loop with the plant
A case with a plant (execute: { plant: … }, docs/embedded-c.md) runs the SoC in the loop: the bench sends the plant's outputs as the step's line, reads the report, and the plant advances on what the SoC computed. On the board (--port) the same, through the serial port: hardware in the loop, in lock step with the plant stepped on this computer.
The bitstream
$ tools/embedded/system/run.sh arclang soc examples/energy/battery-charger/battery_charger.arc --board ulx3s --no-sim
✓ bitstream for ulx3s (LFE5U-25F-6BG381C): VexRiscv, 64 KiB of RAM holding the Zephyr application (24132 bytes), battery_charger_charger_wb
6989 LUT4, 3385 flip-flops, 20 DSP (MULT18X18D), 42 block RAM (DP16KD)
Fmax 37.22 MHz for a clock of 25 MHz: met
bitstream .../soc-10000us/board/gateware/radiona_ulx3s.bit
to the board: openFPGALoader -b ulx3s .../radiona_ulx3s.bitOn the LFE5U-25F: 29 % of the LUTs, 75 % of the block RAM (the 64 KiB of RAM: 32 of them), 71 % of the multipliers. The resources and Fmax are nextpnr's, after routing; the command fails when timing is not met.
On the board. Write the bitstream with openFPGALoader -b ulx3s <bit> (into the FPGA's SRAM; -f writes the flash) from a computer that sees the board's USB (Docker Desktop on macOS does not pass USB devices through: run openFPGALoader on the host, or from a Linux host with --device). Then arclang soc model.arc --port /dev/ttyUSB0 runs every case on the board through its serial port with the same bench and the same judgement; the time reported is then the board's.
The node's part chooses the board: an LFE5U-25F/45F/85F in a BG381 package, speed 6, is the ULX3S's. Other boards are a LiteX platform away (litex_boards), not built in.
In Docker
docker build -t arclang-embedded:1 tools/embedded
docker build -t arclang-embedded-soc:1 tools/embedded/soc
docker build -t arclang-embedded-fpga:1 tools/embedded/fpga
docker build -t arclang-embedded-system:1 tools/embedded/system
tools/embedded/system/run.sh cargo test --release --test embedded_system -- --include-ignoredThe system image is the FPGA image (GHDL, Yosys, nextpnr-ecp5) with Zephyr, its SDK and west copied from the SoC image, LiteX, litex-boards and their data packages at 2026.08, Migen at e19524c9, Verilator 5.006 and openFPGALoader 1.2.0. The CI job "Embedded system" builds the four images and runs those tests.
Boards and clocks
| Board | Part | Oscillator | openFPGALoader |
|---|---|---|---|
ulx3s |
LFE5U-12F/25F/45F/85F-6BG381C | 25 MHz | -b ulx3s |
colorlight_i5 |
LFE5U-25F-6BG381C | 25 MHz | -b colorlight-i5 |
colorlight_i9 |
LFE5U-45F-6BG381C | 25 MHz | -b colorlight-i9 |
ecp5_evn |
LFE5UM5G-85F-8BG381C | 12 MHz | -b ecp5_evn |
A node names its board with board: (arclang soc --board overrides it, --bitstream builds for the model's own); without one, the first board carrying its part. The charger on the Colorlight i5: 6950 LUT4, Fmax 37.1 MHz; on the ECP5 evaluation board (12 MHz to 25 MHz by the PLL): Fmax 50.9 MHz.
Clocks. The bus runs at the clock: of the node that has a processor: (else the first block's). A block whose node has another clock is in a clock domain of its own, reached through LiteX's Wishbone clock domain crossing, clocked by a clocker of its own in the simulator and by an output of the PLL on a board; every clock is timed against its own target. The charger's block at 20 MHz beside a 25 MHz VexRiscv: bit-exact in the simulated SoC; block 38.5 MHz, CPU 68.0 MHz after routing. One SoC is one FPGA: blocks on two parts are refused.
Gate level
arclang soc --gate-level simulates each block as synthesised for the ECP5: synth_ecp5 on the block alone (the synthesis of the bitstream), its netlist of ECP5 cells (LUT4, flip-flops, carry chains, MULT18X18D) simulated with Yosys's cell models inside the SoC; the processor, the RAM and the bus stay as written (third-party cores; and Yosys has no behaviour for the ECP5's block RAM, DP16KD, so a block using block RAM is refused at gate level). Yosys ships the multiplier MULT18X18D as a port list only: ArcLang writes a model of the combinational mode Yosys uses and refuses any other. The charger's block (1432 LUT4, 1347 flip-flops, 16 multipliers) and the window's: bit-exact with the fixed-point C at gate level.
Through the I/O
A case that runs through an io_binding runs the controller's loop (pil_io) on the SoC's own peripherals, the machine in its block:
| Channel | In the SoC |
|---|---|
| gpio 0 to 31, in and out | LiteX GPIOIn (with its interrupt) and GPIOOut |
| pwm 0, 1 | two LiteX PWM cores |
| uart 0, 1 | two LiteX UARTs beside the console |
| adc 0 to 7 | the register bank of the target (arclang,litex-adc) |
| watchdog | the LiteX watchdog |
| can | not yet: a case whose binding uses CAN runs through the step() API, and says so; its channels stay proved by arclang pil in Renode |
The devicetree of these channels and the aliases arclang_io_zephyr.c finds them by are generated from the SoC's map. In the simulator, a bench written in Verilog drives and records them in step with the cycles: the application tells a register of the SoC when its I/O is up and when each cycle ends (arclang_pil_bench, a no-op on a board); at those instants the bench plays the next step's operations (gpio levels, ADC values, UART bytes, read at start from player.hex) and records the outputs (recorder.txt): what the Renode bench does between two quanta, done by the simulated hardware, so a run cannot race the simulator. The recorded channels must be what the model makes them, step by step, exactly; the block's words the fixed-point C's.
✓ pass TC-PW-003
model: pass · SoC: pass
timing: 13 cycles through the I/O in the simulated SoC, 9997 to 10015 us apart for a period of 10000 us (the first at 20390 us)
SoC vs fixed-point C: 13 steps × 21 outputs, largest difference 0 (bit-exact)(the power window's door module: switches on gpio, the diagnostic link on uart 0, the motor's PWM at 800 ‰, the lamps on gpio, a status byte on uart 1, the watchdog fed every cycle.)
Conversions. The driver converts values to the block's words and back with integer arithmetic on the IEEE 754 bits (arc_fx_from, arc_fx_to): no floating-point call on the VexRiscv, the same words as the floating-point formula (checked on 2 million values). The charger's step() went from 180–300 us to 67–82 us.
Limits, said plainly
- No board here. The bitstreams are built and meet timing; the SoC they hold is the one simulated, at RTL and with its blocks at gate level. Writing one to a board and
--porthave not been run on hardware. - No CAN core in the SoC yet: a binding using CAN runs through the step() API in the SoC (its channels proved in Renode).
- The channels on a board are wired to no pin yet: on a board, cases run through the step() API; the pins of each channel are the next step.
- Gate level covers the blocks, not the processor, RAM and bus.
- One FPGA a SoC.