Makefile 初见
脏活累活, 脚本来做.
上一节我们简单讨论了 EOS 编译的过程, 其实这个时候你已经可以开始着手编译 EOS 内核了. 什么? 不知道如何将 C/C++ 源文件编译成目标文件? 还是拿 hello.cpp
举例子:
-c
参数告诉编译器: 不要直接将原文件编译链接到可执行文件, 而是仅执行编译 (compile only), 输出目标文件即可.
接下来你需要将目标文件链接. 链接器并不能通过 collect2
直接运行, 就和我们不直接运行 cc1plus
一样. GNU 工具链中附带的链接器为 ld
, 你可以运行:
但是这个时候链接器报错了:
那怎么办呢? 其实一般我们不会直接调用链接器, 因为根据平台的不同, 编译一个 C/C++ 程序往往需要链接各类不同的运行时库. 通常我们只需要再次执行 g++
:
这个时候 g++
会间接调用链接器, 同时将必要的运行时库一同进行链接. 在这里你同样可以使用 -v
参数来查看 g++
到底做了什么. 同理, 我们也可以直接调用链接器 ld
来链接我们的程序, 但是在此之前, 我们需要让程序不要依赖于运行时库 (否则我们要给链接器传很长的一段参数).
修改完成的“Hello, world!”程序如下:
首先你会看到这个程序没有使用 #include
引用任何头文件, 然后程序里还定义了两个很奇怪的函数, 这两个函数实际上分别用系统调用的方式实现了 write
和 exit
的功能. 最后是 _start
函数, 链接器会默认将这个函数作为程序的入口点.
将这段源代码保存为 hello.c
, 即可使用以下命令编译链接 (其中 -nostdlib
告诉编译器不要使用标准库):
运行 hello
程序, BOOM, 奇迹出现了!
什么是 Makefile?
构建大型项目时, 我们需要将所有的源文件先编译为目标文件, 然后调用链接器生成可执行文件或者静态/动态链接库. 我们还希望, 如果我们只修改了少量的源文件, 则后续构建时只重新编译修改过的源文件, 其余的部分复用之前的编译结果即可. 这些工作让人们手动完成是不现实的, 所以如何用一种较为优雅的方式来完成构建?
简而言之, Makefile 是一种供 make
工具执行的脚本 (类似 *.py
和 python
的关系), 而 make
工具正是为解决这个问题而生的. make
不仅可以根据 Makefile 脚本的内容来执行对应文件的编译和链接, 还可以根据脚本內定义的规则, 分析文件之间的依赖关系, 智能地决定何时复用之前的编译结果.
一个例子
程序设计基础课程中, 我们所编写的程序往往只有一个源文件. 不知道你有没有编写过由多个源文件组成的程序? 现在我们就先来写一个:
在 main.cpp
中, 我们希望实现程序的主函数, 在主函数中调用其他函数来实现一些功能:
其中函数 Say
是我们自行实现的, 其作用是向控制台输出字符串, 整数或者整数数组的内容; 而 say.h
存放了函数的定义, 如下:
我们可以在另一个文件中实现函数 Say
的功能, 就起名叫 say.cpp
吧:
在使用 Makefile 解决问题之前, 我们先试一试自己编译这个程序. 由于源文件里使用了 #include "..."
这样的语句, 我们最好在编译源文件时使用 -I
指定头文件的搜索目录:
执行后得到了预期的输出:
小试牛刀
释义: 指小小的试一下
make
这把牛刀 (误)...
从之前的例子中我们可以总结出这样的规律:
构建时首先将所有的
.cpp
文件编译到.o
文件;然后将得到的
.o
文件链接为一个可执行文件.
Makefile 文件由一系列规则组成, 每个规则可以代表一个需要构建的文件, 之后指定构建这个文件的方法, 比如我们可以这样:
比如 main.o
这个规则表示:
想要得到
main.o
这个文件, 需要依赖main.cpp
和say.h
;上述两个文件就绪后, 可以使用命令
g++ main.cpp -c -o main.o -I.
生成main.o
;当
main.cpp
和say.h
的内容发生变化后,main.o
也应该重新生成.
之后的规则同理. 另外注意, Makefile 文件的缩进必须使用制表符 (tab) 而非空格.
此时删除掉之前生成的所有文件, 在目录中新建文件 Makefile
并存入上述内容, 然后执行:
make
首先会在当前目录中寻找 Makefile, 然后据此执行我们设定的规则, 之后我们就得到了 say
这个可执行文件. 你还可以分别修改 main.cpp
, say.cpp
或者 say.h
的内容, 看看当源文件发生变化时 make
分别如何重新构建 say
.
一般来说, Makefile 还会包括两个约定俗成的规则, 分别是 all
和 clean
:
这两个规则不会生成对应的 all
或者 clean
文件, 它们只是为了给我们提供便利:
all
规则表示构建所有我们最终需要的文件, 这里all
会构建say
. 当 Makefile 中存在all
规则时, 直接执行make
后程序会自动运行all
规则;clean
规则表示清理操作, 一般我们会让其清理所有在构建过程中生成的文件. 在这个例子中clean
会删除main.o
,say.o
和say
.
此时, 如果需要构建, 我们只需要执行:
这个命令等价于 make all
或者 make say
, 但是输入起来更方便. 如果我们需要让 make
不复用之前编译的中间结果, 无论如何都重新编译一次, 可以执行:
或者:
这在某些情况下很有用.
小试牛刀 Plus
对于这个例子来说, 我们很容易发现: 不管是 main.o
还是 say.o
, 它们的构建规则都是类似的, 只不过换了文件名而已. 所以这个 Makefile 还可以进一步简化:
先来看第 8 和第 9 行. 之前的两个规则 main.o
和 say.o
直接合二为一变成了 %.o
. 其中 %
表示模式匹配, 这条规则会匹配所有 .o
文件, 所以你会看到之后 say
依赖了两个 .o
文件, 最终这两个文件都会使用这条规则构建.
而 %.cpp
则表示: 如果匹配到了 %.o
, 就按照 %
对应的那个内容去找一个 %.cpp
, 并将这个文件作为依赖. 比如 main.o
就会去找 main.cpp
作为依赖, 以此类推.
$<
代表引用依赖列表里的第一个文件, 这里引用的是匹配到的那个 .cpp
文件; $@
代表引用这条规则的输出文件, 这里会引用生成的那个 .o
文件.
我们看到在规则 say
中还使用了 $^
, 这个符号表示引用依赖列表里的所有文件, 这里引用的是 main.o
和 say.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.
STFW, RTFM 和 RTFSC
在更多情况下, RTFM 要比 STFW 更有效. 虽然这可能会花更长的时间, 但是你可以规避很多网络转载的内容中出现的错误或纰漏, 毕竟手册中记载的内容往往更符合工具设计者的本意, 并且更为准确.
Last updated
Was this helpful?