From 7246b0e002a647be31f19612d89bd2f1a21cc89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Mon, 21 Sep 2026 21:35:08 +0200 Subject: [PATCH] feat(asm): the TLS access pair in the toolchain's one-instruction form Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 17 ++++--- asm/assemble.go | 96 ++++++++++++++++++++++++++++++++++- asm/encode.go | 15 ++++++ asm/guard_test.go | 8 +-- asm/instrs.go | 13 +++++ asm/link.go | 17 ++++++- asm/operand.go | 12 +++++ cmd/gasm/audit.go | 4 +- cmd/gasm/dis.go | 2 +- cmd/gasm/main.go | 10 ++-- testdata/verify/forms_amd64.s | 7 +++ 11 files changed, 180 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a5e7e..64fe7d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 branch optimiser does; symbol immediates (`MOVQ $sym(SB), AX`) assemble to the toolchain's RIP-relative LEA with an R_PCREL relocation; negated constant expressions in operands (`ADJSP - $-(REGS - 8)`, the shape the cgo ABI macros write) fold; and `gasm - asm` predefines the `GOARCH_` and `GOOS_` macros the go - command passes to `go tool asm`, so GOROOT headers' `#ifdef - GOARCH_amd64` platform blocks (`go_tls.h`'s `get_tls` and friends) - select as intended. The GOROOT corpus measure moves to 261 of 353 - files assembling for every target architecture (73.9 %), 87.5 % of - the real-code corpus, from 70.8 % and 82.2 %. + $-(REGS - 8)`, the shape the cgo ABI macros write) fold; the TLS + access pair assembles as the toolchain's one-instruction form (the + bare `MOVQ TLS, r` load nops out and `off(r)(TLS*1)` folds to the + segment-prefixed absolute whose disp32 carries the R_TLSLE + relocation, per-GOOS); and `gasm asm` predefines the `GOARCH_` + and `GOOS_` macros the go command passes to `go tool asm`, so + GOROOT headers' `#ifdef GOARCH_amd64` platform blocks (`go_tls.h`'s + `get_tls` and friends) select as intended. The GOROOT corpus measure + moves to 267 of 353 files assembling for every target architecture + (75.6 %), 89.4 % of the real-code corpus, from 70.8 % and 82.2 %. ### Added diff --git a/asm/assemble.go b/asm/assemble.go index 36b0feb..3868016 100644 --- a/asm/assemble.go +++ b/asm/assemble.go @@ -38,10 +38,25 @@ func Assemble(t *ast.Text) ([]byte, map[string]int, error) { // rejects SB operands outright (single-function assembly cannot resolve // them). When allowExternal is set, a reference to a symbol no GLOBL in the // file defines is recorded as an external relocation instead of failing -// the object-file emitters resolve it at link time. +// the object-file emitters resolve it at link time. goos selects the TLS +// access form: the empty default behaves as linux. type linkInfo struct { symbols map[string]bool allowExternal bool + goos string +} + +// tlsOneInsn reports the one-instruction TLS form, obj6.go's +// CanUse1InsnTLS for the GOOS gasm supports: the bare TLS load nops out and +// the (TLS*1) index folds to a segment-absolute access. Windows and plan9 +// keep the two-instruction form; shared linux does too, which gasm's raw +// path does not model and therefore does not select. +func (l *linkInfo) tlsOneInsn() bool { + switch l.goos { + case "", "linux", "freebsd": + return true + } + return false } // sbPatch is a function-relative static-symbol relocation: the disp32 field @@ -834,6 +849,15 @@ func encodeNormal(s *ast.Instr, fi frameInfo, link *linkInfo) ([]byte, []sbPatch } return e.out, ps, nil, nil } + // MOVQ/MOVL TLS, r: the bare TLS load. The toolchain's progedit nops + // it out on the one-instruction TLS systems (linux and freebsd, not + // shared) and encodes the segment-prefixed load elsewhere; get_tls(r), + // the macro GOROOT's go_tls.h defines, expands to exactly this + // statement, and the toolchain's pairing pass removes it whenever the + // following instruction's (TLS*1) index folds. + if (mnemUpper == "MOVQ" || mnemUpper == "MOVL") && len(s.Operands) == 2 && isBareTLS(s.Operands[0]) { + return encodeTLSBaseLoad(s, fi, link) + } _, size := splitSize(mnemUpper) if size == 0 { size = 8 @@ -853,10 +877,67 @@ func encodeNormal(s *ast.Instr, fi frameInfo, link *linkInfo) ([]byte, []sbPatch ps := make([]sbPatch, len(e.patches)) for i, p := range e.patches { ps[i] = sbPatch{off: p.off, name: p.name, addend: p.addend} + if p.tls { + ps[i].kind = RelTLSLE + } } return e.out, ps, e.floatPoolList(), nil } +// isBareTLS reports whether the operand is the bare TLS pseudo-register +// load source, the expansion of go_tls.h's get_tls(r) macro. +func isBareTLS(op *ast.Operand) bool { + return op.Kind == ast.OpAddr && op.Addr.Sym != nil && + op.Addr.Sym.Pseudo == "" && op.Addr.Sym.Name == "TLS" && + op.Addr.Base == "" && op.Addr.Index == "" +} + +// encodeTLSBaseLoad assembles MOVQ/MOVL TLS, r. On the one-instruction TLS +// systems (linux and freebsd outside -shared, obj6.go's CanUse1InsnTLS) the +// statement nops out: the following (TLS*1) access folds to a direct +// segment-absolute load. The two-instruction systems keep the segment load, +// nine bytes with the R_TLSLE patch site at the disp32. +func encodeTLSBaseLoad(s *ast.Instr, fi frameInfo, link *linkInfo) ([]byte, []sbPatch, []floatPoolEntry, error) { + _, size := splitSize(strings.ToUpper(s.Mnemonic.Text)) + if size == 0 { + size = 8 + } + dst, err := operandFromAST("MOVQ", s.Operands[1], 8, fi, link) + if err != nil { + return nil, nil, nil, err + } + reg, ok := dst.(Reg) + if !ok || reg.isVec() { + return nil, nil, nil, fmt.Errorf("TLS: destination must be a general register") + } + if link == nil || link.tlsOneInsn() { + return nil, nil, nil, nil // noped out + } + seg := byte(0x64) // FS + if link.goos == "windows" { + seg = 0x65 // GS + } + e := &enc{} + i := &instr{ + prefix: seg, + rexW: size == 8, + rexR: reg.idx >= 8, + opcode: []byte{0x8B}, + modrm: 0x04 | (reg.idx&7)<<3, + sib: 0x25, + disp: le32(0), + tls: true, + } + if err := e.emit(i); err != nil { + return nil, nil, nil, err + } + ps := make([]sbPatch, len(e.patches)) + for i, p := range e.patches { + ps[i] = sbPatch{off: p.off, name: p.name, addend: p.addend, kind: RelTLSLE} + } + return e.out, ps, nil, nil +} + // encodeBookkeeping accepts-and-ignores FUNCDATA and PCDATA at the statement // level, before operand conversion: the toolchain's shapes are FUNCDATA // $n, sym(SB) and PCDATA $n, $m, and neither contributes a byte to the @@ -1108,6 +1189,19 @@ func operandFromAST(mnemUpper string, op *ast.Operand, size int, fi frameInfo, l } m := Mem{Base: base, Disp: a.Offset, HasBase: true, Size: size} if a.Index != "" { + if a.Index == "TLS" { + // off(base)(TLS*1): the thread-local annotation. The + // one-instruction TLS form folds it to off(TLS), the + // segment-prefixed absolute whose disp32 carries an + // R_TLS_LE patch site; the base register disappears + // from the encoding, exactly as the toolchain's + // progedit rewrites the address. + seg := byte(0x64) // FS on linux, freebsd, plan9 + if link != nil && link.goos == "windows" { + seg = 0x65 // GS + } + return TLSMem{Disp: a.Offset, Size: size, Seg: seg}, nil + } idx, ok := ParseReg(a.Index) if !ok { return nil, fmt.Errorf("unknown index register %q", a.Index) diff --git a/asm/encode.go b/asm/encode.go index c512342..795cbed 100644 --- a/asm/encode.go +++ b/asm/encode.go @@ -64,6 +64,7 @@ type encPatch struct { off int name string addend int64 + tls bool // a TLS slot offset: the patch is R_TLSLE with no symbol } func (e *enc) encode(mnem string, ops []Operand) error { @@ -578,6 +579,7 @@ type instr struct { disp []byte imm []byte sb *sbRef // static-symbol displacement in disp, awaiting resolution + tls bool // the displacement is a TLS slot offset, patched R_TLSLE } // sbRef records that an instruction's displacement refers to a static symbol @@ -620,6 +622,9 @@ func (e *enc) emit(i *instr) error { if i.sb != nil { e.patches = append(e.patches, encPatch{off: len(e.out), name: i.sb.name, addend: i.sb.addend}) } + if i.tls { + e.patches = append(e.patches, encPatch{off: len(e.out), tls: true}) + } e.out = append(e.out, i.disp...) e.out = append(e.out, i.imm...) return nil @@ -674,6 +679,16 @@ func setRMReg(i *instr, regField int, rexR, regForced bool, rm Operand, opSize i i.disp = le32(0) i.sb = &sbRef{name: r.name, addend: r.addend} return nil + case TLSMem: + // off(TLS): the segment-prefixed absolute access, mod=00 with the + // SIB escape's disp32 absolute form. The displacement is the TLS + // slot offset, patched by the linker's TLS relocation. + i.prefix = r.Seg + i.modrm = 0x04 | regField<<3 + i.sib = 0x25 + i.disp = le32(r.Disp) + i.tls = true + return nil default: return fmt.Errorf("invalid r/m operand %T", rm) } diff --git a/asm/guard_test.go b/asm/guard_test.go index c3dc134..cd1324b 100644 --- a/asm/guard_test.go +++ b/asm/guard_test.go @@ -307,12 +307,12 @@ func TestStackGuardBytesLOONG64(t *testing.T) { func TestStackGuardGOObjInternalCall(t *testing.T) { for _, tt := range []struct { src string - assemble func(*ast.File) (*Image, error) + assemble func(*ast.File, ...AssembleOption) (*Image, error) }{ {"g_amd64.s", AssembleFile}, - {"g_arm64.s", AssembleFileARM64}, - {"g_riscv64.s", AssembleFileRISCV}, - {"g_loong64.s", AssembleFileLOONG64}, + {"g_arm64.s", func(f *ast.File, _ ...AssembleOption) (*Image, error) { return AssembleFileARM64(f) }}, + {"g_riscv64.s", func(f *ast.File, _ ...AssembleOption) (*Image, error) { return AssembleFileRISCV(f) }}, + {"g_loong64.s", func(f *ast.File, _ ...AssembleOption) (*Image, error) { return AssembleFileLOONG64(f) }}, } { f, errs := parser.Parse(tt.src, "TEXT \u00b7callsmall(SB), $16-0\n\tCALL \u00b7other(SB)\n\tRET\nTEXT \u00b7other(SB), NOSPLIT, $0\n\tRET\n") if len(errs) > 0 { diff --git a/asm/instrs.go b/asm/instrs.go index 2eae65a..4772a5c 100644 --- a/asm/instrs.go +++ b/asm/instrs.go @@ -192,6 +192,19 @@ func (e *enc) encodeMov(ops []Operand, size int) error { } return e.emit(i) + case TLSMem: + if !dstIsReg { + return fmt.Errorf("MOV: two memory operands") + } + // MOV r, off(TLS): the segment-prefixed absolute load, reg=dst, + // rm=src(tlsMem) through the SIB escape; the disp32 is the TLS slot + // offset with its R_TLSLE patch site. + i := newInstr(size, []byte{movRR(size)}) + if err := setRM(i, dstReg, src, size); err != nil { + return err + } + return e.emit(i) + case Imm: if dstIsReg { v := int64(src) diff --git a/asm/link.go b/asm/link.go index 3e30c03..fb664ce 100644 --- a/asm/link.go +++ b/asm/link.go @@ -150,6 +150,18 @@ func (img *Image) Bytes() []byte { return append(out, img.Data...) } +// AssembleOption adjusts the file-level assembly context. +type AssembleOption func(*linkInfo) + +// WithGOOS selects the target operating system for the forms that depend on +// it, the TLS access shape above all: linux and freebsd take the +// one-instruction form, windows and plan9 keep the two-instruction load. +func WithGOOS(goos string) AssembleOption { + return func(l *linkInfo) { + l.goos = goos + } +} + // AssembleFile assembles every TEXT function of a parsed file and lays out // its static symbols (GLOBL/DATA) in a data section behind the code. Each // reference to a file-local static symbol becomes a RIP-relative load whose @@ -157,7 +169,7 @@ func (img *Image) Bytes() []byte { // GLOBL defines is recorded as an external relocation (Externals) with its // displacement left zero, the object-file emitters resolve it at link // time, while the raw image (Bytes) cannot represent it. -func AssembleFile(f *ast.File) (*Image, error) { +func AssembleFile(f *ast.File, opts ...AssembleOption) (*Image, error) { dataSyms, err := collectData(f) if err != nil { return nil, err @@ -174,6 +186,9 @@ func AssembleFile(f *ast.File) (*Image, error) { } } link := &linkInfo{symbols: known, allowExternal: true} + for _, o := range opts { + o(link) + } poolSeen := map[string]bool{} img := &Image{Symbols: map[string]int{}, SourcePath: f.Path} diff --git a/asm/operand.go b/asm/operand.go index a68bd39..11d0998 100644 --- a/asm/operand.go +++ b/asm/operand.go @@ -36,6 +36,18 @@ type FloatImm struct { func (FloatImm) isOperand() {} +// TLSMem is a thread-local access, the source form off(base)(TLS*1) with the +// base dropped: the toolchain's one-instruction TLS rewrite assembles it as +// the segment-prefixed absolute whose disp32 carries an R_TLS_LE patch site +// (the linker fills the TLS slot offset). +type TLSMem struct { + Disp int64 + Size int + Seg byte // the segment override: FS (0x64) or GS (0x65) on windows +} + +func (TLSMem) isOperand() {} + // Mem is a memory operand of the form disp(base)(index*scale). type Mem struct { Base Reg diff --git a/cmd/gasm/audit.go b/cmd/gasm/audit.go index f2b8b93..d8c9daf 100644 --- a/cmd/gasm/audit.go +++ b/cmd/gasm/audit.go @@ -671,7 +671,7 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) { t.fail(path, errs[0]) continue } - if _, err := assembleFile(tg.a, f); err != nil { + if _, err := assembleFile(tg.a, f, goos); err != nil { ok = false t.fail(path, err) continue @@ -701,7 +701,7 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) { if len(errs) > 0 { err = errs[0] // a parse failure is a failure for every target } else { - _, err = assembleFile(tg.a, f) + _, err = assembleFile(tg.a, f, goos) } if err != nil { ok = false diff --git a/cmd/gasm/dis.go b/cmd/gasm/dis.go index b79fd7a..a31cd83 100644 --- a/cmd/gasm/dis.go +++ b/cmd/gasm/dis.go @@ -84,7 +84,7 @@ func disSource(path string, target arch.Arch) int { if len(errs) > 0 { return 1 } - img, err := assembleFile(target, f) + img, err := assembleFile(target, f, "") if err != nil { fmt.Fprintf(os.Stderr, "gasm dis: %v\n", err) return 1 diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 389c429..01923e1 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -582,7 +582,7 @@ naming the package. return 1 } - img, err := assembleFile(targetArch, f) + img, err := assembleFile(targetArch, f, goos) if err != nil { fmt.Fprintf(os.Stderr, "%s: %v\n", path, err) return 1 @@ -813,10 +813,10 @@ func platformPredefinesFor(goarch string, fileGoos string) map[string]string { } // assembleFile assembles a parsed file for the given architecture and returns the image. -func assembleFile(targetArch arch.Arch, f *ast.File) (*asm.Image, error) { +func assembleFile(targetArch arch.Arch, f *ast.File, goos string) (*asm.Image, error) { switch targetArch { case arch.AMD64: - return asm.AssembleFile(f) + return asm.AssembleFile(f, asm.WithGOOS(goos)) case arch.RISCV: return asm.AssembleFileRISCV(f) case arch.ARM64: @@ -847,7 +847,7 @@ func assemblePath(path string, forced arch.Arch, dirs includeDirs) (*asm.Image, if len(errs) > 0 { return nil, fmt.Errorf("parse errors") } - return assembleFile(target, f) + return assembleFile(target, f, "") } // printByteDiff shows the first few byte differences between two code blocks. @@ -943,7 +943,7 @@ func cmdVerifyNonJIT(path string, targetArch arch.Arch, groundTruth, profile boo if len(errs) > 0 { return 1 } - img, err := assembleFile(targetArch, f) + img, err := assembleFile(targetArch, f, "") if err != nil { fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) return 1 diff --git a/testdata/verify/forms_amd64.s b/testdata/verify/forms_amd64.s index 61dcc16..8b479f3 100644 --- a/testdata/verify/forms_amd64.s +++ b/testdata/verify/forms_amd64.s @@ -34,3 +34,10 @@ TEXT ·Frame(SB), NOSPLIT, $0 ADJSP $-(64 - 8) POPFQ RET + +// func Tls() int64 +TEXT ·Tls(SB), NOSPLIT, $0-8 + MOVQ TLS, BX + MOVQ 0(BX)(TLS*1), AX + MOVQ AX, ret+0(FP) + RET