发布于 

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。下面是一段能直接编进控制节点启动逻辑的片段:

#include <pthread.h>
#include <sys/mman.h>
#include <sched.h>
#include <cstdio>

// 在节点初始化最开头调用,越早锁越好
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 级。

参考文献

  1. ROS 2 Documentation. "Humble/Jazzy/Kilted Release Notes & Distro Status." docs.ros.org.(各发行版支持周期与默认 RMW)
  2. The PREEMPT_RT Wiki. "RT_PREEMPT HOWTO / Patch and compile a real-time kernel." wiki.linuxfoundation.org/realtime.(补丁获取与 menuconfig 配置项)
  3. Clark, C. et al. cyclictest man page & rt-tests documentation.(延迟测试命令参数与 Max 判读)
  4. Docker Docs. "Use host networking / Share memory between containers / Runtime privilege." docs.docker.com.(--net=host、--ipc、--privileged 行为说明)
  5. cyclonedds / Eclipse. "Cyclone DDS RMW for ROS 2." cyclonedds.org.(共享内存传输与 /dev/shm 要求)
  6. Texas Instruments. "TPS2660 4.5–60 V, 150 mΩ eFuse with Integrated FET." ti.com.(下一篇电源板参考 IC)