源码解读——getopt

作为程序员的你是否有过疑问:为什么命令行工具用法都差不多?事实上,这是因为早期基于 C/C++ 开发的命令行工具都使用了 getopt 工具来进行选项和参数的解析。

getopt 定义了命令行的两种选项:长选项短选项,其分别以 --- 作为前缀,从而使得命令行工具的使用方式基本都差不多。

为了便于认知,后期使用其他编程语言(如:ruby、python)开发的命令行工具,也都延续了这种选项和参数的风格。

本文,我们来通过阅读源码,了解一下 getopt。相关源码详见 传送门

基本功能

getopt 主要用于 解析命令行中的选项和参数,以便于检查实际的选项和参数是否符合预期。

getopt 的参数主要分为两部分:

  • 内置的选项格式,包括:长选项或短选项等
  • 待解析的所有参数

getopt 内置提供了两个选项 -o/--options-l/--longoptions,分别用于定义待解析参数的所支持的短选项和长选项的格式。基于这两项格式,getopt 才能判断待解析的参数是否符合预期。

选项类型

getopt 定义了三种类型的命令行选项,分别是:

  • 标志选项:该选项仅作为一个标志位,其后面不带参数。
  • 带参选项:该选项用于标识一个特定的参数,其后面必须带参数。
  • 可选选项:该选项是一个可选选项,其后面可以选择不带参数或带参数(参数和选项之间没有空格)。

如下所示,为三种命令行选项的示例。

1
2
3
4
5
6
7
# 标志位选项
$ ls -a

# 可选选项 & 带参选项
# -D 是可选选项,TEST 是参数。对于可选选项,选项和参数之间没有空格。
# -o 是带参选项,getopt 是参数
$ gcc -DTEST getopt.c -o getopt

为了实现三种选项的格式,getopt 设计了一套 DSL,分别用于描述这三种选项类型。下面,下面来看一个例子。

1
2
3
# 短选项格式:--options hxab:c:: 
# 长选项格式:--longoptions help,debug,a-long,b-long:,c-long::
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- --a-long -carg0 --b-long arg1

对于短选项,规定只能用一个字符来表示。

  • 标志选项 末尾不带冒号,上述定义了三个标志短选项: -h-x-a
  • 带参选项 末尾带一个冒号,上述定义了一个带参短选项:-b
  • 可选选项 末尾带两个冒号。上述定义了一个可选短选项:-c

对于长选项,规定可以用多个字符来表示,使用逗号或空格进行分隔。

  • 标志选项 末尾不带冒号,上述定义了三个标志长选项: --help--debug--a-long
  • 带参选项 末尾带一个冒号,上述定义了一个带参长选项:--b-long
  • 可选选项 末尾带两个冒号。上述定义了一个可选长选项:--c-long

注意,上述例子中 -- 右边的部分就是待解析参数,即 --a-long -carg0 --b-long arg1


实现原理

数据结构

getopt 内部定义了两个关键的数据结构:struct optionstruct getopt_control

struct option 用于描述一个长选项,包括:名称,是否带参数,短选项标志等。其定义如下所示:

1
2
3
4
5
6
struct option {
const char *name; // 长选项的命名
int has_arg; // 选项是否带参数
int *flag; // 如果不为 NULL,当发现选项时,将 *flag 设置为 val
int val; // 如果 flag 不为 NULL,此为要设置 *flag 的值;否则,返回该值
};

struct getopt_control 是一个顶层数据结构,主要用于描述外部选项的格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct getopt_control {
shell_t shell; /* the shell we generate output for */
char *optstr; /* getopt(3) optstring */
char *name;
struct option *long_options; /* long options */
int long_options_length; /* length of options array */
int long_options_nr; /* number of used elements in array */
unsigned int
compatible:1, /* compatibility mode for 'difficult' programs */
quiet_errors:1, /* print errors */
quiet_output:1, /* print output */
quote:1; /* quote output */
};

核心流程

getopt 的核心流程分为三部分:

  • 内置选项/参数解析:主要针对 getopt 自身所支持的选项进行解析。
  • 外部选项格式注册:主要基于 内置选项/参数解析 的解析结果,比如:基于 -o/--options-l/--longoptions 选项的 DSL 参数,注册外部选项格式。
  • 外部选项/参数校验:基于外部选项格式,对剩余参数进行解析校验,判断外部选项是否符合预期。

如下所示,为 getopt 的核心流程的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
int main(int argc, char *argv[])
{
struct getopt_control ctl = {
.shell = BASH,
.quote = 1};
int opt;

// 内置的长选项和短选项
static const char *shortopts = "+ao:l:n:qQs:TuhV";
static const struct option longopts[] = {
{"options", required_argument, NULL, 'o'},
{"longoptions", required_argument, NULL, 'l'},
{"quiet", no_argument, NULL, 'q'},
{"quiet-output", no_argument, NULL, 'Q'},
{"shell", required_argument, NULL, 's'},
{"test", no_argument, NULL, 'T'},
{"unquoted", no_argument, NULL, 'u'},
{"help", no_argument, NULL, 'h'},
{"alternative", no_argument, NULL, 'a'},
{"name", required_argument, NULL, 'n'},
{"version", no_argument, NULL, 'V'},
{NULL, 0, NULL, 0}};

// 本地化及其他设置
// ...

// 初始化 ctl 的部分字段
add_longopt(&ctl, NULL, 0); /* init */
// 设置函数指针
getopt_long_fp = getopt_long;

// ...

// 内置选项/参数解析
while ((opt =
getopt_long(argc, argv, shortopts, longopts, NULL)) != EOF)
switch (opt)
{
case 'a':
getopt_long_fp = getopt_long_only;
break;
case 'o':
// 设置外部短选项格式
add_short_options(&ctl, optarg);
break;
case 'l':
// 设置外部长选项格式
add_long_options(&ctl, optarg);
break;
case 'n':
free(ctl.name);
// 设置外部程序的命名
ctl.name = xstrdup(optarg);
break;
case 'q':
ctl.quiet_errors = 1;
break;
case 'Q':
ctl.quiet_output = 1;
break;
case 's':
ctl.shell = shell_type(optarg);
break;
case 'T':
free(ctl.long_options);
return TEST_EXIT_CODE;
case 'u':
ctl.quote = 0;
break;

case 'V':
print_version(EXIT_SUCCESS);
case '?':
case ':':
parse_error(NULL);
case 'h':
usage();
default:
parse_error(_("internal error, contact the author."));
}

// ...

// 外部选项/参数校验
return generate_output(&ctl, argv + optind - 1, argc - optind + 1);
}

首先,进行内置选项/参数解析,可以看到 getopt 自身长选项和短选项格式如下所示。由于长选项和短选项直接存在映射关系,所以可以看到长选项初始化时的 val 域正好对应一个短选项字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// getopt 自身短选项格式
static const char *shortopts = "+ao:l:n:qQs:TuhV";
// getopt 自身长选项格式
static const struct option longopts[] = {
{"options", required_argument, NULL, 'o'},
{"longoptions", required_argument, NULL, 'l'},
{"quiet", no_argument, NULL, 'q'},
{"quiet-output", no_argument, NULL, 'Q'},
{"shell", required_argument, NULL, 's'},
{"test", no_argument, NULL, 'T'},
{"unquoted", no_argument, NULL, 'u'},
{"help", no_argument, NULL, 'h'},
{"alternative", no_argument, NULL, 'a'},
{"name", required_argument, NULL, 'n'},
{"version", no_argument, NULL, 'V'},
{NULL, 0, NULL, 0}};

内置选项/参数解析的核心是通过一个 while 循环,依次解析每一个选项和参数。这里通过调用 getopt_long 方法来进行解析。

getopt_long 会返回一个 ASCII 码值,通过 switch case 选择不同的处理方法。对于 l 则注册外部长选项格式,存储于 ctl.long_options 数组中;对于 o 则注册外部短选项格式,存储于 ctl.optstr 字符串中。

最后,通过 ctl 中已注册的外部选项格式,对剩余的参数进行校验,并打印最终结果。

选项/参数解析

选项/参数解析的核心方法是 getopt_long,它是一个带副作用的函数,其内部会读写多个全局变量,包括:optindoptargoptpos 等。每次调用,执行结果都不一样,从而达到依次遍历 ARGV 的目的。

getopt_long 包含两部分逻辑:长选项及其参数的识别,短选项及其参数的识别。

如果 ARGV 的一个元素以 -- 作为前缀,那么它是一个长选项。如果 ARGV 的一个元素以 - 作为前缀(且不以 -- 作为前缀),那么它是一个短选项。

对于带参选项的参数解析,getopt 支持两种格式,如下所示。

  • 选项和参数之间使用 空格 ' ' 进行分隔
  • 选项和参数之间使用 等号 '=' 进行分隔
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 带参长选项,空格分隔选项和参数
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- --b-long arg1
--b-long 'arg1' --

# 带参短选项,空格分隔选项和参数
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- -b arg1
--b-long 'arg1' --

# 带参长选项,等号分隔选项和参数
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- --b-long=arg1
--b-long 'arg1' --

# 带参短选项,等号分隔选项和参数
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- -b=arg1
--b-long 'arg1' --

对于可选选项的参数解析,getopt 略有不同,如下所示。

  • 对于短选项,选项和参数之间不包含任何其他字符
  • 对于长选项,选项和参数之间只包含 等号 '='。
1
2
3
4
5
6
7
# 可选短选项,选项和参数之间不包含任何其他字符
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- -carg0
-c 'arg0' --

# 可选长选项,选项和参数之间只包含等号
$ getopt --options hxab:c:: --longoptions help,debug,a-long,b-long:,c-long:: -- --c-long=arg0
--c-long 'arg0' --

值得注意的是,很多命令行工具都使用 getopt_long 方法来进行选项/参数解析,比如 util-linux 项目中各种常见的命令工具——传送门

选项格式注册

选项格式注册的核心方法有两个,add_long_optionsadd_short_options,分别对应长选项和短选项预。

add_long_options 的定义如下所示,其本质就是对 ctllong_optionslong_options_length 等字段进行初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 注册长选项
static void add_longopt(struct getopt_control *ctl, const char *name, int has_arg)
{
static int flag;
int nr = ctl->long_options_nr;

// 对 ctl->long_options_length 进行修改,并为 ctl->long_options 增加内存
if (ctl->long_options_nr == ctl->long_options_length)
{
ctl->long_options_length += REALLOC_INCREMENT;
ctl->long_options = xrealloc(ctl->long_options,
sizeof(struct option) *
ctl->long_options_length);
}
// 如果选项有名称,则进行存储
if (name)
{
/* Not for init! */
ctl->long_options[nr].has_arg = has_arg;
ctl->long_options[nr].flag = &flag;
ctl->long_options[nr].val = ctl->long_options_nr;
ctl->long_options[nr].name = xstrdup(name);
}
// 否则,置空
else
{
/* lets use add_longopt(ct, NULL, 0) to terminate the array */
ctl->long_options[nr].name = NULL;
ctl->long_options[nr].has_arg = 0;
ctl->long_options[nr].flag = NULL;
ctl->long_options[nr].val = 0;
}
}

add_short_options 的定义如下所示,其本质就是对 ctloptstr 字段进行初始化。

1
2
3
4
5
6
7
8
9
10
11
12
# 注册短选项
static void add_short_options(struct getopt_control *ctl, char *options)
{
// 将短选项存入 ctl
free(ctl->optstr);
if (*options != '+' && getenv("POSIXLY_CORRECT"))
ctl->optstr = strconcat("+", options);
else
ctl->optstr = xstrdup(options);
if (!ctl->optstr)
err_oom();
}

选项/参数校验

外部选项/参数校验的核心是调用了 generate_output 方法。该方法的实现如下所示,其本质上还是基于选项/参数解析方法 getopt_long 对参数进行解析,判断是否符合预期。和内置选项/参数解析的区别在于其校验的标准存储在了 ctl 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
static int generate_output(struct getopt_control *ctl, char *argv[], int argc)
{
int exit_code = EXIT_SUCCESS; /* Assume everything will be OK */
int opt;
int longindex;
const char *charptr;

if (ctl->quiet_errors)
/* No error reporting from getopt(3) */
opterr = 0;
/* Reset getopt(3) */
optind = 0;

while ((opt = (getopt_long_fp(argc, argv, ctl->optstr, (const struct option *)ctl->long_options, &longindex))) != EOF)
{
if (opt == '?' || opt == ':')
exit_code = GETOPT_EXIT_CODE;
else if (!ctl->quiet_output)
{
switch (opt)
{
case LONG_OPT:
printf(" --%s", ctl->long_options[longindex].name);
if (ctl->long_options[longindex].has_arg)
print_normalized(ctl, optarg ? optarg : "");
break;
case NON_OPT:
print_normalized(ctl, optarg ? optarg : "");
break;
default:
printf(" -%c", opt);
charptr = strchr(ctl->optstr, opt);
if (charptr != NULL && *++charptr == ':')
print_normalized(ctl, optarg ? optarg : "");
}
}
}
if (!ctl->quiet_output)
{
printf(" --");
while (optind < argc)
print_normalized(ctl, argv[optind++]);
printf("\n");
}
for (longindex = 0; longindex < ctl->long_options_nr; longindex++)
free((char *)ctl->long_options[longindex].name);
free(ctl->long_options);
free(ctl->optstr);
free(ctl->name);
return exit_code;
}

总结

getopt 定义了一套命令行选项和参数的使用规范,这套规范一直被沿用至今。getopt 的工作原理主要包含三个步骤:

  • 内置选项/参数解析
  • 外部选项格式注册
  • 外部选项/参数校验

其中 内置选项/参数解析 的核心实现方法 getop_long 被很多命令行工具所引用,从而使得各种命令行工具的选项和参数的使用规范基本一致,也降低了使用者的学习成本。

关于 getopt 在实际中的用途,如果希望开发基于 C/C++ 的命令行工具,可以像其他 Linux 内置的工具一样,使用 getopt 提供的 API 来进行选项校验;如果希望开发基于其他语言的命令行工具,可以调用 getopt 命令行工具来进行选项/参数校验。通过 getopt,我们可以极大地减少选项/参数的定义与校验相关的开发工作量。

参考

  1. util-linux
  2. util-linux wikipedia
  3. Decoded: GNU coreutils
  4. C之attribute用法
  5. getopt_long_only.c
  6. Implementation of getopt() and getopt_long() from musl