[OS] fork()

In this tutorial, introduce

§ Create process from user mode
§ Create process from kernel mode
§ fork() in the linux
§ User Mode
§ Kernel Mode
§ Process: Program in execution

Multiple processes: Concurrent vs Parallel. Processor vs CPU vs Core

§ In multitasking operating systems, processes (running programs) need a way to create  new processes, e.g. to run a program.
  • 任务分离:系统中的每个进程通常只负责执行一个任务。创建新进程可以让程序将不同的任务分配给不同的进程独立执行,这有助于保持任务之间的独立性,防止相互干扰。比如,一个应用程序可以创建一个新进程来处理文件操作,而主进程负责用户界面操作。

  • 资源分配和隔离:每个进程都有自己的地址空间和资源分配。通过创建新进程,操作系统可以更好地隔离资源,防止进程之间发生冲突或资源抢占。

§ Process state:
new : The process is being created
running : Instructions are being executed
waiting : The process is waiting for some event to occur
ready : The process is waiting to be assigned to a processor
terminated : The process has finished execution
Each process is named by a process ID number. Generally, process is identified and managed via a process identifier (pid)
A unique process ID is allocated to each process when it is created.
The lifetime of a process ends when its termination is reported to its parent process. At that time, all of the process resources, including its process ID, are freed.
  • 进程标识符 (PID)

    • 每个进程在操作系统中都有一个唯一的标识符,称为进程标识符 (PID)。当一个新进程被创建时,操作系统会为其分配一个唯一的 PID。这是操作系统在内部识别和管理进程的主要方式。通过 PID,可以区分系统中的每一个进程。

  • 进程的生命周期

    • 创建阶段:进程是由另一个现有进程(通常是父进程)创建的,使用系统调用(如 Unix/Linux 系统中的 fork())。在进程创建时,操作系统会分配资源(如内存、文件描述符等)并分配一个唯一的 PID。

    • 运行阶段:进程在被创建后,进入执行阶段,可以进行各种操作,如访问文件、占用 CPU 等。操作系统通过 PID 来管理和调度这些进程。

    • 终止阶段:当一个进程完成任务或者遇到某些异常情况(如调用 exit() 或者发生了致命错误),进程将终止。当进程终止时,操作系统会释放该进程所占用的资源,包括内存、打开的文件句柄、网络连接等。然而,进程并不会立刻被完全移除,其状态会变成“僵尸状态”(Zombie),等待父进程确认其已终止。

  • 父进程和进程终止的报告

    • 进程的终止状态会通过信号(如 SIGCHLD)报告给父进程。父进程可以通过调用 wait()waitpid() 来获取已终止子进程的退出状态,并清理该进程的资源。
    • 只有在父进程接收到终止状态并进行相应处理后,子进程的 PID 和其他资源才会真正被系统回收。此时,子进程彻底结束,相关资源和 PID 会被释放。
    • 具体来说,wait() 有以下几个功能:

       
      • 等待子进程结束wait() 会让父进程暂停执行,直到有一个子进程终止。这是“等待”的部分。

      • 获取子进程的终止状态:当子进程结束时,wait() 返回子进程的退出状态(比如正常结束还是异常终止)。这就是“获取”的部分。父进程通过 wait() 来得知子进程是如何终止的,以及获取与其终止相关的信息。

        • wait() 的返回值是终止的子进程的 PID,同时还可以通过参数获取子进程的退出状态。这个退出状态能够帮助父进程决定如何处理子进程的结束信息,例如是否需要重新启动这个进程,或者记录其运行结果。
          • 1. wait() 的基本使用

            wait() 的基本作用是让父进程等待任意一个子进程的结束。在 wait() 调用时,父进程会暂停执行,直到一个子进程终止为止。当一个子进程结束时,wait() 会返回该子进程的 PID,并且父进程可以获取子进程的退出状态。

            #include <sys/wait.h>
            
            pid_t wait(int *status);
            

            status:是一个指针,用来存储子进程的退出状态。如果不关心子进程的退出状态,可以传入 NULL

          • 返回值:返回结束的子进程的 PID;如果没有子进程则返回 -1,并设置 errno
          • 如果子进程在 wait() 之前终止了怎么办?
            • 僵尸进程(Zombie Process):当子进程终止后,如果父进程还没有调用 wait(),子进程就会变成一个僵尸进程(Zombie Process)。这个僵尸进程依然占用一些系统资源(如进程表项),直到父进程调用 wait() 来收集它的退出状态。

            • 如果子进程在 wait() 之前已经终止,父进程在调用 wait() 时仍然会正确返回该子进程的退出状态,并回收它的资源。因此,即使子进程提前结束,只要父进程最终调用 wait(),系统资源就会被正确回收,僵尸进程将消失。

          • 什么时候使用 wait()
            • 当父进程需要等待某个子进程结束时,或者父进程需要知道子进程的退出状态,就会使用 wait()
            • 常见的使用场景:
              • 父进程需要知道子进程是否正常结束。
              • 父进程在创建多个子进程后,希望等待它们的终止以继续后续的工作。
              • 防止僵尸进程的产生,确保所有子进程资源都被回收。
            • 4. 如何处理多个子进程?

              如果父进程创建了多个子进程,可以使用以下几种方法来管理这些子进程:

              (1) wait() 循环

              父进程可以在循环中反复调用 wait() 来等待所有子进程的终止。每调用一次 wait(),就会等待任意一个子进程终止,直到所有子进程都结束。

            • (2) waitpid():处理特定子进程

              有时,父进程可能不想等待任意子进程结束,而是等待某个特定的子进程。这时可以使用 waitpid() 函数。它允许指定一个特定的子进程 PID,并且提供更多的灵活性,例如是否等待非阻塞地获取子进程状态。

              #include <sys/types.h>
              #include <sys/wait.h>
              
              pid_t waitpid(pid_t pid, int *status, int options);
              
              • pid:指定要等待的子进程的 PID;如果 pid == -1,则表示等待任意一个子进程(类似 wait())。
              • status:指针,用于存储子进程的退出状态。
              • options:可以设置为 0 或 WNOHANG 等选项,WNOHANG 表示非阻塞地调用 waitpid(),如果没有子进程结束,函数立即返回而不会阻塞父进程。
            • 为什么不能使用 wait(int child_pid)

              如果 wait() 接受的是 int 而不是 int *,即 wait(int child_pid),那意味着:

            • 传递的是一个,而不是指针。C 语言是按值传递参数的,因此函数内部无法修改调用者的变量。如果直接传递 int 类型的变量,wait() 无法通过该变量将子进程的退出状态返回给调用者。

            • child_pidwait(int child_pid) 中无法传递给 wait() 来作为输出值,而 wait() 的返回值才是用来获取子进程 PID 的。因此,调用 wait(int child_pid) 并没有实际意义,因为 wait() 本质上是阻塞等待某个子进程结束并返回其 PID

            • 1. 如何理解 status 的含义?

              wait() 函数中的 status 变量并不是直接给出子进程的退出码(即子进程调用 exit() 或返回的值),而是包含了一些关于子进程终止方式的编码信息。为了从 status 中提取有用的信息,C 标准库提供了一些宏来解析和处理 status

              2. 常用宏函数解析 status

              使用这些宏可以从 status 中提取子进程的退出信息:

              1. WIFEXITED(status):检查子进程是否是正常退出的。
              • 返回值:如果子进程是通过 exit() 或者返回主函数而终止的,则返回非零值(即 true),否则返回零(false)。
            • 2. WEXITSTATUS(status):获取子进程的退出状态码。
              • 使用条件:在 WIFEXITED(status) 返回非零时,才能调用 WEXITSTATUS(status),它返回子进程的退出状态码(即 exit() 传递的值或主函数的返回值)。
            • 3. WIFSIGNALED(status):检查子进程是否因为信号而被终止。
              • 返回值:如果子进程是因为接收到未捕获的信号而终止的,则返回非零值(true)。
            • 4. WTERMSIG(status):获取导致子进程终止的信号编号。
              • 使用条件:在 WIFSIGNALED(status) 返回非零时,才能调用 WTERMSIG(status),它返回终止子进程的信号编号。
            • 5. WIFSTOPPED(status):检查子进程是否被暂停
              • 返回值:如果子进程因为信号暂停了,则返回非零值。
              • 使用条件:在 WIFSTOPPED(status) 返回非零时,才能调用 WSTOPSIG(status),它返回导致暂停的信号编号。
            • 6. WSTOPSIG(status):获取导致子进程暂停的信号编号。
              • 使用条件:在 WIFSTOPPED(status) 返回非零时,才能调用 WSTOPSIG(status),它返回导致暂停的信号编号。
    • fork() 和父子进程的关系:

       
      • fork() 系统调用:在操作系统中,fork() 是一个用于创建新进程的系统调用。它会将当前的进程(称为父进程)复制一份,生成一个新的进程(称为子进程)。这个操作被称为“进程分叉”(forking a process)。

      • 子进程的特性

        • 进程克隆:子进程是父进程的精确副本,它继承了父进程的大部分属性,包括代码段、数据段、文件描述符、环境变量等。它和父进程运行相同的程序,并从 fork() 调用点继续执行。
        • 唯一的 PID:尽管子进程继承了几乎所有父进程的属性,它有一个关键的区别:子进程拥有自己独立的进程标识符 (PID),这使得操作系统能够区分父进程和子进程。
      • fork() 的返回值

        • 父进程:在父进程中,fork() 的返回值是子进程的 PID。这样父进程可以通过这个 PID 来管理或与子进程进行交互。
        • 子进程:在子进程中,fork() 的返回值为 0。通过返回值,子进程能够知道自己是一个新创建的进程,而不是继续作为父进程执行。
      • 父子进程的独立性

        • 独立的执行路径:虽然子进程是父进程的克隆,但父子进程是独立的,它们可以在不同的时间、以不同的速率运行,并且可以独立地对系统资源进行操作。举例来说,父进程可能会继续进行其他任务,而子进程可以执行不同的操作,甚至可以通过 exec() 替换其内存中的程序来运行新的可执行文件。
        • 资源共享与独立:某些资源,如打开的文件描述符,父子进程可以共享,但它们的内存空间、进程计数器和堆栈是独立的。子进程对自己内存空间的修改不会影响到父进程,反之亦然。
      • 典型的使用场景

        • 进程分叉常用于并发执行任务。例如,一个服务器进程可以使用 fork() 创建多个子进程来处理不同的客户端请求,从而提高效率。
        • fork() 通常与 exec() 系列调用结合使用:fork() 负责创建新进程,而 exec() 则让新进程执行不同的程序。这种模式通常用于创建子进程来运行不同的任务或执行外部程序。
    • exec() 系列函数的作用

      • 执行新程序:当进程调用 exec() 时,当前进程的内存空间(代码段、数据段等)会被新程序的内容替换掉。也就是说,exec() 并不会创建一个新的进程,而是让当前的进程停止执行原有的程序,并开始执行一个新的可执行文件。

      • 进程不变:调用 exec() 后,进程的 PID 保持不变,但内存中的程序代码被新程序取代。换句话说,进程还是同一个进程,只是它“改头换面”执行了一个不同的程序。

    • exec()fork() 的结合使用

      通常情况下,exec() 函数与 fork() 系统调用一起使用。这是因为 fork() 创建了一个新的进程,而 exec() 则用于让这个新进程运行一个完全不同的程序。这种模式非常常见,尤其在操作系统中,当父进程希望创建子进程来运行某个特定任务时,会先调用 fork() 生成子进程,然后在子进程中调用 exec() 来执行新的程序。

      例如

      • fork() 创建新进程:父进程调用 fork(),创建一个新的子进程。
      • exec() 替换程序:子进程调用 exec(),把自己的内存内容替换成新程序,并开始执行该程序。
      • 语言背景

        fork()exec() 是在C语言中用于与操作系统交互的系统调用,主要应用于类 Unix 系统(如 Linux、macOS)。这些系统调用是底层的函数,直接与操作系统的内核进行交互。

      • fork() 系统调用的功能

        fork() 是 Unix-like 系统中用于创建新进程的系统调用。它会将当前进程(称为父进程)复制一份,生成一个新的子进程。新创建的子进程是父进程的几乎精确副本,除了它有自己独立的进程 ID (PID)。

        fork() 的返回值

        调用 fork() 后,它会有不同的返回值,具体取决于在哪个进程中:

      • 返回值为 -1:如果 fork() 返回 -1,表示子进程的创建失败。这可能是由于系统资源不足或者其他错误。此时没有创建任何新进程。

      • 返回值为 0:如果 fork() 在子进程中返回 0,这意味着当前的进程是子进程。子进程可以通过这个返回值知道它是由 fork() 创建的。

      • 返回值为正整数:如果 fork() 在父进程中返回了一个正整数,该正整数就是新创建的子进程的 PID。父进程可以通过这个返回值知道子进程已经成功创建,并使用这个 PID 来管理子进程。

      • 返回的 PID 类型

        返回的进程 ID 类型是 pid_t,这个类型是在头文件 sys/types.h 中定义的。pid_t 是一种整数类型,通常用来表示进程 ID。在不同的系统中,pid_t 可能是不同的具体类型(例如 intlong),但它本质上是一个能容纳进程标识符的整型数据。

    • sys/types.h

      • 是一个头文件,主要在 Unix-like 操作系统(如 Linux 和 macOS)中使用。它定义了一些常用的数据类型,这些类型用于各种系统调用和标准库函数中。

        sys/types.h 的作用

        sys/types.h 头文件提供了用于表示系统中各种资源标识符的类型定义,比如文件大小、进程 ID、用户 ID 等。这些类型抽象了底层系统实现的具体细节,以便于在不同的平台上保证代码的可移植性。

        常用的类型定义

        以下是 sys/types.h 中常见的一些类型定义:

        • pid_t:用于表示进程标识符 (PID),这是 fork() 系统调用返回的类型。pid_t 通常是一个整数类型。

          • 例如:pid_t fork(void);
        • uid_t:用于表示用户 ID(用户标识符)。

          • 例如:uid_t getuid(void); 获取当前进程的用户 ID。
        • gid_t:用于表示组 ID(组标识符)。

          • 例如:gid_t getgid(void); 获取当前进程的组 ID。
        • size_t:用于表示对象或内存块的大小,常用于 malloc()sizeof() 等函数的返回类型。它通常是一个无符号整数类型。

    • 为什么需要 sys/types.h
      • 可移植性:不同平台可能对相同的数据使用不同的底层实现,比如 intlong。通过 sys/types.h 提供的类型定义,可以保证这些数据类型在不同系统中的一致性和可移植性。例如,进程 ID 可能在一个系统中是 int,而在另一个系统中是 long,而使用 pid_t 就可以屏蔽这些底层差异。

      • 抽象系统资源:头文件提供了与操作系统资源(如进程、文件、用户等)相关的抽象数据类型,帮助程序员使用这些资源,而不必担心底层实现细节。

    • 一个简单的例子
      • off_t:用于表示文件的偏移量。它通常用于文件读写中的位置偏移。

        • 例如:off_t lseek(int fd, off_t offset, int whence);
      • ssize_t:类似于 size_t,但它是带符号的,通常用于返回可能为负值的字节数。

        • 例如:ssize_t read(int fd, void *buf, size_t count);

fork() 的工作原理

  1. 创建两个独立的进程: 当 fork() 成功调用时,Unix 操作系统会创建一个新的进程(子进程),父进程和子进程都会拥有独立的地址空间。这意味着每个进程都会有自己的内存副本,并且父进程对其地址空间的任何修改都不会影响子进程,反之亦然。

  2. 复制地址空间: 虽然 fork() 会创建一个子进程,表面上看似乎是直接复制了父进程的整个内存空间(代码段、数据段、堆栈等),但大多数现代 Unix-like 操作系统采用了一种称为写时拷贝(Copy-On-Write, COW)的技术。写时拷贝意味着父子进程在 fork() 后会共享同一个内存副本,但只有在其中一个进程试图修改这块内存时,操作系统才会为其分配新的内存。这样可以显著提高效率,避免在 fork() 调用时立即复制整个地址空间。

  3. 父子进程的执行: 一旦 fork() 成功调用,父进程和子进程的执行从 fork() 之后的下一条语句开始。这是 fork() 的一个非常独特之处:它会在两个不同的进程中返回,因此父子进程是从同一个位置继续执行代码。

    • 在父进程中fork() 返回的是子进程的 PID(一个正值),因此父进程可以通过返回值知道子进程已经成功创建。
    • 在子进程中fork() 返回 0,这意味着子进程可以通过这个返回值确认自己是子进程。

 

#include <stdio.h>      // 标准输入输出库
#include <stdlib.h>     // 标准库,包含exit()函数
#include <unistd.h>     // Unix标准库,包含fork()、sleep()等系统调用
#include <sys/types.h>  // 包含pid_t类型定义
#include <string.h>     // 字符串操作函数,如strcpy()

int main(int argc, char *argv[]){
    // 定义一个字符数组buf,长度为50,初始化为"Original test strings"
    char buf[50] = "Original test strings";
    pid_t pid;  // 定义一个pid_t类型的变量pid,用来存储进程ID

    // 打印进程开始分叉的提示信息
    printf("Process start to fork\n");

    // 调用fork()创建新进程,返回值存入pid变量中
    pid = fork();

    // 检查fork()是否失败
    if(pid == -1){  
        // 如果fork()返回-1,表示创建子进程失败,打印错误信息并退出
        perror("fork");
        exit(1);
    }
    else
    {
        // 子进程的代码部分
        if(pid == 0){  // 如果pid为0,表示这是子进程
            // 子进程修改buf中的字符串
            strcpy(buf, "Test strings are updated by child.");
            // 打印子进程中的提示信息和修改后的buf内容
            printf("I'm the Child Process: %s\n", buf);
            exit(0);  // 子进程正常退出
        }

        // 父进程的代码部分
        else{
            // 父进程睡眠3秒,确保子进程先运行
            sleep(3);
            // 打印父进程中的提示信息和buf内容(父进程的buf没有被子进程修改)
            printf("I'm the Parent Process: %s\n", buf);
            exit(0);  // 父进程正常退出
        }
    }
    
    return 0;
}

主要概念:

  • 进程复制:调用 fork() 后,操作系统会复制父进程,生成子进程,子进程与父进程有独立的地址空间。
  • 独立执行:子进程对 buf 的修改不会影响父进程,因为父子进程拥有独立的内存副本。
1. fork() 后的父子进程并发执行:

fork() 被调用时,操作系统会创建一个子进程,子进程与父进程并发执行。父进程和子进程实际上是在同一个终端中输出信息,因为它们共享同一个终端作为标准输出设备(stdout)。因此,不论是父进程还是子进程,它们的 printf() 输出都会显示在同一个终端窗口。

2. 父子进程的执行顺序:
  • 并发执行:父进程和子进程几乎同时开始执行,但操作系统的进程调度程序决定哪个进程先执行以及执行多长时间。因为父子进程并不是串行的(也不是严格意义上的同步),因此它们的执行顺序可能不同,每次运行时结果都可能不同。虽然在这个例子中,子进程先执行了 printf(),但在某些情况下,父进程可能先执行,取决于调度。

  • sleep() 的作用:在您的程序中,父进程调用了 sleep(3),这使得父进程会等待 3 秒,让子进程有足够的时间完成它的任务(即修改 buf 并打印输出)。这就是为什么子进程的输出会先显示在终端中,而父进程的输出在 3 秒后才出现。

3. 输出在同一个终端:

父进程和子进程的标准输出 (stdout) 都指向同一个终端,因此不论哪个进程调用 printf(),输出都会显示在同一个地方。这是因为终端设备是一个共享的资源,多个进程可以通过它进行输出。

4. 子进程运行到何时结束?

子进程会从 fork() 调用的下一行开始执行。在您的代码中:

  • 如果是子进程(pid == 0),它会运行以下语句:

    strcpy(buf, "Test strings are updated by child.");
    printf("I'm the Child Process: %s\n", buf);
    exit(0);  // 子进程在这里退出
    

  • 子进程修改了 buf 的值并打印后,调用 exit(0) 正常退出。exit() 终止了子进程的执行,释放其资源,进程生命周期结束。

  • 父进程则在 sleep(3) 之后继续执行自己的任务,打印 buf 的内容,最终也调用 exit(0) 退出。

  • 6. fork() 之后的执行到结束的流程:

    • 子进程:一旦 fork() 成功,子进程从 fork() 返回值 0 处开始执行,运行自己的代码,最终调用 exit(0) 结束。
    • 父进程:父进程从 fork() 返回子进程的 PID 处继续执行,运行 sleep(3),然后继续执行后面的代码,最后调用 exit(0) 结束。

 Linux 进程树(A Tree of Processes in Linux)

进程树的内容和作用:
  1. init 进程 (PID = 1)

    • init 是 Linux 系统中第一个启动的进程,它是所有其他用户进程和内核线程的祖先进程,位于进程树的根节点。
    • init 进程的主要任务是初始化系统并启动其他进程,比如 loginkthreadd 等。
  2. 父子关系

    • 每个进程都有父进程和可能的子进程。通过图中的连线可以看到父进程创建了子进程。比如:
      • login(PID = 8415) 是 init 的子进程。
      • bash(PID = 8416) 是 login 的子进程。
      • ps(PID = 9298) 和 emacs(PID = 9204) 是 bash 的子进程。
  3. 内核线程

    • kthreadd(PID = 2)是 Linux 内核创建的线程管理进程,专门用于创建和管理其他内核线程,如 khelper(PID = 6) 和 pdflush(PID = 200)。
  4. sshd 进程

    • sshd(PID = 3028 和 PID = 3610)是用于管理远程登录的 Secure Shell Daemon 进程,允许用户通过 SSH 连接到系统。
    • 子进程 tcsh(PID = 4005)是由 sshd 创建的 shell 进程,用于处理远程登录的用户命令。

#include <stdio.h>      // 标准输入输出函数库
#include <stdlib.h>     // 包含exit()函数
#include <unistd.h>     // 包含fork()和execve()函数
#include <sys/types.h>  // 定义pid_t类型
#include <string.h>     // 包含字符串操作函数

int main(int argc, char *argv[]){
    pid_t pid;  // 用来存储进程ID

    pid = fork();  // 创建子进程
    if (pid < 0) {
        printf("Fork error!\n");
        exit(1);
    } 
    else {
        // 子进程
        if (pid == 0) {
            printf("This is child process.\n");
            printf("Child process id is %d\n", getpid());  // 打印子进程的PID

            // 使用 execve() 替换子进程的地址空间
            char *arg[] = {argv[1], NULL};  // 准备传递给新程序的参数(argv[1] 是新程序的路径)
            execve(argv[1], arg, NULL);  // 使用 execve() 执行新程序
            printf("Error: execve failed to execute test program!\n");  // 如果 execve() 失败,则执行该行
        }

        // 父进程
        else {
            printf("This is farther process.\n");
            printf("Farther process id is %d\n", getpid());  // 打印父进程的PID
        }
    }
    return 0;
}
#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Test process id is %d\n", getpid());  // 打印当前进程的PID
    printf("Test completed!\n");
    return 0;
}
gcc -o execve execve.c   # 编译父子进程的代码
gcc -o test test.c       # 编译测试程序
./execve ./test          # 运行父进程,并让子进程执行“test”程序

详细解析和中文注释:

  1. 创建子进程:

    • fork() 函数被调用,创建一个新的子进程。如果 fork() 返回 0,表示这是子进程;如果返回正值,则表示这是父进程。
    • 如果 fork() 返回负值,则表示创建子进程失败。
  2. 子进程执行 execve()

    • 子进程执行 execve(argv[1], arg, NULL),这会使用新的可执行文件(即 argv[1],在这个例子中为 ./test)替换当前子进程的内存空间。
    • execve():这是一个系统调用,用于将当前进程的地址空间替换为新程序的地址空间。换句话说,子进程不再执行原来的代码,而是执行新的程序文件 test.c
    • 替换后的子进程继续运行新程序 test.c 中的代码,打印 Test process id is ...Test completed!
  3. 父进程执行:

    • 父进程不受 execve() 的影响,它继续执行自己的代码。它在子进程之后打印出 This is farther process. 和父进程的 PID。
  4. 终端输出解释:

    • 当父进程和子进程执行后,结果显示在同一个终端:
      • 子进程输出:子进程先输出 This is child process. 和它的 PID,随后被 execve() 替换为 test.c 程序,并输出 Test process id is ...Test completed!
      • 父进程输出:父进程输出 This is farther process.,并打印出父进程的 PID。

关键点:

操作系统如何处理命令行参数以及 execve() 调用中的参数传递。让我们详细解释:

execve() 中的参数和程序的命令行

        1. argv[1]argv[0] 的区别
  • 在 C 程序中,argv[]命令行参数的数组。通常:
    • argv[0]正在运行的程序的名称或路径。
    • argv[1] 及后续元素是传递给程序的其他命令行参数。
      ./execve ./test
      

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值