Introduction
For most programmers, the entry point of a C or C++ program is the main function. However, they may be unaware of the complex steps that occur before main is executed. Depending on the program and the compiler used, various functions may run before main. These functions are automatically included in the final executable binary by the compiler and linker, but they remain hidden from the programmer.
This post discusses the execution process and events from _start to the main function, with future posts covering the next steps.
Before we dive into the main part, you must have some basic knowledge about constructor and destructors.
How does GCC handle constructor and destructor functions?
In C++, dynamic initialization of non-local variables is performed before the first main function statement is executed. In other words, these variables are initialized before the main program starts running. All (or most) C++ compiler implementations guarantee this.
The GCC compiler supports __attribute__((constructor)), which allows you to call an arbitrary function before the main function is executed. These functions, called constructors, can have an optional precedence specified using __attribute__((constructor(N))).
Priorities ranging from 0 to 100 are reserved for internal compiler use, and utilizing them will trigger the -Wprio-ctor-dtor warning. For instance, the gcov tool uses the attribute __attribute__((destructor(100))). Programmers can use priorities from 101 to 65535 for their constructor functions. The priority 65535, specified by the .init_array or .ctors sections without an extension, is the default priority for the dynamic initialization of non-local variables.
#include <stdio.h>
__attribute__((constructor(102))) void init102() { puts("init102"); }
__attribute__((constructor(101))) void init101() { puts("init101"); }
__attribute__((constructor)) void init() { puts("init65535"); }
int main(void)
{
return 0;
}
Constructor functions on ELF platforms are implemented in two ways: the older method uses .init/.ctors, while the newer method utilizes .init_array.
.ctors and .dtors Sections
In GCC’s libgcc/crtstuff.c, when __LIBGCC_INIT_ARRAY_SECTION_ASM_OP__ is not defined and __LIBGCC_INIT_SECTION_ASM_OP__ is defined (indicating that HAVE_INITFINI_ARRAY_SUPPORT is 1 in $builddir/gcc/auto-host.h), the following scheme is used. Note that this condition is not met on modern systems.
C++ dynamic initializations and __attribute__((constructor)) do not use _init directly.When this condition is met, global constructors and destructors are stored in sections known as .ctors and .dtors, respectively. These sections contain the addresses of these functions, and at runtime, the operating system retrieves these addresses and executes the functions in the specified order. In essence, these sections act as a list of functions that must be called before and after the main program begins execution.
Imagine we have two object files named c.o and d.o, each containing a .ctors section with different priorities. After linking these files, the resulting .ctors section will be organized as follows:
crtbegin.o:(.ctors) __CTOR_LIST__
c.o:(.ctors) d.o:(.ctors)
c.o:(.ctors.00001) d.o:(.ctors.00001)
c.o:(.ctors.00002) d.o:(.ctors.00002)
...
c.o:(.ctors.65533) d.o:(.ctors.65533)
c.o:(.ctors.65534) d.o:(.ctors.65534)
...
crtend.o:(.ctors) __CTOR_LIST_END__
The .dtors section is organized in the same manner:
crtbegin.o:(.dtors) __DTOR_LIST__
c.o:(.dtors) d.o:(.dtors)
c.o:(.dtors.00001) d.o:(.dtors.00001)
c.o:(.dtors.00002) d.o:(.dtors.00002)
...
c.o:(.dtors.65533) d.o:(.dtors.65533)
c.o:(.dtors.65534) d.o:(.dtors.65534)
...
crtend.o:(.dtors) __DTOR_LIST_END__
The crtbegin.o and crtend.o files play a crucial role in the initialization and termination processes of a program. These files contain specific functions and data that the linker includes in the final executable file.
The
crtbegin.ofile:- At the beginning of each
.ctorsand.dtorssection, it places a special value known as the guard element. This value is typically -1 (or its equivalent on 64-bit systems,0xffffffffffffffff) and indicates the start of the list of constructors or destructors. - The
crtbegin.ofile also defines a.finisection that calls the__do_global_dtors_auxfunction. This function is responsible for executing static destructors (functions that free resources) in the correct order.
- At the beginning of each
The
crtend.ofile:- At the end of each
.ctorsand.dtorssection, it places a special value called the guard element. This value is usually 0, which signifies the end of the constructor or destructor list. - The
crtend.ofile also defines an.initsection that calls the__do_global_ctors_auxfunction. This function is responsible for executing static constructors (functions that initialize variables and objects) in the correct order.
- At the end of each
The guard elements (-1 and 0) serve as markers to help the linker and operating system identify the beginning and end of the lists of constructors and destructors, respectively. These elements are ignored when the functions are executed.
Reverse Execution Order of Constructors and Destructors
One interesting feature of constructor and destructor execution is their reverse order of execution. Specifically, constructors located in the .ctors section are executed in reverse order, while destructors in the .dtors section are executed in the order in which they were defined.
Why Are Constructors Executed in Reverse Order?
This behavior is by design. When dynamically linking libraries, if one library depends on another, the constructors of the dependent library are executed first. For example, if library a.so depends on library b.so, the constructors of b.so are executed before those of a.so. This sequence ensures that each symbol is properly initialized before being used.
For .ctors sections without an extension (which have the lowest precedence), the order of constructor execution during dynamic linking follows the same pattern as in static linking. This means that when multiple object files are linked together into an executable, the constructors are executed in the order they appear on the command line.
Destructors serve as a complement to constructors, executing in the order of their creation. This ensures that resources are properly deallocated in the reverse order of how they were allocated.
Why Is This Order Important?
- It ensures that each symbol is initialized before use.
- It maintains consistent behavior between dynamic and static linking.
- It guarantees the correct deallocation of resources.
.init_array and .fini_array Sections
The developers of HP-UX identified several issues with the use of the .init and .ctors sections:
- The
_initfunction is fragmented and inconsistent, resulting in code that is difficult to read and prone to errors. - Using sentinel values in the
.ctorssection is considered poor practice. - The
.initand.ctorssections utilize unconventional naming instead of the defined section types.
To address these issues, the DT_INIT_ARRAY mechanism was introduced as an alternative. While glibc implemented this method in 1999, both GCC and Binutils had outdated implementations at that time.
Support for DT_INIT_ARRAY was added to FreeBSD in March 2012, to OpenBSD in August 2016, and to all ports of NetBSD in December 2018.
In the glibc and BSD implementations, the constructors are invoked with argc, argv, and environ arguments. In contrast, the musl implementation calls constructors without any arguments.
In this context, the .init_array and .init_array.N sections are designated as type SHT_INIT_ARRAY. Note that crtbegin.o and crtend.o do not provide any segments.
The layout is as follows:
a.o:(.init_array.1) b.o:(.init_array.1)
a.o:(.init_array.2) b.o:(.init_array.2)
...
a.o:(.init_array.65533) b.o:(.init_array.65533)
a.o:(.init_array.65534) b.o:(.init_array.65534)
a.o:(.init_array) b.o:(.init_array)
The linker defines DT_INIT_ARRAY and DT_INIT_ARRAYSZ based on the address and size of init_array. It also defines init_array_start and init_array_end if they are referenced. These pair of symbols can be used by a statically linked, position-dependent executable that may not include a .dynamic section.
Unlike .ctors, the execution order of .init_array is linear, following .init. Specifically, the order of execution for a.o:(.init_array) b.o:(.init_array) is different from a.o:(.ctors) b.o:(.ctors).
In the GCC compiler, newer ABI implementations, such as AArch64 and RISC-V, exclusively use .init_array and do not include .ctors.
.preinit_array
The linker determines the values of DT_PREINIT_ARRAY and DT_PREINIT_ARRAYSZ based on the address and size of the .preinit_array section. It also defines the symbols __preinit_array_start and __preinit_array_end if they are referenced.
DT_PREINIT_ARRAY contains the address of an array of pointers to pre-initialization functions. The DT_PREINIT_ARRAY table is processed only in executable files and is ignored in shared objects. This feature allows the executable file to run initialization functions before any shared object dependencies are processed. There is no .postfini_array.
Most implementations of ld.so support DT_PREINIT_ARRAY; however, musl does not support this feature.
What is happening behind the scene?
Let’s review the previously written program. This program contains three functions that execute before the main execution and display the corresponding messages. Additionally, the main function in this program does nothing but return 0.
#include <stdio.h>
__attribute__((constructor(102))) void init102() { puts("init102"); }
__attribute__((constructor(101))) void init101() { puts("init101"); }
__attribute__((constructor)) void init() { puts("init65535"); }
int main(void)
{
return 0;
}
To include debug information for analysis in our program, we must compile it using the -ggdb switch of the gcc compiler. You can achieve this with the following command:
gcc -ggdb -o ELF2 ELF_008.c
What is the difference between the -g and -ggdb switches?
The -g switch instructs the compiler to generate debugging information in standard operating system formats such as stabs, COFF, XCOFF, or DWARF. This information helps debugging tools like GDB to better understand and debug the program.
The -ggdb switch produces a more comprehensive level of debugging information specifically designed for GDB. This allows GDB to extract additional information about the program, enabling more detailed debugging.
Before we debug the program in gdb, let’s first examine how a C program is executed from the beginning. We can use the objdump tool to convert machine code into assembly code. The following command saves the disassembly output of an ELF2 program to a file named ELF2.dump:
objdump -d ELF2 > ELF2.dump
The file named prog1.dump contains assembly instructions that the processor executes directly. Here is a brief overview of the functions that we will review shortly:
ELF2: file format elf64-x86-64
0000000000001050 <_start>:
1050: 31 ed xor %ebp,%ebp
1052: 49 89 d1 mov %rdx,%r9
1055: 5e pop %rsi
1056: 48 89 e2 mov %rsp,%rdx
1059: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
105d: 50 push %rax
105e: 54 push %rsp
105f: 45 31 c0 xor %r8d,%r8d
1062: 31 c9 xor %ecx,%ecx
1064: 48 8d 3d 10 01 00 00 lea 0x110(%rip),%rdi # 117b <main>
106b: ff 15 4f 2f 00 00 call *0x2f4f(%rip) # 3fc0 <__libc_start_main@GLIBC_2.34>
1071: f4 hlt
1072: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
1079: 00 00 00
107c: 0f 1f 40 00 nopl 0x0(%rax)
0000000000001139 <init102>:
1139: 55 push %rbp
113a: 48 89 e5 mov %rsp,%rbp
113d: 48 8d 05 c0 0e 00 00 lea 0xec0(%rip),%rax # 2004 <_IO_stdin_used+0x4>
1144: 48 89 c7 mov %rax,%rdi
1147: e8 e4 fe ff ff call 1030 <puts@plt>
114c: 90 nop
114d: 5d pop %rbp
114e: c3 ret
000000000000114f <init101>:
114f: 55 push %rbp
1150: 48 89 e5 mov %rsp,%rbp
1153: 48 8d 05 b2 0e 00 00 lea 0xeb2(%rip),%rax # 200c <_IO_stdin_used+0xc>
115a: 48 89 c7 mov %rax,%rdi
115d: e8 ce fe ff ff call 1030 <puts@plt>
1162: 90 nop
1163: 5d pop %rbp
1164: c3 ret
0000000000001165 <init>:
1165: 55 push %rbp
1166: 48 89 e5 mov %rsp,%rbp
1169: 48 8d 05 a4 0e 00 00 lea 0xea4(%rip),%rax # 2014 <_IO_stdin_used+0x14>
1170: 48 89 c7 mov %rax,%rdi
1173: e8 b8 fe ff ff call 1030 <puts@plt>
1178: 90 nop
1179: 5d pop %rbp
117a: c3 ret
000000000000117b <main>:
117b: 55 push %rbp
117c: 48 89 e5 mov %rsp,%rbp
117f: b8 00 00 00 00 mov $0x0,%eax
1184: 5d pop %rbp
1185: c3 ret
...
The _start Function: The Hidden Beginning of a Program
In most C and C++ programs, the actual starting point of execution is a function called _start. This function is responsible for setting up the program’s execution environment and for calling the main function, which serves as the logical starting point for the programmer.
While it is a common convention to use _start as the main entry point, it is not a strict requirement. Depending on the operating system, compiler, and libraries utilized, a different entry point may be specified. For instance, on macOS, the main function itself serves as the entry point, with the operating system taking care of preparing the execution environment.
The linker plays a crucial role in determining a program’s entry point. By default, linkers such as Clang and GCC set the entry point to the _start function. However, this can be altered using the -e switch, although such changes are typically unnecessary.
The _start function is usually implemented and provided in the C standard library (libc) and is often written in assembly language. This code is found in a file named crt0.s, and compilers generally provide precompiled versions of this file for various architectures.
How do we get to _start?
When you run a Linux program, the shell or graphical user interface (GUI) calls the execve() function, which executes the Linux execve() system call.
int execve(const char *pathname, char *const _Nullable argv[], char *const _Nullable envp[]);
The execve function replaces the currently running program with the program specified by the pathname. The current program is the process that invoked the execve system call, which in this case is the shell or GUI. In other words, the new program takes the place of the previous one, creating a new memory space for itself, which includes a stack, heap, and data sections (both initialized and uninitialized).
The pathname must be an executable binary file or a script that begins with a line in the following format:
#!interpreter [optional-arg]
The argv variable is an array containing the arguments provided on the command line when running the program. Each element of this array is a string representing an argument. For instance, if we run a program called myprogram with the arguments -a and data.txt, the argv array will look like this:
argv[0] = "myprogram";
argv[1] = "-a";
argv[2] = "data.txt";
argv[3] = NULL;
The envp variable is an array of pointers to strings that contains environment variables passed to the new program. These environment variables function like system variables, and each element of this array is a string formatted as key=value. For example:
envp[0] = "PATH=/bin:/usr/bin";
envp[1] = "HOME=/home/user";
envp[2] = NULL;
When execve is successfully executed, the new program completely replaces the previous one, and all the resources of the previous program are freed. This means that the execve function never returns to the calling program; instead, the memory space of the previous program is replaced with that of the new program. Additionally, if the previous program is being traced (using ptrace), a SIGTRAP signal is sent to it after the successful execution of execve.
Following this, the loader is responsible for loading the program into memory and setting up memory addresses. The loader may also perform some initial tasks, such as calling certain functions, before the program begins executing. Once everything is ready, control of the program execution is transferred to a function called _start.
For more information about the execve system call, please refer to this link.
A Closer Look at _start
First, we run the program in GDB using the following command:
gdb ./ELF2
Next, we set a breakpoint at the beginning of the _start function and run the program:
b _start
r
Once we reach the _start function, the program stack appears as follows:
+-----------------+
| NULL |
+-----------------+
| ... |
| envp |
| ... |
+-----------------+
| NULL |
+------------------
| ... |
| argv |
| ... |
+------------------
| argc | <- rsp
+-----------------+
Below, you can see the structure of the _start function for the program we compiled using GCC:
● →0x555555555050 <_start+0000> xor ebp, ebp
0x555555555052 <_start+0002> mov r9, rdx
0x555555555055 <_start+0005> pop rsi
0x555555555056 <_start+0006> mov rdx, rsp
0x555555555059 <_start+0009> and rsp, 0xfffffffffffffff0
0x55555555505d <_start+000d> push rax
0x55555555505e <_start+000e> push rsp
0x55555555505f <_start+000f> xor r8d, r8d
0x555555555062 <_start+0012> xor ecx, ecx
0x555555555064 <_start+0014> lea rdi, [rip+0x110] # 0x55555555517b <main>
0x55555555506b <_start+001b> call QWORD PTR [rip+0x2f4f] # 0x555555557fc0
0x555555555071 <_start+0021> hlt
At the beginning of this code, the
ebpregister is set to zero using thexor ebp, ebpinstruction. This zero value in theebpregister indicates the start of a new stack frame.The value in the
rdxregister, which is being transferred tor9, corresponds to thedl_finifunction. This function serves as a parameter to the__libc_start_mainfunction.The value of
argcis removed from the top of the stack and placed into thersiregister.Next, the instruction
mov rdx, rspis used to transfer the address of theargvparameter into therdxregister.An AND operation is then performed between the
rspregister and the value0xffffffffffffff0. The purpose of this operation is to align the stack to a 16-byte boundary. After popping theargcvalue from the stack, theespvalue changes from0xbffff770to0xbffff774. The AND operation restores therspvalue back to0xbffff770.Finally, the starting address of the
envparray is pushed onto the stack.Note that the r8 and ecx registers are for the init and fini function parameters, and these parameters are set to zero.
In older versions of GCC (2.34 and earlier), the parameters for init and fini were passed as the addresses of the first instructions of the functions __libc_csu_init and __libc_csu_fini, respectively. However, with changes, these two functions have been removed. The responsibilities for processing the init_array, fini_array, and preinit_array arrays have now been divided into different sections of the libc_start_main function. As a result of the removal of these functions, null values are passed to the libc_start_main function for the init and fini parameters instead of the addresses of __libc_csu_init and __libc_csu_fini.
The question arises: how do we determine the locations of argc, argv, and envp?
Before the _start function is called, these three values are placed on the stack as pointers. To understand this, we should examine the contents of the rsp register:
As it shown in the image, the value of argc is represented by the number 1, indicating that it was executed without any additional parameters. Consequently, this parameter value will be stored on the stack at the address 0x00007fffffffe1b0. If we display the value located at this address as a string, we can observe argv, and envp is placed on the stack after argv.
As shown in the image, the value of argc is 1, indicating that the program was executed without any additional parameters. This value will be stored on the stack at the address 0x00007fffffffe1b0. If we display the value stored at this address as a string, we can see argv, while envp is placed on the stack following argv.
Preparing to call the libc_start_main function.
At this stage, the arguments required for the libc_start_main function call are pushed onto the stack in reverse order. The first argument placed on the stack is an arbitrary value, typically stored in the rax register. This value is pushed onto the stack solely for the purpose of maintaining 16-byte alignment and serves no other function. It is essential to maintain this alignment because the subsequent arguments are pushed onto the stack in order, and the additional value ensures proper memory alignment.
The structure of the __libc_start_main function is as follows:
int __libc_start_main( int (*main) (int, char * *, char * *),
int argc, char * * ubp_av,
void (*init) (void),
void (*fini) (void),
void (*rtld_fini) (void),
void (* stack_end));
So, the _start function is expected to push this argument onto the stack in reverse order. The register values before the call looks like this:
| registers | values |
|---|---|
| rdi | a pointer to the first instruction of the main function |
| rsi | argc value |
| rdx | pointer to argv |
| rcx | null value |
| r8 | null value |
| r9 | a pointer to the dl_fini/rtld_fini function |
Where are the environment variables (envp)?
As mentioned, the envp array is stored on the stack right after the argv array. By using argc, we can determine the number of elements in the argv array, which allows us to find the end of this array and access the envp array. In the image below,at line 244, you can see how __libc_start_main extracts envp at the source code level:
After that, you can see that the pointer to the environment variables is stored in a variable named __environ. This variable is accessible throughout the program and is utilized by other C library functions to access environment variables. Whenever libc_start_main requires it—such as when it calls the main function it can reference __environ. Additionally, there’s another vector following the envp array known as the ELF auxiliary vector. The loader uses this vector to provide various information to our process. You can view this information by using the command LD_SHOW_AUXV=1:
LD_SHOW_AUXV=1 ./ELF2
AT_SYSINFO_EHDR: 0x7f7134737000
AT_MINSIGSTKSZ: 1776
AT_HWCAP: 1f8bfbff
AT_PAGESZ: 4096
AT_CLKTCK: 100
AT_PHDR: 0x5640459c7040
AT_PHENT: 56
AT_PHNUM: 14
AT_BASE: 0x7f7134739000
AT_FLAGS: 0x0
AT_ENTRY: 0x5640459c8050
AT_UID: 1000
AT_EUID: 1000
AT_GID: 1000
AT_EGID: 1000
AT_SECURE: 0
AT_RANDOM: 0x7ffc2a3ea779
AT_HWCAP2: 0x0
AT_EXECFN: ./ELF2
AT_PLATFORM: x86_64
AT_??? (0x1b): 0x1c
AT_??? (0x1c): 0x20
init101
init102
init65535
This vector offers us valuable information. Explanations for each can be found at this link. This __libc_start_main performs the following tasks:
Initialization:
- Retrieves the values of
argv,argc, and.envp. - Saves the
stack_end.
- Retrieves the values of
Internal Initialization:
- Calls the functions listed in
.init_arrayfor initialization. - If the program is static, it also executes the functions in
.preinit_array. - Additionally, if the
_initfunction exists, it is called before the functions in.init_array.
- Calls the functions listed in
Function Registration:
- Registers two functions,
rtld_finiandcall_fini, which are invoked when the program exits. These functions are explained in greater detail in the continuation.
- Registers two functions,
Calling call_init
After retrieving argv, argc, and envp, the call_init function is invoked by __libc_start_main. The call_init function executes all the functions listed in the .init_array in sequential order:
→ 0x7ffff7ddcdf2 <__libc_start_main+0052> je 0x7ffff7ddce25 <__libc_start_main_impl+133>
The function call_init receives the following arguments:
[#0] 0x7ffff7ddce25 → call_init(argc=0x1, argv=0x7fffffffde38, env=0x7fffffffde48)
For our program, the first function that will be executed is _init:
→ 0x555555555000 <_init+0000> sub rsp, 0x8
0x555555555004 <_init+0004> mov rax, QWORD PTR [rip+0x2fc5] # 0x555555557fd0
0x55555555500b <_init+000b> test rax, rax
0x55555555500e <_init+000e> je 0x555555555012 <_init+18>
0x555555555010 <_init+0010> call rax
0x555555555012 <_init+0012> add rsp, 0x8
0x555555555016 <_init+0016> ret
This function doesn’t actually perform any special actions! Let’s move on to the next function. The next function is init101, which is defined to run before the main function in the program, based on the priority we set earlier:
→ 0x55555555514f <init101+0000> push rbp
0x555555555150 <init101+0001> mov rbp, rsp
0x555555555153 <init101+0004> lea rax, [rip+0xeb2] # 0x55555555600c
0x55555555515a <init101+000b> mov rdi, rax
0x55555555515d <init101+000e> call 0x555555555030 <puts@plt>
0x555555555162 <init101+0013> nop
0x555555555163 <init101+0014> pop rbp
0x555555555164 <init101+0015> ret
This function is defined in the source code and is invoked by calling puts, which takes the init101 message as a parameter and displays it as output in the terminal.
The next function to be executed is init102, which performs the same task as the previous function and displays the corresponding message:
→ 0x555555555139 <init102+0000> push rbp
0x55555555513a <init102+0001> mov rbp, rsp
0x55555555513d <init102+0004> lea rax, [rip+0xec0] # 0x555555556004
0x555555555144 <init102+000b> mov rdi, rax
0x555555555147 <init102+000e> call 0x555555555030 <puts@plt>
0x55555555514c <init102+0013> nop
0x55555555514d <init102+0014> pop rbp
0x55555555514e <init102+0015> ret
The next step involves calling the frame_dummy function. The primary objective of this call is to register the frame information for data analysis, ensuring that if an exception occurs, the stack frames can be accurately processed and the point of the exception can be identified. This function prepares the necessary arguments for the main frame registration function, __register_frame_info:
→ 0x555555555130 <frame_dummy+0000> endbr64
0x555555555134 <frame_dummy+0004> jmp 0x5555555550b0 <register_tm_clones>
→ 0x5555555550b0 <register_tm_clones+0000> lea rdi, [rip+0x2f61] # 0x555555558018 <completed.0>
0x5555555550b7 <register_tm_clones+0007> lea rsi, [rip+0x2f5a] # 0x555555558018 <completed.0>
0x5555555550be <register_tm_clones+000e> sub rsi, rdi
0x5555555550c1 <register_tm_clones+0011> mov rax, rsi
0x5555555550c4 <register_tm_clones+0014> shr rsi, 0x3f
0x5555555550c8 <register_tm_clones+0018> sar rax, 0x3
0x5555555550cc <register_tm_clones+001c> add rsi, rax
0x5555555550cf <register_tm_clones+001f> sar rsi, 1
0x5555555550d2 <register_tm_clones+0022> je 0x5555555550e8 <register_tm_clones+56>
0x5555555550d4 <register_tm_clones+0024> mov rax, QWORD PTR [rip+0x2efd] # 0x555555557fd8
0x5555555550db <register_tm_clones+002b> test rax, rax
0x5555555550de <register_tm_clones+002e> je 0x5555555550e8 <register_tm_clones+56>
0x5555555550e0 <register_tm_clones+0030> jmp rax
0x5555555550e2 <register_tm_clones+0032> nop WORD PTR [rax+rax*1+0x0]
0x5555555550e8 <register_tm_clones+0038> ret
The last constructor function to be executed is init, serving the same purpose as the previous function defined at the source level:
→ 0x555555555165 <init+0000> push rbp
0x555555555166 <init+0001> mov rbp, rsp
0x555555555169 <init+0004> lea rax, [rip+0xea4] # 0x555555556014
0x555555555170 <init+000b> mov rdi, rax
0x555555555173 <init+000e> call 0x555555555030 <puts@plt>
0x555555555178 <init+0013> nop
0x555555555179 <init+0014> pop rbp
0x55555555517a <init+0015> ret
Calling _dl_audit_preinit@plt
The next function is _dl_audit_preinit@plt, which executes the members of the preinit_array array.
Calling libc_start_call_main
Next, we have the libc_start_call_main function, which is responsible for invoking the main function. It takes three parameters, detailed as follows:
[#0] 0x7ffff7ddccf0 → __libc_start_call_main(main=0x55555555517b <main>, argc=0x1, argv=0x7fffffffde28)
The main function simply returns the value zero in the eax register:
→ 0x55555555517b <main+0000> push rbp
0x55555555517c <main+0001> mov rbp, rsp
0x55555555517f <main+0004> mov eax, 0x0
0x555555555184 <main+0009> pop rbp
0x555555555185 <main+000a> ret
rtld_fini function
The rtld_fini function is called by __libc_start_main after the main function has completed. This function is designed to be executed after the program has finished running but before exiting and before call_fini. It is specifically used for dynamically linked programs and performs several important tasks, including:
- Unloading libraries that were loaded during runtime.
- Calling the destructors for those libraries.
- Deallocating resources used for loading the libraries.
- If the program utilized threading,
rtld_finialso cleans up thread-specific resources that are reserved for Thread Local Storage (TLS).
call_fini function
This function is executed after rtld_fini and is responsible for executing the functions in the fini_array section in reverse order. The functions in this section are destructors that clean up the resources allocated during the program’s execution or initialization.
Conclusion
Understanding the execution process of a program from the _start function to main reveals the hidden complexities underlying a seemingly simple program. This process involves intricate interactions among the compiler, linker, and operating system, which ensure the correct initialization and termination of resources.
Key elements such as constructor and destructor functions, as well as section layouts like .ctors, .init_array, and .preinit_array, play crucial roles in managing the lifecycle of a program. The reverse order of constructor execution and the structured handling of destructors further highlight the deliberate design choices intended to maintain consistency and correctness.
Additionally, exploring how system calls like execve, along with debugging tools like objdump and GDB, expose the step-by-step execution flow provides invaluable insights into program behavior. This knowledge bridges the gap between high-level programming and the low-level mechanisms that drive modern software.
In the next post, we will examine the behind-the-scenes process of the main function until the program concludes.
Links
- https://maskray.me/blog/2021-11-07-init-ctors-init-array
- https://gcc.gnu.org/onlinedocs/gccint/Initialization.html
- http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html
- https://docs.oracle.com/cd/E88353_01/html/E37853/crti.o-7.html
- https://blog.k3170makan.com/2018/10/introduction-to-elf-format-part-v.html