编译过程探究,第三部分
我在 Linuxjournal.com 上撰写的前两篇文章是关于 GCC 在编译过程中经历的步骤,并且基于我几年前教授的一个软件开发课程。我原本没有打算写成三部分系列,但有人指出我没有涵盖 make 工具,我认为讨论软件开发而不讨论 make 几乎是疏忽。既然我不喜欢把自己看作是疏忽大意的人,所以我决定将这个系列扩展到再写一篇文章。
如果你的项目很简单,少于 5 个源文件,你可能不需要使用 make 工具。你可以简单地使用 shell 脚本编译你的项目,就像我正在为 Linux Journal 的另一篇文章编写的一个小型 3D 图形程序所使用的脚本一样。看看这个。
#!/bin/bash
g++ ./game.cpp -lIrrlicht -lGL -lXxf86vm -lXext -lX11 -ljpeg -lpng -o game
这非常简单。我们只有一个源文件,几个库和一个最终的可执行文件。对这个项目的编译进行脚本化,可以让我们免于每次修改程序时都必须输入那个庞大的命令行。
但是,当你的项目有几个,甚至数百个源文件,每个文件有数百或数千行代码时会发生什么?有时,编译一个非常大的项目可能需要几分钟甚至几小时。如果你在程序中发现一个重要的错误,并且必须进行以下更改怎么办?
将 a = a+b;
改为 a = a-b;
这一个字符的更改意味着你必须重新编译整个项目!现在,虽然等待项目重新编译是去喝啤酒的绝妙借口,但这对于作为软件开发人员的你来说是非常低效的时间利用。
我们从使用 make 工具中获得的能力是只重新编译我们项目中实际需要重新编译的部分。让我们看看这是如何工作的。
make 工具使用一个名为 Makefile 的文件来确定我们项目中哪些部分需要重新编译。本质上,Makefile 指定了哪些文件相互依赖,以及如果我们需要重新生成给定文件,应该如何操作。让我们看一个例子。这是一个简单的 Makefile。
main: main.o f.o
gcc main.o f.o -o main
main.o: main.c
gcc -c main.c -o main.o
f.o: f.c
gcc -c f.c -o f.o
Makefile 的通用格式是目标、该目标的依赖项列表以及用于在任何依赖项被修改时重新生成目标的命令行。
从这个 Makefile 中,我们可以看到一些东西。例如,可执行文件 main 依赖于 main.o 和 f.o。当我们需要重新生成 main 时,可能是因为 main.o 或 f.o 发生了更改,我们使用命令“gcc main.o f.o -o main”。反过来,main.o 依赖于 main.c。当 main.c 发生更改时,main.o 需要重新生成,Makefile 告诉我们如何完成此操作。事实证明,如果 main.c 发生更改,我们必须重新生成 main.o,我们还必须重新链接结果才能重新生成最终的可执行文件。Make 负责为我们处理问题的递归性质。
f.o 目标是类似的。
在创建此 Makefile 并首次键入 make 后,我们必须从头开始重新编译整个项目
# make
gcc -c main.c -o main.o
gcc -c f.c -o f.o
gcc main.o f.o -o main
这导致了一个名为“main”的可执行调用。但是,假设我们对 f.c 进行了更改。当我们重新运行 make 时,我们看到这个
# make
gcc -c f.c -o f.o
gcc main.o f.o -o main
我们看到 main.c 这次没有重新编译,因为它没有更改。文件 f.c 被重新编译成 f.o,然后与现有的 main.o 重新链接。结果是另一个更新的可执行文件,名为 main。
如果出于某种原因,我们想要重新编译 main.c,我们可以通过键入以下内容来要求 make 为我们执行此操作
make main.o
在这种情况下,make 将查阅当前目录中的 Makefile,并找出为了重新生成 main.o 目标需要完成的工作。
因此,通过使用 make 工具,我们可以避免观看可能发生的几次不必要的重新编译。
如果这就是我们对 make 工具的全部期望,那也将是一个巨大的时间节省工具。但还有更多。Make 允许我们定义变量并在我们的 Makefile 中使用它们。例如,看看我的另一个项目中的一个摘录
OBJ = network.o config.o protocol.o parsers.o events.o users.o
CPPFLAGS = -DTEXT_CLIENT
CXXFLAGS = -O3 -ffast-math -Wall
LDFLAGS = -lenet
game: $(OBJ) main.cpp
g++ $(CPPFLAGS) $(CXXFLAGS) \
main.cpp $(OBJ) $(LDFLAGS) -o game
在这里我们看到一些有趣的东西。首先,我们定义了几个变量,OBJ、CPPFLAGS、CXXFLAGS、LDFLAGS。这些变量稍后在 Makefile 中使用,我们在其中描述如何重新制作“game”目标。
为了清晰起见,让我们看看在这个 Makefile 代码片段中指定的命令行会发生什么。我们从
g++ $(CPPFLAGS) $(CXXFLAGS) main.cpp $(OBJ) $(LDFLAGS) -o game
我们看到了对我们之前定义的变量的引用,所以让我们继续并替换它们的值。当我们这样做时,我们最终得到
g++ -DTEXT_CLIENT -O3 -ffast-math -Wall main.cpp network.o config.o protocol.o parsers.o events.o users.o -lenet
我想你明白了变量在 Makefile 内部是如何工作的。在现实生活中,变量可能在 Makefile 中的多个地方使用,从而为我们节省大量时间,并使我们免于犯下琐碎但具有破坏性的打字错误的可能性。
我们还看到,我们可以通过以“\”字符结尾并在下一行继续来使命令行更易于阅读。这是一个简单的事实:任何易于阅读的代码都不容易出错,我们的 Makefile 也不例外。
好的,到目前为止,make 工具听起来确实很酷。但是我们可能会遇到哪些问题呢?
人们在使用 make 时常犯的错误... 是他们遗漏了一个依赖项。例如,假设你有一个文件 foo,它依赖于另一个文件 bar.o,但你忘记在 foo 的依赖项中列出它。
现在,如果 bar.o 不存在,你只会收到一些链接错误消息,你马上就会知道你遗漏了一些东西。
但是,如果你一直在手动编译,直到项目变得足够大,值得使用 make?现在 bar.o 已经存在,但没有被提及为 foo 的依赖项。在这种情况下,一切似乎都工作正常,直到你在 bar.o 中发现一个 bug。因此,你进入用于生成 bar.o 的文件并修复你的 bug 并重新编译。你发现你有相同的症状。你认为也许你忘记保存你的更改,所以你再次这样做。同样的 bug。这次你在你的代码中放置一些调试打印语句并重新编译。同样的 bug 并且没有调试输出!如果你碰巧容易咒骂,这就是开始的地方。幸运的是,一旦你犯过一次这个错误,你就倾向于记住它和症状,并且你不会再次被咬。
头文件,或者如果你用 C 语言编写,则为 .h 文件,可能会导致 make 出现一些问题。在这种情况下,常见的情况是头文件包含所有外部函数和数据类型的原型。很多时候,程序员会偷懒,将他们所有的原型都放在一个文件中,这导致他们必须在所有其他源文件中包含此文件。头文件成为项目中每个文件的依赖项。在这种情况下,程序员创建了一种情况,即对该文件的任何更改都需要重新编译整个项目。有时,这只是手头问题的性质,有时,可以将单个头文件拆分为单独的文件以在单独的模块中使用。
程序员通常使用 make 为自己提供从项目中删除所有可执行文件和目标文件的便利,从而需要完全重新编译。通常,程序员会将这样的目标添加到他的 Makefile 中
clean
rm *.o main
然后程序员可以简单地键入“make clean”并获得一个完全干净的状态。类似地,常见的情况是有一个“install”目标,以便最终用户可以键入“make install”并让软件自动为他们安装。
正如你所看到的,make 工具是一个非常棒的省时工具。它可以帮助程序员确保需要重新编译的文件被重新编译,并且只有那些需要重新编译的文件才会被重新编译。这篇文章没有本系列前两篇那么详细,但我希望这篇文章完善了我们对编译过程的讨论。