文章目录
- Linux驱动
- 概念
- 应用程序调用驱动程序流程
- 驱动模块的加载
- linux设备号
- 加载和卸载
- 注册
- 新字符设备注册
- 设备节点
- 自动创建设备节点
- 编译
- 编译驱动程序
- 编译应用程序
- 地址映射
- ioctrl
- 命令码的解析
- 并发与竞争
- 原子操作
- 自旋锁
- 信号量
- 互斥体
- linux中断
- DMA映射
- 其它
- printk
- memcpy
- volatile关键字
- 用户访问内核
Linux驱动
概念
驱动充当着硬件与应用软件之间的桥梁(上面是系统调用,下面是硬件)。
Linux 驱动属于内核的一部分,因此驱动运行于内核空间。
驱动的具体任务:
- 读写设备寄存器(实现控制的方式);
- 完成设备的轮询、中断处理、DMA通信(CPU与外设通信的方式);
- 进行物理内存向虚拟内存的映射(在开启硬件MMU的情况下);
驱动的两个方向:
- 操作硬件(向下);
- 将驱动程序通入内核,实现面向操作系统内核的接口内容,接口由操作系统实现(向上)
用户空间不能直接对内核进行操作,必须使用**“系统调用”**的方法来实现从用户空间’‘陷入’'到内核空间,这样才能实现对底层驱动的操作。
应用程序调用驱动程序流程
驱动加载成功以后会在“/dev”目录下生成一个相应的文件, 应用程序通过对这个名为“/dev/xxx”(xxx 是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作。
open(close)函数:打开(关闭)/dev/xxxx
write(read)函数:向驱动写入数据(从驱动读取数据)
mmap函数:用于将设备的内存映射到进程空间中
应用程序使用到的函数在具体驱动程序中都有与之对应的函数,每一个系统调用,在驱动中都有与之对应的一个驱动函数。
驱动模块的加载
在 Linux 内核文件 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 *); 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, gned 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); } __randomize_layout;
linux设备号
Linux 中每个设备都有一个设备号(32位的dev_t类型),由主设备号(高12位)和次设备号(低20位)两部分组成,主设备号范围为0~4096,不可超出范围。主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备。
一些设备号的操作函数:
从dev_t中获取主设备号:
MAJOR(dev)
从dev_t中获取从设备号:
MANOR(dev)
将给定的主设备号和次设备号的值组合成 dev_t 类型的设备号:
MKDEV(ma,mi)
加载和卸载
将驱动编译成模块(.ko),linux启动以后,使用相应命令加载驱动:
加载驱动模块:
insmod xxx.ko
卸载模块:
rmmod xxx.ko
查看系统中加载的所有模块及模块间的依赖关系:
ismod
注册
编写驱动程序时,需要向内核注册模块加载函数(加载驱动时,module_init会被调用(入口);卸载驱动时,module_exit会被调用(出口)):
static struct file_operations chrdevbase_fops = {
/*传输的函数名称*/
};
/* 驱动入口函数 */
static int __init xxx_init(void)
{
/* 入口函数具体内容 */
return 0;
} /* 驱动出口函数 */
static void __exit xxx_exit(void)
{
/* 出口函数具体内容 */
} /* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);
/*添加模块license信息*/
MODULE_LICENSE("GPL");
新字符设备注册
cdev结构体:
struct cdev { struct kobject kobj; struct module *owner; const struct file_operations *ops; struct list_head list; dev_t dev; unsigned int count; };
-
对cdev变量初始化:
cdev_init(&cdev, &fops);
,cdev就是要初始化的cdev结构体变量,fops是字符设备文件操作函数集合。 -
向linux系统添加字符设备:
cdev_add(&cdev, devid, 1);
,cdev是要添加的字符设备,devid是该设备的设备号,count是要添加的设备数量。 -
卸载驱动时要删除字符设备:
cdev_del(&cdev);
内核动态分配设备号:alloc_chrdev_region(dev, basemibor, count, name)
,dev用来获取设备号,baseminor是次设备号起始值,count是次设备号个数,name是设备名称,返回值为0代表错误。
释放设备号:unregister_chrdev_region(from, count)
,from是要释放的设备号,count是从from开始要释放的设备号数量。
设备节点
驱动加载成功后需要在/dev目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作:mknod /dev/chrdevbase c 200 0
“mknod”是创建节点命令,“/dev/chrdevbase”是要创建的节点文件,“c”表示这 是个字符设备,“200”是设备的主设备号,“0”是设备的次设备号。创建完成以后就会存在 /dev/chrdevbase 这个文件
自动创建设备节点
在驱动入口函数创建类和设备。
-
创建类:
class_create(owner, name)
,owner一般为固定的THIS_MODULE,name是类名字。删除类:
class_destroy(cls);
,cls是要删除的类。 -
在类下创建设备:
device_create(class,parent,devt,drvdata,fmt)
,其中class就是设备要创建于哪个类下面,parent和drvdata一般为0,devt是设备号,fmt是设备名字(如果设置fmt=xxx,就会生成/dev/xxx这个设备文件)。删除设备:
device_destroy(class,devt)
,class是要删除的类,devt是要删除的设备号。 -
将设备的属性信息写成结构体:
struct test_dev{ dev_t devid; /* 设备号 */ struct cdev cdev; /* cdev */ struct class *class; /* 类 */ struct device *device; /* 设备 */ int major; /* 主设备号 */ int minor; /* 次设备号 */ };
编译
编译驱动程序
obj-m表示将.c文件编译为模块,-C表示将当前目录切换到指定目录,加入M=dir以后程序会自动到指定的 dir 目录中读取模块的源码并将其编译为.ko 文件,modules表示编译模块。
KERN_DIR := $linux_pathobj-m := xxx.oall:make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -C $(KERN_DIR) M=`pwd` modulesclean:make -C $(KERN_DIR) M=`pwd` clean
编译应用程序
测试 APP 是要在 ARM 开发板上运行的,所以需要使用 arm-linux-gnueabihf-gcc 来编译:arm-linux-gnueabihf-gcc xxx.c -o xxx
地址映射
MMU(内存管理单元)功能:
- 完成虚拟空间到物理空间的映射;
- 内存保护,设置存储器的访问权限,设置虚拟存储空间的缓冲特性;
32位处理器虚拟地址范围是2^32=4GB
CPU只能访问虚拟地址,不能直接向寄存器地址写入数据,必须通过寄存器物理地址在Linux系统中对应的虚拟地址。
地址映射函数:ioremap(phys_addr,size)
,phys_addr是要映射给的物理起始地址,size是要映射的内存空间大小。
#define APER_CLK_CTRL 0xF800012C
static void __iomem *aper_clk_ctrl_addr;
aper_clk_ctrl_addr = ioremap(APER_CLK_CTRL, 4);
释放映射函数:iounmap(addr)
,其中addr是要取消映射的虚拟地址空间首地址。
iounmap(aper_clk_ctrl_addr);
IO内存写入函数:iowrite32(v,p)
,p为写入的虚拟地址,v为写入数据的地址。
ioctrl
ioctrl是设备接口控制函数,一些无法归类于file_operations所列功能的函数可以统一放在ioctrl这个函数操作中,对应于file_operations结构体的unlocked_ioctl成员。ioctrl函数里实现了多个对硬件的操作,应用层通过传入命令来调用相应的操作。
原型:int (*ioctl) (struct inode * node, struct file *filp, unsigned int cmd, unsigned long arg);
使用:
ioctrl(fd,cmd,arg);
fd:文件标识符,一般对应设备文件/dev/xxx
cmd:命令码
arg:用户传递的数据的地址
ioctrl本质上就是用户空间向内核空间提交一段具有特定含义的命令码,内核空间根据内核规定好的方式,对命令码进行解析,执行对应的底层操作。即:命令码和底层操作应该是一一对应的,一个具体的命令码就代表了一次底层操作的全部信息。
命令码的解析
每个命令码由32bit组成:
bit 位数 | 31 : 30 | 29 : 16 | 15 : 8 | 7 : 0 |
---|---|---|---|---|
代表含义 | 数据传输方向 | 数据传递大小 | 设备类型码(魔数) | 功能码 |
占用bit数 | 2 | 14 | 8 | 8 |
可以使用内核定义好的宏定义简化命令码的封装:如_IOR(设备码,功能码,变量类型)
。
数据传输方向:
[00] 表示不传递数据,内核宏定义为: _IO
[10] 表示只读,内核定义为: _IOR
[01] 表示只写,内核宏定义为: _IOW
[11] 表示可读可写,内核宏定义为: _IOWR
数据传递大小:使用宏定义时,需填写数据类型,如无符号32位就要填int。
设备类型码:每个驱动通过一个唯一的字符来代表,只是为了区分设备,可以为任意char型字符,如‘a’,‘b’。
功能码:区分不同的功能,可以为任意无符号整型数据,范围为0~255,如果定义了多个 ioctl 命令,通常从 0 开始编号递增。
举例:
#include <sys/ioctl.h>/* 使用宏定义封装命令码 表示只写 设备类型码为 'L' 点灯功能码为 10 or 11 传输数据大小为 int 的大小*/
#define LED_ON_FUNC _IOW('L', 10, int)
#define LED_OFF_FUNC _IOW('L', 11, int)
.........
int main(int argc, const char *argv[])
{int fd; // 文件描述符int ledSwitch = 0; // 用来表示操作哪盏 LEDif((fd = open("/dev/ledDev", O_RDWR)) == -1){perror("Open dev failed");return -1;}ledSwitch = 2; // 表示要操作 LED2/* 使用 ioctl 函数 注意传入的是 ledSwitch 的地址*/if((ioctl(fd, LED_ON_FUNC, &ledSwitch)) == -1){perror("ioctl failed");return -1;}
}
并发与竞争
在驱动开发中要注意处理对共享资源的并发访问,保护多个线程都会访问的共享数据。
临界区就是共享数据段,对于临界区必须保证一次只有一个线程访问。
原子操作
为避免竞争,将一些指令作为一个整体运行,即作为一个原子存在,只能对整形变量或者位进行保护。
定义原子变量:atomic_t a;
示例:
atomic_t v = ATOMIC_INIT(0); /* 定义并初始化原子变零 v=0 */ atomic_set(10); /* 设置 v=10 */
atomic_read(&v); /* 读取 v 的值,肯定是 10 */
atomic_inc(&v); /* v 的值加 1,v=11 */
自旋锁
当一个线程要访问某个共享资源的时候首先要先获取相应的锁,锁只能被一个线程持有, 只要此线程不释放持有的锁,那么其他的线程就不能获取此锁。对于自旋锁而言,如果自旋锁 正在被线程 A 持有,线程 B 想要获取自旋锁,那么线程 B 就会处于忙循环-旋转-等待状态。
一 般 在 线 程 中 使 用 spin_lock_irqsave/ spin_unlock_irqrestore , 在 中 断 服 务 函 数 中 使 用 spin_lock/spin_unlock。
注意:
①因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短, 否则的话会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方 式,比如信号量和互斥体。
②自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁。
③不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就 必须“自旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己 把自己锁死了!
示例:
/* 定义并初始化一个自旋锁 */ static spinlock_t lock;spin_lock_init(&lock);/* 线程 A */ void functionA (){ unsigned long flags; /* 中断状态 */ spin_lock_irqsave(&lock, flags); /* 获取锁 */ /* 临界区 */ spin_unlock_irqrestore(&lock, flags); /* 释放锁 */ } /* 中断服务函数 */ void irq() { spin_lock(&lock); /* 获取锁 */ /* 临界区 */ spin_unlock(&lock); /* 释放锁 */ }
信号量
特点:
①因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场 合。
②因此信号量不能用于中断,因为信号量会引起休眠,中断不能休眠。
③如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换 线程引起的开销要远大于信号量带来的那点优势。
示例:
struct semaphore sem; /* 定义信号量 */ sema_init(&sem, 1); /* 初始化信号量 */ down(&sem); /* 申请信号量 */
/* 临界区 */
up(&sem); /* 释放信号量 */
sem_t
是 信号量(semaphore)的类型定义,通常用于多线程或多进程之间的同步和互斥。信号量是一个非负整数,用于控制对共享资源的访问。
信号量通常通过以下几个函数来操作:
sem_init()
:初始化一个信号量。sem_post()
:增加信号量的值(释放一个资源)。sem_wait()
:减少信号量的值(请求一个资源)。如果信号量的值为零,则调用线程将被阻塞,直到信号量的值大于零。sem_trywait()
:尝试减少信号量的值。如果信号量的值大于零,则减少它并立即返回;如果信号量的值为零,则立即返回错误。sem_destroy()
:销毁一个信号量。
互斥体
互斥访问表示一次只有一个线程可以访 问共享资源,不能递归申请互斥体。
特点:
①mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁(因为中 断不参与进程调度,如果一旦在中断服务函数执行过程中休眠了,休眠了则意味着交出了 CPU 的使用权,CPU 使用权则跑到了其它线程了,那么就不能再回到中断断点处了)。
②和信号量一样,mutex 保护的临界区可以调用引起阻塞的 API 函数。
③因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。 并且 mutex 不能递归上锁和解锁。
示例:
struct mutex lock; /* 定义一个互斥体 */ mutex_init(&lock); /* 初始化互斥体 */ mutex_lock(&lock); /* 上锁 */ /* 临界区 */ mutex_unlock(&lock); /* 解锁 */
linux中断
申请中断:request_irq(irq,handler,flags,name,dev)
,irq是要申请的中断号;handler是对应的中断处理函数;flags是中断标志;name是中断名字(设置后可以在/proc/interrupts文件中看到);dev用于区分,可设为NULL;返回0中断申请成功,其它负值则中断申请失败。
中断标志:
IRQF_TRIGGER_RISI NG 上升沿触发
IRQF_TRIGGER_FALL ING下降沿触发
IRQF_TRIGGER_HIGH高电平触发
IRQF_TRIGGER_LOW低电平触发
IRQF_ONESHOT单次中断,中断执行一次就结束
IRQF_TRIGGER_NONE无触发
释放中断:free_irq(irq,dev)
,irq是要释放的中断,dev可设为NULL。
中断处理函数定义:irqreturn_t xxx (*irq_handler_t,int, void *)
,handler_t是要处理的中断号;void要与request_irq 函数的 dev 参数保持一致;返回值使用如下形式:return IRQ_RETVAL(IRQ_HANDLED)
。
获取设备节点:of_find_node_by_path(“node”)
,node是定义在设备树中的节点名,返回值是设备节点的结构体。
从interupts属性中提取设备号:irq_of_parse_and_map(struct device_node *dev, int index)
,dev是设备节点,index是索引号,返回中断号。
DMA映射
DMA传输需要连续的物理地址,而内核中使用的都是虚拟地址,需要建立物理地址和虚拟地址的映射。
一致性DMA映射:A=dma_alloc_coherent(dev,size,handle,flag)
dev:struct device *类型,可设为NULL
size:要分配的内存大小
handle:返回的内存物理(起始)地址,DMA可用
flag:用于指定内存分配的类型和行为的标志位,可设为GFP_KERNEL
返回值A:内存的虚拟起始地址
调用此函数将会分配一段内存,handle将返回这段内存的实际物理地址供DMA使用,A是handle对应的虚拟地址供内核使用,对A和handle任意一个操作,都会改变这段内存缓冲区的内容。
dma_addr_t是一个数据类型,通常用于表示DMA传输中数据所在的物理地址。
其它
printk
printk运行在内核态,根据级别对消息分类。
#define KERN_SOH "\001" /* ASCII Start Of Header */
#define KERN_SOH_ASCII '\001' #define KERN_EMERG KERN_SOH "0" /* system is unusable */
#define KERN_ALERT KERN_SOH "1" /* action must be taken immediately */
#define KERN_CRIT KERN_SOH "2" /* critical conditions */
#define KERN_ERR KERN_SOH "3" /* error conditions */
#define KERN_WARNING KERN_SOH "4" /* warning conditions */
#define KERN_NOTICE KERN_SOH "5" /* normal but significant condition */
#define KERN_INFO KERN_SOH "6" /* informational */
#define KERN_DEBUG KERN_SOH "7" /* debug-level messages */
0优先级最高,默认消息级别为4,只有比7级别高的消息才能显示在控制台上。
memcpy
memcpy函数的功能是从源头指向的内存块拷贝固定字节数的数据到目标指向的内存块。
memcpy用法:memcpy(destination,source,num)
,destination是要拷贝的目的地址内存起始地址,source是源头内存块起始地址,num是要拷贝的字节数。
volatile关键字
编译器优化常用的方法有:将内存变量缓存到寄存器;调整指令顺序充分利用CPU指令流水线。
volatile表示该变量随时可能发生变化,使用volatile声明变量时,总是从它所在的内存读取数据,遇到这个关键字声明的变量,编译器对访问该变量的代码不再进行优化,从而可以提供对特殊地址的稳定访问。
用户访问内核
内核空间到用户空间的复制:copy_from_user(to,from,n)
,to为目标用户空间的地址,from是要拷贝的内容的源内核空间地址,n是要拷贝的字节数,成功返回0。
用户空间到内核空间的复制:copy_to_usr(to,from,n)