前言
在linux上开发c/c++代码,基本都会使用make和makefile作为编译工具。我们也可以选择cmake或qmake来代替,不过它们只负责生成makefile,最终用来进行编译的依然是makefile。如果你也是c/c++开发人员,无论你使用什么工具,makefile都是必须掌握的。特别是当你打算编写开源项目的时候,手动编写一个makefile非常重要。本文的目的就是让大家快速了解makefile。
了解makefile
makefile的官方文档[1] 学习makefile的最佳方式就是直接查阅官方说明
一般的makefile文件会包含几个部分:定义变量、目标、依赖、方法段。下面就是一个基础的makefile大概的样子:
1 TARGET=test 2 OBJS=main.o foo.o bar.o 3 CC=gcc 4 5 $(TARGET):$(OBJS) 6 $(CC) $^ -o $@
1-3行定义了变量,第5行冒号前的部分代表目标,表示这部分编译工作的最终目的。冒号后面的部分是目标的依赖,表示要生成这个目标需要哪些预先准备工作。第6行是方法段,代表具体的方法。第5-6行组成了一个编译片段。一个makefile可以包含多个编译片段,方法段也可以有多行。一个编译片段的依赖可以是其他片段的目标,这样当执行make的时候,它就会根据依赖关系处理执行次序。一个makefile文件不能出现重名的目标名,且当你执行make的时候,它会默认执行第一条编译片段,如果第一条编译片段并没有其他依赖,make不会继续向下执行(这一点很重要,后面会有说明)。
除此以外,makefile还可以通过include的方式包含其它makefile文件,因此我们也可以将公共的部分写到一起。在makefile里,我们也可以编写或调用shell脚本。
常见变量和函数介绍
作为学习前的准备,我们先介绍几个常见的概念:
1. 关于makefile的命名
你可以使用全小写或首字母大写的方式来命名,或者你也可以起任何你喜欢的名字,通过make -f的方式来运行。不过我强烈建议你使用makefile或Makefile,并且在所有的项目中保持统一。
2. 声明变量和使用变量
makefile中声明变量的方式是=或:=,使用:=的方式主要是为了处理循环依赖,这个规则可以参考shell脚本。使用变量的方式是$()。除了我们自定义的变量以外,makefile也有预定义的变量。常见的有:
(1) CC: C编译器的名称,默认是cc。通常如果我们是c++程序会改写它
(2) CXX: c++编译器的名称,默认是g++
(3) RM: 删除程序,默认值为rm -f
(4) CFLAGS: c编译器的选项,无默认值
(5) CXXFLAGS: c++编译器的选项,无默认值
(6) $*: 不包含扩展名的目标文件名称
(7) $+: 所有的依赖文件,以空格分开,并以出现的先后顺序,可能包含重复的依赖文件
(8) $<: 第一个依赖文件的名称
(9) $@: 目标文件的完整名称
(10) $^: 所有不重复的依赖文件,以空格分开
(11) MAKE: 就是make命令本身
(12) CURDIR: makefile的当前路径
3. 常见函数方法介绍
函数调用是makefile的一大特点,调用的共同方式是将函数名以及入参放在$()中,函数名和参数之间以[空格]分开,参数之间用[逗号]分开。除了makefile预定义的函数以外,我们还可以编写自己的函数,函数内部使用$(数字)的方式使用参数。
1 define <Funcname> 2 echo $(1) 3 echo $(2) 4 endef
(1) call: 自定函数的调用方式,第一个入参是函数名,后面是函数入参
(2) wildcard: 通配符函数,表示通配某路径下的所有文件,通常我们是将所有*.cpp或*.h文件选择出来单独处理
(3) patsubst: 替换函数,经常和wildcard联合使用,例如将*.cpp全部替换成*.o,后文有详细的使用方法
(4) foreach: 循环函数,会根据空格将字符串分片处理,我们可以用来处理多个目标的编译或多个文件路径的扫描
(5) notdir: 获取到路径的最后一段文件名
(6) strip: 去掉字符串前后的空格
(7) shell: 用于在makefile中执行shell脚本
4. 条件分支
makefile也可以根据条件,选择不同的处理分支。方式如下:
ifeq () else endif 或者 ifndef else endif
条件分支在我的日常开发中不建议使用,因为很容易让makefile变得晦涩难读。毕竟是做编译用的工具,为了方便维护还是不要弄的太复杂。
5. 关于伪目标
A phony target is one that is not really the name of a file; rather it is just a name for a recipe to be executed when you make an explicit request. There are two reasons to use a phony target: to avoid a conflict with a file of the same name, and to improve performance.
对于伪目标官方提供的解释是这样的: 伪目标不是一个真实存在的文件名,它只表示了一个编译的目标。使用伪目标的意义在于:1,避免makefile中的命名重复;2,提高性能。最常用的伪目标就是clean,为了确保我们声明的目标在makefile路径下不会重现同名的文件。伪目标的编写如下:
clean: $(RM) $(OBJS) $(TARGET) .PHONY:clean
多目录编译和动态库
通常只要我们开发的不是一个demo程序,一个项目都会包含自己的目录结构,某些项目还包含自己的动态库需要在编译时导出。对于多目录的编译,网上的方法很多,这里我只介绍一个我个人比较推荐的方式。所有目录下的源码都在主makefile中编译,如果是动态库目录则单独在动态库所在的目录下编写一个makefile,然后让主目录中的makefile来调用。和编译可执行程序不同,编译动态库有以下三个注意点:
1. LDLIBS=-shard: 告诉编译器,需要生成共享库
2. CXXFLAGS=-fPIC: 这个是C++的编译选项,在将.cpp生成.o文件的时候,由于通常我们使用自动推导,因此我们需要用这个变量指明编译要生成与为位置无关的代码,否则在连接环节会报错
3. 编译目标需要以lib开头.so结尾
一个完整的例子
下面以一个相对完整的例子作为总结,在这个例子中有对源码的编译,也有对动态库的编译和导出,还包含了安装环节。为了方便项目管理,我使用的项目结构如下:
项目
|
-- bin # 可执行程序的所在目录 | -- include # 内部和外部头文件的所在目录。开发初期,这里只会保存外部依赖的头文件,项目内部的头文件是在编译后自动复制进去的,目的是方便在安装换环节统一处理 | -- lib # 动态库所在目录。和include一样,开发初期只包含依赖的动态库,项目内部的动态库是在编译后复制进去的 | -- src # 源码目录
项目源码如下,你可以直接复制并根据文件头部注释中的路径来生成
./foo/foo.h 和 ./foo/foo.cpp
// ./foo/foo.h #ifndef FOO_H_ #define FOO_H_ class Foo { public: explicit Foo(); }; #endiffoo.h
#include "foo.h" #include <iostream> using namespace std; Foo::Foo() { cout << "Create Foo" << endl; }foo.cpp
./xthread/xthread.h和./xthread/xthread.cpp
// ./xthread/xthread.h #ifndef XTHREAD_H #define XTHREAD_H #include <thread> class XThread { public: virtual void Start(); virtual void Wait(); private: virtual void Main() = 0; std::thread th_; }; #endifxthread.h
#include "xthread.h" #include <iostream> using namespace std; void XThread::Start() { cout << "Start XThread" << endl; th_ = std::thread(&XThread::Main, this); } void XThread::Wait() { cout << "Wait XThread Start..." << endl; th_.join(); cout << "Wait XThread End..." << endl; }xthread.cpp
./main.cpp
// ./main.cpp #include <iostream> #include "foo/foo.h" #include "xthread.h" using namespace std; class XTask : public XThread { public: void Main() override { cout << "XTask main start..." << endl; this_thread::sleep_for(chrono::seconds(3)); cout << "XTask main end..." << endl; } }; int main(int argc, char *argv[]) { cout << "hello" << endl; Foo foo; XTask task; task.Start(); task.Wait(); return 0; }main.cpp
main和foo只进行源码编译,xthread是动态库。在编译顺序上,需要先编译xthread并将头文件和动态库文件分别导出到include和lib下,再编译源码。最后执行make install,将所有动态库拷贝至/usr/lib目录,可执行文件拷贝至/usr/bin目录。如果你的动态库还需要给其它项目使用,你还需要将它的头文件拷贝到/usr/include目录下。
根据上面介绍的方法,我们首先编写xthread所在的makefile:
# ./xthread/makefile
TARGET=libxthread.so LDLIBS:=-shared CXXFLAGS:=-std=c++11 -fPIC SRCS:=$(wildcard *.cpp) HEADS:=$(wildcard *.h) OBJS:=$(patsubst %.cpp,%.o,$(SRCS)) $(TARGET):$(OBJS) $(CXX) $(LDFLAGS) $^ -o $@ $(LDLIBS) install:$(TARGET) cp $(TARGET) ../../lib cp $(HEADS) ../../include clean: $(RM) $(OBJS) $(TARGET) .PHONY:clean install
这一步完成以后,makefile可以单独执行。执行make install会先执行$(TARGET)所在的编译片段。
编写主目录下的makefile,并可以通过主目录下的makefile控制xthread的编译执行:
# ./makefile TARGET=hello SRC_PATH=$(CURDIR) $(CURDIR)/foo SRCS=$(foreach dir,$(SRC_PATH),$(wildcard $(dir)/*.cpp)) OBJS=$(patsubst %.cpp,%.o,$(SRCS)) CXXFLAGS=-std=c++11 -I../include LDFLAGS=-L../lib LDLIBS=-lpthread -lxthread CC=$(CXX) INSTALL_DIR=/usr $(TARGET):$(OBJS) depends $(CC) $(LDFLAGS) $(OBJS) -o $@ $(LDLIBS) @cp $(TARGET) ../bin depends: $(MAKE) install -C $(CURDIR)/xthread -f makefile install:$(TARGET) cp ../bin/$(TARGET) $(INSTALL_DIR)/bin cp ../lib/*.so $(INSTALL_DIR)/lib clean: $(RM) $(OBJS) $(TARGET) $(MAKE) clean -C $(CURDIR)/xthread .PHONY: clean install depends
主目录的$(TARGET)有一个depends,属于伪目标,会被预先执行。CXXFLAGS表明了编译需要的外部头文件的搜索目录,LDFLAGS表明了外部依赖库的搜索目录,LDLIBS说明编译过程具体需要哪些动态库。并且会将编译的可执行文件复制到../bin目录下。
其它的细节,建议读者跟着做一遍应该可以掌握。