Những điều cơ bản về Linux Driver (Phần 2)
Ở phần 2, chúng ta sẽ đi tìm hiểu tiếp về hai nội dung còn lại:
- Khung sườn của 1 driver
- Gỡ lỗi và quản lí các message gỡ lỗi
Khung sườn của 1 driver
Ở dưới là 1 Hello World kernel module đơn giản nhất, chúng ta sẽ đi phân tích nó
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Phu Cuong");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");
/* This function is called when the module is loaded */
static int __init hello_init(void)
{
printk(KERN_INFO "Hello Kernel: module loaded!\n");
return 0; // Return 0 means successful loading
}
/* This function is called when the module is removed */
static void __exit hello_exit(void)
{
printk(KERN_INFO "Hello Kernel: module unloaded!\n");
}
module_init(hello_init);
module_exit(hello_exit);
Module entry and exit point
Tất cả các module trong linux kernel đều có 2 hai hàm: hàm khởi tạo (entry point) và hàm kết thúc (exit point), nghe có vẻ giống 1 class trong C++ nhỉ 😀, cơ chế hoạt động của nó cũng tương tự 1 object của C++ khi được khởi tạo. Khi kernel module được load (insmod hoặc modprobe) hàm khởi tạo của nó được gọi, khi module bị unload (rmmod hoặc modprobe -r) thì hàm exit của nó được gọi.
Khi nói đến entry point, chúng ta sẽ liên tưởng tới hàm main() đây là điểm đầu vào duy nhất trong mỗi chương trình trên userspace được viết bằng C/C++, chương trình sẽ kết thúc khi hàm main() trả về. Tuy nhiên, với kernel module, cơ chế lại hoàn toàn khác. Kernel module không sử dụng main() và tên hàm entry point không bị ràng buộc, bạn có thể đặt tên hàm tuỳ thích. Tương tự như vậy, với exit point kernel sẽ sử dụng 1 hàm khác.
Từ khác biệt trên, dẫn đến việc ở dưới kernel chúng ta phải chỉ định đâu là entry point đâu là exit point. Ở ví dụ trên hai điểm đầu vào và ra là hello_init và hello_exit. Chúng ta sẽ khai báo như sau:
module_init(hello_init); /* Chỉ định hàm hello_init() sẽ được chạy khi module được load */
module_exit(hello_exit); /* Chỉ định hàm hello_exit() sẽ được chạy khi module unload */
__init and __exit attributes
Đọc đến đây các bạn có nhận ra việc hai hàm hello_init() và hello_exit() có gì đặc biệt không? ... Đúng rồi, đó chính là hai hàm này được gán hai tiền tố __init và __exit. Chúng để làm gì? Chúng ta cùng đi tìm hiểu nhé!
Hai cái trên thực chất là các macaro của linux được định nghĩa trong include/linux/init.h
Macaro __init cho phép linker đặt đoạn mã của hàm khởi tạo vào một section riêng biệt trong file đối tượng của kernel. Section này đã được kernel biết trước và sẽ được giải phóng khỏi bộ nhớ ngay sau khi module được nạp và hàm init hoàn thành. Cơ chế này chỉ áp dụng cho driver được biên dịch trực tiếp vào kernel (built-in drivers), không áp dụng cho loadable modules.
Khi driver được tích hợp sẵn vào kernel, hàm init của nó sẽ được kernel gọi một lần duy nhất trong quá trình boot. Vì driver dạng built-in không thể bị gỡ bỏ trong runtime, hàm init của nó cũng sẽ không bao giờ được gọi lại cho đến lần khởi động tiếp theo. Do đó, không cần phải giữ lại mã máy của hàm init trong RAM sau khi boot xong.
Tương tự, macaro __exit được dùng để đánh dấu hàm thoát (exit function). Tuy nhiên, mã của hàm exit sẽ bị bỏ hoàn toàn khi driver được biên dịch tĩnh vào kernel, hoặc khi kernel không hỗ trợ module unloading, vì trong cả hai trường hợp, hàm exit không bao giờ được gọi. Do đó, macaro __exit không có tác dụng đối với loadable modules (vì mã hàm exit luôn phải tồn tại để xử lý rmmod).
Error Handling và In-Kernel Error Codes trong Linux Device Driver
Việc xử lý lỗi (error handling) là một phần cực kỳ quan trọng trong phát triển phần mềm, và trong lập trình driver Linux thì điều này còn quan trọng hơn. Trả về sai mã lỗi có thể khiến kernel hoặc ứng dụng user-space đưa ra hành vi sai, dẫn đến crash hoặc kết quả không mong muốn.
Trong ở phần này, chúng ta sẽ tìm hiểu cách kernel định nghĩa lỗi, cách trả về lỗi đúng chuẩn, và cách sử dụng goto để xử lý lỗi hiệu quả.
Error Codes trong Linux Kernel
Trong Linux, các error code được dùng để biểu thị các tình huống bất thường. Khi lỗi xảy ra ở kernel, giá trị lỗi thường được trả về dưới dạng -<ERRORCODE>.
Một số mã lỗi phổ biến được định nghĩa trong:
- include/uapi/asm-generic/errno-base.h
- include/uapi/asm-generic/errno.h
Ví dụ danh sách rút gọn:
#define EPERM 1 /* Operation not permitted */
#define ENOENT 2 /* No such file or directory */
#define ESRCH 3 /* No such process */
#define EINTR 4 /* Interrupted system call */
#define EIO 5 /* I/O error */
#define ENXIO 6 /* No such device or address */
#define ENOEXEC 8 /* Exec format error */
#define EAGAIN 11 /* Try again */
#define ENOMEM 12 /* Out of memory */
#define EACCES 13 /* Permission denied */
#define EBUSY 16 /* Device or resource busy */
#define ENODEV 19 /* No such device */
#define EINVAL 22 /* Invalid argument */
#define ENOSPC 28 /* No space left on device */
#define EPIPE 32 /* Broken pipe */
#define ERANGE 34 /* Math result not representable */
Trả về mã lỗi trong driver
Cách chuẩn để trả về lỗi trong kernel là:
return -EIO;
Ví dụ:
dev = init(&ptr);
if (!dev)
return -EIO;
Lỗi lan trả sang user-space như thế nào
Nếu lỗi xảy ra khi thực hiện system call (như open, read, ioctl, mmap), thì giá trị âm sẽ được kernel tự chuyển thành errno trong user-space.
User-space có thể in lỗi bằng:
#include <errno.h>
#include <string.h>
if (write(fd, buf, 1) < 0) {
printf("Something went wrong! %s\n", strerror(errno));
}
strerror(errno) sẽ dịch error code thành chuỗi dạng dễ hiểu. Ví dụ như No such device or directory
Vì sao nên dùng goto để xử lý lỗi?
Nguyên tắc: Khi một hàm có nhiều bước khởi tạo (init), và lỗi xảy ra ở bước giữa, bạn phải giải phóng tài nguyên được cấp phát ở các bước trước đó.
Không dùng goto, code sẽ lồng nhau như thế này:
if (ops1() != ERR) {
if (ops2() != ERR) {
if (ops3() != ERR) {
if (ops4() != ERR) {
...
Điều này dẫn đến:
- Khó đọc
- Dễ sai
- Dễ sinh lỗi indent
Vì vậy kernel khuyến khích dùng goto để giải phóng tài nguyên theo thứ tự ngược: Cách viết chuẩn trong linux kernel sẽ là:
ptr = kmalloc(sizeof(device_t));
if (!ptr) {
ret = -ENOMEM;
goto err_alloc;
}
dev = init(&ptr);
if (!dev) {
ret = -EIO;
goto err_init;
}
return 0;
err_init:
kfree(ptr);
err_alloc:
return ret;
Cấu trúc tổng quát:
if (ops1() == ERR)
goto error1;
if (ops2() == ERR)
goto error2;
if (ops3() == ERR)
goto error3;
if (ops4() == ERR)
goto error4;
error4:
...
error3:
...
error2:
...
error1:
...
Như bạn có thể thấy các nhãn goto được define với index ngược lại từ lớn đến bé, trái với lúc chúng được return trong block code, điều này đảm bảo chỉ được dùng để nhảy tới phía trước trong cùng một hàm, không được nhảy ngược lên. Từ đó đạt được mục đích khi có lỗi sẽ giải phóng tài nguyên ở các bước trước đó.
Xử lý lỗi Null Pointer trong Linux Kernel: ERR_PTR, IS_ERR và PTR_ERR
Trong lập trình driver Linux, nhiều hàm được thiết kế để trả về con trỏ (pointer). Khi xảy ra lỗi, cách đơn giản nhất là trả về NULL. Tuy nhiên, điều này không cho biết lý do tại sao lỗi xảy ra — hoàn toàn không rõ ràng.
Để giải quyết vấn đề này, Linux kernel cung cấp bộ ba hàm chuyên dụng:
void *ERR_PTR(long error);
long IS_ERR(const void *ptr);
long PTR_ERR(const void *ptr);
Bộ API này cho phép gói lỗi vào pointer, kiểm tra lỗi và trích xuất mã lỗi — tất cả đều an toàn và thống nhất theo chuẩn kernel.
ERR_PTR(): Gói error code thành pointer
Thay vì trả về NULL, driver có thể trả về lỗi dạng pointer "đặc biệt":
return ERR_PTR(-ENOMEM);
Điều này giúp phân biệt giữa:
- NULL hợp lệ (nếu hàm có thể trả về NULL thật)
- NULL báo lỗi (không phân biệt được)
- ERR_PTR(error) (có mã lỗi rõ ràng)
IS_ERR(): Kiểm tra pointer có phải lỗi hay không
Dùng để kiểm tra giá trị trả về có phải là một pointer lỗi:
if (IS_ERR(dev))
return PTR_ERR(dev);
PTR_ERR(): Lấy lại mã lỗi thực
Khi đã biết pointer là lỗi, ta cần lấy error code ra:
return PTR_ERR(dev);
Ví dụ đầy đủ
static struct iio_dev *indiodev_setup() {
[...]
struct iio_dev *indio_dev;
indio_dev = devm_iio_device_alloc(&data->client->dev, sizeof(data));
if (!indio_dev)
return ERR_PTR(-ENOMEM);
[...]
return indio_dev;
}
static int foo_probe([...]) {
[...]
struct iio_dev *my_indio_dev = indiodev_setup();
if (IS_ERR(my_indio_dev))
return PTR_ERR(my_indio_dev);
[...]
}
Lợi ích:
- Dễ debug hơn: biết chính xác mã lỗi
- Không nhầm lẫn giữa lỗi và con trỏ NULL hợp lệ
- Thống nhất theo coding style của kernel
Tổng kết lại
Xử lý lỗi bằng NULL pointer là không đủ trong Linux kernel. Combo ERR_PTR(), IS_ERR(), PTR_ERR():
- cung cấp cách trả lỗi rõ ràng
- an toàn khi sử dụng cùng con trỏ
- tránh nhầm lẫn NULL hợp lệ
- phù hợp với coding style chuẩn của kernel
Nhờ đó, driver sẽ sạch hơn, dễ debug hơn và tuân thủ tiêu chuẩn kernel upstream
Coding Style của Kernel liên quan đến Return Value
Linux kernel có quy tắc đặc biệt cho giá trị trả về của hàm dựa trên tên hàm.
Nếu tên hàm là mệnh lệnh (command/action), hàm nên trả về mã lỗi dạng integer. Ví dụ, hàm add_work() là một hành động; nó trả về 0 nếu thực hiện thành công và -EBUSY nếu thất bại.
Ngược lại, nếu tên hàm là predicate (câu hỏi hoặc kiểm tra điều kiện), hàm nên trả về giá trị boolean. Ví dụ, hàm pci_dev_present() kiểm tra sự hiện diện của thiết bị, trả về 1 nếu tìm thấy thiết bị phù hợp và 0 nếu không.
Quy tắc này giúp code dễ đọc hơn, đoán được kiểu giá trị trả về chỉ qua tên hàm, và duy trì chuẩn thống nhất trong kernel.
All rights reserved