公众号:嵌入式攻城狮(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!rn");
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!rn");
}else{
printk("kernel senddata failed!rn");
}
printk("chrdevbase read!rn");
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:%srn", writebuf);
}else{
printk("kernel recevdata failed!rn");
}
printk("chrdevbase write!rn");
return 0;
}
static int chrdevbase_release(struct inode *inode, struct file *filp){
printk("chrdevbase release!rn");
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 failedrn");
}
printk("chrdevbase init!rn");
return 0;
}
/* 驱动出口函数 */
static void __exit chrdevbase_exit(void){
unregister_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME);
printk("chrdevbase exit!rn");
}
/* 将上面两个函数指定为驱动的入口和出口函数 */
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!rn");
return -1;
}
filename = argv[1];
/* 打开驱动文件 */
fd = open(filename, O_RDWR);
if(fd < 0){
printf("Can't open file %srn", filename);
return -1;
}
⏩ 对 chrdevbase 设备的具体操作
if(atoi(argv[2]) == 1){ /* 从驱动文件读取数据 */
retvalue = read(fd, readbuf, 50);
if(retvalue < 0){
printf("read file %s failed!rn", filename);
}else{
/* 读取成功,打印出读取成功的数据 */
printf("read data:%srn",readbuf);
}
}
if(atoi(argv[2]) == 2){
/* 向设备驱动写数据 */
memcpy(writebuf, usrdata, sizeof(usrdata));
retvalue = write(fd, writebuf, 50);
if(retvalue < 0){
printf("write file %s failed!rn", filename);
}
}
⏩ 关闭设备
/* 关闭设备 */
retvalue = close(fd);
if(retvalue < 0){
printf("Can't close file %srn", 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字符设备驱动开发完成。本文介绍了驱动开发中的字符驱动开发的基本模式,并使用一个虚拟的字符设备驱动进行测试,了解驱动程序与应用程序之间的调用关系。