ROS 2 工业级环境构建:Docker 容器化与 PREEMPT_RT 实时内核
"我电脑上明明跑得好好的"——这句话是创客和工程师之间的一道分水岭。创客把 demo 跑通就赢了;但当你把机器人交给产线,客户那边一开机就报 package not found、DDS 互相看不见节点、电机在力控模式下突然抖成帕金森,你就知道"能跑"和"稳跑"是两回事。
这两个崩溃其实指向两件不同的事:环境不统一和时间不统一。第一件是"为什么我的代码到你机器上就崩"——依赖版本、系统库、Python 解释器全对不上;第二件是"为什么毫秒级抖动能把电机干飞"——Linux 默认内核该 preempt 你的时候没 preempt,一次 3 ms 的调度延迟夹在 1 kHz 力控环里,关节直接冲出扭矩限幅。
这篇顺着这两条线走:先用 Docker 把"环境"钉死,再用 PREEMPT_RT 把"时间"钉死。每一节都在解决上一节没解决的新麻烦。
一、先选对 ROS 2 版本,否则地基就歪了
工业项目最怕的不是技术难,是选了个要过期的版本,交付半年就要升级迁移。先理清现状:
Noetic 是 ROS 1 的最后一版,官方 EOL 已于 2025 年 5 月到期。新项目别再碰 ROS 1,生态和工具链都进入维护冻结。剩下三个 ROS 2 选项,关键区别在"是不是 LTS(长期支持)"和"绑哪套 Ubuntu":
- Humble Hawksbill(2022-05 发布,LTS,支持到 2027-05):绑 Ubuntu 22.04。胜在生态最成熟、第三方驱动/文档最多,很多工业视觉和 PLC 桥接库仍只在 Humble 上验证过。
- Jazzy Jalisco(2024-05 发布,LTS,支持到 2029-05):绑 Ubuntu 24.04。推荐新项目直接上 Jazzy——更长的支持窗口意味着你三年后还在安全区,而且它用上新版
rmw_cyclonedds/DDS 默认项更合理。 - Kilted Kaiju(2025-05 发布,非 LTS,只支持到 2026-05):仅一年寿命。除非你要某个只在 Kilted 才有的新特性,否则工业场景碰都别碰,到期即弃。
一句话结论:新项目选 Jazzy,被 22.04 老库绑架的选 Humble,其余一律不考虑。 下面所有代码示例默认 Jazzy。
二、Docker 多阶段构建:把"环境"焊死在镜像里
光选对版本不够。哪怕都用 Jazzy,你本机 apt 装了 A 版 libopencv,同事的 CI 拉的是 B 版,编译出的 .so ABI 对不上,运行时静默崩。解决办法是不让任何人用本机环境跑:所有编译和运行都发生在同一个官方基础镜像 ros:jazzy 里。
下面这份 Dockerfile 用多阶段构建。为什么分两段?因为编译期需要 build-essential、colcon、一堆 -dev 头文件,体积能到几个 G;而真正上机运行的 runtime 阶段只要编译产物和动态库。把编译垃圾留在 builder 里,运行时镜像能从 4 GB 瘦到 800 MB 出头,下载快、攻击面小。
# ---------- 阶段一:编译期(builder)----------
# 用官方 Jazzy 基础镜像,确保 ROS 版本、Python、系统库全统一
FROM ros:jazzy AS builder
# 换国内源只是可选项;关键是没有这一步,你的同事拉镜像后
# apt 装到的版本和你完全一致,环境差异性从这里被抹平
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
python3-colcon-common-extensions \ # colcon 构建工具
python3-vcstool \ # 批量拉取.repos依赖
libopencv-dev \ # 示例用到的视觉库
&& rm -rf /var/lib/apt/lists/*
# 把源码先拷进来,利用 Docker 层缓存:改代码不重拉依赖
WORKDIR /workspace
COPY . /workspace
# 用 colcon 构建,--merge-install 把包平铺进一个目录,镜像更小
RUN . /opt/ros/jazzy/setup.sh && \
colcon build --merge-install --cmake-args -DCMAKE_BUILD_TYPE=Release
# ---------- 阶段二:运行期(runtime)----------
# 再次从干净的基础镜像起步,只搬运编译产物
FROM ros:jazzy AS runtime
# 运行期需要的最小依赖,不再带编译器
RUN apt-get update && apt-get install -y \
ros-jazzy-rmw-cyclonedds-cpp \ # 选 Cyclone DDS 做默认 RMW
&& rm -rf /var/lib/apt/lists/*
# 只从 builder 拷已编译好的 install 目录,编译工具链全留在上一阶段
COPY --from=builder /workspace/install /opt/robot/install
# 容器启动时默认 source 环境并起主节点
WORKDIR /opt/robot
CMD ["bash", "-c", "source /opt/ros/jazzy/setup.sh && \
source /opt/robot/install/setup.sh && \
exec ros2 launch robot_bringup control.launch.py"]
构建命令很直白,--target runtime 指定只产出运行镜像:
# 构建运行期镜像,打上版本标签,方便回滚
docker build --target runtime -t robot-ctrl:jazzy-v1 .
# 列出镜像,确认 runtime 比 builder 小得多
docker images robot-ctrl
三、运行时挂载:为什么非得 --net=host、--ipc=host、-v /dev:/dev
镜像焊死了,但容器一跑就发现新问题:节点互相看不见、串口打不开、图像话题卡成幻灯片。这是 Docker 默认网络/隔离在作怪,得用三个挂载把它们"打通回宿主机"。
# 完整启动命令,三个挂载各自解决一类问题
docker run -d --name robot-ctrl \
--net=host \ # 让容器共用宿主机网络栈,DDS 组播发现才正常
--ipc=host \ # 共用 /dev/shm,跨节点共享内存零拷贝才通
-v /dev:/dev \ # 把宿主机设备树挂进来,串口/CAN/相机直接可访问
--privileged \ # 实时线程、GPIO 等需要提权(产线可改用细粒度 cap)
robot-ctrl:jazzy-v1
逐个说清原因:
--net=host:ROS 2 默认用 DDS,靠 UDP 组播做节点自动发现。Docker 默认的 bridge 网络对组播支持极差,容器里发出的发现包根本到不了同机其他节点,表现为ros2 topic list空空如也。换成 host 网络后,容器和宿主机共用同一张网卡、同一套 IP,发现包正常收发。--ipc=host:DDS 在同一台机器内传大消息(比如相机图像)会走共享内存而不是走网卡,走的是/dev/shm。Docker 默认的/dev/shm只有 64 MB,图像一多就报shmout、话题静默丢数据。挂 host IPC 后共享宿主机的大块 shm,传输才稳。-v /dev:/dev:机器人离不开硬件——串口(/dev/ttyUSB*)、CAN 适配器(/dev/can*)、实时网卡、相机。把这些设备文件挂进容器,节点才能open()到它们;否则在容器里就是"设备不存在"。
四、PREEMPT_RT:把"时间"也焊死
环境统一了,新问题来了:电机在 1 kHz 力控下还是偶尔抽一下。这是时间的问题,容器解决不了。
标准 Linux 内核不是为实时设计的。它的调度器在大多数时候是抢占式的,但内核里仍有大量不可抢占的临界区(关中断、自旋锁),加上中断处理本身会延迟你的实时线程。当力控循环正要在第 1000 μs 准时输出扭矩时,内核可能正在处理一个网卡中断、把你这个线程堵了 3 ms。对 1 kHz 控制环(周期 1000 μs)来说,一次 3 ms 的延迟意味着三个周期没输出,关节直接冲出限幅——这就是"把电机干飞"的来源。
cyclictest 是测这个的标准工具:它起一组实时线程,周期性地睡醒并检查"我比预定时间晚醒了多少",这个差值就是调度延迟。标准内核在系统满载时,这个最大值常常 > 3000 μs;而打过 PREEMPT_RT 补丁的内核,能压到 50 μs 以内——够 1 kHz 环留出 20 倍余量。
4.1 打补丁与编译内核要点
以 Ubuntu 24.04 + 主线内核为例,思路是先取下rt 补丁、打进内核源码再重新编译:
# 假设已下载内核源码到 ~/linux,并下载对应版本的 PREEMPT_RT 补丁
cd ~/linux
# 打实时补丁(版本号需与内核源码严格匹配,否则拒绝)
xzcat ~/patch/linux-6.8-rt.patch.xz | patch -p1
# 关键配置:把内核变成完全可抢占
make menuconfig
# 依次打开:
# General setup -> Preemption Model -> Fully Preemptible Kernel (Real-Time)
# Kernel hacking -> Memory Debugging 里关掉会引入延迟的检查项
# Processor type and features -> 关闭 DEBUG_PREEMPT(可选,保延迟)
# 编译并安装(-j 用满核心;编译耗时以小时计,属正常)
make -j$(nproc) deb-pkg
sudo dpkg -i ../linux-*.deb
sudo reboot
重启后确认内核已带 -rt 后缀:
# 输出应类似 6.8.0-rt12,带 rt 即说明实时内核生效
uname -a
4.2 解锁实时优先级与内存锁
光有实时内核还不够。普通用户默认不能用 SCHED_FIFO 高优先级,也不能锁内存,得先在 /etc/security/limits.conf 放开:
# 新建一个 realtime 组,把运行机器人的用户加进去
sudo groupadd realtime
sudo usermod -aG realtime $USER
# 在 limits.conf 末尾追加:解锁实时优先级与内存锁
sudo tee -a /etc/security/limits.conf <<'EOF'
@realtime soft rtprio 99
@realtime hard rtprio 99
@realtime soft memlock unlimited
@realtime hard memlock unlimited
EOF
rtprio 99 允许该组用户把线程提到最高实时优先级;memlock unlimited 允许后面代码里把内存锁住,避免被换页到磁盘——一旦实时线程在关键周期被页错误卡住,实时性就破功了。
4.3 C++ 节点里锁内存 + 设实时调度
关键就两件事:mlockall() 把进程物理内存钉死,sched_setscheduler() 把线程交给 SCHED_FIFO。下面是一段能直接编进控制节点启动逻辑的片段:
// 在节点初始化最开头调用,越早锁越好
void enter_realtime(int prio) {
// 1) 把当前及未来分配的内存全部锁进物理 RAM,禁止换页
if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
perror("mlockall 失败:检查 limits.conf 的 memlock");
return;
}
// 2) 把调用线程设为 SCHED_FIFO,优先级 prio(1~99)
struct sched_param sp;
sp.sched_priority = prio;
if (sched_setscheduler(0, SCHED_FIFO, &sp) != 0) {
perror("sched_setscheduler 失败:检查 limits.conf 的 rtprio");
return;
}
printf("实时模式已就绪: SCHED_FIFO 优先级 %d\n", prio);
}
// 在控制循环里这样用:先进实时,再跑 1kHz 环
// enter_realtime(80);
// while (running) {
// auto t0 = now();
// read_sensor(); compute_torque(); write_motor();
// sleep_until(t0 + 1000us); // 周期 1000us = 1kHz
// }
注意 SCHED_FIFO 优先级最高 99,但别把你的线程设成 99——那会和看门狗、内核关键线程抢,反而把系统拖死。控制线程用 80 左右,留几级给更核心的系统任务,这是工业现场踩过坑的默认值。
4.4 cyclictest 满载压测:合格线 < 50 μs
最后用 cyclictest 在"最坏情况"下验证。光空载测没意义,得边压边测——同时跑 stress 烤 CPU、搬大文件、灌网络流量,看实时线程在最恶劣负载下的迟到峰值:
# 先装压测负载源
sudo apt-get install -y rt-tests stress-ng
# 起 4 个实时线程(-t4),纳秒精度(-n),优先级 80(-p80)
# 周期 200us(-i200),持续 60 分钟(-D 60m),锁内存(-m)
sudo cyclictest -m -t4 -n -p80 -i200 -D 60m
# 典型 RT 内核输出(关注 T 列 Max):
# T: 0 ( 1234) P:80 I:200 C: 180000 Min: 2 Act: 4 Avg: 5 Max: 38
# T: 1 ( 1235) P:80 I:200 C: 180000 Min: 2 Act: 5 Avg: 6 Max: 41
# 这里 Max 是 38/41 μs,稳稳低于 50 μs 合格线
判读标准很硬:Max 列的最大值必须 < 50 μs,才算达到 1 kHz 力控的安全余量。对照一下标准(非 RT)内核在同样满载下,Max 通常 > 3000 μs——差了两个数量级,这也解释了为什么不打补丁的机器跑力控会抽。如果测得 Max 还有几百 μs,先排查:是不是 mlockall 没生效、BIOS 里 CPU C-states/Intel SpeedStep 没关、或者某块网卡驱动仍走非实时中断。
五、软硬联动:供电是另一只看不见的手
环境钉死了,时间钉死了,你以为稳了。然后某天现场一上电,整台控制器瞬间重启,Docker 容器全丢,实时线程集体失踪。查了半天才发现:不是软件崩了,是电源崩了。
机器人大系统上电瞬间,电机驱动、伺服电源、大电容同时充电,会产生一次上电浪涌。如果板子没有防反接和缓启动,这个浪涌要么反向灌进控制器把电源芯片干掉,要么电压被瞬间拉垮,Reset 脚被触发,你精心构建的实时系统在一毫秒内灰飞烟灭。软件再稳,也架不住供电这只看不见的手在底层抽你。
这就引出了配套电路篇:如何在电源入口处挡住反接、用缓启动把浪涌削平、用 eFuse 在短路时毫秒级自保。
下一篇把供电这一半讲完:工业级机器人电源板:防反接、缓启动与 eFuse——从一颗理想二极管控制器开始,一路讲到如何用 TPS2660 这类 eFuse IC 把过流保护做到 μs 级。
参考文献
- ROS 2 Documentation. "Humble/Jazzy/Kilted Release Notes & Distro Status." docs.ros.org.(各发行版支持周期与默认 RMW)
- The PREEMPT_RT Wiki. "RT_PREEMPT HOWTO / Patch and compile a real-time kernel." wiki.linuxfoundation.org/realtime.(补丁获取与 menuconfig 配置项)
- Clark, C. et al.
cyclictestman page &rt-testsdocumentation.(延迟测试命令参数与 Max 判读) - Docker Docs. "Use host networking / Share memory between containers / Runtime privilege." docs.docker.com.(--net=host、--ipc、--privileged 行为说明)
- cyclonedds / Eclipse. "Cyclone DDS RMW for ROS 2." cyclonedds.org.(共享内存传输与 /dev/shm 要求)
- Texas Instruments. "TPS2660 4.5–60 V, 150 mΩ eFuse with Integrated FET." ti.com.(下一篇电源板参考 IC)