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 {