相关文章推荐
耍酷的爆米花  ·  SpringCloud ...·  10 月前    · 
豪爽的萝卜  ·  IBM i Access for Web 介绍·  1 年前    · 
·  阅读

对于现代程序员来说,现在以及未来,提升开发效率比以往任何时候都更加有意义。这主要是由于不断涌现的新技术、新工具在帮助我们解决问题的同时也将我们的时间拆分成了很多时间碎片。而变得高效的底层逻辑就是要减少时间碎片。比如写好代码之后不用切换到命令行运行docker build就能直接在当前窗口实现一键部署。而解决这个问题的办法也很多,比如编写一个shell或者今天要讲的Makefile都是提升效率的利器。

而对于Makefile来讲意义远不止如此。比如,对c程序员来说,当所处开发环境只有一个通过终端连接的Linux的时候,Makefile几乎是构建复杂工程唯一的选择,也是项目是否具备工程化的一个重要分水岭。

Makefile逻辑

Makefile就是将一系列的工作流串在一起自动执行,构成Makefile最基本的要素是 目标、依赖、命令 。也就是为了实现目标需要哪些依赖并执行什么样的命令。

target: dependences1 dependences2 ...  
    command1 command2 ...

其中,target表示要生成的目标,dependences表示生成target需要的依赖,而command就是生成target要执行什么命令。在格式上,命令所在行行首都有一个<tab>。

比如对于c语言来讲,生成.o文件需要.c源文件,而生成目标二进制文件又需要.o文件。

test: test.o  
    gcc -o test test.o
test.o: test.c  
    gcc -c test.c -o test.o

通过上面的例子我们隐约可以感觉到Makefile的解析过程,有点类似函数的递归调用。总是触及到最里层的规则之后,后面的每一次返回实际上都是依赖了上一次的调用。如下图:

当然,在编写代码的时候target相互之间的顺序有可能是打乱的,这里不要太死板。

Makefile的核心逻辑就是上面这点东西,而Makefile的创建有两种方式。

第一,将文件名命令为"Makefile",然后在Makefile文件所在的目录直接使用make命令就可以自动解析"Makefile"文件的内容。比如下面是我自己的一个c语言项目的Makefile。

第二,任意命名,比如我们使用一个叫makefile_test的文件来编写Makefile内容。在执行make的时候使用-f参数指定文件名。如下:

make -f makefile_test

当然,Makefile还支持引用其它的Makefile,格式如下:

include <filename>

有些时候,我们希望不生成具体的目标文件,只想执行命令,比如在Linux通过源码安装经常会使用make clean来清除安装产生的额外的中间文件,比如:

test: test.o  
    gcc -o test test.o
clean:  
    rm -rf *.o test

按照Makefile的规则clean也是一个目标,但我们不希望生成clean目标文件,就可以使用.PHONY将其声明为伪目标,表示只执行命令,不生成目标文件。例如:

.PHONY: clean
test: test.o  
    gcc -o test test.o
clean:  
    rm -rf *.o test

当一个Makefile有多个目标的时候,可以通过参数来指定要执行哪个目标,比如上面的clean:

make clean

Makefile变量

Makefile也支持变量,使用上和Shell中的变量很相似,比如:

BUILDDIR=./build
build:  
    mkdir -p $(BUILDDIR)

上面声明了一个变量BUILDDIR,然后在build目标中使用$(BUILDDIR)来引用变量。Makefile中变量可以分为三大类:默认变量、自定义变量和自动变量。\

1. 默认变量

默认变量是Makefile的约定,比如:

test:   $(CC) -o test test.c

其中CC就是一个默认变量,在linux下就是编译器cc。其它比较常用的默认变量如下:

关于命令相关的变量

  • AR : 函数库打包程序。默认命令是 ar
  • AS : 汇编语言编译程序。默认命令是 as
  • CC : C语言编译程序。默认命令是 cc
  • CXX : C++语言编译程序。默认命令是 g++
  • CO : 从 RCS文件中扩展文件程序。默认命令是 co
  • CPP : C程序的预处理器(输出是标准输出设备)。默认命令是 $(CC) –E
  • FC : Fortran 和 Ratfor 的编译器和预处理程序。默认命令是 f77
  • GET : 从SCCS文件中扩展文件的程序。默认命令是 get
  • LEX : Lex方法分析器程序(针对于C或Ratfor)。默认命令是 lex
  • PC : Pascal语言编译程序。默认命令是 pc
  • YACC : Yacc文法分析器(针对于C程序)。默认命令是 yacc
  • YACCR : Yacc文法分析器(针对于Ratfor程序)。默认命令是 yacc –r
  • MAKEINFO : 转换Texinfo源文件(.texi)到Info文件程序。默认命令是 makeinfo
  • TEX : 从TeX源文件创建TeX DVI文件的程序。默认命令是 tex
  • TEXI2DVI : 从Texinfo源文件创建TeX DVI 文件的程序。默认命令是 texi2dvi
  • WEAVE : 转换Web到TeX的程序。默认命令是 weave
  • CWEAVE : 转换C Web 到 TeX的程序。默认命令是 cweave
  • TANGLE : 转换Web到Pascal语言的程序。默认命令是 tangle
  • CTANGLE : 转换C Web 到 C。默认命令是 ctangle
  • RM : 删除文件命令。默认命令是 rm –f
  • 关于命令参数的变量

  • ARFLAGS : 函数库打包程序AR命令的参数。默认值是 rv
  • ASFLAGS : 汇编语言编译器参数。(当明显地调用 .s 或 .S 文件时)
  • CFLAGS : C语言编译器参数。
  • CXXFLAGS : C++语言编译器参数。
  • COFLAGS : RCS命令参数。
  • CPPFLAGS : C预处理器参数。( C 和 Fortran 编译器也会用到)。
  • FFLAGS : Fortran语言编译器参数。
  • GFLAGS : SCCS “get”程序参数。
  • LDFLAGS : 链接器参数。(如: ld )
  • LFLAGS : Lex文法分析器参数。
  • PFLAGS : Pascal语言编译器参数。
  • RFLAGS : Ratfor 程序的Fortran 编译器参数。
  • YFLAGS : Yacc文法分析器参数
  • 2. 自定义变量

    前面我们声明的BUILDDIR就是一个自定义变量,要注意的是,如果声明了一个和默认变量一样的变量就会覆盖默认变量,这也给我们提供了一个改变默认规则的入口。

    自定义变量要注意的是赋值方式,在Makefile中有以下几种赋值方式:

  •   延迟赋值,在Makefile运行时才会被赋值
  • :=  立即赋值,立即赋值是在真正运行前就会被赋值
  • ?=  空赋值,如果变量没有设置过才会被赋值
  • +=  追加赋值,可以理解为字符串的加操作
  • 延迟赋值指的是在Makefile运行时再赋值。比如:

    test1=aa
    test2=$(test1)
    test1=bb
        echo $(test2)
    

    上面的Makefile运行结果如下:

    benggee@程序员班吉:~/app/makefile-test$ make
    echo bb
    

    结果有些反直觉,最终结果是bb,而不是aa,这就是Makefile变量的延迟赋值。

    立即赋值和我们的直觉一致,比如上面的例子改成立即赋值如下:

    test1=aa
    test2:=$(test1)
    test1=bb
    

    结果如下:

    benggee@程序员班吉:~/app/makefile-test$ make
    echo aa
    

    这就是立即赋值和延迟赋值的区别。

    空赋值,是指如果变量没有设置的情况下才会赋值,如下:

    test1=aa
    test1?=bb
        echo $(test1)
    

    结果如下:

    benggee@程序员班吉:~/app/makefile-test$ make
    echo aa
    

    空赋值只会在变量没有设置的时候才有效,这在一些场景下非常有用。比如要改一个别人的Makefile,害怕把别人的变量给覆盖掉,就可以使用?=空赋值。要注意,下面的设置成空也表示变量已经设置过了,例如:

    CC?=g++

    上面的空赋值是不会生效的,因为CC已经在前面设置过了,只不过值是空。

    追加赋值,下面通过一个例子一下子就明白了,如下:

    test1=aa
    test1+=cc
        echo $(test1)
    

    结果如下:

    benggee@程序员班吉:~/app/makefile-test$ make
    echo aa cc
    aa cc
    

    3. 自动变量

    Makefile有很多自动变量,这里只介绍几个常用的,分别是<<、

    $< 表示第一个依赖的文件,例如:

    test: test.o test2.o  
        echo $<
    test.o:
    test2.o:
    

    最终结果是test.o,也就是test第一个依赖。

    $^ 表示所有依赖,还是上面的例子,例如:

    test: test.o test2.o  
        echo $^
    test.o:
    test2.o:
    

    最终结果是test.o test2.o,是test全部的依赖。

    $@ 表示目标,上面的例子:

    test: test.o test2.o  
        echo $@
    test.o:
    test2.o:
    

    最终结果是test,也就是Makefile中的test。

    Makefile规则

    在Makefile中有一些约定俗成的规则,正是这些规则的存在可以大大减少Makefile代码长度,这里我只列出了我认为比较重要的四个规则。

    1. 隐含规则

    这里以c语言的规则举例,先来看一段Makefile:

    main: main.o test.o  
        cc -o main main.o test.o
    

    在当前目录下,只有main.c和test.c两个文件,并没有.o文件,上面的Makefile之所以能运行,是因为它的隐含规则。对于c语言来讲,如果有地方依赖.o文件,会自动去寻找相同名称的.c文件,并构建出.o文件。

    当然隐含规则远没有这么简单,比如Makefile还支持多个步骤的隐形规则链,但这里我们只需要了解到这一步,后面可以查看理详细的文档去深入了解。

    2. 通配符

    Makefile中支持*、?、~三个通配符,其意义和shell中的通配符基本一致。比如~表示宿主目录。例如在make clean的时候清除编译中产生的.o中间文件,如下:

    clean:  
        rm -rf *.o
    

    3. 模式匹配

    在Makefile中模式匹配使用%来实现,表示匹配任意多个非空字符,相当于shell中的*。模式匹配有什么用呢?假如现在有非常多的.c源文件要生成目标.o文件,我们可以像下面这样写:

    %.o: %.c  
        cc -c %^ -o $@
    

    上面的意思是将所有.c文件都经过编译器编译生成.o文件,其中示的是所有的依赖,在上面的场景中就是当前目录下所有.c文件。而^表示的是所有的依赖,在上面的场景中就是当前目录下所有.c文件。而

    4. 文件搜索

    在比较大的工程中,程序可能会有特别多的依赖,Makefile默认会在当前目录下搜索依赖,但是绝大多数情况依赖可能分布在多个目录中,Makefile的VPATH变量可以帮助我们解决依赖搜索的问题,比如:

    VPATH=src:../headers
    

    表示Makefile会从src和..headers目录去搜索依赖文件。

    VPATH还支持模式匹配,比如

    VPATH <pattern> <directories>
    

    比如,下面就表示在headers目录找所有.h文件

    vpath %.h headers
    

    还可以通过模式匹配清除搜索目录。注意,这里说的是清除。

    VPATH <pattern>
    

    或者清除所有已设置好的目录。

    VPATH
    

    Makefile条件分支

    Makefile条件分支比较简单,就ifeq和ifneq。比如:

    ifeq ($(ARCH), x86)   
        CC=gcc
        CC=arm....gcc
    endif
    

    这个比较好理解,而ifneq的使用和ifeq几乎是一样的,可以自己试一下。

    Makefile函数

    Makefile提供了很多内置函数,但这里我只讲其中我认为比较重要的4个函数,分别是:

    1. patsubst :  模式匹配与替换

    patsubst的原型如下:

    $(patsubst <pattern>,<replacement>,<text>)
    

    其语义是,在text中寻找符合pattern模式的内容替换成replacement的模式。这个函数非常有用,还是以c语言为例,在没有生成.o文件之前我们可以通过.c格式的原文件替换最终得到一组.o文件名。比如:

    OBJECTS=$(patsubst %.c,%.o, main.c test.c)
    

    最终main.c和test.c会被分别替换成main.o和test.o,然后将结果赋值给变量OBJECTS。

    2. notdir : 去掉路径中的目录

    notdir的原型如下:

    $(notdir <text>)
    

    有时候我们拿到的是一个文件的全路径,但我们只想要文件名,就可以使用notdir函数,比如src/foo.c,我们只想要foo.c,就可以这样写:

    FOO=$(notdir src/foot.c)
    

    3. wildcard : 匹配文件

    如果我们要从一堆文件里面挑出符合条件的那部分就可以使用wildcard,它的原型如下:

    $(wildcard <pattern>)
    

    比如我们想找出所有.h文件

    INCLUDES=$(wildcard *.h)
    

    注意,这里使用的通配符是"*",这里表示在当前目录找到所有.h文件。

    4. foreach : 批量处理

    foreach可以重复相同的逻辑去处理一批数据,它的原型如下:

    $(foreach <var>,<list>,<text>)
    

    比如我们要一次性找到a、b、c三个目录下的所有.c文件,就可以这样写:

    DIRS:=a b c
    FILES=$(foreach dir, $(dirs), $(wildcard $(dir)/*.c))
    

    foreach的参数有三个,我们分别来看一下

  • <var> 表示从<list>中遍历出来的每一项
  • <list> 是被遍历的原数据列表,可以类比c语言中的数组
  • <text> 在text中是可以引用<var>的也可以使用其它函数,<text>就是foreach函数处理之后的结果,如果<text>中有函数就是函数运行之后的结果。
  • 好了,到这里我们所需要的前置知识都有了。下面来通过一个实际项目将上面的知识点串在一起,实现一个相对比较复杂的Makefile。

    接下来通过一个实战项目练练手,x-proxy是我用c语言写的一个基于四层的代理服务,它的目录结构如下:

    benggee@程序员班吉:~/app$ tree x-proxy/
    x-proxy/
    ├── LICENSE
    ├── main.c
    ├── Makefile
    ├── proxy.conf
    ├── README.md
    ├── src
    │   ├── hh.h
    │   ├── log.c
    │   ├── log.h
    │   ├── proxy.c
    │   ├── proxy.h
    │   ├── route.c
    │   ├── route.h
    │   ├── svc.c
    │   ├── svc.h
    │   ├── tcpclient.c
    │   ├── tcpclient.h
    │   ├── tcpserver.c
    │   ├── tcpserver.h
    │   ├── xtime.c
    │   └── xtime.h
    └── test
    

    核心代码和相关的依赖头文件都放到了src目录,在根目录下有入口程序main.c以及Makefile文件等。代码你可以去这里下载:github.com/benggee/x-p…

    我们希望通过Makefile自动编译出一个xproxy二进制程序,需要实现以下的需求:

  • 使用Makefile在根目录创建一个build目录
  • 所有编译的中间文件,比如.o文件都放到build目录
  • 将二进制文件xproxy复制到根目录
  • 执行make clean删除build目录和xproxy二进制文件
  • 下面我们来一步步拆解这个过程。首先,我们定义好公共的变量,如下:

    # 设置编译器
    CC=gcc
    # 设置要生成的目标二进制文件
    TARGET=xproxy
    # 设置build目录
    BUILDDIR=build
    # 设置.c文件搜索目录,注意main.c在根目录,所以根目录也要设置进去
    SRCDIR=src .
    # 设置头文件include目录
    INCLUDEDIR=src
    # 设置编译选项,告诉编译器头文件搜索目录
    CFLAGS==$(patsubst %,-I%, $(INCLUDEDIR))
    

    其中CFLAGS是编译参数,最终得到的参数是-Isrc,表示从src中搜索头文件,其它的没什么特别要说明的,接下来我们要找出所有.c源文件,作为生成.o文件的依赖。如下:

    SOURCES=$(foreach dir, $(SRCDIR), $(windcard $(dir)/*.c))
    

    上面表示从SRCDIR目录找出所有.c文件并加上目录,比如log.c最终会被修改成src/log.c。

    然后我们还要拿到所有头文件,代码如下:

    INCLUDES=$(foreach dir$(INCLUDEDIR), $(wildcard $(dir)/*.h))
    

    这里的代码和获取.c文件是一样的逻辑。

    接着我们要找到创建xproxy二进制文件所依赖的所有.o文件,代码如下:

    OBJECTS=$(patsubst %.c,$(BUILDDIR)/%.o, %(notdir $(SOURCES)))
    

    我们的需求是所有编译的中间文件都要放在build目录中,而$(BUILDDIR)%.o会将所有.c文件变成build/xxx.c,这样就相当于告诉Makefile的默认规则.o文件是放在build中的。这里面还用到了一个函数notdir,这是因为SOURCES中的文件名都是带了目录名的全路径名,所以要将目录给去掉。

    接着我们要告诉Makefile去哪里找原文件用来生成对应的.o文件。代码如下:

    VPATH=$(SRCDIR)
    

    可以回顾一下VPATH的作用。

    现在就可以正式开始写生成TARGET的规则了,代码如下:

    $(BUILDDIR)/$(TARGET): $(OBJECTS)  
        $(CC) $^ -o $@ cp -r $(BUILDDIR)/xproxy ./
    

    注意上面的技巧,我们要生成的xproxy是要放到build目录中的。

    到这里还差一点,上面只是告诉make程序依赖这些OBJECTS,这个OBJECTS代表的就是build/xxx.o文件。而这些文件目前是还没有的,所以我们需要生成这些文件,代码如下:

    $(BUILDDIR)/%.o:%.c $(INCLUDES)   
        $(CC) $(CFLAGS) -c $< -o $@
    

    这里的代码最终会被翻译成下面这种形式:

    build/log.o xtime.o ...: log.c xtime.c... log.h xtime.h...  
        gcc -Isrc -c log.c xtime.c... -o $@ log.o xtime.o...
    

    到这里其实是有一个问题的,此时build目录还不存在,所以需要先把build目录创建出来。这里有个小技巧,可以使用“|” 符号来让两个依赖都强制满足,我们对上面的代码稍作修改,如下:

    $(BUILDDIR)/%.o:%.c $(INCLUDES) | build  
        $(CC) $(CFLAGS) -c $< -o $@
    build:  
        mkdir -p $(BUILDDIR)
    

    这样就可以在执行命令之前创建好build目录了。

    最后,我们加上make clean,如下:

    clean:  
        rm -rf $(BUILDDIR) xproxy
    

    对于clean和build来讲并不需要生成对应的目录文件,所以我们可以将它们声明为伪目录,如下:

    .PHONY: clean build
    

    下面是最终的代码:

    CC=gcc
    TARGET=xproxy
    BUILDDIR=build
    SRCDIR=src .
    INCLUDEDIR=src
    CFLAGS=$(patsubst %,-I%, $(INCLUDEDIR))
    SOURCES=$(foreach dir$(SRCDIR), $(wildcard $(dir)/*.c))
    INCLUDES=$(foreach dir$(INCLUDEDIR), $(wildcard $(dir)/*.h))
    OBJECST=$(patsubst %.c, $(BUILDDIR)/%.o, $(notdir $(SOURCES)))
    VPATH=$(SRCDIR)
    .PHONY: clean build
    $(BUILDDIR)/$(TARGET)$(OBJECTS)  
        $(CC) $^ -o $@ && cp -rf $(BUILDDIR)/xproxy ./
    $(BUILDDIR)/%.o:%.c $(INCLUDES) | build  
        $(CC) $(CFLAGS) -c $< -o $@
    clean:  
        rm -rf $(BUILDDIR) xproxy
    build:  
        mkdir -p $(BUILDDIR)
    

    到这里,就实现了一个接近生产级别的Makefile,对于这个小项目看起来似乎代码有点多,但是它有非常好的通用性,后期我们在src目录下新增任何.c或者.h文件基本上都不用修改Makefile。

    到这里为止还有非常多的细节没有提到,其实对于任何一门技术,在工作当中都不会用到所有的特性,这里我总结了一个4/6原则,就是一门技术在实际工作中经常被用到的可能只占了它所有内容的4成,剩下的6成很多人在职业生涯中要么基本用不到,要么用到的机会非常小,对于后一种情况我们只需要理解原理,用到的时候去查文档就可以了。

    分类:
    开发工具
    标签: