Imagine this scenario...the system installation is halfway through, and the machine freezes without warning. Checking the serial logs, there are only a few scattered exceptions. That's the trouble with kernel issues—the moment of failure is fleeting, the clues left behind are contradictory, and the true root cause is hidden in a tiny, unremarkable execution path. Tracking it down is like finding a needle in a haystack.

The deepin file manager provides a file indexing feature. The kernel module used for indexing instruments filesystem functions through the VFS kretprobe hook method, collecting the information needed for indexing during file access. Recently, when a large number of file operations were combined with kernel probes, a low-level defect on the LoongArch platform was triggered, causing the system to freeze during installation or when extracting large images. deepin developers spent nine consecutive days investigating this issue, ultimately pinpointing the root cause to the LoongArch kernel's rethook trampoline function incorrectly restoring the percpu base address, and verified the fix direction through targeted experiments.

What follows is the practical process from discovering to fixing this kernel issue. The article is long but packed with useful information—please read patiently~

First, let's understand a few concepts

LoongArch: The processor instruction set architecture adopted by  The kernel must follow the register and runtime conventions defined by this architecture.

percpuvariables: Data that each CPU core holds its own copy  The kernel accesses the current core's data through operations like this_cpu_(), such as scheduler and RCU state, as well as timers and FPU state. The LoongArch kernel convention uses $r21 (also called $su0) to hold the current CPU's percpu base address. During kernel-mode execution, this register should always point to the current core's percpu area.

kretprobe: A Linux kernel function return tracing mechanism that can execute handling logic when a traced function  rethook: The kernel mechanism used to implement this type of return tracing, responsible for arranging callbacks and continuing the original function's return flow. To temporarily take over the function return, the kernel passes through a small piece of assembly code called a trampoline.

Processmigration: The kernel scheduler can move a task from CPU A to CPU B to continue

Preemption: The currently running task is interrupted by a higher-priority

How did this problem occur?

The old code split the logic into three steps:

  1. Enterthe trampoline, push r21 (the current CPU's percpu base address) onto the stack and save it;
  2. Executerethook_trampoline_handler() ;
  3. Exitthe trampoline, restore the saved r21 from the stack back to the register.

The problem lies in step 2

Inside this handler, when full preemption is enabled, the task may be scheduled and migrated to another CPU during this period.

For example, the task starts on CPU0, enters the trampoline, and saves CPU0's r21 value to the stack;

At the preempt_enable_notrace() scheduling point inside rethook_trampoline_handler(), scheduling occurs, and the task is moved to, say, CPU1 to continue running;

After the handler finishes executing, the code prepares to exit the trampoline and reads the old r21 value of CPU0 from the stack, writing it back to the register.

Now the task on CPU1 has r21 set to CPU0's percpu base address. Subsequently, all this_cpu_() operations that were intended to access CPU1's own data (scheduling queues, RCU, timers, FPU state) end up reading and modifying CPU0's percpu data instead.

What failures does this cause?

When kretprobe is heavily used and system load is high:

  • Schedulerdata corruption, resulting in "scheduling while atomic" crash errors;
  • RCUreports "running on wrong CPU" warnings;
  • IdleCPU load balancing validation fails ( WARN_ON_ONCE(rq != this_rq()) );
  • Aftera CPU enters the idle loop, the timer no longer restarts, and the CPU hard-locks.

The reproduction scenario is: Loongson 3A6000, with a large number of kretprobes attached to VFS filesystem functions, while heavily reading and writing files—such as installing the system, using unsquashfs to extract large images, or even when users install software or extract files.

This bug has existed since the old kretprobe era of Linux 6.3. After switching to rethook, this erroneous logic was directly copied over.

Why didn't anyone encounter this before?

Because this defect requires three conditions to be met simultaneously:

  1. Thesystem runs on LoongArch;
  2. Amodule has probes attached to frequently called kernel functions;
  3. Thekernel is configured with full preemption (preempt=full).

deepin's file indexing instruments densely called file-related functions to determine whether files have been modified. The system where this problem occurred also had full preemption enabled to make the interface feel more responsive. If any one of these three conditions is missing, the problem won't occur—which is why it hadn't appeared elsewhere before.

Compared to no preemption, desktop response is more timely; compared to voluntary preemption, full preemption provides lower desktop response latency, at the cost of some performance test impact. It is precisely for this reason that servers and other desktop system versions such as UOS desktop have not enabled full preemption.

This problem hadn't appeared on deepin before because the LoongArch architecture previously did not have the deepin-anything-dkms kernel module installation package that uses kretprobe instrumentation pre-installed.

What preemption modes are there, and how do they differ?

We've repeatedly mentioned "preemption" above. Let's break down its various forms—preemption mode is not just a switch with on and off positions, but several different modes that achieve varying degrees of trade-off between system throughput and response time.

No preemption (PREEMPT_NONE / preempt=none)

The kernel only yields the CPU where code explicitly calls cond_resched(). cond_resched() is an explicitly written "this is a good place to yield" position in kernel code, typically appearing in loops and batch-processing paths that take a long time. Apart from these points, nothing else can be interrupted: once a task enters kernel mode, it will finish that piece of work all the way through, and other tasks can only wait. This achieves the highest throughput because the number of context switches is minimized; the cost is that if some kernel operation doesn't yield for a long time, response gets stuck. It suits scenarios that value processing volume per unit time, such as servers and scientific computing.

Voluntary preemption (PREEMPT_VOLUNTARY / preempt=voluntary)

On top of no preemption, it relaxes one more notch by also treating positions like might_sleep()—"this might sleep"—as scheduling points. Once kernel code enters a path that might sleep, such as waiting for a lock or waiting for I/O, the scheduler has an opportunity to step in and give the CPU to someone else. The idea is: if a piece of code might sleep anyway, there's no additional loss in letting it be interrupted here. So while not changing the bottom line of "don't yield, don't interrupt," it partially addresses the response shortcoming. In daily use, the difference between it and full preemption is not very noticeable.

Full preemption (PREEMPT / preempt=full)

No longer waiting for code to yield on its own—as long as the kernel hasn't explicitly "locked itself," it can be interrupted immediately at any position. Even while processing system calls or running driver code, if a more urgent task arrives, the scheduler can switch to it. The responsive feel of clicking and dragging on the desktop depends on this. The cost is switching points everywhere in the kernel, some throughput loss, and more complex lock contention.

Lazy preemption (PREEMPT_LAZY / preempt=lazy)

Under the full preemption model, the kernel checks need_resched/TIF_NEED_RESCHED at multiple points and triggers task preemption, such as at clock tick events (e.g., HZ=1000 triggers 1000 times per second). Lazy preemption adds a TIF_NEED_RESCHED_LAZY, used to mark that rescheduling is needed at some point but not necessarily immediately. PREEMPT_LAZY sits between PREEMPT and PREEMPT_VOLUNTARY, intending to recover the performance loss of throughput-oriented tasks without affecting real-time task response time. After kernel v7.0, only PREEMPT_LAZY and PREEMPT are selectable.

Real-time preemption (PREEMPT_RT)

Under real-time preemption, the kernel is modified to achieve near hard real-time capability: fully preemptible, interrupt threading, preemptible locks with priority inheritance, pursuing low latency and determinism.

The defect in question manifests under full preemption. The trampoline function briefly disables preemption during execution and re-enables it after processing. Under full preemption, this "re-enable" moment is a genuine scheduling point, and the task may be switched to another CPU right there; under no preemption and voluntary preemption, this step merely decrements the counter, the scheduler won't intervene in that window, the task won't switch CPUs there, and the subsequent error of restoring the old base address won't be triggered. Lazy preemption, from the code perspective, would also trigger the defect, but has not yet been experimentally tested.

This difference also shows why downgrading to voluntary preemption can serve as an emergency measure: it doesn't fix the two lines in the trampoline, it just makes the trigger path unreachable.

PREEMPT_DYNAMIC: Preemption mode is no longer fixed—set the default at build time, choose the active mode at boot

Previously, preemption mode was fixed at kernel compile time—to change it, you'd have to recompile and redistribute a new kernel, and distributions would need separate binaries for servers, desktops, and low-latency desktops, resulting in high maintenance costs.

Linux 5.12 introduced PREEMPT_DYNAMIC to remove this limitation: the kernel still declares a default mode at compile time, but as long as this option is enabled, the boot parameter preempt= can change it at boot time by pre-arranging a set of switches in the kernel and flipping them to the corresponding positions at boot.

The deepin desktop edition sets the default to full preemption to make a click or drag on the desktop respond as quickly as possible. The compile configuration CONFIG_PREEMPT=y and CONFIG_PREEMPT_DYNAMIC=y set this default, and the clock frequency HZ=1000 gives the kernel a clock interrupt every millisecond. The difference between full preemption and voluntary preemption in daily use is actually not very noticeable; what you get in exchange is more stable response.

At kernel boot, the effective mode is printed to the log in a form like Dynamic Preempt: full—you can tell which tier is selected by glancing at the boot log.

The deepin desktop sets the default to full preemption. Declaring full preemption as the default at compile time, while still being adjustable via boot parameters when needed, gives the fastest desktop response. When encountering problems like this one that only manifest under full preemption, you can temporarily change the parameter to preempt=voluntary to block the trigger path without waiting for the full kernel update process to complete.

UOS desktop sets voluntary preemption at compile time. There are two reasons: the difference between voluntary preemption and full preemption in daily use is already not obvious; and abandoning this mechanism saves the overhead of the rewrite and makes the kernel structure simpler. The cost is that the preemption mode can no longer be changed—to switch to another mode, you'd have to recompile a new kernel.

How deepin's file index exposed a LoongArch kernel flaw

Initially, the failure appeared during the installation of the deepin LoongArch daily build image. During system installation, when the installer used unsquashfs to extract the image, the device would suddenly freeze. Due to limited serial output, the early logs only showed SLUB, scheduler, and preemption-related exceptions, and we could not directly identify the root cause.

The investigators at one point suspected the newly added deepin-anything-dkms module in the image. This module is related to file indexing and uses VFS kretprobe hooks to trace file operations, making it an important clue. Subsequent experiments pointed the root cause to the LoongArch kernel's probe return path. The module and high-intensity file access made this kernel defect more likely to manifest.

To reduce the time spent repeatedly building images and flashing devices, the developers narrowed the reproduction process to multi-round unsquashfs tests after installation, and used netconsole network console logs instead of physically connecting a serial console. AI assisted in analyzing exceptions, writing diagnostic patches, and organizing verification ideas, with each conclusion still confirmed through controlled experiments on actual devices.

Multiple consecutive rounds of experiments gradually narrowed the scope to the interaction between the rethook trampoline function, preemption, and task migration. Debug patches also turned the problem from "occasional freeze during installation" into a reproducible kernel failure. Ultimately, the percpu base address anomaly during task migration was correlated with various symptoms in scheduling, RCU, and timers.

The task switched CPUs—why doesn't the register need to be restored?

The LoongArch kernel has a clear convention: during kernel-mode execution, $r21 remains as the current CPU's percpu base address; when entering the kernel from user mode, the exception entry loads the corresponding base address according to architectural rules. After a normal kernel context switch migrates a task to another CPU, the current CPU's register environment should also use the new core's percpu base address.

Therefore, when returning to the trampoline exit after task migration, the $r21 in the register already corresponds to the current core. The trampoline writing back the old value saved on the stack would instead overwrite the correct value. The fix direction is to delete the save and restore operations of $r21 in the rethook trampoline. Deleting the restore operation is sufficient to avoid overwriting the new core's base address; simultaneously deleting the save operation also removes the no-longer-needed stack space.

The LoongArch kretprobe trampoline assembly erroneously saves the percpu base address register. If a task is scheduled and migrated to another CPU during trampoline processing, restoring the old CPU's base address on exit causes subsequent code to access the wrong CPU's private data, triggering scheduling anomalies and timer hard-locks. According to kernel conventions, this register always maintains the correct value in kernel mode, and the trampoline does not need to manually save and restore it—deleting these two lines of assembly fixes it.

Verifying the root cause, not just making the failure disappear

This investigation went through multiple rounds of hypotheses and repeated verification. Initially, it was thought to be a recently introduced issue; CPU frequency scaling and USB auto-suspend were checked, kernel commit bisection and module impact were attempted, and different kernel versions were tried. Finally, after discovering that the original kernel could also reproduce the issue, it was determined to be an upstream problem.

The difficulty of debugging lies in reproducing as quickly as possible and narrowing the problem scope. Many directions were ultimately eliminated by experiments, but each round of results made the reproduction scope and observation focus clearer. Debug patches were continuously added along the points where problems appeared for verification, ultimately finding that the original corruption location at kernel runtime was in the rethook trampoline itself.

In a test totaling 55 minutes, the task migrated between CPUs 4,991 times, and the device no longer froze. Previously, the team had verified through multiple rounds of unsquashfs extraction tests that the failure could be stably reproduced, and also verified the impact of debug changes on the phenomenon.

This type of error has existed since the old kretprobe implementation in Linux 6.3; after switching to rethook, the related register save and restore logic was carried over. It is usually not easily manifested, and only becomes exposed when conditions such as high-frequency probes, preemption, and task migration stack up.

Merged into mainline

  • Patchtitle: LoongArch: Do not save/restore percpu base register in rethook trampoline
  • Commitaddress: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=c3f2feace5e4f4b01b68b9f947b19adb4155c32e
  • Modifiedfile: arch/loongarch/kernel/rethook_trampoline.S, 0 lines added, 2 lines deleted
  • Defectorigin: Fixes: 3f5536860086d ("LoongArch: Add kretprobes support"), affecting 3 and later.
  • Markedfor backport to stable versions: Cc: stable@vger.kernel.org # 3+

The change is only two lines, but locating it took a long time!

Because the initial symptoms didn't point here—installation freezes, no valid serial output, or various scheduling exception errors. Following these clues backward and continuously adding dozens of debug patches for localization and investigation, we step by step reached the trampoline function. In this process, the most time-consuming part was making the problem reproducible, observable, and verifiable; writing the fix was actually the last step.

Will users encounter this problem?

This fix entered the Linux mainline before users began reporting the issue. The dependent updates were pushed externally first, but the fixed kernel had not yet completed testing and reached users at that time. During this time gap, users could still encounter the problem.

A temporary workaround is: add preempt=none or preempt=voluntary to the boot parameters to bypass the yield point that triggers scheduling, and the problem won't occur (this step requires PREEMPT_DYNAMIC to be enabled at kernel compile time, otherwise the boot parameter won't take effect).

----------------------------------------------------------------------------------------------------------------

These types of defects only appear under specific hardware and specific loads, with no logs to follow and no shortcuts. Being able to dig it out depends on engineers being willing to investigate problems thoroughly where no one is watching.

This deepin LoongArch kernel debugging shows us: AI expands the speed and boundaries of engineers' exploration, but what truly drives problems toward resolution is still reverence for technology, adherence to evidence, and repeated verification of every detail. When intelligent tools meet engineering experience, complex problems have more possibilities to be seen, understood, and overcome. Every localization and fix is a reinforcement of system stability, and also a concrete practice of deepin continuously polishing underlying technology and responding to real user needs.

If you are also interested in the Linux kernel, welcome to the deepin forum to exchange ideas with our R&D team: deepin forum.