目录
一.驱动简介
linux的驱动本质上就是一种软件程序,上层软件可以在不了解硬件特性的情况下,通过驱动提供的接口和计算机以及外设进行通信。
系统调用是内核调用和应用程序之间的接口,驱动程序将内核与硬件之间串联起来。为应用开发屏蔽了硬件的细节(不需要对驱动程序特别了解),也能够直接操作硬件。对于linux来说,硬件设备就是一个设备文件,应用程序可以像操作普通文件那样对硬件设备进行操作。
linux驱动程序是内核的一部分,管理着系统的设备控制器和响应设备。驱动程序,英文:"Device Driver",全称“设备驱动程序”,是一种可以使计算机和设备通信的特殊程序,相当于硬件的接口,操作系统通过这个接口控制硬件工作。它主要完成以下几个功能:
1.对设备初始化和释放
2.传送数据到硬盘和从硬件中读取数据
3.检测和处理设备出现的错误
二.驱动分类
计算机系统的硬件由CPU,存储器,和外设共同组成。驱动针对的对象都是存储器和外设。linux将外设和存储器分为三个基础大类:块设备驱动,字符设备驱动,网络设备驱动。
2.1字符设备驱动
字符设备驱动是较为简单和初学者首先进行学习的驱动。字符设备是指那些必须以串行顺序访问的设备(并不是所有的字符驱动设备都需要串行访问),字符设备的I/O操作没有通过缓存。字符设备的操作是以字节为基础,一次只能执行一个字节的操作。例:LCD,串口,LED,触摸屏。(可以在/dev下找到)
注:在字符设备中,串行顺序访问指的是每次对设备的读写操作必须按照先后顺序依次进行,不能并发地进行读写操作。这是因为字符设备内部的数据结构通常只有一个指针,所以无法支持多个进程同时访问同一设备。
例如,在串口中,每个字节的数据只能依次从串口传输线路中传输出去,每个字节数据到达目的地时才可以进行下一个字节的传输。因此,串口就是一个需要以串行顺序访问的字符设备。
需要注意的是,并不是所有的字符设备都需要以串行顺序访问。例如,键盘设备就支持多个进程同时对其进行读取操作,每个进程读取到的键盘输入事件可能是相同的,也可能是不同的。这种情况下,键盘设备就可以支持并发读取。
2.2块设备驱动
块设备驱动是相对于字符设备定义的,可以以任意顺序进行访问,以块为单位进行操作。块设备驱动的读写都有缓存来进行支持,且块设备必须能够随机获取。设备的块大小时设备本身设计时定义好的,软件不能更改,不同的设备块大小不同。常见设备:硬盘NandFlash,SD等。(可以在/dev下找到)
2.3网络设备驱动
网络设备驱动是专门为了网卡设计的驱动模型,面向数据包的接收和发送而进行设计的,它并不应对于文件系统的节点。不对应/dev下的设备文件,应用程序最终用套接字socket完成与网络设备的接口。(在/dev下找不到不映射到dev下)
三.驱动的编译和加载
linux设备驱动属于内核的一部分,linux内核的一个模块可以以两种凡是被编译和加载。
3.1编译方式
内部编译:将驱动程序源码放在内核源码目录中进行编译。
外部编译:将驱动程序源码放在内核源码目录外进行编译。
3.2加载方式
驱动编译成模块(动态加载):以hello驱动程序举例:insmod ./hello.ko(./代表当前路径)
驱动编译进内核(静态加载):将驱动程序源码放在内核源码目录进行编译(开机的时候启动该驱动)
3.3编译器
x86使用gcc即可,ARM架构根据芯片架构使用相关交叉编译工具链进行编译
四.hello驱动程序编写(不需要硬件参与)
4.1上层到底层程序调用关系
用户首先确定一个设备。电脑里面需要先有对应的驱动文件(设备初始化,设备开启关闭等程序)才能对这个设备进行open()等操作,linux内核就包含了一个结构体,驱动开发人员需要挑选一些需要的功能进行实现,最基础的就是open()/write()/read()/close()这些。
4.2驱动开发流程(驱动开发通式)
这是我第一次编写驱动程序,之前在linux上面写程序也都是在应用层上面,有C库可以调,第一次写驱动非常不习惯,出现了很多低级失误(忘记写;等很多错误),万事开头难!
4.2.1先写一个驱动文件
现在写代码都是在内核里面,不是在用户空间(之前在做应用层比较多,一开始比较困难,慢慢来)。
先写一个能够开启/关闭设备的,能够读/写内容这四个功能的驱动。
编写驱动流程(以linux4.9.8内核源码的misc.c文件作为举例)
首先每个设备都需要对应的主设备号(可以自己定义也可以向内核申请分配),主设备号不会重复,然后定义一下自己的file_operations结构体它的作用是:
file_operations
结构体是 Linux 设备驱动程序中用于实现设备操作的结构体类型。它可以用于注册驱动程序所支持的文件操作函数,包括打开、关闭、读取、写入、定位等操作。当用户对设备进行操作时,内核会调用相应的函数指针来执行对应的操作。
具体来说,file_operations
结构体通常包含以下几个成员:
open
:打开设备时调用的函数指针。通常用于初始化设备和分配资源等操作。release
:关闭设备时调用的函数指针。通常用于释放设备占用的资源和清理环境等操作。read
:从设备中读取数据时调用的函数指针。write
:向设备中写入数据时调用的函数指针。llseek
:定位文件读写位置时调用的函数指针。
使用 file_operations
结构体,可以将驱动程序中实现的这些函数指针注册到相应的内核接口中。当用户对设备进行操作时,内核会根据相应的操 作类型调用相应的函数指针来执行相应的操作。因此,file_operations
结构体是 Linux 设备驱动程序中非常重要的一个结构体类型,常用于实现设备操作相关的功能。
/*file_operations结构体原型 misc.c文件中原型*/
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 *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
int (*clone_file_range)(struct file *, loff_t, struct file *, loff_t,
u64);
ssize_t (*dedupe_file_range)(struct file *, u64, u64, struct file *,
u64);
};
4.2.1.1主设备号和次设备号的关系和概念:
linux设备管理之主设备号与次设备号 - jinzi - 博客园 (cnblogs.com)
主设备号对应一个驱动程序,而次设备号对应使用这个驱动程序的设备。
也就是说根据主设备号能够分辨哪个驱动程序为这个硬件服务。
4.2.2添加头文件
主要从misc.c(设备注册和管理文件)文件中复制过来
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/kernel.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>
4.2.3编写驱动设备读写函数
首先是驱动设备开启,读写函数(这些操作是大多数驱动设备必备的) ,由于没有标准C库可以调用其中里面的printk函数也是打印信息,,但是不会打印在终端窗口,要看打印信息的话需要使用dmesg指令(dmesg | tail打印最近信息),它会输出内核运行时的打印信息。
4.2.4设备注册以及注销函数编写(格式按照模板要求来写返回值等要规范)
这两个函数也不过只是两个简单的子函数需要下面的两行代码注册到内核里(告诉内核我哪个函数是入口函数,哪个函数是出口函数)。
4.2.5内核注册,协议声明
必须要声明以下GPL协议,否则有可能会因为侵权导致驱动程序不能使用
/*7.其它完善:提供设备信息,自动创建设备节点*/
module_init(hello_init);//指定了模块入口函数
module_exit(hello_exit);//指定了模块出口函数
MODULE_LICENSE("GPL");//这里必须声明使用了GPL协议,否则驱动程序无法使用
4.2.6完整驱动代码
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/kernel.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>
/*1.确定设备号*/
static int major = 0;
static char kernel_buf[1024];//内核字符缓冲区
static struct class *hello_class;
#define MIN(a,b) (a < b ? a : b)
static ssize_t hello_drv_write (struct file *file, const char __user *buf, size_t size, loff_t * offset)
{
int err;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
err = copy_from_user(kernel_buf, buf, MIN(1024, size));//将用户空间数据拷贝到内核中去,这里是实现写的作用
return MIN(1024, size);
}
static ssize_t hello_drv_read (struct file *file, char __user *buf, size_t size, loff_t * offset)
{
int err;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
err = copy_to_user(buf, kernel_buf, MIN(1024, size));//将用户空间数据拷贝到内核中去,这里是实现读的作用
return MIN(1024, size);
}
static int hello_drv_open (struct inode *nodefile, struct file *file)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
return 0;
}
static int hello_drv_close (struct inode *nodefile, struct file *file)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
return 0;
}
/*2.定义自己的file_operations结构体*/
static struct file_operations hello_drv = {
.owner = THIS_MODULE,
.open = hello_drv_open,
.read = hello_drv_read,
.write = hello_drv_write,
.release = hello_drv_close,
};
/*4.把file_operations结构体告诉内核:注册内核程序*/
/*5.谁来注册驱动程序啊?得有一个入口函数:安装驱动程序时,就会去调用这个入口函数*/
static int __init hello_init(void)
{
int err;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
major = register_chrdev(0, "hello", &hello_drv);//注册字符驱动设备
hello_class = class_create(THIS_MODULE, "hello");//创建一个"hello"设备类
err = PTR_ERR(hello_class);//创建一个类
if (IS_ERR(hello_class))//判断hello设备类是否创建成功
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
unregister_chrdev( major, "hello");//若创建失败,注销字符驱动设备
return -1;
}
/*device_creat函数会将设备添加到底层设备链表当中*/
device_create(hello_class, NULL,
MKDEV(major, 0),
NULL, "hello");//在设备类hello下创建一个设备文件hello,并将设备号与字符设备驱动绑定
return 0;
}
/*6.有入口函数就应该有出口函数:卸载驱动程序时,就会去调用这个出口函数*/
static void __exit hello_exit(void)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
device_destroy(hello_class, MKDEV(major, 0));//销毁hello_class设备类下的设备文件hello设备
class_destroy(hello_class);//销毁设备类class
unregister_chrdev( major, "hello");//注销已经注册到内核的字符设备
}
/*7.其它完善:提供设备信息,自动创建设备节点*/
module_init(hello_init);//告诉内核hello_init是入口函数
module_exit(hello_exit);//告诉内核hello_exit是出口函数
MODULE_LICENSE("GPL");//声明使用了GPL协议,否则驱动程序无法使用
4.3在用户空间写一段代码测试一下
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
/*
* ./hello_drv_test -w abc
* ./hello_drv_test -r
*/
/*这段程序运行可执行程序时可以选择-w或者-r选择向设备/hello中写入或者读出信息*/
int main(int argc, char **argv)
{
int fd;
char buf[1024];
int len;
/* 1. 判断参数 */
if (argc < 2)
{
printf("Usage: %s -w <string>\n", argv[0]);
printf(" %s -r\n", argv[0]);
return -1;
}
/* 2. 打开文件 */
fd = open("/dev/hello", O_RDWR);//打开hello设备(文件)
if (fd == -1)
{
printf("can not open file /dev/hello\n");
return -1;
}
/* 3. 写文件或读文件 */
if ((0 == strcmp(argv[1], "-w")) && (argc == 3))//判断操作是要进行读还是写操作
{
len = strlen(argv[2]) + 1;
len = len < 1024 ? len : 1024;
write(fd, argv[2], len);//向hello设备中写入信息
}
else
{
len = read(fd, buf, 1024);//从hello设备中读出信息
buf[1023] = '\0';
printf("APP read : %s\n", buf);
}
close(fd);//关闭设备
return 0;
}
4.4Makefile文件
# 1. 使用不同的开发板内核时, 一定要修改KERN_DIR
# 2. KERN_DIR中的内核要事先配置、编译, 为了能编译内核, 要先设置下列环境变量:
# 2.1 ARCH, 比如: export ARCH=arm64
# 2.2 CROSS_COMPILE, 比如: export CROSS_COMPILE=aarch64-linux-gnu-
# 2.3 PATH, 比如: export PATH=$PATH:/home/book/100ask_roc-rk3399-pc/ToolChain-6.3.1/gcc-linaro-6# 注意: 不同的开发板不同的编译器上述3个环境变量不一定▒.▒同,
# 请参考各开发板的高级用户使用手册
#由于文件依赖linux内核的很多文件编译所以需要指定内核源码路径
KERN_DIR = /home/book/100ask_imx6ull-sdk/Linux-4.9.88
all:
make -C $(KERN_DIR) M=`pwd` modules
$(CROSS_COMPILE)gcc -o hello_drv_test hello_drv_test.c
clean:
make -C $(KERN_DIR) M=`pwd` modules clean
rm -rf modules.order
rm -f hello_drv_test
obj-m += hello_drv.o
4.5编译驱动文件
make执行Makefile文件生成文件之后将模块挂载执行insmod hello_drv.ko就完成操作了
4.5.1装载/查看/使用/卸载 驱动
insmod 命令装载驱动
lsmod查看是否安装成功驱动了
查看/dev目录下是否有我们的设备驱动节点,这里设备号是245
运行测试代码:
rmmod卸载驱动程序,可以看到设备列表已经没有hello了
五.补充知识
为什么要调用后最是.ko文件加载驱动模块呢?
编译 Linux 内核模块时,会生成一个扩展名为 .ko
的文件。.ko
文件是一种可加载的内核模块,可以通过 insmod
命令将其动态加载到内核中。
.ko
文件的生成过程包括以下几个步骤:
- 编译源文件,生成目标文件(
.o
文件)。 - 将多个目标文件链接成一个可执行的 ELF 可重定位文件(
.elf
文件)。 - 使用
objcopy
工具将可执行文件转换为可加载模块(.ko
文件)。
其中,链接器会将目标文件中未定义的符号链接到其他模块中定义的符号,并在链接的过程中创建模块框架,包括模块头、初始化函数、清理函数等。这些模块框架是用于管理模块生命周期的重要组成部分。
.ko
文件是一种可加载的内核模块,可以在不重新编译内核的情况下动态添加到内核中。当加载某个模块时,内核会将模块的代码、数据、符号表等信息加载到内存中,并运行模块的初始化函数。相反,当卸载某个模块时,内核会调用模块的清理函数,并释放模块占用的资源。
通常情况下,.ko
文件也被称为 Linux 内核驱动程序,因为它们可以用于添加设备驱动等功能。
使用insmod命令加载模块会调用内核驱动程序中指定的初始化函数(module_init()
)这个函数时自己写的入口函数
使用rmmod命令下载模块会调用内核驱动中的自己编写的出口函数(module_exit())
模块是什么?
在 Linux 内核中,模块指的是一种可加载的内核代码,也被称为内核模块或 Linux 内核动态模块。它是一种独立的、功能完整的代码片段,可以在运行时动态地添加到内核中,从而增加内核的功能。
与静态编译的内核不同,模块是在内核运行时加载的,因此可以根据需要加载和卸载模块,而无需重新编译内核。这使得内核模块开发变得更加灵活和方便,特别是对于开发和调试设备驱动程序等小型内核组件非常有用。
模块通常由 C 语言编写,具有固定的格式和结构。一个典型的模块包括模块头、初始化函数、清理函数、模块描述信息等。其中,模块头用于声明模块特性和依赖关系;初始化函数和清理函数是模块的入口和出口,用于模块的初始化和清理工作;模块描述信息用于提供关于模块的元数据,如作者、版本、许可证等信息。
使用模块的过程通常包括以下几个步骤:
- 编译模块源码,生成模块文件(
.ko
文件)。 - 使用
insmod
命令将模块加载到内核中。 - 使用
rmmod
命令将模块从内核中卸载。 - 使用
lsmod
命令查看已经加载的模块列表。
最后需要注意的一点是,Linux 内核具有内核符号表,用于管理内核代码中的全局符号。模块的代码也需要引用内核中的符号,因此需要按照内核编译的方式进行编译,以便将模块中的符号正确地链接到内核中。