proc: implement frame pointer unwinding (#4288)

* Add BenchmarkStacktrace test

* proc: implement frame pointer unwinding

Add hybrid stack unwinding that attempts frame-pointer-based unwinding before falling back to DWARF.

* Rename useDWARF and change to funcToImage

* Update comment

Remove _fixtures/deeprecursion.go

* Add check of non-go code

* Fix ARM64 FP unwind false positives and Windows failures.
This commit is contained in:
Álex Sáez 2026-04-28 20:19:03 +02:00 committed by GitHub
parent 954ef88092
commit 1d5a7eb404
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 192 additions and 4 deletions

15
_fixtures/deepstack.go Normal file
View File

@ -0,0 +1,15 @@
package main
import "runtime"
func deepCall(n int) {
if n == 0 {
runtime.Breakpoint()
return
}
deepCall(n - 1)
}
func main() {
deepCall(10000)
}

View File

@ -1495,6 +1495,34 @@ func BenchmarkLocalVariables(b *testing.B) {
})
}
func BenchmarkStacktrace(b *testing.B) {
withTestProcess("deepstack", b, func(p *proc.Target, grp *proc.TargetGroup, fixture protest.Fixture) {
assertNoError(grp.Continue(), b, "Continue()")
g, err := proc.GetG(p.CurrentThread())
assertNoError(err, b, "GetG()")
if g == nil {
b.Fatal("no current goroutine")
}
frames, err := proc.GoroutineStacktrace(p, g, 600, 0)
assertNoError(err, b, "GoroutineStacktrace()")
if len(frames) < 500 {
b.Fatalf("expected at least 500 frames, got %d", len(frames))
}
b.Logf("stack depth: %d frames", len(frames))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := proc.GoroutineStacktrace(p, g, 600, 0)
if err != nil {
b.Fatal(err)
}
}
})
}
func TestCondBreakpoint(t *testing.T) {
protest.AllowRecording(t)
withTestProcess("parallel_next", t, func(p *proc.Target, grp *proc.TargetGroup, fixture protest.Fixture) {

View File

@ -13,6 +13,7 @@ import (
"github.com/go-delve/delve/pkg/dwarf/frame"
"github.com/go-delve/delve/pkg/dwarf/op"
"github.com/go-delve/delve/pkg/dwarf/reader"
"github.com/go-delve/delve/pkg/goversion"
"github.com/go-delve/delve/pkg/logflags"
)
@ -243,6 +244,13 @@ type stackIterator struct {
count int
// canUseFP is true when the frame pointer has been validated as stable
// for FP-based unwinding. At the top of the stack the topmost function
// may be frameless (BP inherited from caller), so canUseFP starts false.
// It becomes true when BP + 2*PtrSize == CFA, indicating that BP is the
// current frame's own frame pointer.
canUseFP bool
opts StacktraceOptions
}
@ -536,16 +544,153 @@ func (it *stackIterator) appendInlineCalls(callback func(Stackframe) bool, frame
return callback(frame)
}
// advanceRegs calculates the DwarfRegisters for a next stack frame
// advanceRegs calculates the DwarfRegisters for the next stack frame
// (corresponding to it.pc).
//
// The computation uses the registers for the current stack frame (it.regs) and
// the corresponding Frame Descriptor Entry (FDE) retrieved from the DWARF info.
// The computation uses the registers for the current stack frame in it.regs.
// When possible a simple frame pointer based unwinding is done, otherwise
// the Frame Descriptor Entry (FDE) corresponding to the current frame,
// retrieved from DWARF, is used.
//
// The new set of registers is returned. it.regs is not updated, except for
// it.regs.CFA; the caller has to eventually switch it.regs when the iterator
// advances to the next frame.
func (it *stackIterator) advanceRegs() (callFrameRegs op.DwarfRegisters, ret uint64, retaddr uint64) {
if callFrameRegs, ret, retaddr, ok := it.tryFramePointerUnwind(); ok {
return callFrameRegs, ret, retaddr
}
callFrameRegs, ret, retaddr = it.advanceRegsDWARF()
if !it.canUseFP && it.err == nil {
ptrSize := uint64(it.bi.Arch.PtrSize())
stable := false
switch it.bi.Arch.Name {
case "amd64":
// AMD64: BP + 2*PtrSize == CFA
bp := it.regs.BP()
cfa := uint64(it.regs.CFA)
stable = (bp != 0 && bp+2*ptrSize == cfa)
case "arm64":
// ARM64: BP + 2*PtrSize == CFA
bp := it.regs.BP()
cfa := uint64(it.regs.CFA)
stable = (bp != 0 && bp+2*ptrSize == cfa)
// Disable hybrid FP unwinding on Windows when DW_AT_producer is missing
// or unparsable (otherwise canUseFP could activate despite the Go 1.24/1.25
// guard), and for Go 1.24/1.25 due to test failures. See golang/go#63630.
if it.bi.GOOS == "windows" {
ver := goversion.ParseProducer(it.bi.Producer())
if ver.Major < 1 || !ver.IsDevelBuild() && ver.Major == 1 && (ver.Minor == 24 || ver.Minor == 25) {
stable = false
}
}
}
if stable {
it.canUseFP = true
// AArch64: DWARF may omit X29 (BP) rules; inject savedBP when missing
if it.bi.Arch.Name == "arm64" {
bp := it.regs.BP()
if (callFrameRegs.Reg(callFrameRegs.BPRegNum) == nil || callFrameRegs.BP() == 0) && bp != 0 {
savedBP, err := readUintRaw(it.mem, bp, int64(ptrSize))
if err == nil {
callFrameRegs.AddReg(callFrameRegs.BPRegNum, op.DwarfRegisterFromUint64(savedBP))
}
}
}
}
}
return callFrameRegs, ret, retaddr
}
func (it *stackIterator) tryFramePointerUnwind() (callFrameRegs op.DwarfRegisters, ret uint64, retaddr uint64, ok bool) {
// Frame pointer unwinding is only implemented for amd64 and arm64.
if it.bi.Arch.Name != "amd64" && it.bi.Arch.Name != "arm64" {
return op.DwarfRegisters{}, 0, 0, false
}
if !it.canUseFP {
return op.DwarfRegisters{}, 0, 0, false
}
bp := it.regs.BP()
if bp == 0 {
return op.DwarfRegisters{}, 0, 0, false
}
if it.g != nil && !it.systemstack {
if bp < it.g.stack.lo || bp >= it.g.stack.hi {
return op.DwarfRegisters{}, 0, 0, false
}
}
fn := it.bi.PCToFunc(it.pc)
// Frame pointer conventions are only guaranteed for Go code.
if fn == nil || !fn.cu.isgo {
return op.DwarfRegisters{}, 0, 0, false
}
switch fn.Name {
case "runtime.asmcgocall",
"runtime.cgocallback_gofunc", "runtime.cgocallback",
"runtime.goexit", "runtime.goexit0", "runtime.goexit1",
"runtime.mstart", "runtime.mstart0", "runtime.mstart1",
"runtime.systemstack_switch",
"runtime.sigreturn", "runtime.sigtrampgo",
"runtime.sigpanic",
"crosscall2":
return op.DwarfRegisters{}, 0, 0, false
}
ptrSize := uint64(it.bi.Arch.PtrSize())
savedBP, err := readUintRaw(it.mem, bp, int64(ptrSize))
if err != nil {
return op.DwarfRegisters{}, 0, 0, false
}
retaddr = bp + ptrSize
ret, err = readUintRaw(it.mem, retaddr, int64(ptrSize))
if err != nil {
return op.DwarfRegisters{}, 0, 0, false
}
cfa := int64(bp + 2*ptrSize)
if ret == 0 || it.bi.PCToFunc(ret) == nil {
return op.DwarfRegisters{}, 0, 0, false
}
it.regs.CFA = cfa
callimage := it.bi.funcToImage(fn)
callFrameRegs = op.DwarfRegisters{
StaticBase: callimage.StaticBase,
ByteOrder: it.regs.ByteOrder,
PCRegNum: it.regs.PCRegNum,
SPRegNum: it.regs.SPRegNum,
BPRegNum: it.regs.BPRegNum,
LRRegNum: it.regs.LRRegNum,
}
callFrameRegs.AddReg(callFrameRegs.SPRegNum, op.DwarfRegisterFromUint64(uint64(cfa)))
callFrameRegs.AddReg(callFrameRegs.BPRegNum, op.DwarfRegisterFromUint64(savedBP))
if it.bi.Arch.usesLR {
callFrameRegs.AddReg(callFrameRegs.LRRegNum, op.DwarfRegisterFromUint64(ret))
}
if logflags.Stack() {
logflags.StackLogger().Debugf("advanceRegs (fp) at %#x: BP=%#x savedBP=%#x ret=%#x CFA=%#x", it.pc, bp, savedBP, ret, cfa)
}
return callFrameRegs, ret, retaddr, true
}
// advanceRegsDWARF unwinds the stack using DWARF Call Frame Information.
func (it *stackIterator) advanceRegsDWARF() (callFrameRegs op.DwarfRegisters, ret uint64, retaddr uint64) {
logger := logflags.StackLogger()
fde, err := it.bi.frameEntries.FDEForPC(it.pc)
@ -560,7 +705,7 @@ func (it *stackIterator) advanceRegs() (callFrameRegs op.DwarfRegisters, ret uin
framectx = it.bi.Arch.fixFrameUnwindContext(fctxt, it.pc, it.bi)
}
logger.Debugf("advanceRegs at %#x", it.pc)
logger.Debugf("advanceRegs (DWARF) at %#x", it.pc)
cfareg, err := it.executeFrameRegRule(0, framectx.CFA, 0)
if cfareg == nil {