Embedded C++ Tutorial: std::span—A Lightweight, Non-owning Array View
Think of std::span as a "transparent conveyor belt" in C++: it doesn't own the cargo on top (memory), but calmly and efficiently tells you "how many elements are here and where they start." In embedded development, we often need to pass a chunk of memory to a function—without copying it, and without losing type or boundary information. std::span was born for exactly this scenario.
Or rather, it wasn't until C++20 that a standard view container finally appeared.
std::span<T>is a non-owning view: it is not responsible for memory deallocation.- It is typically a pointer plus a length (very lightweight, cheap to copy).
- Using
std::span<const T>as a function parameter elegantly accepts multiple sources likeT[],std::array,std::vector, and raw pointers with a length. - Key warning: Do not let a
spanoutlive the underlying data — a dangling pointer will still bite you.
Motivation: Why not just use a pointer or vector?
In embedded code, we often see function signatures like this:
void process_buffer(uint8_t* buf, size_t n);This approach is indeed flexible, but it has a downside: the reader has to remember the type of buf, whether the length unit is "number of elements" or "number of bytes", and whether the function modifies the data... There are simply too many places for things to go wrong. std::span makes these semantics explicit: the type and value (length) are in the same object, improving both readability and safety.
Basic Usage
#include <span>
#include <vector>
#include <array>
#include <iostream>
void print_bytes(std::span<const uint8_t> s) {
for (auto b : s) std::cout << std::hex << int(b) << ' ';
std::cout << std::dec << '\n';
}
int main() {
uint8_t buffer[] = {0x10, 0x20, 0x30};
std::vector<uint8_t> v = {1,2,3,4};
std::array<uint8_t, 3> a = {9,8,7};
print_bytes(buffer); // 从内置数组构造
print_bytes(v); // 从 vector 构造
print_bytes(a); // 从 std::array 构造
print_bytes({v.data(), 2}); // 从 pointer + size 构造
}print_bytes uses std::span<const uint8_t> to receive input: it states that the content won't be modified, accepts multiple container sources, and requires the caller to copy no data.
Dynamic vs. Static Extent
std::span has two forms:
std::span<T>(orstd::span<T, std::dynamic_extent>): runtime size;std::span<T, N>: compile-time fixed element countN(known as a static extent).
Example:
int arr[4];
std::span<int, 4> s_fixed(arr); // 只有长度为 4 的数组能绑定
std::span<int> s_dyn(arr, 4); // 任意长度,运行时记录A static Extent can enable extra compile-time checks or optimizations in certain scenarios, but in embedded development, a dynamic extent is more common (since buffer lengths are often determined at runtime).
Useful Member Functions
s.size(); // 元素个数
s.size_bytes(); // 字节数(注意!元素个数 * sizeof(T))
s.data(); // 指向首元素的指针(可能为 nullptr 当 size()==0)
s.empty();
s.front(), s.back();
s[i]; // 下标,不做运行时检查(与 operator[] 语义一致)
s.subspan(offset, count); // 切片,返回新的 span(仍为 non-owning)
s.first(n), s.last(n); // 前 n 个或后 n 个元素视图
std::as_bytes(s); // 将 span<T> 视为 span<const std::byte>
std::as_writable_bytes(s); // 视为 span<std::byte>(当 T 可写时)Note: operator[] does not perform bounds checking; if you need boundary checks, use a at-like wrapper or add assertions during debugging.
Advanced Example: Subspan and Byte Operations
#include <span>
#include <cstddef> // for std::byte
void recv_packet(std::span<uint8_t> buffer) {
if (buffer.size() < 4) return;
auto header = buffer.first(4);
uint16_t len = header[2] | (header[3] << 8);
if (buffer.size() < 4 + len) return;
auto payload = buffer.subspan(4, len);
// 把 payload 当作字节流传给 CRC 函数
auto bytes = std::as_bytes(payload);
// crc_check(bytes.data(), bytes.size()); // 示例:调用检验函数
}This pattern of slicing an overall buffer into header/payload is especially well-suited for embedded protocol parsing—it is concise and safe (as long as you ensure the incoming buffer is valid).
Best Practices for Function Parameters
Designing an API to accept std::span has several benefits:
- The caller can pass an array,
std::array,std::vector, or a raw pointer with a length; - The function signature clearly expresses "this is a view (possibly read-only)";
- The function doesn't need template generics to support various containers.
Example:
void process(std::span<const int> data); // 明确:不修改数据
void mutate(std::span<int> data); // 明确:会修改数据This is more intuitive than writing template<class Container> void process(const Container& c), and it avoids unnecessary compile-time bloat.
Common Pitfalls
Dangling views: The most common mistake. Do not bind a
std::spanto thedata()of a localstd::vectorand return it to the caller:cppstd::span<int> bad() { std::vector<int> v = {1,2,3}; return v; // ❌ v 被销毁,返回的 span 悬垂 }Assuming ownership: A span does not hold memory, and it will not destruct or release it. If you need ownership, use
std::vector,unique_ptr, etc.Improper byte views:
std::as_bytesreturns aspan<const std::byte>for read-only byte access; useas_writable_bytesonly when the underlying data is writable.Out-of-bounds access:
operator[]does not check boundaries. Perform explicit checks when necessary, or use debug assertions.Not a null-terminated string: A
std::span<char>is not aCstring and does not guarantee termination with'\0'. For string handling, usestd::string_viewor process it with an explicit length.
Comparison with std::string_view
std::string_viewis designed specifically for character sequences (read-only view) and carries string semantics (commonly used for text).std::span<char>/std::span<std::byte>are generic for any element type, including writable scenarios. When dealing with binary protocols/buffers,std::spanis more appropriate; when handling immutable text,string_viewis more semantic.
Quick Embedded Scenario Examples
- A DMA callback places data into a fixed buffer, and the callback passes a
std::spanto the processing function without copying. - Data is read from Flash into a buffer, and then
std::spanis used to slice and parse the header and blocks. - When passing small chunks of data in an interrupt or real-time path, the copy overhead of
spanis extremely low.
Code Tips
- Write function parameters as
std::span<const T>to express read-only intent. - If you want to accept a buffer of size N without changing the logic, you can accept a
std::span<T, N>(static extent). - Use
subspan,first, andlastto construct subviews, rather than manually calculating pointer offsets. - Explicitly state in your public API documentation: a span does not manage lifetimes.
Quick API Reference
s for std::span<T>:
s.size(),s.size_bytes(),s.data(),s.empty()s[i](no bounds checking),s.front(),s.back()s.begin(),s.end()(supports range-for)s.subspan(offset, count),s.first(n),s.last(n)std::as_bytes(s),std::as_writable_bytes(s)