浅谈Linux下程序的一生
在Linux系统中,程序的一生经历了从编写、编译、创建进程、加载、执行到调度等多个阶段。每一个阶段都有其独特的步骤和机制。本文将详细阐述这些阶段,以C语言程序开发的角度,帮助你理解一个程序在Linux中的完整生命周期。
编写和编译程序
编写代码
程序的生命始于开发者的代码编写。开发者使用文本编辑器(如vim、nano、VSCode等)编写源代码并保存为源代码文件,以下是一个简单的用C语言输出HelloWorld的代码。
// hello.c
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
编译代码
编写好的源代码需要经过编译和链接才能成为可执行文件。在Linux系统中,这通常使用gcc
等编译器。
1. 预处理(Preprocessing)
预处理是编译过程的第一步,它主要处理源代码中的预处理指令。这一步骤由预处理器完成,通常是编译器的一个组成部分。
gcc -E hello.c -o hello.i # 预处理
预处理器处理以下内容:
- 宏定义:展开
#define
定义的宏。 - 文件包含:处理
#include
指令,将头文件的内容插入到源文件中。 - 条件编译:处理
#ifdef
、#ifndef
等条件编译指令。
预处理的结果是一个扩展的源代码文件(.i
文件)。
2. 编译(Compilation)
编译步骤将预处理后的源代码(.i
文件)转换成汇编代码(.s
文件)。
gcc -S hello.i -o hello.s # 编译
编译器负责:
- 语法分析:检查源代码的语法错误。
- 语义分析:检查语义错误,如变量的类型、作用域等。
- 中间代码生成:将源代码转换为中间表示形式(如抽象语法树)。
- 优化:对中间代码进行优化,提升执行效率。
- 汇编代码生成:将优化后的中间代码转化为汇编代码。
3. 汇编(Assembly)
汇编步骤将汇编代码(.s
文件)转换为机器代码,生成目标文件(.o
文件)。
gcc -c hello.s -o hello.o # 汇编
汇编器完成:
- 指令翻译:将汇编代码中的指令翻译成机器指令。
- 符号解析:解析符号和标签,生成符号表。
- 生成目标文件:创建包含机器代码和符号表的目标文件。
4. 链接(Linking)
链接步骤将一个或多个目标文件(.o
文件)和库文件链接在一起,生成最终的可执行文件。
gcc hello.o -o hello # 链接
链接器完成:
- 符号解析:解析所有目标文件中的符号引用,确保所有符号都有定义。
- 地址分配:分配代码段和数据段的地址,确定每个符号在内存中的位置。
- 库文件链接:将标准库(如
libc
)和其他外部库文件链接到目标文件中。 - 生成可执行文件:创建包含所有机器代码、数据和符号表的可执行文件。
链接过程中,链接器可能会遇到以下几种符号:
- 全局符号:程序中定义的函数和全局变量。
- 外部符号:程序引用但未定义的函数和变量,通常由库提供。
- 局部符号:局部变量和函数内部的符号,不对其他文件可见。
执行
在Linux系统中,程序的执行阶段包含了从加载可执行文件到进程调度、执行指令、处理系统调用和信号、到最终终止的各个环节。
1. 加载可执行文件
当一个程序被执行时,操作系统首先通过程序加载器(Loader)将可执行文件加载到内存中。加载可执行文件可以分为静态加载和动态加载两种方式。
静态加载
在静态加载中,可执行文件在编译时已经将所有依赖的库文件链接到一起,生成一个包含所有代码和数据的单一可执行文件。
- 优点:加载速度快,运行时依赖少。
- 缺点:可执行文件体积大,无法共享库文件,更新库文件需要重新编译。
动态加载
在动态加载中,可执行文件在运行时才加载所需的库文件。这些库文件称为动态链接库(如.so
文件)。
- 优点:可执行文件体积小,可以共享库文件,库文件更新无需重新编译。
- 缺点:加载速度相对较慢,运行时依赖较多。
加载步骤
-
可执行文件格式检查:检查文件头部信息,确定其格式(如ELF)。
-
内存分配:为程序的代码段、数据段、堆和栈分配内存。
-
读取文件内容:将可执行文件的代码段和数据段读取到相应的内存位置。
-
动态链接
(如果使用动态加载):
- 解析符号:动态链接器解析动态符号,并将动态库文件加载到内存中。
- 重定位:调整程序中的地址引用,以便正确调用动态库中的函数。
系统调用
execve("/path/to/hello", argv, envp);
execve
系统调用用于执行一个新的程序,替换当前进程的映像。
2. 进程初始化
加载完成后,操作系统会进行一系列的初始化工作:
- 设置进程上下文:包括进程控制块(PCB)、程序计数器(PC)、栈指针(SP)等。
- 初始化堆栈:为程序的主线程初始化堆栈,设置参数(如命令行参数和环境变量)。
- 设置文件描述符:通常包括标准输入、标准输出和标准错误(
stdin
,stdout
,stderr
)。
示例
int main(int argc, char *argv[], char *envp[]) {
// 程序的入口点
return 0;
}
3. 程序执行
操作系统将控制权交给程序的入口点(通常是main
函数),程序开始执行其指令。
程序计数器(PC)
- PC寄存器:指向下一条将被执行的指令的地址。
- 指令执行:CPU根据PC的值取指令、解码并执行。
内存管理
- 代码段:存放程序的指令。
- 数据段:存放全局变量和静态变量。
- 堆:动态分配的内存区域。
- 栈:存放函数调用的局部变量、返回地址等。
4. 进程调度
在学习进程调度之前,我们首先了解以下进程在操作系统的视角中是什么样的。
在Linux操作系统中,每个进程在内核中都有一个对应的结构体来表示,该结构体是task_struct
。这个结构体包含了与进程相关的所有信息,包括状态、调度信息、内存管理、文件系统信息等。
1. task_struct
结构体概述
task_struct
结构体定义在Linux内核源码的include/linux/sched.h
文件中。它是操作系统内核用来描述和管理进程的核心数据结构。以下是task_struct
结构体的简化版本,以展示其主要成员:
struct task_struct {
volatile long state; // 进程的状态
struct mm_struct *mm; // 进程的内存管理信息
pid_t pid; // 进程ID
pid_t tgid; // 线程组ID
struct task_struct *parent; // 父进程
struct list_head children; // 子进程链表
struct list_head sibling; // 兄弟进程链表
struct files_struct *files; // 打开的文件信息
struct fs_struct *fs; // 文件系统信息
struct signal_struct *signal; // 信号处理信息
struct sched_entity se; // 调度实体
struct cfs_rq *cfs_rq; // CFS运行队列
struct list_head tasks; // 任务链表,用于调度
// ... 其他成员
};
2. task_struct
在进程创建中的应用
进程创建是通过fork
、vfork
或clone
系统调用实现的。在这些系统调用中,操作系统会创建一个新的task_struct
实例来表示新进程。
进程创建的主要步骤
-
分配新的
task_struct
:通过dup_task_struct
函数分配和初始化新的task_struct
。static struct task_struct *dup_task_struct(struct task_struct *orig) { struct task_struct *tsk; tsk = alloc_task_struct(); if (!tsk) return NULL; *tsk = *orig; // 复制原进程的task_struct return tsk; }
-
初始化新的
task_struct
:初始化新的task_struct
中的各个成员,包括状态、PID、内存管理、文件系统信息等。static int copy_process(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr, unsigned long tls) { struct task_struct *p; p = dup_task_struct(current); // 复制当前进程的task_struct if (!p) return -ENOMEM; // 初始化新的task_struct p->state = TASK_UNINTERRUPTIBLE; p->pid = alloc_pid(); p->tgid = (clone_flags & CLONE_THREAD) ? current->tgid : p->pid; // ... 其他初始化 return 0; }
-
添加到进程链表:将新创建的进程添加到进程链表和调度队列中。
static int copy_process(...) { // ... 初始化task_struct list_add_tail(&p->tasks, &init_task.tasks); // 添加到进程链表 activate_task(p); // 添加到调度队列 return 0; }
综上,进程在操作系统眼中就是一个结构体,操作系统通过管理结构体中的成员来控制进程的运行状态与调度情况,现在我们来介绍进程的调度。
Linux内核中的进程调度机制决定了哪个进程在什么时候运行,以及运行多长时间。Linux使用完全公平调度器(CFS)来管理进程调度。
进程调度概述
进程调度是操作系统内核的核心功能之一。它负责分配CPU时间给各个进程,以便它们能够并发执行。Linux采用了完全公平调度器(CFS),其主要目标是确保所有进程能够公平地获得CPU资源。
调度器的主要任务
- 选择下一个运行的进程。
- 决定每个进程的运行时间。
- 管理进程状态的转换(就绪、运行、阻塞等)。
完全公平调度器(CFS)
CFS通过维护一个红黑树来管理所有可调度的进程。红黑树是一种自平衡二叉搜索树,它使得插入、删除和查找操作都能在对数时间内完成。CFS调度的核心思想是通过虚拟运行时间(vruntime)来衡量每个进程的CPU使用情况,并选择vruntime最小的进程作为下一个运行的进程。
关键数据结构
struct sched_entity
:表示一个调度实体,它包含了进程的调度信息,如vruntime。struct cfs_rq
:表示CFS运行队列,包含所有调度实体的红黑树。
关键源码解析
进程调度的核心代码在kernel/sched/core.c
文件中。
- 进程插入调度队列
每当一个进程变得可调度时,它会被插入到CFS的红黑树中。这个操作在enqueue_task_fair
函数中完成。
static void enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags) {
struct cfs_rq *cfs_rq = cfs_rq_of(&p->se);
struct sched_entity *se = &p->se;
// 更新进程的vruntime
update_curr(cfs_rq);
if (!(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_WAKING))
se->vruntime += cfs_rq->min_vruntime;
// 插入红黑树
__enqueue_entity(cfs_rq, se);
// 更新CFS运行队列的负载信息
update_load_add(cfs_rq, se);
}
- 选择下一个运行的进程
调度器通过pick_next_task_fair
函数选择下一个运行的进程。该函数选择vruntime最小的进程,即红黑树中最左边的节点。
static struct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) {
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;
// 获取红黑树中vruntime最小的进程
se = __pick_first_entity(cfs_rq);
if (!se)
return NULL;
p = container_of(se, struct task_struct, se);
// 移除选中的进程
__dequeue_entity(cfs_rq, se);
return p;
}
- 上下文切换
当调度器决定切换进程时,它会调用context_switch
函数来完成实际的上下文切换。
static void context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next) {
struct mm_struct *mm, *oldmm;
// 保存当前进程的上下文
prepare_task_switch(rq, prev, next);
mm = next->mm;
oldmm = prev->active_mm;
// 更新当前进程指针
rq->curr = next;
// 切换内存地址空间
switch_mm(oldmm, mm, next);
// 切换CPU上下文
switch_to(prev, next, prev);
}
调度策略
Linux支持多种调度策略,包括CFS、实时调度(RT)等。每种调度策略在选择和管理进程时有不同的优先级和算法。
- SCHED_NORMAL:普通进程,使用CFS调度。
- SCHED_FIFO:实时进程,先进先出调度。
- SCHED_RR:实时进程,时间片轮转调度。
调度策略可以通过sched_setscheduler
系统调用来设置。
int sched_setscheduler(pid_t pid, int policy, const struct sched_param *param);
调度器唤醒机制
当进程因为I/O等操作阻塞时,调度器需要在事件发生后唤醒进程,使其重新进入就绪状态。try_to_wake_up
函数处理这一操作。
int try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags) {
struct rq_flags rf;
struct rq *rq;
int cpu;
// 获取进程对应的运行队列
rq = task_rq_lock(p, &rf);
// 修改进程状态为TASK_RUNNING
p->state = TASK_RUNNING;
// 将进程插入调度队列
enqueue_task(rq, p, wake_flags);
// 如果唤醒的进程优先级更高,触发抢占
if (task_running(rq, p))
resched_curr(rq);
task_rq_unlock(rq, p, &rf);
return 1;
}
调试和优化
调试
- 调试工具:使用调试器(如
gdb
)来设置断点、单步执行、检查变量和内存。 - 错误修复:根据调试结果修改源代码,重新编译和链接。
gdb ./hello # 启动调试器
优化
- 代码优化:根据性能分析工具(如
gprof
)的反馈优化代码。 - 编译优化:使用编译器优化选项(如
-O2
、-O3
)优化生成的机器代码。
gcc -O2 hello.c -o hello # 编译优化
1. 优化级别选项
GCC提供了一些优化级别选项,通过不同的级别,开发者可以控制编译器的优化力度。
-O0
:不进行优化。这是默认选项,编译速度最快,但生成的代码执行效率最低。用于调试和开发阶段。-O1
:基本优化。执行一些常见的优化,如删除无用的代码、简单的寄存器分配等。生成的代码执行效率有所提高,但编译时间较短。-O2
:进一步优化。在-O1
的基础上,增加了更多优化,如循环优化、常量传播等。生成的代码执行效率更高,编译时间适中。-O3
:最高级别优化。包括所有的-O2
优化,以及一些额外的优化,如函数内联、向量化等。生成的代码执行效率最高,但编译时间最长,可能会增加代码体积。-Os
:优化代码尺寸。类似于-O2
,但还会执行一些减少代码体积的优化。适用于内存受限的环境。-Ofast
:忽略标准合规性的优化。包括所有-O3
优化以及一些可能违反标准的优化,生成的代码执行效率最高,但可能不符合标准规范。
2. 具体优化选项
除了优化级别选项外,GCC还提供了一些具体的优化选项,开发者可以根据需要选择合适的优化。
-funroll-loops
:展开循环。将循环体展开,减少循环控制的开销。适用于小循环体。-finline-functions
:内联函数。将小函数直接嵌入调用点,减少函数调用开销。-fomit-frame-pointer
:省略帧指针。在不需要调试信息时,省略帧指针以释放一个寄存器,提高寄存器利用率。-fstrict-aliasing
:严格别名规则。允许编译器根据C标准中的别名规则进行优化,可能会提高性能,但需要确保代码遵循别名规则。-ffast-math
:快速数学运算。允许编译器进行不完全符合IEEE标准的数学运算优化,可能会提高性能,但有时会引入数值不准确性。-floop-optimize
:循环优化。进行各种循环优化,如循环展开、循环分割等。-fprofile-generate
/-fprofile-use
:生成/使用性能分析信息。通过收集运行时的性能数据进行优化。-fstack-protector
/-fstack-protector-all
:栈保护。插入栈保护代码以防止缓冲区溢出攻击。-ftree-vectorize
:向量化。将循环中的标量操作转换为向量操作,利用SIMD指令集提高性能。
Linux中对于程序和进程的控制与管理操作远不止于此,学习的方法即阅读Linux内核源码,深入理解源码中对于进程结构的描述以及有关函数,这样才能对这方面知识有更为深刻的理解。