HomeAbout UsContact Us

Embedded Linux Kernel Driver Debugging: Common Issues

By Jithin Tom
October 12, 2026
7 min read
Embedded Linux Kernel Driver Debugging: Common Issues

Table Of Contents

01
Problem Statement
02
Root Cause Analysis
03
Solution Approaches
04
Complete Working Code Example
05
Verification and Testing Steps
06
Summary
07
Related Reading
08
References
09
Frequently Asked Questions

Embedded Linux kernel driver development presents unique challenges due to the tight integration between silicon IP, Device Tree definitions, the Linux driver core, and real-time hardware constraints. Unlike user-space application development where segmentation faults trigger core dumps under isolated virtual memory, kernel-space faults precipitate kernel Oopses, kernel panics, or silent bus deadlocks that compromise the entire system.

Debugging misbehaving drivers requires a precise understanding of kernel subsystems, hardware bus timing, CPU cache architectures, and kernel execution contexts (process context versus atomic interrupt context). This guide provides a rigorous root cause analysis of the five most frequent failure modes in embedded Linux drivers, detailing diagnostic workflows, synchronization rules, and production-tested C code implementations.

Problem Statement

Embedded software engineers and kernel developers frequently encounter persistent, intermittent failures that are difficult to reproduce under synthetic test harnesses:

  • Module Insertion Failures: Running insmod or modprobe aborts with Unknown symbol in module or disagrees about version of symbol.
  • Resource Exhaustion and OOM Crashes: Out-Of-Memory (OOM) killer activations or slab allocation failures caused by missing teardown in error unwind paths or driver unbind routines.
  • Atomic Context Violations: BUG: scheduling while atomic call traces when code invokes blocking APIs (e.g., mutex_lock(), msleep(), copy_from_user()) while holding spinlocks or running inside top-half Interrupt Service Routines (ISRs).
  • Interrupt Storms and Bus Freezes: An unacknowledged peripheral interrupt pin holding an IRQ line low, causing system lockup or kernel messages reporting irq XX: nobody cared.
  • DMA Data Corruption: Stale buffer reads or corrupted DMA descriptors caused by missing cache invalidation/flush operations on streaming DMA buffers or improper alignment with cache line boundaries.

Root Cause Analysis

1. Symbol Resolution and Module Dependency Mismatches

When a kernel module fails to load during insmod or modprobe, the kernel module loader (kernel/module/main.c) cannot resolve external symbols referenced in the module’s .symtab section against the running kernel’s exported symbol table (__ksymtab visible in /proc/kallsyms). This stems from:

  • Unexported APIs: The target symbol in the core kernel or dependent driver is not marked with EXPORT_SYMBOL() or EXPORT_SYMBOL_GPL().
  • Symbol Namespaces: Modern Linux kernels (Linux 5.4+) isolate major subsystems (such as DMA-BUF, IIO, or DRM) into explicit symbol namespaces via EXPORT_SYMBOL_NS_GPL(). If the consumer driver lacks the corresponding MODULE_IMPORT_NS(NAME); macro, loading fails immediately with undefined symbol errors.
  • Kconfig Discrepancies: The required subsystem is either disabled (# CONFIG_FOO is not set) or built as a module (CONFIG_FOO=m) when your driver was compiled against a header configuration expecting built-in linkage (CONFIG_FOO=y).
  • CRC Version Inconsistencies (CONFIG_MODVERSIONS): When symbol versioning is enabled, symbol CRCs computed from C type signatures are checked at load time. If a data structure changed between kernel builds, the module loader detects a CRC mismatch and rejects the module with disagrees about version of symbol.

2. Memory Leaks and Managed Device Resource (Devres) Lifecycles

Drivers allocate kernel memory (kmalloc, kzalloc, vmalloc) and hardware resources (I/O memory remappings, IRQ lines, DMA channels). Resource leaks typically manifest in two areas:

  • Incomplete Error Unwinding in probe(): When a driver probe function allocates resources incrementally and fails midway (e.g., fails to request an IRQ), failing to release previously allocated memory or unmap registers leaks memory on every failed probe attempt.
  • Devres (devm_*) vs. File Descriptor Lifecycles: While devm_kzalloc() automatically frees memory when a device is unbound (device_release_driver()), it is strictly bound to the struct device lifecycle. If a character device node (/dev/mydev) remains open by a user-space process when the hardware is unbound (e.g., dynamic overlay removal or hot-unplug), devres frees the device memory prematurely. Subsequent calls to read(), write(), or close() by user space dereference freed memory, causing a use-after-free kernel panic.

3. Concurrency Hazards and Atomic Context Violations

Linux enforces strict boundaries between process context and atomic (interrupt) context:

  • Process Context: Code runs on behalf of a user process or kernel thread (kthread). It has an associated struct task_struct and is permitted to sleep, block, allocate memory with GFP_KERNEL, or acquire sleepable locks (struct mutex, struct rw_semaphore).
  • Atomic Context: Includes hard IRQs (top halves), softirqs, tasklets, timer callbacks, and critical sections guarded by raw spinlocks. In atomic context, scheduling is disabled (in_atomic() != 0). Attempting to sleep or call functions that may sleep triggers the might_sleep() check and panics with BUG: scheduling while atomic.
  • Symmetric Multiprocessing (SMP) Deadlocks: If process-context code acquires a spinlock using plain spin_lock() rather than spin_lock_irqsave(), a hardware interrupt on the same CPU can fire, preempt process execution, and attempt to acquire the exact same spinlock. Because the interrupted process context cannot run to release the lock, the CPU spins endlessly in a self-deadlock.

4. Interrupt Subsystem Errors and Misconfigurations

Interrupt bugs in embedded drivers frequently destabilize the entire platform:

  • Unacknowledged Peripheral IRQ Registers: Level-triggered interrupts remain asserted at the interrupt controller until the peripheral’s internal interrupt status register is explicitly cleared (often via write-1-to-clear bits). If the top-half handler exits without clearing the peripheral flag, the interrupt controller immediately refires the ISR, creating an interrupt storm that starves user and kernel threads.
  • Shared IRQ Violations (IRQF_SHARED): When multiple devices share an interrupt line, every registered ISR on that line is executed sequentially upon an interrupt. If a handler returns IRQ_HANDLED without verifying that its specific hardware asserted the line, or returns IRQ_NONE when it was indeed responsible, the kernel disables the line after 100,000 unhandled iterations with irq XX: nobody cared.
  • Slow Bus Transactions in Hard IRQ: Attempting to read registers over I2C or SPI buses within a hard IRQ handler is illegal because I2C/SPI adapter drivers sleep while waiting for bus transfers or DMA completions. Threaded interrupts (request_threaded_irq()) are mandatory for slow-bus peripherals.

5. DMA Mapping Mistakes and Cache Incoherency

On non-coherent architectures common in embedded systems (such as ARM Cortex-A cores without hardware cache-coherent interconnects like CCI/CCN), the CPU cache and the DMA controller have divergent views of physical DRAM:

  • Coherent vs. Streaming DMA Confusion: Calling streaming synchronization APIs (dma_sync_single_*) on memory allocated via dma_alloc_coherent() is an architectural error. Coherent allocations are already mapped as non-cacheable or serviced by hardware snooping.
  • Missing Cache Maintenance: Streaming DMA buffers (dma_map_single()) require explicit cache flushes (DMA_TO_DEVICE cleans dirty L1/L2 cache lines to DRAM) before device transmission, and explicit cache invalidation (DMA_FROM_DEVICE invalidates stale cache lines) before CPU reads.
  • Cache Line False Sharing: If a streaming DMA buffer is not strictly aligned to the CPU’s cache line boundary (dma_get_cache_alignment(), typically 64 bytes on ARMv7/ARMv8), adjacent kernel data sharing the same cache line can be overwritten and corrupted during cache invalidation operations.

Diagnostic Triage Workflow

+-------------------------------------------------------------------------+
| Observed Symptom |
+-------------------------------------------------------------------------+
| | |
v (Module Load Failure) v (Kernel Oops / Freeze) v (Data Corruption)
+-----------------------------+ +-----------------------------+ +-----------------------------+
| Symbol Resolution | | Context & Locking | | DMA & Cache Coherency |
| - Inspect /proc/kallsyms | | - Check "scheduling while | | - Check dma_mapping_error |
| - Check EXPORT_SYMBOL_NS | | atomic" call trace | | - Verify ARCH_DMA_MINALIGN |
| - Validate modversions/CRC | | - Enable CONFIG_LOCKDEP | | - Match map/sync/unmap |
+-----------------------------+ +-----------------------------+ +-----------------------------+
\ | /
\ v /
+-------------------------------------------------------------------------+
| Targeted Kernel Instrumentation & Isolation |
| - ftrace function_graph & trace_printk() |
| - Dynamic debug (/sys/kernel/debug/dynamic_debug/control) |
| - /proc/interrupts delta tracking & /sys/kernel/debug/kmemleak |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Verification on Target Hardware |
| - Kernel sanitizers: CONFIG_KASAN, CONFIG_KFENCE, CONFIG_PROVE_LOCKING |
| - High-load stress testing (stress-ng, cyclic transfers) |
+-------------------------------------------------------------------------+

Solution Approaches

Each failure mode requires targeted architectural patterns, defensive coding standards, and precise kernel subsystem instrumentation.

1. Resolving Module Dependencies and Symbol Export

When dealing with symbol resolution errors:

  • Inspect Module Information: Use modinfo my_driver.ko to inspect module dependencies (depends), alias entries, and kernel signature (vermagic).
  • Audit Kernel Symbol Exports: Verify whether a missing symbol exists in /proc/kallsyms and check if it is exported to modules.
  • Support Symbol Namespaces: If a subsystem exports symbols using EXPORT_SYMBOL_NS_GPL(func, NAMESPACE), your module must explicitly import that namespace.
// In subsystem provider code:
int core_peripheral_register(struct my_device *dev)
{
/* Hardware registration logic */
return 0;
}
EXPORT_SYMBOL_NS_GPL(core_peripheral_register, PERIPHERAL_SUBSYSTEM);
// In your consumer driver code:
#include <linux/module.h>
MODULE_IMPORT_NS(PERIPHERAL_SUBSYSTEM);
int init_my_driver(void)
{
return core_peripheral_register(&my_dev);
}

To diagnose unresolved dependencies from the target terminal:

# Verify exported symbols in the active kernel
grep -w "core_peripheral_register" /proc/kallsyms
# Check module dependencies generated by depmod
modprobe --show-depends my_driver
# If using custom out-of-tree builds, rebuild the module dependency map
depmod -a

2. Managed Resource Lifecycles (Devres) and Leak Prevention

Managed device allocations (devres) tie the lifecycle of memory, IRQs, clocks, and I/O remaps directly to struct device. If probe() fails at any stage, all previously allocated devm_* resources are automatically released in reverse order of allocation, eliminating error unwinding boilerplate.

static int my_driver_probe(struct platform_device *pdev)
{
struct my_driver_priv *priv;
struct resource *res;
int irq, ret;
// Allocate per-device private structure tied to device lifecycle
priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
if (!priv)
return -ENOMEM;
platform_set_drvdata(pdev, priv);
// Map memory-mapped I/O registers safely
priv->base = devm_platform_ioremap_resource(pdev, 0);
if (IS_ERR(priv->base))
return PTR_ERR(priv->base);
// Obtain IRQ from Device Tree; platform_get_irq handles domain mapping
irq = platform_get_irq(pdev, 0);
if (irq < 0)
return irq;
// devm_request_threaded_irq automatically frees the IRQ on unbind
ret = devm_request_threaded_irq(&pdev->dev, irq, my_hard_irq,
my_threaded_irq, IRQF_ONESHOT,
dev_name(&pdev->dev), priv);
if (ret) {
dev_err(&pdev->dev, "Failed to request threaded IRQ %d: %d\n", irq, ret);
return ret;
}
return 0;
}

[!IMPORTANT] Devres Lifetime Hazard: Do not use devm_kzalloc() for data structures referenced by character devices (struct cdev) or network interfaces if user-space file descriptors can outlive the device. If the underlying platform device is unbound while a process holds /dev/mydev open, the devres-managed memory is freed, and the subsequent user-space close() or read() triggers a use-after-free crash. For hot-unpluggable devices, use reference counting via kref with a custom release callback.

To detect memory leaks dynamically during long-duration soak testing:

# Mount debugfs if not already present
mount -t debugfs none /sys/kernel/debug
# Trigger an immediate kmemleak scan
echo scan > /sys/kernel/debug/kmemleak
# View unreferenced memory allocations detected by the kernel
cat /sys/kernel/debug/kmemleak

3. Concurrency Control: Atomic vs. Process Context

To prevent deadlocks and scheduling while atomic crashes, synchronize access using the appropriate primitive based on the execution context:

  • Between Process Context and Hard IRQ: Process context must use spin_lock_irqsave(), which disables local interrupts on the current CPU and saves the interrupt state before acquiring the spinlock. The hard IRQ handler can then acquire the lock using spin_lock().
  • Between Process Context Threads: Use struct mutex when critical sections involve blocking I/O, sleep functions (msleep()), or memory allocations (GFP_KERNEL).
struct my_driver_priv {
spinlock_t hw_lock; // Guards hardware registers shared with ISR
struct mutex io_mutex; // Guards user-space read/write transactions
u32 rx_buffer[64];
size_t rx_count;
};
// Top-half Interrupt Service Routine (Atomic Context)
static irqreturn_t my_hard_irq(int irq, void *dev_id)
{
struct my_driver_priv *priv = dev_id;
// Hard IRQ already has local interrupts disabled on this CPU
spin_lock(&priv->hw_lock);
priv->rx_buffer[priv->rx_count++] = readl(priv->base + REG_DATA);
writel(STATUS_CLEAR_IRQ, priv->base + REG_STATUS);
spin_unlock(&priv->hw_lock);
return IRQ_HANDLED;
}
// Process Context (Syscall, e.g., read() or ioctl())
static ssize_t my_driver_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_driver_priv *priv = filp->private_data;
unsigned long flags;
u32 data_copy[64];
size_t count_copy;
// 1. Guard against concurrent processes
if (mutex_lock_interruptible(&priv->io_mutex))
return -ERESTARTSYS;
// 2. Atomically snapshot data shared with hard IRQ
spin_lock_irqsave(&priv->hw_lock, flags);
count_copy = priv->rx_count;
memcpy(data_copy, priv->rx_buffer, count_copy * sizeof(u32));
priv->rx_count = 0;
spin_unlock_irqrestore(&priv->hw_lock, flags);
mutex_unlock(&priv->io_mutex);
// 3. Copy to user space outside of any spinlock (copy_to_user may page fault / sleep)
if (copy_to_user(buf, data_copy, count_copy * sizeof(u32)))
return -EFAULT;
return count_copy * sizeof(u32);
}

4. Interrupt Handling: Primary vs. Threaded Handlers

For modern SoC drivers, hardware registers are frequently accessed across slow peripheral buses (I2C, SPI) or require complex processing. The Linux interrupt framework provides threaded interrupts (request_threaded_irq()):

// Primary (Hard IRQ) Handler: Must execute in microseconds
static irqreturn_t sensor_hard_irq(int irq, void *dev_id)
{
struct sensor_priv *priv = dev_id;
u32 status;
// Check if our hardware generated the interrupt (mandatory for IRQF_SHARED)
status = readl(priv->base + SENSOR_IRQ_STATUS);
if (!(status & SENSOR_IRQ_DATA_READY))
return IRQ_NONE;
// Acknowledge interrupt at peripheral level to deassert the line
writel(SENSOR_IRQ_DATA_READY, priv->base + SENSOR_IRQ_ACK);
// Wake the associated bottom-half kernel thread
return IRQ_WAKE_THREAD;
}
// Threaded Handler: Runs in a dedicated kernel thread (Process Context)
static irqreturn_t sensor_threaded_irq(int irq, void *dev_id)
{
struct sensor_priv *priv = dev_id;
// Permitted to sleep, perform SPI/I2C transactions, and allocate memory
sensor_read_fifo_over_spi(priv);
sensor_process_samples(priv);
return IRQ_HANDLED;
}

5. DMA Subsystem: Coherent vs. Streaming Allocations

Never mix coherent DMA allocations with streaming synchronization primitives. Choose the correct pattern based on buffer reuse:

Pattern A: Coherent DMA (Control Blocks & Ring Descriptors)

Used for bidirectional ring buffers and descriptors that the CPU and hardware device update frequently:

dma_addr_t ring_dma_handle;
void *ring_cpu_addr;
size_t ring_size = sizeof(struct hw_desc) * DESC_COUNT;
// Allocated in non-cacheable memory or hardware-coherent domain
ring_cpu_addr = dma_alloc_coherent(&pdev->dev, ring_size,
&ring_dma_handle, GFP_KERNEL);
if (!ring_cpu_addr)
return -ENOMEM;
// Provide ring_dma_handle (physical bus address) to hardware register
writel(lower_32_bits(ring_dma_handle), priv->base + REG_DESC_BASE);
// Free upon driver removal
dma_free_coherent(&pdev->dev, ring_size, ring_cpu_addr, ring_dma_handle);

Pattern B: Streaming DMA (Data Payloads & Network/Storage Packets)

Used for transient data buffers where maintaining coherent memory would incur prohibitive bus overhead:

void *data_buf = kmalloc(BUF_SIZE, GFP_KERNEL | GFP_DMA);
dma_addr_t dma_addr;
// 1. Map memory for device reception (DMA_FROM_DEVICE)
dma_addr = dma_map_single(&pdev->dev, data_buf, BUF_SIZE, DMA_FROM_DEVICE);
if (dma_mapping_error(&pdev->dev, dma_addr)) {
kfree(data_buf);
return -EIO;
}
// 2. Submit dma_addr to DMA descriptor and trigger transaction
trigger_dma_transfer(dma_addr, BUF_SIZE);
// 3. Wait for DMA completion interrupt (via completion or wait_event)
wait_for_completion(&priv->dma_done);
// 4. Invalidate CPU cache lines so CPU reads fresh DRAM contents
dma_sync_single_for_cpu(&pdev->dev, dma_addr, BUF_SIZE, DMA_FROM_DEVICE);
// CPU inspects/modifies data_buf here...
// 5. Unmap buffer when streaming transfer is finished
dma_unmap_single(&pdev->dev, dma_addr, BUF_SIZE, DMA_FROM_DEVICE);
kfree(data_buf);

Dynamic Kernel Instrumentation and Profiling

Dynamic kernel tracing provides non-invasive visibility into execution flow without requiring kernel recompilations:

Ftrace Function and Event Tracing

The tracefs pseudo-filesystem (mounted at /sys/kernel/tracing) allows filtering down to exact driver modules:

# Clear existing trace buffer
echo 0 > /sys/kernel/tracing/tracing_on
echo > /sys/kernel/tracing/trace
# Set tracer to function graph to inspect call hierarchy and execution duration
echo function_graph > /sys/kernel/tracing/current_tracer
# Restrict tracing strictly to functions within the target driver module
echo ':mod:my_driver' > /sys/kernel/tracing/set_ftrace_filter
# Enable tracing, execute user-space trigger, then stop tracing
echo 1 > /sys/kernel/tracing/tracing_on
my_test_application /dev/mydev
echo 0 > /sys/kernel/tracing/tracing_on
# Inspect execution trace with microsecond timing
cat /sys/kernel/tracing/trace | head -n 40

Dynamic Debug (CONFIG_DYNAMIC_DEBUG)

Enables runtime control over dev_dbg() and pr_debug() printks without kernel re-compilation:

# Enable all debug printks for a specific driver source file
echo "file my_driver.c +p" > /sys/kernel/debug/dynamic_debug/control
# Include function names (+f) and line numbers (+l) in log output
echo "file my_driver.c +flp" > /sys/kernel/debug/dynamic_debug/control
# Filter debug logs by format string
echo 'format "DMA transfer error" +p' > /sys/kernel/debug/dynamic_debug/control
# Inspect live dmesg output
dmesg -w

Complete Working Code Example

Below is a robust character driver demonstrating proper concurrency synchronization, defensive copy-to-user boundaries, standard error unwinding, and modern kernel API compatibility across Linux 5.x and 6.x kernels:

#include <linux/module.h>
#include <linux/init.h>
#include <linux/version.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/slab.h>
#include <linux/mutex.h>
#include <linux/uaccess.h>
#define DRIVER_NAME "debug_demo"
#define CLASS_NAME "debug_demo_class"
#define BUFFER_SIZE 256
struct debug_demo_dev {
struct cdev cdev;
struct mutex lock; /* Serializes user-space read/write accesses */
char buffer[BUFFER_SIZE];
size_t data_len;
};
static dev_t debug_dev_num;
static struct class *debug_class;
static struct debug_demo_dev *demo_dev;
static int debug_demo_open(struct inode *inode, struct file *filp)
{
struct debug_demo_dev *dev;
/* Retrieve device instance from inode's embedded cdev */
dev = container_of(inode->i_cdev, struct debug_demo_dev, cdev);
filp->private_data = dev;
return 0;
}
static int debug_demo_release(struct inode *inode, struct file *filp)
{
/* Clean up per-file session data if applicable */
filp->private_data = NULL;
return 0;
}
static ssize_t debug_demo_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
struct debug_demo_dev *dev = filp->private_data;
size_t bytes_to_read;
ssize_t ret = 0;
if (mutex_lock_interruptible(&dev->lock))
return -ERESTARTSYS;
if (*ppos >= dev->data_len)
goto out_unlock;
bytes_to_read = min_t(size_t, count, dev->data_len - *ppos);
if (copy_to_user(buf, dev->buffer + *ppos, bytes_to_read)) {
ret = -EFAULT;
goto out_unlock;
}
*ppos += bytes_to_read;
ret = bytes_to_read;
out_unlock:
mutex_unlock(&dev->lock);
return ret;
}
static ssize_t debug_demo_write(struct file *filp, const char __user *buf,
size_t count, loff_t *ppos)
{
struct debug_demo_dev *dev = filp->private_data;
size_t bytes_to_write;
ssize_t ret = 0;
if (mutex_lock_interruptible(&dev->lock))
return -ERESTARTSYS;
bytes_to_write = min_t(size_t, count, BUFFER_SIZE - 1);
if (copy_from_user(dev->buffer, buf, bytes_to_write)) {
ret = -EFAULT;
goto out_unlock;
}
dev->buffer[bytes_to_write] = '\0';
dev->data_len = bytes_to_write;
*ppos = bytes_to_write;
ret = bytes_to_write;
out_unlock:
mutex_unlock(&dev->lock);
return ret;
}
static const struct file_operations debug_demo_fops = {
.owner = THIS_MODULE,
.open = debug_demo_open,
.release = debug_demo_release,
.read = debug_demo_read,
.write = debug_demo_write,
.llseek = default_llseek,
};
static int __init debug_demo_init(void)
{
int ret;
struct device *dev;
/* 1. Allocate device numbers dynamically */
ret = alloc_chrdev_region(&debug_dev_num, 0, 1, DRIVER_NAME);
if (ret < 0) {
pr_err("%s: Failed to allocate major number (%d)\n", DRIVER_NAME, ret);
return ret;
}
/* 2. Allocate driver state explicitly */
demo_dev = kzalloc(sizeof(*demo_dev), GFP_KERNEL);
if (!demo_dev) {
ret = -ENOMEM;
goto err_unregister_chrdev;
}
mutex_init(&demo_dev->lock);
/* 3. Initialize and add character device */
cdev_init(&demo_dev->cdev, &debug_demo_fops);
demo_dev->cdev.owner = THIS_MODULE;
ret = cdev_add(&demo_dev->cdev, debug_dev_num, 1);
if (ret < 0) {
pr_err("%s: Failed to add cdev (%d)\n", DRIVER_NAME, ret);
goto err_kfree_dev;
}
/* 4. Create sysfs class (compatible with Linux >= 6.4 and older kernels) */
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0)
debug_class = class_create(CLASS_NAME);
#else
debug_class = class_create(THIS_MODULE, CLASS_NAME);
#endif
if (IS_ERR(debug_class)) {
ret = PTR_ERR(debug_class);
pr_err("%s: Failed to create device class (%d)\n", DRIVER_NAME, ret);
goto err_cdev_del;
}
/* 5. Create device node under /dev/debug_demo */
dev = device_create(debug_class, NULL, debug_dev_num, NULL, DRIVER_NAME);
if (IS_ERR(dev)) {
ret = PTR_ERR(dev);
pr_err("%s: Failed to create device node (%d)\n", DRIVER_NAME, ret);
goto err_class_destroy;
}
pr_info("%s: Initialized successfully (Major: %d, Minor: %d)\n",
DRIVER_NAME, MAJOR(debug_dev_num), MINOR(debug_dev_num));
return 0;
err_class_destroy:
class_destroy(debug_class);
err_cdev_del:
cdev_del(&demo_dev->cdev);
err_kfree_dev:
kfree(demo_dev);
err_unregister_chrdev:
unregister_chrdev_region(debug_dev_num, 1);
return ret;
}
static void __exit debug_demo_exit(void)
{
device_destroy(debug_class, debug_dev_num);
class_destroy(debug_class);
cdev_del(&demo_dev->cdev);
kfree(demo_dev);
unregister_chrdev_region(debug_dev_num, 1);
pr_info("%s: Cleaned up and removed successfully\n", DRIVER_NAME);
}
module_init(debug_demo_init);
module_exit(debug_demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Jithin Tom");
MODULE_DESCRIPTION("Hardened Linux character driver demonstrating clean synchronization and unwinding");

Architectural Highlights of the Implementation

  • Clean File-to-Device Binding: Uses container_of(inode->i_cdev, struct debug_demo_dev, cdev) inside open() rather than relying on brittle global variable assumptions, enabling seamless scaling to multi-minor devices.
  • Granular Process Mutex Protection: Critical sections inside read() and write() are scoped strictly to buffer copies and pointer updates using mutex_lock_interruptible(), correctly aborting if a pending signal arrives (-ERESTARTSYS).
  • Defensive Copy Limits: All user-space transfers use min_t(size_t, ...) bound checks preventing buffer overflows prior to copy_to_user() / copy_from_user().
  • Exhaustive Reverse Unwinding: Failure at step N in __init jumps to goto err_* labels that undo steps N-1 through 1 in strict reverse order, preventing slab leaks or dangling chrdev reservations.

Verification and Testing Steps

Systematic verification on physical target hardware or QEMU ARM64 validates driver reliability across stress conditions:

1. Symbol Resolution and Vermagic Audit

# Check unresolved symbols before insertion
nm -u debug_demo.ko
# Inspect module info and target kernel vermagic
modinfo debug_demo.ko | grep -E "vermagic|depends"
# Load module and inspect dmesg
insmod debug_demo.ko
dmesg | tail -n 10

2. Memory Leak Soak Testing (CONFIG_DEBUG_KMEMLEAK)

# Run continuous write/read loop
for i in $(seq 1 5000); do
echo "Soak test sample $i" > /dev/debug_demo
cat /dev/debug_demo > /dev/null
done
# Force kmemleak scan
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
# Expected result: zero unreferenced objects

3. Concurrency Stress Validation

# Spawn concurrent background readers and writers using stress-ng or shell jobs
for i in {1..8}; do
(while true; do echo "data_$i" > /dev/debug_demo; done) &
(while true; do cat /dev/debug_demo > /dev/null; done) &
done
# Let test run for 60 seconds, then terminate workers
sleep 60
kill $(jobs -p)
# Verify zero Lockdep warnings or kernel panics occurred
dmesg | grep -iE "lockdep|deadlock|WARNING|BUG"

4. Interrupt Storm & Rate Verification

# Record baseline interrupt count
grep "debug_demo" /proc/interrupts
# Execute high-throughput I/O workload
dd if=/dev/debug_demo of=/dev/null bs=64 count=10000
# Verify interrupt count incremented predictably without triggering "nobody cared"
grep "debug_demo" /proc/interrupts

5. Kernel Sanitizer Suite

Enable these configuration options in your target kernel .config before stress validation:

  • CONFIG_KASAN=y: Catches out-of-bounds slab/stack accesses and use-after-free bugs immediately.
  • CONFIG_KFENCE=y: Low-overhead sampling memory error detector suitable for target soaking.
  • CONFIG_PROVE_LOCKING=y (Lockdep): Tracks lock acquisitions and proves mathematical deadlock freedom.
  • CONFIG_DEBUG_ATOMIC_SLEEP=y: Catches any execution path that sleeps while holding spinlocks or in ISRs.
  • CONFIG_DMA_API_DEBUG=y: Verifies DMA buffer allocation, cache synchronization, and map/unmap integrity.

Summary

Debugging embedded Linux kernel drivers demands disciplined separation between execution contexts, rigorous adherence to device lifecycle boundaries, and proactive use of kernel instrumentation frameworks. By avoiding sleeping operations in atomic contexts, cleanly separating coherent from streaming DMA buffers, structuring error unwind paths in reverse order, and validating builds with Lockdep and KASAN, engineers can eliminate intermittent instabilities and ship production-grade embedded drivers.

References

  1. Linux Kernel Documentation - Driver Implementer’s API Guide: https://docs.kernel.org/driver-api/index.html
  2. Linux Kernel Documentation - Dynamic Debug Howto: https://docs.kernel.org/admin-guide/dynamic-debug-howto.html
  3. Linux Kernel Documentation - DMA Engine and Mapping API: https://docs.kernel.org/core-api/dma-api-howto.html
  4. Linux Kernel Documentation - Tracefs and Ftrace: https://docs.kernel.org/trace/ftrace.html
  5. Sysprog21 - The Linux Kernel Module Programming Guide: https://sysprog21.github.io/lkmpg/
  6. Linux Kernel Debugging - Kernel Newbies: https://kernelnewbies.org/KernelDocuments
  7. Linux Kernel Documentation - Kernel Address Sanitizer (KASAN): https://docs.kernel.org/dev-tools/kasan.html

Frequently Asked Questions

What are the most common issues in embedded Linux kernel drivers?

The primary failure modes include symbol resolution failures and missing namespace imports, devres lifecycle hazards versus character device file descriptor lifetimes, atomic context sleep violations (scheduling while atomic), unacknowledged interrupt lines triggering IRQ storms, and DMA cache incoherency on non-coherent architectures.

Why do kernel modules fail to load with undefined symbol errors in modern kernels?

In modern Linux kernels (5.4+), symbols may be isolated into symbol namespaces via EXPORT_SYMBOL_NS_GPL. If the consuming module does not import the namespace using MODULE_IMPORT_NS(), or if symbol CRCs mismatch under CONFIG_MODVERSIONS due to Kconfig struct discrepancies, insmod aborts symbol resolution.

What is the key difference between coherent DMA and streaming DMA during debugging?

Coherent DMA (dma_alloc_coherent) creates uncached or hardware-snooped memory suitable for descriptors, and must never be synced with dma_sync_single_*. Streaming DMA (dma_map_single) requires explicit cache maintenance (dma_sync_single_for_cpu and dma_sync_single_for_device) and cache line alignment to prevent data corruption.

Why does holding a spinlock while calling sleeping functions cause a kernel panic?

Acquiring a spinlock disables kernel preemption (or interrupts with spin_lock_irqsave). Functions that sleep (such as mutex_lock, copy_from_user, or kmalloc with GFP_KERNEL) invoke the scheduler, which detects an active atomic context and triggers a fatal 'BUG: scheduling while atomic' panic.

When should an embedded driver use threaded interrupts instead of top-half ISRs?

Threaded interrupts (request_threaded_irq) are mandatory when handling peripherals on slow serialized buses (I2C, SPI) or when processing requires blocking memory allocations or mutex acquisition, allowing the top half to quickly deassert the IRQ while the bottom half executes in kernel thread process context.

Tags

embedded-linuxkernel-driversdebugging

Share


Previous Article
Fixing SPI Clock Phase Polarity Mismatch in Multi-Slave
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Fixing TFLite Micro Model Loading Failures on STM32H7
Fixing TFLite Micro Model Loading Failures on STM32H7
September 23, 2026
5 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media