基于 ROS 2 的外骨骼机器人控制系统实战
前言
外骨骼最先让人想到的是电影里那种钢铁战甲,但落到工程上,它要解决的问题很具体:给一条本来抬不起来的腿,借一副可靠的骨骼和肌肉。
想象一位下肢瘫痪的使用者。他大脑里想"迈步"的信号还在,只是传不到肌肉。外骨骼做的事,就是替他把这条信号接出来——读懂他想动,再用电机替他把腿抬起来。这听起来像科幻,但拆开看就是一套会"传令、决策、避险"的普通控制系统。
本系列前面几篇已经分别讲了 ROS 2 基础、Gazebo 仿真和外骨骼技术。这篇把它们拼成一套完整的外骨骼控制系统,可以理解为给腿装一套"自动驾驶":sEMG 传感器是眼睛(读你的意图),运动规划是大脑(决定怎么动),ros2_control 是神经传令(把决策变成电机电流),安全监控是贴身保镖(出事立刻急停)。跟着读完,你就能搭出一个能"读懂肌肉信号 → 驱动关节 → 自己急停"的最小闭环。
读到最后,你会拥有一个能跑的最小闭环:肌肉一使劲,腿就跟着动,越界就急停。它当然还不是产品——没有力位混合的精细控制,也没有针对不同步态的自适应——但它是所有外骨骼研究的共同地基。先把地基打牢,后面想加什么算法都接得上。
动手之前先记住一条主线:意图从左边进来,动作从右边出去,安全监控始终横在中间。后面每一段代码,都是这条主线上的一站。如果某一节没完全懂,先跳过,把整条链路跑通再回头抠细节。
动手前先看这三点
外骨骼是"挂人身上"的系统,和桌面机器人最大的区别是:出错的代价是人体伤害。动手前先默念三条,它们会贯穿后面所有代码:
- 任何电机都要先能急停。 本文的
/emergency_stop必须真接到底层硬件,调通之前不要上电带载,更不要让人套上设备。 - 参数对照实物。 力矩、速度上限按你电机的 datasheet 填,别照抄本文数字——文里用的是示意值,直接套可能要么使不上劲、要么烧电机。
- 分层验证。 每一节都能独立跑、独立测,别等全部接好才第一次启动。先让每个节点单独"活"过来,再谈联动。
把这三句话贴在显示器边上,比任何一段代码都重要。
你会用到的关键概念
读代码前先统一几个词,免得后面绕晕:
- ros2_control:ROS 2 里管"硬件怎么动"的框架,关节、控制器、硬件接口都归它管。
- Controller Manager(控制器管理器):ros2_control 的"派单中心",负责加载、启停各个控制器。
- Hardware Interface(硬件接口):框架和真电机之间的翻译层,本文自己写了一个。
- Impedance Control(阻抗控制):不直接定角度,而是定"软硬程度",让机器人与人柔性交互,而不是硬顶。
- sEMG(表面肌电):从皮肤表面采到的肌肉电信号,用来读人的运动意图。
- MoveIt2:运动规划库,负责在关节边界内算出合法又顺滑的轨迹。
- 话题(topic):节点之间发消息的通道,名字以
/开头。
把它们串起来看一遍最小控制回路:传感器(sEMG / IMU)读出意图 → 规划器算出轨迹 → 控制器算出命令 → 硬件接口发往电机 → 电机回传状态 → 控制器再修正。这个"命令 → 动作 → 反馈"的环,就是所有机器人控制的核心,外骨骼只是把环里每一格都换成了"贴身"版本。先混个脸熟,后面代码就不吓人了。
系统架构设计
下面进入正题。我们会按"先让 ROS 认识这条腿 → 再让系统听懂你的意图 → 然后规划出安全轨迹 → 最后加一道保镖"的顺序推进。每一步都能独立运行、独立调试,所以哪怕你只做到某一节,也能跑出看得见的效果。
在写任何一行控制代码之前,先把整条链路画清楚——否则后面每个节点都会变成孤岛,你永远不知道数据卡在了哪一环。
ROS 2 节点拓扑
这张图是整套系统的"作战地图",数据流从左边进来。
先从左到右扫一遍:sEMG 传感器节点读出你的肌肉信号,IMU 节点读出身体姿态,两者汇入运动规划节点"猜你要做什么";规划结果变成关节命令,交给 Controller Manager 和 ros2_control 去驱动真实电机;同时安全监控节点全程盯着,一不对劲就急停。 后面每段代码,都是图里的一个方块——记住这条主线,读代码就不会迷路。
┌─────────────────────────────────────────────────────┐
│ 外骨骼控制系统 │
│ │
│ ┌──────────┐ /emg_raw ┌──────────────┐ │
│ │ sEMG │──────────────→│ sEMG 信号 │ │
│ │ 传感器 │ │ 处理节点 │ │
│ │ 节点 │ └──────┬───────┘ │
│ └──────────┘ │ │
│ │ /motion_intent │
│ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ IMU │──/imu───→ │ 运动规划 │ │
│ │ 节点 │ │ 节点 │ │
│ └──────────┘ └──────┬───────┘ │
│ │ │
│ │ /joint_command │
│ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ ros2_ │←─/joint──────│ Controller │ │
│ │ control │ _states │ Manager │ │
│ │ 节点 │ └──────────────┘ │
│ └────┬─────┘ │
│ │ /motor_commands │
│ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 电机驱动 │ │ 安全监控 │ │
│ │ 接口 │ │ 节点 │ │
│ └──────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
把图里的节点翻译成人话,方便后面逐块对照:
- sEMG 节点、IMU 节点是"眼睛",负责采集肌肉信号和身体姿态;
- 运动规划节点是"大脑",根据采集到的信息猜你下一步想干嘛;
- Controller Manager 是"调度室",把大脑的决定派给具体控制器去执行;
- ros2_control 是"神经",把控制器的数字指令变成电机里真实的电流;
- 安全监控节点是"保镖",不参与决策,但永远有权踩急停。
它们之间只靠"话题(topic)"互相喊话——/motion_intent 传意图,/joint_command 传命令,/joint_states 回报状态。这几个名字先记熟,后面代码里会反复撞见。
工作空间结构
代码落地前,先把工作空间(workspace)的目录摆好。这一层只是"文件夹规划",不涉及任何控制逻辑——你照着建三个包就行:exo_control 用 C++ 写硬件接口,exo_sem 用 Python 做信号处理,exo_safety 用 Python 做安全监控。后面所有命令都基于这个骨架展开。
~/exo_ws/
├── src/
│ ├── exo_control/ # C++:硬件接口 + 控制器
│ │ ├── launch/
│ │ ├── config/
│ │ ├── urdf/
│ │ ├── include/
│ │ ├── src/
│ │ ├── CMakeLists.txt
│ │ └── package.xml
│ ├── exo_sem/ # Python:sEMG 信号处理
│ │ ├── exo_sem/
│ │ ├── setup.py
│ │ └── package.xml
│ └── exo_safety/ # Python:安全监控
│ ├── exo_safety/
│ ├── setup.py
│ └── package.xml
三个包建好后都不是自动生效的:C++ 包要 colcon build,Python 包要 pip install -e .。装完务必 source install/setup.bash,否则后面 ros2 launch 会告诉你找不到包。本篇所有命令都假设你已经 source 过工作空间。包名一定要和各自 package.xml 里的 <name> 完全一致,差一个字母都会让依赖解析失败。
常见编译坑:如果 colcon build 报找不到某个 ROS 依赖,先 rosdep install --from-paths src -y 把系统级依赖补齐;Python 包若运行时 import 报错,确认 setup.py 里的 entry_points 和包目录名一致。这两个问题占了新手 80% 的"为什么跑不起来"。
骨架搭好了。第一步,得先让 ROS 认识这条腿——从关节配置和硬件接口做起。
实战 1:ros2_control 配置外骨骼关节
从这一节起,前面的"概念"开始落地成真正的文件。你会写出 URDF、YAML、C++ 三类文件,它们恰好对应 ros2_control 的"声明 — 配置 — 实现"三层。
读的时候别急着抄代码,先看每个文件在回答什么问题:URDF 回答"有哪些关节、各自能干什么",YAML 回答"怎么驱动它们",C++ 回答"怎么和真电机说话"。把这三问搞清楚,后面你换一台电机、加一个关节,也只是改参数、不动架构。
这一节的目标是让 ROS 第一次"认识"这条腿:有哪些关节、每个关节能收什么命令、能回报什么状态、物理上接在哪个串口。这三件事分别对应 URDF、控制器配置、硬件接口三段。
读的时候抓住一条线索:URDF 声明能力 → 控制器配置决定怎么用这些能力 → 硬件接口把能力真正落到电机上。三者名字(关节名、接口名)必须一一对上,对不上 ros2_control 启动时就会报接口不匹配。URDF 本质是 XML,别被它的长度吓到——它只是用标签把"有哪些关节、各自能干什么"描述清楚。
URDF 定义下肢外骨骼
创建 urdf/exo_lower_limb.urdf.xacro,定义髋、膝、踝三个关节。
这段 URDF 干一件事:把"一条腿"交给 ROS 认识。 它声明了左髋、左膝、左踝三个关节,每个关节都能收两种命令(position 位置指令、effort 力矩指令)并回报三种状态(位置、速度、力矩)。和之前仿真篇不同,这里 plugin 换成了我们自己的 ExoHardwareInterface——因为要接的是真电机,不是 Gazebo 里的虚拟模型。你只需重点盯三处:hardware 段里的串口参数(决定跟哪台电机通信)、每个 joint 下的 command_interface(能收什么令)、state_interface(能回报什么)。结构看懂"三个关节"就够,细节先跳过。
<robot xmlns:xacro="https://www.ros.org/wiki/xacro" name="exo_lower_limb">
<!-- ros2_control 入口:type=system 表示整条腿当作一个硬件系统统一管理 -->
<ros2_control name="ExoHardware" type="system">
<hardware>
<!-- 关键:用自定义 Hardware Interface,而不是 Gazebo 仿真插件 -->
<plugin>exo_control/ExoHardwareInterface</plugin>
<!-- 串口是上位机和电机驱动器之间的物理通道,按你实际设备改 -->
<param name="serial_port">/dev/ttyUSB0</param>
<param name="baud_rate">115200</param>
<!-- 100Hz 控制频率:太低关节会抖,太高串口带宽扛不住,100 是常见折中 -->
<param name="update_rate">100</param>
</hardware>
<!-- 左髋关节:声明它能收什么命令、能回报什么状态 -->
<joint name="left_hip">
<command_interface name="position"/> <!-- 可以发"转到某角度"的命令 -->
<command_interface name="effort"/> <!-- 也可以直接发"用多少力矩"的命令 -->
<!-- 闭环三件套:控制器靠它们知道自己控得准不准 -->
<state_interface name="position"/>
<state_interface name="velocity"/>
<state_interface name="effort"/>
</joint>
<!-- 左膝关节:结构和髋完全一样,只是名字不同 -->
<joint name="left_knee">
<command_interface name="position"/>
<command_interface name="effort"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
<state_interface name="effort"/>
</joint>
<!-- 左踝关节:同上,三个关节拼成整条腿 -->
<joint name="left_ankle">
<command_interface name="position"/>
<command_interface name="effort"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
<state_interface name="effort"/>
</joint>
</ros2_control>
</robot>
读到这里你可能会想:为什么一个关节要同时能收 position 和 effort 两种命令?
因为外骨骼有两种基本工作模式:位置模式("请转到这个角度")和力矩模式("请出这么大力")。阻抗控制就工作在力矩模式——它不直接命令角度,而是发一个"该用多少力"的指令,由电机去平衡你施加的力量,于是你感到的是"被辅助"而不是"被强行摆弄"。
这也解释了为什么 state_interface 要把位置、速度、力矩全报回来:控制器要做闭环,差一个量就算不准。少报一个,阻抗那根"橡皮筋"就松了准头。
常见坑:<param name="serial_port"> 写错设备名(实际常是 /dev/ttyUSB1)会让 on_init 里 open 串口失败,启动直接报错。先用 ls /dev/ttyUSB* 确认到底是哪个口。
Controller 配置
创建 config/controllers.yaml。
这段是"给每个关节配司机"。 joint_trajectory_controller 负责按规划好的轨迹平滑驱动;exo_position_controller 是直接发位置的简单模式;最关键是下面的 impedance_controller——它是外骨骼的"软"灵魂:不硬邦邦地顶你,而是像橡皮筋一样,你用力它退、你松手它跟,这叫"阻抗/顺应控制"。stiffness / damping / inertia 三个数就是这根橡皮筋的软硬、黏滞和惯性,后面实战 4 还会用到。你只需关注每个 controller 的 type 行(决定它是什么行为),以及 impedance_controller 里那三个向量。
controller_manager:
ros__parameters:
update_rate: 100
# 关节状态广播器:把所有关节状态统一发出去,别的控制器和rviz都靠它
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
# 关节轨迹控制器(配合 MoveIt2 使用):按时间平滑地走到目标角度
joint_trajectory_controller:
type: joint_trajectory_controller/JointTrajectoryController
# 前馈位置控制器(直接控制):最简单,外部发啥角度它就转哪
exo_position_controller:
type: forward_command_controller/ForwardCommandController
# 阻抗控制器(自定义):外骨骼"软"的灵魂,下面才是它的真参数
impedance_controller:
type: exo_control/ImpedanceController
# 轨迹控制器参数
joint_trajectory_controller:
ros__parameters:
joints:
- left_hip
- left_knee
- left_ankle
command_interfaces:
- position
state_interfaces:
- position
- velocity
gain: 100.0 # P 增益:越大跟得越紧,但太大容易抖甚至震荡
allow_partial_joints_goal: false
# 前馈位置控制器参数
exo_position_controller:
ros__parameters:
joints:
- left_hip
- left_knee
- left_ankle
interface_name: position
# 阻抗控制器参数(重点看这里)
impedance_controller:
ros__parameters:
joints:
- left_hip
- left_knee
- left_ankle
# 阻抗三参数 [K 刚度, D 阻尼, M 惯性],单位 N·m/rad
stiffness: [20.0, 30.0, 10.0] # 越大越"硬",像石膏;越小越"软",像橡皮筋
damping: [2.0, 3.0, 1.0] # 越大越黏,抑制抖动;太小会"弹"个不停
inertia: [0.5, 0.8, 0.3] # 越大越"懒",对突发用力反应越慢
max_effort: [30.0, 40.0, 15.0] # 力矩上限:安全护栏,防止控制器发疯烧电机
加载顺序有讲究:必须先起 joint_state_broadcaster,再加载其它控制器。因为后者需要读关节状态来闭环,状态广播器没起来就去订阅,会一直拿不到数据而报错。
impedance_controller 里的 max_effort 是最后一道机械护栏:即便上层规划算疯了、发了离谱的力矩指令,实际输出也不会超过这个数——既保护电机也保护人。
调试建议:第一次跑先把 impedance_controller 换成 exo_position_controller 直接发角度,链路通了再切回阻抗模式,亲自体会"硬顶"和"软跟"的区别。控制器是热插拔的——运行时用 ros2 control switch_controllers 就能换,不用重启整个系统。
自定义 Hardware Interface
创建 src/exo_hardware_interface.cpp(核心逻辑)。
这是 ros2_control 和真硬件之间的"翻译官",也是整篇最硬核的一段 C++。 前面那些控制器只会说"关节转到 0.5",它负责把这句话变成串口指令发给电机,再把电机回传的编码器/电流读回来。重点看四个函数:on_init 初始化、export_*_interfaces 声明能收什么发什么、read 从串口读传感器、write 往串口写电机命令。 中间那堆 position_states_、effort_commands_ 就是收发信箱——控制器往命令箱里塞,write 取走;read 把传感器塞进状态箱,控制器来取。你不用死磕每一行,抓住"read 进、write 出"这条主干即可。
为什么硬件接口非用 C++ 写不可?因为它跑在 100Hz 的实时循环里,Python 的 GIL 和垃圾回收会带来不可预测的延迟抖动,关节可能因此突然一颤。离电机最近的这层通常用 C++,离得远的感知、规划、监控才用 Python——这是 ROS 里"越底层越 C++"的潜规则。
namespace exo_control
{
class ExoHardwareInterface : public hardware_interface::SystemInterface
{
public:
// 初始化:ros2_control 启动第一步就调它,用来认领关节、开串口
CallbackReturn on_init(const hardware_interface::HardwareInfo & info) override
{
// 先记下有几个关节,后面所有缓冲区都按这个数分配
num_joints_ = info.joints.size();
// 分配状态和命令缓冲区:状态箱(读回来的),命令箱(要发出去的)
position_states_.resize(num_joints_, 0.0);
velocity_states_.resize(num_joints_, 0.0);
effort_states_.resize(num_joints_, 0.0);
position_commands_.resize(num_joints_, 0.0);
effort_commands_.resize(num_joints_, 0.0);
// 从 URDF 的 <param> 里取出串口号;实际项目这里还要真正 open 串口
serial_port_ = info_.hardware_parameters["serial_port"];
// ... 串口初始化代码(open、设置波特率等)
return CallbackReturn::SUCCESS;
}
// 告诉 ros2_control:我能回报哪些状态。每个关节报 位置/速度/力矩 三项
std::vector<hardware_interface::StateInterface> export_state_interfaces() override
{
std::vector<hardware_interface::StateInterface> state_interfaces;
for (size_t i = 0; i < num_joints_; i++) {
// 把缓冲区的地址交给框架,框架之后直接读写这些内存
state_interfaces.emplace_back(
info_.joints[i].name, "position", &position_states_[i]);
state_interfaces.emplace_back(
info_.joints[i].name, "velocity", &velocity_states_[i]);
state_interfaces.emplace_back(
info_.joints[i].name, "effort", &effort_states_[i]);
}
return state_interfaces;
}
// 告诉 ros2_control:我能接收哪些命令。每个关节收 位置/力矩 两种
std::vector<hardware_interface::CommandInterface> export_command_interfaces() override
{
std::vector<hardware_interface::CommandInterface> command_interfaces;
for (size_t i = 0; i < num_joints_; i++) {
command_interfaces.emplace_back(
info_.joints[i].name, "position", &position_commands_[i]);
command_interfaces.emplace_back(
info_.joints[i].name, "effort", &effort_commands_[i]);
}
return command_interfaces;
}
// 读:每个控制周期被调一次,从串口把编码器/电流读进状态箱
CallbackReturn read(const rclcpp::Time & time, const rclcpp::Duration & period) override
{
// 真实项目里把串口字节解析成角度和电流,这里用两个占位函数示意
for (size_t i = 0; i < num_joints_; i++) {
position_states_[i] = read_encoder(i); // 编码器 → 角度
effort_states_[i] = read_current(i); // 电流 → 力矩
}
return CallbackReturn::SUCCESS;
}
// 写:每个控制周期被调一次,把命令箱里的值经串口发给电机
CallbackReturn write(const rclcpp::Time & time, const rclcpp::Duration & period) override
{
// 控制器算好的命令,在这一刻真正变成电机动作
for (size_t i = 0; i < num_joints_; i++) {
send_motor_command(i, position_commands_[i], effort_commands_[i]);
}
return CallbackReturn::SUCCESS;
}
private:
size_t num_joints_;
std::string serial_port_;
std::vector<double> position_states_, velocity_states_, effort_states_;
std::vector<double> position_commands_, effort_commands_;
};
} // namespace exo_control
// 把类注册成插件,ros2_control 才能通过 URDF 里的 <plugin> 名字找到它
PLUGINLIB_EXPORT_CLASS(exo_control::ExoHardwareInterface, hardware_interface::SystemInterface)
这段 C++ 编译进 exo_control 后,还必须在 plugin_description.xml 里登记,ros2_control 才能按 URDF 里 <plugin> 的名字找到它。漏掉这步是最常见的"找不到硬件接口"报错来源——编译没报错,一启动却说 plugin 不存在。
read 和 write 每隔 1/100 秒各被调一次,属于"硬实时"路径。里面绝对不要 sleep、不要做复杂计算、不要打印大量日志,否则会拖慢整个控制周期,关节开始抖甚至失稳。串口解析要快进快出,重活(滤波、规划)留给别的节点。
最后一句 PLUGINLIB_EXPORT_CLASS 就是那个"登记"动作,把它和 XML 对应起来,框架才认得这个类。
关节能转了,但"谁来下令动"?接下来接入人的意图——用肌肉电信号告诉机器人"我想走"。
实战 2:sEMG 信号采集与处理
上一节让机器"认识"了腿,但腿还不知道"什么时候该动"。这一节解决"意图从哪来"——也就是让人用肌肉告诉机器人"我想走"。
为什么不用按钮?因为外骨骼的理想形态是无意识辅助:你一想动,它就帮;你一停,它就收。按钮会打断这种自然感。sEMG 恰好能"偷听"到你想动的肌肉信号,比任何开关都快,后面所有"智能"都建立在这层意图识别之上,所以这一节的质量直接决定了整条腿"跟不跟手"。
这一节你会写下第一个 Python 节点。它离电机最远,所以也最适合用 Python——快速试错、改特征、换模型都不心疼。
这一节让系统"听懂"人的意图。思路是:从皮肤表面采肌肉电信号 → 滤波去噪 → 提取几个能区分动作的特征 → 判断你此刻想站、走、坐还是上楼。
注意这里的分工:本节点只负责"信号 → 意图",至于意图怎么变成关节轨迹,交给下一节的 MoveIt2。这样每层各管一摊,调试时也能单独替换(比如换一种分类模型,不影响其它部分)。
sEMG 传感器接入方案
先说硬件选型,因为后面代码的"通道数"由它决定。预算紧就上 MyoWare 2.0,科研要精度就上 Delsys,想彻底自定义就自己搭 ADS1299 板子:
| 方案 | 成本 | 精度 | 适用场景 |
|---|---|---|---|
| MyoWare 2.0 | 50–100 美元 | 中 | 学习/原型开发 |
| Delsys Trigno | 5000 美元以上 | 高 | 科研级 |
| ADS1299 + 自制电极 | 200–500 美元 | 中高 | 定制化开发 |
简单说,sEMG(表面肌电信号)就是肌肉收缩时皮肤表面冒出来的微弱电信号,幅度只有毫伏级、频率集中在 20–500 Hz。把它放大滤波后,不同动作会呈现不同的"波形指纹"——比如想握拳和想伸掌,肌肉激活的模式不一样。代码要做的,就是把这段噪声里的指纹抠出来。
选 MyoWare 这类集成板最省事:它自带放大和整流,直接给你模拟电压;ADS1299 则是专业级多通道采集,适合做科研。先确定通道数,后面代码里的 channels 参数就照它填。电极贴放位置也讲究:通常贴在目标肌群肌腹中央,两片间距 1–2 cm,太远信号弱、太近互相串。贴之前用酒精棉擦一下皮肤,能显著降低接触噪声。
怎么判断信号质量够不够?最直观的办法是用 ros2 topic echo /motion_intent 看特征数值的波动:静息时特征应当接近平稳的小值,用力时明显抬升。如果静息和用力时特征几乎一样,多半是电极松了或增益不对,先调硬件,别急着改算法。
Python sEMG 处理节点
创建 exo_sem/exo_sem/emg_processor.py。
这段是系统里"读心"的那一步。 sEMG 是从皮肤表面采到的肌肉电信号——你可以把它想象成肌肉在"小声嘟囔",这段程序负责先滤波去噪,再提取几个特征,最后猜出你的意图(站/走/坐/上楼)并发布出去。现在里面跑的是模拟数据,接上真实 MyoWare 之类的传感器就能跑真值。你只需抓住主干:process_emg 每 200ms 跑一次,里面依次做"滤波 → 提特征 → 发布"三件事。
#!/usr/bin/env python3
"""sEMG 信号采集与运动意图识别节点"""
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float64MultiArray
import numpy as np
class EMGProcessor(Node):
def __init__(self):
super().__init__('emg_processor')
# 参数:把"通道数/采样率/窗口"做成可调参数,换硬件不用改代码
self.declare_parameter('channels', 4) # sEMG 通道数(肌群数)
self.declare_parameter('sample_rate', 1000) # 采样率 Hz,越高细节越多
self.declare_parameter('window_size', 200) # 分析窗口(200ms)
# 发布运动意图:下游规划节点就订阅这个话题
self.intent_pub = self.create_publisher(
Float64MultiArray, '/motion_intent', 10)
# 定时处理:每 0.2 秒扫一次窗口,太慢反应迟钝,太快噪声大
self.timer = self.create_timer(0.2, self.process_emg)
self.get_logger().info('sEMG Processor node initialized')
def bandpass_filter(self, signal, low=20, high=500, fs=1000):
"""带通滤波(20-500Hz):只留肌肉信号,滤掉工频和抖动"""
from scipy.signal import butter, filtfilt
nyq = fs / 2
# 巴特沃斯 4 阶,边界归一化到 0~1(nyq 是上限)
b, a = butter(4, [low/nyq, high/nyq], btype='band')
return filtfilt(b, a, signal)
def extract_features(self, window):
"""提取时域特征:把一段波形压成几个有辨识度的数"""
features = []
for ch in window:
mav = np.mean(np.abs(ch)) # 平均绝对值:反映肌肉收缩强度
rms = np.sqrt(np.mean(ch ** 2)) # 均方根:更稳的"用力程度"
var = np.var(ch) # 方差:信号波动大小
wl = np.sum(np.abs(np.diff(ch))) # 波形长度:动作复杂度
zc = np.sum(np.diff(np.sign(ch)) != 0) # 过零率:频率高低近似
features.extend([mav, rms, var, wl, zc])
return np.array(features)
def process_emg(self):
"""处理 sEMG 窗口数据:滤波 → 提特征 → 发布意图"""
# 真实场景:从传感器节点订阅最新一窗数据
# raw_data = self.latest_emg_data # shape: (channels, window_size)
# 模拟数据(接真传感器时把这行换成上面订阅到的数据)
channels = self.get_parameter('channels').value
window_size = self.get_parameter('window_size').value
raw_data = np.random.randn(channels, window_size) * 0.01
# 预处理:逐通道带通滤波,去掉噪声
filtered = np.array([
self.bandpass_filter(ch) for ch in raw_data
])
# 特征提取:每段波形 → 一小串数,便于分类
features = self.extract_features(filtered)
# 意图分类(0=站立, 1=行走, 2=坐下, 3=上楼梯)
# 真实项目:intent = self.classifier.predict(features.reshape(1, -1))
# 这里先用占位,下游暂时按 0 处理
# 发布意图:三条关节的目标角度,让规划节点接着算轨迹
msg = Float64MultiArray()
msg.data = [0.0, 0.0, 0.0] # [hip_angle, knee_angle, ankle_angle]
self.intent_pub.publish(msg)
def main():
rclpy.init()
node = EMGProcessor()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
关于实时性:sEMG 到动作的延迟最好压在 200ms 以内,否则人会觉得"跟不上"。所以窗口不能取太长(太长就慢),也不能太短(太短特征不稳)。200ms 是个经验甜点,真机上你可以从 150ms 试到 300ms 找手感。
接真实传感器时,把 process_emg 里那行 np.random.randn(...) 模拟数据,换成从订阅缓存取真实窗口即可,其余逻辑一行不用动。分类那行 classifier.predict 现在被注释掉了,你可以接一个训练好的模型——输入是 extract_features 得到的那串数,输出是 0/1/2/3 四种意图。
实战顺序建议:先用模拟数据把"滤波 → 特征 → 发布"整条链路跑通,确认 /motion_intent 有数据;再接 MyoWare 真实信号替换模拟源。这样一旦出问题,你能立刻判断是硬件没接好还是算法算错了。
读出了"我想走",还得把它变成一条安全、平滑的关节运动路线——这就是运动规划要做的。
为什么需要专门规划?因为从"意图"到"关节角度"之间,隔着人体工学的边界:膝盖不能反弯、步幅不能过大、动作不能太猛。直接把意图当角度发过去,轻则别扭,重则伤人。MoveIt2 的价值,就是在这些边界内帮你算出一条合法又顺滑的轨迹。下面先给它划定每条关节的活动范围。
关于意图分类本身多说一句:真正的产品通常不会用"站/走/坐/上楼"这种硬分类,而是直接回归出目标关节角度或力矩,避免"切换动作瞬间"的突跳。本文用分类只是为了让链路最短、最容易跑通,你后续可以平滑升级。
外骨骼 URDF 关节链
用 MoveIt Setup Assistant 为外骨骼生成配置时,最该关心的是关节的生理活动范围。下面这段就是给左腿三个关节设上下限——注意膝盖不能反弯(下限 0°),踝和髋则按人体工学给范围。你只需关注每个关节的 lower / upper 两行,它们就是机器人的"关节安全边界",规划器绝不会让它越界。
# moveit 配置中的关节限制
left_leg:
joints:
- left_hip:
lower: -0.52 # 约 -30°,向后摆的极限
upper: 1.22 # 约 70°,向前抬的极限
- left_knee:
lower: 0.0 # 0°:膝盖不能反弯,这是硬下限
upper: 2.27 # 约 130°,完全屈曲
- left_ankle:
lower: -0.52 # 约 -30°
upper: 0.52 # 约 30°,上下各 30° 是常见活动范围
怎么确认范围设对了?在 RViz 里拖动规划组,看它会不会在膝关节炎边界处"顶住"——如果 RViz 允许你把膝盖拖成反弯,说明 lower/upper 没生效,回去查关节名是否和 URDF 完全一致。
MoveIt 的"规划组"决定它把哪些关节当成一个整体来规划。外骨骼我们只关心左腿这串关节,所以规划组就是 hip–knee–ankle 一条链。规划组配错(比如漏了踝),规划器就会当那只关节不存在,轨迹自然不对。
下面这段关节上下限,就是给规划器划的"活动场地"——它生成的任何轨迹都不会超出这个范围,等于在算法层面先杜绝了反弯等危险姿势。
安全运动约束
这段给运动规划加了一道"铁律":膝关节永远不允许反弯。 它生成一个 JointConstraint 塞进规划器,权重拉满(weight=1.0),意思是这条约束不可违背——规划器宁可规划失败,也不会给出反弯的轨迹。代码很短,但它是把"人体生理"翻译成"规划器能听懂的约束"的典型写法,值得照抄到你的项目里。
# MoveIt2 中添加人机安全约束
from moveit_msgs.msg import Constraints, JointConstraint
def create_exo_safety_constraints():
constraints = Constraints()
# 膝关节安全约束:任何时候不允许反弯
knee_constraint = JointConstraint()
knee_constraint.joint_name = "left_knee"
knee_constraint.position = 0.0 # 基准设为 0°
knee_constraint.tolerance_above = 2.27 # 向上允许到 130°
knee_constraint.tolerance_below = 0.0 # 向下不允许(不能小于 0°)
knee_constraint.weight = 1.0 # 最高权重:这条必须被满足
constraints.joint_constraints.append(knee_constraint)
return constraints
这里 weight=1.0 是重点:权重越高,规划器越优先满足这条约束。设为 1.0 意味着"不惜代价也要守住"——哪怕为了避开反弯导致规划变慢甚至失败,也绝不出反弯轨迹。如果设得很低,规划器可能为了走捷径而悄悄忽略它。
这类约束可以叠加多条(髋、踝各来一条),组合起来就是外骨骼的"生理红线"。
路线有了,但挂在人腿上不能只图聪明、不图安全——最后加一道保镖。
实战 4:安全监控节点
前面三节让你能"想、懂、规划",但都建立在"一切正常"的假设上。现实里电机可能过载、传感器可能掉线、使用者可能突然踉跄。挂在人腿上的东西,绝对不能赌它永远正常——所以安全要单独成节,而且它的优先级高于前面所有逻辑。
创建 exo_safety/exo_safety/safety_monitor.py。
外骨骼挂在人腿上,出错就是真摔,所以这个"保镖"节点不能省。 它盯着 /joint_states:关节有没有超限、速度是不是太快、力矩是不是过大,任一触发就往 /emergency_stop 发 true。跑起来后,只要哪个关节越界,终端会立刻打印 SAFETY VIOLATION 并急停。 你重点看 check_safety 里的三层判断(限位 / 速度 / 力矩),以及最后发布急停的那两行——这是整套系统里最该认真对待的一段,参数一定要对照你电机的实物规格填。
#!/usr/bin/env python3
"""外骨骼安全监控节点"""
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Bool
import numpy as np
class SafetyMonitor(Node):
def __init__(self):
super().__init__('safety_monitor')
# 订阅关节状态:所有判断都基于这个话题实时推送的数据
self.joint_sub = self.create_subscription(
JointState, '/joint_states', self.check_safety, 10)
# 发布紧急停止信号:下游硬件接口看到 true 必须立刻松力矩
self.estop_pub = self.create_publisher(Bool, '/emergency_stop', 10)
# 安全参数:按你的电机实物规格填,宁可严不可松
self.max_velocity = [2.0, 2.0, 3.0] # rad/s,三个关节各自的速度上限
self.max_effort = [30.0, 40.0, 15.0] # N·m,力矩上限,防止烧电机
self.joint_limits = [
(-0.52, 1.22), # 髋关节 [下限, 上限]
(0.0, 2.27), # 膝关节:下限 0,绝不能反弯
(-0.52, 0.52), # 踝关节
]
self.prev_position = None
self.get_logger().info('Safety Monitor active')
def check_safety(self, msg):
"""实时安全检查:限位 / 速度 / 力矩,任一越界就急停"""
estop = False
reasons = []
# 第一层:关节限位。多留 0.05 余量,避免浮点误差误触发
for i, pos in enumerate(msg.position):
if pos < self.joint_limits[i][0] - 0.05:
estop = True
reasons.append(f'Joint {i} below limit: {pos:.3f}')
elif pos > self.joint_limits[i][1] + 0.05:
estop = True
reasons.append(f'Joint {i} above limit: {pos:.3f}')
# 第二层:速度。用相邻两帧位置差除以周期 dt 估速度
if self.prev_position is not None:
dt = 0.01 # 100Hz 控制频率,即 0.01 秒一帧
for i in range(len(msg.position)):
vel = abs(msg.position[i] - self.prev_position[i]) / dt
if vel > self.max_velocity[i]:
estop = True
reasons.append(f'Joint {i} velocity too high: {vel:.2f}')
# 第三层:力矩。有 effort 数据才查,避免没接力矩传感器时误报
if len(msg.effort) == len(self.max_effort):
for i, eff in enumerate(msg.effort):
if abs(eff) > self.max_effort[i]:
estop = True
reasons.append(f'Joint {i} effort too high: {eff:.2f}')
# 发布急停:true 时下游硬件接口应立刻切断电机力矩
msg_estop = Bool()
msg_estop.data = estop
self.estop_pub.publish(msg_estop)
if estop:
self.get_logger().warn(f'SAFETY VIOLATION: {"; ".join(reasons)}')
self.prev_position = list(msg.position)
def main():
rclpy.init()
node = SafetyMonitor()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
这段里 prev_position 用来算速度——它比较当前帧和上一帧的位置差再除以周期。第一次回调时 prev_position 是 None,所以代码专门用 if self.prev_position is not None 挡了一下。这是写实时循环极易漏掉的一个细节,漏了就会在首帧直接崩。
急停信号发到 /emergency_stop 后,谁来执行?通常要在 exo_hardware_interface 的 write 里订阅这个话题,一旦为 true 立刻把电机力矩清零。保镖喊了停,神经必须真的松手——这条链不接上,急停就是摆设。
参数务必对照你电机的实物规格填:力矩上限填太小会"使不上劲",填太大失去保护意义;速度上限同理。
这个节点最值得单独测:先不接真电机,用 ros2 topic pub /joint_states 手动发一个超限的角度,看终端是否立刻报 SAFETY VIOLATION 并发布 /emergency_stop 为 true。保镖先在仿真里验过,才敢挂到真腿上。
上面这套是我们自己写的 ROS 2 控制栈。但社区里有个成熟项目 OpenExo 已经把电机固件写好了——能不能直接拿来用?答案是可以,加一层"桥"就行。
OpenExo 开源项目解析与 ROS 2 集成思路
OpenExo 目前不直接使用 ROS,但可以通过以下方式集成。思路分三层:ROS 2 负责上层调度,桥接层做协议转换,最底下 OpenExo 自己的固件照常跑。
┌─────────────────────────────────────────┐
│ ROS 2 层 │
│ ros2_control → Controller → MoveIt2 │
├─────────────────────────────────────────┤
│ 桥接层 │
│ serial_bridge_node (串口通信桥接) │
├─────────────────────────────────────────┤
│ OpenExo 层 │
│ Teensy 固件 ← serial → Python GUI │
└─────────────────────────────────────────┘
为什么要这么分层?因为 ROS 2 层和固件层各有各的演进节奏:固件可能跑在单片机、用串口说话,而 ROS 2 跑在 Linux、用话题说话。中间这层桥接把两种语言隔开,任何一边换了实现,另一边都不用动。这也是机器人系统里"硬件抽象"思想的缩影:永远在物理设备和高级算法之间留一层翻译。
桥接方案
OpenExo 本身是另一套固件(跑在 Teensy 上),不直接说 ROS 2 的话。 这段桥接节点就是"同声传译":一边用串口跟 Teensy 聊,另一边用 ROS 2 话题把关节状态发出去、把命令收进来。读懂它,你就掌握了把任何现成硬件塞进 ROS 2 的通用套路:串口桥接 + 话题转换。 你只需看两个函数:read_states 把串口一行文本解析成 JointState 发出去,send_commands 把 ROS 2 命令打包写回串口。
#!/usr/bin/env python3
"""OpenExo ↔ ROS 2 串口桥接节点"""
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Float64MultiArray
import serial
import struct
class OpenExoBridge(Node):
def __init__(self):
super().__init__('openexo_bridge')
# 串口连接 Teensy:波特率必须和固件里设的一致
self.serial = serial.Serial('/dev/ttyACM0', 115200, timeout=0.01)
# ROS 2 接口:状态往外发,命令从外收
self.joint_pub = self.create_publisher(
JointState, '/joint_states', 10)
self.cmd_sub = self.create_subscription(
Float64MultiArray, '/joint_commands', self.send_commands, 10)
# 100Hz 读状态,和外骨骼控制频率对齐
self.timer = self.create_timer(0.01, self.read_states)
def read_states(self):
"""从 Teensy 读取关节状态:串口行 → JointState"""
try:
line = self.serial.readline().decode().strip()
if line.startswith('STATE:'):
# 约定格式 STATE:hip,knee,ankle,hip_v,knee_v,ankle_v,hip_e,knee_e,ankle_e
values = list(map(float, line.split(':')[1].split(',')))
msg = JointState()
msg.position = values[:3] # 髋、膝、踝角度
msg.velocity = values[3:6] # 三个关节角速度
msg.effort = values[6:9] # 三个关节力矩
self.joint_pub.publish(msg)
except Exception as e:
self.get_logger().debug(f'Read error: {e}')
def send_commands(self, msg):
"""向 Teensy 发送关节命令:ROS 2 命令 → 串口行"""
# 把目标角度拼成 CMD:... 文本,和固件约定好的格式一致
cmd = f"CMD:{','.join(map(str, msg.data))}\n"
self.serial.write(cmd.encode())
桥接节点之所以能套到各种设备上,是因为它只做"格式转换":串口文本 ↔ ROS 消息,完全不关心里面跑什么控制算法。这意味着你既可以用 OpenExo,也可以换成任何用串口吐 STATE:...、吃 CMD:... 的设备,桥的代码一字不改。
若对方协议不同,只要重写 read_states(解析对方格式)和 send_commands(按对方格式封装)两个函数即可,节点骨架不动。掌握这个套路,你就能把实验室里任何现成硬件平滑塞进 ROS 2。
桥接节点也能先脱离真硬件测:用 socat 起一个伪串口,或者写个发 STATE:... 的小脚本冒充 Teensy,看 /joint_states 是否如约出现。把硬件依赖架空,逻辑就能在笔记本上反复验证。
所有节点都写好了,最后把它们一次性拉起来。
系统启动流程
前面四节写好了四个节点,但它们彼此独立。这一节把它们"拧成一股绳"——按正确顺序一次性拉起来。
启动顺序不是随便排的:硬件接口必须先起,状态广播器必须早于控制器,否则后者会因拿不到关节状态而报错。把这套顺序固化下来,就是你将来部署脚本的雏形。
这段是把前面所有节点串起来的启动脚本。 依次做五件事:先起硬件接口、再加载两个控制器、然后跑 sEMG 处理、再跑安全监控,最后可选起 MoveIt2。你照着顺序敲就行;真正上线时,这些命令会被写进 launch 文件和 systemd 服务(下一篇多机部署会讲怎么让它们开机自启)。
# 1. 启动 ROS 2 核心 + 外骨骼硬件接口
ros2 launch exo_control exo_hardware.launch.py
# 2. 加载控制器(先状态广播器,再阻抗控制器)
ros2 control load_controller --set-state active joint_state_broadcaster
ros2 control load_controller --set-state active impedance_controller
# 3. 启动 sEMG 处理节点(读肌肉意图)
ros2 run exo_sem emg_processor
# 4. 启动安全监控节点(全程保镖)
ros2 run exo_safety safety_monitor
# 5. (可选)启动 MoveIt2 运动规划
ros2 launch exo_control moveit.launch.py
启动后怎么确认没接错?按层验证,别一上来就指望全跑通:
ros2 topic list—— 五个核心话题(含/motion_intent、/joint_command、/joint_states、/emergency_stop)都在不在;ros2 topic echo /joint_states --once—— 关节状态在不在跳动;- 故意把某个关节推过限位 —— 终端应立刻打印
SAFETY VIOLATION,且电机松力。
三层都通过,说明"意图 → 驱动 → 急停"闭环真的通了。
下一步是把这五条命令固化成 launch 文件,再交给下一篇讲的多机部署与系统服务去开机自启——你不用每次手动敲五条命令、还怕敲错顺序。等这套验证稳了,就把五条命令收进一个 launch 文件,再用下一篇的 systemd 服务包成开机自启。到那时你敲一条命令,整条外骨骼就醒过来——但在这之前,请务必先在站立、有人扶的状态下试,别一上来就松手。
本篇调试检查清单
写机器人最怕的不是"不会写",而是"写完了不知道哪坏了"。外骨骼尤其如此——任何一环沉默,表现出来都是"腿不动"四个字,你根本无从下手。
所以把验证动作提前固化成清单,比多写代码更有用:每勾一项,就排除一大片可能。下面这张表按"从底层到上层"的顺序排,从上往下卡,卡住哪行就回哪节。
把前面散落的验证点收拢成一张表,照着勾就行:
- [ ] 编译:
colcon build通过,source install/setup.bash无报错; - [ ] 硬件接口:启动
exo_hardware.launch.py不报"找不到 plugin"; - [ ] 关节识别:
ros2 topic echo /joint_states能看到三个关节在动; - [ ] 肌肉意图:用力时
/motion_intent的特征数值明显高于静息; - [ ] 安全护栏:手动发超限角度,终端打印
SAFETY VIOLATION且/emergency_stop变 true; - [ ] 桥接(若用 OpenExo):伪串口下
/joint_states能正常出现; - [ ] 急停联动:保镖发 true 时,电机力矩确实被清零。
哪一项卡住,就回到对应那一节单独调,不要带着故障往下走。外骨骼调试的黄金法则:每一项能力都先"单独能跑",再谈"合起来跑"。
还想继续往下读
如果你是从系列前面几篇一路跟过来的,下面这几篇可以作为延伸——它们把"单机控制"往"仿真"和"技术全景"两个方向补齐:
| 篇章 | 内容 |
|---|---|
| ROS 2 入门与环境搭建 | ROS 2 Kilted 安装、核心概念、通信机制 |
| ROS 2 仿真实战 | Gazebo 仿真、URDF 建模、MoveIt2 运动规划 |
| 外骨骼技术概述 | 外骨骼分类、最新研究、控制算法、开源项目 |
| 本文 | 完整的外骨骼 ROS 2 控制系统设计与实现 |