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++ hello.o -o hello

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

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

#define SYSCALL_EXIT  60
#define SYSCALL_WRITE 1

void sys_exit(int code) {
  asm volatile(
    "syscall"
    :
    : "a"(SYSCALL_EXIT), "D"(code)
    : "rcx", "r11", "memory"
  );
}

int sys_write(unsigned fd, const char *buf, unsigned count) {
  unsigned ret;
  asm volatile(
    "syscall"
    : "=a"(ret)
    : "a"(SYSCALL_WRITE), "D"(fd), "S"(buf), "d"(count)
    : "rcx", "r11", "memory"
  );
  return ret;
}

int _start() {
  const char hello[] = "Hello, world!\n";
  sys_write(1, hello, sizeof(hello));
  sys_exit(0);
  return 0;
}

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

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

gcc hello.c -c -o hello.o -nostdlib
ld hello.o -o hello

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

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

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

什么是 Makefile?

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

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

一个例子

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

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

#include "say.h"

int main(int argc, const char *argv[]) {
  const int nums[] = {1, 2, 3, 4, 5};
  Say("Hello!");
  Say(1 + 2 + 3);
  Say(nums, nums + sizeof(nums) / sizeof(int));
  return 0;
}

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

#ifndef OSLAB_SAY_H_
#define OSLAB_SAY_H_

void Say(const char *str);
void Say(int num);
void Say(const int *begin, const int *end);

#endif  // OSLAB_SAY_H_

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

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

#include <iostream>
#include "say.h"
using namespace std;

void Say(const char *str) {
  cout << str << endl;
}

void Say(int num) {
  cout << num << endl;
}

void Say(const int *begin, const int *end) {
  for (const int *it = begin; it != end; ++it) {
    cout << *it << ' ';
  }
  cout << endl;
}

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

g++ main.cpp  -c -o main.o -I.  # 编译 main.cpp
g++ say.cpp   -c -o say.o  -I.  # 编译 say.cpp
g++ main.o say.o -o say         # 链接生成可执行文件

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

Hello!
6
1 2 3 4 5 

小试牛刀

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

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

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

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

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

main.o: main.cpp say.h
	g++ main.cpp -c -o main.o -I.

say.o: say.cpp say.h
	g++ say.cpp -c -o say.o -I.

say: main.o say.o
	g++ main.o say.o -o say

比如 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 say

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

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

all: say

clean:
	rm main.o
	rm say.o
	rm say

main.o: main.cpp say.h
	g++ main.cpp -c -o main.o -I.

say.o: say.cpp say.h
	g++ say.cpp -c -o say.o -I.

say: main.o say.o
	g++ main.o say.o -o say

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

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

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

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

make

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

make clean
make

或者:

make clean all

这在某些情况下很有用.

小试牛刀 Plus

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

all: say

clean:
	rm main.o
	rm say.o
	rm say

%.o: %.cpp say.h
	g++ $< -c -o $@ -I.

say: main.o say.o
	g++ $^ -o $@

先来看第 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 规则写起来还算舒服:

say: main.o say.o

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

SOURCE_DIR  := .
SOURCES     := $(wildcard $(SOURCE_DIR)/*.cpp)
OBJECTS     := $(patsubst $(SOURCE_DIR)/%.cpp, $(SOURCE_DIR)/%.o, $(SOURCES))

all: say

clean:
	rm $(OBJECTS)
	rm say

%.o: %.cpp $(SOURCE_DIR)/say.h
	g++ $< -c -o $@ -I$(SOURCE_DIR)

say: $(OBJECTS)
	g++ $^ -o $@

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

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

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

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

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

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

SOURCE_DIR  := ./a/b/c

就可以再次执行构建了.

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

Last updated

Was this helpful?