Chương 18. Move semantics và smart pointer trong nhúng
Mục tiêu
- Hiểu move: chuyển quyền sở hữu thay vì sao chép.
- Viết kiểu move-only quản lý tài nguyên (ô buffer, bus, kênh DMA) theo RAII.
- Dùng
std::unique_ptrvới custom deleter cho tài nguyên không nằm trên heap; biết vì saoshared_ptrhiếm khi hợp với nhúng.
Câu chuyện: hai nơi cùng "giải phóng" một buffer DMA
Driver nhận một buffer DMA từ pool, đưa cho tầng giao thức xử lý. Nếu buffer được sao chép con trỏ, hai nơi cùng tin mình sở hữu: một nơi trả buffer về pool, nơi kia vẫn ghi vào — DMA ghi đè dữ liệu của người khác. Bug này chỉ xuất hiện khi tải cao. Nếu kiểu buffer là move-only, trình biên dịch chặn việc sao chép và buộc bạn viết rõ ai giữ nó.
Kiến thức
1. Move là gì
Sao chép (copy) tạo bản thứ hai; move chuyển nội dung sang đối tượng mới và để đối tượng cũ ở trạng thái "rỗng nhưng hợp lệ". std::move(x) không di chuyển gì cả — nó chỉ ép x thành rvalue để hàm move-constructor/move-assignment được chọn. Với kiểu chứa tài nguyên, move chỉ chép vài con trỏ/chỉ số và vô hiệu hoá nguồn.
Quy tắc thực dụng (Rule of Zero/Five):
- Nếu chỉ dùng các thành viên đã tự quản lý (
std::array, số,unique_ptr) → không viết gì (Rule of Zero). - Nếu tự quản lý một tài nguyên → viết đủ: destructor, move ctor, move assign, và
= deletecopy (Rule of Five). Đánh dấu movenoexcept.
2. Handle move-only trên pool tĩnh
// Move-only handle sở hữu một ô trong pool tĩnh: RAII + move semantics, không heap.
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <utility>
class BufferPool {
public:
static constexpr std::size_t kSlots = 4;
static constexpr std::size_t kSize = 64;
class Handle {
public:
Handle() = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&& o) noexcept : pool_(o.pool_), idx_(o.idx_) { o.pool_ = nullptr; }
Handle& operator=(Handle&& o) noexcept {
if (this != &o) {
release();
pool_ = o.pool_;
idx_ = o.idx_;
o.pool_ = nullptr;
}
return *this;
}
~Handle() { release(); }
explicit operator bool() const { return pool_ != nullptr; }
std::uint8_t* data() { return pool_->storage_[idx_].data(); }
private:
friend class BufferPool;
Handle(BufferPool* p, std::size_t i) : pool_(p), idx_(i) {}
void release() {
if (pool_) {
pool_->used_[idx_] = false;
pool_ = nullptr;
}
}
BufferPool* pool_ = nullptr;
std::size_t idx_ = 0;
};
Handle acquire() {
for (std::size_t i = 0; i < kSlots; ++i) {
if (!used_[i]) {
used_[i] = true;
return Handle{this, i};
}
}
return Handle{}; // hết ô: handle rỗng, không ném exception
}
std::size_t free_count() const {
std::size_t n = 0;
for (bool u : used_) n += u ? 0 : 1;
return n;
}
private:
std::array<std::array<std::uint8_t, kSize>, kSlots> storage_{};
std::array<bool, kSlots> used_{};
};
// Nhận handle theo giá trị: quyền sở hữu chuyển vào đây, ô được trả khi hàm kết thúc.
void consume(BufferPool::Handle h) {
h.data()[0] = 0xAB;
std::puts("consume: dang xu ly buffer");
}
int main() {
BufferPool pool;
std::printf("trong: %zu\n", pool.free_count());
auto a = pool.acquire();
auto b = pool.acquire();
std::printf("sau 2 acquire: %zu\n", pool.free_count());
consume(std::move(b)); // b bị "rút ruột": không dùng lại được
std::printf("sau consume: %zu (b con hop le? %s)\n", pool.free_count(), b ? "co" : "khong");
{
auto c = pool.acquire();
std::printf("trong scope: %zu\n", pool.free_count());
}
std::printf("ra scope: %zu\n", pool.free_count());
(void)a;
}
trong: 4
sau 2 acquire: 2
consume: dang xu ly buffer
sau consume: 3 (b con hop le? khong)
trong scope: 2
ra scope: 3
Handle(const Handle&) = deletecấm sao chép: không thể có hai handle cùng một ô.- Move ctor lấy
pool_/idx_rồi đặto.pool_ = nullptr— nguồn thành handle rỗng, destructor của nó không trả gì. - Move assign phải
release()ô đang giữ trước khi nhận ô mới, và tự bảo vệ trướca = std::move(a). consume(std::move(b)): quyền sở hữu chuyển vào tham sốh; khiconsumekết thúc,~Handletrả ô về pool — không cần gọi hàm giải phóng thủ công. Sau đóbrỗng (ví dụ in ra "khong").- Toàn bộ bộ nhớ là
std::arraytĩnh: không heap, hết ô thìacquire()trả handle rỗng thay vì ném exception.
3. std::unique_ptr với custom deleter
// unique_ptr với custom deleter: quản lý tài nguyên KHÔNG nằm trên heap (ở đây: quyền dùng một bus SPI).
#include <cstdio>
#include <memory>
struct SpiBus {
int id;
bool busy;
};
static SpiBus g_spi[2] = {{1, false}, {2, false}};
struct SpiDeleter {
void operator()(SpiBus* bus) const {
std::printf("tra SPI%d\n", bus->id);
bus->busy = false;
}
};
using SpiLease = std::unique_ptr<SpiBus, SpiDeleter>;
SpiLease lease_spi(int index) {
SpiBus& bus = g_spi[index];
if (bus.busy) return SpiLease{};
bus.busy = true;
return SpiLease{&bus};
}
int main() {
{
auto lease = lease_spi(0);
std::printf("muon SPI%d\n", lease->id);
auto again = lease_spi(0);
std::printf("muon lan 2: %s\n", again ? "duoc" : "tu choi");
} // lease ra khỏi scope -> deleter chạy
auto after = lease_spi(0);
std::printf("sau khi tra: %s\n", after ? "duoc" : "tu choi");
std::printf("sizeof(SpiLease)=%zu sizeof(void*)=%zu\n", sizeof(SpiLease), sizeof(void*));
}
muon SPI1
muon lan 2: tu choi
tra SPI1
sau khi tra: duoc
sizeof(SpiLease)=8 sizeof(void*)=8
tra SPI1
unique_ptr thường bị hiểu là "con trỏ cho new". Thực ra nó là RAII cho mọi thứ có thể trả lại: deleter tuỳ biến chạy khi unique_ptr ra khỏi scope. Ở đây "tài nguyên" là quyền dùng bus SPI, deleter đánh dấu bus rảnh. Không có new/delete nào. Với deleter rỗng như SpiDeleter (không lưu trạng thái), sizeof(SpiLease) thường bằng một con trỏ — chương in ra để bạn kiểm chứng; với con trỏ hàm làm deleter thì lớn hơn.
std::make_unique thì có dùng heap; trong firmware không cho phép heap, hãy dùng ví dụ pool ở mục 2 hoặc unique_ptr + deleter như trên.
4. shared_ptr — vì sao ít dùng
std::shared_ptr cần block đếm tham chiếu (thường cấp phát heap), đếm bằng phép nguyên tử (tốn chu kỳ, có thể cần thư viện atomic trên Cortex-M0), và làm khó việc suy luận ai giải phóng khi nào. Trong nhúng, quyền sở hữu thường rõ ràng (một chủ, cho mượn tạm) → unique_ptr/handle move-only + tham chiếu/span không sở hữu là đủ.
Góc nhúng
- Move của handle chỉ là vài lệnh gán; với
-O2thường biến mất sau inline. Kiểm tra bằng Compiler Explorer. - Truyền handle theo giá trị kèm
std::moveđể thể hiện "chuyển giao"; truyềnconst Handle&/Handle&khi chỉ dùng tạm. - Nếu handle chạm vào ISR (ví dụ ISR trả buffer về pool), bảo vệ trạng thái
used_bằng critical section hoặc cấu trúc lock-free — ví dụ chương này chưa an toàn cho đa luồng/ISR.
Lỗi thường gặp
- Dùng đối tượng sau
std::move(ngoài việc gán lại hoặc huỷ nó). Trạng thái hợp lệ nhưng không xác định về giá trị. - Quên
noexceptcho move → container/variantcó thể chọn sao chép hoặc không dùng move. std::movemột biếnconst— không có move nào xảy ra, âm thầm rơi về copy (hoặc lỗi).- Move assign quên
release()tài nguyên cũ → rò ô pool. - Tạo
unique_ptrbằngnewrồi tưởng là "không heap".
Bài tập
- Thêm
size()vàreset()choHandle; kiểm trareset()trả ô về pool ngay. - Cố tình sao chép một
Handlevà đọc thông báo lỗi biên dịch. - Viết
SpiLeasethứ hai dùng deleter là lambda hoặc con trỏ hàm; so sánhsizeof. - Giải thích vì sao ví dụ
BufferPoolchưa an toàn khi ISR gọirelease(), và phác thảo cách sửa.
Tóm tắt
- Move chuyển quyền sở hữu; kiểu move-only làm lỗi "hai chủ, một tài nguyên" thành lỗi biên dịch.
- RAII + move giải phóng tài nguyên đúng lúc mà không cần heap;
unique_ptrvới custom deleter tổng quát hoá ý này. shared_ptrhiếm khi hợp với nhúng; ưu tiên một chủ rõ ràng.