Linux-进程随记

Linux-进程笔记


本文仅仅是个人学习进程相关知识时的笔记整理,主要参考了以下一些作者的资料:野火B站视频,书《深入浅出linux工具与编程》,一口linux微信公众号,以及一些博客等

进程相关的命令

命令功能
ps -A查看进程
top查看个进程对CPU的占用率
ps -ef 或 ps -ef | mode查看进程的pid
pstree查看进程树
kill向进程发送信号
./test &后台运行test程序
ctrl+z把当前运行的程序停止并放到后台
jobs -l查看所有任务及其任务编号n和pid
bg %<任务编号n>任务编号为n的任务放到后台运行
fg %<任务编号n>任务编号为n的任务放到前台运行
ctrl+c杀死当前任务
ps aux显示所有进程
ps axjf显示进程及之间的关系

进程的产生

fork()

产生一个子进程函数:pid_t fork()

产生的子进程与父进程完全相同且独立,子进程产生孙进程后自动退出,孙进程由init进程接管,但子进程推出后,还需要父进程为其收尸。

返回值:在父进程返回值为新创建的子进程的pid,pid>0;在子进程中的返回值为0,因此,通过判断返回值来判断程序是在子进程还是在父进程,也就是用过if分支来让父子执行不同的操作。返回值<0,则创建是失败,错误码存于标准出错流中;一般情况下,标准出错流stderr和标准输出流stdout为相同的文件,默认为所打开的终端,下文所有函数的的错误码都是一样的机制,错误码可通过函数perror()打印。

vfork()

vfork()也能创建一个子进程,**与fork()的区别:**与父进程公用内存空间,也就是子进程改变父子进程共有的变量,父进程的变量也会改变;子进程优先运行,且当子进程exit()退出后,父进程才执行。

getpid()

获取当前进程的pid:getpid()

getppid()

获取该进程的父进程的pid:getppid()

进程的消亡

当父进程创建了大量子进程,子进程退出,父进程运行但没给子进程收尸,子进程称为僵尸进程,父进程结束后,这些僵尸进程托孤到init进程,成为孤儿进程。当父进程先于子进程退出时,子进程也成为孤儿进程,托孤到init进程。

init进程

init进程为系统创建的第一个用户级进程,其pid=1。

wait()和waitpid()

两函数都能让父进程为子进程收尸。

pid_t wait(int *status):父进程阻塞地等待一个子进程退出成为僵尸,然后为子进程收尸,其中:int *status用于保存子进程退出时的状态,一般用不到用NULL代替;返回值为收尸成功的子进程的pid。

由于该函数只能阻塞地为一个子进程收尸,若想为多个子进程收尸,需要让子进程向父进程发信号(如:SIGCHLD),父进程受到信号后调用收尸函数wait()

pid_t waitpid(pid_t pid,int *status,int options):返回值pid_t 和参数int *statuswait()功能一样。

输入参数pid:>0只为该PID的子进程结束后收尸;=0等待同一个进程组的所有子进程结束后为它们收尸;=-1只等待第一退出的子进程并收尸,与wait()一样;<-1只等待该值的绝对值的进程组的所有子进程结束并收尸。

options:设置选项(如:是否阻塞的等待收尸),不用时设为0。

exec函数族

上文中fork出的新子进程与父进程完全一致,只通过if来让父子进程执行不同的操作,而该函数族能让进程刷新并执行另一个指定的程序,exec函数族的后缀不同函数有着不一样的功能,如execl(...) execlp(...) execv(...)等等。

注意:exec函数族用之前一定要先用fflush()来清除缓冲区,防止不同流中的缓冲方式(行缓冲、全文缓冲)不同而导致输出结果不同。标准输入输出流:行缓冲;其他(如文件作为)输入输出流可能会是全文缓冲。

例:

int main(char argc, char**argv)
{
    pid_t pid;
    
    fflush(NULL);
    pid = fork();
    
    if(pid == 0)//子进程
    {
        /*执行路径为/bin下的sleep可执行程序,其中输入参数为sleep和100,
        相当于在终端中执行shell命令sleep 100*/
        execl("/bin/sleep", "sleep", "100", NULL);
        exit(0);
    }
    wait(NULL);
    exit(0);
}

exec函数族、system()、popen()

三者都能编译出并执行指定可执行程序。

1、exec函数族需要自己fork出一个子进程;system则不用,它已在内部调用。

2、system、popen运行完可执行文件后,会返回到原程序中继续执行,而exec函数族不会。

3、返回值不同,exec无返回值,执行成功后直接退出原程序,执行失败后,继续原程序;system,成功返回进程的状态值,继续执行原程序;

FILE *stream = popen("ps", "r")(相当于在终端执行ps ,且管道文件只读r),创建一个管道文件,该文件描述符通过返回值获取,该管道文件会连接到该进程的标准输出流(或输入流)中,获取其内容到文件中,执行后,可通过fread()等IO操作把读取到buf中,使用完毕后要用fclose(stream)关闭;

用户权限及组权限

geteuid()getuid():获取当前进程用户的euid和uid

seteuid()setuid():设置当前进程用户的euid和uid

getegid()getgid():获取当前进程用户组的egid和gid

setegid()setgid():设置当前进程用户组的egid和gid

进程间通信

作用

进程间的通信除了能在进程间进行信息的传递,也能做到进程间同步(先执行a进程后才能执行b进程),避免竞争,保护共享资源


种类

进程间通信方式包含:

  1. 管道((无名)管道pipe 和 有名管道FIFO)
  2. 信号(signal)
  3. 共享内存
  4. 消息队列(message queue)
  5. 信号量(sem)
  6. 套接字(socket)

管道

概述

管道通信,其实就是利用Linux文件系统中的管道文件进行通信(半双工),通俗地理解为:

  1. 两个进程a和b;

  2. 进程a创建一个管道文件btoa,以只读的方式打开管道文件,获得指向btoa文件节点(inode)的文件描述符fd_r,利用fd_r从管道文件中接收数据;

  3. 而进程b则以只写方式打开btoa,获得指向btoa文件节点的文件描述符fd_w,往fd_w中写数据,进程a便能收到数据。

  4. 所以,若要a、b都能接收和发送数据,一般需要两个管道文件,分别为:btoa,atob。

无名管道(pipe)

无名管道,只支持父子进程间通信,因为无名管道的管道文件是匿名,只有父子进程能看到并使用,其它进程是找不到该管道文件的。

pipe( int filedes[2] )

创建无名管道:int pipe( int filedes[2] )

创建好的无名管道的读端的文件描述符为filedes[0],而写端的文件描述符为filedes[1],返回值为:0,操作成功;-1,出错。

注意:为了避免两个进程同时写入或读出,所以在操作管道前,要用close()函数关掉不需要的一端。暂时不需要操作管道时,也要close()释放管道的读写端。

例:

#include <unistd.h>
#include <sys/types>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    int pipe_fd[2];
    pid_t pid;
    char buf_r[100];
    char *buf_w;
    int r_num, ret;
    
    memset(buf_r, 0, sizeof(buf_r));
    
    //创建管道
    ret = pipe(pipe_fd);
    if(ret < 0)
    {
        perror("pipe error\n");
        return -1;
    }
    
    pid = fork();
    if(pid == 0)//子进程:读
    {
        printf("child: pipe1=%d, pipe2=%d\n", pipe_fd[0], pipe_fd[1]);
        close(pipe_fd[1]);
        sleep(1);
        r_num = read(pipe_fd[0], r_buf, 100);
        if(r_num>0)
        {
            printf("num of data:%d  data is:%s\n", r_num, buf_r);
        }
        close(pipe_fd[0]);
        exit(0);
    }
    else if(pid > 0)//父进程写
    {
        printf("parent: pipe1=%d, pipe2=%d\n", pipe_fd[0], pipe_fd[1]);
        close(pipe_fd[0]);
        ret = write(pipe_fd[1], "hello", strlen("hello"));
        if(ret != -1)
        {
            printf("write hello success");
        }
        
        ret = write(pipe_fd[1], "pipe", strlen("pipe")+1);
        if(ret != -1)
        {
            printf("write pipe success");
        }
        close(pipe_fd[1]);
        sleep(3);
        waitpid(pid, NULL, 0);
        exit(0);
    }
    else
    {
        perror("fork error");
        exit(-1);
    }
}
有名管道(fifo)

有名管道(FIFO),也叫命名管道,不同祖先的进程都可以通过命名管道共享数据,当共享命名管道执行完所有IO操作后,命名管道仍然存在。

mkfifo(pathname, mode)

创建有名管道:int mkfifo(const char * pathname,mode_t mode)

pathname:文件路径及文件名,该文件必须不存在。

mode:管道文件的权限(mode);

返回值:0成功;-1失败

文件权限

一般而言,mode=0600,从左到右,第一个0表示八进制,后面的600分别表示用户的权限(6[110]可读可写不可执行),所在组的权限(0[000]不可读写与执行),其它用户的权限(0[000]不可读写与执行);但该文件的真正权限为:mode&(~umask),1表示拥有权限,0表示没有权限

在终端输入umask查询值,一般都为0022,从左到右,与mode类似,但0和1的所表示的意思与mode相反,即:0-有权限,1-无权限。

例:下运行send.c,再打开另一个终端运行recieve.c

send.c

#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdio.h>
#include <fcntl.h>
#define FIFO "/temp/fifo"

int main()
{
    char buffer[128];
    int fd, n, ret;
    char info[128];
    
    unlink(FIFO);/*若存在该管道文件,则删除,在接收端则无需此行代码*/
    ret = mkfifo(FIFO, 0600);
    if(ret<0)
    {
        perror("mkfifo error\n");
        return -1;
    }
    memset(info, 0x00, sizeof(info));
    strcpy(info, "hello world!");
    
    fd = open(FIFO, O_WRONLY);/*以只读的方式打开管道文件*/
    n = write(fd, info, strlen(info));
    if(n < 0)
    {
        perror("write error");
        return -1;
    }
    close(fd);
    return 0;
}

recive.c

#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdio.h>
#include <fcntl.h>
#define FIFO "/temp/fifo"

int main()
{
    char buffer[128];
    int fd, n, ret;
    char info[128];
    
    fd = open(FIFO, O_RDONLY);
    n = read(fd, buffer, 128);
    if(n < 0)
    {
        perror("read error");
        return -1;
    }
    printf("buffer=%s\n", buffer);
    close(fd);
    return 0;
}

现象:先运行send程序,程序会阻塞在write()函数处,然后,执行recieve后,打印出“hello world”,并结束;原本阻塞的send进程也结束了。


信号

概述

信号,可以理解为进程间的异步通知机制,也可以理解为一种操作系统通过软件实现的中断处理机制(与单片机的各种中断类似),linux中的每种信号都有一个信号值,可以通过在终端输入kill -l来查看所有种类的信号,kill指令也可以向制定进程发送指定信号。

触发一个信号发出有两种方法:1、通过硬件触发(如:硬件故障、键盘按键按下ctrl+c等);2、通过软件代码触发(如:利用kill、alarm、setitimer、raise等函数触发制定信号)。

进程对每个信号可以设定的处理方式有:忽略(搁置)信号(信号来了也当作没来,不会捕抓该信号);捕抓信号,然后立刻执行对应操作,也可以是空操作;阻塞(屏蔽)信号,也就是先不处理(放到阻塞信号集里),后面把信号从阻塞信号集移出后再处理。注意:信号SIGSTOP和SIGKILL无法进行捕抓、忽略、阻塞。

进程收到信号后,系统为提供了五种默认触发动作

  1. 异常终止(abort):终止进程时,其相关内容会保存到一个core文件
  2. 退出(exit):直接退出进程,没有core文件
  3. 忽略(ignore):忽略该信号
  4. 停止(stop):挂起该进程
  5. 继续(continue):若进程处于挂起状态,则回复进程运行,否则,忽略该信号。

可靠信号(信号值>31):当进程同时收到多个信号时,可靠信号会进行排队,不会丢失

不可靠信号(信号值0~31):不可靠信号无法进行排队,可能会发生丢失。


简单的信号收发函数
kill() 和 raise()

int kill(pid_t pid, int sig);

作用:向进程id为pid的进程发送信号编号为sig的信号;返回值:0-成功;1-错误。

pid>0:向指定pid的进程发送信号;

pid=0:信号发送信号给同进程组的全部进程;

pid=-1:信号发送给系统内的全部进程;

pid<-1:信号发送给进程组号为-pid的进程组的全部进程;


int raise(int sig);

作用:只向自己发送信号编号为sig的信号。


alarm() 和 setitimer()

unsigned int alarm(unsigned int seconds);

作用:在seconds秒后,SIGALRM信号会发送到当前进程;若seconds=0则取消闹钟。将剩余的时间作为返回值返回。

注意

  1. 进程收到SIGALRM信号的默认动作为退出。
  2. 一个进程只有一个alarm(),再次使用会覆盖掉之前的状态。

int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

该函数是常用的计时函数,它比alarm实用,因为它能提供的计时器种类更多,不像alarm那样功能单一。

int which:设置计时器类型:

ITIMER_REAL(以真实的时间来计时,到时间就发出SIGALRM信号);

ITIMER_VIRTUAL(以进程在用户态下运行的时间计算,到时间就发出SIGVTALRM);

ITIMER_PROF(以用户态和内核态所运行的时间来计算,到时间就发出SIGPROF)

struct itimerval *new_value:设置定时器

该结构体的定义:

struct itimerval {
    struct timeval it_interval; /* next value */
    struct timeval it_value;    /* current value */
};

struct timeval {
    time_t      tv_sec;         /* seconds */
    suseconds_t tv_usec;        /* microseconds */
};

其中,第一次计时用it_value的值,到时间就会触发信号,之后每隔it_interval个时间就触发一次信号。若it_value为0,那么一个信号都不会触发;若it_interval为0,那么只会触发第一次以it_value为计时时间的信号,后面的信号都不会触发。

struct itimerval *old_value:一般为NULL,该参数用于存储上一次函数调用时new_value的值,不常用。


pause()

int pause(void)进程暂停,进入睡眠态,直到被信号中断

返回值:-1(出错)


sleep() 和 abort()

unsigned int sleep(unsigned int seconds):进程暂停,直到seconds秒后唤醒,或中途有信号中断。

返回值:到时间被唤醒返回0,被信号中断则返回剩余的时间。


信号处理的配置函数
signal() 和 sigaction()

signal(int signum,void(* handler)(int))

作用:指定信号signum的处理方式为handler。

参数:int signum:指定信号的编号

void(* handler)(int):放入处理函数的函数指针(函数名),其中的传入参数用于获取当前响应的信号编号。当次参数为:SIG_IGN把该信号忽略,SIG_DFL则把该信号设为默认处理方式。


int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)

signum:与signal()一样。

其中struct sigaction结构体的定义为:

struct sigaction
{
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
}
/*
* sa_handler:与signal()函数的handler一样,用于适配旧版本的signal()函数
* sa_sigaction:新的信号处理函数,传入参数除了存储了信号编号外还增加了用于储存其它信息。
*sa_mask:用于设置在处理该信号是需要屏蔽的其它信号
* sa_restorer: 无用参数
* sa_flags:用于设置信号处理的其它操作,如:
	A_NOCLDSTOP:若signum为SIGCHLD时,则子进程退出时不会通知父进程。
	SA_NOMASK/SA_NODEFER:在此信号的处理未结束时,忽略该信号的再次到来
	SA_SIGINFO:信号处理处理函数为sa_sigaction,而非sa_handler。
*/

oldact:若不是NULL,则用于储存修改前的处理方式。

信号集

信号集的出现能更好的对多个信号进行集中处理。比如:要屏蔽多个信号,就可以创建一个信号集进行操作。

信号集变量数据类型:sigset_t

先创建一个信号集变量指针,再利用函数对其进行初始化等相关操作。

信号集操作函数

初始化信号集为空: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(sigset_t *set, int signum)


设置阻塞信号集:int sigprocmask(int how, const sigset_t *set, sigset_t *oset)

how:操作信号集的方式,SIG_BLOCK增加置信号到阻塞信号集;SIG_UNBLOCK:把信号从阻塞信号集移出;SIG_SETMASK:把当前信号集设为阻塞信号集;

set:指定信号集

oset:保留修改前的信号屏蔽字


从信号集中查询搁置信号函数:int sigpending(sigset_t *set)被搁置的信号存储在参数set中。


上述函数的返回值:0-成功;-1-出错

使用步骤:

  1. 定义信号集指针,创建空信号集。
  2. 添加信号到信号集
  3. 设置信号集为屏蔽信号集

System V IPC

接下来的消息队列、共享内存、信号量都属于System V IPC,它们都是早期Unix的通信方式,现在被Linx系统所兼容,使用的方法也十分类似,它们都需要一个键值key来完成创建

在终端上,ipcs查看ipc,ipcrm -<选项> xxx删除ipc。由于ipc如果不删除,会占用系统资源。

System V IPC的相关函数

消息队列信号量共享内存
创建IPC函数int msgget()semget()shmget()
控制IPC函数msgctl()semctl()shmctl()
IPC操作函数msgsnd() msgrcv()semop()shmat() shmdt()

消息队列

msgget()

创建一个IPC:int msgget(key_t key, int msgflg)

返回值:该IPC的标识符(int msqid),后续对该IPC的相关操作都需要用它。当返回-1时,创建出错。

参数:

key_t key:IPC的键值,键值是唯一的,可以通过ftok()函数创建,也可以填入宏IPC_PRIVATE(值为0)随机获取一个key值

msgflag:当该值为IPC_CREAT,则在创建IPC时,若该IPC的key已存在,则返回已存在的IPC的标识符。当该值为IPC_CREAT|IPC_EXCL时,则当IPC的key已存在时,则当作创建失败,返回值为-1。注意:该参数含需要 或(|) 上IPC对象的权限(与文件权限的设定类似),如:IPC_CREAT | 0600

ftok()

获取一个IPC键值key:key_t ftok(const char *pathname, int proj_id)

pathname:文件路径

proj_id:一个自己定义的随机八位数,如:0x22

生成的key是根据pathname文件信息(通过stat()可查看),和proj_id合成的。


msgctl()

int msgctl(int msqid, int cmd, struct msqid_ds *buf)

msqid:消息队列标识符

cmd

  • IPC_STAT,获取消息队列头的数据到buf
  • IPC_SET,把buf中消息队列的属性设置为当前消息队列的属性。

​ 返回值:0-成功;-1-错误


msgsnd()&msgrcv()

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)

msgp:需要发送的消息,该消息可以是任意类型的结构体,但结构体的第一个字段要为long type>0,例如:

/*自定要发送的结构体定义*/
struct s_msg{
    long type;	/*消息类型,用于区分不同的消息*/
    char mtext[128];
};

msgsz:发送的结构中体中数据的大小,不包含消息变量的大小。

msgflg:0-消息队列满时,该函数会阻塞。IPC_NOWAIT-消息队列满时,不阻塞,立即返回错误码。IPC_NOERROR-若发送的消息大于msgsz,则把该消息截断,丢弃超出部分,不报错。


ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg)

msqid与msgsnd()函数的含义类似

msgp:存储接收的结构体,结构体类型与发送端要相同

msgsz:与msgsnd()中的参数类似。

msgtyp:0-接收第一个消息;>0-接收第一个类型为msgtyp的数据;<0-接收第一个类型小于msgtyp的绝对值的数据。

msgflg:0-阻塞地接收数据;IPC_NOWAIT-非阻塞接收,消息队列中无信息则返回错误码ENOMSGIPC_EXCEPT-截断并丢弃大于msgsz的部分。

返回值:成果读取的数据长度;错误:-1。


信号量

信号量它更多的是提供对进程间共享资源的访问管理,相当于是共享资源是否可操作的一个标志。除了保护共享资源外,还可以用于进程间的同步。

注意:信号量一般不会单独出现,而是以信号量集来集中管理,因此信号量标识符也称为信号量集标识符。


semget()

int semget(key_t key, int nsems, int semflg)

nsenms:信号量的个数

剩下参数与消息队列类似。

返回值:信号量集标识符semid。错误:-1.


semop()

int semop(int semid, struct sembuf *sops, unsigned nosops)

作用:操作信号量的值,用于信号量的值的释放(v)与获取§操作。

sops:需要操作的信号量集结构体的首地址。

struct sembuf{
    short semnum;/*信号量在信号集中的编号,0是第一个信号量*/
    short val;/*val>0,释放val个信号量值;val<0,获取val个信号量的值,若信号量的值semval少于要获取的val值,则进程默认会阻塞等待*/
}

nsops要操作的信号量的个数;通常为1,表示对nsops个信号量的值进行sops操作

返回值:操作成功,返回信号集标识;出错返回-1。


semctl()

int semctl(int semid, int semnum, int cmd, union semun arg)

作用:在get()到信号集标识符后,初始化信号量集中的指定信号量。

与semop()的区别

semctl()主要用于初始化信号量集中指定信号量的相关参数或者读取指定信号量的参数,通过参数cmd的不同来进行初始化或者读取参数,信号量的参数存于union semun arg中。

semop()则主要用于对信号量的值的获取与释放,也就是使用指定信号量

参数:

semnum:要操作的信号量在信号集中的编号,可以理解为数组(信号集)的下标。

cmd:通过各种宏指令来初始化指定信号量,如SETVAL-初始化信号量的值,从参数arg获取;IPC_RMID-从内核中删除信号量集;GETNCNT返回当前等待资源的进程个数等。

arg:存储初始化要用的或读取返回的参数。由于是共用体,所以一次只能操作arg中的一个成员,再次操作,便会对原有参数进行覆盖。

union semun{
    short val;				/*GETVAL, SETVAL*/
    struct semid_ds *buf;	/*IPC_STAT, IPC_SET*/
    unsigned short *array;	/*GETALL, SETALL*/
    struct seminfo *_buf;	/*IPC_INFO*/
}

共享内存

共享内存是被多个进程共享的一部分物理内存,每个进程都把这块物理内存映射到自己的虚拟地址空间中,这样,进程进程就可以通过该虚拟地址访问共享内存,完成进程间通信。

shmget()

int shmget(key_t key, size_t size, int shmflg)

size:为>0的整数时,指新建size字节大小的共享内存;0-只获取共享内存。

其它参数与消息队列类似。

返回值:共享内存标识符shmid;错误:-1.


shmat()

void *shmat(int shmid, const void *shmaddr, int shmflg)

作用:把共享内存映射到指定的进程中的空间。

shmaddr:指定要映射到进程中的地址空间,填NULL由系统分配地址空间,并通过返回值返回。

shmflgSHM_RDONLY设为只读模式,其它均为读写模式。

返回值:映射成功的地址。


shmdt()

int shmdt(const void *shmaddr)

断开共享内存与映射地址的连接

返回值:0-成功;-1-出错


shmctl()

int shmctl(int shmid, int cmd, struct shmid_ds *buf)

修改共享内存的相关参数,参数列表在struct shmid_ds 中查看,与信号量集的ctl函数类似

cmd

  1. IPC_STAT得到共享内存的状态,并存在参数buf中。
  2. IPC_SET改变共享内存的状态。
  3. IPC_RMID删除该共享内存。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值