lyyyuna 的小花园

动静中之动, by

RSS

SCons 用户指南第六章 - 依赖

发表于 2016-01

目前为止,我们看到的都是 SCons 一次性编译的例子。但编译工具一个最主要的功能是只重新编译源码改变的部分,换句话说,SCons 不应该浪费时间重新编译不该编译的内容。比如之前的 hello 例子,你可以调用 SCons 两次看结果的不同:

% scons -Q
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q
scons: `.' is up to date.

第二次执行时,SCons 意识到 hello 程序对于源码来说已经是最新的了,它会拒绝重新编译。你可以通过命令行显式指定程序名来看的更清楚些:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.

请注意,SCons 报告的 "...is up to date" 是为了避免混淆输出,且只是针对命令行中显式指定的文件名。

判断输入文件何时改变:Decider 函数

编译工具的一个重要方面是在输入文件改变时能发现并重新编译,从而保证软件实时更新。SCons 默认使用 MD5 签名或校验和来跟踪文件内容,也可以使用修改时间戳来跟踪。你甚至可以指定 Python 函数来决定文件是否改变。

使用 MD5 签名来决定文件是否改变

SCons 默认使用 MD5 签名而不是文件的修改时间来跟踪文件的内容。这意味着,如果你习惯基于文件修改时间来重编译,你会有点不习惯。我们用 touch 命令做个例子:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% touch hello.c
% scons -Q hello
scons: `hello' is up to date.

可以看到,即使改过时间,SCons 仍然能够判断出文件内容并没有改变,也就避免了 hello 的再次编译。又比如有人修改了文件,但文件内容没改变,这时候也不会重新编译。但当文件内容真的改变了,SCons 就能够检查到并重新编译:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
%     [CHANGE THE CONTENTS OF hello.c]
% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o

请注意,如果你愿意,你可以用 Decider 方法来显式地指定默认的行为(比如 MD5 签名):

Program('hello.c')
Decider('MD5')

你也可以用 'MD5' 的同义词 'content'。

使用 MD5 签名的好处

用 MD5 签名来判定内容是否改变带来一个很大的好处:那就是如果一个源文件重新编译后的二进制文件没有改变,则其下游依赖于此的目标文件就不需要重编译。

举例来说,用户如果只是更改了源文件的一个注释,那么重新编译的 hello.o 文件会和之前的完全一致(假设编译器没有做手脚)。SCons 这时候就不会重新编译 hello.o:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
%   [CHANGE A COMMENT IN hello.c]
% scons -Q hello
cc -o hello.o -c hello.c
scons: `hello' is up to date.

本质上,当 SCons 意识到文件没有改变时,会对其他依赖于此的编译行为作“短路”处理。虽然分析目标文件(hello.o)内容会花费一些时间,但相比于漫长的编译时间无疑有很高的性价比。

使用时间戳来判断文件是否改变

你也可以使用时间戳,而不是文件内容来决定是否重编译。SCons 有两种使用时间戳的方法来判断输入文件是否改变。

最常用的时间戳判断法和 Make 类似:那就是,如果源文件的修改时间比目标文件更新,就重编译。参照如下的调用 Decider 方法:

Object('hello.c')
Decider('timestamp-newer')

这种模式下,SCons 有点像 Make,可以用 touch 命令来验证这一点:

% scons -Q hello.o
cc -o hello.o -c hello.c
% touch hello.c
% scons -Q hello.o
cc -o hello.o -c hello.c

实际上正因为其行为和 Make 类似,你也可以用 'make' 字符串来代替 'timestamp-newer':

Object('hello.c')
Decider('make')

类似于 Make 的时间戳方法有一个缺点,那就是如果一个输入文件的修改时间比目标文件早,那么目标文件不会重编译。比如你从备份库中恢复源文件就会触发这种行为。恢复的源文件其内容很有可能发生改变,但由于修改时间早于目标文件,也就自然不会重编译。

不过 SCons 能够在每次编译的时候记录源文件的时间戳,只要时间戳不同就会出发重编译。这样即使新的源文件比目标文件还要早,也能轻松应对。你可以使用 'timestamp-match' 参数让 SCons 使用这种模式:

Object('hello.c')
Decider('timestamp-match')

在这种模式下,只要源文件时间戳改变,就会重编译。所以即使我们用 touch -t 选项来将源文件改成一个老的时间(1989.1.1),SCons 仍然会充编译:

% scons -Q hello.o
cc -o hello.o -c hello.c
% touch -t 198901010000 hello.c
% scons -Q hello.o
cc -o hello.o -c hello.c

通常,用 timestamp-newer 的唯一理由是,你可能有一些特殊需求:改动会来自老文件。

同时使用 MD 签名和时间戳来判断文件是否改变

处于性能的原因,SCons 提供了折中的方法:只对时间戳改变的文件作 MD5 校验。向 Decider 方法传递 'MD5-timestamp' 参数:

Program('hello.c')
Decider('MD5-timestamp')

这样配置之后,其行为会类似于 'MD5' 参数:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% touch hello.c
% scons -Q hello
scons: `hello' is up to date.
% edit hello.c
    [CHANGE THE CONTENTS OF hello.c]
% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o

不过,可以看到,第二个 SCons 调用仅会查看 hello.c 文件的修改时间,而不需要打开文件并计算 MD5 校验和。这种行为能够显著地加速编译。

不过缺点是,如果文件在一秒钟之内完成修改,那么 SCons 就不会去重编译它。当然实际开发编程时,基本不会有人这么快完成源文件的修改动作。不过一些编译脚本或者持续集成工具会自动修改文件,这种行为会非常快,这时候 Decider('MD5-timestamp') 就不合适了。

写一个自定义的 Decider 方法

传递给 Decider 方法的不同字符串,会让 SCons 采用各种内置函数来分析目标文件的依赖(通常是源文件)。当然啦,你也可以自己写一个函数来分析依赖有没有发生变化。

比如,假设我们有很多特定格式的数据在同一个文件中,用来生成不同的目标文件,但每一个目标文件只是依赖于某几个数据。我们想建立这种特定的依赖行为。然而,由于数据太多,我们只想在时间戳改变的时候才去分析输入文件。这时候我们就可以写一个自定义的 Decider 方法:

Program('hello.c')
def decide_if_changed(dependency, target, prev_ni):
    if self.get_timestamp() != prev_ni.timestamp:
        dep = str(dependency)
        tgt = str(target)
        if specific_part_of_file_has_changed(dep, tgt):
            return True
    return False
Decider(decide_if_changed)

请注意在函数定义中,dependency 是第一个参数,然后是 target。这两个参数都是 SCons 的 Node 节点,紧接会用 str() 函数将其转为字符串。

第三个参数 prev_ni 是上次目标文件编译时依赖的签名或时间戳信息。实际上 prev_ni 可以按需求包含各种不同信息。对于普通文件,prev_ni 对象有着以下的属性:

请注意,在 Decider 方法中忽略一些参数是常见的做法,且不会影响编译行为。

另一事需要注意,在第一次运行 SCons 前,三个属性并不都是初始化过的。比如 .sconsign DB 文件在没有目标文件之前不会存在。所以,你必须每次都检查一下 prev_ni 属性的可用性。

最终我们得到一个基于 csig 的决策函数的例子。请注意每次调用,依赖文件的签名信息必须通过 get_csig 函数来初始化(这是手动的!)。

env = Environment()

def config_file_decider(dependency, target, prev_ni):
    import os.path

    # We always have to init the .csig value...
    dep_csig = dependency.get_csig()
    # .csig may not exist, because no target was built yet...
    if 'csig' not in dir(prev_ni):
        return True
    # Target file may not exist yet
    if not os.path.exists(str(target.abspath)):
        return True
    if dep_csig != prev_ni.csig:
        # Some change on source file => update installed one
        return True
    return False

def update_file():
    f = open("test.txt","a")
    f.write("some line\n")
    f.close()

update_file()

# Activate our own decider function
env.Decider(config_file_decider)

env.Install("install","test.txt")

混合使用不同方法来判断一个文件是否改变

上一个例子展现了如何使用全局 Decider 方法来解决依赖问题。有时候你会希望对不同目标文件应用不同的决策方法。这时候你可以使用 env.Decider 方法,这样就只会对特定的编译环境产生影响。

举例来说,我们希望在一次编译中使用 MD5 校验,并在另一次编译中对相同的源文件使用时间戳,那么你就可以这样:

env1 = Environment(CPPPATH = ['.'])
env2 = env1.Clone()
env2.Decider('timestamp-match')
env1.Program('prog-MD5', 'program1.c')
env2.Program('prog-timestamp', 'program2.c')

假设两次编译都包含了 inc.h 文件,那么更新 inc.h 的修改时间(用 touch 命令)就会触发不同的重编译行为:

% scons -Q
cc -o program1.o -c -I. program1.c
cc -o prog-MD5 program1.o
cc -o program2.o -c -I. program2.c
cc -o prog-timestamp program2.o
% touch inc.h
% scons -Q
cc -o program2.o -c -I. program2.c
cc -o prog-timestamp program2.o

决策函数的历史版本

现在的 SCons 版本仍然支持两个曾经主要的决策函数。它们被官方加入 2.0 版本,但现在已经不鼓励使用,因为它们在源文件和目标文件之间建立了一种令人费解的决策行为。现在你只可能在维护旧版本的 SConscript 文件中才会碰到它们。

SourceSignature 方法

SourceSignature 方法非常直接,支持两种决策方法,第一个就是 MD5 签名法:

Program('hello.c')
SourceSignatures('MD5')

另一个是时间戳:

Program('hello.c')
SourceSignatures('timestamp')

它们分别和 Decider('MD5') 和 Decider('timestamp-match') 很像,但是在这种行为之下,SCons 只能依赖于源文件的变化,即那些不是从其他文件生成的文件。

TargetSignatures 方法

当一个目标文件本身被作为其它目标的文件的依赖时,可以用 TargetSignature 方法来决定 SCons 的行为。也就是说,TargetSignature 方法可以配置那些最终目标依赖的临时二进制文件。

TargetSignature 方法同样支持 'MD5' 和 'timestamp' 两个参数,其意义和上节所述是一致的。这个例子中:

Program('hello.c')
TargetSignatures('MD5')

hello.o 的 MD5 签名将决定最终程序 hello 是否被重编译。在下面的例子中:

Program('hello.c')
TargetSignatures('timestamp')

hello.o 的时间戳将决定最终程序 hello 是否被重编译。

TargetSignatures 方法还支持 'source' 和 'build' 两个额外的参数。'source' 参数指定了目标文件和源文件使用同样的决策行为(即和 SourceSignature 一样)。所以在这个例子中:

Program('hello.c')
TargetSignatures('source')
SourceSignatures('timestamp')

所有目标和源文件都将使用修改时间戳来判断是否改变。

最后,'build' 参数指定了 SCons 必须检察目标文件的编译状态,如果一个目标文件被重编译了,在不检查其内容或时间戳的情况下,所有依赖于此的下游目标文件也应该重编译。如果没有重编译,则使用 SourceSignature 重复上节的行为。

这其实是模仿了更早 SCons 版本的 build signatures 行为。build signatures 会合并所有输入文件的签名,这样就不需要计算目标文件的 MD5 签名。虽然在某些配置下能提高性能,但远不如 Decider('MD5-timestamp') 高效。

隐式依赖:$CPPPATH 配置变量

假设我们的 "Hello, World!" 程序用 #include 包含了 hello.h 头文件:

#include <hello.h>
int
main()
{
    printf("Hello, %s!\n", string);
}

hello.h 中内容为:

#define string    "world"

在这个例子中,我们希望 SCons 在发现 hello.h 内容改变时,能重编译。为此,我们需要修改 SConstruct 文件:

Program('hello.c', CPPPATH = '.')

$CPPPATH 告诉 SCons 去当前目录寻找源程序包含的头文件。这时候:

% scons -Q hello
cc -o hello.o -c -I. hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.
%     [CHANGE THE CONTENTS OF hello.h]
% scons -Q hello
cc -o hello.o -c -I. hello.c
cc -o hello hello.o

首先可以注意到,SCons 添加了 $CPPPATH 变量所指定的 -I. 参数,这样编译时能够在本地目录的 hello.h 文件。

然后,SCons 会扫描 hello.c 包含的 hello.h 内容来决定是否重编译。SCons 会将这标记为目标文件的隐式依赖,所有依赖于 hello.c 和 hello.h 的文件都会被重编译。

像 $LIBPATH 变量那样,$CPPPATH 变量可以是一些目录路径的列表,或者是目录路径分割的字符串(在 POSIX/Linux 上是 ':',在 Windows 上是 ';')。这两个方法都能使 SCons 产生正确的编译选项:

Program('hello.c', CPPPATH = ['include', '/home/project/inc'])

在 POSIX 或 Linux 系统上:

% scons -Q hello
cc -o hello.o -c -Iinclude -I/home/project/inc hello.c
cc -o hello hello.o

在 Windows 上:

C:\>scons -Q hello.exe
cl /Fohello.obj /c hello.c /nologo /Iinclude /I\home\project\inc
link /nologo /OUT:hello.exe hello.obj
embedManifestExeCheck(target, source, env)

缓存隐式依赖

扫描每个 #include 文件会耗费额外的时间。当构建大型系统时,扫描时间只占了一小部分的编译时间。但重编译部分组件时,扫描占据的时间就非常可观,在真正编译前,SCons 会花费大量的时间判断所有依赖是否是最新的。

实际中,错误的依赖中会浪费 SCons 扫描时间只是一个小问题。然而,等待时间通常让开发人员不能忍。因此,SCons 允许你缓存依赖关系,下次编译可以直接使用。你只需要在命令行中指定 --implicit-cache 选项:

% scons -Q --implicit-cache hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.

如果你不想每次在命令行指定 --implicit-cache 选项,你可以在 SConscript 文件中设置 implicit_cache ,使其成为默认行为:

SetOption('implicit_cache', 1)

SCons 默认不缓存隐式依赖,因为当依赖发生改变后,SCons 不能及时发现变化。具体来说,在以下情况,--implicit-cache 选项会导致重编译失败:

--implicit-deps-changed 选项

在使用隐式依赖关系时,有时你想重新扫描文件以重建缓存。比如说,你项目编译依赖的一个外部库最近有了更新,这时候缓存的隐式依赖就过时了。你可以通过 --implicit-deps-changed 选项来更新缓存:

% scons -Q --implicit-deps-changed hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.

在这个例子中,SCons 会重新扫描隐式依赖并更新缓存。

--implicit-deps-unchanged 选项

在建立缓存时,SCons 默认会区分那些修改过的文件。然而有时,即使源文件发生变化,你仍然想强制 SCons 使用缓存中的依赖。在 #include 包含的头文件没变化,而只是源文件做了修改的情况下,这可以加速编译过程。在下面这个例子中,你可以使用 --implicit-deps-unchanged 选项:

% scons -Q --implicit-deps-unchanged hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.

这里,SCons 假设缓存是最新的,且不会去重新扫描文件。但实际是,对于持续的微小代码改动,这样虽然减少了编译时间,但有可能却让产品错过了显著的性能提升。

显式依赖:Depends 方法

有时候,SCons 不能发现文件的依赖。在这种情况下,就需要你显式地定义文件之间的依赖关系,才能在文件改动后触发重编译。这通过 Depends 方法来实现:

hello = Program('hello.c')
Depends(hello, 'other_file')

% scons -Q hello
cc -c hello.c -o hello.o
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.
% edit other_file
    [CHANGE THE CONTENTS OF other_file]
% scons -Q hello
cc -c hello.c -o hello.o
cc -o hello hello.o

请注意,Depends 的依赖(即第二个参数)也可以是节点对象的列表:

hello = Program('hello.c')
goodbye = Program('goodbye.c')
Depends(hello, goodbye)

这样,依赖将先于目标文件生成:

% scons -Q hello
cc -c goodbye.c -o goodbye.o
cc -o goodbye goodbye.o
cc -c hello.c -o hello.o
cc -o hello hello.o

来自外部文件的依赖:ParseDepends 方法

SCons 对不同语言有不同的内置扫描器。但由于扫描器实现的不足,有时候不能提取出特定的隐式依赖关系。

下面是一个扫描 C 语言头文件失败的例子:

#define FOO_HEADER <foo.h>
#include FOO_HEADER

int main() {
    return FOO;
}

% scons -Q
cc -o hello.o -c -I. hello.c
cc -o hello hello.o
%    [CHANGE CONTENTS OF foo.h]
% scons -Q
scons: `.' is up to date.

很显然扫描器无法分析头文件的依赖。因为不是一个完整的 C 预处理器,扫描器无法展开宏。

在这些情况下,你也许会求助编译器来提取隐式依赖关系。ParseDepends 方法能够解析编译器的 Make 风格的输出,并显式地建立起所有依赖项。

下面这个例子使用 ParseDepends 来处理编译器产生的依赖文件,而这些依赖文件其实是编译时的副产物:

obj = Object('hello.c', CCFLAGS='-MD -MF hello.d', CPPPATH='.')
SideEffect('hello.d', obj)
ParseDepends('hello.d')
Program('hello', obj)
  
% scons -Q
cc -o hello.o -c -MD -MF hello.d -I. hello.c
cc -o hello hello.o
%    [CHANGE CONTENTS OF foo.h]
% scons -Q
cc -o hello.o -c -MD -MF hello.d -I. hello.c

解析编译器产生的 .d 依赖文件会导致鸡生蛋-蛋生鸡的问题。也就是不必要的重编译问题:

% scons -Q
cc -o hello.o -c -MD -MF hello.d -I. hello.c
cc -o hello hello.o
% scons -Q --debug=explain
scons: rebuilding `hello.o' because `foo.h' is a new dependency
cc -o hello.o -c -MD -MF hello.d -I. hello.c
% scons -Q
scons: `.' is up to date.

在第一次运行时,依赖文件随二进制文件一起产生。因为此时 SCons 并不清楚目标文件依赖于 foo.h。在第二次运行的时候,二进制文件被重编译,因为此时 foo.h 被确认为一个新的依赖。

ParseDepends 在启动时立即读入指定的文件,并判断出文件是否存在。而编译过程产生的依赖文件不会再被自动解析。因此,在同一次编译中,编译器提取的依赖关系不会被存入签名数据库中。这个不足导致 ParseDepends 会带来不必要的重编译行为。因此,应只在 SCons 的扫描器不足以应付时使用这个功能。

忽略依赖:Ignore 函数

有时候即使依赖改变也不想重编译程序,你就需要向 SCons 指定要忽略的文件:

hello_obj=Object('hello.c')
hello = Program(hello_obj)
Ignore(hello_obj, 'hello.h')

% scons -Q hello
cc -c -o hello.o hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.
% edit hello.h
[CHANGE THE CONTENTS OF hello.h]
% scons -Q hello
scons: `hello' is up to date.

现在,上面的例子有点不自然,因为实际开发中很难想象在 hello.h 改变后,不需要重编译 hello。更现实的例子是,hello 所在的目录被不同系统所共享,但每个系统的 stdio.h 头文件都不相同。如果不作设置,那么在切换系统的时候,SCons 就会误以为头文件改变而触发重编译行为。为了避免此问题,可以作如下改动:

hello = Program('hello.c', CPPPATH=['/usr/include'])
Ignore(hello, '/usr/include/stdio.h')

Ignore 方法还可以避免默认的编译行为。这是因为目录实际上依赖于其内部内容。如果想避免某个文件被默认编译,可以指定目录忽略特定文件。请注意,当用户在命令行指定需要目标文件时,还是会触发编译行为。或者其他依赖于此该文件的文件被要求编译时,也会编译该文件。

hello_obj=Object('hello.c')
hello = Program(hello_obj)
Ignore('.',[hello,hello_obj])

% scons -Q
scons: `.' is up to date.
% scons -Q hello
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello
scons: `hello' is up to date.

按序依赖:Requires 方法

偶尔的,你可能需要修改某些文件和目录,且它们被目标文件所依赖,但这些改变对目标文件来说没有影响,你不希望这些改变触发重编译行为。这种关系称为按序依赖,因为只有目标文件之前的依赖关系发生了改变。这样的依赖关系导致的改变并不一定需要传递到目标文件。

举例来说,你需要在每次编译时产生一个文件来记录编译时长、版本信息等。很显然这个文件的内容每次都不同。如果你的 SCons 配置成普通的依赖决策,那么每次都会导致重编译行为。为了演示该行为,可以用 Python 生成一个 version.c 文件,然后让原程序链接此文件,最后在生成目标文件时打印其中的字符串:

import time

version_c_text = """
char *date = "%s";
""" % time.ctime(time.time())
open('version.c', 'w').write(version_c_text)

hello = Program(['hello.c', 'version.c'])

如果 version.c 是一个实际的源文件那么显然每次 version.o 都会重编译,接着会导致 hello 每次也跟着重编译:

% scons -Q hello
cc -o hello.o -c hello.c
cc -o version.o -c version.c
cc -o hello hello.o version.o
% sleep 1
% scons -Q hello
cc -o version.o -c version.c
cc -o hello hello.o version.o
% sleep 1
% scons -Q hello
cc -o version.o -c version.c
cc -o hello hello.o version.o

(请注意,需要 sleep 一秒钟来正确使用上述这个例子)

一个解决方法是使用 Requires 方法,指定 version.o 的改变只有在目标程序需要链接时,才会导致重编译行为:

import time

version_c_text = """
char *date = "%s";
""" % time.ctime(time.time())
open('version.c', 'w').write(version_c_text)

version_obj = Object('version.c')

hello = Program('hello.c',
                LINKFLAGS = str(version_obj[0]))

Requires(hello, version_obj)

请注意,虽然我们不再将 version.c 作为源文件之一,我们还是需要提取文件名作为 $LINKFLAGS 变量,才会触发编译器链接行为。

当做出上述改变后,hello 可执行程序只有在 hello.c 改变时才会重编译,而 version.o 的重编译(因为 SConstruct 每次都会改变 version.c 的内容)不会触发最终程序的重编译:

% scons -Q hello
cc -o version.o -c version.c
cc -o hello.o -c hello.c
cc -o hello version.o hello.o
% sleep 1
% scons -Q hello
cc -o version.o -c version.c
scons: `hello' is up to date.
% sleep 1
%     [CHANGE THE CONTENTS OF hello.c]
% scons -Q hello
cc -o version.o -c version.c
cc -o hello.o -c hello.c
cc -o hello version.o hello.o
% sleep 1
% scons -Q hello
cc -o version.o -c version.c
scons: `hello' is up to date.

AlwaysBuild 方法

最后,还可以通过 AlwaysBuild 方法来影响 SCons 处理依赖的行为。当一个文件被传入 AlwaysBuild 方法:

hello = Program('hello.c')
AlwaysBuild(hello)

每次编译目标文件时,该源文件都会被认为是过时的,所有依赖路径上的文件都会被重编译:

% scons -Q
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q
cc -o hello hello.o

AlwaysBuild 这个名字有歧义,因为它并不意味着目标文件每次都会重编译。而是说每次在命令行指定该目标文件时,会触发重编译行为。所以假如在命令行指定其他的目标文件,那么它只有在真正过时时才会被重编译:

% scons -Q
cc -o hello.o -c hello.c
cc -o hello hello.o
% scons -Q hello.o
scons: `hello.o' is up to date.