Archimedes C-51 Hosted on an IBM-PC Compatible

Comments by Charles Hershey


Charles Hershey received his Bachelor of Science degree in Electrical Engineering and Physics from the California Institute of Technology in Pasadena. He has been writing software professionally for over ten years in such areas as image and data analysis, embedded systems, and robotic control. He currently works as an independent software/electronics consultant. He can be reached at 13539 Wyandotte St., Van Nuys, CA 91405 or via the Internet at hershey@alumni.caltech.edu.

A cross-compiler is a useful tool that allows embedded systems programs to be developed on a computer other than the target embedded system. The host computer typically provides facilities for software development (editors, hard-disk storage, etc.) unavailable on the target system. This article reports the experience of the author using the Archimedes C-51 8051 compiler hosted on an IBM-PC compatible computer.

Running the Compiler

The 8051 C cross-compiler, known as C-51 version 4.23H, comes with a compiler, linker, assembler, and librarian. The compiler and linker require 1MB of extended memory. Because the linker and compiler are run from the command line, the user will probably wish to automate project management using a make utility, which the package does not include. I use Borland C++ for native DR-DOS programming, and the make utility included with that performs quite well. However one caution is in order: both Borland's standard make utility and the Archimedes compiler use extended memory, and they do not graciously cooperate in its use. Therefore, the programmer must use the real mode version of the Borland make utility, maker. exe.

The linker requires that it be informed of every code and data segment used in the program. In meeting this requirement, the programmer can also specify where each segment is to be placed, and in what order. Because this information can occupy many lines, it is communicated to the linker through a linker control file, which also permits specifying other linker options. Sample linker control files are included with the C-51 compiler to get the user started. I had to make only minor modifications to one of these to get my program running on my target system.

Using the 8051's Features

From here on, when I refer to the 8051 microcontroller, I will actually mean the 8051 family of microcontrollers. Produced by various manufacturers, these controllers all share a common general architecture, with variations in type and size of available memory and in their assortment of special registers.

To let the user take advantage of these different memory features, C-51 permits the use of special data types and qualifiers. In particular, Archimedes introduces a special sfr data type to denote a byte-wide register within the Special Function Register (SFR) region of internal memory (see the sidebar, "8051 Memory Organization"). Because an sfr data type denotes a specific register, it has a fixed location that must be specified in its definition. An example of this is:

sfr P0 = 0x80;
This statement tells the compiler that the identifier P0 will be used to access the SFR at location 0x80 in internal memory. Archimedes Software supplies a header file which defines many standard 8051 SFRs. The user may also define new SFR variables that correspond to additional registers in a particular variant of the 8051.

Bit Addressing

C-51 uses the keyword bit to denote a variable that can be stored in one of 256 single-bit locations. This keyword enables the programmer to use a tight, fast Boolean variable that will not hog precious internal data registers. General purpose bit variables are completely relocatable. The compiler specifies the storage needed, and the bits' locations are determined at link time. In addition, C-51 provides access to special-purpose bit variables that are mapped into sixteen of the special function registers. This is done with the following syntax:

sfr_name. bit_number
For example:

P0.5 = 0;
will clear bit 5 in special function register P0 with a single instruction.

Specifying Memory Types

C-51 also offers the choice of keywords or #pragmas to specify whether a particular variable should be placed in internal or external data memory or in the code segment. The freedom to choose the location of individual variables permits the user to optimize the resulting code.

As well as letting the programmer specify the type of storage for individual variables, C-51 offers several memory models which determine the default form of storage for different types of data, including a model that supports banked code memory. The user specifies the desired memory on the compiler command line.

The Stack and Function Calls

The 8051 instruction set is designed to use the internal register array as a stack. C-51 uses the stack to store intermediate results used by library routines and to save the contents of registers used by interrupt service routines. By default, C-51 also uses the stack to store the return addresses in function calls. These return addresses use three bytes per function call.

In programs with heavily nested functions it is not difficult to run out of internal stack space. To avoid running out of space, C-51 permits the user to specify that all function call return addresses be stored in external memory. However, using this option often produces slower, larger code.

Normally, C compilers will also store function local variables on the stack. In large programs this practice also can easily consume the limited internal stack space. The 8051 has no built-in facilities for a stack located in external data RAM, so while a compiler could implement such a stack, doing so would result in code that was unnecessarily large and slow. To avoid these problems, Archimedes decided to allocate fixed memory locations for local variables. This technique is similar in effect to declaring all local variables to be of storage class static, except that the Archimedes linker performs an additional optimization. The linker builds the entire function call tree and determines what functions can and cannot be active at the same time. Rather than wasting valuable memory space, the linker may map local variables that cannot be active at the same time into the same memory locations. This process can produce code that is actually tighter on RAM usage than the equivalent hand-optimized assembly is likely to be. (Few people will actually lay out the entire function tree of a complex assembly program and determine down to the last byte which variables can be overlaid in the same memory locations. At least, not unless they have to.)

This optimization does, however, create problems for two features common in C programs, recursion and reentrancy. Recursion is the process of a function calling itself, directly or indirectly. In a recursive call, two or more copies of the function's local variables will be active at the same time. If these copies are located in the same memory locations, corruption of data values will likely occur. The Archimedes linker solves this problem by detecting recursive function calls and implementing a stack just for the local variables of those functions. Again, this results in bigger, slower code, but the alternative is to implement an external stack for all functions. Archimedes lets the user pay for the overhead of this general purpose stack only when and where it is needed.

A similar problem arises with reentrancy. An example of a reentrant function call ocurs when an interrupt service routine (ISR) calls a function which is itself currently executing. Thus, after this function has been called (entered) the first time, and before it returns, the function is called again (reentered) in service of an interrupt. As with recursion, if the function's local variables have the same locations for separate function invocations, data corruption will likely occur. Archimedes C-51 currently does not support reentrant functions. [Archimedes informs us that they plan to release C-51 version 5.0 in the second quarter of this year. Version 5.0 will support reentrancy and will allow declaration of functions with a new reentrant keyword. — mb] As a result, an ISR should not call a function that could be executing when that ISR's interrupt occurs. For that matter, it's a good idea to avoid calling any function from within an ISR, if that function is also called by non-ISR code.

Interrupt Service

When an interrupt occurs in the 8051, the 8051 transfers control to the code located at an address retrieved from the interrupt vector table (IVT). Each interrupt source will cause the processor to look for its ISR address at different locations in the IVT. The IVT is located in code memory starting at memory location 0x0003.

Other than writing the ISR code, using an ISR requires two steps: placing the address of the routine in the IVT, and making provisions for saving and restoring registers used by the routine. To enable writing and using an ISR, C-51 provides the keyword interrupt, used to declare a routine that services an interrupt. When this keyword is used, the compiler will place the routine's address in the designated IVT location, and cause any registers used by the routine to be automatically saved on entry and restored on exit. In addition, inclusion of the optional keyword using causes the program to switch to one of four register banks (0-3) internal to the 8051 upon entry to the ISR. Switching register banks obviates the need to save and restore any banked registers used by the ISR. Here's a sample ISR definition that includes the keyword using:

interrupt [0x0B] using
        [1] void my_isr(void)
{
    /* Service interrupt using register bank 1 */
}
The function in this example will be called whenever the Timer 0 interrupt (to be explained later) occurs. The Timer 0 interrupt causes the 8051 to vector to address 0x0B in the interrupt vector table.

Limitations and Workarounds

Archimedes C-51 does have some limitations that can be frustrating. For one thing, C-51 delays until run time some calculations that could be performed at compile time. For example, the following code simply stores the high-order byte of an integer in one-byte sized variable, and the low-order byte in another:

int x;
char lo,hi;

/* MSB is stored in lower memory */
hi = ((char*)(&x))[0];
lo = ((char*)(&x))[1];
Ideally, this syntax would simply result in the code necessary to move a single byte from one location to another. (The fancy pointer and array notation simply tells the compiler which byte to access.) Unfortunately, even though the source address is completely specified at compile/link time, the compiler determines this address at run time and calls a run-time library function to do the job.

Fortunately, an equivalent construct yields the desired result. Writing

*( ((char*)(&x)) + 1)
in place of

((char*)(&x))[1]
makes the compiler perform the address calculation at compile time and create the code expected.

Another limitation, as mentioned previously, is C-51's lack of support for reentrant functions. This limitation effectively discourages programmers from calling any function in an ISR if that function appears elsewhere in the program, even if careful analysis might reveal that the function was not in danger of being interrupted (and who wants to do that kind of analysis?) Another alternative is to implement a pseudo-stack for reentrant functions' local variables, and to suffer the penalty of slow, klunky code. Whatever you choose to do, at least the Archimedes linker is kind enough to warn you of any functions which are called in two separate function trees (such as from main and your ISR) so that you don't have to worry about finding all possible conflicts yourself.

A third limitation that has caused me a lot of frustration is that C-51 limits macros to 256 bytes in length. Sometimes when writing repetitive code that must run quickly (such as within an ISR that performs the same operations on several sets of similar variables), I would like to write the code only once, without incurring the overhead of a function call. A macro is the natural choice — unless it runs over 256 characters long. You can get around this length limitation by running the macro and the calls to it through a C preprocessor. One such preprocessor comes with Borland C++, CPP.EXE. This technique does the job, but you still must break up the resulting C source code into individual lines, because C-51 also has a line length limit of 256 characters.

A Simple Program

Listing 1 contains a simple program which illustrates some of the features of the C-51 compiler (Listing 2 shows a link control file, and Listing 3 is a makefile). This program does three important things: It performs basic serial I/O, it demonstrates the use of an ISR under timer control, and it demonstrates the use of a dedicated bit variable in an SFR to toggle one of the microcontroller's external pins.

I chose to implement these features because they represent the first hurdles to writing software for an 8051 in an embedded system. If the programmer does not have a simulator or emulator, embedded systems programming presents a unique challenge in that the programmer cannot simply use printfs to figure out what the program is doing. Thus, it is extremely helpful to have an alternative system for telling the outside world what is going on inside the microcontroller. Two methods for communicating such information are presented here: working character I/O, and changing the state of an output pin which can be monitored with a test instrument such as an oscilloscope. Toggling an output pin also provides timing information. If you note the time between state transitions on the pin, you can empirically determine how long the ISR takes to execute. (This is particularly useful if you want to verify that the ISR is not using too much time.)

Timer Control

The 8051 has two built-in timers, Timer 0 and Timer 1. Timer 1 doubles as a baud-rate generator and is used as such in the example program. Timer 0 can operate in any one of four programmable modes. This program uses Mode 1, which causes Timer 0 to take a 16-bit value loaded into SFRs TL0 and TH0. Timer 0's counter will count up at 1/12 the frequency of the 8051's clock oscillator. When the counter reaches 0xffff, the next count rolls the counter over to 0x0000 and generates an interrupt when Timer 0 interrupts are enabled. The counter will continue to count up from zero unless new values are loaded into TL0 and TH0.

The values I chose for this example assume a system clock rate of 15Mhz. This combination produces an interrupt from Timer 0 once every millisecond (approximately), and a baud rate of 9600 from Timer 2. The 8051 does not reload Timer 0's count value automatically so the program must load it explicitly. The program does not compensate for the time that it takes to respond to the interrupt and reload the counter.

A Walk Through the Source Code

At the top of Listing 1, you will find the line

#pragma language=extended
This line tells the ANSI C compiler that it can now accept non-ANSI extensions such as the interrupt, data, and bit keywords. The first variable definition,

data int millisecond_counter;
defines an integer in internal RAM. The ISR increments this variable, so I assigned it to internal RAM for speed considerations. The bit first_time is the only other variable defined in this program. (I use first_time to overcome a quirk of the serial port electronics.) All other variables are actually registers defined in the header file I051.h included with the compiler. TR0, TR1, RI, TI, ET0, and EA are SFR bits which have special meanings. For example, setting EA to 1 or 0 enables or disables interrupts on the processor. TH0, TL0, SBUF, TH1, TMOD, and SCON are byte-wide SFRs.

The body of the ISR does three things: it toggles bit 7 of I/O port P1 on and off, reloads the count value in Timer 0, and increments millisecond_counter. This last variable is not used for anything, but could be used in a program for timing information.

The next two functions read and write characters from the serial port buffer SBUF. The function setup sets up the two internal timers, enables them and enables the Timer 0 interrupt which will call the ISR. main calls setup and enters an infinite loop which will continuously echo characters received over the serial port.

Conclusion

The 8051 microcontroller and its variants have some unique features, including an interesting memory organization. One natural concern for someone familiar with the 8051 architecture is that using a high-level language will prevent the user from taking advantage of these features and will instead produce code that can use only those properties found in a genetic microprocessor model. This is not the case with Archimedes C-51. The compiler itself makes efficient use of the memory organization, and in addition makes other special features available through high-level constructs. Thus it is possible to write tight code tailored to the 8051 without resorting to assembly language, and after all, that's what C is all about.

Product Information

Products mentioned in this article:

Archimedes' C-8051 for DOS (version 4.23H)
Archimedes Software, Inc.
2159 Union Street
San Francisco, CA 94123
(415) 567-4010

Borland C++ (version 3.0)
Borland International, Inc.
1800 Green Hills Road
P.O. Box 660001
Scotts Valley, CA 95067-0001

8051 Memory Organization

The 8051 instruction set accesses two basic types of memory, internal and external. Internal memory consists of a register array with possible byte addresses from 0 to 255. The first 128 byte locations typically contain a contiguous block of registers. The remaining 128 byte addresses contain various special function registers (SFRs). These registers are generally not contiguous; that is, there are unused register locations. The unused register locations allow for additional SFRs which many manufacturers provide. The 8051 architecture does permit the contiguous register array from low memory to extend above address 127 and to overlap the SFRs. The memory accesses are identified by the addressing mode used. Direct memory addressing used with addresses 128 to 255 will access the SFRs while indirect memory addressing in this region will access the contiguous memory array.

The external memory is divided into separate code and data storage. The addresses for each of these areas are sixteen bits long, for a possible 64k each of data and program memory. Operating on data stored in external memory requires first moving it to internal registers, which in turn requires first setting an internal register to point to that data. Thus it is much faster and more efficient to work with data kept in internal memory than that kept in external memory.

Although programs may address the low address bytes of internal memory as a contiguous block of registers, this area has some other salient features. The first 32 bytes are organized into four eight-byte register banks for fast context switching — Individual bytes within a register bank have special significance within the instruction set. The next sixteen bytes are bit addressable. That is, the 8051 uses instructions that directly read or manipulate individual bits. These bits have potential bit addresses from 0 to 255. The first 128 of these bits are mapped into the sixteen bytes in register addresses 32 to 47. Bit addresses 128 to 255 refer to bits within the special function registers.

Listing 1 A program to illustrate some features of the C-51 compiler

/* ARCH.C */

#include <io51.h>

#pragma language=extended

#define TMOD0 0x01
#define TMOD1 0x20

#define SERIAL_MODE1 0x40
#define RECEIVE_ENABLE 0x10
#define DISCARD_FRAME_ERROR 0x20

#define T0_COUNT 1250
#define T0_LOAD ((unsigned)(0x10000L - T0_COUNT))

#define BAUD_RATE 0xfc

/* Note:  Keyword "data" tells compiler to put the
        following in internal RAM                */
        
data int millisecond_counter;
bit first_time;

interrupt [0x0B] using [1] void my_isr(void)
{
   P1.7 = 1;  /* Signal start of ISR */
   
   /* Load Timer 0 starting count */
   TR0 = 0;   /* disable Timer 0 counting */
   
   TH0 = T0_LOAD >> 8;    /* Load high byte */
   TL0 = T0_LOAD & 0xff;  /* Load low byte */
   
   TR0 = 1;  /* Re-enable Timer 0 counting */
   
   ++millisecond_counter;
   
   P1.7 = 0;  /* Signal end of ISR */
}

char read_char()
{
   while(!RI)
      ;
   RI = 0;
   return(SBUF);
}

void write_char(char c)
{
   while(!(TI||first_time))
      ;
   first_time = 0;
   TI = 0;
   SBUF = c;
}

void setup(void)
{
   /* prepare Timer 0, Timer 1 and serial port */
   first_time = 1;
   TH1 = BAUD_RATE;
   TMOD = TMOD0 | TMOD1;
   SCON = SERIAL_MODE1 | RECEIVE_ENABLE
         | DISCARD_FRAME_ERROR;
   TR1 = 1;  /* Turn on Timer 1 */
   TR0 = 1;  /* Turn on Timer 0 */
   
   ET0 = 1;  /* Enable Timer 0 interrupts */
   EA = 1;   /* Enable All enabled interrupts. */
}

void main()
{
   setup();
   
   for(;;) {
      write_char(read_char());
   }
}
/* End of File */

Listing 2 Link file for sample program

-! ARCH.xcl
   Link File for ARCH
-!

-c8051

-z

-!  Select register bank [0,8,10 or 18] -!

-D_R=0

-!  Setup "bit" segments (always zero if there is no
    need to reserve bit variable space for some other
    purpose) -!
    
-Z(BIT)C_ARGB,BITVARS=0

-!  Setup "data" segments. Start address may not be
    less than _R+8 (start of register bank + 8).
    Space must also be left for interrupt functions
    with the "using" attribute. That is, if _R is 0
    and there is an interrupt function with using [1],
    the start address should be set to 10 (hex) -!
    
-Z(DATA)C_ARGD,D_IDATA,D_UDATA=10-1f,24-7f

-!  Setup "idata" segments
    (usually loaded after "data") -!
    
-Z(IDATA)C_ARGI,I_UDATA,I_IDATA,CSTACK

-!  Setup "xdata" segments to the start address of
    external RAM. Note that this declaration does
    no harm even if you use a memory model that does
    not utilize external data RAM -!
    
-Z(XDATA)C_ARGX,X_UDATA,X_IDATA,ECSTR,RF_XDATA=7000

-!  Setup all read-only segments (PROM).
    Usually at zero -!
    
-Z(CODE)INTVEC,RCODE,D_CDATA,I_CDATA,X_CDATA,C_ICALL=0
-Z(CODE)C_RECFN,CSTR,CCSTR,CODE,CONST

-!  See configuration section
   concerning printf/sprintf -!
-e_small_write=_formatted_write

-!  See configuration section
   concerning scanf/sscanf -!
-e_medium_read=_formatted_read

-!  Load the 'C' library adapted for the selected
   memory model -!
-! cl8051t or cl8051s, cl8051c, cl8051m, c18051l -!

cl8051l
arch

-o arch.hex
-x
-l arch.map
-FINTEL-STANDARD
-t

-!  Code will now reside on file ARCH.hex
    in INTEL-STANDARD format -!

Listing 3 Makefile for sample program

#  Makefile for ARCH
#  Uses Archimedes' 8051 C compiler, C-51, and their
#      general purpose linker, XLINK.

.c.r03:
   -1 d:\arch\bin\c-51.exe -ml -e -L -q -P -g $<
  
ARCH.hex: ARCH.r03
     -1 d:\arch\bin\xlink.exe -f ARCH