编程语言应用

首页 » 常识 » 诊断 » C语言结构化编译加速
TUhjnbcbe - 2023/10/8 16:48:00

大家好,我是TT。

在前面内容中中,我们曾遇到过很多段示例代码。而这些代码有一个共性,就是它们都十分短小,以至于可以被整理在一个单独的.c文件中。并且,通过简短的一行命令,我们就可以同时完成对代码的编译和程序的运行。

但现实情况中的C项目却往往没这么简单,动辄成百上千的源文件、各种各样的外部依赖与配置项,这些都让事情变得复杂了起来。因此,当C项目的体量由小变大时,如何组织其源代码的目录结构与编译流程,就成了我们必须去着重考虑的两个问题。而今天我们就来聊一聊,应该从哪些角度看待这两个问题。

如何组织C项目的源代码目录结构?

我们先来看与源码目录结构相关的话题。其实,对于C项目的源代码目录结构,应该使用哪种组织方式,通常没有所谓的“最佳实践”,而是要具体问题具体分析。

对于小型项目,我们可以简单地将.h与.c这两类源文件分别归纳在两个独立的目录include与src中,甚至是全部混放在同一个目录下。而当项目逐渐变大时,不同的C源文件就可以按照所属功能,再进行更细致的划分。

比如,能够以模块为单位,以库的形式进行抽象的实现,可以统一放在名为libs的目录下进行管理。而使用库接口实现的应用程序代码,则可放置在名为src的目录中。其他与C源代码没有直接关系的文件,可以自由保存在项目根目录,或放置在以对应分类命名的独立目录内。

在下图中,我给出了两种你可以参考的目录结构。但需要注意的是,并没有默认的或最好的C项目目录结构,无论采用哪种形式,你都要随着项目的发展而学会不断变通。

对于源代码目录结构的组织,一个基本原则是“清晰易懂”。其中,“清晰”是指即使在不了解具体实现的情况下,仅通过一层层展开项目代码的目录树,我们也能够以自顶向下的方式,来了解它在代码层面的基本组成结构。而“易懂”则是指在上面这个过程中,通过观察文件夹和文件的名字,我们可以对项目的基本功能与模块化实现有一个大致印象。

如何组织C项目的编译流程?

随着源代码目录被不断调整,项目的编译流程也相应地发生了变化。

假设有一个简单的C项目,它一共包含有三个源文件。按照我在上面介绍的第一种目录组织方式,这些文件被分别整理在项目根目录下的src与include文件夹内。而它们各自包含的内容则如下图所示:

其中,文件src/main.c为程序入口main函数的所在文件。而src/mod.h与include/mod.c两个文件,则一同为模块mod提供了相应的外部接口声明与具体实现。

按照我们之前的习惯,通过下面这行命令,便能够借助GCC编译器来完成对这个项目的编译过程。其中,我们指定了所有需要参与编译的.c文件。使用-I选项,我们为编译器指定了在查找头文件时,需要搜索的目录,即“./include”。而使用-l选项,程序运行时依赖的math数学库可以在运行时被顺利链接。

gccsrc/main.csrc/mod.c-I./include-lm-obin/main

到这里,你可能会觉得相较于单文件C应用来说,多文件C应用的编译也不过如此,只是命令中参与编译的源文件数量和使用到的配置项多了一些。

但随着项目体量的逐渐增大,这种编译方式会面临两个重要问题。首先便是如何对冗长的编译命令进行管理。这个问题关系到,我们是否可以清楚地知道项目每次编译时的具体状态,以及能否快速准确地对这些配置项进行相应修改。

其次,上述命令在每一次执行时,都仅会生成最终的二进制可执行文件,这使得编译的中间结果无法被有效利用。因此,代码在每一次修改后,都需要再次经历完整的编译流程。对于大型项目来说,这无疑降低了开发效率。

那有没有办法来解决这两个问题呢?答案是肯定的。首先来看,我们可以如何利用Makefile来进行结构化的C项目编译。

使用Makefile进行结构化编译

Makefile是一种在(类)Unix操作系统中常用的,用于组织项目代码编译流程的方式,它通常需要配合名为make的构建自动化工具一起使用。make最初由贝尔实验室的StuartFeldman于年实现,后来被整合到了Unix系统中。

make在执行时,会去搜索当前目录下名为Makefile的文本文件,并按照其内部指定的一系列规则,有序地对项目进行编译。

比如,对于上面提到的例子,我们可以编写如下所示的一段文本内容,并将它保存到项目根目录下名为Makefile的文件内。紧接着,通过在该文件所在目录下直接执行make命令,项目得以被正确编译。

bin/main:src/main.csrc/mod.c

gccsrc/main.csrc/mod.c-I./include-lm-obin/main

Makefile使用了一种与声明式编程语言类似的简化语法,以方便开发者灵活配置项目的编译流程。这里,上述配置文本的第一行指定了一个编译目标(bin/main),以及与该目标相关的依赖文件(src/main.c与src/mod.c)。而接下来以Tab键缩进的所有行(这里即第二行),均用于配置依赖文件到目标文件的编译转换细节。可以看到,我们使用了与之前完全相同的命令来实现这个过程,但是两者在编译时的差异已逐渐显现。

通过这种方式,我们已经部分解决了之前提到的问题,即每一次代码修改后,由于直接运行编译命令导致“全量编译”,进而带来的开发效率下降。make命令在每次实际进行编译前,都会首先追踪各个编译目标与其依赖项的版本信息(通常为“最后修改时间”)。而只有当相关依赖的内容在上一次编译后发生改变,或目标文件不存在时,才会再次编译该目标。通过这种方式,我们可以将大部分时间内的项目编译过程都集中在必要的几个源文件上,而不用“浪费”已编译好的其他中间目标文件。

接下来,我们尝试进一步优化Makefile中的配置项,来让最终的二进制编译目标与各个中间依赖项作进一步分离。并且,通过抽离编译命令中的可配置部分,我们也可以让整个编译脚本变得更具可读性与可用性。优化后的文件内容如下所示:

#用于控制编译细节的自定义宏;

CC=gcc

CFLAGS=-I./include

LDFLAGS=-lm

TARGET_FILE=bin/main

#描述各个目标的详细编译步骤;

$(TARGET_FILE):$(patsubstsrc/%.c,src/%.o,$(wildcardsrc/*.c))

$(CC)$^$(LDFLAGS)-o$

src/%.o:src/%.cinclude/%.h

$(CC)$$(CFLAGS)-c-o$

可以看到,通过以“#”开头的注释信息,我们将整个Makefile文件的内容划分成了两个部分。

第一部分包含用户可配置的一些宏常量,这些宏将在make运行时被替换到下面已经配置好的具体编译命令中。这样,用户可以通过修改这些量值来在一定范围内自定义期望使用的编译流程。

而第二部分则对应于各个编译目标的具体编译细节,这里我们将最初的那条编译命令拆分成了如下两步:

1.编译器将src与include文件夹内同名的.c与.h文件编译为对应的.o对象文件;

2.编译器将所有的.o文件一次性编译,并生成最后的二进制可执行文件。

利用这种方式,我们增加了可复用的中间编译结果,使通过make命令进行的每一次编译过程,都仅局限在被修改的.c或同名的.h文件上。如此一来,我们便可以做到最大程度上的“中间结果复用化”。

为了帮你理解这部分配置代码,我将代码中你可能不太熟悉的Makefile语法元素的含义进行了整理,并放在了下面的表格中,你可以参考:

Makefile帮助我们很好地解决了单一编译命令具有的可读性低、中间结果复用性差等诸多问题。

不过,仔细观察后你会发现,我们在Makefile中使用的各类命令与参数选项,都与程序当前运行所在的操作系统和平台直接相关。那么,当同一个Makefile文件被拷贝到其他环境中时,它是否还能正常工作呢?答案是“itdepends”。但很明确的是,“Makefile+make”这种方式,本身就无法直接在除了(类)Unix以外的其他操作系统上使用。因此,如何进一步满足C项目的跨平台自动化编译,便成了社区思考的另一个重要方向。接下来我们看看这个问题是如何被解决的。

使用CMake进行跨平台的自动化构建

“抽象”通常是用来解决这类问题的一大法宝。为了保证项目编译脚本的可移植性,我们便不能使用与具体软硬件实现相关的各类信息。因此,我们可以采取这样一种简单的方式:通过提供平台无关的中立配置选项,把与项目构建相关的所有重要特征“抽离”出来。并且,在项目开始真正编译之前,再根据目标平台的具体情况对项目进行构建。

接下来我为你介绍的工具CMake(Cross-platformMake)便是按照这样的思路实现的。只不过相较于直接编译代码,CMake会根据所在平台的具体情况,生成相应的“平台本地构建项目”。比如,在(类)Unix系统上,它会生成项目对应的Makefile文件;而在Windows系统上,则会生成项目对应的VisualStudio工程文件。在此基础上,再利用所在平台上的相关工具,CMake便可完成项目的真正构建。

同Makefile类似,CMake规定用于描述项目编译细节的配置信息,也需要被保存在名为CMakeLists.txt的文本文件中。作为对比,你可以使用如下所示的这段CMake配置信息,来编译我们在这一讲开头处介绍的那个C项目。关于其中每一行“代码”的具体作用,你可以参考它们上方的注释来进一步理解。

cmake_minimum_required(VERSION.10)

#设置项目名称;

project(Test)

#设置二进制目标文件名称;

set(TARGET_FILE"main")

#添加源文件目录;

aux_source_directory(./srcDIR_SRCS)

#设置二进制目标文件的依赖;

add_executable(${TARGET_FILE}${DIR_SRCS})

#设置头文件查找目录;

target_include_directories(${TARGET_FILE}PUBLIC"${PROJECT_SOURCE_DIR}/include")

#设置需要链接的库;

target_link_libraries(${TARGET_FILE}PUBLICm)

可以很明显地看到,相较于Makefile,CMake的配置信息更加清晰易懂。比如,关于命令target_include_directories的具体用途,我们从它的名字上就能猜个大概。实际上,它便对应于GCC编译器的-I参数,可用于指定查找头文件时的搜索目录。

当项目的CMakeLists.txt文件编写完毕后,通过下面这几个步骤,我们便能够完成项目的编译:

1.使用命令mkdirbuildcdbuild创建并进入用于存放编译结果文件的临时目录;

2.使用命令cmake..生成本地化构建项目。这里,CMake会根据用户在CMakeLists.txt中指定的信息,来对当前环境进行相关检查,其中包括针对编译器的ABI、可用性,以及支持特性的检查等。而当检查结束后,CMake便会根据检查结果,动态生成可用于支持项目在当前环境下进行编译的本地化构建项目;

.使用命令cmake--build.让CMake利用本地的相关工具,完成项目的最终编译过程。

其他可选用工具

其实,早在CMake出现之前,GNU旗下就已经出现了类似的跨平台自动化构建工具,即Autotools工具集。它们可以帮助我们把各类项目的源代码移植到多种不同的Unix系统上。但由于其学习成本较高,使用较为繁琐,因此它们在逐渐被CMake取代。

当然,除此之外,还有Meson、Tup、Bazel等构建工具可供你选择。它们在使用方式上都不尽相同,你可以点击相应链接来了解更多信息。但从实际情况来看,具有完善的功能、成熟的社区及解决方案的CMake,无疑仍是目前进行C项目跨平台自动化构建的最佳选择。

1
查看完整版本: C语言结构化编译加速