Skip to content

How do debuggers work? #35

@ctapobep

Description

@ctapobep

Ever wondered how a debugger works? What happens under the hood when you put a breakpoint? What allows you to stop the execution of a program and get the sate of variables? There are 2 different answers: one for interpreters (JVM, CPython, NodeJS) and one for native executables (C, Rust, C++).

Debugging with interpreters (JVM, CPython, NodeJS, etc)

Let's start with the interpreters. What are they? These are simply programs that read instructions in the format of their choosing. JVM reads binary class files, NodeJS reads text files written in JS. We write those instructions as if we're writing a program, but in reality - it's the JVM/CPython/NodeJS that are programs. They have logic to read and execute what we tell them.

You can write an interpreter yourself - just define some format you expect the instructions to be written in. Could even be something like JSON or XML. Then parse the file in that format:

  • Keep track of variables that are defined in it (in a Map/Dict)
  • If you see an operation like "+", sum up the values from the Map/Dict
  • And keep track of the line numbers where you saw each instruction. This will be useful soon.

So interpreters are programs that read instructions from files. And since it's a program, you can bake in some communication means into it. For instance, it can communicate over network and read external commands from it. One of the commands can be "stop interpreting at instruction #N". And the interpreter checks if(currentInstructionLine == requestedStop) and just.. stops interpreting. It can send you the details about the variables that are currently defined, and even allow to change them.

And that's the whole magic. Our IDE (like IntelliJ/PyCham/WebStorm) communicates with JVM/CPython/NodeJS over network and sends them commands to stop at this or that instruction.

Just-in-Time compilation vs Debug

Interpreting is kinda slow. To speed things up some interpreters can generate machine code on the fly (aka Just-in-Time complication or JiT). So instead of interpreting a command-after-command from the text file, it could read 10 lines, generate some Assembly, compile it into machine code and pass it directly to CPU. When CPU executes those instructions, the interpreter isn't in control anymore. Well, until those lines end with "call my JVM code back".

Adding debugging capabilities to the machine code would be quite an overhead, so:

  • Python JiT is quite primitive for now. You have to mark a function explicitly for JiT'ing. CPython will compile it the first time it reads it, and then it won't switch to interpreting back. That's why you can't debug JiT'ed Python. Your breakpoints simply won't work, and you can't step into compiled function.
  • JVM JiT keeps track of the "hot" code that it runs over and over again. Then it compiles it into machine code. But still it can decide to jump into the machine code or switch to interpreting again. Which would be a natural choice during debugging. This way we're back in the interpreter mode and can react to the commands from the Debugging Client.

Debugging native programs (C, Rust, C++, etc)

So with the interpreters it's just a client-server communication between the debugging client (IDE) and the "server" (JVM, CPython, NodeJS). What about the machine-compiled platforms like C and Rust?

Since the CPU executes those instructions itself - the CPU is our "interpreter". So we need to communicate with the CPU so that it could stop the execution and give us the control. How can a debugger (GDB, LLDB) do this?

Many CPUs support a dedicated breakpoint instruction. The debugger replaces the real instructions with the debug instructions (debugger is allowed to rewrite your code), storing the real instruction somewhere else. Once CPU runs into a breakpoint:

  1. It stops and gives control to the OS.
  2. The OS can call the debugger.
  3. The debugger can inspect the memory state and report it back to the user.

In Linux a lot of this is happening through ptrace syscalls.

If the CPU doesn't have breakpoint instructions, then this can be emulated with exceptions - e.g. the debugger can put a division by 0 - which will call the OS too.


To get notifications about new posts, Watch this repository.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions