OpenHarmony的USB框架

作者:鸿湖万联 许文龙

1、 前言

写这篇文档主要目的是想弄清楚OpenHarmony的usb设备、驱动以及设备结点的加载过程,弄清楚usb分别在内核驱动、HDF、ueventd都做了什么,是什么关系。顺便,学习一下HDF的设计思路,它是如何与内核态的驱动交互的。同时也能窥探一下内核的驱动框架。

最后,就是要基于自己的理解,解决usb设备结点为什么有的没有被创建。比如插入打印机,看不到结点。是系统bug还是有意为之。

??
??
kernel usb
HDF
Ueventd

2、通讯机制

2.1 字符设备驱动

内核提供了三种设备文件,字符设备、块设备、网络设备。

属性开头c标志的都是字符设备。

字符设备驱动机制

在用户态,往字符设备文件写数据,就相当于向设备写数据。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3wnhe8Dg-1683769479072)(figures/image-20230509153033530.png)]

  • usb 设备驱动初始化时,会注册设备驱动。然后生成主设备号,绑定操作函数。并没有生成设备结点。

    const struct file_operations usbdev_file_operations = {
    	.owner =	  THIS_MODULE,
    	.llseek =	  no_seek_end_llseek, ---->  对应Linux 标准接口seek
    	.read =		  usbdev_read,---->  对应Linux 标准接口read
    	.poll =		  usbdev_poll,---->  对应Linux 标准接口poll
    	.unlocked_ioctl = usbdev_ioctl,  ---->  对应Linux 标准接口ioctl
    	.compat_ioctl =   compat_ptr_ioctl,
    	.mmap =           usbdev_mmap,  ---->  对应Linux 标准接口mmap
    	.open =		  usbdev_open,   ---->  对应Linux 标准接口open
    	.release =	  usbdev_release,
    };
    
  • 设备插入时,生成次设备号,并探测到对应的驱动,关联具体的操作函数。状态上报给用户空间,用户守护进程(Openharmony 里是Ueventd)创建设备结点。

  • 在用户空间操作设备,就相当于调用了驱动的操作函数。比如,打开设备文件后,用户调用ioctl, 就相当于调用驱动的ioctl函数。

设备号

字符设备的路径是可以改的,主要的是主设备号、次设备号。主设备号对应bus上的设备驱动,主设备号是固定的,比如usb的主设备号是180,任何设备上都是固定的。而是次设备号是动态申请的。

下面是主设备号:

# cat /proc/devices
Character devices:
  1 mem
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
 10 misc
 13 input
 29 fb
 81 video4linux
 89 i2c
153 spi
180 usb
188 ttyUSB
189 usb_device

usb 可能比较特别,他跟其他驱动不同,他有两个主设备号。180和189, 这主要是因为usb 还包括鼠标、键盘这样的设备以及一些复杂的数据传输。

  • usb_device 189 是usb设备文件系统(usbfs)

    # ls -al /dev/bus/usb/003
    crw-rw---- 1 root root 189, 256 2017-08-05 09:00 001
    crw-rw-r-- 1 root root 189, 259 2017-08-08 07:01 004
    
  • usb 180 是给用户开发使用的。可以直接读写对应的设备结点即可读写设备。

    # ls /dev/usb/ -al
    crw-------  1 root root 180,   0 2017-08-09 05:43 lp0
    

    ​ 用180和189应该都是可以通讯的,只是使用189会复杂些,需要处理许多细节,比如发送数据要自己构造urb的结构体,而180则简单很多,直接按文件读写就可以了。

    ​ HDF DDK,libusb等都是使用189, 因为他们需要实现许多复杂的需求,而普通应用程序只需要收发数据,直接使用180结点就行了。这也解释了为什么用libusb只需要知道bus 和dev 编号就能通讯。

实现原理

字符设备也是有个驱动的,这里没有详细了解。只是弄清楚机制和用法。因为,在Openharmony里很多代码用到这个东西,比如token_id也是这样的。

# ls -al /dev/access_token_id
crw-rw-rw- 1 access_token access_token 10,  56 2017-08-05 09:00 /dev/access_token_id

这就是设备结点的一个基本的机制。

以usb_device 为例:

int usb_devio_init()
{
    // 1、 注册设备号, 这里是189, 即usb_device. 三个参数分别是主设备号(189), 设备可分配最大个数,名称
    retval = register_chrdev_region(USB_DEVICE_DEV, USB_DEVICE_MAX,
					"usb_device"); // 在/proc/devices中添加: 189  usb_device
	if (retval) {
		printk(KERN_ERR "Unable to register minors for usb_device\n");
		goto out;
	}
    // 2、 注册设备号, 关联对应的操作函数
	cdev_init(&usb_device_cdev, &usbdev_file_operations);
    // 3、 添加到usb 的bus上。
	retval = cdev_add(&usb_device_cdev, USB_DEVICE_DEV, USB_DEVICE_MAX);
	if (retval) {
		printk(KERN_ERR "Unable to get usb_device major %d\n",
		       USB_DEVICE_MAJOR);
		goto error_cdev;
	}
	usb_register_notify(&usbdev_nb);
}

const struct file_operations usbdev_file_operations = {
	.owner =	  THIS_MODULE,
	.llseek =	  no_seek_end_llseek, ---->  对应Linux 标准接口seek
	.read =		  usbdev_read,---->  对应Linux 标准接口read
	.poll =		  usbdev_poll,---->  对应Linux 标准接口poll
	.unlocked_ioctl = usbdev_ioctl,  ---->  对应Linux 标准接口ioctl
	.compat_ioctl =   compat_ptr_ioctl,
	.mmap =           usbdev_mmap,  ---->  对应Linux 标准接口mmap
	.open =		  usbdev_open,   ---->  对应Linux 标准接口open
	.release =	  usbdev_release,
};

static long usbdev_do_ioctl(struct file *file, unsigned int cmd,
				void __user *p)
{
	struct usb_dev_state *ps = file->private_data;
	struct inode *inode = file_inode(file);
	struct usb_device *dev = ps->dev;
	int ret = -ENOTTY;

	switch (cmd) {
	case USBDEVFS_BULK:
		snoop(&dev->dev, "%s: BULK\n", __func__);
		ret = proc_bulk(ps, p);
		if (ret >= 0)
			inode->i_mtime = current_time(inode);
		break;

	case USBDEVFS_GET_CAPABILITIES:
		ret = proc_get_capabilities(ps, p);
		break;
	。。。 省略

}

次设备号是在设备插拔时自动生成的,是动态的。主设备号是静态申请的,固定的。

在HDF 的用户态是这样调用的:

	// 对应的设备结点/dev/bus/usb/ 下的设备结点,主设备号是189
	char path[64];
	sprintf_s(path, sizeof(path), USB_DEV_FS_PATH "/%03u/%03u", dev->busNum, dev->devAddr);
	fd = open(path,0666);

	//  获取设备接口描述符
    ret = ioctl(fd, USBDEVFS_GET_CAPABILITIES, &devHandle->caps);

	// 传输数据
    ret = ioctl(fd, USBDEVFS_SUBMITURB, urb);

这就是字符设备的一个机制。

2.2 HdfSBuf

HdfSBuf 类似组件使用的Binder机制,使用比较相似,只是底层机制不同,Binder使用的是共享内存。

HdfSBuf 底层也是使用字符设备实现的。底层细节没有深入。

crw-rw----  1 root root 234,   0 2017-08-05 09:00 dev_mgr
crw-rw----  1 root root 234,   1 2017-08-05 09:00 devsvc_mgr
crw-rw----  1 root root 234,   7 2017-08-05 09:00 hdf_audio_capture
crw-rw----  1 root root 234,   8 2017-08-05 09:00 hdf_audio_render
crw-rw----  1 root root 234,  11 2017-08-05 09:00 hdf_usb_pnp_notify_service

HdfSBuf 不仅可以在内核空间和用户空间通讯,也可以转变成Binder的对象,使用c++开发。

int32_t SbufToParcel(struct HdfSBuf *sbuf, OHOS::MessageParcel **parcel);

下面看一个例子:

服务端:

static int32_t UsbPnpNotifyDispatch(
    struct HdfDeviceIoClient *client, int32_t cmd, struct HdfSBuf *data, struct HdfSBuf *reply)
{
    int32_t ret = HDF_SUCCESS;
    struct UsbPnpAddRemoveInfo *usbPnpInfo = NULL;
    uint32_t infoSize;
    HdfSbufReadBuffer(data, (const void **)(&usbPnpInfo), &infoSize);
    // 根据cmd做相应处理,代码省略
    OsalMutexUnlock(&g_usbSendEventLock);

    if (!HdfSbufWriteInt32(reply, INT32_MAX)) {
        HDF_LOGE("%s: reply int32 fail", __func__);
    }

    return ret;
}

static int32_t UsbPnpNotifyBind(struct HdfDeviceObject *device)
{
    static struct IDeviceIoService pnpNotifyService = {
        .Dispatch = UsbPnpNotifyDispatch,
    };
    device->service = &pnpNotifyService;
    return HDF_SUCCESS;
}

struct HdfDriverEntry g_usbPnpNotifyEntry = {
    .moduleVersion = 1,
    .Bind = UsbPnpNotifyBind,
    .Init = UsbPnpNotifyInit,
    .Release = UsbPnpNotifyRelease,
    .moduleName = "HDF_USB_PNP_NOTIFY",
};

HDF_INIT(g_usbPnpNotifyEntry);

客户端:

struct HdfSBuf *data = HdfObtainDefaultSize();
struct HdfSBuf *reply = HdfObainDefaultSize();
struct UsbPnpAddRemoveInfo usbPnpInfo;
uint32_t ret = 0;

HdfSbufWriteBuffer(data, &usbPnpInfo, sizeof(usbPnpInfo));
// 阻塞的
server->dispatcher->Dispatch(&server->object, cmd, data, reply);

HdfSbufReadInt32(reply, &ret);

HdfSBufRecycle(data);
HdfSBufRecycle(reply);
return ret;

2.3 Uevent

uevent 主要是用于usb设备插拔监控的。

socket的NETLINK协议族有多种协议,包括网络通讯、进程间通信、路由查询,ueventd等。ueventd是内核空间向用户空间主动上报事件的一种协议。内核驱动只是作为socket客户端,广播消息。谁接收由用户进程决定。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mI4r2FW6-1683769479076)(figures/format,png.png)]

在OpenHarmony中,用户进程的热插拔处理是有一个Ueventd的进程。它是基于init的fd代持技术实现条件触发、按需启动。

  • 条件就是收到socket的NETLINK_KOBJECT_UEVENT的消息,ueventd进程就会被唤醒

  • 按需启动就是出现热插拔事件。

通过调试也能看到这一点:

插拔设备之前,一直是停止运行的,
# param dump | grep ueventd
          param: startup.service.ctl.ueventd=stopped

插入设备之后,很快就被唤醒了。
# param dump | grep ueventd
          param: startup.service.ctl.ueventd=running
#

Ueventd使用的是NETLINK_KOBJECT_UEVENT协议,获取驱动发来的uevent消息。

	struct sockaddr_nl addr;

    addr.nl_family = AF_NETLINK;
    addr.nl_pid = getpid();
    addr.nl_groups = 0xffffffff;

    int sockfd = socket(PF_NETLINK, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_KOBJECT_UEVENT);

    setsockopt(sockfd, SOL_SOCKET, SO_RCVBUFFORCE, &buffSize, sizeof(buffSize));
    setsockopt(sockfd, SOL_SOCKET, SO_PASSCRED, &on, sizeof(on));

    if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
    }

3、 USB 通讯过程

3.1 usb 内核驱动

内核usb主要就是加载驱动。并将状态通过uevent报文上报给用户空间。

大致了解一下。

1、注册debugfs, 注册电源等高级配置。 /sys/kernel/debug/usb/ 可以看各种调试信息

2、注册usb总线,并加载到系统驱动总线. 会创建 /sys/bus/usb目录,关联相关的函数操作,包括uevent,设备match

3、分配主设备号,180。 /sys/bus/usb/drviers/usb, 关联ioctl,read,write等函数

4、分配usbfs设备号,189, /sys/bus/usb/drivers/usbfs, 关联ioctl,open, read等函数

5、注册hub

6、注册一般设备驱动(usb分为设备驱动和接口驱动,接口驱动可以理解为逻辑设备,一个设备可以有多个逻辑设备)

7、接口驱动是单独注册的,在/sys/bus/usb/drivers 这里可以看到所有已经加载的驱动。

# ls /sys/bus/usb/drivers/usblp/ -al
total 0
drwxr-xr-x  2 root root    0 2017-08-05 09:26 .
drwxr-xr-x 33 root root    0 2017-08-05 09:00 ..
lrwxrwxrwx  1 root root    0 2017-08-06 01:11 3-1:1.0 -> ../../../../devices/platform/fd840000.usb/usb3/3-1/3-1:1.0
--w-------  1 root root 4096 2017-08-06 01:11 bind
lrwxrwxrwx  1 root root    0 2017-08-06 01:11 module -> ../../../../module/usblp
-rw-r--r--  1 root root 4096 2017-08-06 01:11 new_id
-rw-r--r--  1 root root 4096 2017-08-06 01:11 remove_id
--w-------  1 root root 4096 2017-08-06 01:11 uevent
--w-------  1 root root 4096 2017-08-06 01:11 unbind

驱动框架:

在这里插入图片描述

这里的设备是逻辑设备,对于usb来说就是接口驱动。

状态通知:

插入
通知
HdfSBuf
接口
netlink uevent
设备
usb bus
HDF内核驱动服务
HDF用户态驱动服务
HDI
APP
Ueventd
创建设备结点

数据通讯:

read/write/ioctl
主设备号
次设备号
APP
设备结点
bus usb
设备

3.2 Ueventd进程

在Openharmony里,用户空间使用的是Ueventd作为守护进程。前面已经说过,它是条件触发、按需启动。这里做一个简单梳理:

1、 解析设备结点的权限配置文件

/etc/ueventd.config

/vendor/etc/ueventd.config

// 摘取部分
/dev/block/sdd19 0660 6666 6666
/dev/watchdog 0660  watchdog watchdog
/dev/hdf_input_event* 0660 3029 3029
/dev/HDF* 0666 0 0
/dev/ttyS* 0666 0 0
/dev/ttyACM* 0666 0 0
/dev/ttyUSB* 0666 0 0

2、基于init的fd代持技术,获取socket句柄。

如果init进程socket不存在,就自己按默认方式创建socket服务端。

使用的是NETLINK_KOBJECT_UEVENT

3、解析ueventd报文

就是一般的文本格式。

# cat /sys/class/usbmisc/lp0/uevent
MAJOR=180
MINOR=0
DEVNAME=usb/lp0

4、创建设备结点

两种方式:

  • 静态创建设备结点

    扫描这些目录下的ueventd文件,并创建设备结点。

    Trigger("/sys/block", sockFd, devices, num);
    Trigger("/sys/class", sockFd, devices, num);
    Trigger("/sys/devices", sockFd, devices, num);
    

    就是系统启动时,在ueventd里已经配置好的。一般可能是上次系统关闭之前设备并未拔出,系统重启后就生成的。

    /sys/class/usbmisc/lp0/uevent
    # cat /sys/class/usbmisc/lp0/uevent
    MAJOR=180
    MINOR=0
    DEVNAME=usb/lp0
    
  • 动态创建设备结点

    是收到netlink uevent消息时触发创建的。

下面是usb的设备结点路径处理的代码。

void HandleOtherDeviceEvent(const struct Uevent *uevent)
{
    char deviceNode[DEVICE_FILE_SIZE] = {};
    char sysPath[SYSPATH_SIZE] = {};

    const char *devName = GetDeviceName(sysPath, uevent->deviceName);
    const char *devPath = GetDeviceBasePath(uevent->subsystem);

    if (STRINGEQUAL(uevent->subsystem, "usb")) {
        // 189 的设备结点, /dev/bus/usb/003/001
        if (uevent->deviceName != NULL) {
            if (snprintf_s(deviceNode, DEVICE_FILE_SIZE, DEVICE_FILE_SIZE - 1, "/dev/%s", uevent->deviceName) == -1) {
                INIT_LOGE("Make device file for device [%d : %d]", uevent->major, uevent->minor);
                return;
            }
        } 
    } else if (STARTSWITH(uevent->subsystem, "usb")) {
        //180的设备结点,走这条线路
        // Other usb devies, do not handle it.
        return;
    } else {

    }
    HandleDeviceNode(uevent, deviceNode, false);
    // 最终调用mknode(path, mode, 主设备号<<8|次设备号);  //mode是权限,默认0666
}

5、设备结点参数化

在OpenHarmony release 3.2版本将设备节点参数化。

startup.uevent.xxx = "added"/"remove"

这样,就可以通过参数服务提供的接口,将ueventd的热插拔事件通知到任何用户进程。

不过这部分代码,好像有问题。

3.3 HDF usb

hdf 服务分为内核态(khdf)和用户态(uhdf)服务。 这个可以根据hcs 的配置文件所在的目录可以知道。

vendor/isoftstone/tecsun/hdf_config
├── khdf
│   ├── audio
│   ├── device_info
│   ├── hdf.hcs
│   ├── input
│   └── wifi
└── uhdf
    ├── camera
    ├── device_info.hcs
    ├── hdf.hcs
    ├── media_codec
    ├── usb_ecm_acm.hcs
    └── usb_pnp_device.hcs

在编译时,hdf目录的framework,khdf 部分的代码是链接到内核一起编译的。

wlxuz@swanlink02:~/code/SwanlinkOS$ ls -al out/kernel/src_tmp/linux-5.10/drivers/hdf/
total 24
drwxr-xr-x   4 wlxuz deve 4096 May  6 17:43 .
drwxr-xr-x 146 wlxuz deve 4096 May  6 17:43 ..
drwxr-xr-x   2 wlxuz deve 4096 May  6 17:43 evdev
lrwxrwxrwx   1 wlxuz deve   58 May  6 17:43 framework -> //SwanlinkOS/drivers/hdf_core/framework
lrwxrwxrwx   1 wlxuz deve   67 May  6 17:43 khdf -> //SwanlinkOS/drivers/hdf_core/adapter/khdf/linux
-rw-r--r--   1 wlxuz deve   74 May  6 17:43 Makefile
drwxr-xr-x   3 wlxuz deve 4096 May  6 17:43 wifi

hcs 配置文件里, 每个host是一个独立的进程。

UsbPnpNotify服务是创建在内核态的服务。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6cXuXmhm-1683769479080)(figures/4b535758-af46-11ec-aa7f-dac502259ad0.png)]

它通过调用下面的内核接口获取内核空间的usb驱动的状态:

usb_register_notify(&g_usbPnpNotifyNb);  // 将监听接口注册到usb的通知链。
usb_for_each_dev((void *)client, funcs);  // 遍历设备列表

通知链是usb总线的一个通知链表,当事件触发时,所有注册到这个链表的监听函数,会被依次通知。HDF 利用这种机制将内核的状态变化通知到hdf。

usb_register_notify
HdfSBuf
接口调用
kernel usb(内核空间)
UsbPnpNotify(内核空间)
Usb DDK(用户空间)
usb驱动开发

这样就实现了本来只能在内核态开发的usb驱动迁移到了用户态。

4、 问题解决

4.1 设备结点未创建的问题

主要问题是插入usb设备,设备结点没有被创建。主要分析ueventd的log基本就知道问题所在。

查看log 可以用下面命令都可以看log

# cat /proc/kmsg
# hilog -t kmsg  
# dmesg | grep usb

先用 dmesg|grep usb 查看插拔时产生的log,

[252064.106002] [I/USB_PNP_NOTIFY] UsbPnpNotifyHdfSendEvent:387 report one device information, 4 usbDevAddr=18446743524337473536, devNum=3, busNum=3, infoTable=1-0x483-0x7540
!\x0d
[252066.381377] usb 3-1: new full-speed USB device number 4 using ohci-platform
[252066.614071] usb 3-1: New USB device found, idVendor=0483, idProduct=7540, bcdDevice= 2.00
[252066.614185] usb 3-1: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[252066.614227] usb 3-1: Product: ICOD_Thermal_Printer
[252066.614259] usb 3-1: Manufacturer: ICOD
[252066.614291] usb 3-1: SerialNumber: 000000000002af5360
[252066.630719] usblp 3-1:1.0: usblp0: USB Bidirectional printer dev 4 if 0 alt 0 proto 2 vid 0x0483 pid 0x7540
[252066.634401] [I/USB_PNP_NOTIFY] UsbPnpNotifyHdfSendEvent:387 report one device information, 3 usbDevAddr=18446743524337473536, devNum=4, busNum=3, infoTable=1-0x483-0x7540
!\x0d

可以看到,UsbPnpNotify 服务首先获取到了新插入的设备,并将信息上报.

下面是ueventd打印的log,usb打印机的subsystem 为usbmisc, 路径为lp0.

[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:464)] subsystem = usb, syspath = /devices/platform/fd840000.usb/usb3/3-1
[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:483)] HandleOtherDeviceEvent, devPath = /dev, devName = 004
[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:464)] subsystem = usb, syspath = /devices/platform/fd840000.usb/usb3/3-1/3-1:1.0
[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:464)] subsystem = class, syspath = /class/usbmisc
[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:464)] subsystem = usbmisc, syspath = /devices/platform/fd840000.usb/usb3/3-1/3-1:1.0/usbmisc/lp0
[2017-8-8 7:1:8][pid=5353][<6>][INFO] [ueventd_device_handler.c:483)] HandleOtherDeviceEvent, devPath = /dev, devName = lp0

所以,只需要在HandleOtherDeviceEvent函数拼接路径即可。

void HandleOtherDeviceEvent(const struct Uevent *uevent)
{
    // 其他代码省略 。。。
    
    if (STRINGEQUAL(uevent->subsystem, "usb")) {
        
        。。。 省略, 这里是/dev/bus/usb/ 的设备结点
            
    } else if (STARTSWITH(uevent->subsystem, "usb")) {
        // Other usb devies, do not handle it.
        // 添加的代码
        if (!STRINGEQUAL(uevent->subsystem, "usbmisc")) {
            return;
        }
        if (devPath == NULL) {
            return;
        }
        //  printer usb devices ,need create device node: /dev/usb/lp%d
        if (snprintf_s(deviceNode, DEVICE_FILE_SIZE, DEVICE_FILE_SIZE - 1, "%s/usb/%s", devPath, devName) == -1) {
            INIT_LOGE("Make device file for device [%d : %d]", uevent->major, uevent->minor);
            return;
        }
    } 
}

通过运行, 设备结点确实创建了,而且可用。

# ls -al /dev/usb/
total 0
drwxr-xr-x  2 root root       60 2017-08-10 06:30 .
drwxr-xr-x 21 root root     4340 2017-08-05 09:00 ..
crw-------  1 root root 180,   0 2017-08-10 06:30 lp0

发现权限太高了,配置权限:

在/etc/ueventd.config 添加一行:

/dev/usb/*  0666 1005 1005

1005是samgr的uid,gid

重启系统,权限就变了。

# ls -al /dev/usb/
total 0
drwxr-xr-x  2 root  root        60 2017-08-09 14:09 .
drwxr-xr-x 21 root  root      4340 2017-08-09 14:09 ..
crwxrwxrwx  1 samgr samgr 180,   0 2017-08-09 14:09 lp0

执行打印命令,验证确实可用。

echo "test print" > /dev/usb/lp0

4. 总结

在这里插入图片描述

这里的设备是逻辑设备,对于usb来说就是接口驱动。

状态通知:

插入
通知
HdfSBuf
接口
netlink uevent
读写设备结点
设备
usb bus
HDF内核驱动服务
HDF用户态驱动服务
HDI
APP
Ueventd
创建设备结点

数据通讯:

read/write/ioctl
主设备号
次设备号
APP
设备结点
bus usb
设备
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值