系统调用与驱动开发月度回顾:7月关键排障案例汇编

系统调用与驱动开发月度回顾:7月关键排障案例汇编

一、背景与起点

7月排障记录显示,内核相关的线上问题主要集中在两个层面:
一是系统调用层面的参数校验和并发安全问题。
二是驱动层的DMA映射和中断处理的竞态条件。

这些问题有一个共同特征:
出问题的时候日志很少,甚至内核直接崩溃。
所以排查过程高度依赖ftrace、perf和crash dump分析。

本文整理了7月遇到的5个典型案例,
以及从中提炼出的排障方法论。
这些案例覆盖了系统调用实现和字符设备驱动两个方向。

二、系统调用三大排障案例

案例一:参数校验遗漏导致OOB访问

故障现象:自定义系统调用sys_read_record在生产环境偶发内核崩溃。
崩溃栈总是发生在record_buf[offset]附近的memcpy操作。

// 问题代码(简化) SYSCALL_DEFINE3(read_record, int, fd, char __user *, buf, size_t, count) { struct record *rec = fd_to_record(fd); if (!rec || !buf) return -EINVAL; // BUG: 没有校验count是否超出rec->size if (copy_to_user(buf, rec->data, count)) return -EFAULT; return count; }

问题根因:count参数没有上限校验。
当用户态传入count > rec->size时,copy_to_user会越界读取内核内存。
修复方案:

SYSCALL_DEFINE3(read_record, int, fd, char __user *, buf, size_t, count) { struct record *rec = fd_to_record(fd); if (!rec || !buf) return -EINVAL; // 修复:上限校验 if (count > rec->size) count = rec->size; if (count == 0) return 0; if (copy_to_user(buf, rec->data, count)) return -EFAULT; return count; }

教训:所有用户态传递的参数都必须做边界校验。
包括:指针(IS_ERR_OR_NULL)、长度(上限和下限)、
文件描述符(fget后检查)。

案例二:copy_from_user的并发竞态

故障现象:多线程同时调用sys_set_config时,
内核偶尔出现数据错乱。

// 问题代码 SYSCALL_DEFINE2(set_config, int, id, struct config __user *, ucfg) { static struct config g_cfg; // BUG: 全局变量无锁保护 if (copy_from_user(&g_cfg, ucfg, sizeof(g_cfg))) return -EFAULT; // 使用g_cfg做业务处理... apply_config(id, &g_cfg); return 0; }

这里有两个严重问题:

  1. g_cfg是静态全局变量,多CPU并发写入会互相覆盖。
  2. copy_from_user可能被调度中断,导致部分写入。

排查过程:使用ftrace捕获系统调用序列:

echo sys_set_config > /sys/kernel/debug/tracing/set_ftrace_filter echo function_graph > /sys/kernel/debug/tracing/current_tracer cat /sys/kernel/debug/tracing/trace

修复方案:使用per-CPU缓冲区或动态分配:

SYSCALL_DEFINE2(set_config, int, id, struct config __user *, ucfg) { struct config *cfg = kmalloc(sizeof(*cfg), GFP_KERNEL); if (!cfg) return -ENOMEM; if (copy_from_user(cfg, ucfg, sizeof(*cfg))) { kfree(cfg); return -EFAULT; } // 使用后释放 int ret = apply_config(id, cfg); kfree(cfg); return ret; }

案例三:RCU锁粒度问题

系统调用sys_get_stats在高并发下性能低于预期。
perf top显示热点在rcu_read_lock本身。

原因:该函数内部做了耗时操作(遍历链表+格式化输出),
却全程持有RCU读锁。

// 问题代码 SYSCALL_DEFINE0(get_stats) { struct stat_entry *entry; char *kbuf; rcu_read_lock(); // BUG: 在RCU锁内做大量非关键操作 list_for_each_entry_rcu(entry, &stat_list, list) { // 格式化、计算、拼接字符串... // 不应该在RCU锁内 } rcu_read_unlock(); }

修复:将RCU保护的范围缩小到只读取链表数据:

SYSCALL_DEFINE0(get_stats) { // 阶段1: RCU保护下快照数据 rcu_read_lock(); snapshot_stats_locked(); // 只复制必要字段 rcu_read_unlock(); // 阶段2: 无锁环境下格式化输出 format_stats_to_user(snapshot); }

三、驱动开发两大排障案例

案例四:DMA映射方向与缓存一致性

故障现象:自定义PCIe设备驱动在ARM64平台上,DMA读取的数据随机出现旧值。

排查发现:DMA映射时direction参数使用了DMA_BIDIRECTIONAL,
但实际只需要DMA_FROM_DEVICE。

// 问题代码 dma_addr_t dma_handle; void *cpu_addr = kmalloc(BUF_SIZE, GFP_KERNEL); // BUG: 双向映射导致CPU缓存未失效 dma_handle = dma_map_single(dev, cpu_addr, BUF_SIZE, DMA_BIDIRECTIONAL); // 设备DMA写入数据... dma_unmap_single(dev, dma_handle, BUF_SIZE, DMA_BIDIRECTIONAL); // 期望读到设备写入的数据,但读到缓存旧值

根本原因:DMA_BIDIRECTIONAL映射完成后,
CPU缓存中可能还有旧数据(unmap时处理较保守)。
而DMA_FROM_DEVICE在unmap阶段会显式失效CPU缓存行。

修复:

// 只接收数据 → 使用DMA_FROM_DEVICE dma_handle = dma_map_single(dev, cpu_addr, BUF_SIZE, DMA_FROM_DEVICE); // ... 设备写入 dma_unmap_single(dev, dma_handle, BUF_SIZE, DMA_FROM_DEVICE); // 只发送数据 → 使用DMA_TO_DEVICE dma_handle = dma_map_single(dev, cpu_addr, BUF_SIZE, DMA_TO_DEVICE);

案例五:中断bottom-half的竞态条件

自定义网卡驱动的ISR(中断服务程序)和tasklet之间存在竞态。

// ISR(硬中断上下文) static irqreturn_t nic_isr(int irq, void *dev_id) { struct nic_dev *dev = dev_id; // 读取硬件寄存器,确认中断源 u32 status = readl(dev->regs + IRQ_STATUS); writel(status, dev->regs + IRQ_CLEAR); // 调度bottom half if (status & RX_COMPLETE) tasklet_schedule(&dev->rx_tasklet); return IRQ_HANDLED; } // Bottom half(软中断上下文) static void rx_tasklet_handler(unsigned long data) { struct nic_dev *dev = (struct nic_dev *)data; struct rx_desc *desc = dev->rx_ring + dev->rx_head; // BUG: 中断可能在判断后、操作前再次触发 while (desc->status & DESC_OWN) { process_packet(desc->data, desc->len); desc->status &= ~DESC_OWN; dev->rx_head = (dev->rx_head + 1) % RX_RING_SIZE; desc = dev->rx_ring + dev->rx_head; } }

问题:硬件可能在新中断中修改desc->status
而tasklet没有任何同步保护。

修复:在tasklet中使用自旋锁保护关键区:

static void rx_tasklet_handler(unsigned long data) { struct nic_dev *dev = (struct nic_dev *)data; spin_lock_bh(&dev->rx_lock); // 禁用bh,保护数据 // ... 处理接收描述符 spin_unlock_bh(&dev->rx_lock); }

四、排障方法论的7月沉淀

经过这5个案例,形成了一套系统化的内核排障流程:

  1. 收集信息:dmesg、crash dump、/proc/vmcore。
  2. 缩小范围:根据崩溃地址和调用栈,定位具体函数。
  3. 复现实验:使用stress-ng或自定义工具构造并发场景。
  4. 动态追踪:ftrace跟踪函数调用、perf采样热点。
  5. 代码审查:重点检查锁、边界、并发三个维度。

五、总结

核心技术提炼:

  1. 系统调用安全三要素:边界校验(指针/长度/范围)、并发保护(锁或per-CPU)、内存安全(GFP_KERNEL vs GFP_ATOMIC)。
    遗漏任何一个都是定时炸弹。
  2. DMA映射方向是正确性而非性能:错误的方向不会报错,但会导致不可复现的数据错乱。
    DMA_FROM_DEVICE vs DMA_TO_DEVICE的选择是正确性要求,不是优化项。
  3. 中断上下文的黄金法则:ISR中禁止可能睡眠的操作(GFP_KERNEL、mutex_lock等),bottom-half中用spin_lock_bh保护共享数据。
    违反该规则→内核Panic。
  4. ftrace+perf是排障的瑞士军刀:function_graph跟踪调用链、perf top定位热点、crash分析转储。
    三件套可覆盖90%的内核问题。
  5. 锁的粒度=正确性与性能的平衡点:RCU锁内不做计算、自旋锁内不做IO、mutex不用于中断。
    粒度错误比不加锁更隐蔽更危险。