[clr-interp] Support for breakpoints and stepping#123251
[clr-interp] Support for breakpoints and stepping#123251matouskozak wants to merge 28 commits intodotnet:mainfrom
Conversation
ed5ce4c to
dce1adc
Compare
There was a problem hiding this comment.
Pull request overview
This work-in-progress pull request adds support for managed debugger breakpoints in the CoreCLR interpreter. The changes extend the existing user breakpoint support (e.g., Debugger.Break()) to support IDE breakpoints and enable setting breakpoints when the program is stopped.
Changes:
- Adds interpreter single-step thread state flag and supporting methods
- Introduces new
INTOP_SINGLESTEPopcode for step-over operations - Implements
InterpreterWalkerto analyze interpreter bytecode for debugger stepping - Modifies breakpoint execution logic to distinguish between IDE breakpoints and step-out breakpoints
- Enables JIT completion notifications for interpreter code
- Pre-inserts IL offset 0 entry in the IL-to-native map to support method entry breakpoints
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/coreclr/vm/threads.h | Adds TSNC_InterpreterSingleStep thread state flag and related methods |
| src/coreclr/vm/jitinterface.cpp | Removes interpreter code exclusion from JITComplete notifications |
| src/coreclr/vm/interpexec.cpp | Implements breakpoint and single-step handling with opcode replacement |
| src/coreclr/vm/codeman.h | Adds IsInterpretedCode() helper method |
| src/coreclr/interpreter/intops.h | Adds helper functions to classify interpreter opcodes |
| src/coreclr/interpreter/inc/intops.def | Defines INTOP_SINGLESTEP opcode |
| src/coreclr/interpreter/compiler.cpp | Pre-inserts IL offset 0 mapping for method entry breakpoints |
| src/coreclr/debug/ee/interpreterwalker.h | Declares InterpreterWalker class for bytecode analysis |
| src/coreclr/debug/ee/interpreterwalker.cpp | Implements bytecode walker for debugger stepping operations |
| src/coreclr/debug/ee/functioninfo.cpp | Uses GetInterpreterCodeFromInterpreterPrecodeIfPresent for code address |
| src/coreclr/debug/ee/executioncontrol.h | Defines BreakpointInfo structure and GetBreakpointInfo method |
| src/coreclr/debug/ee/executioncontrol.cpp | Implements INTOP_SINGLESTEP patch support and breakpoint info retrieval |
| src/coreclr/debug/ee/controller.h | Includes interpreterwalker.h header |
| src/coreclr/debug/ee/controller.cpp | Implements TrapStep for interpreter using InterpreterWalker |
| src/coreclr/debug/ee/CMakeLists.txt | Adds interpreterwalker source files to build |
src/coreclr/debug/ee/controller.cpp
Outdated
| { | ||
| // Indirect call (CALLVIRT, CALLI, CALLDELEGATE) - cannot determine target statically | ||
| // Use JMC backstop to catch method entry | ||
| // TODO: Could we do better? Why we can't use StubManagers to trace indirect calls? |
There was a problem hiding this comment.
You can StubManagers if there is one that recognizes the code pattern being used to make the indirect call. If the indirect call doesn't need too many instructions to reach the destination you could also enable single-stepping and get there that way.
| patch->opcode = currentOpcode; // Save original opcode | ||
|
|
||
| // Check if this is a single-step patch by looking at the controller's thread's interpreter SS flag. | ||
| Thread* pThread = patch->controller->GetThread(); |
There was a problem hiding this comment.
I think of this interface as being an abstraction that mirrors how the debugger would normally interact with hardware. By default it would be ApplyPatch(address) and the native implementation would be something like:
VirtualProtect(address, ...); // make it writable
CORDbgInsertBreakpoint(address);
VirtualProtect(address, ...); // make it readonly again
Maybe we'd make it a little more sophisticated so that ApplyPatch is also responsible for saving the old opcode in an output parameter. I would not expect this method to get access to the complete DebuggerPatch object or for the implementation to look at fields like patch->controller. If the debugger needs to do single stepping I'd expect either:
- The debugger uses an explicit API different from this one to turn single stepping on. The interpreter could use special opcodes as an implementation detail if it wanted to, but it would be responsible for doing all the bookkeeping on its own (without using DebuggerPatchTable).
- The debugger emulates single-stepping behavior using an IExecutionControl API that only has breakpoints. In that case the debugger would call this API to set a INTOP_BREAKPOINT instruction. Later when the interpretter sends back the BreakpointHit callback the debugger code would be responsible for looking up the patch at that address and interpretting the breakpoint as the completion of a single step operation. Other architectures do that single step emulation by copying the instruction to a separate buffer but it could also be done inline if the interpretter is going to have a special feature that enables executing the original opcode on a per-thread basis.
There was a problem hiding this comment.
The debugger emulates single-stepping behavior using an IExecutionControl API that only has breakpoints. In that case the debugger would call this API to set a INTOP_BREAKPOINT instruction.
Makes sense, I refactored the code to only use INTOP_BREAKPOINT.
Later when the interpretter sends back the BreakpointHit callback the debugger code would be responsible for looking up the patch at that address and interpretting the breakpoint as the completion of a single step operation.
I think this should happen naturally and get handled in
runtime/src/coreclr/debug/ee/controller.cpp
Lines 7420 to 7449 in 35b1dd2
Other architectures do that single step emulation by copying the instruction to a separate buffer but it could also be done inline if the interpretter is going to have a special feature that enables executing the original opcode on a per-thread basis.
I've added a thread storage for the original opcode which gets retrieved during the interpreter execution loop and re-executed when needed.
Does this align with what you had in mind?
There was a problem hiding this comment.
a thread storage for the original opcode which gets retrieved during the interpreter execution loop
You can have multiple threads running the same interpreter code. What happens when a second thread hits a breakpoint and the original code is saved in a thread local storage of some other thread?
There was a problem hiding this comment.
My understanding is, that if non-stepping threads hits INTOP_BREAKPOINT, they would still call to debugger but they will NOT trigger MatchPatch flow which calls TriggerPatch which sends debugger notification (only the thread which is stepping should do that), instead it will jump to ActivatePatchSkip which sets the bypass opcode (reads it from the patch) and allows non-stepping threads to continue execution.
It's not the most efficient solution, we could optimize this by having some kind of book-keeping solution with {address, original opcode} on the side which would be accessible in the interpreter execution loop to fast track non-stepping threads bypass flow.
There was a problem hiding this comment.
It's not the most efficient solution
I wouldn't worry about optimizing it, the performance requirements for breakpoint bypass aren't very high. At the end if we can use VS to do ~10 steps/sec thats a good speed. (And even if we can't breakpoint bypass is unlikely to be the bottleneck)
src/coreclr/vm/interpexec.cpp
Outdated
| InterpBreakpoint(ip, pFrame, stack, pInterpreterFrame); | ||
| break; | ||
| Thread* pThread = GetThread(); | ||
| bpInfo = execControl->GetBreakpointInfo(ip); |
There was a problem hiding this comment.
I think we want to get rid of this GetBreakpointInfo() call. Ideally at the interpreter layer all breakpoints will be handled uniformly. To get there:
- bpInfo.isStepOut - The interpreter shouldn't be responsible for figuring out what a breakpoint is used for. I see this flag is being used to control some IP adjustment implicitly via the FaultingExceptionFrame but I don't think we want the interpreter doing that either. Instead we can probably just make a contract that the interpreter always reports the IP as being the address where the breakpoint was set. If the debugger needs to sometimes adjust the IP and sometimes not it can be responsible to figure out which breakpoint addresses need that treatment after receiving the Breakpoint callback. It already has access to all the same info this GetBreakpointInfo() API is looking up. Fwiw, I don't yet understand why an adjustment is needed in this case or why it would be conditional, but if there is something conditional that needs to be done the debugger code seems like the right place for it.
- bpInfo.originalOpcode - We probably need a more generalized mechanism for the debugger to tell the interpreter when to execute the original opcode. For example we could have some special fields in the CONTEXT only used by interpreter that stores an address and an opcode. The meaning of those fields would be "if the opcode at address X is a breakpoint, execute opcode Y instead". This allows the debugger to enable the breakpoint bypass behavior at any time, not just after a breakpoint was previously hit. The debugger doesn't always skip breakpoints immediately after hitting them. We might need to define some explicit InterpretterCONTEXT or Get/SetXYZ() functions that define where these fields get placed in the CONTEXT data blob.
There was a problem hiding this comment.
For example we could have some special fields in the CONTEXT only used by interpreter that stores an address and an opcode. The meaning of those fields would be "if the opcode at address X is a breakpoint, execute opcode Y instead". This allows the debugger to enable the breakpoint bypass behavior at any time, not just after a breakpoint was previously hit. The debugger doesn't always skip breakpoints immediately after hitting them. We might need to define some explicit InterpretterCONTEXT or Get/SetXYZ() functions that define where these fields get placed in the CONTEXT data blob.
@noahfalk Do we need to keep multiple address + opcode pairs or is one enough? I'm thinking that we could just record the current address + opcode when we call ActivatePatchSkip but not sure if there is a scenario where this would break.
If we don't need more than one pair, I was thinking we could store it on the thread like thread->SetInterpreterBypass((const int32_t*)PC, (int32_t)patch->opcode); and then check in interpexec we would just get the opcode and execute it.
There was a problem hiding this comment.
I'm ~75% confident that storing a single address will be sufficient
- New InterpreterWalker class decodes bytecode control flow for stepping - Update TrapStep to use InterpreterWalker for interpreted code - Add per-thread TSNC_InterpreterSingleStep flag for step tracking - ApplyPatch now uses INTOP_SINGLESTEP vs INTOP_BREAKPOINT based on flag - Handle INTOP_SINGLESTEP in interpreter execution loop
- needed for step-in support in virtual calls
src/coreclr/vm/interpexec.h
Outdated
| const int32_t *ip; // This ip is updated only when execution can leave the frame | ||
| PTR_InterpMethodContextFrame pNext; | ||
|
|
||
| #if defined(DEBUGGING_SUPPORTED) && !defined(TARGET_BROWSER) |
There was a problem hiding this comment.
Are these TARGET_BROWSER ifdefs still needed? I would expect that the CORDebuggerAttached checks should take care of wasm.
If they are still needed for some reason, should it be TARGET_WASM instead? I would expect that the problem is wasm specific, not browser specific.
There was a problem hiding this comment.
They are no longer needed. Previously, wasm wasn't linking the debugger files but that appears to be fixed now. Removed && !defined(TARGET_BROWSER).
Edit: actually some are still needed because of the wasm exclusion
1d33785 to
a020a26
Compare
src/coreclr/debug/ee/controller.cpp
Outdated
| { | ||
| // Indirect call - enable JMC backstop | ||
| LOG((LF_CORDB,LL_INFO10000,"DS::TICS: Indirect call, enabling MethodEnter backstop\n")); | ||
| EnableJMCBackStop(info->m_activeFrame.md); |
There was a problem hiding this comment.
Is it possible that this indirect call will lead to a jitted or R2R method instead of an interpretted one? If so we need a tracking item to make this more robust because not all jitted/R2R code will emit the MethodEnter callback.
There was a problem hiding this comment.
Created a tracking issue to support mixed mode debugging #124547. For the moment, we are not planning to support stepping from interpreter to R2R code in .NET 11 but good to track this for future work if we decide to enable it.
| } | ||
|
|
||
| // Assume controller lock is held by caller | ||
| bool InterpreterExecutionControl::ApplyPatch(DebuggerControllerPatch* patch) |
There was a problem hiding this comment.
Doesn't have to be in this PR, but I'd prefer if these ExecutionControl methods don't have DebuggerControllerPatch as the parameter to make the interface clearer. Something like:
void ApplyPatch(address, [out] originalOpcode)
void UnapplyPatch(address, originalOpcode)
void BypassPatch(context, address, originalOpcode)
| virtual bool UnapplyPatch(DebuggerControllerPatch* patch) override; | ||
|
|
||
| // Set bypass on the InterpMethodContextFrame so the interpreter executes | ||
| // the original opcode instead of re-hitting the breakpoint. |
There was a problem hiding this comment.
This comment should clarify that the bypass is automatically cleared after the first time the breakpoint is bypassed.
| InterpMethodContextFrame *pFrame = (InterpMethodContextFrame *)GetSP(filterCtx); | ||
| _ASSERTE(pFrame != NULL); | ||
|
|
||
| pFrame->SetBypass((const int32_t*)patch->address, (int32_t)patch->opcode); |
There was a problem hiding this comment.
My understanding is that a bypass is set on the frame triggering a breakpoint. Once we continue execution, we immediately clear the bypass. Does this mean we can have at most one bypass, per thread, at any given time ? In that case it seems that we could move the fields to some less hot structure (like InterpThreadContext) ?
There was a problem hiding this comment.
There are a few places where debugger modifies CONTEXT (SetIP, func eval) so if we would use thread context they would need to save and restore the thread data in addition to handling the context. However, both setip and func eval currently don't work for interpreter so they will possibly require interpreter specific changes anyways. I originally had it as part of InterpThreadContext but later moved to use InterpMethodContextFrame to align with rest of debugger. If it would cause a perf issues I can move it back to InterpThreadContext and revisit this when addressing setip and func eval functionality.
@BrzVlad Is the concern the extra added fields to InterpMethodContextFrame and increased size?
There was a problem hiding this comment.
Yes, that was the primary concern. But also from a conceptual standpoint, where we are allocating these slots in common frame allocation structure, for them to pretty much never be used. And if only one such pair can be in use at one time, it might add confusion to have an unlimited number of these pair reserved (one per each interp frame).
There was a problem hiding this comment.
I moved it to InterpThreadContext as it currently doesn't introduce any extra cost on the debugger side. We can revisit moving it elsewhere if the debugger side of things became too complex because of this. fyi: @noahfalk
src/coreclr/interpreter/compiler.cpp
Outdated
| { | ||
| // In debug builds, emit INTOP_NOP to give sequence points unique native offsets | ||
| // for proper debugger stepping on `{` and `}` braces | ||
| if (ins->opcode == INTOP_NOP && m_corJitFlags.IsSet(CORJIT_FLAGS::CORJIT_FLAG_DEBUG_CODE)) |
There was a problem hiding this comment.
Is this duplicating logic from normal code emit ? Also, if we are actually ending up generating code, then this opcode doesn't really seem to be an emit nop. Would a better approach be to have a new placeholder opcode, that we emit instead for CEE_NOP ? Then the offset mapping happens implicitly with existing code.
There was a problem hiding this comment.
Replaced with INTOP_DEBUG_SEQ_POINT which made it much cleaner. Thanks.
| @@ -1186,8 +1008,11 @@ void InterpExecMethod(InterpreterFrame *pInterpreterFrame, InterpMethodContextFr | |||
| // It will be useful for testing e.g. the debug info at various locations in the current method, so let's | |||
| // keep it for such purposes until we don't need it anymore. | |||
| pFrame->ip = (int32_t*)ip; | |||
|
|
|||
| switch (*ip) | |||
| opcode = ip[0]; | |||
There was a problem hiding this comment.
We have a bunch of places in the exec loop where we use *ip again, inside switch cases. My assumption is that, when a breakpoint is added, the interpreter execution is already suspended. Also, I don't believe we actually have suspension points between switch (*ip) and the following *ip uses. However, this seems somewhat fragile and bound to change. I believe it would make sense to conservatively replace other uses of *ip with opcode so we don't risk them incorrectly observe a potentially added INTOP_BREAKPOINT that would break the opcode execution logic.
There was a problem hiding this comment.
Replaced the ocurrances of *ip with opcode.
src/coreclr/vm/interpexec.cpp
Outdated
| // Notify debugger of method entry for step-in support for indirect calls. | ||
| if (CORDebuggerAttached() && g_pDebugInterface != NULL && g_pDebugInterface->IsMethodEnterEnabled()) | ||
| { | ||
| g_pDebugInterface->OnMethodEnter((void*)ip); |
There was a problem hiding this comment.
I don't remember, was there a reason for this not being a dedicated opcode at the start of all methods, when debugging is enabled ? We would remove the need for any checks when there is no debugger. I'm assuming we would have the same runtime binary for app debug/release builds, right ? So DEBUGGING_SUPPORTED would always be defined in production ?
There was a problem hiding this comment.
I've previously hit an issue with this approach where the reported ip was not pointing to method entry, I've since solved the issue. Please take a look.
src/coreclr/debug/ee/controller.cpp
Outdated
| case InterpreterStepHelper::SSR_NeedStepIn: | ||
| // Handle step-in for calls | ||
| if (pCallTarget != NULL) | ||
| { |
There was a problem hiding this comment.
I'm wondering, if we already have a working mechanism for indirect calls, whether it is beneficial to have this separate direct call approach. Do we expect to gain relevant benefits from this ? For the sake of simplicity and stability, maybe we can use just the general approach.
There was a problem hiding this comment.
Do we expect to gain relevant benefits from this ?
It should be slightly faster as we don't have to rely on the global counter.
For the sake of simplicity and stability, maybe we can use just the general approach.
Yes, since we now have the method entry opcode I think we can simplify even though there is going to be slight perf penalty on debugger side.
53c3009 to
bbc1c9f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/coreclr/debug/ee/controller.h:569
- DebuggerControllerPatch::IsActivated relies on m_interpActivated for interpreter patches to avoid treating opcode==0 as “not active”. However, there are activation paths where a patch can become logically active without going through InterpreterExecutionControl::ApplyPatch (e.g., when another patch already exists at the same address and activation is skipped by copying the opcode). In those cases, if the original opcode is 0 (INTOP_RET), IsActivated will still return false, breaking the invariants that bound patches are activated. Ensure m_interpActivated is set/cleared consistently for all interpreter patch activation/deactivation paths, not just ApplyPatch/UnapplyPatch.
bool IsActivated()
{
#ifdef FEATURE_INTERPRETER
// m_interpActivated is set only by InterpreterExecutionControl::ApplyPatch
if (m_interpActivated)
{
_ASSERTE(address != NULL);
return TRUE;
}
#endif // FEATURE_INTERPRETER
// Patch is activate if we've stored a non-zero opcode
// Note: this might be a problem as opcode 0 may be a valid opcode (see issue 366221).
if( PRDIsEmpty(opcode) ) {
return FALSE;
}
// Patch is active, so it must also be bound
_ASSERTE( address != NULL );
return TRUE;
}
| LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n", | ||
| patch->address)); | ||
| return false; |
There was a problem hiding this comment.
InterpreterExecutionControl::ApplyPatch returns false when the target slot already contains INTOP_BREAKPOINT. DebuggerController::ActivatePatch doesn’t check ApplyPatch’s return value and asserts the patch is activated afterwards, so this can leave the patch in an unactivated state (and potentially with opcode still empty) while the code is patched. Consider treating this case as an already-applied patch (e.g., fetch/copy the saved original opcode from the existing patch entry and mark this patch activated) or make it a hard assert/unreachable if it should never happen under the controller lock.
| LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n", | |
| patch->address)); | |
| return false; | |
| // Treat this as an already-applied patch: record the current opcode (the breakpoint) | |
| // and mark the patch as activated without modifying the bytecode slot again. | |
| patch->opcode = currentOpcode; | |
| patch->m_interpActivated = true; | |
| LOG((LF_CORDB, LL_INFO1000, "InterpreterEC::ApplyPatch Patch already applied at %p\n", | |
| patch->address)); | |
| return true; |
Summary
This PR adds debugger support for the CoreCLR interpreter, enabling IDE breakpoints and single-stepping functionality.
Key Changes
Breakpoint Support:
INTOP_BREAKPOINTopcode injectionApplyPatch/UnapplyPatchfor interpreter code patchesINTOP_RET)Single-Stepping Support:
InterpreterStepHelperclass to encapsulate step setup logicTrapInterpreterCodeStepfor step-in/step-over/step-outOnMethodEnterto notify debugger (needed for step-in on virtual calls)Interpreter Compiler Changes:
Stack Walking:
Testing
Notes/TODOs
PRDIsEmpty(opcode)activation check with explicitm_activatedflag #124499HardwareExecutionControlderived class, createInterpreterExecutionControlfor interpreter support