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_ptr với custom deleter cho tài nguyên không nằm trên heap; biết vì sao shared_ptr hiế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à = delete copy (Rule of Five). Đánh dấu move noexcept.

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&) = delete cấm sao chép: không thể có hai handle cùng một ô.
  • Move ctor lấy pool_/idx_ rồi đặt o.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ước a = std::move(a).
  • consume(std::move(b)): quyền sở hữu chuyển vào tham số h; khi consume kết thúc, ~Handle trả ô về pool — không cần gọi hàm giải phóng thủ công. Sau đó b rỗng (ví dụ in ra "khong").
  • Toàn bộ bộ nhớ là std::array tĩ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 -O2 thườ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ền const 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 noexcept cho move → container/variant có thể chọn sao chép hoặc không dùng move.
  • std::move một biến const — 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_ptr bằng new rồi tưởng là "không heap".

Bài tập

  1. Thêm size() và reset() cho Handle; kiểm tra reset() trả ô về pool ngay.
  2. Cố tình sao chép một Handle và đọc thông báo lỗi biên dịch.
  3. Viết SpiLease thứ hai dùng deleter là lambda hoặc con trỏ hàm; so sánh sizeof.
  4. Giải thích vì sao ví dụ BufferPool chưa an toàn khi ISR gọi release(), 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_ptr với custom deleter tổng quát hoá ý này.
  • shared_ptr hiếm khi hợp với nhúng; ưu tiên một chủ rõ ràng.