14.3.2 环境访问
程序运行时会与宿主环境交换信息,例如命令行参数、环境表项和外部命令执行能力。标准库在 <stdlib.h> 中提供了最小且可移植的访问接口。
1. 环境表项读取:getenv
getenv 通过键名读取环境表中的字符串值。返回指针可能为 NULL,也可能指向由实现管理的存储,因此调用方不应修改其内容。
#include <stdlib.h>
#include <stdio.h>
int main(void) {
const char *home = getenv("HOME");
if (home != NULL) {
printf("HOME=%s\n", home);
}
return 0;
}2
3
4
5
6
7
8
9
10
例如环境中存在 HOME=/home/alice 时,输出为:
HOME=/home/alice
如果环境中没有名为 HOME 的表项,程序不会输出任何内容。
2. 命令行参数
宿主实现中,main 常见原型为 int main(int argc, char *argv[])。argc 表示参数个数,argv 保存参数字符串数组,argv[0] 通常是程序名。读取参数时应先检查下标边界,再做解析。
#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;
}2
3
4
5
6
7
8
例如以 ./args alpha beta 启动程序,且实现把调用时的程序名保存到 argv[0],输出示例为:
argv[0] = ./args argv[1] = alpha argv[2] = beta
3. 外部命令执行:system
system 会把命令字符串交给宿主命令处理器执行。它适合教学示例和简单脚本桥接,但在安全敏感场景应谨慎使用,尤其是命令字符串来自不可信输入时。
#include <stdlib.h>
int main(void) {
return system("echo hello");
}2
3
4
5
4. getenv 返回值生存期
getenv 返回的指针指向由实现管理的存储,不应由调用方释放,也不应原地修改。若后续流程需要长期保存该字符串,建议复制到自有对象中再使用。
5. 写环境表的可移植性
ISO C 只标准化了读取接口 getenv,修改环境表的接口(如 POSIX 的 setenv)属于平台扩展。依赖这些接口的代码不能直接移植到不提供它们的平台。
6. 参数解析应集中在入口层
命令行参数最好在程序入口统一解析为内部配置对象,再把该配置传给后续模块。这样,文本到数值的转换和检查集中在一处,多个模块不会各自重复解析同一个参数。
#include <errno.h>
#include <stdlib.h>
int parse_port(const char *s, int *out) {
char *end = NULL;
errno = 0;
long v = strtol(s, &end, 10);
if (errno != 0 || end == s || *end != '\0' || v < 1 || v > 65535) {
return -1;
}
*out = (int)v;
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
7. 习题
7.1 巩固
下面的程序打印 argc、从 1 开始的各个 argv 元素,以及环境变量 APP_MODE 的三种状态。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
printf("argc = %d\n", argc);
for (int i = 1; i < argc; ++i) {
printf("argv[%d] = [%s]\n", i, argv[i]);
}
const char *mode = getenv("APP_MODE");
if (mode == NULL) {
puts("APP_MODE: unset");
} else if (mode[0] == '\0') {
puts("APP_MODE: empty");
} else {
printf("APP_MODE: %s\n", mode);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在命令行上分别构造下面三种运行方式,写出输出:
- 不带参数,环境中没有
APP_MODE。 - 带两个参数:
alpha和一个空串("")。 APP_MODE分别被设为空串和fast。
命令示例(bash):APP_MODE= ./a.out、APP_MODE=fast ./a.out alpha ""。
7.2 编程
编写 envlook.c,把命令行上的每个名字当作环境变量名逐个查询,输出分为三类:
- 表项存在且值非空:输出
名字: 值 - 表项存在但值为空串:输出
名字: (empty) - 表项不存在:输出
名字: (unset)
(1) [2.2] 一个名字都不给时向标准错误打印 usage: envlook NAME [NAME...] 并返回 EXIT_FAILURE,否则返回 EXIT_SUCCESS。验收(bash):
$ APP_MODE=fast ./envlook APP_MODE
APP_MODE: fast
$ APP_MODE= ./envlook APP_MODE NO_SUCH_KEY
APP_MODE: (empty)
NO_SUCH_KEY: (unset)2
3
4
5
说明在调用 strlen 之前为什么必须先检查 getenv 的返回值是否为 NULL。
(2) [3.3**] 给程序加一个选项 --set NAME=VALUE:设置该表项后继续处理后面的名字,最后统一打印。写入函数按平台条件编译,POSIX 用 setenv(name, value, 1),Windows 用 _putenv_s(name, value);--set 的参数里没有 = 时打印 invalid assignment 并返回 EXIT_FAILURE。验收:
$ ./envlook --set DEMO_MODE=fast DEMO_MODE
DEMO_MODE: fast
$ ./envlook --set DEMO_MODE
invalid assignment2
3
4
再试一次 --set DEMO_MODE=,记录你所在平台上的结果:这两个函数对空值的处理并不一致,setenv 把它设成空串,_putenv_s 则把这一项删掉。说明第 5 节所说的可移植性问题在这段代码里具体落在哪几行,以及为什么读取部分可以不加条件编译。
把下面的代码改为在入口处集中解析,并支持 --port 和 --verbose 两个选项:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
int port = 8080;
int verbose = 0;
for (int i = 1; i < argc; ++i) {
if (strcmp(argv[i], "--verbose") == 0) {
verbose = 1;
} else if (strcmp(argv[i], "--port") == 0) {
port = atoi(argv[i + 1]);
++i;
}
}
printf("port=%d verbose=%d\n", port, verbose);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
要求:
- 用本页第 6 节的
parse_port解析端口,拒绝 1~65535 之外的值和非数字。 --port后面没有值时不得读越界,打印用法并返回EXIT_FAILURE。- 不认识的选项同样打印用法并返回
EXIT_FAILURE。 - 用法行写到标准错误,格式
usage: 程序名 [--verbose] [--port N]。
验收(bash):
$ ./server --port 9000 --verbose
port=9000 verbose=1
$ ./server --port
usage: ./server [--verbose] [--port N]
$ ./server --port 70000
usage: ./server [--verbose] [--port N]2
3
4
5
6
对最后两条命令,用 echo $? 确认返回值为 1。
下面的程序两次打印 APP_PATH 按冒号拆开的各个分量,但第二次只打印出一个分量,而且事后 getenv("APP_PATH") 的值也被改掉了。定位原因并修正。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void print_paths(void) {
char *paths = getenv("APP_PATH");
if (paths == NULL) {
puts("(no APP_PATH)");
return;
}
for (char *p = strtok(paths, ":"); p != NULL; p = strtok(NULL, ":")) {
printf("%s\n", p);
}
}
int main(void) {
print_paths();
puts("-- second call --");
print_paths();
const char *path = getenv("APP_PATH");
printf("APP_PATH=%s\n", path != NULL ? path : "(unset)");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用 APP_PATH=alpha:beta:gamma 运行,观察并解释现象。修正后要求:两次调用都完整打印三个分量,最后的 APP_PATH 仍是原值,拆分过程不修改环境表。
提示:先按本页第 4 节把环境表项复制到自己的数组,再在自己的数组上拆分。
7.3 综合与挑战
写一个小程序 safe_run.c,只用标准 C 的 system 演示命令拼接的风险,并给出限定范围内的替代做法:
- 从命令行接收一段文本(作为一个参数传入,bash 里要加引号),拼成
echo 文本后用system执行。分别用hello和"hello & echo INJECTED"运行,记录输出,说明 shell 把&解释成了什么。 - 把文本换成
"hello; echo INJECTED"和"hello | cat",再记录一次。 - 改写程序:只允许命令行参数中出现字母、数字、下划线和空格,出现其他字符时打印
rejected并返回EXIT_FAILURE,否则才拼接执行。验证前两步的四个输入在改写后分别是什么结果。 - 说明为什么「过滤字符」只适用于这个玩具程序,真实的软件应该怎么做(提示:不经过 shell,直接执行程序并传
argv)。
编译:gcc -std=c17 -Wall -Wextra safe_run.c -o safe_run。Linux 和 macOS 上 system 走 /bin/sh,Windows 上走 cmd.exe,两边的元字符集合不同,答案里注明你实际使用的平台。
写一个配置载入模块,体现「参数解析集中在入口层」:先取默认值,再用环境表覆盖,最后用命令行覆盖,三层都完成后才把配置交给后续模块。
struct config {
char region[16];
int retries;
int verbose;
};2
3
4
5
规则:
- 默认
region = "cn"、retries = 3、verbose = 0。 - 环境表:
APP_REGION、APP_RETRIES(1~10 的十进制整数)、APP_VERBOSE(只能是0或1)。值非法时向标准错误打印invalid environment value并返回EXIT_FAILURE。 - 命令行:
--region NAME、--retries N、--verbose、--help四个选项;后出现的同名选项覆盖前面的;--help打印用法并返回EXIT_SUCCESS。 - 同一份校验逻辑只写一次,环境表与命令行共用。
- 配置解析完成后打印
region=%s retries=%d verbose=%d,再返回。
验收(bash),逐条给出输出与返回状态:
$ env -u APP_REGION -u APP_RETRIES -u APP_VERBOSE ./cfg
region=cn retries=3 verbose=0
$ APP_REGION=eu APP_RETRIES=5 APP_VERBOSE=1 ./cfg
region=eu retries=5 verbose=1
$ APP_REGION=eu APP_RETRIES=5 ./cfg --region jp --retries 2
region=jp retries=2 verbose=0
$ APP_RETRIES=99 ./cfg
invalid environment value
$ ./cfg --bogus
usage: ./cfg [--region NAME] [--retries N] [--verbose] [--help]2
3
4
5
6
7
8
9
10
最后两条的返回状态都是 1;--help 的返回状态是 0。写出完整程序,并在答案里说明「三层覆盖」中哪一层最容易写错、为什么。