Developing 80x86-Based Embedded Systems
Andrew P. Beck
--------------------------------------------------------------------------------
Andrew Beck is a systems engineer specializing in the design of computers
for industrial and scientific applications. He has been designing embedded
systems more than 15 years. Andrew can be reached at (908) 806-8262.
Developing 80x86-Based Embedded Systems
Andrew P. Beck
--------------------------------------------------------------------------------
Andrew Beck is a systems engineer specializing in the design of computers
for industrial and scientific applications. He has been designing embedded
systems more than 15 years. Andrew can be reached at (908) 806-8262.
Introduction
Embedded systems were once programmed only by those brave souls who to dared
write in assembly language and who knew enough about the hardware to program to
the chip level. But as the embedded systems market continues to expand, and the
need for embedded systems programmers grows, all of that has changed.
Most
manufacturers are reducing the size of their products. With the shortage of
experienced embedded systems programmers, more MS-DOS programmers will be
required to develop embedded programs. In this article, I will try to ease the
path for those of you who have never developed an embedded system. I address the
fundamental issues of developing a program to run on an 80x86-based system. I
explain the steps necessary to convert your C programs into embedded code, And I
give you some hints on how to avoid some of the common pitfalls of embedded
systems design.
A Little History
In 1978 Intel developed the 8086 microprocessor. Due primarily to IBM's use
of it in their first PC, the 80x86 family has become the industry's most popular
microprocessor. There are literally millions of 80x86-based computer systems in
operation today. Because of the PC's popularity, scores of lowcost, high-quality
software development tools have been created for it.
Soon after the
introduction of the 8086, embedded systems designers started developing new
products around it. These early designers had to rely on high-priced development
systems to help them refine their designs and develop their software. This,
along with the large number of support chips required for even a modest 8086
design, prevented many designers from using the 8086 in their embedded designs.
Only those who could justify the high initial development cost and long
development cycle could afford to use it.
Intel soon realized that embedded systems offered a potentially large market, so in 1982 they announced the 80186 microcontroller. The 80186 is a highly-integrated controller that includes some of the most often used peripherals on the same chip as the microprocessor. In the past couple of years there has been a dramatic increase in the number of new designs utilizing this controller. In addition to Intel, NEC and AMD currently produce microcontrollers that are code compatible with the 8086.
Users of all types of equipment, from consumer electronics to high-end instrumentation, are demanding ever more intelligent devices. Manufacturers are continuing to put more powerful systems into ever smaller boxes. One market that has seen a tremendous growth in the last few years is the field of industrial instrumentation and automation. The typical handheld instrument of today is required to outperform desktop systems produced just a few years ago. In addition, manufacturers are striving to make these systems easier to use, while introducing new products faster than ever.
All of these factors have combined to force the embedded systems designer to look for tools to help produce better products in a shorter period of time. One of the most powerful tools currently available is the C programming language. C allows the designer to develop programs faster, and with fewer bugs, than assembly language. C programs can be better structured, and therefore more portable and maintainable, than their assembly-language counterparts.
A few years ago, many designers were reluctant to use C in their designs. They felt the language used too much memory. It was often difficult to write interrupt handlers in C, and the designer had little control over placement of data or code. All of this has changed. Today's C compilers offer a high level of optimization. In large complex programs, the C implementation is often no larger than the assembly version. Most compilers provide support for generating interrupt <I>function</I> completely in C.
What is an Embedded System?
An embedded system generally consists of a microprocessor and a few
peripherals that run a dedicated program. That program is usually stored in
non-volatile memory, such as PROM. Embedded systems are not programmable by the
end user, and usually have very limited I/O resources. While many embedded
systems are designed with off-the-shelf components, the majority of them are
based on custom-designed hardware.
Since many embedded systems do not run
under an operating system, the programmer is responsible for supplying all of
the low-level I/O <I>function</I>s. And unlike MS-DOS-based programs
that can be loaded from disk, embedded systems require you to load the program
into PROM. Consequently, developing and debugging these systems presents some
unique challenges.
Embedded Systems Development Tools
Due to the popularity of the PC, there are a number of low-cost, high-quality
development tools available for the 80x86 processors. Two of the most popular C
compilers for the PC are produced by Borland and Microsoft. Because of their
popularity, these compilers have led to the development of many third-party
design tools that support embedded systems.
To create an 80x86-based embedded
system, you will need a standard MS-DOS-based assembler, compiler, and linker.
You will also need a locate program. This program converts a standard MS-DOS
.exe file into a form that can be burned into PROM, typically a hex or binary
file.
If this is your first embedded system design, I'd recommend that you purchase a third-party embedded-systems development package. In addition to the locate program, the vendor will supply the requisite startup code and runtime support <I>function</I>s for your compiler. Most vendors will also supply their own libraries, or a set of utilities to remove non-ROMable <I>function</I>s from your compiler's library.
Basic Principles
Programs written for MS-DOS-based systems run in the system's RAM. When a
program is executed, the program loader locates an available block of memory and
loads the program into it. MS-DOS .exe files are simply relocatable files. As
the program is loaded, the program loader uses a table stored in the file to
resolve the relocatable segment addresses.
Embedded systems require two types
of memory, volatile RAM and non-volatile PROM (or ROM). All of the program's
<I>var</I>iables, as well as the stack, are located in RAM. The
executable portion of the program is stored in PROM.
Constants, such as string data and initialized <I>var</I>iables, are initially stored in the PROM. However, before the program can be executed, this data must be copied to RAM. This is the responsibility of the system's startup code. This code is also responsible for initializing the segment registers, clearing the uninitiliazed RAM, and setting up the heap. Finally the startup code must call the program's main <I>function</I>.
The startup code is the most important piece of software in the system. It is also the most difficult for the first-time embedded systems programmer to develop. If you purchase a third-party embedded development package, the vendor will supply the requisite startup code.
When the 80x86 processor begins running after reset, it executes the code located at 0xffff:0xfff0. Normally the only instruction at this location is a jump to a lower address in PROM, where the actual program is stored. The first piece of code to be executed after the initial jump is the startup code. The startup code is responsible for establishing an environment that the C program can run in, consequently it is always written in assembly.
All embedded C programs require that at least three segments be defined. The code segment contains executable instructions — your program. The data segment contains all of the program's static <I>var</I>iables. Finally, the stack segment is where non-static <I>var</I>iables, passed arguments, and <I>function</I> return addresses are stored. Depending on the memory model used, some programs will also require a far data segment. Some advanced applications may make use of multiple code, data, or stack segments.
Since your programs, constants, and initialized data will be stored in PROM, the startup code must copy them into RAM. It must also zero out the uninitialized data area so that all other static <I>var</I>iables will be initialized to zero. Finally it has to setup all of the segment registers and the stack pointer.
Most of the time the startup code will also be responsible for setting the system's interrupt vectors. Interrupts 0 through 6 are special interrupts generated by the CPU. Interrupts 0, 4, 5, 6 and 7 are processor exception interrupts. They should point to an exception-handler <I>function</I>. Interrupt 2 is the nonmaskable interrupt (NMI). If your hardware utilizes the NMI, its vector must be initialized to your NMI handler's address.
If you are using a microcontroller, its on-board peripherals will use several other interrupts. If you operate them in the interrupt mode, you must initialize their vectors. The remaining interrupts are available for your application. All unused interrupts should be initialized to point to a dummy <I>function</I> that simply performs a return. This prevents an errant interrupt from crashing the system.
In some systems, the startup code may also be responsible for initializing some, or all, of the system's hardware. Once the initialization is complete, the startup code calls the main <I>function</I>. Command-line arguments can be emulated by pushing data onto the stack before main is called. These arguments can then be aclearcase/" target="_blank" >ccessed via the standard argv and argc <I>var</I>iables. Typically, these would be used to indicate the status of the system's setup switches, or some other hardware interface.
Memory Considerations
Since constants are stored in PROM and then copied to RAM, you pay a penalty
in terms of memory usage. Constants require twice as much memory in embedded
systems as they do in MS-DOS-based system. Careful design can reduce this
penalty. One way to reduce memory usage is by eliminating storage of duplicate
constants. Both the Turbo-C and Microsoft compilers can be instructed to merge
duplicate string data. However, they can eliminate duplicate strings only within
a single program module. If you use a lot of strings, careful attention to where
and how you display them can help you conserve your system's precious
memory.
You can place all of your display <I>function</I>s, along
with their associated strings, in one module. Or you can simply place all of
your text in one module and reference it via pointers. Consider how your strings
are constructed. If you use a lot of common substrings, you may want to break
them into separate strings so that the compiler can eliminate duplicates for
you.
For example, consider an application in which you need to display three labels across the bottom of your screen. The text of each label <I>var</I>ies depending on which mode the system is in. You could simply generate separate print statements for each set of labels as shown below:
printf("DOWN RUN UP");
printf("LEFT RUN
RIGHT");
printf(" RUN STOP");
However, if you had several dozen
statements like these, you would find that you had a lot of duplicate
substrings. In that case a better approach would be to create pointers to each
substring as depicted below. You could then direct the compiler to merge
duplicate strings, and save a considerable amount of memory.
printf("%s %s %s", "DOWN ", " RUN ", " UP");
printf("%s %s %s", "LEFT ",
" RUN ", "RIGHT");
printf("%s %s %s", " ", " RUN ", " STOP");
Another
technique for conserving memory is to place all of your strings into their own
far data segment and reference them via far pointers. This technique does not
need the data to be copied to RAM. Unfortunately the Turbo-C compiler does not
support the printf far string pointer argument (%Fs), so this technique is only
valid if you are using the Microsoft compiler.
printf("%Fs %Fs %Fs", down_text, run_text, up_text);
printf("%Fs %Fs %Fs",
left_text, run_text, right_text);
printf("%Fs %Fs %Fs", blank_text, run_text,
stop_text);
Building an Application
You can program the bulk of your embedded program just as you would an
MS-DOS-based program. However, you must avoid using any library
<I>function</I>s that are not ROMable. The ROMability of
<I>function</I>s <I>var</I>ies tremendously from library
to library. If you are using an embedded-system development package, the vendor
will indicate which <I>function</I>s can be ROMed.
Many of the
low-level I/O <I>function</I>s in MS-DOS, such as putch and getch,
interface to the hardware via MS-DOS's interrupt 21 handler. High-level
<I>function</I>s, such as printf and scanf, use this same interrupt.
If you emulate the MS-DOS interrupt 21 handler you can use most of your
library's standard I/O <I>function</I>s. Except for the disk-I/O
<I>function</I>s, all of Turbo C's I/O <I>function</I>s
are fully-ROMable if you supply an interrupt 21 handler. If you use your
library's standard <I>function</I>s, instead of developing your own,
your development time will be reduced and your program will be more
portable.
If your development package supports it, you can also include floating-point math in your application. You will have to supply a runtime interface to the floating-point emulator, or coprocessor, as well as a floating-point exception handler. Most embedded-systems development packages supply the requisite code.
If you are developing your own embedded support <I>function</I>s, you have to make sure you don't inadvertently use a library <I>function</I> that is MS-DOS dependent. The library that comes with Borland's Turbo C compiler tends to be more independent of MS-DOS than does Microsoft's. For example, if you supply emulation for MS-DOS's interrupt 21 console I/O <I>function</I>s, you can use Turbo C's printf <I>function</I> without any other support. On the other hand, Microsoft's printf depends on several low-level MS-DOS <I>function</I>s and cannot be embedded unless you supply emulations of them. If you are using Microsoft's C, refer to their C Compiler User's Guide for a list of the <I>function</I>s that are MS-DOS independent. Both Microsoft and Borland will sell you the source code for most of their library <I>function</I>s, the exceptions being their math and graphics libraries.
Compiling, Linking, and Locating
Once you've written your program, you can compile and link it just as you
would any MS-DOS-based program. The only difference is that you will have to
link in your startup code in place of the compiler's. You will also have to link
in any special runtime support <I>function</I>s that are required by
your system. Make sure that your startup code is the first module listed in the
link list, this ensures that it is the first code executed. You must also
instruct your linker to create a map file. This will be used by the locate
program to aid in determining where code and data are to be placed in your
target system.
The output file produced by your linker will be an .exe file,
a relocatable image of your program. This file contains two parts, a relocation
header and the actual program data and code. The relocation header consists of a
series of offsets into the program that point to segment references. The locate
program must modify these references so that they match the memory layout of
your target system. The locate program will use the link map to determine the
length and location of each of the segments contained in the .exe file.
As a final step the locate program must output your program in a form that can be downloaded into a test system or burned into PROM. Usually this will be a hex or binary file as dictated by the needs of your PROM programmer, emulator, or debugger. The actual locate procedure will <I>var</I>y somewhat depending on your locate program. Most of them require you to create a file that contains a memory map of the target system, along with directives indicating where each of the segments is to be located. You must also indicate which segments are to be duplicated in PROM. Typically this will consist of only the segment that contains your initialized data.
Debugging
Embedded systems present their own unique problems when it comes to
debugging. If you simply burned your program into PROM and ran it, it would be
virtually impossible to determine where a problem existed when it didn't run
properly. Most embedded systems have very limited I/O capability — often nothing
more than a DIP switch and a few LEDs. These would prove useless if you were
trying to debug anything more than the simplest of programs.
Traditionally
embedded systems designers have used an incircuit emulator (ICE), or a dedicated
development system to debug their designs. While development systems provide the
most integrated development environment, they tend to be very expensive, often
costing upwards of ?,000. Many lowcost emulators are available, and they are
fine for developing hardware, but most fall short when it comes to debugging
complicated programs.
If you've opted to use your compiler's libraries, and you've carefully designed and structured your program, you can test a large percentage of your code right on your PC. You can generate test <I>function</I>s that emulate your target system's low-level I/O routines. These routines can make calls to MS-DOS to display data on the PC's screen and get input from the keyboard. If you've emulated the MS-DOS interrupt 21 handler in your program you may not need to generate any low-level test <I>function</I>s.
Perhaps's the best debugging environment for embedded systems is one with which most programmer's are already familiar — the source-level debugger. Most MS-DOS programmers have, at one time or another, debugged their applications with either Borland's Turbo Debugger or Microsoft's Codeview. Both of these products provide a host of options for debugging your program. They allow you view your source code in its native form, including comments. Both support multiple breakpoints and allow you to single step your program. They will also display the contents of memory as well as the CPU registers. However, their most powerful feature is their ability to display the values of complex data types such as arrays, structures, and unions.
Several vendors offer a version of Borland's Turbo Debugger for use on embedded systems. By utilizing the debugger's remote mode you can download and debug your program on your target system.
Some vendors offer their own remote debuggers. Like Turbo Debugger, most of these communicate with your target system via an RS232 serial link. Some, such as Paradigm's DEBUG/RT, can be configured to communicate with any type of interface, including parallel ports and PROM emulators.
With the power of these debuggers available to you, there is no reason to even test your code on the PC. You can perform all of your testing and debugging right on your target system. This will help you uncover subtle problems, especially timing-related ones, early in the development cycle.
Special Considerations
Embedded systems are often required to maintain some of their data even when
their power has been turned off. This may be data collected and stored for later
retrieval, or simply setup information such as the configuration of its
peripherals. It's relatively easy to provide this ability in hardware. The most
popular way is by equipping the system with battery-backed RAM. While system
power is turned off, a small battery applies enough voltage to the RAM for it to
maintain its data.
From a programmer's standpoint, non-volatile data presents
something of a problem. You already know that at reset the startup code copies
over any initialized <I>var</I>iables and zeros out the
uninitialized area. This will reinitialize all data stored in your data segment.
So you have to find a way to protect your non-volatile data from the startup
code.
The simplest method would be eliminate the code that zeros out the uninitialized data area. This presents its own set of problems. If you didn't zero out this area you couldn't depend on uninitialized static <I>var</I>iables to be zero. An unwary maintenance programmer who wasn't aware of this particular peculiarity could easily be tripped up by this.
The best way to solve this problem is to create a segment for all of your non-volatile <I>var</I>iables. Since they're not included in either the initialized or uninitialized data areas, the startup code will not modify them.
You still face one other problem though. If your program depends on any of them being in a known state at reset time, and the startup code isn't modifying them, how do they every get initialized in the first place? Again a simple solution is at hand. You must provide a <I>function</I> that sets all of these <I>var</I>iables to default values. Then in the startup code, or at the beginning of main, you call this <I>function</I> if some predefined condition exists. For example, you could generate a checksum for all of the non-volatile <I>var</I>iables, and call your reset <I>function</I> if the checksum is invalid. Or you can test for some user-generated condition such as a certain switch setting or keyboard input.
Performance Issues
The first time you run your embedded application you may be disappointed with
its performance. Often your program will simply not run as fast as expected.
This problem is particularly prevalent if you test and debug your program on the
PC. Most embedded systems run much slower than today's PCs. Testing your program
on the target system is one way to identify performance problems early, before
they overwhelm the project.
Most PCs are so fast that many MS-DOS programmers
do not pay much attention to the speed issues that were a common concern just a
couple of years ago. Embedded systems programming still requires that extra
attention. Careful attention to selection and implementation of algorithms is
paramount.
One of the simplest ways to get a little more performance out of your system is to set a couple of your compiler's command-line switches. If you're using the Intel 80186 microprocessor, set the switch that enables code generation for it. The 80186 includes several instructions, not included in the 8086, that can increase the speed of interrupts and <I>function</I> calls. If you have plenty of PROM in your system, set your compiler to optimize for speed, instead of for code size.
One area that causes a lot of problems for embedded systems programmers is the use of floating-point math. Most embedded systems lack a math coprocessor, so you'll have to depend on the floating-point emulator in your math library. Even with a highly-optimized, floating-point library, floating-point math is much slower than integer math. Many embedded applications don't require the range of numbers that floating-point supports. Often fixed-point numbers will offer more range and precision than you need. If addition to the improvement in speed, switching from floating-point to fixed-point math can save a considerable amount of RAM.
One of the biggest issues in the design of embedded systems is the tradeoff of hardware versus software. Often hardware designers will opt to eliminate a simple circuit that can be emulated in software. With the rising cost of software development, these tradeoffs have to be considered carefully. In low-volume applications, the software development cost may far exceed the savings in hardware cost.
If your application makes heavy demands on the processor, one or two simple circuits may significantly improve overall system performance. One example of a common input device that is often emulated in software is the keyboard controller. Often it will be implemented by simply connecting a few switches to one of the system's input ports. The software must then periodically poll the status of the switches to determine if any has been pressed. The software must also determine if it is a valid switch closure or simply "switch bounce" from the last key press. In simple systems this rarely taxes the processor.
In many real-time systems, however, the processor may service several I/O devices. It may not be able to spend the time necessary to poll and debounce the keyboard. In this case a simple hardware circuit can be used to decode and debounce the keyboard and interrupt the processor only when a valid key press occurs. Obviously, the better your understanding of the hardware, the easier it will be for you to spot potential problems and inform the hardware designers before the design is finalized.
Conclusion
Developing software for an embedded system is different than for a MS-DOS-based system, but it isn't necessarily harder. In this article I've only skimmed the surface of embedded systems programming. There are many issues that are beyond the scope of an introductory text. Before embarking on your first design, I'd recommend that you develop a good understanding of your target hardware platform. Also look carefully at the development tools that are available, and consider which best meet your needs and budget.