Make

面向 Linux / C/C++ / 交叉编译(ARM、MIPS uClibc 工具链)的构建笔记。

Make 基础概念

Make 通过比较目标(target)与其依赖(prerequisite)的修改时间(mtime)决定是否重建:只要任一依赖比目标新,就执行该目标的命令,并沿依赖图递归向上。

常用选项

  • -C :Change directory(切换目录)

Makefile 中第一个 target 是默认目标(下文示例里是 all),make 不带参数时构建它。

  • 常用调试开关:
    • make -n:通常只打印 recipe;含 $(MAKE) 的递归调用行、带 + 前缀的行以及某些重建 makefile 的操作仍可能执行,不能当作绝对无副作用的模式。
    • make -p:打印内部规则与变量数据库,同时仍可能正常构建;只查询时可用 make -qp,注意其退出码也表示目标是否需要重建。
    • make -d:打印为什么某个目标被判定需要重建。
  • NFS/时钟偏差可能导致 mtime 误判,必要时 make clean 重来。

交叉编译静态库示例

一个完整的交叉编译静态库 Makefile(已修正原示例中的 ar 修饰符与缺失的 .PHONY):

# 交叉编译器前缀
CROSS_COMPILER := arm-mol-linux-uclibcgnueabihf-
CXX := $(CROSS_COMPILER)g++
AR  := $(CROSS_COMPILER)ar
PROJECT_ROOT := /home/meian/work/SVN/nvr/plh/eavs_FH_list/eavs_mc6830/project

# 编译选项:只放编译期标志。
# -L ./lib -lfdk-aac 属于“链接期”选项,不应放进 CXXFLAGS(编译 .o 阶段用不到)
CXXFLAGS = -std=c++11 -Wall -O0
FDK_AAC_INC = -I./include -I./include/fdk-aac -I../mc6830/include

# 静态库名称
LIBNAME = libG711toAAC.a

# 源文件列表
CPPSRC := AACEncoder.cpp G711toAAC.cpp ../mc6830/G711.cpp

# 目标文件列表(把 .cpp 后缀替换成 .o)
CPPOBJ = $(CPPSRC:.cpp=.o)

# 默认目标
all: lib

# 模式规则:编译每个 .cpp -> .o
%.o: %.cpp
	$(CXX) $(CXXFLAGS) $(FDK_AAC_INC) -c $< -o $@

# 归档静态库
# 说明:原示例用的是 rcu,其中 u(仅更新更新的成员)在现代 binutils 默认的
# “确定性归档”模式(D)下会被忽略并告警,故改为 rcs:s 显式表明写入符号索引
lib: $(CPPOBJ)
	$(AR) rcs $(LIBNAME) $(CPPOBJ)

# 仅当使用该 ARM 交叉编译器时,把生成的静态库拷到工程 lib 目录
# 该 ifeq 为解析期条件,紧跟 lib 规则,其下的 cp 会追加到 lib 的 recipe
ifeq ($(CROSS_COMPILER),arm-mol-linux-uclibcgnueabihf-)
	cp $(LIBNAME) $(PROJECT_ROOT)/lib_mc6830/
endif

# 清理生成文件
clean:
	rm -f $(LIBNAME) $(CPPOBJ)

# 声明伪目标:避免与同名文件冲突(见“伪目标”一节)
.PHONY: all lib clean

关于 ar 修饰符:GNU ar 的 r 操作会自动写入符号索引(armap),rc / rcs 均可直接链接,无需再跑 ranlib;只有大写 S 才会抑制索引。rcs 里的 s 只是把“写索引”的意图显式化,并对非 GNU 的 ar 更具可移植性。

Makefile 变量与赋值

四种赋值运算符的差异会直接影响标志拼接与工具链切换,是最容易踩的坑:

运算符名称展开时机典型用途
=递归 / 延迟展开每次引用时重新求值(可引用后面才定义的变量)一般变量;注意 X = $(X)... 会无限递归
:=立即 / 简单展开定义时求值一次路径、CROSS_COMPILE 前缀、$(shell ...) 结果,避免反复求值
?=条件赋值仅当变量未定义时赋值允许命令行/环境覆盖,如 CROSS_COMPILE ?= arm-mol-...-
+=追加继承左侧原有的展开语义CXXFLAGS += -g

其它要点:

  • $(VAR) 与 ${VAR} 等价。
  • 用 := 冻结 $(shell ...) 结果,用 = 则每次引用都会重新执行 shell:
GITVER := $(shell git rev-parse --short HEAD)   # 只执行一次
GITVER2 = $(shell git rev-parse --short HEAD)    # 每次引用都执行一次
  • recipe 里访问 shell 变量要写两个 $:$$HOME(单个 $ 会被 make 当成自己的变量)。

优先级

同一个变量名同时从多个地方定义时,有优先级考虑

override > make 命令行变量 > Makefile 普通赋值 > Shell 环境变量 > Make 内置默认变量
  1. override最高优先级
MCU_VERSION = FROM_MAKEFILE

override MCU_VERSION = FROM_OVERRIDE

all:
	@echo $(MCU_VERSION)
	@echo origin=$(origin MCU_VERSION)
####### 执行 make MCU_VERSION=FROM_COMMAND_LINE
输出结果
FROM_OVERRIDE
origin=override 
即使命令行指定make MCU_VERSION=FROM_COMMAND_LINE,输出结果依然不变
  1. make 命令行变量
MCU_VERSION = FROM_MAKEFILE

all:
	@echo $(MCU_VERSION)
	@echo origin=$(origin MCU_VERSION)
####### 执行 make MCU_VERSION=MCU_CIU32 (这就是command line)
输出结果
MCU_CIU32
origin=command line
这是command line > Makefile 普通赋值 
  1. 普通赋值
MCU_VERSION = FROM_MAKEFILE

all:
	@echo $(MCU_VERSION)
	@echo origin=$(origin MCU_VERSION)
  1. shell环境变量

这个需要与make XXX=xxx区分

all:
	@echo $(MCU_VERSION)
	@echo origin=$(origin MCU_VERSION)

Target、Prerequisite、Recipe

规则的三要素:

target: prerequisites
	recipe          # 必须以 TAB 开头
  • recipe 行必须以真实 TAB 开头,用空格会报 *** missing separator. Stop.——编辑器把 TAB 自动转空格是最高频踩坑。
  • 每条 recipe 行在独立的 shell 中执行;跨行要么行尾加 \ 续行,要么用 ; 连接(如 cd build && make)。
  • order-only 依赖:target: normal-prereqs | order-only-prereqs。竖线右侧的依赖只保证“先存在”,其 mtime 变化不会触发重建,常用于先建输出目录:
OBJDIR := build
$(OBJDIR)/%.o: %.cpp | $(OBJDIR)     # $(OBJDIR) 是 order-only 依赖
	$(CXX) $(CXXFLAGS) -c $< -o $@
$(OBJDIR):
	mkdir -p $@

模式规则与自动变量

自动变量表

变量含义
$@目标名
$<第一个依赖(编译规则里就是源文件)
$^全部依赖,去重(写链接行的对象列表用它)
$+全部依赖,不去重、保持顺序(链接顺序敏感时有用)
$?比目标新的依赖
$*模式规则中 % 匹配到的茎(stem)
$(@D) / $(@F)目标的目录部分 / 文件名部分

链接规则示例:

app: $(CPPOBJ)
	$(CXX) $^ -o $@ $(LDFLAGS)     # $^ = 所有 .o

静态模式规则与 VPATH

  • 静态模式规则:只对指定目标列表套用模式,比通配 %.o: %.cpp 更可控:
$(CPPOBJ): %.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
  • 源文件散落在多目录(如本例的 ../mc6830/)时,用 vpath 让 make 跨目录搜索:
vpath %.cpp ../mc6830          # 只对 .cpp 生效
# VPATH = ../mc6830:../common  # 对所有类型生效

自动头文件依赖生成

%.o: %.cpp 只把 .cpp 列为依赖,头文件对 make 不可见——改了 .h 不会触发重编,会链接进过期的 .o,是典型的隐蔽 bug。让编译器生成依赖文件即可解决:

CXXFLAGS += -MMD -MP
-include $(CPPOBJ:.o=.d)        # 放在 Makefile 末尾
  • -MMD:生成每个 .o 对应的 .d 依赖文件,只记录用户头文件(-MD 连系统头也记)。
  • -MP:为每个头文件生成一个空的伪目标,避免头文件被删后 make 报“无规则可用”。

编译和链接选项

编译分阶段

  • -E:只做预处理,输出到 stdout。
  • -S:编译到汇编(.s)。
  • -c:编译+汇编到目标文件 .o,不链接(编译多文件工程的常态)。
  • 不带上述选项:一路编译并链接成可执行文件。
gcc -c hello.c -o hello.o       # 只编译,产出 .o
gcc hello.o -o hello            # 链接

编译期 vs 链接期选项

阶段常用选项
编译期-pthread -I<dir> -D<宏> -std= -O0/-O2/-Os -g -Wall -Wextra -Werror -f*(如 -fPIC)
链接期-L<dir> -l<name> -Wl,<原样传给 ld 的选项> -shared -static -pthread
  • -I(大写 i):头文件搜索目录——把 <dir> 加入预处理时 #include 的搜索路径(参数是目录,不是某个头文件)。
  • -L(大写 L):库文件搜索目录。
  • -l(小写 L):链接指定库(-lworld 找 libworld.so 或 libworld.a)。
gcc -o hello hello.c -I /home/hello/include -L /home/hello/lib -lworld
  • 嵌入式常用体积/优化选项:-Os(为体积优化,flash 受限时常用)、-Og(可调试的优化);配合 -ffunction-sections -fdata-sections + 链接期 -Wl,--gc-sections 剔除未用段;strip(或链接加 -s)去符号进一步减小体积。

-fpic、-fPIC、-fpie、-fPIE 的区别

(原为空标题,此处补齐。)

  • -fPIC:生成位置无关代码(Position-Independent Code),共享库(.so)必需——凡是要进 .so 的 .o 都得用它编译。
  • -fpic(小写):同样是位置无关代码,但用更小/更快的 GOT 寻址序列,受平台 GOT 表大小限制;MIPS/ARM 上大型库容易溢出,拿不准就用 -fPIC。
  • -fPIE / -fpie:位置无关可执行文件(Position-Independent Executable),配合 ASLR;-fPIE 是编译期标志,链接期还要加 -pie。
  • PIC vs PIE:PIC 允许符号插入(interposition,用于库);PIE 面向主程序、可做更激进优化。
  • 交叉/嵌入式注意:很多 uClibc 工具链默认构建非 PIE;若最终被抽取并链接进 .so 的静态库成员含不适用于共享对象的非 PIC 重定位,可能报 recompile with -fPIC;应以 PIC 方式重新编译相关成员,不能仅给最终链接命令加 -fPIC。

静态库与动态库

创建

  • 静态库(.a):把多个 .o 打包,链接时只抽取被引用到的成员,所需代码进入最终产物,不再运行时加载这个 .a;但程序仍可能依赖 libc 等其他动态库。
$(CROSS)gcc -c a.c b.c
$(CROSS)ar rcs libfoo.a a.o b.o        # s = 写符号索引
  • 动态库(.so):编译加 -fPIC,链接加 -shared,运行期由动态加载器解析(体积小、多进程共享)。
$(CROSS)gcc -fPIC -c a.c b.c
$(CROSS)gcc -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0.0 a.o b.o

soname 与三级命名

  • 用 -Wl,-soname,libfoo.so.1 记录 soname。
  • 磁盘惯例:真实文件 libfoo.so.1.0.0 ← 运行期符号链接 libfoo.so.1(= soname)← 开发期符号链接 libfoo.so(-lfoo 链接时找它)。

链接顺序敏感(高频 undefined reference 根源)

链接器从左到右单遍处理,库必须出现在引用它符号的对象/库之后,否则报 undefined reference:

# G711toAAC 用到 fdk-aac,所以 fdk-aac 放在它右边
$(CXX) main.o libG711toAAC.a -L./lib -lfdk-aac -o app
  • 两个静态库相互依赖时:重复列出,或用 -Wl,--start-group libA.a libB.a -Wl,--end-group。
  • -Wl,-Bstatic / -Wl,-Bdynamic 可强制某段按静态/动态链接。

运行期库查找与排查

  • glibc 加载器在依赖名不含斜杠时,大致依次考虑:仅在无 DT_RUNPATH 时使用 DT_RPATH → LD_LIBRARY_PATH(安全执行模式会忽略)→ DT_RUNPATH → /etc/ld.so.cache → 默认目录。RUNPATH 只用于直接依赖,其他 libc 的规则可能不同。
  • 嵌入搜索路径:-Wl,-rpath,/usr/local/lib,或用 $ORIGIN 相对可执行文件定位 -Wl,-rpath,'$ORIGIN/../lib'。若写在 Makefile recipe 中,要把 $ 写成 $$,例如 -Wl,-rpath,'$$ORIGIN/../lib'。
  • 交叉编译时为传递性 .so 依赖指定仅链接期搜索路径:-Wl,-rpath-link,<dir>。
  • 全静态可执行文件(最小 rootfs 常用):-static;只静态化工具链运行时:-static-libgcc -static-libstdc++。
  • 排查:arm-...-readelf -d a.out 看 NEEDED/RPATH;readelf -h a.out 看架构与 ABI。

Linux 下的符号可见性(对应 Windows 的 dllexport/dllimport)

下面这段 xlog_API 宏是 Windows 的库导出/导入写法(__declspec(dllexport/dllimport));在 Linux 分支上它展开为空,也就没法控制 .so 到底导出哪些符号。

#ifndef XLOG_H
#define XLOG_H
// __declspec(dllexport) 导出 xLog 类的函数到 lib 文件中
// __declspec(dllimport) 导入 xLog 类的函数到 exe 文件中
#ifndef _WIN32 // linux
#   define xlog_API
#else
#   ifdef xlog_STATIC // 静态库
#       define xlog_API
#   else
#       ifdef xlog_EXPORTS
#           define xlog_API __declspec(dllexport) // 库项目
#       else
#           define xlog_API __declspec(dllimport) // 调用方
#       endif
#   endif
#endif
class xlog_API XLog
{
public:
    XLog();
    ~XLog() = default;
};
#endif // XLOG_H

Linux/GCC 的对应机制是 visibility 属性:

  • 用 __attribute__((visibility("default"))) 标记要导出的符号,编译 .so 时加 -fvisibility=hidden 把其余符号默认隐藏。
  • 即把上面 Linux 分支从 #define xlog_API(空)改为构建共享库时定义为 __attribute__((visibility("default")))。
  • 好处:减小 .so 符号表、加快目标机加载,是 dllexport/dllimport 的跨平台对应物。

伪目标

避免和同名文件冲突

如果一个规则并不产生与目标同名的文件,则其命令在每次 make 该目标时都应执行。例如:

clean:
	rm *.o temp

因为 rm 命令并不产生名为 clean 的文件,正常情况下每次 make clean 都会执行。但如果目录里恰好出现了名为 clean 的文件,规则就失效了:它没有依赖,clean 文件永远被视为“最新”,命令永远不执行。用 .PHONY 声明可避免此问题:

.PHONY: clean
clean:
	rm *.o temp

这样 make clean 会无视是否存在 clean 文件,始终执行。

常备伪目标清单

工程里常声明为伪目标的:all clean distclean install uninstall test。可集中声明:

.PHONY: all clean install

.PHONY 除避免同名文件冲突外,还会让 make 跳过该目标的隐式规则搜索。

递归 Make

内核模块编译

  • -C <dir>:进入 <dir> 目录后再执行其 Makefile。
  • M=:传给内核 Makefile 的变量,指定外部模块源码目录。
make -C $(KDIR) M=$(PWD) modules

跨目录递归要点

  • 子目录调用用 $(MAKE) 而非裸 make——这样 -jN、MAKEFLAGS、jobserver 才能正确下传,并行才生效:
sub:
	$(MAKE) -C subdir
  • export VAR 把变量下传给子 make;命令行传入的变量(如 make CROSS_COMPILE=...)默认自动下传。
  • make -jN 并行构建;个别有竞态的目标用 .NOTPARALLEL 强制串行,或用 order-only 依赖保证顺序。
  • 递归 make 的固有缺点是跨目录依赖不完整(Recursive Make Considered Harmful);大工程可考虑非递归单 Makefile,或改用 CMake。

常见问题

典型编译/链接/交叉错误排查

报错原因与处理
*** missing separator. Stop.recipe 用了空格而非 TAB
undefined reference to X缺库,或库顺序错(库要放在引用它的对象右侧),循环依赖用 --start-group
cannot find -lfoo-L 路径或库名不对
wrong ELF class / file 显示架构不符误用了主机 gcc 或错的工具链
version 'GLIBC_2.xx' not found / ABI 报错目标机 libc 与工具链不匹配(uClibc 与 glibc 混用尤甚)
fatal error: xxx.h: No such file or directory缺 -I,或交叉编译未设 --sysroot 导致系统头缺失

找不到头文件(CMake)

main.cpp:10:10: fatal error: gpio.h: No such file or directory
 #include "gpio.h"
          ^~~~~~~~

排查思路:确认头文件实际所在目录,然后用 target_include_directories(见“CMake Target 化用法”)把该目录加入目标的头文件搜索路径;交叉编译还要确认系统头是否在 sysroot 内。

待确认:原笔记此处「目录结构」示意图缺失,具体工程的目录布局需本人补充。

设置 gdb 调试模式(CMake)

set(CMAKE_BUILD_TYPE Debug)
set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g -Wall")

单配置生成器按 CMAKE_BUILD_TYPE 选择对应的 CMAKE_CXX_FLAGS_,并与 CMAKE_CXX_FLAGS 一起使用;例如 Release 使用 CMAKE_CXX_FLAGS_RELEASE。多配置生成器通过 –config Debug 等选择构建配置,不依赖 CMAKE_BUILD_TYPE。

参考:GNU Make;ld.so。