Linux进程间通信

进程间通信

进程间通信(Inter-Process Communication,IPC)是指两个进程的数据之间产生交互。在linux下,支持的主要进程间通信的机制有:

方法方式
匿名管道同一主机下的亲缘进程间通信
命名管道同一主机下的任意进程间通信
信号同一主机下且与进程所属用户权限相关的进程间
消息队列同一主机下的任意进程间通信
共享内存同一主机下的任意进程间通信
信号量同一主机下用于进程同步
Socket同一/不同主机间任意进程通信

匿名管道

管道也叫匿名管道,它是是 UNIX 系统 IPC(进程间通信) 的最古老形式,所有的 UNIX 系统都支持这种通信机制。其一般具有以下特点:

  1. 半双工模式,数据在同一时刻只能在一个方向传输。
  2. 数据只能在管道的一段写入,另一端读取,即分读端和写端。
  3. 写入管道的数据先进先出。
  4. 管道内数据是无具体格式要求的,读写双方自定义。
  5. 匿名管道只能在具有亲缘的进程间使用(父子进程、兄弟进程)
#include <unistd.h>

int pipe(int pipefd[2]);
功能:创建无名管道。
参数:
    pipefd : 为 int 型数组的首地址,其存放了管道的文件描述符 pipefd[0]、pipefd[1]。

    当一个管道建立时,它会创建两个文件描述符 fd[0] 和 fd[1]。其中 fd[0] 固定用于读管道,而 fd[1] 固定用于写管道。一般文件 I/O的函数都可以用来操作管道(lseek() 除外)。
返回值:
    成功:0
    失败:-1
// 列程
int main()
{
    int fd_pipe[2] = { 0 };
    pid_t pid;
    if (pipe(fd_pipe) < 0)  // 创建管道
    {
        perror("pipe");
    }
    pid = fork(); // 创建进程
    if (pid == 0)  // 子进程
    { 
        char buf[] = "this is child thread";
        // 往管道写端写数据
        write(fd_pipe[1], buf, strlen(buf));
        _exit(0);
    }
    else if (pid > 0)  // 父进程
    {
        wait(NULL); // 等待子进程结束,回收其资源
        char str[50] = { 0 };
        // 从管道里读数据
        read(fd_pipe[0], str, sizeof(str));
        printf("str=[%s]\n", str); // 打印数据
    }
    return 0;
}

默认是阻塞模式,在使用时要注意一下:

写管道:

  1. 管道读端全部被关闭, 进程异常终止(也可使用捕捉SIGPIPE信号,使进程终止)

  2. 管道读端没有全部关闭:

    I. 管道已满,write阻塞。
    II. 管道未满,write将数据写入,并返回实际写入的字节数。

读管道:

  1. 管道中有数据,read返回实际读到的字节数。

  2. 管道中无数据:

    I. 管道写端被全部关闭,read返回0 (相当于读到文件结尾)

    II. 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu)

设置非阻塞方法

//获取原来的flags
int flags = fcntl(fd[0], F_GETFL);
// 设置新的flags
flag |= O_NONBLOCK;
// flags = flags | O_NONBLOCK;
fcntl(fd[0], F_SETFL, flags);

此时,如果写端没有关闭,读端设置为非阻塞, 如果没有数据,直接返回 -1。

查看管道缓冲区的方法

#include <unistd.h>

long fpathconf(int fd, int name);
功能:该函数可以通过name参数查看不同的属性值
参数:
    fd:文件描述符
    name:
        _PC_PIPE_BUF,查看管道缓冲区大小
        _PC_NAME_MAX,文件名字字节数的上限
返回值:
    成功:根据name返回的值的意义也不同。
    失败: -1

命名管道

匿名管道只能在亲缘进程间通信,因此为了克服该缺点,提出了命名管道(FIFO)。命名管道(FIFO)不同于无名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,这样,即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。其特点是:

  1. FIFO 在文件系统中作为一个特殊的文件而存在,但 FIFO 中的内容却存放在内存中。
  2. 当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
  3. FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。

创建命名管道

#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
功能:
    命名管道的创建。
参数:
    pathname : 普通的路径名,也就是创建后 FIFO 的名字。
    mode : 文件的权限,与打开普通文件的 open() 函数中的 mode 参数相同。(0666),一个8进制数,表示文件权限
返回值:
    成功:0   状态码
    失败:如果文件已经存在,则会出错且返回 -1。

使用mkfifo创建了一个FIFO后,可以使用open打开它,常见的文件I/O函数都可用于FIFO。如:close、read、write、unlink等。FIFO严格遵循先进先出(first in first out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。其同样不支持诸如lseek()等文件定位操作。

//进程1,写操作
int fd = open("fifo", O_WRONLY);

char send[20] = "Hello World";
write(fd, send, strlen(send));

//进程2,读操作
int fd = open("fifo", O_RDONLY);

char recv[20] = { 0 };
//读数据,命名管道没数据时会阻塞,有数据时就取出来  
read(fd, recv, sizeof(recv));
printf("read from my_fifo buf=[%s]\n", recv);

注意:

  1. 一个为只读而打开一个管道的进程会阻塞直到另外一个进程为只写打开该管道。
  2. 一个为只写而打开一个管道的进程会阻塞直到另外一个进程为只读打开该管道。

读管道:

  1. 管道中有数据,read返回实际读到的字节数。
  2. 管道中无数据:
    I. 管道写端被全部关闭,read返回0 (相当于读到文件结尾)
    II. 写端没有全部被关闭,read阻塞等待

写管道:

  1. 管道读端全部被关闭, 进程异常终止(也可使用捕捉SIGPIPE信号,使进程终止)
  2. 管道读端没有全部关闭:
    I. 管道已满,write阻塞。
    II. 管道未满,write将数据写入,并返回实际写入的字节数。

信号

信号是 Linux 进程间通信的最古老的方式。信号是软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式 。软中断信号(Singal,又简称为信号)用来通知进程发生了异步事件,进程之间可以互相通过系统调用kill函数来发送软中断信号,而Linux内核也可以因为内部时间而给进程发送信号,通知进程发生了某个事件。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。

信号的特点

  • 简单
  • 不能携带大量信息
  • 满足某个特设条件才发送

每个信号都有一个名字,这些名字都以SIG开头,在头文件<signal.h>中,这些信号都被定义为正整数,称为信号编号。

 1) SIGHUP	     2) SIGINT       3) SIGQUIT	     4) SIGILL	     5) SIGTRAP
 6) SIGABRT	     7) SIGBUS	     8) SIGFPE	     9) SIGKILL	    10) SIGUSR1
11) SIGSEGV	    12) SIGUSR2	    13) SIGPIPE	    14) SIGALRM	    15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	    18) SIGCONT	    19) SIGSTOP	    20) SIGTSTP
21) SIGTTIN	    22) SIGTTOU	    23) SIGURG	    24) SIGXCPU	    25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	    28) SIGWINCH	29) SIGIO	    30) SIGPWR
31) SIGSYS	    34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX
编号信号对应事件默认动作
1SIGHUP用户退出shell时,由该shell启动的所有进程将收到这个信号终止进程
2SIGINT当用户按下了ctrl+c组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号终止进程
3SIGQUIT用户按下ctrl+\组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号终止进程
4SIGILLCPU检测到某进程执行了非法指令终止进程并产生core文件
5SIGTRAP该信号由断点指令或其他 trap指令产生终止进程并产生core文件
6SIGABRT调用abort函数时产生该信号终止进程并产生core文件
7SIGBUS非法访问内存地址,包括内存对齐出错终止进程并产生core文件
8SIGFPE在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误终止进程并产生core文件
9SIGKILL无条件终止进程。本信号不能被忽略,处理和阻塞终止进程,可以杀死任何进程
10SIGUSE1用户定义的信号。即程序员可以在程序中定义并使用该信号终止进程
11SIGSEGV指示进程进行了无效内存访问(段错误)终止进程并产生core文件
12SIGUSR2另外一个用户自定义信号,程序员可以在程序中定义并使用该信号终止进程
13SIGPIPEBroken pipe向一个没有读端的管道写数据终止进程
14SIGALRM定时器超时,超时的时间 由系统调用alarm设置终止进程
15SIGTERM程序结束信号,与SIGKILL不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号终止进程
16SIGSTKFLTLinux早期版本出现的信号,现仍保留向后兼容终止进程
17SIGCHLD子进程结束时,父进程会收到这个信号忽略这个信号
18SIGCONT如果进程已停止,则使其继续运行继续/忽略
19SIGSTOP停止进程的执行。信号不能被忽略,处理和阻塞为终止进程
20SIGTSTP停止终端交互进程的运行。按下组合键时发出这个信号暂停进程
21SIGTTIN后台进程读终端控制台暂停进程
22SIGTTOU该信号类似于SIGTTIN,在后台进程要向终端输出数据时发生暂停进程
23SIGURG套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达忽略该信号
24SIGXCPU进程执行时间超过了分配给该进程的CPU时间 ,系统产生该信号并发送给该进程终止进程
25SIGXFSZ超过文件的最大长度设置终止进程
26SIGVTALRM虚拟时钟超时时产生该信号。类似于SIGALRM,但是该信号只计算该进程占用CPU的使用时间终止进程
27SGIPROF类似于SIGVTALRM,它不公包括该进程占用CPU时间还包括执行系统调用时间终止进程
28SIGWINCH窗口变化大小时发出忽略该信号
29SIGIO此信号向进程指示发出了一个异步IO事件忽略该信号
30SIGPWR关机终止进程
31SIGSYS无效的系统调用终止进程并产生core文件
34~64SIGRTMIN ~ SIGRTMAXLINUX的实时信号,它们没有固定的含义(可以由用户自定义)终止进程

信号产生函数

#include <signal.h>

int kill(pid_t pid, int sig);
功能:给指定进程发送指定信号(不一定杀死)
参数:
    pid : 取值有 4 种情况 :
        pid > 0:  将信号传送给进程 ID 为pid的进程。
        pid = 0 :  将信号传送给当前进程所在进程组中的所有进程。
        pid = -1 : 将信号传送给系统内所有的进程。
        pid < -1 : 将信号传给指定进程组的所有进程。这个进程组号等于 pid 的绝对值。

    sig : 信号的编号,这里可以填数字编号,也可以填信号的宏定义,可以通过命令 kill - l("l" 为字母)进行相应查看。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
返回值:
    成功:0
    失败:-1
#include <signal.h>

int raise(int sig);
功能:给当前进程发送指定信号(自己给自己发),等价于 kill(getpid(), sig)
参数:
    sig:信号编号
返回值:
    成功:0
    失败:非0值
#include <stdlib.h>

void abort(void);
功能:给自己发送异常终止信号 6) SIGABRT,并产生core文件,等价于kill(getpid(), SIGABRT);

参数:无

返回值:无
#include <unistd.h>

unsigned int alarm(unsigned int seconds);
功能:
    设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送14)SIGALRM信号。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器。
    取消定时器alarm(0),返回旧闹钟余下秒数。
参数:
    seconds:指定的时间,以秒为单位
返回值:
    返回0或剩余的秒数
#include <sys/time.h>

int setitimer(int which,  const struct itimerval *new_value, struct itimerval *old_value);
功能:
    设置定时器(闹钟)。 可代替alarm函数。精度微秒us,可以实现周期定时。

参数:
        which:指定定时方式
                a) 自然定时:ITIMER_REAL → 14)SIGALRM计算自然时间
                b) 虚拟空间计时(用户空间):ITIMER_VIRTUAL → 26)SIGVTALRM  只计算进程占用cpu的时间
                c) 运行时计时(用户 + 内核):ITIMER_PROF → 27)SIGPROF计算占用cpu及执行系统调用的时间
        new_value:struct itimerval, 负责设定timeout时间

        struct itimerval {
            struct timerval it_interval; // 闹钟触发周期
            struct timerval it_value;    // 闹钟触发时间
        };

        struct timeval {
            long tv_sec;            // 秒
            long tv_usec;           // 微秒
        }

        itimerval.it_value: 设定第一次执行function所延迟的秒数
        itimerval.it_interval:  设定以后每几秒执行function
old_value: 存放旧的timeout值,一般指定为NULL
返回值:
    成功:0
    失败:-1

信号集

为了方便对多个信号进行处理,一个用户进程常常需要对多个信号做出处理,在 Linux 系统中引入了信号集(信号的集合)。信号集是一个能表示多个信号的数据类型,sigset_t set,set即一个信号集。既然是一个集合,就需要对集合进行添加/删除等操作。

#include <signal.h>

int sigemptyset(sigset_t *set);       //将set集合置空
int sigfillset(sigset_t *set);          //将所有信号加入set集合
int sigaddset(sigset_t *set, int signo);  //将signo信号加入到set集合
int sigdelset(sigset_t *set, int signo);   //从set集合中移除signo信号
int sigismember(const sigset_t *set, int signo); //判断信号是否存在

信号阻塞集也称信号屏蔽集、信号掩码。每个进程都有一个阻塞集,创建子进程时子进程将继承父进程的阻塞集。信号阻塞集用来描述哪些信号递送到该进程的时候被阻塞(在信号发生时记住它,直到进程准备好时再将信号通知进程)所谓阻塞并不是禁止传送信号, 而是暂缓信号的传送。若将被阻塞的信号从信号阻塞集中删除,且对应的信号在被阻塞时发生了,进程将会收到相应的信号。

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
功能:
    检查或修改信号阻塞集,根据 how 指定的方法对进程的阻塞集合进行修改,新的信号阻塞集由 set 指定,而原先的信号阻塞集合由 oldset 保存。
参数:
    how : 信号阻塞集合的修改方法,有 3 种情况:
        SIG_BLOCK:向信号阻塞集合中添加 set 信号集,新的信号掩码是set和旧信号掩码的并集。相当于 mask = mask|set。
        SIG_UNBLOCK:从信号阻塞集合中删除 set 信号集,从当前信号掩码中去除 set 中的信号。相当于 mask = mask & ~ set。
        SIG_SETMASK:将信号阻塞集合设为 set 信号集,相当于原来信号阻塞集的内容清空,然后按照 set 中的信号重新设置信号阻塞集。相当于mask = set。
    set : 要操作的信号集地址。
        若 set 为 NULL,则不改变信号阻塞集合,函数只把当前信号阻塞集合保存到 oldset 中。
    oldset : 保存原先信号阻塞集地址
返回值:
    成功:0,
    失败:-1,失败时错误代码只可能是 EINVAL,表示参数 how 不合法。

信号捕获与处理

当进程收到一个信号的时候,可以用如下方法进行处理:

  1. 执行系统默认动作;对大多数信号来说,系统默认动作是用来终止该进程。
  2. 忽略此信号(丢弃);接收到此信号后没有任何动作。
  3. 执行自定义信号处理函数(捕获);用用户定义的信号处理函数处理该信号。

注意:SIGKILL 和 SIGSTOP 不能更改信号的处理方式,因为它们向用户提供了一种使进程终止的可靠方法。

#include <signal.h>

typedef void(*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
功能:
    注册信号处理函数(不可用于 SIGKILL、SIGSTOP 信号),即确定收到信号后处理函数的入口地址。此函数不会阻塞。

参数:
    signum:信号的编号,这里可以填数字编号,也可以填信号的宏定义,可以通过命令 kill - l("l" 为字母)进行相应查看。
    handler : 取值有 3 种情况:
        SIG_IGN:忽略该信号
        SIG_DFL:执行系统默认动作
        信号处理函数名:自定义信号处理函数,如:func
        回调函数的定义如下:
        void func(int signo)
        {
            // signo 为触发的信号,为 signal() 第一个参数的值
        }
返回值:
    成功:第一次返回 NULL,下一次返回此信号上一次注册的信号处理函数的地址。如果需要使用此返回值,必须在前面先声明此函数指针的类型。
    失败:返回 SIG_ERR
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
功能:
    检查或修改指定信号的设置(或同时执行这两种操作)。
参数:
    signum:要操作的信号。
    act:   要设置的对信号的新处理方式(传入参数)。
    oldact:原来对信号的处理方式(传出参数)。
    如果 act 指针非空,则要改变指定信号的处理方式(设置),如果 oldact 指针非空,则系统将此前指定信号的处理方式存入 oldact。
返回值:
    成功:0
    失败:-1
/*
 * struct sigaction结构体
 * sa_handler、sa_sigaction:信号处理函数指针,和 signal() 里的函数指针用法一样;
 * 根据情况给sa_sigaction、sa_handler 两者之一赋值,其取值为:SIG_IGN:忽略该信号、
 * SIG_DFL:执行系统默认动作、 处理函数名:自定义信号处理函数
 * 
 * sa_mask:信号阻塞集,在信号处理函数执行过程中,临时屏蔽指定的信号。
 * sa_flags:用于指定信号处理的行为,通常设置为0,表使用默认属性。
 * 
 */ 
struct sigaction {
    void(*sa_handler)(int); //旧的信号处理函数指针
    void(*sa_sigaction)(int, siginfo_t *, void *); //新的信号处理函数指针
    sigset_t   sa_mask;      //信号阻塞集
    int        sa_flags;     //信号处理的方式
    void(*sa_restorer)(void); //已弃用
}; 
// sigaction的回调函数定义
void(*sa_sigaction)(int signum, siginfo_t *info, void *context);
参数:
 signum:信号的编号。
 info:记录信号发送进程信息的结构体。
 context:可以赋给指向 ucontext_t 类型的一个对象的指针,以引用在传递信号时被中断的接收进程或线程的上下文。
#include <signal.h>

int sigqueue(pid_t pid, int sig, const union sigval value);
功能:
    给指定进程发送信号。
参数:
    pid : 进程号。
    sig : 信号的编号。
    value : 通过信号传递的参数。
        union sigval 类型如下:
            union sigval
            {
                int   sival_int;
                void *sival_ptr;
            };
返回值:
    成功:0
    失败:-1

向指定进程发送指定信号的同时,携带数据。但如传地址,需注意,不同进程之间虚拟地址空间各自独立,将当前进程地址传递给另一进程没有实际意义。
信号的不足
作为一种进程交互机制,信号有一些局限性:

  1. 信号的系统开销太大。
  2. 发送信号的进程要进行系统调用。
  3. 内核要中断接收信号的进程,而且要管理它的堆栈,同时还要调用处理程序,之后恢复执行被中断的进程。
  4. 信号的数量非常有限,因为为只存在有限的不同信号。
  5. 信号能传送的信息量十分有限,用户产生的信号不可能发送附加信息以及各种参数。

所以,在实际使用中,信号机制常常用于进程之间的事件通知,而不应用于复杂的交互操作。

共享内存

存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不使用read和write函数的情况下,使用地址(指针)完成I/O操作。

共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式, 因为进程可以直接读写内存,而不需要任何数据的拷贝。

存储映射函数

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
功能:
        一个文件或者其它对象映射进内存
参数:
    addr:  指定映射的起始地址, 通常设为NULL, 由系统指定
    length:映射到内存的文件长度

    prot:  映射区的保护方式, 最常用的 :
            a) 读:PROT_READ
            b) 写:PROT_WRITE
            c) 读写:PROT_READ | PROT_WRITE

    flags: 映射区的特性, 可以是
            a) MAP_SHARED : 写入映射区的数据会复制回文件, 且允许其他映射该文件的进程共享。
            b) MAP_PRIVATE : 对映射区的写入操作会产生一个映射区的复制(copy - on - write), 对此区域所做的修改不会写回原文件。

    fd:由open返回的文件描述符, 代表要映射的文件。
    offset:以文件开始处的偏移量, 必须是4k的整数倍, 通常为0, 表示从文件头开始映射

返回值:
    成功:返回创建的映射区首地址
    失败:MAP_FAILED宏
#include <sys/mman.h>

int munmap(void *addr, size_t length);
功能:
    释放内存映射区
参数:
    addr:使用mmap函数创建的映射区的首地址
    length:映射区的大小
返回值:
    成功:0
    失败:-1

注意事项:

  1. 创建映射区的过程中,隐含着一次对映射文件的读操作。
  2. 当MAP_SHARED时,要求:映射区的权限应 <=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制。
  3. 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
  4. 特别注意,当映射文件大小为0时,不能创建映射区。所以,用于映射的文件必须要有实际大小。mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
  5. munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++操作。
  6. 文件偏移量必须为4K的整数倍。
  7. mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。

简单的操作方法:

int fd = open("xxx.txt", O_RDWR); //读写文件
int len = lseek(fd, 0, SEEK_END);   //获取文件大小

//一个文件映射到内存,ptr指向此内存
void * ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED)
{
    perror("mmap error");
    exit(1);
}

close(fd); //关闭文件

char buf[4096];
printf("buf = %s\n", (char*)ptr); // 从内存中读数据,等价于从文件中读取内容

strcpy((char*)ptr, "this is a test");//写内容

int ret = munmap(ptr, len);
if (ret == -1)
{
    perror("munmap error");
    exit(1);
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值