Linux内核——进程通信

一.概述

    进程间通信的基本机制有:管道,FIFO(命名管道),信号量,消息队列,共享内存以及套接字。此篇博文不说明其使用,而是说明在linux内核中的实现原理,关于如何使用可以参考博文《进程间通信(IPC)》

 

二.管道

1.管道数据结构

       无名管道没有对应的磁盘映像,而是通告pipefs特殊文件系统加以处理的,有了pipefs便可以将管道完全整合到vfs(虚拟文件系统)中。对于每个管道,内核都会为其创建一个索引节点和两个文件对象,一个文件对象用于读,另一个文件对象用于写。文件对象与索引节点间通过目录项进行连接(这个组织结构可以参博文《文件访问》)。

      当索引指向的是一个管道时,会启用其中的i_pipe子段指向一个pipe_inode_info结构,该结构描述了管道的信息,其中包括:等待队列,缓冲区描述符数组,阻塞读进程的数量等,如下所示:

struct pipe_inode_info {
	wait_queue_head_t wait;                // 等待队列(阻塞的读写进程会等待在该队列上)
	unsigned int nrbufs;                   // 包含读数据的缓冲区的数量
        unsigned int curbuf;                   // 包含读数据的第一个缓冲区索引
	struct pipe_buffer bufs[PIPE_BUFFERS]; // 管道缓冲区描述符数组(PIPE_BUFFERS = 16)
	struct page *tmp_page;                 // 高速缓存页框指针
	unsigned int start;                    // 
	unsigned int readers;                  // 读进程的标志(当有读进行时为1)
	unsigned int writers;                  // 写进程的标志(当有写进程时为1)
	unsigned int waiting_writers;          // 阻塞的读进程数量
	unsigned int r_counter;                // 与readers类似(FIFO时使用)
	unsigned int w_counter;                // 与writers类似(FIFO时使用)
	struct fasync_struct *fasync_readers;  // 
	struct fasync_struct *fasync_writers;  //
};

// 管道缓冲区
struct pipe_buffer {
	struct page *page;                // 指向实际页框
	unsigned int offset;              // 页框内有效数据的起始偏移
        unsigned int len;                 // 页框内有效数据长度
	struct pipe_buf_operations *ops;  // 管道缓冲区方法表
};

       每个管道都有一个自己的环形缓冲,拥有16个缓冲区,每个缓冲区的的大小为一个页(4096字节,这也是为什么任何少于4096字节的写操作都是原子的,之后会根据源码进一步解释)。可通过curbuf获取读索引,通过curbuf与nrbufs获取写索引。

 

2.管道的创建与撤销

     进行pipe()系统调用的进程是最初唯一一个可以读写管道的进程,此时管道的pipe_inode_info结构的readers和writes结构会被初始化为1,事实上,只要用与读写的文件对象还存在,那么readers与writers就会一直为1,亦不会随着读写进程的增多而增加,读写进程的增加只会增加相应文件对象的引用计数。当引用计数为0时文件对象会被释放,且readers和writers置为0,还会释放所有管道缓冲区的页框并唤醒管道等待队列上睡眠的所有进程,以使其可以识别管道状态的变化

 

3.管道读取

      对管道进行读写操作时都需先获取索引节点中用于保护管道的信号量i_sem。当获取到信号量后,通过curbuf找到第一块含有数据的缓冲区,并从中读取指定大小的数据,每次至多读取一个缓冲区中的数据,若不足则待下轮循环读取下一个缓冲区。若此次读操作读空了至少一个缓冲区,那么便会唤醒阻塞在该管道等待队列上的写进程。管道读取过程中区分了各种情况,大致情况如下:当管道内数据大于欲读数据时,直接读取n个字节并返回,当管道内无数据时则等待数据(非阻塞不等待),若管道内的数据量p小于欲读数据量n,则返回p个字节。可参见下表:

       从管道读取数据的代码如下:

static ssize_t  pipe_readv(struct file *filp, const struct iovec *_iov, unsigned long nr_segs, loff_t *ppos)
{
	struct inode *inode = filp->f_dentry->d_inode;
	struct pipe_inode_info *info;
	int do_wakeup;
	ssize_t ret;
	struct iovec *iov = (struct iovec *)_iov;
	size_t total_len;

	total_len = iov_length(iov, nr_segs); // 
	/* Null read succeeds. */
	if (unlikely(total_len == 0))
		return 0;

	do_wakeup = 0;
	ret = 0;
	down(PIPE_SEM(*inode));      // 获取信号量
	info = inode->i_pipe;
	for (;;) {
		int bufs = info->nrbufs; // 含有数据的缓冲区的数量
		if (bufs) {
			int curbuf = info->curbuf;
			struct pipe_buffer *buf = info->bufs + curbuf; // 指向第一个含有数据的缓冲区
			struct pipe_buf_operations *ops = buf->ops;
			void *addr;
			size_t chars = buf->len; // 第一个缓冲区中的数据量
			int error;

			if (chars > total_len)   // 最多读取指定大小的数据
				chars = total_len;

			addr = ops->map(filp, info, buf);
			error = pipe_iov_copy_to_user(iov, addr + buf->offset, chars); // 将数据拷贝到用户缓存
			ops->unmap(info, buf);
			if (unlikely(error)) {
				if (!ret) ret = -EFAULT;
				break;
			}
			ret += chars;
			buf->offset += chars;
			buf->len -= chars;
			if (!buf->len) {   // 若该缓冲区已被读空则进行相应释放操作(释放该缓冲区对应的页框)(这或许也会影响效率)
				buf->ops = NULL;
				ops->release(info, buf);
				curbuf = (curbuf + 1) & (PIPE_BUFFERS-1);
				info->curbuf = curbuf;
				info->nrbufs = --bufs;
				do_wakeup = 1; // 标记唤醒(只有在读空一个缓冲区时才标记)
			}
			total_len -= chars;
			if (!total_len)    // 数据读取完毕
				break;	/* common path: read succeeded */
		}
		if (bufs)	/* More to do? */ // 仍有缓冲区可读
			continue;
		if (!PIPE_WRITERS(*inode)) 	  // 若此时已无写者(pipe_inode_info::writes == 0),则退出
			break;

		// 运行到此处说明欲读的数据量大于管道内的数据量,此时若无等待的写进程且已读到一些数据则跳出返回
		// 这样在一些情况下不会阻塞读进程:比如有一个写者与一个读者,那么当写者只写了n个字节,但读者却希望读
		// 到m(m > n)个字节时不会阻塞。这也是调用read时可以指定缓冲区大小,而非数据大小的原因。
		if (!PIPE_WAITING_WRITERS(*inode)) {
			/* syscall merging: Usually we must not sleep
			 * if O_NONBLOCK is set, or if we got some data.
			 * But if a writer sleeps in kernel space, then
			 * we can wait for that data without violating POSIX.
			 */
			if (ret)
				break;
			if (filp->f_flags & O_NONBLOCK) {
				ret = -EAGAIN;
				break;
			}
		}
		if (signal_pending(current)) {
			if (!ret) ret = -ERESTARTSYS;
			break;
		}
		if (do_wakeup) {   // 当某个缓冲区被读空时会唤醒阻塞在该管道上的写进程
			wake_up_interruptible_sync(PIPE_WAIT(*inode));
 			kill_fasync(PIPE_FASYNC_WRITERS(*inode), SIGIO, POLL_OUT);
		}
		pipe_wait(inode);  // 等待数据,释放信号量(此时写进程便可获取信号量),并阻塞在该管道的等待队列上
	}
	up(PIPE_SEM(*inode));  // 释放信号量
	/* Signal writers asynchronously that there is more room.  */
	if (do_wakeup) {
		wake_up_interruptible(PIPE_WAIT(*inode));
		kill_fasync(PIPE_FASYNC_WRITERS(*inode), SIGIO, POLL_OUT);
	}
	if (ret > 0)
		file_accessed(filp);
	return ret;
}

4.写管道

      当我们调用write()写管道时会调用pipe_write()函数。当写管道时,任何少于4096字节的写操作都是原子的,即当多个进程进行小于4096字节的写操作时不会串话。这是由以下过程决定的,假设要写入n个字节,首先会查找最后一个存由数据的缓冲区能否追加n个字节,若可以则进行追加,否则检查是否有空余缓冲区,若有则写入min(n, 4096)个字节到该缓冲区,对于剩余的n-4096个字节会根据是否还存在剩余缓冲而决定,若仍有空闲缓冲区,则继续写入,否则将进程阻塞在该管道的等待队列上,并释放信号量,此时其它写进进程便可进入,从而造成数据串话。其各种情况如下图所示:

【注】:不同于读数据时,只有当读空至少一个缓冲区才会唤醒写进程,写操作必会唤醒阻塞的读进程,且在缓冲区耗尽时,在陷入睡眠之前便会唤醒读进程(若不这样做,那么在写很大的数据时,将永远阻塞读进程)。

【注】:每次在写入管道前都会先检查管道是否存在读进程,若不存在(readers为0),则会向进程发送SIGPIPE信号。

      代码如下所示:

static ssize_t
pipe_writev(struct file *filp, const struct iovec *_iov,
	    unsigned long nr_segs, loff_t *ppos)
{
	struct inode *inode = filp->f_dentry->d_inode;
	struct pipe_inode_info *info;
	ssize_t ret;
	int do_wakeup;
	struct iovec *iov = (struct iovec *)_iov;
	size_t total_len;

	total_len = iov_length(iov, nr_segs);
	/* Null write succeeds. */
	if (unlikely(total_len == 0))
		return 0;

	do_wakeup = 0;
	ret = 0;
	down(PIPE_SEM(*inode)); // 获取信号量
	info = inode->i_pipe;

	if (!PIPE_READERS(*inode)) { // 检查管道是否至少有一个读进程(pipe_inode_info::readers == 1),否则发送SIGPIPE信号
		send_sig(SIGPIPE, current, 0);
		ret = -EPIPE;
		goto out;
	}

	/* We try to merge small writes */
	// 尝试将此次的数据追加到最后一个装有数据的缓冲区中
	if (info->nrbufs && total_len < PAGE_SIZE) { // 若存在待读数据,且此次写入的数据量小于一页
		int lastbuf = (info->curbuf + info->nrbufs - 1) & (PIPE_BUFFERS-1); // 最后一个装有数据的缓冲区
		struct pipe_buffer *buf = info->bufs + lastbuf;
		struct pipe_buf_operations *ops = buf->ops;
		int offset = buf->offset + buf->len;
		if (ops->can_merge && offset + total_len <= PAGE_SIZE) { // 最后一个装有数据的缓冲区可以装下新数据
			void *addr = ops->map(filp, info, buf);
			int error = pipe_iov_copy_from_user(offset + addr, iov, total_len);
			ops->unmap(info, buf);
			ret = error;
			do_wakeup = 1;
			if (error)
				goto out;
			buf->len += total_len;
			ret = total_len;
			goto out;
		}
			
	}

	// 若最后一个装有数据的缓冲区不可追加,则放入其它缓冲区
	for (;;) {
		int bufs;
		if (!PIPE_READERS(*inode)) {
			send_sig(SIGPIPE, current, 0);
			if (!ret) ret = -EPIPE;
			break;
		}
		bufs = info->nrbufs; // 已写缓冲区的数量
		if (bufs < PIPE_BUFFERS) { // 若还有空余缓冲区
			ssize_t chars;
			int newbuf = (info->curbuf + bufs) & (PIPE_BUFFERS-1); //
			struct pipe_buffer *buf = info->bufs + newbuf;
			struct page *page = info->tmp_page;
			int error;

			if (!page) { // 若无高速缓存页框,则调用伙伴系统进行分配(之后会由该缓冲区的page指针指向该页框)
				page = alloc_page(GFP_HIGHUSER);
				if (unlikely(!page)) { // 分配失败
					ret = ret ? : -ENOMEM;
					break;
				}
				info->tmp_page = page;
			}
			/* Always wakeup, even if the copy fails. Otherwise
			 * we lock up (O_NONBLOCK-)readers that sleep due to
			 * syscall merging.
			 * FIXME! Is this really true?
			 */
			do_wakeup = 1;
			chars = PAGE_SIZE;
			if (chars > total_len)
				chars = total_len;

			// 若总的待发送数据大于一个页,则会分多次存入(这或许也是一个影响效率的问题)
			error = pipe_iov_copy_from_user(kmap(page), iov, chars);
			kunmap(page);
			if (unlikely(error)) {
				if (!ret) ret = -EFAULT;
				break;
			}
			ret += chars;

			/* Insert it into the buffer array */
			buf->page = page; // 该缓冲区的page指针指向该页框。
			buf->ops = &anon_pipe_buf_ops;
			buf->offset = 0;
			buf->len = chars;
			info->nrbufs = ++bufs;
			info->tmp_page = NULL;

			total_len -= chars;
			if (!total_len) // 若无剩余数据
				break;
		}
		if (bufs < PIPE_BUFFERS) // 若还有空闲缓冲区,则再次尝试写,防止向下执行直接陷入等待
			continue;
		if (filp->f_flags & O_NONBLOCK) { // 若是非阻塞写
			if (!ret) ret = -EAGAIN;
			break;
		}
		if (signal_pending(current)) {    // 当收到 -ERESTARTSYS这个返回值后,对于linux来讲,会自动的重新调用这个调用
			if (!ret) ret = -ERESTARTSYS;
			break;
		}
		if (do_wakeup) {
			wake_up_interruptible_sync(PIPE_WAIT(*inode));// 唤醒该管道等待队列上的进程
			kill_fasync(PIPE_FASYNC_READERS(*inode), SIGIO, POLL_IN);
			do_wakeup = 0;
		}
		PIPE_WAITING_WRITERS(*inode)++; // 增加等待写进程的个数
		pipe_wait(inode);				// 将该进程等待在该管道的等待队列wait上
		PIPE_WAITING_WRITERS(*inode)--; // 该进程已被唤醒,则减少等待计数
	}// 再次进入循环,尝试写入数据到管道
out:
	up(PIPE_SEM(*inode)); // 释放信号量
	if (do_wakeup) {
		wake_up_interruptible(PIPE_WAIT(*inode));
		kill_fasync(PIPE_FASYNC_READERS(*inode), SIGIO, POLL_IN);
	}
	if (ret > 0)
		inode_update_time(inode, 1);	/* mtime and ctime */// 更新修改文件和索引节点的时间
	return ret;
}

【实验】:可以做一个这样的实验,两组父子进程,父进程都像子进程通过管道总共写n个字节,其中第一组每次写2048个字节,第二组每次写2049个字节,比较二者效率

【实验猜测】:当父进程写入速度大于子进程读取速度时,第一组的速度更快。

 

三.FIFO

1.与无名管道的区别

       在linux内核中FIFO和管道几乎时相同的,并使用pipe_inode_info结构,管道读写亦使用pipe_read和pipe_write函数。事实上只有两个主要区别,1)在于FIFO的索引节点出现在系统目录树上,而非pipefs特殊文件系统中这也使得非亲缘进程之间也可以使用FIFO进行通信)。2)FIFO是一种双向通信管道。

 

2.FIFO的创建

      FIFO的创建与管道略有不同,无名管道在被创建时读写进程的计数标志便被初始化为1,但FIFO则不同,其会根据访问模式不同而设置读写计数标志,比如:已只读方式打开则r_counter为1,只写则w_counter为1,读写方式则皆为1。当对FIFO执行open时会转而调用pipe_fifo,该函数的行为如下所示:

 

3.FIFO的读写

      FIFO还会根据打开模式调用不同的读写函数,若以写方式打开,则调用write时与无名管道一样,调用pipe_write,否则调用bad_pipe_w,该函数只返回一个错误码,同理,在调用read时调用pipe_read或bad_pipe_r。

 

四.IPC资源

       IPC资源包括:IPC信号量(不同于内核信号量),IPC消息队列和IPC共享内存。每个IPC资源都是持久的(除非被进程显示释放,否则永久驻留在内存中)

1.IPC关键字与IPC标识符

      IPC关键字是由用户指定的,其在创建新的IPC资源时使用,而IPC标识符由内核分配给IPC资源,在系统内部是唯一的。创建IPC资源的函数接收一个关键字为参数,返回IPC标识符,因此不同进程可以使用相同的关键字来得到同一IPC资源的标识符。

 

2.IPC资源在内核中的结构

     每种类型的IPC资源,内核都为其分配了ipc_ids数据结构,该结构包括,由该结构可以访问到某种类型的所有IPC资源。结构如下所示:

struct ipc_ids {
	int in_use;                    // 已分配该种IPC资源数
    /*以下三个子段用于计算IPC标识符(类似于文件描述符)*/
	int max_id;                    // 已使用的最大位置索引
	unsigned short seq;        
	unsigned short seq_max;
	struct semaphore sem;	       // 用于保护ipc_ids结构的信号量
	struct ipc_id_ary nullentry;   // 若IPC资源无法初始化,则entries指向该成员
	struct ipc_id_ary* entries;    // 指向ipc_id_ary结构,其中包括IPC资源数,及指向各个IPC资源的指针数组
};

struct ipc_id_ary {
	int size;                   // IPC资源数
	struct kern_ipc_perm *p[0]; // 指向IPC资源的指针数组,虽然扛上去指向的都是kern_ipc_perm结构
                                    // 但用于描述各种IPC资源的结构都是以kern_ipc_perm起始的
};

struct kern_ipc_perm
{
	spinlock_t	lock;    // 保护IPC资源的自旋锁
	int		deleted; // 当资源被释放时,设置该标志
	key_t		key;     // IPC关键字
	uid_t		uid;     // 属主用户ID
	gid_t		gid;
	uid_t		cuid;    // 创建者用户ID
	gid_t		cgid;
	mode_t		mode;    // 权限掩码(类似于文件的访问模式字)
	unsigned long	seq;
	void		*security;
};

    内核中为三种IPC资源分别定义了3个静态变量:1)信号量 static struct ipc_ids sem_ids; 2)消息队列 static struct ipc_ids msg_ids;

3)共享内存 static struct ipc_ids shm_ids;

                         

 

3.IPC的缺点

  • IPC结构是在系统范围内起作用的,没有引用计数。比如,若进程创建了一个消息队列并写入了数据,那么当进程退出时,若未手动删除(异常退出等),则该消息队列及其中的数据便不会被删除。相比而言,管道在最后一个引用进程终止时,管道会被完全删除。FIFO的名字虽仍存留在系统中,直至显示删除,但FIFO中的数据会被删除。
  • IPC不使用文件描述符(而是使用类似的标识符),这导致不可对其使用I/O多路复用(select,,poll,epoll)

 

五.信号量

1.IPC信号量的特点 

  • 每个IPC信号量都是一个或多个信号量值的集合。每个信号量值在内核中由一个sem结构描述。
  • IPC信号量提供了一种失效安全机制,即当进程退出时会取消之前对信号量进行的取消操作(当调用semop时将第三个参数设为SEM_UNDO时称为取消操作)。内核中是通过两个链表(每信号量取消链表与每进程取消链表)来实现取消操作的,随后进行说明。这种取消操作可以有效防止由于某进程结束前无法手动释放信号量,而导致其它进程用于阻塞下去。

    内核中每个IPC信号量资源由一个sem_array描述,每个信号量值由sem结构记录,每个信号量还拥有一个挂起请求操作队列,每个挂起请求由一个sem_queue结构描述,此外,每个信号量还拥有一个取消链表,对于每个取消操作都会生成一个sem_undo结构放入该量表中,并且执行取消操作的挂起请求亦会指向其对应的sem_undo。

     在进程描述符中也维护了一张取消操作链表,其中记录了与该进程相关的取消操作,当进程结束时,该链表会被用于平息错乱操作。当执行fork时,该表是共享的,这就防止了被取消多次的情况,此外,当调用semctl设置一个信号量值时,会将该信号量取消链表中所有sem_undo.semadj数组中的取消调整值设为0,并将进程中取消链表内与该信号量相关的semid子段设为-1。

   各结构定义如下:

// 描述IPC信号量
struct sem_array {
	struct kern_ipc_perm	sem_perm;	// 所有描述IPC资源的结构都以此起始
	time_t			sem_otime;	/* last semop time */
	time_t			sem_ctime;	/* last change time */
	struct sem		*sem_base;	// 指向信号量值数组
	struct sem_queue	*sem_pending;	// 指向挂起请求队列的第一个元素
	struct sem_queue	**sem_pending_last;// 指向挂起请求队列的最后一个元素
	struct sem_undo		*undo;		// 取消链表,其中记录了与该信号量相关的取消操作
	unsigned long		sem_nsems;	// 信号量值的个数
};

// 描述IPC信号量值
struct sem {
	int	semval;		// 信号量值
	int	sempid;		// 最后操作此信号量值的进程ID
};

// IPC信号量挂起请求队列
struct sem_queue {
	struct sem_queue *	next;	 /* next entry in the queue */
	struct sem_queue **	prev;	 /* previous entry in the queue, *(q->prev) == q */
	struct task_struct*	sleeper; // 指向执行该请求的进程描述符
	struct sem_undo *	undo;	 // 若该请求是挂起请求,则执行一个描述取消操作的sem_undo结构
	int    			pid;	 /* process id of requesting process */
	int    			status;	 // 操作的完成状态
	struct sem_array *	sma;	 // 回指IPC信号量描述符
	int			id;	 /* internal sem id */
	struct sembuf *		sops;	 // 指向一个sembuf数组,每个sembuf描述了对一个信号量值的操作
	int			nsops;	 // sembuf数组大小
	int			alter;   /* does the operation alter the array? */
};

// 对每个信号量值要进行的操作
struct sembuf {
	unsigned short  sem_num;	// 操作第几个信号量值
	short		sem_op;		// 整数表示归还信号量值,负数表示释请求信号量值
	short		sem_flg;	// 操作标志,IPC_NOWAIT(不等待),SEM_UNDO(取消操作)
};

// 描述了一个取消操作
struct sem_undo {
	struct sem_undo *	proc_next;	/* next entry on this process */
	struct sem_undo *	id_next;	/* next entry on this semaphore set */
	int			semid;		/* semaphore set identifier */
	short *			semadj;		// 对信号量中各个信号量值如何取消,比如:若对第1个信号量值
                                // 申请了1个,那么semadj所指数组的第1个村的便是+1。
};

【注】:由上可知,IPC信号量中维护了复杂的数据结构,特别时当进行取消操作时,不仅要修改IPC信号量中的数据结构,而且还要修改进程描述符中的链表,因此信号量效率不高,在APUE一书中也将信号量与文件锁,共享内存中的互斥锁进行了比较,发现信号量时效率最低的。并且,信号量的使用上容易出错,比如在获取了信号量后进行fork,之后父子进程都调用semop进行了归还,那么便会造成信号量值的增加。

 

六.IPC消息队列

   内核中的 static struct ipc_ids msg_ids;对象记录了所有的消息队列结构,其中每个消息队列由一个msg_queue结构描述。消息队列中的每条消息都有一个消息类型,其保存在消息头结构中,用户可以按消息类型存取消息,而非固定的先入先出。每个条消息由两部分组成,消息头+消息正文,每条消息会分别存储在多个页中(消息头后面会直接跟随消息正文),存储一条消息的多个页会用一个链表进行连接,各条消息亦会通过一个双向链表进行连接(即一个二维链表)。其结构如下图所示:

// 消息队列描述符
struct msg_queue {
	struct kern_ipc_perm q_perm;
	time_t q_stime;			/* last msgsnd time */
	time_t q_rtime;			/* last msgrcv time */
	time_t q_ctime;			/* last change time */
	unsigned long q_cbytes;		/* current number of bytes on queue */
	unsigned long q_qnum;		// 队列中的消息数
	unsigned long q_qbytes;		// 队列中的最大字节数
	pid_t q_lspid;			/* pid of last msgsnd */
	pid_t q_lrpid;			/* last receive pid */

	struct list_head q_messages;  // 消息链表
	struct list_head q_receivers; // 所有阻塞的读进程
	struct list_head q_senders;   // 所有阻塞的写进程
};

// 消息头
struct msg_msg {
	struct list_head m_list; 
	long  m_type;       // 消息类型
	int m_ts;           // 消息正文的大小
	struct msg_msgseg* next; // 消息的下一部分
	void *security;
	/* the actual message follows immediately */
};

// 消息正文(续)
struct msg_msgseg {
	struct msg_msgseg* next;
	/* the next part of the message follows immediately */
};

【注】:APUE中进行的测试表示消息队列速度略快于全双工管道与套接字,但鉴于IPC的缺点,还是应该尽量避免使用消息队列。

 

七.IPC共享内存

待补

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值