I want to get closer to the loops in my interpreter, and at the moment learning the assembly notation is a job of its own. Putting the entire interpreter in front of me would give me plenty to stare at and very little idea where to start.

So, two numbers. Can I add them?

That sounds like a small task, but it is going to give us a lot of new things to learn. If Add(2, 3) gives me five, I can concentrate on how those numbers arrived and how Go receives the answer.

An addition I can read

The declaration goes in asm_amd64.go, in an ordinary package:

package asmsum

func Add(a, b uint64) uint64

There is no Go function body. I'll supply that in asm_amd64.s:

#include "textflag.h"

TEXT ·Add(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX
    MOVQ b+8(FP), BX
    ADDQ BX, AX
    MOVQ AX, ret+16(FP)
    RET

The punctuation makes it look more mysterious than the calculation is. Leave the function header aside for a moment. The rest reads like this:

AssemblyWhat I'm asking it to do
MOVQ a+0(FP), AXCopy the first number into register AX.
MOVQ b+8(FP), BXCopy the second number into register BX.
ADDQ BX, AXAdd BX to AX, keeping the result in AX.
MOVQ AX, ret+16(FP)Copy the answer into the result slot.
RETReturn to the caller.

A register is a small storage location in the processor, and here I'm using two to hold the numbers while I work on them. MOVQ copies a value without removing it from wherever I found it. The addition does overwrite one of its inputs. After ADDQ BX, AX the five is in AX while BX still holds three. There isn't a fresh variable called answer waiting for me, so I have to choose where it goes. That's part of the appeal. I can follow the values through a few named places, and each line tells me which one changes. It's also more bookkeeping than writing return a + b (quite a lot more, considering we've only added two numbers).

What is that first line?

TEXT introduces a function and ·Add(SB) names Add in the current package. The middle dot is how the package-local spelling works, and SB is a pseudo-register used when referring to global symbols. Those are assembler conventions. SB isn't a processor register I need to fill before calling the function.

FP looks enough like a register name that I could read a+0(FP) as an address relative to a hardware frame pointer. The assembler guide is worth checking here. This FP is another pseudo-register, a way to refer to arguments and results by name and offset in Go's stack-based assembly interface. For this declaration each uint64 takes eight bytes, giving this layout:

Offset from FPValue
0Argument a
8Argument b
16The result

That explains the 0, 8 and 16 in the loads and store, as well as the 24 in $0-24. The first number is the local frame size, zero here. The second is the total size of the arguments and result. I find that much easier to read once I draw the three slots. Twenty-four isn't an assembly incantation. It's three eight-byte values.

So far my assembly career involves drawing three boxes to add two numbers.

The names before the offsets matter when checking the code. a+0(FP) documents which argument I expect there. With a Go declaration available, go vet can check those names and offsets. An unnamed Go result uses ret in the assembly.

NOSPLIT comes from textflag.h and omits the usual stack-growth check at entry. This tiny leaf function has no local frame and makes no calls, so that's suitable here. Larger functions have stack and runtime obligations to account for. This isn't a speed sticker to scatter over them.

There is one boundary to keep straight. This is Go's stack-based assembly interface or ABI0, while Go's compiler also has an internal calling convention that can pass values in registers. The toolchain supplies wrappers between the two where needed. Reading a from a stack slot here doesn't tell me how every compiled Go function receives its arguments. If I borrow a Go runtime function, I need to check which interface it uses. The Go assembler guide explains these conventions. I'm keeping it open beside the example rather than trying to remember the middle dot and the offsets and the flags and.... Right, that's why the guide stays open.

The spelling takes some getting used to

Go uses an assembly style derived from Plan 9, and it's a semi-abstract input to the toolchain so I can't assume every line always corresponds to exactly one machine instruction.

For the instructions here, operands run from source to destination. MOVQ BX, AX copies BX into AX, and ADDQ BX, AX changes AX. Reading that consistently is more useful to me than trying to learn a large instruction list first.

The Q means a quadword of eight bytes and matches the size of these uint64 values. Other suffixes select other sizes, changing how much data the instruction works on.

Then there is the dollar sign:

MOVQ $5, AX

That puts the literal value five in AX. Without the dollar sign, MOVQ 5, AX means reading from memory at address five. I have no reason to read that address. Please leave the dollar sign on.

Parentheses also indicate a memory access. MOVQ (SI), AX reads the value at the address held in SI, whereas MOVQ SI, AX copies the address itself. We'll need that distinction for the slice. These are short names doing very specific jobs. Once I can read the loads, stores and destinations, the function starts looking like a sequence I can follow rather than a wall of abbreviations.

A slice gives us a loop

Now I can give the function more than two numbers. Add another declaration to asm_amd64.go:

func Sum(xs []uint64) uint64

The slice contains unsigned integers, so I can concentrate on traversal without also dealing with floating-point rounding. The Go reference is small:

func reference(xs []uint64) uint64 {
    var total uint64
    for _, x := range xs {
        total += x
    }
    return total
}

Below Add in the assembly file, I put:

TEXT ·Sum(SB), NOSPLIT, $0-32
    MOVQ xs_base+0(FP), SI
    MOVQ xs_len+8(FP), CX
    XORQ AX, AX
loop:
    TESTQ CX, CX
    JE done
    ADDQ (SI), AX
    ADDQ $8, SI
    DECQ CX
    JMP loop
done:
    MOVQ AX, ret+24(FP)
    RET

This time, the argument is a slice. On amd64 its header contains three eight-byte words for the data pointer, length and capacity, followed by the result at offset twenty-four for an argument/result area of thirty-two bytes. Only the pointer and length enter the loop. Capacity tells me how much backing storage the slice can use, but I'm summing its current elements, so I leave capacity alone.

Three registers, three jobs

SI holds the next element's address. CX holds how many elements remain. AX holds the running total. The XORQ AX, AX sets that total to zero by XORing the value with itself.

I like being able to write those three jobs down before reading the loop. Otherwise it's easy to keep translating the names without noticing what I'm actually tracking.

A loop can stay on one side
Figure 1 - A loop can stay on one side. One call enters the assembly loop, and one return brings the result back to Go.

Following three numbers

Take a slice containing [2, 3, 4]. At entry, SI points at the two, CX holds three, and AX holds zero.

The label loop: names a place to jump to. TESTQ CX, CX checks the counter without changing it, and sets the processor's flags. JE done jumps to done if that test found zero.

Our counter is three, so we carry on. ADDQ (SI), AX reads the two and adds it to the total. Then ADDQ $8, SI advances the pointer by eight bytes, to the next uint64. DECQ CX reduces the counter by one, and JMP loop jumps to the counter test.

The pointer addition is worth stopping over. I'm adding eight to an address there, not adding another eight to the sum. Without that step I'd keep reading the same two.

After each visit, the numbers are:

Element just readRunning total / elements remaining
22 / 2
35 / 1
49 / 0

We return to the test with no elements left to read. We jump to done, store nine in ret+24(FP) and return.

An empty slice takes that exit on the first test, and a nil slice has length zero too so neither case reads through the data pointer. I start at that entrance when checking the loop. It's easy to get absorbed in the addition and forget that doing nothing is part of the job.

The arithmetic wraps on unsigned overflow, as the Go reference does. For example, adding one to the largest uint64 gives zero. An unbounded integer sum would need a different contract.

Putting the files to work

The example needs an amd64 machine and an ordinary Go module. In a fresh directory, go mod init example.com/asmsum creates the module. The two files are asm_amd64.go and asm_amd64.s, with both declarations in the Go file and both bodies in the assembly file.

I compare Add with Go's addition, and Sum with the reference above. The cases include [2, 3, 4], a single element, nil and empty slices, and unsigned overflow. A longer slice is useful too, so the loop has to advance more than twice.

I can put the calculation checks in asm_amd64_test.go:

package asmsum

import "testing"

func TestFunctions(t *testing.T) {
    if got := Add(2, 3); got != 5 {
        t.Fatalf("Add(2, 3) = %d, want 5", got)
    }
    cases := []struct {
        xs   []uint64
        want uint64
    }{
        {nil, 0},
        {[]uint64{}, 0},
        {[]uint64{7}, 7},
        {[]uint64{2, 3, 4}, 9},
        {[]uint64{^uint64(0), 1}, 0},
    }
    for _, tc := range cases {
        if got := Sum(tc.xs); got != tc.want {
            t.Fatalf("Sum(%v) = %d, want %d", tc.xs, got, tc.want)
        }
    }
}

Who picks the answers? These expected sums are small enough to read directly. The last one checks unsigned wraparound, with ^uint64(0) giving the largest value. For longer inputs I use the Go reference too. I don't want the assembly itself choosing the answers its test will expect.

Then I run:

go test ./...
go vet ./...

The comparisons check the calculation. The explicit vet run checks the assembly declarations and offsets too. Benchmarks can wait. I'm still learning to read what I've written.

What interests me now is the route through the function. Once Go calls Sum, the loop keeps all three pieces of state here until the job is finished and I can follow each change. An interpreter loop is a much larger version of that problem, and that's what makes this small function worth spending time on.

This entry came over from blog.haddon.me. I’ve tidied up the grammar and flow, bringing it closer to how I write now. I used AI to help with the grammar, structure and flow. I have dyslexia, and my writing often comes out as a rambling scrawl, so it can be hard to see what I was actually working on at the time. I think the tidied versions are easier to digest and more interesting to read. The artefacts got the same tidy-up while I was at it.