【Linux】第三篇:进程


在这里插入图片描述


1. 简述操作系统

概念

操作系统包括:

  • 内核(进程管理,内存管理,驱动管理,文件管理)
  • 其他程序(shell程序,函数库,部分指令等)

为什么需要操作系统

处理器本身只能对多道程序提供有限的支持,需要有软件来管理处理器和被多个程序共享的其他资源

假使多个程序在同一时间都是活跃的,则需要保护每个程序的数据,I/O和不被其他程序占用的资源。

开发操作系统是为了给应用程序提供方便,安全和一致的接口。

操作系统是计算机硬件和应用程序之间的一款软件。

  • 对下:管理所有的资源(来自于软硬件)

    硬件资源:处理器,内存,I/O模块,磁盘驱动器等

  • 对上:为用户程序(应用程序)提供一个良好稳定的执行环境(系统调用接口)

所以操作系统本质上是一个专门统筹管理资源的软件。

关于系统调用

何为资源管理

先描述,后组织

  • 描述资源(将资源抽象表示:为目标资源选择合适的结构体,各个结构体存放每个程序的状态属性,以及资源所有权)

    资源包含了:内存,网络接口和文件系统等。

  • 组织资源(选择合适的数据结构,将结构体组织起来)

    操作系统为应用程序创建了资源的抽象表示(结构体)之后,就需管理资源的使用,例如既可资源共享,也可允许资源保护。

    对目标的管理转化为对数据的管理。

操作系统的目标

  • 资源对多个应用程序是可用的
  • cpu在多个应用程序间切换,以保证所有程序都在执行中
  • 处理器和I/O都可得到充分利用

2.进程

简而言之,进程是加载到内存且正在执行的程序。

描述进程-PCB

上述进程的解释还不够细致,我们再挖深一点。

操作系统为了能够管理进程,依旧是这个思想:先描述再管理

描述

  • 进程是一个活动单元,其中包含:

    1. 一组执行的代码
    2. 一个当前的状态
    3. 一组相关的系统资源表征

    故进程是一组元素组成的“结构体”,其中最基本的两个元素:
    1.程序代码(program code)
    2.与代码相关联的数据集(set of data)

管理

操作系统把描述一个进程的信息“聚拢”起来,存放在一个结构体之中,称之为:

⭐进程控制块(PCB-Process Control Block)

其中主要包含:

  1. 标识符:与进程相关的唯一标识符,相当于进程独有的名字,用来区分其他标识符。
  2. 状态:程序正在进行,则进程处于运行态。
  3. 优先级: 相对于其他程序的优先顺序
  4. 程序计数器:程序中即将执行的下一条指令的地址
  5. 内存指针:包括程序代码和进程数据的指针,以及与其他进程共享内存块的指针。
  6. 上下文数据:进程执行时处理器寄存器中的数据
  7. I/O状态信息:包括显示I/O请求,分配给进程的I/O设备(如磁盘驱动器)和被进程使用的文件列表
  8. 记账信息:包括处理时间总和、使用的时钟数总和、时间限制、记帐号等。

在有了上述的一些元素信息后,我们便可以中断一个进程的执行,此时cpu将寄存器中的信息转存到PCB中,并立即开始另一个进程的执行。并在后来恢复该进程的执行,此时通过内存指针找到程序代码,由于顺接了上下文信息并且记录了程序下一步开始运行的地址,这就使得进程好像未被中断过一样。

单处理器在任何时候最多只能执行一个进程(为运行态),而进程控制块是操作系统支持多进程并提供多重处理技术的关键工具

有了进程控制块,操作系统管理进程只需将这些PCB通过数据结构(一般为双链表)联系起来,然后轮流交给cpu处理(按时间片来规定cpu对每个进程的处理时间),cpu寄存器中的程序计数器(PC)会根据PCB中的程序计数器的记录决定从哪一行开始调用程序代码,随后通过内存指针来实际调用代码,同时恢复 上下文数据。(操作系统管理进程控制块的方式符合其将资源先描述再组织的思想)

task_struct

在Linux系统中,描述进程的结构体PCB,称为task_struct

task_struct是Linux内核的一种数据结构,当应用程序加载到内存,该结构体便会被装载到内存并包含着该进程的所有信息。

Linux 查看当前进程

进程的信息可以通过 /proc系统文件夹查看

这些数字是PID(进程标识符),目录中中包含了进程的所有属性。
而这些目录全放置于内存之中

  • [ ps ] 显示当前的系统状态

ps指令详解

举例

我们编写一个死循环输出的程序,方便查看进程。

//mycode_test.c
int main()
{
    while(1)
    {
        printf("hello PCB\n");
        sleep(1);
    }
    return 0;
}

复制SSH渠道后,我们运行该程序,同时在另一个界面查看进程

  • [ ps axj | grep “mycode_test” ] 通过管道和过滤 专门只查看mycode_test可执行程序的进程

再完善一下命令,&&将两条命令连起来同时执行。这里我们再显示一下属性列表

  • [ ps axj | head -1 && ps axj | grep “mycode_test” ]

    终止程序后,进程消失

进程标识符-PID

  • 进程id:PID
  • 父进程id:PPID

系统调用函数:getpid(),getppid()

通过查看手册了解这两个函数:

man 2 getpid

man 2 getppid

⚠注意需应用的头文件

#include<sys/types.h> /* 提供类型pid_t的定义 */
#include<unistd.h> /* 提供函数的定义 */

实例

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
  while(1)
  {
    printf("pid:%d,ppid:%d\n", getpid(),getppid());
  }
  return 0;
}

在已知PID后,我们也可以 grep PID来获取该实时进程

  • [ps axj | grep PID ]

通过观察可发现命令行运行的命令变为进程时,其父进程为bash。

  • [ ls /proc/PID ] 查看PID进程的属性

系统调用创建子进程——初识fork()

认识 fork()

  • [man fork]

在真正了解fork()之前,我们先用代码直观的看一下

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    printf("start\n");

    pid_t ret=fork();//分歧

    if(ret>0)
    {
        printf("ret=%d,PID:%d,PPID:%d\n",ret,getpid(),getppid());
    }
    else if(ret==0)
    {  
        printf("ret=%d,PID:%d,PPID:%d\n",ret,getpid(),getppid());
    }
    else
    {
        printf("ret=%d,PID:%d,PPID:%d\n",ret,getpid(),getppid());
    }

    return 0;
}

一个判断语句进行了两次。子进程的 PPID便是父进程的 PID

在语句执行到 pid_t ret=fork() 之前,只有一个进程在执行代码,但是这条语句之后,就变成了两个进程在执行了。

两个进程中,原先存在的进程是父进程,新出现的是为子进程。调用fork的奇妙之处在于,进程仅调用它一次,却能够返回两次。

变量ret存放的是fork()的返回值,他可能有三个不同的返回值:

  1. 给父进程中的fork()返回 子进程的PID
  2. 给子进程的fork()返回 0
  3. 出现错误返回 负值

这么设置返回值是因为一个父进程可能有多个子进程,但子进程只有一个父进程,可以通过 PPID 找到父进程。

fork出错的两种原因:(1)当前的进程数已经达到了系统规定了上限,这时errno值被设置为EAGAIN。(2)系统的内存不足,errno值被设置为ENOMEM。

我们再看一个例子

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    printf("PID:%d\n",getpid());

    printf("PPID:%d\n",getppid());

    pid_t childpid;
    childpid=fork();
    printf("fork1   PID:%8d  PPID:%8d    childpid:%6d\n",getpid(),getppid(),childpid);

    childpid=fork();
    printf("fork2   PID:%8d  PPID:%8d    childpid:%6d\n",getpid(),getppid(),childpid);
   
    sleep(1);
    return 0;
}

如何理解fork()创建的子进程?

无论是我们执行程序创建的进程,亦或是使用fork()创建的子进程,对于操作系统而言都视作一个个独立进程,没有差别。

于是fork()创建的子进程和一般意义上的进程一样,拥有自己的与进程相关的数据结构(PCB)进程的代码与数据

  1. 默认情况下子进程会继承父进程的代码和进程控制块(PCB) 。

  2. fork()之后子进程与父进程的代码是共享的(因为代码在运行时是不可被修改的)。

  3. 进程又是具有独立性的,父子进程的数据通过写时拷贝来完成进程数据间的独立性。一旦有一方执行写入操作,对数据做出了修改,操作系统为了维护进程间的独立性,会让子进程先拷贝一份PCB,将自己独立出来。

  4. 通常会让子进程和父进程做不一样的事情,通过fork()的返回值来分开实现。

用判断语句 判断fork()的返回值来分别执行父子进程。

int main()
{
    pid_t childpid=fork();

    if(childpid==0)
    {
        printf("It's a child process,PID:%d\n",getpid());                                                      
    }
    else   
    {
        printf("It's a parent process,PID:%d\n",getpid());
    }

    return 0;
}

⚠注意:父子进程的运行先后没有定论。不同的平台策略不同。

3. 进程状态

基本概念

操作系统的基本职责之一就是控制进程的执行,包括了

  1. 确定进程间的交替执行的方式
  2. 给进程分配内存空间

那如何确定交替方式呢?

操作系统依旧采用“先描述,后管理”的思想来控制进程执行。

⭐第一步是描述进程所表现出来的行为。

对于一个单处理器而言,在任何时刻,一个进程要么正在执行,要么就是未执行,以此先构建最简单的模型 :

当进程执行时处于运行态,若遇中断则转换为未运行态。

未运行态的进程控制块位于队列中,并等待执行时机,其中进程控制块中包含的信息将帮助未运行态的进程继续执行上次未完的指令。

但是如果使用单个队列来存放所有的未运行态是不合适的,因为未运行态可能有两种情况:

  1. 已就绪等待处理器执行的进程
  2. 处于阻塞等待IO操作结束的进程

于是我们将进程状态再细分五个种类:

  • 新建态:进程控制块已经创建,操作系统将标识符关联到进程。
    操作系统完成创建进程的所有动作,但操作系统还未将其加入到可执行进程组的进程状态。例如操作系统会因为性能不高或内存不足,限制进程的数量。

    此时PCB在内存中,但是程序代码和数据仍处于磁盘里。

  • 就绪态:进程做好了准备,随时等待调度。

  • 运行态:正在被执行,如果只有一个处理器,那只有一个进程处于这种状态。

  • 阻塞/等待态:进程在某些事件完成前不能执行,如等待IO信号,等待用户输入数据。

  • 退出态:操作系统从可执行进程组释放的进程。与进程相关的PCB会被暂时保留,以给父进程提供所需的信息。

进程的状态信息在task_struct(PCB)中

进程状态的意义:可以方便操作系统快速判断进程,完成特定的功能,实质上是将进程分类管理(分而治之)。

其他概念

  • 竞争性:进程数目众多,处理器资源是少量的,进程之间具有竞争关系,需要设置优先级来有序合理的竞争相关资源
  • 独立性:多进程独享各自资源,运行期间互不干扰
  • 并行:多进程在多处理器下同时运行。
  • 并发:多进程在单处理器下切换运行,一段时间里都得以推进,称为并发。

Linux 进程状态

Linux内核对于进程状态的定义

/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char *task_state_array[] = {
	"R (running)",       /*  0*/
    "S (sleeping)",      /*  1*/
    "D (disk sleep)",    /*  2*/
    "T (stopped)",       /*  4*/
    "t (tracing stop)",  /*  8*/
    "Z (zombie)",        /* 16*/
    "X (dead)"           /* 32*/
};

R 运行态+就绪态

只有在该状态的进程才可能在CPU上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的task_struct结构(进程控制块)被放入对应CPU的可执行队列中(一个进程最多只能出现在一个CPU的可执行队列中)。进程调度器的任务就是从可执行队列中选择一个进程在该CPU上运行。

很多操作系统教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为 TASK_RUNNING状态。

int main()
{
   while(1)
   {
        std::cout<<"Hello R\n"<<std::endl;
       sleep(1);
   }
   return 0;
}

ps axj 指令中的 STAT用于查看进程的状态:

S 休眠状态(可中断睡眠) ,阻塞

当需要执行某种任务(I/O,文件读写,网络传输等),任务条件不具备或需要等待信号时(I/O,磁盘设备速度较慢,等待用户输入数据等),进程就会挂起进入等待队列(R->S)(并非等待cpu执行,而是等待任务所需资源),一旦资源备齐,进程不会立马去处理资源,而是被唤醒从等待队列进入就绪态(S->R),那么此进程就会等待CPU来处理资源。

我们在上面代码中添加输出,可以捕捉到S态,这是因为一个死循环输出的代码会频繁的在S和R态之间切换,而且I/O设备的处理速度远比CPU慢,况且我们在这里设置了sleep函数,cpu会将其挂起等待,所以大部分时间是为S态。

上述捕捉R态的程序没有IO,不同访问外设,故无需挂起。

int main()
{
   while(1)
   {
        std::cout<<"Hello R\n"<<std::endl;
        sleep(1);
   }
  return 0;
}

处于S态的进程可以使用 kill来终止休眠状态,或者在执行页面键入 ctrl+c

kill -l以查看kill指令的更多选项

比如可以选择 kill -9 PID 或者 kill SIGKILL PID

D 深度睡眠状态

亦称不可中断状态:

处于D状态的进程,操作系统不能终止该状态的进程,该状态进程通常会等待IO的结束。

D状态存在的意义就在于,内核的某些处理流程是不能被打断的。比如当前的进程需要对磁盘进行写入操作,可能需要使用D状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。

T 暂停状态

19)SIGTOP 进入暂停状态,

或者直接在执行页面键入 ctrl+z进入暂停状态

18)SIGCONT 继续进程

我们可以发现,进程暂停后再次运行,但是R的身后缺少了+号

+号表示前台运行,此时无法输入其他指令,可以使用 ctrl+c/z 来终止/暂停进程

没有 +号表示在进程后台运行,可以在当前页面输入其他Linux指令。后台运行的情况下,我们无法用 ctrl+c/z 的快捷间在执行页面来控制该进程停下,只能通过指令 kill -9 PID来杀死该进程。

./执行文件 & 可以让可执行文件直接在后台运行,后台运行最典型的表现就是不影响当前执行其他Linux命令。

t 追踪状态

“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于 t 状态。调试结束,被调试的进程才会恢复运行态。

X 死亡状态

操作系统回收死亡状态进程的内存资源(进程的相关数据结构+代码数据),这个状态只是一个返回状态,无法在任务列表里看到这个状态。

导致进程终止的原因:

Z(僵尸状态)

进程在退出的过程中,进程占有的所有资源将被回收,除了task_struct结构(以及少数资源)以外。于是进程就只剩下task_struct这么个空壳,故称为僵尸。

之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。比如在shell中,$?变量就保存了最后一个退出的前台进程的退出码,而这个退出码往往被作为if语句的判断条件。

当然,内核也可以将这些信息保存在别的地方,而将task_struct结构释放掉,以节省一些空间。但是使用task_struct结构更为方便,因为在内核中已经建立了从pid到task_struct查找关系,还有进程间的父子关系。释放掉task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。

僵尸进程需要它的父进程来为它收尸,如果他的父进程没安装 SIGCHLD 信号处理函数调用wait或waitpid()等待子进程结束,又没有显式忽略该信号,那么它就一直保持僵尸状态,如果这时父进程结束了, 那么init进程自动会接手这个子进程,为它收尸,它还是能被清除的。但是如果如果父进程是一个循环,不会结束,那么子进程就会一直保持僵尸状态,这就是 为什么系统中有时会有很多的僵尸进程。

查找Z状态,我们设置了一端代码如下:

//mycode_test.cpp
int main()
{
    pid_t childpid=fork();
    if(childpid==0)                                                       
    {
        while(1)
        {
            cout<<"I am a child process"<<endl;
            sleep(2);
        }
    }
    else
    {
        cout<<"I am a parent process"<<endl;
        sleep(50);
    }
}

我们使用脚本来反复观察进程状态

while : ;do ps axj | head -1 && ps axj | grep mycode_test | grep -v grep ;sleep 1;echo "#############################";done

复制渠道后,运行可执行文件

fork开辟了子进程,当父进程进入sleep挂起时,子进程一直执行输出,

此时我们杀死子进程 kill -9 子进程PID,而父进程还未结束,那么子进程会进入僵尸态(Z)等待父进程来“收尸”。

如果父进程不结束,那么僵尸状态的子进程将会一直存在。

末尾的 <defunct>表示该条为无用进程

孤儿进程

如果父进程提前结束,而子进程还未结束,那么子进程会成为孤儿进程,在父进程终止的同时操作系统(1号进程)会“领养”子进程。

进程优先级

处理器资源分配的优先顺序即优先级,在多任务环境下,配置进程的优先级将大大提高多任务环境下的Linux系统性能。

查看进程

ps -al 查看进程的信息

之前提到过的有 PID (进程编号)和 PPID (父进程编号)。

  • UID —— 用户ID ,root的UID为0

  • PRI —— 优先级(priority),值越小优先级越高

  • NI —— 进程的 nice 值

PRI 与 NI

Linux中的优先级数据用PRI表示,值越小优先级越高。

NI是为nice值,表示优先级的修正数据。

在加入nice值后,PRI(after)=PRI(before)+nice

调整优先级便是调整进程的nice值,显然负的nice值是会让优先级变高的。

nice的取值范围: -20~19, 所以一个进程可以有40个级别。

调整进程优先级

top 指令

  • top 指令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况,常用于服务端性能分析。

    之后按 r ,输入欲修改优先级的PID。

    提示renice,即再输入nice值(注意将nice值设为负值时需要root权限)

    这里我们为PID:25470,输入的nice值为10,再 ps -al 查看进程,发现此进程的NI=10,PRI=90(80+NI)

注意如果多次修改则需要root权限。而且PRI值将始终以PRI(初始值)为基准加上NI,比如我们对PID:25470第二次调整,将其NI值修改为5,那么PRI值为85=80+5

renice 指令

  • renice nice值 PID

4. 环境变量

Linux中的命令,工具,程序,本质上都是可执行文件。

我们自己写的程序编译之后成为的可执行文件,在当前目录下可通过 ./ 文件名来运行,通过绝对路径找到此文件,也可以运行。

❓那为什么我们执行系统的指令(系统的可执行文件)时,不用说明路径呢?

这是因为系统能够通过环境变量来找到这些指令存放的目录位置。

环境变量基本概念

  • 环境变量一般是操作系统中用来指定其运行环境的一些参数
  • 例如:我们在执行系统指令时不必添加指令的路径;编译程序链接时,我们不知道库文件的位置但是仍可以生成可执行文件。这些都是因为环境变量帮助我们进行了查找🔍。
  • 环境变量因其特殊用途,故在系统中具有全局特性。

常用环境变量

  • PATH : 指定命令的搜索路径
  • HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
  • SHELL : 当前Shell,它的值通常是/bin/bash

echo $Name 查看环境变量

  • echo $Name 其中 Name 为环境变量名称。
  • 查看PATH环境变量

其中的系统指令都存于上述路径中,冒号分隔。

⭐ 将自己的指令添加进PATH

一般我们不会将自己的指令加入上述的任何一个目录之中,这样会污染命名池。

将当前路径添加进PATH变量中是更好的一种方法;

  • export PATH=$PATH:新目录

    这样可以直接执行我们自己写的可执行程序,而有环境变量帮我们寻找其所在路径。

    ⚠注意:不能使用 export PATH=新目录 这样会将原有的路径全都覆盖掉。

    此次修改只会在当前登录状态有效,如果需要永久修改路径,则可以使用vim编辑器在 ~/.bash_profile文件中修改:

    但是不建议做如此修改。

  • 查看 HOME

    用户的家目录不同,登陆时会按照环境变量HOME进入用户自身的家目录。

和环境变量相关的命令

  • env 用于显示系统中已存在的环境变量,以及在定义的环境中执行指令。

  • set 显示本地定义的shell变量和环境变量,并设置所使用shell的执行方式,可依照不同的需求来做设置。

    本地变量,只在本次登录有效:

  • export可将本地变量变为环境变量(不写入配置文件,仅在本次登陆有效)

  • unset 清除环境变量

通过main函数的参数获取环境变量

我们学习C/C++程序的时候,写过无数个main函数,实际上main函数也是有参数的,共计3个

int main(int argc ,char* argv[]char* env[])
{}
  • argv是一个字符指针数组,指向命令行参数,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空。argc将记录元素个数。

    我们使用代码来做测试

    //myenviron.c
    int main(int argc,char* argv[])
    {
        int i=0;
        for(;i<argc;++i)
        {
            printf("argv[%d]:%s\n",i,argv[i]);
        }
    
        return 0;
    }
    

    如果命令行参数只有一个程序名,那么字符数组argv只有一个元素

    你可以在命令行中传各种参数给main函数

    ⭐命令行参数的用处:同一个程序,通过传入不同的参数使程序表现出不同的功能⭐

    我们在使用系统指令时添加的选项参数便是一种命令行参数

    我们自己也可以试下:

    //myenviron.c
    #include <stdio.h>
    #include <string.h>
    int main(int argc,char* argv[])
    {
        if(argc!=2) 
        {
            printf("Usage:%s -[h|g]\n",argv[0]);
            return 1;
        }
        if(strcmp(argv[1],"-h")==0)
        {
            printf("how are you?\n");
        }
        else if(strcmp(argv[1],"-g")==0)
        {
            printf("goodbye\n");
        }
        else
        {
            printf("hello world\n");
        }
        return 0;
    }
    
    

    指令有很多选项用来完成同一指令的不同子功能,选项底层使用的就是命令行参数。

  • char* env[] 指向的是环境变量字符串

    查看下环境变量:

    #include <stdio.h>
    int main(int argc,char* argv[],char* env[])
    {
        int i=0;
        for(;env[i];++i)
        {
            printf("env[%d]->%s\n",i,env[i]);
        }
    }
    

    得到的结果 与我们在命令行输入 env系统指令获取环境变量的结果是一样的:

不仅如此,还可以通过第三方变量来获取环境变量

  • environ

    ⭐每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。environ正是指向这张环境表的指针!

        #include <stdio.h>
        int main(int argc, char *argv[])
        {
            extern char **environ;
            int i = 0;
            for(; environ[i]; i++)
            {
                printf("%s\n", environ[i]);
            }
            return 0;
        }
    

    结果与env[]是一样的。

getenv 🚩通过系统调用获取环境变量

  • getenv 可快速读取调用的环境变量
//myenviron.c
#include <stdio.h>
#include <stdlib.h>
//int main(int argc,char* argv[],char* env[])
int main()
{
    printf("PATH:%s\n",getenv("PATH"));
    printf("HOME:%s\n",getenv("HOME"));
    printf("SHELL:%s\n",getenv("SHELL"));
}

环境变量的全局属性——父子进程的环境变量继承

命令行启动的进程,其父进程都为bash。

bash从系统的配置文件中读取环境变量并载入自己进程的上下文之中,这些环境变量是可以继承给子进程的。

对于本地变量,如果对其export 导成环境变量,实际上是导给了bash的环境变量列表。

查看下方代码

#include <stdio.h>
#include <stdlib.h>
int main()
{
    printf("mystring:%s\n",getenv("mystring"));
    return 0;
}

bash中没有本地变量的地址,其子进程自然也无法

⭐故环境变量具有全局属性的原因在于,子进程是能够继承环境变量的。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值