110 lines
4.1 KiB
Markdown
110 lines
4.1 KiB
Markdown
# asm-calc
|
||
|
||
A calculator built around x86-64 assembly: it parses arithmetic expressions
|
||
and compiles them to real x64 instructions, then executes those instructions
|
||
to produce the answer.
|
||
|
||
```
|
||
$ asm-calc '2 + 3 * 4'
|
||
14
|
||
|
||
$ asm-calc -S '2 + 3 * 4'
|
||
mov rax, 2 ; literal 2
|
||
push rax ; save left operand
|
||
mov rax, 3 ; literal 3
|
||
push rax ; save left operand
|
||
mov rax, 4 ; literal 4
|
||
mov rcx, rax ; right operand
|
||
pop rax ; restore left operand
|
||
imul rax, rcx ; multiply
|
||
mov rcx, rax ; right operand
|
||
pop rax ; restore left operand
|
||
add rax, rcx ; add
|
||
```
|
||
|
||
## Install
|
||
|
||
```sh
|
||
go install github.com/zg/asm-calc/cmd/asm-calc@latest
|
||
```
|
||
|
||
## Use
|
||
|
||
```sh
|
||
asm-calc '1 + 2 * 3' # evaluate
|
||
asm-calc -S '1 + 2 * 3' # print the assembly
|
||
asm-calc -S -c 'x = 42' # complete assemblable source file
|
||
asm-calc # REPL (:asm, :vars, :quit)
|
||
```
|
||
|
||
Operators: `+ - * / % ^` and parentheses, unary minus, and assignment with
|
||
`=`. Variables persist across REPL lines.
|
||
|
||
## How it runs the code
|
||
|
||
The compiler emits genuine x86-64. Executing it takes one of two paths:
|
||
|
||
- **A built-in simulator** of the emitted instruction subset, which is what
|
||
runs by default. It models the registers, the stack, frame-relative
|
||
memory, and `idiv`'s use of the `RDX:RAX` pair including its two fault
|
||
conditions.
|
||
- **A real CPU**, via `-S -c`, which emits a complete NASM source file with
|
||
a `_start` entry point. Assembling it needs `nasm` and an x86-64 host:
|
||
|
||
```sh
|
||
asm-calc -S -c '6 * 7' > calc.asm
|
||
nasm -f elf64 calc.asm -o calc.o && ld calc.o -o calc
|
||
./calc; echo $? # 42
|
||
```
|
||
|
||
The simulator exists so the emitted code is testable *everywhere*, including
|
||
on machines that are not x86-64 — which is where this was developed. Without
|
||
it, the test suite would only be meaningful on one architecture.
|
||
|
||
## Design notes
|
||
|
||
**Operands go via the stack, not a second register.** Each binary operation
|
||
evaluates its left side into RAX, pushes it, evaluates its right side, then
|
||
pops. That costs a push and pop per operation, but means expressions of any
|
||
depth compile without running out of registers — a 200-level nested
|
||
expression is a test case.
|
||
|
||
**`cqo` before every `idiv`.** `idiv` divides the 128-bit `RDX:RAX` pair by
|
||
its operand, so RDX must hold the sign extension of RAX. Omitting it gives
|
||
wrong answers *only for negative dividends*, which is why `-10 / 2` and
|
||
`-17 % 5` have their own test rather than relying on the general arithmetic
|
||
cases.
|
||
|
||
**Operand order is checked explicitly.** The stack strategy leaves the right
|
||
operand in RAX and the left on the stack — backwards for subtraction and
|
||
division. Commutative tests cannot catch a mistake here, so the
|
||
non-commutative cases are tested separately.
|
||
|
||
**Exponents are constant-folded.** `2^8` unrolls to repeated `imul`, which
|
||
requires the exponent be known at compile time since this instruction subset
|
||
has no jumps. The first version required a literal and so rejected
|
||
`2 ^ 3 ^ 2` — which is right-associative, making the exponent the
|
||
*expression* `3 ^ 2`. Exponents are now folded, so any constant expression
|
||
works.
|
||
|
||
**Division faults are modelled, not ignored.** Division by zero and the one
|
||
overflow case — the most negative value divided by −1, whose quotient is not
|
||
representable — both raise the errors a real CPU would, rather than
|
||
panicking the host.
|
||
|
||
**Frames are 16-byte aligned**, as the ABI requires; an unaligned stack
|
||
crashes any callee that touches SSE.
|
||
|
||
## Tests
|
||
|
||
The strongest test is differential: **3,000 randomly generated expressions**
|
||
are compiled, executed on the simulator, and compared against an independent
|
||
tree-walking evaluator that shares none of the compiler's logic. That covers
|
||
precedence, associativity and operand ordering together, across far more
|
||
cases than a table could enumerate. Push/pop balance is asserted separately —
|
||
a program that leaks stack would corrupt real execution.
|
||
|
||
```sh
|
||
go test ./...
|
||
```
|