From fd6436ba2d374a995a98e2f9144959a6524abb83 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Wed, 17 Jun 2026 02:31:23 +0900 Subject: [PATCH] purego: support structs on Windows amd64/arm64 Enable passing and returning C structs by value on Windows for forward calls (Go calling C). On amd64 the Win64 ABI is used: aggregates of exactly 1, 2, 4, or 8 bytes are passed by value in a single integer slot and returned in RAX, while all other sizes are passed and returned through a caller-allocated pointer. This maps cleanly onto the positional syscall.SyscallN path and needs no float-register handling, because Win64 never passes struct fields in XMM registers. Unlike the System V ABI, an empty struct argument still consumes a slot on Win64. On arm64 the existing AAPCS64 path already runs on Windows via the shared assembly, so only the support gate is opened. Passing or returning structs in callbacks created with NewCallback is not supported on Windows, because they go through the standard library syscall.NewCallback, which cannot carry struct values. The struct test C library used `long` for values that must be 64-bit, which is only 32-bit on Windows (LLP64) and overflowed the 64-bit shift expressions and shrank struct sizes. Replace it with width-correct stdint types: int64_t/uint64_t for Go's fixed-width int64/uint64, and intptr_t/uintptr_t for Go's word-sized int/uint, so the library is correct under both LP64 and LLP64. Closes #237 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 ++++++----- func.go | 44 +++++++++++++++---- struct_amd64.go | 40 +++++++++++++++++ struct_test.go | 47 +++++++++++++++----- testdata/structtest/struct_test.c | 71 ++++++++++++++----------------- 5 files changed, 158 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d431def1..a3721ff9 100644 --- a/README.md +++ b/README.md @@ -30,28 +30,30 @@ except for float arguments and return values. Tier 1 platforms are the primary targets officially supported by PureGo. When a new version of PureGo is released, any critical bugs found on Tier 1 platforms are treated as release blockers. The release will be postponed until such issues are resolved. -- **Android**: amd641, arm641 -- **iOS**: amd641, arm641 +- **Android**: amd641,2, arm641,2 +- **iOS**: amd641,2, arm641,2 - **Linux**: amd64, arm64 - **macOS**: amd64, arm64 -- **Windows**: amd64, arm64 +- **Windows**: amd643, arm643 ### Tier 2 Tier 2 platforms are supported by PureGo on a best-effort basis. Critical bugs on Tier 2 platforms do not block new PureGo releases. However, fixes contributed by external contributors are very welcome and encouraged. -- **Android**: 3861, arm1 -- **FreeBSD**: amd642, arm642 -- **Linux**: 386, arm, loong64, ppc64le, riscv64, s390x1 -- **NetBSD**: amd642, arm642 -- **Windows**: 3863, arm3,4 +- **Android**: 3861,2, arm1,2 +- **FreeBSD**: amd642,4, arm642,4 +- **Linux**: 3862, arm2, loong642, ppc64le2, riscv642, s390x1,2 +- **NetBSD**: amd642,4, arm642,4 +- **Windows**: 3862,5, arm2,5,6 #### Support Notes 1. These architectures require CGO_ENABLED=1 to compile -2. These architectures require the special flag `-gcflags="github.com/ebitengine/purego/internal/fakecgo=-std"` to compile with CGO_ENABLED=0 -3. These architectures only support `SyscallN` and `NewCallback` -4. These architectures are no longer supported as of Go 1.26 +2. These architectures do not support passing structs by value as arguments or return values +3. These architectures support passing structs by value as arguments and return values when calling C functions, but not in callbacks created with `NewCallback` +4. These architectures require the special flag `-gcflags="github.com/ebitengine/purego/internal/fakecgo=-std"` to compile with CGO_ENABLED=0 +5. These architectures only support `SyscallN` and `NewCallback` +6. These architectures are no longer supported as of Go 1.26 ## Example diff --git a/func.go b/func.go index 11df76dd..889be836 100644 --- a/func.go +++ b/func.go @@ -64,7 +64,7 @@ func RegisterLibFunc(fptr any, handle uintptr, name string) { // int64 <=> int64_t // float32 <=> float // float64 <=> double -// struct <=> struct (darwin amd64/arm64, linux amd64/arm64) +// struct <=> struct (darwin amd64/arm64, linux amd64/arm64, windows amd64/arm64) // func <=> C function // unsafe.Pointer, *T <=> void* // []T => void* @@ -102,6 +102,9 @@ func RegisterLibFunc(fptr any, handle uintptr, name string) { // On Darwin ARM64, purego handles proper alignment of struct arguments when passing them on the stack, // following the C ABI's byte-level packing rules. // +// On Windows, struct arguments and returns are supported on amd64 and arm64 when calling C functions. +// Passing or returning structs in callbacks created with [NewCallback] is not supported on Windows. +// // # Example // // All functions below call this C function: @@ -175,7 +178,8 @@ func RegisterFunc(fptr any, cfn uintptr) { } case reflect.Struct: ensureStructSupported() - if arg.Size() == 0 { + if arg.Size() == 0 && runtime.GOOS != "windows" { + // On Windows an empty struct still consumes one argument slot. continue } addInt := func(u uintptr) { @@ -196,9 +200,9 @@ func RegisterFunc(fptr any, cfn uintptr) { ensureStructSupported() outType := ty.Out(0) checkStructFieldsSupported(outType) - if runtime.GOARCH == "amd64" && outType.Size() > maxRegAllocStructSize { - // on amd64 if struct is bigger than 16 bytes allocate the return struct - // and pass it in as a hidden first argument. + if runtime.GOARCH == "amd64" && amd64StructReturnInMemory(outType.Size()) { + // on amd64 a struct returned in memory is allocated by the caller + // and its pointer is passed as a hidden first argument. ints++ } } @@ -283,7 +287,9 @@ func RegisterFunc(fptr any, cfn uintptr) { var arm64_r8 uintptr if ty.NumOut() == 1 && ty.Out(0).Kind() == reflect.Struct { outType := ty.Out(0) - if (runtime.GOARCH == "amd64" || runtime.GOARCH == "loong64" || runtime.GOARCH == "ppc64le" || runtime.GOARCH == "riscv64" || runtime.GOARCH == "s390x") && outType.Size() > maxRegAllocStructSize { + amd64InMemory := runtime.GOARCH == "amd64" && amd64StructReturnInMemory(outType.Size()) + otherInMemory := (runtime.GOARCH == "loong64" || runtime.GOARCH == "ppc64le" || runtime.GOARCH == "riscv64" || runtime.GOARCH == "s390x") && outType.Size() > maxRegAllocStructSize + if amd64InMemory || otherInMemory { val := reflect.New(outType) keepAlive = append(keepAlive, val) addInt(val.Pointer()) @@ -499,9 +505,31 @@ func ensureStructSupported() { if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { panic("purego: struct arguments/returns are only supported on amd64 and arm64") } - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - panic("purego: struct arguments/returns are only supported on darwin and linux") + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" && runtime.GOOS != "windows" { + panic("purego: struct arguments/returns are only supported on darwin, linux, and windows") + } +} + +// amd64StructReturnInMemory reports whether a struct return value of the given +// size is returned through a caller-allocated hidden pointer (true) rather than +// in registers (false). It must only be consulted on amd64. +func amd64StructReturnInMemory(size uintptr) bool { + if size == 0 { + return false + } + if runtime.GOOS == "windows" { + // The Win64 ABI returns aggregates of exactly 1, 2, 4, or 8 bytes in + // RAX. Every other size is returned through a caller-allocated hidden + // pointer that the callee also returns in RAX. + switch size { + case 1, 2, 4, 8: + return false + default: + return true + } } + // The System V ABI returns aggregates of up to two eightbytes in registers. + return size > maxRegAllocStructSize } func roundUpTo8(val uintptr) uintptr { diff --git a/struct_amd64.go b/struct_amd64.go index 40ed91c0..da7044c0 100644 --- a/struct_amd64.go +++ b/struct_amd64.go @@ -12,6 +12,19 @@ import ( func getStruct(outType reflect.Type, syscall syscallArgs) (v reflect.Value) { outSize := outType.Size() + if runtime.GOOS == "windows" { + switch { + case outSize == 0: + return reflect.New(outType).Elem() + case amd64StructReturnInMemory(outSize): + // Returned through the caller-allocated hidden pointer, which the + // callee also returns in RAX. + return reflect.NewAt(outType, *(*unsafe.Pointer)(unsafe.Pointer(&syscall.a1))).Elem() + default: + // 1, 2, 4, or 8 byte aggregates are returned in RAX. + return reflect.NewAt(outType, unsafe.Pointer(&struct{ a uintptr }{syscall.a1})).Elem() + } + } switch { case outSize == 0: return reflect.New(outType).Elem() @@ -87,6 +100,12 @@ const ( ) func addStruct(v reflect.Value, numInts, numFloats, numStack *int, addInt, addFloat, addStack func(uintptr), keepAlive []any) []any { + if runtime.GOOS == "windows" { + // Win64 still passes an empty struct as an argument slot, so this must + // run before the zero-size early return used by the System V path. + return addStructWindows(v, addInt, keepAlive) + } + if v.Type().Size() == 0 { return keepAlive } @@ -112,6 +131,27 @@ func addStruct(v reflect.Value, numInts, numFloats, numStack *int, addInt, addFl return keepAlive } +// addStructWindows passes a struct argument under the Win64 ABI. Aggregates of +// exactly 1, 2, 4, or 8 bytes are passed by value in a single integer slot; all +// other sizes are passed as a pointer to a caller-allocated copy. Empty structs +// fall in the latter group: unlike the System V ABI, Win64 still consumes an +// argument slot for them. +func addStructWindows(v reflect.Value, addInt func(uintptr), keepAlive []any) []any { + switch v.Type().Size() { + case 1, 2, 4, 8: + var val uintptr + reflect.NewAt(v.Type(), unsafe.Pointer(&val)).Elem().Set(v) + addInt(val) + default: + ptrStruct := reflect.New(v.Type()) + ptrStruct.Elem().Set(v) + ptr := ptrStruct.Elem().Addr().UnsafePointer() + keepAlive = append(keepAlive, ptr) + addInt(uintptr(ptr)) + } + return keepAlive +} + func postMerger(t reflect.Type) (passInMemory bool) { // (c) If the size of the aggregate exceeds two eightbytes and the first eight- byte isn’t SSE or any other // eightbyte isn’t SSEUP, the whole argument is passed in memory. diff --git a/struct_test.go b/struct_test.go index 97428e2f..1cd30d39 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2024 The Ebitengine Authors -//go:build (darwin || linux) && (amd64 || arm64) +//go:build (darwin || linux || windows) && (amd64 || arm64) package purego_test @@ -15,6 +15,7 @@ import ( "unsafe" "github.com/ebitengine/purego" + "github.com/ebitengine/purego/internal/load" ) func TestRegisterFunc_structArgs(t *testing.T) { @@ -26,10 +27,15 @@ func TestRegisterFunc_structArgs(t *testing.T) { } defer os.Remove(libFileName) - lib, err := purego.Dlopen(libFileName, purego.RTLD_NOW|purego.RTLD_GLOBAL) + lib, err := load.OpenLibrary(libFileName) if err != nil { - t.Fatalf("Dlopen(%q) failed: %v", libFileName, err) + t.Fatalf("OpenLibrary(%q) failed: %v", libFileName, err) } + defer func() { + if err := load.CloseLibrary(lib); err != nil { + t.Fatalf("failed to close library: %v", err) + } + }() const ( expectedUnsigned = 0xdeadbeef @@ -41,8 +47,9 @@ func TestRegisterFunc_structArgs(t *testing.T) { ) implementations := []struct { - name string - register func(fptr any, handle uintptr, name string, goFn any) + name string + usesCallbacks bool + register func(fptr any, handle uintptr, name string, goFn any) }{ { name: "RegisterLibFunc", @@ -51,7 +58,8 @@ func TestRegisterFunc_structArgs(t *testing.T) { }, }, { - name: "GoCallbackFunc", + name: "GoCallbackFunc", + usesCallbacks: true, register: func(fptr any, handle uintptr, name string, goFn any) { fnType := reflect.TypeOf(fptr).Elem() if fnType.NumOut() > 0 { @@ -69,6 +77,11 @@ func TestRegisterFunc_structArgs(t *testing.T) { } for _, imp := range implementations { imp := imp + if imp.usesCallbacks && runtime.GOOS == "windows" { + // Callbacks on Windows use the stdlib syscall.NewCallback, which does + // not support struct arguments or returns. + continue + } t.Run(imp.name, func(t *testing.T) { register := imp.register { @@ -865,20 +878,27 @@ func TestRegisterFunc_structReturns(t *testing.T) { } defer os.Remove(libFileName) - lib, err := purego.Dlopen(libFileName, purego.RTLD_NOW|purego.RTLD_GLOBAL) + lib, err := load.OpenLibrary(libFileName) if err != nil { - t.Fatalf("Dlopen(%q) failed: %v", libFileName, err) + t.Fatalf("OpenLibrary(%q) failed: %v", libFileName, err) } + defer func() { + if err := load.CloseLibrary(lib); err != nil { + t.Fatalf("failed to close library: %v", err) + } + }() implementations := []struct { - name string - register func(fptr any, handle uintptr, name string) + name string + usesCallbacks bool + register func(fptr any, handle uintptr, name string) }{ { name: "RegisterLibFunc", register: purego.RegisterLibFunc, }, { - name: "GoCallbackFunc", + name: "GoCallbackFunc", + usesCallbacks: true, register: func(fptr any, _ uintptr, _ string) { fn := reflect.MakeFunc(reflect.TypeOf(fptr).Elem(), func(args []reflect.Value) []reflect.Value { retType := reflect.TypeOf(fptr).Elem().Out(0) @@ -899,6 +919,11 @@ func TestRegisterFunc_structReturns(t *testing.T) { } for _, imp := range implementations { imp := imp + if imp.usesCallbacks && runtime.GOOS == "windows" { + // Callbacks on Windows use the stdlib syscall.NewCallback, which does + // not support struct arguments or returns. + continue + } t.Run(imp.name, func(t *testing.T) { register := imp.register { diff --git a/testdata/structtest/struct_test.c b/testdata/structtest/struct_test.c index c6be0a69..b513b816 100644 --- a/testdata/structtest/struct_test.c +++ b/testdata/structtest/struct_test.c @@ -5,16 +5,11 @@ #include #include -#if defined(__x86_64__) || defined(__aarch64__) -typedef int64_t GoInt; -typedef uint64_t GoUint; -#endif - // Empty is empty struct Empty {}; // NoStruct tests that an empty struct doesn't cause issues -unsigned long NoStruct(struct Empty e) { +uint64_t NoStruct(struct Empty e) { return 0xdeadbeef; } @@ -22,28 +17,28 @@ struct EmptyEmpty { struct Empty x; }; -unsigned long EmptyEmpty(struct EmptyEmpty e) { +uint64_t EmptyEmpty(struct EmptyEmpty e) { return 0xdeadbeef; } -unsigned long EmptyEmptyWithReg(unsigned int x, struct EmptyEmpty e, unsigned int y) { +uint64_t EmptyEmptyWithReg(unsigned int x, struct EmptyEmpty e, unsigned int y) { return (x << 16) | y; } // GreaterThan16Bytes is 24 bytes on 64 bit systems struct GreaterThan16Bytes { - long *x, *y, *z; + int64_t *x, *y, *z; }; // GreaterThan16Bytes is a basic test for structs bigger than 16 bytes -unsigned long GreaterThan16Bytes(struct GreaterThan16Bytes g) { +uint64_t GreaterThan16Bytes(struct GreaterThan16Bytes g) { return *g.x + *g.y + *g.z; } // AfterRegisters tests to make sure that structs placed on the stack work properly -unsigned long AfterRegisters(long a, long b, long c, long d, long e, long f, long g, long h, struct GreaterThan16Bytes bytes) { - long registers = a + b + c + d + e + f + g + h; - long stack = *bytes.x + *bytes.y + *bytes.z; +uint64_t AfterRegisters(intptr_t a, intptr_t b, intptr_t c, intptr_t d, intptr_t e, intptr_t f, intptr_t g, intptr_t h, struct GreaterThan16Bytes bytes) { + intptr_t registers = a + b + c + d + e + f + g + h; + int64_t stack = *bytes.x + *bytes.y + *bytes.z; if (registers != stack) { return 0xbadbad; } @@ -53,25 +48,25 @@ unsigned long AfterRegisters(long a, long b, long c, long d, long e, long f, lon return stack; } -unsigned long BeforeRegisters(struct GreaterThan16Bytes bytes, long a, long b) { +uint64_t BeforeRegisters(struct GreaterThan16Bytes bytes, int64_t a, int64_t b) { return *bytes.x + *bytes.y + *bytes.z + a + b; } struct GreaterThan16BytesStruct { struct { - long *x, *y, *z; + int64_t *x, *y, *z; } a ; }; -unsigned long GreaterThan16BytesStruct(struct GreaterThan16BytesStruct g) { +uint64_t GreaterThan16BytesStruct(struct GreaterThan16BytesStruct g) { return *(g.a.x) + *(g.a.y) + *(g.a.z); } struct IntLessThan16Bytes { - long x, y; + int64_t x, y; }; -unsigned long IntLessThan16Bytes(struct IntLessThan16Bytes l) { +uint64_t IntLessThan16Bytes(struct IntLessThan16Bytes l) { return l.x + l.y; } @@ -201,23 +196,23 @@ struct Short { unsigned short a, b, c, d; }; -unsigned long Short(struct Short s) { - return (long)s.a << 48 | (long)s.b << 32 | (long)s.c << 16 | (long)s.d << 0; +uint64_t Short(struct Short s) { + return (uint64_t)s.a << 48 | (uint64_t)s.b << 32 | (uint64_t)s.c << 16 | (uint64_t)s.d << 0; } struct Int { unsigned int a, b; }; -unsigned long Int(struct Int i) { - return (long)i.a << 32 | (long)i.b << 0; +uint64_t Int(struct Int i) { + return (uint64_t)i.a << 32 | (uint64_t)i.b << 0; } struct Long { - unsigned long a; + uint64_t a; }; -unsigned long Long(struct Long l) { +uint64_t Long(struct Long l) { return l.a; } @@ -327,31 +322,31 @@ struct Content { struct { double width, height; } size; }; -unsigned long InitWithContentRect(int *win, struct Content c, int style, int backing, _Bool flag) { +uint64_t InitWithContentRect(int *win, struct Content c, int style, int backing, _Bool flag) { if (win == 0) return 0xBAD; if (!flag) return 0xF1A6; // FLAG - return (unsigned long)(c.point.x + c.point.y + c.size.width + c.size.height) / (style - backing); + return (uint64_t)(c.point.x + c.point.y + c.size.width + c.size.height) / (style - backing); } struct GoInt4 { - GoInt a, b, c, d; + intptr_t a, b, c, d; }; -GoInt GoInt4(struct GoInt4 g) { +intptr_t GoInt4(struct GoInt4 g) { return g.a + g.b - g.c + g.d; } struct GoUint4 { - GoUint a, b, c, d; + uintptr_t a, b, c, d; }; -GoUint GoUint4(struct GoUint4 g) { +uintptr_t GoUint4(struct GoUint4 g) { return g.a + g.b + g.c + g.d; } -GoUint TakeGoUintAndReturn(GoUint a) { +uintptr_t TakeGoUintAndReturn(uintptr_t a) { return a; } @@ -395,7 +390,7 @@ uintptr_t AddPointers(struct TwoPointers wrapper) { // Identity functions for round-trip testing of struct arguments struct OneInt64 { - long long a; + int64_t a; }; struct OneInt64 IdentityOneInt64(struct OneInt64 s) { @@ -423,7 +418,7 @@ struct FloatAndInt IdentityFloatAndInt(struct FloatAndInt s) { } struct ThreeInt64 { - long long a, b, c; + int64_t a, b, c; }; struct ThreeInt64 IdentityThreeInt64(struct ThreeInt64 s) { @@ -431,16 +426,16 @@ struct ThreeInt64 IdentityThreeInt64(struct ThreeInt64 s) { } struct PtrInt64Ptr { - long long *a; - long long b; - long long *c; + int64_t *a; + int64_t b; + int64_t *c; }; struct PtrInt64Ptr IdentityPtrInt64Ptr(struct PtrInt64Ptr s) { return s; } -struct IntLessThan16Bytes IdentityTwoInt64AfterPrims(long long x, double y, struct IntLessThan16Bytes s) { +struct IntLessThan16Bytes IdentityTwoInt64AfterPrims(int64_t x, double y, struct IntLessThan16Bytes s) { return s; } @@ -449,7 +444,7 @@ struct FloatLessThan16Bytes IdentityTwoFloat32AfterFloats(double x, double y, st } struct Mixed5Args { - long long *a; + int64_t *a; int32_t b; float c; int32_t d;