From cae043fa7f754cfcb39363b0828c7ab931f259d0 Mon Sep 17 00:00:00 2001 From: kamran Date: Fri, 5 Jun 2026 03:55:29 +0500 Subject: [PATCH 1/6] Add inlined function tests and InlinedCall struct for issue #53 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add InlinedCall struct and InlinedList field to FuncMetadata - Add add(), multiply(), neverInlined() to testproject - Add comprehensive inline test suite (positive + negative cases) - Positive tests intentionally fail — implementation not yet written --- main.go | 7 ++++++ main_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++ testproject/main.go | 10 ++++++++ 3 files changed, 75 insertions(+) diff --git a/main.go b/main.go index 4ae946e..fae2b47 100644 --- a/main.go +++ b/main.go @@ -44,11 +44,18 @@ type PcLnTabMetadata struct { PointerSize uint32 } +type InlinedCall struct { + Funcname string + CallingPc uint64 + ParentEntry uint64 +} + type FuncMetadata struct { Start uint64 End uint64 PackageName string FullName string + InlinedList []InlinedCall } type ExtractMetadata struct { diff --git a/main_test.go b/main_test.go index bf5bbe7..af9c0ce 100644 --- a/main_test.go +++ b/main_test.go @@ -118,6 +118,64 @@ func TestAllVersions(t *testing.T) { t.Errorf("Go %s no Func type with _ptr_ in CStr found", v) } + // --- Inline function recovery tests --- + // Positive: main.main must have inlined functions (add, multiply) + for _, fn := range data.UserFunctions { + if fn.FullName == "main.main" { + if len(fn.InlinedList) == 0 { + t.Errorf("Go %s main.main expected inlined functions, got none", v) + } + found_add := false + for _, inl := range fn.InlinedList { + if inl.Funcname == "main.add" { + found_add = true + } + } + if !found_add { + t.Errorf("Go %s main.add not found in main.main InlinedList", v) + } + if len(fn.InlinedList) < 2 { + t.Errorf("Go %s main.main expected >= 2 inlines, got %d", v, len(fn.InlinedList)) + } + } + } + + // Negative: neverInlined must have empty InlinedList + for _, fn := range data.UserFunctions { + if fn.FullName == "main.neverInlined" && len(fn.InlinedList) != 0 { + t.Errorf("Go %s main.neverInlined should have no InlinedList entries", v) + } + } + + // Negative: validate all InlinedList entries across all functions + for _, fn := range data.UserFunctions { + seen := map[uint64]bool{} + funcSize := fn.End - fn.Start + for _, inl := range fn.InlinedList { + if inl.Funcname == "" { + t.Errorf("Go %s empty Funcname in %s InlinedList", v, fn.FullName) + } + for _, c := range inl.Funcname { + if c < 32 || c > 126 { + t.Errorf("Go %s non-printable char in Funcname %q in %s", v, inl.Funcname, fn.FullName) + } + } + if !strings.Contains(inl.Funcname, ".") { + t.Errorf("Go %s Funcname %q missing package prefix in %s", v, inl.Funcname, fn.FullName) + } + if inl.CallingPc >= funcSize { + t.Errorf("Go %s CallingPc %x >= funcSize %x in %s", v, inl.CallingPc, funcSize, fn.FullName) + } + if inl.ParentEntry != fn.Start { + t.Errorf("Go %s ParentEntry %x != fn.Start %x in %s", v, inl.ParentEntry, fn.Start, fn.FullName) + } + if seen[inl.CallingPc] { + t.Errorf("Go %s duplicate CallingPc %x in %s", v, inl.CallingPc, fn.FullName) + } + seen[inl.CallingPc] = true + } + } + } else { found_interface := false for _, typ := range data.Types { diff --git a/testproject/main.go b/testproject/main.go index 8571d4b..31dc69e 100644 --- a/testproject/main.go +++ b/testproject/main.go @@ -7,6 +7,12 @@ type structurea struct { test string } +func add(a, b int) int { return a + b } +func multiply(a, b int) int { return a * b } + +//go:noinline +func neverInlined(x int) int { return x * x } + func sum(s []int, c chan int) { sum := 0 for _, v := range s { @@ -35,4 +41,8 @@ func main() { x := <-c fmt.Println(x) + + fmt.Println(add(1, 2)) + fmt.Println(multiply(3, 4)) + fmt.Println(neverInlined(5)) } From b566355767f061a991853113cc4753cd0d9a1b54 Mon Sep 17 00:00:00 2001 From: kamran Date: Fri, 10 Jul 2026 00:49:47 +0500 Subject: [PATCH 2/6] Use runtime-derived arguments in add/multiply test fixture Constant arguments let the compiler fold add(1,2)/multiply(3,4) into literals at compile time, leaving no inline-tree evidence to recover regardless of extraction-code correctness. Deriving from len(os.Args) forces the compiler to keep real, recoverable inlined instructions. Also strengthen main_test.go to check for main.multiply by name, not just main.add, now that both are genuinely recoverable. --- main_test.go | 7 +++++++ testproject/main.go | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/main_test.go b/main_test.go index af9c0ce..da8c415 100644 --- a/main_test.go +++ b/main_test.go @@ -126,14 +126,21 @@ func TestAllVersions(t *testing.T) { t.Errorf("Go %s main.main expected inlined functions, got none", v) } found_add := false + found_multiply := false for _, inl := range fn.InlinedList { if inl.Funcname == "main.add" { found_add = true } + if inl.Funcname == "main.multiply" { + found_multiply = true + } } if !found_add { t.Errorf("Go %s main.add not found in main.main InlinedList", v) } + if !found_multiply { + t.Errorf("Go %s main.multiply not found in main.main InlinedList", v) + } if len(fn.InlinedList) < 2 { t.Errorf("Go %s main.main expected >= 2 inlines, got %d", v, len(fn.InlinedList)) } diff --git a/testproject/main.go b/testproject/main.go index 31dc69e..18975f4 100644 --- a/testproject/main.go +++ b/testproject/main.go @@ -1,7 +1,10 @@ /*Copyright (C) 2022 Mandiant, Inc. All Rights Reserved.*/ package main -import "fmt" +import ( + "fmt" + "os" +) type structurea struct { test string @@ -42,7 +45,8 @@ func main() { x := <-c fmt.Println(x) - fmt.Println(add(1, 2)) - fmt.Println(multiply(3, 4)) + n := len(os.Args) + fmt.Println(add(n, 2)) + fmt.Println(multiply(n, 4)) fmt.Println(neverInlined(5)) } From 82ecb06b6848f3139f47027c8eb5b67c0f1dc93f Mon Sep 17 00:00:00 2001 From: kamran Date: Fri, 10 Jul 2026 04:51:45 +0500 Subject: [PATCH 3/6] feat: add GoFunc field to moduledata layout system Reads moduledata.gofunc (go:func.* base) via the existing per-version layout tables, needed for FUNCDATA_InlTree offsets (issue #53). Offset 320/64-bit, 160/32-bit verified against runtime source and a real test binary; the Go 1.26-only layout bucket is left unset rather than guessed. --- objfile/internals.go | 1 + objfile/layouts.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/objfile/internals.go b/objfile/internals.go index 3ac77cc..562d816 100644 --- a/objfile/internals.go +++ b/objfile/internals.go @@ -157,6 +157,7 @@ type ModuleData struct { ETypes uint64 // points to end of type information Typelinks GoSlice64 // points to metadata about offsets into types for structures and other types ITablinks GoSlice64 // points to metadata about offsets into types for interfaces + GoFunc uint64 // go:func.* base address; funcdata[FUNCDATA_InlTree] offsets are relative to this // Some versions of go with 1.2 moduledata use a slice instead of the types + offset typelinks list LegacyTypes GoSlice64 diff --git a/objfile/layouts.go b/objfile/layouts.go index 3f96d7e..11bfde2 100644 --- a/objfile/layouts.go +++ b/objfile/layouts.go @@ -37,6 +37,8 @@ const ( // FuncTab fields FieldEntryoffset // 22 FieldFuncoffset // 23 + // ModuleData fields (added later) + FieldGoFunc // 24 -- go:func.* base (moduledata.gofunc), needed for FUNCDATA_InlTree ) // String representation for debugging/logging @@ -90,6 +92,8 @@ func (f FieldName) String() string { return "Entryoffset" case FieldFuncoffset: return "Funcoffset" + case FieldGoFunc: + return "GoFunc" default: return "Unknown" } @@ -280,6 +284,12 @@ var functabLayoutLegacy = &StructLayout{ // Only fields that GoReSym actually uses are included var moduleDataLayouts = map[string]*ModuleDataLayout{ "1.22": { + // NOTE: this bucket is real Go 1.26 only (see getModuleDataLayout) -- + // FieldGoFunc offset intentionally omitted here, unverified. The + // gap between Etypes and Textsectmap is 8 bytes larger than the + // "1.21"/"1.20" buckets, so GoFunc's offset for this bucket is NOT + // simply 320/160 -- confirm against a real Go 1.26 binary before + // adding it. Version: "1.22", Fields: []FieldInfo{ {Name: FieldFtab, Offset64: 128, Offset32: 64, Type: FieldTypeSlice}, @@ -293,6 +303,10 @@ var moduleDataLayouts = map[string]*ModuleDataLayout{ }, }, "1.21": { + // Real Go 1.21-1.25 (see getModuleDataLayout). GoFunc offset + // verified against runtime/symtab.go's moduledata struct (go1.22.5 + // source) and empirically against a real binary -- see + // docs/inline_functions/ghidra_inline_exercise_log.md. Version: "1.21", Fields: []FieldInfo{ {Name: FieldFtab, Offset64: 128, Offset32: 64, Type: FieldTypeSlice}, @@ -303,9 +317,11 @@ var moduleDataLayouts = map[string]*ModuleDataLayout{ {Name: FieldTextsectmap, Offset64: 328, Offset32: 164, Type: FieldTypeSlice}, {Name: FieldTypelinks, Offset64: 352, Offset32: 176, Type: FieldTypeSlice}, {Name: FieldItablinks, Offset64: 376, Offset32: 188, Type: FieldTypeSlice}, + {Name: FieldGoFunc, Offset64: 320, Offset32: 160, Type: FieldTypePvoid}, }, }, "1.20": { + // Real Go 1.20 only. Same field layout as "1.21" bucket. Version: "1.20", Fields: []FieldInfo{ {Name: FieldFtab, Offset64: 128, Offset32: 64, Type: FieldTypeSlice}, @@ -315,6 +331,7 @@ var moduleDataLayouts = map[string]*ModuleDataLayout{ {Name: FieldEtypes, Offset64: 304, Offset32: 152, Type: FieldTypePvoid}, {Name: FieldTextsectmap, Offset64: 328, Offset32: 164, Type: FieldTypeSlice}, {Name: FieldTypelinks, Offset64: 352, Offset32: 176, Type: FieldTypeSlice}, + {Name: FieldGoFunc, Offset64: 320, Offset32: 160, Type: FieldTypePvoid}, {Name: FieldItablinks, Offset64: 376, Offset32: 188, Type: FieldTypeSlice}, }, }, @@ -446,6 +463,7 @@ type ModuleDataIntermediate struct { Textsectmap GoSlice64 Typelinks GoSlice64 Itablinks GoSlice64 + GoFunc uint64 } // parseModuleDataGeneric parses moduledata from raw bytes using layout tables @@ -502,6 +520,8 @@ func parseModuleDataGeneric(rawData []byte, runtimeVersion string, layoutVersion case FieldItablinks: data, len := readSlice(rawData, offset, is64bit, littleendian) md.Itablinks = GoSlice64{Data: pvoid64(data), Len: len} + case FieldGoFunc: + md.GoFunc = readPointer(rawData, offset, is64bit, littleendian) } } @@ -582,6 +602,7 @@ func (e *Entry) validateAndConvertModuleData( ETypes: md.Etypes, Typelinks: md.Typelinks, ITablinks: md.Itablinks, + GoFunc: md.GoFunc, } return result, ignorelist, nil From b2bf21c4362912b4ee6c668089369af269ad5643 Mon Sep 17 00:00:00 2001 From: kamran Date: Wed, 23 Sep 2026 04:58:05 +0500 Subject: [PATCH 4/6] Add ParentIndex field + nested-inline fixture/tests for tree-preserving design (issue #53) - InlinedCall gains ParentIndex int (-1 = parent is ParentEntry; else index of another record in the same InlinedList). - testproject/main.go: new nestedOuter/nestedInner fixture (ported from learning/03_nested_inline) exercising real 2-level mid-stack inlining, with the sink-write trick so nestedInner can't be constant-folded away. - main_test.go: new positive assertion that nestedInner.ParentIndex resolves to nestedOuter's own index, not flattened to main.main -- the check that would have caught the flat design. No extraction logic changed -- these assertions are intentionally failing until the InlTree parsing commits land. --- main.go | 1 + main_test.go | 38 ++++++++++++++++++++++++++++++++++++++ testproject/main.go | 24 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/main.go b/main.go index 9196fae..1e9e773 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,7 @@ type InlinedCall struct { Funcname string CallingPc uint64 ParentEntry uint64 + ParentIndex int // -1 = direct parent is ParentEntry (the physical function); else index into this same InlinedList } type FuncMetadata struct { diff --git a/main_test.go b/main_test.go index bcdb0bf..d6d6541 100644 --- a/main_test.go +++ b/main_test.go @@ -147,6 +147,44 @@ func TestAllVersions(t *testing.T) { } } + // Positive: main.main's InlinedList must preserve the real parent chain, + // not flatten it. main.nestedOuter calls main.nestedInner; the compiler + // inlines nestedInner into nestedOuter, then nestedOuter into main.main + // -- so nestedInner's *direct* logical parent is nestedOuter's own + // InlinedList entry, not main.main. See docs/inline_functions/ + // parentpc_verification_exercise.md for the by-hand proof this mirrors, + // and progress_summary.md's "Maintainer decision on flat vs. tree" for + // why this must hold (Steve, 2026-09-13: tree version required). + for _, fn := range data.UserFunctions { + if fn.FullName == "main.main" { + outerIdx, innerIdx := -1, -1 + for i, inl := range fn.InlinedList { + if inl.Funcname == "main.nestedOuter" { + outerIdx = i + } + if inl.Funcname == "main.nestedInner" { + innerIdx = i + } + } + if outerIdx == -1 { + t.Errorf("Go %s main.nestedOuter not found in main.main InlinedList", v) + } + if innerIdx == -1 { + t.Errorf("Go %s main.nestedInner not found in main.main InlinedList", v) + } + if outerIdx != -1 && innerIdx != -1 { + if fn.InlinedList[outerIdx].ParentIndex != -1 { + t.Errorf("Go %s main.nestedOuter.ParentIndex = %d, want -1 (direct parent is main.main itself)", + v, fn.InlinedList[outerIdx].ParentIndex) + } + if fn.InlinedList[innerIdx].ParentIndex != outerIdx { + t.Errorf("Go %s main.nestedInner.ParentIndex = %d, want %d (main.nestedOuter's own index -- not flattened to main.main)", + v, fn.InlinedList[innerIdx].ParentIndex, outerIdx) + } + } + } + } + // Negative: neverInlined must have empty InlinedList for _, fn := range data.UserFunctions { if fn.FullName == "main.neverInlined" && len(fn.InlinedList) != 0 { diff --git a/testproject/main.go b/testproject/main.go index fc8b055..7b75f44 100644 --- a/testproject/main.go +++ b/testproject/main.go @@ -19,6 +19,29 @@ func multiply(a, b int) int { return a * b } //go:noinline func neverInlined(x int) int { return x * x } +// nestedInlineSink forces a real memory write inside nestedInner, so its +// contribution can't be algebraically fused into nestedOuter's own arithmetic +// and optimized away entirely (this happened on the first attempt at this +// fixture -- nestedInner's code vanished with no trace to recover). +var nestedInlineSink int + +// nestedInner is small enough to inline, but the store to nestedInlineSink is +// a real, non-foldable side effect. +func nestedInner(x int) int { + nestedInlineSink = x + return x + 1 +} + +// nestedOuter calls nestedInner. If both are small enough, the compiler can +// inline nestedInner into nestedOuter, AND THEN inline that already-inlined +// copy of nestedOuter into main.main -- this is the "mid-stack inlining" case: +// nestedInner ends up nested two levels deep inside main.main, with its +// logical caller being nestedOuter (itself inlined), not main.main directly. +func nestedOuter(x int) int { + y := nestedInner(x) + return y * 2 +} + func sum(s []int, c chan int) { sum := 0 for _, v := range s { @@ -55,4 +78,5 @@ func main() { fmt.Println(add(n, 2)) fmt.Println(multiply(n, 4)) fmt.Println(neverInlined(5)) + fmt.Println(nestedOuter(n)) } From 9418071bd576712ffb155b81a0202226e9717565 Mon Sep 17 00:00:00 2001 From: kamran Date: Wed, 30 Sep 2026 18:29:05 +0500 Subject: [PATCH 5/6] Add funcData funcdata/pcdata array accessors (issue #53) npcdata(), nfuncdata(), pcdataOffset(), funcdataOffset() on funcData, following the existing field() pattern. nfuncdata() and the new headerSize() helper derive their byte offset from sz0 (same computation field() already does) instead of hardcoding 43/44, so they stay correct for Go 1.18+ on any bitness rather than only the one binary this was hand-verified against. Not yet called anywhere -- wiring these into real InlTree parsing is the next commit. --- debug/gosym/pclntab.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/debug/gosym/pclntab.go b/debug/gosym/pclntab.go index 9bd6b8a..2f8d84b 100644 --- a/debug/gosym/pclntab.go +++ b/debug/gosym/pclntab.go @@ -523,6 +523,43 @@ func (f funcData) deferreturn() uint32 { return f.field(3) } func (f funcData) pcfile() uint32 { return f.field(5) } func (f funcData) pcln() uint32 { return f.field(6) } func (f funcData) cuOffset() uint32 { return f.field(8) } +func (f funcData) npcdata() uint32 { return f.field(7) } + +// nfuncdata returns the number of funcdata entries. It's a single trailing +// byte after the _func struct's nine 4-byte fields (funcID/flag/pad precede +// it), so it can't go through field(), which only covers n <= 9. +func (f funcData) nfuncdata() uint8 { + sz0 := f.t.Ptrsize + if f.t.Version >= ver118 { + sz0 = 4 + } + return f.data[sz0+39] +} + +// headerSize returns the size of the fixed _func header: sz0 (entry field) +// + 36 (fields 1-9, 4 bytes each) + 4 (funcID/flag/pad/nfuncdata, 1 byte each). +// pcdata[] starts here; funcdata[] starts npcdata*4 bytes after that. +func (f funcData) headerSize() uint32 { + sz0 := f.t.Ptrsize + if f.t.Version >= ver118 { + sz0 = 4 + } + return sz0 + 40 +} + +// pcdataOffset returns the offset into t.pctab of the i'th pcdata table +// (e.g. PCDATA_InlTreeIndex). Sentinel 0xffffffff means absent. +func (f funcData) pcdataOffset(i uint32) uint32 { + off := f.headerSize() + i*4 + return f.t.Binary.Uint32(f.data[off:]) +} + +// funcdataOffset returns the offset into moduledata.GoFunc (go:func.*) of the +// i'th funcdata blob (e.g. FUNCDATA_InlTree). Sentinel 0xffffffff means absent. +func (f funcData) funcdataOffset(i uint32) uint32 { + off := f.headerSize() + f.npcdata()*4 + i*4 + return f.t.Binary.Uint32(f.data[off:]) +} // field returns the nth field of the _func struct. // It panics if n == 0 or n > 9; for n == 0, call f.entryPC. From 05c438ec87437a4e9c95145ce4ec0a613f04eebb Mon Sep 17 00:00:00 2001 From: kamran Date: Fri, 2 Oct 2026 21:06:19 +0500 Subject: [PATCH 6/6] Add PCDATA_InlTreeIndex decode + record-count derivation (issue #53) decodePcValueRanges() walks the same delta-encoded pc-value table pcvalue() already reads (LEB128 varint + zigzag via step()), but returns every (value, pcStart, pcEnd) transition instead of stopping at one target PC -- needed later to find every disjoint chunk belonging to a given InlTree record. countInlineRecords() derives the record count as max(all non-negative values) + 1, Delve's approach (maxInlineTreeIndexValue()) -- nothing on disk states the count directly. Verified against hand-decoded data on learning/01_basic_inline and learning/02_runtime_inline. Not yet wired to anything -- InlTree record parsing is next. --- debug/gosym/pclntab.go | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/debug/gosym/pclntab.go b/debug/gosym/pclntab.go index 2f8d84b..5e0b427 100644 --- a/debug/gosym/pclntab.go +++ b/debug/gosym/pclntab.go @@ -561,6 +561,50 @@ func (f funcData) funcdataOffset(i uint32) uint32 { return f.t.Binary.Uint32(f.data[off:]) } +// pcValueRange is one (value, pcStart, pcEnd) transition decoded from a +// pc-value delta table -- the value applies to every PC in [pcStart, pcEnd). +type pcValueRange struct { + val int32 + pcStart uint64 + pcEnd uint64 +} + +// decodePcValueRanges walks the same delta-encoded table pcvalue() reads +// (LEB128 varint + zigzag, via step()), but returns every transition instead +// of stopping at one target PC. off is the pc-value table offset (e.g. from +// funcData.pcdataOffset(objabi.PCDATA_InlTreeIndex)) and entry is the +// function's start PC. +func (t *LineTable) decodePcValueRanges(off uint32, entry uint64) []pcValueRange { + p := t.pctab[off:] + var ranges []pcValueRange + val := int32(-1) + pc := entry + for { + prevPc := pc + if !t.step(&p, &pc, &val, pc == entry) { + break + } + ranges = append(ranges, pcValueRange{val: val, pcStart: prevPc, pcEnd: pc}) + } + return ranges +} + +// countInlineRecords returns the number of InlTree records for a function, +// computed as max(all non-negative PCDATA_InlTreeIndex values) + 1 -- the +// same approach Delve uses (maxInlineTreeIndexValue()), since nothing on +// disk states the record count directly. Verified against hand-decoded +// data on learning/01_basic_inline and learning/02_runtime_inline; see +// docs/inline_functions/ghidra_inline_exercise_log.md. +func countInlineRecords(ranges []pcValueRange) int { + max := int32(-1) + for _, r := range ranges { + if r.val > max { + max = r.val + } + } + return int(max) + 1 +} + // field returns the nth field of the _func struct. // It panics if n == 0 or n > 9; for n == 0, call f.entryPC. // Most callers should use a named field accessor (just above).