Joel Halbert has a B.S. in electrical engineering from the University of Texas in Austin, Texas. He has 12 years experience in the design and implementation of computer hardware and software. He may be contacted at 1407 Meadowmear, Austin, TX 78753.
In implementing software for embedded systems, several items can ease the job. These include a kernel to implement a virtual machine, a high-level language (preferably C), and a good in-circuit emulator. I describe here a simple kernel for the Motorola 6801 microprocessor that supports tasks written in C. I also describe how to interface between the C routines and assembly language. I use the assembler, compiler, and in-circuit emulator (ICE) supplied by American Automation.
The kernel is very simple. It implements only three calls. All kernel code is written in assembly. Calls to the kernel conform to the C subroutine call interface, however, so that tasks written in C can call the kernel directly.
C is a particularly good language to write embedded system code. It has the bit manipulation needed to talk directly to hardware registers. Its high-level features enable programmers to structure code for easier debugging, maintenance, and readability. In addition, C generates less code than other high-level languages.
6801 Architecture
The 6801 microcontroller, designed in the late 1970s, is a relatively old architecture. It is cheap, easy to use, and manufactured by several semiconductor houses. The chip features:
Figure 1 shows the registers for the 6801. Two eight-bit accumulators (called A and B) can be concatenated into one 16-bit register referred to as D. The index register (called X), the stack (called S), and the program counter (called PC) all are 16 bits wide. The condition code register (called CC) is eight bits wide. Figure 2 shows the condition code register in greater detail.
- a serial communications interface
- a 16-bit three-function programmable timer
- choice of single-chip or expanded 64K-byte address space
- 2,048 bytes of ROM
- 128 bytes of RAM
- 29 parallel I/O lines of which two can be defined as handshake lines
- internal clock generator with divide-by-four output
- TTL compatible inputs and outputs
The 6801 operates in eight different modes. The operating mode is determined just after chip reset by levels on three of the I/O pins. Three major divisions of these modes are:
The kernel I describe here uses Expanded Multiplexed mode. That supports external RAM and ROM that can reside in sockets for expandability and ease of maintenance.
- Single Chip All four ports are configured as parallel input/output ports. The MPU functions as a self-contained microcomputer with no external address or data bus.
- Expanded Non-Multiplexed The MPU can directly address modest amounts of external memory (up to 256 bytes).
- Expanded Multiplexed The MPU can address the entire 64K byte address space. In addition, 13 I/O lines are available.
Kernel Architecture
I consider all code except for the tasks part of the system. The rest is task code. To initiate multitasking, the application program calls a setup routine, giving the starting function pointer for each task. This routine allocates separate stack areas (distinct from from system stack) for each of a fixed number of tasks. It then sets up a current stack pointer and execution pointer for each task. After the initialization of four tasks, for example, the system can concurrently execute the tasks with code similar to
while (true) { continue_task(0); continue_task(1); continue_task(2); continue_task(3); }The continue_task function (Listing 1) transfers control to the numbered task and returns when the task calls suspend. The continue_task and suspend functions also save and restore any register variables on the task stack. You can expand the while (true) test to check for various stopping conditions, including global status flags set by the tasks. This kind of multitasking is non-preemptive. It requires that the tasks call suspend frequently. Since each task has its own stack, the call to suspend may occur at any level of subroutine nesting.The kernel may appear to contain a lot of functionality to pack into two pages of assembly code, but it is actually much simpler than a general real-time multitasking kernel. For example, this task scheduler provides no means for an external event to activate a task. Every task is expected to have control for a short time in every loop; the task itself must check for activation conditions. Also, the scheduler does not provide signaling between tasks. The tasks do, however, use message queues and global tables to implement this type of signaling.
Since all tasks are linked with the kernel as reentrant functions in one program, special care must be taken when referencing static variables (both global and non-global). In general, a tasks's use of static variables should be limited to deliberate inter-task communication. Variables allocated off the stack are automatically private to the task.
It may be helpful to see a diagram of the stack structure at various times of the execution of the kernel. At startup, memory is uninitialized. No stacks for task are yet defined. When the startup code calls the initialization routines, the task's stacks are defined, as shown in Figure 3.
Two arrays, task_tab and fram_tab, hold the stack pointers and frame pointers for each task. The kernel saves the system stack pointer in sys_stk and the system frame pointer in sys_sf. When a task is in control, it is using its own stack and frame pointers. When the task calls suspend, the kernel saves these pointers in the task table and retrieves those for the system. The task transfers control to the system by setting task mode to 0 and executing the next task in the master while loop in main.
Data Flow
The tasks communicate with each other via circular queues and system tables. At system startup, all queues are empty and the system tables contain default values. Serious operation starts when the computer receives operating parameters via the serial lines. This causes an interrupt routine to insert characters into a circular buffer called in_queue.When activated, the message_in (Listing 2) task sees that characters are in in_queue. The task determines what kind of message has been sent. After decoding the message, the task loads a return message into another circular buffer, called out_que.
When the task message_out (Listing 3) is activated, it sees that characters are in out_queue. The task removes the message from out_que and reformats it as a message suitable for serial transmission with CRC bytes generated and transparency characters inserted. The formatted message is then put into the xmit_que for sending over the serial line. The task msg_out then calls send_msg, which sends out the first character, enables the send character interrupt, and waits until all characters of the message are sent before disabling the send character interrupt.
The task key_brd in Listing 4 checks for key strokes on the key pad. It works with the counter interrupt routine. If a key is being pressed, the interrupt routine rcv_key decodes the key and puts it into the global variable keyboard_port. When activated, key_brd checks for a character in keyboard_port. If key_brd finds a character, it inserts the character into key_que.
The task display in Listing 5 reads the queue key_que to determine whether or not the the key should affect the display. In addition, display loads a buffer containing key strokes for serial transmission.
Task Structure
All tasks must be organized the same way for this scheme to work. The basic structure is an endless loop:
while (true) { /* all statements * that make up the * task */ }All tasks communicate via shared memory (global memory) using circular queues and semaphores. Also, the tasks read system tables for setting operational parameters.
Interfacing Assembly With C
In this project, I interfaced assembly with C using two different approaches writing functions completely in one language and embedding in-line assembly statements within C code.When writing a function in assembly, the most important aspect is the function calling convention for the particular C compiler you are using. With the American Automation C compiler that I used for this project, the parameters in a call are handled differently depending on the number of parameters passed. If you are passing only one parameter to the subroutine, then the parameter is passed in the D register. If you are passing more than one parameter, then the leftmost parameter is passed in the D register, and the rest of the parameters are passed through the stack. Examine the following call:
init_task(1,&message_in);The leftmost paramater, 1, is passed in the D register, and &message_in, the address of the subroutine message_in, is passed on the stack.The following example shows the second approach, embedding assembly within a C function. The example illustrates how to reference the same variable by C code and assembly. A typical variable declaration in C is
extern unsigned char VARIABLE;Compilers usually transform the variable name when translating the C statements to assembly. A common transformation is to add an underscore or some other character either before or after the name. The American Automation compiler appends a question mark ? to a variable name. When referencing the variable within assembly, I use the following statement:
xref VARIABLE?Each compiler has its own way to embed assembly language in C. Turbo C uses a keyword, asm, to indicate to the complier that the following statement is in-line assembly. The American Automation compiler uses the keyword #asm to indicate the beginning of a series of assembly language statements and the keyword #endasm to indicate the end of a series of assembly language statements. American Automation recommends that all #asm statements be immediately preceeded with a semicolon. See Listing 5.In assembly language, an address gets assigned to each variable that you define. A final hardware address is determined for a variable name using a combination of SECTION and DS (define storage) declarations. The American Automation C compiler supplies an assembly language startup file that is always linked first in front of all other files. This file is where you would assign memory addresses to hardware registers residing on your board. The following demonstrates a SECTION statement in an assembly language source file.
SECTION IO,?,DATA ; data (I/O) start addressYou are defining a section of memory called IO.? means the address will be defined at link time, and DATA means it is a data section. IO has previously been defined as 3000. You are telling the assembler to create a section of memory starting at address 3000 that will be uninitialized data.Now you can begin to use DS statements to assign variables to memory locations. For example:
XDEF I_O?, VARIABLE?,OTHER? I_O?: DS 1 VARIABLE?: OTHER?: DS 1I have defined three variables I_O, VARIABLE, and OTHER. Notice that the XDEF directive enables other files that are linked with this file to know about these variables. Note also that the DS statements come after the SECTION statement. Any other DS statements that follow will define storage in the IO section until another SECTION statement is encountered.
Interrupts
Usually, embedded systems software deals with interrupts. These interrupts come from a variety of sources. The code I show here deals with interrupts from the programmable timer and the serial communications interface.The programmable timer is set up to be a free running counter that will generate an interrupt whenever the contents of the counter equal 0. This timer generates delays and also reads the keypad for any keys that are pressed. The serial communications interface generates an interrupt whenever a character is received by the serial port receive buffer and whenever the transmit buffer is empty.
You incorporate interrupts by the following method. The startup file assigns the addess of the interrupt routine to vector locations by the method shown in Listing 6. The first statement is a SECTION directive that creates a section called VECTORS at location . It is followed by define words containing addresses of the routines that will be invoked when the vector is executed. In addition, the startup file contains an assembly language routine that provides the glue needed to call a C subroutine, which actually does the work in the interrupt handler. There is an assembly language routine for each vector that may be needed. The routine for the timer is shown in Listing 7.
With this setup, you can write your interrupt routine much like any other C function.
Listing 8
Listing 9
Listing 10
Listing 11
Listing 12
Listing 13
Listing 14