From f49be2a993223165e60095ad16d263d4190ec0e7 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Sat, 20 Jun 2026 14:56:46 +0900 Subject: [PATCH] purego: support structs on Android and iOS Enable passing and returning C structs by value on Android and iOS for amd64 and arm64, in both RegisterFunc calls and NewCallback callbacks. The struct-marshalling code already compiled into these builds and was only gated off by runtime.GOOS string checks. iOS shares the Apple ABI with macOS and Android shares the standard ABI with Linux, so route GOOS=ios through the Darwin path via a new isDarwin helper and treat GOOS=android like Linux. Open the ensureStructSupported gate, add ios and android to the getCallbackStruct callback switches, and extend the Darwin ARM64 stack-argument reads in the callback path to iOS. Add an Android emulator CI job (amd64 and arm64) that runs the test suite. Because the emulator has no C toolchain, the test libraries are cross-compiled on the host with the NDK and loaded via the new PUREGO_TEST_PREBUILT_LIBDIR instead of being compiled at run time. Closes #466 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 74 ++++++++++++++++++++++++++++++++++++++ README.md | 20 +++++------ func.go | 20 +++++++---- func_test.go | 20 ++++++++++- struct_amd64.go | 2 +- struct_arm64.go | 20 +++++------ syscall_unix.go | 4 +-- 7 files changed, 129 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b4ae47f..05128098 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -316,3 +316,77 @@ jobs: rm -fr /usr/local/go && tar -C /usr/local -xf go${{matrix.go}}.netbsd-amd64.tar.gz chmod +x $GITHUB_WORKSPACE/.github/scripts/bsd_tests.sh run: $GITHUB_WORKSPACE/.github/scripts/bsd_tests.sh + + # Android is tested on amd64 only. The emulator requires the system image + # architecture to match the host, and GitHub's arm64 hosted runners do not + # provide a usable KVM device, so there is no hosted hardware-accelerated path + # to an Android arm64 emulator. The arm64 struct ABI is already covered by the + # linux/arm64 `test` job, and Android's Bionic/cgo integration by this job. + android: + strategy: + fail-fast: false + matrix: + go: ['1.25.x', '1.26.x'] + name: Test with Go ${{ matrix.go }} on Android amd64 + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + + # The tests compile their C libraries at run time via `go env CC`, but the + # emulator has no C toolchain. Every library the tests load must therefore be + # cross-compiled here and loaded as a prebuilt library at run time instead. + - name: Cross-compile the test binary and C libraries + run: | + set -eux + NDK="$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + CC="$NDK/x86_64-linux-android21-clang" + mkdir -p prebuilt + # Build every library the tests load. Keep this in sync with the + # buildSharedLib callers in the tests; a missing entry surfaces as a + # "prebuilt lib ... no such file" failure on the emulator. + "$CC" -shared -Wall -Werror -fPIC -o prebuilt/structtest.so testdata/structtest/struct_test.c + "$CC" -shared -Wall -Werror -fPIC -o prebuilt/structreturntest.so testdata/structtest/structreturn_test.c + "$CC" -shared -Wall -Werror -fPIC -o prebuilt/libcbtest.so testdata/libcbtest/callback_test.c + "$CC" -shared -Wall -Werror -fPIC -o prebuilt/abitest.so testdata/abitest/abi_test.c + env GOOS=android GOARCH=amd64 CGO_ENABLED=1 CC="$CC" go test -c -o purego.test . + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run the test suite on the Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + script: | + set -eux + adb push purego.test /data/local/tmp/purego.test + adb push prebuilt /data/local/tmp/prebuilt + adb shell "chmod 755 /data/local/tmp/purego.test" + # TestSetuidEtc needs root and glibc (not Bionic) semantics; TestNestedDlopenCall + # needs the C++ runtime present on the device. Both are covered by the Linux + # runners, so skip them here rather than special-casing the emulator. + adb shell "cd /data/local/tmp && TMPDIR=/data/local/tmp PUREGO_TEST_PREBUILT_LIBDIR=/data/local/tmp/prebuilt ./purego.test -test.v -test.skip 'TestSetuidEtc|TestNestedDlopenCall'" | tee result.txt + grep -q '^PASS' result.txt + ! grep -q '^FAIL' result.txt + + # TODO: Add an iOS job that runs the test suite on the iOS Simulator. + # iOS has no emulator runner equivalent: on a macOS runner the test binary must + # be wrapped into a .app and launched via `xcrun simctl` (or x/mobile's + # go_ios_exec), with the C libraries cross-compiled against the iphonesimulator + # SDK and loaded through PUREGO_TEST_PREBUILT_LIBDIR (as the android job does). + # Deprioritized because the iOS arm64 ABI matches macOS arm64, which the `test` + # job already runs. diff --git a/README.md b/README.md index a3721ff9..d7e904d7 100644 --- a/README.md +++ b/README.md @@ -30,27 +30,27 @@ 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,2, arm641,2 -- **iOS**: amd641,2, arm641,2 +- **Android**: amd641, arm641 +- **iOS**: amd641, arm641 - **Linux**: amd64, arm64 - **macOS**: amd64, arm64 -- **Windows**: amd643, arm643 +- **Windows**: amd642, arm642 ### 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,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 +- **Android**: 3861,3, arm1,3 +- **FreeBSD**: amd643,4, arm643,4 +- **Linux**: 3863, arm3, loong643, ppc64le3, riscv643, s390x1,3 +- **NetBSD**: amd643,4, arm643,4 +- **Windows**: 3863,5, arm3,5,6 #### Support Notes 1. These architectures require CGO_ENABLED=1 to compile -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` +2. These architectures support passing structs by value as arguments and return values when calling C functions, but not in callbacks created with `NewCallback` +3. These architectures do not support passing structs by value as arguments or return values 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 diff --git a/func.go b/func.go index 889be836..dc6863cd 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, windows amd64/arm64) +// struct <=> struct (android, darwin, ios, linux, and windows on amd64/arm64) // func <=> C function // unsafe.Pointer, *T <=> void* // []T => void* @@ -99,8 +99,8 @@ func RegisterLibFunc(fptr any, handle uintptr, name string) { // it does not support aligning fields properly. It is therefore the responsibility of the caller to ensure // that all padding is added to the Go struct to match the C one. See `BoolStructFn` in struct_test.go for an example. // -// 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 Apple ARM64 platforms (macOS and iOS), 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. @@ -213,7 +213,7 @@ func RegisterFunc(fptr any, cfn uintptr) { if ints+floats+stack > argsLimit { panic("purego: too many stack arguments") } - } else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + } else if isDarwin && runtime.GOARCH == "arm64" { // On Darwin ARM64, use byte-based validation since arguments pack efficiently. // See https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms stackBytes := estimateStackBytes(ty) @@ -313,7 +313,7 @@ func RegisterFunc(fptr any, cfn uintptr) { continue } // Check if we need to start Darwin ARM64 C-style stack packing - if runtime.GOARCH == "arm64" && runtime.GOOS == "darwin" && shouldBundleStackArgs(v, numInts, numFloats) { + if runtime.GOARCH == "arm64" && isDarwin && shouldBundleStackArgs(v, numInts, numFloats) { // Collect and separate remaining args into register vs stack stackArgs, newKeepAlive := collectStackArgs(args, i, numInts, numFloats, keepAlive, addInt, addFloat, addStack, &numInts, &numFloats, &numStack) @@ -505,11 +505,17 @@ 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" && runtime.GOOS != "windows" { - panic("purego: struct arguments/returns are only supported on darwin, linux, and windows") + switch runtime.GOOS { + case "android", "darwin", "ios", "linux", "windows": + default: + panic("purego: struct arguments/returns are only supported on android, darwin, ios, linux, and windows") } } +// isDarwin is true on platforms that use Apple's calling convention. +// iOS (GOOS=ios) shares it with macOS (GOOS=darwin). +const isDarwin = runtime.GOOS == "darwin" || runtime.GOOS == "ios" + // 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. diff --git a/func_test.go b/func_test.go index d87eac8a..cf53b88e 100644 --- a/func_test.go +++ b/func_test.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "fmt" + "os" "os/exec" "path/filepath" "runtime" @@ -21,7 +22,9 @@ import ( func getSystemLibrary() (string, error) { switch runtime.GOOS { - case "darwin": + case "android": + return "libc.so", nil + case "darwin", "ios": return "/usr/lib/libSystem.B.dylib", nil case "freebsd": return "libc.so.7", nil @@ -601,6 +604,21 @@ func TestABI_TooManyArguments(t *testing.T) { } func buildSharedLib(compilerEnv, libFile string, sources ...string) error { + // When PUREGO_TEST_PREBUILT_LIBDIR is set, the shared library has been + // cross-compiled ahead of time and placed in that directory under the + // base name of libFile. This allows running the tests on a target that + // has no C toolchain, such as an Android emulator. + if dir := os.Getenv("PUREGO_TEST_PREBUILT_LIBDIR"); dir != "" { + data, err := os.ReadFile(filepath.Join(dir, filepath.Base(libFile))) + if err != nil { + return fmt.Errorf("prebuilt lib: %w", err) + } + if err := os.WriteFile(libFile, data, 0o755); err != nil { + return fmt.Errorf("prebuilt lib: %w", err) + } + return nil + } + out, err := exec.Command("go", "env", compilerEnv).Output() if err != nil { return fmt.Errorf("go env %s error: %w", compilerEnv, err) diff --git a/struct_amd64.go b/struct_amd64.go index da7044c0..ab2e2a01 100644 --- a/struct_amd64.go +++ b/struct_amd64.go @@ -339,7 +339,7 @@ func bundleStackArgs(stackArgs []reflect.Value, addStack func(uintptr)) { // - If not enough registers for all eightbytes: entire struct goes on the stack func getCallbackStruct(inType reflect.Type, frame unsafe.Pointer, floatsN *int, intsN *int, stackSlot *int, stackByteOffset *uintptr) reflect.Value { switch runtime.GOOS { - case "darwin", "freebsd", "linux", "netbsd": + case "android", "darwin", "freebsd", "ios", "linux", "netbsd": default: panic("purego: getCallbackStruct is not supported on " + runtime.GOOS) } diff --git a/struct_arm64.go b/struct_arm64.go index 285c756b..bd54984a 100644 --- a/struct_arm64.go +++ b/struct_arm64.go @@ -92,7 +92,7 @@ func addStruct(v reflect.Value, numInts, numFloats, numStack *int, addInt, addFl } func placeRegisters(v reflect.Value, addFloat func(uintptr), addInt func(uintptr)) { - if runtime.GOOS == "darwin" { + if isDarwin { placeRegistersDarwin(v, addFloat, addInt) return } @@ -301,7 +301,7 @@ func isHVA(t reflect.Type) bool { // copyStruct8ByteChunks copies struct memory in 8-byte chunks to the provided callback. // This is used for Darwin ARM64's byte-level packing of non-HFA/HVA structs. func copyStruct8ByteChunks(ptr unsafe.Pointer, size uintptr, addChunk func(uintptr)) { - if runtime.GOOS != "darwin" { + if !isDarwin { panic("purego: should only be called on darwin") } for offset := uintptr(0); offset < size; offset += 8 { @@ -328,7 +328,7 @@ func copyStruct8ByteChunks(ptr unsafe.Pointer, size uintptr, addChunk func(uintp // For non-HFA/HVA structs, Darwin uses byte-level packing. We copy the struct memory in // 8-byte chunks, which works correctly for both register and stack placement. func placeRegistersDarwin(v reflect.Value, addFloat func(uintptr), addInt func(uintptr)) { - if runtime.GOOS != "darwin" { + if !isDarwin { panic("purego: placeRegistersDarwin should only be called on darwin") } // Check if this is an HFA/HVA @@ -356,7 +356,7 @@ func placeRegistersDarwin(v reflect.Value, addFloat func(uintptr), addInt func(u // shouldBundleStackArgs determines if we need to start C-style packing for // Darwin ARM64 stack arguments. This happens when registers are exhausted. func shouldBundleStackArgs(v reflect.Value, numInts, numFloats int) bool { - if runtime.GOOS != "darwin" { + if !isDarwin { return false } @@ -398,7 +398,7 @@ func shouldBundleStackArgs(v reflect.Value, numInts, numFloats int) bool { // registers, used during stack argument bundling to decide if a struct // should go through normal register allocation or be bundled with stack args. func structFitsInRegisters(val reflect.Value, tempNumInts, tempNumFloats int) (bool, int, int) { - if runtime.GOOS != "darwin" { + if !isDarwin { panic("purego: structFitsInRegisters should only be called on darwin") } hfa := isHFA(val.Type()) @@ -431,7 +431,7 @@ func structFitsInRegisters(val reflect.Value, tempNumInts, tempNumFloats int) (b func collectStackArgs(args []reflect.Value, startIdx int, numInts, numFloats int, keepAlive []any, addInt, addFloat, addStack func(uintptr), pNumInts, pNumFloats, pNumStack *int) ([]reflect.Value, []any) { - if runtime.GOOS != "darwin" { + if !isDarwin { panic("purego: collectStackArgs should only be called on darwin") } @@ -488,7 +488,7 @@ const ( // bundleStackArgs bundles remaining arguments for Darwin ARM64 C-style stack packing. // It creates a packed struct with proper alignment and copies it to the stack in 8-byte chunks. func bundleStackArgs(stackArgs []reflect.Value, addStack func(uintptr)) { - if runtime.GOOS != "darwin" { + if !isDarwin { panic("purego: bundleStackArgs should only be called on darwin") } if len(stackArgs) == 0 { @@ -562,7 +562,7 @@ func bundleStackArgs(stackArgs []reflect.Value, addStack func(uintptr)) { // - Darwin ARM64: byte-level packing on the stack func getCallbackStruct(inType reflect.Type, frame unsafe.Pointer, floatsN *int, intsN *int, stackSlot *int, stackByteOffset *uintptr) reflect.Value { switch runtime.GOOS { - case "darwin", "freebsd", "linux", "netbsd": + case "android", "darwin", "freebsd", "ios", "linux", "netbsd": default: panic("purego: getCallbackStruct is not supported on " + runtime.GOOS) } @@ -609,7 +609,7 @@ func getCallbackStruct(inType reflect.Type, frame unsafe.Pointer, floatsN *int, } // Pointer on stack (rare: all integer registers exhausted). - if runtime.GOOS == "darwin" { + if isDarwin { ptrVal := callbackArgFromStack(frame, *stackSlot, stackByteOffset, reflect.TypeOf(uintptr(0))) ptr := uintptr(ptrVal.Uint()) return reflect.NewAt(inType, *(*unsafe.Pointer)(unsafe.Pointer(&ptr))).Elem() @@ -690,7 +690,7 @@ func readHFAFromRegisters(inType reflect.Type, f *[callbackMaxFrame]uintptr, flo // callback frame. On Darwin ARM64, arguments are byte-packed on the stack. // On Linux ARM64, arguments are 8-byte aligned. func readStructFromStackArm64(inType reflect.Type, f *[callbackMaxFrame]uintptr, frame unsafe.Pointer, stackSlot *int, stackByteOffset *uintptr) reflect.Value { - if runtime.GOOS == "darwin" { + if isDarwin { return callbackArgFromStack(frame, *stackSlot, stackByteOffset, inType) } // Linux ARM64: 8-byte aligned slots. diff --git a/syscall_unix.go b/syscall_unix.go index 87030f8c..3ec4cb92 100644 --- a/syscall_unix.go +++ b/syscall_unix.go @@ -164,7 +164,7 @@ func callbackWrap(a *callbackArgs) { case reflect.Float32, reflect.Float64: slots = int((fnType.In(i).Size() + ptrSize - 1) / ptrSize) if floatsN+slots > numOfFloatRegisters() { - if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + if isDarwin && runtime.GOARCH == "arm64" { // Darwin ARM64: read from packed stack with proper alignment args[i] = callbackArgFromStack(a.args, stackSlot, &stackByteOffset, inType) } else if stackFrame != nil { @@ -203,7 +203,7 @@ func callbackWrap(a *callbackArgs) { default: slots = int((inType.Size() + ptrSize - 1) / ptrSize) if intsN+slots > numOfIntegerRegisters() { - if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + if isDarwin && runtime.GOARCH == "arm64" { // Darwin ARM64: read from packed stack with proper alignment args[i] = callbackArgFromStack(a.args, stackSlot, &stackByteOffset, inType) } else if stackFrame != nil {