前言
嵌入式Linux驱动的框架至关重要,了解并且掌握整个框架,对驱动开发很有好处。必须要了解驱动开发的流程、方法,掌握从APP到驱动的调用流程。
说白了,APP是用户层代码,驱动是内核层代码,这里就需要一个框架,来完成如何从应用层调用到内核层方法以及最终操作硬件的目的。
可以简单理解"Linux驱动 = 软件框架 + 硬件操作",其中软件框架必须非常熟悉,硬件操作需要看具体的芯片手册等。
以LED驱动为例,会这样讲解:
这里是什么意思呢?比如GPIO的操作,对于每一家厂商生产的芯片,都有不同的芯片手册,而操作这些GPIO就是驱动需要干的事,为了方便,我们需要开发一个统一的框架来适配多种芯片。其次,每一种芯片又对应着不同的开发板,这时我们也需要开发出一个统一的APP来适配多个开发板。
有几家比较出名的公司:
- RK公司:中文名为瑞芯微电子有限公司,主要在ARM芯片的产品有RK3288、RK3399、RK3326等。
- TI公司:中文名为德州仪器公司,主要ARM芯片的产品有AM335X、AM437X、AM57X等。
- NXP公司:中文名为恩智浦半导体有限公司,主要ARM芯片的产品是i.MX系列,比如i.MX6和i.MX8系列。
- ST公司:中文名为意法半导体,主要ARM芯片产品是STM32系列,比如STM32MP157等。
对于这些厂家提供的芯片手册,嵌入式开发都需要掌握。
正文
驱动程序是内核态的代码,它依赖Linux内核,所以在编写Linux驱动时,需要先得到开发板所使用的Linux内核,并且保证编译驱动的内核就是开发板的内核。
file_operations结构体
当APP打开文件时,可以得到一个整数,这个整数被称为文件句柄。对于APP的每一个文件句柄,在内核中都有一个struct file
结构体对象与之对应:
当我们使用open
打开文件时,传入的flags
、mode
等参数会被记录在该结构体里,同时当去读写文件时,文件的当前偏移地址也会保存在struct file
结构体中的f_pos
成员里。
当我们打开字符设备节点时,内核中也有对应的struct file
结构体,其中重点关注其struct file_operation *f_op
成员,是由驱动程序提供的。
啥意思呢?在Linux中一切皆是文件 ,比如我们读写一个TXT文本文件,这是一个正常的文件,我们可以向里面写内容和读内容。这时,我们也可以把一个字符设备当成一个文件,比如LED灯,我们可以开灯和关灯,以及读取它的状态。正常来说,从高级抽象思维来说,我们需要一个灯对象,然后进行操作。
在Linux中,把这个问题给简化了,这个抽象出来的对象就是一个文件,当我们对文件写1时就是开灯,当写0时就是关灯,从文件读取的值就是LED灯的状态。
在这个前提下,就需要把文件操作与内核驱动对硬件的操作给联系起来 ,这个非常好容易理解。如何对应起来呢?就是前面file_operation
的作用,该结构体如下:
含义非常简单:
- 对于一个正常的文件来说,我们调用
open
时,Linux内核会从文件系统中找到文件,然后做相应返回。 - 而对于设备节点文件来说,调用
open
时就需要调用驱动程序去初始化一些操作,比如对于LED灯来说,驱动程序就需要使能某些模块,让这个LED硬件可以工作。
所以这个struct file
对象在初始化时会根据设备号(后面会详细解说)在chardevs
数组中找到file_operations
对象,该对象中有read
、write
等函数,当用户层调用read
、write
函数时,就会调用到file_operations
对象中的函数。
编写驱动程序几个步骤
至于是如何关联的,我们先不管,现在我们就可以看出如何编写驱动程序的流程了:
- 确定一个设备的主设备号 ,可以让内核分配,在打开
/dev
下的设备节点文件时,struct file
对象会根据主次设备号来查找file_operations
结构体对象。 - 定义属于该设备自己的
file_operations
结构体。 - 实现可以操作设备的
drv_open/drv_read/drv_write
等函数 ,填入上面的file_operation
结构体。 - 把
file_operations
结构体告诉内核,通过register_chrdev
函数,供内核使用。 - 驱动程序是单独写的,需要在内核中运行,所以需要注册驱动程序。在安装驱动程序时,得有一个入口函数。
- 有入口函数,就得有出口函数 ,当卸载驱动程序时,出口函数调用
unregister_chrdev
。 - 晚上其他信息,提供设备信息,创建设备节点 :
class_create
与device_create
。
在上面流程中,我们一定要理解一个流程,就是驱动程序相当于是一个模块,需要后面合并进内核代码 ,而这段驱动程序从哪里开始运行 ,总不能搞个main
函数吧,所以需要一个入口函数。只有开始执行入口函数了,再把一些操作函数给注册进内核。
编写Hello程序
话不多说,我们直接编写代码。因为驱动代码是内核代码,所以为了在编写代码时可以更加方便使用头文件,建议把开发板上运行的Linux系统的源码拷贝一份到Source Insight中,并且加以同步,然后我们就可以把要编写的代码拖到该工程中,这样就非常方便查找源码了。
驱动程序
直接来看驱动程序:
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>
/* 1. 确定主设备号 */
static int major = 0;
static char kernel_buf[1024];
static struct class *hello_class;
#define MIN(a, b) (a < b ? a : b)
/* 3. 实现对应的open/write/read等函数,填入file_operations结构体 */
/*
* 当在用户层调用read函数时,Linux内核会调用该函数,需要把位于Linux内核态的kerenl_buf数据返回给用户态,
* 需要调用copy_to_user函数。
*/
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 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 int hello_drv_open (struct inode *node, struct file* file)
{
printk("%s %s line %d \n", __FILE__, __FUNCTION__, __LINE__);
return 0;
}
static int hello_drv_close (struct inode *node, 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); /* /dev/hello */
hello_class = class_create(THIS_MODULE, "hello_class");
err = PTR_ERR(hello_class);
if (IS_ERR(hello_class)) {
printk("%s %s line %d \n", __FILE__, __FUNCTION__, __LINE__);
unregister_chrdev(major, "hello");
return -1;
}
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "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));
class_destroy(hello_class);
unregister_chrdev(major, "hello");
}
/* 7. 完善其他信息,自动创建设备节点 */
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
上面代码实现的几个流程必须要熟记,后续编写驱动也是一样的套路,其中涉及的细节我们一一来分析:
static
关键字
- 在C语言中,
static
关键字有多个作用,具体取决于它在不同的上下文中:- 在函数内部使用:在函数内部使用
static
关键字声明的变量具有静态存储期。这意味着该变量在程序的整个执行过程中都存在,并且只被初始化一次。此外,该变量的作用域仅限于声明它的内部,其他函数无法访问。 - 在全局变量前使用:在全局变量前使用
static
关键字声明的变量具有内部链接。这意味着该变量只能在声明它的源文件内访问,可以防止命名污染。 - 在函数声明前使用:在函数声明前使用
static
关键字表示该函数具有内部链接。这意味着该函数只能在当前源文件内调用,无法被其他文件调用。
- 在函数内部使用:在函数内部使用
file_operations注册以及获取
-
前面说了在APP中打开设备节点时,会有一个
struct file
结构体,其中有file_operations
成员,而这个file_operations
是由驱动程序注册的 ,所以需要一个识别符,驱动程序用这个识别符注册file_operations
结构体到系统中,然后在使用时通过这个识别符找到之前对应的对象。搞清楚这个流程我们重点关注一下
register_chrdev
函数,即注册字符设备。源码如下:Cmajor = register_chrdev(0, "hello", &hello_drv); static inline int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops){ return __register_chrdev(major, 0, 256, name, fops); } int __register_chrdev(unsigned int major, unsigned int baseminor, unsigned int count, const char *name, const struct file_operations *fops) { struct char_device_struct *cd; struct cdev *cdev; ... cd = __register_chrdev_region(major, baseminor, count, name); ... cdev = cdev_alloc(); ... cdev->owner = fops->owner; cdev->ops = fops; kobject_set_name(&cdev->kobj, "%s", name); err = cdev_add(cdev, MKDEV(cd->major, baseminor), count); ... cd->cdev = cdev; return major ? 0 : cd->major; ... return err; }
首先是__register_chrdev_region
函数用来注册字符设备的区域,它的作用就是查看设备号(major, baseminor)
到(major, baseminor+count-1)
有没有被占用,如果没有就使用这块区域。
这里牵扯到一个概念,就是主设备号和次设备号,主设备号就是major,次设备号就是baseminor,这俩者结合就构成了一个唯一的识别符。
在内核中有一个chrdevs
数组,名字来看是字符设备数组,定义如下:
C
static struct char_device_struct {
struct char_device_struct *next;
unsigned int major;
unsigned int baseminor;
int minorct;
char name[64];
struct cdev *cdev; /* will die */
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];
可以发现该数组大小是CHRDEV_MAJOR_HASH_SIZE
即255,如果用它数组来保存所有字符设备肯定不够,这里它其实实现了一个Hash
加链表的结构,类似Java中HashMap
的原理实现。
首先通过major
取余来确定数组项,比如主设备号1和256都会使用chardevs[1]
,而chardevs[1]
是一个链表,链表里由多个char_device_struct
结构体。
可以发现每个chardevs[i]
都是一个链表,每个元素表示一个驱动程序 。结合char_device_struct
结构体,它指定了主设备号major
、次设备号baseminor
、个数minorct
,以及在cdev中含有file_operations
结构体。
由这里可见,一个驱动程序不仅有主设备号,还有子设备号。从char_device_struct
结构体的定义来看,它的含义是:主次设备号为(major, baseminor)、(major, baseminor+1)...(major, baseminor+minorct-1)的这些设备,都使用同一个file_operations
来操作。
所以现在要更改说法了:当APP打开一个设备节点文件时,内核通过主、次设备号,找到对应的file_operations结构体。
然后调用cdev_add
方法,把cdev
结构体注册进内核:
C
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
int error;
p->dev = dev;
p->count = count;
error = kobj_map(cdev_map, dev, count, NULL,
exact_match, exact_lock, p);
if (error)
return error;
kobject_get(p->kobj.parent);
return 0;
}
这个函数涉及kobj的操作,这是一个通用的链表操作函数,作用是把cdev结构体放入cdev_map链表中 ,对应的索引值是dev到dev+count-1,因为在这个范围内,都是用的同一个file_operations
结构体。
在调用代码err = cdev_add(cdev, MKDEV(1, 2), 10);
中的MKDEV(1,2)就可以构造出一个整数1<<8 | 2
,计算可得是0x102,然后将cdev放入cdev_map中,对应的索引值是0x102到0x10c,根据任意一个索引值都可以找出该cdev。
最后就是当APP打开字符设备节点时,在内核里根据主次设备号计算出一个值,然后从cdev_map中快速找到cdev,再从cdev中得到file_operations结构体,关键函数如下:
这样就完成了一个闭环流程。
生成设备节点
-
然后就是
class_create
与device_class
方法的使用。前面说了驱动程序的核心就是file_operations
结构体,那么就还有一个问题,就是在应用层怎么知道打开哪个文件来操作这个结构体。当调用
class_create
方法时,会在/sys/class
目录下创建一个子目录hello_class
,然后调用device_create
方法时,会在上面hello_class
目录下创建一个hello
程序,这个hello
程序就是设备的信息:其中
245:0
就是主次设备号。看到这里也发现不对啊,我们一般不都是操作/dev
目录下的设备节点吗?这就需要udev
或者mdev
应用程序来创建设备节点 。 udev的全称是userspace device manager
,它负责在内核探测到新设备或者设备变化时,动态地创建、删除和管理设备节点,每个设备对应一个文件,位于/dev
目录下,用于用户程序和设备之间的通信。
应用程序
前面所编写的是驱动程序,需要在内核中运行的,是内核态代码。我们应用程序肯定无法直接操作,我们就通过前面所说的来操作/dev
下的设备节点即可,这个相当于是一个桥梁,代码如下:
C
int main(int argc, char **argv)
{
int fd;
char buf[1024];
int len;
if (argc < 2)
{
printf("Usage: %s -w <string> \n", argv[0]);
printf(" %s -r\n", argv[0]);
return -1;
}
fd = open("/dev/hello", O_RDWR);
if (fd == -1)
{
printf("can not open /dev/hello \n");
return -1;
}
if ((0 == strcmp(argv[1], "-w")) && (argc == 3)) {
len = strlen(argv[2]) + 1;
len = len < 1024 ? len : 1024;
write(fd, argv[2], len);
} else {
len = read(fd, buf, 1024);
buf[1023] = '\0';
printf("APP read : %s \n", buf);
}
close(fd);
return 0;
}
这里我们直接使用glibc库中的open
、write
等应用层函数,打开设备节点文件,就可以对其进行读写,而最终会调用到我们之前写的驱动程序。
编译和运行程序
首先明白一点,就是驱动程序在运行在内核中的,所以当内核变化时,必须重新编译内核,这里的测试程序没有改变内核。
由于要编译驱动程序和应用程序,我们可以写个makefile:
C
# 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.1-2017.05-x86_64_aarch64-linux-gnu/bin
# 注意: 不同的开发板不同的编译器上述3个环境变量不一定相同,
# 请参考各开发板的高级用户使用手册
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
根据不同的开发板环境,需要设置ARCH架构、CROSS_COMPILE交叉编译链以及PATH交叉编译链的路径等全局变量,以及还要设置内核源码路径KERN_DIR。
这里的编译分为2步,第一步的使用gcc进行交叉编译应用程序就不说了,已经很熟悉了。我们来看看编译内核模块的指令:
go
make -C $(KERN_DIR) M=`pwd` modules
说明如下:
-C $(KERN_DIR)
指定了内核源码所在的目录。-C
选项告诉make
进入指定的目录,然后执行Makefile中的规则。M=$(pwd)
设置了一个名字为M的变量,将其值设置为当前工作的路径。这个变量的作用是告诉make在哪个目录下寻找并且编译内核模块。modules
是指定要构建的目标类型,表示只编译内核模块,而不编译整个内核。
ok,写完makefile,把这3个文件通过FileZilla上传到Linux虚拟机中,直接执行make:
编译成功后,会生成内核模块hello_drv.ko和测试程序。接下来就是运行程序了:
- 把它们拷贝到nfs文件系统目录 :
cp *.ko hello_drv_test ~/nfs_rootfs/
; - 然后启动开发板,设置IP,先执行
echo "7 4 1 7" > /proc/sys/kernel/printk
打开内核的打印信息。 - 挂载nfs文件系统 ,
mount -t nfs -o nolock,vers=3 192.168.5.11:/home/book/nfs_rootfs /mnt
; - 进入
/mnt
目录下,安装驱动程序:
csharp
[root@imx6ull:/mnt]# insmod hello_drv.ko
[27227.311479] hello_drv: loading out-of-tree module taints kernel.
[27227.335147] /home/book/src/01_hello_drv/hello_drv.c hello_init line 75
可以发现打印驱动文件的打印已经打印了。
- 安装完之后,会在
/dev
下面生成hello
设备节点,可以对该节点进行操作。 - 可以使用应用程序进行读写了:
bash
[root@imx6ull:/mnt]# ./hello_drv_test -w hello,linux
[97530.106715] /home/book/src/01_hello_drv/hello_drv.c hello_drv_open line 50
[97530.113779] /home/book/src/01_hello_drv/hello_drv.c hello_drv_write line 43
[97530.138969] /home/book/src/01_hello_drv/hello_drv.c hello_drv_close line 56
[root@imx6ull:/mnt]# ./hello_drv_test -r
[97539.483653] /home/book/src/01_hello_drv/hello_drv.c hello_drv_open line 50
[97539.504630] /home/book/src/01_hello_drv/hello_drv.c hello_drv_read line 35
APP read : hello,linux [97539.512820] /home/book/src/01_hello_drv/hello_drv.c hello_drv_close line 56
到这里,我们完成了一个不操作硬件的Hello驱动程序,核心点是代码框架以及如何编译驱动程序。
总结
还是那句话,"Linux驱动=软件框架+硬件操作",本篇文章不涉及任何硬件操作,但是整个流程非常重要,做个简单总结。
首先就是一切皆是文件的思想,在应用层操作设备是通过操作文件来做的,常见的read/write
函数,通过file_operations
结构体,最终会调用到驱动程序中的对应函数:
其次就是编写驱动程序的套路,不外乎就是定义一些函数,把设备告诉内核让应用层来使用,涉及几个固定步骤:
最后就是编译和运行驱动程序和测试程序,也有几个固定步骤:
大体的框架搞明白后,后面来看看如何操作硬件以及优化框架。