- C语言工程构建
- shell脚本(bat脚本)
- Makefile 脚本
- 基本语法规则
- 补充说明
- CMake工具
- 安装
- 简单示例
- 基础规则
- 外部构建
- 定义变量
- 内置变量
- 命令
- 指定构建环境
- 生成 Makefile文件
- 生成 Visual Studio工程
- 其他环境
- 补充
C语言工程构建
为什么需要编译脚本?
当C语言工程很大,源码非常多时,如果还去使用GCC命令编译程序,几乎是不现实的。这时候,可以通过编写shell脚本去执行编译命令,当然这并不是一种好的方式。在Linux上我们可以写shell脚本,在Windows上则可以编写bat脚本
本篇以如下源码作为示例工程,需要编译一个main.exe
程序出来
add.c
int add(int a, int b){
return a b;
}
sub.c
int sub(int a, int b){
return a-b;
}
mul.c
int mul(int a, int b){
return a*b;
}
div.c
int div(int a, int b){
return a*b;
}
calc.h
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);
main.c
#include <stdio.h>
#include "calc.h"
int main(){
printf("1 2=%dn",add(1,2));
printf("18-9=%dn",sub(18,9));
return 0;
}
shell脚本(bat脚本)
由于在Windows平台,使用MinGW环境,这里编写的是bat脚本,创建一个名为build
的文件(文件名任意),修改其扩展名为build.bat
,使用文本编辑器编辑该文件(Linux平台上,则保存扩展名build.sh
)
gcc add.c sub.c mul.c div.c main.c -o main.exe
可以看到,只需要执行build.bat
就能编译生成main.exe
,这比每次手敲命令方便太多了。如果有多个源码文件,只需要写入脚本中,通过执行脚本完成编译。
Makefile 脚本
Makefile 脚本文件是GNU make
工具的输入文件,它也包含一套自己的语法规则,它也能帮助C语言实现编译和链接。既然可以通过命令行脚本(shell)完成编译工作,为什么还需要Makefile脚本文件呢?
虽然命令行脚本也能帮助编译链接,但是它的能力还太弱,它每次都会将所有文件重新编译,例如有几百个源文件,我仅仅只修改了其中一个源文件,那么重新编译时,这几百个源文件也都会重新编译,这样每次编译一下都会耗费大量时间。而make 工具会自动根据修改情况完成源文件的对应.o
文件的更新、库文件的更新以及最终的可执行程序的更新,它实际上是通过比较对应文件的最后修改时间,来决定哪些文件需要更新、那些文件不需要更新。
现在将命令行脚本改写为Makefile脚本,在源码目录下创建一个名为Makefile
的文件(亦可以写作makefile
),注意,它没有拓展名,编辑如下内容:
# 编译一个main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
gcc main.o add.o sub.o mul.o -o main.exe
main.o: main.c calc.h
gcc -c main.c
add.o: add.c
gcc -c add.c
sub.o: sub.c
gcc -c sub.c
mul.o: mul.c
gcc -c mul.c
div.o: div.c
gcc -c div.c
# 伪目标,删除所有.o文件
clean:
rm *.o
cd
到当前目录,执行输入make
命令,即可快速编译生成main.exe
程序,当我们需要清理整个工程时,即全部重新编译时,可以输入make clean
命令,即可删除当前目录下的所有.o
文件。
基本语法规则
注意,#
号开头的行表示注释
语法结构如下
代码语言:javascript复制target1 target2 target3...: prerequisite1 prerequisite2 prerequisite3...
command1
command2
command3
- target 表示目标。通常有三种情况:可以是一个目标文件(
.o
文件);可以是一个可执行文件;可以是一个标签,标签被称为伪目标 - prerequisite 表示条件。实际上表达的是一种依赖关系,即要生成前面的target,所需要依赖的文件或是另一个目标
- command 表示需要执行的命令。即要生成这个目标,对应执行的命令
需要注意,在冒号的左边,可以是一个或多个目标,而在冒号的右边,则可以是零个或多个依赖条件。目标顶格写,而command前面则必须有一个制表符(即
Tab
键)
要想写Makefile
文件,必须对C语言的编译链接阶段有基本的了解,总的来说,就是将.c
源码文件编译为.o
目标文件,然后将.o
文件链接为可执行程序,而Makefile
脚本正是将这个依赖关系反过来描述,即一个可执行程序需要依赖哪些.o
文件,每一个.o
文件又依赖于哪些.c
、.h
文件。
简化版本
除了上面那种标准版本,我们还可以利用make工具的自动推导能力,省略对目标文件的条件依赖描述,包括编译命令。
代码语言:javascript复制# 编译一个main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
gcc main.o add.o sub.o mul.o -o main.exe
main.o: calc.h
add.o:
sub.o:
mul.o:
div.o:
# 伪目标,删除所有.o文件和可执行文件
clean:
rm *.o main.exe
另一种风格
代码语言:javascript复制# 编译一个main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
gcc main.o add.o sub.o mul.o -o main.exe
main.o: calc.h
# 另一种风格,写在同一行
add.o sub.o mul.o div.o:
# 伪目标,删除所有.o文件和可执行文件
clean:
rm *.o main.exe
在make工具中,它能够自动完成对.c
文件的编译并生成对应的.o
文件。它默认执行命令cc -c
来编译.c
源文件,以main.o
为例,它会默认执行cc -c main.c -o main.o
。但是要注意,我们如果在Windows上执行以上简化版的make
,则会报错,这是因为在Linux系统中,cc
命令会默认的链接到gcc
命令上,执行cc
命令就是在执行gcc
命令,而我们Windows系统中是没有cc
命令的。解决办法非常简单粗暴,就是进入gcc.exe
所在目录,将gcc.exe
再复制一份,并更名为cc.exe
即可。
伪目标伪目标就是一个标签,它本身既不是目标文件也不是可执行文件,例如上面例子中的clean
,我们可以通过伪目标定义一些命令,然后在make中去执行。
上面例子中的伪目标在定义上存在一些问题,假如源码目录下真的存在一个名为clean
的文件,则会与当前的伪目标冲突。将一个目标声明为伪目标需要将它作为特殊目标.PHONY
的依赖,这样定义的伪目标就不会和源码目录下的文件名冲突。
正确的定义伪目标
代码语言:javascript复制.PHONY: clean
clean:
rm *.o main.exe
再看一个例子
代码语言:javascript复制# 定义一个伪目标print,它执行命令行的echo命令输出hello,world
.PHONY: print
print:
echo "hello,world"
然后在命令行执行make print
,就会输入出被执行的完整命令,以及命令执行的结果
我们可以根据自己的需要在Makefile
中定义自己的伪目标,通常会定义clean
、install
这些伪目标,install
一般定义拷贝命令,将生成的可执行程序拷贝到应用安装目录下。在Linux平台下,通常是将C语言的源代码和Makefile
脚本一同发布出去,用户只需要在源码目录下分别执行命令make
、make install
即完成了程序的编译和安装,可以看到,有了make
工具后,让开源的C程序的编译使用过程变得非常简单。
补充说明
实际上完整的Makefile 语法体系是非常复杂灵活的,学习完整Makefile语法不亚于学习一门新的编程语言,而且许多语法功能并不是常用的,另一方面,在大型的复杂工程中,自己手写Makefile
是极为不明智的选择。make
工具是一个比较古老的工具,已经有一些工具可以帮助我们自动生成Makefile
文件,例如Linux上的Autoconf
,当然,现在更好的工具是cmake
,它可以自动生成跨平台编译脚本,而且还能用于Android端的NDK开发,是最被推荐的构建工具。
CMake工具
它首先允许开发者编写一种平台无关的 CMakeLists.txt
文件来定制整个编译流程,然后再根据目标用户的平台进一步生成所需的本地化 Makefile
或工程文件,如Linux
下的 Makefile
文件 或 Windows 的 Visual Studio 工程文件。
简单说,以前我们编写的C语言编译脚本是不能跨平台编译的,例如上面示例中编写的 Makefile
,它只能在GCC环境下编译,通常是Linux系统上,而在Windows下的Visual Studio里面就没法用,得重新改造,如果是一个大型项目,那就是灾难。现在我们用CMake工具编写构建脚本,就与平台无关了,它会自动生成对应平台的构建方案,再也不用程序员去操心了。更准确的说,CMake工具真正厉害的地方并不只是跨平台,而是跨编译环境。
安装
进入cmake官网下载页[1] 下载zip包或安装器,安装后,将cmake的bin
目录加入PATH环境变量中,命令行输入cmake --version
检查环境是否配置成功
简单示例
以上面的代码为例,在源码目录下创建 CMakeLists.txt
文件
# CMake最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 配置项目名
project (ch1)
# 指定生成目标,main2为生成的可执行程序名,后面是源码列表
add_executable (main2 add.c sub.c mul.c div.c main.c)
当前面目录下执行以下命令,注意.
不能掉
cmake .
在我们的目录下自动生成了一个 Visual Studio 工程,因为我本地安装了Visual Studio开发环境。可以双击打开ch1.sln
文件或main2.vcxproj
文件,这里会打开Visual Studio IDE,就能直接在IDE里面编译了。
这里,如果我想生成MinGW开发环境的Makefile
,则只需要加一个-G
参数,来指定一个明确的编译环境,从而生成对应的构建脚本。
cmake -G "MinGW Makefiles"
要注意,以上命令直接在CMD命令行执行可能会报错,它需要一个sh
环境,这里有两种解决办法
- 将
sh.exe
所在目录加入到环境变量中,它位于MinGW
根目录下的gitbin
下,修改环境变量后,打开新的命令行窗口然后再执行以上命令 - 第二种就是偷懒的做法,如果你本地安装了
git
工具,则直接鼠标右键,选择Git Bash Here
打开一个bash
来执行以上命令
命令执行完毕,本地目录下就会自动生成一个Makefile
文件,然后执行make
命令即可编译。我们如果打开这个Makefile
文件,会发现看不懂,里面内容比较复杂。
到这里我们已经学会了cmake
构建的简单流程,接下来只需要学习一下 CMakeList.txt
文件的编写规则
基础规则
CMakeLists.txt
文件由命令、注释和空格组成,其中命令是不区分大小写的。#
开头的行表示注释。命令由命令名称、小括号和参数组成,参数之间使用空格进行间隔。例如add_executable (main2 add.c sub.c mul.c div.c main.c)
外部构建
在上面的示例中,执行cmake
命令会在源码工程的目录下生成很多无法自动删除的中间文件或临时文件,这就弄乱了源码工程的目录,如果要发布源码,还得手动一个个去删除这些文件,这显然不是一种好的构建方式,这种方式被称为内部构建,相应的,我们需要使用外部构建的方式来解决问题。
在源码工程的根目录下创建一个build
文件夹,然后在命令行里cd
到build
下,执行cmake ..
或cmake -G "MinGW Makefiles" ..
命令,此时会将所有的中间文件生成到build
目录中,包括Makefile
,然后执行make
编译。当我们需要删除临时文件时,只需要删除build
目录即可,不会对源码工程造成任何影响。
定义变量
源文件较多时,可以定义一个变量来保存,后续只需要引用该变量即可,如下,定义src_list
来保存源文件列表,引用变量则使用${}
包裹.
定义变量使用set
命令,取消命令可使用unset
命令
# 定义变量 src_list
set (src_list add.c sub.c mul.c div.c main.c)
# 打印日志
message (STATUS "源文件列表:${src_list}")
# 引用变量
add_executable (main2 ${src_list})
message
命令是用来打印日志的,它的第一个参数是mode,可省略,常用值如下
mode | 简述 |
---|---|
(none) | 重要信息 |
STATUS | 附带消息 |
WARNING | CMake警告,继续处理 |
AUTHOR_WARNING | CMake警告(dev),继续处理 |
SEND_ERROR | CMake错误,继续处理,但会跳过生成 |
FATAL_ERROR | CMake错误,停止处理和生成 |
内置变量
在cmake
中已经内置了一些变量,我们可以直接使用,也可使用set
命令去修改
CMAKE_SOURCE_DIR
或PROJECT_SOURCE_DIR
表示工程的根目录CMAKE_BINARY_DIR
或PROJECT_BINARY_DIR
表示编译目录。如果是内部构建,则编译目录与工程根目录相同,如果是外部构建,则表示外部构建创建的编译目录,如上例中的build
目录CMAKE_CURRENT_SOURCE_DIR
表示当前处理的CMakeLists.txt
所在文件夹的路径CMAKE_CURRENT_LIST_FILE
当前CMakeLists.txt
文件的完整路径CMAKE_C_COMPILER
和CMAKE_CXX_COMPILER
分别表示C和C 编译器的路径PROJECT_NAME
该变量可获取project
命令配置的项目名
可以使用message
命令打印这些内置变量的值
cmake_minimum_required (VERSION 2.8)
project (ch1)
message (${CMAKE_SOURCE_DIR})
message (${PROJECT_SOURCE_DIR})
message (${CMAKE_BINARY_DIR})
message (${PROJECT_BINARY_DIR})
message (${CMAKE_CURRENT_SOURCE_DIR})
message (${CMAKE_CURRENT_LIST_FILE})
message (${CMAKE_C_COMPILER})
message (${CMAKE_CXX_COMPILER})
message (${PROJECT_NAME})
EXECUTABLE_OUTPUT_PATH
设置该变量可修改可执行程序的生成路径LIBRARY_OUTPUT_PATH
设置该变量可修改库文件生成路径
# build/bin/
SET(EXECUTABLE_OUTPUT_PATH ${PROJECT_BINARY_DIR}/bin)
# build/lib/
SET(LIBRARY_OUTPUT_PATH ${PROJECT_BINARY_DIR}/lib)
BUILD_SHARED_LIBS
指定默认生成的库的类型
命令
CMakeLists.txt
文件基本上就是由命令和参数组成的,例如之前的set
、message
这些,下面就了解一下常用的命令
add_executable
使用给定的源文件,生成一个可执行程序add_library
使用给定的源文件,生成一个库(静态库或共享库)add_subdirectory
添加一个子目录,该子目录也必须包含一个CMakeLists.txt文件include_directories
添加头文件路径add_definitions
添加编译参数target_link_libraries
链接指定的库find_library
查找指定的库,并将库文件路径保存到一个变量set_target_properties
设置目标的一些属性,从而改变构建方式link_directories
添加库的搜索路径aux_source_directory
查找指定路径下的所有源文件
综合实例调整上面示例工程的结构,在工程根目录下创建四个文件夹,分别是build
、calc
、include
、src
,具体工程结构如下所示
ch1
|
--- build/
|
--- calc/
|
--- add.c
|
--- div.c
|
--- mul.c
|
--- sub.c
|
--- CMakeLists.txt
|
--- include/
|
--- calc.h
|
--- src/
|
--- main.c
|
--- CMakeLists.txt
calc
目录作为一个子项目,用于编译一个libcalc.a
静态库,主工程源码在src
下,且需链接静态库。
子项目calc
下需要一个CMakeLists.txt
文件,内容如下
cmake_minimum_required (VERSION 2.8)
# 创建静态库calc,其生成的文件名为libcalc.a
add_library (calc STATIC add.c sub.c mul.c div.c)
工程根目录下也需要CMakeLists.txt
文件,内容如下
cmake_minimum_required (VERSION 2.8)
# 配置项目名
project (ch1)
# 添加一个子文件夹 calc,这里写的相对路径
add_subdirectory (calc)
# 定义变量 SRCS_DIR, 指向src目录的绝对路径
set (SRCS_DIR "${PROJECT_SOURCE_DIR}/src")
# 添加头文件目录,即添加工程根目录下的include目录
include_directories ("${PROJECT_SOURCE_DIR}/include")
# 添加库的搜索路径,即libcalc.a所在的目录(build/calc/libcalc.a)
link_directories ("${PROJECT_BINARY_DIR}/calc")
# 用于生成可执行文件 main.exe
add_executable (main "${SRCS_DIR}/main.c")
# 为main程序指定链接静态库calc
target_link_libraries(main calc)
首先执行cmake -G "MinGW Makefiles" ..
命令自动生成Makefile
文件,然后执行make
命令进行编译,完成后build
目录下即生成main.exe
当链接已经编译好的库时,推荐使用find_library
来查找库,因为link_directories
命令传入相对路径时,会直接将相对路径传给编译器,导致出现找不到问题。
find_library
命令原型如下,第一个参数为变量,第二个参数为库名称,最后面可以填入多个路径find_library(<VAR> name1 [path1 path2 ...])
# 在指定的目录下查找名为calc的库,
# 并将库文件的绝对路径保存到变量STATIC_LIB中
find_library(STATIC_LIB calc "${PROJECT_BINARY_DIR}/calc")
message (${STATIC_LIB})
# 为main程序指定链接静态库calc
target_link_libraries(main ${STATIC_LIB})
静态库与动态库使用add_library
命令默认生成静态库,如add_library (calc add.c sub.c mul.c div.c)
,亦可加上参数STATIC
显式指定,如需生成动态库,则添加参数SHARED
,如add_library (calc SHARED add.c sub.c mul.c div.c)
,此外,还可以通过设置变量BUILD_SHARED_LIBS
来修改默认行为,当该变量为真时,默认会生成动态库,如
# 使用option命令定义选项
option(BUILD_SHARED_LIBS "build shared or static libraries" ON)
自动获取源码列表当我们工程的源码非常多时,一个个去手写源码列表是非常麻烦的,以上述calc
目录下的CMakeLists.txt
文件为例,这时可以使用aux_source_directory
命令
cmake_minimum_required (VERSION 2.8)
# 获取当前目录下的源文件路径列表,并保存到变量SRC_LIST中
aux_source_directory (. SRC_LIST)
# 打印
message (STATUS ${SRC_LIST})
add_library (calc STATIC ${SRC_LIST})
该命令原型如下,第一个参数为搜索的路径,第二个参数为变量
代码语言:javascript复制aux_source_directory(<dir> <variable>)
这个命令只能识别源码文件,不能识别其他文件,比如.h
文件就不能扫描出来,因此存在一定缺陷,想知道能识别哪些拓展名的源文件,可打印两个内置变量获取
message (STATUS ${CMAKE_C_SOURCE_FILE_EXTENSIONS})
message (STATUS ${CMAKE_CXX_SOURCE_FILE_EXTENSIONS})
递归获取文件列表aux_source_directory
命令只能获取源码文件列表,且无法递归获取给定路径下的嵌套子文件夹下的各种源文件,这时可以使用file
命令,结合GLOB_RECURSE
参数,对指定的文件拓展名进行递归获取。
# 递归遍历当前目录下的所有.c .cpp后缀名的文件,并将结果列表保存到SRC_LIST变量中
FILE(GLOB_RECURSE SRC_LIST *.c *.cpp)
# 打印
message (STATUS ${SRC_LIST})
add_library (calc STATIC ${SRC_LIST})
原型如下
代码语言:javascript复制file(GLOB_RECURSE
variable
[RELATIVE path]
[FOLLOW_SYMLINKS]
[globbing expressions]...)
如不需递归,可将GLOB_RECURSE
改为GLOB
指定库的输出名称
代码语言:javascript复制add_library (calc STATIC ${SRC_LIST})
# 将生成 libcalculate.a
set_target_properties(calc PROPERTIES OUTPUT_NAME "calculate")
定义宏与条件编译可使用add_definitions
命令,传入-D
加上宏名称来定义宏,以下定义宏USER_PRO
# 定义宏 USER_PRO
add_definitions(-DUSER_PRO)
# 等价于 #define VER 1 、#define Foo 2
add_definitions(-DVER=1 -DFoo=2)
配合使用option
命令,实现条件编译
project(test)
option(USER_PRO "option for user" OFF)
if (USER_PRO)
add_definitions(-DUSER_PRO)
endif()
option
命令原型:
option(<option_variable> "描述选项的帮助性文字" [initial value])
add_definitions
命令主要用来添加编译参数,add_compile_options
命令也具有相同的功能,示例如下
add_compile_options(-std=c99 -Wall)
add_definitions(-std=c99 -Wall)
指定构建环境
前面已经学会了-G
参数指定构建环境,那么到底可以指定哪些构建环境呢?这里根据官方文档,整理一下-G
后面可以跟哪些值。
生成 Makefile文件
以下是不同环境下的Makefile文件
Borland Makefiles
MSYS Makefiles
MinGW Makefiles
NMake Makefiles
NMake Makefiles JOM
Unix Makefiles
Watcom WMake
生成 Visual Studio工程
Visual Studio 6
Visual Studio 7
Visual Studio 7 .NET 2003
Visual Studio 8 2005
Visual Studio 9 2008
Visual Studio 10 2010
Visual Studio 11 2012
Visual Studio 12 2013
Visual Studio 14 2015
Visual Studio 15 2017
Visual Studio 16 2019
其他环境
Green Hills MULTI
Xcode
CodeBlocks
CodeLite
Eclipse CDT4
Kate
Sublime Text 2
补充
Ninja
这里重点说一下Ninja
,当前的官方文档中没有写Ninja
,实际上CMake从2.8.9版本开始可以支持Ninja
构建
Ninja
是一个注重速度的小型构建系统。它与其他构建系统在两个主要方面不同:它被设计为使其输入文件由更高级别的构建系统生成,并且被设计为尽可能快地运行构建。
简单说,它被设计出来是为了替代make
工具以及Makefile
文件的,它与make
工具的显著区别是,Makefile
是设计出来给人手写的,而Ninja
的build.ninja
设计出来是给其它程序生成的。Makefile
是一个DSL,Ninja
则只是一种配置文件。Makefile
支持分支、循环等流程控制,而Ninja
仅支持一些固定形式的配置。
两者的对应关系:ninja
对应make
,build.ninja
文件对应于Makefile
文件
安装到下载链接[2] 下载对应版本的ninja
工具,解压后配置PATH环境变量,输入ninja --version
检查环境
生成 build.ninja
文件
cmake -G "Ninja" ..
编译
代码语言:javascript复制ninja