From e8823223a6793bc35583c2295ea493e6c6c9925f Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Thu, 17 Sep 2026 13:05:05 +0200 Subject: [PATCH 1/2] docs: record what callbacks accept per platform, and the arm64 aggregate gap NewCallback answers an unsupported signature with a panic, and two of the three limits behind those panics are undocumented, so they read as bugs: panic: ffi: unsupported callback argument type: struct panic: ffi: Windows callbacks require uintptr-sized return type, got int32 docs/CALLBACK_ABI.md puts the three rows in one table -- amd64 Unix takes struct-by-value arguments, arm64 does not, Windows takes uintptr-sized arguments and one uintptr-sized result and nothing else -- and separates the limit that is permanent from the one that is not. Windows is the platform's own contract: NewCallback delegates to syscall.NewCallback, which accepts nothing more without cgo, and purego documents the same restriction. arm64 is unfinished work. For that one the page carries what an implementer needs rather than a wish: where amd64 does the equivalent (callbackWrap's eightbyte classification in ffi/callback.go), the four AAPCS64 cases to cover, the two that are easy to get wrong -- a four-float32 HFA occupying S0-S3, i.e. the low halves of four V registers the frame stores as 64-bit slots, and register exhaustion sending the whole aggregate to the stack with no backfill -- and Apple's naturally aligned stack packing, which means any implementation has to be checked against both a Linux and an Apple toolchain. It also points at ffi/callback_struct_args_test.go as the model for testing classification with a hand-built frame, no C toolchain and no real foreign call needed. Callers get the workaround in one line: take a pointer instead of a value. The panic site in ffi/callback_arm64.go now points at the page, so the message someone actually sees leads to the explanation. --- docs/CALLBACK_ABI.md | 78 +++++++++++++++++++++++++++++++++++++++++++ ffi/callback_arm64.go | 6 ++++ 2 files changed, 84 insertions(+) create mode 100644 docs/CALLBACK_ABI.md diff --git a/docs/CALLBACK_ABI.md b/docs/CALLBACK_ABI.md new file mode 100644 index 0000000..8f6da4e --- /dev/null +++ b/docs/CALLBACK_ABI.md @@ -0,0 +1,78 @@ +# Callback argument support, per platform + +`ffi.NewCallback` turns a Go function into a C function pointer. What that +function may look like is narrower than what the calling direction supports, +and it is not the same everywhere. This page says where the limits are and why, +so a caller can tell an unimplemented case from a broken one. + +## What is supported today + +| | integers, pointers, bool | float32 / float64 | struct by value | +|---|---|---|---| +| linux / darwin / freebsd, **amd64** | yes | yes | **yes** | +| linux / darwin / freebsd, **arm64** | yes | yes | **no** — panics | +| **windows** (amd64, arm64) | uintptr-sized only | no | no | +| **android** arm64 | rejected by design (see `docs/ANDROID.md`) | | | + +Return values follow the same rows: amd64 and arm64 return integers, pointers, +bool and floats; Windows returns exactly one uintptr-sized value. + +Windows is not a gap to close. `ffi.NewCallback` there delegates to Go's +`syscall.NewCallback`, which accepts only uintptr-sized arguments and one +uintptr-sized result; that is the whole contract the platform offers without +cgo, and `purego` documents the same one. + +## The arm64 gap + +On arm64 `validateCallbackSignature` (`ffi/callback_arm64.go`) rejects any +struct argument: + +``` +panic: ffi: unsupported callback argument type: struct +``` + +The amd64 dispatcher classifies aggregates per the System V ABI — one eightbyte +or two, INTEGER or SSE per eightbyte, MEMORY above 16 bytes — in +`callbackWrap` (`ffi/callback.go`), with `classifyEightbyte` and +`isStructAllFloats` doing the work. The arm64 dispatcher has no equivalent: it +reads each argument from one D or X register slot, or one stack slot, and an +aggregate does not fit that shape. + +This is missing work, not a platform limit. AAPCS64 defines the classification +and nothing about it is out of reach: + +- **HFA / HVA** — an aggregate whose members are all the same floating-point + type, at most four of them, goes in that many consecutive V registers. A + struct of four `float32` is the awkward case: the members occupy S0–S3, i.e. + the low 32 bits of four consecutive V registers, while the dispatcher's frame + stores each V register as a 64-bit slot. +- **Small aggregates (≤ 16 bytes)** — passed in one or two X registers, + as if the struct were copied into 8-byte chunks. +- **Larger aggregates** — passed indirectly: the caller places a copy in memory + and passes its address in one X register. +- **Register exhaustion** — when the required registers are not all available, + the whole aggregate goes on the stack, and the remaining registers are *not* + backfilled by later arguments. +- **Apple** — darwin/arm64 packs stack arguments to their natural alignment + rather than 8-byte slots, so a stack-passed aggregate needs its own handling + there. Any implementation has to be checked against both a Linux and an Apple + toolchain, not one of them. + +The calling direction (`ffi.CallFunction` with a struct argument) already does +classify aggregates on arm64; only the callback direction is missing. + +## Working around it + +Take a pointer instead of a value. `func(r *Rect)` is one X register on every +platform in the table, costs nothing, and is what most C APIs that hand a +struct to a callback do anyway. + +## If you are implementing this + +`ffi/callback_struct_args_test.go` is the amd64 model: it drives `callbackWrap` +directly with a hand-built argument frame, so the classification can be tested +without a C toolchain and without a real foreign call. An arm64 counterpart +building an AAPCS64 frame is the way to start, with the four-`float32` HFA and +a > 16-byte aggregate as the first two cases. `unxed/pureffi`'s +`TestFullCircleFFIStruct` is a ready end-to-end check: it skips on arm64 today +and would start running. diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index 572d09b..1f3fdba 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -77,6 +77,12 @@ func validateCallbackSignature(typ reflect.Type) { reflect.Pointer, reflect.UnsafePointer, reflect.Bool: // Valid types default: + // Struct arguments land here. The amd64 dispatcher classifies + // aggregates (System V eightbytes); the arm64 one has no AAPCS64 + // equivalent yet, so the signature is rejected up front rather + // than silently misread. See docs/CALLBACK_ABI.md -- it describes + // what the implementation needs and how to test it, and the + // pointer workaround for callers. panic("ffi: unsupported callback argument type: " + argType.Kind().String()) } } From 11156ba0bd6347197e453e44f57359a787122e85 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Sun, 8 Nov 2026 09:25:02 +0300 Subject: [PATCH 2/2] docs: link CALLBACK_ABI.md and match it to the current callback code isStructAllFloats is gone; the amd64 callback path now takes its register and stack assignment from the call path's System V allocator. Link the page from README and ARCHITECTURE.md and record it in the CHANGELOG. --- CHANGELOG.md | 1 + README.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/CALLBACK_ABI.md | 5 +++-- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2589f8..ff71407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`llms.txt`** ([llmstxt.org](https://llmstxt.org/) format): what goffi does, the rules that trip people up (pointer-to-value `avalue`, keeping Go memory alive, type limits, the cgo linking issue) and links to the API reference, docs and examples. +- **`docs/CALLBACK_ABI.md`: what `NewCallback` accepts on each platform** — integers, floats and (amd64 Unix) structs; `uintptr`-sized values only on Windows; why arm64 rejects struct arguments, what AAPCS64 needs for them and how to test an implementation; the pointer workaround (#84, by @unxed). ### Changed - **Contributors run the checks CI runs; `scripts/pre-release-check.sh` is gone.** It duplicated the CI jobs and had drifted from them. `CONTRIBUTING.md` lists the checks: format, lint on every target, tests and the race detector. diff --git a/README.md b/README.md index 9398419..308f2a0 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,7 @@ go test -v ./ffi # verbose, auto-detects platform |----------|-------------| | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical architecture: assembly, ABIs, callbacks, linking | | [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | Benchmarks, optimization strategies, Go 1.26 | +| [docs/CALLBACK_ABI.md](docs/CALLBACK_ABI.md) | What callback signatures each platform accepts, and the arm64 struct gap | | [docs/ANDROID.md](docs/ANDROID.md) | Android ARM64 preview ABI + build notes | | [docs/ADR-001-userspace-elf-loader.md](docs/ADR-001-userspace-elf-loader.md) | Research: optional pure-Go `.so` loader | | [CHANGELOG.md](CHANGELOG.md) | Version history, migration guides | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4328bf7..1508bf9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -188,7 +188,7 @@ Registers and stack words are assigned by the allocator the call path uses (`int Classification uses `reflect.Type` (not `types.TypeDescriptor`) since callback signatures are Go functions registered via `NewCallback()`. -**Limitations**: callback struct args supported on AMD64 Unix only. ARM64 and Windows callbacks do not yet support struct arguments. +**Limitations**: callback struct args supported on AMD64 Unix only. ARM64 and Windows callbacks do not yet support struct arguments; [CALLBACK_ABI.md](CALLBACK_ABI.md) lists what each platform accepts and what the arm64 implementation needs. --- diff --git a/docs/CALLBACK_ABI.md b/docs/CALLBACK_ABI.md index 8f6da4e..13715fb 100644 --- a/docs/CALLBACK_ABI.md +++ b/docs/CALLBACK_ABI.md @@ -33,8 +33,9 @@ panic: ffi: unsupported callback argument type: struct The amd64 dispatcher classifies aggregates per the System V ABI — one eightbyte or two, INTEGER or SSE per eightbyte, MEMORY above 16 bytes — in -`callbackWrap` (`ffi/callback.go`), with `classifyEightbyte` and -`isStructAllFloats` doing the work. The arm64 dispatcher has no equivalent: it +`callbackWrap` (`ffi/callback.go`), with `classifyEightbyte` classifying each +eightbyte and the call path's allocator (`internal/arch/amd64/layout_sysv.go`) +assigning registers and stack words. The arm64 dispatcher has no equivalent: it reads each argument from one D or X register slot, or one stack slot, and an aggregate does not fit that shape.