非抢占式操作系统的好处是什么?这些好处的价格是多少?


14

对于裸金属MCU,与具有后台循环和计时器中断体系结构的自制代码相比,非抢占式OS的优势是什么?对于采用非抢占式OS的项目,而不是使用具有后台循环体系结构的自制代码的项目,这些好处中有什么足以吸引人?

问题解释:

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

我试图做的是了解一般情况下如何为项目选择最合适的RTOS。
为此,更好的理解基本概念以及不同种类的RTOS的最吸引人的好处和相应的价格将有所帮助,因为没有适用于所有应用程序的最佳RTOS。
几年前,我读了有关OS的书,但现在不再随身携带。在我在此处发布问题之前,我在互联网上进行了搜索,发现此信息最有帮助: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;  
    }  

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

好处2:
如果OS代码已经过良好的测试,则可以节省调试时间。这确实是一个很好的好处。
但是,如果应用程序只有大约5个任务,我认为使用计时器中断和后台循环编写您自己的代码并不是很麻烦。

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

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


简而言之,就是这样。如果您有需要多线程的工作量并且负担得起开销,请使用线程操作系统。否则,在大多数情况下,简单的基于时间或基于任务的“计划程序”就足够了。为了弄清楚抢占式还是协作式多任务是最好的……我想这取决于开销,以及您想要对多任务进行多少控制。
akohlsmith

Answers:


13

这闻起来有点离题,但我会尽力使它回到正轨。

抢先式多任务处理意味着操作系统或内核可以挂起当前正在运行的线程,并根据已有的调度启发式方法切换到另一个线程。大多数情况下,正在运行的线程并不认为系统上还有其他事情在发生,这对您的代码而言意味着您必须谨慎设计代码,以便内核决定在线程中间暂停线程。多步操作(例如更改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()将增加一个计数,等待1000ms,然后为其他线程运行。如果有两个字节进入UARTsleep()有,您可能会错过它们,因为我们假设的UART在CPU忙于做其他事情时没有FIFO存储字符。

实现这个非常糟糕的例子的正确方法是 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()花30ms会怎样?您将错过三个检查字符并回显字符的机会。

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

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

就我个人而言,我几乎总是将代码编码为最后一个标准,即loop + timer实现。我发现应谨慎使用线程。不仅编写和调试更加复杂,而且还需要更多的开销(抢先式多任务微内核总是比笨拙的计时器和主循环事件跟随器还要大)。

俗话说,任何从事线程工作的人都会体会到:

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

:-)


非常感谢您提供详细示例的答复,akohlsmith。但是,我不能从您的答复中得出结论,为什么选择简单的计时器和后台循环体系结构而不是协作式多任务处理。不要误会我的意思。非常感谢您的回复,它提供了许多有关不同计划的有用信息。我只是不明白这一点。
hailang

您能再多做一点吗?
hailang 2013年

谢谢,akohlsmith。我喜欢你结尾处的句子。我花了好一会儿才意识到:)回到您的答案,您几乎总是将代码编码到loop + timer实现中。然后,在您放弃该实现并转向非抢先式OS的情况下,您是怎么做的呢?
hailang

当我运行其他人的操作系统时,我同时使用了协作式和抢先式多任务处理系统。Linux,ThreadX,ucOS-ii或QNX。即使在某些情况下,我也使用了简单有效的timer + event循环(poll()立即想到)。
akohlsmith

我不喜欢嵌入式线程或多任务,但我知道对于复杂系统,这是唯一明智的选择。固定的微型操作系统为您提供了一种快速启动和运行设备的方法,并且通常还提供设备驱动程序。
akohlsmith

6

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

抢先式任务与协作式任务相关的问题是:

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

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

通常,将任务分配给特定作业是有意义的,因为CPU可以支持该任务,并且该作业非常复杂,并且具有足够的依赖于历史的操作,因此将其分解为几个单独的单独事件将很麻烦。处理通讯输入流时通常是这种情况。这些事情通常是由状态驱动的,具体取决于某些先前的输入。例如,可能存在操作码字节,后跟每个操作码唯一的数据字节。然后,当其他感觉像发送它们时,这些字节就会出现。通过一个单独的任务来处理输入流,可以使它出现在任务代码中,就像您要出去并获取下一个字节一样。

总体而言,当状态状态很多时,任务很有用。任务基本上是状态机,其中PC是状态变量。

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

有时您会得到一些更复杂的任务。这些通常可以分解为一系列少量的独立操作。在这些情况下,可以将内部标志用作事件。我在低端PIC上做了很多这样的事情。

例如,如果您具有如上所述的基本事件结构,但还必须通过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目录中查找其名称。


互斥锁在协作多任务系统中仍然是必需的,尽管这种情况很少见。一个典型的示例是需要访问关键部分的ISR。通过更好的设计或为关键数据选择适当的数据容器,几乎总是可以避免这种情况。
akohlsmith

@akoh:是的,我有几次使用互斥锁来处理共享资源,例如访问SPI总线。我的观点是,互斥量并不是先发制人的固有需求。我并不是说要在协作系统中不再需要或从未使用它们。同样,协作系统中的互斥锁可以像在TASK_YIELD循环中检查单个位一样简单。在抢先式系统中,它们通常需要内置到内核中。
Olin Lathrop

@OlinLathrop:我认为非抢先系统在互斥锁方面的最大优势在于,只有在直接与中断进行交互时(本质上是抢先的),或者在需要保留受保护资源的时间时才需要使用它们超出了一个人希望在两次“屈服”调用之间花费的时间,或者超出了一个可能“屈服”屈服(例如“将数据写入文件”)的呼叫周围的受保护资源。在某些情况下,在“写入数据”调用中产生收益会是一个问题时,我包括了……
supercat

...一种用于检查可以立即写入多少数据的方法,以及一种方法(可能会产生结果),以确保有一定数量的可用量(加快回收脏闪存块,并等到适当数量的数据被回收之前) 。
2013年

嗨,奥林,我非常喜欢您的回复。它的信息远远超出了我的疑问。它包含许多实践经验。
hailang 2013年

1

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

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

我已经看到了非常简单的程序结构,其背景循环只是一个空闲循环:for(;;){ ; }。所有工作都在计时器ISR中完成。当程序需要重复某些恒定的操作(保证在不到一个定时器的时间内完成)时,此方法可以起作用。想到某些有限的信号处理。

就个人而言,我写了一些ISR,清理了一次骚扰,让后台接管了所有其他需要做的事情,即使这与乘法和加法一样简单,也可以在一个定时器周期内完成。为什么?总有一天,我会想到一个聪明的主意,在程序中添加另一个“简单”功能,“哎呀,这只需要很短的ISR就可以了”,突然之间,我以前简单的体系结构增加了一些我原本没有计划的交互并不一致地发生。这些调试起来并不有趣。


(以前发布过两种多任务的比较)

任务切换:抢占式MT负责您的任务切换,包括确保没有线程耗尽CPU,并且确保高优先级线程在就绪后立即运行。合作MT要求程序员确保没有线程使处理器一次保持太长时间。您还必须决定多长时间太长。这也意味着,无论何时修改代码,都需要知道现在是否有任何代码段超出了该时间范围。

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

调试:使用CMT通常更容易,因为您可以计划何时/何处进行线程切换。由于线程更改是概率性的,因此不可重复,因此与PMT的非线程安全操作相关的线程和错误之间的竞争状况尤其难以调试。

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

使用非线程安全的库代码:您需要验证您在PMT线程安全下调用的每个库函数。printf()和scanf()及其变体几乎始终不是线程安全的。使用CMT时,您将知道不会发生任何线程更改,除非您特别屈服于处理器。

有限状态机驱动的系统来控制机械设备和/或跟踪外部事件通常是CMT的理想选择,因为在每次事件中都没什么事要做-启动或停止电动机,设置标志,选择下一个状态因此,状态更改功能本质上是简短的。

混合方法可以在以下类型的系统中很好地工作:CMT管理作为一个线程运行的状态机(因此,大多数硬件),以及一个或两个以上的线程来执行状态开始的任何运行的计算更改。


感谢您的回复,JRobert。但这并非针对我的问题。它比较抢占式操作系统与非抢占式操作系统,但不比较非抢占式操作系统与非抢占式操作系统。
hailang 2013年

对-对不起。我的编辑应该可以更好地解决您的问题。
JRobert
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.