From 48e105f8d348cf522e46c30cf613656813a39f14 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 2 Aug 2026 12:31:14 +0800 Subject: [PATCH] debug: add schema-driven GDB runtime adapters --- .github/workflows/targets.yml | 3 + cmd/internal/gdb/gdb.go | 137 +++ cmd/internal/gdb/gdb_test.go | 94 ++ cmd/internal/gdb/integration_test.go | 292 +++++++ cmd/internal/gdb/llgo_plugin.py | 1092 ++++++++++++++++++++++++ cmd/llgo/debugtest/embedded/runtest.sh | 5 + internal/build/build.go | 28 + internal/build/build_test.go | 72 ++ internal/debuginfo/builder.go | 4 + 9 files changed, 1727 insertions(+) create mode 100644 cmd/internal/gdb/gdb.go create mode 100644 cmd/internal/gdb/gdb_test.go create mode 100644 cmd/internal/gdb/integration_test.go create mode 100644 cmd/internal/gdb/llgo_plugin.py diff --git a/.github/workflows/targets.yml b/.github/workflows/targets.yml index fe520c18b8..c82ed1d79c 100644 --- a/.github/workflows/targets.yml +++ b/.github/workflows/targets.yml @@ -50,3 +50,6 @@ jobs: - name: Test embedded debugger transports run: bash cmd/llgo/debugtest/embedded/runtest.sh + + - name: Test LLGo GDB adapter + run: LLGO_GDB_INTEGRATION=1 go test ./cmd/internal/gdb -run '^TestGDBIntegration$' -count=1 diff --git a/cmd/internal/gdb/gdb.go b/cmd/internal/gdb/gdb.go new file mode 100644 index 0000000000..a2fbbcbd2b --- /dev/null +++ b/cmd/internal/gdb/gdb.go @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package gdb launches GDB with LLGo runtime presentation support. +package gdb + +import ( + _ "embed" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/goplus/llgo/internal/debugabi" +) + +const minimumGDBVersion = 12 +const debuggerSchemaFilename = "llgo_debugger_schema_v1.json" + +var ( + //go:embed llgo_plugin.py + pluginSource []byte + + gdbVersionPattern = regexp.MustCompile(`(?im)^GNU gdb[^\n]*?([0-9]+)(?:\.[0-9]+)`) +) + +// Run starts GDB with the LLGo adapter. Candidates are target-specific GDB +// commands in preference order; the configured path and LLGO_GDB take +// precedence over them. +func Run(configuredPath string, candidates, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + path, err := findGDB(configuredPath, candidates) + if err != nil { + return err + } + + pluginDir, err := os.MkdirTemp("", "llgo-gdb-") + if err != nil { + return fmt.Errorf("llgo debug: create GDB plugin directory: %w", err) + } + defer os.RemoveAll(pluginDir) + + pluginPath := filepath.Join(pluginDir, "llgo_plugin.py") + if err := os.WriteFile(pluginPath, pluginSource, 0600); err != nil { + return fmt.Errorf("llgo debug: write GDB plugin: %w", err) + } + schemaPath := filepath.Join(pluginDir, debuggerSchemaFilename) + if err := os.WriteFile(schemaPath, debugabi.SchemaV1(), 0600); err != nil { + return fmt.Errorf("llgo debug: write debugger schema: %w", err) + } + + gdbArgs := make([]string, 0, len(args)+2) + gdbArgs = append(gdbArgs, "-ex", gdbSourceCommand(pluginPath)) + gdbArgs = append(gdbArgs, args...) + command := exec.Command(path, gdbArgs...) + command.Stdin = stdin + command.Stdout = stdout + command.Stderr = stderr + if err := command.Run(); err != nil { + return fmt.Errorf("llgo debug: GDB: %w", err) + } + return nil +} + +func findGDB(configuredPath string, candidates []string) (string, error) { + return findGDBFrom(configuredPath, os.Getenv("LLGO_GDB"), candidates, []string{ + "gdb-multiarch", + "gdb", + }) +} + +func findGDBFrom(configuredPath, environmentPath string, candidates, fallbacks []string) (string, error) { + if configuredPath != "" { + return validateGDB(configuredPath) + } + if environmentPath != "" { + return validateGDB(environmentPath) + } + + seen := make(map[string]bool) + for _, candidate := range append(append([]string(nil), candidates...), fallbacks...) { + path, err := exec.LookPath(candidate) + if err != nil || seen[path] { + continue + } + seen[path] = true + if path, err = validateGDB(path); err == nil { + return path, nil + } + } + return "", fmt.Errorf("llgo debug: GNU GDB %d or newer with Python support not found; install GDB or set LLGO_GDB", minimumGDBVersion) +} + +func validateGDB(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("llgo debug: find GDB %q: %w", name, err) + } + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("llgo debug: query GDB %q version: %w", path, err) + } + match := gdbVersionPattern.FindStringSubmatch(string(output)) + if len(match) != 2 { + return "", fmt.Errorf("llgo debug: cannot parse GDB version from %q", strings.TrimSpace(string(output))) + } + major, err := strconv.Atoi(match[1]) + if err != nil || major < minimumGDBVersion { + return "", fmt.Errorf("llgo debug: %q is GNU GDB %s; version %d or newer is required", path, match[1], minimumGDBVersion) + } + if output, err = exec.Command(path, "--batch", "--nx", "-ex", "python import json, re").CombinedOutput(); err != nil { + return "", fmt.Errorf("llgo debug: GDB %q requires Python support: %s", path, strings.TrimSpace(string(output))) + } + return path, nil +} + +func gdbSourceCommand(path string) string { + path = filepath.ToSlash(path) + path = strings.NewReplacer(`\`, `\\`, ` `, `\ `, `"`, `\"`).Replace(path) + return `source ` + path +} diff --git a/cmd/internal/gdb/gdb_test.go b/cmd/internal/gdb/gdb_test.go new file mode 100644 index 0000000000..caced7061e --- /dev/null +++ b/cmd/internal/gdb/gdb_test.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package gdb + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGDBSourceCommandEscapesPath(t *testing.T) { + got := gdbSourceCommand(`a\b"c.py`) + want := `source a\\b\"c.py` + if got != want { + t.Fatalf("gdbSourceCommand() = %q, want %q", got, want) + } +} + +func TestFindGDBPrecedenceAndVersion(t *testing.T) { + newGDB := writeFakeGDB(t, "GNU gdb (GDB) 15.1", "") + oldGDB := writeFakeGDB(t, "GNU gdb (GDB) 11.2", "") + + got, err := findGDBFrom(newGDB, oldGDB, []string{oldGDB}, nil) + if err != nil || got != newGDB { + t.Fatalf("configured GDB = %q, %v; want %q", got, err, newGDB) + } + if _, err := findGDBFrom(oldGDB, "", nil, nil); err == nil || !strings.Contains(err.Error(), "version 12 or newer") { + t.Fatalf("old configured GDB error = %v", err) + } + got, err = findGDBFrom("", "", []string{oldGDB, newGDB}, nil) + if err != nil || got != newGDB { + t.Fatalf("candidate GDB = %q, %v; want %q", got, err, newGDB) + } +} + +func TestRunEmbedsPluginAndSchema(t *testing.T) { + fake := writeFakeGDB(t, "GNU gdb (Ubuntu 15.0.50) 15.0.50", ` +for arg in "$@"; do + case "$arg" in + source\ *) plugin=${arg#source } ;; + esac +done +test -f "$plugin" +schema=$(dirname "$plugin")/llgo_debugger_schema_v1.json +grep -q '"contract": "llgo.debugger"' "$schema" +grep -q 'class LLGoStatus' "$plugin" +printf 'LLGO_FAKE_GDB_OK\n' +`) + var stdout, stderr bytes.Buffer + if err := Run(fake, nil, []string{"--batch", "debug.out"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("Run() error: %v; stderr=%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "LLGO_FAKE_GDB_OK") { + t.Fatalf("Run() output = %q", stdout.String()) + } +} + +func writeFakeGDB(t *testing.T, version, body string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake shell debugger is Unix-only") + } + path := filepath.Join(t.TempDir(), "gdb") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\n' '` + version + `' + exit 0 +fi +if [ "$1" = "--batch" ] && [ "$2" = "--nx" ]; then + exit 0 +fi +` + body + if err := os.WriteFile(path, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return path +} diff --git a/cmd/internal/gdb/integration_test.go b/cmd/internal/gdb/integration_test.go new file mode 100644 index 0000000000..00ddafd88e --- /dev/null +++ b/cmd/internal/gdb/integration_test.go @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package gdb + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGDBIntegration(t *testing.T) { + if os.Getenv("LLGO_GDB_INTEGRATION") == "" { + t.Skip("set LLGO_GDB_INTEGRATION=1 to run the native GDB acceptance test") + } + if runtime.GOOS != "linux" { + t.Skip("native GDB acceptance currently runs on Linux") + } + + gdbPath := integrationTool(t, os.Getenv("LLGO_GDB"), "gdb-multiarch", "gdb") + llgoPath := integrationTool(t, os.Getenv("LLGO"), "llgo") + root := integrationRepoRoot(t) + fixtureDir := filepath.Join(root, "cmd", "llgo", "lldbtest") + source := filepath.Join(fixtureDir, "main.go") + executable := filepath.Join(t.TempDir(), "debug.out") + + build := exec.Command( + llgoPath, "build", "-O0", "-ldflags=-w=false", + "-o", executable, ".", + ) + build.Dir = fixtureDir + build.Env = append(os.Environ(), "LLGO_ROOT="+root) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build GDB fixture: %v\n%s", err, output) + } + + runtimeLine := integrationMarkerLine(t, source, "LLDB_BREAK: runtime_values") + interfaceLine := integrationMarkerLine(t, source, "LLDB_BREAK: interface_values") + functionLine := integrationMarkerLine(t, source, "LLDB_BREAK: function_values") + containerLine := integrationMarkerLine(t, source, "LLDB_BREAK: container_values") + args := []string{ + "--nx", "--quiet", "--batch", executable, + "-ex", "set debuginfod enabled off", + "-ex", fmt.Sprintf("break %s:%d", source, runtimeLine), + "-ex", fmt.Sprintf("break %s:%d", source, interfaceLine), + "-ex", fmt.Sprintf("break %s:%d", source, functionLine), + "-ex", fmt.Sprintf("break %s:%d", source, containerLine), + "-ex", "break main.InspectGoroutineValues", + "-ex", "run", + "-ex", "echo LLGO_CASE=runtime\\n", + "-ex", "llgo status", + "-ex", "p text", + "-ex", "p binary", + "-ex", "p ints", + "-ex", "p namedInts", + "-ex", "continue", + "-ex", "echo LLGO_CASE=interface\\n", + "-ex", "p nilAny", + "-ex", "p anyInt", + "-ex", "p anyText", + "-ex", "p nilFoo", + "-ex", "p foo", + "-ex", "p err", + "-ex", "continue", + "-ex", "echo LLGO_CASE=function\\n", + "-ex", "p plain", + "-ex", "p closure", + "-ex", "p bound", + "-ex", "p nilFunc", + "-ex", "continue", + "-ex", "echo LLGO_CASE=container\\n", + "-ex", "set print elements 64", + "-ex", "p nilMap", + "-ex", "p single", + "-ex", "p named", + "-ex", "p many", + "-ex", "p queued", + "-ex", "p namedChannel", + "-ex", "p closedChannel", + "-ex", "continue", + "-ex", "echo LLGO_CASE=goroutine\\n", + "-ex", "llgo goroutines", + "-ex", "llgo goroutine 1 bt 3", + } + output := integrationRunGDB(t, gdbPath, fixtureDir, args...) + for _, expected := range []string{ + "LLGO_CASE=runtime", + "LLGo debugger schema v1 (runtime layout v1)", + `= "hello"`, + `= "a\000b"`, + "len=2 cap=4 = {7, 8}", + "len=4 cap=4 = {11, 12, 13, 14}", + "LLGO_CASE=interface", + "= nil", + "= type=int", + "= type=string", + "= type=*main.Struct", + "= type=*errors.errorString", + "LLGO_CASE=function", + "= main.Plain", + "main.RuntimeFunctionValues$1 (closure)", + "main.(*Counter).Add$bound (bound method)", + "LLGO_CASE=container", + `len=1 = {["answer"] = 42}`, + `len=1 = {["named"] = 17}`, + "len=24 = {", + "len=2 cap=4 = {8, 9}", + "len=1 cap=2 = {31}", + `len=1 cap=2 closed = {"remaining"}`, + "LLGO_CASE=goroutine", + "goroutine 1 [running] parent=0", + "ownership=linked", + "main.InspectGoroutineValues", + } { + if !strings.Contains(output, expected) { + t.Fatalf("GDB output missing %q:\n%s", expected, output) + } + } + + integrationTestFallback(t, gdbPath) +} + +func integrationTestFallback(t *testing.T, gdbPath string) { + cc := integrationTool(t, "cc") + dir := t.TempDir() + source := filepath.Join(dir, "fallback.c") + executable := filepath.Join(dir, "fallback") + code := ` +typedef struct { const char *data; unsigned long len; } string; +string cstring = {"raw", 3}; +#ifdef LLGO_MARKER_V2 +__attribute__((used)) int __llgo_debugger_marker_v2 = 2; +#endif +#ifdef LLGO_BAD_RECORD +__attribute__((used)) int __llgo_debugger_marker_v1 = 1; +__attribute__((used, visibility("hidden"))) +unsigned char __llgo_debugger_abi_v1[16] = { + 0x4c, 0x4c, 0x47, 0x4f, 0x44, 0x42, 0x47, 0, + 1, 2, 1, 1, 2, sizeof(void *), 1, 0 +}; +#endif +int main(void) { return 0; } +` + if err := os.WriteFile(source, []byte(code), 0600); err != nil { + t.Fatal(err) + } + compile := exec.Command(cc, "-g", "-o", executable, source) + if output, err := compile.CombinedOutput(); err != nil { + t.Fatalf("compile fallback fixture: %v\n%s", err, output) + } + output := integrationRunGDB( + t, gdbPath, dir, + "--nx", "--quiet", "--batch", executable, + "-ex", "llgo status", + "-ex", "p cstring", + "-ex", "p 1+1", + ) + for _, expected := range []string{ + "Not an LLGo target; raw GDB debugging remains available.", + `= {data = 0x`, + `len = 3}`, + `= 2`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("non-LLGo fallback output missing %q:\n%s", expected, output) + } + } + + compile = exec.Command(cc, "-g", "-DLLGO_MARKER_V2", "-o", executable, source) + if output, err := compile.CombinedOutput(); err != nil { + t.Fatalf("compile unsupported marker fixture: %v\n%s", err, output) + } + output = integrationRunGDB( + t, gdbPath, dir, + "--nx", "--quiet", "--batch", executable, + "-ex", "llgo status", + "-ex", "p 1+1", + ) + for _, expected := range []string{ + "Unsupported LLGo debugger marker version(s): v2", + "raw GDB debugging remains available.", + `= 2`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("unsupported marker output missing %q:\n%s", expected, output) + } + } + + compile = exec.Command(cc, "-g", "-DLLGO_BAD_RECORD", "-o", executable, source) + if output, err := compile.CombinedOutput(); err != nil { + t.Fatalf("compile unsupported record fixture: %v\n%s", err, output) + } + output = integrationRunGDB( + t, gdbPath, dir, + "--nx", "--quiet", "--batch", executable, + "-ex", "llgo status", + "-ex", "p 1+1", + ) + for _, expected := range []string{ + "Unsupported LLGo debugger ABI", + "unsupported record/schema/runtime/ABI versions", + "raw GDB debugging remains available.", + `= 2`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("unsupported record output missing %q:\n%s", expected, output) + } + } +} + +func integrationRunGDB(t *testing.T, gdbPath, dir string, args ...string) string { + t.Helper() + var stdout, stderr bytes.Buffer + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("restore working directory: %v", err) + } + }() + if err := Run(gdbPath, nil, args, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("run GDB: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + } + return stdout.String() + stderr.String() +} + +func integrationMarkerLine(t *testing.T, path, marker string) int { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + scanner := bufio.NewScanner(file) + for line := 1; scanner.Scan(); line++ { + if strings.Contains(scanner.Text(), marker) { + return line + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + t.Fatalf("marker %q not found in %s", marker, path) + return 0 +} + +func integrationTool(t *testing.T, candidates ...string) string { + t.Helper() + for _, candidate := range candidates { + if candidate == "" { + continue + } + if path, err := exec.LookPath(candidate); err == nil { + return path + } + } + t.Fatalf("required integration tool not found: %s", strings.Join(candidates, ", ")) + return "" +} + +func integrationRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate integration test source") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) +} diff --git a/cmd/internal/gdb/llgo_plugin.py b/cmd/internal/gdb/llgo_plugin.py new file mode 100644 index 0000000000..301de9c3ec --- /dev/null +++ b/cmd/internal/gdb/llgo_plugin.py @@ -0,0 +1,1092 @@ +# Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""GDB presentation adapter for the versioned LLGo debugger ABI.""" + +import json +from pathlib import Path +import re + +import gdb + + +LLGO_DEBUGGER_MARKER_PREFIX = "__llgo_debugger_marker_v" +LLGO_DEBUGGER_SCHEMA_FILENAME = "llgo_debugger_schema_v1.json" +LLGO_MAX_STRING_SUMMARY_BYTES = 256 +LLGO_MAX_TYPE_NAME_BYTES = 4096 +LLGO_MAX_CONTAINER_SCAN_BUCKETS = 65536 +LLGO_MAX_GOROUTINES = 65536 + + +def _load_schema(): + source = Path(__file__).resolve() + candidates = [source.with_name(LLGO_DEBUGGER_SCHEMA_FILENAME)] + if len(source.parents) > 3: + candidates.append( + source.parents[3] / "internal" / "debugabi" / "schema_v1.json") + errors = [] + for path in candidates: + try: + with path.open("r", encoding="utf-8") as schema_file: + schema = json.load(schema_file) + if schema.get("contract") != "llgo.debugger": + raise ValueError("unexpected debugger schema contract") + return schema, None + except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: + errors.append(f"{path}: {error}") + return {}, "; ".join(errors) + + +LLGO_DEBUGGER_SCHEMA, LLGO_DEBUGGER_SCHEMA_ERROR = _load_schema() +_RECORD_SCHEMA = LLGO_DEBUGGER_SCHEMA.get("record", {}) +LLGO_DEBUGGER_RECORD_SYMBOL = _RECORD_SCHEMA.get("native_symbol", "") +LLGO_DEBUGGER_RECORD_SIZE = int(_RECORD_SCHEMA.get("size", 0)) +try: + LLGO_DEBUGGER_RECORD_MAGIC = bytes.fromhex( + _RECORD_SCHEMA.get("magic_hex", "")) +except ValueError: + LLGO_DEBUGGER_RECORD_MAGIC = b"" +LLGO_DEBUGGER_RECORD_FIELDS = { + field.get("name"): field + for field in _RECORD_SCHEMA.get("fields", []) + if isinstance(field, dict) and field.get("name") +} +LLGO_DEBUGGER_SCHEMAS = { + symbol: ( + int(contract.get("schema_version", 0)), + int(contract.get("runtime_layout_version", 0)), + int(contract.get("llgo_abi_version", 0)), + ) + for symbol, contract in _RECORD_SCHEMA.get("legacy_symbols", {}).items() + if isinstance(contract, dict) +} +LLGO_BYTE_ORDERS = { + int(value): name + for value, name in LLGO_DEBUGGER_SCHEMA.get("byte_orders", {}).items() +} +LLGO_CABI_MODES = { + int(value): name + for value, name in LLGO_DEBUGGER_SCHEMA.get("cabi_modes", {}).items() +} +LLGO_RUNTIME_LAYOUTS = { + int(version): layout + for version, layout in LLGO_DEBUGGER_SCHEMA.get( + "runtime_layouts", {}).items() + if isinstance(layout, dict) +} +_TARGET_INFO_CACHE = {} + + +class TargetInfo: + def __init__(self, marker_versions=(), schema_version=None, + runtime_layout_version=None, architecture="", + pointer_size=0, byte_order="unknown", record_version=None, + llgo_abi_version=None, cabi_mode=None, cabi_name=None, + compatibility_error=None): + self.marker_versions = marker_versions + self.schema_version = schema_version + self.runtime_layout_version = runtime_layout_version + self.architecture = architecture + self.pointer_size = pointer_size + self.byte_order = byte_order + self.record_version = record_version + self.llgo_abi_version = llgo_abi_version + self.cabi_mode = cabi_mode + self.cabi_name = cabi_name + self.compatibility_error = compatibility_error + + @property + def supported(self): + return ( + self.schema_version is not None and + self.runtime_layout_version in LLGO_RUNTIME_LAYOUTS and + self.compatibility_error is None + ) + + +def _symbol_address(name): + try: + output = gdb.execute(f"info address {name}", to_string=True) + except gdb.error: + return None + match = re.search(r"\bat (?:address )?(0x[0-9a-fA-F]+)\b", output) + return int(match.group(1), 16) if match else None + + +def _marker_versions(): + versions = set() + try: + output = gdb.execute( + f"info variables {LLGO_DEBUGGER_MARKER_PREFIX}", to_string=True) + except gdb.error: + output = "" + pattern = re.compile( + rf"\b{re.escape(LLGO_DEBUGGER_MARKER_PREFIX)}([0-9]+)\b") + versions.update( + int(match.group(1)) for match in pattern.finditer(output)) + # Local object symbols do not appear in info variables on every GDB target. + # Marker versions are one byte, so probing the finite namespace also keeps + # unknown-version rejection deterministic. + for version in range(1, 256): + if version in versions: + continue + if _symbol_address( + f"{LLGO_DEBUGGER_MARKER_PREFIX}{version}") is not None: + versions.add(version) + return tuple(sorted(versions)) + + +def _record_field(raw, name): + field = LLGO_DEBUGGER_RECORD_FIELDS.get(name) + if not isinstance(field, dict): + return None + try: + offset = int(field["offset"]) + size = int(field["size"]) + except (KeyError, TypeError, ValueError): + return None + if offset < 0 or size <= 0 or offset + size > len(raw): + return None + return int.from_bytes(raw[offset:offset + size], "little") + + +def _read_debugger_record(): + if not LLGO_DEBUGGER_RECORD_SYMBOL or LLGO_DEBUGGER_RECORD_SIZE <= 0: + return None + address = _symbol_address(LLGO_DEBUGGER_RECORD_SYMBOL) + if address is None: + return None + try: + memory = gdb.selected_inferior().read_memory( + address, LLGO_DEBUGGER_RECORD_SIZE) + return memory.tobytes() + except (gdb.error, ValueError): + return None + + +def _decode_debugger_record(raw): + if len(raw) != LLGO_DEBUGGER_RECORD_SIZE: + return None, "incorrectly sized native record" + if not LLGO_DEBUGGER_RECORD_MAGIC or not raw.startswith( + LLGO_DEBUGGER_RECORD_MAGIC): + return None, "invalid record magic" + values = { + name: _record_field(raw, name) + for name in ( + "record_version", "schema_version", "runtime_layout_version", + "llgo_abi_version", "cabi_mode", "pointer_size", "byte_order", + "reserved", + ) + } + if any(value is None for value in values.values()): + return None, "record fields do not match the loaded schema" + if values["reserved"] != 0: + return None, "record reserved byte is non-zero" + return values, None + + +def _target_byte_order(): + try: + output = gdb.execute("show endian", to_string=True).lower() + except gdb.error: + return "unknown" + if "little endian" in output: + return "little" + if "big endian" in output: + return "big" + return "unknown" + + +def _target_cache_key(): + try: + objfiles = tuple(objfile.filename or "" for objfile in gdb.objfiles()) + inferior = gdb.selected_inferior() + architecture = inferior.architecture().name() + pid = inferior.pid + except (gdb.error, AttributeError): + return () + return objfiles, architecture, pid + + +def inspect_target(): + key = _target_cache_key() + cached = _TARGET_INFO_CACHE.get(key) + if cached is not None: + return cached + + marker_versions = _marker_versions() + schema_version = None + runtime_layout_version = None + llgo_abi_version = None + record_version = None + cabi_mode = None + cabi_name = None + compatibility_error = None + try: + pointer_size = gdb.lookup_type("void").pointer().sizeof + architecture = gdb.selected_inferior().architecture().name() + except (gdb.error, AttributeError): + pointer_size = 0 + architecture = "" + byte_order = _target_byte_order() + + raw_record = _read_debugger_record() + if raw_record is not None: + record, compatibility_error = _decode_debugger_record(raw_record) + if record is not None: + record_version = record["record_version"] + schema_version = record["schema_version"] + runtime_layout_version = record["runtime_layout_version"] + llgo_abi_version = record["llgo_abi_version"] + cabi_mode = record["cabi_mode"] + cabi_name = LLGO_CABI_MODES.get(cabi_mode) + record_byte_order = LLGO_BYTE_ORDERS.get(record["byte_order"]) + expected = ( + int(_RECORD_SCHEMA.get("version", 0)), + int(LLGO_DEBUGGER_SCHEMA.get("schema_version", 0)), + int(LLGO_DEBUGGER_SCHEMA.get( + "runtime_layout_version", 0)), + int(LLGO_DEBUGGER_SCHEMA.get("llgo_abi_version", 0)), + ) + actual = ( + record_version, schema_version, runtime_layout_version, + llgo_abi_version, + ) + if actual != expected: + compatibility_error = ( + "unsupported record/schema/runtime/ABI versions " + f"{actual}, want {expected}") + elif cabi_name is None: + compatibility_error = f"unsupported C ABI mode {cabi_mode}" + elif record["pointer_size"] != pointer_size: + compatibility_error = ( + f"record pointer size {record['pointer_size']} does not " + f"match target pointer size {pointer_size}") + elif record_byte_order != byte_order: + compatibility_error = ( + f"record byte order " + f"{record_byte_order or record['byte_order']} does not " + f"match target byte order {byte_order}") + elif marker_versions and marker_versions != (schema_version,): + compatibility_error = ( + f"record schema v{schema_version} conflicts with legacy " + f"marker version(s) {marker_versions}") + + if raw_record is None and len(marker_versions) == 1: + candidate = marker_versions[0] + for supported in LLGO_DEBUGGER_SCHEMAS.values(): + if candidate == supported[0]: + schema_version = supported[0] + runtime_layout_version = supported[1] + llgo_abi_version = supported[2] + break + + if ((marker_versions or raw_record is not None) and + not LLGO_DEBUGGER_SCHEMA and compatibility_error is None): + compatibility_error = ( + "debugger schema could not be loaded: " + + (LLGO_DEBUGGER_SCHEMA_ERROR or "unknown error")) + + info = TargetInfo( + marker_versions=marker_versions, + schema_version=schema_version, + runtime_layout_version=runtime_layout_version, + architecture=architecture, + pointer_size=pointer_size, + byte_order=byte_order, + record_version=record_version, + llgo_abi_version=llgo_abi_version, + cabi_mode=cabi_mode, + cabi_name=cabi_name, + compatibility_error=compatibility_error, + ) + _TARGET_INFO_CACHE[key] = info + return info + + +def target_status(info): + if not info.marker_versions and info.record_version is None: + return "Not an LLGo target; raw GDB debugging remains available." + if info.compatibility_error: + return ( + f"Unsupported LLGo debugger ABI: {info.compatibility_error}; " + "raw GDB debugging remains available." + ) + if not info.supported: + versions = ", ".join(f"v{version}" + for version in info.marker_versions) + return ( + f"Unsupported LLGo debugger marker version(s): {versions}; " + "raw GDB debugging remains available." + ) + abi = (f"LLGo ABI v{info.llgo_abi_version}; " + if info.llgo_abi_version is not None else "") + cabi = (f"C ABI mode {info.cabi_mode} ({info.cabi_name}); " + if info.cabi_mode is not None else "") + return ( + f"LLGo debugger schema v{info.schema_version} " + f"(runtime layout v{info.runtime_layout_version}); " + f"{abi}{cabi}target {info.architecture}; " + f"pointer size {info.pointer_size}; byte order {info.byte_order}." + ) + + +def _require_supported_target(): + info = inspect_target() + if not info.supported: + raise gdb.GdbError(target_status(info)) + return info + + +def _layout_for_value(_value=None): + info = inspect_target() + return (LLGO_RUNTIME_LAYOUTS.get(info.runtime_layout_version) + if info.supported else None) + + +def _raw_value(value): + try: + while value.type.code in (gdb.TYPE_CODE_REF, gdb.TYPE_CODE_RVALUE_REF): + value = value.referenced_value() + except (gdb.error, AttributeError): + pass + return value + + +def _canonical_type_name(value): + try: + value_type = value.type.strip_typedefs() + except (gdb.error, AttributeError): + value_type = value.type + name = str(value_type) + if name.startswith("struct "): + return name[len("struct "):] + return name + + +def _pointer_target_name(value): + try: + value_type = value.type.strip_typedefs() + if value_type.code != gdb.TYPE_CODE_PTR: + return "" + name = str(value_type.target().strip_typedefs()) + except (gdb.error, AttributeError): + return "" + return name[len("struct "):] if name.startswith("struct ") else name + + +def _field(value, name): + try: + return value[name] + except (gdb.error, KeyError, TypeError): + return None + + +def _value_as_int(value): + if value is None: + return None + try: + return int(value) + except (gdb.error, ValueError, TypeError): + return None + + +def _lookup_type(name): + for candidate in (name, "struct " + name): + try: + return gdb.lookup_type(candidate) + except gdb.error: + pass + return None + + +def _string_fields(value, layout): + raw = _raw_value(value) + if _canonical_type_name(raw) != layout["string"]["type_name"]: + return None + data = _field(raw, layout["string"]["data"]) + length = _value_as_int(_field(raw, layout["string"]["length"])) + address = _value_as_int(data) + if (data is None or length is None or length < 0 or + length > LLGO_MAX_TYPE_NAME_BYTES or + (length != 0 and (address is None or address == 0))): + return None + return data, length + + +def _read_string(value, layout, limit=LLGO_MAX_STRING_SUMMARY_BYTES): + fields = _string_fields(value, layout) + if fields is None: + return None + data, length = fields + truncated = length > limit + length = min(length, limit) + try: + text = data.string(encoding="utf-8", errors="replace", length=length) + except (gdb.error, UnicodeError): + return None + return text + ("..." if truncated else "") + + +class StringPrinter: + def __init__(self, value, layout): + self.value = value + self.layout = layout + + def display_hint(self): + return "string" + + def to_string(self): + return _read_string(self.value, self.layout) + + +class SlicePrinter: + def __init__(self, value, layout): + self.value = _raw_value(value) + self.layout = layout + + def _fields(self): + spec = self.layout["slice"] + if re.fullmatch(spec["type_pattern"], + _canonical_type_name(self.value)) is None: + return None + data = _field(self.value, spec["data"]) + length = _value_as_int(_field(self.value, spec["length"])) + capacity = _value_as_int(_field(self.value, spec["capacity"])) + address = _value_as_int(data) + if (data is None or length is None or capacity is None or + length < 0 or capacity < length or + (length and (address is None or address == 0))): + return None + try: + element_type = data.type.strip_typedefs().target() + except (gdb.error, AttributeError): + return None + return data.cast(element_type.pointer()), length, capacity + + def display_hint(self): + return "array" + + def to_string(self): + fields = self._fields() + if fields is None: + return None + return f"len={fields[1]} cap={fields[2]}" + + def children(self): + fields = self._fields() + if fields is None: + return + data, length, _ = fields + for index in range(length): + try: + yield f"[{index}]", (data + index).dereference() + except gdb.error: + return + + +def _runtime_type_name(value, address, layout): + runtime = layout["runtime_type"] + runtime_type = _lookup_type(runtime["type_name"]) + if runtime_type is None: + return None + try: + runtime_value = gdb.Value(address).cast( + runtime_type.pointer()).dereference() + except gdb.error: + return None + name = _read_string(_field(runtime_value, runtime["string"]), + layout, LLGO_MAX_TYPE_NAME_BYTES) + if name is None: + return None + tflag = _value_as_int(_field(runtime_value, runtime["tflag"])) + if tflag is not None and tflag & int(runtime["extra_star_flag"]): + name = "*" + name + return name + + +class InterfacePrinter: + def __init__(self, value, layout): + self.value = _raw_value(value) + self.layout = layout + + def to_string(self): + interface = self.layout["interface"] + name = _canonical_type_name(self.value) + if re.fullmatch(interface["type_pattern"], name) is None: + return None + type_address = _value_as_int(_field(self.value, interface["type"])) + if type_address is None: + return None + if type_address == 0: + return "nil" + if name != interface["empty_type"]: + itab_type = _lookup_type(interface["itab_type"]) + if itab_type is None: + return None + try: + itab = gdb.Value(type_address).cast( + itab_type.pointer()).dereference() + except gdb.error: + return None + type_address = _value_as_int( + _field(itab, interface["itab_concrete_type"])) + if type_address is None or type_address == 0: + return None + type_name = _runtime_type_name( + self.value, type_address, self.layout) + return f"type={type_name}" if type_name else None + + +def _symbol_name(address): + if not address: + return None + try: + block = gdb.block_for_pc(address) + if block is not None and block.function is not None: + return block.function.print_name + except (gdb.error, RuntimeError): + pass + try: + output = gdb.execute(f"info symbol 0x{address:x}", to_string=True) + except gdb.error: + return None + if output.startswith("No symbol"): + return None + return output.split(" in section ", 1)[0].split(" + ", 1)[0].strip() + + +class FunctionPrinter: + def __init__(self, value, layout): + self.value = _raw_value(value) + self.layout = layout + + def to_string(self): + function = self.layout["function"] + if re.fullmatch(function["type_pattern"], + _canonical_type_name(self.value)) is None: + return None + code = _value_as_int(_field(self.value, function["code"])) + data = _value_as_int(_field(self.value, function["data"])) + if code is None or data is None: + return None + if code == 0: + return "nil" + name = _symbol_name(code) or f"0x{code:x}" + if name.startswith("__llgo_stub."): + name = name[len("__llgo_stub."):] + if name.endswith(function["bound_symbol_suffix"]): + return f"{name} (bound method)" + if re.search(function["closure_symbol_pattern"], name): + return f"{name} (closure)" + return name + + +def _pointer_runtime_value(value, pattern, target_prefix=""): + raw = _raw_value(value) + matches_name = ( + re.fullmatch(pattern, _canonical_type_name(raw)) is not None) + matches_target = ( + target_prefix and + _pointer_target_name(raw).startswith(target_prefix) + ) + if not matches_name and not matches_target: + return None, None + address = _value_as_int(raw) + if address is None or address == 0: + return address, None + try: + return address, raw.dereference() + except gdb.error: + return address, None + + +def _array_length(value): + try: + low, high = value.type.strip_typedefs().range() + return high - low + 1 + except (gdb.error, AttributeError, TypeError): + return 0 + + +class MapPrinter: + def __init__(self, value, layout): + self.value = _raw_value(value) + self.layout = layout + + def _hash(self): + return _pointer_runtime_value( + self.value, self.layout["map"]["type_pattern"], "hash<") + + def display_hint(self): + return "map" + + def to_string(self): + address, hash_value = self._hash() + if address is None: + return None + if address == 0: + return "nil" + if hash_value is None: + return None + length = _value_as_int( + _field(hash_value, self.layout["map"]["count"])) + return f"len={length}" if length is not None and length >= 0 else None + + def children(self): + _, hash_value = self._hash() + if hash_value is None: + return + spec = self.layout["map"] + length = _value_as_int(_field(hash_value, spec["count"])) + flags = _value_as_int(_field(hash_value, spec["flags"])) + bucket_bits = _value_as_int( + _field(hash_value, spec["bucket_bits"])) + buckets = _field(hash_value, spec["buckets"]) + old_buckets = _field(hash_value, spec["old_buckets"]) + buckets_address = _value_as_int(buckets) + old_buckets_address = _value_as_int(old_buckets) + if (length is None or flags is None or bucket_bits is None or + buckets is None or old_buckets is None or + buckets_address is None or old_buckets_address is None or + length <= 0 or bucket_bits < 0 or bucket_bits >= 63 or + buckets_address == 0): + return + try: + bucket_type = buckets.type.strip_typedefs().target() + bucket_size = bucket_type.sizeof + except (gdb.error, AttributeError): + return + if bucket_size <= 0: + return + + logical_buckets = 1 << bucket_bits + scan_buckets = min(logical_buckets, + LLGO_MAX_CONTAINER_SCAN_BUCKETS) + old_count = ( + logical_buckets + if flags & int(spec["same_size_grow_flag"]) + else logical_buckets >> 1 + ) + emitted = 0 + + def bucket_at(address): + try: + return gdb.Value(address).cast( + bucket_type.pointer()).dereference() + except gdb.error: + return None + + def evacuated(address): + bucket = bucket_at(address) + if bucket is None: + return False + tophash = _field(bucket, spec["bucket_tophash"]) + if tophash is None or _array_length(tophash) == 0: + return False + first = _value_as_int(tophash[0]) + return ( + first is not None and + int(spec["evacuated_tophash_min"]) <= first <= + int(spec["evacuated_tophash_max"]) + ) + + for bucket_index in range(scan_buckets): + bucket_address = buckets_address + bucket_index * bucket_size + if old_buckets_address and old_count: + old_index = bucket_index & (old_count - 1) + old_address = ( + old_buckets_address + old_index * bucket_size) + if not evacuated(old_address): + if bucket_index >= old_count: + continue + bucket_address = old_address + + visited = set() + while (bucket_address and bucket_address not in visited and + emitted < length): + visited.add(bucket_address) + bucket = bucket_at(bucket_address) + if bucket is None: + return + tophash = _field(bucket, spec["bucket_tophash"]) + keys = _field(bucket, spec["bucket_keys"]) + indirect_keys = keys is None + if indirect_keys: + keys = _field(bucket, spec["bucket_indirect_keys"]) + values = _field(bucket, spec["bucket_values"]) + indirect_values = values is None + if indirect_values: + values = _field( + bucket, spec["bucket_indirect_values"]) + if tophash is None or keys is None or values is None: + return + slots = min( + _array_length(tophash), + _array_length(keys), + _array_length(values), + ) + for slot in range(slots): + top = _value_as_int(tophash[slot]) + if (top is None or + top < int(spec["occupied_tophash_min"])): + continue + try: + key = keys[slot] + element = values[slot] + if indirect_keys and (_value_as_int(key) or 0) != 0: + key = key.dereference() + if (indirect_values and + (_value_as_int(element) or 0) != 0): + element = element.dereference() + except gdb.error: + return + yield f"key[{emitted}]", key + yield f"value[{emitted}]", element + emitted += 1 + if emitted >= length: + return + bucket_address = ( + _value_as_int(_field(bucket, spec["bucket_overflow"])) + or 0 + ) + + +class ChannelPrinter: + def __init__(self, value, layout): + self.value = _raw_value(value) + self.layout = layout + + def _fields(self): + spec = self.layout["channel"] + address, channel = _pointer_runtime_value( + self.value, spec["type_pattern"], "hchan<") + if address is None or address == 0 or channel is None: + return address, None + length = _value_as_int(_field(channel, spec["count"])) + capacity = _value_as_int(_field(channel, spec["capacity"])) + buffer = _value_as_int(_field(channel, spec["buffer"])) + receive_index = _value_as_int( + _field(channel, spec["receive_index"])) + closed = _value_as_int(_field(channel, spec["closed"])) + if (length is None or capacity is None or buffer is None or + receive_index is None or closed is None or length < 0 or + capacity < length or + (capacity and receive_index >= capacity) or + (length and buffer == 0)): + return address, None + try: + queue = _field(channel, spec["receive_queue"]) + first = _field(queue, spec["queue_first"]) + waiter_type = first.type.strip_typedefs().target() + element_pointer = next( + field.type + for field in waiter_type.strip_typedefs().fields() + if field.name == spec["waiter_element"] + ) + element_type = element_pointer.strip_typedefs().target() + except (gdb.error, AttributeError, StopIteration): + return address, None + return address, ( + length, capacity, buffer, receive_index, closed != 0, + element_type, + ) + + def display_hint(self): + return "array" + + def to_string(self): + address, fields = self._fields() + if address is None: + return None + if address == 0: + return "nil" + if fields is None: + return None + suffix = " closed" if fields[4] else "" + return f"len={fields[0]} cap={fields[1]}{suffix}" + + def children(self): + _, fields = self._fields() + if fields is None: + return + length, capacity, buffer, receive_index, _, element_type = fields + if capacity == 0 or buffer == 0: + return + pointer = gdb.Value(buffer).cast(element_type.pointer()) + for index in range(length): + buffer_index = (receive_index + index) % capacity + try: + yield f"[{index}]", ( + pointer + buffer_index).dereference() + except gdb.error: + return + + +def lookup_pretty_printer(value): + layout = _layout_for_value(value) + if layout is None: + return None + raw = _raw_value(value) + name = _canonical_type_name(raw) + try: + if name == layout["string"]["type_name"]: + return StringPrinter(raw, layout) + if re.fullmatch(layout["slice"]["type_pattern"], name): + return SlicePrinter(raw, layout) + if re.fullmatch(layout["interface"]["type_pattern"], name): + return InterfacePrinter(raw, layout) + if re.fullmatch(layout["function"]["type_pattern"], name): + return FunctionPrinter(raw, layout) + if (re.fullmatch(layout["map"]["type_pattern"], name) or + _pointer_target_name(raw).startswith("hash<")): + return MapPrinter(raw, layout) + if (re.fullmatch(layout["channel"]["type_pattern"], name) or + _pointer_target_name(raw).startswith("hchan<")): + return ChannelPrinter(raw, layout) + except (gdb.error, KeyError, TypeError, ValueError): + return None + return None + + +lookup_pretty_printer.name = "llgo" + + +def _goroutine_layout(info): + layout = LLGO_RUNTIME_LAYOUTS.get(info.runtime_layout_version) + return layout.get("goroutine") if layout else None + + +def _read_pointer(address, pointer_size, byte_order): + try: + raw = gdb.selected_inferior().read_memory( + address, pointer_size).tobytes() + except (gdb.error, ValueError): + return None + return int.from_bytes(raw, byte_order) + + +def _thread_for_procid(procid): + if procid < 0: + return None + try: + threads = gdb.selected_inferior().threads() + except gdb.error: + return None + for thread in threads: + ptid = thread.ptid + if procid in (ptid[1], ptid[2]): + return thread + return None + + +def _goroutines(): + info = _require_supported_target() + layout = _goroutine_layout(info) + if not layout: + raise gdb.GdbError( + "LLGo debugger schema has no goroutine layout.") + head_address = _symbol_address(layout["head_symbol"]) + goroutine_type = _lookup_type(layout["goroutine_type"]) + if head_address is None or goroutine_type is None: + raise gdb.GdbError( + "LLGo goroutine registry is unavailable in this target.") + pointer = _read_pointer( + head_address, info.pointer_size, info.byte_order) + if pointer is None: + raise gdb.GdbError( + "LLGo goroutine registry requires a stopped process.") + + result = [] + visited = set() + while pointer and pointer not in visited: + if len(result) >= LLGO_MAX_GOROUTINES: + raise gdb.GdbError( + f"LLGo goroutine registry exceeds " + f"{LLGO_MAX_GOROUTINES} entries.") + visited.add(pointer) + try: + value = gdb.Value(pointer).cast( + goroutine_type.pointer()).dereference() + except gdb.error as error: + raise gdb.GdbError( + f"cannot read LLGo goroutine at 0x{pointer:x}: {error}") + status = _value_as_int(_field(value, layout["status"])) + goid = _value_as_int(_field(value, layout["id"])) + parent = _value_as_int(_field(value, layout["parent_id"])) + m_value = _field(value, layout["m"]) + m_address = _value_as_int(m_value) or 0 + mid = -1 + pid = -1 + procid = -1 + ownership = False + if m_address: + try: + m = m_value.dereference() + mid = _value_as_int(_field(m, layout["m_id"])) + procid = _value_as_int(_field(m, layout["m_procid"])) + current = _value_as_int( + _field(m, layout["m_current_goroutine"])) + p_value = _field(m, layout["m_p"]) + p_address = _value_as_int(p_value) or 0 + ownership = current == pointer + if p_address: + p = p_value.dereference() + pid = _value_as_int(_field(p, layout["p_id"])) + ownership = ( + ownership and + _value_as_int(_field(p, layout["p_m"])) == m_address + ) + except gdb.error: + ownership = False + status_names = { + int(value): name + for value, name in layout.get("status_names", {}).items() + } + result.append({ + "address": pointer, + "goid": goid if goid is not None else -1, + "parent": parent if parent is not None else -1, + "status": status if status is not None else -1, + "status_name": status_names.get( + status, f"unknown({status})"), + "mid": mid if mid is not None else -1, + "pid": pid if pid is not None else -1, + "procid": procid if procid is not None else -1, + "ownership": ownership, + "thread": _thread_for_procid( + procid if procid is not None else -1), + }) + pointer = _value_as_int(_field(value, layout["next"])) or 0 + return result + + +class LLGoPrefix(gdb.Command): + """LLGo debugger commands.""" + + def __init__(self): + super().__init__("llgo", gdb.COMMAND_USER, prefix=True) + + +class LLGoStatus(gdb.Command): + """Show LLGo debugger ABI and target compatibility.""" + + def __init__(self): + super().__init__("llgo status", gdb.COMMAND_STATUS) + + def invoke(self, _argument, _from_tty): + print(target_status(inspect_target())) + + +class LLGoPrint(gdb.Command): + """Print an expression with LLGo runtime presentation.""" + + def __init__(self): + super().__init__("llgo print", gdb.COMMAND_DATA, + gdb.COMPLETE_EXPRESSION) + + def invoke(self, argument, _from_tty): + _require_supported_target() + expression = argument.strip() + if not expression: + raise gdb.GdbError("usage: llgo print ") + gdb.execute("print " + expression) + + +class LLGoVars(gdb.Command): + """Print arguments and locals with LLGo runtime presentation.""" + + def __init__(self): + super().__init__("llgo vars", gdb.COMMAND_DATA) + + def invoke(self, _argument, _from_tty): + _require_supported_target() + if gdb.selected_thread() is None: + raise gdb.GdbError( + "LLGo command requires a stopped process.") + gdb.execute("info args") + gdb.execute("info locals") + + +class LLGoGoroutines(gdb.Command): + """List LLGo goroutines and their M/P/thread ownership.""" + + def __init__(self): + super().__init__("llgo goroutines", gdb.COMMAND_STACK) + + def invoke(self, _argument, _from_tty): + for goroutine in _goroutines(): + thread = goroutine["thread"] + thread_id = str(thread.num) if thread is not None else "unavailable" + ownership = ( + "linked" if goroutine["ownership"] else "invalid") + print( + f"goroutine {goroutine['goid']} " + f"[{goroutine['status_name']}] " + f"parent={goroutine['parent']} " + f"m={goroutine['mid']} p={goroutine['pid']} " + f"thread={thread_id} ownership={ownership}" + ) + + +class LLGoGoroutine(gdb.Command): + """Run a GDB command in the native thread that owns an LLGo goroutine.""" + + def __init__(self): + super().__init__("llgo goroutine", gdb.COMMAND_STACK) + + def invoke(self, argument, _from_tty): + args = gdb.string_to_argv(argument) + if len(args) < 2: + raise gdb.GdbError( + "usage: llgo goroutine ") + try: + goid = int(args[0], 10) + except ValueError as error: + raise gdb.GdbError( + f"invalid goroutine id {args[0]!r}") from error + goroutine = next( + (item for item in _goroutines() if item["goid"] == goid), + None, + ) + if goroutine is None: + raise gdb.GdbError(f"LLGo goroutine {goid} was not found.") + thread = goroutine["thread"] + if thread is None: + raise gdb.GdbError( + f"LLGo goroutine {goid} has no matching debugger thread.") + current = gdb.selected_thread() + try: + thread.switch() + gdb.execute(" ".join(args[1:])) + finally: + if current is not None and current.is_valid(): + current.switch() + + +def register(): + gdb.pretty_printers[:] = [ + printer for printer in gdb.pretty_printers + if getattr(printer, "name", None) != "llgo" + ] + gdb.pretty_printers.append(lookup_pretty_printer) + LLGoPrefix() + LLGoStatus() + LLGoPrint() + LLGoVars() + LLGoGoroutines() + LLGoGoroutine() + + +register() diff --git a/cmd/llgo/debugtest/embedded/runtest.sh b/cmd/llgo/debugtest/embedded/runtest.sh index be97df27a5..0ea04e1cbc 100755 --- a/cmd/llgo/debugtest/embedded/runtest.sh +++ b/cmd/llgo/debugtest/embedded/runtest.sh @@ -122,9 +122,12 @@ start_qemu "$gdb_port" if ! gdb_output=$("$gdb" --nx --quiet --batch "$debug_elf" \ -ex "set pagination off" \ -ex "set confirm off" \ + -ex "source $repo_root/cmd/internal/gdb/llgo_plugin.py" \ -ex "target remote 127.0.0.1:$gdb_port" \ -ex "break $source_file:$break_line" \ -ex "continue" \ + -ex "llgo status" \ + -ex "p text" \ -ex 'printf "LLGO_SEED=%d\\n", seed' \ -ex 'printf "LLGO_PAIR=%d,%d\\n", pair.Left, pair.Right' \ -ex 'printf "LLGO_VALUES=%d,%d,%d\\n", values[0], values[1], values[2]' \ @@ -136,6 +139,8 @@ if ! gdb_output=$("$gdb" --nx --quiet --batch "$debug_elf" \ exit 1 fi stop_qemu +assert_contains "$gdb_output" "LLGo debugger schema v1 (runtime layout v1)" +assert_contains "$gdb_output" '= "embedded"' assert_contains "$gdb_output" "LLGO_SEED=7" assert_contains "$gdb_output" "LLGO_PAIR=7,8" assert_contains "$gdb_output" "LLGO_VALUES=9,10,11" diff --git a/internal/build/build.go b/internal/build/build.go index b951e78eab..1388f55f65 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -47,6 +47,7 @@ import ( "github.com/goplus/llgo/internal/crosscompile" "github.com/goplus/llgo/internal/dcepass" "github.com/goplus/llgo/internal/deadcode" + "github.com/goplus/llgo/internal/debugabi" "github.com/goplus/llgo/internal/env" "github.com/goplus/llgo/internal/firmware" "github.com/goplus/llgo/internal/flash" @@ -1438,6 +1439,7 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa } } } + linkArgs = append(linkArgs, debuggerABIRootArgs(ctx)...) linkArgs = append(linkArgs, cSharedExportArgs(ctx, linkedOrder)...) err = linkObjFiles(ctx, outputPath, linkInputs, linkArgs, verbose) @@ -1581,6 +1583,32 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose return cmd.Link(buildArgs...) } +// debuggerABIRootArgs keeps the native marker and structured record in the +// final debug artifact. llvm.used prevents compiler elimination, but a final +// ELF/Mach-O section-GC link still needs explicit roots. Wasm records are +// installed as a custom section by final debug-artifact processing instead. +func debuggerABIRootArgs(ctx *context) []string { + if ctx == nil || ctx.buildConf == nil || !ctx.frontendOptions.Debug || ctx.buildConf.Goarch == "wasm" { + return nil + } + symbols := []string{debugabi.LegacyMarkerSymbol, debugabi.NativeRecordSymbol} + args := make([]string, 0, len(symbols)*2) + for _, symbol := range symbols { + if ctx.buildConf.Goos == "darwin" { + if ctx.crossCompile.Linker != "" { + args = append(args, "-u", "_"+symbol) + } else { + args = append(args, "-Wl,-u,_"+symbol) + } + } else if ctx.crossCompile.Linker != "" { + args = append(args, "--undefined="+symbol) + } else { + args = append(args, "-Wl,--undefined="+symbol) + } + } + return args +} + // cSharedExportArgs keeps //export functions and synthetic test entry points as // shared-library link roots. They live in package archives and otherwise remain // unreferenced, so the linker can omit both their object files and symbols. diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 5d67a571b2..a69cd0a9db 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -26,6 +26,7 @@ import ( "github.com/goplus/llgo/cl" "github.com/goplus/llgo/internal/buildenv" "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/debugabi" "github.com/goplus/llgo/internal/env" "github.com/goplus/llgo/internal/lto" "github.com/goplus/llgo/internal/meta" @@ -322,6 +323,77 @@ func TestNeedsLinuxNoPIE(t *testing.T) { } } +func TestDebuggerABIRootArgs(t *testing.T) { + for _, test := range []struct { + name string + ctx *context + want []string + }{ + {name: "nil context"}, + {name: "no debug", ctx: &context{buildConf: &Config{Goos: "linux", Goarch: "arm64"}}}, + { + name: "wasm custom section", + ctx: &context{ + buildConf: &Config{Goos: "wasip1", Goarch: "wasm"}, + frontendOptions: cl.Options{Debug: true}, + }, + }, + { + name: "ELF compiler driver", + ctx: &context{ + buildConf: &Config{Goos: "linux", Goarch: "arm64"}, + frontendOptions: cl.Options{Debug: true}, + }, + want: []string{ + "-Wl,--undefined=" + debugabi.LegacyMarkerSymbol, + "-Wl,--undefined=" + debugabi.NativeRecordSymbol, + }, + }, + { + name: "ELF direct linker", + ctx: &context{ + buildConf: &Config{Goos: "linux", Goarch: "arm"}, + crossCompile: crosscompile.Export{Linker: "ld.lld"}, + frontendOptions: cl.Options{Debug: true}, + }, + want: []string{ + "--undefined=" + debugabi.LegacyMarkerSymbol, + "--undefined=" + debugabi.NativeRecordSymbol, + }, + }, + { + name: "Mach-O compiler driver", + ctx: &context{ + buildConf: &Config{Goos: "darwin", Goarch: "arm64"}, + frontendOptions: cl.Options{Debug: true}, + }, + want: []string{ + "-Wl,-u,_" + debugabi.LegacyMarkerSymbol, + "-Wl,-u,_" + debugabi.NativeRecordSymbol, + }, + }, + { + name: "Mach-O direct linker", + ctx: &context{ + buildConf: &Config{Goos: "darwin", Goarch: "arm64"}, + crossCompile: crosscompile.Export{Linker: "ld64.lld"}, + frontendOptions: cl.Options{Debug: true}, + }, + want: []string{ + "-u", "_" + debugabi.LegacyMarkerSymbol, + "-u", "_" + debugabi.NativeRecordSymbol, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + got := debuggerABIRootArgs(test.ctx) + if !slices.Equal(got, test.want) { + t.Fatalf("debuggerABIRootArgs = %v, want %v", got, test.want) + } + }) + } +} + func TestDefaultBuildTags(t *testing.T) { const base = "llgo,math_big_pure_go,purego" for _, test := range []struct { diff --git a/internal/debuginfo/builder.go b/internal/debuginfo/builder.go index ad0c83f1da..4ebc44b5b9 100644 --- a/internal/debuginfo/builder.go +++ b/internal/debuginfo/builder.go @@ -90,6 +90,10 @@ func (b *Builder) addDebuggerMarkers() { record.SetGlobalConstant(true) record.SetLinkage(llvm.LinkOnceODRLinkage) record.SetVisibility(llvm.HiddenVisibility) + // Bare-metal linker scripts commonly fold read-only constants into a + // Thumb .text output section. Keep the record address even so GDB does + // not clear bit zero as if this object symbol were a Thumb function. + record.SetAlignment(len(raw)) retained = append(retained, llvm.ConstBitCast(record, ptr)) } usedInit := llvm.ConstArray(ptr, retained)