MODULE 3 · LESSON 3.3 · THEORY
Type Casting in Embedded C: sizeof, Implicit Conversion and Pointer Casts
How sizeof works, what integer promotion and the usual arithmetic conversions do, how truncation and sign extension change values, and how to cast an integer to a memory-mapped register address safely.
Beginner → Intermediate
Level
What You Will Learn
✓ Using sizeof on types, variables and expressions
✓ Why sizeof of an array parameter is the size of a pointer
✓ Integer promotion and the usual arithmetic conversions
✓ Truncation when narrowing and sign extension when widening
✓ Explicit casts: syntax and the cases where you need them
✓ Casting an integer to a hardware address: (volatile uint32_t *) 0x40020C14
✓ Pointer casts, alignment and strict aliasing
✓ Dangerous casts and how to find them with compiler warnings
✓ Best practices for type casting in embedded C
Key Terms
sizeofsize_tInteger promotionUsual arithmetic conversionsImplicit conversionExplicit castTruncationSign extensionNarrowingPointer castuintptr_tMemory-mapped registerStrict aliasing-Wconversion
1. The sizeof Operator: Size in Bytes at Compile Time
sizeof is an operator, not a function. It yields the size in bytes of its operand as a value of type size_t (an unsigned integer type from <stddef.h>). The compiler evaluates it at compile time, so it costs nothing at run time and can be used in constant expressions such as array bounds and _Static_assert.
| Form | Example | Notes |
|---|
| On a type | sizeof(uint32_t) | Parentheses are required |
| On a variable | sizeof(counter) or sizeof counter | Parentheses optional |
| On an expression | sizeof(a + b) | Uses the type of the expression after promotions; the expression is not evaluated |
| On an array | sizeof(buf) | Total bytes of the whole array |
| On a structure | sizeof(struct Packet) | Includes padding bytes |
sizeof(a) / sizeof(a[0]) gives the element count
int a[10]: each element 4 bytes, whole array 40 bytes → 40 / 4 = 10 elements
sizeof_forms.c — every way to use sizeof
#include <stdio.h>
#include <stdint.h>
struct Packet { uint8_t id; uint32_t value; uint8_t flags; };
static void by_pointer(int arr[10]) /* the [10] is ignored: arr is a pointer */
{
printf(“inside function: sizeof(arr) = %zu (a pointer)\n”, sizeof(arr));
}
int main(void)
{
int x = 5;
double d = 1.5;
int a[10];
char msg[] = “hello”;
printf(“sizeof(int) = %zu\n”, sizeof(int));
printf(“sizeof(x) = %zu\n”, sizeof(x));
printf(“sizeof x = %zu (parentheses optional for variables)\n”, sizeof x);
printf(“sizeof(x + d) = %zu (int + double -> double)\n”, sizeof(x + d));
printf(“sizeof(a) = %zu\n”, sizeof(a));
printf(“elements in a = %zu\n”, sizeof(a) / sizeof(a[0]));
printf(“sizeof(msg) = %zu (includes the terminating zero)\n”, sizeof(msg));
printf(“sizeof(struct Packet) = %zu (padding included)\n”, sizeof(struct Packet));
printf(“sizeof(void *) = %zu\n”, sizeof(void *));
int i = 1;
size_t s = sizeof(i++); /* operand is NOT evaluated */
printf(“after sizeof(i++): i = %d, result = %zu\n”, i, s);
by_pointer(a);
return 0;
}
Output on a 64-bit Linux PC (on the STM32F407 the pointer lines show 4 instead of 8)
sizeof(int) = 4
sizeof(x) = 4
sizeof x = 4 (parentheses optional for variables)
sizeof(x + d) = 8 (int + double -> double)
sizeof(a) = 40
elements in a = 10
sizeof(msg) = 6 (includes the terminating zero)
sizeof(struct Packet) = 12 (padding included)
sizeof(void *) = 8
after sizeof(i++): i = 1, result = 4
inside function: sizeof(arr) = 8 (a pointer)
The array-parameter trap: When an array is passed to a function it decays to a pointer. Inside the function sizeof(arr) is the size of a pointer (4 on the STM32F407), not the size of the array. Always pass the element count as a separate parameter.
Why use sizeof instead of a number? memcpy(dst, src, sizeof(src)) and for (i = 0; i < sizeof(buf) / sizeof(buf[0]); i++) stay correct when the type or the array length changes. Hard-coded numbers silently become wrong.
2. Implicit Type Conversion: Integer Promotion
Type casting in embedded C starts with the casts you never write: C converts values automatically in many situations. The first and most surprising rule is integer promotion: any operand of a type narrower than int (char, short, uint8_t, int16_t, bool) is converted to int (or to unsigned int if int cannot hold all its values) before the operation. The Cortex-M4 does arithmetic on 32-bit registers anyway, so promotion is exactly what the hardware does.
uint8_t + uint8_t: what really happens
a (uint8_t)
200
+
b (uint8_t)
100
→
both become int
200 + 100 = 300
→
stored in uint8_t?
300 → 44
promotion.c
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t a = 200, b = 100;
printf(“a + b = %d (int arithmetic, no wrap)\n”, a + b);
printf(“sizeof(a + b) = %zu\n”, sizeof(a + b));
uint8_t x = 5, y = 10;
printf(“x – y = %d (negative: promoted to int)\n”, x – y);
printf(“(x – y) < 0 : %d\n”, (x – y) < 0);
uint8_t z = x – y;
printf(“stored in uint8_t = %u\n”, z);
uint16_t p = 300, q = 300;
printf(“p * q = %d (fits in int, would not fit in uint16_t)\n”, p * q);
printf(“(uint16_t)(p * q) = %u\n”, (uint16_t)(p * q));
return 0;
}
Output (verified by compiling and running)
a + b = 300 (int arithmetic, no wrap)
sizeof(a + b) = 4
x – y = -5 (negative: promoted to int)
(x – y) < 0 : 1
stored in uint8_t = 251
p * q = 90000 (fits in int, would not fit in uint16_t)
(uint16_t)(p * q) = 24464
- The sum
a + b is 300 because the arithmetic happens in int; the wrap to 44 appears only when you store the result in a uint8_t. x - y with unsigned bytes gives −5, because both operands became signed int. Many bugs come from assuming an unsigned byte difference wraps to 251 inside an expression.- Promotion also applies to unary operators:
~a and -a are int.
3. The Usual Arithmetic Conversions
Understanding type casting in embedded C also means knowing the second implicit rule. When the two operands of a binary operator have different types, C converts them to a common type following the usual arithmetic conversions. After integer promotion the rule is: the operand of the lower-ranked type is converted to the higher-ranked one. This ladder shows the order:
Conversion ladder: the type lower on the ladder is converted upward
unsigned long long / long long
char, short (promoted to int first)
Any floating type outranks any integer type. For equal rank, unsigned wins over signed.
| Expression | Common type | Result |
|---|
int + unsigned int | unsigned int | -1 + 1u uses 4294967295 |
int + long | long | wider signed |
int + float | float | int converted to float |
float + double | double | float widened |
uint8_t + int16_t | int | both promoted |
int32_t + uint32_t | uint32_t | signed becomes unsigned |
arithmetic_conversions.c
#include <stdio.h>
int main(void)
{
int i = –1;
unsigned u = 1;
long l = 100000L;
float f = 2.5f;
double d = 2.5;
printf(“-1 + 0u : %u (-1 became 4294967295)\n”, i + 0u);
printf(“i < u : %d\n”, i < u);
printf(“sizeof(i + l) : %zu (long)\n”, sizeof(i + l));
printf(“sizeof(i + f) : %zu (float)\n”, sizeof(i + f));
printf(“sizeof(f + f) : %zu (float)\n”, sizeof(f + f));
printf(“sizeof(f + d) : %zu (double)\n”, sizeof(f + d));
printf(“sizeof(f * 2.0) : %zu (literal 2.0 is double!)\n”, sizeof(f * 2.0));
printf(“sizeof(f * 2.0f) : %zu (float)\n”, sizeof(f * 2.0f));
printf(“7 / 2 : %d\n”, 7 / 2);
printf(“7 / 2.0 : %.2f\n”, 7 / 2.0);
return 0;
}
Output (verified by compiling and running)
-1 + 0u : 4294967295 (-1 became 4294967295)
i < u : 0
sizeof(i + l) : 8 (long)
sizeof(i + f) : 4 (float)
sizeof(f + f) : 4 (float)
sizeof(f + d) : 8 (double)
sizeof(f * 2.0) : 8 (literal 2.0 is double!)
sizeof(f * 2.0f) : 4 (float)
7 / 2 : 3
7 / 2.0 : 3.50
Most expensive trap on a Cortex-M4F: f * 2.0 promotes f to double, forcing slow software floating point. Always write 2.0f.
4. Truncation: What Happens When You Narrow a Value
Type casting in embedded C often narrows values, so know the result. Narrowing converts a value to a type that cannot hold all of its possible values. For integers, the result is the original value reduced modulo 2N: the upper bits are simply discarded. This is called truncation. The compiler does not complain unless you enable -Wconversion.
Truncating int 0x00012345 to uint8_t keeps only the low byte
upper 24 bits discarded
| Conversion | What happens |
|---|
| wider integer → narrower unsigned | Value mod 2N (fully defined) |
| wider integer → narrower signed | Implementation-defined if out of range (wraps on GCC) |
| float → integer | Fraction discarded (toward zero); undefined if the value is out of range |
| integer → float | Rounded if more than 24 significant bits (float) or 53 (double) |
| double → float | Rounded; undefined if out of range |
truncation.c
#include <stdio.h>
#include <stdint.h>
int main(void)
{
int big = 0x12345; /* 74565 */
uint8_t u8 = (uint8_t)big; /* keep low 8 bits */
int8_t s8 = (int8_t)200; /* implementation-defined */
uint16_t u16 = (uint16_t)0x12345678u;
printf(“(uint8_t)0x12345 = 0x%02X = %u\n”, u8, u8);
printf(“(int8_t)200 = %d\n”, s8);
printf(“(uint16_t)0x12345678 = 0x%04X\n”, u16);
printf(“(int)3.99 = %d\n”, (int)3.99);
printf(“(int)-3.99 = %d (toward zero)\n”, (int)-3.99);
printf(“(int)(3.5f + 0.5f) = %d (rounding trick)\n”, (int)(3.5f + 0.5f));
int n = 16777217; /* 2^24 + 1 */
float f = (float)n;
printf(“int 16777217 -> float = %.1f (precision lost)\n”, f);
double dd = 0.1;
float ff = (float)dd;
printf(“double 0.1 -> float = %.10f\n”, ff);
return 0;
}
Output (verified by compiling and running)
(uint8_t)0x12345 = 0x45 = 69
(int8_t)200 = -56
(uint16_t)0x12345678 = 0x5678
(int)3.99 = 3
(int)-3.99 = -3 (toward zero)
(int)(3.5f + 0.5f) = 4 (rounding trick)
int 16777217 -> float = 16777216.0 (precision lost)
double 0.1 -> float = 0.1000000015
5. Widening and Sign Extension
Widening converts to a larger type and is safe for the value, but how the new upper bits are filled depends on the source type. A signed source is sign-extended (the sign bit is copied); an unsigned source is zero-extended.
The byte 0xF6: widened to 32 bits as int8_t versus uint8_t
sign bit copied: −10 stays −10
zeros added: 246 stays 246
sign_extension.c
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t byte = 0xF6;
int8_t sbyte = (int8_t)0xF6;
int32_t a = sbyte; /* sign extension */
int32_t b = byte; /* zero extension */
uint32_t c = (uint32_t)sbyte; /* sign extension, then reinterpret as unsigned */
uint32_t d = (uint32_t)byte;
printf(“int8_t 0xF6 -> int32_t = %d\n”, a);
printf(“uint8_t 0xF6 -> int32_t = %d\n”, b);
printf(“int8_t 0xF6 -> uint32_t = 0x%08X\n”, c);
printf(“uint8_t 0xF6 -> uint32_t = 0x%08X\n”, d);
return 0;
}
Output (verified by compiling and running)
int8_t 0xF6 -> int32_t = -10
uint8_t 0xF6 -> int32_t = 246
int8_t 0xF6 -> uint32_t = 0xFFFFFFF6
uint8_t 0xF6 -> uint32_t = 0x000000F6
Classic bug: Storing a received byte in a char (signed on x86) and then widening it produces 0xFFFFFFF6 instead of 0x000000F6. Use uint8_t for bytes so they are always zero-extended.
6. Explicit Type Casting: Syntax and Purpose
Explicit type casting in embedded C is written by the programmer. An explicit cast tells the compiler to convert a value to another type. The syntax is (type) expression. A cast is a request, not a command to change memory: it creates a new value of the target type.
| Purpose | Example | Why |
|---|
| Force floating-point division | (float)sum / count | Cast an operand before the division |
| Truncate on purpose | (uint8_t)(value & 0xFF) | Documents the intent |
| Widen before a shift | (uint32_t)b << 24 | Avoid signed-shift undefined behaviour |
| Round a float | (int32_t)(x + 0.5f) | Convert with rounding |
| Silence an unused result | (void)func(); | Shows the value is deliberately ignored |
| Integer to pointer | (volatile uint32_t *)0x40020C14UL | Hardware register access (next section) |
Cast placement changes the answer: (float)sum / count versus (float)(sum / count), sum = 7, count = 2
(float)sum / count
cast binds first: 7.0f / 2
3.5
(float)(sum / count)
integer divide first: 7 / 2 = 3
3.0
Precedence: A cast has higher precedence than the arithmetic operators but lower than postfix operators: (float)a / b casts only a, while (float)(a / b) casts the finished quotient.
7. Casting a Pointer to a Hardware Address
This is the type casting in embedded C that has no desktop equivalent: the cast that makes embedded C different from desktop C. A peripheral register lives at a fixed address given in the reference manual. To access it, you convert that integer constant into a pointer to the right type and dereference it:
register_access.c
#include <stdint.h>
/* Step by step: GPIOD ODR register on the STM32F407, address 0x40020C14 */
uint32_t addr = 0x40020C14UL; /* just a number */
volatile uint32_t *odr = (volatile uint32_t *) addr; /* number -> pointer */
*odr = 0x00001000UL; /* write: PD12 high */
uint32_t now = *odr; /* read it back */
/* The idiom you will see everywhere: one macro, no variable */
#define GPIOD_ODR (*(volatile uint32_t *) 0x40020C14UL)
GPIOD_ODR |= (1U << 12); /* set PD12 */
GPIOD_ODR &= ~(1U << 12); /* clear PD12 */
Reading the macro from the inside out
0x40020C14UL
integer constant
→
(volatile uint32_t *)
cast: address of a 32-bit register
→
*( … )
dereference: the register itself
| Part | Why it is there |
|---|
0x40020C14UL | Address from the manual; UL keeps it unsigned and at least 32 bits |
uint32_t * | The register is 32 bits wide, so one 32-bit load/store is used |
volatile | Every access must really reach the hardware (Module 14) |
Leading * | Makes the macro an lvalue you can read and write |
The same pattern works for any width. The pointer type decides the access size: (volatile uint8_t *) performs byte accesses, (volatile uint16_t *) half-word accesses.
| Peripheral (STM32F407) | Base address | Register example | Cast |
|---|
| GPIOD | 0x40020C00 | ODR at +0x14 | (volatile uint32_t *)0x40020C14UL |
| RCC | 0x40023800 | AHB1ENR at +0x30 | (volatile uint32_t *)0x40023830UL |
| USART2 | 0x40004400 | DR at +0x04 | (volatile uint32_t *)0x40004404UL |
| TIM2 | 0x40000000 | CNT at +0x24 | (volatile uint32_t *)0x40000024UL |
| SRAM1 | 0x20000000 | first RAM byte | (uint8_t *)0x20000000UL |
Never run this on a PC: Dereferencing (uint32_t *)0x40020C14 in a desktop program crashes with a segmentation fault: that address is not mapped. The cast itself is harmless; only the access is dangerous. The examples page simulates registers with ordinary variables.
address_cast.c — converting numbers to pointers and back (no dereference)
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
int main(void)
{
volatile uint32_t *odr = (volatile uint32_t *)(uintptr_t)0x40020C14UL;
uintptr_t back = (uintptr_t)odr;
printf(“pointer value : %p\n”, (void *)odr);
printf(“back to integer : 0x%” PRIXPTR “\n”, back);
printf(“odr + 1 (next reg): 0x%” PRIXPTR ” (pointer arithmetic scales by 4)\n”, (uintptr_t)(odr + 1));
volatile uint8_t *b = (volatile uint8_t *)(uintptr_t)0x40020C14UL;
printf(“byte pointer + 1 : 0x%” PRIXPTR ” (scales by 1)\n”, (uintptr_t)(b + 1));
return 0;
}
Output (verified by compiling and running)
pointer value : 0x40020c14
back to integer : 0x40020C14
odr + 1 (next reg): 0x40020C18 (pointer arithmetic scales by 4)
byte pointer + 1 : 0x40020C15 (scales by 1)
8. Pointer-to-Pointer Casts, Alignment and Strict Aliasing
You can also cast between pointer types: (uint8_t *)&value lets you see the bytes of any object, which is how you inspect memory or send a structure over a serial port. Three rules keep this safe:
- Character types may alias anything. Reading any object through
unsigned char * or uint8_t * is always allowed. - Other aliasing is not allowed. Reading a
float through a uint32_t * violates the strict aliasing rule: the optimiser may reorder or remove the access. Use memcpy or a union to reinterpret bits. - Alignment matters. A
uint32_t * must hold an address divisible by 4. Casting a pointer into the middle of a byte buffer can create a misaligned pointer (a hard fault on some Cortex-M accesses, undefined behaviour in C).
pointer_cast.c
#include <stdio.h>
#include <stdint.h>
#include <string.h>
int main(void)
{
float f = 1.0f;
/* 1. Allowed: inspect the bytes through uint8_t * */
const uint8_t *p = (const uint8_t *)&f;
printf(“bytes of 1.0f in memory: %02X %02X %02X %02X\n”, p[0], p[1], p[2], p[3]);
/* 2. Safe way to get the bit pattern as an integer */
uint32_t bits;
memcpy(&bits, &f, sizeof bits);
printf(“bit pattern via memcpy : 0x%08X\n”, bits);
/* 3. Also fine with a union (allowed in C99/C11) */
union { float f; uint32_t u; } pun = { .f = 1.0f };
printf(“bit pattern via union : 0x%08X\n”, pun.u);
return 0;
}
Output on a little-endian machine (the STM32F407 prints the same)
bytes of 1.0f in memory: 00 00 80 3F
bit pattern via memcpy : 0x3F800000
bit pattern via union : 0x3F800000
1.0f is 0x3F800000: little-endian memory holds the bytes in reverse
float f = 1.0f
9. Dangers of Incorrect Type Casting in Embedded C
Danger 1 — truncating a wide value silently: uint8_t level = adc_value; with a 12-bit ADC keeps only the low 8 bits. Scale first: (uint8_t)(adc_value >> 4).
Danger 2 — negative to unsigned: (uint32_t)(-1) is 4294967295; used as a loop bound or array index it becomes a disaster.
Danger 3 — float out of range to integer: (int)1e20f is undefined behaviour: check the range before converting.
Danger 4 — casting a pointer to a type with stricter alignment: (uint32_t *)&buf[1] is misaligned and can raise a hard fault.
Danger 5 — breaking strict aliasing: *(uint32_t *)&f to read a float’s bits may be optimised incorrectly; use memcpy.
Danger 6 — casting away const or volatile: (uint32_t *)reg where reg is volatile uint32_t * drops volatile, so the compiler may remove or reorder register accesses.
Danger 7 — pointer to int with the wrong size: int x = (int)ptr; loses the upper half on a 64-bit host. Use uintptr_t.
Danger 8 — casts that hide real bugs: A cast silences the warning but not the problem. If you need a cast to make the compiler quiet, first ask whether the types are wrong.
10. Best Practices for Type Casting in Embedded C
- Prefer not casting: choose the right type at the declaration.
- When you must cast, cast the operand before the operation, and add a short comment.
- Compile with
-Wall -Wextra -Wconversion -Wsign-conversion -Wcast-align and read every warning. - Use
uintptr_t for pointer-to-integer conversions and volatile pointers for hardware. - Define register addresses once, in a header, as macros or a struct overlay (Lesson 3.2).
- Use
memcpy or a union for type punning; never dereference an incompatible pointer cast. - Use
sizeof instead of numeric sizes, and size_t for the results. - Check ranges before narrowing floats to integers or wide integers to narrow ones.
Key Takeaways — Lesson 3.3
sizeof is a compile-time operator returning size_t; it works on types (with parentheses), variables and expressions without evaluating them.- Arrays decay to pointers in function parameters, so
sizeof there gives the pointer size. - Integer promotion widens anything narrower than
int to int before arithmetic; the usual arithmetic conversions then bring both operands to a common type. - Mixing signed and unsigned of the same rank converts to unsigned;
f * 2.0 forces double. - Narrowing truncates (mod 2N); widening sign-extends signed sources and zero-extends unsigned sources.
- An explicit cast creates a converted value: cast the operand before the operation.
(volatile uint32_t *)0x40020C14UL turns an address from the manual into a register you can read and write.- Dangerous casts: truncation, misalignment, aliasing violations and removing
volatile. Good type casting in embedded C is rare, deliberate and commented.
Frequently Asked Questions
What does sizeof return in C?
The size of its operand in bytes, as a value of the unsigned type size_t. It is evaluated at compile time, and the operand of an expression form is not executed.
Why does sizeof give the wrong size for an array passed to a function?
Because the array parameter decays to a pointer, so sizeof returns the pointer size (4 bytes on the STM32F407). Pass the length as a separate argument.
What is integer promotion?
Operands narrower than int (char, short, uint8_t, int16_t) are converted to int before an arithmetic or bitwise operation. As a result uint8_t a = 200, b = 100; a + b equals 300, not 44.
What is the difference between implicit and explicit type conversion?
Implicit conversion is done automatically by the compiler (promotion, arithmetic conversions, assignment). Explicit conversion is written by the programmer with a cast such as (float)sum.
How do you cast an integer to a hardware register address?
Cast the address constant to a pointer to a volatile fixed-width type and dereference it: #define GPIOD_ODR (*(volatile uint32_t *)0x40020C14UL).
What happens when you cast a larger integer to a smaller one?
The upper bits are discarded (the value is reduced modulo 2N). For signed targets with an out-of-range value the result is implementation-defined, but wraps on GCC.
What is sign extension?
When a signed value is converted to a wider type, the sign bit is copied into the new upper bits so the numeric value is preserved. Unsigned values are zero-extended instead.
Is type punning with pointer casts allowed in C?
Reading an object through a pointer of an incompatible type (for example a float through a uint32_t *) breaks strict aliasing. Use memcpy or a union instead. Access through unsigned char * is always allowed.
Ready to practise?
Next: a sizeof table program, promotion examples, casting to a memory-mapped register (simulated) and truncation examples.
2 Comments