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

在本文的第二部分中,更深入地了解由多个文件组成的 C 程序的结构。
151 位读者喜欢这篇文章。
4 manilla folders, yellow, green, purple, blue

Open Clip Art Library (公共领域)。由 Jen Wike Huger 修改。

第 1 部分中,我介绍了名为 MeowMeow 的多文件 C 程序的结构,该程序实现了一个玩具 编解码器。我还谈到了 Unix 程序设计理念,从一开始就设计了一些空文件,以便拥有一个良好的结构。最后,我简要介绍了 Makefile 是什么以及它能为您做什么。本文接续上一篇文章,现在我将介绍我们这个愚蠢(但具有指导意义)的 MeowMeow 编解码器的实际实现。

对于读过我文章“如何编写一个好的 C main 函数”的人来说,meow/unmeowmain.c 文件的结构应该很熟悉。它具有以下总体轮廓

/* main.c - MeowMeow, a stream encoder/decoder */

/* 00 system includes */
/* 01 project includes */
/* 02 externs */
/* 03 defines */
/* 04 typedefs */
/* 05 globals (but don't)*/
/* 06 ancillary function prototypes if any */
   
int main(int argc, char *argv[])
{
  /* 07 variable declarations */
  /* 08 check argv[0] to see how the program was invoked */
  /* 09 process the command line options from the user */
  /* 10 do the needful */
}
   
/* 11 ancillary functions if any */

包含项目头文件

第二部分 /* 01 project includes /*,从源代码中读取如下

/* main.c - MeowMeow, a stream encoder/decoder */
...
/* 01 project includes */
#include "main.h"
#include "mmecode.h"
#include "mmdecode.h"

#include 指令是一个 C 预处理器命令,它使命名文件的内容在该文件中的此点被“包含”。如果程序员在头文件名称周围使用双引号,编译器将在当前目录中查找该文件。如果文件包含在 <> 中,它将在预定义目录集中查找该文件。

main.h 文件包含 main.c 中使用的定义和 typedef。我喜欢将这些内容收集在此处,以防我想在程序的其他地方使用这些定义。

mmencode.hmmdecode.h 文件几乎相同,因此我将分解 mmencode.h

 /* mmencode.h - MeowMeow, a stream encoder/decoder */
    
 #ifndef _MMENCODE_H
 #define _MMENCODE_H
    
 #include <stdio.h>
    
 int mm_encode(FILE *src, FILE *dst);
    
 #endif	/* _MMENCODE_H */

#ifdef、#define、#endif 结构统称为“保护符”。这可以防止 C 编译器在每个文件中多次包含此文件。如果编译器发现多个定义/原型/声明,它会报错,因此保护符是头文件的必备项。

在保护符内部,只有两件事:一个 #include 指令和一个函数原型声明。我在此处包含 stdio.h 以引入 FILE 的定义,该定义用于函数原型中。函数原型可以被其他 C 文件包含,以在该文件的命名空间中建立该函数。您可以将每个文件视为一个单独的命名空间,这意味着一个文件中的变量和函数不能被另一个文件中的函数或变量使用。

编写头文件很复杂,并且在较大的项目中很难管理。 使用保护符。

MeowMeow 编码,终于来了

该程序的核心——将字节编码/解码为 MeowMeow 字符串——实际上是该项目中容易的部分。到目前为止,我们所有的活动都已将脚手架放置到位,以支持调用此函数:解析命令行、确定要使用的操作以及打开我们将要操作的文件。这是编码循环

 /* mmencode.c - MeowMeow, a stream encoder/decoder */
 ...
     while (!feof(src)) {
    
       if (!fgets(buf, sizeof(buf), src))
         break;
	      
       for(i=0; i<strlen(buf); i++) {
         lo = (buf[i] & 0x000f);
         hi = (buf[i] & 0x00f0) >> 4;
         fputs(tbl[hi], dst);
         fputs(tbl[lo], dst);
       }
	    }

用简单的英语来说,此循环在有剩余块可读取时读取文件中的一块(feof(3)fgets(3))。然后,它将块中的每个字节拆分为 hilo 半字节。请记住,半字节是字节的一半,或 4 位。这里的真正魔力在于意识到 4 位可以编码 16 个值。我使用 hilo 作为 16 字符串查找表 tbl 的索引,该表包含编码每个半字节的 MeowMeow 字符串。这些字符串使用 fputs(3) 写入目标 FILE 流,然后我们继续处理缓冲区中的下一个字节。

该表使用 table.h 中定义的宏进行初始化,这没有什么特别的原因,只是为了演示包括另一个项目本地头文件,并且我喜欢初始化宏。我们将在以后的文章中进一步探讨原因。

MeowMeow 解码

好吧,我承认我尝试了几次才让它工作。解码循环类似:读取一个充满 MeowMeow 字符串的缓冲区,并将编码从字符串反转为字节。

 /* mmdecode.c - MeowMeow, a stream decoder/decoder */
 ...
 int mm_decode(FILE *src, FILE *dst)
 {
   if (!src || !dst) {
     errno = EINVAL;
     return -1;
   }
   return stupid_decode(src, dst);
 }

不是你所期望的?

在这里,我通过外部可见的 mm_decode() 函数公开了函数 stupid_decode()。 当我说“外部”时,我的意思是在此文件之外。 由于 stupid_decode() 不在头文件中,因此无法在其他文件中调用它。

有时当我们想要发布一个可靠的公共接口时,我们会这样做,但是我们还没有完全完成解决问题的功能。就我而言,我编写了一个 I/O 密集型函数,该函数一次从源流中读取 8 个字节,以解码 1 个字节以写入目标流。更好的实现一次将在大于 8 个字节的缓冲区上工作。更好的实现也将缓冲输出字节,以减少对目标流的单字节写入次数。

 /* mmdecode.c - MeowMeow, a stream decoder/decoder */
 ...
 int stupid_decode(FILE *src, FILE *dst)
 {
   char           buf[9];
   decoded_byte_t byte;
   int            i;
      
   while (!feof(src)) {
     if (!fgets(buf, sizeof(buf), src))
       break;
     byte.field.f0 = isupper(buf[0]);
     byte.field.f1 = isupper(buf[1]);
     byte.field.f2 = isupper(buf[2]);
     byte.field.f3 = isupper(buf[3]);
     byte.field.f4 = isupper(buf[4]);
     byte.field.f5 = isupper(buf[5]);
     byte.field.f6 = isupper(buf[6]);
     byte.field.f7 = isupper(buf[7]);
       
     fputc(byte.value, dst);
   }
   return 0;
 }

我没有使用编码器中使用的位移技术,而是选择创建了一个名为 decoded_byte_t 的自定义数据结构。

 /* mmdecode.c - MeowMeow, a stream decoder/decoder */
 ...

 typedef struct {
   unsigned char f7:1;
   unsigned char f6:1;
   unsigned char f5:1;
   unsigned char f4:1;
   unsigned char f3:1;
   unsigned char f2:1;
   unsigned char f1:1;
   unsigned char f0:1;
 } fields_t;
    
 typedef union {
   fields_t      field;
   unsigned char value;
 } decoded_byte_t;

如果一次性查看所有内容,它可能会有点复杂,但请稍等。 decoded_byte_t 定义为 fields_tunsigned charunion。 可以将联合的命名成员视为同一内存区域的别名。 在这种情况下,valuefield 指的是相同的 8 位内存区域。 将 field.f0 设置为 1 也会设置 value 中的最低有效位。

虽然 unsigned char 不应该是一个谜,但是 fields_ttypedef 看起来可能有点陌生。 现代 C 编译器允许程序员在 struct 中指定“位字段”。 字段类型需要是无符号整数类型,并且成员标识符后跟一个冒号和一个指定位字段长度的整数。

这种数据结构使得通过字段名称访问字节中的每个位,然后通过联合的 value 字段访问组装的值变得简单。 我们依靠编译器生成正确的位移指令来访问这些字段,这可以节省您在调试时的大量麻烦。

最后,stupid_decode()愚蠢的,因为它一次仅从源 FILE 流中读取 8 个字节。通常,我们尝试最小化读取和写入的次数,以提高性能并降低系统调用成本。请记住,不太频繁地读取或写入更大的块比更频繁地读取/写入许多更小的块要好得多。

总结

用 C 编写多文件程序比仅编写一个 main.c 需要程序员进行更多的规划。但是,预先付出一些努力可以在重构时节省大量时间和麻烦,因为您添加了功能。

总而言之,我喜欢有很多文件,其中包含一些简短的函数。我喜欢通过头文件公开这些文件中的一小部分函数。我喜欢将常量保留在头文件中,包括数字常量和字符串常量。我喜欢 Makefile,并使用它们代替 Bash 脚本来自动执行各种操作。我喜欢我的 main() 函数来处理命令行参数解析,并充当程序主要功能的支架。

我知道我只触及了这个简单程序中发生的事情的表面,我很高兴了解哪些事情对您有所帮助,哪些主题需要更好的解释。 在评论中分享您的想法,让我知道。

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

评论已关闭。

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