Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RV32I on Babbage's Analytical Engine

A working RV32I interpreter expressed as a chain of Analytical Engine cards, plus a simulator of the engine to run it on and a Babbage-rate cost model.

You write C, a real RISC-V compiler compiles it, and the resulting program is interpreted by a chain of punched cards fed through a simulated Victorian machine that was never built. The engine then tells you how many seconds of steam it would have taken.

The engine running the sieve

The sieve of Eratosthenes, filling 93% of the Store. The throttle is open past a million to one, so a second of watching costs the engine a fortnight.

Layout

engine/ae.py      the machine: Store, mill, levers, card set, assembler, costs
engine/rv32.py    the RV32I interpreter, written in cards
engine/demo.py    RV32 assembler + 19 instruction tests
engine/bench.py   per-instruction cost, index ablation, extrapolation

programs/engine.h the whole C runtime: put, emit, decimal formatting
programs/hello.c  hello world, printing through the print card
programs/fib.c    a fib table plus a recursive cross-check, 60k instructions
programs/sieve.c  a sieve filling 93% of the Store, the one to watch
programs/crt0.S   startup: set sp, call main, halt the mill
programs/link.ld  link flat at address 0 inside the 3440 bytes of Store

build.sh          C -> flat RV32I image, checked for unsupported opcodes
runc.py           load an image into the Store and run it
gui.py            serve the engine to a browser and drive it live
gui/index.html    the brass, the gauges and the steam
docs/engine.gif   the panel in motion
build/            everything generated (gitignored)

Running it yourself

The simulator is pure Python 3 with no dependencies, so the tests and the benchmark work out of the box:

python3 -m engine.demo     # 19/19, incl. fib(10)=55 and an array-sum loop
python3 -m engine.bench    # per-instruction costs; writes build/cards.txt

Compiling C needs a RISC-V cross-compiler with an rv32i/ilp32 multilib. On macOS:

brew install riscv64-elf-gcc

Then:

./build.sh                 # programs/hello.c -> build/hello.bin
python3 runc.py            # runs build/hello.bin

./build.sh fib             # programs/fib.c   -> build/fib.bin
python3 runc.py build/fib.bin

./build.sh sieve && python3 runc.py build/sieve.bin

build.sh takes a bare name (fib), a filename (fib.c) or a path, and refuses to emit an image the card chain cannot decode. Check the toolchain has the right multilib with riscv64-elf-gcc -print-multi-lib | grep rv32i.

Watching it run

./build.sh && python3 gui.py
./build.sh sieve && python3 gui.py build/sieve.bin

It prints a link rather than opening anything; paste it into Google Chrome, which is what the panel was built and checked against.

A brass-and-mahogany front panel: the Store as a thousand figure wheels that flare as they are written, the Mill with its flywheel and its two ingress axes, the SGN and RUN levers, the card chain scrolling under the reader, a pressure gauge counting steam consumed, and the printing apparatus. Steam vents off the mill and thickens as the machine is worked harder.

The throttle sits beside the title, so it stays on screen. At its left stop the engine runs at 1x -- Babbage's own rate, one card every tenth of a second, about ten minutes to an emulated instruction; you can watch individual cards go by. At its right stop it is unthrottled and the whole of hello world -- 16,416 cards -- is over before the page can redraw. In between it is logarithmic, spanning some seven orders of magnitude, and the steam thickens with it.

Pacing is by engine-seconds rather than by cards, because a division costs six hundred times what a card feed does; counting cards would make arithmetic look free. The engine still runs in Python, through the same step_chain() the benchmark uses, so there is no second implementation of the machine and nothing to install -- gui.py is http.server and one HTML file.

C on the engine

build.sh drives riscv64-elf-gcc -march=rv32i -mabi=ilp32 and objcopies the result to a flat image; runc.py lays that image in the Store from column 140, sets pc to 0, and feeds the chain. Hello world compiles to 75 bytes, 79 instructions:

Hello, world!

RV32 instructions             79
mill operations            3,514   (add 1,654  sub 1,310  div 550)
on the engine            895 min   (53,713 s of steam)
on this machine           2.3 ms

Fifteen hours of steam for fourteen characters. Note that the two times are unrelated: the seconds are what Babbage's machine would have taken, counted from the mill operations and the cards fed. The simulator itself runs at about 30,000 emulated instructions per second.

The ABI is two calls. ecall with a7=1 lays a0 on the output apparatus, one value per character; a7=93 halts. There is no libc, no stack unwinding, and no .bss clearing -- the Store comes up as a thousand columns of zero, and a clearing loop would cost minutes per word for no effect.

Because a TRAP card halts with no diagnostic, build.sh greps the disassembly for anything the chain cannot decode (M/A/F/D, halfword access, FENCE, CSR) and refuses to emit an image that would die silently.

fib.c is the larger example: a table of fib(0..24) and a naive recursive fib(16), about three thousand calls, checked against the iterative answer so that every jal, jalr, sw and lw in the chain has to be right.

RV32 instructions         60,775
mill operations        3,024,197   (add 1,497,593  sub 973,606  div 552,885)
on the engine           568 days   (49,055,359 s of steam)
on this machine       1,976.7 ms

Over half those mill operations are divisions, and they are the whole story of the cost: RV32I has no divide instruction, so printing a number in decimal calls into libgcc's __udivsi3, which the engine then services with its own one-minute division. Linking -lgcc is what makes ordinary C arithmetic work on a machine that lacks it.

Filling the Store

sieve.c is built to use very nearly all of the machine. Its byte array is sized so that code, data and stack together come to 93% of the 3440 bytes:

text   911  data    0  bss  2320   footprint 3231 of 3440 (93%)
209 bytes left for the stack

sieve of eratosthenes, 2 to 2319
    2    3    5    7   11   13   17   19   23   29   31   37
   41   43   47   53   59   61   67   71   73   79   83   89
  ...
344 primes below 2320, the largest 2311.

build.sh reports that footprint rather than the image size, because .bss costs Store columns without appearing in the flat binary at all. Static data grows up and the stack grows down, and nothing on this machine will tell you when they meet -- a smashed stack just runs the chain into nonsense -- so link.ld asserts that a gap survives and refuses the link otherwise.

It is also the program worth watching in gui.py. The inner loop strides through the RAM region at the interval of whichever prime is being crossed off, so the figure wheels light every second column, then every third, then every fifth, the stride widening as the primes thin out.

The sieve never multiplies. It carries i*i forward by addition instead, since (i+1)^2 = i^2 + 2i + 1, and RV32I would otherwise call libgcc's __mulsi3 on every iteration -- the difference between minutes and hours of steam.

Machine

Store of 1000 columns x 50 signed decimal digits. Mill with two ingress axes, one egress axis, one secondary egress (remainder), and two levers: SGN (egress negative) and RUN (ran up past 50 digits). Control is a card chain; combinatorial cards advance or reverse it by a punched constant, optionally on a lever.

Cost model uses Babbage's own estimates: ~1 s for a 50-digit addition, ~60 s for a multiplication or division, 0.1 s per card fed.

The one modification

Indexed variable cards (*V7 in the listing) resolve their column number through the Store. This is NOT in Babbage's design -- variable cards carry absolute column numbers punched in cardboard, and there is no dereference. Run with price_linear=True to charge the unmodified engine's cost instead: resolving V[V[k]] by scanning the Store, one subtraction and two combinatorial cards per column.

Word representation

An RV32 word is a plain decimal integer 0..2^32-1 in one column. The alternative -- one bit per decimal digit -- makes AND/OR cheap but destroys arithmetic. Decimal magnitude wins because RISC-V field extraction becomes five successive mill divisions:

q1, opcode = divmod(ir, 2^7)
q2, rd     = divmod(q1, 2^5)
q3, funct3 = divmod(q2, 2^3)
q4, rs1    = divmod(q3, 2^5)
f7, rs2    = divmod(q4, 2^5)

and q4 falls out as the unsigned I-type immediate for free. Left shifts are repeated addition (k seconds for a shift of k, against 60 for a multiply). Only AND/OR/XOR between two arbitrary registers stay dear: 32 iterations of two divisions each.

Store map

0..31     x0..x31          (x0 held at zero, doubles as the zero column)
32        pc
33..89    interpreter temporaries
90..139   constants
140..999  emulated RAM, 860 words = 3440 bytes of RV32 address space

Not implemented: FENCE, CSR, M/A/F/D, LH/LHU/SH, misaligned access.

About

RV32I on Babbage's Analytical Engine

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages