Part 2 of 2
Previously:
- Page 1 built the C foundation: shifts, masks, fields, register write rules, byte order, and target intrinsics.
- The important rule was to keep hardware meaning visible instead of leaving raw bit numbers scattered through the code.
In this part:
- We use C++
std::bitsetfor fixed size software flags, test helpers, and tooling.- We keep a clear boundary between software bitsets and volatile hardware registers.
Use C++ bitset where it fits
C++ has std::bitset in the <bitset> header. It represents a fixed number of bits known at compile time. It can set, reset, flip, test, count, compare, and print bits in a readable way. That fixed size is important. std::bitset<8> behaves like an eight-bit software value. std::bitset<128> behaves like a fixed diagnostic or resource map. It is not a dynamically growing container.
It is not a direct replacement for memory mapped hardware registers. A hardware register is volatile, may have side effects, and often has special write rules. std::bitset is better suited to fixed size software flags, protocol tests, configuration tools, host side unit tests, and embedded projects where the standard library footprint is acceptable.
One embedded caveat is worth making explicit. std::bitset::test() checks the bit position and the standard interface can report an out of range index. On small firmware builds that disable exceptions or use a reduced C++ runtime, keep indexes fixed through enums or validate them at the boundary. Do not turn a simple register flag into a heavy dependency by accident.
Good fits for std::bitset include:
| Use | Why bitset helps |
|---|---|
| Diagnostic snapshots | Named tests, readable counts, easy host assertions |
| Configuration tools | Fixed option sets without magic integers |
| Protocol test code | Compare expected and observed flags clearly |
| Resource maps with fixed size | set, reset, count, any, and none are already present |
| Debug formatting | to_string() gives a quick binary view in logs or test failures |
Poor fits include volatile hardware registers, write-one-to-clear status flags, very large dynamic sets, and public APIs where the bit count changes at runtime. Use the boring mask when the hardware really is a register. Use std::bitset when the bits are software state.
Name bitset indexes like register fields
This example keeps eight software diagnostic flags. The enum gives each bit a name, and std::bitset makes the operations explicit.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
#include <bitset> #include <cstddef> #include <cstdint> #include <string> enum class DiagFlag : std::size_t { RxOverflow = 0, CrcError = 1, SensorTimeout = 2, Brownout = 3, /* Note: the bitset is sized for the full 8 bit register. Bits 4..7 are reserved. */ Count = 8 }; class DiagnosticFlags { public: void set(DiagFlag flag) { /* Good: std::bitset keeps the flag operation readable. */ flags_.set(static_cast<std::size_t>(flag)); } void clear(DiagFlag flag) { flags_.reset(static_cast<std::size_t>(flag)); } bool test(DiagFlag flag) const { return flags_.test(static_cast<std::size_t>(flag)); } std::size_t count() const { return flags_.count(); } bool any() const { return flags_.any(); } std::string to_string() const { return flags_.to_string(); } private: static constexpr std::size_t BitCount = static_cast<std::size_t>(DiagFlag::Count); std::bitset<BitCount> flags_; }; |
The practical benefit is clarity. flags.set(DiagFlag::CrcError) is harder to misread than flags |= 2u. It also works nicely in host tests, where you may want to assert that exactly two diagnostic flags were raised or print the flag state after a simulated fault.
There is one small ordering detail to remember. std::bitset::to_string() prints the highest numbered bit first. For std::bitset<8>, bit 7 appears at the left side of the string and bit 0 appears at the right side. That matches the way we usually draw a byte, but it can surprise people who expect index 0 to be printed first.
Use bitset for snapshots, not volatile registers
The best embedded use of std::bitset is often a snapshot. Read the register using the normal low level code, then copy the value into a bitset for test code, debug printing, or higher level decision logic. That keeps volatile access and register side effects at the boundary.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <bitset> #include <cstdint> static constexpr std::size_t UartStatusBitCount = 8; static constexpr std::size_t UartRxReadyBit = 0; static constexpr std::size_t UartTxEmptyBit = 1; static constexpr std::size_t UartOverrunBit = 3; struct UartStatusSnapshot { std::bitset<UartStatusBitCount> bits; }; UartStatusSnapshot make_uart_status_snapshot(std::uint8_t raw_status) { /* Good: the volatile register was already read before this conversion. */ return UartStatusSnapshot{std::bitset<UartStatusBitCount>(raw_status)}; } bool snapshot_has_error(const UartStatusSnapshot& status) { return status.bits.test(UartOverrunBit); } bool snapshot_can_transmit(const UartStatusSnapshot& status) { return status.bits.test(UartTxEmptyBit); } |
Do not map a std::bitset object over a peripheral address. It does not know about volatile semantics, read side effects, write-one-to-clear flags, reserved bits, bus widths, or required write sequences. Use the bitset after the hardware value has been safely captured.
Convert bitset to and from register shaped values
Sometimes a std::bitset is useful in test code around a register shaped value. Keep the conversion at the boundary. Do not spread conversions across the application. A small conversion function also gives you one place to document the width.
The next example decodes a status byte into a bitset for a host side test helper, checks a flag, then converts the flags back to an integer for comparison.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <bitset> #include <cstdint> static constexpr std::size_t StatusBitCount = 8; static constexpr std::size_t StatusRxReadyBit = 0; static constexpr std::size_t StatusOverrunBit = 3; std::bitset<StatusBitCount> status_to_bits(std::uint8_t status) { return std::bitset<StatusBitCount>(status); } std::uint8_t bits_to_status(std::bitset<StatusBitCount> bits) { /* Tradeoff: to_ulong is fine here because the bitset has only 8 bits. */ return static_cast<std::uint8_t>(bits.to_ulong()); } bool status_has_overrun(std::uint8_t status) { const auto bits = status_to_bits(status); /* Good: named bit index keeps the test readable. */ return bits.test(StatusOverrunBit); } |
For a bitset wider than the target integer type, think before converting back to an integer. to_ulong() and to_ullong() are useful when the width is known to fit. For larger diagnostic maps, keep the value as a bitset, serialize it deliberately, or split it into named words.
Use bitset in host tests and tools
std::bitset is especially useful in host side tests because it makes expectations easy to read. The test does not need to know the register write sequence. It only needs to know which bits should be present in the captured result.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <bitset> #include <cstddef> static constexpr std::size_t FaultCount = 8; static constexpr std::size_t FaultCrcBit = 1; static constexpr std::size_t FaultTimeoutBit = 2; bool has_only_timeout_fault(std::bitset<FaultCount> faults) { return faults.count() == 1u && faults.test(FaultTimeoutBit); } std::bitset<FaultCount> expected_comm_faults() { std::bitset<FaultCount> faults; /* Good: the expected fault set reads like the test intention. */ faults.set(FaultCrcBit); faults.set(FaultTimeoutBit); return faults; } |
For tiny bare metal targets, plain masks may still be the better choice. For host tests, embedded Linux tools, configuration utilities, simulator code, or firmware that already uses a suitable C++ standard library, std::bitset can make fixed flag sets easier to read and review.
Practical checks before shipping bit code
Before committing bit manipulation code, check these points:
| Check | Why it matters |
|---|---|
| Are all masks unsigned? | Avoid signed shift and sign extension surprises |
| Are bit positions range checked when they are data? | Prevent invalid shifts |
| Is each register field defined by shift and mask? | Make packing and extraction reviewable |
| Is the register safe for read-modify-write? | Avoid clearing flags or triggering commands by accident |
| Are write-one-to-clear flags handled separately? | Generic clear helpers are wrong for those registers |
| Are shared flags protected across ISR and task contexts? | volatile is visibility, not atomicity |
| Are byte order rules explicit? | MCU endianness and protocol byte order are different things |
| Are packed fields tested at boundaries? | Most bugs live at zero, max, and out of range values |
Practical takeaways
Good bit manipulation is not about memorizing clever expressions. It is about making hardware and protocol meaning visible. Name bits and fields from the datasheet. Use unsigned masks. Clear fields before writing new values. Treat write-one-to-clear registers as a separate class of register. Protect shared flags when more than one execution context can touch them.
Use plain C masks for direct hardware work because they map clearly to registers and generated code. Use C++ std::bitset when you have a fixed size software flag set, host tests, tools, or an embedded C++ environment where the standard library cost is acceptable. The best choice is the one that keeps the bit meaning clear when the next engineer has to debug it with the board on the bench.
