传统CI/CD vs AI增强流水线:汽车软件构建的增量编译革命
四小时的噩梦:汽车软件构建的结构性瓶颈
凌晨两点,你盯着 Jenkins 面板上那个转圈的进度条,咖啡已经凉透。nightly build 跑到第3小时47分,静态分析阶段刚过半。你明知道这段等待里真正在编译的时间不到一半,但你什么都做不了——50多家供应商的代码还没全拉下来,HIL 台架环境正在排队初始化,合规报告脚本等着收尾。
这不是某个团队的懒惰。这是汽车软件结构性复杂性的日常。
现代高端车型的软件规模早就超过了多数互联网服务。代码总量1到3亿行,Android 也就1500万行,Linux 内核3000万行。供应商50到200家,每家维护自己的仓库和构建系统。ECU 数量100到150个,工具链种类20多种,目标架构5到10种。说白了:你手里不是一个大项目,是一百个系统的联邦,各自说着不同的方言。
一个典型的 nightly build 时间分解是这样的:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 总计 | ~4.5 hr |
这4.5小时还是理想情况。编译失败、测试挂掉、环境故障,随便一个都能让周期重启。你熬到凌晨三点等到的可能是一句 `linker error: undefined reference`。
团队自然想上增量编译。但汽车软件的特殊性让这念头很虚:供应商代码每天更新,基础库频繁变动;功能安全要求任何基础变更都得验证所有依赖路径;A供应商用 GCC 编译的库,B供应商拿 Green Hills 链接,二进制兼容性本身就是个雷区。
说白了,这4小时的根因根本不是「编译慢」。全量编译只占90分钟,剩下3小时烧在多仓库同步、多工具链互操作、多安全等级验证的组织协调上。Bazel 能压缩那90分钟里的增量部分,但治不了50家供应商各自为政的制度瓶颈。你把构建系统换得再快,供应商的代码该从五十个仓库里慢慢拉还得慢慢拉。
核心概念:传统增量编译为何不可信
增量编译的理念听起来很美:只重新编译改过的文件。这思路有点像吃火锅:只涮新下的菜,老肉不动。
问题出在怎么判断「哪些是老肉」这件事上。make 用的是看表——不是尝味道,是记时间。
make 的时间戳陷阱
make 比较目标文件和依赖文件的修改时间。依赖比目标新,就重编。这个设计有三个结构性缺陷:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
但根本问题在这里:时间戳只回答「文件是否可能变化」,不回答「内容是否实际变化」。这就像小区保安只记录业主几点进门,不查包里带了什么——流程走了,安全没走。
注意,这不是 bug。make 诞生于1976年,当时磁盘 I/O 贵到读取文件内容算哈希不可接受,时间戳优化在当时完全理性。真正的问题是:这个49年前的设计选择成了路径依赖,CMake 选兼容而非推翻,Ninja 选「把责任推给上层」而非根治——每一代都在修补前一代的妥协,没人敢从零设计。Bazel 是最接近「从零设计」的,代价是要求 monorepo 加 hermetic toolchain,用组织约束换技术正确性。
头文件依赖的盲区
光靠记时间不行。现代 C++ 靠 `-MMD` 生成 `.d` 依赖文件记录 `#include` 关系,但盲区一大把:
• 条件编译:`#ifdef FEATURE_X` 下的代码块,依赖文件追踪不了宏定义从哪来
• 模板实例化:模板定义在头文件,实例化点散落在其他翻译单元,依赖图跨单元不可见
• 编译器内建宏:`__DATE__`、`__TIME__` 每次编译结果不同,依赖系统不知情
• 预处理器状态:`-D` 宏定义变了,依赖文件不会自动更新
有个真实案例。某自动驾驶公司的基础数学库头文件改了默认模板参数:`Vector` 的维度从 N=4 改回 N=3。`-MMD` 只记录 `vector.h` 被包含,不记录模板实例化参数。`lidar_processing.cpp` 因为直接 include 所以重编了,但 `path_planning.cpp` 通过多层间接包含,某些实例化点没触发重编。链接时符号大小不匹配,运行时内存越界,调试两天才定位。
这就像厨师换了菜谱里的盐量,但只通知了主厨,没通知打荷的——最后上桌的菜咸淡全凭运气。
CMake 的抽象泄漏
CMake 用 `add_library`、`target_link_libraries` 这些高层抽象生成底层规则,但配置变更的传导不透明:
# 修改这行target_compile_definitions(mylib PRIVATE NEW_FEATURE=1)# 哪些目标会重编译?CMake 知道,但你很难预测# 更糟:generator expressions 动态计算的标志,依赖追踪更复杂
CMake 的 `CONFIGURE_DEPENDS` 试图解决 glob 文件列表的更新检测,却增加了配置阶段开销,还有 race condition。
ccache 的妥协与隐患
ccache 用内容哈希(MD4)替代时间戳缓存对象文件,比 make 进步不少。但 MD4 在密码学上已被攻破多年,存在碰撞攻击。构建缓存场景虽然不同于安全协议,碰撞利用难度极高,但在安全关键软件的供应链攻击模型下,这是个被系统性忽视的薄弱环节——如果有人构造两个不同源文件产生相同 MD4 哈希,其中一个过了代码审查,另一个藏了恶意代码,构建缓存就成了投毒通道。
更现实的麻烦是:ccache 作为编译器包装器,拿不到编译器内部状态(随机种子、优化决策),也不理解语义变化。升级编译器版本后,相同输入本可以产生更优代码,ccache 却可能返回旧版产物。ccache 官方手册也承认:它判断不了源代码里是否用了 `__DATE__` 或 `__TIME__`,那些依赖这两个宏的嵌入式固件构建,缓存命中率可能系统性地不可靠。[搜索来源: https://ccache.dev/manual/3.1.html]
传统增量编译在小项目里能省时间,在千万行级 C++ 代码库里是定时炸弹。你的构建系统 silent fail 了,编译器不会报错,链接器可能也不报错,直到车上路才爆。
核心概念:远程缓存与远程执行——真正的10倍提速
传统增量编译的思路是「少做点」。但真正的提速来自另一个思路:「证明这件事不需要做」。
远程缓存(Remote Cache)和远程执行(Remote Execution)是 Bazel 的两大分布式能力。它们不是「更快的本地编译」,而是重构了编译的基本假设:从「在本机重新执行」转向「证明不需要执行」或者「在集群并行执行」。说人话:以前每个工程师都要自己炒菜,现在厨房有个中央冰箱,存着每道菜的标准成品;还有个分布式后厨,可以同时开一百口锅。
Remote Cache:不用重炒的冰箱
Bazel 的核心机制是用内容哈希作为缓存键:
# 概念模型(非真实代码)def compute_action_key(source_files, compiler_flags, compiler_version): # 所有影响输出的输入,纳入哈希键 return hash(all_inputs)def build_target(target): action_key = compute_action_key(target.inputs) if cache.has(action_key): return cache.get(action_key) # 下载预编译产物,秒级 else: result = execute_compile(target) # 实际编译,分钟级 cache.put(action_key, result) return result
关键设计:Action Key 必须包含所有影响输出的因素。Bazel 的 hermeticity(密封性)要求:如果两个编译动作的所有输入——源文件、头文件、编译器二进制、标准库、环境变量——比特级相同,输出必定相同。
这就像分子料理里的「真空低温慢煮」:只要温度、时间、配料比例完全一致,出来的成品必定一致,不需要每次重新发明。
Remote Execution:同时开一百口锅
远程执行把编译任务分发到工作节点集群。它和本地并行(`make -j`)的区别不只是「核多」:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Google 内部数据:C++ 项目从本地1小时构建降到远程执行5分钟(来源:BazelCon 2019)。
确定性构建(Hermetic Build)是前提
远程缓存和远程执行有效的前提是构建确定性。Bazel 通过四条机制保证:
1. 严格依赖声明:每个编译动作必须显式声明所有输入文件,未声明的文件即使存在也不能读取(沙箱强制执行)
2. 工具链封装:编译器、链接器、标准库作为输入的一部分,版本变更自动触发重编译
3. 环境隔离:清除 `PATH`、`HOME` 等环境变量,防止主机状态泄漏
4. 可复现性验证:`bazel build –nocache_test_results` 强制重执行,对比输出哈希
这相当于给每个编译动作造了一个无菌舱:外面世界变什么样都不影响舱内结果。
配置方式
# .bazelrc 配置远程缓存build --remote_cache=grpc://cache.company.internal:9092build --remote_upload_local_results=truebuild --remote_timeout=3600# 配置远程执行(需要更复杂的集群设置)build --remote_executor=grpc://exec.company.internal:8980build --remote_default_exec_properties=OSFamily=linuxbuild --remote_default_exec_properties=container-image=docker://gcr.io/...
实战:BMW 的多 ECU 构建缓存
BMW 在 BazelCon 2022 上披露:其代码库包含10多个 ECU 的软件栈,使用不同工具链(GCC for Linux AOC, Green Hills for Classic AUTOSAR, Clang for Adaptive AUTOSAR)。传统方式下每个 ECU 独立 CMake 构建,无缓存共享。
迁移到 Bazel 后,通用库(通信中间件、诊断协议栈)的编译产物跨 ECU 复用。远程缓存命中后,单个 ECU 增量构建从30分钟降到3分钟。全量 nightly build 从「数小时」优化到可接受范围。
缓存失效的隐形杀手
内容哈希也不是万能的:
• `__DATE__` / `__TIME__`:每次编译结果不同,缓存永不命中。解决:固定值覆盖或纳入 known-non-determinism 管理
• 绝对路径:`__FILE__` 展开为绝对路径,不同开发者路径不同。Bazel 用编译器 wrapper 重写为相对路径
• 并行非确定性:`std::unordered_map` 遍历顺序、ASLR 影响输出。需固定 `-frandom-seed`
缓存中毒:比漏编译更安静的死法
2019年,某自动驾驶公司的 CI 系统出了个诡异 bug:同一 commit 在不同 CI 节点产生不同行为的二进制。排查发现:基础镜像更新了 glibc 补丁版本(2.27-3ubuntu1 升到 2.27-3ubuntu1.2),但构建缓存的哈希键没包含 glibc 版本。部分节点命中旧产物,部分节点 miss 后链接新 glibc。内存分配行为出现微差,某传感器融合算法在特定时序下崩溃。
传统增量编译的错误模式是「漏编译」——链接报错或运行时报错,吵闹、可定位、可修复。远程缓存的错误模式是「错编译」——拿到了别人的编译产物,二进制静默异常,不崩溃、不报错、只是行为微差。对 ASIL-D 代码来说,后者是灾难。StackOverflow 上有开发者记录了类似故障:「我不知怎么把远程缓存用不兼容的 glibc 版本产物给污染了……关掉远程缓存就不报错。」[搜索来源: https://stackoverflow.com/questions/75137799/with-bazel-why-do-i-keep-fetching-wrong-build-artifacts-from-the-remote-cache-de]
远程缓存 vs 远程执行的取舍:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
但事情没那么简单:整车厂比标准更保守。
核心概念:正确性优先——汽车软件为什么不敢”智能跳过”
现在来到文章最关键的分水岭。为什么同样的 Bazel 技术,Google 可以全开,BMW 却要小心翼翼?因为汽车软件的缺陷成本和互联网不在一个宇宙。
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
在这个模型下,「全量重编译4小时」是可接受的运营成本,「漏编译导致未知行为」是不可接受的风险。AI 预测99.9%准确率意味着千分之一概率的灾难——年产百万辆的车企,这就是确定性的召回事件。
ISO 26262 的工具鉴定(Tool Qualification)
ISO 26262 将软件工具分为 TCL1 到 TCL3 等级。用于 ASIL-D 软件开发的构建工具通常需达到 TCL2 或 TCL3:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
说人话:TCL 就是「这工具要是闯祸了,我们能不能抓出来」。TCL3 意味着你得像审犯人一样审这个构建工具,证明它在任何配置下都不会撒谎。
关键条款:工具鉴定必须覆盖「工具使用的所有配置和选项」。如果构建工具的行为存在不可预测性(比如「AI 预测」的随机性),鉴定范围将无限扩大,实际上不可完成。
确定性与预测性的哲学冲突
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这有点像数学考试:Bazel 是「给出完整证明过程」,AI 预测是「我猜这题选 C」。选 C 可能快,但老师要的是你写出推导。ISO 26262 的审计师就是那个只认证明过程的老师。
BMW 的功能安全构建分区
BMW 在 BazelCon 2022 上展示了实际做法:代码库混合 QM(非安全相关)、ASIL-B、ASIL-D 代码,用 target 标签标记安全等级。
# BUILD 文件片段cc_library( name = "brake_controller", srcs = ["brake_controller.cpp"], copts = select({ "//conditions:asil_d": ["-O2", "-fno-omit-frame-pointer"], "//conditions:default": ["-O3"], }), tags = ["asil-d", "no-remote-cache"], # ASIL-D 禁用远程缓存)
ASIL-D 目标强制本地执行、禁用缓存、全量链接时优化验证。QM 目标可充分利用远程缓存和并行。这种分区策略是汽车业引入现代构建系统的典型折中——不是全盘接受,是分而治之。
整车厂比标准更保守
但事情没那么简单:整车厂比标准更保守。
现实是:整车厂比 ISO 26262 字面上要求的更保守。ISO 26262-8 的工具鉴定要求「证明工具在特定使用场景下不会引入安全风险」,并未明确要求构建必须比特级可复现。工具鉴定的核心逻辑是 TCL 评估,取决于 Tool Impact 和 Tool Detection 两个维度。如果一个团队能通过对比参考构建、独立验证、输出签名校验来证明构建正确性,标准并未禁止传统增量编译或非完全密封的远程缓存。
行业普遍采用的「ASIL-D 禁远程缓存、强制全量重编」策略,本质上是安全保守主义的工程实践,而非标准条文的字面解读。Siemens 的验证博客直接点出:「关于 ISO 26262 工具鉴定,存在大量误解和误导信息。」[搜索来源: https://blogs.sw.siemens.com/verificationhorizons/2022/04/13/clearing-the-fog-of-iso-26262-tool-qualification] 汽车软件选择保守,不是被迫的,是在风险模型下的理性选择。
“AI增强流水线”的真相:预测测试,而非预测编译
好了,现在来回答读者被标题骗进来的那个问题:AI 到底增强了什么?
直接说结论:「AI 预测编译」在主流工业界不存在。 不存在。不是「很少见」,不是「还在早期」,是压根没有成熟产品。
行业把「AI 预测测试选择(Gradle PTS)」和「AI 辅助编译优化(编译器标志调优)」缝合成一个叫「AI 增强流水线」的幻觉概念。预测测试选择跳过的是测试执行,不是编译。Gradle 官方文档白纸黑字写的是「selecting only that subset for execution」,那个 execution 指向的是测试,不是编译。[搜索来源: https://gradle.com/develocity/product/predictive-test-selection]
真实存在的三类 AI 辅助技术
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
注意看最后一列:没有一个涉及「跳过编译」。
Gradle PTS 的工作机制
# 概念模型class PredictiveTestSelector: def __init__(self): self.model = train_on_historical_data() # 训练数据: (代码变更文件集合) -> (哪些测试失败) def select_tests(self, changed_files, all_tests): # 为每个测试计算失败概率 for test in all_tests: features = extract_features(test, changed_files) # 特征:代码变更与测试的历史共现、代码依赖距离、作者模式等 test.failure_probability = self.model.predict(features) # 选择策略 selected = [t for t in all_tests if t.failure_probability > THRESHOLD] # 安全约束:关键路径测试永不跳过 selected.extend(get_mandatory_tests()) # 定期全量:每周一次全测试,防止模型漂移 if is_full_test_window(): selected = all_tests return selected
Gradle 官方明确声明了三个关键约束:
• PTS 不跳过编译,只跳过测试执行
• 构建产物必须完整,才能运行选中的测试
• 预测错误的代价是漏测 bug,不是编译产物错误
PTS 的官方宣传数据是:最多削减70%的测试时间。[搜索来源: https://gradle.com/press-media/developer-productivity-gets-a-boost-from-machine-learning] 这确实能省下大量反馈时间,但它和省编译时间是两码事。
预测测试 vs 预测编译的本质区别
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
测试失败本来就是概率事件——代码变更可能引入 bug,也可能不引入。编译正确性是确定性要求——输入相同,输出必须相同。两者的数学结构不同,不能混为一谈。
所以下次再有人跟你吹「AI 构建优化」,你问他:「你说的 AI,是跳过了编译,还是跳过了测试?」这两个回答的区别,决定了他是真懂还是在念 PPT。
落地之痛:Bazel进汽车厂,两年只是起步
知道了原理,回到那个最扎心的问题:如果 Bazel 这么好,为什么你明天上班还是用的 CMake?
因为 Bazel 进汽车厂不是「换个工具」,是「组织变革」。BMW 的专职团队8到10人,花了2到3年,才完成核心迁移——而且至今没覆盖 Classic AUTOSAR。
BMW 的三阶段迁移
BMW 在 BazelCon 2022 上分享了完整历程:
• 动机:多 ECU 软件栈使用5种以上不同构建系统(CMake, Make, Green Hills 专用, 各供应商自定义),集成困难
• 策略:非大爆炸迁移,按 ECU 逐步引入
• 第一阶段(2019-2020):信息娱乐系统(Linux-based,最接近 Bazel 原生假设)
• 第二阶段(2020-2022):Adaptive AUTOSAR(POSIX OS,工具链 Clang/GCC)
• 第三阶段(2022-):Classic AUTOSAR(裸机,最难,评估中)
• 投入:专职团队 8-10 人,2-3 年
• 成果:信息娱乐构建时间从45分钟降到8分钟(含缓存);Adaptive 平台多架构构建统一管理
注意那个「评估中」。最难啃的骨头——制动、转向、发动机控制 ECU——恰恰在 Classic AUTOSAR 里。这些 ECU 代码量不大,但安全等级最高、构建验证最耗时。Bazel 在汽车业的「成功故事」目前只覆盖了安全性要求较低的域,结论不宜过度推广。
真正的硬钉子:商业工具链许可
行业讨论 Bazel 汽车业落地时,焦点永远在技术层面:工具链规则难写、交叉编译配置复杂。但 BMW 的 8-10 人团队用 2-3 年解决了这些技术问题。真正的硬钉子在法务部。
Green Hills、Wind River、Tasking 等商业编译器的许可模型基于 USB dongle 或节点锁定(node-locked license)。Bazel 远程执行的前提——编译器在容器中运行、可分发到任意工作节点——与这种许可模型结构性冲突。你技术上配好了一切,Green Hills 的许可协议可能不允许你把编译器放进 Docker 镜像。
Hacker News 上有嵌入式工程师明确提到:Green Hills MULTI 工具链用 USB dongle 许可,编译器必须绑定物理硬件,无法在云工作节点上动态启动编译容器。[搜索来源: https://news.ycombinator.com/item?id=11199231] 而且 Green Hills 已通过 ISO 26262 ASIL D 工具鉴定——这意味着更换编译器不只是技术迁移,还要重新做工具鉴定,合规成本数以百万美元计。[搜索来源: https://www.exida.com/SAEL-Safety/Green-Hills-Software-MULTI-IDE-Toolchain-and-Runtime-Libraries]
这就像你想在健身房搞个共享教练池,结果高端私教的合同规定「必须在你家楼下那个分店上课,且需要本人指纹签到」。技术上不是不能协调,得找法务谈。
嵌入式适配的技术门槛
当然,技术门槛也确实存在。Bazel 的 C++ 工具链配置需要为每个交叉编译器写规则:
# Bazel 工具链配置示例(概念性,非完整可运行)load("@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl", "tool_path")def _impl(ctx): return cc_common.create_cc_toolchain_config_info( ctx = ctx, toolchain_identifier = "ghs_arm", host_system_name = "x86_64-linux", target_system_name = "arm-none-eabi", target_cpu = "armv7-r", target_libc = "unknown", compiler = "ghs", abi_version = "unknown", abi_libc_version = "unknown", tool_paths = [ tool_path(name = "gcc", path = "/opt/ghs/comp_201754/armintelfulldll"), tool_path(name = "ld", path = "/opt/ghs/elxr"), # ... 所有工具路径 ], cxx_flags = ["-cpu=cortexr4", "-thumb", "-Ospace"], link_flags = ["-T", "memory.ld"], )
汽车 ECU 通常有特定内存布局,链接器脚本必须精确复刻供应商推荐配置。Green Hills、Wind River 等没有社区规则,全部自己开发。
替代路径评估
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
从”更快”到”可证明正确”:构建系统的认知升级
到这里,整篇文章的脉络应该清晰了。汽车软件的构建革命不是「用 AI 猜该编译什么」,而是「用数学证明不需要编译什么」。
两条技术路线
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
汽车软件的选择不是「拒绝新技术」,是拒绝不可证明的技术。
确定性构建的历史脉络
这一理念不是 Bazel 独创,是 Unix 哲学的现代延续:
• 1976:make 引入依赖图,但用 timestamp 妥协了正确性
• 1990s:Nix 包管理提出「纯函数式构建」,输出仅取决于输入
• 2000s:Google 内部 Blaze(Bazel 前身)严格化 hermetic build
• 2010s:Reproducible Builds 项目,Debian 等发行版追求比特级可复现
• 2020s:Bazel 在汽车、金融等保守行业的渗透
“预测” vs “证明”的哲学
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
汽车软件选择「证明」路线,不是因为工程师不喜欢 ML,是因为监管框架要求可追溯的论证链。ISO 26262 的每个条款都需要回答「你如何证明这个工具不会引入错误」——对神经网络,这个问题目前没有答案。
未来的可能融合
这并不意味着汽车软件永远与 AI 优化绝缘。可能的方向:
1. AI 辅助工具鉴定:用 ML 分析工具行为,加速鉴定过程,但最终结论仍需人工审计
2. 预测性测试选择 + 确定性构建:Bazel 保证编译正确,Gradle PTS 加速测试反馈——两者分层,不混合
3. 形式化验证的编译器:CompCert 等经证明正确的编译器,从根本上消除编译不确定性(目前性能受限,仅用于极关键代码)
“保守”是 feature,不是 bug
汽车软件的工程文化源于航空航天和核能:对未知保持敬畏。这与互联网「move fast and break things」形成鲜明对比。两种文化无高下之分,只是风险约束不同。理解这一点,才能理解为何同样的 Bazel 技术,Google 和 BMW 的使用方式截然不同。
你下次再听到有人吹嘘他们用 AI 优化了汽车软件构建,可以微笑着问一个问题:「你说的 AI 优化,是跳过了编译,还是跳过了测试?」如果他回答「跳过编译」,那 AI 根本没上场。
本文仅代表 ZAI攻城狮 运营者个人观点,不构成任何投资建议或商业决策依据。文中涉及的技术分析基于公开资料,如有疏漏欢迎指正。
夜雨聆风