韦东山Linux驱动入门实验班(4)LED驱动

2023年 8月 7日 60.9k 0

前言

(1)我们学习完hello驱动之后,对驱动程序开发有了一点点认识了之后。现在可以开始对硬件进行实际操作了,本人使用的是i.max6ull开发板,STM32MP157和全志的D1H也会进行讲解。
(2)如果还有对于hello驱动程序还不太了解的,请看:
韦东山Linux驱动入门实验班(1)hello驱动;
韦东山Linux驱动入门实验班(2)hello驱动—驱动层与应用层通讯,以及自动产生设备节点;
韦东山Linux驱动入门实验班(3)hello驱动—申请指定数量的次设备号
(3)注意:因为韦东山老师的代码是在他自己的框架上写的,为了方便新手学习理解,所以我进行了微调,把不需要用到的地方进行了删改。

理解Linux驱动如何指向一个GPIO

查看原理图

(1)在编写驱动程序的时候,如果我们需要进行点灯操作,首先要知道控制的是哪一个引脚才可以真正的操作LED。我们通过在原理图中查找发现,LED是由GPIO5_3控制。

在这里插入图片描述

直接通过寄存器来操作GPIO

(1)我们在学习入门视频的时候,常常会看到他们使用ioremap()函数对寄存器进行映射,然后直接操作寄存器。不再要使用这个寄存器的时候,就调用iounmap()函数进行释放。
(2) 这样编写毫无疑问,非常原始,就像是在写51单片机的程序。但是不同的在于,51单片机的寄存器并不多,所以直接操作寄存器并不麻烦。而i.max6ull这个级别的芯片,寄存器一大堆,再直接进行寄存器操作,无疑非常麻烦。
(3)因为直接通过寄存器来操作GPIO基本用不上,所以我不会进行讲解,想要学习的,可以看看正点原子,或者韦东山驱动教学视频。我这里将会讲解,如何使用Linux统一接口来操作GPIO。

利用引脚号操作GPIO

引脚号的概念

(1)从上面的原理图,我们知道了LED是由GPIO5_3控制之后,就可以直接开始操作了吗?
(2)不对,在 Linux 中,GPIO 的标识和控制通常是通过引脚号来进行的,引脚号是用于唯一标识特定的 GPIO 引脚。
(3)如果我们有stm32,msp430这种裸机开发经验,就会发现,不同的芯片对于GPIO的名字定义是不同的。比如STM32将引脚定义成PA0,PB4这种。但是如果是MSP430单片机,他对引脚的定义是P2.3,P1.0这样的。不同厂家对自己的芯片GPIO名字不同。
(4)这种GPIO名字不同,会导致什么结果呢?这样会让驱动开发人员总是要记,不同的芯片的命名规则,显然是非常麻烦的。于是,Linux规定了,我不管你是什么芯片厂家,不管你怎么命名,你如果要跑Linux,就必须将引脚变成一个数字,这个数字叫做引脚号。驱动开发人员,只需要知道这个引脚对应的引脚号,就可以进行操作了。而把这个引脚变成引脚号的过程,就是由芯片原厂的工程师来做了。
(5)关于引脚号的获取,最简单的办法就是,直接联系厂家询问。比如如下是飞腾的芯片,他们的引脚映射表。(在此,感谢C站大佬第二层皮-合肥提供的图片和指导,以及交流群中ID为渐行渐远渐无书 大佬的指点)

在这里插入图片描述

i.max6ull引脚号获取

(1)
但是,这个时候可能就会有人有疑惑了,现在我的这个i.max6ull开发板要控制GPIO5_3,而且我找不到他们的引脚映射表,那么他这个引脚号是多少呢?
如果找不到映射表,我们连接上开发板,输入指令:cat /sys/kernel/debug/gpio即可获得GPIO的映射表,以及他的起始地址。

在这里插入图片描述

(2)
这个时候有人可能就会认为,这里搜索到的gpiochip5就是GPIO5了。答案是否定的。
为什么这么说呢?因为我上面说了,不同厂家对GPIO的命名是不同的,他们厂家的工程师最终会将这些GPIO抽象成一个引脚号。在这个抽象的过程中驱动中的命名可能会和原理图上的命名有些许出入,比如i.max6ull开发板的GPIO5就是gpiochip4,因为i.max6ull开发板是从GPIO1开始进行计算,而驱动程序中,是从gipo0开始的。
如何确定是这样的呢?首先我们看上图,指导gpio0的地址为209c000,那么直接打开芯片手册,可以看到GPIO1的起始地址为209c000,正好对应。

在这里插入图片描述

(4)现在指导GPIO5对应gpio4了,然后从终端中可以指导,gpio4的起始引脚号为128,那么GPIO5_3的引脚号就是128+3=131。

在这里插入图片描述

STM32MP157_Pro引脚号获取

(1)同理,在终端输入:cat /sys/kernel/debug/gpio指令。
(2)我们会发现,ST的这个信息还是很友善的,直接说明了,GPIOA是gpiochip0。假如我们要控制PA10,那么引脚号就是10了。

在这里插入图片描述

D1H引脚号获取

因为全志在相关信息提供的比较垃圾,所以我直接说结论吧。如果是我们要控制PC1,那么引脚号就是2*32+1=65。他PA0对应引脚号0,PB0对应引脚号32,依次类推。

Linux的统一接口 — GPIO子系统

为什么需要统一接口

(1)讲解Linux的GPIO子系统之前,我先拿单片机开发做引子。
(2)对于绝大多数人而言,学习嵌入式开发,都是从51单片机开始的。STC89C52作为51单片机的一款经典单片机,大家多多少少都有些许了解。
(3)在编写STC89C52单片机的程序时候,我们都是直接对寄存器进行操作的,比如下面这个串口初始化程序。

void UartInit(void)		//9600bps@11.0592MHz
{
	SCON = 0x50;		//8位数据,可变波特率
	AUXR &= 0xBF;		//定时器1时钟为Fosc/12,即12T
	AUXR &= 0xFE;		//串口1选择定时器1为波特率发生器
	TMOD &= 0x0F;		//设定定时器1为16位自动重装方式
	TL1 = 0xE8;		//设定定时初值
	TH1 = 0xFF;		//设定定时初值
	ET1 = 0;		//禁止定时器1中断
	TR1 = 1;		//启动定时器1
}

(4)学习完51单片机之后,大多数人开始进阶STM32F103这款芯片。因为STM32F103这款芯片的寄存器很多,直接使用寄存器开发,查手册会相当的麻烦。于是ST公司就封装了一些库,如下为GPIO操作部分的库函数。

在这里插入图片描述

(5)学习完STM32之后,可能有些人因为电赛,需要学习TI的MSP430。他的部分库函数如下

在这里插入图片描述

(6)亦或者同时TI旗下的TM4C123芯片,部分库函数如下

在这里插入图片描述

(7)我们会发现,不同的芯片,他的库函数大概率是不一样的。假如我们编写了一个业务程序,在STM32上跑的好好的。如果因为某些事情,我要换一款芯片,而这款芯片的库函数和STM32的不一样。最终会导致什么结果?很明显,所有业务程序需要重写编写!这是非常麻烦的事情!
(8)为了防止出现这种情况,Linux规定了,不管你是啥芯片,你只要想跑Linux,就必须给我统一接口!管你什么厂家,你的芯片让GPIO设置为输出的函数,名字必须叫做int gpio_direction_output()!
(9)这样做,存在什么好处呢?显而易见,我们业务代码不需要更改了,如果我们想换一款芯片,只需要底层稍微的改动一下即可。这也是为什么有些人说的,没跑Linux,阶级分明,跑了Linux,众生平等。

GPIO子系统函数介绍

Linux的GPIO子系统中可以通过如下函数配置GPIO。

int gpio_request(unsigned gpio, const char *label);
void gpio_free(unsigned gpio);
int gpio_direction_input(unsigned gpio);
int gpio_direction_output(unsigned gpio, int value);
int gpio_get_value(unsigned gpio);
void  gpio_set_value(unsigned gpio, int value);

gpio_request()

(1)作用: 向Linux 内核中用于请求申请一个 GPIO 引脚的函数。如果我们想对一个引脚进行操作,需要最先调用 gpio_request()这个函数。
(2)gpio : 要请求的 GPIO 引脚号。这个引脚号可以自己直接给出(比如上面花了那么多篇幅讲解的)。还可以通过 of_get_named_gpio 函数从设备树获取指定 GPIO 属性信息(设备树的内容,后面会讲解,这里留个影响即可)。
(3)label : 给GPIO起一个名字,因为直接一个引脚号不方便人阅读,所以可以给这个引脚号起一个名字。随便起名字,只要你自己喜欢,不影响。
(4)返回值 : 如何返回0,表示申请GPIO成功。如果返回负数,表示申请GPIO出现错误。

/****** 函数介绍 ******/
/* 作用 :  向Linux 内核中用于请求申请一个 GPIO 引脚
 * 传入参数 : 
     * gpio : 要请求的 GPIO 引脚号
     * label : 给GPIO起一个名字
 * 返回参数 :  如何返回0,表示申请GPIO成功。如果返回负数,表示申请GPIO出现错误
*/
int gpio_request(unsigned gpio, const char *label);

gpio_free()

(1)作用 : 如果不使用某个 GPIO 了, 那么就需要调用 gpio_free 函数进行释放。
(2)gpio : 要释放的GPIO引脚号。与gpio_request的GPIO引脚号是同一个东西。
(3)返回参数 : 无

/****** 函数介绍 ******/
/* 作用 : 如果不使用某个GPIO了,那么就需要调用 gpio_free 函数进行释放
 * 传入参数 : 
     * gpio : 要释放的GPIO引脚号
 * 返回参数 :  无
*/
void gpio_free(unsigned gpio);

gpio_direction_input()

(1)作用 : 将GPIO配置为输入方向。申请完GPIO之后,需要根据需求配置为输入或者输出,这个函数可以将GPIO设置为输入
(2)gpio : 要设置为输入的GPIO 引脚号
(3)返回参数 : 返回 0,表示成功将 GPIO 引脚设置为输入模式。返回负数,表示出错或无法设置 GPIO 引脚。

/****** 函数介绍 ******/
/* 作用 : 设置某个 GPIO 为输入
 * 传入参数 : 
     * gpio : 要设置为输入的GPIO 引脚号
 * 返回参数 : 设置成功返回 0; 设置失败返回负值
*/
int gpio_direction_input(unsigned gpio);

gpio_direction_output()

(1)作用 : 将GPIO配置为输出方向,并且设置默认输出值。申请完GPIO之后,需要根据需求配置为输入或者输出,这个函数可以将GPIO设置为输出
(2)gpio : 设置为输出的GPIO 引脚号
(3)value : GPIO 默认输出值。如果GPIO初始化成功之后,默认输出的电压。
(4)返回参数 : 返回 0,表示成功将 GPIO 引脚设置为输出模式。返回负数,表示出错或无法设置 GPIO 引脚。

/****** 函数介绍 ******/
/* 作用 : 设置某个 GPIO 为输出,并且设置默认输出值
 * 传入参数 : 
     * gpio : 要设置为输出的GPIO 引脚号
     * value : GPIO 默认输出值
 * 返回参数 : 设置成功返回 0; 设置失败返回负值
*/
int gpio_direction_output(unsigned gpio, int value);

gpio_get_value()

(1)作用 : 获取指定GPIO的电平信息
(2)gpio : 要获取电平值的GPIO标号
(3)返回参数 : 获取电平信息成功,高电平返回1,低电平返回0。GPIO电平获取失败返回负值。

/****** 函数介绍 ******/
/* 作用 : 获取指定GPIO的电平值
 * 传入参数 : 
     * gpio : 要获取电平值的GPIO标号
 * 返回参数 : 获取电平信息成功,高电平返回1,低电平返回0。GPIO电平获取失败返回负值
*/
int gpio_get_value(unsigned gpio);

gpio_set_value()

(1)作用 : 设置指定GPIO的电平值
(2)gpio : 要设置指定GPIO的电平值
(3)value : 要设置的电平值,如果传入0,则表示将GPIO设置为低电平。传入一个非0值,表示将GPIO设置为高电平
(4)返回参数 : 无

/****** 函数介绍 ******/
/* 作用 : 获取指定GPIO的电平值
 * 传入参数 : 
     * gpio : 要设置指定GPIO的电平值
     * value : 要获取电平值的GPIO标号
 * 返回参数 : 无
*/
void  gpio_set_value(unsigned gpio, int value);

代码编写

(1)上面铺垫了这么多,现在可以直接开始写代码了。
(2)写代码的顺序,我建议是从上往下写。先写应用层,搞一个大体的框架。然后再往下面写驱动程序,实现具体操作。

应用层程序编写

(1)先确定需求,我们打算在终端输入如下指令,出现如下图效果

/* 可执行文件名   | 表示要操作哪一盏灯  | 灯状态  |    效果
 * ./led_test    |           | on     |硬件上开灯
 * ./led_test    |           | off    |硬件上关灯
 * ./led_test    |           |        |读取led状态,并且显示在终端
 */

在这里插入图片描述

(2)应用层代码如下。
需要说明的是strcmp()函数是C库函数,用于判断两个字符串是否相等,如果相等返回0,如果不相等返回一个非0值。
strtol()函数是将字符转换为数字。因为我们在命令行中输入的1,其实是字符1,而不是数字1。为了和驱动层统一数据类型,所以这里需要调用这个函数。
(3)注意:因为应用层代码比较简单,我就不进行讲解了。看不懂的,可以看看韦东山Linux驱动入门实验班(1)hello驱动的应用层代码讲解。如果看了这个部分依旧不懂的,建议先学一遍C语言再来学习Linux。

/* 说明 : 
 	*1,本代码是学习韦东山老师的驱动入门视频所写,增加了注释。
 	*2,采用的是UTF-8编码格式,如果注释是乱码,需要改一下。
 	*3,这是应用层代码
 	*4,TAB为4个空格
 * 作者 : CSDN风正豪
*/

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

static int fd;


//int led_on(int which);
//int led_off(int which);
//int led_status(int which);

/* 可执行文件名   | 表示要操作哪一盏灯  | 灯状态  |    效果
 * ./led_test    |           | on     |硬件上开灯
 * ./led_test    |           | off    |硬件上关灯
 * ./led_test    |           |        |读取led状态,并且显示在终端
 */
int main(int argc, char **argv)
{
	int ret;     //存放函数返回值,用于判断函数是否正常执行
	char buf[2]; //存放命令行的后两个字符( [on | off])

	
	//如果传入参数少于两个,打印文件用法
	if (argc < 2) 
	{
		printf("Usage: %s  [on | off]n", argv[0]);
		return -1;
	}


	//打开文件,因为在驱动层中,device_create()函数创建的设备节点名字叫做100ask_led,而设备节点都存放在/dev目录下,所以这里是/dev/100ask_led
	fd = open("/dev/100ask_led", O_RDWR);
	//如果无法打开,返回错误
	if (fd == -1)
	{
		printf("can not open file /dev/100ask_ledn");
		return -1;
	}
	//如果传入了三个参数,表示写入
	if (argc == 3)
	{
		/* write */
		/* 作用 : 将字符串转化为一个整数
		 * argv[1] :  要转换为长整数的字符串
		 * NULL :如果提供了 endptr 参数,则将指向解析结束位置的指针存储在 endptr 中。endptr 可以用于进一步处理字符串中的其他内容
		 * 0 : 设置为 0,则会根据字符串的前缀(如 "0x" 表示十六进制,"0" 表示八进制,没有前缀表示十进制)来自动判断进制
		*/
		buf[0] = strtol(argv[1], NULL, 0);

		//判断是否为打开
		if (strcmp(argv[2], "on") == 0)
			buf[1] = 0;  //因为LED外接3.3V,所以输出低电平才是开灯
		else
			buf[1] = 1;  //因为LED外接3.3V,所以输出高电平才是关灯
		//向字符驱动程序中写入
		ret = write(fd, buf, 2);
	}
	//否则表示读取电平信息
	else
	{
		/* read */
		/* 作用 : 将字符串转化为一个整数
		 * argv[1] :  要转换为长整数的字符串
		 * NULL :指向第一个不可转换的字符位置的指针
		 * 0 : 表示默认采用 10 进制转换
		*/
		buf[0] = strtol(argv[1], NULL, 0);
		//读取电平,从驱动层读取两个数据
		ret = read(fd, buf, 2);
		//如果返回值为2,表示正常读取到了电平。(为什么是2,看驱动程序的gpio_drv_read)
		if (ret == 2)
		{
			//打印引脚信息
			printf("led %d status is %sn", buf[0], buf[1] == 0 ? "on" : "off");
		}
	}
	
	close(fd);
	
	return 0;
}

驱动层代码编写

驱动层代码

/* 说明 : 
 	*1,本代码是学习韦东山老师的驱动入门视频所写,增加了注释。
 	*2,采用的是UTF-8编码格式,如果注释是乱码,需要改一下。
 	*3,这是驱动层代码
 	*4,TAB为4个空格
 * 作者 : CSDN风正豪
*/

#include "asm-generic/errno-base.h"
#include "asm-generic/gpio.h"
#include "asm/uaccess.h"
#include 
#include 

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

//描述一个引脚
struct gpio_desc{
	int gpio;   //引脚编号
    char *name; //名字
};

static struct gpio_desc gpios[] = {
    {131, "led0", },  //引脚编号,名字
};

/* 主设备号                                                                 */
static int major = 0;
static struct class *gpio_class;  //一个类,用于创建设备节点


/* 实现对应的open/read/write等函数,填入file_operations结构体                   */
static ssize_t gpio_drv_read (struct file *file, char __user *buf, size_t size, loff_t *offset)
{
	char tmp_buf[2];  //存放驱动层和应用层交互的信息
	int err;   //没有使用,用于存放copy_from_user和copy_to_user的返回值,消除报错
  int count = sizeof(gpios)/sizeof(gpios[0]); //记录定义的最大引脚数量

	//应用程序读的时候,传入的值如果不是两个,那么返回一个错误
	if (size != 2)
		return -EINVAL;
	
	/* 作用 : 驱动层得到应用层数据
	 * tmp_buf : 驱动层数据
	 * buf : 应用层数据
	 * 1  :数据长度为1个字节(因为我只需要知道他控制的是那一盏灯,所以只需要传入一个字节数据)
	*/
	err = copy_from_user(tmp_buf, buf, 1);
	
	//第0项表示要操作哪一个LED,如果操作的LED超出,表示失败
	if (tmp_buf[0] >= count)
		return -EINVAL;
	
	//将引脚电平读取出来
	tmp_buf[1] = gpio_get_value(gpios[(int)tmp_buf[0]].gpio);
	
	/* 作用 : 驱动层发数据给应用层
	 * buf : 应用层数据
	 * tmp_buf : 驱动层数据
	 * 2  :数据长度为2个字节
	*/
	err = copy_to_user(buf, tmp_buf, 2);
	
	return 2;
}

static ssize_t gpio_drv_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
    unsigned char ker_buf[2];
    int err;
	//应用程序读的时候,传入的值如果不是两个,那么返回一个错误
    if (size != 2)
        return -EINVAL;
	
	/* 作用 : 驱动层得到应用层数据
	 * tmp_buf : 驱动层数据
	 * buf : 应用层数据
	 * size  :数据长度为size个字节
	*/
    err = copy_from_user(ker_buf, buf, size);

	//如果要操作的GPIO不在规定范围内,返回错误
    if (ker_buf[0] >= sizeof(gpios)/sizeof(gpios[0]))
        return -EINVAL;

	//设置指定引脚电平
    gpio_set_value(gpios[ker_buf[0]].gpio, ker_buf[1]);
    return 2;    
}



/* 定义自己的file_operations结构体                                              */
static struct file_operations gpio_led_drv= {
	.owner	 = THIS_MODULE,
	.read    = gpio_drv_read,
	.write   = gpio_drv_write,
};


/* 在入口函数 */
static int __init gpio_drv_init(void)
{
    int err;  //用于保存函数返回值,用于判断函数是否执行成功
    int i;    //因为存在多个GPIO可能要申请,所以建立一个i进行for循环
    int count = sizeof(gpios)/sizeof(gpios[0]);  //统计有多少个GPIO
    
	/*__FILE__ :表示文件
	 *__FUNCTION__ :当前函数名
	 *__LINE__ :在文件的哪一行
	*/
	printk("%s %s line %dn", __FILE__, __FUNCTION__, __LINE__);
	
	for (i = 0; i < count; i++)
	{		
		/* 设置为输出引脚 */
		//申请指定GPIO引脚,申请的时候需要用到名字
		err = gpio_request(gpios[i].gpio, gpios[i].name);
		//如果返回值小于0,表示申请失败
		if (err < 0) 
		{
			//如果GPIO申请失败,打印出是哪个GPIO申请出现问题
			printk("can not request gpio %s %dn", gpios[i].name, gpios[i].gpio);
			return -ENODEV;
		}
		//如果GPIO申请成功,设置输出高电平
		gpio_direction_output(gpios[i].gpio, 1);
	}

	/* 注册file_operations 	*/
	//注册字符驱动程序
	major = register_chrdev(0, "100ask_led", &gpio_led_drv);  /* /dev/gpio_desc */
	
	/******这里相当于命令行输入 mknod  /dev/100ask_gpio c 240 0 创建设备节点*****/
	
	//创建类,为THIS_MODULE模块创建一个类,这个类叫做gpio_class
	gpio_class = class_create(THIS_MODULE, "100ask_led_class");
	if (IS_ERR(gpio_class))   //如果返回错误
	{
		/*__FILE__ :表示文件
		 *__FUNCTION__ :当前函数名
		 *__LINE__ :在文件的哪一行
		*/
		printk("%s %s line %dn", __FILE__, __FUNCTION__, __LINE__);
		//注销字符驱动程序
		unregister_chrdev(major, "100ask_led_class");
		//返回错误
		return PTR_ERR(gpio_class);
	}
	
	/*输入参数是逻辑设备的设备名,即在目录/dev目录下创建的设备名
	 *参数一 : 在gpio_class类下面创建设备
	 *参数二 : 无父设备的指针
	 *参数三 : 主设备号+次设备号
	 *参数四 : 没有私有数据
	*/
	device_create(gpio_class, NULL, MKDEV(major, 0), NULL, "100ask_led"); /* /dev/100ask_gpio */
	
	//如果执行到这里了,说明LED驱动装载完成
	printk("LED driver loading is completen");
	return err;
}

/* 有入口函数就应该有出口函数:卸载驱动程序时,就会去调用这个出口函数
 */
static void __exit gpio_drv_exit(void)
{
    int i;
    int count = sizeof(gpios)/sizeof(gpios[0]);
	/*__FILE__ :表示文件
	 *__FUNCTION__ :当前函数名
	 *__LINE__ :在文件的哪一行
	*/
	printk("%s %s line %dn", __FILE__, __FUNCTION__, __LINE__);
	//销毁gpio_class类下面的设备节点
	device_destroy(gpio_class, MKDEV(major, 0));
	//销毁gpio_class类
	class_destroy(gpio_class);
	//注销驱动
	unregister_chrdev(major, "100ask_led");

	for (i = 0; i < count; i++)
	{
		//将GPIO释放
		gpio_free(gpios[i].gpio);		
	}
	
	//如果执行到这里了,说明LED驱动卸载完成
	printk("The LED driver is uninstalledn");
}


/* 7. 其他完善:提供设备信息,自动创建设备节点                                     */

module_init(gpio_drv_init);  //确认入口函数
module_exit(gpio_drv_exit);  //确认出口函数

/*最后我们需要在驱动中加入 LICENSE 信息和作者信息,其中 LICENSE 是必须添加的,否则的话编译的时候会报错,作者信息可以添加也可以不添加
 *这个协议要求我们代码必须免费开源,Linux遵循GPL协议,他的源代码可以开放使用,那么你写的内核驱动程序也要遵循GPL协议才能使用内核函数
 *因为指定了这个协议,你的代码也需要开放给别人免费使用,同时可以根据这个协议要求很多厂商提供源代码
 *但是很多厂商为了规避这个协议,驱动源代码很简单,复杂的东西放在应用层
*/
MODULE_LICENSE("GPL"); //指定模块为GPL协议
MODULE_AUTHOR("CSDN:qq_63922192");  //表明作者,可以不写

驱动层代码解析

(1)看代码,首先我们需要知道函数入口是什么,才能很好的阅读源码。如果是裸机开发的时候,绝大多数,入口函数就是main()函数,所以阅读裸机程序,都是先找到他的main函数。
(2)而Linux驱动程序则不同,他的函数入口是由module_init()这个宏来定义,当我们在命令行中,输入insmod led_drv.ko装载一个驱动程序的时候。系统就会进入module_init()这个宏里面包含的函数里面。
(3)
如下图,当我们在连接上开发板的命令行输入insmod led_drv.ko指令,内核打印出两行信息。
为什么会打印这两行数据呢?因为我们module_init()这个宏里面包含的函数gpio_drv_init()里面有如下两个printk()语句。
注意:如果装载驱动的时候,没有出现内核打印的数据,就需要在连接上开发板的命令行输入echo “7 4 1 7” > /proc/sys/kernel/printk。

	/*__FILE__ :表示文件
	 *__FUNCTION__ :当前函数名
	 *__LINE__ :在文件的哪一行
	*/
	printk("%s %s line %dn", __FILE__, __FUNCTION__, __LINE__);
	
	//如果执行到这里了,说明LED驱动装载完成
	printk("LED driver loading is completen");

在这里插入图片描述

(4)
现在我们知道了函数入口是啥玩意了,那么现在就开始真正的开始分析代码了。
我们看gpio_drv_init()函数最上面的三个变量申请,err和i看了后面的代码之后还比较好理解。
这里的count申请可能有人会有点小懵。因为为了让程序有更强的灵活性,能够做到修改最少的代码来实现功能,所以这里创建了一个结构体进行封装。
如果我们的板子上有多个LED,想要增加LED的话,只需要在gpios[]这个数组里面写入增加LED的GPIO引脚号和名字即可。之后这个count变量能够自动发生改变。
如果是看韦东山老师的视频过来的,会发现本文的gpio_desc结构体和韦东山老师的不一样。这是因为其他参数用不到,为了方便新手入门,我打算减少干扰项,只留下需要使用的参数。


int err;  //用于保存函数返回值,用于判断函数是否执行成功
int i;    //因为存在多个GPIO可能要申请,所以建立一个i进行for循环
int count = sizeof(gpios)/sizeof(gpios[0]);  //统计有多少个GPIO

//描述一个引脚
struct gpio_desc{
	int gpio;   //引脚编号
    char *name; //名字
};

static struct gpio_desc gpios[] = {
    {131, "led0", },  //引脚编号,名字
    //{132, "led1", },  //引脚编号,名字,如果需要增加GPIO
};

(5)
因为我们要让LED驱动能够有比较好的扩展性。所以上面使用count变量获得LED的GPIO数量,现在只需要一个for语句,依次注册GPIO即可。
在将一个GPIO设置为输入或者输出之前,需要先对这个GPIO进行注册,这个就好像单片机程序里面,需要配置一个GPIO,需要先打开他的时钟一样。
注册完GPIO之后,我们还需要进行判断这个GPIO是否注册成功,如果GPIO没有注册成功,你去进行操作,会产生bug。
GPIO注册成功之后,我们就要配置GPIO的方向了。因为我们这里是要驱动LED,所以需要调用gpio_direction_output()这个函数将GPIO设置为输出方向。
从上面的原理图我们可以知道,当GPIO输出高电平的时候,LED灭,输出低电平,LED亮。一般来说,我们的LED都是默认为熄灭状态。所以gpio_direction_output()第二个参数传入1,表示GPIO默认输出高电平。

	for (i = 0; i < count; i++)
	{		
		/* 设置为输出引脚 */
		//申请指定GPIO引脚,申请的时候需要用到名字
		err = gpio_request(gpios[i].gpio, gpios[i].name);
		//如果返回值小于0,表示申请失败
		if (err < 0) 
		{
			//如果GPIO申请失败,打印出是哪个GPIO申请出现问题
			printk("can not request gpio %s %dn", gpios[i].name, gpios[i].gpio);
			return -ENODEV;
		}
		//如果GPIO申请成功,设置输出高电平
		gpio_direction_output(gpios[i].gpio, 1);
	}

(6)
接下来的代码就没有什么可以讲的了。不懂的可以看前面三篇hello驱动程序讲解。
唯一需要再强调的是,register_chrdev()这个函数注册file_operations结构体,给应用层提供接口。比如我们这里提供了read()和write()函数接口,那么应用层调用read()函数,驱动层就会调用gpio_drv_read()函数。如果应用层调用write()函数,驱动层就会调用gpio_drv_write()函数。
这个时候可能就会有人有疑惑了。你驱动层没有提供open和close函数接口啊。那为什么应用层可以使用open和close函数呢?因为,如果你file_operations 结构体中的.open中没有传入函数指针,那么当你应用层调用open函数的时候,他会先执行打开文件的操作,然后再调用驱动层的.open中定义的函数指针。但是因为驱动层.open中没有定义的函数指针,那么就是默认为一个空函数。
可能有些人会有疑问,file_operations结构体中的.open和.release一般都写一些什么呢?(注意,前面几篇讲过,应用层调用close函数,对应的是驱动层的file_operations结构体中.release赋予的函数指针)
很简单,.open一般是GPIO的初始化程序。比如上面的gpio_drv_init()函数中,我们注册了GPIO,并且将他设置为输出方向。这一部分就可以写在.open中,因为应用层使用open()函数的话,就说明要开始使用这个GPIO了,再进行注册也不迟。
.release一般是GPIO的注销,因为当你应用层调用close()函数的时候,就说明这个GPIO使用完了。可以用于注销。(注意,我们这里的GPIO注销程序在写在驱动的函数里面写了)

/* 注册file_operations 	*/
major = register_chrdev(0, "100ask_led", &gpio_led_drv); 

static struct file_operations gpio_led_drv = {
	.owner	 = THIS_MODULE,
	.read    = gpio_drv_read,
	.write   = gpio_drv_write,
};

(7)
现在成功注册了file_operations 结构体。并且在这个结构体中提供了read和write函数接口。那么我们现在先分析read函数。
为了方便理解,我将应用层有关的代码先提取出来。
先看应用层,我们知道buf[]是一个存放两个字符的数组。buf[0]负责存放控制哪一个LED,buf[1]负责存放LED电平信息(如果的控制LED,就是存放LED亮灭信息。如果是读取LED状态,就是存放LED亮灭信息)。
在应用层,我们使用strtol()函数将命令行中的字符串转换为数字存入buf[0]。
然后我们这个buf[]通过read函数传入给驱动层。并且告诉驱动层,我将会读取2个字节数据回来。
现在开始看驱动层代码。因为应用层会向驱动层传入一个buf[]字符数组,但是驱动层只需要知道buf[0]的数据(也就是只需要知道对哪个LED进行数据读取),所以copy_from_user()函数的第三个参数只需要传入1,也就是驱动层只想知道buf[0]的数据,并且将buf[0]的数据传递给tmp_buf[0]。
知道要控制哪一个LED之后,我们还需要进行依次if判断,因为如果应用层传入的LED不存在,会导致产生故障。
我们在调用gpio_get_value()函数的时候,可能有很多人看到gpios[(int)tmp_buf[0]].gpio会懵圈。我们拆开来看,首先看最里面的(int)tmp_buf[0],我们知道tmp_buf[0]里面存放的是要控制的哪一个LED。比如我们要控制LED0,那么tmp_buf[0] 从应用层中得到的数据是数字0(这也是为什么应用层需要调用strtol函数,将字符转换为数字了)。然而buf是一个字符数组,而gpio_get_value()函数第二个参数需要传入整型数据,所以这里需要进行强制类型转换。
现在我们知道tmp_buf[0]=0,那么gpios[(int)tmp_buf[0]].gpio就是gpios[0].gpio。现在是不是明了很多了,然后gpios[0].gpio就是LED0的引脚号。因此这里可以获得LED0的电平信息,并且存入到tmp_buf[1]。
然后再通过copy_to_user()函数将驱动层数据返回给应用层。
最终应用层就获得了电平信息,并且存放在buf[1]中。

/****************  应用层   ****************/

char buf[2]; //存放命令行的后两个字符( [on | off])
/* 作用 : 将字符串转化为一个整数
 * argv[1] :  要转换为长整数的字符串
 * NULL :指向第一个不可转换的字符位置的指针
 * 0 : 表示默认采用 10 进制转换
*/
buf[0] = strtol(argv[1], NULL, 0);
//读取电平,从驱动层读取两个数据
ret = read(fd, buf, 2);

/****************  驱动层   ****************/
static ssize_t gpio_drv_read (struct file *file, char __user *buf, size_t size, loff_t *offset)
{
	char tmp_buf[2];  //存放驱动层和应用层交互的信息
	int err;   //没有使用,用于存放copy_from_user和copy_to_user的返回值,消除报错
	int count = sizeof(gpios)/sizeof(gpios[0]); //记录定义的最大引脚数量

	//应用程序读的时候,传入的值如果不是两个,那么返回一个错误
	if (size != 2)
		return -EINVAL;
	
	/* 作用 : 驱动层得到应用层数据
	 * tmp_buf : 驱动层数据
	 * buf : 应用层数据
	 * 1  :数据长度为1个字节(因为我只需要知道他控制的是那一盏灯,所以只需要传入一个字节数据)
	*/
	err = copy_from_user(tmp_buf, buf, 1);
	
	//第0项表示要操作哪一个LED,如果操作的LED超出,表示失败
	if (tmp_buf[0] >= count)
		return -EINVAL;
	
	//将引脚电平读取出来
	tmp_buf[1] = gpio_get_value(gpios[(int)tmp_buf[0]].gpio);
	
	/* 作用 : 驱动层发数据给应用层
	 * buf : 应用层数据
	 * tmp_buf : 驱动层数据
	 * 2  :数据长度为2个字节
	*/
	err = copy_to_user(buf, tmp_buf, 2);
	
	return 2;
}

(8)
分析完read函数之后,就开始分析write函数。
明白了read函数之后,write函数其实就很简单了。这个就自行理解,如果还不明白评论区问或者看视频吧。

/****************  应用层   ****************/

/* 作用 : 将字符串转化为一个整数
 * argv[1] :  要转换为长整数的字符串
 * NULL :如果提供了 endptr 参数,则将指向解析结束位置的指针存储在 endptr 中。endptr 可以用于进一步处理字符串中的其他内容
 * 0 : 设置为 0,则会根据字符串的前缀(如 "0x" 表示十六进制,"0" 表示八进制,没有前缀表示十进制)来自动判断进制
*/
buf[0] = strtol(argv[1], NULL, 0);

//判断是否为打开
if (strcmp(argv[2], "on") == 0)
	buf[1] = 0;  //因为LED外接3.3V,所以输出低电平才是开灯
else
	buf[1] = 1;  //因为LED外接3.3V,所以输出高电平才是关灯
//向字符驱动程序中写入
ret = write(fd, buf, 2);

/****************  驱动层   ****************/
static ssize_t gpio_drv_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
    unsigned char ker_buf[2];
    int err;
	//应用程序读的时候,传入的值如果不是两个,那么返回一个错误
    if (size != 2)
        return -EINVAL;
	
	/* 作用 : 驱动层得到应用层数据
	 * tmp_buf : 驱动层数据
	 * buf : 应用层数据
	 * size  :数据长度为size个字节
	*/
    err = copy_from_user(ker_buf, buf, size);

	//如果要操作的GPIO不在规定范围内,返回错误
    if (ker_buf[0] >= sizeof(gpios)/sizeof(gpios[0]))
        return -EINVAL;

	//设置指定引脚电平
    gpio_set_value(gpios[ker_buf[0]].gpio, ker_buf[1]);
    return 2;    
}

(9)
到了最后,如果驱动使用完了,不要用了。可以使用rmmod led_drv.ko指令卸载驱动。
调用这一条指令的时候,我们会进入到module_exit()这个宏所包含的函数里面。
因为我们不再使用这个驱动了,所以就需要卸载类,设备节点,驱动和GPIO。

在这里插入图片描述

总结

本文核心就是那6个GPIO子系统函数,只要掌握了GPIO子系统函数和前面hello驱动的框架,本文没有难度。直接看代码也能够理解。

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论