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 {