程序员C语言快速上手——工程篇(十三)

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=%d\n",add(1,2));
    printf("18-9=%d\n",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文件。

基本语法规则

注意,#号开头的行表示注释

语法结构如下

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工具的自动推导能力,省略对目标文件的条件依赖描述,包括编译命令。

# 编译一个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

另一种风格

# 编译一个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的依赖,这样定义的伪目标就不会和源码目录下的文件名冲突。

正确的定义伪目标

.PHONY: clean
clean:
	rm *.o main.exe

再看一个例子

# 定义一个伪目标print,它执行命令行的echo命令输出hello,world
.PHONY: print
print:
	echo "hello,world"

然后在命令行执行make print,就会输入出被执行的完整命令,以及命令执行的结果

我们可以根据自己的需要在Makefile中定义自己的伪目标,通常会定义cleaninstall这些伪目标,install一般定义拷贝命令,将生成的可执行程序拷贝到应用安装目录下。在Linux平台下,通常是将C语言的源代码和Makefile脚本一同发布出去,用户只需要在源码目录下分别执行命令makemake 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官网下载页 下载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根目录下的git\bin下,修改环境变量后,打开新的命令行窗口然后再执行以上命令
  • 第二种就是偷懒的做法,如果你本地安装了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文件夹,然后在命令行里cdbuild下,执行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_DIRPROJECT_SOURCE_DIR 表示工程的根目录
  • CMAKE_BINARY_DIRPROJECT_BINARY_DIR 表示编译目录。如果是内部构建,则编译目录与工程根目录相同,如果是外部构建,则表示外部构建创建的编译目录,如上例中的build目录
  • CMAKE_CURRENT_SOURCE_DIR 表示当前处理的CMakeLists.txt所在文件夹的路径
  • CMAKE_CURRENT_LIST_FILE 当前CMakeLists.txt文件的完整路径
  • CMAKE_C_COMPILERCMAKE_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文件基本上就是由命令和参数组成的,例如之前的setmessage这些,下面就了解一下常用的命令

  • 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
    查找指定路径下的所有源文件

综合实例
调整上面示例工程的结构,在工程跟目录下创建四个文件夹,分别是buildcalcincludesrc,具体工程结构如下所示

  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})

该命令原型如下,第一个参数为搜索的路径,第二个参数为变量

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})

原型如下

file(GLOB_RECURSE 
     variable 
     [RELATIVE path] 
     [FOLLOW_SYMLINKS] 
     [globbing expressions]...)

如不需递归,可将GLOB_RECURSE改为GLOB

指定库的输出名称

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是设计出来给人手写的,而Ninjabuild.ninja设计出来是给其它程序生成的。Makefile是一个DSL,Ninja则只是一种配置文件。 Makefile支持分支、循环等流程控制,而Ninja仅支持一些固定形式的配置。

两者的对应关系
ninja对应makebuild.ninja文件对应于Makefile文件

安装
下载链接 下载对应版本的ninja工具,解压后配置PATH环境变量,输入ninja --version检查环境

生成 build.ninja文件

 cmake -G "Ninja" ..

编译

ninja

欢迎关注我的公众号:编程之路从0到1

编程之路从0到1

猜你喜欢

转载自blog.csdn.net/yingshukun/article/details/101236918