因为图片比较大,微信上公众号的压缩比较厉害,所以很多细节都看不清楚。我上传了一个单独的副本到github。如果想要原图,可以点击下方阅读原文,或者直接使用以下链接访问github:https://github.com/wangyuntao/linux-kernel-illustrated另外,我会阅读精美的全景系列文章和后续的linux内核分析文章整理到这个github仓库,欢迎大家star收藏。相信很多同学都会对一个程序是如何运行的有疑问。为什么我们在shell中执行程序时会调用它的main函数呢?调用main函数之前和之后发生了什么?今天我们就来详细谈谈这个问题。还是和之前一样,我画了一张程序运行的全景图。在上图中,我已经标注了一个程序运行的代码段的git仓库、源文件和函数名。想看源码的可以参考上图中的信息。先说说这张图的整体吧。在Linux下,我们一般通过shell来执行程序。shell其实就是一个普通的程序,它也有自己的main函数。正常运行后会调用read_command函数等待用户输入命令。shell在接收到用户输入的命令后,首先会使用fork系统调用创建一个子进程,然后在这个子进程中,通过execve系统调用执行最终的用户程序。子进程在执行用户程序的过程中,shell主进程会调用waitpid函数阻塞等待子进程执行完毕。子进程完成后,waitpid从阻塞状态返回,status参数会携带子进程的退出码。这个exit代码会保存在后续的逻辑中,供用户查询。之后shell主进程进入下一个循环,继续等待用户输入命令并执行。以上是shell的主要逻辑,对应上图中蓝色部分。接下来我们看一下Linux内核中execve系统调用相关的代码,也就是上图中绿色部分。shell通过execve系统调用通知linux内核,目标程序应该在当前进程中执行。linux内核经过层层代码,最终到达load_elf_binary函数。该函数是整个系统调用中的核心逻辑,主要用于为目标程序准备各种执行环境。例如,将代码区、数据区等映射到当前进程的虚拟地址空间,定期将程序名、环境变量、程序参数等各种数据压入新分配的堆栈,等等。之后,load_elf_binary函数调用start_thread,start_thread又调用start_thread_common函数。在这个函数中,返回到用户区后,会在regs->ip中设置要执行的用户区程序的起始地址,上面新初始化的用户栈的栈顶地址也会被设置,设置为regs->sp。当execve系统调用返回到用户区时,regs->ip和regs->sp中的值会分别赋值给rip和rsp寄存器,这样指定的用户程序才能继续执行。这个过程我们在之前的文章精致全景|系统调用是如何实现的,这里就不赘述了。不过这里还有一点需要注意,就是regs->ip中设置的地址不是我们自己程序的起始地址,而是动态链接器的起始地址/lib64/ld-linux-x86-64.so.2起始地址。之所以需要设置动态链接器的起始地址,是因为我们需要让它在返回用户区后继续为我们的程序准备执行环境,比如帮助加载程序运行的各种动态链接库depends等动态链接器为我们的程序准备好执行环境后,会从进程栈的辅助向量区取出最终用户程序的真正起始地址,并跳转到该位置开始执行。辅助向量区存放的用户程序的起始地址是linux内核初始化上面的栈时设置的。这是动态链接器相关的代码,对应上图中紫色部分。跳转到我们自己程序的起始地址后,首先执行的并不是我们写的main函数,而是glibc中一段名为_start的汇编代码。这段汇编代码也比较简单。它主要是从栈中获取main函数需要的argc、argv等参数,然后最终调用我们写的main函数。main函数返回后,glibc中后续代码会把main函数的返回值作为进程的退出码,然后调用exit结束进程。这些代码对应于上面全景图中的粉红色部分。进程调用exit退出后,shell主进程也会从waitpid的阻塞状态返回,然后继续下一个循环。以上就是程序完整的启动和结束流程。下面我们来看一下具体的源码实现。注意,为了方便理解,我们删除了很多代码。首先是外壳部分。shell就是一个普通的程序,它也有自己的main函数:这个函数中调用了reader_loop:reader_loop的主要逻辑是在while循环中不断使用read_command函数读取用户输入的命令,然后使用execute_command执行命令。execute_command函数经过层层代码后,会使用下图中的fork创建一个子进程:然后在子进程中,使用execve系统调用通知Linux内核用当前子进程执行一个新的用户程序:在shell的main进程中,会调用waitpid函数阻塞等待子进程完成:当子进程退出时,waitpid将从阻塞状态返回,并在子进程中携带子进程的退出码status,然后shell主进程会返回到上面的read_command函数,继续等待用户输入下一条命令。以上就是bash的主要逻辑,对应上图中蓝色部分。我们继续看全景图中绿色部分,也就是Linux内核中与execve相关的代码。当shell的子进程执行execve函数时,就会触发linux内核中相应的系统调用:沿着函数调用链,我们会发现一个名为do_execveat_common的函数,在这个函数中,目标程序的文件名,Copy字符串如环境变量和各种程序参数到新创建的用户栈区:此时新创建的栈区中的内容由上图右下角的a1-a9,b1-b8组成如下图所示的内容二维网格区域。其中,黄色区域存放程序参数./a.outhelloworld,蓝色区域存放环境变量SHLVL=2、HOME=/、TERM=linux、PWD=/,橙色区域存放程序文件要执行的名称。/a.out。这些内容与我们执行的测试程序及其所在的环境如出一辙:沿着内核函数调用链继续往前走,最终会来到load_elf_binary函数,它是整个系统调用的核心。由于在linux上执行的程序基本都是elf格式,所以内核选择的加载函数是load_elf_binary。看这个函数的时候可以参考elf格式的man文档:https://man.archlinux.org/man/elf.5这个函数比较复杂,我做了很多删减,加了很多注释:函数最后会调用start_thread函数,然后调用start_thread_common函数:这个函数的关键点是对regs->ip和regs->sp的赋值,其作用在截图中已经注释了load_elf_binary函数,即回到用户区后,这两个字段的值会分别复制到rip和rsp寄存器中,所以这里的赋值相当于回到用户区后,赋值到rip和rsp寄存器。这在如何实现系统调用中进行了解释。至此,内核部分的代码就结束了。从load_elf_binary函数的截图可以看出regs->ip中设置的地址为elf_entry,这是动态链接器的起始地址,不是我们自己程序的起始地址。原因是我们还需要动态链接器继续帮我们准备执行环境,比如帮我们加载程序所依赖的动态链接库。所以execve系统调用返回用户区后,代码流进入动态链接器中的逻辑,也就是上图中紫色区域:上图中的_start是动态链接器的初始执行地址,可以通过以下方法确认:在_start函数中,先将rsp寄存器的值,即上面内核新初始化的栈顶地址赋值给rdi,然后使用call指令调用_dl_start功能。之所以给rdi寄存器赋值,是因为约定了c语言的调用约定,采用这种方式传递参数。再看_dl_start函数:这个函数调用了_dl_start_final,返回一个地址,就是我们自己程序的起始地址。再看_dl_start_final:这个函数调用了_dl_sysdep_start:这里动态链接器通过内核初始化的栈区中的辅助向量找到最终用户程序的起始执行地址。之后动态链接器的函数调用链依次退出,最后返回到上面的_start函数。在_start函数之后,会依次执行_dl_start_user,相关代码也在上面_start函数的截图中。其逻辑是先将rax中的值,即_dl_start函数返回的终端用户程序的起始地址赋值给r12寄存器,然后jmp到r12寄存器指向的地址开始执行最后的用户程序逻辑。至于rax中的值,为什么是_dl_start函数返回的地址呢?这其实是c调用约定中的一个约定。有兴趣的可以自己查一下。以上就是动态链接器的全部逻辑,对应全景图中的紫色部分。最后,逻辑来到全景图的粉红色部分。动态链接器从内核设置的辅助向量中得到的用户程序的起始地址并不是我们的main函数,而是glibc中一段名为_start的代码。这一点可以通过以下方式得到证实:_start代码段的内容如下:它从栈中获取argc和argv,然后调用__libc_start_main:在__libc_start_main中,我们写的main函数实际上被调用了。main函数返回后,__libc_start_main将main函数返回的值作为进程的退出码,然后调用exit退出当前进程。当进程退出时,主shell进程也从waitpid的阻塞状态返回,携带用户程序的退出码。在上面的全景图例子中,返回码为99:之后shell主进程进入下一个循环,继续等待用户命令并执行,即再次进入全景图蓝色部分。至此,程序在linux上执行的过程形成了一个完整的闭环。你失学了吗?本文转载自微信公众号“尾时半堆”,可通过以下二维码关注。转载本文请联系猫猫猫客公众号。
