3、Make基础回顾:Makefile基本语法、目标、依赖、变量、函数、Android.mk的Make本质

说实话,很多做Android开发的朋友,一听到Make就头大。我当年刚接触AOSP时也一样,看着那一堆Android.mk文件,完全不知道它们在干嘛。后来我才明白——不懂Make,你根本没法真正理解Android的构建系统。

这一章,我们就来扒一扒Make的老底。别怕,我会用最接地气的方式讲清楚。

3.1 Make是什么?一个简单的故事

想象一下:你写了一个App,有100个源文件。你只改了其中1个,难道要重新编译全部100个?太蠢了。Make就是来解决这个问题的——它只重新编译那些「变了」的文件。

说白了,Make是一个自动化构建工具。它根据文件的时间戳来判断:如果目标文件比依赖文件旧,就重新生成。就这么简单。

核心思想:Make的本质是一个「有向无环图(DAG)」的依赖解析器。它把构建规则组织成一张图,然后按拓扑顺序执行。

3.2 Makefile基本语法

一个Makefile的基本结构长这样:

目标(target): 依赖(dependencies)
<Tab>命令(commands)

注意那个Tab!我当年踩过这个坑——用空格代替Tab,结果Make报错说「missing separator」。嗯,这是个经典新手错误。

来看个实际例子:

hello: hello.c
    gcc -o hello hello.c

这个规则的意思是:要生成hello这个文件,需要先有hello.c。如果hello.c比hello新,就执行gcc命令重新编译。

3.3 目标(Target)

目标可以是文件名,也可以是「伪目标」。伪目标不生成文件,只是执行一些操作。

.PHONY: clean
clean:
    rm -f *.o hello

为什么要加.PHONY?因为如果当前目录下恰好有一个叫clean的文件,Make就会认为「clean已经是最新的了」,不执行命令。加.PHONY就是告诉Make:别管文件,直接执行。

我的习惯:所有不生成文件的目标,都加上.PHONY声明。这是好习惯,能避免很多奇怪的问题。

3.4 依赖(Dependencies)

依赖是Make的灵魂。一个目标可以有多个依赖,依赖本身也可以是其他目标。

app: main.o utils.o
    gcc -o app main.o utils.o

main.o: main.c header.h
    gcc -c main.c

utils.o: utils.c utils.h
    gcc -c utils.c

执行make app时,Make会先检查main.o和utils.o是否需要更新,如果需要,就递归地处理它们的依赖。这就是「依赖链」的概念。

我在AOSP里见过最深的依赖链有十几层。你想想看,一个小小的改动,可能触发上百个文件的重新编译。

3.5 变量(Variables)

写Makefile不用变量?那你得累死。变量让Makefile变得灵活可维护。

CC = gcc
CFLAGS = -Wall -O2
OBJS = main.o utils.o

app: $(OBJS)
    $(CC) -o app $(OBJS)

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

这里有几个特殊变量要注意:

变量 含义
$@ 目标文件名
$< 第一个依赖文件名
$^ 所有依赖文件名(去重)
$? 所有比目标新的依赖

我曾经踩过的坑:在AOSP里,变量展开有两种方式——「简单展开」(:=)和「递归展开」(=)。用=定义变量时,如果变量引用了自身,会陷入无限递归。我见过同事因为这个,Make跑了半小时还没结束。

3.6 函数(Functions)

Make内置了不少实用函数,处理字符串、文件列表什么的很方便。

SRCS = $(wildcard src/*.c)
OBJS = $(patsubst src/%.c, obj/%.o, $(SRCS))

app: $(OBJS)
    gcc -o $@ $^

常用的函数有:

  • $(wildcard pattern) — 匹配文件模式
  • $(patsubst pattern,replacement,text) — 模式替换
  • $(foreach var,list,text) — 循环处理
  • $(call func,args...) — 调用自定义函数
  • $(shell command) — 执行shell命令

我个人最常用的是$(foreach),在AOSP里处理多架构编译时特别好用。

3.7 Android.mk的Make本质

好了,重点来了。Android.mk本质上就是一个Makefile——只不过Google给它加了一层「语法糖」。

看一个典型的Android.mk:

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

LOCAL_MODULE := hello
LOCAL_SRC_FILES := hello.c
LOCAL_C_INCLUDES := $(LOCAL_PATH)/include
LOCAL_SHARED_LIBRARIES := libcutils

include $(BUILD_SHARED_LIBRARY)

你可能会问:这跟Makefile长得不一样啊?

其实,LOCAL_XXX都是变量赋值。include $(CLEAR_VARS)是清空这些变量。include $(BUILD_SHARED_LIBRARY)是引入一个「模板Makefile」,它会把LOCAL_XXX变量转换成真正的Make规则。

说白了,Android.mk就是「用变量来配置,用include来组装」的Makefile。

Make本质:Android.mk中的每一行,最终都会被展开成标准的Make规则。LOCAL_MODULE变成目标,LOCAL_SRC_FILES变成依赖,LOCAL_CFLAGS变成编译选项。仅此而已。

3.8 知识体系图

下面这张图,帮你理清Make的核心概念和它们之间的关系:

Makefile 目标 (Target) 依赖 (Dependency) 命令 (Command) 变量 (Variable) 函数 (Function) Android.mk 核心逻辑:Makefile 定义目标、依赖和命令 Android.mk 本质上是「用变量配置的 Makefile 模板」

3.9 实战:从Make到Android.mk的转换

假设我们有一个简单的C项目:

# 普通Makefile
CC = gcc
CFLAGS = -I./include
hello: hello.c utils.c
    $(CC) $(CFLAGS) -o hello hello.c utils.c

转换成Android.mk就是:

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

LOCAL_MODULE := hello
LOCAL_SRC_FILES := hello.c utils.c
LOCAL_C_INCLUDES := $(LOCAL_PATH)/include

include $(BUILD_EXECUTABLE)

你看,LOCAL_MODULE对应目标,LOCAL_SRC_FILES对应依赖,LOCAL_C_INCLUDES对应编译选项。本质完全一样,只是换了个写法。

我的建议:如果你能写Makefile,Android.mk就是换个语法的事。别被那些LOCAL_XXX吓到,它们只是变量而已。

3.10 避坑指南

最后,分享几个我在AOSP里踩过的坑:

  • 空格 vs Tab:Makefile的命令行必须以Tab开头。我见过有人用4个空格代替,结果编译报错。这问题排查起来特别隐蔽。
  • 变量展开时机:=定义变量是延迟展开,用:=是立即展开。在AOSP里,大部分LOCAL变量用:=更安全。
  • 伪目标一定要加.PHONY:否则如果目录下有同名文件,Make会跳过执行。我因为这个,有一次clean操作没生效,导致增量编译一直失败。
  • include顺序:Android.mk里include $(CLEAR_VARS)必须在最前面,否则上次的LOCAL变量会污染当前模块。

嗯,Make的基础就这些。掌握了这些,你再看AOSP里的Android.mk,就不会觉得神秘了。它们本质上就是一堆精心组织的Make规则,用变量和函数组装起来的构建系统。


公众号:蓝海资料掘金营,微信deep3321