《Linux操作系统 - 高级编程》第一部分 标准IO及文件IO(第5章 串口编程)

5.1串口概述

随着嵌入式系统应用的发展,linux操作系统的应用也越来越广泛。linux作为一款免费的并且开放源代码的操作系统,与windows操作系统相比有许多独特的优势。linux可以进行定制内核;linux的gui图形界面能够任意选择;linux可以更方便、更安全地进行远程操作。随着linux操作系统的不断发展和完善,基于linux操作系统的软件开发也得到了长足的发展和应用。如果在工控领域引入linux,不可避免的会遇到在嵌入式linux下如何实现串行通信的问题。

在linux操作系统下,对设备和文件的操作都等同于文件的操作,这样大大简化了系统对不同设备的操作,提高了效率。在程序中,设备和文件都是通过文件描述符来操作的。文件描述符是一个非负数的索引值,指向内核中每个进程打开的文件记录表。当打开一个现存的文件或者创建一个新文件时,内核就向进程返回一个文件描述符。当需要对设备进行读写操作时,也需要把文件描述符作为参数传递给相应的函数。

linux的设备文件都存放在“/dev”目录下,串口资源对应的设备名是“/dev/ttys+编号”,因此串口对应的设备文件的路径是“/dev/ttys*”。而且USB转串口的设备名通常为“/dev/ttyUSB0”,在linux下对设备的操作方法与对文件的操作方法一样。

5.2串口设置详解

串口的设置主要是设置struct termios结构体的各成员值,如下所示:

#include<termios.h>
    struct termios
    { 
        unsigned short c_iflag; /* 输入模式标志 */ 
        unsigned short c_oflag; /* 输出模式标志 */ 
        unsigned short c_cflag; /* 控制模式标志 */
        unsigned short c_lflag; /* 本地模式标志 */
        unsigned char c_line; /* 线路规程 */
        unsigned char c_cc[NCC]; /* 控制特性 */
        speed_t c_ispeed; /* 输入速度 */
        speed_t c_ospeed; /* 输出速度 */
    };

termios是在Posix规范中定义的标准接口,表示终端设备(包括虚拟终端、串口等)。因为串口是一种终端设备,所以通过终端编程接口对其进行配置和控制。因此在具体讨论串口相关编程之前,需要先了解一下终端的相关知识。

终端是指用户与计算机进行对话的接口,如键盘、显示器和串口设备等物理设备,X Window上的虚拟终端。类UNIX操作系统都有文本式虚拟终端,使用【Ctrl+Alt】+F1~F6键可以进入文本式虚拟终端,在X Window上可以打开几十个以上的图形式虚拟终端。类UNIX操作系统的虚拟终端有xterm、rxvt、zterm、eterm等,而Windows上有crt、putty等虚拟终端。

终端有三种工作模式,分别为规范模式(canonical mode)、非规范模式(non-canonical mode)和原始模式(raw mode)。

通过在termios结构的c_lflag中设置ICANNON标志来定义终端是以规范模式(设置ICANNON标志)还是以非规范模式(清除ICANNON标志)工作,默认情况为规范模式。
在规范模式下,所有的输入是基于行进行处理的。在用户输入一个行结束符(回车符、EOF等)之前,系统调用read()函数是读不到用户输入的任何字符的。除了EOF之外的行结束符(回车符等)与普通字符一样会被read()函数读取到缓冲区中。在规范模式中,行编辑是可行的,而且一次调用read()函数最多只能读取一行数据。如果在read()函数中被请求读取的数据字节数小于当前行可读取的字节数,则read()函数只会读取被请求的字节数,剩下的字节下次再被读取。

在非规范模式下,所有的输入是即时有效的,不需要用户另外输入行结束符,而且不可进行行编辑。在非规范模式下,对参数MIN(c_cc[VMIN])和TIME(c_cc[VTIME])的设置决定read()函数的调用方式。设置可以有4种不同的情况。
● MIN = 0和TIME = 0:read()函数立即返回。若有可读数据,则读取数据并返回被读取的字节数,否则读取失败并返回0。
● MIN > 0和TIME = 0:read()函数会被阻塞,直到MIN个字节数据可被读取。
● MIN = 0和TIME > 0:只要有数据可读或者经过TIME个十分之一秒的时间,read()函数则立即返回,返回值为被读取的字节数。如果超时并且未读到数据,则read()函数返回0。
● MIN > 0和TIME > 0:当有MIN个字节可读或者两个输入字符之间的时间间隔超过TIME个十分之一秒时,read()函数才返回。因为在输入第一个字符后系统才会启动定时器,所以,在这种情况下,read()函数至少读取一个字节后才返回。

按照严格意义来讲,原始模式是一种特殊的非规范模式。在原始模式下,所有的输入数据以字节为单位被处理。在这个模式下,终端是不可回显的,而且所有特定的终端输入/输出控制处理不可用。通过调用cfmakeraw()函数可以将终端设置为原始模式,而且该函数通过以下代码可以得到实现:

    termios_p->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP
    | INLCR | IGNCR | ICRNL | IXON);
    termios_p->c_oflag &= ~OPOST;
    termios_p->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
    termios_p->c_cflag &= ~(CSIZE | PARENB);
    termios_p->c_cflag |= CS8;

现在讲解设置串口的基本方法。如上所述,串口设置最基本的操作包括波特率设置,校验位和停止位设置。在这个结构中最为重要的是c_cflag,通过对它的赋值,用户可以设置波特率、字符大小、数据位、停止位、奇偶校验位和硬软流控等。另外,c_iflag和c_cc也是比较常用的标志。在此主要对这3个成员进行详细说明。c_cflag支持的常量名称如表1所示。其中设置波特率宏名为相应的波特率数值前加上B,由于数值较多,本表没有全部列出。

表1 c_cflag支持的常量名称
CBAUD波特率的位掩码
B00波特率(放弃DTR)
B18001800波特率
B24002400波特率
B48004800波特率
B96009600波特率
B1920019200波特率
B3840038400波特率
B5760057600波特率
B115200115200波特率
EXTA外部时钟率
EXTB外部时钟率
CSIZE数据位的位掩码
CS55个数据位
CS66个数据位
CS77个数据位
CS88个数据位
CSTOPB2个停止位(不设则是1个停止位)
CREAD接收使能
PARENB校验位使能
PARODD使用奇校验而不使用偶校验
HUPCL最后关闭时挂线(放弃DTR)
CLOCAL本地连接(不改变端口所有者)
CRTSCTS硬件流控

在这里,对于c_cflag成员不能直接对其初始化,而要将其通过“与”、“或”操作使用其中的某些选项。
输入模式标志c_iflag用于控制端口接收端的字符输入处理。c_iflag支持的常量名称,如表2所示。

表2 c_iflag支持的常量名称

这里写图片描述

c_oflag用于控制终端端口发送出去的字符处理,c_oflag支持的常量名称如表3所示。因为现在终端的速度比以前快得多,所以大部分延时掩码几乎没什么用途。

表3 c_oflag支持的常量名称

这里写图片描述

c_lflag用于控制终端的本地数据处理和工作模式,c_lflag所支持的常量名称如表4所示。

表4 c_lflag支持的常量名称

这里写图片描述

c_cc定义特殊控制特性,c_cc所支持的常量名称如表5所示。

表5 c_cc支持的常量名称
VINTR中断控制字符,对应键为Ctrl+C
VQUIT退出操作符,对应键为Ctrl+Z
VERASE删除操作符,对应键为Backspace(BS)
VKILL删除行符,对应键为Ctrl+U
VEOF文件结尾符,对应键为Ctrl+D
VEOL附加行结尾符,对应键为Carriage return(CR)
VEOL2第二行结尾符,对应键为Line feed(LF)
VMIN指定最少读取的字符数
VTIME指定读取的每个字符之间的超时时间

下面就详细讲解设置串口属性的基本流程。

1.保存原先串口配置
首先,为了安全起见和以后调试程序方便,可以先保存原先串口的配置,在这里可以使用函数tcgetattr(fd, &old_cfg)。该函数得到由fd指向的终端的配置参数,并将它们保存于termios结构变量old_cfg中。该函数还可以测试配置是否正确、该串口是否可用等。若调用成功,函数返回值为0,若调用失败,函数返回值为-1,其使用如下所示:

if (tcgetattr(fd, &old_cfg) != 0) 
{
	perror("tcgetattr");
	return -1;
}

2.激活选项
CLOCAL和CREAD分别用于本地连接和接收使能,因此,首先要通过位掩码的方式激活这两个选项。

newtio.c_cflag |= CLOCAL | CREAD;

调用cfmakeraw()函数可以将终端设置为原始模式,在后面的实例中,采用原始模式进行串口数据通信。

cfmakeraw(&new_cfg);

3.设置波特率
设置波特率有专门的函数,用户不能直接通过位掩码来操作。设置波特率的主要函数有cfsetispeed()和cfsetospeed()。这两个函数的使用很简单,如下所示:

cfsetispeed(&\&new_cfg, B115200);
cfsetospeed(&new_cfg, B115200);

cfsetispeed()函数在termios结构中设置数据输入波特率,而cfsetospeed()函数在termios结构中设置数据输入波特率。一般来说,用户需将终端的输入和输出波特率设置成一样的。这几个函数在成功时返回0,失败时返回-1。
4.设置字符大小
与设置波特率不同,设置字符大小并没有现成可用的函数,需要用位掩码。一般首先去除数据位中的位掩码,再重新按要求设置,如下所示:

new_cfg.c_cflag &= ~CSIZE; /* 用数据位掩码清空数据位设置 */
new_cfg.c_cflag |= CS8;

5.设置奇偶校验位
设置奇偶校验位需要用到termios中的两个成员:c_cflag和c_iflag。首先要激活c_cflag中的校验位使能标志PARENB和确认是否要进行校验,这样会对输出数据产生校验位,而对输入数据进行校验检查。同时还要激活c_iflag中的对于输入数据的奇偶校验使能(INPCK)。如使能奇校验时,代码如下所示:

new_cfg.c_cflag |= (PARODD | PARENB);
new_cfg.c_iflag |= INPCK;

而使能偶校验时,代码如下所示:

new_cfg.c_cflag |= PARENB;
new_cfg.c_cflag &= ~PARODD; /* 清除偶奇校验标志,则配置为偶校验 */
new_cfg.c_iflag |= INPCK;

6.设置停止位
设置停止位是通过激活c_cflag中的CSTOPB而实现的。若停止位为一个比特,则清除CSTOPB;若停止位为两个,则激活CSTOPB。以下分别是停止位为一个和两个比特时的代码:

new_cfg.c_cflag &= ~CSTOPB; /* 将停止位设置为一个比特 */
new_cfg.c_cflag |= CSTOPB; /* 将停止位设置为两个比特 */

7.设置最少字符和等待时间
在对接收字符和等待时间没有特别要求的情况下,可以将其设置为0,则在任何情况下read()函数立即返回,此时串口操作会设置为非阻塞方式,如下所示:

new_cfg.c_cc[VTIME] = 0;
new_cfg.c_cc[VMIN] = 0;

8.清除串口缓冲
由于串口在重新设置后,需要对当前的串口设备进行适当的处理,这时就可调用在<termios.h>中声明的tcdrain()、tcflow()、tcflush()等函数来处理目前串口缓冲中的数据,它们的格式如下所示:
int tcdrain(int fd); /* 使程序阻塞,直到输出缓冲区的数据全部发送完毕 /
int tcflow(int fd, int action); /
用于暂停或重新开始输出 /
int tcflush(int fd, int queue_selector); /
用于清空输入/输出缓冲区 */
在本实例中使用tcflush()函数,对于在缓冲区中尚未传输的数据,或者收到的但是尚未读取的数据,其处理方法取决于queue_selector的值,它可能的取值有以下几种。
● TCIFLUSH:对接收到而未被读取的数据进行清空处理。
● TCOFLUSH:对尚未传送成功的输出数据进行清空处理。
● TCIOFLUSH:包括前两种功能,即对尚未处理的输入/输出数据进行清空处理。
如在本例中所采用的是第一种方法,当然可以使用TCIOFLUSH参数:
tcflush(fd, TCIFLUSH);
9.激活配置
在完成全部串口配置后,要激活刚才的配置并使配置生效。这里用到的函数是tcsetattr(),它的函数原型是:
tcsetattr(int fd, int optional_actions, const struct termios *termios_p);
其中,参数termios_p是termios类型的新配置变量。
参数optional_actions可能的取值有以下3种。
● TCSANOW:配置的修改立即生效。
● TCSADRAIN:配置的修改在所有写入fd的输出都传输完毕之后生效。
● TCSAFLUSH:所有已接收但未读入的输入都将在修改生效之前被丢弃。
该函数若调用成功则返回0,若失败则返回-1,代码如下所示:

 if ((tcsetattr(fd, TCSANOW, &new_cfg)) != 0)
{
	perror("tcsetattr");
	return -1;
}

下面给出了串口配置的完整函数。为了函数的通用性,通常将常用的选项都在函数中列出,这样可以大大方便以后用户的调试使用。该设置函数如下所示:

int set_com_config(int fd,int baud_rate, 
int data_bits, char parity, int stop_bits)
{
	struct termios new_cfg,old_cfg;
	int speed;
    /* 保存并测试现有串口参数设置,在这里如果串口号等出错,会有相关的出错信息 */
    if (tcgetattr(fd, &old_cfg) != 0) 
    {
        perror("tcgetattr");
        return -1;
    }
	/*设置字符大小*/
    new_cfg = old_cfg;
    cfmakeraw(&new_cfg); /* 配置为原始模式 */
    new_cfg.c_cflag &= ~CSIZE;
    /* 设置波特率 */
    switch (baud_rate)
    {
        case 2400:
        {
            speed = B2400;
        }
        break;
        case 4800:
        {
            speed = B4800;
        }
        break;
        case 9600:
        {
           speed = B9600;
        }
        break;
        case 19200:
        {
           speed = B19200;
        }
        break;
        case 38400:
        {
           speed = B38400;
        }
        break;
        default:
        case 115200:
        {
           speed = B115200;
        }
        break;
	}
    cfsetispeed(&new_cfg, speed);
    cfsetospeed(&new_cfg, speed);
    switch (data_bits) /* 设置数据位 */
    {
       case 7:
       {
           new_cfg.c_cflag |= CS7;
       }
       break;
       default:
       case 8:
       {
           new_cfg.c_cflag |= CS8;
       }
       break;
	}
    switch (parity) /* 设置奇偶校验位 */
    {
		default:
        case 'n':
        case 'N':
        {
            new_cfg.c_cflag &= ~PARENB; 
            new_cfg.c_iflag &= ~INPCK; 
        }
		break;
		case 'o':
		case 'O':
		{
			new_cfg.c_cflag |= (PARODD | PARENB); 
            new_cfg.c_iflag |= INPCK; 
        }
        break;
        case 'e':
        case 'E':
        {
           new_cfg.c_cflag |= PARENB; 
           new_cfg.c_cflag &= ~PARODD; 
           new_cfg.c_iflag |= INPCK; 
        }
        break;

        case 's': /* as no parity */
        case 'S':
        {
            new_cfg.c_cflag &= ~PARENB;
            new_cfg.c_cflag &= ~CSTOPB;
        }
        break;
	}
    switch (stop_bits) /* 设置停止位 */
    {
        default:
        case 1:
        {
            new_cfg.c_cflag &= ~CSTOPB;
        }
        break;

        case 2:
        {
            new_cfg.c_cflag |= CSTOPB;
        }
     }
     /* 设置等待时间和最小接收字符 */
     new_cfg.c_cc[VTIME] = 0;
     new_cfg.c_cc[VMIN] = 1;
     tcflush(fd, TCIFLUSH); /* 处理未接收字符 */
     if ((tcsetattr(fd, TCSANOW, &new_cfg)) != 0) /* 激活新配置 */
     {
         perror("tcsetattr");
         return -1;
      } 
      return 0;
}

##5.3串口使用详解
在配置完串口的相关属性后,就可以对串口进行打开和读写操作了。它所使用的函数和普通文件的读写函数一样,都是open()、write()和 read()。它们之间的区别的只是串口是一个终端设备,因此在选择函数的具体参数时会有一些区别。另外,这里会用到一些附加的函数,用于测试终端设备的 连接情况等。下面将对其进行具体讲解。
1.打开串口
打开串口和打开普通文件一样,都是使用open()函数,如下所示:

 fd = open( "/dev/ttyS0", O_RDWR|O_NOCTTY|O_NDELAY);

可以看到,这里除了普通的读写参数外,还有两个参数O_NOCTTY和O_NDELAY。
O_NOCTTY标志用于通知Linux系统,该参数不会使打开的文件成为这个进程的控制终端。如果没有指定这个标志,那么任何一个输入(诸如键盘中止信号等)都将会影响用户的进程。
O_NDELAY标志通知Linux系统,这个程序不关心DCD信号线所处的状态(端口的另一端是否激活或者停止)。如果用户指定了这个标志,则进程将会一直处在睡眠状态,直到DCD信号线被激活。
接下来可恢复串口的状态为阻塞状态,用于等待串口数据的读入,可用fcntl()函数实现,如下所示:

 fcntl(fd, F_SETFL, 0);

再接着可以测试打开文件描述符是否连接到一个终端设备,以进一步确认串口是否正确打开,如下所示:

 isatty(STDIN_FILENO);

这时,一个串口就已经成功打开了。接下来就可以对这个串口进行读和写操作。下面给出了一个完整的打开串口的函数,同样考虑到了各种不同的情况。程序如下所示:

2.读写串口
读写串口操作和读写普通文件一样,使用read()和write()函数即可,如下所示:

write(fd, buff, strlen(buff));
read(fd, buff, BUFFER_SIZE);

下面两个实例给出了串口读和写的两个程序,其中用到前面所讲述的open_port()和set_com_config ()函数。写串口的程序将在宿主机上运行,读串口的程序将在目标板上运行。
写串口的程序如下所示。
/*com_writer.c*/

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<errno.h>
#include "uart_api.h"
int main(void)
{
 	int fd;
 char buff[BUFFER_SIZE];
if((fd=open_port(HOST_COM_PORT))<0)  /*打开串口*/
 	{
  		perror("open_port");
 		return 1;
 	}
 	if(set_com_config(fd,115200,8,'N',1)<0) /*配置串口*/
 	{
  		perror("set_com_config error");
 	 	return 1;
 	}
 	do
 	{
  		printf("Input some words(enter 'quit' to exit):");
  		memset(buff,0,BUFFER_SIZE);
  		if(fgets(buff,BUFFER_SIZE,stdin)==NULL)
  		{
   			perror("fgets");
   			break;
  		}
  		write(fd,buff,strlen(buff));
 	}while(strncmp(buff,"quit",4));
 	close(fd);
 	return 0;
}

读串口的程序如下所示:
/*com_reader.c*/

#include "uart_api.h"
int main(void)
{
 int fd;
 	char buff[BUFFER_SIZE];
 	if((fd=open_port(TARGET_COM_PORT))<0)
 	{
  		perror("open_port");
 		return 1;
 	}
 	if(set_com_config(fd,115200,8,'N',1)<0) /*配置串口*/
 	{
  		perror("set_com_config ");
 		return 1;
 	}
 	do
{
  		memset(buff,0,BUFFER_SIZE);
  		if(read(fd,buff,BUFFER_SIZE)>0)
  		{
   			printf("the receive words are:%s",buff);
  		}
 	}while(strncmp(buff,"quit",4));
 close(fd);
 	return 0;
}

/*uart_api.h*/

#include<errno.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include <termios.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <ctype.h>
#define HOST_COM_PORT 1
#define BUFFER_SIZE  4096
#define MAX_COM_NUM  5
#define TARGET_COM_PORT 1
extern int open_port(int com_port);
extern int set_com_config(int fd,int baud_rate, int data_bits,char parity,int stop_bits);

在宿主机上运行写串口的程序,而在目标板上运行读串口的程序,运行结果如下所示。

参考:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>//文件控制定义
#include <termios.h>//终端控制定义
#include <errno.h>

#define DEVICE "/dev/ttyUSB1" 
#define MAX_COM_NUM 5
#define TARGET_COM_PORT 1

int serial_fd = 0;  
  
//打开串口并初始化设置  
init_serial(void)  
{
	serial_fd = open(DEVICE, O_RDWR | O_NOCTTY | O_NDELAY); 
	if (serial_fd < 0) 
	{          
		perror("open");  
		return -1;  
	}  


  //串口主要设置结构体termios <termios.h>  
  struct termios options;  
 /*
  * 1. tcgetattr函数用于获取与终端相关的参数。 
  *参数fd为终端的文件描述符,返回的结果保存在termios结构体中 
  */  
    tcgetattr(serial_fd, &options);  
 /*2. 修改所获得的参数*/  

	options.c_cflag |= (CLOCAL | CREAD);//设置控制模式状态,本地连接,接收使能  
    options.c_cflag &= ~CSIZE;//字符长度,设置数据位之前一定要屏掉这个位  
	options.c_cflag &= ~CRTSCTS;//无硬件流控  		
	options.c_cflag |= CS8;//8位数据长度  			
	options.c_cflag &= ~CSTOPB;//1位停止位				    
	options.c_iflag |= IGNPAR;//无奇偶检验位			    
	options.c_oflag = 0; //输出模式					    
	options.c_lflag = 0; //不激活终端模式  				   
	cfsetospeed(&options, B115200);//设置波特率  

 /*3. 设置新属性,TCSANOW:所有改变立即生效*/  	    
	tcflush(serial_fd, TCIFLUSH);//溢出数据可以接收,但不读  	    
	tcsetattr(serial_fd, TCSANOW, &options);  		    
	return 0;  
}  

/* 
 *串口发送数据 
 *@fd:串口描述符 
 *@data:待发送数据 
 *@datalen:数据长度 
 */  
int uart_send(int fd, char *data, int datalen)  
{
	int len = 0;  
		    
	len = write(fd, data, datalen);//实际写入的长度  		    
	if(len == datalen) 
	{		    
		return len;  						   
	} 
	else 
	{							       
		tcflush(fd, TCOFLUSH);//TCOFLUSH刷新写入的数据但不传送
		return -1;  													
	}		    
	return 0;  
}  

/*
 *串口接收数据 
 *要求启动后,在pc端发送ascii文件 
 */  
int uart_recv(int fd, char *data, int datalen)  
{
	int len=0, ret = 0;  	    
	fd_set fs_read;  
	struct timeval tv_timeout;  
				      		    
	FD_ZERO(&fs_read);  	    
	FD_SET(fd, &fs_read);  	   
	tv_timeout.tv_sec  = (10*20/115200+2);  			    
	tv_timeout.tv_usec = 0;  
								      
								    
	ret = select(fd+1, &fs_read, NULL, NULL, &tv_timeout);  
									   
	printf("ret = %d\n", ret);  
										   
	//如果返回0,代表在描述符状态改变前已超过timeout时间,错误返回-1 
	if (FD_ISSET(fd, &fs_read)) 
	{
		len = read(fd, data, datalen);  						
		printf("len = %d\n", len);  					
		return len;  					
	} 
	else 
	{									
		perror("select");  						
		return -1;  								
	}  								
	return 0;  									
}  
	
int main(int argc, char **argv)  
{
	init_serial();  
 
	char buf[]="hello world";      
	char buf1[10];  
				    
	uart_send(serial_fd, buf, 10);  				  
	printf("\n");  
											 
	uart_recv(serial_fd, buf1, 10);  					      					   
	printf("uart receive %s\n", buf1);  
								    
	close(serial_fd);  
									    
	return 0;  
}

本章参考代码

点击进入

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Bruceoxl

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值