Golang有go mod、Python有pip、Java有maven。但C语言没有这么好用的包管理工具。当然Conan大概可以算是一个,但其也有自身的局限性,使用起来并不简单。
这就导致我们在写C代码的时候,老是要把心思放在怎么构建项目上。比如有一个项目,结构如下
src
- tcp_connect.h
- tcp_connect.c
- event.h
- event.c
- utils.h
- utils.c
- main.c
有一些C语言经验的人都知道,上面的代码,如果我们手敲命令编译的话,大概会是下面这样的:
# gcc main.c tcp_connect.c event.c utils.c -lthread -o server
上面还只有4个c文件,如果有成百上千个,每次编译都要手敲一遍这显然是不能接受的。
解决上面的问题也有很多方案,比如使用Makefile。
这篇文章我们介绍项目构建工具CMake,至于CMake是什么,怎么运行命令,有哪些参数,这篇文章也都不会讲。这些显而易见的资料太多了,使用搜索引擎,或者效率更高的ChatGPT很容易搞明白。
CMake的原理其实也很简单,就是通过特有的语法规则最终生成对应的Makefile文件,然后通过自带的工具链进行各种操作。可以简单理解CMake是对Makefile的一种封装(但要注意CMake不仅仅可以生成Makefile)。
为什么要学CMake?
首先,CMake现在是很多项目首选的项目构建工具。其次,目前很多开发工具,比如VSCode,Clion都支持使用CMake构建项目。
最后,CMake可以使我们更专注于使用C语言写代码,而不用为项目构建分心,我个人觉得这对于初学C语言是非常有好处的。
这里再啰嗦几句,现在很多课程都在强调写C语言要学会使用vim,甚至有些更离谱,把会不会用vim和能不能写C代码混为一团。我不否认vim的强大,但对于初学者来说。如果学习C语言的前提是要先学vim,很多人在学习vim的时候可能就放弃了。所以,我们应该把这个顺序倒过来,先使用VSCode和Clion这类工具快速写出东西来培养自己的编程感觉,再去学vim。这也是为什么要先有CMake这篇文章的原因,有了CMake这个工具之后,我们只要有一些语法基础,再配合使用ChatGPT这类AI工具便可以写出比较完整的小项目了。学习编程语言的最终目的不就是要写项目吗?
我们在构建一个多文件项目的时候,通常关心的有两个问题。第一,怎么引入我们自己写的代码?
第二,怎么引入别人写的代码?
自己写的代码不难理解,对于别人写的代码,它们通常包括我们从Github或者其它平台上下载过来的别人写好的代码,有一堆h和c文件。另外还有一类比较重要的就是操作系统中自带的或者我们自己安装的动态库和静态库,比如多线程pthread动态链接库。
这里以 C语言最最最核心语法 文章里迷你http服务程序为例,原来的程序写到了一个文件里,显然不利于扩展。所以,我们将代码拆成了若干个文件,至于为什么要这么拆后面有机会再讲,下面是拆完之后的代码结构:
benggee@benggee:~/app/c-program/cmake$ tree .
├── build
│ └── README.md
└── src
├── CMakeLists.txt
├── common.h
链接多线程动态库pthread
├── http_response.h
├── main.c
├── reader.c
├── reader.h
├── tcp_server.c
├── tcp_server.h
├── thread_pool.c
└── thread_pool.h
这个代码可以在github上面去下载,地址:
https://github.com/benggee/c-program
代码没有做改动,只是将原来放在main中的方法拆分到了不同的源文件中。
这里为了方便大家拿到的代码匹配当前示例,我在每一步都打了tag,这里的tag是v1.0你可以下载对应的源代码。
我简单介绍一下目录结构,在c-program项目下,新建了一个cmake目录,这篇文章所有的示例都在cmake下。cmake中有两个目录,build和src。build目录用于保存我们构建的结果,src存放源代码,这里我将main.c和其它拆出的文件都放在了src目录下。
在src目录下,有一个CMakeLists.txt,这便是cmake要用到的模板文件,内容如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
add_executable(http-server main.c
http_response.c
reader.c
tcp_server.c
thread_pool.c)
target_link_libraries(http-server pthread)
上面就是一个CMakeLists.txt文件,cmake运行的时候会去指定的目录找CMakeLists.txt文件。如果不指定路径,会尝试从当前目录下寻找CMakeLists.txt。然后加载其中的指令运行。上面的CMakeLists.txt的意思解释如下:
设置CMake版本号
cmake_minimum_required(VERSION 3.15)
设置C语言标准
设置项目信息
project(http-server VERSION 1.0 LANGUAGES C)
这里表示项目名叫http-server,版本号是1.0,编程语言是C语言。
指定可执行文件
add_executable(http-server main.c
http_response.c
reader.c
tcp_server.c
thread_pool.c)
这里的意思是通过http-server目标文件后面的c文件编译生成一个可执行程序。
链接多线程静态库pthread
target_link_libraries(http-server pthread)
由于我们使用到了多线程库pthread,所以需要连接pthread库才能正常运行,这一行相当于下面这条命令的-lpthread,后面会详细说明。
gcc main.c -o wechat-demo -lpthread
好了,就这么简单几行,你可以试着把代码下载下来,然后依照build目录下README.md的说明执行一下。执行完之后,在build目录下应该是下面这样的:
benggee@benggee:~/app/c-program/cmake/build$ tree .
.
├── CMakeCache.txt
├── CMakeFiles
....
└── http-server
其中,http-server就是我们编译好的可运行的二进制文件,我省略了其它的文件目录,这里暂时先不用管。
相关视频推荐
从程序编译到掌握 cmake 项目构建工具
手把手教你写代码,linux c/c++后端开发的9个实战项目
2023年最新技术图谱,c++后端的8个技术维度,助力你快速成为大牛
免费学习地址:c/c++ linux服务器开发/后台架构师
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
CMake的核心逻辑
CMake的核心逻辑其实就是一连串的命令操作一连串的变量。最终生成对应的项目构建文件,本文中是Makefile。
命令(Command)
上面的例子中
cmake_minimum_required()、set()、project()、add_executable()、target_link_libraries() 在CMake中都叫命令。命令可以小写,也可以大写,例如:
CMAKE_MINIMUM_REQUIRED(VERSION 3.15)
# 或者
CMAKE_minimum_required(VERSION 3.15)
这两种写法都是合法的,但建议最好要么全小写,要么全大写,我个人比较喜欢小写。
变量(Variable)
变量是区分大小写的,变量可以使用set()命令赋值,也可以当作参数传给命令,例如:
set(CMAKE_C_STANDARD 11)
上面的代码意思是将CMAKE_C_STANDARD 变量传给set命令,set命令将其赋值为11。
命令的参数之间使用空格或者分号分隔,例如:
set(CMAKE_C_STANDARD 11)
# 或
set(CMAKE_C_STANDARD;11)
上面两种传参方式都是可以的。当然,假如参数里的值本身就有空格或者分号,那我们需要给其加上引号,例如:
set(TEST_VARIABLE "A TEST")
# 或
set(TEST_VARIABLE "A;TEST")
如果我们要使用变量,可以使用${},例如:
${TEST_VARIABLE}
为了方便调试,我们先引入一个message()命令,它可以打印信息,比如,我们将上面的变量打印一下,如下:
message(${TEST_VARIABLE})
然后我们运行一下
benggee@benggee:~/app/c-program/cmake/build$ cmake ../src
A TEST
-- Configuring done
-- Generating done
...
可以看到变量TEST_VARIABLE的值就被打印出来了,同时也验证了使用${}来引用变量的值。
内部变量
一般,CMake的内部变量都是以PROJECT_和CMAKE_打头并且大写。所以,我们在定义自定义变量的时候要尽量避免和内部变量冲突。下面是一些常用的内部变量:
我所在的目录是下面这样的:
benggee@benggee:~/app/c-program/cmake/build$ pwd
/home/benggee/app/c-program/cmake/build
下面是上面各个变量输出的值:
PROJECT_NAME:http-server
PROJECT_SOURCE_DIR:/home/benggee/app/c-program/cmake/src
PROJECT_BINARY_DIR:/home/benggee/app/c-program/cmake/build
PROJECT_VERSION:1.0
PROJECT_VERSION_MAJOR:1
PROJECT_VERSION_MINOR:0
CMAKE_CXX_STANDARD:11
当然,CMake还有很多其它的内部变量,这里就不一一列举了,有兴趣可以参考CMake的文档,我把链接贴在这里:
https://cmake.org/cmake/help/latest/manual/cmake-variables.7.html
有了上面的基础之后,我们接着要来解决几个问题
头文件和main.c不在同一个目录怎么办?
上面的例子中,我们将所有的头文件和main文件都放在了一起,在main.c中可以直接incude,对于小型的项目似乎也没什么问题。但对于中大型项目在多人协作的情况下这样有一个问题,就是代码的复用性变差了。假如其它项目也有用到我们的代码,就得把这一堆文件复制过去。这样后期假如在某个文件里发现了一个bug,我们得一个个找人问他代码在哪里,简直就是噩梦。
所以我们想,是不是可以把公共的部分抽离出来,比如像下面这样:
├── build
│ └── README.md
├── lib
│ ├── common.h
│ ├── http_response.c
│ ├── http_response.h
│ ├── reader.c
│ ├── reader.h
│ ├── tcp_server.c
│ ├── tcp_server.h
│ ├── thread_pool.c
│ └── thread_pool.h
└── src
├── CMakeLists.txt
└── main.c
我们将除了main.c以外的文件都放到lib目录下了,因为我们觉得lib下的代码都可以被其它人调用。
接着,我们将main函数中引入头文件的路径改了一下,如下:
#include "../lib/tcp_server.h"
#include "../lib/thread_pool.h"
#include "../lib/reader.h"
#include "../lib/http_response.h"
int main(int argc, char *argv[]) {
...
}
上面的代码虽然能用,但不够灵活,假如有一天我们将lib文件移动到了其它目录下,这个代码就不能正常运行了。最好的方式是main引入头文件的方式不变,比如像下面这样:
#include "tcp_server.h"
#include "thread_pool.h"
#include "reader.h"
#include "http_response.h"
int main(int argc, char *argv[]) {
...
}
在CMake中,我们可以为目标文件(编译的二进制文件、或者动态库和静态库)指定头文件目录,如下:
include_directories(${CMAKE_SOURCE_DIR}/../lib)
上面CMAKE_SOURCE_DIR变量我们在上面讲变量的时候有提到过,可以理解为CMakeLists.txt所在的目录,当然这个变量也是可以通过set命令设置的。
修改后的CMakeLists.txt如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
include_directories(${CMAKE_SOURCE_DIR}/../lib)
add_executable(http-server main.c
../lib/http_response.c
../lib/reader.c
../lib/tcp_server.c
../lib/thread_pool.c)
target_link_libraries(http-server pthread)
include_directories可以传多个路径,中间用空格分开。这样main函数中引入头文件还是和之前一样,如下:
#include "tcp_server.h"
#include "thread_pool.h"
#include "reader.h"
#include "http_response.h"
int main(int argc, char *argv[]) {
...
}
但是,include_directories命令有一个问题,它设置完会应用到所有的目标文件。有时候我们在编译多个目标文件的时候,可能希望不同的目标文件的头文件目录相互隔离。这时候我们可以使用另外一个target_include_directories命令,下面是改写后的CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
add_executable(http-server main.c
../lib/http_response.c
../lib/reader.c
../lib/tcp_server.c
../lib/thread_pool.c)
target_include_directories(http-server PUBLIC ${CMAKE_SOURCE_DIR}/../lib)
target_link_libraries(http-server pthread)
要注意的是,include_directories可以放在add_executable命令之前,而target_include_directories不可以。
target_include_directories要比include_directories复杂一些,多了两个参数。
第一个参数是我们指定的目标文件。
第二个参数是权限,可取值为:INTERFACE、PUBLIC、PRIVATE。
目标文件有INCLUDE_DIRECTORIES和INTERFACE_INCLUDE_DIRECTORIES两个属性,前者是对内头文件目录,只给自己用。后者是对外头文件目录,给别人用的。第二个参数不同取值的效果如下:
-
INTERFACE 相当于只会搜索 INTERFACE_INCLUDE_DIRECTORIES目录
-
PUBLIC 相当于两个目录都会搜索
-
PRIVATE 相当于只会搜索INCLUDE_DIRECTORIES
对于上面的例子,使用PUBLIC和PRIVATE都可以,但使用INTERFACE 不行,你可以自己试一下。
第三个参数是头文件路径,和include_directories是一样的。
target_include_directories也支持多个目录。只不过,每个目录都要设置一个指定权限,其原型如下,你一看就明白了。
target_include_directories(<target>
<INTERFACE|PUBLIC|PRIVATE>
[items1...]
[<INTERFACE|PUBLIC|PRIVATE>
[items2...]...]
好了,这样我们就将头文件和目标文件分离开了。
但是,你可能注意到了,上面add_executable命令里面把所有的c文件都写进去了,看起来不是很优雅。当c文件变多的时候,这个地方也要跟着改。为了解决这个问题,这里我们引入一个file命令,它可以将文件名收集到一个变量里,然后在add的时候直接用,下面是修改过的CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
file(GLOB SRC_FILE ${CMAKE_SOURCE_DIR}/../lib/*.c)
add_executable(http-server main.c ${SRC_FILE})
target_include_directories(http-server
PUBLIC ${CMAKE_SOURCE_DIR}/../lib)
target_link_libraries(http-server pthread)
看着是不是清晰了一些呢,截止到这里的代码我打了一个tag:v1.1,你可以下载下来自己试一下。
当然,除了上面的方法,还有一些其它的方法,比如接下来要讲的静态链接库和动态链接库。
静态链接库
在类Unix系统中,可以将代码打包成静态库之后提供给其他人用。在Linux中,如果没有指定静态库的路径,默认会依次从/usr/lib、/usr/local/lib中搜索。当然,也可以手动指定一个搜索路径,例如:
gcc -o main -L/home/my-libs main.c -lmylib
怎么制作一个自己的静态链接库?
首先,我们写一段代码,将代码封装在一个文件里,这和平时写代码没什么区别。这里为了简单,写了一个只包含一个方法的c文件,这个方法返回两个参数的和。如下:
// adder.h
int add(int a, int b);
// adder.c
#include <stdio.h>
#include "adder.h"
int add(int a, int b) {
return a + b;
}
然后我们使用gcc编译器,将这段代码编译成o文件,如下:
gcc -c adder.c -o adder.o
接着,使用ar命令,将o文件打包成静态链接库文件,如下:
ar rcs libadder.a adder.o
得到一个libadder.a文件,这就是我们的静态链接库。
接着,我们在main方法里使用使用一下add方法,如下:
#include "adder.h"
int main(int argc, char *argv[]) {
int ret = add(12, 22);
printf("ret: %d\n", ret);
return 0;
}
注意,在main入口文件中,我们引入了adder.h头文件,这是因为在adder.h头文件里面才有add函数的签名。
最后,就可以使用这个静态链接库了,如下:
gcc main.c -L./ -ladder -o main
当然,我们也可以将多个c文件打包到一个静态链接库,你可以自己尝试一下。
下面,我们尝试使用CMake来打包静态链接库。
前面我们将除了main文件之外的源文件都放到了lib目录中,这里我们在lib目录中也建一个CMakeLists.txt文件,用来将lib下的所有c文件打包成一个静态链接库,如下:
cmake_minimum_required(VERSION 3.15)
project(mylib)
file(GLOB SRC_FILES *.c)
add_library(mylib STATIC ${SRC_FILES})
前面两行没什么可以说的,我们使用file命令拿到所有c文件名。这里比较陌生的是add_library命令,这个命令可以用来打包静态链接库和动态链接库。我们先看一下它的原型、
add_libary(<name> [STATIC | SHARED | MODULE] [EXCLUDE_FROM_ALL] [<source>...])
第一个参数是要创建的库的名字。第二个参数是类型,STATIC为静态链接库,SHARED为动态链接库,MODULE先不管。
第三个参数是要打包的c文件,这里我们为了简单,又使用了file命令。
运行cmake会产生很多我们不关心的文件。为了不污染lib目录,我们在lib目录下创建一个mylib的文件夹,然后执行cmake,如下:
benggee@benggee:~/app/c-program/cmake/lib/mylib$ cmake ..
执行完之后会有一个libmylib.a文件,这就是我们打包的静态链接库,如下:
benggee@benggee:~/app/c-program/cmake/lib/mylib$ ls
CMakeCache.txt CMakeFiles Makefile cmake_install.cmake libmylib.a
静态链接库有了,我们怎么使用呢?
回到src目录下的CMakeLists.txt,还记得我们怎么链接pthread的吗?同样,我们使用target_include_directories命令,如下:
target_link_libraries(cmake PUBLIC /home/benggee/app/c-program/cmake/lib/mylib/libmylib.a)
修改后的CMakeLists.txt如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
add_executable(http-server main.c)
include_directories(${CMAKE_SOURCE_DIR}/../lib)
target_include_directories(http-server
PUBLIC ${CMAKE_SOURCE_DIR}/../lib)
target_link_libraries(http-server /home/benggee/app/c-program/cmake/lib/mylib/libmylib.a)
target_link_libraries(http-server pthread)
有两个小变化:第一,之前的file命令我们删掉了。因为所有依赖都打包成了静态链接库,理论上我们只需要链接静态链接库就可以了。
第二,在链接多线程库pthread之前加了一行链接我们自己的静态链接库,这里我们将路径写死了一个绝对路径,你需要改成你自己的路径。
要注意,pthread的链接要放在后面,这是因为在我们自己的静态链接库里有用到pthread。
好了,到这里静态连接库就搞定了。这里补充一下,这种链接pthread的方式在有些情况下可能会报错。我们可以使用另外一种方式链接pthread动态链接库,如下:
find_package(Threads REQUIRED)
target_link_libraries(http-server PRIVATE Threads::Threads)
和前面一样,截止现在的代码我打了一个tag:v1.2,你可以下载下来自己动手试一下。
动态链接库
这里,还是以上面的adder为例,使用gcc我们可以直接编译成so文件(一般指动态链接库),如下:
gcc -shared -fPIC adder.c -o libadder.so
接着,我们编译main文件,如下:
gcc main.c -L. -ladder -o main
编译好了之后,可以试着运行一下,如下:
benggee@benggee:~/app/c-test$ ./main
./main: error while loading shared libraries: libadder.so:
cannot open shared object file: No such file or directory
可以看到,报了一个错,意思其实就是说动态链接库找不到。
这是因为,我们没有指定动态链接库的时候,在Linux下,系统会一般默认依次从/lib、/usr/lib中查找,由于我们刚刚编译好的so文件在这两个目录中都没有,所以就报错了。我们可以将自己的动态链接库拷贝到/lib或者/usr/lib中可以解决这个问题。当然,还有一个方案,有个优先级更高的环境变量LD_LIBRARY_PATH,我们可以偿试在运行之前先设置一下这个环境变量的值,如下:
LD_LIBRARY_PATH=./ ./main
这样就可以正常运行了
在CMake中,打包动态库和静态库使用的是一个命令,链接和静态链接库也是同一个命令。搞明白了静态链接库是怎么打包的之后,动态链接库也就简单了。在lib目录下的CMakeLists.txt我们稍微修改一下,如下:
cmake_minimum_required(VERSION 3.15)
project(mylib)
file(GLOB SRC_FILES *.c)
add_library(mylib SHARED ${SRC_FILES})
唯一的变化就是将add_library命令里的STATIC改成了SHARED,是不是很简单?
接着我们同样,在lib目录下创建一个mylib的目录,运行cmake,如下:
benggee@benggee:~/app/c-program/cmake/lib/mylib$ cmake ..
然后使用cmake build构建动态链接库,如下:
benggee@benggee:~/app/c-program/cmake/lib/mylib$ cmake --build .
运行完之后,在当前目录就有一个libmylib.so的动态库文件了,如下:
benggee@benggee:~/app/c-program/cmake/lib/mylib$ ls
CMakeCache.txt CMakeFiles Makefile cmake_install.cmake libmylib.so
接着,我们修改一下src目录下的CMakeLists.txt,如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
add_executable(http-server main.c)
include_directories(${CMAKE_SOURCE_DIR}/../lib)
target_include_directories(http-server
PUBLIC ${CMAKE_SOURCE_DIR}/../lib)
target_link_libraries(http-server
/home/benggee/app/c-program/cmake/lib/mylib/libmylib.so)
target_link_libraries(http-server pthread)
咦,怎么没什么变化?其实是有的哈,我们将原来的libmylib.a改成了libmylib.so,就这么简单。
接着我们可以进入到build目录试着编译一下,如下:
benggee@benggee:~/app/c-program/cmake/build$ cmake ../src
...
benggee@benggee:~/app/c-program/cmake/build$ cmake --build .
...
benggee@benggee:~/app/c-program/cmake/build$ ls
CMakeCache.txt CMakeFiles Makefile README.md cmake_install.cmake http-serve
可以看到,同样http-server也编译出来了。同样,代码我打了一个tag:v1.3
好了,到这里动态链连库和静态连接库的用法就搞明白了。当然,如果你对动、静态链接库不是很了解,而你又觉得很好奇这中间到底发生了什么?这里推荐一本书《程序员的自我修养》这本书有三位作者俞甲子、石帆、潘爱民。它的完整名字叫"程序员的自我修养—链接、装载与库",不要弄错了。
当然,等这个系列文章完结之后,我也会留出不限篇幅的文章来探讨C语言的底层实现,到时候也会讲到的。如果有兴趣,可以关注一下。
支持CMake子目录
我们上面讲了静态链接库和动态链接库在CMake中的打包和链接方法,但还是有些麻烦,我们要先进入到lib目录打包对应的库文件。然后回到build目录又通过CMake编译我们的程序。
有没有办法将这两步合成一步呢?答案当然是有的,这里我们再引入一个命令add_subdirectory,这个命令的作用就是跳转到子目录执行CMakeLists.txt。上面我们在lib目录下创建的CMakeLists.txt文件还是保留,但里面的内容我们作一点调整,调整后的内容如下:
file(GLOB SRC_FILES *.c)
add_library(mylib SHARED ${SRC_FILES})
target_link_libraries(mylib pthread)
设置cmake版本和项目名都不要了,在这里链接了pthread动态链接库。
接着我们修改一下src目录下的CMakeLists.txt文件,如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_C_STANDARD 11)
project(http-server VERSION 1.0 LANGUAGES C)
add_subdirectory(${CMAKE_SOURCE_DIR}/../lib
${CMAKE_SOURCE_DIR}/../lib/build)
add_executable(http-server main.c)
include_directories(${CMAKE_SOURCE_DIR}/../lib)
target_include_directories(http-server
PUBLIC ${CMAKE_SOURCE_DIR}/../lib)
target_link_libraries(http-server mylib)
我们使用add_subdirectory命令将lib目录添加为子目录,当CMake执行到这里的时候就会跳到lib目录下搜索CMakeLists.txt文件并执行。由于mylib库已经链接了phtread,所以这里我们就不用再链接了。
add_subdirectory命令第一个参数是要跳转的子目录,第二个参数是编译目标文件目录,也就是我们子目录编译的目标文件所在的目录(这里的目标文件是一个.so的动态链接库)。
接着我们通过cmake编译一下,如下:
benggee@benggee:~/app/c-program/cmake/build$ cmake ../src
...
benggee@benggee:~/app/c-program/cmake/build$ cmake --build .
...
benggee@benggee:~/app/c-program/cmake/build$ ls
CMakeCache.txt CMakeFiles Makefile README.md cmake_install.cmake http-server
这样,我们只在build目录下执行一遍CMake就编译好了。你可能好奇,我们lib目录下的动态连接库编译到哪去了。
编译完在lib目录下多了一个build目录,里面内容如下:
benggee@benggee:~/app/c-program/cmake/lib/build$ ls
CMakeFiles Makefile cmake_install.cmake libmylib.so
可以看到,打包好的动态链接库是在lib/build目录下。
我们前面在链接动态链接库的时候,使用了一个绝对路径来指定库的位置,而这里我们并没有指定路径,CMake是如何知道我们的mylib.so的位置的呢?
诀窍就在我们给add_subdirectory传的第二个参数,这个参数表示的是目标文件编译的位置,最终src下的CMake和lib下的CMake将两个目标文件目录一合计就能链接了。你可以试着把第二个参数删掉,再运行cmake,会得到一个错误,如下:
CMake Error at CMakeLists.txt:7 (add_subdirectory):
add_subdirectory not given a binary directory but the given source
directory "/home/benggee/app/c-program/cmake/lib" is not a subdirectory of
"/home/benggee/app/c-program/cmake/src". When specifying an out-of-tree
source a binary directory must be explicitly specified.
CMake在调用add_subdirectory命令的时候就已经将CMAKE_ARCHIVE_OUTPUT_DIRECTORY变量设置好了,在链接的时候就去这个里面找。
好了,到这里为止,添加CMake子目录就搞定了。上面的示例中我们使用的是动态链接库,你可以自己尝试改成静态链接库编译试一下。代码打了tag:v1.4
动态配置
很多时候,我们写的代码需要兼容多种不同的平台。比如,写一套代码既要在windows下运行,也要在Linux下运行。由于它们之间的api存在差异,管理起来就比较困难。比如epoll在windows下就没有。
在CMake中我们可以使用configure_file命令来动态替换配置,比如:
configure_file(config.h.cmake config.h)
configure_file会将config.h.cmake里面的内容进行替换,最终生成一个config.h文件。
我们在 C语言最最最核心语法那篇文章中实现了一个支持线程池的迷你HTTP服务,里面有一个控制开关线程池的宏,如下:
#ifdef WORKER_POOL_SIZE
struct worker_thread_context *ctx = malloc(WORKER_POOL_SIZE * sizeof(struct worker_thread_context));
for (int i = 0; i < WORKER_POOL_SIZE; i++) {
pthread_mutex_init(&ctx[i].mutex, NULL);
pthread_cond_init(&ctx[i].cond, NULL);
ctx[i].fd = -1;
pthread_create(&ctx[i].thread_id, NULL, worker, (void *)&ctx[i]);
}
#endif
只有定义了WORKER_POOL_SIZE才会创建线程池。原来我们要关闭线程池的话,只能删除或者注释掉这个宏。接下来我们使用CMake的configure_file命令对这个改造一下。
首先我们在lib目录创建一个config.h.cmake文件,在里面写上:
#cmakedefine WORKER_POOL_SIZE "@WORKER_POOL_SIZE@"
然后在lib目录下的CMakeLists.txt中写上:
file(GLOB SRC_FILES *.c)
add_library(mylib SHARED ${SRC_FILES})
target_link_libraries(mylib pthread)
if (${POOL_SIZE})
set(WORKER_POOL_SIZE ${POOL_SIZE})
endif ()
configure_file(config.h.cmake config.h)
POOL_SIZE是在运行cmake的时候传进来的参数,在cmake中,要传一个参数可以使用-D选项,比如:
cmake ../src -DPOOL_SIZE=100
我们判断POOL_SIZE是否设置,如果设置了,那就设置WORKER_POOL_SIZE变量。
然后运行configure_file命令,在config.h.cmake文件中,我们使用cmakedefine开头。这句的意思表示,如果cmake定义了WORKER_POOL_SIZE 变量,就定义一个WORKER_POOL_SIZE的宏,值就是WORKER_POOL_SIZE变量的值,注意理解这句话,有点绕。
替换之后,在lib的build目录下会生成一个config.h文件,里面内容如下:
#define WORKER_POOL_SIZE 100
然后,我们把common.h文件中的WORKER_POOL_SIZE宏给删掉,并引入config.h文件,如下:
...
#include "build/config.h"
这样,运行cmake的时候没有指定POOL_SIZE参数,config.h文件中就没有WORKER_POOL_SIZE这个宏。也就不会开启线程池了。代码tag:v1.5,你可以下载下来自己试一下。
你可以尝试使用configure_file来区分不同的操作系统,从而选择性的引入epoll头文件。如果你有ChatGPT应该是秒秒级就搞定了。
安装
项目构建完了之后,我们就需要将打包编译好的文件归位,放到我们精心安排的位置。比如二进制文件应该放在bin目录,动态链接库和静态链接库应该放在lib目录,配置头文件应该放在include目录。
在CMake中,install命令就是解决这个问题的。上面我们先来解决上面生成的config.h文件。这个文件是公共的,我们在cmake目录下再新建一个include目录,用来存放公共的h文件。
然后我们使用install命令,将config.h文件安装到inlcude目录,如下:
...
target_link_libraries(http-server mylib)
install(FILES ${CMAKE_SOURCE_DIR}/../lib/build/config.h DESTINATION ${CMAKE_SOURCE_DIR}/../include)
然后在build目录下运行安装,如下:
benggee@benggee:~/app/c-program/cmake/build$ cmake --install .
-- Install configuration: ""
-- Installing: /home/benggee/app/c-program/cmake/src/../include/config.h
这样,就将config.h安装到了include目录下了。
然后我们在src目录下的CMakeLists.txt下把include目录加到全局头文件目录中,如下:
...
project(http-server VERSION 1.0 LANGUAGES C)
include_directories(${CMAKE_SOURCE_DIR}/../include)
add_subdirectory(${CMAKE_SOURCE_DIR}/../lib
${CMAKE_SOURCE_DIR}/../lib/build)
...
然后修改lib目录下的common.h如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <pthread.h>
#include "config.h"
我们直接就可以包含config.h文件,不用加路径了。要注意的是,include_directories命令要在add_subdirectory之前,这是因为config.h头文件是被lib目录中的common.h依赖的。
然后我们将二进制文件和动态链接库文件分别安装在build目录的bin和lib目录,如下:
install(TARGETS http-server DESTINATION ${CMAKE_SOURCE_DIR}/../build/bin)
install(FILES ${CMAKE_SOURCE_DIR}/../lib/build/config.h DESTINATION ${CMAKE_SOURCE_DIR}/../include)
install(FILES ${CMAKE_SOURCE_DIR}/../lib/build/libmylib.so DESTINATION ${CMAKE_SOURCE_DIR}/../build/lib)
build目录下现在是这样的:
├── bin │ └── http-server └── lib └── libmylib.so
因为动态链接库的路径变了,所以我们修改一下src目录下CMakeLists.txt链接的路径,如下:
target_link_libraries(http-server PRIVATE ${CMAKE_SOURCE_DIR}/../build/lib/libmylib.so)
这样,我们就将各个文件归位了。代码tag:v1.6,你可以下载下来自己亲自跑一下。
补充:
install命令的原型如下:
# 安装目标文件
install(TARGETS <target> DESTINATION <div>)
# 安装文件
install(FILES <file> DESTINATION <dir>)
# 非目标文件的可执行文件
install(PROGRAMS <非目标文件的可执行文件> DESTINATION <dir>)
# 安装目录
install(DIRECTORY <dir> DESTINATION <dir>)
比较简单,相信你一看就明白。
总结
项目构建工具可以说是踏入C/C++协作编程的一个分水岭了,掌握了项目构建工具就可以进入到中大型项目的开发了,这在行内是必需要掌握的,可又是被很多人所忽略的。
这篇文章的目的是通过一条主线串起来一个项目实例,这个实例就是一个模板,后面当你想更深入学习C/C++的时候,这个模板可以快速让你得到一个扩展性很强的工程结构,让你专注于实现代码逻辑,而不是怎么去构建工程。
当然,CMake的内容还是非常多的,不可能在一篇文章里照顾到所有的细节。依然还有很多内容没有照顾到。这些内容就要靠大家自己去探索了,大家加油!
本文源代码的地址:https://github.com/benggee/c-program