想了解Linux字符设备驱动?来看这篇!

9前言与所需环境

由于驱动程序是内核程序,所以我们所必需的有

1.Linux内核,可以在Linux官网下载,我这里使用了Linux3.14

2.交叉编译工具链,用于交叉编译环境

一些其他的环境,诸如根文件系统,设备树文件等倘若需要可自行添加,如果只是用虚拟机模拟驱动那这两个已经足矣。

什么是设备驱动程序

英文:Device Driver

简称:驱动(Driver)

一种添加到操作系统中的特殊程序,主要作用是协助操作系统完成应用程序与对应硬件设备之间数据传送的功能

简言之,设备驱动程序就是操作系统中“驱动”对应硬件设备使之能正常工作的代码。

一个驱动程序主要完成如下工作:

  1. 初始化设备,让设备做好开始工作的准备
  2. 读数据:将设备产生的数据传递给上层应用程序
  3. 写数据:将上层应用程序交付过来的数据传递给设备
  4. 获取设备信息:协助上层应用程序获取设备的属性、状态信息
  5. 设置设备信息:让上层应用程序可以决定设备的一些工作属性、模式
  6. 其它相关操作:如休眠、唤醒、关闭设备等

其中最核心的工作就是设备数据的输入和输出,因此计算机外部设备(外设)也被称为IO设备

什么是字符设备

字符设备驱动是Linux系统中用于管理和控制字符设备的一类驱动程序。字符设备是指那些以字节流方式进行数据读写的设备,这类设备不能随机访问数据,只能按照顺序读取或写入。常见的字符设备包括鼠标、键盘、串口、控制台等。字符设备驱动为这些设备提供了必要的接口,使得用户程序能够通过统一的文件操作接口来访问这些设备。

字符设备驱动的核心概念

  1. 设备号:每个字符设备都有一个唯一的设备号,用于区分不同的设备。设备号由主设备号和次设备号组成,主设备号标识设备类型,次设备号在同一类型下区分不同的设备实例。

  2. cdev结构体:在Linux内核中,字符设备通过cdev结构体来抽象表示。这个结构体包含了设备的一些基本信息,如设备号、指向操作函数的指针等。通过cdev结构体,内核能够知道如何操作具体的字符设备。

  3. file_operations结构体:这是字符设备驱动中最关键的部分之一,它包含了设备操作的一系列函数指针,如openreleasereadwrite等。这些函数指针指向驱动程序中定义的具体实现函数,当用户程序对字符设备进行文件操作时,内核会调用这些函数来执行相应的操作。

字符设备驱动的主要工作

  1. 初始化:在驱动加载时,进行必要的初始化工作,如分配设备号、注册cdev结构体等。

  2. 设备注册与注销:使用register_chrdev函数注册字符设备,并在驱动卸载时使用unregister_chrdev函数注销设备。这两个函数负责将字符设备的信息添加到内核中或从内核中移除。

  3. 实现操作函数:在file_operations结构体中填充具体的操作函数实现,如openreleasereadwrite等。这些函数定义了设备的基本操作行为。

  4. 数据交互:在用户空间和内核空间之间进行数据交互时,需要使用特定的函数(如copy_to_usercopy_from_user)来安全地传输数据。

字符设备驱动的应用

字符设备驱动是Linux系统中最基本的一类设备驱动,它为系统提供了与字符设备进行交互的能力。通过字符设备驱动,用户程序可以方便地控制各种字符设备,实现数据的输入和输出。例如,用户可以通过编写程序来操作串口设备,实现数据的发送和接收;或者通过操作LED设备驱动来控制LED灯的亮灭等。

总之,字符设备驱动是Linux系统中不可或缺的一部分,它为字符设备提供了必要的接口和支持,使得用户程序能够方便地访问和控制这些设备。

代码示例与讲解

在了解了字符设备驱动的概念后,我们先来看一段代码

#include <linux/module.h>
#include <linux/kernel.h>

int __init myhello_init(void)
{
    printk("#####################################################\n");
    printk("#####################################################\n");
    printk("#####################################################\n");
    printk("#####################################################\n");
    printk("myhello is running\n");
    printk("#####################################################\n");
    printk("#####################################################\n");
    printk("#####################################################\n");
    printk("#####################################################\n");
    return 0;
}

void __exit myhello_exit(void)
{
    printk("myhello will exit\n");
}
MODULE_LICENSE("GPL");
module_init(myhello_init);
module_exit(myhello_exit);

这是一个名为linux-3.14/driver/char/myhello.c的示例文件,它和我们平常写的应用文件有些许不同,比如函数前面有一个__init,__exit的声明,输入语句用的是printk而非printf,后面还有一堆不知什么意思的函数,别着急,接下来我将一个个向你解释。

代码解析

我们先从头文件看起

#include <linux/module.h>

#include <linux/kernel.h>

这是内核程序几乎必须包含的两个头文件,module.h中包含内核编程最常用的函数声明,如printk;kernel.h包含模块编程相关的宏定义,如:MODULE_LICENSE。

int __init myhello_init(void)

这个函数和我们平时写的函数几乎一样,那么前面的__init有什么作用呢?

__init的作用 :

  1. 一个宏,展开后为:attribute ((section (".init.text"))) 实际是gcc的一个特殊链接标记
  2. 指示链接器将该函数放置在 .init.text区段
  3. 在模块插入时方便内核从ko文件指定位置读取入口函数的指令到特定内存位置

看不懂?没关系,只需要知道在我们写内核程序初始化函数时需要包含这个声明。

有了这个声明,下文的 module_init(myhello_init); 就可以与这个函数配合完成调用。

printk();

为什么要用printk而非printf呢?

因为内核是裸机程序,不可以调用C库中printf函数来打印程序信息, Linux内核源码自身实现了一个用法与printf差不多的函数,命名为printk (k-kernel) printk不支持浮点数打印。

接下来的myhello_exit与其大同小异

MODULE_LICENSE("GPL");

MODULE_LICENSE(字符串常量); 字符串常量内容为源码的许可证协议 可以是"GPL" "GPL v2" "GPL and additional rights" "Dual BSD/GPL" "Dual MIT/GPL" "Dual MPL/GPL"等, "GPL"最常用

其本质也是一个宏,宏体也是一个特殊链接标记,指示链接器在ko文件指定位置说明本模块源码遵循的许可证 在模块插入到内核时,内核会检查新模块的许可证是不是也遵循GPL协议,如果发现不遵循GPL,则在插入模块时打印抱怨信息: myhello:module license 'unspecified' taints kernel Disabling lock debugging due to kernel taint 也会导致新模块没法使用一些内核其它模块提供的高级功能

又看不懂了?没关系,只需要知道这句话是对GPL协议的声明,写程序时加上就好。

module_init(myhello_init);

module_init 宏

  1. 用法:module_init(模块入口函数名)
  2. 动态加载模块,对应函数被调用
  3. 静态加载模块,内核启动过程中对应函数被调用
  4. 对于静态加载的模块其本质是定义一个全局函数指针,并将其赋值为指定函数,链接时将地址放到特殊区段(.initcall段),方便系统初始化统一调用。
  5. 对于动态加载的模块,由于内核模块的默认入口函数名是init_module,用该宏可以给对应模块入口函数起别名 

这句宏与上文的__init配合,得以实现外部的初始化调用。

向内核添加新功能

好了,了解了这部分代码是什么意思,那么它有什么用呢?

如果了解过Linux的内核移植,就会知道Linux内核在编译完之后会生成一个uImage文件,而我们在

linux-3.14/driver/char下插入这个.c文件,在经过下列的一些操作,我们就可以完成向Linux内核添加模块文件。

1.静态加载法

说起静态加载法,你的第一印象一定是在运行前就加载(就如同静态分配内存一般),而静态加载法就是这般,接下来我将向你展示详细步骤。

1.新功能源码与Linux内核源码在同一目录结构下

        在linux-3.14/driver/char/目录下编写myhello.c(就是前文的代码)

2.给新功能代码配置Kconfig

#进入myhello.c的同级目录

cd ~/fs4412/linux-3.14/drivers/char

vim Kconfig #39行处添加如下内容:

config MY_HELLO

        tristate "This is a hello test"

        help

        This is a test for kernel new function

在Kconfig中添加的文件除了config MY_HELLO外都是提示信息

3.给新功能代码改写Makefile

#进入myhello.c的同级目录

cd ~/fs4412/linux-3.14/drivers/char

vim Makefile

#拷贝18行,粘贴在下一行,修改成:

obj-$(CONFIG_MY_HELLO) += myhello.o

4.make menuconfig 界面里将新功能对应的那项选择成<*>

注:menuconfig是一个图形化的内核配置界面,可以让我们不必考虑一些依赖和前提方便的修改配置代码

cd ~/fs4412/linux-3.14

make menuconfig

#make menuconfig如果出错,一般是两个原因:

#1. libncurses5-dev没安装

#2. 命令行界面太小(太矮或太窄或字体太大了)

在这里我们可以看到我们刚才写入kconfig中的提示信息

选中它,按空格或者y将其设为<*>,用方向键选择exit,回车退出。

选中yes保存。

5.make uImage

6.cp arch/arm/boot/uImage  /tftpboot

7.启动开发板观察串口终端中的打印信息

没有开发板的朋友了解即可,如果uImage编译成功基本没什么问题。

2.动态加载法

即新功能源码与内核其它源码不一起编译,而是独立编译成内核的插件(被称为内核模块)文件.ko

新功能源码与Linux内核源码在同一目录结构下时

  1. 给新功能代码配置Kconfig

  2. 给新功能代码改写Makefile

  3. make menuconfig 界面里将新功能对应的那项选择成

  4. make uImage

  5. cp arch/arm/boot/uImage /tftpboot

  6. make modules

    make modules会在新功能源码的同级目录下生成相应的同名.ko文件(生成的ko文件只适用于开发板linux)

    注意此命令执行前,开发板的内核源码已被编译

b、新功能源码与Linux内核源码不在同一目录结构下时

  1. cd ~/fs4412
  2. mkdir mydrivercode
  3. cd mydrivercode
  4. cp ../linux-3.14/drivers/char/myhello.c .
  5. vim Makefile
  6. make (生成的ko文件适用于主机ubuntu linux)
  7. make ARCH=arm (生成的ko文件适用于开发板linux,注意此命令执行前,开发板的内核源码已被编译)

file命令可以查看指定ko文件适用于哪种平台,用法:

file ko文件

#结果带x86字样的适用于主机ubuntu linux,带arm字样的适用于开发板linux

c、主机ubuntu下使用ko文件

sudo insmod ./???.ko

#此处为内核模块文件名,将内核模块插入正在执行的内核中运行 ----- 相当于安装插件

lsmod

#查看已被插入的内核模块有哪些,显示的是插入内核后的模块名

sudo rmmod ???

#,此处为插入内核后的模块名,此时将已被插入的内核模块从内核中移除 ----- 相当于卸载插件

sudo dmesg -C

#清除内核已打印的信息

dmesg #查看内核的打印信息

d、开发板Linux下使用ko文件

#先将生成的ko文件拷贝到/opt/4412/rootfs目录下:

cp ????/???.ko /opt/4412/rootfs

#在串口终端界面开发板Linux命令行下执行

insmod ./???.ko #将内核模块插入正在执行的内核中运行 ----- 相当于安装插件

lsmod #查看已被插入的内核模块有哪些

rmmod ??? #将已被插入的内核模块从内核中移除 ----- 相当于卸载插件

内核随时打印信息,我们可以在串口终端界面随时看到打印信息,不需要dmesg命令查看

3.内核模块的多源文件编程

倘若我们写的代码并没有放在driver/char目录下,就可以在放代码的目录下添加一个Makefile,内容如下:

ifeq ($(KERNELRELEASE),)

ifeq ($(ARCH),arm)
KERNELDIR ?= 目标板linux内核源码顶层目录的绝对路径
ROOTFS ?= 目标板根文件系统顶层目录的绝对路径
else
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
endif
PWD := $(shell pwd)

modules:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

modules_install:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) INSTALL_MOD_PATH=$(ROOTFS) modules_install

clean:
    rm -rf  *.o  *.ko  .*.cmd  *.mod.*  modules.order  Module.symvers   .tmp_versions

else
obj-m += hello.o

endif

Makefile中:

obj-m用来指定模块名,注意模块名加.o而不是.ko

可以用 模块名-objs 变量来指定编译到ko中的所有.o文件名(每个同名的.c文件对应的.o目标文件)

一个目录下的Makefile可以编译多个模块:

添加:obj-m += 下一个模块名.o

内核模块信息宏

MODULE_AUTHOR(字符串常量); //字符串常量内容为模块作者说明

MODULE_DESCRIPTION(字符串常量); //字符串常量内容为模块功能说明

MODULE_ALIAS(字符串常量); //字符串常量内容为模块别名

这些宏用来描述一些当前模块的信息,可选宏

这些宏的本质是定义static字符数组用于存放指定字符串内容,这些字符串内容链接时存放在.modinfo字段,可以用modinfo命令来查看这些模块信息,用法:

modinfo  模块文件名

内核空间和用户空间

为了彻底解决一个应用程序出错不影响系统和其它app的运行,操作系统给每个app一个独立的假想的地址空间,这个假想的地址空间被称为虚拟地址空间(也叫逻辑地址),操作系统也占用其中固定的一部分,32位Linux的虚拟地址空间大小为4G,并将其划分两部分:

  1. 0~3G 用户空间 :每个应用程序只能使用自己的这份虚拟地址空间

    1. 3G~4G 内核空间:内核使用的虚拟地址空间,应用程序不能直接使用这份地址空间,但可以通过一些系统调用函数与其中的某些空间进行数据通信

实际内存操作时,需要将虚拟地址映射到实际内存的物理地址,然后才进行实际的内存读写

执行流

执行流:有开始有结束总体顺序执行的一段独立代码,又被称为代码上下文

计算机系统中的执行流的分类:

执行流:

  1. 任务流--任务上下文(都参与CPU时间片轮转,都有任务五状态:就绪态 运行态 睡眠态 僵死态 暂停态)
    1. 进程
    2. 线程
      1. 内核线程:内核创建的线程
      2. 应用线程:应用进程创建的线程
  2. 异常流--异常上下文
    1. 中断
    2. 其它异常

应用编程可能涉及到的执行流:

  1. 进程
  2. 线程

内核编程可能涉及到的执行流:

  1. 应用程序自身代码运行在用户空间,处于用户态 ----------------- 用户态app
  2. 应用程序正在调用系统调用函数,运行在内核空间,处于内核态,即代码是内核代码但处于应用执行流(即属于一个应用进程或应用线程) ---- 内核态app
  3. 一直运行于内核空间,处于内核态,属于内核内的任务上下文 --------- 内核线程
  4. 一直运行于内核空间,处于内核态,专门用来处理各种异常 --------- 异常上下文

模块编程与应用编程的比较

内核接口头文件查询

大部分API函数包含的头文件在include/linux目录下,因此:

  1. 首先在include/linux 查询指定函数:grep 名称 ./ -r -n
  2. 找不到则更大范围的include目录下查询,命令同上

字符设备驱动

了解了内核模块的编程和使用,就可以进入字符设备驱动的环节了。

同样,我们先来看代码

#include<linux/module.h>
#include<linux/kernel.h>
#include<linux/fs.h>
#include<linux/cdev.h>
#include<asm/uaccess.h>
#include "mychar.h"

#define BUF_LEN 100

int major = 11; 
int minor = 0;
int mychar_num =1;

struct mychar_dev
{
    struct cdev mydev;

    char mydev_buf[BUF_LEN];
    int curlen;
}

struct mychar_dev gmydev;

int mychar_open(struct inode *pnode,struct file *pfile)
{
    pfile->private_data = (void *)(container_of(pnode->i_cdev,struct mychar_dev,mydev));
    printk("mychar_open is called\n");
    return 0;
}

int mychar_close(struct inode *pnode,struct file *pfile)
{
    printk("mychar_close is called\n");
    return 0;
}

ssize_t mychar_read(struct file *pfile,char __user *puser,size_t count,loff_t *p_pos)
{
    struct mychar_dev *gmydev = (struct mychar_dev *)pfile->private_data;
    int size = 0;
    int ret = 0;

    if(count>gmydev->curlen)
    {
        size = gmydev->curlen;
    }
   else
    {
        size = count;
    }
    ret = copy_to_user(puser,gmydev->mydev_buf,size);
    if(ret
    {
        printk("copy_to_user failed\n");
        return -1;
    }

    memcpy(gmydev->mydev_buf,gmydev->mydev_buf + size,gmydev->curlen - size);

    gmydev->curlen = size;

    return size;

}

ssize_t mychar_write(struct file *pfile,char __user *puser,size_t count,loff_t *p_pos)
{
    struct mychar_dev *gmydev = (struct mychar_dev *)pfile->private_data;
    int size = 0;
    int ret = 0;

    if(count>BUF_LEN-gmydev->curlen)
    {
        size = BUF_LEN-gmydev->curlen;
    }
    else
    {
        size = count;
    }
    ret = copy_from_user(gmydev->mydev_buf + gmydev->curlen,puser,size);
    if(ret
    {
        printk("copy_from_user failed\n");
        return -1;
    }
    gmydev->curlen += size;

    return size;

}

long mychar_ioctl(struct file *pfile,unsigned int cmd,unsigned long arg)
{
	int __user *pret = (int *)arg;
	int maxlen = BUF_LEN;
	int ret = 0;
	struct mychar_dev *pmydev = (struct mychar_dev *)pfile->private_data;

	switch(cmd)
	{
		case MYCHAR_IOCTL_GET_MAXLEN:
			ret = copy_to_user(pret,&maxlen,sizeof(int));
			if(ret)
			{
				printk("copy_to_user MAXLEN failed\n");
				return -1;
			}
			break;
		case MYCHAR_IOCTL_GET_CURLEN:
			mutex_lock(&pmydev->lock);
			ret = copy_to_user(pret,&pmydev->curlen,sizeof(int));
			mutex_unlock(&pmydev->lock);
			if(ret)
			{
				printk("copy_to_user CURLEN failed\n");
				return -1;
			}
			break;
		default:
			printk("The cmd is unknow\n");
			return -1;

	}

	return 0;
}

struct file_operations myops ={
    .owner = THIS_MODULE,
    .open = mychar_open,
    .release = mychar_close,
    .read = mychar_read,
    .unlocked_ioctl = mychar_ioctl,
    .write = mycahr_write,
};

int __init mychar_init(void)
{
    int ret = 0;
    dev_t devno = MKDEV(major,minor);


    ret = register_chrdev_region(devno,mychar_num,"mychar");
    if(ret)
    {
        ret = alloc_chrdev_region(&devno,minor,mychar_num,"mychar");
        if(ret)
        {
            printk("get devno failed\n");
            return -1;
        }
        major = MAJOR(devno);
    }

    cdev_init(&gmydev.mydev,&myops);

    mydev.owner = THIS_MODULE;
    cdev_add(&gmydev->mydev.devno,mychar_num);

    return 0;

}

void __exit mychar_exit(void)
{
    dev_t devno = MKDEV(major,minor);

    cdev_del(&gmydev->mydev);

    unregister_chrdev_region(devno,mychar_num);
}



MODULE_LICENSE("GPL");

module_init(mychar_init);
module_exit(mychar_exit);

相比于上一篇代码,这篇代码要复杂得多,所以我们先来看理论部分

一、Linux内核对设备的分类

linux的文件种类:

  1. -:普通文件
  2. d:目录文件
  3. p:管道文件
  4. s:本地socket文件
  5. l:链接文件
  6. c:字符设备
  7. b:块设备

Linux内核按驱动程序实现模型框架的不同,将设备分为三类:

  1. 字符设备:按字节流形式进行数据读写的设备,一般情况下按顺序访问,数据量不大,一般不设缓存
  2. 块设备:按整块进行数据读写的设备,最小的块大小为512字节(一个扇区),块的大小必须是扇区的整数倍,Linux系统的块大小一般为4096字节,随机访问,设缓存以提高效率
  3. 网络设备:针对网络数据收发的设备

二、设备号------内核中同类设备的区分

内核用设备号来区分同类里不同的设备,设备号是一个无符号32位整数,数据类型为dev_t,设备号分为两部分:

  1. 主设备号:占高12位,用来表示驱动程序相同的一类设备
  2. 次设备号:占低20位,用来表示被操作的哪个具体设备

应用程序打开一个设备文件时,通过设备号来查找定位内核中管理的设备。

MKDEV宏用来将主设备号和次设备号组合成32位完整的设备号,用法:

dev_t devno;

int major = 251;//主设备号

int minor = 2;//次设备号

devno = MKDEV(major,minor);

MAJOR宏用来从32位设备号中分离出主设备号,用法:

dev_t devno = MKDEV(249,1);

int major = MAJOR(devno);

MINOR宏用来从32位设备号中分离出次设备号,用法:

dev_t devno = MKDEV(249,1);

int minor = MINOR(devno);

如果已知一个设备的主次设备号,应用层指定好设备文件名,那么可以用mknod命令在/dev目录创建代表这个设备的文件,即此后应用程序对此文件的操作就是对其代表的设备操作,mknod用法如下:

@ cd /dev
@ mknod 设备文件名 设备种类(c为字符设备,b为块设备)  主设备号  次设备号    //ubuntu下需加sudo执行

在应用程序中如果要创建设备可以调用系统调用函数mknod,其原型如下:

int mknod(const char *pathname,mode_t mode,dev_t dev);
pathname:带路径的设备文件名,无路径默认为当前目录,一般都创建在/dev下
mode:文件权限 位或 S_IFCHR/S_IFBLK
dev:32位设备号
返回值:成功为0,失败-1

三、申请和注销设备号

字符驱动开发的第一步是通过模块的入口函数向内核添加本设备驱动的代码框架,主要完成:

  1. 申请设备号
  2. 定义、初始化、向内核添加代表本设备的结构体元素
```
int register_chrdev_region(dev_t from, unsigned count, const char *name)
功能:手动分配设备号,先验证设备号是否被占用,如果没有则申请占用该设备号
参数:
    from:自己指定的设备号
    count:申请的设备数量
    name:/proc/devices文件中与该设备对应的名字,方便用户层查询主设备号
返回值:
    成功为0,失败负数,绝对值为错误码
```
int alloc_chrdev_region(dev_t *dev,unsigned baseminor,unsigned count, const char *name)
功能:动态分配设备号,查询内核里未被占用的设备号,如果找到则占用该设备号
参数:
    dev:分配设备号成功后用来存放分配到的设备号
    baseminior:起始的次设备号,一般为0
    count:申请的设备数量
    name:/proc/devices文件中与该设备对应的名字,方便用户层查询主次设备号
返回值:
    成功为0,失败负数,绝对值为错误码

分配成功后在/proc/devices 可以查看到申请到主设备号和对应的设备名,mknod时参数可以参考查到的此设备信息

void unregister_chrdev_region(dev_t from, unsigned count)
功能:释放设备号
参数:
    from:已成功分配的设备号将被释放
    count:申请成功的设备数量

释放后/proc/devices文件对应的记录消失

四、注册字符设备

struct cdev
{
    struct kobject kobj;//表示该类型实体是一种内核对象
    struct module *owner;//填THIS_MODULE,表示该字符设备从属于哪个内核模块
    const struct file_operations *ops;//指向空间存放着针对该设备的各种操作函数地址
    struct list_head list;//链表指针域
    dev_t dev;//设备号
    unsigned int count;//设备数量
};

自己定义的结构体中必须有一个成员为 struct cdev cdev,两种方法定义一个设备:

  1. 直接定义:定义结构体全局变量

  2. 动态申请:

    struct cdev * cdev_alloc()

void cdev_init(struct cdev *cdev,const struct file_operations *fops)

struct file_operations 
{
   struct module *owner;           //填THIS_MODULE,表示该结构体对象从属于哪个内核模块
   int (*open) (struct inode *, struct file *);    //打开设备
   int (*release) (struct inode *, struct file *);    //关闭设备
   ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);    //读设备
   ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);    //写设备
   loff_t (*llseek) (struct file *, loff_t, int);        //定位
   long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);//读写设备参数,读设备状态、控制设备
   unsigned int (*poll) (struct file *, struct poll_table_struct *);    //POLL机制,实现多路复用的支持
   int (*mmap) (struct file *, struct vm_area_struct *); //映射内核空间到用户层
   int (*fasync) (int, struct file *, int); //信号驱动
   //......
};

这个结构体是不是看起来非常复杂?实际用起来非常简单,就如同上文代码中就有它的调用

struct file_operations myops ={
    .owner = THIS_MODULE,
    .open = mychar_open,
    .release = mychar_close,
    .read = mychar_read,
    .write = mycahr_write,
};

owner成员通常情况下都赋为THIS_MODULE

其余成员赋值为对应的操作函数即可,而struct file_operations这个对象各个函数指针成员都对应相应的系统调用函数,应用层通过调用系统函数来间接调用这些函数指针成员指向的设备驱动函数,简单来说,就是对文件操作的集合。

一般定义一个struct file_operations类型的全局变量并用自己实现各种操作函数名对其进行初始化

int cdev_add(struct cdev *p,dev_t dev,unsigned int count)
功能:将指定字符设备添加到内核
参数:
    p:指向被添加的设备
    dev:设备号
    count:设备数量,一般填1
void cdev_del(struct cdev *p)
功能:从内核中移除一个字符设备
参数:
    p:指向被移除的字符设备

小结:

字符设备驱动开发步骤:

  1. 如果设备有自己的一些控制数据,则定义一个包含struct cdev cdev成员的结构体struct mydev,其它成员根据设备需求,设备简单则直接用struct cdev
  2. 定义一个struct mydev或struct cdev的全局变量来表示本设备;也可以定义一个struct mydev或struct cdev的全局指针(记得在init时动态分配)
  3. 定义三个全局变量分别来表示主设备号、次设备号、设备数
  4. 定义一个struct file_operations结构体变量,其owner成员置成THIS_MODULE
  5. module init函数流程:a. 申请设备号 b. 如果是全局设备指针则动态分配代表本设备的结构体元素 c. 初始化struct cdev成员 d. 设置struct cdev的owner成员为THIS_MODULE e. 添加字符设备到内核
  6. module exit函数:a. 注销设备号 b. 从内核中移除struct cdev c. 如果如果是全局设备指针则释放其指向空间
  7. 编写各个操作函数并将函数名初始化给struct file_operations结构体变量

验证操作步骤:

  1. 编写驱动代码mychar.c
  2. make生成ko文件
  3. insmod内核模块
  4. 查阅字符设备用到的设备号(主设备号):cat /proc/devices | grep 申请设备号时用的名字
  5. 创建设备文件(设备节点) : mknod /dev/??? c 上一步查询到的主设备号 代码中指定初始次设备号
  6. 编写app验证驱动(testmychar_app.c)
  7. 编译运行app,dmesg命令查看内核打印信息

五、字符设备驱动框架解析

设备的操作函数如果比喻是桩的话(性质类似于设备操作函数的函数,在一些场合被称为桩函数),则:

驱动实现设备操作函数 ----------- 做桩

insmod调用的init函数主要作用 --------- 钉桩

rmmod调用的exitt函数主要作用 --------- 拔桩

应用层通过系统调用函数间接调用这些设备操作函数 ------- 用桩

两个操作函数中常用的结构体说明

不知道你是否对这行代码感到疑惑

别着急,接下来我将介绍两个非常重要的结构体

内核中记录文件元信息的结构体
struct inode
{
    //....
    dev_t  i_rdev;//设备号
    struct cdev  *i_cdev;//如果是字符设备才有此成员,指向对应设备驱动程序中的加入系统的struct cdev对象
    //....
}
/*
    1. 内核中每个该结构体对象对应着一个实际文件,一对一
    2. open一个文件时如果内核中该文件对应的inode对象已存在则不再创建,不存在才创建
    3. 内核中用此类型对象关联到对此文件的操作函数集(对设备而言就是关联到具体驱动代码)
*/

这个结构体就像一张存储着文件信息的“表”,我们不必非常了解,只需要记住i_rdev和*i_cdev这两个常用成员即可

读写文件内容过程中用到的一些控制性数据组合而成的对象------文件操作引擎(文件操控器)
struct file
{
    //...
    mode_t f_mode;//不同用户的操作权限,驱动一般不用
    loff_t f_pos;//position 数据位置指示器,需要控制数据开始读写位置的设备有用
    unsigned int f_flags;//open时的第二个参数flags存放在此,驱动中常用
    struct file_operations *f_op;//open时从struct inode中i_cdev的对应成员获得地址,驱动开发中用来协助理解工作原理,内核中使用
    void *private_data;//本次打开文件的私有数据,驱动中常来在几个操作函数间传递共用数据
    struct dentry *f_dentry;//驱动中一般不用,除非需要访问对应文件的inode,用法flip->f_dentry->d_inode
    int refcnt;//引用计数,保存着该对象地址的位置个数,close时发现refcnt为0才会销毁该struct file对象
    //...
};
/*
    1. open函数被调用成功一次,则创建一个该对象,因此可以认为一个该类型的对象对应一次指定文件的操作
    2. open同一个文件多次,每次open都会创建一个该类型的对象
    3. 文件描述符数组中存放的地址指向该类型的对象
    4. 每个文件描述符都对应一个struct file对象的地址
*/

这个结构体与PCB和句柄类似,可以用来操作文件,我们需要了解的只有以下三个

unsigned int f_flags;//open时的第二个参数flags存放在此,驱动中常用

struct  file_operations *f_op;//open时从struct inode中i_cdev的对应成员获得地址,驱动开发中用来协助理解工作原理,内核中使用

void *private_data;//本次打开文件的私有数据,驱动中常来在几个操作函数间传递共用数据

f_flags就是open时的第二个参数,例如O_RDONLY等

*f_op就是上文提到的文件操作集合

*private_data可以用来存放一些想要传达的数据

除了这些,还有一个宏函数container:

已知成员的地址获得所在结构体变量的地址:container_of(成员地址,结构体类型名,成员在结构体中的名称)

我们可以通过这个宏函数获得结构体变量的地址。

所以这句代码就很好懂了,通过mydev的地址获得mychar_dev类型的gmydev对象的地址,传入private_data中。

六、读写操作实现

ssize_t xxx_read(struct file *filp, char __user *pbuf, size_t count, loff_t *ppos);
完成功能:读取设备产生的数据
参数:
    filp:指向open产生的struct file类型的对象,表示本次read对应的那次open
    pbuf:指向用户空间一块内存,用来保存读到的数据
    count:用户期望读取的字节数
    ppos:对于需要位置指示器控制的设备操作有用,用来指示读取的起始位置,读完后也需要变更位置指示器的指示位置
 返回值:
    本次成功读取的字节数,失败返回-1
ssize_t xxx_write (struct file *filp, const char __user *pbuf, size_t count, loff_t *ppos);  
完成功能:向设备写入数据
参数:
    filp:指向open产生的struct file类型的对象,表示本次write对应的那次open
    pbuf:指向用户空间一块内存,用来保存被写的数据
    count:用户期望写入的字节数
    ppos:对于需要位置指示器控制的设备操作有用,用来指示写入的起始位置,写完后也需要变更位置指示器的指示位置
 返回值:
    本次成功写入的字节数,失败返回-1

读写现在来看就非常简单了,不过还有一个需要注意的点:

前文提到过内核空间和用户空间,这两个空间逻辑上相互独立不能任意跨界读写,所以为了让两个空间通信,Linux提供了两个函数

unsigned long copy_from_user (void * to, const void __user * from, unsigned long n)

成功为返回0,失败非0

unsigned long copy_to_user (void __user * to, const void * from, unsigned long n)

成功为返回0,失败非0

七、ioctl操作实现

long xxx_ioctl (struct file *filp, unsigned int cmd, unsigned long arg);
功能:对相应设备做指定的控制操作(各种属性的设置获取等等)
参数:
    filp:指向open产生的struct file类型的对象,表示本次ioctl对应的那次open
    cmd:用来表示做的是哪一个操作
    arg:和cmd配合用的参数
返回值:成功为0,失败-1

cmd组成

  • dir(direction),ioctl 命令访问模式(属性数据传输方向),占据 2 bit,可以为 _IOC_NONE、_IOC_READ、_IOC_WRITE、_IOC_READ | _IOC_WRITE,分别指示了四种访问模式:无数据、读数据、写数据、读写数据;
  • type(device type),设备类型,占据 8 bit,在一些文献中翻译为 “幻数” 或者 “魔数”,可以为任意 char 型字符,例如 ‘a’、’b’、’c’ 等等,其主要作用是使 ioctl 命令有唯一的设备标识;
  • nr(number),命令编号/序数,占据 8 bit,可以为任意 unsigned char 型数据,取值范围 0~255,如果定义了多个 ioctl 命令,通常从 0 开始编号递增;
  • size,涉及到 ioctl 函数 第三个参数 arg ,占据 13bit 或者 14bit(体系相关,arm 架构一般为 14 位),指定了 arg 的数据类型及长度,如果在驱动的 ioctl 实现中不检查,通常可以忽略该参数;

我们可以定义一写宏放在头文件中,以方便ioctl操作的实现

mychar.h

#ifndef MY_CHAR_H
#define MY_CHAR_H

#include <asm/ioctl.h>

#define MY_CHAR_MAGIC 'k'

#define MYCHAR_IOCTL_GET_MAXLEN _IOR(MY_CHAR_MAGIC,1,int*)
#define MYCHAR_IOCTL_GET_CURLEN _IOR(MY_CHAR_MAGIC,2,int*)


#endif

此外还有一些宏,了解即可

#define _IOC(dir,type,nr,size) (((dir)<<_IOC_DIRSHIFT)| \
                               ((type)<<_IOC_TYPESHIFT)| \
                               ((nr)<<_IOC_NRSHIFT)| \
                               ((size)<<_IOC_SIZESHIFT))
/* used to create numbers */

// 定义不带参数的 ioctl 命令
#define _IO(type,nr)   _IOC(_IOC_NONE,(type),(nr),0)

//定义带读参数的ioctl命令(copy_to_user) size为类型名
#define _IOR(type,nr,size)  _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))

//定义带写参数的 ioctl 命令(copy_from_user) size为类型名
#define _IOW(type,nr,size)  _IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))

//定义带读写参数的 ioctl 命令 size为类型名
#define _IOWR(type,nr,size) _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))

/* used to decode ioctl numbers */
#define _IOC_DIR(nr)        (((nr) >> _IOC_DIRSHIFT) & _IOC_DIRMASK)
#define _IOC_TYPE(nr)       (((nr) >> _IOC_TYPESHIFT) & _IOC_TYPEMASK)
#define _IOC_NR(nr)     (((nr) >> _IOC_NRSHIFT) & _IOC_NRMASK)
#define _IOC_SIZE(nr)      (((nr) >> _IOC_SIZESHIFT) & _IOC_SIZEMASK)

测试

好了,有了这些,我们就可以开始测试了

test.c

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

#include <stdio.h>

#include "mychar.h"
int main(int argc,char *argv[])
{
	int fd = -1;
	char buf[8] = "";
	int max = 0;
	int cur = 0;

	if(argc < 2)
	{
		printf("The argument is too few\n");
		return 1;
	}

	fd = open(argv[1],O_RDWR);
	if(fd < 0)
	{
		printf("open %s failed\n",argv[1]);
		return 2;
	}

	ioctl(fd,MYCHAR_IOCTL_GET_MAXLEN,&max);
	printf("max len is %d\n",max);

	write(fd,"hello",6);
	
	ioctl(fd,MYCHAR_IOCTL_GET_CURLEN,&cur);
	printf("cur len is %d\n",cur);

	read(fd,buf,8);
	printf("buf=%s\n",buf);

	close(fd);
	fd = -1;
	return 0;
}

注意,测试程序运行在用户空间

测试命令

./test ./dev/设备名

好了,这下就可以暂时告一段落了,现在我们对字符驱动设备已经有了初步的了解,后续仍待补充...

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值