22. 插补算法测试:单元测试框架、边界条件测试、压力测试

做运动控制这么多年,我踩过最大的坑是什么?

不是算法设计,不是RTOS调度,而是——测试没做到位

你想想看,插补算法在仿真环境里跑得再好,一上真实电机就出问题。为什么?因为测试覆盖不全。边界条件没测,压力没扛住,单元测试形同虚设。今天我就把这三块内容掰开了讲,全是实战经验。

22.1 单元测试框架怎么搭?

我个人习惯用C语言 + Unity测试框架。轻量、嵌入式友好、没有依赖地狱。

先看一个最简单的直线插补单元测试:

#include "unity.h"
#include "interpolator.h"

void setUp(void) {}
void tearDown(void) {}

void test_LineInterpolation_Basic(void) {
    Point start = {0.0f, 0.0f, 0.0f};
    Point end   = {100.0f, 0.0f, 0.0f};
    float feedrate = 100.0f;  // mm/s
    
    Interpolator interp;
    interpolator_init(&interp, &start, &end, feedrate);
    
    // 验证总距离
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 100.0f, interp.total_length);
    
    // 验证插补方向向量
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 1.0f, interp.dir.x);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, interp.dir.y);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, interp.dir.z);
    
    // 验证插补周期
    TEST_ASSERT_EQUAL_UINT32(1000, interp.period_us);
}

这里要注意几个点:

  • 浮点数比较用WITHIN宏,别用EQUAL。浮点精度问题我吃过亏,曾经一个0.0001的误差导致电机抖动半小时。
  • 每个测试函数只测一个逻辑。我见过有人一个函数测10个东西,出错了都不知道是哪个条件触发的。
  • setUp/tearDown里做资源初始化。特别是RTOS环境,信号量、队列这些要重置。
我的小技巧: 在Unity里加一个自定义宏 TEST_ASSERT_POINT_EQUAL,专门比较三维坐标点。这样测试代码可读性高很多。

22.2 边界条件测试——最容易翻车的地方

我曾经在一个项目中,圆弧插补在90度边界处偶尔会多走一个脉冲。查了三天,发现是角度计算时atan2的边界处理没做好。

边界条件测试,说白了就是找算法的「死穴」。我总结了几类必测场景:

边界类型 测试场景 预期行为
零长度 起点==终点 不产生任何插补点
单轴运动 仅X轴移动 Y/Z轴输出恒为0
最大速度 进给率=系统上限 不溢出、不丢步
最小步长 移动距离<1个脉冲当量 舍入处理或报错
圆弧半径 半径=0 退化为点或报错
象限切换 圆弧跨象限 平滑过渡,无突变

看一个圆弧跨象限的测试代码:

void test_ArcInterpolation_CrossQuadrant(void) {
    Point center = {0.0f, 0.0f, 0.0f};
    Point start  = {100.0f, 0.0f, 0.0f};  // 第一象限
    Point end    = {0.0f, -100.0f, 0.0f}; // 第四象限
    float radius = 100.0f;
    
    ArcInterpolator arc;
    arc_interpolator_init(&arc, ¢er, &start, &end, radius, ARC_CW);
    
    Point output;
    float last_angle = 0.0f;
    int step_count = 0;
    
    while (arc_interpolator_step(&arc, &output) == 0) {
        // 验证角度单调递减(CW方向)
        float current_angle = atan2f(output.y, output.x);
        if (step_count > 0) {
            TEST_ASSERT_TRUE(current_angle <= last_angle + 0.001f);
        }
        last_angle = current_angle;
        step_count++;
    }
    
    // 验证终点精度
    TEST_ASSERT_FLOAT_WITHIN(0.01f, end.x, output.x);
    TEST_ASSERT_FLOAT_WITHIN(0.01f, end.y, output.y);
}
注意: 边界条件测试一定要在真实硬件或模拟器上跑。纯软件仿真会掩盖很多问题,比如浮点精度在不同MCU上的表现差异。

22.3 压力测试——让算法「喘不过气」

压力测试不是简单的跑循环。我把它分成三个维度:

  1. 时间压力:插补周期被压缩到极限
  2. 空间压力:连续插补段数量巨大
  3. 资源压力:RTOS任务被频繁抢占

先看时间压力测试。我一般这样设计:

void stress_test_TimingPressure(void) {
    // 设置极短插补周期:100us
    interpolator_set_period(100);
    
    Point start = {0, 0, 0};
    Point end   = {10000, 5000, 2000};  // 长距离
    
    Interpolator interp;
    interpolator_init(&interp, &start, &end, 500.0f);
    
    uint32_t start_time = micros();
    uint32_t max_delta = 0;
    uint32_t min_delta = 0xFFFFFFFF;
    
    Point output;
    int ret;
    
    do {
        uint32_t t0 = micros();
        ret = interpolator_step(&interp, &output);
        uint32_t t1 = micros();
        
        uint32_t delta = t1 - t0;
        if (delta > max_delta) max_delta = delta;
        if (delta < min_delta) min_delta = delta;
        
        // 检查是否超时
        TEST_ASSERT_TRUE(delta < 100);  // 必须在100us内完成
    } while (ret == 0);
    
    // 输出统计信息
    printf("Max step time: %u us\n", max_delta);
    printf("Min step time: %u us\n", min_delta);
    printf("Avg step time: %u us\n", (start_time - micros()) / step_count);
}

空间压力测试更狠。我曾在项目中连续下发5000段微线段(每段1-2mm),看插补缓冲区会不会溢出:

void stress_test_SpacePressure(void) {
    const int SEGMENT_COUNT = 5000;
    Point segments[SEGMENT_COUNT];
    
    // 生成连续微线段
    for (int i = 0; i < SEGMENT_COUNT; i++) {
        segments[i].x = i * 1.5f;
        segments[i].y = sinf(i * 0.1f) * 10.0f;
        segments[i].z = 0;
    }
    
    // 逐个送入插补器
    for (int i = 0; i < SEGMENT_COUNT - 1; i++) {
        int ret = interpolator_load_segment(&segments[i], &segments[i+1], 100.0f);
        TEST_ASSERT_EQUAL(0, ret);  // 不能返回缓冲区满
    }
    
    // 验证所有段都被正确消费
    TEST_ASSERT_EQUAL(SEGMENT_COUNT - 1, interpolator_consumed_count());
}

核心观点: 压力测试不是为了证明「能跑」,而是为了找到「什么时候会崩」。我习惯在压力测试中故意制造最坏情况——比如在插补任务执行到一半时,用高优先级任务抢占CPU。

22.4 测试自动化与持续集成

手动测试一次两次还行,天天测谁也受不了。我建议把测试集成到CI流水线里:

  • 每次提交代码:自动跑单元测试 + 边界条件测试
  • 每晚构建:跑完整压力测试(耗时可能30分钟以上)
  • 版本发布前:在目标硬件上跑全量测试

这里给个Makefile片段,一键运行所有测试:

test: unit_test boundary_test stress_test

unit_test:
    @echo "Running unit tests..."
    @./build/test_runner --suite unit

boundary_test:
    @echo "Running boundary tests..."
    @./build/test_runner --suite boundary

stress_test:
    @echo "Running stress tests (this may take a while)..."
    @./build/test_runner --suite stress --iterations 10000
避坑指南: 我曾经把压力测试放在提交前检查里,结果每次提交都要等20分钟。后来改成「提交前跑单元测试,压力测试放夜间构建」,效率高多了。

22.5 测试覆盖率——别被数字骗了

很多人追求100%行覆盖率,但说实话,行覆盖率高不代表测试质量高

我见过一个项目,行覆盖率95%,但边界条件一个没测。为什么?因为所有测试用例都走的是「正常路径」,异常分支根本没触发。

我建议关注三个指标:

  1. 分支覆盖率:每个if-else、switch-case都跑到
  2. 边界覆盖率:每个边界条件都有对应测试用例
  3. 突变测试:故意改一个逻辑(比如把+改成-),看测试能不能发现

突变测试听起来高大上,其实实现很简单。我写了个小脚本,自动修改源码中的运算符,然后跑测试。如果测试没报错,说明那行代码的测试是「假阳性」。

# 突变测试示例:把 + 改成 -
sed -i 's/current_pos += step;/current_pos -= step;/' interpolator.c
make test
# 如果测试通过,说明步进累加的测试不充分!

22.6 知识体系总览

下面这张图是我对插补算法测试的完整理解,画出来方便你对照:

插补算法测试体系 单元测试框架 • Unity/Cmocka • 浮点精度断言 • 单逻辑单测试 • setUp/tearDown • 自定义断言宏 边界条件测试 • 零长度/零半径 • 单轴运动 • 最大/最小速度 • 象限切换 • 最小步长舍入 压力测试 • 时间压力(100us) • 空间压力(5000段) • 资源抢占 • 最坏情况模拟 • 长时间运行 自动化与持续集成 质量度量指标 • 分支覆盖率 ≥ 90% • 边界覆盖率 100% • 突变测试通过率 • 压力测试零失败

这张图把测试体系分成了三层:底层是单元测试,中间是边界条件和压力测试,上层是自动化集成。右侧是质量度量指标,用来衡量测试到底做没做到位。

嗯,最后说一句心里话:测试不是负担,是护身符。我见过太多项目因为测试不到位,在现场出问题,然后花几倍的时间去排查。与其那样,不如一开始就把测试做扎实。

你想想看,一个插补算法写出来可能只要两天,但测试和调优可能要两周。这笔账,值得算。

总结三个关键动作:

  • 单元测试用框架,别手写printf调试
  • 边界条件列成表格,逐条验证
  • 压力测试找最坏情况,别只跑理想场景