| 目录 |

18 外部环境

当你运行一个程序时,其实是你对 shell 说:“嘿,请运行这个东西。”shell 回答:“好的。”然后它对操作系统说:“嘿,能不能新建一个进程,运行这个东西?”如果一切顺利,操作系统照办,你的程序就跑起来了。

但在 shell 里、你的程序之外,还有一整个世界,而你可以从 C 代码内部与它交互。本章我们就来看其中的几样。

18.1 命令行参数

许多命令行工具都接受命令行参数。例如,如果我们想查看所有以 .txt 结尾的文件,在类 Unix 系统上可以这样输入:

ls *.txt

(在 Windows 系统上则用 dir 代替 ls)。

这里命令是 ls,但它的参数是所有以 .txt 结尾的文件119。

那么,我们怎样才能看到从命令行传进程序的内容呢?

假设我们有一个叫 add 的程序,它把命令行上传入的所有数字相加,并打印结果:

./add 10 30 5
45

这肯定能养家糊口!

不过说真的,这是个很好的工具,能让你看清如何从命令行取得这些参数并解析它们。

首先,我们看看怎样才能拿到它们。为此,我们需要一个新的 main()!

下面这个程序会打印出所有命令行参数。例如,如果我们把可执行文件命名为 foo,就可以这样运行它:

./foo i like turtles

然后会看到这样的输出:

arg 0: ./foo
arg 1: i
arg 2: like
arg 3: turtles

这有点奇怪,因为第零个参数就是可执行文件本身的名字。但这只是需要习惯一下而已。真正的参数紧随其后。

源代码:

#include <stdio.h>

int main(int argc, char *argv[])
{
    for (int i = 0; i < argc; i++) {
        printf("arg %d: %s\n", i, argv[i]);
    }
}

哇!main() 的函数签名是怎么回事?argc 和 argv120 又是什么(读作 arg-cee 和 arg-vee)?

先从简单的说起:argc。它是参数个数(argument count),包括程序名本身。如果你把所有参数看成字符串数组(它们确实就是),那么就可以把 argc 看成这个数组的长度(它确实就是)。

所以那个循环做的事,就是遍历所有 argv,一次打印一个。因此对于给定的输入:

./foo i like turtles

我们会得到对应的输出:

arg 0: ./foo
arg 1: i
arg 2: like
arg 3: turtles

明白了这些,我们的加法程序就可以开工了。

我们的计划:

开始吧!

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

int main(int argc, char **argv)
{
    int total = 0;

    for (int i = 1; i < argc; i++) {  // 从 1 开始,也就是第一个实参
        int value = atoi(argv[i]);    // 用 strtol() 获得更好的错误处理

        total += value;
    }

    printf("%d\n", total);
}

示例运行:

$ ./add
0
$ ./add 1
1
$ ./add 1 2
3
$ ./add 1 2 3
6
$ ./add 1 2 3 4
10

当然,如果你传入非整数,它可能会吐出来,但增强它的健壮性就留给读者当练习了。

18.1.1 最后一个 argv 是 NULL

关于 argv 有个有趣的小知识:最后一个字符串之后,是一个指向 NULL 的指针。

也就是说:

argv[argc] == NULL

永远为真!

这看起来毫无意义,但事实证明它在几个地方很有用;我们马上就看其中一个。

18.1.2 另一种写法:char **argv

记住,调用函数时,C 并不区分函数签名中的数组写法和指针写法。

也就是说,下面两者是相同的:

void foo(char a[])
void foo(char *a)

此前把 argv 看成字符串数组(即 char* 的数组)很方便,所以这样写是合理的:

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

但基于这种等价性,你也可以写成:

int main(int argc, char **argv)

没错,这就是指向指针的指针!如果这样更好理解,你可以把它想成指向字符串的指针。但严格来说,它是指向“某个指向 char 的值”的指针。

还要记得下面这些是等价的:

argv[i]
*(argv + i)

这意味着你可以对 argv 做指针运算。

因此,另一种处理命令行参数的方式,就是让指针一路递增,沿着 argv 数组往前走,直到撞上末尾那个 NULL。

让我们把加法程序改成这样:

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

int main(int argc, char **argv)
{
    int total = 0;
    
    // 让编译器不再对下面这个
    // 未使用的变量 argc 发出警告的小技巧:
    (void)argc;

    for (char **p = argv + 1; *p != NULL; p++) {
        int value = atoi(*p);  // 用 strtol() 获得更好的错误处理

        total += value;
    }

    printf("%d\n", total);
}

就我个人而言,我用数组写法访问 argv,但我也见过这种风格流传。

18.1.3 趣闻

关于 argc 和 argv 还有几件小事。

18.2 退出状态

你注意到 main() 的函数签名让它返回 int 类型了吗?这是怎么回事?这跟一个叫退出状态(exit status)的东西有关:它是一个整数,可以返回给启动你的程序的那个程序,让它知道事情进行得如何。

在 C 中,程序可以有多种方式退出,包括从 main() 中 return,或者调用某个 exit() 变体。

所有这些方法都接受一个 int 作为实参。

旁注:你注意到几乎我所有的示例里,尽管 main() 本应返回一个 int,我却并没有真正 return 任何东西吗?在任何其他函数里,这都是非法的,但 C 里有个特例:如果执行到 main() 末尾都没遇到 return,它会自动执行 return 0。

但 0 是什么意思?我们还能往那里放什么别的数字?它们又是怎样被使用的?

规范在这件事上既清晰又含糊,这很常见。清晰是因为它明确写出了你能做什么,含糊则是因为它也并未对此特别加以限制。

别无他法,只能硬着头皮往前闯,把它搞清楚!

让我们先来点《盗梦空间》122式的嵌套:原来当你运行自己的程序时,你是在从另一个程序里运行它。

通常这个“另一个程序”是某种 shell123,它自己并不做太多事,只是启动其他程序。

但这是一个多阶段的过程,在命令行 shell 中尤其明显:

  1. shell 启动你的程序
  2. shell 通常进入休眠(对命令行 shell 而言)
  3. 你的程序运行
  4. 你的程序终止
  5. shell 醒来,等待下一条命令

在第 4 步和第 5 步之间,会发生一点小小的通信:程序可以返回一个状态值,供 shell 查询。通常,这个值用来表示程序成功还是失败;如果是失败,还表示是哪种失败。

这个值就是我们一直从 main() 里 return 的东西。它就是状态。

C 规范允许两种不同的状态值,它们的宏名定义在 <stdlib.h> 中:

状态 描述
EXIT_SUCCESS 或 0 程序成功终止。
EXIT_FAILURE 程序因错误而终止。

我们来写一个小程序,把命令行上的两个数相乘。我们要求你正好指定两个值。如果没做到,就打印一条错误消息,并以错误状态退出。

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

int main(int argc, char **argv)
{
    if (argc != 3) {
        printf("usage: mult x y\n");
        return EXIT_FAILURE;   // 告诉 shell 它没有成功
    }

    printf("%d\n", atoi(argv[1]) * atoi(argv[2]));

    return 0;  // 与 EXIT_SUCCESS 相同,一切正常。
}

现在试着运行它,在命令行参数个数恰好正确之前,我们看到的效果都符合预期:

$ ./mult
usage: mult x y

$ ./mult 3 4 5
usage: mult x y

$ ./mult 3 4
12

但这并没有真正显示出我们返回的退出状态,对吧?不过我们可以让 shell 把它打印出来。假设你运行的是 Bash 或其他 POSIX shell,可以用 echo $? 查看它124。

试试看:

$ ./mult
usage: mult x y
$ echo $?
1

$ ./mult 3 4 5
usage: mult x y
$ echo $?
1

$ ./mult 3 4
12
$ echo $?
0

有意思!可以看到,在我的系统上 EXIT_FAILURE 是 1。规范没有明确规定这一点,所以它可以是任何数字。不过你试试看,你的系统上很可能也是 1。

18.2.1 其他退出状态值

状态 0 毫无疑问表示成功,但其他整数呢,甚至连负数呢?

这里我们就走出 C 规范的疆界,进入 Unix 的地盘了。一般来说,0 表示成功,正的非零数表示失败。所以成功只有一种,失败可以有很多种。Bash 说退出码应当在 0 到 255 之间,不过其中有些码是保留的。

简而言之,如果你想在 Unix 环境里表示不同的错误退出状态,可以从 1 开始,逐步往上用。

在 Linux 上,如果你用了 0-255 范围之外的任何码,它会把该码与 0xff 做按位与,实际上就把它钳制到了这个范围内。

你可以编写 shell 脚本,稍后利用这些状态码来决定下一步做什么。

18.3 环境变量

在深入这个话题之前,我得提醒你:C 并没有规定环境变量是什么。所以我要描述的是我所知的每个主流平台上都能用的那套环境变量系统。

基本上,环境就是那个将要运行你的程序的程序,比如 bash shell。它可能定义了一些 bash 变量。也许你还不知道,shell 可以创建自己的变量。每种 shell 各不相同,但在 bash 里你只要输入 set,它就会把它们全列出来。

下面是我的 bash shell 中定义的 61 个变量里的一段摘录:

HISTFILE=/home/beej/.bash_history
HISTFILESIZE=500
HISTSIZE=500
HOME=/home/beej
HOSTNAME=FBILAPTOP
HOSTTYPE=x86_64
IFS=$' \t\n'

注意它们是键/值对的形式。例如,一个键是 HOSTTYPE,它的值是 x86_64。从 C 的角度看,所有值都是字符串,即使它们其实是数字125。

所以,总之!长话短说,你可以在 C 程序内部取得这些值。

我们来写一个程序,用标准库的 getenv() 函数查询你在 shell 里设置的一个值。

getenv() 会返回指向值字符串的指针;如果环境变量不存在,则返回 NULL。

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

int main(void)
{
    char *val = getenv("FROTZ");  // 尝试获取该值

    // 检查确认它存在
    if (val == NULL) {
        printf("Cannot find the FROTZ environment variable\n");
        return EXIT_FAILURE;
    }

    printf("Value: %s\n", val);
}

如果我直接运行它,会得到:

$ ./foo
Cannot find the FROTZ environment variable

这很合理,因为我还没设置它。

在 bash 里,我可以用下面的方式把它设为某个值126:

$ export FROTZ="C is awesome!"

然后再运行它,就得到:

$ ./foo
Value: C is awesome!

这样一来,你就可以把数据放进环境变量,在 C 代码里取出它,并据此调整程序的行为。

18.3.1 设置环境变量

这不是标准做法,但许多系统都提供了设置环境变量的方法。

如果在类 Unix 系统上,请查阅 putenv()、setenv() 和 unsetenv() 的文档。在 Windows 上,请看 _putenv()。

18.3.2 类 Unix 的另一种环境变量访问方式

如果你在类 Unix 系统上,多半还有另外几种访问环境变量的方式。注意,虽然规范指出这是常见的扩展,但它并不真正属于 C 标准。不过,它属于 POSIX 标准。

其中之一是一个叫 environ 的变量,必须这样声明:

extern char **environ;

它是一个字符串数组,以一个 NULL 指针结尾。

你应该在用之前自己声明它,或者你可能在非标准的 <unistd.h> 头文件里找到它。

每个字符串的形式都是 "key=value",所以如果你想取出键和值,就得自己拆分并解析它。

下面是一个用几种不同方式遍历并打印环境变量的示例:

#include <stdio.h>

extern char **environ;  // 必须是 extern,且命名为 "environ"

int main(void)
{
    for (char **p = environ; *p != NULL; p++) {
        printf("%s\n", *p);
    }

    // 或者你也可以这样做:
    for (int i = 0; environ[i] != NULL; i++) {
        printf("%s\n", environ[i]);
    }
}

得到的输出大致像这样:

SHELL=/bin/bash
COLORTERM=truecolor
TERM_PROGRAM_VERSION=1.53.2
LOGNAME=beej
HOME=/home/beej
... etc ...

尽可能使用 getenv(),因为它可移植性更好。但如果你必须遍历环境变量,用 environ 可能是更好的选择。

另一种非标准的获取环境变量的方式,是把它作为 main() 的形参。它工作方式差不多,但你不必再添加自己的 extern environ 变量。据我所知,连 POSIX 规范都不支持这种做法127,但它在 Unix 世界里很常见。

#include <stdio.h>

int main(int argc, char **argv, char **env)  // <-- 环境变量!
{
    (void)argc; (void)argv;  // 抑制“未使用”警告

    for (char **p = env; *p != NULL; p++) {
        printf("%s\n", *p);
    }

    // 或者你也可以这样做:
    for (int i = 0; env[i] != NULL; i++) {
        printf("%s\n", env[i]);
    }
}

和用 environ 一样,只是可移植性更差。有目标总是好的。


| 目录 |