导语:本文的实时内核的实现是在没有使用任何类型的Linux内核的情况下进行的,实时内核的处理器主要是针对16位和32位Microchip MCU,RAM是从8 KB到32 KB,ROM是从128到512 KB。
概要:
本文的实时内核的实现是在没有使用任何类型的Linux内核的情况下进行的,实时内核的处理器主要是针对16位和32位Microchip MCU,RAM是从8 KB到32 KB,ROM是从128到512 KB。
我们通过采用RTOS(实时操作系统)以及自行迭代的T Neo源代码来实现自定义的实时内核 。
抢占式操作系统如何工作
运行多个线程,由于本文中的处理器是“单线程的”:它们只能一次执行单个指令(这里只针对单核处理器)。所以为了在单核处理器上运行多个线程,我们迫切地需要定期在线程之间切换,以便用户感觉线程的存在并实施运行。
PlantUML图,处理器有一组寄存器。由于处理器是单线程的,所以这组寄存器只属于一个线程。例如,当计算两个数字的和:
实际上发生的却是以下事情(当然,它取决于确切的MCU类型):
并且由于在抢占式操作系统中,一个线程可以在几乎任何时刻抢占另一个线程,当然也可以在该序列之间发生。因此,假设在寄存器V0和V1都填充了值之后,一些其他线程抢占了当前的线程。由于新线程有自己的工作任务,因此,它自己使用的寄存器。当然,两个线程不应该相互干扰,因此,当前线程将被恢复时,它们应该具有与这些寄存器完全相同的值。
这就会导致当从线程A切换到线程B时,首先我们需要线程A的所有寄存器存储在一个地方,然后再从改地方恢复线程B的寄存器。然后线程B恢复执行,并继续工作。
所以,更准确的系统操作图如下:
当我们需要从一个线程切换到另一个线程时,内核获得控制,执行必要的内核处理(至少,保存和恢复寄存器值),然后控制转移到下一个线程运行。
线程的堆栈
在现代操作系统中,由于处理器的MMU,用户的线程堆栈都会动态增长:线程需要的堆栈空间越多,它获得的堆栈空间也就越多(如果内核允许)。但是 由于技术所限,所有RAM都是静态映射到地址空间。因此,每个线程都只接收自己用于堆栈的RAM,如果线程使用过多的堆栈,它会导致内存损坏以及一些运行错误。实际上,每个线程的堆栈空间只是一个单纯的字节数组。
所以,当我们决定为每个线程分配堆栈时,我们只是估计它可能需要多少。例如,如果这是一个具有深度嵌套调用的GUI线程,它可能需要几个千字节,但如果它是一个线程,比如,监听用户按下的按钮,512字节可能就足够了。
所以,让我们假设我们有三个线程,它们的堆栈大小如下:
正如前文所提到的,每个线程的寄存器值都保存在线程的堆栈中。线程的寄存器值的集合称为线程的“进程上下文”(“进程上下文”是可执行程序代码是进程的重要组成部分。进程上下文实际上是进程执行活动全过程的静态描述)。下图标有星号就是活动线程,
注意,活动线程(线程A)没有在堆栈上保存它的上下文。堆栈指针指向线程A的用户数据的顶部,并且当前处理器的寄存器专用于线程A。
当内核决定将控制切换到线程B时,它会执行以下操作:
将所有寄存器值有效地保存到线程A的堆栈顶部; 将堆栈指针切换到线程B的堆栈顶部; 从线程B的堆栈顶部有效地恢复所有寄存器值;
然后线程B就可以继续其进程。
中断
中断条件是建立实时内核的关键一步,是当前正在执行的线程被暂停,然后处理器在这段暂停时间内执行一些其他操作。中断可以在任何时候触发,因此,我们应该随时准备处理它。
在嵌入式开发中,MCU通常具有相当多的板载外设,例如定时器,各种数据转换单元(UART,SPI,CAN等)。所有这些都通常能够在发生重要事件时触发中断,例如,当接收到新字节时,UART外设可能触发中断,以便软件可以在某个地方存储接收到的字节。当定时器超时时,定时器外设触发中断,以便软件可以执行一些周期性的事情等。
中断处理程序
中断可能有不同的优先级:例如,如果一些低优先级中断被触发,当前执行的线程被暂停,并且ISR获得控制。然后,如果一些高优先级中断被触发,则当前执行的ISR被再次暂停,并且用于所述高优先级中断的新的ISR运行。显然,按照优先级顺序,当高优先级中断完成时,才会返回到次级优先执行的ISR,并且当它完成时,中断的线程才能被彻底恢复。
由于暂停的时间很短,所以我们不能保证嵌入式开发正常工作:例如,当我们处理一些可以被ISR改变的数据时,则产生的数据将不一致,这很容易导致灾难性的后果。
这些暂停的短时间段称为“关键段”,因此,如果在临界段期间触发了一些中断,则只有当中断被触发时,中断处理程序才会执行中断。
中断的堆栈
一般来说,我们有两个选择:
使用堆叠的线程被中断; 为中断使用单独的堆栈空间。
如果我们使用被中断的线程堆栈,它看起来如下图(在下面的图中,线程B被中断):
当触发中断时,当前线程的上下文被保存到线程的栈中(因为在ISR完成之后,我们可能想切换到其他线程,所以当前线程的上下文已经保存);
如果我们需要切换到不同的线程,我们至少要切换堆栈指针,线程的上下文才能从堆栈中恢复,线程才能继续运行。
不过在嵌入式开发的上下文中,当我们的资源非常有限时,这种方法有一个严重的缺点。
请注意,中断可以在任何时间发生,所以显然我们不知道当中断发生时哪个线程正在运行。因此,当决定每个线程需要多少堆栈空间时,我们必须假设所有现有中断都可能发生在每个线程中,并且具有不同优先级的中断可能嵌套。堆栈大小根据你的应用程序的大小来决定,例如,我们的应用程序有7个线程,它就会产生7 KB的堆栈大小。
所以,我们可以总结一下,每个线程的堆栈应该都包含以下内容:
线程自己的数据; 线程的上下文; ISR执行的数据。
下面就让我们为所有中断使用单独的堆栈空间:
这样,来自前面示例的ISR的1 KB应该只分配一次,这应该是一个最佳分配的方法,因为在嵌入式的环境中,RAM非常昂贵。
现在,我们就对RTOS的操作的做了一个完整的介绍。
TNKernel
正如我们在开始时所说的,我们使用的是16位和32位MCU的TNKernel。
PIC32的TNKernel失败
鉴于我们正在做的一个常规项目,就是一种分析点火信号的设备,并允许用户观察定时和电压。由于信号快速变化,我们需要频繁地进行ADC测量:每1到2微秒测一次。
用于这个项目的处理器是PIC32,Microchip具有MIPS内核的处理器。
就在我们对设备开始测量时,处理器的程序以一种奇怪的方式崩溃了,并且同时造成了一些内存的损坏,因为寻找一个与内存损坏的漏洞可能是非常困难的,因为没有MMU,所有RAM都可用于系统中的所有线程,所以如果在其他线程存在着一个损坏了的线程和一些内存,问题可能并不是线程本身而是其它相关联的问题。
前面已经提到过TNKernel在没有软件堆栈溢出控制的情况,所以,当我们有一些内存损坏时,首先要检查的是一些线程的堆栈是否溢出。当创建线程时,它的堆栈被初始化为一些值(在用于PIC32的TNKernel中,它只是0xffffffff),所以我们可以很容易地检查堆栈空间的顶部是否为“dirty”。所以如下图所示,堆栈的空闲线程显然是溢出的:
在MIPS上,堆栈会增长,因此,task_idle_stack [0]是空闲任务的最新可用堆栈字节。
但问题就是,这个线程的堆栈分配了巨大的额外空间,当设备正常运行时,使用了大约300个880字!可以想见,只有一些多么疯狂的错误,才会产生这样一个巨大的溢出。
然后,我们仔细检查了内存,发现堆栈空间填充了重复模式。看:序列0xFFFFFFFF,0xFFFFFFFA,0xA0006280,0xA0005454:
和同样的序列:
地址为0xA000051C和0xA00005A4。
我们可以看出,其中的差别是136字节,除以4,就是34个字。
34个字,这正是MIPS的上下文大小!并且相同的模式一次又一次地发生。所以,上下文就一直在连续保存。但是,它怎么会发生呢?
首先,检查堆栈中保存的上下文,其中,在程序存储器中应该有一个地址,在PIC32上,程序闪存映射到从0x9D000000到0x9D007FFF的区域,因此,这些地址很容易与其他数据区分开。所以我们从保存的上下文数据中选择了地址,其中一个是0x9D012C28。我们在反汇编列表中查看了这个地址:
来自相对于SP(堆栈指针)地址的这种不同系列的LW(加载字)指令就上下文恢复的样本。现在很清楚了,线程在从堆栈中恢复上下文时被中断。但是它怎么会连续发生这么多次呢?毕竟,我们都没有那么多ISR。
用于PIC32的TNKernel中的上下文切换,我们已经查看了上下文切换,但问题还没有找到,现在,就让我们添加一些TNKernel来实现一些运行细节。
当内核决定将控制从线程A切换到线程B时,它执行以下操作:
将所有寄存器值有效地保存到线程A的堆栈顶部; 禁用中断; 将堆栈指针切换到线程B的堆栈顶部; 将当前线程指针切换到线程B的描述符; 执行一些其他内核内务; 启用中断; 从堆栈中有效地恢复所有线程B的堆栈顶部的寄存器值;
正如所看到的,当内核切换指针时,中间会有一个短的暂停,否则,可能会出现另外一种情况,当中断触发时,堆栈指针已经切换到线程B,但其他进程仍然不变。这种不一致的数据很容易导致灾难性后果。
PIC32的TNKernel中断
在用于PIC32的TNKernel中,有两种类型的中断:
系统中断:允许它们调用内核服务,这可能导致中断完成后的上下文切换。当该中断被触发时,在ISR获得控制之前,全线程的上下文被保存到堆栈; 用户中断:它们不允许调用内核服务。
正如已经提到的,这种方法颠覆了我们对线程堆栈大小的理解,按照我们的理解,每个线程堆栈都应该能够保存以下内容:
线程自己的数据; 线程的上下文(在MIPS中,堆栈大小为34个字,即136字节); 意外情况时ISR的数据。
不过在上下文切换期间触发中断时,由于在上下文保存到堆栈期间中断没有被禁用,上下文将被保存两次。
所以,当内核决定从线程B切换到线程A时,会发生以下情况:
上下文被保存在线程B的堆栈上; 在这个过程之间,触发中断; 用于一个上下文的空间被分配在线程B的堆栈上,并且保存了上下文; 当ISR返回时,内核就会选择下一个线程来运行(本来,我们是想在中断触发之前,切换到程A)。 禁用中断,将堆栈指针切换到线程A的堆栈顶部,执行其他内务处理; 启用中断,从堆栈中有效地恢复所有线程A的堆栈顶部寄存器值;
下图就是我们得到的:
我们可以发现上下文在线程B的堆栈中被保存了两次。但实际上,如果我们没有溢出堆栈,后果还不是很糟糕,因为这个双保存的上下文将在下一次线程B获得控制时被消除。例如,让我们假设线程A进入休眠状态,内核切换回线程B,上下文被保存在线程A的堆栈上。
禁用中断,将堆栈指针切换到线程B的堆栈顶部,执行其他进程处理;
启用中断,从堆栈中恢复线程B的上下文。
可以说,我们基本上又回到了我们在切换到线程A之前保存上下文的状态。所以,我们继续可以继续来进行:
把完成的上下文保存到堆栈,选择下一个线程运行(现在,线程B线程已经激活,所以,不需要线程切换);
恢复上下文。
之后,线程B就可以按照正常流程来继续操作了,
正如以上分析的那样,我们并没有看到什么意外发生,但我们应该从从得出一个重要的结论:我们所假设的每一个堆栈的运行都是错误的。至少,它必须能够保存2个上下文,而不是单个上下文。
所以,每个线程的堆栈应该能够:
线程本身的数据; 线程上下文(在MIPS,它是34个字,即136字节); 保存第二上下文, 136字节以上; 意外情况ISR的数据。
这时,所占的空间就非常大了,
不过,这样的假设可能让事情变得更加糟糕。
因为即使我们分析了保存双重上下文的过程,也仍然不能解释上下文如何被保存到堆栈10次以上。
请注意,TNKernel有一个限制,所有的系统中断必须有相同的优先级,所以,当第二个上下文被保存到堆栈,如果触发一些其他中断,它不会再次中断,因为当前处理器的中断级别优先级已经被设定。
但是再看看这个,ISR已经完成了,我们切换到线程A:
此时,当前处理器的中断级别被降低,但是上下文却被保存在线程B的堆栈中两次。
于是,当我们切换回线程B时,并且当其上下文被恢复时,可能再发生一次中断。
是的,上下文已经保存了三次。更糟的是,同一个中断可以多次保存上下文。
所以,中断就周期性的发生,并且完全以相同的任务被来回切换,上下文被一次又一次地保存,这最终导致堆栈溢出。
另外,在测试中还有另外一个情况,就是ADC中断被频繁触发。
ADC硬件测量下一个值并触发中断,我们的ADC ISR被触发,从ADC硬件获取结果,并向高优先级线程发送消息,该线程分析ADC数据:
当ISR返回时,内核将控制切换到ADC线程; ADC线程快速完成其工作,并进入睡眠状态,直到发送下一条消息; 当触发ADC中断时,内核切换回任何正在执行的线程。并且当该线程的上下文恢复时,触发新的ADC中断。
由于,ADC中断产生太频繁,所以内核的行为是完全不能被接受的。正确的行为应该是是,我们的线程永远得不到控制,直到中断生成停止。堆栈不应该被大量保存的上下文干扰,并且当中断生成停止时,系统应该继续操作。
即使没有这样的周期性中断,当多个中断保存上下文2次或2次以上,并且嵌入式应用往往会持续工作在汽车报警系统等中持续的进行工作(几个星期,几个月,甚至几年),因此,随着时间的增加,这种故障的概率肯定会出现。
所以我们就要阻止这种情况的出现,
现在我知道失败的原因。但是如何解决呢?
可能最不负责任的解决方法就是禁用中断,同时保存/恢复上下文,但它是一个非常坏的解决方案,因为临界时间段应该尽可能短,所以禁用中断这么长时间显然不是啥好主意。
一个更好的解决办法就是使用单独的堆栈中断。
对TNKernel进行改进
在所有TNKernel源码中发生的最常见的样本就是如下代码:
如果必须在返回之前执行上面的代码,那将会带来一个严重的后果,我们应该这样把上面的代码修改为下面这样:
我们已经在最初的TNKernel 2.7代码中发现了这样的错误。函数tn_sys_tslice_ticks()如下所示:
如果仔细观察,可以看到,如果给定错误的参数,则返回TERR_WRONG_PARAM,并且仍保持禁用中断。如果我们遵循一个入口点,一个退出点规则,这个错误不太可能发生。
不过TNKernel 2.7代码有很多代码重复。由于互斥体具有用于任务优先级的复杂算法,并且以不一致,混乱的方式实现,所以必将导致错误。
任务状态之间的转换同样以不一致的复制粘贴代码的方式完成。当我们需要将任务从RUNNABLE状态移动到WAIT状态时,仅仅清除一个标志并设置另一个标志是不够的,我们还需要从任何运行队列中删除它。
所以正确的方法就是为每个状态创建三个函数:
设置状态; 清除状态; 以测试状态是否活动。
然后,当我们需要将任务从一个状态移动到另一个状态时,我们通常应该调用两个函数:一个用于清除当前状态,一个用于设置新的状态。
当我们需要改变运行状态时,我们只需要在几个地方改变它。不过,这样非常容易出现错误。
所以我们决定重新重构它。为了确保我不破坏任何最初的功能,我们要为它进行单元测试。但很快,我们发现原来的TNKernel根本没有经过测试!
有关TNKernel问题的详细信息,请点击以下链接:https://bitbucket.org/
认识TNeo
TNeo是我们对TNKernel进行改进重构后的一种称呼。TNeo具有一套完整的常见RTOS功能,以及一些额外功能:
任务或线程:内核写入的最常见的功能; Mutexes:共享资源保护对象: 递归互斥体:可选地,互斥体允许嵌套锁定; Mutex死锁检测:如果发生死锁,内核可以通过调用任意函数来通知用户这个问题; 信号量:任务同步的对象; 固定大小的内存块:简单和确定性的内存分配器; 事件组:包含任务可设置,清除和等待的各种事件位置的对象; 事件组连接:当我们需要等待来自多个队列的消息或其他一组不同事件时,这是个非常有用的功能。 数据队列:任务可以发送和接收的消息的FIFO缓冲区; 定时器:要求内核在未来的特定时间调用任意函数,为回调方法提供极大的灵活性; 单独的中断栈:中断使用单独的栈,这种方法节省了大量的RAM; 软件堆栈溢出检查:对于没有硬件堆栈指针限制的架构非常有用的功能; 分析器:允许我们知道每个任务实际运行的时间,获得其最大连续运行时间以及其他相关信息。
目前,TNeo可用于以下体系结构:
ARM Cortex-M内核:Cortex-M0 / M0 + / M1 / M3 / M4 / M4F(支持的工具链:GCC,Keil RealView,clang,IAR) Microchip:PIC32MX / PIC24 / dsPIC 最新稳定的TNeo:html,pdf 当前开发TNeo BETA:html,pdf
TNeo的具体实现
首先,我们需要解释一个内部数据结构:链表。
链接列表,链接列表是将一些数据实体链接在一起的机制。这是非常流行的数据结构,我们可能已经熟悉它了。然而,为了表述的完整性,我们还是有必要详细说明一下。
TNeo大量使用链接列表。更具体地说,它使用循环双向链表。 C结构如下:
它在文件src / core / tn_list.c中定义。
正如我们所看到的,C结构包含两个相同结构的实例的引用。
我们可以创建一个这样的结构链,使它们排列如下:
不过这是要想在每个对象中有一些有效载荷,这个办法就不是特别有用。
最好的方法是将struct TN_ListItem嵌入到我们想链接的任何其他结构中。例如,假设我们有MyBlock的结构:
我们希望使这些结构可链接。首先,我们需要嵌入struct TN_ListItem。
在这个例子中,按照逻辑,应该将struct TN_ListItem放在struct MyBlock的开头,但是为了强调结构可以在任何地方,现在就让我们把它放在中间:
现在,创建它的一些实例:
现在,还有一个关键点:创建一个列表的头。头部只是一个常规的结构TN_ListItem,但不会嵌入任何地方:
现在,我们可以创建以下排列:
它可以通过这样的代码创建:
从上面可以看出,我们还是需要TN_ListItems链,而不是MyBlocks链。但是,从MyBlock的开始到其list_item的偏移是所有MyBlock实例的常量。所以,如果我们有一个指向TN_ListItem的指针,并且我们知道它的这个实例被嵌入到MyBlock,我们可以减去所需的偏移量,最后我们会得到一个指向MyBlock的指针。
有一个特殊的宏:container_of()(在文件src / core / internal / _tn_sys.h中定义):
所以给一个指向TN_ListItem的指针,我们可以很容易得到一个来包装MyBlock的指针:
现在,我们可以遍历列表中的所有块,如下所示:
这段代码虽然会工作,但有点乱。最好使用特殊的宏进行迭代,如下所示:_tn_list_for_each_entry(),在文件src / core / internal / _tn_list.h中定义。
这样,我们可以隐藏所有的代码混乱,并通过我们的MyBlock实例列表迭代,像这样:
所以,我们可以利用这种方式来创建对象列表。我们甚至可以在多个列表中包含相同的对象:
TNeo从不从堆分配内存,它只对作为一些内核服务的参数给出的对象进行操作。
所以,当任务要等待一些互斥,任务被添加到这个互斥的等待任务的列表,并且这个操作的复杂性是O(1):也就是说,它总是在常量时间。
如果要实现一个void * data,我们有两个选择:
当向一些链表添加新对象时,TNeo应该从堆(或从一些预分配池)内部分配此列表项;
客户端应该手动分配TN_ListItems,并将其提供给可能需要它的任何内核服务。
正如前面已经提到的,TNeo大量使用了链接列表:
系统中有所有创建的任务的列表,迭代所有可能很有用的任务,例如,生成概要分析器报告; 当任务可运行时,它被添加到运行队列的优先级; 当任务正在等待来自某个队列的消息时,它被添加到队列的等待任务的列表;
实际上,整个Linux内核中也使用了相同的想法。很多辅助宏都是从Linux Kernel中获得的。
任务(线程)
任务或线程是系统运行的重要组成部分。在TNeo和其他实时内核的上下文中。
在此我们要说明一下,“线程”和“任务”,是两个完全相同的概念,在本文中,我么会互换使用这两个术语。
系统中的每个现有任务都有自己的描述符,struct TN_Task会在文件src / core / tn_tasks.h中定义。
任务描述符的第一个字段是指向任务栈顶部的指针——stack_cur_pt。
内核维护两个指针:到当前运行的任务和运行所需的任务。
在src / core / internal / _tn_sys.h中,我们有:
任务优先级和运行队列,任务可以有不同的优先级。可用的优先级的最大数量由处理器的字大小决定:对于16位MCU,最多有16个优先级,对于32位MCU,最多有32个优先级。要想使内核工作尽可能快,说最后不要在应用程序中超过5个优先级。
对于每个优先级,都有一个属于此优先级的可运行(准备运行)任务的链表。
在src / core / internal / _tn_sys.h中,我们有:
其中TN_PRIORITIES_CNT是用户可配置的值(当然,它不能大于最大值)。
任务描述符具有包含在这些列表中的TN_ListItem:
还有一个位掩码(单个字),其中每个位集合意味着有一些具有相应优先级的可运行任务:
因此,当内核需要确定下一个任务要运行时,它会对该字执行特定于架构的查找优先指令,并立即获得所有可运行任务的最大优先级。然后,它从适当的运行队列(_tn_tasks_ready_list)获取第一个任务,并运行该任务。这就是为什么最大优先级数取决于字的大小。
当然,当任务进入或离开Runnable状态时,它保持_tn_ready_to_run_bmp中的相应位。
任务的上下文,记住,当任务当前没有运行时,它的上下文(所有寄存器值和程序计数器地址)被保存到堆栈。并且任务描述符中的stack_cur_pt地址也会指向保存的上下文的顶部。
对于每个支持的体系结构(MIPS,Cortex-M等),都有一个明确定义的上下文结构,所有这些寄存器都要在堆栈中排列。当任务刚被创建并将要运行时,这些寄存器的堆栈空间会被填充以“初始”上下文,当实际运行时,该初始上下文被恢复。这样,就能保证每个任务都在其单独,干净的环境中开始。
任务状态,任务可以处于以下任何一种状态:
Runnable:任务已准备好运行(这不意味着它正在运行); 等待:任务正在等待运行指令; 已暂停:任务已暂停或通过其他任务完成; 等待+暂停:任务先是等待,此后再被暂停; Dormant:任务尚未激活或它被tn_task_terminate()终止。当下一个任务被激活后,任何先前的任务数据将被重置,并且它将在一个全新的环境中运行。
当任务离开或进入某种状态时,我需要提前做一些事情。例如:
当任务离开Dormant状态时,我们应该初始化它的堆栈,以便任务将在一个全新的环境中运行; 当任务进入Runnable状态时,我们需要将其添加到相应优先级的运行队列,并保持_tn_ready_to_run_bmp,以便内核最终调度它; 当任务离开Runnable状态时,我们需要从当前运行队列中删除它,维护_tn_ready_to_run_bmp,如果这是当前运行的任务,那么我们还需要找到要运行的新任务; 当任务进入等待状态时,我们需要将其添加到相应的等待队列(如果需要),并且如果提供了超时,则添加到计时器队列; 当任务离开等待状态时,我们需要从等待队列中删除它(如果有的话),如果它是活动的,复位定时器;
总之,无论什么情况,我们总是应该从当前运行队列中删除任务并执行其他操作。
实现它的方法是每个状态有两个函数:
使任务处于这种状态; 使任务脱离这种状态。
因此,在文件src / core / tn_tasks.c中,我们有以下函数:
当我们需要将任务从一个状态移动到另一个状态时,如下图所示:
任务创建,由于任务在运行之前需要堆栈空间。所以,在创建任务之前,我们需要为这个任务分配一个要用作堆栈的数组,并将其与其他东西一起传递给tn_task_create()。
内核将任务栈的顶部设置为所提供的值,并将任务置于Dormant状态,然后,当用户调用tn_task_activate(),内核的就会产生如下行为:
从Dormant状态删除任务,正如前文所提到,在这一刻,任务的栈被初始化为全新的上下文; 将任务置于Runnable状态,正如前文所提到,此时,任务被放置在相应的运行队列的末尾(用于任务的优先级)。
现在,内核的调度器将负责这个任务,并在需要时运行它。
让我们来看看任务如何运行。
任务运行
实际任务运行的过程当然非常依赖于架构,内核的行为如下: 从_tn_next_task_to_run指向的任务描述符获取堆栈指针,并将其设置为当前堆栈指针; 将当前运行的任务(_tn_curr_run_task)设置为_tn_next_task_to_run;
从堆栈加载所有寄存器值,作为这些寄存器值的一部分,存在应当恢复任务执行的程序地址,
将控制转移至任务
将控制转移至任务即恢复任务执行的方式严重依赖于架构。例如,在MIPS上,我们应该将任务的程序计数器保存到寄存器EPC(异常程序计数器),并执行指令eret(从异常返回)。所以,内核“处理”处理器的行为就像从“正常”异常返回。之后,将当前PC(程序计数器)设置为存储在EPC中的值,并且有效地恢复任务。
上下文切换
当内核停止当前任务的执行,并切换到下一个任务时,它被称为上下文切换,不过始终在最低优先级的ISR中运行。因此,当需要上下文切换时,内核设置中断挂起位。如果当前执行的代码是用户代码(当前中断优先级为0),则硬件立即调用上下文切换例程(ISR)。然而,如果该位是从某个其他ISR设置而来的,则当前运行的ISR返回时,稍后将调用上下文切换ISR。
当然,用于上下文切换的中断还是取决于架构。例如,在PIC32上,使用核心软件中断。 Cortex-M芯片有专用的上下文切换:PendSV。
当调用上下文切换ISR时,其操作如下:
将所有寄存器值一个一个地保存到当前任务的堆栈。作为这些寄存器值的一部分, 当前任务的程序计数器被保存,使得任务可以稍后恢复; 当所有寄存器值保存到堆栈时,将堆栈指针保存在当前任务的描述符中; 可能会执行上下文切换处理程序(如果启用这些功能中的任何一个,则需要用于分析器和软件堆栈溢出控制);
现在,执行与前面“任务运行”完全相同的序列。
当某些高优先级任务变为Runnable或当前正在运行的任务置于Wait时,需要进行上下文切换。
例如,让我们假设我们有两个任务:低优先级发射机和高优先级接收机。 Receiver尝试从队列中获取消息,并且由于队列为空,它被置于等待状态。它不再用于运行,因此,内核进入低优先级任务发送器。
从上图可以看出,当低优先级发送器通过调用tn_queue_send()发送消息时,它会被内核优先于高优先级接收器立即抢占。所以,到tn_queue_send()返回的时候,已经发生了很多运行:
上下文切换到接收器; Receiver接收消息并处理它; Receiver尝试获取下一条消息,并置于Wait; 上下文切换回发送器。
这样,系统就会变得非常敏感:如果您为任务设置了正确的优先级,则以上这些运行会很快就会被处理。
空闲任务
在TNeo中有一个特殊任务:空闲任务。它具有最低的优先级(用户任务不能具有低优先级),并且它总是可运行的,因此_tn_ready_to_run_bmp至少设置了一个位,即空闲任务的最低优先级的位。显然,当没有其他可运行的任务时,内核将会运行空闲任务。
我们可以实现一个回调函数,它将从空闲任务中被无限地调用,然后用于各种目的:
处理器睡眠,当系统没有任何事情时,处理器便处于睡眠状态。当然,应用程序负责会设置是一个中断来唤醒系统; 系统负载计算,最简单的实现是在idle任务中增加一些变量。速度越快,加载的系统越少。
由于空闲任务始终可以运行,因此禁止从回调中调用任何可以使当前任务等待的内核服务。
计时器
内核需要知道运行时间。有两种方案可以实现:静态滴答和动态滴答。
静态滴答
静态滴答是实现超时的最简单的方法,因为硬件定时器会周期性地产生中断。在本文中,该定时器被称为系统定时器。该定时器的周期由用户确定(通常为1ms,但用户可自由设置不同的值)。在此定时器的ISR中,只需调用tn_tick_int_processing()函数:
每次调用tn_tick_int_processing()时,我们都说系统滴答发生了。在这个函数内,内核会检查一些定时器是否完成计数,并执行适当的动作。
最简单的实现定时器的方法如下,我们只需要一个列表,所有活动的定时器,都会在每个系统设置,我们应该访问所有的定时器列表,并对每个定时器做如下操作:
将超时减少1 如果新的超时为0,然后从列表中删除该定时器(即使定时器无效),并触发相应的定时器功能。
不过这种方法有以下缺点:
我们不能从定时器调用的函数管理定时器。如果我们这样做(例如,如果我们开始新的计时器),那么定时器列表就会被修改。但是我们目前正在迭代这个列表,所以,它很容易被混淆。
对于大量的计时器和过多的超时,这种方法是低效的。
因此,我们来换一种另外的办法,主要是利用Linux内核主线,因为:
嵌入式系统有更少的资源, 内核不需要像Linux一样扩展。
如果定时器在接下来的1到(N-1)个系统节拍中延时,则使用超时的最低有效位会将其添加到专用于短程定时器的N个列表(所谓的“节拍”列表)。如果还没有延时,它被添加到“通用”列表。
来自“通用”列表的所有定时器都会被遍历,并且每个定时器都会执行以下操作,
超时值减小N,如果结果超时小于N,定时器被移动到其他列表中。
在每个系统滴答时,从当前“滴答”列表的所有计时器无条件地触发。这是一个高效的解决方案。
如果我们有N个列表,为什么我们要使用(N-1)个“滴答”列表,这是因为,我们希望能够从定时器函数修改定时器。如果我们使用N个列表,并且用户想要添加具有超时为N的新定时器,则新定时器将被添加到当前被迭代的相同列表,这样很容易出现混乱。
如果我们使用(N-1)个列表,我们就可以保证当我们迭代它时,新的计时器不能被添加到当前的“滴答”列表中。
TNeo中的N由编译时的选项TN_TICK_LISTS_CNT配置。
动态滴答
一般的想法是,应该没有对tn_tick_int_processing()的无用的调用。如果内核在100个系统节拍之后需要被唤醒,那么在系统节拍的100个周期之后,应该精确地调用tn_tick_int_processing()(但是外部异步事件仍然可以发生并重新调度)。
为此,内核应该能够与应用程序通信:
在N个tick之后计划下一次调用tn_tick_int_processing();
询问现在的时间(即获取当前系统滴答)。
因此,当动态节拍模式处于活动状态(TN_DYNAMIC_TICK设置为1)时,用户应该向内核提供这些回调。调度下一次调用tn_tick_int_processing()的实现会完全依赖于MCU(即使在相同的架构上,有很多不同的MCU,它们有不同的外设等),所以,是由应用程序正确实现这些回调。
系统启动
为了使系统正常运行,内核需要满足以下条件:
空闲任务的堆栈空间; 中断堆栈空间; 空闲任务回调函数; 要运行的某些用户任务。
因此,用户应用程序应该为空闲任务和中断分配堆栈数组,提供空闲回调函数(可能为空),并提供特殊的回调函数,创建至少一个(通常只有一个)用户任务。这将是运行的第一个任务,在我们的应用程序中,将其命名为“task_init”或“task_conf”。显然,它需要被初始化。
在main()中,用户应该:
通过调用tn_arch_int_dis()禁用系统中断; 执行一些基本的CPU配置,如振荡器设置等; 设置系统定时器中断(从中调用tn_tick_int_processing()); 调用tn_sys_start(),提供所有必要的信息即堆栈指针,它们的大小和回调函数。
内核的行为如下:
初始化运行队列(_tn_tasks_ready_list)和其他内核的东西; 创建并激活空闲任务(_tn_next_task_to_run设置为空闲任务); 调用用户的回调函数,创建并激活用户初始任务(_tn_next_task_to_run设置为用户任务); 调用与架构相关的函数_tn_arch_sys_start(),这样它的初始化调度程序中断,并对_tn_next_task_to_run指向的任务执行第一个上下文切换。 此时,系统正常运行,用户初始任务被执行。通常,初始任务会执行以下操作: 执行各种板载外设(显示器,闪存芯片或其他)的初始化; 初始化应用程序使用的软件模块; 创建所有剩余的用户任务;
单元测试
通常,当人们谈论单元测试时,它们是指在主机上运行的测试。但是,由于嵌入式C编译器本身的错误,所以我们决定直接在硬件中测试内核。
有一个高优先级任务,如“test director”,创建工作任务以及各种内核对象(队列,互斥体等),然后进行命令,如:
任务A,锁定互斥M1
任务B,锁定互斥M1
任务C,锁定互斥M1
任务A,删除互斥M1
命令完成后,还会对完成状态进行检查,包括任务状态,任务优先级,服务的最后返回值,对象的各种属性等。
下图是代码的一部分,指定上面解释的锁定和删除互斥的序列:
下图是被回送到UART的日志的部分:
如果出现命令错误,测试就会停止。
总结
TNeo如何处理中断
通常,在操作系统中有两种处理中断的方法:统一和分段。
RTOS中断体系结构
在很短的时间内,具有分段中断架构的RTOS可以被写入,以便它从不禁用中断。另一方面,它增加了上下文切换的难度,并且很难为分段结构编写应用程序。
所以使用统一中断架构编写RTOS的应用程序更容易,并且上下文切换更快,但是这样的RTOS必须在其关键部分全局禁用中断。
TNeo会使用统一的中断架构。它在几乎所有内核服务中禁用短时间的中断。这对于特定的应用程序是很好用的,因为他们永远不会遭受禁用中断,例如,100指令。例如,在40MHz上,100个指令需要2.5us。
虽然分段和统一中断架构RTOS都支持嵌入式系统的确定性实时管理,但是它们之间在系统的效率和简洁性方面存在显着差异。分段的RTOS可以准确地从系统服务中的禁用中断。然而,为了实现这一点,它们必须延迟应用程序线程,不过这样做会产生更糟的结果。另外分段方法还向上下文切换过程添加可测量的难度,并且使应用开发复杂化,所以根据我们的经验,统一的中断架构RTOS在实时嵌入式系统开发中会有明显优势。
现在,我们已经把所有的项目都移到TNeo了, 正如前文所述,TNeo是一个格式良好,经过仔细测试的16位和32位MCU的抢占式实时内核,运行紧凑且快速。
目前,TNeo由GitHub托管,请点击以下链接:https://github.com/dimonomid/tneo