I finished rereading The AWK Programming Language today and have been keeping myself entertained working some of the exercises, specifically a toy assembler and interpreter.
I first purchased the book based entirely on the recommendation of Brandon Rhodes in a comment on Stack Overflow:
... the real reason to learn awk is to have an excuse to read the superb book The AWK Programming Language by its authors Aho, Kernighan, and Weinberger. You would think, from the name, that it simply teaches you awk. Actually, that is just the beginning. Launching into the vast array of problems that can be tackled once one is using a concise scripting language that makes string manipulation easy — and awk was one of the first — it proceeds to teach the reader how to implement a database, a parser, an interpreter, and (if memory serves me) a compiler for a small project-specific computer language! If only they had also programmed an example operating system using awk, the book would have been a fairly complete survey introduction to computer science!
I was excited at the thought of such a concise study of computer science in so short a book and it really has lived up to my expectations. I have been thinking about it again recently as a result of my interest in getting back to basics. Getting started with assembly using something like x86 is difficult due to its sheer size and scope. Doing any research online reveals plenty of people claiming no one writes x86 directly and it is best left to compilers - not a good place to position an entry-level exercise. What I really want is a more human instruction set, aimed at teaching more than doing. Which is, incidentally, exactly what I found in The AWK Programming Language.
The "instruction set" given is almost painfully limited, but dead simple to get started with:
instruction | mnemonic | description |
---|---|---|
01 | get |
read a number from input (STDIN) into accumulator |
02 | put |
write contents of accumulator to output (STDOUT) |
03 | ld |
load accumulator with contents of memory location M |
04 | st |
store contents of accumulator in location M |
05 | add |
add contents of location M to accumulator |
06 | sub |
subtract contents of location M from accumulator |
07 | jpos |
jump to location M if accumulator is positive |
08 | jz |
jump to location M if accumulator is zero |
09 | j |
jump to location M |
10 | halt |
stop execution |
With the exception of the references and use of DEBUG
, the following is
excerpted from the book directly:
# -*- awk -*-
# asm - assembler and interpreter for simple computer
# usage: awk -v DEBUG=1 -f asm program-file data-files
BEGIN {
srcfile = ARGV[1]
ARGV[1] = "" # remaining files are data
tempfile = "asm.temp"
n = split("const get put ld st add sub jpos jz j halt", x)
for (i = 1; i <= n; i++) # create table of op codes
op[x[i]] = i-1
# ASSEMBLER PASS 1
FS = "[ \t]+"
while (getline <srcfile > 0) {
sub(/#.*/, "") # strip comments
symtab[$1] = nextmem # remember label location
if ($2 != "") { # save op, addr if present
print $2 "\t" $3 >tempfile
nextmem++
}
}
close(tempfile)
print ""
# ASSEMBLER PASS 2
nextmem = 0
while (getline <tempfile > 0) {
if ($2 !~ /^[0-9]*$/) # if symbolic addr,
$2 = symtab[$2] # replace by numeric value
mem[nextmem++] = 1000 * op[$1] + $2 # pack into word
}
# INTERPRETER
for (pc = 0; pc >= 0; ) {
addr = mem[pc] % 1000
code = int(mem[pc++] / 1000)
if(DEBUG) {
split("const get put ld st add sub jpos jz j halt", x)
printf "code: %-5s acc: %-5s addr: %-10s\n", x[code+1], acc, addr
}
if (code == op["get"]) { getline acc }
else if (code == op["put"]) {
if(DEBUG)
printf "OUTPUT: %10s\n", acc
else
print acc
}
else if (code == op["st"]) { mem[addr] = acc }
else if (code == op["ld"]) { acc = mem[addr] }
else if (code == op["add"]) { acc += mem[addr] }
else if (code == op["sub"]) { acc -= mem[addr] }
else if (code == op["jpos"]) { if (acc > 0) pc = addr }
else if (code == op["jz"]) { if (acc == 0) pc = addr }
else if (code == op["j"]) { pc = addr }
else if (code == op["halt"]) { pc = -1 }
else { pc = -1 }
}
}
Anyone with even a modicum of experience with assembly is probably scratching
their head at the lack of a stack or registers but it's like I said, painfully
limited. I suppose there's nothing to prevent declaring variables named
register1
, because while there is nothing in the instruction set to directly
support it, look-up times should be constant!
I was a little stumped as to how best to practice with such a limited set of instructions but ultimately settled on the following program which accepts user input as a limit for which to calculate Fibonacci numbers.
# take as input a limit N and compute N fibonnacci numbers
get # read upper bound into acc.
add decrement # increment by 1 to avoid early termination
st limit # in the >0 check
loop ld limit
sub decrement
jz done
# else haven't reached limit
st limit
ld last
add current
st sum
ld current # this swap/dance feels like a glaring
st last # limitation of the instruction set!
ld sum
st current
j loop
done ld sum
put # print sum
halt
counter const 0
decrement const 1
last const 0
current const 1
sum const
limit const
Almost immediately some limitations of the "architecture" become obvious the, the single "register" (closest word I can think of for the accumulator) requires an annoying amount of swapping across variables, rather than something like indirect references.
$ awk -v DEBUG=1 -f asm fib.asm <<<"5"
code: get acc: addr: 0
code: add acc: 5 addr: 19
code: st acc: 6 addr: 23
code: ld acc: 6 addr: 23
code: sub acc: 6 addr: 19
code: jz acc: 5 addr: 15
code: st acc: 5 addr: 23
code: ld acc: 5 addr: 20
code: add acc: 0 addr: 21
code: st acc: 1 addr: 22
code: ld acc: 1 addr: 21
code: st acc: 1 addr: 20
code: ld acc: 1 addr: 22
code: st acc: 1 addr: 21
code: j acc: 1 addr: 3
code: ld acc: 1 addr: 23
code: sub acc: 5 addr: 19
code: jz acc: 4 addr: 15
code: st acc: 4 addr: 23
code: ld acc: 4 addr: 20
code: add acc: 1 addr: 21
code: st acc: 2 addr: 22
code: ld acc: 2 addr: 21
code: st acc: 1 addr: 20
code: ld acc: 1 addr: 22
code: st acc: 2 addr: 21
code: j acc: 2 addr: 3
code: ld acc: 2 addr: 23
code: sub acc: 4 addr: 19
code: jz acc: 3 addr: 15
code: st acc: 3 addr: 23
code: ld acc: 3 addr: 20
code: add acc: 1 addr: 21
code: st acc: 3 addr: 22
code: ld acc: 3 addr: 21
code: st acc: 2 addr: 20
code: ld acc: 2 addr: 22
code: st acc: 3 addr: 21
code: j acc: 3 addr: 3
code: ld acc: 3 addr: 23
code: sub acc: 3 addr: 19
code: jz acc: 2 addr: 15
code: st acc: 2 addr: 23
code: ld acc: 2 addr: 20
code: add acc: 2 addr: 21
code: st acc: 5 addr: 22
code: ld acc: 5 addr: 21
code: st acc: 3 addr: 20
code: ld acc: 3 addr: 22
code: st acc: 5 addr: 21
code: j acc: 5 addr: 3
code: ld acc: 5 addr: 23
code: sub acc: 2 addr: 19
code: jz acc: 1 addr: 15
code: st acc: 1 addr: 23
code: ld acc: 1 addr: 20
code: add acc: 3 addr: 21
code: st acc: 8 addr: 22
code: ld acc: 8 addr: 21
code: st acc: 5 addr: 20
code: ld acc: 5 addr: 22
code: st acc: 8 addr: 21
code: j acc: 8 addr: 3
code: ld acc: 8 addr: 23
code: sub acc: 1 addr: 19
code: jz acc: 0 addr: 15
code: ld acc: 0 addr: 22
code: put acc: 8 addr: 0
OUTPUT: 8
code: halt acc: 8 addr: 0
What a mouthful! This disassembly is almost delightfully uncomplicated, which
is not to say bug-free - I've managed to write an infinite loop at least twice
in one evening. I must admit some surprise at how quickly the program actually
runs (never mind the fact that it runs at all). For fun, I attempted a
comparison against a similarly-coded solution in Python and tried reading the
output of dis.dis
:
def fib(n):
a = 0; b = 1; i = 0;
while i < n:
a, b = b, a+b
i += 1
return b
if __name__ == '__main__':
print(dis.dis(f))
With the following results:
8 0 LOAD_CONST 1 (0)
3 STORE_FAST 1 (a)
6 LOAD_CONST 2 (1)
9 STORE_FAST 2 (b)
12 LOAD_CONST 1 (0)
15 STORE_FAST 3 (i)
9 18 SETUP_LOOP 43 (to 64)
>> 21 LOAD_FAST 3 (i)
24 LOAD_FAST 0 (n)
27 COMPARE_OP 0 (<)
30 POP_JUMP_IF_FALSE 63
10 33 LOAD_FAST 2 (b)
36 LOAD_FAST 1 (a)
39 LOAD_FAST 2 (b)
42 BINARY_ADD
43 ROT_TWO
44 STORE_FAST 1 (a)
47 STORE_FAST 2 (b)
11 50 LOAD_FAST 0 (n)
53 LOAD_CONST 2 (1)
56 INPLACE_SUBTRACT
57 STORE_FAST 0 (n)
60 JUMP_ABSOLUTE 21
>> 63 POP_BLOCK
12 >> 64 LOAD_FAST 2 (b)
67 RETURN_VALUE
Which begins to reveal the decisions that drove the design of the Python
virtual machine. Binary ADD
- that's probably nice to have, my stringly-typed
VM sure doesn't have anything like that (directly, the machinations of AWK are
mostly1 beyond the scope of this post).