ESP32 and Microcontroller Targets

PXX cross-compiles Pascal to the two ESP32 CPU families with no vendor compiler in the loop:

Chip CPU PXX target
ESP32-C3 RISC-V (RV32IMC) --target=riscv32
ESP32-S2 / S3 Xtensa LX7 --target=xtensa

There are two integration modes.

Mode 1: Bare metal (--esp-profile=bare)

Produces a self-contained ELF linked at the SoC SRAM map. No ESP-IDF, no FreeRTOS, no linker: the program owns startup (stack setup) and runs directly from RAM. QEMU boots it with -kernel; on hardware you load it like any RAM image.

./pxx --target=riscv32 --esp-profile=bare blink.pas blink.elf
tools/esp_run_bare.sh --chip esp32c3 blink.pas     # compile + boot under QEMU

Under the bare profile the compiler defines PXX_ESP_BARE, so one source file can serve both the device and a desktop oracle build:

program EspHello;

{$ifdef PXX_ESP_BARE}
{ Bare metal: write a byte straight to the UART0 TX FIFO (MMIO). }
procedure PutC(code: Integer);
begin
  PByte(Int64($60000000))^ := Byte(code);
end;
{$else}
procedure PutC(code: Integer);
var b: Byte; r: Int64;
begin
  b := code;
  r := __pxxrawsyscall(1, 1, Int64(@b), 1);
end;
{$endif}

procedure PutS(const s: AnsiString);
var i: Integer;
begin
  for i := 1 to Length(s) do PutC(Ord(s[i]));
end;

begin
  PutS('hello esp32');
  PutC(10);
{$ifdef PXX_ESP_BARE} while True do ; {$endif}
end.

This is exactly how the project's own gate works: make test-esp-bare compiles the same source for x86-64 and for both chips, boots the chip images under Espressif QEMU, and diffs the raw UART bytes against the desktop run.

Notes for the bare profile:

Mode 2: ESP-IDF component (--emit-obj)

Compiles to a relocatable object (main.o) whose exported app_main is called by ESP-IDF's startup task. Externals such as esp_rom_printf and vTaskDelay resolve at IDF link time; FreeRTOS, Wi-Fi and the vendor peripheral drivers stay available. See examples/esp32/hello-c3/ and examples/esp32/net-c3/ for complete buildable projects.

procedure esp_rom_printf(fmt: string; v: Integer); external;
procedure vTaskDelay(ticks: Integer); external;

writeln and readln WORK on this profile (unlike the bare one, further down): they go through the libc stdout/stdin streams that ESP-IDF's console sets up, so ordinary Pascal I/O reaches the serial monitor. esp_rom_printf above remains available and is what IDF's own code uses; either is fine.

Two consequences of using the STREAMS rather than a file descriptor, both of which the runtime is stuck with rather than choosing:

Code size and memory footprint

Measured on 2026-08-30 with pinned v393 (empty program, --esp-profile=bare). Re-measure rather than trust the table — these have roughly doubled since they were first published, and a figure without a pin behind it is a promise nobody renewed:

pxx --target=esp32c3 --esp-profile=bare empty.pas out    # prints code/data/bss
code data bss
esp32c3 (riscv32) ~50 KB 344 B ~104 KB
esp32s3 (xtensa) ~43 KB 344 B ~104 KB

What that buys you — the floor is not "hello world plus bloat", it is the full managed runtime:

An ESP32-C3 has roughly 400 KB of usable SRAM, so a minimal PXX image plus stack currently sits around a quarter of it. That is comfortable but no longer negligible, and it is the honest way to say it — an earlier version of this page claimed "well under a quarter" against a bss figure that has since grown by about half.

Floating point

The ESP cores are compiled without FPU codegen; float operations lower to integer soft-float kernels. On bare images this support is opt-in so programs that never touch floats do not pay for it:

uses softfloat;   { Double/Single arithmetic; ~54 KB of code on xtensa, ~64 KB on riscv32 }

Without the unit, float operations fail at compile time with a clear error rather than silently linking the kernels in. 64-bit integer arithmetic (Int64/UInt64, including multiply, divide and shifts) is always available and validated against the x86-64 oracle.

Real is Single here, not Double. These cores have no hardware double, so Real — the type that means "the native float of this machine" — is the 4-byte one. SizeOf(Real) is 4, an array of Real strides by 4, and Real arithmetic carries about 7 decimal digits. This is deliberate: it keeps Real code on the cheaper soft-float kernels, and Double remains available by name for the places that genuinely need the precision and can afford it.

The trap worth knowing about is shared data. A record containing a Real does not have the same layout on an ESP32 and on the x86-64 host it talks to. Name Single or Double explicitly in anything you serialise, log in binary, or map onto a struct the other side also declares.

See Types.

Generators and language features

Most of the shared-IR language surface works on the ESP targets: records, sets, 64-bit integers, dynamic arrays, proc-typed variables (indirect calls), @proc, and stackless generators. Classes (with virtual dispatch) work on both ESP targets. try/except/finally (including re-raise) works on the bare profile of both chips; an unhandled raise halts the program. Generators on any non-x86-64 target must use the stackless form:

uses slgen;   { the stackless-generator runtime unit }

function Squares(n: Integer): Integer; generator; stackless;
var i: Integer;
begin
  for i := 1 to n do yield i * i;
end;

Guard rails for small RAM

Next