非抢占式操作系统有什么好处?以及这些好处的价格?

电器工程 嵌入式 操作系统 操作系统
2022-01-20 08:20:27

对于裸机单片机,和自制的后台循环加定时器中断架构的代码相比,非抢占式操作系统有什么好处呢?在这些好处中,有哪些优点足以让项目采用非抢占式操作系统,而不是使用具有后台循环架构的自制代码?
.

问题解释:

我非常感谢所有回答我的问题的人。我觉得答案已经差不多了。我在这里向我的问题添加了这个解释,这表明了我自己的考虑,可能有助于缩小问题范围或使其更精确。

我要做的是了解如何为一般项目选择最合适的 RTOS。
为实现这一点,更好地理解不同类型 RTOS 的基本概念和最有吸引力的优势以及相应的价格将有所帮助,因为没有适用于所有应用程序的最佳 RTOS。
几年前我读过有关操作系统的书籍,但我不再随身携带它们了。在我在这里发布我的问题之前,我在互联网上进行了搜索,发现这些信息最有帮助:http ://www.ustudy.in/node/5456 。
还有很多其他有用的信息,比如不同RTOS网站的介绍,抢占式调度和非抢占式调度的比较文章等等。
但是我没有找到任何提及何时选择非抢占式 RTOS 以及何时使用定时器中断和后台循环编写自己的代码更好的话题。
我有自己的答案,但我对它们还不够满意。
我真的很想知道更多有经验的人的答案或观点,尤其是在行业实践中。

到目前为止我的理解是:
无论使用或不使用操作系统,总是需要某种调度代码,即使它的代码形式如下:

    in the timer interrupt which occurs every 10ms  
    if(it's 10ms)  
    {  
      call function A / execute task A;  
    }  
    if(it's 50ms)  
    {  
      call function B / execute task B;  
    }  

好处一:
非抢占式操作系统为调度代码指定了方式/编程风格,这样工程师即使之前不在同一个项目中也可以共享相同的视图。然后在概念任务的相同视图下,工程师可以处理不同的任务并对其进行测试,尽可能独立地对它们进行分析。
但我们真正能从中获得多少?如果工程师在同一个项目中工作,他们可以找到方法很好地共享相同的视图,而无需使用非抢占式操作系统。
如果一个工程师来自另一个项目或公司,如果他以前了解操作系统,他将获得好处。但如果他不这样做,那么再一次,他学习一个新的操作系统或一段新的代码似乎并没有太大的不同。

好处2:
如果操作系统代码已经过很好的测试,那么它可以节省调试时间。这真是一个很好的好处。
但是如果应用程序只有大约 5 个任务,我认为使用定时器中断和后台循环编写自己的代码并不是很麻烦。

这里的非抢占式操作系统是指具有非抢占式调度程序的商业/免费/遗留操作系统。
当我发布这个问题时,我主要想到某些操作系统,例如:
(1)KISS Kernel(A Small NonPreemptive RTOS - 由其网站声称)
http://www.frontiernet.net/~rhode/kisskern.html
(2)uSmartX (轻量级 RTOS - 由其网站声称)
(3)FreeRTOS(这是一个抢占式 RTOS,但据我了解,它也可以配置为非抢占式 RTOS)
(4)uC/OS(类似于 FreeRTOS)
(5 ) 某些公司的遗留操作系统/调度程序代码(通常由公司内部制作和维护)
(由于新 StackOverflow 帐户的限制,无法添加更多链接)

据我了解,非抢占式操作系统是这些代码的集合:
(1)使用非抢占式策略的调度程序。
(2) 用于任务间通信、互斥、同步和时间控制的设施。
(3)内存管理。
(4) 其他有用的设施/库,如文件系统、网络堆栈、GUI 等(FreeRTOS 和 uC/OS 提供这些,但我不确定当调度程序配置为非抢占式时它们是否仍然有效)
一些他们并不总是在那里。但是调度器是必须的。

3个回答

这闻起来有点跑题了,但我会尽量让它回到正轨。

抢先式多任务处理意味着操作系统或内核可以暂停当前运行的线程并根据其现有的调度启发式切换到另一个线程。大多数情况下,运行的线程并不知道系统上正在发生其他事情,这对您的代码意味着您必须小心设计它,以便如果内核决定在一个线程中间挂起一个线程多步操作(例如更改 PWM 输出、选择新的 ADC 通道、从 I2C 外设读取状态等)并让另一个线程运行一段时间,这两个线程不会相互干扰。

任意示例:假设您是多线程嵌入式系统的新手,并且您有一个带有 I2C ADC、SPI LCD 和 I2C EEPROM 的小系统。您认为拥有两个线程是个好主意:一个从 ADC 读取并将样本写入 EEPROM,另一个读取最后 10 个样本,对它们进行平均并在 SPI LCD 上显示它们。没有经验的设计看起来像这样(大大简化):

char i2c_read(int i2c_address, char databyte)
{
    turn_on_i2c_peripheral();
    wait_for_clock_to_stabilize();

    i2c_generate_start();
    i2c_set_data(i2c_address | I2C_READ);
    i2c_go();
    wait_for_ack();
    i2c_set_data(databyte);
    i2c_go();
    wait_for_ack();
    i2c_generate_start();
    i2c_get_byte();
    i2c_generate_nak();
    i2c_stop();
    turn_off_i2c_peripheral();
}

char i2c_write(int i2c_address, char databyte)
{
    turn_on_i2c_peripheral();
    wait_for_clock_to_stabilize();

    i2c_generate_start();
    i2c_set_data(i2c_address | I2C_WRITE);
    i2c_go();
    wait_for_ack();
    i2c_set_data(databyte);
    i2c_go();
    wait_for_ack();
    i2c_generate_start();
    i2c_get_byte();
    i2c_generate_nak();
    i2c_stop();
    turn_off_i2c_peripheral();
}

adc_thread()
{
    int value, sample_number;

    sample_number = 0;

    while (1) {
        value = i2c_read(ADC_ADDR);
        i2c_write(EE_ADDR, EE_ADDR_REG, sample_number);
        i2c_write(EE_ADDR, EE_DATA_REG, value);

        if (sample_number < 10) {
            ++sample_number;
        } else {
            sample_number = 0;
        }
    };
}

lcd_thread()
{
    int i, avg, sample, hundreds, tens, ones;

    while (1) {
        avg = 0;
        for (i=0; i<10; i++) {
            i2c_write(EE_ADDR, EE_ADDR_REG, i);
            sample = i2c_read(EE_ADDR, EE_DATA_REG);
            avg += sample;
        }

        /* calculate average */
        avg /= 10;

        /* convert to numeric digits for display */
        hundreds = avg / 100;
        tens = (avg % 100) / 10;
        ones = (avg % 10);

        spi_write(CS_LCD, LCD_CLEAR);
        spi_write(CS_LCD, '0' + hundreds);
        spi_write(CS_LCD, '0' + tens);
        spi_write(CS_LCD, '0' + ones);
    }
}

这是一个非常粗略和快速的例子。不要这样编码!

现在请记住,抢先式多任务操作系统可以在代码中的任何一行(实际上是在任何汇编指令处)暂停这些线程中的任何一个,并让另一个线程有时间运行。

考虑一下。想象一下,如果操作系统决定adc_thread()在设置 EE 地址以写入和写入实际数据之间暂停会发生什么。lcd_thread()将运行,与 I2C 外围设备一起读取它需要的数据,当adc_thread()轮到它再次运行时,EEPROM 将不会处于与它离开时相同的状态。事情根本不会很好地工作。更糟糕的是,它甚至可能在大部分时间都有效,但并非一直有效,而且你会发疯地试图弄清楚为什么你的代码在它看起来应该有效的时候却不工作!

这是一个最好的例子;操作系统可能会决定从 ' 的上下文中抢占并i2c_write()adc_thread()' 的上下文中再次开始运行它lcd_thread()事情很快就会变得非常混乱。

当您编写代码以在先发制人的多任务环境中工作时,您必须使用锁定机制来确保如果您的代码在不合时宜的时间暂停,所有地狱都不会崩溃。

另一方面,协作多任务意味着每个线程都可以控制何时放弃其执行时间。编码更简单,但必须仔细设计代码,以确保所有线程都有足够的时间运行。另一个人为的例子:

char getch()
{
    while (! (*uart_status & DATA_AVAILABLE)) {
        /* do nothing */
    }

    return *uart_data_reg;
}

void putch(char data)
{
    while (! (*uart_status & SHIFT_REG_EMPTY)) {
        /* do nothing */
    }

    *uart_data_reg = data;
}

void echo_thread()
{
    char data;

    while (1) {
        data = getch();
        putch(data);
        yield_cpu();
    }
}

void seconds_counter()
{
    int count = 0;

    while (1) {
        ++count;
        sleep_ms(1000);
        yield_cpu();
    }
}

该代码不会按照您的想法工作,或者即使它看起来确实有效,但随着回显线程的数据速率增加,它也不会工作。再一次,让我们花一点时间来看看它。

echo_thread()等待一个字节出现在 UART 上,然后获取它并等待直到有空间写入它,然后写入它。完成后,它让其他线程轮流运行。seconds_counter()将增加一个计数,等待 1000 毫秒,然后让其他线程有机会运行。如果在这段时间发生时有两个字节进入 UART sleep(),您可能会错过看到它们,因为我们假设的 UART 没有 FIFO 来存储字符,而 CPU 正忙于做其他事情。

实现这个非常糟糕的例子的正确方法是把yield_cpu()你有一个繁忙的循环放在哪里。这将有助于事情进展,但可能会导致其他问题。例如,如果时间很关键,并且您将 CPU 交给另一个花费比您预期更长的线程,您可能会放弃您的时间。抢占式多任务操作系统不会出现此问题,因为它会强制挂起线程以确保正确调度所有线程。

现在这与计时器和后台循环有什么关系?定时器和后台循环与上面的协作多任务示例非常相似:

void timer_isr(void)
{
    ++ticks;
    if ((ticks % 10)) == 0) {
        ten_ms_flag = TRUE;
    }

    if ((ticks % 100) == 0) {
        onehundred_ms_flag = TRUE;
    }

    if ((ticks % 1000) == 0) {
        one_second_flag = TRUE;
    }
}

void main(void)
{
    /* initialization of timer ISR, etc. */

    while (1) {
        if (ten_ms_flag) {
            if (kbhit()) {
                putch(getch());
            }
            ten_ms_flag = FALSE;
        }

        if (onehundred_ms_flag) {
                    get_adc_data();
            onehundred_ms_flag = FALSE;
        }

        if (one_second_flag) {
            ++count;
                    update_lcd();
            one_second_flag = FALSE;
        }
    };
}

这看起来非常接近协作线程示例;您有一个设置事件的计时器和一个查找它们并以原子方式对其进行操作的主循环。您不必担心 ADC 和 LCD“线程”相互干扰,因为其中一个永远不会中断另一个。您仍然需要担心“线程”花费的时间太长;例如,如果get_adc_data()需要 30 毫秒会发生什么?您将错过三个检查字符并回显它的机会。

循环+定时器的实现通常比协作式多任务微内核更容易实现,因为您的代码可以针对手头的任务设计得更具体。您实际上并没有多任务处理,而是设计了一个固定系统,在该系统中,您给每个子系统一些时间以非常具体和可预测的方式完成其任务。即使是协作式多任务系统也必须为每个线程提供一个通用的任务结构,并且下一个要运行的线程由可能变得非常复杂的调度函数确定。

所有三个系统的锁定机制都是相同的,但每个系统所需的开销却大不相同。

就个人而言,我几乎总是按照最后一个标准进行编码,即循环+计时器实现。我发现线程应该非常谨慎地使用。不仅编写和调试更复杂,而且还需要更多开销(抢占式多任务微内核总是比愚蠢的简单计时器和主循环事件跟随器更大)。

还有一种说法是任何从事线程工作的人都会欣赏:

if you have a problem and use threads to solve it, yoeu ndup man with y pemro.bls

:-)

在许多微控制器项目中,多任务处理可能是一种有用的抽象,尽管在大多数情况下,真正的抢先式调度程序过于繁重且不必要。我已经完成了 100 多个微控制器项目。我已经多次使用协作任务,但到目前为止,先发制人的任务切换及其相关的包袱并不合适。

与合作任务相比,先发制人任务的问题是:

  1. 重量级的多。抢先式任务调度器更复杂,占用更多代码空间,占用更多周期。它们还需要至少一个中断。这通常是应用程序无法接受的负担。

  2. 在可能同时访问的结构周围需要互斥锁。在协作系统中,您只是不要在应该是原子操作的中间调用 TASK_YIELD。这会影响队列、共享全局状态并蔓延到很多地方。

一般来说,当 CPU 可以支持此任务并且该任务足够复杂且具有足够的历史相关操作以将其分解为几个单独的单独事件会很麻烦时,将任务专用于特定作业是有意义的。这通常是处理通信输入流时的情况。这些事情通常是严重的状态驱动,取决于一些先前的输入。例如,可能有操作码字节后跟每个操作码唯一的数据字节。然后当有其他东西想发送它们时,这些字节的问题就会出现。通过一个单独的任务处理输入流,您可以让它出现在任务代码中,就好像您要出去并获取下一个字节一样。

总体而言,当存在大量状态上下文时,任务很有用。任务基本上是状态机,PC 是状态变量。

微型必须做的许多事情可以表示为对一组事件的响应。结果,我通常有一个主事件循环。这会按顺序检查每个可能的事件,然后跳回顶部并再次执行所有操作。当处理一个事件不仅仅需要几个周期时,我通常会在处理完事件后跳回到事件循环的开头。这实际上意味着事件具有基于它们在列表中的检查位置的隐含优先级。在许多简单的系统上,这已经足够好了。

有时你会得到一些更复杂的任务。这些通常可以分解为一系列少量的单独要做的事情。在这些情况下,您可以使用内部标志作为事件。我在低端图片上做过很多次这种事情。

例如,如果您具有上述基本事件结构,但还必须通过 UART 响应命令流,那么让单独的任务处理接收到的 UART 流很有用。一些微控制器用于多任务处理的硬件资源有限,例如无法读取或写入自己的调用堆栈的 PIC 16。在这种情况下,我使用我称之为 UART 命令处理器的伪任务。主事件循环仍然处理其他所有事情,但要处理的事件之一是 UART 接收到了一个新字节。在这种情况下,它会跳转到运行这个伪任务的例程。UART 命令模块包含任务代码,任务的执行地址和一些寄存器值保存在该模块的 RAM 中。事件循环跳转到的代码保存当前寄存器,加载保存的任务寄存器,并跳转到任务重启地址。任务代码调用一个执行相反操作的 YIELD 宏,然后最终跳回到主事件循环的开始。在某些情况下,主事件循环每次运行一次伪任务,通常在底部以使其成为低优先级事件。

在 PIC 18 及更高版本上,我使用真正的协作任务系统,因为调用堆栈可由固件读写。在这些系统上,重新启动地址、其他一些状态和数据堆栈指针都保存在每个任务的内存缓冲区中。为了让所有其他任务运行一次,任务调用 TASK_YIELD。这将保存当前任务状态,通过列表查找下一个可用任务,加载其状态,然后运行它。

在这个架构中,主事件循环只是另一个任务,在循环的顶部调用了 TASK_YIELD。

我所有的 PIC 多任务代码都是免费提供的。要查看它,请在http://www.embedinc.com/pic/dload.htm安装PIC 开发工具版本在 8 位 PIC 的 SOURCE > PIC 目录和 16 位 PIC 的 SOURCE > DSPIC 目录中查找名称中带有“task”的文件。

编辑:(我会在下面留下我之前的帖子;也许有一天它会对某人有所帮助。)

任何类型的多任务操作系统和中断服务程序都不是——也不应该是——竞争系统架构。它们适用于系统不同级别的不同工作。中断实际上是为简短的代码序列设计的,以处理即时的琐事,例如重新启动设备、可能轮询非中断设备、软件计时等。通常假设后台将执行任何进一步的处理,在眼前的需要已经得到满足。如果您需要做的只是重新启动计时器并切换 LED 或脉冲另一个设备,则 ISR 通常可以在前台安全地完成所有操作。否则它需要通知后台(通过设置标志或排队消息)需要做某事,并释放处理器。

我见过非常简单的程序结构,其后台循环只是一个空闲循环:for(;;){ ; }. 所有的工作都在定时器 ISR 中完成。当程序需要重复一些保证在不到一个定时器周期内完成的恒定操作时,这可以工作;我想到了某些有限种类的信号处理。

就我个人而言,我编写的 ISR 会清理退出,并让后台接管任何其他需要做的事情,即使这就像乘法和加法一样简单,可以在一小段时间内完成。为什么?有一天,我会想到一个好主意,在我的程序中添加另一个“简单”功能,并且“哎呀,它只需要一个简短的 ISR 就可以完成”,突然间,我之前简单的架构增加了一些我没有计划的交互并不一致地发生。这些调试起来并不有趣。


(之前贴过两种多任务的对比)

任务切换:抢先式 MT 为您处理任务切换,包括确保没有线程出现 CPU 不足,以及高优先级线程在准备好后立即运行。协作 MT 要求程序员确保没有线程一次使处理器保持太长时间。您还必须决定多长时间太长。这也意味着,无论何时修改代码,您都需要了解现在是否有任何代码段超过了该时间量。

保护非原子操作:使用 PMT,您必须确保线程交换不会发生在不可分割的操作中间。例如,读取/写入必须以特定顺序或在最长时间内处理的某些设备寄存器对。使用 CMT 很容易——只是不要在这样的操作中间让处理器。

调试:通常使用 CMT 更容易,因为您计划何时/何地发生线程切换。线程之间的竞争条件和与使用 PMT 的非线程安全操作相关的错误特别难以调试,因为线程更改是概率性的,因此不可重复。

理解代码:为 PMT 编写的线程几乎可以独立编写。为 CMT 编写的线程被编写为段,并且根据您选择的程序结构,读者可能更难理解。

使用非线程安全库代码:您需要验证您在 PMT 线程安全下调用的每个库函数。printf() 和 scanf() 及其变体几乎总是不是线程安全的。使用 CMT,您将知道不会发生线程更改,除非您专门让处理器。

用于控制机械设备和/或跟踪外部事件的有限状态机驱动系统通常是 CMT 的良好候选者,因为在每个事件中,没有太多可做的 - 启动或停止电机、设置标志、选择下一个状态等。因此,状态变化函数本质上是简短的。

混合方法可以在这些类型的系统中很好地工作:CMT 管理作为一个线程运行的状态机(因此,大部分硬件),以及一个或两个更多线程来执行由状态启动的任何更长的运行计算改变。