Linux 驱动开发随笔:给自家温湿度传感器写个驱动模块

前言:不仅仅是 Hello World

在 Linux 世界里,写个 “Hello World” 的内核模块 (Kernel Module) 并不难。
module_init, printk, insmod,三连招一气呵成。
但这种玩具模块除了污染 dmesg 之外,毫无用处。

真正的驱动开发,是要和硬件打交道的。
今天我们就来玩点真实的:给我桌上的 DHT11 温湿度传感器 写一个 Linux 字符设备驱动,让用户态程序能通过 cat /dev/dht11 直接读出当前的温度和湿度。

这涉及到 GPIO 控制、微秒级时序模拟、中断处理以及 Linux 设备模型。

1. 硬件时序分析

DHT11 是个很“古老”的单总线设备。它没有 I2C/SPI 那么标准的协议,全靠一根线的高低电平长短来通信。

通信过程(由主机发起):

  1. 主机拉低至少 18ms (Start Signal)。
  2. 主机拉高 20-40us。
  3. DHT11 拉低 80us (Response)。
  4. DHT11 拉高 80us。
  5. 接下来发送 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> // copy_to_user

#define DHT11_PIN 17 // 假设接在 GPIO 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) {
// 这里是核心逻辑
// 1. 读取传感器数据
// 2. copy_to_user 返回给用户
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;

// 申请 GPIO
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;

// 必须关中断!不然 50us 的延时要是被 CPU 调度走了,数据就读歪了
local_irq_save(flags);

// 1. 发送开始信号
gpio_direction_output(DHT11_PIN, 0);
mdelay(20); // 拉低 20ms
gpio_direction_output(DHT11_PIN, 1); // 拉高
udelay(30);

// 2. 切换到输入模式,准备读
gpio_direction_input(DHT11_PIN);

// 等待 DHT11 响应 (Low 80us -> High 80us)
if (gpio_get_value(DHT11_PIN)) {
local_irq_restore(flags);
return -EIO; // 没响应
}
while (!gpio_get_value(DHT11_PIN)); // 等待低电平结束
while (gpio_get_value(DHT11_PIN)); // 等待高电平结束

// 3. 读 40 bits
for (i = 0; i < 5; i++) {
data[i] = 0;
for (j = 0; j < 8; j++) {
while (!gpio_get_value(DHT11_PIN)); // 等待 50us 低电平过去

udelay(40); // 延时 40us 看看

if (gpio_get_value(DHT11_PIN)) {
// 如果 40us 后还是高,说明是 '1' (因为 '0' 只有 26us 高)
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;
}

// 格式化: "Temp: 25 C, Hum: 60 %\n"
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 永远无法体会的。


Linux 驱动开发随笔:给自家温湿度传感器写个驱动模块
https://www.qixyuan.top/2025/09/10/10-custom-linux-driver/
作者
QixYuan
发布于
2025年9月10日
许可协议