From 1fcc343cf6b7e163b0f4260840ea84850ebbfb93 Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Wed, 23 Sep 2026 16:10:22 +0800 Subject: [PATCH 1/6] arm64: translate foreign stubs directly to native assembly --- README.md | 36 +-- doc/native-arm64.md | 80 +++++ go_translate.go | 21 +- go_translate_deep_test.go | 11 +- native_arm64_object.go | 340 --------------------- native_arm64_object_test.go | 240 --------------- native_arm64_source.go | 583 ++++++++++++++++++++++++++++++++++++ native_arm64_source_test.go | 322 ++++++++++++++++++++ parser.go | 6 +- types.go | 2 + 10 files changed, 1018 insertions(+), 623 deletions(-) create mode 100644 doc/native-arm64.md delete mode 100644 native_arm64_object.go delete mode 100644 native_arm64_object_test.go create mode 100644 native_arm64_source.go create mode 100644 native_arm64_source_test.go diff --git a/README.md b/README.md index ee97408d..4eb8494f 100644 --- a/README.md +++ b/README.md @@ -164,23 +164,19 @@ go run -C cmd/plan9asm . transpile \ -goos=linux -goarch=amd64 ``` -## Foreign-ABI ARM64 assembly - -Raw C callbacks and tail-call trampolines sometimes have no Go function -signature. Passing them through typed LLVM lowering cannot preserve an unknown -set of incoming or outgoing registers. `ForeignARM64Functions` recognizes the -restricted file-local, NOSPLIT, zero-Go-frame form (non-leaf callbacks must -explicitly use NOFRAME). - -A compiler driver can assemble such a file with `go tool asm -p ` and -pass the Darwin/ARM64 Go object to `TranslateNativeARM64Object`. The returned -Mach-O assembly preserves the encoded instructions and maps R_ADDR and -R_CALLARM64 relocations to selected local definitions or explicitly supplied -dynamic imports. The accompanying DATA list lets the driver bind Go globals to -their assembly definitions. The driver remains responsible for selecting the -target, running tools, and supplying library link arguments. - -This path does not translate Go ABI functions. Unsupported object formats, -relocations, and undeclared foreign calls return errors instead of inventing -function signatures. The object reader accepts the go120ld format used since -Go 1.20; the runtime tests execute native callbacks only on Darwin/ARM64. +## Native Darwin/ARM64 assembly + +`TranslateNativeARM64Source` emits native assembly directly from a bounded Plan 9 +subset for raw callbacks and trampolines. It preserves explicitly written register +interfaces without synthesizing LLVM function signatures or using Go assembler +objects. The driver invokes the native assembler, binds the returned DATA globals, +and supplies imported symbols and library link arguments. + +`ForeignARM64Functions` is a routing hint for files containing only local TEXT; +it does not establish a C ABI. Unsupported frames, flags, instructions, operands, +and references produce errors. Go-declared functions retain typed LLVM translation; +unresolved local signatures require explicit metadata instead of a `void()` guess. + +See [the native backend contract](doc/native-arm64.md) for the exact source, +instruction, data and runtime boundaries. The former `TranslateNativeARM64Object` +API has been removed; callers pass source to `TranslateNativeARM64Source` instead. diff --git a/doc/native-arm64.md b/doc/native-arm64.md new file mode 100644 index 00000000..ee2d4e1a --- /dev/null +++ b/doc/native-arm64.md @@ -0,0 +1,80 @@ +# Native Darwin/ARM64 backend + +`TranslateNativeARM64Source` translates a restricted Plan 9 source file to +Darwin/ARM64 assembly. A native assembler such as LLVM's integrated assembler +creates the final Mach-O object. There is no `go tool asm` invocation, dependency +on a Go object layout, instruction-byte copying, or guessed LLVM function type. + +The contract is **physical register preservation**, not inferred C typing. A +caller of an entry point must arrange exactly the registers expected by its +source. The source must implement the platform ABI itself, including stack +alignment, saving/restoring the link register, callee-saved registers and any +required native frame. This backend supplies no Go ABI wrappers, stack growth, +GC stack maps, exception/unwind metadata or transitions into the Go runtime. + +`ForeignARM64Functions` is only a routing hint: it reports a file composed entirely +of file-local TEXT definitions. Local linkage does not prove a C ABI. A driver +must explicitly choose native translation in a foreign-call context and report +native translation errors rather than retrying with invented signatures. +Package-visible Go functions remain on the typed LLVM path. An unresolved local +function on that path now requires `ManualSig` or a supported inferred signature; +it no longer silently receives `void()`. + +## Source contract + +- The output target is Darwin/ARM64 only, independent of the build host. +- Every `TEXT` is file-local (`name<>`), has `NOSPLIT`, and declares `$0` or `$0-0`. + `NOFRAME` is also required if the body uses `BL` or `CALL`. No implicit Go + prologue or epilogue is generated, including for zero-frame functions. +- Other TEXT flags are rejected. The source manages any native frame explicitly. +- Only `#include "textflag.h"` is accepted. Other includes, macros, conditional + preprocessing, and block comments are currently rejected. Line comments and + constant integer expressions are accepted. +- Integer registers are `R0`–`R30` except Darwin's reserved `R18`, plus `ZR`. + `RSP` is accepted in supported stack-pointer forms; `SP`, `FP`, `g`, `R31`, + `Wn` aliases, and platform aliases are rejected. Floating registers are `F0`–`F31`. +- Local function/branch/import identifiers use ASCII letters, digits and `_`, + beginning with a letter or `_`. Data definitions use `·name` for a package global. +- Local labels and function addresses are renamed per assembly file. Foreign + symbols are resolved only through the driver's explicit import map; no Go + package/runtime call, symbol addend, computed branch or indirect call is accepted. +- `DATA` supports constant integers of 1/2/4/8 bytes and 8-byte addresses of selected + local functions, defined data or declared imports. Each initializer must fit its + `GLOBL` allocation and must not overlap another initializer. Gaps are zero-filled. +- `GLOBL` requires a constant size from 1 byte to 64 MiB. Supported flags are `0`, + `RODATA` and `NOPTR`. Definitions are 8-byte aligned. `RODATA` is placed in + `__DATA_CONST,__const`, allowing address fixups before becoming read-only; mutable + definitions use `.data`. The returned DATA metadata lets the driver verify the + Go global size and bind its definition to the native object. + +## Instruction and operand forms + +| Plan 9 operation | Supported forms and native semantics | +| --- | --- | +| `MOVD` | Integer register/register, integer constant/register, register/base+offset memory in either direction. Constants expand to `movz`/`movk` using only the destination and preserving flags. | +| `MOVW`, `MOVWU` | Register/register or register/base+offset memory. `MOVW` register/load sign-extends to 64 bits; `MOVWU` zero-extends. Stores write 32 bits. Immediate forms are rejected. | +| `FMOVD` | Integer/FP register bit transfers or FP/FP register copies. No numeric conversion or memory form. | +| `ADD`, `SUB` | Two or three operands: register or nonnegative 12-bit immediate, source, destination. Register forms involving SP are rejected. No hidden scratch-register expansion. | +| `CMP`, `CMPW` | Register or nonnegative 12-bit immediate against a register; 64-bit and 32-bit comparisons respectively. | +| `LSL` | Two or three operands, with a register or immediate shift from 0 through 63. | +| `B`, `JMP` | A defined local label, selected local function or declared import. | +| `BL`, `CALL` | A defined local label, selected local function or declared import; the containing function must have `NOFRAME`. | +| `BEQ`, `BNE`, `BLT`, `BLE`, `BGT`, `BGE`, `BHS`, `BLO`, `BHI`, `BLS`, `BMI`, `BPL`, `BVS`, `BVC` | Defined local labels, preserving the corresponding hardware condition. | +| `CBZ`, `CBNZ` | 64-bit integer register and defined local label. | +| `RET` | No operands; returns through the native link register. | + +Memory operands use a single base register and constant byte offset. An offset +must fit the instruction's unsigned scaled 12-bit or signed unscaled 9-bit field. +Index registers, symbolic memory operands, pre/post-indexing and larger offsets +are rejected. Raw instruction directives (`WORD`, `BYTE`) and all unlisted +instructions are rejected. Validation errors return no partial assembly or DATA. + +## Validation + +Tests compile generated assembly to Mach-O on all hosts with Clang, check data +section placement, and execute C harnesses on Darwin/ARM64. The harnesses cover +foreign-call parameters/results, integer-to-FP trampoline arguments, physical +register copies, signed and unsigned loads, constant expansion, negative and +unaligned memory offsets, and local control flow. Rejection tests exercise the +unsupported forms above. The parser retains address initializers explicitly; +they are never inferred by decoding an object relocation. diff --git a/go_translate.go b/go_translate.go index e9f73dd1..40e781d3 100644 --- a/go_translate.go +++ b/go_translate.go @@ -232,17 +232,13 @@ func goSigsForAsmFile(pkg GoPackage, file *File, resolve func(sym string) string b.sigs[resolved] = fs } } - // File-local TEXT symbols (the Plan 9 `<>` form) are commonly used for - // assembly trampolines that are only reached through a raw function - // pointer. They intentionally have no Go declaration. Give any such - // symbols that could not be inferred from a tail jump a conservative - // zero-argument/void signature so they can still be emitted and referenced - // by DATA directives. A caller-provided ManualSig remains authoritative. + // A file-local symbol does not imply a zero-argument C or Go signature. + // Keep signatures obtained from declarations, supported tail-call inference, + // or ManualSig; raw register entry points require the native backend. for resolved := range b.localSigs { - if _, ok := b.sigs[resolved]; ok { - continue + if _, ok := b.sigs[resolved]; !ok { + return nil, fmt.Errorf("missing signature for file-local assembly %q; supply ManualSig or use the native backend", resolved) } - b.sigs[resolved] = FuncSig{Name: resolved, Ret: Void} } return b.sigs, nil } @@ -398,10 +394,9 @@ func (b *goSigBuilder) addReferencedFuncSigs(file *File) error { // missing declaration instead of silently dropping arguments/results. continue } - // An undeclared tail target can be an assembly trampoline reached - // through a raw function pointer. Its ABI is opaque to go/types; keep a - // conservative declaration so the trampoline can still be emitted. - b.sigs[targetResolved] = FuncSig{Name: targetResolved, Ret: Void} + // A tail target with no known caller signature has no type evidence + // either. Leave it unresolved; native register entry points must not + // acquire an invented zero-argument declaration. } } return nil diff --git a/go_translate_deep_test.go b/go_translate_deep_test.go index d6b8d139..a788b26c 100644 --- a/go_translate_deep_test.go +++ b/go_translate_deep_test.go @@ -350,19 +350,14 @@ func TestTranslateGoModuleUndeclaredLocalTrampoline(t *testing.T) { pkg := mustGoPackage(t, "test/pkg", `package testpkg func F() {} `) - tr, err := TranslateGoModule(pkg, []byte(`TEXT local_trampoline<>(SB),NOSPLIT,$0-0 + _, err := TranslateGoModule(pkg, []byte(`TEXT local_trampoline<>(SB),NOSPLIT,$0-0 JMP external_symbol(SB) `), GoModuleOptions{ GOARCH: "arm64", TargetTriple: "aarch64-unknown-linux-gnu", ResolveSym: testResolveSym("test/pkg"), }) - if err != nil { - t.Fatalf("TranslateGoModule(undeclared local trampoline) error = %v", err) - } - defer tr.Module.Dispose() - sig, ok := tr.Signatures["test/pkg.local_trampoline"] - if !ok || sig.Ret != Void || len(sig.Args) != 0 { - t.Fatalf("local trampoline signature = %#v, want void()", sig) + if err == nil || !strings.Contains(err.Error(), "missing signature for file-local assembly") { + t.Fatalf("undeclared trampoline must not receive a guessed void signature: %v", err) } } diff --git a/native_arm64_object.go b/native_arm64_object.go deleted file mode 100644 index c4281302..00000000 --- a/native_arm64_object.go +++ /dev/null @@ -1,340 +0,0 @@ -package plan9asm - -import ( - "bytes" - "encoding/binary" - "fmt" - "regexp" - "sort" - "strconv" - "strings" -) - -// Layout constants from Go 1.27 cmd/internal/goobj/objfile.go (go120ld) -// and cmd/internal/objabi/reloctype.go. Reject other object formats below. -const ( - nativeBlockCount = 19 // includes the end offset - nativeHeaderSize = 20 + nativeBlockCount*4 - nativeSymSize = 21 - nativeSymFlags = 11 - nativeSymSizeOffset = 13 - nativeSymAlign = 17 - nativeRelocSize = 23 - nativeRelocKind = 5 - nativeRelocAddend = 7 - nativeRelocPkg = 15 - nativeRelocSym = 19 - nativeNoSplit = 1 << 4 - nativeRAddr = 1 - nativeRCallARM64 = 9 - nativeBlkSymdef = 3 - nativeBlkNonpkgref = 7 - nativeBlkRelocIdx = 11 - nativeBlkDataIdx = 13 - nativeBlkReloc = 14 - nativeBlkData = 16 - nativePkgSelf = 0x7ffffffb - nativePkgHashed64 = 0x7ffffffe - nativePkgHashed = 0x7ffffffd - nativePkgNone = 0x7fffffff -) - -// NativeData describes a Go global whose storage is supplied by native assembly. -type NativeData struct { - Name string - Size uint32 -} - -// TranslateNativeARM64Object preserves the machine code of foreign-ABI assembly. Unlike -// Go-declared functions, raw callbacks have no signature from which LLVM can -// reconstruct incoming registers. The Go assembler encodes those registers; -// this bridge only converts its address and branch relocations to Mach-O asm. -// -// The input must be a darwin/arm64 cmd/asm object in go120ld format. Only explicitly -// selected functions, their data, and declared dynamic imports are accepted. -// Unknown formats, symbol references and relocations are errors. -func TranslateNativeARM64Object(obj []byte, funcs map[string]bool, imports map[string]string, pkgPath string) (string, []NativeData, error) { - r, err := readNativeObject(obj) - if err != nil { - return "", nil, err - } - labels := make(map[int]string) - var data []NativeData - for i, s := range r.syms[:r.ndef] { - if funcs[s.name] { - if s.flags&nativeNoSplit == 0 { - return "", nil, fmt.Errorf("native function %s is not NOSPLIT", s.name) - } - labels[i] = fmt.Sprintf("Lllgo_native_%d", i) - } else if strings.HasPrefix(s.name, pkgPath+".") { - labels[i] = "_" + s.name - data = append(data, NativeData{s.name, s.size}) - } - } - for name := range funcs { - found := false - for i := range labels { - if r.syms[i].name == name { - found = true - break - } - } - if !found { - return "", nil, fmt.Errorf("missing native function %s", name) - } - } - var out strings.Builder - for i, s := range r.syms[:r.ndef] { - label, ok := labels[i] - if !ok { - continue - } - code := r.payload(i) - if uint64(len(code)) > uint64(s.size) { - return "", nil, fmt.Errorf("oversized native symbol %s", s.name) - } - code = append(append([]byte(nil), code...), make([]byte, int(s.size)-len(code))...) - if funcs[s.name] { - out.WriteString(".text\n.p2align 2\n") - } else { - out.WriteString(".data\n") - fmt.Fprintf(&out, ".globl %s\n", strconv.Quote(label)) - align := s.align - if align == 0 { - align = 8 - } - if align&(align-1) != 0 { - return "", nil, fmt.Errorf("invalid native alignment %d", align) - } - fmt.Fprintf(&out, ".balign %d\n", align) - } - fmt.Fprintf(&out, "%s:\n", strconv.Quote(label)) - rels := r.relocs(i) - sort.Slice(rels, func(i, j int) bool { return rels[i].off < rels[j].off }) - pos := uint32(0) - for _, rel := range rels { - if rel.off < pos || uint64(rel.off)+uint64(rel.size) > uint64(len(code)) { - return "", nil, fmt.Errorf("invalid native relocation in %s", s.name) - } - target, err := r.target(rel.pkg, rel.sym) - if err != nil { - return "", nil, err - } - dst, ok := labels[target] - if !ok { - alias, ok := imports[r.syms[target].name] - if !ok { - return "", nil, fmt.Errorf("native assembly references undeclared foreign symbol %s", r.syms[target].name) - } - dst = "_" + alias - } - expr := strconv.Quote(dst) - if rel.add != 0 { - expr += fmt.Sprintf("%+d", rel.add) - } - nativeBytes(&out, code[pos:rel.off]) - switch { - case rel.kind == nativeRAddr && rel.size == 8: // R_ADDR - fmt.Fprintf(&out, ".quad %s\n", expr) - case rel.kind == nativeRCallARM64 && rel.size == 4 && funcs[s.name]: // R_CALLARM64: B or BL - if rel.add != 0 { - return "", nil, fmt.Errorf("unsupported native branch addend %d in %s", rel.add, s.name) - } - word := binary.LittleEndian.Uint32(code[rel.off:]) - op := "b" - if word&0xfc000000 == 0x94000000 { - op = "bl" - } else if word&0xfc000000 != 0x14000000 { - return "", nil, fmt.Errorf("invalid native branch in %s", s.name) - } - fmt.Fprintf(&out, "%s %s\n", op, expr) - default: - return "", nil, fmt.Errorf("unsupported native relocation %d/%d in %s", rel.kind, rel.size, s.name) - } - pos = rel.off + uint32(rel.size) - } - nativeBytes(&out, code[pos:]) - } - return out.String(), data, nil -} - -func nativeBytes(out *strings.Builder, b []byte) { - var digits [3]byte // largest byte value is 255 - for len(b) > 0 { - n := len(b) - if n > 16 { - n = 16 - } - out.WriteString(".byte ") - for i, x := range b[:n] { - if i != 0 { - out.WriteByte(',') - } - out.Write(strconv.AppendUint(digits[:0], uint64(x), 10)) - } - out.WriteByte('\n') - b = b[n:] - } -} - -type nativeSym struct { - name string - size, align uint32 - flags byte -} -type nativeReloc struct { - off uint32 - size byte - kind uint16 - add int64 - pkg, sym uint32 -} -type nativeObject struct { - b []byte - blocks [nativeBlockCount]uint32 - syms []nativeSym - ndef int - counts [5]int -} - -func readNativeObject(obj []byte) (*nativeObject, error) { - // cmd/asm prefixes the binary object with a target/version text header. - h := bytes.Index(obj, []byte("\n!\n")) - if h < 0 || !bytes.HasPrefix(obj, []byte("go object darwin arm64 ")) { - return nil, fmt.Errorf("expected darwin/arm64 Go assembler object") - } - b := obj[h+3:] - if len(b) < nativeHeaderSize || string(b[:8]) != "\x00go120ld" { - return nil, fmt.Errorf("unsupported Go assembler object format") - } - r := &nativeObject{b: b} - for i := range r.blocks { - r.blocks[i] = binary.LittleEndian.Uint32(b[20+i*4:]) - if r.blocks[i] > uint32(len(b)) || (i > 0 && r.blocks[i] < r.blocks[i-1]) { - return nil, fmt.Errorf("invalid native object block %d", i) - } - } - if r.blocks[0] < nativeHeaderSize { - return nil, fmt.Errorf("invalid native object header") - } - for block := nativeBlkSymdef; block <= nativeBlkNonpkgref; block++ { - buf := r.block(block) - if len(buf)%nativeSymSize != 0 { - return nil, fmt.Errorf("invalid native symbol table") - } - r.counts[block-nativeBlkSymdef] = len(buf) / nativeSymSize - for len(buf) > 0 { - s := buf[:nativeSymSize] - n, off := binary.LittleEndian.Uint32(s), binary.LittleEndian.Uint32(s[4:]) - if uint64(off)+uint64(n) > uint64(len(b)) { - return nil, fmt.Errorf("invalid native symbol name") - } - size := binary.LittleEndian.Uint32(s[nativeSymSizeOffset:]) - if size > 64<<20 { - return nil, fmt.Errorf("native symbol too large") - } - r.syms = append(r.syms, nativeSym{string(b[off : off+n]), size, binary.LittleEndian.Uint32(s[nativeSymAlign:]), s[nativeSymFlags]}) - buf = buf[nativeSymSize:] - } - } - r.ndef = len(r.syms) - r.counts[4] - if len(r.block(nativeBlkRelocIdx)) != (r.ndef+1)*4 || len(r.block(nativeBlkDataIdx)) != (r.ndef+1)*4 || len(r.block(nativeBlkReloc))%nativeRelocSize != 0 { - return nil, fmt.Errorf("invalid native object indices") - } - for _, pair := range [][2]int{{nativeBlkRelocIdx, len(r.block(nativeBlkReloc)) / nativeRelocSize}, {nativeBlkDataIdx, len(r.block(nativeBlkData))}} { - prev := uint32(0) - buf := r.block(pair[0]) - for len(buf) > 0 { - v := binary.LittleEndian.Uint32(buf) - if v < prev || uint64(v) > uint64(pair[1]) { - return nil, fmt.Errorf("invalid native object index") - } - prev = v - buf = buf[4:] - } - } - return r, nil -} - -// block accepts only the block constants above, all strictly before the end -// offset. readNativeObject validates every offset before calling it. -func (r *nativeObject) block(i int) []byte { return r.b[r.blocks[i]:r.blocks[i+1]] } -func (r *nativeObject) payload(i int) []byte { - idx := r.block(nativeBlkDataIdx) - return r.block(nativeBlkData)[binary.LittleEndian.Uint32(idx[i*4:]):binary.LittleEndian.Uint32(idx[(i+1)*4:])] -} -func (r *nativeObject) relocs(i int) []nativeReloc { - idx := r.block(nativeBlkRelocIdx) - a, z := binary.LittleEndian.Uint32(idx[i*4:]), binary.LittleEndian.Uint32(idx[(i+1)*4:]) - var out []nativeReloc - for n := a; n < z; n++ { - v := r.block(nativeBlkReloc)[int(n)*nativeRelocSize:] - out = append(out, nativeReloc{binary.LittleEndian.Uint32(v), v[4], binary.LittleEndian.Uint16(v[nativeRelocKind:]), int64(binary.LittleEndian.Uint64(v[nativeRelocAddend:])), binary.LittleEndian.Uint32(v[nativeRelocPkg:]), binary.LittleEndian.Uint32(v[nativeRelocSym:])}) - } - return out -} -func (r *nativeObject) target(pkg, sym uint32) (int, error) { - // Reserved package indices from cmd/internal/goobj. Package imports and - // runtime builtins have Go ABI and cannot be called by these foreign stubs. - var start, count int - switch pkg { - case nativePkgSelf: - count = r.counts[0] - case nativePkgHashed64: - start = r.counts[0] - count = r.counts[1] - case nativePkgHashed: - start = r.counts[0] + r.counts[1] - count = r.counts[2] - case nativePkgNone: - start = r.counts[0] + r.counts[1] + r.counts[2] - count = r.counts[3] + r.counts[4] - default: - return 0, fmt.Errorf("native assembly references Go package index %#x", pkg) - } - if uint64(sym) >= uint64(count) { - return 0, fmt.Errorf("invalid native symbol reference") - } - return start + int(sym), nil -} - -var nativeTextRE = regexp.MustCompile(`(?m)^\s*TEXT\s+([^\s(),]+)<>\(SB\),\s*([^,]+),\s*\$0(?:-0)?\s*(?://[^\n]*)?$`) - -// ForeignARM64Functions selects files made entirely of raw foreign-ABI -// callbacks/trampolines. Go-declared functions continue through typed LLVM -// lowering. NOSPLIT and zero Go frames are required; the callback may manage -// its own native frame explicitly with NOFRAME. -func ForeignARM64Functions(src []byte) map[string]bool { - matches := nativeTextRE.FindAllSubmatchIndex(src, -1) - if len(matches) == 0 || len(reTextLines.FindAll(src, -1)) != len(matches) { - return nil - } - result := make(map[string]bool) - for i, m := range matches { - flags := string(src[m[4]:m[5]]) - if !hasAsmFlag(flags, "NOSPLIT") { - return nil - } - end := len(src) - if i+1 < len(matches) { - end = matches[i+1][0] - } - if !hasAsmFlag(flags, "NOFRAME") && nativeCallRE.Match(src[m[1]:end]) { - return nil - } - result[string(src[m[2]:m[3]])] = true - } - return result -} - -var reTextLines = regexp.MustCompile(`(?m)^\s*TEXT\b`) -var nativeCallRE = regexp.MustCompile(`(?m)^\s*(?:CALL|BL)\s`) - -func hasAsmFlag(flags, want string) bool { - for _, f := range strings.Split(flags, "|") { - if strings.TrimSpace(f) == want { - return true - } - } - return false -} diff --git a/native_arm64_object_test.go b/native_arm64_object_test.go deleted file mode 100644 index 97c4dc0c..00000000 --- a/native_arm64_object_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package plan9asm - -import ( - "encoding/binary" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func nativeObjectFixture(t *testing.T) []byte { - t.Helper() - dir := t.TempDir() - src := filepath.Join(dir, "native.s") - out := filepath.Join(dir, "native.o") - code := `#include "textflag.h" -TEXT callback<>(SB), NOSPLIT|NOFRAME, $0 - SUB $16, RSP - MOVD R30, (RSP) - BL imported_strlen(SB) - MOVD (RSP), R30 - ADD $16, RSP - RET -GLOBL ·entry(SB), RODATA, $8 -DATA ·entry(SB)/8, $callback<>(SB) -TEXT mixedtramp<>(SB), NOSPLIT, $0-0 - FMOVD R1, F0 - JMP imported_mixed(SB) -GLOBL ·mixedEntry(SB), RODATA, $8 -DATA ·mixedEntry(SB)/8, $mixedtramp<>(SB) -` - if err := os.WriteFile(src, []byte(code), 0600); err != nil { - t.Fatal(err) - } - cmd := exec.Command("go", "tool", "asm", "-p", "probe", "-I", filepath.Join(runtime.GOROOT(), "pkg", "include"), "-o", out, src) - cmd.Env = append(os.Environ(), "GOOS=darwin", "GOARCH=arm64") - if b, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("asm: %v\n%s", err, b) - } - b, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - return b -} - -func TestNativeARM64Object(t *testing.T) { - obj := nativeObjectFixture(t) - funcs := map[string]bool{"callback": true, "mixedtramp": true} - imports := map[string]string{"imported_strlen": "strlen", "imported_mixed": "mixed"} - asm, data, err := TranslateNativeARM64Object(obj, funcs, imports, "probe") - if err != nil { - t.Fatal(err) - } - if len(data) != 2 || data[0].Name != "probe.entry" || data[0].Size != 8 { - t.Fatalf("data = %v", data) - } - if !strings.Contains(asm, `bl "_strlen"`) || !strings.Contains(asm, `.quad "Lllgo_native_`) { - t.Fatalf("missing relocations:\n%s", asm) - } - if _, _, err := TranslateNativeARM64Object(obj, funcs, nil, "probe"); err == nil { - t.Fatal("accepted undeclared foreign call") - } - if _, _, err := TranslateNativeARM64Object(obj, map[string]bool{"missing": true}, imports, "probe"); err == nil { - t.Fatal("accepted missing function") - } - for n := 0; n < len(obj); n++ { - if _, _, err := TranslateNativeARM64Object(obj[:n], funcs, imports, "probe"); err == nil { - t.Fatalf("accepted truncated object at %d", n) - } - } - t.Run("reject unsupported relocation", func(t *testing.T) { - damaged := append([]byte(nil), obj...) - r, err := readNativeObject(damaged) - if err != nil { - t.Fatal(err) - } - for i, sym := range r.syms[:r.ndef] { - if sym.name != "callback" { - continue - } - first := binary.LittleEndian.Uint32(r.block(11)[i*4:]) - rel := r.block(14)[first*23:] - binary.LittleEndian.PutUint16(rel[5:], 0xffff) - break - } - if _, _, err := TranslateNativeARM64Object(damaged, funcs, imports, "probe"); err == nil { - t.Fatal("accepted unknown relocation") - } - }) - if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { - return - } - dir := t.TempDir() - s := filepath.Join(dir, "native.s") - c := filepath.Join(dir, "main.c") - exe := filepath.Join(dir, "probe") - if err := os.WriteFile(s, []byte(asm), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(c, []byte(`extern void *entry __asm("_probe.entry"); -extern void *mixedEntry __asm("_probe.mixedEntry"); -unsigned long mixed(unsigned long x, double y) { return x + (unsigned long)y; } -int main(void) { - if (((unsigned long (*)(const char *))entry)("native ABI") != 10) return 1; - return ((unsigned long (*)(unsigned long, unsigned long))mixedEntry)(5, 0x4000000000000000UL) != 7; -} -`), 0600); err != nil { - t.Fatal(err) - } - cmd := exec.Command("clang", s, c, "-o", exe) - if b, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("link: %v\n%s", err, b) - } - if b, err := exec.Command(exe).CombinedOutput(); err != nil { - t.Fatalf("callback: %v\n%s", err, b) - } -} - -func TestForeignARM64Selection(t *testing.T) { - for _, tc := range []struct { - src string - want bool - }{ - {"TEXT raw<>(SB), NOSPLIT, $0-0\n JMP imported(SB)\n", true}, - {"TEXT raw<>(SB), NOSPLIT, $0-0\r\n JMP imported(SB)\r\n", true}, - {"TEXT raw<>(SB), NOSPLIT, $0-0\n RET\nTEXT raw2<>(SB),NOSPLIT,$0\nRET\n", true}, - {"TEXT raw<>(SB), NOSPLIT|NOFRAME, $0\n BL imported(SB)\n RET\n", true}, - {"TEXT ·declared(SB), NOSPLIT, $0-0\n RET\n", false}, - {"TEXT raw<>(SB), 0, $0-0\n RET\n", false}, - {"TEXT raw<>(SB), NOSPLIT, $8-0\n RET\n", false}, - {"TEXT raw<>(SB), NOSPLIT, $0-0\n BL imported(SB)\n RET\n", false}, - {"TEXT raw<>(SB), NOSPLIT, $0-0\n RET\nTEXT ·goFunc(SB),NOSPLIT,$0\nRET\n", false}, - } { - if got := len(ForeignARM64Functions([]byte(tc.src))) != 0; got != tc.want { - t.Errorf("selection(%q) = %v", tc.src, got) - } - } -} - -// Exercise the parser's rejection contract using valid assembler output with -// one damaged field at a time, so each diagnostic identifies the broken field. -func TestNativeARM64MalformedFields(t *testing.T) { - obj := nativeObjectFixture(t) - funcs := map[string]bool{"callback": true, "mixedtramp": true} - imports := map[string]string{"imported_strlen": "strlen", "imported_mixed": "mixed"} - cases := []struct { - name, want string - damage func(*nativeObject) - }{ - {"header", "invalid native object header", func(r *nativeObject) { binary.LittleEndian.PutUint32(r.b[20:], nativeHeaderSize-1) }}, - {"symbol table", "invalid native symbol table", func(r *nativeObject) { - binary.LittleEndian.PutUint32(r.b[20+nativeBlkNonpkgref*4:], r.blocks[nativeBlkNonpkgref]-1) - }}, - {"symbol name", "invalid native symbol name", func(r *nativeObject) { binary.LittleEndian.PutUint32(r.block(nativeBlkSymdef), ^uint32(0)) }}, - {"symbol size", "native symbol too large", func(r *nativeObject) { - binary.LittleEndian.PutUint32(r.block(nativeBlkSymdef)[nativeSymSizeOffset:], 1<<30) - }}, - {"symbol payload", "oversized native symbol", func(r *nativeObject) { - binary.LittleEndian.PutUint32(r.block(nativeBlkSymdef)[nativeSymSizeOffset:], 0) - }}, - {"alignment", "invalid native alignment", func(r *nativeObject) { - binary.LittleEndian.PutUint32(nativeFixtureSym(r, "probe.entry")[nativeSymAlign:], 3) - }}, - {"indices length", "invalid native object indices", func(r *nativeObject) { - binary.LittleEndian.PutUint32(r.b[20+nativeBlkRelocIdx*4:], r.blocks[nativeBlkRelocIdx]+1) - }}, - {"index range", "invalid native object index", func(r *nativeObject) { binary.LittleEndian.PutUint32(r.block(nativeBlkDataIdx), ^uint32(0)) }}, - {"split function", "not NOSPLIT", func(r *nativeObject) { nativeFixtureSym(r, "callback")[nativeSymFlags] &^= nativeNoSplit }}, - {"relocation bounds", "invalid native relocation", func(r *nativeObject) { binary.LittleEndian.PutUint32(nativeCallbackReloc(r), ^uint32(0)) }}, - {"Go ABI reference", "Go package index", func(r *nativeObject) { binary.LittleEndian.PutUint32(nativeCallbackReloc(r)[nativeRelocPkg:], 1) }}, - {"reference range", "invalid native symbol reference", func(r *nativeObject) { - binary.LittleEndian.PutUint32(nativeCallbackReloc(r)[nativeRelocSym:], ^uint32(0)) - }}, - {"branch addend", "unsupported native branch addend", func(r *nativeObject) { binary.LittleEndian.PutUint64(nativeCallbackReloc(r)[nativeRelocAddend:], 8) }}, - {"branch opcode", "invalid native branch", func(r *nativeObject) { - for i, s := range r.syms { - if s.name == "callback" { - binary.LittleEndian.PutUint32(r.payload(i)[binary.LittleEndian.Uint32(nativeCallbackReloc(r)):], 0) - } - } - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - damaged := append([]byte(nil), obj...) - r, err := readNativeObject(damaged) - if err != nil { - t.Fatal(err) - } - tc.damage(r) - _, _, err = TranslateNativeARM64Object(damaged, funcs, imports, "probe") - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("got %v, want %q", err, tc.want) - } - }) - } -} - -func nativeFixtureSym(r *nativeObject, name string) []byte { - for i, s := range r.syms { - if s.name == name { - for block, count := range r.counts { - if i < count { - return r.block(nativeBlkSymdef + block)[i*nativeSymSize:] - } - i -= count - } - } - } - panic("fixture missing symbol " + name) -} - -func nativeCallbackReloc(r *nativeObject) []byte { - for i, s := range r.syms { - if s.name == "callback" { - first := binary.LittleEndian.Uint32(r.block(nativeBlkRelocIdx)[i*4:]) - return r.block(nativeBlkReloc)[first*nativeRelocSize:] - } - } - panic("fixture missing callback") -} - -func TestNativeARM64SymbolReferences(t *testing.T) { - r := &nativeObject{counts: [5]int{2, 3, 4, 5, 6}} - for _, tc := range []struct { - pkg uint32 - sym uint32 - want int - }{ - {nativePkgSelf, 1, 1}, {nativePkgHashed64, 2, 4}, {nativePkgHashed, 3, 8}, {nativePkgNone, 10, 19}, - } { - got, err := r.target(tc.pkg, tc.sym) - if err != nil || got != tc.want { - t.Fatalf("target(%x,%d) = %d, %v; want %d", tc.pkg, tc.sym, got, err, tc.want) - } - } -} diff --git a/native_arm64_source.go b/native_arm64_source.go new file mode 100644 index 00000000..ef8ee124 --- /dev/null +++ b/native_arm64_source.go @@ -0,0 +1,583 @@ +package plan9asm + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// NativeData describes a Go global whose storage is supplied by native assembly. +type NativeData struct { + Name string + Size uint32 +} + +// ForeignARM64Functions identifies files containing only file-local TEXT symbols. +// This is a routing hint, NOT an inference of a C function signature. The native +// backend validates frames, flags, instructions, and references independently. +// Files with package-visible TEXT must use the typed backend and explicit Go +// declarations. Unsupported native forms must not fall back to signature guessing. +func ForeignARM64Functions(src []byte) map[string]bool { + f, err := Parse(ArchARM64, string(src)) + if err != nil || len(f.Funcs) == 0 { + return nil + } + result := make(map[string]bool) + for _, fn := range f.Funcs { + if !strings.HasSuffix(fn.Sym, "<>") { + return nil + } + result[strings.TrimSuffix(fn.Sym, "<>")] = true + } + return result +} + +// TranslateNativeARM64Source emits Darwin/ARM64 assembly directly from a bounded +// Plan 9 subset. It neither invokes cmd/asm nor reads Go object files. Entry and +// call registers are the physical registers written in the source; no function +// signature, Go stack adjustment, or register allocator is involved. +// +// All TEXT symbols must be file-local and NOSPLIT, with zero frame/argument sizes. +// Non-leaf code requires NOFRAME and must manage its C ABI frame itself. Only +// explicit registers, immediate/register arithmetic, base+offset memory operands, +// local branches, declared foreign calls and checked DATA/GLOBL are supported. +// See doc/native-arm64.md for the complete operand and directive contract. +func TranslateNativeARM64Source(src []byte, imports map[string]string, pkgPath string) (string, []NativeData, error) { + clean, err := nativeSource(src) + if err != nil { + return "", nil, err + } + f, err := Parse(ArchARM64, clean) + if err != nil { + return "", nil, err + } + if len(f.Funcs) == 0 { + return "", nil, fmt.Errorf("native assembly requires TEXT") + } + e := nativeARM64{funcs: map[string]string{}, globals: map[string]string{}, imports: imports} + for name, alias := range imports { + if !nativeName.MatchString(name) || !nativeName.MatchString(alias) { + return "", nil, fmt.Errorf("unsupported native import %q -> %q", name, alias) + } + } + for i, fn := range f.Funcs { + name := strings.TrimSuffix(fn.Sym, "<>") + if name == fn.Sym || !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("native TEXT must be file-local: %s", fn.Sym) + } + if _, ok := e.funcs[fn.Sym]; ok { + return "", nil, fmt.Errorf("duplicate native TEXT %s", fn.Sym) + } + e.funcs[fn.Sym] = fmt.Sprintf("Lnative_func_%d", i) + parts := strings.Split(fn.Instrs[0].Raw, ",") + if len(parts) != 3 || (strings.TrimSpace(parts[2]) != "$0" && strings.TrimSpace(parts[2]) != "$0-0") { + return "", nil, fmt.Errorf("native TEXT requires zero Go frame and arguments: %s", fn.Sym) + } + flags, err := nativeFlags(parts[1], "NOSPLIT", "NOFRAME") + if err != nil { + return "", nil, err + } + if !flags["NOSPLIT"] { + return "", nil, fmt.Errorf("native TEXT requires NOSPLIT: %s", fn.Sym) + } + for _, ins := range fn.Instrs { + if (ins.Op == "BL" || ins.Op == "CALL") && !flags["NOFRAME"] { + return "", nil, fmt.Errorf("native non-leaf TEXT requires NOFRAME: %s", fn.Sym) + } + } + } + var data []NativeData + for _, g := range f.Globl { + name := strings.TrimPrefix(g.Sym, "·") + if name == g.Sym || !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("native GLOBL must name a package global: %s", g.Sym) + } + if _, ok := e.globals[g.Sym]; ok { + return "", nil, fmt.Errorf("duplicate native GLOBL %s", g.Sym) + } + if g.Size <= 0 || g.Size > 64<<20 { + return "", nil, fmt.Errorf("unsupported native GLOBL size %d", g.Size) + } + if _, err := nativeFlags(g.Flags, "RODATA", "NOPTR"); err != nil { + return "", nil, err + } + e.globals[g.Sym] = "_" + pkgPath + "." + name + data = append(data, NativeData{pkgPath + "." + name, uint32(g.Size)}) + } + var out strings.Builder + for i, fn := range f.Funcs { + e.labels = map[string]string{} + for _, ins := range fn.Instrs { + if ins.Op == OpLABEL { + name := ins.Args[0].Sym + if !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("unsupported native label %s", name) + } + if _, ok := e.labels[name]; ok { + return "", nil, fmt.Errorf("duplicate native label %s", name) + } + e.labels[name] = fmt.Sprintf("Lnative_%d_label_%d", i, len(e.labels)) + } + } + fmt.Fprintf(&out, ".text\n.p2align 2\n%s:\n", e.funcs[fn.Sym]) + for _, ins := range fn.Instrs { + if err := e.instruction(&out, ins); err != nil { + return "", nil, fmt.Errorf("native %s: %s: %w", fn.Sym, ins.Raw, err) + } + } + } + for _, d := range f.Data { + if _, ok := e.globals[d.Sym]; !ok { + return "", nil, fmt.Errorf("native DATA has no GLOBL: %s", d.Sym) + } + } + for _, g := range f.Globl { + flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") + if flags["RODATA"] { + out.WriteString(".section __DATA_CONST,__const\n") + } else { + out.WriteString(".data\n") + } + label := strconv.Quote(e.globals[g.Sym]) + fmt.Fprintf(&out, ".p2align 3\n.globl %s\n%s:\n", label, label) + var values []DataStmt + for _, d := range f.Data { + if d.Sym == g.Sym { + values = append(values, d) + } + } + sort.Slice(values, func(i, j int) bool { return values[i].Off < values[j].Off }) + pos := int64(0) + for _, d := range values { + if d.Off < pos || d.Width <= 0 || d.Off > g.Size || d.Width > g.Size-d.Off { + return "", nil, fmt.Errorf("overlapping or out-of-bounds native DATA for %s", g.Sym) + } + if d.Off > pos { + fmt.Fprintf(&out, ".zero %d\n", d.Off-pos) + } + if d.Addr != "" { + if d.Width != 8 { + return "", nil, fmt.Errorf("native address DATA requires width 8") + } + target, err := e.symbol(d.Addr, true) + if err != nil { + return "", nil, err + } + fmt.Fprintf(&out, ".quad %s\n", target) + } else { + if d.Payload != nil { + return "", nil, fmt.Errorf("native string DATA is unsupported") + } + directive := map[int64]string{1: ".byte", 2: ".short", 4: ".long", 8: ".quad"}[d.Width] + if directive == "" { + return "", nil, fmt.Errorf("unsupported native DATA width %d", d.Width) + } + mask := ^uint64(0) + if d.Width < 8 { + mask = (uint64(1) << (8 * d.Width)) - 1 + } + fmt.Fprintf(&out, "%s %#x\n", directive, d.Value&mask) + } + pos = d.Off + d.Width + } + if pos < g.Size { + fmt.Fprintf(&out, ".zero %d\n", g.Size-pos) + } + } + return out.String(), data, nil +} + +var nativeName = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`) +var nativePseudoRegister = regexp.MustCompile(`\b(?:SP|FP|g|G|R18_PLATFORM|R18|R31|W[0-9]+)\b`) + +// The general parser deliberately tolerates ignored includes and symbolic +// placeholders. The native backend must reject these before parsing, rather +// than silently emitting guessed offsets or dropping conditional code. +func nativeSource(src []byte) (string, error) { + var b strings.Builder + for _, line := range strings.Split(string(src), "\n") { + line, _, _ = strings.Cut(line, "//") + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + if line != `#include "textflag.h"` { + return "", fmt.Errorf("unsupported native preprocessor directive: %s", line) + } + continue + } + if strings.Contains(line, "/*") || strings.ContainsAny(line, "\"\\") { + return "", fmt.Errorf("unsupported native source syntax: %s", line) + } + if nativePseudoRegister.MatchString(line) { + return "", fmt.Errorf("unsupported native register or Go stack operand: %s", line) + } + for _, stmt := range splitSemicolons(line) { + op, rest := splitOpcode(stmt) + // Enforce integer expressions before the permissive parser can invent a + // symbolic GLOBL size or interpret an integer operand as float bits. + if op == "GLOBL" { + parts := strings.Split(rest, ",") + if len(parts) != 3 { + return "", fmt.Errorf("invalid native GLOBL") + } + if _, ok := nativeInteger(strings.TrimSpace(parts[2])); !ok { + return "", fmt.Errorf("native GLOBL requires a constant integer size") + } + } + if op == "DATA" { + _, rhs, ok := strings.Cut(rest, ",") + rhs = strings.TrimSpace(rhs) + if !ok { + return "", fmt.Errorf("invalid native DATA") + } + if _, ok := nativeInteger(rhs); !ok && !(strings.HasPrefix(rhs, "$") && strings.HasSuffix(rhs, "(SB)")) { + return "", fmt.Errorf("unsupported native DATA initializer: %s", rhs) + } + } + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String(), nil +} +func nativeInteger(s string) (uint64, bool) { + if !strings.HasPrefix(s, "$") { + return 0, false + } + return parseImmExpr(strings.TrimPrefix(s, "$")) +} +func nativeFlags(s string, allowed ...string) (map[string]bool, error) { + flags := map[string]bool{} + for _, flag := range strings.Split(strings.TrimSpace(s), "|") { + flag = strings.TrimSpace(flag) + if flag == "0" { + continue + } + ok := false + for _, a := range allowed { + ok = ok || flag == a + } + if !ok || flags[flag] { + return nil, fmt.Errorf("unsupported native flag %q", flag) + } + flags[flag] = true + } + return flags, nil +} + +type nativeARM64 struct{ funcs, globals, imports, labels map[string]string } + +func (e *nativeARM64) symbol(s string, data bool) (string, error) { + if !strings.HasSuffix(s, "(SB)") { + return "", fmt.Errorf("unsupported native symbol %s", s) + } + name := strings.TrimSuffix(s, "(SB)") + if label, ok := e.funcs[name]; ok { + return label, nil + } + if data { + if label, ok := e.globals[name]; ok { + return strconv.Quote(label), nil + } + } + if alias, ok := e.imports[name]; ok { + return strconv.Quote("_" + alias), nil + } + return "", fmt.Errorf("undeclared foreign symbol or unsupported native reference %s", s) +} +func nativeReg(o Operand, bits int, sp bool) (string, error) { + if o.Kind != OpReg { + return "", fmt.Errorf("expected native register") + } + if o.Reg == SP && sp && bits == 64 { + return "sp", nil + } + if o.Reg == ZR { + if bits == 32 { + return "wzr", nil + } + return "xzr", nil + } + name := string(o.Reg) + if strings.HasPrefix(name, "R") { + if n, err := strconv.Atoi(name[1:]); err == nil && n >= 0 && n <= 30 && n != 18 { + prefix := "x" + if bits == 32 { + prefix = "w" + } + return fmt.Sprintf("%s%d", prefix, n), nil + } + } + return "", fmt.Errorf("unsupported native register %s", o.Reg) +} +func nativeFPReg(o Operand) (string, error) { + if o.Kind == OpReg && strings.HasPrefix(string(o.Reg), "F") { + if n, err := strconv.Atoi(string(o.Reg)[1:]); err == nil && n >= 0 && n < 32 { + return fmt.Sprintf("d%d", n), nil + } + } + return "", fmt.Errorf("expected native floating register") +} +func nativeMemory(o Operand, width int) (string, bool, error) { + m := o.Mem + if o.Kind != OpMem || m.Sym != "" || m.OffRaw != "" || m.Index != "" || m.Segment != "" { + return "", false, fmt.Errorf("native memory requires a constant offset and one base register") + } + base, err := nativeReg(Operand{Kind: OpReg, Reg: m.Base}, 64, true) + if err != nil || base == "xzr" { + return "", false, fmt.Errorf("invalid native memory base") + } + unscaled := false + if m.Off < 0 || m.Off%int64(width) != 0 || m.Off/int64(width) > 4095 { + if m.Off < -256 || m.Off > 255 { + return "", false, fmt.Errorf("native memory offset requires unsupported address expansion") + } + unscaled = true + } + return fmt.Sprintf("[%s, #%d]", base, m.Off), unscaled, nil +} +func (e *nativeARM64) branch(o Operand, external bool) (string, error) { + if external && o.Kind == OpSym { + return e.symbol(o.Sym, false) + } + name := o.Ident + if o.Kind == OpLabel { + name = o.Sym + } + if label, ok := e.labels[name]; ok { + return label, nil + } + return "", fmt.Errorf("undefined native branch label %s", o.String()) +} +func (e *nativeARM64) instruction(out *strings.Builder, ins Instr) error { + a := ins.Args + op := string(ins.Op) + if ins.Op != OpTEXT && ins.Op != OpLABEL { + _, rest := splitOpcode(ins.Raw) + parts := splitTopLevelCSV(rest) + if len(parts) != len(a) { + return fmt.Errorf("invalid native operand list") + } + for i, operand := range a { + if operand.Kind == OpImm { + if _, ok := nativeInteger(strings.TrimSpace(parts[i])); !ok { + return fmt.Errorf("native operand requires a constant integer") + } + } + } + } + + emit := func(op string, args ...string) { fmt.Fprintf(out, "\t%s %s\n", op, strings.Join(args, ", ")) } + bad := func() error { return fmt.Errorf("unsupported native operand form for %s", op) } + switch op { + case "TEXT": + return nil + case "LABEL": + fmt.Fprintf(out, "%s:\n", e.labels[a[0].Sym]) + return nil + case "RET": + if len(a) != 0 { + return bad() + } + emit("ret") + return nil + case "JMP", "B", "BL", "CALL": + if len(a) != 1 { + return bad() + } + dst, err := e.branch(a[0], true) + if err != nil { + return err + } + inst := "b" + if op == "BL" || op == "CALL" { + inst = "bl" + } + emit(inst, dst) + return nil + case "BEQ", "BNE", "BLT", "BLE", "BGT", "BGE", "BHS", "BLO", "BHI", "BLS", "BMI", "BPL", "BVS", "BVC": + if len(a) != 1 { + return bad() + } + dst, err := e.branch(a[0], false) + if err != nil { + return err + } + emit("b."+strings.ToLower(op[1:]), dst) + return nil + case "CBZ", "CBNZ": + if len(a) != 2 { + return bad() + } + reg, err := nativeReg(a[0], 64, false) + if err != nil { + return err + } + dst, err := e.branch(a[1], false) + if err != nil { + return err + } + emit(strings.ToLower(op), reg, dst) + return nil + case "MOVD", "MOVW", "MOVWU": + if len(a) != 2 { + return bad() + } + width := 8 + if op != "MOVD" { + width = 4 + } + if a[0].Kind == OpMem || a[1].Kind == OpMem { + mem, reg := a[0], a[1] + load := true + if a[1].Kind == OpMem { + mem, reg = a[1], a[0] + load = false + } + bits := width * 8 + if load && op == "MOVW" { + bits = 64 + } + r, err := nativeReg(reg, bits, false) + if err != nil { + return err + } + addr, unscaled, err := nativeMemory(mem, width) + if err != nil { + return err + } + inst := "ldr" + if !load { + inst = "str" + } + if unscaled { + if load { + inst = "ldur" + } else { + inst = "stur" + } + } + if load && op == "MOVW" { + inst += "sw" + } + emit(inst, r, addr) + return nil + } + dst, err := nativeReg(a[1], 64, op == "MOVD") + if err != nil { + return err + } + if a[0].Kind == OpImm { + if op != "MOVD" || dst == "sp" || a[0].ImmRaw != "" { + return bad() + } + // MOVD constant expansion uses only the destination; it never clobbers + // a hidden scratch register or the condition flags. + v := uint64(a[0].Imm) + emit("movz", dst, fmt.Sprintf("#%d", v&65535)) + for shift := 16; shift < 64; shift += 16 { + if half := (v >> shift) & 65535; half != 0 { + emit("movk", dst, fmt.Sprintf("#%d", half), fmt.Sprintf("lsl #%d", shift)) + } + } + return nil + } + bits := 64 + if op != "MOVD" { + bits = 32 + } + src, err := nativeReg(a[0], bits, op == "MOVD") + if err != nil { + return err + } + if op == "MOVW" { + emit("sxtw", dst, src) + } else if op == "MOVWU" { + emit("uxtw", dst, src) + } else { + if (src == "sp" && dst == "xzr") || (src == "xzr" && dst == "sp") { + return bad() + } + emit("mov", dst, src) + } + return nil + case "FMOVD": + if len(a) != 2 { + return bad() + } + src, se := nativeFPReg(a[0]) + dst, de := nativeFPReg(a[1]) + if se != nil && de != nil { + return bad() + } + if se != nil { + src, se = nativeReg(a[0], 64, false) + } + if de != nil { + dst, de = nativeReg(a[1], 64, false) + } + if se != nil || de != nil { + return bad() + } + emit("fmov", dst, src) + return nil + case "ADD", "SUB", "CMP", "CMPW", "LSL": + cmp := op == "CMP" || op == "CMPW" + if len(a) != 2 && (cmp || len(a) != 3) { + return bad() + } + bits := 64 + if op == "CMPW" { + bits = 32 + } + dstOp := a[len(a)-1] + srcOp := dstOp + if len(a) == 3 { + srcOp = a[1] + } + allowSP := op == "ADD" || op == "SUB" || cmp + src, err := nativeReg(srcOp, bits, allowSP) + if err != nil { + return err + } + dst, err := nativeReg(dstOp, bits, allowSP) + if err != nil { + return err + } + var rhs string + if a[0].Kind == OpImm && a[0].ImmRaw == "" { + limit := int64(4095) + if op == "LSL" { + limit = 63 + } + if a[0].Imm < 0 || a[0].Imm > limit { + return bad() + } + rhs = fmt.Sprintf("#%d", a[0].Imm) + if src == "xzr" || src == "wzr" || (!cmp && dst == "xzr") { + return bad() + } + } else { + rhs, err = nativeReg(a[0], bits, false) + if err != nil { + return err + } + // Register ADD/SUB with SP needs explicit extended-register encoding. + // Reject it rather than selecting an alias with different register-31 semantics. + if src == "sp" || dst == "sp" { + return bad() + } + } + if cmp { + emit("cmp", src, rhs) + } else { + emit(strings.ToLower(op), dst, src, rhs) + } + return nil + } + return fmt.Errorf("unsupported native instruction %s", op) +} diff --git a/native_arm64_source_test.go b/native_arm64_source_test.go new file mode 100644 index 00000000..72376641 --- /dev/null +++ b/native_arm64_source_test.go @@ -0,0 +1,322 @@ +package plan9asm + +import ( + "debug/macho" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const nativeCallbackSource = `#include "textflag.h" +TEXT callback<>(SB), NOSPLIT|NOFRAME, $0 + SUB $16, RSP + MOVD R30, (RSP) + BL imported_strlen(SB) + MOVD (RSP), R30 + ADD $16, RSP + RET +GLOBL ·entry(SB), RODATA, $8 +DATA ·entry(SB)/8, $callback<>(SB) +TEXT mixedtramp<>(SB), NOSPLIT, $0-0 + FMOVD R1, F0 + MOVD $0x11, R1 + JMP imported_mixed(SB) +GLOBL ·mixedEntry(SB), RODATA, $8 +DATA ·mixedEntry(SB)/8, $mixedtramp<>(SB) +` + +func TestNativeARM64Source(t *testing.T) { + imports := map[string]string{"imported_strlen": "strlen", "imported_mixed": "mixed"} + assembly, data, err := TranslateNativeARM64Source([]byte(nativeCallbackSource), imports, "probe") + if err != nil { + t.Fatal(err) + } + if len(data) != 2 || data[0] != (NativeData{"probe.entry", 8}) { + t.Fatalf("data=%v", data) + } + for _, want := range []string{`bl "_strlen"`, `.quad Lnative_func_0`, "fmov d0, x1", ".section __DATA_CONST,__const"} { + if !strings.Contains(assembly, want) { + t.Fatalf("missing %q:\n%s", want, assembly) + } + } + nativeCompileAndRun(t, assembly, `extern void *entry __asm("_probe.entry"); +extern void *mixedEntry __asm("_probe.mixedEntry"); +unsigned long mixed(unsigned long x, unsigned long flags, double y) { + return flags == 17 ? x + (unsigned long)y : 999; +} +int main(void) { + if (((unsigned long (*)(const char *))entry)("native ABI") != 10) return 1; + return ((unsigned long (*)(unsigned long, unsigned long))mixedEntry)(5, 0x4000000000000000UL) != 7; +}`) +} + +// Compile the generated Mach-O assembly on every LLVM host; only execution +// needs Darwin/ARM64. No test or implementation reads a Go object format. +func nativeCompileAndRun(t *testing.T, assembly, harness string) { + t.Helper() + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang not installed") + } + dir := t.TempDir() + src := filepath.Join(dir, "native.s") + object := filepath.Join(dir, "native.o") + if err := os.WriteFile(src, []byte(assembly), 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(clang, "--target=arm64-apple-darwin", "-c", src, "-o", object) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("assemble: %v\n%s\n%s", err, b, assembly) + } + obj, err := macho.Open(object) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + if obj.Cpu != macho.CpuArm64 { + t.Fatal("wrong architecture") + } + constant := obj.Section("__const") + if constant == nil || constant.Seg != "__DATA_CONST" { + t.Fatal("lost read-only DATA placement") + } + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Log("Mach-O object validated; native execution requires darwin/arm64") + return + } + c := filepath.Join(dir, "main.c") + exe := filepath.Join(dir, "probe") + if err := os.WriteFile(c, []byte(harness), 0600); err != nil { + t.Fatal(err) + } + cmd = exec.Command(clang, object, c, "-o", exe) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("link: %v\n%s", err, b) + } + if b, err := exec.Command(exe).CombinedOutput(); err != nil { + t.Fatalf("run: %v\n%s", err, b) + } +} + +func TestNativeARM64RegistersAndMemory(t *testing.T) { + src := `TEXT probe<>(SB), NOSPLIT, $0 + MOVD $0x123456789abcdef0, R2 + MOVD R2, (R0) + MOVD R1, 16(R0) + MOVD 16(R0), R3 + MOVW 8(R0), R4 + MOVWU 8(R0), R5 + MOVD R4, 24(R0) + MOVD R5, 32(R0) + MOVW R4, R6 + MOVWU R4, R7 + MOVD R6, 40(R0) + MOVD R7, 48(R0) + MOVW R4, 56(R0) + MOVD $0x1122334455667788, R8 + MOVD R8, -8(R1) + MOVD -8(R1), R9 + CMP R8, R9 + BNE fail + ADD $64, R0, R10 + MOVD R8, 1(R10) + MOVD 1(R10), R9 + CMP R8, R9 + BNE fail + FMOVD R8, F0 + FMOVD F0, F1 + FMOVD F1, R9 + CMP R8, R9 + BNE fail + MOVD $0, R4 + CBZ R4, zero + B fail +zero: + MOVD $4, R4 + LSL $2, R4, R5 + ADD R4, R5, R5 + SUB R4, R5, R5 + CMP $16, R5 + BNE fail +loop: + SUB $1, R4 + CBNZ R4, loop + CMPW $0, R4 + BNE fail + MOVD $0, R0 + RET +fail: + MOVD $1, R0 + RET +GLOBL ·entry(SB), RODATA, $8 +DATA ·entry(SB)/8, $probe<>(SB) +` + assembly, _, err := TranslateNativeARM64Source([]byte(src), nil, "probe") + if err != nil { + t.Fatal(err) + } + nativeCompileAndRun(t, assembly, `#include +extern void *entry __asm("_probe.entry"); +int main(void) { + uint64_t memory[10] = {0,0x80000000}; + uint64_t other[2] = {0}; + int status=((int (*)(uint64_t *,uint64_t *))entry)(memory,other+1); + if(status) return status; + if(memory[0]!=0x123456789abcdef0ULL) return 2; + if(memory[2]!=(uintptr_t)(other+1)) return 3; + if(memory[3]!=0xffffffff80000000ULL || memory[4]!=0x80000000ULL) return 4; + if(memory[5]!=memory[3] || memory[6]!=memory[4]) return 5; + if(memory[7]!=0x80000000ULL || other[0]!=0x1122334455667788ULL) return 6; + return 0; +}`) +} + +func TestNativeARM64RejectsUnsupportedSource(t *testing.T) { + const prefix = "TEXT raw<>(SB), NOSPLIT|NOFRAME, $0\n" + tests := []struct{ name, source, want string }{ + {"Go ABI", "TEXT ·goFunc(SB), NOSPLIT, $0\nRET\n", "file-local"}, + {"frame", "TEXT raw<>(SB), NOSPLIT, $8-0\nRET\n", "zero Go frame"}, + {"args", "TEXT raw<>(SB), NOSPLIT, $0-8\nRET\n", "zero Go frame"}, + {"missing nosplit", "TEXT raw<>(SB), NOFRAME, $0\nRET\n", "NOSPLIT"}, + {"implicit frame", "TEXT raw<>(SB), NOSPLIT, $0\nBL imported(SB)\nRET\n", "NOFRAME"}, + {"flags", "TEXT raw<>(SB), NOSPLIT|WRAPPER, $0\nRET\n", "flag"}, + {"undeclared", prefix + "BL missing(SB)\n", "undeclared foreign"}, + {"package call", prefix + "BL runtime·foo(SB)\n", "undeclared foreign"}, + {"unknown", prefix + "NOT_AN_INSTRUCTION\n", "unsupported native instruction"}, + {"raw opcode", prefix + "WORD $0xd65f03c0\n", "unsupported native instruction"}, + {"indirect call", prefix + "BL (R0)\n", "undefined native branch"}, + {"postincrement", prefix + "MOVD.P 8(R0), R1\n", "unsupported native instruction"}, + {"indexed", prefix + "MOVD (R0)(R1), R2\n", "native memory"}, + {"large displacement", prefix + "MOVD 32768(R0), R1\n", "address expansion"}, + {"Go FP", prefix + "MOVD arg+0(FP), R0\n", "Go stack"}, + {"Go SP", prefix + "MOVD 8(SP), R0\n", "Go stack"}, + {"Go g", prefix + "MOVD g, R0\n", "Go stack"}, + {"reserved", prefix + "MOVD R18, R0\n", "Go stack"}, + {"ambiguous31", prefix + "MOVD R31, R0\n", "Go stack"}, + {"unknown label", prefix + "BNE nowhere\n", "undefined native branch"}, + {"branch addend", prefix + "BL imported+4(SB)\n", "undeclared foreign"}, + {"immediate expansion", prefix + "ADD $4096, R0\n", "operand form"}, + {"float immediate", prefix + "MOVD $1.0, R0\n", "constant integer"}, + {"symbolic immediate", prefix + "MOVD $(unknown + 8), R0\n", "constant integer"}, + {"include", "#include \"go_asm.h\"\n" + prefix + "RET\n", "preprocessor"}, + {"conditional", "#if 0\n" + prefix + "RET\n#endif\n", "preprocessor"}, + {"macro", "#define NAME RET\n" + prefix + "NAME\n", "preprocessor"}, + {"data size", prefix + "RET\nGLOBL ·p(SB), RODATA, $(unknown + 8)\n", "constant integer"}, + {"data missing", prefix + "RET\nDATA ·p(SB)/8, $raw<>(SB)\n", "no GLOBL"}, + {"data bounds", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p+4(SB)/8, $raw<>(SB)\n", "out-of-bounds"}, + {"data overlap", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p(SB)/8, $raw<>(SB)\nDATA ·p+4(SB)/4, $1\n", "overlapping"}, + {"data addend", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p(SB)/8, $raw<>+4(SB)\n", "undeclared foreign"}, + {"data pointer width", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p(SB)/4, $raw<>(SB)\n", "width 8"}, + {"data flags", prefix + "RET\nGLOBL ·p(SB), DUPOK, $8\n", "flag"}, + {"duplicate", prefix + "RET\n" + prefix + "RET\n", "duplicate"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assembly, data, err := TranslateNativeARM64Source([]byte(tc.source), map[string]string{"imported": "strlen"}, "probe") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err=%v; want %q", err, tc.want) + } + if assembly != "" || data != nil { + t.Fatal("returned partial output on error") + } + }) + } +} + +func TestForeignARM64Selection(t *testing.T) { + for _, tc := range []struct { + src string + want bool + }{ + {nativeCallbackSource, true}, + {"TEXT ·declared(SB), NOSPLIT, $0\nRET", false}, + {"TEXT local<>(SB), NOSPLIT, $8\nRET", true}, // route and reject; never guess void() + {"TEXT local<>(SB), NOSPLIT, $0\nRET\nTEXT ·declared(SB), NOSPLIT, $0\nRET", false}, + } { + if got := len(ForeignARM64Functions([]byte(tc.src))) != 0; got != tc.want { + t.Fatalf("selection=%v want %v for %s", got, tc.want, tc.src) + } + } +} + +func TestNativeARM64CompareConditions(t *testing.T) { + conditions := []struct{ op, expr string }{ + {"BEQ", "a == b"}, {"BNE", "a != b"}, + {"BLT", "(int64_t)b < (int64_t)a"}, {"BLE", "(int64_t)b <= (int64_t)a"}, + {"BGT", "(int64_t)b > (int64_t)a"}, {"BGE", "(int64_t)b >= (int64_t)a"}, + {"BHS", "b >= a"}, {"BLO", "b < a"}, {"BHI", "b > a"}, {"BLS", "b <= a"}, + {"BMI", "(int64_t)(b-a) < 0"}, {"BPL", "(int64_t)(b-a) >= 0"}, + {"BVS", "((b^a)&(b^(b-a))) >> 63"}, {"BVC", "!(((b^a)&(b^(b-a))) >> 63)"}, + } + var source, c strings.Builder + c.WriteString("#include \n") + for i, condition := range conditions { + // MOVD's multi-instruction expansion must not overwrite CMP's flags. + fmt.Fprintf(&source, `TEXT compare%d<>(SB), NOSPLIT, $0 + CMP R0, R1 + MOVD $0x1122334455667788, R2 + %s yes + MOVD $0, R0 + RET +yes: + MOVD $1, R0 + RET +GLOBL ·compare%d(SB), RODATA, $8 +DATA ·compare%d(SB)/8, $compare%d<>(SB) +`, i, condition.op, i, i, i) + fmt.Fprintf(&c, "extern void *compare%d __asm(\"_probe.compare%d\");\n", i, i) + } + c.WriteString("int main(void) { uint64_t values[]={0,1,17,0x7fffffffffffffffULL,0x8000000000000000ULL,0xffffffffffffffffULL}; for(int i=0;i<6;i++) for(int j=0;j<6;j++){uint64_t a=values[i],b=values[j];\n") + for i, condition := range conditions { + fmt.Fprintf(&c, "if (((int (*)(uint64_t,uint64_t))compare%d)(a,b) != !!(%s)) return %d;\n", i, condition.expr, i+1) + } + c.WriteString("} return 0; }") + assembly, _, err := TranslateNativeARM64Source([]byte(source.String()), nil, "probe") + if err != nil { + t.Fatal(err) + } + nativeCompileAndRun(t, assembly, c.String()) +} + +func TestNativeARM64DataAndLocalCalls(t *testing.T) { + source := `TEXT caller<>(SB), NOSPLIT|NOFRAME, $0 + SUB $16, RSP + MOVD R30, (RSP) + CALL helper<>(SB) + MOVD (RSP), R30 + ADD $16, RSP + RET +TEXT helper<>(SB), NOSPLIT, $0 + ADD $3, R0 + RET +GLOBL ·entry(SB), RODATA, $8 +DATA ·entry(SB)/8, $caller<>(SB) +GLOBL ·bytes(SB), NOPTR, $24 +DATA ·bytes+0(SB)/1, $0x12 +DATA ·bytes+2(SB)/2, $0x3456 +DATA ·bytes+4(SB)/4, $0x789abcde +DATA ·bytes+8(SB)/8, $-1 +GLOBL ·address(SB), RODATA, $8 +DATA ·address(SB)/8, $·bytes(SB) +` + assembly, _, err := TranslateNativeARM64Source([]byte(source), nil, "probe") + if err != nil { + t.Fatal(err) + } + nativeCompileAndRun(t, assembly, `#include +extern void *entry __asm("_probe.entry"); +extern unsigned char bytes[24] __asm("_probe.bytes"); +extern void *address __asm("_probe.address"); +int main(void) { + if(((uint64_t (*)(uint64_t))entry)(39)!=42) return 1; + if(address!=bytes) return 2; + if(bytes[0]!=0x12 || bytes[1]!=0 || bytes[2]!=0x56 || bytes[3]!=0x34) return 3; + if(*(uint32_t *)(bytes+4)!=0x789abcde) return 4; + if(*(uint64_t *)(bytes+8)!=~0ULL || *(uint64_t *)(bytes+16)!=0) return 5; + bytes[0]=42; return bytes[0]!=42; +}`) +} diff --git a/parser.go b/parser.go index 963dd50f..ae63e067 100644 --- a/parser.go +++ b/parser.go @@ -286,6 +286,7 @@ func parseDATAStmt(arch Arch, rest string) (DataStmt, error) { val, ok := parseImm(rhs) var payload []byte + var addr string if !ok { trimRHS := strings.TrimSpace(rhs) if strings.HasPrefix(trimRHS, "$\"") { @@ -300,7 +301,8 @@ func parseDATAStmt(arch Arch, rest string) (DataStmt, error) { // Accept symbol-address initializers (e.g. $runtime·main(SB)) even when // relocation details are not modeled; encode as zero placeholder. if strings.HasPrefix(strings.TrimSpace(rhs), "$") { - if _, symOK := parseSym(strings.TrimPrefix(strings.TrimSpace(rhs), "$")); symOK { + if sym, symOK := parseSym(strings.TrimPrefix(strings.TrimSpace(rhs), "$")); symOK { + addr = sym val = 0 ok = true } @@ -309,7 +311,7 @@ func parseDATAStmt(arch Arch, rest string) (DataStmt, error) { if !ok { return DataStmt{}, fmt.Errorf("DATA invalid immediate %q: %q", rhs, "DATA "+rest) } - return DataStmt{Sym: sym, Off: off, Width: width, Value: uint64(val), Payload: payload}, nil + return DataStmt{Sym: sym, Off: off, Width: width, Value: uint64(val), Payload: payload, Addr: addr}, nil } func parseWidth(arch Arch, s string) (int64, error) { diff --git a/types.go b/types.go index fa36d02b..0c23182d 100644 --- a/types.go +++ b/types.go @@ -854,6 +854,8 @@ type Instr struct { // global. String payloads are copied byte-for-byte and zero-padded to Width, // matching cmd/asm's DATA string semantics. type DataStmt struct { + // Addr retains a symbol-address initializer, including its (SB) suffix. + Addr string Sym string Off int64 Width int64 From 217011737355b4888934e4bf57d8b3748aacb498 Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Wed, 23 Sep 2026 16:18:06 +0800 Subject: [PATCH 2/6] test: cover native source validation boundaries --- native_arm64_source_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/native_arm64_source_test.go b/native_arm64_source_test.go index 72376641..7be676ef 100644 --- a/native_arm64_source_test.go +++ b/native_arm64_source_test.go @@ -178,6 +178,26 @@ int main(void) { func TestNativeARM64RejectsUnsupportedSource(t *testing.T) { const prefix = "TEXT raw<>(SB), NOSPLIT|NOFRAME, $0\n" tests := []struct{ name, source, want string }{ + {"empty", "", "no TEXT"}, + {"data only", "GLOBL ·p(SB), RODATA, $8\n", "requires TEXT"}, + {"invalid local name", "TEXT bad-name<>(SB), NOSPLIT, $0\nRET\n", "file-local"}, + {"duplicate label", prefix + "here:\nRET\nhere:\nRET\n", "duplicate native label"}, + {"invalid global name", prefix + "RET\nGLOBL p<>(SB), RODATA, $8\n", "package global"}, + {"duplicate global", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nGLOBL ·p(SB), RODATA, $8\n", "duplicate native GLOBL"}, + {"empty global", prefix + "RET\nGLOBL ·p(SB), RODATA, $0\n", "GLOBL size"}, + {"huge global", prefix + "RET\nGLOBL ·p(SB), RODATA, $67108865\n", "GLOBL size"}, + {"data width", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p(SB)/3, $1\n", "DATA width"}, + {"data string", prefix + "RET\nGLOBL ·p(SB), RODATA, $8\nDATA ·p(SB)/3, $\"abc\"\n", "source syntax"}, + {"block comment", prefix + "/* ignored */ RET\n", "source syntax"}, + {"FP to FP integer move", prefix + "MOVD F0, R0\n", "unsupported native register"}, + {"floating move without FP", prefix + "FMOVD R0, R1\n", "operand form"}, + {"stack register arithmetic", prefix + "ADD R0, RSP\n", "operand form"}, + {"stack immediate move", prefix + "MOVD $0, RSP\n", "operand form"}, + {"narrow immediate move", prefix + "MOVW $1, R0\n", "operand form"}, + {"zero memory base", prefix + "MOVD (ZR), R0\n", "memory base"}, + {"zero immediate arithmetic", prefix + "ADD $1, ZR\n", "operand form"}, + {"explicit return operand", prefix + "RET R0\n", "operand form"}, + {"Go ABI", "TEXT ·goFunc(SB), NOSPLIT, $0\nRET\n", "file-local"}, {"frame", "TEXT raw<>(SB), NOSPLIT, $8-0\nRET\n", "zero Go frame"}, {"args", "TEXT raw<>(SB), NOSPLIT, $0-8\nRET\n", "zero Go frame"}, @@ -320,3 +340,14 @@ int main(void) { bytes[0]=42; return bytes[0]!=42; }`) } + +func TestNativeARM64RejectsInvalidImport(t *testing.T) { + for _, imports := range []map[string]string{ + {"bad.name": "strlen"}, {"imported_strlen": "bad+8"}, + } { + assembly, data, err := TranslateNativeARM64Source([]byte(nativeCallbackSource), imports, "probe") + if err == nil || !strings.Contains(err.Error(), "unsupported native import") || assembly != "" || data != nil { + t.Fatalf("invalid import returned %q, %v, %v", assembly, data, err) + } + } +} From 5246d1dfa1cab213480672c5c52e3d10838fbd5c Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Wed, 23 Sep 2026 22:36:59 +0800 Subject: [PATCH 3/6] native: share source backend across Linux and Darwin targets --- doc/native-arm64.md | 59 ++++++- native_amd64_source.go | 199 ++++++++++++++++++++++ native_arm64_source.go | 294 +-------------------------------- native_source.go | 364 +++++++++++++++++++++++++++++++++++++++++ native_source_test.go | 292 +++++++++++++++++++++++++++++++++ 5 files changed, 910 insertions(+), 298 deletions(-) create mode 100644 native_amd64_source.go create mode 100644 native_source.go create mode 100644 native_source_test.go diff --git a/doc/native-arm64.md b/doc/native-arm64.md index ee2d4e1a..0772939c 100644 --- a/doc/native-arm64.md +++ b/doc/native-arm64.md @@ -1,8 +1,10 @@ -# Native Darwin/ARM64 backend +# Native physical-register backend -`TranslateNativeARM64Source` translates a restricted Plan 9 source file to -Darwin/ARM64 assembly. A native assembler such as LLVM's integrated assembler -creates the final Mach-O object. There is no `go tool asm` invocation, dependency +`TranslateNativeSource` takes explicit `NativeOptions` (`GOOS`, `GOARCH`, +`PackagePath`, and `Imports`) and translates a restricted Plan 9 source file to +native assembly. The implemented targets are Linux and Darwin, each on amd64 +and arm64. A native assembler such as LLVM's integrated assembler +creates the final ELF or Mach-O object. There is no `go tool asm` invocation, dependency on a Go object layout, instruction-byte copying, or guessed LLVM function type. The contract is **physical register preservation**, not inferred C typing. A @@ -12,7 +14,7 @@ alignment, saving/restoring the link register, callee-saved registers and any required native frame. This backend supplies no Go ABI wrappers, stack growth, GC stack maps, exception/unwind metadata or transitions into the Go runtime. -`ForeignARM64Functions` is only a routing hint: it reports a file composed entirely +`ForeignNativeFunctions` is only a routing hint: it reports a file composed entirely of file-local TEXT definitions. Local linkage does not prove a C ABI. A driver must explicitly choose native translation in a foreign-call context and report native translation errors rather than retrying with invented signatures. @@ -22,7 +24,9 @@ it no longer silently receives `void()`. ## Source contract -- The output target is Darwin/ARM64 only, independent of the build host. +- The output target is explicit and independent of the build host. Unsupported + target pairs are errors. `TranslateNativeARM64Source` remains a compatibility + wrapper for Darwin/ARM64. - Every `TEXT` is file-local (`name<>`), has `NOSPLIT`, and declares `$0` or `$0-0`. `NOFRAME` is also required if the body uses `BL` or `CALL`. No implicit Go prologue or epilogue is generated, including for zero-frame functions. @@ -30,7 +34,7 @@ it no longer silently receives `void()`. - Only `#include "textflag.h"` is accepted. Other includes, macros, conditional preprocessing, and block comments are currently rejected. Line comments and constant integer expressions are accepted. -- Integer registers are `R0`–`R30` except Darwin's reserved `R18`, plus `ZR`. +- On ARM64, integer registers are `R0`–`R30` except `R18` (reserved conservatively on both OSes), plus `ZR`. `RSP` is accepted in supported stack-pointer forms; `SP`, `FP`, `g`, `R31`, `Wn` aliases, and platform aliases are rejected. Floating registers are `F0`–`F31`. - Local function/branch/import identifiers use ASCII letters, digits and `_`, @@ -43,11 +47,12 @@ it no longer silently receives `void()`. `GLOBL` allocation and must not overlap another initializer. Gaps are zero-filled. - `GLOBL` requires a constant size from 1 byte to 64 MiB. Supported flags are `0`, `RODATA` and `NOPTR`. Definitions are 8-byte aligned. `RODATA` is placed in - `__DATA_CONST,__const`, allowing address fixups before becoming read-only; mutable + `__DATA_CONST,__const` on Mach-O and `.data.rel.ro` on ELF, allowing address + fixups before becoming read-only under a RELRO-enabled linker; mutable definitions use `.data`. The returned DATA metadata lets the driver verify the Go global size and bind its definition to the native object. -## Instruction and operand forms +## ARM64 instruction and operand forms | Plan 9 operation | Supported forms and native semantics | | --- | --- | @@ -78,3 +83,39 @@ register copies, signed and unsigned loads, constant expansion, negative and unaligned memory offsets, and local control flow. Rejection tests exercise the unsupported forms above. The parser retains address initializers explicitly; they are never inferred by decoding an object relocation. + +## AMD64 instruction and operand forms + +The native ABI is SysV AMD64 on Linux and the corresponding Darwin x86-64 ABI +on macOS. Physical registers are AX/BX/CX/DX/SI/DI/BP/SP and R8 through R15; +MOVQ also supports bit transfers between X0 through X15 and integer registers +or base+offset memory. MOVL writes zero-extend the destination register. +`SP` means the physical stack pointer only: named Go stack slots and `FP` are +rejected. Callers and source code own stack alignment and callee-saved registers. + +The bounded instruction set is MOVQ/MOVL, LEAQ (memory to register), +ADD/SUB/AND/OR/XOR/CMP/TEST in Q and L widths, immediate SHL/SHR/SAR in Q and L +widths, CALL/JMP, RET, and JEQ/JNE/JLT/JLE/JGT/JGE/JCS/JCC/JHI/JLS/JMI/JPL/JOS/JOC. +CMP reverses operands when emitting AT&T syntax to preserve Go comparison order. +Memory has one base and a signed 32-bit constant displacement; indices, segment +registers, named offsets, indirect calls, byte/word operations, and other forms +are rejected. Q-width arithmetic immediates must fit signed 32 bits; MOVQ to a +register can load a full 64-bit value. Shifts require counts below operand width. +No implicit prologue, scratch register, Go ABI wrapper, or unwind metadata is added. + +## Target format and execution matrix + +Shared validation, TEXT/DATA/GLOBL handling and symbol resolution are separate +from instruction lowering. Darwin symbols receive a leading underscore. ELF +symbols do not; AMD64 imported calls use PLT references, ARM64 imported calls use +native CALL26/JUMP26 relocations. ELF emits function type/size and non-executable +GNU-stack metadata. Native assemblers and linkers perform instruction encoding +and relocations; the backend does not read or depend on Go object files. + +`TestNativeTargetMatrix` cross-assembles all four combinations, checks object +headers/data placement, and executes the C ABI harness on the matching host. +Linux harnesses may also run locally via explicitly configured Docker images: +`PLAN9ASM_NATIVE_DOCKER` (amd64), `PLAN9ASM_NATIVE_DOCKER_ARM64` (arm64). +They verify foreign calls, integer/FP argument shuffles, returns, physical stack +frames, and data-address relocation in PIE executables. Unsupported targets such +as Windows require their own ABI/format qualification before being enabled. diff --git a/native_amd64_source.go b/native_amd64_source.go new file mode 100644 index 00000000..ae624b5f --- /dev/null +++ b/native_amd64_source.go @@ -0,0 +1,199 @@ +package plan9asm + +import ( + "fmt" + "strconv" + "strings" +) + +type nativeAMD64 struct{ *nativeEmitter } + +func nativeAMD64Reg(o Operand, bits int) (string, error) { + if o.Kind != OpReg { + return "", fmt.Errorf("expected amd64 register") + } + names := map[Reg][2]string{AX: {"eax", "rax"}, BX: {"ebx", "rbx"}, CX: {"ecx", "rcx"}, DX: {"edx", "rdx"}, SI: {"esi", "rsi"}, DI: {"edi", "rdi"}, SP: {"esp", "rsp"}, BP: {"ebp", "rbp"}} + if n, ok := names[o.Reg]; ok { + if bits == 32 { + return "%" + n[0], nil + } + return "%" + n[1], nil + } + name := string(o.Reg) + if strings.HasPrefix(name, "R") { + n, err := strconv.Atoi(name[1:]) + if err == nil && n >= 8 && n <= 15 { + suffix := "" + if bits == 32 { + suffix = "d" + } + return fmt.Sprintf("%%r%d%s", n, suffix), nil + } + } + return "", fmt.Errorf("unsupported amd64 register %s", name) +} + +func nativeAMD64Operand(o Operand, bits int) (string, error) { + switch o.Kind { + case OpReg: + return nativeAMD64Reg(o, bits) + case OpImm: + if o.ImmRaw != "" { + return "", fmt.Errorf("unresolved native immediate") + } + return fmt.Sprintf("$%d", o.Imm), nil + case OpMem: + m := o.Mem + if m.Sym != "" || m.OffRaw != "" || m.Index != "" || m.Segment != "" || m.Off < -1<<31 || m.Off > 1<<31-1 { + break + } + base, err := nativeAMD64Reg(Operand{Kind: OpReg, Reg: m.Base}, 64) + if err != nil { + return "", err + } + return fmt.Sprintf("%d(%s)", m.Off, base), nil + } + return "", fmt.Errorf("unsupported amd64 operand %s", o.String()) +} + +func nativeAMD64XMM(o Operand) (string, bool) { + if o.Kind != OpReg || !strings.HasPrefix(string(o.Reg), "X") { + return "", false + } + n, err := strconv.Atoi(string(o.Reg)[1:]) + if err != nil || n < 0 || n > 15 { + return "", false + } + return fmt.Sprintf("%%xmm%d", n), true +} + +func (e *nativeAMD64) instruction(out *strings.Builder, ins Instr) error { + a := ins.Args + op := string(ins.Op) + bad := func() error { return fmt.Errorf("unsupported native amd64 operand form for %s", op) } + emit := func(op string, args ...string) { fmt.Fprintf(out, "\t%s %s\n", op, strings.Join(args, ", ")) } + if ins.Op != OpTEXT && ins.Op != OpLABEL { + _, rest := splitOpcode(ins.Raw) + parts := splitTopLevelCSV(rest) + if len(parts) != len(a) { + return bad() + } + for i, o := range a { + if o.Kind == OpImm { + if _, ok := nativeInteger(strings.TrimSpace(parts[i])); !ok { + return bad() + } + } + } + } + switch op { + case "TEXT": + return nil + case "LABEL": + fmt.Fprintf(out, "%s:\n", e.labels[a[0].Sym]) + return nil + case "RET": + if len(a) != 0 { + return bad() + } + emit("ret") + return nil + case "CALL", "JMP": + if len(a) != 1 { + return bad() + } + dst, err := e.branch(a[0], true) + if err != nil { + return err + } + // ELF interposable function references use PLT relocations; local TEXT does not. + if e.target.GOOS == "linux" && a[0].Kind == OpSym { + name := strings.TrimSuffix(a[0].Sym, "(SB)") + if _, ok := e.imports[name]; ok { + dst += "@PLT" + } + } + emit(strings.ToLower(op), dst) + return nil + case "JEQ", "JNE", "JLT", "JLE", "JGT", "JGE", "JCS", "JCC", "JHI", "JLS", "JMI", "JPL", "JOS", "JOC": + if len(a) != 1 { + return bad() + } + dst, err := e.branch(a[0], false) + if err != nil { + return err + } + inst := map[string]string{"JEQ": "je", "JNE": "jne", "JLT": "jl", "JLE": "jle", "JGT": "jg", "JGE": "jge", "JCS": "jb", "JCC": "jae", "JHI": "ja", "JLS": "jbe", "JMI": "js", "JPL": "jns", "JOS": "jo", "JOC": "jno"}[op] + emit(inst, dst) + return nil + } + if len(a) != 2 { + return bad() + } + bits := 64 + if strings.HasSuffix(op, "L") { + bits = 32 + } + allowed := map[string]bool{"MOVQ": true, "MOVL": true, "LEAQ": true, "ADDQ": true, "ADDL": true, "SUBQ": true, "SUBL": true, "XORQ": true, "XORL": true, "ANDQ": true, "ANDL": true, "ORQ": true, "ORL": true, "CMPQ": true, "CMPL": true, "TESTQ": true, "TESTL": true, "SHLQ": true, "SHLL": true, "SHRQ": true, "SHRL": true, "SARQ": true, "SARL": true} + if !allowed[op] { + return bad() + } + // MOVQ between XMM and integer registers is a bit transfer, not conversion. + if op == "MOVQ" { + x0, ok0 := nativeAMD64XMM(a[0]) + x1, ok1 := nativeAMD64XMM(a[1]) + if ok0 || ok1 { + if ok0 && ok1 { + return bad() + } + if ok0 { + r, err := nativeAMD64Operand(a[1], 64) + if err != nil || a[1].Kind == OpImm { + return bad() + } + emit("movq", x0, r) + } else { + r, err := nativeAMD64Operand(a[0], 64) + if err != nil || a[0].Kind == OpImm { + return bad() + } + emit("movq", r, x1) + } + return nil + } + } + // Go CMP places the minuend first, unlike AT&T syntax. + if op == "CMPQ" || op == "CMPL" { + a = []Operand{a[1], a[0]} + } + if a[1].Kind == OpImm || (a[0].Kind == OpMem && a[1].Kind == OpMem) { + return bad() + } + if op == "LEAQ" && (a[0].Kind != OpMem || a[1].Kind != OpReg) { + return bad() + } + shift := strings.HasPrefix(op, "SHL") || strings.HasPrefix(op, "SHR") || strings.HasPrefix(op, "SAR") + if shift && (a[0].Kind != OpImm || a[0].Imm < 0 || a[0].Imm >= int64(bits)) { + return bad() + } + if a[0].Kind == OpImm && !shift { + v := a[0].Imm + if bits == 32 { + if v < -1<<31 || v > 1<<32-1 { + return bad() + } + } else if !(op == "MOVQ" && a[1].Kind == OpReg) && (v < -1<<31 || v > 1<<31-1) { + return bad() + } + } + src, err := nativeAMD64Operand(a[0], bits) + if err != nil { + return err + } + dst, err := nativeAMD64Operand(a[1], bits) + if err != nil { + return err + } + emit(strings.ToLower(op), src, dst) + return nil +} diff --git a/native_arm64_source.go b/native_arm64_source.go index ef8ee124..3b6595dc 100644 --- a/native_arm64_source.go +++ b/native_arm64_source.go @@ -2,293 +2,22 @@ package plan9asm import ( "fmt" - "regexp" - "sort" "strconv" "strings" ) -// NativeData describes a Go global whose storage is supplied by native assembly. -type NativeData struct { - Name string - Size uint32 -} - -// ForeignARM64Functions identifies files containing only file-local TEXT symbols. -// This is a routing hint, NOT an inference of a C function signature. The native -// backend validates frames, flags, instructions, and references independently. -// Files with package-visible TEXT must use the typed backend and explicit Go -// declarations. Unsupported native forms must not fall back to signature guessing. +// ForeignARM64Functions is the Darwin/ARM64 compatibility routing helper. func ForeignARM64Functions(src []byte) map[string]bool { - f, err := Parse(ArchARM64, string(src)) - if err != nil || len(f.Funcs) == 0 { - return nil - } - result := make(map[string]bool) - for _, fn := range f.Funcs { - if !strings.HasSuffix(fn.Sym, "<>") { - return nil - } - result[strings.TrimSuffix(fn.Sym, "<>")] = true - } - return result + return ForeignNativeFunctions(src, "arm64") } -// TranslateNativeARM64Source emits Darwin/ARM64 assembly directly from a bounded -// Plan 9 subset. It neither invokes cmd/asm nor reads Go object files. Entry and -// call registers are the physical registers written in the source; no function -// signature, Go stack adjustment, or register allocator is involved. -// -// All TEXT symbols must be file-local and NOSPLIT, with zero frame/argument sizes. -// Non-leaf code requires NOFRAME and must manage its C ABI frame itself. Only -// explicit registers, immediate/register arithmetic, base+offset memory operands, -// local branches, declared foreign calls and checked DATA/GLOBL are supported. -// See doc/native-arm64.md for the complete operand and directive contract. +// TranslateNativeARM64Source is the Darwin/ARM64 compatibility entry point. func TranslateNativeARM64Source(src []byte, imports map[string]string, pkgPath string) (string, []NativeData, error) { - clean, err := nativeSource(src) - if err != nil { - return "", nil, err - } - f, err := Parse(ArchARM64, clean) - if err != nil { - return "", nil, err - } - if len(f.Funcs) == 0 { - return "", nil, fmt.Errorf("native assembly requires TEXT") - } - e := nativeARM64{funcs: map[string]string{}, globals: map[string]string{}, imports: imports} - for name, alias := range imports { - if !nativeName.MatchString(name) || !nativeName.MatchString(alias) { - return "", nil, fmt.Errorf("unsupported native import %q -> %q", name, alias) - } - } - for i, fn := range f.Funcs { - name := strings.TrimSuffix(fn.Sym, "<>") - if name == fn.Sym || !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("native TEXT must be file-local: %s", fn.Sym) - } - if _, ok := e.funcs[fn.Sym]; ok { - return "", nil, fmt.Errorf("duplicate native TEXT %s", fn.Sym) - } - e.funcs[fn.Sym] = fmt.Sprintf("Lnative_func_%d", i) - parts := strings.Split(fn.Instrs[0].Raw, ",") - if len(parts) != 3 || (strings.TrimSpace(parts[2]) != "$0" && strings.TrimSpace(parts[2]) != "$0-0") { - return "", nil, fmt.Errorf("native TEXT requires zero Go frame and arguments: %s", fn.Sym) - } - flags, err := nativeFlags(parts[1], "NOSPLIT", "NOFRAME") - if err != nil { - return "", nil, err - } - if !flags["NOSPLIT"] { - return "", nil, fmt.Errorf("native TEXT requires NOSPLIT: %s", fn.Sym) - } - for _, ins := range fn.Instrs { - if (ins.Op == "BL" || ins.Op == "CALL") && !flags["NOFRAME"] { - return "", nil, fmt.Errorf("native non-leaf TEXT requires NOFRAME: %s", fn.Sym) - } - } - } - var data []NativeData - for _, g := range f.Globl { - name := strings.TrimPrefix(g.Sym, "·") - if name == g.Sym || !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("native GLOBL must name a package global: %s", g.Sym) - } - if _, ok := e.globals[g.Sym]; ok { - return "", nil, fmt.Errorf("duplicate native GLOBL %s", g.Sym) - } - if g.Size <= 0 || g.Size > 64<<20 { - return "", nil, fmt.Errorf("unsupported native GLOBL size %d", g.Size) - } - if _, err := nativeFlags(g.Flags, "RODATA", "NOPTR"); err != nil { - return "", nil, err - } - e.globals[g.Sym] = "_" + pkgPath + "." + name - data = append(data, NativeData{pkgPath + "." + name, uint32(g.Size)}) - } - var out strings.Builder - for i, fn := range f.Funcs { - e.labels = map[string]string{} - for _, ins := range fn.Instrs { - if ins.Op == OpLABEL { - name := ins.Args[0].Sym - if !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("unsupported native label %s", name) - } - if _, ok := e.labels[name]; ok { - return "", nil, fmt.Errorf("duplicate native label %s", name) - } - e.labels[name] = fmt.Sprintf("Lnative_%d_label_%d", i, len(e.labels)) - } - } - fmt.Fprintf(&out, ".text\n.p2align 2\n%s:\n", e.funcs[fn.Sym]) - for _, ins := range fn.Instrs { - if err := e.instruction(&out, ins); err != nil { - return "", nil, fmt.Errorf("native %s: %s: %w", fn.Sym, ins.Raw, err) - } - } - } - for _, d := range f.Data { - if _, ok := e.globals[d.Sym]; !ok { - return "", nil, fmt.Errorf("native DATA has no GLOBL: %s", d.Sym) - } - } - for _, g := range f.Globl { - flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") - if flags["RODATA"] { - out.WriteString(".section __DATA_CONST,__const\n") - } else { - out.WriteString(".data\n") - } - label := strconv.Quote(e.globals[g.Sym]) - fmt.Fprintf(&out, ".p2align 3\n.globl %s\n%s:\n", label, label) - var values []DataStmt - for _, d := range f.Data { - if d.Sym == g.Sym { - values = append(values, d) - } - } - sort.Slice(values, func(i, j int) bool { return values[i].Off < values[j].Off }) - pos := int64(0) - for _, d := range values { - if d.Off < pos || d.Width <= 0 || d.Off > g.Size || d.Width > g.Size-d.Off { - return "", nil, fmt.Errorf("overlapping or out-of-bounds native DATA for %s", g.Sym) - } - if d.Off > pos { - fmt.Fprintf(&out, ".zero %d\n", d.Off-pos) - } - if d.Addr != "" { - if d.Width != 8 { - return "", nil, fmt.Errorf("native address DATA requires width 8") - } - target, err := e.symbol(d.Addr, true) - if err != nil { - return "", nil, err - } - fmt.Fprintf(&out, ".quad %s\n", target) - } else { - if d.Payload != nil { - return "", nil, fmt.Errorf("native string DATA is unsupported") - } - directive := map[int64]string{1: ".byte", 2: ".short", 4: ".long", 8: ".quad"}[d.Width] - if directive == "" { - return "", nil, fmt.Errorf("unsupported native DATA width %d", d.Width) - } - mask := ^uint64(0) - if d.Width < 8 { - mask = (uint64(1) << (8 * d.Width)) - 1 - } - fmt.Fprintf(&out, "%s %#x\n", directive, d.Value&mask) - } - pos = d.Off + d.Width - } - if pos < g.Size { - fmt.Fprintf(&out, ".zero %d\n", g.Size-pos) - } - } - return out.String(), data, nil + return TranslateNativeSource(src, NativeOptions{GOOS: "darwin", GOARCH: "arm64", PackagePath: pkgPath, Imports: imports}) } -var nativeName = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`) -var nativePseudoRegister = regexp.MustCompile(`\b(?:SP|FP|g|G|R18_PLATFORM|R18|R31|W[0-9]+)\b`) +type nativeARM64 struct{ *nativeEmitter } -// The general parser deliberately tolerates ignored includes and symbolic -// placeholders. The native backend must reject these before parsing, rather -// than silently emitting guessed offsets or dropping conditional code. -func nativeSource(src []byte) (string, error) { - var b strings.Builder - for _, line := range strings.Split(string(src), "\n") { - line, _, _ = strings.Cut(line, "//") - line = strings.TrimSpace(line) - if line == "" { - continue - } - if strings.HasPrefix(line, "#") { - if line != `#include "textflag.h"` { - return "", fmt.Errorf("unsupported native preprocessor directive: %s", line) - } - continue - } - if strings.Contains(line, "/*") || strings.ContainsAny(line, "\"\\") { - return "", fmt.Errorf("unsupported native source syntax: %s", line) - } - if nativePseudoRegister.MatchString(line) { - return "", fmt.Errorf("unsupported native register or Go stack operand: %s", line) - } - for _, stmt := range splitSemicolons(line) { - op, rest := splitOpcode(stmt) - // Enforce integer expressions before the permissive parser can invent a - // symbolic GLOBL size or interpret an integer operand as float bits. - if op == "GLOBL" { - parts := strings.Split(rest, ",") - if len(parts) != 3 { - return "", fmt.Errorf("invalid native GLOBL") - } - if _, ok := nativeInteger(strings.TrimSpace(parts[2])); !ok { - return "", fmt.Errorf("native GLOBL requires a constant integer size") - } - } - if op == "DATA" { - _, rhs, ok := strings.Cut(rest, ",") - rhs = strings.TrimSpace(rhs) - if !ok { - return "", fmt.Errorf("invalid native DATA") - } - if _, ok := nativeInteger(rhs); !ok && !(strings.HasPrefix(rhs, "$") && strings.HasSuffix(rhs, "(SB)")) { - return "", fmt.Errorf("unsupported native DATA initializer: %s", rhs) - } - } - } - b.WriteString(line) - b.WriteByte('\n') - } - return b.String(), nil -} -func nativeInteger(s string) (uint64, bool) { - if !strings.HasPrefix(s, "$") { - return 0, false - } - return parseImmExpr(strings.TrimPrefix(s, "$")) -} -func nativeFlags(s string, allowed ...string) (map[string]bool, error) { - flags := map[string]bool{} - for _, flag := range strings.Split(strings.TrimSpace(s), "|") { - flag = strings.TrimSpace(flag) - if flag == "0" { - continue - } - ok := false - for _, a := range allowed { - ok = ok || flag == a - } - if !ok || flags[flag] { - return nil, fmt.Errorf("unsupported native flag %q", flag) - } - flags[flag] = true - } - return flags, nil -} - -type nativeARM64 struct{ funcs, globals, imports, labels map[string]string } - -func (e *nativeARM64) symbol(s string, data bool) (string, error) { - if !strings.HasSuffix(s, "(SB)") { - return "", fmt.Errorf("unsupported native symbol %s", s) - } - name := strings.TrimSuffix(s, "(SB)") - if label, ok := e.funcs[name]; ok { - return label, nil - } - if data { - if label, ok := e.globals[name]; ok { - return strconv.Quote(label), nil - } - } - if alias, ok := e.imports[name]; ok { - return strconv.Quote("_" + alias), nil - } - return "", fmt.Errorf("undeclared foreign symbol or unsupported native reference %s", s) -} func nativeReg(o Operand, bits int, sp bool) (string, error) { if o.Kind != OpReg { return "", fmt.Errorf("expected native register") @@ -340,19 +69,6 @@ func nativeMemory(o Operand, width int) (string, bool, error) { } return fmt.Sprintf("[%s, #%d]", base, m.Off), unscaled, nil } -func (e *nativeARM64) branch(o Operand, external bool) (string, error) { - if external && o.Kind == OpSym { - return e.symbol(o.Sym, false) - } - name := o.Ident - if o.Kind == OpLabel { - name = o.Sym - } - if label, ok := e.labels[name]; ok { - return label, nil - } - return "", fmt.Errorf("undefined native branch label %s", o.String()) -} func (e *nativeARM64) instruction(out *strings.Builder, ins Instr) error { a := ins.Args op := string(ins.Op) diff --git a/native_source.go b/native_source.go new file mode 100644 index 00000000..813cd903 --- /dev/null +++ b/native_source.go @@ -0,0 +1,364 @@ +package plan9asm + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// NativeData describes a Go global whose storage is supplied by native assembly. +type NativeData struct { + Name string + Size uint32 +} + +// ForeignNativeFunctions identifies files containing only file-local TEXT symbols. +// This is a routing hint, NOT an inference of a C function signature. The native +// backend validates frames, flags, instructions, and references independently. +// Files with package-visible TEXT must use the typed backend and explicit Go +// declarations. Unsupported native forms must not fall back to signature guessing. +func ForeignNativeFunctions(src []byte, goarch string) map[string]bool { + f, err := Parse(Arch(goarch), string(src)) + if err != nil || len(f.Funcs) == 0 { + return nil + } + result := make(map[string]bool) + for _, fn := range f.Funcs { + if !strings.HasSuffix(fn.Sym, "<>") { + return nil + } + result[strings.TrimSuffix(fn.Sym, "<>")] = true + } + return result +} + +// NativeOptions selects a bounded physical-register backend. It does not describe +// Go ABI entries: callers must already obey the target native calling convention. +type NativeOptions struct { + GOOS, GOARCH string + PackagePath string + Imports map[string]string +} + +// SupportsNativeTarget reports implemented instruction/object-format pairs. +func SupportsNativeTarget(goos, goarch string) bool { + return (goos == "darwin" || goos == "linux") && (goarch == "arm64" || goarch == "amd64") +} + +// TranslateNativeSource translates checked Plan 9 source directly to native +// assembly, without Go object files, implicit frames, or signature inference. +func TranslateNativeSource(src []byte, opts NativeOptions) (string, []NativeData, error) { + if !SupportsNativeTarget(opts.GOOS, opts.GOARCH) { + return "", nil, fmt.Errorf("unsupported native target %s/%s", opts.GOOS, opts.GOARCH) + } + imports, pkgPath := opts.Imports, opts.PackagePath + + clean, err := nativeSource(src, opts.GOARCH) + if err != nil { + return "", nil, err + } + f, err := Parse(Arch(opts.GOARCH), clean) + if err != nil { + return "", nil, err + } + if len(f.Funcs) == 0 { + return "", nil, fmt.Errorf("native assembly requires TEXT") + } + e := nativeEmitter{target: opts, funcs: map[string]string{}, globals: map[string]string{}, imports: imports} + for name, alias := range imports { + if !nativeName.MatchString(name) || !nativeName.MatchString(alias) { + return "", nil, fmt.Errorf("unsupported native import %q -> %q", name, alias) + } + } + for i, fn := range f.Funcs { + name := strings.TrimSuffix(fn.Sym, "<>") + if name == fn.Sym || !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("native TEXT must be file-local: %s", fn.Sym) + } + if _, ok := e.funcs[fn.Sym]; ok { + return "", nil, fmt.Errorf("duplicate native TEXT %s", fn.Sym) + } + e.funcs[fn.Sym] = fmt.Sprintf("Lnative_func_%d", i) + parts := strings.Split(fn.Instrs[0].Raw, ",") + if len(parts) != 3 || (strings.TrimSpace(parts[2]) != "$0" && strings.TrimSpace(parts[2]) != "$0-0") { + return "", nil, fmt.Errorf("native TEXT requires zero Go frame and arguments: %s", fn.Sym) + } + flags, err := nativeFlags(parts[1], "NOSPLIT", "NOFRAME") + if err != nil { + return "", nil, err + } + if !flags["NOSPLIT"] { + return "", nil, fmt.Errorf("native TEXT requires NOSPLIT: %s", fn.Sym) + } + for _, ins := range fn.Instrs { + if (ins.Op == "BL" || ins.Op == "CALL") && !flags["NOFRAME"] { + return "", nil, fmt.Errorf("native non-leaf TEXT requires NOFRAME: %s", fn.Sym) + } + } + } + var data []NativeData + for _, g := range f.Globl { + name := strings.TrimPrefix(g.Sym, "·") + if name == g.Sym || !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("native GLOBL must name a package global: %s", g.Sym) + } + if _, ok := e.globals[g.Sym]; ok { + return "", nil, fmt.Errorf("duplicate native GLOBL %s", g.Sym) + } + if g.Size <= 0 || g.Size > 64<<20 { + return "", nil, fmt.Errorf("unsupported native GLOBL size %d", g.Size) + } + if _, err := nativeFlags(g.Flags, "RODATA", "NOPTR"); err != nil { + return "", nil, err + } + e.globals[g.Sym] = e.prefix() + pkgPath + "." + name + data = append(data, NativeData{pkgPath + "." + name, uint32(g.Size)}) + } + var out strings.Builder + for i, fn := range f.Funcs { + e.labels = map[string]string{} + for _, ins := range fn.Instrs { + if ins.Op == OpLABEL { + name := ins.Args[0].Sym + if !nativeName.MatchString(name) { + return "", nil, fmt.Errorf("unsupported native label %s", name) + } + if _, ok := e.labels[name]; ok { + return "", nil, fmt.Errorf("duplicate native label %s", name) + } + e.labels[name] = fmt.Sprintf("Lnative_%d_label_%d", i, len(e.labels)) + } + } + fmt.Fprintf(&out, ".text\n.p2align 2\n%s:\n", e.funcs[fn.Sym]) + if opts.GOOS == "linux" { + fmt.Fprintf(&out, ".type %s, @function\n", e.funcs[fn.Sym]) + } + for _, ins := range fn.Instrs { + var err error + if opts.GOARCH == "arm64" { + err = (&nativeARM64{&e}).instruction(&out, ins) + } else { + err = (&nativeAMD64{&e}).instruction(&out, ins) + } + if err != nil { + return "", nil, fmt.Errorf("native %s: %s: %w", fn.Sym, ins.Raw, err) + } + } + if opts.GOOS == "linux" { + fmt.Fprintf(&out, ".size %s, .-%s\n", e.funcs[fn.Sym], e.funcs[fn.Sym]) + } + } + for _, d := range f.Data { + if _, ok := e.globals[d.Sym]; !ok { + return "", nil, fmt.Errorf("native DATA has no GLOBL: %s", d.Sym) + } + } + for _, g := range f.Globl { + flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") + if flags["RODATA"] { + if opts.GOOS == "darwin" { + out.WriteString(".section __DATA_CONST,__const\n") + } else { + out.WriteString(".section .data.rel.ro,\"aw\",@progbits\n") + } + } else { + out.WriteString(".data\n") + } + label := strconv.Quote(e.globals[g.Sym]) + fmt.Fprintf(&out, ".p2align 3\n.globl %s\n%s:\n", label, label) + var values []DataStmt + for _, d := range f.Data { + if d.Sym == g.Sym { + values = append(values, d) + } + } + sort.Slice(values, func(i, j int) bool { return values[i].Off < values[j].Off }) + pos := int64(0) + for _, d := range values { + if d.Off < pos || d.Width <= 0 || d.Off > g.Size || d.Width > g.Size-d.Off { + return "", nil, fmt.Errorf("overlapping or out-of-bounds native DATA for %s", g.Sym) + } + if d.Off > pos { + fmt.Fprintf(&out, ".zero %d\n", d.Off-pos) + } + if d.Addr != "" { + if d.Width != 8 { + return "", nil, fmt.Errorf("native address DATA requires width 8") + } + target, err := e.symbol(d.Addr, true) + if err != nil { + return "", nil, err + } + fmt.Fprintf(&out, ".quad %s\n", target) + } else { + if d.Payload != nil { + return "", nil, fmt.Errorf("native string DATA is unsupported") + } + directive := map[int64]string{1: ".byte", 2: ".short", 4: ".long", 8: ".quad"}[d.Width] + if directive == "" { + return "", nil, fmt.Errorf("unsupported native DATA width %d", d.Width) + } + mask := ^uint64(0) + if d.Width < 8 { + mask = (uint64(1) << (8 * d.Width)) - 1 + } + fmt.Fprintf(&out, "%s %#x\n", directive, d.Value&mask) + } + pos = d.Off + d.Width + } + if pos < g.Size { + fmt.Fprintf(&out, ".zero %d\n", g.Size-pos) + } + } + if opts.GOOS == "linux" { + out.WriteString(".section .note.GNU-stack,\"\",@progbits\n") + } + return out.String(), data, nil +} + +var nativeName = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`) +var nativePseudoRegister = regexp.MustCompile(`\b(?:SP|FP|g|G|R18_PLATFORM|R18|R31|W[0-9]+)\b`) + +// The general parser deliberately tolerates ignored includes and symbolic +// placeholders. The native backend must reject these before parsing, rather +// than silently emitting guessed offsets or dropping conditional code. +func nativeSource(src []byte, goarch string) (string, error) { + var b strings.Builder + for _, line := range strings.Split(string(src), "\n") { + line, _, _ = strings.Cut(line, "//") + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + if line != `#include "textflag.h"` { + return "", fmt.Errorf("unsupported native preprocessor directive: %s", line) + } + continue + } + if strings.Contains(line, "/*") || strings.ContainsAny(line, "\"\\") { + return "", fmt.Errorf("unsupported native source syntax: %s", line) + } + if (goarch == "arm64" && nativePseudoRegister.MatchString(line)) || (goarch == "amd64" && nativeAMD64PseudoRegister.MatchString(line)) { + return "", fmt.Errorf("unsupported native register or Go stack operand: %s", line) + } + for _, stmt := range splitSemicolons(line) { + if strings.Contains(stmt, ":") && (!strings.HasSuffix(stmt, ":") || !nativeName.MatchString(strings.TrimSuffix(stmt, ":"))) { + return "", fmt.Errorf("unsupported native label or segment syntax: %s", stmt) + } + op, rest := splitOpcode(stmt) + if op != "TEXT" && op != "GLOBL" && op != "DATA" { + for _, raw := range splitTopLevelCSV(rest) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(raw, "$") && strings.HasSuffix(raw, ")") && !strings.HasSuffix(raw, "(SB)") { + if i := strings.LastIndex(raw, "("); i >= 0 { + offset := strings.TrimSpace(raw[:i]) + if offset != "" { + if _, ok := parseImmExpr(offset); !ok { + return "", fmt.Errorf("native memory requires a constant offset: %s", raw) + } + } + } + } + } + } + // Enforce integer expressions before the permissive parser can invent a + // symbolic GLOBL size or interpret an integer operand as float bits. + if op == "GLOBL" { + parts := strings.Split(rest, ",") + if len(parts) != 3 { + return "", fmt.Errorf("invalid native GLOBL") + } + if _, ok := nativeInteger(strings.TrimSpace(parts[2])); !ok { + return "", fmt.Errorf("native GLOBL requires a constant integer size") + } + } + if op == "DATA" { + _, rhs, ok := strings.Cut(rest, ",") + rhs = strings.TrimSpace(rhs) + if !ok { + return "", fmt.Errorf("invalid native DATA") + } + if _, ok := nativeInteger(rhs); !ok && !(strings.HasPrefix(rhs, "$") && strings.HasSuffix(rhs, "(SB)")) { + return "", fmt.Errorf("unsupported native DATA initializer: %s", rhs) + } + } + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String(), nil +} +func nativeInteger(s string) (uint64, bool) { + if !strings.HasPrefix(s, "$") { + return 0, false + } + return parseImmExpr(strings.TrimPrefix(s, "$")) +} +func nativeFlags(s string, allowed ...string) (map[string]bool, error) { + flags := map[string]bool{} + for _, flag := range strings.Split(strings.TrimSpace(s), "|") { + flag = strings.TrimSpace(flag) + if flag == "0" { + continue + } + ok := false + for _, a := range allowed { + ok = ok || flag == a + } + if !ok || flags[flag] { + return nil, fmt.Errorf("unsupported native flag %q", flag) + } + flags[flag] = true + } + return flags, nil +} + +var nativeAMD64PseudoRegister = regexp.MustCompile(`\b(?:FP|g|G)\b`) + +type nativeEmitter struct { + target NativeOptions + funcs, globals, imports, labels map[string]string +} + +func (e *nativeEmitter) prefix() string { + if e.target.GOOS == "darwin" { + return "_" + } + return "" +} + +func (e *nativeEmitter) symbol(s string, data bool) (string, error) { + if !strings.HasSuffix(s, "(SB)") { + return "", fmt.Errorf("unsupported native symbol %s", s) + } + name := strings.TrimSuffix(s, "(SB)") + if label, ok := e.funcs[name]; ok { + return label, nil + } + if data { + if label, ok := e.globals[name]; ok { + return strconv.Quote(label), nil + } + } + if alias, ok := e.imports[name]; ok { + return strconv.Quote(e.prefix() + alias), nil + } + return "", fmt.Errorf("undeclared foreign symbol or unsupported native reference %s", s) +} + +func (e *nativeEmitter) branch(o Operand, external bool) (string, error) { + if external && o.Kind == OpSym { + return e.symbol(o.Sym, false) + } + name := o.Ident + if o.Kind == OpLabel { + name = o.Sym + } + if label, ok := e.labels[name]; ok { + return label, nil + } + return "", fmt.Errorf("undefined native branch label %s", o.String()) +} diff --git a/native_source_test.go b/native_source_test.go new file mode 100644 index 00000000..0e51123e --- /dev/null +++ b/native_source_test.go @@ -0,0 +1,292 @@ +package plan9asm + +import ( + "debug/elf" + "debug/macho" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const nativeAMD64Callback = `TEXT callback<>(SB), NOSPLIT|NOFRAME, $0 + SUBQ $8, SP + CALL imported_strlen(SB) + ADDQ $8, SP + RET +GLOBL ·entry(SB), RODATA, $8 +DATA ·entry(SB)/8, $callback<>(SB) +TEXT mixedtramp<>(SB), NOSPLIT, $0 + MOVQ SI, X0 + MOVQ $17, SI + JMP imported_mixed(SB) +GLOBL ·mixedEntry(SB), RODATA, $8 +DATA ·mixedEntry(SB)/8, $mixedtramp<>(SB) +TEXT arithmetic<>(SB), NOSPLIT, $0 + MOVQ $0x123456789abcdef0, AX + MOVQ AX, (DI) + MOVL $0xffffffff, CX + MOVQ CX, 8(DI) + LEAQ 8(DI), DX + MOVQ (DX), AX + SHLQ $1, AX + SHRQ $1, AX + CMPQ AX, CX + JNE fail + CMPQ SI, $-1 + JNE fail + XORL AX, AX + RET +fail: + MOVQ $1, AX + RET +GLOBL ·arithmetic(SB), RODATA, $8 +DATA ·arithmetic(SB)/8, $arithmetic<>(SB) +` + +func TestNativeTargetMatrix(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + for _, goarch := range []string{"amd64", "arm64"} { + t.Run(goos+"/"+goarch, func(t *testing.T) { + src := nativeCallbackSource + if goarch == "amd64" { + src = nativeAMD64Callback + } + opts := NativeOptions{GOOS: goos, GOARCH: goarch, PackagePath: "probe", Imports: map[string]string{"imported_strlen": "strlen", "imported_mixed": "mixed"}} + asm, _, err := TranslateNativeSource([]byte(src), opts) + if err != nil { + t.Fatal(err) + } + prefix := "" + if goos == "darwin" { + prefix = "_" + } + harness := fmt.Sprintf(`extern void *entry __asm("%sprobe.entry"); +extern void *mixedEntry __asm("%sprobe.mixedEntry"); +unsigned long mixed(unsigned long x, unsigned long flags, double y) { return flags == 17 ? x+(unsigned long)y : 999; } +int main(void) { + if (((unsigned long (*)(const char *))entry)("native ABI") != 10) return 1; + if (((unsigned long (*)(unsigned long,unsigned long))mixedEntry)(5,0x4000000000000000UL) != 7) return 2; +`, prefix, prefix) + if goarch == "amd64" { + harness += fmt.Sprintf(`extern void *arithmetic __asm("%sprobe.arithmetic"); unsigned long a[2]={0}; + if (((int (*)(void *,unsigned long))arithmetic)(a,~0UL) || a[0]!=0x123456789abcdef0UL || a[1]!=0xffffffffUL) return 3; +`, prefix) + } + harness += "return 0; }\n" + nativeTargetCompileRun(t, opts, asm, harness) + }) + } + } +} + +func nativeTargetCompileRun(t *testing.T, opts NativeOptions, asm, harness string) { + t.Helper() + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable") + } + dir := t.TempDir() + src := filepath.Join(dir, "native.s") + obj := filepath.Join(dir, "native.o") + for name, value := range map[string]string{"native.s": asm, "main.c": harness} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(value), 0600); err != nil { + t.Fatal(err) + } + } + arch := "aarch64" + if opts.GOARCH == "amd64" { + arch = "x86_64" + } + triple := arch + "-unknown-linux-gnu" + if opts.GOOS == "darwin" { + triple = arch + "-apple-darwin" + } + if b, err := exec.Command(clang, "--target="+triple, "-c", src, "-o", obj).CombinedOutput(); err != nil { + t.Fatalf("assemble: %v\n%s\n%s", err, b, asm) + } + if opts.GOOS == "linux" { + f, err := elf.Open(obj) + if err != nil { + t.Fatal(err) + } + defer f.Close() + machine := elf.EM_AARCH64 + if opts.GOARCH == "amd64" { + machine = elf.EM_X86_64 + } + if f.Machine != machine || f.Type != elf.ET_REL { + t.Fatal(f.FileHeader) + } + if f.Section(".data.rel.ro") == nil || f.Section(".note.GNU-stack") == nil { + t.Fatal("missing RELRO or stack metadata") + } + syms, err := f.Symbols() + if err != nil { + t.Fatal(err) + } + found := false + for _, s := range syms { + if strings.HasPrefix(s.Name, "probe.") && s.Section != elf.SHN_UNDEF { + found = true + } + } + if !found { + t.Fatal("missing DATA symbol") + } + } else { + f, err := macho.Open(obj) + if err != nil { + t.Fatal(err) + } + defer f.Close() + cpu := macho.CpuArm64 + if opts.GOARCH == "amd64" { + cpu = macho.CpuAmd64 + } + if f.Cpu != cpu { + t.Fatal(f.FileHeader) + } + } + if runtime.GOOS == opts.GOOS && runtime.GOARCH == opts.GOARCH { + exe := filepath.Join(dir, "probe") + if b, err := exec.Command(clang, obj, filepath.Join(dir, "main.c"), "-o", exe).CombinedOutput(); err != nil { + t.Fatalf("link: %v\n%s", err, b) + } + if b, err := exec.Command(exe).CombinedOutput(); err != nil { + t.Fatalf("execute: %v\n%s", err, b) + } + } else if opts.GOOS == "linux" && os.Getenv("PLAN9ASM_NATIVE_DOCKER") != "" { + image := os.Getenv("PLAN9ASM_NATIVE_DOCKER") + if opts.GOARCH == "arm64" { + image = os.Getenv("PLAN9ASM_NATIVE_DOCKER_ARM64") + } + if image == "" { + t.Log("cross-object validated; no execution image") + return + } + // Opt-in local execution; ordinary Linux CI executes directly above. + cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", "--platform", "linux/"+opts.GOARCH, "-v", dir+":/probe", "-w", "/probe", image, "-c", "cc -fPIE -pie native.o main.c -Wl,-z,relro,-z,now -o probe && ./probe") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Linux execution: %v\n%s", err, b) + } + } else { + t.Log("object validated; execution needs matching target") + } +} + +func TestNativeAMD64Reject(t *testing.T) { + opts := NativeOptions{GOOS: "linux", GOARCH: "amd64"} + for _, body := range []string{"MOVQ foo+8(SP), AX", "MOVQ x+0(FP), AX", "MOVQ $symbol, AX", "MOVQ $1.5, AX", "MOVQ (AX)(CX*4), DX", "MOVQ 2147483648(AX), DX", "MOVQ FS:0(AX), DX", "MOVQ AX, R16", "MOVQ AX, X16", "MOVQ X0, X1", "MOVQ $4, X0", "ADDQ $0xffffffff, AX", "MOVQ $0x100000000, (AX)", "MOVL $0x100000000, AX", "SHLQ $64, AX", "SHLQ CX, AX", "LEAQ AX, BX", "MOVQ (AX), (BX)", "MOVQ AX, $1", "CALL AX", "CALL missing(SB)", "JEQ missing", "RET AX", "MOVB AL, BL", "MOVQ $1, AX, BX", "PUSHQ AX"} { + t.Run(body, func(t *testing.T) { + _, _, err := TranslateNativeSource([]byte("TEXT f<>(SB), NOSPLIT|NOFRAME, $0\n"+body+"\n"), opts) + if err == nil { + t.Fatal("accepted unsupported form") + } + }) + } + for _, target := range []NativeOptions{{GOOS: "windows", GOARCH: "amd64"}, {GOOS: "linux", GOARCH: "386"}} { + if _, _, err := TranslateNativeSource([]byte(nativeAMD64Callback), target); err == nil { + t.Fatal("accepted unsupported target") + } + } + if ForeignNativeFunctions([]byte("TEXT ·f(SB), NOSPLIT, $0\nRET\n"), "amd64") != nil { + t.Fatal("routed Go entry") + } + asm, _, err := TranslateNativeSource([]byte("TEXT f<>(SB), NOSPLIT, $0\nJMP imported(SB)\n"), NativeOptions{GOOS: "linux", GOARCH: "amd64", Imports: map[string]string{"imported": "strlen"}}) + if err != nil || !strings.Contains(asm, `"strlen"@PLT`) { + t.Fatalf("PLT reference: %s %v", asm, err) + } +} + +func TestNativeAMD64Conditions(t *testing.T) { + conditions := []struct{ op, expr string }{ + {"JEQ", "a == b"}, {"JNE", "a != b"}, {"JLT", "(int64_t)a < (int64_t)b"}, {"JLE", "(int64_t)a <= (int64_t)b"}, {"JGT", "(int64_t)a > (int64_t)b"}, {"JGE", "(int64_t)a >= (int64_t)b"}, + {"JCS", "a < b"}, {"JCC", "a >= b"}, {"JHI", "a > b"}, {"JLS", "a <= b"}, {"JMI", "(int64_t)(a-b) < 0"}, {"JPL", "(int64_t)(a-b) >= 0"}, {"JOS", "((a^b)&(a^(a-b))) >> 63"}, {"JOC", "!(((a^b)&(a^(a-b))) >> 63)"}, + } + var src, c strings.Builder + c.WriteString("#include \n") + for i, cond := range conditions { + fmt.Fprintf(&src, `TEXT compare%d<>(SB), NOSPLIT, $0 + CMPQ DI, SI + MOVQ $0x1122334455667788, R8 + %s yes + MOVL $0, AX + RET +yes: + MOVL $1, AX + RET +GLOBL ·compare%d(SB), RODATA, $8 +DATA ·compare%d(SB)/8, $compare%d<>(SB) +`, i, cond.op, i, i, i) + fmt.Fprintf(&c, "extern void *compare%d __asm(\"probe.compare%d\");\n", i, i) + } + c.WriteString("int main(void){uint64_t values[]={0,1,17,0x7fffffffffffffffULL,0x8000000000000000ULL,~0ULL};for(int i=0;i<6;i++)for(int j=0;j<6;j++){uint64_t a=values[i],b=values[j];\n") + for i, cond := range conditions { + fmt.Fprintf(&c, "if(((int(*)(uint64_t,uint64_t))compare%d)(a,b)!=!!(%s))return %d;\n", i, cond.expr, i+1) + } + c.WriteString("}return 0;}") + opts := NativeOptions{GOOS: "linux", GOARCH: "amd64", PackagePath: "probe"} + asm, _, err := TranslateNativeSource([]byte(src.String()), opts) + if err != nil { + t.Fatal(err) + } + nativeTargetCompileRun(t, opts, asm, c.String()) +} + +func TestNativeAMD64Operations(t *testing.T) { + src := `TEXT operations<>(SB), NOSPLIT|NOFRAME, $0 + SUBQ $8, SP + MOVQ SI, X1 + MOVQ X1, R9 + MOVQ R9, (SP) + MOVQ (SP), X2 + MOVQ X2, AX + MOVL $0xffffffff, R8 + MOVL R8, R9 + ADDL $2, R9 + SUBL $1, R9 + CMPL R9, $0 + JNE fail + MOVQ AX, R10 + ANDQ $255, R10 + ORQ $256, R10 + XORQ $256, R10 + TESTQ R10, R10 + JEQ fail + SARQ $1, AX + ADDQ $2, AX + SUBQ $1, AX + MOVL AX, (DI) + MOVL (DI), R11 + ANDL $255, R11 + ORL $256, R11 + XORL $256, R11 + SHLL $2, R11 + SHRL $1, R11 + SARL $1, R11 + TESTL R11, R11 + JEQ fail + CALL local<>(SB) + ADDQ $8, SP + RET +fail: + MOVQ $-1, AX + ADDQ $8, SP + RET +TEXT local<>(SB), NOSPLIT, $0 + ADDQ $1, AX + RET +GLOBL ·entry(SB), RODATA, $8 +DATA ·entry(SB)/8, $operations<>(SB) +` + opts := NativeOptions{GOOS: "linux", GOARCH: "amd64", PackagePath: "probe"} + asm, _, err := TranslateNativeSource([]byte(src), opts) + if err != nil { + t.Fatal(err) + } + nativeTargetCompileRun(t, opts, asm, `extern void *entry __asm("probe.entry"); int main(void){unsigned int value=0;unsigned long result=((unsigned long(*)(void*,unsigned long))entry)(&value,10);return result!=7 || value!=6;}`) +} From b2b9126cacd84f082e20c28f8122dbc180e8a8c9 Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Wed, 23 Sep 2026 23:18:15 +0800 Subject: [PATCH 4/6] native: lower raw entries to naked LLVM inline-asm functions --- doc/native-arm64.md | 72 ++++++++++-- native_module.go | 187 ++++++++++++++++++++++++++++++ native_module_test.go | 220 +++++++++++++++++++++++++++++++++++ native_source.go | 263 +++++++++++++++++++++++++----------------- 4 files changed, 622 insertions(+), 120 deletions(-) create mode 100644 native_module.go create mode 100644 native_module_test.go diff --git a/doc/native-arm64.md b/doc/native-arm64.md index 0772939c..e724c5cd 100644 --- a/doc/native-arm64.md +++ b/doc/native-arm64.md @@ -1,11 +1,16 @@ # Native physical-register backend -`TranslateNativeSource` takes explicit `NativeOptions` (`GOOS`, `GOARCH`, -`PackagePath`, and `Imports`) and translates a restricted Plan 9 source file to -native assembly. The implemented targets are Linux and Darwin, each on amd64 -and arm64. A native assembler such as LLVM's integrated assembler -creates the final ELF or Mach-O object. There is no `go tool asm` invocation, dependency -on a Go object layout, instruction-byte copying, or guessed LLVM function type. +`TranslateNativeModule` takes an LLVM context and explicit `NativeOptions` +(`GOOS`, `GOARCH`, `PackagePath`, and `Imports`). It lowers each restricted Plan 9 +TEXT to a naked LLVM function containing function-local inline assembly, and +DATA/GLOBL to LLVM global definitions. Linux and Darwin on amd64 and arm64 are +supported. The driver links the returned module into its package module before +normal optimization, bitcode/LTO and object emission. No module-level assembly, +separate native assembler invocation, Go object reader or Go toolchain is needed. + +`TranslateNativeSource` remains available for consumers needing standalone native +assembly. It shares source validation and instruction lowering with the module +backend; `TranslateNativeARM64Source` is its Darwin/ARM64 compatibility wrapper. The contract is **physical register preservation**, not inferred C typing. A caller of an entry point must arrange exactly the registers expected by its @@ -39,18 +44,20 @@ it no longer silently receives `void()`. `Wn` aliases, and platform aliases are rejected. Floating registers are `F0`–`F31`. - Local function/branch/import identifiers use ASCII letters, digits and `_`, beginning with a letter or `_`. Data definitions use `·name` for a package global. -- Local labels and function addresses are renamed per assembly file. Foreign +- Local labels and function addresses are renamed per assembly file. Naked + modules use LLVM symbol operands and inline-asm unique IDs across module merges. Foreign symbols are resolved only through the driver's explicit import map; no Go package/runtime call, symbol addend, computed branch or indirect call is accepted. - `DATA` supports constant integers of 1/2/4/8 bytes and 8-byte addresses of selected local functions, defined data or declared imports. Each initializer must fit its `GLOBL` allocation and must not overlap another initializer. Gaps are zero-filled. - `GLOBL` requires a constant size from 1 byte to 64 MiB. Supported flags are `0`, - `RODATA` and `NOPTR`. Definitions are 8-byte aligned. `RODATA` is placed in - `__DATA_CONST,__const` on Mach-O and `.data.rel.ro` on ELF, allowing address - fixups before becoming read-only under a RELRO-enabled linker; mutable - definitions use `.data`. The returned DATA metadata lets the driver verify the - Go global size and bind its definition to the native object. + `RODATA` and `NOPTR`. Definitions are 8-byte aligned. Naked modules use packed LLVM constants with + pointer-typed address initializers and explicit zero-filled gaps; RODATA sets + LLVM's global constant flag. LLVM selects the target data sections. The driver + checks the storage size against the Go definition before module linking. + The standalone source emitter uses `__DATA_CONST,__const` on Mach-O and + `.data.rel.ro` on ELF for RODATA; mutable definitions use `.data`. ## ARM64 instruction and operand forms @@ -119,3 +126,44 @@ Linux harnesses may also run locally via explicitly configured Docker images: They verify foreign calls, integer/FP argument shuffles, returns, physical stack frames, and data-address relocation in PIE executables. Unsupported targets such as Windows require their own ABI/format qualification before being enabled. + +## Naked LLVM function contract + +Each TEXT becomes an internal `void ()` function with `naked noinline`. This is +an address/code carrier, **not an inferred C or Go prototype**. Its only body is +one side-effecting inline-asm call followed by `unreachable`. All entry parameters, +return values, stack alignment and callee-saved registers remain the source's +physical-register contract. Call sites keep their own actual calling convention +and types. Go has already marshalled syscall arguments before entering these +stubs; no Go parameter slot or LLVM formal argument is read by the carrier. + +LLVM 22 explicitly exempts naked functions from prototype-based call rewriting +in `InstCombineCalls.cpp` because their assembly may consume parameters absent +from the prototype. This differs from synthesizing a typed `call void()` for an +unknown assembly callee, which remains forbidden in typed LLVM translation. +The backend must not mark a carrier `noreturn`: assembly RET returns to the +machine caller even though it does not fall through to the IR terminator. +Every body must end in RET or an unconditional branch; implicit fallthrough +between TEXT functions is rejected by the module backend. + +Function and imported-symbol references are constant inline-asm operands (`s` +on AMD64 PIC, `i` on ARM64). Imports are external byte-address declarations, +not guessed foreign function prototypes. Native CALL/JMP still executes inside +assembly. LLVM therefore sees dependencies for module renaming and DCE without +lowering the call's arguments. Local branch labels use `${:uid}`; literal AMD64 +immediate `$` characters are escaped before adding LLVM template operands. +The assembly call conservatively clobbers memory. It does not request compiler +stack alignment, argument moves, or a generated prologue/epilogue. + +Module tests execute optimized IR consumers of the void() carriers with actual +integer/pointer parameters, integer and floating-point results, external calls, +register shuffles and tail calls. They run O2 and full-LTO optimization pipelines; +merge identically named local functions/labels from separate files; check that +referenced functions survive and unreachable carriers disappear; and resolve an +external assembly address to a typed LLVM function definition. Packed DATA tests +cover forward references, address relocations, gaps, truncation and mutable data. +LLGo integration also exercises complete off/thin/full LTO links on Darwin/ARM64. + +The assembly instructions themselves remain opaque to IR optimization. This +backend does not introduce Go ABI adapters, stack maps, unwinding or runtime +transitions. diff --git a/native_module.go b/native_module.go new file mode 100644 index 00000000..2fd31d41 --- /dev/null +++ b/native_module.go @@ -0,0 +1,187 @@ +package plan9asm + +import ( + "fmt" + "strings" + + llvm "github.com/xgo-dev/llvm" +) + +// TranslateNativeModule represents each raw native entry as an address-only +// naked LLVM function. The void() carrier is NOT a Go/C signature: all inputs, +// results, calls and returns live in physical registers inside inline assembly. +// Call sites retain their own ABI. No typed IR call is generated for a carrier. +// DATA contains real LLVM references, as do inline-asm symbol operands, so module +// linking, renaming and dead-code elimination can track their dependencies. +// The caller owns the returned module; errors never return a partial module. +func TranslateNativeModule(ctx llvm.Context, src []byte, opts NativeOptions) (mod llvm.Module, err error) { + f, e, _, err := prepareNativeSource(src, opts) + if err != nil { + return llvm.Module{}, err + } + mod = ctx.NewModule("native-" + opts.PackagePath) + defer func() { + if err != nil { + mod.Dispose() + mod = llvm.Module{} + } + }() + arch := "aarch64" + if opts.GOARCH == "amd64" { + arch = "x86_64" + } + triple := arch + "-unknown-linux-gnu" + if opts.GOOS == "darwin" { + triple = arch + "-apple-darwin" + } + mod.SetTarget(triple) + // LLVM 22 InstCombineCalls explicitly preserves mismatched prototypes for + // naked functions: their asm can consume arguments absent from the carrier. + // Keep this separate from typed lowering, which must never invent void calls. + carrierTy := llvm.FunctionType(ctx.VoidType(), nil, false) + funcs := map[string]llvm.Value{} + globals := map[string]llvm.Value{} + imports := map[string]llvm.Value{} + for i, fn := range f.Funcs { + v := llvm.AddFunction(mod, fmt.Sprintf("__plan9_native_%d", i), carrierTy) + v.SetLinkage(llvm.InternalLinkage) + for _, attr := range []string{"naked", "noinline"} { + v.AddFunctionAttr(ctx.CreateEnumAttribute(llvm.AttributeKindID(attr), 0)) + } + funcs[fn.Sym] = v + } + // Reserve all globals first, allowing forward DATA references. A packed + // structure gives byte-exact layout while retaining pointer-typed relocations. + for _, g := range f.Globl { + values, _ := e.dataValues(f, g) + var fields []llvm.Type + pos := int64(0) + for _, d := range values { + if d.Off > pos { + fields = append(fields, llvm.ArrayType(ctx.Int8Type(), int(d.Off-pos))) + } + ty := ctx.IntType(int(d.Width * 8)) + if d.Addr != "" { + ty = llvm.PointerType(ctx.Int8Type(), 0) + } + fields = append(fields, ty) + pos = d.Off + d.Width + } + if pos < g.Size { + fields = append(fields, llvm.ArrayType(ctx.Int8Type(), int(g.Size-pos))) + } + v := llvm.AddGlobal(mod, ctx.StructType(fields, true), opts.PackagePath+"."+strings.TrimPrefix(g.Sym, "·")) + v.SetAlignment(8) + flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") + v.SetGlobalConstant(flags["RODATA"]) + globals[g.Sym] = v + } + resolve := func(s string, data bool) (llvm.Value, error) { + if !strings.HasSuffix(s, "(SB)") { + return llvm.Value{}, fmt.Errorf("unsupported native symbol %s", s) + } + name := strings.TrimSuffix(s, "(SB)") + if v, ok := funcs[name]; ok { + return v, nil + } + if data { + if v, ok := globals[name]; ok { + return v, nil + } + } + if alias, ok := opts.Imports[name]; ok { + if v, ok := imports[alias]; ok { + return v, nil + } + // An untyped external address, not a guessed foreign function prototype. + v := llvm.AddGlobal(mod, ctx.Int8Type(), alias) + imports[alias] = v + return v, nil + } + return llvm.Value{}, fmt.Errorf("undeclared foreign symbol or unsupported native reference %s", s) + } + for _, g := range f.Globl { + values, _ := e.dataValues(f, g) + var fields []llvm.Value + pos := int64(0) + for _, d := range values { + if d.Off > pos { + fields = append(fields, llvm.ConstNull(llvm.ArrayType(ctx.Int8Type(), int(d.Off-pos)))) + } + var value llvm.Value + if d.Addr != "" { + value, err = resolve(d.Addr, true) + if err != nil { + return mod, err + } + } else { + value = llvm.ConstInt(ctx.IntType(int(d.Width*8)), d.Value, false) + } + fields = append(fields, value) + pos = d.Off + d.Width + } + if pos < g.Size { + fields = append(fields, llvm.ConstNull(llvm.ArrayType(ctx.Int8Type(), int(g.Size-pos)))) + } + globals[g.Sym].SetInitializer(ctx.ConstStruct(fields, true)) + } + builder := ctx.NewBuilder() + defer builder.Dispose() + for i, fn := range f.Funcs { + if err = e.functionLabels(fn, i); err != nil { + return mod, err + } + for label, value := range e.labels { + e.labels[label] = value + "___native_uid__" + } + var refs []llvm.Value + e.symbolOperand = func(s string, data bool) (string, error) { + v, err := resolve(s, data) + if err != nil { + return "", err + } + for n, ref := range refs { + if ref == v { + return fmt.Sprintf("__native_operand_%d__", n), nil + } + } + n := len(refs) + refs = append(refs, v) + return fmt.Sprintf("__native_operand_%d__", n), nil + } + var body strings.Builder + if err = e.functionBody(&body, fn); err != nil { + return mod, err + } + last := string(fn.Instrs[len(fn.Instrs)-1].Op) + if last != "RET" && last != "JMP" && last != "B" { + return mod, fmt.Errorf("native naked TEXT must end with RET or an unconditional branch: %s", fn.Sym) + } + // Escape literal x86 '$' immediates before adding LLVM template operands. + assembly := strings.ReplaceAll(body.String(), "$", "$$") + assembly = strings.ReplaceAll(assembly, "__native_uid__", "${:uid}") + var types []llvm.Type + var constraints []string + for n, v := range refs { + assembly = strings.ReplaceAll(assembly, fmt.Sprintf("__native_operand_%d__", n), fmt.Sprintf("${%d:c}", n)) + types = append(types, v.Type()) + constraint := "i" + if opts.GOARCH == "amd64" { + constraint = "s" + } + constraints = append(constraints, constraint) + } + constraints = append(constraints, "~{memory}") + ty := llvm.FunctionType(ctx.VoidType(), types, false) + inline := llvm.InlineAsm(ty, assembly, strings.Join(constraints, ","), true, false, llvm.InlineAsmDialectATT, false) + block := ctx.AddBasicBlock(funcs[fn.Sym], "entry") + builder.SetInsertPointAtEnd(block) + builder.CreateCall(ty, inline, refs, "") + // Machine RET/tail JMP exits the carrier. This is not a noreturn function. + builder.CreateUnreachable() + } + if err = llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + return mod, err + } + return mod, nil +} diff --git a/native_module_test.go b/native_module_test.go new file mode 100644 index 00000000..eebb6236 --- /dev/null +++ b/native_module_test.go @@ -0,0 +1,220 @@ +package plan9asm + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + llvm "github.com/xgo-dev/llvm" +) + +func TestNativeNakedModule(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + for _, goarch := range []string{"amd64", "arm64"} { + t.Run(goos+"/"+goarch, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + opts := NativeOptions{GOOS: goos, GOARCH: goarch, PackagePath: "probe", Imports: map[string]string{"imported_strlen": "strlen", "imported_mixed": "mixed"}} + source := nativeCallbackSource + if goarch == "amd64" { + source = nativeAMD64Callback + } + mod, err := TranslateNativeModule(ctx, []byte(source), opts) + if err != nil { + t.Fatal(err) + } + defer mod.Dispose() + // Address consumers in the SAME IR module deliberately use real signatures + // unlike the void() carriers. O2 and LTO must preserve arguments and results. + builder := ctx.NewBuilder() + defer builder.Dispose() + for _, mixed := range []bool{false, true} { + name := "entry" + args := []llvm.Type{llvm.PointerType(ctx.Int8Type(), 0)} + if mixed { + name = "mixedEntry" + args = []llvm.Type{ctx.Int64Type(), ctx.Int64Type()} + } + ty := llvm.FunctionType(ctx.Int64Type(), args, false) + wrapper := llvm.AddFunction(mod, "call_"+name, ty) + builder.SetInsertPointAtEnd(ctx.AddBasicBlock(wrapper, "entry")) + ptr := builder.CreateLoad(llvm.PointerType(ctx.Int8Type(), 0), mod.NamedGlobal("probe."+name), "address") + var vals []llvm.Value + for n := range args { + vals = append(vals, wrapper.Param(n)) + } + result := builder.CreateCall(ty, ptr, vals, "result") + builder.CreateRet(result) + } + ir := mod.String() + if strings.Contains(ir, "module asm") || !strings.Contains(ir, "naked noinline") || !strings.Contains(ir, "~{memory}") { + t.Fatal(ir) + } + harness := `extern unsigned long call_entry(const char *); +extern unsigned long call_mixedEntry(unsigned long,unsigned long); +unsigned long mixed(unsigned long x,unsigned long flags,double y){return flags==17 ? x+(unsigned long)y : 999;} +int main(void){return call_entry("native ABI")!=10 || call_mixedEntry(5,0x4000000000000000UL)!=7;}` + for _, mode := range []string{"default", "lto-pre-link,lto"} { + t.Run(mode, func(t *testing.T) { + asm := nativeModuleAssembly(t, ir, mode) + nativeTargetCompileRun(t, opts, asm, harness) + }) + } + }) + } + } +} + +func nativeModuleAssembly(t *testing.T, ir, pipeline string) string { + t.Helper() + for _, name := range []string{"opt", "llc"} { + if _, err := exec.LookPath(name); err != nil { + t.Skip(name + " unavailable") + } + } + dir := t.TempDir() + src := filepath.Join(dir, "native.ll") + optimized := filepath.Join(dir, "optimized.ll") + out := filepath.Join(dir, "native.s") + if err := os.WriteFile(src, []byte(ir), 0600); err != nil { + t.Fatal(err) + } + if b, err := exec.Command("opt", "-passes="+pipeline, "-verify-each", "-S", src, "-o", optimized).CombinedOutput(); err != nil { + t.Fatalf("opt: %v\n%s\n%s", err, b, ir) + } + if b, err := exec.Command("llc", "-relocation-model=pic", optimized, "-o", out).CombinedOutput(); err != nil { + t.Fatalf("llc: %v\n%s\n%s", err, b, ir) + } + asm, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + return string(asm) +} + +func TestNativeNakedLinkAndReachability(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + t.Run(arch, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("merged") + defer mod.Dispose() + for i := 0; i < 2; i++ { + body := fmt.Sprintf("MOVQ $%d, AX\nCMPQ AX, $42\nJLT done\nMOVQ $99, AX\ndone:\nRET", 40+i) + call := "SUBQ $8, SP\nCALL callee<>(SB)\nADDQ $8, SP\nRET" + dead := "MOVQ $999, AX\nRET" + if arch == "arm64" { + body = fmt.Sprintf("MOVD $%d, R0\nCMP $42, R0\nBLT done\nMOVD $99, R0\ndone:\nRET", 40+i) + call = "SUB $16, RSP\nMOVD R30, (RSP)\nCALL callee<>(SB)\nMOVD (RSP), R30\nADD $16, RSP\nRET" + dead = "MOVD $999, R0\nRET" + } + src := fmt.Sprintf("TEXT caller<>(SB), NOSPLIT|NOFRAME, $0\n%s\nTEXT callee<>(SB), NOSPLIT, $0\n%s\nTEXT unused<>(SB), NOSPLIT, $0\n%s\nGLOBL ·entry(SB), RODATA, $8\nDATA ·entry(SB)/8, $caller<>(SB)\n", call, body, dead) + src = strings.ReplaceAll(src, "·entry", fmt.Sprintf("·entry%d", i)) + part, err := TranslateNativeModule(ctx, []byte(src), NativeOptions{GOOS: "linux", GOARCH: arch, PackagePath: "probe"}) + if err != nil { + t.Fatal(err) + } + mod.SetTarget(part.Target()) + if err := llvm.LinkModules(mod, part); err != nil { + t.Fatal(err) + } + } + asm := nativeModuleAssembly(t, mod.String(), "lto-pre-link,lto") + if strings.Contains(asm, "999") { + t.Fatal("unreachable carrier survived DCE") + } + nativeTargetCompileRun(t, NativeOptions{GOOS: "linux", GOARCH: arch}, asm, `extern void *a __asm("probe.entry0");extern void *b __asm("probe.entry1");int main(void){return ((int(*)(void))a)()!=40 || ((int(*)(void))b)()!=41;}`) + }) + } +} + +func TestNativeNakedModuleReject(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + for _, src := range []string{"TEXT f<>(SB), NOSPLIT, $0\nBAD\n", "TEXT f<>(SB), NOSPLIT, $0\nMOVQ $1, AX\n", "TEXT f<>(SB), NOSPLIT, $0\nRET\nend:\n", "TEXT f<>(SB), NOSPLIT, $0\nJMP missing(SB)\n", "TEXT f<>(SB), NOSPLIT, $0\nRET\nGLOBL ·entry(SB), RODATA, $4\nDATA ·entry(SB)/8, $f<>(SB)\n"} { + mod, err := TranslateNativeModule(ctx, []byte(src), NativeOptions{GOOS: "linux", GOARCH: "amd64", PackagePath: "probe"}) + if err == nil { + mod.Dispose() + t.Fatal("accepted invalid source") + } + if mod != (llvm.Module{}) { + t.Fatal("returned partial module") + } + } +} + +func TestNativeNakedDataAndIRReferences(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + t.Run(arch, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + src := `TEXT f<>(SB), NOSPLIT, $0 + JMP imported(SB) +GLOBL ·record(SB), RODATA, $40 +DATA ·record+1(SB)/1, $0x122 +DATA ·record+8(SB)/8, $f<>(SB) +DATA ·record+16(SB)/8, $·storage(SB) +DATA ·record+24(SB)/8, $imported(SB) +GLOBL ·storage(SB), NOPTR, $8 +DATA ·storage(SB)/8, $7 +` + opts := NativeOptions{GOOS: "linux", GOARCH: arch, PackagePath: "probe", Imports: map[string]string{"imported": "body"}} + mod, err := TranslateNativeModule(ctx, []byte(src), opts) + if err != nil { + t.Fatal(err) + } + defer mod.Dispose() + // Resolve an untyped external address to a real, differently typed LLVM + // function definition during module linking; do not hard-code its spelling. + body := ctx.NewModule("body") + body.SetTarget(mod.Target()) + fn := llvm.AddFunction(body, "body", llvm.FunctionType(ctx.Int64Type(), nil, false)) + b := ctx.NewBuilder() + b.SetInsertPointAtEnd(ctx.AddBasicBlock(fn, "entry")) + b.CreateRet(llvm.ConstInt(ctx.Int64Type(), 42, false)) + b.Dispose() + if err := llvm.LinkModules(mod, body); err != nil { + t.Fatal(err) + } + asm := nativeModuleAssembly(t, mod.String(), "lto-pre-link,lto") + nativeTargetCompileRun(t, opts, asm, `#include +extern unsigned char record[] __asm("probe.record"); +int main(void){unsigned long (*entry)(void),(*body)(void);unsigned long *storage; +memcpy(&entry,record+8,8);memcpy(&storage,record+16,8);memcpy(&body,record+24,8); +if(record[0] || record[1]!=0x22 || record[2] || record[39] || *storage!=7 || entry()!=42 || body()!=42)return 1; +*storage=9;return *storage!=9;}`) + }) + } +} + +func TestNativeNakedFloatingReturn(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + t.Run(arch, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + move := "MOVQ DI, X0" + if arch == "arm64" { + move = "FMOVD R0, F0" + } + source := "TEXT fp<>(SB), NOSPLIT, $0\n" + move + "\nRET\nGLOBL ·entry(SB), RODATA, $8\nDATA ·entry(SB)/8, $fp<>(SB)\n" + opts := NativeOptions{GOOS: "linux", GOARCH: arch, PackagePath: "probe"} + mod, err := TranslateNativeModule(ctx, []byte(source), opts) + if err != nil { + t.Fatal(err) + } + defer mod.Dispose() + b := ctx.NewBuilder() + defer b.Dispose() + ty := llvm.FunctionType(ctx.DoubleType(), []llvm.Type{ctx.Int64Type()}, false) + f := llvm.AddFunction(mod, "floating", ty) + b.SetInsertPointAtEnd(ctx.AddBasicBlock(f, "entry")) + ptr := b.CreateLoad(llvm.PointerType(ctx.Int8Type(), 0), mod.NamedGlobal("probe.entry"), "address") + b.CreateRet(b.CreateCall(ty, ptr, []llvm.Value{f.Param(0)}, "result")) + asm := nativeModuleAssembly(t, mod.String(), "lto-pre-link,lto") + nativeTargetCompileRun(t, opts, asm, `extern double floating(unsigned long);int main(void){return floating(0x4004000000000000UL)!=2.5;}`) + }) + } +} diff --git a/native_source.go b/native_source.go index 813cd903..2762d785 100644 --- a/native_source.go +++ b/native_source.go @@ -50,51 +50,119 @@ func SupportsNativeTarget(goos, goarch string) bool { // TranslateNativeSource translates checked Plan 9 source directly to native // assembly, without Go object files, implicit frames, or signature inference. func TranslateNativeSource(src []byte, opts NativeOptions) (string, []NativeData, error) { + f, ep, data, err := prepareNativeSource(src, opts) + if err != nil { + return "", nil, err + } + e := *ep + var out strings.Builder + for i, fn := range f.Funcs { + if err := e.functionLabels(fn, i); err != nil { + return "", nil, err + } + fmt.Fprintf(&out, ".text\n.p2align 2\n%s:\n", e.funcs[fn.Sym]) + if opts.GOOS == "linux" { + fmt.Fprintf(&out, ".type %s, @function\n", e.funcs[fn.Sym]) + } + if err := e.functionBody(&out, fn); err != nil { + return "", nil, err + } + + if opts.GOOS == "linux" { + fmt.Fprintf(&out, ".size %s, .-%s\n", e.funcs[fn.Sym], e.funcs[fn.Sym]) + } + } + for _, g := range f.Globl { + flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") + if flags["RODATA"] { + if opts.GOOS == "darwin" { + out.WriteString(".section __DATA_CONST,__const\n") + } else { + out.WriteString(".section .data.rel.ro,\"aw\",@progbits\n") + } + } else { + out.WriteString(".data\n") + } + label := strconv.Quote(e.globals[g.Sym]) + fmt.Fprintf(&out, ".p2align 3\n.globl %s\n%s:\n", label, label) + values, _ := e.dataValues(f, g) + pos := int64(0) + for _, d := range values { + if d.Off > pos { + fmt.Fprintf(&out, ".zero %d\n", d.Off-pos) + } + if d.Addr != "" { + target, err := e.symbol(d.Addr, true) + if err != nil { + return "", nil, err + } + fmt.Fprintf(&out, ".quad %s\n", target) + } else { + directive := map[int64]string{1: ".byte", 2: ".short", 4: ".long", 8: ".quad"}[d.Width] + mask := ^uint64(0) + if d.Width < 8 { + mask = (uint64(1) << (8 * d.Width)) - 1 + } + fmt.Fprintf(&out, "%s %#x\n", directive, d.Value&mask) + } + pos = d.Off + d.Width + } + if pos < g.Size { + fmt.Fprintf(&out, ".zero %d\n", g.Size-pos) + } + } + if opts.GOOS == "linux" { + out.WriteString(".section .note.GNU-stack,\"\",@progbits\n") + } + return out.String(), data, nil +} + +func prepareNativeSource(src []byte, opts NativeOptions) (*File, *nativeEmitter, []NativeData, error) { if !SupportsNativeTarget(opts.GOOS, opts.GOARCH) { - return "", nil, fmt.Errorf("unsupported native target %s/%s", opts.GOOS, opts.GOARCH) + return nil, nil, nil, fmt.Errorf("unsupported native target %s/%s", opts.GOOS, opts.GOARCH) } imports, pkgPath := opts.Imports, opts.PackagePath clean, err := nativeSource(src, opts.GOARCH) if err != nil { - return "", nil, err + return nil, nil, nil, err } f, err := Parse(Arch(opts.GOARCH), clean) if err != nil { - return "", nil, err + return nil, nil, nil, err } if len(f.Funcs) == 0 { - return "", nil, fmt.Errorf("native assembly requires TEXT") + return nil, nil, nil, fmt.Errorf("native assembly requires TEXT") } e := nativeEmitter{target: opts, funcs: map[string]string{}, globals: map[string]string{}, imports: imports} for name, alias := range imports { if !nativeName.MatchString(name) || !nativeName.MatchString(alias) { - return "", nil, fmt.Errorf("unsupported native import %q -> %q", name, alias) + return nil, nil, nil, fmt.Errorf("unsupported native import %q -> %q", name, alias) } } for i, fn := range f.Funcs { name := strings.TrimSuffix(fn.Sym, "<>") if name == fn.Sym || !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("native TEXT must be file-local: %s", fn.Sym) + return nil, nil, nil, fmt.Errorf("native TEXT must be file-local: %s", fn.Sym) } if _, ok := e.funcs[fn.Sym]; ok { - return "", nil, fmt.Errorf("duplicate native TEXT %s", fn.Sym) + return nil, nil, nil, fmt.Errorf("duplicate native TEXT %s", fn.Sym) } e.funcs[fn.Sym] = fmt.Sprintf("Lnative_func_%d", i) parts := strings.Split(fn.Instrs[0].Raw, ",") if len(parts) != 3 || (strings.TrimSpace(parts[2]) != "$0" && strings.TrimSpace(parts[2]) != "$0-0") { - return "", nil, fmt.Errorf("native TEXT requires zero Go frame and arguments: %s", fn.Sym) + return nil, nil, nil, fmt.Errorf("native TEXT requires zero Go frame and arguments: %s", fn.Sym) } flags, err := nativeFlags(parts[1], "NOSPLIT", "NOFRAME") if err != nil { - return "", nil, err + return nil, nil, nil, err } if !flags["NOSPLIT"] { - return "", nil, fmt.Errorf("native TEXT requires NOSPLIT: %s", fn.Sym) + return nil, nil, nil, fmt.Errorf("native TEXT requires NOSPLIT: %s", fn.Sym) } for _, ins := range fn.Instrs { if (ins.Op == "BL" || ins.Op == "CALL") && !flags["NOFRAME"] { - return "", nil, fmt.Errorf("native non-leaf TEXT requires NOFRAME: %s", fn.Sym) + return nil, nil, nil, fmt.Errorf("native non-leaf TEXT requires NOFRAME: %s", fn.Sym) } } } @@ -102,120 +170,31 @@ func TranslateNativeSource(src []byte, opts NativeOptions) (string, []NativeData for _, g := range f.Globl { name := strings.TrimPrefix(g.Sym, "·") if name == g.Sym || !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("native GLOBL must name a package global: %s", g.Sym) + return nil, nil, nil, fmt.Errorf("native GLOBL must name a package global: %s", g.Sym) } if _, ok := e.globals[g.Sym]; ok { - return "", nil, fmt.Errorf("duplicate native GLOBL %s", g.Sym) + return nil, nil, nil, fmt.Errorf("duplicate native GLOBL %s", g.Sym) } if g.Size <= 0 || g.Size > 64<<20 { - return "", nil, fmt.Errorf("unsupported native GLOBL size %d", g.Size) + return nil, nil, nil, fmt.Errorf("unsupported native GLOBL size %d", g.Size) } if _, err := nativeFlags(g.Flags, "RODATA", "NOPTR"); err != nil { - return "", nil, err + return nil, nil, nil, err } e.globals[g.Sym] = e.prefix() + pkgPath + "." + name data = append(data, NativeData{pkgPath + "." + name, uint32(g.Size)}) } - var out strings.Builder - for i, fn := range f.Funcs { - e.labels = map[string]string{} - for _, ins := range fn.Instrs { - if ins.Op == OpLABEL { - name := ins.Args[0].Sym - if !nativeName.MatchString(name) { - return "", nil, fmt.Errorf("unsupported native label %s", name) - } - if _, ok := e.labels[name]; ok { - return "", nil, fmt.Errorf("duplicate native label %s", name) - } - e.labels[name] = fmt.Sprintf("Lnative_%d_label_%d", i, len(e.labels)) - } - } - fmt.Fprintf(&out, ".text\n.p2align 2\n%s:\n", e.funcs[fn.Sym]) - if opts.GOOS == "linux" { - fmt.Fprintf(&out, ".type %s, @function\n", e.funcs[fn.Sym]) - } - for _, ins := range fn.Instrs { - var err error - if opts.GOARCH == "arm64" { - err = (&nativeARM64{&e}).instruction(&out, ins) - } else { - err = (&nativeAMD64{&e}).instruction(&out, ins) - } - if err != nil { - return "", nil, fmt.Errorf("native %s: %s: %w", fn.Sym, ins.Raw, err) - } - } - if opts.GOOS == "linux" { - fmt.Fprintf(&out, ".size %s, .-%s\n", e.funcs[fn.Sym], e.funcs[fn.Sym]) - } - } for _, d := range f.Data { if _, ok := e.globals[d.Sym]; !ok { - return "", nil, fmt.Errorf("native DATA has no GLOBL: %s", d.Sym) + return nil, nil, nil, fmt.Errorf("native DATA has no GLOBL: %s", d.Sym) } } for _, g := range f.Globl { - flags, _ := nativeFlags(g.Flags, "RODATA", "NOPTR") - if flags["RODATA"] { - if opts.GOOS == "darwin" { - out.WriteString(".section __DATA_CONST,__const\n") - } else { - out.WriteString(".section .data.rel.ro,\"aw\",@progbits\n") - } - } else { - out.WriteString(".data\n") - } - label := strconv.Quote(e.globals[g.Sym]) - fmt.Fprintf(&out, ".p2align 3\n.globl %s\n%s:\n", label, label) - var values []DataStmt - for _, d := range f.Data { - if d.Sym == g.Sym { - values = append(values, d) - } - } - sort.Slice(values, func(i, j int) bool { return values[i].Off < values[j].Off }) - pos := int64(0) - for _, d := range values { - if d.Off < pos || d.Width <= 0 || d.Off > g.Size || d.Width > g.Size-d.Off { - return "", nil, fmt.Errorf("overlapping or out-of-bounds native DATA for %s", g.Sym) - } - if d.Off > pos { - fmt.Fprintf(&out, ".zero %d\n", d.Off-pos) - } - if d.Addr != "" { - if d.Width != 8 { - return "", nil, fmt.Errorf("native address DATA requires width 8") - } - target, err := e.symbol(d.Addr, true) - if err != nil { - return "", nil, err - } - fmt.Fprintf(&out, ".quad %s\n", target) - } else { - if d.Payload != nil { - return "", nil, fmt.Errorf("native string DATA is unsupported") - } - directive := map[int64]string{1: ".byte", 2: ".short", 4: ".long", 8: ".quad"}[d.Width] - if directive == "" { - return "", nil, fmt.Errorf("unsupported native DATA width %d", d.Width) - } - mask := ^uint64(0) - if d.Width < 8 { - mask = (uint64(1) << (8 * d.Width)) - 1 - } - fmt.Fprintf(&out, "%s %#x\n", directive, d.Value&mask) - } - pos = d.Off + d.Width + if _, err := e.dataValues(f, g); err != nil { + return nil, nil, nil, err } - if pos < g.Size { - fmt.Fprintf(&out, ".zero %d\n", g.Size-pos) - } - } - if opts.GOOS == "linux" { - out.WriteString(".section .note.GNU-stack,\"\",@progbits\n") } - return out.String(), data, nil + return f, &e, data, nil } var nativeName = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`) @@ -319,6 +298,7 @@ func nativeFlags(s string, allowed ...string) (map[string]bool, error) { var nativeAMD64PseudoRegister = regexp.MustCompile(`\b(?:FP|g|G)\b`) type nativeEmitter struct { + symbolOperand func(string, bool) (string, error) target NativeOptions funcs, globals, imports, labels map[string]string } @@ -331,6 +311,9 @@ func (e *nativeEmitter) prefix() string { } func (e *nativeEmitter) symbol(s string, data bool) (string, error) { + if e.symbolOperand != nil { + return e.symbolOperand(s, data) + } if !strings.HasSuffix(s, "(SB)") { return "", fmt.Errorf("unsupported native symbol %s", s) } @@ -362,3 +345,67 @@ func (e *nativeEmitter) branch(o Operand, external bool) (string, error) { } return "", fmt.Errorf("undefined native branch label %s", o.String()) } + +func (e *nativeEmitter) functionLabels(fn Func, i int) error { + e.labels = map[string]string{} + for _, ins := range fn.Instrs { + if ins.Op == OpLABEL { + name := ins.Args[0].Sym + if !nativeName.MatchString(name) { + return fmt.Errorf("unsupported native label %s", name) + } + if _, ok := e.labels[name]; ok { + return fmt.Errorf("duplicate native label %s", name) + } + e.labels[name] = fmt.Sprintf("Lnative_%d_label_%d", i, len(e.labels)) + } + } + return nil +} +func (e *nativeEmitter) functionBody(out *strings.Builder, fn Func) error { + for _, ins := range fn.Instrs { + var err error + if e.target.GOARCH == "arm64" { + err = (&nativeARM64{e}).instruction(out, ins) + } else { + err = (&nativeAMD64{e}).instruction(out, ins) + } + if err != nil { + return fmt.Errorf("native %s: %s: %w", fn.Sym, ins.Raw, err) + } + } + return nil +} + +func (e *nativeEmitter) dataValues(f *File, g GloblStmt) ([]DataStmt, error) { + var values []DataStmt + for _, d := range f.Data { + if d.Sym == g.Sym { + values = append(values, d) + } + } + sort.Slice(values, func(i, j int) bool { return values[i].Off < values[j].Off }) + pos := int64(0) + for _, d := range values { + if d.Off < pos || d.Width <= 0 || d.Off > g.Size || d.Width > g.Size-d.Off { + return nil, fmt.Errorf("overlapping or out-of-bounds native DATA for %s", g.Sym) + } + if d.Addr != "" { + if d.Width != 8 { + return nil, fmt.Errorf("native address DATA requires width 8") + } + if _, err := e.symbol(d.Addr, true); err != nil { + return nil, err + } + } else { + if d.Payload != nil { + return nil, fmt.Errorf("native string DATA is unsupported") + } + if d.Width != 1 && d.Width != 2 && d.Width != 4 && d.Width != 8 { + return nil, fmt.Errorf("unsupported native DATA width %d", d.Width) + } + } + pos = d.Off + d.Width + } + return values, nil +} From 66e8f32af1ccf9577e228a32a92ec77f53140e61 Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Thu, 24 Sep 2026 09:40:57 +0800 Subject: [PATCH 5/6] docs: describe shared LLVM IR driver integration --- doc/native-arm64.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/native-arm64.md b/doc/native-arm64.md index e724c5cd..8a42983b 100644 --- a/doc/native-arm64.md +++ b/doc/native-arm64.md @@ -4,9 +4,12 @@ (`GOOS`, `GOARCH`, `PackagePath`, and `Imports`). It lowers each restricted Plan 9 TEXT to a naked LLVM function containing function-local inline assembly, and DATA/GLOBL to LLVM global definitions. Linux and Darwin on amd64 and arm64 are -supported. The driver links the returned module into its package module before -normal optimization, bitcode/LTO and object emission. No module-level assembly, -separate native assembler invocation, Go object reader or Go toolchain is needed. +supported. The caller owns the returned module. LLGo serializes it as LLVM IR +and uses the same `.ll` compilation, object/archive and LTO pipeline as ordinary +Plan 9 assembly. Native carriers bypass signature-based ABI rewrites. Other +consumers may link the module into a larger LLVM module before optimization. +No module-level assembly, separate native assembler invocation, Go object reader +or Go toolchain is needed. `TranslateNativeSource` remains available for consumers needing standalone native assembly. It shares source validation and instruction lowering with the module From 35531ccdc908213c2e8b75af1b4982d5439a0753 Mon Sep 17 00:00:00 2001 From: ZhouGuangyuan Date: Thu, 24 Sep 2026 12:24:39 +0800 Subject: [PATCH 6/6] ci: retry stalled LLVM installation jobs