C和C++构建:Makefile编写详细指南(学习笔记)

2021年3月8日16:28:31 发表评论 2,924 次浏览

本文概述

前言

C和C++构建工具:Makefile文件编写

C/C++开发一直都不简单,首先掌握其语言就有一些难度,本人是自学C/C++的,磨了好长时间,现在终于能综合C/C++开发了——其实和Java或其它语言开发都差不多:

  • 首先要掌握语言,基本SDK。
  • 项目开发一般要配备一个构件工具,使用构建工具都要书写构建脚本。

C/C++基本要掌握基本语言特性、STL,高级的例如掌握boost库,接着项目开发需要使用make或cmake进行构建。本文讨论make构建C/C++项目,make来自GNU,使用Makefile作为构建脚本。

我以前刚接触C++开发的时候,看到个Makefile,都不知道是啥,而且这脚本内容让人觉得恐惧——恐惧来自无知,Makefile编写并不难,本文带你一起编写Makefile,只要内心,一天就可以掌握Makefile编写了。

构建工具:make和Makefile的必要性是什么?

Makefile示例

之前已经说过很多次了,因为我一直在解决项目开发中的构建问题。任何语言,包括C/C++开发,首先你要编写各种的C/C++源文件,正常来说,你需要将这些源文件逐一地编译成二进制目标代码(一般是.o或.obj文件),然后将这些目标代码链接成可执行文件,或编译成静态库和动态库。

如果你只有几个源文件,这个过程还好。假如是要编译10个源文件,这工作量也不小,而且很可能是你每次编写代码完成都要编译运行一下,非常麻烦!浪费时间,而且有可能哪里出错了,那就更加浪费时间了。如果当前项目不止10个源文件,而是更多,成十个、上百个、上千个……手动编译成了一个麻烦。

构建工具就是为了解决这个问题,它可以帮你一次性完全这些任务,并且每次可以让你即时编写、即时运行项目。省去了很多不必要的工作,让我们可以集中于项目的需求实现。

Make就是这样的构建工具,它使用Makefile作为构建脚本,我们在Makefile中编写项目编译链接指令,使用Make解析Makefile,并执行里面的指令。在Unix/Linux下你可能使用过Make,例如下载第三方程序的源代码进行安装,一般是使用Make进行编译和安装。

流行的C/ C++替代构建系统有SCons、CMake、Bazel和Ninja。一些代码编辑器(如Microsoft Visual Studio)有自己的内置构建工具。对于Java,有Ant、Maven和Gradle。其他语言如Go和Rust也有自己的构建工具。

像Python、Ruby和Javascript这样的解释型语言不需要类似makefile的构建。makefile的目标是编译任何需要编译的文件,基于已经更改的文件。但当解释语言中的文件发生变化时,不需要重新编译任何文件。当程序运行时,使用文件的最新版本。

编译器的选择

Make是GNU编译工具链的一部分,但是Makefile并不一定只能使用GCC编译器,原则上你可以使用所有需要的编译器,只要你当前的平台安装了GNU的Make即可。

目前流行的编译器包括GCC、Clang、LLVM和微软的CL,使用Make构建C/C++项目,都可以使用这些编译器。但是习惯上,只有面向Unix/Linux开发才需要使用Make+Makefile,对于Windows平台的开发则不需要。但这不是一定的,你仍然可以使用Make构建任何平台的项目——支持Make即可,例如windows下的CLion默认使用CMake构建。

Makefile类似shell脚本,所以使用Make构建和平台或编译器无关,它等于将你在命令行输入的命令复制到Makefile中了。

Makefile编写准备

编写Makefile需要使用Make和一个编译器,如果你想在Windows上编写C/C++的Make项目,可以使用MinGW。方便起见,本文直接在Linux下编程,因为对Linux的相关命令比较熟悉。

你需要在命令行中输入gcc/g++ -v查看当前系统中是否安装了GNU编译套件,如果还没安装,那要先安装,安装过程就不说了,下面直接进入Makefile的编写。

GCC编译器选项

常用的GCC编译选项如下:

  • -o <file>:指定输出文件名。
  • -Wall:打印所有编译警告信息。
  • -Werror:在产生警告的地方停止编译,迫使程序员重新修改代码。
  • -g:生成gdb调试器使用的附加符号调试信息。
  • -E:仅预处理文件(如导入头文件、预处理宏),不编译、汇编或链接。一般生成*.i源代码文件,文件包含源文件的完整代码形式。
  • -S:仅编译,不汇编或链接。一般生成*.s文件,文件包含源代码的汇编代码。
  • -c:编译和汇编,不链接。一般生产*.o或*.obj文件,文件包含源代码的二进制字节码。
  • ar rc lib<name>.a *.o:编译静态库。
  • gcc -fPIC -shared -o lib<name>.so:编译动态库。
  • gcc -c -I<dir> -o *.o:使用指定头文件编译(库头文件),-I(大写i)指定编译使用的头文件目录(编译的时候需要库的头文件,链接的时候需要库的实现文件.a或.so,windows下是.lib或.dll)。
  • gcc -L<dir> -l<name>:链接第三方库,包括动态库和静态库,-L指定库文件目录,-l(大写l)指定库文件的名称,类Nnix下前缀lib和后缀.a省略,windows下需要完整名称,如-lapp.lib。
  • -static:强制链接时使用静态库链接。

对于静态库链接,搜索路径的顺序为:

  • Ld会去找GCC命令中的-L参数。
  • 然后找环境变量LIBRARY_PATH中的值。
  • 找默认目录/lib、/usr/lib、/usr/local/lib。

动态库的搜索路径顺序为:

  • 找-L参数指定的目录。
  • 找环境变量LD_LIBRARY_PATH中的路径。
  • 找配置文件/etc/ld.so.conf中指定的路径。
  • 找默认目录/lib和/usr/lib。

C/C++开发一般是将编译和链接分开的,也就是说常用的一个命令是-c,而编译.c/.cpp文件需要指定头文件目录,默认.cpp和.h在同一个目录中,但是如果使用库则不是了,需要使用-l<dir>指定头文件目录。

而链接的时候需要指定二进制文件,包括目标文件和库文件-L<dir>指定目录,-l<name>指定库文件。

下面是GNU相关的常用命令:

  • file <filename>:查看目标文件或可执行文件的类型。
  • nm <filename>:查看二进制文件的符号表。
  • ldd <filename>:查看可执行文件需要的共享库列表。

GNU Make快速入门

C/C++项目结构

C/C++项目虽然没有分包,但是最好有一个清晰的项目结构,什么东西都放到一个目录下可是超级乱。

下面是推荐的C/C++项目结构:

  • src:放置项目源文件,里面可以再建立更多的目录(类似分包)。
  • bin/build:目标文件或可执行文件,例如.o、.obj或.exe文件。
  • include:库头文件,用于编译阶段。
  • lib:库文件,包括静态库和动态库,用于链接阶段。
  • sources:资源文件,例如配置文件、图片文件等。

第一个Makefile示例

首先在src中新建一个main.c源文件,内容如下:

#include <stdio.h>

int main(int count, char **args) {
    printf("hello world!\n");
    return 0;
}

然后在项目根目录新建一个makefile文件,文件名可以是makefile、Makefile或GNUMakefile,内容如下:

all: app

app: app.o
    gcc -o app app.o

app.o: main.c
    gcc -c -o app.o main.c

clean:
    rm app *o

run:
    ./app

在项目根目录中运行make(相当于运行make all):

$ make
gcc -c -o app.o main.c
gcc -o app app.o

运行make run或./app可以运行该程序。

不带参数地运行make会启动makefile中的目标“all”。makefile由一组规则组成。规则由3部分组成:目标、先决条件列表和命令,如下所示:

目标: 条件1 条件2 ...
    命令

其中命令左边空格是一个Tab,目标和先决条件使用冒号:隔开。

当make被要求执行一个规则时,它首先在先决条件中查找文件(一个条件可能对应一个文件)。如果任何先决条件都有关联的规则,则尝试先更新它们。规则的整体执行逻辑为:

Make执行指定的规则,检查条件对应的文件是否存在,如果不存在,则查找对应的规则创建它。例如上面的例子,make会首先执行all目标,app不存在。检查app文件对应的规则,规则app又依赖于app.o;,app.o不存在,接着找到app.o规则,该规则依赖于main.c,该文件存在。接着对比main.c和app.o的更新时间,如果先决条件不比target更新,则不会运行该命令。换句话说,只有当目标与它的先决条件相比过时时,该命令才会运行。

如果你再次运行make,构建不会再次执行,而是提示:make: Nothing to be done for `all'.

由上面你可以发现,每个target相当于一个任务或函数,make target可以执行指定的任务或函数。执行target的命令需要检查:

  • 条件为空,则表示无条件执行命令。
  • 条件对应的文件不存在,则去执行对应新的target。
  • 条件对应的文件存在,则去对比当前target对应的文件和条件对应的文件的更新时间,只有条件比target新才会执行命令。

如果你的项目需要重新构建,那么需要清理一下上一次生成的目标文件。

Target和条件名称一般对应一个文件,但不是必须的,如果不对应一个文件,表示该文件不存在,条件一般是用于提供当前target命令的输入文件。只要一个target提供了给其它target的命令,它的命名不重要,例如下面的例子:

all: app.exe

app.exe: myanme
    gcc -o ./bin/app.exe ./bin/app.o

myanme: main.c
    gcc -c -o ./bin/app.o ./src/main.c

myname不对应一个文件,但是myname对应的target命令能生成app.exe target所需要的文件。

Makefile的更多内容

注释和断行

注释以#开头,一直持续到行尾。长行可以通过反斜杠(\)断行并在几行中继续。

虚假目标(或人为目标)

不代表文件的目标称为虚假目标。例如,上面例子中的“clean”,它只是一个命令的标签。如果目标是一个文件,它将根据其先决条件检查是否过时。“假目标”总是过时的,它的命令将被运行。标准的假目标是:all,clean,install。

变量

变量以$开头,用圆括号(…)或大括号{…}括起来。单字符变量不需要圆括号,例如:$(CC), $(CC_FLAGS), $@, $^。

自动变量

自动变量在匹配规则后由make设置。这包括:

  • $@:目标文件名。
  • $*:没有文件扩展名的目标文件名。
  • $<::第一个条件的文件名。
  • $^:所有文件的先决条件,以空格分隔,丢弃重复的。
  • $+:类似于$^,但包含重复项。
  • $?:所有比目标更新的先决条件的名称,用空格分隔。

我们可以使用变量重写以上的Makefile文件:

# 变量定义
app_name = app
clean_cmd = rm app *.o
COPTIONS = -c -o

# 使用变量: ${variable name}
all: ${app_name}

app: app.o
    gcc -o $@ $<

app.o: main.c
    gcc $(COPTIONS) $@ $^

# 2. 使用变量: $(variable name)
clean:
    $(clean_cmd)

run:
    ./app

逼格是不是高很多了?——达到了让大部分看不懂的程度。

虚拟路径:VPATH & vpath

可以使用VPATH(大写)指定搜索依赖项和目标文件的目录。例如,

VPATH = src include

你还可以使用vpath(小写)来更精确地描述文件类型及其搜索目录。例如,

vpath %.c src
vpath %.h include

模式规则

如果没有显式规则,可以使用模式匹配字符'%'作为文件名的模式规则来创建目标。例如,

%.o: %.c
    $(COMPILE.c) $(OUTPUT_OPTION) $<
 
%: %.o
$(LINK.o) $^ $(LOADLIBES) $(LDLIBS) -o $@

隐式模式规则

Make附带了大量的隐式模式规则,你可以通过make --print-data-base命令列出所有规则。

下面我们继续展开详细讨论。

Makefile基本语法解析

一个Makefile包含一系列的规则(rule),一个规则的语法如下:

targets: prerequisites
    command1
    command2
    command3
    ......

其中:

  • targets是文件名,使用空格分隔,通常一个规则只有一个taregt。
  • command以一个Tab空格开始,命令可以有多个,这些命令是用于完成target的。
  • prerequisites也是文件名,使用空格分隔,这些文件需要存在命令才能运行,这些又称为依赖。

例如下面的例子,该例子包含3个规则,当我们运行make app的时候,make会从app目标开始执行,其执行的详细步骤如下:

  • 我们允许make app,所以make会首先搜索该app target。
  • app target依赖于app.o,所以make去搜索app.o target。
  • app.o target又依赖于app.c,make接着搜索app.c target。
  • app.c没有依赖,所以echo命令无条件运行。
  • 以上命令运行完成后返回app.o target中,因为该target的依赖已经完成了,所以开始执行gcc -c命令。
  • gcc -c命令执行完成后返回顶部的app target中,因为该app的依赖已经完成了,所以开始执行gcc命令。
  • 当app中的命令完成后,即可得到程序app。
app: app.o
    gcc app.o -o app

app.o: app.c
    gcc -c app.c -o app.o

app.c:
    echo "int main() {return 0}" > app.c

多个Target

一个规则中可以有多个target,使用空格分隔,下面是一个例子:

TARGET = app
OBJS = main.o calculator.o
SOURS = main.cpp calculator.cpp
G+ = g++

$(TARGET): $(OBJS)
    $(G+) -o $@ $^

$(OBJS): $(SOURS)
    $(G+) -c -o $@ $*.cpp

clean:
    rm app *.o

Make通配符和模式规则

在make中,*和%都是通配符,其中:

  • *匹配任意字符,*搜索你的文件系统以匹配文件名。建议你总是将它封装在wildcard函数中,例如$(wildcard *.c)。*可用于目标、先决条件、命令或通配符函数中,但不能用于变量中(除非使用wildcard函数),用于变量中会将*作为一个文件名。
  • 对于%,当使用“匹配”模式时,它匹配字符串中的一个或多个字符;在“替换”模式下使用时,它接受匹配的词干并替换字符串中的词干,%最常用于规则定义和一些特定的函数中。

静态规则

下面是一个静态规则:

targets ...: target-pattern: prereq-patterns ...
   commands

这是什么意思?请继续看下面的例子:

objects = foo.o bar.o all.o
all: $(objects)

# 这些文件通过隐式规则编译
foo.o: foo.c
bar.o: bar.c
all.o: all.c

all.c:
    echo "int main() { return 0; }" > all.c

%.c:
    touch $@

clean:
    rm -f *.c *.o all

下面是一个更加神奇的写法(上天了,越来越看不懂了):

objects = foo.o bar.o all.o
all: $(objects)

#和上面的例子的展开写法是类似的
$(objects): %.o: %.c

all.c:
    echo "int main() { return 0; }" > all.c

%.c:
    touch $@

clean:
    rm -f *.c *.o all

这些文件通过隐式规则编译,隐式规则就是GCC帮我们自动做了一些工作,除了最终的那个目标命令不能省略(以上例子是all,引用自别人的代码,虽然还是省略,但是我在CPP中尝试不行,除非all目标的链接命令不省略,展开写法最好换行)。

下面是我的例子:

TARGET = app 
objects = main.o calculator.o
CXX = g++

$(TARGET): $(objects)
    $(CXX) -o $@ $^

#$(objects): %.o: %.cpp

main.o: main.cpp 

calculator.o: calculator.cpp

clean:
    rm app *o

至于为什么会这样的,make在背后做了手脚,我也不知道,不深究,知道这样的写法能够省去编写编译命令即可。

静态规则和过滤器函数

继续看看下面逆天的写法:

obj_files = foo.result bar.o lose.o
src_files = foo.raw bar.c lose.c

all: $(obj_files)

$(filter %.o,$(obj_files)): %.o: %.c
    echo "target: $@ prereq: $<"
$(filter %.result,$(obj_files)): %.result: %.raw
    echo "target: $@ prereq: $<" 

%.c %.raw:
    touch $@

clean:
    rm -f $(src_files)

其中filter是一个函数,函数名和参数使用空格隔开,参数列表使用逗号分隔,你可以猜到,这个函数的意思是:使用第一个参数的匹配模式 从第二个参数的文件集中过滤目标文件,例如第一个filter函数过滤所有.o文件。

隐含规则

也许make中最让人困惑的部分是所创造的魔法规则和变量。以下是一些隐含规则:

  • 编译一个C程序:n.o从n.c中自动生成,命令形式为$(CC) -c $(CPPFLAGS) $(CFLAGS)
  • 编译c++程序:n.o由n.cc或n.cpp自动生成,命令格式为$(CXX) -c $(CPPFLAGS) $(CXXFLAGS)
  • 链接单个对象文件:n自动从n.o生成,通过运行命令$(CC) $(LDFLAGS) n.o $(LOADLIBES) $(LDLIBS)。

因此,隐式规则使用的重要变量是:

  • CC:编译C程序的程序;默认cc
  • CXX:编译c++程序的程序;默认g++
  • CFLAGS:给C编译器的额外标记
  • CXXFLAGS:给c++编译器的额外标记
  • CPPFLAGS:给C预处理器的额外标记
  • LDFLAGS:当编译器应该调用链接器时,提供给编译器的额外标记

下是一个使用示例:

TARGET = app 
objects = main.o calculator.o
CXX = g++ # 隐含规则: 指定编译器
CXXFLAGS = -g # 隐含规则: 指定编译选项

$(TARGET): $(objects)
    $(CXX) -o $@ $^

$(objects): %.o: %.cpp

clean:
    rm app *o

模式规则

定义一个模式规则,将每个.c文件编译成.o文件:

%.o : %.c
        $(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@

模式规则在目标中包含一个'%'。这个'%'匹配任何非空字符串,其他字符匹配它们自己。模式规则先决条件中的' % '代表与目标中的' % '匹配的同一个词干。

下面是另一个例子:

%.c:
   touch $@

双冒号规则

双冒号规则很少使用,但允许为同一个目标定义多个规则。如果这些是单冒号,则会打印警告,并且只运行第二组命令。

all: blah

blah::
    echo "hello"

blah::
    echo "hello again"

clean:
    rm -f $(src_files)

命令和执行

命令响应/静默

在命令前添加@以阻止输出

你还可以使用make -s命令在每一行之前添加一个@

all: 
    @echo "这行将不会被打印"
    echo "但这行会"

Makefile完整配置文件

让我们来看看一个非常有趣的例子,它适用于中型项目。

这个makefile的奇妙之处在于它自动为你确定依赖项。你所要做的就是把你的C/C++文件放到src/文件夹中。

# Thanks to Job Vranish (https://spin.atomicobject.com/2016/08/26/makefile-c-projects/)
TARGET_EXEC := final_program

BUILD_DIR := ./build
SRC_DIRS := ./src

# Find all the C and C++ files we want to compile
SRCS := $(shell find $(SRC_DIRS) -name *.cpp -or -name *.c)

# String substitution for every C/C++ file.
# As an example, hello.cpp turns into ./build/hello.cpp.o
OBJS := $(SRCS:%=$(BUILD_DIR)/%.o)

# String substitution (suffix version without %).
# As an example, ./build/hello.cpp.o turns into ./build/hello.cpp.d
DEPS := $(OBJS:.o=.d)

# Every folder in ./src will need to be passed to GCC so that it can find header files
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
# Add a prefix to INC_DIRS. So moduleA would become -ImoduleA. GCC understands this -I flag
INC_FLAGS := $(addprefix -I,$(INC_DIRS))

# The -MMD and -MP flags together generate Makefiles for us!
# These files will have .d instead of .o as the output.
CPPFLAGS := $(INC_FLAGS) -MMD -MP

# The final build step.
$(BUILD_DIR)/$(TARGET_EXEC): $(OBJS)
    $(CC) $(OBJS) -o $@ $(LDFLAGS)

# Build step for C source
$(BUILD_DIR)/%.c.o: %.c
    mkdir -p $(dir $@)
    $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

# Build step for C++ source
$(BUILD_DIR)/%.cpp.o: %.cpp
    mkdir -p $(dir $@)
    $(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@

.PHONY: clean
clean:
    rm -r $(BUILD_DIR)

# Include the .d makefiles. The - at the front suppresses the errors of missing
# Makefiles. Initially, all the .d files will be missing, and we don't want those
# errors to show up.
-include $(DEPS)

总结

文章虽然写得很长,但是发现后面的部分写得有点乱。关于模式匹配和函数的解释和例子不足,希望下次写文章能够再详细描述清楚。但是本文对于构建一般的C/C++项目已经足够了,例如上面介绍的隐式规则,利用这些隐式规则,可以使用一个精简的Makefile构建一个复杂的项目。

如果要像编程一样编写Makefile还是,Makefile还是蛮复杂的,这简直就是一个make语言的脚本。Makefile非常强大,看来一篇文章介绍是不足够的,接着再写一篇详细理清楚Makefile吧。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: