RK3568嵌入式linux驱动开发笔记


前言

之前在imx6u开发板上已经学习了一次嵌入式linux驱动开发,这次在rk3568中复习一遍,主要是想要总结一下,方便记忆。
主要是参考正点原子的资料。

kernel

.BoardConfig.mk: 没有那个文件或目录

在rk3568Linux系统开发手册的6.2.1内核编译中:《SDK》/kernel目录下

make distclean
./make.sh
cd ..
device/rockchip/common/mk-fitimage.sh kernel/boot.img device/rockchip/rk356x/boot.its

然后报错:device/rockchip/common/mk-fitimage.sh: 行 9: /home/hlf/RK3568/SDK/linux/rk3568_linux_sdk/device/rockchip/.BoardConfig.mk: 没有那个文件或目录

解决方法:需要在sdk目录下,使用 ./build.sh uboot ./build.sh kernel ,怎么说呢,我使用build.sh all 在buildroot编译过程中会报错,所以我干脆不编译all 了,单独编译,但没有使用build.sh ,而是在各目录下使用make.sh,导致部分文件缺失。

一、字符设备驱动开发(第五章)

1.字符设备驱动开发步骤(文档5.2)

1.1 Linux 驱动有两种运行方式

①静态加载:将驱动编译进 Linux 内核中,这样当 Linux 内核启动的时候就会自动运行驱动程序。
②动态加载:将驱动编译成模块(Linux 下模块扩展名为.ko)。

1.2 加载驱动模块代码运行流程

“modprobe”或者“insmod”命令加载驱动模块.ko
···························↓····························
使用“modprobe”命令会调用module_init( xxx_init ) 函数,其中的模块加载/入口函数xxx_init 函数就会被调用。
···························↓····························
xxx_init 为驱动入口函数, 其中需要字符设备注册函数 register_chrdev注册字符设备。
static inline int register_chrdev(unsigned int major,const char *name,const struct file_operations *fops)
···························↓····························
字符设备注册函数 register_chrdev()需要file_operations 结构体,每个字符设备都需要实现一个自己的file_operations 结构体。
···························↓····························
Linux 内核文件 include/linux/fs.h的file_operations 结构体是 Linux 内核驱动操作函数集合,实现需要的函数即可。

1.3 卸载驱动模块代码运行流程

“rmmod”命令卸载具体驱动.ko
···························↓····························
使用“rmmod”命令会调用module_exit( xxx_exit) 函数,其中的模块卸载/出口函数xxx_exit 函数就会被调用。
···························↓····························
xxx_exit为驱动入口函数, 其中需要字符设备注销函数 unregister_chrdev注销字符设备。
static inline void unregister_chrdev(unsigned int major,const char *name)

1.4 添加 LICENSE 和作者信息

LICENSE 是必须添加的,MODULE_LICENSE(“GPL”),LICENSE 采用 GPL 协议。
作者信息可以添加也可以不添加,MODULE_AUTHOR(“alientek”)。

2.linux设备号(5.3)

2.1 设备号的组成

设备号由主设备号和次设备号两部分组成
主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备,多个相同的设备可以使用同一个驱动,所以次设备号不同。

dev_t 的数据类型表示设备号, dev_t 其实就是 unsigned int 类型,是一个 32 位的数据类型。
其中高 12 位为主设备号,低 20 位为次设备号。

typedef u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;

2.2 设备号的分配

①静态分配设备号
使用“cat /proc/devices”命令即可查看当前系统中所有已经使用了的设备号,找一个没用过的即可。

②动态分配设备号
在注册字符设备之前先申请一个设备号;卸载驱动的时候释放掉这个设备号即可。
函数如下:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
void unregister_chrdev_region(dev_t from, unsigned count)

3.字符设备驱动开发实验(5.4)

3.1 printk函数

printf运行在用户态, printk 运行在内核态,内核中使用printk函数向控制台输出或显示一些内容。
printk 可以根据日志级别对消息进行分类,一共有 8 个消息级别,其中 0 的优先级最高, 7 的优先级最低。
显式调用

#define KERN_EMERG KERN_SOH "0" /* 紧急事件,一般是内核崩溃 */
printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");  //显式调用

隐式调用:内核图形化界面配置

Default console loglevel (1-15) //设置默认终端消息级别,MESSAGE_LOGLEVEL_DEFAULT 
Default message log level (1-7) //设置默认消息级别,CONSOLE_LOGLEVEL_DEFAULT

MESSAGE_LOGLEVEL_DEFAULT 设置默认终端消息级别
CONSOLE_LOGLEVEL_DEFAULT控制着哪些级别的消息可以显示在控制台上,高于可显示。

3.2 copy_to_user、 copy_from_user 函数

内核空间不能直接操作用户空间的内存, copy_to_user 函数实现内核空间的数据到用户空间的复制, copy_from_user 将用户空间的数据复制到内核空间中。
函数原型如下:

static inline long copy_to_user(void __user *to, const void *from, unsigned long n)
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

3.3驱动模块操作命令流程

“depmod”命令会自动生成 modules.alias、 modules.symbols 和 modules.dep 等等一些 modprobe 所需的文件
···························↓····························
modprobe 加载驱动模块.ko,ko这个后缀可以不用
···························↓····························
“lsmod”命令可查看当前系统中存在的模块,
cat /proc/devices查看当前系统中所有已经使用了的设备号,
可以看到有模块存在,有设备号信息,但还需要创建设备节点文件
···························↓····························
创建设备节点文件,应用程序就是通过操作设备节点文件来完成对具体设备的操作,
mknod /dev/节点文件名称 c 200 0
ls -ahl /dev/节点,进行查看
···························↓····························
rmmod chrdevbase卸载驱动模块

3.4 用户空间-系统调用-内核空间

驱动中会申请cdev(字符设备),创建设备文件(/dev/XXX),应用程序中会使用open函数打开设备文件(/dev/XXX)得到fd(文件描述符),此时,fd就和驱动绑定在一起了,应用程序使用系统调用read、write、异步、poll之类的函数,都会关联到驱动的ops中。

应用程序中的 open 函数与驱动中的 open 函数参数差异解析
在这里插入图片描述
在这里插入图片描述
应用程序的 write 函数与驱动程序的 write 函数参数差异解析
在这里插入图片描述
在这里插入图片描述

3.5 驱动模块.ko的Makefile

KERNELDIR := /home/han/RK3568/SDK/linux/rk3568_linux_sdk/kernel
CURRENT_PATH := $(shell pwd)
obj-m := dtsled.o

build: kernel_modules

kernel_modules:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean

1、KERNELDIR 表示开发板所使用的 Linux 内核源码目录,使用绝对路径。
2、CURRENT_PATH 表示当前路径。
3、obj-m 表示将 dtsled.c 这个文件编译为 dtsled.ko 模块。
4、kernel_modules中,-C 表示将当前的工作目录切换到指定目录中,也就是 KERNERLDIR 目录。 M 表示模块源码目录,“make modules”命令中加入 M=dir 以后程序会自动到指定的 dir 目录中读取模块的源码并将其编译为.ko 文件。
5、Makefile 编写好以后输入命令编译驱动模块:
make ARCH=arm64 //ARCH=arm64 必须指定,否则编译会失败

二、嵌入式 Linux LED 驱动开发实验

1.MMU地址映射

内存管理单元MMU 全称叫做 MemoryManage Unit,其完成虚拟空间到物理空间的映射和内存保护。
即有了虚拟地址(VA,Virtual Address)、物理地址(PA, Physcical Address)之分, MMU设置好内存映射后,CPU 访问的都是虚拟地址。
但不建议直接通过指针访问这些虚拟地址,有读/写操作函数。

1.1 ioremap 函数

ioremap 函 数 用 于 获 取 指 定 物 理 地 址 空 间 对 应 的 虚 拟 地 址 空 间.

void __iomem *ioremap(resource_size_t res_cookie, size_t size)

1.2 iounmap 函数

卸载驱动的时候需要使用 iounmap 函数释放掉 ioremap 函数所做的映射

void iounmap (volatile void __iomem *addr)

1.3 ioremap 与 mmap 的区别对比

ioremap‌内核空间使用:物理地址到虚拟地址
mmap‌用户空间使用:mmap 建立磁盘文件地址与进程虚拟地址的一一映射关系,进程可通过指针直接读写内存区域,减少 read/write 的系统调用和数据拷贝次数。

2.RK3568 GPIO 驱动原理

GPIO配置有四大点:
①时钟
②复用
③电气属性(驱动功能)
④输入输出、中断、高低电平等配置

LED 接到了 GPIO0_C0,即以GPIO0_C0为例。Chapter 1 System Overview 1.1 Address Mapping中有基地址的信息。

2.1 复用

PMU_GRF_GPIO0C_IOMUX_L :GPIO0C IOMUX control low bits
PMU_GRF 0xFDC20000
Address: Operational Base + offset (0x0010) = 0xFDC20000+0x0010=0xFDC20010

2.2 电气特性,驱动功能

PMU_GRF_GPIO0C_DS_0:GPIO0C driver strength control
PMU_GRF 0xFDC20000
Address: Operational Base + offset (0x0090) = 0xFDC20000+0X0090=0xFDC20090

2.3输入输出等

GPIO_SWPORT_DDR_H:Port Data Direction Register (High)
GPIO0 0xFDD60000
Address: Operational Base + offset (0x000C) = 0xFDD60000+0X000C=0XFDD6000C

GPIO_SWPORT_DR_H :Port Data Register (High)
Address: Operational Base + offset (0x0004) = 0xFDD60000+0X0004=0XFDD60004

三、新字符设备驱动开发

1.分配和释放设备号

register_chrdev 函数注册字符设备的时候只需要给定一个主设备号,会将一个主设备号下的所有次设备号都使用掉
如果没有指定设备号的话就使用如下函数来申请设备号:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

如果给定了设备的主设备号和次设备号就使用如下所示函数来注册设备号即可:

int register_chrdev_region(dev_t from, unsigned count, const char *name)

使用上面两个函数都只占用了主设备号的一个次设备号

通 过 alloc_chrdev_region 函数还是register_chrdev_region 函数申请的设备号,统一使用如下释放函数:

void unregister_chrdev_region(dev_t from, unsigned count)

在这里插入图片描述

2.新的字符设备注册方法

Linux 中使用 cdev 结构体表示一个字符设备
cdev_init 函数对其进行初始化

void cdev_init(struct cdev *cdev, const struct file_operations *fops)

cdev_add 函数用于向 Linux 系统添加字符设备(cdev 结构体变量)

int cdev_add(struct cdev *p, dev_t dev, unsigned count)

功能上:
alloc_chrdev_region/register_chrdev_region + cdev_init + cdev_add = register_chrdev

cdev_del 函数从 Linux 内核中删除相应的字符设备

void cdev_del(struct cdev *p)

功能上:
cdev_del + unregister_chrdev_region = unregister_chrdev

3.自动创建设备节点

3.1 udev与 mdev机制

udev 是 Linux 内核中的一个设备管理器,主要用于动态管理 /dev 目录下的设备节点。
mdev 是 Linux 系统中一个轻量级的设备管理工具,主要用于嵌入式或资源受限的环境中。它是 BusyBox 套件的一部分,功能类似udev。

3.2 创建和删除类

自动创建设备节点的工作是在驱动程序的入口函数中完成的,一般在 cdev_add 函数后面添加自动创建设备节点相关代码。
首先要创建一个 class 类, class 是个结构体.卸载驱动程序的时候需要删除掉类。

struct class *class_create (struct module *owner, const char *name)
void class_destroy(struct class *cls);

3.3 创建和删除设备

在类下创建一个设备实现自动创建设备节点

struct device *device_create(struct class *class,
								struct device *parent,
								dev_t devt,
								void *drvdata,
								const char *fmt, ...)
void device_destroy(struct class *cls, dev_t devt)

如果设置 fmt=xxx 的话,就会生成/dev/xxx这个设备文件

3.4 设置文件私有数据

每个硬件设备都有一些属性,将一个设备的所有属性信息做成一个结构体,编写驱动 open 函数的时候将设备结构体作为私有数据添加到设备文件中:
在这里插入图片描述
在 write、 read、 close 等函数中直接读取 private_data即可得到设备结构体。

四、设备树

4.1 什么是设备树

设备树(Device Tree)是一种‌描述硬件资源的数据结构‌,采用树状层级形式组织硬件信息,用于在嵌入式系统和操作系统中描述设备的特性、连接关系和配置 。用于替代传统内核中的板级硬件描述代码(如board file),实现硬件配置与内核解耦。

一个 SOC 可以作出很多不同的板子,这些不同的板子肯定是有共同的信息,将这些共同的信息提取出来作为一个通用的文件,其他
的.dts 文件直接引用这个通用文件即可,这个通用文件就是.dtsi 文件。一般.dts 描述板级信息(也就是开发板上有哪些 IIC 设备、 SPI 设备等), .dtsi 描述 SOC 级信息(也就是 SOC 有几个 CPU、主频是多少、各个外设控制器信息等)。

4.2 DTS、 DTB 、DTSI和DTC

DTS(Device Tree Source) 是设备树源码文件
DTSI(Device Tree Source Include)公共设备树头文件,支持模块化设计
DTB (Device Tree Blob)是将DTS 编译以后得到的二进制文件。
DTC‌(Device Tree Compiler)将.c 文件编译为.o 需要用到 gcc 编译器,那么将.dts 编译为.dtb需要用到 DTC 工具!

DTS、 DTB 、DTSI在 arch/arm64/boot/dts/rockchip
make ARCH=arm64 all”命令是编译 Linux 源码中的所有东西,包括uImage/zImage, .ko 驱动模块以及设备树
make ARCH=arm64 dtbs”会编译选中的所有设备树文件。
make ARCH=arm64 rockchip/rk3568-atk-evb1-ddr4-v10-linux.dtb”只要编译指定的某个设备树。

4.3 设备节点

设备树是采用树形结构来描述板子上的设备信息的文件,每个设备都是一个节点,叫做设备节点,每个节点都通过一些属性信息来描述节点信息,属性就是键值对。

“/”是根节点,每个设备树文件只有一个根节点,不同文件中的“/”根节点的内容会合并成一个根节点。
在设备树中节点命名格式如下:

node-name@unit-address
label: node-name@unit-address

其中,"label"是节点标签(label),引入 label 的目的就是为了方便访问节点,可以直接通过&label 来访问这个节点.
“node-name”是节点名字,为 ASCII 字符串。
“unit-address”一般表示设备的地址或寄存器首地址,如果某个节点没有地址或者寄存器的话“unit-address”可以不要.

4.4 标准属性

节点是由一堆的属性组成,节点都是具体的设备,不同的设备需要的属性不同,有自定义属性和标准属性。

①compatible 属性

也叫做“兼容性”属性,compatible 属性的值是一个字符串列表, compatible 属性用于将设备和驱动绑定起来。字符串列表用于选择设备所要使用的驱动程序,字符串列表代表可以有多个匹配的驱动程序, compatible 属性值的格式如下所示:

“manufacturer1,model1”,“manufacturer2,model2”

其中 manufacturer 表示厂商, model 一般是模块对应的驱动名字.

在linux内核代码中,通过搜索 .compatible = "xxx" ,可以找到对应的驱动程序。

设备节点compatible属性内可以有多个字符串,一般驱动程序文件都会有一个 OF 匹配表,此 OF 匹配表保存着一些 compatible 值,如果设备节点的 compatible 属性值和 OF 匹配表中的任何一个值相等,那么就表示设备可以使用这个驱动。

根节点“/”也有 compatible 属性,设备节点的 compatible 属性值是为了匹配 Linux 内核中的驱动程序, 通过根节点的 compatible 属性可以知道我们所使用的设备,如:

compatible = "rockchip,rk3568-evb1-ddr4-v10","rockchip,rk3568";

一般第一个值描述了所使用的硬件设备名字,比如这里使用的是“rk3568-evb1-ddr4-v10”这个设备,第二个值描述了设备所使用的 SOC,比如这里使用的是“RK3568”这颗SOC。 Linux 内核会通过根节点的 compoatible 属性查看是否支持此设备,如果支持的话设备就会启动 Linux 内核。

②model 属性、name 属性

model 属性值也是一个字符串,一般 model 属性描述开发板的名字或者设备模块信息:
name 属性值为字符串, name 属性用于记录节点名字, name 属性已经被弃用。

model = "Rockchip rk3568 EVB DDR4 V10 Board";

③status 属性

status 属性和设备状态有关的, status 属性值也是字符串,字符串是设备的状态信息
在这里插入图片描述

④reg 属性

一般 reg 属性都是和地址有关的内容,起始地址和地址长度(皆为32 位), reg 属性的格式为:

reg = <address1 length1 address2 length2 address3 length3……>

每个“address length”组合表示一个地址范围,其中 address 是起始地址, length 是地址长度

⑤#address-cells 和#size-cells 属性

这两个属性的值都是无符号 32 位整形,用在任何拥有子节点的设备中,用于描述子节点的地址信息。 #address-cells 属性值决定了子节点 reg 属性中地址信息所占用的字长(32 位), #size-cells 属性值决定了子节点 reg 属性中长度信息所占的字长(32 位),表示该设备占用的‌地址空间大小‌(以字节为单位)。

⑥ranges 属性

设备树中的ranges属性用于描述地址空间的映射关系,格式通常为<子地址 父地址 长度>的三元组
每个字段的位数由父节点的#address-cells和#size-cells决定
示例:ranges = <0x0 0x3000000 0x3000>表示将子地址0x0映射到父地址0x3000000,长度为0x3000

4.5 设备树在系统中的体现

Linux 内核启动的时候会解析设备树中各个节点的信息,并且在根文件系统的/proc/devicetree 目录下根据节点名字创建不同文件夹
/proc/device-tree 目录下是根节点“/”的所有属性和子节点,linux下皆文件,这些属性和子节点都表现为一个个文件。可以使用文件操作命令查看它们。

4.6 aliases 子节点

单词 aliases 的意思是“别名”,因此 aliases 节点的主要功能就是定义别名,定义别名的目的就是为了方便访问节点。不过我们一般会在节点命名的时候会加上 label,然后通过&label 来访问节点。

4.7 chosen 子节点

chosen 并不是一个真实的设备, chosen 节点主要是为了 uboot 向 Linux 内核传递数据,重点是 bootargs 参数。传递给 Linux 内核的bootargs 是 uboot 下 bootargs 环境变量的值加设备树里面 bootargs 属性的值.
/proc/cmdline 是 Linux 系统中一个重要的虚拟文件,用于存储内核启动时传递的引导参数,cmdline 的值就和/proc/device-tree/chosen/ bootargs一样,因此是 uboot 来完成 bootargs环境变量和设备树中的 bootargs 属性值拼接的,然后将其结合体传递给内核。

4.8 绑定信息文档

在Linux 内核源码中有详细的绑定文档描述了如何添加节点,路径为: Linux 源码目录/Documentation/devicetree/bindings

4.9 OF操作函数

Linux 内核提供了一系列的函数来获取设备树中的节点或者属性信息,这一系列的函数都有一个统一的前缀“of_”,所以也被叫做 OF 函数。这些 OF 函数原型都定义在 include/linux/of.h 文件中。
Linux 内核使用 device_node 结构体来描述一个节点,此结构体定义在文件 include/linux/of.h 中。

struct device_node {
	const char *name;
	const char *type;
	phandle phandle;
	const char *full_name;
	struct fwnode_handle fwnode;

	struct	property *properties;
	struct	property *deadprops;	/* removed properties */
	struct	device_node *parent;
	struct	device_node *child;
	struct	device_node *sibling;
#if defined(CONFIG_OF_KOBJ)
	struct	kobject kobj;
#endif
	unsigned long _flags;
	void	*data;
#if defined(CONFIG_SPARC)
	const char *path_component_name;
	unsigned int unique_id;
	struct of_irq_controller *irq_trans;
#endif
};

struct property {
	char	*name;
	int	length;
	void	*value;
	struct property *next;
#if defined(CONFIG_OF_DYNAMIC) || defined(CONFIG_SPARC)
	unsigned long _flags;
#endif
#if defined(CONFIG_OF_PROMTREE)
	unsigned int unique_id;
#endif
#if defined(CONFIG_OF_KOBJ)
	struct bin_attribute attr;
#endif
};

①of_find_node_by_name 函数

of_find_node_by_name 函数通过节点名字查找指定的节点,device_node 中的const char *name,函数原型如下:

struct device_node *of_find_node_by_name(struct device_node *from,const char *name);

②of_find_node_by_type 函数

of_find_node_by_type 函数通过 device_type 属性查找指定的节点device_node 中的const char *type;,函数原型如下:

struct device_node *of_find_node_by_type(struct device_node *from, const char *type)

③of_find_compatible_node 函数

of_find_compatible_node 函数根据 device_type 和 compatible 这两个属性查找指定的节点,函数原型如下:

struct device_node *of_find_compatible_node(struct device_node *from,const char *type,const char *compat)

④of_find_matching_node_and_match 函数

of_find_matching_node_and_match 函数通过 of_device_id 匹配表来查找指定的节点,函数原型如下:

struct device_node *of_find_matching_node_and_match(struct device_node *from,
													const struct of_device_id *matches,
													const struct of_device_id **match)

matches: of_device_id 匹配表,也就是在此匹配表里面查找节点。
match: 找到的匹配的 of_device_id。

⑤of_find_node_by_path 函数

of_find_node_by_path 函数通过路径来查找指定的节点,函数原型如下:

inline struct device_node *of_find_node_by_path(const char *path)

path为节点路径,根节点‘/’就类属于根文件,每个节点就是一个个文件夹,其子节点就是文件夹里的内容。就是/proc/device-tree里的内容。

⑥of_get_parent 函数

of_get_parent 函数用于获取指定节点的父节点(如果有父节点的话),函数原型如下:

struct device_node *of_get_parent(const struct device_node *node)

⑦of_get_next_child 函数

of_get_next_child 函数用迭代的查找子节点,函数原型如下:

struct device_node *of_get_next_child(const struct device_node *node,struct device_node *prev)

⑧of_find_property 函数

of_find_property 函数用于查找指定的属性,函数原型如下:

struct property *of_find_property(const struct device_node *np,const char *name,int *lenp)

⑨of_property_count_elems_of_size 函数

of_property_count_elems_of_size 函数用于获取属性中元素的数量,比如 reg 属性值是一个数组,那么使用此函数可以获取到这个数组的大小,此函数原型如下:

int of_property_count_elems_of_size(const struct device_node *np,const char *propname,int elem_size)

⑩其他函数

of_property_read_u32_index 函数用于从属性中获取指定标号的 u32 类型数据值(无符号 32位),比如某个属性有多个 u32 类型的值,那么就可以使用此函数来获取指定标号的数据值。

of_property_read_u8_array 函数
of_property_read_u16_array 函数
of_property_read_u32_array 函数
of_property_read_u64_array 函数,这 4 个函数分别是读取属性中 u8、 u16、 u32 和 u64 类型的数组数据,比如大多数的 reg 属性都是数组数据,可以使用这 4 个函数一次读取出 reg 属性中的所有数据。

of_property_read_u8 函数
of_property_read_u16 函数
of_property_read_u32 函数
of_property_read_u64 函数,有些属性只有一个整形值,这四个函数就是用于读取这种只有一个整形值的属性,分别用于读取 u8、 u16、 u32 和 u64 类型属性值。

of_property_read_string 函数用于读取属性中字符串值。

of_n_addr_cells 函数用于获取#address-cells 属性值。

of_n_size_cells 函数用于获取#size-cells 属性值。

of_device_is_compatible 函数用于查看节点的 compatible 属性是否有包含 name 指定的字符串,也就是检查设备节点的兼容性。

of_get_address 函数用于获取地址相关属性,主要是“reg”或者“assigned-addresses”属性值。

of_translate_address 函数负责将从设备树读取到的物理地址转换为虚拟地址。

of_address_to_resource 是 Linux 设备树(Device Tree)中用于将设备节点的地址信息转换为内核标准 resource 结构的关键函数,本质上就是提取 reg 属性值,然后将其转换为 resource 结构体类型。Linux内核中的struct resource是用于管理硬件资源的核心数据结构,主要用于描述设备在物理地址空间中的资源分配情况。将硬件资源抽象为可通过标准接口操作的对象。

of_iomap 函数用于直接内存映射,本质上也是将 reg 属性中地址信息转换为虚拟地址。

4.10 添加设备树节点

在根节点“/”下创建一个名为“rk3568_led”的子节点,打开 rk3568-atk-evb1-ddr4-v10.dtsi文件,此文件是正点原子编写的,故可以修改此dtsi文件,在根节点“/”最后面输入如下所示内容:

rk3568_led {
			compatible = "atkrk3568-led";
			status = "okay";
			reg = <0x0 0xFDC20010 0x0 0x08 /* PMU_GRF_GPIO0C_IOMUX_L */
			0x0 0xFDC20090 0x0 0x08 /* PMU_GRF_GPIO0C_DS_0 */
			0x0 0xFDD60004 0x0 0x08 /* GPIO0_SWPORT_DR_H */
			0x0 0xFDD6000C 0x0 0x08 >;/* GPIO0_SWPORT_DDR_H */
};

在rk3568.dtsi(一般不修改此dtsi文件)中,/ 下有#address-cells = <2>;#size-cells = <2>;所以0x0 0xFDC20010为地址,0x0 0x08为长度

4.11 烧写设备树dtb文件(boot.img)

在正点原子rk3568开发板中,如果改动了设备树文件,需要重新编译设备树得到新的 Kernel DTB(rk3568-atk-evb1-ddr4-v10-linux.dtb),然后将它打包进 resource.img、之后再把 resource.img 打包进 boot.img,最后将新的 boot.img 烧录到开发板 boot 分区完成替换;虽然很麻烦,但必须得这么做。

所以,一般建议直接 ./build.sh kernel编译完成以后得到 boot.img.然后转移到Windows中,使用RKDevTool工具进行烧写。
或者,先kernel下:make ARCH=arm64 dtbs, 再./build.sh kernel,这样不用重新整个kernel的编译,要更快一些。

打开RKDevTool工具,提前连接OTG端口,进入LOADER模式,开发板烧录过镜像,上电或复位后开发板正常启动进入到系统后,瑞芯微开发工具(也就是 RKDevTool 工具)会显示“发现一个 ADB 设备”,然后点击“切换”按钮,进入到 Loader模式

我们需要烧写boot.img,有.cfg 配置文件最好,没有也无妨,boot.img需要下载到boot分区中,根据分区表文件 parameter.txt可以知道boot地址是0x00008000,大小为0x00020000 sector,自己修改如下,打上对勾,输入地址,找到boot.img路径,然后点击设备分区表,可以看到右边的输出内容,可以证实boot地址大小无误,点击执行就可以下载重启了。
在这里插入图片描述

五、pinctrl子系统

使用GPIO 寄存器的驱动开发方式和裸机基本没啥区别,Linux 内核提供了 pinctrl 和 gpio 子系统用于GPIO 驱动。

pinctrl 子系统主要工作内容如下:
①、获取设备树中 pin 信息。
②、根据获取到的 pin 信息来设置 pin 的复用功能
③、根据获取到的 pin 信息来设置 pin 的电气特性, 如驱动能力。
对于我们使用者来讲,只需要在设备树里面设置好某个 pin 的相关属性即可,其他的初始化工作均由 pinctrl 子系统来完成。

rk3568.dtsi 文件的 pinctrl 节点中,有GPIO0~GPIO4的基本信息,每组 GPIO 对应的寄存器地址不同,根据该节点中的基地址加上偏移就得到了 GPIO的其他寄存器地址。

k3568-pinctrl.dtsi文件, 此文件需要编译内核以后才能得到,该文件就是向 pinctrl 节点追加数据,当然其他文件内也可向pinctrl节点添加数据,绑定文档Documentation/devicetree/bindings/pinctrl/rockchip,pinctrl.txt 描述了如何在设备树中设置 rk3568的 PIN 信息。

每个 pincrtl 节点必须至少包含一个子节点来存放 pincrtl 相关信息,也就是 pinctrl 集,这个集合里面存放当前外设用到哪些引脚(PIN)、复用配置、上下拉、驱动能力等。一般这个存放pincrtl集的子节点名字是“rockchip,pins”。
引脚复用设置的格式如下:rockchip,pins = <PIN_BANK PIN_BANK_IDX MUX &phandle>

PIN_BANK 就是 PIN 所属的组, RK3568 一共有 5 组 PIN: GPIO0-GPIO4,分别对应 0~4,
PIN_BANK_IDX 是组内的编号,以 GPIO0 组为例,一共有 A0~A7、 B0~B7、 C0~C7、 D0~D7,这 32 个 PIN,分别对应0-31. include/dt-bindings/pinctrl/rockchip.h 文件有定义。
MUX 就是设置 PIN 的复用功能,一个 PIN 最多有 16 个复用功能, include/dtbindings/pinctrl/rockchip.h 中有定义。
phandle用来描述一些引脚的通用配置信息,设置电气特性,驱动功能,可见 scripts/dtc/includeprefixes/arm/rockchip-pinconf.dtsi 文件

如下,在pinctrl节点下的tsc子节点下添加tsc_gpio: tsc-gpio节点,是来设置某个设备需要的pin,pintrcl子系统需要根据这里的配置进行pin设置,这不是pintrcl子系统

&pinctrl{
	tsc {
		tsc_gpio: tsc-gpio {
		rockchip,pins =
				<3 RK_PB1 RK_FUNC_GPIO &pcfg_pull_up>,
				<3 RK_PB2 RK_FUNC_GPIO &pcfg_pull_up>,
				<3 RK_PC4 RK_FUNC_GPIO &pcfg_pull_up>,
				<3 RK_PC5 RK_FUNC_GPIO &pcfg_pull_up>;
		};
	};
}

六、gpio子系统

pinctrl 子系统重点是设置 PIN(有的 SOC 叫做 PAD)的复用和电气属性,如果一个 PIN 复用为 GPIO ,那就要用到 gpio 子系统。一般来说,一个 PIN 用作 GPIO 功能的时候也需要创建对应的 pinctrl 节点,但对rk3568而言,GPIO的pin无需pinctrl节点。

6.1 介绍

gpio子系统就是用于初始化 GPIO 并且提供相应的 驱动API 函数,比如设置 GPIO为输入输出,读取 GPIO 的值等。

rk3568.dtsi 文件的 pinctrl 节点中,有GPIO0~GPIO4的基本信息,其中 #gpio-cells = <2>; “#gpio-cells”属性和“#address-cells”类似, #gpio-cells 应该为 2,表示一共有两个 cell,第一个 cell 为 GPIO 编号,比如“&gpio0 RK_PC0”就表示 GPIO0_C0。第二个 cell表 示 GPIO 极 性 ,如 果 为 0(GPIO_ACTIVE_HIGH) 的 话 表 示 高 电 平 有 效 , 如 果 为1(GPIO_ACTIVE_LOW)的话表示低电平有效。
如下:&gpio3 RK_PB1 为一个cell,GPIO_ACTIVE_LOW为一个cell

tsc@24 {
		status = "okay";
		compatible = "cy,cyttsp5_i2c_adapter";
		reg = <0x24>;
		cy,adapter_id = "cyttsp5_i2c_adapter";
		cy,core {
			cy,name = "cyttsp5_core";
			pinctrl-names = "default";
			pinctrl-0 = <&tsc_gpio>;
			cy,irq_gpio =  <&gpio3 RK_PB1 GPIO_ACTIVE_HIGH>;
			cy,rst_gpio =  <&gpio3 RK_PB2 GPIO_ACTIVE_HIGH>;
			cy,1v8_gpio =  <&gpio3 RK_PC4 GPIO_ACTIVE_HIGH>;
			cy,2v8_gpio =  <&gpio3 RK_PC5 GPIO_ACTIVE_HIGH>;

在tsc节点中,pinctrl-0 = <&tsc_gpio>; pincrtl子系统会知道tsc@24这个设备需要设置tsc_gpio中的相关内容(复用和电气)。
cy,irq_gpio、cy,rst_gpio等,gpio子系统会知道tsc@24这个设备使用了这些IO,什么电平有效。

6.2 gpio 子系统 API 函数

gpio_request 函数用于申请一个 GPIO 管脚,在使用一个 GPIO 之前一定要使用 gpio_request进行申请

gpio_free 函数如果不使用某个 GPIO 了,那么就可以调用 gpio_free 函数进行释放

gpio_direction_input 函数用于设置某个 GPIO 为输入

gpio_direction_output 函数用于设置某个 GPIO 为输出,并且设置默认输出值

gpio_get_value 函数用于获取某个 GPIO 的值(0 或 1)

gpio_set_value 函数用于设置某个 GPIO 的值

6.3 gpio 相关的 OF 函数

设备节点中会有gpio属性,如上面的cy,irq_gpio等,在驱动程序中需要读取 gpio 属性内容, Linux 内核提供了几个与 GPIO 有关的 OF 函数。

of_gpio_named_count 函数用于获取设备树某个属性(参数指定)里面定义了几个 GPIO 信息,要注意的是空的 GPIO 信息也会被统计到

of_gpio_count 函数统计的是“gpios”这个属性的 GPIO 数量,而 of_gpio_named_count 函数可以统计任意属性的 GPIO 信息

of_get_named_gpio 函数获取 GPIO 编号,因为 Linux 内核中关于 GPIO 的 API 函数都要使用 GPIO 编号,此函数会将设备树中类似<&gpio4 RK_PA1 GPIO_ACTIVE_LOW>的属性信息转换为对应的GPIO 编号

七、pinctrl、gpio实验

led灯是gpio0-c0

7.1 检测IO是否占用

检查一下 GPIO0_C0 对应的 GPIO 有没有被其他外设使用,如果这个 GPIO 已经被分配给了其他外设,那么我们驱动在申请这个 GPIO 就会失败。当前开发板系统将 GPIO0_C0 这个 IO 分配给了内核自带的 LED 驱动做心跳灯了,添加 status = disabled。

7.2 修改设备树

在 rk3568-atk-evb1-ddr4-v10.dtsi 文件的根节点“/”下创建 LED 灯节点和pinctrl所需PIN设置。

/ {
	gpioled{
			compatible = "alientek,led";
			pinctrl-names = "default";
			pinctrl-0 = <&led>;
			led-gpio = <&gpio0 RK_PC0 GPIO_ACTIVE_HIGH>;
			status = "okay";
		};
}
&pinctrl{ //不建议
	led: led_gpio{
		rockchip,pins =
		<0 RK_PC0 RK_FUNC_GPIO &pcfg_pull_none>;
	};
}
&pinctrl{ //建议
	led-gpios{
		/omit-if-no-ref/
		led: led_gpio{
		rockchip,pins =
				<0 RK_PC0 RK_FUNC_GPIO &pcfg_pull_none>;
		};
	};
};

第一种直接使用led_gpio作为子节点名,未遵循标准命名惯例。
第二种采用led-gpios作为容器节点,内部再定义led_gpio子节点,符合设备树分层设计规范。
在rk3568中,其实&pinctrl下的led: led_gpio 可以不用写,但为了完整我还是补上去了,便于理解。

7.3 后续

需要./build.sh kernel 和烧写dtb(boot.img),4.11中有。
还需要驱动程序编写和对应的APP编写,为了方便adb 和 编译APP,在Makefile中添加:

pro_name = gpioled
pro_app = ledApp
adb:
	adb push $(pro_name).ko $(pro_app) /lib/modules/4.19.232
app:
	/opt/atk-dlrk356x-toolchain/bin/aarch64-buildroot-linux-gnu-gcc $(pro_app).c -o  $(pro_app)

然后去rk3568开发板中运行即可。

八、并发与竞争

8.1 并发(Concurrency)

多个任务在同一时间段内交替执行的能力,单核CPU通过时间片轮转实现"伪同时"执行,Linux 系统是个多任务操作系统,会存在多个任务同时访问同一片内存区域,这就是并发,这些任务可能会相互覆盖这段内存中的数据,造成内存数据混乱。
现在的 Linux 系统并发产生的原因很复杂,如下:

①多线程并发访问, Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因
②抢占式并发访问,也就是说调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
③中断程序并发访问
④SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。

并发访问带来的问题就是竞争。

8.2 竞争(Race Condition)

多个并发任务因执行顺序不确定导致共享资源访问冲突,引发数据不一致或程序错误.就像freertos中的临界区,所谓的临界区就是共享数据段,对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的。

在编写驱动的时候一定要注意避免并发和防止竞争访问,一般在编写驱动的时候就要考虑到并发与竞争。
在程序中数据是共享资源,要保护的是多个线程都会访问的共享数据。一般像全局变量,设备结构体这些肯定是要保护的,至于其他的数据就要根据实际的驱动程序而定了。单纯访问,不修改可以不用保护,重点是需要更改的那些数据,确保每一次只有一个‘人’在更改。

8.3 原子操作atomic

原子操作就是指不能再进一步分割的操作,一般原子操作用于整形变量或者位操作
c语言中 a = 3,编译为成汇编指令,ARM 架构不支持直接对寄存器(内存)进行读写操作,要借助寄存器 R0、 R1 等来完成赋值操作:

ldr r0, =0X30000000 /* 变量 a 地址 */
ldr r1, = 3 /* 要写入的值 */
str r1, [r0] /* 将 3 写入到 a 变量中 */

ARM架构不支持直接对内存进行读写操作的设计源于其RISC(精简指令集)理念和硬件实现优化,主要原因包括:
一、RISC架构设计原则

  1. 指令精简性ARM采用load-store架构,明确区分数据搬运(LDR/STR)和运算指令,这种设计能减少指令复杂度,提高流水线效率。
  2. 硬件简化需求直接内存操作需要复杂的总线控制和多周期指令,而通过寄存器中转可简化ALU电路设计。

需要确保三行汇编指令作为一个整体运行,也就是作为一个原子存在。

Linux 内核提供了两组原子操作 API 函数,一组是对整形变量进行操作的,一组是对位进行操作的

①原子整形操作 API 函数

Linux 内核定义了叫做 atomic_t 的结构体来完成整形数据的原子操作,在使用中用原子变量来代替整形变量,此结构体定义在 include/linux/types.h 文件中。

typedef struct {
	int counter;
} atomic_t;
//64 位的 SOC 的话,就要用到 64 位的原子变量
typedef struct {
	s64 counter;
} atomic64_t;

int a 转换成原子变量 atomic_t a,确保为原子操作。
通过宏 ATOMIC_INIT 向原子变量赋初值

atomic_t a; //定义 a
atomic_t b = ATOMIC_INIT(0); //定义原子变量 b 并赋初值为 0

原子变量有了,接下来就是对原子变量进行操作,比如读、写、增加、减少等等, Linux 内核提供了大量的原子操作 API 函数

在这里插入图片描述

②原子位操作 API 函数

原子位操作是直接对内存进行操作
在这里插入图片描述
在这里插入图片描述

③实验

每次调用 open 函数打开驱动设备的时候先申请 lock,如果申请成功的话就表示LED灯还没有被其他的应用使用,如果申请失败就表示LED灯正在被其他的应用程序使用。每次打开驱动设备的时候先使用 atomic_dec_and_test 函数将 lock 减 1,如果 atomic_dec_and_test
函数返回值为真就表示 lock 当前值为 0,说明设备可以使用。如果 atomic_dec_and_test 函数返回值为假,就表示 lock 当前值为负数(lock 值默认是 1), lock 值为负数的可能性只有一个,那就是其他设备正在使用 LED。其他设备正在使用 LED 灯,那么就只能退出了,在退出之前调用函数 atomic_inc 将 lock 加 1,因为此时 lock 的值被减成了负数,必须要对其加 1,将 lock 的值变为 0。LED 灯使用完毕,应用程序调用 close 函数关闭的驱动文件, led_release 函数执行,调用 atomic_inc 释放 lcok,也就是将 lock 加 1。

实验中产生竞争的数据是atomic lock,根据lock的值来判断是否占用。

8.4 自旋锁 spinlock

原子操作只能对整形变量或者位进行保护,但是,设备结构体变量就不是整型变量,我们对于结构体中成员变量的操作也要保证原子性。需要锁机制,在 Linux内核中就是自旋锁。

当锁被占用时,请求线程会持续循环检测(自旋)而非休眠,避免上下文切换开销。

自旋锁缺点:那就等待自旋锁的线程会一直处于自旋状态,这样会浪费处理器时间,降低系统性能,所以自旋锁的持有时间不能太长。

spinlock_t lock; //定义自旋锁

在这里插入图片描述
自旋锁API函数适用于SMP或支持抢占的单CPU下线程之间的并发访问,也就是用于线程与线程之间,被自旋锁保护的临界区一定不能调用任何能够引起睡眠和阻塞的API 函数,否则的话会可能会导致死锁现象的发生。

①休眠、阻塞死锁

睡眠死锁:自旋锁会自动禁止抢占,当线程 A得到锁以后会暂时禁止内核抢占。如果线程 A 在持有锁期间进入了休眠状态,那么线程 A 会自动放弃 CPU 使用权。线程 B 开始运行,线程 B 也想要获取锁,但是此时锁被 A 线程持有,而且内核抢占还被禁止了!线程 B (一直自旋)无法被调度出去,那么线程 A 就无法运行,锁也就无法释放,死锁发生了!

自旋锁的‌自动禁止抢占‌(Automatic Preemption Disabling)是Linux内核中自旋锁实现的关键机制,其核心目的是‌防止持有锁的线程被高优先级线程抢占‌,从而避免死锁。
死锁风险‌:若持有锁的线程被抢占,高优先级线程可能尝试获取同一把锁,导致双方互相等待(死锁)‌。
‌示例‌:

线程A持有锁lock1,被抢占后,线程B(高优先级)尝试获取lock1。
线程B因锁被占用而自旋,而线程A因被抢占无法释放锁,形成死锁‌。

阻塞:中断里面可以使用自旋锁,但是在中断里面使用自旋锁的时候,在获取锁之前一定要先禁止本地中断,否则可能导致锁死现象的发生。其他CPU的中断处理程序不会直接抢占当前CPU的执行流,因此无需全局禁用中断。

最好的解决方法就是获取锁之前关闭本地中断, Linux 内核提供了相应的 API 函数
在这里插入图片描述
一般在线程中使用 spin_lock_irqsave/spin_unlock_irqrestore,在中断中使用 spin_lock/spin_unlock

下半部(BH)也会竞争共享资源,有些资料也会将下半部叫做底半部
在这里插入图片描述

②自旋锁使用注意事项

1、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,如果临界区比较大,运行时间比较长的话要选择信号量和互斥体。
2、自旋锁保护的临界区内不能调用任何可能导致线程休眠、阻塞的 API 函数。
3、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自旋”,自己锁自己。
4、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。

③实验

自旋锁保护的临界区要尽可能的短,因此在 open 函数中申请自旋锁,然后在 release 函数中释放自旋锁的方法就不可取。我们可以使用一个变量来表示设备的使用情况,如果设备被使用了那么变量就加一,设备被释放以后变量就减 1,我们只需要使用自旋锁保护这个变量即
可。
综上所述,在本节例程中,我们通过定义一个变量 dev_stats 表示设备的使用情况, dev_stats为 0 的时候表示设备没有被使用, dev_stats 大于 0 的时候表示设备被使用。驱动 open 函数中先判断 dev_stats 是否为 0,也就是判断设备是否可用,如果为 0 的话就使用设备,并且将 dev_stats加 1,表示设备被使用了。使用完以后在 release 函数中将 dev_stats 减 1,表示设备没有被使用了。因此真正实现设备互斥访问的是变量 dev_stats,但是我们要使用自旋锁对 dev_stats 来做保护。

这里的dev_stats 类似与 atomic lock,lock 的操作本身就是原子操作,不会被干扰,而dev_stats 实验 spinlock 实现原子操作。

8.5 信号量Semaphore

①介绍

信号量(Semaphore)是操作系统和并发编程中用于实现同步与互斥的重要机制,主要用于控制多线程/多进程对共享资源的有序访问。
信号量是一个计数器,表示可用资源的数量,通过down/up函数实现资源的申请与释放。信号量的值大于 0 ,表示资源可用,等于 0 则不可用。

常见类型‌
‌1.二进制/值信号量‌:取值0或1,用于互斥锁场景。互斥场景更推荐使用mutex替代二进制信号量
‌2.计数信号量‌:整数型,表示多个可用资源(如线程池限流)

信号量的特点:
①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
②、信号量不能用于中断中(spinlock可以),因为信号量会引起休眠,中断不能休眠。
③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的优势。
④、信号量保护的临界区可以调用引起阻塞的 API 函数,线程 A 在持有锁期间进入了休眠状态,那么线程 A 会自动放弃 CPU 使用权。线程 B 开始运行,线程 B 也想要获取锁,但是此时锁被 A 线程持有且无资源可用,线程 B 也进入休眠状态,休眠状态不是spinlock的死等待,spinlock的死等待占用cpu,且就算线程A休眠结束也无法获得cpu,而休眠状态下,线程A休眠结束接着运行,不会死锁。

Linux 内核使用 semaphore 结构体表示信号量:struct semaphore sem; 定义信号量

struct semaphore {
	raw_spinlock_t lock;
	unsigned int count;
	struct list_head wait_list;
}

在这里插入图片描述

②实验

二值信号量:
在 open函数中申请信号量,可以使用 down 函数,也可以使用 down_interruptible函数。如果信号量值大于等于 1 就表示可用,那么应用程序就会开始使用 LED 灯。如果信号量值为 0 就表示应用程序不能使用 LED 灯,此时应用程序就会进入到休眠状态。等到信号量值大
于 1 的时候应用程序就会唤醒,申请信号量,获取 LED 灯使用权。在 release 函数中调用 up 函数释放信号量,这样其他因为没有得到信号量而进入休眠状态的应用程序就会唤醒,获取信号量。

8.6 互斥体 mutex

①介绍

互斥体是一种同步基元,确保同一时间仅有一个线程访问共享资源(如内存、文件等),其他线程需等待持有者释放锁,不能递归。(freertos中的临界段代码保护函数可以嵌套)

Linux 内核使用 mutex 结构体表示互斥体:struct mutex lock; /* 定义一个互斥体 */,*lock 似乎存在问题,开发板上运行报错: Unable to handle kernel NULL pointer dereference at virtual address,lock没问题。

struct mutex {
	atomic_long_t owner;
	spinlock_t wait_lock;
};

在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:
①、 mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁
②、和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。因为休眠而不是死等待。
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。

在这里插入图片描述

②实验

open 函数中调用 mutex_lock_interruptible 或者 mutex_lock 获取 mutex,成功的话就表示可以使用 LED 灯,失败的话就会进入休眠状态,和信号量一样。在 release 函数中调用 mutex_unlock 函数释放 mutex,这样其他应用程序就可以获取 mutex 了。在驱动入口函数中调用 mutex_init 初始化 mutex。互斥体和二值信号量类似,只不过互斥体是专门用于互斥访问的。

九、按键/GPIO输入

按键驱动和 LED 驱动原理上来讲基本都是一样的,都是操作 GPIO,只不过一个是读取GPIO 的高低电平,一个是从 GPIO 输出高低电平。
rk3568-pinctrl.dtsi 文件,在 pinctrl 节点下添加 GPIO3_C5 的 pinctrl 信息

key-gpios{
	/omit-if-no-ref/
	key_gpio: key-pin{
		rockchip,pins =
		<3 RK_PC5 RK_FUNC_GPIO &pcfg_pull_none>;
	};
};

rk3568-atk-evb1-ddr4-v10.dtsi 文件, 在根节点“/”下创建 KEY 节点,节点名为“key”

key {
	compatible = "alientek,key";
	pinctrl-0 = <&key_gpio>;
	pinctrl-names = "alientek,key";
	key-gpio = <&gpio3 RK_PC5 GPIO_ACTIVE_HIGH>;
	status = "okay";
};

Linux 下的 input 子系统专门用于输入设备,所以输入设备的驱动看input。

十、Linux内核定时器

10.1 时钟介绍

1.内部时钟‌:
由集成在芯片内的RC振荡器或晶体振荡电路直接生成,无需外部元件。受工艺、温度及电压波动影响显著,频率误差可达5%~15%(如RC振荡器),仅适用于低精度场景。

‌2.外部时钟‌:
需外接石英晶振、陶瓷谐振器或其他独立时钟发生器提供信号源。依赖高稳定晶体谐振器,精度可达百万分之一级别(如32.768kHz晶振),温漂小,适用于通信协议、实时控制等高精度需求。

一般MCU启动后需切换至外部时钟,因为内部时钟在温度变化时频偏显著,切换至外部时钟可提升系统稳定性,确保外设(如ADC、定时器)时序精度。

3.硬件定时器(Hardware Timer)‌
物理电路驱动‌, 依赖芯片内置的专用定时器外设(如SysTick、GPT、PWM定时器等),通过配置寄存器直接控制定时周期和中断触发。
独立时钟源‌, 使用外部晶振或内部时钟(如APB总线时钟)作为基准,与CPU主频解耦,时序精度高。

4.软件定时器(Software Timer)‌
软件模拟计时‌, 基于系统节拍(如FreeRTOS的Tick)或硬件定时器的周期性中断,通过计数比较实现定时功能。

10.2 内核时间管理

UCOS 或 FreeRTOS 是需要一个硬件定时器提供系统时钟,一般使用 Systick 作为系统时钟源。Linux系统时钟由硬件定时器(如TSC/HPET)提供计时基准,通过内核的时钟子系统实现时间管理。

cat /sys/devices/system/clocksource/clocksource0/current_clocksource   tsc

Linux 内核中有大量的函数需要时间管理,硬件定时器(如上所示为tsc)提供时钟源,时钟源的频率可以设置,设置好以后就周期性的产生定时中断,系统使用定时中断来计时。中断周期性产生的频率就是系统频率,也叫做节拍率(tick rate)(有的资料也叫系统频率),比如 100Hz、 1000Hz 等,系统默认情况下选择 300Hz。这也就是系统时钟。

Linux中有 # define HZ CONFIG_HZ ,CONFIG_HZ一般为300.即HZ 表示一秒的节拍数,300次。

Linux 内核使用全局变量 jiffies 来记录系统从启动以来的系统节拍数,系统启动的时候会将 jiffies 初始化为 0。 有64 位的 jiffies_64和unsigned long 类型的 32 位的 jiffies。jiffies_64 和 jiffies 其实是同一个东西, jiffies_64 用于 64 位系统,而 jiffies 用于 32 位系统。为了兼容不同的硬件, jiffies 其实就是 jiffies_64 的低 32 位, 在 64 位的系统上 jiffes 和 jiffies_64表示同一个变量,所以不管是 32 位的系统还是 64 位系统,都可以使用 jiffies。

HZ 表示每秒的节拍数, jiffies 表示系统运行的 jiffies 节拍数,所以 jiffies/HZ 就是系统运行时间,单位为秒。32 位和64 位的 jiffies,都有溢出的风险,溢出以后会重新从 0 开始计数,也叫绕回;Linux 内核提供了如表 14.1.1.1 所示的几个 API 函数来处理绕回。
在这里插入图片描述
time_after: known after unknown 为真。如果 unkown 超过 known 的话, time_after 函数返回真,否则返回假。
time_before: known before unknown 为真。如果 unkown 没有超过 known 的话 time_before 函数返回真,否则返回假。

Linux 内核提供了几个 jiffies 和 ms、 us、 ns 之间的转换函数:
在这里插入图片描述

10.3 内核定时器-传统(软件)定时器(基于jiffies)‌

Linux 内核定时器是‌软件定时器‌,但其实现依赖于硬件定时器的中断机制。
内核定时器由内核代码实现,属于软件层面的计时机制,通过系统时钟模拟计时功能,优势在于可移植性强,无需依赖特定硬件,适用于通用场景。

Linux 内核定时器采用系统时钟来实现。 Linux 内核定时器只需要提供超时时间(相当于定时值)和定时处理函数即可,当超时时间到了以后设置的定时处理函数就会执行,内核定时器并不是周期性运行的,超时以后就会自动关闭,因此如果想要周期性定时,那么就需要在定时处理函数中重新开启定时器。 Linux 内核使用 timer_list 结构体表示内核定时器, timer_list 定义在文件 include/linux/timer.h 中:

struct timer_list {
	struct hlist_node	entry;
	unsigned long		expires;
	void			(*function)(struct timer_list *);
	u32			flags;
#ifdef CONFIG_LOCKDEP
	struct lockdep_map	lockdep_map;
#endif
	ANDROID_KABI_RESERVE(1);
	ANDROID_KABI_RESERVE(2);
};

使用内核定时器,定义一个 timer_list 变量,表示定时器, tiemr_list 结构体的expires 成员变量表示超时时间,单位为节拍数。比如我们现在需要定义一个周期为 2 秒的定时器,那么这个定时器的超时时间就是 jiffies+(2HZ),因此 expires=jiffies+(2HZ)。 function 就是定时器超时以后的定时处理函数, function 函数的形参就是我们定义的 timer_list 变量。

需 API 函数来初始化此定时器:
1、timer_setup 函数负责初始化 timer_list 类型变量。
2、add_timer 函数用于向 Linux 内核注册定时器,使用 add_timer 函数向内核注册定时器以后,定时器就会开始运行。
3、del_timer 函数用于删除一个定时器,不管定时器有没有被激活,都可以使用此函数删除。
4、del_timer_sync 函数是 del_timer 函数的同步版,会等待其他处理器使用完定时器再删除,del_timer_sync 不能使用在中断上下文中。
5、mod_timer 函数用于修改定时值,如果定时器还没有激活的话, mod_timer 函数会激活定时器。

10.4 Linux 内核短延时函数

Linux 内核提供了毫秒ms、微秒us和纳秒ns延时函数:
在这里插入图片描述

10.5 实验

使用内核定时器周期性的点亮和熄灭开发板上的 LED 灯, LED 灯的闪烁周期由内核定时器来设置,测试应用程序可以控制内核定时器周期。

①ioctl机制

Linux驱动和C应用程序中的ioctl机制是实现用户空间与内核空间设备控制的核心接口:

/* 设备操作函数 */
static struct file_operations timer_fops = {
	.owner = THIS_MODULE,
	.open = timer_open,
	.unlocked_ioctl = timer_unlocked_ioctl,
	.release = led_release,
};

一般而言字符设备驱动不可能只会调用读写操作(read/write),因为字符设备还需要进行其他参数的配置(如摄像头驱动 设置摄像头的画面参数、获取摄像头的能力…),像这样的操作都不会使用读写函数来实现,一般内核都会交给ioctl函数来实现,像ioctl函数的特点就是通过发送不同命令码,然后驱动返回不同的数据。

应用程序中的ioctl函数

int ioctl(int fd, unsigned long request,...);  

参数说明:
int fd:文件描述符 open返回值
unsigned long request:依赖于设备的请求代码(对设备的请求方式、命令)
… :第三个参数可以有,也可以省略,要根据参数二来确定

驱动中的ioctl函数,参数同上。

long (*unlocked ioctl)(struct file *,unsigned int, unsigned long);

request参数,在linux中,提供了一种 ioctl 命令的统一格式,将 32 位 int 型数据划分为四个位段,如下所示:

#define _IOC(dir,type,nr,size) \
	(((dir)  << _IOC_DIRSHIFT) | \
	 ((type) << _IOC_TYPESHIFT) | \
	 ((nr)   << _IOC_NRSHIFT) | \
	 ((size) << _IOC_SIZESHIFT))

在这里插入图片描述
可用的宏有如下:

#define _IO(type,nr)        _IOC(_IOC_NONE,(type),(nr),0)

// _IO宏用于创建不带数据传输的ioctl命令
// type是设备类型,通常是一个字符常量,用于区分不同的设备或设备驱动程序
// nr是命令序号,用于在同一设备类型下区分不同的命令

#define _IOR(type,nr,size)  _IOC(_IOC_READ,(type),(nr),sizeof(size))

// _IOR宏用于创建从设备读取数据的ioctl命令
// type和nr的含义与_IO宏相同
// size是数据类型大小

#define _IOW(type,nr,size)  _IOC(_IOC_WRITE,(type),(nr),sizeof(size))

// _IOW宏用于创建向设备写入数据的ioctl命令
// 其他参数的含义与_IOR宏相同

#define _IOWR(type,nr,size) _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))

// _IOWR宏用于创建既读取又写入数据的ioctl命令
// 其他参数的含义与_IOR和_IOW宏相同

如实验驱动和c应用代码中:0XEF是type,是设备类型,0x1、0x2、0x3代表不同的ioctl命令。

#define CLOSE_CMD (_IO(0XEF, 0x1)) /* 关闭定时器 */
#define OPEN_CMD (_IO(0XEF, 0x2)) /* 打开定时器 */
#define SETPERIOD_CMD (_IO(0XEF, 0x3)) /* 设置定时器周期命令 */

②from_timer宏

Linux内核中的from_timer宏是用于从定时器结构体timer_list反向获取其所属父结构体的关键工具,尤其在处理内核定时器回调时具有重要作用。from_timer宏通过container_of机制,根据已知的timer_list成员指针,推导出包含它的父结构体地址。

宏定义原型‌,典型定义形式如下(以Linux 5.15+为例):

#define from_timer(var, timer_ptr, timer_field) \
    container_of(timer_ptr, typeof(*var), timer_field)

‌var‌: 目标父结构体指针变量
‌timer_ptr‌: 当前timer_list指针
‌timer_field‌: 父结构体中timer_list成员的字段名

如驱动代码:

void timer_function(struct timer_list *arg)
	struct timer_dev *dev = from_timer(dev, arg, timer);
	。。。。。。

十一、linux中断

在单片机中使用中断,需要配置寄存器,使能 IRQ 等等。但是 Linux 内核提供了完善的中断框架,我们只需要申请中断,然后注册中断处理函数即可。

11.1 驱动中断号

每个中断都有一个中断号,通过中断号即可区分不同的中断,有的资料也把中断号叫做中断线, 在 Linux 内核中使用一个 int 变量表示中断号。编写驱动的时候需要用到中断号,中断信息已经写到了设备树里面,因此可以通过 irq_of_parse_and_map 函数从 interupts 属性中提取到对应的设备号,函数原型如下:

unsigned int irq_of_parse_and_map(struct device_node *dev, int index)

irq_of_parse_and_map 函数返回的虚拟IRQ号与设备树中的原始中断ID存在本质差异。
如果使用 GPIO 的话,可以使用 gpio_to_irq 函数来获取 gpio 对应的中断号,函数原型如下:

int gpio_to_irq(unsigned int gpio)

11.2 request_irq 函数

request_irq 函数用于申请中断, request_irq函数可能会导致睡眠,因此不能在中断上下文或者其他禁止睡眠的代码段中使用 request_irq 函数。 request_irq 函数会激活(使能)中断。request_irq 函数原型如下:

int request_irq(unsigned int irq,
		irq_handler_t handler,
		unsigned long flags,
		const char *name,
		void *dev)

flag中断触发类型

/* 获取设备树中指定的中断触发类型 */
	irq_flags = irq_get_trigger_type(key.irq_num);
	if (IRQF_TRIGGER_NONE == irq_flags)
		irq_flags = IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING;

dev: 如果将 flags 设置为 IRQF_SHARED 的话, dev 用来区分不同的中断,一般情况下将dev 设置为设备结构体, dev 会传递给中断处理函数 irq_handler_t 的第二个参数。

11.3 free_irq 函数

使用中断的时候需要通过 request_irq 函数申请,使用完成以后就要通过 free_irq 函数释放掉相应的中断。如果中断不是共享的,那么 free_irq 会删除中断处理函数并且禁止中断。 free_irq函数原型如下所示:

void free_irq(unsigned int irq,void *dev_id)

dev_id:如果中断设置为共享(IRQF_SHARED)的话,此参数用来区分具体的中断。共享中断只有在释放最后中断处理函数的时候才会被禁止掉。

11.4 中断处理函数

使用 request_irq 函数申请中断的时候需要设置中断处理函数,中断处理函数格式如下所示:
irqreturn_t (*irq_handler_t) (int, void *)
第一个参数是要中断处理函数要相应的中断号。
第二个参数是一个指向 void 的指针,也就是个通用指针,需要与 request_irq 函数的 dev_id 参数保持一致。用于区分共享中断的不同设备。中断处理函数的返回值为 irqreturn_t 类型, irqreturn_t 类型定义如下所示:

enum irqreturn {
	IRQ_NONE = (0 << 0),
	IRQ_HANDLED = (1 << 0),
	IRQ_WAKE_THREAD = (1 << 1),
};
typedef enum irqreturn irqreturn_t;

可以看出 irqreturn_t 是个枚举类型,一共有三种返回值。一般中断服务函数返回值使用如下形式:return IRQ_RETVAL(IRQ_HANDLED)

11.5 中断使能与禁止

void enable_irq(unsigned int irq)
void disable_irq(unsigned int irq)
void disable_irq_nosync(unsigned int irq)

disable_irq函数要等到当前正在执行的中断处理函数执行完才返回,因此使用者需要保证不会产生新的中断,并且确保所有已经开始执行的中断处理程序已经全部退出。

disable_irq_nosync 函数调用以后立即返回,不会等待当前中断处理程序执行完毕。

使能或者禁止当前处理器的整个中断系统:慎用

local_irq_enable()
local_irq_disable()

local_irq_enable 用于使能当前处理器中断系统。
local_irq_disable 用于禁止当前处理器中断系统。

local_irq_save(flags)
local_irq_restore(flags)

这两个函数是一对, local_irq_save 函数用于禁止中断,并且将中断状态保存在 flags 中。local_irq_restore 用于恢复中断,将中断恢复到 flags 状态。

11.6 上半部与下半部

也称为顶半部和底半部。我们在使用request_irq 申请中断的时候注册的中断服务函数属于中断处理的上半部,只要中断触发,那么中断处理函数就会执行。中断函数执行过程要快速,费时的工作交给下半部。分离上下半部,Linux在保证硬件实时响应的同时,降低了中断阻塞风险。

上半部: 上半部就是中断处理函数,那些处理过程比较快,不会占用很长时间的处理就可以放在上半部完成。
下半部: 如果中断处理过程比较耗时,那么就将这些比较耗时的代码提出来,交给下半部去执行,这样中断处理函数就会快进快出。

上下半部参考建议:
①、如果要处理的内容不希望被其他中断打断,那么可以放到上半部。
②、如果要处理的任务对时间敏感,可以放到上半部。
③、如果要处理的任务与硬件有关,可以放到上半部
④、除了上述三点以外的其他任务,优先考虑放到下半部。

Linux提供了多种下半部实现方式,具体对比如下:

①软中断(SoftIRQ)‌

‌特点‌:内核级机制,执行速度快,不可休眠
‌适用场景‌:高优先级任务(如网络包处理。
‌局限性‌:需静态编译进内核,灵活性低。

②Tasklet‌

‌特点‌:基于软中断实现,支持动态注册,不可休眠
‌适用场景‌:驱动程序中常见的延迟任务(如按键消抖)。
如果要使用 tasklet,必须先定义一个 tasklet_struct 变量,然后使用 tasklet_init 函数对其进行初始化, taskled_init 函数原型如下:

void tasklet_init(struct tasklet_struct *t,
		void (*func)(unsigned long),
		unsigned long data);

宏 DECLARE_TASKLET 可以 一 次 性 完 成 tasklet 的 定 义 和 初 始 化 ,

DECLARE_TASKLET(name, func, data)

在上半部,也就是中断处理函数中调用 tasklet_schedule 函数就能使 tasklet 在合适的时间运行, tasklet_schedule 函数原型如下:

void tasklet_schedule(struct tasklet_struct *t)

③工作队列(Workqueue)‌

‌特点‌:在进程上下文运行,允许休眠和调度
‌适用场景‌:复杂任务(如文件I/O、长时间计算)。

工作队列的执行主体是内核线程(如kworker),因此可在进程上下文中运行,允许睡眠、调度和阻塞操作。

Linux 内核使用 work_struct 结构体表示一个工作。

多个工作形成工作队列,工作队列使用 workqueue_struct 结构体表示。

Linux 内核使用工作者线程(worker thread)来处理工作队列中的各个工作, Linux 内核使用worker 结构体表示工作者线程,每个 worker 都有一个工作队列,工作者线程处理自己工作队列中的所有工作。在实际的驱动开发中,我们只需要定义工作(work_struct)即可,关于工作队列和工作者线程我们基本不用去管。

定义一个 work_struct 结构体:struct work_struct testwork;然后使用 INIT_WORK 宏来初始化工作, INIT_WORK 宏定义如下:

#define INIT_WORK(_work, _func) //_work 表示要初始化的工作, _func 是工作对应的处理函数。

也可以使用 DECLARE_WORK 宏一次性完成工作的创建和初始化,宏定义如下:

#define DECLARE_WORK(n, f) //n 表示定义的工作(work_struct), f 表示工作对应的处理函数。

和 tasklet 一样,工作也是需要调度才能运行的,工作的调度函数为 schedule_work,函数原型如下所示:

bool schedule_work(struct work_struct *work)

11.7 GIC中断控制器 Generic Interrupt Controller

GIC 是 ARM 公司给 Cortex-A/R 内核提供的一个中断控制器,类似 Cortex-M 内核中的NVIC。当 GIC 接收到外部中断信号以后就会报给 ARM 内核,但是ARM 内核只提供了四个信号给 GIC 来汇报中断情况: VFIQ、 VIRQ、 FIQ 和 IRQ,他们之间的关系如图 15.1.3.1 所示:
在这里插入图片描述
GIC 接收众多的外部中断,然后对其进行处理,最终就只通过四个信号报给 ARM 内核,这四个信号的含义如下:

VFIQ:虚拟快速 FIQ。
VIRQ:虚拟快速 IRQ。
FIQ:快速中断 IRQ。
IRQ:外部中断 IRQ。

GIC将中断分为三类: ‌

1、SPI(Shared Peripheral Interrupt)‌:共享外设中断,可路由至任意CPU核心
2、PPI(Private Peripheral Interrupt)‌:CPU私有中断(如本地定时器),仅绑定到特定核心
3、SGI(Software Generated Interrupt)‌:软件生成的中断,用于核间通信

11.8 设备树中断ID

中断源有很多,为了区分这些不同的中断源肯定要给他们分配一个唯一 ID,中断 ID。每一个 CPU 最多支持 1020 个中断 ID,中断 ID 号为 ID0~ID1019。这 1020 个 ID 分配如下:
ID0~ID15:这 16 个 ID 分配给 SGI。
ID16~ID31:这 16 个 ID 分配给 PPI。
ID32~ID1019:这 988 个 ID 分配给 SPI,像 GPIO 中断、串口中断等这些外部中断 。
至于具体到某个 ID 对应哪个中断那就由半导体厂商根据实际情况去定义了。

11.9 设备树上的中断设置

rk3568.dtsi 文件,其中的 gic 节点就是 GIC 的中断控制器节点:

/{
	gic: interrupt-controller@fd400000 {
		compatible = "arm,gic-v3";
		#interrupt-cells = <3>;
		#address-cells = <2>;
		#size-cells = <2>;
		ranges;
		interrupt-controller;
		reg = <0x0 0xfd400000 0 0x10000>, /* GICD */
		      <0x0 0xfd460000 0 0xc0000>; /* GICR */
		interrupts = <GIC_PPI 9 IRQ_TYPE_LEVEL_HIGH>;
		its: interrupt-controller@fd440000 {
			compatible = "arm,gic-v3-its";
			msi-controller;
			#msi-cells = <1>;
			reg = <0x0 0xfd440000 0x0 0x20000>;
		};
	};
}

① #interrupt-cells 和 interrupts属性

#interrupt-cells‌ ‌的归属对象‌:中断控制器节点(如GIC节点)
作用‌:声明该中断控制器描述单个中断所需的数据单元(cell)数量

#interrupt-cells 和#address-cells、 #size-cells 一样。表示此中断控制器下设备的 cells大小,对于设备而言,会使用 interrupts 属性描述中断信息, #interrupt-cells 描述了 interrupts 属性的 cells 大小,也就是一条信息有几个 cells。每个 cells 都是 32 位整形值,对于 ARM 处理的GIC 来说,前提是interrupt-parent = <&gic>;,一共有 3 个 cells,这三个 cells 的含义如下:
第一个 cells:中断类型, 0 表示 SPI 中断, 1 表示 PPI 中断。
第二个 cells:中断号,对于 SPI 中断来说中断号的范围为 32-1019(具体取决于半导体厂商实际使用了多少个中断号),对于 PPI 中断来说中断号的范围为 16~31,但是该 cell 描述的中断号是从 0 开始。
第三个 cells:标志, bit[3:0]表示中断触发类型,为 1 的时候表示上升沿触发,为 2 的时候表示下降沿触发,为 4 的时候表示高电平触发,为 8 的时候表示低电平触发。 bit[15:8]为 PPI 中断的 CPU 掩码。
例如gic节点下:

interrupts = <GIC_PPI 9 IRQ_TYPE_LEVEL_HIGH>;

gic节点的中断信息:中断类型为PPI 中断,中断号为9,高电平触发。

例如i2c0节点:

/{
	i2c0: i2c@fdd40000 {
		compatible = "rockchip,rk3399-i2c";
		reg = <0x0 0xfdd40000 0x0 0x1000>;
		clocks = <&pmucru CLK_I2C0>, <&pmucru PCLK_I2C0>;
		clock-names = "i2c", "pclk";
		interrupts = <GIC_SPI 46 IRQ_TYPE_LEVEL_HIGH>;
		pinctrl-names = "default";
		pinctrl-0 = <&i2c0_xfer>;
		#address-cells = <1>;
		#size-cells = <0>;
		status = "disabled";
	};
}

i2c0节点的中断信息:中断类型为SPI 中断,中断号为46+32=78,32代表前面32 个 PPI 中断号,高电平触发。

②interrupt-controller

如gic节点中,interrupt-controller 节点为空, 表示当前节点是中断控制器。

③interrupt-parent 和interrupts属性

interrupt-parent‌ ‌归属对象‌:外设设备节点(非中断控制器节点)
作用‌:指定该设备的中断信号所连接的中断控制器节点。

rk809节点,是i2c0节点下的一个具体设备:

&i2c0{
	rk809: pmic@20 {
		compatible = "rockchip,rk809";
		reg = <0x20>;
		interrupt-parent = <&gpio0>;
		interrupts = <3 IRQ_TYPE_LEVEL_LOW>;
		。。。。。。
}

gpio0节点:

/{
	pinctrl:pinctrl{
		gpio0: gpio@fdd60000 {
			compatible = "rockchip,gpio-bank";
			reg = <0x0 0xfdd60000 0x0 0x100>;
			interrupts = <GIC_SPI 33 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&pmucru PCLK_GPIO0>, <&pmucru DBCLK_GPIO0>;
			gpio-controller;
			#gpio-cells = <2>;
			gpio-ranges = <&pinctrl 0 0 32>;
			interrupt-controller;
			#interrupt-cells = <2>;
		};
	}
}

RK809 是正点原子 ATK-DLRK3568 开发板上核心板的 PMIC 芯片,上述代码就是 RK809的节点信息, RK809 芯片有一个中断,此引脚链接到了 RK3568 的 GPIO0_A3 上,此中断是低电平触发。interrupt-parent 属性设置中断控制器,因为 GPIO0_A3 属于 GPIO0 组,所以这里设置中断控制器为 GPIO0。 gpio0节点中有#interrupt-cells = <2>;,interrupts 设置中断信息, 3 表示 GPIO0_A3 属于 GPIO0 组的第 4 个 IO,前 3个为 A0~A2。 IRQ_TYPE_LEVEL_LOW 表示下降沿触发。

④总结

简单总结一下与中断有关的设备树属性信息:
①、 #interrupt-cells,声明该中断控制器描述单个中断所需的数据单元(cell)数量
②、 interrupt-controller,表示当前节点为中断控制器。
③、 interrupts,指定中断号,触发方式等。
④、 interrupt-parent,指定父中断,也就是中断控制器。

11.10 实验

设备树:

/{
	key {
		compatible = "alientek,key";
		pinctrl-0 = <&key_gpio>;
		pinctrl-names = "alientek,key";
		key-gpio = <&gpio3 RK_PC5 GPIO_ACTIVE_HIGH>;
		interrupt-parent = <&gpio3>;
		interrupts = <21 IRQ_TYPE_EDGE_BOTH>;
		status = "okay";
	};
};

&pinctrl{
		key-gpios{
		/omit-if-no-ref/
		key_gpio: key-pin {
			rockchip,pins =
				<3 RK_PC5 RK_FUNC_GPIO &pcfg_pull_none>;
		};
	};
};

c应用驱动:

使用到了内核定时器(timer),自旋锁(spinlock)和,gpio中断,步骤如下:
按键按下,产生gpio中断,进入key_interrupt中断处理函数,此函数中,使用mod_timer设计15ms的定时器,相当于15ms的按键消抖,15ms后,进入static void key_timer_function定时器处理函数,该函数中,使用前一刻电平状态和当前电平状态判断按键状态。自旋锁对全局变量 status 进行加锁保护。

十二、阻塞IO与非阻塞IO

12.1 介绍

这里的“IO”并不是单片机中的“GPIO” (也就是引脚)。这里的 IO 指的是 Input/Output,也就是输入/输出,是应用程序对驱动设备的输入/输出操作

阻塞式 IO:当应用程序对设备驱动进行操作的时候,如果不能获取到设备资源,那么阻塞式 IO 就会将应用程序对应的线程挂起,直到设备资源可以获取为止。

非阻塞 IO:应用程序对应的线程不会挂起,它要么一直轮询等待,直到设备资源可以使用(可以设置等待时间),要么就直接放弃。但是,非阻塞IO可以并发,可以一下子等待多个IO,不像阻塞IO,阻塞IO只要前面的读取完成了,后面才能接着读,阻塞IO的串行的,而非阻塞IO是并发的。

通常,应用程序对于设备驱动文件的默认读取方式就是阻塞式的,使用 open 函数的时候添加了参数“O_NONBLOCK”,表示以非阻塞方式打开设备。

fd = open("/dev/xxx_dev", O_RDWR); /* 阻塞方式打开 */
fd = open("/dev/xxx_dev", O_RDWR | O_NONBLOCK); /* 非阻塞方式打开 */

12.2 等待队列wait queue

Linux 内核提供了等待队列(wait queue)来实现阻塞进程的唤醒工作

在驱动中使用等待队列,必须创建并初始化一个等待队列头,等待队列头使用结构体wait_queue_head 表示,定义好等待队列头以后需要初始化,使用 init_waitqueue_head 函数初始化等待队列头,函数原型如下:

void init_waitqueue_head(struct wait_queue_head *wq_head)

参数 wq_head 就是要初始化的等待队列头。也可以使用宏 DECLARE_WAIT_QUEUE_HEAD 来一次性完成等待队列头的定义和初始化。

12.3 等待队列项

等待队列头就是一个等待队列的头部,每个访问设备的进程都是一个队列项,当设备不可用的时候就要将这些进程对应的队列项添加到等待队列里面。 wait_queue_entry 结构体表示等待队列项,使用宏 DECLARE_WAITQUEUE 定义并初始化一个等待队列项,宏的内容如下:

DECLARE_WAITQUEUE(name, tsk)

name 就是等待队列项的名字, tsk 表示这个等待队列项属于哪个任务(进程),一般设置为current , 在 Linux 内 核 中 current 相 当 于 一 个 全 局 变 量 , 表 示 当 前 进 程 。

①将队列项添加/移除等待队列头

当设备不可访问的时候就需要将进程对应的等待队列项添加到前面创建的等待队列头中,只有添加到等待队列头中以后进程才能进入休眠态。当设备可以访问以后再将进程对应的等待队列项从等待队列头中移除即可。

等待队列项添加 API 函数如下:

void add_wait_queue(struct wait_queue_head *wq_head,struct wait_queue_entry *wq_entry)

等待队列项移除 API 函数如下:

void remove_wait_queue(struct wait_queue_head *wq_head,struct wait_queue_entry *wq_entry)

②等待唤醒

当设备可以使用的时候就要唤醒进入休眠态的进程,唤醒可以使用如下两个函数:

void wake_up(struct wait_queue_head *wq_head)
void wake_up_interruptible(struct wait_queue_head *wq_head)

参数 wq_head 就是要唤醒的等待队列头,这两个函数会将这个等待队列头中的所有进程都唤醒。
wake_up 函数可以唤醒处于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 状态的进程
wake_up_interruptible 函数只能唤醒处于 TASK_INTERRUPTIBLE 状态的进程。
TASK_INTERRUPTIBLE : 允许被信号或条件满足唤醒
TASK_UNINTERRUPTIBLE :不允许被信号或条件满足唤醒

③等待事件

除了主动唤醒以外,也可以设置等待队列等待某个事件,当这个事件满足以后就自动唤醒等待队列中的进程。
和等待事件有关的 API 函数如表 16.1.2.1 所示:
在这里插入图片描述
wait_event_xxx虽然会在条件满足时自动唤醒进程,但仍需显式调用 wake_up_xxx 函数的原因如下:
1、被动条件检测‌:wait_event_interruptible 通过循环检查条件(condition),但条件变量的修改通常由其他线程或中断处理程序完成。若没有 wake_up 的主动通知,进程可能因未及时检测条件变化而持续休眠。
2、调度时机‌:进程被移出运行队列(runqueue)后,仅当通过 wake_up 重新加入队列时,内核才会重新调度该进程并触发条件检查。

显式调用 wake_up_xxx 函数会将进程从等待队列移出并设为可运行状态(TASK_RUNNING),但进程被调度执行时,wait_event_interruptible 会‌立即重新检查 condition‌:
若 condition 仍为假,进程‌再次进入休眠‌(重新加入等待队列)。
若 condition 为真,进程继续执行后续代码。

12.4 轮询

也称I/O 多路复用:外部阻塞式, 内部监视多路 I/O。在应用程序中使用 select()或 poll()时需要注意一个问题,当监测到某一个或多个文件描述符成为就绪态(可以读或写)时,需要执行相应的 I/O 操作,以清除该状态,否则该状态将会一直存在;

虽然叫轮询,但只执行一次,同时有timeout参数,等待过程中还是进行阻塞挂起。驱动程序中也需要wait_queue_head等待队列头。

如果用户应用程序以非阻塞的方式访问设备,设备驱动程序就要提供非阻塞的处理方式,也就是轮询。 应用程序通过 select、 epoll 或 poll轮询函数来查询设备是否可以操作,此时,设备驱动程序中的 poll 函数就会执行,因此需要在设备驱动程序中编写 poll 函数。

如果用户应用程序以非阻塞的方式访问设备,但设备驱动程序没有提供非阻塞的处理方式,因为内核默认将设备文件视为阻塞式访问,所以驱动会忽略用户态的非阻塞请求或者出错。

我们先来看一下应用程序中使用的 select、 poll 和 epoll 这三个函数。

①select函数

select 函数原型如下:

int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout)

参数:
nfds:

所要监视的这三类(readfds、writefds和exceptfds)文件描述集合中, 最大文件描述符加 1。

readfds、 writefds 和 exceptfds:

这三个指针指向描述符集合fd_set ,fd_set类型本质是一个位图,如果将文件描述符2和4添加到位图当中,则位图表示的是为10100,就是二进制的数。readfds 用于监视指定描述符集的读变化,也就是监视这些文件是否可以读取,只要这些集合里面有一个文件可以读取,那么 seclect 就会返回一个大于 0 的值表示文件可以读取。writefs 用于监视这些文件是否可以进行写操作。exceptfds 用于监视这些文件的异常。如果这三个参数都设置为 NULL,则可以将 select()当做为一个类似于 sleep()休眠的函数来使用,通过 select()函数的最后一个参数 timeout 来设置休眠时间
fd_set的宏操作:
void FD_ZERO(fd_set *set) // FD_ZERO 用于将 fd_set 变量的所有位都清零
void FD_SET(int fd, fd_set *set) //FD_SET 用于将 fd_set 变量的某个位置 1,也就是向 fd_set 添加一个文件描述符fd
void FD_CLR(int fd, fd_set *set) //FD_CLR 用于将 fd_set变量的某个位清零,也就是将一个文件描述符fd从 fd_set 中删除
int FD_ISSET(int fd, fd_set *set) //FD_ISSET 用于测试一个文件是否属于某个集合,参数 fd 就是要判断的文件描述符。
驱动会重新赋值给这三个fd_set,代表结果。

timeout:

超时时间,如果没有文件可以读取,那么就会根据 timeout 参数来判断是否超时,当 timeout 为 NULL 的时候就表示无限期的等待。
超时时间使用结构体 timeval 表示,结构体定义如下所示:
struct timeval {
long tv_sec; /* 秒 /
long tv_usec; /
微妙 */
}

返回值:

0,表示的话就表示超时发生,就是没有任何文件描述符可以进行操作;
-1,发生错误;
其他值,可以进行操作的文件描述符个数

select()函数返回的情况,有以下事情发生:
⚫ readfds、 writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态;对于 其他值,可以进行操作的文件描述符个数
⚫ 该调用被信号处理函数中断; -1,发生错误;
⚫ 参数 timeout 中指定的时间上限已经超时。 0,就是没有任何文件描述符可以进行操作;

②poll函数

在单个线程中, select 函数能够监视的文件描述符数量有最大的限制,一般为 1024,poll 函数本质上和 select 没有太大的差别,但是 poll 函数没有最大文件描述符限制, Linux 应用程序中 poll 函数原型如下所示:

int poll(struct pollfd *fds,nfds_t nfds,int timeout)

fds: 要监视的文件描述符集合以及要监视的事件,为一个数组,数组元素都是结构体 pollfd类型的, pollfd 结构体如下所示:

struct pollfd {
	int fd; /* 文件描述符 */
	short events; /* 请求的事件 */
	short revents; /* 返回的事件 */
};

fd 是要监视的文件描述符,如果 fd 无效的话那么 events 监视事件也就无效,并且 revents返回 0。
events 是要监视的事件,可监视的事件类型如下所示:
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN
revents 是返回参数,也就是返回的事件, 由 Linux 内核设置具体的返回事件。

在这里插入图片描述

nfds: poll 函数要监视的文件描述符数量。
timeout: 超时时间,单位为 ms。
timeout > 0‌ 阻塞等待最多timeout毫秒,超时后返回0(即使无事件发生)
timeout = 0‌ 非阻塞模式,立即返回当前就绪的文件描述符数量(若无事件则返回0)
timeout < 0‌ 无限阻塞,直到至少一个文件描述符就绪或发生错误

poll()函数返回值含义与 select()函数的返回值是一样的,有如下几种情况:
⚫ 返回-1 表示有错误发生,并且会设置 errno。
⚫ 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
⚫ 返回一个正整数表示有一个或多个文件描述符处于就绪态了, 返回值表示 fds 数组中返回的 revents变量不为 0 的 struct pollfd 对象的数量

③epoll 函数

传统的 selcet 和 poll 函数都会随着所监听的 fd 数量的增加,出现效率低下的问题,而且poll 函数每次必须遍历所有的描述符来检查就绪的描述符,这个过程很浪费时间。为此, epoll应运而生, epoll 就是为处理大并发而准备的,一般常常在网络编程中使用 epoll 函数。

12.5 Linux 驱动下的 poll 操作函数

当应用程序调用 select 或 poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations 操作集中的 poll 函数就会执行,应用程序中的select、poll函数中的timeout参数就是ops.poll函数可阻塞的时间,ops.poll通常有条件判断,能够进行阻塞,只有条件满足ops.poll函数才会结束,产生返回值,这时应用序中的select、poll函数才能得到结果,根据结果进行相应的操作。并不是在ops.poll函数中完成读写之类的操作,是判断能否进行读写之类的操作。
poll 函数原型如下所示:

unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait)

filp: 要打开的设备文件(文件描述符)。
wait: 结构体 poll_table_struct 类型指针, 由应用程序传递进来的。一般将此参数传递给poll_wait 函数。
返回值:向应用程序返回设备或者资源状态。

需要在驱动程序的 poll 函数中调用 poll_wait 函数,poll_wait 函数不会引起阻塞,会将当前进程加入设备的等待队列。
poll_wait 函数原型如下:

void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)

filp‌:设备文件指针,通常传递驱动中的 struct file 对象
‌wait_address‌:预先定义的等待队列头
‌p‌:poll_table 指针,由内核在调用 poll 方法时传入,用于关联等待队列,也就是file_operations 中 poll 函数的 wait 参数。

12.6 阻塞IO实验

虽然在应用程序中默认使用阻塞IO的方式,但驱动程序中没有等待队列头等,也就无法实现进程阻塞。
在key 设备结构体中有:

atomic_t status; /* 按键状态 */
wait_queue_head_t r_wait; /* 读等待队列头 */

当按键状态发生改变时,使用wake-up-xxx函数,配合wait_event_interruptible函数。
故在key_timer_function定时器函数中使用wake-up-xxx函数
在file-operation的key_read函数中使用wait_event_interruptible函数

12.7 非阻塞IO实验

在阻塞IIO实验的基础上,添加了非阻塞IO的代码,但因此应用程序中的select和poll函数都有timeout参数,所以实际上也会进行休眠,占用cpu的资源也很少。
在key_read中添加非阻塞方式:

if (filp->f_flags & O_NONBLOCK) {	// 非阻塞方式访问
        if(KEY_KEEP == atomic_read(&key.status))
            return -EAGAIN;
    } else {							// 阻塞方式访问
	/* 加入等待队列,当有按键按下或松开动作发生时,才会被唤醒 */
	ret = wait_event_interruptible(key.r_wait, KEY_KEEP != atomic_read(&key.status));
	if(ret)
		return ret;
	}

如果&key.status为按下或松开,那么可以在应用程序中直接使用read函数读取,否则read函数读取失败,表明需要使用poll函数或select函数轮询。必须要在read函数中添加非阻塞的代码,不然,如果应用程序使用非阻塞方式访问read函数,会执行wait_event_interruptible阻塞代码,导致逻辑混乱,或者在read函数添加判断,如果是非阻塞访问,直接读取失败,必须用poll、select等函数进行非阻塞访问。

十三、异步通知

也称异步IO。

Linux异步通知是一种软件层模拟中断的机制,允许内核或进程通过信号主动通知目标进程特定事件的发生,无需目标进程轮询查询状态。例如,驱动程序能主动向应用程序发出通知,报告自己可以访问,然后应用程序再从驱动程序中读取或写入数据。这样就不用应用程序主动进行询问了,一切都是由驱动设备自己告诉给应用程序的。

异步通知的核心就是信号,除了 SIGKILL(9 终止进程)和 SIGSTOP(19 暂停进程)这两个信号不能被忽略外,其他的信号都可以忽略。这些信号就相当于中断号,不同的中断号代表了不同的中断,不同的中断所做的处理不同,类属于处理函数不同。

中断需要设置中断处理函数,应用程序中使用信号,必须设置信号所使用的信号处理函数,在应用程序中使用 signal 函数来设置指定信号的处理函数, signal 函数原型如下所示:

sighandler_t signal(int signum, sighandler_t handler)

signum:要设置处理函数的信号。
handler: 信号的处理函数。
返回值: 设置成功的话返回信号的前一个处理函数,设置失败的话返回 SIG_ERR。

信号处理函数原型如下所示:

typedef void (*sighandler_t)(int)

参数表示触发该处理函数的信号编号(如SIGINT、SIGTERM等)

13.1 驱动中的信号处理

需要在驱动程序中定义一个 fasync_struct 结构体指针变量,主要用于驱动层与应用层之间的信号通信,一般将 fasync_struct 结构体指针变量定义到设备结构体中:

struct fasync_struct *async_queue;

要使用异步通知,需要在设备驱动中实现 file_operations 操作集中的 fasync 函数,此函数格式如下所示:

int (*fasync) (int fd, struct file *filp, int on)

释放fasync_struct也使用这个函数:fasync (-1, filp, 0)

fasync 函数里面一般通过调用 fasync_helper 函数来初始化前面定义的 fasync_struct 结构体指针, fasync_helper 函数原型如下:

int fasync_helper(int fd, struct file * filp, int on, struct fasync_struct **fapp)

fasync_helper 函数的前三个参数就是 fasync 函数的那三个参数,第四个参数就是要初始化的 fasync_struct 结构体指针变量。

当设备可以访问的时候,驱动程序需要向应用程序发出信号,相当于产生“中断”。 kill_fasync函数负责发送指定的信号, kill_fasync 函数原型如下所示:

void kill_fasync(struct fasync_struct **fp, int sig, int band)

函数参数和返回值含义如下:
fp:要操作的 fasync_struct。
sig: 要发送的信号。
band: 可读时设置为 POLL_IN,可写时设置为 POLL_OUT。band会影响信号的处理顺序,高优先级信号(如POLL_ERR)可能被优先递送给应用层。应用层通过signal或sigaction注册的处理函数无法直接获取band值,需结合其他机制(如全局状态变量)传递信息。

13.2 应用程序对异步通知的处理

要使用异步 I/O,程序需要按照如下步骤来执行:
⚫ 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。(不一定,根据实际情况)
⚫ 通过指定 O_ASYNC 标志使能异步 I/O。
⚫ 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程。
⚫ 为内核发送的通知信号注册一个信号处理函数。默认情况下, 异步 I/O 的通知信号是 SIGIO,内核会给进程发送信号 SIGIO。
⚫ 以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行 I/O 操作。

① O_ASYNC 标志使能异步 I/O

O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,在调用 open()时无法通过指定 O_ASYNC 标志来使能异步 I/O,但可以使用 fcntl()函数添加 O_ASYNC 标志使能异步 I/O:

flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_ASYNC; //将 O_ASYNC 标志添加到 flag
fcntl(fd, F_SETFL, flag); //重新设置 flag

②设置异步 I/O 事件的接收进程

为文件描述符设置异步 I/O 事件的接收进程, 也就是设置异步 I/O 的所有者。 同样也是通过 fcntl()函数
进行设置,操作命令 cmd 设置为 F_SETOWN,第三个参数传入接收进程的进程 ID(PID), 通常将调用进
程的 PID 传入, 譬如:
fcntl(fd, F_SETOWN, getpid());

③注册 SIGIO 信号的处理函数

通过 signal()或 sigaction()函数为 SIGIO 信号注册一个信号处理函数,当进程接收到内核发送过来的SIGIO 信号时,会执行该处理函数,所以我们应该在处理函数当中执行相应的 I/O 操作。

重点就是通过 fcntl 函数设置进程状态为 FASYNC,经过这一步,驱动程序中的 fasync 函数就会执行。

13.3 系统调用fcntl

fcntl(File Control)是Linux/Unix系统中用于精细控制文件描述符的核心系统调用,fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作。系统调用(System Call)是操作系统内核提供给用户程序的接口,允许应用程序请求内核执行特权操作。

int fcntl(int fd, int cmd, ... /* arg */);

‌fd‌:目标文件描述符。
‌cmd‌:操作命令,决定函数行为。
‌arg‌:可选参数,类型取决于cmd。

13.4 实验

驱动创建设备(dev/XXX),应用程序打开设备(dev/xxx),此时,fd已经和驱动(ops函数集)绑定在一起了。

驱动:
在key设备结构体中添加fasync_struct结构体:

/* key设备结构体 */
struct key_dev{
	dev_t devid;			/* 设备号 	 */
	。。。。。。
	struct fasync_struct *async_queue;	/* fasync_struct结构体 */
};

在定时器处理函数中添加kill_fasync函数:

if(key.async_queue)
			kill_fasync(&key.async_queue, SIGIO, POLL_IN);

if(key.async_queue):判断该结构体是否初始化, fasync_helper 函数对 fasync_struct结构体进行初始化,而 fasync_helper 函数在 file_operations 操作集中的 fasync 函数中,所以,只有应用程序使用系统调用fcntl开启异步通知,才会进行初始化,所以这个判断的作用是判断应用程序是否开启异步通知。发生SIGIO信号(可以进行输入/输出操作).

file_operations 操作集中的.release = key_release 和.fasync = key_fasync

static int key_fasync(int fd, struct file *filp, int on)
{
	return fasync_helper(fd, filp, on, &key.async_queue);
}

static int key_release(struct inode *inode, struct file *filp)
{
	return key_fasync(-1, filp, 0);
}

应用程序:

fd = open(argv[1], O_RDONLY | O_NONBLOCK);

虽然用非阻塞方式打开文件,但没有使用select、poll函数,且驱动程序有非阻塞对应的操作,所以这里用不用O_NONBLOCK都无所谓。似乎使用异步IO有的情况是要通过指定 O_NONBLOCK 标志使能非阻塞 I/O的,这个需要注意。

/* 设置信号SIGIO的处理函数 */
signal(SIGIO, sigio_signal_func);
fcntl(fd, F_SETOWN, getpid());			// 将当前进程的进程号告诉给内核
flags = fcntl(fd, F_GETFD);				// 获取当前的进程状态
fcntl(fd, F_SETFL, flags | FASYNC);		// 设置进程启用异步通知功能

十四、platform平台驱动

参考:linux驱动之统一设备模型

在这里插入图片描述
在图中,我们可以抽象出几个概念:总线(Bus)、设备(Device)、驱动(Driver) 和 类(Class)。

总线:为了而昂 CPU 和 多个 设备 之间进行信息交互的通道,由此抽象出总线。所有的设备都连接到 总线(无论CPU的外设总线和虚拟总线platform Bus) 上。

类:Linux内核中的 类 不是 对面对象程序设计中的类,而是指 具有相似功能或属性的设备。由于设备之间的功能或属性相同,所以可以在多个设备之间抽象出一套 统一的数据结构和接口,这就是 类。从属于 相同类的设备驱动程序 就不再需要重复定义公共属性,直接从类中继承即可。

设备:将系统中所有硬件设备的共同属性,比如 名字、 属性、从属总线 和 类 等信息抽象出来,即成为 设备

驱动:Linux内核使用 驱动 来描述硬件设备的驱动程序,驱动包含了 设备初始化、电源管理接口 和 设备操作接口,而驱动开发基本围绕这些规定的接口进行开发。

14.1 linux驱动的分离

分离‌:‌横向解耦‌硬件描述与驱动逻辑,实现同一驱动适配不同硬件
通过设备树描述硬件资源(如GPIO引脚、中断号),驱动代码通过标准API获取这些信息,消除硬编码依赖。
设备树、或者platform_device:

gpioled{
		compatible = "alientek,led";
		pinctrl-names = "alientek,led";
		pinctrl-0 = <&led>;
		led-gpio = <&gpio0 RK_PC0 GPIO_ACTIVE_HIGH>;
		status = "okay";
	};

驱动:

/* 匹配列表 */
static const struct of_device_id led_of_match[] = {
    { .compatible = "alientek,led" },
    { /* Sentinel */ }
};

MODULE_DEVICE_TABLE(of, led_of_match);

/* platform驱动结构体 */
static struct platform_driver led_driver = {
    .driver     = {
        .name   = "rk3568-led",         	/* 驱动名字,用于和设备匹配 */
        .of_match_table = led_of_match, /* 设备树匹配表        */
    },
    .probe      = led_probe,
    .remove     = led_remove,
};

同一驱动适配多硬件(如不同GPIO引脚LED),只需修改设备树即可,驱动代码无需修改,驱动开发者无需关注硬件细节。

14.2 linux驱动的分层

分层‌:‌纵向切割‌功能模块,构建标准化接口层级,隐藏底层实现细节
输入子系统分层模型,以触摸屏驱动为例的三层架构:

// 底层:硬件操作
static void read_touch_data(struct i2c_client *client, int *x, int *y) {
    *x = i2c_smbus_read_byte_data(client, X_REG);
    *y = i2c_smbus_read_byte_data(client, Y_REG);
}
// 中间层:事件抽象
static void report_event(struct input_dev *dev, int x, int y) {
    input_report_abs(dev, ABS_X, x);
    input_report_abs(dev, ABS_Y, y);
    input_sync(dev);
}
// 应用层接口
// 用户程序通过/dev/input/eventX读取标准化事件

相同接口兼容多设备,新增设备只需实现底层接口,应用开发者无需了解硬件协议。

14.3 platform平台驱动模型

总线(bus)、驱动(driver)和设备(device)模型

①platform总线

平台总线(platform_bus_type)是Linux内核定义的虚拟总线,用于管理没有物理总线连接的片上设备(如GPIO、定时器等)。它通过/sys/bus/platform目录暴露给用户空间,提供设备与驱动的匹配机制。

platform总线的内容Linux内核已经写好了,我们只需要根据其使用规则,设置好相应的配置即可,比如,platform_match 函数的匹配方式中的信息。

Linux系统内核使用bus_type结构体表示总线,bus_type结构体中有

int (*match)(struct device *dev, struct device_driver *drv);

match 函数就是完成设备和驱动之间匹配的,总线就是使用 match 函数来根据注册的设备来查找对应的驱动,或者根据注册的驱动来查找相应的设备,因此每一条总线都必须实现此函数。 match 函数有两个参数: dev 和 drv,这两个参数分别为 device 和 device_driver 类型,也就是设备和驱动。

platform 总线是 bus_type 的一个具体实例:

struct bus_type platform_bus_type = {
	.name = "platform",
	.dev_groups = platform_dev_groups,
	.match = platform_match,
	.uevent = platform_uevent,
	.dma_configure = platform_dma_configure,
	.pm = &platform_dev_pm_ops,
};

platform_bus_type 就是 platform 平台总线,其中 platform_match 就是匹配函数。platform_match 函数中有四种匹配方式:

if (of_driver_match_device(dev, drv))
	return 1;
 /* Then try ACPI style match */
if (acpi_driver_match_device(dev, drv))
	return 1;
/* Then try to match against the id table */
if (pdrv->id_table)
	return platform_match_id(pdrv->id_table, pdev) != NULL;
/* fall-back to driver name match */
return (strcmp(pdev->name, drv->name) == 0);

第一种匹配方式, OF 类型的匹配,也就是设备树采用的匹配方式,of_driver_match_device 函数定义在文件 include/linux/of_device.h 中。 device_driver 结构体(表示驱动)中有个名为of_match_table的成员变量,此成员变量保存着驱动的compatible匹配表,设备树中的每个设备节点的 compatible 属性会和 of_match_table 表中的所有成员比较,查看是否有相同的条目,如果有的话就表示设备和此驱动匹配,设备和驱动匹配成功以后,驱动的probe 函数就会执行。

第二种匹配方式, ACPI 匹配方式。

第三种匹配方式, id_table 匹配,每个 platform_driver 结构体有一个 id_table成员变量,顾名思义,保存了很多 id 信息。这些 id 信息存放着这个 platformd 驱动所支持的驱动类型。无设备树匹配方式

第四种匹配方式,如果第三种匹配方式的 id_table 不存在的话就直接比较驱动和设备的 name 字段,看看是不是相等,如果相等的话就匹配成功。无设备树匹配方式。

②platform驱动

platform驱动本质上还是设备驱动,只不过需要安装platform平台驱动的框架去写,要添加platform_driver 结构。

platform_driver 结构体表示 platform 驱动,内容有:

struct platform_driver {
	int (*probe)(struct platform_device *);
	int (*remove)(struct platform_device *);
	void (*shutdown)(struct platform_device *);
	int (*suspend)(struct platform_device *, pm_message_t state);
	int (*resume)(struct platform_device *);
	struct device_driver driver;
	const struct platform_device_id *id_table;
	bool prevent_deferred_probe;
};

probe 函数,当驱动与设备匹配成功以后driver的 probe 函数就会执行,如果自己要编写一个全新的驱动,那么 probe 就需要自行实现。

id_table 表,也就是我们上一小节讲解 platform 总线匹配驱动和设备的时候采用的第三种方法, id_table 是个表(也就是数组)。

driver 成员,为 device_driver 结构体变量, Linux 内核里面大量使用到了面向对象的思维, device_driver 相当于基类,提供了最基础的驱动框架。 plaform_driver 继承了这个基类,然后在此基础上又添加了一些特有的成员变量。

device_driver 结构体内容有:

struct device_driver {
	const char *name;
	const struct of_device_id *of_match_table;
	int (*probe) (struct device *dev);
	int (*remove) (struct device *dev);
	void (*shutdown) (struct device *dev);
	int (*suspend) (struct device *dev, pm_message_t state);
	int (*resume) (struct device *dev);
	。。。。。。
}

其中,of_match_table 就是采用设备树的时候驱动使用的匹配表,第一种匹配方式,同样是数组,每个匹配项都为 of_device_id 结构体类型,内容如下:

struct of_device_id {
	char name[32];
	char type[32];
	char compatible[128];
	const void *data;
};

其中, compatible 非常重要,因为对于设备树而言,就是通过设备节点的 compatible 属性值和 of_match_table 中每个项目的 compatible 成员变量进行比较,如果有相等的就表示设备和此驱动匹配成功。

在编写 platform 驱动的时候,首先定义一个 platform_driver 结构体变量,然后实现结构体中的各个成员变量,重点是实现匹配方法(device_driver.name 、device_driver.of_match_table)以及 probe 函数。当驱动和设备匹配成功以后 probe函数就会执行,具体的驱动程序在 probe 函数里面编写,比如字符设备驱动等等。

在驱动入口函数里面调用platform_driver_register 函数向 Linux 内核注册一个 platform 驱动, platform_driver_register 函数
原型如下所示:

int platform_driver_register (struct platform_driver *driver)

在驱动卸载函数中通过 platform_driver_unregister 函数卸载 platform 驱动,platform_driver_unregister 函数原型如下:

void platform_driver_unregister(struct platform_driver *drv)

或者直接调用module_platform_driver 函数。在 Linux 内核中会大量采用 module_platform_driver 来完成向 Linux 内核注册 platform 驱动的操作。module_platform_driver(gpio_led_driver) 等效于:

static int __init gpio_led_driver_init(void)
{
	return platform_driver_register (&(gpio_led_driver));
}
module_init(gpio_led_driver_init);
static void __exit gpio_led_driver_exit(void)
{
	platform_driver_unregister (&(gpio_led_driver) );
}
module_exit(gpio_led_driver_exit);

总体来说, platform 驱动还是传统的字符设备驱动、块设备驱动或网络设备驱动,只是套上了一张“platform”的皮,目的是为了使用总线、驱动和设备这个驱动模型来实现驱动的分离与分层。

③platform设备

既然platform驱动本质上还是设备驱动,那么需要platform设备或设备树提供相应的设备信息。如果内核支持设备树的话就不要再使用 platform_device 来描述设备。主要是在platform_device.resource中设置寄存器相关信息。

platform_device 这个结构体表示 platform 设备,结构体内容如下:

struct platform_device {
	const char *name;
	int id;
	bool id_auto;
	struct device dev;
	u32 num_resources;
	struct resource *resource;
	const struct platform_device_id *id_entry;
	。。。。。。
}

name 表示设备名字,要和所使用的 platform 驱动的 name 字段相同(第四种匹配方式),否则的话设备就无法匹配到对应的驱动。
resource 表示资源,也就是设备信息,比如外设寄存器等。
id_entry 包含一组唯一的标识符(如字符串或数值),用于在驱动程序的 id_table 中查找匹配项。即第三种匹配方式。

在以前不支持设备树的Linux版本中,用户需要编写platform_device变量来描述设备信息,然后使用 platform_device_register 函数将设备信息注册到 Linux 内核中,此函数原型如下所示:

int platform_device_register(struct platform_device *pdev)

如果不再使用 platform 的话可以通过 platform_device_unregister 函数注销掉相应的 platform设备, platform_device_unregister 函数原型如下:

void platform_device_unregister(struct platform_device *pdev)

14.4 无设备树实验

使用 platform 驱动框架来编写一个 LED 灯驱动。因为无设备树,所以需要platform_device,leddevice.c 中使用platform_device结构体设置led相关的寄存器地址。在leddriver.c 中,使用ioremap得到虚拟地址进行gpio设置。

编译后得到,leddevice.ko leddriver.ko驱动文件和ledApp应用文件,

modprobe leddevice //加载设备模块
modprobe leddriver //加载驱动模块

进入/sys/bus/platform/devices/目录,在 leddevice.c 中设置设备的 name 字段为“rk3568-led”,在/sys/bus/platform/devices/目录下存在一个名字“rk3568-led”的文件.
在 leddriver.c 中设置name 字段为“rk3568-led”,因此会在/sys/bus/platform/drivers/目录下存在名为“rk3568-led”这个文件.
使用第四种匹配方式,strcmp(pdev->name, drv->name) == 0,匹配成功。

14.5 设备树实验

在使用设备树的时候,设备的描述被放到了设备树中,因此 platform_device 就不需要我们去编写了,我们只需要实现 platform_driver 即可。

&pinctrl{ //不建议
	led: led_gpio{
		rockchip,pins =
		<0 RK_PC0 RK_FUNC_GPIO &pcfg_pull_none>;
	};
}
&pinctrl{ //建议
	led-gpios{
		/omit-if-no-ref/ //表示当没有其他节点引用此pinctrl配置时,内核会自动忽略此节点,避免冗余配置占用资源
		led: led_gpio{
		rockchip,pins =
				<0 RK_PC0 RK_FUNC_GPIO &pcfg_pull_none>;
		};
	};
};

使用第一种写法报错:
rockchip-pinctrl pinctrl: unable to find group for node led_gpio
rk3568-led: probe of gpioled failed with error -22
直接定义在&pinctrl下的扁平化节点(如第一种写法)可能被误判为独立功能组,而Rockchip建议通过容器节点组织相关配置。

modprobe leddriver //加载驱动模块
在leddriver.c 中设置 name 字段为“rk3568-led”,因此会在/sys/bus/platform/drivers/目录下存在名为“rk3568-led”这个文件.
在/sys/bus/platform/devices/目录下也存在 gpioled 的设备文件,也就是设备树中 gpioled 这个节点的名字。

十五、MISC驱动

Linux MISC(杂项)驱动是内核中用于简化字符设备驱动开发的框架,适用于无法归类到传统设备类型的外设,或者说,适用于一些简单的字符设备驱动开发中。

15.1 简介

所有的 MISC 设备驱动的主设备号都为 10,不同的设备使用不同的从设备号。MISC 设备会自动创建 cdev,不需要像我们以前那样手动创建,因此采用 MISC 设备驱动可以简化字符设备驱动的编写。我们需要向 Linux 注册一个 miscdevice 设备, miscdevice是一个结构体,如下:

struct miscdevice  {
	int minor;
	const char *name;
	const struct file_operations *fops;
	struct list_head list;
	struct device *parent;
	struct device *this_device;
	const struct attribute_group **groups;
	const char *nodename;
	umode_t mode;
};

定义一个 MISC 设备(miscdevice 类型)需要设置 minor、 name 和 fops 这三个成员变量。
minor 表示子设备号, MISC 设备的主设备号为 10,需要用户指定子设备号,MISC_DYNAMIC_MINOR(255)为动态分配。
name 就是此 MISC 设备名字,当此设备注册成功以后就会在/dev 目录下生成一个名为 name的设备文件。
fops 就是字符设备的操作集合, MISC 设备驱动最终是需要使用用户提供的 fops操作集合。

/* MISC设备结构体 */
static struct miscdevice led_miscdev = {
    .minor = MISC_DYNAMIC_MINOR, // 动态分配
    .name = MISCLED_NAME,
    .fops = &miscled_fops,
};

misc_register 函数向系统中注册一个 MISC 设备,此函数原型如下:

int misc_register(struct miscdevice * misc)

misc_deregister 函数来注销掉 MISC 设备,函数原型如下:

int misc_deregister(struct miscdevice *misc)

15.2 实验

实验中,misc驱动嵌套在Platform总线驱动中,实现设备与驱动的绑定。定义好 struct miscdevice led_miscdev结构体,在probe函数中使用misc_register函数,在remove函数中使用misc_deregister函数。

当驱动模块加载成功以后我们可以在/sys/class/misc 这个目录下看到一个名为“miscled”的子目录。所有的 misc 设备都属于同一个类, /sys/class/misc 目录下就是 misc 这个类的所有设备,每个设备对应一个子目录。

驱动与设备匹配成功以后就会执行probe函数(misc_register执行),生成/dev/miscled 这个设备驱动文件。

十六、input 子系统

参考:Linux内核编程(十三)Input输入子系统
讲的很清楚,容易看懂。

16.1 介绍

Input 子系统是 Linux 内核中专门为输入设备设计的一个子系统,它提供了一个通用的框架来管理各种输入设备,如键盘、鼠标、触摸屏、游戏手柄等。输入设备驱动的共同点就是获取数据,上报给用户。所以Linux就将通用的代码编写好,将差异化的代码留给驱动开发工程师来编写。input子系统的主要目的是简化和规范化输入设备驱动的开发,同时提高驱动的通用性和兼容性。

input 子系统分为 input 驱动层、 input 核心层、 input 事件处理层,最终给用户空间提供可访问的设备节点。
在这里插入图片描述
中间部分属于Linux 内核空间,驱动分层模型分为驱动层、核心层和事件层:

设备驱动层:设备驱动层可以通过获取设备树中硬件的信息,对硬件各寄存器的读写访问和将底层硬件的状态变化转换为标准的输入事件,将相应事件上报,再通过核心层提交给事件处理层。
核心层:用于将设备驱动层和事件处理层进行匹配,处理输入事件的分发和管理,是输入子系统的核心部分。这部分由内核工程师来编写,不需要我们自己编写。
事件处理层:这一层是直接与应用程序交互的部分。事件处理层负责将核心层生成的输入事件传递给系统的高层应用,并确保这些事件被正确处理。应用程序通过这一层接收用户输入(如点击、键盘按键等),并据此进行相应的操作。这部分由内核或设备厂商来实现,也不需要我们编写。

那么对于一个输入子系统驱动程序,我们只需要调用内核实现的接口来编写设备驱动层即可。核心层,和事件处理层的代码不需要我们来编写。

16.2 input设备驱动

input 核心层会向 Linux 内核注册一个字符设备,drivers/input/input.c 就是 input 输入子系统的核心层。
创建一个名为input的class 类:

struct class input_class = {
	.name = "input",
	.devnode = input_devnode,
};

注册input,在/sys/class 目录下有一个 input 子目录:

err = class_register(&input_class);

注册字符设备,主设备号为 INPUT_MAJOR(13)

err = register_chrdev_region(MKDEV(INPUT_MAJOR, 0),INPUT_MAX_CHAR_DEVICES, "input");

因此, input 子系统的所有设备主设备号都为 13,我们在使用 input 子系统处理输入设备的时候就不需要去注册字符设备了,我们只需要向系统注册一个 input_device 即可

设备驱动层‌负责与具体硬件交互,将物理信号转化为标准输入事件。开发者需实现 input_dev 结构体注册设备,并通过 input_event() 上报事件。

①注册input_dev

在使用 input 子系统的时候我们只需要注册一个input_dev 结构体表示 input设备。结构体内容如下:

struct input_dev {
	const char *name;  // 设备名称,例如 "Keyboard" 或 "Mouse"
	const char *phys;  // 设备在系统中的物理路径,例如 "usb-0000:00:14.0-1/input0"
	const char *uniq;  // 设备的唯一标识符,通常用于匹配特定硬件
	struct input_id id; // 包含设备识别信息的结构体(例如供应商ID、产品ID、版本号)

	// 属性位图,用于表示设备支持的属性类型
	unsigned long propbit[BITS_TO_LONGS(INPUT_PROP_CNT)];

	// 事件位图,用于表示设备支持的事件类型
	unsigned long evbit[BITS_TO_LONGS(EV_CNT)];
	// 键位图,用于表示设备支持的按键类型
	unsigned long keybit[BITS_TO_LONGS(KEY_CNT)];
	// 相对位图,用于表示设备支持的相对轴事件。 例如鼠标
	unsigned long relbit[BITS_TO_LONGS(REL_CNT)];
	// 绝对位图,用于表示设备支持的绝对轴事件,例如触摸屏
	unsigned long absbit[BITS_TO_LONGS(ABS_CNT)];
	// 杂项位图,用于表示设备支持的其他事件类型
	unsigned long mscbit[BITS_TO_LONGS(MSC_CNT)];
	// 指示灯位图,用于表示设备支持的LED灯类型
	unsigned long ledbit[BITS_TO_LONGS(LED_CNT)];
	// 声音位图,用于表示设备支持的声音类型
	unsigned long sndbit[BITS_TO_LONGS(SND_CNT)];
	// 力反馈位图,用于表示设备支持的力反馈事件
	unsigned long ffbit[BITS_TO_LONGS(FF_CNT)];
	// 开关位图,用于表示设备支持的开关类型
	unsigned long swbit[BITS_TO_LONGS(SW_CNT)];

	unsigned int hint_events_per_packet; // 每个数据包中的建议事件数量

	unsigned int keycodemax;   // 最大按键码数量
	unsigned int keycodesize;  // 每个按键码的大小
	void *keycode;             // 指向按键码数据的指针

	// 设置按键码的函数指针
	int (*setkeycode)(struct input_dev *dev,
			  const struct input_keymap_entry *ke,
			  unsigned int *old_keycode);
	// 获取按键码的函数指针
	int (*getkeycode)(struct input_dev *dev,
			  struct input_keymap_entry *ke);

	struct ff_device *ff;  // 力反馈设备的指针

	unsigned int repeat_key;  // 重复按键
	struct timer_list timer;  // 用于处理重复按键的定时器
	
	int rep[REP_CNT];  // 用于存储重复延迟和重复率
	struct input_mt *mt;  // 多点触控相关信息的指针
	struct input_absinfo *absinfo;  // 绝对轴相关信息的指针

	// 当前设备状态的位图(按键、指示灯、声音、开关)
	unsigned long key[BITS_TO_LONGS(KEY_CNT)];
	unsigned long led[BITS_TO_LONGS(LED_CNT)];
	unsigned long snd[BITS_TO_LONGS(SND_CNT)];
	unsigned long sw[BITS_TO_LONGS(SW_CNT)];

	// 打开设备的函数指针
	int (*open)(struct input_dev *dev);
	// 关闭设备的函数指针
	void (*close)(struct input_dev *dev);
	// 刷新设备的函数指针
	int (*flush)(struct input_dev *dev, struct file *file);
	// 处理事件的函数指针
	int (*event)(struct input_dev *dev, unsigned int type, unsigned int code, int value);

	struct input_handle __rcu *grab;  // 用于处理独占设备的指针

	spinlock_t event_lock;  // 用于保护事件处理的自旋锁
	struct mutex mutex;     // 设备访问的互斥锁

	unsigned int users;  // 使用该设备的用户数量
	bool going_away;     // 标志设备是否正在关闭

	struct device dev;  // 设备的基础信息

	struct list_head h_list;  // 处理句柄的链表
	struct list_head node;    // 设备的链表节点

	unsigned int num_vals;  // 当前输入值的数量
	unsigned int max_vals;  // 最大输入值的数量
	struct input_value *vals;  // 输入值数组的指针

	bool devres_managed;  // 标志设备资源是否由设备资源管理器管理
};

其中, evbit 表示输入事件类型,可选的事件类型定义在 include/uapi/linux/input.h 文件中,事件类型如下:
在这里插入图片描述
本章要使用到按键,那么就需要注册 EV_KEY 事件,如果要使用连按功能的话还需要注册 EV_REP 事件。同时要用到 keybit, keybit 就是按键事件使用的位图。Linux 内核定义了很多按键值,这些按键值定义在 include/uapi/linux/input-event-codes.h 文件中。

实验任务: GPIO3_C5 引脚来模拟按键,然后将其按键值设置为KEY_0。
input_dev 注册过程如下:
①、使用 input_allocate_device 函数申请一个 input_dev。
②、初始化 input_dev 的事件类型以及事件值。
③、使用 input_register_device 函数向 Linux 系统注册前面初始化好的 input_dev。
④、卸载input驱动的时候需要先使用input_unregister_device函数注销掉注册的input_dev。

注意:在 Linux 输入子系统的驱动开发中,使用 input_unregister_device() 函数后,通常‌不需要‌再显式调用 input_free_device() 函数。原因如下: input_unregister_device() 函数在内部会自动调用设备的 release 回调方法。如果 input_dev 结构体是通过input_allocate_device() 分配的,其 release 方法已被默认设置为执行内存释放操作,手动调用 input_free_device() 可能在双释放内存时导致内核崩溃。

input_allocate_device 函数来申请一个 input_dev,此函数原型如下所示:

struct input_dev *input_allocate_device(void)

input_free_device函数来释放掉前面申请到的input_dev,input_free_device 函数原型如下:

void input_free_device(struct input_dev *dev)

input_register_device 函数向 Linux 内核注册 input_dev,此函数原型如下:

int input_register_device(struct input_dev *dev)

input_unregister_device 函数来注销掉前面注册的 input_dev, input_unregister_device 函数原型如下:

void input_unregister_device(struct input_dev *dev)

在设备驱动层代码中,因为不同的输入设备产生的数据类型不同,需要描述输入设备能产生什么类型的数据(evbit中的EV_KEY),这个类型的数据要产生什么具体的事件(keybit中的按键值KEY_0),然后值是什么(上报输入函数中体现)。在代码中可以通过 __set_bit() 设置相应的位,可以表示设备支持的事件类型。

__set_bit(EV_KEY, inputdev->evbit); /* 设置产生按键事件 */
__set_bit(EV_REP, inputdev->evbit); /* 重复事件 */
__set_bit(KEY_0, inputdev->keybit); /*设置产生哪些按键值 */

②上报输入事件

获取到具体的输入值,或者说是输入事件,然后将输入事件上报给 Linux 内核。不同的事件,其上报事件的 API 函数不同。
input_event 函数,此函数用于上报指定的事件以及对应的值,函数原型如下:

void input_event(struct input_dev *dev,unsigned int type,unsigned int code,int value)

函数参数和返回值含义如下:
dev:需要上报的 input_dev。
type: 上报的事件类型,比如 EV_KEY。
code: 事件码,也就是我们注册的按键值,比如 KEY_0、 KEY_1 等等。
value:事件值,比如 1 表示按键按下, 0 表示按键松开。
返回值: 无。
input_event 函数可以上报所有的事件类型和事件值, Linux 内核也提供了其他的针对具体事件的上报函数,这些函数其实都用到了 input_event 函数。如下:

void input_report_key(struct input_dev *dev,unsigned int code, int value)
void input_report_rel(struct input_dev *dev, unsigned int code, int value)
void input_report_abs(struct input_dev *dev, unsigned int code, int value)
void input_report_ff_status(struct input_dev *dev, unsigned int code, int value)
void input_report_switch(struct input_dev *dev, unsigned int code, int value)
void input_mt_sync(struct input_dev *dev)

当上报事件以后还需要使用 input_sync 函数来告诉 Linux 内核 input 子系统上报结束,input_sync 函数本质是上报一个同步事件,此函数原型如下所示:

void input_sync(struct input_dev *dev)

按键的上报事件的参考代码如下所示,需要结合platform驱动连接设备树。
在这里插入图片描述

③输入事件input_event结构体

详细可以看看c应用笔记中的16.2 读取数据,其中有input_event结构体的介绍。

Linux 内核使用 input_event 这个结构体来表示所有的输入事件,内容如下:

struct input_event {
	struct timeval time;
	__u16 type;
	__u16 code;
	__s32 value;
};

time:时间,为 timeval 结构体类型,有tv_sec (秒)和 tv_usec(毫秒) 这两个成员变量都为32位 long 类型。
type: 事件类型,比如 EV_KEY,表示此次事件为按键事件,此成员变量为 16 位。
code: 事件码,比如在 EV_KEY 事件中 code 就表示具体的按键码,如: KEY_0、 KEY_1等等这些按键。此成员变量为 16 位。
value: 值,比如 EV_KEY 事件中 value 就是按键值,如果为 1 的话说明按键按下,如果为 0 的话说明按键没有被按下或者按键松开了。

input_event 这个结构体非常重要,因为所有的输入设备最终都是按照 input_event 结构体呈现给用户的,用户应用程序可以通过 input_event 来获取到具体的输入事件或相关的值。

④input子系统的优点

(1)兼容所有的输入设备:Input 子系统为各种输入设备(如键盘、鼠标、触摸屏等)提供了统一的接口,使得这些设备能够通过标准化的方式与系统进行交互。不同厂家的设备,只要遵循 Input 子系统的规范,就能保证在 Linux 系统中正常工作。例如,只需要设置好设备树,使用platform驱动连接即可。
(2)统一的驱动编程方式:Input 子系统提供了一套标准化的框架,使得开发者只需按照这个框架编写驱动,而不需要为每种设备编写完全不同的代码。这避免了因为不同开发者使用不同的编码方式导致的兼容性问题,也降低了驱动开发的复杂性。例如,input_dev 结构体表示 input设备,input_event函数上报输入事件。
(3)统一的应用操作接口:Input 子系统提供了一致的设备节点(通常位于 /dev/input 目录下),应用程序可以通过这些统一的接口来访问和操作输入设备,而不需要关心设备的具体实现。这大大简化了应用程序的开发和维护。例如,input_event结构体保存输入事件的内容,应用程序通过read函数读取 /dev/input/eventX 设备节点的input_event 结构体获取信息。

16.3 实验

驱动:
按键按下或释放,产生gpio中断,进入key_interrupt中断处理函数,此函数中,先关闭中断,使用mod_timer设计15ms的定时器,相当于15ms的按键消抖,15ms后,进入static void key_timer_function定时器处理函数,该函数中,读取按键值并上报按键事件,并开启中断。

因为evbit设置了重复事件,所以按下去不放开,就会一直输出信息。

可以直接使用 hexdump 命令来查看/dev/input/eventX 文件内容:
在这里插入图片描述
应用程序:
当向 Linux 内核成功注册 input_dev 设备以后,会在/dev/input 目录下生成一个名为“eventX(X=0….n)”的文件,这个/dev/input/eventX 就是对应的 input 设备文件。读取这个文件就可以获取到输入事件信息,比如按键值什么的。使用 read 函数读取输入设备文件,也就是/dev/input/eventX,读取到的数据按照 input_event 结构体组织起来。

在默认的 阻塞式I/O模式‌ 下,若应用程序通过 read 函数读取 /dev/input/eventX 设备节点时无可用事件数据,该函数会使调用进程进入休眠状态(挂起),同时,在 Linux 输入子系统(Input Subsystem)的默认实现中,‌input 驱动程序已经内置了等待队列机制‌,并完成了进程休眠与唤醒的自动化管理,input驱动开发者无需手动实现等待队列

十七、PWM (Pulse Width Modulation)

17.1 介绍

周期(T)‌:完整高低电平循环所需时间,频率为周期的倒数。
频率(Freq):1秒内有多少个周期。
占空比(Duty Cycle)‌:高电平时间占周期的比例,以百分比表示,一个周期内高电平时间越长占空比就越大,反之占空比就越小。

RK3568 有 4 个 PWM 模块,每个 PWM 模块有 4 个通道,因此一共有 16 路 PWM:PWM0~PWM15。
在这里插入图片描述

17.2 PWM 设备节点

RK3568 的 PWM 设备树绑定信息文档为: Documentation/devicetree/bindings/pwm/pwm-rockchip.txt
其实里面没有rk3568的详细介绍,compatible也是rockchip,rk3288-pwm,虽然写了rockchip,rk3568-pwm,但是找不到对应驱动,只能使用rockchip,rk3288-pwm进行查找。

17.3 PWM子系统

Linux 内核提供了个 PWM 子系统框架

PWM子系统的核心是 pwm_chip 结构体,定义在文件 include/linux/pwm.h 中,定义如下:

struct pwm_chip {
	struct device *dev;
	struct list_head list;
	const struct pwm_ops *ops;
	int base;
	unsigned int npwm;
	struct pwm_device *pwms;
	struct pwm_device * (*of_xlate)(struct pwm_chip *pc,const struct of_phandle_args *args);
	unsigned int of_pwm_n_cells;
};

pwm_ops 结构体就是 PWM 外设的各种操作函数集合,需要开发者实现,pwm_ops 结构体定义如下:

struct pwm_ops {
	int (*request)(struct pwm_chip *chip, struct pwm_device *pwm);// requesting a PWM
	void (*free)(struct pwm_chip *chip, struct pwm_device *pwm);//freeing a PWM
	int (*config)(struct pwm_chip *chip, struct pwm_device *pwm,//configure duty cycles and period length for this PWM
		      int duty_ns, int period_ns);
	int (*config_extend)(struct pwm_chip *chip, struct pwm_device *pwm,
		      u64 duty_ns, u64 period_ns);
	int (*set_polarity)(struct pwm_chip *chip, struct pwm_device *pwm,
			    enum pwm_polarity polarity);
	int (*capture)(struct pwm_chip *chip, struct pwm_device *pwm,
		       struct pwm_capture *result, unsigned long timeout);
	int (*enable)(struct pwm_chip *chip, struct pwm_device *pwm);
	void (*disable)(struct pwm_chip *chip, struct pwm_device *pwm);
	int (*get_output_type_supported)(struct pwm_chip *chip,
			struct pwm_device *pwm);
	int (*set_output_type)(struct pwm_chip *chip, struct pwm_device *pwm,
			enum pwm_output_type output_type);
	int (*set_output_pattern)(struct pwm_chip *chip,
			struct pwm_device *pwm,
			struct pwm_output_pattern *output_pattern);
	int (*apply)(struct pwm_chip *chip, struct pwm_device *pwm,//应用一个新的PWM配置。状态参数应根据实际硬件配置进行调整
		     struct pwm_state *state);
	void (*get_state)(struct pwm_chip *chip, struct pwm_device *pwm,
			  struct pwm_state *state);
#ifdef CONFIG_DEBUG_FS
	void (*dbg_show)(struct pwm_chip *chip, struct seq_file *s);
#endif
	struct module *owner;
};

pwm_ops 中的这些函数不一定全部实现,linux源码中只实现了apply和get_state。这里跟正点原子的教程不同。

初始化 pwm_chip 结构体,然后向内核注册初始化完成以后的pwm_chip, pwmchip_add 函数,函数原型如下:

int pwmchip_add(struct pwm_chip *chip)

卸载 PWM 驱动的时候需要将前面注册的 pwm_chip 从内核移除掉,pwmchip_remove 函数原型如下:

int pwmchip_remove(struct pwm_chip *chip)

rk3568的pwm驱动文件是kernel/drivers/pwm/pwm-rockchip.c,PWM 驱动,RK已经写好了。

17.4 pwm驱动编写

pwm驱动RK已经写好了,接下来是设备树,其实也写好了。
实验:GPIO3_C5 这个引脚, 这个引脚可以用作 PWM15_IR_M0,也就是 PWM3 的通道 3 的 PWM 输出引脚。
rk3568-pinctrl.dtsi 文件:

&pinctrl{
		pwm15 {
			/omit-if-no-ref/
			pwm15m0_pins: pwm15m0-pins {
				rockchip,pins =
					/* pwm15_irm0 */
					<3 RK_PC5 1 &pcfg_pull_down>;
			};
			/omit-if-no-ref/
			pwm15m1_pins: pwm15m1-pins {
				rockchip,pins =
					/* pwm15_irm1 */
					<4 RK_PC3 1 &pcfg_pull_none>;
			};
	};
}

rk3568.dtsi 文件:

/{
		pwm15: pwm@fe700030 {
		compatible = "rockchip,rk3568-pwm", "rockchip,rk3328-pwm";
		reg = <0x0 0xfe700030 0x0 0x10>;
		interrupts = <GIC_SPI 85 IRQ_TYPE_LEVEL_HIGH>,
			     <GIC_SPI 89 IRQ_TYPE_LEVEL_HIGH>;
		#pwm-cells = <3>;
		pinctrl-names = "active";
		pinctrl-0 = <&pwm15m0_pins>;
		clocks = <&cru CLK_PWM3>, <&cru PCLK_PWM3>;
		clock-names = "pwm", "pclk";
		status = "disabled";
	};
}

rk3568.dtsi 文件中的pwm15是status = "disabled";,所以需要在 rk3568-atk-evb1-ddr4-v10.dtsi 文件中向打开 pwm15 节点:

&pwm15{
	status = "okay";
};

使能 PWM 驱动瑞芯微官方的 Linux 内核已经默认使能了 PWM 驱动,所以不需要我们修改。

17.5 pwm驱动测试

直接在用户层来配置 PWM,当然,也可以用应用程序。
1、确定 PWM15 对应的 pwmchipX 文件
进入目录/sys/class/pwm 中:ls -ahl

root@ATK-DLRK3568:/sys/class/pwm# ls -ahl
total 0
drwxr-xr-x  2 root root 0 May 11 16:46 .
drwxr-xr-x 72 root root 0 May 11 16:46 ..
lrwxrwxrwx  1 root root 0 May 11 16:46 pwmchip0 -> ../../devices/platform/fdd70020.pwm/pwm/pwmchip0
lrwxrwxrwx  1 root root 0 May 11 16:46 pwmchip1 -> ../../devices/platform/fe6e0000.pwm/pwm/pwmchip1
lrwxrwxrwx  1 root root 0 May 11 16:46 pwmchip2 -> ../../devices/platform/fe6e0010.pwm/pwm/pwmchip2
lrwxrwxrwx  1 root root 0 May 11 16:46 pwmchip3 -> ../../devices/platform/fe700030.pwm/pwm/pwmchip3

PWM15这个定时器的寄存器起始地址就是0XFE700030。因此, pwmchip3 就是 PWM15 对应的文件。

2、调出 pwmchip15 的 pwm0 子目录
输入如下命令打开 pwmchip15 的 pwm0 子目录

echo 0 > /sys/class/pwm/pwmchip3/export

执行完成会在 pwmchip3 目录下生成一个名为“pwm0”的子目录

3、设置 PWM 的频率
注意,这里设置的是周期值,单位为 ns,比如 20KHz 频率的周期就是 50000ns,输入如下命令:

echo 50000 > /sys/class/pwm/pwmchip3/pwm0/period

4、设置 PWM 的占空比
这里不能直接设置占空比,而是设置的一个周期的 ON 时间,也就是高电平时间,比如20KHz 频率下 20%占空比的 ON 时间就是 10000,输入如下命令:

echo 10000 > /sys/class/pwm/pwmchip3/pwm0/duty_cycle

5、设置 PWM 极性
设置一下 PWM 波形的极性,输入如下命令:

echo normal > /sys/class/pwm/pwmchip3/pwm0/polarity

极性设置为 normal,也就是 duty_cycle 为高电平时间。如果要将极性反过来,可以设置为inversed。
5、使能 PWM
一定要先设置频率和波特率,最后在开启 PWM,否则会提示参数错误!输入如下命令使能PWM:

echo 1 > /sys/class/pwm/pwmchip3/pwm0/enable

十八、MIPI DSI屏幕驱动

18.1 MIPI联盟

MIPI( Mobile Industry Processor Interface ) 即移动产业处理器接口,是移动处理器生产厂家成立的一个联盟,MIPI 联盟主要是为移动处理器定制标准接口和规范。比如:

· MIPI DSI(显示屏接口)
· MIPI CSI(摄像头接口)
· MIPI I3C
· MIPI RFFE(射频前端控制接口)
· MIPI SPMI(系统电源管理接口)

MIPI 主要有四个方向的协议:

①、 Multimedia,多媒体。
②、 Control&Data,控制和数据。
③、 Chip-to-Chip Inter Process Communications,
④、 Debug&Trace,调试和追踪

其中,Multimedia多媒体部分有:
摄像头,应用层有 CCS,协议层主要有 CSI-2、 CSI-3,物理层有 A-PHY、 C-PHY、 D-PHY和 M-PHY。
屏幕,应用层有 DCS,协议层主要有 DSI,物理层有 A-PHY、 C-PHY、 D-PHY。

其中,不管是摄像头还是屏幕,目前用的最多的接口就是D-PHY:
D-PHY采用1对差分时钟通道(Clock Lane)和1-4对差分数据通道(Data Lane)组成,只有 Data0 这一组数据线可以是单向也可以是双向的,其他组的数据线都是单向的。支持最大约10线布局(含4对数据通道和1对时钟通道)。每个数据通道包含高速(HS)收发器和低功耗(LP low-power)收发器,时钟通道仅支持单向传输,由主控产生,发送给设备。

18.2 MIPI DSI (Display Serial Interface)

学习如何驱动 MIPI 接口屏幕,就是学习 MIPI DSI,DSI 全称是Display Serial Interface,是主控和显示模组之间的串行连接接口,图 24.2.1.1 展示了主控和屏幕之间的连接方式,MIPI DSI 以串行的方式发送指令和数据给屏幕,也可以读取屏幕中的信息。
在这里插入图片描述

①MIPI DSI 分层

和网络协议栈一样, MIPI DSI 也是分层的。
在这里插入图片描述
MIPI DSI 一共有四层,从上往下依次为:

·应用层
·协议层
·通道管理层/链路层
·物理层

②MIPI DSI 物理层

MIPI DSI 的物理层也叫 PHY 层,前面说了 MIPI 有 C-PHY、 D-PHY 等,只是在 MIPI DSI领域 D-PHY 用的最多,所以这里可以简单的认为 MIPI DSI 物理层说的就是 D-PHY。

数据链路分为 High-Speed 模式和 Low-Power 模式,也就是常说的 HS 和 LP。 HS 模式用来传输高速数据,比如屏幕像素数据。 LP 模式用来传输低速的异步信号,一般是配置指令,屏幕的配置参数就是用 LP 模式传输的。

Lane 分为 HS 和 LP 两种模式:
HS 采用低压差分信号,传输速度高,但是功耗大,信号电压幅度 100mv~300mV,中心电平 200mV。
LP 模式下采用采用单端驱动,功耗小,速率低(<10Mbps),信号电压幅度 0~1.2V。在 LP 模式下只使用 Lane0(也就是数据通道 0),不需要时钟信号,通信过程的时钟信号通过 Lane0 两个差分线异或得到,而且是双向通信。

HS的差分信号:是一种通过‌互补信号对‌传输数据的通信技术。其核心在于利用‌两条信号线‌的电压差来表示逻辑状态,而非依赖单根信号线的绝对电压值,差分信号由一对相位相反的信号组成(‌P端‌和‌N端‌)。外界的电磁干扰(EMI)通常同时作用于两条信号线,接收端通过比较两者的‌差值‌(而非绝对值)来判定逻辑状态,有效抵消共模噪声的影响。
逻辑“1”‌:P端电压 > N端电压(正向电压差,如+200mV);
逻辑“0”‌:P端电压 < N端电压(负向电压差,如-200mV)。

LP的时钟信号:差分对的 P 和 N 信号始终为互补状态。当数据发生变化时,P/N 会同步翻转(例如 P 从高→低,N 从低→高)。
P=1→0, N=0→1(翻转过程)
XOR=1→0→1(产生脉冲)

③D-PHY信号电平

HS 和 LP 模式下的信号电平如图 24.3.2.1 所示:
在这里插入图片描述
其中,
蓝色实线是 LP 模式下的信号波形示例,电压为 0~1.2V。
绿色虚线是 LP 模式下信号的高低电平门限。
红色实线是 HS 模式下的信号波形示例,中心电平 200mV。

④通道状态

HS 模式下是单向差分信号,主控发送(HS_TX),外设接收(HS_RX)。而 LP 是双向单端信号,接收和发送端都有 LP_TX 和 LP_RX,注意只有 Lane0 能做 LP。
由于 HS 采用差分信号,所以只有两种状态:
HS-0: 高速模式下 Dp 信号低电平, Dn 信号高电平的时候。逻辑“1”‌
HS-1:高速模式下 Dp 信号高电平, Dn 信号低电平的时候。逻辑“0”‌

LP 模式下有两根独立的信号线驱动,所以有 4 个状态:
LP-00,LP-01、 LP-10 和 LP-11。
LP-xx,第一个x是Dp ,0为低电平,1为高电平,第二个x是Dn。

这 6 种状态对应的功能如图 24.3.3.1 所示,通过图 24.3.3.1 种这 6 个状态的转换, D-PHY 就能工作在不同的工作模式。
在这里插入图片描述

⑤工作模式

D-PHY 协议规定,通过 Lane 的不同状态转换有三种工作模式:控制模式、高速模式和Escape 模式。控制模式和 Escape 模式都属于 LP,高速模式属于 HS。如上图所示的burst mode 、control mode和escape mode。

1、高速模式,burst mode
高速模式用于传输实际的屏幕像素数据,采用突发(Bursts)传输方式。为了帮助接收端同步,需要在数据头尾添加一些序列,接收端在接收到数据以后要把头尾去掉。在高速模式下传输数据的时候时钟 Lane也工作在 HS 模式,提供 DDR 时钟,也就是双边沿时钟,在时钟频率不变的情况下,传输速率提高一倍,这样可以有效利用带宽。

一个完整的高速模式数据传输时序如图:在这里插入图片描述
左侧蓝色部分是进入 HS 模式,高速数据传输起始于STOP 状态(LP-11),要从 LP-11→LP01→LP-00,然后数据线进入到 HS 模式,也就是中间红色部分,传输实际的数据。终于 STOP 状态(LP-11),也就是右边的蓝色部分。

基础的高速传输结构示意图:
在这里插入图片描述
数据线进入到 HS 模式后,发出一个 SOT 序列(Start-of-Transmission), SOT 后面跟着的就是实际的负载数据。当负载数据传输结束以后会紧跟一个 EOT 序列(End-of-Transmission)序列,数据线直接进入到 STOP 模式。

2、Escape模式
Escape 是运行在 LP 状态下的一个特殊模式,给屏幕发送配置信息就需要运行在 Escape 模式下。数据线进入 Escape 模式的方式为: LP-11→LP-10→LP-00→LP-01→LP-00。退出 Escape 模式的方式为: LP-00→LP-10→LP-11,也就是最后会进入到 STOP 模式,进入和退出 Escape 模式的时序如图 24.3.4.3 所示:
在这里插入图片描述
对于数据 Lanes,进入 Escape 模式以后,应该紧接着发送一个 8bit 的命令来表示接下来要做的操作
有三个可选的命令:
LPDT(Low-Power Data Transmission) :11100001,LPDT 命令序列后面紧跟着就是要发送的数据
ULPS(Ultra-Low Power State):00011110,让 Lane 进入超低功耗模式。
Remote-Trigger:01100010,也叫reset-trigger,就是远程复位。

18.3 MIPI链路层模式

在 MIPI DSI 的链路层有两种模式: video(视频)和 command(命令)模式

command 模式一般是针对那些含有 buffer 的 MCU 屏幕,当画面有变化的时候, DSI Host 端将数据发给屏幕,主控只有在画
面需要更改的时候发送像素数据,画面不变化的时候屏幕驱动芯片从自己内部 buffer 里面提取。

video 模式没有 framebuffer,需要主控一直发送数据给屏幕,和我们使用过的 RGB 接口屏幕类似。

后续

正点原子后面还有很多内容,但是感觉太深入了,不是这个专业的就不认真学了。

十九、HDMI屏幕驱动

19.1 简介

HDMI 全称为 High Definition Multimedia Interface,也就是高清多媒体接口,是一个纯数字的音视频传输接口,通过一根线同时发送音视频数据。
HDMI 相关术语:

HDCP: High-bandwidth Digital Content Protection,版权保护相关,通过 HDMI 的 DDC 通道获取相关信息。
EDID: Extended Display Identification Data,扩展显示标识数据, 包括显示器参数信息、供应商、图像大小、颜色、厂商预设值等信息。
DDC: Display Data Channel,显示数据通道,本质是 IIC,因为大家会在 HDMI 接口上看到 SCL 和 SDA 引脚, DDC 用来获取 EDID、 HDCP 信息。
CEC: Consumer Electronics Control,用户电气控制,可以通过 CEC 引脚控制一些从设备,实现遥控功能。
TMDS: Transition Minimized Differential Signaling,最小化传输差分信号,用来传输 HDMI信号。
HEAC: HDMI Ethernet and Audio Return Channel,以太网和音频返回,需要额外的 PHY 支持。
Source: HDMI 信号输出设备。
Sink: HDMI 信号输入设备。

①HDMI 接口

在这里插入图片描述

图 25.1.1.1 中左侧是 Source,用于产生 Video、 Audio 等信号。左侧是 Sink,也就是接受Source 端发送过来的 Video、 Audio 等信息。 Source 和 Sink 之间通过 TMDS 来传输信号,一共有 3 个 TMDS 数据通道, 1 个 TMDS 时钟通道。另外还有 DDC、 CEC、 HPD 等信号来传输其他的控制信息。
简单总结一下这几个通道的功能:

TMDS: 传输音视频数据。
CEC: 实现遥控器功能。
DDC: 实现屏幕分辨率自适应,通过 DDC 获取不同屏幕的参数信息。
HPD: 实现热插拔。

HDMI最常用的就是 TypeA 口,如下:
在这里插入图片描述
在这里插入图片描述
可以和HDMI结构图对应,其中IIC(SCL、SDA)就是CEC。

②TMDS传输原理

HDMI 主要是通过 TMDS 编码来传输音视频信号,差分信号的原理在MIPI中已经讲到。TMDS 由 3 个数据通道和 1 个时钟通道组成。TMDS 的时钟通道以所传输的视频信号像素时钟的固定比例运行。每个 TMDS 时钟周期,每个 TMDS 数据通道都会传输 10bit 数据。
这 8bit 数据有三种情况,含义如下:

D[7:0]: 8bit 的实际图像数据,为并行数据。
D[1:0]: 通道 0的这2 位是HSYNC和VSYNC 信号,通道 1和通道 2的这两位是 CTL0~CTL3这 4 个控制信号。
D[3:0]: 额外的一些辅助数据,比如音频数据等

8bit 数据的三种情况对应HDMI 数据传输分为 3 个阶段:

①、 Video Data Period:视频数据传输阶段,也就是此阶段传输实际的图像数据。对应 D[7:0],会将 8bit 数据编码并串行化为 10bit 发送出去。
②、 Data Island Period:音频和额外数据传输阶段。对应D[3:0],会将 4bit数据编码并串行化为 10bit 发送出去。
③、 Control Period:控制信号传输阶段,当不传输音视频信号和控制信号的时候,都处于这个阶段。对应D[1:0],会将 2bit 数据编码并串行化为 10bit 发送出去。

数据通道 0: 传输图像的蓝色分量、 HS 和 VS 信号。
数据通道 1: 传输图像的绿色分量、 CTL0 和 CTL1 信号。
数据通道 2: 传输图像的红色分量、 CTL2 和 CTL3 信号。
时钟通道: 图像的像素时钟。

③HDMI时钟与带宽

时钟频率‌:表示系统周期性信号的工作频率。
带宽‌:在数字系统中,带宽通常指系统单位时间内能传输的最大数据量。

1、像素时钟
分别以 RK3568 所支持的 1920x1080p@120Hz 和 4096x2304@60fps 这两个分辨率来算一下对应的像素时钟:
1920x1080p@120Hz: 1920×1080×120=248832000≈248.832MHz。
4096x2304@60Hz: 4096×2304×60=566231040≈566.2MHz。
上面算出来的就是 1秒 中要传输的像素数,也就是像素时钟。

2、理论带宽
假设像素格式为 RGB888,也就是一个像素 24bit,那么对应的理论带宽就是:
1920x1080p@120Hz: 248832000× 24=5971968000≈6.0Gbps。
4096x2304@60Hz: 566200000×24=13588800000≈13.6Gbps。
可以看出仅传输视频最少就需要 13.6Gbps 的带宽,而 HDMI 不仅仅要传输视频,还要传输音频等其他信号,所以 HDMI 的实际带宽要大于 13.6Gpbs。

3、 TMDS 时钟
HDMI2.0协议将TMDS时钟加到了600MHz。RK3568 的 HDMI 接口为 2.0 版本,我们就以 600MHz 的 TMDS 时钟计算一下 HDMI 接口实际提供的带宽。一个 TMDS 时钟单个通道传输 10bit 的数据,所以单个通道的带宽就是:

600000000× 10 = 6Gpbs

一共有 3 个 TMDS 数据通道,所以总带宽就是:

6Gpbs× 3=18Gbps

TMDS 在传输的时候会将原始的 8bit 有效数据编码为 10bit,所以真实的有效带宽要再乘一个0.8,因此实际有效带宽为:

18Gpbs× 0.8=14.4 Gbps

可以看出,实际有效带宽为 14.4Gbps,大于我们前面算出来的 13.6Gbps 要求。

19.2 EDID Extended Display Identification Data

①介绍

HDMI 屏幕初始化的时候主控会读取屏幕的 EDID 信息, EDID 信息存放在显示器里面,主控通过 DDC 接口,也就是 IIC 接口来读取显示里面的 EDID 信息。 EDID 信息包含了显示器特性、特点、分辨率、厂商、序列号、显示器的时序信息等。

EDID 的功能:主动告诉主控显示器的参数信息,主控就可以使用最佳的参数驱动屏幕。

EDID 1.0~1.3 都是 128 个字节。后面提出了 EDID 1.4 和 EEDID(增强型 EDID),将长度增加到 256 字节,不管哪个版本的 EDID,其前 128 字节内容是一样的。

②读取显示器EDID

启动 ATK-DLRK3568 开发板,接上 HDMI 显示器,系统启动以后输入如下命令读取 EDID:

cat /sys/class/drm/card0-HDMI-A-1/edid > /data/edid.bin

命令执行完以后会得到一个名为“edid.bin”的文件,注意这文件不是 txt 格式的,不能直接打开看里面的内容。我们将 edid.bin 发送到 windows 下,然后用 winhex 软件打开,这样才能看到原始的数据。

19.3 RK3568 HDMI

RK3568 自带一个 HDMI TX 外设,可以用来连接 HDMI 显示器。 RK3568 的 HDMI 外设包括一个 HDMI 传输控制器和一个 PHY。HDMI TX PHY 是 HDMI 一个物理传输层,用于完成相关编码,并将相关的高速数据发送出去。PHY 包含一个 PLL,用于从参考像素时钟中合成
出 13.5-600MHz 的高速串行时钟,供 HDMI 使用。

PLL(Phase Locked Loop,锁相环)是一种用于同步两个信号的相位和频率的电子电路‌。

19.4 RK3568 VOP

瑞芯微的中高端芯片都有一个叫做 VOP 的外设, VOP 是一个链接 frame buffer 和显示设备的接口.RK3568 的 VOP 有 3 个端口, vp0~vp2, vp0-vp2 支持不同的显示接口:
在这里插入图片描述

后续

后续看正点原子的驱动手册吧。

二十、I2C Inter-Integrated Circuit

20.1 I2C协议

I2C(Inter-Integrated Circuit)是一种广泛应用于嵌入式系统和低速外设通信的同步串行总线协议。

架构特性‌:
采用主从模式,支持多主多从结构,通过唯一地址区分设备。
仅需两根信号线:‌SDA‌(串行数据线)和‌SCL‌(串行时钟线),两线必须要接一个上拉电阻,一般是 4.7K。
半双工通信,数据在同一线上分时双向传输。
在这里插入图片描述

①起始位

顾名思义,也就是 I2C 通信起始标志,主机通知从机开始I2C通信,在 SCL 为高电平的时候, SDA 出现下降沿就表示为起始位。
在这里插入图片描述

②停止位

停止位就是停止 I2C 通信的标志位,和起始位的功能相反。在 SCL 位高电平的时候, SDA出现上升沿就表示为停止位.
在这里插入图片描述

③数据传输

I2C 总线在数据传输的时候要保证在 SCL 高电平期间, SDA 上的数据稳定,因此 SDA 上的数据变化只能在 SCL 低电平期间发生.
在这里插入图片描述

④应答信号

当 I2C 主机发送完 8 位数据以后会将 SDA 设置为输入状态,等待 I2C 从机应答(ACK),也就是等待 I2C 从机告诉主机它接收到数据了。应答信号是由从机发出的,主机需要提供应答信号所需的时钟,主机发送完 8 位数据以后紧跟着的一个时钟信号就是给应答信号使用的。从机通过将 SDA 拉低来表示发出应答信号,表示通信成功,否则表示通信失败(非应答NACK)。
在这里插入图片描述

NACK的典型应用场景‌

‌地址无效‌:从设备地址与主设备寻址字节不匹配时发送NACK‌。
‌传输结束‌:主设备读取完最后一个字节后主动发送NACK,通知从设备释放SDA线‌。
‌从设备忙‌:从设备暂时无法处理数据时通过NACK延迟响应‌。

⑤I2C写时序

在这里插入图片描述
在这里插入图片描述

分析如下:
1)、主机发送起始信号。
2)、发送从机地址及写方向位,这是一个 8 位的数据,其中高 7 位是设备地址,最后 1 位是读写位。
3)、 I2C 器件地址后面跟着一个写操作置0,为 0 表示写操作,为 1 表示读操作。
4)、从机发送的 ACK 应答信号。
5)、重新发送开始信号。
6)、发送要写写入数据的寄存器地址。
7)、从机发送的 ACK 应答信号。
8)、发送要写入寄存器的数据。
9)、从机发送的 ACK 应答信号。
10)、停止信号。

⑥I2C 读时序

在这里插入图片描述
在这里插入图片描述

读时序分为 4 大步:
第一步是发送设备地址
第二步是发送要读取的寄存器地址
第三步重新发送设备地址,
第四步就是 I2C 从器件输出要读取的寄存器值。
分析如下:
1)、主机发送起始信号。
2)、主机发送要读取的 I2C 从设备地址。
3)、读写控制位,因为是向 I2C 从设备发送数据,因此是写信号置0。
4)、从机发送的 ACK 应答信号。
5)、主机重新发送 START 信号。
6)、主机发送要读取的寄存器地址。
7)、从机发送的 ACK 应答信号。
8)、主机重新发送 START 信号。
9)、主机重新发送要读取的 I2C 从设备地址。
10)、读写控制位,这里是读信号置1,表示接下来是从 I2C 从设备里面读取数据。
11)、从机发送的 ACK 应答信号。
12)、从机发送寄存器的数据。
13)、主机发出 NO ACK 信号,表示读取完成,不需要从机再发送 ACK 信号了。
14)、主机发出 STOP 信号,停止 I2C 通信。

⑦多主机和仲裁

在I2C中,支持多个主机及从机,多个主机同时使用总线时,通过仲裁方式避免数据冲突,决定总线占用权。

在I2C通信中,主机‌既是发送方也是接收方,在发送每一位数据时,主机都会将自己发送的位与 SDA 线上的实际电平进行比较。一旦检测到自己发送的电平与 SDA 线上的实际低电平不一致,该主机会判定自己在仲裁中失败,然后放弃对总线的控制,进入接收状态。

输出模式下读取输入状态的硬件条件,大多数微控制器允许在GPIO配置为‌输出模式‌时,直接读取输入寄存器(如GPIOx_IDR)获取引脚实际电平。

20.2 RK3568 硬件 I2C

RK3568 支持 6 个独立 I2C: I2C0-I2C5。
在硬件I2C接口中,I2C的信号线(SDA和SCL)通常需要配置为开漏输出模式(Open Drain)以支持多设备的总线系统。
开漏输出模式中的输出引脚有两种情况:低电平(接地)和高阻态。
高阻抗状态下,引脚既不主动输出高电平也不拉低电平,等效于与电路断开,呈现极高阻抗。其目的是避免电平冲突,如果是高电平,那么有可能出现高电平接地的状况。为了输出高电平,所以需要外接上拉电阻,在高阻态下,上拉电阻分的压很少,故SDA上是高电平状态。

20.3 AP3216C 三合一环境传感器

ATK-DLRK3568 开发板上通过 I2C5 连接了一个三合一环境传感器: AP3216C, 其支持环境光强度(ALS)、接近距离(PS)和红外线强度(IR)这三个环境参数检测。

AP3216 的设备地址为 0X1E,同几乎所有的 I2C 从器件一样, AP3216C 内部也有一些寄存器,通过这些寄存器我们可以配置 AP3216C 的工作模式(配置寄存器),并且读取相应的数据(数据寄存器)。

20.4 Linux I2C 总线框架

GPIO、IIC、SPI、USB、PCIe虽然硬件结构完全不同,但是内核驱动架构大致都是相同的,可以分为靠近应用层的总线设备驱动模型,在这里编写驱动程序,调用核心层提供的接口,向下面的控制器驱动(主机、总线驱动)发送数据,控制器驱动就是靠近硬件的部分,由芯片原厂开发,一般采用平台总线设备驱动模 型,与硬件直接打交道。

在这里插入图片描述
在这里插入图片描述

参考 :正点原子,野火

一个 i2c(例如 i2c1) 上可以挂在多个 i2c 设备,例如 MPU6050 等等,这些设备共用一个 i2c,这个 i2c 的驱动我们称为 i2c 总线驱动。而对应具体的设备, 例如 mpu6050 的驱动就是 i2c 设备驱动。这样我们要使用 mpu6050 就需要拥有“两个驱董”一个是 i2c 总线驱动和 mpu6050 设备驱动。
• i2c 总线驱动(i2c控制器驱动)由芯片厂商提供 (驱动复杂,官方提供了经过测试的驱动,我们直接用),
• mpu6050 设备驱动(i2c_driver)可以从 mpu6050 芯片厂家那里获得 (不确定有),也可以我们手动编写。

根据 Linux 的驱动分离与分层的思想, Linux 内核也将I2C 驱动分为两部分:I2C 总线驱动和I2C 设备驱动。

I2C 总线/主机驱动也就是 SoC 的 I2C 控制器对应的驱动程序, I2C 设备驱动其实就是挂在I2C 总线下的具体设备对应的驱动程序,对于总线驱动来说,一旦编写完成就不需要再做修改,其他的 I2C 设备直接调用主机驱动提供的 API 函数完成读写操作即可。

Linux下的I2C 总线框架,也叫作 I2C 子系统。结构如下:

在这里插入图片描述
如上图所示, i2c 驱动框架包括 i2c 总线驱动、具体某个设备的驱动。

i2c 总线包括 i2c 设备 (i2c_client) 和 i2c 驱动 (i2c_driver), 当我们向 linux 中注册设备或驱动的时候,按照 i2c 总线匹配规则进行配对,配对成功,则可以通过 i2c_driver 中.probe 函数创建具体的设备驱动。在现代 linux 中, i2c 设备不再需要手动创建,而是使用设备树机制引入,设备树节点是与 paltform 总线相配合使用的。所以需先对 i2c 总线包装一层 paltform 总线,当设备树节点转换为平台总线设备时,我们在进一步将其转换为 i2c 设备,注册到 i2c 总线中。

设备驱动创建成功,我们还需要实现设备的文件操作接口 (file_operations),file_operations 中会使用到内核中 i2c 核心函数 (i2c 系统已经实现的函数,专门开放给驱动工程师使用)。使用这些函数会涉及到 i2c 适配器,也就是 i2c 控制器。由于 ic2 控制器有不同的配置,所有 linux 将每一个 i2c控制器抽象成 i2c 适配器对象。这个对象中存在一个很重要的成员变量——Algorithm, Algorithm中存在一系列函数指针,这些函数指针指向真正硬件操作代码。
在这里插入图片描述
I2C 子系统分为三大组成部分:
1、 I2C 核心(I2C-core)
I2C 核心提供了 I2C 总线驱动(适配器)和设备驱动的注册、注销方法, I2C 通信方法(algorithm)与具体硬件无关的代码,以及探测设备地址的上层代码等;
2、 I2C 总线驱动(I2C adapter)
I2C 总线驱动是 I2C 适配器的软件实现,提供 I2C 适配器与从设备间完成数据通信的能力。I2C 总线驱动由 i2c_adapter 和 i2c_algorithm 来描述。 I2C 适配器是 SoC 中内置 i2c 控制器的软件抽象,可以理解为他所代表的是一个 I2C 主机;
3、 I2C 设备驱动(I2C client driver)
包括两部分:设备的注册和驱动的注册。I2C 子系统帮助内核统一管理 I2C 设备,让驱动开发工程师在内核中可以更加容易地添加自己的 I2C 设备驱动程序。

参考:linux驱动之i2c框架

i2c_bus_type:i2c_bus_type 是 Linux内核设备框架 中的 总线。该 变量 是一个全局变量,用于 匹配和删除I2C设备和I2C驱动 ,并负责提供 匹配规则。i2c_bus_type 的 i2c_device_match 会查看 驱动 和 设备 是否 匹配,如果匹配则通过 i2c_device_probe 调用 驱动的probe函数。
其代码如下:

struct bus_type i2c_bus_type = {
    .name       = "i2c",
    .match      = i2c_device_match,
    .probe      = i2c_device_probe,
    .remove     = i2c_device_remove,
    .shutdown   = i2c_device_shutdown,
};

i2c_adapter:i2c_adapter 称为 I2C适配器。所谓 I2C适配器 就是 Soc 上的 I2C控制器,而 i2c_adapter 就是 硬件I2C控制器 的 驱动实现,即实现了 CPU通过I2C控制器与外界进行数据交换。

struct i2c_adapter {
    /* I2C适配器的通信方法 */
    const struct i2c_algorithm *algo;
    /* I2C适配器的device结构体,表明其也是一个设备 */
    struct device dev;
};

i2c_algorithm :i2c_algorithm 是 I2C适配器 的 通信方法 实现,一般通过该结构体实现 数据的发送和接受。

struct i2c_algorithm {
    /* 主机发送函数 */
    int (*master_xfer)(struct i2c_adapter *adap, struct i2c_msg *msgs,
               int num);
    /* 从机发送函数 */
    int (*smbus_xfer) (struct i2c_adapter *adap, u16 addr,
               unsigned short flags, char read_write,
               u8 command, int size, union i2c_smbus_data *data);
    /* 返回通信方法适用的适配器特性 */
    u32 (*functionality) (struct i2c_adapter *);
};

i2c_client:i2c_client 就是 I2C设备,该结构体描述了 I2C设备硬件信息

struct i2c_client {
    /* 设备I2C地址 */
    unsigned short addr;        /* chip address - NOTE: 7bit    */
    /* 设备名称 */
    char name[I2C_NAME_SIZE];
    /* I2C适配器 */
    struct i2c_adapter *adapter;    /* the adapter we sit on    */
    /* device结构体,表明其为一个设备 */
    struct device dev;      /* the device structure     */
};

i2c_driver:i2c_driver 就是 I2C驱动程序,就是 I2C硬件设备端的实现。一般挂接在 I2C适配器 上,并通过 I2C适配器 与 CPU 交换数据。

struct i2c_driver {
    /* 驱动的probe函数 */
    int (*probe)(struct i2c_client *, const struct i2c_device_id *);
    /* 驱动的remove函数 */
    int (*remove)(struct i2c_client *);
    /* I2C驱动的device_driver结构体,表明其也是一个设备驱动 */
    struct device_driver driver;
    /* 驱动ID table,用于匹配 */
    const struct i2c_device_id *id_table;
    /* 该驱动拥有的I2C地址 */
    const unsigned short *address_list;
    /* 该驱动拥有的I2C设备链表 */
    struct list_head clients;
};

关系图如下:
在这里插入图片描述

虚线表示对应关系,实现表示数据。

硬件信息 通过 设备树 解析到了 i2c_client
i2c_driver 的 匹配信息 在 驱动程序 中填充。
i2c_client 和 i2c_driver 通过 i2c_bus_type 进行匹配,如果成功 i2c_bus_type 调用 i2c_driver 的 probe函数
i2c_driver 通过 i2c_adapter 向外发送数据

①I2C总线驱动 -硬件I2C控制器的驱动实现

一般 SoC 的 I2C 总线驱动都是由半导体厂商编写的。I2C 总线驱动是 I2C 适配器的软件实现,提供 I2C 适配器与从设备间完成数据通信的能力。可以理解为,I2C 总线驱动是基于I2C协议提供了一个接口,具体的协议内容,比如时序、数据传输过程中的处理之类的已经打包好了,只需要在其基础上选项需要发送的数据内容即可,如何发送,如何实现的 I2C 总线驱动已经搞定了,封装好了。

platform 是虚拟出来的一条总线,目的是为了实现总线、设备、驱动框架。对于 I2C 而言,I2C总线是真实存在的。 I2C 总线驱动重点是 I2C 适配器(也就是 SoC 的 I2C 接口控制器)驱动,这里要用到两个重要的数据结构: i2c_adapter 和 i2c_algorithm。

I2C 子系统将 SoC 的 I2C 适配器(控制器)抽象成一个 i2c_adapter 结构体, i2c_adapter 结构体中包含一个i2c_algorithm 类型的指针变量 algo,对于一个 I2C 适配器,肯定要对外提供读写 API 函数,设备驱动程序可以使用这些 API 函数来完成读写操作。 i2c_algorithm 就是
I2C 适配器与 IIC 设备进行通信的方法。

i2c_algorithm 结构体中包含许多需要实现的函数,其中,master_xfer 就是 I2C 适配器的传输函数,可以通过此函数来完成与 IIC 设备之
间的通信。 smbus_xfer 就是 SMBUS 总线的传输函数。 等等。

②I2C设备驱动

根据总线、设备和驱动模型,I2C 设备驱动重点关注两个数据结构: i2c_client 和 i2c_driver,i2c_client 用于描述 I2C 总线下的设备,
i2c_driver 则用于描述 I2C 总线下的设备驱动,类似于 platform 总线下的 platform_device 和platform_driver。

一个 I2C 设备对应一个 i2c_client 结构体变量,系统每检测到一个 I2C 从设备就会给这个设备分配一个 i2c_client。driver的probe中可以得到一个 i2c_client。

i2c_driver 类似 platform_driver,是驱动开发者编写 I2C 设备驱动重点要处理的内容。i2c_driver 结构体包含:
probe 函数:待实现,当 I2C 设备和驱动匹配成功以后 probe 函数就会执行。
remove函数:待实现,卸载i2c驱动时会运行。
device_driver 驱动结构体:device_driver 的of_match_table 成员变量,也就是驱动的兼容(compatible)属性,用于设备树匹配。
struct i2c_device_id *id_table:id_table 是传统的、未使用设备树的设备匹配 ID 表。

对于我们 I2C 设备驱动编写人来说,重点工作就是构建 i2c_driver,
i2c_driver 注册函数为 int i2c_register_driver 或 i2c_add_driver(driver) ,此函数原型如下:

int i2c_register_driver(struct module *owner,struct i2c_driver *driver)
#define i2c_add_driver(driver) \
	i2c_register_driver(THIS_MODULE, driver)

注销 I2C 设备驱动,2c_del_driver 函数,此函数原型如下:

void i2c_del_driver(struct i2c_driver *driver)

20.5 I2C设备与驱动匹配过程

I2C 设备和驱动的匹配过程是由 I2C 子系统核心层来完成的,I2C 总线的数据结构为 i2c_bus_type, i2c_bus_type 内容如下:

struct bus_type i2c_bus_type = {
	.name = "i2c",
	.match = i2c_device_match,
	.probe = i2c_device_probe,
	.remove = i2c_device_remove,
	.shutdown = i2c_device_shutdown,
};

这与struct bus_type platform_bus_type类似,.match 就是 I2C 总线的设备和驱动匹配函数。

static int i2c_device_match(struct device *dev,
struct device_driver *drv)
{
	struct i2c_client *client = i2c_verify_client(dev);
	struct i2c_driver *driver;
	/* Attempt an OF style match */
	if (i2c_of_match_device(drv->of_match_table, client))
		return 1;
	/* Then ACPI style match */
	if (acpi_driver_match_device(dev, drv))
		return 1;
	driver = to_i2c_driver(drv);
	/* Finally an I2C match */
	if (i2c_match_id(driver->id_table, client))
		return 1;
	return 0;
}

第一种匹配方式,2c_of_match_device 函数用于完成设备树中定义的设备与驱动匹配过程。比较I2C 设备节点的 compatible 属性和 (of_match_table) of_device_id 中的 compatible 属性是否相等.

第二种匹配方式,acpi_driver_match_device 函数用于 ACPI 形式的匹配。

第三种匹配方式,i2c_match_id 函数用于传统的、无设备树的 I2C 设备和驱动匹配过程。比较 I2C设备名字和(id_table) i2c_device_id 的 name 字段是否相等.

20.6 RK3568 I2C 适配器驱动分析

I2C 适配器驱动就是 SoC 的 I2C 控制器驱动。 I2C 设备驱动是需要用户根据不同的 I2C 从设备去编写,而 I2C 适配器驱动一般都是 SoC 厂商去编写的,根据设备树I2C节点的compatible属性找到对应驱动文件:drivers/i2c/busses/i2c-rk3x.c

RK3568 的 I2C 适配器驱动是个标准的 platform 驱动,虽然 I2C 总线为别的设备提供了一种总线驱动框架,但是 I2C 适配器却是 platform驱动。

当设备和驱动匹配成功以后probe( rk3x_i2c_probe) 函数就会执行,其中,
RK 使用 rk3x_i2c 结构体来表示 RK 系列 SOC 的 I2C 控制器,
i2c_parse_fw_timings 函数设置 I2C 频率,
rk3x_i2c 结构体中有 i2c_adapter, i2c_adapter中有i2c_algorithm,设置i2c_algorithm为rk3x_i2c_algorithm。
有I2C中断的申请和注册。
有i2c_adapter的申请。

rk3x_i2c_algorithm 包含 I2C1 适配器与 I2C 设备的通信函数 master_xfer

static const struct i2c_algorithm rk3x_i2c_algorithm = {
	.master_xfer		= rk3x_i2c_xfer,
	.functionality		= rk3x_i2c_func,
};

20.7 I2C设备驱动

①I2C设备信息-无设备树

在未使用设备树的时候需要在 BSP 里面使用 i2c_board_info 结构体来描述一个具体的 I2C 设备。

i2c_board_info 结构体中type 和 addr 这两个成员变量是必须要设置的,一个是 I2C 设备的名字,一个是 I2C 设备的器件地址。例如:

static struct i2c_board_info armadillo5x0_i2c_rtc = {
	I2C_BOARD_INFO("s35390a", 0x30),
};

使用 I2C_BOARD_INFO 来完成 armadillo5x0_i2c_rtc 的初始化工作。

②设备树

使用设备树的时候,I2C 设备信息挂载到相应的I2C节点下即可。例如I2C5下的AP3216C三合一的环境传感器:

&i2c5 {
	status = "okay";
	ap3216c@1e {
		compatible = " alientek,ap3216c";
		reg = <0x1e>;
	}
}

“ap3216c@1e”是子节点名字,“@”后面的“1e”就是 ap3216c 的 I2C 器件地址。
reg 属性也是设置 ap3216c 的器件地址的。

I2C 设备节点的创建重点是 compatible 属性和 reg 属性的设置,一个用于匹配驱动,一个用于设置器件地址。

③I2C设备数据收发

I2C 设备驱动首先要做的就是初始化 i2c_driver 并向 Linux 内核注册。当设备和驱动匹配以后 i2c_driver 里面的 probe 函数就会执行,probe 函数里面所做的就是字符设备驱动那一套了。一般需要在 probe 函数里面初始化 I2C 设备,要初始化 I2C 设备就必须能够对 I2C 设备寄存器进行读写操作,这里就要用到 i2c_transfer 函数了。 i2c_transfer 函数最终会调用 I2C 适配器中 i2c_algorithm 里面的 master_xfer 函数,对于 RK3568 而言就是rk3x_i2c_xfer 这个函数。 i2c_transfer 函数原型如下:

int i2c_transfer(struct i2c_adapter *adap,struct i2c_msg *msgs,int num)

adap: 所使用的 I2C 适配器, i2c_client 会保存其对应的 i2c_adapter。
msgs: I2C 要发送的一个或多个消息。
num: 消息数量,也就是 msgs 的数量。
返回值: 负值,失败,其他非负值,发送的 msgs 数量。

I2C 进行数据收发说白了就是消息的传递,Linux 内核使用 i2c_msg 结构体来描述一个消息。使用 i2c_transfer 函数发送数据之前要先构建好 i2c_msg。 i2c_msg 结构体如下:

struct i2c_msg {
	__u16 addr; /* 从机地址 */
	__u16 flags; /* 标志 */
	......
	__u16 len; /* 消息(本 msg)长度 */
	__u8 *buf; /* 消息数据 */
}

因为消息数据的单位是u8,一个字节,len的基本单位也是一个字节。

使用i2c_transfer读取从设备寄存器数据:

/*
 * @description	:	从ap3216c读取多个寄存器数据
 * @param – dev	: 	ap3216c设备
 * @param – reg	:  	要读取的寄存器首地址
 * @param – val	:  	读取到的数据
 * @param – len	:  	要读取的数据长度
 * @return      	: 	操作结果
 */
static int ap3216c_read_regs(struct ap3216c_dev *dev, u8 reg, void *val, int len)
{
    int ret;
    struct i2c_msg msg[2];
    struct i2c_client *client = (struct i2c_client *)dev->client;

    /* msg[0]为发送要读取的首地址 */
    msg[0].addr = client->addr;      	/* ap3216c地址 	*/
    msg[0].flags = 0;                   	/* 标记为发送数据 	*/
    msg[0].buf = &reg;                		/* 读取的首地址 		*/
    msg[0].len = 1;                     	/* reg长度			*/

    /* msg[1]读取数据 */
    msg[1].addr = client->addr;         	/* ap3216c地址 	*/
    msg[1].flags = I2C_M_RD;            	/* 标记为读取数据	*/
    msg[1].buf = val;                   	/* 读取数据缓冲区 	*/
    msg[1].len = len;                   	/* 要读取的数据长度	*/

    ret = i2c_transfer(client->adapter, msg, 2);
    if(ret == 2) {
        ret = 0;
    } else {
        printk("i2c rd failed=%d reg=%06x len=%d\n",ret, reg, len);
        ret = -EREMOTEIO;
    }
    return ret;
}

因为 I2C 读取数据的时候要先发送要读取的寄存器地址,然后再读取数据,所以需要准备两个 i2c_msg。一个用于发送寄存器地址,一个用于读取寄存器值。

使用i2c_transfer向从设备寄存器写入数据:

/*
 * @description:	向ap3216c多个寄存器写入数据
 * @param - dev: 	ap3216c设备
 * @param - reg: 	要写入的寄存器首地址
 * @param - buf: 	要写入的数据缓冲区
 * @param - len: 	要写入的数据长度
 * @return    :   	操作结果
 */
static s32 ap3216c_write_regs(struct ap3216c_dev *dev, u8 reg, u8 *buf, u8 len)
{
    u8 b[256];
    struct i2c_msg msg;
    struct i2c_client *client = (struct i2c_client *)dev->client;
    
    b[0] = reg;                 	/* 寄存器首地址 						*/
    memcpy(&b[1],buf,len);      	/* 将要写入的数据拷贝到数组b里面 	*/
        
    msg.addr = client->addr;    	/* ap3216c地址 					*/
    msg.flags = 0;              	/* 标记为写数据 						*/

    msg.buf = b;                	/* 要写入的数据缓冲区 				*/
    msg.len = len + 1;          	/* 要写入的数据长度 					*/

    return i2c_transfer(client->adapter, &msg, 1);
}

I2C 写操作要比读操作简单一点,因此一个 i2c_msg 即可。数组 b 用于存放寄存器首地址和要发送的数据, msg 的 len 为 len+1,因为要加上一个字节的寄存器地址。

另外还有两个API函数分别用于I2C数据的收发操作,这两个函数最终都会调用i2c_transfer。首先来看一下 I2C 数据发送函数 i2c_master_send,函数原型如下:

int i2c_master_send(const struct i2c_client *client,const char *buf,int count)

I2C 数据接收函数为 i2c_master_recv,函数原型如下:

int i2c_master_recv(const struct i2c_client *client,char *buf,int count)

20.8 硬件原理图

在这里插入图片描述
在这里插入图片描述
可以看到,AP3216c的I2C连接的是I2C5,且I2C5的SDA和SCL都接了上拉电阻4.7K。i2c5_sclm0对应GPIO3_B3,i2c5_sdam0对应GPIO3_B4.

20.9 实验

设备树:
rk3568-pinctrl.dtsi:

&pinctrl{
	i2c5 {
		/omit-if-no-ref/
		i2c5m0_xfer: i2c5m0-xfer {
			rockchip,pins =
				/* i2c5_sclm0 */
				<3 RK_PB3 4 &pcfg_pull_none_smt>,
				/* i2c5_sdam0 */
				<3 RK_PB4 4 &pcfg_pull_none_smt>;
		};
		/omit-if-no-ref/
		i2c5m1_xfer: i2c5m1-xfer {
			rockchip,pins =
				/* i2c5_sclm1 */
				<4 RK_PC7 2 &pcfg_pull_none_smt>,
				/* i2c5_sdam1 */
				<4 RK_PD0 2 &pcfg_pull_none_smt>;
		};
	};
}

pcfg_pull_none_smt 常用于 I2C 的 SCL/SDA 引脚定义
pull_none‌:表示禁用内部上拉(pull-up)和下拉(pull-down)电阻,引脚处于高阻态。这种配置通常用于总线场景(如 I2C),需依赖外部电路提供上拉电阻。
‌smt‌:代表启用施密特触发器(Schmitt Trigger),用于增强输入信号的噪声容限,改善信号完整性。这对于高速或易受干扰的接口(如 I2C)尤为重要。

rk3568-atk-evb1-ddr4-v10.dtsi:

&i2c5{
	ap3216c@1e {
		compatible = "alientek,ap3216c";
		reg = <0x1e>;
	};
}

rk3568.dtsi:

/{
	i2c5: i2c@fe5e0000 {
		compatible = "rockchip,rk3399-i2c";
		reg = <0x0 0xfe5e0000 0x0 0x1000>;
		clocks = <&cru CLK_I2C5>, <&cru PCLK_I2C5>;
		clock-names = "i2c", "pclk";
		interrupts = <GIC_SPI 51 IRQ_TYPE_LEVEL_HIGH>;
		pinctrl-names = "default";
		pinctrl-0 = <&i2c5m0_xfer>;
		#address-cells = <1>;
		#size-cells = <0>;
		status = "disabled";
	};
}

综上,AP3216c默认使用pinctrl-0 = <&i2c5m0_xfer>; 虽然rk3568.dtsi 中是status = "disabled";,但在其他dts、dtsi文件中设置了status = "okay";,例如rk3568-evb.dtsi的i2c5节点就有。不放心的话自己写的就直接加上status = "okay";

驱动:
类似于platform驱动,在module_init()的驱动初始化函数xxx_init中使用i2c_add_driver函数,同理module_exit。重点是i2c_client 和 i2c_driver。

因为 AP3216C 好像不支持连续多字节读取,才有了ap3216c_read_reg 函数一个个字节的读取。

因为 linux 内核不推荐使用全局变量, 所以没有定义一个全局变量(即没有定义私有数据ap3216c_dev全局变量),要使用内存的就用 devm_kzalloc 之类的函数去申请空间。没有定义私有数据ap3216c_dev全局变量,那么字符设备cdev的ops中的open、read、release之类的函数如何获得私有数据ap3216c_dev呢?i2c_driver的probe、remove函数如何获得私有数据ap3216c_dev呢?

1、在probe函数中,定义ap3216cdev:

ap3216cdev = devm_kzalloc(&client->dev, sizeof(*ap3216cdev), GFP_KERNEL);

&client->dev:关联的设备对象指针,用于绑定内存生命周期,将ap3216cdev 同&client->dev关联起来。
sizeof(*ap3216cdev):分配大小。
GFP_KERNEL:内存分配标志。

2、调用i2c_set_clientdata 函数将 ap3216cdev 变量的地址绑定到 client。

i2c_set_clientdata(client,ap3216cdev);

i2c_set_clientdata 是 Linux 内核 I2C 设备驱动中用于关联设备私有数据的核心函数,将自定义的设备结构体指针(如驱动私有数据)与 i2c_client 对象关联,便于后续通过 i2c_get_clientdata 快速获取 ,i2c_set_clientdata 实际调用 dev_set_drvdata,将数据存储到 i2c_client->dev->driver_data 字段中。

这样在i2c框架中,i2c_driver的probe函数和remove函数都可以访问设备结构体指针ap3216cdev。

3、字符设备cdev的ops中的函数获得私有数据ap3216c_dev

 /* 从file结构体获取cdev指针,再根据cdev获取ap3216c_dev首地址 */
    struct cdev *cdev = filp->f_path.dentry->d_inode->i_cdev;
    struct ap3216c_dev *ap3216cdev = container_of(cdev, struct ap3216c_dev, cdev);

通过文件对象 filp 逐层访问关联的 dentry(目录项)、inode(索引节点),最终获取字符设备对象 cdev。
inode->i_cdev:存储字符设备驱动注册时绑定的 struct cdev 指针。也就是ap3216c_dev->cdev.
‌container_of 宏‌:根据 cdev 成员在 struct ap3216c_dev 中的偏移量,反向计算出包含它的外层结构体 ap3216c_dev 的起始地址。

这样在cdev框架中,实现从‌内核标准结构体‌到‌驱动自定义私有数据‌的转换。这一步依赖于probe函数中的字符设备驱动的注册。

二十一、RTC real time clock

RTC 设备驱动是一个标准的字符设备驱动,应用程序通过 open、 release、 read、 write 和 ioctl等函数完成对 RTC 设备的操作。

21.1 内置RK809的RTC

①RTC 驱动框架

Linux 内核将 RTC 设备抽象为 rtc_device 结构体,因此 RTC 设备驱动就是申请并初始化rtc_device,最后将 rtc_device 注册到 Linux 内核里面, rtc_device 结构体中有struct rtc_class_ops *ops,rtc_class_ops为 RTC 设备的最底层操作函数集合,包括从 RTC 设备中读取时间、向 RTC 设备写入新的时间值等。rtc_class_ops 中的这些函数只是最底层的 RTC 设备操作函数,并不是提供给应用层的file_operations 函数操作集。

Linux 内核提供了一个 RTC 通用字符设备驱动文件,文件名为 drivers/rtc/rtc-dev.c, rtcdev.c 文件提供了所有 RTC 设备共用的 file_operations 函数操作集。其函数会调用rtc_class_ops中的函数。

可以理解为rtc_class_ops是不同设备的具体操作,rtcdev.c 文件将这个接口统一了。

②RK3568 核心板 RTC 驱动分析

rk809设备节点的compatible = "rockchip,rk809"; ,找到驱动文件 drivers/mfd/rk808.c,当设备和驱动匹配成功以后 rk808_probe 函数就会执行,probe函数中使用devm_mfd_add_devices 函数里添加rtc 设备,也就是rk808-rtc,平台设备已经添加了,“rk808-rtc”的驱动实现为drivers/rtc/rtc-rk808.c文件,这里面就是rtc_device的注册步骤。RTC 底层驱动集为 rk808_rtc_ops。 rk808_rtc_ops 操作集包含了读取/设置 RTC时间,读取/设置闹钟等函数。

21.2 外置RTC芯片AT8563T

①简介

AT8563 是一个 CMOS 实时时钟/日历芯片, 计时计数器由世纪、年、月、日、日、时、分、秒位组成。系统可以设置或读取 AT8563 中存放的时间,从而对数据进行相应的处理。

AT8563 有 16 个内部寄存器,这些寄存器都是 8 位的。每个寄存器的作用参考手册。

AT8563是一个 IIC 接口的 RTC 芯片,因此在 Linux 系统下就涉及到两类驱动:
◎IIC 驱动,需要 IIC 驱动框架来读写 AT8563 芯片。
◎RTC 驱动,因为这是一个 RTC 芯片,因此要用到 RTC 驱动框架。
如果要用到中断功能的话,还需要用到 Linux 系统中的中断子系统, Linux 系统默认就已经集成了AT8563 驱动,我们使用起来非常简单,直接修改设备树,添加 AT8563 节点信息,然后使能内核的 AT8563 驱动即可。

②硬件原理图

在这里插入图片描述
AT8563 连接到了 ATK-DLRK3568 的 I2C5 接口上,引脚为 GPIO3_B3和 GPIO3_B4。另外, AT8563 的 INT 引脚连接到了 GPIO0_D3 引脚上。这里的I2C接口和AP3216C的一样,设备树的内容可以参考(二十、I2C)中的介绍。

③驱动

AT8563 与 PCF8563 的驱动兼容,需要在menuconfig中使能Linux 内核自带的 PCF8563 驱动。

AT8563 驱动源码为drivers/rtc/rtc-pcf8563.c,是个标准的 I2C 驱动框架,在probe函数中pcf8563 = devm_kzalloc(&client->dev, sizeof(struct pcf8563),GFP_KERNEL);定义一个私有数据结构体pcf8563,同AP3216c一样,不定义全局变量,使用i2c_set_clientdata函数、to_i2c_client函数进行数据关联。

to_i2c_client 是 Linux 内核中用于将通用设备结构体(struct device)转换为 I2C 客户端结构体(struct i2c_client)的宏,本质上也是使用了container_of函数。

pcf8563->rtc = devm_rtc_device_register(&client->dev,
				pcf8563_driver.driver.name,
				&pcf8563_rtc_ops, THIS_MODULE);

设置 rtc_device 的 ops 成员变量为 pcf8563_rtc_ops,pcf8563_rtc_ops 包含了 AT8563 的具体操作。pcf8563_rtc_ops 提供了 AT8563 的时间以及闹钟读写操作函数,应用程序对 AT8563 的所有操作最终都是通过这些函数来完成的。

最后应该也是rtcdev.c 文件统一接口。

④实验运行

ls  /dev/rtc*

查看rtc的设备节点,rtc0 就是核心板上的 RK809 内部硬件 RTC 时钟, rtc1 则是 AT8563。

hwclock 是 Linux 系统中用于管理硬件时钟(RTC)的核心命令

date +%T //查看系统时间并输出 24 小时制,rtc0 
hwclock -w -f /dev/rtc1 //将当前时间写入 RTC1
hwclock -f /dev/rtc1 – show //读取 RTC1 的时间

二十二、串口 serial

22.1 接口标准

串口通信中,TTL、RS232、RS485 是三种常见的接口标准。
TTL可以理解为正常的GPIO输出,而RS232‌与RS485‌可以在TTL的基础上,通过电平转换得到。

①TTL‌

逻辑定义‌:逻辑“1”对应 +3.3V 或 +5V,逻辑“0”对应 0V。
‌特点‌:电平范围小,抗干扰能力弱,适合板级短距离通信(<1m)。
‌应用‌:MCU 与传感器、模块间的直接通信(如 Arduino 连接蓝牙模块)。
点对点全双工,直接连接 GPIO 引脚

②RS232‌

‌逻辑定义‌:逻辑“1”为 -3V ~ -15V,逻辑“0”为 +3V ~ +15V。
‌特点‌:采用单端信号传输,传输距离较短(通常 <15m),需电平转换芯片(如 MAX232)与 TTL 互连。
‌应用‌:工业设备调试、老式计算机串口通信。
点对点全双工,需至少 3 根线(TX/RX/GND)

③RS485‌

逻辑定义‌:通过差分信号传输,A/B 线电压差表示逻辑(如 A > B 为“1”,反之为“0”)。
‌特点‌:抗干扰强,支持长距离(可达 1200m)和多节点(32~256 个),需终端电阻匹配阻抗。
‌应用‌:工业自动化、楼宇控制网络等复杂环境.
总线型半双工,采用两线制差分传输

22.2 UART 驱动框架

Linux 提供了串口驱动框架,我们只需要按照相应的串口框架编写驱动程序即可。串口驱动没什么主机端和设备端之分,就只有一个串口驱动,而且这个驱动也已经由瑞芯微官方编写好了,我们真正要做的就是在设备树中添加所要使用的串口节点信息。当系统启动以后串口驱动和设备匹配成功,相应的串口就会被驱动起来,生成/dev/ttySx 文件,其中 x 代表数字。

①uart_driver 注册和注销

类属于I2c中的i2c_driver。

uart_driver 结构体代表 UART 驱动,每个串口驱动需要定义一个 uart_driver,加载驱动的时候通过 uart_register_driver 函数向系统注册这个 uart_driver,函数原型如下:

int uart_register_driver(struct uart_driver *uart)

注销驱动的时候也需要注销掉前面注册的 uart_driver, 需要用到 uart_unregister_driver 函数原型如下:

void uart_unregister_driver(struct uart_driver *uart)

②uart_port 的添加与移除

uart_port 表示一个具体的端口 port。uart_port 中最主要的就是uart_ops *ops; , ops 包括了串口的具体驱动函数。

每个 UART 都有一个 uart_port, uart_add_one_port 函数将uart_port 和 uart_driver 结合起来。函数原型如下:

int uart_add_one_port(stuct uart_driver *reg, stuct uart_port *port)

卸载 UART 驱动的时候也需要将 uart_port 从相应的 uart_driver 中移除,需要用到uart_remove_one_port 函数,函数原型如下:

int uart_remove_one_port(struct uart_driver *reg, struct uart_prot *prot)

③uart_ops实现

uart_port 中的 pos 成员变量很重要,因为 ops 包含了针对 UART 具体的驱动函数, Linux 系统收发数据最终调用的都是 ops 中的函数。ops 是 uart_ops类型的结构体指针变量。UATT 驱动编写人员需要实现 uart_ops,因为 uart_ops 是最底层的 UART 驱动接口,是实实在在的和 UART 寄存器打交道的。

22.3 硬件原理图

8250驱动通过设备树声明区分接口类型,结合外部硬件实现电平转换:

1.‌TTL‌:默认模式,直接使用CPU引脚;
/
2.‌RS485‌:需设备树标记、GPIO方向控制,设备树有linux,rs485-enabled-at-boot-time标志,但RK3568中直接当作普通GPIO使用,因为RS485 采用 SP3485EN 这款芯片来实现,通过电路设计,执行TX、RX接口,直接当作uart串口即可,SP3485E无需寄存器操作。
/
3.‌RS232‌:依赖外部转换芯片,驱动无感知.使用sp3232芯片实现,也无需操作芯片寄存器,直接当作uart串口。

①RS232

在这里插入图片描述
COM1 母头连接到 UART3 接口上,把 JP6 的 1-3 和 2-4 连接起来以后 SP232 就和 UART3连接到一起了, UART3_TX_M1 和 UART3_RX_M1 分别接到了 GPIO3_B7 和 GPIO3_C0 这两个引脚上。

②RS485

在这里插入图片描述
RS485 采用 SP3485EN 这款芯片来实现, RO 为数据输出端, DI 为数据接收端, RE 是接收使能信号(低电平有效), DE 是发送使能信号(高电平有效)。在图 32.3.2 中 RE 和 DE 经过一系列的电路,最终通过RS485_RX 来控制,这样我们可以省掉一个 RS485 收发控制 IO,将 RS485 完全当作一个串口来使用,方便我们写驱动。把 JP2 的 1-3 和 2-4 连接起来以后SP3485EN 就和 UART4 连接到一起了, UART4_TX_M1 和 UART4_RX_M1 分别接到了GPIO3_B2 和 GPIO3_B1 这两个引脚上。

后续

有关RK的uart驱动详情以及实验可看正点原子的手册。

二十三、SPI Serial Peripheral interface

参考野火的手册。
串行外设接口 (Serial Peripheral interface) 简称 SPI,是一种高速的,全双工,同步的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚。

23.1 spi 介绍

①物理总线

spi 总线都可以挂载多个设备, spi 支持标准的一主多从,全双工半双工通信等。其中四根控制线包括:

• SCK:时钟线,数据收发同步
• MOSI:数据线,主设备数据发送、从设备数据接收 ,master ,slave ,input,output
• MISO:数据线,从设备数据发送,主设备数据接收
• NSS:片选信号线

在这里插入图片描述
i2c 通过 i2c 设备地址选择通信设备,而 spi 通过片选引脚选中要通信的设备。

②时序

在这里插入图片描述
• 起始信号: NSS 信号线由高变低
• 停止信号: NSS 信号由低变高
• 数据传输:在 SCK 的每个时钟周期 MOSI 和 MISO 同时传输一位数据,高/低位传输没有硬性规定
– 传输单位: 8 位或 16 位
– 单位数量:允许无限长的数据传输

上图的通信模式为spi模式1,CPOL=0,CPHA=1.

③通信模式

根据总线空闲时 SCK 的时钟状态以及数据采样时刻, SPI 的工作模式分为四种:
在这里插入图片描述
• 时钟极性 CPOL:指 SPI 通讯设备处于空闲状态时, SCK 信号线的电平信号:
– CPOL=0 时, SCK 在空闲状态时为低电平
– CPOL=1 时, SCK 在空闲状态时为高电平
• 时钟相位 CPHA:数据的采样的时刻:
– CPHA=0 时,数据在 SCK 时钟线的“奇数边沿”被采样,SCK信号开始时采样。
– CPHA=1 时,数据在 SCK 时钟线的“偶数边沿”被采样,SCK信号结束时采样。

在这里插入图片描述

23.2 spi 驱动框架

spi 设备驱动和 i2c 设备驱动非常相似。
在这里插入图片描述
spi 可分为 spi 总线驱动和 spi 设备驱动。spi 设备驱动涉及到字符设备驱动、 SPI 核心层、 SPI 主机驱动,具体功能如下。

• SPI 核心层:提供 SPI 控制器驱动和设备驱动的注册方法、注销方法, SPI Core 提供操作接口函数,允许spi master, spi driver 和 spi device 初始化时在 SPI Core 中进行注册,以及推出时进行注销,也提供上层 API 接口。
• SPI 主机驱动:也就是 spi 控制器驱动 (SPI Master Driver),主要包含 SPI 硬件体系结构中适配器 (spi 控制器) 的控制,实现 spi 总线的硬件访问操作
• SPI 设备驱动:对应于 spi 设备端的驱动程序,通过 SPI 主机驱动与 CPU 交换数据。

①spi核心层

linux 系统在开机的时候就会执行,自动进行 spi 总线注册。/drivers/spi/spi.c中有:

status = bus_register(&spi_bus_type);
status = class_register(&spi_master_class);

会在 sys/bus 下面生成一个 spi 总线,sys/class/目录下会可以找到 spi_master 类。一个 spi_master 代表了一个 spi 总线
spi_bus_type 总线定义:

struct bus_type spi_bus_type = {
	.name		= "spi",
	.dev_groups	= spi_dev_groups,
	.match		= spi_match_device,
	.uevent		= spi_uevent,
};

.match 函数指针,设定了 spi 设备和 spi 驱动的匹配规则,如下:

/* Check override first, and if set, only use the named driver */
	if (spi->driver_override)
		return strcmp(spi->driver_override, drv->name) == 0;

	/* Attempt an OF style match */
	if (of_driver_match_device(dev, drv))
		return 1;

	/* Then try ACPI */
	if (acpi_driver_match_device(dev, drv))
		return 1;

	if (sdrv->id_table)
		return !!spi_match_id(sdrv->id_table, spi);

	return strcmp(spi->modalias, drv->name) == 0;

②spi控制器驱动

rk3568 芯片有 4 个 spi 控制器,rk3568.dtsi中有:

/{
		spi0: spi@fe610000 {
		compatible = "rockchip,rk3066-spi";
		reg = <0x0 0xfe610000 0x0 0x1000>;
		interrupts = <GIC_SPI 103 IRQ_TYPE_LEVEL_HIGH>;
		#address-cells = <1>;
		#size-cells = <0>;
		clocks = <&cru CLK_SPI0>, <&cru PCLK_SPI0>;
		clock-names = "spiclk", "apb_pclk";
		dmas = <&dmac0 20>, <&dmac0 21>;
		dma-names = "tx", "rx";
		pinctrl-names = "default", "high_speed";
		pinctrl-0 = <&spi0m0_cs0 &spi0m0_cs1 &spi0m0_pins>;
		pinctrl-1 = <&spi0m0_cs0 &spi0m0_cs1 &spi0m0_pins_hs>;
		status = "disabled";
	};
}

pinctrl中,spi0m0_cs是片选引脚,spi0m0_pins是sck、mosi和miso。
通过compatible属性,找到驱动文件drivers/spi/spi-rockchip.c,该驱动文件使用platform驱动有:

static struct platform_driver rockchip_spi_driver = {
	.driver = {
		.name	= DRIVER_NAME,
		.pm = &rockchip_spi_pm,
		.of_match_table = of_match_ptr(rockchip_spi_dt_match),
	},
	.probe = rockchip_spi_probe,
	.remove = rockchip_spi_remove,
};

在probe函数中实例化spi_controller,也就是spi_master。spi_master 是 SPI 控制器接口,类似 i2c_adapter,里面有属性和函数。例如,
cur_msg: spi_message 结构体类型,我们发送的信息都会被封装在这个结构体中。 cur_msg,当前正带处理的消息队列。
transfer_one_message: 发送一个 spi 消息,类似 IIC 适配器里的 algo->master_xfer,产生 spi通信时序。

在probe函数中使用 devm_spi_register_controller 函数向 SPI 子系统注册 SPI 控制器。

③spi设备驱动

spi 总线驱动,由硬件供应商提供,spi 设备的注册和注销函数分别在驱动的入口和出口函数中调用,这与平台设备驱动、 i2c 设备驱动相同,spi 设备注册和注销函数如下:

int spi_register_driver(struct spi_driver *sdrv)
static inline void spi_unregister_driver(struct spi_driver *sdrv)

spi_setup() 函数设置 spi 设备的片选信号、传输单位、最大传输速率等。

int spi_setup(struct spi_device *spi)

spi_driver和spi_device.当驱动和设备匹配成功后 (例如设备树节点) 我们可以从.prob 函数的参数中得到 spi_device 结构体,同i2c的i2c_client.

struct spi_driver {
	const struct spi_device_id *id_table;
	int			(*probe)(struct spi_device *spi);
	int			(*remove)(struct spi_device *spi);
	void			(*shutdown)(struct spi_device *spi);
	struct device_driver	driver;
};

23.3 实验

oled 屏幕驱动,硬件连接;
在这里插入图片描述

设备树:

&spi3{
	status = "okay";
	pinctrl-names = "default", "high_speed";
	pinctrl-0 = <&spi3m1_cs0 &spi3m1_pins>;
	pinctrl-1 = <&spi3m1_cs0 &spi3m1_pins_hs>;
	cs-gpios = <&gpio4 RK_PC6 GPIO_ACTIVE_LOW>;

	spi_oled@0 {
		status = "okay";
		compatible = "fire,spi_oled";
		reg = <0>; //chip select 0:cs0 1:cs1
		spi-max-frequency = <24000000>; //spi output clock
		dc_control_pin = <&gpio3 RK_PA7 GPIO_ACTIVE_HIGH>;
		pinctrl-names = "default";
		pinctrl-0 = <&spi_oled_pin>;
		};
};

pinctrl:开启 spi3,指定使用的 pinctrl 节点<&spi3m1_cs0 &spi3m1_pins>等。
cs-gpios:指定使用的片选引脚GPIO4_C6
reg:设置 reg 属性为 0, 表示 spi_oled 连接到 spi3 的通道 0,片选通道。
dc_control_pin :指定 spi_oled 使用的 D/C 控制引脚,在驱动程序中会控制该引脚设置发送的是命令还是数据。高电平是数据,低电平是控制命令。

驱动:
主要是spi_driver的注册,probe函数中对spi_device的初始化。以及字符设备操作函数集实现,ops的实现依靠spi数据传递。

在 spi 设备驱动程序中, spi_transfer 结构体用于指定要发送的数据:

• tx_buf: 发送缓冲区,用于指定要发送的数据地址。
• rx_buf: 接收缓冲区,用于保存接收得到的数据,如果不接收不用设置或设置为 NULL.
• len: 要发送和接收的长度,根据 SPI 特性发送、接收长度相等。
• tx_dma、 rx_dma: 如果使用了 DAM, 用于指定 tx 或 rx DMA 地址。
• bits_per_word: speed_hz,分别用于设置每个字节多少位、发送频率。

发送流程:依次为初始化 spi_transfer 结构体指定要发送的数据、初始化消息结构体、将消息spi_message结构体添加到队尾部、调用 spi_sync 函数执行同步发送。

spi_transfer 结构体保存了要发送 (或接收) 的数据,而在 SPI 设备驱动中数据是以“消息”的形式发送。 spi_message 是消息结构体.spi_message 的初始化以及“绑定” spi_transfer 传输结构体都是由内核函数实现。

/* 填充 message 和 transfer 结构体 */
transfer->tx_buf = commands;
transfer->len = lenght;
spi_message_init(message); //初始化 spi_message
spi_message_add_tail(transfer, message);   //将 spi_transfer 结构体添加到 spi_message 队列的末尾
error = spi_sync(spi_device, message); //阻塞当前线程进行数据传输

spi_async函数,在驱动程序中调用 async 时不会阻塞当前进程,只是把当前 message 结构体添加到当前 spi 控制器成员 queue 的末尾。然后在内核中新增加一个工作,这个工作的内容就是去处理这个 message 结构体。

二十四、块设备驱动

24.1 介绍

块设备是针对存储设备的,比如 SD 卡、 EMMC、 NAND Flash、 Nor Flash、 SPI Flash、机械硬盘、固态硬盘等。因此块设备驱动其实就是这些存储设备驱动,块设备驱动相比字符设备驱动的主要区别如下:
①、块设备只能以块为单位进行读写访问,块是 linux 虚拟文件系统(VFS)基本的数据传输单位。字符设备是以字节为单位进行数据传输的,不需要缓冲。
②、块设备在结构上是可以进行随机访问的,对于这些设备的读写都是按块进行的,块设备使用缓冲区来暂时存放数据,等到条件成熟以后再一次性将缓冲区中的数据写入块设备中。这么做的目的为了提高块设备寿命,减少了对块设备的擦除次数。

块设备结构的不同其 I/O 算法也会不同,比如对于 EMMC、 SD 卡、 NAND Flash 这类没有任何机械设备的存储设备就可以任意读写任何的扇区(块设备物理存储单元)。但是对于机械硬盘这样带有磁头的设备,读取不同的盘面或者磁道里面的数据,磁头都需要进行移动,因此
对于机械硬盘而言,将那些杂乱的访问按照一定的顺序进行排列可以有效提高磁盘性能, linux里面针对不同的存储设备实现了不同的 I/O 调度算法。

24.2 块设备驱动框架

①block_device 结构体

Linux 内 核 使 用 block_device 表示块设备,其中有:struct gendisk * bd_disk;
内核使用 block_device 来表示一个具体的块设备对象,比如一个硬盘或者分区,如果是硬盘的话, bd_disk 就指向通用磁盘结构gendisk。

向内核注册新的块设备、申请设备号,块设备注册函数为register_blkdev,函数原型如下:

int register_blkdev(unsigned int major, const char *name)

如果不使用某个块设备了,那么就需要注销掉,函数为unregister_blkdev,函数原型如下:

void unregister_blkdev(unsigned int major, const char *name)

②gendisk 结构体

Linux 内核使用 gendisk 来描述一个磁盘设备:

struct gendisk {
	int major; /* major number of driver */
	int first_minor;
	int minors;
	struct disk_part_tbl __rcu *part_tbl;
	struct hd_struct part0;
	const struct block_device_operations *fops;
	struct request_queue *queue;
	。。。。。。
}

major :为磁盘设备的主设备号。
first_minor:为磁盘的第一个次设备号。
minors :为磁盘的此设备号数量,也就是磁盘的分区数量,这些分区的主设备号一样,此设备号不同。
part_tbl: 为磁盘对应的分区表, disk_part_tbl 的核心是一个 hd_struct 结构体指针数组,此数组每一项都对应一个分区信息。
fops: 为块设备操作集,为 block_device_operations 结构体类型。和字符设备操作集 file_operations 一样。
queue :为磁盘对应的请求队列,所有针对该磁盘设备的请求都放到此队列中,驱动程序需要处理此队列中的所有请求。

编写块的设备驱动的时候需要分配并初始化一个 gendisk, linux 内核提供了一组 gendisk操作函数:
1、申请 gendisk
使用 gendisk 之前要先申请, allo_disk 函数用于申请一个 gendisk,函数原型如下:

struct gendisk *alloc_disk(int minors)

minors:次设备号数量,也就是 gendisk 对应的分区数量。

2、删除 gendisk
如果要删除 gendisk 的话可以使用函数 del_gendisk,函数原型如下:

void del_gendisk(struct gendisk *gp)

3、将 gendisk 添加到内核
申请到 gendisk 以后系统还不能使用,必须使用 add_disk 函数将申请到的 gendisk 添加到内核中, add_disk 函数原型如下:

void add_disk(struct gendisk *disk)

4、设置 gendisk 容量
每一个磁盘都有容量,所以在初始化 gendisk 的时候也需要设置其容量,使用函数set_capacity,函数原型如下:

void set_capacity(struct gendisk *disk, sector_t size)

函数参数和返回值含义如下:
disk: 设置容量的 gendisk。
size: 盘容量大小,注意这里是扇区数量。块设备中最小的可寻址单元是扇区,一个扇区一般是 512 字节,有些设备的物理扇区可能不是 512 字节。不管物理扇区是多少,内核和块设备驱动之间的扇区都是 512 字节。所以 set_capacity 函数设置的大小就是块设备实际容量除以512 字节得到的扇区数量。比如一个 2MB 的磁盘,其扇区数量就是(2X1024X1024)/512=4096。

1MB =1,024×1,024=1,048,576 字节

5、调整 gendisk 引用计数
内核会通过 get_disk_and_module 和 put_disk 这两个函数来调整 gendisk 的引用计数, get_disk_and_module 是增加 gendisk 的引用计数, put_disk 是减少 gendisk的引用计数,这两个函数原型如下所示:

truct kobject * get_disk_and_module (struct gendisk *disk)
void put_disk(struct gendisk *disk)

③block_device_operations 结构体

和字符设备的 file_operations 一样,块设备也有操作集,为结构体 block_device_operations。其中有:

·open 函数用于打开指定的设备。
·release 函数用于关闭(释放)指定的块设备。
·rw_page 函数用于读写指定的页。
·ioctl 函数用于块设备 I/O 控制。
·compat_ioctl 函数与 ioctl 函数一样,都是用于块设备的 I/O 控制。区别在于在 64位系统上, 32 位应用程序的 ioctl 会调用 compat_iotl 函数。在 32 位系统上运行的 32 位应用程序调用的就是 ioctl 函数。
·getgeo 函数用于获取磁盘信息,包括磁头、柱面和扇区等信息。
·owner 表示此结构体属于哪个模块,一般直接设置为 THIS_MODULE。

在 block_device_operations 结构体中并没有找到 read 和 write 这样的读写函数,块设备从物理块设备中读写数据需要请求。这里就引处理块设备驱动中非常重要的 request_queue、 request 和 bio。

24.3 块设备I/O请求过程

内核将对块设备的读写都发送到请求队列 request_queue 中, request_queue 中是大量的request(请求结构体),而 request 又包含了 bio, bio 保存了读写相关数据,比如从块设备的哪个地址开始读取、读取的数据长度,读取到哪里,如果是写的话还包括要写入的数据等。

①请求队列 request_queue

request_queue 结构体,每个磁盘(gendisk)都要分配一个 request_queue。gendisk结构体中有一个 request_queue 结构体指针类型成员变量 queue。

一般 blk_alloc_queue 和 blk_queue_make_request 是搭配在一起使用的,用于那些非机械的存储设备、无需 I/O 调度器,比如 EMMC、 SD 卡等。 blk_mq_init_sq_queue函数会给请求队列分配一个 I/O 调度器,用于机械存储设备,比如机械硬盘等。

1、初始化请求队列-机械硬盘
初始化请求队列可以分为两部分,第一部分是创建 blk_mq_tag_set 结构体,然后使用blk_mq_alloc_tag_set 函数初始化 blk_mq_tag_set 对象。第二部分使用 blk_mq_init_queue 函数获取 request_queue。

blk_mq_tag_set 结构体,其中有:const struct blk_mq_ops *ops;
ops 为驱动实现的操作集合,会被 request_queue 继承。

blk_mq_ops 结构体,其中有:queue_rq_fn *queue_rq;
queue_rq 这是一个 queue_rq_fn 类型的指针,需驱动开发者实现,可以通过第二形参获取 request 结构体, queue_rq_fn 类型定义如下:

typedef blk_status_t (queue_rq_fn)(struct blk_mq_hw_ctx *,const struct blk_mq_queue_data *);

linux 内核提供了一组 blk_mq_tag_set 操作相关的函数:
blk_mq_alloc_tag_set :为一个或多个请求队列分配 tag 和 request 集合
blk_mq_free_tag_set:释放请求队列中的 tag 集合
blk_mq_init_queue :初始化 IO 请求队列 request_queue,此函数会申请 request_queue返回。

Linux 内核也提供了一步创建 request_queue 队列的函数: blk_mq_init_sq_queue,使用此函数可以一步创建请求队列,函数原型如下:

struct request_queue *blk_mq_init_sq_queue(struct blk_mq_tag_set *set,const struct blk_mq_ops *ops,
										unsigned int queue_depth,unsigned int set_flags)

函数参数和返回值含义如下:
set: blk_mq_tag_set 对象。
ops: 操作函数。
queue_depth: 队列深度。
set_flags: 标志。
返回值: request_queue 的地址。

2、分配请求队列并绑定制造请求函数-非机械硬盘
blk_mq_init_queue 函数完成了请求队列的申请以及请求处理函数的绑定,这个一般用于像机械硬盘这样的存储设备,需要 I/O 调度器来优化数据读写过程。但是对于 EMMC、 SD 卡这样的非机械设备,可以进行完全随机访问,所以就不需要复杂的 I/O 调度器了。对于非机械设备我们可以先申请 request_queue,然后将申请到的 request_queue 与“制造请求”函数绑定在一起。

request_queue 申请函数 blk_alloc_queue,函数原型如下:

struct request_queue *blk_alloc_queue(gfp_t gfp_mask)

为申请到的请求队列绑定一个“制造请求”函数, blk_queue_make_request函数原型如下:

void blk_queue_make_request(struct request_queue *q, make_request_fn *mfn)

mfn:需要绑定的“制造”请求函数,需驱动开发者实现,函数原型如下:

void (make_request_fn) (struct request_queue *q, struct bio *bio)

3、删除请求队列
当卸载块设备驱动的时候我们还需要删除掉前面申请到的 request_queue,删除请求队列使用函数 blk_cleanup_queue,函数原型如下:

void blk_cleanup_queue(struct request_queue *q)

②请求 request

请求队列(request_queue)里面包含的就是一系列的请求(request), request 是一个结构体,request 里面有一个名为“bio”的成员变量,类型为 bio 结构体指针。真正的数据就保存在 bio 里面,所以我们需要从 request_queue 中取出一个一个的 request,然后再从每个 request 里面取出 bio,最后根据 bio 的描述讲数据写入到块设备,或者从块设备中读取数据。

1、开启请求
当有请求处理的时候,我们要用 blk_mq_start_request 函数开启请求处理,函数原型如下:

void blk_mq_start_request(struct request *rq);

2、结束请求
我们不用处理请求的时候,要使用 blk_mq_end_request 函数结束请求处理,函数原型如下:

void blk_mq_end_request(struct request *rq, blk_status_t error);

开启请求与结束请求之间是对块设备的操作代码。

③bio 结构

每个 request 里面里面会有多个 bio, bio 保存着最终要读写的数据、地址等信息。上层应用程序对于块设备的读写会被构造成一个或多个 bio 结构, 上层会将 bio 提交给 I/O 调度器, I/O 调度器会将这些 bio 构造成 request 结构, request_queue 里面顺序存放着一系列的 request。新产生的 bio 可能被合并到 request_queue 里现有的 request 中,也可能产生新的 request,然后插入到 request_queue 中合适的位置,这一切都是由 I/O 调度器来完成的。

在这里插入图片描述
bio 是个结构体,其中有:

struct bvec_iter bi_iter;
struct bio_vec *bi_io_vec; 

在这里插入图片描述
对于物理存储设备的操作不外乎就是 RAM 和 物理存储设备 之间的数据传递。数据传输三个要求:数据源、数据长度以及数据目的地,也就是你要从物理存储设备的哪个地址开始读取、读取到 RAM 中的哪个地址处、读取的数据长度是多少。既然 bio 是块设备最小的数据传输单元,那么 bio 就有必要描述清楚这些信息,其中:
·bi_iter 这个结构体成员变量就用于描述物理存储设备地址信息,比如要操作的扇区地址。
·bi_io_vec 指向 bio_vec 数组首地址, bio_vec 数组就是 RAM 信息,比如页地址、页偏移以及长度,“页地址”是 linux 内核里面内存管理相关的概念。
在这里插入图片描述

1、遍历请求request中的 bio
因此就涉及到遍历请求中所有 bio 并进行处理。遍历请求中的 bio 使用函数 __rq_for_each_bio(_bio, rq)

2、遍历 bio 中的所有段
bio 包含了最终要操作的数据,因此还需要遍历 bio 中的所有段,这里要用到bio_for_each_segment(bvl, bio, iter)

3、通知 bio 处理结束
如果使用“制造请求”,也就是抛开 I/O 调度器直接处理 bio 的话,在 bio 处理完成以后要通知内核 bio 处理完成,使用 bio_endio 函数,函数原型如下:bvoid bio_endio(struct bio *bio, int error)

24.4 实验

①请求队列实验

两个重要的结构体: blk_mq_tag_set 和gendisk。
·blk_mq_tag_set 对应的就是request_queue,blk_mq_tag_set 的ops可以看作真正的 IO 读写操作。具体来说是queue_rq_fn *queue_rq函数,有了底层操作还不行,还需要 gendisk 结构体为上层提供接口调用。
·gendisk对应block_device ,gendisk的fops就是实现上层调用的操作。
因为使用了请求队列,所有数据处理过程在blk_mq_tag_set .ops. queue_rq函数中,也就是说blk_mq_start_request、blk_mq_end_request函数也在其中。blk_mq_tag_set .ops. queue_rq函数操作的是request

②非请求队列实验

需要把blk_mq_tag_set 相关的都删除掉,使用create_req_queue 、blk_alloc_queue函数替代,create_req_queue 函数设置的“制造请求”函数替代blk_mq_tag_set .ops. queue_rq函数。只不过“制造请求”函数 ramdisk_make_request_fn中是对bio进行操作的。

二十五、Regmap API

以SPI/I2C为例,
SPI/I2C的主机驱动提供了符合SPI/I2C协议的API接口,驱动开发者只需使用这些接口传输数据即可,而Regmap将 SPI/ I2C 接口 API 函数统一为 regmap API函数,进一步简化了数据传输的步骤。

25.1 介绍

Linux 下大部分设备的驱动开发都是操作其内部寄存器,比如 I2C/SPI 外设,通过 I2C/SPI 接口读写芯片内部寄存器。芯片内部寄存器也是同样的道理,比如 ATKDLRK3568 的 PWM、 TIM 等外设初始化,最终都是要落到寄存器的设置上。为了减少代码复用, Linux 内核引入了 regmap 模型, regmap 将寄存器访问的共同逻辑抽象出来,驱动开发人员不需要再去纠结使用 SPI 或者 I2C 接口 API 函数,统一使用 regmap API函数。

优点:
regmap 是 Linux 内核为了减少慢速 I/O 在驱动上的冗余开销,提供了一种通用的接口来操作硬件寄存器。另外, regmap 在驱动和硬件之间添加了 cache,降低了低速 I/O 的操作次数,提高了访问效率。

“缓存”(Cache)是一种基于程序局部性原理设计的高速存储器,主要用于提升数据存取效率。其核心作用是通过存储频繁访问的数据副本,减少对主存或磁盘的直接访问次数,从而突破硬件性能瓶颈。

缺点:
实时性会降低。

使用场景:
①、硬件寄存器操作,比如选用通过 I2C/SPI 接口来读写设备的内部寄存器,或者需要读写 SOC 内部的硬件寄存器。
②、提高代码复用性和驱动一致性,简化驱动开发过程。
③、减少底层 I/O 操作次数,提高访问效率。

25.2 Regmap 驱动框架

在这里插入图片描述
regmap 框架分为三层:
①、底层物理总线: regmap 就是对不同的物理总线进行封装.
②、 regmap 核心层,用于实现 regmap,我们不用关心具体实现。
③、 regmap API 抽象层, regmap 向驱动编写人员提供的 API 接口,驱动编写人员使用这些API 接口来操作具体的芯片设备,也是驱动编写人员重点要掌握的。

Linux 内 核 将 regmap 框 架 抽 象 为 regmap 结构体,要使用 regmap,肯定要先给驱动分配一个具体的 regmap 结构体实例,其中有许多函数和变量,需要驱动编写人员根据实际情况选择性的初始化, regmap 的初始化通过结构体 regmap_config 来完成。

regmap_config 结构体就是用来初始化 regmap 的,其中有两个必填字段:
1、reg_bits:寄存器地址位数
2、val_bits:寄存器值位数

25.3 Regmap 操作函数

①Regmap 申请与初始化

Linux 内核提供了针对不同接口的 regmap 初始化函数, SPI 接口初始化函数为 regmap_init_spi,函数原型如下:

struct regmap * regmap_init_spi(struct spi_device *spi,const struct regmap_config *config)

I2C 接口的 regmap 初始化函数为 regmap_init_i2c,函数原型如下:

struct regmap * regmap_init_i2c(struct i2c_client *i2c,const struct regmap_config *config)

不管是什么接口,全部使用 regmap_exit 这个函数来释放 regmap,函数原型如下:

void regmap_exit(struct regmap *map)

②regmap 设备访问

对于寄存器的操作就两种:读和写。 regmap 提供了最核心的两个读写操作: regmap_read 和 regmap_write。这两个函数分别用来
读/写寄存器, regmap_read 函数原型如下:

int regmap_read(struct regmap *map,unsigned int reg,unsigned int *val)

regmap_write 函数原型如下:

int regmap_write(struct regmap *map,unsigned int reg,unsigned int val)

25.4 实验

驱动:
对比《二十、 I2C 驱动实验》中的 I2C 驱动,本次实验采用了 regmap API 和 MISC 驱动框架,驱动程序精简了很多。

regmap 的regmap_write、regmap_read函数替代了i2c_transfer函数,且regmap的初始化部分十分简化,但是还是需要对IIC进行初始化和释放,相当于在IIC框架下又添加了regmap框架:

/* 初始化 regmap_config 设置 */
ap3216c.regmap_config.reg_bits = 8; /* 寄存器长度 8bit */
ap3216c.regmap_config.val_bits = 8; /* 值长度 8bit */

/* 初始化 I2C 接口的 regmap */
ap3216c.regmap = regmap_init_i2c(client, &ap3216c.regmap_config);
if(IS_ERR(ap3216c.regmap)){
	return PTR_ERR(ap3216c.regmap);
}

MISC简化了cdev的注册步骤。

二十六、IIO Industrial I/O

IIO(Industrial I/O)子系统是Linux内核中为传感器和模拟/数字信号转换(ADC、DAC)设备设计的统一框架。各类传感器内部都是有个 ADC,内部 ADC 将原始的模拟数据转换为数字量,然后通过其他的通信接口,比如 IIC、 SPI 等传输给 SOC。同理DAC。

26.1 iio_dev 结构体

IIO 子系统使用结构体 iio_dev 来描述一个具体 IIO 设备,其中有struct iio_info *info;
info 为 iio_info 结构体类型,这个结构体里面有很多函数,需要驱动开发人员编写,从用户空间读取 IIO 设备内部数据,最终调用的就是 iio_info 里面的函数。

其中有:struct cdev chrdev;
chrdev 为字符设备,由 IIO 内核创建。

①iio_dev 申请与释放

申请 iio_dev,申请函数为 iio_device_alloc,函数原型如下:

struct iio_dev *iio_device_alloc(int sizeof_priv)

·sizeof_priv: 私有数据内存空间大小,一般我们会将自己定义的设备结构体变量作为 iio_dev的私有数据,这样可以直接通过 iio_device_alloc 函数同时完成 iio_dev 和设备结构体变量的内存申请。申请成功以后使用 iio_priv 函数来得到自定义的设备结构体变量首地址。
·返回值:如果申请成功就返回 iio_dev 首地址,如果失败就返回 NULL。

也 可 以 使 用 devm_iio_device_alloc 来 分 配 iio_dev , 这 样 就 不 需 要 我 们 手 动 调 用iio_device_free 函数完成 iio_dev 的释放工作。

一般 iio_device_alloc 和 iio_priv 之间的配合使用如下所示:

struct ap3216c_dev *dev;
struct iio_dev *indio_dev;
/* 1、申请 iio_dev 内存 */
indio_dev = iio_device_alloc(sizeof(*dev));
if (!indio_dev)
	return -ENOMEM;
/* 2、获取设备结构体变量地址 */
dev = iio_priv(indio_dev);

释放 iio_dev,需要使用 iio_device_free 函数,函数原型如下:

void iio_device_free(struct iio_dev *indio_dev)

②iio_dev 注册与注销

分配好 iio_dev 以后就要初始化各种成员变量,初始化完成以后就需要将 iio_dev 注册到内核中,需要用到 iio_device_register 函数,函数原型如下:

int iio_device_register(struct iio_dev *indio_dev)

如果要注销 iio_dev 使用 iio_device_unregister 函数,函数原型如下:

void iio_device_unregister(struct iio_dev *indio_dev)

26.2 iio_info 结构体

iio_dev 有个成员变量: info,为 iio_info 结构体指针变量,因为用户空间对设备的具体操作最终都会反映到 iio_info 里面,这个是我们在编写 IIO 驱动的时候需要着重去实现的。

iio_info结构体部分如下:

struct iio_info {
const struct attribute_group *event_attrs;
const struct attribute_group *attrs;

int (*read_raw)(struct iio_dev *indio_dev,struct iio_chan_spec const *chan,int *val,int *val2,long mask);
int (*write_raw)(struct iio_dev *indio_dev,struct iio_chan_spec const *chan,int *val,int *val2,long mask);
int (*write_raw_get_fmt)(struct iio_dev *indio_dev,struct iio_chan_spec const *chan,long mask);
......

·attrs 是通用的设备属性。
·read_raw:读设备内部数据的操作函数
·write_raw:写设备内部数据的操作函数

这两个函数的参数都是一样的:
indio_dev: 需要读写的 IIO 设备。
chan:需要读取的通道。
val1, val2:
对于 read_raw 函数来说 val 和 val2 这两个就是应用程序从内核空间读取到数据,
对于 write_raw 来说就是应用程序向设备写入的数据。
val 和 val2 共同组成具体值,具体val1、val2的组合形式根据LInux提供的参数决定。
在这里插入图片描述
mask:掩码,用于指定我们读取的是什么数据。

·write_raw_get_fmt:用于设置用户空间向内核空间写入的数据格式,决定了 wtite_raw 函数中 val 和 val2 的意义。

26.3 iio_chan_spec

IIO 的核心就是通道,一个传感器可能有多路数据,比如一个 ADC 芯片支持 8 路采集,那么这个 ADC 就有 8 个通道。Linux 内核使用 iio_chan_spec 结构体来描述通道。
其中有:

enum iio_chan_type type;

type 为通道类型, iio_chan_type 是一个枚举类型,列举出了可以选择的通道类型,如果是 ADC,那就是 IIO_VOLTAGE 电压类型。

后续

感觉手册不够仔细,差了许多内容,有些看不懂,有需要了再研究吧。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐