当前位置: 首页 > 科技观察

一张图看懂Linux内核中Percpu变量的实现

时间:2023-03-18 00:20:29 科技观察

我们在使用各种编程语言进行多线程编程的时候,经常会用到线程局部变量。所谓线程局部变量就是对于同一个变量,每个线程都有自己的份额,并且对这个变量的访问是线程隔离的,不会互相影响,所以不会出现多线程问题。正确使用线程局部变量可以大大简化多线程开发。所以无论是c/c++/rust还是java/c#,都内置了对线程局部变量的支持。但是你知道吗,不仅在编程语言中,而且在linux内核中,也有类似的机制来实现类似的目的,它叫做percpu变量。percpu变量,顾名思义就是每个CPU都有自己的同一个变量的副本,可以用来存放一些CPU特有的数据,比如CPU的ID,CPU上运行的线程,等机制可以很方便的解决一些特定的问题,所以在内核编程中被广泛使用。如果你很好奇,你一定会问,它是如何实现的?先不说细节,先看一张图,从全局的角度理解它的实现。从上图可以看出,在各个源文件中通过DEFINE_PER_CPU定义了很多percpu变量。这些变量会被链接器根据vmlinux.lds.S中的相关定义聚合起来,然后放在最终的vmlinux文件中,有一个名为.data..percpu的section。这些变量的地址也经过特殊处理,从零开始依次递增。这样一个变量的地址就是这个变量在整个vmlinux的.data..percpu区的位置。一个cpu的percpu内存块的起始地址可以很方便的计算出这个cpu对应的变量的运行时内存地址。linux内核启动的时候,会先把vmlinux文件加载到内存中,然后根据cpu的个数为每个cpu分配一块内存区域用来存放percpu变量,然后把.data..percpu段在vmlinux中复制将每个cpu的percpu内存块中的内容放到静态区,最后将每个percpu内存块的起始地址放入对应cpu的gs寄存器中。至此percpu变量的初始化就结束了。当我们访问percpu变量时,只需要将gs寄存器中的地址与我们要访问的percpu变量的地址相加,就可以得到percpu变量在cpu上的真实内存地址。有了这个地址,我们就可以很方便的操作这个percpu变量了。上图关注的是内核编译时已经确定的那些percpu变量。这些变量是静态的,不会随时间动态增减,所以在内核初始化的时候就初始化了。直接复制到每个percpu内存块的静态区。除了这个静态percpu变量之外,还有另外两个percpu变量。其中之一是内核模块中的静态percpu变量。虽然可以在编译时确定,但由于内核模块的动态加载,它并不是完全静态的。内核将这个percpu变量单独存储在percpu内存块中。开辟了一块区域,称为保留区,当内核模块加载到内存中时,其静态percpu变量会在该区域分配内存。另外一个percpu变量是一个纯动态的percpu变量,它是在运行时动态分配的,它使用的内存就是上图中的动态区域。静态区的大小在编译时是固定的,保留区也是固定的,但它的大小是估计的,动态区是可以动态增加的。这三个percpu变量的分配方式虽然不同,但是它们的内部机制本质上是一样的,所以这里只讲内核中的staticpercpu变量。对其他两种方式感兴趣的同学可以自行查阅内核源码。正在研究中。下面通过一个具体的例子来看看percpu变量是如何实现的。上图中的current表示获取当前线程对象,其实是一个宏,具体定义如下:从上图可以看出,current获取的当前线程对象其实是一个名为current_task的percpu变量.在get_current方法中,通过this_cpu_read_stable方法获取属于当前cpu的current_task。this_cpu_read_stable方法其实就是一个宏,完全展开后是这样的:这里先不说宏展开后每条语句的含义,先从一个问题说起。看过linux内核源码的同学都知道,在linux内核中,宏的使用比较多,而且比较复杂。如果我们对自己宏展开的正确性没有信心,可以按照我下面介绍的方法来使用,你可以轻松得到任意文件宏展开的结果。我们知道,程序的构造分为预处理、编译、汇编、链接等阶段,宏展开发生在预处理阶段。每个阶段完成后,一般都会为下一阶段生成一个临时文件。这些临时文件默认不会保存到磁盘中,但是我们可以通过指定一些参数告诉g??cc帮我们保存这些临时文件,这样我们就可以查看各个阶段生成的内容。按照这个思路,我们只需要在编译net/socket的时候加上这些参数即可。但是,为了查看单个文件宏展开的结果,将整个内核中编译的所有源文件的临时文件保存下来,是非常耗时且不划算的。有没有办法查看哪个文件?宏扩展,只编译那个文件一次?确实如此。其实这个方法也很简单。我们只需要知道编译一个文件所用的编译命令是什么,这样当我们需要查看这个文件的宏展开时,就可以使用这个编译命令,加上一些具体的参数,再编译一次,这样就可以在文件的编译过程中得到各个阶段的临时文件。那么如何找到编译各个源文件所用的命令呢?这个内核实际上已经为我们完成了。当我们编译内核时,编译内核中每个文件时使用的命令都会保存在对应的临时文件中。比如上面的net/socket.c文件的编译命令保存在如下文件中:net/socket.c的编译命令就是上图中的第一行,从gcc开始到这个结束线。这个编译命令已经够复杂了,不过我们不用管它。我们只需要知道net/socket.c可以通过这个命令编译成net/socket.o。现在我们在这条命令的基础上加上-save-temps=obj参数,告诉gcc在编译时保留各个阶段的临时文件。具体操作过程如下:从上面可以看出,加上-save-temps=obj参数最后编译过程又生成了两个文件,net/socket.i是gcc预处理后的文件。打开net/socket.i,找到我们需要的get_current方法:看上图中选中的部分,它的内容和我们自己宏展开的结果完全一样。这个方法还不错。当然,我们也可以通过反编译进一步确认宏展开后确实是这样:从上面可以看出,宏展开后,其实是一条mov指令,current_task的地址的值变量是0x16d00。这条指令的意思是将gs寄存器中的地址与current_task的地址相加,然后将相加地址指向的内存空间中的值移动到rax中。这和我们上面提到的percpu的实现机制是一致的。好了,我们回到上面打断的部分,继续看get_current方法中宏展开后的每条语句的含义。上面说了get_current方法中的this_cpu_read_stable方法宏,主要是展开后的asm语句。可能有些同学对这个说法不熟悉。它其实不是C语言标准规范中的语法,而是gcc对C标准的扩展,通过asm语句,我们可以直接在c中执行汇编指令。其详细的语法规则可以参考以下链接:https://gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html#Using-Assembly-Language-with-C不关心细节同学们不用看具体语法,我们只需要知道asm语句的意思就是获取current_task的地址,将该地址与gs段寄存器中的基本地址值相加即可得到afinaladdress,然后通过mov指令,将finaladdress指向的内存的值放入pfo_val__变量中。指令执行后,pfo_val__变量保存的值是当前cpu执行的当前线程对象structtask_struct的地址,即pfo_val__变量是当前执行线程对象的指针。那为什么这样得到的是当前cpu正在执行的当前线程对象的指针呢?其实我们上面已经提到了这一点。关键是gs寄存器存放的是当前cpu的percpu内存块的开始。地址,而current_task的地址表示current_task变量在任意percpu内存块中的位置,所以这两个地址相加,结果自然就是当前cpu的current_task变量的当前值。理论上是这样的,但是我们从源码的角度来看一下。首先我们看一下current_task变量的定义:DEFINE_PER_CPU仍然是一个宏,它的展开如下:在宏展开后的变量定义中,最重要的是将变量的section指定为.data..处理器。我们看看这一段用在什么地方:从上图可以看出,这一段用在了PERCPU_INPUT宏中,而PERCPU_INPUT宏又被后面的PERCPU_VADDR宏使用了。我们来看一下PERCPU_VADDR宏的用处:从上面可以看出,在vmlinux.lds.S文件中使用了PERCPU_VADDR宏。vmlinux.lds.S是一个链接脚本。在链接阶段,链接器会根据vmlinux.lds.S中的定义,将同一段的内核变量或方法进行聚合,放入最终输出文件vmlinux.h的对应段中。比如上面的PERCPU_VADDR宏,意思是提取所有源文件中属于各个.data..percpu段的变量,然后依次放到输出文件vmlinux的.data..percpu段中。上图中需要注意的是,在调用PERCPU_VADDR时,传入的vaddr参数为0,即vmlinux中.data..percpu段存储的变量地址从0开始依次递增。前面我们说过,地址是用来表示变量在.data..percpu段中的位置的,也就是说,地址表示变量在运行时在每个cpu的percpu内存块中的位置Location.vmlinux中.data..percpu段存储的变量地址是从0开始的,我们可以通过__per_cpu_start的值来确认:另外需要注意的是__per_cpu_load的地址值是正常的内核编译地址,用于指定,当vmlinux加载到内存中时,.data..percpu段在vmlinux中的内存位置:综上所述,PERCPU_VADDR宏的作用是设置所有source中属于每个.data..percpu段的变量files聚合,然后将它们依次放在输出文件vmlinux的.data..percpu段中,段中的变量地址从0开始,所以这些变量的地址表示它们所在段的位置。另外在PERCPU_VADDR宏中定义了三个地址值:__per_cpu_load表示vmlinux加载到内存时.data..percpu段在vmlinux中的内存位置。__per_cpu_start的值为0。__per_cpu_end的值为vmlinux中.data..percpu段的结束地址。这样就可以通过__per_cpu_load知道vmlinux加载到内存时.data..percpu段的位置,通过__per_cpu_end-__per_cpu_start可以知道.data..percpu段的大小。从上面可以看出内核中percpu变量占用的内存大小差不多有170KiB。至此,percpu变量的所有准备工作已经完成。我们来看看它在内核vmlinux文件启动过程中是如何利用这些信息为每个cpu分配percpu内存块,初始化内存块数据,并将内存块地址设置到gs寄存器的。通过搜索__per_cpu_load、__per_cpu_start、__per_cpu_end,我们可以知道这些内存分配任务都是在setup_per_cpu_areas方法中完成的:该方法的文件路径和大致外观如上图所示。为了方便查看,我删除了很多不需要的代码。由于该方法的逻辑非常复杂,这里不对每一行代码进行详细解释,只看一些关键部分。该方法及相关方法的主要作用是为每个cpu分配自己的percpu内存块:然后将vmlinux的.data..percpu段复制到每个cpu的percpu内存块中:这里ai->static_size就是__per_cpu_end减去Go到__per_cpu_start的值。最后将每个cpu的percpu内存块的起始地址值设置到每个cpu的gs寄存器中:上图需要注意gs寄存器的设置方法。我们知道,在x86_64模式下,段寄存器CS、DS、ES、SS基本不用。虽然FS和GS仍然使用,但是使用传统的mov指令来设置FS和GS的值。支持的地址空间只能达到32位。如果要支持64位,必须写MSR表格来完成。这个在AMD官方文档中有详细解释:设置gs寄存器的值后,我们回过头来想一下内核是如何获取当前cpu的current_task变量的地址值的:mov%gs:0x16d00,%rax现在你就会明白这行代码到底是什么意思了。至此percpu部分的内容已经全部讲完,但是还有一点就是如何获取当前cpu运行的当前线程的current_task值。我们知道一个cpu可以运行多个线程。如果想让percpu变量current_task指向当前cpu的当前线程,切换线程时必须更新current_task:同上。现在,关于percpu变量的知识你已经完全了解了吗?如果还有问题,可以去我文章开头画的图,或者给我留言,我们一起讨论。本文转载自微信公众号“猫食猫客”,可通过以下二维码关注。转载本文请联系猫猫猫客公众号。