x86 PerCPU变量基址(gs寄存器)的原理

0. 引子

我们在调试的时候发现,x86下有一个快捷方法,只需一条简单的汇编指令mov %gs:var就能取出某个percpu变量在当前cpu的值,非常高效。

unsigned long get_mem_value(unsigned long addr) {
    unsigned long value = 0 ;
    __asm__ __volatile__ ("mov %0, %%rax\n\t"::"r"(addr)) ;
    __asm__ __volatile__ ("mov %gs:(%rax), %rax\n\t") ;
    __asm__ __volatile__ ("mov %%rax, %[value]\n\t" :[value]"=r"(value)) ;
    return value ;
}

unsigned long get_xxx_var(void) {
    unsigned long addr = kallsyms_lookup_name("xxx_var") ;
    
    if(! addr) {
        dbg("Can't found xxx_var symbols! \r\n") ;
        return 0 ;
    }
    return get_mem_value(addr) ;
}

这种操作的原理是什么样的呢?gs寄存器是是什么时候被赋值为percpu的基地址的呢?

1. percpu基本原理

percpu在NUMA系统上的内存分配还是比较复杂的,这里就不详细解析了。我们这里只了解最基本percpu静态变量的原理。

静态的percpu变量使用DEFINE_PER_CPU()宏来定义,目的就是把这种类型的变量都放到section(".data..percpu"):

#define DEFINE_PER_CPU(type, name)					\
	DEFINE_PER_CPU_SECTION(type, name, "")

#define DEFINE_PER_CPU_SECTION(type, name, sec)				\
	__PCPU_ATTRS(sec) PER_CPU_DEF_ATTRIBUTES			\
	__typeof__(type) name

#define __PCPU_ATTRS(sec)						\
	__percpu __attribute__((section(PER_CPU_BASE_SECTION sec)))	\
	PER_CPU_ATTRIBUTES

#define PER_CPU_BASE_SECTION ".data..percpu"

链接脚本中关于section(".data..percpu")的定义,__per_cpu_start是section起始地址,__per_cpu_end是section结束地址,__per_cpu_load是变量地址和存储地址的offset值:

PERCPU_VADDR(INTERNODE_CACHE_BYTES, 0, :percpu)

#define PERCPU_VADDR(cacheline, vaddr, phdr)				\
	VMLINUX_SYMBOL(__per_cpu_load) = .;				\
	.data..percpu vaddr : AT(VMLINUX_SYMBOL(__per_cpu_load)		\
				- LOAD_OFFSET) {			\
		PERCPU_INPUT(cacheline)					\
	} phdr								\
	. = VMLINUX_SYMBOL(__per_cpu_load) + SIZEOF(.data..percpu);

#define PERCPU_INPUT(cacheline)						\
	VMLINUX_SYMBOL(__per_cpu_start) = .;				\
	*(.data..percpu..first)						\
	. = ALIGN(PAGE_SIZE);						\
	*(.data..percpu..page_aligned)					\
	. = ALIGN(cacheline);						\
	*(.data..percpu..readmostly)					\
	. = ALIGN(cacheline);						\
	*(.data..percpu)						\
	*(.data..percpu..shared_aligned)				\
	VMLINUX_SYMBOL(__per_cpu_end) = .;

需要注意的是section(".data..percpu")会被链接到地址0,通过符号可以查看:

~> cat /proc/kallsyms 
0000000000000000 V irq_stack_union
0000000000000000 D __per_cpu_start
0000000000004000 V gdt_page
0000000000005000 V exception_stacks
000000000000a000 V espfix_stack
000000000000a008 V espfix_waddr
000000000000a010 V tlb_vector_offset
000000000000a080 V old_rsp
...

在内核启动时,需要给每个cpu分配一块独立的percpu变量空间并且拷贝原始内容到独立空间中,每块空间都是section(".data..percpu")的副本。原始的section(".data..percpu")属于init段,在内核启动完成后会被释放。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aZoOoH2v-1592912701960)(images/percpu/percpu_diagram.png)]

如上图,其实有两个地址的概念,一个是基地址base,一个是offset地址:原变量基地址 + offset[N] = 新percpu基地址base[N]。因为原变量基地址是0,所以通常情况下offset[N] = base[N]

这项工作主要在setup_per_cpu_areas()函数中完成:

linux-3.0.101-63\arch\x86\kernel\setup_percpu.c

start_kernel() -> setup_per_cpu_areas():

#define per_cpu_offset(x) (__per_cpu_offset[x])

void __init setup_per_cpu_areas(void)
{
	...

	/* (1) 给每个cpu分配一个percpu空间,并拷贝数据内容 */
	if (pcpu_chosen_fc != PCPU_FC_PAGE) {

		rc = pcpu_embed_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
					    dyn_size, atom_size,
					    pcpu_cpu_distance,
					    pcpu_fc_alloc, pcpu_fc_free);
	}
	if (rc < 0)
		rc = pcpu_page_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
					   pcpu_fc_alloc, pcpu_fc_free,
					   pcpup_populate_pte);


	/* alrighty, percpu areas up and running */
	delta = (unsigned long)pcpu_base_addr - (unsigned long)__per_cpu_start;
	/* (2) 根据已经分配的空间给控制数据赋值 */
	for_each_possible_cpu(cpu) {
		/* (2.1) 计算percpu空间offset基地址数组__per_cpu_offset[cpu]的值 */
		per_cpu_offset(cpu) = delta + pcpu_unit_offsets[cpu];

		/* (2.2) 定义了一个percpu的变量"this_cpu_off",用percpu的方式来保存__per_cpu_offset[cpu]数组 */
		per_cpu(this_cpu_off, cpu) = per_cpu_offset(cpu);

		/* (2.3) 赋值smp_processor_id */
		per_cpu(cpu_number, cpu) = cpu;
		setup_percpu_segment(cpu);
		setup_stack_canary_segment(cpu);
		
		...

		/*
		 * Up to this point, the boot CPU has been using .init.data
		 * area.  Reload any changed state for the boot CPU.
		 */
		/* (2.4) 设置boot cpu (cpu 0)的gs寄存器为__per_cpu_offset[0] */
		if (!cpu)
			switch_to_new_gdt(cpu);
	}
}

2. percpu宏的实现

有了上一节的基本原理了解后,理解相关的操作宏就比较容易了。percpu常用的有以下宏:

2.1 per_cpu()

这个宏获取某个cpu的percpu变量,原理也特别简单:变量地址(&var) + percpu变量的offset基地址(__per_cpu_offset[cpu])

#define per_cpu(var, cpu) \
	(*SHIFT_PERCPU_PTR(&(var), per_cpu_offset(cpu)))

#define per_cpu_offset(x) (__per_cpu_offset[x])

#define SHIFT_PERCPU_PTR(__p, __offset)	({				\
	__verify_pcpu_ptr((__p));					\
	RELOC_HIDE((typeof(*(__p)) __kernel __force *)(__p), (__offset)); \
})

注意:这个宏计算的时候,使用的是变量地址(前面有&符号),对应加上percpu变量的offset基地址。

2.2 percpu_read()

上面的获取指定某个cpu的percpu变量没有体现出x86的性能优化,现在我们看看获取当前cpu的percpu变量的宏percpu_read()的实现。

一般架构获取当前cpu的percpu变量的步骤:

1. 获取到当前cpu id, smp_processor_id()。
2. 计算得到当前cpu的percpu变量基地址__per_cpu_offset[cpu]。
3. 使用var地址 + __per_cpu_offset[cpu], 得到var在当前cpu的地址。

而x86架构的实现:

#define percpu_read(var)		percpu_from_op("mov", var, "m" (var))

#define percpu_from_op(op, var, constraint)		\
({							\
	typeof(var) pfo_ret__;				\
	switch (sizeof(var)) {				\
	case 1:						\
		asm(op "b "__percpu_arg(1)",%0"		\
		    : "=q" (pfo_ret__)			\
		    : constraint);			\
		break;					\
	case 2:						\
		asm(op "w "__percpu_arg(1)",%0"		\
		    : "=r" (pfo_ret__)			\
		    : constraint);			\
		break;					\
	case 4:						\
		asm(op "l "__percpu_arg(1)",%0"		\
		    : "=r" (pfo_ret__)			\
		    : constraint);			\
		break;					\
	case 8:						\
		asm(op "q "__percpu_arg(1)",%0"		\
		    : "=r" (pfo_ret__)			\
		    : constraint);			\
		break;					\
	default: __bad_percpu_size();			\
	}						\
	pfo_ret__;					\
})

#define __percpu_arg(x)		__percpu_prefix "%P" #x

#define __percpu_prefix		"%%"__stringify(__percpu_seg)":"

#define __percpu_seg		gs

展开这些宏,归为一句话:

		asm("mov %%gs:%P1,%0"		\
		    : "=r" (pfo_ret__)			\
		    : "m" (var));			\

其中的关键就是当前cpu的gs寄存器保存了__per_cpu_offset[cpu]基地址。

更关键的是gs寄存器被设置成了__per_cpu_offset[cpu]基地址是在哪个节点干的呢??

注意:这类宏传入的是变量而不是变量地址,在asm指令时才会取地址,这是和per_cpu()的不同

3. x86_64 gs寄存器的初始化

x86使用WRMSR指令来配置gs寄存器。

x86_64位长模式下,FS和GS寄存器已经和GDT没有关系,其基址保存在MSR_FS_BASE和MSR_GS_BASE中。

MSR 是CPU 的一组64 位寄存器,可以分别通过RDMSR 和WRMSR 两条指令进行读和写的操作,前提要在ECX 中写入MSR 的地址:

指令作用描述
RDMSR读模式定义寄存器。对于RDMSR 指令,将会返回相应的MSR 中64bit 信息到(EDX:EAX)寄存器中
WRMSR写模式定义寄存器。对于WRMSR 指令,把要写入的信息存入(EDX:EAX)中,执行写指令后,即可将相应的信息存入ECX 指定的MSR 中

3.1 cpu0 boot阶段

linux-3.0.101-63\arch\x86\kernel\head_64.S:

	/* Set up %gs.
	 *
	 * The base of %gs always points to the bottom of the irqstack
	 * union.  If the stack protector canary is enabled, it is
	 * located at %gs:40.  Note that, on SMP, the boot cpu uses
	 * init data section till per cpu areas are set up.
	 */
	movl	$MSR_GS_BASE,%ecx
	movl	initial_gs(%rip),%eax
	movl	initial_gs+4(%rip),%edx
	wrmsr	

	ENTRY(initial_gs)
	.quad	INIT_PER_CPU_VAR(irq_stack_union)

#define INIT_PER_CPU_VAR(var)  init_per_cpu__##var

/*
 * Per-cpu symbols which need to be offset from __per_cpu_load
 * for the boot processor.
 */
#define INIT_PER_CPU(x) init_per_cpu__##x = x + __per_cpu_load
INIT_PER_CPU(gdt_page);
INIT_PER_CPU(irq_stack_union);

在boot阶段时,给cpu0的gs寄存器配置了一个初始值__per_cpu_load,这个是原始的section(".data..percpu")

3.2 cpu0 运行阶段

在setup_per_cpu_areas()中分配完实际运行时的per_cpu内存空间后,cpu0的gs寄存器需要重新配置:

void __init setup_per_cpu_areas(void)
{
	...

	for_each_possible_cpu(cpu) {

		/*
		 * Up to this point, the boot CPU has been using .init.data
		 * area.  Reload any changed state for the boot CPU.
		 */
		/* (2.4) 设置boot cpu (cpu 0)的gs寄存器为__per_cpu_offset[0] */
		if (!cpu)
			switch_to_new_gdt(cpu);
	}
}

↓

switch_to_new_gdt()

↓

void load_percpu_segment(int cpu)
{
#ifdef CONFIG_X86_32
	loadsegment(fs, __KERNEL_PERCPU);
#else
	loadsegment(gs, 0);

	/* (2.4.1) 将当前cpu的percpu(irq_stack_union.gs_base)的值配置进当前cpu的`gs`寄存器 */
	wrmsrl(MSR_GS_BASE, (unsigned long)per_cpu(irq_stack_union.gs_base, cpu));
#endif
	load_stack_canary_segment();
}

这里就来到了全文最关键、最难、最精彩的一个地方,per_cpu(irq_stack_union.gs_base, cpu)怎么就等于__per_cpu_offset[cpu]基地址的值了?这个是什么时候赋值的?

这里是使用一个隐含技巧来实现的:

DEFINE_PER_CPU_FIRST(union irq_stack_union,
		     irq_stack_union) __aligned(PAGE_SIZE);

union irq_stack_union {
	char irq_stack[IRQ_STACK_SIZE];
	/*
	 * GCC hardcodes the stack canary as %gs:40.  Since the
	 * irq_stack is the object at %gs:0, we reserve the bottom
	 * 48 bytes of the irq stack for the canary.
	 */
	struct {
		char gs_base[40];
		unsigned long stack_canary;
	};
};

我们可以看到irq_stack_union是使用DEFINE_PER_CPU_FIRST()宏来进行定义的,这个宏定义的变量会放在section(".data..percpu..first"),在section(".data..percpu")的最前面。并且使用DEFINE_PER_CPU_FIRST()宏来定义的变量只有一个,就是irq_stack_union。

而且irq_stack_union.gs_base[]是一个数组,所以我们获取到的是它的地址,而不是它保存的数值

> cat /proc/kallsyms | grep irq_stack_union
0000000000000000 V irq_stack_union

所以,per_cpu(irq_stack_union.gs_base, cpu)展开来就是:

0 + __per_cpu_offset[cpu]

setup_per_cpu_areas()函数中,在__per_cpu_offset[cpu]被赋值以后,per_cpu(irq_stack_union.gs_base, cpu)就等价于__per_cpu_offset[cpu]了。

per_cpu(irq_stack_union.gs_base, cpu)宏的展开:

per_cpu(irq_stack_union.gs_base, cpu)

↓

#define per_cpu(var, cpu) \
	(*SHIFT_PERCPU_PTR(&(var), per_cpu_offset(cpu)))

↓

#define SHIFT_PERCPU_PTR(__p, __offset)	({				\
	__verify_pcpu_ptr((__p));					\
	RELOC_HIDE((typeof(*(__p)) __kernel __force *)(__p), (__offset)); \
})

↓

#define RELOC_HIDE(ptr, off)					\
  ({ unsigned long __ptr;					\
    __asm__ ("" : "=r"(__ptr) : "0"(ptr));		\
    (typeof(ptr)) (__ptr + (off)); })

3.3 cpuN 运行阶段

除了cpu0,其他cpu在boot阶段也需要配置gs寄存器:

linux-3.0.101-63\arch\x86\kernel\smp.c:

smp_ops -> native_cpu_up() -> do_boot_cpu() -> start_secondary() -> cpu_init() -> switch_to_new_gdt() -> load_percpu_segment()

原理和cpu0一致。

参考文档:

1.内核基础设施——per cpu变量
2.同步与互斥_percpu变量
3.Per-cpu -1- (Basic)
4.x86 SWAPGS

posted @ 2020-06-23 19:47  pwl999  阅读(324)  评论(0编辑  收藏  举报