Chương 19. constexpr, concepts và xử lý lỗi không exception
Mục tiêu
- Chuyển việc tính bảng tra và kiểm tra cấu hình sang lúc biên dịch bằng
constexpr,static_assert,if constexpr. - Biết
consteval,constinitvà khi nào cần chúng. - Thiết kế xử lý lỗi cho build
-fno-exceptions:Result<T>/std::expectedthay cho exception. - Tổng kết cách chọn công cụ C++ hiện đại cho firmware.
Câu chuyện: bảng CRC 256 phần tử chiếm 256 byte RAM
Một bản firmware tính bảng CRC-8 khi khởi động: vòng lặp 256×8 lần, tốn thời gian boot và 256 byte RAM. Bảng này là hằng số — không có lý do gì để tính lúc chạy. Với constexpr, trình biên dịch tính sẵn và đặt kết quả vào Flash (.rodata); static_assert còn kiểm tra luôn với vector chuẩn ngay lúc biên dịch.
Kiến thức
1. constexpr và static_assert
// Bảng CRC-8 (poly 0x07) tính hoàn toàn lúc biên dịch; static_assert kiểm tra với vector chuẩn "123456789" -> 0xF4.
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
constexpr std::array<std::uint8_t, 256> make_crc8_table(std::uint8_t poly) {
std::array<std::uint8_t, 256> table{};
for (unsigned i = 0; i < 256; ++i) {
auto c = static_cast<std::uint8_t>(i);
for (int bit = 0; bit < 8; ++bit)
c = (c & 0x80) ? static_cast<std::uint8_t>((c << 1) ^ poly) : static_cast<std::uint8_t>(c << 1);
table[i] = c;
}
return table;
}
inline constexpr auto kCrcTable = make_crc8_table(0x07);
constexpr std::uint8_t crc8(const std::uint8_t* data, std::size_t n) {
std::uint8_t crc = 0;
for (std::size_t i = 0; i < n; ++i) crc = kCrcTable[crc ^ data[i]];
return crc;
}
inline constexpr std::array<std::uint8_t, 9> kCheck{'1', '2', '3', '4', '5', '6', '7', '8', '9'};
static_assert(crc8(kCheck.data(), kCheck.size()) == 0xF4, "CRC-8 sai");
// if constexpr: nhánh không dùng bị loại bỏ hoàn toàn khỏi code sinh ra.
template <typename T>
constexpr const char* width_name() {
if constexpr (sizeof(T) == 1) return "8-bit";
else if constexpr (sizeof(T) == 2) return "16-bit";
else return "32-bit+";
}
int main() {
const std::uint8_t msg[] = {0xDE, 0xAD, 0xBE, 0xEF};
std::printf("crc8(check) = 0x%02X\n", static_cast<unsigned>(crc8(kCheck.data(), kCheck.size())));
std::printf("crc8(msg) = 0x%02X\n", static_cast<unsigned>(crc8(msg, sizeof msg)));
std::printf("%s %s %s\n", width_name<std::uint8_t>(), width_name<std::uint16_t>(), width_name<std::uint32_t>());
}
crc8(check) = 0xF4
crc8(msg) = 0xCA
8-bit 16-bit 32-bit+
make_crc8_tablelà hàmconstexprthường: có vòng lặp, biến cục bộ,std::array— từ C++14/17 đều hợp lệ.inline constexpr auto kCrcTable = ...buộc gọi nó lúc biên dịch.static_assert(crc8(...) == 0xF4)dùng vector chuẩn CRC-8 (poly 0x07, dữ liệu"123456789", kết quả 0xF4). Nếu sai đa thức hay thuật toán, code không biên dịch — kiểm thử miễn phí, chạy mọi lần build, không cần phần cứng.- Cùng hàm
crc8được gọi ở lúc chạy với dữ liệu thật (msg). Đó là điểm mạnh: một code cho cả hai thời điểm. - Bảng nằm ở đâu? Với
constexprtoàn cục thường vào.rodata(Flash). Xác nhận bằng file.maphoặcarm-none-eabi-size— đừng đoán.
2. if constexpr
Trong template, if constexpr chọn nhánh lúc biên dịch và loại nhánh còn lại khỏi code sinh ra (nhánh bị loại không cần hợp lệ với kiểu đó, miễn là còn phụ thuộc tham số template). Dùng cho: chọn cách ghi thanh ghi 8/16/32-bit, chọn thuật toán theo kích thước kiểu, bỏ code debug.
3. consteval và constinit (C++20)
consteval: hàm bắt buộc chạy lúc biên dịch (không thể gọi với giá trị lúc chạy). Hợp với hàm tính hằng nhưbaud_divisor(clk, baud)— gọi sai lúc chạy là lỗi.constinit: biến toàn cục bắt buộc khởi tạo tĩnh (không có "static initialization order fiasco" lúc khởi động), nhưng vẫn sửa được lúc chạy. Hữu ích cho trạng thái toàn cục cần sẵn sàng trướcmain.constexpr= "có thể" tính lúc biên dịch; hai từ khoá trên siết chặt thành "phải".
4. Concepts — nhắc lại và mở rộng
Ở chương 15 ta ràng buộc kiểu bằng concept. Cùng cú pháp dùng để ghi lại hợp đồng của driver:
template <typename T>
concept Transport = requires(T t, std::uint8_t b) {
{ t.init() } -> std::same_as<void>;
{ t.write(b) } -> std::same_as<void>;
};
template <Transport T>
void send(T& t, std::uint8_t b) { t.write(b); }
Truyền vào kiểu thiếu write → lỗi ngay tại điểm gọi, nêu rõ ràng buộc nào không thoả.
5. Xử lý lỗi khi không dùng exception
// Xử lý lỗi không exception: Result<T> tối giản (C++23 có std::expected làm đúng việc này).
#include <cstdint>
#include <cstdio>
#include <utility>
#include <variant>
enum class Error : std::uint8_t { Timeout, BadCrc, Overflow };
constexpr const char* to_string(Error e) {
switch (e) {
case Error::Timeout: return "timeout";
case Error::BadCrc: return "bad crc";
case Error::Overflow: return "overflow";
}
return "?";
}
template <typename T>
class Result {
public:
constexpr Result(T value) : v_(std::move(value)) {}
constexpr Result(Error e) : v_(e) {}
constexpr bool ok() const { return std::holds_alternative<T>(v_); }
// get_if không ném exception: an toàn cho build -fno-exceptions (chỉ gọi khi ok() đúng).
constexpr const T& value() const { return *std::get_if<T>(&v_); }
constexpr Error error() const { return *std::get_if<Error>(&v_); }
private:
std::variant<T, Error> v_;
};
Result<std::uint16_t> read_adc(bool link_up, std::uint32_t raw) {
if (!link_up) return Error::Timeout;
if (raw > 0xFFFF) return Error::Overflow;
return static_cast<std::uint16_t>(raw);
}
Result<std::uint32_t> to_millivolt(std::uint16_t raw) {
return static_cast<std::uint32_t>(raw) * 3300u / 4095u;
}
int main() {
struct Case { bool up; std::uint32_t raw; };
const Case cases[] = {{true, 2048}, {false, 0}, {true, 70000}};
for (const auto& c : cases) {
const auto r = read_adc(c.up, c.raw);
if (!r.ok()) {
std::printf("loi: %s\n", to_string(r.error()));
continue;
}
const auto mv = to_millivolt(r.value());
std::printf("raw=%u -> %u mV\n", static_cast<unsigned>(r.value()), static_cast<unsigned>(mv.value()));
}
}
raw=2048 -> 1650 mV
loi: timeout
loi: overflow
Nhiều firmware build với -fno-exceptions (tiết kiệm Flash, thời gian chạy dự đoán được, MISRA/AUTOSAR hạn chế exception). Khi đó lỗi phải đi qua giá trị trả về:
| Cách | Ưu | Nhược |
|---|---|---|
Mã lỗi int/enum + tham số ra |
Quen thuộc, không overhead | Dễ quên kiểm tra, kết quả đi qua con trỏ |
std::optional<T> |
Đơn giản | Không cho biết vì sao lỗi |
Result<T> / std::expected<T, E> (C++23) |
Mang được cả giá trị lẫn lỗi; buộc xử lý cả hai | Cần trình biên dịch mới hoặc tự viết bản tối giản |
Result<T> trong ví dụ dùng std::variant<T, Error> với std::get_if để không ném exception. Thêm [[nodiscard]] lên kiểu (hoặc hàm) để trình biên dịch cảnh báo khi người gọi bỏ qua kết quả. Nếu toolchain của bạn hỗ trợ C++23, std::expected (header <expected>) làm đúng việc này — cần kiểm tra bản arm-none-eabi-g++ bạn dùng có hỗ trợ chưa.
Nguyên tắc: lỗi dự kiến (timeout, CRC sai, hết buffer) → trả giá trị lỗi; lỗi không thể xảy ra (vi phạm bất biến) → assert/dừng hệ thống có kiểm soát.
Tổng kết Phần 4: chọn công cụ nào?
| Bài toán | Công cụ |
|---|---|
| Bảng tra, hằng cấu hình, kiểm tra hợp lệ | constexpr + static_assert |
| Callback vào API C | Lambda không capture |
| Hành vi tuỳ biến trong thuật toán | Template + lambda |
| Mảng cố định, bảng | std::array |
| Giá trị có thể vắng | std::optional |
| Tập sự kiện đóng | std::variant + std::visit |
| Tài nguyên có chủ duy nhất | Handle move-only / unique_ptr + deleter |
| Lỗi có lý do, không exception | Result<T> / std::expected |
Góc nhúng
- Đo thời gian boot trước/sau khi chuyển bảng CRC sang
constexpr(đọc bộ đếm chu kỳ DWT hoặc timer) — đó là con số của bạn, không phải con số trong sách. -fno-exceptions -fno-rtticùng-Oslà bộ cờ thường gặp cho MCU nhỏ; kiểm trastd::variant/<optional>vẫn biên dịch được với chúng trên toolchain của bạn.- Bảng
constexprlớn làm tăng Flash: cân nhắc kích thước bảng (256 phần tử) so với tính trực tiếp từng bit khi Flash chật.
Lỗi thường gặp
- Tưởng
constexprluôn chạy lúc biên dịch: nếu đối số là giá trị lúc chạy, hàm vẫn chạy lúc chạy. Muốn ép, gán vàoconstexprbiến hoặc dùngconsteval. constexprbiến cục bộ trong hàm không đảm bảo nằm ở Flash; đặt ở phạm vi file hoặcstatic.- Để
static_assertvới thông báo mơ hồ; viết rõ điều gì sai. - Trả
Resultnhưng bỏ qua kết quả; thiếu[[nodiscard]]. - Gọi
std::get<T>trênvarianttrong build không exception.
Bài tập
- Đổi đa thức thành 0x1D (CRC-8/SAE-J1850 khác ở giá trị khởi tạo và XOR cuối, nên chỉ đổi bảng là chưa đủ); tra tài liệu chuẩn để lấy vector kiểm thử đúng rồi thêm
static_assertmới. - Viết
consteval std::uint32_t baud_divisor(std::uint32_t clk, std::uint32_t baud)cóstatic_assert-kiểu kiểm trabaud != 0; thử gọi với biến lúc chạy và đọc lỗi. - Thêm
[[nodiscard]]vàoResultvà cố tình bỏ qua kết quảread_adc— xem cảnh báo. - Viết concept
Transportnhư mục 4 và áp dụng choUartDriver/SpiDrivercủa chương 15.
Tóm tắt
constexpr+static_assertchuyển tính toán và kiểm thử sang lúc biên dịch: rẻ, sớm, không cần phần cứng.if constexpr,consteval,constinitsiết chặt "khi nào" mọi thứ xảy ra.- Không exception ≠ không xử lý lỗi: dùng
Result<T>/std::expectedđể lỗi có lý do và không bị bỏ sót. - Phần 4 cho bộ công cụ đủ để viết firmware C++ hiện đại: an toàn hơn C, chi phí đo được, không bắt buộc heap.