Linux内核调试与自旋锁使用详解
1. 内核配置选项与调试测试
在Linux内核开发中,有一个重要的配置选项 DEBUG_ATOMIC_SLEEP ,可以在 make menuconfig 的 Kernel Hacking 菜单中交互式地查找和设置该选项。其详细配置信息如下:
// lib/Kconfig.debug
[ ... ]
config DEBUG_ATOMIC_SLEEP
bool "Sleep inside atomic section checking"
select PREEMPT_COUNT
depends on DEBUG_KERNEL
depends on !ARCH_NO_PREEMPT
help
If you say Y here, various routines which may sleep will become very
noisy if they are called inside atomic sections: when a spinlock is
held, inside an rcu read side critical section, inside preempt disabled
sections, inside an interrupt, etc...
为了测试该配置选项的作用,我们在Ubuntu 20.04 LTS虚拟机上进行了对比测试。该虚拟机使用默认的通用5.4 Linux内核,通常未配置为“调试”内核,即 CONFIG_DEBUG_ATOMIC_SLEEP 内核配置选项未设置。
具体操作步骤如下:
1. 插入有缺陷的驱动:
$ sudo insmod ./miscdrv_rdwr_spinlock.ko buggy=1
- 运行测试程序向驱动写入新的机密信息:
$ ../../ch12/miscdrv_rdwr/rdwr_test_secret w /dev/llkd_miscdrv_rdwr_spinlock "passwdcosts500bucksd
- 查看内核日志:
$ dmesg
从内核日志输出可以看到,虽然我们知道写入方法存在严重的缺陷,但内核并没有崩溃,也没有报告任何问题。这是非常危险的,因为这可能会让我们错误地认为代码没有问题,而实际上存在一个潜在的严重缺陷,随时可能引发问题。
为了深入调查问题,我们使用 trace-cmd 工具再次运行测试程序。 trace-cmd 是一个强大的工具,它是Ftrace内核跟踪基础设施的便捷前端。操作步骤如下:
1. 记录跟踪信息:
$ sudo trace-cmd record -p function_graph -F ../../ch12/miscdrv_rdwr/rdwr_test_secret w /dev/llkd_
- 生成报告并保存到文件:
$ sudo trace-cmd report -I -S -l > report.txt
- 查看报告文件:
$ sudo less report.txt
从 trace-cmd 的输出中可以看到, write(2) 系统调用最终调用了驱动的 write_miscdrv_rdwr() 函数,并且在原子上下文中调用了 schedule_timeout() 和 schedule() 函数,这正是我们驱动中故意设置的缺陷。但由于当前运行的不是“调试”内核,该缺陷没有被报告。这充分证明了在“调试”内核上运行测试用例和进行内核开发的重要性。
2. LDV项目规则
LDV(Linux Driver Verification)项目针对Linux模块(主要是驱动程序)和核心内核的各种编程方面制定了有用的“规则”。其中一条规则是关于自旋锁和解锁函数的使用( http://linuxtesting.org/ldv/online?action=show_rule&rule_id=0039 ),该规则详细说明了自旋锁的正确使用方法,并展示了一个驱动中自旋锁被尝试释放两次的实际缺陷实例,这明显违反了锁定规则,会导致系统不稳定。
3. 自旋锁与中断的交互
在Linux内核开发中,我们已经学习了如何使用互斥锁和基本的自旋锁 spin_[un]lock() API。然而,在某些情况下,我们可能需要使用自旋锁的其他API变体。下面通过一个场景来详细说明:
假设我们是驱动程序开发者,正在开发的设备会触发硬件中断,因此我们编写了相应的中断处理程序。在实现驱动的读取方法时,发现其中存在一个非阻塞的关键代码段,我们可以使用自旋锁来保护它。但如果在读取方法的关键代码段执行期间,设备的硬件中断触发了,会发生什么情况呢?
硬件中断会抢占一切,控制权将转移到中断处理程序,从而抢占驱动的读取方法。这种情况是否会成为问题,取决于中断处理程序和读取方法的具体操作以及实现方式。下面我们来分析几种不同的场景:
| 场景 | 描述 | 是否有竞争 |
| ---- | ---- | ---- |
| 场景一 | 中断处理程序(理想情况下)仅使用局部变量,即使读取方法处于关键代码段,也不会有影响。中断处理会很快完成,控制权将返回给被中断的程序。 | 否 |
| 场景二 | 中断处理程序操作的是全局共享可写数据,但不是读取方法使用的数据项。因此,与读取代码没有冲突和竞争。不过,中断代码本身有一个关键代码段,需要使用自旋锁进行保护。 | 否 |
| 场景三 | 中断处理程序和读取方法操作的是相同的全局共享可写数据。在这种情况下,存在明显的竞争可能性,需要使用锁定机制。 | 是 |
下面重点分析场景三。我们需要使用自旋锁来保护中断处理代码中的关键代码段(因为在任何中断上下文中都不允许使用互斥锁)。并且,必须在读取方法和中断处理程序的代码路径中使用相同的自旋锁,否则无法实现有效的保护。
以下是错误的代码示例:
// Driver read method ; WRONG !
driver_read(...) << time t0 >>
{
[ ... ]
spin_lock(&slock);
<<--- time t1 : start of critical section >>
... << operating on global data object gCtx >> ...
spin_unlock(&slock);
<<--- time t2 : end of critical section >>
[ ... ]
} << time t3 >>
// 设备驱动的中断处理程序
handle_interrupt(...) << time t4; hardware interrupt fires! >>
{
[ ... ]
spin_lock(&slock);
<<--- time t5: start of critical section >>
... << operating on global data object gCtx >> ...
spin_unlock(&slock);
<<--- time t6 : end of critical section >>
[ ... ]
} << time t7 >>
在上述代码中,如果硬件中断在读取方法的关键代码段执行期间触发,中断处理程序会尝试获取相同的自旋锁,但由于该自旋锁已被锁定,中断处理程序会进入自旋等待状态,而读取方法无法继续执行解锁操作,从而导致死锁。
为了解决这个问题,我们可以使用自旋锁API的 _irq 变体:
#include <linux/spinlock.h>
void spin_lock_irq(spinlock_t *lock);
spin_lock_irq() 函数会在当前运行的处理器核心上禁用中断,从而避免通过中断产生的竞争。其实现会调用 local_irq_disable() 和 preempt_disable() 宏,同时禁用中断和内核抢占。对应的解锁函数是 spin_unlock_irq() 。
以下是正确的代码示例:
// Driver read method ; CORRECT !
driver_read(...) << time t0 >>
{
[ ... ]
spin_lock_irq(&slock);
<<--- time t1 : start of critical section >>
[now all interrupts + preemption on local CPU core are masked (disabled)]
... << operating on global data object gCtx >> ...
spin_unlock_irq(&slock);
<<--- time t2 : end of critical section >>
[ ... ]
} << time t3 >>
4. 中断掩码保存与恢复
在更复杂的项目中,可能会有开发者故意设置中断掩码,允许部分中断而阻止其他中断。如果在这种情况下使用 spin_lock_irq() 函数,可能会导致中断掩码被错误地恢复,从而破坏项目的正常运行。
为了解决这个问题,我们可以使用 spin_lock_irqsave() 和 spin_unlock_irqrestore() 这一对API来保存和恢复中断掩码:
#include <linux/spinlock.h>
unsigned long spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);
以下是使用示例:
spinlock_t slock;
spin_lock_init(&slock);
[ ... ]
driver_read(...)
{
[ ... ]
spin_lock_irqsave(&slock, flags);
<< ... critical section ... >>
spin_unlock_irqrestore(&slock, flags);
[ ... ]
}
5. 其他自旋锁API
如果任务小处理程序或软中断(下半部中断机制)的关键代码段与进程上下文代码路径存在竞争,可以使用 spin_lock_bh() 函数。该函数会禁用本地处理器的下半部中断,然后获取自旋锁,从而保护关键代码段。
void spin_lock_bh(spinlock_t *lock);
在性能敏感的代码路径中,使用最简单形式的自旋锁可以减少开销。但在某些情况下,可能需要使用更强的自旋锁API。例如,在5.4.0 Linux内核中,不同形式的自旋锁API的使用实例数量大致如下:
| 自旋锁API | 使用实例数量 |
| ---- | ---- |
| spin_lock() | 超过9400次 |
| spin_lock_irq() | 超过3600次 |
| spin_lock_irqsave() | 超过15000次 |
| spin_lock_bh() | 超过3700次 |
6. 自旋锁的内部实现
自旋锁的内部实现通常是与架构相关的代码,由原子机器语言指令组成,在微处理器上执行速度非常快。例如,在流行的x86[_64]架构上,自旋锁最终归结为对自旋锁结构成员的原子测试和设置机器指令(通常通过 cmpxchg 机器语言指令实现);在ARM机器上,通常使用 wfe (Wait For Event)和 SEV (SetEvent)机器指令。作为内核或驱动开发者,在使用自旋锁时,应仅使用暴露的API(和宏)。
7. 自旋锁使用总结
为了方便大家快速选择合适的自旋锁API,下面对自旋锁的使用进行简要总结:
- 最简单、最低开销 :当在进程上下文中保护关键代码段,且没有中断需要处理或中断不会产生竞争时,使用非中断自旋锁原语 spin_lock() / spin_unlock() 。
- 中等开销 :当中断会产生影响,且进程和中断上下文共享全局数据时,使用禁用中断(以及内核抢占)的版本 spin_lock_irq() / spin_unlock_irq() 。
- 最强(相对)、高开销 :这是最安全的使用方式,适用于需要确保绝对安全的场景。
通过以上对Linux内核调试和自旋锁使用的详细介绍,希望能帮助开发者更好地理解和掌握相关知识,提高内核开发的质量和效率。
Linux内核调试与自旋锁使用详解
8. 不同场景下自旋锁API选择流程图
为了更直观地展示在不同场景下如何选择合适的自旋锁API,下面给出一个mermaid格式的流程图:
graph TD;
A[是否有中断需要处理?] -->|否| B[使用spin_lock()/spin_unlock()];
A -->|是| C[中断与进程上下文是否共享全局数据?];
C -->|否| B;
C -->|是| D[是否有开发者设置了中断掩码?];
D -->|否| E[使用spin_lock_irq()/spin_unlock_irq()];
D -->|是| F[使用spin_lock_irqsave()/spin_unlock_irqrestore()];
G[任务小处理程序或软中断关键代码段与进程上下文竞争?] -->|是| H[使用spin_lock_bh()];
G -->|否| B;
9. 自旋锁使用示例代码汇总
为了方便大家查看和对比不同自旋锁API的使用,下面汇总了前面提到的示例代码:
// 错误示例:可能导致死锁
// Driver read method ; WRONG !
driver_read(...) << time t0 >>
{
[ ... ]
spin_lock(&slock);
<<--- time t1 : start of critical section >>
... << operating on global data object gCtx >> ...
spin_unlock(&slock);
<<--- time t2 : end of critical section >>
[ ... ]
} << time t3 >>
// 设备驱动的中断处理程序
handle_interrupt(...) << time t4; hardware interrupt fires! >>
{
[ ... ]
spin_lock(&slock);
<<--- time t5: start of critical section >>
... << operating on global data object gCtx >> ...
spin_unlock(&slock);
<<--- time t6 : end of critical section >>
[ ... ]
} << time t7 >>
// 正确示例:使用spin_lock_irq()
// Driver read method ; CORRECT !
driver_read(...) << time t0 >>
{
[ ... ]
spin_lock_irq(&slock);
<<--- time t1 : start of critical section >>
[now all interrupts + preemption on local CPU core are masked (disabled)]
... << operating on global data object gCtx >> ...
spin_unlock_irq(&slock);
<<--- time t2 : end of critical section >>
[ ... ]
} << time t3 >>
// 正确示例:使用spin_lock_irqsave()和spin_unlock_irqrestore()
spinlock_t slock;
spin_lock_init(&slock);
[ ... ]
driver_read(...)
{
[ ... ]
spin_lock_irqsave(&slock, flags);
<< ... critical section ... >>
spin_unlock_irqrestore(&slock, flags);
[ ... ]
}
// 使用spin_lock_bh()示例
void some_function()
{
spinlock_t lock;
spin_lock_init(&lock);
spin_lock_bh(&lock);
// 关键代码段
spin_unlock_bh(&lock);
}
10. 常见问题及解决方法
在使用自旋锁的过程中,可能会遇到一些常见问题,下面列出这些问题及相应的解决方法:
| 问题 | 描述 | 解决方法 |
| ---- | ---- | ---- |
| 死锁 | 如在读取方法关键代码段执行时中断触发,中断处理程序尝试获取已锁定的自旋锁,导致死锁 | 使用 spin_lock_irq() 或 spin_lock_irqsave() 等API,避免中断与关键代码段竞争 |
| 中断掩码错误恢复 | 使用 spin_lock_irq() 时,可能会错误地恢复中断掩码,破坏项目运行 | 使用 spin_lock_irqsave() 和 spin_unlock_irqrestore() 保存和恢复中断掩码 |
| 性能开销问题 | 在性能敏感的代码路径中,使用复杂的自旋锁API可能会增加开销 | 根据实际情况选择合适的自旋锁API,如简单场景使用 spin_lock() / spin_unlock() |
11. 总结
通过本文的介绍,我们深入了解了Linux内核调试中 DEBUG_ATOMIC_SLEEP 配置选项的作用,以及在不同内核配置下驱动程序的表现。同时,详细探讨了自旋锁与中断的交互问题,以及如何使用不同的自旋锁API来避免竞争和死锁。
在实际开发中,我们需要根据具体的场景选择合适的自旋锁API。如果没有中断需要处理或中断不会产生竞争,使用最简单的 spin_lock() / spin_unlock() 即可;如果中断与进程上下文共享全局数据,可根据是否有开发者设置中断掩码,选择 spin_lock_irq() 或 spin_lock_irqsave() ;如果任务小处理程序或软中断关键代码段与进程上下文竞争,则使用 spin_lock_bh() 。
总之,正确使用自旋锁对于保证内核和驱动程序的稳定性和性能至关重要。希望本文能帮助开发者更好地掌握自旋锁的使用,提高内核开发的质量和效率。



被折叠的 条评论
为什么被折叠?



