Editor integration (DAP)¶
ntoseye dap makes a debugger session available over the Debug Adapter Protocol. The editor gives source and disassembly views, breakpoints, stacks, registers, and watches, and its Debug Console accepts inspection commands. To step and continue, use the editor’s controls.
Quickstart¶
Configure the VM as Choosing a backend describes, and use one client for each target.
You can name the target on the command line or in the client’s launch configuration.
ntoseye dap # stdio, spawned by the client
ntoseye dap --port 4711
VS Code¶
VS Code needs an extension to register the debug type. To set it up:
Create
~/.vscode/extensions/ntoseye-dap/package.jsonand setprogramto the path of your ntoseye binary:{ "name": "ntoseye-dap", "publisher": "local", "version": "0.0.1", "engines": { "vscode": "^1.75.0" }, "categories": ["Debuggers"], "contributes": { "breakpoints": [{ "language": "c" }, { "language": "cpp" }, { "language": "rust" }], "debuggers": [ { "type": "ntoseye", "label": "ntoseye (Windows kernel)", "program": "/usr/local/bin/ntoseye", "args": ["dap"] } ] } }
Reload VS Code, then add a configuration to
launch.json:{ "version": "0.2.0", "configurations": [ { "name": "Windows kernel (ntoseye)", "type": "ntoseye", "request": "attach", "backend": "kd", "connect": "/tmp/ntoseye-kd.sock", "symbolPath": ["${workspaceFolder}/x64/Debug", "${workspaceFolder}/x64/Release"], "sourcePath": "${workspaceFolder}" } ] }
This configuration assumes the driver’s source tree is the workspace; Paths from the workspace explains the two paths.
To debug the adapter itself, run ntoseye dap --port 4711 in a terminal and add "debugServer": 4711 to the configuration:
{
"version": "0.2.0",
"configurations": [
{
"name": "Windows kernel (ntoseye)",
"type": "ntoseye",
"request": "attach",
"debugServer": 4711,
"backend": "kd",
"connect": "/tmp/ntoseye-kd.sock"
}
]
}
VS Code then connects to that adapter process instead of starting a new one, and the adapter’s stderr output stays visible.
Emacs (dape)¶
Install
dapewithM-x package-install, then add this to your Emacs configuration:(with-eval-after-load 'dape (add-to-list 'dape-configs '(ntoseye modes (c-mode c-ts-mode c++-mode c++-ts-mode rust-mode rust-ts-mode) ensure dape-ensure-command command "ntoseye" command-args ("dap") :type "ntoseye" :request "attach" :backend "kd" :connect "/tmp/ntoseye-kd.sock")))
Add
:symbolPathand:sourcePathentries as needed, using the formats in Attach arguments.If
ntoseyeis not on Emacs’sexec-path, setcommandto its absolute path.Run
M-x dapeand selectntoseye. dape starts the adapter over stdio.
nvim-dap¶
local dap = require("dap")
dap.adapters.ntoseye = {
type = "executable",
command = "ntoseye",
args = { "dap" },
}
dap.configurations.c = {
{
name = "Windows kernel (ntoseye)",
type = "ntoseye",
request = "attach",
backend = "kd",
connect = "/tmp/ntoseye-kd.sock",
symbolPath = "/path/to/your/driver/symbols",
sourcePath = "/home/you/src/MyDriver",
},
}
Attach arguments¶
Kernel debugging does not start a process, so launch and attach do the same thing, and both accept the same target options as the command line.
Argument |
Meaning |
|---|---|
|
|
|
KD socket path, KDNET listen address, or GDB address. |
|
KDNET encryption key (four base-36 components). |
|
Optional positive integer seconds for the initial KD/KDNET handshake, for |
|
|
|
Opens a crash dump or a user-mode minidump instead of attaching to a live VM, and has priority over the other live options. Combining it with |
|
Directories or symbol servers to add to the end of the symbol path, in |
|
Source-path mappings to add to the end of the source path, in |
For example, "backend": "kd", "kdHandshakeTimeout": 30 allows 30 seconds for the handshake. For a startup attach, use --kd-handshake-timeout 30 with the CLI target options. If the client names the target after launch, set kdHandshakeTimeout in its attach arguments instead.
symbolPath accepts a local directory, a symbol server (https://..., srv*a*b), a ;-separated list, or a JSON array. ntoseye adds the entries after the managed cache and the Microsoft server, and loads the symbols again when it attaches.
sourcePath accepts the local directory that contains the source tree, which ntoseye matches as .srcpath describes, or a mapping <prefix-recorded-in-the-pdb>=<local-root>. It also accepts the same ;-separated list and JSON array forms as symbolPath. If the driver was not built on this host, a source view needs sourcePath.
If the command line already sets a target (ntoseye dap --dump crash.dmp), the attach request uses that session and ignores its own target arguments, but symbolPath and sourcePath still apply.
Paths from the workspace¶
VS Code and nvim-dap replace ${workspaceFolder} with the workspace directory before they send the configuration, so one configuration serves every checkout of a driver.
sourcePathcan be the workspace itself. ntoseye matches the end of each recorded source path under it, so sources compiled asC:\Users\you\src\MyDriver\queue.cinside the guest are found at${workspaceFolder}/queue.c.symbolPathmust name the folders that contain the PDB. ntoseye looks for<folder>/<name>.pdband for the symbol-store layout under each folder, and does not search subfolders.You can list every output folder of the project, for example Debug and Release, or x64 and ARM64. ntoseye loads a PDB only when its GUID and age match the loaded driver, so a PDB from another build in a listed folder is passed over, and a stale PDB is never loaded.
If the PDB exists only inside the guest, ntoseye can read it from guest memory instead, shortly after the build; see Symbols in the driver guide.
Feature mapping¶
DAP surface |
ntoseye |
|---|---|
Threads |
backend execution contexts, as listed by |
Call stack |
|
Locals scope |
|
Variable expansion |
|
Registers scope |
|
Watch and hover |
the shared core expression grammar |
Debug Console |
any command that inspects state |
Breakpoints |
|
Function breakpoints |
|
Instruction breakpoints |
|
Data breakpoints |
|
Exception breakpoints |
|
Condition and hit count |
breakpoint condition and WinDbg pass count |
Log points |
a |
Step over and into |
one source line, or one instruction |
Step out |
|
Disassembly view |
|
Variable writes |
in-place scalar writes |
Memory view |
virtual reads and writes |
Modules view |
|
Exception info |
the stop’s NTSTATUS, or the bugcheck code and its four parameters |
Output console |
guest |
Threads and stacks¶
DAP threads are backend execution contexts (vCPUs), as ~ lists them. A stop halts the whole target and sets allThreadsStopped. To examine Windows threads, use !process, !stacks, and !thread. You cannot step or resume a parked _ETHREAD.
A thread’s name is the processor, what it runs, and its symbol (processor 0 [kernel] nt!KiIdleLoop+0x2d). For a vCPU halted in the Windows hypervisor, the name adds where each VTL left off, with the hypercall of a VMCALL exit, and the guest partition’s VP that the processor serves: p01.02 [hypervisor] hv!HvCallSendSyntheticClusterIpi (VTL0 hvcall!Hypercall) (serving partition 0x3 VP 2 VTL0 ffffffffc0000000, last exit VMCALL (hypercall 0x000b HvCallSendSyntheticClusterIpi fast)). !hvcall in the Debug Console decodes that call’s input (VBS). ntoseye gdbserver names its threads the same way.
The call stack uses k, with source lines from private PDBs. Each frame’s name and source lines use the address space that ntoseye recovered that frame from, including the process address space of a parked thread.
The stack of the current vCPU starts from the console context, which these commands select:
.thread <ethread>selects the saved context of a parked Windows thread. If the vCPU of the thread is halted in the Windows hypervisor,.threadselects the VTL0 state that the hypervisor saved..cxrselects a context record..trapselects a trap frame..vtlcxr1selects the VTL1 state that the hypervisor saved, and.vtlcxrthe VTL0 state again.
The stacks of other vCPUs start from their own registers, and showing them in the editor does not change the console context. If you select a frame from one of these stacks, the console switches to that vCPU.
For a vCPU halted in the Windows hypervisor, whose stack starts where VTL0 (or, after .vtlcxr 1, VTL1) left off, the call stack shows the hypervisor’s own frames first, as the call chain runs, then a label frame (VTL0, as the hypervisor saved it), then the frames of that saved state. Each frame keeps the registers and the address space of its own walk, so the Registers pane and expressions show the hypervisor’s state for its frames and the guest’s for the others. The label has no registers. After .cxr in the console, the stack is the hypervisor’s alone.
Clients that support supportsInvalidatedEvent refresh their panes automatically. With other clients, you refresh the panes manually.
User-mode minidumps¶
With a Windows user-mode minidump, the DAP threads are the dump’s threads, with their thread IDs as DAP thread IDs, and each is named by its <pid>.<tid>, its thread name when the dump records one, and its symbol. When the dump records an exception, the session stops with the reason exception on the thread that raised it, exceptionInfo describes the exception record, and that thread’s call stack starts at the exception’s context, as after .ecxr; a dump without one stops with the reason entry. Call stacks, scopes, variables, watches, the Debug Console, the memory view, and the modules view work as on a kernel target.
A user-mode minidump never runs and is read-only, so continue and the step requests fail, breakpoints of every type stay unverified, and writes to memory, registers, and variables fail. With ntoseye dap --dump <file>, the initialize response already leaves out these capabilities; a dump opened by launch or attach withdraws them with a capabilities event.
A kernel crash dump is a static snapshot too, and the adapter treats it the same way: run control fails before it starts, every breakpoint type, exception filters included, stays unverified without changing the sx settings, and the same capabilities are withdrawn.
Locals, registers, and watches¶
When the compiler inlines a call, ntoseye shows it as a frame named [Inline Frame] module!function, as Visual Studio does. That frame is at the source line of the inlined function, and the frame that contains the inlined call is at the line of the call.
Locals and parameters use PDB locations, as in dv. An inline frame shows the locals and parameters of the inlined function, and the frame that contains the inlined call shows those of its own procedure.
Caller frames contain only the registers that ntoseye recovers when it unwinds the stack, and other values are not available.
ntoseye reads a frame’s locals, their values, and their children from the address space in which it recovered the stack. As a result, the frames of a parked thread show the locals of that thread’s process, whatever .process selects.
ntoseye expands structs, unions, arrays, and pointers with dt decoding. Null pointers, unresolved types, and zero-sized types do not expand. To see the raw layout, use dt in the console.
The Registers scope uses r. Frame 0 shows the live register file and is writable, as are the inline frames at its address, which share its registers. Caller frames show the sparse recovered context.
When ntoseye walks a stack from a saved context (a parked thread, a .cxr context record, or the VTL0 state that the hypervisor saved), every frame is recovered, so you cannot write to the registers of any frame in that stack.
Watch and hover use core expressions, which can contain:
locals (
index,Irp->IoStatus.Status)addresses (
poi(nt!PsInitialSystemProcess))registers (
@rip)casts (
(_IRP*)@rcx)
The console radix (n 10) applies to these expressions. An expression uses the registers of the selected frame and, like the locals, reads the address space in which ntoseye recovered that frame’s stack. The Debug Console uses the console’s inspection context instead, so a .process scope applies there.
Locals need private PDBs and a location that ntoseye can recover in the selected frame. Use $!name to make sure that a name resolves to a local, and & to get the storage address of a local. Typed structs, arrays, and pointers expand into children.
You can write in place to scalar registers, locals, struct fields, and array elements. Bitfields and values wider than 8 bytes need console commands such as eb or ed.
Breakpoints¶
Source breakpoints use bu file:line. A breakpoint on an unresolved line stays unverified until its module loads, and then a breakpoint event updates the client. You can edit breakpoints while the target runs, in which case the adapter pauses and resumes the target.
Function breakpoints use bu <symbol> but skip the prologue, so the parameters are available when the breakpoint stops. bu in the console stops at the symbol itself.
Instruction breakpoints, which you set from the disassembly view, use bp <address>.
Data breakpoints use ba on variable storage, including fields and array elements. Locals held in registers and bitfields have no separate address that ntoseye can watch. The kd, kdnet, and gdb backends support data breakpoints.
No DAP breakpoint type names a hypercall’s caller, so set a hypercall breakpoint with !hvbp in the Debug Console. The client’s continue honors its filter, and its hit is reported as a breakpoint stop.
The editor’s exception breakpoints set the exception policy that the sx commands set. The adapter offers access violation, illegal instruction, integer divide by zero, and stack overflow, checked by default: a checked one stops at the first chance (sxe), and an unchecked one only at the second (sxd), when no handler took the exception. Module load and module unload, unchecked by default, stop at each driver or module that loads or unloads (sxe ld, sxe ud). Each change in the editor replaces the policy of all six, so set any other exception’s policy, a module filter such as sxe ld:mydriver, or a command to run at the stop, with sx commands in the console. Conditions on exception breakpoints are refused.
When a module load stops, the adapter reports it with the reason module load, and the console shows the stop’s ModLoad: line. A module unload stop has the reason module unload, with its Unload module line.
All breakpoint types accept conditions and hit counts. hitCondition must be a decimal pass count, so values such as >5 or 0x10 are not valid. Breakpoint conditions in the editor also use decimal literals, while conditions in the console use the session radix.
Log points¶
A log point is a breakpoint with the action .printf "..."; gc, which prints a message and continues. Each {...} placeholder contains a core expression, for example a local such as {index}, and shows its value in hexadecimal. The text around the placeholders is literal.
Log placeholders cannot contain quotes or semicolons, and ntoseye removes whitespace from them because .printf uses whitespace to separate arguments. When a log action succeeds, the target continues without stopping the client. Conditions and hit counts still apply.
Stepping¶
Step over and step into go forward one source line when line records exist, or one instruction when they do not. With instruction granularity, a step is always one instruction. A step covers a range of straight-line instructions in one run.
Step out uses gu and stops at the return address for all granularities.
The disassembly view uses u to scroll forward and ub to scroll backward.
Memory¶
The memory view reads and writes virtual memory in the current process context. When ntoseye reads memory, it replaces the bytes of its own breakpoints with the original opcodes, so the view does not show an int3 that ntoseye put in memory. A write over one of these breakpoints changes the code under it, and the breakpoint stays set.
If a write crosses into a page that ntoseye cannot translate, ntoseye writes the bytes before that page. When the request sets allowPartial, the response gives the number of bytes written. Otherwise, the write fails and gives that number.
Paging¶
Stack requests support paging with startFrame and levels, and variable requests with start and count.
ntoseye decodes arrays one window at a time, up to 1024 elements for each request. It does one bounded stack walk for each stop, and each stack page is a slice of that walk. dt in the console shows 16 elements by default; to see more, use dt -a or dq.
Resume and step from the editor¶
The Debug Console uses the same remote dispatch context as MCP and does not accept commands that resume the target, such as g, p, gu, wt, and .reboot. To step and continue, use the editor’s controls.
Breakpoint actions still run. For example, a breakpoint created with bp nt!NtCreateFile do "k; gc" runs its action at each hit and prints the output in the console, and because the action ends in gc, the target continues.
A step is answered at once and runs the way a continue does: while it runs, the editor shows the thread as running, the adapter answers its other requests, and Pause ends the step where it is, for example a step over a call that waits. A step whose thread exits ends with a stop that says so.
Disconnect, terminate, SIGTERM, SIGHUP, and SIGINT detach the adapter as qd does, which removes the installed breakpoints and resumes the guest. The adapter does this cleanup before it sends the disconnect response, because a client can kill the adapter immediately. If the target cannot halt for the cleanup, the adapter reports the failure and does not resume the target.
Warning
SIGKILL stops the adapter before it can do the cleanup, so the breakpoint entries stay installed. For more information, see breakpoint recovery.