张雨梅 原创作品转载请注明出处
《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-10000
1.c文件的编译
图中显示了c文件生成可执行文件的过程,以一个c程序命名为hello.c为例,分析linux下生成可执行文件的具体过程。c文件先作预处理,这一步在图中省略了。
gcc -E -o hello.cpp hello.c
//输出hello.cpp,预处理的中间文件,主要是把.h文件,宏定义替换
gcc -x cpp-output -S -o hello.s hello.cpp
//输出hello.s,编译成汇编文件,其中是汇编代码
gcc -x assembler -c hello.s -o hello.o
//输出hello.o,汇编成目标代码,vi查看是乱码,其中有些机器指令,是一个二进制文件
gcc -o hello hello.o
//输出hello,链接成可执行文件,使用共享库libc里的函数,hello.o和hello都是ELF文件
gcc -o hello.static hello.o -m32 -static
//如果想使用静态链接,在上一条语句后添加-static即可
常见的目标文件格式,最古老的是A.out,后来发展成coff,现在使用最多的是PE、ELF,分别用在windows、linux系统。ELF,Executable and Linkable Format,可执行、可链接格式。也是abi,在应用程序里是二进制兼容的,适应了某一种cpu体系结构上的二进制指令。
abi和目标文件格式的关系?
ELF三种主要的目标文件:
1.可重定位:保存代码和适当数据,用来和其他的object文件一起创建可执行/共享文件,主要是.o文件
2.可执行文件:指出了exec如何创建程序进程映像,怎么加载,从哪里开始执行
3.共享object文件:保存代码和适当数据,用来被下面的两个连接器链接。
(1)连接editor,连接可重定位、共享object文件。即装载时链接。
(2)动态链接器,联合可执行、其他共享object文件创建进程映像。即运行时链接。
ELF的前两种文件分别是.o文件与可执行文件,有什么区别呢?
可重定位文件(主要是.o文件)和可执行文件都是目标文件,一般使用相同的文件格式。可重定位程序虽然打包了机器语言指令,但它还需要和其它的库(比如printf())做链接,最终生成可执行目标文件,这样才可能被加载到内存中由系统执行。
目标文件参与程序的联接(创建一个程序)和程序的执行(运行一个程序)。比如.o文件链接成可执行文件,之后可以运行一个程序。目标文件的具体内容从连接视图和执行视图,如下:
文件的开始是ELF头,保存了路线图,描述该文件的组织情况,用readlf-h main,这里main是一个可执行文件,可以查看main函数的ELF header的内容。
指明了文件的信息,如版本,类型、标志位等,和程序入口地址,program header,section header的大小、数量等。
Program header table用来说明怎样创建进程的内存映像。
进程的内存映像是指内核在内存中如何存放可执行程序文件,在将程序转化为进程的过程中,操作系统将可执行程序从硬盘复制至内存中,其布局如下:
其中栈用于保存函数返回地址、函数参数、函数内部定义的局部变量。释放时由编译器自动释放。地址向下增长。
堆保存程序中动态分配的变量,比如malloc,new函数等。一般由程序员分配,若未释放,可能由OS回收。地址向上增长。
bss保存已声明未初始化的局部变量。
data保存已初始化数据。
text保存程序代码。
可执行程序与内存映像的不同在于:
(1)可执行程序位于磁盘中,内存映像位于内存
(2)可执行程序没有堆栈,因为程序被加载到内在中才会分配堆栈
(3)可执行程序虽然也有未初始化数据段但它并不被存储在位于硬盘中的可执行文件中
(4)可执行程序是静态的、不变的,而内存映像随着程序的执行是在动态变化的,比如参数值变化,函数调用会使内存映像变化
内存映像中的程序就是进程,可以动态运行的程序。程序转化为进程的步骤:
(1)内核将程序读入内存,拷贝一个文件的段到一个虚拟的内存段,存在一种映射关系,为其分配映像空间
程序加载时默认映射地址总是0x8048000,前面存放的是ELF header,entry point address存放的是程序入口地址,ELF header大小不同,其地址不同,一般是0x8048_00,是刚加载过可执行文件的进程的起始地址。
(2)内核为进程分配一个pid
(3)内核为进程保存pid及相应的状态信息
Section header table包含了每个section的入口,包含名字,大小等。
2.可执行程序的装载
一般通过shell执行可执行程序,shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,也就是main函数愿意接受的参数情况。
例如,int main(int argc, char *argv[])//一般用户输入时传递参数
又如, int main(int argc, char *argv[], char *envp[])//环境变量envp,shell会自动加上
shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数,execve的函数原型
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
execve是一个特殊的系统调用
执行execve时,陷入内核态,在内核里,用执行execve加载的可执行文件覆盖了当前的可执行程序,当exevce系统调用返回时,不是原来的可执行程序,是新的可执行程序,返回到新的可执行程序的起点。
fork创建一个子进程,完全复制的是父进程,调用execve时,要加载的可执行程序把原来的环境覆盖了,他的用户态堆栈被清空了,因为有一个新的程序要执行。
创建一个新的用户态堆栈时,实际上是把filename, argv[ ], envp[ ]的内容通过指针方式传递到系统调用的内核处理函数,内核处理函数在创建可执行程序新的堆栈时会把参数拷贝到用户态堆栈来处理可执行程序,即初始化新的可执行程序的上下文环境。所以新的函数能从main函数开始把对应的函数接收过来开始执行。
静态链接传递一些参数就可以运行,有的程序还需要动态链接库。
动态链接分为可执行程序装载时动态链接和运行时动态链接,如下代码演示了这两种动态链接。
shlibexample.c表示的是装载动态链接程序,dllibexample.c是运行时动态链接程序。
这是dllibexample.c程序的主要实现,shlibexample.c内部实现基本一样。
main的头文件
只包含了shlibexample.h。
首先准备.so文件。.so文件就是常说的动态链接库, 都是C或C++编译出来的。linux下的动态链接文件,windows下是.dll。Linux下的.so文件时不能直接运行的,一般来讲,.so文件称为共享库。
编译成libshlibexample.so文件//.h,.c生成共享库的.so文件
$ gcc -shared shlibexample.c -o libshlibexample.so -m32//共享库文件
编译成libdllibexample.so文件
$ gcc -shared dllibexample.c -o libdllibexample.so -m32//可动态加载的
分别以共享库和动态加载共享库的方式使用libshlibexample.so文件和libdllibexample.so文件。
调用共享库可直接调用
SharedLibApi();//是shlibexample.c中定义的函数
这样就完成了装载时的动态链接。
动态加载时
void * handle = dlopen("libdllibexample.so",RTLD_NOW);//打开.so文件
int (*func)(void);//声明函数指针
func = dlsym(handle,"DynamicalLoadingLibApi");//找到函数指针
func(); //执行func,调用的是DynamicalLoadingLibApi
这样就可以使用共享库libdllexample里定义的函数。
编译main,注意这里只提供shlibexample的-L(库对应的接口头文件(即.h)所在目录)和-l(库名,如libshlibexample.so去掉lib和.so的部分),并没有提供dllibexample的相关信息,只是指明了-ldl
- $ gcc main.c -o main -L/path/to/your/dir -lshlibexample -ldl -m32//-l指明库,系统会自动找,首先到默认路径user/lib,或者把当前目录加入默认路径 -ldl,把ldl(动态加载库)加进来
- $ export LD_LIBRARY_PATH=$PWD #将当前目录加入默认路径,否则main找不到依赖的库文件,当然也可以将库文件copy到默认路径下
- //这就是动态链接库的寻找方式
- $ ./main
运行结果如下:
可以看到装载时动态链接和运行时动态链接两种方式都执行了。
3.execve系统调用
调用execve时,其参数在shell程序当前的堆栈中,而这个堆栈在加载完新的可执行程序后已经被清空了,所以内核创建了一个新的用户态堆栈。把filename, argv[ ], envp[ ]的内容通过指针方式传递到系统调用的内核处理函数,初始化新的可执行程序的上下文环境。下面分析execve函数的具体执行过程。
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);//获得文件名,argv,envp
1610}
execve函数的处理入口,调用do_execve。
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_execve_common(filename, argv, envp);
}
调用do_execve_common,参数和环境变量作了转换。
1430static int do_execve_common(struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp)
{
struct linux_binprm *bprm;
struct file *file;
struct files_struct *displaced;
int retval;
......
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);//创建结构体
if (!bprm)
goto out_files; retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free; check_unsafe_exec(bprm);
current->in_execve = ; file = do_open_exec(filename);//打开要加载的可执行文件,还会加载文件头部
retval = PTR_ERR(file);
......
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);//把参数拷贝进来
if (retval < )
goto out; retval = copy_strings(bprm->argc, argv, bprm);//把参数拷贝进来
if (retval < )
goto out; retval = exec_binprm(bprm);
if (retval < )
goto out;
调用exec_binprm
1405static int exec_binprm(struct linux_binprm *bprm)
{
pid_t old_pid, old_vpid;
int ret; /* Need to fetch pid before load_binary changes it */
old_pid = current->pid;
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock(); ret = search_binary_handler(bprm);//寻找可执行文件的处理函数......
调用search_binary
1352int search_binary_handler(struct linux_binprm *bprm)
{
bool need_retry = IS_ENABLED(CONFIG_MODULES);
struct linux_binfmt *fmt;
int retval;
......
retval = -ENOENT;
retry:
read_lock(&binfmt_lock);
//被观察者
list_for_each_entry(fmt, &formats, lh) {//寻找能够解析当前可执行文件的代码模块
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);
bprm->recursion_depth++;
retval = fmt->load_binary(bprm);//加载可执行文件的处理函数,是一个函数指针例如elf文件,这一句代码实际执行的是调用load_elf_binary
read_lock(&binfmt_lock);
put_binfmt(fmt);
bprm->recursion_depth--;
......
加载可执行文件对应的处理函数,然后解析,即可执行对应的可执行程序。以elf文件为例,分析其过程
(1)search_binary_handler符合寻找文件格式对应的解析模块,是被观察者。
(2)对于ELF格式的可执行文件fmt->load_binary(bprm);执行的应该是load_elf_binary,观察者
static struct linux_binfmt elf_format = {//elf的格式
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
static int __init init_elf_binfmt(void)
{
register_binfmt(&elf_format);//变量注册进入内核链表
return ;
}
search_binary_handler相当于被观察者,load_elf_binary相当于观察者,当出现elf文件时,elf_format会自动执行。
fmt->load_binary(bprm)是一种多态的机制,不同的文件格式,被观察部分代码相同,观察部分不同。
4.使用gdb跟踪分析一个execve
更新menu文件,用test.c替换test_exec.c,新的menuos系统中添加了exec命令。
用gdb调试,设置三个断点sys_execve,load_elf_binary,start_thread
运行截图
通过po new_ip和新窗口的 readelf -h helloc查看入口地址,两个地址是一样的。
总结
在原可执行程序中通过int 0x80h进入系统调用,执行execve函数,这时函数要去执行另一个程序,程序执行完ret返回时,已经变成另一个程序。
execve系统调用执行的目的就是执行指定的程序,返回时不需要回到原可执行成程序中。