Skip to content
erikcorryPublic

About

Free 16 bit RISC-inspired ISA with 8 registers, optimizing gcc toolchain, minimal libc, beginnings of an FPGA implementation. Optimized for code size & an 8-bit data bus.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

Fructus

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 Fructus opcode map: 159 assigned first bytes in an eight-column grid, coloured by addressing mode, with a key

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, rb or movhi rd, ra, #imm replaces ra's high byte without a spare register. With a page-aligned table, movhi is 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 sp is for push and pop — stm stores through r5 and ldm loads through r1, 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)

The implementation structure

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.

Quick start

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 checks

Then 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.bin

Assembly 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 --trace
0000  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.

Notation

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.

What's here

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

Tools

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.

gcc

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

rtl/

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.

Tests

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.mjs assembles, decodes with tools/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.mjs runs 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.mjs runs libc/ 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 loop memmove picked, since a version that always copies downwards writes exactly the right bytes and is three times slower.

Design principles

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.

Possible enhancements

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.)

rsb with a #1<<n immediate

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.

The one-byte region

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.

The zero page

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.

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.

Performance

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.

Status

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:

  • bitrev is marked TENTATIVE in the spec.
  • Nothing checks that a call site and its callee agree about arity. The fix is a .args declaration the assembler and linker verify; isa/abi.s describes it and why it is not written yet.

License

ISC.

About

Free 16 bit RISC-inspired ISA with 8 registers, optimizing gcc toolchain, minimal libc, beginnings of an FPGA implementation. Optimized for code size & an 8-bit data bus.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages