简介
Linux的 make
程序用来自动化编译大型源码,(Unix/Linux自带make,可直接使用)我们在Linux下编译安装软件,只需要 敲一个 make
就可以全自动完成,非常方便。
make
能自动化完成这些工作,因为项目提供了一个 Makefile
文件,它负责告诉 make ,应该如何编译和链接程序。
Makefile
相当于Java项目的 pom.xml
,Node工程的 package.json
,Rust项目的 Cargo.toml
, 不同之处在于, make 虽然最初是针对C语言开发,但它实际上并不限定C语言,而是可以应用到任意 项目,甚至不是编程语言。此外, make
主要用于Unix/Linux环境的自动化开发,掌握 Makefile
的写 法,可以更好地在Linux环境下做开发,也可以为后续开发Linux内核做好准备。
Makefile基础
在Linux环境下,当我们输入 make
命令时,它就在当前目录查找一个名为 Makefile
的文件,然后,根 据这个文件定义的规则,自动化地执行任意命令,包括编译命令。
Makefile
这个单词,顾名思义,就是指如何生成文件。
我们举个例子:在当前目录下,有3个文本文件: a.txt
, b.txt
和 c.txt
。
现在,我们要合并 a.txt
与 b.txt
,生成中间文件 m.txt
,再用中间文件 m.txt
与 c.txt
合并,生 成最终的目标文件 x.txt
。
┌─────┐ ┌─────┐ ┌─────┐
│a.txt│ │b.txt│ │c.txt│
└─────┘ └─────┘ └─────┘
│ │ │
└───┬───┘ │
│ │
▼ │
┌─────┐ │
│m.txt│ │
└─────┘ │
│ │
└─────┬─────┘
│
▼
┌─────┐
│x.txt│
└─────┘
规则
Makefile
由若干条规则(Rule)构成,每一条规则指出一个目标文件(Target),若干依赖文件 (prerequisites),以及生成目标文件的命令。 例如,要生成 m.txt
,依赖 a.txt
与 b.txt
,规则如下:
#目标文件: 依赖文件1 依赖文件2
m.txt: a.txt b.txt
cat a.txt b.txt > m.txt
一条规则的格式为 目标文件: 依赖文件1 依赖文件2 ...
,紧接着,以Tab开头的是命令,用来生成目标 文件。上述规则使用 cat
命令合并了 a.txt
与 b.txt
,并写入到 m.txt
。用什么方式生成目标文件 make
并不关心,因为命令完全是我们自己写的,可以是编译命令,也可以是 cp
、 mv
等任何命令。 以 #
开头的是注释,会被 make
命令忽略。
注意:Makefile的规则中,命令必须以Tab开头,不能是空格。
由于 make
执行时,默认执行第一条规则(自动寻找关联关系),所以,我们把规则 x.txt
放到前面。完整的 Makefile
如 下:
x.txt: m.txt c.txt
cat m.txt c.txt > x.txt
m.txt: a.txt b.txt
cat a.txt b.txt > m.txt
再次运行 make ,输出如下:
$ make
make: 'x.txt' is up to date.
伪目标
x.txt
和m.txt
是自动生成文件,可以安全删除。使用clean
,但是由于clean
无依赖文件,需要命令make clean
。
$ make clean
rm -f m.txt
rm -f x.txt
若手动创建一个clean文件,此时clean规则不执行,我们需要添加标识才能让clean不视为一个文件。
.PHONY: clean
clean:
rm -f m.txt
rm -f x.txt
执行多条命令
cd:
pwd
cd ..
pwd
$ make cd
pwd
/home/ubuntu/makefile-tutorial/v1
cd ..
pwd
/home/ubuntu/makefile-tutorial/v1
//发现 cd .. 命令执行后,并未改变当前目录,两次输出的 pwd 是一样的,这是因为 make针对每条命令,都会创建一个独立的Shell环境,类似 cd .. 这样的命令,并不会影响当前目录。
cd_ok:
pwd; cd ..; pwd;
$ make cd_ok
pwd; cd ..; pwd
/home/ubuntu/makefile-tutorial/v1
/home/ubuntu/makefile-tutorial
//可以使用 \ 把一行语句拆成多行,便于浏览:
cd_ok:
pwd; \
cd ..; \
pwd
//另一种执行多条命令的语法是用 && ,它的好处是当某条命令失败时,后续命令不会继续执行:
cd_ok:
cd .. && pwd
控制打印
//如果我们不想打印某一条命令,可以在命令前加上 @ ,表示不打印命令(但是仍然会执行):
no_output:
@echo 'not display'
echo 'will display'
//执行结果:
$ make no_output
not display
echo 'will display'
will display
注意命令 echo 'not display' 本身没有打印,但命令仍然会执行,并且执行的结果仍然正常打印。
控制错误
make
在执行命令时,会检查每一条命令的返回值,如果返回错误(非0值),就会中断执行。
例如:不使用-f
删除一个不存在的文件会报错:
has_error:
rm zzz.txt
echo 'ok'
//输出结果:
$ make has_error
rm zzz.txt
rm: zzz.txt: No such file or directory
make: *** [has_error] Error 1
由于命令 rm zzz.txt
报错,导致后面的命令 echo 'ok'
并不会执行, make
打印出错误,然后退出。
有些时候,我们想忽略错误,继续执行后续命令,可以在需要忽略错误的命令前加上 - :
ignore_error:
-rm zzz.txt
echo 'ok'
//输出结果:
$ make ignore_error
rm zzz.txt
rm: zzz.txt: No such file or directory
make: [ignore_error] Error 1 (ignored)
echo 'ok'
ok
//对于执行可能出错,但不影响逻辑的命令,可以用 - 忽略。
编译c程序
C程序的编译通常分两步:
-
将每个 .c 文件编译为 .o 文件;
-
将所有 .o 文件链接为最终的可执行文件。
我们假设如下的一个C项目,包含 hello.c
、 hello.h
和 main.c
。
//hello.c
#include <stdio.h>
int hello()
{
printf("hello, world!\n");
return 0;
}
//hello.h
int hello();
//main.c
#include <stdio.h>
#include "hello.h"
int main()
{
printf("start...\n");
hello();
printf("exit.\n");
return 0;
}
注意到 main.c
引用了头文件 hello.h
。我们很容易梳理出需要生成的文件,逻辑如下:
┌───────┐ ┌───────┐ ┌───────┐
│hello.c│ │main.c │ │hello.h│
└───────┘ └───────┘ └───────┘
│ │ │
│ └────┬────┘
│ │
▼ ▼
┌───────┐ ┌───────┐
│hello.o│ │main.o │
└───────┘ └───────┘
│ │
└───────┬──────┘
│
▼
┌─────────┐
│world.out│
└─────────┘
假定最终生成的可执行文件是 world.out
,中间步骤还需要生成 hello.o
和 main.o
两个文件。根据 上述依赖关系,我们可以很容易地写出 Makefile
如下:
# 生成可执行文件:
world.out: hello.o main.o
cc -o world.out hello.o main.o
# 编译 hello.c:
hello.o: hello.c
cc -c hello.c
# 编译 main.c:
main.o: main.c hello.h
cc -c main.c
clean:
rm -f *.o world.out
//执行make,输出如下
$ make
cc -c hello.c
cc -c main.c
cc -o world.out hello.o main.o
//执行 world.out
$ ./world.out
start...
hello, world!
exit.
使用隐式规则
仍然以上一节的C项目为例,即使我们把 .o 的规则删掉,也能正常编译:
# 只保留生成 world.out 的规则:
world.out: hello.o main.o
cc -o world.out hello.o main.o
clean:
rm -f *.o world.out
//执行 make ,输出如下
$ make
cc -c -o hello.o hello.c
cc -c -o main.o main.c
cc -o world.out hello.o main.o
因为 make
最初就是为了编译C程序而设计的,为了免去重复创建编译 .o
文件的规则, make
内置了隐 式规则(Implicit Rule),即遇到一个 xyz.o
时,如果没有找到对应的规则,就自动应用一个隐式规则:
xyz.o: xyz.c
cc -c -o xyz.o xyz.c
使用隐式规则有一个潜在问题,那就是无法跟踪 .h
文件的修改。如果我们修改了 hello.h
的定义,由于隐式规则 main.o: main.c
并不会跟踪 hello.h
的修改,导致 main.c
不会被重新编译。
使用变量
编程语言使用变量(Variable)来解决反复引用的问题,类似的,在 Makefile
中,也可以使用变量来 解决重复问题。
world.out: hello.o main.o
cc -o world.out hello.o main.o
clean:
rm -f *.o world.out
//编译的最终文件 world.out 重复出现了3次,因此,完全可以定义一个变量来替换它:
TARGET = world.out
$(TARGET): hello.o main.o
cc -o $(TARGET) hello.o main.o
clean:
rm -f *.o $(TARGET)
变量定义用 变量名 = 值 或者 变量名 := 值 ,通常变量名全大写。引用变量用 $(变量名) ,非常简单。
内置变量
//可以用变量 $(CC) 替换命令 cc :
$(TARGET): $(OBJS)
$(CC) -o $(TARGET) $(OBJS)
//没有定义变量 CC 也可以引用它,因为它是 make 的内置变量(Builtin Variables),表示C编译器的名字,默认值是 cc ,我们也可以修改它,例如使用交叉编译时,指定编译器
CC = riscv64-linux-gnu-gcc
自动变量
在 Makefile
中,经常可以看到 $@ 、 $<
这样的变量,这种变量称为自动变量,它们在一个规则中自动指向某个值。 例如, $@
表示目标文件, $^
表示所有依赖文件,因此,我们可以这么写:
world.out: hello.o main.o
cc -o $@ $^
没有歧义时可以写 $@
,也可以写 $(@)
,有歧义时必须用括号,例如 $(@D)
。
//把变量打印出来
world.out: hello.o main.o
@echo '$$@ = $@' # 变量 $@ 表示target
@echo '$$< = $<' # 变量 $< 表示第一个依赖项
@echo '$$^ = $^' # 变量 $^ 表示所有依赖项
cc -o $@ $^
//结果输出如下
$@ = world.out
$< = hello.o
$^ = hello.o main.o
cc -o world.out hello.o main.o
使用模式规则
//前面我们讲了使用隐式规则可以让 make 在必要时自动创建 .o 文件的规则,但 make 的隐式规则的命令是固定的,对于 xyz.o: xyz.c ,它实际上是:
$(CC) $(CFLAGS) -c -o $@ $<
//能修改的只有变量 $(CC) 和 $(CFLAGS) 。如果要执行多条命令,使用隐式规则就不行了。
//我们可以自定义模式规则,修改上一节的 Makefile 如下
OBJS = $(patsubst %.c,%.o,$(wildcard *.c))
TARGET = world.out
$(TARGET): $(OBJS)
cc -o $(TARGET) $(OBJS)
# 模式匹配规则:当make需要目标 xyz.o 时,自动生成一条 xyz.o: xyz.c 规则:
%.o: %.c
@echo 'compiling $<...'
cc -c -o $@ $<
clean:
rm -f *.o $(TARGET)
当 make
执行 world.out: hello.o main.o
时,发现没有 hello.o
文件,于是需要查找以 hello.o
为目 标的规则,结果匹配到模式规则 %.o: %.c
,于是 make
自动根据模式规则为我们动态创建了如下规则:
hello.o: hello.c
@echo 'compiling $<...'
cc -c -o $@ $<
查找 main.o
也是类似的匹配过程,于是我们执行 make
,就可以用模式规则完成编译:
$ make
compiling hello.c...
cc -c -o hello.o hello.c
compiling main.c...
cc -c -o main.o main.c
cc -o world.out hello.o main.o
模式规则仍然没有解决修改 hello.h
头文件不会触发 main.c
重新编译的问题。
自动生成依赖
前面我们讲了隐式规则和模式规则,这两种规则都可以解决自动把 .c
文件编译成 .o
文件,但都无法 解决 .c
文件依赖 .h
文件的问题。
因为一个 .c
文件依赖哪个 .h
文件必须要分析文件内容才能确定,没有一个简单的文件名映射规则。
但是,要识别出 .c
文件的头文件依赖,可以用GCC
提供的 -MM
参数:
$ cc -MM main.c
main.o: main.c hello.h
上述输出告诉我们,编译 main.o
依赖 main.c
和 hello.h
两个文件。
因此,我们可以利用GCC
的这个功能,对每个 .c
文件都生成一个依赖项,通常我们把它保存到 .d
文 件中,再用 include
引入到 Makefile
,就相当于自动化完成了每个 .c
文件的精准依赖。
我们改写上一节的 Makefile
如下:
# 列出所有 .c 文件:
SRCS = $(wildcard *.c)
# 根据SRCS生成 .o 文件列表:
OBJS = $(SRCS:.c=.o)
# 根据SRCS生成 .d 文件列表:
DEPS = $(SRCS:.c=.d)
TARGET = world.out
# 默认目标:
$(TARGET): $(OBJS)
$(CC) -o $@ $^
# xyz.d 的规则由 xyz.c 生成:
%.d: %.c
rm -f $@; \
$(CC) -MM $< >$@.tmp; \
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@; \
rm -f $@.tmp
# 模式规则:
%.o: %.c
$(CC) -c -o $@ $<
clean:
rm -rf *.o *.d $(TARGET)
# 引入所有 .d 文件:
include $(DEPS)
变量 $(SRCS)
通过扫描目录可以确定为 hello.c main.c
,因此,变量 $(OBJS)
赋值为 hello.o main. o
,变量 $(DEPS)
赋值为 hello.d main.d
。
通过 include $(DEPS)
我们引入 hello.d
和 main.d
文件,但是这两个文件一开始并不存在,不过, make
通过模式规则匹配到 %.d: %.c
,这就给了我们一个机会,在这个模式规则内部,用 cc -MM
命 令外加 sed
把 .d
文件创建出来。
运行 make
,首次输出如下:
$ make
Makefile:31: hello.d: No such file or directory
Makefile:31: main.d: No such file or directory
rm -f main.d; \
cc -MM main.c >main.d.tmp; \
sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.tmp > main.d; \
rm -f main.d.tmp
rm -f hello.d; \
cc -MM hello.c >hello.d.tmp; \
sed 's,\(hello\)\.o[ :]*,\1.o hello.d : ,g' < hello.d.tmp > hello.d; \
rm -f hello.d.tmp
cc -c -o hello.o hello.c
cc -c -o main.o main.c
cc -o world.out hello.o main.o
make
会提示找不到 hello.d
和 main.d
,不过随后自动创建出 hello.d
和 main.d
。 hello.d
内容如 下:
hello.o hello.d : hello.c
上述规则有两个目标文件,实际上相当于如下两条规则:
hello.o : hello.c
hello.d : hello.c
main.d
内容如下:
main.o main.d : main.c hello.h
因此, main.o
依赖于 main.c
和 hello.h
,这个依赖关系就和我们手动指定的一致。 改动 hello.h
,再次运行 make
,可以触发 main.c
的编译:
$ make
rm -f main.d; \
cc -MM main.c >main.d.tmp; \
sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.tmp > main.d; \
rm -f main.d.tmp
cc -c -o main.o main.c
cc -o world.out hello.o main.o
在实际项目中,对每个 .c
文件都可以生成一个对应的 .d
文件表示依赖关系,再通过 include
引入 到 Makefile
,同时又能让 make
自动更新 .d
文件,有点蛋生鸡和鸡生蛋的关系,不过,这种机制能 正常工作,除了 .d 文件不存在时会打印错误,有强迫症的同学肯定感觉不满意,这个问题我们后面解决。
完善makefile
上一节我们解决了自动生成依赖的问题,这一节我们对项目目录进行整理,把所有源码放入 src
目 录,所有编译生成的文件放入 build
目录:
<project>
├── Makefile
├── build
└── src
├── hello.c
├── hello.h
└── main.c
整理 Makefile
,内容如下:
SRC_DIR = ./src
BUILD_DIR = ./build
TARGET = $(BUILD_DIR)/world.out
CC = cc
CFLAGS = -Wall
# ./src/*.c
SRCS = $(shell find $(SRC_DIR) -name '*.c')
# ./src/*.c => ./build/*.o
OBJS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))
# ./src/*.c => ./build/*.d
DEPS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.d,$(SRCS))
# 默认目标:
all: $(TARGET)
# build/xyz.d 的规则由 src/xyz.c 生成:
$(BUILD_DIR)/%.d: $(SRC_DIR)/%.c
@mkdir -p $(dir $@); \
rm -f $@; \
$(CC) -MM $< >$@.tmp; \
sed 's,\($*\)\.o[ :]*,$(BUILD_DIR)/\1.o $@ : ,g' < $@.tmp > $@; \
rm -f $@.tmp
# build/xyz.o 的规则由 src/xyz.c 生成:
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c -o $@ $<
# 链接:
$(TARGET): $(OBJS)
@echo "buiding $@..."
@mkdir -p $(dir $@)
$(CC) -o $(TARGET) $(OBJS)
# 清理 build 目录:
clean:
@echo "clean..."
rm -rf $(BUILD_DIR)
# 引入所有 .d 文件:
-include $(DEPS)
这个 Makefile
定义了源码目录 SRC_DIR
、生成目录 BUILD_DIR
,以及其他变量,同时用 -include
消 除了 .d
文件不存在的错误。执行 make
,输出如下:
$ make
cc -Wall -c -o build/hello.o src/hello.c
cc -Wall -c -o build/main.o src/main.c
buiding build/world.out...
cc -o ./build/world.out ./build/hello.o ./build/main.o