The old C-style pointer plus length pair is familiar, but it is easy to split apart by accident. A review may show the pointer in one line, the length in another, and a later edit changes only one side of the pair. std::span does not make memory ownership easier, but it does put the shape of a borrowed buffer into one visible object.
That sounds small until you have debugged a packet parser, a DMA handoff, or a test harness where the pointer was still right but the count was stale. At that point, the value of a clearer API is not theoretical. It saves review time and removes one ordinary way to lie to yourself about what memory a function is allowed to touch.
Start with the pointer plus length problem
Imagine reviewing a UART parser after a small bug fix. The buffer pointer is passed into one helper, the length is adjusted in another helper, and a third function assumes both still describe the same byte range. Nothing looks dramatic in the diff, but the code now accepts a packet one byte past the valid frame. That is the kind of quiet mistake std::span is meant to make harder to write and easier to review.
A span keeps the address and size together at the API boundary. It will not prove that the memory is alive, and it will not validate the protocol for you, but it gives the borrowed range a single type. The next engineer can then see that the function works on a view of contiguous elements instead of chasing a pointer and a size through the call site.
Span is a view, not storage
A std::span points at contiguous elements owned somewhere else. It can view an array, a std::array, a std::vector, or a firmware buffer. The standard library reference describes it as a non-owning view over a contiguous sequence, which is the main idea to keep in your head. It does not allocate, copy, or extend the lifetime of the data.
The next example uses a receive buffer owned by the caller. The parser only needs to inspect the bytes for a packet header, so a span expresses "borrow this byte range" without pretending to own it. The byte 0xA5 is a protocol start byte in this example, so it is named instead of repeated as a bare number.
|
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 |
#include <array> #include <cstdint> #include <span> constexpr std::uint8_t PACKET_START_BYTE = 0xA5u; bool has_packet_header(std::span<const std::uint8_t> bytes) { if (bytes.size() < 2u) { return false; } /* Good: the start byte is named as a protocol value. */ return bytes[0] == PACKET_START_BYTE; } void poll_uart(void) { std::array<std::uint8_t, 32> rx_buffer{}; /* Good: span carries the pointer and length together. */ const bool header_seen = has_packet_header(rx_buffer); (void)header_seen; } |
The function signature is easier to review than const std::uint8_t* data, std::size_t length because the buffer and its extent cannot be separated at the call site.
Use const spans for read-only access
The element type controls whether the function may change the elements. A std::span<const T> is the usual choice for parsers, checksum functions, serializers, and inspection helpers. It tells the reader that the function can inspect the data, but it is not supposed to repair, clear, or reuse the caller's storage.
This checksum example receives bytes from any contiguous source. The body does not need to know whether those bytes came from an array, DMA buffer, or vector used in a desktop test harness.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
std::uint8_t checksum8(std::span<const std::uint8_t> bytes) { std::uint8_t sum = 0u; for (std::uint8_t byte : bytes) { /* Good: the function reads each byte but cannot modify it. */ sum = static_cast<std::uint8_t>(sum + byte); } return sum; } |
The span does not hide the cost. It is still a pointer and a size in the usual dynamic extent case. It just gives that pair a type with a clear meaning.
Use mutable spans for output buffers
When a function fills or transforms caller-owned memory, a mutable span makes that mutation explicit. That is useful in firmware because buffer ownership is often fixed by interrupt handlers, DMA, or static storage. The function can write into the provided range, but it still does not own the memory.
The following function writes a small command frame into a caller-owned buffer. The command byte is a protocol field, and the frame start byte is named so the packet format stays visible in the code.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
constexpr std::uint8_t FRAME_START_BYTE = 0xA5u; constexpr std::size_t COMMAND_FRAME_SIZE = 3u; bool build_command_frame(std::span<std::uint8_t> out, std::uint8_t command, std::uint8_t value) { if (out.size() < COMMAND_FRAME_SIZE) { return false; } /* Good: the caller provided enough writable storage. */ out[0] = FRAME_START_BYTE; out[1] = command; out[2] = value; return true; } |
This is more honest than returning a pointer to a local array, and it is more reviewable than passing a raw pointer with a separate capacity argument.
Bounds still need real checks
One trap is assuming that a span makes indexing safe. It does not. In C++20, operator[] on a span behaves like array indexing, so it does not give you a runtime bounds check. The size is available, which makes the check easier to write and review, but the code still has to make that check before indexing.
That matters in packet code because the size relationship is often part of the protocol. If byte 1 claims the payload length is 24, the parser must still prove that the frame contains the header plus 24 payload bytes before it reads the payload.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
bool read_u16_le(std::span<const std::uint8_t> bytes, std::uint16_t& value) { if (bytes.size() < 2u) { return false; } /* Good: indexing happens only after the length check. */ value = static_cast<std::uint16_t>(bytes[0]) | static_cast<std::uint16_t>(bytes[1]) << 8; return true; } bool read_u16_le_unsafe(std::span<const std::uint8_t> bytes, std::uint16_t& value) { /* Bad: span does not make unchecked indexing safe. */ value = static_cast<std::uint16_t>(bytes[0]) | static_cast<std::uint16_t>(bytes[1]) << 8; return true; } |
For embedded code, I prefer explicit size checks close to the indexing. It gives the reviewer the full reason in one place: what size is required, what happens if the frame is short, and which bytes are read after the check.
Use spans at module boundaries
std::span is most useful at the point where ownership should not cross the boundary. A driver may own a receive buffer, a parser may only inspect it, and an application layer may only need the decoded fields. Passing a span into the parser keeps that boundary narrow.
This becomes especially helpful when the same code has to run in two places. On the target, bytes may come from a fixed UART or SPI buffer; in a unit test, the same function may receive a std::array or std::vector. The parser does not need a template just to accept different containers. It needs a view of contiguous bytes.
Subspans make protocol parsing clearer
Protocol code often peels a header away from a payload. With raw pointers, that usually means pointer arithmetic and duplicated length checks. With spans, the smaller views keep their sizes attached, which makes the code easier to scan during a review.
In this packet shape, byte 0 is the start byte, byte 1 is the payload length, and the remaining bytes are the payload. The snippet checks the declared length before creating the payload view.
|
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 |
struct PacketView { std::uint8_t length; std::span<const std::uint8_t> payload; }; bool parse_packet(std::span<const std::uint8_t> frame, PacketView& packet) { constexpr std::size_t HEADER_SIZE = 2u; if (frame.size() < HEADER_SIZE) { return false; } const std::uint8_t payload_length = frame[1]; if (frame.size() < HEADER_SIZE + payload_length) { return false; } packet.length = payload_length; /* Good: the payload view keeps its own checked size. */ packet.payload = frame.subspan(HEADER_SIZE, payload_length); return true; } |
The important detail is that the subspan is created only after the size relationship is checked. Span helps express the view, but it does not remove the need for protocol validation.
Static extent documents fixed-size views
Sometimes a function really does require an exact number of elements. A static extent span, such as std::span<T, 4>, makes that part of the type. This is useful for small hardware register images, calibration points, fixed protocol fields, and places where accepting the wrong count would only move the failure deeper into the code.
The calibration example below expects exactly two points. That is not a runtime preference. The simple line fit in the example depends on having the low and high point in a known order.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct CalPoint { std::uint16_t raw; std::uint16_t mv; }; int slope_uv_per_count(std::span<const CalPoint, 2> points) { const int raw_delta = static_cast<int>(points[1].raw) - static_cast<int>(points[0].raw); const int mv_delta = static_cast<int>(points[1].mv) - static_cast<int>(points[0].mv); if (raw_delta == 0) { return 0; } /* Good: the two-point assumption is visible in the type. */ return (mv_delta * 1000) / raw_delta; } |
Use static extent only when exact size is part of the interface. For variable length packets and logs, dynamic extent is usually the better fit.
Use byte views carefully
std::as_bytes and std::as_writable_bytes can be useful when the real operation is byte-level inspection of a contiguous object range. Checksums, transport framing, and diagnostic dumps sometimes need that view. The important word is “inspection.” A byte view does not solve endian, padding, alignment, or object representation questions for a protocol.
The example below is fine for a local diagnostic checksum over memory that the program already owns. It would not be a portable wire format by itself, because byte order and representation still need a deliberate protocol decision.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <cstddef> #include <cstdint> #include <span> std::uint8_t checksum_object_bytes(std::span<const std::uint16_t> words) { const auto bytes = std::as_bytes(words); std::uint8_t sum = 0u; for (std::byte byte : bytes) { /* OK: byte-level inspection for a local diagnostic checksum. */ sum = static_cast<std::uint8_t>( sum + std::to_integer<std::uint8_t>(byte)); } return sum; } |
For real communication protocols, write the encoding explicitly. A span can carry the range, but it should not hide serialization rules that have to survive different compilers, CPUs, and project versions.
Keep ownership decisions explicit
A common overcorrection is to replace every container reference with a span. That is not the goal. If a function needs to keep data after the call returns, it should usually copy into an owning container or take an owning object. A span is for borrowing during a call, not for remembering data after the owner may have moved on.
This distinction shows up in logging, deferred work, and queued commands. If a command queue stores a span to a temporary frame built by the caller, the code may pass tests and still fail later when the storage has been reused. Use std::vector, std::array, or a project-owned buffer when ownership needs to move or persist.
Span does not fix lifetime bugs
The most important warning is lifetime. A span borrows memory, so if the owner goes away, the span becomes dangling just like a raw pointer. The type makes the range clearer, but it does not become a safety net for careless storage.
This helper creates a temporary array and returns a view to it. The code is intentionally wrong because it shows the boundary of what span can protect. The type carries size, but it does not own the bytes.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
std::span<const std::uint8_t> bad_default_frame(void) { std::array<std::uint8_t, 3> frame{0xA5u, 0x01u, 0x00u}; /* Bad: the returned span points to a local array that is about to die. */ return frame; } std::span<const std::uint8_t> good_default_frame(void) { static constexpr std::array<std::uint8_t, 3> frame{0xA5u, 0x01u, 0x00u}; /* Good: the static array lives for the whole program. */ return frame; } |
In embedded firmware, this usually means spans should be passed down the call stack, not stored casually in long-lived objects unless the owner lifetime is very clear.
Watch concurrency and DMA ownership
Firmware buffers are often shared with interrupt handlers, DMA engines, or communication drivers. A span can describe the bytes, but it cannot tell you whether another context is changing them while the parser is reading. That problem still belongs to the design.
For a DMA receive buffer, make the handoff explicit. Parse only the portion that the driver has marked complete, and avoid keeping a span while the peripheral is refilling the same memory. If a background task needs a stable view after the next transfer starts, copy the bytes or use a double-buffered design. Span makes the range visible, but the synchronization policy still has to be real.
Also be careful with volatile. A volatile buffer may be needed for a memory-mapped register or for a specific low-level interface, but volatile is not synchronization and it does not make multi-context access atomic. For task plus ISR or DMA plus CPU handoff, the real design still needs ownership transfer, cache maintenance when relevant, barriers when the platform requires them, and a policy for when the buffer is stable.
Check target support before standardizing on it
std::span is a C++20 library feature in the <span> header. On desktop compilers and current mainstream standard libraries, it is normally available. In embedded projects, the answer still depends on the exact compiler, standard library, language mode, and vendor package. Check the current compiler support notes for GCC, Clang, MSVC, or your vendor toolchain before making it part of a shared firmware API.
If a small bare-metal project is locked to C++17, a project wrapper or a Guidelines Support Library style span can still give the same API shape. That is often better than keeping pointer-plus-length everywhere. Just avoid pretending the replacement is identical to the C++20 standard type unless the constructors, extent behavior, and lifetime expectations match the code you are writing.
Useful references:
Choose span when the API wants a view
The best use of std::span is boring in a good way. It appears on functions that read from a range, write into caller-owned storage, or carve a checked subrange from a packet. It should make the interface smaller and clearer, not more clever.
| Situation | Good fit? | Reason |
|---|---|---|
| Parser reads caller-owned bytes | Yes | Pointer and length travel together |
| Function fills a caller buffer | Yes | Capacity is visible at the interface |
| Object needs to own data | No | Use an owning container instead |
| Long-lived view into DMA memory | Maybe | Only if lifetime and concurrency are controlled |
| Protocol parser creates a payload view | Yes | Checked subspans keep size attached |
| Deferred command stores bytes for later | No | The queue needs ownership, not a borrowed view |
| Small MCU project without C++20 library support | Maybe | Use a compatible project span only if the team documents the differences |
Practical takeaways
Use std::span to make borrowed contiguous data explicit. Prefer std::span<const T> for readers and parsers, and mutable spans for output buffers. Use static extent only when the exact size is part of the contract. Check lengths before indexing and before creating subspans. Do not use span as an ownership type, and do not let it hide lifetime, DMA, cache, or concurrency questions that still need a real design answer.
