diff --git a/.github/actions/setup-windows-llvm19/action.yml b/.github/actions/setup-windows-llvm19/action.yml new file mode 100644 index 0000000..f9f56f3 --- /dev/null +++ b/.github/actions/setup-windows-llvm19/action.yml @@ -0,0 +1,74 @@ +name: Setup Windows LLVM 19 +description: Install the pinned LLVM 19 toolchain used by the Windows CI lanes. + +runs: + using: composite + steps: + - name: Set up MSYS2 for LLVM 19 + uses: msys2/setup-msys2@v2 + with: + msystem: CLANG64 + path-type: inherit + update: true + + - name: Install LLVM 19 + shell: msys2 {0} + env: + LLVM_VERSION: '19.1.7' + LLVM_PACKAGE_VERSION: '19.1.7-1' + run: | + set -euo pipefail + repo=https://repo.msys2.org/mingw/clang64 + prefix=mingw-w64-clang-x86_64 + # MSYS2 is rolling and no longer publishes LLVM 19 in its current + # repository metadata. Install one archived, version-consistent set + # so Windows exercises the same LLVM major as Linux and macOS. + packages=( + "clang-$LLVM_PACKAGE_VERSION" + "clang-libs-$LLVM_PACKAGE_VERSION" + "compiler-rt-$LLVM_PACKAGE_VERSION" + "llvm-$LLVM_PACKAGE_VERSION" + "llvm-libs-$LLVM_PACKAGE_VERSION" + "lld-$LLVM_PACKAGE_VERSION" + "libc++-$LLVM_PACKAGE_VERSION" + "libunwind-$LLVM_PACKAGE_VERSION" + "gettext-runtime-0.22.5-2" + "libffi-3.4.6-1" + "libiconv-1.17-4" + "libxml2-2.12.9-2" + "xz-5.6.3-3" + "zlib-1.3.1-1" + "zstd-1.5.6-2" + ) + urls=() + for package in "${packages[@]}"; do + urls+=("$repo/$prefix-$package-any.pkg.tar.zst") + done + + # These archived packages predate the gcc-libs -> cc-libs virtual + # dependency rename. libc++ 19 supplies the runtime; acknowledge only + # that metadata rename rather than disabling dependency checks. + pacman --noconfirm -U \ + --assume-installed "$prefix-cc-libs=$LLVM_VERSION" \ + "${urls[@]}" + + if [[ "$(llvm-config --version)" != "$LLVM_VERSION" ]]; then + echo "expected LLVM $LLVM_VERSION, got $(llvm-config --version)" >&2 + exit 1 + fi + + flatten() { + local value=$1 + value="${value//$'\r'/}" + value="${value//$'\n'/ }" + printf '%s' "$value" + } + echo "CGO_CFLAGS=$(flatten "$(llvm-config --cflags)")" >> "$GITHUB_ENV" + echo "CGO_CXXFLAGS=$(flatten "$(llvm-config --cxxflags)")" >> "$GITHUB_ENV" + echo "CGO_LDFLAGS=$(flatten "$(llvm-config --ldflags --libs all --system-libs)")" >> "$GITHUB_ENV" + clang_bin="$(cygpath -w /clang64/bin)" + echo "CC=$clang_bin\\clang.exe" >> "$GITHUB_ENV" + echo "CXX=$clang_bin\\clang++.exe" >> "$GITHUB_ENV" + echo 'CGO_ENABLED=1' >> "$GITHUB_ENV" + echo 'GOFLAGS=-tags=byollvm' >> "$GITHUB_ENV" + echo "$clang_bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index bed9091..d8737a2 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.22.x' + go-version: '1.26.x' cache: true - name: Go fmt check @@ -72,12 +72,14 @@ jobs: os: - ubuntu-latest - macos-latest + - windows-latest go-version: - '1.21.x' - '1.22.x' - '1.23.x' - '1.24.x' - '1.25.x' + - '1.26.x' steps: - name: Checkout @@ -98,6 +100,16 @@ jobs: brew install llvm@19 echo "PATH=$(brew --prefix llvm@19)/bin:$PATH" >> $GITHUB_ENV + - name: Setup Python (Windows) + if: runner.os == 'Windows' + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install LLVM 19 (Windows) + if: runner.os == 'Windows' + uses: ./.github/actions/setup-windows-llvm19 + - name: Setup Go uses: actions/setup-go@v5 with: @@ -105,16 +117,23 @@ jobs: cache: true - name: Stdlib asm corpus gate + shell: bash run: | chmod +x scripts/check-stdlib-corpus.sh scripts/check-stdlib-corpus.sh test: - runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + runs-on: ${{ matrix.os }} strategy: # Keep both matrix variants visible even if one fails. fail-fast: false matrix: + os: + - ubuntu-latest + - windows-latest go-version: - '1.21.x' - '1.22.x' @@ -133,7 +152,14 @@ jobs: go-version: ${{ matrix.go-version }} cache: true - - name: Install LLVM 19 + - name: Setup Python (Windows) + if: runner.os == 'Windows' + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install LLVM 19 (Ubuntu) + if: runner.os == 'Linux' run: | echo "deb http://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-19 main" | sudo tee /etc/apt/sources.list.d/llvm.list wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/llvm-snapshot.asc >/dev/null @@ -141,24 +167,62 @@ jobs: sudo apt-get install -y llvm-19-dev clang-19 libclang-19-dev lld-19 libunwind-19-dev libc++-19-dev echo "PATH=/usr/lib/llvm-19/bin:$PATH" >> $GITHUB_ENV + - name: Install LLVM 19 (Windows) + if: runner.os == 'Windows' + uses: ./.github/actions/setup-windows-llvm19 + - name: LLVM tools info + shell: bash run: | - which llc + command -v llc llc --version - which clang + command -v clang clang --version - - name: Go test + - name: Go test (Ubuntu) + if: runner.os != 'Windows' run: go test ./... - - name: Go test (cmd/plan9asm) + - name: Go test (cmd/plan9asm, Ubuntu) + if: runner.os != 'Windows' run: go test ./... working-directory: cmd/plan9asm - - name: Go test (cmd/plan9asmll) + - name: Go test (cmd/plan9asmll, Ubuntu) + if: runner.os != 'Windows' run: go test ./... working-directory: cmd/plan9asmll + - name: Go test with coverage (Windows) + if: runner.os == 'Windows' && matrix.go-version == '1.26.x' + shell: bash + run: | + set -euo pipefail + go test ./... -coverprofile=coverage-windows-root.out + (cd cmd/plan9asm && go test ./... -coverprofile=../../coverage-windows-plan9asm.out) + (cd cmd/plan9asmll && go test ./... -coverprofile=../../coverage-windows-plan9asmll.out) + + - name: Go test (Windows) + if: runner.os == 'Windows' && matrix.go-version != '1.26.x' + shell: bash + run: | + set -euo pipefail + go test ./... + (cd cmd/plan9asm && go test ./...) + (cd cmd/plan9asmll && go test ./...) + + - name: Upload Windows coverage to Codecov + if: runner.os == 'Windows' && matrix.go-version == '1.26.x' + uses: codecov/codecov-action@v5 + with: + use_oidc: true + files: ./coverage-windows-root.out,./coverage-windows-plan9asm.out,./coverage-windows-plan9asmll.out + disable_search: true + flags: windows + name: windows-go1.26 + fail_ci_if_error: true + verbose: true + arm-scan: runs-on: ubuntu-latest strategy: @@ -192,7 +256,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.26.1' + go-version: '1.26.x' cache: true - name: ARM scan gate @@ -229,7 +293,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.22.x' + go-version: '1.26.x' cache: true - name: Install LLVM 19 @@ -256,7 +320,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.25.x' + go-version: '1.26.x' cache: true - name: Install LLVM 19 @@ -289,7 +353,8 @@ jobs: with: use_oidc: true files: ./coverage-root.out + disable_search: true flags: unittests - name: ubuntu-go1.25 + name: ubuntu-go1.26 fail_ci_if_error: false verbose: true diff --git a/amd64_ctx.go b/amd64_ctx.go index bca38fc..4ea3dac 100644 --- a/amd64_ctx.go +++ b/amd64_ctx.go @@ -1160,6 +1160,13 @@ func parseSBRef(sym string) (base string, off int64, ok bool) { } func (c *amd64Ctx) addrFromMem(mem MemRef) (addrI64 string, err error) { + if mem.Segment != "" { + return "", fmt.Errorf("segment-relative memory requires a segment-aware pointer") + } + return c.addrFromPlainMem(mem) +} + +func (c *amd64Ctx) addrFromPlainMem(mem MemRef) (addrI64 string, err error) { base, err := c.loadReg(mem.Base) if err != nil { return "", err @@ -1187,6 +1194,31 @@ func (c *amd64Ctx) addrFromMem(mem MemRef) (addrI64 string, err error) { return cur, nil } +func (c *amd64Ctx) ptrFromMem(mem MemRef) (ptr, ptrType string, err error) { + addr, err := c.addrFromPlainMem(mem) + if err != nil { + return "", "", err + } + if mem.Segment == "" { + return c.ptrFromAddrI64(addr), "ptr", nil + } + addressSpace := 0 + switch mem.Segment { + case GS: + // LLVM's x86 target maps address space 256 to the GS segment. + addressSpace = 256 + case FS: + // LLVM's x86 target maps address space 257 to the FS segment. + addressSpace = 257 + default: + return "", "", fmt.Errorf("unsupported x86 segment register %s", mem.Segment) + } + t := c.newTmp() + ptrType = fmt.Sprintf("ptr addrspace(%d)", addressSpace) + fmt.Fprintf(c.b, " %%%s = inttoptr i64 %s to %s\n", t, addr, ptrType) + return "%" + t, ptrType, nil +} + func (c *amd64Ctx) ptrFromAddrI64(addrI64 string) string { t := c.newTmp() fmt.Fprintf(c.b, " %%%s = inttoptr i64 %s to ptr\n", t, addrI64) diff --git a/amd64_lower_mov.go b/amd64_lower_mov.go index 2323375..e3d4134 100644 --- a/amd64_lower_mov.go +++ b/amd64_lower_mov.go @@ -201,7 +201,19 @@ func (c *amd64Ctx) lowerMov(op Op, ins Instr) (ok bool, terminated bool, err err // MOVQ src, dst switch dst.Kind { case OpReg: - v, err := c.evalI64(src) + var v string + var err error + if src.Kind == OpMem && src.Mem.Segment != "" { + p, ptrType, err2 := c.ptrFromMem(src.Mem) + if err2 != nil { + return true, false, err2 + } + t := c.newTmp() + fmt.Fprintf(c.b, " %%%s = load i64, %s %s, align 1\n", t, ptrType, p) + v = "%" + t + } else { + v, err = c.evalI64(src) + } if err != nil { // Allow MOVQ mem, reg. if src.Kind == OpMem { @@ -239,12 +251,11 @@ func (c *amd64Ctx) lowerMov(op Op, ins Instr) (ok bool, terminated bool, err err if err != nil { return true, false, err } - addr, err := c.addrFromMem(dst.Mem) + p, ptrType, err := c.ptrFromMem(dst.Mem) if err != nil { return true, false, err } - p := c.ptrFromAddrI64(addr) - fmt.Fprintf(c.b, " store i64 %s, ptr %s, align 1\n", v, p) + fmt.Fprintf(c.b, " store i64 %s, %s %s, align 1\n", v, ptrType, p) return true, false, nil case OpSym: if !strings.HasSuffix(strings.TrimSpace(dst.Sym), "(SB)") { diff --git a/amd64_needed.go b/amd64_needed.go index 64f37ff..9a52b4d 100644 --- a/amd64_needed.go +++ b/amd64_needed.go @@ -23,7 +23,7 @@ func funcNeedsAMD64CFG(fn Func) bool { } // Keep the linear path only for the tiny subset it currently lowers. switch Op(op) { - case OpTEXT, OpRET, OpBYTE, OpMOVQ, OpMOVL, OpADDQ, OpSUBQ, OpXORQ, OpCPUID, OpXGETBV: + case OpTEXT, OpBYTE, OpMOVQ, OpMOVL, OpADDQ, OpSUBQ, OpXORQ, OpCPUID, OpXGETBV: // For MOVQ/MOVL, linear lowering supports immediate/reg/FP value flow. // Addressing forms (mem/sym) require CFG lowering. if (op == "MOVQ" || op == "MOVL") && len(ins.Args) == 2 { @@ -36,6 +36,10 @@ func funcNeedsAMD64CFG(fn Func) bool { } } } + case OpRET: + if len(ins.Args) != 0 { + return true + } default: return true } diff --git a/amd64_segment_test.go b/amd64_segment_test.go new file mode 100644 index 0000000..9dd820d --- /dev/null +++ b/amd64_segment_test.go @@ -0,0 +1,61 @@ +//go:build !llgo + +package plan9asm + +import ( + "strings" + "testing" +) + +func TestTranslateAMD64SegmentMemory(t *testing.T) { + src := ` +TEXT loadGS(SB),NOSPLIT,$0-0 + MOVQ 0x30(GS), DI + MOVQ DI, 0(CX)(GS) + MOVQ -8(FS), AX + RET +` + file, err := Parse(ArchAMD64, src) + if err != nil { + t.Fatal(err) + } + ir, err := Translate(file, Options{ + TargetTriple: "x86_64-pc-windows-msvc", + Sigs: map[string]FuncSig{ + "loadGS": {Name: "loadGS", Ret: Void}, + }, + Goarch: "amd64", + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "load i64, ptr addrspace(256)", + "store i64", + "ptr addrspace(256)", + "load i64, ptr addrspace(257)", + } { + if !strings.Contains(ir, want) { + t.Fatalf("segment-relative output missing %q:\n%s", want, ir) + } + } + if strings.Contains(ir, "0x30(GS)") { + t.Fatalf("segment-relative memory was emitted as a symbol:\n%s", ir) + } +} + +func TestAMD64SegmentMemoryRejectsInvalidUses(t *testing.T) { + c, _ := newAMD64CtxWithFuncForTest(t, Func{}, FuncSig{Name: "segmentErrors", Ret: Void}, nil) + if _, err := c.addrFromMem(MemRef{Segment: GS}); err == nil { + t.Fatal("addrFromMem accepted a segment-relative address") + } + if _, _, err := c.ptrFromMem(MemRef{Segment: Reg("CS")}); err == nil { + t.Fatal("ptrFromMem accepted an unsupported segment") + } + if ok, _, err := c.lowerMov(OpMOVQ, Instr{Args: []Operand{ + {Kind: OpMem, Mem: MemRef{Segment: Reg("CS")}}, + {Kind: OpReg, Reg: AX}, + }}); !ok || err == nil { + t.Fatalf("lowerMov(invalid segment) = (%v, %v), want handled error", ok, err) + } +} diff --git a/amd64_translate.go b/amd64_translate.go index 57ded58..5fbbd71 100644 --- a/amd64_translate.go +++ b/amd64_translate.go @@ -118,6 +118,12 @@ func (c *amd64Ctx) lowerInstr(bi int, ii int, ins Instr, emitBr amd64EmitBr, emi case OpTEXT, OpBYTE: return false, nil case OpRET: + if len(ins.Args) == 1 && ins.Args[0].Kind == OpSym && strings.HasSuffix(ins.Args[0].Sym, "(SB)") { + return true, c.tailCallAndRet(ins.Args[0]) + } + if len(ins.Args) > 1 { + return true, fmt.Errorf("amd64 RET expects at most 1 operand: %q", ins.Raw) + } return true, c.lowerRET() case "PCALIGN", "NO_LOCAL_POINTERS", "PCDATA", "FUNCDATA", "NOP", "ADJSP", "CLD", "STD", "REP", "PUSH_REGS_HOST_TO_ABI0()", "POP_REGS_HOST_TO_ABI0()": diff --git a/arm64_needed.go b/arm64_needed.go index 3eee0b1..29da2fe 100644 --- a/arm64_needed.go +++ b/arm64_needed.go @@ -20,7 +20,12 @@ func funcNeedsARM64CFG(fn Func) bool { } switch Op(op) { // Keep the linear path only for the tiny subset it can currently lower. - case OpTEXT, OpRET, OpBYTE, OpMRS: + case OpTEXT, OpBYTE, OpMRS: + continue + case OpRET: + if len(ins.Args) != 0 { + return true + } continue case OpMOVD: // Linear arm64 lowering only supports immediate/reg/FP value moves. diff --git a/arm64_translate.go b/arm64_translate.go index 220bf88..b051de8 100644 --- a/arm64_translate.go +++ b/arm64_translate.go @@ -121,6 +121,12 @@ func (c *arm64Ctx) lowerInstr(bi int, ins Instr, emitBr arm64EmitBr, emitCondBr case OpTEXT, OpBYTE: return false, nil case OpRET: + if len(ins.Args) == 1 && ins.Args[0].Kind == OpSym && strings.HasSuffix(ins.Args[0].Sym, "(SB)") { + return true, c.tailCallAndRet(ins.Args[0]) + } + if len(ins.Args) > 1 { + return true, fmt.Errorf("arm64 RET expects at most 1 operand: %q", ins.Raw) + } return true, c.lowerRET() case "WORD": return false, c.lowerRawWord(ins) diff --git a/arm_deep_coverage_test.go b/arm_deep_coverage_test.go index 9a352a2..cd43a3c 100644 --- a/arm_deep_coverage_test.go +++ b/arm_deep_coverage_test.go @@ -420,8 +420,8 @@ func TestARMBranchMovmAndSyscallCoverage(t *testing.T) { if err := c2.tailCallAndRet(Operand{Kind: OpSym, Sym: "ret32"}); err == nil { t.Fatalf("tailCallAndRet(no sb) unexpectedly succeeded") } - if err := c2.tailCallAndRet(Operand{Kind: OpSym, Sym: "missing(SB)"}); err == nil { - t.Fatalf("tailCallAndRet(missing sig) unexpectedly succeeded") + if err := c2.tailCallAndRet(Operand{Kind: OpSym, Sym: "missing(SB)"}); err != nil { + t.Fatalf("tailCallAndRet(missing sig) error = %v", err) } if err := c2.tailCallAndRet(Operand{Kind: OpSym, Sym: "tail64(SB)"}); err == nil { t.Fatalf("tailCallAndRet(mismatch) unexpectedly succeeded") diff --git a/arm_lower_branch.go b/arm_lower_branch.go index c0b8613..5e468ff 100644 --- a/arm_lower_branch.go +++ b/arm_lower_branch.go @@ -125,7 +125,10 @@ func (c *armCtx) tailCallAndRet(symOp Operand) error { callee := c.resolve(strings.TrimSuffix(s, "(SB)")) csig, ok := c.sigs[callee] if !ok { - return fmt.Errorf("arm tailcall missing signature for %q", callee) + // Cross-package trampoline (e.g. sync/atomic -> internal/runtime/atomic). + // If we don't have an explicit signature, fall back to caller signature. + csig = c.sig + csig.Name = callee } callee = funcSigSymbol(callee, csig) args := make([]string, 0, len(csig.Args)) diff --git a/arm_needed.go b/arm_needed.go index 491c432..9f03aff 100644 --- a/arm_needed.go +++ b/arm_needed.go @@ -15,7 +15,12 @@ func funcNeedsARMCFG(fn Func) bool { op = op[:dot] } switch Op(op) { - case OpTEXT, OpRET, OpBYTE: + case OpTEXT, OpBYTE: + continue + case OpRET: + if len(ins.Args) != 0 { + return true + } continue case "MOVW", "MOVB", "MOVBU", "ADD", "SUB", "AND", "ORR", "EOR", "RSB": // The linear ARM path cannot handle conditional execution or post-inc. diff --git a/arm_translate_cfg.go b/arm_translate_cfg.go index 65aa86b..7bce3a6 100644 --- a/arm_translate_cfg.go +++ b/arm_translate_cfg.go @@ -80,6 +80,12 @@ func (c *armCtx) lowerInstr(bi int, ins Instr, emitBr armEmitBr, emitCondBr armE case string(OpTEXT), string(OpBYTE): return false, nil case string(OpRET): + if len(ins.Args) == 1 && ins.Args[0].Kind == OpSym && strings.HasSuffix(ins.Args[0].Sym, "(SB)") { + return true, c.tailCallAndRet(ins.Args[0]) + } + if len(ins.Args) > 1 { + return true, fmt.Errorf("arm RET expects at most 1 operand: %q", ins.Raw) + } return true, c.lowerRET() case "UNDEF": c.b.WriteString(" call void asm sideeffect \"udf #0\", \"~{memory}\"()\n") diff --git a/cmd/plan9asm/main.go b/cmd/plan9asm/main.go index 44e88b6..19d9da4 100644 --- a/cmd/plan9asm/main.go +++ b/cmd/plan9asm/main.go @@ -515,7 +515,10 @@ func resolvePath(path string) (string, error) { func goListPackages(query, goos, goarch string) ([]goListPackage, error) { args := []string{"list", "-json", query} cmd := exec.Command("go", args...) - cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch) + // Package discovery only needs metadata. Disabling cgo prevents a host C + // toolchain from being used while listing a different target (for example, + // Windows-hosted tooling inspecting Linux standard-library assembly). + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+goos, "GOARCH="+goarch) out, err := cmd.Output() if err != nil { diff --git a/cmd/plan9asm/main_test.go b/cmd/plan9asm/main_test.go index b8a8025..1a396a8 100644 --- a/cmd/plan9asm/main_test.go +++ b/cmd/plan9asm/main_test.go @@ -1,27 +1,59 @@ package main import ( + "os" "path/filepath" "reflect" + "runtime" "testing" ) func TestPackageSFilesAbsFiltersNonPlan9Asm(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(t.TempDir(), "abs", "keep.s") pkg := goListPackage{ - Dir: "/tmp/pkg", + Dir: dir, SFiles: []string{ "foo.s", "bar.S", "baz.Sx", - filepath.Join("/abs", "keep.s"), + abs, }, } got := packageSFilesAbs(pkg) want := []string{ - filepath.Join("/tmp/pkg", "foo.s"), - filepath.Join("/abs", "keep.s"), + filepath.Join(dir, "foo.s"), + abs, } if !reflect.DeepEqual(got, want) { t.Fatalf("packageSFilesAbs() = %#v, want %#v", got, want) } } + +func TestGoListPackagesDisablesCgo(t *testing.T) { + dir := t.TempDir() + name := "go" + script := "#!/bin/sh\nprintf '%s\\n' \"{\\\"ImportPath\\\":\\\"$CGO_ENABLED\\\"}\"\n" + if runtime.GOOS == "windows" { + name = "go.cmd" + script = "@echo off\r\necho {\"ImportPath\":\"%CGO_ENABLED%\"}\r\n" + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + pkgs, err := goListPackages("std", "linux", "amd64") + if err != nil { + t.Fatalf("goListPackages() error = %v", err) + } + if len(pkgs) != 1 || pkgs[0].ImportPath != "0" { + t.Fatalf("goListPackages() = %#v, want one package with CGO disabled", pkgs) + } +} diff --git a/cmd/plan9asmscan/main.go b/cmd/plan9asmscan/main.go index 90a5284..8c1e243 100644 --- a/cmd/plan9asmscan/main.go +++ b/cmd/plan9asmscan/main.go @@ -152,9 +152,12 @@ func listStdPackages(goos, goarch string) ([]pkgJSON, error) { "GOOS="+goos, "GOARCH="+goarch, ) - out, err := cmd.CombinedOutput() + out, err := cmd.Output() if err != nil { - msg := strings.TrimSpace(string(out)) + var msg string + if ee, ok := err.(*exec.ExitError); ok { + msg = strings.TrimSpace(string(ee.Stderr)) + } if msg != "" { return nil, fmt.Errorf("go list -json std: %w: %s", err, msg) } diff --git a/cmd/plan9asmscan/main_test.go b/cmd/plan9asmscan/main_test.go index 5b45981..dfcb07b 100644 --- a/cmd/plan9asmscan/main_test.go +++ b/cmd/plan9asmscan/main_test.go @@ -176,14 +176,44 @@ func TestListStdPackages(t *testing.T) { } } +func TestListStdPackagesIgnoresDiagnostics(t *testing.T) { + dir := t.TempDir() + name := "go" + script := "#!/bin/sh\nif [ \"$CGO_ENABLED\" != 0 ]; then echo cgo must be disabled >&2; exit 1; fi\necho warning from fake go >&2\nprintf '%s\\n' '{\"ImportPath\":\"runtime\"}'\n" + if runtime.GOOS == "windows" { + name = "go.cmd" + script = "@echo off\r\nif not \"%CGO_ENABLED%\"==\"0\" exit /b 1\r\necho warning from fake go 1>&2\r\necho {\"ImportPath\":\"runtime\"}\r\n" + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + pkgs, err := listStdPackages("linux", "amd64") + if err != nil { + t.Fatalf("listStdPackages() error = %v", err) + } + if len(pkgs) != 1 || pkgs[0].ImportPath != "runtime" { + t.Fatalf("listStdPackages() = %#v, want one runtime package", pkgs) + } +} + func TestPackageSFilesAndAddOpStat(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(t.TempDir(), "abs", "c.s") pkg := pkgJSON{ ImportPath: "example/p", - Dir: "/tmp/pkg", - SFiles: []string{"a.s", "b.S", filepath.Join("/abs", "c.s")}, + Dir: dir, + SFiles: []string{"a.s", "b.S", abs}, } got := packageSFiles(pkg) - want := []string{filepath.Join("/tmp/pkg", "a.s"), filepath.Join("/abs", "c.s")} + want := []string{filepath.Join(dir, "a.s"), abs} if !reflect.DeepEqual(got, want) { t.Fatalf("packageSFiles() = %#v, want %#v", got, want) } diff --git a/go_translate.go b/go_translate.go index 72548fd..7344fb3 100644 --- a/go_translate.go +++ b/go_translate.go @@ -90,6 +90,11 @@ func TranslateGoModule(pkg GoPackage, src []byte, opt GoModuleOptions) (*GoModul if bytes.Contains(src, []byte("const_")) { src = goExpandConsts(src, pkg.Types, pkg.Imports) } + // Struct layout macros only exist when the assembly includes go_asm.h. + // Keep the common path cheap: building them walks every package-scope type. + if bytes.Contains(src, []byte("go_asm.h")) { + src = goExpandAsmHeaderTypes(src, pkg.Types, opt.GOARCH) + } file, err := Parse(arch, string(src)) if err != nil { @@ -133,6 +138,7 @@ func TranslateGoModule(pkg GoPackage, src []byte, opt GoModuleOptions) (*GoModul var goABISuffixRe = regexp.MustCompile(`]*>$`) var goConstRefRe = regexp.MustCompile(`\bconst_[A-Za-z0-9_]+\b`) var goConstPlusRefRe = regexp.MustCompile(`([\pL\pN_∕·./]+)\+const_([A-Za-z0-9_]+)`) +var goAsmHeaderIdentRe = regexp.MustCompile(`\b[A-Za-z_][A-Za-z0-9_]*\b`) func goStripABISuffix(sym string) string { sym = goABISuffixRe.ReplaceAllString(sym, "") @@ -488,8 +494,16 @@ func goExpandConsts(src []byte, pkgTypes *types.Package, imports map[string]*typ if !ok || c == nil || c.Val() == nil { return "", false } - if i64, ok := constant.Int64Val(c.Val()); ok { - return fmt.Sprintf("%d", i64), true + switch c.Val().Kind() { + case constant.Int: + if i64, ok := constant.Int64Val(c.Val()); ok { + return strconv.FormatInt(i64, 10), true + } + if u64, ok := constant.Uint64Val(c.Val()); ok { + return strconv.FormatUint(u64, 10), true + } + case constant.String: + return strconv.Quote(constant.StringVal(c.Val())), true } return "", false } @@ -534,6 +548,57 @@ func goExpandConsts(src []byte, pkgTypes *types.Package, imports map[string]*typ }) } +// goExpandAsmHeaderTypes expands the struct size and field offset macros that +// cmd/compile writes to go_asm.h for the package's named struct types. The +// Plan 9 parser intentionally ignores #include, so the Go-aware translation +// path resolves these macros directly from the same go/types information it +// already uses for function signatures. +func goExpandAsmHeaderTypes(src []byte, pkgTypes *types.Package, goarch string) []byte { + if pkgTypes == nil || pkgTypes.Scope() == nil { + return src + } + sizes := types.SizesFor("gc", goarch) + if sizes == nil { + return src + } + macros := make(map[string]string) + for _, name := range pkgTypes.Scope().Names() { + obj, ok := pkgTypes.Scope().Lookup(name).(*types.TypeName) + if !ok { + continue + } + st, ok := obj.Type().Underlying().(*types.Struct) + if !ok { + continue + } + size := sizes.Sizeof(obj.Type()) + if size < 0 { + continue + } + macros[name+"__size"] = strconv.FormatInt(size, 10) + fields := make([]*types.Var, st.NumFields()) + for i := range fields { + fields[i] = st.Field(i) + } + for i, offset := range sizes.Offsetsof(fields) { + field := fields[i] + if field.Name() == "_" || offset < 0 { + continue + } + macros[name+"_"+field.Name()] = strconv.FormatInt(offset, 10) + } + } + if len(macros) == 0 { + return src + } + return goAsmHeaderIdentRe.ReplaceAllFunc(src, func(ident []byte) []byte { + if value, ok := macros[string(ident)]; ok { + return []byte(value) + } + return ident + }) +} + func goLLVMTypeForType(t types.Type, goarch string) (LLVMType, error) { switch tt := t.(type) { case *types.Basic: diff --git a/go_translate_helpers_test.go b/go_translate_helpers_test.go index 4fc7f58..8a961eb 100644 --- a/go_translate_helpers_test.go +++ b/go_translate_helpers_test.go @@ -1,6 +1,7 @@ package plan9asm import ( + "bytes" "go/ast" "go/constant" "go/parser" @@ -99,6 +100,9 @@ func TestGoHelperArchTupleAndSymParsing(t *testing.T) { func TestGoExpandConsts(t *testing.T) { pkg := types.NewPackage("test/pkg", "pkg") addIntConst(pkg, "Local", 7) + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "Big", types.Typ[types.UntypedInt], constant.MakeUint64(^uint64(0)))) + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "Text", types.Typ[types.UntypedString], constant.MakeString("test"))) + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "Bool", types.Typ[types.UntypedBool], constant.MakeBool(true))) runtimePkg := types.NewPackage("runtime", "runtime") addIntConst(runtimePkg, "Const", 3) @@ -108,6 +112,9 @@ MOVD foo+const_Local(SB), R1 MOVD runtime.foo+const_Const(SB), R2 MOVD runtime/foo+const_Const(SB), R3 MOVD missing+const_Missing(SB), R4 +DATA big(SB)/8, $const_Big +DATA text(SB)/4, $const_Text +MOVD $const_Bool, R5 `) got := string(goExpandConsts(src, pkg, map[string]*types.Package{ "runtime": runtimePkg, @@ -119,11 +126,113 @@ MOVD missing+const_Missing(SB), R4 "MOVD runtime.foo+3(SB), R2", "MOVD runtime/foo+3(SB), R3", "MOVD missing+const_Missing(SB), R4", + "DATA big(SB)/8, $18446744073709551615", + `DATA text(SB)/4, $"test"`, + "MOVD $const_Bool, R5", } { if !strings.Contains(got, want) { t.Fatalf("expanded consts missing %q in:\n%s", want, got) } } + if _, err := dataStmtPayload(DataStmt{Sym: "·wide", Width: 1, Payload: []byte("xx")}); err == nil { + t.Fatal("oversized DATA payload unexpectedly accepted") + } +} + +func TestGoAsmHeaderDataConstants(t *testing.T) { + pkg := types.NewPackage("test/pkg", "pkg") + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "smallInt", types.Typ[types.UntypedInt], constant.MakeInt64(42))) + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "bigInt", types.Typ[types.UntypedInt], constant.MakeUint64(^uint64(0)))) + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "stringVal", types.Typ[types.UntypedString], constant.MakeString("test"))) + long := "this_is_a_string_constant_longer_than_seventy_characters_which_used_to_fail_see_issue_50253" + pkg.Scope().Insert(types.NewConst(token.NoPos, pkg, "longStringVal", types.Typ[types.UntypedString], constant.MakeString(long))) + fields := []*types.Var{ + types.NewField(token.NoPos, pkg, "a", types.Typ[types.Uint64], false), + types.NewField(token.NoPos, pkg, "b", types.NewArray(types.Typ[types.Uint8], 100), false), + types.NewField(token.NoPos, pkg, "c", types.Typ[types.Uint8], false), + types.NewField(token.NoPos, pkg, "_", types.Typ[types.Uint64], false), + } + typName := types.NewTypeName(token.NoPos, pkg, "typ", nil) + types.NewNamed(typName, types.NewStruct(fields, nil), nil) + pkg.Scope().Insert(typName) + intName := types.NewTypeName(token.NoPos, pkg, "word", nil) + types.NewNamed(intName, types.Typ[types.Int], nil) + pkg.Scope().Insert(intName) + + unchanged := []byte("MOVD $typ__size, R0") + if got := goExpandAsmHeaderTypes(unchanged, nil, "arm64"); !bytes.Equal(got, unchanged) { + t.Fatalf("nil-package expansion changed source: %q", got) + } + if got := goExpandAsmHeaderTypes(unchanged, pkg, "unsupported"); !bytes.Equal(got, unchanged) { + t.Fatalf("unsupported-arch expansion changed source: %q", got) + } + nonStructPkg := types.NewPackage("test/nonstruct", "nonstruct") + nonStructPkg.Scope().Insert(types.NewVar(token.NoPos, nonStructPkg, "value", types.Typ[types.Int])) + if got := goExpandAsmHeaderTypes(unchanged, nonStructPkg, "arm64"); !bytes.Equal(got, unchanged) { + t.Fatalf("package without struct macros changed source: %q", got) + } + + src := goExpandConsts([]byte(`TEXT ·dummy(SB),NOSPLIT,$0-0 +RET +DATA ·small(SB)/8, $const_smallInt +DATA ·big(SB)/8, $const_bigInt +DATA ·text(SB)/4, $const_stringVal +DATA ·long(SB)/91, $const_longStringVal +`), pkg, nil) + src = goExpandAsmHeaderTypes(append(src, []byte(`DATA ·typSize(SB)/8, $typ__size +DATA ·typA(SB)/8, $typ_a +DATA ·typB(SB)/8, $typ_b +DATA ·typC(SB)/8, $typ_c +DATA ·blank(SB)/8, $typ__ +`)...), pkg, "arm64") + file, err := Parse(ArchARM64, string(src)) + if err == nil { + t.Fatal("blank struct field macro unexpectedly expanded") + } + src = bytes.ReplaceAll(src, []byte("DATA ·blank(SB)/8, $typ__\n"), nil) + file, err = Parse(ArchARM64, string(src)) + if err != nil { + t.Fatalf("Parse(expanded asmhdr) error = %v\n%s", err, src) + } + want := map[string][]byte{ + "·small": {42, 0, 0, 0, 0, 0, 0, 0}, + "·big": {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, + "·text": []byte("test"), + "·long": []byte(long), + "·typSize": {120, 0, 0, 0, 0, 0, 0, 0}, + "·typA": {0, 0, 0, 0, 0, 0, 0, 0}, + "·typB": {8, 0, 0, 0, 0, 0, 0, 0}, + "·typC": {108, 0, 0, 0, 0, 0, 0, 0}, + } + for _, data := range file.Data { + got, err := dataStmtPayload(data) + if err != nil { + t.Fatalf("dataStmtPayload(%s) error = %v", data.Sym, err) + } + expect, ok := want[data.Sym] + if !ok { + t.Fatalf("unexpected DATA symbol %q", data.Sym) + } + if !bytes.Equal(got, expect) { + t.Fatalf("DATA %s payload = %v, want %v", data.Sym, got, expect) + } + delete(want, data.Sym) + } + if len(want) != 0 { + t.Fatalf("missing DATA symbols: %v", want) + } + + translation, err := TranslateGoModule(GoPackage{Path: pkg.Path(), Types: pkg}, []byte(`#include "go_asm.h" +DATA ·typSize(SB)/8, $typ__size +GLOBL ·typSize(SB), RODATA, $8 +`), GoModuleOptions{GOARCH: "arm64"}) + if err != nil { + t.Fatalf("TranslateGoModule(go_asm.h) error = %v", err) + } + defer translation.Module.Dispose() + if ir := translation.Module.String(); !strings.Contains(ir, `c"x\00\00\00\00\00\00\00"`) { + t.Fatalf("TranslateGoModule(go_asm.h) did not expand typ__size:\n%s", ir) + } } func TestGoLLVMHelpers(t *testing.T) { diff --git a/parser.go b/parser.go index 850a7ec..df0ad32 100644 --- a/parser.go +++ b/parser.go @@ -165,7 +165,8 @@ func Parse(arch Arch, src string) (*File, error) { return nil, fmt.Errorf("line %d: RET outside TEXT: %q", lineno, stmt) } if strings.TrimSpace(rest) != "" { - // Some files use "RET" alone; accept "RET x" as generic for now. + // A symbol operand is a tail call; register operands retain the + // architecture-specific return behavior. args, err := parseOperandsCSV(rest) if err != nil { return nil, fmt.Errorf("line %d: %v", lineno, err) @@ -194,7 +195,7 @@ func Parse(arch Arch, src string) (*File, error) { if err := sc.Err(); err != nil { return nil, err } - if len(f.Funcs) == 0 { + if len(f.Funcs) == 0 && len(f.Data) == 0 && len(f.Globl) == 0 { return nil, fmt.Errorf("no TEXT directive found") } return f, nil @@ -202,12 +203,12 @@ func Parse(arch Arch, src string) (*File, error) { func parseDATAStmt(arch Arch, rest string) (DataStmt, error) { // DATA sym+off(SB)/width, $value - parts := strings.Split(rest, ",") - if len(parts) != 2 { + lhs, rhs, ok := strings.Cut(rest, ",") + if !ok { return DataStmt{}, fmt.Errorf("invalid DATA: %q", "DATA "+rest) } - lhs := strings.TrimSpace(parts[0]) - rhs := strings.TrimSpace(parts[1]) + lhs = strings.TrimSpace(lhs) + rhs = strings.TrimSpace(rhs) if lhs == "" || rhs == "" { return DataStmt{}, fmt.Errorf("invalid DATA: %q", "DATA "+rest) } @@ -235,12 +236,15 @@ func parseDATAStmt(arch Arch, rest string) (DataStmt, error) { sym, off := splitSymPlusOff(symPart) val, ok := parseImm(rhs) + var payload []byte if !ok { trimRHS := strings.TrimSpace(rhs) - // Accept string DATA payloads as zero placeholders for now. if strings.HasPrefix(trimRHS, "$\"") { - val = 0 - ok = true + str, err := strconv.Unquote(strings.TrimPrefix(trimRHS, "$")) + if err == nil && int64(len(str)) <= width { + payload = []byte(str) + ok = true + } } } if !ok { @@ -256,7 +260,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)}, nil + return DataStmt{Sym: sym, Off: off, Width: width, Value: uint64(val), Payload: payload}, nil } func parseWidth(arch Arch, s string) (int64, error) { diff --git a/parser_data_globl_test.go b/parser_data_globl_test.go index 51b8115..877534f 100644 --- a/parser_data_globl_test.go +++ b/parser_data_globl_test.go @@ -1,6 +1,34 @@ package plan9asm -import "testing" +import ( + "strings" + "testing" +) + +func TestParseDataOnlyFile(t *testing.T) { + file, err := Parse(ArchARM64, `DATA ·value(SB)/8, $42 +GLOBL ·value(SB),RODATA,$8 +`) + if err != nil { + t.Fatalf("Parse(data-only) error = %v", err) + } + if len(file.Funcs) != 0 || len(file.Data) != 1 || len(file.Globl) != 1 { + t.Fatalf("Parse(data-only) = funcs:%d data:%d globl:%d", len(file.Funcs), len(file.Data), len(file.Globl)) + } + mod, err := TranslateModule(file, Options{ + Goarch: "arm64", + ResolveSym: func(sym string) string { + return "test." + strings.TrimPrefix(sym, "·") + }, + }) + if err != nil { + t.Fatalf("TranslateModule(data-only) error = %v", err) + } + defer mod.Dispose() + if ir := mod.String(); !strings.Contains(ir, `@test.value = constant [8 x i8]`) || !strings.Contains(ir, `c"*\00`) { + t.Fatalf("TranslateModule(data-only) missing initialized global:\n%s", ir) + } +} func TestParseDataAndGloblDirectives(t *testing.T) { file, err := Parse(ArchARM64, `TEXT ·Fn(SB),NOSPLIT,$0-0 @@ -26,8 +54,12 @@ GLOBL ·symptr<>(SB), NOPTR, $(machTimebaseInfo__size) if ds := file.Data[0]; ds.Sym != "·tab<>" || ds.Off != 8 || ds.Width != 8 || ds.Value != 1 { t.Fatalf("unexpected first DATA: %#v", ds) } - if ds := file.Data[1]; ds.Sym != "·str<>" || ds.Value != 0 { - t.Fatalf("unexpected string DATA placeholder: %#v", ds) + if ds := file.Data[1]; ds.Sym != "·str<>" || string(ds.Payload) != "hello" { + t.Fatalf("unexpected string DATA payload: %#v", ds) + } + payload, err := dataStmtPayload(file.Data[1]) + if err != nil || string(payload[:5]) != "hello" || len(payload) != 8 || payload[5] != 0 { + t.Fatalf("padded string DATA payload = (%v, %v)", payload, err) } if ds := file.Data[2]; ds.Sym != "·symptr<>" || ds.Value != 0 { t.Fatalf("unexpected symbol DATA placeholder: %#v", ds) @@ -39,4 +71,49 @@ GLOBL ·symptr<>(SB), NOPTR, $(machTimebaseInfo__size) if gs := file.Globl[1]; gs.Sym != "·symptr<>" || gs.Flags != "NOPTR" || gs.Size != 64 { t.Fatalf("unexpected macro-sized GLOBL: %#v", gs) } + + comma, err := parseDATAStmt(ArchARM64, `·comma(SB)/12, $"hello, world"`) + if err != nil || string(comma.Payload) != "hello, world" { + t.Fatalf("parse comma string DATA = (%#v, %v)", comma, err) + } + if _, err := parseDATAStmt(ArchARM64, `·short(SB)/4, $"hello"`); err == nil { + t.Fatal("oversized string DATA unexpectedly parsed") + } +} + +func TestParseDataRejectsMalformedPayloads(t *testing.T) { + for _, stmt := range []string{ + `·missing(SB)/8 $1`, + `, $1`, + `·empty(SB)/8,`, + `·quote(SB)/8, $"unterminated`, + } { + if _, err := parseDATAStmt(ArchARM64, stmt); err == nil { + t.Errorf("parseDATAStmt(%q) unexpectedly succeeded", stmt) + } + } + if _, err := Parse(ArchARM64, "// no directives\n"); err == nil { + t.Fatal("directive-free file unexpectedly parsed") + } +} + +func TestDataGlobalBounds(t *testing.T) { + for _, data := range []DataStmt{ + {Sym: "·negative", Off: -1, Width: 1}, + {Sym: "·wide", Width: maxDataGlobalSize + 1}, + {Sym: "·overflow", Off: maxDataGlobalSize, Width: 1}, + } { + if _, err := dataStmtEnd(data); err == nil { + t.Errorf("dataStmtEnd(%#v) unexpectedly succeeded", data) + } + } + if _, err := dataStmtPayload(DataStmt{Sym: "·wide", Width: maxDataGlobalSize + 1}); err == nil { + t.Fatal("oversized DATA payload unexpectedly accepted") + } + if _, err := makeDataGlobal("negative", -1); err == nil { + t.Fatal("negative global size unexpectedly accepted") + } + if _, err := makeDataGlobal("huge", maxDataGlobalSize+1); err == nil { + t.Fatal("oversized global unexpectedly accepted") + } } diff --git a/retjmp_test.go b/retjmp_test.go new file mode 100644 index 0000000..e2b6462 --- /dev/null +++ b/retjmp_test.go @@ -0,0 +1,104 @@ +package plan9asm + +import ( + "strings" + "testing" +) + +func TestTranslateRetjmp(t *testing.T) { + for _, tc := range retjmpArchitectures { + tc := tc + t.Run(tc.name, func(t *testing.T) { + file, err := Parse(tc.arch, "TEXT ·f(SB),NOSPLIT,$0-0\n\tRET ·next(SB)\n") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + ir, err := Translate(file, retjmpOptions(tc.goarch)) + if err != nil { + t.Fatalf("Translate() error = %v", err) + } + if !strings.Contains(ir, "call void @example.next()") { + t.Fatalf("RET target was not lowered as a tail jump:\n%s", ir) + } + }) + } +} + +func TestTranslateRetjmpUsesCallerSignatureWhenCalleeIsExternal(t *testing.T) { + for _, tc := range retjmpArchitectures { + tc := tc + t.Run(tc.name, func(t *testing.T) { + file, err := Parse(tc.arch, "TEXT ·f(SB),NOSPLIT,$0-0\n\tRET ·external(SB)\n") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + opt := retjmpOptions(tc.goarch) + opt.Sigs = map[string]FuncSig{ + "example.f": {Name: "example.f", Ret: Void}, + } + ir, err := Translate(file, opt) + if err != nil { + t.Fatalf("Translate() error = %v", err) + } + if !strings.Contains(ir, "call void @example.external()") { + t.Fatalf("external RET target did not inherit the caller signature:\n%s", ir) + } + }) + } +} + +var retjmpArchitectures = []struct { + name string + arch Arch + goarch string +}{ + {name: "arm", arch: ArchARM, goarch: "arm"}, + {name: "arm64", arch: ArchARM64, goarch: "arm64"}, + {name: "amd64", arch: ArchAMD64, goarch: "amd64"}, +} + +func retjmpOptions(goarch string) Options { + return Options{ + ResolveSym: func(sym string) string { return "example." + strings.TrimPrefix(sym, "·") }, + Goarch: goarch, + Sigs: map[string]FuncSig{ + "example.f": {Name: "example.f", Ret: Void}, + "example.next": {Name: "example.next", Ret: Void}, + }, + } +} + +func TestTranslateRetRegister(t *testing.T) { + for _, tc := range retjmpArchitectures { + tc := tc + t.Run(tc.name, func(t *testing.T) { + file, err := Parse(tc.arch, "TEXT ·f(SB),NOSPLIT,$0-0\n\tRET (R27)\n") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + ir, err := Translate(file, retjmpOptions(tc.goarch)) + if err != nil { + t.Fatalf("Translate() error = %v", err) + } + if !strings.Contains(ir, "ret void") { + t.Fatalf("register RET was not lowered as a function return:\n%s", ir) + } + }) + } +} + +func TestTranslateRetjmpRejectsMultipleTargets(t *testing.T) { + for _, tc := range retjmpArchitectures { + tc := tc + t.Run(tc.name, func(t *testing.T) { + file, err := Parse(tc.arch, "TEXT ·f(SB),NOSPLIT,$0-0\n\tRET ·one(SB), ·two(SB)\n") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + _, err = Translate(file, retjmpOptions(tc.goarch)) + if err == nil || !strings.Contains(err.Error(), "RET expects at most 1 operand") { + t.Fatalf("Translate() error = %v, want RET operand count error", err) + } + }) + } +} diff --git a/scripts/check-stdlib-corpus.sh b/scripts/check-stdlib-corpus.sh index 8c712f2..20328d1 100755 --- a/scripts/check-stdlib-corpus.sh +++ b/scripts/check-stdlib-corpus.sh @@ -8,8 +8,14 @@ if ! command -v llc >/dev/null 2>&1; then echo "llc not found in PATH" >&2 exit 1 fi -if ! command -v python3 >/dev/null 2>&1; then - echo "python3 not found in PATH" >&2 +if [[ "${RUNNER_OS:-}" == "Windows" ]] && command -v python >/dev/null 2>&1; then + python_cmd=python +elif command -v python3 >/dev/null 2>&1; then + python_cmd=python3 +elif command -v python >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3 not found in PATH" >&2 exit 1 fi @@ -23,6 +29,16 @@ targets=( "darwin arm64 arm64-apple-macosx" ) +# Include Windows by default. CI may disable these two extra targets for old +# Go compatibility lanes; the latest toolchain and the Windows host lane still +# scan and compile both COFF corpora. +if [[ "${PLAN9ASM_CORPUS_INCLUDE_WINDOWS:-1}" != "0" ]]; then + targets+=( + "windows amd64 x86_64-pc-windows-msvc" + "windows arm64 aarch64-pc-windows-msvc" + ) +fi + for target in "${targets[@]}"; do set -- $target goos=$1 @@ -32,7 +48,7 @@ for target in "${targets[@]}"; do echo "==> scan $goos/$goarch" json="$tmp_root/$goos-$goarch.json" go run ./cmd/plan9asmscan -goos="$goos" -goarch="$goarch" -repo-root . -format json -out "$json" - python3 - "$json" "$goos/$goarch" <<'PY' + "$python_cmd" - "$json" "$goos/$goarch" <<'PY' import json import sys diff --git a/translate.go b/translate.go index 6ecc1d4..7e452e8 100644 --- a/translate.go +++ b/translate.go @@ -96,7 +96,7 @@ func translateIRText(file *File, opt Options) (string, error) { if file == nil { return "", fmt.Errorf("nil file") } - if len(file.Funcs) == 0 { + if len(file.Funcs) == 0 && len(file.Data) == 0 && len(file.Globl) == 0 { return "", fmt.Errorf("empty file") } @@ -348,18 +348,16 @@ func emitDataGlobals(b *strings.Builder, file *File, resolve func(string) string sd = &symData{bytes: map[int64][]byte{}} syms[name] = sd } - if d.Width <= 0 { - return fmt.Errorf("DATA %s: invalid width %d", d.Sym, d.Width) + end, err := dataStmtEnd(d) + if err != nil { + return err } - payload := make([]byte, d.Width) - // Plan 9 asm DATA encodes immediates little-endian on amd64/arm64. - v := d.Value - for i := int64(0); i < d.Width; i++ { - payload[i] = byte(v & 0xff) - v >>= 8 + payload, err := dataStmtPayload(d) + if err != nil { + return err } sd.bytes[d.Off] = payload - if end := d.Off + d.Width; end > sd.size { + if end > sd.size { sd.size = end } } @@ -380,7 +378,10 @@ func emitDataGlobals(b *strings.Builder, file *File, resolve func(string) string if sd.size <= 0 { continue } - buf := make([]byte, sd.size) + buf, err := makeDataGlobal(name, sd.size) + if err != nil { + return err + } for off, p := range sd.bytes { if off < 0 || off+int64(len(p)) > int64(len(buf)) { return fmt.Errorf("DATA %s: out of bounds off=%d len=%d size=%d", name, off, len(p), len(buf)) @@ -393,6 +394,54 @@ func emitDataGlobals(b *strings.Builder, file *File, resolve func(string) string return nil } +// Data globals are currently materialized as byte slices and then as LLVM +// constant arrays. Bound their size so malformed input cannot exhaust the +// translator's memory before LLVM sees it. The Go standard library's largest +// assembly global is only a few KiB. +const maxDataGlobalSize int64 = 64 << 20 + +func dataStmtEnd(d DataStmt) (int64, error) { + if d.Off < 0 { + return 0, fmt.Errorf("DATA %s: invalid offset %d", d.Sym, d.Off) + } + if d.Width <= 0 || d.Width > maxDataGlobalSize || d.Off > maxDataGlobalSize-d.Width { + return 0, fmt.Errorf("DATA %s: range off=%d width=%d exceeds %d-byte limit", d.Sym, d.Off, d.Width, maxDataGlobalSize) + } + return d.Off + d.Width, nil +} + +func makeDataGlobal(name string, size int64) ([]byte, error) { + if size < 0 || size > maxDataGlobalSize { + return nil, fmt.Errorf("global %s: size %d exceeds %d-byte limit", name, size, maxDataGlobalSize) + } + return make([]byte, size), nil +} + +func dataStmtPayload(d DataStmt) ([]byte, error) { + if d.Width <= 0 { + return nil, fmt.Errorf("DATA %s: invalid width %d", d.Sym, d.Width) + } + if d.Width > maxDataGlobalSize { + return nil, fmt.Errorf("DATA %s: width %d exceeds %d-byte limit", d.Sym, d.Width, maxDataGlobalSize) + } + payload := make([]byte, d.Width) + if d.Payload != nil { + if int64(len(d.Payload)) > d.Width { + return nil, fmt.Errorf("DATA %s: string payload is %d bytes, exceeds width %d", d.Sym, len(d.Payload), d.Width) + } + copy(payload, d.Payload) + return payload, nil + } + // Plan 9 asm DATA encodes integer immediates little-endian on the + // architectures supported by this translator. + v := d.Value + for i := range payload { + payload[i] = byte(v & 0xff) + v >>= 8 + } + return payload, nil +} + func bestAlign(size int64) int64 { // Conservative alignment guess good enough for stdlib constant tables. switch { diff --git a/translate_deep_coverage_test.go b/translate_deep_coverage_test.go index 87be3bc..6e55ae2 100644 --- a/translate_deep_coverage_test.go +++ b/translate_deep_coverage_test.go @@ -215,6 +215,16 @@ func TestTranslateIRTextCoverage(t *testing.T) { }, resolve); err == nil { t.Fatalf("emitDataGlobals(oob) unexpectedly succeeded") } + if err := emitDataGlobals(&dataIR, &File{ + Globl: []GloblStmt{{Sym: "huge", Size: maxDataGlobalSize + 1}}, + }, resolve); err == nil { + t.Fatalf("emitDataGlobals(huge) unexpectedly succeeded") + } + if err := emitDataGlobals(&dataIR, &File{ + Data: []DataStmt{{Sym: "dataOnly", Width: 1, Value: 1}}, + }, resolve); err != nil { + t.Fatalf("emitDataGlobals(data-only) error = %v", err) + } if got := bestAlign(32); got != 16 { t.Fatalf("bestAlign(32) = %d", got) @@ -825,12 +835,15 @@ func TestDirectModuleTypeHelperCoverage(t *testing.T) { mod := ctx.NewModule("data-helper") defer mod.Dispose() - if err := emitDataGlobalsModule(mod, &File{Globl: []GloblStmt{{Sym: "huge", Size: (1 << 31) + 1}}}, testResolveSym("example")); err == nil { + if err := emitDataGlobalsModule(mod, &File{Globl: []GloblStmt{{Sym: "huge", Size: maxDataGlobalSize + 1}}}, testResolveSym("example")); err == nil { t.Fatalf("emitDataGlobalsModule(huge) unexpectedly succeeded") } if err := emitDataGlobalsModule(mod, &File{Data: []DataStmt{{Sym: "bad", Width: 0}}}, testResolveSym("example")); err == nil { t.Fatalf("emitDataGlobalsModule(width=0) unexpectedly succeeded") } + if err := emitDataGlobalsModule(mod, &File{Data: []DataStmt{{Sym: "dataOnly", Width: 1, Value: 1}}}, testResolveSym("example")); err != nil { + t.Fatalf("emitDataGlobalsModule(data-only) error = %v", err) + } if err := emitDataGlobalsModule(mod, &File{ Globl: []GloblStmt{{Sym: "small", Size: 1}}, Data: []DataStmt{{Sym: "small", Off: -1, Width: 1, Value: 1}}, diff --git a/translate_module_direct.go b/translate_module_direct.go index 8b9486e..92cd049 100644 --- a/translate_module_direct.go +++ b/translate_module_direct.go @@ -23,7 +23,7 @@ func translateModuleDirect(file *File, opt Options) (llvm.Module, error) { if file == nil { return llvm.Module{}, fmt.Errorf("nil file") } - if len(file.Funcs) == 0 { + if len(file.Funcs) == 0 && len(file.Data) == 0 && len(file.Globl) == 0 { return llvm.Module{}, fmt.Errorf("empty file") } if opt.AnnotateSource { @@ -574,17 +574,16 @@ func emitDataGlobalsModule(mod llvm.Module, file *File, resolve func(string) str sd = &symData{bytes: map[int64][]byte{}} syms[name] = sd } - if d.Width <= 0 { - return fmt.Errorf("DATA %s: invalid width %d", d.Sym, d.Width) + end, err := dataStmtEnd(d) + if err != nil { + return err } - payload := make([]byte, d.Width) - v := d.Value - for i := int64(0); i < d.Width; i++ { - payload[i] = byte(v & 0xff) - v >>= 8 + payload, err := dataStmtPayload(d) + if err != nil { + return err } sd.bytes[d.Off] = payload - if end := d.Off + d.Width; end > sd.size { + if end > sd.size { sd.size = end } } @@ -599,10 +598,10 @@ func emitDataGlobalsModule(mod llvm.Module, file *File, resolve func(string) str if sd.size <= 0 { continue } - if sd.size > (1 << 31) { - return directUnsupportedf("global %s too large for direct lowering: %d", name, sd.size) + buf, err := makeDataGlobal(name, sd.size) + if err != nil { + return err } - buf := make([]byte, sd.size) for off, p := range sd.bytes { if off < 0 || off+int64(len(p)) > int64(len(buf)) { return fmt.Errorf("DATA %s: out of bounds off=%d len=%d size=%d", name, off, len(p), len(buf)) diff --git a/types.go b/types.go index e41467b..11fc15f 100644 --- a/types.go +++ b/types.go @@ -30,6 +30,8 @@ const ( SP Reg = "SP" BP Reg = "BP" PC Reg = "PC" + FS Reg = "FS" + GS Reg = "GS" AL Reg = "AL" AH Reg = "AH" @@ -80,6 +82,10 @@ func parseReg(s string) (Reg, bool) { return BP, true case "PC": return PC, true + case "FS": + return FS, true + case "GS": + return GS, true case "AL": return AL, true case "AH": @@ -225,10 +231,11 @@ type Operand struct { } type MemRef struct { - Base Reg - Off int64 - Index Reg // optional; empty if not present - Scale int64 // optional; defaults to 1 when Index is present + Base Reg + Off int64 + Index Reg // optional; empty if not present + Scale int64 // optional; defaults to 1 when Index is present + Segment Reg // optional x86 segment override (FS or GS) } func (o Operand) String() string { @@ -260,13 +267,20 @@ func (o Operand) String() string { return o.Sym + ":" case OpMem: // Best-effort pretty print. + segment := "" + if o.Mem.Segment != "" { + segment = fmt.Sprintf("(%s)", o.Mem.Segment) + } if o.Mem.Index != "" { if o.Mem.Scale == 0 { - return fmt.Sprintf("%d(%s)(%s)", o.Mem.Off, o.Mem.Base, o.Mem.Index) + return fmt.Sprintf("%d(%s)(%s)%s", o.Mem.Off, o.Mem.Base, o.Mem.Index, segment) } - return fmt.Sprintf("%d(%s)(%s*%d)", o.Mem.Off, o.Mem.Base, o.Mem.Index, o.Mem.Scale) + return fmt.Sprintf("%d(%s)(%s*%d)%s", o.Mem.Off, o.Mem.Base, o.Mem.Index, o.Mem.Scale, segment) + } + if o.Mem.Base == "" && o.Mem.Segment != "" { + return fmt.Sprintf("%d%s", o.Mem.Off, segment) } - return fmt.Sprintf("%d(%s)", o.Mem.Off, o.Mem.Base) + return fmt.Sprintf("%d(%s)%s", o.Mem.Off, o.Mem.Base, segment) case OpRegList: parts := make([]string, 0, len(o.RegList)) for _, r := range o.RegList { @@ -762,12 +776,15 @@ type Instr struct { // // DATA sym+off(SB)/width, $value // -// Width is in bytes. Value is encoded little-endian into the global. +// Width is in bytes. Integer values are encoded little-endian into the +// global. String payloads are copied byte-for-byte and zero-padded to Width, +// matching cmd/asm's DATA string semantics. type DataStmt struct { - Sym string - Off int64 - Width int64 - Value uint64 + Sym string + Off int64 + Width int64 + Value uint64 + Payload []byte } // GloblStmt models a minimal Plan 9 GLOBL directive: @@ -978,6 +995,10 @@ func parseMem(s string) (MemRef, bool) { } mem := MemRef{Base: base, Off: off} + if base == FS || base == GS { + mem.Base = "" + mem.Segment = base + } if rest == "" { return mem, true } @@ -987,6 +1008,13 @@ func parseMem(s string) (MemRef, bool) { return MemRef{}, false } inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(rest, "("), ")")) + if segment, ok := parseReg(inner); ok && (segment == FS || segment == GS) { + if mem.Segment != "" { + return MemRef{}, false + } + mem.Segment = segment + return mem, true + } idx, scale, ok := parseIndexScale(inner) if !ok { return MemRef{}, false diff --git a/types_deep_test.go b/types_deep_test.go index 08f4ed0..592312e 100644 --- a/types_deep_test.go +++ b/types_deep_test.go @@ -20,9 +20,17 @@ func TestTypeHelperCoverage(t *testing.T) { {Operand{Kind: OpLabel, Sym: "loop"}, "loop:"}, {Operand{Kind: OpMem, Mem: MemRef{Base: SI, Off: 8}}, "8(SI)"}, {Operand{Kind: OpMem, Mem: MemRef{Base: BX, Off: -4, Index: CX, Scale: 2}}, "-4(BX)(CX*2)"}, + {Operand{Kind: OpMem, Mem: MemRef{Base: BX, Index: CX, Segment: GS}}, "0(BX)(CX)(GS)"}, + {Operand{Kind: OpMem, Mem: MemRef{Off: 0x30, Segment: GS}}, "48(GS)"}, + {Operand{Kind: OpMem, Mem: MemRef{Base: CX, Segment: GS}}, "0(CX)(GS)"}, {Operand{Kind: OpRegList, RegList: []Reg{"R0", "R1"}}, "(R0, R1)"}, {Operand{}, ""}, } + for _, want := range []Reg{FS, GS} { + if got, ok := parseReg(string(want)); !ok || got != want { + t.Fatalf("parseReg(%q) = (%q, %v)", want, got, ok) + } + } for _, tc := range cases { if got := tc.op.String(); got != tc.want { t.Fatalf("Operand.String() = %q, want %q", got, tc.want) @@ -57,6 +65,9 @@ func TestTypeHelperCoverage(t *testing.T) { {"-1(AX*2)", true}, {"(0*8)(R8)(BX*8)", true}, {"(symSize)(R14)", true}, + {"0x30(GS)", true}, + {"0(CX)(GS)", true}, + {"0(GS)(FS)", false}, {"not-mem", false}, } { _, ok := parseMem(tc.in)