Linux 同步机制之 spinlock 设计与实现(二):ARM32实现

日期 :2026-07-27 · 参考源码版本:Linux 内核 5.x
版权声明 本文为原创技术文章,著作权归作者所有。未经作者书面授权,严禁任何形式的转载、摘编、复制、建立镜像、商业使用

写在前面

spinlock第一部分讲到 spinlock 的调用链止于 arch_spin_lock---"架构相关的原子获取"。这句话把真正的魔法藏了起来:arch_spin_lock 到底怎么自旋?怎么保证公平?怎么避免总线风暴?

本文揭开这一层,以ARM(32位)为例,讲清 票号锁(ticket lock) 的实现:每个 CPU 取一个号,按号顺序服务。我们会看到 ldrex/strex 独占访问、wfe 低功耗等待、dsb_sev 唤醒,以及内存屏障在获取/释放点的精巧位置。

阅读本文你将了解:

  • 简单 test-and-set 自旋锁为何不公平、会引发总线风暴
  • 票号锁的 next/owner 排队模型如何保证 FIFO 公平
  • ARM ldrex/strex 独占对如何实现原子"取号"
  • wfe/dsb_sev 如何降低等待功耗并唤醒
  • smp_mb 为何在获取末尾与释放开头各放一次

一、问题:为什么需要票号锁

1.1 简单自旋锁的问题

最朴素的自旋锁是 test-and-set:每个 CPU 在一个共享变量上原子地"读-改-写",抢到 0 者获得锁,其余继续自旋重试。这在多核上有两个问题:

  • 不公平:锁释放时所有等待者同时竞争,"运气好"的 CPU 可能反复抢到,某些 CPU 长期饥饿。
  • 总线风暴:每次原子读-改-写都会在总线上广播(独占缓存行),N 个等待者每轮触发 N 次总线流量,可扩展性差。

1.2 票号锁思想

票号锁模拟生活中的排队取号:

  • next :发号机,每次取号 next+1
  • owner :服务窗口,当前正在服务的号,释放时 owner+1
  • 每个 CPU 取到自己的号后,只等自己的号 == owner

这样等待者按取号顺序获得锁(FIFO 公平),且释放时只有一个 CPU 的 owner 读取会命中--它发现 owner == 我的号 即获得锁,其余继续 wfe 睡眠。总线流量从 O(N) 降到 O(1)。

二、核心设计思想:next/owner 排队模型

票号锁的核心是把"竞争获取"变成"排队等待"。如图 1 所示,每个 CPU 取号后排在队尾,服务窗口按号推进。

图 1:票号锁排队模型

flowchart LR N[next 发号机] -->|CPU 取号 next+1| Q[等待队列] O[owner 服务窗口] -->|释放 owner+1| Q Q --> S[号等于 owner 的 CPU 获得锁]
  • 公平:取号顺序即获得锁顺序,先到先得,无饥饿。
  • 总线友好 :释放时只 owner+1(一次缓存行写入);等待者用 wfe 进入低功耗,被 sev 唤醒后再读 owner--只有"下一个该获得锁的 CPU"会真正推进,其余继续睡。
  • 类型无关:next/owner 是纯整数计数器,与锁的具体位布局解耦。

不变量:任意时刻 owner <= nextnext - owner 即当前排队等待者数量;owner == next 表示锁空闲。

三、数据结构:arch_spinlock_t

arch/arm/include/asm/spinlock_types.h

c 复制代码
typedef struct {
    union {
        u32 slock;                       // 32 位锁值,next 与 owner 打包
        struct __raw_tickets {
#ifdef __ARMEB__                          // 大端:先声明字段位于高位
            u16 next;                     // 下一个票号,高 16 位
            u16 owner;                    // 当前服务票号,低 16 位
#else  // 小端:字段顺序对调,保 next 恒在高位
            u16 owner;                    // 当前服务票号,低 16 位
            u16 next;                     // 下一个票号,高 16 位
#endif
        } tickets;
    };
} arch_spinlock_t;

一个 32 位字拆成两半:高 16 位 next、低 16 位 owner(小端情形)。两者打包进一个 u32 是为了用一次原子访问同时读改两者 --ARM 的 ldrex/strex 操作 32 位字,一次独占加载拿到 next+owner,一次独占存储写回更新后的值。大端/小端只是字段顺序调整,语义一致。

TICKET_SHIFT 为 16:1 << TICKET_SHIFT 即把 next 字段(高 16 位)加 1;owner 字段(低 16 位)加 1 用普通 +1

四、关键操作原理

4.1 arch_spin_lock:独占取号 + 等待

arch/arm/include/asm/spinlock.h

c 复制代码
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
    unsigned long tmp;
    u32 newval;
    arch_spinlock_t lockval;

    prefetchw(&lock->slock);              // 预取锁到缓存(准备写入)
    __asm__ __volatile__(
"1:    ldrex    %0, [%3]\n"     // 独占加载当前锁值到 lockval
"    add    %1, %0, %4\n"       // newval = lockval + (1<<16)  即 next+1
"    strex    %2, %1, [%3]\n"   // 独占存储 newval,%2=0 表示成功
"    teq    %2, #0\n"           // 测试 strex 结果 %2 是否为 0
"    bne    1b"                 // 失败(独占被打破)则重试
    : "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
    : "r" (&lock->slock), "I" (1 << TICKET_SHIFT)
    : "cc");

    /* 等待直到自己的票号被服务 */
    while (lockval.tickets.next != lockval.tickets.owner) {
        wfe();                           // 等待事件,降低功耗
        lockval.tickets.owner = READ_ONCE(lock->tickets.owner);
    }

    smp_mb();                            // 获取内存屏障
}

分两阶段:

① 取号(ldrex/strex 独占对)

  • ldrex 独占加载 slock(拿到当前 next/owner),并对该缓存行打开"独占监视"。
  • add 把 next 字段加 1(1 << TICKET_SHIFT),得到 newval
  • strex 尝试独占存储 newval;若期间有其他 CPU 写过该缓存行,strex 失败(返回非 0),bne 1b 跳回重试。
  • 成功后 lockval.tickets.next 就是我取到的号(即原 next 值,我的号;锁的 next 已 +1 留给下一位)。

ldrex/strex 是 ARM 的独占监视(exclusive monitor)机制:ldrex 打开监视,strex 仅在监视未被清除时才写入并返回成功。它不锁总线、只监视缓存行,多核扩展性优于早期的 swp 指令。

② 等待服务(wfe 轮询)

  • next != owner(前面还有人),进入 while 循环:wfe 让 CPU 进入低功耗等待,被事件唤醒后重读 owner
  • READ_ONCE 保证每次都从内存读 owner(防编译器把它缓存到寄存器)。
  • owner 追上自己的 next(即 next == owner),获得锁,退出循环。

smp_mb():获取锁后加内存屏障,保证后续临界区读写在"已获锁"之后被其他 CPU 观察。持锁流程如图 2 所示。

图 2:arch_spin_lock 持锁流程

sequenceDiagram participant CPU0 CPU0->>CPU0: next = next + 1 CPU0->>CPU0: CPU0拿到票号0 CPU0->>CPU0: 检查owner是否等于CPU0票号 activate CPU0 CPU0->>CPU0: 如果票号相等则立即获得锁 CPU0->>CPU0: 进入临界区 deactivate CPU0

4.2 arch_spin_unlock:推进 owner + 唤醒

arch/arm/include/asm/spinlock.h

c 复制代码
static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
    smp_mb();                            // 释放内存屏障
    lock->tickets.owner++;               // owner+1,服务下一个等待者
    dsb_sev();                           // 数据屏障 + 发送事件唤醒等待者
}
  • smp_mb():释放 加屏障,保证临界区内的写操作在"锁释放"之前对其他 CPU 可见--与获取末尾的 smp_mb 配对,构成完整的临界区内存可见性。
  • owner++:推进服务号,下一个等待者(next == 新 owner)将获得锁。
  • dsb_sev()dsb 保证 owner++ 落地后再 sev(发送事件),唤醒所有 wfe 睡眠的 CPU。被唤醒者重读 owner,只有"下一个该获锁的"继续推进,其余重新 wfe

解锁流程如图 3 所示。

图 3:arch_spin_unlock 解锁流程

sequenceDiagram participant CPU0 as 锁持有者 CPU0 participant CPU1 as 等待者 CPU1 participant CPU2 as 等待者 CPU2 CPU0->>CPU0: 执行临界区操作 CPU0-->>CPU0: owner++ 0→1 CPU0-->>CPU1: dsb_sev 唤醒 CPU0-->>CPU2: dsb_sev 唤醒 Note over CPU1,CPU2: 所有等待CPU被唤醒 CPU1-->>CPU1: 检查owner==我的票号? CPU1-->>CPU1: owner=1,我的票号=1,获得锁 CPU2-->>CPU2: owner=1,我的票号=2,继续等待

获取把 smp_mb 放在末尾、释放放在开头,是经典的 ACQUIRE/RELEASE 语义:获取后临界区不会重排到锁外,释放前临界区不会重排到锁外。两者配对,保证临界区内存操作对下一个持锁者可见。

4.3 arch_spin_trylock:无竞争快速路径

arch/arm/include/asm/spinlock.h

c 复制代码
static inline int arch_spin_trylock(arch_spinlock_t *lock)
{
    unsigned long contended, res;
    u32 slock;

    prefetchw(&lock->slock);
    do {
        __asm__ __volatile__(
        "    ldrex    %0, [%3]\n"               // 独占加载 slock
        "    mov    %2, #0\n"                   // 初始化 res=0,供 strexeq 写入结果
        "    subs    %1, %0, %0, ror #16\n"     // contended = slock - ROR(slock,16),为 0 则空闲
        "    addeq    %0, %0, %4\n"             // 无竞争则 next+1
        "    strexeq    %2, %0, [%3]"           // 无竞争才尝试存储
        : "=&r" (slock), "=&r" (contended), "=&r" (res)
        : "r" (&lock->slock), "I" (1 << TICKET_SHIFT)
        : "cc");
    } while (res);                              // strexeq 失败(res≠0)则重试

    if (!contended) {
        smp_mb();
        return 1;                               // 成功
    } else {
        return 0;                               // 已被持有
    }
}

trylock 的关键在不自旋 :用 subs %1, %0, %0, ror #16 一条指令判断锁是否空闲。

  • ROR(slock, 16) 把 slock 的高低 16 位对调。若 next == owner(锁空闲),则 slock == ROR(slock,16)subs 结果为 0(contended=0,Z 位置位)。
  • addeq/strexeq 仅在 Z 位置位(无竞争)时执行 next+1 并存储--一次原子地"判定空闲 + 取号"。
  • 有竞争(contended != 0)则直接返回 0,不自旋。

ROR(slock,16) 巧妙地把"比较 next 与 owner 是否相等"压成一条减法:两者相等当且仅当高低 16 位互换后值不变。这是把数据结构布局(next/owner 同宽打包)与指令能力对齐的设计。

4.4 多 CPU 竞争流程

多个 CPU 同时取号时,ldrex/strex 的独占监视保证每次只有一个 CPU 的 strex 成功,失败者重试,从而 next 严格递增无遗漏。竞争流程如图 4 所示。

图 4:多 CPU 竞争取号流程

sequenceDiagram participant CPU0 participant CPU1 participant CPU2 participant Lock Note over Lock: 初始:next=0, owner=0 CPU0-->>Lock: spin_lock Note over Lock: next=1, owner=0 CPU0-->>CPU0: 获得锁,票号0 CPU1-->>Lock: spin_lock Note over Lock: next=2, owner=0 CPU1-->>CPU1: 等待,票号1 CPU2-->>Lock: spin_lock Note over Lock: next=3, owner=0 CPU2-->>CPU2: 等待,票号2 CPU0-->>Lock: spin_unlock Note over Lock: next=3, owner=1 Note over CPU1: 被唤醒,获得锁 CPU1-->>Lock: spin_unlock Note over Lock: next=3, owner=2 Note over CPU2: 被唤醒,获得锁

4.5 操作语义对照表

操作 语义 时间复杂度 典型用途
arch_spin_lock 独占取号,wfe 等待至 owner==我的号 取号 O(1),等待 O(队列长) 标准加锁路径
arch_spin_unlock owner++ 推进服务号,dsb_sev 唤醒 O(1) 释放锁
arch_spin_trylock 原子判空闲并取号;有竞争直接返回失败 O(1),不自旋 无阻塞尝试加锁

三者共享 ldrex/strex 独占对与 1<<TICKET_SHIFT 取号布局,差异仅在"是否等待"与"是否推进 owner"。

五、内存屏障:smp_mb 的位置

位置 屏障 作用
arch_spin_lock 末尾 smp_mb() 获取后,临界区读写不重排到获锁之前
arch_spin_unlock 开头 smp_mb() 释放前,临界区读写不重排到释放之后

两者配对构成 ACQUIRE/RELEASE 语义:保证同一把锁保护的临界区在所有 CPU 眼中的内存可见性顺序一致。ldrex/strex 本身保证取号的原子性,但 保证临界区内存操作的顺序--顺序由 smp_mb 兜底。

六、设计哲学小结

  1. 排队替代竞争:票号锁把 N 个 CPU 的获取竞争变成按号排队,公平性(FIFO)与可扩展性(释放时 O(1) 唤醒)兼得。
  2. 数据布局服务指令 :next/owner 同宽打包进 32 位,使一次 ldrex/strex 同时读改两者,且 trylock 能用一条 ROR 减法判空闲。
  3. 独占监视替代总线锁ldrex/strex 只监视缓存行、不锁总线,多核扩展性优于 swp
  4. 低功耗等待wfe 让等待者睡眠,dsb_sev 精准唤醒,避免忙轮询浪费 CPU 与功耗。
  5. 屏障在边界smp_mb 放获取末尾与释放开头,构成 ACQUIRE/RELEASE,把原子性(指令层)与可见性(内存序层)分离。

这些原则与篇一的通用层呼应:架构层用最少的原语(独占对 + 屏障 + 事件)实现公平、高效、低功耗的自旋,上层完全复用。

附录 A:延伸阅读

  • arch/arm/include/asm/spinlock.hspinlock_types.h:本文参考实现
  • 本系列篇一:spinlock 通用层调用链与 SMP/UP 分派
  • ARM Architecture Reference Manual:ldrex/strex/wfe/sev 指令语义
  • arch/x86/:x86 的 qspinlock(MCS 派生),更高竞争下的扩展方案
  • Documentation/locking/:lock子系统总览与各架构实现索引

版权声明(重申)

本文所有内容(包括文字、图表、代码注释与设计分析)均为作者独立创作,著作权归作者所有。

未经作者书面授权,严禁转载、摘编、复制、建立镜像、用于任何商业目的。

Copyright © 2026. All rights reserved. Unauthorized reproduction, distribution, or commercial use is strictly prohibited.

相关推荐
爱莉希雅&&&1 小时前
Rocky Linux 10.1 + K8s 1.36.3 离线集群部署笔记(Kubernetes)
linux·笔记·docker·kubernetes·calico
三8441 小时前
基于 NFS 共享存储的 Nginx Web 服务部署项目
linux·运维·服务器
逻极2 小时前
Shell脚本实战:从“能跑”到“高效”的3个关键跃升
linux·服务器·shell·脚本
ARM|X86+FPGA工业主板厂家2 小时前
Linux+Xenomai 实时系统在机器人中的应用
linux·运维·机器人
唔662 小时前
Linux工具使用情况buildroot
linux·运维·服务器
云泽8082 小时前
Linux 核心机制详解(三):文件系统权限与共享安全——从删除规则到粘滞位防护
linux·运维·安全
码农阿豪2 小时前
Prometheus怎么监控另一台Linux服务器?Node Exporter配置教程
linux·服务器·prometheus
xywww1682 小时前
Claude Opus 5 API 接入实战:国内项目上线前的网络、Key、限流和排错清单
大数据·linux·网络·数据库·云计算·aws
ShineWinsu2 小时前
对于Linux:http的解析
linux·网络·c++·网络协议·http·请求·响应