在裸机上面开发可以直接操作硬件,而在linux等操作系统上开发却无法直接操作硬件,必须编写驱动程序,通过驱动程序操作硬件。
应用程序如何通过驱动来控制硬件?
应用程序通过对设备文件调用系统接口(如read、write、open等)以调用设备文件对应驱动的对应函数,当然前提是该驱动中有对应的函数可供调用,如果没有,则会出错。
如何编写驱动?怎样创建设备文件?
接下来就让我们一起来学习如何 编写一个简单的驱动 hello_drv。
就像我们编写的第一个C语言程序一样
#include "stdio.h"
int main(void) {
printf("hello world!\n");
return 0;
}
驱动程序也是有一个简单的框架的,一个最简单的驱动程序的框架如下:
#include <linux/module.h>
int init_module(void) {
return 0;
}
void cleanup_module(void) {
}
MODULE_LICENSE("GPL");
刚开始时我们可以不用弄清除为什么这么写,先写,以后慢慢的就懂了(当然自己要尽力的去弄懂,实在不懂的可以先放一放)。
有了第一个驱动程序后我们应该去编译它,驱动程序是基于内核开发的,所以我们在驱动中包含的头文件都是内核文件,我们的编译自然也不能像以前一样gcc,我们必须要指定内核的路径,这样才能成功编译。这里我直接提供一个Makefile
#后面的hello_drv是你要生成的模块的名字,可自行修改
obj-m := hello_drv.o
CURRENT_PATH := $(shell pwd)
LINUX_KERNEL := $(shell uname -r)
LINUX_KERNEL_PATH := /usr/src/linux-headers-$(LINUX_KERNEL)
all:
$(MAKE) -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modules
clean:
rm *.ko
rm *.o
其中hello_drv.o
是目标文件的名字,可自行修改。
执行make后就能看到目录下多了很多文件,其中有一个hello_drv.ko就是我们需要的kernel object文件。
看到这个文件就说明我们的驱动模块编译成功了,接下来就是装载模块了。使用sudo insmode hello_drv.ko
装载模块,使用lsmod
查看所有已经装载的模块,仔细找找就能发现我们装载的模块。使用sudo rmmod hello_drv
(注意后面不需要加.ko)可以卸载模块。
上面的驱动程序是最简单的驱动程序,什么都没干,什么现象都没有,下面我们开始尝试在上面的代码中添加一些东西
#include <linux/module.h>
int init_module(void) {
printk("hello drv");
return 0;
}
void cleanup_module(void) {
}
MODULE_LICENSE("GPL");
跟上面相比这里制作了轻微的修改---------输出一个hello drv
,不过值得注意的是并不是使用printf函数,而是使用printk也就是print kernel函数。将这个输出语句放在init_module中意味着模块加载的时候会输出hello drv
,因为init_module函数是模块被加载时被调用的函数。但是这个输出并不会直接输出到控制台,我们必须通过dmesg命令才能看到输出的内容。
如果有人按照上面的步骤尝试了,但是并没有看到hello drv的信息,而是看到如下的信息:
hello_drv: loading out-of-tree module taints kernel.
hello_drv: module verification failed: signature and/or required key missing - tainting kernel
你只需要卸载模块,然后重新装载一次就行了(注意期间不能重启哦),每次重启后第一次模块装载都会出现如上的字样。
关于驱动的入口函数名和出口函数名
驱动有固定的入口函数和出口函数名,但是难道每次都必须用固定的函数名吗?其实不是的,我们可以用自己喜欢的任意函数名字当作驱动入口或者出口函数的名字。只需要使用module_init()和module_exit()这个宏进行说明就行。例如你可以尝试一下编译下面的模块。
#include <linux/module.h>
int hello_init(void) {
printk("hello drv init\n");
return 0;
}
void hello_exit(void) {
printk("hello drv exit\n");
}
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);
虽然函数名变了,但是模块仍然可以在装载和卸载的时候调用该函数。
int hello_init(void) 与 int __init hello_init(void)的区别是加了__init后该函数不会加载进内存中,因为只有在加载的时候调用一次。
void hello_exit(void) 与 void __exit hello_exit(void),如果一个模块被编进内核,那么该模块是无法卸载的,所以在一般的模块中__exit没有实际意义,但是对于被编进内核中的模块而言__exit会使出口函数不会加载进内存中。
进一步理解驱动程序
先看一个程序
#include <linux/module.h>
#include <linux/fs.h>
static unsigned int major = 0;
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
};
int hello_init(void) {
printk("hello drv init\n");
major = register_chrdev(0, "hello_drv", &hello_fops);
return 0;
}
void hello_exit(void) {
printk("hello drv exit\n");
unregister_chrdev(major, "hello_drv");
}
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);
相比于上个程序,这个程序多了一个结构体的定义以及两个函数的调用,先说结构体,file_operations结构体中包含了对file(文件)的各种operations(操作),查看定义如下:
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 (*iopoll)(struct kiocb *kiocb, bool spin);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*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 *);
unsigned long mmap_supported_flags;
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);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;
除了一个owner之外清一色的都是函数指针,我们可以让函数指针指向对应的函数,这样当我们对一个设备文件进行相应操作的时候就会调用对应驱动中对应函数指针指向的函数。
在我们的函数中我们没有让函数指针指向任何函数,只是一个简单的.owner = THIS_MODULE
,这里顺便说一个.owner是指结构体中名为owner的成员,通过这种C99后新出现的语法就能单独对结构体中的某个属性进行定义,非常方便。
register_chrdev函数用于注册一个字符设备,它的第一个参数是想要注册的主设备号(如果传入0则表示让系统分配),第二个参数是设备的名字,第三个参数是一个file_operations结构体。通过注册一个字符设备,我们就可以用cat /proc/devices
查看设备了,仔细找找,一定能找到注册的设备号,unregister_chrdev自然就是取消字符设备的注册了。
光注册设备还不行,我们必须还得创建节点,在shell下使用sudo mknod /dev/hello_drv c 240 0
创建一个字符设备节点。之后就能对该设备节点进行open、read、write等操作了。
在创建了一个设备节点之后,我们可以编写一段测试代码来对设备节点进行操作,测试代码如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("/dev/hello_drv", O_RDWR);
if(fd == -1) {
printf("open error\n");
return -1;
}
else {
printf("open success\n");
int ret = write(fd, "hello", 5);
if(ret == -1) {
printf("write error\n");
return -1;
}
else {
printf("write success\n");
}
}
printf("end\n");
}
调用该测试代码,你就会发现open成功但是write失败了,如果你连open都没有成功的话那么一定是你没有sudo。其实由此我们也可以知道会提供一个默认的open函数而不会提供默认的write函数。那不妨让我们自己写一个write函数来看看会有什么效果吧。
在驱动代码中添加一个write函数,然后再次调用测试代码。添加write函数后的驱动代码如下:
#include <linux/module.h>
#include <linux/fs.h>
static ssize_t hello_write (struct file *, const char __user *, size_t, loff_t *);
static unsigned int major = 0;
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.write = hello_write,
};
int hello_init(void) {
printk("hello drv init\n");
major = register_chrdev(0, "hello_drv", &hello_fops);
return 0;
}
void hello_exit(void) {
printk("hello drv exit\n");
unregister_chrdev(major, "hello_drv");
}
ssize_t hello_write (struct file *FILE, const char __user *buf, size_t len, loff_t * off) {
return 0;
}
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);
此时就会发现write成功了,其实就是当我们对设备文件调用write函数的时候,会先去检测字符设备对应的file_operations结构体中是否有对write函数的定义,如果有,则调用该write函数,如果没有则返回一个错误。当然对于该结构体中的其他函数也是一样的了。
编写有意义的驱动程序
前面的步骤让我们对驱动的理解更深一步了,但是单单只是在write函数中返回一个 0 并不是我们想要的结果,我们希望能通过write函数往文件中写数据,并且能通过read函数将数据读出来。这样才算是实现了一个有意义的驱动程序,这也是我们本阶段的目标。
在此之前,需要先介绍几个函数:
-
static inline int copy_to_user(void __user volatile *to, const void *from, unsigned long n)
copy_to_user函数有三个参数,其中第一个参数是目标缓冲区的地址,第二个参数是源缓冲区的地址,第三个参数是要拷贝的长度。从函数名我们可以知道这个函数是把数据拷贝到用户区中去,从哪里拷贝到用户区呢 ? 当然是从内核区了。
-
static inline int copy_from_user(void *to, const void __user volatile *from, unsigned long n)
copy_from_user也是三个参数,其中第一个参数是目标缓冲区的地址,第二个参数是源缓冲区的地址,第三个参数是要拷贝的长度。从函数名我们可以知道这个函数是从用户区中拷贝数据,拷贝到哪儿呢?当然是内核区啦!
学会这两个函数之后我们就可以简单的完成我们上面的需求了,驱动代码和测试代码如下:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
static ssize_t hello_write (struct file *, const char __user *, size_t, loff_t *);
ssize_t hello_read (struct file *, char __user *, size_t, loff_t *);
static unsigned int major = 0;
static char kernel_buf[10];
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.write = hello_write,
.read = hello_read,
};
int hello_init(void) {
printk("hello drv init\n");
major = register_chrdev(0, "hello_drv", &hello_fops);
return 0;
}
void hello_exit(void) {
printk("hello drv exit\n");
unregister_chrdev(major, "hello_drv");
}
ssize_t hello_write (struct file *FILE, const char __user *buf, size_t len, loff_t * off) {
if(len > 10) return -1;
copy_from_user( kernel_buf, buf, len);
return len;
}
ssize_t hello_read (struct file *FILE, char __user * buf, size_t len, loff_t *off) {
copy_to_user(buf, kernel_buf, len);
return len;
}
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
if(argc < 2) {
printf("格式错误\n");
return -1;
}
int fd = open("/dev/hello_drv", O_RDWR);
if(fd == -1) {
printf("open error\n");
return -1;
}
else {
printf("open success\n");
if(!strcmp(argv[1], "-w")) {
int ret = write(fd, argv[2], strlen(argv[2]));
printf("成功写入 %d 个字节\n",ret);
}
else if(!strcmp(argv[1], "-r")) {
char buf[11];
int ret = read(fd, buf, 5);
buf[ret] = '\0';
printf("读到的数据为 %s\n",buf);
}
else {
printf("未定义的操作\n");
return -1;
}
}
printf("end\n");
}
虽然上面的代码有很多不完善的地方,但是确实可以观测到我们想要的现象。
luoxin@luoxin-virtual-machine:/mnt/hgfs/linux_driver/hello_drv$ sudo ./hello_drv_test -w 12345
open success
成功写入 5 个字节
end
luoxin@luoxin-virtual-machine:/mnt/hgfs/linux_driver/hello_drv$ sudo ./hello_drv_test -r
open success
读到的数据为 12345
end
luoxin@luoxin-virtual-machine:/mnt/hgfs/linux_driver/hello_drv$ sudo ./hello_drv_test -w hello
open success
成功写入 5 个字节
end
luoxin@luoxin-virtual-machine:/mnt/hgfs/linux_driver/hello_drv$ sudo ./hello_drv_test -r
open success
读到的数据为 hello
end
- 构造一个file_operation结构体
- 将结构体放入chrdevs数组中(需要先确定主设备号)
入口函数:每个驱动都有一个入口函数和一个出口函数,入口函数和出口函数都是固定的,但是我们可以通过module_init()这个宏来指定函数成为入口函数。如果你不想使用module_init(),那么你必须指定入口函数名字为int init_module(void)