Skip to content

Latest commit

 

History

History
1253 lines (934 loc) · 53.2 KB

File metadata and controls

1253 lines (934 loc) · 53.2 KB

项目管理

C/C++的项目管理可以称得上混乱,毕竟是两门相当古老的编程语言,可以说打从一开始就没考虑项目管理的问题,C/C++非常难以上手很大程度上也是拜这所赐.

我们可以将项目管理拆分为两个部分:

  • 包分发:即如何让别人可以找到你的包

  • 包调用:如何让程序可以使用找到的包.

包分发

包分发可以理解为3个方面的基本功能

  • 包的元数据管理,即发布者如何描述包的信息,让别人可以识别使用.以python为例在python中这个功能是由setup.cfg/setup.py完成的
  • 分发平台,即包发布后放在什么地方,以python为例在python中这个功能是由pypi平台或者自己搭建的pypi实例完成的.
  • 分发形式,即上传下载的内容如何编码解码,以python为例在python中这个功能是通常由zip包实现的(wheel和egg可以理解为带有元数据的zip包)

不用看C/C++在这三个方面都没有统一的方案. 传统c/c++的元数据管理就是依靠命名--C依靠所有函数和结构体等统一使用相同的前缀,C++则使用命名空间(本质上和c一样)而其他比如包的版本,说明等信息基本也就是靠说明文档和下载时的路径了;

传统c/c++包的分发平台通常就是操作系统发行版的包管理工具,比如ubuntu/debian的apt,alpine的apk等,他们安装好后会将包和头文件放在固定位置(/usr/lib,/usr/local/lib,/usr/include,/usr/local/include等).这些也是你在这些平台上安装编译工具后默认的依赖搜索路径.这种方式倒是很方便调用,但要管理依赖的版本或者要使用平台上没有的包又或者要控制包的分发范围(企业内部包管理)就比较尴尬了;

c/c++包的分发模式基本就两种:

  • 源码分发,这是golang的策略,C/C++的编译速度是很慢的,这条路因此相当难走
  • 二进制分发,就是apt,apkw这些包管理工具的默认分发形式,二进制分发对平台会有相当强的依赖,因此往往不能通用
  • 混合分发,像homebrew,它会先尝试下载二进制的分发版本,如果不行则会下载源码进行编译,多数非系统级分发方案都是这种模式

包调用

包调用则可以分为大致几个方面

  1. 环境隔离,即让项目的依赖不会影响其他程序和项目,以python为例在python中这个功能是由python的标准库venv实现的
  2. 包查询和包下载,即让使用者可以快速找到需要的包并下载下来作为依赖,,以python为例在python中这个功能是由pip实现的
  3. 依赖管理,即让这个包移植的时候可以直接通过依赖管理工具快速部署依赖,这个功能是由pip freezepip install -r实现的

不用看C/C++在这三个方面也没有统一的方案.

使用apt,apk这样的操作系统相关的依赖管理系统可以在包查询和包下载上非常方便,但这必定是全局安装的,因此完全没有环境隔离一说,如果要环境隔离,那就得每个依赖的项目下载源码到本地现编译,然后使用gcc/g++连接.

在编译部分我们就已经介绍过了c/c++的包调用就2种方式

  • 源码方式
  • 库方式(动态链接库/静态链接库)

但我们前面两节也已经演示过如何引用已经存在的包,即便使用makefile来定制编译过程这个纯手动档的依赖管理也是极其繁琐的.

另外要注意:

  1. C++可以直接调用C写的库
  2. C无法直接调用C++写的库,需要额外做一层包装,这个后面的章节中会介绍

通用的解决方案

很遗憾没有!毕竟历史太长了,很多遗留代码可能早已没人维护,但一些成年老程序依然在用.对于C/C++这种老古董来说历史包袱和历史遗产都是不得不面对的问题.但也不是完全无解,一个相对没那么痛苦的方案是使用cmake配合git服务来做项目管理.大体的思路是:

使用cmake描述项目和安装依赖,使用git服务保存项目源码.通用常用的依赖和工具依靠操作系统的包管理工具,而专用的希望做环境隔离的依赖则使用cmake从源码安装.

这个方案的好处是:

  1. cmake相对是铺的比较广的工具,github上的c/c++项目几乎都由cmake支持
  2. cmake是不少重要工具的指定编译辅助工具
  3. cmake本身是个嫁接工具,它的后端可以是makefile,可以是nijia等
  4. cmake提供了一定的跨平台能力和交叉编译能力

当然缺点也有:

  1. cmake语法反人类,非常不直观
  2. cmake的操作几乎都是黑盒,很难debug

但是没办法其他的方案相比而言还不如它因此本文也主讲这种方式

使用cmake管理项目

Cmake本质上也不提供流程化编译的功能,他其实是用来生成不同平台的不同编译工具的配置文件的,这些不同编译工具通常被称为其"后端",由于cmake只针对C/C++/Fortain/CUDA和汇编语言,所以会有一些额外的细化配置.

本文已最新版本的cmake 3.20为基准需要注意cmake的接口并不算稳定,因此本文的例子不保证向后兼容.

cmake的安装

cmake目前只支持x86-64平台下的windows,linux,macos系统以及arm平台下的linux和macos系统,基本覆盖了主流平台.同时它也提供了源码可以尝试在其他平台下编译安装.

在linux下

在linux下我们一般都需要额外安装cmake来使用,linux下可以直接从操作系统的包管理工具种安装

  • debian/ubuntu: apt install -y --no-install-recommends cmake(国内最好先使用sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list换源)
  • alpine: apk add cmake(国内最好先使用sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories换源)

这种方式好处是方便快速,但坏处是一般版本较低,比如debian buster上版本为3.13,因此更多的我们还是选择下载安装

cmake的下载页直接下载对应平台的.sh文件,然后使用命令./cmake-xxxx.sh --skip-license --prefix=/安装/目录即可,安装好后会是一个包含/bin的文件夹,将这个文件夹加入环境变量PATH中即可.

在windows下

windows下也是在上面的下载页,下载.msi后缀的文件,然后双击安装即可.安装完后同样将目录下的/bin的文件夹加入环境变量PATH

在macos下

mac下比较推荐使用homebrew安装(brew install cmake)

cmake的使用流程

cmake的使用流程有3步:

  1. 定义CmakeLists.txt文件用于描述项目信息,编译流程等
  2. 执行cmake [options] .为后端生成配置文件,通常称为配置时
  3. 用后端编译项目,通常称为构建时

cmake本身并不能构建我们的目标,它的作用是通过生成器生成构建器(也称作后端)可以用于构建的配置.cmake默认的后端是makefile,但我们也可以通过-G设置使用的生成器,常用的后端还有NinjaMinGW Makefiles等.实际上一旦cmake写好后生成什么后端并不重要,生成了配置文件后只要执行它就可以了.

cmake支持内部构建和外部构建,所谓内部构建就是在原项目根目录下直接构建临时文件,而外部构建则是指定一个目录将所有的临时文件都放到目录中.

比较推荐的是外部构建,因为它不会改变原有项目的结构,删起来也方便.cmake默认是内部构建,我们可以使用-B<目录路径>来指定外部构建的目录,然后进去外部构建目录执行后端.通常这个外部构建目录都会是项目根目录下的build文件夹

makefile作为后端

  • 编译

    make [VERBOSE=1]

    如果添加VERBOSE=1则会展示出编译时的具体命令

  • 安装(如果CmakeLists.txt中有配置install命令的话)

    make install
  • 测试(如果CmakeLists.txt中有配置add_test命令的话)

    make test
  • 清除编译过程中的中间结果

    make clean

ninja作为后端

  • 编译

    ninja
  • 安装(如果CmakeLists.txt中有配置install命令的话)

    ninja install
  • 测试(如果CmakeLists.txt中有配置add_test命令的话)

    ninja test

CmakeLists.txt配置语法

Cmake配置有两种风格:

  • 传统风格(directory风格),以大量设置变量的值为特征,基本属于过程式的配置风格
  • 现代风格(Modern Cmake),以Target为导向,有一定的面相对象特征

本文将只介绍现代风格的Cmake配置.

CmakeLists.txt是每个项目下的配置文件,它用于描述

  • 项目的元信息
  • 项目的依赖描述[可选]
  • 项目的编译配置
  • 项目的测试行为配置[可选],一般是目标为链接库时会有,这部分我们在后面的测试部分介绍
  • 项目的安装行为配置[可选]

通常上面三部分是按顺序写的,依赖描述部分根据是否有依赖选择要不要有.

而充cmake的语法就是用于配置上面目的的专用语言.

cmake语法的基本结构

cmake的语法可以解构为如下几个部分:

  1. 命令,内置的功能项目,每一个命令代表一项功能,cmake的最基本构成元素
  2. ,命令中填充的内容,用于指示命令的目标和使用资源等
  3. 变量,保存值的符号
  4. 消息,就是一般编程语言中的print或者log,用于提示,本质上是一条命令
  5. 逻辑语句,用于抽象分支和循环
  6. 函数和宏,用于抽象执行过程
  7. 模块,用于分发和代码复用

其他还有一些不太常用的包括:

  1. 生成器表达式
  2. 自定义操作等

由于本文不涉及过于复杂的构建过程 就不做介绍了,感兴趣的可以自行找资料

命令

Cmake使用指令式的配置方式,基本形式就是cmake_minimum_required (VERSION 2.8)这样,()外面的是命令,里面的是配置内容,配置内容可以是值也可以是变量.具体有哪些命令可以查看官方文档

命令并不区分大小写.

cmake中的值基本就3种:

  1. 字符串
  2. 布尔值
  3. list

布尔值基本只有逻辑语句中会用到,它本质还是字符串,只是符合条件的字符串会被认定为布尔值.

CMake支持的布尔值可以在下面表中囊括

类型
true 1,ON,YES,TRUE,Y,非0的值
false 0,OFF,NO,FALSE,N,IGNORE,NOTFOUND,空字符串,以-NOTFOUND结尾的字符串

list其实就是字符串列表,它有专门的处理命令LIST()

读操作

  • list(LENGTH <list> <out-var>)获取列表长度
  • list(GET <list> <element index> [<index> ...] <out-var>)获取列表指定下标的元素
  • list(JOIN <list> <glue> <out-var>)获取列表内容用glue作为分隔符的字符串
  • list(SUBLIST <list> <begin> <length> <out-var>)获取列表中的子列表
  • list(FIND <list> <value> <out-var>)查询列表中是否有指定值,有的话它的下标,否则返回-1

写操作

  • list(APPEND <list> [<element>...])在列表尾部添加元素
  • list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>)过滤列表中的元素
  • list(INSERT <list> <index> [<element>...])将元素插入列表指定下标
  • list(POP_BACK <list> [<out-var>...])弹出列表最后的元素
  • list(POP_FRONT <list> [<out-var>...])弹出列表最前面的元素
  • list(PREPEND <list> [<element>...])在列表最前端插入数据
  • list(REMOVE_ITEM <list> <value>...)删除指定值的元素
  • list(REMOVE_AT <list> <index>...)删除指定下标的元素
  • list(REMOVE_DUPLICATES <list>)删除列表中的重复元素
  • list(TRANSFORM <list> <ACTION> [...])每个元素执行操作后作为结果放入原位置
  • list(REVERSE <list>)倒转列表中的元素
  • list(SORT <list> [...])列表排序

变量

需要注意cmake的变量区分大小写.

变量是cmake传统风格中最重要的部分,但在Modern Cmake中它的地位已经下降了,通常在Modern Cmake中变量只有如下4个用处:

  • 设置一些全局变量用于改变cmake编译行为,也就是上面介绍的部分,比较重要的设置项有:

    • CMAKE_C_COMPILER :指定c语言编译器
    • CMAKE_CXX_COMPILER:指定c++语言编译器
  • 获取一些全局变量,根据预设值结合if语句做分支处理.这部分基本是一些操作系统的元数据

    • CMAKE_MAJOR_VERSION,CMAKE 主版本号,比如 2.4.6 中的 2
    • CMAKE_MINOR_VERSION,CMAKE 次版本号,比如 2.4.6 中的 4
    • CMAKE_PATCH_VERSION,CMAKE 补丁等级,比如 2.4.6 中的 6
    • CMAKE_SYSTEM,系统名称,比如 Linux-2.6.22
    • CMAKE_SYSTEM_NAME,不包含版本的系统名,比如 Linux
    • CMAKE_SYSTEM_VERSION,系统版本,比如 2.6.22
    • CMAKE_SYSTEM_PROCESSOR,处理器名称,比如 i686.
    • UNIX,在所有的类 UNIX 平台为 TRUE,包括 OS X 和 cygwin
    • WIN32,在所有的 win32 平台为 TRUE,包括 cygwin
  • 使用全局变量和自定义变量通过字符串组合构造新的值,这部分常用的有如下几个:

    • PROJECT_NAME返回通过 PROJECT 指令定义的项目名称.
    • CMAKE_BINARY_DIR/PROJECT_BINARY_DIR/<projectname>_BINARY_DIR如果是in-source编译指的就是工程顶层目录;如果是out-of-source编译,指的是工程编译发生的目录.
    • CMAKE_SOURCE_DIR/PROJECT_SOURCE_DIR/<projectname>_SOURCE_DIR这三个变量指代的内容是一致的,不论采用何种编译方式,都是工程顶层目录
    • CMAKE_CURRENT_SOURCE_DIR指的是当前处理的文件夹路径
    • CMAKE_CURRRENT_BINARY_DIR如果是in-source编译,它跟CMAKE_CURRENT_SOURCE_DIR一致,如果是out-of-source编译,他指的是target编译目录
    • CMAKE_CURRENT_LIST_DIR,当前CMakeLists.txt所在的路径,注意它可以被include指令改变,通常我们都用它
  • 用于承接外部赋值的变量,我们可以使用option命令定义变量可以外部赋值,cmake也提供了几个变量可以直接从外部赋值

    • CMAKE_BUILD_TYPE: 指定生成 debug 版和 release 版的后端配置
    • CMAKE_INSTALL_PREFIX: 在cmake生成后端时通过设置它可以改变安装到的目标目录,不设置默认安装到/usr/local/下.

Cmake中变量的赋值使用SET()命令,取消赋值使用UNSRT()命令,常规变量的set命令语法为:

set(<variable> <value>... [PARENT_SCOPE])

unset命令语法为:

unset(<variable>)
变量的作用域

cmake中有如下几个概念和域有关:

  1. 函数(function)封闭的执行过程抽象,可以理解为python中的函数
  2. 文件(file),执行过程,函数,宏等配置代码保存的地方.可以理解为python中的.py文件
  3. 项目(Directory)也就是文件夹,一般指的是下面保存有CmakeLists.txt文件的文件夹,它可以是多级的,可以理解为python中的一个package.CmakeLists.txt的作用类似__init__.py
  4. 配置时,也就是执行cmake .时,可以理解为一个全局的缓存作用域
  5. 系统会话进程,也就是我们一次打开terminal到关闭terminal的过程

变量的作用域分为如下几种:

  • Function Scope也就是函数级,在函数中定义的变量作用域只到函数结束
  • Directory Scope也就是项目级,可以理解为每个单独的CmakeLists.txt就是一级,其中定义的变量默认无法被其他项目使用,可以通过设置让父级项目也可以访问(也就是可见性,可以参考golang中的变量大写小写区别).
  • Persistent Cache持续缓存级,它支持在各个项目间传递变量,也就是说在配置时全局可访问
  • 系统级,也就是环境变量.全系统的当次会话进程有效

需要注意CmakeLists.txtfile,但file可以包含其他.cmake结尾的文件,这些被称为模块,模块没有单独的作用域,它和导入它的项目共用作用域.

其中持续缓存级和环境变量的设置方式和上面的不太一样,下面会单独说.

对于一般的变量而言,我们通过设置set命令中PARENT_SCOPE可以用于控制变量的作用域的扩展

  1. 当变量在函数外定义时,如果设置了PARENT_SCOPE则允许其父级可以访问
  2. 当变量在函数内定义时,如果设置了PARENT_SCOPE则允许在函数外以及函数定义项目的父级访问

而要取消设置了PARENT_SCOPE的变量也需要显式的用unset(<variable> PARENT_SCOPE)

持续缓存变量

持续缓存变量使用如下语法定义

set(<variable> <value>... CACHE <type> <docstring> [FORCE])

其中type可以是

  • BOOL,即布尔型

  • FILEPATH,即文件路径

  • PATH,即文件夹路径

  • STRING,即字符串

  • INTERNAL,即内部字符串,必须和force同时存在,也是字符串

持续缓存变量的取消赋值语句为unset(<variable> CACHE)

从外部赋值变量

cmake可以通过option(<variable> "<help_text>" [value])从外部赋值变量,也就是说构造简易的命令行工具.这个指令对已经定义了的变量无效.

从外部赋值了的变量可以正常使用,通常我们会在使用option时设置默认值防止变量为空.我们也可以通过cmake . -LH查看自己定义的和预定义的外部赋值变量.

option()指令构造的变量都是持续缓存变量,这也就意味着这些变量可以在整个项目下传递,因此如果你依赖的子项目有用option命令设置一些编译选项,在父级编译时我们也可以填上.

CMAKE_BUILD_TYPECMAKE_INSTALL_PREFIX也都是持续缓存变量

环境变量

cmake也支持设置和获取系统的环境变量,设置使用SET(ENV{变量名} 值),获取则使用$ENV{NAME}指令,删除环境变量赋值可以使用

消息

消息使用命令message来实现,它类似python中的logger,可以用于打印消息,其完整语法为:

message([<mode>|<checkState>] "message text" ...)

<mode>的范围包括:

  • FATAL_ERRORerror级别的log信息,同时抛出错误
  • SEND_ERRORerror级别的log信息,但不抛出错误
  • WARNING警告级别log
  • NOTICE等同于不填直接写消息,相当于一般log工具中的info级别信息
  • DEBUG相当于一般log工具中的debug级别信息
  • STATUS用户可能会感兴趣的信息,一般表示特定状态,因此消息应该简短

<checkState>的含义则和上面不一样,它用于描述校验变量状态上下文的状态,<checkState>的取值只有三种:

  • CHECK_START描述校验上下文开始
  • CHECK_PASS描述校验通过
  • CHECK_FAIL描述校验不通过

是下面例子的一个规范化写法

message(STATUS "Looking for someheader.h")
if(checkSuccess)
  message(STATUS "Looking for someheader.h - found")
else()
  message(STATUS "Looking for someheader.h - not found")
endif()

上面的例子可以写成

message(CHECK_START "Looking for someheader.h")
if(checkSuccess)
  message(CHECK_PASS "Looking for someheader.h - found")
else()
  message(CHECK_FAIL "Looking for someheader.h - not found")
endif()

如果要还没有执行CHECK_FAILCHECK_PASS就要重置上下文,则可以使用unset(missingComponents)来实现

逻辑语句

cmake只有最基本逻辑语句即条件分支和循环.他们本质上还是通过命令实现的.

条件分支

cmake使用if指令设置条件分支.其条件语句(谓词)语法可以看这个页面

我们通常这样用:

  • 条件判断

    IF(<condition>)
        # THEN section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
    ENDIF()
  • 双分支

    IF(<condition>)
        # THEN section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ELSE()
        # ELSE section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ENDIF()
  • 多分支

    IF(<condition1>)
        # THEN section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ELSEIF(<condition2>)
        # ELSE section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ELSEIF(<condition3>)
        # ELSE section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ELSE()
        # ELSE section.
        COMMAND1(ARGS ...)
        COMMAND2(ARGS ...)
        ...
    ENDIF()
循环

cmake有两种循环:

  • WHILE指令,可用break()continue()退出循环,其语义和一般编程语言中一致

    基本语法为:

    WHILE(condition)
      COMMAND1(ARGS ...)
      COMMAND2(ARGS ...)
      ...
    ENDWHILE(condition)
  • FOREACH

    FOREACH 指令的使用方法有两种形式:

    1. 列表

      FOREACH(loop_var arg1 arg2 ...)
      COMMAND1(ARGS ...)
      COMMAND2(ARGS ...)
      ...
      ENDFOREACH(loop_var)
    2. 范围(类似pythonz中的for xxx in range(x))

      FOREACH(loop_var RANGE oop_var RANGE start stop [step])
      ENDFOREACH(loop_var)

函数和宏

cmake中函数和宏都是用于做执行过程抽象的,他们之间的唯一区别是变量的作用范围--宏中定义的变量没有作用范围而函数可以控制其中变量的作用范围.其定义语法如下:

macro (<macro_name> [<arg1> [<arg2> ...]])
    <process ...>
endmacro (<macro_name>)

函数

function(<function_name> [<arg1> [<arg2> ...]])
    <process ...>
endfunction([function_name])

调用方式也都一样,就是直接<function_name>/<macro_name>([<arg1> [<arg2> ...]])

形参

cmake中的形参只有位置形参,不过和一般编程语言中不同,调用函数和宏时传入的参数可以超过定义时的数量,他们会按如下规则被放在固定的变量中:

变量 说明
ARGV 比如定义宏/函数时参数为2个,实际传了4个,则ARGV代表实际传入的前两个(与传入次序匹配)
ARGV{n} ARGV0ARGV第一个,ARGV1ARGV第二个,依次类推.
ARGN 比如定义宏/函数时参数为2个,实际传了4个,则ARGN代表际传入的后两个
ARGN{n} ARGN0ARGN中第一个,ARGN1ARGN中第二个,依次类推.
ARGC 实际传入的参数的个数
返回值

cmake中的函数和宏都没有返回值,它们只能造成副作用,也就是说它可以定义和赋值变量,而赋值变量是可以控制作用域的.这种方式有点反直觉.

cmake中也有命令return(),但其作用是退出当前执行位置所在的function/file/Directory,并没有返回数据的含义,需要注意宏中没法使用return()

模块(module)

前面已经介绍了模块,cmake中定义包含cmake语句的以.cmake为后缀的文件(file)就是模块.导入模块使用include()指令,通常我们会将一些各个项目经常需要用到的重复代码定义在模块中,然后需要的时候导入模块,由于模块和导入时的项目共用作用域所以并不会有冲突.

include()指令的详细语法为

include(<file|module> [OPTIONAL] [RESULT_VARIABLE <var>]
                      [NO_POLICY_SCOPE])
  • OPTIONAL决定如果没找到模块会不会报错

include指令的查找范围在变量CMAKE_MODULE_PATH中定义,通常我们会将自己定义的模块append到CMAKE_MODULE_PATH的末尾以便可以找到:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")

默认的CMAKE_MODULE_PATH为空,但cmake可以加载自带的模块,这些模块可以在这个页面查看

元信息设置

cmake中的元信息包括3部分

  • cmake_minimum_required:指定运行此配置文件所需的CMake的最低版本

  • project(<PROJECT-NAME> [VERSION <version>] [DESCRIPTION <project-description-string>] [HOMEPAGE_URL <url-string>] [LANGUAGES <language-name>...] []):描述项目的基本信息.

  • 设置全局的平台信息和编译器信息,这些设置我们使用SET(变量 值)的形式进行改变,我们也可以通过${变量}直接获取想要知道的变量然后通过message指令打印出来,cmake自带一部分预定义变量,范围可以在这个页面查看到说明.

项目的编译配置

每一个项目都必然有至少一个输出,通常我们会控制一个项目默认只有一个输出,输出的内容分为两大类:

我们称要编译出来的内容为Target,cmake中配置一个目标的流程大致如下:

  1. 申明目标(add_executable/add_library)
  2. 为目标配置编译器选项(target_compile_options),也就是编译器部分介绍的gcc/g++的各种选项
  3. 为目标配置编译器特性(target_compile_features),比如cxx_constexpr,它实际会为不同的特性指定c/c++的标准版本
  4. 为目标配置预编译宏(target_compile_definitions),相当于gcc/g++中使用-D
  5. 为目标配置源码(target_sources)
  6. 为目标配置搜索的头文件列表(target_include_directories)
  7. 为目标添加要预编译的头文件列表target_precompile_headers
  8. 为目标配置依赖的链接库(target_link_libraries)
  9. 为目标配置其他属性(set_target_properties),所有支持的属性可以在这个页面查到,要查看当前目标的属性值可以使用get_target_property(<VAR> target property)

输出为可执行文件的编译配置

可执行文件使用add_executable命令来声明target(目标),它并不是一定会编译这个目标的,在控制依赖的时候我们也用它来声明已经存在的包.当前我们只介绍声明为待编译的目标如何写.

其完整语法为

add_executable(<name> [WIN32] [MACOSX_BUNDLE]
              [EXCLUDE_FROM_ALL]
              [source1] [source2 ...])

其中[WIN32][MACOSX_BUNDLE]为平台指定的选项,通常不需要加;[EXCLUDE_FROM_ALL]是表示默认不会编译这个目标,只有在执行生成的后端配置文件时指定这个目标才会编译.这个一般用于编译测试用的可执行文件. add_executable可以指定源码文件,但通常不建议这样指定,我们最好还是通过target_sources指定源码以保持每条命令职责单一,便于维护.

在确定好target后我们就可以开始设置这个target

我们以之前的helloworld作为第一个例子,将它改造为使用cmake管理,在其根目录下放上CmakeLists.txt

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (helloworld
    VERSION 0.0.0
    DESCRIPTION "简单测试"
    LANGUAGES C
)
# 创建文件夹存放可执行文件
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin)
add_executable(${PROJECT_NAME})
# 设置源码位置
target_sources(${PROJECT_NAME} 
    PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/helloworld.c
)
# 设置可执行文件的存放位置
set_target_properties(${PROJECT_NAME} PROPERTIES 
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin
)

上面这个例子有两个点是上面没有提到的

  1. file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin)可以用于创建文件夹,如果已经存在也不会保存

  2. target_sources用于声明编译目标需要使用到的源文件,其的完整语法为

    target_sources(<target>
        <INTERFACE|PUBLIC|PRIVATE> [items1...]
        [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...]
    )

    其中<INTERFACE|PUBLIC|PRIVATE>是描述资源文件可访问性的修饰词后面会具体将

  3. set_target_properties用于声明目标的属性,其完整语法为

    set_target_properties(target1 target2 ...
        PROPERTIES 
            prop1 value1
            prop2 value2 ...
    )
  4. 可以为target设置RUNTIME_OUTPUT_DIRECTORY属性,它会控制编译好后存放的位置

资源文件的可访问性

我们可以在target_sources,target_include_directories,target_link_libraries,target_compile_options等用于描述目标使用的资源的方法中设置访问可见性标识.标识的范围有

  • INTERFACE,表示其作用范围为依赖本项目的项目,通常这个用于简化接口,我们可以用这个标识控制哪些资源是外层调用而本项目中不需要有的
  • PUBLIC,表示其作用范围可以是本项目自身也可以是依赖本项目的项目
  • PRIVATE,表示其作用范围为本项目自身,通常这个用于简化接口,我们可以用这个标识控制哪些资源不想让外层项目调用

通常建议如果是可执行文件,建议全部用PRIVATE;如果是链接库,则根据你的接口性质设置可访问性,注意别在被导入时出问题就可以.

输出纯静态的可执行文件

通常由于依赖难以管理,我们的c/c++程序很少会编译为纯静态的可执行文件,但如果真的有必要(比如打包到docker镜像中)我们也可以为编译可执行文件的cmake配置加上对应的可选项

上面的例子中CmakeLists.txt改为如下内容

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (helloworld
    VERSION 0.0.0
    DESCRIPTION "简单测试"
    LANGUAGES C
)

option(AS_STATIC "是否作为纯静态可执行文件编译" off)
# 创建文件夹存放可执行文件
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin)
add_executable(${PROJECT_NAME})

# 设置源码位置
target_sources(${PROJECT_NAME} 
    PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/helloworld.c
)
# 设置可执行文件的存放位置
set_target_properties(${PROJECT_NAME} PROPERTIES 
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin
)
if(AS_STATIC)
    message("编译纯静态可执行文件")
    target_compile_options(${PROJECT_NAME}
        PRIVATE "-static"
    )
endif()

这样我们如果需要编译为纯静态,则可以使用cmake -DAS_STATIC=on .命令指定选项,这样执行make后得到的就是纯静态的程序了.

上面例子的新知识点有:

  1. target_compile_options可以用于声明给编译器的选项,其完整语法为

    target_compile_options(<target> [BEFORE]
        <INTERFACE|PUBLIC|PRIVATE> [items1...]
        [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...]
    )

    其中BEFORE指示选项在编译命令中的放置位置为在已存在的其他选项前还是在其后如果要在其他选项前则需要加上BEFORE

输出为包的编译配置

包使用add_library命令来声明,和add_executable一样,它也可以用于声明已经存在的链接库或者计划要依赖的链接库,当前我们还是只介绍cmake中如何编译项目为包.

其完整语法为:

add_library(<name> [STATIC | SHARED | MODULE]
            [EXCLUDE_FROM_ALL]
            [<source>...])

其中STATIC | SHARED | MODULE是指的输出的链接库类型,STATIC表示静态链接库,SHARED表示动态链接库,MODULE则是一种可以通过类似dlopen加载的插件型动态链接库,这里因为我们的目的不是为了做需要热加载的复杂软件所以不做介绍.

我们通过前文的vector包的构建为例子,用cmake重构一下它

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (myvector
    VERSION 0.0.0
    DESCRIPTION "简单测试编译包"
    LANGUAGES C
)

# 定义目标
add_library(${PROJECT_NAME} STATIC)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
# 设置源码位置
file(GLOB SRC "${CMAKE_CURRENT_LIST_DIR}/src/*.c")
target_sources(${PROJECT_NAME} 
    PRIVATE ${SRC}
)
# 设置头文件位置
target_include_directories(${PROJECT_NAME} BEFORE
    PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
)
# 设置静态库文件的存放位置
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib)
set_target_properties(${PROJECT_NAME} PROPERTIES 
    ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib
)

这里有两个新知识点:

  1. file(GLOB SRC "<partten>")可以用于批量的查找到指定目标路径下符合匹配要求的文件,形成一个以;分隔的列表字符串,我们在这里指定src目录下的所有.c为后缀的文件作为源文件

  2. 静态链接库可以通过设置propertiesARCHIVE_OUTPUT_DIRECTORY来指定包的输出位置,注意动态链接库不可以用这个,需要使用LIBRARY_OUTPUT_DIRECTORY

  3. target_include_directories用于指定编译过程中需要用到的头文件,相当于gcc中的-I,其的完整语法为

    target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
        <INTERFACE|PUBLIC|PRIVATE> [items1...]
        [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...]
    )

    AFTER|BEFORE用于指定搜索顺序是在已有的之前还是之后;SYSTEM表示使用系统提供的include路径,如果SYSTEMPUBLICINTERFACE一起使用则将使用指定的目录填充INTERFACE_SYSTEM_INCLUDE_DIRECTORIES目标属性

允许同时编译动态和静态链接库

前文有介绍动态库和静态库的区别和特点,很多时候我们会生成静态库,毕竟相对性能更好些,但有时候我们也会希望生成动态库是一个可选项,我们可以一次同时生成静态动态两种库,到时候调用方可以自己选择用什么.

我们可以修改CMakeLists.txt文件为如下内容,它演示了允许同时生成两种库的模式:

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (myvector
    VERSION 0.0.0
    DESCRIPTION "简单测试编译包"
    LANGUAGES C
)

# 设置源码位置
file(GLOB SRC "${CMAKE_CURRENT_LIST_DIR}/src/*.c")
# 准备存放位置
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib)
# 编译为iobject对象减少重复编译
add_library(objlib OBJECT ${SRC})
target_include_directories(objlib
    PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
)
set_target_properties(objlib PROPERTIES
    POSITION_INDEPENDENT_CODE 1
)
# 编译静态库
add_library(${PROJECT_NAME} STATIC)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
target_sources(${PROJECT_NAME}
    PRIVATE $<TARGET_OBJECTS:objlib>
)
target_include_directories(${PROJECT_NAME}
    PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
)
set_target_properties(${PROJECT_NAME} PROPERTIES 
    OUTPUT_NAME ${PROJECT_NAME}
    CLEAN_DIRECT_OUTPUT 1
    ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib
)
# 编译动态库
option(WITHSHARE "build share lib also?" off)
if(WITHSHARE)
    message("also build share library")
    # shared and static libraries built from the same object files
    add_library(${PROJECT_NAME}_shared SHARED)
    target_sources(${PROJECT_NAME}_shared
        PRIVATE $<TARGET_OBJECTS:objlib>
    )
    target_include_directories(${PROJECT_NAME}_shared
        PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
    )
    set_target_properties(${PROJECT_NAME}_shared PROPERTIES 
        OUTPUT_NAME ${PROJECT_NAME}
        CLEAN_DIRECT_OUTPUT 1
        LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib
    )
endif()

在上面的例子中,我们有个新的知识点:

  1. add_library(objlib OBJECT ${SRC})虽然也是用的add_library,但其含义和创建链接库完全不同,实际上这是用于将源码编译为了object文件,我们现编译object文件,然后使用object文件再分别编译为动态链接库和静态链接库,这样可以减少重复编译.
  2. POSITION_INDEPENDENT_CODE 1含义是object文件连接时为地址无关代码,也就是和gcc/g++中的-fPIC一样.
  3. OUTPUT_NAME ${PROJECT_NAME}可以用于指定库名
  4. CLEAN_DIRECT_OUTPUT 1用于指定生成库时是否删除上次的生成结果.

定义包的安装行为

定义包的安装行为使用命令install,它会为后端添加一个名为install的子指令,执行<make|ninja> install就会执行安装.

其主要语法是:

install(TARGETS targets... [EXPORT <export-name>]
        [[ARCHIVE|LIBRARY|RUNTIME|OBJECTS|FRAMEWORK|BUNDLE|
          PRIVATE_HEADER|PUBLIC_HEADER|RESOURCE]
         [DESTINATION <dir>]
         [PERMISSIONS permissions...]
         [CONFIGURATIONS [Debug|Release|...]]
         [COMPONENT <component>]
         [NAMELINK_COMPONENT <component>]
         [OPTIONAL] [EXCLUDE_FROM_ALL]
         [NAMELINK_ONLY|NAMELINK_SKIP]
        ] [...]
        [INCLUDES DESTINATION [<dir> ...]]
        )

install的注意点有如下:

  1. TARGETS可以是多个,我们一个install可以将需要的target都包裹进去

  2. 可以用于install的目标文件有如下几种常见的,他们都有默认的安装目录变量和默认的安装文件夹.其他的不太常见而且多与平台相关,就不做介绍了

    目标文件 内容 安装目录变量 默认安装文件夹
    ARCHIVE 静态库 ${CMAKE_INSTALL_LIBDIR} lib
    LIBRARY 动态库 ${CMAKE_INSTALL_LIBDIR} lib
    RUNTIME 可执行二进制文件 ${CMAKE_INSTALL_BINDIR} bin
    PUBLIC_HEADER 与库关联的PUBLIC头文件 ${CMAKE_INSTALL_INCLUDEDIR} include
    PRIVATE_HEADER 与库关联的PRIVATE头文件 ${CMAKE_INSTALL_INCLUDEDIR} include
  3. PUBLIC_HEADERPRIVATE_HEADER需要通过set_target_properties为target添加属性PUBLIC_HEADER或者PRIVATE_HEADER才能被install

  4. DESTINATION指定存放位置,如果使用安装目录变量则可以受CMAKE_INSTALL_PREFIX影响

  5. CONFIGURATIONS指定安装时使用的配置,也就是DEBUGRELEASE

  6. EXCLUDE_FROM_ALL指定该文件从完整安装中排除

  7. OPTIONAL如果要安装的文件不存在则指定不是错误

我们继续修改例子,将install指令添加上

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (myvector
    VERSION 0.0.0
    DESCRIPTION "简单测试编译包"
    LANGUAGES C
)

# 设置源码位置
file(GLOB SRC "${CMAKE_CURRENT_LIST_DIR}/src/*.c")
# 准备存放位置
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib)
# 编译为iobject对象减少重复编译
add_library(objlib OBJECT ${SRC})
target_include_directories(objlib
    PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
)
set_target_properties(objlib PROPERTIES
    POSITION_INDEPENDENT_CODE 1
)
# 编译静态库
add_library(${PROJECT_NAME} STATIC)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
target_sources(${PROJECT_NAME}
    PRIVATE $<TARGET_OBJECTS:objlib>
)
target_include_directories(${PROJECT_NAME}
    PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
)
set_target_properties(${PROJECT_NAME} PROPERTIES 
    OUTPUT_NAME ${PROJECT_NAME}
    CLEAN_DIRECT_OUTPUT 1
    ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib
    PUBLIC_HEADER ${CMAKE_CURRENT_LIST_DIR}/inc/binary_vector.h
)
# 编译动态库
option(WITHSHARE "build share lib also?" off)
if(WITHSHARE)
    message("also build share library")
    # shared and static libraries built from the same object files
    add_library(${PROJECT_NAME}_shared SHARED)
    target_sources(${PROJECT_NAME}_shared
        PRIVATE $<TARGET_OBJECTS:objlib>
    )
    target_include_directories(${PROJECT_NAME}_shared
        PUBLIC ${CMAKE_CURRENT_LIST_DIR}/inc
    )
    set_target_properties(${PROJECT_NAME}_shared PROPERTIES 
        OUTPUT_NAME ${PROJECT_NAME}
        CLEAN_DIRECT_OUTPUT 1
        LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/lib
    )

    # 安装
    install(TARGETS ${PROJECT_NAME} ${PROJECT_NAME}_shared
        ARCHIVE
            DESTINATION ${CMAKE_INSTALL_LIBDIR}
        LIBRARY
            DESTINATION ${CMAKE_INSTALL_LIBDIR}
        PUBLIC_HEADER
            DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
    )
else()
    message("only build static library")
    # 安装
    install(TARGETS ${PROJECT_NAME}
        ARCHIVE
            DESTINATION ${CMAKE_INSTALL_LIBDIR}
        PUBLIC_HEADER
            DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
    )
endif()

项目的依赖描述

项目的依赖可以分为如下几种途径:

  1. 系统级别安装的包,也就是使用apt,apk什么的安装的包,他们会放在系统的默认搜索路径里,因此一般不太需要管
  2. 外部安装的包,通常这些包是用cmake不太好管理的,比如那些比较老的包很多只支持makefile不支持cmake,那么我们就可以先在外面将其编译好,然后安装到系统目录(通常都会支持)
  3. 外部安装的包,通常这些包是用cmake不太好管理的,比如那些比较老的包很多只支持makefile不支持cmake,而且并没有提供安装到系统目录的make指令,那么我们就可以先在外面将其编译好,然后在cmake中指定他们
  4. 支持cmake的包,但是网络环境不好只能下下载下来再导入
  5. 支持cmake的包,且网络条件良好,我们就可以直接在cmake中设置指定.

其中第一个算是直接使用系统的包,第2,3种是外部源码安装,第4,5种则是使用cmake直接管理依赖.第1,2,3种实际上都不是cmake在直接管理依赖,而是我们在外面编译好后cmake只是负责找到它,算是间接的管理这些依赖

但无论是哪种依赖,我们都使用target_link_libraries来导入依赖,其完整语法为

target_link_libraries(<target>
    <PRIVATE|PUBLIC|INTERFACE> <item>...
    [<PRIVATE|PUBLIC|INTERFACE> <item>...]...
)

其中item可以是如下几种情况:

  1. 一个链接库的完整路径
  2. 一个链接库的名字,也就是说它必须在编译器的查找范围内,我们可以通过设置target_compile_options中的-L来强行指定搜索路径
  3. add_library(<name> <STATIC|SHARED|MODULE|UNKNOWN|OBJECT|INTERFACE> IMPORTED [GLOBAL])导入的target名,注意这边的add_library只是用于声明包而不是编译的目标.通常这用于查找到已经存在的链接库后进行声明
  4. add_library(<name> ALIAS <target>)声明的包,通常这用于引用其他cmake管理的链接库

使用cmake管理其他用cmake管理的项目作为依赖

cmake可以很方便的导入其他用cmake管理的项目作为项目依赖,但这也是有代价的

  1. 这种方式铁定就是使用源码分发了,这也就是意味着所有依赖都得从源码开始编译,会很花时间
  2. 你不得不维护一个包含其他项目源码的项目(这个如果你的项目使用git管理源码的话可以用.gitignore屏蔽)
使用add_subdirectory导入本地由cmake管理的其他项目作为依赖

如果你已经将依赖项下载到本地了,那么你可以使用add_subdirectory来导入项目,其完整语法为

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])

其中binary_dir用于指定一个用于存放输出文件的目录,可以是相对路径也可以是绝对路径.如果是相对路径则是相对当前输出目录的一个相对路径.如果该参数没有指定则默认的输出目录使用source_dir

使用add_subdirectory导入根项目的子项目必须根目录有CMakeLists.txt文件.根项目的会优先编译子项目,而跟项目下只要使用target_link_libraries就可以为目标导入子项目中使用add_library(ALIAS)声明的链接库了. 需要注意一些老的cmake项目由于历史原因并没有在构造CMakeLists.txt时考虑被人作为子项目调用而没有声明add_library(ALIAS),我们就需要在根目录为其补充对应的声明,比如faiss在其项目中就没有对应的声明,我们可以在导入时为其添加

add_library(faiss::faiss ALIAS faiss)

这样我们在target_link_libraries中就可以通过使用faiss::faiss来使用faiss了.

一般来说add_library(ALIAS)的命名是使用<subproject>::<targetname>的形式,这样相当于用命名空间区分了target的来源.

一般来说,我们会把下载下来的依赖都放在项目根目录一个名为thirdpart的目录下,我们可以先设置变量THIRD_PART(set(THIRD_PART ${CMAKE_CURRENT_LIST_DIR}/thirdpart)),然后在source_dir位置统一使用${THIRD_PART}/xxx来导入

使用FetchContent_系列命令导入远程托管的由cmake管理的项目作为依赖

如果我们网络环境好,也可以通过FetchContent_系列命令导入远程托管的由cmake管理的项目作为依赖.其基本形式为

# 导入FetchContent模块以支持远程导入
include(FetchContent)
# 下载依赖
message(NOTICE "下载argparse")
FetchContent_Declare(argparse
  URL  https://github.com/p-ranav/argparse/archive/refs/heads/master.zip
  TLS_VERIFY     FALSE
)
#激活依赖
FetchContent_MakeAvailable(argparse)
  • FetchContent_Declare用于声明并下载依赖,它支持git方式,http方式,svn方式等主流的远程代码托管方式.不过实测http方式最稳.其配置项可以在这个文档查阅. 有一个坑就是如果在docker中打镜像,需要安装ca-certificates,否则请求的时候会提示tls验证错误
  • FetchContent_MakeAvailable用于将下载下来的子项添加为subdirectory

和上面的方式一样,一些比较老的库可能并没有声明add_library(ALIAS),我们一样需要为这种库做上面一样的设置.

我们改造helloworld项目,让他使用spdlog代替stdio

#项目编译环境
cmake_minimum_required (VERSION 3.17)
project (helloworld_with_log
    VERSION 0.0.0
    DESCRIPTION "简单测试"
    LANGUAGES CXX
)
# 下载外部依赖源码
message(NOTICE "下载外部依赖")
include(FetchContent)
## log
message(NOTICE "下载spdlog")
FetchContent_Declare(spdlog
  TLS_VERIFY     FALSE
  URL  https://github.com/gabime/spdlog/archive/v1.8.2.tar.gz
)
FetchContent_MakeAvailable(spdlog)

option(AS_STATIC "是否作为纯静态可执行文件编译" off)
# 创建文件夹存放可执行文件
file(MAKE_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin)
add_executable(${PROJECT_NAME})

# 设置源码位置
target_sources(${PROJECT_NAME} 
    PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/helloworld.cc
)
# 设置可执行文件的存放位置
set_target_properties(${PROJECT_NAME} PROPERTIES 
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/bin
)
target_link_libraries(${PROJECT_NAME}
  PRIVATE spdlog::spdlog
)

if(AS_STATIC)
    message("编译纯静态可执行文件")
    target_compile_options(${PROJECT_NAME}
        PRIVATE "-static"
    )
endif()

使用find_package指令间接管理已经存在的依赖

这种方式对应1,2,3种途径,cmake做的事只是找到已有的包并记录在运行过程中.cmake提供了find_package命令专门用于查找已经存在的依赖

find_package的基本命令为:

find_package(<PackageName> [version] [EXACT] [QUIET] [MODULE]
             [REQUIRED] [[COMPONENTS] [components...]]
             [OPTIONAL_COMPONENTS components...]
             [NO_POLICY_SCOPE])

但是注意find_package必须结合cmake的模块来使用,它会查找并加载<PackageName>声明的名为Find<PackageName>.cmake的模块,这种命名的模块专门用于包装依赖.如果这个模块找到了那么<PackageName>_FOUND会被设置为true,如果没找到,又设置了REQUIRED那么就会报错,否则会被设置为false.version则是用于在有多个版本时指定版本.

cmake已经自带了不少常用的依赖,我们可以在这个页面找到

如果我们要的依赖不在这个范围内,我们就需要自己写一个Find<PackageName>.cmake模块,并将其所在目录加到CMAKE_MODULE_PATH中.我们以protobuf为例看看如何封装.

  • FindProtobuf.cmake
# PROTOBUF_GENERATE_CPP 用于用protobuf命令行工具生成指定目录下文件的cpp版本代码
#@params SRCS 生成的.cc文件存放的变量
#@params HDRS 生成的头文件存放的变量
#@params DEST 生成的文件存放的目录
#@params ARGN .proto 文件
function(PROTOBUF_GENERATE_CPP SRCS HDRS DEST)
  if(NOT ARGN)
    message(SEND_ERROR "Error: PROTOBUF_GENERATE_CPP() called without any proto files")
    return()
  endif()

  if(PROTOBUF_GENERATE_CPP_APPEND_PATH)
    # Create an include path for each file specified
    foreach(FIL ${ARGN})
      get_filename_component(ABS_FIL ${FIL} ABSOLUTE)
      get_filename_component(ABS_PATH ${ABS_FIL} PATH)
      list(FIND _protobuf_include_path ${ABS_PATH} _contains_already)
      if(${_contains_already} EQUAL -1)
          list(APPEND _protobuf_include_path -I ${ABS_PATH})
      endif()
    endforeach()
  else()
    set(_protobuf_include_path -I ${CMAKE_CURRENT_SOURCE_DIR})
  endif()

  if(DEFINED PROTOBUF_IMPORT_DIRS)
    foreach(DIR ${PROTOBUF_IMPORT_DIRS})
      get_filename_component(ABS_PATH ${DIR} ABSOLUTE)
      list(FIND _protobuf_include_path ${ABS_PATH} _contains_already)
      if(${_contains_already} EQUAL -1)
          list(APPEND _protobuf_include_path -I ${ABS_PATH})
      endif()
    endforeach()
  endif()

  set(${SRCS})
  set(${HDRS})
  foreach(FIL ${ARGN})
    get_filename_component(ABS_FIL ${FIL} ABSOLUTE)
    get_filename_component(FIL_WE ${FIL} NAME_WE)

    list(APPEND ${SRCS} "${DEST}/${FIL_WE}.pb.cc")
    list(APPEND ${HDRS} "${DEST}/${FIL_WE}.pb.h")

    add_custom_command(
      OUTPUT "${DEST}/${FIL_WE}.pb.cc"
             "${DEST}/${FIL_WE}.pb.h"
      COMMAND protobuf::protoc
      ARGS --cpp_out ${DEST} ${_protobuf_include_path} ${ABS_FIL}
      DEPENDS ${ABS_FIL} protobuf::protoc
      COMMENT "Running C++ protocol buffer compiler on ${FIL}"
      VERBATIM )
  endforeach()

  set_source_files_properties(${${SRCS}} ${${HDRS}} PROPERTIES GENERATED TRUE)
  set(${SRCS} ${${SRCS}} PARENT_SCOPE)
  set(${HDRS} ${${HDRS}} PARENT_SCOPE)
endfunction()


if(NOT DEFINED PROTOBUF_GENERATE_CPP_APPEND_PATH)
  set(PROTOBUF_GENERATE_CPP_APPEND_PATH TRUE)
endif()

# 查找头文件位置
find_path(PROTOBUF_INCLUDE_DIR google/protobuf/service.h)
mark_as_advanced(PROTOBUF_INCLUDE_DIR)

# 查找并声明protobuf链接库
find_library(PROTOBUF_LIBRARY NAMES protobuf)
mark_as_advanced(PROTOBUF_LIBRARY)
add_library(protobuf::libprotobuf UNKNOWN IMPORTED)
set_target_properties(protobuf::libprotobuf PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES ${PROTOBUF_INCLUDE_DIR}
    INTERFACE_LINK_LIBRARIES pthread
    IMPORTED_LOCATION ${PROTOBUF_LIBRARY}
)

# 查找并声明protobuf-lite链接库
find_library(PROTOBUF_LITE_LIBRARY NAMES protobuf-lite)
mark_as_advanced(PROTOBUF_LITE_LIBRARY)
add_library(protobuf::libprotobuf-lite UNKNOWN IMPORTED)
set_target_properties(protobuf::libprotobuf-lite PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES ${PROTOBUF_INCLUDE_DIR}
    INTERFACE_LINK_LIBRARIES pthread
    IMPORTED_LOCATION ${PROTOBUF_LITE_LIBRARY}
)

# 查找并声明Protoc链接库
find_library(PROTOBUF_PROTOC_LIBRARY NAMES protoc)
mark_as_advanced(PROTOBUF_PROTOC_LIBRARY)
add_library(protobuf::libprotoc UNKNOWN IMPORTED)
set_target_properties(protobuf::libprotoc PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES ${PROTOBUF_INCLUDE_DIR} # 关联头文件
    INTERFACE_LINK_LIBRARIES protobuf::libprotobuf #关联依赖
    IMPORTED_LOCATION ${PROTOBUF_PROTOC_LIBRARY} #设置链接库的存放位置
)

# 查找并声明Protoc可执行文件
find_program(PROTOBUF_PROTOC_EXECUTABLE NAMES protoc)
mark_as_advanced(PROTOBUF_PROTOC_EXECUTABLE)
add_executable(protobuf::protoc IMPORTED)
set_target_properties(protobuf::protoc PROPERTIES
    IMPORTED_LOCATION ${PROTOBUF_PROTOC_EXECUTABLE}
)

# 固定写法,让find_package可以找到包
include(${CMAKE_ROOT}/Modules/FindPackageHandleStandardArgs.cmake)
FIND_PACKAGE_HANDLE_STANDARD_ARGS(Protobuf DEFAULT_MSG
    PROTOBUF_LIBRARY PROTOBUF_INCLUDE_DIR PROTOBUF_PROTOC_EXECUTABLE)

基本上模式就是

  1. 如果有链接库要声明就声明为add_library(<模块名>::lib<连接库名> UNKNOWN IMPORTED)
  2. 如果有可执行文件要声明就声明为add_executable(<模块名>::<可执行文件名> IMPORTED)
  3. 如果要执行可执行文件,就写个函数把执行过程包装下

需要注意这种包装需要将链接库或者可执行文件声明为IMPORTED