5.环境变量的组织方式
(1)环境表
每个进程在启动的时候都会收到一张环境表,环境表主要指环境变量的集合,每个进程都有一个环境表,用于记录与当前进程相关的环境变量信息。
环境表采用字符指针数组的形式进行存储,然后使用全局变量char** envrion来记录环境表的首地址,使用NULL表示环境表的末尾:
以前写c代码时,main函数可以带2个参数:
1. #include<stdio.h> 2. 3. int main(int argc,char *argv[]) 4. { 5. return 0; 6. }
其中第二个参数argv是指针数组,数组元素一共有argc个,argc决定了有几个有效命令行那个字符串。可以把命令行参数的细节打印出来:
1. #include<stdio.h> 2. 3. int main(int argc,char *argv[]) 4. { 5. int i = 0; 6. for(i = 0;i<argc;i++) 7. { 8. printf("argv[%d] = %s\n",i,argv[i]); 9. } 10. return 0; 11. }
命令行带参数运行:
命令行参数数组的元素个数是动态变化的,有几个参数就有对应的长度大小:
在命令行中传递的各种各样的数据最终都会传递给main函数,由main函数一次保存在argv中,由argc再表明个数 。
数组结尾是NULL,那么可以不使用argc吗?不可以,原因有两个:
- 作为数组传参,一般建议把个数带上
- 用户填参数到命令行,如果想限定用户输入命令行参数的个数,就要用到argc,例如:
1. if(argc != 5) 2. { 3. //TODO 4. }
命令行参数的作用在于,同一个程序可以用给它带入不同参数的方式来让它呈现出不同的表现形式或功能,例如:
实现一个程序,假如输入参数为o或e,就打印hello linux:
inputPara.c
1. #include<stdio.h> 2. #include<string.h> 3. #include<unistd.h> 4. int main(int argc,char *argv[]) 5. { 6. 7. if(argc != 2)//输入参数不为2时 8. { 9. printf("Usage: %s -[l|n]\n",argv[0]); 10. return 1; 11. } 12. if(strcmp(argv[1],"-l") == 0)//输入第二个参数为-l 13. { 14. printf("hello linux! -l\n"); 15. } 16. else if(strcmp(argv[1],"-n") == 0)//输入第三个参数为-n 17. { 18. printf("hello linux -n\n"); 19. } 20. else 21. { 22. printf("hello\n"); 23. } 24. 25. return 0; 26. }
输入不同的参数就有不同的执行结果:
命令行参数的意义在于,指令有很多选项,用来完成同一个命令的不同子功能。选项底层使用的就是命令行参数。
假如函数没有参数,那么可以使用可变参数列表去获取。
(2)获取环境变量
- 使用getenv获取环境变量
1. #include <stdlib.h> 2. 3. char *getenv(const char *name);
获取PATH、HOME、SHELL这3个环境变量:
1. #include<stdio.h> 2. #include<stdlib.h> 3. 4. int main() 5. { 6. printf("PATH:%s\n",getenv("PATH")); 7. printf("HOME:%s\n",getenv("HOME")); 8. printf("SHELL:%s\n",getenv("SHELL")); 9. 10. return 0; 11. }
如下:
- 使用命令行第3个参数获取环境变量
使用命令行第3个参数env获取环境变量:
env1.c
1. #include<stdio.h> 2. 3. int main(int argc,char *argv[],char *env[]) 4. { 5. int i = 0; 6. for(; env[i];i++) 7. { 8. printf("%s\n",env[i]); 9. } 10. 11. return 0; 12. }
结果如下:
- 通过第三方变量environ获取
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
1. #include <stdio.h> 2. int main(int argc, char *argv[]) 3. { 4. extern char **environ; 5. int i = 0; 6. for(; environ[i]; i++){ 7. printf("%s\n", environ[i]); 8. } 9. return 0; 10. }
结果如下:
6.环境变量的全局属性
环境变量通常具有全局属性,可以被子进程继承。
如下代码:
geteEnvironment.c
1. #include<stdio.h> 2. #include<sys/types.h> 3. #include<unistd.h> 4. 5. int main() 6. { 7. printf("pid = %d,ppid = %d\n",getpid(),getppid()); 8. return 0; 9. }
发现每次运行该程序,子进程的ID都不相同,但是父进程的ID都相同
命令行上启动的进程,父进程都是bash,bash的环境变量是从系统里读的,系统的环境变量就在系统配置中,bash登陆时,bash就把系统的配置导入到自己的上下文当中。子进程的环境变量是系统给的,也就是父进程bash给的。环境变量一旦导出是可以影响子进程的
环境变量具有全局属性的原因是环境变量是可以被继承的。比如bash创建子进程后,子进程又创建了更多的子进程,相当于从bash开始,一个环境变量被设置,所有的子进程全都看到了bash环境变量,所有用户都可以获得这个环境变量,用这些环境变量做一些搜索查找等的任务,gcc和gdb能链接到各种库的原因是,他们都是命令,都是bash的子进程,bash的所有关于库路径的查找,头文件查找等各种全局设计都可以被这些命令找到,本质上是因为环境变量可以指导编译工具去进行相关查找,所以在编译程序时不用带很多选项,默认就能找到,能够让程序快速完成翻译和调试。
7.本地变量
与环境变量相对的还有本地变量,针对当前用户的当前进程生效,是一种临时变量,退出本次登陆后就失效了。
如下,变量value的值在没有退出登录前,打印到是5,ctrl+d退出登录后
再去echo $value发现value已经失效了
本地变量能被子进程继承吗?用env查看,发现shell的上下文中是没有的:
说明本地变量是不能被继承的,只能bash自己用。
现在使用getenv获取这个本地变量的环境变量:
getLocalValue.c
1. #include<stdio.h> 2. #include<stdlib.h> 3. 4. int main() 5. { 6. printf("value = %d\n",getenv("value")) ; 7. return 0; 8. }
运行之后发现,value变成了0,说明刚刚定义的value变量就是本地变量
把定义的value变量用export导成环境变量,实际上是导给了父进程bash的环境变量列表:
这时候用env查看,发现shell的上下文中有了:
这说明环境变量已经到给了父进程bash,bash中已经有了环境变量,./getLocalValue.c运行时,它的环境变量信息会继承自父进程,父进程现在多了一个环境变量,用env就能够获取成功了。
九、程序地址空间
1.程序地址空间分布
C/C++程序地址空间:
那么C/C++的程序地址空间是内存吗?为了验证它到底是什么,可以使用如下代码:
printfProcessAddress.c
1. #include<stdio.h> 2. #include<string.h> 3. #include<stdlib.h> 4. 5. int g_UnValue; 6. int g_Value = 1; 7. 8. int main() 9. { 10. const char *string = "hello world"; 11. char *heap = (char*)malloc(10); 12. int a = 5; 13. 14. printf("code address:%p\n",main);//代码区 15. 16. printf("read only string:%p\n",string);//字符常量区 17. printf("statck address:%p\n",&string);//栈区 18. 19. printf("uninit address:%p\n",&g_UnValue);//未初始化全局变量区 20. printf("Init address:%p\n",&g_Value);//已初始化全局变量区 21. 22. printf("heap address:%p\n",heap);//堆区 23. printf("stack address:%p\n",&heap);//栈区 24. 25. printf("stack a:%p\n",&a);//栈区 26. 27. return 0; 28. }
运行之后发现:
(1)代码区的地址0x40057d最小,说明在程序地址空间中,代码区在最下面;
(2)字符串常量区0x400710次之
(3)已初始化全局变量区0x60103c次之
(4)未初始化全局变量区0x601044次之
(5)堆区0x17e4010、0x17e4030次之,两个地址依次增大,说明堆是向上增长的
(6)栈区地址最大,并且3个栈地址是依次减小的:
先打印了高地址,最后打印了低地址, 这说明栈是向下增长的。
以上就完整还原了程序地址空间的地址分布。
2.程序地址空间是虚拟地址
先看一段下面的代码,子进程在运行过程中修改了全局变量的值:
printfFork.c
1. #include<stdio.h> 2. #include<string.h> 3. #include<unistd.h> 4. 5. int g_Value = 1; 6. 7. int main() 8. { 9. //发生写时拷贝时,数据是父子进程各自私有一份 10. if(fork() == 0)//子进程 11. { 12. int count = 5; 13. while(count) 14. { 15. printf("child,times:%d,g_Value = %d,&g_Value = %p\n",count,g_Value,&g_Value); 16. count--; 17. sleep(1); 18. if(count == 3) 19. { 20. printf("############child开始更改数据############\n"); 21. g_Value = 5; 22. printf("############child数据更改完成############\n"); 23. } 24. } 25. } 26. else//父进程 27. { 28. while(1) 29. { 30. printf("father:g_Value = %d,&g_Value = %p\n",g_Value,&g_Value); 31. sleep(1); 32. } 33. } 34. 35. return 0; 36. }
但是打印时却发现,同一个地址,g_Value值却不一样:
如果写时拷贝访问的是同一个物理地址的话,为什么得到的g_Value是不一样的值呢?所以程序地址空间使用的不是物理地址,而是虚拟地址。
C/C++中用到的都是虚拟地址,操作系统不会把物理内存暴露给用户,物理地址由操作系统统一管理,操作系统负责把虚拟地址转化成物理地址。在计算机刚启动时,操作系统没有加载,因此计算机就只能访问物理内存,操作系统启动之后,CPU正常运行,就进入了虚拟空间。
所以上面画的程序地址空间分布图不是物理地址,而是进程虚拟地址空间。
3.虚拟地址
进程地址空间本质上是操作系统内的一种数据结构类型,操作系统让每个进程都感受到自己在独占系统内存资源,每个进程都认为自己独占4GB空间。
(1)mm_struct
在创建一个进程时,进程的task_struct结构中包含了一个指向mm_struct结构的指针,用来描述进程虚拟地址空间,即用户看到的空间。mm_struct中包含装入的可执行映像信息和进程的页表目录指针pgd,通过页表将虚拟地址映射为实际的物理地址:
每个进程都认为mm_struct代表整个内存的地址空间。地址空间不仅能够形成区域,还能再各个区域中抽象出一个地址,因为这个地址是线性连续的。start和end就对应到数组下标,下标对应到虚拟地址。task_struct所看到的地址不是物理地址,而是虚拟地址。
每个进程都只有一个虚拟空间,这个虚拟空间可以被别的进程共享。
那么虚拟地址的作用是什么呢?
虚拟地址本质上在软件上为进程画了饼,让每个进程都感受到自己在独占资源。无论怎样画饼,最终都要能让进程访问地址数据,读取并执行代码进行计算。
(2)页表和MMU
页表是一种数据结构,记录页面和页框的对应关系,本质是映射表,增加了权限管理,隔离了地址空间,能够将虚拟地址转换成物理地址。操作系统为每个进程维护一张页表。
MMU(Memory Manage Unit)内存管理单元,是虚拟地址的整体空间,是对整个用户空间的描述。MMU一般继承在CPU当中。
所以进程的各个区包括代码区,已初始化区、未初始化区、堆区、栈区、共享区等都是虚拟地址,经过页表和MMU映射成对应的物理地址,再让进程去访问代码和数据。
(3)进程地址空间存在的原因
如果进程直接访问内存行不行呢?为什么中间要映射呢?
这是因为加了一个中间层有利于管理,防止进程的不合法行为。如果让一个进程直接去访问物理内存,可以访问自己的代码和数据,但这个进程也有可能访问修改别的进程的代码和数据,甚至有些恶意进程通过非法指针访问操作别的进程的代码和数据,这会带来严重问题,可能威胁到系统安全。
存在进程地址空间的原因:
① 通过添加一层软件层,完成有效的对进程操作内存进行风险管理(权限管理),本质的目的是为了保护物理内存及各个进程的数据安全。
现在在虚拟地址和物理内存中间加了一个页表,就相当于加了一个软件层,这个软件层是操作系统的代言人,页表和MMU在映射时,其实是操作系统在映射,能不能映射是由操作系统决定的,这就能做到权限管理。比如:
1. const char *str = "spring"; 2. *str = "summer";//报错,不允许
修改str指向的变量的值时,是不被允许的。因为str是栈上的局部变量,但是spring在字符常量区,不可以修改,因为操作系统给你的权限只有r读权限。这就是为什么代码区内容是不可以修改的,字符常量区内容也不能修改的原因。因为页表是有权限管理的,给代码区和字符常量区分配的权限是r读权限。所以str指向的是虚拟地址,当要对*str进行写入的时候,访问的也是虚拟地址,这就需要操作系统进行虚拟地址和物理地址之间的转换,但是看到*str的权限是r读,不能写入,就把进程崩溃掉。
② 将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和操作系统管理操作进行软件层面上的分离的目的,让应用和内存管理解耦。
假如某个进程申请1000个字节,那么它能够马上使用这1000个字节吗?不一定,可能会存在暂时不会全部使用,甚至暂时不使用的情况。站在操作系统角度,如果把这1000个字节的空间马上给这个进程,那么就意味着,本来可以给别人马上用的空间,现在却被你闲置着。因此操作系统不会马上给这个进程分配1000个字节的物理内存空间,但是在进程虚拟地址空间上是批准了的。当进程马上要使用这1000个字节的空间时,进程就会告诉上层说已经申请1000个字节的空间了,准备要访问了,这是操作系统就会在物理内存中申请1000个字节的空间,这次的申请空间是透明的,然后把这实际的1000个字节的空间和进程申请的虚拟空间建立映射关系。
③ 站在CPU和应用层的角度,进程可以统一看作使用4GB空间,每个区域的相对位置是确定的,目的是让每个进程都认为自己独占着系统资源。
程序的代码和数据是要加载到物理内存的,操作系统需要知道main函数的物理地址。如果每个进程的main函数物理地址都不一样,那么对于CPU来说执行进程代码时,都要去不同的物理地址找main函数,这样很麻烦。每个进程的main函数物理起始地址可能都不相同,但是有了进程地址空间以后,就可以把main函数的物理起始地址,通过页表和MMU都映射成同一个虚拟空间地址,这样就把这一个虚拟空间地址和各个进程的物理地址建立起了映射关系。假如还要运行其它进程,就可以把其他进程的main函数其实地址映射到那个虚拟空间地址。这样CPU在读取进程的时候,main函数起始代码统一都从同一个起始位置去读,每个进程的main函数入口位置都可以找到。
另外,数据和代码可能在物理内存中不连续,而页表通过映射的方式把所有的代码区、已初始化全局数据区、未初始化全局数据区等映射到虚拟地址空间上时,可以把它们映射到连续的区域,形成线性区域。
4.写时拷贝
在printfFork.c的代码中,让子进程运行5秒,第3秒的时候,把g_Value的值改掉了,所以同一个地址子进程打印了2次1,后面的都是5了,父进程一直打印1,原因就是每个进程都有自己的页表,地址是虚拟地址而不是物理地址。
程序刚开始运行时,只有一个进程,即父进程, 父进程的pcb指向父进程的地址空间,全局变量定义出来的时候,子进程没有fork,g_Value对应已初始化区域的定义的全局变量,经过页表映射到物理内存上的g_Value
当fork创建子进程时,以父进程为模板为子进程创建新的pcb、地址空间和页表 。子进程把父进程的大部分内容都继承下来了,比如地址空间,子进程的地址空间、页表也都和父进程一样,所以创建子进程后,一开始子进程也指向了父进程的g_Value:
在第3秒的时候,子进程修改了g_Value的值,操作系统并没有让子进程直接把值改了,因为进程具有独立性,互不干扰。修改时发生写时拷贝,给子进程重新开辟一块物理空间,把g_Value变量值拷贝进来,再重新建立子进程的虚拟地址到物理地址之间的映射 :
因此看到的子进程和父进程打印的地址是一样的,是因为虚拟地址是一样的。值不一样的原因是在物理内存上本来就是不同的变量。