Documentation
pdp11-apsim — design
This document describes how pdp11-apsim runs a PDP-11 a.out on a modern host: the emulated machine, the instruction interpreter, the bit-faithful FP11, the loader, and the per-era syscall layer that turns guest sys traps…
pdp11-apsim — design
This document describes how pdp11-apsim runs a PDP-11 a.out on a modern
host: the emulated machine, the instruction interpreter, the bit-faithful
FP11, the loader, and the per-era syscall layer that turns guest sys traps
into host system calls. For how to use it, see the user guide.
apsim is a user-mode simulator, in the spirit of Apout and qemu-user: it
loads one a.out, executes its instructions, and services the sys traps its
libc stubs make — it does not boot a kernel or model the MMU, the Unibus,
or any device. There is one guest process image, no supervisor mode, no page
tables; "the kernel" is a C switch statement. The tool is one file, apsim.c
(~6,100 lines), plus ../common/universe.h (generated) for the universe list —
kept self-contained because it is the execution engine the tree's
oracle suites run their ground-truth binaries under,
and it builds with nothing but a host C compiler.
1. The emulated machine
Core is two fixed 64 KB byte arrays: M[] (data, and the shared I&D space) and
MI[] (the separate instruction space, used only by 0411/0431 binaries).
Isp points at whichever array instruction fetches read. Every guest memory
access masks its address to 16 bits (a &= 0xffff) in ld2/st2/ld1/
st1/ifetch, so a guest can never index outside its own 64 KB — a wild
pointer wraps, exactly as the hardware would.
The register file is unsigned short R[8] with SP=R[6], PC=R[7], and the
four condition codes FN/FZ/FV/FC as separate ints. There is no PSW word kept
as such; it is assembled only where one is observed (a signal frame, RTI).
ifetch reads I-space; the data loads/stores read D-space. On shared-I&D
binaries the two arrays are the same pointer, so the distinction is free; on
separate-I&D it is what lets a 0411 program address a full 64 KB of code and
64 KB of data.
2. The instruction interpreter
step() fetches one word at PC, advances PC, and dispatches in an order
chosen so overlapping encodings resolve correctly:
| order | class | note |
|---|---|---|
| 1 | conditional branches | high bits overlap other groups, so tested first |
| 2 | sys/trap (0104400|n) |
into do_sys |
| 3 | EMT (0104000|n) |
overlay switch, V1 validated-rts, else SIGEMT |
| 4 | late hardware | late_insn: MFPT/SPL/TSTSET/WRTLCK, FIS, CIS |
| 5 | FP11 group (017xxxx) |
into do_fp |
| 6 | double-operand | MOV/CMP/BIT/BIC/BIS/ADD/SUB, +010 = byte forms |
| 7 | single-operand & the rest | CLR/INC/JSR/JMP/… |
Addressing is resolved once, by operand(spec, byte), which returns a
"location" — either ISREG|n, an ISIMM literal, or a 16-bit memory address —
and performs auto-increment/decrement and index/absolute word fetches as a side
effect. Index and absolute words come from I-space (ifetch), the pointers
they yield are dereferenced in D-space (ld2). getv/putv then read or
write through the location. The one deliberate special case is MOVB into a
register, which sign-extends the byte into the high half (every other byte
instruction leaves it alone) — without it a movb of a NUL leaves stale high
bits and a printf %d format loop never terminates.
3. FP11 floating point (bit-faithful, no host FP)
The FP11 is simulated with true soft D/F arithmetic, not host doubles.
An accumulator is a struct fpv {sign, exp, frac} carrying a 56-bit mantissa
in D mode (24 in F). Every operation normalizes a 128-bit intermediate and
rounds by adding one at the first discarded bit — the FP11's round-half-up
rule, with no sticky bit and no round-to-even. A host double has only 53
mantissa bits and rounds differently, so it silently mis-rounds the last bits
(caught against ecvt.c's constant conversion: ...c3 on the real machine,
...c0 under host-double emulation). The KEV11 FIS instructions share the
exact same engine at F width, so no host floating point is used anywhere in
apsim and there is no libm dependency.
bits2fpv/fpv2bits convert to and from the on-disk DEC layout exactly both
ways; rdfloat/wrfloat move 2 or 4 guest words. The FPS word tracks D/L/T
mode; FT (chop) truncates instead of rounding, exercised by the factor
regression.
late_insn covers the instructions later PDP-11s added over the base set:
MFPT, SPL, TSTSET/WRTLCK, the FIS group, and the Commercial
Instruction Set — CIS being packed/zoned decimal arithmetic on 128-bit
intermediates (dec_read/dec_write/dec_cc), enough of the ADDN/SUBN/CMPN/CVT
family for the corpus. The whole group is the subject of the cistest.s golden
probe (§Testing).
4. The loader
load_aout_env is shared by the initial exec and by guest exec(2). It reads
the eight-word header a word at a time, masking each to 16 bits so a
short or hostile file cannot turn a size into a negative int and thence a huge
size_t. It recognises:
| magic | form | load |
|---|---|---|
0405 |
First Edition | whole file image at core 040000, entry 040000 |
0407 |
shared I&D, unsplit | text then data at 0 (V7 lineage) / at 040000 under a V1/V2 universe |
0410 |
shared I&D, read-only text | data on the next 8 KB boundary |
0411 |
separate I&D | text into MI[], data into M[], both from 0 |
0430 |
auto-overlay, shared I&D | overlay window above text, data above it |
0431 |
auto-overlay, separate I&D | base text and the window in I-space, data at D:0 |
Segments are read by loadseg, which clamps the count so off+count can
never exceed 0x10000 — a short file just leaves the tail zero (core was
memset first).
Overlays. A 0430/0431 header carries max_ovl and an ov_siz[] array (7
slots through 2.9, 15 from 2.10). Each image reads into a fixed 16 KB ov_img[]
slot, and the loader bounds every ov_siz to the slot and to core because
the run-time EMT handler memcpys ov_siz bytes into the window. That handler
(ovno in r0, the 2.9 csv.s protocol) zeroes the ov_max window and copies
the image in; for 0431 the window lives in I-space (MI[]), which is how 2.11
csh fits (55 K base + window + data) into 64 K.
Scripts. A #! file execs the named interpreter with the classic argv
rewrite (one optional argument, one level). A shebang-less text file on
apsim's own command line runs through the guest /bin/sh — the execvp
courtesy, since there is no calling shell to do the ENOEXEC fallback — while a
guest exec of such a file fails authentically.
The exec stack is laid out where crt0 expects it (setup_stack: argc, argv,
NULL, envp, NULL, strings near the top of D-space; env from the caller, else
$APSIM_ENV, else a default). First Edition uses setup_stack_v1 (a
-1-terminated argv near the top of 8 K core — pointers must be positive 16-bit
values or ar's member walk breaks). The entry PC is masked with ~1: the V7
lineage treats an odd a_entry as a marker, not an address.
5. Universes and kernel personalities
apsim serves 20 universes (V1–V7, 1BSD/2BSD/2.79/2.8/2.9/2.10/2.11, System III,
SVR2, Ultrix-11 1.0–3.1), selected by --universe/-u or $PDP11_UNIVERSE;
the default is bsd29. The list lives in the generated universe.h, which
maps each universe to one of eight era-ordered kernel personalities:
K_V1 < K_V56 < K_V7 < K_SYS3 < K_ULTRIX < K_BSD2X < K_BSD210 < K_BSD211
Because the enum is era-ordered, lineage checks read as ranges — Kern >= PDP11_K_BSD210 means "the 4.3-numbered eras" — and one comparison decides, for
instance, whether stackargs (arguments passed on the C stack rather than
inline after the trap) is in force. Selecting a universe sets Kern, Univ,
and stackargs; a 0405 magic still forces the First Edition personality at
load time regardless of the universe named.
6. The syscall layer
do_sys decodes the trap's argument-passing convention, then hands a canonical
number to do_syscall:
- indirect (
sys 0; .word blk) — the real number and inline args live atblk; - inline (V1..2.9) — argument words follow the trap;
do_sysstepsPCpastsysnargs(num)of them (counts taken verbatim from Apout'sv7arg[]); - stack (2.10/2.11) — a bare
sys Nhas no inline words; args are read from2(sp)…, and a per-call table decides whether the first arg is an fd (which still rides inr0); - Ultrix
0200— the 2.0 FP-less/binsets bit0200to mean "args on the C stack" with the call number in the low 7 bits (sys 204=write|0200).
do_syscall is a single canonical switch in V7 numbering — the lineage
trunk, which 2.8/2.9 extend in place — plus synthetic C_* extension ids for
calls with no V7 ancestor (sockets, sigvec, statfs, …). Each renumbering
era supplies one data remap table (Bsd210Remap, Bsd211Remap,
Sys3Remap, Ultrix3Remap) mapping its guest numbers onto canonical ones;
sremap_apply moves a number and, via the SR_STAT flag, records when the era
wants the newer stat shape. A V5/V6 call that did not yet exist is gated to
EINVAL by v56_nosys.
Around that switch:
- struct stat has three era writers —
put_stat_v6(packed 36-byte, 16-bit size),put_stat_v7(32-bit size),put_stat_211(52-byte, 4.3 layout) — chosen by universe and theSR_STATflag. - errno is mapped host→guest by
errno_h2g: the first 34 are the shared V7 inheritance and pass through; past that the eras diverge (the 4.3 eras movedEAGAINto 35 and gained the socket errno block at 35..68), so an unmappable Linux number collapses to a sane in-range meaning rather than leaking through. An unknown syscall number returnsENOSYS, never halts. - directories — classic Unix reads them with
read(2), which Linux refuses, so apsim snapshots an opened directory into the era's on-disk record format (V1 10-byte, V5..2.9 16-byte, 2.10/2.11 4.3 variable) and serves reads from the snapshot; the reallsworks. Sockets are real host sockets — 2.11'sAF_/SOCK_constants andsockaddr_inalready match the host, so only the option level/name and theAF_UNIXpath need translating. - signals use the full 4.3 reliable frame: delivery builds the 26-byte
sendsigframe (includingsc_ovno, the overlay mapped when the signal hit, sosigreturnrestores it), enters the libcsigtramp, and blockssig|hmaskuntilsigreturn. Guest numbers are translated to/from host numbers (they agree for most of 1–15 but diverge in the 16–31 job-control set).fork/wait/pipeuse the real host calls, so a child inherits a full copy of the guest image for free; host process groups back the guest's, so a default-stop signal really stops andSIGCONTresumes it — the genuine 2.11 csh runs jobs and pipelines. - ptrace (opt-in via
$APSIM_PTRACE) can't reach a forked tracee'sM[]/R[]directly, so a traced guest parks on its trap and serves the classic ops (PT_READ/WRITE_I/D/U,PT_CONTINUE,PT_STEP,PT_KILL) over an abstractAF_UNIXsocket named for its pid; the tracer'swaitsees a syntheticWIFSTOPPED. This drives the real 2.11adb.
Every apsim diagnostic goes to Dbg, a private high-numbered close-on-exec dup
of the startup stderr — not raw fd 2 — because the guest owns fds 0/1/2 and
a program that reassigns fd 2 (csh does) would otherwise mute the simulator.
7. The First Edition personality
A 0405 binary (or a 0407 under -u v1/v2) selects the 1971–72 machine.
The whole file image loads at 040000 and runs from there — the magic word
0405 is br .+14, branching over its own 12-byte header. Traps follow the
First Edition convention: no indirect call, per-call inline argument words
(from v1inl[], cross-checked against the Nov-1971 manual), fd-style first args
in r0, the C bit signalling error, and time returning in the KE11-A's
AC/MQ. The machine grows a KE11-A extended-arithmetic element at 0177300
(intercepted in ld2/st2), because the 11/20 had no EIS and V1 userland
multiplies and divides through it. emt n is the V1 kernel's validated
rts (it checks the return address is in core, even, and non-null, then
bounces through it — chown returns from subroutines this way), and
v1statout writes the 34-byte stat. The three surviving First Edition binaries
(ar, mv, chown) run.
8. Memory-safety discipline
Because the loader parses vintage and dump-carved binaries and the syscall
layer copies guest-controlled lengths, hostile input is a first-class concern.
Three rules contain it: every ld/st/ifetch masks the address to 16 bits,
so no access escapes its 64 KB array; every bulk transfer between core and the
host goes through gclamp(addr, len), which clamps the length to what fits
from addr to the end of core (read/write/readv/writev/send/recv/
sendto/recvfrom/sendmsg/recvmsg and the ptrace read/write paths); and
the loader clamps every segment read (loadseg), bounds each overlay size
to its slot and to core, keeps the EMT memcpy within ov_max, and reads the
header with per-word masking. Valid guests are unaffected by any of it.
Testing
make check runs tests/run.sh, a set of golden probes assembled with this
tree's own pdp11-as:
| probe | asserts |
|---|---|
cistest.s |
MFPT/SPL/TSTSET/WRTLCK, FIS, and decimal CIS — each case jumps to fail with its number in r5, so the exit code names the first failure |
errno.s |
a failed open delivers the era errno (ENOENT=2), not a blanket 1 or a raw host number |
gate211.s |
the universe gate both ways: sys 64 is getpagesize under -u bsd211 and must fail under the default universe |
fpimm.s |
FP11 $literal operands and FT chop mode (the factor regression) |
timetext.s, ptrace.s, sockpair.s |
time writes no memory; the ptrace channel; socketpair |
| real era binaries | V5/V6 ls/cat and 2.11 echo/cat, skipped when the distribution trees are absent |
make fuzz-smoke runs tests/fuzz-load.sh over a deterministic 800-case
malformed-a.out corpus (mkfuzz.py: every exec- and overlay-header word
crossed with hostile values, plus a truncation ladder, over one seed per magic).
A case passes unless apsim dies of a sanitizer abort or its own SIGSEGV — a
rejected load, a guest fault, a clean exit, and a timeout are all legitimate
answers to garbage. make check-san rebuilds under ASan+UBSan
(non-recoverable) and runs both the suite and the fuzz smoke under them; leak
detection is deliberately off (the guest space and tables are never freed by
design), the value being memory-corruption detection on the hostile-input paths.
For a maintainer
- It is one file on purpose.
apsim.cbuilds with a host C compiler anduniverse.h; keep it self-contained so the oracle suites can run it anywhere. universe.his generated fromuniverses.tsvbymkuniverse.py— add or change a universe there, never by editing the header, and map it to one of the era-orderedPDP11_K_*personalities.- The syscall switch is canonical V7 numbering. A new era gets a
sremapdata table, not a new copy of the switch; a call with no V7 ancestor gets aC_*extension id. Shape differences (stat, stack args) travel in flags, not forks. - Honour the memory-safety discipline. Any new bulk copy between guest core
and the host must go through
gclamp(or an equivalent bound); the loader fuzzer andcheck-sanare what keep that honest — runmake check-sanafter touching the loader or a copy path. - No host floating point, ever. FP11 and FIS share the soft engine; adding a
host
doublereintroduces the rounding divergence this design exists to avoid. - Diagnostics go to
Dbg, not stderr — the guest owns fd 2.