☰
ROS 2 colcon构建全解析:包发现、依赖排序与编译调优
2026/10/26 14:32:14 网站建设 项目流程

第一次在 ROS 2 里敲完ros2 pkg create,盯着src下凭空冒出来的目录,我确实愣了几秒:包是建好了,可它怎么变成能跑的节点?答案就是colcon。它是 ROS 2 默认的构建工具,负责把你的源码目录翻个底朝天、找出里面每一个包、算清包与包谁先谁后的依赖顺序,再一个一个编译、安装,最后给你一整套能让ros2 run认得出来的环境变量。说白了,colcon就是 ROS 2 工作空间的"总包工头"——没有它,你写的 C++ 节点和 Python 脚本都只是一堆躺在硬盘上的文本。

这篇东西适合三类人看:刚装完 ROS 2、还没搞明白build、install、log三个目录是干嘛的新手;已经能跑起来、但每次改一行代码要等五六分钟的中间层;以及手里管着几十个包、被依赖顺序和环境变量冲突折磨过的老手。我不打算把colcon --help抄一遍,而是按"它为什么这么设计 → 第一次怎么跑通 → 哪些参数真的能省时间 → 依赖和构建脚本怎么管 → 测试链路 → 踩过的坑怎么爬出来"这条线走,中间该给命令给命令,该给参数就给计算过程。

1. colcon 到底在构建什么:从包发现到安装布局

1.1 一次colcon build背后发生的四件事

很多人以为colcon build等价于"进每个包目录跑一次cmake && make",其实差得远。它本质上是一个"包级调度器",真正的编译工作交给每个包自己的构建系统去做。一次完整的构建会走四个阶段,理解这四步,后面所有的参数就都有了落脚点。

第一步是包发现。colcon会从你当前目录开始递归往下扫,识别出所有带package.xml(ROS 包)或者带COLCON_IGNORE之外可被识别标记的目录。这一步的关键在于它会自动跳过build、install、log这三个名字的目录,也跳过任何含有COLCON_IGNORE空文件的目录——这一点非常重要,否则你第二次构建时会把上次的安装产物当成源码再扫一遍,直接把自己绕死。

第二步是依赖图排序。拿到包列表后,它读每个package.xml里的<depend>、<build_depend>、<exec_depend>、<buildtool_depend>,构建一张有向图,然后做拓扑排序。这就是为什么你改了一个底层接口包,上层依赖它的包会自动重编;也是为什么当出现循环依赖时,colcon会直接甩给你一句Circular dependency然后罢工——它不会自作聪明地猜你想怎么办。

第三步是逐包构建,默认并行。每个包在自己的build/<包名>目录里独立跑 CMake(或 setup.py),编译产物不落地到源码目录,这叫 out-of-source build。好处是你rm -rf build就等于一次干净的重新构建,源码永远不受污染。

第四步是安装与环境注册,产物落到install/<包名>,同时生成一堆setup.bash、local_setup.bash、package.dsv之类的环境脚本。你后面source install/setup.bash之所以能让ros2 run找到新包,靠的就是这一步写进去的路径。

提示:colcon本身对"什么是包"是插件化识别的,ament_cmake、ament_python、ament_cmake_python、纯cmake包它都认。所以同一个工作空间里混着 C++ 包、Python 包、甚至一个不依赖 ROS 的普通 CMake 库,是完全正常的用法,不是 hack。

1.2 三个目录加一个日志:为什么不能像以前那样只留一个devel

如果你是从早期 ROS 1 的catkin_make转过来的,最容易困惑的就是"为什么多了个install,devel去哪了"。这个变化不是没事找事,而是为了解决一个非常具体的痛点:安装空间和构建空间的职责分离。

catkin_make时代的devel目录是半成品仓库——既有编译中间产物,又有可以直接 source 的环境脚本。听着方便,实际上带来两个问题。一是devel里的环境脚本是"活的",它指向的是构建目录里的库文件,你一旦清掉某个包的构建产物,环境变量还在,运行时就会碰到"明明 source 过了却找不到库"的诡异现象。二是它很难做"部分覆盖安装",你想拿一个包的新版本盖掉系统里装好的旧版本,路径优先级很容易乱。

colcon的做法是把这两件事拆开:build/只管编译中间态,install/只管最终可用的产物,log/存每次构建的完整输出。默认的isolated install模式下,每个包在install/下有自己独立的子目录,环境脚本通过package.dsv一条条把路径链起来。这样做的代价是路径长、环境变量条目多(包多了以后AMENT_PREFIX_PATH能长到几百个字符),但换来的是干净的可卸载性和明确的优先级——后 source 的覆盖先 source 的。

对应的另一个选项是--merge-install,把所有包的文件合并铺到install/根下,路径短、环境变量少,启动时扫描更快,适合部署到资源紧张的板子上。但它的问题是文件互相覆盖时你很难追查是谁覆盖了谁。我的经验是:开发机用默认的 isolated,交付到嵌入式设备上再考虑--merge-install,两边的取舍完全不同。

log/目录经常被忽略,其实是排查利器。log/latest_build/<包名>/stdout_stderr.log里存着那个包完整的编译输出,command.log里存着实际执行的命令行。当你怀疑"这参数到底传进去了没有",直接看command.log比翻文档快得多。

2. 第一次跑通:从建目录到ros2 run成功

2.1 建工作空间和第一个包

标准动作没什么花样,但顺序不能错。先建目录、再进src建包、最后回工作空间根目录构建——很多新手是在src里面直接敲colcon build,然后发现install目录跑到src里去了,越搞越乱。

mkdir -p ~/ws_demo/src cd ~/ws_demo/src ros2 pkg create --build-type ament_cmake demo_node \ --dependencies rclcpp std_msgs cd ~/ws_demo colcon build

--build-type这个参数要按包的实际类型选:写 C++ 节点用ament_cmake,纯 Python 脚本用ament_python。选错了不是不能编,而是后面setup.py和CMakeLists.txt的模板对不上,你得手工补一堆东西。--dependencies会顺手写进package.xml和CMakeLists.txt,这是最省事的做法,比事后手改两份文件可靠。

构建成功的输出大概长这样:Starting >>> demo_node,然后Finished <<< demo_node [3.2s],最后一行Summary: 1 package finished [3.5s]。如果Summary里出现failed,别急着往上滚屏,先看最后那几行Failed <<< 包名 [耗时],然后去log/latest_build/包名/stdout_stderr.log看真实错误。

2.2 source 哪一条:setup.bash和local_setup.bash的区别

这是新手最容易含糊的一步,而且含糊的后果很隐蔽——你可能 source 错了文件,当时一切正常,等到工作空间里包变多了才开始出诡异的"版本不对"问题。

两个文件的差别在于是否递归。local_setup.bash只把当前这个工作空间install/下的路径加进环境变量,不管它的依赖来自哪里。setup.bash则会顺着install/里记录的依赖前缀链,把下层(比如系统装的 ROS 2 主目录)的环境也一起 source 进来。

那到底该用哪个?我的实际习惯是:

  • 在新开的终端里第一次进入开发,用source install/setup.bash,一次到位。
  • 在已经 source 过底层环境的终端里切工作空间,用source install/local_setup.bash,避免重复叠加。

为什么怕重复叠加?因为source setup.bash是追加而不是替换。你在同一个终端里 source 三次,AMENT_PREFIX_PATH里就有三份同样的路径,LD_LIBRARY_PATH同理。绝大多数时候没事,但当你想临时切到另一个工作空间编译出的另一个版本时,优先级就会被旧路径挡住——你以为切过去了,实际加载的还是老的.so。

# 检查当前环境里到底叠了多少层 echo $AMENT_PREFIX_PATH | tr ':' '\n'

看到重复项就该警惕了。最干脆的解法不是手工删,而是新开一个终端。这也是我后来养成"一个工作空间一个终端"习惯的原因。

2.3 验证闭环:从编译到运行

构建完、source 完,验证三步走:ros2 pkg list | grep demo_node看包认没认出来,ros2 pkg executables demo_node看可执行文件注册上没有,最后才是ros2 run demo_node xxx。很多人跳过前两步直接 run,报错No executable found时就开始怀疑人生——其实九成是CMakeLists.txt里install(TARGETS ...)写了但没 source,或者 source 的是local_setup.bash而底层环境压根没加载。

对于 Python 包,还有一个常被忽略的点:setup.py里的entry_points决定可执行文件的名字,console_scripts里写的是什么,ros2 run就认什么。名字对不上,包能 import 但跑不起来。

3. 高频参数实测:把十分钟的构建压到几十秒

3.1 只编你改过的东西:包选择系参数

大工作空间里全量构建是最浪费时间的行为。colcon提供了一组按包筛选的参数,用对了能省掉 90% 的等待。

--packages-select是最直接的:只编你点名的包,不管依赖。适合"我只改了 A 包的实现,接口没动"的场景。注意它不会帮你重编下游,如果 A 的接口(头文件签名、消息定义)变了,下游用的还是旧产物,运行时会炸或者行为诡异。

--packages-up-to是"点名 + 它的全部依赖",安全得多。改了一个底层库,用它保证链条完整。

--packages-above反过来,选中"依赖它的所有包",适合接口改动后验证影响面。还有个--packages-above-and-dependencies两头都包,做发布前回归时我一般直接用它。

# 只编我改的那两个包,以及依赖它们的下游 colcon build --packages-above src/my_iface src/my_driver # 只编指定包,依赖用已有产物(快,但有风险) colcon build --packages-select my_driver # 跳过某个一直编不过的实验包 colcon build --packages-skip my_wip_pkg

注意:--packages-skip用久了容易忘。我见过有人在构建脚本里写死了 skip 参数,三个月后接手的人死活找不出某个包为什么不更新。要跳就在命令行跳,别写进脚本。

3.2 并行度与内存:--parallel-workers怎么定

colcon默认按 CPU 核数并行。8 核机器同时跑 8 个包的编译,听起来很美,直到你编到一个模板满天飞的 C++ 包,8 个g++进程每个吃 1.5G 内存,机器直接 OOM,g++被内核 kill 掉,报错是一句莫名其妙的Killed (signal 9)。

并行度的合理值不是核数,而是可用内存 / 单包峰值内存。单包峰值怎么估?看它在单线程下g++的最大 RSS,一个中等规模的消息处理包大概 800MB~1.5G,重模板的能到 3G。你的机器 16G 内存、留 4G 给系统和 IDE,剩 12G,那并行度就是 12 / 1.5 ≈ 8,看着还行;但如果里面有 3G 的大包,稳妥值是 4。

# 保守并行,适合内存吃紧的开发机 colcon build --parallel-workers 4 # 彻底串行,排查并发相关的构建问题(比如共享文件被同时写) colcon build --executor sequential

--executor sequential和--parallel-workers 1效果接近,但前者是把调度器都换掉了,调试时更彻底。我在排查过两次"并行构建时某个包的生成文件被另一个包读到半截"的问题后,养成了出怪错先串行跑一遍的习惯——如果串行能过,八成是并行写冲突或者依赖声明漏了。

3.3--symlink-install:改 Python 脚本不用重编,但有代价

这个参数对 Python 包开发是降维打击。默认情况下你改了.py文件,得重新colcon build才会同步到install/。加了--symlink-install之后,install/里的文件变成指向源码的软链接,改完存盘直接ros2 run就能看到效果,省掉整轮构建。

colcon build --symlink-install

但它有三个代价,得心里有数。第一,软链只对非编译产物有效。C++ 的.so还是得真编,这个参数救不了你。第二,新增文件不会自动出现。你在CMakeLists.txt里加了一条install(FILES ...),必须重新 build 一次才会建出新的软链,光改源码不够。第三,删除源文件会留下断链,某些工具碰到断链会报错,install/目录看得也乱。

还有一个隐蔽的坑:--symlink-install的效果和你上次构建的模式是绑定的。你先用普通模式 build 过一次,install 里是实体文件;这次加--symlink-install再 build,colcon会尝试覆盖,但混着软链和实体文件的目录偶尔会出现"改了没生效"的错觉。我的做法是:决定用--symlink-install就从一开始用,中途切换前先rm -rf build install,别省这一分钟。

3.4 CMake 透传参数与常用组合表

--cmake-args是把参数转给每个包的 CMake 配置阶段,注意它后面要加--分隔,否则colcon会把你后面跟的东西当成自己的参数。这是语法上最容易错的地方。

# Release 构建(默认是空 build type,性能差很多) colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release # 只构建某个具体 target,跳过测试和文档 colcon build --cmake-target install --packages-select my_pkg # 打开编译数据库,给 clangd / IDE 用 colcon build --cmake-args -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

-DCMAKE_BUILD_TYPE=Release这条我建议写进日常脚本。默认不指定 build type 时,CMake 不会开任何优化,你测出来的算法耗时比实际慢两三倍,然后误以为是代码有问题。真实场景里我见过有人为了"优化性能"重写了整个数据处理流程,最后发现是没开-O2。

场景推荐参数组合说明
日常改 Python--symlink-install --packages-select X秒级生效,不重编 C++
改接口定义--packages-above src/iface带上受影响的下游
首次全量--parallel-workers 4 --cmake-args -DCMAKE_BUILD_TYPE=Release控制内存峰值
发布前回归--packages-above-and-dependencies+ 串行宁可慢,不可漏
排查并发怪错--executor sequential --event-handlers console_direct+输出实时,顺序可靠
部署到小设备--merge-install --install-base /opt/xxx路径短,环境变量少

顺带说--event-handlers console_direct+:默认的console_cohesion+会把每个包的输出攒在一起,全部结束才打印,好处是日志整齐,坏处是长时间构建时你完全不知道卡在哪。加上console_direct+能看到实时滚动,代价是多个包的输出会交错。串行调试时它几乎是必选项。

4. 依赖声明与构建脚本:让colcon少犯错

4.1package.xml里的依赖写错了会怎样

colcon的构建顺序完全依赖package.xml声明。少写一条,构建顺序就可能错;多写一条,会引入不必要的重编。

实践中最常见的错误是只写了运行依赖,漏了构建依赖。比如某个包在CMakeLists.txt里find_package(std_msgs REQUIRED),但package.xml里只写了<exec_depend>std_msgs</exec_depend>。在本机可能因为环境里已经装好而侥幸编过,换一台干净的机器就find_package失败,而报错信息指向的是 CMake 找不到配置文件,跟"依赖漏声明"这个真实原因隔了十万八千里。

稳妥的写法是用<depend>这个聚合标签,它同时覆盖 build 和 exec 两种。确实只需要运行期依赖的(比如纯 Python 包只在运行中 import),才用<exec_depend>。构建工具本身(ament_cmake)用<buildtool_depend>,这类依赖不参与运行环境。

还有一条:依赖了某个包,就一定要在package.xml里写,哪怕 CMake 里没find_package。因为colcon排序和rosdep装依赖都只认package.xml。

4.2colcon.meta:给单包单独下参数

当你有二十个包、但只有两个需要特殊 CMake 参数时,命令行传参就不合适了——--cmake-args是全局生效的,会给每个包都加一遍,碰上不认识该参数的包直接编挂。

colcon.meta就是解这个问题的。放在工作空间根目录,用 JSON 描述"哪个包用什么参数":

{ "names": { "heavy_pkg": { "cmake-args": ["-DCMAKE_BUILD_TYPE=Release", "-DENABLE_FAST_MATH=ON"] }, "debug_pkg": { "cmake-args": ["-DCMAKE_BUILD_TYPE=Debug"] } } }

顶层的"cmake-args"键作用于所有包,"names"下面的是单包覆盖。这个文件被colcon自动读取,不需要额外参数指向它(也可以用--metas显式指定路径)。我在一个四十多个包的项目里用它把"调试包必须带符号、性能包必须开优化"这件事固定下来,比每个人自己记参数靠谱得多。

4.3 用COLCON_IGNORE隔离不该编的目录

src下面放临时拷贝、旧版本备份、别人给的参考代码,这些都不该被扫进去。最轻量的做法是在那个目录里丢一个空的COLCON_IGNORE文件:

touch src/reference_code/COLCON_IGNORE

colcon扫到它就整棵子树跳过,不再往下找包。相比把目录挪走,这个办法的好处是引用路径不用改,代码还在原地,只是不参与构建。

这个文件还有个进阶用法:把它放进build/和install/。虽然colcon默认会跳过这两个名字的目录,但如果你用了--install-base或者--build-base指定到别的路径上,名字就不叫build了,递归扫描有可能把它们当源码扫进去,然后你就会看到一大堆莫名其妙的package.xml解析错误。加个COLCON_IGNORE一劳永逸。

5. 测试链路与日志:构建之后还有半步没走完

5.1colcon test和test-result是两件事

很多人只跑colcon build,从来不跑 test。等到 CI 上失败才发现自己本地有测试用例挂了半年。ROS 2 的测试生态还算完整,ament_cmake包默认会注册gtest用例,ament_python包可以挂pytest。

colcon test --packages-select demo_node --event-handlers console_direct+ colcon test-result --verbose

注意这是两条独立的命令,而且第一条的返回码不一定是最终结果。colcon test负责跑,但它对单个用例失败的处理相对宽容;真正的结论要看colcon test-result,它读build/<包名>/test_results/下的 XML 汇总。不加--verbose时它只报总数,加上才会列出具体的失败用例和失败断言。

还有一个细节:colcon test默认不会重新构建。如果你改了代码没 build 就直接 test,测的是旧产物。测试结果和代码状态对不上,能让人怀疑人生半小时。我的习惯是固定写一行colcon build --packages-select X && colcon test --packages-select X && colcon test-result --verbose,当成一个动作。

5.2 日志目录的读法

log/下面每次构建会生成一个带时间戳的目录,latest_build和latest_test是指向最新一次的软链。结构是这样:

log/ ├── latest_build -> build_2024-xx-xx_xx-xx-xx ├── latest_test -> test_2024-xx-xx_xx-xx-xx └── build_2024-xx-xx_xx-xx-xx/ ├── events.log # 结构化的构建事件流 └── <pkg_name>/ ├── command.log # 实际执行的命令行 ├── stdout.log # 标准输出 ├── stderr.log # 标准错误 └── streams.log # 两者合并

排查问题的正确顺序是:先看stderr.log找错误关键词,看不出来再看command.log确认参数是不是你以为的那样,最后如果还是没头绪,看events.log——它是 JSON 行格式的完整事件流,能告诉你每个阶段的时间点和触发源。

我遇到过一次很典型的案例:加了--cmake-args -DCMAKE_BUILD_TYPE=Release但性能一点没变。看command.log才发现参数确实传了,但那个包自己的CMakeLists.txt里有一句set(CMAKE_BUILD_TYPE Debug)硬编码写死了,把它覆盖回去了。这种问题看构建输出是看不出来的,只有command.log加CMakeLists.txt对照才能定位。

6. 踩坑实录:六类典型故障和排查路径

6.1 "找不到包":八成是环境没 source 对

最高频的报错,没有之一。Package 'xxx' not found出现时,先别怀疑代码,按这个顺序查:

ros2 pkg prefix <包名>看它认为包在哪。返回空或者报错,说明AMENT_PREFIX_PATH里没有这个安装前缀。然后echo $AMENT_PREFIX_PATH看看你 source 的那个install目录到底在不在列表里。

如果路径在,但ros2 run还是找不到可执行文件,那问题在包内部:install/<包名>/lib/<包名>/下面有没有那个可执行文件?没有的话,是CMakeLists.txt的install(TARGETS ...)漏了;有的话,可能是可执行文件缺执行权限,或者架构不匹配。

还有一种很隐蔽的情况:包里有两个同名可执行文件,一个来自你本地编译,一个来自系统安装,ros2 run加载的是系统那个。用ros2 pkg executables <包名>看不出区别,得看which出来的实际路径。

6.2 OOM 与Killed (signal 9)

前面提过并行度的问题,这里补充排查手法。构建中途某个包突然Failed,日志里只有Killed,基本可以确定是被内核的 OOM killer 干掉了。确认方法:

# 构建前后看一眼,或者构建时另开终端盯着 dmesg -T | tail -20 | grep -i "out of memory"

看到Out of memory: Killed process xxx (cc1plus)就是实锤。处理办法按代价从低到高:降--parallel-workers;把最重的那个包单独拎出来编(--packages-select);给机器加 swap(治标,构建会变慢但至少能过);最后才是加内存。

我个人更推荐"拎出来单独编"这个思路。因为真正吃内存的往往就一两个包,把并行度整体降到 2 会让其他十几个小包的构建时间翻几倍,不划算。

6.3 切换--merge-install后环境错乱

这是个容易自己给自己挖坑的操作。先用默认 isolated 模式构建,环境一切正常;某天为了部署试了一次--merge-install,之后回到默认模式,发现各种找不到库、符号未定义。

原因是两种模式的install/布局完全不同:isolated 是install/<pkg>/lib/,merge 是install/lib/。切换时如果不清理,旧的目录结构还在,环境脚本却按新模式生成,路径指来指去就错位了。

铁律:换安装布局前,先rm -rf build install。没有例外。这一分钟省不得,省了要花半小时排查。

6.4 Python 改了不生效:三个可能

第一,没用--symlink-install,改完没重新 build。第二,用了--symlink-install但改的是setup.py里的entry_points或者新增了文件,这类改动必须重新 build 才会反映。第三,最阴的一种——你的 Python 包在PYTHONPATH里有另一个副本,import 时命中的是旧的。

第三个怎么确认?在节点里加一行打印:

import my_module print(my_module.__file__)

打印出来的路径如果不是你工作空间里install/下那个,就是路径污染。清理办法是新开终端,只 source 当前工作空间。

6.5 增量构建失效:为什么每次都全量编

正常情况下colcon是增量的,build/<包名>里的 CMake cache 和 object 文件会被复用。如果你发现每次构建都像第一次一样慢,检查两件事:一是你是不是每次都手动rm -rf build(有些人的构建脚本里写死了这行,忘了删);二是CMakeLists.txt里有没有configure_file之类的操作在每次配置阶段都改时间戳,导致所有目标都被判定为过期。

第二种比较隐蔽,判断方法是连续跑两次 build,看第二次耗时。正常应该是秒级,如果还是几十秒,就是有东西在每次配置时被"弄脏"了。看build/<包名>/CMakeFiles/下哪些.o被重编,能反推出是哪个 target 受影响。

6.6 依赖装的不是那个版本

find_package(xxx REQUIRED)找到了,但版本不对,API 对不上,编译报"没有成员函数"。这类问题在多工作空间叠加的环境里特别常见:系统装了一份,同事的工作空间装了一份,你的AMENT_PREFIX_PATH里两个都在,CMake 按顺序找到先出现的那份。

排查用colcon info <包名>看包的前缀路径,或者直接在CMakeLists.txt里临时加一行message(STATUS "xxx_DIR=${xxx_DIR}"),把 CMake 实际用的配置文件路径打出来。看到路径不对劲,就去调整 source 顺序,或者干脆把冲突的那份从环境里摘掉。

现象高概率原因第一步动作
Package not found没 source / source 错文件echo $AMENT_PREFIX_PATH
Killed (signal 9)并行导致 OOMdmesg查 OOM 记录,降并行
改了不生效未重编 / 路径污染打印模块__file__
切换布局后报错残留旧 install 目录rm -rf build install
每次全量重编构建脚本写死清理 / 时间戳脏连跑两次比对耗时
符号未定义依赖版本冲突打印 CMake 实际配置文件路径

我个人的习惯是把这些检查项直接写成一个小脚本放在工作空间根目录,出问题先跑一遍,能省掉大量"从零开始回忆"的时间。经验是:colcon本身很少出错,绝大多数问题出在环境变量叠加和依赖声明不全这两件事上,把这两个根源盯紧了,剩下的都是个案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询