Skip to content
VLSI Mentor

C++ Part I · Module 4 · Expressions and Operators

Bitwise Operators and Shifts

A register is not a quantity. It is a row of bits with named fields packed into it, and the most common thing an engineer does with one is pull a field out, change it, and put it back. Chapter 3.4 said that technique was called masking, that it needed operators the curriculum had not reached, and deferred it. This is where it arrives. The operators here treat a value as a pattern of bits rather than as a number, which makes them the natural tool for anything that came from hardware — and it also makes them the place where the promotion rule from Chapter 4.2 produces its most confusing result, because inverting an 8-bit mask does not give you an 8-bit answer. The second half of the chapter is about the shifts, which have something the other operators do not: a specific set of operand values for which the standard declines to say what happens at all, and which your compiler will usually not warn you about.

Foundation17 min readC++Part IFundamentalsBit Manipulation

Part I · Module 4 · Chapter 4.8 · Bitwise Operators and Shifts

1. Six Operators on Bit Patterns

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    a & b       // AND  — a bit is 1 where both have 1
    a | b       // OR   — a bit is 1 where either has 1
    a ^ b       // XOR  — a bit is 1 where they differ
    ~a          // NOT  — every bit flipped
    a << n      // shift left by n bit positions
    a >> n      // shift right by n bit positions

These work on integers only, and they treat the value as a row of bits. Chapter 4.5 §7 distinguished & from &&: && asks whether two things are true, & combines two patterns bit by bit.

Here they are on a real value:

Azvya Education Pvt. Ltd.VLSI Mentor
bitwise.cpp — the six operators on one register value
    const std::uint32_t reg = 0x00003A5Fu;

Expected output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reg           = 0x00003A5F
reg & 0x00FF  = 0x0000005F
reg | 0xF000  = 0x0000FA5F
reg ^ 0x00FF  = 0x00003AA0
~reg          = 0xFFFFC5A0
reg >> 8      = 0x0000003A
reg << 4      = 0x0003A5F0

Hexadecimal is the right way to read these, for the reason Chapter 3.4 §3 gave: each hex digit is exactly four bits, so the digits line up with bit positions. 0x5F is 0101 1111.

Work through two of them:

  • reg & 0x00FF kept the low byte (5F) and cleared everything above it, because 0x00FF has 1s only in the low eight positions. AND with a mask keeps the bits the mask selects.
  • reg | 0xF000 set four bits that were previously 0, leaving the rest alone. OR with a mask sets the bits the mask selects.

The printing uses std::hex and friends, which are formatting controls belonging to Chapter 23.2. They appear here because decimal output of a bit pattern is unreadable.

2. What Each Operator Is For

OperatorUse it toIdiom
&keep selected bits, clear the restvalue & MASK
& with ~clear selected bitsvalue & ~MASK
|set selected bitsvalue | MASK
^flip selected bitsvalue ^ MASK
& then comparetest whether bits are set(value & MASK) != 0

That last row is worth its own note. value & MASK produces a number, not a truth value — the number formed by the bits that survived. To ask "is this bit set?", compare against zero explicitly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    if ((reg & 0x0010u) != 0) { /* bit 4 is set */ }

It would also work without the comparison, because a non-zero value converts to true (Chapter 3.6 §3). Writing the comparison says what you mean, and keeps you out of the precedence trap Chapter 4.5 §6 measured — !reg & MASK is not !(reg & MASK).

3. The Shifts

a << n moves every bit n positions toward the high end; a >> n moves them toward the low end. Bits shifted off the end are gone, and the vacated positions fill with zeros.

For unsigned values there is an arithmetic reading: shifting left by n multiplies by 2ⁿ, shifting right by n divides by 2ⁿ and truncates. reg >> 8 gave 0x3A from 0x3A5F — the high byte of the low 16 bits, which is the same as dividing by 256.

Do not reach for shifts to do arithmetic. Write value * 2 when you mean multiplication; any compiler will emit a shift if that is faster. Use shifts when you mean move these bits, which is what the next section is about.

4. Shift and Mask: Getting a Field Out

This is the idiom the chapter exists for, and it is the frozen example from the curriculum index:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::uint32_t field = (reg >> 8) & 0xFu;

Two steps. With reg = 0x00003A5F, extracting the 4-bit field that starts at bit 8:

Four steps extracting a field from a register. Step one, the register holds 0x00003A5F, and the field we want is the four bits starting at bit position 8. Step two, shifting right by 8 gives 0x0000003A, which moves the wanted field down to the lowest four bits but leaves higher bits above it. Step three, the mask 0xF has ones in only the lowest four bit positions. Step four, combining the shifted value with the mask using bitwise and gives 0x0000000A, which is decimal 10 — the field's value.(reg >> 8) & 0xF01REG = 0X00003A5Fthe field we want is 4 bits starting atbit 802REG >> 8 = 0X0000003Athe field is now at the bottom — withjunk still above it03MASK = 0XFones in the low four positions only04(REG >> 8) & 0XF = 0XAdecimal 10 — the field's value
Figure 1 — extracting a 4-bit field at bit 8 from a 32-bit register. The shift moves the field down to the bottom of the value; the mask discards everything above it. Each step is an ordinary expression, and the value after each one is shown.

Verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
(reg >> 8) & 0xF = 10 (decimal)

The order matters. Shift first, then mask. Shifting brings the field to the bottom; masking discards whatever was above it. Masking first and shifting after also works if you adjust the mask, but shift-then-mask is the conventional form because the mask is then always a simple run of low bits whose width is the field's width.

A 4-bit field uses 0xF, an 8-bit field 0xFF, a 3-bit field 0x7. The pattern is 2ⁿ − 1.

5. Putting a Field Back

The reverse operation, and it is where &, | and ~ work together:

Azvya Education Pvt. Ltd.VLSI Mentor
field.cpp — replace a 4-bit field at bit 8 without disturbing anything else
    std::uint32_t reg = 0x00003A5Fu;

    const std::uint32_t mask     = 0xFu << 8;      // 0x00000F00
    const std::uint32_t new_value = 0x7u;

    reg = (reg & ~mask) | (new_value << 8);

Read it in three pieces:

  1. ~mask is every bit except the field's — 0xFFFFF0FF.
  2. reg & ~mask keeps everything except the field, clearing the field to zeros.
  3. | (new_value << 8) moves the new value into position and sets those bits.

This is called read-modify-write, and the reason it is written this way rather than just assigning is that a register holds many fields. Writing the whole register would destroy the others.

6. Shift Counts With No Defined Result

The shifts carry a restriction the other operators do not.

Shifting by a count that is negative, or greater than or equal to the width of the (promoted) left operand, is undefined behaviour.

For a std::uint32_t, a shift count of 32 or more is undefined. Not "gives zero" — undefined, in the Chapter 1.3 §5 sense.

The interesting part is the detection, and it splits exactly as Chapter 3.3 §5 and Chapter 4.3 §5 did:

Azvya Education Pvt. Ltd.VLSI Mentor
shiftub.cpp — INCORRECT. The shift count equals the type's width.
    std::uint32_t reg   = 1;
    int           shift = 32;
    std::cout << (reg << shift) << '\n';   // undefined behaviour
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ c++ -std=c++17 -Wall -Wextra -Wpedantic shiftub.cpp -o su
$                                            ← no diagnostic

$ c++ -std=c++17 -fsanitize=undefined shiftub.cpp -o su && ./su
shiftub.cpp:7:23: runtime error: shift exponent 32 is too large
                  for 32-bit type 'std::uint32_t'

Now the same shift written as a constant:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::cout << (reg << 32) << '\n';
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
shiftconst.cpp:6:23: warning: shift count >= width of type
                     [-Wshift-count-overflow]

The compiler warns when it can see the count and says nothing when it cannot. That is not inconsistency — a variable's value is not known at compile time, so there is nothing to check until the program runs.

This is the third time this module has met the same pattern, and it is worth naming:

A compile-time check needs a compile-time value. When the operand comes from a variable, the check has to move to run time — which means a sanitizer, or a guard you wrote.

In practice, shift counts come from field definitions and are usually constants, which is why this rarely bites. It bites when a width or position is computed — from a configuration value, a parsed descriptor, or a loop — and that is exactly when nothing will warn you.

7. The Trap: ~ on a Narrow Type

The most common surprise in this chapter, and it catches people the first time they invert a mask.

Azvya Education Pvt. Ltd.VLSI Mentor
nottrap.cpp — complete program
#include <cstdint>
#include <iostream>
#include <type_traits>

int main()
{
    std::uint8_t mask = 0x0Fu;
    std::cout << "~mask is int? " << std::is_same<decltype(~mask), int>::value << '\n';
    std::cout << "~mask as int      = " << (~mask) << '\n';
    std::cout << "~mask back in u8  = "
              << static_cast<unsigned>(static_cast<std::uint8_t>(~mask)) << '\n';
    return 0;
}

Expected output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
~mask is int? 1
~mask as int      = -16
~mask back in u8  = 240

~mask produced −16, not 240.

The expected answer was 240: inverting 0000 1111 in eight bits gives 1111 0000, which is 240. What happened instead is Chapter 4.2 §3's integer promotion:

  1. mask is a std::uint8_t, narrower than int, so it is promoted to int — value 15, in 32 bits: 0000...0000 1111.
  2. ~ inverts all 32 bits: 1111...1111 0000.
  3. As a signed int, that pattern is −16.

The inversion was correct. It was performed on a wider value than you were thinking about. Converting back to std::uint8_t keeps the low eight bits and gives the 240 you expected.

Two defences:

  • Use a type at least as wide as int for mask work. std::uint32_t masks do not promote, so ~ behaves as you expect.
  • If you must invert a narrow mask, convert back explicitly — static_cast<std::uint8_t>(~mask) — so the truncation is visible rather than accidental.

And note that nothing warned. The program is well-formed and the arithmetic is defined; it simply computed something other than what the author pictured.

8. Where This Belongs

Everything in this chapter is the vocabulary for one job: working with values whose meaning is in their bits.

TaskExpression
Is bit n set?(reg & (1u << n)) != 0
Set bit nreg |= (1u << n)
Clear bit nreg &= ~(1u << n)
Flip bit nreg ^= (1u << n)
Read a field of width w at position p(reg >> p) & ((1u << w) - 1)
Write a fieldreg = (reg & ~mask) | (value << p)

The compound forms are Chapter 4.6 §5, and &=, |=, ^= are why that chapter mentioned bitwise compound assignment.

Chapter 3.4 §5 said a std::uint8_t holding a 5-bit field will happily hold 200 and that the type enforces nothing. Masking is the enforcement: value & 0x1Fu guarantees a result that fits in five bits, whatever value was.

Building these expressions into something readable — named constants for positions and widths, and small functions instead of repeated masks — is a design question. Chapter 27.7 covers register models properly, once you have the language to build them.

9. Mistakes Worth Making Once

Using & where && was meant. Chapter 4.5 §7 measured both differences. 1 & 2 is 0.

Forgetting that bitwise operators bind loosely. reg & 0x10 != 0 is reg & (0x10 != 0). Parenthesise.

Inverting a narrow mask. ~mask on a std::uint8_t gives a 32-bit inversion — measured, −16 rather than 240.

Shifting by the type's width or more. Undefined. The compiler warns only for a constant count; a variable count needs a sanitizer or a guard.

Shifting to do arithmetic. Write * 2 when you mean multiply. The compiler will choose the instruction.

Masking first and shifting second without adjusting the mask. Shift-then-mask keeps the mask a simple low-bit run.

Assigning a whole register to set one field. That destroys the other fields. Read-modify-write exists for this.

Treating reg & MASK as a truth value. It is a number. Compare it against zero.

10. What You Should Be Able to Say Now

  1. &, |, ^, ~, << and >> treat an integer as a row of bits.
  2. & keeps bits, | sets them, ^ flips them, & ~MASK clears them, and (value & MASK) != 0 tests them.
  3. reg & MASK produces a number, not a truth value — compare it against zero explicitly.
  4. Shifts move bits and fill with zeros; bits shifted off the end are lost.
  5. Shift then mask to read a field: (reg >> 8) & 0xF. Measured: 0x00003A5F gives 10.
  6. A field mask of width w is (1u << w) - 1 — 0xF, 0xFF, 0x7.
  7. Read-modify-write to change a field: reg = (reg & ~mask) | (value << p), because a register holds several fields.
  8. Bitwise operators bind more loosely than comparisons. reg & 0x10 != 0 tests bit 0. Parenthesise.
  9. Shifting by a negative count, or by the operand's width or more, is undefined behaviour.
  10. Measured: a constant count of 32 warned with -Wshift-count-overflow; a variable count of 32 produced no diagnostic and was caught by -fsanitize=undefined.
  11. The pattern across this module: a compile-time check needs a compile-time value; otherwise the check moves to run time.
  12. ~ on a narrow type promotes first. Measured: ~mask on a std::uint8_t gave −16, not 240, and nothing warned.
  13. Use std::uint32_t for mask work, or convert back explicitly.
  14. Masking is what enforces a field's width, which the storage type does not.

11. Exercises

Exercise 1 — predict four results. With std::uint32_t reg = 0x00FF00A5u: (a) reg & 0xFFu (b) reg | 0x0F00u (c) reg >> 16 (d) reg ^ reg.

Exercise 2 — extract a field. Write the expression that reads a 3-bit field starting at bit 12 of a std::uint32_t called control. Show the mask you used and how you derived it.

Exercise 3 — write a field. Write the read-modify-write expression that sets that same 3-bit field to 5, and explain what each of the three parts does.

Exercise 4 — explain −16. std::uint8_t mask = 0x0F; ~mask gives −16. Walk through the three steps, naming the rule and the chapter that established it, and give two ways to avoid it.

Exercise 5 — the precedence bug. if (status & 0x08 != 0) compiles and tests the wrong bit. Say which bit it actually tests, why, and write the correct line.

Exercise 6 — two shift counts. For a std::uint32_t, explain what the compiler does about value << 32 and about value << n where n holds 32, and why the two cases differ.

Exercise 7 — enforce a width. Chapter 3.4 noted that a std::uint8_t holding a 5-bit field accepts 200. Write the expression that forces any value into five bits, and say what 200 becomes.

12. Summary

  • Six operators on bit patterns: & keeps, | sets, ^ flips, ~ inverts, << and >> move.
  • reg & MASK is a number, so compare it against zero to ask a question.
  • Shift then mask to read a field — (reg >> 8) & 0xF gave 10 from 0x00003A5F. A width-w mask is (1u << w) - 1.
  • Read-modify-write to change one: (reg & ~mask) | (value << p), because registers hold several fields.
  • Bitwise binds more loosely than comparison — reg & 0x10 != 0 tests the wrong bit. Parenthesise reflexively.
  • Shifting by the width or more is undefined. Measured: a constant count warned; a variable count did not, and needed the sanitizer.
  • A compile-time check needs a compile-time value — the third time this module has shown that split.
  • ~ on a narrow type promotes first. Measured: −16 rather than 240, with no diagnostic.
  • Masking is what enforces a field width, which the storage type does not.

Two chapters of this module have now ended by telling you to add parentheses, and both pointed at the same place. The bitwise operators bind more loosely than the comparisons; ! binds more tightly than almost everything; assignment groups right to left while most operators group left to right. Those are not separate facts to memorise — they are one table, and knowing how to read it removes an entire category of bug. Chapter 4.9 is that table, and the thing it is most often confused with: the question of when each part of an expression actually runs, which C++ answers very differently and much less completely.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 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.