Skip to content

[lldb][Windows] Fix x86_64 default unwind plan#210076

Open
charles-zablit wants to merge 2 commits into
llvm:mainfrom
charles-zablit:cz/lldb/test-stepping-trampoline
Open

[lldb][Windows] Fix x86_64 default unwind plan#210076
charles-zablit wants to merge 2 commits into
llvm:mainfrom
charles-zablit:cz/lldb/test-stepping-trampoline

Conversation

@charles-zablit

Copy link
Copy Markdown
Contributor

ABIWindows_x86_64::CreateDefaultUnwindPlan() uses a "CFA = rbp + 16" as a placeholder, however, Windows-x86_64 does not use rbp as a frame pointer. With this bogus rbp based CFA, the unwinder could not produce the caller frame, so stepping out of such a function fails with "Could not create return address breakpoint".

This patch uses the Windows-x86_64 correct rule for the fallback: the return address is at the top of the stack, so CFA = rsp + 8 and pc = [CFA - 8].

This patch also ports lang/c/trampoline_stepping from Swiftlang.

@llvmorg-github-actions

Copy link
Copy Markdown

@llvm/pr-subscribers-lldb

Author: Charles Zablit (charles-zablit)

Changes

ABIWindows_x86_64::CreateDefaultUnwindPlan() uses a "CFA = rbp + 16" as a placeholder, however, Windows-x86_64 does not use rbp as a frame pointer. With this bogus rbp based CFA, the unwinder could not produce the caller frame, so stepping out of such a function fails with "Could not create return address breakpoint".

This patch uses the Windows-x86_64 correct rule for the fallback: the return address is at the top of the stack, so CFA = rsp + 8 and pc = [CFA - 8].

This patch also ports lang/c/trampoline_stepping from Swiftlang.


Full diff: https://github.com/llvm/llvm-project/pull/210076.diff

4 Files Affected:

  • (modified) lldb/source/Plugins/ABI/X86/ABIWindows_x86_64.cpp (+2-8)
  • (added) lldb/test/API/lang/c/trampoline_stepping/Makefile (+3)
  • (added) lldb/test/API/lang/c/trampoline_stepping/TestTrampolineStepping.py (+100)
  • (added) lldb/test/API/lang/c/trampoline_stepping/main.c (+52)
diff --git a/lldb/source/Plugins/ABI/X86/ABIWindows_x86_64.cpp b/lldb/source/Plugins/ABI/X86/ABIWindows_x86_64.cpp
index 079b22a307602..be3c83880bd60 100644
--- a/lldb/source/Plugins/ABI/X86/ABIWindows_x86_64.cpp
+++ b/lldb/source/Plugins/ABI/X86/ABIWindows_x86_64.cpp
@@ -751,21 +751,15 @@ UnwindPlanSP ABIWindows_x86_64::CreateFunctionEntryUnwindPlan() {
 
 // Windows-x86_64 doesn't use %rbp
 // No available Unwind information for Windows-x86_64 (section .pdata)
-// Let's use SysV-x86_64 one for now
 UnwindPlanSP ABIWindows_x86_64::CreateDefaultUnwindPlan() {
-  uint32_t fp_reg_num = dwarf_rbp;
   uint32_t sp_reg_num = dwarf_rsp;
   uint32_t pc_reg_num = dwarf_rip;
 
   UnwindPlan::Row row;
-
-  const int32_t ptr_size = 8;
-  row.GetCFAValue().SetIsRegisterPlusOffset(dwarf_rbp, 2 * ptr_size);
   row.SetOffset(0);
   row.SetUnspecifiedRegistersAreUndefined(true);
-
-  row.SetRegisterLocationToAtCFAPlusOffset(fp_reg_num, ptr_size * -2, true);
-  row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, ptr_size * -1, true);
+  row.GetCFAValue().SetIsRegisterPlusOffset(sp_reg_num, 8);
+  row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, -8, false);
   row.SetRegisterLocationToIsCFAPlusOffset(sp_reg_num, 0, true);
 
   auto plan_sp = std::make_shared<UnwindPlan>(eRegisterKindDWARF);
diff --git a/lldb/test/API/lang/c/trampoline_stepping/Makefile b/lldb/test/API/lang/c/trampoline_stepping/Makefile
new file mode 100644
index 0000000000000..10495940055b6
--- /dev/null
+++ b/lldb/test/API/lang/c/trampoline_stepping/Makefile
@@ -0,0 +1,3 @@
+C_SOURCES := main.c
+
+include Makefile.rules
diff --git a/lldb/test/API/lang/c/trampoline_stepping/TestTrampolineStepping.py b/lldb/test/API/lang/c/trampoline_stepping/TestTrampolineStepping.py
new file mode 100644
index 0000000000000..bbf1dc8cb7fea
--- /dev/null
+++ b/lldb/test/API/lang/c/trampoline_stepping/TestTrampolineStepping.py
@@ -0,0 +1,100 @@
+"""Test that stepping in/out of trampolines works as expected."""
+
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test import lldbutil
+
+
+class TestTrampoline(TestBase):
+    def setup(self, bkpt_str):
+        self.build()
+
+        _, _, thread, _ = lldbutil.run_to_source_breakpoint(
+            self, bkpt_str, lldb.SBFileSpec("main.c")
+        )
+        return thread
+
+    def test_direct_call(self):
+        thread = self.setup("Break here for direct")
+
+        # Sanity check that we start out in the correct function.
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("direct_trampoline_call", name)
+
+        # Check that stepping in will take us directly to the trampoline target.
+        thread.StepInto()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("foo", name)
+
+        # Check that stepping out takes us back to the trampoline caller.
+        thread.StepOut()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("direct_trampoline_call", name)
+
+        # Check that stepping over the end of the trampoline target
+        # takes us back to the trampoline caller.
+        thread.StepInto()
+        thread.StepOver()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("direct_trampoline_call", name)
+
+    def test_chained_call(self):
+        thread = self.setup("Break here for chained")
+
+        # Sanity check that we start out in the correct function.
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("chained_trampoline_call", name)
+
+        # Check that stepping in will take us directly to the trampoline target.
+        thread.StepInto()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("foo", name)
+
+        # Check that stepping out takes us back to the trampoline caller.
+        thread.StepOut()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("chained_trampoline_call", name)
+
+        # Check that stepping over the end of the trampoline target
+        # takes us back to the trampoline caller.
+        thread.StepInto()
+        thread.StepOver()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("chained_trampoline_call", name)
+
+    def test_trampoline_after_nodebug(self):
+        thread = self.setup("Break here for nodebug then trampoline")
+
+        # Sanity check that we start out in the correct function.
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("trampoline_after_nodebug", name)
+
+        # Check that stepping in will take us directly to the trampoline target.
+        thread.StepInto()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("foo", name)
+
+        # Check that stepping out takes us back to the trampoline caller.
+        thread.StepOut()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("trampoline_after_nodebug", name)
+
+        # Check that stepping over the end of the trampoline target
+        # takes us back to the trampoline caller.
+        thread.StepInto()
+        thread.StepOver()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("trampoline_after_nodebug", name)
+
+    def test_unused_target(self):
+        thread = self.setup("Break here for unused")
+
+        # Sanity check that we start out in the correct function.
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("unused_target", name)
+
+        # Check that stepping into a trampoline that doesn't call its target
+        # jumps back to its caller.
+        thread.StepInto()
+        name = thread.frames[0].GetFunctionName()
+        self.assertIn("unused_target", name)
diff --git a/lldb/test/API/lang/c/trampoline_stepping/main.c b/lldb/test/API/lang/c/trampoline_stepping/main.c
new file mode 100644
index 0000000000000..cb98be00ca1f6
--- /dev/null
+++ b/lldb/test/API/lang/c/trampoline_stepping/main.c
@@ -0,0 +1,52 @@
+void foo(void) {}
+
+__attribute__((transparent_stepping))
+void bar(void) {
+  foo();
+}
+
+__attribute__((transparent_stepping))
+void baz(void) {
+  bar();
+}
+
+__attribute__((nodebug))
+void nodebug(void) {}
+
+__attribute__((transparent_stepping))
+void nodebug_then_trampoline(void) {
+  nodebug();
+  baz();
+}
+
+__attribute__((transparent_stepping))
+void doesnt_call_trampoline(void) {}
+
+void direct_trampoline_call(void) {
+  bar(); // Break here for direct 
+  bar();
+}
+
+void chained_trampoline_call(void) {
+  baz(); // Break here for chained
+  baz();
+}
+
+void trampoline_after_nodebug(void) {
+  nodebug_then_trampoline(); // Break here for nodebug then trampoline
+  nodebug_then_trampoline();
+}
+
+void unused_target(void) {
+  doesnt_call_trampoline(); // Break here for unused
+}
+
+
+int main(void) {
+  direct_trampoline_call();
+  chained_trampoline_call();
+  trampoline_after_nodebug();
+  unused_target();
+  return 0;
+}
+

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

✅ With the latest revision this PR passed the C/C++ code formatter.

@felipepiovezan

felipepiovezan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

To make sure I understand:

CFA = rsp + 8 and pc = [CFA - 8].

According to this, you are saying that CFA is one row above where the dashed line "call B" is on this picture?

image

@felipepiovezan

Copy link
Copy Markdown
Contributor

Would it also have worked to say that CFA = rsp and PC = [CFA]?


UnwindPlan::Row row;

const int32_t ptr_size = 8;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any particular reason to remove the ptr_size variable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, I copied the implementation from

UnwindPlan::Row row;
row.GetCFAValue().SetIsRegisterPlusOffset(sp_reg_num, 8);
row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, -8, false);
row.SetRegisterLocationToIsCFAPlusOffset(sp_reg_num, 0, true);

I'm happy to add it back.

row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, ptr_size * -1, true);
row.GetCFAValue().SetIsRegisterPlusOffset(sp_reg_num, 8);
row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, -8, false);
row.SetRegisterLocationToIsCFAPlusOffset(sp_reg_num, 0, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line is also interesting in that I can't find rsp on the picture I linked

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it even right to say that rsp itself is spilled on the stack at row 0?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasonmolenda maybe you can help us interpret that picture and answer this question

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied the implementation from

UnwindPlan::Row row;
row.GetCFAValue().SetIsRegisterPlusOffset(sp_reg_num, 8);
row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, -8, false);
row.SetRegisterLocationToIsCFAPlusOffset(sp_reg_num, 0, true);

As I understand, on Windows CFA = rsp+8 and pc = [CFA-8] given the following:

  1. https://llvm-mos.org/wiki/DWARF_implementation_guide#The_Canonical_Frame_Address:

On most processors, the CFA is simply "the stack pointer value when this function was called." Local variables are at negative offsets from the CFA (below it on the stack), and the return address is at a fixed position relative to the CFA.

2.https://learn.microsoft.com/en-us/cpp/build/prolog-and-epilog?view=msvc-170#prolog-code

The code for a typical prolog might be:

mov    [RSP + 8], RCX

Is it even right to say that rsp itself is spilled on the stack at row 0?

This is using the Is variant of the method, not the At variant. As I understand it, the register is not spilled on the stack (rightfully so). And that's why we don't see it in the diagram.

@jasonmolenda

Copy link
Copy Markdown
Contributor

I don't see how this can be correct, but I have zero experience with Windows. rsp is especially inflexible on x86 because the CALL/RET instructions adjust it and push/pop the return address, so I'm not sure how this could be possible. If I have local variables in a function that require stack storage, RSP is decremented to make space for them, isn't it? So when I do a CALL, the return address is saved to stack and RSP decremented again. If RSP was not decremented past the local variables, when I do a CALL, the locals would be overwritten.

e.g.

#include <stdio.h>
int main () { 
  int c = 10;
  int d = 20;
  printf ("%d %d\n", c, d);

(lldb) dis
a.out`main:
->  0x100000470 <+0>:  push   rbp
    0x100000471 <+1>:  mov    rbp, rsp
    0x100000474 <+4>:  sub    rsp, 0x10
    0x100000478 <+8>:  mov    dword ptr [rbp - 0x4], 0xa
    0x10000047f <+15>: mov    dword ptr [rbp - 0x8], 0x14
    0x100000486 <+22>: mov    esi, dword ptr [rbp - 0x4]
    0x100000489 <+25>: mov    edx, dword ptr [rbp - 0x8]
    0x10000048c <+28>: lea    rdi, [rip + 0x15]         ; "%d %d\n"
    0x100000493 <+35>: mov    al, 0x0
    0x100000495 <+37>: call   0x1000004a2               ; symbol stub for: printf

The correct unwind state of this program at every instruction point is

row[0]:    0: CFA=rsp +8 => rsp=CFA+0 rip=[CFA-8] 
row[1]:    1: CFA=rsp+16 => rbp=[CFA-16] rsp=CFA+0 rip=[CFA-8] 
row[2]:    4: CFA=rbp+16 => rbp=[CFA-16] rsp=CFA+0 rip=[CFA-8] 
row[3]:   49: CFA=rsp +8 => rsp=CFA+0 rip=[CFA-8] 

On function entry, the CFA is rsp+8, and rip is at CFA-8 -- that is, the return address is at *$rsp.

But as soon as we spill the caller's rbp, now rsp has decremented and our CFA rule changes. Then we copy rsp into rbp (to establish the frame base). Then we subtract 16 from rsp again to make room for the local variables.

It's possible for clang to do "omit frame pointer" codegen when the stack frame size is fixed, of course, e.g. this is what clang does

(lldb) dis
a.out`main:
->  0x100000470 <+0>:  push   rax
    0x100000471 <+1>:  mov    dword ptr [rsp + 0x4], 0xa
    0x100000479 <+9>:  mov    dword ptr [rsp], 0x14
[...]
    0x100000490 <+32>: call   0x10000049a               ; symbol stub for: printf
    0x100000495 <+37>: xor    eax, eax
    0x100000497 <+39>: pop    rcx
    0x100000498 <+40>: ret    

here clang pushed a scratch register to stack (rcx) as a cheaper way to decrement $sp, then it puts the two variables on the stack where it had rcx. And in the epilogue it pointlessly restores rcx from stack.

But you can see in this function, once we do the push rcx, the return address is no longer at the same $rsp offset,

(lldb) ima show-unwind -a $pc
Assembly language inspection UnwindPlan:
row[0]:    0: CFA=rsp +8 => rsp=CFA+0 rip=[CFA-8] 
row[1]:    1: CFA=rsp+16 => rsp=CFA+0 rip=[CFA-8] 
row[2]:   40: CFA=rsp +8 => rsp=CFA+0 rip=[CFA-8] 

@jasonmolenda

jasonmolenda commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

On targets that use "omit-frame-pointer" codegen, there is no valid default unwind plan, really. We need function-specific knowledge to know how much rsp was decremented during the function, to do any unwinding at all.
There is a "AtFunctionEntry" unwindplan that the ABI plugin can specify but that's about it. Is that the situation?

@jasonmolenda

Copy link
Copy Markdown
Contributor

(even when omit-frame-pointer codegen is being done, you can force a frame pointer register to be used if you use a non-fixed stack frame size. On unix alloca() will do this, or with modern C, a variable length array VLA. e.g. void foo(int size) { int array[size]; ... } the size of the stack frame is unknown at compile time, so the comiler will spill rbp, copy rsp into rbp, then decrement rsp to make room for all the locals. And in the epilogue, it restores the rbp value into rsp without needing to know the size of the stack frame. I'm sure the Windows compiler does the same. VLA stack frames are so uncommon there's no point in making a ArchDefaultUnwindPlan that assumes they're in use, though.)

@jasonmolenda

Copy link
Copy Markdown
Contributor

FTR I wouldn't be shocked if Windows codegen is omit-frame-pointer by default. On 32-bit i386 it was a real performance boost to get one more general purpose register (rbp). On 64-bit x86_64, it's a much smaller performance boost but they were probably in the habit of that codegen style already, and stuck with it. On targets like AArch64 or RISC-V, the register file is quite large and there's no benefit to omit-frame-pointer.

@charles-zablit

Copy link
Copy Markdown
Contributor Author

Would it also have worked to say that CFA = rsp and PC = [CFA]?

Yes because we are reading from the same stack slot:

CFA PC
This patch rsp + 8 pc = [CFA - 8] = [rsp]
Your suggestion rsp pc = [CFA] = [rsp]

@charles-zablit

Copy link
Copy Markdown
Contributor Author

There is a "AtFunctionEntry" unwindplan that the ABI plugin can specify but that's about it. Is that the situation?

Yes, I matched what we do here:

UnwindPlan::Row row;
row.GetCFAValue().SetIsRegisterPlusOffset(sp_reg_num, 8);
row.SetRegisterLocationToAtCFAPlusOffset(pc_reg_num, -8, false);
row.SetRegisterLocationToIsCFAPlusOffset(sp_reg_num, 0, true);
.

This effectively makes Windows equal to the AtFunctionEntry unwindplan.

I could not find any source that Windows is omit-frame-pointer by default, however, Windows x64 does not guarantee that RBP is a frame pointer:
https://learn.microsoft.com/en-us/cpp/build/x64-software-conventions?view=msvc-170#prolog-and-epilog

Register volatility and preservation
...
RBP | Nonvolatile | May be used as a frame pointer; must be preserved by callee

My understanding is that RegisterContextUnwind::GetFullUnwindPlanForFrame only falls back to the default when there is no unwinding data for the function. On Windows, that's a function which does not edit rsp, so rsp+8 is valid for the whole body and the current rbp+16 is wrong even in that case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants