What happens when ptrace SINGLESTEP is called in aarch64, Linux kernel?
Linux reference for this question: 5.15.5 (latest stable in November 2021).
Nomenclature: tracer (the process which traces) and tracee (the traced process).
With static analysis + ftracing the linux kernel, I tried to reconstruct what precisely happens upon a ptrace SINGLESTEP call. I tried to write what I understood in a learning OS, failing in obtaining the same behavious. Before asking my question, let me summarize the process that I tried to reconstruct:
/* ptrace API */
void user_enable_single_step(struct task_struct *task)
{
struct thread_info *ti = task_thread_info(task);
if (!test_and_set_ti_thread_flag(ti, TIF_SINGLESTEP))
set_regs_spsr_ss(task_pt_regs(task));
}
/*
* Single step API and exception handling.
*/
static void set_user_regs_spsr_ss(struct user_pt_regs *regs)
{
regs->pstate |= DBG_SPSR_SS;
}
static void noinstr el0_dbg(struct pt_regs *regs, unsigned long esr)
{
/* Only watchpoints write FAR_EL1, otherwise its UNKNOWN */
unsigned long far = read_sysreg(far_el1);
enter_from_user_mode(regs);
do_debug_exception(far, esr, regs);
local_daif_restore(DAIF_PROCCTX);
exit_to_user_mode(regs);
}
do_debug_exception() is defined in fault.c and, since it is a software step, it should call the function early_brk64 of debug_fault_info data structure:/*
* __refdata because early_brk64 is __init, but the reference to it is
* clobbered at arch_initcall time.
* See traps.c and debug-monitors.c:debug_traps_init().
*/
static struct fault_info __refdata debug_fault_info[] = {
{ do_bad, SIGTRAP, TRAP_HWBKPT, "hardware breakpoint" },
{ do_bad, SIGTRAP, TRAP_HWBKPT, "hardware single-step" },
{ do_bad, SIGTRAP, TRAP_HWBKPT, "hardware watchpoint" },
{ do_bad, SIGKILL, SI_KERNEL, "unknown 3" },
{ do_bad, SIGTRAP, TRAP_BRKPT, "aarch32 BKPT" },
{ do_bad, SIGKILL, SI_KERNEL, "aarch32 vector catch" },
{ early_brk64, SIGTRAP, TRAP_BRKPT, "aarch64 BRK" },
{ do_bad, SIGKILL, SI_KERNEL, "unknown 7" },
};
PC (of the tracee) of 4 bytes (aarch64 instructions are on 32 bits):void arm64_skip_faulting_instruction(struct pt_regs *regs, unsigned long size)
{
regs->pc += size;
/*
* If we were single stepping, we want to get the step exception after
* we return from the trap.
*/
if (user_mode(regs))
user_fastforward_single_step(current);
if (compat_user_mode(regs))
advance_itstate(regs);
else
regs->pstate &= ~PSR_BTYPE_MASK;
}
static void clear_user_regs_spsr_ss(struct user_pt_regs *regs)
{
regs->pstate &= ~DBG_SPSR_SS;
}
If this chain of calls is correct, I can't understand where and how the context switching (from tracer to tracee) happens. Indeed, from these calls, it seems that points 2-6 regards the tracer. I can't notice a context switch in this chain, but it should.
I have tried to replicate all these steps in a learning OS. For the sake of clarity, I failed generating an exception after setting SPSR.SS bit (point 2), but I forced to generate an hardware debug exception by setting SS bit, MDE bit and KDE bit on MDSCR_EL1 register.
Once I did this trick, my steps were verified but, indeed, the exception was captured by the tracer (and not by the tracee as it should): PC of the tracer were updated, and so on. I think that steps that I retrieved through static analysis of the code and ftrace are not completely correct. Can you help in identifying where?
You want to look inside the signal delivery code, eventually in kernel/signal.c. This is initiated in do_debug_exception, with its call to arm64_notify_die. You can trace this down to force_sig_fault and then we are in generic architecture-independent signal code.
The breakpoint exception causes SIGTRAP to be delivered to the traced process, and since it's being traced, every signal causes the tracee to stop, much as if it had been sent SIGSTOP. At this point the tracer is notified, in the same way a process is notified when a child exits or stops: if it's blocked on waitpid then waitpid will now return; if it's not then the next call to waitpid will return immediately. The tracer can make further ptrace calls to inspect or alter the tracee's state, and call ptrace(PTRACE_CONT) (analogous to SIGCONT) when it is ready for the tracee to take another step. It will set the appropriate flag so that the tracee ignores the SIGTRAP, which otherwise would typically terminate it.
So it's much the same flow as if a parent is in waitpid(pid, &status, WUNTRACED) and its child receives a SIGSTOP or SIGTSTP (e.g. Ctrl-Z hit in the terminal); the relevant kernel code isn't specific to debugging. In particular, there is not an explicit context switch from tracee directly to tracer. Rather, the tracee becomes stopped and the tracer becomes ready to run. The CPU then enters the scheduler. The tracer may be the next process picked to run, either by luck or if all other processes are sleeping; otherwise it has to wait for a timeslice like everyone else. The tracer may even be running on a different core, which is also fine.
Your analysis goes off course in step 4. The breakpoint handler is initialized to early_brk64 at boot, but as the comment on early_brk64 suggests, during boot, debug_traps_init hooks single_step_handler in its place. Then we go through send_user_sigtrap to arm64_force_sig_fault to force_sig_fault which is in generic architecture-independent kernel code.
Note that all of this is in the context of the tracee.
In particular, bug_handler() is not called during this process. That function is meant to handle a kernel bug, possibly by OOPS-killing the process, or panicking the kernel. early_brk64 calls it unconditionally, I think because it is only installed as a handler in early boot, before any userspace processes exist, and where the kernel should not under any circumstances be taking debug exceptions.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With