3 Linux系统编程之进程--学习笔记

grep命令的-r 当前目录 -w 精确查找 -n 显示行

vim中的一些操作:

gcc, 数字+gcc多行注释

:e 文件名 在当前的代码当中打开另外的代码, ctrl+n就可以切换代码

ctrl + v,= 可以调整格式

批量删除、复制的快捷键, d +要删除到的行数 + G

1.进程基础知识

进程:动态执行的程序 + 虚拟内存 + 虚拟CPU

进程调度 : 调度程序(分配CPU)

​   时间片轮转

​   完全公平调度算法:时间片轮转+动态修改优先级

​   三个基础状态:运行、就绪、阻塞

cpu的状态:用户态和内核态

查看cpu 信息:cat /proc/cpuinfo

Linux管理进程:

​   进程控制块(pcb)[Windows] --> task_struct进程控制块[Linux下进程与线程都用该数据结构管理]

​   用一个双向循环队列[任务队列]管理task_struct结构体

​   进程id:pid 父进程id: ppid

ps命令查看进程:

​   ps -l 当前与自己bash相关的进程的信息

​   ps -elf 所有进程的信息

开机流程:

bootloader进程->0号进程->1号进程(init)->/etc/ttys终端->login->shell

​            ->2号进程(启动页面守护进程)(负责管理内核线程)

​   开机成功后0号进程死亡,然后又重新开启一个新的0号进程idle,是空闲进程

​   内核线程ps查看时用[]括起来的



2.id获取与动态权限

一些获取函数:

pid_t getpid(void);  pid_t getppid(void);

uid_t getuid(void);  uid_t geteuid(void);

gid_t getgid(void);  gid_t getegid(void);	//真实/有效

//进程的(e)uid、(e)gid默认身份是启动者,权限也是启动者的;

动态权限之suid:

​   问题引出:/etc/shadow 中存储的用户密码,只有root有读写权限,但自己用passwd可以修改自己的密码的原因?其他例如sudo命令也有类似功能,sudo就是用suid来实现的。

​   条件:拥有suid和user的x权限

​   效果:任何用户通过该文件(可执行程序)启动进程,euid变成文件拥有者id

​   加suid权限操作:chmod u+s xxx可执行程序名

动态权限之sgid:

​   同时拥有sgid和group的x权限

​   任何用户通过该文件(可执行程序)启动进程,egid改为文件拥有者所在组的gid

​   加suid权限操作:chmod g+s xxx可执行程序名

动态权限之sticky:

​   目录文件的sticky

​   条件:拥有sticky和other的w权限

​   效果:对于非root的其他用户,可新建文件、删除自己的文件,不可删除别人的文件

​   加sticky权限操作:chmod o+t dir



3.主要命令

查看相关

ps -l和ps -elf 、ps aux各列所代表的意思
  请添加图片描述
​   标记 状态 uid(有效) pid ppid c(cpu占用率) PRI(priority) NI(nice) ADDR(内存位置) SZ(占据内存大小) WCHAN(使进程阻塞的系统调用) STIME(启动时间) TTY(终端,tty普通终端,pts网络终端) TIME(消耗总CPU时间) CMD(启动该进程的命令)

状态:
  请添加图片描述
ps aux:
  请添加图片描述
​ VSZ(所占虚拟内存大小) RSS(所占真实内存空间大小) STAT见下图
  请添加图片描述
free命令:查看内存使用的情况

top命令:动态查看进程,各项含义

Linux中的虚拟机:qemu 速度最快的处理器模拟机,使用KVM虚拟机技术。KVM原理…

优先级相关

优先级:140个优先级,Ubuntu为[-40,99],数值越低,优先级越高

​   NICE:[-20,19] PRI = 80+NICE,修改NICE值,调度策略不变

​   PRI: [60,99]普通调度策略CFS

​         [-40,59]实时调度策略: FIFO和RR(最高优先级的进程平分时间)

​   修改NICE不会改变调度策略

renice:改变进程的优先级eg:renice -n 10 -p 进程ID

nice: 指定优先级启动进程eg: nice -n 10 ./while

   无sudo权限,只能提高数值,降低优先级

发送信号

kill命令:给进程发信号

​   kill -l:查看信号

​   kill -9 进程号:杀死进程

​   kill -19 进程号:暂停进程

前台与后台转换

前台和后台进程:

​   可执行程序后加&,后台执行进程 eg: ./while &

​   前台响应键盘信号:ctrl+c:终止 ctrl+z:前台拉后台,暂停

​            ctrl+\:终止(退出),生成core文件,保存退出时的栈帧

​   jobs查看后台进程

​   fg 任务编号:后台拉前台

​   bg 任务编号:后台暂停变运行

​   ctrl+z:前台拉后台,暂停

设置定时任务

crontab命令:设置定时任务

​   crontab -e 然后在文件里设置,如果是周期性的任务,就将对应时间选项编程*。

​   sudo vim /etc/crontab 所有用户的定时任务



4.多进程编程

1.新进程的开启

int system(const char *command); 执行一个命令(包括启动一个可执行程序,包括其他语言程序,脚本等等)

#include <func.h>
int main()
{
    system("python hello.py");
    sleep(5);
    return 0;
}

pid_t fork(void):创建子进程,子进程是父进程的完全拷贝,父进程返回子进程pid,子进程返回0,用户态完全拷贝:堆、栈、数据段、代码段。内核态部分共享。

fork的流程:

​   执行do_fork()系统调用

​     1.根据旧的task_struct创建新的task_struct;

​     2.do_fork()存在一些参数,决定了新旧任务的资源共享程度。

​   do_fork()的步骤:

​     1.申请资源,创建task_struct

​     2.修改一些必要数据,如pid,ppid [这两步不可抢占]

​     3.加入就绪队列

线程:创建一个任务,新任务和旧任务共享进程地址空间,线程创建也是用do_fork()系统调用。

线程和进程都有自己独立的task_struct,只不过对进程地址空间的共享程度有一定的区别。

中断:异步事件

fork写时复制(COW):虚拟内存地址映射到相同物理地址,只要任意一个进程修改内存内容,才会执行物理层面上的复制,避免多余的开销

fork的共享和拷贝:

​   用户态拷贝(cow,只读的是共享,比如代码段)

​   内核态一部分共享,一部分拷贝

​   文件流和文件缓冲区是拷贝的,因为是行缓冲,若未加\n输出,内容会暂存于缓冲区,fork时会将缓冲区中的内容一同拷贝

​   文件对象是不拷贝的,共享,只是执行了类似dup()函数的效果

exec函数族:

int execl(const char *path,const char *arg,...,NULL);
//命令参数以可变参数传递

int execv(const char * path,char *const argv[]);
//先用数组存储参数,再传递数组

//都要以NULL结尾,如:
int main()
{
    printf("before execl, you can see me!\n");
    //execl("./add","./add","3","4",NULL);
    char *argv[] = {"./add","4","5",NULL};
    execv("./add",argv);
    printf("after execl, you can't see me!\n");
    return 0;
}

exec的原理:

​   1.代码段、数据段替换

​   2.堆栈清空

​   3.pc指针重新回代码段的开始

​   相当于用新程序占领了旧程序的躯壳

​   用fork+exec实现system的效果

2.资源回收

pid_t wait(int *wstatus);

父进程等待子进程的终止,回收子进程的资源。

bash原理:while(1){ puts(“$”);fork+exec(CMD);wait}

孤儿进程:父进程终止早于子进程,子进程是孤儿进程,孤儿进程由1号进程收养,孤儿进程终止,资源由1号进程释放;孩子是后台进程。

僵尸进程:进程终止,资源未回收,可以通过杀死父进程来让init进程接管子进程,来进行清理。(子进程先死,父进程未死,不回收子进程的资源)

wait原理:

​   子进程终止时会触发一个信号SIGCHLD通知给父进程的wait。

​   wait的参数:获取子进程的终止状态和原因。有以下宏来获取信息:

​   WIFEXITED(wstatus)是否正常终止:WEXITSTATUS(wstatus)获取返回值

​   WIFSIGNALED(wstatus)是否信号终止:WTERMSIG(wstatus)获取信号的类型

​   WIFSTOPPED(wstatus)是否暂停:WSTOPSIG(wstatus)返回导致暂停的信号

int main()
{
    if(fork() == 0){
        printf("child pid = %d, ppid = %d\n", getpid(), getppid());
        //return -1;
        //while(1);
        //_exit(2);
        abort();
    }
    else{
        int wstatus;
        printf("parent pid = %d, ppid = %d\n", getpid(), getppid());
        wait(&wstatus);
        if(WIFEXITED(wstatus)){
            printf("normal exit, return val = %d\n",WEXITSTATUS(wstatus));
        }
        else if(WIFSIGNALED(wstatus)){
            printf("killed by signal, signal num = %d\n",WTERMSIG(wstatus));
        }
    }
    return 0;
}

echo $? 命令是查看上一个进程的返回值,若大于128,则是该数-128对应的信号原因退出。实际上用了wait系统调用,查了wstatus。

pid_t waitpid(pid_t pid,int *wstatus,int options);

​   等待指定的进程,参数为-1等待任意子进程,大于0是指定子进程。

​   第三个参数:WNOHANG,非阻塞的等待,若存在指定的进程但还未终止,则返回0。需配合循环来使用。

int main()
{
    pid_t pid;
    if((pid = fork()) == 0){
        printf("child pid = %d, ppid = %d\n", getpid(), getppid());
        //return -1;
        while(1);
    }
    else{
        int wstatus;
        printf("parent pid = %d, ppid = %d\n", getpid(), getppid());
        while(1){
            int ret = waitpid(pid,&wstatus,WNOHANG);
            if(ret != 0){
                if(WIFEXITED(wstatus)){
                    printf("normal exit, return val = %d\n",WEXITSTATUS(wstatus));
                }
                else if(WIFSIGNALED(wstatus)){
                    printf("killed by signal, signal num = %d\n",WTERMSIG(wstatus));
                }
                break;
            }
            else{
                printf("child has not dead yet!\n");
                sleep(1);
            }
        }
    }
    return 0;
}
3.进程的终止

正常终止:

​   1.在main()函数中使用return,终止进程;

​   2.在任何位置调用exit(返回值),终止进程,会清空缓冲区(打印);

​   3.在任何位置使用_exit()和_Exit(),缓冲区内容丢弃;

异常终止:

​   1.abort,给自己发送一个SIGABORT信号;void abort(void);

​   2.信号终止:kill -9

4.进程的组织

1.进程组是进程的集合,组ID是进程组组长的pid,组长终止,组ID不变,故组长不能脱离原进程组再新建组。

​   int setpgid(pid_t pid,pid_t pgid); 设置进程的组id,如果pid和pgid为0,指本进程。

​   pid_t getpgid(pid_t pid); 获取进程的组id,参数为0,代表本进程。

2.bash创建进程时,不仅fork+exec,还setpgid把该进程设置为进程组的组长,子进程默认属于父进程的进程组。

3.父进程和子进程都while(1),都属于前台进程组,可用ctrl+c中断,若对子进程setpgid(0,0),子进程脱离前台进程组,将不再受键盘中断的影响。

4.前台进程组只能有0-1组,后台进程组可以有0-多组。

5.会话:会话是进程组的集合,一个会话可以包含多个进程组,只能对应一个控制终端。每个会话有一个会话首进程,即创建会话的进程,建立与终端连接的就是这个会话首进程,也被称为控制进程。一个会话可以包括多个进程组,这些进程组可被分为一个前台进程组和一个或多个后台进程组。每个终端对应一个会话。

6.终端和会话断开连接,会话中所有进程会收到断开连接信号,一般会终结进程。

​   pid_t getsid(pid_t pid); 获取会话ID,参数为0表示本进程。

​   pid_t setsid(void); 设置会话id,创建新会话,意味着要创建新的进程组,所以进程组组长不能创建新会话。

7.守护进程

​   守护进程完全脱离控制终端控制,daemon(所以一般以d结尾,如sshd),生命周期是从系统开启到结束。

​   创建守护进程的流程:

​     1.fork(),关闭父进程

​     2.setsid(),创建会话,脱离原来会话

​     3.关闭所有文件描述符

​     4.改变当前工作目录为根目录

​     5.去掉掩码

#include <func.h>
#define MAXFD 64
void Daemon(){
    if(fork() != 0){
        exit(0);
    }
    setsid();
    for(int i = 0; i < MAXFD; ++i){
        close(i);
    }
    chdir("/");
    umask(0);
}
int main()
{
    Daemon();
    return 0;
}
  1. 使用守护进程记录log:

    ​ void syslog(int priority,const char *format,…)

    ​ 第一个参数代表优先级,后面跟参数printf一样。

    ​ log记录在/var/log/syslog中。

//在Daemon函数最后添加如下内容:
for(int i = 0; i < 10; ++i){
    time_t now;
    time(&now);
    struct tm* pTm = localtime(&now);
    syslog(LOG_INFO,"%4d%02d%02d %02d:%02d", pTm->tm_year+1900,pTm->tm_mon+1, pTm->tm_mday,pTm->tm_hour,pTm->tm_min);
    sleep(2);
}


5.管道

1.无名管道int pipe(int pipefd[2]);

​   1. 成功返回0,失败返回-1,参数是一个数组,保存是管道的两端(文件描述符)

​   2. fd[1]是管道的是写端,fd[0]是管道的读端

​   fork()之后父进程和子进程最好把自己不用的那一端的文件描述符关闭掉,良好的习惯。

2.无名管道的特点

​   1. 无名管道只能在有亲缘关系之间的进程进行通信(父子,兄弟)

​   2. 半双工

​   3. 依赖于文件系统,生命周期随进程的结束而结束

​   4. 管道是基于字节流来通信的,数据没有边界,多次写管道,数据是粘在一起的

​   5. 管道关闭读端,写管道,程序回收到SIGPIPE信号,进程的退出码是141,(echo $? 查看进程退出码,若大于128,就说明,进程是被信号打断的)

​   6. 关闭管道的写端(父子进程都要关闭写端),使用read读管道,那么read会变成非阻塞的,返回0;

​   7.先写入,再关闭写端,读管道,则会把缓冲区的内容正常读出,再读,会得到0。

//关闭写端,读端返回0
int main(int argc, char **argv)
{
    int fds[2];
    int ret = 0;
    ret = pipe(fds);
    ERROR_CHECK(ret, -1, "pipe");

    if(fork()){
        printf("main process\n");
        close(fds[1]); //父进程关闭
        wait(NULL);
    }
    else{
        close(fds[1]); //子进程也要关闭
        char buf[64] = {0};
        ret = read(fds[0], buf, sizeof(buf));
        printf("buf = %s, ret  =%d \n", buf, ret);
    }
    return 0;
}
//关闭读端来写,触发SIGPIPE信号
int main(int argc, char **argv)
{
    int fds[2];
    int ret = 0;
    ret = pipe(fds);
    ERROR_CHECK(ret, -1, "pipe");
    
    if(fork()){
        close(fds[0]);
        sleep(1);  //父进程先睡,让子进程能执行关闭,否则会正常退出
        ret = write(fds[1], "hello", 5);
        printf("ret = %d\n", ret);
        ERROR_CHECK(ret, -1, "write");
        wait(NULL);
    }
    else{
        close(fds[0]);
    }
    return 0;
}

3.有名管道(命名管道)特点

​   1. 可以在非亲缘关系的进程间通信

​   2. 是一种特殊类型的文件,不会随着进程的结束而消失。

4.int mkfifo(const char *pathname, mode_t mode)

​   1. 成功返回0, 失败返回-1,

​   2. 参数1:创建的命名管道,

​   3. 参数2:权限

5.删除有名管道 int unlink(const char *pathname)

​   1. 可以删除有名管道文件,还可以删除普通文件

​   2. 删除的连接,当问文件的连接数为0的时候,才真正的删除该文件。

  应用小结:mkfifo创建以后可以用open打开来读写。

6.标准流管道

7.FILE *popen(const char *command, const char *type)

​   1. 参数1:启动另一个进程

​   2. 参数2:打开方式

3.以w方式启动,将写入的内容重定向到以命令启动的进程的标准输入。

4.以r方式启动,被启动进程的标准输出重定向到该进程的fread()。
请添加图片描述

//popen_w.c
int main(int argc, char **argv)
{
    FILE* fp = popen("./read", "w");
    fwrite("hello", 1, 5, fp);
    fclose(fp);
    return 0;
}
//read.c
int main(int argc, char **argv)
{
    char buf[64] = {0};
    read(STDIN_FILENO, buf, sizeof(buf));
    printf("buf = %s\n", buf);
    return 0;
}
//popen_r.c
int main(int argc, char **argv)
{
    FILE* fp = popen("./print", "r");
    char buf[64] = {0};
    fread(buf, 1, sizeof(buf), fp);
    printf("buf = %s", buf);
    return 0;
}
//print.c
int main(int argc, char **argv)
{
    printf("world\n");
    return 0;
}

8.popen其实就是对管道的操作进行一些封装

  1. 创建一条管道

  2. fork一个子进程

  3. 在父进程中关闭不需要使用的文件描述符

  4. 执行exec函数族的调用

  5. 执行函数中所指定的命令



6.进程间通信的高级方式

1.共享内存

1.system V的通信方式

  1. 共享内存

  2. 信号量

  3. 消息队列

2.共享内存:一段特殊的内存区域, 可以被多个进程共享,进程想要使用这块共享内存,需要把共享区域映射到本进程的地址空间中,就可以实现进程间的数据交互。

3.创建共享内存的接口: int shmget(key_t key, size_t size, int shmflg)

  1. 成功返回共享内存id, 失败返回-1,

  2. key是一个整型值,可以使用ftok函数生成,

  3. 参数2:创建共享内存的大小

  4. 参数3:IPC_CREAT|0666;

4.key_t ftok(const char *pathname, int proj_id)

  1. 成功返回一个key(8位的整型值),失败返回-1,

  2. 参数1:是一个路径,路径所指向的文件(文件夹)必须真实存在,可访问的文件)

  3. 参数2:是一个整型值(必须是非零值)

5.ftok函数的参数如果每次都是一样的,那么生成的key值,每次都是一样的、

6.查看共享内存命令: ipcs

7.删除共享内存的方式

  1. ipcrm -m 共享内存id

  2. ipcrm -M 共享内存的键值

8.共享内存一旦创建好之后,就会一直存在,不会随进程的结束而消失,直到使用命令删除,或者重启系统。

9.shmat:at是attach:功能是将创建好的共享内存映射到本进程的地址空间,方便使用。

10.映射函数void *shmat(int shmid, const void *shmaddr, int shmflg)

  1. 成功返回指向该共享内存的指针,失败是返回(void*)-1,

  2. 参数1:共享内存的id,

  3. 参数2:填NULL,表示让内核决定一个合适的位置

  4. 参数3:标志位,填0;

11.shmdt:dt是detach分离

12.解除映射int shmdt(const void *shmaddr)

​ 参数:shmat的返回指针

​ 成功返回0,失败返回-1

//shm_wrirw.c
#include <head.h>
int main(int argc, char **argv)
{
    //生成key值
    key_t key = ftok("../shm", 1);
    ERROR_CHECK(key, -1, "ftok");
    int shmid = shmget(key, 1024, IPC_CREAT|0666);
    ERROR_CHECK(shmid, -1, "shmget");

    //讲共享内存映射到本进程的地址空间
    char *p = (char*)shmat(shmid, NULL, 0);
    ERROR_CHECK(p, (void*)-1, "shmat");
    printf("shmid = %d\n", shmid);

    strcpy(p, "hello");
    
	int ret = shmdt(p);
    ERROR_CHECK(ret, -1, "shmdt");
    return 0;
}


//shm_read.c
#include <head.h>
int main(int argc, char **argv)
{
    //生成key值
    key_t key = ftok("../shm", 1);
    ERROR_CHECK(key, -1, "ftok");
    int shmid = shmget(key, 1024, IPC_CREAT|0666);
    ERROR_CHECK(shmid, -1, "shmget");

    //讲共享内存映射到本进程的地址空间
    char *p = (char*)shmat(shmid, NULL, 0);
    ERROR_CHECK(p, (void*)-1, "shmat");
    printf("shmid = %d\n", shmid);

    printf("p = %s\n", p);
    
    int ret = shmdt(p);
    ERROR_CHECK(ret, -1, "shmdt");
    return 0;
}

13.共享内存控制函数int shmctl(int shmid, int cmd, struct shmid_ds *buf)

  1. 参数2:IPC_STAT获取共享内存的信息、IPC_SET设置共享内存相关信息、IPC_RMID删除该共享内存

  2. 参数3:是一个结构体,保存共享内存的相关信息,只有IPC_STAT、IPC_SET时会用来存储信息。
    请添加图片描述
    请添加图片描述

14.对于共享内存的这种删除,叫标记删除:此时删除一段共享内存时,该共享内存正在使用(连接数不为0),该共享内存不会立即被删除,当该共享内存不在被使用的时候,才会真正的删除。

15.当键值为全0的时候,是私有的共享内存。只能在有亲缘关系间的进程之间使用。

16.私有方式的共享内存不受key约束(shmget创建私有共享内存时,key值填0),并且每次执行都会生成新的一块共享内存。[而普通共享内存,只要存在,每次执行都只是打开]

#include <head.h>
int main(int argc, char **argv)
{
    int shmid = shmget(0, 1024, IPC_CREAT|0666);
    ERROR_CHECK(shmid, -1, "shmget");

    printf("shmid = %d\n", shmid);
    return 0;
}
2.内存管理

1.虚拟地址:进程实际看到的地址。

2.物理地址:存放程序数据的真实位置。

3.物理地址和虚拟地址转换:使用MMU内存管理单元进行转换(硬件),不同的系统,转换机制不同。

4.32位系统,进程的地址空间是0-4G(2的32次方), 64位系统当中,进程的地址空间是2的48次方(256T)。

5.可以通过 cat /proc/cpuinfo 查看

6.内存的最小分配大小4k,对于虚拟地址来讲又叫页,对于物理地址叫页框

7.申请内存的时候,系统是采用lazy模式分配的

  1. lazy模式:当申请内存时,但是没有使用,系统不会立刻分配内存空间。只有当真正的使用该内存空间的时候,系统才分配内存空间。

  2. 页表:保存虚拟地址和物理地址的对应关系。当申请的内存空间没有被使用的时候,页表是不会存储物理地址和虚拟地址的对应关系。

8.一级页表的对应关系
请添加图片描述

9.二级页表

请添加图片描述

10.为什么要采用多级页表? 节约内存空间

11.大页,linux默认的大页是2M, 可以通过 grep Huge /proc/meminfo查看。

12.快表TLb:记录下一次或者频率比较高的,虚拟和物理地址的对应关系。

​   快表更新算法:FIFO、LRU

13.快表存储原理:局部性原理:

  1. 时间局部性:

  2. 空间局部性:

14.不同进程:不同的虚拟地址是否能对应相同的物理地址? 可以

15.不同进程:相同的虚拟地址能否对应不同的物理地址? 可以

16.虚拟地址和物理地址并没有一个绝对的对应关系。

3.大页的使用

1.想使用大页的方式申请内存,需要系统支持的,默认的情况下不使用大页的方式分配,需要手动设置

2.如果切换到root报认证失败,

  1. 密码写错了

  2. 没有设置密码, sudo passwd root + 密码

3.设置系统支持大页方式,

  1. 切换到root用户

  2. echo 10 > /proc/sys/vm/nr_hugepages

  3. cat /proc/sys/vm/nr_hugepages 发现如果和你设置的大页数量一样,就说明设置好了

4.查看大页详细信息 grep Huge /proc/meminfo

//上述设置后,在代码中使用大页的例子
#include <head.h>
#define SHM_HUGE_2MB 1<<21
int main(int argc, char **argv)
{
    //使用大页的方式分配共享内存
    int shmid = shmget(1000, 1<<21, IPC_CREAT|0666|SHM_HUGETLB|SHM_HUGE_2MB);
    ERROR_CHECK(shmid, -1, "shmget");

    char *p = (char*)shmat(shmid, NULL, 0);
    ERROR_CHECK(p, (void*)-1, "shmat");

    strcpy(p, "hello");
    shmdt(p);
    return 0;
}

5.使用mmap实现共享内存

  1. 创建一个文件touch file

  2. 给文件开辟空间ftruncate 函数,命令:truncate -s +开辟空间的大小 + 文件名

    //函数
    int truncate(const char *path, off_t length);
    int ftruncate(int fd, off_t length);
    
    void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
    int munmap(void *addr, size_t length);
    
//例子
//mmap_sharem_w.c
#include <head.h>
int main(int argc, char **argv)
{
    int fd = open("file", O_RDWR);
    ERROR_CHECK(fd, -1, "open");

    char *pMap = (char*)mmap(NULL, 10, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
    ERROR_CHECK(pMap, (void*)-1, "mmap");

    strcpy(pMap, "hello");
    munmap(pMap, 10);
    return 0;
}


//mmap_sharem_r.c
#include <head.h>
int main(int argc, char **argv)
{
    int fd = open("file", O_RDWR);
    ERROR_CHECK(fd, -1, "open");

    char *pMap = (char*)mmap(NULL, 10, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
    ERROR_CHECK(pMap, (void*)-1, "mmap");

    printf("pMap = %s\n", pMap);
    munmap(pMap, 10);
    return 0;
}

6.mmap使用大页的方式申请共享内存

​  要有下面两步然后再用mmap的大页方式

  1. sudo mount none /mnt/huge/ -t hugetlbfs

  2. 在huge目录下创建file

#include <head.h>
#define  MAP_HUGE_2MB 1<<21
int main(int argc, char **argv)
{
    int fd = open("/mnt/huge/file", O_RDWR);
    ERROR_CHECK(fd, -1, "open");

    char *pMap = (char*)mmap(NULL, 10, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_HUGETLB|MAP_HUGE_2MB, fd, 0);
    ERROR_CHECK(pMap, (void*)-1, "mmap");

    strcpy(pMap, "hello");

    munmap(pMap, 10);
    return 0;
}

7.不要对一个指针取sizeof,64位系统当中指针占8个字节

8.当两个进程对共享每次+1,各自相加100,正确的原因,是进程在+100,没有用完时间片,没有进程间切换,或者切换没有在+1的过程当中进程切换。

9.当两个进程对共享区值每次+1,各加1000w,有时候能成功,有时失败的原因是?

  1. 成功的原因:进程间切换没有在+1这个操作过程中发生切换

  2. 失败的原因:在+1的过程当中发生了进程间切换

4.信号量

1.信号量的提出者是荷兰计算机科学家Dijkstra,信号量的作用:可以用于进程间的同步与互斥。

2.同步:两个或两个以上的量在变化过程中,保持一定的相对关系。

3.互斥:两个事物不能同时存在。对资源的独占式访问

4.信号量种类分三种:system V、posix、posix基于内存的信号量

5.信号量又叫信号灯

6.对于只有两种状态的信号量又叫二进制信号量,常用于互斥操作

7.对于具有多个资源的信号量,又称为计数信号量。常用于同步

8.信号量的值:资源的数量

9.二进制信号量:初始值为1, 只有两种值,0和1

10.计数信号量:值要大于1

11.临界资源:可以被共享,但是需要互斥访问的资源

12.临界区:访问临界资源的代码,临界区也需要互斥的访问

13.如何使用信号量控制资源, p v操作

14.P操作:测试、探查操作,测试控制该资源信号量的值是否大于0, 大于0表示有资源可以使用,程序会进入临界区,访问临界资源

15.V操作: 增加操作, 访问完资源之后,释放资源,资源的数量+1;

16.passeren, 通过的意思,vrijgeven 释放

17.p、v是一个原子操作。是由内核保证的

18.创建信号int semget(key_t key, int nsems, int semflg)

  1. 成功返回信号量的id、失败返回-1

  2. 参数2,是信号量的个数,

  3. 参数3:IPC_CREAT|0600;
    请添加图片描述

19.对信号量的操作int semctl(int semid, int semnum, int cmd, …)

  1. 失败返回-1

  2. 参数2:对那个信号量操作,就那个的编号,编号从零开始

  3. 参数3:cmd

    IPC_RMID,删除,

    GETVAL:获取该信号量所代表资源的数量,semctl返回该值

    GETALL: 获取所有信号的各自所代表的资源数量

    SETVAL:设置当前信号量所代表的资源数量,semctl成功返回0

    SETALL:设置所有信号量所代表的资源数量

  4. 可变参数
    请添加图片描述
    请添加图片描述

    //使用举例
    #include <head.h>
    int main(int argc, char **argv)
    {
        //创建信号量
        int semid = semget(2000, 3, IPC_CREAT|0666);
        ERROR_CHECK(semid, -1, "semget");
    
        unsigned short arr[3] = {1, 2, 3};
        int ret = 0;
    
        unsigned short retArr[3] = {0};
        //设置资源数量
        semctl(semid, 0, SETALL, arr);
    	//获取资源数量
        semctl(semid, 0, GETALL, retArr);
        for(int i = 0; i < 3; ++i){
            printf("retArr[%d] = %d\n", i, retArr[i]);
        }
        
        for(int i = 0; i < 3; ++i){
            printf("sem[%d] = %d\n", i, semctl(semid, i, GETVAL));
        }
        return 0;
    }
    

20.信号量的操作函数int semop(int semid, struct sembuf *sops, size_t nsops)

  1. 成功返回0, 失败-1

  2. 参数2:操作信号的结构体
    请添加图片描述
     sem_flg一般填0

  3. 参数3:结构体的数量

21.如何使用pv操作

  1. 进入临界区前p操作 p: -1

  2. 出临界区V操作 V: +1

22.p、v操作,系统为了保证原子性,所以每次p、v都会消耗比较多的时间

#include <head.h>
#define N 10000000
int main(int argc, char **argv)
{   
    int ret = 0;
    int shmid = shmget(1000, 4, IPC_CREAT|0666);
    ERROR_CHECK(shmid, -1, "shmget");

    int *p = (int *)shmat(shmid, NULL, 0);
    ERROR_CHECK(p, (void*)-1, "shmat");
    //对该地址所存储值清空
    memset(p, 0, sizeof(int));

    int semid = semget(2000, 1, IPC_CREAT|0666);
    ERROR_CHECK(semid, -1, "semget");
    semctl(semid, 0, SETVAL, 1);

    struct sembuf P, V;
    //p操作,申请一个资源
    P.sem_num = 0;
    P.sem_op = -1;
    P.sem_flg = 0;

    //v操作,释放一个资源
    V.sem_num = 0;
    V.sem_op = 1;
    V.sem_flg = 0;

    time_t beg, end;
    time(&beg);

    //父进程+100次
    if(fork()){
        for(int i = 0; i < N; ++i){
            //进入临界区前p操作
            ret = semop(semid, &P, 1);
            ERROR_CHECK(ret, -1, "semop");
            (*p)++;
            //出临界区V操作
            ret = semop(semid, &V, 1);
            ERROR_CHECK(ret, -1, "semop2");
        }
        wait(NULL);
        printf("p = %d\n", *p);
        time(&end);
        printf("cost time = %ld\n", end - beg);
    }
    //子进程+100次
    else{
        for(int i = 0; i < N; ++i){
            //进入临界区前p操作
            ret = semop(semid, &P, 1);
            ERROR_CHECK(ret, -1, "semop");
            (*p)++;
            //出临界区V操作
            ret = semop(semid, &V, 1);
            ERROR_CHECK(ret, -1, "semop2");
        }
    }
    shmdt(p);
    return 0;
}

23.sem_flag设置为SEM_UNDO的作用

  1. 如果某个操作指定SEM_UNOD, 那么进程终止时自动撤销资源操作

  2. 好处:可以避免死锁。

  3. 为了防止p操作后,进程意外退出,资源没有被释放,造成信号量的值发生错误。

24.删除信号量:ipcrm -s semid

25.生产者消费者模型

#include <head.h>
int main(int argc, char **argv)
{
    int ret = 0;
    int semid = semget(3000, 2, IPC_CREAT|0666);
    ERROR_CHECK(semid, -1, "semget");

    //0代表商品的数量,5代表货架的数量
    unsigned short arr[2] = {0, 5};
    semctl(semid, 0, SETALL, arr);

    struct sembuf sop[2];
    memset(sop, 0, sizeof(sop));

    //父进程充当生产者
    if(fork()){
        //生产者生产商品,消耗货架
        sop[0].sem_num = 0; 
        sop[0].sem_op = 1;
        sop[0].sem_flg = 0;

        sop[1].sem_num = 1;
        sop[1].sem_op = -1;
        sop[1].sem_flg = 0;
        while(1){
            ret = semop(semid, sop, 2);
            ERROR_CHECK(ret, -1, "semop1");
            printf("生产者:商品的数量 = %d, 货架的数量 = %d\n", semctl(semid, 0, GETVAL), semctl(semid, 1, GETVAL));
            sleep(1);
        }
    }
    else{
        //消费者,消耗商品,释放货架
        sop[0].sem_num = 0;
        sop[0].sem_op = -1;
        sop[0].sem_flg = 0;
        
        sop[1].sem_num = 1;
        sop[1].sem_op = 1;
        sop[1].sem_flg = 0;
        while(1){
            ret = semop(semid, sop, 2);
            ERROR_CHECK(ret, -1, "semop2");
            printf("消费者:商品的数量 = %d, 货架的数量 = %d\n", semctl(semid, 0, GETVAL), semctl(semid, 1, GETVAL));
            sleep(2);
        }
    }
    return 0;
}
5.消息队列(Message Queue)MQ

1.是一种多进程之间通信一种机制:解耦、异步、削峰

  1. 解耦:就是解除两个或者两个以上事物之间的关联性,使其具有独立性

  2. 异步:

  3. 削峰:

2.创建消息队列使用的函数int msgget(key_t key, int msgflg)

  1. 成功返回消息队列id,失败返回-1,

3.往消息队列传数据int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)

  1. 成功返回0, 失败返回-1,

  2. 参数1,msgid

  3. 参数2:该结构体需要重构,重构的地方时结构的第二个成员,改成自己需要使用的大小

    struct msgbuf{
        long mtype;      //message type,must be > 0
        char mtext[1];   //message data
    }
    
  4. 参数3:发送数据的长度

  5. 参数4:标志位,填0;

4.从消息队列里面接收数据的函数ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

  1. 成功返回接收的字节数,失败返回-1,

  2. 参数2:接收的信息保存在该结构体中

  3. 参数3:最多接收的数据量

  4. 参数4:指定接收哪个类型的数据

  5. 参数 5:标志位,填0

5.消息队列,是先进先出结构,相同类型下,先写入队列的数据,先接收;数据之间是有边界的

//msgsnd.c
#include <head.h>
struct mymsgbuf {
    long mtype;
    char mtext[10];
};
int main(int argc, char **argv)
{
    int ret = 0;
    int msgid = msgget(1000, IPC_CREAT|0666);
    ERROR_CHECK(msgid, -1, "msgget");
    
    struct mymsgbuf mbuf;
    memset(&mbuf, 0, sizeof(mbuf));
    
    mbuf.mtype = atoi(argv[1]);
    strcpy(mbuf.mtext, argv[2]);

    ret = msgsnd(msgid, &mbuf, strlen(mbuf.mtext), 0);
    ERROR_CHECK(ret, -1, "msgsnd");
    return 0;
}

//msgrcv.c
#include <head.h>
struct mymsgbuf {
    long mtype;
    char mtext[10];
};
int main(int argc, char **argv)
{
    int ret = 0;
    int msgid = msgget(1000, IPC_CREAT|0666);
    ERROR_CHECK(msgid, -1, "msgget");
    
    struct mymsgbuf mbuf;
    memset(&mbuf, 0, sizeof(mbuf));
    
    long type = atoi(argv[1]);

    //从消息队列里面接收数据
    ret = msgrcv(msgid, &mbuf, sizeof(mbuf.mtext), type, 0);
    ERROR_CHECK(ret, -1, "msgrcv");

    printf("ret = %d\n", ret);
    printf("buf = %s\n", mbuf.mtext);
    return 0;
}

6.msgrcv接收类型如果是填0,代表无差别类型接收;如果填负数,就取小于该负数绝对值的数据

7.函数小结:
请添加图片描述

6.信号

1.信号不同于信号量

2.信号也是进程间通信的一种

3.系统当中信号的定义:信号是进程运行过程中,由自身产生,或者由进程外部发过过来的消息。

4.中断:中断是指计算运行过程当中,出现某些意外情况需要主机干预,机器能自动停止正在运行的程序,并且转入处理新情况的程序,处理完毕之后,又会返回原来被暂停的程序中继续运行。

  1. 硬中断:硬中断是由外设产生的,硬中断信号是由中断控制器提供,硬中断可以屏蔽的。

  2. 软中断:软中断是利用硬中断的概念,用软件的方式进行的模拟。宏观上异步的执行效果

  3. 软中断是实现系统API函数调用的手段

5.软中断不会去抢占另一个软中断,硬中断可以抢占软件中断。硬件中断可以保证时效性

6.每一个信号都自己的默认行为,它决定了收到信号后的行为。

  1. Term:终止当前进程

  2. Ign:忽略该信号

  3. Core:终止当前进程,并且产生core dump

  4. Stop:停止(挂起)一个进程

  5. Cont:使当前停止的进程,继续运行

7.常见信号

  1. SIGINT 2 term 中断来自键盘 ctrl c

  2. SIGQUIT 3 Core 从键盘退出 ctrl \

  3. SIGKILL 9 Term Kill signal 不能被捕捉也能被修改

  4. SIGPIPE 13 Term 写一个关闭读端的管道回收这个信号

  5. SIGALRM 14 Term sleep函数就是使用这个信号

  6. SIGUSR1 SIGUSR2 是提供给用户使用的信号 默认行为 Term

8.signal、sigaction这两个函数可以捕捉信号,并且可以修改信号的行为

9.由进程内部产生的信号又称为同步信号, 由进程外部产生的信号又叫异步信号

10.进程收到信号后有三种行为处理该信号
  1.接收默认处理
  2.忽略信号
  3.捕捉信号并处理


11. signal函数
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
//signal()  returns  the  previous  value  of  the signal handler, or SIG_ERR on error.
//参数1:捕捉或处理的信号
//参数2:处理信号的函数
//参数2还可以填SIG_DFL(默认行为),SIG_IGN(忽略)
//例子
#include <head.h>
void sigFunc(int sigNum)
{
    printf("sig %d is comming\n", sigNum);
}
int main(int argc, char **argv)
{
    if(SIG_ERR == signal(2, sigFunc)){
        printf("signal is error\n");
        return -1;
    }
    else{
        printf("signal is success\n");
        while(1);
    }
    return 0;
}

12.两个函数功能类似,参数越多,功能越强

13.signation函数

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
//参数1:信号值
//参数2,如下结构体
struct sigaction {
	void  (*sa_handler)(int);//信号处理函数1
	void  (*sa_sigaction)(int, siginfo_t *, void *);//信号处理函数2
	sigset_t   sa_mask; //阻塞信号的集合
	int  sa_flags;//信号处理方式,使用信号处理函数1填0,使用信号处理函数2填SA_SIGINFO
	void  (*sa_restorer)(void);//保留不用
};
//参数3,保存原来信号的一些信息
/*struct sigaction 中第二个成员的第二个参数(结构体)为传入传出参数,该结构体中有许多保存信号信息的变量,
如信号值,信号产生的时间,若想获取监听到的信号的相关信息的话,可选取这个函数。第一个参数自动传入的是信号的编号。*/

13.第二个成员的最后一个参数一般为NULL,其实是一个ucontext结构体的指针,查看ucontext_t结构体的方法:源码文件里glibc里查找

grep -rwn "typedef struct ucontext_t {"
vim sysdeps/unix/sysv/linux/sparc/sys/ucontext.h

14.使用举例

#include <head.h>
void sigFunc(int sigNum)
{
    printf("sig %d is comming\n", sigNum);
}
void newFunc(int sigNum, siginfo_t* pInfo, void * p)
{
    printf("new sig %d is comming\n", sigNum);
}
int main(int argc, char **argv)
{
    int ret = 0;
    struct sigaction act;
    memset(&act, 0, sizeof(act));

    //使用旧类型的信号处理函数
    /* act.sa_handler = sigFunc; */
    /* act.sa_flags = 0; */

    //使用新类型的信号处理函数
    act.sa_sigaction = newFunc;
    act.sa_flags = SA_SIGINFO;

    ret = sigaction(2, &act, NULL);
    ERROR_CHECK(ret, -1, "sigaction");

    while(1);
    return 0;
}

15.在signal处理机制下,有四种行为需要考虑

  1. 情形1:处理完一个信号后,是否需要重新注册捕捉下一个信号。(不需要)

  2. sigaction函数的sa_flag可以按位或SA_RESETHAND,第一次捕捉信号去执行信号处理函数,第二次执行信号的默认行为。

  3. 情形2:如果正执行当前的信号处理函数,还没有执行完, 此时来了一个相同的信号,它的行为是什么?

    1. 先执行完当前的信号处理函数,然后再只执行新的相同信号一次
      请添加图片描述
  4. 情形3:如果正执行当前的信号处理函数,还没有执行完, 此时来了一个不同的信号,它的行为是什么?

    1. 会优先执行新的信号(前提是该新信号没有与之相同的信号在被打断中),新的信号处理完成之后再继续执行之前的信号处理函数。
      请添加图片描述
      请添加图片描述
  5. 情形4:如果当前进程阻塞在系统调用上(比如read函数),那么当它收到一个信号后,它的处理行为是什么?

    1. 对于signal函数,先处理信号处理函数,然后再返回系统调用上

    2. 对于sigaction函数:先处理信号处理函数,然后不会返回到系统调用上,系统调用read函数直接返回-1

    3. sigaction的sa_flag按位或上SA_RESTART就可以实现重启系统调用

16.同时使用sigaction的新类型和旧类型信号处理函数

  1. 会执行最后设置类型的处理函数

  2. 因为结构体sigaction类型实际上是一个联合体

    //源码
    struct sigaction {
        union{
            void  (*sa_handler)(int);
    		void  (*sa_sigaction)(int, siginfo_t *, void *);
        } _u;
    	sigset_t   sa_mask; 
    	int  sa_flags;
    	void  (*sa_restorer)(void);
    };
    
  3. sigaction 使用新类型sa_flags填SA_SIGINFO才可在第二个参数精确保存该信号的相关信息。

  4. 查看当前bash的进程id : echo $$

17.信号的阻塞

  1. 阻塞与忽略的区别,

    1. 阻塞:阻塞一会,之后还会继续执行

    2. 忽略:不会再理会该信号

  2. 使用sigaction阻塞信号,阻塞的作用域就在信号处理函数这个范围内

    阻塞操作步骤(注意要修改sigaction中结构体参数中的sa_mask的值)

    //操作函数
    int sigemptyset(sigset_t *set);
    int sigfillset(sigset_t *set);
    int sigaddset(sigset_t *set, int signum);
    int sigdelset(sigset_t *set, int signum);
    int sigismember(const sigset_t *set, int signum);  //第一个参数要用sigpending来获得
    
    int sigpending(sigset_t *set);  //获取阻塞信号集合
    
    //例如
    #include <head.h>
    void newFunc(int sigNum, siginfo_t * pInfo, void* p)
    {
        printf("new  sig %d is comming\n", sigNum);
        sleep(5);
        printf("after sig\n");
    }
    int main(int argc, char **argv)
    {
        int ret = 0;
    
        struct sigaction act;
        memset(&act, 0, sizeof(act));
    
        sigset_t set;
        sigemptyset(&set);
    
        //把3号信号放到阻塞集合当中
        sigaddset(&set, 3);
    
        act.sa_sigaction = newFunc;
        act.sa_flags = SA_SIGINFO;
        act.sa_mask = set;
    
        ret = sigaction(2, &act, NULL);
        ERROR_CHECK(ret, -1, "sigaction");
        while(1);
        return 0;
    }
    
  3. sigprocmask它可以实现全局范围内的信号阻塞。

  4. 查看进程中阻塞集合路径 vim include/linux/sched.h +931

  5. sigprocmask函数

     int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
    //成功返回0,失败返回错误码
    //参数1:表示怎么处理第二个参数这个集合
    //SIG_BLOCK:把集合加入到原有的阻塞集合当中
    //SIG_UNBLOCK:把集合从原有的集合中删除
    //SIG_SETMASK:把集合特换原有的阻塞集合
    //参数2:阻塞集合
    //参数3:原有的集合
    
    //使用举例
    #include <head.h>
    void newFunc(int sigNum, siginfo_t * pInfo, void* p)
    {
        printf("new  sig %d is comming\n", sigNum);
        sleep(5);
        printf("after sig\n");
    }
    int main(int argc, char **argv)
    {
        int ret = 0;
    
        struct sigaction act;
        memset(&act, 0, sizeof(act));
    
        sigset_t set;
        sigemptyset(&set);
        //把2号信号放到阻塞集合当中
        sigaddset(&set, 2);
    
        //使用新类型
        act.sa_sigaction = newFunc;
        act.sa_flags = SA_SIGINFO;
    
        ret = sigaction(2, &act, NULL);
        ERROR_CHECK(ret, -1, "sigaction");
    
        //设置阻塞
        sigprocmask(SIG_BLOCK, &set, NULL);
    
        printf("before sleep\n");
        sleep(5); //运行到此时收到2号信号会阻塞,并不会去执行信号处理函数
        printf("after sleep\n");
    
        //解除阻塞
        sigprocmask(SIG_UNBLOCK, &set, NULL);
    	//解除阻塞后才会根据阻塞队列执行信号处理函数
        while(1);
        return 0;
    }
    

18.kill函数:int kill(pid_t pid, int sig);

19.查看后台是否有任务:jobs

20.睡眠函数

  1. sleep:1秒

  2. usleep:1微秒

  3. 1秒 = 100W微秒

  4. alarm:睡眠函数, pause

  5. alarm的参数,设置闹钟多长时间后相应:其实就是在该时间后发送SIGALRM信号。pause函数收到这个信号后返回。

  6. pause是一个阻塞性函数,alarm后阻塞在pause,直到收到SIGALRM函数。

  7. alarm函数返回值:是指上一次闹钟还剩多长时间

  8. 注意:alarm函数不要和sleep一起使用。alarm到时间后直接发信号给pause,使中间的sleep失效。

    #include <head.h>
    
    int main(int argc, char **argv)
    {
        int ret = 0;
        ret =  alarm(3);
        printf("alarm1 ret = %d\n", ret);
    
        sleep(10);
        ret =  alarm(3);
        printf("alarm2 ret = %d\n", ret);
    
        pause();
        return 0;
    }
    

21.计时器

  1. 真实计时器:程序实际运行的时间(从进程启动那一刻起一直到进程结束,进程不占cpu的时候的时间也被计入)

  2. 虚拟计时器:程序在用户态所消耗的时间(不含系统调用的时间,与不含睡眠所占用的时间)

  3. 实用计时器:程序在用户态和内核态所占用的时间之和。

  4. 三个计时器对应的发送的信号:SIGALARM、SIGVTALARM、SIGPROF

    int getitimer(int which, struct itimerval *curr_value);
    //参数1:计时器的种类, ITIMER_REAL   ITIMER_VIRTUAL  ITIMER_PROF
    //参数2:保存计时器的初始时间和间隔时间
    int setitimer(int which, const struct itimerval *new_value,struct itimerval *old_value);
    
    struct itimerval {
    	struct timeval it_interval; /* Interval for periodic timer */
    	struct timeval it_value;    /* Time until next expiration */
    };
    
    /*struct itimerval的第一个成员表示信号开始后每隔多长之间发一个,setitimer第一个参数指定的情况下的对应信号,第二个成员指函数执行后多久开始发信号。*/
    
    struct timeval {
    	time_t      tv_sec;         /* seconds */
    	suseconds_t tv_usec;        /* microseconds */
    };
    
  5. 例子

    #include <head.h>
    void sigFunc(int sigNum)
    {
        time_t now;
        time(&now);
    
        printf("now time = %s\n", ctime(&now));
    }
    int main(int argc, char **argv)
    {
        signal(SIGPROF, sigFunc);
    
        struct itimerval val;
        memset(&val, 0, sizeof(val));
    
        val.it_value.tv_sec = 1;
        val.it_interval.tv_sec = 2;
        time_t now;
        time(&now);
    
        printf("now time = %s\n", ctime(&now));
    
        setitimer(ITIMER_PROF, &val, NULL);
    
        printf("before sleep\n");
        sleep(3);
        printf("after sleep\n");
    
        while(1);
        return 0;
    }
    //当有其他进程也在运行,打印出来的time结果并不是按照预想的那样,因为其他进程占用了时间片,而实用计时器只计算该进程占用时间片时的时间来发SIGPROF信号
        return 0;
    }
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值