From 38b91effef005b6457010c389e4f1c5b8db2f335 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Thu, 14 May 2026 18:42:02 -0400 Subject: [PATCH] compiler: support file-level //go:linkname directives Modern golang.org/x/sys/unix (v0.36+) declares its linknames detached from function declarations, e.g.: func syscall_syscall(...) //go:linkname syscall_syscall syscall.syscall TinyGo's pragma parser only inspected function doc comments and therefore missed these, producing link errors like: undefined symbol: _golang.org/x/sys/unix.syscall_syscall Extend parsePragmas to also walk the enclosing *ast.File's free-standing comments for //go:linkname directives matching the function's name. Function-attached directives still take precedence. The existing 'unsafe' import gate is preserved. Fixes #4395, #5365 --- compiler/compiler.go | 4 +- compiler/symbol.go | 73 +++++++++++++++++++++++++++++++++++++ compiler/testdata/pragma.go | 28 ++++++++++++++ compiler/testdata/pragma.ll | 20 ++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 45e6c8a54b..4a803f1ddd 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -90,7 +90,8 @@ type compilerContext struct { astComments map[string]*ast.CommentGroup embedGlobals map[string][]*loader.EmbedFile pkg *types.Package - packageDir string // directory for this package + loaderPkg *loader.Package // current package being compiled (for AST access) + packageDir string // directory for this package runtimePkg *types.Package } @@ -294,6 +295,7 @@ func CompilePackage(moduleName string, pkg *loader.Package, ssaPkg *ssa.Package, c.packageDir = pkg.OriginalDir() c.embedGlobals = pkg.EmbedGlobals c.pkg = pkg.Pkg + c.loaderPkg = pkg c.runtimePkg = ssaPkg.Prog.ImportedPackage("runtime").Pkg c.program = ssaPkg.Prog diff --git a/compiler/symbol.go b/compiler/symbol.go index 4f24ddbfc3..d733482039 100644 --- a/compiler/symbol.go +++ b/compiler/symbol.go @@ -346,6 +346,51 @@ func (c *compilerContext) parsePragmas(info *functionInfo, f *ssa.Function) { } } + // Also scan file-level //go:linkname directives. These appear as + // free-standing comments in *ast.File.Comments (not attached to any + // declaration), and are used by modern golang.org/x/sys/unix and others. + // Function-attached directives (above) take precedence — we only add + // file-level ones if no doc-comment linkname was found for this function. + // + // TODO: the hasUnsafeImport gate enforced downstream (see the + // //go:linkname case below) is package-level. gc enforces it per + // file, on the file containing the directive. For file-level + // linknames this is more important than for function-attached ones, + // because the directive can live in a file separate from the + // function. A stricter implementation would check whether the file + // returned by fileForFunc imports "unsafe", not whether any file in + // the package does. + hasFunctionLinkname := false + for _, comment := range pragmas { + if strings.HasPrefix(comment.Text, "//go:linkname ") { + parts := strings.Fields(comment.Text) + if len(parts) == 3 && parts[1] == f.Name() { + hasFunctionLinkname = true + break + } + } + } + if !hasFunctionLinkname { + if file := c.fileForFunc(f); file != nil { + for _, group := range file.Comments { + // Skip the function's own doc comment — already handled above. + if decl, ok := syntax.(*ast.FuncDecl); ok && group == decl.Doc { + continue + } + for _, comment := range group.List { + if !strings.HasPrefix(comment.Text, "//go:linkname ") { + continue + } + parts := strings.Fields(comment.Text) + if len(parts) != 3 || parts[1] != f.Name() { + continue + } + pragmas = append(pragmas, comment) + } + } + } + } + // Parse each pragma. for _, comment := range pragmas { parts := strings.Fields(comment.Text) @@ -637,6 +682,34 @@ type globalInfo struct { section string // go:section } +// fileForFunc returns the *ast.File that contains the declaration of f, or +// nil if it cannot be determined. File-level pragmas are only consulted for +// functions in the package currently being compiled — functions imported from +// other packages have their file-level pragmas processed when those packages +// are compiled. +func (c *compilerContext) fileForFunc(f *ssa.Function) *ast.File { + if c.loaderPkg == nil || f.Pkg == nil || f.Pkg.Pkg != c.loaderPkg.Pkg { + return nil + } + syntax := f.Syntax() + if f.Origin() != nil { + syntax = f.Origin().Syntax() + } + if syntax == nil { + return nil + } + pos := syntax.Pos() + if !pos.IsValid() { + return nil + } + for _, file := range c.loaderPkg.Files { + if file.FileStart <= pos && pos < file.FileEnd { + return file + } + } + return nil +} + // loadASTComments loads comments on globals from the AST, for use later in the // program. In particular, they are required for //go:extern pragmas on globals. func (c *compilerContext) loadASTComments(pkg *loader.Package) { diff --git a/compiler/testdata/pragma.go b/compiler/testdata/pragma.go index 1e6e967f53..b053862a21 100644 --- a/compiler/testdata/pragma.go +++ b/compiler/testdata/pragma.go @@ -115,3 +115,31 @@ func doesNotEscapeParam(a *int, b []int, c chan int, d *[0]byte) //go:noescape func stillEscapes(a *int, b []int, c chan int, d *[0]byte) { } + +// Define a function in a different package using a file-level go:linkname. +// (Same as withLinkageName1, but with the //go:linkname directive detached +// from the function declaration — see https://github.com/tinygo-org/tinygo/issues/4395) +func withFileLevelLinkageName1() { +} + +// Import a function from a different package using a file-level go:linkname. +// (Same as withLinkageName2, but with the //go:linkname directive detached +// from the function declaration.) +func withFileLevelLinkageName2() + +//go:linkname withFileLevelLinkageName1 somepkg.someFileLevelFunction1 +//go:linkname withFileLevelLinkageName2 somepkg.someFileLevelFunction2 + +// File-level linkname directives can also appear between two function +// declarations, in which case Go's AST attaches them as the doc comment +// of the following function — even when the directive's localname refers +// to a different function. Exercise that case: the directive below names +// withAdjacentLinkageName, but Go will attach it to +// sentinelAfterAdjacentLinkname's Doc. The file-level scan must find it +// by walking comment groups regardless of which decl they're attached to. +func withAdjacentLinkageName() { +} + +//go:linkname withAdjacentLinkageName somepkg.someAdjacentFunction +func sentinelAfterAdjacentLinkname() { +} diff --git a/compiler/testdata/pragma.ll b/compiler/testdata/pragma.ll index f9ddc59846..cf1ae155f9 100644 --- a/compiler/testdata/pragma.ll +++ b/compiler/testdata/pragma.ll @@ -93,6 +93,26 @@ entry: ret void } +; Function Attrs: nounwind +define hidden void @somepkg.someFileLevelFunction1(ptr %context) unnamed_addr #2 { +entry: + ret void +} + +declare void @somepkg.someFileLevelFunction2(ptr) #1 + +; Function Attrs: nounwind +define hidden void @somepkg.someAdjacentFunction(ptr %context) unnamed_addr #2 { +entry: + ret void +} + +; Function Attrs: nounwind +define hidden void @main.sentinelAfterAdjacentLinkname(ptr %context) unnamed_addr #2 { +entry: + ret void +} + attributes #0 = { allockind("alloc,zeroed") allocsize(0) "alloc-family"="runtime.alloc" "target-features"="+bulk-memory,+bulk-memory-opt,+call-indirect-overlong,+mutable-globals,+nontrapping-fptoint,+sign-ext,-multivalue,-reference-types" } attributes #1 = { "target-features"="+bulk-memory,+bulk-memory-opt,+call-indirect-overlong,+mutable-globals,+nontrapping-fptoint,+sign-ext,-multivalue,-reference-types" } attributes #2 = { nounwind "target-features"="+bulk-memory,+bulk-memory-opt,+call-indirect-overlong,+mutable-globals,+nontrapping-fptoint,+sign-ext,-multivalue,-reference-types" }