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结构体,最终会调用到驱动程序中的对应函数:

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

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

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

相关推荐
xq5148634 分钟前
Linux系统下安装mongodb
linux·mongodb
柒七爱吃麻辣烫5 分钟前
在Linux中安装JDK并且搭建Java环境
java·linux·开发语言
孤寂大仙v41 分钟前
【Linux笔记】——进程信号的产生
linux·服务器·笔记
深海蜗牛1 小时前
Jenkins linux安装
linux·jenkins
愚戏师1 小时前
Linux复习笔记(三) 网络服务配置(web)
linux·运维·笔记
Despacito0o1 小时前
RGB矩阵照明系统详解及WS2812配置指南
c语言·线性代数·矩阵·计算机外设·qmk
JANYI20182 小时前
嵌入式MCU和Linux开发哪个好?
linux·单片机·嵌入式硬件
熊大如如2 小时前
Java NIO 文件处理接口
java·linux·nio
晚秋大魔王2 小时前
OpenHarmony 开源鸿蒙南向开发——linux下使用make交叉编译第三方库——nettle库
linux·开源·harmonyos
农民小飞侠2 小时前
ubuntu 24.04 error: cannot uninstall blinker 1.7.0, record file not found. hint
linux·运维·ubuntu