文章目录


命令行参数与环境变量 & 程序地址空间


一、命令行参数

1.1 main 函数可以包含参数吗?

我们经常写代码时,main 函数本身可以包含参数吗?答案是:main 函数可以包含参数。我们同学以前在写代码时,应该见过有人是这样写的:

int main(int argc, char *argv[])

其中第二个参数 argv 是一个指针数组。指针数组当中包含的元素是一个一个的 char * 类型。

char * 这个东西,在 C 语言当中,要么是用来指向字符类型(字符的地址),要么是指向字符串的。大部分情况下,我们同学以前接触到的 char * 类型,基本上指向的全都是一个字符串。这也正常,因为 C 语言当中没有字符串类型(C++ 有 string 类型),所以它就只能用指针的形式来表达。

所以,argv 就是一个 char * 类型的指针数组

而第一个参数 argc 表示的是数组中元素的个数。既然 argv 是个指针数组,那么该数组当中有效元素是多少个呢?答案是有效元素是 argc 个

1.2 argv 里面保存的是什么?

那么,这个数组里面保存的是什么呢?是什么字符串?不废话,直接上代码测试。

cd code/lesson18
touch myproc.c
#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;
}

编译运行:

gcc -o myproc myproc.c
./myproc

输出结果:argv[0] 里面保存的是 ./myproc

再运行带参数的情况:

./myproc abc bcd

输出:

argv[0]: ./myproc
argv[1]: abc
argv[2]: bcd

我们发现,这个数组竟然是动态变化的。argv[0] 代表的是可执行程序的名字。再多一些参数:

./myproc abc bcd 12 34 56 78

此时我们发现,在命令行当中输入的所有内容,最后就会把它传递给 argv 被我们的 main 函数拿到。也就是未来传递给 main 函数的 argv 里面,将来就是在命令行当中以空格作为分隔符的一个一个的参数。argc 代表的是数组有效元素的个数。

1.3 用命令行参数实现选项功能

接下来写一段看起来"莫名其妙"的代码:

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    if (strcmp(argv[1], "-a") == 0) {
        printf("这是我的程序的功能一\n");
    } else if (strcmp(argv[1], "-b") == 0) {
        printf("这是我的程序的功能二\n");
    } else {
        printf("这是我的程序的默认功能\n");
    }
    return 0;
}

(如果 argc 未使用产生告警,可以用 (void)argc; 来消除。)

编译运行:

./myproc -a    # 输出:这是我的程序的功能一
./myproc -b    # 输出:这是我的程序的功能二
./myproc -c    # 输出:这是我的程序的默认功能
./myproc d     # 输出:这是我的程序的默认功能

这揭示了一个重要事实:当我们输入 ls -als -lls -nls -i 的时候,ls 是同一个程序,它会根据选项的不同,呈现出不同的功能——这种功能就是通过命令行参数完成的。

1.4 命令行参数的概念

给 main 函数传递对应的参数,我们称之为命令行参数。

命令行参数允许我们在命令行当中,例如输入:

./proc ad abcd 1234 5678

那么 bash 会以空格作为分隔符,把我们输入的一个长串字符串解析成一个字符串、两个字符串、三个字符串、四个字符串,然后把这四个字符串依次填充到一个叫 argv 的表里面。这个表:

  • argv[0] —— 对应可执行程序的起始地址(程序名)
  • argv[1] —— 指向第一个选项
  • argv[2] —— 指向下一个选项
  • argv[3] —— 指向最后一个选项

**最后一个元素永远必须以 NULL 结尾。**为了能够表示这个数组一共有多少个有效元素,argc 就指明它数组当中的有效元素个数。

换句话说,我们的操作系统会在命令行当中获取当前用户命令行输入的、以空格作为分隔符的一个一个的子串,把这些子串的起始地址填充到这张表里面,然后让这张表以 NULL 结尾,进而把 argc 和这张表传递给 main 函数,从而可以让我们的函数实现多选项功能。

我们平时所用的所有指令——lspwdwhoamiwhereis 等等——这些命令基本都是 C 语言写的。它只要是 C 语言写的,它就会包含命令行参数,它就会获取比如 ls -a -l -n 中的 -a-l-n 这些选项,根据命令行参数的方式交给当前程序,程序就能够得到用户命令行输入当中所对应的选项,进而根据不同的选项呈现出不同的功能。

1.5 命令行参数的省略与加载

main 函数的命令行参数可以省略。因为我们的加载器在加载运行我们自己的程序时,它会对命令行参数做判断:如果 argc 为 0(也就是这个数组为空),它会调用一个无参的 main 函数;如果你传递了命令行选项,那么加载器在启动时调用的 main 函数其实是带参的——也就是把命令行当中获取的 argcargv 传递到 main 函数当中。

在 Windows 系统中也有类似的功能。比如 shutdown 命令:

  • shutdown -s —— 关闭计算机
  • shutdown -t —— 设置关机时间
  • shutdown -a —— 终止系统关闭

shutdown 也是用 C 语言写的,它通过系统调用的方式把操作系统直接关掉,它也会有传递对应的选项。

1.6 命令行参数表的形成

解析命令行参数形成指针数组这个工作,通常是由父进程来做的。更准确地说,通常情况下是由 bash 来解析的。bash 解析好了之后,是通过程序替换的功能把参数传递给新进程的(这部分是后续要讲的系统调用内容)。

每一个进程在启动的时候,都会有这样一张表,这张表叫命令行参数表。这张表可以为空,但很多情况下如果我们带选项了,它就不为空了。


二、环境变量

2.1 什么是环境变量?

环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。例如:我们在编写 C/C++ 代码的时候,在链接的时候,从来不知道我们所链接的动态库 / 静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。

环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性

变量这个东西永远都有变量的名字和变量的内容。环境变量既然是"变量",它也必然有变量名和变量内容。只不过这批变量的内容通常和系统相关,各管一摊事。

Windows 中的环境变量

在 Windows 中,打开"控制面板 → 系统 → 高级系统设置 → 环境变量",就可以看到:

  • 系统环境变量:对所有用户有效
  • 用户环境变量:只对该用户有效

常见的如:

  • PATH:搜索可执行程序的路径
  • OS=Windows_NT:操作系统信息
  • PROCESSOR_*:CPU 相关信息

举个例子:为什么有些软件(如网易云音乐 cloudmusic.exe)用快捷方式双击就能启动,但在命令行直接输入 cloudmusic 就不行呢?

因为快捷方式直接指向了目标文件的绝对路径,系统自动解析了快捷方式就找到了它。而在命令行中直接运行一个命令,系统需要去环境变量 PATH 所指明的路径里逐一查找。如果安装路径没有被添加到 PATH 环境变量中,系统就找不到。

所以,当你安装过 Git、Python、VS 等开发工具时,它们都要求你配置环境变量。一旦配置好 PATH,就可以在系统的任意地方以命令的方式执行对应的工具。

VS 不仅仅是一个软件——它安装的是一套工具链:文本编辑器、编译器、调试器等,这些都是一个个独立的可执行程序。为了让系统随时随地能找到这些工具,就需要把它们的路径添加到环境变量里。

2.2 Linux 下的常见环境变量

正式定义:环境变量是系统级变量。它的变量内部保存的是系统相关启动 / 运行时的核心参数。这些核心参数在开机时就为我们确定好了,会随着系统的运行一直存在,必要时有的环境变量还会一直进行更新。这些变量在系统当中通常具有全局属性

2.2.1 PATH

在 Linux 当中也存在一个环境变量叫 PATH

要查看一个环境变量的内容,需要用 echo$ 符号:

echo $PATH

输出类似:

/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin

该环境变量以冒号作为分隔符来标定一堆路径。

为什么我们自己的可执行程序需要带路径,而系统命令不需要?

以我们自己写的 myproc 为例:

# 直接运行不行
myproc          # command not found

# 必须带路径
./myproc        # 正常运行

lspwdtopwhichmake 这些命令都是用 C 语言写的,凭什么它们运行时不需要带路径呢?

答案是:系统开机的时候,会维护一个叫 PATH 的环境变量。这个环境变量会被加载到当前 bash 进程的上下文里。系统命令几乎都在 /usr/bin 等路径下,当你在命令行输入 ls 时,系统会去 PATH 环境变量所标识的路径里面一个一个地找,找到了就帮你直接执行(类似于 /bin/ls),所以不需要带路径。

而我们自己的可执行程序,因为系统在 PATH 指定的路径里找不到它,所以必须用 ./ 告诉系统"就在当前目录下"(. 表示当前,/ 是路径分隔符)。

两种让程序无需带路径运行的方法:

方法一: 把可执行程序拷贝到系统 PATH 所指明的路径下:

sudo cp myproc /usr/local/bin/
myproc   # 现在可以直接运行了
sudo rm /usr/local/bin/myproc
myproc   # 删掉之后又不行了

指令的本质就是一个二进制程序,只不过被系统能"掌握"罢了。

方法二: 把当前路径添加到 PATH 环境变量里。

错误做法(覆盖式修改,会导致系统命令全部失效):

PATH=/current/working/directory
# 然后 ls、top、mkdir、touch 等绝大部分命令都跑不起来了
# 因为 PATH 原来的内容被清掉了,只保留了你当前的这个路径

这从另一个角度证明了:Linux 系统能直接执行某些命令,不是因为系统"自带"了这些命令,而是因为环境变量 PATH 里面记录了你应该去哪里搜索命令的路径。

恢复方法:关掉 SSH 重新登录即可。由此可见,在命令行当中改变环境变量的内容,它的修改并不会影响系统本身,它只是做内存级修改

正确做法(追加式修改):

export PATH=$PATH:/当前工作路径
echo $PATH   # 可以看到当前路径已经被追加进来了
myproc       # 现在可以直接运行了,其他命令也能正常工作
which 命令的原理

which 命令查找某个命令的路径,其实就是在遍历 PATH 环境变量里的路径。它会把你要 which 的程序名和 PATH 里的路径依次做拼接(如 /usr/local/bin/myproc/usr/bin/myproc……),然后确认文件在指定路径下是否存在,存在就结束查找,把路径返回给你。

2.2.2 HOME
echo $HOME

HOME 代表的是当前用户所对应的家目录

不同用户登录时,HOME 的值不同:

  • 普通用户 whb/home/whb
  • root 用户:/root

所以为什么你登录时默认所处的路径就是你的家目录?因为在环境变量里,系统提前给你配置好了 HOME

cd ~HOME 的关系:~ 展开后就是 HOME 的值。

2.2.3 PWD 与 OLDPWD
echo $PWD       # 当前工作路径
cd /
echo $PWD       # 输出 /
echo $OLDPWD    # 输出上一次的工作路径

PWD 用来记录当前用户所处的路径,随着用户对自身路径做切换,系统自动维护 PWD

OLDPWD 记录的是上一次的工作路径。

cd - 的原理:它可以在最近两次路径当中互相跳转,因为 PWDOLDPWD 已经把最近这两次所在的路径都记录下来了。当你 cd - 时,直接进入到 OLDPWD 所记录的路径。

2.2.4 用 env 查看所有环境变量
env

使用 env 命令可以把当前用户登录系统时的所有环境变量全部呈现出来。

常见环境变量及含义:

环境变量 含义
SHELL=/bin/bash 当前登录采用的 Shell
HOSTNAME=... 当前机器的主机名
TERM=xterm 当前登录终端类型
USER=whb 当前登录的用户是谁
SSH_CLIENT=... 远程登录的客户端信息
SSH_TTY=/dev/pts/0 登录时分配的终端文件
LANGUAGE=... 当前支持的语言、编码格式
LS_COLORS=... ls 命令执行时的配色方案
HOME=... 当前用户的家目录
PWD=... 当前用户所处的工作路径
OLDPWD=... 上一次的工作路径
LOGNAME=... 当前真实登录的用户
HISTSIZE=1000 历史命令记录条数
2.2.5 HISTSIZE

Linux 系统会记录用户输入的所有命令。按上键就能翻到历史命令,history 命令可以查看:

history

但是,如果 Linux 系统用上三年五年,输入了几十万条指令,全部记录的话数据量太大,搜索效率降低,太陈旧的命令也不一定有用了。所以 Linux 系统说:只允许记录最近用户输入的 1000 条指令。

凭什么说只记录 1000 条?因为系统的环境变量里有一个 HISTSIZE=1000。这 1000 条命令被维护成一个队列(大小上限为 1000),一旦用户再输入新命令,就入队列、出队列,始终只维护最新的 1000 条。

2.2.6 其他环境变量
  • SSH_TTY=/dev/pts/0:系统给当前用户分配的终端文件。如果再登录一次,会给另一个终端文件(如 /dev/pts/1)。如果你在 pts/1 写一串字符,重定向到 /dev/pts/0,那就是给 pts/0 那个终端写。这也就解释了为什么当前启动的所有进程默认都会往对应的终端上输出——因为环境变量里记录下来了你的终端文件是谁,所有子进程都会继承这个信息。
  • SHELL=/bin/bash:当前使用的 shell 是 /bin/bash
  • USER:当前登录用户是谁。

2.3 环境变量的组织方式

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 '\0' 结尾的环境字符串。环境字符串的格式为:环境变量名=环境变量内容

整个这张表最后一个元素必须以 NULL 结尾。

2.4 通过代码获取环境变量

方法一:main 函数第三个参数
#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;
}

char *env[] 也是一个指针数组,结构和 argv 一模一样。只不过它指针所指向的内容不再是命令行参数,而是一个一个的环境变量字符串。每一项都是一个环境变量字符串,包括环境变量名、等号、环境变量内容。整个表最后一个元素必须以 NULL 结尾。

在 99.999% 的情况下,这个参数一般不写,因为有第二种方式也能获取。

方法二:通过第三方全局变量 environ
#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 声明。

为什么 environ 的类型是 char **(二级指针)?因为环境变量表是一个 char * 的指针数组,数组里的元素是 char *,我们要指向一个一个指针对应的地址,就必须用 char **(二级指针)来指向。

注意:编译时可能会报 environ 未定义的错误,需要在代码中显式声明:

extern char **environ;
方法三:通过 getenv 函数

这是最常用的一种方式,因为它可以根据实际情况帮助获得指定名称的环境变量。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("PATH: %s\n", getenv("PATH"));
    printf("HOME: %s\n", getenv("HOME"));
    printf("PWD: %s\n", getenv("PWD"));
    printf("HOSTNAME: %s\n", getenv("HOSTNAME"));
    printf("NONEXIST: %s\n", getenv("NONEXIST"));  // 不存在的环境变量返回 NULL
    return 0;
}

getenv —— 根据环境变量的名字获得指定的环境变量内容:

  • 如果获取成功,返回环境变量的(只返回等号右侧的部分,不是整个 “变量名=内容” 的字符串)
  • 如果获取失败,返回 NULL

它的原理是在环境变量表里面搜索字符串前缀:是不是以 SHELL 开头、以 PATH 开头、以 HOME 开头……找到后返回等号右侧的内容。

2.5 环境变量的理解

2.5.1 命令行参数表和环境变量表

任何一个进程在启动时,都会存在两张表

  1. 命令行参数表
  2. 环境变量表

这两张表都是由 bash 进程提供的。

命令行参数表:当用户输入命令字符串(如 ls -a -l)后,bash 会在内部进行解析,形成命令行参数表。在 C 语言层面,可以理解为 bash 内部类似于:

char **argv = malloc(sizeof(char *) * (argc + 1));
// 把用户输入的字符串的地址依次填入这张表

最后以 NULL 结尾。

环境变量表:同理,bash 进程内部也类似于 malloc 申请一个结构,构建环境变量表(元素个数 = 环境变量个数 + 1)。

2.5.2 环境变量的继承——为什么具有全局属性?

在命令行当中启动的所有进程,都是 bash 的子进程

两表在 bash 进程当中属于全局数据。如同当年我们 fork 之后,代码是共享的,数据以写时拷贝的方式各自持有,但命令行参数和环境变量一般我们不改。不改的话,子进程天然地就能够继承父进程的环境变量信息

这就是为什么我们可以通过 extern char **environ 或者 getenv 直接获得环境变量——因为这个表信息已经被子进程继承了,在子进程内部它是能看到父进程所创建的环境变量表的,就好比父子进程当中创建一个全局变量,父子进程都能看到一样。

如果子进程再创建子进程呢?环境变量表同样会被依次继承下去。所以环境变量具有全局属性——本质是因为环境变量会被以全局数据的形式被所有子进程依次继承。你在 bash 这里定一个环境变量,未来所有的子进程都能看到。

2.5.3 用 export 导出环境变量来验证继承
# 创建一个测试程序
vim myproc.c
#include <stdio.h>
#include <stdlib.h>

int main()
{
    char *env = getenv("MYENV");
    if (env) {
        printf("%s\n", env);
    } else {
        printf("MYENV not found\n");
    }
    return 0;
}
# 直接运行——没有结果,因为 MYENV 根本不存在
./myproc

# 用 export 导出环境变量
export MYENV="hello world"

# 查看是否导出成功
env | grep MYENV

# 再次运行程序——发现结果有了!
./myproc   # 输出:hello world

这证明了:环境变量是可以被子进程继承下去的!

unset 可以取消环境变量:

unset MYENV
env | grep MYENV   # 环境变量不存在了
./myproc           # 又获取不到了
2.5.4 本地变量 vs 环境变量

在 shell 命令行中也可以直接定义变量(因为 shell 是解释型语言):

i=10
echo $i       # 输出 10

a=100
echo $a       # 输出 100

ok=1234
echo $ok      # 输出 1234

# 运行程序,程序获取不到 ok
./myproc      # 获取不到

这种直接用 变量名=变量内容 定义出来的变量,我们称之为本地变量(又叫普通变量)。本地变量只在 bash 内部有效。bash 把它当成普通字符串维护在自己的内部缓冲区,不会把它添加到环境变量表里面。因为只有环境变量的信息才会被子进程继承,本地变量不能继承——它只在本 bash 内部有效,一旦交给子进程去运行,子进程就看不到了。

set 命令可以查看所有变量(包括本地变量和环境变量):

set
# 会输出大量内容,包括本地变量 a=100、i=10、ok=1234 等

env 命令只查看环境变量。

把本地变量导出为环境变量:

ok=1234        # 先定义一个本地变量
set | grep ok  # set 能看到 ok
env | grep ok  # env 看不到 ok

export ok      # 导出为环境变量
env | grep ok  # 现在 env 也能看到 ok 了
./myproc       # 程序也能获取到了

相当于把本地变量的字符串信息添加到环境变量表里面,这样就可以被子进程继承、子进程就能看到了。

如果在 export 的同时设置新值:

export MYVAR="new_value"
2.5.5 环境变量的来源——配置文件

bash 进程的环境变量表信息是从哪里来的?

环境变量表的信息是从系统的特定配置文件中来的。当每一次我们登录一个 bash 的时候,操作系统都会为我们启动一个新的 bash 来提供命令行解析服务。bash 在被创建时,会去读取用户家目录下的登录配置文件来获取环境变量。

每登录一次,系统就会创建一个 bash 进程。可以这样验证:

# 在另一个终端查看 bash 进程数量
while :; do ps aux | grep bash | grep -v grep; sleep 1; done
# 随着登录增多,bash 进程也随之增多;关掉终端后 bash 进程也随之减少

关键的配置文件:

  • ~/.bash_profile
  • ~/.bashrc

系统在加载时,先读取 ~/.bash_profile。在这个文件里,它判断当前目录下有没有 ~/.bashrc,如果有就加载(用 . 命令):

# ~/.bash_profile 中典型内容
if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

(注意:. 是一个内置命令,相当于 source,即把指定文件的内容在当前 bash 进程中执行。)

~/.bashrc 又会去加载 /etc/bashrc(系统全局配置):

# ~/.bashrc 中典型内容
if [ -f /etc/bashrc ]; then
    . /etc/bashrc
fi

/etc/bashrc 中,系统为我们设置了诸如 PATHumaskSHELL、命令行提示符格式等一系列环境变量和配置。

实验——自己添加环境变量到配置文件:

vim ~/.bash_profile

写入:

haha="ok-you-can-see-me"
export haha
echo "当前用户登录了,导入了 bash_profile 配置文件"

保存退出,关掉 SSH 重新登录。登录时会看到输出提示,并且 env | grep haha 就能看到我们刚导出的环境变量。

这就证明了:bash 自己获得环境变量是通过扫描 bash_profile 以及相关的配置文件来构建环境变量表的。今天我启动之后,一个子进程都没创建,环境变量就已经有了——因为环境变量表信息在 bash 启动时就直接从配置文件里导入到了 bash 进程内部。后来被子进程继承,所以子进程才能全部获得。

这也解释了为什么环境变量被改坏了不用慌:

环境变量被搞错,只是内存级把它搞错了。重新把 SSH 关掉再打开,bash 会重新读取配置文件,形成新的环境变量,一切恢复正常。换句话说,环境变量是内存级的,但它的来源是配置文件

2.5.6 Windows 环境变量的本质

Windows 中那个图形化的"环境变量"配置界面(系统属性 → 环境变量),本质上也是在修改配置文件。只不过 Windows 用图形化界面把配置文件包裹起来了,不让你直接去系统里找配置文件改,避免格式输入错误。你把 Windows 中的环境变量改了,电脑开机重启后环境变量一直有效——为什么?因为它是被设置到配置文件里的。

所以我们配置环境变量的本质,就相当于修改 Linux 当中的配置文件。

2.6 环境变量相关命令汇总

命令 功能
echo $变量名 显示某个环境变量的值
env 显示所有环境变量
export 变量名=值export 变量名 设置一个新的环境变量(或将本地变量导出为环境变量)
unset 变量名 清除环境变量
set 显示本地定义的 shell 变量和环境变量(所有变量)

2.7 内建命令 vs 普通命令

在 Linux 系统里,有两类命令:

普通命令:以前用到的大部分命令,比如 lsmkdirtopmake,包括我们自己创建的可执行程序。它们通过创建子进程来执行

内建命令(built-in commands):比如 exportenvcdecho 等。它们不会通过创建子进程来执行,而是由 bash 自己提供、自己执行。

为什么需要内建命令?考虑一下:export 如果是一个子进程,那它导出环境变量就只能影响自己这个子进程,无法影响到父进程 bash 的环境变量表。但实际上 export 确实能影响当前 bash 的环境变量表——说明它是 bash 自己内部的函数,bash 自己调用自己的 export 函数亲自执行,这就叫内建命令。

bash 本身是 C 语言写的、面向过程的程序,bash 内部有很多函数。export 实际上就是 bash 内部的一个函数名,bash 调用它来执行对应的功能。

命令执行流程:

  1. 先确认这个命令是不是内建命令
  2. 如果是内建命令 → bash 自己执行,跟子进程没关系
  3. 如果不是内建命令 → 去环境变量 PATH 指明的路径里查
  4. 查到了 → 创建子进程去执行
  5. 查不到 → 报 command not found

关于内建命令,真正要完全理解它需要到"进程控制"章节,自己写一个 shell 的时候才能慢慢体会。

2.8 用环境变量实现权限控制

环境变量在实际开发中是有用的。举个例子:写一个程序,只能被我自己执行,即便是超级用户 root 也不能执行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
    char *who = getenv("USER");
    if (strcmp(who, "whb") == 0) {    // 必须是我自己
        printf("程序正常运行...\n");
    } else {
        printf("权限错误:不认识你,%s!\n", who);
    }
    return 0;
}

我自己运行时正常执行,root 来运行时输出"权限错误"。这就是使用环境变量来对程序进行权限设置的简单例子。

2.9 总结:环境变量的完整图景

任何一个进程在启动时,bash(所有命令行进程的"父进程")会为所有子进程提供两张表:

  1. 命令行参数表 —— 用户输入的命令字符串经 bash 解析后形成,以空格为分隔符
  2. 环境变量表 —— 从系统配置文件(~/.bash_profile~/.bashrc/etc/bashrc 等)中读取,在 bash 启动时构建

两张表的结构都是 char * 类型的指针数组,最后一个元素以 NULL 结尾。

在 C 语言中获取环境变量有三种方式:

  • main 函数第三个参数 char *env[]
  • 第三方全局变量 extern char **environ
  • 函数 getenv("变量名")

环境变量具有全局属性,是因为它可以被子进程继承。bash 构建了环境变量表后,所有子进程都能通过继承获得。

环境变量可以通过 export 设置,通过 unset 取消,通过修改配置文件来持久化。


三、程序地址空间

3.1 回顾:C 语言中的空间布局

我们之前在学 C/C++ 的时候,老师画过这样的空间布局图(32 位平台):

高地址
┌────────────────────┐
│   命令行参数 & 环境变量  │
├────────────────────┤
│       栈区 (Stack)   │  ← 向下增长(向低地址)
│         ↓↓          │
│                     │
│         ↑↑          │
│       堆区 (Heap)    │  ← 向上增长(向高地址)
├────────────────────┤
│   未初始化数据区 (.bss) │
├────────────────────┤
│   已初始化数据区 (.data) │
├────────────────────┤
│   代码区 (.text/正文)  │
└────────────────────┘
低地址

地址由低向高依次增长,各区域分布:

  • 正文代码区(代码段 / .text):存放程序代码
  • 已初始化数据区(.data):已初始化的全局变量和静态变量
  • 未初始化数据区(.bss):未初始化的全局变量和静态变量
  • 堆区(Heap):动态内存分配(malloc / new)
  • 栈区(Stack):局部变量、函数调用栈帧
  • 命令行参数和环境变量:在栈区之上

堆和栈之间有一大片的镂空区域,堆向地址增大的方向增长,栈向地址减小的方向增长——堆栈相对而生

3.2 验证各区域分布

#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);                 // 堆区
    printf("heap addr: %p\n", heap_mem1);
    printf("heap addr: %p\n", heap_mem2);
    printf("heap addr: %p\n", heap_mem3);

    printf("test static addr: %p\n", &test);             // static 变量

    printf("stack addr: %p\n", &heap_mem);               // 栈区(指针变量本身在栈上)
    printf("stack addr: %p\n", &heap_mem1);
    printf("stack addr: %p\n", &heap_mem2);
    printf("stack addr: %p\n", &heap_mem3);

    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;
}

编译运行(注意 C99 标准的 for 循环变量声明问题,可以用 -std=c99 或将 int i 提到函数开头):

gcc -std=c99 -o myproc myproc.c
./myproc

输出结果(32 位平台示例):

code addr:           0x40055d
init global addr:    0x601034
uninit global addr:  0x601040
test static addr:    0x601038
heap addr:           0x1791010
heap addr:           0x1791030
heap addr:           0x1791050
heap addr:           0x1791070
stack addr:          0x7ffd0f9a4368
stack addr:          0x7ffd0f9a4360
stack addr:          0x7ffd0f9a4358
stack addr:          0x7ffd0f9a4350
read only string:    0x400800
argv[0]:             0x7ffd0f9a4811
env[0]:              0x7ffd0f9a4819
...

分析:

  • 代码区 0x40055d —— 地址最小(6 个十六进制位)
  • 已初始化全局数据 0x6010340x601038(static 变量)—— 6 开头,比代码区大
  • 未初始化全局数据 0x601040 —— 6 开头,比已初始化稍大(注意:先定义的 g_unval 地址反而小,这跟定义先后无关,是编译器在形成地址时按空间布局方式处理的)
  • 堆空间 0x17910100x17910300x17910500x1791070 —— 1 开头,在未初始化数据之上,且依次增加(向地址增大方向增长,每次 +0x20 = 32 字节,包含 malloc 的元数据开销)
  • 栈空间 0x7ffd... —— 7F 开头,比堆大得多,且依次减小(向地址减小方向增长)
  • 命令行参数和环境变量 —— 在栈的更上面

整体结论:地址空间自底向上依次为:代码区 → 已初始化数据区 → 未初始化数据区 → 堆区 → 栈区 → 命令行参数和环境变量,其中堆和栈相对而生。

关于栈地址顺序的说明

可能随着平台差异、系统版本差异和编译器差异,栈变量地址不一定严格按定义顺序递减。这跟编译器有关——在函数内部定义变量,虽然变量确实在运行时才生成,但形成该变量的代码在编译时就已经形成了,编译器可能会按自己的规则来分配。但从函数调用形成栈帧的过程看,必然是地址向下的(汇编层面使用 sub $N, %rsp 等指令让栈指针向下挪出空间)。所以不影响"栈向下增长"这个整体结论。

关于"堆栈"一词
  • 就是堆(Heap)
  • 就是栈(Stack)
  • 堆栈 其实是(有些教材把 Stack 翻译为"堆栈",但堆和栈是两个不同的东西)

口头表述时:说"堆"就是堆,说"栈"就是栈,说"堆栈"那就是栈结构。

3.3 细节问题一:字符常量区在哪里?

以前在 C 语言中经常写这样的代码:

char *s = "hello world";

因为这个字符串 “hello world” 的大小远超 4 或 8 字节(一个指针在系统中只能占 4 字节(32 位)或 8 字节(64 位)),所以这个字符串没办法存在指针变量里面。当时我们说:这个字符串会被编译到一个叫字符常量区的地方。字符常量区用指针 s 指向它——在栈上有个 s,它指向字符常量区。如果试图用指针修改这个字符串的内容,程序会直接崩溃(段错误)。

字符常量区的地址是多少呢?

code addr:           0x40055d
...
read only string:    0x400800

字符常量区的地址是 0x400800,而代码区是 0x40055d——它跟代码区是4 开头(而已初始化 / 未初始化全局变量是 6 开头)。

结论:字符常量区是和正文代码编到一块的。

代码区是只读的。你试图改这个字符常量,就相当于改代码区——操作系统直接让你的进程挂掉(段错误)。

const 的本质
// 不带 const
char *s = "hello world";
*s = 'H';     // 编译不报错,但运行时段错误(程序崩掉)

// 带 const
const char *s = "hello world";
*s = 'H';     // 编译就直接报错!

const 的本质是将运行时报错提前暴露,在编译时就提前发生。

你不带 const 的时候,运行时会报错,但你又不知道,后来运行了才崩掉。那往后养成一个好习惯:如果这个字符串不可改,就带上 const 修饰它——不准用户通过指针修改字符串常量,它不会在运行时报错,而是在编译时就把这个问题暴露出来,让我们更快地解决问题。

const 属于告诉编译器这个变量是只读的、不能修改——它是一种防御性编程。因为定义变量可能在第一行,修改变量可能在第一百行,到时候不容易发现这个错误。加上 const 后,编译器在编译期间帮我们做语法检查,有错误提前报。

3.4 细节问题二:static 修饰局部变量

C 语言中,局部变量用 static 修饰,作用域不变(只在本函数内有效),但生命周期变成全局的

为什么作用域不变很好理解(毕竟是个局部变量,在函数内定义),但生命周期成为全局的是为什么呢?

// 普通局部变量
int test = 10;        // 打印地址 → 在栈上

// static 修饰的局部变量
static int test = 10; // 打印地址 → 在已初始化数据区!

普通局部变量的地址在栈区范围内(如 0x7ffd...),而 static 变量的地址变成了 0x601038——跟全局已初始化变量的地址一模一样。

结论:static 修饰的局部变量在编译之后,编译器会把它直接变成全局变量。

一旦 static 修饰的变量变成了全局变量,那么只要进程存在,地址空间就一直存在;地址空间存在,已初始化 / 未初始化全局变量区就得存在——所以 static 修饰的变量也就必须得一直存在。所以生命周期成全局,是因为 static 这个变量自己本身在编译层面就变成了全局变量。

只不过这个变量名被编译器做了处理(前面可能加上函数名的作用域前缀来限制访问),在语法层面做了限制:你不能直接通过变量名访问它,必须在函数内部才能看到它。

3.5 虚拟地址——这个地址空间是内存吗?

以前学到的这个"程序地址空间",它是内存吗?

答案:它不是内存。它是进程的虚拟地址空间。

写一段代码来感受:

#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) {
        // 子进程
        g_val = 100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    } else {
        // 父进程
        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

我们发现:父子进程,输出的地址是一样的,但是变量内容不一样

能得出如下结论:

  • 变量内容不一样 → 父子进程输出的变量绝对不是同一个变量
  • 但地址值是一样的 → 这个地址绝对不是物理地址!
  • 在 Linux 下,这种地址叫做虚拟地址
  • 我们在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由操作系统统一管理。

操作系统必须负责将虚拟地址转化成物理地址。

每个进程都有自己独立的虚拟地址空间。只要进程存在,这个虚拟地址空间就必须一直存在。关于"虚拟地址空间是什么、为什么要有它、操作系统是如何做到虚拟地址到物理地址的转换的"——这些属于下节课的内容。


四、全景总结

  1. 命令行参数:给 main 函数传递参数,通过 argcargv 实现。bash 以空格为分隔符解析命令行输入的字符串,形成命令行参数表,最后以 NULL 结尾。这是实现程序"选项功能"的核心机制(如 ls -a -l)。

  2. 环境变量:系统级变量,保存系统启动和运行时的核心参数。具有全局属性,可以被所有子进程继承。环境变量表在 bash 启动时从配置文件(~/.bash_profile~/.bashrc/etc/bashrc)中读取构建。

    环境变量的三种获取方式:

    • main 函数第三个参数 char *env[]
    • 全局变量 extern char **environ
    • 函数 getenv("变量名")

    相关命令:echo $VARenvexportunsetset

    本地变量VAR=value)只在 bash 内部有效,不会被继承;环境变量会被子进程继承。

    内建命令(如 exportcdecho)由 bash 自己执行,不创建子进程。

    环境变量在命令行中修改只是内存级的,重新登录后会重新从配置文件读取。要持久化环境变量,需要修改配置文件。

  3. 程序地址空间:不是物理内存,是进程的虚拟地址空间。32 位系统下共 4GB 地址空间。自底向上依次分布:代码区 → 已初始化数据区 → 未初始化数据区 → 堆区(向上增长)→ 镂空 → 栈区(向下增长)→ 命令行参数和环境变量。

    字符常量区和代码区编译在一起,是只读的。const 是防御性编程,在编译期暴露错误。

    static 修饰的局部变量在编译后变成全局变量,因此生命周期变为全局。

    所有 C/C++ 代码中看到的地址都是虚拟地址,物理地址由操作系统统一管理、对用户不可见。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐