26、操作系统任务调度机制解析

操作系统任务调度机制解析

1. 简单调度器的实现与问题

最初实现的小操作系统已能正常工作,内核可按顺序调度两个任务。在每次循环开始时,ID为0的任务会恢复执行,但在这个简单示例中,内核任务仅在循环中调用调度函数,随即恢复ID为1的任务。不过,这种设计下系统的响应性完全取决于任务的实现,因为每个任务都可能无限占用CPU,从而阻止其他任务运行。这种协作式模型仅适用于特定场景,在这些场景中,每个任务需直接负责调节其CPU周期并与其他线程协作,这可能会影响整个系统的响应性和公平性。

此外,该实现未考虑 jiffies 变量的溢出问题。若 jiffies 每毫秒递增一次,大约42天后它将溢出其最大值。而实际的操作系统必须实现适当的机制来比较时间变量,以检测在计算时间差时的溢出情况。

2. 并发与时间片调度

另一种调度方法是为每个任务分配短时间的CPU时间,并以极短的间隔连续切换进程。抢占式调度器可自主中断正在运行的任务,恢复下一个任务,而无需任务本身的明确请求。它还能制定关于选择下一个运行任务以及为每个任务分配CPU的时间间隔(即时间片)的策略。

从任务的角度来看,执行现在可以是连续的,并且完全独立于调度器。调度器在后台工作,不断中断和恢复每个任务,给人一种所有任务都在同时运行的错觉。以下是重新定义的两个任务,用于以不同间隔闪烁LED:

void task_test0(void *arg)
{
    uint32_t now = jiffies;
    blue_led_on();
    while(1) {
        if ((jiffies - now) > 500) {
            blue_led_toggle();
            now = jiffies;
        }
    }
}
void task_test1(void *arg)
{
    uint32_t now = jiffies;
    red_led_on();
    while(1) {
        if ((jiffies - now) > 125) {
            red_led_toggle();
            now = jiffies;
        }
    }
}

为了以循环方式交替任务,可在SysTick处理程序中触发PendSV的执行,从而实现定期的任务切换。新的SysTick处理程序每 TIMESLICE 毫秒触发一次上下文切换:

#define TIMESLICE (20)
void isr_systick(void)
{
    if ((++jiffies % TIMESLICE) == 0)
        schedule();
}

在这种新配置下,我们有了一个更完整的模型,允许多个任务独立运行,并且调度完全由内核监督。

3. 阻塞任务的实现

目前实现的简单调度器仅为任务提供了两种状态: TASK_READY TASK_RUNNING 。可以实现第三种状态来定义一个因被阻塞而无需恢复的任务,该任务正在等待事件或超时。任务可能等待以下类型的系统事件:
- 任务正在使用的输入/输出(I/O)设备的中断事件。
- 来自另一个任务的通信,如TCP/IP栈。
- 同步机制,如互斥锁或信号量,以访问系统中当前不可用的共享资源。
- 超时事件。

为了管理不同的状态,调度器可以实现两个或更多的列表,将当前正在运行或准备运行的任务与等待事件的任务分开。调度器会从处于 TASK_READY 状态的任务中选择下一个任务,并忽略阻塞任务列表中的任务。

新的调度器使用全局指针来跟踪当前运行的任务,并将任务组织成两个列表:
- tasklist_active :包含正在运行的任务块以及所有处于 TASK_READY 状态、等待调度的任务。
- tasklist_waiting :包含当前被阻塞的任务块。

实现这种新机制的一个简单示例是 sleep_ms 函数,任务可以使用该函数暂时切换到等待状态,并设置未来的恢复点以便再次被调度。以下是使用 sleep_ms 函数的任务示例:

void task_test0(void *arg){
    blue_led_on();
    while(1) {
        sleep_ms(500);
        blue_led_toggle();
    }
}
void task_test1(void *arg)
{
    red_led_on();
    while(1) {
        sleep_ms(125);
        red_led_toggle();
    }
}

为了将任务块组织成列表,需要在结构中添加指向下一个元素的指针,以便在运行时填充这两个列表。同时,为了管理 sleep_ms 函数,需要添加一个新字段来跟踪任务应该被放入活动列表以恢复执行的系统时间:

struct task_block {
    char name[TASK_NAME_MAXLEN];
    int id;
    int state;
    void (*start)(void *arg);
    void *arg;
    uint8_t *sp;
    uint32_t wakeup_time;
    struct task_block *next;
};

可以使用两个简单的函数来插入和删除列表元素:

struct task_block *tasklist_active = NULL;
struct task_block *tasklist_waiting = NULL;
static void tasklist_add(struct task_block **list,struct task_block *el)
{
    el->next = *list;
    *list = el;
}
static int tasklist_del(struct task_block **list, struct task_block *delme)
{
    struct task_block *t = *list;
    struct task_block *p = NULL;
    while (t) {
        if (t == delme) {
            if (p == NULL)
                *list = t->next;
            else
                p->next = t->next;
            return 0;
        }
        p = t;
        t = t->next;
    }
    return -1;
}

还需要添加两个额外的函数来将任务在活动列表和等待列表之间移动,并更改任务本身的状态:

static void task_waiting(struct task_block *t)
{
    if (tasklist_del(&tasklist_active, t) == 0) {
        tasklist_add(&tasklist_waiting, t);
        t->state = TASK_WAITING;
    }
}
static void task_ready(struct task_block *t)
{
    if (tasklist_del(&tasklist_waiting, t) == 0) {
        tasklist_add(&tasklist_active, t);
        t->state = TASK_READY;
    }
}

sleep_ms 函数设置恢复时间,将任务移动到等待状态,然后激活调度器以抢占该任务:

void sleep_ms(int ms)
{
    if (ms < TASK_TIMESLICE)
        return;
    t_cur->wakeup_time = jiffies + ms;
    task_waiting(t_cur);
    schedule();
}

新的PendSV处理程序从活动列表中选择下一个要运行的任务,假设活动列表中始终至少有一个任务,因为内核主任务永远不会被放入等待状态。新线程通过 tasklist_next_ready 函数选择,该函数还确保如果当前任务已从活动列表中移除,或者是队列中的最后一个任务,则选择活动列表的头部作为下一个时间片的任务:

static inline struct task_block *tasklist_next_ready(struct task_block *t)
{
    if ((t->next == NULL) || (t->next->state != TASK_READY))
        return tasklist_active;
    return t->next;
}

以下是新的PendSV处理程序的代码:

void __attribute__((naked)) isr_pendsv(void)
{
    store_context();
    asm volatile("mrs %0, msp" : "=r"(t_cur->sp));
    if (t_cur->state == TASK_RUNNING) {
        t_cur->state = TASK_READY;
    }
    t_cur = tasklist_next_ready(t_cur);
    t_cur->state = TASK_RUNNING;
    asm volatile("msr msp, %0" ::"r"(t_cur->sp));
    restore_context();
    asm volatile("mov lr, %0" ::"r"(0xFFFFFFF9));
    asm volatile("bx lr");
}

最后,为了检查每个休眠任务的唤醒时间,内核会遍历等待列表,并在唤醒时间过去后将任务块移回活动列表。内核初始化现在包括一些额外的步骤,以确保内核任务本身在启动时被放入运行任务列表:

void main(void) {
    clock_pll_on(0);
    led_setup();
    button_setup();
    systick_enable();
    kernel.name[0] = 0;
    kernel.id = 0;
    kernel.state = TASK_RUNNING;
    kernel.wakeup_time = 0;
    tasklist_add(&tasklist_active, &kernel);
    task_create("test0",task_test0, NULL);
    task_create("test1",task_test1, NULL);
    task_create("test2",task_test2, NULL);
    while(1) {
        struct task_block *t = tasklist_waiting;
        while (t) {
            if (t->wakeup_time && (t->wakeup_time < jiffies)) {
                t->wakeup_time = 0;
                task_ready(t);
            }
            t = t->next;
        }
        WFI();
    }
}
4. 资源等待与任务阻塞

在给定时间间隔内阻塞只是任务暂时从活动列表中排除的一种可能性。内核可以实现其他事件和中断处理程序,将任务带回调度循环,使任务在 TASK_WAITING 状态下等待特定资源的I/O事件时被阻塞。

在示例代码中,实现了一个 read 函数来从任务中检索按钮的状态。该函数会阻塞,直到按钮被按下才返回。在此之前,调用任务会留在等待列表中,不会被调度。以下是一个每次按钮按下时切换绿色LED的任务示例:

#define BUTTON_DEBOUNCE_TIME 120
void task_test2(void *arg)
{
    uint32_t toggle_time = 0;
    green_led_off();
    while(1) {
        if (button_read()) {
            if((jiffies - toggle_time) > BUTTON_DEBOUNCE_TIME)
            {
                green_led_toggle();
                toggle_time = jiffies;
            }
        }
    }
}

button_read 函数会跟踪调用任务,使用 button_task 指针在按钮按下时唤醒它。任务会被移动到等待列表,驱动程序会启动读取操作,然后任务被抢占:

struct task_block *button_task = NULL;
int button_read(void)
{
    if (button_task)
        return 0;
    button_task = t_cur;
    task_waiting(t_cur);
    button_start_read();
    schedule();
    return 1;
}

为了在按钮按下时通知调度器,驱动程序使用一个回调函数,该函数由内核在初始化期间指定,并作为参数传递给 button_setup

static void (*button_callback)(void) = NULL;
void button_setup(void (*callback)(void))
{
    AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
    GPIOA_MODE &= ~ (0x03 << (BUTTON_PIN * 2));
    EXTI_CR0 &= ~EXTI_CR_EXTI0_MASK;
    button_callback = callback;
}

内核将 button_wakeup 函数与驱动程序回调关联,以便在事件发生时,如果有任务正在等待按钮按下通知,它会被移回活动任务列表,并在调度器选择它运行时恢复执行:

void button_wakeup(void)
{
    if (button_task) {
        task_ready(button_task);
        button_task = NULL;
        schedule();
    }
}

在按钮驱动程序中,为了启动阻塞操作,会启用中断并将其与信号的上升沿关联,这对应于按钮按下事件:

void button_start_read(void)
{
    EXTI_IMR |= (1 << BUTTON_PIN);
    EXTI_EMR |= (1 << BUTTON_PIN);
    EXTI_RTSR |= (1 << BUTTON_PIN);
    nvic_irq_enable(NVIC_EXTI0_IRQN);
}

当检测到事件时,回调函数会在中断上下文中执行。中断会被禁用,直到下一次调用 button_start_read

void isr_exti0(void)
{
    nvic_irq_disable(NVIC_EXTI0_IRQN);
    EXTI_PR |= (1 << BUTTON_PIN);
    if (button_callback)
        button_callback();
}

任何依赖中断处理来解锁关联任务的设备驱动程序或系统模块都可以使用回调机制与调度器交互。使用类似的阻塞策略,可以实现读写操作,使调用任务留在等待列表中,直到检测到所需事件并在调度器代码中处理回调。

其他为裸机嵌入式应用设计的系统组件和库可能需要额外的层来通过阻塞调用集成到操作系统中。嵌入式TCP/IP栈实现,如lwIP和picoTCP,提供了可移植的RTOS集成层,包括阻塞套接字调用,通过在专用任务中运行循环函数来实现,该任务管理与其他任务中使用的套接字API的通信。锁机制,如互斥锁和信号量,预计会实现阻塞调用,当请求的资源不可用时,这些调用会挂起任务。

目前实现的调度策略具有很高的响应性,任务之间的交互效果良好,但它没有考虑优先级级别,而这在设计实时系统时是必要的。

5. 实时调度

实时操作系统的关键要求之一是能够在短且可预测的时间内对选定的事件做出反应,通过执行相关代码来实现。为了实现具有严格时间要求的功能,操作系统必须专注于快速的中断处理和调度,而不是其他指标,如吞吐量或公平性。每个任务可能有特定的要求,如截止日期,指示执行必须开始或停止的准确时间,或者与共享资源相关,这可能会引入与系统中其他任务的依赖关系。一个能够以确定性时间要求执行任务的系统必须能够在可测量的固定时间内满足截止日期。

实现实时调度是一个复杂的问题。虽然有相关的权威文献,但这里不会详细解释。研究表明,基于为每个任务分配优先级,并结合在运行时切换任务的适当策略的几种方法,可以提供足够的近似值,以提供满足实时要求的通用解决方案。

为了支持具有确定性截止日期的硬实时任务,操作系统应考虑实现以下特性:
- 调度器中实现快速的上下文切换过程。
- 可测量的系统在禁用中断的情况下运行的时间间隔。
- 短的中断处理程序。
- 支持中断优先级。
- 支持任务优先级,以最小化硬实时任务的延迟。

从任务调度的角度来看,实时任务的延迟主要与系统在外部事件发生时恢复任务的能力有关。

为了保证选定任务组的确定性延迟,实时操作系统(RTOS)通常实现固定优先级级别,这些级别在任务创建时分配,并决定在每次调度器的管理调用执行时选择下一个任务的顺序。

时间关键操作应在具有较高优先级的任务中实现。为了优化实时任务的反应时间,同时保持系统的响应性,并解决低优先级任务可能出现的饥饿问题,人们研究了许多调度策略。为特定场景找到最佳调度策略可能非常困难,而确定实时系统延迟和抖动的详细计算超出了本文的范围。

一种在实时操作系统中非常流行的方法是,在每次调用调度器的管理调用时,从准备执行的任务中选择优先级最高的任务,为实时任务提供即时的上下文切换。这种调度策略称为静态优先级驱动的抢占式调度,但它并非在所有情况下都是最优的,因为任务的延迟取决于同一优先级级别的任务数量,并且没有机制来防止在系统负载较高时低优先级任务可能出现的饥饿问题。不过,这种机制足够简单,可以轻松实现,以展示优先级机制对实时任务延迟的影响。

另一种可能的方法是根据任务的特性在运行时动态重新分配优先级。实时调度器可以从一种确保首先选择截止日期最近的任务的机制中受益。这种方法称为最早截止日期优先调度(EDF),在负载较重的系统中,它在满足实时截止日期方面更有效。从Linux 3.14版本开始包含的 SCHED_DEADLINE 调度器就是这种机制的实现,尽管它相对容易实现,但在嵌入式操作系统中不太受欢迎。

以下是一个简单的静态优先级驱动调度器的实现示例。使用四个单独的列表来存储活动任务,每个列表对应系统支持的一个优先级级别。在任务创建时为每个任务分配一个优先级级别,内核的优先级为0,其主任务仅在所有其他任务休眠时运行,其唯一目的是检查休眠任务的定时器。任务准备好时可以插入到相应优先级级别的活动任务列表中,被阻塞时则移动到等待列表。为了跟踪任务的静态优先级,在任务块中添加了 priority 字段:

struct task_block {
    char name[TASK_NAME_MAXLEN];
    int id;
    int state;
    void (*start)(void *arg);
    void *arg;
    uint8_t *sp;
    uint32_t wakeup_time;
    uint8_t priority;
    struct task_block *next;
};

定义了两个快捷函数,用于快速将任务块添加到和从具有相同优先级的任务列表中移除:

static void tasklist_add_active(struct task_block *el)
{
    tasklist_add(&tasklist_active[el->priority], el);
}
static int tasklist_del_active(struct task_block *el)
{
    return tasklist_del(&tasklist_active[el->priority], el);
}

在新的 task_waiting task_ready 函数版本中,当任务被移除或插入到给定优先级的相应活动任务列表时,可以使用这些函数:

static void task_waiting(struct task_block *t)
{
    if (tasklist_del_active(t) == 0) {
        tasklist_add(&tasklist_waiting, t);
        t->state = TASK_WAITING;
    }
}
static void task_ready(struct task_block *t)
{
    if (tasklist_del(&tasklist_waiting, t) == 0) {
        tasklist_add_active(t);
        t->state = TASK_READY;
    }
}

在系统中创建了三个任务,但在按钮按下事件时会阻塞的任务被赋予了较高的优先级级别:

void main(void) {
    clock_pll_on(0);
    led_setup();
    button_setup(button_wakeup);
    systick_enable();
    kernel.name[0] = 0;
    kernel.id = 0;
    kernel.state = TASK_RUNNING;
    kernel.wakeup_time = 0;
    kernel.priority = 0;
    tasklist_add_active(&kernel);
    task_create("test0",task_test0, NULL, 1);
    task_create("test1",task_test1, NULL, 1);
    task_create("test2",task_test2, NULL, 3);
    while(1) {
        struct task_block *t = tasklist_waiting;
        while (t) {
            if (t->wakeup_time && (t->wakeup_time < jiffies)) {
                t->wakeup_time = 0;
                task_ready(t);
            }
            t = t->next;
        }
        WFI();
    }
}

选择下一个任务的函数被重新设计,以在准备运行的任务中找到优先级最高的任务。为此,从最高优先级到最低优先级遍历优先级列表。如果最高优先级列表与当前任务之一相同,并且可能的话,选择同一级别中的下一个任务,以保证在同一优先级级别内竞争CPU的任务采用循环机制。在其他情况下,选择最高优先级列表中的第一个任务:

static int idx;
static inline struct task_block *
tasklist_next_ready(struct task_block *t)
{
    for (idx = MAX_PRIO - 1; idx >= 0; idx--) {
        if ((idx == t->priority) && (t->next != NULL) &&
            (t->next->state == TASK_READY))
            return t->next;
        if (tasklist_active[idx])
            return tasklist_active[idx];
    }
    return t;
}

这个调度器与单优先级调度器在对ID等于2的任务中按钮按下事件的响应方面的主要区别在于,按钮按下事件与任务本身的反应之间的时间间隔。两个调度器都通过在按钮事件的中断处理程序中立即将任务恢复到就绪状态来实现抢占。

综上所述,操作系统的任务调度机制是一个复杂且关键的部分,从简单的顺序调度到支持实时任务的优先级调度,不同的调度策略适用于不同的应用场景。在设计和实现操作系统时,需要根据具体的需求和系统特点选择合适的调度策略,以确保系统的性能和响应性。

操作系统任务调度机制解析(续)

6. 不同调度策略对比

为了更清晰地理解不同调度策略的特点,我们对前面提到的几种调度策略进行对比:
| 调度策略 | 优点 | 缺点 | 适用场景 |
| — | — | — | — |
| 协作式调度 | 实现简单 | 系统响应性依赖任务实现,可能导致任务饥饿 | 特定简单场景,任务能自觉调节CPU周期 |
| 时间片轮转调度 | 允许多任务独立运行,调度由内核监督 | 未考虑任务优先级 | 对任务优先级要求不高的通用场景 |
| 静态优先级驱动的抢占式调度 | 为实时任务提供即时上下文切换 | 任务延迟受同优先级任务数量影响,可能导致低优先级任务饥饿 | 对实时性要求较高的场景 |
| 最早截止日期优先调度(EDF) | 能更好地满足实时任务截止日期 | 实现相对复杂,在嵌入式系统中不太流行 | 系统负载较重,对任务截止日期要求严格的场景 |

7. 调度机制的优化方向

根据前面的分析,我们可以总结出调度机制的一些优化方向:
- 优先级平衡 :在静态优先级驱动的调度中,引入机制避免低优先级任务饥饿,例如设置优先级提升机制,当低优先级任务等待时间过长时,适当提升其优先级。
- 动态调度 :结合动态优先级分配和最早截止日期优先的思想,根据任务的实时状态和截止日期动态调整任务优先级,提高系统的实时性和资源利用率。
- 中断处理优化 :进一步缩短中断处理程序的执行时间,减少系统在中断处理时的延迟,提高系统对外部事件的响应速度。

8. 调度机制的实现流程总结

为了帮助大家更好地理解调度机制的实现过程,我们用mermaid流程图来展示整体流程:

graph TD;
    A[系统初始化] --> B[创建任务];
    B --> C{任务状态判断};
    C -->|TASK_READY| D[加入活动列表];
    C -->|TASK_WAITING| E[加入等待列表];
    D --> F[调度选择任务];
    F --> G[任务执行];
    G --> H{是否阻塞};
    H -->|是| I[移动到等待列表];
    H -->|否| J{时间片是否用完};
    J -->|是| F;
    J -->|否| G;
    I --> K{等待事件是否发生};
    K -->|是| D;
    K -->|否| I;

这个流程图展示了从系统初始化开始,任务的创建、状态管理、调度选择、执行以及阻塞和恢复的整个过程。

9. 代码示例的实际应用

前面给出的代码示例可以在实际项目中进行应用,以下是一个简单的应用步骤说明:
1. 环境搭建 :根据代码中涉及的硬件资源,如LED、按钮等,搭建对应的硬件环境,并确保硬件连接正确。
2. 代码配置 :根据实际硬件的引脚和时钟配置,修改代码中的相关参数,如 GPIO 引脚定义、时钟频率等。
3. 任务定制 :根据项目需求,修改或添加任务函数,例如调整LED闪烁的时间间隔,或者添加更多的任务逻辑。
4. 编译和烧录 :使用合适的编译器将代码编译成可执行文件,并通过编程器将其烧录到目标设备中。
5. 测试和调试 :运行系统,观察任务的执行情况,如LED的闪烁、按钮的响应等,根据实际情况进行调试和优化。

10. 结论

操作系统的任务调度机制是一个复杂而又关键的领域,不同的调度策略各有优缺点,适用于不同的应用场景。通过对简单调度器、时间片轮转调度、阻塞任务管理以及实时调度等多种机制的研究和实现,我们可以根据具体的系统需求选择合适的调度策略,并对其进行优化,以提高系统的性能和响应性。同时,在实际应用中,我们需要结合硬件环境和项目需求,对代码进行适当的配置和定制,确保系统能够稳定、高效地运行。未来,随着计算机技术的不断发展,操作系统的调度机制也将不断演进,以满足更加复杂和多样化的应用需求。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值