Linux学习笔记 - 程序的执行(完结)

这篇博客深入探讨了Linux系统中程序执行的相关概念,包括执行跟踪技术,如ptrace系统调用在调试中的应用;可执行文件的格式,特别是ELF标准;以及执行域,解释了Linux如何支持其他操作系统的程序执行。内容详细介绍了各种执行跟踪事件、可执行格式的加载过程以及不同类型的exec函数。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

执行跟踪

    执行跟踪是一个程序监视另一个程序执行的一种技术。被跟踪的程序一步一步地执行,直到接收到一个信号或调用一个系统调用。执行跟踪由调试程序(debugger)广泛使用,当然还使用其他技术(包括在被调试程序中插入断点及运行时访问它的变量)。与往常一样,我们将集中讨论内核怎样支持执行跟踪,而不讨论调试程序怎样工作。

    在Linux中,通过ptrace()系统调用进行执行跟踪,这个系统调用能处理各类跟踪的命令。设置了CAP_SYS_PTRACE权能的进程可以跟踪系统中的任何进程(除了init)。相反,没有CAP_SYS_PTRACE权能的进程P只能跟踪与P有相同属主的进程。此外,两个进程不能同时跟踪一个进程。ptrace()系统调用修改被跟踪进程描述符的parent字段以使它指向跟踪进程,因此,跟踪进程变为被跟踪进程的有效父进程。当执行跟踪终止时,也就是当以PTRACE_DETACH命令调用ptrace()时,这个系统调用把p_pptr设置为real_parent的值,恢复被跟踪进程原来的父进程。与被跟踪程序相关的几个监控事件为:

    • 一条单独汇编指令执行的结束
    • 进入系统调用
    • 退出系统调用
    • 接收到一个信号

    当一个监控的事件发生时,被跟踪的程序停止,并且将SIGCHID信号发送给它的父进程。当父进程希望恢复子进程的执行时,就使用PTRACE_CONT、PTRACE_SINGLESTEP和PTRACE_SYSCALL命令中的一条命令,这取决于父进程要监控哪种事件:

    • PTRACE_CONT命令只继续执行,子进程将一直执行到收到另一个信号。这种跟踪通过进程描述符ptrace字段中的PF_PTRACED标志实现的,这个标志的检查是由do_signal()函数进行的。
    • PTRACE_SINGLESTEP命令强迫子进程执行下一条汇编语言指令,然后再次停止它。这种跟踪是基于80x86机器的eflags寄存器的TF陷阱标志而实现的。当这个标志为1时,在任一条汇编语言指令之后正好产生一个“Debug”异常。相应的异常处理程序只是清掉这个标志,强迫当前进程停止,并发送SIGCHLD信号给父进程。设置TF标志并不是特权操作,因此用户态进程即使在没有ptrace()系统调用的情况下,也能强迫单步执行。内核检查进程描述符中的PT_DTRACE标志,以跟踪子进程是否通过ptrace()进行单步执行。
    • PTRACE_SYSCALL命令使被跟踪的进程重新恢复执行,直到一个系统调用被调用。进程停止两次,第一次是在系统调用开始时,第二次是在系统调用终止时。这种跟踪是利用进程描述符中的TIF_SYSCALL_TRACE标志实现的。这个标志是在进程thread_info结构的flags字段中,并在system_call()汇编语言的函数中被检查。

    也可以利用Intel处理器的一些调试特点来跟踪进程。例如,父进程使用PTRACE_POKEUSR命令为子进程设置dr0 - dr7调试寄存器的值。当由某调试寄存器监控的事情发生时,CPU产生“Debug”异常,异常处理程序然后挂起被调试的进程并给父进程发送SIGCHLD信号。

 

可执行格式

    Linux标淮的可执行格式是ELF(Executable and Linking Format),它由Unix系统实验室开发并在Unix世界相当流行。著名的Unix操作系统都把ELF作为它们的主要可执行格式。Linux的旧版支持另一种名叫Assembler OUTput Format (a.out)的格式。因为现在ELF非常实用,因此已经很少用a.out格式。

   Linux支持很多其他不同格式的可执行文件。在这种方式下,Linux能运行为其他操作系统所编译的程序,如MS-DOS的EXE程序。有几种可执行格式,如Java或bash脚本,是与平台无关的。由类型为linux_binfmt的对象所描述的可执行格式实质上提供以下三种方法:

    • load_binary 通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境。
    • load_shlib  用于动态地把一个共享库捆绑到一个已经在运行的进程,这是由uselib()系统调用激活的。
    • core_dump 在名为core的文件中存放当前进程的执行上下文。这个文件通常在进程接收到缺省操作为“dump”的信号时被创建,格式取决于被执行程序的可执行类型。

    所有的linux binfmt对象都处于一个单向链表中,第一个元素的地址存放在formats变量中。可以通过调用register_binfmt()和unregister_binfmt()函数在链表中插入和删除元素。在系统启动期间,为每个编译进内核的可执行格式都执行register_binfmt()函数。当实现了一个新的可执行格式的模块正被装载时,也执行这个函数,当模块被卸载时,执行unregister_binfmt()函数。在formats链表中的最后一个元素总是对解释脚本的可执行格式进行描述的一个对象。这种格式只定义了load_binary方法。其相应的load_script()函数检查这种可执行文件是否以两个#!字符开始。如果是,这个函数就把第一行的其余部分解释为另一个可执行文件的路径名,并把脚本文件名作为参数传递以执行它。

    Linux允许用户注册自己定义的可执行格式。对这种格式的识别或者通过存放在文件前128字节的魔数,或者通过表示文件类型的扩展名。例如,MS-DOS的扩展名由“.”把三个字符从文件名中分离出来:.exe扩展名标识可执行文件,而.bat扩展名标识shell脚本。当内核确定可执行文件是自定义格式时,它就启动相应的解释程序。解释程序运行在用户态,读入可执行文件的路径名作为参数,并执行计算。例如,包含Java程序的可执行文件就由Java虚拟机(如//usr/lib/Java/bin/Java)来解释。

    这种机制与脚本格式类似,但功能更加强大,这是因为它对自定义格式不加任何限制。要注册一个新格式,就必须在binfmt_misc文件系统的注册文件内写人一个字符串,其格式为: name:type:offset:string:mask:interpreter:flags每个字段的含义如下:

name

    新格式的标识符。

type

    识别类型(M表示魔数,E表示扩展)。

offset

    魔数在文件中的起始偏移量。

string

    以魔数或者以扩展名匹配的字节序列。

mask

    用来屏蔽掉string中的一些位的字符串。

interpreter

    解释程序的完整路径名。

flags

    可选标志,控制必须怎样调用解释程序。

例如,超级用户执行的下列命令将使内核识别出Microsoft Windows的可执行格式:

    echo :DOSWin:M:O:MZ:Oxff:/usr/bin/wine:’>/proc/sys/fs/binfmt misc/register

Windows可执行文件的前两个字节是魔数MZ,由解释程序/usr/bin/wine执行这个可执行文件。

 

执行域

    Linux的一个巧妙的特点就是能执行其他操作系统所编译的程序。当然,只有内核运行的平台与可执行文件包含的机器代码对应的平台相同时这才是可能的。对这些“外来”程序提供两种支持:

    • 模拟执行(emulated execution):程序中包含的系统调用与POSIX不兼容时才有必要执行这种程序。
    • 原样执行(native execution):只有程序中所包含的系统调用完全与POSIX兼容时才有效。

    Microsoft MS-DOS和Windows程序是被模拟执行的,因为它们包含的API不能被Linux所认识,因此不能原样执行。像DOSemu或Wine这样的模拟程序被调用来把每个API调用转换为一个模拟的封装函数调用,而封装函数调用又使用现有的Linux系统调用。

    另一方面,不用太费力就可以执行为其他操作系统编译的与POSIX兼容的程序,因为与POSIX兼容的操作系统都提供了类似的API。内核必须消除的细微差别通常涉及如何调用系统调用或如何给各种信号编号。这种信息存放在类型为exec_domain的执行域描述符中。进程可以指定它的执行域,这是通过设置进程描述符的personality字段,以及把相应exec_domain数据结构的地址存放到thread_info结构的exec_domain字段来实现的。进程可以通过发布一个personality()系统调用来改变它的个性(personality)。程序员通常不希望直接改变其程序的个性;相反,应该通过建立进程的执行上下文的“粘合”代码来发出personality()系统调用。

 

exec函数

    Unix系统提供了一系列函数,这些函数能用可执行文件所描述的新上下文代替进程的上下文。这样的函数名以前缀exec开始,后跟一个或两个字母。下表列出了exec族函数,它们之间的差别在于如何解释参数。

exec函数

函数名路径搜索命令行参数环境数组
execl()列表
execlp()列表
execle()列表
execv()数组
execvp()数组
execve()数组

    每个函数的第一个参数表示被执行文件的路径名。路径名可以是绝对路径或是当前进程目录的相对路径。此外,如果路径名中不包含“/”字符,exec1p()和execvp()函数就在PATH环境变量指定的所有目录中搜索这个可执行文件。

    除了第一个参数,execl(), execlp()和exec1e()函数包含的其他参数个数都是可变的。每个参数指向一个字符串,这个字符串是对新程序命令行参数的描述,正如函数名中“l”字符所隐含的一样,这些参数组织成一个列表,最后一个值为NULL。通常情况下,第一个命令行参数复制可执行文件名。相反,execv(), execvp()和execve()函数指定单个参数的命令行参数,正如函数名中的“v”字符所隐含的一样,这单个参数是指向命令行参数串的指针向量地址。数组的最后一个元素必须存放NULL值。

    execle()和execve()函数的最后一个参数是指向环境串的指针数组的地址:数组的最后一个元素照样必须为NULL。其他函数对新程序环境参数的访问是通过C库定义的外部全局变量environ进行的。所有的exec函数(除execve()外)都是C库定义的封装例程,并利用了execve()系统调用,这是Linux所提供的处理程序执行的唯一系统调用。

sys_execve()服务例程接收下列参数:

    • 可执行文件路径名的地址(在用户态地址空间)。
    • 以NULL结束的字符串指针数组的地址(在用户态地址空间),每个字符串表示一个命令行参数。
    • 以NULL结束的字符串指针数组的地址(也在用户态地址空间)。每个字符串以NAME = value形式表示一个环境变量。

sys_execve()把可执行文件路径名拷贝到一个新分配的页框。然后调用do_execve()函数,传递给它的参数为指向这个页框的指针、指针数组的指针及把用户态寄存器内容保存到内核态堆栈的位置。do_execve()依次执行下列操作:

1.动态地分配一个linux_binprn数据结构,并用新的可执行文件的数据填充这个结构。

2.调用path_lookup(), dentry_open()和path_release(),以获得与可执行文件相关的目录项对象、文件对象和索引节点对象。如果失败,则返回相应的错误码。

3.检查是否可以由当前进程执行该文件,再检查索引节点的i_writecount字段,以确定可执行文件没被写人;把-1存放在这个字段以禁止进一步的写访问。

4.在多处理器系统中,调用sched_exec()函数来确定最小负载CPU以执行新程序,并把当前进程转移过去。

5.调用ini_new_context()检查当前进程是否使用自定义局部描述符表。如果是,函数为新程序分配和淮备一个新的LDT。

6.调用prepare_binprm()函数填充linux_binprm数据结构,这个函数又依次执行下列操作:

    • 再一次检查文件是否可执行(至少设置一个执行访问权限)。如果不可执行,则返回错误码(因为带有CAP_DAC_OVERRIDE权能的进程总能通过检查,所以第3步中的检查还不够。
    • 初始化linux_binprm结构的e_uid和e_gid字段,考虑可执行文件的setuid和setgid标志的值。这些字段分别表示有效的用户ID和组ID。也要检查进程的权能。
    • 用可执行文件的前128字节填充linux_binprm结构的buf字段。这些字节包含的是适合于识别可执行文件格式的一个魔数和其他信息。

7.把文件路径名、命令行参数及环境串拷贝到一个或多个新分配的页框中,最终它们会被分配给用户态地址空间。

8.调用search_binary_handler()函数对formats链表进行扫描,并尽力应用每个元素的load_binary方法,把linux_binprm数据结构传递给这个函数。只要load_binary方法成功应答了文件的可执行格式,对formats的扫描就终止。

9.如果可执行文件格式不在formats链表中,就释放所分配的所有页框并返回错误码 -ENOEXEC,表示Linux不认识这个可执行文件格式。

10.否则,函数释放linux_binprm数据结构,返回从这个文件可执行格式的load_binary方法中所获得的代码。

 

可执行文件格式对应的load_binary方法执行下列操作(假定这个可执行文件所在的文件系统允许文件进行内存映射并需要一个或多个共享库):

1.检查存放在文件前128字节中的一些魔数以确认可执行格式。如果魔数不匹配,则返回错误码 -ENOEXEC。

2.读可执行文件的首部。这个首部描述程序的段和所需的共享库。

3.从可执行文件获得动态链接程序的路径名,并用它来确定共享库的位置并把它们映射到内存。

4.获得动态链接程序的目录项对象,也就获得了索引节点对象和文件对象。

5.检查动态链接程序的执行许可权。

6.把动态链接程序的前128字节拷贝到缓冲区。

7.对动态链接程序类型执行一些一致性检查。

8.调用flush_old_exec()函数释放前一个计算所占用的几乎所有资源。这个函数又依次执行下列操作:

    • 如果信号处理程序的表为其他进程所共享,那么就分配一个新表并把旧表的引用计数器减1;而且它将进程从旧的线程组脱离。这是通过调用de_ thread()函数完成的。
    • 如果与其他进程共享,就调用unshare_files()拷贝描述进程已打开文件的files_struct结构。
    • 调用exec_mmap()函数释放分配给进程的内存描述符、所有线性区及所有页框,并清除进程的页表。
    • 将可执行文件路径名赋给进程描述符的comm字段。
    • 用flush_thread()函数清除浮点寄存器的值和在TSS段保存的调试寄存器的值。
    • 调用flush_signal_handlers()函数,用于将每个信号恢复为默认操作,从而更新信号处理程序的表。
    • 调用flush_old_files()函数关闭所有打开的文件,这些打开的文件在进程描述符的files->close_on_exec字段设置了相应的标志。现在,已经不能返回了,如果真出了差错,这个函数再不能恢复前一个计算清除进程描述符的PF_FORKNOEXEC标志。这个标志用于在进程创建时设置进程记账,在执行一个新程序时清除进程记账。设立进程新的个性,即设置进程描述符的personality字段。

11.调用arch_pick_mmap_layout(),以选择进程线性区的布局。

12.调用setup_arg_pages()函数为进程的用户态堆栈分配一个新的线性区描述符,并把那个线性区插入到进程的地址空间。setup_arg_pages()还把命令行参数和环境变量串所在的页框分配给新的线性区。

13.调用do_map()函数创建一个新线性区来对可执行文件正文段(即代码)进行映射。这个线性区的起始线性地址依赖于可执行文件的格式,因为程序的可执行代码通常是不可重定位的。因此,这个函数假定从某一特定逻辑地址的偏移量开始装入正文段。ELF程序被装入的起始线性地址为0x080480000。

14.调用do_mmap()函数创建一个新线性区来对可执行文件的数据段进行映射。这个线性区的起始线性地址也依赖于可执行文件的格式,因为可执行代码希望在特定的偏移量(即特定的线性地址)处找到它自己的变量。在ELF程序中,数据段正好被装在正文段之后。

15.为可执行文件的其他专用段分配另外的线性区,通常是无。

16.调用一个装入动态链接程序的函数。如果动态链接程序是ELF可执行的,这个函数就叫做load_elf_interp()。一般情况下,这个函数执行第12-14步的操作,不过要用动态链接程序代替被执行的文件。动态链接程序的正文段和数据段在线性区的起始线性地址是由动态链接程序本身指定的,但它们处于高地址区(通常高于0x40000000),这是为了避免与被执行文件的正文段和数据段所映射的线性区发生冲突。

17.把可执行格式的linux_binfmt对象的地址存放在进程描述符的binfmt字段中。

18.确定进程的新权能。

19.创建特定的动态链接程序表并把它们存放在用户态堆栈,这些表处于命令行参数和指向环境串的指针数组之间。

20.设置进程的内存描述符的start_code、end_code、start_data、end_data、start_brk、brk及start_stack字段。

21.调用do_brk()函数创建一个新的匿名线性区来映射程序的bss段(当进程写入一个变量时,就触发请求调页,进而分配一个页框)。这个线性区的大小是在可执行程序被链接时就计算出来的。因为程序的可执行代码通常是不可重新定位的,因此,必须指定这个线性区的起始线性地址。在ELF程序中,bss段正好装在数据段之后。

22.调用start_thread()宏修改保存在内核态堆栈但属于用户态寄存器的eip和esp的值,以使它们分别指向动态链接程序的入口点和新的用户态堆栈的栈顶。

23.如果进程正被跟踪,就通知调试程序execve()系统调用已完成。

24.返回0(成功)。

    当execve()系统调用终止且调用进程重新恢复它在用户态的执行时,执行上下文被大幅度改变,调用系统调用的代码不复存在。从这个意义上看,我们可以说execve()从未成功返回。取而代之的是,要执行的新程序已被映射到进程的她址幸间。但是,新程序还不能执行,因为动态链接程序还必须考虑共享库的装载(注7)0尽管动态链接程序运行在用户态,但我们还要在这里简要概述一下它是如何运作的。它的第一个工作就是从内核保存在用户态堆栈的信息(处于环境串指针数组和arg start之间)开始,为自己建立一个基本的执行上下文。然后,动态链接程序必须检查被执行的程序,以识别哪个共享库必须装入及在每个共享库中哪个函数被有效地请求。接下来,解释器发出几个mmap()系统调用来创建线性区,以对将存放程序实际使用的库函数(正文和数据)的页进行映射。然后,解释器根据库的线性区的线性地址更新对共享库符号的所有引用。最后,动态链接程序通过跳转到被执行程序的主入口点而终止它的执行。从现在开始,进程将执行可执行文件的代码和共享库的代码。

    如果可执行文件是静态链接的,即如果不需要共享库,load_binary方法只需将程序的正文段、数据段、bss段和堆栈段映射到进程线性区,然后把用户态eip寄存器的内容设置为新程序的入口点即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值