使用Nucleus SE实时操作系统
使用Nucleus SE实时操作系统
Using the Nucleus SE real-time operating system
到目前为止,在本系列文章中,我们详细介绍了Nucleus SE提供的所有设施。现在是时候看看如何在一个真正的嵌入式软件应用程序中使用它。
什么是核SE?
我们知道Nucleus SE是一个实时内核,但是了解它如何与应用程序的其余部分相适应是很重要的。“适应”正是它所做的,因为与桌面操作系统(如Windows)不同,您并不真正在Nucleus SE上运行应用程序;内核只是运行在嵌入式设备上的应用程序软件的一部分。这种情况在实时操作系统中非常常见。
在最高层次上,传统的嵌入式应用程序是一些代码,当CPU复位时运行。这将初始化硬件和软件环境,然后调用main()函数,这是应用程序代码的开始。不同的是,在使用Nucleus SE(或许多其他类似的内核)时,main()函数是作为内核代码的一部分提供的。此函数只需初始化所有内核数据结构,然后调用调度程序,从而运行应用程序代码(任务)。用户可能希望向main()函数添加任何特定于应用程序的初始化。
Nucleus SE还包括一系列函数——应用程序接口(API)——它们提供了一系列功能,如任务间通信和同步、定时、内存分配等。本系列前面已经介绍了所有API函数。
所有Nucleus SE软件都是作为(主要是C语言)源代码提供的。条件编译用于根据特定应用程序的要求配置代码。这将在本文后面的配置中详细描述。
当代码被编译后,生成的Nucleus SE对象模块与应用程序代码的对象模块相链接,以生成一个二进制图像,该图像通常被放置在嵌入式设备的闪存中。这样一个静态链接的结果是,所有的符号信息都可能保持可用——无论是来自应用程序代码还是来自内核。这对调试是一个有用的帮助,但是需要注意避免误用Nucleus SE数据。
CPU和工具支持
由于Nucleus SE是在源代码中提供的,其目的是使其具有可移植性。然而,在如此低的级别上工作的代码(即,除了运行到完成之外,需要上下文切换的调度程序)不可能完全脱离汇编语言。但我已经将这一点降到了最低限度,移植到一个新的CPU上只需要很少的低级代码。使用新的开发工具包(编译器、汇编程序、链接器等)也可能引起可移植性问题。
配置Nucleus SE应用程序
有效使用Nucleus SE的关键是正确配置。这可能看起来很复杂,但实际上相当合乎逻辑,只是需要系统地处理。几乎所有的配置都是通过编辑两个文件来完成的:nuse_config.h和nuse_config.c。
设置nuse_config.h
这个文件只是一个#define符号的列表,这些符号被设置为适当的值来指定所需的内核配置。在默认的nuse_config.h文件中,所有符号都存在,但设置为最小配置。
对象计数
每个内核对象类型的数量是通过为NUSE_SEMAPHORE_number形式的符号赋值来设置的。对于大多数对象,该值可以是0到15。任务是例外,因为必须至少有一个任务。信号本身并不是真正的对象,因为它们与任务相关,并通过将NUSE_SIGNAL_SUPPORT设置为TRUE来启用。
API函数启用
每个Nucleus SE API函数可以通过将与函数同名的符号(例如NUSE_PIPE_JAM)设置为TRUE来单独启用。这将导致函数的代码包含在应用程序中。
调度程序选择和设置
Nucleus SE支持四种类型的调度程序,如前一篇文章中详细描述的那样。要用于应用程序的调度程序类型可以通过将NUSE_scheduler_type设置为以下值之一来指定:NUSE_RUN_to_COMPLETION_scheduler、NUSE_TIME_SLICE_scheduler、NUSE_ROUND__scheduler或NUSE_PRIORITY_scheduler。
还可以设置调度器的其他方面:
NUSE_TIME_SLICE_TICKS指定时间片调度程序每个插槽的计时点数。如果正在使用另一个计划程序,则必须将其设置为0。
可将“任务调度程序”设置为“启用U计数”或“禁用U计数”机制。
使用SUSPEND_ENABLE可以方便地支持任务挂起。如果设置为FALSE,则任务永远不会挂起,并且始终准备好运行。如果使用优先级计划程序,则必须将此选项设置为TRUE。 NUSE_BLOCKING_ENABLE在许多API函数上启用任务阻塞(挂起)。这意味着对这样一个函数的调用可能会导致调用任务暂停,等待资源的可用性。选择此选项要求NUSE_SUSPEND_ENABLE也设置为TRUE。
其他选项
可以将其他一些选项设置为TRUE或FALSE以启用/禁用其他内核功能:
NUSE_API_PARAMETER_CHECKING允许包含代码来验证API函数调用参数,通常在调试期间设置。
NUSE_INITIAL_TASK_STATE_SUPPORT可将所有任务的初始状态指定为NUSE_READY或NUSE_PURE_SUSPEND。如果此功能被禁用,则所有任务都将以“使用就绪”状态启动。
NUSE_TIMER_EXPIRATION_ROUTINE_SUPPORT支持在应用程序计时器过期时调用函数。如果禁用,则在计时器过期时不执行任何操作。
支持使用系统时钟。
尽可能多地将Nucleus配置包括在u中。它会激活已配置对象的所有可选功能和每个API函数。它被用作创建Nucleus SE配置的速记,以执行内核代码的新端口。
设置nuse_config.c
在nuse_config.h中指定内核配置后,需要初始化各种基于ROM的数据结构。这是在nuse_config.c中完成的。数据结构的定义由条件编译控制,因此它们都存在于nuse_config.c的默认副本中。
任务数据
数组NUSE_Task_Start_Address[]应该用每个任务的开始地址初始化–这通常只是一个函数名的列表(没有括号)。任务输入函数的原型也必须是可见的。在默认文件中,一个任务被配置为NUSE_Idle_task()——这可能会被应用程序任务替换。
除非正在使用“运行到完成”计划程序,否则每个任务都需要自己的堆栈。RAM中的数组通常是为每个任务的堆栈创建的。数组的类型应该是ADDR,每个数组的地址都应该放在NUSE_Task_Stack_Base[]中。估计数组的大小很困难,最好通过测量来完成——请参阅本文后面的调试。每个数组的大小(即用文字表示的堆栈大小)应放在NUSE_Task_stack_size[]中。
如果指定初始任务状态的功能已启用(使用NUSE_initial_task_STATE_支持),则数组NUSE_task_initial_STATE[]应初始化为NUSE_READY或NUSE_PURE_SUSPEND。
分区池数据
如果配置了任何分区池,则需要在RAM中为每个分区池定义一个数组(U8类型)。这些数组的大小是这样计算的:(分区数*(分区大小+1))。这些数组的地址(即它们的名称)应该分配给NUSE_Partition_Pool_Data_Address[]的适当元素。对于每个池,分区数和分区大小应分别分配给NUSE_Partition_pool_Partition_number[]和NUSE_Partition_pool_Partition_size[]。
队列数据
如果配置了任何队列,则需要在RAM中为每个队列定义一个数组(ADDR类型)。这些数组的大小只是每个队列中所需的元素数。这些数组的地址(即它们的名称)应该分配给NUSE_Queue_Data[]的适当元素。对于每个队列,其大小应分配到NUSE_queue_size[]的适当元素中。
管道数据
如果配置了任何管道,则需要在RAM中为每个管道定义一个数组(U8类型)。这些数组的大小是这样计算的:(pipe size*pipe message size)。这些数组的地址(即它们的名称)应该分配给NUSE_Pipe_Data[]的适当元素。对于每个管道,其大小和消息大小应分别分配到NUSE_pipe_size[]和NUSE_pipe_message_size[]的适当元素中。
信号量数据
如果配置了任何信号量,则需要将数组NUSE_simaphore_Initial_Value[]初始化为下计数器开始值。
应用程序计时器数据
如果配置了任何计时器,则需要将数组NUSE_Timer_Initial_Time[]初始化为计数器的起始值。另外,需要将NUSE_Timer_Reschedule_Time[]设置为重新启动值。这些是初始序列过期后要使用的计数器值。如果重新启动值设置为0,计数器在一个循环后停止。
如果配置了对计时器过期例程的支持(通过将NUSE_timer_expiration_ROUTINE_support设置为TRUE),则需要再初始化两个数组。过期例程的地址(只是函数名的列表(不带括号)应分配给NUSE_Timer_expiration_Routine_Address[]。数组NUSE_Timer_Expiration_Routine_参数[]应初始化为到期例程参数值。
哪个API?
所有操作系统都有某种应用程序接口(API)。Nucleus SE也不例外,组成其API的函数调用在本系列中已经有了广泛的介绍。
因此,当编写一个包含Nucleus SE的应用程序时,很明显您将使用它所描述的API。情况未必总是如此。
对于许多用户来说,Nucleus SE API将是新的,可能是他们使用操作系统API的第一次体验,而且由于它相当简单和直接,因此它可能会很好地介绍这个主题。在这种情况下,前进的道路是明确的。
对于其他一些用户,另一种API可能很有吸引力。在这种情况下,有三种明显的情况:
Nucleus SE应用程序只是系统的一部分,其他操作系统正在/正在用于其他组件。具有代码的可移植性,更重要的是,在所使用的操作系统之间具有专业知识是非常有吸引力的。
用户对另一个操作系统的API有丰富的经验。重用这些专业知识是非常可取的。
用户希望重用为其他操作系统的API编写的代码。重新编码以更改API调用是可能的,但很耗时。
由于提供了Nucleus SE的完整源代码,因此完全可以编辑每个API函数,使其看起来与另一个操作系统中的等效函数相同。然而,这将是一个耗时的过程,最终是徒劳的。更好的方法是编写一个“包装器”。这可以通过多种方式实现,但最简单的是一个头文件(#include),其中包含一系列从“外来”API映射到Nucleus SE API的“定义宏”。
发行版中包含了一个将Nucleus RTOS API映射到Nucleus SE的包装器。有Nucleus RTOS经验的开发人员可以使用它,或者将来有可能迁移到这个RTOS。这个包装器也可以作为一个例子来帮助另一个包装器的开发。
调试Nucleus SE应用程序
使用多任务内核编写嵌入式应用程序是一个挑战。验证这段代码是否有效,以及错误的识别可能会非常麻烦。尽管它只是在处理器上执行代码,但多个任务的明显并发执行意味着专注于特定的执行线程并不容易。当代码在任务之间共享时,这种情况会更加复杂;最糟糕的情况是两个任务使用完全相同的代码(但显然操作不同的数据)。另一个问题是用于实现内核对象的数据结构的解构,以便以有意义的方式查看信息。
调试使用Nucleus SE构建的应用程序不需要任何特殊的库或其他工具。所有的内核源代码都在那里,并且可能对调试器“可见”。因此,所有的符号信息都可供使用和询问。任何现代调试工具都可以用于基于Nucleus SE的应用程序。
使用调试器
专门为嵌入式应用程序设计的调试工具已经与我们一起使用了30多年,因此变得非常复杂。与桌面程序相比,嵌入式应用程序的主要特点是每个嵌入式系统都不同(但一台PC看起来非常相似)。一个好的嵌入式调试器的诀窍在于它要足够灵活和可定制,以适应从一个用户到另一个用户的需求变化。调试器的可定制性以各种形式表现出来,但通常有一些脚本功能。尤其是这个工具,可以利用它使调试器在基于内核的应用程序中运行良好。我将在这里回顾一些可能性。
需要注意的是,调试器通常是一系列工具,而不仅仅是一个程序。调试器可能有不同的操作模式,因此它可以帮助在模拟目标或真实目标硬件上开发代码。
任务感知断点
如果一个应用程序中的多个任务共享代码,那么使用断点的常规调试会非常混乱。很可能您只希望代码在您尝试调试的特定任务上下文中达到断点时停止。您需要的是一个任务感知断点。
幸运的是,现代调试器的脚本工具和Nucleus SE符号的可见性使得实现任务感知断点非常简单。所需要的只是一个简单的脚本,它附加到一个断点上,您希望使它成为“任务感知”的。此脚本将接受一个参数,即您感兴趣的任务的索引(ID)。脚本只需将该值与当前正在运行的任务(在NUSE_task_Active中)的索引进行比较。如果值匹配,则停止执行;如果它们不同,则允许继续执行。应该指出的是,此脚本的执行将对应用程序的实时配置文件产生一些影响,但是,除非它处于脚本可能非常频繁地执行的循环中,否则这种影响将是最小的。
核心对象信息
在调试基于Nucleus SE的应用程序时,一个明显的需求是能够了解内核对象的特性和当前状态。这需要回答这样的问题:“队列有多大,其中有多少消息?”
一种促进这一点的方法是向应用程序添加一些额外的调试代码,这可以使用“information”API调用,比如NUSE_Queue_information()。当然,这意味着您的应用程序包含额外的代码,部署后不需要这些代码。使用一个#define符号,使用条件编译来切换此代码,将是一个明智的解决方案。
有些调试器可以执行目标函数调用,即直接调用informationapi函数。这就避免了添加额外代码的需要,除了必须为调试器配置API函数才能使用它。
另一种方法是直接访问内核对象的数据结构,这种方法更灵活,但不太“经得起未来考验”。最好使用调试器的脚本功能来完成。在我们的例子中,队列的大小可以从NUSE_queue_size[]获得,而它们的当前使用情况可以从NUSE_queue_Items[]中获得。此外,使用队列数据区域的地址(来自NUSE_queue_data[])和头/尾指针(NUSE_queue_head[]和NUSE_queue_tail[]),可以显示排队的消息。
API调用返回值
许多API函数返回一个状态值,该值指示调用是否成功。监视这些值并在它们不是NUSE_SUCCESS(值为零)的情况下标记这些值是很有用的。由于此监视仅用于调试目的,所以有条件编译是正确的。全局RAM变量的定义(例如NUSE_API_Call_Status)可以有条件地编译(在#define符号的控制下)。然后API调用的赋值部分(即NUSE_API_Call_Status=)可以类似地有条件地编译。例如,出于调试目的,调用通常如下所示:
使用邮箱发送(mbox、msg、NUSE_SUSPEND);
变成
NUSE_API_Call_Status=NUSE_邮箱发送(mbox、msg、NUSE_SUSPEND);
如果启用任务阻塞,许多API函数调用只能返回成功或对象已重置的指示。但是,如果启用了API参数检查,则可能会有其他各种返回值。
任务堆栈大小和溢出
在前面的文章中讨论了堆栈溢出保护的主题。在调试过程中,还有其他几种可能性:
堆栈内存区域可以用一个特征值填充,而不是全1或全零。然后可以使用调试器来监视内存位置,值的更改程度指示堆栈使用的程度。如果所有内存位置都已更改,则不一定意味着堆栈已溢出,但可能意味着堆栈仅足够大,这是脆弱的。它应该扩大,并接受进一步的测试。
如前一篇文章中所讨论的,在实现诊断时,添加位置-“保护字”可能位于堆栈内存区域的任一端。调试器可用于监视对这些字的访问,因为任何写入操作都会指示堆栈下溢或溢出。
Nucleus SE配置清单
因为Nucleus SE的设计非常灵活,并且可以定制以满足应用程序的精确需求,因此需要大量的配置。这就是为什么整篇文章基本上都致力于这个主题。为了便于确保涵盖所有内容,以下是构建基于Nucleus SE的嵌入式应用程序所涉及的所有关键步骤的清单:
获取Nucleus SE–尽管Nucleus SE的几乎所有代码都已在本系列中发布,但下一篇文章将告诉您如何以更直接的可用形式获取它。
考虑CPU/工具支持——可能需要重写汇编语言部分并重新起草构建脚本。
构建一个简单的演示-这将验证您是否拥有所有组件和工具是否兼容。
计划你的任务结构-有多少任务,他们做什么。设置它们的起始地址和堆栈大小。当然,你可以稍后再调整。一个应用程序中最多可以有16个任务。
应用程序初始化–是否需要向main()添加任何代码?
选择调度程序–您有四个可供选择,这可能会在以后更改。
验证定时器中断向量-如果你有一个定时器。
验证上下文开关陷阱向量
Signals–如果您要使用信号,请启用对它们的支持。
系统时钟–如果需要,启用时钟。
内核对象计数-确定您需要的每种对象的数量。你可以稍后更改。每种类型最多16个对象。
内核对象ROM–为您正在使用的每种类型的对象初始化ROM数据。
ramdata–为需要它的对象(队列、管道和分区池)设置RAM数据空间。
API enables–启用所需的所有API调用。