Linux 驱动实战:SR501 人体红外传感器驱动开发与调试全记录

摘要:本文详细记录了在 IMX6ULL 开发板上,将一份 GPIO 按键输入驱动改造成 SR501 人体红外传感器驱动的完整过程。内容涵盖硬件连接、驱动代码逐行讲解、关键函数剖析、中断配置、常见错误排查(如 GPIO 占用、模块版本不匹配、设备节点未创建等)以及最终成功运行的测试结果。文中提供了完整可编译的驱动代码与测试程序,并配有流程图和知识巩固问答,适合嵌入式 Linux 驱动初学者参考。


目录

1. 项目背景与目标

在嵌入式 Linux 系统中,GPIO 是最基础的外设之一。SR501 是一款常用的人体红外传感器,当检测到人体移动时,其 OUT 引脚会输出高电平,无人时恢复低电平。本项目旨在编写一个字符设备驱动,实现以下功能:

  • 将 SR501 的 OUT 引脚连接到 IMX6ULL 的 GPIO4_19(编号 115)。
  • 驱动能够捕获引脚电平变化(上升沿和下降沿),并通过中断方式通知应用程序。
  • 提供标准的文件操作接口:readpollfasync
  • 自动创建设备节点 /dev/sr501
  • 编写测试程序,实时打印传感器状态(有人/无人)。

通过本项目,你将掌握:

  • GPIO 的申请、方向设置、电平读取
  • GPIO 中断的注册与处理
  • 环形缓冲区、等待队列、异步通知等内核机制
  • 字符设备驱动的完整框架
  • 常见编译、加载错误的排查方法

2. 硬件连接与 GPIO 编号计算

在这里插入图片描述

根据开发板接口图,SR501 与 IMX6ULL 的连接如下:

SR501 引脚 开发板引脚 说明
VCC 5V 供电正极
GND GND 供电负极
OUT GPIO4_19 信号输出

GPIO 编号计算公式
对于 i.MX6ULL,GPIO 编号 = 组号 × 32 + 组内偏移。

  • GPIO4 对应组号 3(因为从 0 开始计数:GPIO1=0,GPIO2=1,GPIO3=2,GPIO4=3)
  • GPIO4_19 的编号 = 3 × 32 + 19 = 115

因此,驱动代码中使用 GPIO 编号 115

注意:该引脚在设备树中默认可能被复用为 CSI 摄像头接口功能,需要确保硬件连接无误,并在驱动中正确申请 GPIO 资源。


3. 驱动代码逐行详解

3.1 头文件与全局结构

c

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/gpio.h>
#include <linux/interrupt.h>
#include <linux/irq.h>
#include <linux/wait.h>
#include <linux/poll.h>
#include <linux/slab.h>
#include <linux/fcntl.h>
#include <linux/uaccess.h>

头文件作用

  • module.h:内核模块必需
  • fs.h:文件操作结构体
  • gpio.h:GPIO API(申请、设置方向、读写)
  • interrupt.hirq.h:中断注册
  • wait.hpoll.h:等待队列与 poll 机制
  • uaccess.hcopy_to_user / copy_from_user

自定义结构体

c

struct gpio_desc {
    int gpio;       // GPIO 编号
    int irq;        // 中断号
    char *name;     // 标签名称
    int key;        // 传感器编号(本例未使用)
    struct timer_list key_timer; // 定时器(本例未使用)
};

static struct gpio_desc gpios[1] = {
    {115, 0, "sr501", },
};

虽然结构体包含了 keykey_timer 字段,但这是从按键驱动改造过程中保留的,SR501 驱动并未使用它们。保留是为了代码修改的连续性,实际可精简。

3.2 环形缓冲区与等待队列

c

#define BUF_LEN 128
static int g_keys[BUF_LEN];
static int r, w;

#define NEXT_POS(x) ((x+1) % BUF_LEN)

static int is_key_buf_empty(void) { return (r == w); }
static int is_key_buf_full(void) { return (r == NEXT_POS(w)); }

static void put_key(int key) {
    if (!is_key_buf_full()) {
        g_keys[w] = key;
        w = NEXT_POS(w);
    }
}

static int get_key(void) {
    int key = 0;
    if (!is_key_buf_empty()) {
        key = g_keys[r];
        r = NEXT_POS(r);
    }
    return key;
}

static DECLARE_WAIT_QUEUE_HEAD(gpio_wait);
struct fasync_struct *button_fasync;

环形缓冲区:用于缓存传感器事件,防止应用程序来不及读取时丢失数据。put_key 由中断处理函数调用,将打包的事件数据存入缓冲区;get_keyread 函数调用,取出最早的事件。

等待队列:当缓冲区为空时,read 调用 wait_event_interruptible 使进程睡眠;当中断产生并放入数据后,调用 wake_up_interruptible 唤醒睡眠进程。

异步通知结构体button_fasync 用于支持 fcntl(fd, F_SETFL, O_ASYNC),当有数据可读时,内核向应用进程发送 SIGIO 信号。

3.3 文件操作函数(read/poll/fasync)

read 函数

c

static ssize_t gpio_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
    int err;
    int key;

    if (is_key_buf_empty() && (file->f_flags & O_NONBLOCK))
        return -EAGAIN;

    wait_event_interruptible(gpio_wait, !is_key_buf_empty());
    key = get_key();
    err = copy_to_user(buf, &key, 4);

    return 4;
}

逻辑

  1. 若文件打开时设置了非阻塞标志且缓冲区为空,立即返回 -EAGAIN
  2. 否则调用 wait_event_interruptible 睡眠,直到缓冲区非空。
  3. 被唤醒后从缓冲区取出一个 key 值(4 字节整数)。
  4. 通过 copy_to_user 将数据拷贝到用户空间。

数据格式key 的低 8 位为传感器编号(本例中为 0),高 8 位为电平值(1 表示高电平有人,0 表示低电平无人)。应用层打印 0x%x 即可看到类似 0x100(有人)和 0x0(无人)的输出。

poll 函数

c

static unsigned int gpio_drv_poll(struct file *fp, poll_table *wait)
{
    poll_wait(fp, &gpio_wait, wait);
    return is_key_buf_empty() ? 0 : POLLIN | POLLRDNORM;
}

支持 select/poll 多路复用。将等待队列添加到 poll_table,并返回可读状态掩码。

fasync 函数

c

static int gpio_drv_fasync(int fd, struct file *file, int on)
{
    if (fasync_helper(fd, file, on, &button_fasync) >= 0)
        return 0;
    else
        return -EIO;
}

当应用程序设置异步标志时调用,内核负责管理异步通知链表。中断处理函数中通过 kill_fasync 发送信号。

3.4 中断处理函数

c

static irqreturn_t gpio_key_isr(int irq, void *dev_id)
{
    struct gpio_desc *gpio_desc = dev_id;
    int val;
    int key;

    printk("gpio_key_isr key %d irq happened\n", gpio_desc->gpio);

    val = gpio_get_value(gpio_desc->gpio);
    key = (gpio_desc->key) | (val << 8);
    put_key(key);
    wake_up_interruptible(&gpio_wait);
    kill_fasync(&button_fasync, SIGIO, POLL_IN);

    return IRQ_HANDLED;
}

执行流程

  1. dev_id 获取 GPIO 描述结构体。
  2. 读取当前引脚电平 val
  3. 打包 key 值:低 8 位为 gpio_desc->key(此处为 0),高 8 位为 val
  4. key 存入环形缓冲区。
  5. 唤醒等待队列上的睡眠进程。
  6. 向设置了异步通知的进程发送 SIGIO 信号。

为什么在中断中做这么多事?
因为 SR501 输出的是稳定数字信号,无需软件消抖,直接在中断里完成数据采集和通知可保证最佳实时性。

3.5 模块初始化函数

c

static int __init gpio_drv_init(void)
{
    int err;
    int i;
    int count = sizeof(gpios) / sizeof(gpios[0]);

    printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);

    for (i = 0; i < count; i++) {
        // 1. 申请 GPIO
        err = gpio_request(gpios[i].gpio, gpios[i].name);
        if (err) {
            printk(KERN_ERR "can not request gpio %s %d\n", gpios[i].name, gpios[i].gpio);
            return -ENODEV;
        }
        // 2. 设置为输入
        gpio_direction_input(gpios[i].gpio);
        // 3. 获取中断号
        gpios[i].irq = gpio_to_irq(gpios[i].gpio);
        // 4. 注册中断
        err = request_irq(gpios[i].irq, gpio_key_isr,
                          IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,
                          gpios[i].name, &gpios[i]);
        if (err) {
            printk(KERN_ERR "request_irq failed for %s\n", gpios[i].name);
            gpio_free(gpios[i].gpio);
            return err;
        }
    }

    // 注册字符设备
    major = register_chrdev(0, "100ask_gpio_key", &gpio_key_drv);
    // 创建类
    gpio_class = class_create(THIS_MODULE, "100ask_gpio_key_class");
    if (IS_ERR(gpio_class)) {
        unregister_chrdev(major, "100ask_gpio_key");
        return PTR_ERR(gpio_class);
    }
    // 创建设备节点
    device_create(gpio_class, NULL, MKDEV(major, 0), NULL, "sr501");

    return 0;
}

关键步骤说明

  • gpio_request:向内核申请 GPIO 资源,防止冲突。
  • gpio_direction_input:将引脚配置为输入模式(必须,否则无法作为中断源)。
  • gpio_to_irq:将 GPIO 编号转换为中断号。
  • request_irq:注册中断处理函数,触发方式为双边沿(上升沿和下降沿都触发)。
  • register_chrdev:注册字符设备,动态分配主设备号。
  • class_create + device_create:创建类与设备节点,触发 udev/mdev 自动创建设备文件。

3.6 模块退出函数

c

static void __exit gpio_drv_exit(void)
{
    int i;
    int count = sizeof(gpios) / sizeof(gpios[0]);

    device_destroy(gpio_class, MKDEV(major, 0));
    class_destroy(gpio_class);
    unregister_chrdev(major, "100ask_gpio_key");

    for (i = 0; i < count; i++) {
        free_irq(gpios[i].irq, &gpios[i]);
        gpio_free(gpios[i].gpio);
    }
}

逆序释放资源:先销毁设备节点,再销毁类,然后注销字符设备,最后释放中断和 GPIO 资源。


4. 测试程序代码及说明

c

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

static int fd;

int main(int argc, char **argv)
{
    int val;
    int i;

    if (argc != 2) {
        printf("Usage: %s <dev>\n", argv[0]);
        return -1;
    }

    fd = open(argv[1], O_RDWR);
    if (fd == -1) {
        printf("can not open file %s\n", argv[1]);
        return -1;
    }

    while (1) {
        if (read(fd, &val, 4) == 4)
            printf("get button: 0x%x\n", val);
        else
            printf("get button: -1\n");
    }

    close(fd);
    return 0;
}

程序逻辑

  1. 接收设备路径参数(如 /dev/sr501)。
  2. 以阻塞方式打开设备文件。
  3. 循环调用 read 读取 4 字节数据,打印十六进制值。
  4. 当传感器状态变化时,驱动唤醒 read,打印对应数据。

输出示例

text

get button: 0x100   // 有人,高电平
get button: 0x0     // 无人,低电平

5. 编译与加载流程

5.1 Makefile 编写

makefile

KERNEL_DIR := /home/book/100ask_imx6ull-sdk/Linux-4.9.88
CURRENT_DIR := $(shell pwd)
obj-m := gpio_drv.o

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURRENT_DIR) modules
	arm-buildroot-linux-gnueabihf-gcc -o button_test button_test.c

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURRENT_DIR) clean
	rm -rf modules.order button_test
  • obj-m:指定内核模块目标。
  • make modules:在内核源码树下编译外部模块。
  • 测试程序使用交叉工具链编译。

5.2 编译驱动与测试程序

在驱动源码目录执行:

bash

make clean
make

生成 gpio_drv.kobutton_test

5.3 加载模块与设备节点创建

将文件传输到开发板后:

bash

insmod gpio_drv.ko           # 加载模块
dmesg | tail -20             # 查看初始化日志
ls /dev/sr501                # 检查设备节点
./button_test /dev/sr501     # 运行测试

如果节点未自动创建,手动创建:

bash

cat /proc/devices | grep gpio   # 获取主设备号,如 248
mknod /dev/sr501 c 248 0

6. 实战中遇到的错误及解决方案

6.1 模块版本不匹配

错误信息

text

led_drv: disagrees about version of symbol module_layout

原因:编译模块的内核源码版本与开发板运行的内核版本不一致。

解决:确保 KERNEL_DIR 指向与开发板内核完全匹配的源码树,重新编译。

6.2 GPIO 被配置为输出无法申请中断

错误信息

text

gpiochip_lock_as_irq: tried to flag a GPIO set as output for IRQ
genirq: Failed to request resources for sr501

原因:驱动在 request_irq 之前没有调用 gpio_direction_input 将引脚设为输入模式,或者该引脚在设备树中被复用为其他功能(如 CSI),导致 GPIO 子系统无法将其用于中断。

解决

  1. gpio_request 后立即调用 gpio_direction_input
  2. 如果问题依旧,换一个空闲 GPIO(如 GPIO116)测试,确认驱动代码正确后,再研究设备树修改。

6.3 sysfs 类目录重复导致加载失败

错误信息

text

sysfs: cannot create duplicate filename '/class/100ask_gpio_key_class'

原因:之前加载驱动时创建了类目录,但因中途失败未能正确销毁,再次加载时重复创建导致冲突。

解决

  1. 重启开发板(sysfs 是内存文件系统,重启后清空)。
  2. 或手动删除残留目录:rm -rf /sys/class/100ask_gpio_key_class(需要 root 权限)。

6.4 传感器预热与硬件接触问题

现象:驱动加载成功,中断注册成功,但测试程序无输出,中断计数为 0。

排查过程

  1. 通过 sysfs 读取 GPIO 电平:cat /sys/class/gpio/gpio115/value,发现电平会变化,说明传感器工作正常。
  2. 用杜邦线手动触碰 3.3V 和 GND,测试程序立即有输出,证明中断功能正常。
  3. 重新插拔传感器接线,等待一段时间(SR501 需预热),再次测试,成功。

结论:SR501 上电后有初始化时间(约 30~60 秒),期间输出不稳定;同时杜邦线可能存在接触不良,重新插拔后恢复正常。


7. 驱动与应用程序流程图

驱动初始化流程

text

模块加载
    │
    ▼
gpio_drv_init()
    │
    ├─► gpio_request() 申请 GPIO
    ├─► gpio_direction_input() 配置输入
    ├─► gpio_to_irq() 获取中断号
    ├─► request_irq() 注册中断
    │
    ├─► register_chrdev() 注册字符设备
    ├─► class_create() 创建类
    └─► device_create() 创建设备节点 /dev/sr501

中断处理与数据流

text

硬件电平跳变
    │
    ▼
gpio_key_isr()
    │
    ├─► gpio_get_value() 读取电平
    ├─► 打包 key 值
    ├─► put_key() 存入环形缓冲区
    ├─► wake_up_interruptible() 唤醒睡眠进程
    └─► kill_fasync() 发送异步信号

应用程序执行流程

text

开始
    │
    ├─► open("/dev/sr501", O_RDWR)
    │
    └─► while (1)
            │
            ├─► read(fd, &val, 4)  阻塞等待数据
            │
            └─► printf("get button: 0x%x\n", val)

8. 知识巩固:10 道简答题及答案

Q1:gpio_requestgpio_direction_input 的作用分别是什么?
A1: gpio_request 向内核申请独占使用某个 GPIO,防止冲突;gpio_direction_input 将 GPIO 设置为输入模式,只有输入模式才能正确读取外部电平并产生中断。

Q2:为什么中断处理函数中要调用 wake_up_interruptiblekill_fasync
A2: wake_up_interruptible 唤醒因 wait_event_interruptible 睡眠的进程,使其能继续执行 read 读取数据;kill_fasync 向设置了异步通知的进程发送 SIGIO 信号,告知数据可读。

Q3:环形缓冲区的作用是什么?
A3: 当传感器事件产生速度快于应用程序读取速度时,环形缓冲区可以暂存多个事件,避免数据丢失。它通过读写指针实现先进先出。

Q4:register_chrdev 第一个参数传 0 的含义是什么?
A4: 表示由内核动态分配一个未使用的主设备号,返回值即为主设备号。

Q5:设备节点 /dev/sr501 是如何自动创建的?
A5: 驱动中调用 class_create 创建类,再调用 device_create 创建设备并发送 uevent 事件;用户空间的 mdevudev 监听该事件,自动在 /dev 下创建节点。

Q6:中断触发方式 IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING 表示什么?
A6: 表示上升沿和下降沿都会触发中断。对于 SR501,有人时输出上升沿,无人时输出下降沿,使用双边沿触发可以捕获所有状态变化。

Q7:copy_to_usercopy_from_user 为什么不能省略?
A7: 用户空间和内核空间内存隔离,直接访问用户指针可能导致内核崩溃。这两个函数会进行地址合法性检查,确保数据安全传输。

Q8:驱动退出时释放资源的顺序为什么必须与初始化相反?
A8: 因为资源间存在依赖关系。例如,先销毁设备节点再销毁类,否则可能出现空指针;先释放中断再释放 GPIO,防止正在处理的中断访问已释放的 GPIO。

Q9:测试程序中 read(fd, &val, 4) 返回 4 表示什么?
A9: 表示成功读取了 4 字节数据,即驱动打包的 key 值。若返回值小于 4,则可能是被信号中断或发生错误。

Q10:SR501 上电后为什么需要等待一段时间才能稳定输出?
A10: 传感器内部电路需要预热和自校准,通常需要 30~60 秒才能进入稳定工作状态。在此期间输出可能不稳定或不响应。


9. 总结与展望

通过本次项目,我们成功将一份复杂的按键驱动精简并改造成了适配 SR501 传感器的输入驱动,完整实现了 GPIO 中断、环形缓冲、等待队列、异步通知等内核机制。在调试过程中,我们排查了模块版本不匹配、GPIO 方向错误、类目录残留、传感器预热等实际问题,积累了宝贵的实战经验。

最终成果

  • 驱动代码稳定运行,中断响应及时。
  • 测试程序能准确打印传感器状态。
  • 硬件连接正确,传感器触发正常。

后续可扩展方向

  • 使用设备树描述硬件,避免硬编码 GPIO 编号。
  • 实现 sysfs 属性接口,方便用户空间配置触发边沿、消抖时间等。
  • 支持多个传感器同时工作(如 GPIO116、GPIO117 等)。

希望本文能为 Linux 驱动初学者提供清晰的指引,助力大家快速上手嵌入式开发!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐