Advertisement

Mapping memory

August 11, 2004

Dan_Saks-August 11, 2004

Memory-mapped I/O is something you can do reasonably well in standard C and C++.

Device drivers communicate with peripheral devices through device registers. A driver sends commands or data to a device by storing into its device register, or retrieves status or data from a device by reading from its device register.

Many processors use memory-mapped I/O, which maps device registers to fixed addresses in the conventional memory space. To a C or C++ programmer, a memory-mapped device register looks very much like an ordinary data object. Programs can use ordinary assignment operators to move values to or from memory-mapped device registers.

Some processors use port-mapped I/O, which maps device registers to locations in a separate address space, typically smaller than the conventional memory space. On these processors, programs must use special machine instructions, such as the in and out instructions of the Intel x86 processors, to move data to or from device registers. To a C programmer, port-mapped device registers don't look quite like ordinary data.

The C and C++ standards are silent about port-mapped I/O. Programs that perform port-mapped I/O must use some nonstandard, platform-specific language or library extensions, or worse, assembly code. On the other hand, memory-mapped I/O is something you can do reasonably well within the standard language dialects.

This month, I'll look at different approaches you can use to refer to memory-mapped device registers.

Device register types
Some device registers might occupy just a byte; others may occupy a word or more. In C or C++, the simplest representation for a single device register is as an object of an appropriately sized and signed integer type. For example, you might declare a one-byte register as a char or a two-byte register as an unsigned short.

For example, the ARM Evaluator-7T is a single-board computer with a small assortment of memory-mapped peripheral devices. The board's documentation refers to the device registers as special registers. The special registers span 64KB starting at address 0x03FF0000. The memory is byte-addressable, but each register is a four-byte word aligned to an address that's a multiple of four. You could manipulate each special register as if it were an int or unsigned int. Some programmers prefer to use a type that specifies the physical size of the register more overtly, such as int32_t or uint32_t. (Types such as int32_t and uint32_t are defined in the C99 header <stdint.h>.)1

I prefer to use a symbolic type whose name conveys the meaning of the type rather than its physical extent, such as:

typedef unsigned int special_register;

Special registers are actually volatile entities — they may change state in ways that the compiler can't detect. Therefore, the typedef should be an alias for a volatile-qualified type, as in:

typedef unsigned int volatile special_register;

Many devices interact through a small collection of device registers, rather than just one. For example, the Evaluator-7T uses five special registers to control the two integrated timers:

  • TMOD: timer mode register
  • TDATA0: timer 0 data register
  • TDATA1: timer 1 data register
  • TCNT0: timer 0 count register
  • TCNT1: timer 1 count register

You can represent the timer registers as a struct defined as:

typedef struct dual_timers dual_timers;
struct dual_timers
    {
    special_register TMOD;
    special_register TDATA0;
    special_register TDATA1;
    special_register TCNT0;
    special_register TCNT1;
    };

The typedef before the struct definition elevates the name dual_timers from a mere tag to a full-fledged type name.2 I'd rather spell TCNT0 as count0, but TCNT0 is the name used throughout the product documentation, so it's probably best not to change it.

In C++, I'd define this struct as a class with appropriate member functions. Whether dual_timers is a C struct or a C++ class doesn't affect the following discussion.

Positioning device registers
Some compilers provide language extensions that will let you position an object at a specified memory address. For example, using the TASKING C166/ST10 C Cross-Compiler's _at attribute you can write a global declaration such as:

unsigned short count _at(0xFF08);

to declare count as a memory-mapped device register residing at address 0xFF08. Other compilers offer #pragma directives to do something similar. However, the _at attribute and #pragma directives are nonstandard. Each compiler with such extensions is likely to support something different.

Standard C and C++ don't let you declare a variable so that it resides at a specified address. The common idiom for accessing a device register is to use a pointer whose value contains the register's address. For example, the timer registers on the Evaluator-7T reside at address 0x03FF6000. A program can access these registers via a pointer that points to that address. You can define that pointer as a macro, as in:

#define timers ((dual_timers *)0x03FF6000)

or as a constant pointer, as in:

dual_timers *const timers
    = (dual_timers *)0x03FF6000;

Either way you define timers, you can use it to reach the timer registers. For example, the TMOD register contains bits that you can set to enable a timer and clear to disable a timer. You can define the masks for those bits as enumeration constants:

enum { TE0 = 0x01, TE1 = 0x08 };

Then you can disable both timers using:

timers->TMOD &= ~(TE0 | TE1);

Weighing the alternatives
These two pointer definitions—the macro and the constant object—are largely interchangeable. However, they produce slightly different behavior and, on some platforms, generate slightly different machine code.

As I explained in an earlier column, the macro preprocessor is a distinct compilation phase.3 The preprocessor does macro substitution before the compiler does any other symbol processing. For example, given the macro definition for timers, the preprocessor transforms:

timers->TMOD &= ~(TE0 | TE1);

into:

((dual_timers *)0x03FF6000)->TMOD
    &= ~(TE0 | TE1);

Later compilation phases never see the macro symbol timers; they see only the source text after macro substitution. Many compilers don't pass macro names on to their debuggers, in which case macro names are invisible to the debugger.

Macros have an even more serious problem: macro names don't observe the scope rules that apply to other names. For example, you can't restrict a macro to a local scope. Defining a macro within a function, as in:

void timer_handler()
    {
    #define timers ((dual_timers *)0x03FF6000)
     ...
    }

doesn't make the macro local to the function. The macro is still effectively global. Similarly, you can't declare a macro as a member of a C++ class or namespace.

Actually, macro names are worse than global names. Names declared in inner scopes can temporarily hide names from outer scopes, but they can't hide macro names. Consequently, macros might substitute in places where you don't expect them to.

Declaring timers as a constant pointer avoids both of these problems. The name should be visible in your debugger, and if you declare it in a nonglobal scope, it should stay there.

On the other hand, with some compilers on some platforms, declaring timers as a constant pointer might—I emphasize might—produce slightly slower and larger code. The compiler might produce different code if you define the pointer globally or locally. It might produce different code if you compile the definition in C as opposed to C++. I'll explain what the differences are and why they occur in my next column.

Dan Saks Saks is president of Saks & Associates, a C/C++ training and consulting company. You can write to him at dsaks@wittenberg.edu.

Endnotes

Loading comments...