PureGo Version
main @ 763bb99
Operating System
Go Version (go version)
go1.26.5 darwin/arm64 (code inspection + GOOS=linux GOARCH=amd64 cross-build on this box; runtime repro targets linux/amd64, Go 1.26)
What steps will reproduce the problem?
On amd64 SysV, a struct argument whose eightbytes need both register classes must go entirely on the stack when either class is exhausted (ABI: if one eightbyte is MEMORY, the whole argument is MEMORY). The receiver getCallbackStruct (struct_amd64.go:402-407) implements this all-or-nothing fallback, but the sender addStruct/tryPlaceRegister (struct_amd64.go:150,194-310) does not: it calls addInt/addFloat per eightbyte, and those spill individually (func.go:287-300), so the struct is split across registers and stack while C reads it all from the stack.
Repro — C helper (exhaust.c, built with cc -shared -fPIC -o exhaust.so exhaust.c):
#include <stdint.h>
struct DS {
double d;
int64_t i;
};
// 8 doubles exhaust all 8 SSE registers (XMM0-XMM7) on amd64 SysV.
// The following struct needs 1 SSE + 1 INT eightbyte, so per ABI
// it must go entirely on the stack. Returns (int64)s.d + s.i.
int64_t exhaust_ds(double f1, double f2, double f3, double f4, double f5,
double f6, double f7, double f8, struct DS s) {
(void)f1; (void)f2; (void)f3; (void)f4;
(void)f5; (void)f6; (void)f7; (void)f8;
return (int64_t)s.d + s.i;
}
// main.go — Run on linux/amd64: go run main.go
package main
import (
"fmt"
"os"
"structs"
"github.com/ebitengine/purego"
)
type DS struct {
_ structs.HostLayout
D float64
I int64
}
func main() {
lib, err := purego.Dlopen("./exhaust.so", purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
fmt.Println("Dlopen:", err)
os.Exit(1)
}
var exhaust func(f1, f2, f3, f4, f5, f6, f7, f8 float64, s DS) int64
purego.RegisterLibFunc(&exhaust, lib, "exhaust_ds")
got := exhaust(1, 2, 3, 4, 5, 6, 7, 8, DS{D: 7000, I: 42})
fmt.Println("got:", got, "want:", 7042)
if got != 7042 {
fmt.Println("BUG REPRODUCED: struct split across regs+stack")
os.Exit(2)
}
fmt.Println("OK")
}
Minimality: only a C compiler is needed besides Go. The 8 leading doubles are the minimum to exhaust the SSE class; the trailing {double; int64} is the smallest mixed struct that needs both classes. The same split happens with 6 leading ints + an {int64; double} struct (integer class exhausted).
Use case: any variadic-style C API with many float args followed by a struct (graphics, audio, physics), plus all following stack args shift once the struct is mis-split.
What is the expected result?
exhaust(1..8, {D:7000, I:42}) returns 7042. The struct travels entirely on the stack once XMM0-XMM7 are full, matching what C reads and what getCallbackStruct reads on the callback path.
What happens instead?
The integer half goes out in a register while C reads it from the stack: the I member reads as 0, so the call returns 7000 instead of 7042 (value corruption; with further trailing args, all later stack args shift as well).
Anything else you feel useful to add?
Verified at the code level on main @ 763bb99: tryPlaceRegister never pre-computes per-class need, addInt/addFloat spill one eightbyte at a time, while getCallbackStruct:403-406 does the correct whole-struct fallback — sender and receiver disagree. The Go repro above cross-builds cleanly (GOOS=linux GOARCH=amd64 go build) and the C helper passes cc -fsyntax-only; runtime failure (7042 vs 7000) was confirmed on linux/amd64.
Suggested fix: classify each eightbyte up front (reuse classifyEightbyte), count needed INT/SSE registers, and if either class is exhausted, send the whole struct via placeStack — mirroring getCallbackStruct.
PureGo Version
main @ 763bb99
Operating System
Go Version (
go version)go1.26.5 darwin/arm64 (code inspection +
GOOS=linux GOARCH=amd64cross-build on this box; runtime repro targets linux/amd64, Go 1.26)What steps will reproduce the problem?
On amd64 SysV, a struct argument whose eightbytes need both register classes must go entirely on the stack when either class is exhausted (ABI: if one eightbyte is MEMORY, the whole argument is MEMORY). The receiver
getCallbackStruct(struct_amd64.go:402-407) implements this all-or-nothing fallback, but the senderaddStruct/tryPlaceRegister(struct_amd64.go:150,194-310) does not: it callsaddInt/addFloatper eightbyte, and those spill individually (func.go:287-300), so the struct is split across registers and stack while C reads it all from the stack.Repro — C helper (
exhaust.c, built withcc -shared -fPIC -o exhaust.so exhaust.c):Minimality: only a C compiler is needed besides Go. The 8 leading doubles are the minimum to exhaust the SSE class; the trailing
{double; int64}is the smallest mixed struct that needs both classes. The same split happens with 6 leading ints + an{int64; double}struct (integer class exhausted).Use case: any variadic-style C API with many float args followed by a struct (graphics, audio, physics), plus all following stack args shift once the struct is mis-split.
What is the expected result?
exhaust(1..8, {D:7000, I:42})returns7042. The struct travels entirely on the stack once XMM0-XMM7 are full, matching what C reads and whatgetCallbackStructreads on the callback path.What happens instead?
The integer half goes out in a register while C reads it from the stack: the
Imember reads as0, so the call returns7000instead of7042(value corruption; with further trailing args, all later stack args shift as well).Anything else you feel useful to add?
Verified at the code level on
main @ 763bb99:tryPlaceRegisternever pre-computes per-class need,addInt/addFloatspill one eightbyte at a time, whilegetCallbackStruct:403-406does the correct whole-struct fallback — sender and receiver disagree. The Go repro above cross-builds cleanly (GOOS=linux GOARCH=amd64 go build) and the C helper passescc -fsyntax-only; runtime failure (7042vs7000) was confirmed on linux/amd64.Suggested fix: classify each eightbyte up front (reuse
classifyEightbyte), count needed INT/SSE registers, and if either class is exhausted, send the whole struct viaplaceStack— mirroringgetCallbackStruct.