Skip to content

Commit d05021a

Browse files
committed
feat: support Darwin on arm64
Duplicate the text segment into a new mapping allocated with MAP_JIT and force the program to execute from there so that we can modify the code.
1 parent 05985f3 commit d05021a

27 files changed

Lines changed: 1330 additions & 163 deletions

‎.github/workflows/test-darwin-arm64.yaml‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

‎.github/workflows/test.yaml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ jobs:
1414
strategy:
1515
matrix:
1616
go: ['1.25', '1.26']
17-
host: [ubuntu-latest, macos-15-intel, windows-latest, ubuntu-24.04-arm]
17+
host:
18+
- ubuntu-latest
19+
- macos-15-intel
20+
- windows-latest
21+
- ubuntu-24.04-arm
22+
- macos-latest
1823

1924
steps:
2025
- name: Checkout code

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Go Reference](https://pkg.go.dev/badge/github.com/pboyd/redefine.svg)](https://pkg.go.dev/github.com/pboyd/redefine)
44

5-
Highly experimental package to redefine Go functions at runtime as some interpreted languages allow (Ruby, Perl, etc.). I wrote about how this works and some of the limitations [here](https://pboyd.io/posts/redefining-go-functions/). This is a fun experiment, but do not use it for production code.
5+
Highly experimental package to redefine Go functions at runtime as some interpreted languages allow (Ruby, Perl, etc.). I wrote about how this works and some of the limitations [here](https://pboyd.io/posts/redefining-go-functions/), and about Darwin / Mac OS support in particular [here](https://pboyd.io/posts/redefining-go-functions-on-darwin-arm64/). This is a fun experiment, but do not use it for production code.
66

77
```go
88
package main
@@ -38,10 +38,10 @@ It's 5:00 PM somewhere
3838
| Darwin (macOS) | amd64 | Full | |
3939
| Linux | arm64 | Full | |
4040
| Windows | arm64 | Full | |
41+
| Darwin (macOS) | arm64 | Full | |
4142
| FreeBSD | amd64 | Untested | Compiles but untested |
4243
| OpenBSD | amd64 | Untested | Compiles but untested |
4344
| NetBSD | amd64 | Untested | Compiles but untested |
44-
| Darwin (macOS) | arm64 | Broken | `mprotect` returns EACCES |
4545

4646
## FAQ
4747

‎clone.go‎

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"unsafe"
1212

1313
"github.com/pboyd/malloc"
14+
"github.com/pboyd/redefine/internal/cacheflush"
15+
"github.com/pboyd/redefine/internal/static"
1416
)
1517

1618
var errAddressOutOfRange = errors.New("address out of range")
@@ -23,7 +25,7 @@ func cloneFunc[T any](fn T) (*clonedFunc[T], error) {
2325
return nil, fmt.Errorf("not a function, kind: %v", fnv.Kind())
2426
}
2527

26-
originalCode, err := funcSlice(fn)
28+
originalCode, err := static.GetInfo().FuncSlice(fn)
2729
if err != nil {
2830
return nil, err
2931
}
@@ -64,7 +66,7 @@ func cloneFunc[T any](fn T) (*clonedFunc[T], error) {
6466
return nil, errors.New("failed to allocate memory for cloned function")
6567
}
6668

67-
cacheflush(newCode)
69+
cacheflush.Flush(newCode)
6870

6971
// This seems too complicated. The idea is to take our newly allocated
7072
// buffer of machine instructions and convince Go that it's really a
@@ -128,16 +130,9 @@ func (a *allocator) init(startSize int) error {
128130
const absMinAddress = 0x100000
129131

130132
func initMallocBackend() (malloc.ArenaBackend, error) {
131-
var text, etext uintptr
132-
var end uintptr
133-
pc, _, _, _ := runtime.Caller(0)
134-
datap := findfunc(pc).datap
135-
if datap != nil {
136-
text = datap.text
137-
etext = datap.etext
138-
end = datap.end
139-
}
140-
if text == 0 || etext == 0 || end == 0 {
133+
info := static.GetInfo()
134+
text, etext := info.Text()
135+
if text == 0 || etext == 0 || info.End == 0 {
141136
return nil, fmt.Errorf("failed to find moduledata")
142137
}
143138

@@ -149,7 +144,7 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
149144
//
150145
// Use the size of the existing text segment so there's enough space to
151146
// clone every statically-linked function.
152-
size := (etext - text + pageSize - 1) &^ (pageSize - 1)
147+
size := etext - text
153148

154149
// Cloned functions need to be near the existing text and data
155150
// segments so that they can be reached by the same
@@ -163,8 +158,8 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
163158
// If there's an ideal range for the architecture, try that first.
164159
if idealCloneDistance > 0 {
165160
// Search before text
166-
minAddress := end - idealCloneDistance
167-
if minAddress > end || minAddress < absMinAddress {
161+
minAddress := info.End - idealCloneDistance
162+
if minAddress > info.End || minAddress < absMinAddress {
168163
minAddress = absMinAddress
169164
}
170165
be := tryBackendRange(size, minAddress, text-pageSize-size)
@@ -177,15 +172,15 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
177172
if maxAddress < text {
178173
maxAddress = math.MaxUint
179174
}
180-
be = tryBackendRange(size, end, maxAddress)
175+
be = tryBackendRange(size, info.End, maxAddress)
181176
if be != nil {
182177
return be, nil
183178
}
184179
}
185180

186181
// Nothing in the ideal range, so search within the acceptable range
187-
minAddress := end - maxCloneDistance
188-
if minAddress > end || minAddress < absMinAddress {
182+
minAddress := info.End - maxCloneDistance
183+
if minAddress > info.End || minAddress < absMinAddress {
189184
minAddress = absMinAddress
190185
}
191186
be := tryBackendRange(size, minAddress, text-pageSize-size)
@@ -197,7 +192,7 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
197192
if maxAddress < text {
198193
maxAddress = math.MaxUint
199194
}
200-
be = tryBackendRange(size, end, maxAddress)
195+
be = tryBackendRange(size, info.End, maxAddress)
201196
if be != nil {
202197
return be, nil
203198
}

‎clone_mprotect_darwin.go‎

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,33 @@ package redefine
44

55
/*
66
#include <pthread.h>
7+
#include <string.h>
8+
9+
// jit_memcpy copies len bytes from src to dst within a JIT write-protected
10+
// scope: pthread_jit_write_protect_np(0) before and (1) after, with an
11+
// I-cache flush in between. The entire operation runs in C so that the return
12+
// to Go code happens after MAP_JIT pages are back in execute mode.
13+
static void jit_memcpy(void *dst, const void *src, size_t len) {
14+
pthread_jit_write_protect_np(0);
15+
memcpy(dst, src, len);
16+
__builtin___clear_cache(dst, (char *)dst + len);
17+
pthread_jit_write_protect_np(1);
18+
}
719
*/
820
import "C"
9-
import (
10-
"runtime"
11-
12-
"golang.org/x/sys/unix"
13-
)
21+
import "unsafe"
1422

1523
func mprotectHook(inner func(int) error) func(int) error {
16-
return func(prot int) error {
17-
// Instead of calling mprotect, just use Darwin's
18-
// pthread_jit_write_protect_np which is effectively the same
19-
// in this case.
20-
21-
// This value is thread specific, so lock the running goroutine
22-
// to the system thread. This assumes that this function is
23-
// called in BeginMutate/EndMutate pairs.
24+
return inner
25+
}
2426

25-
if prot&unix.PROT_WRITE != 0 {
26-
runtime.LockOSThread()
27-
C.pthread_jit_write_protect_np(0)
28-
} else {
29-
C.pthread_jit_write_protect_np(1)
30-
runtime.UnlockOSThread()
31-
}
32-
return nil
33-
}
27+
// writeJITCode copies src into dst on MAP_JIT pages. The JIT write-protect
28+
// toggle and I-cache flush happen entirely in C, so the return to Go (which
29+
// executes from the duplicate MAP_JIT text) is always in execute mode.
30+
func writeJITCode(dst, src []byte) {
31+
C.jit_memcpy(
32+
unsafe.Pointer(unsafe.SliceData(dst)),
33+
unsafe.Pointer(unsafe.SliceData(src)),
34+
C.size_t(len(src)),
35+
)
3436
}

‎doc.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
// is a fun experiment, but do not use it for production code.
66
//
77
// This project is fundamentally non-portable. OS/Arch support:
8-
// - Full support: Linux/amd64, Windows/amd64, Darwin/amd64, Linux/arm64, Windows/arm64
9-
// - Might work (untested, but it compiles): FreeBSD/amd64, OpenBSD/amd64, NetBSD/amd64
10-
// - Known broken: Darwin/arm64 (EACCES errors from mprotect)
8+
// - Full support: Linux, Windows, Darwin/MacOS on amd64 and arm64
9+
// - Might work (untested, but it compiles): FreeBSD, OpenBSD, NetBSD on amd64
1110
//
1211
// Other limitations:
1312
// - Relies on internal Go APIs that can break at any time
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//go:build arm64
22

3-
package redefine
3+
package cacheflush
44

55
import "unsafe"
66

@@ -11,7 +11,7 @@ static void cacheflush(char *start, char *end) {
1111
*/
1212
import "C"
1313

14-
func cacheflush(buf []byte) {
14+
func Flush(buf []byte) {
1515
start := unsafe.Pointer(unsafe.SliceData(buf))
1616
end := unsafe.Pointer(uintptr(len(buf)) + uintptr(start))
1717
C.cacheflush((*C.char)(start), (*C.char)(end))
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
//go:build arm64 && !cgo
22

3-
package redefine
3+
package cacheflush
44

55
// arm64 requires a C compiler to flush the instruction cache.
66
// Install a C compiler and build with CGO_ENABLED=1.
7-
func cacheflush(buf []byte) {
7+
func Flush(buf []byte) {
88
arm64_requires_cgo_for_instruction_cache_flushing()
99
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//go:build !arm64
22

3-
package redefine
3+
package cacheflush
44

55
// This isn't needed on amd64. The arm64 version uses the C builtin which is a
66
// no-op, but avoiding cgo makes cross-compiling easier.
7-
func cacheflush(buf []byte) {}
7+
func Flush(buf []byte) {}

‎internal/mach/vm.go‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//go:build darwin && arm64
2+
3+
package mach
4+
5+
/*
6+
#include <mach/mach.h>
7+
#include <mach/mach_vm.h>
8+
*/
9+
import "C"
10+
import (
11+
"fmt"
12+
"unsafe"
13+
)
14+
15+
type KernErr int
16+
17+
func (e KernErr) Error() string {
18+
// Error strings from https://web.mit.edu/darwin/src/modules/xnu/osfmk/man/vm_remap.html and kern_return.h
19+
switch e {
20+
case C.KERN_INVALID_ADDRESS:
21+
return "Specified address is not currently valid."
22+
case C.KERN_NO_SPACE:
23+
return "There is not enough space in the task's address space to allocate the new region for the memory object."
24+
case C.KERN_PROTECTION_FAILURE:
25+
return "Specified memory is valid, but the backing memory manager is not permitted by the requesting task."
26+
}
27+
return fmt.Sprintf("Unknown error code: %d", e)
28+
}
29+
30+
const (
31+
VmProtNone = C.VM_PROT_NONE
32+
VmProtRead = C.VM_PROT_READ
33+
VmProtWrite = C.VM_PROT_WRITE
34+
VmProtExecute = C.VM_PROT_EXECUTE
35+
)
36+
37+
type VmInfo struct {
38+
Addr unsafe.Pointer
39+
Size uintptr
40+
Prot int
41+
MaxProt int
42+
}
43+
44+
// VmRemap makes a new virtual memory mapping of srcAddr. If addr is 0 then the
45+
// new mapping will be allocated anywhere, otherwise the page will be requested
46+
// at exactly the given address and may overwrite a previously existing mapping
47+
// at that address.
48+
func VmRemap(addr uintptr, srcAddr uintptr, size uintptr) (*VmInfo, error) {
49+
info := VmInfo{
50+
Size: size,
51+
}
52+
53+
var vmAddr C.mach_vm_address_t
54+
vmAddr = C.mach_vm_address_t(addr)
55+
56+
var flags int
57+
if addr == 0 {
58+
flags |= C.VM_FLAGS_ANYWHERE
59+
} else {
60+
flags |= C.VM_FLAGS_FIXED | C.VM_FLAGS_OVERWRITE
61+
}
62+
63+
var curProt, maxProt C.vm_prot_t
64+
65+
ret := C.mach_vm_remap(
66+
C.mach_task_self_,
67+
&vmAddr,
68+
C.mach_vm_address_t(size),
69+
0,
70+
C.int(flags),
71+
C.mach_task_self_,
72+
C.mach_vm_address_t(srcAddr),
73+
0,
74+
&curProt,
75+
&maxProt,
76+
C.VM_INHERIT_NONE,
77+
)
78+
79+
if ret != 0 {
80+
return nil, KernErr(ret)
81+
}
82+
83+
info.Addr = unsafe.Pointer(uintptr(vmAddr))
84+
info.Prot = int(curProt)
85+
info.MaxProt = int(maxProt)
86+
87+
return &info, nil
88+
}
89+
90+
// Slice returns a byte slice that uses the backing memory referenced in VmInfo.
91+
func (vmi *VmInfo) Slice() []byte {
92+
if vmi.Addr == nil || vmi.Size == 0 {
93+
return nil
94+
}
95+
return unsafe.Slice((*byte)(vmi.Addr), vmi.Size)
96+
}
97+
98+
// Unmap deallocates the referenced memory.
99+
func (vmi *VmInfo) Unmap() error {
100+
ret := C.mach_vm_deallocate(
101+
C.mach_task_self_,
102+
C.mach_vm_address_t(uintptr(vmi.Addr)),
103+
C.mach_vm_address_t(vmi.Size),
104+
)
105+
if ret != 0 {
106+
return KernErr(ret)
107+
}
108+
109+
vmi.Addr = nil
110+
vmi.Size = 0
111+
vmi.Prot = 0
112+
vmi.MaxProt = 0
113+
114+
return nil
115+
}

0 commit comments

Comments
 (0)