Linux 内核如何装载和启动一个可执行程序
1. Linux 可执行程序的加载和启动过程
Linux 加载和启动一个可执行文件的过程涉及以下步骤:
-
编译和链接:程序的源代码通过编译生成目标文件(通常为
.o
文件),这些文件包含二进制代码和符号信息。链接器负责将这些目标文件组合成一个可执行文件,链接阶段将不同模块组合并解析符号。最终生成的可执行文件通常为 ELF (Executable and Linkable Format) 格式。 -
ELF 文件结构:ELF 文件包含多个段(sections),如
.text
、.data
、.bss
、.rodata
等。程序入口点存储在 ELF 头中,执行时会加载该入口点。ELF 支持静态和动态链接,静态链接会将所有库函数嵌入生成的 ELF 文件,而动态链接则将符号解析延迟到程序运行时。 -
加载和内存映射:当
execve
系统调用被调用时,内核会进行以下操作:- 检查用户对文件的访问权限。
- 解析 ELF 文件头,并确定加载文件的各个段。
- 分配内存,将 ELF 文件的各个段加载到对应的内存区域。
- 如果是动态链接程序,还会加载所需的动态链接库。
- 配置初始堆栈和环境变量。
- 将进程的指令指针设置为 ELF 文件的入口点(即开始执行的位置)。
-
程序开始执行:内核将程序的入口地址设定在 CPU 的指令指针寄存器中,这样程序的执行从入口点开始。
2. 使用 exec 系列函数加载可执行文件
可以使用 exec
系列函数(例如 execv
、execvp
等)来加载和执行可执行文件。exec
系列函数会用新程序替换当前进程映像,但保持原进程的进程 ID 和其他资源。
示例代码:调用 execv
执行另一个程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
char *args[] = {"/bin/ls", "-l", "/", NULL}; // 需要执行的命令
printf("Executing ls -l /\n");
if (execv("/bin/ls", args) == -1) {
perror("execv failed");
}
printf("This line will not be printed if execv is successful\n");
return 0;
}
在这段代码中,execv
替换当前进程的映像,如果执行成功,将不会返回,而是从新程序的入口点开始执行。
3. 动态链接库的两种使用方式
动态链接库的使用分为两种方式:
- 装载时动态链接:当可执行文件加载时,动态链接器会自动装载并解析所需的动态库。
gcc test2.c -o test2 -ldl
- 运行时动态链接:使用
dlopen
和dlsym
等函数在程序运行时加载和使用共享库。
示例:运行时动态链接
#include <stdio.h>
#include <dlfcn.h>
int main() {
void *handle;
double (*cosine)(double);
char *error;
// 动态加载 math 库
handle = dlopen("libm.so.6", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s\n", dlerror());
return 1;
}
// 查找 cos 函数
cosine = dlsym(handle, "cos");
if ((error = dlerror()) != NULL) {
fprintf(stderr, "%s\n", error);
return 1;
}
printf("cos(2.0) = %f\n", (*cosine)(2.0));
dlclose(handle);
return 0;
}
4. 使用 GDB 跟踪 execve
系统调用
可以使用 GDB 跟踪和分析 execve
系统调用。以下是具体步骤:
-
启动 GDB 并加载程序:
gdb ./test
-
在
execve
系统调用处设置断点:break execve
-
运行程序,并观察
execve
调用的参数和返回信息:run
-
在
execve
执行时,GDB 会暂停程序运行,可以查看execve
的参数和内核的处理流程。可通过查看寄存器和内存,深入了解进程在内核加载过程中的转换。
5. 可执行程序的启动位置和 execve
返回后的执行
- 程序开始执行的位置:对于静态链接的可执行程序,程序入口地址由链接器在编译时确定,
execve
将控制权转移至该地址。 - 动态链接程序的加载差异:动态链接程序在
execve
返回时已经完成动态库的装载和符号解析,进入入口点之前动态链接器会完成所有所需的加载配置。
以下是对execve
系统调用加载可执行程序后执行流程的分析:
新的可执行程序执行位置
新的可执行程序的执行从其 入口地址 开始。对于 ELF 格式的可执行文件,入口地址在 ELF 头(ELF header)中指定。内核在加载可执行文件时会读取这个入口地址并将其设置为程序的起始指令地址。
- 对于静态链接程序,入口地址直接指向程序的 main 函数所在位置。
- 对于动态链接程序,入口地址指向动态链接器(如
ld-linux.so
)的入口位置,动态链接器会先完成对所需共享库的装载和符号解析,再跳转到程序的实际入口地址。
execve
系统调用返回后,新的可执行程序能顺利执行:
当 execve
被调用时,内核会将当前进程的用户态部分完全替换成新程序的代码和数据。具体过程如下:
- 内存空间清理和重新分配:
execve
会先清理当前进程的用户空间,将其原有代码段、数据段、堆栈等释放。 - 加载新程序:根据 ELF 文件头的信息,内核为新程序分配所需内存空间并将 ELF 各个段加载到内存中。
- 设置入口地址:内核将进程的程序计数器(
PC
或EIP
)指向 ELF 文件的入口地址,以确保新程序从指定位置开始执行。 - 初始化堆栈和环境:内核会将新程序的命令行参数和环境变量压入栈,并为进程设置合适的栈指针。
当这些步骤完成后,内核会将控制权交给新程序的入口地址,旧的程序完全被替换。这样,execve
就不会返回到旧程序,而是直接开始新程序的执行。
静态链接和动态链接程序在 execve
返回时的不同
静态链接和动态链接程序的不同主要体现在加载库和符号解析的方式上:
-
静态链接程序:静态链接程序在编译时将所有依赖的库函数直接嵌入可执行文件中。因此,
execve
系统调用加载静态链接程序时,不需要额外的符号解析或库加载,加载完成后即可直接跳转到程序的入口点开始执行。 -
动态链接程序:动态链接程序在加载时需要先加载动态链接器(如
/lib/ld-linux.so
)。execve
系统调用在返回时,先将控制权交给动态链接器,动态链接器会装载和解析所需的共享库符号表,完成符号绑定后,再将控制权转交给程序的实际入口点。
6. 总结
Linux 内核通过 execve
系统调用加载和启动可执行程序,过程包括解析 ELF 文件、加载程序的各个段、配置堆栈和环境变量,并将程序的入口点设置为 CPU 的指令指针。对于静态链接的程序,所有库在编译时就已经嵌入,可直接执行;而对于动态链接的程序,内核需要先加载动态链接器,解析并装载共享库,完成符号解析后才会跳转到程序的入口点。execve
完全替换当前进程映像,使新程序从入口点开始执行,而不返回到旧程序。动态链接和静态链接的程序在 execve
返回时有所不同,静态链接程序无需额外的库加载,直接进入执行,而动态链接程序需要经过动态链接器的配置。