上一节:第 2 章 成果展示:这台4500元的机器人到底能做什么(二)极低成本 · 实物上手 · 可迁移至高端人形平台
第一部分:缘起与蓝图

第 3 章 整体架构设计:三端协同,ROS2具身智能 极低成本 · 实物上手 · 可迁移至高端人形平台

3.1 先看全景:一张图看懂三端架构

推机器人一下,它晃了晃站稳了。这个过程里IMU在10ms内检测倾斜,ZMP控制器在10ms内算出补偿,舮机在10ms内执行——这些必须在本地完成,走网络就来不及了。同时PC监控系统在0.5秒后看到倾斜曲线,Unity虚拟角色也可能做了个表情。这就是三端架构的本质:硬实时在机器人端,软实时在PC端,渲染在Unity端。
最早我把所有逻辑——AI推理、平衡控制、舮机驱动——全塞进ARM嵌入式主板,结果AI推理跑在ARM CPU上一句话要等十几秒,同时100Hz控制循环也因CPU被占满而丢帧。反过来把控制放PC端,WiFi卡一下延迟了200ms,机器人直接倒了。硬实时控制不能走网络——这是铁律,不是设计选择。
三端架构的核心思想是“分层计算”:将不同实时性要求的任务分配到合适的硬件平台上。机器人端(嵌入式ARM主板)负责100Hz实时控制,这是最低层的「小脑」,只做一件事且必须快。PC/手机端负责监控、调度和AI推理,这是「大脑皮层」,做所有需要CPU/GPU算力的事情。Unity端(Windows桌面应用)承担渲染和交互,是「表现层」,展示给人看的东西。三端之间通过多种通信协议互联,形成完整的人形机器人控制体系。
在这里插入图片描述

3.2 机器人端:100Hz的实时控制核心

机器人端是「小脑」——不思考、不决策,只做一件事:每10ms计算一次姿态补偿,驱动21个舮机执行。

3.2.1 ROS2 Workspace结构——四个包,各管一块

机器人端代码组织在ROS2 workspace中,四个核心包互不耦合:
zmp_walking: ZMP行走+平衡控制器,整个项目最核心的代码。拆成12个mixin模块(站立姿势、平衡PD、ZMP计算、跨步恢复、抬脚后跟、重心转移、自动调参、参数动态加载、数据采集、安全保护、诊断日志、主循环),每个模块一个文件,责任清晰。不拆的话,每次改一个PD参数都要在一万多行文件里找,改完不知道影响了哪些功能。
sensor_fusion: 传感器融合。IMU传感器模块内置MCU传感器融合,通过I2C直接输出已融合的姿态数据(欧拉角+角速度+四元数)。驱动节点在_map_to_standard()中完成轴映射,发布到/imu/euler和/imu/data。没有它,控制器就是瞎子。
foot_pressure_sensor: 足底压力传感器。读取两个Arduino Nano(每脚一个,各接4个HX711+5kg压力传感器),计算CoP和ZMP,以75Hz发布。有了ZMP前馈,就像睬开了眼睛,能提前预判重心偏移。
lebao_description: 机器人URDF模型描述,用于仿真和动作编辑器骨骼参考。
以下是ZMP控制器的核心订阅/发布模式,展示了ROS2节点间的典型通信方式:

# 订阅 IMU 数据和速度指令
imu_qos = QoSProfile(depth=5, reliability=ReliabilityPolicy.BEST_EFFORT)
self._imu_euler_sub = self.create_subscription(
    Float32MultiArray, '/imu/euler',
    self._imu_euler_callback, qos_profile=imu_qos)
self._cmd_vel_sub = self.create_subscription(
    Twist, '/cmd_vel', self._cmd_vel_callback, 10)

# 订阅足底压力 ZMP
self._zmp_sub = self.create_subscription(
    PointStamped, '/foot_pressure/zmp',
    self._zmp_callback, 10)

# 发布关节角度指令 (21个元素)
self._joint_pub = self.create_publisher(
    Float32MultiArray, '/robot_joint_angles', 10)

# 控制循环中发布
msg = Float32MultiArray()
msg.data = joint_angles  # 21个角度值 (弧度)
self._joint_pub.publish(msg)

这个模式的关键在于:QoS配置。IMU数据使用BEST_EFFORT(允许丢帧,但保证低延迟),而控制指令使用RELIABLE(确保不丢帧)。如果不分清这两种QoS场景,不是IMU数据卡顿就是控制指令丢失。
在实际部署时,所有节点通过ROS2 Launch文件统一启动。以下是一个典型的Launch文件,展示了节点间的依赖关系和参数注入:


```python
#!/usr/bin/env python3
"""
lebao_bringup.launch.py - 机器人启动入口
启动顺序: 传感器 -> 舮机桥 -> 控制器
"""
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import TimerAction, LogInfo

def generate_launch_description():
    return LaunchDescription([
        LogInfo(msg=['LeBao Bringup - Starting...']),
        # 第1步: IMU传感器驱动
        Node(
            package='sensor_fusion',
            executable='ybimu_driver_node',
            name='ybimu_driver',
            output='screen',
            parameters=[{'i2c_address': 0x23}]
        ),
        # 第2步: 舮机控制桥接 (延迟2秒确保串口就绪)
        TimerAction(
            period=2.0,
            actions=[
                Node(
                    package='zmp_walking',
                    executable='buslinker_controller',
                    name='bus_servo_controller',
                    output='screen',
                    parameters=[{'servo_count': 21}]
                ),
            ]
        ),
        # 第3步: ZMP控制器 (延迟4秒,等舮机桥就绪)
        TimerAction(
            period=4.0,
            actions=[
                Node(
                    package='zmp_walking',
                    executable='zmp_walking_controller',
                    name='zmp_walking_controller',
                    output='screen',
                    parameters=[
                        {'control_frequency': 50.0},
                        {'com_height': 0.38},
                    ]
                ),
            ]
        ),
    ])

这个Launch文件展示了一个重要的设计原则:TimerAction实现节点启动顺序。为什么不用ROS2内置的生命周期管理?因为它只能管“节点启动了”,不能管“节点真的就绪了”——而舮机控制桥需要串口打开并初始化后才能接受指令,这个过程可能需要一两秒。用简单的延时启动比用复杂的生命周期状态机更靠谱。

3.2.2 数据流全景 从IMU到舮机指令的10ms旅程

用一个完整的10ms周期带你走一遍数据流:

  1. IMU传感器模块通过I2C读取姿态数据(内置MCU融合),驱动节点调用_map_to_standard()完成轴映射,发布到/imu/euler。
  2. 两个Arduino Nano通过串口(115200bps)发送8个压力传感器原始值,足底压力服务计算CoP和ZMP,发布到/foot_pressure/zmp。
  3. ZMP控制器(100Hz主循环)收到数据后,执行容错逻辑:
    a. ZMP新鲜度检查:超过0.1秒未更新就降级为纯IMU模式。这是每次机器人莫名其妙摔倒后,查日志发现“传感器数据断了”然后加上的。
    b. 双足无力检测:总压力<200g就跳过补偿。机器人被拿起来了,还在那里调舮机要站稳——不仅没用,还可能导致舮机过热。
    c. 互补滤波:ZMP提供P项(位置误差→踝关节角度补偿),IMU提供D项(角速度→阻尼),两者叠加才能又快又稳。
    d. 站立姿势 + 平衡补偿 + 腰部后仰 → 21个关节目标角度。
  4. 发布/robot_joint_angles(Float32MultiArray,21个元素)。
  5. 舮机控制板收到后,通过正反字典处理硬件方向,转换为位置值(01000对应0°240°),通过串口逐个发送给21个串口总线舮机。
    从IMU读数到舮机执行,全链路延迟不到15ms。100Hz是反复验证过的最佳频率:低于此值响应有可感知延迟,高于此值串口带宽成为瓶颈。容错机制在串口传感器丢数据时保护机器人——这些不是设计出来的,是每次摔倒后一条一条加上的。
    足底压力传感
# 足底压力传感器数据处理流程
# 每只脚4个HX711传感器,通过Arduino Nano采集

def _parse_pressure_data(self, raw_line):
    """解析串口数据: s0,s1,s2,s3,heartbeat,checksum"""
    parts = raw_line.strip().split(',')
    if len(parts) < 6:
        return None
    # 校验和验证
    raw = [int(p) for p in parts[:4]]
    if sum(raw) % 256 != int(parts[5]):
        return None  # 校验失败,丢弃
    return raw  # [s0, s1, s2, s3] 原始ADC值

# 计算CoP (Center of Pressure)
# 传感器位置: s0=左前, s1=右前, s2=左后, s3=右后
def _compute_cop(self, raw, foot_size):
    total = sum(raw)
    if total < 50:  # 过滤噪声
        return None
    # 加权平均计算压力中心
    cop_x = ((raw[1] + raw[3]) - (raw[0] + raw[2])) / total * foot_size[0]
    cop_y = ((raw[2] + raw[3]) - (raw[0] + raw[1])) / total * foot_size[1]
    return (cop_x, cop_y)

# 双脚合成ZMP
# ZMP_x = (L_x * L_F + R_x * R_F) / (L_F + R_F)
def _compute_zmp(self, left_cop, left_force, right_cop, right_force):
    total = left_force + right_force
    if total < 200:  # 双脚无力
        return None
    zmp_x = (left_cop[0] * left_force + right_cop[0] * right_force) / total
    zmp_y = (left_cop[1] * left_force + right_cop[1] * right_force) / total
    return (zmp_x, zmp_y)

这段代码背后有三个关键设计决策:第一,校验和不是可选项——串口传输中偶尔会丢字节,没有校验和的话,你会拿到一个错误的压力值而不知道它是错的,ZMP计算就会偏离,机器人就会倒。第二,过滤噪声的阈值不是随便设的——总力<50的数据点不参与CoP计算,否则你会拿到一个因除以近乎零的值而产生的巨大跳变。第三,双脚合成ZMP时又有一个更高的阈值(200g)——这是因为机器人被拿起来时不能让ZMP参与平衡补偿。

3.2.3 v13方向处理规则:只在两个边界处理方向

这是整个架构中最容易出错的环节,也是我踩坑最深的地方。先说结论:
方向转换只在两个边界处理:IMU数据入口(驱动节点的_map_to_standard()),舮机指令出口(舮机控制板的正反字典)。所有控制算法(ZMP、抬脚、重心转移、自动调参)直接消费标准坐标系,严禁任何轴交换或符号翻转。
早期版本中方向处理散落在各个模块,每个模块都自己做轴交换或符号翻转。结果就是「双重错误抵消」——A模块的错误和B模块的错误巧合地相互抵消,表面上机器人能站稳。但你只要改了下代码执行的顺序——这个巧合就破了,机器人直接倒。你会以为是你改的代码搞坏了什么,但实际上是两个错误的抵消被打破了。这类bug极难排查——你会在一万多行代码里找一个不存在的bug。
集中到两个边界后,算法层不需要关心方向了。你在算法层写的任何代码都不需要判断“这个关节要不要取反”——方向问题已经在边界处理掉了。如果某个关节运动方向不对,只需改舮机控制板的正反字典,而不是在算法代码里到处找“哪里要乘-1”。代码干净了,bug少了。这套方法论在宇树、傅利叶等高端平台上同样适用。

3.2.4 8个systemd服务的依赖关系

机器人端服务全部通过systemd管理,开机自启。启动顺序:先硬件层(传感器+舮机),再ROS2通信桥,最后应用层(ZMP控制器+音频+命令执行)。systemd提供了服务依赖声明、崩溃自动重启、日志统一管理——这些用脚本启动都是重复造轮子。
服务名 端口 类型 说明
lebao-sensors — 硬件层 IMU传感器模块驱动(I2C),发布/imu/euler和/imu/data
bus-servo-controller — 硬件层 舮机控制板(订阅/robot_joint_angles→串口控制21个串口总线舮机)
lebao-foot-pressure — 硬件层 足底压力传感器(两个Arduino Nano×HX711,发布/foot_pressure/*)
startros 10000 通信层 ROS-TCP-Endpoint(Unity通信桥接,TCP端口10000)
lebao-zmp-walking — 应用层 ZMP行走+平衡控制器(100Hz主循环,核心算法)
robot-audio-bridge 9001 应用层 音频桥接(麦克风采集+音乐播放,WebSocket通信)
robot-command-executor 9002 应用层 命令执行器(语音命令→ROS2 motion,HTTP API)
lebao-status-api 9005 应用层 状态API(HTTP API,供监控系统查询机器人状态)

依赖关系:lebao-sensors和bus-servo-controller是基础层,必须先启动。lebao-foot-pressure依赖串口连接。lebao-zmp-walking依赖传感器和舮机服务。robot-audio-bridge和robot-command-executor依赖ZMP控制器。这些依赖在systemd服务文件中显式声明(After=、Requires=),不是靠“启动顺序碰巧对”的运气。
以下是一个典型的systemd服务文件,展示了依赖声明、自动重启、环境变量等配置:

 
# LeBao ZMP Walking Controller
[Unit]
Description=LeBao ZMP Walking + Balance System
After=network.target lebao-sensors.service lebao-foot-pressure.service
Wants=network.target lebao-sensors.service lebao-foot-pressure.service

[Service]
Type=simple
User=root
WorkingDirectory=/home/lantian/Lebao
Environment=ROS_DISTRO=humble
Environment=ROS_DOMAIN_ID=168
ExecStart=/bin/bash /home/lantian/Lebao/lebao_ws/src/zmp_walking/scripts/start_zmp_direct.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
 

关键配置解读:After=和Wants=声明了传感器服务作为前置依赖,Restart=always确保崩溃后自动重启,RestartSec=5避免崩溃循环。ROS_DOMAIN_ID=168是本项目的独立Ros2域,防止与其他ROS2节点干扰。这些细节在开发阶段不明显,但在多机器人协同或长期运行时至关重要。
最后一环是舮机控制桥接——它订阅ROS2话题,转换为串口指令。这个环节是从“软件世界”到“硬件世界”的最后一关:

```python
# 舮机控制桥接:ROS2话题 -> 串口指令
class ServoControllerNode(Node):
    def __init__(self):
        super().__init__('bus_servo_controller')
        # 订阅关节角度指令
        self._joint_sub = self.create_subscription(
            Float32MultiArray, '/robot_joint_angles',
            self._joint_callback, 10)
        # 用于增量更新的缓存
        self._last_sent_deltas = [0.0] * 21

    def _joint_callback(self, msg):
        """收到21个关节角度(弧度),转换为串口指令"""
        for i, angle_rad in enumerate(msg.data):
            servo_id = i + 2  # 舮机ID从2号开始
            angle_deg = math.degrees(angle_rad)
            # 正反字典处理硬件方向
            if self.reverse.get(servo_id, False):
                angle_deg = -angle_deg
            # 角度 -> 位置值 (0°=0, 240°=1000)
            position = int((angle_deg + 120) / 240 * 1000)
            position = max(83, min(917, position))  # 限位
            # 增量更新: 只发送变化>阈值的
            delta = abs(position - self._last_sent_deltas[i])
            if delta > 0.05:
                self._send_servo_cmd(servo_id, position, 20)
                self._last_sent_deltas[i] = position

这段代码展示了两个重要的架构设计决策:增量更新和正反字典。增量更新不是优化,是必须——串口总线的带宽有限,每帧发送21条指令会导致延迟堆积,控制板看门狗超时就会蜂鸣。正反字典是方向处理的出口边界——算法层不需要知道某个舮机是反装的,一切方向问题都在这里解决。

3.3 PC/手机端:监控、调度、AI的枢纽

PC端是「大脑皮层」——不直接控制舮机,但监控一切、调度一切、做所有需要算力的AI推理。

3.3.1 监控系统:机器人的「仪表盘」

监控系统是一个Python桌面应用,约2800行代码。设计目标:让所有机器人状态一目了然,不用SSH上去敲命令。三个标签页的设计逻辑:
系统状态标签页: 9个服务状态指示灯(绿=运行中,红=停止,黄=异常),系统信息(CPU温度/内存/磁盘),认知系统状态(ASR/LLM/TTS/语音输入/对话状态),ZMP自稳定模块状态。底部有TTS测试框。这是打开监控系统后的第一眼——机器人是死是活,一眼就知道。
关节校准标签页: 21个关节,分5组(左腿/右腿/左臂/右臂/上半身),每个关节有[+]和[-]按钮,步长可选0.1°~25°。右侧绘制机器人正视图和侧视图,实时显示当前角度。有了这个页面,你点点按钮就能看到机器人实时运动,不用在代码里硬编码角度或SSH上去一个个测。
自稳定系统标签页: 7个systemd服务状态,IMU实时数据(加速度/角速度/姿态角带曲线图),足底压力传感器数据(8个传感器+ZMP坐标),平衡状态。每个服务有独立的启动/停止/重启按钮。调试平衡参数时,你推机器人一下,0.5秒内就能看到数据的变化。
通信机制采用双通道策略:优先HTTP API(端口9005),不可用时回退到SSH。双通道保证了API服务崩溃时仍能查看状态。监控系统有两个独立刷新循环:主监控循环每5秒刷新一次服务状态,传感器监控循环每0.5秒刷新一次IMU和足底压力数据。分开刷新避免了无意义的网络请求,同时保证了关键数据的实时性。所有网络操作在独立线程中执行,UI通过queue.Queue实现线程安全更新——不会因网络超时而卡住界面。
在这里插入图片描述

3.3.2 守护进程:语音对话全链路调度

语音守护进程负责启动和监控ASR、LLM、TTS三个子进程,处理进程间通信和异常重启。拆成多个子进程是因为Python的GIL锁——如果所有AI推理跑在一个进程里,ASR识别时会阻塞LLM,LLM推理时会阻塞TTS,整个链路延迟从500ms变成3秒。拆成独立进程后,每个模块独立运行,互不阻塞。

3.3.3 通信协议:为什么不只用一种协议

三端之间的通信用了三种协议,每种解决不同问题:
HTTP API(优先级最高): PC端发送控制命令到机器人端。简单、可靠、无状态。发一个“跳舞”命令,返回200 OK,事情就结束了。失败时自动降级到SSH。
WebSocket: 用于音频流传输。音频数据连续、实时,HTTP轮询浪费带宽,WebSocket双向流式传输是最优解。手机端发音频到机器人端播放,机器人端录音回传手机做ASR,全走WebSocket。
SSH(降级通道): 当HTTP API不可用时,PC端通过SSH直接执行远程命令。这是“救急方案”——服务崩溃、端口冲突、网络抖动时,SSH是你最后的控制通道。没有它,你就得走过去物理重启机器人。
ROS-TCP-Endpoint: Unity端通过TCP端口10000连接ROS2。Unity使用C#,ROS2使用C++/Python,通过TCP桥接实现跨语言通信。Unity端可以直接订阅ROS2话题、发布速度指令,实现虚拟角色与真实机器人实时同步、测试和验证动作编辑器来摆姿势和跳舞。一开始其实是用Unity来映射机器人关节的,也就是说Unity里的角色做了什么动作,机器人就会做什么样的动作,这也是舞蹈动作编辑器的工作原理,这块已经完全打通,后面应用其实也是挺广的,可以拓展高级手办甚至是餐厅门口的招揽机器人。
多协议不是过度设计,是每种协议解决了不同的问题。一种协议包打天下的方案,我试过,结果要么延迟太高,要么断连后无法恢复,要么带宽不够。
其中WebSocket的消息格式设计值得单独拿出来说,因为音频桥接是三端中最复杂的通信模块——它同时处理音频流、控制指令、状态反馈,每种数据的实时性要求不同。以下是音频桥接的消息协议设计:

# WebSocket 消息协议:JSON封装,通过type字段区分消息类型
# 客户端发送控制指令:
{
  "type": "command",
  "action": "play_music",
  "params": {"file": "dance_01.mp3", "loop": false}
}

# 服务端返回状态:
{
  "type": "status",
  "ok": true,
  "state": "playing",
  "timestamp": 1692000000.123
}

音频数据使用二进制帧(不走JSON,避免编码开销):

帧格式: [1字节类型(0x01=PCM)] [4字节长度] [数据]

def _handle_binary_frame(self, data):
    frame_type = data[0]
    if frame_type == 0x01:  # PCM音频帧
        length = struct.unpack('>I', data[1:5])[0]
        pcm_data = data[5:5+length]
        self._play_audio(pcm_data)
    elif frame_type == 0x02:  # 心跳包
        self._last_heartbeat = time.time()
        self._send_heartbeat_ack()

这个设计的关键在于“控制走JSON,音频走二进制”。如果音频数据也走JSON(base64编码),传输体积会增大约33%,且编解码开销会占用嵌入式CPU的宝贵时间。另外,心跳包不是可选项——WebSocket连接在WiFi不稳定时可能假死(TCP连接还在但数据不通),没有心跳包你发现不了这种情况。
以下是命令执行器的HTTP API实现,展示了简单的RESTful风格设计:

class CommandExecutorHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/api/executor/status':
            self._send_json({
                'ok': True,
                'service': 'robot_command_executor',
                'supported_actions': get_supported_actions(),
            }, 200)
            return
        if self.path == '/api/health':
            self._send_json({'ok': True, 'status': 'running'}, 200)
            return
        self._send_json({'ok': False, 'message': 'Not found'}, 404)

    def do_POST(self):
        if self.path == '/api/executor/execute':
            body = self._read_body()
            data = json.loads(body)
            action = data.get('action', '')
            # 解析动作转发到 ROS2 /cmd_vel
            if action == 'move_forward':
                self._publish_cmd_vel(0.05, 0.0, 0.0)
            self._send_json({'ok': True, 'action': action})
            return
        self._send_json({'ok': False}, 404)

监控系统的双通道降级机制是整个架构的“最后一道防线”,实现代码如下:

# 监控系统的双通道降级机制
class RobotStatusChecker:
    def query_service_status(self, service_name):
        """优先HTTP API,失败降级SSH"""
        try:
            # 第一层: HTTP API
            resp = requests.get(
                f'http://{self.robot_ip}:9005/api/status',
                timeout=3  # 3秒超时
            )
            if resp.status_code == 200:
                return resp.json()
        except (requests.Timeout, requests.ConnectionError):
            pass  # API不可用,降级

        try:
            # 第二层: SSH直接执行
            cmd = f'systemctl is-active {service_name}'
            result = subprocess.run(
                ['ssh', '-o', 'ConnectTimeout=5',
                 f'root@{self.robot_ip}', cmd],
                capture_output=True, text=True, timeout=8
            )
            return {'ok': True, 'status': result.stdout.strip()}
        except Exception as e:
            # 两层都失败: 机器人离线
            return {'ok': False, 'error': f'offline: {e}'}

这个降级机制的设计细节值得注意:每层都有独立的超时时间(HTTP 3秒,SSH 8秒),且两层超时不同——HTTP超时短,因为它要么快速响应要么快速失败;SSH超时长,因为它是最后希望,多等一会儿值得。另外,这两层不是“并行尝试”而是“串行降级”——因为并行尝试会同时消耗网络和CPU资源,而在网络不好时这两个资源都是稀缺的。

3.3.4 配置文件结构与管理

整个项目统一使用JSON格式作为配置文件。为什么不用YAML?因为现实情况是你会在多种语言(Python、C#、JavaScript)之间共享配置,JSON是所有语言的最大公约数。以下是命令执行器的配置文件示例:
{
“host”: “0.0.0.0”,
“port”: 9002,
“service_name”: “robot_command_executor”,
“simulation_mode”: true,
“ros2_control_enabled”: true,
“walking_speed_forward”: 0.05,
“walking_speed_backward”: 0.03,
“walking_speed_turn”: 0.3
}
配置文件的设计原则:每个服务独立一个配置文件,不共享全局配置。这个看似“冗余”的做法其实是为了避免一个常见的坑:你会发现你在不同场景下需要不同的参数组合,而且当你改了“全局配置”后很容易忘记谁在用它,导致不相关的服务莫名其妙地坏了。听起来像是软件工程教科书里的话,但这是真实采过的坑。另外,配置文件中只放运行时参数,不放算法参数——算法参数用独立的YAML文件(如zmp_walking_params.yaml),因为算法参数需要注释、分组、版本管理,YAML格式更适合人工阅读和维护。

3.3.5 架构设计中的关键拿择与放弃

回顾整个架构的演进过程,有几个重要的拿择和放弃值得记录下来,因为这些决策的背后都是真实的代价:
拿择一:为什么不用DDS直接跨网段通信? ROS2的DDS是为局域网设计的,虽然理论上可以跨网段,但实际部署时你会发现:需要配置路由器的多播转发,需要处理防火墙规则,需要解决不同网段的延迟和丢包问题。在家庭WiFi环境下,这些问题的解决成本远超直接用HTTP。所以我选择了“机器人内部用DDS,跨网段用HTTP”的策略——这比“全部用DDS”多写了一些代码,但省下了无数个调试小时。
拿择二:为什么不用MQTT做统一消息总线? MQTT确实是物联网的标配协议,但它有两个问题:第一,音频流传输不是MQTT的设计场景,你需要额外的传输协议来处理音频;第二,引入MQTT Broker意味着多了一个中间件需要维护,而这个中间件崩溃会导致所有通信中断。我选择了“每种数据用最合适的协议”的策略,代价是代码里有多种通信实现,但好处是每一个都很简单,且独立崩溃不会影响其他模块。
拿择三:为什么控制器拆成12个mixin而不是独立节点? 按ROS2的最佳实践,应该每个功能一个独立节点,通过话题通信。但实际上,平衡控制的各个模块之间有强耦合关系——平衡PD需要知道当前姿势,跨步恢复需要知道平衡状态,抬脚后跟需要知道重心转移进度。拆成独立节点后,这些状态传递会引入额外的延迟和不确定性。所以我用了mixin模式——代码逻辑上独立(每个mixin一个文件),但运行时在同一个进程内,共享内存中的状态。这是在“模块化”和“实时性”之间的折中。
拿择四:为什么监控系统不用Web前端框架? 用Electron或React写监控系统肯定更好看,但有三个问题:第一,我不是前端开发者,学习成本太高;第二,监控系统需要直接调用SSH和串口,这些在Node.js里不如Python方便;第三,Tkinter写的界面虽然丑,但它启动快、内存小、不依赖浏览器。在这个项目里,“快速上手”比“好看”重要得多。你在做自己的项目时,用你最熟悉的工具就行,不要为了“最佳实践”而拖慢进度。进度比完美重要。

3.4 Unity端:虚拟角色与动作编辑器

Unity端承担两个角色:3D虚拟角色的渲染和同步,以及动作编辑器的可视化编排。分别回答“机器人现在是什么姿态”和“我想让机器人做什么动作”。

3.4.1 虚拟角色渲染:透明窗口桌面宠物

虚拟角色是一个运行在Windows桌面上的透明窗口Unity应用,通过Windows UpdateLayeredWindow API实现透明渲染,角色叠加在桌面上,可去背、可拖拽、可交互。
为什么做桌面宠物而不是独立窗口?我的机器人除了真实存在现实生活中,也存在我的电脑桌面上,可以随时响应我的交互,而且虚拟跟现实互为映射,听起来也挺酷的,可以拓展的商业场景也不少。
在这里插入图片描述

3.4.2 动作编辑器:让机器人像高级手办一样活起来

动作编辑器的编写初衷很简单:让机器人像一个高级手办一样,能快速实现有趣动作的编排和切换。不是冷冰冰的技术工具,而是为了让机器人「活起来」、让不懂代码的人也能给机器人编排动作,这样可以让团队里的美术小伙伴也可以参与到项目里来了。
你不用在代码里写死角度值,不用手动计算关键帧时序,编辑器里看到的样子,就是机器人做出来的样子。拖滑块调角度,时间线上排关键帧,所见即所得。
编辑器核心功能:
关节控制面板: 21个关节,每个关节一个滑块,右侧3D预览里的机器人模型实时同步运动。面板上还有实时计算的重心(COM)球体——拖关节时重心球会跟着移动,让你直观看到“这个姿势会不会倒”。
时间线编辑器: 像视频剪辑软件一样打关键帧,编辑器自动做三次样条插值,生成平滑的速度曲线。可以设置每个关键帧的过渡时间(毫秒级),让动作从“机械”变成“自然”。
BVH动作捕捉导入: 支持BVH格式的动作捕捉数据导入。网上的开源动作捕捉数据(走路、跑步、挥手等)可以直接导入,编辑器自动把BVH骨骼映射到机器人的21个关节。
动作文件管理: 60多个预置动作文件,可直接用也可作为模板修改。双击加载到时间线,一键导出JSON格式文件,机器人端舞蹈播放器直接读取。
如果不用编辑器,给机器人编一个挥手动作要多久?你得先手动测每个关节的角度范围,然后写JSON文件,每一帧写21个角度值,一个3秒的动作按30fps就是90帧,1890个角度值——你写完手都麻了。有了编辑器,拖几个滑块、打几个关键帧,3分钟搞定。
动作编辑器导出的动作文件是JSON格式,包含所有关键帧的关节角度和时间信息。以下是一个典型的舞蹈动作文件结构:

{
  "name": "wave_hand",
  "duration": 3.0,
  "fps": 30,
  "keyframes": [
    {
      "time": 0.0,
      "joints": {
        "r_shoulder_lateral": 0.0,
        "r_elbow_joint": 0.0,
        "r_wrist_joint": 0.0
      }
    },
    {
      "time": 1.5,
      "joints": {
        "r_shoulder_lateral": 30.0,
        "r_elbow_joint": -45.0,
        "r_wrist_joint": 20.0
      }
    },
    {
      "time": 3.0,
      "joints": {
        "r_shoulder_lateral": 0.0,
        "r_elbow_joint": 0.0,
        "r_wrist_joint": 0.0
      }
    }
  ]
}

这个结构的设计思路是“只记录关键帧,中间由插值算法填充”。一个3秒的动作只需要定义3~5个关键帧,而不是90帧全部写死。播放器会自动做三次样条插值,保证速度和加速度在关键帧处都是零,动作平滑无冲击。这个设计的关键细节在于:只记录有变化的关节,没变化的关节不写。如果你在每个关键帧都写满21个角度值,不仅文件大,还容易把“没变”的关节写错。

3.5 双系统架构:开发与演示互不干扰

这个设计来源于一个教训:我在开发时改了一个参数,在演示之前忘记改回来了,机器人在众人面前直接倒了。
从那以后,我在机器人上搞了两套系统——开发系统(Dev)和演示系统(Demo)。两套系统各自独立的配置文件、代码副本、systemd服务。开发时在Dev上折腾,怎么改都不影响Demo;演示时切到Demo,稳定可靠。
切换方式:在机器人终端输入switchdemo切换到演示系统,switchdev切回开发系统。开机默认进入Dev系统,Demo系统仅通过手动切换后临时运行,重启后自动回到Dev。这个设计保证了:你永远有一个“能跑”的版本作为演示的兜底。
两套系统共享基础服务(舮机控制、IMU传感器、ROS-TCP-Endpoint),但各自有独立的ZMP控制器、音频桥接、命令执行器。关键差异:Demo系统关闭了跨步恢复(recovery_enabled=false),因为演示场景不需要;Dev系统开启了所有实验性功能。
双系统架构还有一个实用的副作用:当你想试验一个新参数组合时,你可以在Dev上随意玩,不用担心“玩坏了”——最坏的情况下,切回Demo就能回到稳定状态。这个“安全网”让你敢于实验,而实验次数越多,你对机器人的理解就越深。如果每次改参数都要先备份、改完再恢复,这个心理门槛就会让你不愿意试错。而不试错是学不会的。
双系统切换的实现也很简单,就是停止一套服务、启动另一套:

#!/bin/bash
# switchdemo - 切换到演示系统
# 停止Dev的ZMP控制器、音频、命令执行器
systemctl stop lebao-zmp-walking
systemctl stop robot-audio-bridge
systemctl stop robot-command-executor
# 启动Demo的对应服务
systemctl start lebao-zmp-walking-demo
systemctl start robot-audio-bridge-demo
systemctl start robot-command-executor-demo

# switchdev - 切回开发系统
systemctl stop lebao-zmp-walking-demo
systemctl stop robot-audio-bridge-demo
systemctl stop robot-command-executor-demo
systemctl start lebao-zmp-walking
systemctl start robot-audio-bridge
systemctl start robot-command-executor

这个设计的精妙之处在于:基础服务(舮机控制、IMU传感器、ROS-TCP-Endpoint)是共享的,不参与切换。这些服务一旦停止,机器人就完全瘫痪了——舮机不动,IMU不读数。所以只切换应用层服务,硬件层和通信层保持运行。这个分层设计也是三端架构思想的自然延伸。

3.6 总结:架构设计的三个核心原则

回头看这套架构,有三个原则贯穿始终:

  1. 硬实时在本地,软实时走网络: 100Hz控制循环必须在机器人本地完成,不能依赖网络。AI推理可以放手机端,因为对话延迟500ms可以接受,但平衡控制延迟50ms就会倒。
  2. 多协议各司其职: HTTP做命令,WebSocket做流媒体,SSH做降级,TCP做ROS桥接。一种协议解决一类问题,不搞万能协议。
  3. 永远有兜底: 双系统架构保证“能跑”的版本永远在。通信降级机制保证网络断了也能控制机器人。这些兜底不是“设计”出来的,是每次出问题后加的。你也会遇到同样的问题——到时候记得加兜底。
    最后,说一个重要的观点:架构不是一次设计好的,是一点点演变过来的。你现在看到的这套架构,在我的项目里经历了至少三次大重构:从单机全部塞进去,到分离硬实时和软实时,再到加入双系统和多通信降级。每一次重构都是因为出了问题——丢帧、摔倒、演示翻车——然后你不得不改。所以你不需要一开始就把架构做得很完美,但你需要知道什么时候该拆、什么时候该分、什么时候该加兜底。这些判断力,比完美的架构图更重要。

下一节:第 4 章 开发历程回顾:从零到推不倒 极低成本 · 实物上手 · 可迁移至高端人形平台

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐