makefile小白入门

发布时间 2023-03-25 15:53:38作者: liqinglucky

原文地址:https://www.cnblogs.com/liqinglucky/p/makefile.html

回答的问题:

通常的代码工程都包含了大量的文件,如何将这些源文件最后编译成一个整体?

如果想在自己工程里使用开源项目的模块,需要把开源项目的源码文件统统拷贝进自己的代码目录吗?

不同语言的代码如何交叉编译?

GCC编译过程

可执行文件是如何编译出来的。

编译过程链:

Source-code ➤ Preprocessing ➤ Compilation ➤ Assembler ➤ Object file ➤ Linker ➤ Executable

源文件 ➤ 预处理 ➤ 编译 ➤ 汇编 ➤ 中间文件 ➤ 链接 ➤ 可执行文件

不管多么大型复杂的软件本质上就是一个可执行文件,程序的开始也是从一个简单的main函数开始。我们从一个简单的hello world程序开始逐步增加源文件数的复杂度。看看在复杂度增加的过程中,计算机工程又创造了哪些方法去克服这些复杂工作。

gcc

gcc常用参数

-c:预处理,编译,和汇编,生成.o文件。
-o:连接,生成可执行程序。
-Wall:产生尽可能多的警告信息。
-Werror:将所有的警告当成错误进行处理。
-I(大写i):可以看作是include的首字母大写,主要是包含对应的头文件路径。
-L:指定包含库文件的目录路径。
-l (小写L):可以看做是library,指定链接库的名字。例如 -lmath代表的链接库名字为:libmath.so

最简单的单个文件的编译,用gcc编译hello world。

gcc main.c -o app

这里直接生成了可执行文件。但main.c里的代码只是这个程序的一小部分,在编译链接过程将printf这些系统库函数链接到程序里才能使得程序正确执行。虽然这里我们看到的是单个源文件,但因为gcc命令已经隐藏了其他系统库文件的存在。

编译多个文件

大部分情况下,我们需要编译多个源文件。用gcc如何编译出最后可以运行的可执行程序。

比如这里有个main程序是计算两个数的和与差。示例代码:makefile/math/

目录结构:

.
├── add.c
├── sub.c
├── math.h
└── main.c

main函数调用了add函数,add函数的定义在add.c中。我们知道main函数要通过头文件(.h)去找add函数。

flowchart TD subgraph 源文件 a1[main.c] b1[add.c] c1[sub.c] h[math.h] a1--找头文件-->h h--找函数定义-->b1 h--找函数定义-->c1 end

如果用gcc编译这个程序。直接编译main.c会提示add, sub没有定义

# gcc main.c -o app
/usr/bin/ld: /tmp/ccrpC4An.o: in function `main':
main.c:(.text+0x25): undefined reference to `add'
/usr/bin/ld: main.c:(.text+0x47): undefined reference to `sub'
collect2: error: ld returned 1 exit status

原因是gcc不会自动去编译add.c, sub.c里的函数。需要人为先将add.c, sub.c编译好,最后再做链接。

flowchart TD subgraph 编译过程 a1[main.c] a2[main.o] b1[add.c] b2[add.o] c1[sub.c] c2[sub.o] o[app] a1--汇编-->a2--链接math.h-->o b1--汇编-->b2--链接-->o c1--汇编-->c2--链接-->o end

编译链接过程[1]如下

  1. 先将所有代码生成汇编
# gcc -c add.c -o add.o
# gcc -c sub.c -o sub.o
# gcc -c main.c -o main.o
  1. 然后将所有汇编文件链接成一个可执行文件
# gcc add.o sub.o main.o -o app

//这个时候程序就编译成功了
# ./app
add:6
sub:2

不同语言的代码都要生成汇编语言计算机才能执行,想要做不同语言的交叉编译[2]也就可以在最后链接过程完成。

小节:在多个文件编译时,相比单个文件的编译复杂度增加了。而复杂度主要是在如何链接这步,链接时缺少必要的依赖文件都将导致程序编译失败。

gcc允许将上面的步骤简化成

# gcc main.c add.c sub.c -o app

我们还可以延伸出两个问题:

  1. 加法函数(add)和减法函数(sub)是数学算法,规则是固定的。一旦代码实现就不需要再做代码改动了,可不可以将第一次编译出的汇编结果(add.o, sub.o)保存起来。以后只需改变main函数里的输入参数也一样可以用,这样可以减少重复编译数学算法程序的这个步骤。这里就引申出下面讲的代码库文件。

  2. 编译时总是将源码不断重复用gcc命令,可不可以将这些输入规则归纳做成脚本,用正则表达式的方法识别出.c然后生成.o文件。这里就引申出后面讲的构建工具Makefile

将那些经常用到的程序,但又还不是最后可运行的可执行文件的集合成一个库文件。这样使用开源项目的方式就变成先编译出库文件,然后将库文件编译进自己项目。而不需要把源代码文件拷贝增加项目编译难度。

库文件的优点:

  1. 代码模块化。不管库里面多么复杂,引入使用库只需要接口头文件(.h),里面声明了接口函数。
  2. 代码的具体实现对客户保密。客户能看到的就是头文件里的接口函数(API函数)。

静态库

生成静态库的编译过程链与生成可执行文件一样:

flowchart TD subgraph 静态库编译链接 a1[main.c] a2[main.o] b1[add.c] b2[add.o] c1[sub.c] c2[sub.o] o[app] a1--汇编-->a2--链接math.h-->o b1--汇编-->b2--链接-->libmath.a c1--汇编-->c2--链接-->libmath.a --链接-->o end

静态库与汇编生成的目标文件一起链接为可执行文件,那么静态库必定跟.o文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o/.obj文件)的集合,即很多目标文件经过压缩打包后形成的一个文件。

静态库使用举例。示例代码:makefile/math/

  1. 编译生成库包含的汇编文件
# gcc -c add.c -o add.o
# gcc -c sub.c -o sub.o
  1. 将所有汇编文件链接打包成静态库文件
# ar rcs -o libmath.a add.o sub.o

//查看静态库里的文件
# ar t libmath.a 
add.o
sub.o
  1. 将静态库编译进可执行文件
# gcc -o app main.c -L. -lmath

动态库

动态库链接过程[3][4]

生成动态库的编译过程链:

flowchart TD subgraph 动态库编译链接 a1[main.c] a2[main.o] b1[add.c] b2[add.o] c1[sub.c] c2[sub.o] o[app] a1--汇编-->a2--链接math.h-->o b1--汇编-->b2--链接-->libmath.so c1--汇编-->c2--链接-->libmath.so-->o end subgraph 运行 run[./app] end o --> run libmath.so --动态链接--> run

动态库使用举例。示例代码:makefile/math/

  1. 编译生成库包含的汇编文件
# gcc -c add.c -o add.o
# gcc -c sub.c -o sub.o
  1. 将所有汇编文件链接成动态库文件
# gcc -shared -fPIC -o libmath.so add.o sub.o

说明:

  • -shared: 指示编译器创建动态库的目标文件。
  • -fPIC: 指示编译器生成位置无关代码,这样动态库才能加载到任意的内存位置。

ldd命令来观察可执行文件链接了哪些动态库:

//ldd 命令查看动态库
# ldd libmath.so 
        linux-vdso.so.1 (0x00007ffdb21e8000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f71eb155000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f71eb33f000)
  1. 将动态库编译进可执行文件
# gcc -o app main.c -L. -lmath

//等价于命令
# gcc -o app main.c ./libmath.so

现在只是复制了动态库的重定位信息和符号表(Relocation and Symbol table) 到可执行文件中。

如果在动态库文件和静态库文件同时存在,优先使用动态库编译[5]

注意:

如果现在就直接运行可执行文件会报错[6]

# ./app
./app: error while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory

原因:运行时找不到动态库,要设置环境变量指定动态库目录。

  1. 设置环境变量指定动态库目录。

环境变量设置命令格式:{so文件所在路径}/libary:.{so文件所在路径}可以使用绝对路径也可以使用相对路径。

# export LD_LIBRARY_PATH=/makefile/math/libary:.

//查看环境变量
# echo ${LD_LIBRARY_PATH}
/makefile/math/libary:.

# env | grep LD_
  1. 程序加载,动态库加载[3:1]时操作系统的执行过程。

    1. 操作系统加载器先将动态链接器加载到内存中运行。
    2. 然后由动态链接器首先将动态库中的代码和数据重定位到一个内存段。
    3. 接下来重定位可执行文件中由libmath.so定义的符号引用。
    4. 动态链接器把控制权交给应用程序app。从这个时候开始,共享库的位置(内存地址)就固定了,并且在程序执行的过程中都不会改变。
    5. 然后程序app开始执行。
    6. 应用程序还可能在运行时要求动态链接器加载和链接某个动态库。
# ./app
add:6
sub:2

静态库与动态库比较

相同点:静态链接库与动态链接库都是.o文件的集合。

不同点[7][8]

  1. 代码段内存位置不一样

静态链接库会直接编译进可执行文件[9],与可执行文件程序的非函数库的代码编译和执行方式完全一样。所以说静态链接库是编译时就确定了状态。最终加载进内存后也就增加了代码段大小。静态链接库在执行时比动态链接库执行更快。

动态链接库代码段并没有编译进可执行文件。代码段在运行时(runtime)才加载进内存,相对来说执行也会更慢。动态链接库是程序运行后在内存才能确定状态。而且内存位置是单独一块,与可执行文件程序的非函数库的代码内存段不在一起。动态链接库减少了可执行文件的占用容量(footprint),最终也减少了代码段大小。

  1. 占用磁盘大小不一样[10]

如果有多个可执行文件,那么静态链接库中的同一个函数的代码(如:libmath.a里的add)就会被复制多份进每一个可执行文件调用的位置。而动态库的同一个函数代码(如:libmath.so里的add)只有一份。因此使用静态链接库占用的磁盘空间相对比动态链接库要大,会导致空间浪费。

  1. 库内部代码更新的升级方式不一样[10:1]

静态链接库代码更新了,如:libmath.a里的add函数做了修改。所有使用libmath.a的可执行文件都需要重新编译。对程序的更新、部署和发布会带来麻烦。

动态链接库在程序编译时并不会被链接到目标代码中,而是在程序运行是才被载入。动态链接库的代码更新,如:libmath.so里的add函数做了修改。只需要重新编译该动态链接库libmath.so,然后将libmath.so拷贝回原来动态链接库的目录就可以。

头文件和库查找顺序

使用gcc进行编译时,可能会出现找不到相应库或头文件的情况。这就有必要了解库和头文件的查找顺序[11][12]

头文件

gcc在编译时按照如下顺序寻找所需要的头文件:

  1. 先搜索当前目录( 这里注意,只有使用双引号“”包含的include文件如:#include "file.h"时才会搜索当前目录
  2. 接着搜索-I指定的目录
  3. 然后找gcc的环境变量 C_INCLUDE_PATH,CPLUS_INCLUDE_PATH,OBJC_INCLUDE_PATH
  4. 再找内定目录: /usr/include, /usr/local/include
  5. 最后找gcc的一系列自带目录,如:
CPLUS_INCLUDE_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include

库文件

gcc在编译时按照如下顺序寻找所需要的库文件:

  1. gcc会去找-L指定的目录
  2. 再找gcc的环境变量LIBRARY_PATH
  3. 再找内定目录
/lib
/usr/lib
/usr/local/lib

Makefile[13][14]

我们已经了解了使用gcc将源码编译成可执行文件的过程。但是如果使用gcc命令去编译一个源文件数量很多的工程时任然很困难。因为一个工程中的源文件不计其数,并且按类型、 功能、模块分别放在若干个目录中,源文件间可能存在依赖关系,编译链接顺序不对都会导致编译失败。如何将这些代码编译构建成一个软件。这就需要用到Makefile将代码文件组织起来。

Makefile指定了整个工程的编译规则。哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。Makefile就像一个Shell脚本一样,其中也可以执行操作系统的命令。Makefile的好处就是“自动化编译”,一旦写好,只需要一个make命令,整个工程完全 自动编译,极大的提高了软件开发的效率。

make是一个命令工具,是一个解释Makefile中 指令的命令工具,一般来说,大多数的IDE都有这个命令,比如:Delphi的make,Visual C++的nmake,Linux下GNU的make。Makefile都成为了一种在工程方面的编译方法。

规则

在Makefile中,规则的顺序是很重要的,因为,Makefile中只应该有一个最终目标,其它的目标 都是被这个目标所连带出来的,所以一定要让make知道你的最终目标是什么。一般来说,定义在Makefile中的目标可能会有很多,但是第一条规则中的目标将被确立为最终的目标。如果第 一条规则中的目标有很多个,那么,第一个目标会成为最终的目标。make所完成的也就是这个目标。

makefile的核心规则语法[15]

规则包含两个部分,一个是依赖关系,一个是生成目标的方法。

target ... : prerequisites ...
	command
	...
	...

说明:

  • target: 目标。可以是一个可执行文件。

  • prerequistite: 生成target所依赖的文件

  • command: 生成target要执行的命令。

target依赖于prerequisite,当prerequisite发生变化时,依赖的时间戳比目标的时间戳新时则执行command。

prerequisites中如果有一个以上的文件比target文件要新的话,command所定义的命令就会被执行。

用Makefile将gcc的编译过程重写如下:示例代码:makefile/math/

Makefile文件

app : main.o add.o sub.o
	gcc main.o add.o sub.o -o app

add.o : add.c
	gcc -c add.c -o add.o

sub.o : sub.c
	gcc -c sub.c -o sub.o

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

编译与运行

# make
gcc -c main.c -o main.o
gcc -c add.c -o add.o
gcc -c sub.c -o sub.o
gcc main.o add.o sub.o -o app

# ./app
add:6
sub:2

由此可见Makefile可以简单理解为将gcc的命令写到一个脚本文件里。make命令就是gcc命令的嵌套。

从编译时的打印可以推测makefile的语句执行顺序:makefile是递归式的,一层一层往下找。

  1. 先从上往下读入文件语句。首先执行的语句是生成目标(target): app,但是app的依赖(prerequisite): main.o add.o sub.o还没有生成。
  2. 首先执行生成第一个子目标(target): main.o,由于依赖(prerequisite): main.c是现成的源代码文件,于是执行相应的命令(command)。
  3. 然后回到父目标(target): app的执行,但此时还缺add.o sub.o的依赖。继续跳转到执行生成子目标(target): add.o sub.o相应的命令(command)。
  4. 子目标执行完毕后,回到父目标(target): app的执行。现在依赖(prerequisite)都已存在,于是执行命令(command)生成可执行文件app。

隐含规则通配符

make支持三个通配符(wildcard): * , ? 和 ~

*.c: 表示所有的.c文件

波浪号( ~ )字符在文件名中也有比较特殊的用途。如果是 ~/test ,这就表示当前用户
$HOME目录下的test目录。

命令

每条规则中的命令和操作系统Shell的命令行是一致的。make会一按顺序一条一条的执行命令, 每条命令的开头一定要使用Tab键而不是空格键,除非,命令是紧跟在依赖规则后面的分号后的。在命令行 之间中的空格或是空行会被忽略,但是如果该空格或空行是以Tab键开头的,那么make会认为 其是一个空命令。

嵌套执行make

在一些大的工程中,不同模块或是不同功能的源文件放在不同的目录中,我们可以在每个目录中都书写一个该目录的Makefile,这有利于让我们的Makefile变得更加地简洁,而 不至于把所有的东西全部写在一个Makefile中,这样会很难维护我们的Makefile,这个技术对于 我们模块编译和分段编译有着非常大的好处。

subsystem:
	cd subdir && $(MAKE)

变量

在Makefile中的定义的变量,就像是C/C++语言中的宏一样,他代表了一个文本字串, 在Makefile中执行的时候其会自动原模原样地展开在所使用的地方。其与C/C++所不同的是, 你可以在Makefile中改变其值。在Makefile中,变量可以使用在“目标”,“依赖目标”, “命令”或 是Makefile的其它部分中。

变量定义语法:

//变量的定义,等号右边为被变量替换的对象

var = obj

//变量的追加

var += obj

//常量

CC := gcc

变量的使用可以按照等价替换的原理去对makefile里的内容进行替换。示例代码:makefile/math/var.mk

TAR = app
Obj = main.o add.o sub.o
CC := gcc

自动变量

$^: 构造所需文件列表所有所有文件的名字

$@: 目标的文件名

$<: 所有的依赖文件的第一个文件

示例代码:makefile/math/autovar.mk

$(TAR) : $(Obj)
	$(CC) $^ -o $@

%.o:%.c
	$(CC) -c $^ -o $@

目录VPATH

告诉make到不同的目录去查找源文件。

VPATH变量的内容是一份目录列表,供make搜索其所需要的文件。

可以这样理解,把src的值赋值给变量 VPATH, 执行make后去VPATH的所有目录下搜索文件

VPATH = src 

示例代码:makefile/vpath/

目录结构:

.
├── include
│   └── math.h
├── lib
│   ├── add.c
│   └── sub.c
├── main.c
└── Makefile

不使用VPATH指定源文件目录的makefile

编译时默认是当前目录的文件。如果不是当前目录则要在makefile里写相对当前目录的路径。

app : main.o add.o sub.o
	gcc main.o add.o sub.o -o app

add.o : lib/add.c
	gcc -c lib/add.c -o add.o

sub.o : lib/sub.c
	gcc -c lib/sub.c -o sub.o

main.o : main.c
	gcc -I include -c main.c -o main.o

注意:

  1. 未写明源文件目录
# make
gcc -I include -c main.c -o main.o
make: *** No rule to make target 'add.c', needed by 'add.o'.  Stop.

解决方法:
改为详细路径:lib/add.c

  1. 找不到头文件
# make
gcc -c main.c -o main.o
main.c: In function ‘main’:
main.c:7:23: warning: implicit declaration of function ‘add’ [-Wimplicit-function-declaration]
    7 |   printf("add:%d \n", add(a,b));
      |                       ^~~
main.c:8:23: warning: implicit declaration of function ‘sub’ [-Wimplicit-function-declaration]
    8 |   printf("sub:%d \n", sub(a,b));
      |                       ^~~
gcc -c lib/add.c -o add.o
gcc -c lib/sub.c -o sub.o
gcc main.o add.o sub.o -o app

解决方法:添加头文件的目录

gcc -I include -c main.c -o main.o

使用VPATH指定源文件目录的makefile

VPATH的作用将不同目录的源码文件在逻辑上变成了同一个目录的文件。make会遍历所有VPATH目录下的文件。示例代码:makefile/vpath/vpath.mk

CFLAGS = -I include
VPATH := lib include

编译

# make -f vpath.mk
gcc -I include -c main.c -o main.o
print: main.c main.o
gcc -I include -c lib/add.c -o add.o
print: lib/add.c add.o
gcc -I include -c lib/sub.c -o sub.o
print: lib/sub.c sub.o
gcc main.o add.o sub.o -o app
print: main.o add.o sub.o app

make是如何工作的[16]

make命令执行时,先找当前目录的makefile文件。然后makefile告诉make命令需要怎么样的去编译和链接程序。make执行过程:

  1. 读入所有的Makefile。

  2. 读入被include 的其他 Makefile。

  3. 初始化文件中的变量。

  4. 推导隐晦规则,并分析所有规则。

  5. 为所有目标文件创建依赖关系链。

  6. 根据依赖关系,决定哪些目标要重新生成。

  7. 执行生成命令。

指定Makefile

# make -f file.mk

参考

附录

示例代码目录结构

1 makefile/math/

makefile/
└── math/
    ├── add.c
    ├── sub.c
    ├── math.h
    ├── main.c
    ├── Makefile
    ├── autovar.mk
    └── var.mk

2 makefile/vpath/

makefile/
└── vpath/
    ├── include/
    │   └── math.h
    ├── lib/
    │   ├── add.c
    │   └── sub.c
    ├── main.c
    ├── Makefile
    └── vpath.mk

源码C文件

main.c

#include <stdio.h>
#include "math.h"

int main(void)
{
  int a = 4, b = 2;
  printf("add:%d \n", add(a,b));
  printf("sub:%d \n", sub(a,b));
  return 0;
}

add.c

#include <stdio.h>

int add(int a, int b)
{
  return a+b;
}

sub.c

#include <stdio.h>

int sub(int a, int b)
{
  return a-b;
}

math.h

#ifndef _MATH_H_
#define _MATH_H_

int add(int a, int b);
int sub(int a, int b);

#endif

源码Makefile文件

1 makefile/math/Makefile

app : main.o add.o sub.o
        gcc main.o add.o sub.o -o app

add.o : add.c
        gcc -c add.c -o add.o

sub.o : sub.c
        gcc -c sub.c -o sub.o

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

.PHONY:
cleanall:
        rm -rf main.o add.o sub.o app
clean:
        rm -rf main.o add.o sub.o

2 makefile/math/var.mk

TAR = app
Obj = main.o add.o sub.o
CC := gcc

$(TAR) : $(Obj)
        $(CC) $(Obj) -o app

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

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

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

.PHONY:
cleanall:
        rm -rf $(Obj) $(TAR)
clean:
        rm -rf $(Obj)

3 makefile/math/autovar.mk

TAR = app
Obj = main.o add.o sub.o
CC := gcc

$(TAR) : $(Obj)
        $(CC) $^ -o $@
        @echo "print:" "$^" "$@"

%.o:%.c
        $(CC) -c $^ -o $@
        @echo "print:" "$^" "$@"

.PHONY:
cleanall:
        rm -rf $(Obj) $(TAR)
clean:
        rm -rf $(Obj)

4 makefile/vpath/Makefile

app : main.o add.o sub.o
        gcc -I include main.o add.o sub.o -o app

add.o : lib/add.c
        gcc -c lib/add.c -o add.o

sub.o : lib/sub.c
        gcc -c lib/sub.c -o sub.o

main.o : main.c
        gcc -I include -c main.c -o main.o

.PHONY:
cleanall:
        rm -rf main.o add.o sub.o app
clean:
        rm -rf main.o add.o sub.o

5 makefile/vpath/vpath.mk

TAR = app
Obj = main.o add.o sub.o
CC := gcc

CFLAGS = -I include
VPATH := lib include

$(TAR) : $(Obj)
        $(CC) $^ -o $@
        @echo "print:" "$^" "$@"

%.o:%.c
        $(CC) $(CFLAGS) -c $^ -o $@
        @echo "print:" "$^" "$@"

.PHONY:
cleanall:
        rm -rf $(Obj) $(TAR)
clean:
        rm -rf $(Obj)

  1. 隐藏的细节:编译与链接 ↩︎

  2. c cc混合编译 ↩︎

  3. CSAPP-深入理解计算机系统7-8. 动态链接共享库 ↩︎ ↩︎

  4. 最后一步,让程序运行起来!(链接) ↩︎

  5. C/C++静态库与动态库的制作和使用 ↩︎

  6. gdb设置断点出现Cannot access memory at address的错误 ↩︎

  7. 静态库和动态库区别 ↩︎

  8. 动态链接库与静态链接库有什么区别 ↩︎

  9. linux中的两种共享代码方式静态库和动态库 ↩︎

  10. 静态库和动态库的区别 ↩︎ ↩︎

  11. Linux 编译运行查找头文件和库的顺序 - 煊奕 - 博客园 (cnblogs.com) ↩︎

  12. include包含文件查找的顺序 - 梦想Sky - 博客园 (cnblogs.com) ↩︎

  13. how-to-write-makefile.pdf ↩︎

  14. GNU+Make项目管理(第三版).pdf ↩︎

  15. Makefile入门教程 (qq.com) ↩︎

  16. configure、 make、 make install 背后的原理 ↩︎