三類裝置#

Linux 把裝置分三類:

類型範例介面特色
字元裝置/dev/null/dev/tty、感測器串流式(依序 read/write),無位移概念
區塊裝置/dev/sda/dev/nvme0n1依固定大小區塊存取,可隨機 seek,有 buffer cache
網路裝置eth0wlan0lo不在 /dev/ 裡,靠 socket API 存取

字元裝置是最簡單的一類,也是寫驅動最常見的起點。本章只談字元裝置。

從 userspace 看裝置#

ls -l /dev/null /dev/random /dev/sda
# crw-rw-rw- 1 root root 1, 3 Apr 29 10:00 /dev/null
# crw-rw-rw- 1 root root 1, 8 Apr 29 10:00 /dev/random
# brw-rw---- 1 root disk 8, 0 Apr 29 10:00 /dev/sda

開頭的 c 表示字元裝置,b 表示區塊裝置。後面的兩個數字是 majorminor number:

  • major:哪個驅動程式
  • minor:同一個驅動下的哪個實例
dev_t devno = MKDEV(major, minor);   // 編碼
unsigned int M = MAJOR(devno);
unsigned int m = MINOR(devno);

完整流程#

寫一個能 cat /dev/myxxx 看到內容的字元裝置,需要四步:

  1. 配置一個 dev_t(major + minor 範圍)
  2. 初始化 struct cdev 並關聯 file_operations
  3. cdev_add 把它註冊進核心
  4. (手動建立 /dev/... 節點,或用 device_create 讓 udev 自動建)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>

#define DEVICE_NAME  "myxxx"
#define BUF_SIZE     1024

static dev_t        dev_no;
static struct cdev  my_cdev;
static struct class *my_class;
static char        *buffer;
static size_t       buffer_len;

static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("myxxx: open\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    pr_info("myxxx: release\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *off)
{
    size_t avail;
    if (*off >= buffer_len)
        return 0;                       // EOF
    avail = min(count, buffer_len - (size_t)*off);
    if (copy_to_user(buf, buffer + *off, avail))
        return -EFAULT;
    *off += avail;
    return avail;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *off)
{
    size_t to_copy = min(count, (size_t)BUF_SIZE);
    if (copy_from_user(buffer, buf, to_copy))
        return -EFAULT;
    buffer_len = to_copy;
    return to_copy;
}

static const struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

static int __init myxxx_init(void)
{
    int err;

    buffer = kzalloc(BUF_SIZE, GFP_KERNEL);
    if (!buffer) return -ENOMEM;

    err = alloc_chrdev_region(&dev_no, 0, 1, DEVICE_NAME);
    if (err) goto free_buf;

    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    err = cdev_add(&my_cdev, dev_no, 1);
    if (err) goto unreg;

    my_class = class_create(DEVICE_NAME);     // 6.4+ 簽名
    if (IS_ERR(my_class)) { err = PTR_ERR(my_class); goto del_cdev; }

    device_create(my_class, NULL, dev_no, NULL, DEVICE_NAME);
    pr_info("myxxx: registered (major=%d)\n", MAJOR(dev_no));
    return 0;

del_cdev:
    cdev_del(&my_cdev);
unreg:
    unregister_chrdev_region(dev_no, 1);
free_buf:
    kfree(buffer);
    return err;
}

static void __exit myxxx_exit(void)
{
    device_destroy(my_class, dev_no);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_no, 1);
    kfree(buffer);
    pr_info("myxxx: unregistered\n");
}

module_init(myxxx_init);
module_exit(myxxx_exit);
MODULE_LICENSE("GPL");

載入後:

sudo insmod myxxx.ko
ls -l /dev/myxxx                            # 由 udev 自動建立
echo "hello world" > /dev/myxxx
cat /dev/myxxx                              # hello world
sudo rmmod myxxx

file_operations ── 介面就是這個結構#

整個字元裝置 API 都圍繞一個 vtable:

struct file_operations {
    struct module *owner;
    loff_t  (*llseek)   (struct file *, loff_t, int);
    ssize_t (*read)     (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)    (struct file *, const char __user *, size_t, loff_t *);
    __poll_t (*poll)    (struct file *, struct poll_table_struct *);
    long    (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    int     (*mmap)     (struct file *, struct vm_area_struct *);
    int     (*open)     (struct inode *, struct file *);
    int     (*release)  (struct inode *, struct file *);
    int     (*fsync)    (struct file *, loff_t, loff_t, int datasync);
    int     (*fasync)   (int, struct file *, int);
    // ...
};

每個函式都對應一個 syscall:

syscallfop 欄位
open(2).open
close(2).release
read(2).read
write(2).write
lseek(2).llseek
poll/select/epoll.poll
ioctl(2).unlocked_ioctl
mmap(2).mmap

不需要實作的填 NULL,VFS 會用合理預設或回傳 -ENOTTY/-EINVAL

使用者空間 vs 核心空間:copy_to/from_user#

永遠不要直接 memcpy(kernel_ptr, user_ptr, n)。原因:

  1. user pointer 可能指向沒映射的位址(page fault)
  2. user pointer 可能被惡意指向核心位址(安全漏洞)
  3. 使用者頁可能被換出(swap),需要核心介入觸發 page-in
if (copy_to_user(user_buf, kernel_buf, n))
    return -EFAULT;

if (copy_from_user(kernel_buf, user_buf, n))
    return -EFAULT;

這對函式回傳「沒搬完的 byte 數」,0 表示全部成功。它們會:

  • 檢查 user pointer 在合法 user 範圍
  • 處理 page fault
  • 失敗時回傳剩餘量,由你決定怎麼處理

__user 是個 sparse 註解 ── 提醒人類「這是 user-space 指標,不能直接解參考」。一般編譯不檢查,但跑 make C=1 時 sparse 會抓出違規。

misc 裝置 ── 偷懶的字元裝置#

如果你不需要自己分配 major number,用 misc 機制更簡單:

#include <linux/miscdevice.h>

static struct miscdevice my_misc = {
    .minor = MISC_DYNAMIC_MINOR,    // 動態配 minor
    .name  = "myxxx",                // 會出現在 /dev/myxxx
    .fops  = &my_fops,
};

static int __init myxxx_init(void) {
    return misc_register(&my_misc);
}
static void __exit myxxx_exit(void) {
    misc_deregister(&my_misc);
}

整個 cdev_initcdev_addclass_createdevice_create 一條 misc_register 全包。所有 misc 裝置共用 major 10,minor 動態配。/dev/null/dev/random 等都不是 misc,但很多現代驅動(如 /dev/kvm/dev/loop-control/dev/fuse)是。

阻塞 I/O 與 wait queue#

如果讀取時還沒資料,UNIX 慣例是阻塞直到有資料。靠 wait queue 實現:

static DECLARE_WAIT_QUEUE_HEAD(read_wq);
static int data_ready = 0;

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *off)
{
    int ret;

    if (!data_ready) {
        if (filp->f_flags & O_NONBLOCK)
            return -EAGAIN;            // 非阻塞模式:立刻回

        ret = wait_event_interruptible(read_wq, data_ready);
        if (ret)
            return -ERESTARTSYS;       // 被訊號打斷
    }

    // 拷貝資料到 user...
    data_ready = 0;
    return n;
}

// 寫入端(例如中斷處理)通知讀者:
data_ready = 1;
wake_up_interruptible(&read_wq);

wait_event_interruptible 三個關鍵:

  1. 進入 sleep state 之前先檢查條件(避免 lost wakeup)
  2. 可被訊號中斷(重要!否則 kill 也殺不掉)
  3. 醒來後重新檢查條件(spurious wakeup 是真的存在)

對比有兩種變體:

  • wait_event(wq, cond):不可被訊號中斷(極少用,會讓行程變成 D 狀態無法 kill)
  • wait_event_timeout(wq, cond, jiffies):超時版本

poll/select/epoll 支援#

userspace 想用 poll() 多工 ── 你必須實作 .poll

#include <linux/poll.h>

static __poll_t my_poll(struct file *filp, struct poll_table_struct *wait)
{
    __poll_t mask = 0;

    poll_wait(filp, &read_wq, wait);   // 把當前等待者加到 wq

    if (data_ready)
        mask |= EPOLLIN | EPOLLRDNORM; // 可讀
    if (write_space_available)
        mask |= EPOLLOUT | EPOLLWRNORM;// 可寫

    return mask;
}

poll_wait 不會真的睡眠 ── 它只是「在 poll 表登記這個 wq」,由 poll/epoll 框架統一管理。當 wake_up_interruptible(&read_wq) 被呼叫,所有正在 poll/select/epoll_wait 等這個 fd 的 task 都會被喚醒。

ioctl ── 「其他什麼都用這個」#

read/write 是串流,但有時候你要下命令:「重置裝置」、「設定取樣率」、「查詢狀態」── 這時候用 ioctl

#include <linux/ioctl.h>

#define MY_MAGIC 'M'
#define MY_RESET   _IO(MY_MAGIC, 0)
#define MY_SET_RATE _IOW(MY_MAGIC, 1, int)
#define MY_GET_RATE _IOR(MY_MAGIC, 2, int)

static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    switch (cmd) {
    case MY_RESET:
        do_reset();
        return 0;
    case MY_SET_RATE: {
        int rate;
        if (copy_from_user(&rate, (int __user *)arg, sizeof(rate)))
            return -EFAULT;
        set_rate(rate);
        return 0;
    }
    case MY_GET_RATE: {
        int rate = current_rate();
        if (copy_to_user((int __user *)arg, &rate, sizeof(rate)))
            return -EFAULT;
        return 0;
    }
    default:
        return -ENOTTY;        // 慣例:未知 cmd 回 ENOTTY
    }
}

_IO/_IOR/_IOW/_IOWR 巨集會把 magic、方向、大小編碼進 cmd 整數。這樣核心可以驗證型別、防止傳錯命令給錯的驅動。

ioctl 是 Linux 接口設計上常被詬病的部分 ── 它違反了「介面要小」的 UNIX 哲學。新驅動應優先考慮其他機制:sysfs(給簡單參數)、netlink(給大流量配置)、eBPF(給可程式化)。但對既有硬體,ioctl 仍然是事實標準。

非同步通知(SIGIO)#

讓 userspace 收到 SIGIO 訊號當資料可用:

static int my_fasync(int fd, struct file *filp, int mode)
{
    return fasync_helper(fd, filp, mode, &async_queue);
}

// fops:
.fasync = my_fasync,

// 資料到來時:
kill_fasync(&async_queue, SIGIO, POLL_IN);

userspace 要先 fcntl(fd, F_SETOWN, getpid()) + fcntl(fd, F_SETFL, O_ASYNC) 才會收到訊號。實務上 epoll 幾乎完全取代了 SIGIO ── 訊號 + 非同步是兩個都不好處理的東西。

偵錯小技巧#

dmesg -w                                  # 即時 tail 核心日誌
sudo cat /proc/devices                    # 已註冊的 character devices
sudo cat /proc/misc                       # 已註冊的 misc devices
sudo strace -e read,write,ioctl ./prog    # 看 syscall 流向

如果 cat /dev/myxxx 卡住沒反應 ── 通常是阻塞讀沒人 wake_up。檢查中斷處理或 timer 有沒有正確觸發 wake_up_interruptible

小結#

字元裝置驅動的整個世界就是:

  1. 註冊一個 major/minor + file_operations
  2. 實作對應的 syscall handler
  3. copy_to/from_user 跨越 user/kernel 邊界
  4. 用 wait queue 處理阻塞,用 .poll 支援 epoll
  5. 用 ioctl 處理「其他什麼」(盡量少用)

下一章看 syscall 機制本身,理解「read(fd, buf, n) 是怎麼從 user code 一路走到 my_read」的全鏈路。