嵌入式C51课程设计--迷宫鼠

引言  

     大家好 , 我将在这里记录我的课程报告完成情况和算法设计,如有不足 ,请多指教(其中如果有一些程序模块的逻辑不是很清楚的地方可以私信我,代码部分经过多次验证没有出现问题,如果有,则是小车的传感器的灵敏度调节的问题,这方面确实是个技术活 :),本文因为某些原因删除了大量调试功能函数,不过不影响小车的正常使用,但是同时也丧失了一些代码可读性。

制作思路

    小车移动

        如何控制电机

        电机的正反转

        实现小车的左转 右转 掉头

    位置修正(由硬件和软件引起的位置偏差)

        红外检测

            红外发射

                74138(三线八线译码器来省端口数)

                可调电阻(用来调节传感器的灵敏度)

            红外接收

                对应的端口为低电平

                但是由于发射和接收需要一定的时间,所以又有一定的写法

        前修正

            检测到前面有挡板的时候应该停下来

                什么时候识别到前面有挡板?

                    通过调节前方红外的灵敏度可以调到对应的位置

        左修正

            当左前检测到的时候就要向右修正

        右修正

            当右前检测到的时候就要向左修正

        正常走到中间的时候 --> 检测不到  向左或向右偏 --> 检测到 --> 修正

            但是不能调的太过灵敏了,否则会左右抽搐的感觉(因为有惯性,可能每次都刚好停在中线的位置)

        那么只要先将小车的传感器的极限值调好,将小车放到迷宫中,那么它就会保持走的尽量是直线

            此调节程序写在了AdjustRedLight中,但是beep 响的时候为0 ,有挡板为 0 , 无挡板为 1

            由于每个人的小车内部的电路也许是不一样的,所以在没有电路图的情况下建议自己试一下,反正不是0 就是 1(因为是数字电路)

        前修正

            当我们的一部信息调的不那么准确时,我们可能由于一步的步长偏大或者偏小,这种误差是无法避免的,

            步长偏大

                由于走的步数可能很长,造成误差的累计,使多走的距离达到了一格之多,所以我们很有可能在行走的过程中撞墙,所以我们使用前修正

                即在一格中检测到前面挡板的时候就停下来

            步长偏小

                这种我们是十分难调节的,所以我们只可以将步长设置的稍稍偏大,等到前修正来将它修正

 如何建立迷宫

        1.坐标系问题

            因为车头的方向不是每时每刻都是向上,而传感器的方向却是相对于车头有固定方向的,所以我们要对车头方向进行规定,以及迷宫的挡板信息(也就是小车对应被遮挡的传感器的方向)

            规定 :   上 : 0 右 : 1 下 : 2 左 :3、

            对应到maze的低四位(记录迷宫挡板信息的位分别是)左 下 右 上

            而对应不同的位置,例如走到迷宫的尽头小车应该调转一百八十度这种动作,向左或向右等总共有三个,而且这个动作与小车车头改变后的车头代码有一定的关系,所以我们将他定为动作代码

             将动作代码定义为 ( head  - dir + 4 ) % 4 , 其中head 为当前车头的方向(坐标系的方向  ),dir 为目标方向,至于后面的加4再对4趋于,是为了防止负数的出现,比如当前车头朝右,则按照规定,head  = 1 , 目标方向是下 , 则dir = 2 , 根据上面的式子得出动作代码move = 3  , 而我们实际车头从右到下实际上就是车头向右转90度,那么move = 3 对应的情况就是车头向右旋转90度 同理我们可以推出

       move = 0 时 , 目标方向和当前车头方向相同,不用旋转

       move = 1 时 , 车头向左旋转90度

       move = 2 时 , 车头转180度(其实也就是向左转或向右转90做两次)

       move  =3 时 , 车头向右旋转90度

那么我们在遇到需要转向的逻辑时全部可以采用这种方法,只要我们知道当前车头的代码和目标方向的代码,那么就可以转到正确的位置,那么问题就集中到如何确定目标方向上,减少了我们代码和思维的复杂度(拓展,这种通过加几模几的方法来防止负数的出现,从而将数全部控制在某一个范围内的问题被称为约瑟夫问题,以上也是约瑟夫的经典写法,大家可以多了解了解)

     

        2.我们要维护的信息

                第一个点不管是不是岔路口都要压入堆栈,因为小车要走回来

                在本文章中所涉及到的迷宫的四个角都不是岔路口,所以第一个点不会被认为是岔路口而被重复加入栈中,导致栈永远不会发生的问题,笔者其实在这里偷了一个懒,但是如果起点是岔路口的情况实际存在,则要修改文末的代码,我们要对第一点进行特别判断,就要设计一个状态变量来控制第一个情况,有兴趣的朋友可以思考一下如何解决第一个点的问题。

                本程序中的stack明显值没有考虑到所有情况,因为我们确实存在所有点都是岔路口的情况,也就是迷宫中一个挡板也没有,但是这样的迷宫是没有实际意义的,我们在写程序的时候可以考虑 ,但是也要考虑整个单片机的内存资源的设计,考虑到单片机的内存很小,开了完全情况的stack会消耗完所有内存,所以根据实际情况预测最多有10个路口,这个地方可以修改

 遍历程序流程

            判断栈是否为空

                不空:

                     判断当前格子是否第一次来如果是则更新挡板信息和车头来的方向,否则不更新

                    判断当前可走路的条数

                            小车进行扫描四周挡板信息,并计算可走路的条数

                            如果有两条以上可以走的路

                                 说明当前是岔路口,将当前坐标压入堆栈 

                                  并按照右手原则选择一条可以走的路,调转车头,前进,更新坐标

                           如果只有一条可以走的路

                                 调转车头,前进,更新坐标

                          没有发现这么一条路

                                小车回到上一个格子

                                   判断是不是岔路口(栈顶)

                                   不是岔路口直接回退

                                            算出动作代码

                                            根据动作代码转向

                                            走一步

                                   是岔路口则弹栈

                空:结束

       

冲刺程序流程

         登高表的建立(预处理)

            这所谓的登高表,就是按照广度优先搜索的策略的每一层的深度信息,比如起点就是第一层,起点可以直接到达的点就是第二层,而这些第二层的点可以到达的点就是第三层。。。

        依次将迷宫中的所有点的登高表信息完善。而登高表的建立过程说明了每一个点只可能会被它直接可以到达的点到达,中间并不会有多余的绕路,比如上下两个点,肯定是从上面那个点到下面那个点的距离最短,

        不可能是上面那个点先往右再往下绕最短。

            所以登高表记录的实际上是起点到各个点的最短距离

            而我们的“可以直接到达”是怎么定义的呢?

            1.下一个格子是当前格子的相邻格子(前提是这个格子必须存在,反映到程序中就是数组下标不越界)

            2.这两个格子直接没有墙,

            3.相邻的格子之前没有被找过(反应到程序中就是table中为初始化值0xff)

            而整个BFS(广度优先搜索)的数据结构我们使用队列,当当前格子的四个方向的相邻格子都被搜索过后,当前格子出队列,被搜索到的格子加入队列,可以想象为搜索树的每一层,当整个队列为空时,搜索结束

            而我们的队列可以用一个数组实现,我们只用维护队首指针hh , 和队尾指针tt

            操作:

                1.入队列

                    queue[++ tt] = num;

                2.出队列

                    x = queue[++ hh];

                3.检查队列是否为空

                    bit flag = tt < hh 为 1 时为空,否则不为空

    冲刺

        基本思路

            我们需要从终点的登高表开始寻找起点

           如何寻找起点?就是按照终点的登高表去向它的四周去寻找,比他当前的登高表深度小一个并且相互之间没有墙的相邻格子,将这样搜索出来的路径压入栈内(下面详细说明了为什么会再次使用栈)

        为什么不能从起点开始搜索路径?

                因为我们是根据从起点到终点的登高表连续的步数去寻找的,从 1 步到的点到 2 步可到的点之间会有四个点的可能,但是从 2 步可到的点去寻找 1 步可到的点只有一种可能,而我们这里终点已知

                那么最后一个点的步长是可以知道的,我们只要逐步去寻找步长逐渐减小的点就可以得到最短的路径,这样是最简单的

            但是这样搜索出来的路径刚好是终点到起点的路径,那么我们小车的实际坐标已经被重置到起点,所以我们的路径必须反向

            但是用一个数组来存路径吗?

            可以,但是并不推荐,因为我们又要开 log64(广度优先搜索的层数) 的空间来存储路径

            但是我们可以这么想:

                是不是现在路径中的最后一个是我们要走的第一个呢?

                完全正确!

                换言之,最后进的格子最先使用

                而我们刚好又有这么一个结构,它满足先进后出,后进先出,那就是我们之前使用过的栈,而之前栈中存储的信息(岔路口的坐标)已经清空,所以我们直接存入之前的栈中

                而每次我们根据栈顶的格子的坐标调整车头方向

                移动

                弹栈

                如此循环,直到栈为空,即到达目标位置

 代码部分

#include<reg52.h>
#include<intrins.h>

unsigned char code walkStraight[]={0x11,0x93,0x82,0xc6,0x44,0x6c,0x28,0x39};
unsigned char code walkLeft[]={0x11,0x99,0x88,0xcc,0x44,0x66,0x22,0x33};
unsigned char code walkRight[]={0x11,0x33,0x22,0x66,0x44,0xcc,0x88,0x99};
unsigned char code segcode[10] = {0xc0 , 0xf9 , 0xa4 , 0xb0 , 0x99 , 0x92 , 0x82 , 0xf8 , 0x80 , 0x90};

unsigned char cntCirce , i , j , k;
unsigned char maze[8][8];
unsigned char table[8][8];

unsigned char xx = 0 , yy = 0;
unsigned char x = 0 , y = 0;
unsigned char head = 0;
char destX = 7;
char destY = 7;
//用来计算动作代码
char dir;

unsigned char stack[10];
unsigned char queue[70];

char hh = 0 , tt = -1;
char stacktop = -1;

//函数声名
void displayCoordinate();
void Delay1ms();
void oneStep();
void turnLeft();
void turnRight();
void turnBack();
void initMaze();
char checkisPassed(char dir);
char countPath();
void backStep();
void initTable();
void displayDigit(char num);
void debug();

sfr P4 = 0xe8;

sbit A0 = P4^0;
sbit A1 = P2^0;
sbit A2 = P2^7;

sbit irR1 = P2^1;
sbit irR2 = P2^2;
sbit irR3 = P2^3;
sbit irR4 = P2^4;
sbit irR5 = P2^5;
sbit tube1 = P4^2;
sbit tube2 = P4^3;
sbit beep = P3^7;



bit irC , irLU , irL , irR , irRU;
bit isR , isC , isL;

char irNum = 1;

void Delay1ms(){		//@11.0592MHz
	unsigned char i, j;

	_nop_();
	_nop_();
	_nop_();
	i = 11;
	j = 190;
	do
	{
		while (--j);
	} while (--i);
}

void Delay500us()		//@11.0592MHz
{
	unsigned char i, j;

	_nop_();
	_nop_();
	i = 6;
	j = 93;
	do
	{
		while (--j);
	} while (--i);
}

void oneStep(){
	for(cntCirce = 0 ; cntCirce <= 100 ; cntCirce ++){
		if(irC == 1){
			if(irLU == 0){
				for(i = 0 ; i < 8 ; i ++ ){
					P1 = walkRight[i];
					Delay500us();
				}
			}
			
			if(irRU == 0){
				for(i = 0 ; i < 8 ; i ++ ){
					P1 = walkLeft[i];
					Delay500us();
				}
			}
				
			for(i = 0 ; i < 8 ; i ++){
				P1 = walkStraight[i];
				Delay500us();
			}
		}
		
	}
	
	switch(head){
		case 0:
			yy ++;
			break;
		case 1:
			xx ++;
			break;
		case 2:
			yy --;
			break;
		case 3:
			xx --;
			break;
	}
	
	displayCoordinate();
	
	for(i = 0 ; i < 10 ; i ++) Delay1ms();
	

}

void turnLeft(){ 
	
	for(cntCirce = 0 ; cntCirce <= 48 ; cntCirce ++){
		for(i = 0 ; i < 8 ; i ++){
			P1 = walkLeft[i];
			Delay500us();
		}
	}
	
	head = (head + 3)% 4;
}

void turnRight(){
	
	for(cntCirce = 0 ; cntCirce <= 48 ; cntCirce ++){
		for(i = 0 ; i < 8 ; i ++){
			P1 = walkRight[i];
			Delay500us();
		}
	}
	
	head = (head + 1)% 4;
}
void turnBack(){
	turnRight();
	turnRight();
}	

void initMaze(){
	for(i = 0 ; i < 8 ; i ++){
		for(j = 0 ; j < 8 ; j ++){
			maze[i][j] = 0xff;
		}
	}
}



char countPath(){
	char res = 0;
	
	isR = 0;
	isC = 0;
	isL = 0;
	
	if(head == 0){
		if(yy + 1 < 8 && ((maze[xx][yy+1] & 0xf0) == 0xf0) && (irC == 1)) res ++ , isC = 1;
		if(xx - 1 >= 0 && ((maze[xx - 1][yy] & 0xf0) ==  0xf0) && (irL == 1)) res ++ , isL = 1;
		if(xx + 1 < 8 && ((maze[xx+1][yy] & 0xf0) == 0xf0) && (irR == 1)) res ++ , isR = 1;
	}
	
	if(head == 1){
		if(xx + 1 < 8 && ((maze[xx+1][yy] & 0xf0) == 0xf0) && (irC == 1)) res ++ , isC = 1;
		if(yy + 1 < 8 && ((maze[xx][yy+1] & 0xf0) == 0xf0) && (irL == 1)) res ++ , isL = 1;
		if(yy - 1 >= 0 && ((maze[xx][yy-1] & 0xf0) == 0xf0) && (irR == 1)) res ++ , isR = 1;
	}
	
	if(head == 2){
		if(yy - 1 >= 0 && ((maze[xx][yy-1] & 0xf0) == 0xf0) && (irC == 1)) res ++ , isC = 1;
		if(xx + 1 < 8 && ((maze[xx+1][yy] & 0xf0) == 0xf0) && (irL == 1)) res ++ , isL = 1;
		if(xx - 1 >= 0 && ((maze[xx-1][yy] & 0xf0) == 0xf0) && (irR == 1)) res ++ , isR = 1;
	}
	
	if(head == 3){
		if(xx - 1 >= 0 && ((maze[xx-1][yy] & 0xf0) == 0xf0) && (irC == 1)) res ++ , isC = 1;
		if(yy - 1 >= 0 && ((maze[xx][yy-1] & 0xf0) == 0xf0) && (irL == 1)) res ++ , isL = 1;
		if(yy + 1 < 8 && ((maze[xx][yy+1] & 0xf0) == 0xf0) && (irR == 1)) res ++ , isR = 1;
	}
	//displayDigit(res);
	return res ;
}

void choosePath(){
	if(isR == 1) turnRight();
	else if(isC == 1) ;
	else if(isL == 1) turnLeft();
}



//更改坐标显示 , 左边显示 x 坐标 , 右边显示 y 坐标
void displayCoordinate(){
	P0 = segcode[xx];
	tube1 = 0;
	tube2 = 1;
	tube2 = 0;
	P0 = segcode[yy];
	tube1 = 1;
	tube2 = 0;
	tube1 = 0;
}

//根据来的方向调转车头并回退一格
void backStep(){
	char i , move;
	
	for(i = 0 ; i < 4 ; i ++){
		if(maze[xx][yy] >> (4 + i) & 1) break;
	}
	
	move = (head - i + 4) % 4;
	switch(move){
		case 0:
			break;
		case 1:
			turnLeft();
			break;
		case 2:
			turnBack();
			break;
		case 3:
			turnRight();
			break;
	}
	
	oneStep();
}

void initTable(){
	for(i = 0 ; i < 8 ; i ++){
		for(j = 0 ; j < 8 ; j ++){
			table[i][j] = 0xff;
		}
	}
}

//将当前变量的值显示在第二位数码管上
void displayDigit(char num){
	P0 = segcode[num];
	tube1 = 1;
	tube2 = 0;
	tube1 = 0;
}


void main(){
	TL2 = 60536;
	RCAP2L = 60536;
	TH2 = 60536 >> 8;
	RCAP2H = 60535 >> 8;
	TR2 = 1;
	EA = 1;
	ET2 = 1;
	
	
	
	while(1){
		initMaze();
		displayCoordinate();
		Delay1ms();
		for(i = 0 ; i < 10 ; i ++) Delay1ms();
		
		//这条语句决定了第一个点不能是岔路口,否则第一个点会重复入栈
		stack[++ stacktop] = xx << 4 | yy;
		
		while(stacktop >= 0){
			if(maze[xx][yy] == 0xff){
				maze[xx][yy] = (char)irC << head | (char)irR << (head + 1) % 4 | (char)irL << (head + 3) % 4 | (char) 1 << (head + 2) % 4;
				//更新来的方向 , 高四位分别是 左 下 右 上
				maze[xx][yy] = maze[xx][yy] & 0x0f | 1 << ((head + 2) % 4 + 4);
 			}
			
			if(countPath() >= 2){	
				stack[++ stacktop] = xx << 4 | yy;
				choosePath();
				oneStep();
				
			}else if(countPath() == 1){
				choosePath();
				oneStep();
			}else{
				while(xx != stack[stacktop] >> 4 || yy != stack[stacktop] % 16){
					backStep();
				}
				stacktop --;
				
				
				
			}
			
		}

		initTable();
		queue[++ tt] = xx << 4 & yy;
		table[0][0] = 1;
		
		while(hh <= tt){			
			x = queue[hh] >> 4;
			y = queue[hh] % 16;
			++ hh;
			//相邻格子可以直接被当前格子搜索到
			if(y + 1 < 8 && table[x][y + 1] == 0xff && maze[x][y] % 2){
					queue[++ tt] = x << 4 | (y + 1);
					table[x][y + 1] = table[x][y] + 1;
			}
			
			if(x + 1 < 8 && table[x + 1][y] == 0xff && maze[x][y] % 4 >> 1){
					queue[++ tt] = (x + 1) << 4 | y;
					table[x + 1][y] = table[x][y] + 1;
			}
			
			if(y - 1 >= 0 && table[x][y - 1] == 0xff && maze[x][y] % 8 >> 2){
					queue[++ tt] = x << 4 | (y - 1);
					table[x][y - 1] = table[x][y] + 1;
			}
			
			if(x - 1 >= 0 && table[x - 1][y] == 0xff && maze[x][y] % 16 >> 3){
					queue[++ tt] = (x - 1) << 4 | y;
					table[x - 1][y] = table[x][y] + 1;
			}
			
		}		
		//清空栈
		stacktop = -1;
			
		//开始根据终点找起点然后加入栈中
		
		stack[++ stacktop] = destX << 4 | destY;
		
		while(destX != 0 || destY != 0){
			
			if(destY + 1 < 8 && table[destX][destY + 1] == table[destX][destY] - 1 && maze[destX][destY] % 2){
				stack[++ stacktop] = destX << 4 | (destY + 1);
				destY = destY + 1;
				continue;
			}
			
			if(destX + 1 < 8 && table[destX + 1][destY] == table[destX][destY] - 1 && maze[destX][destY] % 4 >> 1){
				stack[++ stacktop] = (destX + 1) << 4 | destY;
				destX = destX + 1;
				continue;
			}
			
			if(destY - 1 >= 0 && table[destX][destY - 1] == table[destX][destY] - 1 && maze[destX][destY] % 8 >> 2){
				stack[++ stacktop] = destX << 4 | (destY - 1);
				destY = destY - 1;
				continue;
			}
			
			if(destX - 1 >= 0 && table[destX - 1][destY] == table[destX][destY] - 1 && maze[destX][destY] % 16 >> 3){
				stack[++ stacktop] = (destX - 1) << 4 | destY;
				destX = destX - 1;
				continue;
			}
			
		}

		//起点肯定入栈了,但是起点不用到达,先将起点弹栈
		stacktop --;
		
		//按照路径冲刺
		while(stacktop >= 0){
			destX = stack[stacktop] >> 4;
			destY = stack[stacktop] % 16;
			stacktop --;
			//判断要走的格子在当前格子的哪个方向
			
			//上
			if(yy + 1 == destY && xx == destX){
				dir = head;
				switch(dir){
					case 0:
						break;
					case 1:
						turnLeft();
						break;
					case 2:
						turnBack();
						break;
					case 3:
						turnRight();
						break;
				}
			}
			//右
			if(yy == destY && xx + 1 == destX){
				dir = (head + 3) % 4;
				switch(dir){
					case 0:
						break;
					case 1:
						turnLeft();
						break;
					case 2:
						turnBack();
						break;
					case 3:
						turnRight();
						break;
				}
			}
			//下
			if(yy - 1 == destY && xx == destX){
				dir = (head + 2) % 4;
				switch(dir){
					case 0:
						break;
					case 1:
						turnLeft();
						break;
					case 2:
						turnBack();
						break;
					case 3:
						turnRight();
						break;
				}
			}
			//左
			if(yy == destY && xx - 1 == destX){
				dir = (head + 1) % 4;
				switch(dir){
					case 0:
						break;
					case 1:
						turnLeft();
						break;
					case 2:
						turnBack();
						break;
					case 3:
						turnRight();
						break;
				}
			}
			
			oneStep();
		}
		
		
		while(1);
		
	}
}

void Timer2() interrupt 5{
	TF2 = 0;
	switch(irNum){
		case 1:
			irRU = irR5;
			A0 = 0 ; A1 = 0 ; A2 = 0;
			break;
		case 2:
			irC = irR1;
			A0 = 1 ; A1 = 0 ; A2 = 0;
			break;
		case 3:
			irLU = irR2;
			A0 = 0 ; A1 = 1 ; A2 = 0;
			break;
		case 4:
			irL = irR3;
			A0 = 1 ; A1 = 1 ; A2 = 0;
			break;
		case 5:
			irR = irR4;
			A0 = 0 ; A1 = 0 ; A2 = 1;
			break;
	}
	if(++ irNum >= 6) irNum = 1;	
}

一点思考

            1.删去前修正

                为什么删去前修正呢?

                    因为我们正常前修正的程序写法是当检测到前面有墙时立即停止,这种想法是没有错,但是本人经多次实验后发现,会造成坐标混乱的情况

                为何如此?

                    因为我们的迷宫的表面发生的是漫反射,迷宫的材质比较粗糙,导致小车距离的远近会造成光接收的正确性的一个差异,简单点来说,就是远点可能检测到了墙,但是近点由于漫反射导致又检测不到墙,所以这种检测是不稳定的,也就是说我们不能通过一时的前方传感器的结果来判断前方是否有挡板,而是我们要通过加一个二次检验来增强我们识别的抗干扰能力,正常的写风险很大,完全是一个不确定的状态

                但是为什么左右识别没有问题呢?因为左右识别完全是停在那里识别的,所以移动的距离不会对识别产生影响

                而左右修正的移动检测其实也是存在这种问题,但是左右修正是时刻进行移动,所以有时候检测不出来反应出来的现象就是传感器不灵敏

                所以我们又可以得出传感器不灵敏的原因其实会有很多:

                    比如传感器可调电位器的改变,或者是墙面凹凸不平,都会产生这样的效果

                为了谨慎,我还是先不写前修正了:)

          2.寻优算法

                迷宫鼠在寻找最优路径的时候其实有两种写法,第一种是dfs(深度优先搜索),但是这种需要回溯的时候检查登高表是否具有更优的选择,另一种则是本文提到的bfs(宽度优先搜索),这种就是格子第一次被搜索到的时候就是最优路径,因为它永远是能被搜索到的时候就被搜索到,而不会走弯路,所以每一步都是最优的路径。但是大家思考一下,本文中的遍历使用的是dfs,我们开了一个栈空间供遍历使用,所以如果我们采用dfs来寻找最优路径,那么我们可以节省原来队列的空间,但是也会因此牺牲掉一定的时间复杂度,dfs的时间复杂度是 n ^ 4的,但是广度优先搜索是logn的,在数据比较大的情况下,广度的运行速率是明显快的。但是也不能说一定某种策略一定优于另一种策略,而是要看我们实际运行的需求来设计算法和数据结构。大家可以想一下为什么这里不用dijkstra(单源最短路算法,提示:从边权的角度思考),还是蛮有意思的。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值