Chương 32. Thư viện container và callable không heap
Mục tiêu
- Hiểu vì sao STL chuẩn (
std::vector,std::function) thường không phù hợp firmware cấm heap, và container/callable sức chứa cố định giải quyết thế nào. - Tự viết
FixedVector<T, N>vàFunctionRef<Sig>để nắm cơ chế, trước khi dùng thư viện thật như ETL. - Biết ETL (Embedded Template Library) tồn tại như một lựa chọn thư viện chuyên cho nhúng, và khi nào nên dùng nó thay vì tự viết.
Câu chuyện: std::vector "rất tiện" cho tới khi profiling lộ ra malloc
Một driver ghép danh sách cảm biến bằng std::vector<Sensor>, code gọn và dễ đọc. Khi mang lên MCU thật với heap giới hạn, mỗi lần push_back vượt sức chứa hiện tại gây cấp phát lại (thường gấp đôi dung lượng) — không dự đoán được thời điểm, và có thể phân mảnh heap sau hàng giờ chạy. Giải pháp không phải "bỏ container, quay về mảng C thô" — mà là dùng một container có API tương tự nhưng sức chứa cố định, biết trước lúc biên dịch.
Kiến thức
1. FixedVector<T, N>: API quen thuộc, không heap
// FixedVector<T, N>: API giong std::vector (push_back, size, operator[], range-for) nhung SUC CHUA
// CO DINH, khong heap - dung tinh than voi thu vien ETL (Embedded Template Library, etlcpp.com) that,
// vien viet don gian de day du hieu co che, khong thay the ETL cho du an that.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <utility>
template <typename T, std::size_t N>
class FixedVector {
public:
bool push_back(const T& value) {
if (size_ == N) return false; // day: khong cap phat them, bao that bai co kiem soat
data_[size_++] = value;
return true;
}
template <typename... Args>
bool emplace_back(Args&&... args) {
if (size_ == N) return false;
data_[size_++] = T(std::forward<Args>(args)...);
return true;
}
void pop_back() { if (size_ > 0) --size_; }
T& operator[](std::size_t i) { return data_[i]; }
const T& operator[](std::size_t i) const { return data_[i]; }
std::size_t size() const { return size_; }
constexpr std::size_t capacity() const { return N; }
bool full() const { return size_ == N; }
bool empty() const { return size_ == 0; }
T* begin() { return data_; }
T* end() { return data_ + size_; }
const T* begin() const { return data_; }
const T* end() const { return data_ + size_; }
private:
T data_[N]{};
std::size_t size_ = 0;
};
struct Reading {
std::uint16_t raw;
float celsius;
};
int main() {
FixedVector<int, 4> nums;
for (int v : {10, 20, 30}) nums.push_back(v);
std::printf("size=%zu capacity=%zu full=%s\n", nums.size(), nums.capacity(), nums.full() ? "co" : "khong");
for (int v : nums) std::printf("%d ", v);
std::printf("\n");
if (!nums.push_back(40)) std::puts("push thu 4 thanh cong");
if (!nums.push_back(50)) std::puts("push thu 5 THAT BAI (da day, dung nhu mong doi)");
FixedVector<Reading, 2> readings;
readings.emplace_back(Reading{2048, 25.5f});
readings.emplace_back(Reading{2100, 26.1f});
for (const auto& r : readings) std::printf("raw=%u -> %.1f C\n", static_cast<unsigned>(r.raw), r.celsius);
std::printf("sizeof(FixedVector<int,4>) = %zu (co dinh, biet duoc luc bien dich)\n", sizeof(nums));
}
size=3 capacity=4 full=khong
10 20 30
push thu 5 THAT BAI (da day, dung nhu mong doi)
raw=2048 -> 25.5 C
raw=2100 -> 26.1 C
sizeof(FixedVector<int,4>) = 24 (co dinh, biet duoc luc bien dich)
FixedVector có push_back, operator[], begin/end (dùng được với range-for) giống std::vector, nhưng dữ liệu nằm trong mảng thành viên cố định T data_[N] — không new, không con trỏ tới heap. Khi đầy, push_back trả false thay vì tự động cấp phát lại — người gọi phải xử lý trường hợp đầy, đúng tinh thần "lỗi có kiểm soát" đã lặp lại xuyên suốt sách (chương 18, 19, 22).
sizeof(FixedVector<int, 4>) là một con số cố định, biết lúc biên dịch — khác hẳn std::vector mà kích thước thật (bao gồm bộ nhớ heap nó trỏ tới) không nằm trong sizeof của chính đối tượng.
2. FunctionRef<Sig>: callable không heap, không giới hạn kiểu capture
// FunctionRef<Sig>: tham chieu KHONG SO HUU toi mot callable co operator() (lambda co capture, functor) -
// khong heap, khong gioi han kich thuoc capture (khac std::function), nhung: doi tuong duoc tham chieu
// phai CON SONG khi FunctionRef duoc goi (giong tham chieu thuong - xem canh bao chuong 16). Ban toi
// gian nay CHUA ho tro nhan thang mot con tro ham (can xu ly rieng vi con tro ham khong chuyen doi
// ngam dinh sang void* - xem phan van ban trong sach).
#include <cstdio>
template <typename Signature>
class FunctionRef;
template <typename R, typename... Args>
class FunctionRef<R(Args...)> {
public:
template <typename F>
FunctionRef(F& f) : obj_(&f), invoke_(&invoke_impl<F>) {} // nhan tham chieu, khong sao chep F
R operator()(Args... args) const { return invoke_(obj_, args...); }
private:
template <typename F>
static R invoke_impl(void* obj, Args... args) {
return (*static_cast<F*>(obj))(args...);
}
void* obj_;
R (*invoke_)(void*, Args...);
};
// Ham nhan FunctionRef: khong quan tam ben trong la lambda hay functor nao, khong heap, mot lan goi
// gian tiep qua con tro ham (khong co bang ao, khac voi lop co ham ao - chuong 14).
int apply_twice(FunctionRef<int(int)> f, int x) { return f(f(x)); }
int main() {
int factor = 3;
auto multiply = [factor](int x) { return x * factor; }; // lambda CO capture
std::printf("apply_twice(multiply, 5) = %d\n", apply_twice(multiply, 5)); // (5*3)*3 = 45
struct AddOne { int operator()(int x) const { return x + 1; } } add_one;
std::printf("apply_twice(add_one, 5) = %d\n", apply_twice(add_one, 5)); // (5+1)+1 = 7
std::printf("sizeof(FunctionRef<int(int)>) = %zu (chi 2 con tro, bat ke callable ben trong lon co nao)\n",
sizeof(FunctionRef<int(int)>));
}
apply_twice(multiply, 5) = 45
apply_twice(add_one, 5) = 7
sizeof(FunctionRef<int(int)>) = 16 (chi 2 con tro, bat ke callable ben trong lon co nao)
Chương 16 đã nêu vấn đề của std::function: có thể cấp phát heap khi callable lớn. FunctionRef giải quyết một tình huống khác: tham chiếu không sở hữu tới một callable đã tồn tại sẵn (một lambda cục bộ, một functor), dùng làm tham số hàm. sizeof(FunctionRef<int(int)>) luôn là hai con trỏ, bất kể lambda bên trong lớn cỡ nào (capture nhiều biến) — vì nó không sao chép callable, chỉ giữ địa chỉ và một "trampoline" (mẫu đã gặp ở chương 28) gọi đúng kiểu thật qua template invoke_impl<F>.
Đánh đổi quan trọng — vòng đời: giống mọi tham chiếu (chương 9), FunctionRef chỉ hợp lệ khi đối tượng nó trỏ tới còn sống. Không được trả FunctionRef ra khỏi hàm đã tạo lambda cục bộ đó, hay lưu nó lại dùng sau khi lambda đã bị huỷ — đây là lỗi dangling reference kinh điển (chương 16 mục 3), áp dụng y hệt ở đây.
Phiên bản tối giản trong sách chưa hỗ trợ nhận thẳng con trỏ hàm thường (int(*)(int)), vì con trỏ hàm không chuyển đổi ngầm định sang void* (khác con trỏ đối tượng) — một implementation đầy đủ cần xử lý riêng trường hợp này (thường bằng nạp chồng constructor hoặc reinterpret_cast có ghi chú rõ ràng về tính khả chuyển).
3. ETL (Embedded Template Library) — khi nào dùng thư viện có sẵn
ETL là thư viện mã nguồn mở, header-only, cung cấp các container sức chứa cố định (etl::vector, etl::deque, etl::map...), etl::delegate (tương tự FunctionRef/std::function không heap), và nhiều tiện ích khác theo đúng tinh thần chương này — đã được kiểm thử kỹ và dùng rộng rãi trong công nghiệp nhúng. Chữ ký API chính xác và cách cài đặt (CMake FetchContent, submodule...) nên tra tài liệu chính thức của ETL cho phiên bản bạn dùng — sách không đưa API của ETL vào ví dụ chạy được vì môi trường biên dịch của sách không có sẵn thư viện này.
Tự viết FixedVector/FunctionRef như trong chương có giá trị: hiểu cơ chế, và đủ dùng cho dự án nhỏ hoặc khi không muốn thêm phụ thuộc ngoài. Với dự án lớn, nhiều kiểu container cần dùng, ETL (hoặc thư viện tương đương đã kiểm thử kỹ) thường đáng tin cậy hơn code tự viết vội.
Góc nhúng
- Sức chứa
NcủaFixedVectorlà một quyết định thiết kế tường minh, không phải "cứ thêm bao nhiêu cũng được" — chọn dựa trên phân tích worst-case của dữ liệu thực tế (số cảm biến tối đa, độ sâu hàng đợi lệnh...), không đoán. FixedVector<T, N>vớiTkhông tầm thường (có constructor/destructor riêng) gọi constructor cho toàn bộ N phần tử ngay khi khởi tạo (T data_[N]{}), kể cả các ô "chưa dùng" — vớiTnặng, cân nhắc dùng vùng nhớ thô (std::byte/aligned_storage) + placement new (chương 22) chỉ cho các ô đang dùng.- Đo
sizeofmọi container cố định bạn thêm vào driver — tổng cộng lại rất nhanh trên MCU RAM nhỏ.
Lỗi thường gặp
- Trả về hoặc lưu trữ
FunctionReftrỏ tới một lambda cục bộ đã ra khỏi phạm vi — dangling reference im lặng. - Chọn
Nquá nhỏ choFixedVectordựa trên dữ liệu thử nghiệm bàn, không dựa trên worst-case thực tế;push_backâm thầm thất bại trong sản phẩm nếu không kiểm tra giá trị trả về. - Dùng
std::vector"tạm thời cho nhanh" trong code chạy trên MCU rồi quên đổi lại trước khi release. - Viết lại từ đầu một container phức tạp (ví dụ bản đồ băm cố định) thay vì dùng ETL hoặc thư viện tương đương đã kiểm thử kỹ, tốn thời gian và dễ có bug tinh vi.
Bài tập
- Thêm
insert_at(index, value)vàerase(index)choFixedVector, dịch chuyển phần tử phù hợp. - Viết một
FixedVector<FunctionRef<void()>, 4>làm danh sách "việc cần làm" (task list) đơn giản, gọi từng phần tử trong vòng lặp — lưu ý vòng đời của các callable được tham chiếu. - Đọc tài liệu chính thức của ETL (etlcpp.com) về
etl::vector— so sánh API và hành vi khi đầy vớiFixedVectortự viết trong chương. - Giải thích bằng lời vì sao
FunctionRefkhông thể (một cách an toàn và tổng quát) nhận trực tiếp một con trỏ hàm thường mà không có xử lý đặc biệt, trong khi nhận lambda/functor lại tự nhiên.
Tóm tắt
- Container/callable sức chứa cố định giữ được API tiện lợi của STL mà không cần heap, đổi lấy việc phải xử lý tường minh trường hợp "đầy".
FunctionReflà tham chiếu không sở hữu tới callable, nhẹ hơnstd::functionnhưng đòi hỏi đối tượng được tham chiếu phải còn sống khi gọi.- ETL là lựa chọn thực tế, đã kiểm thử, cho các container/tiện ích này trong dự án nhúng thật — tự viết chỉ để hiểu cơ chế hoặc cho nhu cầu rất nhỏ.