Skip to content
VLSI Mentor

C Part II · Module 15 · Bit-Level Programming

Width, Promotion and Shift Traps in Bit Code

A sixteen-bit register shifted left by one produces a seventeenth bit. A thirty-two-bit value shifted by thirty-two produces nothing at all — no value, undefined behaviour, and silence from every warning flag. A thirty-two-bit result assigned to a sixteen-bit variable quietly keeps half. These look like three unrelated bugs and they are all the same question, asked in three places: what type does this expression actually have? This chapter measures all three, shows that one operator answers the question in every case, and makes the check something you can run rather than a rule you have to remember.

Advanced23 min readCPart IIHardwareCorrectness

Part II · Module 15 · Chapter 15.5 · Width, Promotion and Shift Traps in Bit Code

1. Where These Bugs Live

Everything in Chapters 15.2 through 15.4 worked. Every expression had a uint32_t on the left of the shift, a small constant on the right, and a uint32_t destination — and under those conditions the arithmetic does what it looks like it does.

Real register code breaks all three conditions routinely. The register is 16 bits because the bus is. The shift count is computed from a field width rather than written as a literal. The result goes into a narrower variable because that is what the field is. And the failures that follow share a signature that makes them unusually unpleasant to debug:

They appear only at the top bit. Every test with a small value passes. The code ships. The first value that reaches the high end of the field produces a wrong answer, or a value that varies with optimisation level, or nothing at all.

The three traps are promotion, shift count, and truncation. §6 is the reason they are one chapter.

2. The Rule Underneath All Three

Chapter 3 established integer promotion and Chapter 14.2 §6 restated it as a caution with a promise that it would be the subject later. This is later.

If a type is narrower than int, every arithmetic operator converts it to int first — provided int can represent all its values, which on this machine it can for every uint8_t and uint16_t.

The consequence is one sentence and it is the whole chapter:

The type you declared is the type of the storage. It is not necessarily the type of the arithmetic.

For a uint32_t the two coincide, which is why nothing in 15.2–15.4 misbehaved. For anything narrower they do not, and the gap is where the values go.

3. Trap One: The Operand Is Promoted

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint16_t r16 = 0x8000u;
printf("uint16_t 0x8000 << 1    = 0x%x    sizeof = %zu\n", r16 << 1, sizeof(r16 << 1));
printf("((uint32_t)r16) << 1    = 0x%x\n", (uint32_t)r16 << 1);
printf("(uint16_t)(r16 << 1)    = 0x%x\n", (unsigned)(uint16_t)(r16 << 1));

uint8_t r8 = 0xFFu;
printf("uint8_t 0xFF << 4       = 0x%x    sizeof = %zu\n", r8 << 4, sizeof(r8 << 4));
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint16_t 0x8000 << 1    = 0x10000    sizeof = 4
((uint32_t)r16) << 1    = 0x10000
(uint16_t)(r16 << 1)    = 0x0    <- what a 16-bit register does
uint8_t 0xFF << 4       = 0xff0    sizeof = 4

sizeof(r16 << 1) is 4. The variable is two bytes; the expression is four. The compiler is stating the fact outright, and it is the whole explanation for the line above it: the shift happened in 32 bits, so bit 15 moved to bit 16 instead of falling off the end.

Which answer is correct depends on what you are modelling, and that is the point.

If you are computing a value, 0x10000 is right and the promotion saved you. A 16-bit count multiplied by two really is 65536, and a language that wrapped it silently would be worse.

If you are modelling a 16-bit hardware register, 0x10000 is wrong. The register has sixteen bits; bit 15 shifted left is gone. The third line — cast back after the shift — is what the device does, and if you are writing a reference model that disagrees with the RTL at exactly one bit, this is the first place to look.

If you are building a mask, 0x10000 is a bit outside the register that will corrupt a neighbouring field when you | it in — Chapter 15.4 §5's measured trap, arriving from a different direction.

So the fix is not a single incantation. It is to decide, per expression, which width the operation should happen in, and say so:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint32_t wide   = (uint32_t)r16 << 1;        /* I want all 17 bits   */
uint16_t wrapped = (uint16_t)(r16 << 1);     /* I want 16-bit wrap   */

The cast goes before the shift in the first and after it in the second, and they are different programs. Writing (uint32_t)(r16 << 1) would be a third thing — shift in int, then widen the result — which happens to agree with the first here and would not if the shift count were larger.

4. Trap Two: The Shift Count Reaches the Width

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
unsigned n = (unsigned)atoi(argv[1]);    /* opaque: nothing can be folded */
uint32_t v = 1u;
printf("1u << %u = 0x%x\n", n, v << n);
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -Wall -Wextra -Wpedantic sh.c -o sh
(no diagnostic)

$ ./shu 31
1u << 31 = 0x80000000

$ ./shu 32
sh.c:8:38: runtime error: shift exponent 32 is too large for 32-bit type 'uint32_t'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior sh.c:8:38

$ ./shu 33
sh.c:8:38: runtime error: shift exponent 33 is too large for 32-bit type 'uint32_t'

No value is printed for 32 or 33, because the program has none.

The rule: a shift count must be greater than or equal to zero and strictly less than the width of the promoted left operand. For a uint32_t the legal counts are 0 through 31. Both ends matter — a negative count is undefined too, which is reachable whenever the count is computed as a difference.

The reason this is undefined rather than defined-as-something is hardware. Different processors do genuinely different things: some mask the count to its low bits so x << 32 is x << 0 and returns x unchanged; others produce zero; others leave the result unspecified. The standard refused to pick, so the program has no meaning and the compiler may assume it never happens.

The practical shape of the danger is that the count is usually not a literal. It is a field width, a difference between two positions, a loop variable, a value from a register map:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
mask = (1u << width) - 1u;             /* fails at width == 32  */
field = v >> (32 - width);             /* fails at width == 0   */
bit = v >> (hi - lo);                  /* fails if lo > hi      */

Chapter 15.2 §5 met the first of these and gave the guarded form. The general habit is to make the count's range a stated property rather than an assumption:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
assert(width < 32u);
_Static_assert(FIELD_W < 32u, "field width must be under the word size");

5. Trap Three: The Assignment Is Narrower

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint32_t wide   = 0x12345678u;
uint16_t narrow = (uint16_t)wide;
printf("uint32_t 0x12345678 -> uint16_t = 0x%x\n", narrow);
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint32_t 0x12345678 -> uint16_t = 0x5678

The top half is discarded. This is defined behaviour — conversion to an unsigned type keeps the value modulo 2ⁿ, which Chapter 13.4 established — and that is exactly why it is dangerous. Nothing is wrong, nothing is reported, and half the value is gone.

In bit code the common shape is an accessor whose return type is narrower than its arithmetic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint8_t get_thresh(uint32_t reg) { return (reg >> 16) & 0xFFu; }   /* fine: masked to 8 bits */
uint8_t get_div(uint32_t reg)    { return (reg >> 4)  & 0xFFFu; }  /* 12 bits into 8 */

The second silently drops the top four bits of a twelve-bit field. The mask says twelve bits and the return type says eight, and only one of them can be right.

Chapter 14.2 §3 measured the exact boundary of what the compiler will tell you here, and it is worth restating because it decides how much you can rely on the flag:

  • when the source is genuinely wider than the destination, -Wimplicit-int-conversion fires — get_div above is diagnosed
  • when both operands are already the narrow type, nothing fires, under any flag including -Weverything

So -Wconversion is real help at the boundaries of a function and no help inside a byte-arithmetic routine. Where it does fire, the fix is not to silence it with a cast but to decide which width is correct and make the return type and the mask agree.

6. The Three Are One Question

Three failures that look unrelated, shown to be one question. The first: a uint16_t holding 0x8000 shifted left by one gives 0x10000, a seventeenth bit the register cannot hold, because the operand was promoted to int before the shift; this is defined behaviour and wrong for a 16-bit register. The second: shifting a uint32_t by 32 is undefined behaviour, reported by UndefinedBehaviorSanitizer as shift exponent 32 too large, because the count reached the width of the promoted type. The third: assigning 0x12345678 to a uint16_t silently keeps only 0x5678, because the destination is narrower than the value; this is defined and silent. All three are answered by the same question, which is what type the expression actually has, and sizeof of the expression answers it directly.The symptomWhat you wroteWhat happenedStatusA bit appearsthe register cannothold itshift a 16-bitvalueuint16_t r = 0x8000; r<< 1a seventeenth bit0x10000 · promoted toint firstdefinedand wrong for a 16-bitregisterNonsense at thetop bitshift by thewidthuint32_t v; v << 32no value at allthe count reached thewidthundefinedUBSan names itThe top halfvanishesassign to anarrower typeuint16_t n =0x12345678the top half isgone0x5678 · destinationis narrowerdefinedand silent12
Figure 1 — the three traps side by side, which is the only way to see that they are not three. Read the Status column first: one is undefined behaviour and two are perfectly defined, so 'is it UB?' does not separate them and is not the useful question. Read the What happened column next and the actual pattern appears — in every row, the type the expression evaluated at was not the type that was written in the declaration. That is one question, not three, and the last paragraph of this section gives the operator that answers it in all three cases without any reasoning at all.

The three failures differ in their mechanism, in their standard status, and in which tool catches them. What they have in common is that in every case the expression was evaluated at a width other than the one in the declaration:

  • the uint16_t shifted at 32 bits, so bit 15 survived
  • the uint32_t shift count was compared against 32, the width of the promoted operand
  • the 32-bit result was stored at 16 bits, so half was discarded

So there is one question, and it is not "which of these three rules applies". It is:

What type does this expression actually have?

And C has an operator that answers it without any reasoning on your part:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
printf("%zu\n", sizeof(r16 << 1));      /* 4 — not 2 */
printf("%zu\n", sizeof(a + b));         /* 4 — not 1, for uint8_t a, b */

sizeof applied to an expression gives the size of that expression's type, and it does not evaluate the expression, so it is free and safe to apply to anything. When bit code is producing a value you cannot explain, print the sizeof of the subexpressions. It answers trap one directly, tells you what width to compare the shift count against for trap two, and tells you whether an assignment narrows for trap three.

That is what makes this one debugging skill rather than three rules: you do not have to recognise which trap you are in, because the same measurement tells you in all three cases.

7. The Misconception: Three Bugs, Three Rules

"So I need to remember: promotion goes to int, shift counts must be under the width, and assignments truncate."

Those three statements are all true and memorising them is the wrong strategy, because in the moment you are not reading a list — you are looking at a value that is wrong by one bit and you do not yet know which of the three is happening. Possibly none of them is. Possibly two are.

The working method is a single question applied in order:

One — what type is each operand, after promotion? sizeof on the operand tells you. Anything narrower than int has already been widened before the operator saw it.

Two — is the shift count in range for that type? Not for the declared type: for the promoted type. A count of 20 on a uint16_t is fine, because the shift happens at 32 bits — and the result then has bits the register cannot hold, which is trap one and not a shift problem at all.

Three — what type is the destination? If it is narrower than the expression, bits are being discarded, legally and silently.

Each question has a mechanical answer and none requires recalling a rule. The reason this matters more in bit code than anywhere else is the signature from §1: these failures hide below the top bit, so you will not meet them while writing the code. You will meet them in a bug report, and what you need then is a procedure, not a memory.

8. Writing Bit Code That Does Not Have These Problems

Five habits, each of which removes one of the ways in.

Do bit arithmetic in uint32_t or wider, whatever the register's width. Promotion is going to widen it anyway; doing it deliberately means you chose the width. Narrow back at the end, where the truncation is visible and intended.

Cast before the shift, never after. (uint32_t)r16 << 1 and (uint32_t)(r16 << 1) are different programs, and only the first controls where the shift happens.

Give every shift literal a width. UINT32_C(1) not 1, as Chapter 15.2 §4 established. 1 is an int and 1 << 31 is undefined.

Make every computed shift count's range a stated fact. assert(width < 32u) at runtime, _Static_assert when the width is a constant. Both ends: a count computed as hi - lo is negative whenever the arguments are swapped.

Make the return type of an accessor match its mask. A twelve-bit field returns uint16_t or uint32_t, never uint8_t. Where the source is genuinely wider, -Wconversion will tell you — and Chapter 14.2 §3 measured exactly where it will not.

And when something is wrong anyway, print sizeof of the subexpressions before forming a theory.

9. What You Should Be Able to Say Now

  1. The declared type is the storage type, not necessarily the arithmetic type. For uint32_t they coincide; for anything narrower they do not.
  2. Promotion, measured: uint16_t 0x8000 << 1 is 0x10000 with sizeof 4; uint8_t 0xFF << 4 is 0xff0 with sizeof 4. A 16-bit register would give 0x0.
  3. Which answer is right depends on what you are modelling — a value, a hardware register, or a mask — so the cast is a decision, placed before or after the operator accordingly.
  4. A shift count must be ≥ 0 and strictly less than the width of the promoted left operand. Measured: 31 gives 0x80000000; 32 and 33 are UB, named by UBSan, silent at every warning level.
  5. The condition is on the count, not the result. 0u << 32 is undefined too.
  6. Truncation on assignment is defined and silent. 0x12345678 into a uint16_t is 0x5678. -Wconversion fires when the source is genuinely wider and not when both operands are already narrow (14.2 §3).
  7. The three are one question: what type does this expression actually have? sizeof on the expression answers it, does not evaluate it, and costs nothing.
  8. The signature is the top bit. All three pass every test with small values.

10. Exercises

  1. Print sizeof for r16, r16 << 1, r8, r8 + r8, and (uint32_t)r16 << 1. Explain every answer that is not the declared size.
  2. Produce all three answers for uint16_t 0x8000 << 1 — the 17-bit value, the 16-bit wrapped value, and the one you get by casting after the shift. Say which models a 16-bit register and why the third is a different expression.
  3. Build §4's program with an opaque shift count and run it for 0, 31, 32 and 33 under -fsanitize=undefined. Then try a negative count and record what happens.
  4. Write get_div from §5 with the uint8_t return type and confirm -Wconversion reports it. Fix it two ways — change the return type, and change the mask — and say which is correct for a twelve-bit field.
  5. mask = (1u << width) - 1u fails at width == 32 and field = v >> (32 - width) fails at width == 0. Write one guarded expression that is correct for every width from 0 to 32 inclusive, and test all 33 under UBSan.
  6. Take uint16_t r = 0xFFFF; int x = r << 16; from §3's callout. Confirm with UBSan that it is undefined, and say which chapter first established the rule it breaks.
  7. Find a bit expression in your own code whose operands are narrower than int. Print the sizeof of the whole expression. If it is larger than you expected, decide whether the wider arithmetic is right for what the code models.

11. Summary

  • Storage type ≠ arithmetic type. Anything narrower than int is promoted before any operator sees it.
  • Measured: uint16_t 0x8000 << 1 = 0x10000, sizeof = 4. The 16-bit register's answer is 0x0.
  • Cast before the shift to choose the width; after it, to model a wrap. They are different programs.
  • Shift count: ≥ 0 and < the width of the promoted operand. 31 fine, 32 and 33 undefined — UBSan names them, no flag does. The condition is on the count, not the result.
  • Truncation is defined and silent: 0x12345678 → 0x5678. -Wconversion helps only when the source is genuinely wider.
  • One question: what type does this expression have? sizeof answers it for all three, without evaluating anything.
  • Habits: arithmetic in uint32_t+ · cast before the shift · UINT32_C(1) · assert the count's range · accessor return type matches its mask.
  • These all hide below the top bit — which is why they reach production.

Every value in this chapter was unsigned, and that was doing quiet work: unsigned conversions keep the value modulo 2ⁿ and unsigned arithmetic cannot overflow, so the only question was ever how many bits. A hardware field that represents a temperature, an offset or a correction is signed, and for those the promotion that widened 0x8000 to 0x00008000 does something quite different — and getting it to do that deliberately, rather than discovering it, is a technique in its own right.

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.