Documentation
pdp11-as — design
This document describes how pdp11-as turns 2BSD assembler source into a PDP-11 object file: how one binary covers every era from First Edition (1972) to 2.11BSD, where the opcode knowledge lives, how a name is looked up…
pdp11-as — design
This document describes how pdp11-as turns 2BSD assembler source into a
PDP-11 object file: how one binary covers every era from First Edition (1972)
to 2.11BSD, where the opcode knowledge lives, how a name is looked up and
era-gated, the two-pass span relaxation, the sys pseudo-op and the ~
local-symbol replay, and the three a.out formats it can write. For how to use
it, see the user guide; for the era dialects in full, see
std.md.
The tool is one file, as.c (~2400 lines), a C reimplementation of the 2BSD
as — which is itself pure PDP-11 assembly and so could not be cross-compiled.
It runs on a modern LP64 host and produces the classic objects this toolchain's
ld and nm read (not the newer string-table a.out GNU pdp11-aout
emits). The opcode knowledge lives in two hand-laid tables, optab.h and
extoptab.h.
1. One binary, three orthogonal era axes
The awkward fact as exists to handle: across the lineage it descends from,
the instruction set, the system-call vocabulary, and the object format
each changed on their own schedule. Rather than an assembler per era, as
carries three independent switches, each also reachable through a --std
preset that sets a sanctioned combination:
| axis | flag | state | choices |
|---|---|---|---|
| instructions | --isa= |
isa / jflag |
1972 (V1–V3), 0 common (V4–2.10, default), 211 (2.11 / J-11), extended (full hardware line, jflag) |
| syscall names | --sys= |
sysnames |
0 none (V7+), 1972, 6 (V4–V6) |
| object format | --aout= |
v1fmt / newsym |
v1 First Edition, v2 0407 (default), v2+ 2.11 string-table |
The axes are genuinely orthogonal: --isa=211 --sys=v6 describes a machine
that never existed, and the assembler will happily be it. A --std=<era> token
sets all three plus v7flag (undefined-symbol strictness) to one historical
combination — it is a preset, never a fourth axis.
2. The opcode and keyword tables
as never hard-codes a mnemonic; every one lives in a struct op {name, type, opcode} table. There are two headers:
optab.h— the default table, extracted verbatim from the authentic 2.8BSDas19.s: registers, the double- and single-operand instructions, the branches and theirjbr/jxxxpseudo-forms,sys, the FP11 group, EAE, and the directives.extoptab.h— four tables that are not in the default set:tab211(the 2.11 / J-11 additions —mfpi,spl,mfpt,csm, …),v1systab(the 1972 syscall-name keywords),v6systab(the V4–V6 list), andextoptab(the extended hardware line — FIS, the full CIS,med/xfc, FP11 DEC names). They are opt-in because their names collide with symbols in the era corpora, and a builtin name silently suppresses a same-named^-alias definition at write-out.
Both headers are wrapped in /* clang-format off */ on purpose: the tab
columns are the documentation, hand-aligned so the table reads as a table.
The marker must be exactly clang-format off — trailing text on that line is
not honoured, so the columns would silently reflow otherwise.
The type field is a class key (also from as19.s) that selects the operand
encoder:
| type | class | type | class |
|---|---|---|---|
01 |
absolute / no-operand | 020 |
.even |
06 |
branch | 023 |
.globl |
07 |
jsr / xor | 024 |
register name |
010 |
rts | 025/026/027 |
.text/.data/.bss |
011 |
sys | 030 |
mul / div (EIS) |
013 |
double-operand | 031 |
sob |
015 |
single-operand | 032 |
.comm |
016 |
.byte |
035/036 |
jbr / jxxx |
017 |
.ascii |
040 |
.word (bare expression) |
3. Name lookup and era gating
oplook() walks the tables in a fixed order and returns the first live match:
optab (filtered by kwskip), then tab211 (if isa==211 or jflag),
v1systab (if sysnames==1972), v6systab (if sysnames==6), and extoptab
(if jflag). Duplicate mnemonics in the base table — mul/div/ash appear
first as absolute EAE addresses and later as the EIS instruction — are resolved
last-wins, so mul is the instruction, matching 2BSD's last-wins symbol
table.
kwskip() era-gates entries that exist in optab but not in the selected era:
- under
isa==1972: thejbr/jxxxpseudo-branches (types 035/036),.ascii, and the V4 FP additionsldfps/stfps/movie/moveiare hidden; - under any
sysnames: thewaitinstruction is hidden, because every era that names its calls makeswaitthe syscall (7), sov1systab/v6systabwin; - under
isa==211: the ancient EAE keywords (ac,mq,csw,mpy, …) disappear, exactly as 2.11 dropped them.
4. Two passes and span relaxation
as is a two-pass assembler — pass 1 assigns addresses, pass 2 emits code,
relocation and symbols — but pass 1 is really a three-part span relaxation that
reproduces the 2BSD as1+as2 structure so the branch sizes come out
identical:
- the
as1estimate — a single top-to-bottom pass sizing each branch inline: a forward text branch is long, a backward one short iff its displacement fits[-254,0). Not iterated to a fixpoint — that over-sizes backward branches. - the
as2sizing pass, run once over that estimate — each branch decides fromtarget − dot(forward refs corrected bybrdelt, the most recent label's estimate drift); the choices are final, recorded inspanlong[]. - pass 2 replays those choices and emits.
jbr/jxxx (types 035/036) collapse to the short branch form when the target
is a near text label; otherwise they stay in the long jmp form — correct if
not always minimal. Symbol-table indices are assigned between the relaxation
and pass 2, because emitword() stamps a symbol's index into each external
relocation word and the index must already match the symbol's position in the
table writeout() emits.
5. The sys pseudo-op and named system calls
sys (type 011) reaches sysop(), which requires an absolute operand and emits
base | (v & 0377) — base 0104400. The trap code is the low 8 bits, not
6: the library's job-control stubs write sys read+200, and a &077 mask would
truncate the 0200 flag bit. emt (type 041) takes an optional operand
(emt alone is emt 0).
When a --sys list is active its names become keywords, so sys write
assembles by name instead of sys 4. The two lists that ever existed are
carried verbatim: the 1972 table (v1systab, with quit/intr/cemt/
ilgins) and the V4–V6 table (v6systab, with signal). Both make wait
the call, never the instruction.
6. The ~ local-symbol replay
c2 emits local/sdb symbols spelled with a leading tilde (~~funcname:,
~var=reg). The authentic as (as14.s) strips one ~, marks the symbol "not
for the hash table", and appends it to the symbol list directly — so these are
never deduplicated: each ~var=reg in a different function is a distinct
entry (the source of the +10 symbol count versus a hashing assembler).
as.c mirrors that with tildesym(): a per-pass occurrence counter
(tildeidx) indexes a flat tildesyms[] array, so the Nth ~-symbol is the
same object across every relaxation pass (stable values and brdelt) while
distinct occurrences stay distinct. They are never hashed, so ordinary lookup
never returns one — matching the define-only, stripped-from-the-binary role.
7. Three a.out formats on the way out
writeout() emits the symbol table in first-creation order (walking the
onext insertion chain, not the hash buckets) because ld assigns
common/bss addresses in that order — the order is load-bearing. All three
formats bias a symbol's value into the unified object address space (text@0,
data@txtsize, bss@txtsize+datsize), which ld backs out when combining
objects.
--aout= |
header | relocation | symbols |
|---|---|---|---|
v1 (v1fmt) |
6-word, magic 0405, 12 bytes | 2-bit bit-stream, MSB-first: 00 abs, 01 reloc, 10 pcrel-external, 1101 abs-external (1100/1110 carry a 16-bit addend extension); external words hold the symtab offset 12*index |
V1 flags (00 undef / 01 abs / 02 reg / 03 reloc, ` |
v2 (default) |
8-word, magic 0407, 16 bytes | one 16-bit word per code word, (index<<4) | rtype | pcrel, rtype ∈ {0, 02 text, 04 data, 06 bss, 010 ext} |
12 bytes: name[8], n_type, n_ovly=0, value |
v2+ (newsym, -n) |
as v2 |
as v2 |
8-byte nlist (strx as a PDP-11 long, high word first; type; value) plus a trailing string table; names up to 32 chars |
The First Edition path merges text and data into one contiguous body before
streaming relocation (their addresses are already contiguous there); the result
is a file a simulated 1971 system, or apout, can exec().
8. Testing
The standard of proof is byte-identity to the native 2.9 as, run under apsim:
oracle/selfhost.shrebuilds the toolchain's own binaries from source — assembling 2.9'sas0.s/as2.swith thisasreproduces the native/bin/asand/lib/as2byte-for-byte.oracle/as-corpus.sh,cc-corpus.shandtree-sweep.shsweep whole 2.9 source trees andcmpeach.oagainst nativeas: ~1400 files, zero differences. The one non-match (sys/lfstat.s) is asys.sversion skew, not an assembler bug.tests/as/encode.shdisassembles the output with GNUobjdump -m pdp11to confirm every mode, target and immediate decodes; an apsim CIS golden exercises the--isa=extendedencodings.- The das round-trip oracles (
das → as → identical object) prove the symbol-table order and external-relocation indices survive losslessly.
9. For a maintainer
- The tables are the spec.
optab.h/extoptab.hare hand-columned underclang-format off; add an entry in the right alignment and never let the formatter reflow them. Put an era-specific mnemonic in the era table (tab211/extoptab), not the default one. - Era-gate through
kwskip/oplook, not by editing the base table. A builtin name suppresses a same-named^-alias symbol at write-out, so adding a keyword can silently drop a corpus symbol. - Keep the three axes orthogonal.
--isa/--sys/--aoutare independent; a--stdtoken is only a bundle of them plusv7flag. - Hold output to
cmpagainst nativeas(selfhost + the corpus sweeps) — that catches a wrong data/bss bias, relocation type, or symbol order, which a disassembly check alone does not. - The object is written even when assembly errored (authentic 2BSD:
asnever unlinks a partial.o); the nonzero status is reported afterwriteout().