Calling Global Constructors
本教程讨论如何正确调用全局构造函数,例如全局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.o,crti.o,crtbegin.o,crtend.o 和 crtn.o。 这些目标文件一起实现两个特殊功能:_init运行全局构造函数和其它初始化任务, 以及运行全局析构函数和其他终止任务的 _fini。
此方案使编译器可以很好地控制程序初始化,使你的工作变得容易,但你必须与编译器合作,否则会发生不好的事情。 你的交叉编译器将为你提供 crtbegin.o 和 crtend.o。 这些文件包含编译器希望对你隐藏但对你有用的内部文件。 要访问此信息,你需要提供你自己的crti.o和crtn.o实现。 幸运的是,这很容易,并且在本教程中进行了详细描述。 第五个文件crt0.o包含程序入口点(通常为_start),并调用特殊的_init函数,该函数运行运行crti的“程序初始化任务”(由crti.o, crtbegin.o组成)。 crtend.o和crtn.o组合在一起, 你的exit函数通常会调用这些目标生成的函数。 但是,crt0.o超出了本文的讨论范围。 (请注意,包含 _start 的目标文件在内核中充当 crt0.o。)
为了理解这种明显的复杂性,考虑一个由foo.o和bar.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.o 和 crtend.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.o和crtend.o, 但通常C库提供crti.o和crtn.o, 但是在这种情况下不是这样。 内核应该提供自己的crti.o和crtn.o实现 (即使它在其它方面与用户空间libc版本相同)。 内核用 -nostdlib 选项链接 (这与使用-nodefaultlibs和-nostartfiles选项相同) 这将禁用通常自动添加到链接命令行的“start files”crt*.o。 通过使用-nostartfiles选项,我们向编译器保证,我们自己负责调用crtbegin.o 和 crtend.o文件中的“程序初始化任务”。 这意味着我们需要手动添加crti.o,crtbegin.o,crtend.o,和crtn.o到命令行。 由于我们自己提供了crti.o和crtn.o,因此将其添加到内核命令行有点琐碎。 但是,由于 crtbegin.o 和 crtend.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
在用户空间中使用这些目标文件非常容易,因为交叉编译器会自动以正确的顺序将它们链接到最终程序中。 编译器将一如既往地提供crtbegin.o和crtend.o。 然后,你的C库将提供crt0.o(程序入口点文件)、crti.o和crtn.o。 如果你有操作系统特定工具链,你可以更改程序入口点的名称 (通常是_start),编译器搜索路径下的crt{0,i,n}.o 文件, 通过修改STARTFILE_SPEC和ENDFILE_SPEC,了解使用了哪些文件(可能带有其它名称)和顺序。 当你开始创建一个用户空间时,创建一个特定于操作系统的工具链可能是值得的,因为它允许你很好地控制所有这些工作的具体方式。
x86 (32-bit)
在x86下实现这一点非常简单。 只需在crti.o中定义两个函数的头即可, 并定义在crtn.o中的脚,让后在你的C库或内核中使用这些目标。 然后,你可以简单地调用 _init 来执行初始化任务,并调用 _fini 来执行终止任务 (通常从crt0.o或my-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.o和crtend.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.o 和 crtend.o 不会在 .init 和 .fini 部分中插入指令。 结果是,如果你遵循Intel/AMD系统的方法,则你的_init和_fini函数将不会起任何作用。 你的交叉编译器实际上可能带有默认的 crti.o和crtn.o目标,但是它们也会受到这个ABI决定的影响,它们的_init和_fini函数也不会执行任何操作。
解决方案是提供自己的crti.o目标文件,这个目标文件在.init_array和.fini_array节开头插入一个符号, 如同你自己的crtn.o,它在节的末尾插入一个符号。 在这种情况下,实际上可以在C中编写 crti.o 和 crtn.o,因为我们没有编写不完整的函数。 这些文件应该像内核的其它部分一样编译,像普通crti.o和crtn.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未在此处列出,你将需要手动查阅相应的文档,如果相关信息,请也帮忙在此处记录。
另见
外部链接
- 初始化函数的处理方式 在GCC的文档中。