公众号:嵌入式攻城狮(ID:andyxi_linux)

作者:安迪西

 

1. 字符设备驱动简介

 

字符设备是Linux驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据是分先后顺序的。比如常见的点灯、按键、IIC、SPI、LCD 等等都是字符设备,这些设备的驱动就叫做字符设备驱动。

 

Linux驱动基本原理:Linux中一切皆为文件,驱动加载成功后会在/dev目录下生成一个相应的文件,应用程序通过对这个名为/dev/xxx的文件进行相应的操作即可实现对硬件的操作。

 

比如LED驱动,会有/dev/led驱动文件,应用程序使用open函数来打开该文件;若要点亮或关闭led,就使用write函数写入开关值;若要获取led灯的状态,就用read函数从驱动文件中读取相应的状态;使用完成后使用close函数关闭该驱动文件。

 

Linux软件从上到下可分为4层结构,如下图左示。以控制LED为例,具体过程如下图右示:

 

 

每个系统调用,在驱动中都有与之对应的驱动函数,内核include/linux/fs.h文件中有个file_operations结构体,就是Linux内核驱动操作函数集合:

 

struct file_operations {
  struct module *owner;
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t*);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  ......
  ......
};

 

Linux驱动运行方式有以下两种:

将驱动编译进内核中, 当Linux内核启动时就会自动运行驱动程序

将驱动编译成模块, 在内核启动后使用insmod命令加载驱动模块

 

在驱动开发阶段一般都将其编译为模块,不需要编译整个Linux代码,方便调试驱动程序。当驱动开发完成后,根据实际需要,可以选择是否将驱动编译进Linux内核中。

 

2. Linux设备号

 

2.1 设备号的组成

 

Linux中每个设备都有一个设备号,由主设备号和次设备号两部分组成:

主设备号表示某一个具体的驱动

次设备号表示使用这个驱动的各个设备

 

Linux 提供了名为dev_t的数据类型表示设备号,其本质是32位的unsigned int数据类型,其中高12位为主设备号,低20位为次设备号,因此Linux中主设备号范围为0~4095

 

在文件include/linux/kdev_t.h中提供了几个关于设备号操作的宏定义:

 

#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))

 

MINORBITS:表示次设备号位数,一共20位

MINORMASK:表示次设备号掩码

MAJOR:用于从dev_t中获取主设备号,将dev_t右移20位即可

MINOR:用于从dev_t中获取次设备号,取dev_t的低20位的值即可

MKDEV:用于将给定的主设备号和次设备号的值组合成dev_t类型的设备号

 

2.2 主设备号的分配

 

主设备号的分配包括静态分配和动态分配。静态分配需要手动指定设备号,并且要注意不能与已有的重复,一些常用的设备号已经被Linux内核开发者给分配掉了,可使用cat /proc/devices命令查看当前系统中所有已经使用了的设备号。

 

动态分配是在注册字符设备之前先申请一个设备号,系统会自动分配一个没有被使用的设备号, 这样就避免了冲突。在卸载驱动的时候释放掉这个设备号即可。

 

⏩ 设备号的申请函数

//设备号申请函数
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
// dev:保存申请到的设备号
// baseminor:次设备号起始地址
// count:要申请的设备号数量
// name:设备名字

 

⏩ 设备号的释放函数

//设备号释放函数
void unregister_chrdev_region(dev_t from, unsigned count)
// from:要释放的设备号
// count:表示从 from 开始,要释放的设备号数量

 

3. 字符设备驱动开发模板

 

3.1 加载与卸载

 

在编写驱动的时候需要注册模块加载和卸载这两种函数:

module_init(xxx_init);    //注册模块加载函数
module_exit(xxx_exit);    //注册模块卸载函数

 

⏩ module_init():向内核注册一个模块加载函数,参数xxx_init就是需要注册的具体函数,当使用insmod命令加载驱动时,xxx_init函数就会被调用

 

⏩ module_exit():向内核注册一个模块卸载函数,参数xxx_exit就是需要注册的具体函数,当使用rmmod命令卸载驱动时,xxx_exit函数就会被调用

 

字符设备驱动模块加载和卸载模板如下所示:

 

/* 驱动入口函数 */
static int __init xxx_init(void)
{
  /*入口函数具体内容*/
  return 0;
}

/* 驱动出口函数 */
static void __exit xxx_exit(void)
{
  /*出口函数具体内容*/
}

/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);

 

驱动编译完成以后扩展名为.ko,有两种命令可以加载驱动模块:

insmod:最简单的模块加载命令,但不能解决模块的依赖关系

modprobe:会分析模块的依赖关系,将所有的依赖模块都加载到内核中

 

卸载驱动也有两种命令:

rmmod:最简单的模块卸载命令

modprobe -r:除了卸载指定的驱动,还卸载其所依赖的其他模块,若依赖模块还在被其它模块使用,就不能使用该命令来卸载驱动模块

 

3.2 注册与注销

 

对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,卸载驱动模块时也要注销掉字符设备。字符设备的注册和注销函数原型如下所示:

 

static inline int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops)
//major:主设备号
//name:设备名字,指向一串字符串
//fops:结构体 file_operations 类型指针,指向设备的操作函数集合变量
static inline void unregister_chrdev(unsigned int major, const char *name)
//major:要注销的设备对应的主设备号
//name:要注销的设备对应的设备名

 

一般字符设备的注册在驱动模块的入口函数xxx_init中进行,字符设备的注销在驱动模块的出口函数xxx_exit中进行

 

//定义了一个file_operations结构体变量,就是设备的操作函数集合
static struct file_operations test_fops;

/* 驱动入口函数 */
static int __init xxx_init(void){
  /* 入口函数具体内容 */
  int retvalue = 0;
  /* 注册字符设备驱动 */
  retvalue = register_chrdev(200, "chrtest", &test_fops);
  if(retvalue < 0){
    /* 字符设备注册失败,自行处理 */
  }
  return 0;
}

/* 驱动出口函数 */
static void __exit xxx_exit(void){
  /* 注销字符设备驱动 */
  unregister_chrdev(200, "chrtest");
}

/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);

 

3.3 实现设备的具体操作函数

 

file_operations结构体就是设备的具体操作函数。假设对chrtest这个设备有如下两个要求:

能够实现打开和关闭操作:需要实现open和release这两个函数

能够实现进行读写操作:需要实现read和write这两个函数

 

实现file_operations中的这四个函数,完成后的内容框架如下所示:

 

/* 打开设备 */
static int chrtest_open(struct inode *inode, struct file *filp){
  /* 用户实现具体功能 */
  return 0;
}

/* 从设备读取 */
static ssize_t chrtest_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt){
  /* 用户实现具体功能 */
  return 0;
}

/* 向设备写数据 */
static ssize_t chrtest_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt){
  /* 用户实现具体功能 */
  return 0;
}

/* 关闭/释放设备 */
static int chrtest_release(struct inode *inode, struct file *filp){
  /* 用户实现具体功能 */
  return 0;
}

 

然后是驱动的入口(init)和出口(exit) 函数:

 

//定义了一个file_operations结构体变量test_fops,就是设备的操作函数集合
static struct file_operations test_fops = {
  .owner = THIS_MODULE,
  .open = chrtest_open,
  .read = chrtest_read,
  .write = chrtest_write,
  .release = chrtest_release,
}

/* 驱动入口函数 */
static int __init xxx_init(void){
  /* 入口函数具体内容 */
  int retvalue = 0;
  /* 注册字符设备驱动 */
  retvalue = register_chrdev(200, "chrtest", &test_fops);
  if(retvalue < 0){
    /* 字符设备注册失败,自行处理 */
  }
  return 0;
}

/* 驱动出口函数 */
static void __exit xxx_exit(void){
  /* 注销字符设备驱动 */
  unregister_chrdev(200, "chrtest");
}

/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);

 

3.4 添加LICENSE和作者信息

 

LICENSE是必须添加的,否则编译时会报错,作者信息可加可不加

 

MODULE_LICENSE() //添加模块 LICENSE 信息
MODULE_AUTHOR() //添加模块作者信息

 

综上所述,字符设备驱动开发流程如下图所示:

 

 

4. 字符设备驱动开发实验

 

下面以正点原子的IMX6ULL开发板为平台,完整的编写一个虚拟字符设备驱动模块。chrdevbase不是实际存在的一个设备,只是为了学习字符设备的开发的流程

 

4.1 驱动程序编写

 

⏩ 宏定义及变量定义

 

#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>

#define CHRDEVBASE_MAJOR    200		 /* 主设备号 */
#define CHRDEVBASE_NAME	    "chrdevbase" /* 设备名   */

static char readbuf[100];		 /* 读缓冲区 */
static char writebuf[100];		 /* 写缓冲区 */
static char kerneldata[] = {"kernel data!"};

 

⏩ 打开、关闭、读取、写入函数实现

 

static int chrdevbase_open(struct inode *inode, struct file *filp){
  printk("chrdevbase open!\r\n");
  return 0;
}

static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt){
  int retvalue = 0;
  /* 向用户空间发送数据 */
  memcpy(readbuf, kerneldata, sizeof(kerneldata));
  retvalue = copy_to_user(buf, readbuf, cnt);
  if(retvalue == 0){
    printk("kernel senddata ok!\r\n");
  }else{
    printk("kernel senddata failed!\r\n");
  }
	
  printk("chrdevbase read!\r\n");
  return 0;
}

static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt){
  int retvalue = 0;
  /* 接收用户空间传递给内核的数据并且打印出来 */
  retvalue = copy_from_user(writebuf, buf, cnt);
  if(retvalue == 0){
    printk("kernel recevdata:%s\r\n", writebuf);
  }else{
    printk("kernel recevdata failed!\r\n");
  }
	
  printk("chrdevbase write!\r\n");
  return 0;
}

static int chrdevbase_release(struct inode *inode, struct file *filp){
  printk("chrdevbase release!\r\n");
  return 0;
}

 

⏩ 驱动加载与注销

 

static struct file_operations chrdevbase_fops = {
  .owner = THIS_MODULE,	
  .open = chrdevbase_open,
  .read = chrdevbase_read,
  .write = chrdevbase_write,
  .release = chrdevbase_release,
};

/*驱动入口函数  */
static int __init chrdevbase_init(void){
  int retvalue = 0;
  retvalue = register_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME, &chrdevbase_fops);
  if(retvalue < 0){
    printk("chrdevbase driver register failed\r\n");
  }
  printk("chrdevbase init!\r\n");
  return 0;
}

/* 驱动出口函数 */
static void __exit chrdevbase_exit(void){
  unregister_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME);
  printk("chrdevbase exit!\r\n");
}

/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);

 

⏩ LICENSE与作者

 

MODULE_LICENSE("GPL");
MODULE_AUTHOR("andyxi");

 

4.2 应用程序编写

 

应用程序运行在用户空间,其通过输入相应的指令来对chrdevbase设备执行读或者写操作。下面将程序进行分段介绍

 

⏩ 头文件和main函数入口,以及main函数的传参处理

#include "stdio.h"
#include "unistd.h"
#include "sys/types.h"
#include "sys/stat.h"
#include "fcntl.h"
#include "stdlib.h"
#include "string.h"

static char usrdata[] = {"usr data!"};

int main(int argc, char *argv[]){
  int fd, retvalue;
  char *filename;
  char readbuf[100], writebuf[100];

  if(argc != 3){
    printf("Error Usage!\r\n");
    return -1;
  }

  filename = argv[1];

  /* 打开驱动文件 */
  fd  = open(filename, O_RDWR);
  if(fd < 0){
    printf("Can't open file %s\r\n", filename);
    return -1;
  }

 

⏩ 对 chrdevbase 设备的具体操作

  if(atoi(argv[2]) == 1){ /* 从驱动文件读取数据 */
    retvalue = read(fd, readbuf, 50);
    if(retvalue < 0){
      printf("read file %s failed!\r\n", filename);
    }else{
      /* 读取成功,打印出读取成功的数据 */
      printf("read data:%s\r\n",readbuf);
    }
  }

  if(atoi(argv[2]) == 2){
    /* 向设备驱动写数据 */
    memcpy(writebuf, usrdata, sizeof(usrdata));
    retvalue = write(fd, writebuf, 50);
    if(retvalue < 0){
      printf("write file %s failed!\r\n", filename);
    }
  }

 

⏩ 关闭设备

  /* 关闭设备 */
  retvalue = close(fd);
  if(retvalue < 0){
    printf("Can't close file %s\r\n", filename);
    return -1;
  }
  return 0;
}

 

4.3 程序编译

 

程序编译包括驱动程序编译和应用程序编译两个部分

 

驱动程序编译:将驱动程序编译为.ko模块

 

⏩ 创建Makefile文件

 

# KERNELDIR:开发板所使用的Linux内核源码目录
KERNELDIR := /home/andyxi/linux/kernel/linux-imx-rel_imx_4.1.15_2.1.0_ga_andyxi
# CURRENT_PATH:当前路径,通过运行“pwd”命令获取
CURRENT_PATH := $(shell pwd)
# obj-m:将 chrdevbase.c 这个文件编译为chrdevbase.ko模块
obj-m := chrdevbase.o

build: kernel_modules
# -C 表示切换工作目录到KERNERLDIR目录
# M 表示模块源码目录
# modules 表示编译模块
kernel_modules:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean

 

⏩ 输入make命令即可编译,编译成功以后就会生成一个叫做 chrdevbaes.ko 的文件,此文件就是 chrdevbase 设备的驱动模块

 

 

注意:若直接make编译可能会出错,是因为kernel中没有指定编译器和架构,使用了默认的x86平台编译报错。解决办法就是在内核顶层Makefile中,直接定义ARCH和CROSS_COMPILE这两个的变量值为 arm 和 arm-linux-gnueabihf- 即可

 

 

应用程序编译:无需内核参与,直接编译即可

 

arm-linux-gnueabihf-gcc chrdevbaseApp.c -o chrdevbaseApp

 

使用file命令,查看生成的chrdevbaseApp文件信息,如下图示,文件是32位LSB格式,ARM版本的,因此只能在ARM芯片下运行

 

 

4.4 运行测试

 

为了方便测试,Linux系统选择通过TFTP从网络启动,并且使用NFS挂载网络根文件系统。确保开发板系统移植成功,能正常启动。具体的实现方法可参考之前介绍过的系统移植专辑系列文章

 

加载驱动模块

 

⏩ 在根文件系统创建/lib/modules/4.1.15文件夹,用来存放驱动模块

 

/lib/modules是通用的

 

4.1.15根据所使用的内核版本来设置,否则modprobe命令无法加载驱动模块

 

⏩ 在Ubuntu中将chrdevbase.ko和chrdevbaseAPP,复制到根文件系统的 rootfs/lib/modules/4.1.15 目录中

 

 

⏩ 在开发板中使用insmod或modprobe命令来加载驱动文件

 

 

⏩ 输入lsmod命令即可查看当前系统中存在的模块,输入cat /proc/devices命令,查看当前系统中有没有chrdevbase 这个设备

 

 

创建设备节点文件:驱动加载成功后,需要在/dev目录下创建一个与之对应的设备节点文件,应用程序通过操作这个设备节点文件来完成对具体设备的操作

 

⏩ 使用mknod命令创建/dev/chrdevbase设备节点文件

mknod /dev/chrdevbase c 200 0
#/dev/chrdevbase 是要创建的节点文件
# c 表示这是个字符设备
# 200 是设备的主设备号
# 0 是设备的次设备号

 

⏩ 创建完后可使用ls /dev/chrdevbase -l命令查看是否存在

 

 

操作设备测试:使用应用程序读写设备,对/dev/chrdevbase文件进行读写操作

 

# 读操作命令
./chrdevbaseApp /dev/chrdevbase 1
# 输出“ kernel senddata ok!”是驱动程序中chrdevbase_read函数输出的信息
# “read data:kernel data!”就是chrdevbaseAPP打印出来的接收到的数据
# 写操作命令
./chrdevbaseApp /dev/chrdevbase 2
# “kernel recevdata:usr data!”,是驱动程序中的chrdevbase_write函数输出的

 

 

卸载驱动模块:若不再使用某个设备的话可以将其驱动卸载掉。输入rmmod命令卸载驱动后,使用lsmod命令查看chrdevbase这个模块还存不存在

 

 

至此,Linux字符设备驱动开发完成。本文介绍了驱动开发中的字符驱动开发的基本模式,并使用一个虚拟的字符设备驱动进行测试,了解驱动程序与应用程序之间的调用关系。