Liunx 操作系统 进程的概念(下)
一.命令行参数和环境变量
1.环境变量的概念
环境变量(Environment Variables)可以理解成:
操作系统提前准备好的一些“全局配置信息”,程序运行时可以读取这些信息。
例如:
echo $PATH
就是查看 PATH 环境变量。
可以把环境变量想象成:
操作系统
|
|--- PATH
|--- HOME
|--- SHELL
|--- ...
不同程序都可以读取这些信息。
2.常见环境变量
1. PATH
PATH:指定命令的搜索路径。
当我们在终端输入一条命令(例如 ls、python 或 java)时,操作系统并不会立刻知道该命令对应的程序存放在哪里。它会按照 PATH 环境变量中记录的目录顺序,依次去这些目录里查找是否存在同名的可执行文件。一旦找到,就立即执行;如果所有目录都找遍了仍然没有,终端就会提示 command not found(命令未找到)。
例如,当我们执行 echo $PATH 时,通常会看到类似下面这样的输出:
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
这里每一段路径之间用冒号(:)分隔,表示系统会按照从左到右的顺序依次在这些目录中搜索命令。这种设计带来的好处是:我们不需要记住每个程序安装的完整路径,只要把它的目录加入 PATH,就可以在任何位置直接输入命令名来启动它。
在实际开发中,我们经常需要修改 PATH。比如安装了某个新工具后,如果直接运行命令提示找不到,通常就是因为它的安装目录还没有被加入 PATH。此时可以在终端中临时追加路径,例如:
export PATH=$PATH:/自定义/工具目录
这条命令会把新的目录追加到现有 PATH 的末尾,从而让系统也能在该目录中查找命令。需要注意的是,这种方式只在当前终端会话中生效,关闭终端后就会失效;如果希望永久生效,需要把这一行写入 ~/.bashrc 或 ~/.zshrc 等配置文件里。
>
/bin/ls
就能运行?
因为Linux会去 PATH 中指定的目录寻找 ls。
查看:
echo $PATH
可能得到:
/usr/local/bin:/usr/bin:/bin:/usr/local/sbin
这些目录之间用:
:
分隔。
Linux执行:
ls
大致会:
ls
↓
去PATH中的目录找
↓
/usr/local/bin/ls
↓
没找到
↓
/usr/bin/ls
↓
找到了
↓
执行
2.HOME
echo $HOME
例如普通用户可能得到:
/home/whb
root可能是:
/root
HOME表示:当前用户的主目录
3.SHELL环境变量
查看:
echo $SHELL
可能得到:
/bin/bash
表示:当前用户默认使用的Shell。
例如:
/bin/bash
就是 Bash。
测试:
假设你写了:
#include <stdio.h>
int main()
{
printf("hello world!\n");
return 0;
}
编译:
gcc hello.c -o hello
当前目录:
hello
方法一:./hello
执行:
./hello
这里:
.
表示:当前目录
所以:
./hello
就是执行当前目录下的hello程序。
因此可以运行。
方法二:直接输入 hello
如果输入:
hello
Linux会去:
PATH
里面寻找 hello。
例如:
/usr/local/bin
/usr/bin
/bin
...
但是你的 hello 在:
当前目录
而当前目录通常不在PATH中。
所以:
hello
找不到。
于是报:
command not found
3.和环境变量相关的命令
1. echo —— 查看某一个环境变量
语法:
echo $变量名
例如:
echo $PATH
输出:
/usr/local/bin:/usr/bin:/bin
表示查看 PATH 的值。
再比如:
echo $HOME
可能输出:
/home/whb
为什么前面有 $?
$PATH
表示:把 PATH 这个变量里面保存的值取出来。
所以:
echo PATH
和:
echo $PATH
是不一样的。
2. export —— 设置/导出环境变量
例如:
export MYNAME=hello
然后:
echo $MYNAME
得到:
hello
这里相当于:
MYNAME = hello
并且通过 export,它成为环境变量,子进程也可以继承。
例如:
export TEST=123
./a.out
a.out 这个程序就可以读取 TEST。
要区分:
MYNAME=hello
和:
export MYNAME=hello
前者只是 Shell 本地变量。
后者是 环境变量,可以传给子进程。
可以简单记:export = 把 Shell 变量“导出”给子进程。
3. env —— 查看所有环境变量
直接输入:
env
会显示很多环境变量,例如:
PATH=/usr/local/bin:/usr/bin:/bin
HOME=/home/whb
SHELL=/bin/bash
USER=whb
PWD=/home/whb/test
所以:
echo $PATH
是只看 PATH
而:
env
是看所有环境变量
4. unset —— 删除环境变量
例如:
export MYNAME=hello
现在:
echo $MYNAME
得到:
hello
然后:
unset MYNAME
再:
echo $MYNAME
就没有值了。
所以:
unset 变量名
就是删除这个变量。
注意这里不加 $:
unset MYNAME # 正确
unset $MYNAME # 不应该这样写
因为 unset 操作的是变量本身,不是变量的值。
5. set —— 查看 Shell 变量 + 环境变量
输入:
set
会显示当前 Shell 中定义的变量,内容通常很多。
可以简单理解成:
set
↓
Shell变量 + 环境变量
而:
env
↓
主要查看环境变量
4. 环境变量的组织方式
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 '\0' 结尾的环境字符串。
环境表
假设系统有这些环境变量:
PATH=/usr/bin:/bin
HOME=/home/whb
SHELL=/bin/bash
USER=whb
程序运行时,这些环境变量可以组织成:
环境表
|
↓
+---------------------+
| 指针1 ───────────────┼────→ "PATH=/usr/bin:/bin\0"
+---------------------+
| 指针2 ───────────────┼────→ "HOME=/home/whb\0"
+---------------------+
| 指针3 ───────────────┼────→ "SHELL=/bin/bash\0"
+---------------------+
| 指针4 ───────────────┼────→ "USER=whb\0"
+---------------------+
| NULL |
+---------------------+
这就是:字符指针数组 char *env[]
例如:
环境变量:
PATH=/usr/bin:/bin
实际上是一个完整字符串:
P A T H = / u s r / b i n : / b i n \0
意思就是:
env[0] ───→ PATH=/usr/bin:/bin\0
env[1] ───→ HOME=/home/whb\0
env[2] ───→ SHELL=/bin/bash\0
所以每个指针指向一个以 '\0' 结尾的环境字符串。
假设有:
char *env[] =
{
"AAA=111",
"BBB=222",
"CCC=333",
NULL
};
最后的:
NULL
作用是:告诉程序:环境变量已经结束了。
类似字符串使用:
'\0'
表示结束。
环境表使用
NULL
表示结束。
所以:
字符串:
字符 → 字符 → 字符 → '\0'
↑
结束
环境表:
env[0] → 字符串
env[1] → 字符串
env[2] → 字符串
env[3] → NULL
↑
结束
可以这样遍历环境表
Linux程序中通常可以使用:
extern char **environ;
environ 指向环境表。
例如:
#include <stdio.h>
extern char **environ;
int main()
{
int i = 0;
while(environ[i] != NULL)
{
printf("%s\n", environ[i]);
i++;
}
return 0;
}
运行后可能输出:
PATH=/usr/local/bin:/usr/bin:/bin
HOME=/home/whb
SHELL=/bin/bash
USER=whb
...
5.通过系统调⽤获取或设置环境变量
getenv()
函数原型:
char *getenv(const char *name);
作用:根据环境变量的名字,找到它对应的值。
比如我们之前在命令行中:
echo $PATH
可以查看 PATH。
在 C 程序里面,就可以:
getenv("PATH")
获取 PATH 的值。
返回类型:
char *
也就是说:返回一个指向环境变量值字符串的字符指针。
例如:
char *p = getenv("PATH");
那么:
p
↓
"/usr/bin:/bin\0"
因此可以:
printf("%s\n", p);
6.环境变量通常是具有全局属性的
环境变量通常具有全局属性,可以被子进程继承下去
例如:
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *env = getenv("MYENV");
if(env)
{
printf("%s\n", env);
}
return 0;
}
直接运行:
./test
程序执行:
char *env = getenv("MYENV");
但是当前环境中没有:
MYENV=hello world
所以:
getenv("MYENV")
返回:
NULL
于是:
if(env)
相当于:
if(NULL)
条件不成立,所以:
printf("%s\n", env);
根本不会执行,因此没有输出。
执行 export
export MYENV="hello world"
现在运行:
./test
./test 是 Shell 启动的一个新进程。
关系可以简单画成:
Shell进程
│
│ 创建/启动
↓
test进程
而 Shell 本身已经有:
MYENV=hello world
所以启动 test 时,Shell 会把自己的环境变量传递给新程序。
于是:
Shell环境:
MYENV=hello world
↓
↓ 继承
↓
test进程环境:
MYENV=hello world
所以 test 中:
getenv("MYENV")
就能找到:
hello world
于是输出:
hello world
子进程继承的是父进程的环境,但之后它们是相互独立的。
例如:
父进程:
MYENV=hello
↓ 创建子进程
子进程:
MYENV=hello
如果子进程把自己的环境改成:
MYENV=world
一般不会导致父进程变成:
MYENV=world
父进程仍然是:
MYENV=hello
可以理解为创建子进程时,子进程获得父进程环境的副本。
必须使用 export
假设你只写:
MYENV="hello world"
然后:
./test
可能还是没有输出。
因为:
MYENV="hello world"
只是当前 Shell 的本地 Shell 变量,不一定会放进传给子进程的环境表。
而:
export MYENV="hello world"
相当于:
Shell变量
↓
export
↓
环境变量
↓
加入环境表
↓
子进程可以继承
这就是导出环境变量
二.程序地址空间
1.虚拟地址
示例1:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 0;
}
else if(id == 0)
{
// child 子进程
printf("child[%d]: %d : %p\n",
getpid(), g_val, &g_val);
}
else
{
// parent 父进程
printf("parent[%d]: %d : %p\n",
getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
可能输出:
parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8
因为 fork() 后,子进程会以父进程为模板,拥有相同的虚拟地址空间布局。
所以父子进程看到的:
g_val
对应的虚拟地址都是:
0x80497d8
示例2:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 0;
}
else if(id == 0)
{
// child 子进程
g_val = 100;
printf("child[%d]: %d : %p\n",
getpid(), g_val, &g_val);
}
else
{
// parent 父进程
sleep(3);
printf("parent[%d]: %d : %p\n",
getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
可能输出:
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
问题来了:
如果:
0x80497e8
真的是物理地址,
那么父进程和子进程访问的应该是同一块物理内存。
子进程:
g_val = 100;
改了它。
那么父进程再读取:
g_val
应该也得到:
100
但实际却是:
父进程:0
子进程:100
所以:&g_val 打印出来的地址不是物理地址。
这个地址叫:虚拟地址
包括我们平时写 C/C++:
int a = 10;
printf("%p", &a);
看到的地址:
0x7ffffff...
是:虚拟地址
不是内存条上的真实物理地址。
真正的物理内存可以简单理解成:
物理内存
+----------------+
| 真实的内存空间 |
| |
| |
+----------------+
但是普通用户程序不能直接随意操作物理地址。
Linux 操作系统负责管理物理内存。
程序看到的是:
程序
↓
虚拟地址
↓
操作系统 + 页表
↓
物理地址
↓
真正的物理内存
也就是:
虚拟地址 ──────→ 物理地址
OS必须负责将 虚拟地址转化成 物理地址
为什么 fork() 后一开始还可以共享?
这里就要引出一个非常重要的机制:写时拷贝(Copy-On-Write,COW)
fork() 刚创建子进程的时候,操作系统并不会马上把父进程所有内存完整复制一份。
而是先让父子进程:
父进程虚拟地址 ─┐
├──→ 同一块物理内存
子进程虚拟地址 ─┘
例如:
父进程 子进程
0x80497e8 0x80497e8
│ │
└────────┬─────────────┘
↓
物理内存 A
g_val = 0
此时父子进程都没有修改:
父:0
子:0
所以完全没问题。
当子进程执行 g_val = 100 呢?
这时候事情发生变化。
子进程要:
g_val = 100;
操作系统发现:“这块内存目前和父进程共享,子进程现在要写它。”
于是触发:写时拷贝
操作系统给子进程准备一块新的物理内存:
修改之前:
父进程虚拟地址 ──┐
├──→ 物理内存A
子进程虚拟地址 ──┘ g_val=0
修改之后:
父进程:
0x80497e8
│
↓
物理内存 A
g_val = 0
子进程:
0x80497e8
│
↓
物理内存 B
g_val = 100
注意:
虚拟地址没变!
仍然都是:
0x80497e8
但是:
映射到的物理地址变了!
父:
虚拟地址 → 物理地址A
子:
虚拟地址 → 物理地址B
这就是同一个变量,地址相同,其实是虚拟地址相同;内容不同,其实是被映射到了不同的物理地址。
2.为什么要设计虚拟地址?
如果没有虚拟地址,所有程序直接使用物理地址:
程序A ──→ 物理内存
程序B ──→ 物理内存
程序C ──→ 物理内存
那么不同程序之间很容易互相覆盖、破坏。
有了虚拟地址之后:
进程A虚拟地址空间
↓
页表A
↓
物理内存
进程B虚拟地址空间
↓
页表B
↓
物理内存
每个进程都有自己的进程地址空间。
这样可以实现:
- 进程之间相互隔离
- 提高内存管理效率
- 实现虚拟内存
- 实现写时拷贝
- 程序不需要知道真实物理地址
3.虚拟内存管理
描述linux下进程的地址空间的所有的信息的结构体是 个mm_struct结构,在每个进程的 mm_struct (内存描述符)。每个进程只有⼀ task_struct 结构中,有⼀个指向该进程的mm_struct结构体指针。
例如:
struct task_struct
{
/* ... */
struct mm_struct *mm;
/* ... */
};
这里:
struct mm_struct *mm;
是一个指针。
它指向:
mm_struct
也就是这个进程对应的虚拟地址空间描述信息。
可以画成:
task_struct
┌────────────────────┐
│ PID │
│ 状态 │
│ 优先级 │
│ ... │
│ │
│ mm ────────────────┼────→ mm_struct
└────────────────────┘ │
↓
虚拟地址空间
每⼀个进程都会有⾃⼰独⽴的 mm_struct , 这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。
例如:
进程A 进程B
mm_struct A mm_struct B
↓ ↓
地址空间A 地址空间B
↓ ↓
虚拟地址0x1000 虚拟地址0x1000
↓ ↓
物理内存A 物理内存B
虽然:
进程A:0x1000
进程B:0x1000
可能是一样的虚拟地址,但是可以通过不同的页表映射到不同的物理内存。
因此不同进程拥有独立的虚拟地址空间,从而实现进程之间的内存隔离。
4.mm_struct
struct mm_struct
{
struct vm_area_struct *mmap;
struct rb_root mm_rb;
unsigned long task_size;
unsigned long start_code, end_code;
unsigned long start_data, end_data;
unsigned long start_brk, brk;
unsigned long start_stack;
unsigned long arg_start, arg_end;
unsigned long env_start, env_end;
/* ... */
};
mm_struct 就像一张“进程虚拟地址空间的地图”。
它记录了:
代码在哪里?
数据在哪里?
堆在哪里?
栈在哪里?
参数在哪里?
环境变量在哪里?
有哪些虚拟内存区域?
例如:
unsigned long start_code, end_code;
表示代码段的开始和结束位置。
unsigned long start_data, end_data;
表示数据段的开始和结束位置。
unsigned long start_brk, brk;
和堆(heap)有关
unsigned long start_stack;
表示栈的起始位置。
unsigned long arg_start, arg_end;
表示:命令行参数区域。
比如:
./test hello 123
这里的:
hello
123
就是命令行参数。
unsigned long env_start, env_end;
表示:环境变量所在区域。
5.vm_area_struct
linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚 拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型 的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进 程快速访问
一个进程的虚拟地址空间不是一整块完全一样的区域。
例如:
┌───────────────────┐
│ 栈 │
├───────────────────┤
│ │
│ ... │
├───────────────────┤
│ 堆 │
├───────────────────┤
│ 数据段 │
├───────────────────┤
│ 代码段 │
└───────────────────┘
代码、数据、堆、栈的:
- 权限
- 用途
- 管理方式
都可能不同。
所以 Linux 使用:
struct vm_area_struct
来描述一个独立的虚拟内存区域。
简称:VMA(Virtual Memory Area)
一个进程有很多 VMA
比如一个进程:
进程A
│
↓
mm_struct
│
├── VMA 1 → 代码段
│
├── VMA 2 → 数据段
│
├── VMA 3 → 堆
│
├── VMA 4 → 共享库
│
├── VMA 5 → 栈
│
└── ...
所以:
mm_struct
↓
管理整个地址空间
vm_area_struct
↓
描述其中一个具体的虚拟内存区域
vm_area_struct 里面最重要的两个成员
unsigned long vm_start;
unsigned long vm_end;
分别表示:
vm_start → 虚拟内存区域开始地址
vm_end → 虚拟内存区域结束地址
比如:
VMA
┌──────────────────────┐
│ │
│ 某个区域 │
│ │
└──────────────────────┘
↑ ↑
vm_start vm_end
所以一个 VMA 本质上就是:从虚拟地址 A 到虚拟地址 B 的这一段区域,它是什么、有什么权限、怎么管理。
vm_next 和 vm_prev
struct vm_area_struct *vm_next;
struct vm_area_struct *vm_prev;
这两个就是:前后指针。
因此可以把多个 VMA 连接起来:
VMA1 ←→ VMA2 ←→ VMA3 ←→ VMA4
这就是双向链表。
而:
struct vm_area_struct *mmap;
可以指向这个链表。
所以当虚拟区间较少时,可以通过链表管理。
如果一个进程有很多 VMA:
VMA1
VMA2
VMA3
...
VMA100
VMA101
...
如果每次寻找某个地址对应哪个 VMA,都从头开始链表查:
VMA1 → VMA2 → VMA3 → ...
效率可能比较低。
所以还可以使用:红黑树
struct rb_root mm_rb;
就是红黑树的根。
可以简单理解:
mm_struct
/ \
链表 红黑树
↓ ↓
vm_area_struct vm_area_struct
6.为什么一定需要虚拟地址空间?
如果程序直接操作物理内存,会怎么样?
假设电脑有:
128MB物理内存
程序A需要:
10MB
程序B需要:
110MB
可以分:
物理内存
0MB
│
├──────── A 10MB ────────┤
│
├──────── B 110MB ──────────────────────┤
│
└────────────────────────────────────────┘
128MB
看起来能运行。
但是这种方式问题非常大。
第一个问题:安全性
如果程序直接操作物理地址:
程序A
↓
物理内存
那么理论上程序A可能去访问:
程序B的内存
甚至:
操作系统内核的内存
那就非常危险。
例如恶意程序:
修改其他程序数据
↓
修改系统数据
↓
系统崩溃
所以必须进行隔离。
有了虚拟地址:
进程A
↓
虚拟地址空间A
↓
页表A
↓
物理内存
程序不能随意访问别人的虚拟地址空间。
第二个问题:地址不确定
假设程序里面有:
int a = 10;
如果直接使用物理地址。
第一次运行:
物理地址:
0x1000
第二次运行,由于内存已经被其他程序占用了:
物理地址:
0x5000
那么程序每次运行实际地址都可能不同。
这对程序非常麻烦。
有虚拟地址之后
程序只需要认为:
a → 0x1000
每次都可以使用这个虚拟地址。
操作系统再负责:
虚拟地址0x1000
↓
页表
↓
物理地址可能是0x5000
下一次:
虚拟地址0x1000
↓
页表
↓
物理地址可能是0x9000
程序完全不需要知道。
第三个问题:提高内存利用率
假设物理内存不够。
没有虚拟内存时:
整个进程
↓
一起搬到磁盘
这样效率很低。
有虚拟内存和分页机制以后,可以:
进程的一部分页面
↓
物理内存
暂时不用的页面
↓
磁盘
不需要把整个进程一次性搬走。
所以效率更高。
7.malloc 申请的是虚拟空间
在 C/C++ 中使用 malloc、new 申请空间时,本质上是向进程的地址空间申请。
例如:
int *p = malloc(100 * sizeof(int));
你可能以为:“操作系统马上给我一块真实物理内存。”
实际上不能简单这么理解。
可以理解成:
malloc
↓
申请虚拟地址空间
↓
操作系统建立/准备相应的内存管理关系
真正访问页面时,如果页面尚未准备好,操作系统可能通过缺页异常等机制分配物理页并建立映射。
这就是延迟分配。
8.“进程管理”和“内存管理”解耦
以前如果程序直接使用物理地址:
程序
↓
直接占用物理内存
程序和物理内存的位置强相关。
有了虚拟地址以后:
进程
↓
虚拟地址空间
↓
页表
↓
物理内存
于是:进程只需要关心自己的虚拟地址空间,物理内存由操作系统统一管理。
这就是进程管理模块和内存管理模块解耦。
9.为什么程序看到的地址可以是“连续”的?
这是虚拟地址非常厉害的地方。
例如程序认为:
虚拟地址:
0x1000
0x2000
0x3000
0x4000
是连续的。
但实际物理内存可能:
虚拟地址 物理地址
0x1000 → 0x8000
0x2000 → 0x2000
0x3000 → 0xF000
0x4000 → 0x5000
物理内存可以是分散的。
但是从进程角度看:
0x1000
↓
0x2000
↓
0x3000
↓
0x4000
依然是连续、有序的。
所以在进程视角,所有的内存分布都可以是有序的。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)