第 6 课:不同频率数据流、轨迹冲击与伺服跟随误差

一、先理解 50 Hz 和 1 kHz 的区别

假设 GMR 每秒输出 50 帧机器人关节参考,底层伺服控制器每秒执行 1000 次。

数据流

频率

周期

动捕 / GMR

50 Hz

20 ms

电机伺服循环

1000 Hz

1 ms

这意味着:

GMR 每产生一帧目标位置,伺服控制器大约会执行 20 个控制周期。

例如 GMR 输出:

t = 0 ms    q = 0.0 rad
t = 20 ms   q = 0.5 rad
t = 40 ms   q = 1.0 rad

如果直接使用最新目标,控制器在前 20 ms 看到的目标可能一直是 0,随后在第 20 ms 突然变成 0.5 rad。

二、位置跳变为什么会造成冲击?

这里有三个必须连起来理解的物理量:

位置:电机关节转到了哪里
    ↓
速度:关节位置变化有多快
    ↓
加速度:关节速度变化有多快
    ↓
力矩需求:加速或减速需要多大的驱动力矩

假设:

上一帧目标 = 0 rad
下一帧目标 = 0.5 rad
间隔       = 20 ms

即使让机器人在完整 20 ms 内匀速完成,也需要平均速度:

\[ v=\frac{0.5}{0.02}=25\ \text{rad/s} \]

这对于很多机器人关节已经是非常大的速度要求。

如果你的伺服目标在 1 ms 内直接从 0 跳到 0.5 rad,那么用一个控制周期计算的离散目标速度就达到 500 rad/s。

这里不是说电机会实际达到 500 rad/s,而是说:目标指令要求的变化过于突然,可能远远超过机械系统能够跟随的范围。

可能出现:

  • 位置跟随误差突然增大。

  • PID 或 PD 控制产生较大的控制输出。

  • 执行器触及速度、力矩或电流限制。

  • 机器人发生抖动、冲击或振荡。

  • 严重时触发伺服驱动器故障保护。

什么是跟随误差?

你可以记住最简单的表达:

\[ e=q_{\text{target}}-q_{\text{actual}} \]

例如:

目标位置:1.0 rad
实际位置:0.2 rad

跟随误差:0.8 rad

说明机器人实际位置落后于目标。

但是产生跟随误差,不代表电机或者编码器一定损坏。

也可能是目标运动太快,超过了执行器当前能够跟随的能力。

三、为什么要做插值?

最直观的方法是:不要让目标位置一下从 0 变成 0.5,而是生成中间目标。

例如在 20 ms 内生成 20 个小步长的参考位置。

四、为什么线性插值还不够?

你可能会说:

“既然位置跳变,那我把两个位置之间分成 20 份,不就平滑了吗?”

这只是解决了第一层问题。

假设:

第 1 段:0 → 0.5 rad
第 2 段:0.5 → 0.5 rad

第一段在 20 ms 内完成,平均速度是 25 rad/s。

第二段目标没有变化,速度变成 0。

也就是说,在两段轨迹交界时:

速度 25 rad/s → 0 rad/s

位置虽然连续,速度却发生了突变。

而速度发生突变,就意味着加速度出现不连续的冲击需求。

这也是为什么 ros2_control 的 joint_trajectory_controller 文档指出,仅提供位置点进行线性插值时,可能在路径点处出现速度不连续的问题。

三种插值怎么选?

方法

能保证什么

局限

线性插值

位置连续

速度可能突变

三次多项式

可满足给定的端点位置、速度约束

加速度在连接处不一定连续

五次多项式

可满足端点位置、速度、加速度约束

不自动保证速度、加速度、jerk 不超限

这里的 jerk,中文叫加加速度,就是加速度变化有多快。

对于机器人来说,控制冲击不只和加速度大小有关,还和加速度变化是否突兀有关。

不过你不需要现在推导五次多项式系数。

你只要理解:

插值解决的是轨迹连续性;限速、限加速度、限 jerk 解决的是轨迹可执行性和运动平顺性。

特别注意,五次多项式不是万能的。即使曲线非常光滑,如果要求机器人在 20 ms 内运动很大的角度,仍然可能超出电机能力。

还有一个容易被忽略的问题:目标一直在变

你的动捕每 20 ms 产生新数据。

如果每收到一个新目标就重新规划一条“从静止开始,到静止结束”的轨迹,机器人可能不断减速、加速,动作也会显得不自然。

更合理的在线轨迹生成方式是:

从当前轨迹状态继续规划,在满足运动约束的条件下逐渐跟踪新的目标。

例如 Ruckig 就支持依据当前状态和位置、速度、加速度及 jerk 限制生成轨迹,也提供追踪变化目标的相关接口。

五、工程中怎么定位“机器人突然抖了一下”?

假设 G1 的某个手臂关节突然发生明显跳变。

你不能一开始就断定是电机坏了。

应该按数据流逐层检查:

检查位置

检查内容

判断什么问题

动捕输入

相邻帧姿态变化、时间戳

源数据是否跳变

GMR 输出

相邻关节角差值

逆解是否跳构型

轨迹生成

速度、加速度、限幅状态

参考轨迹是否合理

控制器

期望位置与实际位置

跟随误差是否过大

驱动器

电流、力矩、状态字、故障码

是否发生饱和或驱动故障

这里最重要的两个判断是:

情况 A:GMR 输出本身突然跳变。

可能是人体输入异常、坐标系处理错误或 IK 多解切换。插值可以缓解执行冲击,但不能代替上游故障定位。

情况 B:GMR 输出连续,但电机实际运动抖动。

需要重点检查参考速度、加速度、控制增益、执行器饱和、反馈噪声与周期抖动。

如果控制目标长时间没有更新,还必须有指令超时机制。不能无限继续使用过期的运动参考。

六、一个最简单的 C++ 限速示例

先看一个基础方案:

#include <algorithm>

double limitPositionStep(
    double target,
    double previous_cmd,
    double max_velocity,
    double dt)
{
    const double max_step = max_velocity * dt;

    const double error = target - previous_cmd;

    const double step = std::clamp(
        error,
        -max_step,
        max_step
    );

    return previous_cmd + step;
}

例如:

double target = 1.0;
double previous = 0.0;

double max_velocity = 2.0; // rad/s
double dt = 0.001;         // 1 ms

double next = limitPositionStep(
    target,
    previous,
    max_velocity,
    dt
);

输出:

next = 0.002 rad

即本周期最多向目标移动 0.002 rad。

这个函数能限制每周期位置变化量,对应一个简单的速度约束。

但是它没有限制加速度,也没有限制 jerk,因此不等于完整的平滑轨迹生成器。

这段代码是适合你理解原理的第一步,不建议未经进一步设计和测试直接用于机器人安全关键控制。

“动捕只有 50 Hz,机器人底层是 1 kHz,你怎么处理频率不匹配?”

你可以这样回答:

动捕和 GMR 输出的频率比较低,而机器人底层控制频率更高。如果直接将最新关节位置下发,目标会呈现阶梯式变化,可能产生较大的瞬时跟随误差和控制冲击。

因此,我会在 GMR 和底层控制之间增加轨迹生成层,在伺服周期内生成连续参考。

简单线性插值可以保证位置连续,但不能保证速度和加速度连续,所以还需要根据关节速度、加速度和必要的 jerk 约束设计轨迹。

对于实时遥操作,我还会考虑目标不断更新的问题,尽量从当前轨迹状态继续规划,而不是每收到一帧就重新从静止状态启动。

最后,我会通过记录 GMR 输出、轨迹参考、关节实际位置和跟随误差,判断抖动来自上游目标跳变,还是底层执行器跟踪问题。

第 7 课:IK 多解跳变、数值异常、奇异位形与万向锁

一、为什么 IK 会突然跳构型?

假设某个时刻:

上一帧:
肩关节 = 30°
肘关节 = 60°

下一帧:
肩关节 = -150°
肘关节 = -60°

即使两组解都能让末端到达相近位置,也不代表机器人可以直接从第一组跳到第二组。

如果求解器每次只追求末端误差最小,却没有充分考虑上一帧的关节状态,就可能在不同解分支之间切换。

这就是所谓的IK 多解跳变。

怎样减少这种问题?

一个常见的工程思路是:

  • 使用上一帧有效关节解作为下一帧求解初值。

  • 在优化目标中考虑关节变化量。

  • 添加关节位置和速度约束。

  • 关注奇异位形附近的数值稳定性。

  • 对最终输出进行连续性检查。

例如优化时,不只关注“手有没有到达目标”,还要关注“为了到达目标,关节是否需要突然转很大角度”。

但这些是常用改进思路,不代表你使用的 GMR 版本一定采用了全部策略。

二、最重要的排查手段:FK 回代

面试官会追问:

“你怎么证明这是 IK 多解切换,而不是数值计算错误?”

这时就要想到 FK 回代。

假设 IK 求出了:

q = [q1, q2, q3, ...]

你把这组关节角重新送入 FK:

q → FK → 实际求得的末端位姿

然后和原始目标位姿进行比较。

如果关节角突然跳变,但 FK 回代后仍然非常接近目标,说明这组关节解在任务空间可能是有效的。

如果 FK 回代误差明显增大,则说明求解结果可能没有满足目标。

但是有一个非常重要的边界:

FK 回代误差小,只能证明输出关节角能够实现目标位姿,不能单独证明发生了 IK 多解切换。

还需要比较前后两组关节解、末端位姿连续性、关节限位、求解器状态和模型配置。

三、如何区分 IK 多解、数值异常和万向锁?

这是本课必须掌握的核心表格。

故障类型

典型表现

怎么验证

IK 多解切换

关节角突然大幅变化,但末端位姿依然接近目标

FK 回代、关节变化量、解分支对比

数值异常

NaN、Inf、迭代不收敛或误差突然增大

检查有限值、求解残差、迭代日志

运动学奇异

小幅末端运动要求很大关节速度,或某些方向难以运动

检查 Jacobian 奇异值、条件数

万向锁

欧拉角表示突变或失去一个独立旋转自由度

检查欧拉角顺序、旋转矩阵和四元数

角度表示回绕

179° 突然变成 -179°

比较模 360° 的角度差

这里有一个容易混淆的概念:

机器人运动学奇异,不等于欧拉角万向锁。

机器人运动学奇异与机械结构和 Jacobian 有关;万向锁则是某些欧拉角参数化方式存在的表示奇异性。SciPy 的欧拉角转换文档也明确指出,万向锁发生时会失去一个可独立确定的角度自由度。

示例 1:179° → -179°

实际只变化了约 2°,因为角度表示跨越了 ±180° 边界。

这不一定是关节真实跳变。

示例 2:30° → -150°

差值是 180°,并不能仅靠角度回绕解释。

这时需要优先排查 IK 解分支、关节定义、姿态转换和数据异常。

示例 3:IK 输出出现 NaN

这不是正常的多解切换。

应检查数值求解、非法输入、除零、矩阵计算和异常传播,阻止非法关节参考继续进入控制链路。

六、G1 项目实际排查应该怎么做?

假设某一时刻人体手部动作连续,但 GMR 输出的左肩关节突然变化。

我建议按这个顺序排查:

1. 检查人体输入

时间戳、骨骼位置、坐标系、姿态是否连续

2. 检查 GMR 输出

NaN / Inf、关节限位、相邻帧关节差值

3. FK 回代验证

位置误差、旋转误差、末端是否连续

4. 检查求解器与姿态表示

初值、收敛、Jacobian、四元数和欧拉角转换

5. 隔离异常参考并执行安全处理

不直接把突变目标发送给机器人;按安全策略降速、保持或停机

最有价值的日志字段

timestamp
session_id
seq

human_target_pose
gmr_joint_positions
previous_joint_positions

joint_position_delta
ik_solver_status
ik_iterations
ik_residual

fk_position_error
fk_rotation_error
joint_limit_violation

trajectory_target
robot_actual_position

不需要所有日志都在 1 kHz 控制线程同步写磁盘。实时线程可以将关键诊断数据写入预分配缓存,再由非实时线程处理。

如果采用 Pinocchio 等工具,可以使用其正运动学和 Jacobian 计算能力完成验证。官方示例也展示了基于末端位姿误差和 Jacobian 的迭代 IK 方法。

“如果 GMR 输出的关节角突然跳变,你怎么定位?”

我会先确认人体动捕输入是否连续,排除源数据异常和坐标系转换问题。

然后检查 GMR 输出关节角有没有 NaN、Inf、关节限位异常,以及相邻帧是否出现不合理的变化。

如果输入连续但关节突然跳变,我会重点考虑 IK 多解切换、奇异位形和姿态表示问题。

具体会使用 FK 将异常关节解回代到末端位姿,与目标位姿比较。如果末端误差很小但关节配置变化很大,就说明可能存在多解切换;如果 FK 误差也很大,则需要重点检查求解收敛情况和输入模型。

工程上可以采用上一帧关节解作为初值,添加关节连续性和限位约束,并对异常输出做检测。确认目标不可信时,不能直接发送给机器人,应当由下游执行安全的目标超时或运动停止策略。

第 8 课:多线程队列过载、OOM 防护与丢帧策略

一、丢旧帧还是丢新帧?

这是本课最重要的工程取舍。

答案不是永远丢旧帧,也不是永远丢新帧,而是看队列中的数据代表什么。

对于你的 G1 动捕系统,我建议把两种业务分开:

  • 实时控制链路:有界缓存,优先保证数据新鲜度,过期目标不再执行。

  • 审计记录链路:尽量完整记录,必要时采用独立的有界写入队列和磁盘持久化。

不要为了保证审计数据完整而让实时控制线程等待磁盘写入。

二、怎么实现一个真正有界的 C++ 队列?

先看一个常见但有风险的写法:

std::deque<Frame> queue;

void onFrame(Frame frame) {
    std::lock_guard<std::mutex> lock(mutex);
    queue.push_back(std::move(frame));
}

它有互斥锁,所以在适当的同步条件下可以避免多个线程同时修改队列导致的数据竞争。

但它没有限制容量。

下面用一个教学版本展示如何限制帧数,并在队列满时淘汰最旧的帧:

#include <deque>
#include <mutex>
#include <utility>

template <typename T>
class BoundedQueue {
public:
    explicit BoundedQueue(size_t capacity)
        : capacity_(capacity) {}

    bool tryPushDropOldest(T item) {
        std::lock_guard<std::mutex> lock(mutex_);

        if (capacity_ == 0) {
            return false;
        }

        bool dropped = false;

        if (queue_.size() >= capacity_) {
            queue_.pop_front();
            dropped = true;
        }

        queue_.push_back(std::move(item));
        return !dropped;
    }

    bool tryPop(T& output) {
        std::lock_guard<std::mutex> lock(mutex_);

        if (queue_.empty()) {
            return false;
        }

        output = std::move(queue_.front());
        queue_.pop_front();
        return true;
    }

private:
    size_t capacity_;
    std::deque<T> queue_;
    std::mutex mutex_;
};

这个版本展示了基本思想,但仍然只是教学代码,不是完整的实时队列实现。

特别注意三个细节:

  1. tryPushDropOldest() 返回 false 既可能代表队列满后淘汰了旧帧,也可能代表容量为零,因此生产代码最好使用明确的结果枚举,区分接受、淘汰和拒绝。

  2. 队列容量限制不等于字节数限制。如果 T 内部持有可变大小的动态内存,还需要限制单帧大小和总缓存字节数。

  3. std::mutex 可能产生锁竞争和调度延迟。如果它处于严格的硬实时控制路径,需要进一步评估锁的行为,考虑预分配内存、固定容量缓冲区以及经过验证的实时同步方案。

三、为什么还需要帧龄限制?

即使队列容量只有 20 帧,也不代表数据一定新鲜。

假设系统暂停处理了 1 秒,恢复后队列中仍然有一些历史帧。

如果只检查队列长度,这些过期帧仍然可能被继续执行。

因此,每帧还应该带有时间信息:

struct Frame {
    uint32_t seq;
    uint64_t timestamp_ns;
    // 运动数据
};

处理前检查:

当前时刻 - 帧生成时刻 > 最大允许帧龄

如果超过限制,就根据业务策略丢弃、记录或触发超时处理。

不过,跨机器计算帧龄时仍然需要可信的时间同步或偏移估计;不能随意相减两个机器的单调时钟读数。

四、工程上还要加哪些保护?

保护项

作用

最大帧数

防止无限积压

最大字节数

防止大消息导致内存超限

最大帧龄

防止过期运动目标被执行

队列高水位报警

在队列完全耗尽内存之前发现消费能力不足

丢帧统计

区分主动淘汰、输入缺帧、过期丢弃

线程隔离

避免日志或网络阻塞影响实时接收

降频与过载策略

生产速度长期高于消费速度时控制积压

故障超时处理

长时间没有有效目标时进入预先设计的安全状态

有一个很容易被忽略的点:

有界队列只能限制积压,并不能自动解决生产速度长期高于消费速度的问题。

如果下游长期处理不过来,你还需要分析 CPU 占用、GMR 计算耗时、线程调度和网络发送耗时,判断应该优化消费能力、降低输入频率,还是调整数据处理策略。

“你的动捕队列如果爆满,怎么避免 OOM?为什么不直接把队列开得很大?”

无界队列在生产速度长期高于消费速度时会持续积压,既可能导致内存占用持续增长,也会让控制数据越来越滞后。

我会为队列设置最大帧数、最大字节数和最大帧龄,并监控队列深度、入队失败、主动丢帧和处理延迟。

对于实时遥操作目标,我倾向于淘汰过期的旧目标,让系统尽快追上最新状态,同时在下游进行连续性检查和轨迹重建,避免直接把跳变目标送给伺服控制器。

对于审计和录制数据,则采用独立的记录链路,尽可能保证数据完整性,不让磁盘写入阻塞实时控制。

最后,我还会分析生产者和消费者的处理速率。如果下游长期处理不过来,就需要优化处理耗时或调整数据策略,而不是无限增加队列容量。

第 9 课:单圈编码器多圈累积、高速运动与丢包风险

一、单圈编码器为什么需要多圈累积?

假设电机编码器一圈有 8192 个计数。

它只会报告:

0 → 1000 → 2000 → ... → 8191 → 0

电机继续转第二圈,编码器又从 0 开始。

也就是说,单圈编码器只告诉你:

当前电机转轴位于这一圈中的什么位置,而不能直接告诉你总共转了多少圈。

如果机器人需要持续测量电机位置,就需要将单圈计数转换成连续的多圈位置。

二、半圈阈值算法怎么工作?

假设编码器分辨率为 8192。

它的半圈阈值就是:

8192 / 2 = 4096

如果相邻两次数据变化超过半圈,我们就考虑是否发生了回绕。

例如:

上一帧:8190
当前帧:3

直接相减:3 - 8190 = -8187

但电机实际上只是正向转过了 5 个计数。

所以应该做回绕修正:

delta = -8187 + 8192 = 5

反过来:

上一帧:3
当前帧:8190

delta = 8187 - 8192 = -5

表示反向移动 5 个计数。

C++ 核心实现

int unwrapDelta(int current, int previous) {
    constexpr int N = 8192;
    constexpr int HALF = N / 2;

    int delta = current - previous;

    if (delta > HALF) {
        delta -= N;
    } else if (delta < -HALF) {
        delta += N;
    }

    return delta;
}

之后累积:

multi_turn_count += unwrapDelta(
    current_encoder,
    previous_encoder
);

previous_encoder = current_encoder;

这就是你之前使用的半圈阈值思路。

三、这个算法最重要的前提是什么?

相邻两次有效采样之间,电机真实转动量必须严格小于半圈。

否则可能出现无法区分的情况。

例如:

上一帧编码器:1000
当前帧编码器:2000

你可能认为电机正向转动了 1000 个计数。

但真实情况也可能是:

正向转动 9192 个计数
反向转动 7192 个计数

它们最终得到的单圈读数都是 2000。

仅靠两个单圈读数,无法区分。

这就是采样歧义。

交互验证

这里有一个很关键的细节:如果实际刚好移动半圈,也就是 4096 counts,正转半圈和反转半圈可能产生相同的观测结果,因此不能保证方向判断正确。

四、高速运动和丢包为什么会让算法失效?

正常情况下,控制器每隔几毫秒读取一次编码器。

只要两次采样之间的实际转动量小于半圈,半圈阈值方法就可以恢复连续角度。

但如果 CAN 数据丢了几帧:

正常:
1000 → 2000 → 3000 → 4000

丢帧:
1000 → [2000丢失] → [3000丢失] → 4000

这时算法看到的只有:

1000 → 4000

虽然本例仍然可以正确恢复,但相邻有效采样之间的转动跨度已经变大。

如果跨度超过半圈,就可能发生整圈误判。

如何判断采样频率够不够?

假设电机最高转速是 3000 rpm,也就是每秒 50 圈。

半圈需要:

\[ \frac{0.5}{50}=0.01\text{ s}=10\text{ ms} \]

因此,在这个理想匀速示例下,两次有效采样之间必须严格小于 10 ms,才满足半圈条件。

有效采样间隔

3000 rpm 下的转动量

半圈法是否有保证

1 ms

0.05 圈

有

5 ms

0.25 圈

有

10 ms

0.5 圈

无,方向存在歧义

20 ms

1 圈

无,可能漏掉整圈

这里要特别注意:采样间隔指两次实际有效编码器观测的间隔,不一定等于主机收到两帧 CAN 数据的时间差。 如果驱动器在内部采样后延迟发送,最好使用源端采样时间戳。

实际设计还应该保留裕量,考虑转速变化和通信抖动,而不是刚好卡在半圈边界。

五、工程上怎么防护?

建议采用三层检查。

第一层:时间检查。

记录相邻有效帧的时间间隔。如果超过允许上限,就认为多圈累积结果存在歧义风险。

第二层:速度合理性检查。

利用电机允许的最大速度,判断这段时间内是否有可能转过半圈。

注意:仅凭估计速度较低不能证明数据一定可靠,因为可能已经漏掉整圈。要结合可信的物理最高转速、实际采样间隔及其他传感信息。

第三层:故障处理。

一旦无法确定真实圈数,不应该继续假装多圈位置精确。

应标记位置估计无效,并根据系统设计停止使用不可信位置进行控制。恢复时可以使用驱动器内部多圈位置、绝对编码器、可信参考点或重新回零等机制。

此外,若多圈位置是由主机软件从单圈值累积得到的,进程重启后也不能仅凭新的单圈值恢复重启前的绝对圈数。增量位置测量通常需要额外参考才能确定绝对位置。

“你用半圈阈值实现多圈累积,如果电机高速转动或者 CAN 丢包,怎么办?”

你可以回答:

我使用相邻两次单圈编码器读数计算差值,如果差值超过半圈,就根据回绕方向加减一整圈,再进行连续位置累积。

这个方法有一个前提:两次有效采样之间的真实转动量必须小于半圈。

如果电机转速太快或者 CAN 中间丢了多帧,有效采样间隔变长,就可能超过半圈,导致算法误判方向或者漏掉整圈。

工程上我会结合电机最大转速和采样间隔设置有效性检查,同时监控 CAN 丢帧、接收超时和位置变化异常。

如果已经无法保证半圈条件,就不应该继续认为多圈位置准确,而应该上报位置估计异常,并根据系统安全策略处理。对于需要长期可靠绝对位置的系统,可以考虑使用驱动器内部多圈计数或绝对位置参考机制。

第 10 课:Protobuf 性能优化与实时日志架构

一、Protobuf 在你的 G1 项目中负责什么?

先看典型通信链路:

这里有两个概念:

  • 序列化 Serialize:把关节位置、速度、时间戳等结构化数据转换成便于传输的字节。

  • 反序列化 Parse:将收到的字节解析为程序中的消息对象。

例如:

MotionFrame frame;

frame.set_seq(100);
frame.add_joint_positions(0.1);
frame.add_joint_positions(0.2);

std::string payload;
frame.SerializeToString(&payload);

这段代码是示意,假设已经有对应的 .proto 文件及生成类型。

二、Protobuf 为什么会产生性能开销?

你可以把一次消息发送理解成以下过程:

填充字段
   ↓
申请或复用内存
   ↓
序列化编码
   ↓
复制或移交到通信缓冲
   ↓
网络传输
   ↓
接收端解析
   ↓
下游计算

Protobuf 本身不等于网络通信协议,也不负责保证实时传输。

它主要负责消息编码和解码。

性能开销可能来自:

  • 创建和销毁大量消息对象。

  • 动态字符串和重复字段的内存分配。

  • 字段编码和解析。

  • 不必要的数据复制。

  • 数据结构过大或字段设计不合理。

Google 官方 Protobuf 文档明确提到,消息对象复用和 Arena 内存分配可以在合适的场景下减少内存分配开销。但不能在没测量之前就认定 Protobuf 是瓶颈。

三、50 Hz 动捕链路,先优化什么?

假设每 20 ms 到达一帧。

你测量某帧的各阶段耗时:

阶段

假设耗时

数据整理

0.3 ms

Protobuf 序列化

0.1 ms

发送入队

0.1 ms

GMR 计算

8 ms

从这个例子能看出,Protobuf 的耗时远低于 GMR。

如果这时候把大量时间花在修改 Protobuf 底层,可能收益并不明显。

但是要注意:这只是教学假设,不是你项目的真实性能测试结果。

应该统计哪些数据?

除了平均耗时,还应该关注:

  • 每帧序列化和反序列化耗时。

  • P50、P95、P99 延迟。

  • 最大消息大小。

  • 消息对象和缓冲区的内存分配情况。

  • 各线程队列积压和端到端延迟。

尤其是实时系统:

平均耗时低,不代表偶发的高延迟不会影响控制链路。

四、Protobuf 有哪些优化方法?

按实习生应该掌握的程度,记住以下四种。

方法

原理

注意事项

复用 Message 对象

减少反复创建对象的开销

避免旧字段残留

复用序列化缓冲

减少重复申请内存

检查容量、错误返回值

Arena 分配

批量管理内存分配和释放

生命周期和对象所有权要正确

优化消息结构

减少无用字段或重复拷贝

必须保持协议兼容性

最容易掌握的优化:对象复用

例如:

MotionFrame frame;

while (running) {
    frame.Clear();

    frame.set_seq(next_seq++);

    for (double q : joint_positions) {
        frame.add_joint_positions(q);
    }

    if (!frame.SerializeToString(&buffer)) {
        // 记录序列化失败
    }
}

在适合复用的工作线程里,可以避免每帧都重新构造相同类型的消息对象。

注意,Clear() 并不一定释放之前分配的所有内存。对象复用虽然能减少分配,但也可能保留曾经扩大的容量。需要结合实际消息大小监测内存使用。

Arena 是什么?

把 Arena 想成一块用于集中管理多个 Protobuf 对象内存的区域。

google::protobuf::Arena arena;

auto* msg =
    google::protobuf::Arena::Create<MotionFrame>(
        &arena
    );

它适合某些创建大量短生命周期消息对象的场景。

但是 Arena 不是“开启后就一定更快”,也不等于零拷贝。使用不当还可能造成额外复制或内存增长。

面试到这里,已经足够。

五、第二个重点:为什么实时线程不应该直接频繁写磁盘?

假设你的 EtherCAT 控制循环要求每 1 ms 执行一次。

正常情况下:

读取 PDO
   ↓
计算控制目标
   ↓
写入 PDO
   ↓
进入下个周期

现在你为了定位问题,在控制循环里加入日志:

while (running) {
    readMotor();

    computeCommand();

    writeMotor();

    writeLogToFile();
}

看起来没问题,但磁盘写入、文件系统锁、日志格式化、缓冲刷新等操作都可能产生不可预测的执行时间。

如果某次日志处理耗费了几毫秒,就可能错过 1 ms 的控制周期。

因此:

实时控制线程应该尽量避免同步文件 I/O。

你可以将它理解成:控制线程负责控制,日志线程负责记录,两者要隔离。

六、正确的日志架构:实时线程 + 环形缓存 + 后台 I/O

这里最重要的是:

实时线程只写入内存中的小型记录,不直接进行磁盘写入。

例如固定结构:

struct DiagnosticRecord {
    uint64_t timestamp_ns;

    uint32_t cycle_us;
    uint32_t wkc_actual;
    uint32_t wkc_expected;

    int32_t target_position;
    int32_t actual_position;

    uint16_t statusword;
    uint16_t error_code;
};

相比每周期拼接复杂字符串,这种结构更容易控制每条日志的大小和记录过程的开销。

不过,环形缓冲必须明确生产者/消费者模型,并采用正确的原子操作或同步机制;不是使用普通数组就天然线程安全。

日志队列满了怎么办?

如果实时线程等待后台日志线程清空队列,就又可能产生阻塞。

因此应该预先选择日志过载策略。

对于低优先级诊断信息,可以采用非阻塞写入并统计丢弃数量;对于关键故障事件,可以单独保留有界的高优先级记录区或触发故障快照。

不要把所有普通日志和关键故障记录放在一个可以随意覆盖的队列中。

现成库例如 spdlog 支持异步日志和不同的队列溢出策略。但它的异步模式默认也可能在队列满时阻塞,必须选择适合实时线程的策略。

七、重点追问:进程突然 Crash,日志还能留下吗?

这正是你之前模拟面试中的一个知识盲区。

假设:

t = 100 ms:EtherCAT WKC 异常
t = 101 ms:驱动器 Fault
t = 102 ms:程序发生 Segmentation Fault

如果诊断数据只保存在当前进程的普通内存里,进程崩溃后,这些尚未持久化的数据一般就无法通过原来的内存队列继续读取。

所以:

后台 I/O 线程 + 环形缓存能减少实时线程阻塞,但不能保证进程崩溃时内存里所有日志都已落盘。

这里要区分三种情况。

记录方式

进程崩溃后

局限

仅进程内环形缓存

未另行保存的数据通常丢失

缺少持久化

后台定期写入文件

已成功写入内核文件缓存的数据通常仍可读取

最近尚未写入的记录可能丢失

定期持久化同步

可提高系统崩溃或断电后的数据保存可靠性

同步有性能代价,仍不能保证硬件绝对可靠

要注意,进程崩溃和整机突然断电是不同的故障场景。write() 成功也不等于数据已经可靠落到物理存储介质;必要时需要 fsync() 等持久化机制。

更进一步的工程方案

对于机器人系统,我建议把故障诊断分成两个等级。

普通运行日志

由后台日志线程批量写入,允许在极端过载时丢弃部分低优先级记录。

关键故障证据

保存最近一段时间的 WKC、Statusword、控制周期耗时、目标与反馈等信息,并设计独立的持久化或崩溃后取证机制。

如果要求即使控制进程意外退出也能尽量保留故障前数据,可以考虑独立日志进程、适当的共享内存或 IPC 机制,以及持续持久化的故障记录。

但也要明确:即使日志进程独立,仍然存在 IPC 缓冲尚未被消费、系统崩溃或断电等边界情况。

问题一:Protobuf 编解码性能怎么优化?

Protobuf 序列化会产生字段编码和内存分配等开销,但我不会直接假设它是性能瓶颈。

我会先统计每帧序列化和反序列化耗时、消息大小以及 P95、P99 延迟,再和 GMR 计算、网络发送等阶段比较。

如果确认 Protobuf 占比较大,可以考虑复用 Message 对象、复用缓冲区、优化消息结构,必要时评估 Arena 内存分配。

对于当前项目,如果没有做过专项性能优化,我会明确说明目前主要完成了通信链路功能和验证,性能优化属于后续可测量、可验证的改进方向。

问题二:如何避免实时日志影响控制周期,同时保留 Crash 证据?

我不会在 1 kHz 实时控制线程里频繁同步写磁盘。

比较合理的设计是让实时线程将固定大小的诊断记录写入预分配的有界环形缓存,再由独立后台 I/O 线程负责批量写入文件。

对日志队列设置容量和溢出策略,避免日志积压反过来阻塞控制线程。

不过这种方案不能保证进程崩溃时所有缓存日志都已经落盘。如果对崩溃前的数据保存要求比较高,还需要定期持久化、独立日志进程或其他故障取证机制,并明确不同方案能够覆盖的故障范围。

这两段回答不用背得一字不差,但必须讲清楚实际测量、线程隔离、资源上限、崩溃持久化这四个关键词。

第 11 课:CPU 绑核、SCHED_FIFO、实时线程与 TCP 延迟抖动

一、为什么 1 kHz 控制循环会发生周期抖动?

假设 EtherCAT 主站每 1 ms 执行一次控制循环:

理想情况:

0 ms → 1 ms → 2 ms → 3 ms → 4 ms

但实际运行时可能变成:

实际情况:

0 ms → 1.1 ms → 2.0 ms → 4.5 ms → 5.0 ms

某个周期晚了 2.5 ms,就可能导致控制参考更新不及时、反馈处理延迟,甚至触发执行器的通信超时。

这就是周期抖动(Jitter)。

影响周期稳定性的常见因素包括:

  • Linux 调度器让其他任务先执行。

  • 中断处理和软中断占用 CPU。

  • 线程之间争抢锁。

  • 动态内存分配、缺页或其他阻塞操作。

  • 日志写入、网络调用或文件 I/O。

  • CPU 降频、缓存争用及其他硬件因素。

因此,机器人系统工程师不能只写一个 while 循环,还要测量实际周期、最大延迟和超时次数。

二、CPU affinity:为什么要给线程绑核?

假设一台机器人主控有 8 个 CPU 核心。

Linux 可以将线程调度到不同的 CPU 上运行。对于某些高频控制任务,可以使用 CPU affinity 限制线程允许运行的 CPU 集合。

例如:

CPU 0:系统服务与普通任务
CPU 1:网络接收线程
CPU 2:机器人控制线程
CPU 3:日志与后台任务

这是一个示意布局,不是所有机器人都应该照搬。

绑核可能带来几个好处:

  • 减少线程在不同 CPU 之间迁移。

  • 改善缓存局部性。

  • 方便隔离和分析高优先级任务的执行干扰。

但是,绑核不等于实时。

如果把控制线程绑在一个同时处理大量中断的 CPU 上,情况可能更差。如果多个高优先级线程被绑到同一个核心,也可能互相争抢。

真正的工程工作是:根据 CPU 使用情况、中断分布、线程优先级和周期测量结果设计布局。

三、SCHED_FIFO:为什么高优先级线程可能让系统卡死?

Linux 提供 SCHED_FIFO 实时调度策略。

普通调度策略会考虑公平分配 CPU 时间;而 SCHED_FIFO 会优先运行可执行的最高优先级实时线程,并且不会像 SCHED_RR 那样自动按时间片轮转。

Linux 常见的实时优先级范围是 1~99,数字越大,优先级越高。

例如:

线程 A:SCHED_FIFO,优先级 80
线程 B:SCHED_FIFO,优先级 60
线程 C:普通 SCHED_OTHER 线程

如果线程 A 一直处于可运行状态且不主动阻塞,那么它可能持续占用 CPU,让 B 和普通线程长时间得不到运行机会。

这就是实时调度中必须防范的线程饥饿。

为什么这对机器人很危险?

假设你把 EtherCAT 控制线程设置为优先级 90。

但程序中有一个错误:

while (true) {
    // 忘记等待下一个周期
    // 一直执行计算
}

这个循环持续占用 CPU,又没有适当的周期等待。

在 SCHED_FIFO 下,它可能压制其他低优先级线程,包括负责处理部分通信、日志或系统任务的线程。

结果可能是:控制线程还在运行,但系统的其他关键服务已经无法及时响应。

所以要记住:

实时优先级不是越高越好,而是要依据任务截止时间、执行时间和系统调度关系进行设计。

四、mlockall:为什么实时程序需要锁定内存?

Linux 使用虚拟内存机制。程序访问某个页面时,如果页面尚未准备好,就可能触发缺页处理。

在普通应用中,这通常可以接受;但在严格的实时控制路径中,缺页可能导致额外延迟。

mlockall() 可以请求将当前进程的内存页面锁定在物理内存中,减少因页面被换出或首次访问缺页而产生的延迟。它并不能自动消除所有缺页和其他延迟来源。

你可以把它理解成:

  • CPU affinity:限制线程可以在哪些 CPU 上运行。

  • SCHED_FIFO:改变线程的调度优先级和运行规则。

  • mlockall():降低内存分页引起的延迟风险。

它们解决的是不同问题。

一个典型的准备流程

#include <sys/mman.h>

int main() {
    // 示例:请求锁定当前和未来映射的内存页面
    if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
        // 记录错误,不能假设实时内存保护已经生效
    }

    // 初始化对象、分配缓冲区
    // 预热栈和关键内存
    // 配置线程调度与 CPU affinity
    // 完成准备后再进入控制循环

    return 0;
}

这只是展示 API 用法的示例,不是完整的实时控制程序。

实际使用时要注意:

  1. 进程可能没有足够的锁页权限或资源限制。

  2. MCL_FUTURE 可能导致后续内存分配或栈扩展失败。

  3. 应在进入实时路径前完成内存分配和预热。

  4. 锁定内存不等于程序永远不会阻塞,也不等于已经获得硬实时保证。

五、CPU affinity + SCHED_FIFO + mlockall 应该怎么组合?

对你准备从事的机器人系统联调岗位,不必一开始就做复杂的内核级调优,但要知道这些能力应该怎样组合。

这里的顺序很重要:先解决程序本身的阻塞和执行时间问题,再考虑操作系统调优。

否则即使使用实时内核,也可能因为锁竞争、过长的临界区或设计不合理而错过截止时间。

六、TCP 为什么会给实时链路带来延迟抖动?

TCP 的优势是可靠、有序的字节流传输。它通过序列号、确认和重传等机制处理数据丢失。

但这些机制也意味着:当某段数据丢失时,后续数据即使已经到达接收端,也可能因为有序交付要求而等待缺失数据补齐。

这就是一个重要的延迟来源。

假设你每 1 ms 发送一条控制参考:

发送:A → B → C → D

网络丢失:B

接收端:
A → 等待 B 重传 → B → C → D

对于严格时限的底层控制,B 即使最终被成功重传,也可能已经错过执行时间。

TCP 还受到拥塞控制、发送缓冲、接收端处理速度和调度延迟等因素影响,因此不适合被当作具有确定传输时限的伺服通信机制。

但要注意一个边界:

TCP 不是不能用于机器人,也不是所有 UDP 都比 TCP 实时。

例如,TCP 很适合配置下发、日志上传、非硬实时管理接口和一般状态查询。UDP 则适合允许应用自行处理丢包、乱序和过期数据的实时数据流。

真正的选择依据是业务对可靠性、顺序、延迟上界和丢包处理方式的要求。

结合你的机器人项目

场景

通常考虑的方案

主要原因

机器人配置、参数下发

TCP

需要可靠、有序交付

运行日志上传

TCP 或可靠消息机制

更重视数据完整性

实时动捕参考流

UDP 或适当的实时传输方案

可以由应用控制过期数据策略

EtherCAT 周期伺服通信

EtherCAT 主站周期通信

面向工业实时 I/O 与周期同步

底层 CAN 电机通信

取决于电机协议和控制架构

需要考虑周期、总线负载和反馈时序

这里不能只凭传输协议名称断言整个系统是否实时。真正的端到端延迟还取决于发送线程、操作系统、网卡、中间网络、接收线程和控制器设计。

七、Linux 上怎样检查线程调度与 CPU 绑核?

你可以先从观察开始,而不是直接修改系统配置。

1. 查看线程

ps -eLf

查看进程及其线程。

2. 查看线程 CPU 和调度状态

top -H -p <PID>

其中 <PID> 要替换为目标进程的实际进程号。

3. 查看 CPU 拓扑

lscpu

查看逻辑 CPU、核心和拓扑信息。

4. 查看进程允许使用哪些 CPU

taskset -pc <PID>

这可以查看或修改进程的 CPU affinity;但在多线程程序中,进程内不同线程可以有各自的 affinity,不能简单认为整个进程所有线程一定运行在同一个核心。

5. 查看实时调度策略

chrt -p <PID>

查看目标进程的调度策略和优先级。

注意,这些命令主要用于观察。实际部署实时调度、绑核和锁定内存前,应先确认运行权限、系统配置以及控制程序的安全退出机制。

对于你的求职方向,先能解释每个命令的作用、知道如何查看结果,就已经具备了很好的面试基础。

“如果你的 EtherCAT 控制循环是 1 kHz,如何降低 Linux 上的周期抖动?”

我会先测量控制循环的实际周期、执行耗时、最大延迟和超时次数,判断问题来自计算量、锁竞争、内存分页还是线程调度。

程序设计上,我会尽量把磁盘 I/O、阻塞网络操作和动态内存分配移出关键控制路径。

如果确实存在调度抖动,再根据系统负载评估 CPU affinity、SCHED_FIFO 实时调度和 mlockall 内存锁定等手段。

但这些配置不能单独保证硬实时。SCHED_FIFO 使用不当可能导致其他线程饥饿,CPU 绑核也可能加剧中断和任务争用,所以必须结合负载测试和最坏情况延迟验证。

最终需要在实际运行环境下检查周期抖动、截止时间违约次数和故障恢复行为,而不是只看控制循环平均耗时。

这段回答足以应对大多数实习生岗位关于 Linux 实时性的基础追问。

Logo

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

更多推荐