So far in this series of articles we have looked in detail at all the facilities that Nucleus SE has to offer. Now it is time to see how to use it for a real embedded software application.
What is Nucleus SE?
We know that Nucleus SE is a real time kernel, but it is important to understand how that fits in with the rest of an application. “Fitting in” is exactly what it does, because, unlike a desktop operating system, like Windows, you do not really run an application on Nucleus SE; the kernel is simply part of the application software that runs on the embedded device. This is very commonly the case with real time operating systems.
At the highest level, a conventional embedded application is some code, that is run when the CPU is reset. This initializes the hardware and software environment and then calls the main() function, which is the start of the application code. What is different, when using Nucleus SE (or many other similar kernels) is that the main() function is supplied as part of the kernel code. This function simply initializes all the kernel data structures, then it calls the scheduler, which results in the application code (tasks) being run. The user may wish to add any application specific initialization to the main() function.
Nucleus SE also includes a selection of functions – the application program interface (API) – that provide a range of facilities like inter-task communication and synchronization, timing, memory allocation etc. All the API functions have been described earlier in this series.
All the Nucleus SE software is provided as (mostly C language) source code. Conditional compilation is used to configure the code to the requirements of a specific application. This is described in detail in Configuration later in this article.
When the code has been compiled, the resulting Nucleus SE object modules are linked with those of the application code to result in a single binary image, which would normally be placed in flash memory in the embedded device. The result of such a static link is that all symbolic information may remain available – both from the application code and the kernel. This is a useful aid to debugging, but care is needed to avoid misuse of Nucleus SE data.
CPU and Tool Support
Since Nucleus SE is supplied in source code, the intention is for it to be portable. However, it is impossible for code that works at such a low level (i.e. with a scheduler other than run to completion, where context switches are required) to be totally free of assembly language. But I have minimized this, and very little low-level coding should be needed to port to a new CPU. Using a new development toolkit (compiler, assembler, linker etc.) may also raise portability issues.
Configuring a Nucleus SE Application
The key to using Nucleus SE efficiently is getting the configuration right. This may appear complex but is actually quite logical and just needs to be approached systematically. Almost all the configuration is performed by editing two files: nuse_config.h and nuse_config.c .
Setting up nuse_config.h
This file is simply a list of #define symbols, which are set to appropriate values to specify the desired kernel configuration. In the default nuse_config.h file, all the symbols are present, but set to a minimal configuration.
The number of each kernel object type is set by assigning values to symbols of the form NUSE_SEMAPHORE_NUMBER . For most objects, the value can be 0 to 15. Tasks are the exception, as there must be at least one. Signals are not really objects in their own right, as they are associated with tasks and enabled by setting NUSE_SIGNAL_SUPPORT to TRUE .
API Function Enables
Each Nucleus SE API function may be individually enabled by setting a symbol with the same name as the function – e.g. NUSE_PIPE_JAM – to TRUE . This results in the code for the function being included in the application.
Scheduler Selection and Settings
Nucleus SE supports four types of scheduler, as described in detail in an earlier article. The type of scheduler to be used for an application may be specified by setting NUSE_SCHEDULER_TYPE to one of these values: NUSE_RUN_TO_COMPLETION_SCHEDULER , NUSE_TIME_SLICE_SCHEDULER , NUSE_ROUND_ROBIN_SCHEDULER or NUSE_PRIORITY_SCHEDULER .
Other aspects of the scheduler may also be set up:
NUSE_TIME_SLICE_TICKS specifies the number of ticks per slot for the time slice scheduler. If another scheduler is in use, this must be set to 0.
NUSE_SCHEDULE_COUNT_SUPPORT can be set to TRUE or FALSE to enable/disable the task scheduler counting mechanism.
NUSE_SUSPEND_ENABLE facilitates support for task suspend. If this is set to FALSE , tasks can never be suspended and are always ready to be run. If the priority scheduler is used, this option must be set to TRUE .
NUSE_BLOCKING_ENABLE enables task blocking (suspend) on many API functions. This means that a call to such a function may result in suspension of the calling task, pending the availability of a resource. Selecting this option requires that NUSE_SUSPEND_ENABLE is also set to TRUE .
A few other options may be set to TRUE or FALSE to enable/disable other kernel functionality:
NUSE_API_PARAMETER_CHECKING enables inclusion of code to verify API function call parameters and would normally be set during debugging.
NUSE_INITIAL_TASK_STATE_SUPPORT enables the initial state of all tasks to be specified as NUSE_READY or NUSE_PURE_SUSPEND . If this facility is disabled, all tasks start with the status NUSE_READY .
NUSE_TIMER_EXPIRATION_ROUTINE_SUPPORT enables support for a call to a function to be made when an application timer expires. If it is disabled, no action is taken on timer expiry.
NUSE_SYSTEM_TIME_SUPPORT enables the system tick clock.
NUSE_INCLUDE_EVERYTHING is an option to include as much as possible into the Nucleus SE configuration. It results in the activation of all optional functionality and every API function for objects that have been configured. It is used as shorthand to create a Nucleus SE configuration to exercise a new port of the kernel code.
Setting up nuse_config.c
After specifying the kernel configuration in nuse_config.h , various ROM based data structures need to be initialized. This is done in nuse_config.c . Definition of the data structures is controlled by conditional compilation, so they are all present in the default copy of nuse_config.c .
The array NUSE_Task_Start_Address should be initialized with the start addresses for each task – this is normally just a list of function names (without the parentheses). Prototypes for the task entry functions must also be visible. In the default file, a single task is configured with the name NUSE_Idle_Task() – this may be replaced with an application task.
Unless the run to completion scheduler is in use, each task requires its own stack. An array in RAM much be created for each task’s stack. The arrays should be of type ADDR and the address of each one placed in NUSE_Task_Stack_Base . Estimating the size of arrays is difficult and best done by measurement – see Debugging later in this article. The size of each array (i.e. stack size in words) should be placed in NUSE_Task_Stack_Size .
If the facility to specify initial task status has been enabled (using NUSE_INITIAL_TASK_STATE_SUPPORT ), the array NUSE_Task_Initial_State should be initialized to NUSE_READY or NUSE_PURE_SUSPEND .
Partition Pool Data
If any partition pools are configured, an array (of type U8 ) needs to be defined in RAM for each one. The size of these arrays is computed thus: (number of partitions * (partition size + 1)). The addresses of these arrays (i.e. just their names) should be assigned to the appropriate elements of NUSE_Partition_Pool_Data_Address . For each pool, the number of partitions and the size of those partitions should be assigned into NUSE_Partition_Pool_Partition_Number and NUSE_Partition_Pool_Partition_Size respectively.
If any queues are configured, an array (of type ADDR ) needs to be defined in RAM for each one. The size of these arrays is simply the number of elements required in each queue. The addresses of these arrays (i.e. just their names) should be assigned to the appropriate elements of NUSE_Queue_Data . For each queue, its size should be assigned into the appropriate element of NUSE_Queue_Size .
If any pipes are configured, an array (of type U8 ) needs to be defined in RAM for each one. The size of these arrays is computed thus: (pipe size * pipe message size). The addresses of these arrays (i.e. just their names) should be assigned to the appropriate elements of NUSE_Pipe_Data . For each pipe, its size and message size should be assigned into the appropriate elements of NUSE_Pipe_Size and NUSE_Pipe_Message_Size respectively.
If any semaphores are configured, the array NUSE_Semaphore_Initial_Value needs to be initialized to the down-counter start values.
Application Timer Data
If any timers are configured, the array NUSE_Timer_Initial_Time needs to be initialized to the start values for the counters. Also, NUSE_Timer_Reschedule_Time needs to be set to the restart values. These are counter values to be used after the initial sequence expires. If the restart values are set to 0, the counter stops after one cycle.
If support for timer expiration routines is configured (by setting NUSE_TIMER_EXPIRATION_ROUTINE_SUPPORT to TRUE ), two more arrays need to be initialized. The addresses of the expiration routines (just a list of function names (without the parentheses) should be assigned to NUSE_Timer_Expiration_Routine_Address . The array NUSE_Timer_Expiration_Routine_Parameter should be initialized to the expiration routine parameter values.
All operating systems have an application program interface – an API – of some kind. Nucleus SE is no exception and the function calls that make up its API have been covered extensively in this series.
So, it would seem obvious that, when writing an application that incorporates Nucleus SE you would use its API as described. This may not always be the case.
For many users, the Nucleus SE API will be new and may be their first experience of using an operating system’s API and, since it is fairly simple and straightforward, it may make a very good introduction to the topic. In this case, the way forward is clear.
For some other users, an alternative API might be attractive. There are three obvious situations when this might be the case:
The Nucleus SE application is just part of a system where other operating system(s) is/are in use for other components. Having portability of code and, more importantly, expertise between the operating systems in use is very attractive.
The user has extensive experience of another operating system’s API. Reusing that expertise is very desirable.
The user wishes the re-use code which was written for the API of some other operating system. Recoding to change the API calls is possible, but time consuming.
Since the complete source code to Nucleus SE is provided, it would be entirely possible to edit each API function to appear to be the same as its equivalent in another OS. However, this would be time consuming and ultimately unproductive process. A better approach is to write a “wrapper”. This can be done in various ways, but the simplest is simply a header (#include ) file which contains a series of #define macros that map from a “foreign” API to Nucleus SE’s API.
A wrapper that maps (a subset of) the Nucleus RTOS API onto Nucleus SE is included in the distribution. This can be used by developers with experience of Nucleus RTOS or where migration to this RTOS is a future possibility. This wrapper also serves as an example to aid the development of another.
Debugging a Nucleus SE Application
Writing an embedded application using a multi-tasking kernel is a challenge. Verifying that this code works, and the identification of bugs can be very problematic. Although it is just code executing on a processor, the apparent concurrent execution of multiple tasks means that focusing on a particular thread of execution is not easy. This is compounded when code is shared between tasks; worst of all when two tasks use entirely the same code (but operate on different data obviously). An additional issue is the untangling of data structures, which are used to implement kernel objects, in order to see the information in a meaningful fashion.
To debug an application built with Nucleus SE does not require any special libraries or other facilities. All the kernel source code is there and may be “visible” to a debugger. Hence all the symbolic information is available for use and interrogation. Any modern debugging tool may be employed to work on a Nucleus SE based application.
Using a Debugger
Debugging tools that are designed specifically for embedded applications have been with us for more than 30 years now and have, hence, become very sophisticated. The key characteristic of an embedded application, as compared with a desktop program, is that every embedded system is different (but one PC looks very much like every other). The trick with a good embedded debugger is for it to be flexible and customizable enough to accommodate such variability in requirements from one user to another. The customizability of a debugger is manifest in various forms, but there is generally some scripting capability. It is this facility in particular which may be exploited to make a debugger perform well with a kernel-based application. I will review some of the possibilities here.
It is important to note that a debugger is typically a family of tools, not just a single program. A debugger may have different modes of operation whereby it can assist with development of code on a simulated target or with real target hardware.
Task Aware Breakpoints
If code is shared by multiple tasks in an application, it makes conventional debugging using breakpoints rather confusing. It is likely that you will only want the code to stop when a breakpoint is reached in the context of the particular task you are endeavoring to debug. What you need is a task aware breakpoint.
Fortunately, the scripting facilities of a modern debugger and the visibility of Nucleus SE symbols make implementing task aware breakpoints quite straightforward. All that is needed is a simple script that is attached to a breakpoint that you wish to make “task aware”. This script would take a parameter, which is the index (ID) of the task in which you are interested. The script would simply compare this value with the index of the currently running task (in NUSE_Task_Active ). If the values match, execution is halted; if they are different, execution is allowed to continue. It is fair to point out that the execution of this script will have some effect upon the real time profile of the application, but, unless it is in a loop where the script is likely to be executed very frequently, that effect will be minimal.
Kernel Object Information
An obvious need, while debugging a Nucleus SE based application, is the ability to find out about kernel objects – what are their characteristics and current status. This is a matter of answering questions like: “How big is a queue and how many messages are in it?”
One way to facilitate this is to add some additional debug code to your application, which can make use of the “information” API calls – like NUSE_Queue_Information() . This, of course, means that your application contains extra code, which will not be needed after deployment. Using a #define symbol to switch this code in and out, using conditional compilation, would be a sensible solution.
Some debuggers can perform a target function call – i.e. directly call the information API function. This gets around the need to add extra code, except that the API function must have been configured for the debugger to make use of it.
An alternative approach, that is more flexible, but less “future proof” is to directly access the kernel object’s data structures. This is probably best done using the debugger’s scripting capabilities. In our example, the size of the queue may be obtained from NUSE_Queue_Size and their current usage from NUSE_Queue_Items . Furthermore, using the address of the queue’s data area (from NUSE_Queue_Data ) and the head/tail pointers (NUSE_Queue_Head and NUSE_Queue_Tail ), the queued messages may be displayed.
API Call Return Values
Many API functions return a status value, which indicates whether the call was successful. It would be useful to monitor these values and flag instances where they are not NUSE_SUCCESS (which has the value zero). Since this monitoring is for debug purposes only, conditional compilation is in order. The definition of a global RAM variable (NUSE_API_Call_Status , say) can be conditionally compiled (under the control of a #define symbol ). Then the assignment part of the API calls (i.e. the NUSE_API_Call_Status = ) can be similarly conditionally compiled. For example, for debugging purposes, a call that would normally look like this:
NUSE_Mailbox_Send(mbox, msg, NUSE_SUSPEND);
NUSE_API_Call_Status = NUSE_Mailbox_Send(mbox, msg, NUSE_SUSPEND);
If task blocking is enabled, many API function calls can only return success or an indication that the object has been reset. However, if API parameter checking is enabled, a variety of other return values are possible.
Task Stack Sizing and Overflow
The topic of stack overflow protection was discussed in an earlier article. During debugging, there are a couple of other possibilities:
A stack memory area could be filled with a characteristic value – something other than all ones or all zeroes. The debugger can then be used to watch the memory locations and the extent to which the values get changed indicate the extent of stack usage. If all the memory locations have been changed, it does not necessarily mean that the stack has overflowed but may mean that the stack is only just large enough, which is fragile. It should be enlarged and be subject to further testing.
As discussed, when implementing diagnostics in an earlier article, addition locations – “guard words” – may be located at either end of the stack memory area. The debugger can be used to monitor access to these words, as any write would indicate stack underflow or overflow.
Nucleus SE Configuration Checklist
Because Nucleus SE is designed to be very flexible and customizable to accommodate the precise needs of the application, a significant amount of configuration is required. This is why this whole article is essentially dedicated to the topic. To make it easy to ensure that everything is covered, here is a checklist of all the key steps involved in build a Nucleus SE based embedded application:
Obtain Nucleus SE – Although almost all the code of Nucleus SE has been published in this series, the next article will tell you how to obtain it in a more immediately usable form.
Consider CPU/tool support – There may be a need to rewrite the assembly language parts and redraft build scripts.
Build the simple demo – This verifies that you have all the components and the tools are compatible.
Plan your task structure – How many tasks and what do they do. Set up their start addresses and stack sizes. You can adjust this later, of course. You may have up to 16 tasks in an application.
Application initialization – Do you need to add any code to main() ?
Select scheduler – You have four to choose from and this might be changed later.
Verify timer interrupt vector – If you have a timer.
Verify the context switch trap vector
Signals – Enable support for signals if you are going to use them.
System clock – Enable the clock if you need it.
Kernel object counts – Determine how many of each kind of object you need. You can change this later. Maximum of 16 objects of each type.
Kernel object ROM – Initialize the ROM data for each type of object that you are using.
RAM data – Set up RAM data space for objects that need it (queues, pipes and partition pools).
API enables – Enable all the API calls that you need.
The next article – the last in the series – will wrap up the Nucleus SE story with some comprehensive reference information to aid is implementation and usage.
Colin Walls has nearly forty years’ experience in the electronics industry, largely dedicated to embedded software. A frequent presenter at conferences and seminars and author of numerous technical articles and two books on embedded software, Colin is an embedded software technologist with Mentor, a Siemens business, and is based in the UK. His regular blog is located at: http://blogs.mentor.com/colinwalls. He may be reached by email at email@example.com