MCU Coding

Published 23 February 2022

A collection of useful MCU programming techniques and sensor-related details that I have encountered in embedded projects.

This page was originally intended as a collection that could grow over time. The examples below cover integer and memory considerations, I²C EEPROM page writes, a bit-banged UART for debugging, and handling two's-complement sensor values.

Optimization and integer types

When programming a microcontroller, RAM and flash usage may be important. Fixed-width integer types from <stdint.h> are useful when the exact width of a value matters.

Type Typical size Typical use Minimum Maximum
uint8_t 1 byte Characters, small sensor values 0 255
uint16_t 2 bytes ADC and sensor data 0 65,535
uint32_t 4 bytes Bit fields, counters, flags 0 4,294,967,295
int8_t 1 byte Signed sensor data -128 127
int16_t 2 bytes Signed arithmetic -32,768 32,767
int32_t 4 bytes Signed arithmetic -2,147,483,648 2,147,483,647
float Usually 4 bytes Floating-point arithmetic Implementation dependent
double Usually 8 bytes* Floating-point arithmetic Implementation dependent
unsigned char 1 byte 0 At least 255
unsigned int Usually 2 or 4 bytes Natural integer type for the CPU 0 Implementation dependent
char 1 byte Signedness is implementation dependent
int Usually 2 or 4 bytes Natural signed integer type Implementation dependent
long At least 4 bytes Implementation dependent

* The exact size of C types is compiler and architecture dependent. On some embedded platforms, double has the same representation as float.

On small controllers it can also be useful to avoid floating-point operations when they are not needed. On some architectures, especially those without hardware floating-point support, floating-point code can increase both execution time and code size.

Fixed-width types such as uint8_t and uint16_t are particularly convenient for registers, protocols and sensor data. However, they are not necessarily the most efficient type for every function argument.

For example, compare:

uint8_t usart_send(uint8_t c)
{
    ...
}

with:

unsigned int usart_send(unsigned int c)
{
    ...
}

On some 32-bit microcontrollers the native unsigned int version can generate equally compact or even smaller code, despite using only the lowest eight bits of the argument. Narrow integer arguments may require additional masking or extension instructions.

This is compiler-, ABI- and architecture-dependent. If flash size matters, compare the generated assembly or object-file size rather than assuming that the smallest C type will always produce the smallest code.

I²C EEPROM page writes

I²C EEPROMs can normally be accessed byte-by-byte or using page writes. After a write operation, the EEPROM needs time to program its memory cells. A typical write-cycle time is several milliseconds.

Writing one byte at a time therefore becomes unnecessarily slow. Page-write mode allows several bytes to be transferred to the EEPROM's internal page buffer before one programming cycle takes place.

The page size depends on the EEPROM and is commonly 64 or 128 bytes. An important detail is that a page write must not cross a page boundary. The software therefore needs to split a block into suitable pieces.

The example below was written for a 32-bit NXP microcontroller and a CAT24C512 EEPROM with a 128-byte page size.

#define I2C_PAGESIZE 128

I2C_STATUS_T i2ceeprom_writeBlock(unsigned int addr,
                                  uint8_t *pData,
                                  unsigned int count)
{
    uint8_t arr[I2C_PAGESIZE + 2];
    I2C_STATUS_T r;

    unsigned int p0 = addr >> 7;          // 7 bits for page size 128
    unsigned int pN = (addr + count) >> 7;

    unsigned int p, n;
    unsigned int nFirst, nLast;

    nFirst = I2C_PAGESIZE - (addr % I2C_PAGESIZE);

    if (count < nFirst)
        nFirst = count;

    nLast = (addr + count) % I2C_PAGESIZE;

    for (p = p0; p <= pN; p++) {

        if (p == p0)
            n = nFirst;
        else if (p == pN)
            n = nLast;
        else
            n = I2C_PAGESIZE;

        arr[0] = (uint8_t)(addr >> 8);
        arr[1] = (uint8_t)(addr & 0xff);

        memcpy(&arr[2], pData, n);

        r = i2ceeprom_writeBlockArray(arr, n + 2);

        if (r != I2C_STATUS_DONE)
            break;

        Chip_Clock_System_BusyWait_ms(6);

        addr += n;
        pData += n;
    }

    return r;
}

The function i2ceeprom_writeBlockArray(array_ptr, bytecount) sends the array over I²C using the EEPROM's device address. The first two bytes contain the target memory address.

A more efficient implementation can poll the EEPROM for an ACK after each page write instead of always waiting a fixed 6 ms. The fixed delay used here is simple and was sufficient for the original application.

Bit-banged UART

Sometimes a microcontroller needs to be debugged when there is no display, unused hardware UART, or convenient debugger connection. Bit-banging a UART transmitter on an ordinary GPIO pin can be a very useful diagnostic tool.

Debug messages and hexadecimal values can then be sent to a computer through a USB-to-TTL serial cable. In this example I used 2400 baud, 8 data bits, no parity and one stop bit (8N1).

The GPIO was connected to an FTDI TTL serial cable such as the TTL-232R-3V3-WE.

// Hardware/MCU-specific definitions.

#define BB_OUT_PIN 9

#define BB_OUT_WR_HIGH() \
    Chip_GPIO_SetPinState(NSS_GPIO, 0, BB_OUT_PIN, true)

#define BB_OUT_WR_LOW() \
    Chip_GPIO_SetPinState(NSS_GPIO, 0, BB_OUT_PIN, false)

#define WAIT_US(x) \
    Chip_Clock_System_BusyWait_us(x)


// 2400 baud: approximately 416.7 us per bit.

#define Tx_WAIT_BPS_START 417
#define Tx_WAIT_BPS_DATA  417


void bb_uart_send_byte(unsigned int data_byte)
{
    uint8_t i;
    static bool first = true;

    if (first) {
        Chip_GPIO_SetPinDIROutput(NSS_GPIO, 0, BB_OUT_PIN);
        BB_OUT_WR_HIGH();
        WAIT_US(800);
        first = false;
    }

    // Start bit
    BB_OUT_WR_LOW();
    WAIT_US(Tx_WAIT_BPS_START);

    // Eight data bits, least-significant bit first
    for (i = 8; i != 0; --i) {

        if (data_byte & 1)
            BB_OUT_WR_HIGH();
        else
            BB_OUT_WR_LOW();

        data_byte >>= 1;
        WAIT_US(Tx_WAIT_BPS_DATA);
    }

    // Stop bit
    BB_OUT_WR_HIGH();
    WAIT_US(Tx_WAIT_BPS_DATA);
}


void bb_uart_send_str(char *s)
{
    while (*s)
        bb_uart_send_byte((unsigned int)*(s++));
}


#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wconversion"

void bb_uart_send_hex4(unsigned int c)
{
    if (c > 9)
        c += ('A' - 10);
    else
        c += '0';

    bb_uart_send_byte(c);
}

#pragma GCC diagnostic pop


void bb_uart_send_hex8(unsigned int v)
{
    bb_uart_send_hex4((v >> 4) & 0x0f);
    bb_uart_send_hex4(v & 0x0f);
}


void bb_uart_send_hex16(unsigned int v)
{
    bb_uart_send_hex4((v >> 12) & 0x0f);
    bb_uart_send_hex4((v >> 8) & 0x0f);
    bb_uart_send_hex4((v >> 4) & 0x0f);
    bb_uart_send_hex4(v & 0x0f);
}


void bb_uart_send_hex32(unsigned int v)
{
    bb_uart_send_hex16((uint16_t)(v >> 16));
    bb_uart_send_hex16((uint16_t)(v & 0xffff));
}

The GCC diagnostic pragmas in the original code were used to suppress conversion warnings around operations where truncation was deliberate.

Bit-banged UART is particularly useful for diagnostics because the receiver requires no cooperation from the target. The target only needs one available output pin.

Timing is CPU- and implementation-dependent. Interrupts can also disturb the bit timing, so a low baud rate such as 2400 baud makes this technique considerably more tolerant.

Two's complement

Sensors frequently return signed measurements using two's-complement representation. If the sensor value is not already stored in a correctly sized signed C type, the sign needs to be handled explicitly.

The following old VBScript example converts an unsigned 8-bit value:

function twoc8(x)
    if x and 128 Then
        twoc8 = -((x xor 255) + 1)
    else
        twoc8 = x
    End if
end function

Here bit 7 is the sign bit. If it is set, the value represents a negative number.

Sign extension

If a signed value occupies fewer bits than the integer used to hold it, its sign bit needs to be extended before the value can be interpreted correctly.

Original 8-bit value 10000000
Sign-extended to 16 bits 1111111110000000
Invert bits 0000000001111111
Add one 0000000010000000
Result -128

The following MCU function was useful when different sensors returned signed values with different widths. One sensor used an 11-bit two's-complement representation and another used 8 bits.

// Convert a two's-complement sensor value to int16_t.

#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wconversion"

static int16_t twoc2int(uint8_t vH,
                        uint8_t vL,
                        uint8_t bits)
{
    int d;

    d = (vH << 8) | vL;

    if (d & (1 << (bits - 1))) {

        // Sign extension.
        d = d | ~((1 << (bits - 1)) - 1);
    }

    return (int16_t)d;
}

#pragma GCC diagnostic pop

For example, with bits = 8, bit 7 is treated as the sign bit. With bits = 11, bit 10 becomes the sign bit.

When the incoming data format exactly matches a standard-width signed integer, a cast to an appropriate type may be simpler. Explicit sign extension is useful when a sensor uses unusual widths such as 11, 12 or 14 bits.