概述
我们常见的一个应用场景是,在shell中输入命令,然后等待命令返回。如果以进程创建和终止的角度来看,shell首先会读取命令,解析命令,创建自建成并执行命令,然后父进程在等待子进程终止,其如下图示
对于用户空间的一个进程,首先我们需要编写对应的.c/.h文件,然后经过编译器编译成二进制的可执行文件,装载到硬盘上开始执行,最终生成用户进程,这里面涉及到很多细节,本章主要针对这些内容进行深入学习,主要包括以下内容
- 对于程序员如何从文本文件到可执行的程序
- 操作系统如何完成对于可执行文件加载
1 程序的编译
当我们把程序写完了,是否就万事大吉了,可是CPU是不能执行文本文件里的指令,CPU需要能够执行机制指令,比如"0101"这种,所以这些需要能够翻译成机器识别的二进制文件,这个过程就叫编译。
在Linux下面,二进制的程序需要有严格的格式,这种格式被成为ELF(Executeable and Linkable Format,可执行与可链接格式),这种格式根据编译的结果不同,分为不同的格式。我们对于编译的整个过程如下图所示
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o 文件,这就是 ELF 的第一种类型,可重定位文件(Relocatable File)。
这个文件的格式是这样的:
ELF 文件的头是用于描述整个文件的。这个文件格式在内核中有定义,分别为 struct elf32_hdr 和 struct elf64_hdr。
- .text:放编译好的二进制可执行代码
- .data:已经初始化好的全局变量
- .rodata:只读数据,例如字符串常量、const 的变量
- .bss:未初始化全局变量,运行时会置 0
- .symtab:符号表,记录的则是函数和变量
- .strtab:字符串表、字符串常量和变量名
2 ELF文件格式
Linux下标准的可执行文件格式是ELF.ELF(Executable and Linking Format)是一种对象文件的格式,用于定义不同类型的对象文件(Object files)中都放了什么东西、以及都以什么样的格式去放这些东西。它自最早在 System V 系统上出现后,被 UNIX 世界所广泛接受,作为缺省的二进制文件格式来使用。
但是linux也支持其他不同的可执行程序格式, 各个可执行程序的执行方式不尽相同, 因此linux内核每种被注册的可执行程序格式都用linux_bin_fmt来存储, 其中记录了可执行程序的加载和执行函数。同时我们需要一种方法来保存可执行程序的信息, 比如可执行文件的路径, 运行的参数和环境变量等信息,即linux_bin_prm结构
struct linux_binprm {
char buf[BINPRM_BUF_SIZE]; // 保存可执行文件的头128字节
#ifdef CONFIG_MMU
struct vm_area_struct *vma; //内存相关vm_area_structc初始化
unsigned long vma_pages;
#else
# define MAX_ARG_PAGES 32
struct page *page[MAX_ARG_PAGES];
#endif
struct mm_struct *mm; //内存相关mm_struct初始化
unsigned long p; /* current top of mem */
unsigned int
cred_prepared:1,/* true if creds already prepared (multiple
* preps happen for interpreters) */
cap_effective:1;/* true if has elevated effective capabilities,
* false if not; except for init which inherits
* its parent's caps anyway */
#ifdef __alpha__
unsigned int taso:1;
#endif
unsigned int recursion_depth; /* only for search_binary_handler() */
struct file * file; // 要执行的文件
struct cred *cred; /* new credentials */
int unsafe; /* how unsafe this exec is (mask of LSM_UNSAFE_*) */
unsigned int per_clear; /* bits to clear in current->personality */
int argc, envc;
const char * filename; /* Name of binary as seen by procps,要执行的文件的名称*/
const char * interp; /* Name of the binary really executed. Most
of the time same as filename, but could be
different for binfmt_{misc,script} *///要执行的文件的真实名称,通常和filename相同
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
}
linux内核对所支持的每种可执行的程序类型都有个struct linux_binfmt的数据结构,定义如下
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
};
成员 | 描述 |
---|---|
load_binary | 通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境,普通程序加载 |
load_shlib | 用于动态的把一个共享库捆绑到一个已经在运行的进程, 这是由uselib()系统调用激活的,主要用于动态加载,即动态库 |
core_dump | 主要用于程序错误的情况下输出共享转储,该转存储随后可以通过调试器(gdb)分析,以便解决问题 |
所有的linux_binfmt对象都处于一个链表中, 第一个元素的地址存放在formats变量中, 可以通过调用register_binfmt()和unregister_binfmt()函数在链表中插入和删除元素, 在系统启动期间, 为每个编译进内核的可执行格式都执行registre_fmt()函数. 当实现了一个新的可执行格式的模块正被装载时, 也执行这个函数, 当模块被卸载时, 执行unregister_binfmt()函数.
3 程序启动
我们启动程序一般都是在命令行中,其实是在与shell
打交道,然后shell
帮我们启动程序,并传递相关参数。strace工具能够追踪一个程序执行的系统调用,因而我们构造一个简单的空程序,并在命令行执行: strace ./a.out -a -b
可见,shell
启动程序时执行的第一个系统调用为execve
,在glibc库函数中的exec
函数族: execl, execlp, execle, execv, execvp, execvpe
最终即是调用得该系统调用。
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}
第一个参数是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量[1]。当进入sys_execve()
系统调用时,在中断处理程序中调用了do_execve()
[2]:(路径:fs/exec.c)
int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
通过上述代码,我们可以看到,在do_execve中,最终调用了do_execveat_common,其除了使用do_execve中的参数之外,还有额外的两个参数。
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
char *pathbuf = NULL;
struct linux_binprm *bprm;
struct file *file;
struct files_struct *displaced;
int retval;
//1. 检查文件名指针释放为空,如果为空,就直接返回
if (IS_ERR(filename))
return PTR_ERR(filename);
//2. 检查当前进程的标志,表明未超出正在运行的进程的限制
if ((current->flags & PF_NPROC_EXCEEDED) &&
atomic_read(¤t_user()->processes) > rlimit(RLIMIT_NPROC)) {
retval = -EAGAIN;
goto out_ret;
}
//3.如果两项检查成功,我们将当前进程的标志取消设置PF_NPROC_EXCEEDED,以防止程序执行失败
current->flags &= ~PF_NPROC_EXCEEDED;
//4. 取消共享当前任务的文件,并检查此函数结果
retval = unshare_files(&displaced);
if (retval)
goto out_ret;
retval = -ENOMEM;
//5, 二进制参数准备,内核申请struct linux_binprm结构
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_files;
//6. 准备工作,初始化化linux_binprm的cred结构变量,该结构变量中包含任务的实际uid,任务的实际guid,虚拟文件系统操作的uid和gudid
retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free;
//7. 将当前进程设置为in_execve状态
check_unsafe_exec(bprm);
current->in_execve = 1;
//8. 核心函数,打开可执行文件
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_unmark;
//9. 用于确定可以执行新程序的最小负载处理器,并将当前进程迁移到该处理器
sched_exec();
//10. 检查给出的二进制文件的文件描述符
bprm->file = file;
//我们尝试检查二进制文件的名称是否从/符号开始,或者给定的可执行二进制文件的路径是否相对于调用进程 //的当前工作目录进行了解释,或者文件描述符为AT_FDCWD。 如果这些检查之一成功,我们将设置二进制参数 //文件名
if (fd == AT_FDCWD || filename->name[0] == '/') {
bprm->filename = filename->name;
} else {
//否则,如果文件名称为空,则将文件名设置为/dev/fd/%d (即/dev/fd/文件描述符),否则将文件名重新设 //置为/dev/fd/%d/文件名(其中,fd指向可执行文件的文件描述符)
if (filename->name[0] == '