Linux驱动程序设计之二---Linux字符驱动程序(上)

UNIT 2  Linux字符驱动程序(上)

 

 

 

 

 

 

1.  机制和策略

2.  Linux驱动程序概述

3.  设备节点与设备号

4.  重要的数据结构

5.  字符驱动的注册与注销

6.  文件操作接口

7.  使用新设备

 

1.机制和策略

机制和策略的区分是其中一个在 Unix 设计背后的最好观念. 大部分的编程问题其实可以划分为两部分:"提供什么能力"(机制) "如何使用这些能力"(策略). 如果这两方面由程序的不同部分来表达, 或者甚至由不同的程序共同表达, 软件包是非常容易开发和适应特殊的需求.

    Unix的世界中有两个非常典型的例子,其一是UNIX的图形显示管理划分为X服务器和窗口&会话管理器,X 服务器理解硬件以及提供了统一的接口给用户程序;窗口&会话管理器实现了一个特别的策略,对硬件一无所知。人们可以在不同的硬件上使用相同的窗口管理器,而且不同的用户可以在同一台工作站上运行不同的配置。甚至完全不同的桌面环境,例如KDEGnome可以在同一个系统中共存。

    另一个例子是 TCP/IP 网络的分层结构: 操作系统提供 socket 抽象层, 它对要传送的数据而言不实现策略, 而不同的服务器负责各种服务( 以及它们的相关策略). 而且, 一个服务器, 例如 ftpd 提供文件传输机制, 同时用户可以使用任何他们喜欢的客户端; 无论命令行还是图形客户端都存在, 并且任何人都能编写一个新的用户接口来传输文件。

    对于驱动而言,它是工作在操作系统的底层,它应该实现的是提供什么样的能力,而不是决定如何使用这些硬件资源,所以驱动程序解决的是一个机制问题,而不是策略。

2Linux驱动程序概述

内核的划分

Unix 系统中, 几个并发的进程专注于不同的任务. 每个进程请求系统资源, 象计算能力, 内存, 网络连接, 或者一些别的资源. 内核是个大块的可执行文件, 负责处理所有这样的请求. 尽管不同内核任务间的区别常常不是能清楚划分, 内核的角色可以划分(如同图内核的划分)成下列几个部分:

进程管理

 

内核负责创建和销毁进程, 并处理它们与外部世界的联系(输入和输出). 不同进程间通讯(通过信号, 管道, 或者进程间通讯原语)对整个系统功能来说是基本的, 也由内核处理. 另外, 调度器, 控制进程如何共享 CPU, 是进程管理的一部分. 更通常地, 内核的进程管理活动实现了多个进程在一个单个或者几个 CPU 之上的抽象.

内存管理

计算机的内存是主要的资源, 处理它所用的策略对系统性能是至关重要的. 内核为所有进程的每一个都在有限的可用资源上建立了一个虚拟地址空间. 内核的不同部分与内存管理子系统通过一套函数调用交互, 从简单的 malloc/free 对到更多更复杂的功能.

文件系统

 

Unix 在很大程度上基于文件系统的概念; 几乎 Unix 中的任何东西都可看作一个文件. 内核在非结构化的硬件之上建立了一个结构化的文件系统, 结果是文件的抽象非常多地在整个系统中应用. 另外, Linux 支持多个文件系统类型, 就是说, 物理介质上不同的数据组织方式.

例如, 磁盘可被格式化成标准 Linux ext3 文件系统, 普遍使用的 FAT 文件系统, 或者其他几个文件系统.

设备控制

 

几乎每个系统操作最终都映射到一个物理设备上. 除了处理器, 内存和非常少的别的实体之外, 全部中的任何设备控制操作都由特定于要寻址的设备相关的代码来进行. 这些代码称为设备驱动. 内核中必须嵌入系统中出现的每个外设的驱动, 从硬盘驱动到键盘和磁带驱动器. 内核功能的这个方面是本书中的我们主要感兴趣的地方.

网络

 

网络必须由操作系统来管理, 因为大部分网络操作不是特定于某一个进程: 进入系统的报文是异步事件. 报文在某一个进程接手之前必须被收集, 识别, 分发. 系统负责在程序和网络接口之间递送数据报文, 它必须根据程序的网络活动来控制程序的执行. 另外, 所有的路由和地址解析问题都在内核中实现.

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

设备的分类

Linux 的方式看待设备可区分为 3 种基本设备类型. 每个模块常常实现 3 种类型中的 1 , 因此可分类成字符模块, 块模块, 或者一个网络模块. 这种将模块分成不同类型或类别的方法并非是固定不变的; 程序员可以选择建立在一个大块代码中实现了不同驱动的巨大模块. 但是, 好的程序员, 常常创建一个不同的模块给每个它们实现的新功能, 因为分解是可伸缩性和可扩张性的关键因素.

3 类驱动如下:

 

字符设备

 

一个字符(char) 设备是一种可以当作一个字节流来存取的设备( 如同一个文件); 一个字符驱动负责实现这种行为. 这样的驱动常常至少实现 open, close, read, write 系统调用. 文本控制台(/dev/console)和串口(/dev/ttyS0 及其友)是字符设备的例子, 因为它们很好地展现了流的抽象. 字符设备通过文件系统结点来存取, 例如 /dev/tty1 /dev/lp0. 在一个字符设备和一个普通文件之间唯一有关的不同就是, 你经常可以在普通文件中移来移去, 但是大部分字符设备仅仅是数据通道, 你只能顺序存取.然而, 存在看起来象数据区的字符设备, 你可以在里面移来移去. 例如, frame grabber 经常这样, 应用程序可以使用 mmap 或者 lseek 存取整个要求的图像.

块设备

 

如同字符设备, 块设备通过位于 /dev 目录的文件系统结点来存取. 一个块设备(例如一个磁盘)应该是可以驻有一个文件系统的. 在大部分的 Unix 系统, 一个块设备只能处理这样的 I/O 操作, 传送一个或多个长度经常是 512 字节( 或一个更大的 2 的幂的数 )的整块. Linux, 相反, 允许应用程序读写一个块设备象一个字符设备一样 -- 它允许一次传送任意数目的字节.

结果就是, 块和字符设备的区别仅仅在内核在内部管理数据的方式上, 并且因此在内核/驱动的软件接口上不同. 如同一个字符设备, 每个块设备都通过一个文件系统结点被存取的, 它们之间的区别对用户是透明的. 块驱动和字符驱动相比, 与内核的接口完全不同.

网络接口

 

任何网络事务都通过一个接口来进行, 就是说, 一个能够与其他主机交换数据的设备. 通常, 一个接口是一个硬件设备, 但是它也可能是一个纯粹的软件设备, 比如环回接口. 一个网络接口负责发送和接收数据报文, 在内核网络子系统的驱动下, 不必知道单个事务是如何映射到实际的被发送的报文上的. 很多网络连接( 特别那些使用 TCP )是面向流的, 但是网络设备却常常设计成处理报文的发送和接收. 一个网络驱动对单个连接一无所知; 它只处理报文.

既然不是一个面向流的设备, 一个网络接口就不象 /dev/tty1 那么容易映射到文件系统的一个结点上. Unix 提供的对接口的存取的方式仍然是通过分配一个名子给它们( 例如 eth0 ), 但是这个名子在文件系统中没有对应的入口. 内核与网络设备驱动间的通讯与字符和块设备驱动所用的完全不同.

不用 read write, 内核调用和报文传递相关的函数.

 

 

有其他的划分驱动模块的方式, 与上面的设备类型是正交的. 通常, 某些类型的驱动与给定类型设备的其他层的内核支持函数一起工作. 例如, 你可以说 USB 模块, 串口模块, SCSI 模块, 等等. 每个USB 设备由一个 USB 模块驱动, USB 子系统一起工作, 但是设备自身在系统中表现为一个字符设备( 比如一个 USB 串口 ), 一个块设备( 一个 USB 内存读卡器 ), 或者一个网络设备( 一个 USB 以太网接口 ).

另外的设备驱动类别近来已经添加到内核中, 包括 FireWire 驱动和 I2O 驱动. 以它们处理 USB SCSI 驱动相同的方式, 内核开发者集合了类别范围内的特性, 并把它们输出给驱动实现者, 以避免重复工作和 bug, 因此简化和加强了编写类似驱动的过程.

在设备驱动之外, 别的功能, 不论硬件和软件, 在内核中都是模块化的. 一个普通的例子是文件系统. 一个文件系统类型决定了在块设备上信息是如何组织的, 以便能表示一棵目录与文件的树. 这样的实体不是设备驱动, 因为没有明确的设备与信息摆放方式相联系; 文件系统类型却是一种软件驱动, 因为它将低级数据结构映射为高级的数据结构. 文件系统决定一个文件名多长, 以及在一个目录入口中存储每个文件的什么信息. 文件系统模块必须实现最低级的系统调用, 来存取目录和文件, 通过映射文件名和路径( 以及其他信息, 例如存取模式 )到保存在数据块中的数据结构. 这样的一个接口是完全与数据被传送来去磁盘( 或其他介质 )相互独立, 这个传送是由一个块设备驱动完成的.

如果你考虑一个 Unix 系统是多么依赖下面的文件系统, 你会认识到这样的一个软件概念对系统操作是至关重要的. 解码文件系统信息的能力处于内核层级中最低级, 并且是最重要的; 甚至如果你为你的新 CD-ROM 编写块驱动, 如果你对上面的数据不能运行 ls 或者 cp 就毫无用处. Linux 支持一个文件系统模块的概念, 其软件接口声明了不同操作, 可以在一个文件系统节点, 目录, 文件和超级块上进行操作. 对一个程序员来说, 居然需要编写一个文件系统模块是非常不常见的, 因为官方内核已经包含了大部分重要的文件系统类型的代码.

3. 设备节点和设备号

    我们在Linux开发基础中就提到Linux的一个核心的哲学思想:“一切皆是文件”。在那里我们提到了六种文件:常规文件,目录,设备文件,命名管道,符号连接以及socket. 设备文件,我们也称之为设备节点

    我们在讲到ls –l的时候也提到了设备文件由两种类型:一种是字符设备文件,一种是块设备文件,分别用cb来表示文件的类型,比如:

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

从这可以看出,如果文件类型为c或者为b的都表明它是一个设备文件,比如adsp audio都是字符设备节点,fd0b,所以是块设备节点,仔细观察一下,你会发现对于块设备节点或者是字符设备节点都比其它的文件多了一个字段。而我们以前讲述过对于文件来说第六个字段是文件的大小,那么对于字符设备节点或者块设备节点来说,第五个字段是什么?第六个字段还是文件的大小吗?

这两个字段也就是我们现在要解释的,统称为设备号,前面一个为主设备号,后面一个为次设备号。

传统上, 主编号标识设备相连的驱动. 例如, /dev/null /dev/zero 都由驱动1来管理, 而虚拟控制台和串口终端都由驱动 4 管理; 同样, vcs1 vcsa1 设备都由驱动 7 管理. 现代 Linux 内核允许多个驱动共享主编号, 但是你看到的大部分设备仍然按照一个主编号一个驱动的原则来组织.

次编号被内核用来决定引用哪个设备. 依据你的驱动是如何编写的(如同我们下面见到的), 你可以从内核得到一个你的设备的直接指针,或者可以自己使用次编号作为本地设备数组的索引. 不论哪个方法, 内核自己几乎不知道次编号的任何事情, 除了它们指向你的驱动实现的设备.

设备节点当然是可以创建的,从上面的例子可以看出,在创建设备节点的时候,需要确定以下几个问题:

ü  设备节点的名称是什么?放在什么位置?

ü  设备节点的类型是什么?是字符设备节点还是块设备节点?

ü  这个设备节点的主设备号,次设备号各是多少?

确定了这个问题之后我们就可以用mknod来创建设备节点,首先可以用mknod –help来查看mknod的用法:

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

下面是创建设备节点的例子:

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

上面在/dev的目录下创建了一个字符设备节点ctest, 主设备号是10,次设备号是0。创建了块设备节点btest,主设备号10,次设备号0

我们在创建这个设备节点的时候随随便便指定了一个主设备号和次设备号,实际的驱动中应该是怎样指定的呢?主设备号和次设备号的作用到底是什么?在内核中又是如何表示的呢?

设备编号的内部表示

在内核中, dev_t 类型( <linux/types.h>中定义)用来持有设备编号 -- 主次部分都包括. 对于 2.6.0 内核, dev_t 32 位的量, 12 位用作主编号, 20 位用作次编号. 你的代码应当, 当然, 对于设备编号的内部组织从不做任何假设; 相反, 应当利用在<linux/kdev_t.h>中的一套宏定义. 为获得一个 dev_t 的主或者次编号, 使用:

MAJOR(dev_t dev);

MINOR(dev_t dev);

相反, 如果你有主次编号, 需要将其转换为一个 dev_t, 使用:

MKDEV(int major, int minor);

注意, 2.6 内核能容纳有大量设备, 而以前的内核版本限制在 255 个主编号和 255 个次编号. 有人认为这么宽的范围在很长时间内是足够的, 但是计算领域被这个特性的错误假设搞乱了. 因此你应当希望 dev_t 的格式将来可能再次改变; 但是, 如果你仔细编写你的驱动, 这些变化不会是一个问题.

设备号的作用

    设备号,是Linux内核的一项资源,当被某个驱动程序申请了这项资源之后,其他的驱动或者内核程序将不能申请同一个设备号资源,除非先释放了这个设备号资源。换句话说,内核用设备号来标识某个驱动以及其设备,所以设备号在内核中是唯一的。Linux 为字符设备和块设备各维护了一组设备号资源。从/proc/devices文件可以直观的看出来:

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

首先显示的是字符设备,而后显示的是块设备,这个文件只显示了当前系统中已经被申请的主设备号,而没有列出次设备号。(所以,我们不能直观的看出来,哪个设备号是空闲的)。

    这里需要注意的是,当一个设备号是否被占用,与我们是否建立了该设备号的设备节点无关。比如,我们上一节虽然创建了两个设备节点ctest,btest,设备号都是(10,0).但是在/proc/devices下看到的设备号与我们创建的这两个节点完全无关。任何时刻都该记住:设备号是一项资源,需要向内核申请使用,由内核分配。这是由驱动程序完成的。创建一个设备节点的时候,仅仅只是为了把一个设备文件和一个驱动程序关联起来,所以创建节点的时候并没有向内核申请设备号资源。

字符设备号的分配和释放

在建立一个字符驱动时你的驱动需要做的第一件事是获取一个或多个设备编号来使用. 为此目的的必要的函数是 register_chrdev_region, <linux/fs.h>中声明:

int register_chrdev_region(dev_t first, unsigned int count, char *name);

ü  这里, first 是你要分配的起始设备编号. first 的次编号部分常常是 0, 但是没有要求是那个效果.

ü  count 是你请求的连续设备编号的总数. 注意, 如果 count 太大, 你要求的范围可能溢出到下一个次编号; 但是只要你要求的编号范围可用, 一切都仍然会正确工作.

ü  最后, name 是应当连接到这个编号范围的设备的名子; 它会出现在 /proc/devices sysfs .

如同大部分内核函数, 如果分配成功进行, register_chrdev_region 的返回值是 0. 出错的情况下, 返回一个负的错误码, 你不能存取请求的区域.

如果你确实事先知道你需要哪个设备编号, register_chrdev_region 工作得好. 然而, 你常常不会知道你的设备使用哪个主编号; Linux 内核开发社团中一直努力使用动态分配设备编号. 内核会乐于动态为你分配一个主编号, 但是你必须使用一个不同的函数来请求这个分配.

int alloc_chrdev_region(dev_t *dev, unsigned int firstminor,

unsigned int count, char *name);

ü  使用这个函数,dev是一个只输出的参数,它在函数成功完成时持有你的分配范围的第一个数.

ü  fisetminor 应当是请求的第一个要用的次编号; 它常常是 0.

ü  count name 参数如同给 request_chrdev_region 的一样.

不管你任何分配你的设备编号, 你应当在不再使用它们时释放它. 设备编号的释放使用:

void unregister_chrdev_region(dev_t first, unsigned int count);

调用 unregister_chrdev_region 的地方常常是你的模块的 cleanup 函数.

上面的函数分配设备编号给你的驱动使用, 但是它们不告诉内核你实际上会对这些编号做什么. 用户空间程序能够存取这些设备号中一个之前, 你的驱动需要连接它们到它的实现设备操作的内部函数上. 我们将描述如何简短完成这个连接, 但首先顾及一些必要的枝节问题.

主编号的动态分配

一些主设备编号是静态分派给最普通的设备的. 一个这些设备的列表在内核源码树的 Documentation/devices.txt . 分配给你的新驱动使用一个已经分配的静态编号的机会很小, 但是, 并且新编号没在分配. 因此, 作为一个驱动编写者, 你有一个选择: 你可以简单地捡一个看来没有用的编号, 或者你以动态方式分配主编号. 只要你是你的驱动的唯一用户就可以捡一个编号用; 一旦你的驱动更广泛的被使用了, 一个随机捡来的主编号将导致冲突和麻烦.

因此, 对于新驱动, 我们强烈建议你使用动态分配来获取你的主设备编号, 而不是随机选取一个当前空闲的编号. 换句话说, 你的驱动应当几乎肯定地使用 alloc_chrdev_region, 不是 register_chrdev_region.

动态分配的缺点是你无法提前创建设备节点, 因为分配给你的模块的主编号会变化. 对于驱动的正常使用, 这不是问题, 因为一旦编号分配了, 你可从 /proc/devices 中读取它.

【实验1】下面是对hello模块的修改,使其在模块加载的时候能够向内核申请字符设备号,在模块退出的时候释放所申请的内核设备号,采用静态和动态的方式申请,思考一下静态分配和动态分配的优劣。

-----------------------------------------------------------------------

//静态的方法:

#include <linux/init.h>

#include <linux/module.h>

#include <linux/kernel.h>

#include <linux/moduleparam.h>

#include <linux/kdev_t.h>   //设备号操作的宏如MKDEV()在此文件中声明

#include <linux/types.h>    //dev_t的数据类型在此头文件中声明

#include <linux/fs.h>       //register_chrdev_region()在此头文件中声明

MODULE_LICENSE("Dual BSD/GPL");

MODULE_AUTHOR("stephanxu@eetek");

MODULE_DESCRIPTION("the first kernel module");;

 

static int major = 101;    //可由加载的时候,传递主设备号和次设备号

static int minor = 0;

static dev_t devnum;

module_param(major,int,S_IRUGO);

module_param(minor,int,S_IRUGO);

static int __init hello_init(void)

{

  int ret=0;

  devnum = MKDEV(major,minor);

  ret = register_chrdev_region(devnum,1,"static ctest");

  if(!ret){

printk("register char device number successfully,

major=%d,minor=%d/n",MAJOR(devnum),MINOR(devnum));

  }

  return ret;

}

 

static void __exit hello_exit(void)

{

  unregister_chrdev_region(devnum,1);

}

 

module_init(hello_init);

module_exit(hello_exit);

----------------------------------------------------------------------

同样将此程序命名为hello.c,采用之前的Makefile就可以对其进行编译,生成hello.ko,然后,加载此模块,可用dmesg命令查看显示的信息:

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

你还可以通过查看/proc/devices,可以看到101的主设备号,被static ctest所占用:

2010年11月06日 - 御风亭 - 专业,诚信,责任

若我们把模块卸载后,也就是会调用unregister_chrdev_region()来释放资源,我们可以看到101 static ctest将在/proc/devices文件中消失了。

若在我的机器上,采用major=10,则不能正确地加载内核模块,如下:

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

给出的错误信息是Device or resource busy,实际上,这里是因为major=10,minor=0的设备号已经被其他驱动所占有,所以在这里申请设备号资源会失败。

这也就引出一个问题:静态分配的时候,我们必须去了解哪些设备号已经被使用,那些设备号是空闲的。而动态分配将不会由此问题,因为内核总是返回一个空闲的设备号。

-----------------------------------------------------------------------

//动态的方法

#include <linux/init.h>

#include <linux/module.h>

#include <linux/kernel.h>

#include <linux/moduleparam.h>

#include <linux/kdev_t.h>

#include <linux/types.h>

#include <linux/fs.h>

MODULE_LICENSE("Dual BSD/GPL");

MODULE_AUTHOR("stephanxu@eetek");

MODULE_DESCRIPTION("the first kernel module");;

static dev_t devnum;

static int __init hello_init(void)

{

  int ret=0;

  ret = alloc_chrdev_region(&devnum,0,1,"dynamic ctest");

  if(!ret){

printk("register char device number successfully,

major=%d,minor=%d/n",MAJOR(devnum),MINOR(devnum));

  }

  return ret;

}

 

static void __exit hello_exit(void)

{

  unregister_chrdev_region(devnum,1);

}

 

module_init(hello_init);

module_exit(hello_exit);

-----------------------------------------------------------------------

同样将此程序命名为hello.c,采用之前的Makefile就可以对其进行编译,生成hello.ko,然后,加载此模块,可用dmesg命令查看显示的信息:

2010年11月06日 - 御风亭 - 专业,诚信,责任

 

 

你还可以通过查看/proc/devices,可以看到253的主设备号,被dynamic ctest所占用:

 

 

2010年11月06日 - 御风亭 - 专业,诚信,责任

若我们把模块卸载后,也就是会调用unregister_chrdev_region()来释放资源,我们可以看到253 dynamic ctest将在/proc/devices文件中消失了。

由内核动态分配的不足是在模块加载之前,我们无法得知所申请的设备号是多少,这样也就无法事先创建设备节点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Linux设备驱动程序是一种软件模块,它允许操作系统与硬件设备进行通信。设备驱动程序设计需要遵循一定的规范和流程,包括以下几个步骤: 1. 确定设备类型:设备可以是字符设备、块设备或网络设备等。每种设备类型都有不同的驱动程序接口和操作方法。 2. 编写设备驱动程序:设备驱动程序是一个内核模块,它包含了设备的初始化、读写操作、中断处理等函数。驱动程序需要遵循Linux内核的编程规范和API。 3. 注册设备驱动程序驱动程序需要在内核中注册,以便操作系统能够识别和加载它。注册过程包括分配设备号、初始化设备结构体、注册字符设备或块设备等。 4. 测试设备驱动程序:测试设备驱动程序需要使用一些工具和技术,例如ioctl命令、proc文件系统、sysfs文件系统等。测试过程需要验证设备的正确性、性能和稳定性。 以下是一个简单的字符设备驱动程序的例子: ```c #include <linux/module.h> #include <linux/fs.h> #include <linux/uaccess.h> #define DEVICE_NAME "mydevice" #define BUF_LEN 1024 static int Major; static char msg[BUF_LEN]; static char *msg_ptr; static int device_open(struct inode *inode, struct file *file) { msg_ptr = msg; try_module_get(THIS_MODULE); return 0; } static int device_release(struct inode *inode, struct file *file) { module_put(THIS_MODULE); return 0; } static ssize_t device_read(struct file *filp, char *buffer, size_t length, loff_t *offset) { int bytes_read = 0; if (*msg_ptr == 0) return 0; while (length && *msg_ptr) { put_user(*(msg_ptr++), buffer++); length--; bytes_read++; } return bytes_read; } static ssize_t device_write(struct file *filp, const char *buffer, size_t length, loff_t *offset) { int i; for (i = 0; i < length && i < BUF_LEN; i++) get_user(msg[i], buffer + i); msg_ptr = msg; return i; } static struct file_operations fops = { .read = device_read, .write = device_write, .open = device_open, .release = device_release }; static int __init init_module(void) { Major = register_chrdev(0, DEVICE_NAME, &fops); if (Major < 0) { printk(KERN_ALERT "Registering char device failed with %d\n", Major); return Major; } printk(KERN_INFO "I was assigned major number %d. To talk to\n", Major); printk(KERN_INFO "the driver, create a dev file with\n"); printk(KERN_INFO "'mknod /dev/%s c %d 0'.\n", DEVICE_NAME, Major); return 0; } static void __exit cleanup_module(void) { unregister_chrdev(Major, DEVICE_NAME); } MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple example Linux module."); ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值