从make到cmake

概述:make工具是非常有用的工程管理工具,常见于linux系统使用,在windows中更多是cmake或者各种IDE来负责工程管理,个人比较喜欢啥都玩一下,所以就有了这篇简单使用总结。这里的只是总结,如果已经有c/cpp程序编译过程的简单了解的,应该阅读不算吃力,可以先看看这篇文章,觉得很好理解当然下面就能够更快接受。

一个简单的c/cpp程序,从代码到可执行文件,主要经历预编译、编译、汇编和链接四个阶段,中间会生成预处理.i文件,编译后.s汇编文件,汇编处理后包含机器指令集的.o文件,最后进行标准库或者第三方库的链接得到可执行文件。这个过程,针对一个简单c/cpp文件来说,就一个gcc/g++ test.c/test.cpp -o test命令而已,但如果对于一个庞大的工程项目,来来回回的代码修改更迭,耦合解耦再耦合,简单的过程就会变得复杂,简单的一句命令就会变成一套命令才能搞定。

针对这样的一套命令,makefile就算是这个命令的集合,但它的执行主体是make,它自有一套语法来解析makefile的内容,从而执行正确的编译命令。当一套工程的编译流程,归结成命令,汇总到makefile中,在后续修改不改变架构的情况下,编译一个工程就成了一句make命令和等待的事儿了。

最简单的makefile语法:

target: dependency_file
<TAB>command

如上,target是目标文件,dependency_file是依赖,也是来源,要得到target文件,就需要执行command命令把dependency_file文件,试验是在windows的mingw环境,所以各种类linux的执行命令都有附带,不过make命令的使用需要修改一个可执行命令的文件名,"mingw32-make.exe"修改为"make.exe",因为路径本来就添加到环境变量中去了的,所以不用重复改动环境然后重启。

一个简单的cpp程序

#include <iostream>
using std::cout;
using std::endl;
int main(){
cout << "Hello, makefile." << endl;
return 0;
}

这么一个cpp程序,编译成可执行文件就是g++ test.cpp -o test,落在makefile中就是:

test.exe: test.cpp
g++ test.cpp -o test

简单跑一下:

PS D:\Desktop> make
g++ test.cpp -o test
PS D:\Desktop> ./test
Hello, makefile.

把前面的生成文件删掉,把g++编译命令拆解一下,就是:

g++ -c test.cpp -o test.o
g++ test.o -o test

拆解以后,再落实到makefile中:

test: test.o
g++ test.o -o test
test.o: test.cpp
g++ -c test.cpp -o test.o

再重新跑一下:

PS D:\Desktop> make
g++ -c test.cpp -o test.o
g++ test.o -o test
PS D:\Desktop> ./test
Hello, makefile.
PS D:\Desktop> ls
目录: D:\Desktop
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2023/5/30 22:06 79 makefile
-a---- 2023/5/30 21:54 133 test.cpp
-a---- 2023/5/30 22:06 56882 test.exe
-a---- 2023/5/30 22:06 2399 test.o
PS D:\Desktop>

好的,大致结果就这样,完成命令的拆解,落实到makefile中做成命令集,然后执行make命令,托管这些东西给make。另外就是在这个过程中,会生成很多中间文件,这些个文件对于最后发布来说,基本可以说是没啥用的了,对于这一类软件,可以给一个清理操作:

clean:
rm *.o

因为上面的test.o是不需要的,最多就研究的时候会用到,所以就rm了,rm是linux下命令删除命令,在makefile声明了clean操作以后,clean就像make的子命令,执行make clean就可以进行里面的rm *.o一系列命令。不过毕竟是linux命令,在windows的使用还是有所差异,所以这里没有执行成功。

简单讲解,一个makefile是编译命令的集群,最上层是最终结果和其依赖,而要得到这些个依赖,就要一层一层找下去,直到最早那个源文件,然后从最初的命令开始执行,逐层套娃,一直到最上面,才是需要的结果文件。但如果还定义了诸如clean的操作,在编译完成后,也是会进行对应处理的。

深入一点

从一个代码文件到一个可执行文件,可以拆解成四个步骤,从这个拆解的过程来看软件发展,一个发行的可执行文件,在代码不断耦合解耦的过程中,就有了各种各样的依赖;其中编译四步中的链接的静态库和动态库的拆分,也就使得这些个发行软件有了各种各样的依赖,所以有时候一个软件安装了以后就是这样的:

软件安装目录样式

一个发行软件,往往像上面那样,需要各种依赖,在windows中动态库静态库算是应用最泛的了吧,常常能在某某软件安装完以后,点击运行,当当!缺失某某DLL依赖,需要去微软官网去下载补丁跑一圈才能用。而且很多程序员都知道大融合时代,用不了你的代码那我就直接用你的可执行软件,再加上一个公司发行一个软件,公司内不同部门做不同部分最后拿出那么几个动态库静态库或者可执行文件,然后层层依赖,就成了一个庞大的体系。难以理解?就像现在的各种中间件一样,一个mysql数据库,主打的就是关系型数据的存储,它本身就是一个软件,但它对外提供服务,各种软件都可以使用它来作为信息存储,这样就是软件间的依赖。

回归到编译过程中,为了最后形成这样的动态库或静态库又或者可执行文件的依赖,就需要有序地执行编译,这样的层层依赖,在代码中也有,.h头文件进行声明,但实现却是在两个不同的.cpp文件,如下:

交叉依赖

如上,声明在a.h头文件的一些变量、函数或者类可以分别定义实现在a.cpp和b.cpp中,而b.h头文件的一些声明可以定义实现在b.cpp当中,这就是一个交叉依赖,a.cpp中的函数实现依赖于a.h中的声明,而b.cpp同时依赖于a.h和b.h,对于这样的几个文件,因为要实现商业保密,所以把a.cpp编译成动态库,把b.cpp编译成静态库,再给个执行主体的main,实现起来就成了下面这样:

test:main.o, libba.a, a.so
g++ main.o liba.so libb.a -o test -lb -L .
libb.a: b.o
ar crs libb.a b.o
liba.so: a.o
g++ -shared a.o -o liba.so
main.o: main.cpp, a.h, b.h
g++ -c main.cpp -o main.o
b.o: a.h, b.h, b.cpp
g++ -c b.cpp -o b.o
a.o: a.h, a.cpp
g++ -c -fPIC a.cpp -o a.o
clean:
rm *.o

上面只是简单的构思,具体见仁见智吧。要注意的是,对于一个工程项目,很多东西如果推倒重来,是很麻烦的事,如果你上面的项目只因为修改了一下a.cpp中fun1的内部实现,然后执行make命令的时候就全部生成一次,那这样是很耗费时间的,所以make聪明的一个地方在于,它会根据文件时间戳来决定某文件是否有变更,从而把依赖此文件的一系列文件进行重新编译,相对于整体来说,这样会很节省时间。

有时候头文件进行修改,然后进行make,但发现并没有生效,可能是因为在makefile中并没有把这个头文件添加到依赖文件列表中,所以它就不把这个文件视为需要检查的文件队列里。

添加点东西

前面的就一个简单的流程,但简单的流程走下来,总有时候会有稀奇古怪的问题,所以需要检查一下情况的进展,这也是代码中添加日志输出的原因,而makefile是命令集,在某个执行的时候也可以使用echo命令来输出重要信息,用过shell脚本的应该有所了解。除此以外,还可以添加变量:

aa = hello
something:
echo $(aa)

比如这样,定义了一个值为hello字符串的aa变量,然后在something(名字无所谓,反正不同),在命令处执行echo把aa变量进行输出,结果就成了这样:

PS D:\Desktop> make
echo hello
hello
PS D:\Desktop>

这种操作可以用在设置参数部分,比如提前设定某某长长的命令执行参数为某个变量,就像一个别名,这样执行的时候就直接调用这个命令加这个变量即可。作用上类似cpp中的常量。

不同的赋值

上面的赋值只有一个的情况下,看不出来什么特别的,但如果出来多个赋值变量,就会出错了:

tst=aa
tst1=$(tst)
tst=bb
all:
echo $(tst1)

上面按照日常我们的各种语言的顺序来看,应该都是tst1被赋值为aa字符串才对,但实际上并不是这样:

PS D:\Desktop> make
echo bb
bb
PS D:\Desktop>

因为这个是一个延迟赋值,它是在makefile运行时才会赋值,但这个解释让人更懵,运行时再赋值?这么说就是先运行了tst=bb才运行的all中命令?所以可以确定它连赋值都是逆序,从下而上的不?不过记住这个赋值有延迟就够了,要正常赋值,可以使用:=,修改一下:

tst=aa
tst1 := $(tst)
tst=bb
all:
echo $(tst1)
PS D:\Desktop> make
echo aa
aa
PS D:\Desktop>

这样就有比较正常的结果了。除了延迟赋值=,立即赋值:=,还有着空赋值?=,这种是变量没有给设置值只是一个空变量的时候就赋值,不然就保持原值:

tst=aa
tst1 ?= $(tst)
all:
echo $(tst1)
PS D:\Desktop> make
echo aa
aa
PS D:\Desktop>

这里因为一开始tst1就是空的,所以空赋值就存了tst的aa的值,如果它一开始就已经赋值,那结果就不一样了:

tst=aa
tst1 ?= $(tst)
tst=bb
all:
echo $(tst1)
PS D:\Desktop> make
echo bb
bb
PS D:\Desktop>

这一种可以用来在工程文件中,设置一个不确定是否存在的变量,如果有了那就按照原来的值来,没有就用上新设置的值,空赋值就是这么个使用。

特殊的自动变量

有那么几个自动变量,比如$<表示第一个依赖文件:

file: file.a, file.b
echo $<
file.a:
file.b:

照理来说,这样的语法是没问题的,但它一直报错说没有生成file.a或者file.b的rule,删去这部分以后也有问题,同样的情况在linux中也出错,看来就算是实验还需要有确切文件存在?那接下来就简单记录那么些个特殊变量和使用样式就好:

$^ 表示所有依赖,使用样式如下:

test: test.cpp
echo $^
g++ $^ -o test

$@ 表示目标:

test: test.cpp
echo $@
g++ $^ -o $@

总的来说,makefile的使用,就会类似一个shell脚本,里面都是命令集,跟随特定语法的指示下有序执行。常见的是一个递归关系去确认整个依赖树,然后递归执行。

自动推导

在makefile中有很多自动隐含的规则,就是自动推导:

test: test.o
g++ test.o -o test

如上,并没有显式表示test.o的依赖,在测试中我也只保留一份test.cpp文件,上面的依赖规则却指明需要test.o的参与,所以make自动推导test同名的cpp文件,生成test.o文件,再来生成最后的test文件,这种自动隐藏的规则很危险,因为人的想法没有那么直接,在一个执行过程中往往会因各种原因而出现千奇百怪的结果,所以还是需要进行显式确定。这也是在c/cpp编程中,各种限制自动推导的原因吧。但高手会用还是会用,个人能明了写清就已经是最大追求,看懂当然是必须。

有用的VPATH

前面的实验都是在一个目录下进行实验,而makefile默认会在当前目录下去找寻依赖,但一个c/cpp项目,往往其下有着各种各样的包,当中要生成奇奇怪怪的各种目标文件,这也是很多github发行包,使用make安装的其下各种目录都有着它独特的makefile的原因。但这是泾渭分明的情况,有时候编译的时候需要夸目录去寻找依赖,这就需要使用VPATH了:

VPATH=src:../test

以上表示VPATH所在makefile,标明依赖路径除了当前所在目录,还可以去当前目录的src目录和父级目录的test目录下去寻找依赖,因为linux下的分割符是冒号:,所以这里使用冒号分割两个路径。还可以进行匹配,不过上面的是VPATH大写的变量,下面的是vpath关键字:

vpath %.cpp src

如上表示在当前src目录下所有.cpp都是依赖文件,%是通配符用法,命令中使用广泛,表示0或多个字符。当前的使用都是设置,vpath单个出现就是表示前面的vpath的设置都清除掉的意思。

条件分支

很多定义了语法的,都会定义条件分支,makefile语法也不例外,它的条件分支的语法样式如下:

ifeq stat
exp
else
exp1
endif

如上,ifeq开启语句块,endif记性语句块闭合,就这样。

一些内置函数

首先,在makefile中函数的使用需要以$起头,然后隔一个空格再用括号或者大括号把函数名和参数给包圆,内部参数列表不会再用括号括起来进行表示(考验眼力?),常用的内置函数有subst字符替换函数,patsubst通过通配符确定规则的字符替换函数,去除空格的strip函数,查找字符串的findstring函数,还有filter、filter-out、sort、word、wordlist等等。举例实在太多,实际用的就那么几个,直接查资料吧。

来一个相对正式点的例子

CC=g++
CFLAGS=-c -Wall
LDFLAGS=
SOURCES=main.cpp hello.cpp
OBJECTS=$(SOURCES:.cpp=.o)
EXECUTABLE=hello
all: $(SOURCES) $(EXECUTABLE)
$(EXECUTABLE): $(OBJECTS)
$(CC) $(LDFLAGS) $(OBJECTS) -o $@
.cpp.o:
$(CC) $(CFLAGS) $< -o $@
clean:
rm -rf $(OBJECTS)

上面首先是定义了CC、CFLAGS、SOURCES、OBJECTS、EXECUTABLE变量,也置空了LDFLAGS以待后续需要,all标明了项目中所有涉及文件。

$(EXECUTABLE): $(OBJECTS)
$(CC) $(LDFLAGS) $(OBJECTS) -o $@
.cpp.o:
$(CC) $(CFLAGS) $< -o $@

这里则是命令的执行过程,最下面的是生成目标文件,上面的是生成可执行文件,后续还定义了clean清理。

二、cmake来了

作为跨平台的开源的一个构建系统,cmake的使用在linux还是很普遍的,虽然在windows上用的更多,因为GUI嘛。使用前,首先是安装,在windows里面的话,去官网下载就完了,一键安装就完了:

官网地址

在linux里面也是apt或者yum一键搞定:

# ubuntu
sudo apt update
sudo apt install cmake

嗯。。。。。。好像centos下要用下载源包来编译安装,好吧,那就不记录这步了。安装好以后,还是习惯命令行来查看版本:

PS D:\Desktop> cmake --version
cmake version 3.25.0-rc4
CMake suite maintained and supported by Kitware (kitware.com/cmake).
PS D:\Desktop>

2.1 简单CMakeList

和make一样,cmake也有着自己的独特文件CMakeList.txt(似乎大小写要明确),所以关注一下cmakelist.txt文件语法,拿起前面的简单例子,来个开门红吧。

#include <iostream>
int main(){
std::cout << "Hello, cmakefile." << std::endl;
return 0;
}

简单例子重申,然后是CMakeList文件:

project(test)
add_executable(test.exe test.cpp)

然后在当前只有test.cpp和CMakeList.txt文件的项目目录下,创建build目录(因为cmake采用out-of-source方式来进行构建,把中间产物和源文件进行分离),然后执行cd build;cmake ..生成一大堆的中间文件在build中,然后在build目录下进行make。结果失败了,因为这次实验是在windows系统,所以它没有生成make需要的makefile,而是自动生成了MSVC项目。所以这里要明确指定版本:

cd build;cmake .. -G "Unix Makefiles"
make

这样就生成默认可执行文件了,不过因为CMakeList指定了可执行文件名为test.exe,所以它又自动加了后缀成了test.exe.exe,嗯。。。。。。问题不大。

2.2 cmake的拓展

整理一下,cmake的使用,是控制项目有序生成对应系统平台的build,windows下是生成比较标准的vs解决方案,嗯所以很多时候用cmake以后还是用vs打开来编译一下;linux下则是专门生成makefile(当然windows下指定参数来生成也是可以),后面再make就行。

把上面的简单例子进行一下切割,拆出输出函数为一个库,然后在main中调用:

#include <iostream>
#include <string>
void hello();
void sayHi(const std::string& name);

函数实现:

#include "../include/hi.h"
void hello(){
std::cout << "Hello!" << std::endl;
}
void sayHi(const std::string& name){
std::cout << "Hi, "<< name << "!" << std::endl;
}

重要的调用:

#include "include/hi.h"
int main(){
std::string a = "Jack";
hello();
sayHi(a);
}

把头文件和实现文件进行切割,分别放在include目录和src目录,然后把main放在根目录下,项目结构如下:

D:\DESKTOP\TEST
│ CMakeLists.txt
│ main.cpp
├───include
│ hi.h
└───src
hi.cpp

然后简单编写一下CMakeList:

# cmake版本要求
cmake_minimum_required(VERSION 3.0)
project(test)
# PROJECT_SOURCE_DIR变量是cmake命令执行时的执行目录
message(STATUS "项目路径:${PROJECT_SOURCE_DIR}")
# 添加include目录路径
include_directories(${PROJECT_SOURCE_DIR}/include)
# 查找src目录下所有cpp文件为一个列表,记录在全局变量SRC_LIST中
file(GLOB SRC_LIST ${PROJECT_SOURCE_DIR}/src/*.cpp)
add_executable(test main.cpp;${SRC_LIST})

如上,在cmakelist中,#表示单行注释,多行注释可以使用#[[]]来进行括起来,其他的都如注释所言,比较明了吧,然后,还是这个执行:

# 创建目录并切换进去
mkdir build;cd build
cmake .. -G "Unix Makefiles"
make

然后就可以在当前路径查看到可执行文件了,执行一下:

PS D:\Desktop\test\build> ./test
Hello!
Hi, Jack!

比较明了,除此以外,还可以通过set命令设置宏EXECUTABLE_OUTPUT_PATH的值,可以设定为项目路径的bin目录,这样可执行文件就会生成在其中了:

...
set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin)
...

然后执行以后就可以在bin目录查看可执行文件了,嗯,如果没有bin目录,它会自行创建。不过,上面的方式是直接暴露所有src的做法,很多时候,各种实现都是以静态库或者动态库的形式出现,所以下面再添加一下静态库和动态库的一个库的实验。

2.3 静态库和动态库

在这一部分,这两种库的创建可以用简单的语句来进行,只需要进行库名和依赖文件的确定就可以了,比如要生成静态库:

...
add_library(hi STATIC ${SRC_LIST})
# add_executable(test main.cpp)

如上,注释掉可执行文件的生成,使用add_library确定生成库名为hi,STATIC确定生成方式是静态库文件,后面SRC_LIST全局变量是依赖,然后去cmake然后make,就在build目录下得到名为libhi.a的静态库文件了,当然,这是linux形式的,如果要进行windows形式的静态库文件生成,可以引入vs帮忙,不过这里不介绍。然后引入动态库:

add_library(hi SHARED ${SRC_LIST})
# add_executable(test main.cpp)

同样的步骤,把参数STATIC换成SHARED即可,然后继续cmake并且make,libhi.dll和libhi.dll.a就来了,毕竟是windows系统,还是有一些系统特色限制的,并没有像真正的linux那样生成.so的动态库文件。

不过上面的方式,都是在build中进行生成,所以要设定LIBRARY_OUTPUT_PATH宏来设置静态库和动态库的生成路径,重新走一遍上面的步骤,就可以在项目目录的lib目录下找到需要的静态库或动态库文件。

2.4 链接库文件

静态库和动态库,都是拿来用的嘛,接着前面的内容,把静态库或动态库分别进行一次链接,生成可执行文件。首先的是链接静态库,需要用到的是link_libraries进行链接,link_directories指定第三方库:

# 指定外界第三方库文件生成路径
set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/lib)
...
add_library(hi STATIC ${SRC_LIST})
# 指定链接路径
link_directories(${PROJECT_SOURCE_DIR}/lib)
# 链接hi第三方静态库
link_libraries(hi)
add_executable(test main.cpp)

然后到bin目录下去执行test,无错,实验通过。然后是动态库,使用target_link_libraries来进行链接,同样使用link_directories来引入库路径:

set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/lib)
...
add_library(hi SHARED ${SRC_LIST})
# 指定链接路径
link_directories(${PROJECT_SOURCE_DIR}/lib)
add_executable(test main.cpp)
# 指定动态链接的库
target_link_libraries(test hi)

如上,就完成了动态库的链接了,不过正如同上面的动态库生成不够完美那样,这里的可执行文件的执行也是有问题,毕竟是linux的样式,所以执行以后就闪退了,为了解决这个办法,把可执行文件和动态库放一起,就可以了。

更多文章记录可以看这个

posted @   夏目&贵志  阅读(241)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· Vue3状态管理终极指南:Pinia保姆级教程
点击右上角即可分享
微信分享提示