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
go install github.com/zg/asm-calc/cmd/asm-calc@latest
Use
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 theRDX:RAXpair including its two fault conditions. -
A real CPU, via
-S -c, which emits a complete NASM source file with a_startentry point. Assembling it needsnasmand an x86-64 host: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.
go test ./...