集中解决检出开源项目和写Makefile
的痛点
1、背景
-
在做正式项目时,通常会不可避免引用第三方项目(通常是开源项目)。若数量不多, 可手动下载或检出,反之则写
Shell
脚本或Makefile
进行管理。 -
在检出和使用一个开源项目时,往往视开源项目本身和目标项目的需求两方面因素, 而对开源项目的检出方式和内容有不同的要求,例如:按正式发行版本(或标签)检出、 检出某一次提交的内容、只检出少数几个文件等等。
-
如何通过固定的脚本来满足前一点所提及的需求,而不必每次从头写起或机械般复制粘贴? 这显然是个很有挑战的问题,后文将综合运用
Shell
命令、Makefile
和VIM
来解决。 -
至于
Makefile
,则表现为每写一个Makefile
都在重复一次相似而又不尽似的脚本书写。 初学者翻箱倒柜查语法,老手往往将以往项目的Makefile
复制一份,然后改啊改—— 常见网上吐槽CRUD
型码农,说N年的经验其实是一年重复了N次,没什么提高, 其实多数人对Makefile
的掌握情况亦类似,每一份Makefile
仅满足当前项目的需要, 没有通用性,书写时也仅用最简单的语法,重复内容太多,更别提适当的抽象和模块化, 这就是最大的痛点。
2、预期目标
-
写一份支持多种方式的开源项目检出
Shell
脚本或Makefile
。 -
写一份通用的C/C++语言编译
Makefile
。 -
由于应用场景千变万化,上述脚本要允许定制和扩展,强调一个通用性,而不能头痛医头脚痛医脚, 只局限于单一场景或项目。
3、实现详情
3.1 开源项目检出脚本的核心逻辑
根据版本控制系统
(Version Control System
,VCS
)和检出方式的不同,而采用不同的逻辑。
由于当前Git
最流行、GitHub
资源最丰富,所以本文以Git
指代VCS
。下面根据不同的检出方式,
逐一给出其对应的核心逻辑:
-
按正式发行版本(或标签)检出:先全量
git clone
,再git checkout
, checkout的参数是一个标签
(tag
)。 -
检出某一次提交的内容:与上相似,但checkout的参数是某次提交的
散列码
(Hash Code
)。 -
只检出少数几个文件:
Git
不支持(SVN
支持),所以要改用直接下载的方式, 可考虑使用wget
、curl
等命令。关键的一点是:如何确定下载链接?这由代码托管网站
决定, 所幸的是,国外的GitHub
、GitLab
和国内的码云
(Gitee
)对于单个文件的链接规则是一样的, 这极大地简化了脚本的书写,实现详情见后文给出的完整脚本链接。
固定的逻辑如上所示。至于要检出哪些项目、需要每个项目的哪个版本/哪次提交/哪些文件、 检出到哪个目录、项目的出处等,则可按一定格式将这些信息分别赋给特定的变量, 然后让以上固定的检出逻辑以这些变量为条件,逐一将所需内容检出即可。
完整脚本详见懒编程秘笈项目
的makefile/checkout.git.mk
文件。至于SVN
版本的检出脚本,后续再实现。
3.2 普通C/C++应用程序Makefile
的核心逻辑
按这类Makefile
的常见需求点来逐项说明:
- 定制编译参数:与具体的编译器密切相关,不过在当前开源世界里,
GCC
可谓是编译器先锋和巨头, 所以多数场景下编译参数基本可与GCC
参数划等号。习惯上以CFLAGS
来汇集C编译选项、 以CXXFLAGS
来汇集C++编译选项。常见的定制项举例:- 警告级别:用
-W
选项来指定,例如-Wall
打开绝大多数警告项开关,-Wextra
在-Wall
基础上打开更多,-Wpedantic
会促使编译器采用更严格的语法标准,-Werror
会将警告当成错误对待,-Wno-XXX
用于屏蔽指定类型的警告,等等。 - 优化级别:一般的优化可通过
-O
选项来指定,例如-O0
是完全没优化, 且常与-g
(此选项用于生成调试符号)一起用于调试(Debug)版本;-O2
是多数项目(包括Linux
内核)用于发布(Release)版本的优化级别。 更细粒度或特定架构的优化可通过-fXXX
、-mXXX
以及GCC
手册里的具体选项来指定。 - 宏定义:通过
-D
来定义一个宏、-U
来取消一个宏,这需要根据目标源码的需求而定, 但有两个宏很常用:一个是_REENTRANT
用于多线程的可重入逻辑, 另一个则是NDEBUG
用于产品的发布版本。 - 头文件路径:通过
-I
或-i
指定非标准的头文件目录,在具有多个子模块的项目里几乎是必用。 - 程序版本号:非刚需,但却是具有代码版本管理和产品迭代意识的开发者都会遵循的规则,
其表现形式通常是:将一个随着每次代码提交而变动的数字或散列码定义成宏,
在编译期传递给编译器,进而固化到目标程序里,供用户在必要的时候查询。
版本号的生成逻辑详见懒编程秘笈项目
的
makefile/__ver__.mk
文件。 - 是否生成
与位置无关的代码
(Position-Independent-Code
):若需要则通过-fPIC
选项来指定。
- 警告级别:用
-
决定如何链接生成可执行文件:例如指定
-fPIE
来生成在加载到内存时使用相对地址的程序(与-fPIC
类似), 用-Wl,--start-group
和-Wl,--end-group
括起待链接的文件集合以便这些文件集合可被乱序书写,等等。 再增加一点条件判断,也可用于库的生成。 -
并行编译:在递归执行
make
命令时,指定-j
选项,其选项值通常取CPU的核心数, 一种自适应的写法为:-j $(shell grep -c "processor" /proc/cpuinfo)
-
多平台交叉编译:设置
ARCH
和CROSS_COMPILE
变量,来指定所要使用的编译器及部分编译参数, 包括Linux
内核在内的很多项目都用这种方法。 -
依赖关系管理:确保被依赖对象更新时,依赖对象要重新编译,包括头文件的更新。 一般来说,要指定一个目标依赖的源文件比较简单,但指定依赖的头文件就比较麻烦, 因为有一个写一个是比较低级的做法,不仅繁琐而且容易出错,推荐的做法是使用
-Wp,-MMD
选项生成*.d
文件, 再在Makefile
包含这些*.d
文件即可。 -
需要清理哪些编译产物:遵循生成什么就清理什么的原则即可。
- 代码静态检查:调用
cppcheck
、clang
等工具对代码进行静态检查, 目的是将大部分错误尤其是低级错误扼杀于摇篮中。这个需求项本来没什么可说的, 但既然要对代码进行检查,首先要做的便是获知代码源文件有哪些,这要么与使用者达成某种约定, 让使用者通过特定的变量来传递源文件明细项,要么是Makefile
进行一定的自动推断。 个人的做法是将这两种方法结合起来,具体为:- 约定以
C_SRCS
和CXX_SRCS
变量分别表示全部的C和C++源文件。 - 以
GOAL
和GOALS
变量分别表示单目标和多目标项目最终生成的可执行文件或库, 这两个变量必须要定义且仅定义一个(两个都定义也可以,但不符合使用语义)。 - 检测
C_SRCS
和CXX_SRCS
是否已定义(即赋值),若否,则运行make ${GOAL} ${GOALS} --dry-run
, 其中--dry-run
选项是用于模拟编译过程并打印但却不真正编译,再对打印内容进行过滤, 得到相应的C或C++源文件列表并赋给C_SRCS
或CXX_SRCS
,这就是自动推断。
- 约定以
-
扩展编译参数:与前述的
定制编译参数
目的相同但意义不同,定制
侧重于较为固定而具体的参数, 预先定义,是大多数场景下的默认配置;而扩展
则针对不确定的参数,完全由使用者自行定义, 属于个性化需求,通用Makefile
无法也不必预测这些需求是什么,只需提供相应的变量, 例如C_DEFINES
、C_INCLUDES
、OTHER_CFLAGS
,供使用者赋值即可。 - 重定义中间产物的生成规则:需要关注的中间产物通常是
*.o
文件。其实,编译参数若确定, 中间产物的生成规则也基本确定,加上Makefile
有内置的规则,本来不必重新定义规则, 但若想编译输出内容更简略或更具有提示性,则重定义必不可少,例如: 要想营造一个静默的C源码编译过程,则可以这样写:%.o: %.c @printf 'CC\t$<\n' @${CC} ${CFLAGS} -c -o $@ $<
- 重定义最终目标的生成规则(理由与上相似):
- 若要生成可执行文件,则核心语句为:
${CC} -o $@ -fPIE -Wl,--start-group $^ -Wl,--end-group
- 若要生成静态库,则核心语句为:
${AR} ${ARFLAGS} $@ $^
- 若要生成共享库,则核心语句为:
${CC} -shared -o $@ $^
- 若要生成可执行文件,则核心语句为:
完整脚本详见懒编程秘笈项目
的makefile/c_and_cpp.mk
文件。
3.3 Linux
驱动Makefile
的核心逻辑
简单地说就是Linux
内核编译系统(含Kconfig
、Makefile
、Shell
脚本等),
所以只需在此基础上简单封装一下即可,而无需重复造轮子,在此之前已专门写过文章,
详见《懒人版Linux驱动Makefile》。
3.4 嵌入式
项目Makefile
的核心逻辑
嵌入式
其实是个很宽泛的概念,最简单的是51
单片机,更复杂且又常见的有AVR
、
STM32
、ARM Cortex
等。这些嵌入式处理器中,有些配套的专用编译工具只有Windows
版本,
无法或很难使用Makefile
;而有些则除了提供Linux
版的集成开发环境
(IDE
)外,
还支持开源的编译器,这些编译器通常是gcc
的变体,所以能很好地利用Makefile
提供的机制。
作为一个非常火、应用非常广泛的芯片系列,STM32
芯片家族不乏性价比很高的型号,
且其IDE
可跨平台使用,既支持图形界面,又对命令行友好,可以纳入通用Makefile
体系。
与前述Linux
驱动Makefile
一样,关于STM32
之前也写过类似的文章,因此在本文也不再展开,
详见《在Linux下玩转STM32》的“4.3 增加顶层Makefile
”小节。
3.5 脚本的模块化、组合及生成
-
模块化:前面在介绍各个细项的核心逻辑时,已经产出具体的
.mk
文件,满足了模块化的要求。 - 组合:即在用户的
Makefile
里,将以上模块拼装起来,并且就算是用于不同的项目, 也能少改甚至不改参数即能运用起来。这么说还是很抽象,下面通过一份极度简化的用户Makefile
示例, 即能表述清楚:all: init # 根据项目的类型(Type),赋予不同的值 T := app -include __ver__.mk ifeq (${T}, app) # 一些必要的设置 -include c_and_cpp.mk else ifeq (${T}, driver) # 一些必要的设置 -include linux_driver.mk else ifeq (${T}, stm32cubeide) # 一些必要的设置 -include stm32_cube_ide.mk else $(error Unrecognized Makefile type: ${T}) endif pre-init: # 前置初始化 checkout: # 具体的检出逻辑 post-init: # 后置初始化 init: pre-init checkout post-init -include checkout.git.mk
- 生成:其实在此之前,已经有不少人也实现了不同形式、不同用途的通用
Makefile
, 但它们都有一个共同硬伤,就是每应用于一个新项目,都需要手动复制粘贴一些东西。 其实这种手动操作是可以避免的,可以考虑将上面的Makefile
示例内容保存到一个文件, 并重命名为mk.tpl
,然后在~/.vimrc
写入以下内容,以后每次用VIM
新建一个Makefile
, 都能自动生成内容,而不必手动复制粘贴:autocmd BufNewFile,FileType make 0r /path/to/mk.tpl
- 额外说明:其实上面两点对于脚本的
组合
和生成
,是为了解决鸡生蛋还是蛋生鸡
的难题。 因为从使用的角度来说,首先要检出各个通用的Makefile
模块,才能利用其进行第三方项目检出、 快速完成目标项目Makefile
的编写等工作,但这些通用的Makefile
模块同时也是一个第三方项目, 它们也需要被检出,这就陷入了一个循环……要打破这个循环,必须在外部引入一个可称之为创世
、点火
、启动
或其他能表达这种含义的操作。手动复制可解决但解决得不优雅, 利用VIM
的脚本能力来生成一个Makefile
模板可算是将手动操作降至最低程度的方案, 虽然这个方案要求使用者对命令行和VIM
操作有一定了解,但是……既然都要用到Makefile
了, 命令行和VIM
想必都是必修课吧?最后,关于mk.tpl
的完整版内容, 详见懒编程秘笈项目 的vim/coding_templates/mk.tpl
文件。
3.6 使用说明
-
利用
VIM
生成一个Makefile
(“vim [Mm]akefile
”或“vim xxx.mk
”均可), 并根据项目需求小改相应的编译参数(生成的Makefile
有相关的注释可作提示), 以及配置好THIRD_PARTY_PROJECTS
变量,以确定需要检出哪些项目。 -
执行
make seeds
下载checkout.git.mk
以及生成*.git.chkout.mk
, 然后根据具体情况修改各个*.git.chkout.mk
。 -
执行
make init
执行真正的检出操作。注意可为init
目标添加更多的初始化操作, 这些初始化操作会与第三方项目检出一同被执行。 -
第三方项目正常检出、其余初始化执行完毕后,即可按正常流程进行
make
、make clean
等操作,还可在Makefile
的末尾按需添加install
、uninstall
等, 这些操作由于没有固定的规则而没被纳入通用的Makefile
,但通常逻辑很简单, 而且不是每个项目都需要,所以只能交给使用者自行实现。
4、总结
其实没什么好总结,只是在写完详细内容之后,对开篇的背景作一些呼应和补充而已, 不看亦无损失,但看了或许有新启发:
-
如果不是做复杂项目,就不需要引用第三方项目,可惜现代软件往往很复杂。
-
如果不是第三方项目较多,就不需要专门想方案来解决这些项目的检出问题。
-
Shell
命令或脚本是项目检出的首选方案,因为很难开发一个通用的图形界面程序, 并期望它很容易地与各种项目集成到一起。并且,Makefile
也能很方便调用Shell
命令, 干脆就将这个需求作为通用Makefile
的其中一个子任务吧。 -
如果不是在各种场合下写过各种用途但又有一定共同点的
Makefile
, 就体会不到写Makefile
的枯燥,也体会不到其灵活和强大,也就不会萌生这么一个想法, 摸索出一个通用Makefile
,以达到就算不能一劳永逸也能少做很多重复劳动的目的。 所以在很多时候,懒惰才是第一生产力。而且,人不懒,肝就要受罪,懂的都懂。 -
从使用的效果来看,用户的
Makefile
往往只需要进行少量参数的修改, 而不需要过多地编写具体的编译规则,便可投入使用,其实这已经与cmake
有异曲同工之感, 且某些方面更简单、更可控,所以很多技术只是表面形式不同,底层原理却相似,做法上也殊途同归。 掌握基础原理、底层知识,永远不会过时,不要被各种炒起来的新概念迷花了眼。 -
正因为大部分内容已经被通用模板囊括,用户要写的内容很少, 所以特别适合写完即弃的
黑佬窝
(Hello World
)程序,这类程序经常是在想做一些小测试、 入门练习时临时写一下,但往往没有长久维护的价值,与其配套的Makefile
当然也要追求短平快
。 对于正式且较为复杂的项目,只需要对生成的Makefile
删除多余的条件分支(例如项目是一个Linux
驱动, 则删除driver
以外的if
分支),再将初始化操作集中到一个独立的子模块里, 其余模块的初始化则简单地转换为切换到该独立子模块目录里执行make init
即可, 如此便可确保各个模块没有太多的冗余内容,保持了良好的可读性和简洁性。 要进行这样的改动,工作量也不大,时间也耗费不多,非常实用。 -
除了
Shell
和Makefile
,VIM
也发挥了意想不到的作用, 可知其编辑器之神
的称号不是白叫的,这也可作为对那些“为何当今图形化开发环境如此发达, 还是有人用VIM”这种问题的一个回答。其实VIM
的可玩性远不局限于此, 但不理解它的人,永远也不可能理解,只能希望包括VIM
、Makefile
、Shell
在内的一切包含UNIX
哲学的工具能得到长久的传承和维护,即使这样的人不多。 -
最后要说的是,这份通用的
Makefile
肯定不适合所有人,甚至不适合大部分人, 原因在于每个人的工作场景、技术口味、做事风格都不一样,甲之蜜糖,乙之砒霜, 这就是多数新语言、新编辑器、新框架的诞生缘由以及各派别打嘴炮的根本原因。 但有一点可以确定,只要掌握基本原理,就能随便造轮子,应付不同项目的差异也能游刃有余。 要问Makefile
的基本原理,当然首推其官方手册, 但这个对初学者尤其是非英语母语者不太友好,好在已有不少前辈已为我们铺好了路, 国内开发者可以考虑从陈皓
的《跟我一起写Makefile》 入门。本人就从这份教程中获益良多,感谢这位前辈!