Linux驱动---设备驱动模型
一、简介
在上一篇文章《字符设备》中,我们学习了驱动开发的基础框架。掌握了这个框架后,我们只需要添加对应的代码就可以开发驱动。不过,这种方式存在一个问题:如果我们将硬件的信息都写进驱动代码中,那么每次硬件的引脚接口发生变化,驱动代码就需要重新修改,这显然是不合理的。
那么,有没有合适的解决方案呢?答案是肯定的,Linux引入了设备驱动模型分层的概念, 将我们编写的驱动代码分成了两块:设备与驱动。
- 设备负责提供硬件资源;
- 驱动代码负责去使用这些设备提供的硬件资;
- 总线将它们联系起来;
这样子就构成以下图形中的关系。
针对Linux下的这种分层理念,我们在学习驱动模型之前要先了解以下几个概念:
- 设备(device):挂载在某个总线上的物理设备;
- 驱动(driver):与特定设备相关的软件,负责初始化该设备以及提供一些操作该设备的操作方式;
- 总线(bus):负责管理挂载对应总线的设备以及驱动;
- 类(class):对于具有相同功能的设备,归结到一种类别,进行分类管理;
二、驱动模型
2.1、总线
通过上面的图我们也可以看出来,在Linux的设备驱动模型中,总线是整个模型的核心,负责将设备和驱动联系起来。
驱动模型中的总线可以是真实存在的物理总线(USB总线、I2C总线等),也可以是为了驱动模型架构设计出的虚拟总线(Platform总线)。为此Linux设备驱动模型都将围绕“总线-设备-驱动”来展开,因为符合Linux设备驱动模型的设备与驱动都是必须挂载在一个总线上的。
每条总线管理着两条链表---设备链表和驱动链表。设备链表用于管理该总线上所有的设备实例,驱动链表用于管理该总线上所有已注册的驱动程序。
当我们向系统的某条总线上注册一个驱动时,它会向Linux内核里该总线的驱动链表中插入我们的新驱动;同样,当我们向系统总线上接入一个新设备时,也会向该总线的设备链表中插入我们的新设备。在插入的同时该总线的核心层会执行bus_type
结构体中的match()
方法对新插入的设备/驱动进行匹配,如USB总线通过PID(产品ID)和VID(厂商ID)来匹配,而Platform总线则通过compatible
字符串进行匹配。
这样,在Linux内核中无论是设备先出现还是驱动先出现,总能彼此找到对方。当Linux内核的总线核心层在匹配成功后,将会调用device_driver
结构体中probe
函数,并且在移出设备或驱动时,会调用device_driver
结构体中remove
函数,分别完成驱动的注册和移除工作。
最后,我们再来看一下总线在内核中的表现方式---bus_type结构体
(linux/device.h)
struct bus_type {
const char *name;
const struct attribute_group **bus_groups; // 为bus目录创建属性
const struct attribute_group **dev_groups; // 为device目录创建属性
const struct attribute_group **drv_groups; // 为driver目录创建属性
int (*match)(struct device *dev, struct device_driver *drv); // 匹配函数
int (*uevent)(struct device *dev, struct kobj_uevent_env *env);
int (*probe)(struct device *dev);
int (*remove)(struct device *dev);
int (*suspend)(struct device *dev, pm_message_t state);
int (*resume)(struct device *dev);
const struct dev_pm_ops *pm;
struct subsys_private *p; // 私有数据
};
- name :指定总线的名称,当新注册一种总线类型时,会在/sys/bus目录创建一个新的目录,目录名就是该参数的值;
- drv_groups、dev_groups、bus_groups :分别表示驱动、设备以及总线的属性;
- match :当向总线注册一个新的设备或者是新的驱动时,会调用该回调函数。该回调函数主要负责判断是否有注册了的驱动适合新的设备,或者新的驱动能否驱动总线上已注册但没有驱动匹配的设备;
- uevent :总线上的设备发生添加、移除或者其它动作时,就会调用该函数,来通知驱动做出相应的对策;
- probe :当总线将设备以及驱动相匹配之后,执行该回调函数,最终会调用驱动提供的probe函数;
- remove :当设备从总线移除时,调用该回调函数;
- p :该结构体用于存放特定的私有数据,其成员klist_devices和klist_drivers记录了挂载在该总线的设备和驱动;
在实际编写linux驱动模块时,Linux内核已经为我们写好了大部分总线驱动,正常情况下我们一般不会去注册一个新的总线, 内核中提供了bus_register
函数来注册总线,以及bus_unregister
函数来注销总线,其函数原型如下:
int bus_register(struct bus_type *bus); //成功返回0,失败返回负数
void bus_unregister(struct bus_type *bus);
当我们成功注册总线时,会在/sys/bus/目录下创建一个新目录,目录名为我们新注册的总线名。bus目录中包含了当前系统中已经注册了的所有总线,例如i2c,spi,platform等。我们看到每个总线目录都拥有两个子目录devices和drivers, 分别记录着挂载在该总线的所有设备以及驱动。
2.2、设备
在Linux中,一切都是以文件的形式存在, 设备也不例外。/sys/devices目录记录了系统中所有设备,实际上在sys目录下所有设备文件最终都会指向该目录对应的设备文件。
然后我们再来看一下设备在内核中的表现方式---device结构体
(linux/device.h)
struct device {
const char *init_name;
struct device *parent;
struct kobject kobj;
struct bus_type *bus;
struct device_driver *driver;
void *platform_data;
void *driver_data;
struct device_node *of_node;
dev_t devt;
struct class *class;
void (*release)(struct device *dev);
const struct attribute_group **groups; /* optional groups */
struct device_private *p;
};
- init_name:指定该设备的名称,总线匹配时,一般会根据比较名字来进行配对;
- parent:表示该设备的父对象;
- bus:表示该设备依赖于哪个总线,当我们注册设备时,内核便会将该设备注册到对应的总线;
- of_node :存放设备树中匹配的设备节点。当内核使能设备树,总线负责将驱动的of_match_table以及设备树的compatible属性进行比较之后,将匹配的节点保存到该变量;
- driver_data:驱动层可通过dev_set/get_drvdata函数来获取该成员;
- class :指向了该设备对应类,我们可以在/sys/class目录下对应的类找到该设备,如input、leds、pwm等目录;
- release:回调函数,当设备被注销时,会调用该函数。如果我们没定义该函数时,移除设备时,会提示“Device ‘xxxx’ does not have a release() function, it is broken and must be fixed”的错误;
- group:指向struct attribute_group类型的指针,指定该设备的属性;
内核也提供相关的API来注册和注销设备,如下所示:
int device_register(struct device *dev); //成功返回0,失败返回负数
void device_unregister(struct device *dev);
2.3、驱动
设备能否正常工作,取决于驱动。驱动需要告诉内核, 自己可以驱动哪些设备,如何初始化设备。接下来,我们再来看一下驱动在内核中的表示方式---device_driver结构体
(linux/device.h)
struct device_driver {
const char *name;
struct bus_type *bus;
struct module *owner;
const char *mod_name; /* used for built-in modules */
bool suppress_bind_attrs; /* disables bind/unbind via sysfs */
const struct of_device_id *of_match_table;
const struct acpi_device_id *acpi_match_table;
int (*probe) (struct device *dev);
int (*remove) (struct device *dev);
const struct attribute_group **groups;
struct driver_private *p;
};
- name :指定驱动名称,总线进行匹配时,利用该成员与设备名进行比较;
- bus :表示该驱动依赖于哪个总线,内核需要保证在驱动执行之前,对应的总线能够正常工作;
- owner :表示该驱动的拥有者,一般设置为THIS_MODULE;
- suppress_bind_attrs:布尔量,用于指定是否通过sysfs导出bind与unbind文件,bind与unbind文件是驱动用于绑定/解绑关联的设备;
- of_match_table :指定该驱动支持的设备类型。当内核使能设备树时,会利用该成员与设备树中的compatible属性进行比较;
- remove :当设备从操作系统中拔出或者是系统重启时,会调用该回调函数;
- probe :当驱动以及设备匹配后,会执行该回调函数,对设备进行初始化。通常的代码,都是以main函数开始执行的,但是在内核的驱动代码,都是从probe函数开始的;
- group :指向struct attribute_group类型的指针,指定该驱动的属性;
内核提供了函数来注册/注销驱动,成功注册的驱动会记录在/sys/bus/
int driver_register(struct device_driver *drv); //成功返回0,失败返回负数
void driver_unregister(struct device_driver *drv);
三、设备树
3.1、设备树简介
上面我们一直在强调的就是设备驱动分层,所以为了将设备和驱动剥离开来,社区引入了设备树(Device Tree)这种数据结构。它用来描述硬件设备的信息,特别是硬件设备与操作系统如何交互的方式。设备树的作用有:
- 描述硬件布局:设备树可以描述CPU、内存、外设、GPIO等硬件资源的位置和属性;
- 硬件与驱动分离:设备树将硬件信息从内核代码中分离出去,使得内核能够更加通用,不依赖特定硬件配置;
- 动态配置:设备树支持热插拔设备和动态硬件配置,内核可以在启动时解析设备树来获取硬件信息;
设备树通常由设备树源文件(.dts文件)和编译后的设备树二进制文件(.dtb文件)组成,它在描述硬件资源时有两个特点:
- 树的主干就是系统总线,在设备树里面称为“根节点”。IIC控制器、GPIO控制器、SPI控制器等都是接到系统主线上的分支,在设备树里称为“根节点的子节点”;
- 设备树可以像头文件(.h文件)那样,一个设备树文件引用另一个设备树文件,这样可以实现“代码”的重用。例如多个硬件平台都使用I.MX6ULL作为主控芯片,那么我们可以将I.MX6ULL芯片的硬件资源写到一个单独的设备树文件里面(一般使用.dtsi后缀),其他设备树文件直接使用
#include xxx
引用即可;
3.2、设备树格式
我们已经了解了设备树是什么,接下来,我们再来一起看一看设备树长什么样。在Linux内核源码中,设备树文件存放在arch/arm/boot/dts
路径中,以我使用的NXP IMX6ULL开发板为例,查看对应的igbboard-imx6ull.dts
文件,内容如下:
点击查看代码
#include "imx6ull.dtsi" /*头文件*/
/*设备树根节点*/
/ {
model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL"; /*model属性,用于指定设备的制造商和型号*/
compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull"; /*compatible属性,系统用来决定绑定到设备驱动的关键,用来查找节点的方法之一*/
/*根节点的子节点*/
chosen {
stdout-path = &uart1;
};
/*根节点的子节点*/
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x20000000>;
};
/*根节点的子节点*/
reserved-memory {
#address-cells = <1>;
#size-cells = <1>;
ranges;
linux,cma {
compatible = "shared-dma-pool";
reusable;
size = <0xa000000>;
linux,cma-default;
};
};
/*根节点的子节点*/
leds {
compatible = "gpio-leds";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_gpio_leds>;
status = "okay";
sysled {
lable = "sysled";
gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
linux,default-trigger = "heartbeat";
default-state = "off";
};
};
/*-------------以下内容省略-------------*/
};
/*设备树节点追加内容*/
/*+--------------+
| Misc Modules |
+--------------+*/
/*而是向原有节点追加内容*/
&uart1 {
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_uart1>;
status = "okay";
};
&pwm1 { /* backlight */
#pwm-cells = <2>;
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_pwm1>;
status = "okay";
};
... ...
总体上,设备树的源码分为三部分:
- 头文件。设备树是可以像C语言那样使用“#include”引用“.h”后缀的头文件,也可以引用设备树“.dtsi”后缀的头文件。
#include "imx6ull.dtsi" /*头文件*/
- 设备树节点。设备树给我们最直观的感受是它由一些嵌套的大括号“{}”组成, 每一个“{}”都是一个“节点”。“/ {…};”表示“根节点”,每一个设备树只有一个根节点。在根节点内部的
chosen{...}、memory{...}
等字符,都是根节点的子节点。如果我们想要添加一个自定义的节点(如leds),需要添加到根节点里。
/*设备树根节点*/
/ {
model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL"; /*model属性,用于指定设备的制造商和型号*/
compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull"; /*compatible属性,系统用来决定绑定到设备驱动的关键,用来查找节点的方法之一*/
/*根节点的子节点*/
chosen {
stdout-path = &uart1;
};
/*根节点的子节点*/
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x20000000>;
};
/*根节点的子节点*/
reserved-memory {
#address-cells = <1>;
#size-cells = <1>;
ranges;
linux,cma {
compatible = "shared-dma-pool";
reusable;
size = <0xa000000>;
linux,cma-default;
};
};
/*根节点的子节点*/
leds {
compatible = "gpio-leds";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_gpio_leds>;
status = "okay";
sysled {
lable = "sysled";
gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
linux,default-trigger = "heartbeat";
default-state = "off";
};
};
/*-------------以下内容省略-------------*/
};
- 设备树节点追加内容。在官方的设备树头文件中,已经定义了绝大部分的设备节点,如uart、i2c、spi、pwm控制器等。很多情况,我们需要在这些节点里添加、删除或者修改一些内容,此时我们可以使用
&
来引用前面已经定义好的节点。
&pwm1 { /* backlight */
#pwm-cells = <2>;
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_pwm1>;
status = "okay";
};
其中,pwm1的定义如下:
pwm1: pwm@2080000 { /*节点标签:节点名称@单元地址*/
compatible = "fsl,imx6ul-pwm", "fsl,imx27-pwm"; /*model属性用于指定设备的制造商和型号*/
reg = <0x02080000 0x4000>; /*reg属性描述设备资源在其父总线定义的地址空间内的地址*/
interrupts = <GIC_SPI 83 IRQ_TYPE_LEVEL_HIGH>; /*描述中断相关的信息*/
clocks = <&clks IMX6UL_CLK_PWM1>, /*初始化GPIO外设时钟信息*/
<&clks IMX6UL_CLK_PWM1>;
clock-names = "ipg", "per";
#pwm-cells = <3>; /*表示有多少个cells来描述pwm引脚*/
status = "disabled"; /*状态属性用于指示设备的“操作状态”*/
};
3.3、节点格式
我们知道设备树由一个根节点和众多子节点组成,子节点也可以继续包含其他节点,也就是子节点的子节点。设备树的组成很简单,下面我们来看一看节点的基本格式吧。
node-name@unit-address{
属性1 = …
属性2 = …
属性3 = …
子节点…
}
- node-name:节点名称,用于指定节点的名称,如上面的pwm。它的长度为1至31个字符,只能由“数字、大小字母、英文逗号句号、下划线和加减号”组成,节点名应当使用大写或小写字母开头并且能够描述设备类别。
- @unit-address:其中的符号
@
可以理解为是一个分割符,“unit-address”用于指定“单元地址”,它的值要和节点“reg”属性的第一个地址一致,如上面的@2080000
。如果节点没有“reg”属性值,可以直接忽略“@unit-address”。 同级别的子节点的节点名可以相同,但是要求“单元地址”不同,node-name@unit-address 的整体要求同级唯一。 - 节点标签:节点名的简写,当其他位置需要引用时可以使用节点标签来向该结点中追加内容,如上面节点名pwm前面多了个pwm1,这个pwm1就是我们所说的节点标签。
- 节点路径:通过指定从根节点到所需节点的完整路径,可以唯一标识设备树中的节点。不同层次的设备树节点名字可以相同,同层次的设备树节点要唯一。
- 节点属性:节点的“{}”中包含的内容是节点属性,通常情况下一个节点包含多个属性信息, 这些属性信息就是要传递到内核的“板级硬件描述信息”,驱动中会通过一些API函数获取这些信息。
3.4、节点属性
设备树最主要的内容是编写节点的节点属性,通常情况下一个节点代表一个设备,下面我整理了一些节点的常用属性:
(1)compatible属性
compatible属性值由一个或多个字符串组成,有多个字符串时使用“,”分隔开。设备树中的每一个设备的节点都要有一个compatible属性,它用来指定该设备所使用的驱动。如 leds 节点中的 compatible = "gpio-leds"
; 就指定了该设备使用 Linux 内核源码中自带的通用 Led 驱动。在该驱动文件中也会声明其 compatible = "gpio-leds"
; ,通过这个 compatbile
标识符,Linux 内核的 platform 总线就可以帮设备找到驱动,驱动找到设备了;
(2)model属性
用于指定设备的制造商和型号,推荐使用“制造商, 型号”的格式,当然也可以自定义;
(3)status属性
状态属性用于指示设备的“操作状态”,通过status可以去禁止设备或者启用设备,比如okay是使能设备、disabled是禁用设备;
(4)#address-cells 和 #size-cells
#address-cells,用于指定子节点reg属性“地址字段”所占的长度(单元格cells的个数)。 #size-cells,用于指定子节点reg属性“大小字段”所占的长度(单元格cells的个数)。例如#address-cells=2,#size-cells=1,则reg内的数据含义为reg = <address address size address address size>,因为每个cells是一个32位宽的数字,例如需要表示一个64位宽的地址时,就要使用两个address单元来表示;
(5)reg属性
reg属性描述设备资源在其父总线定义的地址空间内的地址。通常情况下用于表示一块寄存器的起始地址(偏移地址)和长度, 在特定情况下也有不同的含义。例如#address-cells = <1>,#size-cells = <1>,reg = <0x9000000 x4000>, 其中0x9000000表示的是地址,0x4000表示的是地址长度,这里的reg属性指定了起始地址为0x9000000,长度为0x4000的一块地址空间;
(6)ranges属性
该属性提供了子节点地址空间和父地址空间的映射(转换)方法,常见格式是 <子地址、父地址、地址长度>。如果父地址空间和子地址空间相同则无需转换。比如对于#address-cells和#size-cells都为1的话,以ranges=<0x0 0x10 0x20>为例,表示将子地址的从0x0~(0x0 + 0x20)的地址空间映射到父地址的0x10~(0x10 + 0x20);
四、设备树API函数
在Linux内核采用设备树之后,驱动程序需要获取设备树的属性。Linux内核为驱动提供了一系列API函数,用于获取设备树的属性值。这些函数都是以“_of_”开头,我们称为OF操作函数,接下来整理一些常见的OF操作函数。
4.1获取设备节点
在内核中,设备以节点的形式附加到设备树上,因此要获得设备信息,必须先获取设备节点。Linux内核使用device_node结构体
来描述一个设备节点,此结构体定义在文件 include/linux/of.h
中,代码如下:
点击查看代码
struct device_node {
const char *name; /*节点的名字*/
phandle phandle;
const char *full_name; /*节点的全名,node-name[@unit-address]*/
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)
unsigned int unique_id;
struct of_irq_controller *irq_trans;
#endif
};
上述数据结构是设备节点结构。让我们来看一下获取设备节点的几个常见函数。
(1)通过设备节点的名字获取设备节点
struct device_node *of_find_node_by_name(struct device_node *from, const char *name);
//from:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//name:要查找的设备节点的名称;
//成功返回设备节点结构,失败时返回NULL;
(2)通过设备节点类型获取设备节点
struct device_node *of_find_node_by_type(struct device_node *from, const char *type);
//from:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//type:要查找的设备节点的类型,device_type属性值;
//成功返回设备节点结构,失败返回NULL;
(3)通过节点的compatible属性和type获取设备节点
struct device_node *of_find_compatible_node(struct device_node *from, const char *type, const char *compat);
//from参数:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//type参数:要查找的设备节点类型。如果为NULL,则忽略类型限制;
//compatible参数:要查找的设备节点的compatible属性名称;
//成功返回设备节点结构,失败时返回NULL;
(4)通过设备节点路径名获取设备节点
static inline struct device_node *of_find_node_by_path(const char *path)
{
return of_find_node_opts_by_path(path, NULL);
}
//path参数:设备节点的路径名;
//成功返回设备节点结构,失败时返回NULL;
(5)通过 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)
//from: 指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//matches: of_device_id 匹配表,也就是在此匹配表里面查找节点;
//match: 找到的匹配的 of_device_id;
//成功返回找到的节点,失败时返回NULL;
4.2、获取父子设备节点API
(1)用于获取某一节点的父节点
struct device_node *of_get_parent(const struct device_node *node);
//node参数:要查找父节点的节点;
//功返回父节点的设备节点结构,失败时返回NULL;
(2)遍历某一节点的子节点
device_node *of_get_next_child(const struct device_node *node,struct device_node *prev);
//node参数:父节点;
//prev参数:上一个找到的子节点,即从哪个子节点开始搜索。如果为NULL,表示从第一个子节点开始搜索;
//返回值是找到的下一个子节点的设备节点结构;
4.3、platform_device相关
(1)根据device_node分配platform_device
struct platform_device *of_device_alloc(struct device_node *np,const char *bus_id,struct device *parent);
//np: 设备节点在设备树中对应的节点;
//bus_id: 设备所属的总线类型(如 "spi"、"i2c" 等),通常此参数被设为 NULL,表示依赖设备属性(device properties)标识符自动匹配设备总线类型;
//parent: 设备的父设备,通常设置为 NULL;
//该函数返回值是新分配的platform_device结构体指针,如果分配失败则返回 NULL;
(2)查找与给定设备节点(np)相对应的平台设备
struct platform_device *of_find_device_by_node(struct device_node *np);
//np: 设备节点在设备树中对应的节点;
//如果找到了匹配的平台设备,则返回这个平台设备的指针;否则,返回 NULL;
该函数常用于在设备树驱动程序中查找一个已知的平台设备,以便对其进行进一步的操作或数据访问。在实际编写设备树驱动程序中,通常需要通过 of_find_device_by_node
函数查找与设备节点关联的设备,并使用dev_set_drvdata
函数将自定义的设备数据结构(如设备驱动结构体)与平台设备绑定。
(3)基于设备树节点节点创建一个平台设备
struct platform_device *of_platform_device_create(struct device_node *np,const char *bus_id,struct device *parent);
//np: 设备节点在设备树中对应的节点;
//bus_id: 设备所属的总线类型(如 "spi"、"i2c" 等),通常此参数被设为NULL,表示依赖设备属性(device properties)标识符自动匹配设备总线类型;
//parent: 设备的父设备,通常设置为NULL;
//该函数返回值是新创建的平台设备(platform_device)结构体指针。如果创建失败,则返回 NULL
在函数内部,of_platform_device_create
会使用 devm_kzalloc
函数为设备结构体分配内存,并调用of_device_add函数将该设备注册到系统总线中。然后,将设备节点、总线类型和父设备作为参数传递给这个平台设备结构体,并返回新创建的平台设备结构体指针。
在这里,我仅仅整理了我目前接触到的一些主要OF操作函数,如果还需要更详细的,可以看这篇文章,我觉得还挺详细的。
本文作者:小信嵌梦
本文链接:https://www.cnblogs.com/Xin-Code9/p/18706412
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步