『Linux』进程信号

什么是信号?

现实生活中,我们经常会遇到下面这种场景,比如:快递员小哥给你发个短信,通知你19点前,到XXX取走快递,快递编号XXX;在这个例子中,快递小哥就是操作系统,你就是一个进程,而这个短信就是信号


我们先来演示一个常见的信号使用方式

#include <iostream>
#include <unistd.h>

int main(){
	// 死循环
	while(1){
		std::cout << "This is a endless loop, You should use ctrl + c to terminate it!\n";
		sleep(2);
	}

	return 0;
}

编译运行,如下:
在这里插入图片描述
可以看到这里是一个进程,它完成的功能是不停的打印一句话,想要把它停下来,必须使用ctrl + c来结束它。这里我们按下ctrl + c相当于是操作系统向该进程发送了一个信号,让该进程结束


同样是这段代码,我们重新运行一下
在这里插入图片描述
从上面的例子,我们可以看出,使用ctrl + c也无法终止该进程
原因如下:

  • ctrl + c产生的信号只能发给前台进程一个命令后面加上&表示将这个进程放到后台运行,这样shell不必等待进程结束就可以接受新的命令,启动新的进程。
  • shell可以同时运行一个前台进程和任意多个后台进程只有前台进程才能接到ctrl + c这种控制键产生的信号
  • 前台进程在运行过程中用户随时可能按下ctrl + c而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能受到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步

那么操作系统中都有哪些信号呢,我们可以通过kill -l来查看所有的信号

[sss@aliyun ~]$ kill -l

在这里插入图片描述
从上面可以看到,一共有62个信号。其中,

  • 1~31号继承自Unix(每个都有各自对应的事件),非可靠信号,非实时信号
  • 这些信号对应的事件,可以通过man手册来查看,man 7 signal
    在这里插入图片描述
  • 34~64是后加的31个信号(后序添加的信号,没有对应的事件),可靠信号,实时信号
  • 每个信号都有一个编号和一个宏定义名称,我们可以通过下面命令来查看这些宏定义
[sss@aliyun ~]$ vim /usr/include/asm/signal.h

在这里插入图片描述

信号的产生

按键产生信号

我们前面使用的ctrl + c相当于是发送了SIGINT信号,SIGINT信号的处理动作是终止进程并且CoreDump,我们来验证一下:
什么是Core Dump
一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令设置允许产生core文件的大小

  • 首先,我们使用ulimit -a命令看一下默认core文件大小
    在这里插入图片描述
    可以看到,默认产生的core文件大小为0,也就是不允许产生core文件
  • 我们使用ulimit -c 1024将core文件大小调整为1024K
    在这里插入图片描述
  • 我们还是使用前面那个死循环程序,运行起来,另开一个终端查看其进程id,使用ctrl + \退出它
    在这里插入图片描述
    在这里插入图片描述
    可以发现,core文件的命名就是core + .pid
  • 我们进入gdb,将corefile加载进来
    在这里插入图片描述

总结ctrl + c产生终止信号(SIGINT)(2)ctrl + \产生退出信号(SIGQUIT)(3)ctrl + z产生停止信号(SIGTSTP)(20)

命令、函数产生信号

我们可以使用kill -signo -pid对进程ID为pid的进程发送一个signo号信号,演示如下,还是使用前面死循环程序

  • 首先,运行程序
    在这里插入图片描述
  • 另开一个终端,查看该进程的进程ID,然后对该进程发送一个2号信号SIGQUIT
    在这里插入图片描述
  • 可以看到,进程退出了,信号发送成功
    在这里插入图片描述

下面介绍几个可以产生信号的函数

// 头文件:signal.h
// 功能:向进程pid发送sig信号
int kill(pid_t pid, int sig);
/*
*	参数:
*		pid:进程ID;
*		sig:信号编号。
*	返回值:成功返回0,失败返回-1。
*/

代码演示

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>

int main(){
	int cnt = 5;

	// 循环5次
	while(cnt >= 0){
		std::cout << "I will kill myself, count down: " 
			<< cnt << std::endl;
		// 休眠1s
		sleep(1);

		--cnt;
	}

	// 向自己发送SIGKILL信号
	int ret = kill(getpid(), SIGKILL);
	if(ret < 0){
		// 信号发送失败
		perror("kill error");

		return -1;
	}

	return 0;
}

编译运行程序,效果如下
在这里插入图片描述


// 头文件:signal.h
// 功能:给调用进程或调用线程发送指定信号
int raise(int sig);
/*
*	参数:
*		sig:信号。
*	返回值:成功返回0,失败返回非0。
*/

代码演示

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>

int main(){
	int cnt = 5;

	// 循环5次
	while(cnt >= 0){
		std::cout << "I will kill myself, count down: "
			<< cnt << std::endl;

		// 睡眠1s
		sleep(1);

		--cnt;
	}

	// 杀死自己
	int ret = raise(SIGKILL);
	if(ret != 0){
		// 信号发送失败
		perror("raise error");

		return -1;
	}

	return 0;
}

在这里插入图片描述


// 头文件:stdlib.h
// 功能:使当前信号收到信号而异常终止
void abort(void);

代码演示

#include <iostream>
#include <stdlib.h>

int main(){
	std::cout << "This is a abort test code!\n";

	// 异常终止
	abort();

	return 0;
}

编译运行,效果如下
在这里插入图片描述


// 头文件:unistd.h
// 功能:设置一个定时器,seconds秒后给调用进程发送SIGALARM信号。
//		seconds为0表示取消闹钟
unsigned int alarm(unsigned int seconds);
/*
*	参数:
*		seconds:定时的秒数。
*	返回值:
*		如果调用此alarm()前,进程已经设置了闹钟时间,则返回上一个闹钟时间的剩余时间;
*		否则返回0;失败返回-1。
*/

代码演示

#include <iostream>
#include <unistd.h>

int main(){
	// 设置一个5s的闹钟
	alarm(5);

	int i = 0;

	// 死循环
	while(1){
		std::cout << "seconds: " << ++i << std::endl;
		sleep(1);
	}

	return 0;
}

在这里插入图片描述

硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送合适的信号。例如当前进程执行了除0的指令CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
下面我们来测试一下
非法内存地址

#include <iostream>

int main(){
	int a = 3;
	int b = 0;

	// 除0
	int div = a / b;
	std::cout << div << std::endl;

	return 0;
}

编译运行,效果如下
在这里插入图片描述
下面我们来捕捉SIGFPE信号

#include <iostream>
#include <signal.h>
#include <unistd.h>

// 信号捕获函数
void sig_handler(int signo){
	// 休眠1s
	sleep(1);
	std::cout << "div zero!\n";
}

int main(){
	int a = 3;
	int b = 0;

	// 信号捕获
	signal(SIGFPE, sig_handler);

	// 除0
	int div = a / b;

	return 0;
}

编译运行程序,效果如下
在这里插入图片描述


非法地址访问

#include <iostream>
#include <signal.h>
#include <unistd.h>

int main(){
	// 非法地址访问
	int* p = NULL;
	*p = 10;

	return 0;
}

编译运行,效果如下
在这里插入图片描述
下面我们来捕获一下SIGSEGV信号

#include <iostream>
#include <signal.h>
#include <unistd.h>

// 信号处理程序
void sig_handler(int signo){
	// 休眠1s
	sleep(1);
	std::cout << "invalid address!\n";
}

int main(){
	// 信号捕获
	signal(SIGSEGV, sig_handler);

	// 非法地址访问
	int* p = NULL;
	*p = 10;

	return 0;
}

编译运行,效果如下
在这里插入图片描述
从上述演示可以看出,在C/C++中除0、内存越界等异常,在系统层面上,都是被当成信号处理的

信号的阻塞

  • 实际执行信号的处理动作称为信号递达(delivery)
  • 信号从产生到递达之间的状态称为信号未决(pending)
  • 进程可以选择阻塞(block)某个信号
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

信号在内核中的表示
在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号为阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有接触阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号位产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

信号的注册

  • 非实时信号在递达之前产生多次只计一次
  • 实时信号在递达之前产生多次可以依次放在一个队列里

信号的注销

  • 非实时信号:因为非实时信号结点只有一个,因此删除结点,位图直接置0
  • 实时信号:因为实时信号结点可能会有多个,若还有相同信号结点,则位图依然置1,否则置0

信号集操作接口介绍

头文件:signal.h
功能:阻塞set集合中的信号,将原来阻塞的信号保存到oldset中,
	 不关心旧的, 可以将oldset设置为NULLint sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
	how: 
		SIG_BLOCK:阻塞set中的信号 mask = mask | set。
		SIG_UNBLOCK:对set中的信号解除阻塞 mask = mask & (~set)。
		SIG_SETMASK:将set中的信号设置为阻塞信号 mask = set。    
返回值:成功0,失败-1
头文件:signal.h
功能:将所有信号添加到set集合中。
int sigfillset(sigset_t *set);
返回值:成功0,失败-1
头文件:signal.h
功能:将指定信号添加到set中。
int sigaddset(sigset_t *set, int signum);
返回值:成功0,失败-1
头文件:signal.h
功能:获取当前进程的未决信号集合。
int sigpending(sigset_t *set);
返回值:成功0,失败-1
头文件:signal.h
功能:判断signo是否在集合set中。
int sigismember(const sigset_t *set, int signo);
返回值:包含返回1,不包含返回0,出错返回-1
头文件:signal.h
功能:将signum从set集合中移除。
int sigdelset(sigset_t *set, int signum);
返回值:成功0,失败-1
功能:清空信号集合set。
int sigemptyset(sigset_t *set);
返回值:成功0,失败-1

sigset_t结构体
我们到signal.h中看一下sigset_t的定义

[sss@aliyun ~]$ vim /usr/include/signal.h

在这里插入图片描述
发现这里没有,我们再去查找一下
在这里插入图片描述
我们使用下面命令打开sigset.h头文件:

[sss@aliyun ~]$ vim /usr/include/bits/sigset.h

在这里插入图片描述
可以看到sigset_t是一个位图


代码演示

#include <stdio.h>
#include <signal.h>

// 信号处理函数
void myHandler(int signo){
	printf("recv signo: %d\n", signo);
}

int main(){
	struct sigaction act;
	act.sa_handler = myHandler;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGINT, &act, NULL);
	sigaction(SIGQUIT, &act, NULL);
	sigaction(SIGRTMIN+5, &act, NULL);

	sigset_t set, old_set;
	// 清空set
	sigemptyset(&set);
	// 将所有信号添加到set集合
	sigfillset(&set);

	// 阻塞set中的信号
	sigprocmask(SIG_BLOCK, &set, &old_set);
	// 按下回车解除阻塞
	printf("press ENTER to unblock!\n");
	// 获取一个回车
	getchar();

	sigset_t pending;
	// 获取当前进程的未决信号集
	sigpending(&pending);

	// 将未决信号打印出来
	for(int i = 1; i <= 64; ++i){
		if(sigismember(&pending, i)){
			printf("1 ");
		}
		else{
			printf("0 ");
		}
	}
	printf("\n");

	// 解除阻塞
	sigprocmask(SIG_UNBLOCK, &set, NULL);

	return 0;
}

首先,我们编译运行程序
在这里插入图片描述
此时,我们另开一个终端,发送几个信号
在这里插入图片描述
回到运行程序中断,按下回车,效果如下
在这里插入图片描述
从上述运行结果,也可以看出实时信号和非实时信号在信号注册是的区别
注意:

  • 有两个信号SIGKILL 9 和 SIGSTOP 19无法被阻塞,无法自定义,无法被忽略。
  • Linux中init进程属于特例,内核会忽略发送给该进程的SIGKILL信号,换句话说,内核在运行时无论如何都不会init进程都不会被终止,它的生命周期与系统的生命周期相同。

信号的捕获

内核是如何实现信号捕捉的
在这里插入图片描述

  • 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号
  • 由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT信号的处理函数sig_handler。当前正在执行main函数,这时候发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达内核决定返回用户态后不是回复main函数的上下文继续执行,而是执行sig_handler函数sig_handler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程sig_handler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了

接口介绍

头文件:signal.h
功能:使用handler函数替换signum信号的处理方式。
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
	signum: 信号编号。
	handler: 函数指针, 有两个宏: SIG_IGN(忽略处理方式), SIG_DFL(默认处理方式)。	
返回值:

代码演示

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// 信号处理函数
void sig_handler(int signo){
	printf("recv signo: %d %s\n", signo, "SIGINT");
}

int main(){
	// 信号捕获
	signal(SIGINT, sig_handler);

	// 死循环
	while(1){
		printf("This is a endless loop!\n");
		// 睡眠2s
		sleep(2);
	}

	return 0;
}

编译运行,效果如下:
在这里插入图片描述


头文件:signal.h
功能:使用act动作替换signum原有的处理动作, 并将原有处理动作拷贝到oldact中。
int sigaction(int signum, 
	const struct sigaction *act, struct sigaction *oldact);
参数:
	signum:信号编号。
	act:根据act修改该信号的处理动作。
	oldact:保存该信号原处理动作。
返回值:成功返回0,失败返回-1

下面,我们来看一下sigaction结构体
在这里插入图片描述

// 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);
};

sigaction各成员作用如下

  • sa_handler是一个函数指针,保存信号处理函数,可以给其传SIG_IGN和SIG_DFL。也可以穿自定义的信号处理函数。保存的信号处理函数是一个回调函数,不是被main函数调用,而是被系统调用
  • sa_mask添加需要额外屏蔽的信号。当某个信号的处理函数被调用时内核自动将当前信号加入进程的信号屏蔽字当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽额外的信号,则可以将信号添加到sa_mask中,同样的当信号处理函数返回时自动回复原来的信号屏蔽字
  • sa_flags字段指定对信号进行处理的各个选项,默认置0

代码演示

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

struct sigaction act, old_act;

// 信号处理函数
void sig_handler(int signo){
	printf("recv: %d %s\n", signo, "SIGINT");
	// 信号捕获
	sigaction(SIGINT, &old_act, NULL);
}

int main(){
	act.sa_handler = sig_handler;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGINT, &act, &old_act);

	while(1){
		printf("This is a endless loop!\n");
		// 睡眠2s
		sleep(2);
	}

	return 0;
}

编译运行,效果如下
在这里插入图片描述

SIG_CHLD

  • 我们知道,如果子进程先于父进程退出,并且父进程没有读取到子进程退出返回代码时,操作系统不会完全释放子进程的资源,子进程就成为僵尸进程,僵尸进程的危害时很大的。
  • 当然也有解决方法,父进程使用wait或waitpid进行等待可以避免僵尸进程的产生,但是wait和waitpid都是有代价的,如果是阻塞等待子进程,父进程就不能处理自己的工作了,如果是非阻塞等待子进程,还需要轮询查看一下,这是很不方便的。
  • 其实,子进程在退出时会给父进程发SIGCHLD信号该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait()清理子进程即可
  • 由于UNIX的历史原因,要想不产生僵尸进程还有另外一种方法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程也不会通知父进程系统默认的忽略动作和用户用sigaction函数自定的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。

代码演示
首先我们先来生成一个僵尸进程

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(){
	// 创建进程
	pid_t pid = fork();
	if(pid < 0){
		// 进程创建失败
		perror("fork error");
		return -1;
	}
	else if(pid == 0){
		// 子进程
		while(1){
			printf("child process is running...\n");
			// 睡眠5s
			sleep(5);
		}

		exit(0);
	}

	// 父进程
	while(1){
		printf("parent process is running...\n");
		// 睡眠5s
		sleep(5);
	}

	return 0;
}

编译运行程序,同时另开一中断,杀死子进程,效果如下
在这里插入图片描述
在这里插入图片描述
下面,我们对代码进行修改,使用自定义信号处理函数来对进程进行等待

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

// SIGCHLD信号处理函数
void sig_handler(int signo){
	while(!waitpid(-1, NULL, WNOHANG));
}

int main(){
	struct sigaction act;
	act.sa_handler = sig_handler;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGCHLD, &act, NULL);

	// 创建进程
	pid_t pid = fork();
	if(pid < 0){
		// 进程创建失败
		perror("fork error");
		return -1;
	}
	else if(pid == 0){
		// 子进程
		while(1){
			printf("child process is running...\n");
			// 睡眠5s
			sleep(5);
		}

		exit(0);
	}

	// 父进程
	while(1){
		printf("parent process is running...\n");
		// 睡眠5s
		sleep(5);
	}

	return 0;
}

编译运行程序,另开一终端杀死子进程,观察子进程是否称为僵尸进程
在这里插入图片描述
在这里插入图片描述
可以看到,子进程并没有成为僵尸进程
最后,我们再来试一下将SIG_CHLD的信号处理函数修改为SIG_IGN是否有效

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

int main(){
	struct sigaction act;
	// 信号处理函数为忽略
	act.sa_handler = SIG_IGN;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGCHLD, &act, NULL);

	// 创建进程
	pid_t pid = fork();
	if(pid < 0){
		// 进程创建失败
		perror("fork error");
		return -1;
	}
	else if(pid == 0){
		// 子进程
		while(1){
			printf("child process is running...\n");
			// 睡眠5s
			sleep(5);
		}

		exit(0);
	}

	// 父进程
	while(1){
		printf("parent process is running...\n");
		// 睡眠5s
		sleep(5);
	}

	return 0;
}

编译运行程序,另开一终端杀死子进程,观察子进程是否称为僵尸进程
在这里插入图片描述
在这里插入图片描述
可以看到,子进程并没有成为僵尸进程

竞态条件

因运行时序而造成数据竞争,导致数据二义性
可重入与不可重入
不可重入函数函数中所完成的操作并非原子操作,并且操作的数据是一个全局数据。并且这个操作不受保护,则称这个函数是一个不可重入函数不可在多个运行时序中重复调用(重复调用有可能造成数据二义性)。如:malloc,free是不可重入函数。因为它们操作了全局链表,并且这些操作不受保护。
可重入函数:可以在多个时序运行中重复调用,不会造成数据二义性

volatile关键字

先来看一段代码

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// 全局变量
int i = 1;

// 信号处理函数
void sig_handler(int signo){
	i = 0;
	printf("recv: %d %s\n", signo, "SIGINT");
}

int main(){
	struct sigaction act;

	act.sa_handler = sig_handler;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGINT, &act, NULL);

	// 死循环
	while(i){
	}

	return 0;
}

从代码逻辑看,按下ctrl + c后会将i的值置为0,从而退出while死循环,我们来编译运行测试一下:
在这里插入图片描述
从上面的运行结果,可以看出,结果和我们的预期相差甚远,这是为什么呢
我们对代码略作修改,在来看一下效果

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// 全局变量
volatile int i = 1;

// 信号处理函数
void sig_handler(int signo){
	i = 0;
	printf("recv: %d %s\n", signo, "SIGINT");
}

int main(){
	struct sigaction act;

	act.sa_handler = sig_handler;
	act.sa_flags = 0;

	// 信号捕获
	sigaction(SIGINT, &act, NULL);

	// 死循环
	while(i){
	}

	return 0;
}

在这里插入图片描述
这里,可以看到当我们按下ctrl + c确实结束了循环,这是为什么呢

  • 这是因为g++ -O2对代码进行了优化,将i的值放到了寄存器上修改i的值只是修改内存中的值,寄存器中i一直是1,想要解决这个问题,需要使用volatile关键字。
  • volatile关键字的作用是保持内存可见性告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值