diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 1923e4ae9db961..e059d7a5b9039b 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -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. @@ -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 @@ -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(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. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs index faa75b262f5563..45333f1639046d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs @@ -155,9 +155,11 @@ public TargetCodePointer GetVirtualIP(TargetPointer sp) /// /// Advances by one R2R frame and produces the caller's virtual IP, - /// mirroring WasmUnwindStackFrameCore. Returns false when the R2R walk terminates - /// (no R2R frame at ), in which case is set to - /// . + /// mirroring WasmUnwindStackFrameCore. Returns false when the R2R walk cannot advance + /// (no R2R frame at , missing unwind data, or a zero frame size), in + /// which case is set to . Otherwise + /// is the caller's stack pointer, and is null when + /// the caller is not ReadyToRun code. /// public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC, out TargetCodePointer ip) { @@ -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; } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs index 93a964213974da..b0354f3d10894d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs @@ -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)) { diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs index a09ef0fb12afe8..a590625c4d4863 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs @@ -18,7 +18,8 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; /// pointer) into the three synthetic 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 blob from the frame's TargetContext. +/// serialized blob from the frame's TargetContext, restoring +/// IP, SP and the frame pointer. /// /// Hijack frames are a debugger / GC-suspension concept that is not yet supported on WASM. /// @@ -33,6 +34,11 @@ internal sealed class WasmFrameHandler(Target target, ContextHolder 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. @@ -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 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. diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index db514222517802..20f505534f942c 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -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; @@ -1745,6 +1819,97 @@ private static TargetPointer AddWasmR2RFunctions( return runtimeFunctions.Address; } + /// + /// Native InlinedCallFrame::UpdateRegDisplay_Impl returns before recording the owning + /// InterpreterFrame when the frame has no active call. + /// + [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 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(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); + } + + /// + /// 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. + /// + [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.SoftwareExceptionFrame] = new() + { + Fields = new Dictionary + { + [nameof(Data.SoftwareExceptionFrame.ReturnAddress)] = new() { Offset = ReturnAddressOffset, TypeName = "CodePointer" }, + [nameof(Data.SoftwareExceptionFrame.TargetContext)] = new() { Offset = TargetContextOffset }, + }, + }, + [DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary() }, + }); + }); + + ContextHolder context = new(); + context.Context.FramePointer = new TargetPointer(0x0007_0000); + FrameHelpers frameHelpers = new(target); + Data.Frame frame = target.ProcessedData.GetOrAdd(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 diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index 532f67ee0e6130..c53fa9e4598a14 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -227,6 +227,36 @@ public void TryUnwindOneFrame_AdvancesBySingleByteFrameSize_AndYieldsCallerVirtu Assert.Equal(VirtualIpBase + 14, ip.Value); // caller local VIP 7*2 } + [Fact] + public void WasmContext_UnwindCallerWithoutVirtualIp_PreservesStackPointerAndClearsIPAndFP() + { + const uint leafFrameSize = 0x20; + ulong callerBase = FramesBase + leafFrameSize; + + FakeWasmR2RInfo info = new(); + info.UnwindData[LeafControlPc] = BlobsBase; + + TestPlaceholderTarget target = CreateTarget( + [ + Frame(FramesBase, FuncIndexLeaf, 3, "leaf"), + Frame(callerBase, FuncIndexCaller, 7, "nonR2RCaller"), + Blob(BlobsBase, [(byte)leafFrameSize], "leafUnwind"), + ]); + WasmUnwinder unwinder = new(target, info); + WasmContext context = new() + { + StackPointer = new TargetPointer(FramesBase), + InstructionPointer = new TargetCodePointer(LeafControlPc), + FramePointer = new TargetPointer(FramesBase), + }; + + context.Unwind(unwinder); + + Assert.Equal(callerBase, context.StackPointer.Value); + Assert.Equal(TargetCodePointer.Null, context.InstructionPointer); + Assert.Equal(TargetPointer.Null, context.FramePointer); + } + [Fact] public void TryUnwindOneFrame_DecodesMultiByteFrameSize() {