本文最后更新于 2026-01-12T14:51:08+08:00
前言:不仅仅是 Hello World
在 Linux 世界里,写个 “Hello World” 的内核模块 (Kernel Module) 并不难。
module_init, printk, insmod,三连招一气呵成。
但这种玩具模块除了污染 dmesg 之外,毫无用处。
真正的驱动开发,是要和硬件打交道的。
今天我们就来玩点真实的:给我桌上的 DHT11 温湿度传感器 写一个 Linux 字符设备驱动,让用户态程序能通过 cat /dev/dht11 直接读出当前的温度和湿度。
这涉及到 GPIO 控制、微秒级时序模拟、中断处理以及 Linux 设备模型。
1. 硬件时序分析
DHT11 是个很“古老”的单总线设备。它没有 I2C/SPI 那么标准的协议,全靠一根线的高低电平长短来通信。
通信过程(由主机发起):
- 主机拉低至少 18ms (Start Signal)。
- 主机拉高 20-40us。
- DHT11 拉低 80us (Response)。
- DHT11 拉高 80us。
- 接下来发送 40 bit 数据 (5 Bytes):湿度整数、湿度小数、温度整数、温度小数、校验和。
- 每一 bit 都是以 50us 低电平开始。
- 既然是高电平:26-28us 表示 ‘0’,70us 表示 ‘1’。
难点:Linux 是非实时操作系统 (Not RTOS)。你在内核里用 udelay 忙等待时,可能会被中断打断,导致时序偏差。但对于 DHT11 这种慢速设备,关中断忙等 (Spinlock irqsave) 勉强能用。
2. 驱动框架搭架子
我们需要注册一个字符设备 (Character Device) 和一个杂项设备 (Misc Device)。Misc 能够自动分配主设备号,比较省事。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #include <linux/module.h> #include <linux/fs.h> #include <linux/miscdevice.h> #include <linux/gpio.h> #include <linux/delay.h> #include <linux/uaccess.h>
#define DHT11_PIN 17
static int dht11_open(struct inode *inode, struct file *file) { return 0; }
static ssize_t dht11_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { return 0; }
static struct file_operations dht11_fops = { .owner = THIS_MODULE, .open = dht11_open, .read = dht11_read, };
static struct miscdevice dht11_misc = { .minor = MISC_DYNAMIC_MINOR, .name = "dht11", .fops = &dht11_fops, };
static int __init dht11_init(void) { int ret; ret = gpio_request(DHT11_PIN, "dht11"); if (ret) return ret; return misc_register(&dht11_misc); }
static void __exit dht11_exit(void) { misc_deregister(&dht11_misc); gpio_free(DHT11_PIN); }
module_init(dht11_init); module_exit(dht11_exit); MODULE_LICENSE("GPL");
|
3. 核心读取逻辑:在内核里“数脉冲”
在 dht11_read 里,我们需要极其精准地控制 GPIO。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| static int read_dht11_data(unsigned char *data) { int i, j; unsigned long flags; local_irq_save(flags); gpio_direction_output(DHT11_PIN, 0); mdelay(20); gpio_direction_output(DHT11_PIN, 1); udelay(30); gpio_direction_input(DHT11_PIN); if (gpio_get_value(DHT11_PIN)) { local_irq_restore(flags); return -EIO; } while (!gpio_get_value(DHT11_PIN)); while (gpio_get_value(DHT11_PIN)); for (i = 0; i < 5; i++) { data[i] = 0; for (j = 0; j < 8; j++) { while (!gpio_get_value(DHT11_PIN)); udelay(40); if (gpio_get_value(DHT11_PIN)) { data[i] |= (1 << (7 - j)); while (gpio_get_value(DHT11_PIN)); } } } local_irq_restore(flags); if (data[4] != (data[0] + data[1] + data[2] + data[3])) { return -EILSEQ; } return 0; }
|
4. 将结果传回用户空间
内核拿到 data[5] 数组后,不能直接 return 也就是 C 语言数组。我们需要按照字符串格式化一下,然后用 copy_to_user 搬运到用户空间的 buffer。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| static ssize_t dht11_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { unsigned char data[5]; char kbuf[64]; int len; if (read_dht11_data(data) != 0) { return -EIO; } len = sprintf(kbuf, "Temp: %d C, Hum: %d %%\n", data[2], data[0]); if (copy_to_user(buf, kbuf, len)) { return -EFAULT; } return len; }
|
5. 编译与测试
我们需要一个 Makefile,指向内核源码构目录。
1 2 3 4 5 6 7
| obj-m += dht11_driver.o KDIR := /lib/modules/$(shell uname -r)/build
all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean
|
编译完成后:
1 2 3 4 5 6
| $ sudo insmod dht11_driver.ko $ dmesg | tail [ 123.456] dht11: probe success
$ sudo cat /dev/dht11 Temp: 24 C, Hum: 50 %
|
6. 总结:内核态的“特权”与“危险”
写内核模块最爽的地方在于:你拥有了对硬件的绝对控制权。你可以关中断,可以玩死循环,可以读写任意物理地址。
但这也是最危险的。一个 nullptr dereference 就会导致 Kernel Panic,屏幕直接卡死(或者蓝屏)。
所以,谨慎使用 local_irq_save,不要在内核里做繁重的浮点运算(内核一般不支持 FPU),时刻记得检查 copy_to_user 的返回值。
Linux 驱动开发是连接软件与物理世界的桥梁。当你第一次敲下命令,屏幕上显示出真实的物理温度时,那种打通任督二脉的快感,是写 CRUD 永远无法体会的。