菜单

kaoqi
kaoqi
发布于 2026-08-03 / 12 阅读
0
0

kernel链接器简介

现代软件工程中,一个大程序往往由一系列源文件组成。链接指的时把这些目标文件的代码段、数据段、符号表以及未初始化的数据段等内容收集起来并按照某种格式组合成一个可执行的二进制文件的过程。

链接器就是一个遵循AT&T格式的脚本(比如kernel的/arch/arm64/kernel/vmlinux.lds.S文件)。这里的所谓链接器脚本本质上就是一个遵循汇编器语言编写的脚本。(GNU项目下的AT&T格式,本质就是ASM汇编语言格式)。

注意:

链接和动态链接,是发生在两个不同阶段的两件事情。链接是软件在编译过程中,将各牵连的二进制文件,以某种链接脚本中,输入段、输出段的段结构重编程的方式,输出并整理成一个全新的段结构的故事。动态链接则是,在执行二进制文件中,根据二进制文件里.dynamic段所描述的他者标准库进行跳转后,结合了本二进制文件与标准库中二进制文件一起执行的一种执行方式。

因此,本文中所探讨的链接,就是kernel编译过程中,完成了各单独模块的编译形成.o文件后,生成对应kernel文件或img文件之前的,由/arch/arm64/kernel/vmlinux.lds.S文件所描述的,在生成img文件的时候提供给链接器的,一种带了对应特殊处理链接脚本的链接的一种编译方式。

链接脚本的section语法

链接器脚本vmlinux.lds.S是一个不太大的文件,只有200多行,这里可以完备的展示并介绍一下:

SECTIONS
{
	/*
	 * XXX: The linker does not define how output sections are
	 * assigned to input sections when there are multiple statements
	 * matching the same input section name.  There is no documented
	 * order of matching.
	 */
	DISCARDS
	/DISCARD/ : {
		*(.interp .dynamic)
		*(.dynsym .dynstr .hash .gnu.hash)
		*(.ARM.attributes)
	}  // 这里描述了被输出段忽略的一些输入段,如果有这些输入段,要丢

	. = KIMAGE_VADDR;  // 

	.head.text : {
		_text = .;  // 申明一个全局符号_text,并将其值设定为当前计数器.的值,这里会配合后面的ENTRY做起始地址的描述的。
		HEAD_TEXT  // 这是一个宏,展开后为KEEP(*(.head.text)),作用是将所有的输入目标文件中.head.text的段合并到这里。
	}  // 这里的head.text是内核上电后
	.text : {			/* Real text segment		*/
		_stext = .;		/* Text and read-only data	*/
			IRQENTRY_TEXT  // 中断处理入口代码,分别对应硬中断和软中断
			SOFTIRQENTRY_TEXT  // 软中断
			ENTRY_TEXT  // 内核入口相关的底层代码
			TEXT_TEXT  // 内核最核心的通用代码逻辑
			SCHED_TEXT  // 进程调度器相关的代码
			CPUIDLE_TEXT  // CPU空闲管理相关代码
			LOCK_TEXT  // 锁机制的实现代码
			KPROBES_TEXT  // 内核动态探针(kprobes)相关的代码
			HYPERVISOR_TEXT  // 虚拟化扩展相关的代码,KVM和HYP模式代码
			IDMAP_TEXT  // 恒等映射代码,用于MMU开启或关闭的过度阶段
			HIBERNATE_TEXT  // 系统休眠的保存与恢复代码
			TRAMP_TEXT  // 内核入口跳板代码,用于处理内核地址空间布局随机化或异常级别切换
			*(.fixup)  // 异常修复表
			*(.gnu.warning)  // GCC产生的告警信息
		. = ALIGN(16);  // 强制将当前地址按16字节对齐
		*(.got)			/* Global offset table		*/ // 全局偏移
	}  // 由一系列特定功能的子段按顺序拼接而成,各子段对应不同的内核功能

	/*
	 * Make sure that the .got.plt is either completely empty or it
	 * contains only the lazy dispatch entries.
	 */
	.got.plt : { *(.got.plt) } // 这里其实是在检查,是否存在PLT(过程链接表)的段,这个段的主要作用是用于动态链接可执行文件和共享库,如果代码中存在此段,说明编译不是在隔绝外部第三方库的环境下编译的,GCC传入的参数有问题,因此在下面的ASSERT判断中会抛出对应的错误
	ASSERT(SIZEOF(.got.plt) == 0 || SIZEOF(.got.plt) == 0x18,
	       "Unexpected GOT/PLT entries detected!")

	. = ALIGN(SEGMENT_ALIGN);  // 将当前地址计数器向上对齐到SEGMENT_ALIGN所指定的边界上
	_etext = .;			/* End of text section */  // 命名一个_etext的段,标记内核代码段的结束边界

	/* everything from this point to __init_begin will be marked RO NX */
	RO_DATA(PAGE_SIZE)  // 将只读数据段收集起来,按照PAGE_SIZE对齐

	idmap_pg_dir = .;  // 定义一个名为idmap_pg_dir的全局表,用于标记内核身份映射页表的起始地址
	. += IDMAP_DIR_SIZE;  // 将地址计数器向前移动IDMAP_DIR_SIZE个字节
	idmap_pg_end = .;  // 添加一个idmap_pg_end作为身份映射页结束的标记

#ifdef CONFIG_UNMAP_KERNEL_AT_EL0
	tramp_pg_dir = .;  // 作为“跳板页表”的起始地址;这里主要用于内核在热补丁或内核虚拟地址随机化时的安全跳转临时页表
	. += PAGE_SIZE;
#endif

	reserved_pg_dir = .;  // 保留页表
	. += PAGE_SIZE;  // 偏移一页的大小

	swapper_pg_dir = .;  // 内核主页表
	. += PAGE_SIZE;  // 偏移一页的大小

	. = ALIGN(SEGMENT_ALIGN);  // 将地址计数器强制对齐到SEGMENT_ALIGN边界,确保初始化代码段在内存中独立成页
	__init_begin = .;  // 标记整个初始化区域
	__inittext_begin = .;  // 标记初始化代码段的起始地址

	INIT_TEXT_SECTION(8)  // 收集所有.init.text代码放在这里,8表示对齐数。这些代码在内核初始化完成后统一回收,这里的这些实际上是用于执行初始化函数的代码

	__exittext_begin = .;  // 这里开始描述的是各个模块的卸载或内核热卸载时使用的函数
	.exit.text : {
		EXIT_TEXT  // 这里时具体的收集,收集宏展开为.exit.text
	}
	__exittext_end = .;  // 这里是卸载结束代码段

	. = ALIGN(4);  // 这里是内核指令替换相关的代码,这里的ALIGN的意思是强制4字节对齐
	.altinstructions : {  // ARM64内核的指令替换机制核心数据结构
		__alt_instructions = .;  // 设定一个全局符号
		*(.altinstructions)  // 匹配目标文件的.altinstructions段
		__alt_instructions_end = .;  // 一个结束标记符号
	}

	. = ALIGN(SEGMENT_ALIGN);  // 页表对齐
	__inittext_end = .;  // 标记初始化代码段的结束
	__initdata_begin = .;  // 标记初始化数据段的开始

	.init.data : {  // init相关的data段
		INIT_DATA  // 所有的.init.data段
		INIT_SETUP(16)  // 收集内核启动时早期的参数,比如early_param指定的命令行解析函数
		INIT_CALLS  // 收集所有初始化调用函数指针,内核启动时会按顺序执行它们
		CON_INITCALL  // 收集控制台初始化调用
		INIT_RAM_FS  // 收集initramfs相关数据
		*(.init.rodata.* .init.bss)	/* from the EFI stub */  // 额外收集所有来自EFI Stub组件产生的初始化只读数据和BSS段
	}
	.exit.data : { // 标记退出数据段
		EXIT_DATA  // 标记.exit.data的退出数据段
	}

	PERCPU_SECTION(L1_CACHE_BYTES)  // 展开后创建.data.percpu用于存放内核中CPU变量,确保CPU的数据副本按L1缓存对齐
	HYPERVISOR_PERCPU_SECTION  // 为虚拟化场景预留,用于Hypervisor进行CPU变量

	.rela.dyn : ALIGN(8) {  // 动态重定位表,ARM64内核虽然是静态链接的,但在KASLR(内核地址随机化)或KVM虚拟化场景下,内核需要在启动时根据实际加载地址修正一些绝对地址引用。
		*(.rela .rela*)  // 收集所有需要重定位的条目
	}

	__rela_offset	= ABSOLUTE(ADDR(.rela.dyn) - KIMAGE_VADDR);  // 计算重定位表在内核镜像文件中的偏移量,ADDR(.rela.dyn)是链接后的虚拟地址,减去KIMAGE_VADDR的内核镜像虚拟基址,就得到了文件偏移。KASLR重定位代码会用这个偏移来找到重定位表。
	__rela_size	= SIZEOF(.rela.dyn); // 记录重定位表的总大小用于确定需要处理的重定位条目数量

#ifdef CONFIG_RELR
	.relr.dyn : ALIGN(8) {  // 重定位地址指针储存
		*(.relr.dyn)  // 地址指针
	}

	__relr_offset	= ABSOLUTE(ADDR(.relr.dyn) - KIMAGE_VADDR);  // 计算内核镜像文件中.relr.dyn段的偏移量
	__relr_size	= SIZEOF(.relr.dyn);  // 记录该段的总大小,用于确定需要处理的条目数量
#endif

	. = ALIGN(SEGMENT_ALIGN);  // 对齐页表
	__initdata_end = .;  // 标记数据初始化段的结束
	__init_end = .;  // 标记整个初始化区域的结束

	.data.rel.ro : { *(.data.rel.ro) }  // 用户太程序用于RELRO只读重定位的段,内核实际上应该没有这个段
	ASSERT(SIZEOF(.data.rel.ro) == 0, "Unexpected RELRO detected!")  // 如果有,报错

	_data = .;  // 指向可读写数据段的起始地址
	_sdata = .;  // 这个也是一样的,只不过是历史命名的惯例
	RW_DATA(L1_CACHE_BYTES, PAGE_SIZE, THREAD_ALIGN)  // 这是一个宏,展开后包含.data已初始化全局变量、.data.percpu的初始化副本、.data.read_mostly频繁读取的变量

	/*
	 * Data written with the MMU off but read with the MMU on requires
	 * cache lines to be invalidated, discarding up to a Cache Writeback
	 * Granule (CWG) of data from the cache. Keep the section that
	 * requires this type of maintenance to be in its own Cache Writeback
	 * Granule (CWG) area so the cache maintenance operations don't
	 * interfere with adjacent data.
	 */
	.mmuoff.data.write : ALIGN(SZ_2K) {  // 存放MMU关闭时就需要写入的数据
		__mmuoff_data_start = .;  // 标记MMU区域,内核启动代码会根据此进行特殊处理
		*(.mmuoff.data.write)  // 存放需要写入的数据
	}
	. = ALIGN(SZ_2K);  // 2KB对齐
	.mmuoff.data.read : {  // 存放MMU关闭时只需读取的数据
		*(.mmuoff.data.read)  // 具体的数据
		__mmuoff_data_end = .;  // 标记结束位
	}

	PECOFF_EDATA_PADDING  // 兼容EFI的启动填充,用于在数据段的末尾插入,确保内核镜像符合PE/COFF格式的对齐要求,兼容EFI固件直接加载内核镜像的场景
	__pecoff_data_rawsize = ABSOLUTE(. - __initdata_begin);  // 记录从初始化数据段开始到当前地址的原始数据大小,EFI Stub用这个值来正确加载内核
	_edata = .;  // 标记所有数据段的结束

	BSS_SECTION(0, 0, 0)  // 一个宏展开,用于定义bss段,存放未初始化的全局变量

	. = ALIGN(PAGE_SIZE);  // 对齐
	init_pg_dir = .;  // 内核初始化完成后使用的最终页表目录
	. += INIT_DIR_SIZE;  // 通常是一个3页大小,容纳PGD、PUD、PMD
	init_pg_end = .;  // 标记该页表区域的结束边界

	. = ALIGN(SEGMENT_ALIGN);  // 对齐
	__pecoff_data_size = ABSOLUTE(. - __initdata_begin);  // 计算从初始化数据段开始到当前地址的数据段总大小
	_end = .;  // 标记整个内核镜像的结束

	STABS_DEBUG  // 收集STABS格式的调试信息
	DWARF_DEBUG  // 收集DWARF格式的调试信息
	ELF_DETAILS  // 收集ELF文件的其他元数据

	HEAD_SYMBOLS  // 导出一些关键的头部符号

	/*
	 * Sections that should stay zero sized, which is safer to
	 * explicitly check instead of blindly discarding.
	 */
	.plt : {  // 过程链接表
		*(.plt) *(.plt.*) *(.iplt) *(.igot .igot.plt)   // 是假的,只是重新再来一次,看看有没有bug
	}
	ASSERT(SIZEOF(.plt) == 0, "Unexpected run-time procedure linkages detected!")  // 如果有过程链接就报错
}


评论