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 new file mode 100644 index 0000000..13715fb --- /dev/null +++ b/docs/CALLBACK_ABI.md @@ -0,0 +1,79 @@ +# 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` 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. + +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()) } }