1. 项目概述:一次在龙芯平台上真实发生的死锁,远比教科书里写的更“拧巴”
“龙芯平台一次死锁分析过程”——这标题看着平实,甚至有点枯燥,但如果你真在国产CPU平台上跑过服务、调过内核、守过凌晨三点的告警,你就会知道,这七个字背后是一场没有硝烟的拉锯战。它不是理论推演,不是模拟环境下的玩具实验,而是某次生产环境升级后,某台部署在某高校计算中心的龙芯3A5000服务器,在持续运行72小时后突然卡死,SSH连不上,console无响应,只有风扇还在徒劳地高速旋转。我们面对的,是一个真实、顽固、拒绝配合调试的死锁现场。
核心关键词就三个:龙芯、死锁、分析过程。这里没有“一键修复”,没有“万能脚本”,只有从硬件寄存器到内核调度队列、从汇编指令流到应用层锁顺序的全栈式逆向追踪。龙芯平台的特殊性在于,它用的是自主指令集LoongArch,其内存模型、缓存一致性协议、中断处理路径,与x86或ARM有着本质差异。这意味着,你在x86上练就的那套“先看/proc/locks、再查dmesg、最后祭出perf”的套路,在龙芯上可能第一步就失效——因为/proc/locks里压根不显示LoongArch特有的锁状态标记,而dmesg里的报错信息,往往只有一行“scheduling while atomic”,像一句冰冷的判决书,却不告诉你谁在原子上下文中睡着了、又为什么睡着了。
这个分析过程,适合三类人:第一类是正在龙芯生态中做系统移植的工程师,你们需要知道,当熟悉的工具链突然“失语”时,该把探针插进哪里;第二类是嵌入式或实时系统开发者,你们对“原子操作”“自旋锁”“抢占禁用”的边界感比谁都敏感,而龙芯的LoongArch架构恰恰在这些边界上画了新的线;第三类是刚接触国产CPU的运维同学,你们不需要写一行内核代码,但必须能看懂crash工具输出的栈回溯里,那个反复出现的__mutex_lock_slowpath函数,为何在龙芯上会陷入一个永远无法被唤醒的等待循环。它解决的不是一个具体bug,而是帮你建立一套在非主流架构上“听诊脉搏”的方法论——当系统不再按预期呼吸时,你该去摸它的哪根动脉。
我试过用通用Linux调试手段硬刚,结果是整整两天毫无进展。直到我把逻辑倒过来:不假设“它应该是什么样”,而是先确认“它此刻到底在干什么”。我把服务器物理断电重启,在BIOS里打开LoongArch的硬件性能监控(HPE)寄存器采集,再用龙芯官方提供的loongarch-perf工具抓取死锁发生前10秒的精确指令流。数据回来一看,真相浮出水面——问题根本不在应用层的锁竞争,而在于一个被遗忘的驱动模块,在初始化时错误地调用了spin_lock_irqsave(),却在另一个CPU核心上,被一个高优先级的定时器中断打断,而该中断处理函数里,又试图获取同一把自旋锁。两个CPU在两条完全不同的执行路径上,用两套互不兼容的锁保护机制,死死咬住了对方。这不是代码写错了,这是对LoongArch中断嵌套规则的集体误读。所以,这篇复盘,不讲大道理,只讲那天晚上,我们是怎么一帧一帧,把这段“拧巴”的执行流,从硅片深处给抠出来的。
2. 龙芯平台死锁的底层逻辑与独特性:LoongArch不是x86的“平替”
2.1 死锁的本质没变,但触发的“土壤”彻底不同
死锁的四个必要条件——互斥、占有并等待、不可剥夺、循环等待——在任何CPU架构上都成立。但龙芯平台让这四个条件的满足方式,变得异常刁钻。关键就在于LoongArch指令集架构(ISA)的设计哲学:它追求的是确定性与可预测性,而非x86那种靠微码补丁堆砌出来的向后兼容。这就导致几个直接影响死锁行为的底层差异:
首先是内存序模型(Memory Ordering Model)。x86采用强内存序(Strong Ordering),绝大多数store指令的效果对其他CPU核心几乎是立即可见的;而LoongArch默认采用的是RCpc(Release Consistency with Processor Consistency)模型,它要求程序员必须显式插入lbar(Load Barrier)和sbar(Store Barrier)指令,才能保证内存操作的全局可见顺序。一个在x86上能“侥幸”通过的锁实现,在LoongArch上,很可能因为缺少一个sbar,导致一个CPU核心看到的锁状态,永远是“未加锁”的幻觉,从而一头撞进临界区,而另一个核心正拿着锁在等它退出——循环等待就此形成。我见过最典型的案例,就是一个自定义的环形缓冲区驱动,其head和tail指针的更新,只用了普通的addi和sw指令,没有任何屏障。在x86上跑了三年没问题,一迁移到龙芯3A5000,上线三天必死锁。
其次是中断处理与抢占的耦合关系。在x86 Linux中,“关中断”(cli)和“禁用内核抢占”(preempt_disable())是两个可以独立操作的动作。你可以关中断但允许抢占,也可以开中断但禁止抢占。但在LoongArch上,local_irq_disable()宏的实现,会同时禁用本地中断和内核抢占。这是一个硬性的硬件约束,源于LoongArch的CSR(Control and Status Register)寄存器设计。这意味着,一旦你的代码执行了spin_lock_irqsave(),它不仅关了中断,还把自己所在CPU的调度器给“锁死”了。此时,如果另一个CPU上的高优先级软中断(如网络收包的NET_RX_SOFTIRQ)恰好需要获取同一把锁,它就会在__mutex_lock_slowpath里无限自旋,而持有锁的那个CPU,又因为抢占被禁用,永远无法被调度出去释放锁——一个经典的“自旋锁+中断”死锁闭环,其根源,是架构层面对“关中断”语义的重新定义。
最后是缓存一致性协议(Cache Coherency Protocol)的差异。龙芯3A5000采用的是基于目录(Directory-based)的一致性协议,而非x86常用的MESI。这导致在多核场景下,一个CPU核心对某个缓存行的修改,其传播到其他核心的延迟和路径,与x86有显著不同。一个在x86上表现为“偶发超时”的锁争用,在龙芯上可能直接退化为“必然死锁”,因为缓存行无效化的时机,被LoongArch的目录协议“精准”地卡在了最要命的那个毫秒。
提示:不要试图用x86的经验去“猜”龙芯的行为。我曾花一整天,试图用
perf record -e cycles,instructions,cache-misses去复现一个死锁,结果发现cache-misses事件在LoongArch上根本无法准确反映真实的缓存行争用。后来才明白,LoongArch的性能监控单元(PMU)对“缓存一致性流量”的计数,是绑定在目录控制器上的,必须用loongarch-perf list命令,专门查看dir_*系列的事件,比如dir_snoop_req(目录嗅探请求)和dir_inv_ack(目录无效化应答),才能看到真正的瓶颈。
2.2 龙芯平台的调试工具链:不是“少”,而是“不一样”
很多人以为龙芯平台调试难,是因为工具少。其实不然。龙芯生态的调试工具链非常完整,只是它们的“语言”和x86不同。强行套用x86的思维去用,只会事倍功半。
crash工具:这是内核崩溃分析的基石。龙芯版crash(通常随kernel-debuginfo包一起安装)能完美解析LoongArch的vmlinux符号。但关键在于,你得会看它的输出。比如,bt(backtrace)命令显示的栈帧,其返回地址(RA)寄存器的值,在LoongArch上是ra,而不是x86的rip。更重要的是,crash的ps命令,在龙芯上会额外显示一个STATE字段,其值为UN(Uninterruptible)时,并不意味着进程在等IO,而极有可能是它正陷在一个自旋锁里,因为LoongArch的spin_lock实现,会让CPU进入一个bnez(Branch if Not Equal to Zero)的忙等循环,crash将其识别为一种特殊的不可中断状态。loongarch-perf:这是龙芯的“瑞士军刀”。它不仅能做常规的性能采样,还能直接读取LoongArch特有的CSR寄存器。比如,要确认某个CPU核心是否真的“卡死”,你可以用loongarch-perf stat -C 1 -e cycles,instructions,csr_0x1000000000000000(其中0x1000000000000000是CSR_TICLR,即Timer Interrupt Clear Register的地址)。如果csr_0x1000000000000000的计数值在10秒内纹丝不动,而cycles计数却在狂涨,那就铁证如山:这个CPU核心正在一个死循环里空转,连定时器中断都无法打断它。kgdb与gdb的联调:龙芯支持完整的内核源码级调试。但有一个致命细节:LoongArch的GDB远程协议(target remote)要求,gdb客户端必须使用龙芯官方编译的loongarch-linux-gnu-gdb,且其版本号必须与目标内核的CONFIG_DEBUG_INFO配置严格匹配。我曾因用了一个高版本的gdb去连一个低版本内核,gdb能连上,但所有变量的值都是乱码,折腾了六个小时才发现是gdb的符号解析器,对LoongArch的DWARF调试信息格式理解有偏差。
注意:龙芯平台没有
kdump的“一键式”配置。kdump的vmcore生成,依赖于kexec加载的捕获内核(capture kernel)能否正确初始化LoongArch的MMU和中断控制器。某次我们配置失败,不是因为kdump服务没启,而是因为捕获内核的initrd里,缺少了针对龙芯3A5000的loongson-pch驱动模块。dmesg里只有一行[ 0.000000] pch: probe failed,轻描淡写,却让整个崩溃转储流程瘫痪。
3. 一次真实死锁的完整分析过程:从现象到根因的七步法
3.1 第一步:现象确认与环境快照(耗时15分钟)
死锁发生后,第一反应不是重启,而是“保现场”。我们当时面对的是一台无人值守的计算节点,SSH已失联。所幸,该服务器配置了IPMI(Intelligent Platform Management Interface),我们通过IPMI的Web界面,远程打开了串口控制台(Serial-over-LAN, SOL)。
SOL界面一片死寂,光标静止。我们没有立刻按Ctrl+Alt+Del,而是先执行IPMI命令:ipmitool -I lanplus -H <IP> -U <USER> -P <PASS> sol activate,确保SOL连接稳定。- 然后,我们尝试发送一个
SysRq组合键。在LoongArch上,SysRq的触发键是Alt+PrintScreen,但通过SOL,我们用ipmitool的raw命令模拟:ipmitool -I lanplus -H <IP> -U <USER> -P <PASS> raw 0x00 0x08 0x01(这是发送SysRq的原始IPMI命令)。几秒钟后,SOL界面终于滚动出几行字:SysRq : Show Blocked State ... [ 123.456789] INFO: task ksoftirqd/1:12 blocked for more than 120 seconds. [ 123.456789] Not tainted 5.10.113-loongarch64 #1 [ 123.456789] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message. [ 123.456789] ksoftirqd/1 D 0 12 2 0x00000000 [ 123.456789] Call Trace: [ 123.456789] [<ffffffff8000a123>] __mutex_lock_slowpath+0x123/0x240 [ 123.456789] [<ffffffff8000b456>] mutex_lock+0x46/0x60 [ 123.456789] [<ffffffff8001c789>] netif_receive_skb_core+0x189/0x320
这一段日志,就是我们的“破案起点”。它明确告诉我们:ksoftirqd/1(CPU 1上的软中断守护进程)被阻塞了超过120秒,其调用栈最终停在__mutex_lock_slowpath。这说明,问题大概率出在一把互斥锁(mutex)上,且持有者极有可能是另一个CPU核心(很可能是CPU 0)。
实操心得:
SysRq是龙芯平台的“生命线”。务必在系统初始化脚本(如/etc/rc.local)中,加入echo 1 > /proc/sys/kernel/sysrq,并确保CONFIG_MAGIC_SYSRQ在内核配置中被启用。很多龙芯发行版默认是关闭的,因为它在某些LoongArch的早期固件上存在兼容性问题。
3.2 第二步:获取内核转储(vmcore)与符号文件(耗时20分钟)
有了初步线索,下一步是拿到“尸体”进行解剖。我们决定触发一次可控的内核崩溃,以生成vmcore。
- 我们再次通过IPMI发送
SysRq,这次是Alt+PrintScreen + c(c代表crash)。SOL界面瞬间刷出大量红色错误信息,然后黑屏。几秒钟后,IPMI检测到主机重启。 - 服务器重启后,我们登录,检查
/var/crash/目录。kdump服务已将vmcore和对应的vmlinux(带调试符号的内核镜像)保存在此。我们确认了vmcore的大小(约1.2GB,与物理内存大小吻合),并用file vmcore命令验证其格式为ELF 64-bit LSB core file, LoongArch64,确认无误。 - 关键一步:我们没有直接用
crash打开vmcore,而是先执行crash vmlinux vmcore --osrelease,检查crash工具是否能正确识别内核版本。输出为5.10.113-loongarch64,与uname -r一致,说明符号文件匹配。
注意:龙芯的
kdump内核(capture kernel)必须与主内核(production kernel)使用完全相同的内核配置(.config),尤其是CONFIG_LOONGARCH、CONFIG_SMP、CONFIG_HIGHMEM等与架构强相关的选项。我们曾因捕获内核忘了开启CONFIG_DEBUG_INFO_DWARF4,导致crash无法解析任何变量,白白浪费了4个小时。
3.3 第三步:用crash定位“持锁者”(耗时45分钟)
这是整个分析过程中最烧脑也最关键的一步。目标是找到那个正在__mutex_lock_slowpath里“睡觉”的ksoftirqd/1,它在等谁的锁?
启动
crash:crash vmlinux vmcore查看所有处于
D(Uninterruptible)状态的任务:ps | grep " D "。输出如下:PID PPID CPU TASK ST %MEM VSZ RSS COMM 12 2 1 ffff800008012000 D 0.0 123456 7890 ksoftirqd/1 345 2 0 ffff800008023000 R 0.1 234567 8901 kworker/0:1确认
PID 12(ksoftirqd/1)是唯一的D状态进程。查看它的完整调用栈:
bt 12。输出很长,我们聚焦在栈底(最老的调用):PID: 12 TASK: ffff800008012000 CPU: 1 COMMAND: "ksoftirqd/1" #0 [ffff800008013e80] __mutex_lock_slowpath at ffffffff8000a123 #1 [ffff800008013eb0] mutex_lock at ffffffff8000b456 #2 [ffff800008013ec0] netif_receive_skb_core at ffffffff8001c789 #3 [ffff800008013f00] __netif_receive_skb_one_core at ffffffff8001c9ab #4 [ffff800008013f20] __netif_receive_skb at ffffffff8001ca5c #5 [ffff800008013f40] process_backlog at ffffffff8001d123 #6 [ffff800008013f70] __do_softirq at ffffffff8000a987 #7 [ffff800008013fb0] do_softirq at ffffffff8000aa1b #8 [ffff800008013fc0] irq_exit at ffffffff8000ab2c #9 [ffff800008013fd0] do_IRQ at ffffffff8000ac1d #10 [ffff800008013ff0] ret_from_exception at ffffffff80000123栈顶是
__mutex_lock_slowpath,这没错。但关键信息在#0行的地址ffffffff8000a123。我们需要知道,这个地址对应的是哪一行C代码。执行sym ffffffff8000a123,得到:ffffffff8000a123 (t) __mutex_lock_slowpath+123还不够。我们用
dis命令反汇编这个函数的附近代码:crash> dis __mutex_lock_slowpath+120 20 ... ffffffff8000a120: 10000000 lw a0,0(a0) # load the mutex->count ffffffff8000a124: 00000000 bnez a0,ffffffff8000a120 # if count != 0, loop!看到了!
bnez a0, ...,这就是那个“死循环”。a0寄存器里存的是mutex->count的值。只要它不为零,CPU就永远跳回0x120地址,原地打转。那么,mutex->count现在是多少?我们得找到这个mutex结构体的地址。在
bt输出中,#0栈帧的参数是隐含的。__mutex_lock_slowpath的第一个参数,就是它要获取的struct mutex *lock。crash提供了foreach bt -s命令来显示每个栈帧的寄存器状态。执行bt -v 12,在#0帧附近,我们找到了:R0: 0000000000000000 R1: ffff800008013e80 R2: 0000000000000000 R3: 0000000000000000 R4: 0000000000000000 R5: 0000000000000000 R6: 0000000000000000 R7: 0000000000000000 R8: ffff800008013e80 R9: ffff800008013e80 R10: ffff800008013e80 R11: ffff800008013e80 R12: ffff800008013e80 R13: ffff800008013e80 R14: ffff800008013e80 R15: ffff800008013e80 R16: ffff800008013e80 R17: ffff800008013e80 R18: ffff800008013e80 R19: ffff800008013e80 R20: ffff800008013e80 R21: ffff800008013e80 R22: ffff800008013e80 R23: ffff800008013e80 R24: ffff800008013e80 R25: ffff800008013e80 R26: ffff800008013e80 R27: ffff800008013e80 R28: ffff800008013e80 R29: ffff800008013e80 R30: ffff800008013e80 R31: ffff800008013e80等等,这不对。所有寄存器都是同一个值?显然
bt -v在这里没能正确解析LoongArch的栈帧。我们换一个思路。__mutex_lock_slowpath的调用者是mutex_lock,而mutex_lock的调用者是netif_receive_skb_core。我们去看netif_receive_skb_core的汇编,找它调用__mutex_lock_slowpath之前的那条ld(load)指令,它一定把mutex的地址加载进了某个寄存器。执行
dis netif_receive_skb_core,在接近末尾处,我们找到了:ffffffff8001c780: 10000000 ld a0,0(s0) # s0 points to the 'struct sk_buff *skb' ffffffff8001c784: 10000000 ld a1,8(s0) # a1 = skb->dev ffffffff8001c788: 10000000 ld a2,16(a1) # a2 = dev->rx_lock (the mutex!) ffffffff8001c78c: 10000000 jal __mutex_lock_slowpath关键!
a2寄存器在调用前,被赋值为dev->rx_lock。a2的值,就是我们要找的mutex地址。crash的reg命令可以查看当前CPU的寄存器。但我们是在vmcore里,reg显示的是崩溃时的寄存器快照。我们执行reg -a,果然,在CPU 1的寄存器列表里,a2的值是ffff800008034560。现在,我们有了
mutex的地址:ffff800008034560。执行struct mutex ffff800008034560,crash输出:struct mutex { count = { counter = 0xfffffffffffffffe }, wait_list = { next = 0xffff800008012000, prev = 0xffff800008012000 }, owner = 0xffff800008023000, ... }counter = 0xfffffffffffffffe,也就是-2。这证实了我们的猜想:这是一个被持有的互斥锁,且持有者已经进入了“睡眠”状态(-2表示MUTEX_LOCKED且MUTEX_WAITING)。而owner字段指向0xffff800008023000,这正是PID 345(kworker/0:1)的TASK地址!最后一步,确认
kworker/0:1在干什么。执行bt 345:PID: 345 TASK: ffff800008023000 CPU: 0 COMMAND: "kworker/0:1" #0 [ffff800008024e80] __mutex_lock_slowpath at ffffffff8000a123 #1 [ffff800008024eb0] mutex_lock at ffffffff8000b456 #2 [ffff800008024ec0] my_driver_do_work at ffffffff8002a123 #3 [ffff800008024f00] process_one_work at ffffffff8000c9ab #4 [ffff800008024f20] worker_thread at ffffffff8000ca5c #5 [ffff800008024f40] kthread at ffffffff8000d123 #6 [ffff800008024f70] ret_from_fork at ffffffff80000123完美闭环!
kworker/0:1(CPU 0)也在__mutex_lock_slowpath里,它在等的,正是ksoftirqd/1(CPU 1)持有的那把锁。而ksoftirqd/1,又在等kworker/0:1释放锁。一个经典的双CPU死锁。
3.4 第四步:源码级根因分析(耗时60分钟)
现在,我们知道是my_driver_do_work和netif_receive_skb_core在争抢同一把锁。但为什么?我们得回到源码。
my_driver_do_work是我们自己写的驱动,其核心逻辑是:static void my_driver_do_work(struct work_struct *work) { struct my_device *dev = container_of(work, struct my_device, work); unsigned long flags; spin_lock_irqsave(&dev->lock, flags); // <-- 问题在这里! // ... do some hardware register access ... mutex_lock(&dev->rx_lock); // <-- 这里试图获取 rx_lock // ... process data ... mutex_unlock(&dev->rx_lock); spin_unlock_irqrestore(&dev->lock, flags); }- 而
netif_receive_skb_core的调用链是:网卡中断 ->napi_schedule->napi_poll->netif_receive_skb_core->mutex_lock(&dev->rx_lock)。
问题昭然若揭:my_driver_do_work在spin_lock_irqsave的保护下,试图获取一个mutex。这是Linux内核编程的绝对禁忌。spin_lock_irqsave会禁用本地中断和抢占,而mutex_lock是一个可能引起睡眠的函数(如果锁被占用,它会将当前进程置为TASK_UNINTERRUPTIBLE并调度出去)。在LoongArch上,由于spin_lock_irqsave禁用了抢占,mutex_lock的睡眠操作根本无法执行,它只能选择无限自旋,等待锁被释放。而释放锁的代码,又在my_driver_do_work的临界区内,形成了一个无法打破的闭环。
但为什么在x86上没出事?因为x86的spin_lock_irqsave只禁用中断,不强制禁用抢占。mutex_lock在x86上,即使拿不到锁,也能成功睡眠,把CPU让给其他进程,从而避免了死锁。而LoongArch的硬件设计,让这个“侥幸”不复存在。
实操心得:在龙芯平台上,
spin_lock_irqsave和mutex_lock的混用,是死锁的头号杀手。我们的代码规范现在强制规定:所有在中断上下文(包括软中断、tasklet、workqueue的process_one_work)中执行的代码,只能使用spin_lock系列,绝不能碰mutex。如果业务逻辑复杂到必须用mutex,那必须把这部分逻辑拆出来,放到一个独立的、由kthread管理的线程里去执行。
3.5 第五步:复现与验证(耗时30分钟)
理论分析完了,必须复现,才能100%确认。
- 我们修改了驱动代码,将
my_driver_do_work中mutex_lock的部分,全部移入一个新创建的kthread中:static int my_kthread_fn(void *data) { struct my_device *dev = data; while (!kthread_should_stop()) { mutex_lock(&dev->rx_lock); // ... process data ... mutex_unlock(&dev->rx_lock); msleep(10); // avoid busy loop } return 0; } - 重新编译、安装驱动,并启动
kthread。 - 然后,我们用一个简单的压力脚本,模拟高负载网络收包:
#!/bin/bash while true; do echo "test" | nc -u 127.0.0.1 5000 & sleep 0.001 done - 运行24小时,系统稳定如初。
crash检查ps,再也看不到D状态的ksoftirqd。
3.6 第六步:性能影响评估(耗时20分钟)
修复了死锁,但引入了kthread,性能会不会下降?我们必须量化。
我们用
loongarch-perf对比了修复前后的关键指标:指标 修复前(死锁版) 修复后(kthread版) 变化 cycles(per packet)12,500 13,200 +5.6% instructions(per packet)8,900 9,100 +2.2% dir_snoop_req(per second)1,200 850 -29% cache-misses(per packet)45 38 -15.5% 数据很有意思。虽然单包处理的
cycles和instructions略有上升(因为多了线程切换的开销),但dir_snoop_req和cache-misses却大幅下降。这说明,kthread方案虽然慢了一点点,但它让CPU核心之间的缓存行争用(cache line bouncing)减少了近三成。在高并发场景下,这种“用一点时间换空间”的策略,反而提升了整体吞吐量。我们在后续的netperf测试中,TCP_STREAM的吞吐量从1.8 Gbps提升到了2.1 Gbps。
3.7 第七步:编写补丁与提交(耗时10分钟)
最后一步,是把修复变成社区资产。
- 我们按照Linux内核的补丁规范,编写了标准的
patch文件:From: Your Name <your.email@example.com> Subject: [PATCH] mydriver: Fix deadlock on LoongArch by moving mutex to kthread ... diff --git a/drivers/mydriver/mydriver.c b/drivers/mydriver/mydriver.c index abc1234..def5678 100644 --- a/drivers/mydriver/mydriver.c +++ b/drivers/mydriver/mydriver.c @@ -123,10 +123,15 @@ static void my_driver_do_work(struct work_struct