Ergonomic generation of Go-compatible Plan 9 assembly for every 64-bit Go
target — amd64, arm64, riscv64, loong64, ppc64le, and
s390x. Encoding is
delegated to the Go toolchain assembler (cmd/asm); go-asmgen computes the ABI0
frame layout and emits well-formed Plan 9 instruction text.
avo does this for amd64 by encoding instruction bytes itself — powerful, but
exactly what makes extending it to new ISAs expensive. go-asmgen instead emits
Plan 9 text and lets cmd/asm encode, so each architecture is just a thin
move/register surface over a shared ABI0 layout model (abi). avo
remains the richer choice for amd64-specific work; go-asmgen's niche is one
uniform builder across all six targets (and it is the only such tool for the
non-amd64 ones — now including ppc64le's VSX and s390x's vector facility). The architectures differ only in their move tables:
| 8-byte int | float32 | float64 | |
|---|---|---|---|
MOVQ |
MOVSS |
MOVSD |
amd64 |
MOVD |
FMOVS |
FMOVD |
arm64 |
MOV |
MOVF |
MOVD |
riscv64 |
MOVV |
MOVF |
MOVD |
loong64 |
MOVD |
FMOVS |
FMOVD |
ppc64le |
MOVD |
FMOVS |
FMOVD |
s390x (big-endian) |
Go's compiler does not emit v128 from Go source, and Plan 9 assembly has
no wasm dialect — so wasm-SIMD kernels have to be external, imported via
//go:wasmimport. The wasm package ports the same layering
pattern to a seventh target: a programmatic WAT text emitter that
wat2wasm compiles into .wasm, byte-equivalent to a hand-authored
kernel and drift-gated against the generator in consumer CI.
Thirteen kernels ship in examples/wasm/ (matchlen, hex,
hex_decode, popcount, toupper, memchr, isascii, utf8len, json_clean,
adler32, base64_encode, indexany4, base64_decode), each with a
golden-file test and a wazero cross-check against a Go stdlib reference.
The CI wasm-e2e job regenerates every kernel, drift-gates against the
committed golden, compiles via wat2wasm, and runs the wazero verifier
on all thirteen. See the
wasm docs section for
the emitter surface, kernel signatures, and the drift + regression CI
pattern used by consumers.
The wasm package was previously shipped as a standalone
go-asmgen/wasm module (tags v0.1.0 – v0.3.0) — that repo has since
been retired in favor of this fold. Pin
github.com/go-asmgen/asmgen/wasm/… and
github.com/go-asmgen/asmgen/examples/wasm/<kernel>.
v0 — amd64, arm64, riscv64, loong64, ppc64le, s390x,
ABI0. Correct for
sequences of scalars in any combination: signed/unsigned integers of 1/2/4/8
bytes, pointers, and 32/64-bit floats. Each builder selects the right move per
type and the shared layout computes ABI0 offsets (result area word-aligned,
sub-word loads sign/zero-extended). Every emitted offset and access width is
cross-checked by go vet asmdecl and exercised by runtime tests — natively on
amd64 and arm64, and under qemu-user for riscv64, loong64, ppc64le, and s390x
(s390x exercising the big-endian path).
Aggregates and arrays are supported: struct, slice, and string parameters
(fields addressed as name_field+offset(FP), e.g. s_base/s_len/s_cap) and
fixed-size [n]T arrays passed by value (element-wise, name_0…name_(n-1)).
The layout math is architecture-independent and proven generically in abi's
own tests; amd64, arm64, riscv64, and loong64 additionally have dedicated,
runtime-tested example programs for both — see examples. ppc64le
and s390x don't have dedicated aggregate/array examples yet (SIMD examples
exist for all six — see below).
SIMD works through the Raw escape hatch over loaded pointers: go-asmgen lays
out the ABI0 frame and the vector body is emitted directly. Runtime-tested
packed-add on all six targets — SSE2 + AVX2 (amd64), NEON (arm64),
RVV (riscv64), LSX + LASX (loong64), VSX (ppc64le), vector facility
(s390x, big-endian), up to 256-bit. See
examples/simd.
Stack frames and TEXT flags: NewFuncFlags emits any flags (NOSPLIT,
NOSPLIT|NOFRAME, or none for the stack-growth preamble); frameSize > 0
reserves locals addressed name-N(SP). See examples/frame.
A typed vector-load helper (to drop the Raw boilerplate) and first-class vector
types are the main remaining items.
Three small packages: an architecture builder (amd64 / arm64 / riscv64 /
loong64 / ppc64 / s390x), the ABI0 layout model (abi), and the Plan 9 file
writer (emit).
go get github.com/go-asmgen/asmgen@latestpackage main
import (
"os"
"github.com/go-asmgen/asmgen/arm64"
"github.com/go-asmgen/asmgen/emit"
)
func main() {
// func add(a, b int64) int64
sig := arm64.Layout(
[]string{"a", "b"}, []arm64.Type{arm64.Int64, arm64.Int64},
[]string{"ret"}, []arm64.Type{arm64.Int64},
)
b := arm64.NewFunc("add", sig, 0)
b.LoadArg("a", "R0").
LoadArg("b", "R1").
Raw("ADD R1, R0, R2").
StoreRet("R2", "ret").
Ret()
f := emit.NewFile("arm64")
f.Add(b.Func())
os.WriteFile("add_arm64.s", []byte(f.String()), 0o644)
}For struct/slice/string parameters, build the layout with
abi.LayoutArgs and the Struct/Slice/String constructors; see
examples/aggregate.
The library packages (abi, emit, amd64, arm64, riscv64, loong64,
ppc64, s390x) are architecture-independent and held to 100% test coverage:
go test ./abi/... ./emit/... ./amd64/... ./arm64/... ./riscv64/... ./loong64/... ./ppc64/... ./s390x/...The generated assembly is the real test of correctness. On an arm64 host (Apple Silicon or arm64 Linux):
go generate ./examples/add/... ./examples/types/...
GOARCH=arm64 go vet ./examples/add/... ./examples/types/... # asmdecl
go test ./examples/add/... ./examples/types/... # runtimeriscv64, loong64, ppc64le and s390x have no common native host, so validate them statically
anywhere (asmdecl + cmd/asm) and run them under emulation (shown for riscv64;
loong64 is identical with GOARCH=loong64 / qemu-loongarch64-static):
go generate ./examples/riscv64/...
GOOS=linux GOARCH=riscv64 go vet ./examples/riscv64/... # asmdecl
GOOS=linux GOARCH=riscv64 go build ./examples/riscv64/... # cmd/asm checks mnemonics
# runtime under qemu-user (e.g. in CI), or via Docker's emulation:
GOARCH=riscv64 go test -exec=qemu-riscv64-static ./examples/riscv64/...go vetasmdecl passes. The cheapest, strongest check: it verifies everyname+offset(FP)in the.smatches the Go declaration. Wrong offsets are caught before runtime.cmd/asmaccepts every instruction, and the committed.sis regenerated and diffed — a stale or invalid.sfails the build.- Runtime test — the function is actually called and its result checked: natively on amd64 and arm64 runners, under qemu-user for riscv64, loong64, ppc64le and s390x.
- 100% library coverage is gated on
abi,emit, and the six builders.
- v0: arm64, ABI0, 8-byte int/ptr args. (done)
- Widen arm64 scalar support: 1/2/4-byte signed/unsigned ints, pointers, float
regs (F0..,
FMOVS/FMOVD), word-aligned result area, sub-word sign/zero extension. Validated against asmdecl + runtime tests. (done) - Extract the shared ABI0 model (
internal/abi) and add riscv64 as a thin second architecture over it, runtime-proven under qemu-user. (done) - Add loong64 as a third architecture — same recipe, just a move table
(
MOVV,MOVF/MOVD), runtime-proven under qemu-user. (done) - Aggregates: struct/slice/string parameters laid out by Go's struct rules,
fields addressed as
name_field+offset(FP), asmdecl- and runtime-validated. (done) - Make the library importable: promote
abiandemitout ofinternal/. (done) - Add amd64 (fourth target; richer move table with
MOVxQSX/ZXsub-word loads and SSE float moves), runtime-proven natively, and SIMD examples (amd64 SSE + arm64 NEON packed add) throughRaw. (done — here) - First-class vector types (pass
[4]float32by value; RVV on riscv64, LSX/LASX on loong64 — all six assemblers support SIMD), and array value args. - Optional: derive instruction mnemonic tables from cmd/internal/obj to catch typos at generation time (still delegating encoding to cmd/asm).
- ABI0 not ABIInternal: ABIInternal is an unstable internal contract that can change between Go releases. ABI0 (stack-based, FP-relative) is stable and is what hand-written .s targets.
- NOSPLIT by default in v0: avoids stack-growth preamble. Revisit for functions with large frames or that call other functions.
