进程(2):环境变量与进程地址空间
命令行参数
命令行参数是用户在命令行界面执行可执行程序 / 系统命令时,紧跟在程序名之后输入的字符串序列。
C语言程序想要接收命令行参数,必须使用 main 函数的完整标准原型:
int main(int argc, char *argv[])
| 参数名 | 全称 | 含义 |
|---|---|---|
| argc | argument count | 命令行参数的总个数(整数) |
| argv | argument vector | 命令行参数数组(字符串数组) |
规则
argv[0]固定为程序名 / 程序路径argv[1] ~ argv[argc-1]:用户输入的真正参数argv[argc]:固定为 NULL(数组结束标记)argc最小值 = 1
示例:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
for(int i = 0; i < argc; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
总结:进程拥有一张argv表(命令行参数表),用来支持实现选项功能。
- 内核加载程序时,会将命令行参数拷贝到进程内存,为进程自动创建 argv 表;
- 每个进程都有独立的 argv 表,互不干扰、互不共享;
- 程序通过
main(argc, argv)直接访问这张表。
环境变量
基本概念
- 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
- 格式固定:
大写变量名=字符串值 - 本质:进程的全局配置表
核心特性
- 系统中每一个进程都有自己独立的环境变量表,进程之间环境变量相互隔离。
- 父子进程默认继承:父进程通过
fork()创建子进程时,子进程会完整复制父进程的环境变量表 - 子进程修改自己的环境变量 不会影响父进程,父进程修改也不会影响已经创建好的子进程
核心作用
- 提供命令搜索路径(核心
PATH),让系统不用写全路径就能执行命令 - 保存用户基本信息:家目录、用户名、当前工作目录
- 配置系统运行环境:语言编码、终端类型、动态库路径
- 为应用程序提供全局运行配置,无需修改代码即可改变程序行为
常见的环境变量
PATH:命令搜索路径,多个路径用:分隔HOME:当前用户家目录USER:当前登录用户名PWD:当前所在工作目录OLDPWD上一次所在目录,cd -就是读取它SHELL:当前默认命令行解释器(一般/bin/bash)HISTSIZE:内存中最多能保存多少条历史命令记录LD_LIBRARY_PATH:动态链接库搜索路径,程序运行时找依赖.so 库
Linux 环境变量 常用操作命令
- 查看环境变量
env # 查看所有环境变量
echo $PATH # 查看单个环境变量
echo $HOME
- 临时添加环境变量(只对当前这个终端窗口有效,关闭终端 → 变量立刻消失)
# 定义新环境变量
export 变量名="变量值"
# 最常用:给 PATH 追加路径(**千万不要直接覆盖,要追加!**)
export PATH=$PATH:新路径
- 取消环境变量
unset 变量名
unset TEST
- 永久生效(加载配置文件)
修改完/etc/profile(系统全局永久添加) 或~/.bashrc(当前用户永久添加)后执行:
source ~/.bashrc
环境变量 底层存储结构

- 每个进程内部都有一张环境变量表,底层是字符串数组:
char *environ[]; - 数组中每一项都是
NAME=VALUE格式字符串 - 环境表末尾以
NULL标记结束 - 和命令行参数表
argv[]并列,都是进程启动时由内核传入
补充:C 语言 main 完整原型:
int main(int argc, char *argv[], char *env[]);
第三个参数就是当前进程的环境变量表。
代码获取 Linux 环境变量
1.通过 main 函数第三个参数
示例:
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
(void)argc;
(void)argv;
for(int i = 0; env[i]; i++)
{
printf("env[%d]-> %s\n", i, env[i]);
}
return 0;
}
2.通过标准库函数 getenv()(推荐使用)
- 需包含头文件:
#include <stdlib.h> - 函数原型:
char *getenv(const char *name); - 功能:根据变量名,精准获取对应环境变量的值
示例:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[], char *env[])
{
(void)argc;
(void)argv;
(void)env;
char *value = getenv("PATH");
if(value == NULL)
return 1;
printf("PATH->%s\n", value);
return 0;
}
3.通过全局变量 environ
直接访问进程全局环境变量表
- 需包含头文件:
#include <unistd.h> - 全局变量声明:
extern char **environ; - 本质:
environ是一个指针,直接指向进程的环境表数组
示例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
extern char **environ;
int main(int argc, char *argv[])
{
(void)argc;
(void)argv;
for(int i = 0; environ[i]; i++)
{
printf("environ[%d]-> %s\n", i, environ[i]);
}
return 0;
}
补充:set 显示当前 Shell 里的所有变量,包含:环境变量(export 导出的,子进程可继承)、Shell 局部变量(没加 export,仅当前 Shell 有效)、Shell 自定义函数
Shell 本地变量
Shell 本地变量(也叫局部变量、自定义 Shell 变量):在 当前 Shell 终端 中直接定义、不使用 export 导出 的变量。
语法:变量名=变量值
核心特性
- 只对当前这一个终端 Shell 有效
- 通过
fork创建的子进程、子 Shell 无法获取本地变量 - 关闭当前终端、退出 Shell,本地变量直接消失
核心功能
- 终端临时存数据:在命令行临时记录路径、文件名、数字、字符串
- Shell 脚本内部做中间变量
等等
进程地址空间
虚拟地址
来段代码感受一下:
#include <stdio.h>
#include <unistd.h>
int gval = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("子:gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
sleep(1);
gval++;
}
}
else
{
while(1)
{
printf("父:gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
sleep(1);
}
}
return 0;
}
运行结果:
先看运行现象
- 父子进程打印的
&gval地址完全相同 - 子进程的 gval 会不断自增,父进程的 gval 永远是 100
- 地址一样,值却不一样,违背常识,但完全符合 Linux 设计
核心答案
- 地址一样:我们打印的是 虚拟地址,不是物理地址。父子进程拥有独立但布局完全相同的虚拟地址空间,所以虚拟地址一致
- 值不一样:
fork采用 写时拷贝 机制,子进程修改变量时,操作系统会重新分配物理内存,父子进程最终映射到不同的物理地址,数据相互隔离。 - 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
进程地址空间布局

用户空间各区域详解(从低到高)
| 区域 | 存储内容 |
|---|---|
正文代码段(.text段) |
程序编译后的机器指令(可执行代码)、只读的字符串常量(如"hello world") |
初始化数据段(.data段) |
已初始化、且值不为 0 的全局变量和静态变量 |
未初始化数据段(.bss段) |
未初始化、或初始化为 0 的全局变量和静态变量 |
| 堆(Heap) | 程序运行中通过malloc/calloc/new动态申请的内存 |
| 共享区(mmap 映射区) | 动态链接库(.so文件)、共享内存、文件映射(mmap系统调用) |
| 栈(Stack) | 局部变量、函数参数、函数返回地址、栈帧(函数调用的上下文) |
| 命令行参数与环境变量区 | 进程的命令行参数(argv[])和环境变量表(env[]) |
- 堆向上、栈向下生长,中间的共享区作为弹性缓冲,避免堆和栈扩展时直接碰撞,同时为动态库 / 文件映射预留空间,最大化利用地址空间。
- 每个进程的虚拟地址空间布局(代码段、数据段、堆、栈的地址范围)完全相同
一个进程,一个虚拟地址空间

32 位系统虚拟地址空间整体划分
32 位寻址总范围:2 ^ 32 = 4GB
固定分成两大区域:
- 用户空间:0x00000000 ~ 0xBFFFFFFF 占 3GB
- 每个进程私有
- 存放程序代码、全局变量、栈、堆、环境变量等
- 内核空间:0xC0000000 ~ 0xFFFFFFFF 占 1GB
- 所有进程共享同一份
- 存储内容:操作系统内核代码、页表、驱动程序、内核栈、内核数据结构(如
task_struct) - 用户态进程不能直接访问
一个进程,一套页表

- 页表 是操作系统给进程准备的 「虚拟地址 → 物理地址」的映射对照表
- 物理地址是内存条硬件本身真实的地址编号,是内存单元的唯一硬件编号。
- 虚拟地址是操作系统为每个进程虚构、分配的逻辑地址
虚拟地址 与 物理地址的映射
- 进程在自己的进程地址空间里发出虚拟地址
- CPU 的 MMU 硬件,查当前进程的页表
- 把虚拟地址翻译成物理地址
- 访问真实内存条
上面父子进程代码的最终解释
现象:虚拟地址相同,变量值不同
根本原因:
- 虚拟地址
&gval一样 - 父子各有独立进程地址空间、各有一套独立页表
- 初始页表映射同一物理页
- 子进程修改
gval++→ 触发写时拷贝 COW ,内核修改子进程自己的页表,映射到新物理页。 - 父进程页表不变,仍指向原物理页
同虚拟地址 + 两套不同页表 → 映射不同物理内存 → 值不一样
虚拟内存管理
- 虚拟地址空间(进程地址空间)在内核中的本质,就是一个数据结构:
struct mm_struct struct mm_struct是 Linux 内核专门用于描述、管理一个进程完整虚拟地址空间 的核心数据结构- 每一个进程都会有自己独立的
mm_struct
「归属关系」(内核底层绑定)
内核用 task_struct 描述进程,里面有一个指针:
struct task_struct {
// ... 进程PID、状态、优先级等
struct mm_struct *mm; // 指向进程专属的虚拟地址空间管理结构体
};
简化版 struct mm_struct 源码
struct mm_struct {
// 1. 虚拟地址空间的各个区域(代码段、数据段、堆、栈、映射区)
struct vm_area_struct *mmap; // 虚拟内存区域链表:管理代码段、数据段、堆、栈、动态库等所有虚拟内存区域
struct rb_root mm_rb; // 红黑树:快速查找虚拟地址属于哪个区域
// 2. 页表(最核心!虚拟地址 → 物理地址的映射表)
pgd_t *pgd; // 页全局目录(一套页表的起始地址)
// 3. 虚拟地址空间的边界(32位:0 ~ 0xBFFFFFFF)
unsigned long start_code, end_code; // 代码段起止虚拟地址
unsigned long start_data, end_data; // 数据段起止虚拟地址
unsigned long start_brk, brk; // 堆区起止虚拟地址
unsigned long start_stack; // 栈区起始虚拟地址
// 4. 其他:统计、权限、共享标记...
};
vm_area_struct *mmap + mm_rb管理进程虚拟地址空间里的所有分段:代码段、数据段、BSS、堆、栈、动态库映射区。
→ 内核靠它知道:哪个虚拟地址属于哪个区域。pgd_t *pgd(页表指针)指向进程专属页表的起始地址- 各段虚拟地址边界(start_code /brk/start_stack) 记录32 位 4GB 虚拟空间的固定布局:代码段在哪、数据段在哪、堆从哪开始、栈从哪开始。
由 task_struct 到 mm_struct,进程地址空间的分布情况:
vm_area_struct(VMA)
vm_area_struct(VMA)是 Linux 内核描述进程一段连续虚拟内存区域的结构体
层级从属关系
task_struct
↓
mm_struct 整个虚拟地址空间
↓
多个 vm_area_struct(VMA)
每一个 VMA 对应一段:代码段 / 数据段 / 堆 / 栈 / 动态库
- 一个进程只有 1 个
mm_struct,一个mm_struct里有 若干个vm_area_struct - 进程的代码段、数据段、堆、栈、动态库,每一段都是一个独立 VMA
精简版内核源码
struct vm_area_struct {
// 1. 该虚拟区域的 起始、结束 虚拟地址
unsigned long vm_start;
unsigned long vm_end;
// 2. 链表节点:把所有 VMA 串成双向链表
struct vm_area_struct *vm_next, *vm_prev;
// 3. 红黑树节点:用于快速查找
struct rb_node vm_rb;
// 4. 内存权限:可读、可写、可执行、私有/共享
unsigned long vm_flags;
// 5. 所属的虚拟地址空间(归属哪个 mm_struct)
struct mm_struct *vm_mm;
// 6. 文件映射偏移(映射文件、动态库时用)
unsigned long vm_pgoff;
};
对上图再进行更细致的描述:

mm_struct 如何管理所有 VMAstruct mm_struct 里有两个成员:
struct mm_struct {
struct vm_area_struct *mmap; // VMA 双向链表头
struct rb_root mm_rb; // VMA 红黑树根
};
两种管理方式:
1.双向链表:从头到尾遍历所有内存区域
2.红黑树:根据虚拟地址快速查找所属区域、检查权限
为什么需要虚拟地址空间
- 因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。
- 地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也一定要在OS的监管之下来进行访问!!也顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!
- 因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)