自定义内核 在很早以前,我们就对内核进行了基础的解读,具体可以看早期的系列:【开发一个Linux系统吧】,当时并没有在内核的基础上进行二次开发,仅仅使用了一些Linux特有的API进行开发,而本系列的【RK3588系列内核开发】则是直接针对linux的内核进行开发。
开发一个Linux系统吧:https://blog.minloha.cn/posts/182214b18bc0992022122222.html
Kconfig详解 我们在编译Linux内核时会接触到终端UI界面,在我们使用make menuconfig
时就可以看到,这个UI界面包含了基本的页面配置,此配置由Kconfig进行生成,首先介绍一下Kconfig语法:
1、读取另一个Kconfig文件(注意Kconfig文件的文件名也必须是Kconfig)
2、生成一个菜单条目
1 2 3 menu "I2C support" ... endmenu
这句话就是在menuconfig生成一个可供选择的条目,用户可以在menuconfig按回车进入其中,举个栗子:
1 2 3 4 5 6 menu "Menu Title" config FOO bool "Option Foo" config BAR int "Option Bar" endmenu
在这句话中,用户需要按enter进入”Menu Title”才可以看到内部的两个config,它仅作为视觉上的分组,不存在配置值。
一个menu内可以有很多个config,或者menu或者menuconfig,这些都是可以嵌套的,当然存在最外层的Kconfig说明,在整个内核的根目录(main menu)
3、条件判断
直接举例:
1 2 3 4 5 6 7 8 menuconfig MODULE_FOO bool "Enable Foo Module" if MODULE_FOO config FOO_OPTION1 bool "Option 1" config FOO_OPTION2 int "Option 2" endif
这里我使用到了一个新的字叫menuconfig,与menu类似,这是一个带有子选项的配置项,其本身带有开启或关闭选项(bool或tristate类型,tristate类型比bool除了是否以外多了一个编译为ko文件的选项),以例子解释,他会生成一个下面的样子,直接按enter里面是空的,仅当前面的方括号内有*
即被选中才可以查看内部的选项:
1 [ ] Enable Foo Module --->
如图所示I2C support即为menu,而I3C support、SPI support等均为menuconfig,包含一个可选的前框和可进入的菜单。
4、多选一
为了解决有些条目的互斥,需要进行多选一,因此有特殊的关键字,看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 choice prompt "Select the CPU architecture" # 选择菜单的标题 default ARM # 默认选中的选项 depends on PLATFORM_X86_OR_ARM # 整个choice的依赖条件 config X86 bool "Intel x86 architecture" config ARM bool "ARM architecture" config RISCV bool "RISC-V architecture" endchoice
choice/endchoice
就是多选条目的开头和结束,在区域内的条目仅允许勾选一次
5、信息提示
这是一个可以打出环境变量的提示语句,用起来非常简单:
1 comment "SPI Master Controller Drivers"
比如这样,就会在终端打出一句话。
6、config
作为全Kconfig的灵魂所在,config的作用不容小觑,比如我们需要定义一个可供用户选择的编译项,我们可以写:
1 2 3 4 5 config GPU_OPENGL bool "OpenGL" default y help OpenGL
其中config后面跟随的为开关的名字,在经过解释器后将会变成头文件中的一个宏,记为:CONFIG_开关名,在这里就是CONFIG_GPU_OPENGL。
bool为类型,取值仅为y或n,当然还有其他类型:
类型 值 bool y/n tristate y/m/n string 字符串 hex 十六进制的数据 int 十进制的int数
help为帮助,当光标位于对应条目上按h
就可以看到提示信息。
7、组件依赖
我们的一个config可能无法满足所有内容,因此我们用到了依赖关系,如果我们勾选一个config,它有前置需要开启就只需要添加:
这样如果B没有勾选就去勾选A,则会被提示。
自定义菜单 我们直接在根目录创建一个just_kid文件夹,在里面新建一个Kconfig文件,然后在根目录的Kconfig添加just_kid下的Kconfig:
1 source "just_kid/Kconfig"
然后我们在just_kid的Kconfig内写入:
1 2 3 4 5 6 7 8 menu "just a kid" config KID_CONFIG bool "just kid hahaha" default y help you can learn Kconfig programmer comment "Kid comment" endmenu
然后我们进入menuconfig就可以看到我们添加这个条目了:
使用这个数值也很简单,我们的Kconfig编译后生成了一个对应的宏,我们可以直接给makefile传递,以I3C举例,这是他的Makefile:
1 2 3 4 i3c-y := device.o master.o obj-$(CONFIG_I3C) += i3c.o obj-$(CONFIG_I3C) += master/
其中他的Kconfig的config名为I3C,因此当I3C为y是直接编译进入内核,为m是编译为模块,为n是不编译。这些均在autoconf.h有所定义,但是我们一般不会导入此头文件,而是交给makefile进行取舍,当文件变成模块后则会定义一个新的宏,比如I3C,如果编译进入内核则多出:CONFIG_I3C
,如果编译成模块则多出:CONFIG_I3C_MODULE
。
字符设备 接下来这部分是linux内核开发的初步,我们学习linux内核的目的就是在内核的基础上进行二次开发,由于内核已经非常完善了,我们仅仅是在其基础上进行外设或驱动的开发,因此我们所有的开发其实都是设备的驱动开发,因此我们这一章就是在对外设进行开发。
设备号 我们在使用内核的操作系统的时候经常会查看/dev/
目录下的文件,这里面包含了所有设备的驱动,比如相机、串口、GPIO等,这些设备我们在使用的时候可以在C语言、python中采用文件的方法直接打开或写入,这是由于linux有一套虚拟文件系统,将所有外设都抽象为文件,对于内核的代码可以查看之前写过的这篇博客:
[4]开发一个Linux系统吧——VFS
所有的设备都是设备块,被inode所指向,而inode不仅仅是文件还是设备,因此我们要想声明一个自己的设备到/dev
目录下,我们也需要一个inode
,注意VFS的节点等级,node本身就保存在内存中,因此其空间管理较为繁琐,对于大块空间还存在超级块,这些并非我们的操作核心。
VFS核心源码位于:include\linux\fs.h
VFS的结构类似于双向链表,每个inode都存在一个super_node的指针用于索引,而每个inode又存在一个dev_t指向我们的字符设备,我们可以查看inode的具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 struct inode { umode_t i_mode; unsigned short i_opflags; kuid_t i_uid; kgid_t i_gid; const struct inode_operations *i_op ; struct super_block *i_sb ; dev_t i_rdev; loff_t i_size; union { const struct file_operations *i_fop ; void (*free_inode)(struct inode *); }; union { struct pipe_inode_info *i_pipe ; struct block_device *i_bdev ; struct cdev *i_cdev ; char *i_link; unsigned i_dir_seq; }; } __randomize_layout;
我们可以看到inode的实现非常丰富,包含了几乎所有的操作。在inode以后就是dev_t和cdev了,其中dev_t是一个u32类型的数字,表示为设备号,高12位为主设备号,低20位为次设备号。主设备号表示设备驱动的类型或者叫设备驱动的大类,而次设备号用于表示设备的具体示例,用于区分同一个驱动的多个不同设备实例。比如v4l2对摄像头设备的驱动,就会生成相同的主设备号:
他们的主设备号相同,次设备号从0开始递增排列,因此我们一个字符设备尽量只申请一个主设备号,多个次设备号。
设备号的申请分为动态申请和静态申请,动态申请由char_dev中的find_dynamic_major在设备链表内寻找一个没有被使用的主设备号:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static int find_dynamic_major (void ) { int i; struct char_device_struct *cd ; for (i = ARRAY_SIZE(chrdevs)-1 ; i >= CHRDEV_MAJOR_DYN_END; i--) { if (chrdevs[i] == NULL ) return i; } for (i = CHRDEV_MAJOR_DYN_EXT_START; i >= CHRDEV_MAJOR_DYN_EXT_END; i--) { for (cd = chrdevs[major_to_index(i)]; cd; cd = cd->next) if (cd->major == i) break ; if (cd == NULL ) return i; } return -EBUSY; }
而次设备号不会被查询,默认为0,当指定返回多个设备号时才会在返回多个主设备号的前提下申请多个次设备号,否则仅为0。我们从根本开始,在kdev_t.h存在一些关键的宏:
1 2 3 4 5 6 7 8 9 10 #define MINORBITS 20 #define MINORMASK ((1U << MINORBITS) - 1) #define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS)) #define MINOR(dev) ((unsigned int) ((dev) & MINORMASK)) #define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))
然后我们的注册静态设备号就存在这样的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 int register_chrdev_region (dev_t from, unsigned count, const char *name) { struct char_device_struct *cd ; dev_t to = from + count; dev_t n, next; for (n = from; n < to; n = next) { next = MKDEV(MAJOR(n)+1 , 0 ); if (next > to) next = to; cd = __register_chrdev_region(MAJOR(n), MINOR(n), next - n, name); if (IS_ERR(cd)) goto fail; } return 0 ; fail: to = n; for (n = from; n < to; n = next) { next = MKDEV(MAJOR(n)+1 , 0 ); kfree(__unregister_chrdev_region(MAJOR(n), MINOR(n), next - n)); } return PTR_ERR(cd); }
我们注意到一个非常关键的写法,内核出现报错并不会return,而是使用goto,这是由于一旦我们标记了一个区域的设备号出现问题后必须撤销操作,我们以前写代码直接return就是因为内核存在这种撤销操作的机制,否则我们不写的话内核也不会理会你的操作。
动态分配设备号的方法如下:
1 2 3 4 5 6 7 8 9 10 int alloc_chrdev_region (dev_t *dev, unsigned baseminor, unsigned count, const char *name) { struct char_device_struct *cd ; cd = __register_chrdev_region(0 , baseminor, count, name); if (IS_ERR(cd)) return PTR_ERR(cd); *dev = MKDEV(cd->major, cd->baseminor); return 0 ; }
此方法会返回一个没有被使用的设备号,linux判断指针是否有效也存在一个函数叫IS_ERR,原因是内核分页管理算法的最后一页就是给错误指针的位置,从0x ff ff ff ff ff ff f0 00~ 0x ff ff ff ff ff ff ff ff这段空间就是错误码,并进行一一对应。可以在errno-base.h查看到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #define EPERM 1 #define ENOENT 2 #define ESRCH 3 #define EINTR 4 #define EIO 5 #define ENXIO 6 #define E2BIG 7 #define ENOEXEC 8 #define EBADF 9 #define ECHILD 10 #define EAGAIN 11 #define ENOMEM 12 #define EACCES 13 #define EFAULT 14 #define ENOTBLK 15 #define EBUSY 16 #define EEXIST 17 #define EXDEV 18 #define ENODEV 19 #define ENOTDIR 20 #define EISDIR 21 #define EINVAL 22 #define ENFILE 23 #define EMFILE 24 #define ENOTTY 25 #define ETXTBSY 26 #define EFBIG 27 #define ENOSPC 28 #define ESPIPE 29 #define EROFS 30 #define EMLINK 31 #define EPIPE 32 #define EDOM 33 #define ERANGE 34
介绍完API后我们正式开始学习使用这些API。
动态与静态设备号申请 我们需要的API已经在上面详细介绍了,我们直接写出一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/kdev_t.h> static int major, minor = 0 ; module_param(major, int , S_IRUGO); module_param(minor, int , S_IRUGO);static dev_t dev_number;static int __init chrdev_init (void ) { int ret = 0 ; if (major) { dev_number = MKDEV(major, minor); printk(KERN_INFO "Using major %d, minor %d\n" , major, minor); printk(KERN_INFO "Device number: %d\n" , dev_number); ret = register_chrdev_region(dev_number, 1 , "chr_dev" ); if (ret < 0 ) { printk(KERN_INFO "Failed to register chrdev region: %d\n" , ret); return ret; } printk(KERN_INFO "chrdev region registered successfully\n" ); } else { ret = alloc_chrdev_region(&dev_number, minor, 1 , "chr_dev" ); if (ret < 0 ) printk(KERN_INFO "Failed to allocate chrdev region: %d\n" , ret); printk(KERN_INFO "chrdev region allocated successfully\n" ); major = MAJOR(dev_number); minor = MINOR(dev_number); printk(KERN_INFO "Using allocated major %d, minor %d\n" , major, minor); } return 0 ; }static void __exit chrdev_exit (void ) { unregister_chrdev_region(dev_number, 1 ); printk(KERN_INFO "chrdev region unregistered\n" ); } module_init(chrdev_init); module_exit(chrdev_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" );
我们写一个makefile直接编译出ko文件,加载进rk3588内,查看dmesg就可以看到下面的输出,这是我没有进行指定设备号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 obj-m := dev_t.o ARCH := arm64 CROSS_COMPILE := /mnt/g/rk3588-linux/prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-2021.07-x86_64-aarch64-none-linux-gnu/bin/aarch64-none-linux-gnu- ccflags-y := -std=gnu11 KERNEL_DIR := /mnt/g/rk3588-linux/kernelall: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modulesclean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
我们指定设备号就是传递一个内核参数即可,即加载内核时使用:
1 sudo insmod dev_t .ko major=314 minor=2
这样我们的dmesg输出就是:
我们可以看到device number,这是组合后的dev_t的值,也就是314 << 20 + 2的大小,经过计算器验算很容易知道这是对的。我们可以在系统进程的设备里面看到这个设备,输出cat /proc/devices
即可找到我们设备名
可以看到主设备号为314符合我们的设定。
设备功能函数 我们之前详细介绍了设备号的API,接下来介绍一下cdev.h,也就是字符设备绑定功能函数,我们申请字符设备就可以让我们创建的字符设备具有读写功能。总体的方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 struct cdev { struct kobject kobj ; struct module *owner ; const struct file_operations *ops ; struct list_head list ; dev_t dev; unsigned int count; } __randomize_layout;void cdev_init (struct cdev *, const struct file_operations *) ;struct cdev *cdev_alloc (void ) ;void cdev_put (struct cdev *p) ;int cdev_add (struct cdev *, dev_t , unsigned ) ;void cdev_set_parent (struct cdev *p, struct kobject *kobj) ;int cdev_device_add (struct cdev *cdev, struct device *dev) ;void cdev_device_del (struct cdev *cdev, struct device *dev) ;void cdev_del (struct cdev *) ;void cd_forget (struct inode *) ;
实现方法也很简单,我们在上一小节的代码进行修改即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/kdev_t.h> #include <linux/cdev.h> static dev_t dev_number;static struct cdev char_dev { .owner = THIS_MODULE };static int __init _cdev_init(void ) { int ret, major, minor; ret = alloc_chrdev_region(&dev_number, 0 , 1 , "chardev_name" ); if (ret < 0 ) { printk(KERN_EMERG "Failed to allocate char dev region\n" ); return ret; } major = MAJOR(dev_number); minor = MINOR(dev_number); printk(KERN_EMERG "major is %d\n" , major); printk(KERN_EMERG "minor is %d\n" , minor); cdev_init(&char_dev, NULL ); ret = cdev_add(&char_dev, dev_number, 1 ); if (ret < 0 ) { printk(KERN_EMERG "Failed to add cdev\n" ); unregister_chrdev_region(dev_number, 1 ); return ret; } printk(KERN_EMERG "char_dev initialized\n" ); return 0 ; }static void __exit _cdev_exit(void ) { cdev_del(&char_dev); unregister_chrdev_region(dev_number, 1 ); printk(KERN_EMERG "char_dev exited\n" ); } module_init(_cdev_init); module_exit(_cdev_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" ); MODULE_DESCRIPTION("A simple char device" );
编译ko文件并运行,dmesg输出:
这是我们使用指令cat /proc/devices
可以看到我们的设备已经出现:
创建设备节点 到目前为止,我们的设备均只存在于进程空间的设备驱动,我们无法进行外部调用,因此我们需要在/dev创建一个设备节点,在Linux中由于VFS的存在,创建设备节点需要创建一个文件,因此我们需要对inode进行基础的操作。
在class.h内实现了对设备节点程序创建的方法,我们仅需掌握两个宏:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 #define class_create(owner, name) \ ({ \ static struct lock_class_key __key; \ __class_create(owner, name, &__key); \ }) struct class *__class_create (struct module *owner , const char *name , struct lock_class_key *key ) { struct class *cls ; int retval; cls = kzalloc(sizeof (*cls), GFP_KERNEL); if (!cls) { retval = -ENOMEM; goto error; } cls->name = name; cls->owner = owner; cls->class_release = class_create_release; retval = __class_register(cls, key); if (retval) goto error; return cls; error: kfree(cls); return ERR_PTR(retval); }void class_destroy (struct class *cls) { if ((cls == NULL ) || (IS_ERR(cls))) return ; class_unregister(cls); }
class_create需要传递的参数为inode指向的模块,一般写作宏THIS_MODULE
,也就是一个特殊的汇编值__this_module。这里的方法用于创建设备的描述文件,创建设备需要在core.c内使用下面的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 struct device *device_create (struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...) { va_list vargs; struct device *dev ; va_start(vargs, fmt); dev = device_create_groups_vargs(class, parent, devt, drvdata, NULL , fmt, vargs); va_end(vargs); return dev; }void device_destroy (struct class *class, dev_t devt) { struct device *dev ; dev = class_find_device_by_devt(class, devt); if (dev) { put_device(dev); device_unregister(dev); } }
这里写了设备创建和销毁的方法,给定设备描述的class后就可以在/dev内创建出我们的设备了。
我们开始编写对应的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/kdev_t.h> #include <linux/cdev.h> static dev_t dev_number;struct cdev cdev_test ;struct file_operations cdev_opt = { .owner= THIS_MODULE, };struct class *cls_test ;static int __init chrcls_init (void ) { int ret, major, minor; ret = alloc_chrdev_region(&dev_number, 0 , 1 , "chardev_name" ); if (ret < 0 ) { printk(KERN_EMERG "Failed to allocate char dev region\n" ); return ret; } major = MAJOR(dev_number); minor = MINOR(dev_number); printk(KERN_EMERG "major is %d\n" , major); printk(KERN_EMERG "minor is %d\n" , minor); cdev_init(&cdev_test, NULL ); ret = cdev_add(&cdev_test, dev_number, 1 ); if (ret < 0 ) { printk(KERN_EMERG "cdev add error" ); } cls_test = class_create(THIS_MODULE, "char_cls" ); device_create(cls_test, NULL , dev_number, NULL , "cls_device_test" ); return 0 ; }static void __exit chrcls_exit (void ) { cdev_del(&cdev_test); unregister_chrdev_region(dev_number, 1 ); device_destroy(cls_test, dev_number); class_destroy(cls_test); printk("moduleexit\n" ); } module_init(chrcls_init); module_exit(chrcls_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" );
我们编译运行可以看到:
在/dev和/sys/class均出现了我们的设备节点和设备描述。到目前为止我们就正式的创建出了我们的设备节点。
驱动框架 在之前我们已经写过了file_operations
结构体的声明,但是我们还没有正式使用此结构体,此结构体内包含了作为一个设备驱动的所有函数,包括读写等,可以查看下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 struct file_operations { struct module *owner ; loff_t (*llseek) (struct file *, loff_t , int ); ssize_t (*read) (struct file *, char __user *, size_t , loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t , loff_t *); ssize_t (*read_iter) (struct kiocb *, struct iov_iter *); ssize_t (*write_iter) (struct kiocb *, struct iov_iter *); int (*iopoll)(struct kiocb *kiocb, bool spin); int (*iterate) (struct file *, struct dir_context *); int (*iterate_shared) (struct file *, struct dir_context *); __poll_t (*poll) (struct file *, struct poll_table_struct *); long (*unlocked_ioctl) (struct file *, unsigned int , unsigned long ); long (*compat_ioctl) (struct file *, unsigned int , unsigned long ); int (*mmap) (struct file *, struct vm_area_struct *); unsigned long mmap_supported_flags; int (*open) (struct inode *, struct file *); int (*flush) (struct file *, fl_owner_t id); int (*release) (struct inode *, struct file *); int (*fsync) (struct file *, loff_t , loff_t , int datasync); int (*fasync) (int , struct file *, int ); int (*lock) (struct file *, int , struct file_lock *); ssize_t (*sendpage) (struct file *, struct page *, int , size_t , loff_t *, int ); unsigned long (*get_unmapped_area) (struct file *, unsigned long , unsigned long , unsigned long , unsigned long ) ; int (*check_flags)(int ); int (*flock) (struct file *, int , struct file_lock *); ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t , unsigned int ); ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t , unsigned int ); int (*setlease)(struct file *, long , struct file_lock **, void **); long (*fallocate)(struct file *file, int mode, loff_t offset, loff_t len); void (*show_fdinfo)(struct seq_file *m, struct file *f);#ifndef CONFIG_MMU unsigned (*mmap_capabilities)(struct file *);#endif ssize_t (*copy_file_range)(struct file *, loff_t , struct file *, loff_t , size_t , unsigned int ); loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in, struct file *file_out, loff_t pos_out, loff_t len, unsigned int remap_flags); int (*fadvise)(struct file *, loff_t , loff_t , int ); ANDROID_KABI_RESERVE(1 ); ANDROID_KABI_RESERVE(2 ); ANDROID_KABI_RESERVE(3 ); ANDROID_KABI_RESERVE(4 ); } __randomize_layout;
这里面的函数与用户态的函数存在对应关系,比如open或read,均是对设备的读写操作等,我们这部分就是利用file_operations完整我们的字符设备的读写功能。
设备在用户空间的声明周期为从打开开始,直到释放,因此我们最需要的就是设备的打开和释放功能,同时我们也实现一下设备的读写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/kdev_t.h> #include <linux/cdev.h> static int chrdev_open (struct inode *inode, struct file *file) { printk(KERN_EMERG "chrdev_open called\n" ); return 0 ; }static ssize_t chrdev_read (struct file *file, char __user *buf, size_t count, loff_t *offset) { char kbuf[32 ] = "test read data" ; if (copy_to_user(buf, kbuf, count) != 0 ) { printk(KERN_ERR "Failed to copy data to user space\n" ); return -EFAULT; } printk(KERN_EMERG "chrdev_read: %s\n" , kbuf); return 0 ; }static ssize_t chrdev_write (struct file *file, const char __user *buf, size_t count, loff_t *offset) { char kbuf[32 ] = {0 }; if (copy_from_user(kbuf, buf, count) != 0 ) { printk(KERN_ERR "Failed to copy data from user space\n" ); return -EFAULT; } printk(KERN_EMERG "chrdev_write: %s\n" , kbuf); printk(KERN_EMERG "chrdev_write called\n" ); return count; }static int chrdev_release (struct inode *inode, struct file *file) { printk(KERN_EMERG "chrdev_release called\n" ); return 0 ; }static struct file_operations chrdev_fops = { .owner = THIS_MODULE, .open = chrdev_open, .read = chrdev_read, .write = chrdev_write, .release = chrdev_release, };static dev_t dev_number;static struct cdev chrdev_cdev ;static struct class *chrdev_class ;static int __init chrdev_fops_init (void ) { int ret, minor, major; ret = alloc_chrdev_region(&dev_number, 0 , 1 , "chrdev" ); if (ret < 0 ) { printk(KERN_ERR "Failed to allocate char device region\n" ); return ret; } major = MAJOR(dev_number); minor = MINOR(dev_number); printk(KERN_ERR "Allocated char device with major: %d, minor: %d\n" , major, minor); cdev_init(&chrdev_cdev, &chrdev_fops); ret = cdev_add(&chrdev_cdev, dev_number, 1 ); if (ret < 0 ) { printk(KERN_ERR "Failed to add char device\n" ); unregister_chrdev_region(dev_number, 1 ); return ret; } chrdev_class = class_create(THIS_MODULE, "chrdev" ); if (IS_ERR(chrdev_class)) { printk(KERN_ERR "Failed to create class\n" ); cdev_del(&chrdev_cdev); unregister_chrdev_region(dev_number, 1 ); return PTR_ERR(chrdev_class); } device_create(chrdev_class, NULL , dev_number, NULL , "chrdev" ); return 0 ; }static void __exit chrdev_fops_exit (void ) { device_destroy(chrdev_class, dev_number); class_destroy(chrdev_class); cdev_del(&chrdev_cdev); unregister_chrdev_region(dev_number, 1 ); } module_init(chrdev_fops_init); module_exit(chrdev_fops_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" );
这里我用到了两个函数分别为copy_to_user
和copy_from_user
,正如字面意思是从用户态拷贝数据和传递数据,原因是内核与用户之间存在隔离,因此需要这两个函数作为桥梁进行数据沟通:
1 2 copy_to_user(用户态数组, 内核态数组, 数据长度); copy_from_user(内核态数组, 用户态数组, 数据长度);
写完这个代码编译后我们可以在发现出现了一个文件叫:/dev/chrdev
然后我们写一个用户态的代码,在rk3588上编辑一个叫app.c的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main (int argc, char *argv[]) { int fd; char buf[32 ]; fd = open(argv[1 ], O_RDWR, 0666 ); if (fd < 0 ) { perror("Failed to open device" ); return 1 ; } if (!strcmp (argv[2 ], "read" )) { ssize_t ret = read(fd, buf, sizeof (buf)); if (ret < 0 ) { perror("Failed to read from device" ); close(fd); return 1 ; } printf ("Read from device: %s\n" , buf); } else if (!strcmp (argv[2 ], "write" )) { const char *msg = "Hello, Device!" ; ssize_t ret = write(fd, msg, strlen (msg)); if (ret < 0 ) { perror("Failed to write to device" ); close(fd); return 1 ; } printf ("Wrote to device: %s\n" , msg); } else { fprintf (stderr , "Unknown operation: %s\n" , argv[2 ]); close(fd); return 1 ; } close(fd); return 0 ; }
然后我们在rk3588上使用下面的指令进行编译:
我们编译出的app需要有两个参数,第一个参数是设备的绝对路径,第二个参数为对设备的操作,比如读或者写,因此我们可以使用下面的两条指令分别测试:
1 2 sudo ./app /dev/chrdev read sudo ./app /dev/chrdev write
可以看到我们的用户态和内核态均有输出,并且数据传输正常,这样我们就完成了驱动框架的使用。
面向对象的设备驱动 我们都知道Linux内核可以说是C语言之祖,现代编程语言都有Linux内核的影子,我们在C内核开发时也可以体现出面向对象和私有属性的特点,因此我们在多设备驱动时可以实现一个类 ,让多设备共用一套属性,并利用file结构体自带的private_data进行数据私有化。
首先介绍一个宏:
1 2 3 4 5 6 7 #define container_of(ptr, type, member) ({ \ void *__mptr = (void *)(ptr); \ BUILD_BUG_ON_MSG(!__same_type(*(ptr), ((type *)0)->member) && \ !__same_type(*(ptr), void), \ "pointer type mismatch in container_of()" ); \ ((type *)(__mptr - offsetof(type, member))); })
这个宏是从一个结构体成员指针ptr,结合数据类型反推出整个结构体的位置。三个参数分别为:
ptr
:指向结构体成员的指针。type
:结构体的类型。member
:结构体中成员的名称。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/kdev_t.h> #include <linux/cdev.h> struct adapt_device { dev_t dev_number; int major, minor; struct cdev chr_dev ; struct class *cls ; struct device *dev ; char kbuf[32 ]; };typedef struct adapt_device adapt_device_t ;static adapt_device_t dev1, dev2;static int _cdev_open(struct inode *inode, struct file *f) { f->private_data = container_of(inode->i_cdev, adapt_device_t , chr_dev); printk(KERN_INFO "cdev_open: minor=%d\n" , MINOR(inode->i_rdev)); return 0 ; }static ssize_t _cdev_write(struct file *f, const char __user *buf, size_t size, loff_t *off) { adapt_device_t *dev = f->private_data; if (copy_from_user(dev->kbuf, buf, size) != 0 ) { printk(KERN_ERR "cdev_write: copy_from_user failed\n" ); return -EFAULT; } printk(KERN_INFO "cdev_write: minor=%d, data=%s\n" , dev->minor, dev->kbuf); return size; }static ssize_t _cdev_read(struct file *f, char __user *buf, size_t size, loff_t *off) { adapt_device_t *dev = f->private_data; if (copy_to_user(buf, dev->kbuf, size) != 0 ) { printk(KERN_ERR "cdev_read: copy_to_user failed\n" ); return -EFAULT; } printk(KERN_INFO "cdev_read: minor=%d, data=%s\n" , dev->minor, dev->kbuf); return size; }static int _cdev_release(struct inode *inode, struct file *f) { printk(KERN_INFO "cdev_release: minor=%d\n" , MINOR(inode->i_rdev)); return 0 ; }static struct file_operations _cdev_fopts = { .owner = THIS_MODULE, .open = _cdev_open, .write = _cdev_write, .read = _cdev_read, .release = _cdev_release, };static int __init _cdev_init(void ) { int ret; ret = alloc_chrdev_region(&dev1.dev_number, 0 , 2 , "mul_chrdev" ); if (ret < 0 ) { printk(KERN_ERR "alloc_chrdev_region failed\n" ); return ret; } dev1.major = MAJOR(dev1.dev_number); dev1.minor = MINOR(dev1.dev_number); printk(KERN_ERR "Driver1 loaded with major=%d, minor=%d\n" , dev1.major, dev1.minor); dev1.chr_dev.owner = THIS_MODULE; cdev_init(&dev1.chr_dev, &_cdev_fopts); cdev_add(&dev1.chr_dev, dev1.dev_number, 1 ); dev1.cls = class_create(THIS_MODULE, "mul_chrdev0" ); dev1.dev = device_create(dev1.cls, NULL , dev1.dev_number, NULL , "mul_chrdev%d" , dev1.minor); dev2.major = MAJOR(dev2.dev_number + 1 ); dev2.minor = MINOR(dev2.dev_number + 1 ); printk(KERN_ERR "Driver2 loaded with major=%d, minor=%d\n" , dev2.major, dev2.minor); dev2.chr_dev.owner = THIS_MODULE; cdev_init(&dev2.chr_dev, &_cdev_fopts); cdev_add(&dev2.chr_dev, dev2.dev_number, 1 ); dev2.cls = class_create(THIS_MODULE, "mul_chrdev1" ); dev2.dev = device_create(dev2.cls, NULL , dev2.dev_number, NULL , "mul_chrdev%d" , dev2.minor); return 0 ; }static void __exit _cdev_exit(void ) { device_destroy(dev1.cls, dev2.dev_number); device_destroy(dev1.cls, dev1.dev_number); cdev_del(&dev2.chr_dev); cdev_del(&dev1.chr_dev); class_destroy(dev1.cls); class_destroy(dev2.cls); unregister_chrdev_region(dev1.dev_number, 2 ); printk(KERN_INFO "Driver unloaded\n" ); } module_init(_cdev_init); module_exit(_cdev_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" ); MODULE_DESCRIPTION("Multi Character Device Driver" );
这个私有化的过程如下::
用户打开设备文件 内核不知道打开的是哪个文件也没有相关参数传递,因此内核需要从用户传递的设备文件中获取保存的设备结构体adapt_device,并将整个结构体保存在传递的设备文件的私有空间内 当读取时,只需要打开设备文件的私有空间,转化为adapt_device结构体指针,进行读取即可 写入和释放同理 这样我们再实现一个app.c,同时打开这两个设备,分别写入数据到两个不同的设备中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main (int argc, char *argv[]) { int fd1, fd2; char buf1[32 ] = "Hi /dev/mul_chrdev0" ; char buf2[32 ] = "Hi /dev/mul_chrdev1" ; fd1 = open("/dev/mul_chrdev1" , O_RDWR); if (fd1 < 0 ) { perror("Failed to open /dev/mul_chrdev0" ); return 1 ; } write(fd1, buf1, sizeof (buf1)); close(fd1); printf ("=======================\n" ); fd2 = open("/dev/mul_chrdev2" , O_RDWR); if (fd2 < 0 ) { perror("Failed to open /dev/mul_chrdev1" ); close(fd1); return 1 ; } write(fd2, buf2, sizeof (buf2)); close(fd2); return 0 ; }
编译运行可以看到下面的内容:
截至目前,我们就以及完全学完字符设备的开发了。
寄存器控制GPIO 这部分我们进行GPIO控制,我们选择从寄存器开始,因此我们需要选择一个瑞芯微的数据手册名为:Rockchip RK3588 TRM 以及你开发板的原理图,首先我需要控制一个GPIO,因此我需要选择一个GPIO,在讯为的板子上仅开放了5个GPIO。
在讯为的板子上,这些接口分别接在下表中:
GPIO想要控制必须要有复用寄存器 、方向寄存器 和数据寄存器 进行配置。为了与原教程进行区分,我们采用GPIO2_C3_d这个IO口,也就是I2S2_SDI_M0_BT。
复用寄存器
首先我们需要查询数据手册,找到BUS_IOC功能(总线IO控制器),在其中找到GPIO2C的控制地址。
我们在数据手册的通用寄存器栏目找到 BUS_IOC Register Description,就可以找到这个寄存器的地址,其中分为高低基地址,然后我们往下找,寻找到gpio2c3_sel这个描述:
我们可以看到此偏移地址为0x0054,对应在BUS_IOC_GPIO2C_IOMUX_SEL_H寄存器上,所以我们记这个偏移地址0x54。
在数据手册的第一张第一部分有一个Address Mapping,寻找BUS_IOC所处的地址:
因此我们的基地址为0xfd 5f 80 00,所以此复用寄存器的地址为:基地址+偏移地址=0xfd 5f 80 00 + 0x50= 0xfd 5f 80 50。到这里我们就找到了GPIO2_C3这个寄存器所在的位置,我们可以看到在描述这个寄存器的地址是[15:12],因此我们需要控制这几位的值选择对应的功能:
数值 功能 0 GPIO 1 ETH0_REFCLKO_25M 2 I2S2_SDI_M0 8 SPI1_CS0_M0 9 I2C6_SCL_M2
方向寄存器
首先我们需要直到瑞芯微的IO命名规则,对rk系列IO命名为GPIO控制器、端口号、索引号。其中控制器有4个,分别为GPIO1、GPIO2、GPIO3、GPIO4。端口号有四个分别为ABCD,索引号每个端口均有8个,分别为0~7.
打开GPIO部分的 Registers Summary,我们需要方向寄存器,其中端口控制器为我们的基地址,我们需要组合端口号和索引号获取方向寄存器的偏移地址,AB组对应的端口为低方向,CD组对应为高方向。因此我们需要GPIO_SWPORT_DDR_H。
然后我们查看GPIO_SWPORT_DDR_H的描述,高16位为写入,低16位为读取
其中位次对应位模式,[31:16]为WO模式,也就是仅写入,这里是写标志位,无法单独使用。低16为位为读写控制。如果低16位中某一位要设置输入输出的话,那么对应的高位也需要设置对应的功能。举个例子,我们要控制c3为输出模式,那么我们第三位就需要设置为1,同时高16位的第16+3位也需要设置为1。
然后我们需要找到GPIO2的基地址:
这样我们就得到GPIO2_的CD组地址为:0xfe c3 00 00 + 0x00 0c = 0xfe c3 00 0c,然后我们只需要操作第三位和第十九位就可以设置GPIO2_C3的模式。
数据寄存器
数据寄存器我们依然选用高位的,比如我们需要让gpio2_c3输出一个高电平,与方向寄存器相同只需要让高16复制低16的状态即可,如果我们要让gpio2_c3输出高电平只需要写入1即可,可以看下面的计算器截图:
让第3位和第19位为1即可,原因很简单
高16位是写标志位,只有对应的位置1才可以写入,或者说输出电平。反之如果输出低电平可以写:
最终我们可以总结一下:
复用寄存器地址:基地址0xfd 5f 80 00,偏移地址0x00 54,操作地址0xfd 5f 80 54
GPIO方向寄存器地址:基地址0xfe c3 00 00,偏移地址0x00 0c,操作地址为0xfe c3 00 0c
数据值:输出模式0x0008 0008,输入模式:0x0000 0000
GPIO数据寄存器地址:基地址0xfe c3 00 00,偏移地址0x00 04,操作地址为0xfe c3 00 04
数据值:高电平0x0008 0008,低电平:0x0008 0000
当GPIO的方向位于输入时,数据寄存器对应的低16位就是IO的电平,因此我们还可以实现读取电压的功能,注意内核的地址需要利用ioremap
函数进行重映射到寄存器中,然后我们写一下代码就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 #include <linux/init.h> #include <linux/module.h> #include <linux/kdev_t.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #include <linux/io.h> #define GPIO_DR 0xFEC30004 #define GPIO_DDR 0xFEC3000C struct adapt_device { dev_t dev_number; int major, minor; struct cdev chr_dev ; struct class *cls ; struct device *dev ; char kbuf[32 ]; unsigned int *gpio_dir; };typedef struct adapt_device adapt_device_t ;adapt_device_t adapt_dev;static int _cdev_open(struct inode *inode, struct file *f) { f->private_data = container_of(inode->i_cdev, adapt_device_t , chr_dev); printk(KERN_INFO "cdev_open: minor=%d\n" , MINOR(inode->i_rdev)); return 0 ; }static ssize_t _cdev_write(struct file *f, const char __user *buf, size_t size, loff_t *off) { adapt_device_t * dev = (adapt_device_t * )f->private_data; *((unsigned int *) (ioremap(GPIO_DDR, 4 ))) = 0x80008 ; if (copy_from_user(dev->kbuf, buf, size) != 0 ) { printk(KERN_ERR "cdev_write: copy_from_user failed\n" ); return -EFAULT; } if (dev->kbuf[0 ] == 1 ) { *(dev->gpio_dir) = 0x80008 ; printk(KERN_ERR "bufer is %d\n" , dev->kbuf[0 ]); } else if (dev->kbuf[0 ] == 0 ) { *(dev->gpio_dir) = 0x80000 ; printk(KERN_ERR "bufer is %d\n" , dev->kbuf[0 ]); } return size; }static ssize_t _cdev_read(struct file *f, char __user *buf, size_t size, loff_t *off) { adapt_device_t * dev = (adapt_device_t *)f->private_data; *((unsigned int *) (ioremap(GPIO_DDR, 4 ))) = 0x00000000 ; dev->kbuf[0 ] = (*(dev->gpio_dir) >> 3 ) & 0x01 ; if (copy_to_user(buf, dev->kbuf, size) != 0 ) { printk(KERN_ERR "cdev_read: copy_to_user failed\n" ); return -EFAULT; } printk(KERN_INFO "cdev_read: minor=%d, data=%s\n" , dev->minor, dev->kbuf); return size; }static int _cdev_release(struct inode *inode, struct file *f) { printk(KERN_INFO "cdev_release: minor=%d\n" , MINOR(inode->i_rdev)); return 0 ; }static struct file_operations adapt_fops = { .owner = THIS_MODULE, .open = _cdev_open, .write = _cdev_write, .read = _cdev_read, .release = _cdev_release, };static int __init chr_fops_init (void ) { int ret; ret = alloc_chrdev_region(&adapt_dev.dev_number, 0 , 1 , "adapt_gpio" ); if (ret < 0 ) { printk(KERN_ERR "chr_fops_init: register_chrdev_region failed\n" ); return ret; } adapt_dev.chr_dev.owner = THIS_MODULE; cdev_init(&adapt_dev.chr_dev, &adapt_fops); ret = cdev_add(&adapt_dev.chr_dev, adapt_dev.dev_number, 1 ); if (ret < 0 ) { printk(KERN_ERR "chr_fops_init: cdev_add failed\n" ); unregister_chrdev_region(adapt_dev.dev_number, 1 ); return ret; } adapt_dev.cls = class_create(THIS_MODULE, "adapt_gpio" ); if (IS_ERR(adapt_dev.cls)) { printk(KERN_ERR "chr_fops_init: class_create failed\n" ); cdev_del(&adapt_dev.chr_dev); unregister_chrdev_region(adapt_dev.dev_number, 1 ); return PTR_ERR(adapt_dev.cls); } adapt_dev.dev = device_create(adapt_dev.cls, NULL , adapt_dev.dev_number, NULL , "adapt_gpio" ); if (IS_ERR(adapt_dev.dev)) { printk(KERN_ERR "chr_fops_init: device_create failed\n" ); class_destroy(adapt_dev.cls); cdev_del(&adapt_dev.chr_dev); unregister_chrdev_region(adapt_dev.dev_number, 1 ); return PTR_ERR(adapt_dev.dev); } adapt_dev.gpio_dir = ioremap(GPIO_DDR, 4 ); if (IS_ERR(adapt_dev.gpio_dir)) { printk(KERN_ERR "chr_fops_init: ioremap failed\n" ); device_destroy(adapt_dev.cls, adapt_dev.dev_number); class_destroy(adapt_dev.cls); cdev_del(&adapt_dev.chr_dev); unregister_chrdev_region(adapt_dev.dev_number, 1 ); return PTR_ERR(adapt_dev.gpio_dir); } return 0 ; }static void __exit chr_fops_exit (void ) { *((unsigned int *) (ioremap(GPIO_DDR, 4 ))) = 0x00 ; iounmap(adapt_dev.gpio_dir); device_destroy(adapt_dev.cls, adapt_dev.dev_number); class_destroy(adapt_dev.cls); cdev_del(&adapt_dev.chr_dev); unregister_chrdev_region(adapt_dev.dev_number, 1 ); } module_init(chr_fops_init); module_exit(chr_fops_exit); MODULE_LICENSE("GPL" ); MODULE_AUTHOR("Minloha" ); MODULE_DESCRIPTION("GPIO Control Driver" );
读写代码如例子所示,看起来非常的简单,然后我们写一个用户空间的IO读写功能用于测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int main (int argc, char *argv[]) { int fd; char buf[32 ] = {0 }; fd = open("/dev/adapt_gpio" , O_RDWR); if (fd < 0 ) { perror("Failed to open /dev/adapt_gpio" ); return 1 ; } buf[0 ] = atoi(argv[1 ]); write(fd, buf, sizeof (buf)); buf[0 ] = 0 ; read(fd, buf, sizeof (buf)); printf ("IO value: " , buf[0 ]); close(fd); return 0 ; }
我们可以添加一个gpiorock的驱动用于验证我们的IO输出电平,使用:
1 2 3 4 // 查看IO的输出方向cat /sys/class/gpio/gpio83/direction // 查看IO的电压cat /sys/class/gpio/gpio83/value
为什么是GPIO83?这里用到了一个全局编号的计算方法,即:全局编号=端口号×32+组号×8+位号。
我们的GPIO2_C3的端口号为2,组号为C(A=0, B=1, C=2, D=3),位号为3,计算一下就得到:
2×32+2×8+3=64+16+3=83
然后我们分别编译运行,查看一下输出值:
这样我们掌握了如何利用寄存器完成对外设的开发。
总结 本期博客难度较大,建议读者认真学,多操作。如果喜欢博客的话可以分享出去。
文档与资料
寄存器:https://gitee.com/flying-cat/rk3588-TRM-and-Datasheet