makefile快速入门

前言

  在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();
};

#endif
foo.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_;
};

#endif
xthread.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目录下。

其它的细节,建议读者跟着做一遍应该可以掌握。

posted @ 2022-02-05 13:24  冷豪  阅读(950)  评论(5编辑  收藏  举报