在Linux系统中,程序的执行是一个复杂而精细的过程,涉及多个步骤。
1.进程创建
在Linux中,进程的创建,除了第一个进程(0号进程)是通过硬编码创建,其他所有进程通常都是通过fork()系统调用来实现,fork()会创建当前进程的一个副本,即子进程。父进程和子进程几乎拥有相同的代码和数据,除了一些必要的信息是不同的,比如进程id(pid)和内核堆栈等。
Linux中有几个与创建进程相关的系统调用函数fork、vfork和clone,这三个系统调用底层都是通过do_fork函数创建进程,只是这三个调用函数的传递参数各有不同。
- do_fork首先检查是否允许创建新进程,包括检查系统的最大进程数限制等。
- 然后进入复制阶段,调用copy_process(),此时会复制task_struct结构和根据情况处理mm_struct结构的复制或共享。
- 最后,调用wake_up_new_task(),新进程被加入到调度器的就绪队列中,并设置相应的状态,使其可以被CPU调度执行。
总之进程调用do_fork()后,系统会把当前进程的描述符(PCB)等相关进程资源复制一份,从而产生一个子进程,并根据子进程的需要对复制的进程描述符做一些修改,然后把创建好的子进程放入就绪队列中等待CPU调度。
2.可执行程序的加载
不同于Windows系统的PE格式文件,Linux系统中大多数可执行程序和共享库都采用ELF(Executable and Linkable Format)文件格式。ELF文件不仅支持二进制代码的执行,还支持代码与数据的动态链接。一个ELF文件通常包含程序头表(program header table)和节区(section),这些信息指导了程序如何被加载到内存并执行。
ELF格式的目标文件可分为三种不同类型:
- 可重定位文件,.c代码文件经过编译得到的.o文件就是可重定位文件,由编译器和汇编器创建,一个源代码文件会生成一个可重定位文件,用来和其他目标文件一起创建一个可执行文件、静态库文件或动态库文件。
- 可执行文件:一般由多个可重定位文件生成,是完成了所有重定位工作和符号解析的文件,文件中保存着一个用来执行的程序。
- 共享目标文件:是指可以被其他进程动态地加载和链接的目标文件,通常在Linux环境下以.so为扩展名,跟可执行文件一样包含可执行代码和数据,但是不可被运行。
程序从源代码文件到可执行文件一般要经历预处理、编译、汇编和链接四个主要步骤。
1.预处理阶段
预处理器处理源代码文件中的所有以#开头的预处理指令,如#include、#define等。这一阶段主要负责删除注释、包含头文件、展开宏定义以及处理条件编译指令。
预处理的输出是一个经过修改的C或C++源代码,其文件扩展名通常为.i。这个文件不再包含任何预处理指令,是被送往编译器进一步处理的纯净代码。
2.编译阶段
编译器分析预处理后的代码,进行词法分析、语法分析和语义分析。这一阶段检查代码中的各种语法错误和警告,并产生一个中间表示,通常是汇编语言代码。
编译阶段的输出是一个.s文件,即汇编代码文件。这个文件包含了机器码的文本表示形式,是下一步汇编器的输入。
3.汇编阶段
汇编器将编译生成的汇编代码转换成机器代码,生成的目标文件是可重定位的,意味着它们可以被链接到其他目标文件或库文件,形成最终的可执行文件。
汇编器的输出是一个.o文件,即目标代码文件,它包含机器码,但没有执行头部信息和可能缺失的一些外部引用。
4.链接阶段
链接器负责将一个或多个目标文件和所需的库文件合并成一个单一的可执行文件。此过程中,链接器解决符号引用问题,确定数据和函数的最终内存地址。
链接成功后,生成的是可执行文件,扩展名通常为.exe(在Windows系统)或无扩展名(在Unix/Linux系统)。此时的文件已准备好被操作系统加载并执行。
3.程序的执行
当需要启动一个新程序时,通常使用fork()和execve()组合。fork()创建一个新进程后,execve()负责替换当前进程的映像为新的程序文件,从而开始执行新的程序。
其中do_execve_common()函数是execve()系统调用的核心实现,位于Linux内核中。它负责解析ELF文件的头部,初始化新进程的mm_struct(内存管理结构),以及处理程序的加载和执行。
通过mmap系统调用,将可执行文件的代码段和数据段映射到新进程的虚拟地址空间。这种映射是按需加载的,即只有在真正访问到某部分时才会将其内容加载到物理内存中。
内核会设置好进程的页表,确保程序的所有引用都正确地映射到物理内存或在访问时由内核处理页面错误进行加载。
如果一个可执行程序依赖于动态链接库,那么这些库可以在程序启动时由动态链接器加载,或者在程序运行时通过dlopen等API按需加载。
动态链接器会查找并加载所需的共享库。这个过程包括解析程序的依赖项,将这些库映射到进程的地址空间,并处理任何未解决的符号引用。
一旦所有的加载步骤完成,内核就会开始执行新程序的main函数。这个调用是通过直接跳转到ELF文件中指定的入口点来实现的。
在execve()调用中,可以向新程序传递环境变量和命令行参数。这些信息被复制到新进程的地址空间中,使得新程序能够访问这些数据。
对进程的管理是操作系统的核心功能,Linux通过分页机制和虚拟内存管理进程的内存,每个进程有独立的虚拟地址空间,操作系统将虚拟地址映射到物理内存。进程通过文件描述符管理打开的文件、管道和设备。信号是进程间通信的一种机制,操作系统可以向进程发送信号,通知其发生了某些事件。
4.进程的调度
Linux系统中进程的调度涉及多种状态、优先级以及调度策略。
进程状态
在Linux系统中,每个进程都有其状态,这些状态定义了进程当前的行为模式和系统对其的处理方式。常见的状态包括运行态(Running)、就绪态(Ready)、阻塞态(Blocked)和挂起态(Suspended)。这些状态通过一个整型变量表示,并存储在task_struct结构中。
队列
Linux内核利用不同的队列来管理处于不同状态的进程。例如,就绪队列用于存放准备执行但尚未分配CPU的进程。这些队列通常通过链表实现,确保了高效的队列操作和管理。
优先级
Linux中的进程优先级决定了进程获取CPU资源的先后顺序。优先级较高的进程更容易获得CPU时间片,从而更快地执行。Linux使用一个整数来表示进程的优先级,数值越小,优先级越高。
Linux允许根据系统负载和运行情况动态调整进程的优先级。这种调整可以通过nice值来完成,用户可以通过修改nice值来提升或降低进程的优先级,从而影响其执行顺序。
调度算法与策略
inux系统支持多种调度算法,如CFS(完全公平调度器)用于普通进程,实时调度用于需要快速响应的实时进程等。每种算法针对不同的进程类型和场景,确保系统的高效运行。
Linux内核根据进程的类型和属性选择合适的调度策略。例如,对于实时进程,系统会使用实时调度策略,以保证其严格的时间需求得到满足。
进程切换与并发管理
进程切换是操作系统中频繁发生的操作之一,指的是CPU从一个进程转向另一个进程的过程。Linux通过保存和恢复进程的上下文(如寄存器状态、内存映射等)来实现高效的进程切换。
Linux系统支持多任务并发执行,通过时间片轮转等方式,使得多个进程可以“同时”执行,提高系统的吞吐量和资源利用率。
特殊进程状态的处理
当子进程结束但其父进程尚未读取其退出状态时,子进程将成为僵尸进程。系统需要特别处理这类进程,以避免资源泄漏。
当父进程结束后,其子进程成为孤儿进程,系统会自动将这些进程的父进程设置为init进程,由init进程负责其后续的资源回收工作。