0基础写一个Hello驱动程序

前言

嵌入式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打开文件时,传入的flagsmode等参数会被记录在该结构体里,同时当去读写文件时,文件的当前偏移地址也会保存在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对象,该对象中有readwrite等函数,当用户层调用readwrite函数时,就会调用到file_operations对象中的函数。

编写驱动程序几个步骤

至于是如何关联的,我们先不管,现在我们就可以看出如何编写驱动程序的流程了:

  1. 确定一个设备的主设备号 ,可以让内核分配,在打开/dev下的设备节点文件时,struct file对象会根据主次设备号来查找file_operations结构体对象。
  2. 定义属于该设备自己的file_operations结构体。
  3. 实现可以操作设备的drv_open/drv_read/drv_write等函数 ,填入上面的file_operation结构体。
  4. file_operations结构体告诉内核,通过register_chrdev函数,供内核使用。
  5. 驱动程序是单独写的,需要在内核中运行,所以需要注册驱动程序。在安装驱动程序时,得有一个入口函数
  6. 有入口函数,就得有出口函数 ,当卸载驱动程序时,出口函数调用unregister_chrdev
  7. 晚上其他信息,提供设备信息,创建设备节点class_createdevice_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关键字

  1. 在C语言中,static关键字有多个作用,具体取决于它在不同的上下文中:
    • 在函数内部使用:在函数内部使用static关键字声明的变量具有静态存储期。这意味着该变量在程序的整个执行过程中都存在,并且只被初始化一次。此外,该变量的作用域仅限于声明它的内部,其他函数无法访问。
    • 在全局变量前使用:在全局变量前使用static关键字声明的变量具有内部链接。这意味着该变量只能在声明它的源文件内访问,可以防止命名污染。
    • 在函数声明前使用:在函数声明前使用static关键字表示该函数具有内部链接。这意味着该函数只能在当前源文件内调用,无法被其他文件调用。

file_operations注册以及获取

  1. 前面说了在APP中打开设备节点时,会有一个struct file结构体,其中有file_operations成员,而这个file_operations是由驱动程序注册的 ,所以需要一个识别符,驱动程序用这个识别符注册file_operations结构体到系统中,然后在使用时通过这个识别符找到之前对应的对象

    搞清楚这个流程我们重点关注一下register_chrdev函数,即注册字符设备。源码如下:

    C 复制代码
    major = 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结构体,关键函数如下:

这样就完成了一个闭环流程。

生成设备节点

  1. 然后就是class_createdevice_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库中的openwrite等应用层函数,打开设备节点文件,就可以对其进行读写,而最终会调用到我们之前写的驱动程序。

编译和运行程序

首先明白一点,就是驱动程序在运行在内核中的,所以当内核变化时,必须重新编译内核,这里的测试程序没有改变内核。

由于要编译驱动程序和应用程序,我们可以写个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结构体,最终会调用到驱动程序中的对应函数:

其次就是编写驱动程序的套路,不外乎就是定义一些函数,把设备告诉内核让应用层来使用,涉及几个固定步骤:

最后就是编译和运行驱动程序和测试程序,也有几个固定步骤:

大体的框架搞明白后,后面来看看如何操作硬件以及优化框架。

相关推荐
长弓聊编程11 分钟前
Linux系统使用valgrind分析C++程序内存资源使用情况
linux·c++
陌小呆^O^15 分钟前
Cmakelist.txt之win-c-udp-client
c语言·开发语言·udp
cherub.18 分钟前
深入解析信号量:定义与环形队列生产消费模型剖析
linux·c++
梅见十柒41 分钟前
wsl2中kali linux下的docker使用教程(教程总结)
linux·经验分享·docker·云原生
Koi慢热1 小时前
路由基础(全)
linux·网络·网络协议·安全
传而习乎1 小时前
Linux:CentOS 7 解压 7zip 压缩的文件
linux·运维·centos
我们的五年1 小时前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
IT果果日记1 小时前
ubuntu 安装 conda
linux·ubuntu·conda
Python私教1 小时前
ubuntu搭建k8s环境详细教程
linux·ubuntu·kubernetes
羑悻的小杀马特2 小时前
环境变量简介
linux