车讯网 - 专业汽车新闻资讯门户
当前位置: 车讯网 -> 热点

你写的代码是如何跑起来的

时间:2022-12-26 09:43  |  责任编辑:宋元明清  |  来源: IT之家  |  关键词:  |  阅读量:13651  |  

大家好,我是飞哥!

今天,我们来思考一个简单的问题一个程序如何在Linux上运行

我们以宇宙中最简单的Hello World程序为例。

# includeltstdio.hgtintmainprintf,return0

写完代码后,我们简单编译一下,然后就可以在shell命令行启动了。

#gccmain.c—ohelloworld#。/helloworldHello,世界!

那么编译,启动,运行的过程中发生了什么呢今天我们就来仔细看看

首先,了解可执行文件的格式

源代码编译完成后,会生成一个可执行的程序文件我们先来了解一下编译后的二进制文件是什么样子的

首先,让我们使用file命令来检查这个文件的格式。

# filehelloworldhelloworld:elf 64—bitLSBexecutable,x86—64,版本1,...

file命令给出了这个二进制文件的摘要信息,其中ELF 64位LSB可执行文件表示这个文件是ELF格式的64位可执行文件X86—64表示此可执行文件支持的cpu体系结构

LSB的全称是Linux Standard Base,是一个Linux标准规范它的目的是制定一系列标准来增强Linux发行版的兼容性

ELF的全称是可执行可链接格式,是一种二进制文件格式Linux下的目标文件,可执行文件和CoreDump都是按照这种格式存储的

ELF文件由四部分组成,即ELF文件头,程序头表,段和段头表。

接下来,我们分几个板块逐一介绍。

1.1 ELF文件头

ELF文件头记录了整个文件的属性信息原来的二进制很不方便观察但是,我们有一个方便的工具——readelf,可以帮助我们查看elf文件中的各种信息

我们先来看看编译后的可执行文件的ELF文件头,可以使用—file—header选项查看。

# readelf—file—header helloworldelfheader:Magic:7f 454 c 46020101000000000000000 class:elf 64 data:2 ' s complement,little endian version:1OS/ABI:UNIX—SystemVABIVersion:0 type:EXECMachine:advancedmicrodevices x86—64 version:0x 1 entry point address:0x 401040 startofprogramheaders:64和CORE。

入口地址:程序的入口地址,显示入口在0x401040。

这个头的大小:ELF文件头的大小,这里显示为占用64个字节。

以上字段是ELF头中对ELF的整体描述此外,ELF头包含关于程序头和节头的描述信息

程序头的开始:表示程序头的位置。

程序头的大小:每个程序头的大小

节目头数:总共有多少个节目头。

节头的开始:表示节头的开始位置。

节标题的大小:每个节标题的大小

章节标题的数量:有多少章节标题。

1.2程序标题表

在介绍程序头表之前,先介绍一对类似的概念ELF文件中的Segment和Section。

ELF文件中最重要的单元是一个接一个的节每个部分由编译器链接器生成,有不同的用途比如编译器会把我们写的代码编译好放进去文本部分,并将全局变量放入数据或bss部分

但是对于操作系统来说,它并不关注具体的节是什么,它只关注这个内容应该加载到内存中的权限是什么,比如读,写,执行等权限属性因此,具有相同权限的Section可以放在一起形成一个段,便于操作系统更快地加载

由于Segment和Section翻译成中文,它们的意思过于接近,很难理解所以在这篇文章里,我直接用了原来的Segment和Section的概念,而不是翻译成段落或小节,太混乱了

节目头表作为所有节目段的头信息来描述所有节目段

使用readelf工具的— program—headers选项来分析和查看存储在该区域中的内容。

# readelf—program—headershelloworldelfpiletypeisexecentrypoint 0x 401040 there are 11 program headers,startingatoffset 64 program headers:typeoffsetvirtaddrphysadrfilesizemsizflagsalignphdr 0x 00000000000040000000004000000000000000040000000000000000000000000000000000000...0001 . interp 02 . interp . note . GNU . build—id . note . ABI—tag . GNU . hash . dyn sym . dynstr . GNU . version . GNU . version _ r . rela . dyn . rela . PLT . text . fini 04 . rodata . eh _ frame _ HDR . eh _ frame 05 . init _ array . fini _ array . dynamic . got . PLT . data . BSS 06 . dynamic 07 . note . GNU .

上面的结果显示总共有11个程序头。

对于每个段,输出Offset,VirtAddr和描述当前段的其他信息Offset表示当前段在二进制文件中的起始位置,FileSiz表示当前段的大小Flag表示当前段的权限类型,R表示全部,E表示可执行,W表示可写

在底部,它还显示了每个部分由哪些部分组成例如,第03节由四个部分组成初始化plt文字菲尼

1.3章节标题表

与程序头表不同,段头表直接描述每个段它们都描述了不同的部分,但是它们有不同的目的,一个用于加载,另一个用于链接

使用readelf工具的— section—headers选项来解析和查看存储在该区域中的内容。

# readelf—section—header shell worldthere 30 section headers,startingatoffset 0x5b 10:section headers:NameTypeAddressOffsetSizeEntSizeFlagsLinkInfoAlign....... textprogbits 0000000000401040000000104000000000000175000000000000000 ax 0016。......data progbits 00000000004040200000000302000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000......KeytoFlags:W,A,X,M,S,I,L,O,G,T,C,x,o,E,l,p

结果显示,文件中有30个部分,每个部分在二进制文件中的位置由Offset列指示部分的大小由大小列反映

在这30个部分中,每个部分都有独特作用我们写的代码会放到节里编译成二进制指令后的文本此外,我们看到文本部分显示地址为00000000401040回想一下,我们在ELF文件的头文件中看到,入口点地址显示的入口地址是0x401040这表明程序的入口地址是文本段

还有另外两个部分值得注意:数据和bss代码中的全局变量数据在编译后会占据这两段中的一些位置如下面的简单代码所示

//未初始化的内存区域位于bss段intdata1//初始化的内存区域位于数据段intdata2 = 100//代码位于intmain1.4在供进一步查看的文本段

接下来,我们想看看前面提到的程序条目0x401040,看看它是什么这一次,让我们在nm命令的帮助下,仔细看看可执行文件中的符号及其地址信息—n选项用于按地址而不是按名称对显示的符号进行排序

# nm—nhelloworldw _ _ gmon _ start _ _ U _ _ libc _ start _ mainGLIBC _ 2 . 2 . 5 uprintfglibc _ 2 . 2 . 50000000000401040t _ start 0000000000401126 main

从上面的输出可以看出,程序条目0x401040指向_start函数的地址在这个函数执行一些初始化操作之后,我们的入口函数main将被调用,它位于地址0x401126

二。用户进程创建过程概述

我们写好的代码编译生成可执行程序后,下一步就是用shell加载运行了一般来说,shell进程通过fork+execve加载并运行新的进程简单加载helloworld命令的shell的核心逻辑如下

//shell代码示例int main)PID = fork(),If(pid0)//如果在进程中//使用exec系列函数加载运行可执行文件execve ( "Hello World ",argv,envp),其他

shell进程首先通过fork系统调用创建一个进程然后在子进程中调用execve来加载执行的程序文件,然后就可以调用到程序文件的运行入口来运行这个程序了

fork系统调用在内核入口处的kernel/fork.c下。

//file:kernel/fork . csyscall _ define 0 return do _ fork(SIGCHLD,0,0,NULL,NULL),

在do_fork的实现中,核心是一个copy_process函数,它复制父进程生成一个新的task_struct。

//file:kernel/fork . clong do _ fork//复制一个task_struct到struct task _ struct * p,p=copy_process(clone_flags,stack_start,stack_size,child_tidptr,NULL,trace),//将子任务加入就绪队列,等待调度器调度wake _ up _ new _ task(p),

在copy_process函数中为新进程申请task_struct,用当前进程自己的地址空间,命名空间等初始化新进程,并为其申请工艺pid

//file:kernel/fork . cstatics tructtask_struct * copy _ process//复制进程task _ struct结构struct task _ struct * p,p=dup_task_struct(当前),//process核心元素初始化retval = copy _ files (clone _ flags,p),retval=copy_fs(clone_flags,p),retval=copy_mm(clone_flags,p),retval = copy _ namespaces(clone _ flags,p),//申请pidampamp设置进程号PID = alloc _ PID(p—n proxy—PID _ ns),p—PID = PID _ NR(PID),p—tgid = p—PID,

执行后,进入wake_up_new_task,让新进程等待调度器调度。

但是,fork系统调用只能根据当前shell进程复制一个新进程这个新进程中的代码和数据仍然与原始shell进程中的代码和数据完全相同

要加载并运行另一个程序,比如我们编译的helloworld程序,您需要使用execve系统调用。

三。Linux可执行加载程序

其实Linux只能加载ELF这种可执行文件格式,不能写死当它启动时,它将加载它支持的所有可执行文件的解析器并使用格式双向链表来保存所有解析器内存中格式双向链表的结构如下图所示

我们以ELF的loader elf_format为例,看看这个loader是怎么注册的在Linux中,每个加载器由一个linux_binfmt结构表示它指定了用于加载二进制可执行文件的load_binary函数指针,以及用于加载崩溃文件的core_dump函数

//file:include/Linux/bin fmts . hstructlinux _ binfmtint(struct Linux _ bin PRM *),int(* load _ shlib)(struct file *),int(* core _ dump)(structcoredump _ params * cprm),,

ELF的loader ELF _ format指定了具体的加载函数,例如load_binary成员指向具体的load_elf_binary函数这是ELF装载的入口

//file:fs/bin fmt _ elf . cstaticstructlinux _ binfmtelf _ format =模块= THIS _模块,load_binary=load_elf_binary,load_shlib=load_elf_library,核心转储=elf核心转储,

register_binfmt将在初始化期间注册加载程序elf_format。

//file:fs/bin fmt _ elf . cstaticint _ _ init init _ elf _ binfmtregister _ bin fmt(amp,elf _ format),return0

而register_binfmt就是把加载器挂在全局加载器list—formats全局链表中。

//file:fs/exec . cstaticlist _ HEAD,void _ _ register _ bin fmt(struct Linux _ bin fmt * fmt,intinsert)插入。list _ add(amp,fmt—左侧,amp格式):list _ add _ tail(amp,fmt—左侧,amp格式),

Linux支持除elf文件格式之外的其他格式在源码目录中搜索register_binfmt,可以找到Linux操作系统支持的所有加载器格式

# grep—r " register _ bin fmt " * fs/bin fmt _ flat . c:register _ bin fmt,fs/bin fmt _ elf _ FD pic . c:register _ bin fmt(amp,elf _ FD pic _ format),fs/bin fmt _ som . c:register _ bin fmt(amp,som _ format),fs/bin fmt _ elf . c:register _ bin fmt(amp,elf _ format),fs/bin fmt _ aout . c:register _ bin fmt(amp,aout _ format),fs/bin fmt _ script . c:register _ bin fmt(amp,script _ format),fs/bin fmt _ em86 . c:register _ bin fmt(amp,em86 _ format),

以后Linux在加载二进制文件时会遍历格式链表,根据要加载的文件格式查询合适的加载器。

四。execve加载用户程序

加载可执行文件的具体工作由execve系统调用完成。

系统调用将读取用户输入的可执行文件名称,参数列表和环境变量,并开始加载和运行用户指定的可执行文件系统调用的位置在fs/exec.c文件中

//file:fs/exec . csys call _ define 3 struct filename * path = getname(filename),do_execve(path—name,argv,envp)int do _ exec ve()returndo _ exec ve _ common(filename,argv,envp),

execve系统调用了do_execve_common函数我们来看看这个函数的实现

//file:fs/exec . cstaticindo _ exec ve _ common//Linux _ bin PRM结构用于保存加载二进制文件时使用的参数structlinux _ binprm * bprm//1申请并初始化brm对象值bprm = kzaloc (sizeof (* bprm),GFP _ kernel),bprm—file =,bprm—filename =,bprm _ mm _ init(bprm)bprm—argc = count(argv,MAX _ ARG _ STRINGS),bprm—envc=count(envp,MAX _ ARG _ STRINGS),prepare _ bin PRM(bprm),//2遍历查找合适的二进制加载器search _ binary _ handler(bprm),

在该功能中申请和初始化brm对象的具体工作可以如下图所示。

在这个函数中,已经完成了三项工作。

1.使用kzalloc申请linux_binprm内核对象这个内核对象用于保存加载二进制文件时使用的参数应用后,参数对象被初始化

其次,在bprm_mm_init中将申请一个全新的mm_struct对象,它将被保留给新的进程。

第三,为新进程的堆栈申请一页虚拟内存空间,记录堆栈指针。

第四,读取二进制文件的前128个字节。

让我们看一下与初始化堆栈相关的代码。

//file:fs/exec . cstaticint _ _ bprm _ mm _ init bprm—VMA = VMA = kmem _ cache _ zal loc(VM _ area _ cachep,GFP _ KERNEL),VMA—VM _ end = STACK _ TOP _ MAX,VMA—VM _ start = VMA—VM _ end—PAGE _ SIZE,bprm—p = VMA—VM _ end—sizeof(void *),

在上面的函数中,申请了一个vma对象,vm_end指向STACK_TOP_MAX(靠近地址空间顶部的一个位置),在vm_start和vm_end之间留出一个页面大小也就是说,默认情况下,堆栈的大小为4KB最后,堆栈的指针被记录到bprm—gt,p中等

再看一下prepare_binprm在这个函数中,从文件头中读取128个字节这样做的原因是为了读取二进制文件的文件头,以便于后期判断其文件类型

//file:include/uapi/Linux/bin fmts . h # defineBINPRM _ BUF _ SIZE 128//file:fs/exec . cint prepare _ binprmmemset(bprm—BUF,0,bin PRM _ BUF _ SIZE),returnkernel_read(bprm—file,0,bprm—buf,bin PRM _ BUF _ SIZE),

在申请并初始化brm对象值后,最后使用search_binary_handler函数遍历系统中注册的加载器,尝试解析并加载当前的可执行文件。

在3.1节中,我们介绍了系统的所有加载程序都注册在格式全局链表中search_binary_handler函数的工作过程是遍历全局链表,根据二进制文件头中携带的文件类型数据找到解析器找到调用解析器加载二进制文件的函数

//file:fs/exec . cint search _ binary _ handler fortry = 0,try2try++list_for_each_entry(fmt,ampformats,LH)int(* fn)(struct Linux _ bin PRM *)= fmt—load _ binary,retval = fn(bprm),//如果加载成功,则返回If(retval = 0)return retval,//加载失败继续循环以尝试加载

上面代码中的list_for_each_entry是遍历格式的全局链表,遍历时判断每个链表元素是否有load_binary函数如果有,调用它并尝试加载它

回想一下3.1可执行文件加载程序的注册对于ELF文件加载器elf_format,load_binary函数的指针指向load_elf_binary

//file:fs/bin fmt _ elf . cstaticstructlinux _ binfmtelf _ format =模块= THIS _模块,

然后加载工作会进入load_elf_binary函数这个函数很长可以说,所有的程序加载逻辑都体现在这个函数中根据这个功能的主要工作,我分以下五个小部分给大家介绍

在介绍的过程中,为了表达清楚,我会稍微调整一下源代码的位置,可能和内核源代码的行顺序不一样。

4.1 ELF文件头读取

在load_ELF_binary中,将首先读取ELF文件的头。

文件头包含了当前文件格式类型等一些数据,所以在读取文件头后会做出一些合法性判断如果不合法,退出并返回

//file:fs/bin fmt _ ELF . cstaticintload _ ELF _ binary//4.1 ELF文件头解析//定义结构标题并申请内存保存ELF文件头structstructelfhdrelf _ exstructelfhdrinterp _ elf _ ex* locloc=kmalloc(sizeof(*loc),GFP _ KERNEL),//获取二进制头loc—gt,elf _ ex = *((structelfhdr *)bprm—gt,buf),//头上做一系列合法性判断,退出if(loc—gt,elf_ex.e_type!= ET _ EXECampamp...)gotoout...4.2程序标题读取

程序头的数目记录在ELF文件的头中,紧接在ELF文件头之后的是程序头表这样内核就可以读出所有的程序头

//file:fs/bin fmt _ elf . cstaticintload _ elf _ binary//4.1 elf文件头解析//4.2ProgramHeader读取//elf_ex.e_phnum保存程序头个数//然后根据program header size size of(struct elf _ phdr)//计算所有程序头大小,读入size = loc—elf _ exe _ phnum * sizeof(struct elf _ phdr),elf_phdata=kmalloc(size,GFP _ KERNEL),kernel_read(bprm—file,loc—elf_ex.e_phoff,(char*)elf_phdata,size),4.3清空父进程继承的资源

fork系统调用创建的进程包含了原进程的很多信息,比如旧的地址空间,信号表等等这些新程序在运行时毫无用处,所以需要清理

工作包括初始化新进程的信号表,应用新的地址空间对象等。

//file:fs/bin fmt _ Elf . cstaticintload _ Elf _ binary//4.1 Elf文件头解析//4.2ProgramHeader读取//4.3清除父进程继承的资源retval = flush _ old _ exec(bprm),current—mm—start _ stack = bprm—p,

清空父进程继承的资源后,直接将之前准备的进程栈的地址空间指针设置为mm对象以便将来可以使用该堆栈

4.4执行分段加载

接下来,加载程序会将ELF文件中所有加载类型的段加载到内存中使用elf_map在虚拟地址空间中分配虚拟内存最后,适当地设置虚拟地址空间mm_struct中的地址空间相关指针,如start_code,end_code,start_data和end_data

我们来看看具体的代码:

//file:fs/bin fmt _ Elf . cstaticintload _ Elf _ binary//4.1 Elf文件头解析//4.2ProgramHeader读取//4.3清除父进程继承的资源//4.4执行段加载进程//遍历可执行文件的program header for(I = 0,elf _ ppnt = ppnt iltloc—gt,elf _ ex.e _ phnum++,elf _ ppnt++)//只加载LOAD类型的段,否则跳过if(elf_ppnt—p_type!=PT_LOAD)继续,//为段建立内存mmap,将程序文件的内容映射到虚拟内存空间//这样以后就可以访问程序中的代码和数据了

其中load_bias是要加载到内存中的段的基址。这个参数有几种可能性

值为0表示直接根据ELF文件中的地址在内存中映射。

为了将该值与整数页的开头对齐,物理文件对于可执行文件的大小来说可能足够紧凑,而不考虑对齐问题可是,为了高效地运行,操作系统需要在整数页的开始处加载段

4.5数据存储应用放大器,堆初始化

因为进程的数据段需要写权限,所以需要使用set_brk系统调用为数据段申请虚拟内存。

//file:fs/bin fmt _ Elf . cstaticintload _ Elf _ binary//4.1 Elf文件头解析//4.2ProgramHeader读取//4.3清除父进程继承的资源//4.4执行段加载进程//4.5申请数据内存amp初始化retval=set_brk(elf_bss,elf _ brk),

set_brk函数中做了两件事:第一件是为数据段申请虚拟内存,第二件是初始化进程堆的开始指针和结束指针。

//file:fs/bin fmt _ ELF . cstaticintset _ brk//1为数据段申请虚拟内存start = ELF _ page align(start),end = ELF _ page align(end),if(end start)unsignedlongaddr,addr=vm_brk(start,end—start),//2初始化堆的指针current—mm—start _ brk = current—mm—brk = end,return0

因为程序初始化时堆还是空的因此,当堆指针被初始化时,堆的start_brk address _ brk和end地址brk都被设置为相同的值

4.6跳转到程序入口执行。

程序的入口地址记录在ELF文件的头中在非动态链接加载的情况下,入口地址是这样的

但是如果是动态链接,也就是说有INTERP类型的段,这个动态链接器会先加载运行,然后回调到程序的代码入口地址。

# readelf—program—headershelloworldProgramHeaders:typeoffsetvirtaddrphysadrfilesizmemsizflagsaligninterp0x 0000000000002 a 80000000000004002 a 80000000000000001 c 0x 0000000000000001 c 0x 0 x 1

对于动态加载器类型,您需要首先将动态加载器加载到地址空间中。

加载完成后,计算动态加载程序的入口地址下面我展示这段代码,不耐烦的同学可以跳过反正只要知道这里是一个程序的入口地址就行了

//file:fs/bin fmt _ Elf . cstaticintload _ Elf _ binary//4.1 Elf文件头解析//4.2ProgramHeader读取//4.3清除父进程继承的资源//4.4执行段加载//4.5申请数据内存amp初始化//4.6跳转到程序入口执行//第一次遍历programheadertable//只对PT_INTERP类型的段进行预处理//该段保存动态加载器在文件系统中的路径信息for(I = 0,iltloc—gt,elf _ ex.e _ phnum++) ...//第二次遍历programheadertable并做一些特殊处理elf _ ppnt = elf _ phdatafor(I = 0,iltloc—gt,elf _ ex.e _ phnum++,elf _ ppnt++)...//如果程序中指定了动态链接器,则读出动态链接器程序if(elf_interpreter)// Load并返回动态链接器代码段elf_entry=load_elf_interp的地址(amploc—gt,interp_elf_ex,解释器,放大器,interp_map_addr,load _ bias),//计算动态链接器入口地址elf _ entry+= loc—gt,interp _ elf _ ex.e _ entryelse elf _ entry = loc—gt,elf _ ex.e _ entry//跳转到门户启动start _ thread (regs,elf _ entry,bprm—gt,p),...五.总结

看似简单的一行helloworld代码,但要想看清楚它的运行过程,需要很大的内功。

本文首先带领大家认识和理解二进制可运行ELF文件格式ELF文件由四部分组成,即ELF文件头,程序头表,段和段头表

当Linux初始化时,所有受支持的加载程序都将在一个全局链表中注册对于ELF文件,其加载器在内核中定义为elf_format,其二进制加载入口为load_elf_binary函数

一般来说,shell进程通过fork+execve加载并运行新的进程执行fork系统调用的作用是创建一个新的进程但是,fork创建的新进程的代码和数据与原来的shell进程完全相同要加载并运行另一个程序,需要使用execve系统调用

在execve系统调用中,将首先应用一个linux_binprm对象在初始化linux_binprm的过程中,会申请一个全新的mm_struct对象,并为新进程预留还将为新进程的堆栈准备一页虚拟内存还会读取可执行文件的前128个字节

下一步是调用ELF加载器的load _ ELF _ binary函数进行实际加载。将大致执行以下步骤:

ELF文件头解析

程序头读取

清空父进程继承的资源,使用新的mm_struct和新的堆栈。

执行段加载,将ELF文件中加载类型的所有段加载到虚拟内存中。

为数据段申请内存,初始化堆的开始指针。

最后计算,跳转到程序入口执行。

当用户进程启动时,我们可以通过proc伪文件查看进程中的每个段。

# cat/proc/46276/maps 00400000—00401000 r—p 000000000 FD:01396999/root/work _ temp/hello world 00401000—00402000 r—XP 00001000 FD:01396999/root/work _ temp/hello world 00402000—004030000......7 f 01231 c 0000—7 f 01231 c 1000 r—p 0002 a 000 FD:011182554/usr/lib 64/LD—2.32 . so 7 f 01231 c 1000—7 f 01231 c 3000 rw—p 0002 b 000 FD:011182554/usr/lib 64/LD—2.32 . so 7 ffdf 059000......

虽然这篇文章很长,但它仍然只是列出了一般的加载和启动过程如果你在以后的工作和学习中遇到了想找出来的问题,可以按照本文的思路,在源代码中找到具体的问题,然后帮你找到工作中问题的解决方案

最后,细心的读者可能会发现,在这个例子中,加载一个新程序来运行的过程实际上存在一些浪费fork系统调用首先复制父进程的大量信息,当execve加载可执行程序时,重新赋值因此,在实际的shell程序中,一般使用vfork它的工作原理和fork的基本相同,不同的是它会复制较少execve系统调用中不使用的信息,从而提高加载性能