☰
Linux内核Per-CPU变量详解:接口、实现与性能优化实战
2026/10/7 21:37:47 网站建设 项目流程

做内核模块优化的时候,我遇到过一件印象很深的事:一个全局计数器在高并发下性能惨不忍睹,perf 一打,热点全在锁和 cache line 乒乓上。后来我把计数器改成 Linux 内核的 Per-CPU 变量,代码量几乎没增加,吞吐量直接上了一个台阶。从那时起我就意识到,理解和用好内核里静态/动态两套 Per-CPU 变量接口,是写出高性能内核代码的基本功。

这篇文章把我当时整理的笔记完整写出来:先讲 Per-CPU 变量解决了什么问题,再分别拆静态接口和动态接口的实现与用法,然后给出一份接口选型对照,最后分享几个我在实际项目中踩过的坑和调试思路。适合正在写驱动、做网络或存储模块优化、接触内核热路径的读者,准备内核面试的人也可以拿来做复习提纲。

1. 为什么 Per-CPU 变量能成为多核性能的救星

1.1 一次计数器竞态引发的连锁反应

先还原我当时遇到的场景。模块里维护了一个全局统计值,每次收包都执行atomic_inc(&global_counter)。在低流量下毫无问题,但流量上来后,原子操作带来的 cache line 争抢非常严重。多个 CPU 同时修改同一个内存地址,硬件要不断在核间同步缓存,表现就是:CPU 使用率不高,但吞吐上不去,perf里全是atomic_inc的等待和锁总线的开销。

这时候最容易想到的方案是加锁保护,或者拆分统计维度。但不管是 spinlock 还是 seqlock,只要多个 CPU 还在写同一个地址,性能瓶颈就依然存在。真正治本的办法是让每个 CPU 都有一份自己的变量副本,各自改各自的,互不干扰。这就是 Per-CPU 变量出现的根本原因。

Per-CPU 变量从设计上保证:每个 CPU 看到的是自己的副本,写入只在本地缓存行上发生,不存在跨核共享,也就不需要任何锁。以计数器为例,this_cpu_inc(counter)在 x86 上就是一条带%gs段前缀的指令,既没有锁,也没有函数调用开销。

1.2 Per-CPU 变量的核心设计:空间换锁

Per-CPU 变量的本质是“以空间换并发”。同一份逻辑数据,在内存里存在 N 份物理副本,N 等于系统可能的 CPU 数量。每个处理器访问自己的那份,把“多核并发写同一地址”的问题直接变成了“单核写本地地址”的问题。

这个思路的关键在于“隔离”:

  • 数据被放在特殊的 per-CPU 段里,链接成一份模板。
  • 系统启动时,为每个 CPU 复制出独立的运行时副本。
  • 访问接口通过 CPU 编号定位到具体副本。

这样做也确实有代价:内存占用会随 CPU 数量线性增加。假设一个 per-CPU 数组每个 CPU 占 4KB,128 核机器上就要吃掉 512KB。所以 Per-CPU 变量适合存放高频访问的小对象,不适合放巨大的缓冲区。大块数据一般用 per-CPU 页分配或其他机制,这点后面讲动态分配时会再提到。

1.3 接口的两个基本维度:静态/动态与按 CPU/按本 CPU

Per-CPU 变量接口可以按两个维度划分。

第一个维度是“变量的来源”:静态定义还是运行时分配。静态接口用DEFINE_PER_CPU在编译期声明变量,符号永久存在,访问时可以生成最高效的指令。动态接口用alloc_percpu在运行时分配,适合数量不确定的场景,比如每个设备队列一份统计数据。

第二个维度是“访问目标”:是按指定 CPU 编号访问副本,还是只访问“当前 CPU”的副本。前者是per_cpu(var, cpu),后者是this_cpu_read/write/inc以及get_cpu_var这一族接口。

这两个维度互相组合,基本上就构成了整个 Per-CPU 变量 API 的骨架:

  • 静态定义 + 按 CPU 访问:常用于遍历汇总。
  • 静态定义 + 本 CPU 访问:最常见的性能路径写法。
  • 动态分配 + 按 CPU 访问:遍历动态 per-CPU 数组。
  • 动态分配 + 本 CPU 访问:驱动热路径最常用的组合。

理解了这两个维度,再看各种宏就不会觉得乱。下面逐个拆解。

2. 静态 Per-CPU 变量:从 DEFINE_PER_CPU 到 GS 段寻址的完整链路

2.1 定义宏与 .data..percpu 段

静态 Per-CPU 变量靠一组宏定义。最基础的两个:

DEFINE_PER_CPU(unsigned long, my_counter); DEFINE_PER_CPU_ALIGNED(struct packet_stat, pkt_stats);

DEFINE_PER_CPU(type, name)展开后,实际上是把变量放进了一个专门的段:.data..percpu。这个段和普通.data段是分开的,链接器后续会对它做特殊处理。类似地还有DECLARE_PER_CPU,用于在头文件里声明外部变量。

除了基础宏,内核还提供了几个变体,各有明确用途:

  • DEFINE_PER_CPU_ALIGNED(type, name):按自然对齐要求对齐,适合需要避免 false sharing 的结构体。
  • DEFINE_PER_CPU_PAGE_ALIGNED(type, name):按页对齐,适合 TLB 性能敏感的场合。
  • DEFINE_PER_CPU_READ_MOSTLY(type, name):声明为“读多写少”,编译器可以优化访问路径,适合统计类的只读查询数据。

这里有个新手容易忽略的点:普通全局变量你直接取地址就能用,但 Per-CPU 变量不行。&my_counter拿到的地址只是链接期模板里的地址,不是当前 CPU 的运行时副本地址。所以访问 Per-CPU 变量必须走接口,不能当普通变量使。

2.2 链接脚本与 __per_cpu_start 符号的真相

静态 Per-CPU 段的布局由内核链接脚本控制。在include/asm-generic/vmlinux.lds.h里,有PERCPU_INPUT和PERCPU_OUTPUT这样的宏,它们会把所有.data..percpu输入段收集起来,排列成一个连续区间,并定义几个关键符号:

  • __per_cpu_start:per-CPU 段起始地址。
  • __per_cpu_end:per-CPU 段结束地址。
  • __per_cpu_load:per-CPU 段在 vmlinux 中的装载地址。

这三个符号看起来很像,含义却完全不同。__per_cpu_load指向的是静态数据的“原始拷贝”位置,系统启动时要把这份数据作为种子,复制到每个 CPU 的运行时区域。__per_cpu_start和__per_cpu_end表示链接期 per-CPU 区间的边界,但它不是任何 CPU 的实际运行时基址。

这个区分很重要。你查/proc/kallsyms看到__per_cpu_start的地址,那是链接地址,直接解引用它访问到的只是模板数据,不是本 CPU 的副本。内核里所有访问 Per-CPU 变量的宏,本质上都是“链接期地址 + 运行时偏移”的组合运算。

2.3 运行时偏移:__per_cpu_offset 是怎么来的

系统启动时,setup_per_cpu_areas()会为每个 CPU 分配独立的 per-CPU 运行时区域,并计算每个区域和链接期__per_cpu_start之间的偏移,保存在全局数组__per_cpu_offset[cpu]里。

访问指定 CPU 的副本,概念上就是一次地址换算:

CPU n 的变量地址 = __per_cpu_offset[n] + 链接期变量地址

per_cpu(var, cpu)这个宏做的事就是这个。它读per_cpu_offset(cpu),加到变量链接地址上,得到目标副本的地址,然后解引用。

在实际机器码层面,x86 做得更彻底。x86 架构把当前 CPU 的 per-CPU 基址放进了 GS 段寄存器,于是访问本 CPU 的 Per-CPU 变量,可以直接生成一条带%gs前缀的访存指令,不需要先查__per_cpu_offset数组。例如:

unsigned long v = this_cpu_read(my_counter);

在 x86_64 上反汇编,会看到类似这样的指令:

mov %gs:my_counter_offset, %rax

my_counter_offset在编译链接时就已经确定,是变量在__per_cpu_start段内的相对位置。GS 段基址指向当前 CPU 的 per-CPU 区域基址,两者一配合,一条指令就完成了本 CPU 变量的读取。这就是 Per-CPU 变量高性能的底层来源。

2.4 静态接口的完整访问矩阵

静态 Per-CPU 变量的访问接口,按“是否需要关心抢占”和“访问本 CPU 还是指定 CPU”可以分成几类。我用一张表概括:

接口访问目标抢占保护典型用途
per_cpu(var, cpu)指定 CPU无汇总、跨 CPU 读写
get_cpu_var(var)/put_cpu_var(var)本 CPU自动关 / 恢复短临界区,需要保证 CPU 稳定
this_cpu_read/write/inc/add本 CPU无(单指令安全)热路径计数、无调度点访问
__this_cpu_read/write/inc本 CPU无,不做额外检查中断或关抢占上下文
raw_cpu_read/write/inc本 CPU无,无任何检查启动早期、NMI 等极端场景

这里重点解释一下get_cpu_var和this_cpu_inc的区别。get_cpu_var内部会做preempt_disable(),保证你在访问期间不会被调度到其他 CPU,用完再put_cpu_var恢复。它安全,但有额外开销。

而this_cpu_inc这类操作,单条指令在执行的瞬间 CPU 不会变,所以即使不关抢占,这条指令也一定会作用于当前 CPU 的副本。问题出在多条指令的组合上:如果先this_cpu_read读出来,中间又调用了可能睡眠的函数,再this_cpu_write写回去,进程就可能被调度到另一个 CPU,导致读和写发生在不同副本上。这点后面踩坑部分会细讲。

2.5 模块中的静态 Per-CPU 变量

在可加载模块里定义静态 Per-CPU 变量,要注意导出和访问的问题。模块被加载时,模块自己的.data..percpu段会被拷贝到动态分配的 per-CPU 区域里,模块内的符号引用也会被重定位到拷贝后的位置。

如果模块 A 想访问模块 B 导出的 Per-CPU 变量,B 必须显式导出:

EXPORT_PER_CPU_SYMBOL(my_counter); EXPORT_PER_CPU_SYMBOL_GPL(my_counter);

另一个模块声明DECLARE_PER_CPU(unsigned long, my_counter);后,就可以用per_cpu(my_counter, cpu)或this_cpu_read(my_counter)访问。注意,导出的是符号,但使用方式依然是 per-CPU 接口,不能直接拿&my_counter当普通指针用。

3. 动态 Per-CPU 变量:alloc_percpu 背后的内存管理内幕

3.1 动态接口的语义与使用姿势

静态变量适合“一个内核里就一份”的全局场景,但很多驱动需要“每个实例一份”的 per-CPU 数据。比如每个网络设备都有自己的统计数组,设备数量运行时才知道,这时候就要用动态分配。

基本接口是:

int __percpu *stats; stats = alloc_percpu(int); if (!stats) return -ENOMEM; // 访问 CPU 2 上的副本 int *p = per_cpu_ptr(stats, 2); *p = *p + 1; // 访问当前 CPU 的副本 int *local = this_cpu_ptr(stats); (*local)++; free_percpu(stats);

注意alloc_percpu(int)的返回类型是int __percpu *。这里的__percpu是给 sparse 静态检查工具看的标注,表示这是一个 per-CPU 指针,不能用普通指针的规则去解引用。写代码时如果直接写*stats = 1,sparse 会报警告,语义上也是错误的。

动态 Per-CPU 变量的一个关键特性:它为所有 possible CPU 都分配了副本,不是只为当前 online CPU 分配。所以可以安全地用for_each_possible_cpu(cpu)去遍历。但要注意,访问一个 offline CPU 的副本虽然地址换算合法,里面的数据却是旧数据,不能想当然地当“当前在线值”用。

3.2 底层分配器:chunk、block 与 CPU 热插拔

动态 Per-CPU 变量由mm/percpu.c里的分配器统一管理,入口是pcpu_alloc()。分配器把 per-CPU 虚拟地址空间组织成一个一个的 chunk,每个 chunk 内部再划分成多个 block,按需分配。

大致流程是:

  1. 根据请求 size 在已有 chunk 里查找可用 block。
  2. 找到就分配,返回一个 per-CPU 逻辑指针。
  3. 找不到就新建 chunk,扩容映射,再做分配。
  4. 释放时把 block 回收到空闲链表,chunk 全空则可能销毁。

之所以要维护这么一套复杂结构,是因为 per-CPU 内存的“每个 CPU 一份”特性:分配器不能像普通 buddy 系统那样只记一个地址,它要保证每个 CPU 都有一块对应的区域,而且逻辑指针要能被换算到任意 CPU 的副本。这就是per_cpu_ptr(ptr, cpu)的价值——ptr只是逻辑地址,加上__per_cpu_offset[cpu]才是真实的内核地址。

CPU 热插拔场景下,分配器也要配合处理。CPU 上线时,需要建立该 CPU 的 per-CPU 映射;CPU 下线时,相关区域不会立刻释放,但数据语义上已经“冻结”。写热插拔回调时,遍历动态 per-CPU 数组要区分 online 和 possible,否则容易把下线 CPU 的历史数据混进实时统计里。

3.3 free_percpu 与生命周期管理

动态内存最忌讳的就是泄漏和悬垂。free_percpu(ptr)会释放所有 CPU 对应的副本。释放之后,任何对ptr的访问都是非法访问,轻则读到野数据,重则直接内核崩溃。

我见过一个比较隐蔽的问题:模块里把this_cpu_ptr(stats)的返回值保存到全局变量,后续热路径直接使用这个缓存的真实地址,模块退出时又调用了free_percpu(stats)。看起来没问题,但如果中间发生了 CPU 热插拔,或者模块卸载后其他代码还持有那个真实地址,悬垂指针就会造成随机崩溃。

规范的做法是:动态 per-CPU 指针作为资源统一管理,所有访问都通过ptr临时换算,不要缓存某个 CPU 的真实地址跨生命周期使用。释放前必须确保没有任何代码路径还持有该指针的引用。

另外,free_percpu不要放在原子上下文或中断上下文。虽然内部实现已经尽量优化,但它可能需要释放 chunk 并修改映射,属于可能睡眠的操作。进程上下文调用是最安全的。

4. 接口选型:get_cpu_var、this_cpu_ops、per_cpu_ptr 怎么选不翻车

4.1 三类接口的语义与性能差异

写 Per-CPU 代码时,最纠结的往往是到底用哪个接口。我总结了三个主要家族:

第一个是per_cpu(var, cpu)。它访问指定 CPU 的副本,不依赖当前 CPU,所以不涉及抢占问题。代价是要先读__per_cpu_offset[cpu]再访存,比本 CPU 访问多几步。它适合遍历汇总、跨 CPU 设置等非热路径。

第二个是get_cpu_var/put_cpu_var。它自动关闭抢占,然后返回当前 CPU 副本的指针,用完之后再恢复抢占。因为访问期间 CPU 不会变,所以是安全的。但preempt_disable/preempt_enable有开销,不适合循环里高频调用。

第三个是this_cpu_ops,也就是this_cpu_read/write/inc/add/dec这一族。它直接对当前 CPU 副本做操作,在 x86 上通常编译成单条指令,性能最好。如果只是做一个计数递增,this_cpu_inc就是最优解,不需要关抢占。

三者的取舍其实很清晰:

  • 要跨 CPU 读:用per_cpu。
  • 需要保证本 CPU 且临界区多步操作:用get_cpu_var。
  • 只需要单指令操作本 CPU:用this_cpu_ops。
  • 在已关闭抢占或中断上下文:用__this_cpu_ops。

this_cpu_ptr则是一个特殊的形态,它拿到当前 CPU 副本的真实指针,相当于per_cpu_ptr(ptr, smp_processor_id())。它不自动关抢占,所以在可抢占路径上,多步操作要自己保证 CPU 不迁移。

4.2 上下文约束:抢占、中断与 NMI

接口选型不能只看性能,还要看代码执行的上下文。不同类型的上下文,对接口的约束完全不一样。

进程上下文:可以被抢占。单指令操作可以用this_cpu_ops,多步操作必须用get_cpu_var或显式preempt_disable,否则进程可能中途被调度到别的 CPU,数据就串了。

中断上下文:当前 CPU 上的进程调度被阻断,但同 CPU 上中断和进程代码可能交替执行。这时单靠“CPU 不变”不够,还要防止中断打断临界区。如果是单条原子指令,比如this_cpu_inc,问题不大;如果是多步读写,可能需要local_irq_save或者local_bh_disable。

软中断上下文同理:中断返回后、进程调度前,softirq 可能在同一 CPU 上运行。per-CPU 数据如果是统计累加,通常在 softirq 和进程上下文中都会访问,那么就要考虑用local_bh_disable保护。

NMI 和启动早期是最极端的场景。NMI 中断里不能用可能触发调试检查的接口,一般只能用raw_cpu_ops。启动早期,per-CPU 区域可能还没完全设置好,这时候访问也要格外小心。我建议普通驱动代码默认用this_cpu_ops或get_cpu_var,碰到 NMI 场景再用raw_cpu_ops,不要一上来就追求“最底层”。

4.3 一张选型表与典型场景

把上面的讨论浓缩成一张表,实战中直接对着选:

场景推荐接口原因
本 CPU 单指令计数this_cpu_inc性能最好,无锁无抢占开销
本 CPU 多步操作get_cpu_var/put_cpu_var自动保证 CPU 稳定
遍历所有 CPU 汇总per_cpu(var, cpu)指定 CPU 副本,适合 for_each 循环
驱动动态 per-CPU 数组alloc_percpu+this_cpu_ptr按实例分配,访问灵活
关中断 / 原子上下文__this_cpu_ops已具备原子性前提,避免多余检查
NMI 等极端上下文raw_cpu_ops跳过所有检查和偏移修正
读多写少的统计快照DEFINE_PER_CPU_READ_MOSTLY+this_cpu_read编译期优化,访问快

具体到网络收包路径,最典型的组合是:收包软中断里用this_cpu_inc累加计数;读取统计时用for_each_possible_cpu(cpu)配合per_cpu(counter, cpu)汇总。这样写既高效,逻辑又清楚。

5. 我在 Per-CPU 接口上踩过的坑与调试定位思路

5.1 把 per_cpu_ptr 缓存在全局变量中的后果

这是我在一个网络统计模块里犯过的错。初始化时,我为了方便,把this_cpu_ptr(stats)的返回值存到了全局变量global_stats_ptr,之后所有 CPU 的收包路径都直接写(*global_stats_ptr)++。

表面上看,这确实访问到了“某个 CPU”的副本,但问题有两个:

第一,这个全局指针是在初始化时由初始化所在 CPU 换算出来的,它指向的是那个 CPU 的副本。其他 CPU 用它,等于跨 CPU 写同一份数据,Per-CPU 的隔离性完全失效,cache line 乒乓又回来了。

第二,如果这个指针跨越了 CPU 热插拔生命周期,指向的区域可能已经失效。更不要说模块卸载后其他路径还持有这个指针,那就是典型的 UAF。

正确的做法是:每次需要访问时,用this_cpu_ptr(stats)或per_cpu_ptr(stats, cpu)现场换算。虽然多几条指令,但保证了正确性。

5.2 动态指针直接解引用:一个典型的崩溃现场

另一个常见错误是把alloc_percpu返回的指针当成普通数组用。比如:

int __percpu *arr = alloc_percpu(int); // 错误写法 arr[0] = 1;

arr是 per-CPU 逻辑指针,不是 C 语言意义上的普通数组指针。直接解引用,在部分架构和内核版本上可能访问到某个 CPU 的副本(取决于具体实现),在另一些情况下直接崩溃。最要命的是这种问题具有随机性:你在一台机器上测没问题,换到别的架构或开启不同配置后就开始出诡异 bug。

正确写法是:

int *p = per_cpu_ptr(arr, cpu); *p = 1;

或者访问当前 CPU:

int *p = this_cpu_ptr(arr); *p = 1;

如果代码里有大量__percpu指针,建议开启 sparse 检查。编译时加make C=1 M=drivers/xxx/,sparse 会帮你找出泛型指针和__percpu指针混用的问题,能省下很多排查时间。

5.3 可抢占路径上的多步操作

第三个坑来自对this_cpu_read的过度自信。单指令操作没问题,但多步组合在可抢占路径上很容易出错:

// 危险写法 unsigned long a = this_cpu_read(seq_count); do_something_may_sleep(); // 这里可能发生调度 this_cpu_write(seq_count, a + 1);

如果do_something_may_sleep()真的触发了调度,进程可能从 CPU0 迁移到 CPU1。于是a是从 CPU0 副本读出来的,写回时却写到了 CPU1 副本,CPU0 的计数值丢失,CPU1 的计数值凭空多了一笔。

这种 bug 在低负载下很难复现,因为调度不一定发生。但线上流量一起来,统计数值就开始各种跳变。排查这类问题,建议先在可疑函数前后加上sched_preempt_enable_no_resched之类的调试输出观察调度点,或者直接用get_cpu_var把临界区包起来。

5.4 调试 Per-CPU 问题的实用工具与思路

Per-CPU 变量出问题时,现象往往是“计数少了”“数据写串了”,不像空指针那样直接崩溃,所以定位要靠工具和计算。

我常用的排查路径是这么几步:

第一步,先确认问题是不是“跨 CPU 副本”引起的。在热点路径打印raw_smp_processor_id()和所访问指针的实际地址,看同一指针在不同 CPU 上是否落在不同内存区域。如果所有 CPU 打印出的地址都一样,说明你的指针很可能被当普通指针缓存了。

第二步,检查链接期地址和运行时地址。/proc/kallsyms里看到的__per_cpu_start是链接地址,要换算成实际地址需要加上对应的__per_cpu_offset[cpu]。用 crash 或 gdb 查看__per_cpu_offset数组,可以快速判断访问的地址属于哪个 CPU 的副本。

第三步,打开内核调试选项。CONFIG_DEBUG_PREEMPT和CONFIG_PROVE_LOCKING能帮你抓上下文违规;CONFIG_DEBUG_ATOMIC_SLEEP能抓到在原子上下文里调用可能睡眠的函数。很多 per-CPU 误用都是先被这些选项抓出来的。

第四步,如果你在用 BPF 做线上排查,bpf_per_cpu_ptr()可以直接在 BPF 程序里访问内核的 per-CPU 变量,打印不同 CPU 上的副本值,对比之间就能看出数据有没有“串”。这个手段在排查线上统计异常时特别高效。

5.5 最后想提醒的两点

Per-CPU 变量解决的是“CPU 间并发写同一变量”的问题,它不解决“同一 CPU 上不同上下文交错”的问题。比如 softirq 和进程上下文都在访问同一个 per-CPU 计数,虽然都在 CPU0 上,但中断可能随时打断进程,数据一样会乱。这种场景要结合local_bh_disable、local_irq_save等机制使用,或者保证操作本身是单指令原子操作。

另外一点,不要把 Per-CPU 变量当成万能药。如果你的数据结构天然需要跨 CPU 共享和复杂同步,硬拆成 per-CPU 反而会让代码变得绕,还得额外处理汇总逻辑。门槛在于识别“真正被高频写入且可以容忍每 CPU 独立积累”的数据。统计计数、队列长度、分配器缓存这类场景是最典型的适用对象,而需要全局一致性的状态机就不适合硬套。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询