Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions docs/design/datacontracts/StackWalk.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ Most of the handlers are implemented in `BaseFrameHandler`. Platform specific co

#### InlinedCallFrame

InlinedCallFrames store and update only the IP, SP, and FP of a given context. If the stored IP (CallerReturnAddress) is 0 then the InlinedCallFrame does not have an active call and should not update the context.
InlinedCallFrames store and update only the IP, SP, and FP of a given context. If the stored IP (CallerReturnAddress) is 0 then the InlinedCallFrame does not have an active call and should not update the context. On WASM this includes recording the following `InterpreterFrame` for the interpreter unwind.

* On ARM, the InlinedCallFrame stores the value of the SP after the prolog (`SPAfterProlog`) to allow unwinding for functions with stackalloc. When a function uses stackalloc, the CallSiteSP can already have been adjusted. This value should be placed in R9.
* On WASM, a `CallerReturnAddress` of `INLINED_PINVOKE_FROM_R2R` (`1`) marks an active inlined P/Invoke from ReadyToRun code rather than an address. SP is taken from `CallSiteSP`, IP is the R2R virtual IP of the shadow frame at `CallSiteSP`, and FP is the WASM logical frame pointer at `CallSiteSP` (see below). If no virtual IP can be recovered, IP is set to null.
Expand All @@ -463,6 +463,8 @@ An active InlinedCallFrame stays the current Frame after its context update so t

SoftwareExceptionFrames store a copy of the context struct. The IP, SP, and all ABI specified (platform specific) callee-saved registers are copied from the stored context to the working context.

On WASM the only callee-saved register is the frame pointer (`InterpreterFP`), and the `CalleeSavedRegisters` data descriptor is empty, so the IP, SP and FP are copied explicitly from the stored context (native `SoftwareExceptionFrame::UpdateRegDisplay_Impl`).

**Return Address**: Read from the `ReturnAddress` field on the frame.

#### TransitionFrame
Expand All @@ -488,7 +490,27 @@ A funclet instead stores its establishing-frame pointer plus two, giving the ide

**WASM R2R unwinding.** The virtual IP is the identity-derived method base plus twice the physical frame's stored function-local value. `WasmContext.Unwind` passes its current virtual IP as the control PC to `IExecutionManager.TryGetUnwindInfo`, reads the fixed frame size as a uint32 ULEB128 value from `unwindInfoBaseAddress + RuntimeFunction.UnwindData`, and adds that size to the physical frame base. A zero frame size terminates the R2R walk because it would make no progress; malformed uint32 ULEB128 data is invalid.

For a managed caller, the resulting stack pointer identifies the caller's R2R frame record; its virtual IP and logical frame pointer are resolved as above. When the current frame has a positive odd reverse-P/Invoke identity, unwinding still preserves the advanced caller SP for the explicit `Frame` chain, but leaves IP and FP null because the caller is native. At a terminator or another non-R2R caller, the R2R context is cleared and walking continues through the explicit `Frame` or interpreter frame chain.
For a managed caller, the resulting stack pointer identifies the caller's R2R frame record; its virtual IP and logical frame pointer are resolved as above. When the current frame has a positive odd reverse-P/Invoke identity, unwinding still preserves the advanced caller SP for the explicit `Frame` chain, but leaves IP and FP null because the caller is native. When the resulting stack pointer has no R2R frame record (a terminator, an interpreter transition or the stack top), the caller is not R2R code: the advanced SP is kept, IP and FP are null, and walking continues through the explicit `Frame` or interpreter frame chain. The R2R walk is cleared (SP, IP and FP all null) only when the current frame cannot be unwound at all: no R2R frame at SP, no unwind data, or a zero frame size.

```csharp
// WasmContext.Unwind: one R2R frame, mirroring native RtlVirtualUnwind / WasmUnwindStackFrameCore.
void Unwind()
{
TargetPointer frameBase = /* frame base of the R2R frame at SP */;
uint identity = target.Read<uint>(frameBase + FrameIdentityOffset);
uint frameSize = /* ULEB128 in the unwind data of the function containing IP */;
if (/* no frame at SP, no unwind data, or frameSize == 0 */)
{
SP = IP = FP = null;
return;
}

SP = frameBase + frameSize;
bool callerIsNative = identity is odd && (int)identity > 0; // reverse P/Invoke root
IP = callerIsNative ? null : /* R2R virtual IP at SP, or null when SP has no R2R frame record */;
FP = IP != null ? /* WASM logical frame pointer at SP */ : null;
}
```

**Return Address**: Read from `TransitionBlock.ReturnAddress`. On WASM, it is derived from `TransitionBlock.StackPointer` when it is 0, as above. For an x86 `StubDispatchFrame` with neither a method nor a GCRefMap, subtract five bytes to return the adjusted call address, matching the context update.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,11 @@ public TargetCodePointer GetVirtualIP(TargetPointer sp)

/// <summary>
/// Advances <paramref name="sp"/> by one R2R frame and produces the caller's virtual IP,
/// mirroring <c>WasmUnwindStackFrameCore</c>. Returns false when the R2R walk terminates
/// (no R2R frame at <paramref name="sp"/>), in which case <paramref name="sp"/> is set to
/// <see cref="TargetPointer.Null"/>.
/// mirroring <c>WasmUnwindStackFrameCore</c>. Returns false when the R2R walk cannot advance
/// (no R2R frame at <paramref name="sp"/>, missing unwind data, or a zero frame size), in
/// which case <paramref name="sp"/> is set to <see cref="TargetPointer.Null"/>. Otherwise
/// <paramref name="sp"/> is the caller's stack pointer, and <paramref name="ip"/> is null when
/// the caller is not ReadyToRun code.
/// </summary>
public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC, out TargetCodePointer ip)
{
Expand Down Expand Up @@ -192,14 +194,9 @@ public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC,
return true;
}

// A null IP means the caller is not R2R code (an interpreter transition or the stack top).
// Like native, keep the caller's SP so the walk continues through the Frame chain.
ip = GetVirtualIP(sp);
if (ip == TargetCodePointer.Null)
{
// The caller is not R2R-generated code (an interpreter transition or the stack top);
// the R2R walk is exhausted.
sp = TargetPointer.Null;
return false;
}

return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ public void Unwind(Target target)

internal void Unwind(Wasm.WasmUnwinder unwinder)
{
// Advance one ReadyToRun frame over the managed linear stack. When the R2R walk
// terminates (an interpreter transition or the stack top), StackPointer becomes null and
// the caller falls back to the explicit Frame chain / interpreter frame chain.
// Advance one ReadyToRun frame over the managed linear stack. When the caller is not R2R
// code (an interpreter transition, native code above a reverse P/Invoke, or the stack top),
// StackPointer is the caller's SP and the IP and FP are null; the walker continues through
// the explicit Frame chain / interpreter frame chain.
TargetPointer sp = StackPointer;
if (unwinder.TryUnwindOneFrame(ref sp, InstructionPointer, out TargetCodePointer ip))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers;
/// pointer) into the three synthetic <see cref="WasmContext"/> slots, which is the common
/// P/Invoke-boundary seeding path; an inlined P/Invoke from R2R code instead stores a marker and is
/// resolved from its R2R shadow frame. The software/faulting exception frame handlers likewise read a
/// serialized <see cref="WasmContext"/> blob from the frame's <c>TargetContext</c>.
/// serialized <see cref="WasmContext"/> blob from the frame's <c>TargetContext</c>, restoring
/// IP, SP and the frame pointer.
///
/// Hijack frames are a debugger / GC-suspension concept that is not yet supported on WASM.
/// </remarks>
Expand All @@ -33,6 +34,11 @@ internal sealed class WasmFrameHandler(Target target, ContextHolder<WasmContext>

public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame)
{
// Native InlinedCallFrame::UpdateRegDisplay_Impl returns before any update, including the
// interpreter-frame stash below, when the frame has no active call.
if (inlinedCallFrame.CallerReturnAddress == TargetCodePointer.Null)
return;

if (inlinedCallFrame.CallerReturnAddress.Value == InlinedPInvokeFromR2R)
{
// Mirrors InlinedCallFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp.
Expand Down Expand Up @@ -64,6 +70,18 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame)
}
}

// Native restores FP with IP and SP, but the CalleeSavedRegisters descriptor is empty on WASM,
// so the base handler would leave FP stale.
public override void HandleSoftwareExceptionFrame(SoftwareExceptionFrame softwareExceptionFrame)
{
ContextHolder<WasmContext> saved = new();
saved.ReadFromAddress(_target, softwareExceptionFrame.TargetContext);

_holder.Context.InstructionPointer = saved.Context.InstructionPointer;
_holder.Context.StackPointer = saved.Context.StackPointer;
_holder.Context.FramePointer = saved.Context.FramePointer;
}

// Mirrors TransitionFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. With a recorded
// R2R stack pointer and a known return address, the caller is the R2R frame at that stack pointer
// (TransitionFrame::GetSP); otherwise the caller's stack pointer is the end of the TransitionBlock.
Expand Down
165 changes: 165 additions & 0 deletions src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,80 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC
Assert.True(walked.Length <= 7, $"Walk did not terminate: {walked.Length} frames");
}

// Unwinding keeps the caller's SP when the caller is not R2R, so the walk passes the
// InlinedCallFrame in the method's own frame and reports the method once.
[Fact]
public void CreateStackWalk_WasmR2RMethodWithNonR2RCaller_PassesInlinedCallFrameInItsFrame()
{
MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false };
const uint FunctionTableIndex = 5;
const ulong MinVirtualIP = 0x8001_0000;
const uint FunctionBeginAddress = 0x100;
const uint LocalVirtualIPHalf = 3;
const byte R2RFrameSize = 0x70;
const ulong R2RIp = MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2;
const ulong InlinedPInvokeFromR2R = 1;
const uint TerminateR2RStackWalk = 1;

MockThread? thread = null;
MockInlinedCallFrame? icf = null;
int icfSize = 0;
ulong r2rFrame = 0;
TargetPointer runtimeFunctionAddress = TargetPointer.Null;
TestPlaceholderTarget target = CreateTarget(
wasmArch,
threadBuilder => thread = threadBuilder.AddThread(1, 1234),
frameBuilder =>
{
icf = frameBuilder.AddInlinedCallFrame(InlinedPInvokeFromR2R, datum: 0);
icfSize = frameBuilder.InlinedCallFrameLayout.Size;
},
runtimeArchitecture: RuntimeInfoArchitecture.Wasm,
configureTarget: targetBuilder =>
{
TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers;
MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000);
runtimeFunctionAddress = AddWasmR2RFunction(targetBuilder, allocator, FunctionTableIndex, MinVirtualIP, FunctionBeginAddress, R2RFrameSize);
uint frameIdentity = AddWasmPortableEntryPoint(targetBuilder, allocator, FunctionTableIndex);
AddWasmNullDebugger(targetBuilder, allocator);

// The method's frame record sits just below the InlinedCallFrame, and its caller's
// stack pointer is above it. The caller slot holds TERMINATE_R2R_STACK_WALK, so the
// caller has no R2R virtual IP.
r2rFrame = icf!.Address - 0x10;
ulong callerSp = r2rFrame + R2RFrameSize;
Assert.True(icf.Address + (ulong)icfSize <= callerSp);

byte[] record = new byte[8];
helpers.Write(record.AsSpan(0, sizeof(uint)), frameIdentity);
helpers.Write(record.AsSpan(4, sizeof(uint)), LocalVirtualIPHalf);
targetBuilder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = r2rFrame, Data = record, Name = "R2RFrame" });

byte[] callerRecord = new byte[8];
helpers.Write(callerRecord.AsSpan(0, sizeof(uint)), TerminateR2RStackWalk);
targetBuilder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = callerSp, Data = callerRecord, Name = "NonR2RCaller" });

icf.CallSiteSP = r2rFrame;
thread!.Frame = icf.Address;
},
executionManager: CreateInterpreterExecutionManager(
[],
managedIps: [R2RIp],
unwindInfo: () => (runtimeFunctionAddress, TargetPointer.Null)));

IStackWalk stackWalk = target.Contracts.StackWalk;
ThreadData threadData = target.Contracts.Thread.GetThreadData(new TargetPointer(thread!.Address));
IStackDataFrameHandle[] walked = stackWalk.CreateStackWalk(threadData).Take(16).ToArray();

(ulong Ip, ulong Sp)[] frameless = walked
.Where(f => f.State == StackWalkState.Frameless)
.Select(f => (stackWalk.GetInstructionPointer(f).Value, stackWalk.GetStackPointer(f).Value))
.ToArray();

Assert.Equal([(R2RIp, r2rFrame)], frameless);
Assert.True(walked.Length <= 3, $"Walk did not terminate: {walked.Length} frames");
}

private sealed class InterpreterExecutionManager : IExecutionManager
{
private readonly ulong[] _interpreterIps;
Expand Down Expand Up @@ -1745,6 +1819,97 @@ private static TargetPointer AddWasmR2RFunctions(
return runtimeFunctions.Address;
}

/// <summary>
/// Native InlinedCallFrame::UpdateRegDisplay_Impl returns before recording the owning
/// InterpreterFrame when the frame has no active call.
/// </summary>
[Fact]
public void UpdateContextFromFrame_WasmInactiveInlinedCallFrameOverInterpreterFrame_LeavesContextUnchanged()
{
MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false };

ulong icfAddr = 0;
TestPlaceholderTarget target = CreateTarget(
wasmArch,
threadBuilder => threadBuilder.AddThread(1, 1234),
frameBuilder =>
{
ulong interpAddr = frameBuilder.AddFrame(MockFrameBuilder.InterpreterFrameIdentifierValue, "InterpreterFrame").Address;
MockInlinedCallFrame icf = frameBuilder.AddInlinedCallFrame(callerReturnAddress: 0, datum: 0, callSiteSP: 0x0004_1000);
icf.Next = interpAddr;
icfAddr = icf.Address;
});

ContextHolder<WasmContext> context = new();
context.Context.StackPointer = new TargetPointer(0x0005_0000);
context.Context.InstructionPointer = new TargetCodePointer(0x0005_1000);
context.Context.FramePointer = new TargetPointer(0x0005_2000);
FrameHelpers frameHelpers = new(target);
Data.Frame frame = target.ProcessedData.GetOrAdd<Data.Frame>(icfAddr);
frameHelpers.UpdateContextFromFrame(frame, context);

Assert.True(context.TryReadRegister(WasmContext.InterpreterWalkFramePointerRegister, out TargetNUInt stashed));
Assert.Equal(0ul, stashed.Value);
Assert.Equal(0x0005_0000ul, context.StackPointer.Value);
Assert.Equal(0x0005_1000ul, context.InstructionPointer.Value);
Assert.Equal(0x0005_2000ul, context.FramePointer.Value);
}

/// <summary>
/// On WASM the callee-saved register set is InterpreterFP, so a SoftwareExceptionFrame restores
/// the frame pointer from its saved context along with IP and SP (native
/// SoftwareExceptionFrame::UpdateRegDisplay_Impl). WASM's CalleeSavedRegisters descriptor is
/// empty, so the frame pointer must not be left from the previous frame.
/// </summary>
[Fact]
public void UpdateContextFromFrame_WasmSoftwareExceptionFrame_RestoresFramePointer()
{
MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false };
TargetTestHelpers helpers = new(wasmArch);
const ulong FrameAddress = 0x0006_0000;
const int ReturnAddressOffset = 8;
const int TargetContextOffset = 12;

// WasmContext layout: ContextFlags, InterpreterWalkFramePointer, InterpreterSP, InterpreterFP, InterpreterIP.
byte[] data = new byte[TargetContextOffset + 5 * sizeof(uint)];
helpers.Write(data.AsSpan(0, sizeof(uint)), (uint)MockFrameBuilder.SoftwareExceptionFrameIdentifierValue);
helpers.Write(data.AsSpan(TargetContextOffset + 8, sizeof(uint)), 0x0004_1000u);
helpers.Write(data.AsSpan(TargetContextOffset + 12, sizeof(uint)), 0x0004_1040u);
helpers.Write(data.AsSpan(TargetContextOffset + 16, sizeof(uint)), 0x8001_0106u);

TestPlaceholderTarget target = CreateTarget(
wasmArch,
threadBuilder => threadBuilder.AddThread(1, 1234),
frameBuilder => { },
runtimeArchitecture: RuntimeInfoArchitecture.Wasm,
configureTarget: builder =>
{
builder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = FrameAddress, Data = data, Name = "SoftwareExceptionFrame" });
builder.AddTypes(new Dictionary<DataType, Target.TypeInfo>
{
[DataType.SoftwareExceptionFrame] = new()
{
Fields = new Dictionary<string, Target.FieldInfo>
{
[nameof(Data.SoftwareExceptionFrame.ReturnAddress)] = new() { Offset = ReturnAddressOffset, TypeName = "CodePointer" },
[nameof(Data.SoftwareExceptionFrame.TargetContext)] = new() { Offset = TargetContextOffset },
},
},
[DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary<string, Target.FieldInfo>() },
});
});

ContextHolder<WasmContext> context = new();
context.Context.FramePointer = new TargetPointer(0x0007_0000);
FrameHelpers frameHelpers = new(target);
Data.Frame frame = target.ProcessedData.GetOrAdd<Data.Frame>(new TargetPointer(FrameAddress));
frameHelpers.UpdateContextFromFrame(frame, context);

Assert.Equal(0x0004_1000ul, context.StackPointer.Value);
Assert.Equal(0x8001_0106ul, context.InstructionPointer.Value);
Assert.Equal(0x0004_1040ul, context.FramePointer.Value);
}

// When an active InlinedCallFrame is directly followed by an InterpreterFrame, WasmFrameHandler
// stashes the InterpreterFrame address into the synthetic first-argument register
// (InterpreterWalkFramePointer) so the subsequent interpreter virtual unwind can recover the
Expand Down
Loading
Loading