Calling Global Constructors

来自osdev
Zhang3讨论 | 贡献2022年3月17日 (四) 07:11的版本 (创建页面,内容为“本教程讨论如何正确调用全局构造函数,例如全局C++对象上的构造函数。 这些应该在你的main函数之前运行,这就是为什么程序入口点通常是一个名为 _start的函数。 此函数负责解析命令行参数,初始化标准库(内存分配、信号等),运行全局构造函数并最终exit(main(argc, argv))。 如果你更改编译器,自制操作系统上的情况可能会有所不同,但是如果你使…”)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳到导航 跳到搜索

本教程讨论如何正确调用全局构造函数,例如全局C++对象上的构造函数。 这些应该在你的main函数之前运行,这就是为什么程序入口点通常是一个名为 _start的函数。 此函数负责解析命令行参数,初始化标准库(内存分配、信号等),运行全局构造函数并最终exit(main(argc, argv))。 如果你更改编译器,自制操作系统上的情况可能会有所不同,但是如果你使用的是GNU编译器套件(GCC),遵循System V ABI可能比较好。

在大多数平台上,全局构造函数/析构函数存储在函数指针的有序数组中,调用这些就像遍历数组再运行每个元素一样简单。 但是,编译器并不总是允许访问此列表,一些编译器会认为这算是内部实现细节。 在这种情况下,你将不得不与编译器合作 - 与编译器对着干只会造成麻烦。

GNU Compiler Collection - System V ABI

System V ABI(用于 i686-elf-gcc, x86_64-elf-gcc和其它ELF平台)指定使用五个不同的目标文件,它们一起处理程序初始化。 这些传统上称为 crt0.ocrti.ocrtbegin.ocrtend.ocrtn.o。 这些目标文件一起实现两个特殊功能:_init运行全局构造函数和其它初始化任务, 以及运行全局析构函数和其他终止任务的 _fini

此方案使编译器可以很好地控制程序初始化,使你的工作变得容易,但你必须与编译器合作,否则会发生不好的事情。 你的交叉编译器将为你提供 crtbegin.ocrtend.o。 这些文件包含编译器希望对你隐藏但对你有用的内部文件。 要访问此信息,你需要提供你自己的crti.ocrtn.o实现。 幸运的是,这很容易,并且在本教程中进行了详细描述。 第五个文件crt0.o包含程序入口点(通常为_start),并调用特殊的_init函数,该函数运行运行crti的“程序初始化任务”(由crti.o, crtbegin.o组成)。 crtend.o和crtn.o组合在一起, 你的exit函数通常会调用这些目标生成的函数。 但是,crt0.o超出了本文的讨论范围。 (请注意,包含 _start 的目标文件在内核中充当 crt0.o。)

为了理解这种明显的复杂性,考虑一个由foo.obar.o组成的程序。由以下代码链接:

i686-elf-gcc foo.o bar.o -o program

编译器将重写命令行并将其传递给链接器,如下所示:

i686-elf-ld crt0.o crti.o crtbegin.o foo.o bar.o crtend.o crtn.o

这里的意图是,这些文件在链接过程中一起形成 _init_fini 函数。 这是通过将_init函数存储在.init节(section), 以及 _fini函数在.fini节中来实现的。 然后,每个文件为这些部分贡献一点,链接器将命令行中指定的代码中的片段粘在一起。 crti.o提供函数头, crtbegin.ocrtend.o 提供主体, 和crtn.o提供脚(返回语句)。 重要的是要理解链接顺序很重要,如果目标没有完全按照这个顺序链接,可能会发生奇怪的事情。

使用来自C的全局构造函数

作为一个特殊的扩展,GCC允许C程序作为全局构造函数运行函数。 有关详细信息,请参阅编译器文档。 这通常用作:

__attribute__ ((constructor)) void foo(void)
{
	printf("foo is running and printf is available at this point\n");
}

int main(int argc, char* argv[])
{
	printf("%s: main is running with argc=%i\n", argv[0], argc);
}

在内核中使用crti.o, crtbegin.o, crtend.o和crtn.o

在内核中,你没有用户空间C库可以使用。 你可能正在使用特殊的内核 “C库”,或者根本没有。 编译器总是提供crtbegin.ocrtend.o, 但通常C库提供crti.o和crtn.o, 但是在这种情况下不是这样。 内核应该提供自己的crti.ocrtn.o实现 (即使它在其它方面与用户空间libc版本相同)。 内核用 -nostdlib 选项链接 (这与使用-nodefaultlibs-nostartfiles选项相同) 这将禁用通常自动添加到链接命令行的“start files”crt*.o。 通过使用-nostartfiles选项,我们向编译器保证,我们自己负责调用crtbegin.ocrtend.o文件中的“程序初始化任务”。 这意味着我们需要手动添加crti.ocrtbegin.ocrtend.o,和crtn.o到命令行。 由于我们自己提供了crti.ocrtn.o,因此将其添加到内核命令行有点琐碎。 但是,由于 crtbegin.ocrtend.o 安装在特定于编译器的目录中,因此我们需要找出路径。 幸运的是,gcc提供了一个选项来实现这一点。 如果i686-elf-gcc是你的交叉编译器,$CFLAGS是你通常会提供给编译器的标志,则

i686-elf-gcc $CFLAGS -print-file-name=crtbegin.o

将使编译器将正确的 crtbegin.o 文件 (与 $CFLAGS选项兼容) 的路径打印到标准输出。 这同样适用于crtend.o。 如果你使用的是GNU Make,则可以在Makefile中轻松完成此操作,假设$(CC)是你的交叉编译器,而$(CFLAGS)是你通常传递给它的标志:

CRTBEGIN_OBJ:=$(shell $(CC) $(CFLAGS) -print-file-name=crtbegin.o)
CRTEND_OBJ:=$(shell $(CC) $(CFLAGS) -print-file-name=crtend.o)

然后,你可以这样使用它们 (适应你的真实构建系统):

OBJS:=foo.o bar.o

CRTI_OBJ=crti.o
CRTBEGIN_OBJ:=$(shell $(CC) $(CFLAGS) -print-file-name=crtbegin.o)
CRTEND_OBJ:=$(shell $(CC) $(CFLAGS) -print-file-name=crtend.o)
CRTN_OBJ=crtn.o

OBJ_LINK_LIST:=$(CRTI_OBJ) $(CRTBEGIN_OBJ) $(OBJS) $(CRTEND_OBJ) $(CRTN_OBJ)
INTERNAL_OBJS:=$(CRTI_OBJ) $(OBJS) $(CRTN_OBJ)

myos.kernel: $(OBJ_LINK_LIST)
	$(CC) -o myos.kernel $(OBJ_LINK_LIST) -nostdlib -lgcc

clean:
	rm -f myos.kernel $(INTERNAL_OBJS)

重要的是要记住,这些目标文件必须按照这个确切的顺序链接,否则你会遇到奇怪的错误。

然后,你的内核将有一个_init和一个_fini函数链接在一起,可以从boot.o调用它们 (boot.o是你的内核入口点目标文件名称),然后将控制权传递给 kernel_main (kernel_main是你的内核主例程)。 请注意,此时内核可能根本没有初始化,你只能从全局构造函数中执行一些琐碎的操作。 此外,_fini可能永远不会被调用,因为你的操作系统将保持运行状态,并且当需要关机时,对于马上会处理器重置,做什么工作也没有价值了。 可能值得设置一个 kernel_early_main 函数来初始化堆,日志和其它核心内核功能。 然后你的boot.o可以调用kernel_early_main,然后调用_init,最后将控制权传递给真正的kernel_main。 这类似于在用户空间中的工作方式,其中 crt0.o 调用_initialize_c_library (进行C库初始化), 然后_init,最后exit(main(argc, argv))

在用户空间中使用crti.o,crtbegin.o,crtend.o和crtn.o

正文: Creating a C Library

在用户空间中使用这些目标文件非常容易,因为交叉编译器会自动以正确的顺序将它们链接到最终程序中。 编译器将一如既往地提供crtbegin.ocrtend.o。 然后,你的C库将提供crt0.o(程序入口点文件)、crti.ocrtn.o。 如果你有操作系统特定工具链,你可以更改程序入口点的名称 (通常是_start),编译器搜索路径下的crt{0,i,n}.o 文件, 通过修改STARTFILE_SPECENDFILE_SPEC,了解使用了哪些文件(可能带有其它名称)和顺序。 当你开始创建一个用户空间时,创建一个特定于操作系统的工具链可能是值得的,因为它允许你很好地控制所有这些工作的具体方式。

x86 (32-bit)

在x86下实现这一点非常简单。 只需在crti.o中定义两个函数的头即可, 并定义在crtn.o中的脚,让后在你的C库或内核中使用这些目标。 然后,你可以简单地调用 _init 来执行初始化任务,并调用 _fini 来执行终止任务 (通常从crt0.omy-kernel-boot-object.o执行)。

/* x86 crti.s */
.section .init
.global _init
.type _init, @function
_init:
	push %ebp
	movl %esp, %ebp
	/* GCC会很好地将crtegin.o的.init节的内容放在这里。*/

.section .fini
.global _fini
.type _fini, @function
_fini:
	push %ebp
	movl %esp, %ebp
	/* GCC会很好地将crtbegin.o的.fini节的内容放在这里。*/
/* x86 crtn.s */
.section .init
	/* GCC会很好地将crtend.o的.init节的内容放在这里。*/
	popl %ebp
	ret

.section .fini
	/* GCC会很好地将crtend.o的.fini节的内容放在这里。*/
	popl %ebp
	ret

x86_64 (64-bit)

x86_64上的系统ABI类似于32位,我们还需要提供函数头和函数脚,编译器将插入其余的 _init 以及通过crtbegin.ocrtend.o提供_fini函数

/* x86_64 crti.s */
.section .init
.global _init
.type _init, @function
_init:
	push %rbp
	movq %rsp, %rbp
	/*GCC会很好地将crtegin.o的.init节的内容放在这里。*/

.section .fini
.global _fini
.type _fini, @function
_fini:
	push %rbp
	movq %rsp, %rbp
	/*GCC会很好地将crtbegin.o的.fini节内容放在这里。*/
/* x86_64 crtn.s */
.section .init
	/*GCC会很好地将crtend.o的.init节内容放在这里。*/
	popq %rbp
	ret

.section .fini
	/*GCC会很好地将crtend.o的.fini节内容放在这里。*/
	popq %rbp
	ret

ARM (BPABI)

在这种情况下,情况略有不同。 ABI系统要求使用名为.init_array.fini_array的特殊部分, 而不是常见的.init.fini节。 这意味着交叉编译器提供的 crtbegin.ocrtend.o 不会在 .init.fini 部分中插入指令。 结果是,如果你遵循Intel/AMD系统的方法,则你的_init_fini函数将不会起任何作用。 你的交叉编译器实际上可能带有默认的 crti.ocrtn.o目标,但是它们也会受到这个ABI决定的影响,它们的_init_fini函数也不会执行任何操作。

解决方案是提供自己的crti.o目标文件,这个目标文件在.init_array.fini_array节开头插入一个符号, 如同你自己的crtn.o,它在节的末尾插入一个符号。 在这种情况下,实际上可以在C中编写 crti.ocrtn.o,因为我们没有编写不完整的函数。 这些文件应该像内核的其它部分一样编译,像普通crti.ocrtn.o一样使用。

/* crti.c for ARM - BPABI - use -std=c99 */
typedef void (*func_ptr)(void);

extern func_ptr _init_array_start[0], _init_array_end[0];
extern func_ptr _fini_array_start[0], _fini_array_end[0];

void _init(void)
{
	for ( func_ptr* func = _init_array_start; func != _init_array_end; func++ )
		(*func)();
}

void _fini(void)
{
	for ( func_ptr* func = _fini_array_start; func != _fini_array_end; func++ )
		(*func)();
}

func_ptr _init_array_start[0] __attribute__ ((used, section(".init_array"), aligned(sizeof(func_ptr)))) = { };
func_ptr _fini_array_start[0] __attribute__ ((used, section(".fini_array"), aligned(sizeof(func_ptr)))) = { };
/* crtn.c for ARM - BPABI - use -std=c99 */
typedef void (*func_ptr)(void);

func_ptr _init_array_end[0] __attribute__ ((used, section(".init_array"), aligned(sizeof(func_ptr)))) = { };
func_ptr _fini_array_end[0] __attribute__ ((used, section(".fini_array"), aligned(sizeof(func_ptr)))) = { };

此外,如果你使用构造函数/析构函数优先级,编译器会将这些优先级附加到节名。 链接器脚本会试图对这些脚本进行排序,因此你必须将以下内容添加到链接器脚本中。 注意,我们必须特别对待crti.o and crtn.o目标,因为我们需要将符号按正确的顺序排列。 或者,你也可以自己从链接器脚本发出_init_array_start_init_array_end_ fini_array_start_ fini_array_end符号。

/* 包括已排序的初始化函数列表*/
.init_array :
{
    crti.o(.init_array)
    KEEP (*(SORT(EXCLUDE_FILE(crti.o crtn.o) .init_array.*)))
    KEEP (*(EXCLUDE_FILE(crti.o crtn.o) .init_array))
    crtn.o(.init_array)
}

/* 包含已排序的终止函数列表。*/
.fini_array :
{
    crti.o(.fini_array)
    KEEP (*(SORT(EXCLUDE_FILE(crti.o crtn.o) .fini_array.*)))
    KEEP (*(EXCLUDE_FILE(crti.o crtn.o) .fini_array))
    crtn.o(.fini_array)
}

CTOR/DTOR

执行全局构造函数/析构函数的另一种方法是手动执行.ctors / .dtors 符号 (假设你有自己的ELF加载程序,请参见ELF_教程)。 将每个ELF文件加载到内存中,并且所有符号都已解析和重新定位后,可以使用.ctors/.dtor手动执行全局构造函数/析构函数 (显然,这同样适用于.init_array和.fini_array)。 要执行此操作,必须首先找到.ctors / .dtors 节头:

for (i = 0; i < ef->ehdr->e_shnum; i++)
{
    char name[250];
    struct elf_shdr *shdr;

    ret = elf_section_header(ef, i, &shdr);
    if (ret != ELF_SUCCESS)
        return ret;

    ret = elf_section_name_string(ef, shdr, &name);
    if (ret != BFELF_SUCCESS)
        return ret;

    if (strcmp(name, ".ctors") == 0)
    {
        ef->ctors = shdr;
        continue;
    }

    if (strcmp(name, ".dtors") == 0)
    {
        ef->dtors = shdr;
        continue;
    }
}

现在你有了.ctors/.dtors节头,你可以使用以下内容解析每个构造函数。 请注意.ctors / .dtors是一个指针表 (ELF32为32bit,ELF64为64bit)。 每个指针都是必须执行的函数。

typedef void(*ctor_func)(void);

for(i = 0; i < ef->ctors->sh_size / sizeof(void *); i++)
{
    ctor_func func;
    elf64_addr sym = 0;

    sym = ((elf64_addr *)(ef->file + ef->ctors->sh_offset))[i];
    func = ef->exec + sym;
    func();

    /* elf->文件是存储你使用的ELF文件的字符 *。 Could be binary or shared library */
    /*elf->exec是一个char*,它存储ELF文件已加载到的内存位置,并重新放置*/
}

如果在.ctors / .dtors.中你只有一个条目,不要惊讶。 至少在x86_64上,GCC似乎将单个条目添加到一组名为_GLOBAL__SUB_I_XXX和_GLOBAL_SUB_D_XXX的函数中,这两个函数调用的_Z41__static_initialization_and_destruction_0ii实际上为你调用了每个构造函数。 添加更多全局定义的构造函数/析构函数将导致此函数增长,而不是ctors/.dtors。

稳定性问题

如果不调用GCC提供的构造函数/析构函数,则GCC将生成代码,该代码将在某些条件下使用x86_64发生段错误(segfault)。 以此为例:

class A
{
    public: 
        A() {}
};

A g_a;

void foo(void)
{
    A *p_a = &g_a;
    p_a->anything();     // <---- segfault
}

GCC似乎在使用构造函数/析构函数初始化例程时不仅仅是调用每个全局定义类的构造函数/析构函数。 执行.ctors/.dtors中定义的函数不仅可以初始化所有的构造函数/析构函数,还可以解决这些类型的段错误(上面只是许多已解决的错误中的一个示例)。 据我所知,当全局定义的对象存在时,GCC也可能创建 “.data.rel.ro”,这是GCC需要处理的另一个重定位表。 它被标记为PROGBITS,而不是REL/RELA,这意味着ELF加载程序不会为你重新定位。 相反,执行.ctors中定义的函数将执行_Z41__static_initialization_and_destruction_0ii,它似乎会为我们执行重定位。 有关更多信息,请参见以下内容: [1]

Clang

注意: 由于Clang试图在很大程度上与GCC兼容,因此这里列出的信息很可能会变化。 如果有你尝试过,请在此处记录调查结果。

据我所知,clang不需要你在传递给链接器的目标中指定正确的crt{begin,end}.o,前提是你在正确的位置传递crt[in].o,能够输出到许多目标, 并不总是有一个可用的crt{begin,end}.o在手边,它似乎是按需编译的。 调用_init似乎也不是一项要求。 对_init的额外调用不会执行两次,因此手动调用仍然是安全的。

总之,为了将GCC的代码修改为clang,从你的链接程序行中去掉crt{Begin,End}.o就可以了。

其它编译器/平台

如果你的编译器或系统ABI未在此处列出,你将需要手动查阅相应的文档,如果相关信息,请也帮忙在此处记录。

另见

外部链接