4. 就绪列表测试:插入/删除操作的正确性、边界条件

就绪列表,说白了就是调度器的核心数据结构。所有等待 CPU 的任务都在里面排队。如果这个列表出了问题,整个系统就乱套了。我见过不少项目,调度器跑着跑着任务就丢了,十有八九是就绪列表的操作有 bug。

这一章,我们就来专门测它。插入、删除、边界条件,一个都不能少。

4.1 就绪列表的基本操作

先明确一下我们要测什么。一个典型的就绪列表,通常支持以下操作:

  • 插入:把一个任务控制块(TCB)放入列表
  • 删除:从列表中移除一个指定的 TCB
  • 查找最高优先级:找到当前就绪态中优先级最高的任务
  • 遍历:检查列表完整性

我个人习惯把测试分成三个维度:功能正确性、边界条件、压力稳定性。这一章我们先搞定前两个。

4.2 插入操作测试用例

插入操作看起来简单,但坑不少。我列几个关键测试点:

测试编号 测试场景 预期结果
TC-INS-001 向空列表插入一个任务 列表不为空,头指针指向该任务
TC-INS-002 插入优先级高于当前头节点 新任务成为头节点
TC-INS-003 插入优先级低于当前尾节点 新任务成为尾节点
TC-INS-004 插入优先级与已有任务相同 按 FIFO 顺序排在同优先级末尾
TC-INS-005 插入 NULL 指针 函数返回错误码,列表不变

核心验证点:插入后,列表的 prev/next 指针必须形成双向闭环。我见过一个案例,插入时忘了更新前一个节点的 next 指针,结果遍历时直接死循环。

代码示例,一个典型的插入测试:

// 测试:向空列表插入任务
void test_insert_into_empty_list(void)
{
    ready_list_t list;
    tcb_t task;

    ready_list_init(&list);
    task.priority = 10;

    // 执行插入
    int ret = ready_list_insert(&list, &task);

    // 验证
    assert(ret == 0);
    assert(list.head == &task);
    assert(list.tail == &task);
    assert(task.next == &task);  // 自环
    assert(task.prev == &task);  // 自环
    assert(list.count == 1);

    printf("[PASS] TC-INS-001: 空列表插入成功\n");
}

4.3 删除操作测试用例

删除比插入更容易出问题。为什么?因为要处理四个指针的更新,少一个就崩了。

测试编号 测试场景 预期结果
TC-DEL-001 删除唯一的节点(列表变空) head/tail 均为 NULL,count=0
TC-DEL-002 删除头节点 原第二节点成为新头节点
TC-DEL-003 删除尾节点 原倒数第二节点成为新尾节点
TC-DEL-004 删除中间节点 前后节点正确连接
TC-DEL-005 删除不存在的节点 返回错误码,列表不变

我曾经踩过的坑:删除头节点时,忘了检查列表是否只有一个节点。如果只有一个节点,删除后 head 和 tail 都应该置 NULL。我当时没处理这个分支,结果后续插入时直接写野指针,系统随机死机。排查了整整两天。

删除测试代码示例:

// 测试:删除中间节点
void test_delete_middle_node(void)
{
    ready_list_t list;
    tcb_t task1, task2, task3;

    ready_list_init(&list);
    task1.priority = 10;
    task2.priority = 20;
    task3.priority = 30;

    ready_list_insert(&list, &task1);
    ready_list_insert(&list, &task2);
    ready_list_insert(&list, &task3);

    // 删除中间节点 task2
    int ret = ready_list_remove(&list, &task2);

    assert(ret == 0);
    assert(list.count == 2);
    assert(task1.next == &task3);
    assert(task3.prev == &task1);
    assert(task2.next == NULL);  // 已移除,指针清空
    assert(task2.prev == NULL);

    printf("[PASS] TC-DEL-004: 删除中间节点成功\n");
}

4.4 边界条件测试

边界条件,说白了就是测极端情况。我把它分成三类:

4.4.1 空列表边界

  • 从空列表删除任意节点 → 返回错误
  • 从空列表查找最高优先级 → 返回 NULL
  • 连续两次删除同一个节点 → 第二次应失败

4.4.2 满列表边界

对于静态数组实现的就绪列表(比如用位图或固定大小数组),要测:

  • 插入到最大容量时,再插入一个 → 返回错误
  • 满列表删除后,能否继续插入
  • 满列表的遍历是否完整

4.4.3 优先级边界

  • 插入优先级为 0(最高)的任务
  • 插入优先级为 MAX_PRIORITY-1(最低)的任务
  • 所有任务优先级相同

一个小技巧:我习惯在测试用例里故意制造「优先级反转」的场景。比如先插入一个低优先级任务,再插入高优先级,然后删除高优先级,检查低优先级是否恢复为头节点。这个场景能暴露很多指针更新遗漏的问题。

4.5 就绪列表测试框架图

下面这张图,是我自己整理的就绪列表测试框架。每次做新调度器,我都会照着这张表逐项过一遍。

就绪列表测试框架 插入测试 删除测试 边界测试 空列表插入 头/尾/中间 同优先级FIFO 删除唯一节点 删除头/尾/中 删除不存在节点 空列表操作 满列表操作 优先级极值 验证指标:指针完整性 | 节点计数 | 优先级顺序 | 错误处理 每个测试用例必须包含:前置条件 → 执行操作 → 断言验证 → 清理恢复

4.6 测试执行与结果验证

测试写好了,怎么跑?我推荐用自动化测试框架。在嵌入式环境里,我常用的是 Unity 或 Ceedling。它们轻量,适合资源受限的平台。

一个典型的测试执行流程:

  1. 初始化就绪列表
  2. 按测试用例构造前置条件
  3. 执行插入/删除操作
  4. 遍历列表,验证每个节点的 prev/next 指针
  5. 验证 count 计数
  6. 验证头尾节点正确性
  7. 清理资源,准备下一个用例

关键点:每个测试用例结束后,必须把列表恢复到初始状态。否则用例之间会互相影响,你根本分不清是哪个操作引入的 bug。我早期吃过这个亏,测试报告一片红,排查了一下午才发现是上一个用例没清理干净。

4.7 常见问题与避坑指南

问题现象 可能原因 解决方法
遍历时死循环 指针未正确更新,形成环 插入/删除后立即验证指针完整性
任务丢失 删除时未更新前驱节点的 next 画链表图,逐指针检查
优先级顺序错乱 插入时未按优先级比较 插入函数中增加优先级排序断言
空列表操作崩溃 未检查 head==NULL 所有操作入口增加空列表守卫

我曾经遇到一个特别隐蔽的 bug:在中断服务函数里插入任务到就绪列表,主循环里删除。由于中断嵌套,插入操作被中断打断,导致链表指针处于不一致状态。后来我加了临界区保护,问题才解决。所以,如果你的调度器支持中断嵌套,一定要在测试用例里模拟这种并发场景。

好了,就绪列表的插入删除测试,核心就是这些。记住:指针完整性是生命线,边界条件最容易翻车。下一组测试,我们开始搞压力测试,看看调度器在高频操作下能不能扛得住。