Make 编译脚本上手
Make 编译脚本上手
考察下面的示例代码:
main.c
#include <stdio.h>
int main(){
printf("hello world!");
return 0;
}
正常情况下,通过 gcc
在命令行将其编译后产出相应文件,可执行文件或 object 文件等。
$ gcc -o main.out main.c
上面命令编译后运行 main.out
可执行文件。
$ ./main.out
hello world!
Make 工具
通过 make
命令,可以将上面的编译进行有效自动化管理。通过将从输入文件到输出文件的编译无则编写成 Makefile 脚本,Make 工具将自动处理文件间依赖及是否需要编译的检测。
make
命令所使用的编译配置文件可以是 Makefile
,makefile
或 GUNMake
。
其中定义任务的基本语法为:
target1 [target2 ...]: [pre-req-1 pre-req-2 ...]
[command1
command2
......]
上面形式也可称作是一条编译规则(rule)。
其中,
target
为任务名或文件产出。如果该任务不产出文件,则称该任务为Phony Targets
。make
内置的 phony target 有all
,install
及clean
等,这些任务都不实际产出文件,一般用来执行一些命令。pre-req123...
这些是依赖项,即该任务所需要的外部输入,这些输入可以是其他文件,也可以是其他任务产出的文件。command
为该任务具体需要执行的 shell 命令。
Makefile 示例
比如文章最开始的编译,可通过编写下面的 Makefile 来完成:
Makefile
all:main.out
main.out: main.c
gcc -o main.out main.c
clean:
rm main.out
上面的 Makefile 中定义了三个任务,调用时可通过 make <target name>
形式来调用。
比如:
$ make main.out
gcc -o main.out main.c
产出 main.out
文件。
再比如:
$ make clean
rm main.out
该 clean
任务清除刚刚生成的 main.out
文件。
三个任务中,all
为内置的任务名,一般一个 Makefile 中都会包含,当直接调用 make
后面没有跟任务名时,默认执行的就是 all
。
$ make
gcc -o main.out main.c
命令的换行
如果一条编译规则中所要执行的 shell 命令有单条很长的情况,可通过 \
来换行。
main.out: main.c
gcc \
-o main.out \
main.c
注意 \
与命令结尾处需要间隔一个空格,否则识别出错。
main.out: main.c
gcc\ # 🚨
-o main.out\ # 🚨
main.c
任务间的依赖
前面调用 all
的效果等同于调用 main.out
任务,因为 all
的输入依赖为 main.out
文件。Make 在执行任务前会先检查其输入的依赖项,执行 all
时发现它依赖 main.out
文件,于是本地查找,发现本地没有,再从 Makefile 中查找看是否有相应任务会产生该文件,结果确实有相应任务能产生该文件,所以先执行能够产生依赖项的任务。
增量编译
使用 Makefile 进行编译有个好处是,在执行任务时,它会先检查依赖项是否比需要产出的文件新,如果说依赖项更新新,则说明我们需要产出的目标文件属于过时的产物,需要重新生成。
什么意思。比如上面的示例,当执行
$ make main.out
试图生成 main.out
产出时,会检查这个任务的依赖文件 main.c
是否有修改过。
比如前面我们已经执行过该任务产生过 main.out
。再次执行时,会得到如下提示:
$ make main.out
make: `main.out' is up to date.
NOTE: 上面是 Mac 上最新版本的 Make 工具(GNU Make 3.81)的提示语,老版或其他变种工具得到的可能是 Nothing to be done for \
main.out` `。
现在对输入文件 main.c
进行修改:
#include <stdio.h>
int main(){
- printf("hello world!");
+ printf("hello wayou!");
return 0;
}
再次执行 make main.out
会发现任务正常执行并产生了新的输出,
$ make main.out
gcc -o main.out main.c
$ ./main.out
hello wayou!⏎
这里 main.c
修改后,它在文件上来说,就比 main.out
更新了,所以我们说 main.out
这个目标, 过时(out-dated) 了。
过时的任务才会被重新执行,而未过时的会跳过,并输出相应信息。
以上,Makefile 天然实现了增量编译的效果,在大型项目下会节省不少编译时间,因为它只编译过期的任务。
Phony 类型任务的执行
需要注意的是,phony 类型的任务永远都属于过时类型,即,每次 make
都会执行。因为这种类型的任务它没有文件产出,就无所谓检查,它的主体只是调用了另外的命令而以。
拿这里的 all
来说,当我们执行 make
或 make all
时,得到:
$ make
make: Nothing to be done for `all'.
这里看不出来 all
有没有执行,因为目前它还没有包含任何一句命令,调用 all
后实际执行的是它的依赖文件 main.out
中的任务,而因为后者已经是最新的了,所以无须执行,所以得到了如上的输出。
为了验证 phony 类型任务是否每次都执行,向 all
及 main.out
中添加 echo
命令打印一些信息、
all:main.out
+ echo "[all] done"
main.out: main.c
gcc -o main.out main.c
+ echo "[main.out] done"
clean:
rm main.out
再次执行:
$ make
echo "[all] done"
[all] done
$ make
echo "[all] done"
[all] done
$ make main.out
make: `main.out' is up to date.
可以看到,属于 phony 类型的任务 all
每次都会执行其中定义的 shell 命令,而非 phony 类型的任务 main.out
则走了增量编译的逻辑。
变量/宏
Makefile 中可使用变量(宏)来让脚本更加灵活和减少冗余。
其中变量使用 $
加圆括号或花括号的形式来使用,$(VAR)
,定义时类似于 C 中定义宏,所以变量也可叫 Makefile 中的宏,
CC=gcc
这里定义 CC
表示 gcc
编译工具。然后在后续编译命令中,就可以使用 $(CC)
代替 gcc
来书写 shell 命令了。
+ CC=gcc
all:main.out
main.out: main.c
- gcc -o main.out main.c
+ $(CC) -o main.out main.c
clean:
rm main.out
这样做的好处是什么?因为编译工具可能随着平台或环境或需要编译的目标不同,而不同。比如 gcc
只是用来编译 C 代码的,如果是 C++ 你可能要用 g++
来编译。如果是编译 WebAssembly 则需要使用 emcc
。
无论怎样变,我们只需要修改定义在文件开头的 CC
变量即可,无须修改其他地方。这当然只是其中一点好处。
自动变量/Automatic Variables
自动变量/Automatic Variables 是在编译规则匹配后工具进行设置的,具体包括:
$@
:代表产出文件名$*
:代表产出文件名不包括扩展名$<
:依赖项中第一个文件名$^
:空格分隔的去重后的所有依赖项$+
:同上,但没去重$?
:同上,但只包含比产出更新的那些依赖
这些变量都只有一个符号,区别于正常用字母命名的变量需要使用 $(VAL)
的形式来使用,自动变量无需加括号。
利用自动变量,前面示例可改造成:
CC=gcc
TARGET=main.out
all:$(TARGET)
$(TARGET): main.c
$(CC) -o $@ $^
clean:
rm $(TARGET)
减少了重复代码,更加易于维护,需要修改时,改动比较小。
VPATH & vpath
可通过 VPATH
指定依赖文件及产出文件的搜索目录。
VPATH = src include
通过小写的 vpath
可指定具体的文件名及扩展名类型,
vpath %.c src
vpath %.h include
此处 %
表示文件名。
依赖规则/Dependency Rules
Main.o : Main.h Test1.h Test2.h
Test1.o : Test1.h Test2.h
Test2.o : Test2.h
像这种,只定义了产出与依赖没包含任务命令的无则,叫作依赖无则。因为它只定义了某个产出依赖哪些输入,故名。
这种规则可达到这种效果,即,右边任何文件有变更,左边的产出便成为过时的了。
匹配规则/Pattern Rules
区别于明确指定了产出与依赖,如果一条规则包含通配符,则称作匹配规则(Pattern Rules)。
比如,
%.o: %.c
gcc -o $@ $^
上面定义了这么一条编译规则,将所有匹配到的 c 文件编译成 Object 产出。
有什么用?
这种规则一般不是直接调用的,是被其他它规则触间接使用。比如上面的依赖规则。
%.o : %.cpp
g++ -g -o $@ -c $<
Main.o : Main.h Test1.h Test2.h
Test1.o : Test1.h Test2.h
Test2.o : Test2.h
当右侧这些头文件有变动时,左边的产出会在 make
时被检测到过时,于是会被执行。当执行时匹配规则 %.o
会被匹配到,所以匹配规则里面的命令会执行,从而将 cpp
文件编译成相应 Object 文件。达到了依赖更新后批量更新产出的目的,而不写成这样:
Main.o : Main.h Test1.h Test2.h
g++ -g -o $@ -c $<
Test1.o : Test1.h Test2.h
g++ -g -o $@ -c $<
Test2.o : Test2.h
g++ -g -o $@ -c $<
一个实际一点的示例
添加 lame 依赖到项目并将其编译打包。
首先下载并解压 lame 到项目目录:
$ wget https://sourceforge.net/projects/lame/files/lame/3.100/lame-3.100.tar.gz
$ tar -zxvf lame-3.100.tar.gz
主程序中调用 lame,这只仅简单地打印其版本信息:
main.c
#include <stdio.h>
#include "./lame-3.100/include/lame.h"
int main() {
const char* ver = get_lame_version();
printf("lame ver: %s", *ver);
return 0;
}
编译项目的 Makefile:
Makefile
CC=gcc
TARGET=main.out
ENTRY=main.c
LAME_DIR=lame-3.100
all: $(TARGET)
$(TARGET): $(ENTRY) $(LAME_DIR)/libmp3lame/.libs/libmp3lame.a
$(CC) -o $@ \
$^
$(LAME_DIR)/libmp3lame/.libs/libmp3lame.a:
cd $(LAME_DIR); ./configure ; make
clean:
rm $(TARGET)
编译并运行:
$ make
$ ./main.out
lame ver: 3⏎