linux内核中socket的实现

首先来看整个与socket相关的操作提供了一个统一的接口sys_socketcall. 

下面就是它的代码片段:

asmlinkage long sys_socketcall(int call, unsigned long __user *args)  
{  
    unsigned long a[6];  
    unsigned long a0, a1;  
    int err;  

  
    a0 = a[0];  
    a1 = a[1];  
  
    switch (call) {  
    case SYS_SOCKET:  
        err = sys_socket(a0, a1, a[2]);  
        break;  
    case SYS_BIND:  
        err = sys_bind(a0, (struct sockaddr __user *)a1, a[2]);  
        break;  
    case SYS_CONNECT:  
        err = sys_connect(a0, (struct sockaddr __user *)a1, a[2]);  
        break;  
    case SYS_LISTEN:  
        err = sys_listen(a0, a1);  
        break;  
    case SYS_ACCEPT:  
        err =  
            do_accept(a0, (struct sockaddr __user *)a1,  
                  (int __user *)a[2], 0);  
        break;  
    case SYS_GETSOCKNAME:  
        err =  
            sys_getsockname(a0, (struct sockaddr __user *)a1,  
                    (int __user *)a[2]);  
        break;  
 
    return err;  
}  
可以看到代码比较简单,就是通过传递进来的call类型,来调用相应的socket相关的函数. 

这里你可能注意到了,那就是一般文件句柄相关的操作,比如write,read,aio,poll这些并没有看到(也就是file_operations).这是因为socket上面其实还有一层vfs层,内核把socket当做一个文件系统来处理,并实现了相应的vfs方法.因此下面我们先来了解下vfs.然后会描述下进程如何通过vfs存取句柄. 

vfs其实就相当于对下层的文件系统和上层应用之间的粘合层,它定义了文件系统需要实现的相关的操作,然后下层的文件系统只需要实现这些方法就可以了,也就是说在内核其他部分和上层应用看来,所有的文件系统没有任何区别. 

下面的这张图就是从用户空间调用write的大体流程: 



vfs中有4种主要的数据结构: 

1 超级块对象,代表一个已安装的文件系统.super_block 

2 索引节点对象,代表一个文件.inode 

3 目录项对象,代表一个目录项.dentry 

4 文件对象,表示一个被进程打开的文件.file 

其中每种对象都包含一个操作对象.依次为super_operations,inode_operations,dentry_operations以及file_operations.各自操作不同的层次.然后我们的文件系统只需要实现这些方法,然后注册到内核就可以了. 

接下来我们来看和vfs相应的结构: 

第一个就是file_system_type结构,这个结构表示了一个文件系统: 
struct file_system_type {  
    const char *name;  
    int fs_flags;  
///最关键的函数,得到文件系统的超级块.  
    int (*get_sb) (struct file_system_type *, int,  
               const char *, void *, struct vfsmount *);  
    void (*kill_sb) (struct super_block *);    
};  

然后是vfsmount结构,它表示了一个安装点,换句话说也就是一个文件系统实例. 

第三个是files_struct结构,它主要是为每个进程来维护它所打开的句柄.这里只需要注意一个就是fd_array和fstable中的fd的区别.当进程数比较少也就是小于NR_OPEN_DEFAULT(32)时,句柄就会存放在fd_array中,而当句柄数超过32则就会重新分配数组,然后将fd指针指向它(然后我们通过fd就可以取得相应的file结构). 

而且files_struct是每个进程只有一个的.

struct files_struct {  
  /* 
   * read mostly part 
   */  
    atomic_t count;  
    struct fdtable *fdt;  
    struct fdtable fdtab;  
  /* 
   * written part on a separate cache line in SMP 
   */  
    spinlock_t file_lock ____cacheline_aligned_in_smp;  
    int next_fd;  
    struct embedded_fd_set close_on_exec_init;  
    struct embedded_fd_set open_fds_init;  
///所打开的所有文件  
    struct file * fd_array[NR_OPEN_DEFAULT];  
};  
  
  
struct fdtable {  
    unsigned int max_fds;  
    struct file ** fd;      /* current fd array */  
    fd_set *close_on_exec;  
    fd_set *open_fds;  
    struct rcu_head rcu;  
    struct fdtable *next;  
};  
还有两个一个是fs_struct,一个是namespace也都是进程相关的.这里就不一一介绍了. 

我这里vfs介绍只是个大概,需要详细了解的,可以去看ulk的vfs相关章节和linux内核设计与实现的相关章节. 

因此下面的图表示了进程和socket的关系: 




上面的这张图有些老了,新的内核中的inode节点中已经没有u这个联合体了,对应的是会有一个包含socket和inode的一个结构体,然后我们通过inode,而inode中专门有个i_mode域来判断相应的inode类型,比如socket就是 S_IFSOCK.就可以直接计算出相应的socket的地址,然后就可以存取socket了.后面我们会介绍. 

内核中标售socket有两个数据结构,一个是socket,另一个是sock,其中socket是一个general BSD socket, 它也就是应用程序和4层协议之间的一个接口,屏蔽掉了相关的4层协议部分.而在内核中,socket所需要使用的相关的4层协议的信息全部是保存在sock结构当中的,而socket和sock这两个结构都有保存对方的指针,因此可以很容易的存取对方.

还有一个就是ops域,这个域保存了所有的相关的4层协议的操作函数.. 

而在sock中有一个sk_common保存了一个skc_prot域,这个域保存的是相应的协议簇的操作函数的集合. 


后面介绍到socket创建的时候,我们会分析proto_ops和proto的区别.其实proto相当于对proto_ops的一层封装,最终会在proto中调用proto_ops. 

/** 
 *  struct socket - general BSD socket 
 *  @state: socket state (%SS_CONNECTED, etc) 
 *  @type: socket type (%SOCK_STREAM, etc) 
 *  @flags: socket flags (%SOCK_ASYNC_NOSPACE, etc) 
 *  @ops: protocol specific socket operations 
 *  @fasync_list: Asynchronous wake up list 
 *  @file: File back pointer for gc 
 *  @sk: internal networking protocol agnostic socket representation 
 *  @wait: wait queue for several uses 
 */  
struct socket {  
    socket_state        state;  
    short           type;  
    unsigned long       flags;  
    const struct proto_ops  *ops;  
    struct fasync_struct    *fasync_list;  
    struct file     *file;  
    struct sock     *sk;  
    wait_queue_head_t   wait;  
};  
  
struct sock_common {  
    unsigned short      skc_family;  
    volatile unsigned char  skc_state;  
    unsigned char       skc_reuse;  
    int         skc_bound_dev_if;  
    struct hlist_node   skc_node;  
    struct hlist_node   skc_bind_node;  
    atomic_t        skc_refcnt;  
    unsigned int        skc_hash;  
    struct proto        *skc_prot;  
#ifdef CONFIG_NET_NS  
    struct net      *skc_net;  
#endif  
};  
  
struct proto_ops {  
    int     family;  
    struct module   *owner;  
    int     (*release)   (struct socket *sock);  
    int     (*bind)      (struct socket *sock,  
                      struct sockaddr *myaddr,  
                      int sockaddr_len);  
    int     (*connect)   (struct socket *sock,  
                      struct sockaddr *vaddr,  
                      int sockaddr_len, int flags);  
 
};  
然后我们来看sock_init的实现,在这个函数中,将socket注册为一个伪文件系统,并安装相应的mount点: 

///相应的mount对象  
static struct vfsmount *sock_mnt __read_mostly;  
///文件系统对象.  
static struct file_system_type sock_fs_type = {  
    .name =     "sockfs",  
    .get_sb =   sockfs_get_sb,  
    .kill_sb =  kill_anon_super,  
};  
  
static int __init sock_init(void)  
{  
    /* 
     *      Initialize sock SLAB cache. 
     */  
  
    sk_init();  
  
    /* 
     *      Initialize skbuff SLAB cache 
     */  
    skb_init();  
  
///初始化一个inodecache.  
    init_inodecache();  
///注册文件系统到内核.  
    register_filesystem(&sock_fs_type);  
///安装mount点.  
    sock_mnt = kern_mount(&sock_fs_type);  
  
#ifdef CONFIG_NETFILTER  
    netfilter_init();  
#endif  
    return 0;  
} 
我们知道每次创建一个socket,都是要依赖于当前的protocol family类型的(后面会分析sys_socket的源码的时候会看到).而在内核中,每种类型的protocol family都会有一个相对应的net_proto_family结构,然后将这个结构注册到内核的net_families数组中,这样我们创建socket的时候,就可以调用这个数组来创建socket. 

我们先来看sock_register的源码,也就是如何将一个net_proto_family注册到相应的数组: 

static const struct net_proto_family *net_families[NPROTO] __read_mostly;  
  
int sock_register(const struct net_proto_family *ops)  
{  
    int err;  
  
    if (ops->family >= NPROTO) {  
        printk(KERN_CRIT "protocol %d >= NPROTO(%d)\n", ops->family,  
               NPROTO);  
        return -ENOBUFS;  
    }  
  
    spin_lock(&net_family_lock);  
///代码非常简单,就是根据类型,然后放到相应的位置.  
    if (net_families[ops->family])  
        err = -EEXIST;  
    else {  
        net_families[ops->family] = ops;  
        err = 0;  
    }  
    spin_unlock(&net_family_lock);  
  
    printk(KERN_INFO "NET: Registered protocol family %d\n", ops->family);  
    return err;  
}  
我们知道每个协议簇和相应的套接口都对应有好多种组合,因此在协议簇的实现中保存了一个相应的结构来保存这些组合,然后后面就首先通过family然后确定到某个结构,再根据套接口的类型来得到这个结构,并赋值给sock. 

这里要注意我们只分析af_inet的实现,其他的协议簇都差不多: 

我们来看这个的实现: 

///可以看到这是一个数组,每个元素都是一个链表,也就是每种类型的socket就是一个链表.而这个链表所包含的是不同4层协议的inetsw.可是在inet中,现在每种类型的socket只对应一个4层协议.这里只是为了以后扩展.  
static struct list_head inetsw[SOCK_MAX];  
  
///相应的socket的对应的信息的结构.  
struct inet_protosw {  
    struct list_head list;  
  
///需要这两个key才能定位一个inet_protosw.  
    unsigned short   type;     /* This is the 2nd argument to socket(2). */  
    unsigned short   protocol; /* This is the L4 protocol number.  */  
  
///相应的基于ipv4的4层协议的操作集合.  
    struct proto     *prot;  
///相应的协议簇的操作信息.  
    const struct proto_ops *ops;  
    
    int              capability; /* Which (if any) capability do 
                      * we need to use this socket 
                      * interface? 
                                      */  
    char             no_check;   /* checksum on rcv/xmit/none? */  
    unsigned char    flags;      /* See INET_PROTOSW_* below.  */  
};  
  
void inet_register_protosw(struct inet_protosw *p)  
{  
    struct list_head *lh;  
    struct inet_protosw *answer;  
    int protocol = p->protocol;  
    struct list_head *last_perm;  
 
    answer = NULL;  
    last_perm = &inetsw[p->type];  
///这个操作也很简单,就是将inet_protosw根据套接口类型插入到全局链表数组.  
    list_for_each(lh, &inetsw[p->type]) {  
        answer = list_entry(lh, struct inet_protosw, list);  
  
        /* Check only the non-wild match. */  
        if (INET_PROTOSW_PERMANENT & answer->flags) {  
            if (protocol == answer->protocol)  
                break;  
            last_perm = lh;  
        }  
  
        answer = NULL;  
    }  
    if (answer)  
        goto out_permanent;  
///插入链表.  
    list_add_rcu(&p->list, last_perm);  


接下来来分析inet_init的源码. 

///表示了所有的可能的当前协议簇和套接口类型的组合.  
static struct inet_protosw inetsw_array[] =  
{  
    {  
        .type =       SOCK_STREAM,  
        .protocol =   IPPROTO_TCP,  
        .prot =       &tcp_prot,  
        .ops =        &inet_stream_ops,  
        .capability = -1,  
        .no_check =   0,  
        .flags =      INET_PROTOSW_PERMANENT |  
                  INET_PROTOSW_ICSK,  
    },  
  
    {  
        .type =       SOCK_DGRAM,  
        .protocol =   IPPROTO_UDP,  
        .prot =       &udp_prot,  
        .ops =        &inet_dgram_ops,  
        .capability = -1,  
        .no_check =   UDP_CSUM_DEFAULT,  
        .flags =      INET_PROTOSW_PERMANENT,  
       },  
  
  
       {  
           .type =       SOCK_RAW,  
           .protocol =   IPPROTO_IP,    /* wild card */  
           .prot =       &raw_prot,  
           .ops =        &inet_sockraw_ops,  
           .capability = CAP_NET_RAW,  
           .no_check =   UDP_CSUM_DEFAULT,  
           .flags =      INET_PROTOSW_REUSE,  
       }  
};  
  
///协议簇的创建函数.  
static struct net_proto_family inet_family_ops = {  
    .family = PF_INET,  
    .create = inet_create,  
    .owner  = THIS_MODULE,  
};  
  
static int __init inet_init(void)  
{   
  
///注册相应的proto到全局链表中.  
    rc = proto_register(&tcp_prot, 1);  
    if (rc)  
        goto out;  
  
    rc = proto_register(&udp_prot, 1);  
    if (rc)  
        goto out_unregister_tcp_proto;  
  
    rc = proto_register(&raw_prot, 1);  
    if (rc)  
        goto out_unregister_udp_proto;  
  
///注册协议簇的操作函数(后面socket创建的时候会用到).  
    (void)sock_register(&inet_family_ops);  

    /* Register the socket-side information for inet_create. */  
    for (r = &inetsw[0]; r < &inetsw[SOCK_MAX]; ++r)  
        INIT_LIST_HEAD(r);  
  
///将inetsw_array插入到相应的数组链表.  
    for (q = inetsw_array; q < &inetsw_array[INETSW_ARRAY_LEN]; ++q)  
        inet_register_protosw(q);  
  
} 
接下来我们来通过分析创建socket的函数sys_socket,来更加好的理解socket的实现.

asmlinkage long sys_socket(int family, int type, int protocol)  
{   
///主要是两个函数,一个是创建socket  
    retval = sock_create(family, type, protocol, &sock);  
    if (retval < 0)  
        goto out;  
///这个是相应的文件系统的操作.  
    retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));  
}  
sock_create的具体流程我们就不分析了,我们只需要知道最终他会通过传递进来的family的值,来取得相应的family中注册的creat函数.然后会调用这个函数来完成socket的创建.而在上面的代码分析中,我们知道在af_inet中,注册的create函数是inet_create函数,因此我们来看这个函数的实现: 
static int inet_create(struct net *net, struct socket *sock, int protocol)  
{  
    struct sock *sk;  
    struct inet_protosw *answer;  
    struct inet_sock *inet;  
    struct proto *answer_prot;  
    unsigned char answer_flags;  
    char answer_no_check;  
    int try_loading_module = 0;  
    int err;  
///首先给socket状态赋值.  
  
    sock->state = SS_UNCONNECTED;  
  
    /* Look for the requested type/protocol pair. */  
lookup_protocol:  
    err = -ESOCKTNOSUPPORT;  
    rcu_read_lock();  
///通过type和protocl的值,来查找到相应的inet_protosw结构.  
    list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {  
  
        err = 0;  
        /* Check the non-wild match. */  
        if (protocol == answer->protocol) {  
            if (protocol != IPPROTO_IP)  
                break;  
        } else {  
            /* Check for the two wild cases. */  
            if (IPPROTO_IP == protocol) {  
                protocol = answer->protocol;  
                break;  
            }  
            if (IPPROTO_IP == answer->protocol)  
                break;  
        }  
        err = -EPROTONOSUPPORT;  
    }  
  
///开始给socket赋值.这里我们可以看到最终socket的ops域所得到的值就是相应的协议簇的操作集合(比如inet_stream_ops这些)..  
    sock->ops = answer->ops;  
    answer_prot = answer->prot;  
    answer_no_check = answer->no_check;  
    answer_flags = answer->flags;  
    rcu_read_unlock();  
  
    WARN_ON(answer_prot->slab == NULL);  
  
    err = -ENOBUFS;  
///alloc一个sock结构,其中将刚才取得的inet_protosw中的pro 域赋值给sock的sk_prot域和sk_prot_creator.以及family域也被相应的赋值.  
    sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot);  
    if (sk == NULL)  
        goto out;    
  
///这个函数中会初始化相应的socket中的写队列,读队列以及错误队列.并将sk指针和sock连接起来.而且还将初始化相应的定时器.  
  
    sock_init_data(sock, sk);  
///调用相应的初始化.  
  
    if (sk->sk_prot->init) {  
///其实也就是相对应4层协议的初始化函数,它会初始化一些协议相关的东西.  
        err = sk->sk_prot->init(sk);  
        if (err)  
            sk_common_release(sk);  
    }  
out:  
    return err;  
out_rcu_unlock:  
    rcu_read_unlock();  
    goto out;  
}
这里举个例子,来看一下tcp_v4_init_sock的实现,也就是tcp的初始化函数.

static int tcp_v4_init_sock(struct sock *sk)  
{  
    struct inet_connection_sock *icsk = inet_csk(sk);  
    struct tcp_sock *tp = tcp_sk(sk);  
  
    skb_queue_head_init(&tp->out_of_order_queue);  
///初始化定时器,也就是tcp的那3个定时器,write,delay以及keepalive定时器.  
    tcp_init_xmit_timers(sk);  
    tcp_prequeue_init(tp);   
///状态赋值.初始状态.  
    sk->sk_state = TCP_CLOSE;  
   
  
    return 0;  
}  

上面我们看到有两个新的结构inet_connection_sock以及tcp_sock.我们接下来就来看这两个结构. 

inet_connection_sock也就是所有面向连接的协议的socket的相关信息.它的第一个域是inet_sock,因此我们可以很方便的进行转换.而tcp_sock 相当与inet_connection_sock得一个子类,保存有所有tcp相关的socket的信息.它的第一个域就是inet_connection_sock. 

可以看到其实tcp_socket类似于inet_sock(前面的blog有介绍),都是保存了本层的相关的信息. 

这里就不列出这两个结构了,内核中这两个结构的注释都是很详细的.. 


在看sock_map_fd实现之前,我们先来看内核中socket类型的inode节点的实现: 

这里看到,我们只要拥有了inode节点,通过containof宏我们就可以计算出socket的地址,从而就可以得到整个socket的信息了. 
struct socket_alloc {  
    struct socket socket;  
    struct inode vfs_inode;  
}; 
而inode节点的赋值是在sock_alloc中实现的,而这个函数是在__sock_create中被调用的,也就是在init_cteate被调用之前. 

static struct socket *sock_alloc(void)  
{  
    struct inode *inode;  
    struct socket *sock;  
  
///新建一个inode,sock_mnt就是sock_init中被安装的mount点.  
    inode = new_inode(sock_mnt->mnt_sb);  
    if (!inode)  
        return NULL;  
///然后组合inode和socket结构.  
    sock = SOCKET_I(inode);  
///设置inode类型.  
    inode->i_mode = S_IFSOCK | S_IRWXUGO;  
    inode->i_uid = current->fsuid;  
    inode->i_gid = current->fsgid;  
///将sockets_in_use(也就是当前创建的socket)加一.  
    get_cpu_var(sockets_in_use)++;  
    put_cpu_var(sockets_in_use);  
    return sock;  
}  
然后我们来看sock_map_fd的实现.我们首先要知道,socket是没有open函数的,因此要通过vfs层的调用,必须要在create的时候,映射一个file结构,从而将句柄与这个file关联起来. 

int sock_map_fd(struct socket *sock, int flags)  
{  
    struct file *newfile;  
///找到一个可用的fd,并找到一个可用的file结构并返回.  
    int fd = sock_alloc_fd(&newfile, flags);  
  
    if (likely(fd >= 0)) {  
///初始化这个file结构.  
        int err = sock_attach_fd(sock, newfile, flags);  
  
        if (unlikely(err < 0)) {  
            put_filp(newfile);  
            put_unused_fd(fd);  
            return err;  
        }  
///将句柄和文件指针关联起来.  
        fd_install(fd, newfile);  
    }  
    return fd;  
}  
sock_alloc_fd实现比较简单,这里就不分析了. 
就来看下sock_attach_fd的实现.: 
这里要注意,内核通过把socket指针赋值给file的private_data,这样就可以通过句柄,在fdtable中得到file对象,然后轻松取得socket对象. 

///目录项的操作集合  
static struct dentry_operations sockfs_dentry_operations = {  
    .d_delete = sockfs_delete_dentry,  
    .d_dname  = sockfs_dname,  
};  
///文件的操作集合.这些函数最终调用的还是socket的ops域中的函数.而我们上面已经提过最终他们调用sock域的proto中的函数.  
static const struct file_operations socket_file_ops = {  
    .owner =    THIS_MODULE,  
    .llseek =   no_llseek,  
    .aio_read = sock_aio_read,  
    .aio_write =    sock_aio_write,  
    .poll =     sock_poll,  
    .unlocked_ioctl = sock_ioctl,  
#ifdef CONFIG_COMPAT  
    .compat_ioctl = compat_sock_ioctl,  
#endif  
    .mmap =     sock_mmap,  
    .open =     sock_no_open,   /* special open code to disallow open via /proc */  
    .release =  sock_close,  
    .fasync =   sock_fasync,  
    .sendpage = sock_sendpage,  
    .splice_write = generic_splice_sendpage,  
    .splice_read =  sock_splice_read,  
};  
  
static int sock_attach_fd(struct socket *sock, struct file *file, int flags)  
{  
    struct dentry *dentry;  
    struct qstr name = { .name = "" };  
///根据装载点的mnt_sb(super block)的root域来创建一个目录项.  
    dentry = d_alloc(sock_mnt->mnt_sb->s_root, &name);  
    if (unlikely(!dentry))  
        return -ENOMEM;  
///将sockfs的目录项操作集合赋值.  
    dentry->d_op = &sockfs_dentry_operations;  
    /* 
     * We dont want to push this dentry into global dentry hash table. 
     * We pretend dentry is already hashed, by unsetting DCACHE_UNHASHED 
     * This permits a working /proc/$pid/fd/XXX on sockets 
     */  
    dentry->d_flags &= ~DCACHE_UNHASHED;  
///将inode和目录项关联起来.  
    d_instantiate(dentry, SOCK_INODE(sock));  
  
    sock->file = file;  
///初始化文件对象,主要就是将socket_file_ops赋值给file结构的f_op域.  
    init_file(file, sock_mnt, dentry, FMODE_READ | FMODE_WRITE,  
          &socket_file_ops);  
    SOCK_INODE(sock)->i_fop = &socket_file_ops;  
    file->f_flags = O_RDWR | (flags & O_NONBLOCK);  
    file->f_pos = 0;  
///将sock赋值给private_data,这样我们就能通过file轻松获得socket结构(在后面会用到).  
    file->private_data = sock;  
  
    return 0;  
}  
下面就是sys_socket的流程图: 


 


最终来总结一下.内核中,socket是作为一个伪文件系统来实现的,它在初始化时注册到内核,而每个进程的files_struct域保存了所有的句柄,包括socket的.一般的文件操作的话,内核直接调用vfs层的方法,然后会自动调用socket实现的相关方法.内核通过inode结构的imode域就可以知道当前的句柄所关联的是不是socket类型,这时遇到socket独有的操作,就通过containof方法,来计算出socket的地址,从而就可以进行相关的操作. 


最后我们要注意的是,内核在调用相关操作都是直接调用socket的ops域,然后在ops域中调用相应的sock结构体中的sock_common域的skc_prot的操作集中的相对应的函数. 

举个例子,假设现在我们使用tcp协议然后调用bind方法,内核会先调用sys_bind方法: 

asmlinkage long sys_bind(int fd, struct sockaddr __user *umyaddr, int addrlen)  
{   
            if (!err)  
                err = sock->ops->bind(sock,  
                              (struct sockaddr *)  
                              &address, addrlen);   
}  
可以看到它调用的是ops域的bind方法.而这时我们的ops域是inet_stream_ops,来看它的bind方法: 
int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)  
{   
  
    /* If the socket has its own bind function then use it. (RAW) */  
    if (sk->sk_prot->bind) {  
        err = sk->sk_prot->bind(sk, uaddr, addr_len);  
        goto out;  
    }   
}  
它最终调用的是sock结构的sk_prot域(也就是sock_common的skc_prot域)的bind方法,而此时我们的skc_prot的值是tcp_prot,因此最终会调用tcp_prot的bind方法. 

下面就是示意图: 



PS:随便抱怨下,linux kernel的socket实现也太复杂了..不知道其他的操作系统的socket实现的怎么样..

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值