Hardware register access: Difference between revisions

From Bare metal micro:bit
Jump to navigation Jump to search
(Created page with "With the programming conventions used in the book, including the header file @hardware.h@ allows fixed peripheral registers to be referenced using a notation like @TIMER0.MODE@. In a driver that can use one of several timers, we might also wish to use the notation @TIMER[i]->MODE@ to refer to the @MODE@ register of the timer peripheral with index @i@. How do these notations come to refer to a device register at a fixed address? In @hardware.h@, a C structure @_timer@...")
 
No edit summary
 
Line 13: Line 13:
}
}
</pre>
</pre>
The sizes and offsets of each member have been carfully calculated to agree with those given in the hardware manual, and padding is included to fill up unused gaps between registers.  The @_PADDING@ macro expands into an effectively anonymous field of an approprate size: in this case
The sizes and offsets of each member have been carefully calculated to agree with those given in the hardware manual, and padding is included to fill up unused gaps between registers.  The @_PADDING@ macro expands into an effectively anonymous field of an approprate size: in this case
@0x504 - 0x308 - 4 = 0x1f8 = 504@ bytes.
@0x504 - 0x308 - 4 = 0x1f8 = 504@ bytes. (It's just a coincidence that the amount of padding in decimal matches the offset of the @MODE@ register in hexadecimal.)


Then in @hardware.h@, several instances of this structure are declared with external linkage:
Then in @hardware.h@, several instances of this structure are declared with external linkage:
Line 74: Line 74:
In the suggested scheme, the padding between members of a device structure can be calculated by a simple preprocessor that takes register definitions containing explicit addresses and calculates how much empty space appears between each register and the next.  The file named @hardware.f@ is the input to this preprocessor.
In the suggested scheme, the padding between members of a device structure can be calculated by a simple preprocessor that takes register definitions containing explicit addresses and calculates how much empty space appears between each register and the next.  The file named @hardware.f@ is the input to this preprocessor.


As a final note, my practice has been to use the type @unsigned@ for most registers, and not to write the type as @uint32_t@: for me reading the code in my head, @unsigned@ is two syllables and @uint32_t@ is six, and that makes a big difference in readability.  I am willing to pay the price when in 2054 a family of microcontrollers is released where the @unsigned@ type is no longer 32&nbsp;bits long.
As a final note, my practice has been to use the type @unsigned@ for most registers, and not to write the type as @uint32_t@: for me reading the code in my head, @unsigned@ is two syllables and @uint32_t@ is six, and that makes a big difference in readability.  I am willing to pay the price when in 2054 a family of microcontrollers is released where the @unsigned@ type is no longer 32&nbsp;bits long.  I also haven't thought it worthwhile to distinguish between registers that are read/write and those that are read-only, though this could be done.

Latest revision as of 09:25, 16 October 2024

With the programming conventions used in the book, including the header file hardware.h allows fixed peripheral registers to be referenced using a notation like TIMER0.MODE. In a driver that can use one of several timers, we might also wish to use the notation TIMER[i]->MODE to refer to the MODE register of the timer peripheral with index i. How do these notations come to refer to a device register at a fixed address?

In hardware.h, a C structure _timer is defined with an member named MODE:

struct _timer {
    unsigned START;                     // 0x000
    unsigned STOP;                      // 0x004
    unsigned COUNT;                     // 0x008
    ...
    unsigned INTENCLR;                  // 0x308
    _PADDING(504)
    unsigned MODE;                      // 0x504
}

The sizes and offsets of each member have been carefully calculated to agree with those given in the hardware manual, and padding is included to fill up unused gaps between registers. The _PADDING macro expands into an effectively anonymous field of an approprate size: in this case 0x504 - 0x308 - 4 = 0x1f8 = 504 bytes. (It's just a coincidence that the amount of padding in decimal matches the offset of the MODE register in hexadecimal.)

Then in hardware.h, several instances of this structure are declared with external linkage:

extern volatile struct _timer TIMER0, TIMER1, TIMER2, TIMER3, TIMER4;

This declaration tells the C compiler that it can refer to the instances using assembly-language symbols TIMER0, etc., but does not cause the compiler to allocate any storage for the structures.

The header file also declares an array of pointers to such structures.

extern volatile struct _timer * const TIMER[];

Again, the linkage is external, so the compiler will use the name TIMER to refer to this array of pointers, but will not allocate any storage for it.

Leaving the array aside for a moment, let's consider what assembly language output the compiler can produce for an assignment like

TIMER0.MODE = 0;

Several options are possible, but the compiler knows enough to work out that the device register appears at a fixed offset 0x504 from the symbolic address TIMER0, so it can generate the assembly language

ldr r0, =TIMER0+0x504
mov r1, #0
str r1, [r0]

The ldr instruction is a request to the assembler and linker to calculate the value TIMER0+0x504 and arrange for this constant to be moved into register r0 by planting the constant at a convenient place in the code segment and using a PC-relative load instruction.

This leaves to be answered the question how the symbolic constant TIMER0 gets its value. The answer is that the linker script device.ld contains a series of definitions of such constants, including

TIMER0  = 0x40008000;

This provides the information needed for the linker to calculate the address of TIMER0.MODE and 0x40008000 + 0x504 = 0x40008504.

For uniform access to multiple instances of the same peripheral, we use an array of pointers, with the indirection of a pointer needed when multiple instances exist but not at evenly-spaced addresses. For example, the nRF52833 has five timers with these addresses:

TIMER0  = 0x40008000;
TIMER1  = 0x40009000;
TIMER2  = 0x4000a000;
TIMER3  = 0x4001a000;
TIMER4  = 0x4001b000;

These are not evenly spaced, so making the _timer structure have a fixed size such as 0x1000 would not be enough to view them as a uniform array of stuctures. Instead, in hardware.h, the array TIMER is declared as shown above, with external linkage. The definition is in the C file startup.c:

volatile struct _timer * const TIMER[] = {
    &TIMER0, &TIMER1, &TIMER2, &TIMER3, &TIMER4
};

The upshot is that a reference such as TIMER[i]->MODE compiles into code that fetches a pointer from the constant array (which can live in ROM), then accesses the MODE register at a fixed offset 0x504 from the pointer. If one kind of peripheral must be treated this way, then for uniformity it's best to treat every peripheral which may have multiple instances in a similar way.

Ther chief advantage of the scheme outlined here is that it avoids the C language abuse of casting a constant integer address to a pointer type, as in

#define TIMER0 ((volatile struct _timer *) 0x40008000)

This form may give slightly more scope for compiler optimisations, since the device addresses are known to the compiler and not only to the linker. But (apart from the untidy cast) it involves a slightly tricky use of a preprocessor macro, with the potential to evoke confusing compiler messages when there is an error. Similar criticisms apply to the scheme where each device register has its own macro, as in

#define TIMER0_MODE ((volatile unsigned *) 0x40008504)

with the added disadvantage that it makes uniform treatment of families of peripherals more difficult without further tricks.

In the suggested scheme, the padding between members of a device structure can be calculated by a simple preprocessor that takes register definitions containing explicit addresses and calculates how much empty space appears between each register and the next. The file named hardware.f is the input to this preprocessor.

As a final note, my practice has been to use the type unsigned for most registers, and not to write the type as uint32_t: for me reading the code in my head, unsigned is two syllables and uint32_t is six, and that makes a big difference in readability. I am willing to pay the price when in 2054 a family of microcontrollers is released where the unsigned type is no longer 32 bits long. I also haven't thought it worthwhile to distinguish between registers that are read/write and those that are read-only, though this could be done.