658 lines
17 KiB
Markdown
658 lines
17 KiB
Markdown
# 参数系统 (Parameter) 全解
|
||
|
||
> **目标**: 彻底理解 ROS2 参数系统的设计原理、API、配置方式、回调机制,能独立设计参数化节点。
|
||
>
|
||
> **阅读时间**: 60-90 分钟
|
||
>
|
||
> **前置知识**: 已完成 `py_pubsub`(理解节点 + Topic),已阅读 `10-concepts.md`。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [1. 是什么 (What)](#1-是什么-what)
|
||
- [2. 为什么需要参数 (Why)](#2-为什么需要参数-why)
|
||
- [3. 设计原理 (Design)](#3-设计原理-design)
|
||
- [4. API 全解 (API)](#4-api-全解-api)
|
||
- [5. 实战代码 (Code)](#5-实战代码-code)
|
||
- [6. 测试策略 (Test)](#6-测试策略-test)
|
||
- [7. 进阶玩法 (Advanced)](#7-进阶玩法-advanced)
|
||
- [8. 故障排查 (Pitfalls)](#8-故障排查-pitfalls)
|
||
- [9. 推荐阅读 (Further)](#9-推荐阅读-further)
|
||
|
||
---
|
||
|
||
## 1. 是什么 (What)
|
||
|
||
**参数 (Parameter)** 是 ROS2 节点的**运行时配置项**,它有以下特点:
|
||
|
||
| 特性 | 说明 |
|
||
|---|---|
|
||
| **类型** | 7 种基本类型 + 数组 + 字节数组 |
|
||
| **生命周期** | 节点启动时声明,运行时可改,节点关闭时销毁 |
|
||
| **作用范围** | 节点级(每个节点独立) / 全局(全局参数服务) |
|
||
| **持久化** | 可选,持久化参数在节点重启后恢复 |
|
||
| **原子性** | `set_parameters_atomically` 保证多参数同时生效 |
|
||
|
||
### 7 种基本类型
|
||
|
||
```python
|
||
ParameterType.PARAMETER_BOOL # bool
|
||
ParameterType.PARAMETER_INTEGER # int
|
||
ParameterType.PARAMETER_DOUBLE # float
|
||
ParameterType.PARAMETER_STRING # str
|
||
ParameterType.PARAMETER_BYTE_ARRAY # bytes
|
||
ParameterType.PARAMETER_BOOL_ARRAY # List[bool]
|
||
ParameterType.PARAMETER_INTEGER_ARRAY # List[int]
|
||
ParameterType.PARAMETER_DOUBLE_ARRAY # List[float]
|
||
ParameterType.PARAMETER_STRING_ARRAY # List[str]
|
||
```
|
||
|
||
### 与 Topic / Service 的区别
|
||
|
||
| 维度 | Parameter | Topic | Service |
|
||
|---|---|---|---|
|
||
| **用途** | 配置 | 流式数据 | 请求-响应 |
|
||
| **频率** | 低(偶尔改) | 高(传感器 ~100Hz) | 单次 |
|
||
| **持久化** | 可选 | 否 | 否 |
|
||
| **回调** | on_set_parameters | on_message | on_request |
|
||
|
||
---
|
||
|
||
## 2. 为什么需要参数 (Why)
|
||
|
||
### 2.1 没有参数会怎样
|
||
|
||
假设你写了一个相机驱动节点,分辨率硬编码为 `640×480`。换相机后想用 `1920×1080`,只能改源码重编译。
|
||
|
||
### 2.2 有参数的好处
|
||
|
||
```python
|
||
class CameraDriver(Node):
|
||
def __init__(self):
|
||
super().__init__('camera_driver')
|
||
self.declare_parameter('width', 640)
|
||
self.declare_parameter('height', 480)
|
||
self.declare_parameter('frame_rate', 30)
|
||
# ...
|
||
```
|
||
|
||
换相机 / 调分辨率,不用改代码,只要改 launch 文件 / YAML / CLI:
|
||
|
||
```bash
|
||
ros2 param set /camera_driver width 1920
|
||
```
|
||
|
||
### 2.3 ROS2 参数的设计目标
|
||
|
||
引用 [ROS2 Design: Parameter](https://design.ros2.org/articles/ros_parameters.html):
|
||
|
||
> "Parameters are intended to be a way to configure nodes at startup or during runtime, without changing code."
|
||
|
||
关键点:
|
||
|
||
1. **无代码修改** — 配置与代码解耦
|
||
2. **支持运行时修改** — 不重启节点也能改
|
||
3. **类型安全** — 7 种类型 + 校验
|
||
4. **可回调** — 节点能响应参数变化
|
||
5. **可序列化** — YAML / 命令行
|
||
|
||
---
|
||
|
||
## 3. 设计原理 (Design)
|
||
|
||
### 3.1 架构图
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ Parameter Server │ ← 全局服务 /set_parameters, /list_parameters, /describe_parameters, /get_parameters
|
||
│ (每个进程内置,无独立进程) │
|
||
└──────────────────────────────────────┘
|
||
▲ ▲
|
||
│ set_parameters │ get_parameters
|
||
│ │
|
||
┌──────┴──────┐ ┌──────┴──────┐
|
||
│ Node A │ │ Node B │
|
||
│ params: │ │ params: │
|
||
│ - rate=10 │ │ - topic= │
|
||
│ - topic=X │ │ - format= │
|
||
└─────────────┘ └─────────────┘
|
||
```
|
||
|
||
注意:**Parameter Server 不是独立进程**,它运行在每个节点进程内的 `rcl` 层(具体是 `rcl_params`)。每个节点都有自己的参数副本,通过 DDS 同步。
|
||
|
||
### 3.2 关键概念
|
||
|
||
#### a) Declare vs Use
|
||
|
||
```python
|
||
# 第一步:声明(必须)
|
||
self.declare_parameter('rate', 1.0)
|
||
|
||
# 第二步:使用
|
||
rate = self.get_parameter('rate').value
|
||
```
|
||
|
||
不声明就 `get_parameter` → 抛 `ParameterNotDeclaredException`。
|
||
|
||
#### b) On-Set Callback
|
||
|
||
```python
|
||
self.add_on_set_parameters_callback(self._on_change)
|
||
```
|
||
|
||
回调签名:`Callable[[List[Parameter]], SetParametersResult]`。
|
||
|
||
返回 `SetParametersResult(successful=True)` → 接受;返回 `(False, reason)` → 拒绝。
|
||
|
||
**注意**: 回调是**同步阻塞**的,执行慢的回调会卡住参数设置。
|
||
|
||
#### c) Parameter Override
|
||
|
||
启动顺序(优先级从高到低):
|
||
|
||
```
|
||
1. CLI: --params-file /path/to/file.yaml
|
||
2. CLI: -p param_name:=value
|
||
3. Launch: Node(parameters=[yaml_file])
|
||
4. YAML 文件: config/params.yaml
|
||
5. 代码: declare_parameter('name', default_value)
|
||
```
|
||
|
||
最右的默认值优先级最低,CLI / Launch 覆盖它。
|
||
|
||
#### d) 持久化参数 (YAML I/O)
|
||
|
||
```bash
|
||
# 保存当前参数
|
||
ros2 param dump /node_name > saved.yaml
|
||
|
||
# 恢复
|
||
ros2 param load /node_name saved.yaml
|
||
```
|
||
|
||
格式:
|
||
|
||
```yaml
|
||
/node_name:
|
||
ros__parameters:
|
||
rate: 5.0
|
||
topic: "/chatter"
|
||
```
|
||
|
||
---
|
||
|
||
## 4. API 全解 (API)
|
||
|
||
### 4.1 rclpy API
|
||
|
||
#### 声明
|
||
|
||
```python
|
||
declare_parameter(
|
||
name: str,
|
||
value: Any = None, # 推断类型
|
||
descriptor: str = '',
|
||
ignore_override: bool = False,
|
||
) -> Parameter
|
||
```
|
||
|
||
#### 读取
|
||
|
||
```python
|
||
get_parameter(name: str) -> Parameter
|
||
# or
|
||
get_parameters(names: List[str]) -> List[Parameter]
|
||
```
|
||
|
||
`Parameter` 对象的属性:
|
||
|
||
- `.name`: 参数名
|
||
- `.value`: 参数值(类型推断)
|
||
- `.type`: ParameterType 枚举
|
||
- `.descriptor`: 描述符
|
||
|
||
#### 设置
|
||
|
||
```python
|
||
set_parameters(parameters: List[Parameter]) -> List[SetParametersResult]
|
||
```
|
||
|
||
或原子性:
|
||
|
||
```python
|
||
set_parameters_atomically(parameters: List[Parameter]) -> SetParametersResult
|
||
```
|
||
|
||
#### 回调
|
||
|
||
```python
|
||
add_on_set_parameters_callback(
|
||
callback: Callable[[List[Parameter]], SetParametersResult],
|
||
prepend: bool = False,
|
||
) -> None
|
||
|
||
remove_on_set_parameters_callback(callback) -> None
|
||
```
|
||
|
||
#### 列出 / 描述
|
||
|
||
```python
|
||
list_parameters() -> List[str]
|
||
describe_parameters(names: List[str]) -> List[ParameterDescriptor]
|
||
```
|
||
|
||
### 4.2 CLI 工具
|
||
|
||
```bash
|
||
# 列出某节点所有参数
|
||
ros2 param list /node_name
|
||
|
||
# 读参数
|
||
ros2 param get /node_name param_name
|
||
|
||
# 设参数
|
||
ros2 param set /node_name param_name value
|
||
|
||
# 导出参数
|
||
ros2 param dump /node_name
|
||
|
||
# 加载参数
|
||
ros2 param load /node_name saved.yaml
|
||
```
|
||
|
||
### 4.3 Launch 文件传参
|
||
|
||
#### 方式 A: 直接传值
|
||
|
||
```python
|
||
Node(
|
||
package='my_pkg',
|
||
executable='my_node',
|
||
parameters=[{
|
||
'rate': 10.0,
|
||
'topic': '/chatter',
|
||
}],
|
||
)
|
||
```
|
||
|
||
#### 方式 B: 加载 YAML
|
||
|
||
```python
|
||
from launch.substitutions import PathJoinSubstitution
|
||
from launch_ros.substitutions import FindPackageShare
|
||
|
||
config = PathJoinSubstitution([
|
||
FindPackageShare('my_pkg'),
|
||
'config',
|
||
'params.yaml',
|
||
])
|
||
|
||
Node(
|
||
package='my_pkg',
|
||
executable='my_node',
|
||
parameters=[config],
|
||
)
|
||
```
|
||
|
||
#### 方式 C: CLI 覆盖
|
||
|
||
```bash
|
||
ros2 run my_pkg my_node --ros-args -p rate:=10.0 -p topic:=/new_topic
|
||
```
|
||
|
||
#### 方式 D: 全局参数
|
||
|
||
```python
|
||
from launch_ros.actions import PushRosNamespace
|
||
|
||
# 启动时把所有参数推到 /my_ns
|
||
PushRosNamespace('my_ns')
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 实战代码 (Code)
|
||
|
||
完整示例见 `src/py_params/`,这里讲关键设计:
|
||
|
||
### 5.1 主节点: `py_params/param_node.py`
|
||
|
||
```python
|
||
class ParamsTalker(Node):
|
||
DEFAULT_RATE_HZ = 1.0
|
||
DEFAULT_TOPIC = 'params_chatter'
|
||
DEFAULT_PREFIX = 'Params:'
|
||
|
||
def __init__(self, *, node_name='params_talker'):
|
||
super().__init__(node_name)
|
||
|
||
# 1. 声明三个参数(类型自动推断)
|
||
self.declare_parameter('publish_rate_hz', self.DEFAULT_RATE_HZ)
|
||
self.declare_parameter('topic_name', self.DEFAULT_TOPIC)
|
||
self.declare_parameter('message_prefix', self.DEFAULT_PREFIX)
|
||
|
||
# 2. 读取参数 + 构造组件
|
||
publish_rate_hz = self.get_parameter('publish_rate_hz').value
|
||
topic_name = self.get_parameter('topic_name').value
|
||
|
||
self.publisher_ = self.create_publisher(String, topic_name, 10)
|
||
|
||
period = 1.0 / publish_rate_hz if publish_rate_hz > 0 else 1.0
|
||
self.timer_ = self.create_timer(period, self._on_timer)
|
||
|
||
# 3. 缓存可变参数 + 注册回调
|
||
self._prefix = self.get_parameter('message_prefix').value
|
||
self.add_on_set_parameters_callback(self._validate_parameter_change)
|
||
```
|
||
|
||
### 5.2 回调: 拒绝非法值
|
||
|
||
```python
|
||
def _validate_parameter_change(self, params):
|
||
for param in params:
|
||
if param.name == 'publish_rate_hz':
|
||
if not isinstance(param.value, (int, float)):
|
||
return SetParametersResult(successful=False,
|
||
reason=f'publish_rate_hz 必须是数字')
|
||
if param.value <= 0.0:
|
||
return SetParametersResult(successful=False,
|
||
reason=f'publish_rate_hz 必须 > 0')
|
||
elif param.name == 'message_prefix':
|
||
if not isinstance(param.value, str):
|
||
return SetParametersResult(successful=False,
|
||
reason='message_prefix 必须是字符串')
|
||
self._prefix = param.value
|
||
return SetParametersResult(successful=True)
|
||
```
|
||
|
||
### 5.3 YAML 配置: `config/params.yaml`
|
||
|
||
```yaml
|
||
params_talker:
|
||
ros__parameters:
|
||
publish_rate_hz: 2.0
|
||
topic_name: "params_chatter"
|
||
message_prefix: "Configured:"
|
||
```
|
||
|
||
### 5.4 Launch 文件
|
||
|
||
```python
|
||
from launch import LaunchDescription
|
||
from launch_ros.actions import Node
|
||
from launch.substitutions import PathJoinSubstitution
|
||
from launch_ros.substitutions import FindPackageShare
|
||
|
||
def generate_launch_description():
|
||
cfg = PathJoinSubstitution([
|
||
FindPackageShare('py_params'),
|
||
'config',
|
||
'params.yaml',
|
||
])
|
||
return LaunchDescription([
|
||
Node(
|
||
package='py_params',
|
||
executable='params_talker',
|
||
parameters=[cfg],
|
||
output='screen',
|
||
),
|
||
])
|
||
```
|
||
|
||
> 注:本仓库 launch 文件名是 `params_launch.py`,命令:`ros2 launch py_params params_launch.py`。
|
||
|
||
---
|
||
|
||
## 6. 测试策略 (Test)
|
||
|
||
### 6.1 单元测试: 声明默认值
|
||
|
||
```python
|
||
def test_param_declaration():
|
||
rclpy.init()
|
||
node = ParamsTalker()
|
||
assert node.get_parameter('publish_rate_hz').value == 1.0
|
||
assert node.get_parameter('topic_name').value == 'params_chatter'
|
||
assert node.get_parameter('message_prefix').value == 'Params:'
|
||
```
|
||
|
||
### 6.2 单元测试: 合法 set
|
||
|
||
```python
|
||
new_param = Parameter(
|
||
name='publish_rate_hz',
|
||
value=ParameterValue(type=ParameterType.PARAMETER_DOUBLE, double_value=5.0),
|
||
)
|
||
result = node.set_parameters([new_param])
|
||
assert result[0].successful is True
|
||
```
|
||
|
||
### 6.3 单元测试: 非法值被拒绝
|
||
|
||
```python
|
||
bad = Parameter(
|
||
name='publish_rate_hz',
|
||
value=ParameterValue(type=ParameterType.PARAMETER_DOUBLE, double_value=-1.0),
|
||
)
|
||
result = node.set_parameters([bad])
|
||
assert result[0].successful is False
|
||
assert 'publish_rate_hz 必须 > 0' in result[0].reason
|
||
```
|
||
|
||
### 6.4 YAML 集成测试
|
||
|
||
```python
|
||
def test_yaml_loadable():
|
||
with open('config/params.yaml') as f:
|
||
cfg = yaml.safe_load(f)
|
||
assert cfg['params_talker']['ros__parameters']['publish_rate_hz'] == 2.0
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 进阶玩法 (Advanced)
|
||
|
||
### 7.1 动态重配置 (Dynamic Reconfigure)
|
||
|
||
ROS1 时代的 dynamic_reconfigure,在 ROS2 里被参数 + 回调取代。
|
||
|
||
例: 实时调 PID 参数:
|
||
|
||
```python
|
||
def _on_change(self, params):
|
||
for p in params:
|
||
if p.name == 'kp':
|
||
self._pid.set_kp(p.value)
|
||
elif p.name == 'kd':
|
||
self._pid.set_kd(p.value)
|
||
return SetParametersResult(successful=True)
|
||
```
|
||
|
||
外部通过 `ros2 param set` 实时调:
|
||
|
||
```bash
|
||
ros2 param set /controller kp 0.5
|
||
```
|
||
|
||
### 7.2 参数回调链 (Callback Chain)
|
||
|
||
多个回调按顺序执行,任何一个返回 False 整链失败:
|
||
|
||
```python
|
||
node.add_on_set_parameters_callback(cb_validate) # 校验
|
||
node.add_on_set_parameters_callback(cb_propagate) # 传给内部子系统
|
||
```
|
||
|
||
### 7.3 全局参数 (Global Parameter)
|
||
|
||
通过 `PushRosNamespace` 把所有参数推到命名空间:
|
||
|
||
```python
|
||
from launch_ros.actions import PushRosNamespace
|
||
|
||
LaunchDescription([
|
||
PushRosNamespace('robot1'),
|
||
Node(package='cam', executable='driver'),
|
||
])
|
||
# 启动后参数命名:/robot1/cam/driver/...
|
||
```
|
||
|
||
### 7.4 参数文件覆盖
|
||
|
||
启动顺序优先级(从高到低):
|
||
|
||
```
|
||
1. --params-file (CLI)
|
||
2. -p name:=value (CLI)
|
||
3. Launch(parameters=...)
|
||
4. YAML 文件
|
||
5. declare_parameter 默认值
|
||
```
|
||
|
||
多个 YAML 文件,后面的覆盖前面的(数组传参)。
|
||
|
||
---
|
||
|
||
## 8. 故障排查 (Pitfalls)
|
||
|
||
### 8.1 `ParameterNotDeclaredException`
|
||
|
||
**症状**: `get_parameter` 抛异常。
|
||
|
||
**原因**: 没 `declare_parameter` 就 `get_parameter`。
|
||
|
||
**解决**:
|
||
|
||
```python
|
||
# 错误
|
||
def __init__(self):
|
||
rate = self.get_parameter('rate').value # 💥
|
||
|
||
# 正确
|
||
def __init__(self):
|
||
self.declare_parameter('rate', 1.0) # 先声明
|
||
rate = self.get_parameter('rate').value # 再 get
|
||
```
|
||
|
||
### 8.2 回调未触发
|
||
|
||
**症状**: `ros2 param set` 后回调没反应。
|
||
|
||
**原因**: 多个回调,前一个返回 False 短路了。
|
||
|
||
**解决**: 检查每个回调的返回值,所有都要 `successful=True`。
|
||
|
||
### 8.3 YAML 没加载
|
||
|
||
**症状**: 启动后参数是默认值,不是 YAML 里的值。
|
||
|
||
**原因**: YAML 路径错 / 节点名不匹配。
|
||
|
||
**解决**:
|
||
|
||
```bash
|
||
# 检查 YAML 是否被安装到 share/
|
||
ros2 pkg prefix py_params
|
||
# 查看 install/py_params/share/py_params/config/params.yaml
|
||
|
||
# 启动时打印实际加载的参数
|
||
ros2 param list /params_talker # 看实际值
|
||
```
|
||
|
||
### 8.4 浮点精度
|
||
|
||
**症状**: `ros2 param set rate 0.1` 后实际值是 `0.10000000149...`。
|
||
|
||
**原因**: IEEE 754 浮点表示。
|
||
|
||
**解决**: 容忍误差,或在回调里 `round(p.value, 3)`。
|
||
|
||
### 8.5 数组参数类型不匹配
|
||
|
||
**症状**: `get_parameter` 抛 `ParameterTypeMismatchException`。
|
||
|
||
**原因**: YAML 写 `'topic'`(字符串)但代码期望 `topic_name` 是字符串数组。
|
||
|
||
**解决**: 检查 YAML 缩进和 `[]` / 引号:
|
||
|
||
```yaml
|
||
# 字符串数组
|
||
names: ["a", "b", "c"]
|
||
# 或
|
||
names: ['a', 'b', 'c']
|
||
```
|
||
|
||
### 8.6 Callback 阻塞导致 hang
|
||
|
||
**症状**: `ros2 param set` 卡死。
|
||
|
||
**原因**: 回调里有阻塞 I/O(网络 / 大文件)。
|
||
|
||
**解决**: 把阻塞操作放后台线程,回调只做校验。
|
||
|
||
### 8.7 Atomically vs 普通 set
|
||
|
||
**症状**: 多参数同时设置,部分生效部分失败。
|
||
|
||
**解决**: 用 `set_parameters_atomically`,要么全成功要么全失败。
|
||
|
||
```python
|
||
result = node.set_parameters_atomically([
|
||
Parameter(name='kp', value=...),
|
||
Parameter(name='kd', value=...),
|
||
])
|
||
if not result.successful:
|
||
self.get_logger().error(f'set failed: {result.reason}')
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 推荐阅读 (Further)
|
||
|
||
### 官方文档
|
||
|
||
- [ROS2 Parameter Design](https://design.ros2.org/articles/ros_parameters.html) — 设计稿,必读
|
||
- [ROS2 Humble Parameter Tutorial](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Parameters/Understanding-ROS2-Parameters.html)
|
||
- [ROS2 CLI: ros2 param](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Using-ROS2-CLI-Tools.html)
|
||
- [rclpy: Parameter class](https://docs.ros2.org/en/latest/rclpy_api/rclpy.parameter.html)
|
||
- [rcl_interfaces.msg](https://github.com/ros2/rcl_interfaces) — Parameter / ParameterValue / SetParametersResult 消息定义
|
||
|
||
### 相关 RFC / 设计稿
|
||
|
||
- [ROS2 Design: Parameter Validation](https://design.ros2.org/articles/ros_parameters.html#parameter-validation)
|
||
- [ROS2 Design: On-Set Parameters Callback](https://design.ros2.org/articles/ros_parameters.html#on-set-parameters-callback)
|
||
|
||
### 进阶话题
|
||
|
||
- **与 ros2_control 集成**: hardware_interface 启动时从 YAML 加载控制器参数
|
||
- **与 MoveIt2 集成**: PlanningScene 用参数配置避障
|
||
- **与 Nav2 集成**: 行为树参数、Costmap 参数全部走 ROS2 参数
|
||
|
||
### 实战例子
|
||
|
||
- [ros2/demos: topic_monitor](https://github.com/ros2/demos/blob/humble/demo_nodes_py/demo_nodes_py/topics/topic_monitor.py)
|
||
- [turtlebot3: 参数化](https://github.com/ROBOTIS-GIT/turtlebot3/blob/humble/turtlebot3_node/src/turtlebot3_node.cpp)
|
||
|
||
---
|
||
|
||
## 一句话总结
|
||
|
||
> **ROS2 参数 = 节点的"配置项",从 launch / YAML / CLI 传入,运行时可改,回调里能拒绝非法值。设计目标是"配置与代码解耦"。**
|
||
|
||
下一节: `doc/16-custom-interfaces.md` 学习自定义 .msg / .srv / .action。
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 📖 阅读路径导航
|
||
|
||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||
>
|
||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||
> 📍 **当前位置**: 第 6 / 24 篇
|
||
|
||
- ⏮ **上一篇**: [Node / Topic / Service / Action / TF / Time](10-concepts.md)
|
||
- ⏭ **下一篇**: [自定义 .msg/.srv/.action](16-custom-interfaces.md)
|