Discoverying and navigating through Go linkname compiler directives

12 min read

Go supports a number of directives which can influence the behaviour of the compiler.

These compiler directives are in the form of magic comments, probably because of some historical reasons, combined with the fact that the Go developers don’t really like extending further the syntax of the language, in order to keep it small and simple.

One interesting compiler directive is linkname, which allows us to declare a symbol in one package, which happens to be an alias for a symbol in another package. The symbol to which we link can even be an unexported symbol, allowing us to break the encapsulation rules as a whole and make a package-internal symbol exported via another symbol in a different package.

The following example shows how we can use the linkname directive in order to expose an unexported symbol from one package via another package.

Lets create a new Go module and the directories for our example packages.

mkdir linkname-example
cd linkname-example
go mod init linkname-example
mkdir -p pkg/{foo,bar}

Our example project layout looks like this.

[linkname-example] > tree .
.
├── go.mod
└── pkg
    ├── bar
    └── foo

4 directories, 1 file

A few words about the project structure – the foo package will contain an unexported symbol, which usually we cannot access from another package. However, we will expose this unexported symbol from foo package via the bar package using the linkname compiler directive.

Lets start with the foo package first. This is what our pkg/foo/foo.go file looks like.

// pkg/foo/foo.go

package foo

import (
        "fmt"
        _ "unsafe"
)

// Use go:linkname directive here to make things clearer to the reader
// that this particular symbol will be referenced by other symbols.
//
// Using go:linkname violates the encapsulation of symbols, and allows
// other packages to refer even to un-exported symbols.
//
// For that reason importing "unsafe" is a requirement.

//go:linkname foo
func foo() {
        fmt.Println("foo() was called")
}

In the code above we can see a single foo function, which is unexported, and it also contains the linkname directive. This is the function to which we will be creating an alias.

And this is what our pkg/bar/bar.go file looks like, from which we will expose the foo.foo symbol.

// pkg/bar/bar.go

package bar

import (
        "fmt"
        _ "linkname-example/pkg/foo" // We need to import `foo' here as well
        _ "unsafe"                   // Importing unsafe is required
)

// f() here is an alias for [foo.foo]
//
//go:linkname f linkname-example/pkg/foo.foo
func f()

func Bar() {
        fmt.Println("Bar() was called")
        f()
}

A few words about the code above. We have one exported symbol - the Bar function, which simply prints Bar() was called, and then it calls the unexported f() function from the same package.

The f() function does not look like a regular Go function, since it lacks a function body. It is a function declaration only. It also uses the linkname directive in order to link the bar.f symbol to the foo.foo symbol.

Using the linkname directive also requires that we import the unsafe package, since Go requires it, as documented in the code above.

Additionally, we had to import the linkname-example/pkg/foo package, so that it is part of the import graph, in order to find the respective pkg/foo.foo symbol. If pkg/foo was already part of the imports because of a transitive dependency, we would not have to explicitly import it, but that is not the case in this example.

Finally we can wire things up in our main.go file and simply call the exported bar.Bar() function.

// main.go

package main

import "linkname-example/pkg/bar"

func main() {
        bar.Bar()
}

Running this program prints the following, and we can see that even though foo.foo was unexported we have successfully called it via the bar.Bar() function and the symbol alias bar.f.

[linkname-example] > go run main.go
Bar() was called
foo() was called

This is not a new or unconventional thing at all. The Go standard library uses this all over the place in order to share re-usable code between internal-only packages without having to make such symbols exported in the first place.

Another reason for the stdlib to use linkname is to avoid circular import dependencies. For example the testing/synctest package imports the testing package, since it needs testing.T because it is used by the testing/synctest.Test function.

Internally, testing/synctest.Test calls testing/synctest.testingSynctestTest, which is a pull linkname. The push linkname symbol is testing.testingSynctestTest.

  • testing/synctest imports testing (because it needs testing.T)
  • testing/synctest.Test calls testing/synctest.testingSynctestTest (a linkname pull symbol)
  • testing.testingSynctestTest provides the function body and is the linkname push symbol

Now, we could have moved the function body of testing.testingSynctestTest into testing/synctest.testingSynctestTest, but the thing is that testing.testingSynctestTest needs to work with lots of other internal-only symbols in testing, e.g. testing.tRunner function, the testing.common struct, etc. We either have to linkname all of these, or make them exported.

My best guess here is that the idea behind having testing.testingSynctestTest as a linkname push symbol is to keep the changes to a minimum when introducing the testing/synctest package. In the end we have one linkname push symbol in testing package, which is better than exposing everything else in testing via additional linknames, or making them exported.

The tradeoffs however are that these magic comments don’t really have a concrete AST representation, unlike in other languages like Lisp or Rust. If we inspect the AST tree we would find these directives as plain Comment nodes in Go, instead of something like Directive.

Fortunately, gopls already comes with support for parsing linkname directives, however support for is partial. For example, gopls can jump to a referred symbol when using the 2-arg form of linkname. However, it does not support the find references action, which allows us to view symbols that link to a given symbol, which uses the 1-arg form.

It is also worth noting here that recent Go versions (> 1.23.x) come with tightened up restrictions about using linkname directive when linking to a symbol in the stdlib. See the following links for additional information.

In order to fill that gap and be able to navigate through all the linkname symbols in the stdlib I’ve built a tool for this purpose only.

The tool can be found in the dnaeon/golinkname repo and can be installed via the following command.

go install -v github.com/dnaeon/golinkname/cmd/golinkname@latest

The golinkname tool supports a number of sub-commands, which allow you to generate an index of all linkname directives in a given Go module in JSON format, list a user-friendly table of the discovered symbols, and find related symbols.

$ golinkname help
NAME:
   golinkname - Lookup //go:linkname directives in a Go module

USAGE:
   golinkname [global options] [command [command options]]

COMMANDS:
   index    Emit a JSON array of every directive in the module
   refs     Find directives whose target is exactly <pkgpath>.<name>
   related  Find every directive related to <pkgpath>.<name>
   list     Display a table of discovered directives
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h  show help

We can list all linkname symbols from the current Go module using the following command.

golinkname list

Running the list sub-command against the example project we’ve built before we can see these symbols.

$ golinkname list --dir /path/to/linkname-example
FILE:LINE          FORM          DIR   LOCAL  TARGET                        RESOLVED           WARNINGS
pkg/bar/bar.go:13  func/two-arg  pull  f      linkname-example/pkg/foo.foo  pkg/foo/foo.go:19  -
pkg/foo/foo.go:18  func/one-arg  push  foo    -                             -                  -

In order to list all linkname symbols from the standard library we can use the following command.

golinkname list --dir "$(go env GOROOT)/src"

Example snippet from the command above looks like this.

$ golinkname list --dir "$( go env GOROOT)/src"
FILE:LINE                                           FORM                 DIR   LOCAL                                          TARGET                                              RESOLVED                                            WARNINGS
arena/arena.go:87                                   func/two-arg         push  reflect_arena_New                              reflect.arena_New                                   reflect/arena.go:18                                 -
arena/arena.go:92                                   func/one-arg         pull  runtime_arena_newArena                         -                                                   -                                                   -
arena/arena.go:95                                   func/one-arg         pull  runtime_arena_arena_New                        -                                                   -                                                   -
arena/arena.go:101                                  func/one-arg         pull  runtime_arena_arena_Slice                      -                                                   -                                                   -
arena/arena.go:104                                  func/one-arg         pull  runtime_arena_arena_Free                       -                                                   -                                                   -
arena/arena.go:107                                  func/one-arg         pull  runtime_arena_heapify                          -                                                   -                                                   -
crypto/fips140/enforcement.go:40                    func/one-arg         pull  setBypass                                      -                                                   -                                                   -
crypto/fips140/enforcement.go:43                    func/one-arg         pull  isBypassed                                     -                                                   -                                                   -
crypto/fips140/enforcement.go:46                    func/one-arg         pull  unsetBypass                                    -                                                   -                                                   -
crypto/internal/fips140/cast.go:16                  func/two-arg         pull  fatal                                          crypto/internal/fips140.fatal                       crypto/internal/fips140/cast.go:17                  -
crypto/internal/fips140/check/check.go:32           var/two-arg-extern   pull  Linkinfo                                       go:fipsinfo                                         -                                                   -
crypto/internal/fips140/check/checktest/test.go:20  var/two-arg          pull  RODATA                                         crypto/internal/fips140/check/checktest.RODATA      crypto/internal/fips140/check/checktest/test.go:21  -
...

The index sub-command returns a JSON index of the discovered linkname directives and their respective symbols.

golinkname index --pretty

The result from the index sub-command is what can be used to build integrations with other tools, editors, or IDEs. An Emacs package which wraps golinkname is available in the editor/emacs directory, which lets you list, and navigate through the linkname directives in a Go codebase.

The following demo shows the Emacs integration with golinkname, which takes advantage of the golinkname index.

We can also find who references a given symbol via a linkname directive using the refs sub-command.

$ golinkname refs linkname-example/pkg/foo.foo | jq
[
  {
    "schemaVersion": 1,
    "file": "pkg/bar/bar.go",
    "line": 13,
    "col": 1,
    "form": "two-arg",
    "direction": "pull",
    "localName": "f",
    "declName": "f",
    "declKind": "func",
    "target": {
      "raw": "linkname-example/pkg/foo.foo",
      "pkgPath": "linkname-example/pkg/foo",
      "name": "foo",
      "resolved": [
        {
          "file": "pkg/foo/foo.go",
          "line": 19,
          "col": 6,
          "inModule": true
        }
      ]
    },
    "hasUnsafeImport": true,
    "warnings": []
  }
]

The example above shows that the pkg/bar.f symbol references the pkg/foo.foo symbol via a linkname directive, which is exactly what the example project did.

If we run the same command, but against the pkg/bar.f symbol we would see that nobody references it, which is correct, since nobody has a linkname reference to it.

$ golinkname refs --dir /tmp/linkname-example linkname-example/pkg/bar.f | jq
[]

However, we can see to which other symbols pkg/bar.f is related.

$ golinkname related --dir /tmp/linkname-example linkname-example/pkg/bar.f | jq
[
  {
    "schemaVersion": 1,
    "file": "pkg/bar/bar.go",
    "line": 13,
    "col": 1,
    "form": "two-arg",
    "direction": "pull",
    "localName": "f",
    "declName": "f",
    "declKind": "func",
    "target": {
      "raw": "linkname-example/pkg/foo.foo",
      "pkgPath": "linkname-example/pkg/foo",
      "name": "foo",
      "resolved": [
        {
          "file": "pkg/foo/foo.go",
          "line": 19,
          "col": 6,
          "inModule": true
        }
      ]
    },
    "hasUnsafeImport": true,
    "warnings": []
  }
]

The output above shows that pkg/bar.f is related to pkg/foo.f, because it references it.

Make sure to check the dnaeon/golinkname repo for additional details.

Written on June 6, 2026