65.9K
CodeProject 正在变化。 阅读更多。
Home

树莓派的简单I/O设备驱动程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (17投票s)

2015年9月26日

CPOL

14分钟阅读

viewsIcon

102803

downloadIcon

1752

为树莓派编写I/O设备驱动程序

引言

树莓派这个奇妙的小玩意儿绝对是个有趣的玩具。但运行着Wheezy Linux系统,它也构成了一个在ARM平台上运行的完整Linux嵌入式系统。这使得它在编程方面非常吸引人,并促使我萌生了在上面实现一个I/O设备驱动程序的想法,仅仅是为了控制一个数字输出的开关以及读取一个输入的状态。

在树莓派上构建设备驱动程序,基本上和在其他Linux系统上构建是一样的。但有一些小的差异,让分享它变得有价值 :-)

构建环境

要在Linux上编译设备驱动程序,需要一些特殊的源文件。这些文件构建了与内核的接口,它们被称为内核头文件。这些头文件必须与驱动程序将来要工作的内核版本相同,并且它们不包含在Wheezy发行版中。因此,它们必须从互联网下载。这里已经出现了一个树莓派与Linux世界其他地方的第一个小区别。

通常,可以通过以下命令(在XTerminal中输入)获取内核头文件:

sudo apt-get install linux-headers-$(uname -r)

这个命令通过“uname -r”读取当前的内核版本,下载正确的头文件并将其安装到正确的目录中。

但在树莓派上,这不起作用。由于某些原因,与现有的Wheezy发行版匹配的内核头文件无法这样找到。尽管存在几乎相同版本的头文件,但不应该考虑使用它们。可以使用这些头文件编译内核驱动程序,但如果版本不完全匹配,它将无法加载到内核中。

所以只有一个解决方案:必须重新编译并安装一个新的内核。这也会安装合适的内核头文件。

在树莓派页面上可以找到许多有用的提示。在

https://www.raspberrypi.org/documentation/linux/kernel/

这里有一个很好的描述,说明如何下载、编译和安装一个新的内核,以及以下命令:

使用以下命令获取新源代码:

git clone --depth=1 https://github.com/raspberrypi/linux

通过以下命令添加一些缺失的依赖项(不管这是什么意思 :-)):

sudo apt-get install bc

现在,在当前用户的当前工作目录中应该有一个名为“linux”的目录。通过“cd linux”切换到该目录,然后从那里配置新内核,之后才能编译。关于这一点,现在有不同的意见:树莓派页面建议使用以下命令的默认配置:

    make bcmrpi_defconfig

我就是这样做的,而且效果很好。

在一些描述中,他们使用当前内核的旧配置,通过:

    make oldconfig

这基本上是可能的。但请记住:新内核将提供许多在旧配置中未配置的新功能。因此,您可能会被问到许多奇怪的问题,例如:

匿名内存分页支持 是 或 否

System V IPC 是 或 否

您必须每次都做出正确的决定。如果您在一个问题上出错,树莓派可能会在长达8小时的编译时间后显示一条错误消息。这非常令人沮丧。所以最好还是使用默认配置 :-)

现在可以开始内核编译了,可以把树莓派放在一边一段时间。调用以下命令:

    make
    make modules
    sudo make modules_install

如果一切顺利,大约8小时后,在“arch/arm/boot/”目录中应该会有一个名为“Image”的新文件。这就是我们的新内核。此文件应重命名为“kernel.img”并使用以下命令复制到系统的“/boot”目录中:

    sudo cp arch/arm/boot/ kernel.img /boot/

现在我们准备好重启新内核了。重启后,新版本应显示在命令行:

    uname –r

内核头文件现在已安装,可以在“/lib/modules/”目录中找到。

实现驱动程序

Linux设备驱动程序必须有一个定义的结构,其中至少包含以下函数:

    int init_module(void)

加载驱动程序

    void cleanup_module(void)

卸载驱动程序。

除了这两个函数,我们还需要更多函数来读取或写入我们的设备,以及一个打开函数和一个关闭函数。

static ssize_t device_read(struct file *filp,  char *buffer,  size_t length, loff_t * offset)
static ssize_t device_write(struct file *filp, const char *buff, size_t len, loff_t * off)
static int device_open(struct inode *inode, struct file *file)
static int device_release(struct inode *inode, struct file *file)

这些函数必须在驱动程序加载时在内核中注册。因此,使用file_operations结构。在该结构中,每个函数都有一个预定义的引用,它指向相应的函数。看起来是这样的:

static struct file_operations fops = {
    .owner   = THIS_MODULE,
    .open    = device_open,
    .release = device_release,
    .read    = device_read,
    .write   = device_write
};
init_module(void)

init_module函数中,驱动程序将被注册到内核中。当使用insmod命令将驱动程序加载到内核时,它会自动调用。注册是通过以下命令完成的:

    Major = register_chrdev(0,DEVICE_NAME, &fops);

register_chrdev函数基本上将驱动程序的Major编号作为第一个参数。对于内核来说,Major编号是驱动程序的标识符,它将与传递给register_chrdev函数的设备名称(作为第二个参数)相关联。

Major编号可以是固定数字,也可以像这里一样是0。如果为0,内核将检查系统中可用的Major编号,并自行将其提供给我们的驱动程序。如果驱动程序打算在不同系统上分发和安装,这可能是最佳解决方案。在一个在其生命周期内环境不会改变的小型嵌入式系统中,也可以将Major编号设置为固定数字。在这种情况下,开发人员必须自己定义一个可用的Major编号。为此,必须检查/proc/devices文件,并找到一个尚未使用的数字。

传递给register_chrdev的最后一个参数是file_operations结构。

如果注册成功,该函数将返回分配给设备的Major编号。必须保留此数字,因为卸载驱动程序时还需要再次使用它。

现在驱动程序需要检查它想要读取或写入的I/O区域或内存区域是否未被占用,并为自己保留该区域。为此,我们需要查看树莓派的硬件。现在,与至少i86世界相比,又有一个小小的区别。在树莓派上,I/O保存在内存范围内,而不是I/O范围内,如果这还不够的话。该内存区域被映射到另一个区域 J

在以下描述中:

https://www.raspberrypi.org/wp-content/uploads/2012/02/BCM2835-ARM-Peripherals.pdf

GPIO端口的控制地址解释如下:

但正如我所提到的:它们实际上并不在那里。地址0x7E20 0000被映射到0x2020 0000,依此类推。所以,要检查内存区域,我们需要以下命令:

    check_mem_region(PORT, RANGE)

    static unsigned PORT = 0x20200000;
    static unsigned RANGE =  0x40;

如果成功,则

    request_mem_region(PORT, RANGE, DEVICE_NAME);

来保留该区域(在I/O端口区域的情况下,将是check_region(PORT, RANGE)request_ region(PORT, RANGE, DEVICE_NAME))。如果这也成功了,内存区域就被保留了,驱动程序就可以使用它了。这意味着init_module函数的任务完成了,它可以返回SUCCESS表示成功。

    cleanup_module(void)

cleanup_module函数在驱动程序被卸载时被调用。它释放内存区域并注销驱动程序。为此,需要调用以下命令:

    release_mem_region(PORT, RANGE);
    unregister_chrdev(Major, DEVICE_NAME);

(这里我们再次需要Major编号)。

以上是驱动程序的加载和卸载部分。加载和卸载的确切过程将在稍后解释。

    device_open(struct inode *inode, struct file *file)

在我们能够读写内存区域之前,我们必须先打开覆盖这个“东西”的设备。device_open函数准备好设备以供使用。这意味着在这里,我们必须确保一次只有一个应用程序可以使用该设备,确保在使用过程中驱动程序不会被卸载,并按照我们想要的方式设置I/O端口。对于第一个检查,使用了变量Device_Open。如果设备被打开,该变量将被递增,并且在函数入口处,我们检查它是否为0,以查看它是否已被使用。为了防止卸载,我们使用try_module_get(THIS_MODULE)。此函数递增一个内部计数器,该计数器可防止驱动程序在计数器不为0时被卸载。当设备稍后关闭时,将使用module_put(THIS_MODULE)再次递减计数器。

如果到目前为止一切顺利,我们就可以开始使用该设备了。这意味着,我们可以按照我们想要使用的方式设置端口。为此,我们需要对我们想要使用的地址范围进行另一次重新映射。内核函数ioremap将所需的重新映射地址返回到一个指针。

    addr = ioremap(PORT, RANGE);

这里重要的是要注意,我们的地址指针“addr”是u8类型的字节指针,即使我们要写入32位值。我们将通过以下命令设置一个端口:

    writel(cmd, (addr+4));

到基地址加上4的偏移量,我们就得到了“function select 1”寄存器。偏移量4将被转换为4字节地址,因为addr是字节指针。这一点很重要。如果addr是另一种类型,就需要考虑寻址。如果我们要使用u32指针,则硬件文档中找到的偏移量需要除以4。当然,这样做是可能的并且是可以的,但如果想看清楚工作原理,就不那么清楚了。所以也许最好还是使用u8指针进行寻址 :-)

设置端口

为了设置每个端口的功能,树莓派使用4个“function select”寄存器。其中每个寄存器控制10个GPIO引脚,最后一个除外。它控制8个。每个引脚的功能在该寄存器中被编码为位。每个引脚有3位来设置其功能。编码如下:

    000 = GPIO Pin is an input
    001 = GPIO Pin is an output
    100 = GPIO Pin takes alternate function 0
    101 = GPIO Pin takes alternate function 1
    110 = GPIO Pin takes alternate function 2
    111 = GPIO Pin takes alternate function 3
    011 = GPIO Pin takes alternate function 4
    010 = GPIO Pin takes alternate function 5

我的设备驱动程序想将GPIO引脚10设置为输出,并将GPIO引脚14设置为输入。所以,我必须在具有偏移量4的“function set 1”寄存器中写入一个设置命令。为了清晰起见,我首先将所有GPIO引脚10到19设置为输入,命令如下:

    cmd = 0;
    writel(cmd, (addr+4));

然后,我可以将引脚10设置为输出,通过:

    cmd = 1;
    writel(cmd, (addr+4));

这就是我们在这里需要做的。

    static ssize_t device_write(struct file *filp, const char *buff, size_t len, loff_t * off)

为了设置或清除我们的GPIO引脚,我们实现了device_write函数。树莓派提供了“GPIO pin output set 0”和“GPIO pin output set 1”寄存器来设置输出。我们必须使用第一个来设置引脚10。引脚是按位编码设置的。这意味着我们有一个32位的值,每一位代表一个引脚。引脚10由位10表示,要设置引脚10,我们可以使用以下命令:

    cmd = 1 << 10;
    writel(cmd, (addr+0x1c));

此命令只需执行一次即可设置引脚。引脚将保持设置状态,直到调用清除命令。这与某些外围设备不同,后者需要通过静态命令设置输出。

要清除引脚,我们必须在“GPIO pin output clear 0”寄存器上执行相同的操作。

    cmd = 1 << 10;
    writel(cmd, (addr+0x28));
static ssize_t device_read(struct file *filp, char *buffer, size_t length, loff_t * offset)

在device_read函数中实现了读取输入状态。我们可以在“GPIO pin level 0”寄存器中读取引脚0到31的状态。在这里,我们再次按位编码读取每个引脚的状态。

    res = readl(addr+0x34);

将状态返回到u32变量res。从该变量中,我提取4个u8部分,并将它们复制到buf数组中,通过:

    buf[0] = res & 0xFF;
    buf[1] = (res >> 8) & 0xFF;
    buf[2] = (res >> 16) & 0xFF;
    buf[3] = (res >> 24) & 0xFF;

并且,由于我们将在char* buffer中返回此数组,因此我们必须用0终止buf。

    buf[4] = 0;

为了将此数据从内核空间移动到用户空间,我们使用put_user函数:

    index = 4;
    while (length && (index >= 0))
    {
        put_user(*(msg_Ptr++), buffer++);
        length--;
        index--;
        bytes_read++;
    }
    return bytes_read;

通过此实现,应用程序可以通过一次device_read调用读取所有GPIO引脚的按位编码状态。

编译驱动程序

要编译设备驱动程序模块,需要一种特殊的程序。我们必须使用Makefile并在其中定义编译。编译过程中需要加载几个内核模块。唯一正确完成此任务的方法是使用Makefile。我的Makefile包含以下5行:

    obj-m := raspy_io.o
    all:
        make -C /lib/modules/4.0.9+/build M=$(PWD) modules
    clean:
        make -C /lib/modules/4.0.9+/build M=$(PWD) clean

我使用的是内核版本4.0.9+,内核头文件位于/lib/modules/4.0.9+。从那里应该包含内核模块。我自己的源文件与Makefile在同一个目录中。因此,我可以通过“M=$(PWD)”将当前路径设置为源路径。当我们通过“make”命令(在同一目录中)开始编译时,编译器会查找raspy_io.c文件,将其编译并链接为raspy_io.ko。这就是可以加载到内核中的模块。

将驱动程序加载到内核

将驱动程序加载到内核是一项艰巨的任务 :-)。我们必须将模块插入内核,然后必须在/dev目录中创建一个设备节点,并为该节点设置正确的权限,以便应用程序可以加载它。但,一步一步来。

要将模块插入内核,我们可以调用:

    Sudo insmod raspy_io.ko

这很容易。

但现在困难的部分开始了。要创建节点,我们需要知道我们在驱动程序中实现的设备的“Major编号”。现在有两种方法可以获取此数字。一种方法是在我们的驱动程序代码的init_module函数中,在调用register_chrdev函数时将Major编号固定。但为此,我们需要知道要在使用的系统上可用的Major编号。基本上,我们可以查看/proc目录中的devices文件,查找所有已使用的数字,并使用一个未列出的数字。这在一个将保持不变的小型嵌入式系统上可能会起作用。但由于我们可能想在以后可以安装其他驱动程序的系统上使用我们的驱动程序,所以我们应该走另一条路,让系统在将驱动程序插入内核时动态地为我们提供一个可用的Major编号,并自己找出我们获得了哪个编号。我已经以这种方式实现了我的驱动程序。

insmod命令调用init_module函数,并向内核请求一个Major编号。此编号会立即列在/pro/devices文件中,我们可以从devices文件中读取此编号。为了做到这一点,我使用了强大的AWK工具。AWK是一个功能强大的工具,但它非常复杂 :-)

在shell脚本中获取Major编号的命令如下:

    module="raspy_io.ko"
    device="io_dev"
    major=`cat /proc/devices | awk "{if(\\$2==\"$device\")print \\$1}"`

命令cat /proc/devices列出devices的所有条目,并将其作为一个表传递给awk,该表包含第一行的所有Major编号和第二行的设备名称。Awk现在解析所有行,并检查第二行($2)的值是否为我们的“io_dev”。如果是,它将取第一行($1)的值并将其返回到我们的“major”脚本变量。使用这个major变量,我首先确保要创建的节点已被删除,然后通过以下方式重新创建它:

    rm -f /dev/${device} c $major 0
    mknod /dev/${device} c $major 0

现在我必须设置读取和写入设备的权限:

    chmod 666 /dev/${device}

就是这样。现在可以使用该设备了。它的名称是“io_dev”,所有用户都可以读取或写入它。

我将这些命令放入一个名为“load.sh”的shell脚本中。此脚本可以以root身份调用,并插入和注册设备,使其准备好使用。

使用驱动程序

要使用我们创建的设备,我们只需以读/写模式打开它即可:

    int fd;
    fd = open("/dev/io_dev", O_RDWR);

如果这成功了,fd将大于0,然后我们可以通过以下方式读取或写入:

    char buf[4];
    char wbuf[4];
    read(fd, buf, sizeof(buf));

通过以下命令设置引脚14:

    wbuf[0] = 1;
    wbuf[1] = 0;
    write(fd, wbuf, sizeof(wbuf));

并清除它,通过:

    wbuf[0] = 0;
    wbuf[1] = 0;
    write(fd, wbuf, sizeof(wbuf));

最后,我们必须通过以下方式再次释放驱动程序:

    close(fd);

要编译使用设备驱动程序的应用程序,必须包含fcntl.h:

    #include <fcntl.h>

编辑驱动程序源文件和测试应用程序使用了Geany(安装方法:“sudo apt-get install geany”)。Geany是一个方便的小巧C编辑器。GCC编译器应该预装在树莓派上。我只需要这些 :-)

关注点

为了更深入地学习,我推荐O’Reilly的《Linux Device Drivers》一书。这是一本非常全面的书(就像O’Reilly的书通常那样 :-)),对于编写Linux设备驱动程序非常有帮助。

© . All rights reserved.