Contents
The Challenge of Fixed-Size Integers Before C99
In embedded programming, before adopting the C99 standard (ISO/IEC 9899:1999), a significant challenge was ensuring the consistent sizing of key data objects. This complexity stemmed from the C standard’s (ISO/IEC 9899) non-committal stance on the size of an int
. We knew:
- A
short
is a minimum of 16-bits. - A
long
is a minimum of 32-bits. - An
int
is somewhere between ashort
and along
.
This flexibility boosted C’s portability, making it a favourite for various architectures, including some with non-standard bit sizes like Unisys’s 36-bit and 48-bit ints. However, from a portability perspective, we could rely on limited ranges (signed int
: -32767…32767, unsigned int
: 0…65535).
Coding standards before C99 (like MISRA-C1 and MISRA-C2) navigated this by eschewing basic integral types (char
, short
, int
, and long
) in favour of specific-length typedefs
, e.g.,
typedef i8 signed char;
typedef u8 unsigned char;
// etc.
C99 and <stdint.h>
: A Turning Point
C99 introduced fixed-width integer types, offering a standardised approach across systems:
Integer Width | Signed Type | Unsigned Type |
---|---|---|
8 bits | int8_t |
uint8_t |
16 bits | int16_t |
uint16_t |
32 bits | int32_t |
uint32_t |
64 bits | int64_t |
uint64_t |
Modern coding standards like MISRA-C:2012 now require these types from <stdint.h>
to be used instead of traditional integral types.
Tuning for Size and Performance: A Simple Case Study
Consider the function to square a number (ignoring macro definitions):
unsigned square(unsigned num) {
return num * num;
}
This implementation poses portability challenges across different architectures. For instance:
- On 8- or 16-bit systems,
sizeof(unsigned)
is typically ‘2’, limiting the parameter range to 0…32767. - On 32- or 64-bit systems,
sizeof(unsigned)
is likely ‘4’, extending the range to 0…2147483647.
One could argue that for maximum portability, we should use:
unsigned long long square(unsigned long long num) {
return num * num;
}
Giving a much greater range of parameter values before considering number wrapping.
However, for a 16-bit TI MSP430, the assembly generated (msp430-gcc -O3
) for unsigned long long
is considerably more complex than for unsigned int
:
Assembly for unsigned int
:
square:
MOV.W R12, R13
CALL #__mspabi_mpyi
RET
Assembly for unsigned long long
:
square:
PUSHM.W #3, R10
MOV.W R12, R8
MOV.W R13, R9
MOV.W R14, R10
MOV.W R15, R11
CALL #__mspabi_mpyll
POPM.W #3, R10
RET
The unsigned int
version results in reduced code size and better performance. This leads to a preference for uint16_t
if we know the parameter argument does not exceed UINT_MAX/2
:
uint16_t square(uint16_t num) {
return num * num;
}
The Surprising Efficiency of Larger Types
Optimising for specific ranges can be counterintuitive. For instance, using uint8_t
for squaring numbers 0-100 seems optimal but can be less efficient on non-8-bit processors. The assembly code for MSP430 illustrates this:
Assembly for uint8_t
:
square:
MOV.B R12, R13
MOV.W R13, R12
CALL #__mspabi_mpyi
RET
Here, uint16_t
would be more optimal, emphasising that the best integer type often aligns with sizeof(int)
.
Portability Challenges in Architecture Transitions
Moving from a 16-bit system to a 32-bit Cortex-M0 can unexpectedly increase code size. The optimised uint16_t
code now generates less efficient assembly:
Assembly for uint16_t
on Cortex-M0:
square:
mul r0, r0, r0
uxth r0, r0
bx lr
Whereas:
Assembly for uint32_t
on Cortex-M0:
square:
mul r0, r0, r0
bx lr
This one extra opcode (uxth
– Unsigned Extend Halfword) might seem insignificant. Still, over a whole codebase, it can waste a considerable amount of Flash memory and slow down processing speeds (remember, we are also working with slower clocks for power considerations).
And this leaves us with a bit of a headache. If we’d coded to unsigned
, then the code would have ported efficiently, but as we work to modern coding standards, we are forced to use a fixed-size type from <stdint.h>
.
The Role of Fast and Least Integers in <stdint.h>
There are five categories of integer types defined within <stdint.h>
, but for this discussion, we are limiting ourselves to three:
- integer types having specific exact widths, e.g.
uint16_t
/int16_t
- integer types having at least specific specified widths, e.g.
uint_least16_t
/int_least16_t
- fastest integer types having at least specific specified widths, e.g.
uint_fast16_t
/int_fast16_t
It has been over 20 years since the introduction of <stdint.h>
and the aliased integer types. But in all that time I cannot recall working with any projects (C or C++) that use either the least or fast integer types (please let me know if you use them regularly).
Fixed, Least or Fast?
Returning to our headache, do we use conditionals to select between a 16-bit build (for current systems) and 32-bit builds for future systems, or do we end up with two separate codebases (neither is ideal)?
This is where the other integer types become helpful, specifically, uint_fastN_t
.
Across the compilers we’ve had experience with, it appears that:
uintN_t == uint_leastN_t
We have not seen a case where the code generated for a least integer differs from using the fixed type across 8-, 16-, 32- and 64-bit architectures. Note that we’re focused on the deeply embedded space, so there may be cases where this is untrue.
First, if we recode to:
uint_fast8_t square(uint_fast8_t num) {
return num * num;
}
On an 8-bit processor (e.g. AVR ATtiny13A), the generated code uses an unsigned char as the type of the parameter (AVR GCC -O3 -mmcu=attiny13a
):
u8squaref:
mov r22,r24
rcall __mulqi3
For the 16-bit MSP430 code, we get the more efficient (16-bit integer parameter) code generated:
square:
; start of prologue
; end of prologue
MOV.W R12, R13
CALL #__mspabi_mpyi
; start of epilogue
RET
Importantly, if we then compile this code for the Cortex-M0, we now also get 32-bit efficient code (ARM GCC 12.3.1 (none) -O3
):
square:
muls r0, r0
bx lr
So, given a parameter range 0…100, using int_fast8_t
generates the smallest, fastest code for each architecture.
C++ and Fixed-Size Integer Types
For some bizarre reason, Modern C++ has chosen to make compiler support for the fixed-size types (e.g. std::unit32_t
) optional! But fast and least are mandatory (exploding head emoji) – that said, I do not know of any compiler that doesn’t support the fixed-size type alias.
Summary and Reflections
Embedded programming involves intricate decisions around integer types, balancing size, performance, and portability. Before C99, programmers relied on specific-length typedefs
to ensure size consistency. C99’s <stdint.h>
introduced fixed-width integer types, simplifying this process. However, choosing the right integer type – whether fixed, least, or fast – depends on the specific requirements of the target architecture and the application.
For instance, while uint16_t
might be optimal for 16-bit systems, but transitioning to a 32-bit system like the Cortex-M0 could necessitate a shift to uint32_t
for efficiency. Additionally, in scenarios where parameter values are within a specific range, the uint_fastN_t
types offer an adaptable solution to generate efficient code across various architectures.
In conclusion, the journey from fixed-size integers to exploring fast and least types in C and C++ highlights the evolving landscape of embedded programming. Understanding these nuances is vital to writing optimised, portable, and maintainable code in this domain.
As a summary, based on GCC, this is the expected sizes of parameters based on the standard integer type system
MCU | ATTiny13a | MSP430 | Cortex-M0 |
---|---|---|---|
Size | 8-bit | 16-bit | 32-bit |
sizeof(char) | 1 | 1 | 1 |
sizeof(short) | 2 | 2 | 2 |
sizeof(int) | 2 | 2 | 4 |
sizeof(long) | 4 | 4 | 4 |
uint8_t | unsigned char | unsigned char | unsigned char |
uintfast8t | unsigned char | unsigned int | unsigned int |
uint16_t | unsigned int | unsigned short | unsigned short |
uintfast16t | unsigned int | unsigned int | unsigned int |
uint32_t | unsigned long | unsigned long | unsigned int |
uintfast32t | unsigned long | unsigned long | unsigned int |
- Navigating Memory in C++: A Guide to Using std::uintptr_t for Address Handling - February 22, 2024
- Embedded Expertise: Beyond Fixed-Size Integers; Exploring Fast and Least Types - January 15, 2024
- Disassembling a Cortex-M raw binary file with Ghidra - December 20, 2022
Co-Founder and Director of Feabhas since 1995.
Niall has been designing and programming embedded systems for over 30 years. He has worked in different sectors, including aerospace, telecomms, government and banking.
His current interest lie in IoT Security and Agile for Embedded Systems.