一文读懂如何在 Linux 中编写设备驱动程序

本文是《在 Linux 中编写设备驱动程序:简要教程》的中文翻译

原文标题 Writing device drivers in Linux: A brief tutorial

原文目前可以在 https://studenti.fisica.unifi.it/~carla/manuali/linux_device_driver_tutorial.pdf 访问

----------------

原文由 Xavier Calbet 发表在 http://www.freesoftwaremagazine.com

文章标题:在 Linux 中编写设备驱动程序:简要教程

副标题:快速而简单的介绍,如何像一个真正的内核开发人员那样为 Linux 编写设备驱动程序!

“Do you pine for the nice days of Minix-1.1, when men were men and wrote their own device drivers?” Linus Torvalds

译者:这是Linux创始人Linux Torvalds在 comp.os.minix 邮件组中一封著名邮件的第一句话,这封邮件被认为是Linux系统的发端

所需基础知识

要开发 Linux 设备驱动程序,需要先了解以下基础知识:

  • C 编程 需要一些深入的 C 编程知识,如指针用法、位操作等。
  • 微处理器编程 需要了解微型计算机内部是如何工作的:内存寻址、中断等。这些概念对于汇编程序员来说应该都是熟悉的。

Linux 中的设备分为几种类别。简单起见,在这个简短的教程里,将只涵盖作为模块加载的字符型设备。将使用内核 2.6.x(确切地说是 Debian Sarge 下的内核 2.6.8,当前 Debian 的稳定版)。

用户空间和内核空间

在编写设备驱动程序时,区分清楚“用户空间”和“内核空间”很重要。

  1. 内核空间
    Linux(一个内核)以简单高效的方式管理计算机的硬件,为用户提供简单而统一的编程接口。同样,内核,特别是其中的设备驱动程序,提供了接口让最终用户(及程序员)和硬件之间有了一个沟通的桥梁。任何构成内核一部分的子例程或函数(例如模块 module 和设备驱动程序)都是内核空间的一部分。
  2. 用户空间
    用户程序,如 UNIX shell 或其他基于 GUI 的应用程序(例如 kpresenter),都是用户空间的一部分。 显然,这些应用程序是需要与系统的硬件进行交互的。 但是,用户应用程序并不能直接与硬件交互,而只能通过调用由内核支持的函数来与硬件交互。

请看图 1 。

图1:应用程序所在的用户空间以及模块和设备驱动所在的内核空间

用户空间和内核空间之间的接口函数

内核向用户空间提供了几个子程序或函数,以允许最终用户应用程序的编写者可以与硬件进行交互。通常,在 UNIX 或 Linux 系统中,这种与硬件的交互是通过读写文件的函数来完成的。之所以这样是因为在 Unix 中,从用户的角度来看,设备被视为文件。

另一方面,在内核空间,Linux 也提供了几个函数或子程序来直接与硬件进行底层的低级交互,并允许将信息从内核传输到用户空间。

通常,对于用户空间中的每个接口函数(允许对设备或文件的使用),在内核空间中都存在一个等价函数(允许信息从内核空间传递给用户空间,或者从用户空间传递给内核空间)。如表 1 所示,现在这张表还空着。随着接下来对设备驱动程序各概念的介绍会逐步充实这张表。

表1. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块
打开设备
读取设备
写入设备
关闭设备
卸载模块

内核空间和硬件设备之间的接口函数

内核空间里也有一些接口函数,它们可以控制设备或在内核和硬件之间交换信息。表 2 展示了这些概念。随着介绍的深入,该表也将逐步充实。

表2. 设备操作及其对应的访问硬件设备的内核函数
设备操作内核函数
读取数据
写入数据

第一个驱动:在用户空间加载和卸载驱动程序

我现在将向您展示如何开发您的第一个 Linux 设备驱动程序,这个驱动程序将作为一个模块( module)引入内核。
为此,我将编写以下名为 nothing.c 的程序

#include <linux/module.h>
MODULE_LICENSE("Dual BSD/GPL");

自 Linux 内核 2.6.x 发布以来,编译模块变得稍微复杂了一些。首先,您需要有一个完整的、已编译的内核源代码树。如果您有 Debian Sarge 系统,您可以按照附录 B (在本文结尾)中的步骤进行操作。下面,我将假设使用内核版本 2.6.8。

接下来,需要在 nothing.c 相同目录下新建一个名为 Makefile 的文件。

obj-m := nothing.o

与以前版本的内核不同,现在还需要使用要加载使用这个模块的相同的内核来编译模块。 要编译它,您可以键入:

$ make -C /usr/src/kernel-source-2.6.8 M="nothing.c所在目录路径" modules

译者:我在 ubuntu 20.04 LTS 上测试,内核已经是 5.4.0 版本了。要编译内核模块,只需要安装内核头文件,命令如下:

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

make命令和执行结果如下

$ make -C /usr/src/linux-headers-5.4.0-121-generic M="~/Develop/linuxdriver/nothing" modules
make: Entering directory '/usr/src/linux-headers-5.4.0-121-generic'
  CC [M]  /home/faund/Develop/linuxdriver/nothing/nothing.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC [M]  /home/faund/Develop/linuxdriver/nothing/nothing.mod.o
  LD [M]  /home/faund/Develop/linuxdriver/nothing/nothing.ko
make: Leaving directory '/usr/src/linux-headers-5.4.0-121-generic'

这时,在 nothing.c 所在目录之下会生成编译好的 nothing.ko

这个极其简单的模块属于内核空间,一旦加载它就会成为内核的一部分。
在用户空间中,您可以以 root 身份通过在命令行中键入以下内容来加载这个 nothing 模块:

# insmod nothing.ko

insmod 命令允许用户在内核中安装模块。虽然现在这个 nothing 模块啥也干不了。

可以通过 lsmod 查看所有已安装的模块来检查模块是否已正确安装:

# lsmod

最后,通过 rmmod 可以将模块从内核中删除。

# rmmod nothing

通过再次运行 lsmod 命令,可以检查到这个 nothing 模块已经不存在于内核中了。

以上可以总结到表3中

表3. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmod
打开设备
读取设备
写入设备
关闭设备
卸载模块rmmod

译者:这是我在 ubuntu 20.04 LTS 上的测试截图

 “Hello world”驱动:在内核空间加载和卸载驱动程序

当设备驱动程序模块加载到内核中时,通常会执行一些初始化任务,比如重置设备、预留 RAM、注册中断和输入/输出端口等。卸载模块时要做一些相应的清理工作。

这些任务由内核空间中的两个函数执行:module_init 和 module_exit,这两个函数是必需的(并需要明确声明的); 它们对应于用户空间在安装或删除模块时使用的命令 insmod 和 rmmod。 综上所述,用户命令 insmod 和 rmmod 使用了内核空间函数 module_init 和 module_exit。

让我们看一个实际示例,一个经典的 Hello world 程序:

hello.c 程序

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("Dual BSD/GPL");
static int hello_init(void) {
    printk("<1> Hello world!\n");
    return 0;
}
static void hello_exit(void) {
    printk("<1> Bye, cruel world\n");
}
module_init(hello_init);
module_exit(hello_exit);

函数 hello_init 和 hello_exit 可以叫任意名字。但给它们加上 init 和 exit 后缀比较易于识别为相应的加载和删除函数,它们必须作为参数传递给函数 module_init 和 module_exit。

这里还用到了 printk 函数。它与有名的 printf 非常相似,只是它只工作在内核中。 <1> 符号表示消息的高优先级(数字越小越优先),这样,除了在内核系统日志文件中获取消息外,您还应该在系统控制台中收到此消息。

在将其名称添加到 Makefile 之后,可以使用与以前相同的命令编译此模块。

Makefile 文件

obj-m := nothing.o hello.o

在本文后面部分,Makefiles 将作为练习留给读者。编译本教程的所有模块的完整 Makefile 列在附录 A 中。
加载或删除模块时,在 printk 语句中写入的消息将显示在系统控制台中。 如果这些消息没有出现在控制台中,您可以通过发出 dmesg 命令或使用 cat /var/log/syslog 输出系统日志文件来查看它们。

译者:下图是在 Ubuntu 20.04 LTS (确切的说是基于 Ubuntu 20.04 LTS 的 Linux Mint 20.3 Una 发行版) 的实验结果

 

表4. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmodmodule_init()
打开设备
读取设备
写入设备
关闭设备
卸载模块rmmodmodule_exit()

完整的“memory”驱动程序

最初的代码

我现在将展示如何构建一个完整的设备驱动程序:memory.c 。可以向这个设备写入字符或者读取字符。该设备虽然也不是很有用,但它提供了一个非常具有演示性的示例,因为它是一个完整的驱动程序;而且也很容易实现,因为它不连接到真实的硬件设备(除了计算机本身的内存)。

要开发此驱动程序,需要添加几个新的 #include 语句,这些语句经常出现在设备驱动程序中:

memory.c 最初代码

/* 设备驱动需要的头文件 */
#include <linux/init.h>
#include <linux/config.h>
#include <linux/module.h>
#include <linux/kernel.h> /* printk() */
#include <linux/slab.h> /* kmalloc() */
#include <linux/fs.h> /* everything... */
#include <linux/errno.h> /* error codes */
#include <linux/types.h> /* size_t */
#include <linux/proc_fs.h>
#include <linux/fcntl.h> /* O_ACCMODE */
#include <asm/system.h> /* cli(), *_flags */
#include <asm/uaccess.h> /* copy_from/to_user */
MODULE_LICENSE("Dual BSD/GPL");
/* memory.c 函数声明 */
int memory_open(struct inode *inode, struct file *filp);
int memory_release(struct inode *inode, struct file *filp);
ssize_t memory_read(struct file *filp, char *buf, size_t count, loff_t *f_pos);
ssize_t memory_write(struct file *filp, char *buf, size_t count, loff_t *f_pos);
void memory_exit(void);
int memory_init(void);
/* 一个用于声明通常的文件操作函数的结构体 */
struct file_operations memory_fops = {
    read: memory_read,
    write: memory_write,
    open: memory_open,
    release: memory_release
};
/* init 和 exit 函数 */
module_init(memory_init);
module_exit(memory_exit);
/* 驱动程序的全局变量 */
/* 主设备号 */
int memory_major = 60;
/* 用于存储数据的内存 */
char *memory_buffer;

在头文件之后,声明稍后将会具体定义的函数。通常用于操作文件的常用函数在 file_operations 结构的定义中声明,这些也将在后面详细解释。接下来,在加载和删除模块时使用的初始化和退出函数被声明给内核。最后,声明驱动的全局变量:一个是驱动的主设备号,另一个是指向内存中某个区域的指针,memory_buffer,将用作驱动数据的存储空间。

通过文件来连接设备

在 UNIX 和 Linux 中,从用户空间访问设备的方式与访问普通文件的方式完全相同。 这些设备文件通常在 /dev 目录中。

为了将普通文件与内核模块链接起来,需要两个数字:主设备号和次设备号。主设备号是内核用来链接文件和驱动程序的编号。次设备号供设备驱动内部使用,为简单起见,本文将不予介绍。

为此,必须创建一个文件(将用于访问设备驱动程序),方法是以 root 身份输入以下命令:

# mknod /dev/memory c 60 0

上面命令中,c表示要创建一个字符设备(char device),60是主设备号,0是次设备号。

在驱动程序中,为了在内核空间中将其与对应的 /dev 文件链接,要使用 register_chrdev 函数,它使用三个参数来调用:主设备号、模块名称字符串以及一个file_operations 结构体,这个结构体指向了一组此设备特定的文件操作函数 。register_chrdev 函数在安装模块时调用,方式如下:

memory.c memory_init 加载模块函数

int memory_init(void) {
    int result;
    /* 注册设备 */
    result = register_chrdev(memory_major, "memory", &memory_fops);
    if (result < 0) {
        printk(
            "<1>memory: cannot obtain major number %d\n", memory_major);
        return result;
    }
    /* 分配存储所需要的内存空间, 1 个字节 */
    memory_buffer = kmalloc(1, GFP_KERNEL);
    if (!memory_buffer) {
        result = -ENOMEM;
        goto fail;
    }
    memset(memory_buffer, 0, 1);
    printk("<1>Inserting memory module\n");
    return 0;

    fail:
        memory_exit();
        return result;
}

另外,请注意 kmalloc 函数的使用。该函数用于分配位于内核空间的内存,设备驱动程序的存储内存是位于内核空间的。它的使用与众所周知的 malloc 函数非常相似。最后,如果注册主设备号或分配内存失败,模块会执行相应的代码。

卸载“memory”驱动

在 memory_exit 函数中卸载模块时,需要用 unregsiter_chrdev 函数来释放前面加载模块时在内核注册的主设备号。

memory.c memory_exit 卸载模块函数

void memory_exit(void) {
    /* 释放主设备号 */
    unregister_chrdev(memory_major, "memory");
    /* 释放内存 */
    if (memory_buffer) {
        kfree(memory_buffer);
    }

    printk("<1>Removing memory module\n");
}

加载时分配的内存空间也在 memory_exit 函数中释放了,这样设备驱动卸载后可以留下一个干净的内核。

像打开文件那样打开设备

对应于用户空间用于打开文件的函数 fopen,在内核空间中的驱动程序模块里相应的就是 file_operations 结构体内的成员 open,这个 file_operations 结构体在调用 register_chrdev 注册设备时作为参数传递进去。在 memory 设备这个例子里,open 成员指向了 memory_open 函数,它需要 2 个参数:一个 inode 结构体用于把主设备号和次设备号信息发给内核,以及,一个包含了针对本文件可以进行哪些文件操作的 file 结构体。具体的内核文件操作函数(对于设备驱动来说就是对设备进行控制的函数)本文不作讨论。

当打开一个设备文件时,通常需要初始化驱动程序变量或重置设备。但是,在目前这个简单的示例中,并不需要这些操作。

memory_open 函数如下所示:

memory.c memory_open 打开设备文件函数

int memory_open(struct inode *inode, struct file *filp) {
    /* Success */
    return 0;
}

这个新的函数现在可以填充到表 5 中

表5. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmodmodule_init()
打开设备fopenfile_operations: open
读取设备
写入设备
关闭设备
卸载模块rmmodmodule_exit()

像关闭文件那样关闭设备 

于在用户空间关闭文件的函数(fclose)对应的是 file_operations 结构的成员 release 成员。memory设备的例子中,它是函数 memory_release,它有一个 inode 结构和一个 file 结构作为参数,像前面打开设备一样。

当文件关闭时,通常需要释放已使用的内存以及与设备打开相关的任何变量。但是,由于这个例子很简单,这些操作都不需要。

memory_release函数如下所示:

memory.c memory_release 关闭设备文件函数

int memory_release(struct inode *inode, struct file *filp) {
    /* Success */
    return 0;
}

新函数填充到表 6 中

表6. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmodmodule_init()
打开设备fopenfile_operations: open
读取设备
写入设备
关闭设备fclosefile_operations: release
卸载模块rmmodmodule_exit()

像读文件一样读取设备

要使用用户函数 fread 或类似函数读取设备,对应于 file_operations 结构的 read 成员。在本例中是函数memory_read。它的参数是:一个 file 结构; 一个缓冲区(buf),用户空间函数(fread)将从该缓冲区中读取; 要传输的字节数(count),它与用户空间函数(fread)中要读取字节数参数的值相同;最后一个参数是读取的起始位置(f_pos)。

在这个简单的例子中,memory_read 函数使用函数 copy_to_user 将单个字节从驱动程序缓冲区 (memory_buffer) 传输到用户空间:

memory.c memory_read 读取设备文件函数

ssize_t memory_read(struct file *filp, char *buf, size_t count, loff_t *f_pos) {
    /* 向用户空间发送数据 */
    copy_to_user(buf,memory_buffer,1);
    /* 读取数据后把缓冲区指针调整到合适的位置 */
    if (*f_pos == 0) {
        *f_pos+=1;
        return 1;
    } else {
        return 0;
    }
}

读取之后,文件 (f_pos) 中的读取位置也需要变化。 如果读取位置在文件的开头,则将其加 1,并将正确读取的字节数作为返回值给出,也就是返回 1。如果读取位置不在文件开头,则返回一个文件结束符(0),因为 memory 文件总共只存储 1 个字节。

新函数添加到表 7 中

表7. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmodmodule_init()
打开设备fopenfile_operations: open
读取设备freadfile_operations: read
写入设备
关闭设备fclosefile_operations: release
卸载模块rmmodmodule_exit()

像写文件一样对设备进行写入

file_operations 结构中的 write 成员对应于用户空间的 fwrite 一类的文件写操作。本例中 write 成员指向 memory_write 函数,它接受以下参数:一个 file 结构,一个由用户空间 fwrite 函数写入用的缓冲区 buf,与用户空间写入字节数参数的值相同的参数 count,f_pos参数指出写入的起始位置。

memory.c memory_write 写入设备文件函数

ssize_t memory_write( struct file *filp, char *buf,
    size_t count, loff_t *f_pos) {
    char *tmp;
    tmp=buf+count-1;
    copy_from_user(memory_buffer,tmp,1);
    return 1;
}

copy_from_user函数从用户空间把数据传递到内核空间。

这个新函数填充到表 8 中

表8. 设备驱动操作及其对应的用户空间和内核空间接口函数
设备驱动操作用户空间接口函数内核空间接口函数
加载模块insmodmodule_init()
打开设备fopenfile_operations: open
读取设备freadfile_operations: read
写入设备fwritefile_operations: write
关闭设备fclosefile_operations: release
卸载模块rmmodmodule_exit()

 完整的“memory”驱动程序

把前面所示的所有代码放在一起就得到了完整的 memory 驱动程序:

memory.c 完整版

最初代码
memory_init
memory_exit
memory_open
memory_release
memory_read
memory_write

译者:时至今日,内核已经发生很多变化,下面是在5.4.0版本内核下调试过可以编译的代码

/* 设备驱动需要的头文件 */
#include <linux/init.h>
// #include <linux/config.h> 此头文件已经在 2.6.19 版内核中删除
#include <linux/errno.h>  /* error codes */
#include <linux/fcntl.h>  /* O_ACCMODE */
#include <linux/fs.h>     /* everything... */
#include <linux/kernel.h> /* printk() */
#include <linux/module.h>
#include <linux/proc_fs.h>
#include <linux/slab.h>  /* kmalloc() */
#include <linux/types.h> /* size_t */
// #include <asm/system.h> /* cli(), *_flags */ 此头文件已经在 3.4 版内核中删除
// #include <asm/uaccess.h> /* copy_from/to_user */
#include <linux/uaccess.h> /* copy_from/to_user */

MODULE_LICENSE("Dual BSD/GPL");

/* memory.c 函数声明 */
int memory_open(struct inode *inode, struct file *filp);
int memory_release(struct inode *inode, struct file *filp);
ssize_t memory_read(struct file *filp, char *buf, size_t count, loff_t *f_pos);
// ssize_t memory_write(struct file *filp, char *buf, size_t count, loff_t *f_pos); 
// 要加上 const 关键字
ssize_t memory_write(struct file *filp, const char *buf, size_t count, loff_t *f_pos);
void memory_exit(void);
int memory_init(void);
/* 一个用于声明通常的文件操作函数的结构体 */
struct file_operations memory_fops =
    {read : memory_read, write : memory_write, open : memory_open, release : memory_release};
/* init 和 exit 函数 */
module_init(memory_init);
module_exit(memory_exit);
/* 驱动程序的全局变量 */
/* 主设备号 */
int memory_major = 60;
/* 用于存储数据的内存 */
char *memory_buffer;

int memory_init(void) {
    int result;
    /* 注册设备 */
    result = register_chrdev(memory_major, "memory", &memory_fops);
    if (result < 0) {
        printk("<1>memory: cannot obtain major number %d\n", memory_major);
        return result;
    }
    /* 分配存储所需要的内存空间, 1 个字节 */
    memory_buffer = kmalloc(1, GFP_KERNEL);
    if (!memory_buffer) {
        result = -ENOMEM;
        goto fail;
    }
    memset(memory_buffer, 0, 1);
    printk("<1>Inserting memory module\n");
    return 0;

fail:
    memory_exit();
    return result;
}

void memory_exit(void) {
    /* 释放主设备号 */
    unregister_chrdev(memory_major, "memory");
    /* 释放内存 */
    if (memory_buffer) {
        kfree(memory_buffer);
    }

    printk("<1>Removing memory module\n");
}

int memory_open(struct inode *inode, struct file *filp) {
    /* Success */
    return 0;
}

int memory_release(struct inode *inode, struct file *filp) {
    /* Success */
    return 0;
}

ssize_t memory_read(struct file *filp, char *buf, size_t count, loff_t *f_pos) {
    /* 向用户空间发送数据 */
    copy_to_user(buf, memory_buffer, 1);
    /* 读取数据后把缓冲区指针调整到合适的位置 */
    if (*f_pos == 0) {
        *f_pos += 1;
        return 1;
    } else {
        return 0;
    }
}

ssize_t memory_write(struct file *filp, const char *buf, size_t count, loff_t *f_pos) {
    const char *tmp;
    tmp = buf + count - 1;
    copy_from_user(memory_buffer, tmp, 1);
    return 1;
}

下面这个 Makefile 放在 memory.c 相同目录下

obj-m := nothing.o hello.o memory.o

all:
		make -C /usr/src/linux-headers-5.4.0-121-generic M=$(PWD) modules
clean:
		make -C /usr/src/linux-headers-5.4.0-121-generic M=$(PWD) clean

编译过程如下

 

和之前其它那些模块一样,memory 模块要编译后才能使用。以下命令可以加载 memory 模块:

# insmod memory.ko

用以下命令修改 /dev/memory 的权限以使普通用户可以读写这个设备

# chmod 666 /dev/memory

如果一切顺利,可以得到一个 /dev/memory 设备,向这个设备写入字符串的话,它可以存储最后一个字符。可以向下面这样进行写入设备操作

$ echo -n abcdef >/dev/memory

要查看 /dev/memory 设备存储的内容,可以简单地用 cat 命令实现:

$ cat /dev/memory

设备中存储的内容不会变化,除非再次向这个设备写入或者模块被卸载了。

译者:5.4.0内核 Ubuntu 20.04 LTS 下的测试结果

 

 

真正的“并口”驱动程序

并口的简介

我现在将继续修改我刚刚创建的驱动程序,以开发一个真正在真实设备上执行操作的驱动程序。 我将使用简单而无处不在的计算机并行端口,驱动程序将被称为  parlelport(并行端口)。

译者:时至今日,并口已不常见,而非作者所说的那样“无处不在”,我本来想找替代方法的,比如串口的 DTR 或者 RTS 脚给它置高电平,但是转念一想,现在的电脑上串口也不常见了,大家真的要实验,可能只能去买一个 ARM 开发板,或者其它什么实验板来玩一玩了。

并口(并行端口)实际上是一种用于数字信息输入输出的设备。更具体地说,它有一个带有 25 个针脚的 D-25 母接头。在计算机内部,从 CPU 的角度来看,它使用三个字节的内存(设备寄存器)。在 PC 中,基地址(从设备的第一个字节开始算的地址)通常是 0x378。在我们这个基本示例中,我将只使用第一个字节,这个字节全部是控制输出的。

上述字节与外部引脚的连接示意如图 2 所示

 图2:并口首个寄存器与外部D-25连接头中的针脚之间的连接示意

模块初始化

需要对前面的 memory_init 函数进行修改——把分配内存空间的代码改成申请并口寄存器地址的代码。为了这个目的,需要用到检查地址区可用性的函数(check_region),还有为这个设备驱动保留地址区的函数(request_region)。这两个函数都需要地址区的基地址和地址区长度2个参数。request_region 还需要一个字符串来指明地址区是保留给哪个设备驱动的。

为 parlelport 修改的模块 init 函数

/* Registering port */
port = check_region(0x378, 1);
if (port) {
    printk("<1>parlelport: cannot reserve 0x378\n");
    result = port;
    goto fail;
}
request_region(0x378, 1, "parlelport");

从内核删除模块

与 memory 的释放内存很相似,这里需要去除对并口地址区的保留。release_region 函数可以解除对之前申请的地址区的占用,它需要的参数与 check_region 是一样的。

为 parlelport 修改的模块 exit 函数

/* Make port free! */
if (!port) {
    release_region(0x378,1);
}

读取设备

本例中,一个真实的读取设备的行为需要添加,这样,就可以将真实的设备数据传递到用户空间去。inb 函数可以做到读取真实设备的数据,它的参数是并口的地址,它将并口的内容作为返回值返回。

parlelport inport

/* 读取并口 */
parlelport_buffer = inb(0x378);

把表 2 填充上这个新函数后就成为表 9

表9. 设备操作及其对应的访问硬件设备的内核函数
设备操作内核函数
读取数据inb
写入数据

向设备写入数据

需要增加一个“写入设备”的函数,这样可以从用户空间数据传递数据到内核空间进而写入到设备的寄存器中。outb 函数实现这个功能,它的参数包括需要写入设备的内容以及写入的目的地址。

parlelport outport

/* Writing to the port */
outb(parlelport_buffer,0x378);

表 10 增加了这个函数

表10. 设备操作及其对应的访问硬件设备的内核函数
设备操作内核函数
读取数据inb
写入数据outb

完整的“parlelport”并口驱动程序

我们继续查看 parlelport 模块的整个代码。您必须在整个 memory 模块的代码中把 “memory” 查找替换成 “parlelport”。最终结果如下所示:

parlelport.c 由以下代码组成:

<parlelport initial>
<parlelport init module>
<parlelport exit module>
<parlelport open>
<parlelport release>
<parlelport read>
<parlelport write>

初始化节

在驱动程序的初始化节,我们使用了一个与之前 memory 设备不同的主设备号 61。同时,全局变量 memory_buffer 被换成了 port,而且新增了2个头文件:ioport.h 和 io.h。

<parlelport initial>

/* 驱动程序所需的头文件 */
#include <linux/init.h>
#include <linux/config.h>
#include <linux/module.h>
#include <linux/kernel.h> /* printk() */
#include <linux/slab.h> /* kmalloc() */
#include <linux/fs.h> /* everything... */
#include <linux/errno.h> /* error codes */
#include <linux/types.h> /* size_t */
#include <linux/proc_fs.h>
#include <linux/fcntl.h> /* O_ACCMODE */
#include <linux/ioport.h>
#include <asm/system.h> /* cli(), *_flags */
#include <asm/uaccess.h> /* copy_from/to_user */
#include <asm/io.h> /* inb, outb */

MODULE_LICENSE("Dual BSD/GPL");

/* parlelport.c 的函数声明 */
int parlelport_open(struct inode *inode, struct file *filp);
int parlelport_release(struct inode *inode, struct file *filp);
ssize_t parlelport_read(struct file *filp, char *buf,
                        size_t count, loff_t *f_pos);
ssize_t parlelport_write(struct file *filp, char *buf,
                        size_t count, loff_t *f_pos);
void parlelport_exit(void);
int parlelport_init(void);

/* 声明了通用文件操作函数的结构体 */
struct file_operations parlelport_fops = {
    read: parlelport_read,
    write: parlelport_write,
    open: parlelport_open,
    release: parlelport_release
};

/* 驱动程序的全局变量 */
/* 主设备号 */
int parlelport_major = 61;

/* 用于并口地址保留的控制变量 */
int port;

module_init(parlelport_init);
module_exit(parlelport_exit);

模块的 init

在这个模块初始化例程中,像前面介绍过的那样我将引入对并口地址的保留操作。

<parlelport init module>

int parlelport_init(void) {
    int result;

    /* 注册设备 */
    result = register_chrdev(parlelport_major, "parlelport",
        &parlelport_fops);
    if (result < 0) {
        printk(
            "<1>parlelport: cannot obtain major number %d\n",
            parlelport_major);
        return result;
    }

    /* Registering port 注册端口(保留并口地址) */
    port = check_region(0x378, 1);
    if (port) {
        printk("<1>parlelport: cannot reserve 0x378\n");
        result = port;
        goto fail;
    }
    request_region(0x378, 1, "parlelport");

    printk("<1>Inserting parlelport module\n");
    return 0;

    fail:
        parlelport_exit();
        return result;
}

从内核移除模块

这个例程要包括前面提到过的修改

<parlelport exit module>

void parlelport_exit(void) {
    /* 释放主设备号! */
    unregister_chrdev(parlelport_major, "parlelport");

    /* 释放端口! */
    if (!port) {
        release_region(0x378,1);
    }

    printk("<1>Removing parlelport module\n");
}

以文件方式打开设备

和 memory 驱动一样,打开设备时并不需要做什么

<parlelport open>

int parlelport_open(struct inode *inode, struct file *filp) {
    /* Success 直接返回打开成功 */
    return 0;
}

以文件方式关闭设备

一样,并不需要做什么

<parlelport release>

int parlelport_release(struct inode *inode, struct file *filp) {
    /* Success */
    return 0;
}

从设备读取数据

与 memory 类似,只是这次是从并口寄存器地址中读取数据。

<parlelport read>

ssize_t parlelport_read(struct file *filp, char *buf,
    size_t count, loff_t *f_pos) {

    /* 用于从设备读取的 1 字节缓冲区 */
    char parlelport_buffer;

    /* 读取并口 */
    parlelport_buffer = inb(0x378);

    /* 把读到的数据传递到用户空间 */
    copy_to_user(buf,&parlelport_buffer,1);

    /* 当前读取位置移动到合适位置 */
    if (*f_pos == 0) {
        *f_pos+=1;
        return 1;
    } else {
        return 0;
    }
}

向设备写入数据

和 memory 也差不多,不同的是现在直接写真实的设备地址。

<parlelport write>

ssize_t parlelport_write( struct file *filp, char *buf,
    size_t count, loff_t *f_pos) {

    char *tmp;

    /* 需要写入设备的内容 */
    char parlelport_buffer;

    tmp=buf+count-1;
    copy_from_user(&parlelport_buffer,tmp,1);

    /* 写入端口 */
    outb(parlelport_buffer,0x378);

    return 1;
}

用于测试并口的 LED 灯

这一节,我将详细介绍如何制作一个简单的硬件,这个硬件通过一些发光二极管使我们可以看到并口的当前状态(各个相关针脚的电平高低)。

警告:将设备连接到并行端口可能会损坏计算机。连接设备时,请确保您已正确接地并且计算机是关机状态。因为进行这个实验而出现的任何问题都是您自己负责。

电路图如图 3 所示。您还可以阅读 Zoller 的《PC 和电子产品:将您的 PC 连接到外部世界》(PC & Electronics: Connecting Your PC to the Outside World)作为参考。

 图3:监控并口状态的LED阵列电路图

为了正常使用我们的 parlelport 设备,您必须首先确保所有硬件都正确连接,关闭计算机并将设备连接到并口,然后开机并卸载掉所有与并行端口相关的设备驱动程序(例如 lp、parport、parport_pc 等)。Debian Sarge 发行版的 hotplug 模块特别烦人,要卸载掉。如果还没有创建设备文件 /dev/parlelport,用以下命令以 root 身份创建它:

# mknod /dev/parlelport c 61 0

修改权限让所有用户都可以读写这个设备文件

# chmod 666 /dev/parlelport

现在可以用 insmod 来加载 parlelport 驱动模块。您可以用下面的命令查看输入输出端口地址 0x378 是不是已经保留给 parlelport 模块:

# cat /proc/ioports

要点亮 LED 灯来验证系统工作正常,用下面的命令:

$ echo -n A >/dev/parlelport

这个命令应该点亮 0 和 6 号两盏灯,其它所有灯都是灭的。

您可以执行下面命令来检查并口的当前状态

$ cat /dev/parlelport

最终的应用程序:跑马灯

最终,我将开发一个漂亮的跑马灯程序,它使这个 LED 灯一个接一个的亮。为了这个目的,我们需要向 /dev/parlelport 设备每次只写入 1 个位是 1 的数据,然后连续不断地移位数据再写入。

<lights.c>

#include <stdio.h>
#include <unistd.h>
int main() {
    unsigned char byte,dummy;
    FILE * PARLELPORT;

    /* Opening the device parlelport, 打开 parlelport 设备 */
    PARLELPORT=fopen("/dev/parlelport","w");

    /* 把文件IO的缓冲取消掉,这样写入数据的函数一执行数据就会立即写入文件 */
    setvbuf(PARLELPORT,&dummy,_IONBF,1);

    /* 把最低1位置 1 ,其余位置 0*/
    byte=1;

    /* 死循环 */
    while (1) {
        /* 写入并口 */
        /* 点亮一盏灯 */
        printf("Byte value is %d\n",byte);
        fwrite(&byte,1,1,PARLELPORT);
        sleep(1);

        /* 左移一位更新字节内容 */
        byte<<=1;
        if (byte == 0) byte = 1;
    }

    fclose(PARLELPORT);

}

用通常编译用户程序的方式编译它

$ gcc -o lights lights.c

用下面命令运行这个程序

$ lights

跑马灯就开始表演了!如图4

图4:运行Linux的计算机和电路板上的LED灯。两个终端分别显示了正在运行的 lights 程序和 parlelport驱动模块加载的情况,Tux吉祥物正在密切关注事态。

结论

遵循这个简短的教程之后,您现在应该能够为简单的硬件如中继板(参见附录 C)编写自己的完整设备驱动程序,或者为复杂硬件编写最小的设备驱动程序。通过学习理解 Linux 内核背后的一些简单概念让您以一种快速而简单的方式来入门设备驱动程序的编写。而且,这将使您更进一步而成为真正的 Linux 内核开发人员。

Bibliography

A. Rubini, J. Corbert. 2001. Linux device drivers (second edition). Ed. O’Reilly. This book is available for free on the internet.

Jonathan Corbet. 2003/2004. Porting device drivers to the 2.6 kernel​​​​​​​. This is a very valuable resource for porting drivers to the new 2.6 Linux kernel and also for learning about Linux device drivers.

B. Zoller. 1998. PC & Electronics: Connecting Your PC to the Outside World (Productivity Series). Nowadays it is probably easier to surf the web for hardware projects like this one.

M. Waite, S. Prata. 1990. C Programming. Any other good book on C programming would suffice.

Appendix A. Complete Makefile

<Makefile> =
obj-m := nothing.o hello.o memory.o parlelport.o

Appendix B. Compiling the kernel on a Debian Sarge system

To compile a 2.6.x kernel on a Debian Sarge system you need to perform the following steps, which should be run as root:

1. Install the “kernel-image-2.6.x” package.

2. Reboot the machine to make this the running kernel image. This is done semi-automatically by Debian. You may need to tweak the lilo configuration file /etc/lilo.conf and then run lilo to achieve this.

3. Install the “kernel-source-2.6.x” package.

4. Change to the source code directory, cd /usr/src and unzip and untar the source code with bunzip2 kernel-source-2.6.x.tar.bz2 and tar xvf kernel-source-2.6.x.tar. Change to the kernel source directory with cd /usr/src/kernel-source-2.6.x

5. Copy the default Debian kernel configuration file to your local kernel source directory cp /boot/config-2.6.x .config.

6. Make the kernel and the modules with make and then make modules.

Appendix C. Exercises

If you would like to take on some bigger challenges, here are a couple of exercises you can do:

1. I once wrote two device drivers for two ISA Meilhaus boards, an analog to digital converter (ME26) and a relay control board (ME53). The software is available from the ADQ project. Get the newer PCI versions of these Meilhaus boards and update the software.

2. Take any device that doesn’t work on Linux, but has a very similar chipset to another device which does have a proven device driver for Linux. Try to modify the working device driver to make it work for the new device. If you achieve this, submit your code to the kernel and become a kernel developer yourself!

Comments and acknowledgements

Three years have elapsed since the first version of this document was written. It was originally written in Spanish and intended for version 2.2 of the kernel, but kernel 2.4 was already making its first steps at that time. The reason for this choice is that good documentation for writing device drivers, the Linux device drivers book (see bibliography), lagged the release of the kernel in some months. This new version is also coming out soon after the release of the new 2.6 kernel, but up to date documentation is now readily available in Linux Weekly News making it possible to have this document synchronized with the newest kernel.

Fortunately enough, PCs still come with a built-in parallel port, despite the actual trend of changing everything inside a PC to render it obsolete in no time. Let us hope that PCs still continue to have built-in parallel ports for some time in the future, or that at least, parallel port PCI cards are still being sold.

This tutorial has been originally typed using a text editor (i.e. emacs) in noweb format. This text is then processed with the noweb tool to create a LaTeX file ( .tex ) and the source code files ( .c ). All this can be done using the supplied makefile.document with the command make -f makefile.document.

I would like to thank the “Instituto Polit?ico de Bragan?rdquo;, the “N?o Estudantil de Linux del Instituto Polit?ico de Bragan?(NUX)”, the “Asociaci?e Software Libre de Le?SLe?rdquo; and the “N?o de Estudantes de Engenharia Inform?ca da Universidade de ?ora” for making this update possible.

Biography

Xavier Calbet (/user/42" title="View user profile.): Xavier Calbet (xcalbet AT googlemail DOT com) is a long time free software user who started using Linux distributions in the ancient times when Slackware had to be installed using tens of floppy disks. He is currently working on his two pet projects: a meteorological field and satellite image display system, SAPO (http://sourceforge.net/projects/sapo), and the best available free numerical computer language to date, PDL (Perl Data Language) (http://pdl.perl.org/).

Copyright information

Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.2 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is available at http://www.gnu.org/copyleft/fdl.html.

Source URL:

http://www.freesoftwaremagazine.com/articles/drivers_linux

  • 6
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值