请选择 进入手机版 | 继续访问电脑版

你的浏览器版本过低,可能导致网站不能正常访问!
为了你能正常使用网站功能,请使用这些浏览器。

linux内核中percpu变量的实现

[复制链接]
gaosmile 发布时间:2021-2-20 10:48
我们在使用各种编程语言进行多线程编程时,经常会用到thread local变量。
; c3 k5 i1 k9 n& y& p

; Z2 G7 q% P' H# h0 p) v0 L
所谓thread local变量,就是对于同一个变量,每个线程都有自己的一份,对该变量的访问是线程隔离的,它们之间不会相互影响,所以也就不会有各种多线程问题。
- m& ^* c! V/ K5 ~( f- Q/ {8 {
正确的使用thread local变量,能极大的简化多线程开发。所以不管是c/c++/rust,还是java/c#等,都内置了对thread local变量的支持。
/ j; q$ m! d8 }2 Z) z5 l
2 ?# G' ^8 g- _
但你知道吗,不仅是在编程语言中,在linux内核中,也有一个类似的机制,用来实现类似的目的,它叫做percpu变量。
; K3 `. b6 ~3 B) J3 ?& b  |1 O
percpu变量,顾名思义,就是对于同一个变量,每个cpu都有自己的一份,它可以被用来存放一些cpu独有的数据,比如cpu的id,cpu上正在运行的线程等等,因该机制可以非常方便的解决一些特定问题,所以在内核编程中被广泛使用。

3 w7 w& |* \+ P8 V9 c# v' ?1 V6 v
好奇的你们肯定都在问,它是怎么实现的呢?
! n6 [* p; s- r7 e0 q+ P
我们先不管细节,先来看一张图,这样从全局的角度来了解下它的实现。
* P) U' a2 l6 z
微信图片_20210220104041.jpg

6 r) h0 ]+ D, p7 z
从上图中我们可以看到,各种源文件中通过DEFINE_PER_CPU的方式,定义了很多percpu变量,这些变量根据vmlinux.lds.S中的相关定义,会被linker聚合在一起,然后放到最终vmlinux文件的,一个名叫.data..percpu的section里。
! t* p; E# B: }/ B3 v% k
这些变量的地址也是被特殊处理过的,它们从零开始依次递增,这样一个变量的地址,就是该变量在整个vmlinux的.data..percpu区里的位置,有了这个位置,然后再知道某个cpu的percpu内存块的起始地址,就可以很方便的计算出该cpu对应的该变量的运行时内存地址。
. @: P) I# I/ L. q3 f" R
linux内核在启动时,会先把vmlinux文件加载到内存中,然后根据cpu的个数,为每个cpu都分配一块用于存放percpu变量的内存区域,之后把vmlinux中的.data..percpu section里的内容,拷贝到各个cpu的percpu内存块的static区域里,最后将各percpu内存块的起始地址放到对应cpu的gs寄存器里。
: N% s) \/ O( L3 {! ~5 g
: u+ O. K' ]  I0 }. w* h
到这里有关percpu变量的初始化工作就已经结束了。
; A6 k  n* N3 `5 s# f
8 z6 C& w0 m: @, {  w# x0 Q1 I
当我们在访问percpu变量时,只需要将gs寄存器里的地址,加上我们想要访问的percpu变量的地址,就能得到在该cpu上,该percpu变量真实的内存地址。
/ h: \, d) w' L9 G9 R
有了这个地址,我们就可以方便的操作这个percpu变量了。

# L7 [8 Z" @, z8 K: ~7 g
上图中重点描述的是那些,在内核编译期就已经确定的percpu变量,这些变量是静态的,是不会随着时间的推移而动态的增加或减少的,所以它们在内核初始化时,就直接被拷贝到了各个percpu内存块的static区。
( M) U1 V1 x. e, d$ c- p, d! V
除了这种静态percpu变量,还有另外两种percpu变量。

4 a( T- i! d# b. @8 D: l( z2 n
其中一种是内核模块中的静态percpu变量,它虽然也是在编译期就能确定的,但由于内核模块动态加载的特性,它不是完全静态的,内核为这种percpu变量在percpu内存块中单独开辟了一个区域,叫reserved区,当内核模块被加载到内存时,其静态percpu变量就会在这个区域分配内存。
3 C( S3 f% p! g% d, g- G$ v
另外一种percpu变量就是纯动态的percpu变量,它是在运行时动态分配的,它使用的内存是上图中的dynamic区。
) m1 V% }+ e: ~* J# [* J0 v! v

  J# p; c1 E+ H6 m
static区的大小是在编译期就算好的,是固定不变的,reserved区也是固定不变的,但其大小是预估的,dynamic区是可以动态增加的。
+ W" f$ s. Z7 O7 b* G- o
虽然这三种percpu变量的分配方式不同,但它们的内在机制本质上都是一样的,所以这里我们只讲内核里的静态percpu变量,对其他两种方式感兴趣的同学,可以参考内核源码自己研究下。

& e/ v/ ?% O3 P, ?7 P" T! W/ m% P7 Y
下面我们就用一个具体的例子,来看下percpu变量到底是怎么实现的。

3 h* s7 `  E9 _( O
微信图片_20210220103851.png

5 {* C1 x) M% l0 j7 b
上图中的current表示要获取当前线程对象,它其实是一个宏,具体定义如下:
$ b6 w" F1 U8 j4 x" d8 V! V! W$ y" m* m

" x4 j9 e- B* [; _8 q; T; c6 N
微信图片_20210220103854.png

  V( b, N9 x9 K0 b/ `/ S
由上可见,current获取的当前线程对象其实是一个名为current_task的percpu变量。
* T" g; C7 [1 d8 I
在get_current方法中,通过this_cpu_read_stable方法,获取属于当前cpu的current_task。
9 |, p" x" U" c. n( x
this_cpu_read_stable方法其实也是一个宏,它全部展开后是下面这个样子:
2 u1 u6 A$ w2 U
微信图片_20210220103857.png
# L4 M1 X8 _! F  G) Z% S
- l2 z- J9 L: k$ t
在这里,我们先不讲宏展开后各语句到底是什么意思,我们先跑个题。' R# i; `, O: T* E3 N# |' M+ n
读过linux内核源码的同学都知道,在linux内核中,宏使用的非常多,且比较复杂,如果我们对自己进行宏展开的正确性没有信心的话,可以使用下面我介绍的这个方式,使用它,你可以非常容易的得到任意文件宏展开后的结果。% q6 E# g: l; J% x
我们知道,一个程序的构建分为预处理、编译、汇编、链接这些阶段,而宏展开就发生在预处理阶段。
8 m0 _, V( V/ Y! Y2 r) `

# w9 ~" a9 w: F4 r
各个阶段在完成后,一般都会生成一个临时文件给下一阶段使用,这些临时文件默认是不会保存到磁盘上的,但我们可以通过指定一些参数,告知gcc帮我们保留下来这些临时文件,这样我们就可以查看各个阶段的生成内容了。
) q# @7 l" @5 M) x* b9 T! Y) n
/ s$ H( ]) b& @1 ?
依据该思路,我们只要在编译比如上面的net/socket.c文件时,加上这些参数,我们就能得到这些临时文件,也就可以查看其预处理之后的宏展开是什么样子的了。
( ^! M5 ?  H9 q- ?, _8 K
' `, L! [; k6 j: W
但是,如果只是为了查看单个文件的宏展开后结果,就保存下整个内核中,所有源文件编译时的临时文件,这是非常耗时且不划算的,那有没有办法可以想查看哪个文件的宏展开,就单独编译一次那个文件呢?8 j) Q( x* W3 z
还真有。" O: W% z6 p3 v9 j  b! |( F
其实说起来该方法也很简单,我们只需要知道编译某个文件时使用的编译命令是什么,这样当我们需要查看这个文件的宏展开时,再使用这个编译命令,且加上一些特定的参数,再编译一遍,这样就能得到该文件编译过程中,各阶段的临时文件了。5 r3 X/ w8 _! j! t

/ j* `! v3 v/ V3 T" Z3 ~, ^9 I* w+ M, ^
那如何找到编译各个源文件时使用的命令呢?2 g5 U# n  X1 t0 r* `" E

9 B/ @  _& J; t! Q7 R: X
这个内核其实已经帮我们做好了。
5 v( y8 I- p" F1 @* U$ ~
当我们在编译内核时,内核中每个文件被编译时使用的命令,都会保存到一个对应的临时文件里,比如上面net/socket.c文件的编译命令就保存在下面的文件里:
. N/ K# L# D% K  `3 |

' [, e3 R' d( ^9 Q
微信图片_20210220103900.png
' ^5 u2 \, D6 o7 ]7 A2 J
2 o8 k1 o$ P% ?2 }/ @
net/socket.c的编译命令就是上图中的第一行,从gcc开始到该行结束的部分。
( e* Y' T# L0 y1 \0 h$ N+ R
( K; s( y5 V3 a% Y; L# ~  U+ C
这个编译命令够复杂吧,但我们不用管,我们只用知道,使用该命令,就可以将net/socket.c编译成net/socket.o。
! R: h# o3 {. _6 \' o9 t0 L
现在我们在该命令的基础上,加上-save-temps=obj参数,告知gcc在编译时保留下各阶段的临时文件,具体操作流程如下:
9 ?  t9 |! g2 l8 j5 K
. l% ~3 s+ W2 `0 V# x
微信图片_20210220103903.png

9 e+ R1 H4 |% P  O& E

9 b0 r% }; i" q  A. |0 v1 w
由上可见,加上-save-temps=obj参数后,该编译过程多生成两个文件,而net/socket.i就是gcc预处理之后的文件。8 h; a( s7 `& V- t
打开net/socket.i,并找到我们需要的get_current方法:0 D3 u4 i% V! m9 k3 f% `0 Z" [

, A7 y% ?$ D6 ]0 D' T! p

( u# x( j9 F! U7 D' O6 a# D
微信图片_20210220103906.png
2 x% t3 c4 J, j" B( e8 c, m
+ k( O. S) L% ?$ R3 a. B3 @/ W
看上图中的选中部分,其内容和我们自己宏展开后的结果,是完全一样的。( T% i4 L) E8 S

0 P, p4 R; z2 j; W
这个方法还不错吧。
# V- F6 m1 Q; d6 v" \! ^1 a
当然,我们还可以通过反编译的方式,进一步确认下宏展开后确实是这样:
7 R- X7 ?) O6 b2 t; x& z* `
& i! u, U# z  F5 Q/ ?$ O7 {
6 k+ G$ ]: @  {% O- W  m; O
微信图片_20210220103914.png
* ]% T; b6 L" W( L& j7 o0 a

" H" n" F/ J, y. U# t
由上可见,宏展开后其实主要就是一条mov指令,其中current_task变量地址的值为0x16d00。
) F$ I( P2 W3 Z. `# ]. m( U
该指令的意思是,将gs寄存器里的地址,和current_task的地址相加,然后将相加后地址指向的内存空间里的值,移动到rax里。
. J% e9 o9 Q* [! |
这个和我们上面提到的,percpu的实现机制是一致的。
9 p* D8 `6 {0 t/ d) g: l
好,我们回到上文中断的部分,来继续看下get_current方法里宏展开后各语句的意思。
, M% U% h# v, j& r4 R* s, g
上文讲到,get_current方法里的this_cpu_read_stable方法宏展开后主要是一条asm语句,可能有些同学对该语句不太熟悉,它其实并不是c语言标准规范里的语法,而是gcc对c标准的扩展,通过asm语句,我们可以在c中直接执行汇编指令。
$ h0 C. M1 p" i3 G$ U3 {5 Z9 C8 ^
有关其详细的语法规则,可以参考以下链接:
, p/ G4 w! m7 `& K

- N$ L7 T0 j" Q& k/ ~
https://gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html#Using-Assembly-Language-with-C2 ^$ `4 x+ Q# f) X
不关心细节的同学可以不用去看具体语法,我们只要知道该asm语句的意思是,获取current_task的地址,将该地址与gs段寄存器里的基础地址值相加,得到一个最终的地址,然后通过mov指令,将该最终地址指向的内存的值,放到pfo_val__变量里。# I2 ?# @5 U0 p
该指令执行完毕后,pfo_val__变量里存放的值,就是当前cpu执行的当前线程对象struct task_struct的地址,也就是说,pfo_val__变量为当前正在执行的线程对象的指针。5 f( \5 J* W# |+ D; R
那为什么通过这种方式,得到的就是当前cpu正在执行的当前线程对象的指针呢?
0 K7 z7 d4 C1 p# I. B; C; g
这个其实上文我们已经讲过了,关键点在于gs寄存器中存放的是当前cpu的percpu内存块的起始地址,而current_task的地址表示的又是,current_task变量在任意percpu内存块的位置,所以这两个地址一相加,得到的自然就是当前cpu的current_task变量的当前值了。% @4 t7 Z/ |% `5 E6 T3 B. r* ?, a" q
理论上是如此,不过我们还是通过源码角度再看下。3 }' ~1 k6 p& K8 u
首先我们来看下current_task变量的定义:( H5 C9 L- S' X
  a8 R0 P: J- @

5 V1 v: Q3 p% T# x5 U2 y
微信图片_20210220103916.png

9 I  v: `5 S" f; y% o1 y

1 O. [; s+ r. I" o
DEFINE_PER_CPU还是一个宏,其展开后如下:! I! j6 o! U% O; ~4 P
2 ^3 `! z/ X. `% c
微信图片_20210220103919.png

- Q3 R8 F3 ]/ c) U

/ ]7 P/ u) L' w: x  f
在宏展开后的变量定义中,最重要的是指定该变量的section为.data..percpu。8 H/ H, M* x9 C8 f8 A
我们再看什么地方使用了这个section:
4 e( O% T8 O9 Y5 V" D: z/ Y- n- T# B
& `: R9 ~2 d* k' L) ^
: k$ [" g( h2 C9 o1 r' h
微信图片_20210220103922.png

* ^( m8 o0 M/ H& c, N% U& G  q9 {8 `
# ~" [+ ~: C* C) R- p1 [5 [8 @* s
由上图可见,PERCPU_INPUT宏里使用了该section,而PERCPU_INPUT宏又被下面的PERCPU_VADDR宏使用。2 G) W7 S% x/ m6 e# E5 \
我们再来看下PERCPU_VADDR宏在哪里使用:" [9 w3 n% @8 I0 P4 ~
& F$ h/ c* K8 F+ ^( v% N7 r4 z
微信图片_20210220103925.png

+ ?8 T2 ?  j- b0 v1 {  ]& j! i
( Q: y# `2 q* K3 t3 Y9 d. ~1 ^
由上可见PERCPU_VADDR宏又在vmlinux.lds.S文件中使用。
+ x/ M8 J5 ^0 u; j: z, }/ q& b# C3 y6 h
vmlinux.lds.S是一个链接脚本,在链接阶段,linker会根据vmlinux.lds.S里的定义,把相同section的内核变量或方法,聚合起来,放到最终输出文件vmlinux的对应section里。, Y( X1 c$ ?$ x2 e7 |% g
比如上面的PERCPU_VADDR宏就是说,把所有源文件中的属于各种.data..percpu section的变量提取出来,然后依次放入到输出文件vmlinux的.data..percpu的section中。
4 x. Y6 E& F5 n( @
上图中需要注意的是,在调用PERCPU_VADDR时,传入的vaddr参数是0,它表示vmlinux中.data..percpu section里存放的变量地址是从0开始,依次递增的。
( T# l, N7 t+ x6 ?. |
这个我们之前也说过,该地址是用来表示该变量在.data..percpu section里的位置,也就是说,该地址表示的是该变量在运行时的,各cpu的percpu内存块里的位置。
8 ~7 ]8 l- O3 \) N( k
vmlinux里.data..percpu section存放的变量地址是从0开始的,这个我们可以通过__per_cpu_start的值得到确认:
) F  B+ |: ]/ d2 {8 p
! }4 K, x# P: P, G# v
微信图片_20210220103928.png

1 d, o7 n, L! _: b& p0 q* ?

+ ^  e& U5 v2 D+ g" p
另一个需要注意的是,__per_cpu_load的地址值是正常的内核编译地址,它用来指定,当vmlinux被加载到内存后,vmlinux里的.data..percpu section所处内存的位置:
+ _3 ]8 T6 t2 {

0 d- U4 q: N$ O! `# I! m' Y
微信图片_20210220103931.png

2 h0 S9 X# x% P8 e' E5 D( ^
4 h1 J1 m8 J# i5 {
综上可知,PERCPU_VADDR宏的作用是,将所有源文件中属于各个.data..percpu section的变量聚合起来,然后依次放到输出文件vmlinux的.data..percpu section中,且section中的变量地址是从0开始的,这样这些变量的地址就表示其所处的该section的位置。% x) P# u& |$ R1 t
另外,PERCPU_VADDR宏里还定义了三个地址值:
5 \% s# e" `: n* Q, S
__per_cpu_load表示当vmlinux被加载到内存时,vmlinux中的.data..percpu section所处内存位置。__per_cpu_start的值是0。__per_cpu_end的值是vmlinux中的.data..percpu section的结束地址。
. Q) a7 v# O* D3 ]% |: q& }0 s
这样通过__per_cpu_load就可以知道当vmlinux被加载到内存时,.data..percpu section所处位置,通过__per_cpu_end - __per_cpu_start,就可以知道.data..percpu section的大小。
& s- A9 x3 q' ^! T  r4 S" s
4 {* @: H# C  j$ j
微信图片_20210220103934.png

& A5 k* _+ o( X9 z6 N7 P

. w. q' k3 P1 Z. t( [* K
由上可见,内核中的percpu变量占用内存大小差不多是170KiB。
3 c9 E- F( d5 f+ ?" u
7 A4 w) }1 i3 W! H" P2 a$ x! M6 w
到这里,有关percpu变量的所有准备工作都已做好,下面我们来看下,在内核vmlinux文件启动过程中,它是怎么利用这些信息,为各个cpu分配percpu内存块,初始化内存块数据,及设置内存块地址到gs寄存器的。* M2 l9 q" j: u
通过搜索__per_cpu_load, __per_cpu_start, __per_cpu_end我们可以知道,这些内存分配工作是在setup_per_cpu_areas方法里完成的:
* I% J5 b; R2 k/ L  b1 {

+ }0 Y" k. _, q3 H$ }
微信图片_20210220103937.png
' W. u: q1 e4 L# z" u

3 Q6 ?! E! m+ ~
该方法的文件路径和大致样子就如上图所示,为了方便查看,我删除了很多不必要的代码。
+ E# d# E3 \- _% C" R
由于该方法的逻辑非常复杂,这里我们就不详细讲解每行代码了,只看些关键部分。7 f# u/ Q& K$ Q% Y$ n: D
% f' j0 t6 g* Q1 M, M( P
该方法及相关方法的主要作用是为每个cpu分配自己的percpu内存块:- ]; q( I3 v+ y) V. D. o
% K7 m# q- P. p6 u

/ H+ f# E9 B; ]6 ]2 t3 p
微信图片_20210220103939.png
& E, P7 E# i" C

6 _" \! o( l3 A* R2 @
然后将vmlinux的.data..percpu section拷贝到各个cpu的percpu内存块里:9 m8 ^/ g. y5 ^8 w( ]8 R9 F: x

; r" k  k% N& O9 O2 n1 o: b  L

* R6 v7 d! A2 E$ B
微信图片_20210220103942.png
/ b. Y4 H( t' [
+ ~' I: x" q. K! w- V0 p4 j
这里的ai->static_size就是__per_cpu_end减去__per_cpu_start的值。
% K, m  ^5 a/ }6 Y1 F  N
最后设置各cpu的percpu内存块的起始地址值到各自cpu的gs寄存器里:
! [! I- j' o( d3 ?
1 ~6 k- u. e6 W% ?9 r
微信图片_20210220103945.png

8 g# ]- h- @" e+ M  V
/ T; q) [5 _7 A1 t" K! @! t! O
上图中需要注意的是gs寄存器的设置方式,我们知道,在x86_64模式下,段寄存器CS, DS, ES, SS基本上是不用了,FS和GS虽然还在用,但使用传统的mov指令等方式设置FS和GS值,支持的地址空间只能到32位,如果想要支持到64位,必须通过写MSR的形式来完成。
. h3 J! R5 K9 R

% I. [/ x: @2 w7 l$ q$ R# J: q
这个在AMD官方文档里有详细说明:
( r1 C% Z' }' n/ X0 O
6 f$ R. t  p  R: k3 t, r
微信图片_20210220103947.png
& n/ B: t. v+ q% B, ^
2 h$ k+ I" T* S4 p" y# y
在设置完gs寄存器的值后,我们再回头来想想,内核是如何获取当前cpu的current_task变量的地址值的呢:
7 G' {. q+ t5 M6 P, ~  D5 z
mov %gs:0x16d00, %rax0 S( M. R& K1 p" C6 U
现在这行代码的意思你就完全明白了吧。
1 n6 K' N  U# M% D+ {5 ~
到这里,percpu部分的内容就已经完全讲完了,但有关如何获取当前cpu正在运行的当前线程的current_task值,还有一点没讲到。  b0 ^& ^5 C  t. v
我们知道,一个cpu是可以运行多个线程的,如果想要让current_task这个percpu变量,指向当前cpu的当前线程,那在线程切换的时候必须要更新一下current_task:
- R1 E6 Z! h4 L4 ~, G5 G5 e
  W/ S3 J% s! B9 o
/ _& C: c5 o$ M* r  y
微信图片_20210220103950.png
, i" Z2 Z! Q8 b8 H

9 T2 Y8 z9 w  @0 C
如上。
" @9 s5 |' \$ _; t5 d9 ~
现在,有关percpu变量的知识,你是否已经完全了解了呢,如果还有疑问,可以再去看看文章开始我画的那张图,或者给我留言,我们可以一起讨论。
5 ~6 b( i+ y% H2 o9 m6 n) y
收藏 评论0 发布时间:2021-2-20 10:48

举报

0个回答

所属标签

相似分享

关于意法半导体
我们是谁
投资者关系
意法半导体可持续发展举措
创新和工艺
招聘信息
联系我们
联系ST分支机构
寻找销售人员和分销渠道
社区
媒体中心
活动与培训
隐私策略
隐私策略
Cookies管理
行使您的权利
关注我们
st-img 微信公众号
st-img 手机版