目录
[1.1 基本概念](#1.1 基本概念)
[1.2 常见环境变量](#1.2 常见环境变量)
[1.3 查看环境变量方法](#1.3 查看环境变量方法)
[1.4 和环境变量相关的命令](#1.4 和环境变量相关的命令)
[1.5 环境变量的组织方式](#1.5 环境变量的组织方式)
[1.6 通过代码获取环境变量](#1.6 通过代码获取环境变量)
[1.7 通过系统调用获取或设置环境变量](#1.7 通过系统调用获取或设置环境变量)
[1.8 环境变量通常具有全局属性](#1.8 环境变量通常具有全局属性)
[2.1 研究平台](#2.1 研究平台)
[2.2 程序地址空间回顾](#2.2 程序地址空间回顾)
[2.3 虚拟地址](#2.3 虚拟地址)
[2.4 进程地址空间](#2.4 进程地址空间)
[2.5 虚拟内存管理(一)](#2.5 虚拟内存管理(一))
[2.6 为什么要有虚拟地址空间](#2.6 为什么要有虚拟地址空间)
一、命令行参数和环境变量
1.1 基本概念
- 环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数。
- 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们所链接的动态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局性。
1.2 常见环境变量
- PATH:指定命令的搜索路径
- HOME:指定用户的主工作目录(即用户登入到Linux系统中时,默认的目录)
- SHELL:当前shell,它的值通常是/bin/bash
1.3 查看环境变量方法
echo $NAME // NAME:你的环境变量名称

测试PATH
cpp
#include <stdio.h>
int main()
{
printf("hello!\n");
return 0;
}
- 创建hello.c文件
- 对比 ./hello 执行和 hello 执行
- 为什么有些指令可以直接执行,不需要到带路径,而我们的二进制程序需要带路径才能执行
- 将我们的程序所在路径加入到环境变量PATH当中,export PATH=$PATH:hello程序所在的路径
测试HOME
用root用户和普通用户,分别执行 echo $HOME,对比差异。

1.4 和环境变量相关的命令
- echo:显示某个环境变量的值
- export:设置一个新的环境变量
- env:显示所有环境变量
- unset:清除环境变量
- set:显示本地定义的shell变量和环境变量

1.5 环境变量的组织方式

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以'/0'结尾的环境字符串。
1.6 通过代码获取环境变量
- 命令行第三个参数
cpp
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for (; env[i]; i++)
{
printf("%s\n", env[i]);
}
return 0;
}

- 通过第三方变量environ获取
cpp
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for (; environ[i]; i++)
{
printf("%s\n", environ[i]);
}
return 0;
}

libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
1.7 通过系统调用获取或设置环境变量
- getenv 获取环境变量
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s", getenv("PATH"));
return 0;
}

- putenv 设置环境变量
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
char my_env[] = "hello=hello world";
if(putenv(my_env) != 0) {
return 1;
}
printf("%s\n", getenv("hello"));
return 0;
}

1.8 环境变量通常具有全局属性
环境变量通常具有全局属性,可以被子进程继承下去。
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("abc"));
return 0;
}

这里我们直接查看abc这个环境变量,并没有找到。
这时我们通过 export 将abc导入进去再次运行这个程序试试:

所以子进程可以继承父进程的环境变量。
二、程序地址空间
2.1 研究平台
- kernel 2.6.32
- 32位平台
2.2 程序地址空间回顾
我们在说C语言的时候,有这样一幅图:

cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10;
char *heap_mem = (char *)malloc(10);
char *heap_mem1 = (char *)malloc(10);
char *heap_mem2 = (char *)malloc(10);
char *heap_mem3 = (char *)malloc(10);
printf("heap addr: %p\n", heap_mem); // heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); // heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); // heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); // heap_mem(0), &heap_mem(1)
printf("test static addr: %p\n", &test); // heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); // heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); // heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); // heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); // heap_mem(0), &heap_mem(1)
printf("read only string addr: %p\n", str);
for (int i = 0; i < argc; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for (int i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}

2.3 虚拟地址
我们通过下面这段代码感受一下
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.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;
}

我们发现,输出出来的变量值和地址是一模一样的,原因很简单,子进程以父进程为模板,且父子并没有对变量进行任何修改。但是我们将代码稍微改动一下:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.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 = 9;
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;
}

我们发现,父子进程输出的地址是一样的,但是变量值却是不一样的!我们能得到以下结论:
- 变量的内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但是地址值是一样的,说明这个地址绝对不是物理地址
- 在Linux中,这个地址叫做虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,有OS统一管理
- 所以,OS必须负责将虚拟地址转换为物理地址
2.4 进程地址空间
所以之前说的"程序的地址空间"是不准确的,更准确应该说成进程地址空间,那该怎么理解呢?
分页与虚拟地址空间

上面的图就足以说明问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同,其实是被映射到了不同的物理地址!
2.5 虚拟内存管理(一)
描述Linux 下进程的地址空间的所有信息的结构体是mm_struct (内存描述符)。每个进程只有一个mm_struct 结构,在每个进程的task_struct 结构中,有一个指向该进程的mm_struct结构体的指针。
cpp
struct task_struct
{
/*...*/
struct mm_struct* mm;
// 对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。
struct mm_struct* active_mm;
// 该字段是内核线程使⽤的。当
// 该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因
// 为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
/*...*/
}
可以说,mm_struct 是对整个用户空间的描述。每个进程都会有自己独立的mm_struct ,这样每个进程都会有自己独立的地址空间才能互不干扰,先来看由task_struct 到mm_struct,进程的地址空间的分布情况:

定位mm_struct 文件所在位置和task_struct 所在路径是一样的,不过他们所在的文件是不一样,mm_struct 所在的文件是mm_types.h
cpp
struct mm_struct
{
/*...*/
struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
struct rb_root mm_rb; /* red_black树 */
unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/
/*...*/
// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/*...*/
}
既然每个进程都会有自己独立的mm_struct ,操作系统肯定是要讲这么多进程的mm_struct组织起来的的。虚拟空间的组织方式有两种:
- 当虚拟区较少时采用单链表,由mmap指针指向这个单链表
- 当虚拟区较多时采用红黑树进行管理,由mm_rb指向这棵树
Linux 内核使用vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA ),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct 结构来表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct 结构来连接各个VMA,方便进程快速访问。
cpp
struct vm_area_struct
{
unsigned long vm_start; // 虚存区起始
unsigned long vm_end; // 虚存区结束
struct vm_area_struct *vm_next, *vm_prev; // 前后指针
struct rb_node vm_rb; // 红⿊树中的位置
unsigned long rb_subtree_gap;
struct mm_struct *vm_mm; // 所属的 mm_struct
pgprot_t vm_page_prot;
unsigned long vm_flags; // 标志位
struct
{
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; // vma对应的实际操作
unsigned long vm_pgoff; // ⽂件映射偏移量
struct file *vm_file; // 映射的⽂件
void *vm_private_data; // 私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
所以我们可以对上图进行更细致的描述,如下图所示:


2.6 为什么要有虚拟地址空间
这个问题其实可以转化成:如果程序直接操控物理地址会造成什么问题?
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种分配方法可以保证程序A和程序B都正常运行,但是这种简单的内存分配策略会有很多问题
- 安全风险:每个进程都可以访问任意的内存空间,这也就意味着一个进程能够去读写系统相关的内存区域,如果是一个木马病毒,他就可以随意修改内存空间,让设备直接瘫痪。
- 地址不确定:众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那执⾏a.out的时候,内存地址就不⼀定了。
- 效率低下:如果直接使用物理内存的话,⼀个进程就是作为⼀个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程⼀起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。
存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然!
- 地址空间和页表是OS创建并维护的。这也就意味着,凡是想要使用地址空间和页表进行映射,就一定要在OS的监管之下进行访问。这就顺带保护了物理内存中的所有的合法数据,包括各个进程以及内核当中的有效数据。
- 因为有了地址空间和页表映射的存在,我们的物理内存可以对未来的数据进行任意位置的加载。物理内存的分配和进程的管理就可以没有任何关系,进程管理模块和内存管理模块就完成了解耦合。
- 因为有地址空间的存在,所以我们在C,C++语言上的malloc,new空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成的,用户包括进程完全0感知!
- 因为也表的映射存在,程序在物理内存中理论上就可以在任意位置进行加载。他可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分配都可以是有序的。