Why and How to Use Netlink Socket

Why and How to Use Netlink Socket

作者:Kevin He,2005-01-05

原文地址:http://www.linuxjournal.com/article/7356

 
译者:Love. Katherine,2007-03-23

译文地址:http://blog.csdn.net/lovekatherine/archive/2007/03/23/1539267.aspx

 
转载时务必以超链接形式标明文章原始出处及作者、译者信息。

                                                                            

一种用于在内核空间与用户空间之间双向传递数据的通用方法。

 

由于内核开发和维护的复杂性,内核中只保留最重要和对性能要求最严格的代码。其它部分,例如GUI、管理和控制代码等,通常以用户空间应用程序的方式实现。在Linux系统中,这种将某些功能在内核和用户空间中分开实现的做法是很常见的。

 

现在的问题是内核代码和用户空间代码之间要如何相互通讯?

 

答案就是存在于内核和用户空间之间的各种IPC方法,例如系统调用,ioctl, proc文件系统或者Netlink Socket. 本文对Netlink Socket进行讨论,并展示它作为网络特性友好IPC方法的优点。

 


socket不仅仅在网络中使用,非网络功能的而又是进程间通信也可用socket解决,这样的实例在android中大量存在

如vold http://www.cnblogs.com/bastard/archive/2012/12/03/2799298.html

charge http://blog.csdn.net/pillarbuaa/article/details/9062115

 

简介

 

Netlink Socket是一种用于在内核和用户空间进程之间传递信息的特殊IPC。对于用户进程,Netlink Socket以标准socket API的形式为内核与用户之间提供了全双工的通讯通道;而对于内核模块,则提供了一类特殊的API。相对于TCP/IP socket使用AF_INET地址族,Netlink Socket使用地址族AF_NETLINK。每个Netlink Socket功能定在kernel 头文件 include/linux/netlink.h中定义自己的protocol type。

 

以下是目前Netlink Socket所提供的功能和相应protocol type的一个子集

 

    *   NETLINK_ROUTE:用户空间的路由守护进程,例如BGP,OSPF,RIP等,与内核数据包转发模块之间的通讯通道。用户空间的路由守护进程通过该类型的Netlink Socket更新内核的路由表 

    *     NETLINK_FIREWALL:接收由IPV4 防火墙所放过的数据包。

    *     NETLINK_NFLOG:用户空间的iptable 管理工具与内核空间的Netfilter之间的通讯通道

    *     NETLINK_ARPD:用于用户空间程序管理ARP table。

 

为什么上面的功能要使用Netlink而不是系统调用、ioctl或proc文件系统来实现用户空间和内核世界的通讯?这是因为增加系统调用、ioctl或proc文件并不是件简单的事情——这样会有污染现有内核并损害系统稳定性的危险。而Netlink Socket则很简单,只有一个常量即协议类型,需要被添加至netlink.h头文件。之后,内核模块和应用程序可以立即使用socket风格的API进行通讯。

 

Netlink是异步通讯过程;和其他socket API一样,它为每个socket提供了缓冲队列,以使突发性的消息发送平滑化。用于发送Netlink message的系统调用将消息放入接收者所申请的Netlink Socket对应的缓冲队列中,之后触发接收者的接收处理函数。在执行接收处理函数执行这样的上下文环境下,接收者可以决定是立即处理收到的消息,还是将消息留在队列中留到稍后在不同的上下文环境中处理。不同于Netlink,系统调用要求同步处理。因此,假设我们使用系统调用从用户空间向内核传递一条消息,如果用于处理该消息的时间较长的话,可能会影响内核调度的粒度。

 

内核中用于实现系统调用的代码在编译是被静态连接入内核;因此,在可动态加载的模块中(大多数驱动程序都为此类),包含系统调用代码是不合适的使用方法。而对于Netlink Socket,在Linux kernel的Netlink模块核心,与存在于可加载内核模块中的Netlink 应用程序,这两者之间不存在编译时的依赖问题。

 

Netlink Socket 支持多播,这是与系统调用、ioctls和proc文件系统相比的又一优势。一个进程可以以多播的形式,将一条消息发送给一个Netlink 组地址,同时任意数量的其他进程都可以监听该组地址。这就为从内核向用户空间分发事件通知提供了一种近乎完美的解决机制。

 

系统调用和ioctl都是单工IPC,即只有用户进程能使用这两种IPC方法建立会话。然而,如果一个内核模块有一个紧急消息要发送给用户进程,该怎么办?用这两种IPC,没有直接的解决办法。通常,应用程序周期性的对kernel进行轮询以检查状态的变化,然而轮询的代价是很高的(占用大量CPU时间)。Netlink通过也允许内核发起会话的方式,优雅的解决了这一问题。这称之为Netlink的双工特性。

 

最后,Netlink Socket 提供的BSD socket风格的API,很容易被软件开发者所理解。

 

 

与BSD路由socket的关系

 

在BSD的TC/IP协议栈的实现中,包括一种被称为路由socket的特殊sccket。该类socket使用地址族AF_ROUTE ,socket类型为原始套接字(SOCK_RAW),协议类型为PF_ROUTE。在BSD中,进程通过路由socket来对内核路由表执行添加或删除操作。

 

在Linux中,与BSD中的路由socket对等的功能是由协议类型为NETLINK_ROUTE的Netlink Socket来提供的,而且Netlink Socket所提供的功能是BSD的路由socket的超集。

 

 

Netlink Socket APIS

 

 

标准socket API——socket(),sendmsg(), recvmsg() and close()——,都可以被用户空间进程用于操作Netlink Socket。这些API的详细说明请查阅相关的使用手册(man pages)。本文只针对Netlink Socket来讨论如何为这些API选择合适的参数。对于任何曾经使用TCP/IP socket编写过普通网络应用程序的用户,这些API应该是非常熟悉的。

 

 

socket()

 

通过socket()函数创建一个socket,输入:

 

int socket(int domain, int type, int protocol);

 

Netlink Socket所使用的域(地址族)为AF_NETLINK,socket类型为原始套接字(SOCK_RAW)或数据报套接字(SOCK_DGRAM),因为Netlink提供的是面向消息的服务。

 

协议类型(protocol)决定了使用Netlink所提供的哪项功能。以下是一些预定义的Nettlinkx协议类型:

NETLINK_ROUTE,NETLINK_FIREWALL, NETLINK_ARPD, NETLINK_ROUTE6 and NETLINK_IP6_FW。

用户可以很容易的添加自己的Netlink 协议类型。

 

bind()

 

每个Netlink协议类型中可以最多定义32个多播组。每个多播组用相应的掩码表示 (1<<i, 其中0<=i<=31)。当一组用户进程和内核协调完成同一功能时,这是及其有用的。发送多播Netlink message能够减少执行系统调用的次数,并且减轻了用户进程需要维护多播组成员列表的负担。

 

类似于TCP/IP socket,Netlink 的bind() API 将已打开的socket与某一本地socket地址结构关联起来 。

 

Netlink Socket的地址结构如下

 

struct sockaddr_nl

{

  sa_family_t    nl_family;             /* AF_NETLINK   */                 地址族

  unsigned short nl_pad;                   /* zero         */                  

  __u32          nl_pid;               /* process pid */                        进程ID

  __u32          nl_groups;           /* mcast groups mask */            多播组掩码

} nladdr;

 

 

调用bind()时,结构sockaddr_nl 的nl_pid字段应该填写为调用进程的pid。在这里,nl_pid字段充当了Netlink Socket的本地地址的角色。应用程序需负责选择一个唯一的32字节的整数填入该字段。

 

NL_PID Formula 1:  nl_pid = getpid();

 

生成式1:选择应用程序的pid作为nl_pid的值。如果对于给定的Netlink 协议类型,进程只需要一个Netlink Socket的话,这是种很自然也很合理的选择。

 

如果同一进程内的不同线程需要创建多个同一协议类型的Netlink Socket,可采用生成式2来生成合适的nl_pid.

 

 

NL_PID Formula 2: pthread_self() << 16 | getpid();

 

生成式2:这种方式下,同一进程内的不同线程都可以为同一Netlink 协议类型申请自己特有的socket。实际上,即使在一个线程内,创建多个基于相同协议类型的Netlink Socket也是可能的。然而,开发者需要在如何生成唯一nl_pid上更具创造性。此处,我们不考虑这种非正常情形。

 

如果应用程序希望接收到某种协议类型发往某些多播组的Netlink message,那么就应该将其所有感兴趣的多播组的掩码通过"OR"运算组合起来,并填入sockaddr_nl结构中的nl_groups字段。否则,nl_groups字段就应该被清零,这样应用程序就只接收到发送至该进程的对应协议类型的单播Netlink message。将变量nladdr(类型为struct sockaddr_nl)填写好后,执行如下的bind()

 

bind(fd, (struct sockaddr*)&nladdr, sizeof(nladdr));

 

发送Netlink message

 

为了向内核和其他用户空间进程发送消息,需要另外一个类型为sockaddr_nl的对象提供目标地址,这点与通过sendmsg发送UDP包相同。

 

如果消息是发往内核的,nl_pid和nl_groups字段都应该置0。

如果是发往另一个进程的单播消息, nl_pid应该是目标进程的pid而nl_groups字段置0(假设系统采用生成式1计算nl_pid)。

如果是发往一个或多个多播组的消息,所有目标多播组对应的掩码应该执行"OR"操作后填入nl_groups字段。

 

然后,按如下方式,向sendmsg()API 所需要的 msghdr结构提供目标Netlink 地址。

 

struct msghdr msg;

msg.msg_name = (void *)&(nladdr);

msg.msg_namelen = sizeof(nladdr);

 

Netlink Socket 还需要有自己的消息头部。这是为了为所有Netlink协议类型提供一个公共基础。

 

由于Linux内核中的Netlink核心假设如下头部在每个Netlink message中的存在,用户必须为每个发送的Netlink message提供这个头部。

 

struct nlmsghdr

{

  __u32 nlmsg_len;   /* Length of message */                //消息总长度

  __u16 nlmsg_type;  /* Message type*/                        //消息类型

  __u16 nlmsg_flags; /* Additional flags */                //附加控制

  __u32 nlmsg_seq;   /* Sequence number */                 //序列号

  __u32 nlmsg_pid;   /* Sending process PID */             //发送方的pid

};

 

 

nlmsg_len     表示整个Netlink message的长度(包括消息的头部),并且是Netlink核心要求必须填写的。

nlmsg_type     由用户使用,对Netlink核心是一个不透明的值。

nlmsg_flags     用于对Netlink message提供额外的控制;该字段被Netlink 核心读取并更新。

nlmsg_seq和nlmsg_pid由用户进程用于跟踪消息,对于Netlink 核心同样是不透明的值。

 

因此,一条Netlink message由消息头部(nlmsghdr结构)和消息负载组成。一旦一条消息被输入,它被放入由nlh 指针所指向的缓冲区。

 

struct iovec iov;

iov.iov_base = (void *)nlh;

iov.iov_len = nlh->nlmsg_len;

msg.msg_iov = &iov;

msg.msg_iovlen = 1;

 

完成上述步骤后,调用sendmsg(),将消息发送出去。

 

sendmsg(fd, &msg, 0);

 

接收Netlink message

 

接收进程需要分配足够大的缓冲区来存放Netlink message(包括消息头部消息负载)。然后需要填写如下的struct msghdr,并调用标准的recvmsg()来接收Netlink message(此处假设nth指向缓冲区)

 

 

struct sockaddr_nl nladdr;

struct msghdr msg;

struct iovec iov;

iov.iov_base = (void *)nlh;

iov.iov_len = MAX_NL_MSG_LEN;

msg.msg_name = (void *)&(nladdr);

msg.msg_namelen = sizeof(nladdr);

msg.msg_iov = &iov;

msg.msg_iovlen = 1;

recvmsg(fd, &msg, 0);

 

 

消息被正确接收后,nth应该指向刚接收的Netlink message的头部,而nladdr则应该存放着接收到消息的目标地址,其中包含目标pid和多播组。定义于头文件netlink.h中的宏NLMSG_DATA(nlh),返回指向Netlink message的负载的指针。

 

调用close(fd)则关闭由文件描述符fd所标识的Netlink Socket

 

内核空间使用的Netlink API

 

内核空间的Netlink API是由Netlink核心在net/core/af_netlink.c文件提供的。内核使用与用户空间不同的API。内核模块可以调用这些API来操纵Netlink Socket,并与用户空间程序通讯。若不打算利用已有的Netlink协议类型,用户必须通过在netlink.h中添加常量来添加自己的协议。

 

例如,我们可以通过在netllink.h头文件中插入下面一行,来增加一种用于测试目的的协议类型。

 

#define NETLINK_TEST  17

 

之后,就可以在内核的任意地方引用所添加的协议类型

 

在用户空间,用户调用socket()函数来创建Netlink Socket;但是在内核空间,则需要调用下面的API:

 

struct sock * 

netlink_kernel_create(int unit,

                                  void (*input)(struct sock *sk, int len));

 

参数unit实际上是Netlink协议类型,例如NETLINK_TEST。函数指针input,指向一个回调函数,该函数在有消息到达Netink Socket时被调用。

 

在内核创建了一个类型为NETLINK_TEST的Netlink Socket后,无论何时用户空间向内核发送一条类型为NETLINK_TEST的Netlink message时,之前调用netlink_kernel_create()时通过input参数注册的回调函数被调用。下面是一个回调函数的示例代码

 

void input (struct sock *sk, int len)

{

      struct sk_buff *skb;

      struct nlmsghdr *nlh = NULL;

      u8 *payload = NULL;

      while ((skb = skb_dequeue(&sk->receive_queue))

         != NULL) {

             /* process netlink message pointed by skb->data */

             nlh = (struct nlmsghdr *)skb->data;

             payload = NLMSG_DATA(nlh);

             /* process netlink message with header pointed by

             * nlh and payload pointed by payload

             */

             }  

}

 

 

input()函数是在由发送进程所激发的sendmeg()系统调用的上下文环境中执行的。如果对该Netlink message的处理速度很快的话,在input()函数中执行对消息的处理是没有问题的。但是如果对该Netlink message的处理是耗时操作,为了避免阻止其他系统调用"陷入"内核,应该将处理操作移出input()函数。这种情况下可以使用一个内核线程来无限循环的完成下述操作。

 

使用 skb = skb_recv_datagram(nl_sk),其中nl_sk是 netlink_kernel_create()返回的Netlink Socket。然后,处理由skb->data所指向的netlink message。

 

内核线程在nl_sk中没有Netlink message时睡眠。因此,在回调函数input()中,只需要唤醒睡眠的内核进程,如下:

 

void input (struct sock *sk, int len)

{

             wake_up_interruptible(sk->sleep);

}

 

这种方式是一种用户空间和内核间更具扩展性的通讯模型。此外,还改善了上下文切换的粒度。

 

从内核发送Netlink message

 

如同在用户空间一样,源Netlink 地址和目标Netlink 地址,这两者需要在发送Netlink message时指定。

假设指针skb指向存放待发送netlink message的sk_buff 结构,源地址可以这样设置:

 

NETLINK_CB(skb).groups = local_groups;

NETLINK_CB(skb).pid = 0;                     /* from kernel */

 

目标地址可这样设置:

 

NETLINK_CB(skb).dst_groups = dst_groups;

NETLINK_CB(skb).dst_pid = dst_pid;

 

以上这些信息并不存放在skb->data指向的缓冲区中,而是存放在sk_buff的control block字段中。

 

 

要发送单播消息,使用:

int

netlink_unicast(struct sock *ssk, struct sk_buff

                       *skb, u32 pid, int nonblock);

 

其中参数ssk是由netlink_kernel_create()返回的Netlink Socket,skb->data指向要发送的Netlink message,而参数pid为接收进程的pid(假设采用的是NLPID 计算方法一);参数nonblock指示API在接收缓冲区不可用时是阻塞(),还是立即返回一个错误。

 

内核同样可以发送多播消息。下面的API不仅消息发送至由参数pid执行的进程,也发送至由参数group指定的多播组

 

void

netlink_broadcast(struct sock *ssk, struct sk_buff

              *skb, u32 pid, u32 group, int allocation);

 

参数group是所有目标多播组对应掩码的"OR"操作的合值。参数allocation指定内核内存分配方式,通常GFP_ATOMIC用于中断上下文,而GFP_KERNEL用于其他场合。这个参数的存在是因为该API可能需要分配一个或多个缓冲区来对多播消息进行clone。

 

在内核中关闭一个Netlink Socket

 

对于通过netlink_kernel_create()返回的 指向sock结构的指针nl_sk ,调用如下的API来关闭内核中的Netlink Socket

 

sock_release(nl_sk->socket);

 

目前为止,只展示了描述Netlink 编程框架的最少代码。现在我们要使用己定义的NETLINK_TEST 协议类型,并假设其已经被添加至内核头文件中。这里展示的内核模块代码只包含netlink 相关的部分,所以它应该被插入一个完整的内核模块框架,而这样的框架可以从很多地方找到。

 

In this example, a user-space process sends a netlink message to the kernel module, and the kernel module echoes the message back to the sending process. Here is the user-space code:

 

 

#include <sys/socket.h>

#include <linux/netlink.h>

#define MAX_PAYLOAD 1024  /* maximum payload size*/

struct sockaddr_nl src_addr, dest_addr;

struct nlmsghdr *nlh = NULL;

struct iovec iov;

int sock_fd;

void main() {

 sock_fd = socket(PF_NETLINK, SOCK_RAW,NETLINK_TEST);

 memset(&src_addr, 0, sizeof(src_addr));

 src__addr.nl_family = AF_NETLINK;     

 src_addr.nl_pid = getpid();  /* self pid */

 src_addr.nl_groups = 0;  /* not in mcast groups */

 bind(sock_fd, (struct sockaddr*)&src_addr,

      sizeof(src_addr));

 memset(&dest_addr, 0, sizeof(dest_addr));

 dest_addr.nl_family = AF_NETLINK;

 dest_addr.nl_pid = 0;   /* For Linux Kernel */

 dest_addr.nl_groups = 0; /* unicast */

 nlh=(struct nlmsghdr *)malloc(

                         NLMSG_SPACE(MAX_PAYLOAD));

 /* Fill the netlink message header */

 nlh->nlmsg_len = NLMSG_SPACE(MAX_PAYLOAD);

 nlh->nlmsg_pid = getpid();  /* self pid */

 nlh->nlmsg_flags = 0;

 /* Fill in the netlink message payload */

 strcpy(NLMSG_DATA(nlh), "Hello you!");

 iov.iov_base = (void *)nlh;

 iov.iov_len = nlh->nlmsg_len;

 msg.msg_name = (void *)&dest_addr;

 msg.msg_namelen = sizeof(dest_addr);

 msg.msg_iov = &iov;

 msg.msg_iovlen = 1;

 sendmsg(fd, &msg, 0);

 /* Read message from kernel */

 memset(nlh, 0, NLMSG_SPACE(MAX_PAYLOAD));

 recvmsg(fd, &msg, 0);

 printf(" Received message payload: %s/n",

        NLMSG_DATA(nlh));

   

 /* Close Netlink Socket */

 close(sock_fd);

}   

 

 

 

And, here is the kernel code:

 

 

struct sock *nl_sk = NULL;

void nl_data_ready (struct sock *sk, int len)

{

  wake_up_interruptible(sk->sleep);

}

void netlink_test() {

 struct sk_buff *skb = NULL;

 struct nlmsghdr *nlh = NULL;

 int err;

 u32 pid;    

 nl_sk = netlink_kernel_create(NETLINK_TEST,

                                   nl_data_ready);

 /* wait for message coming down from user-space */

 skb = skb_recv_datagram(nl_sk, 0, 0, &err);

 nlh = (struct nlmsghdr *)skb->data;

 printk("%s: received netlink message payload:%s/n",

        __FUNCTION__, NLMSG_DATA(nlh));

 pid = nlh->nlmsg_pid; /*pid of sending process */

 NETLINK_CB(skb).groups = 0; /* not in mcast group */

 NETLINK_CB(skb).pid = 0;      /* from kernel */

 NETLINK_CB(skb).dst_pid = pid;

 NETLINK_CB(skb).dst_groups = 0;  /* unicast */

 netlink_unicast(nl_sk, skb, pid, MSG_DONTWAIT);

 sock_release(nl_sk->socket);

}   

 

 

 

After loading the kernel module that executes the kernel code above, when we run the user-space executable, we should see the following dumped from the user-space program:

 

Received message payload: Hello you!

 

 

 

And, the following message should appear in the output of dmesg:

 

netlink_test: received netlink message payload:

Hello you!

 

 

 

Multicast Communication between Kernel and Applications

 

In this example, two user-space applications are listening to the same netlink multicast group. The kernel module pops up a message through Netlink Socket to the multicast group, and all the applications receive it. Here is the user-space code:

 

 

#include <sys/socket.h>

#include <linux/netlink.h>

#define MAX_PAYLOAD 1024  /* maximum payload size*/

struct sockaddr_nl src_addr, dest_addr;

struct nlmsghdr *nlh = NULL;

struct iovec iov;

int sock_fd;

void main() {

 sock_fd=socket(PF_NETLINK, SOCK_RAW, NETLINK_TEST);

 memset(&src_addr, 0, sizeof(local_addr));

 src_addr.nl_family = AF_NETLINK;      

 src_addr.nl_pid = getpid();  /* self pid */

 /* interested in group 1<<0 */ 

 src_addr.nl_groups = 1;

 bind(sock_fd, (struct sockaddr*)&src_addr,

      sizeof(src_addr));

 memset(&dest_addr, 0, sizeof(dest_addr));

 nlh = (struct nlmsghdr *)malloc(

                          NLMSG_SPACE(MAX_PAYLOAD));

 memset(nlh, 0, NLMSG_SPACE(MAX_PAYLOAD));     

   

 iov.iov_base = (void *)nlh;

 iov.iov_len = NLMSG_SPACE(MAX_PAYLOAD);

 msg.msg_name = (void *)&dest_addr;

 msg.msg_namelen = sizeof(dest_addr);

 msg.msg_iov = &iov;

 msg.msg_iovlen = 1;

 printf("Waiting for message from kernel/n");

 /* Read message from kernel */

 recvmsg(fd, &msg, 0);

 printf(" Received message payload: %s/n",

        NLMSG_DATA(nlh));

 close(sock_fd);

}   

 

 

 

And, here is the kernel code:

 

 

#define MAX_PAYLOAD 1024

struct sock *nl_sk = NULL;

void netlink_test() {

 sturct sk_buff *skb = NULL;

 struct nlmsghdr *nlh;

 int err;

 nl_sk = netlink_kernel_create(NETLINK_TEST,

                               nl_data_ready);

 skb=alloc_skb(NLMSG_SPACE(MAX_PAYLOAD),GFP_KERNEL);

 nlh = (struct nlmsghdr *)skb->data;

 nlh->nlmsg_len = NLMSG_SPACE(MAX_PAYLOAD);

 nlh->nlmsg_pid = 0;  /* from kernel */

 nlh->nlmsg_flags = 0;

 strcpy(NLMSG_DATA(nlh), "Greeting from kernel!");

 /* sender is in group 1<<0 */

 NETLINK_CB(skb).groups = 1;

 NETLINK_CB(skb).pid = 0;  /* from kernel */

 NETLINK_CB(skb).dst_pid = 0;  /* multicast */

 /* to mcast group 1<<0 */

 NETLINK_CB(skb).dst_groups = 1;

 /*multicast the message to all listening processes*/

 netlink_broadcast(nl_sk, skb, 0, 1, GFP_KERNEL);

 sock_release(nl_sk->socket);

}   

 

 

 

Assuming the user-space code is compiled into the executable nl_recv, we can run two instances of nl_recv:

 

 

./nl_recv &

Waiting for message from kernel

./nl_recv &

Waiting for message from kernel

 

 

 

Then, after we load the kernel module that executes the kernel-space code, both instances of nl_recv should receive the following message:

 

Received message payload: Greeting from kernel!

Received message payload: Greeting from kernel!

 

 

总结

 

 

Netlink Socket 是一种用于用户空间程序和内核之间通讯的灵活的借口。它为应用程序和内核提供一套易用的socket API还提供了其他高级通讯功能,例如全双工,缓冲式I/O,多播,以及异步通讯,这些都是其他内核-用户空间 IPC方法所缺少的。

 

Kevin Kaichuan He(hek_u5@yahoo.com)是Solustek Corp.的首席软件工程师。他目前的工作包括嵌入式系统、设备驱动以及网络协议工程。他之前的工作经验包括任Cisco Systems高级软件工程师,Purdue University 计算机系助教。业余时间,他喜欢数字摄影、PS2游戏和文学。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值