你在使用Makefile管理工程么?

221 阅读11分钟

大家好!😄

感谢大家的时间来阅读此文,如果您对以下内容感兴趣,欢迎关注我的公众号《叨叨叨的成长记录》,这里你可以收获以下内容:

  1. 专业的IT内容分享
  2. 前沿LLM技术和论文分享
  3. 个人对行业的思考
  4. 投资理财的经验和笔记

如果您也对这些感兴趣,欢迎在后台留言,大家多多交流!


Makefile的发展历史

Makefile 是一种自动化构建工具的描述文件,通常与 `make` 命令一起使用。它的历史可以追溯到20世纪70年代和80年代,以下是Makefile的发展历程的主要里程碑:

早期的Unix系统(1970s)

  • 背景:在早期软件开发中,构建和管理代码的过程常常是手动的,开发者需要逐一编译源代码并链接生成可执行文件。这一过程既繁琐又易出错。
  • 解决方案:随着Unix操作系统的发展,出现了自动化构建工具的概念。开发者开始构建脚本或简单程序来自动化标准的构建过程。

Make的引入(1976)

  • 贡献:Make工具最初由斯图尔特·弗里德曼(Stuart Feldman)在1976年为Unix系统开发。它可以自动化构建过程,使用清单文件描述如何构建目标文件。
  • 工作原理:Make使用规则、依赖关系和命令来描述如何从源文件生成目标,如编译源代码和创建库文件。

Makefile的标准化

  • 描述:随着Make的推广,多数开发者开始使用Makefile来定义项目的构建规则。Makefile使用特定的语法来指定源文件、目标文件和构建命令。
  • 特性:Makefile中可以包含宏、条件判断、模式匹配等,这使得其在复杂项目中更具灵活性。

GNU Make(1980s)

  • 贡献:GNU项目于1985年启动,GNU Make工具成为开源社区的一部分,加入了一些新的特性,如更好的调试支持、交叉平台支持和增强的功能。
  • 优势:GNU Make扩展了原有Make的功能,使其能够更好地处理大型项目并支持更多的编程语言。

跨平台和现代理念

  • 描述:尽管Make和Makefile在Unix/Linux系统中广泛使用,但随着软件开发的复杂化,出现了更高级的构建工具(如CMake、Bazaar和Gradle),这些工具在某些方面提供了更丰富的功能。
  • 未来:尽管如此,Makefile依然在许多项目中使用,尤其是在系统软件和嵌入式系统的构建过程中。

历史影响

  • 工具链基础:Makefile对编译系统和自动化构建工具的影响广泛深远,许多现代构建系统(如CMake、Meson等)都借鉴了其思想。
  • 社区和后续改进:Makefile的语法和功能不断得到改进,并在许多不同的环境中被使用,如C/C++、Python、Go等。

打开电脑上的终端,我们使用 man make 这个命令,可以看下对应的内容。这里我们可以看到是关键词GNU Make?GNU是什么?make用来干啥的?

浅浅的了解下GNU

GNU 项目(GNU Project)是一个自由软件项目,旨在创建一个完全自由的操作系统。以下是关于 GNU 项目的一些关键信息:

项目起源

  • 创始人:GNU 项目由理查德·斯托曼(Richard Stallman)在 1983 年发起,目的是提供一个可以替代昂贵和封闭的 Unix 操作系统的自由软件版本。
  • GNU 的含义:GNU 是 “GNU's Not Unix” 的递归缩写,强调与 Unix 系统的相似性,同时又是不受专利和限制的自由软件。

主要目标

  • 自由软件:GNU 项目致力于开发和分发自由软件,使用户拥有使用、复制、分发和修改软件的自由。
  • 完全自由操作系统:项目的终极目标是创建一个完全自由的操作系统,包括核心组件、应用程序和开发工具。

核心组件

  • GNU 工具和实用程序:GNU 开发了许多基础工具和实用程序,如编译器(GCC)、文本编辑器(Emacs)、程序调试器(GDB)等。
  • GNU 操作系统:虽然 GNU 项目的核心组件已经相对完善,但它与许多自由开启内核(如 Linux)结合使用,形成了 GNU/Linux 操作系统,这也是目前最广泛使用的自由操作系统。

许可证

  • GNU 通用公共许可证(GPL):GNU 项目开发的软件通常使用 GPL 许可证,该许可证确保用户自由使用、分发和修改软件,同时确保派生作品也保持相同的自由属性。

资源与文档

  • GNU 官网:GNU 项目的官方网站(gnu.org)提供了各种软件的下载、文档、以及有关自由软件的教育信息。

现状与发展

  • 持续更新:尽管 GNU 项目已有数十年的历史,它仍在不断更新和扩展,继续开发新工具和部分,以支持现代计算需求。

我们今天要了解的 makefile 也是GNU的一个产物。

由浅入深了解Makefile

GNU Make 是 GNU 项目的一部分,是一个用于自动化构建过程的工具。

概览

1. 基本概念

  • Make 工具:GNU Make 是一个自动化构建工具,负责根据 Makefile 中定义的规则来编译和构建项目。
  • 目标:其主要目标是减少软件开发中的重复劳动,提高开发效率和精确性。

2. 主要特点

  • 自动化构建:能够根据文件的时间戳和依赖关系来自动决定哪些文件需要重新编译。
  • 变量和宏:支持在 Makefile 中定义变量,使构建文件更易于管理和扩展。
  • 条件判断:支持条件语句,允许根据不同的环境或参数选择性地执行某些命令。
  • 模式规则:可以定义模式匹配的规则,使得处理相似文件(如多个源文件)变得更加简单。

3. 历史背景

  • 开发起源:GNU Make 最初是由斯图尔特·弗里德曼(Stuart Feldman)在1976年开发的,GNU Make 是 GNU 项目的一个便捷版,首次发布于1985年。
  • 开源:作为GNU 项目的一部分,GNU Make 是开源软件,遵循GNU通用公共许可证(GPL)。

4. 使用场景

  • C/C++项目:最常用于 C/C++ 等语言的编译和链接过程。
  • 其他编程语言:虽然最初是为 C/C++ 设计的,但 GNU Make 也可以用于其他编程语言和脚本的构建。

5. 安装和使用

  • 跨平台:可在多种操作系统上使用,包括 Linux、macOS 和 Windows(通过 Cygwin 或 MinGW 进行)。
  • 基本命令:使用 make 命令执行 Makefile 中定义的任务,指定目标(如 make target)。

6. 社区和文档

  • 文档支持:GNU Make 具有详细的文档,包括用户手册、参考手册,以及在线文档(GNU Make Manual)。
  • 社区:作为一个开源项目,GNU Make 拥有活跃的开发和用户社区,支持用户通过邮件列表和论坛进行交流。

7. 优势和局限性

  • 优势:广泛的支持、强大的功能、灵活的语法和大型项目的处理能力。
  • 局限性:对于初学者来说,Makefile 的语法可能难以掌握;在复杂项目中,Makefile 可能会变得难以维护。

Makefile Rules

在makefile中,最基础的元素是 Rule。我们一起来看下官方对于rule的一些定义和描述,这部分内容是来自GNU的官网中。

编写Makefile文件

简单C工程构件

main.c文件内容
#include <stdio.h>
#include "utils.h" // 假设 utils.h 包含了 utils.c 的声明

int main() {
    printf("Hello, World!\n");
    greet("User"); // 调用 utils.c 中的函数
    return 0;
}

utils.h文件内容

#ifndef UTILS_H
#define UTILS_H

void greet(const char *name);

#endif // UTILS_H

utils.c文件内容

#include <stdio.h>

void greet(const char *name) {
    printf("Hello, %s!\n", name);
}

makefile文件内容

# 变量定义
CC = gcc
CFLAGS = -g -Wall

# 目标定义
all: program

program: main.o utils.o
    $(CC) -o program main.o utils.o

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

utils.o: utils.c
$(CC) $(CFLAGS) -c utils.c

clean:
    rm -f program *.o

在终端中执行make命令后,得到的内容如下

追加多个C文件

为了后续学习一些makefile的用法,我们将这个工程在多追加几个文件 a.x, b.x, c.x, 同时更新下对应的makefile文件。
# 变量定义
CC = gcc
CFLAGS = -g -Wall

# 目标定义
all: program

program: main.o utils.o a.o b.o c.o
	$(CC) -o program main.o utils.o a.o b.o c.o

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

utils.o: utils.c
	$(CC) $(CFLAGS) -c utils.c

a.o: a.c a.h
	$(CC) $(CFLAGS) -c a.c

b.o: b.c b.h
	$(CC) $(CFLAGS) -c b.c

c.o: c.c c.h
	$(CC) $(CFLAGS) -c c.c

clean:
	rm -f program *.o

重新编写makefile

我们可以通过使用模式规则、自动变量和简单的变量来精简 `Makefile`
# 变量定义
CC = gcc
CFLAGS = -g -Wall
SRCS = main.c utils.c a.c b.c c.c
OBJS = $(SRCS:.c=.o)

# 目标定义
all: program

program: $(OBJS)
	$(CC) -o $@ $^

# 通用规则
%.o: %.c
	$(CC) $(CFLAGS) -c $<

clean:
	rm -f program $(OBJS)

对上述文件中的几个核心的改写进行说明:

对上述文件中的几个核心的改写进行说明:

  1. 变量定义
  • SRCS 表示所有源文件的列表。
  • OBJS 使用模式替换语法,将 .c 文件名转换为 .o
  1. 目标定义
  • program: $(OBJS) 表示生成目标 program 依赖于所有对象文件。
  1. 通用规则
  • 使用 %.o: %.c 规则,make 会为每个 .c 文件生成对应的 .o 文件。$< 表示第一个依赖文件,$@ 表示目标文件,$^ 表示所有的依赖文件。
  1. 清理
  • clean 目标用于删除程序和生成的对象文件。

让工程更加规范些

在我们要进行一些稍微复杂点的工程的开发时,工程师会通过目录来管理工程代码,让 include 文件和 source 文件分开,同时将构建过程中生成的二进制文件单独保存,这样会让工程管理起来更加规范。接下来我们调整下工程目录,一起来探索下这部分的代码如何来编写。

# 变量定义
CC = gcc
CFLAGS = -g -Wall
SRC_DIR = src
INCLUDE_DIR = include
BUILD_DIR = build
SRCS = $(wildcard $(SRC_DIR)/*.c)
OBJS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))

# 目标定义
all: $(BUILD_DIR) program

# 创建 build 目录
$(BUILD_DIR):
	mkdir -p $(BUILD_DIR)

program: $(OBJS)
	$(CC) -o $@ $^

# 通用规则
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -I$(INCLUDE_DIR) -c $< -o $@

clean:
	rm -rf program $(BUILD_DIR)/*

wildcardpatsubst 是 GNU Make 中的两个内置函数,它们用于处理文件名和文本。以下是它们的详细说明和参数说明:

wildcard

功能wildcard 函数用于获取匹配特定模式的文件列表。

语法

wildcard(pattern)

参数

  • pattern:一个字符模式,用于匹配文件名。例如,可以使用 *.c 来匹配当前目录中的所有 .c 文件。

示例

SRCS = $(wildcard src/*.c)

在这个例子中,wildcard 会返回 src 目录下所有以 .c 结尾的文件名,并将它们赋值给 SRCS 变量。

patsubst

功能patsubst 函数用于按照模式替换文本中的部分字符串。

语法

patsubst(pattern,replacement,text)

参数

  • pattern:要匹配的模式,可以包含通配符,例如 % 表示任意字符。
  • replacement:用于替代匹配到的部分的字符串。
  • text:要处理的文本,可以是变量或字面值。

示例

OBJS = $(patsubst src/%.c,build/%.o,$(SRCS))

在这个例子中,patsubstSRCS (可能包含 src/ 开头的 .c 文件名) 中的每个文件名中的 src/ 替换成 build/,并将 .c 替换为 .o,最终结果是得到 build 目录下的对象文件名。

  • wildcard 用于获取符合特定模式的文件列表,返回其文件名。
  • patsubst 用于在字符串中进行模式替换,生成一个新的字符串或文件名列表。

这两个函数在构建自动化中非常有用,特别是在处理多个源文件和对象文件时。

多Makefile文件引用

要在第三个工程中引用前两个工程的函数和模块,可以通过将前两个工程编译成静态库(例如 .a 文件)或动态库(例如 .so 文件),然后在第三个工程中链接这些库。下面我将展示如何组织这些工程并编写相应的 Makefile

项目结构

假设我们有以下的项目结构:
project/
├── lib1/                  # 第一个库工程
│   ├── src/
│   │   ├── lib1.c
│   │   └── lib1.h
│   └── Makefile
├── lib2/                  # 第二个库工程
│   ├── src/
│   │   ├── lib2.c
│   │   └── lib2.h
│   └── Makefile
└── app/                   # 第三个应用工程
    ├── src/
    │   ├── main.c
    └── Makefile
1. 第一个库的 Makefile(在 lib1/ 目录中)
# lib1/Makefile
CC = gcc
CFLAGS = -g -Wall -fPIC
SRC = src/lib1.c
OBJ = lib1.o

all: lib1.a

# 规则:生成静态库

lib1.a: $(OBJ)
	ar rcs $@ \$^

# 规则:编译 .c 文件为 .o 文件

$(OBJ): src/lib1.c
	$(CC) $(CFLAGS) -c $< -o \$@

clean:
rm -f \$(OBJ) lib1.a

2. 第二个库的 Makefile(在 lib2/ 目录中)
# lib2/Makefile
CC = gcc
CFLAGS = -g -Wall -fPIC
SRC = src/lib2.c
OBJ = lib2.o

all: lib2.a

# 规则:生成静态库
lib2.a: $(OBJ)
	ar rcs $@ $^

# 规则:编译 .c 文件为 .o 文件
$(OBJ): src/lib2.c
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJ) lib2.a

3. 第三个应用的 Makefile(在 app/ 目录中)
# app/Makefile
CC = gcc
CFLAGS = -g -Wall -I../lib1/src -I../lib2/src

# 引用前两个库

LIB1 = ../lib1/lib1.a
LIB2 = ../lib2/lib2.a

# 应用源文件

SRC = src/main.c
OBJ = main.o

all: program

program: $(OBJ) $(LIB1) $(LIB2)
	$(CC) -o $@ $(OBJ) -L../lib1 -l1 -L../lib2 -l2

$(OBJ): $(SRC)
$(CC) $(CFLAGS) -c $< -o $@

clean:
rm -f \$(OBJ) program

4. 示例源文件

lib1/src/lib1.c:

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

void lib1_function() {
    printf("This is a function from lib1.\n");
}

lib1/src/lib1.h:

#ifndef LIB1_H
#define LIB1_H

void lib1_function();

#endif // LIB1_H

lib2/src/lib2.c:

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

void lib2_function() {
    printf("This is a function from lib2.\n");
}

lib2/src/lib2.h:

#ifndef LIB2_H
#define LIB2_H

void lib2_function();

#endif // LIB2_H

app/src/main.c:

#include "lib1.h"
#include "lib2.h"

int main() {
    lib1_function();
    lib2_function();
    return 0;
}
运行步骤
  1. lib1/lib2/ 目录中分别运行 make,这将生成 lib1.alib2.a 静态库。
  2. 然后在 app/ 目录中运行 make,这将编译并链接使用这两个库的程序。

能否进一步,仅在app目录中执行make

肯定是可以的,我们仅需要将LIB1和LIB2的构建命令编写到makefile中去。
# app/Makefile
CC = gcc
CFLAGS = -g -Wall -I../lib1/src -I../lib2/src

# 引用前两个库
LIB1 = ../lib1/lib1.a
LIB2 = ../lib2/lib2.a

# 应用源文件
SRC = src/main.c
OBJ = main.o

all: $(LIB1) $(LIB2) program

# 自动构建 lib1 和 lib2
$(LIB1):
	$(MAKE) -C ../lib1

$(LIB2):
	$(MAKE) -C ../lib2

program: $(OBJ) $(LIB1) $(LIB2)
	$(CC) -o $@ $(OBJ) -L../lib1 -l1 -L../lib2 -l2

$(OBJ): $(SRC)
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJ) program

我们再来熟悉一个命令:

$(MAKE) -C 是 GNU Make 中的一个命令,用于在指定的目录中调用 make,并执行该目录下的 Makefile

  • $(MAKE)
    • $(MAKE) 是一个特殊变量,它代表当前调用的 make 命令。这确保了当我们在不同的环境中运行 make 时,能够使用正确的 make 命令(例如,如果系统上安装了多个版本的 make)。
  • -C
    • -C 选项用于告诉 make 切换到指定的目录。这个选项后面需要跟一个目录路径。
    • make 切换到该目录时,它会在该目录中查找 Makefile 并根据其规则进行构建。
  • 参数
    • -C 参数后面通常接一个目录路径。例如,如果你想在 ../lib1 目录中运行 make,可以这样写:$(MAKE) -C ../lib1

我们仔细观察上面的目录中的文件,在构建过程中会产生很多中间状态的二进制文件,是否可以通过改写 makefile文件将中间状态的文件统一管理起来。

优雅的管理构建文件

当然可以!为了将生成的 `.o` 文件保存在特定的目录中,你可以在每个库的 `Makefile` 中指定一个 `build` 目录用于存放这些文件。
1. lib1 的 Makefile

lib1/Makefile 中,将对象文件存放到 build 目录:

# lib1/Makefile
CC = gcc
CFLAGS = -g -Wall -fPIC
SRC = src/lib1.c
OBJ = build/lib1.o

# 规则:创建 build 目录
all: build lib1.a

build:
    mkdir -p build

# 规则:生成静态库
lib1.a: $(OBJ)
    ar rcs $@ $^

# 规则:编译 .c 文件为 .o 文件
$(OBJ): src/lib1.c build
    $(CC) $(CFLAGS) -c $< -o $@

clean:
    rm -f $(OBJ) lib1.a
2. lib2 的 Makefile

lib2/Makefile 中,重复相同的步骤:

# lib2/Makefile
CC = gcc
CFLAGS = -g -Wall -fPIC
SRC = src/lib2.c
OBJ = build/lib2.o

# 规则:创建 build 目录
all: build lib2.a

build:
    mkdir -p build

# 规则:生成静态库
lib2.a: $(OBJ)
    ar rcs $@ $^

# 规则:编译 .c 文件为 .o 文件
$(OBJ): src/lib2.c build
    $(CC) $(CFLAGS) -c $< -o $@

clean:
    rm -f $(OBJ) lib2.a
3. app 的 Makefile

app/Makefile 无需更改,但确保构建时路径正确:

# app/Makefile
CC = gcc
CFLAGS = -g -Wall -I../lib1/src -I../lib2/src

# 引用前两个库
LIB1 = ../lib1/lib1.a
LIB2 = ../lib2/lib2.a

# 应用源文件
SRC = src/main.c
OBJ = main.o

all: $(LIB1) $(LIB2) program

$(LIB1):
    $(MAKE) -C ../lib1

$(LIB2):
    $(MAKE) -C ../lib2

program: $(OBJ) $(LIB1) $(LIB2)
    $(CC) -o $@ $(OBJ) -L../lib1 -l1 -L../lib2 -l2

$(OBJ): $(SRC)
    $(CC) $(CFLAGS) -c $< -o $@

clean:
    rm -f $(OBJ) program
运行 make all

现在,在执行 make all 时,.o 文件将生成在 lib1/build/lib2/build/ 目录中,从而使得文件结构更加整洁。执行 make clean 也能够正确清理这些生成的对象文件和库。

Makefile是可以进行非常丰富的扩展的,还有很多高级的用法,需要配合编译器一起使用才能展示它的强大。本次介绍仅仅展现了冰山一角,有机会继续给大家介绍更高级的用法,大家在项目构建上可以越来越规范!