ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Jetson+STM32双芯片架构:移动机器人实时控制源码包解析

Jetson+STM32双芯片架构:移动机器人实时控制源码包解析

简介:这份源码资源完整呈现了一台基于Jetson Orin NX(8GB)与STM32协同控制的移动机器人项目,面向正在入门ROS机器人开发、嵌入式运动控制或激光雷达与视觉融合应用的开发者。包内包含ROS工作空间、STM32控制逻辑、机械臂与车身运动控制相关代码,以及思岚C1激光雷达与奥比中光Gemini相机的驱动配置与启动文件,能帮助读者理解从底层驱动到上层感知的完整工程结构。资源包共1884个文件,以make与cmake构建脚本为主,辅以头文件、launch启动脚本、cpp与py源码,并包含srv/msg自定义通信接口,便于按模块研读和部署。压缩包仅2.39MB,版本简洁,适合在Jetson平台快速参考。目前已有1309人学习下载,适合具备一定Linux与ROS基础、希望参考一套多传感器融合实车源码来搭建或改进自己机器人项目的开发者。

1. 双芯片机器人源码包:Jetson Orin NX 和 STM32 是怎么各司其职的

第一次用这套源码包搭移动机器人,我把电机 PID 直接放在 Jetson 上跑,视觉模型一推理,速度指令就开始抽风,轮胎的转速听起来像在打拍子。后来把底盘控制全部下放给 STM32,Jetson Orin NX(8GB)只干感知和规划,问题才彻底消失。这套源码包就是按这个分工设计的:Jetson 端接管 Gemini 深度相机和激光雷达的驱动,跑 SLAM、障碍物检测与全局路径规划;STM32 端负责电机速度环、编码器采集、急停和电量监控,两边通过串口或 CAN 交换速度指令与状态反馈。

适合正在做室内移动底盘、巡检机器人、服务机器人的开发者。新手可以照着章节把系统串起来,老手则能直接参考协议帧和线程结构,省掉从零写底层驱动的时间。它不是什么黑匣子,而是把一台机器人的“脑子”和“小脑”拆开,各自干最擅长的事。

2. 硬件协同与选型:为什么是“Jetson 算,STM32 控”

选型不是拍脑袋。移动机器人里,感知和运动控制对计算平台的要求完全相反:感知需要大算力,但能容忍几十毫秒抖动;运动控制需要硬实时,但计算量不大。用一块 Jetson 跑 Linux 做全部事情,会遇到调度延迟和中断响应不确定的问题。源码包把两者拆开,本质上是把一个实时性问题拆成“算力不够”和“实时性不够”两个独立问题,分别用不同芯片解决。

2.1 双芯片分工的依据:算力、实时性与功耗的权衡

Jetson Orin NX 8GB 在嵌入式平台里算力并不弱,跑轻量级 YOLO 模型、运行 2D SLAM 都没有压力。但它是 Linux 环境,GPU 调度、内存交换、后台进程都可能造成毫秒级抖动。电机速度环需要确定的 1kHz 控制周期,一旦某个周期被延迟,电流和转速就会出现毛刺。而 STM32 这类微控制器,用硬件定时器中断可以保证周期误差在微秒级,所以分工的第一条原则是:不是看“能不能跑”,而是看“能不能按时跑”。

我一般把任务分成两类,源码包也是按这个思路组织的。

功能模块归属芯片原因
Gemini 深度图、点云生成Jetson依赖 Linux + CUDA SDK
激光雷达驱动、SLAM、代价地图Jetson需要 ROS 生态和大内存
路径规划、速度指令发布Jetson与传感器数据耦合紧密
电机速度环、位置环STM32需要 1kHz 硬实时控制
编码器倍频解算STM32中断频率高、定时器精度要求高
急停、限位开关、电量监控STM32必须独立于 Linux 运行

功耗也是现实约束。Jetson 模块跑起视觉推理,整机功耗经常到几十瓦;STM32 加电机驱动板待机只有几十毫瓦。做电池供电的巡检机器人时,让 STM32 负责睡眠唤醒和低功耗巡检,Jetson 深度睡眠,续航能明显提升。源码包里虽然没有把整个电源管理做完,但底层接口已经留好了。

2.2 激光雷达与 Gemini 深度相机的数据接入方式

常见的传感器接法是:激光雷达和 Gemini 深度相机都接 Jetson,STM32 不直接读传感器数据。原因很简单,避障算法需要把激光点云和深度点云放在同一时间戳下融合,数据都走 Jetson,才能用消息同步机制对齐。STM32 只接收 Jetson 下发的最终速度指令,这样底层不会被传感器数据淹没。

室内移动机器人常用单线激光雷达,大部分通过 USB 转串口出数据,ROS 驱动会发布 /scan 话题。Gemini 深度相机走 USB 3.0,输出 RGB、深度图和点云,这类相机提供的 SDK 依赖 Linux 和 CUDA,所以把它接在 Jetson 上是唯一合理选择。

这里有一个容易忽略的带宽问题:Jetson 的 USB 控制器虽然支持 USB 3.0,但同时挂载深相机、激光雷达、4G 模块时,带宽会争抢。我的习惯是 Gemini 相机独占一路 USB Controller,激光雷达挂在另一路,避免同时大量传输时出现深度帧掉帧。源码包的设备启动脚本里会做 USB 端口绑定,就是为了减少这种软性丢帧。

2.3 电源与通信:12V 供电、串口选择和共地问题

电源设计是整个系统最容易翻车的地方。Jetson Orin NX 载板一般需要 DC 12V 供电,STM32 开发板常用 5V,激光雷达和 Gemini 相机也吃 5V。如果全从一个开关电源拉电,要注意瞬态电流:Gemini 相机 USB 供电在启动瞬间可能超过 1A,USB 口压降一大就直接掉线。所以我的做法是给相机独立供电,数据线只传信号,不传电源。

Jetson 与 STM32 之间常用串口或 CAN。UART 接线少、协议简单,波特率 115200 或 460800 足够传递速度指令;CAN 抗干扰强、支持多节点,适合底盘带多个电机控制板的情况。无论选哪种,通信线都要尽量远离电机动力线,否则电机电流突变时容易出现误码。

还有一个必须注意的点:Jetson 的 GND 和 STM32 的 GND 必须共地。有些人只看 RX/TX 两根线,结果串口乱码,最后发现是两个板子的地电平不一致,加了根地线就正常了。源码包的协议设计里带了校验字段,也是为了抵抗这些噪声带来的偶发错帧。

3. 源码结构拆解:从“一个包”到“能跑起来”的逻辑

拿到源码包先别急着编译,看清楚目录划分再动手。源码包不是把所有代码堆在一起,而是按“感知、决策、底层控制”三条线分开。理解目录结构能让你知道改哪个文件、启动哪个节点,以及遇到问题时该去哪里查日志。

3.1 目录划分:每个文件夹解决什么问题

解压后是典型的工作空间布局,我用一个简化版本说明:

robot_ws/ ├── jetson_nx/ # 运行在 Jetson 上的 ROS 功能包 │ ├── perception/ # Gemini 相机与激光雷达驱动封装 │ ├── navigation/ # SLAM、代价地图、路径规划 │ ├── decision/ # 避障策略与目标点管理 │ └── serial_bridge/ # 与 STM32 通信的串口/ CAN 桥接节点 ├── stm32_firmware/ # STM32 裸机工程 │ ├── Core/ # 主循环、中断回调 │ ├── Drivers/ # HAL 库与底层驱动 │ └── App/ # 电机 PID、协议解析、急停逻辑 └── scripts/ # 设备权限、开机自启、录包脚本

perception 和 navigation 分开是有原因的。感知模块只管把雷达 /scan 和 Gemini 的深度图转成可供决策的障碍信息;navigation 只消费处理后的数据,不关心 Gemini 的 SDK 怎么安装。将来想换一款相机或者加一个超声波传感器,只改 perception 就够了。

serial_bridge 是 Jetson 端连接 STM32 的唯一通道,它订阅 /cmd_vel,把线速度和角速度打包成二进制帧,通过串口发给 STM32。这样做的好处是上层程序不需要关心底层协议,STM32 也不需要在 ROS 环境里跑。

3.2 核心数据流:从传感器到电机命令

整个系统的数据流可以概括成一条链:激光雷达和 Gemini → 障碍物检测 → 决策节点 → /cmd_vel → serial_bridge → STM32 → 电机。下面这段代码可以理解决策节点的工作逻辑:

#!/usr/bin/env python3 # 决策节点:订阅 /scan,计算最近障碍距离,输出速度指令 import rospy from sensor_msgs.msg import LaserScan from geometry_msgs.msg import Twist pub = rospy.Publisher('/cmd_vel', Twist, queue_size=1) def scan_cb(msg): tw = Twist() valid_ranges = [r for r in msg.ranges if 0.1 < r < 3.5] if not valid_ranges: rospy.logwarn("激光雷达有效范围为空,保持停止") pub.publish(tw) return min_dist = min(valid_ranges) if min_dist < 0.5: tw.linear.x = 0.0 tw.angular.z = 0.5 # 原地向右转 else: tw.linear.x = 0.3 tw.angular.z = 0.0 pub.publish(tw) rospy.init_node('decision') rospy.Subscriber('/scan', LaserScan, scan_cb) rospy.spin()

这段代码的关键在于queue_size=1。发布 /cmd_vel 时如果队列太长,旧指令会堆积,STM32 拿到的是迟到的速度,导致电机反应迟钝。设为 1 表示只保留最新指令,丢掉没来得及处理的旧指令,这是低速移动机器人最常用的策略。实际源码包里的决策节点会更复杂,可能会融合 Gemini 点云生成 3D 障碍栅格,但数据流主线就是这个链路。

serial_bridge 把 /cmd_vel 转成下面的帧格式再发送给 STM32:

typedef struct __attribute__((packed)) { uint8_t head; // 帧头 0xAA 0x55 uint8_t id; // 0x01 表示速度指令 int16_t linear_x; // 线速度,单位 mm/s int16_t angular_z; // 角速度,单位 0.01 rad/s uint8_t checksum; // 累加和校验 } SpeedFrame;

用 int16_t 传速度而不是浮点,是因为 STM32 端解析整数快,也不需要引入浮点库。线速度单位是 mm/s,角速度单位是 0.01 rad/s,这样可以保留一位小数精度,又避开浮点误差。校验和字段必须保留,电机噪声和地线波动产生的偶发错帧,靠它才能被识别丢弃。

3.3 关键参数表:波特率、坐标系与速度上限

移植到自己的机器人时,最优先检查的是下面这张表,参数几乎都在配置文件中能找到:

参数名默认值说明
serial_port/dev/ttyTHS1Jetson 与 STM32 的串口设备号
baud_rate460800串口波特率,与 STM32 端保持一致
scan_frame_idlaser_link雷达坐标系
camera_frame_idgemini_link深度相机坐标系
base_frame_idbase_link机器人底盘坐标系
max_linear_speed0.5 m/s线速度上限
max_angular_speed1.0 rad/s角速度上限
pid_rate1000 HzSTM32 速度环频率

坐标系是新手最容易栽的地方。laser_link、gemini_link、base_link 三者之间的 TF 变换必须和真实物理安装位置一致,否则导航模块以为自己看到了墙,实际上雷达看的是自己的支架。源码包给了一个 tf2 静态变换发布脚本,改了安装位置以后,记得同步改这里的数值。

4. 从零跑通:环境配置、编译与联调步骤

源码包能不能跑起来,取决于环境是否对齐。Jetson 端需要 ROS 环境、相机 SDK、雷达驱动;STM32 端需要编译链和烧录工具。下面按“先 Jetson,再 STM32,最后联调”的顺序讲,每一步都有验证方法。

4.1 Jetson 端环境:刷系统、安装 ROS 与编译工作空间

拿到 Jetson 模块以后,先刷对应版本的 Ubuntu 镜像。刷机工具会预装 Linux 内核、CUDA 和 TensorRT,不需要手动装 NVIDIA 驱动,这一步能省掉很多麻烦。刷完系统以后,安装 ROS 基础环境:

# 安装 ROS 基础组件,这里以 ROS2 的某个 LTS 版本为例 # 具体版本号请和源码包 README 保持一致 sudo apt update sudo apt install -y ros-${ROS_DISTRO}-desktop sudo apt install -y ros-${ROS_DISTRO}-serial sudo apt install -y ros-${ROS_DISTRO}-tf2 # 创建工作空间并编译源码包 cd ~/robot_ws rosdep install --from-paths src --ignore-src -r -y colcon build --symlink-install source install/setup.bash

${ROS_DISTRO}是环境变量,实际使用时需要根据安装的 ROS 版本替换,比如 humble 或 foxy。--symlink-install表示用符号链接方式安装,改 Python 脚本不用重新编译,调试阶段特别有用。rosdep 会自动拉取未知依赖,但国内网络环境下偶尔会卡住,遇到超时可以手动安装对应包,我后面会讲一个和权限相关的坑。

4.2 STM32 端固件工程:用 STM32CubeMX 生成并烧录

STM32 端的工程建议从源码包里提供的 CubeMX 配置开始,不要自己重新建工程。用 STM32CubeMX 打开 ioc 文件,检查 UART 引脚是否和 Jetson 串口对应,然后重新生成代码。生成后编译,用 ST-Link 烧录:

# 单独编译固件,输出 bin 文件 make -j4 # 通过 ST-Link 烧录到 Flash 起始地址 st-flash --connect-under-reset write build/firmware.bin 0x8000000

如果手边没有 ST-Link,也可以直接用串口 DFU 烧录,但需要先把 BOOT0 引脚拉高,速度比较慢。烧录完成后,STM32 端的板载 LED 常亮代表主循环正常,如果 LED 闪烁则可能是协议解析错误,需要通过串口回环检查接线。

4.3 雷达与 Gemini 相机驱动验证:先看 topic,再测数据

启动驱动的先后顺序有讲究。先启动相机和雷达,再启动导航,最后再开启 serial_bridge。这样节点启动时就能拿到传感器数据,不会因为 /cmd_vel 无人订阅而报警。

# 查看 USB 设备是否识别到 Gemini 和雷达转接器 lsusb # 启动 Gemini 相机 ROS 驱动 roslaunch gemini_camera gemini_node.launch # 启动激光雷达驱动,具体包名以源码包 README 为准 roslaunch lidar_lib lidar_node.launch # 分别检查话题发布频率 rostopic hz /scan /camera/depth/image_raw

rostopic hz输出每秒消息数,雷达一般在 10~15Hz,Gemini 深度图根据分辨率不同,可能有 15~30Hz。如果某个话题频率是 0,说明驱动没正常起来,先去看对应节点的日志,不要急着调导航参数。lsusb是最直观的设备排查手段,确认设备是否被系统识别。

5. 联调避坑:时间同步、串口丢包与权限问题

这一章是实车调试遇到过的“血泪经验”。下面这些坑看起来都很小,但每一个都可能导致整台机器人原地罢工。我把它们按“现象 → 原因 → 解决”写成记录,方便直接对号入座。

5.1 高频踩坑记录(现象 → 原因 → 解决)

现象 1:串口发给 STM32 的速度指令时断时续,电机一顿一顿地转。 原因:Jetson 端串口缓冲区溢出,STM32 端没有做环形缓冲,单字节中断又丢失数据。 解决:STM32 端切换到 UART DMA + IDLE 不定长接收,用空闲中断判断一帧结束;Jetson 端提高串口 buffer 大小,并校验帧头帧尾,丢弃错帧。

现象 2:Gemini 深度相机在 USB 3.0 下运行一段时间后掉线,重启节点能恢复。 原因:USB 供电不足,或者 Jetson 的 USB 电源管理自动挂起设备。 解决:给 Gemini 独立 5V 供电,并在系统里禁用 USB 自动挂起。

# 禁用 USB 自动挂起,将下面配置写入 /etc/rc.local 或 systemd 服务 echo 'on' > /sys/bus/usb/devices/1-1/power/control

现象 3:激光雷达和深度相机融合后,障碍物边缘出现明显重影,避障时频繁急停。 原因:两个传感器数据帧的时间戳不一致,导航节点拿到的是“错位的世界”。 解决:用 message_filters 做时间同步。如果时间差异大于 20ms,则需要考虑硬件触发或者调整发布频率。

现象 4:STM32 的 PID 频率调到 1kHz 后,急停按钮反应很迟钝,有时候撞到墙才停下。 原因:PID 放在 main loop 的 while 循环里,急停逻辑也要等循环跑完才能执行。 解决:把急停用外部中断实现,并设为 STM32 最高抢占优先级;PID 放在定时器中断里,优先级低于急停。

现象 5:设备每次重启以后,激光雷达的串口设备号从 /dev/ttyUSB0 漂到 /dev/ttyUSB1,启动脚本找不到设备。 原因:linux 按枚举顺序分配设备名,换个 USB 口或者启动顺序变了,名字就变了。 解决:通过 udev 规则绑定设备 vendor/product ID,固定符号链接到 /dev/lidar,脚本里只引用这个固定路径。

5.2 硬件时序与中断优先级:为什么急停必须最高

STM32 端的中断优先级设置直接影响安全。这里给出典型的优先等级配置:

// 急停外部中断,抢占优先级最高 HAL_NVIC_SetPriority(EXTI15_10_IRQn, 0, 0); // 电机 PID 定时器中断,比急停低一级 HAL_NVIC_SetPriority(TIM6_IRQn, 1, 0); // 串口 DMA 空闲中断,优先级最低 HAL_NVIC_SetPriority(USART3_IRQn, 2, 0);

急停不能放在主循环里用if (GPIO_ReadPin())来判断,因为在 while 执行其他阻塞代码时,灾难已经发生了。必须用硬件外部中断才能保证任何时刻都能打断当前任务。PID 定时器中断虽然也有实时要求,但低于急停。这个优先级顺序写进源码包里,不建议为了 PID 响应速度去抢占急停的优先级。

5.3 软件层面的日志与诊断工具

系统出问题时,第一步不是改代码,而是看日志。Jetson 端我常用下面几条命令定位:

# 检查 USB 设备是否异常断开 dmesg | grep -i "usb\|gemini" # 查看关键话题是否持续发布 rostopic hz /scan /camera/depth/image_raw # 查看 Jetson 的 CPU/GPU 占用,排除算力瓶颈 sudo jtop

dmesg 能看到 USB 设备的反复插拔记录,往往能直接定位相机掉线。rostopic hz能确认是驱动问题还是设备问题。jtop 可以看 GPU 和 CPU 占用,如果决策节点和大模型同时跑导致 GPU 满载,也会让导航降频,看起来像电机反应慢。把这些日志按时间对比,基本能判断问题出在感知、决策还是底层控制。

6. 进阶技巧:用 rosbag 回放代替实车调试,先把参数调明白

实车调试最烦的是每次改一个参数都要重新跑一圈,耗时又危险。我的习惯是把传感器原始数据录下来,回到实验室用 rosbag 回放调算法,等参数基本收敛再上车。这个方法能省掉一大半实车调试时间。

先在实车开一圈,同时录制必要的话题:

# 记录雷达、深度点云、里程计和速度指令 rosbag record -O indoor_lidar.bag \ /scan \ /camera/depth/image_raw \ /camera/depth/points \ /odom \ /cmd_vel

录制时带上 /cmd_vel,这样回放时能看到当时的控制指令。回到实验室回放时,不要发布 /cmd_vel,否则机器人会以为自己已经被控制了:

# 只回放传感器和里程计,不发布 /cmd_vel rosbag play --pause \ --topics /scan /camera/depth/image_raw /camera/depth/points /odom \ indoor_lidar.bag # 打开 RViz,观察代价地图和路径变化 roslaunch rviz rviz

暂停播放,每改一个导航参数后再继续播放几秒,立刻能看到路径和代价地图形状变化。以前调局部代价地图的膨胀半径,实车跑了两个多小时没试对,回放时用这个方法二十分钟就确定阈值:膨胀半径太小,机器人贴着墙走;太大,狭窄通道直接封死。从那以后我每次动控制参数,都强制走一遍“先录后放”的流程,调出来再实车验证。

需要注意,rosbag 回放解决的是感知和规划问题,电机响应延迟、轮胎打滑这类问题还是要实车测。回放时也没有真实碰撞风险,参数可以大胆试,这也是我推荐它的原因。希望这个习惯能帮你在联调时少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进