Makefile 初见

脏活累活, 脚本来做.

上一节我们简单讨论了 EOS 编译的过程, 其实这个时候你已经可以开始着手编译 EOS 内核了. 什么? 不知道如何将 C/C++ 源文件编译成目标文件? 还是拿 hello.cpp 举例子:

g++ hello.cpp -c -o hello.o

-c 参数告诉编译器: 不要直接将原文件编译链接到可执行文件, 而是仅执行编译 (compile only), 输出目标文件即可.

接下来你需要将目标文件链接. 链接器并不能通过 collect2 直接运行, 就和我们不直接运行 cc1plus 一样. GNU 工具链中附带的链接器为 ld, 你可以运行:

ld hello.o -o hello

但是这个时候链接器报错了:

ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
ld: hello.o: in function `main':
hello.cpp:(.text+0x19): undefined reference to `std::cout'
ld: hello.cpp:(.text+0x1e): undefined reference to `std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)'
ld: hello.cpp:(.text+0x28): undefined reference to `std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)'
ld: hello.cpp:(.text+0x33): undefined reference to `std::ostream::operator<<(std::ostream& (*)(std::ostream&))'
ld: hello.o: in function `__static_initialization_and_destruction_0(int, int)':
hello.cpp:(.text+0x63): undefined reference to `std::ios_base::Init::Init()'
ld: hello.cpp:(.text+0x6a): undefined reference to `__dso_handle'
ld: hello.cpp:(.text+0x78): undefined reference to `std::ios_base::Init::~Init()'
ld: hello.cpp:(.text+0x80): undefined reference to `__cxa_atexit'
ld: hello: hidden symbol `__dso_handle' isn't defined
ld: final link failed: bad value

思考: 链接器为什么会报错, 通过报错的信息我们可以看出链接器分别缺少了之前提到的一般 C/C++ 程序所依赖的哪些必要部分?

那怎么办呢? 其实一般我们不会直接调用链接器, 因为根据平台的不同, 编译一个 C/C++ 程序往往需要链接各类不同的运行时库. 通常我们只需要再次执行 g++:

这个时候 g++ 会间接调用链接器, 同时将必要的运行时库一同进行链接. 在这里你同样可以使用 -v 参数来查看 g++ 到底做了什么. 同理, 我们也可以直接调用链接器 ld 来链接我们的程序, 但是在此之前, 我们需要让程序不要依赖于运行时库 (否则我们要给链接器传很长的一段参数).

修改完成的“Hello, world!”程序如下:

首先你会看到这个程序没有使用 #include 引用任何头文件, 然后程序里还定义了两个很奇怪的函数, 这两个函数实际上分别用系统调用的方式实现了 writeexit 的功能. 最后是 _start 函数, 链接器会默认将这个函数作为程序的入口点.

将这段源代码保存为 hello.c, 即可使用以下命令编译链接 (其中 -nostdlib 告诉编译器不要使用标准库):

运行 hello 程序, BOOM, 奇迹出现了!

如果你尝试将这个文件改名为 hello.cpp, 然后用 g++ 进行编译操作, 你会发现之后无论如何都无法正常链接. 为什么?

既然如此, 如何稍作改动就让这个 hello.cpp 能够正确链接?

什么是 Makefile?

构建大型项目时, 我们需要将所有的源文件先编译为目标文件, 然后调用链接器生成可执行文件或者静态/动态链接库. 我们还希望, 如果我们只修改了少量的源文件, 则后续构建时只重新编译修改过的源文件, 其余的部分复用之前的编译结果即可. 这些工作让人们手动完成是不现实的, 所以如何用一种较为优雅的方式来完成构建?

简而言之, Makefile 是一种供 make 工具执行的脚本 (类似 *.pypython 的关系), 而 make 工具正是为解决这个问题而生的. make 不仅可以根据 Makefile 脚本的内容来执行对应文件的编译和链接, 还可以根据脚本內定义的规则, 分析文件之间的依赖关系, 智能地决定何时复用之前的编译结果.

一个例子

程序设计基础课程中, 我们所编写的程序往往只有一个源文件. 不知道你有没有编写过由多个源文件组成的程序? 现在我们就先来写一个:

main.cpp 中, 我们希望实现程序的主函数, 在主函数中调用其他函数来实现一些功能:

其中函数 Say 是我们自行实现的, 其作用是向控制台输出字符串, 整数或者整数数组的内容; 而 say.h 存放了函数的定义, 如下:

函数的定义较为简单. 这里你可能会问那些 #ifndef, #define#endif 是干什么的, 它们是 include guard.

我们可以在另一个文件中实现函数 Say 的功能, 就起名叫 say.cpp 吧:

在使用 Makefile 解决问题之前, 我们先试一试自己编译这个程序. 由于源文件里使用了 #include "..." 这样的语句, 我们最好在编译源文件时使用 -I 指定头文件的搜索目录:

执行后得到了预期的输出:

小试牛刀

释义: 指小小的试一下 make 这把牛刀 (误)...

从之前的例子中我们可以总结出这样的规律:

  1. 构建时首先将所有的 .cpp 文件编译到 .o 文件;

  2. 然后将得到的 .o 文件链接为一个可执行文件.

Makefile 文件由一系列规则组成, 每个规则可以代表一个需要构建的文件, 之后指定构建这个文件的方法, 比如我们可以这样:

比如 main.o 这个规则表示:

  • 想要得到 main.o 这个文件, 需要依赖 main.cppsay.h;

  • 上述两个文件就绪后, 可以使用命令 g++ main.cpp -c -o main.o -I. 生成 main.o;

  • main.cppsay.h 的内容发生变化后, main.o 也应该重新生成.

思考: make 是怎么知道哪个文件发生了变化的?

之后的规则同理. 另外注意, Makefile 文件的缩进必须使用制表符 (tab) 而非空格.

此时删除掉之前生成的所有文件, 在目录中新建文件 Makefile 并存入上述内容, 然后执行:

make 首先会在当前目录中寻找 Makefile, 然后据此执行我们设定的规则, 之后我们就得到了 say 这个可执行文件. 你还可以分别修改 main.cpp, say.cpp 或者 say.h 的内容, 看看当源文件发生变化时 make 分别如何重新构建 say.

一般来说, Makefile 还会包括两个约定俗成的规则, 分别是 allclean:

这两个规则不会生成对应的 all 或者 clean 文件, 它们只是为了给我们提供便利:

  • all 规则表示构建所有我们最终需要的文件, 这里 all 会构建 say. 当 Makefile 中存在 all 规则时, 直接执行 make 后程序会自动运行 all 规则;

  • clean 规则表示清理操作, 一般我们会让其清理所有在构建过程中生成的文件. 在这个例子中 clean 会删除 main.o, say.osay.

此时, 如果需要构建, 我们只需要执行:

这个命令等价于 make all 或者 make say, 但是输入起来更方便. 如果我们需要让 make 不复用之前编译的中间结果, 无论如何都重新编译一次, 可以执行:

或者:

这在某些情况下很有用.

小试牛刀 Plus

对于这个例子来说, 我们很容易发现: 不管是 main.o 还是 say.o, 它们的构建规则都是类似的, 只不过换了文件名而已. 所以这个 Makefile 还可以进一步简化:

先来看第 8 和第 9 行. 之前的两个规则 main.osay.o 直接合二为一变成了 %.o. 其中 % 表示模式匹配, 这条规则会匹配所有 .o 文件, 所以你会看到之后 say 依赖了两个 .o 文件, 最终这两个文件都会使用这条规则构建.

%.cpp 则表示: 如果匹配到了 %.o, 就按照 % 对应的那个内容去找一个 %.cpp, 并将这个文件作为依赖. 比如 main.o 就会去找 main.cpp 作为依赖, 以此类推.

$< 代表引用依赖列表里的第一个文件, 这里引用的是匹配到的那个 .cpp 文件; $@ 代表引用这条规则的输出文件, 这里会引用生成的那个 .o 文件.

我们看到在规则 say 中还使用了 $^, 这个符号表示引用依赖列表里的所有文件, 这里引用的是 main.osay.o. 注意区别 $^$< 的关系.

这样, 我们就进一步简化了 Makefile 的内容.

小试牛刀++

虽然我们已经编写了一个能够匹配所有 .o 文件的构建规则, 但是目前的工程里只有两个 .o 文件, 最后的 say 规则写起来还算舒服:

那假如有一万个 .o 文件呢? 难道我们要把那一万个文件都写一遍吗? 显然不用, 我们同样可以用模式匹配的方式在当前目录下寻找所有的 .cpp 文件, 将其转换为对应的 .o 文件写在 say 规则之后:

在此解释其中的几个地方:

  • VAR := value 表示新建一个变量 VAR 并将其赋值为 value;

  • $(VAR) 表示引用变量 VAR 的值;

  • $(wildcard ...) 函数会在指定的路径中匹配所有符合条件的文件;

  • $(patsubst A, B, C) 函数会执行模式匹配替换, 将 C 的内容使用模式 A 匹配, 并替换为 B.

这样, Makefile 的可配置性就大幅提高了. 试想由于某种原因, 所有的源文件都移到了当前目录下的 a/b/c 目录中, 我们只需要修改第一行为:

就可以再次执行构建了.

之后章节中还会出现更为复杂的 Makefile 脚本, 其中可能会有许多你没有见过的新用法, 此时你应当学会 RTFM.

Last updated

Was this helpful?