Xenomai 实时系统系列(三):Cobalt POSIX 线程醒了,为什么还没轮到它?

第二章讲了实时核心如何接进 Linux。这一章把镜头移到 Cobalt 的 POSIX 皮肤:跟着一条 1 ms 控制线程,看看 pthread 怎样进入 Cobalt、怎样排队和运行,以及为什么线程醒了也可能赶不上截止时间。

1. “我醒了”和“我上班了”,中间还隔着一个调度器

早上七点闹钟响了,你睁开眼睛。老板能据此认定你已经坐在工位上吗?

当然不能。醒来、出门、到公司,是三件事。

实时线程也一样。定时器到点,线程被唤醒,只代表它有机会参与调度;CPU 是否马上交给它,还要看现在谁在运行、有没有更高优先级的工作,以及是否到了允许切换的时机。

唤醒让线程重新具备运行资格,调度决定它什么时候真正运行。

先把线程的主要状态画出来:

实时线程从创建、就绪到运行、等待和退出的状态变化

图 1:这是教学状态图。被抢占的线程通常仍然就绪;等待定时器或同步对象的线程则暂时不能参与 CPU 竞争。

这张图里最值得分清的是三个状态:

状态 线程此刻在做什么 能否被调度运行
就绪 READY 条件已经满足,正在等 CPU 可以,但还没选中它
运行 RUNNING 正在 CPU 上执行指令 已经选中它
等待 WAIT 等时间到、等数据,或等同步条件满足 条件满足前不参与竞争

Cobalt 内部可能同时记录多个阻塞条件;解除其中一个,并不保证线程立刻就绪。要等仍然阻止它运行的条件都消失,才能重新参与调度。这也是官方线程服务把“恢复资格”和“实际切换”分开处理的原因。Cobalt 线程服务

这一章以 Xenomai 3 Cobalt/POSIX、普通固定优先级实时线程为主,先讨论同一个 CPU,假定没有额外的调度锁。Alchemy 是 Xenomai 的另一套应用 API,文中只作为对照;无论使用哪套 API,底层实时调度仍由 Cobalt 完成。

2. POSIX pthread 怎样进入 Cobalt?

使用 POSIX 皮肤时,应用代码看起来像普通 Linux 程序:

pthread_create()
pthread_attr_setschedpolicy()
pthread_attr_setschedparam()
clock_nanosleep()
pthread_mutex_lock()

但程序必须链接 Xenomai 的 POSIX 包装库,相关 POSIX 服务才会被导向 Cobalt:

gcc periodic.c -o periodic \
    $(xeno-config --posix --cflags --ldflags)

pthread_create() 创建的是线程;是否使用实时调度策略,则由线程属性决定:

pthread_attr_t attr;
struct sched_param param;
pthread_t thread;

pthread_attr_init(&attr);
pthread_attr_setinheritsched(
    &attr, PTHREAD_EXPLICIT_SCHED);
pthread_attr_setschedpolicy(
    &attr, SCHED_FIFO);

param.sched_priority = 80;
pthread_attr_setschedparam(&attr, &param);

pthread_create(&thread, &attr, control_thread, NULL);
pthread_attr_destroy(&attr);

这里有两个容易混淆的点:

  • pthread_create() 只负责创建线程,不代表线程已经运行;
  • SCHED_FIFO 只描述调度策略和优先级,不代表线程一定立即获得 CPU。

实时线程启动前,应准备好共享配置、设备句柄和缓冲区。运行期间尽量避免缺页、动态分配、无界日志、文件 I/O 和不可控网络调用;常见做法是在创建实时线程前调用:

mlockall(MCL_CURRENT | MCL_FUTURE);

如果 pthread_create() 返回 EPERM,还要检查 CAP_SYS_NICE、RLIMIT_RTPRIO 和 RLIMIT_MEMLOCK。

Alchemy 的对应写法是 rt_task_create() 加 rt_task_start(),周期调用则是 rt_task_set_periodic() 加 rt_task_wait_period()。它们与本章的 POSIX 接口不同,但最终仍由 Cobalt 的同一套实时调度核心执行。Cobalt/POSIX 接口Alchemy 任务管理

3. CPU 给谁?先看就绪,再看优先级

我们安排两名主角:

  • H:控制线程,优先级 80。
  • L:后台实时计算线程,优先级 40。

两者都由 Cobalt 管理,使用固定优先级策略,并且只能在同一个 CPU 上运行。这里数值越大,实时优先级越高。L 是一条低优先级实时线程,普通 Linux 活动另画一条线。

当 H 正在等下一周期时,即使它的优先级是 80,也不会占着 CPU。L 可以先干活。

等 H 被唤醒、变成就绪,调度器在允许调度的时机选择 H;L 暂停,但仍保留执行现场和运行资格。H 完成这一轮并等待后,L 接着干。只有该 CPU 上没有需要运行的带外线程或其他带外工作,Linux 才获得执行机会。

高优先级线程唤醒后抢占低优先级线程,阻塞后低优先级线程恢复

图 2:优先级决定就绪线程之间的次序。正在等待的 H 不会因为优先级高,就隔空霸占 CPU。时间段只表示先后关系。

Cobalt 的调度信息按 CPU 组织。常见的重新选择时机包括:当前线程阻塞、更高优先级线程变为可运行、优先级改变,或轮转规则要求换人。内部 xnsched_run() 负责应用调度状态变化;它也可能发现当前线程仍然合适,于是什么都不切。Cobalt 调度核心

因此,中断处理程序唤醒 H,并不意味着就在中断处理函数的中间跳进 H 的用户代码。更新线程状态与执行上下文切换,是相邻但不同的步骤。普通应用使用任务、定时器和同步 API 即可,不需要自己调用内核里的 xnsched_run()

这里还有两个边界:

第一,80 对 40 的比较有前提。它比较的是这组 Cobalt 固定优先级线程,不能拿来和 Linux 的 nice 值或 Linux 实时优先级直接混排。Cobalt 与带内 Linux 的先后关系属于另一层机制。Cobalt 与 Linux 的协同关系

第二,多核没有一张“全机所有线程排同一队”的简单图。其他 CPU 可以同时运行任务;亲和性、迁移和跨核唤醒都要算进去。本文先固定一个 CPU,目的是把因果关系看清楚。

4. FIFO 和 RR:同样优先级,谁先来谁一直干?

假设 A 和 B 都是优先级 60。

使用 SCHED_FIFO 时,A 正在运行,B 只是变为就绪,并不会因为“也等了一会儿”就自动获得一个时间片。A 阻塞、退出或主动轮转,B 才可能接手;更高优先级线程仍然可以抢占它们。

SCHED_RR 则在同优先级线程之间加入时间片轮转:本次时间片用完,轮到同组中的下一个就绪线程。RR 的公平范围主要是同优先级,不是所有线程大锅饭。Cobalt 支持 FIFO、RR 等策略,并在实时调度类内提供轮转机制。Cobalt 调度接口实时调度类的轮转

同优先级线程在 FIFO 与 RR 下的运行时间线

图 3:FIFO 不自动分配轮转时间片;RR 在同优先级之间轮转。两种情况下,更高优先级线程就绪都可能改变时间线。

有个看起来很礼貌、其实可能毫无帮助的写法:

for (;;) {
    check_something();
    sched_yield();
}

你觉得:“我每轮都主动让路,还不够谦让吗?”

但 Cobalt 的 sched_yield() 主要是把自己移到本优先级组的队尾。如果没有同级竞争者,自己仍可能马上继续运行;低优先级线程和带内 Linux 不会因此得到固定份额。Cobalt 的主动让出语义

没事做的时候,应该等待真实的事件、定时器或同步对象。忙轮询加 yield(),不能自动变成一个合格的周期任务。

5. 每隔 1 ms 执行,为什么不能“干完再睡 1 ms”?

假设控制计算和输出花了 200 μs,随后再相对睡眠 1 ms。先忽略其他开销,下一轮开始已经是 1.2 ms 以后了。

如果这轮工作花 300 μs,间隔就变成 1.3 ms。你原本要一位按点报时的钟表匠,最后请来了一位“忙完再说”的临时工。

周期任务通常需要一组固定的计划释放时刻:

t₀、t₀+T、t₀+2T、t₀+3T……

这一轮早做完,就等到下一个计划点;这一轮晚开始,也不会因此把后面所有计划点一起往后推。这样能避免“工作时间+相对睡眠”造成的累积漂移,但不能消灭实际唤醒抖动。

工作后相对睡眠造成漂移,与固定周期释放点的对比

图 4:固定的是计划时间线,实际运行仍可能晚到。图中数值是构造例子,不是 Xenomai 性能测试。

5.1 POSIX 实现:等一个时刻,不是睡一段时间

clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...) 的第三个参数是计划醒来的绝对时刻。每轮把计划点加一个周期,时间线就不会被本轮工作耗时不断推后。

下面是可以接入第 2 节 pthread 的周期线程主循环。示例运行 1000 轮,control_step() 由业务实现;统计结构传给线程,主线程在 pthread_join() 后读取。这里选择“跳过过时释放点”,不连续补算。

#include <errno.h>
#include <stdint.h>
#include <time.h>

struct periodic_stats {
    uint64_t missed_releases;
    int error;
};

static uint64_t to_ns(const struct timespec *t)
{
    return (uint64_t)t->tv_sec * 1000000000ULL
           + (uint64_t)t->tv_nsec;
}

static struct timespec to_timespec(uint64_t ns)
{
    struct timespec t = {
        .tv_sec = (time_t)(ns / 1000000000ULL),
        .tv_nsec = (long)(ns % 1000000000ULL)
    };
    return t;
}

extern void control_step(void);

static void *control_thread(void *arg)
{
    struct periodic_stats *stats = arg;
    const uint64_t period_ns = 1000000; /* 1 ms */
    struct timespec now;

    if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
        stats->error = errno;
        return NULL;
    }
    uint64_t release_ns = to_ns(&now) + period_ns;

    for (unsigned int n = 0; n < 1000; ++n) {
        struct timespec release = to_timespec(release_ns);
        int ret;
        do {
            ret = clock_nanosleep(CLOCK_MONOTONIC,
                                 TIMER_ABSTIME,
                                 &release, NULL);
        } while (ret == EINTR);
        if (ret != 0) {
            stats->error = ret;
            return NULL;
        }

        control_step();

        if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
            stats->error = errno;
            return NULL;
        }
        release_ns += period_ns;
        uint64_t now_ns = to_ns(&now);
        if (release_ns <= now_ns) {
            uint64_t skipped =
                (now_ns - release_ns) / period_ns + 1;
            stats->missed_releases += skipped;
            release_ns += skipped * period_ns;
        }
    }
    return NULL;
}

创建时把第 2 节示例的最后一个参数改为统计结构地址,并在线程结束后检查结果:

struct periodic_stats stats = {0};
int ret = pthread_create(&thread, &attr,
                         control_thread, &stats);
if (ret != 0) {
    /* 创建失败:此时尚未进入实时循环,按 ret 处理错误。 */
} else {
    ret = pthread_join(thread, NULL);
    /* ret == 0 后读取 stats.error 和 stats.missed_releases。 */
}

这段代码有三个细节:纳秒转 timespec 时要进位;被信号打断后仍等待同一个绝对时刻clock_nanosleep() 直接返回错误码,不能写成“返回 -1,再看 errno”。绝对时刻已经过去时,调用不会替你再等一个周期,因此过期后的补算或跳过策略要由应用确定。Cobalt POSIX 时间服务

示例用“当前时间+1 ms”建立单线程起点;多任务需要统一时间时,把它换成事先约定的 epoch_ns + offset_ns。各线程保留自己的周期索引,即可共享同一条逻辑时间线。

作为对照,Alchemy 用 rt_task_set_periodic() 设置周期时间线,用 rt_task_wait_period() 等待释放点。Xenomai 3 的 set_periodic 不负责首次等待。Xenomai 2 → 3 迁移说明

5.2 超周期,该补作业还是交白卷?

rt_task_wait_period() 返回 -ETIMEDOUT,表示发生周期定时器 overrun,任务错过了此前的释放点;传入的计数指针可取得待报告的 overrun 数。该输出在成功或 -ETIMEDOUT 时有效,其他错误不能拿它当新结果。wait_period 返回值

这不是一句“打印警告继续跑”就结束的事情。对控制算法而言,可能有不同处理:

处理方式 含义 要想清楚的问题
跳过过时工作 使用最新输入,只做当前需要的一轮 算法能否接受时间间隔变化
有限补偿 在规定次数内补算遗漏步骤 补算会不会把下一轮也拖晚
降级或停机 交给预设的故障处理流程 输出状态和恢复条件是什么

这些是应用层策略,周期 API 不会替你判断。特别是读取传感器的任务,过去那一刻的数据如果从未采到,连续补调用几次函数,也不会把历史补回来。

还要分清两种“超时”:错过周期释放点,与输出错过业务截止时间,不是同一项指标。假设周期 1 ms,要求输出在释放后 300 μs 内完成;这一轮花了 500 μs,虽然还没跨到下个周期,业务已经迟到了。

5.3 用 POSIX 记录 runtime、延迟和 overrun

定时器只负责唤醒,运行时间和超期统计由应用自己记录。至少保存三个时间点:

计划释放时间:release
实际开始时间:start
执行结束时间:finish

由此得到:

唤醒延迟 = start - release
本轮墙钟耗时 = finish - start
端到端时间 = 输出完成时间 - release

这里的 finish - start 是实际经过的时间,包含期间的抢占、等待和阻塞,不是线程独占 CPU 的净计算时间。如果项目把它称为 runtime 或 TET,应先把这个口径写清楚。下面也是按这个口径统计,辅助函数为示意;要测线程实际消耗的 CPU 时间,应另用线程 CPU 时钟等计量方式,并确认目标版本支持。

struct timespec start, finish;

clock_gettime(CLOCK_MONOTONIC, &start);
control_step();
clock_gettime(CLOCK_MONOTONIC, &finish);

uint64_t tet_ns = timespec_diff_ns(&start, &finish);

if (tet_ns > period_ns)
    task->tet_overrun_count++;

if (timespec_after(&start, &release))
    task->release_late_count++;

这里要区分:

指标 含义
TET overrun 按本例口径,本轮墙钟耗时超过周期
Release late 实际开始时间晚于计划释放点
Missed release 执行结束时已经越过后续释放点
Deadline miss 输出完成超过业务截止时间

clock_nanosleep() 不会自动返回错过了几个周期。可以用整数纳秒维护统一逻辑时间:

release_ns = epoch_ns + offset_ns
             + release_index * period_ns;

执行完成后,如果下一个释放点已经过去,就统计并跳过过时释放点:

if (next_release_ns <= now_ns) {
    uint64_t skipped =
        (now_ns - next_release_ns) / period_ns + 1;
    missed_releases += skipped;
    release_index += skipped;
    next_release_ns = epoch_ns + offset_ns
                      + release_index * period_ns;
}

这里跳到严格晚于采样时刻的下一个计划点,并用一次计算完成跳过,避免严重落后时循环补数。该策略适合允许舍弃过时工作的任务;具体任务是否可以丢步,仍要由算法和项目要求决定。

5.4 POSIX timer 是另一种实现

还可以使用 timer_create()timer_settime()sigwaitinfo()。这里必须注意 Cobalt 的实现边界:显式通知方式支持 SIGEV_THREAD_ID,不能照搬普通 Linux 的 SIGEV_SIGNAL 示例。更简洁的写法是把通知参数设为 NULL,由创建定时器的当前 Cobalt 线程接收 SIGALRM,并在同一线程里同步等待它。

#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <time.h>

/* 在接收通知的 Cobalt pthread 中调用;返回 0 或错误码。 */
static int run_timer(uint64_t *extra_expirations)
{
    sigset_t set;
    if (sigemptyset(&set) == -1 ||
        sigaddset(&set, SIGALRM) == -1)
        return errno;
    int mask_error = pthread_sigmask(SIG_BLOCK, &set, NULL);
    if (mask_error != 0)
        return mask_error;

    timer_t timerid;
    if (timer_create(CLOCK_MONOTONIC, NULL, &timerid) == -1)
        return errno;

    struct itimerspec its = {0};
    its.it_value.tv_nsec = 1000000;    /* 首次 1 ms */
    its.it_interval.tv_nsec = 1000000; /* 周期 1 ms */
    int error = 0;
    if (timer_settime(timerid, 0, &its, NULL) == -1) {
        error = errno;
        goto out;
    }

    for (unsigned int n = 0; n < 1000; ++n) {
        siginfo_t info;
        int signo;
        do {
            signo = sigwaitinfo(&set, &info);
        } while (signo == -1 && errno == EINTR);
        if (signo == -1) {
            error = errno;
            break;
        }
        int overrun = timer_getoverrun(timerid);
        if (overrun == -1) {
            error = errno;
            break;
        }
        *extra_expirations += (uint64_t)overrun;
        control_step(); /* 或由这里释放到期的任务 */
    }
out:
    if (timer_delete(timerid) == -1 && error == 0)
        error = errno;
    return error;
}

timer_getoverrun() 统计的是最近一次定时器通知以来积压的到期次数;它不是任务实际开始晚了多少,也不是输出是否错过业务截止时间。后两类指标仍要用实际时间戳计算。

Cobalt 的这类通知由接收线程调用 sigwait()sigtimedwait()sigwaitinfo() 来取得。上述例子不是异步 Linux 信号处理函数,也没有创建回调线程。SIGEV_THREAD 不属于 Cobalt timer_create() 支持的通知方式;如果应用使用了回退到普通 Linux 的路径,就必须另行评估它的调度与延迟。Cobalt POSIX 时间服务

6. 线程阻塞了,是不是就掉回 Linux?

不是。这里容易把两个不同的问题混在一起:

  • 运行状态:它在执行、等 CPU,还是等某个条件?
  • 执行模式:它当前走 Cobalt 实时路径,还是 Linux 带内路径?

例如,Cobalt 线程等待实时信号量,可以由 Cobalt 记录等待关系并选择其他线程;信号量到达后,线程再获得调度资格。这不要求把这次等待改交给普通 Linux 服务。官方 Cobalt sem_wait() 就是带有 switch-primary 标签的阻塞服务。Cobalt 信号量

线程运行状态与 primary、secondary 模式是不同维度

图 5:等待 Cobalt 实时对象,不等于进入 secondary;普通 Linux 服务则可能需要换域。判断依据是实际接口及调用上下文。

Cobalt 用户线程需要普通 Linux 服务时,可能进入 secondary mode,由 Linux 管理相应执行;需要回到实时路径时,再通过适当的 Cobalt 服务迁回。不要假定普通 Linux 系统调用返回的下一行,就已经自动恢复 primary。

读 API 文档时,switch-primaryswitch-secondary 表示服务可能引发的切换方向,要结合上下文、快路径和其他标签理解。API 服务标签

这与第二章“中断暂时打断 Linux”也有区别:一个普通 Linux 任务被 OOB 中断打断,并不会因此自动变成一条 Cobalt 实时线程。Dovetail 区分“代码正在 OOB 阶段执行”和“任务转由协同调度器管理”,任务迁移还有自己的调度与唤醒流程。Dovetail 协同调度

因此,实时循环里的调用清单应该逐项核对。尤其不能把 glibc 的同步对象、Cobalt 的实时对象、内核普通锁和 RTDM 接口,只按函数名字归成一类。

7. 我优先级最高,为什么还要等低优先级线程?

再请来三位同处 Cobalt 的线程:H 优先级 80、M 优先级 60、L 优先级 40。

这次 L 手里拿着一把互斥锁,H 也要用它。

  1. L 先拿到锁,正在更新共享数据。
  2. H 就绪并抢占 L,随后申请同一把锁。
  3. 锁还在 L 手里,H 只能阻塞。
  4. 此时 M 就绪。因为 60 高于 40,M 抢在 L 前面运行。
  5. L 没机会继续,更没机会解锁;H 也只能继续等。

最尴尬的是:M 根本不碰那把锁,却把 H 等锁的时间拉长了。这就是典型的优先级反转。

无优先级继承与启用优先级继承时的锁等待时间线

图 6:左侧假设不支持 PI,展示反转如何被 M 放大;右侧启用 PI,L 在必要时临时获得较高有效优先级,推进解锁。不是让 H 越过锁直接改数据。

优先级继承(PI)的做法,是在 H 等锁期间,临时提高锁主 L 的有效优先级,让 M 不能轻易插到 L 前面。L 解锁后,再根据剩余等待关系调整优先级;简单的单锁场景中,它恢复自己的基础优先级。

Alchemy 的 RT_MUTEX 实现了优先级继承,所以图中“无 PI”用于解释问题,并不是说 Alchemy mutex 默认没有 PI。Alchemy mutex

如果用 Cobalt/POSIX mutex,则要看属性设置:默认协议为 PTHREAD_PRIO_NONE,需要 PI 时应配置 PTHREAD_PRIO_INHERIT,不能仅凭“用了 mutex”就认定继承已启用。Cobalt/POSIX mutex 属性

不过,PI 没有时间机器。L 在锁里本来就要算 2 ms,继承优先级以后,也不会突然变成 20 μs。

这给出几个直接的设计要求:共享数据更新尽量短;耗时计算移到锁外;避免持锁等待设备或另一条不可控路径;锁的依赖顺序要明确。第二章提到的“带外忙等普通 Linux 锁”是另一种跨域错误,也不能指望 PI 自动修复。

8. 回到 1 ms 控制器:怎样才算这一轮按时?

现在把所有环节装回去。

某次任务计划在 t₀ 释放,业务要求在 t₀+1 ms 前完成输出。假设这一轮:

  • 从计划释放到开始执行,花了 40 μs;
  • 采样与计算花了 350 μs;
  • 设备输出路径花了 110 μs。

端到端共 500 μs,剩余 500 μs。以上数字均为教学构造,不对应实际平台。

一个 1 ms 控制周期中的释放等待、采样计算、设备输出及剩余时间

图 7:预算从计划释放时刻开始算,不能把线程拿到 CPU 之前的等待删掉。输出完成的测量位置,也必须与业务定义一致。

测量时,至少记录三个时间点:计划释放、实际开始、输出完成。由此分别计算启动偏差和端到端响应时间,并记录业务截止时间错过次数。再结合 overrun 计数,才能区分“起步迟了”“本轮干得太久”和“已经追不上周期”。

例如,调用驱动返回可能只表示数据已进入发送队列,未必表示电机端已经收到。如果截止时间要求的是执行器生效,就要把测量延伸到那个位置,必要时结合设备时间戳或外部仪器。

测量记录先写进预分配的内存,交给非实时路径汇总。每轮在控制线程里打印一行,容易把“测调度”变成“测日志系统今天心情好不好”。

本章没有在目标实时设备上运行上述示例。工程验证还应固定内核与用户库版本、CPU 亲和性、IRQ 配置、调度策略和负载条件。绑核只约束运行位置,共享缓存、内存总线和固件干扰仍可能存在。Linux 实时系统的硬件因素

本章小结

把一条线程的一轮工作连起来看:条件满足 → 就绪 → 获得 CPU → 完成工作 → 等下一次条件。优先级只负责其中的选择规则,周期机制负责计划时刻,锁和 I/O 又带来各自的等待。

FIFO 不保证同级轮流,RR 不保证低优先级得到 CPU;Cobalt 阻塞不等于切回 Linux;优先级继承能减少干扰,但不能把长临界区变短。要让控制器准时,每一段都需要明确的时间预算。

下一章走到真正的设备边上:用户线程已经准时醒了,数据怎样通过 RTDM、设备中断和 DMA,准时进出硬件?

posted @ 2026-09-20 14:18  大米饭1  阅读(3)  评论(0)    收藏  举报