什么是命令行参数呢?首先我们得先知道,主函数是可以传参的!而这个传给主函数的参数就是命令行参数。
我们可以创建一个主函数,并在主函数中接收命令行参数,把命令行参数打印出来观察一下,如下:
图中的 argc 和 argv 就是接收命令行参数的形参,我们观察一下打印出来的数据:
我们看到,打印出来的数据竟然是我们可执行程序的名字,那么 0 又代表什么呢?我们尝试在可执行程序后面加多一些数据,如下:
最后我们得出结论:我们输入的指令中,以空格作为分隔符,被分割成了4个子串,而这4个子串最终会传入主函数中被接收,argc 就是命令行参数的个数,argv 就是被分割的子串;其中 0 号子串一定是我们的可执行程序,后面带的可以说是选项,为什么说是选项呢,因为我们可以通过不同的子串执行不同的代码,例如:
1 #include
2 #include
3
4 int main(int argc, char* argv[])
5 {
6 if(argc != 2)
7 {
8 printf("error!\n");
9 return 1;
10 }
11 if(strcmp(argv[1], "-a") == 0)
12 printf("aaa\n");
13 else if(strcmp(argv[1], "-b") == 0)
14 printf("bbb\n");
15 // ........
16
17 return 0;
18 }
如以上代码,我们可以通过命令行参数,支持各种指令级别的命令行选项的设置!
常见的环境变量有:
什么是 PATH 呢?我们平时在 Linux 中写一份代码,想要运行起来首先需要找到这个可执行程序的路径,所以如果这个可执行程序在当前路径下,就需要在前面加上 ./ ,例如下图:
那么通过上面命令行参数的学习,我们知道,Linux 中的指令也是可执行程序,那么为什么它们的指令不用加 ./ 就能正常运行呢?这就和我们的环境变量 PATH 有关了,PATH 是系统默认的搜索路径,只要将我们程序的路径添加到 PATH 中,我们的程序也不需要加 ./ 就能跑啦!
其中我们可以使用指令 which + 指令
可以查看这个指令所在的路径,例如我们需要查看 ll
指令所处的路径:
接下来我们查看一下 PATH 中的内容,使用指令 echo $PATH
查看;其中 $ 相当于解引用查看的含义,这是 shell 的语法;如下:
如果我们想将我们的当前路径添加到 PATH 中呢?我们首先使用 pwd
查看我们当前的路径:
然后我们将我们的路径复制,然后放到以下的位置,可以使用如下指令:
PATH=$PATH:/home/lmy/.mygitee/Linux_Study/study9
此时我们的路径也就添加到 PATH 中了,我们可以查看一下:
如上图,我们确实将路径添加到了 PATH 中;那么我们现在执行当前路径下的可执行程序时就不用在前面加上 ./ 了;如下图:
如果我们想删除当前路径呢?也很简单,只需要将不需要的部分去掉就行了,假设我们将当前路径在 PATH 中去掉,可以复制除了当前路径的其它路径,然后执行以下指令:
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/lmy/.local/bin:/home/lmy/bin
就把当前路径去掉了;注意 PATH 中是以 : 作为分隔符将各个不同的路径分割开来的。
上面是一种方法,还有另外一种方法,就是将我们的可执行程序直接使用 cp
指令拷贝到 PATH 中的某个路径的文件下也可实现,注意这里拷贝需要提升权限,使用 sudo
即可,这里将不再演示。这种方法就叫做程序安装,本质就是将可执行程序拷贝到系统可以找得到的路径下;程序卸载即是将这个可执行程序从路径下删除即可。
如果我们把 PATH 设置为空串,看看会发生什么情况,如下图:
我们可以看到,大部分的指令都不能用了;此时我们可以重新启动 Xshell 即可恢复。所以我们得出,默认更改环境变量,只限于本次登录,重新登录环境变量自动被恢复,这是为什么呢?我们后面再讲。
我们在使用 pwd
指令的时候,系统怎么知道我们所在的路径在哪里呢?原因是因为 Linux 中也会存在一个环境变量:PWD.
PWD 是给我们当前对应的 bash 内置的一个专门用来记录我们当前所处的路径的一个环境变量。
例如我们先查看一下我们的当前路径:
如上图,PWD 环境变量也会跟着变化;所以 pwd 的指令其实就是读取 PWD 环境变量中的内容打印出来即可。
当我们默认登录 Xshell 时,我们所处的路径/目录是 /home/xxx
,例如:
但是如果我们是以 root 用户登录,我们所处的默认目录将会是 /root
;这是为什么呢?
这是因为当我们登录的时候,首先我们需要输入用户名和密码,等待系统认证;认证完毕后,会形成环境变量,此时肯定不止一个环境变量(PATH, PWD, HOME 等);然后会根据用户名初始化 HOME,即 HOME = /root,HOME = /home/xxx;最后只需要执行 cd $HOME 即可。
我们可以使用 env
指令查看所有的环境变量,Linux 中的环境变量非常多,大家可以自行查看。而每一个环境变量都有它自己的特殊用途,用来完成特定的系统功能。
我们有一个接口可以通过代码直接获取环境变量,就是 getenv
,我们可以通过 man 指令查看一下:
我们可以使用一下 getenv
查看一下 PATH 环境变量,如下代码:
1 #include
2 #include
3
4 int main()
5 {
6 printf("%s\n", getenv("PATH"));
7 return 0;
8 }
执行结果如下:
我们上面所说每一个环境变量都有它自己的特殊用途,如何体现出来呢?下面我们结合 getenv
简单使用一下,如下代码:
#include
#include
#include
int main()
{
char* s = getenv("USER");
if(strcmp(s, "root") != 0)
{
printf("%s 是非法用户!\n", s);
return 1;
}
printf("Hello!\n");
printf("Hello!\n");
printf("Hello!\n");
return 0;
}
我们使用环境变量 USER 去判断当前用户是否是 root,我们只让root 执行相应的代码;如下运行结果:
当我们是 root 用户:
当我们是普通用户:
1.通过我们上面所学的命令参数,我们知道命令行参数可以有两个,但是其实还可以有第三个命令行参数,那就是 env!
env 其实就是一张环境变量表,系统启动我们的程序时,可以选择给我们的进程(main)提供两张表:1. 命令行参数表 2. 环境变量表;其中这个第三个参数 env 就是环境变量表,env 是一个指针,指向的是一个函数指针数组,可以参考下图理解:
其中右边一大串的是环境变量;通常 env 的最后一个元素是指向空的;
我们也可以通过代码打印出来观察;我们可以打印当前程序运行的 pid,如下段代码:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7 int main(int argc, char* argv[], char* env[])
8 {
9 int i = 0;
10 for(; env[i]; i++)
11 {
12 printf("pid: %d, env[%d]: %s\n", getpid(), i, env[i]);
13 }
14
15 return 0;
16 }
运行的结果大家可以自行运行后查看;那么我们为什么要打印出它的 pid 呢?那么我们不妨想想,这个进程的 env 是谁传给我们的呢?就是 bash!我们命令行启动的进程都是 shell(bash) 的子进程,子进程的命令行参数和环境变量,都是父进程 bash 传递的!
那么问题又来了,父进程 bash 的环境变量又从哪里来呢?其实环境变量是以脚本配置文件的形式存在的;我们也可以找到这个文件,例如我们回到我们的家目录下,有一个隐藏文件叫做 .bash_profile
,如下图:
每一次登录的时候,我们的 bash 进程都会读取 .bash_profile
这个文件中的内容,为我们 bash 进程形成一张新的环境变量表信息!这也就能解释了我们上面的一个问题:我们将 PATH 的路径改成空,重启 Xshell 之后就会恢复正常了。
2.另一个问题,我们也可以创建属于自己的环境变量,如下图,直接在命令行中输入即可:
此时我们在环境变量表中查看一下:
发现并没有导入到我们的环境变量表中;或许我们可以直接在我们的可执行程序中查找:
也一样没有,因为我们还没有导入到环境变量表中,我们需要把它导入到环境变量表中,使用命令 export MYENV_LMY
即可,如下:
我们也可以直接在创建环境变量时导入,使用指令
export 环境变量名称=内容
,如下图:
我们可以看到它们两个都出现在了环境变量表中;如果此时我们退出再重新登录,它们两个还存在吗?答案是不存在了!因为以上这两个环境变量并没有写入配置文件中!改变的只是当前 bash 内部的环境变量表,当我们退出重新登录后,bash 会重新读取 .bash_profile
文件从而重新获取环境变量表!上面这种只在 bash 进程内部有效的叫做本地变量!
所以我们想要我们自己的环境变量永远生效,我们需要把它添加到 .bash_profile
配置文件中,如下图所示:
此时我们保存退出,重新登陆后,就可以查到我们对应的环境变量:
这种通过子进程继承的方式继承环境变量表的,就叫做环境变量!
我们知道了命令行参数表和环境变量表都可以通过继承给子进程的方式让子进程继承,这就表明系统环境变量具有全局属性!
第二种方式获取环境变量需要我们写命令行参数传入,也有一种方式不需要写命令行参数就可以获取环境变量表,就是通过系统给我们提供的 environ 指针;这个指针我们使用的时候需要使用 extern 声明一下,因为不是我们自己定义的,是系统给我们提供的,其实 environ 就是指向 env 的指针,它们的关系如下图:
我们也可以通过 environ 打印出环境变量表,如下段代码:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7 int main()
8 {
9 extern char** environ;
10 int i = 0;
11 for(; environ[i]; i++)
12 {
13 printf("pid: %d, environ[%d]: %s\n", getpid(), i, environ[i]);
14 }
15
16 return 0;
17 }
运行的结果大家可以自行尝试去观察,结果和第二种方法是一样的。
上面我们也简单地介绍了一下本地变量和环境变量,接下来我们进一步分析它们之间的区别:
我们首先回忆起当我们把 PATH 设为空时,是不是有一些命令能跑,有一些命令不能跑呢?我们再次尝试一下,如下图:
我们可以看到,确实有些命令是跑不了了,但为什么诸如 pwd、echo 这样的命令还能跑呢?原因是因为 Linux 中的命令可分为两类:
常规命令是 shell 命令行解释器进行 fork 让子进程执行的。
内建命令是 shell 命令行的一个函数,建立在 shell 的内部,所以可以直接读取 shell 内部定义的本地变量!
echo 是显示某个环境变量值;这个我们在上面也用过,例如需要查看 PATH 的环境变量值:
export 是将本地变量导入到环境变量表中,即设置一个新的环境变量,我们在上面也使用过。例如我们先设置一个本地变量,此时是可以用 echo 查看的,因为 echo 是内建命令;但是在环境变量 env 中没有这个本地变量,如下图:
我们可以将该本地变量导入环境变量中,就能在 env 中查看到:
unset 是清除环境变量,删除某个环境变量。例如我们要删除上面我们自己定义的环境变量 MYENV,如下:
此时本地变量和环境变量中都没有了。
env 是显示所有环境变量,这个我们上面也介绍过。
set 是显示本地定义的 shell 变量和环境变量。例如我们定义一个本地变量 local_env 和一个环境变量 myenv,此时我们在环境变量中查看的时候只有 myenv,如下图:
但是我们使用 set 查看的时候两个都能看见,如下图:
我们以前大概了解过在 C/C++ 中诸如下图的空间分布图:
其中堆和栈是相对而生的,堆区往上增长,栈区往下增长,其实堆区和栈区中间还有其它空间,我们后面在学;静态数据变量也存在于数据段中,其实静态变量会被编译器修饰成全局变量,所以它会被放到数据段中。
如何证明我们程序的地址是按照以上的空间分布呢?下面我们使用代码验证一下,如下段代码:
1 #include
2 #include
3
4 int init_val = 1;
5 int uninit_val;
6
7 int main()
8 {
9 char* str = "Hello";
10 char* heap1 = (char*)malloc(10);
11 char* heap2 = (char*)malloc(10);
12 char* heap3 = (char*)malloc(10);
13 char* heap4 = (char*)malloc(10);
14 static int static_val1 = 2;
15 static int static_val2;
16
17 printf("栈区地址1:%p\n", &heap1);
18 printf("栈区地址2:%p\n", &heap2);
19 printf("栈区地址3:%p\n", &heap3);
20 printf("栈区地址4:%p\n", &heap4);
21
22 printf("堆区地址4:%p\n", heap4);
23 printf("堆区地址3:%p\n", heap3);
24 printf("堆区地址2:%p\n", heap2);
25 printf("堆区地址1:%p\n", heap1);
26
27 printf("未初始化静态变量地址:%p\n", &static_val2);
28 printf("未初始化全局数据区:%p\n", &uninit_val);
29 printf("已初始化静态变量地址:%p\n", &static_val1);
30 printf("已初始化全局数据区:%p\n", &init_val);
31 printf("字符常量区地址:%p\n", str);
32 printf("代码区地址:%p\n", main);
33
34 return 0;
35 }
运行结果如下,结果确实是这样的:
我们单独拿栈区出来分析,我们在局部创建的数组、结构体都是在栈区中向下增长开辟空间的,假设我们有一个 a[10] 的数组,一个 struct A = {x, y, z} 结构体,那么 a[0] 和 a[9] 的地址谁大呢?A obj 中,&obj.x 和 &obj.z 谁的地址大呢?下面我们写个程序验证一下,如下代码:
#include
#include
typedef struct A
{
int x;
int y;
int z;
}A;
int main()
{
int a[10];
A obj;
printf("%p\n", &a[0]);
printf("%p\n", &a[9]);
printf("%p\n", &obj.x);
printf("%p\n", &obj.y);
printf("%p\n", &obj.z);
return 0;
}
结果如下:
所以我们得出结论,栈区是往下开辟申请空间,但是使用的时候是局部往上使用的,可以用下图来概括:
如上图,如果我们在栈上定义了一个变量 int b,那么我们知道 int 是占四个字节的,而我们上面的空间中每个空间占一个字节,那么我们 &b 拿的是哪个地址呢?其实是拿最低的地址,然后是通过起始地址 + 偏移量的方式进行访问。
其实除了上面空间分布中的区域外,还有一些我们还没学,所以我们以后再介绍,但是在栈区上面有两个是我们刚学习的区域,就是命令行参数和环境变量。
所以我们上面学的空间分布,它到底是什么呢?它是内存吗?我们下面开始学习。
首先我们回顾一下我们以前学习 fork 的时候,父子进程之间是怎么运行的,我们这时候想起来还有一个问题还没解决,那就是当子进程修改代码时,会发生写时拷贝,但是一个变量不同的值为什么会有相同的地址呢?这就是我们接下来需要学习的;首先我们把代码再敲出来,如下段代码:
1 #include
2 #include
3 #include
4
5 int g_val = 100;
6
7 int main()
8 {
9 pid_t id = fork();
10 if(id == 0)
11 {
12 //child
13 int cnt = 5;
14 while(1)
15 {
16 printf("i am child, g_val: %d, &g_val=%p\n", g_val, &g_val);
17 sleep(1);
18 if(cnt == 0)
19 {
20 g_val=200;
21 printf("child change g_val: 100->200\n");
22 }
23 cnt--;
24 }
25 }
26 else
27 {
28 //father
29 while(1)
30 {
31 printf("i am father, g_val: %d, &g_val=%p\n", g_val, &g_val);
32 sleep(1);
33 }
34 }
35 sleep(100);
36 return 0;
37 }
我们定义了一个全局变量 g_val,当子进程对它进行修改时,父进程还是原来的 g_val,因为父子进程之间的数据互不影响,具有独立性;我们观察运行的结果会发现,它们的地址竟然是一样的!如下图:
那么为什么对同一个地址进行读取会得出不同的内容呢?所以通过上图我们得出的结论是,我们在C/C++平常所见到的地址绝对不是物理地址,其实都是虚拟地址/线性地址!
其实,我们上面所学的空间分布的那张图,就是进程地址空间,里面的地址全都是虚拟地址!如下图:
但是我们的进程需要被cpu调度,进程中的数据要被cpu读取识别,就必须加载到内存中,即物理内存中。那么这个过程到底是怎样的呢?其实我们上面的代码所打印出来的地址,全部都是它的进程地址空间的地址,也就是虚拟地址,而这个可执行程序是 bash 的子进程啊!而这个父进程在代码中又创建自己的子进程,也有它自己的进程地址空间,所以我们认为,每一个程序运行之后,都会有一个进程地址空间的存在!
那么我们现在已经有三个重要的角色了,分别是 pcb(task_struct)、进程地址空间、物理内存,其中 task_struct 中肯定有一些字段是指向属于自己的进程地址空间的;而物理内存中也一定需要存储该进程的一些数据的,在物理内存中存储这个数据的地址才叫做物理地址,如下面的 0x11111111;所以它们现在的关系如下图:
那么进程地址空间和物理内存之间是如何联系起来的呢?在操作系统中,为了让进程找到物理内存中自己的数据,会为每个进程维护一张映射表,它要为进程地址空间和物理内存之间构建一种映射关系,而这个表叫做页表。它的左侧放的是虚拟地址,右侧是数据在物理内存中的地址,它们两者之间是一种映射关系。可以结合下图理解:
如上就是页表的简单结构,页表中还有其它字段我们后面再介绍;其中进程通过进程地址空间中的地址可以查找页表找到对应在物理内存中的物理地址。
我们从上面知道,进程在运行之后都会有一个进程地址空间的存在,而在系统层面它们都要有自己的页表映射结构;因为我们的上面的父进程也会有子进程,所以操作系统会将父进程的 pcb 拷贝一份给子进程,所以子进程也会有自己的进程地址空间和页表,都是从父进程那获取的,它们俩互相独立,互不影响,例如下图:
当我们的子进程对数据进行修改时,通过页表找到相应的数据,但是操作系统发现父进程正在使用这个数据,所以子进程不能直接对该数据进行修改,因为它们具有独立性,所以操作系统会将物理内存中的值进行写时拷贝,生成一块新的物理地址,然后将子进程想要修改的数据覆盖之间的数据即可;这时候子进程中的物理地址就发生变化,结合下图理解:
所以我们就理解了为什么它们会有相同的地址而值却不一样,因为它们两个进程都有之间独立的进程地址空间和页表,写时拷贝发生在物理内存中,改变的也是物理地址,虚拟地址并没有改变,所以相同的虚拟地址并不互相影响。
我们从上面知道,物理内存是操作系统直接管理的。当我们的进程需要使用空间时,操作系统会给进程一个虚拟地址空间,这个虚拟地址空间就是地址空间。当我们的进程多起来的时候,既然操作系统要把进程管理起来,那么操作系统也要把地址空间管理起来,因为如果不管理起来就会乱套了。那么如何管理起来呢?我们下面再说。
所谓的区域划分,就是用 start 和 end 这样的 int 或者 long long 变量来区分一个线性空间的开始和结束;空间大小可以调整,调整时只需要将 start 和 end 的值进行扩大或缩小即可;不要只看到空间的范围,空间范围内的地址我们都可以使用。
地址空间要被操作系统管理起来,因为每一个进程都有一个地址空间,系统中一定要把地址空间做管理。如何管理呢?我们以前学过,先描述,在组织,所以地址空间最终一定是一个内核的数据结构对象,就是一个内核的结构体!
我们发现,进程地址空间中的内容全都是区域划分!所以我们就可以进行区域划分进行管理!
在 Linux 中,这个进程/虚拟地址空间叫做 struct mm_struct
,其中它大概就长下面这个样子:
struct mm_struct
{
long code_start;
long code_end;
long data_start;
long data_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
......
}
其中 struct mm_struct
这个结构体是由一个叫做 struct mm_struct* mm
的指针指向的,而这个指针存在于 task_struct
中,所以每个进程被创建的时候都会创建一个 struct mm_struct* mm
,也就有了一个进程地址空间;所以我们的进程地址空间为什么不是内存呢,原因就是因为它只是一个内核数据结构。
为什么这么说呢?我们思考一下,当我们的程序加载到内存中时,它是有顺序地加载的吗?并不是的,程序加载到内存是乱序的,但是通过地址空间和页表就可以做到将乱序的内存数据变为有序,让进程认为这些数据就是按照它的方式进行分类的!
首先我们再要了解一下,在页表中,还有一列叫做访问权限字段的东西,它的结构就如下:
访问权限字段有什么用呢?它会在访问物理内存的时候检查对应的权限是否满足,或者检查这个地址是否合法,如果权限不满足或者地址非法,就会在页表中直接拦截,就不允许访问物理内存中的地址。
例如以下代码:
int main()
{
char* s = "hello";
*s = 'h';
return 0;
}
上面这段代码是不能正常运行的,因为我们知道 “hello” 是常量,不可被修改,那么这是为什么呢?这时候我们就知道了,s 的地址是在进程地址空间中的字符常量区的,而通过字符常量区映射的页表中的访问权限字段是只读的,即 r,所以当我们需要写入修改时,在页表就直接被拦截了。
另外,进程进行各种转换、各种访问,这个进程一定是正在运行!所以在 cpu 中有一个寄存器叫做 CR3,会存放页表的地址,这个页表的地址是物理地址。因为这个进程正在cpu 内运行,所以CR3中的内容本质是在该进程的硬件上下文内容当中!所以当该进程切换出去的时候,本质这个CR3寄存器的内容会保存到当前进程的上下文里。所以每个进程都有自己的页表。
在操作系统层面,当我们在磁盘中有程序需要加载到内存时,首先需要在内存中申请内存,然后填充内容和页表,然后建立映射关系;其中页表中还有一列内容,是专门判断某个地址是否在内存中有分配内存和是否有内容的,里面可能是两个比特位,例如 1/0 表示是/否分配有内存,1/0 表示 是否在内存中有内容;例如下图:
当我们的进程拿着一个虚拟地址来找物理地址的时候,假设这时候内存还没有给它分配物理地址,此时操作系统就会把该进程暂停,并从磁盘中加载相对应的程序到内存中,然后再填充页表,建立映射关系;这个过程叫做缺页中断!这个概念我们以后还会介绍,现在先了解一下。
当我们上面在内存中申请内存,然后填充内容和页表,然后建立映射关系,这一套流程叫做内存管理;而这个过程进程是不需要理会的,而且进程也不知道这个过程;进程需要做的只是该调度的就调度,该访问的就访问,这一套叫做进程管理;进程管理不关心内存管理,所以进程管理和内存管理因为有了地址空间和页表的存在实现了操作系统层面上的模块的解耦!
最后,通过页表,还可以让进程映射到不同的物理内存中,从而实现进程的独立性!
我们当前所能用的地址空间都是在用户空间中使用的,在地址空间中,还有一部分空间是要留给操作系统自身用的;其中我们用户用到的空间叫做用户空间,有 3GB;操作系统自身用的空间叫做内核空间,有 1GB. 如下图所示:
在 struct mm_struct
结构体中,其实还有一个指针,它指向的是一个 vm_area_struct
的结构体,它有什么用呢?在我们的地址空间中,被划分成了很多区域,但是总有一些区域还没有被使用的,所以当我们想要划分自己的区域的时候,可以申请一个 vm_area_struct
的对象,这个对象中有 start 、end 两个值,分别是空间的开始和结束,还有一个 next 的指针指向下一个结构体的对象,它们之间构成一个链表的结构,所以有了这样一个结构体,我们就能根据自己的需求划分属于自己的空间了!可以根据下图进行理解:
其中我们的 mm_struct
结构体其实真正叫做内存描述符;而 vm_area_struct
叫做线性空间;这两个概念合起来才叫做地址空间!但是由于方便,我们认为 mm_struct
才是地址空间!