Skip to content

Design Pattern kinh điển trong Linux Kernel (1/3): OOP và vòng đời object

Design Patterns 1/3

Linux kernel viết bằng C, không có class, template hay destructor, nhưng lại dùng rất có hệ thống các design pattern hướng đối tượng và concurrency. Phần lớn code driver hay subsystem thực chất chỉ là tổ hợp của một nhóm nhỏ các pattern này. Nhận ra chúng thì đọc một driver lạ sẽ nhanh hơn nhiều, và review code cũng có tiêu chí rõ ràng hơn.

Đây là phần 1 trong loạt 3 phần.

Mở đầu

Mỗi pattern được trình bày theo bốn ý: vấn đề cần giải quyết, cách kernel làm, ví dụ code, và lỗi hay gặp. Code được rút gọn cho dễ đọc, nhưng tên struct và hàm giữ nguyên như mainline (kernel 6.x) để các bạn tra trực tiếp trên Elixir Bootlin.

Bài viết dài nên được chia thành ba phần:

  1. Phần 1: OOP trong C, vòng đời và tài nguyên (mục 1-2): ops table, container_of, kobject, reference counting, goto cleanup, ERR_PTR, devres, slab cache.
  2. Phần 2: dữ liệu intrusive và concurrency (mục 3-4): list_head, hlist, rbtree, RCU, per-CPU, seqlock, deferred work, static key.
  3. Phần 3: kiến trúc và checklist review (mục 5-7): registration framework, notifier chain, initcall, library vs midlayer, bảng đối chiếu GoF/C++/DPDK, checklist và bài tập.

1. OOP trong C

Kernel giả lập ba trụ cột của OOP bằng C thuần: đa hình bằng bảng con trỏ hàm, kế thừa bằng struct nhúng, và quản lý vòng đời object bằng kobject.

1.1 Ops table — đa hình bằng con trỏ hàm

Vấn đề. VFS phải gọi read() trên ext4, procfs hay một char device mà không cần biết loại cụ thể là gì.

Cách làm. Mỗi "class" định nghĩa một struct chứa các con trỏ hàm. Mỗi "implementation" điền một instance const của struct đó, và object giữ con trỏ tới bảng.

struct file_operations {
    struct module *owner;
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    /* ... */
};

static const struct file_operations mydev_fops = {
    .owner   = THIS_MODULE,
    .open    = mydev_open,
    .read    = mydev_read,
    .release = mydev_release,
};

/* Phía VFS, rút gọn: */
ret = file->f_op->read(file, buf, count, pos);

ops table · VFS → struct file → bảng const

Hình 1. ops table · VFS → struct file → bảng const

Thêm một loại file mới chỉ cần thêm một bảng ops mới; vfs_read() không phải sửa dòng nào.

Cần nhớ.

  • Bảng ops luôn là const, nằm trong .rodata. Attacker không ghi đè được con trỏ hàm, đây là một biện pháp hardening.
  • Callback bằng NULL nghiĩa là "không hỗ trợ" hoặc "dùng mặc định". Caller có trách nhiệm kiểm tra.
  • Gặp lại ở khắp nơi: net_device_ops, dma_map_ops, ethtool_ops, irq_chip.

1.2 Embedded struct + container_of — kế thừa

Vấn đề. Subsystem chỉ biết object "base" (struct cdev, struct device), còn driver cần lấy lại object "derived" của mình.

Cách làm. Nhúng struct base vào struct của driver. Khi nhận lại con trỏ base, trừ đi offset của member để ra địa chỉ struct chứa nó.

/* Bản rút gọn; bản thật có thêm static_assert kiểm tra kiểu */
#define container_of(ptr, type, member) \
    ((type *)((void *)(ptr) - offsetof(type, member)))

struct mydev {
    void __iomem *regs;
    spinlock_t    lock;
    struct cdev   cdev;      /* "base class" */
};

static int mydev_open(struct inode *inode, struct file *filp)
{
    struct mydev *dev = container_of(inode->i_cdev, struct mydev, cdev);

    filp->private_data = dev;
    return 0;
}

container_of · từ con trỏ member về đầu struct

Hình 2. container_of · từ con trỏ member về đầu struct

Không có tra cứu hay cấp phát nào: container_of chỉ là một phép trừ với hằng số biết trước lúc compile.

Cần nhớ.

  • Member base không cần đứng đầu struct, khác với cách ép kiểu trực tiếp.
  • Một struct có thể nhúng nhiều base cùng lúc (một list_head, một kref, một work_struct), tức là "đa kế thừa" an toàn.
  • Lỗi nguy hiểm nhất: gọi container_of trên một con trỏ không nằm trong struct đó. Compiler không bắt được, kết quả là memory corruption âm thầm.

1.3 kobject và device model — cây object có vòng đời

Vấn đề. Hàng nghìn device, driver, bus cần được tổ chức thành cây, hiển thị qua sysfs, và chỉ bị giải phóng khi không còn ai tham chiếu.

Cách làm. struct kobject gói sẵn tên, con trỏ parent, một kref và một kobj_type chứa hàm release(). struct device nhúng kobject, và driver core dựng tiếp các khái niệm bus_type, device_driver, class lên trên.

struct kobject {
    const char             *name;
    struct kobject         *parent;   /* tạo cây /sys */
    struct kset            *kset;
    const struct kobj_type *ktype;    /* chứa release() */
    struct kref             kref;
    /* ... */
};

Cần nhớ.

  • Mọi object có kobject phải được giải phóng trong release(), không bao giờ kfree() trực tiếp. Userspace có thể đang giữ file trong sysfs khi driver unload.
  • Driver hiếm khi dùng kobject trực tiếp. Hãy làm việc với struct device và các API get_device()/put_device().

2. Vòng đời và tài nguyên

Không có destructor hay garbage collector, nên kernel dựa vào quy ước chặt chẽ để biết khi nào được giải phóng tài nguyên và giải phóng theo thứ tự nào. Đây là nhóm gây ra nhiều bug nhất trong driver: leak, use-after-free và double free.

2.1 Reference counting — kref, refcount_t

Vấn đề. Một object được nhiều nơi cùng giữ (file handle, timer, list). Không ai biết mình có phải là người cuối cùng hay không.

Cách làm. Nhúng một bộ đếm, cặp hàm get/put, và một release() chạy khi đếm về 0.

struct mydev {
    struct kref ref;
    /* ... */
};

static void mydev_release(struct kref *ref)
{
    struct mydev *dev = container_of(ref, struct mydev, ref);

    kfree(dev);
}

static inline void mydev_get(struct mydev *d) { kref_get(&d->ref); }
static inline void mydev_put(struct mydev *d) { kref_put(&d->ref, mydev_release); }

Quy tắc vàng. Cất con trỏ vào đâu đó thì get, bỏ con trỏ đi thì put. Khi lookup một object trong list dưới lock, phải get trước khi nhả lock. Nếu lookup dưới RCU, dùng kref_get_unless_zero() vì object có thể đang chết.

Cần nhớ. Dùng refcount_t thay cho atomic_t khi làm bộ đếm: nó bão hòa và cảnh báo khi overflow/underflow thay vì quay vòng về 0. Bạn sẽ gặp cùng pattern dưới nhiều tên: get_device/put_device, dget/dput, fget/fput, skb_get/kfree_skb.

2.2 Goto cleanup ladder

Vấn đề. probe() xin năm, sáu tài nguyên liên tiếp. Bước thứ tư lỗi thì phải trả đúng ba thứ đã xin, theo thứ tự ngược.

Cách làm. Mỗi bước lỗi nhảy tới một label. Các label xếp theo thứ tự ngược với lúc xin, và code "rơi" xuống qua từng label.

static int mydev_probe(struct platform_device *pdev)
{
    int ret;

    ret = clk_prepare_enable(priv->clk);
    if (ret)
        return ret;

    ret = request_irq(irq, mydev_isr, 0, "mydev", priv);
    if (ret)
        goto err_clk;

    ret = misc_register(&priv->misc);
    if (ret)
        goto err_irq;

    return 0;

err_irq:
    free_irq(irq, priv);
err_clk:
    clk_disable_unprepare(priv->clk);
    return ret;
}

Cần nhớ.

  • Đặt tên label theo việc nó gỡ bỏ (err_irq), không theo số thứ tự (err2). Coding style của kernel khuyến nghị rõ điều này.
  • remove() phải là bản sao đúng thứ tự của phần cleanup trong probe().
  • Từ 6.5, kernel có <linux/cleanup.h> với __free() và guard(mutex)(&lock), cho phép tự động giải phóng khi ra khỏi scope kiểu RAII. Code mới đang dần dùng cách này, nhưng goto ladder vẫn chiếm đa số.

2.3 ERR_PTR — mã lỗi nằm trong con trỏ

Vấn đề. Hàm trả về con trỏ nhưng cũng cần trả được lý do thất bại, không chỉ mỗi NULL.

Cách làm. 4095 địa chỉ cuối cùng của không gian địa chỉ không bao giờ là con trỏ hợp lệ. Kernel dùng chúng để mã hóa -errno.

priv->clk = devm_clk_get(&pdev->dev, "core");
if (IS_ERR(priv->clk))
    return dev_err_probe(&pdev->dev, PTR_ERR(priv->clk),
                         "failed to get core clock\n");

Cần nhớ. Luôn đọc kỹ API trả về NULL hay ERR_PTR. Kiểm tra nhầm loại là lỗi rất phổ biến và thường chỉ lộ ra khi phần cứng lỗi. dev_err_probe() xử lý luôn trường hợp -EPROBE_DEFER mà không spam log.

2.4 devres (devm_*) — tài nguyên gắn với device

Vấn đề. Goto ladder dài và dễ lệch giữa probe() với remove().

Cách làm. Mỗi tài nguyên xin qua devm_* được ghi vào một danh sách gắn với struct device. Khi probe lỗi hoặc sau khi driver unbind, driver core tự giải phóng chúng theo thứ tự ngược.

priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
if (!priv)
    return -ENOMEM;

priv->regs = devm_platform_ioremap_resource(pdev, 0);
if (IS_ERR(priv->regs))
    return PTR_ERR(priv->regs);

ret = devm_request_irq(&pdev->dev, irq, mydev_isr, 0, "mydev", priv);
if (ret)
    return ret;
/* Không cần label nào */

Cần nhớ.

  • Tài nguyên devm_ được giải phóng sau khi remove() chạy xong. Với devm_request_irq, ISR vẫn có thể chạy trong lúc remove() đang gỡ state. Hãy tắt ngắt ở phần cứng trước.
  • Bộ nhớ có thể còn được truy cập sau khi unbind (ví dụ userspace còn giữ file descriptor của char device) thì không được dùng devm_kzalloc. Phải dùng refcount như mục 2.1.
  • Tránh trộn devm_ và giải phóng thủ công cho cùng một loại tài nguyên, vì thứ tự sẽ khó đoán.

2.5 kmem_cache — object pool

Vấn đề. Một loại object kích thước cố định bị cấp phát và giải phóng liên tục trên fast path.

Cách làm. Tạo một cache riêng. Slab allocator giữ sẵn các object đã align, có freelist theo từng CPU.

static struct kmem_cache *req_cache;

req_cache = kmem_cache_create("mydev_req", sizeof(struct mydev_req),
                              0, SLAB_HWCACHE_ALIGN, NULL);

req = kmem_cache_alloc(req_cache, GFP_ATOMIC);
/* ... */
kmem_cache_free(req_cache, req);

Cần nhớ. Cache có tên riêng hiện trong /proc/slabinfo, nên dễ truy leak hơn kmalloc. Hiện mainline chỉ còn SLUB, vì SLOB và SLAB đã bị gỡ bỏ. Pattern này tương đương rte_mempool bên DPDK.


Phần sau: Phần 2 · Mục lục cả loạt