对软件项目的构建,也就是build过程,就是对一堆源代码进行编译,生成最终可执行程序或库。Java和C++不一样,不是编译型语言,是解释型语言,但都需要一个build过程。
下面以C/C++语言为例来对代码的条件编译进行分析。
第一种情况,单个项目的构建,源文件结构如下:
main.c
moduleA.c
moduleB.c
......
......
这是单个项目,包含全部的源码进行编译,每个源文件会生成obj文件,再将这些obj文件进行链接,生成最终的可执行文件。这种方式就是所有的obj文件的内容整合到一起,生成二进制可执行文件或库文件。所以如果object文件中,在同样作用域中,有重名的符号,就会编译错误。C和C++的符号系统是不同的,比如C++的函数符号的签名里使用了函数参数,而C语言就没有。C++可以有不同参数列表的同名函数,而C就不可以有同名函数,即使参数列表不同。C/C++的函数签名和函数返回类型无关。
针对这种情况,只是最简单的软件项目,而从产品角度讲,产品的功能是由软件和硬件共同完成的。比如通用型的PC上运行的软件,或者各种嵌入式电子设备上跑的软件。通用型的PC,硬件平台都有一套规范和标准,比如键盘、显示器、网口、USB接口等,即使硬件上有差异,比如CPU架构不同等,也会在操作系统上进行隔离,所以同样的软件,行为是一样的。这是对庞大的PC市场的一种适配性和兼容性的管理。而对嵌入式设备来说,因为硬件配置可能各不相同,所以同样的软件,体现在产品上,行为就会不同,比如软件控制GPIO输出电平信号,有的硬件配置是使用的LED灯做外设终端,有的使用蜂鸣器做外设终端。
或者这样理解,软件是基于一定的输入,进行运算等处理,然后将结果输出。而输入输出的接口的功能和模式,是依赖于硬件提供的,并且软件的运行也是基于硬件平台。软件基于硬件而设计和实现,硬件需要适配软件的接口模式和兼容运行平台。
第二种情况,软件项目集合的构建。这种情况,对应的一般是多个产品,比如嵌入式电子产品中的多个型号或一系列的产品。这些产品共用一套代码库,按软件架构、功能等,将代码库分成各个组件,不同产品会复用这些组件。不同项目会根据不同配置来进行构建。编译出来的软件,会适配相应的的硬件接口,包括主芯片架构、相关外设等。整个产品的行为则由相应的硬件和软件共同决定。
相比上面第一种所有源文件参与编译和使用固定的构建过程,第二种情况下,会根据项目或产品不同,执行不同的构建过程。比如使用Keil或IAR来创建工程时,不同的产品会使用不同的工程文件,还会按照功能区别,选择添加不同组件,以及组件内的的源文件。一些源文件可以是此项目特有的,一些源文件是公用的。通过管理变和不变,使得各个项目的代码都在一个代码仓库中,方便管理和复用。如果使用Makefile的构建方式,原理也是类似的,Makefile就相当于工程文件。通过传入变量给Makefile或CMake,来改变build脚本的行为。
每套单独的硬件是固定的,而软件是灵活的。软件的许多源文件,可以按组件分类,再按照构建需要,选择组件,并进行相应的设置,编译最终的可运行软件。这种构建时的要求就比较高,复杂度也高一些。比如要使用Make、CMake等工具,来对构建过程进行管理。
构建完成的目标软件,可以不止适配一套硬件,也可以适配多套硬件,比如主芯片相同、外设功能不同的相似硬件。这样构建出的软件可以认为是多套硬件对应的单个软件所组合起来的超集。比如Linux里使用的Device Tree技术,就是将硬件信息通过bootloader传给kernel,Kernel会加载不同的硬件驱动,并且在Linux运行的APP也可以根据不同的硬件信息,提供不同的功能。这是软件的增量式构建,一套软件多种用途。而很多时候我们也需要减法操作,在融合了多个项目的代码仓库中,为某个产品编出一个不多不少的恰到好处的目标软件,就如整篇文章所讲述的情况。
第三种情况,基于第二种情况,引入了库。在构建相应项目时,将每个模块编译成静态库,其他源码使用头文件来引用库的功能。做成静态库,库里面没有使用到的object文件就不会被链接进来。
项目结构如下:
main.c
libA.a (moduleA.c)
libB.a ( moduleB.c)
......
......
将编译出的obj文件打包,就会生成静态库。命令是:
$ gcc -c -o out.o out.c
$ gcc -c a.c b.c c.c
$ ar rcs libout.a out.o a.o b.o c.o
如果在链接过程中使用了静态库的话,和链接object文件不同,不是所有内容最后都会被链接到最终可执行文件中,没有使用到的代码,比如函数或变量,是不会包含到最终的可执行文件中的。
使用了静态库,在编译期来决定某个源文件是否可能被使用。以C++代码为例,如果使用了某个子类,那父类的代码也需要,就会编译进来。
这里会出现一个问题,将某个组件加入到可执行软件中,是需要调用组件的相关函数才行。那不需要使用的组件,就不要调用。
第四种情况,理想情况下,各个组件是按需参与构建,不要的组件就不会包含到最终软件中。但由于软件功能的复杂性,各个组件之间会存在各种耦合。比如,互相之间存在函数调用。在实际运行中,某组件并不会使用,但在编译期从代码分析上却体现不出来。这就导致不被使用的组件也会参与编译,并被链接进最终的可执行软件。
这样就需要从代码层面来解决组件之间的耦合问题,尽量在软件设计上避免或减少组件间的互相依赖。比如通过引入一个中心消息组件,来减少组件之间的直接函数调用,而改为收发消息。代价就是可能会降低软件性能。如果每个组件是独立进程,就要使用进程间通信来处理组件间通信,处理耦合反而容易。如果每个组件是属于不同线程,或直接使用轮循方式执行任务,处于同一内存空间,则组件之间很容易直接调用,执行和开发效率很高,但增加了软件的熵。有一种方法,可以给每个组件的窗口模块(源文件)或窗口类加一个空实现。如果不要这个组件,那就选择这个空实现的模块文件或类文件参与构建。如果要使用这个组件,在构建时就把正常的窗口类包含进来参与编译。
还有一种方法, 使用预处理宏作为编译开关,将组件中的耦合代码进行隔离。正常情况下,应该有一个关键的调用接口层,比如上面的窗口类,将这些代码注释掉就应该可以了。这个编译开关,是在编译时的命令行参数中加入的宏定义。这种要改代码,引入了额外的工作量。需要评估。
如果是需要一个恰如其分的不引入多余源码的目标软件,在决定引入组件的主函数或窗口类的关键代码处,就应该注意。不使用这个组件的话,就不要引入。否则,就可能把这个组件的大部分代码包含进来了。你引入了组件主函数或窗口类,但其实后面又没用到,就亏了。这部分代码,可以每个产品或项目使用不同的源码文件。或者代码里,使用项目或产品的编译开关来控制组件的引入与否。
能在编译期,通过构建脚本来管理不同项目参与编译的源文件是更好的,一个地方把这个问题都解决。而不要在代码层面引入编译开关和修修改改,这会添加不必要的信息,也让阅读代码的人产生困惑。
实际操作举例
1,我们使用cmake来构建项目,在构建时传给cmake一个项目参数,比如machine name。
2,在cmake中,使用optiotn函数设定一些选项,用于描述machine的功能。后面将这使用这些选项来选择参与构建的组件、选择组件内参与构建的源文件,以及定义源文件内使用的编译开关。
3,在cmake中,根据machine name来设置option项。
4,在cmake中,通过add_definitions命令,通过命令行参数在编译源文件时加入编译开关。
构建命令:
$ mkdir build
$ cd build
$ cmake ../ -DMACHINE=APPLE_DEVICE
$ make
CMakeLists.txt
option (FEATURE_WIFI "Wifi" OFF)
if ("${MACHINE}" STREQUAL "APPLE_DEVICE")
set(FEATURE_WIFI ON)
endif()
if(FEATURE_WIFI)
add_library(wifi
src/wifi_main.c
src/wifi_driver.c
src/wifi_msg.c
)
endif()
if(FEATURE_WIFI)
add_definitions(-DENABLE_WIFI=1)
endif()
代码里:
int communication_wifi(void)
{
#if ENABLE_WIFI
// Call Wifi function
#endif
return 0;
}