Linux 基础 6.进程
文章目录
- 6.1 进程和程序
- 1. **程序 (Program)**
- 2. **进程 (Process)**
- 3. **程序与进程的区别**
- 4. **进程的创建与执行**
- 5. **总结**
- 6.2 进程号和父进程号
- 1. **进程号 (PID)**
- 2. **进程号的分配**
- 3. **父进程号 (PPID)**
- 4. **进程树结构**
- 5. **进程号的限制与调整**
- 6. **总结**
- 6.3 进程内存布局
- 1. **文本段 (Text Segment)**
- 2. **初始化数据段 (Initialized Data Segment)**
- 3. **未初始化数据段 (Uninitialized Data Segment, BSS 段)**
- 4. **栈 (Stack)**
- 5. **堆 (Heap)**
- 6. **命令行参数和环境变量**
- 7. **特殊符号:`etext`、`edata` 和 `end`**
- 8. **图 6-1:x86-32 体系结构中的进程内存布局**
- 9. **总结**
- 4 进程内存布局与虚拟内存管理
- 1. **虚拟内存的基本概念**
- 2. **页(Page)与页帧(Page Frame)**
- 3. **页面错误(Page Fault)**
- 4. **页表(Page Table)**
- 5. **虚拟内存的优势**
- 6. **虚拟内存管理的动态调整**
- 7. **硬件支持:分页内存管理单元(PMMU)**
- 8. **总结**
- 5. 栈和栈帧
- 1. **栈的增长和收缩**
- 2. **栈帧的结构**
- 3. **栈帧的嵌套和递归**
- 4. **用户栈 vs 内核栈**
- 5. **栈帧的示例**
- 6. **栈溢出(Stack Overflow)**
- 7. **总结**
- 7. 命令行参数(`argc` 和 `argv`)
- 1. **`argc` 和 `argv` 的定义**
- 2. **`main()` 函数的签名**
- 3. **命令行参数的示例**
- 4. **程序清单 6-2:回显命令行参数**
- 5. **使用 `NULL` 终止的参数列表**
- 6. **`argv[0]` 的用途**
- 7. **传递命令行参数给其他函数**
- 8. **从程序内部访问命令行参数**
- 9. **总结**
- 8. 环境列表
- 1. **环境变量的作用**
- 2. **环境变量的继承**
- 3. **设置和管理环境变量**
- 3.1 **在 Shell 中设置环境变量**
- 3.2 **临时设置环境变量**
- 3.3 **使用 `env` 命令**
- 3.4 **查看环境变量**
- 4. **从程序中访问环境变量**
- 4.1 **使用 `environ` 全局变量**
- 4.2 **使用 `main()` 函数的第三个参数**
- 4.3 **使用 `getenv()` 函数**
- 5. **修改环境变量**
- 5.1 **使用 `putenv()` 函数**
- 5.2 **使用 `setenv()` 函数**
- 5.3 **使用 `unsetenv()` 函数**
- 5.4 **清除整个环境**
- 6. **总结**
- 8.1 :修改进程环境 (`modify_env.c`)
- 代码解析
- 详细说明
- 示例运行
- 关键点总结
- 9. 执行非局部跳转:`setjmp()` 和 `longjmp()`
- 1. **非局部跳转的概念**
- 2. **`setjmp()` 和 `longjmp()` 的工作原理**
- 2.1 **`setjmp()` 函数**
- 2.2 **`longjmp()` 函数**
- 3. **使用场景**
- 4. **示例程序**
- 5. **程序运行示例**
- 6. **`setjmp()` 的使用限制**
- 7. **滥用 `longjmp()` 的问题**
- 8. **优化编译器的问题**
- 9. **示例:优化编译器的影响**
- 10. **编译器警告**
- 11. **最佳实践**
- 12. **总结**
6.1 进程和程序
在计算机操作系统中,进程(process)和程序(program)是两个密切相关但又有所区别的概念。理解它们之间的区别对于深入理解操作系统的原理和应用程序的执行过程至关重要。
1. 程序 (Program)
程序是一个静态的概念,指的是存储在磁盘上的可执行文件,包含了用于创建进程的所有必要信息。具体来说,程序文件通常包含以下几个部分:
-
二进制格式标识:每个程序文件都包含元信息,描述了该文件的格式。内核使用这些信息来解释文件中的其他内容。历史上,UNIX 系统曾使用
a.out
和COFF
两种格式,但现在大多数 UNIX 实现(包括 Linux)采用的是 ELF(Executable and Linkable Format) 格式。ELF 格式具有更好的灵活性和扩展性,支持动态链接、调试信息等多种特性。 -
机器语言指令:这是程序的核心部分,包含了用机器语言编码的指令,描述了程序的算法和逻辑。
-
程序入口地址:指定了程序开始执行时的第一条指令的位置。当程序被加载到内存并启动时,CPU 会从这个地址开始执行指令。
-
数据:程序文件中还包含了变量的初始值和字面常量(如字符串)。这些数据在程序运行时会被加载到内存中,供程序使用。
-
符号表及重定位表:符号表记录了程序中函数和全局变量的名称及其在代码中的位置,主要用于调试和动态链接。重定位表则用于在加载程序时调整代码和数据的地址,以适应不同的内存布局。
-
共享库和动态链接信息:程序文件中可能包含对共享库的引用,以及动态链接器的路径名。这些信息告诉操作系统在运行时如何加载和链接所需的共享库。
-
其他信息:程序文件还可能包含其他辅助信息,例如调试信息、版本号等,用于支持程序的开发、调试和维护。
2. 进程 (Process)
进程是一个动态的概念,指的是程序在执行时的一个实例。换句话说,进程是程序在内存中的一个活动副本,它包含了程序的执行状态和系统为其分配的资源。从内核的角度来看,进程是由以下两部分组成的:
-
用户空间内存:这部分内存包含了程序的代码、数据、堆栈以及动态分配的内存区域。它是程序实际执行的地方,包含了所有正在运行的指令和数据。
-
内核数据结构:内核为每个进程维护了一系列的数据结构,用于跟踪和管理进程的状态。这些数据结构包括但不限于:
- 进程标识符 (PID):每个进程都有一个唯一的标识符,用于区分不同的进程。
- 虚拟内存表:描述了进程的虚拟地址空间,包括代码段、数据段、堆栈段等,并映射到物理内存。
- 打开文件描述符表:记录了进程当前打开的文件描述符及其对应的文件对象。
- 信号处理信息:记录了进程接收到的信号及其处理方式。
- 资源限制:定义了进程可以使用的资源上限,例如 CPU 时间、内存大小等。
- 当前工作目录:记录了进程当前的工作目录,用于相对路径的解析。
- 其他状态信息:包括进程的优先级、调度信息、父进程 ID 等。
3. 程序与进程的区别
-
静态 vs 动态:程序是静态的,存储在磁盘上,是一个被动的实体;而进程是动态的,存在于内存中,是一个活跃的实体,表示程序的执行过程。
-
多个实例:一个程序可以在同一时间创建多个进程。例如,多个用户可以同时运行同一个编辑器程序,每个用户的编辑器都是一个独立的进程。
-
资源分配:程序本身不占用系统资源,只有当它被加载到内存并创建为进程时,才会分配内存、CPU 时间等资源。每个进程都有自己独立的资源,即使它们运行的是相同的程序。
-
生命周期:程序的生命周期是从创建到删除,通常是长期存在的;而进程的生命周期是从创建到终止,通常是短暂的。进程一旦终止,其占用的资源将被释放。
4. 进程的创建与执行
当用户或系统启动一个程序时,操作系统会执行以下步骤来创建一个新进程:
-
加载程序:操作系统将程序文件从磁盘加载到内存中,解析 ELF 文件格式,将代码和数据加载到相应的内存区域。
-
初始化进程控制块 (PCB):操作系统为新进程创建一个进程控制块(Process Control Block),记录进程的各种状态信息,如 PID、内存映射、文件描述符等。
-
设置初始上下文:操作系统为进程设置初始的执行上下文,包括寄存器状态、栈指针、程序计数器等,确保进程可以从程序的入口点开始执行。
-
调度进程:操作系统将新进程放入就绪队列,等待 CPU 调度。当进程获得 CPU 时,它将开始执行程序中的指令。
-
执行与终止:进程在执行过程中可能会进行 I/O 操作、创建子进程、接收信号等。当进程完成任务后,它会调用
exit()
或_exit()
系统调用,通知操作系统终止该进程,并释放其占用的资源。
5. 总结
- 程序 是一个静态的可执行文件,包含了创建进程所需的所有信息,存储在磁盘上。
- 进程 是程序的一个实例,表示程序在内存中的执行过程,由操作系统管理和调度。
- 多个进程 可以运行相同的程序,每个进程都有自己独立的资源和状态。
- 进程的生命周期 从创建到终止,操作系统负责为其分配和回收资源。
通过理解程序和进程的区别,我们可以更好地理解操作系统如何管理程序的执行,以及如何有效地利用系统资源。
6.2 进程号和父进程号
在 UNIX 和 Linux 系统中,每个进程都有一个唯一的标识符,称为进程号(PID, Process ID),用于区分系统中的不同进程。此外,每个进程还有一个父进程号(PPID, Parent Process ID),指向创建该进程的父进程。本节将详细介绍进程号和父进程号的概念、相关系统调用以及它们的管理方式。
1. 进程号 (PID)
-
定义:进程号是一个正整数,用于唯一标识系统中的某个进程。它是操作系统为每个进程分配的一个编号,确保在同一时间点内,不会有两个进程拥有相同的 PID。
-
数据类型:进程号的数据类型是
pid_t
,这是由 POSIX 标准(SUSv3)规定的整数类型,专门用于存储进程号。pid_t
的具体实现依赖于平台,但在大多数系统上,它通常是一个有符号的整数类型。 -
系统调用:可以通过
getpid()
系统调用来获取当前进程的 PID。#include <unistd.h> pid_t getpid(void);
- 返回值:成功时返回调用进程的 PID,失败时返回 -1。
-
用途:
- 唯一标识:进程号用于唯一标识系统中的进程,确保每个进程都可以被独立管理和操作。
- 系统调用参数:许多系统调用(如
kill()
、wait()
等)需要传递进程号作为参数,以便对特定进程进行操作。 - 文件名生成:进程号常用于生成与进程相关的临时文件名或日志文件名,以确保文件名的唯一性。
2. 进程号的分配
-
范围:在早期的 Linux 系统中,进程号的范围是 1 到 32767。从 Linux 2.6 版本开始,进程号的默认上限仍然是 32767,但可以通过
/proc/sys/kernel/pid_max
文件进行调整。- 32 位系统:
pid_max
的最大值为 32768。 - 64 位系统:
pid_max
的最大值可以高达 2^22(约 400 万),这使得系统可以容纳更多的进程。
- 32 位系统:
-
分配策略:
- 按顺序分配:新进程创建时,内核会按顺序分配下一个可用的进程号。
- 重置机制:当进程号达到 32767 时,内核不会从 1 开始重新分配,而是从 300 开始。这是因为低数值的进程号通常被系统进程和守护进程长期占用,直接从 1 开始分配可能会导致不必要的搜索和浪费时间。
-
特殊进程:
init
进程:进程号为 1 的init
进程是系统启动时的第一个进程,所有其他进程都是它的后代。init
进程负责管理系统中的其他进程,并在子进程成为孤儿时收养它们。
3. 父进程号 (PPID)
-
定义:父进程号是指创建当前进程的父进程的 PID。每个进程都有一个父进程,除了
init
进程(PID 为 1),它是所有进程的始祖。 -
系统调用:可以通过
getppid()
系统调用来获取当前进程的父进程号。#include <unistd.h> pid_t getppid(void);
- 返回值:成功时返回调用进程的父进程号,失败时返回 -1。
-
孤儿进程:如果一个进程的父进程终止,该进程就会变成“孤儿进程”。此时,
init
进程会自动收养该孤儿进程,成为其新的父进程。因此,孤儿进程后续对getppid()
的调用将返回 1(即init
进程的 PID)。
4. 进程树结构
-
家族树:所有进程之间的父子关系形成了一个树状结构,称为“进程树”。每个进程的父进程又有自己的父进程,最终回溯到
init
进程(PID 为 1)。通过查看进程树,可以了解系统中各个进程之间的层次关系。 -
工具:可以使用
pstree
命令来可视化显示进程树结构。pstree
以树状图的形式展示进程及其子进程,帮助用户更好地理解系统的进程组织。pstree
-
/proc 文件系统:在 Linux 系统中,
/proc
文件系统提供了丰富的进程信息。对于每个进程,/proc/PID/status
文件中包含了许多关于该进程的详细信息,包括其父进程号(PPid
字段)。cat /proc/<PID>/status | grep PPid
例如,查看进程 1234 的父进程号:
cat /proc/1234/status | grep PPid
5. 进程号的限制与调整
-
pid_max
文件:从 Linux 2.6 版本开始,进程号的上限可以通过/proc/sys/kernel/pid_max
文件进行动态调整。这个文件的值表示系统中允许的最大进程号加 1。-
查看当前值:
cat /proc/sys/kernel/pid_max
-
修改值:可以通过写入新值来调整
pid_max
,但这需要超级用户权限。echo 4096 > /proc/sys/kernel/pid_max
-
持久化设置:为了使更改在系统重启后仍然有效,可以将设置添加到
/etc/sysctl.conf
文件中:kernel.pid_max = 4096
然后运行
sysctl -p
使配置生效。
-
6. 总结
- 进程号 (PID) 是一个正整数,用于唯一标识系统中的每个进程。它可以通过
getpid()
系统调用获取。 - 父进程号 (PPID) 是指创建当前进程的父进程的 PID,可以通过
getppid()
系统调用获取。 - 进程号的分配 是按顺序进行的,当达到上限时,内核会从 300 开始重新分配,以避免低数值进程号的冲突。
- 进程树结构 反映了系统中所有进程的父子关系,
init
进程(PID 为 1)是所有进程的始祖。 - 孤儿进程 的父进程会被
init
进程收养,getppid()
将返回 1。 pid_max
文件 允许用户动态调整进程号的上限,以适应不同系统的需求。
通过理解和管理进程号和父进程号,开发人员可以更好地控制和调试多进程应用程序,确保系统的稳定性和安全性。
6.3 进程内存布局
在 UNIX 和 Linux 系统中,每个进程的虚拟内存被划分为多个逻辑段(segment),每个段负责存储不同类型的数据。这种划分有助于提高系统的安全性和效率。本节将详细介绍进程内存的各个段,并解释它们的作用和特性。
1. 文本段 (Text Segment)
-
定义:文本段(也称为代码段)包含了进程运行时所需的机器语言指令。它是程序的可执行部分,包含了编译后的二进制代码。
-
属性:
- 只读:为了防止进程通过错误指针意外修改自身的指令,文本段通常被设置为只读。这可以避免由于自修改代码导致的不稳定行为。
- 共享:多个进程可以同时运行同一程序,因此文本段是可共享的。操作系统会将同一程序的文本段映射到所有这些进程的虚拟地址空间中,从而节省内存。例如,多个用户同时运行
bash
命令行解释器时,它们共享同一个bash
的文本段。
-
示例:假设你有两个进程 A 和 B 都在运行相同的程序
myapp
,那么它们的文本段将指向同一个物理内存区域,而不需要为每个进程单独加载一份副本。
2. 初始化数据段 (Initialized Data Segment)
-
定义:初始化数据段包含了显式初始化的全局变量和静态变量。当程序加载到内存时,这些变量的初始值会从可执行文件中读取并加载到内存中。
-
示例:
int primes[] = {2, 3, 5, 7}; // 初始化的全局数组 static int key = 9973; // 初始化的静态变量
-
特点:这些变量在程序启动时已经被赋予了初始值,因此它们占用的内存空间在可执行文件中是有实际内容的。
3. 未初始化数据段 (Uninitialized Data Segment, BSS 段)
-
定义:未初始化数据段(也称为 BSS 段)包含了未进行显式初始化的全局变量和静态变量。在程序启动之前,系统会将这一段内的所有内存初始化为 0。
-
历史原因:BSS 段的名称来源于早期汇编语言中的助记符“block started by symbol”。将经过初始化的全局变量和静态变量与未初始化的变量分开存放的原因在于,程序在磁盘上存储时,没有必要为未初始化的变量分配存储空间。相反,可执行文件只需记录 BSS 段的位置和大小,直到运行时再由程序加载器来分配这一空间。
-
示例:
char globBuf[65536]; // 未初始化的全局数组 static char mbuf[10240000]; // 未初始化的静态数组
-
特点:这些变量在程序启动时会被自动初始化为 0,但它们在可执行文件中并不占用实际的存储空间,只记录了所需的空间大小。
4. 栈 (Stack)
-
定义:栈是一个动态增长和收缩的段,由栈帧(stack frames)组成。每个当前调用的函数都会在栈上分配一个栈帧,用于存储函数的局部变量(自动变量)、实参和返回值。
-
特点:
- 动态增长:每当调用一个函数时,栈会向下增长(在大多数体系结构中,栈是从高地址向低地址增长的),为该函数分配一个新的栈帧。
- 动态收缩:当函数返回时,栈帧会被释放,栈会向上收缩。
- 局部变量:栈帧中存储了函数的局部变量、实参和返回值。局部变量通常被称为“自动变量”,因为它们的生命周期仅限于函数的执行期间。
-
示例:
void square(int x) { int result; // 局部变量,存储在栈帧中 result = x * x; return result; }
-
栈溢出:如果递归调用过深或局部变量过多,可能会导致栈溢出(stack overflow),进而引发程序崩溃。现代操作系统通常会对栈的大小进行限制,以防止这种情况发生。
5. 堆 (Heap)
-
定义:堆是一块可以在运行时动态分配和释放的内存区域。程序可以通过
malloc()
、calloc()
、realloc()
和free()
等标准库函数在堆上分配和管理内存。 -
特点:
- 动态分配:堆上的内存是在程序运行时按需分配的,程序员可以灵活地控制内存的分配和释放。
- 程序中断点:堆的顶端称为程序中断点(program break),它标志着当前堆的边界。操作系统通过
brk()
或sbrk()
系统调用来调整堆的大小。 - 内存泄漏:如果程序员忘记释放不再使用的堆内存,可能会导致内存泄漏(memory leak),进而消耗过多的系统资源。
-
示例:
char *p = malloc(1024); // 在堆上分配 1024 字节的内存 if (p == NULL) { // 内存分配失败 exit(EXIT_FAILURE); } // 使用 p 指向的内存 free(p); // 释放内存
6. 命令行参数和环境变量
-
定义:在进程的虚拟地址空间的顶部,通常会预留一部分内存用于存储程序的命令行参数(
argv
)和环境变量(environ
)。这些数据在程序启动时由操作系统传递给进程。 -
特点:
- 命令行参数:
argv
是一个指向字符串数组的指针,包含程序的命令行参数。argc
表示参数的数量。 - 环境变量:
environ
是一个指向字符串数组的指针,包含进程的环境变量。环境变量用于传递配置信息,如路径、用户 ID 等。
- 命令行参数:
-
示例:
int main(int argc, char *argv[]) { // argv[0] 是程序名,argv[1] 到 argv[argc-1] 是命令行参数 for (int i = 0; i < argc; i++) { printf("Argument %d: %s\n", i, argv[i]); } return 0; }
7. 特殊符号:etext
、edata
和 end
-
定义:在大多数 UNIX 实现(包括 Linux)中,C 语言编程环境提供了三个全局符号:
etext
、edata
和end
。这些符号可以用于获取程序文本段、初始化数据段和未初始化数据段结尾处下一字节的地址。 -
声明:
extern char etext, edata, end;
-
用途:
&etext
:指向文本段结束处的下一个字节。&edata
:指向初始化数据段结束处的下一个字节。&end
:指向未初始化数据段(BSS 段)结束处的下一个字节。
-
示例:
#include <stdio.h> extern char etext, edata, end; int main() { printf("Text segment ends at: %p\n", &etext); printf("Initialized data segment ends at: %p\n", &edata); printf("BSS segment ends at: %p\n", &end); return 0; }
8. 图 6-1:x86-32 体系结构中的进程内存布局
图 6-1 展示了在 x86-32 体系结构中,典型进程的内存布局。请注意,虚拟内存地址的范围可能会因内核配置和程序链接选项的不同而有所变化。
-
虚拟地址空间:
- 低地址:从
0x08048000
开始,依次是文本段、初始化数据段和未初始化数据段(BSS 段)。 - 高地址:从接近
0xC0000000
的地方开始,依次是堆、栈、命令行参数和环境变量。
- 低地址:从
-
栈和堆的增长方向:
- 栈:从高地址向低地址增长。
- 堆:从低地址向高地址增长。
-
不可用区域:图中标灰的区域表示这些范围在进程虚拟地址空间中不可用,即没有为这些区域创建页表。这些区域通常是保留给内核或其他系统用途的。
![[Linux-UNIX系统编程手册(上册)_page125_image.png]]
9. 总结
- 文本段:包含程序的机器语言指令,具有只读和共享属性。
- 初始化数据段:包含显式初始化的全局变量和静态变量,初始值从可执行文件中读取。
- 未初始化数据段(BSS 段):包含未初始化的全局变量和静态变量,程序启动时自动初始化为 0。
- 栈:动态增长和收缩的段,用于存储函数的局部变量、实参和返回值。
- 堆:可在运行时动态分配和释放的内存区域,用于动态内存管理。
- 命令行参数和环境变量:存储在进程虚拟地址空间的顶部,由操作系统传递给进程。
- 特殊符号:
etext
、edata
和end
可用于获取各段的边界地址。
通过理解进程内存的布局,开发人员可以更好地优化程序的性能,避免常见的内存管理问题(如栈溢出、内存泄漏等),并确保程序的安全性和稳定性。
4 进程内存布局与虚拟内存管理
在讨论进程内存布局时,理解虚拟内存(Virtual Memory)的概念至关重要。虚拟内存技术是现代操作系统中的一项关键特性,它通过将物理内存(RAM)与磁盘空间结合使用,提供了更大的灵活性和更高的资源利用率。本节将详细介绍虚拟内存的工作原理及其带来的优势,并解释它如何影响进程的内存布局。
![[Linux-UNIX系统编程手册(上册)_page127_image.png]]
1. 虚拟内存的基本概念
-
定义:虚拟内存是一种抽象机制,它为每个进程提供了一个独立的、连续的虚拟地址空间,而无需实际占用连续的物理内存。通过虚拟内存,操作系统可以管理比物理内存更大的地址空间,并实现高效的内存管理和保护。
-
访问局部性:虚拟内存的设计基于程序的访问局部性(locality of reference)特征,即程序倾向于访问最近访问过的内存地址或其附近的内存。这种局部性分为两种类型:
- 空间局部性(Spatial Locality):程序倾向于访问在最近访问过的内存地址附近的内存。例如,指令通常是顺序执行的,数据结构也常常按顺序处理。
- 时间局部性(Temporal Locality):程序倾向于在不久的将来再次访问最近刚访问过的内存地址。例如,在循环中,变量和指令会被反复访问。
由于访问局部性,即使程序的整个地址空间没有完全加载到物理内存中,程序仍然可以高效运行。操作系统只需确保当前需要访问的页面驻留在物理内存中即可。
2. 页(Page)与页帧(Page Frame)
-
页:虚拟内存被划分为固定大小的小块,称为“页”(page)。每个页通常包含 4096 字节(4KB),但不同架构可能有不同的页大小。例如,Alpha 架构使用 8192 字节(8KB)的页,IA-64 架构使用可变大小的页,默认为 16384 字节(16KB)。
-
页帧:物理内存(RAM)也被划分为与虚存页相同大小的块,称为“页帧”(page frame)。任一时刻,每个进程的虚拟地址空间中只有部分页驻留在物理内存中,这些页构成了所谓的驻留集(resident set)。
-
交换区(Swap Area):当物理内存不足时,操作系统会将不常用的页面移到磁盘上的交换区(swap area)。交换区作为 RAM 的补充,用于存储暂时不需要的页面。当进程需要访问这些页面时,操作系统会从交换区将它们重新加载到物理内存中。
3. 页面错误(Page Fault)
-
定义:当进程尝试访问一个不在物理内存中的页面时,会发生页面错误(page fault)。此时,操作系统会暂停进程的执行,并将所需的页面从磁盘加载到物理内存中。加载完成后,操作系统恢复进程的执行,继续处理原先的内存访问请求。
-
处理过程:
- 进程尝试访问一个虚拟地址。
- 如果该地址对应的页不在物理内存中,硬件会触发页面错误。
- 操作系统接管并找到该页在磁盘上的位置。
- 操作系统将该页加载到物理内存中,并更新页表。
- 操作系统恢复进程的执行,继续处理原先的内存访问请求。
4. 页表(Page Table)
-
定义:为了支持虚拟内存的管理,内核为每个进程维护了一张页表(page table)。页表描述了每个虚拟页面在进程虚拟地址空间中的位置,并指明该页面是否驻留在物理内存中,以及它在物理内存中的具体位置(页帧)。
-
页表条目:每个页表条目包含以下信息:
- 物理页帧号:如果页面驻留在物理内存中,条目会指向相应的物理页帧。
- 磁盘位置:如果页面不在物理内存中,条目会指向磁盘上的交换区或其他存储位置。
- 访问权限:条目还包含访问控制信息,如页面是否可读、可写或可执行。
-
稀疏页表:在进程的虚拟地址空间中,并非所有地址范围都需要页表条目。对于未使用的地址范围,内核不会为其创建页表条目。如果进程尝试访问一个不存在页表条目的地址,操作系统会生成一个**段错误(SIGSEGV)**信号,终止进程的执行。
5. 虚拟内存的优势
虚拟内存管理带来了许多重要的优点,极大地提高了系统的性能和安全性:
-
进程隔离:每个进程都有自己的虚拟地址空间,且进程之间的内存是完全隔离的。这意味着一个进程不能直接访问或修改另一个进程的内存,增强了系统的安全性和稳定性。
-
内存共享:尽管进程之间是隔离的,但在适当的情况下,多个进程可以共享同一块物理内存。这主要发生在以下场景:
- 共享代码:多个进程可以共享同一份只读的程序代码副本。例如,多个用户同时运行相同的程序(如
bash
)时,它们共享同一个文本段。 - 显式共享内存:进程可以通过
shmget()
和mmap()
系统调用显式地请求与其他进程共享内存区域,用于进程间通信(IPC)。
- 共享代码:多个进程可以共享同一份只读的程序代码副本。例如,多个用户同时运行相同的程序(如
-
内存保护:操作系统可以在页表条目中标记页面的访问权限,指定页面是否可读、可写或可执行。这使得不同进程可以对同一块物理内存采取不同的保护措施。例如,一个进程可以以只读方式访问某页面,而另一个进程可以以读写方式访问同一页面。
-
简化编程模型:程序员和编译器、链接器等工具无需关心程序在物理内存中的具体布局。虚拟内存使得编程更加简单,因为程序员只需要考虑虚拟地址空间,而不必担心物理内存的分配和管理。
-
提高加载和运行效率:由于程序的整个地址空间不必全部加载到物理内存中,程序的加载和运行速度更快。操作系统只需确保当前需要访问的页面驻留在物理内存中即可。此外,虚拟内存允许进程使用的内存(即虚拟内存大小)超出物理内存的容量。
-
增加并发性:由于每个进程使用的物理内存减少了,更多的进程可以同时驻留在物理内存中。这增加了在任一时刻至少有一个进程可以执行的概率,从而提高了 CPU 的利用率。
6. 虚拟内存管理的动态调整
虚拟内存的大小和布局并不是固定的,而是可以在进程的生命周期中动态调整。以下是一些常见的动态调整场景:
-
栈的增长:当函数调用嵌套过深时,栈会向下增长(在大多数体系结构中,栈是从高地址向低地址增长的)。如果栈扩展到之前未使用的地址范围,操作系统会为这些新地址分配页表条目,并将相应的页面加载到物理内存中。
-
堆的扩展:当进程在堆上分配内存时(例如通过
malloc()
或brk()
系统调用),操作系统会调整程序中断点(program break),为新的内存分配创建页表条目。同样,当进程释放堆内存时,操作系统可以回收不再使用的页面。 -
共享内存区:当进程调用
shmat()
系统调用连接 System V 共享内存区时,操作系统会为共享内存区创建页表条目,并将其映射到进程的虚拟地址空间。当进程调用shmdt()
脱离共享内存区时,操作系统会移除相应的页表条目。 -
内存映射文件:当进程调用
mmap()
系统调用创建内存映射时,操作系统会将文件的内容映射到进程的虚拟地址空间,并为映射的页面创建页表条目。当进程调用munmap()
解除内存映射时,操作系统会移除相应的页表条目。
7. 硬件支持:分页内存管理单元(PMMU)
虚拟内存的实现依赖于硬件中的分页内存管理单元(Paging Memory Management Unit, PMMU)。PMMU 的主要功能是将虚拟地址转换为物理地址。当进程尝试访问某个虚拟地址时,PMMU 会根据页表查找该地址对应的物理页帧。如果页面不在物理内存中,PMMU 会触发页面错误,通知内核进行处理。
PMMU 还负责维护快表(Translation Lookaside Buffer, TLB),这是一个高速缓存,用于存储最近使用的虚拟地址到物理地址的映射。通过 TLB,PMMU 可以快速完成地址转换,减少每次内存访问时的开销。
8. 总结
虚拟内存管理是现代操作系统中的一项关键技术,它通过将物理内存与磁盘空间结合使用,提供了更大的灵活性和更高的资源利用率。虚拟内存的主要特点包括:
- 进程隔离:每个进程都有独立的虚拟地址空间,进程之间相互隔离,增强了系统的安全性和稳定性。
- 内存共享:多个进程可以共享同一块物理内存,减少了内存占用。
- 内存保护:操作系统可以对页面设置访问权限,确保内存的安全性。
- 简化编程模型:程序员无需关心物理内存的布局,只需考虑虚拟地址空间。
- 提高加载和运行效率:程序的加载和运行速度更快,且虚拟内存大小可以超出物理内存的容量。
- 增加并发性:更多的进程可以同时驻留在物理内存中,提高了 CPU 的利用率。
通过理解虚拟内存的工作原理,开发人员可以更好地优化程序的内存使用,避免常见的内存管理问题(如栈溢出、内存泄漏等),并充分利用操作系统提供的高级功能(如共享内存、内存映射等)。
5. 栈和栈帧
在计算机系统中,栈(stack) 是一种用于存储函数调用信息的数据结构,它以“后进先出”(LIFO, Last In First Out)的方式管理数据。每当一个函数被调用时,系统会在栈上分配一个新的栈帧(stack frame),用于存储该函数的局部变量、实参和返回地址等信息。当函数执行完毕并返回时,对应的栈帧会被移除,栈的大小也随之减少。
1. 栈的增长和收缩
-
增长方向:在大多数现代操作系统(如 Linux 和 UNIX)中,栈通常从高地址向低地址增长(向下增长)。这意味着栈顶位于较低的内存地址,而栈底位于较高的内存地址。这种设计使得栈可以与向上增长的堆(heap)在内存中相向扩展,从而更有效地利用内存空间。
-
X86-32 架构:在 X86-32 体系架构中,栈从高地址向低地址增长。栈指针寄存器(
ESP
,Extended Stack Pointer)用于跟踪当前栈顶的位置。 -
其他架构:在某些硬件平台上,栈的增长方向可能不同。例如,在 HP PA-RISC 架构中,栈是从低地址向高地址增长的(向上增长)。因此,栈的增长方向是一个实现细节,具体取决于硬件平台。
-
-
栈顶和栈底:尽管栈的实际增长方向可能是向下的,但在抽象层面上,我们仍然将栈的增长端称为栈顶,而将栈的起始位置称为栈底。这种命名方式是为了方便理解和描述栈的操作。
-
栈的动态性:栈是动态增长和收缩的。每次函数调用时,栈会分配一个新的栈帧;每次函数返回时,栈帧会被释放,栈的大小随之减少。然而,在大多数 Linux 实现中,栈的大小并不会随着栈帧的释放而立即减少。相反,释放的栈帧所占用的内存会在后续的函数调用中被重新利用。
2. 栈帧的结构
每个栈帧包含以下几类信息:
-
函数实参:当一个函数被调用时,传递给该函数的参数(实参)会被压入栈中。这些参数通常是通过栈传递的,尤其是在早期的 ABI(应用程序二进制接口)中。现代优化编译器可能会将某些参数通过寄存器传递,以提高性能。
-
局部变量(自动变量):函数内部声明的局部变量(也称为自动变量)会被分配在栈帧中。这些变量的生命周期仅限于函数的执行期间,函数返回时,栈帧被释放,局部变量也随之销毁。这是自动变量与静态变量和全局变量的主要区别:后者与函数执行无关,且长期存在。
-
返回地址:当一个函数调用另一个函数时,程序计数器(PC,Program Counter)指向的下一条指令的地址(即返回地址)会被保存在栈帧中。当被调用函数执行完毕并返回时,返回地址会被恢复,程序继续执行调用者的下一条指令。
-
寄存器保存区:为了确保函数调用不会破坏调用者使用的寄存器值,被调用函数会将某些关键寄存器(如基址指针
EBP
、栈指针ESP
等)的值保存在栈帧中。这样,当函数返回时,可以通过恢复这些寄存器的值来确保调用者的状态不被破坏。 -
调用链信息:栈帧中还可能包含其他与函数调用相关的信息,如异常处理信息、调试信息等。
3. 栈帧的嵌套和递归
由于函数可以嵌套调用,栈中可能会有多个栈帧。例如,假设函数 A
调用了函数 B
,而 B
又调用了函数 C
,那么栈中将会依次出现三个栈帧,分别对应 A
、B
和 C
的调用。
- 递归调用:如果一个函数递归调用自身,那么栈中将会出现多个相同的栈帧。每次递归调用都会在栈上分配一个新的栈帧,直到递归终止条件满足为止。递归调用的深度受限于栈的大小,如果递归过深,可能会导致栈溢出(stack overflow),进而引发程序崩溃。
4. 用户栈 vs 内核栈
-
用户栈:用户栈是每个进程在用户空间中维护的栈,用于存储用户态代码的函数调用信息。用户栈驻留在虚拟内存中,受操作系统的保护机制限制,内核无法直接访问用户栈。
-
内核栈:每个进程在内核空间中也有一个独立的栈,称为内核栈。内核栈用于存储系统调用过程中内核内部函数的调用信息。由于内核栈驻留在受保护的内核内存中,它可以安全地用于内核函数的调用,而不会受到用户态代码的干扰。
- 内核栈的作用:当用户进程执行系统调用时,CPU 会切换到内核模式,并使用内核栈来执行内核函数。内核栈的大小通常较小,但足以处理大多数系统调用。内核栈的设计考虑了性能和安全性,确保内核函数的调用不会影响用户态栈的完整性。
5. 栈帧的示例
参考程序清单 6-1 中的 square()
函数,假设该函数被调用时,栈中包含的栈帧如图 6-3 所示。栈帧的结构如下:
void square(int x) {
int result; // 局部变量,存储在栈帧中
result = x * x;
return result;
}
-
栈帧内容:
- 实参
x
:传递给square()
函数的参数x
被压入栈中。 - 局部变量
result
:square()
函数内部声明的局部变量result
被分配在栈帧中。 - 返回地址:
square()
函数的返回地址被保存在栈帧中,以便函数返回时能够继续执行调用者的下一条指令。 - 寄存器保存区:
square()
函数可能会保存某些寄存器的值,以确保函数返回时调用者的状态不被破坏。
- 实参
-
栈帧的生命周期:当
square()
函数执行完毕并返回时,栈帧中的所有信息(包括局部变量和寄存器保存区)都会被释放,栈的大小也会相应减少。
6. 栈溢出(Stack Overflow)
栈溢出是指栈的大小超过了其分配的空间,导致程序崩溃或行为异常。栈溢出通常发生在以下几种情况下:
-
递归调用过深:如果一个函数递归调用自身,且没有适当的终止条件,栈帧会不断累积,最终超出栈的容量。
-
局部变量过大:如果函数中声明了非常大的局部变量(如大型数组),可能会一次性占用大量栈空间,导致栈溢出。
-
栈大小限制:操作系统通常会对每个进程的栈大小进行限制。如果栈的使用超出了这个限制,操作系统会生成一个段错误(SIGSEGV)信号,终止进程的执行。
为了避免栈溢出,开发人员应尽量避免过深的递归调用,并合理控制局部变量的大小。此外,可以通过调整操作系统的栈大小限制来缓解栈溢出问题。
7. 总结
- 栈 是一种用于存储函数调用信息的数据结构,以 LIFO 方式管理数据。栈通常从高地址向低地址增长(向下增长),栈顶位于较低的内存地址。
- 栈帧 是每次函数调用时在栈上分配的一个内存区域,用于存储函数的实参、局部变量、返回地址和寄存器保存区等信息。
- 栈帧的生命周期 仅限于函数的执行期间,函数返回时栈帧会被释放,局部变量也随之销毁。
- 用户栈 和 内核栈 是两个不同的栈,前者用于用户态代码的函数调用,后者用于内核内部函数的调用。
- 栈溢出 是栈使用超出其分配空间的情况,可能导致程序崩溃。开发人员应避免过深的递归调用和过大的局部变量,以防止栈溢出。
通过理解栈和栈帧的工作原理,开发人员可以更好地优化函数调用和内存使用,避免常见的栈溢出问题,并充分利用操作系统提供的高级功能。
![[Linux-UNIX系统编程手册(上册)_page128_image.png]]
7. 命令行参数(argc
和 argv
)
在 C 语言程序中,main()
函数是程序的入口点。当用户通过命令行启动程序时,操作系统会将命令行参数传递给 main()
函数。这些参数通过两个特殊的参数 argc
和 argv
提供给程序。
1. argc
和 argv
的定义
-
argc
:argc
是一个整数,表示命令行参数的个数(包括程序名)。它的全称是 “argument count”。 -
argv
:argv
是一个指向字符指针数组的指针,每个指针指向一个命令行参数。它的全称是 “argument vector”。argv[0]
通常是指向程序名称的字符串,而argv[1]
到argv[argc-1]
是用户提供的其他参数。argv[argc]
总是指向NULL
,表示参数列表的结束。
2. main()
函数的签名
int main(int argc, char *argv[])
或者等价地:
int main(int argc, char **argv)
argc
:命令行参数的数量。argv
:指向命令行参数的指针数组,每个参数都是以空字符\0
结尾的字符串。
3. 命令行参数的示例
假设我们编写了一个简单的程序 necho
,用于回显命令行参数。当我们执行以下命令时:
./necho hello world
argc
的值为 3,因为有三个参数:"./necho"
、"hello"
和"world"
。argv
的内容如下:argv[0]
指向"./necho"
argv[1]
指向"hello"
argv[2]
指向"world"
argv[3]
为NULL
,表示参数列表的结束。
4. 程序清单 6-2:回显命令行参数
以下是一个简单的 C 程序,它会逐行输出所有命令行参数:
#include <stdio.h>
int main(int argc, char *argv[]) {
for (int i = 0; i < argc; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
运行该程序并传入命令行参数:
./necho hello world
输出结果将是:
argv[0]: ./necho
argv[1]: hello
argv[2]: world
5. 使用 NULL
终止的参数列表
由于 argv[argc]
总是指向 NULL
,因此可以使用 NULL
来遍历参数列表,而不需要依赖 argc
。以下是修改后的代码,它使用 NULL
终止来遍历参数:
#include <stdio.h>
int main(int argc, char *argv[]) {
int i = 0;
while (argv[i] != NULL) {
printf("argv[%d]: %s\n", i, argv[i]);
i++;
}
return 0;
}
这段代码的效果与之前的版本相同,但它不依赖于 argc
,而是通过检查 argv
中的 NULL
来确定参数列表的结束。
6. argv[0]
的用途
-
程序名称:
argv[0]
通常是指向程序名称的字符串。这使得程序可以根据调用时使用的名称来执行不同的操作。例如,gzip
、gunzip
和zcat
实际上是同一个可执行文件的不同链接,它们根据argv[0]
的值来决定执行不同的功能。ln -s /bin/gzip /usr/bin/gunzip ln -s /bin/gzip /usr/bin/zcat
在
gzip
程序中,可以通过检查argv[0]
来决定执行压缩、解压或显示压缩文件的内容:if (strcmp(argv[0], "gzip") == 0) { // 执行压缩操作 } else if (strcmp(argv[0], "gunzip") == 0) { // 执行解压操作 } else if (strcmp(argv[0], "zcat") == 0) { // 显示压缩文件的内容 }
-
处理意外链接:如果用户通过一个不在预期范围内的链接名调用程序,程序应该能够正确处理这种情况。例如,
gzip
可能会打印一条错误消息,提示用户使用正确的命令。
7. 传递命令行参数给其他函数
argc
和 argv
只在 main()
函数中直接可用。如果其他函数需要访问命令行参数,可以通过以下几种方式实现:
-
传递参数:将
argc
和argv
作为参数传递给其他函数。void process_arguments(int argc, char *argv[]) { // 处理命令行参数 } int main(int argc, char *argv[]) { process_arguments(argc, argv); return 0; }
-
使用全局变量:将
argv
或argc
存储在一个全局变量中,以便其他函数可以访问。extern char **environ; char **global_argv; int global_argc; int main(int argc, char *argv[]) { global_argv = argv; global_argc = argc; return 0; }
注意:使用全局变量可能会降低代码的可维护性和可移植性,因此应谨慎使用。
8. 从程序内部访问命令行参数
除了通过 argc
和 argv
访问命令行参数外,还有两种方法可以在程序的任意位置访问这些信息,但这会破坏程序的可移植性:
-
/proc/PID/cmdline
文件:在 Linux 系统中,可以通过读取/proc/PID/cmdline
文件来获取进程的命令行参数。PID
是进程的进程号,可以通过getpid()
系统调用来获取当前进程的 PID。#include <stdio.h> #include <unistd.h> void print_cmdline() { char path[64]; snprintf(path, sizeof(path), "/proc/%d/cmdline", getpid()); FILE *fp = fopen(path, "r"); if (fp) { char buffer[1024]; size_t len = fread(buffer, 1, sizeof(buffer) - 1, fp); buffer[len] = '\0'; fclose(fp); printf("Command line: %s\n", buffer); } } int main(int argc, char *argv[]) { print_cmdline(); return 0; }
注意:
/proc/PID/cmdline
文件中的参数是以空字符\0
分隔的,而不是空格。因此,读取时需要注意这一点。 -
environ
全局变量:environ
是一个指向环境变量的全局指针数组。虽然它主要用于访问环境变量,但在某些系统上也可以通过environ
访问命令行参数。然而,这种方法并不标准,且不同系统的行为可能有所不同。extern char **environ; int main(int argc, char *argv[]) { for (int i = 0; environ[i] != NULL; i++) { printf("Environment variable: %s\n", environ[i]); } return 0; }
注意:
environ
主要用于环境变量,而不是命令行参数。因此,使用它来访问命令行参数并不是推荐的做法。
9. 总结
argc
和argv
是 C 语言程序中用于接收命令行参数的两个特殊参数。argc
表示参数的数量,argv
是一个指向字符指针数组的指针,每个指针指向一个命令行参数。argv[0]
通常是指向程序名称的字符串,可以用于根据调用时的程序名称执行不同的操作。- 命令行参数的传递:可以通过将
argc
和argv
作为参数传递给其他函数,或者使用全局变量来使其他函数访问这些参数。 - 从程序内部访问命令行参数:在 Linux 系统中,可以通过读取
/proc/PID/cmdline
文件来获取进程的命令行参数,但这种方法会破坏程序的可移植性。
通过理解 argc
和 argv
的工作原理,开发人员可以编写更加灵活和功能丰富的命令行工具,并根据用户输入的参数执行不同的操作。
8. 环境列表
在每个进程的上下文中,环境列表(environment list)是一个字符串数组,通常简称为环境(environment)。每个字符串都以 name=value
的形式定义,表示一个环境变量。环境变量是“名称-值”对的集合,可以存储任何信息。新进程在创建时会继承其父进程的环境副本,这是一种简单的进程间通信方式,尽管它是一次性的、单向的。
1. 环境变量的作用
环境变量在多个方面发挥着重要作用:
-
Shell 配置:Shell 使用环境变量来配置其行为,并将这些变量传递给它启动的子进程。例如,
SHELL
环境变量指定了当前使用的 Shell 程序的路径。 -
库函数行为控制:通过设置某些环境变量,用户可以在不修改程序代码或重新链接库的情况下,改变库函数的行为。例如,
POSIXLY_CORRECT
环境变量可以改变getopt()
函数的行为(见附录 B)。 -
应用程序配置:许多应用程序依赖环境变量来获取配置信息,如数据库连接字符串、日志文件路径等。
-
系统配置:环境变量还可以用于配置系统的全局行为,如
PATH
环境变量决定了命令搜索的路径。
2. 环境变量的继承
当一个新进程通过 fork()
创建时,子进程会继承父进程的环境副本。这意味着子进程在创建时会获得与父进程相同的环境变量。然而,父进程和子进程在创建后都可以独立地修改各自的环境变量,而这些修改不会影响对方。
3. 设置和管理环境变量
3.1 在 Shell 中设置环境变量
大多数 Shell 提供了 export
命令来将变量添加到环境中。例如,在 Bourne shell (sh
)、Bash 和 Korn shell 中,可以使用以下命令设置环境变量:
export NAME=value
在 C shell (csh
) 和 Tcsh 中,使用 setenv
命令:
setenv NAME value
3.2 临时设置环境变量
如果只想为某个特定命令设置环境变量,而不影响当前 Shell 或后续命令,可以在命令前直接指定变量赋值:
NAME=value command
例如:
EDITOR=vim nano /path/to/file
这只会为 nano
命令设置 EDITOR
环境变量,而不会影响当前 Shell 或其他命令。
3.3 使用 env
命令
env
命令允许在运行程序时修改环境变量。它可以添加、删除或覆盖环境变量。例如:
env NEW_VAR=value existing_var=modified_value command
这会在 command
运行时创建一个新的环境变量 NEW_VAR
,并修改现有的 existing_var
。
3.4 查看环境变量
printenv
命令用于显示当前环境变量:
printenv
或者只显示特定的环境变量:
printenv PATH
4. 从程序中访问环境变量
在 C 语言程序中,可以通过以下几种方式访问环境变量:
4.1 使用 environ
全局变量
environ
是一个指向字符指针数组的全局变量,定义在 C 运行时启动代码中。它指向当前进程的环境列表。每个指针指向一个以空字符 \0
结尾的字符串,格式为 name=value
。environ
数组以 NULL
结尾。
extern char **environ;
int main() {
for (char **env = environ; *env != NULL; env++) {
printf("%s\n", *env);
}
return 0;
}
4.2 使用 main()
函数的第三个参数
main()
函数可以接受第三个参数 char *envp[]
,它也是一个指向字符指针数组的指针,类似于 environ
。不过,envp
只在 main()
函数的作用域内可用,且该特性不在 SUSv3 标准中定义,因此不推荐使用。
int main(int argc, char *argv[], char *envp[]) {
for (int i = 0; envp[i] != NULL; i++) {
printf("%s\n", envp[i]);
}
return 0;
}
4.3 使用 getenv()
函数
getenv()
函数用于从环境中检索单个环境变量的值。它返回一个指向该变量值的字符串指针,如果变量不存在,则返回 NULL
。
#include <stdlib.h>
int main() {
const char *shell = getenv("SHELL");
if (shell != NULL) {
printf("Shell: %s\n", shell);
} else {
printf("SHELL environment variable not set.\n");
}
return 0;
}
注意事项:
- 不可修改返回的字符串:
getenv()
返回的字符串是指向环境变量的直接引用,因此不应修改它。如果需要修改环境变量的值,应使用setenv()
或putenv()
。 - 静态缓冲区问题:某些实现中,
getenv()
可能使用静态缓冲区返回结果,后续对getenv()
、setenv()
、putenv()
或unsetenv()
的调用可能会重写该缓冲区。因此,如果需要保留getenv()
返回的字符串,应先将其复制到其他位置。
5. 修改环境变量
有时,程序需要修改其环境变量,以便这些修改对其后续创建的子进程可见。常见的修改操作包括添加新变量、修改现有变量的值或删除变量。
5.1 使用 putenv()
函数
putenv()
函数用于向环境中添加一个新变量或修改现有变量的值。它接受一个指向 name=value
形式的字符串的指针,并将该字符串直接添加到环境中。注意,putenv()
不会复制字符串,而是直接使用传入的指针,因此不应使用自动变量作为参数。
#include <stdlib.h>
int main() {
char *new_var = "MY_VAR=new_value";
if (putenv(new_var) != 0) {
perror("putenv");
return 1;
}
return 0;
}
注意事项:
- 避免使用自动变量:
putenv()
将传入的字符串直接添加到环境中,因此不应使用栈上的自动变量作为参数,因为函数返回后,栈上的内存可能会被重用。 - 非标准扩展:如果
putenv()
的参数不包含等号(=
),则会从环境中移除以该名称命名的变量(这是 glibc 库的一个非标准扩展)。
5.2 使用 setenv()
函数
setenv()
函数用于向环境中添加一个新变量或修改现有变量的值。它会为 name=value
形式的字符串分配新的内存,并将 name
和 value
复制到该内存中。因此,setenv()
不会直接使用传入的指针,而是创建副本,这使得可以安全地使用自动变量作为参数。
#include <stdlib.h>
int main() {
if (setenv("MY_VAR", "new_value", 1) != 0) {
perror("setenv");
return 1;
}
return 0;
}
- 参数说明:
name
:环境变量的名称。value
:环境变量的值。overwrite
:如果为1
,则覆盖现有变量的值;如果为0
,则仅在变量不存在时添加。
5.3 使用 unsetenv()
函数
unsetenv()
函数用于从环境中移除指定的环境变量。
#include <stdlib.h>
int main() {
if (unsetenv("MY_VAR") != 0) {
perror("unsetenv");
return 1;
}
return 0;
}
注意事项:
- 返回值:
unsetenv()
在成功时返回0
,失败时返回非零值(而不是-1
)。 - 早期实现:在 glibc 2.2.2 之前的版本中,
unsetenv()
的返回类型为void
,这与最初的 BSD 实现相同。一些 UNIX 实现仍然使用这种原型。
5.4 清除整个环境
有时需要清除整个环境,然后重建。例如,在执行 set-user-ID
程序时,为了安全起见,可能需要清除继承自父进程的环境变量。可以通过将 environ
设置为 NULL
来清除环境,但这不是标准做法。
extern char **environ;
void clear_environment() {
environ = NULL;
}
更推荐的做法是使用 unsetenv()
逐一移除所有环境变量:
#include <stdlib.h>
#include <string.h>
void clear_environment() {
extern char **environ;
for (char **env = environ; *env != NULL; env++) {
char *name = *env;
char *equals = strchr(name, '=');
if (equals != NULL) {
*equals = '\0'; // 暂时截断 name=value 字符串
unsetenv(name);
*equals = '='; // 恢复原字符串
}
}
}
6. 总结
- 环境变量 是一种“名称-值”对的集合,用于存储配置信息,并在进程之间传递。
- 新进程继承父进程的环境副本,但父进程和子进程可以独立修改各自的环境变量。
- 设置环境变量 可以通过
export
或setenv
命令在 Shell 中完成,也可以通过putenv()
和setenv()
函数在程序中完成。 - 访问环境变量 可以通过
environ
全局变量、main()
函数的第三个参数或getenv()
函数实现。 - 修改环境变量 可以使用
putenv()
、setenv()
和unsetenv()
函数。 - 清除环境 可以通过将
environ
设置为NULL
或逐一调用unsetenv()
来实现。
通过理解和正确使用环境变量,开发人员可以编写更加灵活和可配置的程序,并确保程序能够在不同的环境中正确运行。
8.1 :修改进程环境 (modify_env.c
)
这个程序展示了如何使用 clearenv()
、putenv()
和 setenv()
函数来修改当前进程的环境变量。程序首先清除整个环境,然后根据命令行参数添加新的环境变量,接着设置一个名为 GREET
的环境变量,并尝试移除一个名为 BYE
的环境变量(即使该变量可能不存在)。最后,程序遍历并打印当前的环境变量。
代码解析
#define _GNU_SOURCE /* To get various declarations from <stdlib.h> */
#include <stdlib.h>
#include "tlpi_hdr.h" /* Custom header file for error handling */
extern char **environ; /* Global variable pointing to the environment list */
int
main(int argc, char *argv[])
{
int j;
char **ep;
clearenv(); /* Erase entire environment */
/* Add new environment variables from command-line arguments */
for (j = 1; j < argc; j++) {
if (putenv(argv[j]) != 0) /* Add or modify environment variable */
errExit("putenv: %s", argv[j]);
}
/* Set the GREET environment variable */
if (setenv("GREET", "Hello world", 0) == -1)
errExit("setenv");
/* Attempt to remove the BYE environment variable */
unsetenv("BYE");
/* Print all environment variables */
for (ep = environ; *ep != NULL; ep++)
puts(*ep);
exit(EXIT_SUCCESS);
}
详细说明
-
头文件和宏定义
#define _GNU_SOURCE
:这个宏定义是为了确保<stdlib.h>
中包含clearenv()
和其他 GNU 扩展函数的声明。#include <stdlib.h>
:标准库头文件,包含了环境变量管理函数的声明。#include "tlpi_hdr.h"
:这是一个自定义的头文件,通常用于提供一些方便的错误处理函数(如errExit()
),以便在发生错误时能够更清晰地输出错误信息。
-
全局变量
environ
extern char **environ;
:environ
是一个指向字符指针数组的全局变量,每个指针指向一个以name=value
形式表示的环境变量字符串。environ
数组以NULL
结尾。
-
清除环境
clearenv();
:调用clearenv()
函数清除当前进程的所有环境变量。这会将environ
设置为NULL
,从而清空整个环境列表。
-
添加或修改环境变量
for (j = 1; j < argc; j++)
:遍历命令行参数(从argv[1]
开始,因为argv[0]
是程序名称)。if (putenv(argv[j]) != 0)
:使用putenv()
函数将命令行参数中的每个字符串作为环境变量添加到环境中。putenv()
接受一个name=value
形式的字符串,并将其直接添加到环境中。如果操作失败,调用errExit()
输出错误信息并退出程序。
-
设置
GREET
环境变量if (setenv("GREET", "Hello world", 0) == -1)
:使用setenv()
函数设置一个名为GREET
的环境变量,其值为"Hello world"
。第三个参数0
表示如果GREET
已经存在,则不覆盖其值。如果操作失败,调用errExit()
输出错误信息并退出程序。
-
移除
BYE
环境变量unsetenv("BYE");
:尝试移除名为BYE
的环境变量。如果该变量不存在,unsetenv()
也不会报错。即使BYE
不存在,程序也会继续执行。
-
打印所有环境变量
for (ep = environ; *ep != NULL; ep++)
:遍历environ
数组,逐个打印每个环境变量。puts(*ep);
用于输出每个name=value
形式的字符串。
-
退出程序
exit(EXIT_SUCCESS);
:正常退出程序,返回状态码0
,表示程序成功执行。
示例运行
假设我们编译并运行这个程序,传入一些环境变量作为命令行参数:
./modify_env PATH=/usr/bin HOME=/home/user
程序的输出可能类似于以下内容:
PATH=/usr/bin
HOME=/home/user
GREET=Hello world
关键点总结
clearenv()
:清除整个环境变量列表,将environ
设置为NULL
。putenv()
:添加或修改环境变量。它接受一个name=value
形式的字符串,并将其直接添加到环境中。注意,putenv()
不会复制字符串,因此不应使用自动变量作为参数。setenv()
:添加或修改环境变量。它为name=value
形式的字符串分配新的内存,并将name
和value
复制到该内存中。可以安全地使用自动变量作为参数。unsetenv()
:移除指定的环境变量。如果该变量不存在,unsetenv()
也不会报错。environ
:全局变量,指向当前进程的环境变量列表。每个元素是一个指向name=value
形式的字符串的指针。
通过这个程序,您可以更好地理解如何在 C 语言中管理和修改进程的环境变量。
9. 执行非局部跳转:setjmp()
和 longjmp()
setjmp()
和 longjmp()
是 C 语言标准库中用于执行非局部跳转(nonlocal goto)的函数。它们允许程序从一个函数跳转到另一个函数,甚至可以跳回到调用栈中更早的位置。这种机制在处理错误或异常时非常有用,尤其是在深度嵌套的函数调用中,可以避免逐层返回的繁琐操作。
1. 非局部跳转的概念
- 局部跳转:C 语言中的
goto
语句只能在同一函数内进行跳转,不能跨越函数边界。 - 非局部跳转:
setjmp()
和longjmp()
提供了跨越函数边界的跳转能力,可以从一个函数跳转到另一个函数,甚至是跳回到调用栈中更早的位置。
2. setjmp()
和 longjmp()
的工作原理
2.1 setjmp()
函数
-
原型:
int setjmp(jmp_buf env);
-
功能:
setjmp()
保存当前程序的执行环境(包括寄存器状态、栈指针等)到env
中,并返回0
。- 如果后续通过
longjmp()
跳转回这个setjmp()
调用点,setjmp()
会返回传给longjmp()
的值(如果该值为0
,则返回1
)。
-
参数:
jmp_buf env
:一个结构体,用于保存当前的执行环境。jmp_buf
是一个不透明的类型,用户不需要了解其内部结构。
2.2 longjmp()
函数
-
原型:
void longjmp(jmp_buf env, int val);
-
功能:
longjmp()
根据env
中保存的执行环境,恢复程序的状态,使程序从setjmp()
的调用点继续执行。val
是传递给setjmp()
的返回值。如果val
为0
,setjmp()
实际上会返回1
,以避免与初始调用混淆。
-
参数:
jmp_buf env
:由setjmp()
保存的执行环境。int val
:传递给setjmp()
的返回值。
3. 使用场景
setjmp()
和 longjmp()
主要用于以下场景:
- 错误处理:在深度嵌套的函数调用中,当发生错误时,可以直接跳回到错误处理代码,而不需要逐层返回。
- 异常处理:类似于其他编程语言中的异常处理机制,
setjmp()
和longjmp()
可以用来实现类似的功能。 - 信号处理:在信号处理器中,
sigsetjmp()
和siglongjmp()
(setjmp()
和longjmp()
的变体)可以用于处理信号并恢复程序的正常执行。
4. 示例程序
下面是一个简单的示例程序,展示了 setjmp()
和 longjmp()
的用法。程序通过 setjmp()
建立一个跳转目标,然后根据命令行参数决定是否调用 longjmp()
来跳回 setjmp()
的调用点。
#include <setjmp.h>
#include "tlpi_hdr.h"
static jmp_buf env;
static void f2(void) {
printf("In f2(), calling longjmp(env, 2)\n");
longjmp(env, 2); /* Jump back to setjmp() call in main() */
}
static void f1(int argc) {
if (argc == 1) {
printf("In f1(), calling longjmp(env, 1)\n");
longjmp(env, 1); /* Jump back to setjmp() call in main() */
} else {
f2(); /* This will call longjmp(env, 2) */
}
}
int main(int argc, char *argv[]) {
switch (setjmp(env)) {
case 0:
printf("Calling f1() after initial setjmp()\n");
f1(argc); /* Never returns... */
break;
case 1:
printf("We jumped back from f1()\n");
break;
case 2:
printf("We jumped back from f2()\n");
break;
default:
printf("Unexpected return value from setjmp()\n");
break;
}
exit(EXIT_SUCCESS);
}
5. 程序运行示例
-
不带命令行参数:
$ ./program Calling f1() after initial setjmp() In f1(), calling longjmp(env, 1) We jumped back from f1()
-
带命令行参数:
$ ./program arg Calling f1() after initial setjmp() In f2(), calling longjmp(env, 2) We jumped back from f2()
6. setjmp()
的使用限制
SUSv3 和 C99 标准对 setjmp()
的使用有严格限制,以确保程序的正确性和可移植性。setjmp()
只能在以下几种情况下使用:
- 作为选择或迭代语句的控制表达式(如
if
、switch
、while
等)。 - 作为一元操作符
!
的操作对象,且最终表达式构成选择或迭代语句的控制表达式。 - 作为比较操作的一部分(如
==
、!=
、<
等),另一操作对象必须是整数常量表达式,且最终表达式构成选择或迭代语句的控制表达式。 - 作为独立的函数调用,且没有嵌入到更大的表达式中。
禁止将 setjmp()
用于赋值语句或其他复杂表达式中,因为这可能导致无法正确保存和恢复执行环境。
7. 滥用 longjmp()
的问题
-
跳转到已返回的函数:如果
longjmp()
跳转到一个已经返回的函数中,会导致栈帧被破坏,程序行为未定义。例如,调用setjmp()
后返回,再从其他地方调用longjmp()
,可能会导致程序崩溃或进入死循环。 -
多线程环境:在多线程程序中,
setjmp()
和longjmp()
不能跨线程使用。即在一个线程中调用setjmp()
,而在另一个线程中调用longjmp()
,这是未定义行为。 -
信号处理器中的使用:在嵌套的信号处理器中调用
longjmp()
也是未定义行为。信号处理器可能在不同的上下文中被调用,longjmp()
无法正确恢复之前的执行环境。
8. 优化编译器的问题
优化编译器可能会对代码进行重组,将某些变量存储在 CPU 寄存器中,而不是内存中。这种优化依赖于程序的词法结构,而 setjmp()
和 longjmp()
的跳转操作是在运行时动态确立的,编译器无法预测这些跳转的发生。因此,优化后的代码可能会导致变量的值不正确。
为了防止这种情况,应该将所有可能受影响的局部变量声明为 volatile
,告诉编译器不要对其进行优化。volatile
关键字确保变量的每次读取和写入都直接访问内存,而不是使用寄存器中的缓存值。
9. 示例:优化编译器的影响
以下程序展示了优化编译器如何影响 setjmp()
和 longjmp()
的行为:
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>
static jmp_buf env;
static void doJump(int nvar, int rvar, int vvar) {
printf("Inside doJump(): nvar=%d rvar=%d vvar=%d\n", nvar, rvar, vvar);
longjmp(env, 1);
}
int main(int argc, char *argv[]) {
int nvar;
register int rvar; /* May be allocated in a register */
volatile int vvar; /* Prevent optimization */
nvar = 111;
rvar = 222;
vvar = 333;
if (setjmp(env) == 0) {
nvar = 777;
rvar = 888;
vvar = 999;
doJump(nvar, rvar, vvar);
} else {
printf("After longjmp(): nvar=%d rvar=%d vvar=%d\n", nvar, rvar, vvar);
}
exit(EXIT_SUCCESS);
}
-
常规编译:
$ cc -o setjmp_vars setjmp_vars.c $ ./setjmp_vars Inside doJump(): nvar=777 rvar=888 vvar=999 After longjmp(): nvar=777 rvar=888 vvar=999
-
优化编译:
$ cc -O2 -o setjmp_vars setjmp_vars.c $ ./setjmp_vars Inside doJump(): nvar=777 rvar=888 vvar=999 After longjmp(): nvar=111 rvar=222 vvar=999
在这个例子中,优化编译器将 nvar
和 rvar
存储在寄存器中,导致 longjmp()
后这些变量的值被重置为 setjmp()
初次调用时的值。而 vvar
被声明为 volatile
,因此它的值保持正确。
10. 编译器警告
使用 -Wextra
选项编译时,编译器会发出警告,提示某些变量可能会被 longjmp()
破坏:
$ cc -Wall -Wextra -O2 -o setjmp_vars setjmp_vars.c
setjmp_vars.c: In function 'main':
setjmp_vars.c:17: warning: variable 'nvar' might be clobbered by 'longjmp' or 'vfork'
setjmp_vars.c:18: warning: variable 'rvar' might be clobbered by 'longjmp' or 'vfork'
11. 最佳实践
-
尽量避免使用
setjmp()
和longjmp()
:虽然它们提供了强大的非局部跳转功能,但会使程序难以阅读和维护。通常可以通过返回错误码或使用异常处理机制来替代。 -
谨慎使用:如果确实需要使用
setjmp()
和longjmp()
,请确保:- 不要跳转到已经返回的函数中。
- 不要在多线程环境中跨线程使用。
- 在信号处理器中使用时要特别小心。
- 将所有可能受影响的局部变量声明为
volatile
,以防止优化编译器的干扰。
-
考虑使用更现代的异常处理机制:许多现代编程语言提供了更安全、更易用的异常处理机制(如 C++ 的
try-catch
或 Python 的try-except
),建议优先考虑这些机制。
12. 总结
setjmp()
和 longjmp()
提供了一种强大的非局部跳转机制,适用于某些特定的错误处理和异常处理场景。然而,由于其复杂性和潜在的风险,应当谨慎使用。在设计和编码时,尽量避免使用这些函数,除非确实有必要。