qfpc: Pascal Compiler for QSOE (FPC-based)
qfpc 0.1 is tagged today. It is an Object Pascal compiler for RISC-V 64 and for QSOE, and for nothing else: one target, one dialect, no other operating system and no other machine. It compiles itself, its run-time library runs on the boards, and its first user, qupkg, runs its whole test cycle on the K3 as a qfpc-built program that QSOE's own linker linked. The first commit is dated 12 September. This post is about the nine days between, and about the decisions, which were the part of the work that was mine.
In layers, in the order the compiler goes through them:
| layer | what it is | units | lines |
|---|---|---|---|
base | types, containers, messages, files, settings, the target | 14 | 5.6 k |
lex | characters to tokens, the directives, conditional compilation | 11 | 4.3 k |
sym | symbols, types, symbol tables, the unit file | 33 | 16.5 k |
nodes | the parse tree, the type check, the first pass | 60 | 28.1 k |
parse | the parser | 28 | 12.8 k |
cg | registers, the calling convention, the allocators, the primitives | 24 | 12.5 k |
ncg | code from the tree | 19 | 8.2 k |
asm | the lists, the instructions, the rvasm writer, typed constants, the inline assembler | 18 | 9.2 k |
tables | VMTs, type information, resource strings, a program's tables | 7 | 2.8 k |
driver | the command line, finding and loading units, recompilation, the tools | 7 | 2.2 k |
221 units and 103,000 lines of Pascal, none of the units longer than 832 lines; the run-time library is another 11 thousand, the tests 7 thousand. Every layer has a README and a test of its own, and the whole is under GPL-2.0-or-later, because the code is Free Pascal's.
Where it comes from
It's the Free Pascal compiler, Florian Klaempfl's and the FPC team's. This open-source Object Pascal compiler has had a RISC-V 64 back end for years, and it is GPL. I took the compiler directory of FPC's main branch as it stood on 11 September — 595,000 lines across every target FPC has ever had, 286 thousand in the generic part alone — and kept what one target and one dialect need. What survived is 103 thousand lines, and it has a different shape: scanner.pas, 7,036 lines, became the 11 units of lex/; symdef.pas, 9,901 lines, became 14 units, one per family of types. The rule from the first evening was that the code must look nice — four spaces, a header comment on every routine, a file that fits in one head — and much of FPC's code does not and cannot, because it carries thirty years and a dozen targets. Gone are every {$ifdef} of another CPU or system, the {$MODE} zoo, the code page machinery, the message files and their translations (a hand-written English table instead), and the unit names squeezed to eight characters in another century: cutils, globtype and cfileutl are utils, globtypes and fileutils. What FPC does that is right — the two-pass tree, the register allocator, the calling-convention machinery, the PPU format's idea — is kept, and reads better for the cutting.
One dialect
There is one Object Pascal here, the class-based one, and no directive asks for another. AnsiString is UTF-8 and UnicodeString is UCS-2, and there is no code page anywhere: a source file is UTF-8, a string constant is decoded once, and a Char is a byte. Single and Double, and Extended is Double, since RISC-V has no 80-bit float. No generics, no variants, no Turbo Pascal objects (this may change, though), no helpers. Kept: interfaces of both the COM and the CORBA kind, records with methods, resource strings, threadvars, libraries with exports, inline assembler in rvasm's syntax. Two calling conventions, cdecl and the default, and the default is what the ABI says. One rule of my own: a constant out of the range of the type it lands in — B := 300 for a Byte — is an error whether range checks are on or not; FPC warns, and I have never wanted the warning.
A unit is one file
A compiled unit is one file, name.qpu: an ELF relocatable object with the code and the data in the usual sections and, in a section .qfpc.unit that is neither loaded nor written, the interface — FPC's PPU, with its three checksums: over the whole, over the interface, over what the interface exposes of other units. The idea is from 2005 or 2006, when I first wanted Turbo Pascal's .tpu back — one file that is both the object and the interface, where FPC keeps a .ppu next to an .o — and Rust's rlib, Swift's modules and Delphi's .dcu have all wanted the same thing since. What it buys is simple: the object of a unit is its unit file, a .qpu links as it is, an ar archive of them carries every interface of a library, and a linker that honors SHF_EXCLUDE drops the blob from an executable without being asked. GNU's does, QSOE's does, and rvasm learned an exclude flag for the section so both would.
The driver decides what to recompile before it loads anything. It reads the head of every unit file — the units it used, with the interface checksums it saw — and the heads of those, and marks stale whatever is missing, older than its source, of another compiler, or built against an interface that has since changed, transitively, until nothing changes. What is left is fresh for good and can be loaded in any order, which is what makes a cycle through implementations no trouble at all. The price is that a change deep down recompiles everything above it, whether the interfaces changed or not; the cost is a few seconds. qupkg's 29 units compile in under a second, and the second run loads them in 11 milliseconds.
rvasm, and QSOE's own linker
qfpc writes assembler source for rvasm and for nothing else: no GNU assembler dialect, no binary writer. rvasm is the NASM-flavored RISC-V assembler I mentioned with qjmcc, and the output is meant to be read, not just assembled — symbols are Unit.Name (Sample.TShape.Move, Sample.Describe$2 for the second overload, Sample.TShape.$vmt), a routine sits between proc and endp, stores are written memory first, labels are numbered in order of appearance, data is db and dq eight to a line. A program is then linked as QSOE's own C programs are: crt0.o, its objects, libc.so, at a fixed address, without an interpreter, since taskman is the loader — through ld, and which ld does not matter. QSOE's own, which grew a RISC-V back end this month on top of the Sun linker (ld and libld from OpenSolaris and illumos, objcopy and strip from mcs; it deserves a post of its own), and GNU's take the same command line, and the new one put qupkg's 32 objects together in 7 milliseconds. Neither needs libgcc, because nothing qfpc emits does.
The run-time library
The run-time library is FPC's too, taken apart the same way: System is one unit made of includes, each about one domain, on libc.so. crt0 calls main, which System exports; the heap is malloc's; a real goes to text through snprintf and comes back through strtod; there is no libm in QSOE's libc, so Exp, Ln, Sin, Cos and ArcTan are computed in Pascal, from fdlibm and Cephes; an exception frame is a setjmp; and a threadvar lives in a thread-specific block, because QSOE has no ELF TLS. Unit QSOE is not a port of FPC's BaseUnix. It is the twenty-odd calls that qupkg's host.pas needed — Stat, SymLink, Unlink, MkDir, OpenDir, Spawn, WaitPid, GetTimeOfDay, errno as a Pascal result — and host.pas became its first client. SysUtils is the subset the compiler itself uses; TypInfo is the type information a program may read. On the evening of 18 September, six days after the first commit, hello ran on the K3: the first qfpc code on a real QSOE machine.
The compiler compiles itself
The same night the compiler compiled by itself, linked with QSOE's libc, ran on QSOE/N under QEMU and wrote a hello.asm identical to the host compiler's. The next day came the second stage: the RISC-V compiler, under qemu-user, compiled every unit of the compiler, and every object was byte for byte the host compiler's — the fixed point a bootstrap is looking for. What it found on the way was small and instructive. TypInfo's TOrdType was four bytes wide until the unit had its {$PACKENUM 1}, so every enumeration lookup on RISC-V read the wrong byte, which showed only as {$WARN} failing in the self-compiled compiler and nowhere else; a method-pointer temporary sized at eight bytes instead of sixteen clobbered a saved s0; a short-string typed constant was one byte too long; and a read from QSOE's tmpfs comes back short, 896 bytes of a larger request, which a compiler reading its source had better expect. To be compiled by itself the compiler also had to live within what it implements, so its two Turbo Pascal objects became records and Format went away.
The first user
qupkg is QSOE's package manager — a port of Serge Vakulenko's pkg for Braam to Pascal, 29 units, ~9400 lines, and deliberately plain: records with methods, dynamic arrays, sets, AnsiString, no classes, no exceptions, no SysUtils. On 17 September a throwaway driver compiled all 29 of them — 44.7 thousand lines of assembler, 373 routines — and found two compiler bugs doing it: Length of a dynamic array crashed the register typing, and a temporary whose address had been passed was never freed (FPC has the same leak, hidden under EXTDEBUG). On the 19th the whole of qupkg's test cycle — update, install, scripts spawned, triggers, sideload, remove, upgrade, clean — ran on QSOE/N under QEMU, and that evening on the K3 under QSOE/L: 0 failures, for the binary GNU ld linked and for the one QSOE's linker linked, which was the first output of that linker to run on a board.
As with the 0.3 release, a program on two systems is worth the bugs only the second one finds. The first K3 run died forty processes in with taskman's CSpace exhausted, a capability leak in QSOE/L's taskman, fixed on the QSOE side the same evening. And QSOE told a few truths about itself: stat on the boot archive calls a directory a regular file; mkdir on an existing directory reached through the /home symlink answers ENOENT, not EEXIST; and a Pascal Reset opens a file read-write by default (FileMode 2, FPC's convention), which a read-only place refuses — every program that reads from where it cannot write has that trap, and the run-time library owes it an answer.
How it was made
I should say how nine days is possible, because it is not that I typed 103 thousand lines. Claude Code did the porting — reading FPC, cutting, reshaping, writing the tests, finding the bugs under qemu with gdb — and I did what could not be delegated: what to keep and what to cut, the dialect, the strings, the unit file, the frame layout, the run-time library on libc rather than on system calls, the surface of the OS unit, and the reading. Every layer came as a proposal with its open questions listed, and I answered them before it was built. It is the same shape as qjmcc: a toolchain I can read in full, on an operating system I architected, and a tool that does the parts I never had the years to do by hand. I will not make it larger than it is. It is a small compiler, for one machine and one system, and it works.
Next
rvasm on QSOE: a board compiles with -s today and assembles elsewhere. Then the compiler, the assembler and the linker as one .qup package that qupkg installs. Then the peephole pass, for which the numbers are already in: in qupkg's code a tenth of the instructions are mv, there are 641 li-and-mul pairs for constant element sizes, and a 32-bit index is zero-extended by two shifts. Then threads, on pthreads, and debugging information. The version is the git tag, as in every QSOE component.
Source at gitlab.com/qsoe/devtools/qfpc, rvasm at gitlab.com/qsoe/devtools/rvasm. Every file carries its SPDX line and its two copyright lines, the original and the modifications.