结论先行:ztask 的全部机制是「一条按到期时刻升序的单向链表,加一个只看表头的轮询函数」。注册按插入排序入链(O(n)),轮询只判表头是否到期(O(1)),与任务总数无关;不需要堆、不需要栈切换、不需要动态内存。代价集中在四件事上:任务持续超期会让 zt_poll 不再返回;一次性任务的句柄在执行完即刻失效,此后调用 zt_unbind 会把空闲链破坏;32 位 tick 回绕时,三处时间比较用了两种口径,排序与休眠时长会算错;同一 tick 到期的任务,执行顺序由插入时的比较符决定。这四条是这套设计换来的余量,不是实现漏洞。

1. 定位

ztask 把「哪个函数在哪个 tick 执行」记在一张静态表里,由主循环反复调用一个轮询函数来分发。任务在主循环线程里被内联调用,整条链路上没有上下文切换,也没有可抢占点。

维度提供不提供
并发模型主循环内联执行任务,任务自行返回抢占、优先级、时间片
时间单调 tick、周期任务、一次性任务、下次到期时刻绝对时间、日历、超时取消保证
依赖无,一个 .h 加一个 .c内核对象、锁、信号量
中断只有 zt_tick 允许在 ISR 中调用在 ISR 中注册/注销任务、在 ISR 中 poll

适用范围由四条同时成立的假设划出来:任务数在几十个以内,注册与注销是低频事件;每个任务的执行时间远小于周期且不阻塞;只需要周期与一次性两种语义;主循环在两次到期之间能休眠。任务数只有个位数时,固定数组线性扫描比维护链表更划算;上百个任务时该换跳表或时间轮。第二条是硬边界,违反它的后果最严重,见 4.1。

flowchart TB
    subgraph TIME["时间源"]
        TMR["硬件定时器
1 tick = 1 ms"]:::time ISR["定时器 ISR
只调用 zt_tick"]:::time end subgraph CORE["调度器:无堆、无锁、无栈切换"] CTX["上下文
ticks · active_tasks · free_tasks"]:::core BIND["zt_bind / zt_unbind
只在主循环上下文调用"]:::core POLL["zt_poll
取表头,执行到期任务"]:::core NEXT["zt_ticks_to_next_task"]:::core end subgraph TASKS["任务函数:合作式,必须自己返回"] TA["任务 A"]:::task TB["任务 B"]:::task end subgraph MAIN["主循环"] LOOP["while (1):poll → 算休眠 → 睡"]:::main SLP["低功耗休眠
前提见 3.4"]:::main end TMR -->|"每 1 ms 一次"| ISR ISR -->|"ticks++"| CTX BIND -->|"取块 / 还块"| CTX LOOP --> POLL POLL -->|"读表头"| CTX POLL -->|"到期就调用"| TA POLL -->|"到期就调用"| TB POLL --> NEXT NEXT -->|"还要等多少 tick"| LOOP LOOP --> SLP classDef time fill:#FBE3E9,stroke:#D9738A,color:#5E2333; classDef core fill:#D6E4F0,stroke:#4A90D9,color:#1B3A57; classDef task fill:#D8EEDC,stroke:#6BAA75,color:#254A2E; classDef main fill:#FFF3CD,stroke:#E8A33D,color:#5C3D00;

图 1:执行拓扑。ISR 只推进 ticks;任务函数在主循环线程里被内联调用,所以整条链路上没有一次上下文切换,也没有可抢占点。

2. 数据结构与内存布局

2.1 一个上下文、两条链表

flowchart TB
    CTX["g_ztask_ctx,文件内 static 单例
ticks · active_tasks · free_tasks"]:::ctx subgraph ACT["active_tasks:按 next_schedule 升序的单向链"] direction LR A1["到期 107"]:::node --> A2["到期 250"]:::node --> A3["到期 250"]:::node --> A4["NULL"]:::nil end subgraph FREE["free_tasks:无顺序,分配取头、回收还头"] direction LR F1["空闲块"]:::node --> F2["空闲块"]:::node --> F3["NULL"]:::nil end TCB["zt_task_t,一个任务 16 B(32 位目标)/ 24 B(64 位主机)
func · repeat_ticks · next_schedule · next"]:::tcb CTX -->|"active_tasks"| A1 CTX -->|"free_tasks"| F1 A1 -.->|"链上每个节点都是同一结构体"| TCB F1 -.->|"同一块内存,两种身份"| TCB classDef ctx fill:#D6E4F0,stroke:#4A90D9,color:#1B3A57; classDef node fill:#D8EEDC,stroke:#6BAA75,color:#254A2E; classDef tcb fill:#FFF3CD,stroke:#E8A33D,color:#5C3D00; classDef nil fill:#EFEFEF,stroke:#9E9E9E,color:#424242;

图 2:数据关系。池就是这块内存切成的等长块数组,块在两条链之间来回移动,内容不做二次拷贝。

三条不变量:active_tasks 沿 next 链按 next_schedule 非递减,表头是最近的到期时刻(只在当前 tick 前后各 2^31 的窗口内成立);一块 TCB 在任一时刻只能挂在两条链之一上,free_tasks 是分配和回收的唯一来源;ticks 只由 zt_tick 递增,next_schedule 只在任务执行后按 repeat_ticks 前进。

2.2 静态池:池子大小算不出来

zt_init 把调用方给的内存按 sizeof(zt_task_t) 切块、串成 free_tasks,返回块数;zt_bind 从头取块,zt_unbind 和一次性任务结束后还块,全程没有分配器。但头文件里 struct zt_task_s 是不完整类型,调用方既不能 sizeof 它,也不能声明它的数组:原文档给的示例 static uint8_t zt_mem[MAX_TASKS * sizeof(struct zt_task_s)]; 实测报错 invalid application of 'sizeof' to incomplete type 'struct zt_task_s',按字面抄下来跑不起来。最小修法是公开一个「一块任务存储」的类型(附录 A 的 ztask.h 里的 zt_task_store_t),尺寸随指针宽度自动伸缩,并在 .c 里用两条静态断言把类型的尺寸与对齐锁死;把该类型里的 raw 改小,编译立刻失败(实测 static assertion failed: "ztask: task pool too small"),不会等到运行时才发现。用 union 而不是 uint8_t 数组做池,还顺带给出了对齐保证。

2.3 任务的生命周期

flowchart LR
    S0["池内空闲块
挂在 free_tasks 上"]:::free S1["活动任务
挂在 active_tasks 上,等到期"]:::active S2["执行中
func() 正在运行"]:::run S0 -->|"zt_bind"| S1 S1 -->|"zt_poll 取到表头且到期"| S2 S2 -->|"repeat_ticks > 0,next_schedule += repeat"| S1 S2 -->|"repeat_ticks == 0:句柄在此失效"| S0 S1 -->|"zt_unbind"| S0 S0 -.->|"对已失效句柄再 unbind:块被归还两次,见 4.1"| S1 classDef free fill:#EFEFEF,stroke:#9E9E9E,color:#424242; classDef active fill:#D8EEDC,stroke:#6BAA75,color:#254A2E; classDef run fill:#D6E4F0,stroke:#4A90D9,color:#1B3A57;

图 3:状态流转。虚线那条边是错误路径:块已经在空闲链上,再归还一次就成环。

3. 调度过程

3.1 注册

zt_bind 三步:取空闲链头、填 func / repeat_ticks / next_schedule = ticks + delay_ticks、按序插入活动链。首执行时刻由相对延时决定,相位锚定在绑定那一刻:绑定时 tick 为 7、周期 100,就在 107、207、307 执行。插入是线性扫描,成本 O(n),n 是活跃任务数而不是池容量。落点用严格小于比较:新任务停在第一个到期时刻不早于它的节点之前,因此同一 tick 到期的任务会插到已有同到期任务前面,按绑定顺序倒序执行(实测绑定 A、B、C 得到 A C B)。

3.2 轮询

sequenceDiagram
    autonumber
    participant ISR as 定时器 ISR
    participant ML as 主循环
    participant ZT as 调度器
    participant TA as 任务函数

    Note over ZT: active = [107, 250],ticks = 106
    ISR->>ZT: zt_tick,ticks 变成 107
    ML->>ZT: zt_poll
    ZT->>ZT: 只看表头,判 (ticks - next_schedule) < 2^31
    ZT->>ZT: 从链上摘下该任务
    ZT->>TA: 调用 func,内联执行,无切换
    TA-->>ZT: 自行返回
    ZT->>ZT: next_schedule += repeat,重新按序插入
    ZT->>ZT: 回到循环开头,再判表头
直到表头不再到期 ML->>ZT: zt_ticks_to_next_task ZT-->>ML: 143 ML->>ML: 休眠 143 tick,期间 ISR 继续推进 ticks

图 4:一轮调度。判定与执行在一起,所以「判到到期」之后必然执行。

到期判定用差值 (ticks - next_schedule) < UINT32_MAX / 2:把差值当有符号数看,非负即到期,因此对回绕安全,代价是周期必须小于 2^31 tick(1 ms tick 下约 24.8 天)。检查只碰表头,成本与任务总数无关,代价转移到了插入与注销。一次 zt_poll 会把所有到期任务执行完(退出条件是表头不再到期),而任务执行期间 ISR 仍在推进 ticks,3.3 的追帧现象由此而来。

3.3 时间语义:锚定在计划时刻

周期任务重排用的是 next_schedule += repeat_ticks,另一类实现(含 tomzbj 原版)记的是「上次执行时刻」:

锚定上次执行时刻(last = ticks)锚定计划时刻(next += repeat,本文)
实际周期repeat 加执行时间再加轮询延迟,逐周期累积恒等于 repeat,与执行时间无关
落后之后最多补跑一次,丢掉的周期不再补连续补跑直到追上

追帧有多直接:周期 100、停滞 350 tick 不 poll,随后一次 zt_poll 会把 3 个周期全部补上,下次到期在 50 tick 之后(见附录 C 的 V1 输出)。任务不适合补跑时,把 next_schedule += repeat_ticks 改成 next_schedule = ticks + repeat_ticks,代价是周期变成 repeat 加执行时间,且逐周期累积。

3.4 休眠链路

主循环三段:zt_poll、算休眠、睡。zt_ticks_to_next_task 返回 next_schedule - ticks(已到期返回 0),没有任务时返回 UINT32_MAX,这个哨兵值要单独处理。省电能否兑现取决于三点:tick 源在休眠期间必须继续计数(Stop 模式要选 LPTIM、RTC 这类低功耗下仍在跑的定时器,否则 ticks 与真实时间脱钩);休眠粒度就是一个 tick,真实设备上唤醒只会晚不会早,晚醒只是延后执行,不会漏执行;在 RTOS 上休眠要让出 CPU,而不是空转。

3.5 一次性任务与句柄

zt_bind 返回的句柄就是池中那块 TCB 的地址,没有槽号,也没有序号:一次性任务执行完由 zt_poll 直接还块,句柄在同一刻失效;周期任务被 zt_unbind 摘除后还块,句柄同样失效。

sequenceDiagram
    autonumber
    participant APP as 应用
    participant ZT as 调度器

    APP->>ZT: h = zt_bind(once, 0, 300)
    ZT-->>APP: h 指向池中一块,已插入活动链
    Note over APP,ZT: ticks 推进到 300
    APP->>ZT: zt_poll
    ZT->>ZT: repeat_ticks 为 0,执行后把块还回 free_tasks
    ZT-->>APP: h 仍是合法指针,但已不代表任何任务

    alt 应用把这当作普通注销
        APP->>ZT: zt_unbind(h)
该块第二次入空闲链 Note over ZT: 空闲链成环,之后两次 zt_bind 返回同一地址,见 4.1 end

图 5:句柄在任务结束同时失效。指针合法,语义已死。

4. 加固:把边界变成拒绝与计数

上面四类失效加上句柄误用,都能用很少的代码从「偶发、难复现、靠看门狗兜底」改成「当场拒绝,计数器可见」。加固动的是 4.1 列出的几处,任务池类型与 TCB 布局一个字没动;完整清单、演示程序与验证脚本见文末附录。

4.1 加固机制

机制原版行为加固后实测
派发重入回调里再调 zt_poll,同一个函数重入改写链表in_poll 守卫,重入直接拒绝并计数3 次重入全部被拒,随后派发照常
本轮时间基准每轮判断都重读 ticks,任务执行期间新到期的任务在本轮内继续执行进入时冻结 now,本轮只派发进入时已到期的任务一轮的派发集合与进入时一致
回调内绑定回调里 zt_bind 的新任务可能在本轮就被派发回调内且 delay_ticks == 0 时首执行落在 ticks + 1当轮 0 次、下一轮 1 次
一轮预算表头一直到期就不返回ZT_POLL_BUDGET(默认 8),打满即返回并计数原版一次 poll 连跑 100 001 次;加固版每轮最多 8 次,每次 poll 都返回

中间两条针对同一件事:一次回调从开始执行到返回,中间不应该再插进第二个任务的执行。派发循环会接着取下一个到期任务,回调里新注册的任务也可能立刻被执行(该约束的完整论述见 5 节参考)。

防护做法实测
野句柄用 uintptr_t 做池范围与块边界校验,不用跨对象指针关系比较池外、未对齐、非块起点三类各拒 1 次,调度器仍自洽
陈旧句柄块不在活动链上就不归还拒绝 1 次;随后两次 zt_bind 返回不同块
自注销记录正在执行的块,回调里 zt_unbind 自身句柄时把 repeat_ticks 置 0,本轮结束由 zt_poll 还块第 3 次运行后停止,块被回收
超期单次执行不短于周期即记一次369 次,从「看门狗复位」变成可上报的计数器

4.2 代价与语义变化

项原版加固版
代码(Cortex-M4,-Os,arm-none-eabi-gcc)312 B508 B
常驻数据(同上)12 B52 B
每任务 RAM(32 位目标)16 B16 B,未变
空表的 zt_poll 调用(x86-64,-O1)1.0 ns2.1 ns

多出的 40 字节是守卫标志、池边界和 6 个计数器,一次性占用,每任务多 0 字节;加固没有引入锁、没有关中断、没有动态内存。替换原版时,这四条要写进接口约定:

  1. 回调内 zt_bind(func, repeat, 0) 的首执行落在下一轮 poll,不再可能落在当轮。
  2. 一轮最多派发 ZT_POLL_BUDGET 个任务;打满时主循环应立刻再调一次 zt_poll,此时 zt_ticks_to_next_task 返回 0,不会误睡。
  3. zt_unbind 遇到不合法句柄的行为从「破坏链表」变成「拒绝并累加 rejected_unbinds」,调用方可以据此判定手里的句柄已失效。
  4. 自注销要求任务保存自己的句柄;不保存句柄的任务只能用 repeat_ticks == 0 的一次性形式。

5. 参考

主题来源
单次处理不被打断的完整论述(重入约束)本博客《MCU 周期任务服务的并发三件套:RMW、RTC 与 AOP》
原始实现(固定数组 + 线性扫描)tomzbj/ztask,github.com/tomzbj/ztask
无符号差值比较的写法Linux 内核 include/linux/jiffies.h 的 time_before()
按到期时刻排序的定时器链表FreeRTOS timers.c
用跳表组织定时器RT-Thread src/timer.c
时间轮Linux 内核 kernel/time/timer.c

附录

附录 A:完整清单

ztask.h:

#ifndef ZTASK_H
#define ZTASK_H

#ifdef __cplusplus
extern "C" {
#endif

#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>

/**
 * @brief Opaque handle to a task structure.
 */
struct zt_task_s;
typedef struct zt_task_s* zt_task_handle_t;

/**
 * @brief Function pointer type for a task.
 */
typedef void (*zt_func_t)(void);

/**
 * @brief Storage for one task. Callers define their pool as an array of this
 * type: the size follows the pointer width of the target (16 bytes on a
 * 32-bit MCU, 24 bytes on a 64-bit host) and the union supplies alignment.
 */
typedef union zt_task_store_u {
    struct zt_task_s *next;
    void             *p;
    uint32_t          u32;
    uint8_t           raw[2u * sizeof(void *) + 8u];
} zt_task_store_t;

/**
 * @brief Diagnostic counters. Read-only for the caller.
 */
typedef struct {
    uint32_t dispatches;          /**< Task dispatches completed. */
    uint32_t overruns;            /**< Dispatches whose run time reached the period. */
    uint32_t budget_hits;         /**< Poll calls that stopped on the per-call budget. */
    uint32_t rejected_polls;      /**< Re-entrant zt_poll calls refused. */
    uint32_t rejected_unbinds;    /**< zt_unbind calls refused (foreign/stale handle). */
    uint32_t deferred_first_runs; /**< Tasks bound inside a callback, first run deferred. */
} zt_stats_t;

/** Maximum number of tasks one zt_poll call may dispatch. */
#ifndef ZT_POLL_BUDGET
#define ZT_POLL_BUDGET 8u
#endif

int32_t zt_init(void *zt_mem, uint32_t size);
zt_task_handle_t zt_bind(zt_func_t func, uint32_t repeat_ticks, uint32_t delay_ticks);
void zt_unbind(zt_task_handle_t task);
void zt_poll(void);
void zt_tick(void);
uint32_t zt_ticks_to_next_task(void);
uint32_t zt_get_ticks(void);

/** Counters snapshot. Never NULL. */
const zt_stats_t *zt_stats(void);

/** Clears the counters. */
void zt_stats_reset(void);

#ifdef __cplusplus
}
#endif

#endif /* ZTASK_H */

ztask.c:

/*
 * ztask.c - Cooperative periodic-task scheduler, with dispatch guards and
 * handle validation.
 *
 * What the hardening buys, per mechanism:
 *   re-entry guard   - a callback may not call zt_poll again; refused and
 *                      counted.
 *   frozen time base - one pass dispatches from one time base; tasks that come
 *                      due *during* the pass wait for the next call.
 *   deferred bind    - a task bound inside a callback is not dispatched in the
 *                      same pass (its first run lies past the frozen base).
 *   budget           - a pass dispatches at most ZT_POLL_BUDGET tasks, so an
 *                      overrunning task can no longer own the main loop.
 *   overrun counter  - a dispatch that took at least one period is counted.
 *   handle checks    - a foreign, misaligned or stale handle is refused instead
 *                      of being linked into the free list twice.
 *   self-cancel      - a task may unbind itself from inside its own callback.
 *
 * The task control block keeps its four 32-bit fields; every addition lives in
 * the scheduler context, so per-task RAM is unchanged at 16 bytes (32-bit).
 */

#include "ztask.h"

typedef struct zt_task_s {
    zt_func_t func;
    uint32_t  repeat_ticks;
    uint32_t  next_schedule;
    struct zt_task_s *next;
} zt_task_t;

_Static_assert(ZT_POLL_BUDGET > 0u, "ztask: poll budget must be non-zero");
_Static_assert(sizeof(zt_task_t) <= sizeof(zt_task_store_t), "ztask: task pool too small");
_Static_assert(_Alignof(zt_task_t) <= _Alignof(zt_task_store_t), "ztask: task pool misaligned");

static struct {
    volatile uint32_t ticks;
    zt_task_t *active_tasks;
    zt_task_t *free_tasks;
    zt_task_t *running;        /* block of the callback in progress, else NULL */
    const uint8_t *pool_base;
    const uint8_t *pool_end;
    uint8_t    in_poll;
    zt_stats_t stats;
} g_ztask_ctx;

/* --- Internal helpers --- */

/* Modular order: true when a lies before b within half of the tick range.
 * This is the only comparison the scheduler is allowed to use on tick values;
 * plain < and > mis-sort the list and mis-compute the sleep length across the
 * 32-bit wrap. */
static bool zt_before(const uint32_t a, const uint32_t b)
{
    return ((int32_t)(a - b) < 0);
}

/* A handle is usable when it points at the start of a block inside our pool. */
static bool zt_handle_ok(const zt_task_t *task)
{
    const uintptr_t handle = (uintptr_t)task;
    const uintptr_t base   = (uintptr_t)g_ztask_ctx.pool_base;

    if ((handle < base) || ((handle - base) % sizeof(zt_task_t) != 0u)) { return false; }
    if (handle + sizeof(zt_task_t) > (uintptr_t)g_ztask_ctx.pool_end) { return false; }
    return true;
}

static void zt_insert_task(zt_task_t * const task)
{
    if ( (g_ztask_ctx.active_tasks == NULL) ||
         zt_before(task->next_schedule, g_ztask_ctx.active_tasks->next_schedule) )
    {
        task->next = g_ztask_ctx.active_tasks;
        g_ztask_ctx.active_tasks = task;
    }
    else
    {
        zt_task_t *current = g_ztask_ctx.active_tasks;
        while ( (current->next != NULL) &&
                zt_before(current->next->next_schedule, task->next_schedule) )
        {
            current = current->next;
        }
        task->next = current->next;
        current->next = task;
    }
}

/* Unlinks task from the active list. Returns false when it is not there. */
static bool zt_list_remove(zt_task_t * const task)
{
    if (g_ztask_ctx.active_tasks == task)
    {
        g_ztask_ctx.active_tasks = task->next;
        return true;
    }
    else
    {
        zt_task_t *current = g_ztask_ctx.active_tasks;
        while ((current != NULL) && (current->next != task))
        {
            current = current->next;
        }
        if (current == NULL)
        {
            return false;
        }
        current->next = task->next;
        return true;
    }
}

static void zt_release_block(zt_task_t * const task)
{
    task->next = g_ztask_ctx.free_tasks;
    g_ztask_ctx.free_tasks = task;
}

/* --- Public API --- */

int32_t zt_init(void * const zt_mem, const uint32_t size)
{
    uint32_t num_tasks;
    uint8_t *mem_ptr;
    zt_task_t *current;
    uint32_t i;

    if ( (zt_mem == NULL) || (size < sizeof(zt_task_t)) )
    {
        return -1;
    }

    mem_ptr   = (uint8_t *)zt_mem;
    num_tasks = size / (uint32_t)sizeof(zt_task_t);

    g_ztask_ctx.ticks        = 0U;
    g_ztask_ctx.active_tasks = NULL;
    g_ztask_ctx.free_tasks   = (zt_task_t *)mem_ptr;
    g_ztask_ctx.running      = NULL;
    g_ztask_ctx.pool_base    = mem_ptr;
    g_ztask_ctx.pool_end     = mem_ptr + (num_tasks * sizeof(zt_task_t));
    g_ztask_ctx.in_poll      = 0U;
    zt_stats_reset();

    current = g_ztask_ctx.free_tasks;
    for (i = 0U; i < (num_tasks - 1U); ++i)
    {
        mem_ptr += sizeof(zt_task_t);
        current->next = (zt_task_t *)mem_ptr;
        current = current->next;
    }
    current->next = NULL;

    return (int32_t)num_tasks;
}

void zt_tick(void)
{
    g_ztask_ctx.ticks++;
}

uint32_t zt_get_ticks(void)
{
    return g_ztask_ctx.ticks;
}

const zt_stats_t *zt_stats(void)
{
    return &g_ztask_ctx.stats;
}

void zt_stats_reset(void)
{
    g_ztask_ctx.stats.dispatches          = 0U;
    g_ztask_ctx.stats.overruns            = 0U;
    g_ztask_ctx.stats.budget_hits         = 0U;
    g_ztask_ctx.stats.rejected_polls      = 0U;
    g_ztask_ctx.stats.rejected_unbinds    = 0U;
    g_ztask_ctx.stats.deferred_first_runs = 0U;
}

zt_task_handle_t zt_bind(zt_func_t const func, const uint32_t repeat_ticks, const uint32_t delay_ticks)
{
    zt_task_t *new_task;
    uint32_t first_delay = delay_ticks;

    if (func == NULL)
    {
        return NULL;
    }

    new_task = g_ztask_ctx.free_tasks;
    if (new_task == NULL)
    {
        return NULL;                      /* pool exhausted */
    }
    g_ztask_ctx.free_tasks = new_task->next;

    /* A task bound from inside a callback must not be dispatched in the same
     * pass: its first due time is pushed past the pass's frozen base. */
    if ( (g_ztask_ctx.in_poll != 0U) && (first_delay == 0U) )
    {
        first_delay = 1U;
        g_ztask_ctx.stats.deferred_first_runs++;
    }

    new_task->func          = func;
    new_task->repeat_ticks  = repeat_ticks;
    new_task->next_schedule = g_ztask_ctx.ticks + first_delay;
    zt_insert_task(new_task);

    return new_task;
}

void zt_unbind(zt_task_handle_t const task)
{
    zt_task_t * const to_remove = (zt_task_t *)task;

    if (task == NULL)
    {
        return;
    }
    if (!zt_handle_ok(to_remove))
    {
        g_ztask_ctx.stats.rejected_unbinds++;   /* foreign or misaligned pointer */
        return;
    }

    /* Self-cancellation from inside the callback: the block is not on the active
     * list at this moment (zt_poll already popped it). Marking it as one-shot
     * makes zt_poll return it to the pool once the callback has returned. */
    if (to_remove == g_ztask_ctx.running)
    {
        to_remove->repeat_ticks = 0U;
        return;
    }

    /* A stale handle - one whose block was already returned - is refused; the
     * block is never linked into the free list twice. */
    if (!zt_list_remove(to_remove))
    {
        g_ztask_ctx.stats.rejected_unbinds++;
        return;
    }

    zt_release_block(to_remove);
}

void zt_poll(void)
{
    uint32_t now;
    uint8_t  budget = (uint8_t)ZT_POLL_BUDGET;

    if (g_ztask_ctx.in_poll != 0U)
    {
        g_ztask_ctx.stats.rejected_polls++;     /* re-entry guard */
        return;
    }
    g_ztask_ctx.in_poll = 1U;

    now = g_ztask_ctx.ticks;                    /* one time base for the pass */

    while (budget > 0U)
    {
        zt_task_t *task = g_ztask_ctx.active_tasks;
        uint32_t started;

        if (task == NULL)
        {
            break;
        }
        if (zt_before(now, task->next_schedule))
        {
            break;                              /* head is not due yet */
        }

        g_ztask_ctx.active_tasks = task->next;
        task->next = NULL;

        g_ztask_ctx.running = task;
        started = g_ztask_ctx.ticks;
        task->func();                           /* the callback must return */
        g_ztask_ctx.running = NULL;

        g_ztask_ctx.stats.dispatches++;

        if (task->repeat_ticks > 0U)
        {
            if ((g_ztask_ctx.ticks - started) >= task->repeat_ticks)
            {
                g_ztask_ctx.stats.overruns++;   /* the task did not complete in time */
            }
            task->next_schedule += task->repeat_ticks;
            zt_insert_task(task);
        }
        else
        {
            zt_release_block(task);
        }

        budget--;
    }

    if ( (budget == 0U) && (g_ztask_ctx.active_tasks != NULL) &&
         (!zt_before(now, g_ztask_ctx.active_tasks->next_schedule)) )
    {
        g_ztask_ctx.stats.budget_hits++;        /* more due work waits for the next call */
    }

    g_ztask_ctx.in_poll = 0U;
}

uint32_t zt_ticks_to_next_task(void)
{
    const zt_task_t *task = g_ztask_ctx.active_tasks;
    uint32_t next_event_in;

    if (task == NULL)
    {
        next_event_in = UINT32_MAX;
    }
    else
    {
        const uint32_t now    = g_ztask_ctx.ticks;
        const uint32_t remain = task->next_schedule - now;

        /* Same convention as the due test in zt_poll: a remainder below 2^31
         * means "still ahead", anything else means "already due". */
        next_event_in = (remain < (UINT32_MAX / 2U)) ? remain : 0U;
    }

    return next_event_in;
}

附录 B:演示程序与实测输出

主循环的三段结构都在:推进 ticks(模拟 1 ms 中断)、poll、按算出的时长休眠。ticks 从 0 开始,与真实设备一致;时钟源用单调时钟,避免把系统时间直接当 tick 总数喂进去,否则绑定时的 100 tick 延时早就过期,第一个 zt_poll 会先补跑上千万个周期。

#include <stdio.h>
#include <time.h>
#include "ztask.h"

#define ZT_MAX_TASKS 8
static zt_task_store_t  zt_pool[ZT_MAX_TASKS];
static zt_task_handle_t h_report;
static zt_task_handle_t h_sample;
static uint32_t         t0_ms;

static uint32_t now_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint32_t)(ts.tv_sec * 1000u + ts.tv_nsec / 1000000u);
}

static void task_sample(void) { printf("[%4u] sample\n", zt_get_ticks()); }
static void task_report(void) { printf("[%4u] report\n", zt_get_ticks()); }
static void task_once(void)   { printf("[%4u] once\n",   zt_get_ticks()); }
static void task_stop(void)
{
    printf("[%4u] stop: unbind report\n", zt_get_ticks());
    zt_unbind(h_report);
    h_report = NULL;                       /* the handle is dead after this */
}

int main(void)
{
    t0_ms = now_ms();
    zt_init(zt_pool, sizeof(zt_pool));     /* ticks start at 0, as on a real MCU */

    h_sample = zt_bind(task_sample, 100, 100);
    h_report = zt_bind(task_report, 250, 250);
    zt_bind(task_once, 0, 300);            /* one-shot, handle not kept */
    zt_bind(task_stop, 0, 800);

    while (zt_get_ticks() < 1000u) {
        const uint32_t elapsed = now_ms() - t0_ms;
        while (zt_get_ticks() < elapsed) { zt_tick(); }   /* stand-in for the 1 ms ISR */

        zt_poll();

        const uint32_t sleep_ticks = zt_ticks_to_next_task();
        if (sleep_ticks == UINT32_MAX) { printf("no task left\n"); break; }
        if (sleep_ticks > 0u) {
            struct timespec req = { 0, (long)sleep_ticks * 1000000L };
            nanosleep(&req, NULL);
        }
    }

    zt_unbind(h_sample);
    printf("[%4u] unbind sample, ticks_to_next_task = %u (UINT32_MAX = %u)\n",
           zt_get_ticks(), zt_ticks_to_next_task(), UINT32_MAX);
    return 0;
}

编译运行 gcc -std=gnu11 -Wall -Wextra -O1 -o demo demo.c ztask.c && ./demo,实测输出(记录自一次运行;tick 打印值随主机调度有 1 到 2 tick 的浮动,任务顺序不变):

[ 101] sample
[ 200] sample
[ 250] report
[ 300] sample
[ 300] once
[ 400] sample
[ 500] report
[ 500] sample
[ 600] sample
[ 700] sample
[ 750] report
[ 800] sample
[ 800] stop: unbind report
[ 901] sample
[1000] sample
[1000] unbind sample, ticks_to_next_task = 4294967295 (UINT32_MAX = 4294967295)

附录 C:验证脚本与两版对照

下面这段不依赖时钟,手工推进 tick,复现 3.3 的追帧(V1)与 4.1 提到的重复归还(V4);同一份脚本分别链原实现与加固版,输出如下。

#include <stdio.h>
#include <stdint.h>
#include "ztask.h"

#define ZT_MAX_TASKS 8
static zt_task_store_t  zt_pool[ZT_MAX_TASKS];

static int  runs;
static void task_periodic(void) { runs++; }
static void task_noop(void) {}

int main(void)
{
    /* V1: a 350-tick stall against a 100-tick period */
    zt_init(zt_pool, sizeof(zt_pool));
    zt_bind(task_periodic, 100, 100);
    while (zt_get_ticks() < 350U) { zt_tick(); }
    zt_poll();
    printf("V1  stall 350 ticks, period 100 -> runs in one poll = %d, next due in %u ticks\n",
           runs, zt_ticks_to_next_task());

    /* V4: unbinding a one-shot task again after it has run */
    zt_init(zt_pool, sizeof(zt_pool));
    zt_task_handle_t h = zt_bind(task_noop, 0, 1);
    zt_tick(); zt_poll();            /* runs once, the block goes back to the pool */
    zt_unbind(h);                    /* the handle is stale */
    zt_task_handle_t p = zt_bind(task_noop, 0, 1000);
    zt_task_handle_t q = zt_bind(task_noop, 0, 1000);
    printf("V4  two binds after the stale unbind -> %s\n",
           (p == q) ? "same block (aliased)" : "two distinct blocks");
    return 0;
}
V1  stall 350 ticks, period 100 -> runs in one poll = 3, next due in 50 ticks
V4  two binds after the stale unbind -> same block (aliased)
V1  stall 350 ticks, period 100 -> runs in one poll = 3, next due in 50 ticks
V4  two binds after the stale unbind -> two distinct blocks