Linux Kernel: _text(%rip) 的值如何计算?

310 阅读3分钟

说明:

本文采用Linux 内核 v3.10 版本

在学习 Linux Kernel 源码时,看到如下代码片段:

// file: arch/x86/kernel/head_64.S
startup_64:
	/*
	 * Compute the delta between the address I am compiled to run at and the
	 * address I am actually running at.
	 */
	leaq	_text(%rip), %rbp
	subq	$_text - __START_KERNEL_map, %rbp

那么_text(%rip)的值该如何计算呢?

一、指令指针相对寻址

在求值之前,我们先来看下指令格式。这种带有 %rip 的指令,是 x86_64 架构下新增的指令格式,称为指令指针相对寻址(RIP-Relative Addressing),其计算方式为下一条指令的起始地址加上偏移量。偏移量是32位有符号整数,所以允许 ±2GB 的偏移范围。

A new addressing form, RIP-relative (relative instruction-pointer) addressing, is implemented in 64-bit mode. An

effective address is formed by adding displacement to the 64-bit RIP of the next instruction.

注:引用自 Intel 64 and IA-32 Architectures Software Developer Manuals Volume 2A Chapter 2 Instruction Format 2.2.1.6 RIP-Relative Addressing

其使用方式有两种:

  • 偏移量为常数,比如 1234(%rip)
  • 偏移量为符号,比如 symbol(%rip)

对于第一种情况,计算非常简单,直接把下一条指令地址加上常数就可以。

第二种情况的计算方式和第一种不同,不能把符号symbol的地址和指令地址直接相加,要先计算从 RIP 到达符号 symbol的偏移量,然后把偏移量与指令地址相加。换句话说,其指向的是符号symbol的实际地址

The x86-64 architecture adds an RIP (instruction pointer relative) addressing. This addressing mode is specified by using ‘rip’ as a base register. Only constant offsets are valid. For example:

AT&T: ‘1234(%rip)’, Intel: ‘[rip + 1234]’

​ Points to the address 1234 bytes past the end of the current instruction.

AT&T: ‘symbol(%rip)’, Intel: ‘[rip + symbol]’

​ Points to the symbol in RIP relative way, this is shorter than the default absolute addressing.

注:引用自 ld 官方文档: 9.16.7 Memory References

二、符号的值

说完了指令格式,我们再来看看_text符号的值。

符号 _text 定义在链接脚本 arch/x86/kernel/vmlinux.lds.S中:

// file: arch/x86/kernel/vmlinux.lds.S
#ifdef CONFIG_X86_32
        ...
#else
        . = __START_KERNEL;
        phys_startup_64 = startup_64 - LOAD_OFFSET;
#endif

	/* Text and read-only data */
	.text :  AT(ADDR(.text) - LOAD_OFFSET) {
		_text = .;
		...
		...
		...
	} :text = 0x9090

在链接脚本中,特殊符号 ”.“ 是指位置计数器,它总是指向当前输出位置;给符号 ”.“ 赋值,会导致位置计数器移动。关于位置计数器,可参考 ld 在线文档:3.10.5 The Location Counter

在上述代码中,符号 _text 被设置为位置计数器的值;而在x86_64系统中,位置计数器被定义为__START_KERNEL。宏 __START_KERNEL 定义在文件 arch/x86/include/asm/page_64_types.h中:

// file: arch/x86/include/asm/page_64_types.h
#define __PHYSICAL_START	((CONFIG_PHYSICAL_START +	 	\
				  (CONFIG_PHYSICAL_ALIGN - 1)) &	\
				 ~(CONFIG_PHYSICAL_ALIGN - 1))

#define __START_KERNEL		(__START_KERNEL_map + __PHYSICAL_START)
#define __START_KERNEL_map	_AC(0xffffffff80000000, UL)

其中,CONFIG_PHYSICAL_STARTCONFIG_PHYSICAL_ALIGN是 Linux Kernel 配置选项,其默认值如下:

// file: include/generated/autoconf.h
#define CONFIG_PHYSICAL_START 0x1000000   	// 1M
#define CONFIG_PHYSICAL_ALIGN 0x1000000		// 1M

通过计算之后,宏 __START_KERNEL0xffffffff81000000。该值也是_text的符号(虚拟)地址,从 vmlinx 上也可以查看到:

$ nm vmlinux|grep _text
...
ffffffff81000000 T _text
...

另外,我们也看到了另外一个宏 __PHYSICAL_START,该宏定义的是内核代码(从保护模式开始)加载的物理地址,其值为0x1000000( 1M);1M 以下是BIOS、bootloader、引导扇区、内核启动程序等的地址。宏__START_KERNEL_map是内核加载到虚拟内存的起始地址,对应的是物理内存地址 0。

既然_text 符号的地址为 0xffffffff81000000 ,那是否意味着 _text(%rip)的计算结果就是该地址呢?先不着急下结论,我们来看下leaq _text(%rip), %rbp 指令编译后的结果。

在反编译之前,我们需要找到符号 startup_64及下一个符号的地址:

$ nm vmlinux|sort -k1|grep -A 1 startup_64
0000000001000000 A phys_startup_64
ffffffff81000000 T startup_64
ffffffff81000000 T _text
ffffffff81000110 T secondary_startup_64
ffffffff810001b0 T start_cpu0

可以看到,startup_64编译后的地址为 0xffffffff81000000,其下一个符号 secondary_startup_64的编译地址为 0xffffffff81000110。然后使用 objdump命令来反编译 vmlinux:

$ objdump -d vmlinux --start-address=0xffffffff81000000 --stop-address=0xffffffff81000110

vmlinux:     file format elf64-x86-64


Disassembly of section .text:

ffffffff81000000 <_text>:
ffffffff81000000:	48 8d 2d f9 ff ff ff 	lea    -0x7(%rip),%rbp        # ffffffff81000000 <_text>
ffffffff81000007:	48 81 ed 00 00 00 01 	sub    $0x1000000,%rbp
ffffffff8100000e:	48 89 e8             	mov    %rbp,%rax
ffffffff81000011:	25 ff ff 1f 00       	and    $0x1fffff,%eax
ffffffff81000016:	85 c0                	test   %eax,%eax
ffffffff81000018:	0f 85 a7 01 00 00    	jne    ffffffff810001c5 <bad_address>
ffffffff8100001e:	48 8d 05 db ff ff ff 	lea    -0x25(%rip),%rax        # ffffffff81000000 <_text>
ffffffff81000025:	48 c1 e8 2e          	shr    $0x2e,%rax
...
...

可以看到,leaq _text(%rip), %rbp 编译后为 lea -0x7(%rip),%rbp,其计算后的偏移量为 -0x7。为什么是-0x7呢,因为符号_text的编译地址为 0xffffffff81000000,当前指令长度为 7个字节,下一条指令的起始地址为0xffffffff81000007;从下一条指令想要回到_text处,需要减掉-0x7

可以看到, _text(%rip) 经过编译后变成了-0x7(%rip),其中已经没有符号地址了,变成了符号相对于%rip 的偏移量;其计算结果要看实际运行时 %rip 是多少,毕竟-0x7(%rip)中只有一个变量%rip

那么此时%rip的值是多少呢?

三、调试及结论

我们使用qemu + gdb 来调试一下。先在 startup_64 符号处打断点,然后运行,结果如下:

(gdb) file vmlinux
Reading symbols from vmlinux...
(gdb) target remote :1234
Remote debugging using :1234
0x000000000000fff0 in perf_throttled_count ()
(gdb) b startup_64
Breakpoint 1 at 0xffffffff81000000: file arch/x86/kernel/head_64.S, line 72.
(gdb) c
Continuing.

可以看到,虽然我们在 startup_640xffffffff81000000)处打了断点,但内核程序并没有在此处停止,而是一把梭哈到底。说明程序根本没有执行到该地址。

重新启动 qemu 和 gdb,在物理地址 0x1000000处打断点,运行,结果如下:

(gdb) b *0x1000000
Breakpoint 1 at 0x1000000
(gdb) c
Continuing.

Breakpoint 1, 0x0000000001000000 in ?? ()

这一次,内核程序在断点处停下了,我们查看一下当前执行的指令:

(gdb) display /3i $pc
2: x/3i $pc
=> 0x1000000:	lea    -0x7(%rip),%rbp        # 0x1000000
   0x1000007:	sub    $0x1000000,%rbp
   0x100000e:	mov    %rbp,%rax
结论:

可以看到,该地址处的指令,就是我们上文反汇编得到的startup_64处的指令,所以:

_text(%rip)的值为 0x1000000 而不是0xffffffff81000000

四、原因分析

为什么是这样呢?

在运行到startup_64处时,虽然已经是64位模式了,并且开启了分页:

// file: arch/x86/boot/compressed/head_64.S
/* Enter paged protected Mode, activating Long Mode */
movl	$(X86_CR0_PG | X86_CR0_PE), %eax /* Enable Paging and Protected mode */
movl	%eax, %cr0

并且构建了早期页表:

// file: arch/x86/boot/compressed/head_64.S
/*
  * Build early 4G boot pagetable
  */
	/* Initialize Page tables to 0 */
	leal	pgtable(%ebx), %edi
	xorl	%eax, %eax
	movl	$((4096*6)/4), %ecx
	rep	stosl

	/* Build Level 4 */
	leal	pgtable + 0(%ebx), %edi
	leal	0x1007 (%edi), %eax
	movl	%eax, 0(%edi)

	/* Build Level 3 */
	leal	pgtable + 0x1000(%ebx), %edi
	leal	0x1007(%edi), %eax
	movl	$4, %ecx
1:	movl	%eax, 0x00(%edi)
	addl	$0x00001000, %eax
	addl	$8, %edi
	decl	%ecx
	jnz	1b

	/* Build Level 2 */
	leal	pgtable + 0x2000(%ebx), %edi
	movl	$0x00000183, %eax
	movl	$2048, %ecx
1:	movl	%eax, 0(%edi)
	addl	$0x00200000, %eax
	addl	$8, %edi
	decl	%ecx
	jnz	1b

	/* Enable the boot page tables */
	leal	pgtable(%ebx), %eax
	movl	%eax, %cr3

但由于程序还处于启动阶段,该页表仅仅是个临时页表,里面只映射了 4G 物理内存,而且物理内存和虚拟内存是一对一映射的,也就是说此时 0x1000000 既是物理内存地址,也是虚拟内存地址。而把内核加载到以 __START_KERNEL_map 为基准的虚拟地址,那是以后才干的事,现在还没完成。

五、参考资料

1、 Intel 64 and IA-32 Architectures Software Developer Manuals Volume 2A Chapter 2 Instruction Format 2.2.1.6 RIP-Relative Addressing

2、ld 在线文档: 9.16.7 Memory References

3、ld 在线文档:3.10.5 The Location Counter

4、How do RIP-relative variable references like "[RIP + _a]" in x86-64 GAS Intel-syntax work?