如果你曾经为Makefile的语法而苦恼,遇到过制表符与空格使用上的问题,或者尝试让构建系统在Linux、macOS和Windows上都能正常运行,那么SCons绝对值得你关注。它用一个统一的工具取代了Make、autoconf和automake,而且每一个构建文件实际上都是一段Python脚本。

这本手册会从基础开始教你如何使用SCons。你会学习如何安装它,如何使用它来构建一个包含多个文件的C++项目并生成静态库,还会了解如何为嵌入式系统(比如高通的QuRT实时操作系统)进行交叉编译,并理解SCons与Make、CMake之间的区别所在。

读完这本书后,你将会掌握一个可以自己根据需要进行调整的构建系统。

所有的示例代码都是独立的,你可以直接将其输入到命令行中运行,每一步都会产生实际的输出结果供你查看。

目录

先决条件

您需要在自己的系统上安装Python 3.7或更高版本。同时,还需要一台C++编译器(GCC、Clang或MSVC)。假设您已经了解基本的C/C++编译原理,即编译器和链接器的功能。虽然有使用Make或其他构建系统的经验会更有帮助,但并不是必需的。

对于涉及QuRT交叉编译的部分,您需要在自己的机器上安装高通Hexagon SDK。这些内容是独立存在的,因此如果您只对原生构建感兴趣,也可以跳过这些部分。

什么是SCons?它为什么会被开发出来?

SCons是一款开源的、跨平台的软件构建工具,完全是用Python编写的。Steven Knight在2000年8月的设计赢得了Software Carpentry举办的SC Build竞赛后,于2001年创建了这款工具。

这次竞赛要求参赛者设计一款更好的构建工具,而Knight提交的“ScCons”方案最终胜出了其他竞争方案。后来,在该项目从Software Carpentry分离出来之后,其名称被简化为“SCons”。

Knight的设计在很大程度上借鉴了Bob Sidebotham在20世纪90年代末开发的基于Perl的构建工具Cons。Cons引入了一些在当时非常新颖的理念:基于文件内容的变更检测机制(使用MD5哈希值而非时间戳)、自动扫描C/C++头文件的依赖关系,以及一个能够消除递归Make所带来问题的全局依赖关系图。

SCons将这些理念全部采用进来,并用Python重新实现了它们,同时还添加了适当的配置接口、跨平台支持功能,以及通过Python的对象模型来实现扩展性。

目前,这个项目由William Deegan和Gary Oberbrunner负责维护,它是在MIT许可证下发布的。当前的稳定版本是4.10.x。开发工作在GitHub上进行,社区成员则通过Discord服务器、IRC(Libera.Chat上的#scons频道)以及邮件列表进行交流。

SCons的工作原理

SCons的核心理念非常简单:构建文件应该用真正的编程语言来编写,而不是那种具有特殊语法规则的领域特定语言。

一个SConstruct文件其实就是一段Python脚本。您可以使用循环、条件语句、函数、类,以及系统中所有的Python库。没有任何需要记住的特殊语法规则,也不会出现因为制表符的使用不同而导致构建失败的问题,更不会因为空格和制表符的区别而影响构建过程。只要你会写Python代码,就能编写SCons的构建文件。

SCons在确定哪些文件需要重新编译方面也与Make有所不同。Make会比较文件的时间戳;如果您执行了touch main.c命令,即使文件内容没有发生变化,Make也会重新编译它。

SCons则会为每个源文件计算一个内容哈希值(默认使用MD5算法)。如果文件内容没有改变,SCons就会跳过重新编译的步骤。这样就可以避免许多不必要的重新编译操作。同时,这也意味着您永远不需要执行make clean命令,因为您不必担心构建状态是否一致。由于SCons是根据文件内容来跟踪变化的,所以它的构建状态始终是正确的。

已有几项大型项目在生产环境中使用了SCons。Godot游戏引擎就采用SCons作为构建系统;MongoDB多年来也一直在使用SCons;嵌入式开发平台PlatformIO同样以SCons为核心构建工具。National Instruments在那些包含超过5,000个源文件的项目中也曾使用过SCons。NSIS以及一些航空航天项目(包括Aerosonde无人机项目)也都依赖SCons来完成构建工作。

SCons与Make、CMake和Meson的比较

了解SCons相对于其他构建工具的优势,有助于你决定在什么情况下使用它。

SCons与Make的对比

Make使用一种自定义的DSL进行构建操作,但这种语言极其繁琐且易出错——制表符的使用规则非常严格(本应使用制表符的地方如果使用了空格,就会导致错误);变量扩展规则也很复杂,存在多种不同的表达方式(=:=?=+=);对于C/C++头文件的依赖关系检测,要么需要手动配置,要么需要借助makedepend这样的外部工具,或者编译器生成的.d文件。

对于多目录项目而言,递归式的Make构建方式很容易忽略跨目录的依赖关系——这个问题在Peter Miller于1997年发表的论文《递归式Make的危害》中就有详细阐述。

SCons解决了所有这些问题。它能够自动扫描C/C++源文件,在一次操作中为所有目录生成完整的依赖关系图,并且使用内容哈希而不是时间戳来管理依赖关系。

不过,这种机制也会带来启动速度上的影响。SCons在开始构建之前必须读取所有的构建文件并生成完整的依赖关系图,这就增加了额外的处理开销,而Make则没有这样的问题。对于规模较小或中等的项目(源文件数量不超过几千个),这种开销可以忽略不计;但对于那些包含数万个文件的大型项目来说,每次构建操作所花费的时间可能会增加几秒钟。

SCons与CMake的对比

CMake本身并不是一种构建工具,而是一个元构建系统——它能够生成Makefile、Ninja文件或Visual Studio项目文件。你需要编写CMakeLists.txt文件,然后运行cmake来生成相应的构建文件,最后再运行makeninja来进行实际构建。

SCons则可以直接进行构建操作,无需任何中间生成步骤。CMake拥有更为庞大的生态系统,与各种IDE的集成程度也更高(它可以生成Xcode项目文件、Visual Studio解决方案以及CLion配置文件);同时,它还提供了大量的find_package模块,可以帮助用户查找Boost、OpenSSL和Qt等第三方库。而SCons在这些方面并不具备相应的优势。

不过,在简洁性和易调试性方面,SCons显然更胜一筹。你的构建文件是用Python编写的,因此你可以使用print()函数来输出变量值,用pdb工具设置断点,利用列表推导式进行代码编写,甚至可以调用任何Python函数。而CMake的自定义构建语言则更难调试,其作用域规则也相当复杂,而且还需要学习一种在其他地方根本不会使用的语法。

SCons与Meson的对比

Meson是一种较新的构建工具,它能够生成Ninja文件,从而实现快速的并行构建。该工具使用了一种自定义的DSL,这种DSL被有意设计成非图灵完备的。在配置阶段,你无法对源文件进行循环操作,也不能调用任意的外部程序。这看似有些限制,但实际上这样的设计可以有效避免一类常见的构建文件错误(比如那些会依赖于其他开发者机器上不存在的环境变量而导致的错误)。

对于大型项目而言,Meson比SCons更快,因为它的后端工具Ninja针对增量构建进行了高度优化。此外,Meson还通过专门的“cross file”格式提供了更好的跨平台编译支持。

SCons借助Python提供了更大的灵活性,但Meson那种更为严格的设计方式能够在配置阶段及时发现更多问题,从而提高构建速度。

简而言之:当你需要在构建文件中充分利用Python的功能,或者需要根据文件内容自动检测是否需要重新构建时,又或者你正在使用已经基于SCons的项目进行开发,再或者你的项目涉及一些特殊的工具链或文件类型,那么就应该选择SCons。

而当集成IDE和生态系统的重要性高于构建速度时,就应该选择CMake。

Make与SCons的对比分析

通过观察同一个项目在Make和SCons中的实现方式,就能清楚地了解它们之间的差异。以一个包含两个C文件和一个头文件的简单项目为例吧。

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', 'utils.c'])

CC = gcc
CFLAGS = -Wall -O2
OBJECTS = main.o utils.o

myapp: $(OBJECTS)
\((CC) \)(CFLAGS) -o \(@ \)^

main.o: main.c utils.h
\((CC) \)(CFLAGS) -c $<

utils.o: utils.c utils.h
\((CC) \)(CFLAGS) -c $<

clean:
.rm -f myapp $(OBJECTS)

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('myapp', ['main.c', '

安装SCons

最简单的安装方法是使用pip,因为SCons是一个纯Python包,没有任何编译后的依赖项。

pip install scons

这样就可以在全球范围内(或在你当前激活的虚拟环境中)安装scons命令。在PyPI上,这个包的名称是SCons。在某些系统中,如果你需要使用Python 3,可能需要用pip3代替pip

你也可以通过系统的包管理器来安装:

# Debian / Ubuntu
sudo apt install scons

# Fedora
sudo dnf install scons

# 使用Homebrew的macOS
brew install scons

# Arch Linux
sudo pacman -S scons

# Conda
conda install -c conda-forge scons

使用pip install命令可以从PyPI下载SCons包,并将scons可执行文件添加到你的PATH环境变量中。系统的包管理器也会完成同样的操作,只不过它们会与操作系统的包数据库集成在一起。这两种方法都是可行的。pip安装的方式通常能让你获得最新版本的SCons,而通过系统包管理的方式安装的版本可能会滞后一到两个版本。

你可以通过查看版本号来确认是否已经成功安装了SCons。

scons --version

输出结果会显示SCons的版本号以及它所运行的Python版本。如果这个命令无法执行,那就请检查你的Python脚本所在目录是否被添加到了PATH环境变量中。在Linux系统中,用户安装的Python脚本通常位于~/.local/bin目录下;而在使用Homebrew的macOS系统中,这个目录通常是/usr/local/bin/opt/homebrew/bin

在编写构建文件之前需要了解的核心概念

SCons是围绕五个核心概念来组织构建过程的。在开始编写任何代码之前理解这些概念,可以避免以后出现混淆。

SConstruct构建文件

这是最高级别的构建文件。当你在某个目录中运行scons命令时,系统会查找名为SConstruct的文件(首字母大写,不带文件扩展名)。SCons也接受Sconstructsconstruct这两个替代名称,但通常还是使用首字母大写的版本。

这个文件实际上是一个Python脚本,它定义了需要构建什么以及如何进行构建。每个项目都只有一个SConstruct文件,而且这个文件会位于项目的根目录中。

SConscript构建文件

这些是用于子目录的辅助构建文件。最高级别的SConstruct文件会通过调用SConscript('src/SConscript')来从src目录中获取构建配置信息。

在SConscript文件中指定的所有文件路径都是相对于该文件所在的位置的,而不是项目根目录的。路径开头加上#符号表示“相对于SConstruct文件所在目录”,这样就可以方便地从任意深度的SConscript文件中引用共享的包含文件目录了。

例如,#include命令始终会引用项目根目录下的include目录,而无论SCConscript是在哪个子目录中使用这个命令。

构建环境

这是一个Python对象(通过Environment()创建),它包含了构建过程中所需的所有配置信息:应该使用哪种编译器、需要传递哪些参数、头文件位于何处、以及应该链接哪些库。你可以为不同的构建配置创建多个这样的环境(比如调试版本与发布版本的配置,或者本机编译版本与跨平台编译版本的配置)。

每个环境都有一组构建变量(如CCCCFLAGSCPPPATHLIBS)以及一组构建工具(如ProgramLibraryObject)。当你使用env.Append()env.Replace()修改某个环境时,你会改变之后所有基于该环境的构建操作所使用的配置。如果需要隔离这些更改,可以先使用env.Clone()复制该环境。

构建工具方法

这些都是Environment对象上的方法,它们能够生成特定类型的输出结果。

  • env.Program()用于编译并链接可执行文件。

  • env.StaticLibrary()用于创建静态库(在Linux系统中为.a文件,在Windows系统中为.lib文件)。

  • env.SharedLibrary()用于创建共享库(在Linux系统中为.so文件,在macOS系统中为.dylib文件,在Windows系统中为.dll文件)。

  • env.Object()用于将单个源文件编译成目标文件。

  • env.Command()用于运行任意的shell命令。

每个构建工具都会返回一个Node对象列表,这些对象代表了它最终会生成的文件。对于SCons不识别的文件类型,比如协议缓冲区定义文件、着色器文件或固件镜像文件,你可以自行定义相应的构建工具。

Node对象

这些是SCons内部用来表示文件和目录的对象。当你调用env.Object('main.cpp')时,得到的是一个Node对象,而不是一个字符串。你可以将Node对象传递给其他构建工具,使用+运算符将它们连接起来,并且可以在任何SCons期望接收文件引用的地方使用这些对象。

使用Node对象而不是原始字符串,可以使你的构建文件在不同平台上都具有兼容性,因为SCons会内部处理与平台相关的文件扩展名和路径分隔符。

你也可以显式地创建Node对象:File('foo.c')用于创建一个文件Node对象,Dir('src')用于创建一个目录Node对象,而Entry('ambiguous')则会创建一个类型不明确的Node对象,SCons会在之后确定它的具体类型是文件还是目录。

SCons中的三种环境

SCons区分了三种类型的构建环境,将它们混淆是很容易导致错误的原因。提前了解这些区别,可以帮助你避免遇到那些难以诊断的构建问题。

外部环境指的是你的shell所处的环境,在Python中可以通过`os.environ`来访问这个环境。它包含了诸如`PATH`、`HOME`、`PKG_CONFIG_PATH`之类的变量,以及你在`.bashrc`或`.zshrc`文件中设置的任何其他变量。

SCons并不会自动导入这个外部环境。这是有意为之的。如果SCons继承了你的shell环境,那么每次构建时所使用的环境设置都会取决于具体开发者的个人设置,从而导致构建结果无法复现。例如,在你的机器上能够成功构建的项目,可能在同事的机器上会因为`PATH`路径不同而失败,而SCons正是为了避免这类问题而设计的。

构建环境则是你在SConstruct文件中创建的`Environment()`对象。这个对象包含了用于控制SCons如何调用各种工具的配置变量。

  • CC用于指定C编译器。
  • CXX用于指定C++编译器。
  • CCFLAGS包含适用于C和C++编译的额外参数。
  • CPPPATH列出了头文件搜索路径。
  • LIBS列出了需要链接的库文件。
  • LIBPATH列出了库文件搜索路径。

这些变量并非来自你的shell环境。SCons会为它们设置适合当前平台的默认值(例如,在Linux系统中`CC`默认为`gcc`,而在使用MSVC的Windows系统中则默认为`cl`)。

执行环境是存储在构建环境中的字典对象,其键值为`env['ENV']`。当SCons运行编译器、链接器等子进程时,这些变量会被传递给这些子进程。

默认情况下,执行环境中会包含一个最基本的`PATH`路径,足以让编译器能够被找到。如果你的构建工具需要额外的环境变量(比如某些跨平台编译工具会依赖`HEXAGON_SDK_ROOT`这个路径),那么你必须明确地将这些变量添加到`env['ENV']`中。

当构建失败且提示“找不到某个工具”时,问题几乎总是出在:该工具存在于你的shell的`PATH`环境中,但并不存在于执行环境的`PATH`路径中。解决这个问题的方法是将其添加到执行环境的`PATH`中:

import os
env = Environment()
env['ENV']['PATH'] = os.environ['PATH']

这样就可以确保子进程能够使用与你在终端中相同的工具路径。

另一种更彻底的做法是写成`env = Environment(ENV=os.environ.copy())`,这种方式会复制所有环境变量,但这样一来构建结果的复现性就会受到影响,因为构建过程现在会依赖于你的shell中的所有设置。

构建变量参考

SCons提供了大量的构建变量。对于C/C++项目来说,那些你最常使用的变量确实值得记住它们的名称。CC 是用于调用 C 编译器的命令。默认情况下,系统会使用平台默认的 C 编译器:在 Linux 上是 gcc,在 macOS 上是 clang,在安装了 MSVC 的 Windows 上则是 cl。如果你需要使用其他编译器或交叉编译器,可以覆盖这个设置。

CXX 是用于调用 C++ 编译器的命令。其默认设置与 CC 相同,但适用于 C++ 语言。

CCFLAGS 用于指定在编译 C 和 C++ 代码时需要使用的参数。这些参数包括警告级别设置(如 -Wall)、优化选项(如 -O2),以及其他与编程语言无关但仍然需要配置的选项。

CFLAGS 也用于指定仅适用于 C 编译器的参数。例如,-std=c11 这个选项指定了 C 代码应遵循 C11 标准进行编译。

CXXFLAGS 用于指定仅适用于 C++ 编译器的参数。例如,-std=c++17 这个选项指定了 C++ 代码应遵循 C++17 标准进行编译。

CPPPATH 是一个目录列表,SCons 会在这些目录中查找头文件。每个目录都会被转换为 -I 参数。如果目录前缀为 #,则表示该目录是相对于 SConstruct 执行目录而言的。

CPPDEFINES 是一个预处理器定义列表。例如,env.Append(CPPDEFINES=['DEBUG', ('VERSION', '2')]) 这行代码会生成 -DDEBUG -DVERSION=2 这个参数。建议使用 CPPDEFINES 而不是直接在 CCFLAGS 中添加 -D 参数,因为 SCons 会将这些定义作为结构化数据进行管理,这样在重新构建项目时就能正确地应用这些设置。

LIBS 是一个库文件列表,程序链接时会使用这些库文件。例如,LIBS=['pthread', 'm'] 这行代码会生成 -lpthread -lm 这些链接参数。你也可以使用 StaticLibrarySharedLibrary 构建器返回的 Node 对象来指定库文件。

LIBPATH 是一个目录列表,SCons 会在这些目录中查找库文件。这个参数会被转换为 -L 链接选项。

LINKFLAGS 用于指定链接器需要使用的参数。这些参数包括 -nostdlib-Wl,--gc-sections-static 等选项,它们分别用于控制链接器的行为。

AR 是用于构建静态库的命令。在 POSIX 系统上,默认使用的命令是 ar

LINK 是用于调用链接器的命令。默认情况下,系统会使用 C 或 C++ 编译器来内部调用链接器。

PROGSUFFIX 是可执行文件的扩展名。在 POSIX 系统上这个字段为空,在 Windows 上是 .exe。通常不需要手动设置这个值,因为 SCons 会自动根据平台环境来确定正确的扩展名。

所有这些变量都可以在 Environment() 构造函数中设置,也可以通过 env.Append()env.Prepend()env.Replace() 方法来修改它们的值。此外,在每次调用构建器时,也可以通过传递相应的参数来覆盖这些默认设置。

你的第一个 SConstruct 文件

创建一个用于实验的目录,并在其中放置一个 C 语言程序文件。


// hello.c
#include 

int main() {
    printf("Hello from SCons!\n");
    return 0;
}

这是一个非常简单的 C 程序,它只是输出一条消息然后结束执行。这个程序的存在仅仅是为了让 SCons 有东西可以构建而已。

现在在同一目录下创建一个 SConstruct 文件。

Program('hello.c')

这一行代码就是一个完整的 SConstruct 文件。Program 是一个默认的构建工具,无需手动创建环境即可使用。在后台,SCons 会生成一个包含适合当前平台的编译器设置的环境,并用它来执行这个 Program 命令。该命令会让 SCons 编译 hello.c 并将其链接成可执行文件。

运行构建过程。

scons

SCons 会输出它所执行的编译和链接命令。在 Linux 系统上使用 GCC 时,你会看到类似 gcc -o hello.o -c hello.c 这样的命令,随后还会执行 gcc -o hello hello.o。最终生成的可执行文件名为 hello(在 Linux/macOS 上)或 hello.exe(在 Windows 上)。SCons 会通过去掉源文件扩展名来确定可执行文件的名称。

再次运行 scons,不要做任何更改。此时 SCons 会输出 “scons: 'hello' is up to date.” 并不会执行任何操作。它会读取 hello.c 的内容哈希值,与上一次构建时保存的哈希值进行比较,从而判断是否需要重新构建。这就是基于文件内容来检测是否需要重建的功能在起作用。

现在先运行 touch hello.c,然后再运行 scons。这次 SCons 仍然不会执行任何操作,因为 hello.c 的内容没有发生变化,所以其哈希值与之前相同。如果是普通构建过程,SCons 会重新编译这个文件,但在这个例子中它并没有这样做。

为了得到一个更贴近实际情况的示例,可以创建一个带有自定义参数的构建环境。

env = Environment(
    CC='gcc',
    CCFLAGS=['-Wall', '-Wextra', '-O2'],
)
env.Program('hello', 'hello.c')

在这个例子中,我们明确指定了编译器为 gcc,同时通过 -Wextra 启用了额外的警告选项,并通过 -O2 开启了优化功能。Program 命令现在接受两个参数:目标文件的名称 'hello' 和源文件的名字 'hello.c'。当你同时提供这两个参数时,就可以直接控制生成的可执行文件的名称。

你可以在同一个 SConstruct 文件中添加多个程序:

env = Environment(CCFLAGS=['-Wall', '-O2'])
env.Program('hello', 'hello.c')
env(Program('goodbye', 'goodbye.c')

运行 scons 会构建这两个可执行文件;而如果只输入 scons hello,则只会构建第一个程序。SCons 允许在命令行中指定目标文件的名称来有选择地构建特定文件。

逐步构建多文件 C++ 项目

使用单文件示例可以帮助你验证安装是否正确,但实际的项目通常会包含多个源文件、库以及头文件目录。这一节将介绍如何构建一个包含所有这些元素的实际项目。

该项目的结构如下所示:

myproject/
    SConstruct
    include/
        config.h
    lib/
        SConscript
        mathutils.h
        mathutils.cpp
        stringutils.h
        stringutils.cpp
    src/
        SConscript
        main.cpp
        app.h
        app.cpp

该图显示了一个项目,其根目录下包含三个子目录。include目录中存放着一个用于定义版本常量的共享配置头文件。lib目录包含了两个实用模块(分别负责数学运算和字符串操作),这些模块会被编译成一个名为libmyutils.a的静态库。src目录则包含了依赖该库的主程序代码。

每个包含可编译源文件的目录都对应着一个自己的SConscript文件,而顶层的SConstruct文件负责协调整个构建过程。

构建系统会先编译静态库,然后再编译应用程序,并将所有的构建结果保存在单独的build目录中,这样就可以保持源代码树的整洁。这种分离机制意味着你可以直接删除整个build目录,然后从头开始重新构建程序,而不会影响到任何源文件。

首先需要创建项目目录及其所有的子目录。

mkdir -p myproject/include myproject/lib myproject/src
cd myproject

这些命令会生成完整的目录结构。mkdir命令中的-p选项会根据需要自动创建父目录,即使这些目录已经存在也不会报错。

接下来开始创建各个文件。首先从共享配置头文件开始。

// include/config.h
#ifndef CONFIG_H
#define CONFIG_H
#define APP_VERSION "1.0.0"
#define APP_NAME "SCons Demo"
#endif

这个头文件定义了应用程序代码会引用的版本号和程序名称常量。其中的包含保护机制(#ifndef / #define / #endif)可以防止头文件被重复包含,这是C/C++编程中的标准做法。由于这个头文件位于include目录中,因此任何想要使用它的源文件都必须将其添加到头文件搜索路径中。SConstruct文件通过CPPPATH变量来处理这一配置。

接下来是数学实用库的实现:

// lib/mathutils.h
#ifndef MATHUTILS_H
#define MATHUTILS_H

int factorial(int n);
double circle_area(double radius);

#endif
// lib/mathutils.cpp
#include "mathutils.h"
#include 

int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

double circle_area(double radius) {
    return M_PI * radius * radius;
}

mathutils模块提供了两个函数:递归计算阶乘的功能以及计算圆面积的功能。头文件中声明了这些函数的接口,这样其他编译单元就可以调用它们;实现文件则具体定义了这些函数的实现逻辑。cmath头的包含使得程序能够使用数学常量M_PI

当SCons处理mathutils.cpp时,它会扫描其中的#include指令,从而发现mathutils.cpp既依赖于mathutils.h,也依赖于系统头文件cmath。如果你后来修改了mathutils.h,SCons会自动知道需要重新编译mathutils.cpp,而无需你手动指定依赖关系。
现在来看这个字符串处理工具模块:

 // lib/stringutils.h
#ifndef STRINGUTILS_H
#define STRINGUTILS_H
#include 

std::string to_upper(const std::string& s);

#endif
 // lib/stringutils.cpp
#include "stringutils.h"
#include 
#include 

std::string to_upper(const std::string& s) {
    std::string result = s;
    std::transform(result.begin(), result.end(),
                   result.begin(), ::toupper);
    return result;
}

stringutils模块中包含一个函数,该函数利用标准库中的transform算法将字符串转换为大写形式。这里作为转换函数使用的::toUpperCase其实是来自头的C语言区域设置相关函数。与mathutils模块一起,这两个模块构成了一个小型实用工具库,应用程序在链接时会引用这些模块。
接下来是应用程序层代码:

 // src/app.h
#ifndef APP_H
#define APP_H

void run_app();

#endif
 // src/app.cpp
#include "app.h"
#include "config.h"
#include "mathutils.h"
#include "stringutils.h"
#include 

void run_app() {
    std::cout << "应用程序名称: " << APP_NAME << std::endl;
    std::cout << "版本信息: " << APP_VERSION << std::endl;
    std::cout << "5的阶乘等于: " << factorial(5) << std::endl;
    std::cout << "半径为3的圆的面积是: " << circle_area(3.0) << std::endl;
    std::cout << to_upper("hello scons") << std::endl;
}
 // src/main.cpp
#include "app.h"

int main() {
    run_app();
    return 0;
}

app.cpp文件引用了三个目录中的头文件:config.h来自include目录,mathutils.hstringutils.h来自lib目录,还有它自己所在的目录下的app.h
这种跨目录的依赖关系在实际项目中非常常见,而恰恰在这种情况下,Make工具的手动依赖管理方式容易出错。SCons能够自动处理这类问题。main.cpp文件被设计得相当简洁,所有功能都交由run_app()函数来执行。这种结构使得代码更易于测试,因为你可以将app.cpp与测试框架直接链接起来,而无需同时引入main.cpp
现在来看构建文件。首先从顶层的SConstruct配置文件开始:

# SConstruct
import os

env = Environment(
    CPPPATH=['#include', '#lib'],
    CCFLAGS=['-Wall', '-std=c++17'],
)

debug = ARGUMENTS.get('debug', '0')
if debug == '1':
    env.Append(CCFLAGS=['-g', '-O0', '-DDEBUG'])
    variant = 'build/debug'
else:
    env.Append(CCFLAGS=['-O2', '-DNDEBUG'])
    variant = 'build/release'

Export('env')

lib = SConscript('lib/SConscript',
                 variant_dir=variant + '/lib',
                 duplicate=0)

SConscript('src/SConscript',
           variant_dir=variant + '/src',
           duplicate=0,
           exports={'mylib': lib})

这个SConstruct文件是构建过程的控制中心。接下来的部分会详细分析每一行代码的作用。

库相关的SConscript文件如下:

# lib/SConscript
Import('env')

lib = env.StaticLibrary('myutils', [
    'mathutils.cpp',
    'stringutils.cpp',
])

Return('lib')

这个文件会导入共享环境,将两个库源文件编译成一个静态库,其在Linux系统上的名为libmyutils.a,在Windows系统上名为myutils.lib,最后将生成的库返回给调用者。

mathutils.cppstringutils.cpp这些源文件的路径是相对于这个SConscript文件所在的目录lib/而言的。由于SCons已经知道了这一路径信息,因此不需要手动写成lib/mathutils.cpp这样的形式。

应用程序相关的SConscript文件如下:

# src/SConscript
Import('env')
Import('mylib')

app = env.Program(
    target='myapp',
    source=['main.cpp', 'app.cpp'],
    LIBS=[mylib, 'm'],
    LIBPATH=['#build/release/lib', '#build/debug/lib'],
)

Return('app')

这个文件会导入共享环境以及之前生成的库文件,然后编译应用程序的源代码,并将其与myutils库以及数学计算库(-lm)链接在一起。LIBPATH指定了链接器应该去哪里查找libmyutils.a这个库文件。

由于同时列出了调试版本和发布版本的库文件路径,因此无论使用的是哪种构建版本,链接器都能找到相应的库文件。

项目中每个文件的详细解析

这一部分会逐行解释这些SConstruct和SConscript文件的内容。理解每一行的作用,是区分那些机械地使用构建系统的人与那些能够自信地对构建系统进行修改的人之间的关键。

SConstruct文件

import os

这是标准的Python导入语句。以后你可能会需要使用os.environ来将shell环境变量传递给构建过程,使用os.path.join来生成跨平台的文件路径,或者使用os.path.exists来检查是否安装了某些工具链。即使你现在不需要这些功能,在SConstruct文件中预先包含这些导入语句也是常见的做法。

env = Environment(
    CPPPATH=['#include', '#lib'],
    CCFLAGS=['-Wall', '-std=c++17'],
)

Environment()用于创建构建环境。这个配置对象包含了SCons编译和链接代码所需的所有信息。CPPPATH用于设置头文件的搜索路径。“#”前缀表示“相对于包含SConstruct的目录”,因此#include会解析为myproject/include#lib会解析为myproject/lib,无论哪个SConscript文件使用了这个环境设置。

当SCons调用编译器时,它会自动将CPPPATH中指定的路径转换为-I选项:即-Iinclude -IlibCCFLAGS用于存储传递给C和C++编译器的参数。-Wall选项会启用所有标准警告信息,-std=c++17则指定使用C++17标准。需要注意的是,-std=c++17属于语言标准相关选项,因此也可以放在CXXFLAGS中(仅针对C++编译器),但由于这个项目中没有C源文件,将其放在CCFLAGS中也不会产生任何问题。

debug = ARGUMENTS.get('debug', '0')
if debug == '1':
    env.Append(CCFLAGS=['-g', '-O0', '-DDEBUG'])
    variant = 'build/debug'
else:
    env.Append(CCFLAGS=['-O2', '-DNDEBUG'])
    variant = 'build/release'

ARGUMENTS是一个全局字典,SCons会从命令行输入的键值对中填充这个字典。执行scons debug=1会使得ARGUMENTS['debug']的值变为字符串'1'。由于get方法会在键不存在时返回默认值'0',因此直接运行scons时会以发布模式进行构建。

根据debug变量的值,代码会添加不同的调试选项(如-g用于生成调试符号以便GDB能够显示源代码行,-O0表示不进行优化,这样变量值就不会被优化掉,-DDEBUG则用于定义一个预处理宏,你可以通过#ifdef DEBUG来使用这个宏)或发布选项(如-O2表示进行优化,-DNDEBUG则表示禁用assert()语句)。

variant变量用于确定构建生成文件的输出目录。env.Append()方法会向现有的变量中添加新的内容,而不会覆盖原有的值。如果CCFLAGS已经包含了['-Wall', '-std=c++17'],那么再添加['-g', '-O0', '-DDEBUG']后,最终的CCFLAGS值将会是['-Wall', '-std=c++17', '-g', '-O0', '-DDEBUG']

Export('env')

Export方法可以让调用Import('env')的SConscript文件访问env变量。这是SCons在构建文件之间共享数据的一种机制,它通过SCons管理的全局命名空间来实现这一功能,而不是利用Python的模块导入系统。你可以导出任何Python对象,比如环境对象、字符串、列表、字典或Node对象。也可以一次性导出多个变量,例如Export('env', 'version', 'platform')

lib = SConscript('lib/SConscript',
                 variant_dir=variant + '/lib',
                 duplicate=0)

SConscript() 用于读取并执行子构建文件。它的第一个参数是指定相对于 SConstruct 文件而言的 SConscript 文件路径。variant_dir 参数会将所有构建生成的文件从 lib/ 目录重定向到相应的变体目录中(例如,build/release/lib)。这样就可以避免编译后的对象文件和库文件出现在源代码目录中。duplicate=0 这一设置会告诉 SCons 不要将源文件复制到变体目录中。

如果不使用这个选项,SCons 会在 build/release/lib 目录下创建源文件的副本,这样构建工具就能将源代码和生成文件放在同一个目录中了。但实际上这种重复操作很少是必要的,而且还会导致每个源文件都有两份副本,从而造成混淆。将 duplicate=0 设置为 true 可以让 SCons 直接引用原始的源文件。SConscript() 的返回值就是子构建文件中通过 Return() 返回的值,在这个例子中,返回值是一个表示已编译静态库的 Node 对象。

SConscript('src/SConscript',
           variant_dir=variant + '/src',
           duplicate=0,
           exports={'mylib': lib})

第二个 SConscript 调用用于读取应用程序的构建文件。参数 exports 与全局的 Export() 函数不同——它将库文件对应的 Node 对象(由子构建文件返回)以 mylib 这个名称传递给主构建脚本。

这种导出方式是具有作用域限制的:只有当前这个特定的 SConscript 调用才能接收到 mylib。主构建脚本可以通过 Import('mylib') 来获取这个库对象。这样,应用程序的构建文件就可以在不硬编码 `.a` 文件路径的情况下知道如何使用这个库了。

库文件的构建过程

Import('env')

Import 函数用于从 SCons 的全局导出命名空间中获取变量。这样就可以使用在 SConstruct 文件中通过 Export('env') 导出的环境配置了。在这行代码之后,env 就会引用在 SConstruct 中创建的同一个 Environment 对象。在这里对 env 进行的任何修改都会影响到整个系统中使用该环境的部分。如果需要进行局部修改,可以先使用 env.clone() 创建一个副本。

lib = env.StaticLibrary('myutils', [
    'mathutils.cpp',
    'stringutils.cpp',
])

env.StaticLibrary() 是一个构建工具,它会将列出的源文件编译成对象文件,然后使用 ar 命令将这些对象文件打包成一个静态库。

第一个参数是库的名称。SCons 会自动为库名添加适合当前平台的前缀和后缀:在 Linux/macOS 上会是 libmyutils.a,在 Windows 上则是 myutils.lib。因此完全不需要手动指定这些路径。源文件的路径是相对于当前 SConscript 文件所在的目录来计算的(这个目录默认是 lib/)。

SCons会自动扫描这些.cpp文件,查找其中的#include指令,以此确定程序对头文件的依赖关系。如果mathutils.cpp文件引用了mathutils.h,那么这种依赖关系就会被自动记录下来,而你无需采取任何行动。

Return('lib')

Return函数会将返回的库节点发送回调用它的SConstruct中的SConscript()函数。这里传递的字符串'lib'表示要返回的局部变量的名称,并非文件路径。这与Python中的return语句类似,但它是适用于SCons构建文件执行模型的。你可以返回多个值,例如:Return('lib', 'headers')

应用程序的SConscript脚本

Import('env')
Import('mylib')

这里进行了两次导入操作:首先是导入共享构建环境(通过全局的Export命令),其次是导入库节点(通过SConstruct文件中SConscript()调用的局部exports参数)。虽然这是两个独立的Import调用,但你也可以将它们写在同一行上,例如:Import('env', 'mylib')

app = env.Program(
target='myapp',
source=['main.cpp', 'app.cpp'],
LIBS=[mylib, 'm'],
LIBPATH=['#build/release/lib', '#build/debug/lib'],
)

env PROGRAM()函数用于编译源文件并将它们链接成可执行文件。target参数指定了输出可执行文件的名称(在Windows系统中,SCons会自动添加.exe后缀)。source参数列出了需要编译的C++源文件。源文件的顺序对最终结果没有影响,但通常习惯是将main.cpp放在列表的首位。

LIBS参数指定了需要链接到的库文件。直接传递mylib节点(而不是像'myutils'这样的字符串)是正确的做法,因为这样SCons就能准确知道所需的文件依赖关系;如果库文件发生了变化,SCons也会自动重新编译可执行文件。

'm'这个参数用于链接系统数学库(在命令行中需要使用-lm选项),这是因为mathutils.cpp文件使用了头文件中的函数。LIBPATH参数告诉链接器去哪里查找这些库文件,这在命令行中对应于-L选项。由于需要同时考虑调试版本和发布版本,因此这里列出了两种路径。

这些关键字参数(LIBSLIBPATH)仅针对当前的构建操作覆盖环境中的默认设置,并不会修改全局的env对象。

Return('app')

Return函数会将应用程序节点返回给调用者。在当前的示例中,SConstruct并没有使用这个返回值,但这样做是一种良好的编程习惯,因为这样可以为未来的扩展留出空间。例如,你以后可以在SConstruct脚本中添加env.Install('/usr/local/bin', app)语句,或者创建一个env.Alias('run', app, './build/release/src/myapp')别名,从而定义一个scons run命令。

运行构建过程并理解输出结果

当所有文件都已准备就绪后,请从项目根目录开始运行构建脚本。

scons

在Linux系统中使用GCC时,SCons会生成如下输出:

scons: 正在读取SConstruct文件…
scons: 读取SConstruct文件完成。
scons: 正在构建目标文件…
g++ -o build/release/lib/mathutils.o -c -Wall -std=c++17 -O2 -DNDEBUG -Iinclude -Ilib lib/mathutils.cpp
g++ -o build/release/lib/stringutils.o -c -Wall -std=c++17 -O2 -DNDEBUG -Iinclude -Ilib lib/stringutils.cpp
ar rc build/release/lib/libmyutils.a build/release/lib/mathutils.o build/release/lib/stringutils.o
ranlib build/release/lib/libmyutils.a
g++ -o build/release/src/main.o -c -Wall -std=c++17 -O2 -DNDEBUG -Iinclude -Ilib src/main.cpp
g++ -o build/release/src/app.o -c -Wall -std=c++17 -O2 -DNDEBUG -Iinclude -Ilib src/app.cpp
g++ -o build/release/src/myapp build/release/src/main.o build/release/src/app.o -Lbuild/release/lib -Lbuild/debug/lib build/release/lib/libmyutils.a -lm
scons: 构建目标文件完成。

前两行显示SCons正在读取所有的SConstruct和SConscript文件。在这一阶段,它会在内存中构建完整的依赖关系图,但此时尚未开始任何编译操作。

“构建目标文件”这一部分列出了实际执行的命令。每个`g++`命令都包含了从`CPPPATH`环境变量中获取的`-I`选项(例如`-Iinclude -Ilib`),以及来自`CCFLAGS`的环境变量中的选项(如`-Wall -std=c++17 -O2 -DNDEBUG`),还有用于编译的`-c`选项(该选项用于生成目标文件,而非进行链接操作)。

`ar rc`命令用于创建静态库文件包,而`ranlib`命令则会生成一个索引文件,以便链接器能够高效地查找各个符号的位置。

最后的`g++`命令会将所有生成的文件链接在一起。其中,`-L`选项指定了库文件的路径;显式的库文件路径也被用来完成链接过程;`-lm`选项则用于引入系统提供的数学函数库。

现在可以运行最终生成的可执行文件了:

./build/release/src/myapp

程序的输出结果如下:

应用程序:SCons演示版
版本:1.0.0
5的阶乘值:120
半径为3的圆的面积:28.2743
HELLO SCONS

每一行输出都对应着`run_app()`函数中的某次调用。程序的版本号和名称来自`config.h`文件,阶乘计算和圆面积的计算功能则由`mathutils`库实现,而大写字母的输出内容则来源于`stringutils`库。所有链接到的库文件都已正确安装,所有的头文件路径也都被正确解析了。

现在来构建调试版本吧:

scons debug=1

执行此命令后,会在`build/debug/`目录下生成对应的调试版本构建文件。而`build/release/`目录下的发布版本构建文件则不会被修改。

<您可以在这两种构建版本之间进行切换,而无需触发另一种版本的重新编译。每种构建版本都有自己对应的`.o`文件、`.a`库以及可执行文件。位于`build/debug/`目录下的文件结构与`build/release/`目录下的文件结构是完全相同的。

增量构建过程中会发生什么

了解SCons在第二次及后续构建过程中所执行的操作,有助于你信任这个系统,并能够诊断出那些意外的重建情况。

在成功完成一次构建后,再次运行scons命令。其输出结果如下:

scons: 正在读取SConscript文件…
scons: 读取SConscript文件已完成。
scons: 正在构建目标文件…
scons: 文件`.`已更新为最新版本。
scons: 目标文件的构建工作已经完成。

SCons仍然会读取所有的SConscript文件,并生成完整的依赖关系图。随后,它会遍历这个依赖关系图,检查每一个节点。

对于每一个源文件,SCons都会计算其内容哈希值,并将其与存储在.sconsign.dblite文件中的哈希值进行比较;对于每一个目标文件,它也会检查源文件的哈希值、编译命令以及各种选项是否与上一次构建时的配置相同。如果所有信息都一致,那么就不会有重新构建的操作发生。

现在,在lib/mathutils.h文件中添加一个新的函数声明:

// 在mathutils.h文件中添加这一行
int fibonacci(int n);

再次运行scons命令。SCons会重新编译mathutils.cpp文件(因为该文件包含了已修改的mathutils.h),同时也会重新编译app.cpp文件(因为它同样引用了修改后的mathutils.h);由于mathutils.o文件已经发生变化,静态库也会被重新打包;而因为库文件和app.o文件都发生了变化,可执行文件也需要被重新链接。

SCons不会重新编译stringutils.cpp文件(因为它并不包含mathutils.h),也不会重新编译main.cpp文件(因为该文件只包含了没有发生变化的app.h)。

这就是依赖关系图在发挥作用的过程。SCons能够清楚地了解整个构建链:由于mathutils.h文件发生了变化,因此所有直接或间接依赖于它的文件都会被重新构建;而那些不依赖于它的文件则不会被修改。你根本不需要手动指定这些依赖关系。

现在,在stringutils.cpp文件中添加一条注释,但不要更改任何实际的代码:

// 这只是一条注释
#include "stringutils.h"

再次运行scons命令。由于这条注释的内容发生了变化,SCons会重新编译stringutils.cpp文件。

但这就是SCons的聪明之处:在重新编译之后,它会计算新的stringutils.o文件的哈希值;如果编译器生成的 объект文件与之前的完全相同(对于仅仅包含注释的修改来说,这种情况很常见,因为注释并不会影响编译结果),那么SCons就不会重新打包静态库或重新链接可执行文件。

这种“短路处理”机制能够有效避免不必要的后续重建操作。而Make工具则无法实现这一点,因为它只关注文件的时间戳,而不关心文件的实际内容。

为QuRT(高通实时操作系统)进行交叉编译

SCons的一个显著优势在于:配置交叉编译过程时,并不需要使用专门的工具链文件格式(比如CMake所使用的工具链文件)。你可以使用Python语言,通过已经熟悉的Environment API来完成所有配置工作。

什么是QuRT

QuRT是高通公司自主研发的实时操作系统,它运行在Snapdragon处理器中搭载的Hexagon数字信号处理器上。Hexagon数字信号处理器是Snapdragon系统芯片中的独立处理核心,与用于运行Android或Linux系统的ARM应用核心截然不同。

虽然ARM核心负责处理用户界面及一般的应用程序逻辑,但Hexagon数字信号处理器专门用于执行计算强度高、对延迟要求严格的任务,比如音频处理、传感器数据融合、相机图像处理以及机器学习推理等。

QuRT为Hexagon数字信号处理器提供了线程管理、内存管理以及中断处理功能。它是一种基于微内核的实时操作系统,并能提供硬实时保障:中断处理的延迟具有明确的范围且可预测,这对于音频处理这类对时间要求极高的应用来说至关重要,因为任何延迟都可能导致明显的音质问题。QuRT支持类POSIX的线程模型(使用qurt_thread_create而非pthread_create),同时也支持互斥锁、信号量、内存映射I/O等功能。

要开发基于QuRT的应用程序,需要使用Hexagon SDK。该工具包包含了Hexagon编译器(hexagon-clanghexagon-clang++)、链接器、汇编器、压缩工具,以及专为QuRT设计的系统头文件和库文件。SDK还提供了一个模拟器(hexagon-sim),允许在没有实际硬件的情况下,在开发机器上运行Hexagon二进制文件进行测试。

Hexagon SDK的目录结构

Hexagon SDK遵循特定的目录结构,了解这一结构对于配置构建系统来说是非常必要的。典型的安装路径如下:

$HEXAGON_SDK_ROOT/
    tools/
        HEXAGON_Tools/
            8.8.06/
                Tools/
                    bin/
                        hexagon-clang
                        hexagon-clang++
                        hexagon-ar
                        hexagon-ranlib
                        hexagon-as
                        hexagon-sim
                    include/
                    lib/
    rtos/
        qurt/
            computev66/
                include/
                    qurt.h
                    qurt_thread.h
                    qurt_mutex.h
                    posix/
                lib/
                    libqurt.a
            computev73/
                include/
                lib/
    libs/
        common/

tools/HEXAGON_Tools目录中包含了编译工具链。版本号(如8.8.06)对应于Hexagon Tools的具体版本。rtos/qurt目录中存放着QuRT内核的头文件和预构建库文件,这些文件是根据不同的架构版本进行分类组织的。computev66针对的是Hexagon V66架构(常见于较早期的Snapdragon芯片),而computev73则适用于V73架构(如Snapdragon 8 Gen 2系列芯片)。由于不同架构版本的内核编译方式有所不同,因此每个版本都有独立的includelib目录。

交叉编译构建脚本

以下SConstruct文件为QuRT配置了交叉编译环境。该脚本假定Hexagon SDK已安装,并且环境变量HEXAGON_SDK_ROOT指向该SDK的安装路径。

# 用于QuRT与Hexagon之间的交叉编译的SConstruct脚本
import os
import sys

hexagon_sdk = os.environ.get('HEXAGON_SDK_ROOT',
                              '/opt/hexagon/sdk')
if not os.path.isdir(hexagon_sdk):
    print('错误:HEXAGON_SDK_ROOT未设置或该目录不存在')
    print('请使用以下命令设置它:export HEXAGON_SDK_ROOT=/path/to/hexagon.sdk')
    Exit(1)

hexagon_tools = os.path.join(hexagon_sdk, 'tools', 'HEXAGON_Tools')
hexagon_ver = os.environ.get('HEXAGON_TOOLS_VER', '8.8.06')
tool_base = os.path.join(hexagon_tools, hexagon_ver, 'Tools')
tool_bin = os.path.join/tool_base, 'bin')

hexagon_arch = ARGUMENTS.get('arch', 'v73')
qurt_root = os.path.join(hexagon_sdk, 'rtos', 'qurt')
qurt_variant = 'compute' + hexagon_arch
qurt_inc = os.path.join(qurt_root, qurt_variant, 'include')
qurt_lib = os.path.join(qurt_root, qurt_variant, 'lib')

env = Environment(
    CC=os.path.join-tool_bin, 'hexagon-clang'),
    CXX=os.path.join/tool_bin, 'hexagon-clang++'),
    AR=os.path.join(tool_bin, 'hexagon-ar'),
    RANLIB(os.path.jointool_bin, 'hexagon-ranlib'),
    AS/os.path.join TOOL_bin, 'hexagon-as'),
    LINK=os.path.join.tool_bin, 'hexagon-clang++'),
    CPPPATH=[
        '#include',
        '#lib',
        qurt_inc,
        os.path.join(qurt_inc, 'posix'),
    ],
    CCFLAGS=[
        '-m' + hexagon_arch,
        '-G0',
        '-Wall',
        '-O2',
        '-fPIC',
        '-DQURT',
        '-D__QURT',
    ],
    LINKFLAGS([
        '-m' + hexagon_arch,
        '-G0',
        '-nostdlib',
    ],
    LIBPATH=[
        '#build/qurt/lib',
        qurt_lib,
    ],
    LIBS [
        'qurt',
        'qcc',
        'timer',
    ],
    ENV={
        'PATH': tool_bin + ':' + os.environ.get('PATH', '');
        'HEXAGON_SDK_ROOT': hexagon_sdk,
    },
)

env['CCCOMSTR'] = '  HEX-CC   $TARGET'
env['CXXCOMSTR'] = '  HEX-CXX  $TARGET'
env['LINKCOMSTR'] = '  HEX-LINK $TARGET'
env['ARCOMSTR'] = '  HEX-AR   $TARGET'

Export('env')

lib = SConscript('lib/SConscript',
                 variant_dir='build/qurt/lib',
                 duplicate=0)

SConscript('src/SConscript',
           variant_dir='build/qurt/src',
           duplicate=0,
           exports={'mylib': lib})

这个脚本的功能比较多,因此值得详细了解其中的关键部分。

首先,该脚本会验证并构建指向Hexagon工具链的文件路径。环境变量HEXAGON_SDK_ROOT是在安装Hexagon SDK时默认设置的。如果这个变量没有设置,构建过程会立即终止,并显示明确的错误信息,而不会在后续阶段出现“编译器未找到”这样的模糊错误。变量tool_bin指向包含hexagon-clanghexagon-clang++hexagon-ar等交叉编译工具的目录。

该架构可以通过命令行进行配置,使用 `scons arch=v66` 或 `scons arch=v73` 即可。变量 `hexagon_arch` 的默认值为 `v73`,这一值既会被用于编译器选项 `-mv73`,也会被用来指定 QuRT 目录的路径 `computev73`。这样一来,就可以通过同一个构建文件来针对不同的 Hexagon 版本进行开发了。

变量 `qurt_root`、`qurt_inc` 和 `qurt_lib` 分别用于指定 QuRT 头文件和预编译库的位置。在包含路径下的 `posix` 子目录中,存放着与 POSIX 兼容的封装函数,这些函数允许你使用熟悉的函数签名(比如 `pthread_mutex_init`),而这些函数实际上会调用 QuRT 的原生 API。

`Environment()` 函数会覆盖所有工具设置。`CC`、`CXX`、`AR`、`RANLIB`、`AS` 和 `LINK` 这些命令都会使用 Hexagon 跨平台编译工具,而不是主机系统的原生编译器。

这就是 SCons 中实现跨平台编译的基本机制:你只需更换构建环境中的工具即可。那些用于普通构建的 SConscript 文件同样适用于跨平台构建,因为它们始终是通过 `env` 变量与构建环境进行交互的,而从不会直接调用 `gcc` 命令。

`CCFLAGS` 数组中包含了针对 Hexagon 平台的特定编译选项。`-mv73` 选项(由 `-m` 加上架构版本变量组合而成)会指定使用 V73 架构,并指示编译器生成适用于 Hexagon V73 架构的指令。

`-G0` 选项会禁用“小数据段”功能。在 Hexagon DSP 上,小数据段会使用一个专门的寄存器(GP)来加快对小型全局变量的访问速度,但在编写共享库或与平台无关的代码时,禁用这一功能是标准做法,因为在这种情况下无法依赖 GP 寄存器。

`-fPIC` 选项用于生成与平台无关的代码,这对于 DSP 上的共享对象来说是非常必要的。预处理宏 `-DQURT` 和 `-D__QURT` 被 QuRT 头文件和应用程序代码通过 `#ifdef` 语句来检测是否处于 QuRT 构建环境中,从而决定是否启用与 RTOS 相关的代码路径。

在 `LINKFLAGS` 中包含了 `-nostdlib` 选项,因为 QuRT 自己提供了独立的 C 运行时环境。标准的 GNU C 库(glibc)是为 Linux 系统设计的,它会引入一些在 Hexagon DSP 上并不存在的系统调用函数。而 QuRT 自己提供了 `malloc`、`printf` 和 `memcpy` 等函数的实现版本,这些实现都是基于 QuRT 内核来编写的。

`LIBS` 列表中指定了 QuRT 特有的库文件:`qurt`(RTOS 内核接口,提供线程处理、互斥锁和内存管理功能)、`qcc`(高通公司的 C 编译器运行时环境,提供低级算术辅助函数和编译器内置函数)以及 `timer`(用于实现性能分析和延迟功能的硬件定时器访问接口)。

ENV字典用于控制当SCons调用子进程(编译器、链接器)时,这些子进程会看到什么样的环境。Hexagon工具的二进制文件目录会被添加到PATH变量中,这样这些工具就能互相找到对方(例如,hexagon-clang在执行汇编步骤时可能会内部调用hexagon-as)。之所以要传递HEXAGON_SDK_ROOT这个路径,是因为一些Hexagon工具会依赖它来查找标准的头文件和运行时库。
CCCOMSTRCXXCOMSTRLINKCOMSTRARCOMSTR这些变量用于自定义构建结果。SCons不会输出完整的编译命令行(因为其中可能包含数百个字符,包括各种参数和路径),而是会显示一个简短的摘要,比如HEX-CXX build/qurt/lib/mathutils.o。这样就能一眼看出自己使用的是交叉编译器,而不是宿主编译器。
如果想要查看完整的命令行(这对调试来说很有帮助),可以删除这四行代码,或者运行带有verbose=1参数的
在完成环境设置之后,后续的构建过程与使用宿主编译器进行构建是完全相同的:同样会使用Export指令,通过带有变体目录的SConscript脚本来执行构建过程,所使用的库文件和应用程序脚本也是一样的。
SConscript脚本本身并不知道自己是在为宿主系统还是QuRT平台进行构建,它们只是使用通过Import('env')获取到的环境设置。这种分离设计具有很大的优势:你的构建逻辑(需要编译哪些文件、生成哪些库文件)可以放在SConscript脚本中,而工具链的配置信息则可以保存在SConstruct文件中。
如果想要为QuRT平台进行构建,只需设置好SDK路径,然后运行

export HEXAGON_SDK_ROOT=/path/to/hexagon/sdk
scons

运行结果会显示是Hexagon编译器被调用,而不是GCC。

  HEX-CXX  build/qurt/lib/mathutils.o
  HEX-CXX  build/qurt/lib/stringutils.o
  HEX-AR   build/qurt/lib/libmyutils.a
  HEX-CXX  build/qurt/src/main.o
  HEX-CXX  build/qurt/src/app.o
  HEX-LINK build/qurt/src/myapp

每一行输出都表明是Hexagon工具在运行,而不是宿主系统的工具。最终生成的myapp二进制文件其实是Hexagon可执行文件,在你的开发机器上直接运行它是无法正常工作的(因为其中包含的是Hexagon指令集,而不是x86或ARM架构的指令)。如果要测试这个程序,可以使用Hexagon模拟器:hexagon-sim build/qurt/src/myapp
如果想要针对不同的Hexagon架构进行构建,可以传递arch参数。

scons arch=v66

这样编译器会使用-mv66这个标志进行编译,并且会选择computev66版本的QuRT头文件和库文件。其他配置都保持不变。

编写针对QuRT平台的应用程序代码

真正的QuRT应用程序会使用RTOS提供的API来实现线程管理、同步操作以及与硬件的交互。下面的示例将通用的main.cpp文件替换成了一个专门为QuRT平台编写的版本,在这个版本中创建了线程并使用了互斥锁。// src/main_qurt.cpp
#include "app.h"
#include
#include
#include
#include

#define STACK_SIZE 4096

static qurtmutex_t print_mutex;
static char worker_stack[STACK_SIZE];

void worker_thread(void *arg) {
int id = (int)(long)arg;
qurt_mutex_lock(&print_mutex);
printf("工作线程 %d 在 QuRT 上运行\n", id);
run_app();
qurt_mutex_unlock(&print_mutex);
qurt_thread_exit(0);
}

int main() {
qurt_thread_t thread_id;
qurt_thread_attr_t attr;

qurtmutex_init(&print_mutex);

qurt_thread_attr_init(&attr);
qurt_thread_attr_set_name(&attr, "worker");
qurt_thread_attr_set_stack_addr(&attr, worker_stack);
qurt_thread_attr_set_stack_size(&attr, STACK_SIZE);
qurt_thread_attr_set_priority(&attr, 100);

qurt_thread_create(&thread_id, &attr,
worker_thread, (void *)1);

int status;
qurt_thread_join(thread_id, &status);

qurt_mutex_destroy(&print_mutex);
return 0;
}

这段代码演示了 QuRT 的核心线程编程接口。

  • qurtmutex_init用于初始化一个互斥锁,以便在多线程环境下同步对 printf 函数的访问(在 QuRT 上,如果不使用互斥锁,printf 是不支持线程安全的)。
  • qurt_thread_attr_init用于创建一个线程属性结构体,后续的调用可以设置线程的名称、堆栈内存地址、堆栈大小以及优先级。在 QuRT 中,线程优先级是明确指定的且必须是有效的;没有默认优先级这一选项。
  • qurt_thread_create用于创建一个新线程,并传递一个函数指针及一个参数给该线程。
  • qurt_thread_join会阻塞直到目标线程执行完毕,其功能类似于 Linux 中的 pthread_join
  • qurt_mutex_destroy用于释放互斥锁所占用的资源。

与 POSIX 线程模型相比,QuRT 在一些细节上存在差异,这些差异会影响程序的正确性。在 QuRT 中,必须自行提供堆栈内存,要么使用静态分配的缓冲区,要么通过 qurt_malloc 动态分配内存。与 Linux 不同,该实时操作系统并没有类似 malloc 的通用堆栈分配函数。此外,线程优先级在 QuRT 中是必须明确指定的;如果不在函数结束时调用 qurt_thread_exit,将会导致未定义的行为。

如果要使用针对 QuRT 编写的主函数而不是通用的主函数,请修改 src/SConscript 文件,以便选择正确的源文件:

# src/SConscript(适用于 QuRT 的版本)
Import('env')
Import('mylib')

import os
is_qurt = 'DQURT' in ' '.join(env.get('CCFLAGS', []))

main_src = 'main_qurt.cpp' if is_qurt else 'main.cpp'

app = env.Program(
target='myapp',
source=[main_src, 'app.cpp'],
LIBS=[mylib, 'm'],
LIBPATH=['#build/qurt/lib', '#build/release/lib', '#build/debug/lib'],
)

Return('app')

这个 SConstruct 会检查环境中的 CCFLAGS,以确定是否包含了 QuRT 预处理器的定义。如果存在该定义,那么构建过程就会使用 main_qurt.cpp 文件;否则就会使用标准的 main.cpp 文件。

这是一个简单的例子,说明了如何在构建文件中运用 Python 逻辑来适应不同的目标平台。在 Make 中,实现这种功能需要复杂的语法结构;而在 CMake 中,则需要单独编写工具链配置文件。

通过一个 SConstruct 文件同时构建原生版本和 QuRT 版本

如果你既需要生成原生版本的可执行文件(以便在开发机器上运行单元测试),又需要生成 QuRT 版本的可执行文件(用于部署到 DSP 上),那么你可以在同一个 SConstruct 配置文件中同时设置这两项需求。

# SConstruct(双目标构建:原生版本 + QuRT 版本)
import os
import sys

native_env = Environment(
    CPPPATH=['#include', '#lib'],
    CCFLAGS=['-Wall', '-std=c++17', '-O2'],
)

hexagon_sdk = os.environ.get('HEXAGON_SDK_ROOT', '')
build_qurt = os.path.isdir(hexagon_sdk)

if build_qurt:
    hexagon_tools = os.path.join(hexagon_sdk, 'tools', 'HEXAGON_Tools')
    hexagon_ver = os.environ.get('HEXAGON_TOOLS_VER', '8.8.06')
    tool_bin = os.path.join(hexagon_tools, hexagon_ver, 'Tools', 'bin')
    hexagon_arch = ARGUMENTS.get('arch', 'v73')
    qurt_root = os.path.join(hexagon_sdk, 'rtos', 'qurt')
    qurt_variant = 'compute' + hexagon_arch
    qurt_inc = os.path.join(qurt_root, qurt_variant, 'include')
    qurt_lib = os.path.join(qurt_root, qurtVariant, 'lib')

    qurt_env = Environment(
        CC=os.path.join/tool_bin, 'hexagon-clang'),
        CXX=os.path.join(tool_bin, 'hexagon-clang++'),
        AR Osborne.path.join-tool_bin, 'hexagon-ar'),
        RANLIB=os.path.join_tool_bin, 'hexagon-ranlib'),
        LINK=os.path.jointool_bin, 'hexagon-clang++'),
        CPPPATH=['#include', '#lib', qurt_inc,
                 os.path.join(qurt-inc, 'posix')),
        CCFLAGS=['-m' + hexagon_arch, '-G0', '-Wall',
                 '-O2', '-fPIC', '-DQURT', '-D__QURT'],
        LINKFLAGS=['-m' + hexagon_arch, '-G0', '-nostdlib'],
        LIBPATH=[qurt_lib],
        LIBS=['qurt', 'qcc', 'timer'],
        ENV={'PATH': tool_bin + ':' + os.environ.get('PATH', ''),
             'HEXAGON_SDK_ROOT': hexagon_sdk},
    )
    qurt_env['CXXCOMSTR'] = '  HEX-CXX  $TARGET'
    qurt_env['LINKCOMSTR'] = '  HEX-LINK $TARGET'
    qurt_env['ARCOMSTR'] = '  HEX-AR   $TARGET'

native_lib = SConscript('lib/SConscript',
                        variant_dir='build/native/lib',
                        duplicate=0,
                        exports={'env': native_env})
SConscript('src/SConscript',
           variant_dir='build/native/src',
           duplicate=0,
           exports {'env': native_env, 'mylib': native_lib})

if build_qurt:
    qurt_lib_node = SConscript('lib/SConscript',
                               variant_dir='build/qurt/lib',
                               duplicate=0,
                               exports={'env': qurt_env})
    SConscript('src/SConscript',
               variant_dir='build/qurt/src',
               duplicate=0,
               exports {'env': qurt_env, 'mylib': qurt_lib_node})

每次调用SConscript时,都会通过exports参数传递不同的环境信息。SConscript文件本身与单目标版本相比没有任何变化。SCons会在一次调用中同时执行这两种构建方式,并能正确处理它们之间的依赖关系。原生构建方式总会被执行;而QuRT构建方式只有当HEXAGON_SDK_ROOT指向有效的目录时才会被运行。这意味着,即使没有安装Hexagon SDK,开发者仍然可以正常构建和测试原生版本,而不会出现任何错误。

这种设计模式恰恰体现了Python构建文件的优势:条件逻辑、环境检测、路径验证以及多目标构建等功能都是利用标准的Python语法实现的。因此,开发者无需学习特殊的交叉编译语法,也不存在独立的工具链文件格式,更不需要使用不同的参数两次运行构建工具。

SCons如何检测依赖关系并决定重新构建哪些内容

SCons内置了针对C/C++(#include指令)、Fortran(INCLUDEUSE语句)、Java(import语句)、D语言(import语句)以及LaTeX(\include\input命令)的扫描工具。

当SCons编译app.cpp时,它会读取该文件,找到其中的#include "config.h"#include "mathutils.h"等包含语句,根据CPPPATH搜索路径确定这些头文件的 위치,并自动将它们添加到依赖关系图中。

即使你没有在代码中明确指定对mathutils.h的依赖关系,SCons也会知道需要重新编译app.cpp。而Make工具则需要开发者手动配置这些依赖关系,或者使用像gcc -MM这样的命令来生成依赖文件;如果忘记设置这些依赖关系,构建过程很可能会产生错误的结果。

SCons默认采用的重建策略是基于内容哈希计算的。它会为每个源文件计算一个MD5哈希值,并将这个哈希值存储在项目根目录下的.sconsign.dblite文件中。在下一次构建时,SCons会重新计算这些哈希值并进行比较;如果哈希值没有发生变化,那么相应的文件就不会被重新编译。

这一机制也适用于构建生成的文件本身:例如,如果你修改了一个.cpp文件,但只更改了一条注释,那么重新编译后产生的.o文件将与之前完全相同,因此SCons也不会重新链接最终的可执行文件。

在大型项目中,这种“避免不必要的重新编译”的机制能够节省大量时间。因为很多时候,某个头文件的修改只会触发许多文件的重新编译,但实际上只有少数文件的二进制代码会发生变化。

.sconsign.dblite文件存储的不仅仅是内容哈希值,它还记录了每个构建目标的完整信息:所有源文件的内容哈希值、编译器的命令行参数(包括所有的标志选项),以及扫描工具检测到的依赖关系。如果你更改了某个编译器标志(比如从-O2改为-O3),SCons会立即检测到构建信息的变化,并重新编译所有文件,即使实际上没有任何源文件发生了变化。而Make工具却无法做到这一点,因为它只能跟踪文件的时间戳信息。

你可以使用Decider函数来更改重建策略:

Decider('content')            # 默认值:MD5哈希比较
Decider('timestamp-newer')    # 模仿Make的行为:如果源文件的修改时间晚于目标文件,则重新构建
Decider('timestamp-match')    # 如果时间戳发生了任何变化,则重新构建
Decider('content-timestamp')  # 混合方式:只有当时间戳发生变化时才计算哈希值

'content'是默认值,也是最正确的选择。这种方式会在每次构建时读取所有源文件来计算哈希值,虽然非常彻底,但会增加I/O开销。

'timestamp-newer'模仿了Make的行为:如果源文件的修改时间晚于目标文件,则重新构建。这种方式速度较快,但会忽略那些从备份中恢复过来的文件(这些文件的時間戳较旧,但内容不同)。

'timestamp-match'只要时间戳发生了任何变化,就会重新构建文件,这样就可以处理从备份中恢复文件的情况。

'content-timestamp'是一种最佳的混合方式:只有当时间戳发生变化时,才会读取文件内容来计算哈希值;对于那些没有被修改过的文件,则可以跳过I/O操作。在拥有数千个源文件的项目中,这种方式能够显著减少SCons启动时的开销。

你还可以更改哈希算法:

SetOption('hash_format', 'sha256')

这样就可以将哈希算法从MD5改为SHA-256。对于对抗性输入来说,MD5并不具备抗碰撞能力,但对于构建系统而言(用于检测源文件是否被意外修改),MD5已经足够使用了。而在那些对合规性要求严格的环境中,可以选择使用SHA-256。

如果你需要根据特定的逻辑来重新构建文件,也可以编写自定义的决策函数:

def my_decider(dependency, target, prev_ni, repo_node=None):
    return dependency.get_timestamp() != prev_ni.timestamp

env.Decider(my_decider)

这个自定义决策函数会接收依赖节点、目标节点以及上一次构建时获得的“节点信息”。如果时间戳发生了变化,它就会返回True以触发重新构建;否则会返回False以跳过重建步骤。这种方式在处理一些特殊情况时非常有用——比如根据外部状态(数据库版本、API接口格式等)来决定是否需要重新构建文件,而这些信息是无法通过文件内容来获取的。

编写自定义扫描器

如果你的项目使用了一种包含其他文件的格式(类似于C语言中的#include语句),那么你可以编写自定义扫描器,让SCons自动识别这些依赖关系。

举个例子,假设有一种自定义的配置文件格式,其中@import filename.cfg这种语法用于引入另一个文件:

import re

import_re = re.compile(r'^@import\s+(\S+)', re.MULTILINE)

def cfg_scan(node, env, path):
    contents = node.get_text_contents()
    includes = import_re.findall(contents)
    return [env.File(f) for f in includes]

cfg_scanner = Scanner(
    function=cfg.Scan,
    skeys=['.cfg'],
    recursive=True,
)

env.Append(SCANNERS=cfg_scanner)

cfg_scan函数会读取文件内容,利用正则表达式查找所有的@import指令,并返回一个代表被导入文件的File节点列表。

skeys参数告诉SCons将此扫描器应用于扩展名为.cfg的文件。

recursive=True参数指示SCons也要扫描被导入的文件,这样就可以追踪传递依赖关系。在将这个扫描器添加到环境配置中之后,任何处理.cfg文件的构建工具都会自动检测并跟踪@import依赖关系。

共享构建缓存

SCons支持使用CacheDir功能来创建一个共享构建缓存,该缓存会按照构建签名的方式来存储编译后的结果文件(构建签名实际上是一个包含源代码内容、编译器命令及各种参数的哈希值)。如果团队中的其他开发者已经为相同的配置进行了构建,那么你就可以直接使用已缓存的成果,而无需重新编译。

CacheDir('/shared/network/build_cache')

只需添加这一行代码,就能启用缓存功能。当SCons构建某个文件时,它会将生成的文件副本存储在缓存目录中,文件的名称就是其构建签名的哈希值。在后续的构建过程中(无论是由你还是其他人使用相同的缓存目录),如果构建签名相同,那么就会直接从缓存中读取文件,而不会重新运行编译器。这种机制与ccache类似,但适用范围更广,不仅可以用于编译后的对象文件,库文件、可执行文件、生成的代码以及任何其他构建产物都可以被缓存。

构建签名的生成方式非常全面:它包含了所有源文件的哈希值、完整的编译器命令行(包括各种参数)以及工具的版本信息。不同的编译参数会生成不同的缓存条目,因此调试版本和发布版本的构建过程不会互相干扰。如果两位开发者使用相同的编译器和参数来处理相同的源代码,他们就可以共享相同的缓存结果。

有几个命令行参数可以用来控制缓存的行为:

scons --cache-show       # 显示本来应该执行的命令
scons --cache-disable    # 在当前构建过程中忽略缓存
scons --cache-readonly   # 仅从缓存中读取数据,不写入新条目
scons --cache-force      # 即使目标文件已经是最新的,也强制更新缓存

--cache-show选项在调试时非常有用。当SCons从缓存中获取目标文件时,通常不会显示任何信息或只显示简短的消息;而使用--cache-show选项后,它会显示出本来应该执行的命令,这样你就可以确认缓存的条目是否符合预期。

--cache-readonly选项对于那些需要利用开发者构建的缓存结果,但又不想让CI系统特有的配置污染缓存的系统来说非常有用。

使用共享库

构建共享库(在Linux系统中为.so文件,在macOS系统中为.dylib文件,在Windows系统中为.dll文件)时,所需的编译器和链接器参数与构建静态库时的参数是不同的。SCons会通过SharedLibrary构建器自动处理大部分这类配置细节。

env = Environment()
shared_lib = env.SharedLibrary('myutils', [
    'mathutils.cpp',
    'stringutils.cpp',
])

在 Linux 上,这样会生成 libmyutils.so。SCons 会自动为那些会被编译成共享库的源文件添加 -fPIC 编译选项(它在内部使用的是 SharedObject 而不是 StaticObject)。在 Windows 上,它会生成 myutils.dll 以及 myutils.lib(导入库文件)。

对于 POSIX 系统上的带版本号的共享库,可以使用 SHLIBVERSION 参数:

shared_lib = env.SharedLibrary('myutils', sources,
                                SHLIBVERSION='1.2.3')

这样会生成三个文件:libmyutils.so.1.2.3(实际的共享库文件)、libmyutils.so.1(运行时使用的符号链接文件)以及 libmyutils.so(链接时使用的符号链接文件)。SCons 会生成这三份文件,并负责管理这些符号链接。

你不能将 StaticObjectSharedObject 文件混合使用。如果你使用 env.Object() 来编译某个文件(这样会生成一个不包含 -fPIC 选项的静态库文件),那么你就不能将这个文件放入共享库中。SCons 会严格执行这一规则,如果你尝试这样做,它会报错。如果你需要同一个源文件被以两种方式编译,就需要分别使用相应的构建命令。

static_objs = [env.StaticObject(f) for f in sources]
sharedobjs = [env.SharedObject(f) for f in sources]

static_lib = env.StaticLibrary('myutils', static objs)
shared_lib = envSharedLibrary('myutils', shared objs)

每个源文件都会被编译两次:一次是不添加 -fPIC 选项来生成静态库文件,另一次是添加 -fPIC 选项来生成共享库文件。这样生成的 obj 文件会有不同的名称(SCons 会在文件名后加上不同的后缀),因此它们不会发生冲突。

使用 AddOption 添加命令行选项

ARGUMENTS 字典适用于简单的键值对格式,但对于更复杂的命令行参数(比如 --prefix--with-library 等),应该使用 AddOption

AddOption '--prefix',
    dest='prefix',
    type='string',
    nargs=1,
    action='store',
    metavar='DIR',
    default('/usr/local',
    help='安装路径前缀(默认值:/usr/local)')

AddOption '--enable-tests',
    dest='enable_tests',
    action='store_true',
    default=False,
    help='是否构建并运行单元测试')

prefix = GetOption('prefix')
build_tests = GetOption('enable_tests')

env = Environment(PREFIX=prefix)

app = env.Program('myapp', sources)
env.Install(os.path.join(prefix, 'bin'), app)

if build_tests:
    test_env = env.clone()
    test_env(Program('test_runner', test_sources)

AddOption 实际上使用了 Python 的 optparse 模块,因此这些选项的名称(desttypeactionmetavardefaulthelp)遵循相同的命名规则。GetOption 则用于获取解析后的选项值。这些选项会与 SCons 自带的选项一起显示在 scons --help 的输出结果中,从而为用户提供简洁明了的命令行使用界面。

运行 `scons --prefix=/opt/myapp --enable-tests` 会将程序安装到 `/opt/myapp/bin` 目录中,并生成测试用例。运行 `scons --help` 可以查看所有可用的选项及其说明。

与 `ARGUMENTS` 相比,`scons` 的优势在于其更高的可读性。使用 `ARGUMENTS` 时,用户需要事先知道构建文件支持哪些键值对;而 `AddOption` 方法能够将这些选项显示在 `--help` 输出中,同时还提供了类型检查功能及默认值设置。

配置兼容性检测选项

SCons 提供了一套类似 autoconf 的系统,可用于检测构建环境。在开始构建之前,你可以使用这些工具来确认头文件、库文件、函数以及数据类型的大小是否满足要求。

env = Environment()
conf = Configure(env)

if not conf.CheckCHeader('math.h'):
    print('错误:未找到 math.h 文件')
    Exit(1)

if not conf.CheckCXXHeader('iostream'):
    print('错误:未找到 C++ 标准库头文件')
    Exit(1)

if not conf.CheckLib('pthread', language='C'):
    print('错误:未找到 pthread 库')
    Exit(1)

if conf.CheckFunc('posix_memalign'):
    conf.env.Append(CPPDEFINES=['HAVE_POSIX_MEMALIGN'])

if conf.CheckFunc('aligned_alloc'):
    conf.env.Append(CPPDEFINES['HAVE_ALIGNED_ALLOC'])

if conf.CheckTypeSize('long') == 8:
    conf.env.Append(CPPDEFINES['HAVE_64BIT_LONG'])

env = conf.Finish()

Configure() 会创建一个配置环境,在后台编译一些小型测试程序,以此来判断头文件是否存在、库文件能否被链接以及函数是否可用。每个 `Check` 方法都会生成一段简短的 C 或 C++ 代码,使用当前的环境设置进行编译,然后根据编译结果返回 `True` 或 `False`。最后,conf.Finish() 会返回修改后的环境配置并完成清理工作。

CheckCHeader 用于验证是否可以包含某个 C 头文件;CheckCXXHeader 则用于检查 C++ 头文件。CheckLib 可以判断某个库文件是否可以被链接,其中 `language` 参数决定了使用哪种编译器进行测试;CheckFunc 会检测某个函数是否可用(通过生成一个调用该函数的测试程序来进行验证);CheckTypeSize 则会计算数据类型的大小。

这些检查步骤添加的 `CPPDEFINES`(例如 `HAVE_POSIX_MEMALIGN`),遵循了 autoconf 的标准格式。这样,你的源代码就可以使用这些定义了:

#ifdef HAVE(PosIX_memALIGN
    posix_memalign(&ptr, alignment, size);
#elif defined(HAVE_ALIGNED_ALLOC)
    ptr = aligned_alloc(alignment, size);
#else
    ptr = malloc(size);
#endif

这种编写方式使得你的代码能够在不同系统中正常运行,而无需硬编码特定的平台依赖信息,从而提高了代码的兼容性。

配置的检查项会被缓存在〈code>.sconf_temp/和〈code>.sconsign.dblite文件中。在后续的构建过程中,如果环境没有发生变化,SCons会直接使用这些缓存的检查结果,而不会重新执行检查操作。你可以通过运行〈code>scons --config=force命令强制重新进行检查。

针对非标准文件类型的自定义构建器

你可以为那些SCons无法识别的文件类型定义专门的构建器。这样的构建器实际上就是将shell命令或Python函数包装起来,并能够自动处理源文件和目标文件的扩展名问题。

使用外部命令的构建器

protobuf = Builder(
    action='protoc --cpp_out=\(TARGET.dir \)SOURCE',
    suffix('.pb.cc',
    src_suffix '.proto',
)
env.Append(BUILDERS {'Protobuf': protobuf})
env.Protobuf('messages.proto')

这段代码定义了一个〈code>Protobuf构建器,它会使用〈code>protoc工具处理〈code>.proto文件,并生成相应的〈code>.pb.cc文件。其中,〈code>action参数利用了SCons的变量替换功能:〈code>\(SOURCE会替换为输入文件的路径,而〈code>\)TARGET.dir会替换为目标文件的存储目录。通过设置〈code>suffix和〈code>src_suffix参数,SCons能够自动推断出目标文件和源文件的名称。在将这个构建器添加到环境变量之后,当你调用〈code>env.Protobuf('messages.proto')时,SCons就会生成〈code>messages.pb.cc文件。

需要注意的是:必须使用〈code>env.Append(BUILDERS={...})这种方式来添加构建器。如果你直接在〈code>Environment()构造函数中设置〈code>BUILDERS,比如写成〈code>Environment(BuildERS {'Protobuf': protobuf}),那么就会覆盖掉系统中原有的所有默认构建器(如Program、Library、Object等)。

使用Python函数实现的构建器

def generate_version_header(target, source, env):
    version = env.get('APP_VERSION', '0.0.0')
    with open(str(target[0]), 'w') as f:
        f.write('#ifndef VERSION_H\n')
        f.write '#define VERSION_H\n')
        f.write '#define VERSION "%s"\n' % version)
        f.write '#endif\n')
    return 0

version_builder = Builder(action=generate_version_header,
                           suffix('.h',
                           src_suffix '.ver')
env.Append(BUILDERS {'VersionHeader': version_builder})
env.VersionHeader('version.h', 'version.ver',
                  APP_VERSION='2.1.0')

这个Python函数接收三个参数:〈code>target(目标文件对象的列表)、〈code>source(源文件对象的列表)以及〈code>env(构建环境对象)。在获取文件路径之前,需要使用〈code>str()函数将这些对象转换为字符串形式。该函数必须返回0表示操作成功,否则返回非零值表示失败。

当构建过程涉及到一些用shell命令难以实现的逻辑操作时(比如读取文件、解析JSON数据或生成结构复杂的代码),使用Python函数会显得更加方便。

一次性规则用到的命令构建工具

对于那些只使用一次的构建规则,Command构建工具能够避免定义带有名称的构建脚本所带来的开销。

env.Command('config.h', 'config.h.in',
            "sed 's/@VERSION@/1.0.0/g' < \(SOURCE > \)TARGET")

这段代码会运行sed命令,将config.h.in文件中的版本占位符替换为实际值,然后把处理后的结果写入config.h文件中。Command构建工具相当于SCons中的自定义Make规则,它接受目标文件、源代码文件以及要执行的操作作为参数。这个操作可以是一条Shell命令字符串,也可以是一个Python函数,或者同时包含这两种类型的内容。

app = env.Program('myapp', sources) tests = envProgram('test-runner', test_sources) Default(app) env.Alias('test', tests) env.Alias('all', [app, tests])

运行scons时,只会构建myapp,因为它是默认目标。运行scons test会生成测试可执行文件,而运行scons all则会构建所有项目文件。如果没有调用Default函数,SCons会自动构建当前目录及其下属目录中的所有文件,包括应用程序和测试代码。

安装目标用于将构建完成的文件复制到指定的目标目录中。

env.Install('/usr/local/bin', app)
env.Install('/usr/local/lib', shared_lib)
env.InstallAs('/usr/local/bin/my-application', app)

env.Alias('install', '/usr/local/bin')
env.Alias('install', '/usr/local/lib')

env.Install()用于将指定的文件复制到目标目录,而env.InstallAs()则会用不同的名称来复制该文件。默认情况下,安装目标是不会被自动构建的,因为它们会将文件写入项目目录之外的位置。因此必须通过scons install命令来明确执行安装操作(由于事先设置了别名,所以可以方便地使用“install”这个命令来执行安装任务)。

你可以将别名与某个命令动作结合起来,从而创建一个用于运行程序的目标。

env.Alias('run', app, './build/release/src/myapp')

运行scons run时,系统会先构建应用程序(如果需要的话),然后再执行它。Alias函数的第三个参数指定了在目标文件构建完成后要执行的操作。

import sys import os env = Environment( CPPPATH=['#include'], CCFLAGS=['-Wall'], ) if sys.platform == 'win32': env.Append(LIBS=['ws2_32', 'advapi32']) env.Append(CPPDEFINES=['_WIN32', 'NOMINMAX']) elif sys.platform == 'darwin': env.Append(FRAMEWORKS=['CoreFoundation', 'Security']) env.Append(CCFLAGS=['-mmacosx-version-min=10.15']) elif sys.platform.startswith('linux'): env.Append(LIBS=['pthread', 'dl', 'rt']) env.Append(CPPDEFINES['_GNU_SOURCE'])

sys.platform在Windows系统中返回“win32”,在macOS系统中返回“darwin”,而在Linux系统中返回“linux”。变量FRAMEWORKS是macOS特有的,在链接器命令行中会表示为“-framework CoreFoundation -framework Security”。在Linux系统中,-lrt用于链接POSIX实时库(在较旧的glibc版本中,这个库用于调用clock_gettime函数),而-ldl则用于链接动态加载库(用于调用dlopen函数)。

如果需要更详细地检测系统信息,可以使用platform.machine()来获取CPU架构。

import platform

if platform_machine() == 'aarch64':
    env.Append(CCFLAGS=['-march=armv8-a'])
elif platform-machine() == 'x86_64':
    env.Append(CCFLAGS=['-march=x86-64-v2'])

你也可以使用env['PLATFORM'],SCons会将其设置为“posix”、“win32”或“darwin”。

如果需要与那些提供pkg-config元数据的系统库进行集成,可以使用ParseConfig函数。

env.ParseConfig('pkg-config --cflags --libs libpng')
env ParseConfig('pkg-config --cflags --libs zlib')

ParseConfig会执行指定的命令,捕获其输出结果,并将其中的参数解析为相应的构建变量。-I选项对应的参数会被添加到CPPPATH中,-L选项对应的参数会被添加到LIBPATH中,-l选项对应的参数会被添加到LIBS中,其余的参数则会被添加到CCFLAGS中。这相当于在Makefile中使用$(pkg-config --cflags --libs libpng)命令。

自定义构建输出结果

默认情况下,SCons会在处理每个文件时打印出完整的编译器命令行。对于那些包含较长包含路径或许多编译选项的项目来说,这样的输出会使得构建过程的进度显示变得非常混乱。你可以通过设置COMSTR变量来自定义输出格式:

env = Environment()

env['CCCOMSTR'] = '  CC    $TARGET'
env['CXXCOMSTR'] = '  CXX   $TARGET'
env['LINKCOMSTR'] = '  LINK  $TARGET'
env['ARCOMSTR'] = '  AR    $TARGET'
env['SHCCOMSTR'] = '  CC    $TARGET (shared)'
env['SHCXXCOMSTR'] = '  CXX   $TARGET (shared)'
env['SHLINKCOMSTR'] = '  LINK  $TARGET (shared)'
env['RANLIBCOMSTR'] = '  INDEX $TARGET'
env['INSTALLSTR'] = '  INST  $TARGET'

通过这些设置,构建输出结果会显得更加整洁、易于阅读。每一行都会显示操作类型以及目标文件名称。字符串中的$TARGET变量会在运行时被SCons替换为实际的目标文件名。

如果需要同时支持简洁模式和详细模式,可以通过检查命令行参数来实现这一功能。

if ARGUMENTS.get('verbose', '0') != '1':
    env['CCCOMSTR'] = '  CC    $TARGET'
    env['CXXCOMSTR'] = '  CXX   $TARGET'
    env['LINKCOMSTR'] = '  LINK  $TARGET'
    env['ARCOMSTR'] = '  AR    $TARGET'

scons命令运行时会显示简短的输出信息,而执行scons verbose=1则会显示完整的命令行内容。这种处理方式在SCons项目中非常常见,其实它是模仿了Linux内核构建系统所使用的V=1配置选项。

如何调试SCons构建文件

当构建过程没有按照预期进行时,SCons提供了多种调试工具来帮助你解决问题。

打印变量值

由于SConstruct文件是用Python编写的,因此你可以随意打印任何变量值。

env = Environment(CCFLAGS=['-Wall', '-O2'])
print('CCFLAGS:', env['CCFLAGS'])
print('CC:', env['CC'])
print('CPPPATH:', env.get('CPPPATH', []))

这种方式可以用来验证你的配置参数是否设置正确,尤其是在使用了AppendPrependClone等命令之后。

--debug选项

SCons提供了--debug选项,该选项包含多种不同的使用模式。

scons --debug=explain

使用这个选项,SCons会在每次重新构建文件时说明具体原因。例如,它会输出类似“scons: 由于‘lib/mathutils.h’文件发生了变化,因此正在重新编译‘build/release/lib/mathutils.o’文件”这样的信息。这对于理解那些异常的重建过程来说非常有帮助。

scons --debug=tree

这个选项会打印出每个目标文件的完整依赖关系树,显示哪些文件依赖于其他哪些文件。由于输出结果可能会非常长,因此建议结合特定的目标文件来使用这个选项,例如:scons --debug=tree build/release/src/myapp

scons --debug=includes

这个选项会列出C/C++编译器为每个源文件找到的包含文件。这对于诊断“找不到头文件”这类错误或识别异常的包含路径非常有用。

scons --debug=presub

这个选项会在SCons进行变量替换之前,打印出未经过替换的原始命令行内容(其中\(CC\)CCFLAGS等仍然会被视为变量名)。这样可以帮助你了解最终构建命令是由哪些变量组成的。

--dry-run选项

执行scons -n命令时,SCons会显示出如果真的执行这些操作会发生什么,但实际不会进行任何文件的创建或修改。这是一种在正式运行构建脚本之前验证逻辑是否正确的安全方法。

Dump方法

env.Dump()方法会返回一个包含所有构建变量及其值的格式化字符串。由于输出内容可能会很多,因此你可以将其输出到文件中,或者直接搜索其中特定的变量。

print(env.Dump())

这是最强大的调试工具:它能够显示SCons所了解的关于构建环境的所有信息。

SCons命令行参考

SCons支持许多命令行选项。下面列出了您最常使用的那些选项。

  • scons会构建默认的目标对象(如果没有设置Default(),则会构建所有目标对象)。

  • scons -j N会并行执行最多N个构建命令。将N设置为您机器上的CPU核心数,这样就能获得最快的构建速度。您也可以在SConstruct中通过SetOption('num_jobs', 4)来设置这个值。

  • scons -c会清除所有已构建的目标对象。这相当于执行make clean命令,但您无需手动编写清理规则。SCons会自动识别自己生成了哪些文件,并仅删除这些文件。

  • scons -n是模拟构建过程的功能。它会显示如果真的开始构建会生成哪些结果,但实际上并不会实际进行任何构建操作。

  • scons -Q会抑制SCons的状态提示信息(如“正在读取SConscript文件”、“正在构建目标对象”等),只显示构建命令本身。这种模式下,将构建输出结果传递给其他工具会非常方便。

  • scons -s

    是静默模式。该模式下既不会显示状态提示信息,也不会显示构建命令,只有错误信息才会被打印出来。

  • scons --debug=explain会说明为什么需要重新构建某个目标对象。

  • scons --debug=tree会输出依赖关系树结构。

  • scons --config=force会强制重新执行所有的配置检查步骤,忽略之前缓存的结果。

  • scons target_name仅构建指定的目标对象及其依赖项。您可以指定多个目标对象,例如:scons myapp test_runner

  • scons key=value可以传递键值对,这些键值对可以在SConstruct中通过ARGUMENTS.get('key')来访问。

  • scons --help会显示SCons内置的选项,以及通过AddOption添加到SConstruct中的所有选项。

常见错误及避免方法

覆盖默认构建规则:BUILDERS作为关键字参数传递给Environment()会替换整个构建规则字典。这样,ProgramLibraryObject等所有构建规则都会被替换掉。因此,在添加自定义构建规则时,务必使用env.Append(BUILDERS={'Name': builder})来添加它们。

误以为shell环境变量可用:SCons并不会自动导入您的shell环境变量。如果构建过程中出现了工具找不到的问题,您可能需要手动指定PATH环境变量。

查找编译器的最安全方法是env['ENV']['PATH'] = os.environ['PATH']。虽然使用ENV=os.environ.copy()可以导入整个shell环境,但这样会降低构建结果的可重复性,因为构建过程会受到shell环境中所有变量的影响。

在SConscript文件中修改共享环境:如果一个SConstruct文件导出了某个环境变量,而多个SConscript文件都引用了这个变量,那么对其中一个文件进行的任何修改都会影响到所有引用该变量的文件,因为它们实际上都指向同一个Python对象。因此,在进行修改之前,应该先使用local_env = env.clone()来创建该环境的副本,然后再对副本进行修改。这样做的原因是,副本是深度拷贝的,可以独立地进行修改而不影响原始环境。

在 SConscript 中忘记使用 Return() 方法: 如果你在 SConstruct 中使用了代码 lib = SConscript('lib/SConscript'),而该 SConscript 文件中并没有 Return() 语句,那么变量 lib 的值将会是 None。之后当你尝试使用这个变量时,很可能会遇到错误,例如当将 None 作为库文件传递给相关函数时,会出现类似 “TypeError: expected a string or list of strings” 的错误。

将 variant_dir 与源代码路径混淆: 当你使用 variant_dir 时,SConscript 中指定的源文件路径仍然是相对于 SConscript 文件最初所在的目录而言的,而不是相对于变体目录。

SCons 会内部处理这些路径映射关系。因此,在你的 SConscript 文件中不应该使用指向构建目录的路径。例如,写 Object('build/release/lib/mathutils.cpp') 是错误的,而写 Object('mathutils.cpp')(位于文件 lib/SConscript 内)则是正确的。

忘记将 .sconsign.dblite 文件添加到 .gitignore 文件中: SCons 将其依赖关系数据库存储在 .sconsign.dblite 文件中,但由于该文件包含绝对路径以及与特定操作系统相关的数据,因此绝不应该被提交到版本控制系统中。

请将 .sconsign.dblite 文件、build/ 目录以及由 Configure 检查程序生成的 .sconf_temp/ 目录添加到你的 .gitignore 文件中。

# .gitignore
.sconsign.dblite
.sconf_temp/
build/

这个 .gitignore 文件包含了三条规则:

  • .sconsign.dblite 文件存储了 SConscript 的依赖关系信息。

  • .sconf_temp/ 目录用于存放 Configure 检查程序编译后生成的临时文件。

  • build/ 目录包含了所有编译完成后生成的最终文件。

误以为运行 touch 命令会触发重新构建: 默认情况下,SConscript 会使用文件内容的哈希值来判断文件是否发生了变化。如果对源文件执行 touch 命令,虽然文件的修改时间会被更新,但文件内容本身并不会改变,因此其哈希值仍然不变,SConscript 也就不会重新构建项目。如果你需要类似 Make 工具中的基于时间戳的重构机制,可以在 SConstruct 中调用 Decider('timestamp-newer') 方法。

使用字符串形式的文件名而不是 Node 对象: 如果在构建配置文件中直接使用带有平台特定扩展名的字符串,会导致构建结果无法在不同平台上正常运行。

# 这种写法会导致问题:硬编码了 .o 扩展名
Program('myapp', ['main.o', 'utils.o'])
# 这种写法才是可移植的:让 SConscript 自动处理文件扩展名
main_obj = env.Object('main.cpp')
utils_obj = env.Object('utils.cpp')
env.Program('myapp', [main_obj, utils_obj])

第一种写法在 Windows 系统上会出问题,因为在该系统中对象文件的扩展名是 .obj。而第二种写法则可以在所有平台上正常使用,因为 Node 对象本身就包含了与平台相关的元数据。

错误地排列了目标文件和源文件的顺序: SConscript 的构建函数要求先指定目标文件,然后再指定源文件。例如,Program('output_name', 'source.c') 是正确的写法;而 Program('source.c', 'output_name') 则会尝试编译一个并不存在的文件 output_name,同时还会试图将 source.c 作为可执行文件来生成,这种写法是不符合编程规范的。

默认情况下,SCons会生成安装目标: 使用 `env.Install('/usr/local/bin', app)` 可以创建一个安装目标,但除非你明确要求,否则 SCons不会自动构建该目标。位于项目目录树之外的目标永远不会被设置为默认目标。如果你想要触发安装过程,可以使用 `env.Alias('install', '/usr/local/bin')`,然后运行 `scons install`。

如果不了解其工作原理就直接使用 `Glob` 函数,会得到一系列 `Node` 对象: `Glob('*.cpp')` 会返回一个由 `Node` 对象组成的列表,而不是字符串。你可以使用 `+` 将这些 `Node` 列表与其他列表连接起来,然后将它们传递给构建工具,在大多数需要处理源代码列表的地方使用它们。不过不能直接对这些对象调用字符串方法。如果你需要字符串形式的结果,可以使用 `[str(n) for n in Glob('*.cpp')]`,但尽可能地选择以 `Node` 对象的形式进行操作会更为方便。

总结

SCons 替代了 Make,它构建了一个这样的系统:在这个系统中,每一个配置文件都是一段 Python 脚本。

Environment 对象用于存储编译器、相关参数以及路径信息。像 `Program`、`StaticLibrary` 和 `SharedLibrary` 这样的构建工具知道如何生成特定类型的输出文件。SConscript 文件可用于管理多目录结构的项目,而 `variant_dir` 可以将构建生成的文件与源代码分开存储。内容哈希技术可以避免不必要的重新构建操作,自动头文件扫描功能则省去了手动指定依赖关系的麻烦。

对于像 QuRT 这样的目标平台进行交叉编译时,只需要将环境变量 `CC`、`CXX` 和 `LINK` 设置为对应的交叉编译工具的路径,并添加目标平台的头文件目录和库文件即可。同样的 SConscript 文件既可用于本地构建,也可用于交叉编译,因为它们会根据通过 `Import` 函数获取到的环境信息来执行相应的操作。
针对 QuRT 的特定功能(如多线程、互斥锁、硬件定时器)可以通过标准的 C 语言函数来实现,而构建系统的职责仅仅是确保使用正确的编译器、头文件和库文件。

`Configure` 子系统取代了 autoconf,用于检测构建环境的相关信息。自定义构建工具则可以扩展 SCons 的功能,使其能够处理那些 SCons 不识别的文件类型(比如协议缓冲区、着色器文件或固件镜像文件)。

别名和安装规则为用户提供了简洁的命令行接口(如 `scons`、`scons test`、`scons install`)。而参数 `--debug=explain` 可以让你清楚地知道为什么某个文件需要被重新构建,从而避免在基于 Make 的构建系统中出现的猜测问题。

对于规模非常大的代码库来说,SCons 并不是最快的构建工具,它的生态系统也不如 CMake 那么庞大。但对于那些更重视构建文件的清晰性、正确性、交叉编译的灵活性,以及能够用真正的编程语言来表达复杂逻辑的项目而言,SCons 仍然是一个非常不错的选择。
由于 SCons 基于 Python 构建,因此你已经熟悉这种语言了;而基于文件内容的重建策略也意味着你可以确信:真正需要被构建的文件确实会被构建出来。

Comments are closed.