如何构建多文件 C 程序:第一部分

拿起你最喜欢的饮料、编辑器和编译器,放点音乐,开始构建一个由多个文件组成的 C 程序。
172 位读者喜欢这篇文章。
Programming keyboard.

Opensource.com

人们常说,计算机编程的艺术一部分在于管理复杂性,一部分在于命名事物。我认为这在很大程度上是正确的,但还要加上“有时它需要绘制框图”。

在本文中,我将命名一些事物并管理一些复杂性,同时编写一个小型的 C 程序,该程序大致基于我在“如何编写一个好的 C main 函数”中讨论的程序结构——但有所不同。这个程序将会做一些事情。拿起你最喜欢的饮料、编辑器和编译器,放点音乐,让我们一起编写一个有点意思的 C 程序。

优秀 Unix 程序的设计理念

关于这个 C 程序,首先要知道的是它是一个 Unix 命令行工具。这意味着它可以运行在(或可以移植到)提供 Unix C 运行时环境的操作系统上。当 Unix 在贝尔实验室被发明时,它从一开始就被赋予了 设计理念。用我自己的话说:程序只做一件事,做好它,并且作用于文件。 虽然只做一件事并做好它很有意义,但关于“作用于文件”的部分似乎有点不合适。

事实证明,Unix 对“文件”的抽象非常强大。一个 Unix 文件是一个字节流,以文件结束 (EOF) 标记结尾。就是这样。文件中任何其他的结构都由应用程序强加,而不是由操作系统强加。操作系统提供系统调用,允许程序对文件执行一组标准操作:打开、读取、写入、查找和关闭(还有其他的,但这些是最重要的)。标准化对文件的访问允许不同的程序共享一个公共的抽象,并且即使不同的人用不同的编程语言实现它们,也可以协同工作。

拥有一个共享的文件接口使得构建可组合的程序成为可能。一个程序的输出可以作为另一个程序的输入。每当程序执行时,Unix 系列操作系统默认提供三个文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。其中两个文件以只写模式打开:stdoutstderr,而 stdin 以只读模式打开。当我们使用像 Bash 这样的命令 shell 中的文件重定向时,我们就会看到这种行为。

$ ls | grep foo | sed -e 's/bar/baz/g' > ack

这种结构可以简述为:ls 的输出被写入 stdout,然后重定向到 grep 的 stdin,grep 的 stdout 被重定向到 sedsed 的 stdout 被重定向到写入当前目录中名为 ack 的文件。

我们希望我们的程序在这个同样灵活和强大的程序生态系统中运行良好,所以让我们编写一个可以读取和写入文件的程序。

MeowMeow:一种流编码/解码的概念

当我还只是一个刚开始学习计算机科学的天真少年的时候(大概是某个年代),有很多的编码方案。 其中一些用于压缩文件,一些用于将文件打包在一起,而另一些则没有任何目的,只是为了让人感到非常愚蠢。最后一个例子是 MooMoo 编码方案

为了给我们的程序一个目的,我将为 21 世纪 更新这个概念,并实现一个名为 MeowMeow 编码的概念(因为互联网喜欢猫)。这里的基本思想是获取文件并用文本“meow”对每个 nibble(半个字节)进行编码。小写字母表示零,大写字母表示一。是的,这将使文件的大小膨胀,因为我们用 32 位来交换 4 位。是的,这是毫无意义的。但是想象一下当发生这种情况时别人脸上的惊讶表情。

$ cat /home/your_sibling/.super_secret_journal_of_my_innermost_thoughts
MeOWmeOWmeowMEoW...

这将会非常棒。

最终的实现

这个程序的完整源代码可以在 GitHub 上找到,但我会讲解我编写它时的思考过程。目的是说明如何构建一个由多个文件组成的 C 程序。

我已经确定要编写一个以 MeowMeow 格式编码和解码文件的程序,所以我启动了一个 shell 并执行了以下命令。

$ mkdir meowmeow
$ cd meowmeow
$ git init
$ touch Makefile     # recipes for compiling the program
$ touch main.c       # handles command-line options
$ touch main.h       # "global" constants and definitions
$ touch mmencode.c   # implements encoding a MeowMeow file
$ touch mmencode.h   # describes the encoding API
$ touch mmdecode.c   # implements decoding a MeowMeow file
$ touch mmdecode.h   # describes the decoding API
$ touch table.h      # defines encoding lookup table values
$ touch .gitignore   # names in this file are ignored by git
$ git add .
$ git commit -m "initial commit of empty files"

简而言之,我创建了一个包含空文件的目录并将它们提交到 git。

即使文件是空的,你也可以从它的名称推断出每个文件的用途。为了防止您无法推断,我用简短的描述注释了每个 touch 命令。

通常,一个程序开始时是一个简单的 main.c 文件,只有两三个函数来解决问题。然后,程序员草率地将该程序展示给朋友或老板,突然间,文件中的函数数量激增,以支持所有新出现的“特性”和“需求”。“程序俱乐部”的第一条规则是不谈论“程序俱乐部”。第二条规则是尽量减少一个文件中的函数数量。

说实话,C 编译器一点也不关心程序中的每个函数是否都在一个文件中。但是我们不是为计算机或编译器编写程序;我们为其他人(有时是我们自己)编写程序。我知道这可能令人惊讶,但这是真的。一个程序体现了一组算法,用计算机解决一个问题,当问题的参数以意想不到的方式改变时,人们理解它很重要。人们将不得不修改程序,如果你在一个文件中放入了全部 2,049 个函数,他们会诅咒你的名字。

因此,我们这些优秀和真实的程序员会分解函数,将相似的函数分组到单独的文件中。 这里我有文件 main.cmmencode.cmmdecode.c。对于像这样的小程序来说,这似乎有点过头了。但是小程序很少保持很小,所以为扩展进行计划是一个“好主意”。

但是那些 .h 文件呢?我将在稍后的一般术语中解释它们,但简而言之,这些文件被称为文件,它们可以包含 C 语言类型定义和 C 预处理器指令。头文件应该包含任何函数。你可以将头文件视为由其他 .c 文件使用的 .c 风味文件提供的应用程序编程接口 (API) 的定义。

但是 Makefile 到底是什么?

我知道你们这些酷孩子正在使用“Ultra CodeShredder 3000”集成开发环境来编写下一个热门应用程序,并且构建你的项目包括猛击 Ctrl-Meta-Shift-Alt-Super-B。但是在我的年代(也就是今天),许多有用的工作都是通过用 Makefile 构建的 C 程序完成的。Makefile 是一个包含用于处理文件的配方的文本文件,程序员使用它来自动化从源代码构建程序二进制文件的过程(以及其他内容!)。

例如,看看这颗小宝石

00 # Makefile
01 TARGET= my_sweet_program
02 $(TARGET): main.c
03    cc -o my_sweet_program main.c

井号/磅/哈希后面的文本是注释,就像在第 00 行中一样。

第 01 行是一个变量赋值,其中变量 TARGET 取字符串值 my_sweet_program。按照惯例,OK,按照我的偏好,所有 Makefile 变量都大写并使用下划线分隔单词。

第 02 行包含配方创建的文件的名称以及它所依赖的文件。在这种情况下,目标是 my_sweet_program, 依赖项是 main.c

最后一行,第 03 行,用制表符而不是四个空格缩进。这是将要执行以创建目标的命令。在这种情况下,我们调用 C 编译器前端 cc 来编译和链接 my_sweet_program

使用 Makefile 很简单

$ make
cc -o my_sweet_program main.c
$ ls 
Makefile  main.c  my_sweet_program

将构建我们的 MeowMeow 编码器/解码器的 Makefile 比这个例子复杂得多,但基本结构是相同的。我将在另一篇文章中以 Barney 式的方式分解它。

形式服从功能

我的想法是编写一个读取文件,转换它,并将转换后的数据写入另一个文件的程序。以下捏造的命令行交互是我设想使用该程序的方式

	$ meow < clear.txt > clear.meow
	$ unmeow < clear.meow > meow.tx
	$ diff clear.txt meow.tx
	$

我们需要编写代码来处理命令行解析和管理输入和输出流。我们需要一个函数来编码一个流并将其写入另一个流。最后,我们需要一个函数来解码一个流并将其写入另一个流。等一下,我一直在谈论编写一个程序,但在上面的例子中,我调用了两个命令:meowunmeow?我知道你可能认为这变得非常复杂。

次要的题外话:argv[0] 和 ln 命令

如果你还记得,C main 函数的签名是

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

其中 argc 是命令行参数的数量,argv 是字符指针(字符串)的列表。argv[0] 的值是包含正在执行的程序的文件的路径。许多具有互补功能的 Unix 实用程序程序(例如,compress 和 uncompress)看起来像两个程序,但实际上,它们是一个程序,在文件系统中有两个名称。两名技巧是通过使用 ln 命令创建文件系统“链接”来实现的。

我笔记本电脑上的 /usr/bin 中的一个例子是

   $ ls -li /usr/bin/git*
3376 -rwxr-xr-x. 113 root root     1.5M Aug 30  2018 /usr/bin/git
3376 -rwxr-xr-x. 113 root root     1.5M Aug 30  2018 /usr/bin/git-receive-pack
...

这里 gitgit-receive-pack 是具有不同名称的同一个文件。我们可以判断它是同一个文件,因为它们具有相同的 inode 编号(第一列)。Inode 是 Unix 文件系统的一个特性,并且完全超出了本文的范围。

优秀和/或懒惰的程序员可以利用 Unix 文件系统的这个特性,用更少的代码交付更多的程序。首先,我们编写一个程序,使其行为根据 argv[0] 的值而改变,然后我们确保创建具有导致该行为的名称的链接。

在我们的 Makefile 中,unmeow 链接是使用以下配方创建的:

 # Makefile
 ...
 $(DECODER): $(ENCODER)
         $(LN) -f $< $@
	...

我倾向于在我的 Makefile 中参数化所有内容,很少使用“裸”字符串。我将所有定义分组在 Makefile 的顶部,这样可以很容易地找到和更改它们。当您尝试将软件移植到新平台并且需要更改所有规则以使用 xcc 而不是 cc 时,这会有很大的不同。

除了两个内置变量 $@$< 之外,该配方应该显得相对简单。第一个是配方目标的快捷方式;在本例中是 $(DECODER)。(我记住这一点是因为 at 符号看起来像一个目标。)第二个,$< 是规则依赖项;在本例中,它解析为 $(ENCODER)

事情肯定变得复杂了,但它是可控的。

接下来阅读什么
标签
XENON coated avatar will glow red in the presence of aliens.
Erik O'Shaughnessy 是一位固执己见但友好的 UNIX 系统程序员,在德克萨斯州过着幸福的生活。 在过去的二十多年(或更长时间!)里,他曾在 IBM、Sun Microsystems、Oracle 以及最近的 Intel 工作,从事与计算机系统性能相关的工作。

2 条评论

非常好的文章,我期待下一部分。 如果你有时间,请写一篇关于你在 Sun Microsystems 的冒险/工作的文章。

Creative Commons License本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.