目录
1. 命令行参数 int main(int argc, char* argv[])
2.5 通过代码获取环境变量(main函数第三个参数-环境变量表)
3.4 虚拟地址空间&物理地址空间-理解+区域划分(概括+举例) [重重重点]
3.5 进程运行时虚拟地址空间的开辟 及 与物理地址空间的映射
1. 命令行参数 int main(int argc, char* argv[])
1.1 命令行参数的使用
我们写的main函数也有参数,其中
argc:是输入的以空格为分隔符的字符串个数,
argv:是以空格为分隔符的字符串数组。
main函数的命令行参数,是实现程序不同子功能的方法!!指令选项的实现原理。
总结:进程拥有一张表,argv表,用来支持实现选项功能。
举例1:argv是字符指针数组,接收命令行参数
#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;
}
./code
# argv[0]:./code
./code a b c d # ./code也算字符串
# argv[0]:./code
# argv[1]:a
# argv[2]:b
# argv[3]:c
# argv[4]:d
像我们的ls,cp等命令行指令,也是通过这种方法实现的。
举例2:解释ls,cp等指令的参数功能实现
#include<stdio.h>
#include<string.h>
int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("Usage: %s [-a|-b|-c]\n",argv[0]);
return 1;
}
const char *arg = argv[1];
if(strcmp(arg,"-a") == 0)
printf("这是功能a\n");
else if(strcmp(arg,"-b") == 0)
printf("这是功能b\n");
else if(strcmp(arg,"-c") == 0)
printf("这是功能c\n");
else
printf("Usage: %s [-a|-b|-c]\n",argv[0]);
return 0;
}
./code
# Usage: ./code [-a|-b|-c]
./code -a
# 这是功能a
./code -b
# 这是功能b
./code -c
# 这是功能c
./code -d
# Usage: ./code [-a|-b|-c]
2. 环境变量
2.1 环境变量概念
引言:为什么系统的指令不需要带“./”,而我们自己的程序需要"./"才能执行?
答:系统中存在环境变量!要执行一个程序,必须先找到他。系统中存在环境变量(PATH)来帮助系统找到二进制文件。(当然系统中还有其他各种环境变量)
环境变量(environment variables):一般是指在操作系统中用来指定操作系统运行环境的一些参数。(参数被bash使用,也就是间接被用户使用了)
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
举例:
问题1:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
2.2 常见环境变量
• HOSTNAME: 主机名
• SHELL : 当前Shell,它的值通常是/bin/bash。
• HISTSIZE: bash记录历史命令条数的最大值(history可以查看历史命令)
• SSH_CLIENT: 公网IP
• OLD_PWD: 前一个工作目录(cd -)
• SSH_TTY: 当前设备 /dev/pts/0
• USER: 当前用户
• PATH : 指定命令的搜索路径(比如我们写的程序执行要使用./code,而系统命令比如ls不需要“ls”,因为ls的路径在PATH的环境变量中)
• PWD: 当前工作目录
• HOME : 指定用户的主工作目录(用户家目录)(cd ~可以直接进入家目录)
• LOGNAME: 当前登录的用户(su不改变USER和LOGNAME,su -改变,即su -是退出重新登陆了)
2.3 环境变量相关命令(增删查改,只对当前bash生效)
每当我们打开一个终端(终端也是一个进程),终端会系统中继承一份环境变量表。终端进程启动bash,bash在终端进程中继承了环境变量表,并且读取./bashrc和./bash_profile等配置文件中的内容,修改并整合刚刚从终端继承的环境变量表,从而形成自己最终要使用的“完整环境变量表”。而以下我们可以在bash终端中执行的对环境变量的操作,都是对bash环境变量表的修改。当新开一个bash进程时,bash还是重新继承环境变量表,所以我们在bash中对环境变量表的操作都是临时性的。
如果要永久性修改环境变量,需要修改./bashrc或./bash_profile文件
指令 | 功能 | 举例 |
查看环境变量 | ||
env | 显示所有环境变量 | env |
echo $NAME | 显示某个环境变量值 | echo $PATH |
set | 显示本地定义的shell变量和环境变量 | |
新增环境变量给bash | ||
export NAME=内容 | 设置一个新的环境变量,当前bash生效 | export MYENV1=111 env查看 |
删除环境变量 | ||
unset NAME | 清除环境变量(当前bash生效) | unset MYENV1 env查看 |
修改环境变量 | ||
NAME=内容 | 修改环境变量内容为指定内容 | |
NAME=$NAME:内容 | 添加指定内容到环境变量中(当前bash生效) | 见下面代码 |
PATH=$PATH:/home/gyy/linux-system-programming/lesson15
echo $PATH
# /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/gyy/.local/bin:/home/gyy/bin:/home/gyy/linux-system-programming/lesson15
2.4 环境变量的组织方式、存放位置
1. bash为什么能根据环境变量执行指令?(PATH环境变量)
每当我们打开一个终端(终端也是一个进程),终端会系统中继承一份环境变量表。终端进程启动bash,bash在终端进程中继承了环境变量表,并且读取./bashrc和./bash_profile等配置文件中的内容,修改并整合刚刚从终端继承的环境变量表,从而形成自己最终要使用的“完整环境变量表”。(环境表量表也是一个指针数组,存放每个环境变量的字符串)
指令执行流程举例:ls -a -l
1. 命令行参数表拆分命令
2. bash根据命令在环境变量表找到PATH,从PATH中获取ls程序的路径,执行ls -a -l指令。
2. 环境变量的存放位置、存放方式?
系统的相关配置文件:.bash_profile,.bashrc(在用户家目录里面的隐藏文件)
bash程序启动时继承终端环境变量表并整合.bash_profile,.bash_profile配置文件的内容形成完整的环境变量表。
bash中环境变量的存放方式,环境变量表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
3. 如果Linux系统有10个用户登录呢?
存在10个bash,每个bash都要从系统的相关配置文件中读取环境变量形成环境变量表和命令行参数表。
2.5 通过代码获取环境变量(main函数第三个参数-环境变量表)
1. main函数参数获取环境变量表
main函数的第三个参数env,env是获取的父进程的环境变量,当前程序的父进程就是bash
#include<stdio.h>
#include<string.h>
// main最多有几个参数?3个
// env参数是父进程传给我们的
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;
}
环境变量可以被子进程继承,只要是bash的子孙进程,都可以拿到bash的环境变量,即环境变量在系统中通常具有全区特性。
为什么要被子孙进程继承环境变量?子孙进程拿到bash的环境变量后可以结合环境变量做个性化操作,比如:定制一个只能让指定用户执行的程序。(getenv获取USER,指定的USER才能执行对应的程序)
2. getenv函数获取环境变量
getenv头文件<stdlib.h>
getenv("NAME"); //返回对应名称环境变量的内容字符串
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
// main最多有几个参数?3个
// env参数是父进程传给我们的
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获取环境变量表
environ的头文件<unistd.h>,使用时需要声明全区变量。
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
extern char **environ;
// main最多有几个参数?3个
// env参数是父进程传给我们的
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;
}
注意:推荐使用第二种方法,只获取某个环境变量的内容。
2.6 环境变量的全局属性、本地变量、内建命令
• 环境变量通常具有全局属性,可以被子进程继承下去
• 本地变量:不会被子进程继承,只能在bash内部被调用。
• 内建命令(built-in command):不需要创建子进程,而是让bash自己亲自执行。bash自己调用函数,或者系统调用。例如:export
举例:当使用以下命令之久修改当前bash的PATH环境变量时,还有一些指令可以使用,这些就是内建命令。
PATH=/home/gyy/linux-system-programming/lesson15
3. 程序地址空间->进程的虚拟地址空间
3.1 进程的虚拟地址空间 概念
我们之前所说的程序地址空间其实并不应该叫做程序地址空间,应该叫做进程地址空间或 进程的虚拟地址空间,这是一个操作系统层级的概念,不是语言层的概念。之前讲程序地址空间只是为了好理解C语言中变量的存储位置,实际中不止我们的程序代码和变量存在这个位置,还包含了系统注入的信息(如环境变量、命令行参数)以及动态链接库等。
为什么又叫做虚拟地址空间呢?它之所以是‘虚拟’的,是因为操作系统通过虚拟内存机制,为每个进程提供了一个独立、统一的地址空间幻觉,并将其地址映射到物理内存甚至硬盘上,实现了内存管理和保护。
由下列代码可见,环境变量(environ)和命令行参数(argv, argc)就存放在栈区的最高地址区域。
注意:堆区地址由低地址向高地址增长,栈区由高地址向低地址增长
#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;
}
code addr: 0x40057d
init global addr: 0x60103c
uninit global addr: 0x601048
heap addr: 0x148a010
heap addr: 0x148a030
heap addr: 0x148a050
heap addr: 0x148a070
test static addr: 0x601040
stack addr: 0x7fffa66d3cb8
stack addr: 0x7fffa66d3cb0
stack addr: 0x7fffa66d3ca8
stack addr: 0x7fffa66d3ca0
read only string addr: 0x400820
argv[0]: 0x7fffa66d47e5
env[0]: 0x7fffa66d47ed
env[1]: 0x7fffa66d4801
env[2]: 0x7fffa66d480f
env[3]: 0x7fffa66d481a
env[4]: 0x7fffa66d482a
env[5]: 0x7fffa66d4838
env[6]: 0x7fffa66d485c
env[7]: 0x7fffa66d486f
env[8]: 0x7fffa66d4878
env[9]: 0x7fffa66d48bb
env[10]: 0x7fffa66d4e57
env[11]: 0x7fffa66d4e70
env[12]: 0x7fffa66d4eca
env[13]: 0x7fffa66d4efa
env[14]: 0x7fffa66d4f0b
env[15]: 0x7fffa66d4f22
env[16]: 0x7fffa66d4f2a
env[17]: 0x7fffa66d4f39
env[18]: 0x7fffa66d4f45
env[19]: 0x7fffa66d4f7a
env[20]: 0x7fffa66d4f9d
env[21]: 0x7fffa66d4fbc
env[22]: 0x7fffa66d4fc6
3.2 虚拟内存机制证明
• 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
• 但地址值是一样的,说明,该地址绝对不是物理地址!在Linux地址下,这种地址叫做虚拟地址。
• 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。OS必须负责将虚拟地址转化成物理地址。
#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: 100, &gval: 0x601054, pid: 9907, ppid: 9906
父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013
子: gval: 101, &gval: 0x601054, pid: 9907, ppid: 9906
父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013
子: gval: 102, &gval: 0x601054, pid: 9907, ppid: 9906
父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013
子: gval: 103, &gval: 0x601054, pid: 9907, ppid: 9906
父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013
子: gval: 104, &gval: 0x601054, pid: 9907, ppid: 9906
父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013
3.3 一个进程有一个虚拟地址空间+页表+写时拷贝
虚拟地址空间总共4GB,[0,3]GB为用户空间,[3,4]GB为内核空间。
1. 代码中取地址如何找到完整变量?
虚拟地址空间的宽度是1字节,而例如一个整型g_val占四个字节,有四个虚拟地址,我们取地址时拿到的是起始虚拟地址(地址数值最小)的那个然后根据变量类型的偏移量就能找到完整的变量。
2. 页表概念
每个进程有一套“页表”,用来做虚拟地址空间和物理地址空间的映射关系,一个虚拟地址对应一个物理地址。
3. 写时拷贝
因为一个进程有一个虚拟地址空间,所以我们使用fork创建的子进程也有自己的虚拟地址空间。子进程的task_struct(PCB)、虚拟地址空间、页表都是拷贝的父进程的。所以子进程的初始化数据区也有全局变量g_val地址。因为子进程拷贝了父进程的页表,页表中存放的都是虚拟地址和物理地址空间的映射关系也被拷贝了(浅拷贝),所以子进程和父进程中g_val的虚拟地址和物理地址都相同。所以在2.2中父子进程打印的g_val的地址是相同的(父子进程中g_val的虚拟地址相同)!
因为浅拷贝,所以代码也是共享的!
因为我们的进程具有独立性,所以在子进程对g_val修改时,操作系统会进行判断,如果要修改g_val,则会给子进程的g_val重新分配物理地址空间并修改页表(深拷贝)。到此,父子进程中g_val的虚拟地址相同,而各自映射的物理地址不同,所以父子进程对g_val变量的修改互不影响!!这也就是写时拷贝(不修改则先浅拷贝,修改则再深拷贝)!
再回看第五章的3.1.6,fork的两个问题,id和gval都有写时拷贝机制!!!
3.4 虚拟地址空间&物理地址空间-理解+区域划分(概括+举例) [重重重点]
物理内存中完全没有代码区、堆区、栈区这些概念,物理内存只会给每个进程进行简单的分块。代码区、堆区、栈区都是虚拟地址空间的概念!!具体每个虚拟地址空间的布局是由操作系统和编译器在编译器决定的。进程只是在运行时,在这个已经规划好的布局内,动态地使用堆和栈等区域。
1. 理解-虚拟地址空间&物理地址空间(大富翁)
一个富翁有10亿$,有四个私生子,富翁给每个私生子承诺会把10亿$遗产继承给他们(私生子互不认识),所以每个人都会认为自己能继承到10亿$的遗产(画大饼)!
富翁:操作系统;
10亿$遗产:物理内存;
私生子:不同的进程;
大饼:虚拟地址空间;
即:让每个进程都认为自己有4G的物理内存(以32位操作系统为例),或者每一个进程都认为自己独占物理内存。
怎么管理大饼?→怎么管理虚拟地址空间?
先描述,再组织!虚拟地址空间本质时一个数据结构:struct mm_struct{},每个进程的struct mm_struct{},包含各自虚拟地址空间的各种信息。
2. 虚拟地址空间的区域划分(38线)
小时候同桌a,b之间画38线,把课桌平分,将课桌的100cm每个厘米都标注出来(对桌子进行编址,其实桌子的划分就成了0-100的数字,所以可以用int整数来保存地址),a占0-50cm,b占50-100cm。即区域划分只需要确认区域的开始和结束即可!
桌子:地址空间
桌子的100cm:地址空间的2^32各个字节对应的地址
桌子上的刻度:地址空间上的地址
每个小朋友有各自对应的区域:正文代码区、初始化数据区、未初始化数据区、堆区等等。
根据每个区域的开始和结束地址划分地址空间!而struct mm_struct{}里面就保存了各个区域的起始和结束地址!(long code_start; long code_end; long init_start, init_end;……)
但是有一天a惹b生气了,b把a打了一顿,然后把38线调整,a:b=30:70。即为地址空间的区域调整!例:area.b_start -= 20; area.a_end -= 20;,a占0-30cm,b占30-100cm。
3.5 进程运行时虚拟地址空间的开辟 及 与物理地址空间的映射
流程:
1.【编译链接】规划蓝图:编译器与链接器在可执行文件中规定好虚拟地址空间的布局(如.text在0x08048000)。此时虚拟地址空间是“蓝图”。
2.【加载】创建虚拟空间并建立文件映射:执行程序时,OS为新进程创建mm_struct和页表。OS按“蓝图”在虚拟地址空间中划出区域(代码区、数据区等)并设置权限。OS将页表项设置为无效,并将其映射到磁盘上的可执行文件(而非物理内存)。至此,仍未分配任何物理内存。
3.【运行时】按需分配物理内存(由缺页中断驱动):
4.【动态管理】堆与栈的分配:malloc和函数调用最初也只在虚拟空间上操作(移动brk指针或扩展栈指针)。只有当真正读写这些新区域时,才会触发第3步的缺页中断流程,从而分配物理内存。对于堆,分配的是普通物理页;对于栈,可能映射到零页(Zero Page)。
虚拟地址空间<->物理地址空间 ,通过页表映射!(物理地址转化为虚拟地址),将虚拟地址空间提供给上层用户使用。我们的进程控制块task_struct就是mm_struct来管理进程的代码和数据的!!!(第五章3.1进程的执行)
虚拟地址空间是 mm_struct 结构体所管理和描述的那个“对象”
3.6 为什么要有虚拟地址空间?
用户访问的程序中的地址是连续的。而物理内存加载代码和数据的位置已经不重要了,无论代码和数据加载到物理内存的哪个位置,分散的还是聚集的,通过虚拟地址空间的映射,在我们用户看来,他们都是顺序存放的!
1. 虚拟地址空间将地址从“无序”→“有序”。
2. 虚拟地址转换成物理地址。转换过程中,对地址和操作进行合法性、权限等判定,进而保护物理内存。(因为页表中不止存放了虚拟地址空间和物理地址空间的映射关系,还存放了物理地址空间权限的数据,用来保护物理内存)
a. 野指针:堆区没有分配内存,但是要访问野指针指向的地址,野指针指向的是虚拟内存地址,虚拟内存地址要映射到物理内存地址去访问,但由于没有开辟物理内存,所以就没有权限访问物理地址,进而保护物理内存。野指针访问失败,程序就可能崩溃。
b. char *str = "helloworld"; "str = 'H'; 修改常量字符串导致程序崩溃,原因?字符串常量存放在字符常量区(字符常量区在正文代码和初始化数据区之间,跟正文代码编在一块的),则字符常量区的页表权限是只读的,所以当我们对字符串常量进行修改的时候,程序会崩溃。(为什么在字符常量区写入会崩溃?查找页表的时候,权限拦截了!)(这是操作系统的概念,并不只针对某种编程语言。)
3. 让进程管理 和 内存管理,进行一定程度的解耦合。为了实现:安全性、可靠性、高效性和灵活性。(例如: 进程使用realloc时,只需要对mm_struct的对象修改,不需要对PCB修改,方便内存管理)
补:缺页中断,后面讲
3.7 澄清一些问题
1. 我们可以不加在代码和数据,只有tast_struct,mm_struct,页表
2. 创建进程,先有task_struct,mm_struct等,还是先加载 代码和数据?现有数据结构
3. 如何理解进程挂起?比如阻塞挂起:找到进程页表,将物理内存换出到磁盘,并将页表标记为无效。此时PCB和页表仍然存在。
4.虚拟内存管理:在虚拟地址空间中,堆区通常表现为一个连续的大区域(由一个主要的vm_area_struct描述)。malloc等内存分配器在这个连续的虚拟地址空间内进行管理。然而,支撑这个虚拟堆区的物理内存页框是不连续的、分散的。mm_struct通过管理一个由vm_area_struct组成的链表/红黑树,来管理整个虚拟地址空间中的所有区域(包括代码段、数据段、堆、栈以及每个通过mmap分配的独立内存块)