A 16-bit proposed retrocomputer instruction set, and a toolchain that's complete enough to write code and evaluate how the ISA should be changed.
Ports of gas, gcc, ld, objdump etc are provided. A minimal libc (with an O(1) malloc implementation) and tuned integer math operations for libgcc are provided. Printf with optimized int-to-decimal.
A simulator is available.
A complete FPGA implementation including interrupts and VGA output is written, but never tested on hardware. Timing analysis puts it between about 22 and 30 MHz on iCE40, depending on the configuration — the CPU alone or with the VGA frame buffers beside it, 32K or 64K of memory — and every configuration is fast enough to share the VGA's 25 MHz clock as a single clock domain. Most instructions take one cycle, whatever their length, in a three-stage pipeline. Full barrel shifter and a 16×16 multiply, either half, in two of the iCE40's DSPs, and a dynamic branch predictor in one block RAM, but no cache.
The instruction set reference (PDF) has a page for every instruction: each of its encodings in hex and drawn bit by bit, its cycle counts, its semantics and the spec's notes on it.
The design is RISC-inspired:
- The only memory operations are load, store, push, pop, store-multiple, load-multiple.
- 8 16 bit registers, and all ALU instructions can use all 8.
- Flat 16 bit address space.
- All ALU operations have a regular three-register form, rd = ra * rb
- There's also an immediate form with immediates from -512 to 511: rd = ra * #imm
- For larger immediates the assembler can transparently use a scratch register.
- The stack pointer is a regular register, sp = r6
- The return address is a regular register, lr = r7
- No status flags, but explicit compare-branch and operations that write 0 or 1 to a regular register.
- Modern calling convention. Arguments and return values are passed in registers where possible, including small structs.
But we don't want to pay the typical code density penalty of RISC on a 64k machine
- Compact encodings for two-register forms where rd is ra.
- Compact encodings for smaller and common immediates like -16 to 15, single-bit-set, and common masks.
- One-byte encodings for very popular ALU operations that hard code all three arguments.
- The length of the instructions is 1-3 bytes and the first byte determines the length.
- Still many free opcodes for experimentation eg. instructions for garbage collectors (we already have popcount and clz).
- Byte packing:
movhi rd, ra, rbormovhi rd, ra, #immreplaces ra's high byte without a spare register. With a page-aligned table,movhiis also the 6502's page-indexed addressing. - The intention is that the assembler programmer can code as if all three-register and two-register-imm16 forms were available, but the tooling selects the shortest possible encoding. For C code, gcc is aware of the encoding tradeoffs and selects instructions to match.
- Up to three arbitrary registers can be pushed or popped in a single two-byte instruction for compact
function prologs and epilogs. Store-multiple and load-multiple can write or read up to three registers
(repeats allowed) for memcpy, memset, strlen. Their pointer is fixed, as
spis for push and pop —stmstores throughr5andldmloads throughr1, each counting up — which leaves all of byte 1 for the register list.
It is conceived to run on the kind of machine a 6502 ran on — a 64K address space where every byte of code is one you cannot spend on anything else — so the design question behind almost every decision in here is what does this cost in bytes. The first core also fetched a byte a cycle, so bytes were time as well; the pipelined core that replaced it runs most instructions in a cycle whatever their length, and bytes are now space, and time only where its fetcher falls behind.
The non-interactive SVG opcode map above and the interactive HTML form with per-cell tooltips come from
npm run map.
The ALU is strictly two-input, single output. Comparator operations (conditional branch and the "? 1 : 0" instructions iseq, isset, ishi) have a fourth 3-bit input which selects the condition. Unary operations will be implemented as binary operations where the second (immediate) input selects the operation.
Right now almost half the opcode space is still free. Some short forms have been specified that likely aren't worth it and will be removed. The FPGA implementation will provide input as to which features make sense.
Current ALU instruction forms (those requiring 9 bits take up two opcodes):
- reg, reg, reg - 2-byte - Any three registers
- reg, reg, imm3 - 2-byte - Immediate is one of -1, 0, 1, 2, 3, 4, 6, 8
- reg, imm5 - 2-byte - Immediate between -16 and +15
- reg, immbit5 - 2-byte - Immediate is any value 1 << n or its bitwise complement
- reg, immask5 - 2-byte - Immediate is one of the following or their complements: 0xc000, 0x3000, 0x0c00, 0x0300, 0x00c0, 0x0030, 0x000c, 0x0003, 0xf000, 0x0f00, 0x00f0, 0x000f, 0xff00, 0xf0f0, 0xcccc, 0xaaaa
- reg, reg, imm10 - 3-byte - Immediate is any value between -512 and 511
- reg (implicit in opcode), imm16 - 3-byte - Any immediate, only for mov and some control flow instructions
Current condition forms (all 2-byte, but the branch instructions add a third byte for the relative PC offset):
- reg, reg, cond - The usual 8 conditions including overflow. Their inverses are achieved by reversing the two registers
- reg, imm5 - The imm5 selects common constant-condition pairs
- reg, immbit5 - Immediate as above is and-ed with the register and tested for zero (brclr) or non-zero (brset, isset)
- reg, immask5 - Immediate as above is and-ed with the register and tested for zero (brclr) or non-zero (brset, isset)
To allow experimentation with the ISA
isa/fructus.toml is the single source of truth. It holds
every encoding, every operand type, and what every instruction does. The
assembler, the decoder and the simulator are all generated from it or driven by
it, so none of them can drift away from the spec or from each other.
The one hand-maintained view left in it — the opcode map in the header comment —
is checked against the encodings below it by npm run check.
It also contains some of the considerations that went into the design so far, so it's part of the documentation.
just install-deps # the apt packages: gcc's prerequisites, iverilog, yosys
cargo install customasm # the second assembler the suite checks gas against
npm install # one dependency: a TOML parser
npm run gen # build/fructus.asm, the customasm ruledefs, from the spec
just build-tools # gas, ld, objdump and readelf for fructus-elf
just build-gcc # the C compiler and libgcc
npm test # the suite: 168 checksThen compile a C program and run it on the simulator. tools/fcc drives gcc,
links crt0 and the C library, and writes a flat image; the exit status of the
run is main's:
just run-c examples/hello.c
tools/fcc -O2 examples/hello.c -o build/hello && node tools/fcc-run.mjs build/hello.binAssembly runs on the bare simulator, which starts at address 0 and can trace each instruction:
customasm -q build/fructus.asm myprog.s -f binary -o build/myprog.bin
npm run sim -- build/myprog.bin --trace0000 mov r0, #10 r0=0000 r1=0000 r2=0000 ... r6=fffe r7=0000
0002 mov r1, #0 r0=000a r1=0000 ...
0004 add r1, r1, r0 r0=000a r1=0000 ...
0005 add r0, r0, #-1 r0=000a r1=000a ...
0006 br ne, r0, #0, 0x0004 r0=0009 r1=000a ...
halted after 33 instructions
The registers on each line are the state entering that instruction — what it reads, not what it produced.
Three spellings in the assembly language have no opcode behind them. They are
[[alias]] entries in isa/fructus.toml, not rules in the generator, because
the spec is what defines the language:
| you write | it assembles as | why |
|---|---|---|
mov rd, rs |
or rd, rs, #0 |
there is no move opcode; or with zero is a copy |
sub rd, ra, rb |
rsb rd, rb, ra |
one subtract opcode, sources swapped |
sub rd, ra, #k |
add rd, ra, #-k |
…and the immediate negated |
ld rd, [ra] |
ld rd, [ra, #0] |
the offset is a field of the encoding, so #0 is just typing |
The last one covers ld, ld8, st and st8. Rewriting happens before
form selection, so the shorthand still reaches the shortest encoding —
ld r0, [r0] is one byte, the pinned abbreviation at 0x03.
Disassembly prints the long form in every case except mov. A listing read
beside a hex dump should show every field the bytes carry, and an offset field
that is zero is still an offset field.
isa/fructus.toml the ISA: encodings, operand types, semantics, rationale
isa/abi.s the calling convention, as a file that assembles
isa/mos6502.toml the NMOS 6502 opcode table, for comparison - not part of Fructus
tools/ the toolchain, all driven by the TOML
rtl/ hardware, generated from the spec - see below
crt/ crt0, the C library and malloc, in C
libc/ the libc routines written by hand in assembly
libgcc/ gcc's support routines, in assembly
ld/ linker scripts: the simulator's, and a 16K ROM
examples/ C programs to run on the simulator, with the display
rom/ a minimal ROM image, built by `just rom`
docs/ the generated opcode maps and instruction set reference
ideas/ proposals, not yet built
tests/ the test suite
npm run check |
validates the encoding invariants and the object format, prints the opcode census |
npm run gen |
emits build/fructus.asm, a customasm ruledef |
npm run sim -- <bin> |
runs a flat binary. --trace, --pc, --sp, --max |
just profile <prog> [<prog2>] |
where a compiled program's cycles go — by function, kind of cost, mnemonic and, with -g, source line — or two builds compared row by row. --from/--to a symbol, --per, --lines, --kinds |
just bench [<compiler>] [flags] |
five small C kernels in tests/bench, their cycles and bytes, for weighing a compiler change |
npm run map |
the opcode map: build/opcodes.html and docs/opcodes.svg |
npm run manual |
the instruction set reference, docs/fructus-isa.pdf, from the spec |
npm run map6502 |
the same map for the NMOS 6502: build/6502.html and docs/6502.svg |
npm run rtl |
the generated files in rtl/, from the spec |
npm test |
everything, npm run check included |
Heading for hardware: docs/fpga-toolchain.md is the open-source iCE40 toolchain, how to install it and the two things that catch you out.
There is a gas port and a gcc port. g++ probably works with no exceptions.
- char: 8 bit
- short: 16 bit
- int: 16 bit
- pointer: 16 bit
- long: 32 bit
- long long: 64 bit
The calling convention depends on the arity. r2 and r3 are caller-saved
when the function takes an argument in them and callee-saved when it does not —
a static approximation of interprocedural register allocation, using the only
signal that costs nothing to distribute. isa/abi.s has the argument, including
the case against.
- r0: caller save
- r1: caller save
- r2: caller save if it passes an argument or returns a value
- r3: caller save if it passes an argument or returns a value
- r4: callee save
- r5: caller save, used for large immediates
- r6: sp, stack pointer
- r7: lr, return address
This is my first (AI-assisted) foray into FPGA design, and still subject to
change. It is a complete CPU — every opcode in the spec, interrupts included —
plus the VGA output in rtl/video/ (see docs/vga.md). The
clock speeds quoted below were each measured for one block or configuration at
the time it was added; what the whole system reaches depends on whether the
VGA is in the same FPGA and how much memory is attached.
rtl/cpu.sv is a three-stage pipeline over a 16-bit memory port — the
UP5K's SPRAM, read a word at a time — behind a fetcher:
- fetch reads a word in every cycle the bus is not a load's or a store's, into a buffer of three words, whole words in;
- dispatch looks the opcode up in
rtl/classify.sv, a table generated from the spec, and decides from its kind and length alone whether the next instruction can dispatch in the very next cycle, once the buffer holds all its bytes; - decode has the whole instruction, in the buffer's flops, reads both operands from the register file and latches them, with a bypass from the instruction just ahead;
- ALU is two operand flops, the ALU and the register write.
So an ALU instruction takes one cycle whatever its length, while the fetcher
keeps up: it brings two bytes a cycle, and falls behind in a run of three-byte
instructions or after a run of loads and stores - 6% of the cycles of the
benchmark kernels. A conditional branch is guessed as it dispatches: by a
table of two-bit counters indexed by the last eleven branch verdicts and
bits of the jumps' addresses, or, on a miss, backward taken and forward not.
A branch guessed right costs nothing extra if not taken, and three cycles
if taken. A wrong guess is found in the ALU stage, beside the ALU, and costs
five cycles, six if the instruction at the right target spans two words.
jmp and call to an absolute address fetch their target as they dispatch
and take two and three cycles; through a register, jumps and calls take
five. Measured behind a real SPRAM, the processor runs at 30.0 MHz
(medians of sixteen placements); experiments/ib
has how the buffer was chosen, and
ideas/branch-prediction.md the predictor.
tools/sim.js counts the same cycles, and tests/cpu-check.mjs holds the two
together instruction by instruction on random programs. The header of
rtl/cpu.sv has the reasons for each choice and what the alternatives measured.
rtl/cpu.sv and rtl/alu.sv are written by hand, but what they know about the
instruction set comes from the spec: the per-opcode control is the generated
rtl/classify.sv, and the rest - the exception vector, which registers sp and
lr are, which bit of the immediate picks each unary operation - is macros in
the generated rtl/isa.svh. Where the circuit relies on something a macro
cannot carry, such as brk being opcode 0, tools/gen-isa.js checks it and
npm run rtl fails with the place to change.
It replaced a byte-serial core, which fetched a byte a cycle through a
microcoded datapath: smaller — 1371 LUT4s against 1651 — but slower. It is in
the history, as rtl/ at commit 9a1d953, for a part too small for this one.
Its instruction register, its microcode-field blocks and its predecode table
are gone with it; tools/predecode-rows.js still holds the rows the
classifier is built from.
Loads, stores, block moves and the exception instructions enter the pipeline
and stop dispatch behind them. The ALU computes the first address, and a
sequencer then makes an access a cycle - a whole word if the first address is
even, a byte if it is odd - and writes registers through the ALU's result, so
the register file has one write port. Whether it is aligned is bit 0 of the
address, aq[0] ^ bq[0] with no carry, which picks the routine's aligned copy
in the ALU cycle. Its control is microcode —
rtl/ucode.sv, a block RAM generated by tools/gen-ucode.js from
tools/ucode.js — whose output is registered, so nothing about it is on the
critical path. brk, rti, sei, cli, mul, clz and popcount are
routines in the same ROM, and an interrupt is a brk dispatched in place of
the instruction it preempts. The free opcodes stop the machine with trapped.
Blit mode is built only with `define FRUCTUS_BLIT, for the system in
docs/vga.md: ld, ld8 and ldm then take their bytes a cycle
late from the far side of the die, through late copies of their routines, and a
push or pop above 0x8000 stops the machine. Without the define the netlist
is the same, bit for bit, as without the feature.
Generated, not written. rtl/immgen.sv produces the 16-bit immediate right-hand
side for every instruction that has one — the ALU and shift groups, mov, the
load and store displacements, brclr/brset, and the packed branch's
condimm5 constant — from the instruction bytes and one control line. 109
LUT4 and three LUT levels on an iCE40 UP5K.
It costs that little because of properties of the values in the spec, not of
the circuit: immbit5 and immask5 are each sixteen entries plus their exact
complements, and in the same order a nibble shifted by four times the index's
top two bits, so the two are one shifter with one complement layer; and shift3 is imm3
masked to four bits, which is what the shifter does anyway, so there is no
shift3 table in hardware at all. Both are checked by npm run check, which
names the hardware cost when an edit breaks them.
rtl/lhs.sv is the left-hand operand's register: one four-bit field naming a
register or picking byte 1's rd or ra field. Its generator works out each
form's left-hand register from the spec's semantics and finds its field by
decoding every byte 1; the column decides it everywhere but call ra.
rtl/dest.sv is the write address, in the same style: a register named
outright, or one of four places an instruction carries a register number. The
rd field serves every ALU operation, load, mov and unary operation; the ra and
port-B fields exist for the second and third registers of pop, and the opcode
field for mov rd, #imm16. Codes 8 and 9 mean the same as in lhs.sv.
rtl/alu.sv is the ALU. The operation is a four-bit field from the classifier —
the spec forbids decoding it from the opcode — and every instruction is given
an operation from its semantics, failing on any that cannot be placed. iseq
and isset are ALU operations, and neither needs a carry. ishi is the
borrow out of rsb's subtractor, chosen over the difference by a select flop
set at dispatch, so it adds no arm to the result mux; with islo, its alias
with the sources swapped, a 32-bit add's carry is one instruction rather than
a branch. The fast unary
operations — sxt8 and bitrev — are chosen by bits of the right-hand
immediate the ALU already has. clz and popcount are computed beside it with a flop in the
middle, and taken a cycle later — extra_cycles in the spec, which the
simulator counts.
The instructions that need the DSPs — mul, recip, mulhu and mulhs, all
optional and emitted by gcc only with -mmul — are one block, 0xb8 to 0xcf,
above 0x80 and away from the base instruction set.
mul takes the low half of a 16×16 product from an SB_MAC16, at 0xb8
(rd, rd, #imm5), 0xb9 (rd, ra, #imm10) and 0xbe/0xbf (rd, ra, rb), in the
ALU groups' shapes. Like clz and popcount
it takes two cycles: the ALU stage holds it while the DSP's registered product
arrives. mulhu and mulhs take the high half, unsigned and signed, at
0xc6/0xc7 and 0xce/0xcf, three registers only. To everything but the DSPs the
three are one ALU operation; the classifier's lsel column, which the
operation had no use for, drives the DSPs' synchronous OLOAD pins, so each
half not wanted loads zero into its output register and the result is the
three halves OR'd. The signed high half is a second SB_MAC16's, since the
DSP's sign is a parameter, not a pin. gcc emits all three only with -mmul,
since a part without a multiplier has none of them; without it a product is a
call to libgcc's __mulhi3. With it a 16×16→32 product is mul and mulhu
or mulhs inline, a 32×32→32 is four products inline at -O2, and a
division by a constant is a mulhu or mulhs by its reciprocal.
recip rd, ra, at 0xc3, is Möller and Granlund's 2/1 reciprocal of a
normalised divisor, floor((2³²−1)/d) − 2¹⁶ for d = ra | 0x8000, in five
cycles. Its byte 1 is the imm3 column's with the immediate pinned at #0,
which the assembler does not write. A 32-bit divisor's 3/2 reciprocal, with
which a quotient word's estimate errs only one way, is made from it in
software by their Algorithm 6: one more product and two rare corrections.
It was an instruction, three cycles longer, until it turned out to cost 480
logic cells for about 20 cycles of a wide 32-bit division. A high word with bit 15 clear,
which after normalising means a divisor of zero, traps to divide_by_zero,
0xffc8, as a guard does: recip writes its answer for ra | 0x8000, and the
trap is taken in place of the next instruction. It is rtl/recip.sv beside the
multiplier: a straight line per slot from one block RAM of 128 pairs, which
lands 0 to 3 below, then a second product to find out how far. It has two
SB_MAC16s of its own. To the ALU it is mul with every
half zeroed and its answer OR'd in. With -mmul every division libgcc does
is built on it: the _mul routines in libgcc/lib1funcs.s divide a word at a
time, so a 16-bit division is about 33 cycles, a 32-bit one 82 to 95 against
about 470 for the C, and a 64-bit one 260 to 580 against 720 to 3160.
rtl/alu.sv instantiates the DSPs rather
than letting yosys's -dsp infer them, because the inference copies the operand
flops into the DSP's input registers and drags the logic in front of them to
its column. nextpnr doesn't time through SB_MAC16 at all — see the
toolchain notes.
Branches are decided beside the ALU, not in it. rtl/compare.sv takes the same
two operands and answers one bit, taken, that never enters the ALU's result;
rtl/cond.sv supplies its condition. The packed branch's condimm5 entries are
mirrored onto imm - R[a] and need a negate bit as well as cond3: R[a] < k is
not (k <= R[a]), and cond3 spells gt only by exchanging registers. Every
entry is checked against the simulator for all 65536 register values.
Port B's register number comes off the instruction bytes rather than out of
immgen, so the register file's read overlaps the immediate unit instead of
waiting for it. That is also why nothing checks immgen at +6/+7: nothing
reads it there.
tests/cpu-check.mjs runs the processor: random programs of every form, and
some assembled ones, on the RTL and the simulator, and after every instruction
the pc and all eight registers must match, as must the memory at the halt and
the cycle each instruction dispatches in — the cadence above, confirmed rather
than assumed. Then the same programs under interrupts, and built for blit mode,
with it off and on. tests/rtl-check.mjs checks the generated blocks and the
ALU on their own against the spec's tables and the simulator, and
tests/blit-check.mjs the whole system, video and frame buffers included. The
suite regenerates every generated file under rtl/ and fails if a committed
copy has drifted.
It also decodes real bytes with tools/decode.js to confirm that
{byte1[7:6], opcode[0]} really is ALU port B for every three-operand form,
which is what decode relies on.
tools/isa.js reads the spec; tools/decode.js turns bytes back into
operands; tools/sim.js executes the semantics expressions. The generator and
the decoder share only the code that reads an encoding string — below that
they are independent implementations of the same table, going in opposite
directions.
The suite is layered, and each layer catches something the one below it cannot.
- It assembles. Proves an encoding exists. Says nothing about whether it is the right one.
- It round-trips.
tests/roundtrip.mjsassembles, decodes withtools/decode.js, re-assembles, and compares bytes. Two independent readings of the encoding checked against each other — this catches a field gathered in the wrong order or a signed immediate read unsigned, which validate clean and round-trip wrong. - It computes the right answer.
tests/sim-check.mjsruns the actual assembled bytes on the simulator against an independently computed answer — not another transcription of the algorithm. - It survives every placement.
tests/libc-check.mjsrunslibc/against a reference built from a snapshot rather than a second copy of the algorithm, sweeping every overlap of source and destination in a window, at two bases and across the 0x8000 sign boundary. Two things the sweep depends on are asserted rather than assumed: no placement may leave the window, and the fill may not resemble a shifted copy of itself — a fill with a period, or merely with runs of equal bytes, makes a wrong-direction copy invisible. It also checks which loopmemmovepicked, since a version that always copies downwards writes exactly the right bytes and is three times slower.
Instruction length comes from the first byte alone. The decoder is a
256-entry table and the fetch unit never looks ahead. tools/decode.js asserts
it, which the assembler structurally cannot — it only ever goes the other way.
Displacements count bytes and are never scaled by access width, so that
a V8-style or SOM virtual machine can tag pointers in the low bit. Field offsets come out odd (ld rd, [rp, #-1]),
and a scaled displacement could not express them at all.
There is no carry flag. The carry out of a 16-bit add is recoverable from the
result — sum < A — so 32-bit arithmetic is add, compare, conditionally bump.
r5 belongs to the assembler. Any immediate that does not fit its
instruction expands through it, so no function can promise to preserve it. This
is MIPS's $at, and it costs what MIPS's does. It can be disabled for gcc
and assembler authors who are familiar with the restrictions on immediate range.
The rule that follows is don't keep anything in r5 that has to survive —
not "check whether this particular instruction expands". Whether lsr r5, lr, #14
expands depends on which forms exist for that mnemonic, which is not something a
reader should have to know. So it is enforced rather than remembered: npm test
assembles every file in libc/ and libgcc/ against the --noat ruledef,
which omits every rule that borrows the scratch. A file that assembles there
cannot contain an expansion, and r5 in it is an ordinary register.
isa/abi.s is the one exception, and expands exactly once on purpose to show
what it costs — the suite pins the count at one.
Unaligned 16-bit access is legal, with no fault, which is what lets the
stack pack byte arguments without padding. It is not free: memory is a word
wide, so an aligned word moves in one cycle and an unaligned one a byte at a
time, a cycle a byte. The first address decides for a whole push, pop,
stm or ldm, so a stack kept even moves a register a cycle.
The exception instructions are the lowest opcodes — brk at 0x00, halt
at 0x01, rti at 0x02 — so erased memory, an unwritten ROM and a wild jump
into a zeroed page all trap to the handler with the faulting address in lr,
where a monitor can print it. nop is at 0x87, well away from them.
The cost model is the pipeline's, and it is under [cpu] in the spec,
with the fetcher keeping up: an ALU instruction or an untaken branch is one
cycle; a taken branch five; a jump or call four, or five through a register,
and any of them one more when the instruction at the target spans two words;
a load or store of N accesses N + 2, where an access is a byte or a whole word
at an even address, so ld is three aligned and four not, and a
three-register push five with sp even and eight with it odd; mul, mulhu,
mulhs, clz and popcount two, and recip five. A taken branch is what
makes a longer unrolled loop worth anything once the instruction mix is fixed,
and a memory instruction is the slowest thing a program does often. The
simulator does not keep a list of which instructions are which: the semantics
say which assign pc conditionally, which unconditionally, and which touch
memory.
The opcode map makes the gaps visible, and two of them are worth naming. Neither
is implemented; they are here so the space does not get spent on something else
by accident. (Two earlier entries here, a multi-register store and an indexed
ld rd, [ra, rb], have since become stm/ldm and the [ra, rb] forms of
ld, ld8, st and st8.)
Slot +4 of every ALU group is the immbit5 slot, and four of the eight
operations now use theirs — add (0x3c), xor (0x4c), or (0x54), and
(0x5c). rsb (0x44) is the one left that could plausibly want it:
rsb rd, rd, #1<<n computes (2ⁿ − rd), plausible for mirroring an index, and
no routine here has wanted one yet. The shifts leave theirs free because a
shift distance is four bits.
One opcode each and no new hardware — the 4-to-16 decoder is already built for the other three. Cheap enough that the question is whether they earn their line in the documentation, not whether the map can afford them.
The other three free +4 slots — shl, asr and lsr at 0x64, 0x6c and 0x74
— are free for a reason and should stay that way. A shift count is masked to
four bits, so 1<<4 and everything above it reads as a shift of zero. immbit5
is meaningless there.
Thirty-two slots, x000_xxxx, and every one-byte instruction is in it:
brk, halt and rti at the bottom, nop at 0x87, ret, sei and cli,
jmp r5 and call r5, and pinned abbreviations — add r0, r0, #1,
mov r0, r1, mov r0, #0, push lr, ld r0, [sp, #0] and their neighbours
at one byte instead of two.
mov r0, #0 (0x83) is there for the 65C02's reason: STZ was one of that
part's most valuable additions because clearing a location is the commonest
thing a program does that the 6502 had no short way to say. Zeroing a register
is the same observation one level in — loop counters, accumulators, null
pointers, cleared flags — and r0 is where a return value and a first argument
live.
The second sixteen were spent by count, not by guess: the fully pinned
instructions that occur most in SOM++'s DeltaBlue and in CoreMark at -Os and
-O2 with malloc linked in, taken across all three so that no one program's
habits — an interpreter's dispatch, say — decide it. jmp r5 and call r5 are
the exception: r5 carries no argument, so an indirect target with its
arguments in r0-r3 can wait there, and gcc stages an indirect tail call in it
already.
Not an addressing mode, a convention with a relocation behind it. A register
holding 0x0200 is a base from which [rb, #imm10] reaches the whole of
0x0000–0x03ff, since imm10 is signed, and 0x0200 is a single set bit, so the
base costs a two-byte mov. The 6502's zero page was the same idea with the
base wired to zero.
0x0000 - 0x01ff zp_lo .zplo, .zplo.bss [rb, #-512..-1]
0x0200 - 0x03ff zp_hi .zphi, .zphi.bss [rb, #0..511]
0x0400 - 0x04ff the I/O page, below
0x0500 - ... the stack, growing down towards the I/O page from wherever
the system puts its top
In assembly, .zplo and .zphi switch to the two halves, and #zp(sym) in
any imm10 field is the symbol's displacement from the base, placed by the
linker as R_FRUCTUS_ZP10:
.zplo
count: .2byte 0
.text
mov r4, #0x200
ld r0, [r4, #zp(count)] ; 3 bytes, the imm10 form always
st r1, [r4, #zp(count)+2]
The linker refuses an address outside 0x0000–0x03ff, and the linker scripts
refuse a half that overflows. A program that wants zp_hi for something else
never says .zphi. A symbol is taken in an imm10 field only through zp(),
so ld r0, [r4, #count] is still an error rather than an address quietly
read as a displacement.
In C, __attribute__ ((zp)) puts a variable with static storage in zp_lo and
__attribute__ ((zp ("hi"))) in zp_hi. Its address stays an ordinary one, so
mov rd, #x then [rd, #0] still reaches it, and gcc decides per function
which way is smaller: through the base, a function touching d zero page
variables r times costs about 2 + 3r bytes in one register, and directly
3d + 2r in d of them. So the base wins with several variables and loses with
one variable used often. An element at a variable index is reached
[rt, ri] either way and counts for neither. -mzp-base=always and
-mzp-base=never override the choice. The base is one pseudo set at the top
of the function, so the register allocator decides whether to keep it or to
rematerialize the mov where it is used.
On the simulator, ld/fructus-sim.ld keeps 0x0000–0x0002 for a jmp _start,
since the CPU starts at 0. Above the zero page and the I/O page is a 4K
stack, 0x0500–0x14ff, then malloc's heap up to 0x7fff, then the program from
0x8000. -Wl,--defsym=__stack_top=... moves the boundary between the stack
and the heap; it must be a multiple of 256. A program that needs more than
32K moves the boundary between the heap and itself by defining __heap_end,
asm (".globl __heap_end\n.set __heap_end, 0x6000"); for 40K, which the
script's default gives way to. The console is at 0x0420. The jmp also keeps every object off address 0, which is NULL;
ld/fructus-rom16k.ld keeps the two bytes free for the same reason.
tools/fpga-system.sv has its registers in the I/O page.
256 bytes at 0x0400, between the zero page and the stack. [cpu.io] in
isa/fructus.toml is the authority:
0x0400 - 0x040f guard
0x0410 - 0x047f the CPU's 0x0410 shadow_sp, 0x0412 shadow_lr, 0x0414 shadow_isp,
0x0424 cycles (two words, low first);
the system's UART: 0x0420 data (the console),
0x0422 status, 0x0428 divisor
0x0480 - 0x04ef the GPU's docs/vga.md, "Registers"
0x04f0 - 0x04ff guard
The guards trap. A load or store that touches one runs out unchanged,
writing the memory behind a guard as it would any other, and then the CPU
enters io_guard, 0xffcc, in place of the next instruction, as an interrupt
does, so lr is that next instruction's address. The top guard is a stack
guard: the stack grows down towards it, so a push past the bottom traps. The
bottom one catches a zero page array indexed off its end. Both are debugging
aids, not something to recover from. The handler has a stack to report on,
since entry moves sp to shadow_isp, but a frame larger than 16 bytes can
step over the top guard with an sp-relative store.
One register to a 16-bit word, at its even address, so an aligned st
writes exactly one register and a wide data bus never has to merge two
unrelated ones. A register of 8 bits or fewer takes st8 there as well. A
large array is an address and a data register, a word each: the address
increments on every access to the data register and wraps at the array's size.
A register the hardware changes, like a status word or an array's address,
reads as its current value.
So the GPU's 2 KB of sprite patterns cost the page four bytes.
The shadows have a word each, so a handler can read the interrupted code's
sp and lr for a crash report, a scheduler can swap them, and boot code can
set shadow_isp with a store instead of the rti it needs otherwise. They
are still not registers: no instruction names them.
The cycle counter counts every clock since reset, 32 bits in two
read-only words at 0x0424, low word first. It has no hold register, so the
high word can move between the two loads: read high, low and high again, and
go round once more if the high word changed. fructus_cycles() in
crt/include/fructus/cycles.h does that. The simulator gives the same count
as rtl/cpu.sv at every load, which tests/cpu-check.mjs holds it to, so a
difference of two readings there is what the hardware takes.
The UART is an option of the system's, tools/fpga-system.sv built with
FRUCTUS_UART and rtl/uart.sv, for the serial line most boards take to a
USB converter: ports uart_rx and uart_tx. Eight data bits, no parity, one
stop bit, at 115200 baud from reset; the divisor at 0x0428 is clocks to a bit
less one, 12 bits, so 9600 baud and up.
Its data register is the console, so putchar reaches the host on the
hardware as it does on the simulator: a byte stored is sent, and a load takes
the byte received. The status word at 0x0422 has bit 0 busy, bit 1
received, bit 2 overrun, bit 4 the enable of an interrupt on received
and bit 5 of one while not busy, for a handler feeding a buffer out; both
are ORed with the interrupt pin. A status of zero is idle, so
on a machine without the UART, where the word is plain memory, putchar's
wait for busy to clear never waits. crt/include/fructus/uart.h has
fructus_uart_getc() and the rest; tools/fcc-run.mjs --input FILE is what
its UART receives. Built without the option the system's netlist is the same
as before. With it the system is 4385 logic cells of the UP5K's 5280,
against 4229, and places at 28.13 MHz against 28.31 (medians of sixteen); two
pins, and no block RAM, DSP or SPRAM.
tools/sim.js implements all of this for a machine made with io, which is
tools/fcc-run.mjs's. A program that touches a guard there exits 124, naming
the instruction after the one that did. tests/stack-guard.c recurses into
the top guard. rtl/cpu.sv maps the shadows too and traps on the guards, for
44 logic cells and no slowest path through any of it, and
tools/fpga-system.sv has the GPU's registers.
CoreMark v1.01, EEMBC's sources unmodified (coremark.md5
checks them), built with this repository's GCC and run on the simulator.
The cycles are the I/O page's counter, which tests/cpu-check.mjs holds to
rtl/cpu.sv's at every load, so they are the hardware's cycles rather than
an estimate. Every build passes EEMBC's validation: ten seconds of run
time, and the known CRCs for both the performance and validation seeds.
| Build | Bytes | Cycles/iteration | CoreMark/MHz | CoreMark at 30 MHz | at 25.175 MHz |
|---|---|---|---|---|---|
-Os |
8622 | 2,023,742 | 0.494 | 14.82 | 12.44 |
-Os -mmul |
8240 | 970,087 | 1.031 | 30.93 | 25.95 |
-O2 |
12141 | 1,841,792 | 0.543 | 16.29 | 13.67 |
-O2 -mmul |
11873 | 807,185 | 1.239 | 37.17 | 31.19 |
30 MHz is the processor alone on the iCE40; with the VGA frame buffers it
runs at the 25.175 MHz pixel clock. Bytes are the whole image — .text,
.rodata and .data, printf and the libgcc helpers included. -mmul uses
the 16×16 multiply in the DSPs; without it a multiply is a shift-and-add
call. GCC 16.2.1, MEM_METHOD MEM_STACK. just coremark regenerates the
table, in about seven minutes, and just profile shows where the cycles go —
tools/profile.mjs has CoreMark at -Os against -O2 as its example.
CoreMark® is a trademark of EEMBC.
FructusMark puts CoreMark, SOM++'s DeltaBlue
and Richards, and stand-ins for SPECint92's espresso, eqntott, compress and
jpeg into one score. It is the geometric mean of the speedups over -Os -mmul at the baseline commit, which scores 100:
-mmul |
without | |
|---|---|---|
-Os |
108.7 | 81.0 |
-O2 |
120.3 | 99.2 |
just fructusmark scores -Os -mmul in about a minute and a half, and
-O2 and --no-mul select the other builds.
The instruction set is settled enough to write real code against, and the C programs compiled by gcc run on it, CoreMark among them. Open:
bitrevis marked TENTATIVE in the spec.- Nothing checks that a call site and its callee agree about arity. The fix is
a
.argsdeclaration the assembler and linker verify;isa/abi.sdescribes it and why it is not written yet.
ISC.