C Part II · Module 14 · C Through a Hardware Engineer's Eyes
Choosing an Integer Type for a Hardware Field
Two bytes of a register, declared uint8_t, added together: the result is 300. Stored back, it is 44. Every warning flag this curriculum recommends is silent, and so is -Weverything. The explanation is a Part I rule — integer promotion — doing exactly what Module 3 said it does, in a place where Part I never put it. This chapter makes width and signedness one deliberate decision instead of two defaults: which stdint.h type a field actually calls for, why the exact-width types are permitted not to exist, when the least and fast variants are the right answer, and the precise boundary of what the compiler will and will not tell you about a narrowing you did not mean.
Intermediate21 min readCPart IIHardwareRepresentation
Part II · Module 14 · Chapter 14.2 · Choosing an Integer Type for a Hardware Field
1. The Decision, Stated Once
Chapter 14.1 ended with a rule: a representation specified outside your program belongs in the declaration. For an integer field, that representation has exactly two components.
How many bits. Not "at least this many" — exactly how many, because the field occupies a stated number of bits in a register, on a bus, or in a record.
Whether the top bit is a sign. A 16-bit field holding a temperature offset of −40 is not the same object as a 16-bit field holding a length of 65496, and the bits are identical. Only the specification distinguishes them.
C's answer is <stdint.h>, standardised in C99 and unchanged in C17. It provides names that state both components and nothing else:
#include <stdint.h>
uint32_t status; /* exactly 32 bits, unsigned, no padding */
int16_t signed_offset; /* exactly 16 bits, two's complement, signed */
uint8_t version; /* exactly 8 bits, unsigned */That is the whole recommendation, and most of this chapter is about the ways it is got wrong rather than the ways it is got right.
2. What the Exact-Width Types Actually Guarantee
uint32_t is not merely "an unsigned type that is 32 bits on your machine". The standard's requirement is stronger and worth knowing precisely, because the strength is what makes the type usable for a hardware field.
An exact-width type intN_t or uintN_t must have exactly N bits, no padding bits, and — for the signed form — a two's complement representation. There is no room for an implementation to supply a 36-bit uint32_t with four bits ignored.
The price of that strictness is that the types are optional. If a target has no 8-bit type without padding, it does not define uint8_t at all, and #include <stdint.h> simply does not give you the name. This is deliberate: the standard would rather the name be absent than be a lie. Machines where this bites are real — DSP families with 16-bit or 32-bit char — and on those, code written against uint8_t fails to compile instead of silently processing the wrong number of bits.
Measured here:
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
int main(void) {
printf("exact : uint8 %zu uint16 %zu uint32 %zu uint64 %zu\n",
sizeof(uint8_t), sizeof(uint16_t), sizeof(uint32_t), sizeof(uint64_t));
printf("least : %zu %zu %zu\n",
sizeof(uint_least8_t), sizeof(uint_least16_t), sizeof(uint_least32_t));
printf("fast : %zu %zu %zu\n",
sizeof(uint_fast8_t), sizeof(uint_fast16_t), sizeof(uint_fast32_t));
printf("uintptr_t %zu\n", sizeof(uintptr_t));
printf("UINT32_MAX = %" PRIu32 " INT32_MIN = %" PRId32 "\n", UINT32_MAX, INT32_MIN);
return 0;
}exact : uint8 1 uint16 2 uint32 4 uint64 8
least : 1 2 4
fast : 1 2 4
uintptr_t 8
UINT32_MAX = 4294967295 INT32_MIN = -2147483648Three families, and they answer three different questions.
| Family | Guarantee | Use it when |
|---|---|---|
uintN_t | exactly N bits, no padding | the width is in a specification — a register field, a wire format, a file record |
uint_leastN_t | at least N bits, smallest such type | you need the range, you want the smallest storage, and exactness is not required |
uint_fastN_t | at least N bits, fastest such type | a loop counter or accumulator that must hold N bits but lives only in your program |
The least and fast families are mandatory — every conforming implementation provides them for 8, 16, 32 and 64 — precisely because the exact ones may be missing. The widths of the fast types are implementation-defined and the values printed above are this platform's; another implementation may legitimately make uint_fast16_t eight bytes, reasoning that a register-width type is faster than a narrow one. That is exactly why fast must never be used for a field whose width is specified.
3. The Trap: Narrow Types Do Not Do Narrow Arithmetic
Here is the measurement that makes this chapter's priority Critical rather than High.
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint8_t a = 200, b = 100;
printf("uint8_t 200 + 100 = %d (type is int, not uint8_t)\n", a + b);
printf("stored back into uint8_t = %u\n", (uint8_t)(a + b));
printf("sizeof(a + b) = %zu\n", sizeof(a + b));
uint16_t reg = 0x8000;
printf("uint16_t 0x8000 << 1 = 0x%x (promoted to int first)\n", reg << 1);
return 0;
}uint8_t 200 + 100 = 300 (type is int, not uint8_t)
stored back into uint8_t = 44
sizeof(a + b) = 4
uint16_t 0x8000 << 1 = 0x10000 (promoted to int first)200 + 100 is 300, not 44. The addition did not wrap, because the addition was never eight bits wide.
This is Chapter 3's integer promotion, unchanged. Any operand of a type narrower than int is converted to int before an arithmetic operator sees it, provided int can represent all its values — and on this machine int is 32 bits, so every uint8_t and uint16_t value fits. The expression a + b therefore has type int, and sizeof(a + b) is 4 rather than 1, which is the compiler stating the fact outright.
So uint8_t controls storage, not arithmetic. The truncation to 44 happened at the cast, not at the +.
The uint16_t line shows the same rule being helpful: 0x8000 << 1 gives 0x10000 and not zero, because the shift happened in 32 bits. Code that expected a 16-bit register to wrap at the top bit got a wider answer than the hardware would produce.
This cuts both ways, and that is the point. Promotion is not a bug to be worked around; it is a rule to be accounted for. Sometimes it saves you from a wrap you did not want; sometimes it gives you a value the hardware cannot hold. Either way the type you declared is not the type the arithmetic used, and the only reliable habit is to decide deliberately what width each operation should happen in:
/* if the hardware wraps at 8 bits, say so where the wrap happens */
acc = (uint8_t)(acc + delta);
/* if you need the full product, widen BEFORE the operation, not after */
uint32_t wide = (uint32_t)reg << 1;Writing (uint32_t)(reg << 1) instead would be a different and usually wrong expression — the shift would still happen in int, and the cast would convert whatever came out.
4. Signedness Is Half the Decision and Gets a Tenth of the Thought
Width gets chosen carefully and signedness gets whatever was convenient. It should be the other way round: a wrong width usually fails loudly, and a wrong signedness fails the way Chapter 14.1's status byte failed — silently, and only for values with the top bit set.
The rule is short. Signed if and only if the specification says the field can be negative.
#include <stdint.h>
uint32_t status; /* a bag of flags. Never negative. Unsigned. */
uint16_t length; /* a byte count. Never negative. Unsigned. */
int16_t temp_offset; /* -40 means -40. Signed, and the spec says so. */
uint8_t version; /* an identifier. Unsigned. */Two specific failures follow from getting this wrong, and both have already been measured in this curriculum.
A signed field read as unsigned loses the negative values. A temperature offset of −40 read through a uint16_t is 65496, and every comparison against it behaves accordingly.
An unsigned field read as signed loses the top of the range and sign-extends on promotion — which is exactly Chapter 14.1 §1, where 0x85 became −123 and the bit-7 test returned false.
And a third, from Part I: mixing them in one comparison is Chapter 13.5's entire subject. A uint32_t register value compared against a signed int constant converts the int to unsigned, and -1 becomes 4294967295. That chapter measured the conversion and named -Wsign-compare, which lives in -Wextra, as the flag that catches the direct form. Nothing in Part II changes it; register values simply make the mixed comparison much more common, because the register is unsigned by nature and the constants you compare it against are written as plain decimal numbers.
/* the field is 16 bits, signed, from the specification */
int16_t offset = read_offset();
if (offset < 0) { ... } /* fine: both sides signed */
uint16_t raw = read_raw();
if (raw > offset) { ... } /* 13.5's bug: offset converts to unsigned */5. Writing Constants and Printing Values
Two small mechanisms complete the toolkit, and both exist because the obvious thing fails at the top bit or on another platform.
Constants with a guaranteed width. A literal like 1 has type int. Shifting it to bit 31 therefore shifts an int, and that is undefined behaviour — Chapter 13.4's territory, confirmed by the sanitizer rather than guessed at:
$ cc -std=c17 -fsanitize=undefined -fno-sanitize-recover=all shift_ub.c && ./shift_ub
shift_ub.c:2:28: runtime error: left shift of 1 by 31 places cannot be
represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior shift_ub.c:2:28No value from that program is printed here, because the program has no defined value to print. The fix is to give the literal the width you need before shifting it. <stdint.h> provides the macros:
uint32_t enable_bit = UINT32_C(1) << 31; /* 0x80000000 */
uint32_t field_mask = (UINT32_C(1) << 12) - 1; /* 0xfff, a 12-bit mask */UINT32_C(1) << 31 = 0x80000000
12-bit mask = 0xfff1u << 31 also works on this machine and on any machine where unsigned int is at least 32 bits, which is nearly all of them. UINT32_C(1) says what you mean without the "nearly".
Printing. %u expects unsigned int. uint32_t happens to be unsigned int here, so %u works — and on a target where uint32_t is unsigned long it is a mismatched conversion specifier, which is undefined behaviour and exactly the kind that works until it does not. <inttypes.h> supplies format-string fragments that are correct everywhere:
#include <inttypes.h>
uint32_t status = 0xDEADBEEFu;
printf("status = %" PRIu32 " / 0x%" PRIx32 "\n", status, status);status = 3735928559 / 0xdeadbeefThe syntax is adjacent string literal concatenation — "%" PRIx32 "\n" is three literals that the compiler joins — and it is ugly, which is the single most common reason people skip it. -Wformat=2, in Chapter 13.7's recommended set, is what catches the mismatch when they do.
6. The Misconception: uint8_t Means Eight-Bit Arithmetic
"If I declare everything
uint8_t, the arithmetic wraps at 255 the way the hardware does."
§3 measured the answer: 200 + 100 is 300. The arithmetic was 32 bits wide because every narrow operand is promoted to int first, and only the assignment back into a uint8_t truncated.
The belief is seductive because it is nearly right in a specific case — when every intermediate result happens to fit in eight bits, the promoted arithmetic and the imagined eight-bit arithmetic agree exactly. So code written under this misconception works for months and fails on the first value that overflows.
Three consequences worth separating:
An intermediate result is not truncated. (a + b) / 2 with a = 200, b = 100 gives 150, not 22. Here the promotion is doing you a favour — the eight-bit machine would have needed a wider accumulator to get the same answer.
A shift can produce bits the field cannot hold. uint16_t 0x8000 << 1 is 0x10000, a seventeenth bit that no 16-bit register has. If you are modelling hardware, that value is wrong, and only an explicit mask or cast makes it right.
The truncation happens at the store, so that is where it must be written. acc = (uint8_t)(acc + delta) places the cast where the wrap occurs and says it is intended. Without the cast the wrap still happens and nothing — not -Wconversion, not -Weverything — records that anyone decided it should.
Chapter 15.5 takes this much further, into the shift counts and top-bit behaviour that only appear in bit-manipulation code. What this chapter asks is narrower: choose the type for what the field is, then decide separately what width each operation should happen in. They are two decisions, and uint8_t only ever answers the first.
7. What You Should Be Able to Say Now
- A hardware field needs two decisions: how many bits, and whether the top one is a sign.
<stdint.h>has a name that states both, and no name that states only one. uintN_tmeans exactly N bits, no padding, two's complement for the signed form — and is therefore optional. A target that cannot provide it omits it, so your code fails to build rather than silently using the wrong width.leastandfastare mandatory and are not substitutes.leastis the smallest type with the range;fastis the quickest, and its width is implementation-defined. Measured here as 1/2/4, which another implementation may legitimately make 1/8/8. Never usefastfor a specified field.- The test for which family: if the value crosses a boundary — register, bus, file, another language — it needs an exact type. If it never leaves the program, it does not.
- Narrow types do not do narrow arithmetic.
uint8_t 200 + 100is 300,sizeof(a + b)is 4. Promotion converts both operands toint; the type controls storage, not arithmetic. - The warning boundary, measured. A conversion from a genuinely wider source is diagnosed (
-Wimplicit-int-conversiononuint32_t→uint8_t). Two operands already of the narrow type are not diagnosed, by any flag including-Weverything. - Signed if and only if the specification permits negative values. Unsigned read as signed loses the top bit (14.1's
0x85→ −123); signed read as unsigned loses the negatives (−40 → 65496); mixing them in a comparison is Chapter 13.5. 1 << 31is undefined — the literal is anint. UseUINT32_C(1) << 31or1u << 31. Print withPRIu32/PRIx32from<inttypes.h>, not%u.
8. Exercises
- Run §2's program and record all five lines. If your
fastwidths differ from 1/2/4, explain which guarantee is still satisfied. - Write the three
add_*functions from §3's callout, compile with-Wall -Wextra -Wconversion, and confirm the silence. Then add a fourth taking auint32_tand returninguint8_t, and confirm that one warns. State the boundary in your own words. - A register field is 12 bits wide, unsigned, at bit position 8 of a 32-bit register. Declare the variable that holds the extracted value and write the mask as a constant that cannot be a wrong width. Do not write the extraction itself — that is Chapter 15.4's.
- A sensor returns a 16-bit signed temperature in units of 0.1 °C. Declare it. Then write the conversion to whole degrees and say what happens at −0.5 °C, naming the Part I rule that decides it.
- Take §3's
uint16_t reg = 0x8000; reg << 1and write the two versions: one that models a 16-bit register that drops the overflow, and one that keeps all 17 bits. Verify both by printing. - Find a
printfin your own code that prints auint32_twith%u. Rewrite it withPRIu32. Then compile the original with-Wformat=2and say whether the flag would have caught it on this platform, and why that answer is not the whole story. - Write a header for a device with four fields — an 8-bit version, a 16-bit unsigned length, a 32-bit unsigned address, and a 16-bit signed offset — declaring each with the right type and asserting
CHAR_BIT == 8. Do not declare a structure; Chapter 14.5 explains why that is a separate decision.
9. Summary
- A field needs width and signedness as one deliberate decision;
<stdint.h>names state both. uintN_t: exactly N bits, no padding, two's complement, and optional — absent rather than wrong on a target that cannot provide it.least= smallest with the range;fast= quickest, implementation-defined width (measured 1/2/4 here). Both mandatory. Neither is for a specified field.- Test: does the value cross a boundary out of your program? Exact type. Otherwise, it does not need one.
- Measured:
uint8_t 200 + 100= 300,sizeof(a + b)= 4. Narrow types control storage, not arithmetic — promotion makes every operandintfirst. - Warning boundary, measured: a wider source narrowing is diagnosed; same-narrow-type operands are not, under any flag including
-Weverything. Write the cast because you meant it. - Signed only if the spec allows negatives. Both directions fail silently and only at the top bit.
1 << 31is UB (UBSan confirms). UseUINT32_C(1) << 31; print withPRIu32/PRIx32.
The width and the signedness are now stated. Both describe a single value — a number with a top bit and a bottom bit, and no opinion about where those bits sit in memory. That stops being sufficient the instant the value is written to something another machine will read, because at that moment a second ordering question appears that no integer type answers.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
