From 5b3dbe418d86d305c6982b1663a78542a7606eb4 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sat, 1 Aug 2026 22:01:10 +0800 Subject: [PATCH 1/2] debug: let supported targets retain DWARF --- internal/build/build.go | 4 +- internal/build/collect_test.go | 4 +- internal/build/link_options.go | 22 ++-- internal/build/link_options_test.go | 113 +++++++++++++++++++-- internal/crosscompile/crosscompile.go | 48 +++++++-- internal/crosscompile/crosscompile_test.go | 43 ++++++-- 6 files changed, 201 insertions(+), 33 deletions(-) diff --git a/internal/build/build.go b/internal/build/build.go index 9607c9d00a..53c426972e 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -1534,9 +1534,7 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose buildArgs = append(buildArgs, linuxExportDynamicArgs(ctx)...) } - if shouldEmitDebugInfo(ctx.buildConf, &ctx.crossCompile) { - buildArgs = append(buildArgs, "-gdwarf-4") - } + buildArgs = append(buildArgs, dwarfPreserveLinkerArgs(ctx.buildConf, &ctx.crossCompile)...) if ctx.buildConf.GenLL { var compiledObjFiles []string diff --git a/internal/build/collect_test.go b/internal/build/collect_test.go index d6a8c585d5..94b2601c57 100644 --- a/internal/build/collect_test.go +++ b/internal/build/collect_test.go @@ -186,7 +186,7 @@ func TestCollectFingerprintIncludesEmitDWARF(t *testing.T) { } targetWithoutDWARF := newPkg() - if err := newContext(LinkOptions{}, crosscompile.DebugInfoPolicy{AlwaysOmit: true}).collectFingerprint(targetWithoutDWARF); err != nil { + if err := newContext(LinkOptions{}, crosscompile.DebugInfoPolicy{Capability: crosscompile.DebugInfoUnavailable}).collectFingerprint(targetWithoutDWARF); err != nil { t.Fatal(err) } if withDWARF.Fingerprint == targetWithoutDWARF.Fingerprint { @@ -197,7 +197,7 @@ func TestCollectFingerprintIncludesEmitDWARF(t *testing.T) { t.Fatal(err) } if targetData.Common != nil && targetData.Common.EmitDWARF { - t.Fatalf("always-omit target manifest unexpectedly contains EMIT_DWARF=true:\n%s", targetWithoutDWARF.Manifest) + t.Fatalf("target without DWARF support unexpectedly contains EMIT_DWARF=true:\n%s", targetWithoutDWARF.Manifest) } } diff --git a/internal/build/link_options.go b/internal/build/link_options.go index 1a7c0140d8..8d1370920a 100644 --- a/internal/build/link_options.go +++ b/internal/build/link_options.go @@ -76,10 +76,10 @@ func omitDWARFRequested(conf *Config) bool { } // effectiveOmitDWARF combines command intent with the selected toolchain's -// baseline behavior. Some fixed-target linkers always omit DWARF, so LLGo -// should avoid generating debug metadata that cannot reach the artifact. +// capability. LLGo avoids generating debug metadata when the linked output +// format cannot retain it. func effectiveOmitDWARF(conf *Config, target *crosscompile.Export) bool { - return omitDWARFRequested(conf) || target.DebugInfo.AlwaysOmit + return omitDWARFRequested(conf) || !target.DebugInfo.CanRetain() } // shouldEmitDebugInfo reports whether this compilation should produce DWARF. @@ -100,13 +100,13 @@ func validateLinkOptions(conf *Config, target *crosscompile.Export) error { if err := conf.LinkOptions.validate(); err != nil { return err } - if conf.LinkOptions.DWARF == DWARFPreserve && target.DebugInfo.AlwaysOmit { + if conf.LinkOptions.DWARF == DWARFPreserve && !target.DebugInfo.CanRetain() { return fmt.Errorf("preserving DWARF is not supported by the selected target linker") } if !omitDWARFRequested(conf) { return nil } - if target.DebugInfo.AlwaysOmit { + if !target.DebugInfo.CanRetain() { return nil } if len(target.DebugInfo.OmitLinkFlags) == 0 { @@ -120,7 +120,7 @@ func validateLinkOptions(conf *Config, target *crosscompile.Export) error { // earlier; this handles debug sections in native and prebuilt inputs without // rewriting the linked binary afterward. func dwarfLinkerArgs(conf *Config, target *crosscompile.Export) []string { - if target.DebugInfo.AlwaysOmit || !effectiveOmitDWARF(conf, target) { + if !target.DebugInfo.CanRetain() || !effectiveOmitDWARF(conf, target) { return nil } // c-archive has no final native link step. Omitting generated DWARF is @@ -130,3 +130,13 @@ func dwarfLinkerArgs(conf *Config, target *crosscompile.Export) []string { } return slices.Clone(target.DebugInfo.OmitLinkFlags) } + +// dwarfPreserveLinkerArgs returns compiler-driver options needed while linking +// a debug artifact. Direct linkers such as ld.lld retain input DWARF without a +// corresponding flag, while clang-compatible drivers accept -gdwarf-4. +func dwarfPreserveLinkerArgs(conf *Config, target *crosscompile.Export) []string { + if !shouldEmitDebugInfo(conf, target) { + return nil + } + return slices.Clone(target.DebugInfo.PreserveLinkFlags) +} diff --git a/internal/build/link_options_test.go b/internal/build/link_options_test.go index b903487dfe..d5ec1254b1 100644 --- a/internal/build/link_options_test.go +++ b/internal/build/link_options_test.go @@ -19,6 +19,7 @@ package build import ( + "bytes" "debug/elf" "os" "os/exec" @@ -29,6 +30,7 @@ import ( "testing" "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/firmware" "github.com/goplus/llgo/xtool/env/llvm" ) @@ -48,7 +50,9 @@ func TestDwarfLinkerArgs(t *testing.T) { {name: "w", conf: Config{LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: configurableDebugInfo(), want: []string{"-Wl,-S"}}, {name: "s implies w", conf: Config{LinkOptions: LinkOptions{OmitSymbolTable: true}}, target: configurableDebugInfo(), want: []string{"-Wl,-S"}}, {name: "explicit w false", conf: Config{LinkOptions: LinkOptions{OmitSymbolTable: true, DWARF: DWARFPreserve}}}, - {name: "target linker already suppresses DWARF", conf: Config{Target: "rp2040", LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: alwaysOmitDebugInfo()}, + {name: "fixed target w", conf: Config{Target: "rp2040", BuildMode: BuildModeExe, LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: targetDebugInfo(), want: []string{"-S"}}, + {name: "fixed target w false", conf: Config{Target: "rp2040", BuildMode: BuildModeExe, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: targetDebugInfo()}, + {name: "target without DWARF support", conf: Config{Target: "rp2040", LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: unavailableDebugInfo()}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -59,6 +63,27 @@ func TestDwarfLinkerArgs(t *testing.T) { } } +func TestDwarfPreserveLinkerArgs(t *testing.T) { + tests := []struct { + name string + conf Config + target crosscompile.Export + want []string + }{ + {name: "native preserve", conf: Config{Mode: ModeBuild, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: configurableDebugInfo(), want: []string{"-gdwarf-4"}}, + {name: "native omit", conf: Config{Mode: ModeBuild, LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: configurableDebugInfo()}, + {name: "direct target preserve", conf: Config{Mode: ModeBuild, Target: "rp2040", LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: targetDebugInfo()}, + {name: "unsupported target", conf: Config{Mode: ModeBuild, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: unavailableDebugInfo()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dwarfPreserveLinkerArgs(&tt.conf, &tt.target); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("dwarfPreserveLinkerArgs() = %v, want %v", got, tt.want) + } + }) + } +} + func TestEffectiveOmitDWARF(t *testing.T) { tests := []struct { name string @@ -71,7 +96,7 @@ func TestEffectiveOmitDWARF(t *testing.T) { {name: "safe default c-shared", conf: Config{BuildMode: BuildModeCShared, OmitDWARFByDefault: true}, want: true}, {name: "safe default c-archive", conf: Config{BuildMode: BuildModeCArchive, OmitDWARFByDefault: true}, want: true}, {name: "requested", conf: Config{LinkOptions: LinkOptions{DWARF: DWARFOmit}}, want: true}, - {name: "target baseline", target: alwaysOmitDebugInfo(), want: true}, + {name: "target baseline", target: unavailableDebugInfo(), want: true}, {name: "explicit preserve", conf: Config{LinkOptions: LinkOptions{DWARF: DWARFPreserve}}}, {name: "explicit preserve overrides safe default", conf: Config{OmitDWARFByDefault: true, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}}, } @@ -100,7 +125,7 @@ func TestShouldEmitDebugInfo(t *testing.T) { {name: "linked s w false", conf: Config{Mode: ModeBuild, LinkOptions: LinkOptions{OmitSymbolTable: true, DWARF: DWARFPreserve}}, want: true}, {name: "generation default", conf: Config{Mode: ModeGen}}, {name: "generation requested", conf: Config{Mode: ModeGen, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, want: true}, - {name: "target always omits", conf: Config{Mode: ModeBuild}, target: alwaysOmitDebugInfo()}, + {name: "target without DWARF support", conf: Config{Mode: ModeBuild}, target: unavailableDebugInfo()}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -130,8 +155,10 @@ func TestValidateLinkOptions(t *testing.T) { {name: "c-archive omit", conf: Config{Goos: "linux", BuildMode: BuildModeCArchive, LinkOptions: w}, target: configurableDebugInfo()}, {name: "c-shared preserve", conf: Config{Goos: "linux", BuildMode: BuildModeCShared, LinkOptions: wFalse}, target: configurableDebugInfo()}, {name: "c-archive preserve", conf: Config{Goos: "linux", BuildMode: BuildModeCArchive, LinkOptions: wFalse}, target: configurableDebugInfo()}, - {name: "fixed target omit", conf: Config{Target: "rp2040", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: w}, target: alwaysOmitDebugInfo()}, - {name: "fixed target explicit DWARF", conf: Config{Target: "rp2040", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: wFalse}, target: alwaysOmitDebugInfo(), wantErr: true}, + {name: "target without DWARF support omit", conf: Config{Target: "custom", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: w}, target: unavailableDebugInfo()}, + {name: "target without DWARF support preserve", conf: Config{Target: "custom", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: wFalse}, target: unavailableDebugInfo(), wantErr: true}, + {name: "fixed target omit", conf: Config{Target: "rp2040", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: w}, target: targetDebugInfo(), wantErr: false}, + {name: "fixed target preserve", conf: Config{Target: "rp2040", Goos: "linux", BuildMode: BuildModeExe, LinkOptions: wFalse}, target: targetDebugInfo()}, {name: "configurable WASI omit", conf: Config{Target: "wasi", Goos: "wasip1", BuildMode: BuildModeExe, LinkOptions: w}, target: configurableDebugInfo()}, {name: "configurable WASI preserve", conf: Config{Target: "wasi", Goos: "wasip1", BuildMode: BuildModeExe, LinkOptions: wFalse}, target: configurableDebugInfo()}, {name: "no omission", conf: Config{Goos: "windows", BuildMode: BuildModeExe, LinkOptions: wFalse}}, @@ -182,12 +209,82 @@ func TestDwarfLinkerArgsSuppressNativeInputDWARF(t *testing.T) { } } +func TestTargetDWARFDoesNotChangeLoadableELF(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "main.c") + object := filepath.Join(dir, "main.o") + debugELF := filepath.Join(dir, "debug.elf") + strippedELF := filepath.Join(dir, "stripped.elf") + debugBin := filepath.Join(dir, "debug.bin") + strippedBin := filepath.Join(dir, "stripped.bin") + if err := os.WriteFile(source, []byte("volatile int value = 41; void Reset_Handler(void) { value++; for (;;) {} }\n"), 0o644); err != nil { + t.Fatal(err) + } + clang, err := exec.LookPath("clang") + if err != nil { + t.Fatal(err) + } + if out, err := exec.Command(clang, "--target=thumbv7em-none-unknown-eabi", "-g", "-ffreestanding", "-fno-unwind-tables", "-fno-asynchronous-unwind-tables", "-c", "-o", object, source).CombinedOutput(); err != nil { + t.Fatalf("compile Cortex-M DWARF fixture: %v\n%s", err, out) + } + linker, err := exec.LookPath("ld.lld") + if err != nil { + t.Fatal(err) + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + linkerScript := filepath.Join(repoRoot, "targets", "lm3s6965.ld") + link := func(path string, opts LinkOptions) { + t.Helper() + conf := &Config{Target: "cortex-m", BuildMode: BuildModeExe, LinkOptions: opts} + target := targetDebugInfo() + args := append(dwarfLinkerArgs(conf, &target), "-T", linkerScript, "-L", repoRoot, "-o", path, object) + if out, err := exec.Command(linker, args...).CombinedOutput(); err != nil { + t.Fatalf("link Cortex-M fixture: %v\n%s", err, out) + } + } + link(debugELF, LinkOptions{DWARF: DWARFPreserve}) + link(strippedELF, LinkOptions{DWARF: DWARFOmit}) + if !elfHasDebugInfo(t, debugELF) { + t.Fatal("preserved Cortex-M ELF has no debug information") + } + if elfHasDebugInfo(t, strippedELF) { + t.Fatal("omitted Cortex-M ELF still has debug information") + } + if err := firmware.ConvertFormats("", "", map[string]string{"out": debugELF, "bin": debugBin}); err != nil { + t.Fatal(err) + } + if err := firmware.ConvertFormats("", "", map[string]string{"out": strippedELF, "bin": strippedBin}); err != nil { + t.Fatal(err) + } + debugImage, err := os.ReadFile(debugBin) + if err != nil { + t.Fatal(err) + } + strippedImage, err := os.ReadFile(strippedBin) + if err != nil { + t.Fatal(err) + } + if len(debugImage) == 0 || !bytes.Equal(debugImage, strippedImage) { + t.Fatal("DWARF changed Cortex-M flashed bytes") + } +} + func configurableDebugInfo() crosscompile.Export { - return crosscompile.Export{DebugInfo: crosscompile.DebugInfoPolicy{OmitLinkFlags: []string{"-Wl,-S"}}} + return crosscompile.Export{DebugInfo: crosscompile.DebugInfoPolicy{ + PreserveLinkFlags: []string{"-gdwarf-4"}, + OmitLinkFlags: []string{"-Wl,-S"}, + }} +} + +func targetDebugInfo() crosscompile.Export { + return crosscompile.Export{DebugInfo: crosscompile.DebugInfoPolicy{OmitLinkFlags: []string{"-S"}}} } -func alwaysOmitDebugInfo() crosscompile.Export { - return crosscompile.Export{DebugInfo: crosscompile.DebugInfoPolicy{AlwaysOmit: true}} +func unavailableDebugInfo() crosscompile.Export { + return crosscompile.Export{DebugInfo: crosscompile.DebugInfoPolicy{Capability: crosscompile.DebugInfoUnavailable}} } func elfHasDebugInfo(t *testing.T, path string) bool { diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index c017c7e5f4..46916a96dc 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -47,21 +47,50 @@ type Export struct { Device flash.Device // Device configuration for flashing/debugging } +// DebugInfoCapability describes whether the selected linker and its linked +// output format can retain DWARF. Deployment formats such as bin, hex, and uf2 +// are derived later and do not affect this capability. +type DebugInfoCapability uint8 + +const ( + DebugInfoRetainable DebugInfoCapability = iota + DebugInfoUnavailable +) + // DebugInfoPolicy describes how a selected linker handles debug information. // Build orchestration consumes this typed capability instead of inferring it // from a target name or linker executable. type DebugInfoPolicy struct { - AlwaysOmit bool - OmitLinkFlags []string + Capability DebugInfoCapability + PreserveLinkFlags []string + OmitLinkFlags []string +} + +func (p DebugInfoPolicy) CanRetain() bool { + return p.Capability == DebugInfoRetainable } func nativeDebugInfoPolicy(goos string) DebugInfoPolicy { + policy := DebugInfoPolicy{PreserveLinkFlags: []string{"-gdwarf-4"}} switch goos { case "darwin", "linux": - return DebugInfoPolicy{OmitLinkFlags: []string{"-Wl,-S"}} - default: - return DebugInfoPolicy{} + policy.OmitLinkFlags = []string{"-Wl,-S"} } + return policy +} + +func targetDebugInfoPolicy(linker, llvmTarget string) DebugInfoPolicy { + switch linker { + case "ld.lld": + if !strings.HasPrefix(llvmTarget, "wasm") { + return DebugInfoPolicy{OmitLinkFlags: []string{"-S"}} + } + case "wasm-ld": + if strings.HasPrefix(llvmTarget, "wasm") { + return DebugInfoPolicy{OmitLinkFlags: []string{"-S"}} + } + } + return DebugInfoPolicy{Capability: DebugInfoUnavailable} } // URLs and configuration that can be overridden for testing @@ -333,7 +362,10 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le if goarch != "wasm" { return } - export.DebugInfo.OmitLinkFlags = []string{"-Wl,-S"} + export.DebugInfo = DebugInfoPolicy{ + PreserveLinkFlags: []string{"-gdwarf-4"}, + OmitLinkFlags: []string{"-Wl,-S"}, + } // Configure based on GOOS switch goos { @@ -504,7 +536,7 @@ func UseTarget(targetName string, level optlevel.Level, ltoMode lto.Mode) (expor export.BinaryFormat = config.BinaryFormat export.FormatDetail = config.FormatDetail() export.Emulator = config.Emulator - export.DebugInfo.AlwaysOmit = true + export.DebugInfo = targetDebugInfoPolicy(config.Linker, config.LLVMTarget) // Set flashing/debugging configuration export.Device = flash.Device{ @@ -531,7 +563,7 @@ func UseTarget(targetName string, level optlevel.Level, ltoMode lto.Mode) (expor // Convert LLVMTarget, CPU, Features to CCFLAGS/LDFLAGS // ICF off for Go pc-identity semantics (see the non-cross flags above). - ldflags := []string{"-S", "--icf=none"} + ldflags := []string{"--icf=none"} ccflags := []string{level.Flag()} cflags := []string{"-Wno-override-module", "-Qunused-arguments", "-Wno-unused-command-line-argument"} if config.LLVMTarget != "" { diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index ea89a9596a..7bbe5c5628 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -248,11 +248,14 @@ func TestUseTarget(t *testing.T) { if err != nil { t.Fatalf("Unexpected error for target %s: %v", tc.targetName, err) } - if !export.DebugInfo.AlwaysOmit { - t.Fatalf("target %s debug-info policy = %+v, want AlwaysOmit", tc.targetName, export.DebugInfo) + if !export.DebugInfo.CanRetain() { + t.Fatalf("target %s debug-info policy = %+v, want retainable ELF DWARF", tc.targetName, export.DebugInfo) } - if !slices.Contains(export.LDFLAGS, "-S") { - t.Fatalf("target %s declares AlwaysOmit without linker -S: %v", tc.targetName, export.LDFLAGS) + if slices.Contains(export.LDFLAGS, "-S") { + t.Fatalf("target %s unconditionally strips DWARF: %v", tc.targetName, export.LDFLAGS) + } + if !slices.Equal(export.DebugInfo.OmitLinkFlags, []string{"-S"}) { + t.Fatalf("target %s debug omission flags = %v, want [-S]", tc.targetName, export.DebugInfo.OmitLinkFlags) } // Check if LLVM target is in CCFLAGS @@ -344,7 +347,9 @@ func TestUseWithTarget(t *testing.T) { t.Error("Expected LDFLAGS to be set for native build") } wantDebugInfo := nativeDebugInfoPolicy(runtime.GOOS) - if export.DebugInfo.AlwaysOmit != wantDebugInfo.AlwaysOmit || !slices.Equal(export.DebugInfo.OmitLinkFlags, wantDebugInfo.OmitLinkFlags) { + if export.DebugInfo.Capability != wantDebugInfo.Capability || + !slices.Equal(export.DebugInfo.PreserveLinkFlags, wantDebugInfo.PreserveLinkFlags) || + !slices.Equal(export.DebugInfo.OmitLinkFlags, wantDebugInfo.OmitLinkFlags) { t.Fatalf("native debug-info policy = %+v, want %+v", export.DebugInfo, wantDebugInfo) } } @@ -362,7 +367,9 @@ func TestNativeDebugInfoPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.goos, func(t *testing.T) { policy := nativeDebugInfoPolicy(tt.goos) - got := !policy.AlwaysOmit && slices.Equal(policy.OmitLinkFlags, []string{"-Wl,-S"}) + got := policy.CanRetain() && + slices.Equal(policy.PreserveLinkFlags, []string{"-gdwarf-4"}) && + slices.Equal(policy.OmitLinkFlags, []string{"-Wl,-S"}) if got != tt.supported { t.Fatalf("nativeDebugInfoPolicy(%q) = %+v, supported = %v", tt.goos, policy, got) } @@ -370,6 +377,30 @@ func TestNativeDebugInfoPolicy(t *testing.T) { } } +func TestTargetDebugInfoPolicy(t *testing.T) { + tests := []struct { + name string + linker string + target string + canRetain bool + omitFlags []string + }{ + {name: "ELF lld", linker: "ld.lld", target: "thumbv7em-none-unknown-eabi", canRetain: true, omitFlags: []string{"-S"}}, + {name: "Wasm lld", linker: "wasm-ld", target: "wasm32-unknown-unknown", canRetain: true, omitFlags: []string{"-S"}}, + {name: "mismatched ELF linker", linker: "ld.lld", target: "wasm32-unknown-unknown"}, + {name: "mismatched Wasm linker", linker: "wasm-ld", target: "thumbv7em-none-unknown-eabi"}, + {name: "unknown linker", linker: "custom-ld", target: "thumbv7em-none-unknown-eabi"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := targetDebugInfoPolicy(tt.linker, tt.target) + if got.CanRetain() != tt.canRetain || !slices.Equal(got.OmitLinkFlags, tt.omitFlags) { + t.Fatalf("targetDebugInfoPolicy(%q, %q) = %+v, want retain=%v flags=%v", tt.linker, tt.target, got, tt.canRetain, tt.omitFlags) + } + }) + } +} + func TestOptimizationFlagPlacement(t *testing.T) { export, err := UseTarget("rp2040", optlevel.Oz, lto.Off) if err != nil { From dde8ca459c1cf66561ac3a8834da055a681212fc Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sat, 1 Aug 2026 23:02:37 +0800 Subject: [PATCH 2/2] debug: add typed artifact modes and Wasm sidecars --- .github/workflows/llgo.yml | 16 ++ cmd/internal/flags/debug_artifact.go | 59 +++++ cmd/internal/flags/flags.go | 5 + cmd/internal/flags/flags_test.go | 46 ++++ internal/build/build.go | 29 ++- internal/build/clean.go | 1 + internal/build/clean_test.go | 6 +- internal/build/debug_artifact.go | 142 ++++++++++++ internal/build/debug_artifact_external.go | 99 +++++++++ .../build/debug_artifact_external_test.go | 95 ++++++++ internal/build/debug_artifact_test.go | 85 +++++++ internal/build/invocation.go | 1 + internal/build/outputs.go | 13 ++ internal/build/outputs_test.go | 23 ++ internal/wasmdebug/wasmdebug.go | 210 ++++++++++++++++++ internal/wasmdebug/wasmdebug_test.go | 145 ++++++++++++ 16 files changed, 963 insertions(+), 12 deletions(-) create mode 100644 cmd/internal/flags/debug_artifact.go create mode 100644 internal/build/debug_artifact.go create mode 100644 internal/build/debug_artifact_external.go create mode 100644 internal/build/debug_artifact_external_test.go create mode 100644 internal/build/debug_artifact_test.go create mode 100644 internal/wasmdebug/wasmdebug.go create mode 100644 internal/wasmdebug/wasmdebug_test.go diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index 40eff89107..346ba76e99 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -393,3 +393,19 @@ jobs: GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" + + - name: Validate embedded and external Wasm DWARF + if: matrix.go == '1.26.5' + shell: bash + run: | + GOOS=wasip1 GOARCH=wasm llgo build -debug-artifact=embedded -o "$RUNNER_TEMP/runtime-debug.wasm" ./internal/build/testdata/wasm-runtime + llvm-objdump -h "$RUNNER_TEMP/runtime-debug.wasm" > "$RUNNER_TEMP/runtime-debug.sections" + grep -q '\.debug_info' "$RUNNER_TEMP/runtime-debug.sections" + + GOOS=wasip1 GOARCH=wasm llgo build -debug-artifact=external -o "$RUNNER_TEMP/runtime-external.wasm" ./internal/build/testdata/wasm-runtime + test -s "$RUNNER_TEMP/runtime-external.debug.wasm" + llvm-objdump -h "$RUNNER_TEMP/runtime-external.wasm" > "$RUNNER_TEMP/runtime-external.sections" + llvm-objdump -h "$RUNNER_TEMP/runtime-external.debug.wasm" > "$RUNNER_TEMP/runtime-external-debug.sections" + grep -q 'external_debug_info' "$RUNNER_TEMP/runtime-external.sections" + ! grep -q '\.debug_info' "$RUNNER_TEMP/runtime-external.sections" + grep -q '\.debug_info' "$RUNNER_TEMP/runtime-external-debug.sections" diff --git a/cmd/internal/flags/debug_artifact.go b/cmd/internal/flags/debug_artifact.go new file mode 100644 index 0000000000..804d5ab25e --- /dev/null +++ b/cmd/internal/flags/debug_artifact.go @@ -0,0 +1,59 @@ +/* + * 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 flags + +import ( + "flag" + "fmt" + + "github.com/goplus/llgo/internal/build" +) + +type debugArtifactFlag struct { + Specified bool + Mode build.DebugArtifactMode +} + +func (f *debugArtifactFlag) String() string { + return f.Mode.String() +} + +func (f *debugArtifactFlag) Set(value string) error { + var mode build.DebugArtifactMode + switch value { + case "embedded": + mode = build.DebugArtifactEmbedded + case "external": + mode = build.DebugArtifactExternal + case "host": + mode = build.DebugArtifactHost + case "none": + mode = build.DebugArtifactNone + default: + return fmt.Errorf("invalid debug artifact mode %q (valid: embedded, external, host, none)", value) + } + f.Specified = true + f.Mode = mode + return nil +} + +var DebugArtifact debugArtifactFlag + +func addDebugArtifactFlag(fs *flag.FlagSet) { + DebugArtifact = debugArtifactFlag{Mode: build.DebugArtifactDefault} + fs.Var(&DebugArtifact, "debug-artifact", "DWARF artifact mode: embedded, external, host, or none") +} diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 2d9a72d1f7..b24caefefa 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -220,6 +220,7 @@ func AddBuildFlags(fs *flag.FlagSet) { AddLTOFlag(fs) AddGlobalDCEFlag(fs) addPCLNFlag(fs) + addDebugArtifactFlag(fs) fs.StringVar(&Tags, "tags", "", "Build tags") fs.StringVar(&BuildEnv, "buildenv", "", "Build environment") fs.Var(&PthreadStackSize, "pthread-stack-size", "Stack size for pthread-backed goroutines, e.g. 32MB or 1024KB (0 uses the platform default)") @@ -372,6 +373,10 @@ func UpdateConfig(conf *build.Config) error { conf.PCLNMode = PCLN.Mode conf.PCLNModeSet = true } + if DebugArtifact.Specified { + conf.DebugArtifactMode = DebugArtifact.Mode + conf.DebugArtifactModeSet = true + } if LTOPluginPath != "" { if conf.LTO != lto.Full { return fmt.Errorf("lto pass plugin can only be enabled with full LTO (-lto=full)") diff --git a/cmd/internal/flags/flags_test.go b/cmd/internal/flags/flags_test.go index 53a7818bb9..ee9535250c 100644 --- a/cmd/internal/flags/flags_test.go +++ b/cmd/internal/flags/flags_test.go @@ -245,6 +245,52 @@ func TestBuildPCLNFlagInvalid(t *testing.T) { } } +func TestBuildDebugArtifactFlags(t *testing.T) { + tests := []struct { + name string + args []string + want build.DebugArtifactMode + specified bool + }{ + {name: "default", want: build.DebugArtifactDefault}, + {name: "embedded", args: []string{"-debug-artifact=embedded"}, want: build.DebugArtifactEmbedded, specified: true}, + {name: "external", args: []string{"-debug-artifact=external"}, want: build.DebugArtifactExternal, specified: true}, + {name: "host", args: []string{"-debug-artifact=host"}, want: build.DebugArtifactHost, specified: true}, + {name: "none", args: []string{"-debug-artifact=none"}, want: build.DebugArtifactNone, specified: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet(tt.name, flag.ContinueOnError) + fs.SetOutput(new(bytes.Buffer)) + AddBuildFlags(fs) + if err := fs.Parse(tt.args); err != nil { + t.Fatal(err) + } + if DebugArtifact.Specified != tt.specified || DebugArtifact.Mode != tt.want { + t.Fatalf("DebugArtifact = %+v, want mode=%v specified=%v", DebugArtifact, tt.want, tt.specified) + } + conf := &build.Config{} + if err := UpdateConfig(conf); err != nil { + t.Fatal(err) + } + if conf.DebugArtifactMode != tt.want || conf.DebugArtifactModeSet != tt.specified { + t.Fatalf("Config debug artifact = %v/%v, want %v/%v", conf.DebugArtifactMode, conf.DebugArtifactModeSet, tt.want, tt.specified) + } + }) + } +} + +func TestBuildDebugArtifactFlagInvalid(t *testing.T) { + for _, args := range [][]string{{"-debug-artifact"}, {"-debug-artifact="}, {"-debug-artifact=default"}, {"-debug-artifact=EXTERNAL"}} { + fs := flag.NewFlagSet("invalid-debug-artifact", flag.ContinueOnError) + fs.SetOutput(new(bytes.Buffer)) + AddBuildFlags(fs) + if err := fs.Parse(args); err == nil { + t.Fatalf("Parse(%v) succeeded", args) + } + } +} + func TestUpdateConfigPreservesPCLNModeWhenUnspecified(t *testing.T) { fs := flag.NewFlagSet("pclntab-unspecified", flag.ContinueOnError) fs.SetOutput(new(bytes.Buffer)) diff --git a/internal/build/build.go b/internal/build/build.go index 53c426972e..361cd57b97 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -115,13 +115,14 @@ type OutFmts struct { // OutFmtDetails contains detailed output file paths for each format type OutFmtDetails struct { - Out string // Base output file path - PCLN string // PCLN sidecar output file path (.pclntab) - Bin string // Binary output file path (.bin) - Hex string // Intel hex output file path (.hex) - Img string // Image output file path (.img) - Uf2 string // UF2 output file path (.uf2) - Zip string // ZIP/DFU output file path (.zip) + Out string // Base output file path + PCLN string // PCLN sidecar output file path (.pclntab) + DWARF string // External debugger-owned DWARF container (.debug.wasm) + Bin string // Binary output file path (.bin) + Hex string // Intel hex output file path (.hex) + Img string // Image output file path (.img) + Uf2 string // UF2 output file path (.uf2) + Zip string // ZIP/DFU output file path (.zip) } // ModuleHook observes a package module immediately after it is generated and @@ -174,8 +175,12 @@ type Config struct { GoBuildFlags []string // BuildParallelism is the package-level concurrency requested by Go's -p // build flag for llgo test. Zero uses the Go default, GOMAXPROCS. - BuildParallelism int - LinkOptions LinkOptions + BuildParallelism int + LinkOptions LinkOptions + DebugArtifactMode DebugArtifactMode + // DebugArtifactModeSet distinguishes an explicit command request from the + // effective mode derived from -w and the current build default. + DebugArtifactModeSet bool // OmitDWARFByDefault controls linked builds only when -w was not // explicitly specified. Explicit -w and -w=false always win. OmitDWARFByDefault bool @@ -392,6 +397,9 @@ func Build(inv Invocation) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + if err := resolveDebugArtifactMode(conf, &export); err != nil { + return nil, err + } if err := validateLinkOptions(conf, &export); err != nil { return nil, err } @@ -663,6 +671,9 @@ func Build(inv Invocation) ([]Package, error) { if err := finalizeRuntimePCLN(ctx, outFmts, verbose); err != nil { return nil, err } + if err := finalizeDebugArtifact(conf, outFmts, verbose); err != nil { + return nil, err + } if conf.Mode == ModeBuild && conf.SizeReport { if err := reportBinarySize(outFmts.Out, conf.SizeFormat, conf.SizeLevel, allPkgs); err != nil { fmt.Fprintf(os.Stderr, "Warning: size report failed: %v\n", err) diff --git a/internal/build/clean.go b/internal/build/clean.go index 7523ab7a93..71d46a50ac 100644 --- a/internal/build/clean.go +++ b/internal/build/clean.go @@ -82,6 +82,7 @@ func cleanMainPkg(pkg *packages.Package, conf *Config, verbose bool) { func removeExecutableArtifacts(executable string, verbose bool) { removeFile(executable, verbose) removeFile(pclnSidecarPath(executable), verbose) + removeFile(dwarfSidecarPath(executable), verbose) } func cleanPkgs(initial []*packages.Package, verbose bool) { diff --git a/internal/build/clean_test.go b/internal/build/clean_test.go index ed9bd49b42..4fa482bd16 100644 --- a/internal/build/clean_test.go +++ b/internal/build/clean_test.go @@ -10,7 +10,7 @@ import ( "github.com/goplus/llgo/internal/packages" ) -func TestCleanMainPkgRemovesPCLNSidecars(t *testing.T) { +func TestCleanMainPkgRemovesSidecars(t *testing.T) { root := t.TempDir() binDir := filepath.Join(root, "bin") sourceDir := filepath.Join(root, "source") @@ -30,7 +30,7 @@ func TestCleanMainPkgRemovesPCLNSidecars(t *testing.T) { filepath.Join(sourceDir, "demo.exe"), } for _, executable := range outputs { - for _, artifact := range []string{executable, pclnSidecarPath(executable)} { + for _, artifact := range []string{executable, pclnSidecarPath(executable), dwarfSidecarPath(executable)} { if err := os.WriteFile(artifact, []byte("artifact"), 0o644); err != nil { t.Fatal(err) } @@ -44,7 +44,7 @@ func TestCleanMainPkgRemovesPCLNSidecars(t *testing.T) { cleanMainPkg(pkg, conf, false) for _, executable := range outputs { - for _, artifact := range []string{executable, pclnSidecarPath(executable)} { + for _, artifact := range []string{executable, pclnSidecarPath(executable), dwarfSidecarPath(executable)} { if _, err := os.Stat(artifact); !os.IsNotExist(err) { t.Errorf("artifact %q still exists (stat error %v)", artifact, err) } diff --git a/internal/build/debug_artifact.go b/internal/build/debug_artifact.go new file mode 100644 index 0000000000..e52c9ff649 --- /dev/null +++ b/internal/build/debug_artifact.go @@ -0,0 +1,142 @@ +/* + * 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 build + +import ( + "fmt" + "strings" + + "github.com/goplus/llgo/internal/crosscompile" +) + +// DebugArtifactMode controls where debugger-owned DWARF is packaged. It is +// independent of runtime pclntab packaging. +type DebugArtifactMode uint8 + +const ( + // DebugArtifactDefault derives the effective mode from -w and the current + // build default. It is valid only before build configuration is resolved. + DebugArtifactDefault DebugArtifactMode = iota + // DebugArtifactEmbedded retains DWARF in the executable or Wasm module. + DebugArtifactEmbedded + // DebugArtifactExternal writes a debugger-owned sidecar referenced by the + // executable or module. + DebugArtifactExternal + // DebugArtifactHost retains a full host-side debug executable and derives a + // separate deployment image from it. + DebugArtifactHost + // DebugArtifactNone omits LLGo DWARF, equivalent to an effective -w. + DebugArtifactNone +) + +func (m DebugArtifactMode) String() string { + switch m { + case DebugArtifactDefault: + return "default" + case DebugArtifactEmbedded: + return "embedded" + case DebugArtifactExternal: + return "external" + case DebugArtifactHost: + return "host" + case DebugArtifactNone: + return "none" + default: + return fmt.Sprintf("DebugArtifactMode(%d)", uint8(m)) + } +} + +// IsValid reports whether m is a recognized debug-artifact mode. +func (m DebugArtifactMode) IsValid() bool { + switch m { + case DebugArtifactDefault, DebugArtifactEmbedded, DebugArtifactExternal, DebugArtifactHost, DebugArtifactNone: + return true + default: + return false + } +} + +func isWasmDebugTarget(conf *Config, target *crosscompile.Export) bool { + return conf.Goarch == "wasm" || strings.HasPrefix(target.LLVMTarget, "wasm") +} + +// resolveDebugArtifactMode validates an explicit artifact request, translates +// it into typed -w intent, and records the effective packaging mode. The +// existing safe DWARF default remains authoritative when no mode was supplied; +// restoring Go's default is owned by the optimized-DWARF dependency chain. +func resolveDebugArtifactMode(conf *Config, target *crosscompile.Export) error { + if !conf.DebugArtifactMode.IsValid() { + return fmt.Errorf("invalid debug artifact mode %d", conf.DebugArtifactMode) + } + if conf.DebugArtifactModeSet && conf.DebugArtifactMode == DebugArtifactDefault { + return fmt.Errorf("debug artifact mode default cannot be selected explicitly") + } + + if conf.DebugArtifactModeSet { + switch conf.DebugArtifactMode { + case DebugArtifactNone: + if conf.LinkOptions.DWARF == DWARFPreserve { + return fmt.Errorf("debug artifact mode none conflicts with -w=false") + } + conf.LinkOptions.DWARF = DWARFOmit + case DebugArtifactExternal: + if !isWasmDebugTarget(conf, target) { + return fmt.Errorf("external debug artifacts are currently supported only for WebAssembly") + } + if conf.Mode == ModeGen || conf.BuildMode != BuildModeExe { + return fmt.Errorf("external debug artifacts require an executable build") + } + if conf.LinkOptions.DWARF == DWARFOmit { + return fmt.Errorf("debug artifact mode external conflicts with -w") + } + conf.LinkOptions.DWARF = DWARFPreserve + case DebugArtifactHost: + if conf.Target == "" || isWasmDebugTarget(conf, target) { + return fmt.Errorf("host debug artifacts require a non-WebAssembly target build") + } + if conf.Mode == ModeGen || conf.BuildMode != BuildModeExe { + return fmt.Errorf("host debug artifacts require an executable build") + } + if conf.LinkOptions.DWARF == DWARFOmit { + return fmt.Errorf("debug artifact mode host conflicts with -w") + } + conf.LinkOptions.DWARF = DWARFPreserve + case DebugArtifactEmbedded: + if conf.Mode == ModeGen { + return fmt.Errorf("embedded debug artifacts are not supported in generation mode") + } + if conf.Target != "" && !isWasmDebugTarget(conf, target) { + return fmt.Errorf("non-WebAssembly target builds use host debug artifacts") + } + if conf.LinkOptions.DWARF == DWARFOmit { + return fmt.Errorf("debug artifact mode embedded conflicts with -w") + } + conf.LinkOptions.DWARF = DWARFPreserve + } + } + + if conf.DebugArtifactMode == DebugArtifactDefault { + if effectiveOmitDWARF(conf, target) { + conf.DebugArtifactMode = DebugArtifactNone + } else if conf.Target != "" && !isWasmDebugTarget(conf, target) { + conf.DebugArtifactMode = DebugArtifactHost + } else { + conf.DebugArtifactMode = DebugArtifactEmbedded + } + } + return nil +} diff --git a/internal/build/debug_artifact_external.go b/internal/build/debug_artifact_external.go new file mode 100644 index 0000000000..a2e5224051 --- /dev/null +++ b/internal/build/debug_artifact_external.go @@ -0,0 +1,99 @@ +/* + * 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 build + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/goplus/llgo/internal/wasmdebug" +) + +func finalizeDebugArtifact(conf *Config, out *OutFmtDetails, verbose bool) error { + if conf == nil || out == nil { + return nil + } + if conf.DebugArtifactMode != DebugArtifactExternal { + if out.Out != "" { + // A successful non-external rebuild owns this conventional sibling + // path and must not leave a stale optional artifact behind. + _ = os.Remove(dwarfSidecarPath(out.Out)) + } + return nil + } + if out.Out == "" { + return fmt.Errorf("external DWARF executable path is empty") + } + if out.DWARF == "" { + return fmt.Errorf("external DWARF output path is empty") + } + raw, err := os.ReadFile(out.Out) + if err != nil { + return err + } + // external_debug_info stores a URL, not a filesystem path. Keep the + // sidecar adjacent to the module and escape its filename for URL lookup. + main, err := wasmdebug.Externalize(raw, url.PathEscape(filepath.Base(out.DWARF))) + if err != nil { + return fmt.Errorf("externalize WebAssembly DWARF: %w", err) + } + info, err := os.Stat(out.Out) + if err != nil { + return err + } + if err := writeDebugArtifactFile(out.DWARF, raw, info.Mode()); err != nil { + return err + } + if err := writeDebugArtifactFile(out.Out, main, info.Mode()); err != nil { + _ = os.Remove(out.DWARF) + return err + } + if verbose { + fmt.Fprintf(os.Stderr, "llgo: external DWARF: %d bytes -> %s\n", len(raw), out.DWARF) + } + return nil +} + +func writeDebugArtifactFile(path string, data []byte, mode os.FileMode) (err error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + if err := tmp.Chmod(mode.Perm()); err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} diff --git a/internal/build/debug_artifact_external_test.go b/internal/build/debug_artifact_external_test.go new file mode 100644 index 0000000000..61f1712c6e --- /dev/null +++ b/internal/build/debug_artifact_external_test.go @@ -0,0 +1,95 @@ +//go:build !llgo + +package build + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/goplus/llgo/internal/wasmdebug" +) + +func TestFinalizeExternalWasmDWARF(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "main.c") + module := filepath.Join(dir, "app.wasm") + sidecar := filepath.Join(dir, "app debug.wasm") + if err := os.WriteFile(source, []byte("int add(int a, int b) { return a + b; }\n"), 0o644); err != nil { + t.Fatal(err) + } + clang, err := exec.LookPath("clang") + if err != nil { + t.Fatal(err) + } + if out, err := exec.Command(clang, + "--target=wasm32-unknown-unknown", "-g", "-nostdlib", + "-Wl,--no-entry", "-Wl,--export=add", "-o", module, source, + ).CombinedOutput(); err != nil { + t.Fatalf("compile Wasm DWARF fixture: %v\n%s", err, out) + } + original, err := os.ReadFile(module) + if err != nil { + t.Fatal(err) + } + if err := finalizeDebugArtifact( + &Config{DebugArtifactMode: DebugArtifactExternal}, + &OutFmtDetails{Out: module, DWARF: sidecar}, + false, + ); err != nil { + t.Fatal(err) + } + debugModule, err := os.ReadFile(sidecar) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(debugModule, original) { + t.Fatal("external DWARF sidecar differs from the linked debug module") + } + main, err := os.ReadFile(module) + if err != nil { + t.Fatal(err) + } + if has, err := wasmdebug.HasDWARF(main); err != nil || has { + t.Fatalf("main module HasDWARF = %v, %v", has, err) + } + if has, err := wasmdebug.HasDWARF(debugModule); err != nil || !has { + t.Fatalf("sidecar HasDWARF = %v, %v", has, err) + } + url, ok, err := wasmdebug.ExternalURL(main) + if err != nil || !ok || url != "app%20debug.wasm" { + t.Fatalf("main external URL = %q, %v, %v", url, ok, err) + } +} + +func TestFinalizeDebugArtifactValidation(t *testing.T) { + if err := finalizeDebugArtifact(nil, nil, false); err != nil { + t.Fatalf("nil configuration: %v", err) + } + if err := finalizeDebugArtifact(&Config{}, &OutFmtDetails{}, false); err != nil { + t.Fatalf("non-external mode: %v", err) + } + if err := finalizeDebugArtifact(&Config{DebugArtifactMode: DebugArtifactExternal}, &OutFmtDetails{}, false); err == nil { + t.Fatal("external mode accepted an empty executable path") + } +} + +func TestFinalizeDebugArtifactRemovesStaleSidecar(t *testing.T) { + module := filepath.Join(t.TempDir(), "app.wasm") + sidecar := dwarfSidecarPath(module) + if err := os.WriteFile(sidecar, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + if err := finalizeDebugArtifact( + &Config{DebugArtifactMode: DebugArtifactEmbedded}, + &OutFmtDetails{Out: module}, + false, + ); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sidecar); !os.IsNotExist(err) { + t.Fatalf("stale sidecar still exists (stat error %v)", err) + } +} diff --git a/internal/build/debug_artifact_test.go b/internal/build/debug_artifact_test.go new file mode 100644 index 0000000000..a6f5f5e487 --- /dev/null +++ b/internal/build/debug_artifact_test.go @@ -0,0 +1,85 @@ +//go:build !llgo + +package build + +import ( + "testing" + + "github.com/goplus/llgo/internal/crosscompile" +) + +func TestDebugArtifactMode(t *testing.T) { + tests := []struct { + mode DebugArtifactMode + name string + valid bool + }{ + {DebugArtifactDefault, "default", true}, + {DebugArtifactEmbedded, "embedded", true}, + {DebugArtifactExternal, "external", true}, + {DebugArtifactHost, "host", true}, + {DebugArtifactNone, "none", true}, + {DebugArtifactMode(255), "DebugArtifactMode(255)", false}, + } + for _, tt := range tests { + if got := tt.mode.String(); got != tt.name { + t.Errorf("DebugArtifactMode(%d).String() = %q, want %q", tt.mode, got, tt.name) + } + if got := tt.mode.IsValid(); got != tt.valid { + t.Errorf("DebugArtifactMode(%d).IsValid() = %v, want %v", tt.mode, got, tt.valid) + } + } +} + +func TestResolveDebugArtifactMode(t *testing.T) { + native := crosscompile.Export{LLVMTarget: "arm64-apple-darwin"} + wasm := crosscompile.Export{LLVMTarget: "wasm32-wasip1"} + fixed := crosscompile.Export{LLVMTarget: "thumbv7em-none-unknown-eabi"} + base := func() Config { + return Config{Mode: ModeBuild, BuildMode: BuildModeExe} + } + tests := []struct { + name string + conf Config + target crosscompile.Export + wantMode DebugArtifactMode + wantDWARF DWARFMode + wantErr bool + }{ + {name: "safe default", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, OmitDWARFByDefault: true}, target: native, wantMode: DebugArtifactNone}, + {name: "native explicit preserve overrides safe default", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, OmitDWARFByDefault: true, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: native, wantMode: DebugArtifactEmbedded, wantDWARF: DWARFPreserve}, + {name: "native default with DWARF", conf: base(), target: native, wantMode: DebugArtifactEmbedded}, + {name: "fixed default with DWARF", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "rp2040"}, target: fixed, wantMode: DebugArtifactHost}, + {name: "fixed explicit preserve overrides safe default", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "rp2040", OmitDWARFByDefault: true, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: fixed, wantMode: DebugArtifactHost, wantDWARF: DWARFPreserve}, + {name: "wasm default with DWARF", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "wasi", Goarch: "wasm"}, target: wasm, wantMode: DebugArtifactEmbedded}, + {name: "explicit none", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true}, target: native, wantMode: DebugArtifactNone, wantDWARF: DWARFOmit}, + {name: "none conflicts preserve", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true, LinkOptions: LinkOptions{DWARF: DWARFPreserve}}, target: native, wantErr: true}, + {name: "embedded native", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactEmbedded, DebugArtifactModeSet: true}, target: native, wantMode: DebugArtifactEmbedded, wantDWARF: DWARFPreserve}, + {name: "embedded overrides s implication", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactEmbedded, DebugArtifactModeSet: true, LinkOptions: LinkOptions{OmitSymbolTable: true}}, target: native, wantMode: DebugArtifactEmbedded, wantDWARF: DWARFPreserve}, + {name: "embedded conflicts w", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactEmbedded, DebugArtifactModeSet: true, LinkOptions: LinkOptions{DWARF: DWARFOmit}}, target: native, wantErr: true}, + {name: "embedded rejects fixed", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "rp2040", DebugArtifactMode: DebugArtifactEmbedded, DebugArtifactModeSet: true}, target: fixed, wantErr: true}, + {name: "host fixed", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "rp2040", DebugArtifactMode: DebugArtifactHost, DebugArtifactModeSet: true}, target: fixed, wantMode: DebugArtifactHost, wantDWARF: DWARFPreserve}, + {name: "host rejects native", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactHost, DebugArtifactModeSet: true}, target: native, wantErr: true}, + {name: "host rejects wasm", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "wasi", Goarch: "wasm", DebugArtifactMode: DebugArtifactHost, DebugArtifactModeSet: true}, target: wasm, wantErr: true}, + {name: "external wasm", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, Target: "wasi", Goarch: "wasm", DebugArtifactMode: DebugArtifactExternal, DebugArtifactModeSet: true}, target: wasm, wantMode: DebugArtifactExternal, wantDWARF: DWARFPreserve}, + {name: "external rejects native", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactExternal, DebugArtifactModeSet: true}, target: native, wantErr: true}, + {name: "external rejects archive", conf: Config{Mode: ModeBuild, BuildMode: BuildModeCArchive, Target: "wasi", Goarch: "wasm", DebugArtifactMode: DebugArtifactExternal, DebugArtifactModeSet: true}, target: wasm, wantErr: true}, + {name: "explicit default rejected", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactDefault, DebugArtifactModeSet: true}, target: native, wantErr: true}, + {name: "invalid", conf: Config{Mode: ModeBuild, BuildMode: BuildModeExe, DebugArtifactMode: DebugArtifactMode(255)}, target: native, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := tt.conf + err := resolveDebugArtifactMode(&conf, &tt.target) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveDebugArtifactMode() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if conf.DebugArtifactMode != tt.wantMode || conf.LinkOptions.DWARF != tt.wantDWARF { + t.Fatalf("resolved mode/options = %v/%v, want %v/%v", conf.DebugArtifactMode, conf.LinkOptions.DWARF, tt.wantMode, tt.wantDWARF) + } + }) + } +} diff --git a/internal/build/invocation.go b/internal/build/invocation.go index 620f7a326a..0ce80fbdc2 100644 --- a/internal/build/invocation.go +++ b/internal/build/invocation.go @@ -33,6 +33,7 @@ func (e commandEnv) configure(cmd *exec.Cmd) *exec.Cmd { func resolveOutputs(dir string, out *OutFmtDetails) { out.Out = resolvePath(dir, out.Out) out.PCLN = resolvePath(dir, out.PCLN) + out.DWARF = resolvePath(dir, out.DWARF) out.Bin = resolvePath(dir, out.Bin) out.Hex = resolvePath(dir, out.Hex) out.Img = resolvePath(dir, out.Img) diff --git a/internal/build/outputs.go b/internal/build/outputs.go index 4ab63186e7..a6941adcb5 100644 --- a/internal/build/outputs.go +++ b/internal/build/outputs.go @@ -29,6 +29,13 @@ func pclnSidecarPath(executable string) string { return executable + pclnSidecarSuffix } +func dwarfSidecarPath(executable string) string { + if strings.HasSuffix(executable, ".wasm") { + return strings.TrimSuffix(executable, ".wasm") + ".debug.wasm" + } + return executable + ".debug.wasm" +} + func genTempOutputFile(prefix, ext string) (string, error) { tmpFile, err := os.CreateTemp("", prefix+"-*"+ext) if err != nil { @@ -176,6 +183,9 @@ func buildOutFmts(pkgName string, conf *Config, multiPkg bool, crossCompile *cro if conf.PCLNMode == PCLNExternal { details.PCLN = pclnSidecarPath(details.Out) } + if conf.DebugArtifactMode == DebugArtifactExternal { + details.DWARF = dwarfSidecarPath(details.Out) + } if conf.Target == "" { // Native target - we're done @@ -260,6 +270,9 @@ func (details *OutFmtDetails) ToEnvMap() map[string]string { if details.PCLN != "" { envMap["pclntab"] = details.PCLN } + if details.DWARF != "" { + envMap["dwarf"] = details.DWARF + } return envMap } diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go index db2667b290..bbe7b8fd2b 100644 --- a/internal/build/outputs_test.go +++ b/internal/build/outputs_test.go @@ -408,6 +408,29 @@ func TestBuildOutFmtsPCLN(t *testing.T) { } } +func TestBuildOutFmtsExternalDWARF(t *testing.T) { + tests := []struct { + out string + want string + }{ + {out: "app.wasm", want: "app.debug.wasm"}, + {out: "dist/app", want: "dist/app.debug.wasm"}, + } + for _, tt := range tests { + conf := &Config{Mode: ModeBuild, BuildMode: BuildModeExe, OutFile: tt.out, PCLNMode: PCLNExternal, DebugArtifactMode: DebugArtifactExternal} + got, err := buildOutFmts("app", conf, false, &crosscompile.Export{}) + if err != nil { + t.Fatal(err) + } + if got.DWARF != tt.want || got.ToEnvMap()["dwarf"] != tt.want { + t.Fatalf("external DWARF path = %q/%q, want %q", got.DWARF, got.ToEnvMap()["dwarf"], tt.want) + } + if wantPCLN := got.Out + pclnSidecarSuffix; got.PCLN != wantPCLN || got.ToEnvMap()["pclntab"] != wantPCLN { + t.Fatalf("external pclntab path = %q/%q, want %q", got.PCLN, got.ToEnvMap()["pclntab"], wantPCLN) + } + } +} + func TestOutFmtDetailsToEnvMapIncludesPCLN(t *testing.T) { details := &OutFmtDetails{Out: "app", PCLN: "app.pclntab"} if got := details.ToEnvMap()["pclntab"]; got != details.PCLN { diff --git a/internal/wasmdebug/wasmdebug.go b/internal/wasmdebug/wasmdebug.go new file mode 100644 index 0000000000..8ed96d1c1e --- /dev/null +++ b/internal/wasmdebug/wasmdebug.go @@ -0,0 +1,210 @@ +/* + * 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 wasmdebug implements the WebAssembly tool-conventions packaging for +// embedded and external DWARF custom sections. +package wasmdebug + +import ( + "bytes" + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +const externalDebugInfo = "external_debug_info" + +var wasmHeader = []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + +type section struct { + raw []byte + id byte + name string + content []byte +} + +func readULEB32(raw []byte, off *int) (uint32, error) { + var value uint32 + for shift := uint(0); shift < 35; shift += 7 { + if *off >= len(raw) { + return 0, errors.New("truncated WebAssembly varuint32") + } + b := raw[*off] + (*off)++ + if shift == 28 && b > 0x0f { + return 0, errors.New("WebAssembly varuint32 overflows") + } + value |= uint32(b&0x7f) << shift + if b&0x80 == 0 { + return value, nil + } + } + return 0, errors.New("invalid WebAssembly varuint32") +} + +func appendULEB32(dst []byte, value uint32) []byte { + for { + b := byte(value & 0x7f) + value >>= 7 + if value != 0 { + b |= 0x80 + } + dst = append(dst, b) + if value == 0 { + return dst + } + } +} + +func readName(raw []byte, off *int) (string, error) { + size, err := readULEB32(raw, off) + if err != nil { + return "", err + } + if uint64(size) > uint64(len(raw)-*off) { + return "", errors.New("truncated WebAssembly name") + } + name := raw[*off : *off+int(size)] + *off += int(size) + if !utf8.Valid(name) { + return "", errors.New("invalid UTF-8 WebAssembly name") + } + return string(name), nil +} + +func parse(raw []byte) ([]section, error) { + if len(raw) < len(wasmHeader) || !bytes.Equal(raw[:len(wasmHeader)], wasmHeader) { + return nil, errors.New("invalid WebAssembly header") + } + var sections []section + for off := len(wasmHeader); off < len(raw); { + start := off + id := raw[off] + off++ + size, err := readULEB32(raw, &off) + if err != nil { + return nil, err + } + if uint64(size) > uint64(len(raw)-off) { + return nil, errors.New("truncated WebAssembly section") + } + end := off + int(size) + entry := section{raw: raw[start:end], id: id} + if id == 0 { + payloadOff := off + entry.name, err = readName(raw[:end], &payloadOff) + if err != nil { + return nil, fmt.Errorf("invalid WebAssembly custom section: %w", err) + } + entry.content = raw[payloadOff:end] + } + sections = append(sections, entry) + off = end + } + return sections, nil +} + +func isDWARFSection(name string) bool { + return strings.HasPrefix(name, ".debug_") || + strings.HasPrefix(name, ".zdebug_") || + strings.HasPrefix(name, "reloc..debug_") || + strings.HasPrefix(name, "reloc..zdebug_") +} + +func appendCustomSection(dst []byte, name string, content []byte) []byte { + payload := appendULEB32(nil, uint32(len(name))) + payload = append(payload, name...) + payload = append(payload, content...) + dst = append(dst, 0) + dst = appendULEB32(dst, uint32(len(payload))) + return append(dst, payload...) +} + +// HasDWARF reports whether module contains at least one DWARF custom section. +func HasDWARF(module []byte) (bool, error) { + sections, err := parse(module) + if err != nil { + return false, err + } + for _, section := range sections { + if section.id == 0 && isDWARFSection(section.name) { + return true, nil + } + } + return false, nil +} + +// Externalize removes embedded DWARF custom sections and appends the standard +// external_debug_info URL record. The original module is suitable for use as +// the sidecar because the convention permits it to retain code and data. +func Externalize(module []byte, url string) ([]byte, error) { + if url == "" || !utf8.ValidString(url) { + return nil, errors.New("external DWARF URL must be non-empty UTF-8") + } + sections, err := parse(module) + if err != nil { + return nil, err + } + out := append([]byte(nil), wasmHeader...) + foundDWARF := false + for _, section := range sections { + if section.id == 0 { + if isDWARFSection(section.name) { + foundDWARF = true + continue + } + if section.name == externalDebugInfo { + continue + } + } + out = append(out, section.raw...) + } + if !foundDWARF { + return nil, errors.New("WebAssembly module contains no DWARF sections") + } + content := appendULEB32(nil, uint32(len(url))) + content = append(content, url...) + return appendCustomSection(out, externalDebugInfo, content), nil +} + +// ExternalURL returns the external_debug_info URL, if present. +func ExternalURL(module []byte) (string, bool, error) { + sections, err := parse(module) + if err != nil { + return "", false, err + } + var url string + found := false + for _, section := range sections { + if section.id != 0 || section.name != externalDebugInfo { + continue + } + if found { + return "", false, errors.New("multiple external_debug_info sections") + } + off := 0 + url, err = readName(section.content, &off) + if err != nil { + return "", false, fmt.Errorf("invalid external_debug_info section: %w", err) + } + if off != len(section.content) { + return "", false, errors.New("external_debug_info section has trailing data") + } + found = true + } + return url, found, nil +} diff --git a/internal/wasmdebug/wasmdebug_test.go b/internal/wasmdebug/wasmdebug_test.go new file mode 100644 index 0000000000..a02692cad1 --- /dev/null +++ b/internal/wasmdebug/wasmdebug_test.go @@ -0,0 +1,145 @@ +package wasmdebug + +import ( + "bytes" + "strings" + "testing" +) + +func appendSection(dst []byte, id byte, payload []byte) []byte { + dst = append(dst, id) + dst = appendULEB32(dst, uint32(len(payload))) + return append(dst, payload...) +} + +func debugFixture() []byte { + module := append([]byte(nil), wasmHeader...) + module = appendCustomSection(module, "producers", []byte("LLGo")) + module = appendSection(module, 1, []byte{0x01, 0x60, 0x00, 0x00}) + module = appendCustomSection(module, ".debug_info", []byte{1, 2, 3}) + module = appendSection(module, 10, []byte{0x01, 0x02, 0x00, 0x0b}) + module = appendCustomSection(module, ".debug_line", []byte{4, 5, 6}) + module = appendCustomSection(module, externalDebugInfo, appendULEB32(nil, 0)) + return module +} + +func TestExternalize(t *testing.T) { + sidecar := debugFixture() + url := strings.Repeat("debug-", 24) + ".wasm" + main, err := Externalize(sidecar, url) + if err != nil { + t.Fatal(err) + } + if has, err := HasDWARF(sidecar); err != nil || !has { + t.Fatalf("sidecar HasDWARF = %v, %v", has, err) + } + if has, err := HasDWARF(main); err != nil || has { + t.Fatalf("main HasDWARF = %v, %v", has, err) + } + gotURL, ok, err := ExternalURL(main) + if err != nil || !ok || gotURL != url { + t.Fatalf("ExternalURL = %q, %v, %v; want %q", gotURL, ok, err, url) + } + + originalSections, err := parse(sidecar) + if err != nil { + t.Fatal(err) + } + mainSections, err := parse(main) + if err != nil { + t.Fatal(err) + } + var originalStandard, mainStandard []byte + for _, section := range originalSections { + if section.id != 0 { + originalStandard = append(originalStandard, section.raw...) + } + } + for _, section := range mainSections { + if section.id != 0 { + mainStandard = append(mainStandard, section.raw...) + } + } + if !bytes.Equal(originalStandard, mainStandard) { + t.Fatal("externalization changed standard WebAssembly sections") + } + if countCustom(mainSections, externalDebugInfo) != 1 { + t.Fatal("externalization did not replace the old URL section") + } + if countCustom(mainSections, "producers") != 1 { + t.Fatal("externalization removed an unrelated custom section") + } +} + +func countCustom(sections []section, name string) int { + count := 0 + for _, section := range sections { + if section.id == 0 && section.name == name { + count++ + } + } + return count +} + +func TestExternalizeErrors(t *testing.T) { + withoutDWARF := appendSection(append([]byte(nil), wasmHeader...), 1, []byte{0}) + tests := []struct { + name string + module []byte + url string + }{ + {name: "empty URL", module: debugFixture()}, + {name: "invalid URL", module: debugFixture(), url: string([]byte{0xff})}, + {name: "invalid header", module: []byte("not wasm"), url: "app.debug.wasm"}, + {name: "truncated size", module: append(append([]byte(nil), wasmHeader...), 0, 0x80), url: "app.debug.wasm"}, + {name: "oversized section", module: append(append([]byte(nil), wasmHeader...), 1, 2, 0), url: "app.debug.wasm"}, + {name: "no DWARF", module: withoutDWARF, url: "app.debug.wasm"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := Externalize(tt.module, tt.url); err == nil { + t.Fatal("Externalize succeeded") + } + }) + } +} + +func TestExternalURLValidation(t *testing.T) { + base := append([]byte(nil), wasmHeader...) + validContent := appendULEB32(nil, 4) + validContent = append(validContent, "a.wm"...) + tests := []struct { + name string + module []byte + ok bool + url string + }{ + {name: "absent", module: base}, + {name: "valid", module: appendCustomSection(base, externalDebugInfo, validContent), ok: true, url: "a.wm"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, ok, err := ExternalURL(tt.module) + if err != nil || ok != tt.ok || url != tt.url { + t.Fatalf("ExternalURL = %q, %v, %v", url, ok, err) + } + }) + } + + duplicate := appendCustomSection(appendCustomSection(base, externalDebugInfo, validContent), externalDebugInfo, validContent) + if _, _, err := ExternalURL(duplicate); err == nil { + t.Fatal("ExternalURL accepted duplicate sections") + } + trailing := append(append([]byte(nil), validContent...), 0) + if _, _, err := ExternalURL(appendCustomSection(base, externalDebugInfo, trailing)); err == nil { + t.Fatal("ExternalURL accepted trailing data") + } +} + +func TestHasDWARFRejectsMalformedCustomSection(t *testing.T) { + module := append([]byte(nil), wasmHeader...) + module = appendSection(module, 0, []byte{2, 'x'}) + if _, err := HasDWARF(module); err == nil { + t.Fatal("HasDWARF accepted a truncated custom-section name") + } +}