机器人是如何动起来的:从舵机转动到Action Chunk
简介
如果机器人是一台运行 Linux、连接执行器和传感器的计算机”,下一个问题是:这台计算机上的软件应该怎样拆分、通信和管理故障。
Linux 通过串口驱动和设备接口提供串口配置与字节收发能力;用户态协议库在这些能力之上,实现舵机寻址、寄存器读写和控制命令。
- 板载计算机通过 UART、I²C 等接口连接舵机、IMU、传感器;一条总线上可以挂多个设备
- 内核驱动管理通信硬件,用户态程序通过 OS 接口收发数据;设备协议由用户态代码继续解释 OS 不需要理解“站起来”或“肩关节转到 0.9 rad”,这些语义由上层控制代码、协议库和舵机固件共同实现。
using serial ports in Linux
Everything Is A File: In typical UNIX style, serial ports are represented by files within the operating system. These files usually pop-up in /dev/, and begin with the name tty*. 比如 /dev/ttyUSB0 - 大多数 USB 转串口线都会以这样的文件名显示。
要写入串行端口,需对该文件执行写操作。要读取串行端口,需对该文件执行读操作。 但如何设置波特率、校验位等串行端口参数呢?这需要通过一个特殊的 tty 配置 struct 来完成。
网络:应用协议库 → socket 接口 → Linux 网络栈与驱动
串口:设备协议库 → 串口接口 → Linux 串口驱动
如何写程序让舵机动起来
网络:biz程序 → redis-py → socket → Linux TCP/IP 协议栈与网卡驱动
↓
Redis 服务端
串口:biz程序 → dynamixel_sdk → pyserial → Linux 串口接口与驱动
↓
舵机固件
从 “应用 → 用户态库 → OS 接口与驱动 → 硬件” 分层来看
| 层次 | 连接 Redis | 控制 Dynamixel 舵机 | 读取 IMU(I²C) | 读取 USB 摄像头 |
|---|---|---|---|---|
| 应用 | 业务程序:读写 key/value | 控制程序:设置目标位置 | 感知程序:读取加速度、角速度 | 视觉程序:获取图像帧 |
| 用户态库 | redis-py |
dynamixel_sdk → pyserial |
芯片专用库 → smbus2 |
OpenCV(使用 V4L2 后端) |
| OS 接口 | socket:connect/send/recv |
串口设备:read/write、termios 配置 |
/dev/i2c-*:ioctl 等 |
V4L2:ioctl、mmap 等 |
| 内核子系统与驱动 | TCP/IP 协议栈 → 网卡驱动 | TTY/串口驱动,或 USB 转串口驱动 | I²C 子系统 → I²C 控制器驱动 | V4L2/UVC 驱动 → USB 子系统与控制器驱动 |
| 本机通信硬件 | 网卡 | UART 控制器,或 USB 控制器 + USB 转串口适配器 | I²C 控制器 | USB 主控制器 |
| 链路另一端 | 网络 → Redis 所在服务器 | 串口总线 → 舵机 | I²C 总线 → IMU 芯片 | USB 线 → 摄像头 |
以 Dynamixel 舵机为例,让 1 号舵机在当前位置基础上再转动 10°。假设舵机已经启用位置控制和扭矩,目标在允许范围内。
# 第三方通用串口库:pip install pyserial,导入名是 serial
import serial
# 厂商设备协议库:封装 Dynamixel 指令和响应
from dynamixel_sdk import PortHandler, PacketHandler
# 用户态 SDK:内部通过 pyserial 调用 Linux 串口接口。
port = PortHandler("/dev/ttyUSB0")
port.openPort()
port.setBaudRate(57600) # 与设备配置一致
# 用户态 SDK:负责 Dynamixel Protocol 2.0 的编码与解析。
protocol = PacketHandler(2.0)
# 以下寄存器地址、位置分辨率以 XL430-W250 为例。
SERVO_ID = 1
PRESENT_POSITION = 132
GOAL_POSITION = 116
# SDK 发送读取请求,再解析舵机返回的当前位置。
current, comm_result, device_error = protocol.read4ByteTxRx(
port, SERVO_ID, PRESENT_POSITION
)
# 应用逻辑:把“再转 10°”转换为绝对目标位置。
# 一圈对应 4096 个位置计数。
target = current + round(10 / 360 * 4096)
# SDK 编码写入指令,经串口发送给 1 号舵机。
comm_result, device_error = protocol.write4ByteTxRx(
port, SERVO_ID, GOAL_POSITION, target
)
port.closePort()
robotd:持续运行的身体控制进程
Linux 提供串口、网络、视频采集等设备访问能力,用户态协议库进一步封装具体设备。但要让多个关节配合运动,还需要软件管理身体状态、控制指令、执行时序和异常处理。这套软件栈在 robot application 与linux 之间,ROS 2 生态和 Microduck 的多 daemon 方案,以不同方式组织这些能力。robotd 是 Microduck 为这些工作实现的常驻身体控制服务。
| 软件职责 | Microduck 的做法 | ROS 2 系统中的常见做法 |
|---|---|---|
| 功能拆分 | robotd、mediad、tofd 等独立进程 |
拆成多个 Node,部署到一个或多个进程 |
| 指令与查询 | Unix socket + JSON-RPC | Service、Topic,长任务可用 Action |
| 状态与传感器数据 | 各服务提供订阅流或专门传输路径 | Topic,以及按需要设计的数据传输路径 |
| 身体控制 | robotd 的控制循环和硬件接口 |
控制 Node,可结合 ros2_control |
| 启动与恢复 | systemd 管理进程 | ROS 2 launch 组织启动,也可以结合 systemd |
能干啥
如同 MySQL 在 Linux 的文件与存储能力之上构建数据库服务,robotd 在设备通信能力之上构建身体控制服务。前者让应用通过 SQL 操作数据,后者让应用通过运动指令控制身体,而不必自行处理每个舵机的通信和执行时序。
业务应用
↓ SQL
MySQL Server 进程
├─ 连接管理、SQL 解析、优化与执行
└─ InnoDB:索引、事务、缓存、数据页与日志读写
↓
Linux 文件接口 → 文件系统、块设备层与存储驱动
↓
存储控制器 → SSD / 磁盘
控制应用
↓ Command / Action Chunk
robotd 进程
├─ 请求处理、控制权、状态管理
└─ 控制循环:读取反馈、计算/选择目标、检查与下发
↓
硬件访问与设备协议库
↓
Linux 串口接口与驱动
↓
通信控制器 → 多个舵机
把 robotd 理解成一个长期运行的后端服务比较容易(类似mysqld):它监听 Unix domain socket,接受 JSON-RPC 请求,即IPC(Inter-Process Communication)通信。假设1号舵机管的是鸭子头部的转动,若初始角度为 0°,让鸭子的头部偏航角设为 10° 的伪代码如下(没有现成的、类似PyMySQL 的robotclient)
import json
import math
import socket
# 假设 robotd 已启动,机器人已初始化且头部控制可用。
request = {
"jsonrpc": "2.0",
"method": "robot.head",
"params": {
"head_yaw": math.radians(10),
"head_pitch": 0.0,
"head_roll": 0.0,
"neck_pitch": 0.0,
},
}
# AF_UNIX:通过本机 Unix socket 连接 robotd。
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.connect("/run/robotd.sock")
# robotd 使用换行分隔 JSON 消息。
message = json.dumps(request) + "\n"
client.sendall(message.encode("utf-8"))
| 数据库 | 机器人 |
|---|---|
| PyMySQL 等客户端库 | 假设封装的 RobotClient |
| MySQL 协议 | JSON-RPC |
| TCP 或 Unix socket | Unix socket |
mysqld 服务进程 |
robotd 服务进程 |
| SQL 请求 | robot.head 等控制请求 |
如何实现
robotd在独立控制线程中,持续运行一个目标频率为 50 Hz 的循环,消费客户端提供的指令状态或动作序列(即使没有新指令,也可能需要继续读取反馈、维持姿态并处理异常)。50 Hz 即大约每 20 ms 一轮。这是调度目标,包含本轮计算和 I/O,并非每次处理完后额外休眠 20 ms,也不是普通 Linux 上的硬实时保证。
下面用 Python 风格伪代码展示正常运行路径,假设身体已初始化、运动控制已启用。
# robotd 内部,接口名称为示意。
controller = MotionController()
safety = Safety(RobotIo.open())
timer = PeriodicTimer(hz=50)
while True:
timer.wait_next_tick()
# 1. 读取实际身体状态
# 包括各关节位置、速度,以及 IMU 提供的姿态等反馈。
sensors = safety.read_body()
# 2. 取得当前指令
# 本例中包含 head_yaw = 0.1745 rad,即头部目标为 10°。
command = intents.snapshot()
# 3. 计算本周期各关节的目标
# 原有控制路径中,运动 Policy 根据指令和身体状态进行推理,
# 控制代码再将模型输出转换为关节目标角度。
targets = controller.step(sensors, command)
# 4. 检查并下发目标
# Safety 检查数值及执行器范围,
# 经 RobotIo 完成设备映射、单位转换和协议发送。
safety.apply(targets)
# 5. 更新供外部读取的状态
publish_state(
joints=sensors.positions, # 本轮写入之前的实际位置
targets=targets, # 本轮刚下发的目标
)
Action Chunk 是一段有时间含义的目标序列
假设用户说“把前面的球往前推”。机器人需要接近球、调整身体与头部姿态,再向前施力。模型看到当前画面和身体状态后,一次预测接下来一小段时间内各关节的动作,这就是 Action Chunk。执行期间,球可能滚动、接触位置可能偏离预期。机器人取得新观测后,再生成下一段动作,修正后续运动。
任务层面的描述:
接近球 → 调整姿态 → 接触球 → 推球
某次模型推理输出的 Action Chunk:
时间步 腿部各关节目标 颈部与头部关节目标……
t 一组角度 一组角度
t + Δt 一组角度 一组角度
t + 2Δt 一组角度 一组角度
……
为什么一次预测多个动作:腿部、身体和头部需要在时间上配合。
- 抓取、迈步、绕障碍都需要前后动作配合。预测一段动作,更容易表达持续的运动趋势,减少逐步独立预测带来的犹豫或抖动。
- 降低模型推理成本/延迟。假设机器人每 20 ms 需要一个目标,而模型推理一次需要 100 ms,就无法每轮都等它生成新动作。一次生成多个目标后,执行端可以按控制频率逐个使用;推理端同时准备下一段。异步推理。
- 利用示范数据中的连续动作片段。人类演示本来就是连续运动。预测一段动作,可以直接学习“观察到这种情况后,接下来怎样完成这一小段行为”。 但预测得越远,越可能因为环境变化而过时。 因此常见做法是“预测较长一段,只执行前面几步,再用新观测重新预测”,即 receding-horizon execution(滚动时域执行)。
留下评论