我知道,Python 和 JavaScript 是现在孩子们用来编写他们疯狂的“应用程序”的语言。 但是,不要急于否定 C,它是一种功能强大且简洁的语言,有很多优点。 如果你需要速度,用 C 编写可能是你的答案。 如果你正在寻找工作保障和学习如何追捕 空指针解引用 的机会,C 也可能是你的答案! 在本文中,我将解释如何构建 C 文件并编写 C main 函数,像冠军一样处理命令行参数。
我:一个经验丰富的 Unix 系统程序员。
你:拥有编辑器、C 编译器和一些空闲时间的人。
开始吧。
一个枯燥但正确的 C 程序

C 程序以 main() 函数开始,通常保存在名为 main.c 的文件中。
/* main.c */
int main(int argc, char *argv[]) {
}
这个程序可以编译,但不会做任何事情。
$ gcc main.c
$ ./a.out -o foo -vv
$
正确且枯燥。
Main 函数是独特的
main() 函数是你的程序开始执行时执行的第一个函数,但它不是第一个被执行的函数。 第一个函数是 _start(),它通常由 C 运行时库提供,在你的程序编译时自动链接进来。 细节高度依赖于操作系统和编译器工具链,所以我假装我没提过它。
main() 函数有两个参数,传统上称为 argc 和 argv,并返回一个有符号整数。 大多数 Unix 环境期望程序在成功时返回 0(零),在失败时返回 -1(负一)。
参数 | 名称 | 描述 |
---|---|---|
argc | 参数计数 | 参数向量的长度 |
argv | 参数向量 | 字符指针数组 |
参数向量 argv 是调用你的程序的命令行的标记化表示。 在上面的示例中,argv 将是以下字符串的列表
argv = [ "/path/to/a.out", "-o", "foo", "-vv" ];
参数向量保证始终在第一个索引 argv[0] 中至少有一个字符串,它是执行程序的完整路径。
main.c 文件的结构
当我从头开始编写 main.c 时,它通常结构如下
/* main.c */
/* 0 copyright/licensing */
/* 1 includes */
/* 2 defines */
/* 3 external declarations */
/* 4 typedefs */
/* 5 global variable declarations */
/* 6 function prototypes */
int main(int argc, char *argv[]) {
/* 7 command-line parsing */
}
/* 8 function declarations */
我将在下面讨论这些编号的部分,除了零。 如果你必须在源代码中放置版权或许可文本,请将其放在那里。
另一件我不会谈论添加到你的程序中的是注释。
"Comments lie."
- A cynical but smart and good looking programmer.
与其使用注释,不如使用有意义的函数和变量名。
利用程序员固有的惰性,一旦你添加了注释,你就使你的维护负担增加了一倍。 如果你更改或重构代码,则需要更新或扩展注释。 随着时间的推移,代码会逐渐演变成与注释描述的任何内容都不相似的东西。
如果你必须编写注释,请不要写关于代码正在做什么。 相反,写关于代码为什么要做它正在做的事情。 编写你希望在五年后阅读的注释,那时你已经忘记了关于这段代码的一切。 而世界的命运取决于你。 没有压力。
1. 包含
我添加到 main.c 文件的第一件事是包含,以使我的程序可以使用大量的标准 C 库函数和变量。 标准 C 库做了很多事情; 探索 /usr/include 中的头文件,以了解它可以为你做什么。
#include 字符串是一个 C 预处理器 (cpp) 指令,它导致将引用的文件完整地包含在当前文件中。 C 中的头文件通常以 .h 扩展名命名,并且不应包含任何可执行代码; 仅包含宏、定义、typedef 和外部变量和函数原型。 字符串 <header.h> 告诉 cpp 在系统定义的头文件路径(通常是 /usr/include)中查找名为 header.h 的文件。
/* main.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <libgen.h>
#include <errno.h>
#include <string.h>
#include <getopt.h>
#include <sys/types.h>
这是我默认包含的用于以下内容的最小全局包含集
#include 文件 | 它提供的功能 |
---|---|
stdio | 提供 FILE、stdin、stdout、stderr 和 fprint() 函数系列 |
stdlib | 提供 malloc()、calloc() 和 realloc() |
unistd | 提供 EXIT_FAILURE、EXIT_SUCCESS |
libgen | 提供 basename() 函数 |
errno | 定义外部 errno 变量及其可以采用的所有值 |
string | 提供 memcpy()、memset() 和 strlen() 函数系列 |
getopt | 提供外部 optarg、opterr、optind 和 getopt() 函数 |
sys/types | Typedef 快捷方式,如 uint32_t 和 uint64_t |
2. 定义
/* main.c */
<...>
#define OPTSTR "vi:o:f:h"
#define USAGE_FMT "%s [-v] [-f hexflag] [-i inputfile] [-o outputfile] [-h]"
#define ERR_FOPEN_INPUT "fopen(input, r)"
#define ERR_FOPEN_OUTPUT "fopen(output, w)"
#define ERR_DO_THE_NEEDFUL "do_the_needful blew up"
#define DEFAULT_PROGNAME "george"
现在这可能没有太多意义,但是 OPTSTR 定义是我将声明程序将推荐哪些命令行开关的地方。 请查阅 getopt(3) 手册页,了解 OPTSTR 将如何影响 getopt() 的行为。
USAGE_FMT 定义是一个 printf() 风格的格式字符串,在 usage() 函数中被引用。
我也喜欢将字符串常量作为 #defines 收集在文件的这一部分中。 收集它们可以更轻松地修复拼写、重用消息以及在需要时国际化消息。
最后,在命名 #define 时使用所有大写字母,以将其与变量和函数名称区分开来。 你可以根据需要将单词连在一起,或者用下划线分隔单词; 只要确保它们都是大写。
3. 外部声明
/* main.c */
<...>
extern int errno;
extern char *optarg;
extern int opterr, optind;
extern 声明将该名称带入当前编译单元(又名“文件”)的命名空间,并允许程序访问该变量。 在这里,我们引入了三个整数变量和一个字符指针的定义。 以 opt 开头的变量由 getopt() 函数使用,而 errno 被标准 C 库用作带外通信通道,以传达函数可能失败的原因。
4. Typedefs
/* main.c */
<...>
typedef struct {
int verbose;
uint32_t flags;
FILE *input;
FILE *output;
} options_t;
在外部声明之后,我喜欢为结构、联合和枚举声明 typedefs。 命名 typedef 本身就是一种宗教; 我强烈偏爱 _t 后缀来指示该名称是一种类型。 在此示例中,我已将 options_t 声明为一个包含四个成员的 struct。 C 是一种空白中立的编程语言,因此我使用空白将字段名称在同一列中对齐。 我只是喜欢它的外观。 对于指针声明,我在名称前面加上星号,以明确表明它是一个指针。
5. 全局变量声明
/* main.c */
<...>
int dumb_global_variable = -11;
全局变量是一个坏主意,你永远不应该使用它们。 但是,如果你必须使用全局变量,请在此处声明它们,并确保为它们提供默认值。 认真地说,不要使用全局变量。
6. 函数原型
/* main.c */
<...>
void usage(char *progname, int opt);
int do_the_needful(options_t *options);
当你编写函数时,在 main() 函数之后而不是之前添加它们,请在此处包含函数原型。 早期的 C 编译器使用单遍策略,这意味着你在程序中使用的每个符号(变量或函数名称)都必须在使用之前声明。 现代编译器几乎都是多遍编译器,它们在生成代码之前构建完整的符号表,因此严格来说不需要使用函数原型。 但是,有时你无法选择在你的代码上使用哪个编译器,因此请编写函数原型并继续前进。
通常,我总是包含一个 usage() 函数,当 main() 不理解你从命令行传入的内容时,它会调用该函数。
7. 命令行解析
/* main.c */
<...>
int main(int argc, char *argv[]) {
int opt;
options_t options = { 0, 0x0, stdin, stdout };
opterr = 0;
while ((opt = getopt(argc, argv, OPTSTR)) != EOF)
switch(opt) {
case 'i':
if (!(options.input = fopen(optarg, "r")) ){
perror(ERR_FOPEN_INPUT);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
break;
case 'o':
if (!(options.output = fopen(optarg, "w")) ){
perror(ERR_FOPEN_OUTPUT);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
break;
case 'f':
options.flags = (uint32_t )strtoul(optarg, NULL, 16);
break;
case 'v':
options.verbose += 1;
break;
case 'h':
default:
usage(basename(argv[0]), opt);
/* NOTREACHED */
break;
}
if (do_the_needful(&options) != EXIT_SUCCESS) {
perror(ERR_DO_THE_NEEDFUL);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
return EXIT_SUCCESS;
}
好的,内容很多。 main() 函数的目的是收集用户提供的参数,执行最少的输入验证,然后将收集的参数传递给将使用它们的函数。 此示例声明一个 options 变量,该变量已使用默认值初始化并解析命令行,并在必要时更新 options。
此 main() 函数的核心是一个 while 循环,它使用 getopt() 遍历 argv 以查找命令行选项及其参数(如果有)。 文件前面 OPTSTR #define 是驱动 getopt() 行为的模板。 opt 变量采用 getopt() 找到的任何命令行选项的字符值,并且程序对检测到命令行选项的响应发生在 switch 语句中。
那些注意听讲的人现在会质疑为什么 opt 被声明为 32 位 int,但期望采用 8 位 char? 事实证明,当 getopt() 到达 argv 的末尾时,它会返回一个取负值的 int,我将其与 EOF (文件结尾 标记)进行检查。 char 是一个有符号量,但我喜欢将变量与其函数返回值匹配。
当检测到已知的命令行选项时,会发生特定于选项的行为。 某些选项有一个参数,在 OPTSTR 中用尾随冒号指定。 当一个选项有一个参数时,程序可以通过外部定义的变量 optarg 访问 argv 中的下一个字符串。 我使用 optarg 打开文件进行读取和写入,或将命令行参数从字符串转换为整数值。
这里有几个关于风格的要点
- 将 opterr 初始化为 0,这将禁用 getopt 发出 ?。
- 在 main() 的中间使用 exit(EXIT_FAILURE); 或 exit(EXIT_SUCCESS);。
- /* NOTREACHED */ 是我喜欢的 lint 指令。
- 在返回 int 的函数末尾使用 return EXIT_SUCCESS;。
- 显式转换隐式类型转换。
如果编译此程序,则其命令行签名将如下所示
$ ./a.out -h
a.out [-v] [-f hexflag] [-i inputfile] [-o outputfile] [-h]
事实上,这就是编译后 usage() 将输出到 stderr 的内容。
8. 函数声明
/* main.c */
<...>
void usage(char *progname, int opt) {
fprintf(stderr, USAGE_FMT, progname?progname:DEFAULT_PROGNAME);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
int do_the_needful(options_t *options) {
if (!options) {
errno = EINVAL;
return EXIT_FAILURE;
}
if (!options->input || !options->output) {
errno = ENOENT;
return EXIT_FAILURE;
}
/* XXX do needful stuff */
return EXIT_SUCCESS;
}
最后,我编写的函数不是样板代码。 在此示例中,函数 do_the_needful() 接受指向 options_t 结构的指针。 我验证 options 指针是否不是 NULL,然后继续验证 input 和 output 结构成员。 如果任何一个测试失败,则返回 EXIT_FAILURE,并且通过将外部全局变量 errno 设置为传统错误代码,我向调用者发出一个一般原因信号。 调用者可以使用便捷函数 perror() 根据 errno 的值发出人类可读的错误消息。
函数几乎总是应该以某种方式验证其输入。 如果完全验证成本很高,请尝试执行一次并以不可变的方式处理验证后的数据。 usage() 函数使用 fprintf() 调用中的条件赋值来验证 progname 参数。 usage() 函数无论如何都要退出,所以我不会费心设置 errno 或对使用正确的程序名称大惊小怪。
我在此处尝试避免的一大类错误是取消引用 NULL 指针。 这将导致操作系统向我的进程发送一个名为 SYSSEGV 的特殊信号,从而导致不可避免的死亡。 用户最不想看到的是由于 SYSSEGV 导致的崩溃。 最好捕获 NULL 指针,以便发出更好的错误消息并优雅地关闭程序。
有些人抱怨在一个函数体中有多个 return 语句。 他们就“控制流的连续性”和其他内容提出论点。 老实说,如果函数中间出现问题,那么现在是返回错误条件的好时机。 编写大量的嵌套 if 语句只是为了有一个返回绝不是一个“好主意”。™
最后,如果你编写一个接受四个或更多参数的函数,请考虑将它们捆绑在一个结构中并传递指向该结构的指针。 这使得函数签名更简单,使它们更容易记住,并且在以后调用时不会出错。 这也使得调用函数稍微快一些,因为需要复制到函数堆栈帧中的内容更少。 在实践中,只有当函数被调用数百万或数十亿次时,这才会成为一个考虑因素。 如果这没有意义,请不要担心。
等等,你说不要注释!?!!
在 do_the_needful() 函数中,我编写了一种特定类型的注释,它旨在作为占位符而不是记录代码
/* XXX do needful stuff */
当你进入状态时,有时你不想停下来编写一些特别棘手的代码。 你稍后会回来做,只是不是现在。 那就是我会给自己留下一小块面包屑的地方。 我插入一个带有 XXX 前缀的注释和一个简短的备注,描述需要做什么。 稍后,当我有更多时间时,我将通过源代码 grep 查找 XXX。 你使用什么并不重要,只要确保它不太可能在你的代码库中的另一个上下文中出现,例如作为函数名或变量。
将所有内容放在一起
好的,当你编译并运行这个程序时,它仍然几乎什么都不做。 但是现在你有一个坚实的骨架来构建你自己的命令行解析 C 程序。
/* main.c - the complete listing */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <libgen.h>
#include <errno.h>
#include <string.h>
#include <getopt.h>
#define OPTSTR "vi:o:f:h"
#define USAGE_FMT "%s [-v] [-f hexflag] [-i inputfile] [-o outputfile] [-h]"
#define ERR_FOPEN_INPUT "fopen(input, r)"
#define ERR_FOPEN_OUTPUT "fopen(output, w)"
#define ERR_DO_THE_NEEDFUL "do_the_needful blew up"
#define DEFAULT_PROGNAME "george"
extern int errno;
extern char *optarg;
extern int opterr, optind;
typedef struct {
int verbose;
uint32_t flags;
FILE *input;
FILE *output;
} options_t;
int dumb_global_variable = -11;
void usage(char *progname, int opt);
int do_the_needful(options_t *options);
int main(int argc, char *argv[]) {
int opt;
options_t options = { 0, 0x0, stdin, stdout };
opterr = 0;
while ((opt = getopt(argc, argv, OPTSTR)) != EOF)
switch(opt) {
case 'i':
if (!(options.input = fopen(optarg, "r")) ){
perror(ERR_FOPEN_INPUT);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
break;
case 'o':
if (!(options.output = fopen(optarg, "w")) ){
perror(ERR_FOPEN_OUTPUT);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
break;
case 'f':
options.flags = (uint32_t )strtoul(optarg, NULL, 16);
break;
case 'v':
options.verbose += 1;
break;
case 'h':
default:
usage(basename(argv[0]), opt);
/* NOTREACHED */
break;
}
if (do_the_needful(&options) != EXIT_SUCCESS) {
perror(ERR_DO_THE_NEEDFUL);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
return EXIT_SUCCESS;
}
void usage(char *progname, int opt) {
fprintf(stderr, USAGE_FMT, progname?progname:DEFAULT_PROGNAME);
exit(EXIT_FAILURE);
/* NOTREACHED */
}
int do_the_needful(options_t *options) {
if (!options) {
errno = EINVAL;
return EXIT_FAILURE;
}
if (!options->input || !options->output) {
errno = ENOENT;
return EXIT_FAILURE;
}
/* XXX do needful stuff */
return EXIT_SUCCESS;
}
现在你已准备好编写更易于维护的 C 代码。 如果你有任何问题或反馈,请在评论中分享它们。
38 条评论