个人学习笔记--Linux中的驱动程序基石

中断

        CPU 在运行的过程中,也会被各种“异常”打断,如图,中断也是“异常”的一种。区别在于中断也是程序正常运行中的一部分,而异常则是在设计程序时不想发生的,要避免的。

1、中断处理流程

硬件如下:

        首先,要初始化中断。1)设置可以产生中断的中断源  2)设置中断控制器,使它可以屏蔽某些中断,设定中断优先级 3)设置CPU总开关,使能要用的中断

        其次,在程序执行的过程中产生中断后,中断处理器接收中断并告诉CPU,CPU会停下当前的工作,转而由内核调用中断服务程序。(中断控制器发送中断给处理器的时候,处理器根据中断号查找中断向量表,找到中断服务程序的入口地址,才能去执行中断服务程序)CPU每执行完一条指令都会检查有无中断/异常的产生。

软件如下:    

       软件中断是由软件程序主动发起的        中断,它通常通过系统调用或软中断指令来向CPU发起中断请求。

      系统调用是一种从用户空间程序向操作系统内核发出服务请求的机制。当用户程序需要访问操作系统提供的服务或资源时,如文件系统、进程管理、网络通信等,它会通系统调用指令(如int 0x80)进入内核态,并将请求参数及系统调用号传递给内核程序进行处理。内核程序处理也即是根据中断号,此处是系统调用号来调用中断服务程序,内核程序处理完请求后,将结果返回给用户程序,并通过软件中断指令将CPU控制权返回到用户程序中。

(注:软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行中断处理程序。

        由于中断号是有限的,操作系统不舍得每一个系统调用对应一个中断号,而更倾向于用一个或少数几个中断号来对应所有的系统调用。Linux则使用int 0x80来触发所有系统调用。每个系统调用对应一份系统调用号,这个系统调用号在执行int 0x80指令前会放置在某个固定的寄存器里(eax),对应的中断代码会取得这个系统调用号,并且调用正确的函数)

2、异常向量表

        如图,这就是异常向量表,写在U-BOOT或内核中,每一条指令对应一种异常。这些指令都是跳转指令,发生异常时,CPU就会停下现在的工作转而执行指令,跳转至其他地址执行更复杂的函数。

        这么看来,异常向量表就像是一本异常问题手册,供CPU查阅使用,哪里不对点哪里。

Linux系统对中断的处理

1、进程、线程、中断的核心:栈

         ARM 芯片属于精简指令集计算机(RISC: Reduced Instruction Set Computing),它所用的指令比较简单。对内存的数据只能进行读或写的操作,对于数据运算只能在CPU内部运算逻辑单元(也就是ALU)中进行,对读到的值和运算结果都放入CPU的寄存器中(保存现场)。由此可知CPU中寄存器的值相当重要,当要暂停某个程序时,就需要将寄存器中的值保存下来,保存到内存中的栈中。(注:每个线程都有自己的栈)
2、符合中断的情况有
1)函数的调用
        a.  在函数 A 里调用函数 B ,实际就是中断函数 A 的执行
        b.  那么需要把函数 A 调用 B 之前瞬间的 CPU 寄存器的值,保存到栈里
        c.再执行函数B  
        d.函数 B 返回之后,就从栈中恢复函数 A 对应的 CPU 寄存器值,继续执行

        (注:这也就解释了局部变量的生命周期短的原因,函数B返回之后,存储在栈上的局部变量的空间就被释放了。 那么函数B是如何返回的?当调用函数时,主程序代码下一条指令的地址保存写入到栈中;当函数返回时,程序就会从栈中获取该地址,并从那一点继续向下执行。在函数调用了其它函数的情况下,将每一个返回地址都放到栈中;当函数结束时,就可以找到它们在栈中的地址。)
2)中断处理
        a.  进程A执行时发生中断
        b. CPU保存现场并执行中断向量表的跳转指令
        c.可以保存在进程 A 的内核态栈,也可以保存在进程 A 的内核结构体中
        d. 中断处理完毕,要继续运行进程 A 之前,恢复这些值
3)进程切换
        进程并不是同时运行的,而是微秒纳秒级别的快速切换,每个程序都执行一小段时间。CPU同一时间只能执行一个进程。

Linux 对中断的扩展:硬件中断、软件中断 

       

        硬件中断是由硬件设备触发的中断,如时钟中断、串口接收中断、外部中断等。当硬件设备有数据或事件需要处理时,中断处理器会接收中断并会向CPU提供中断号,CPU在收到中断请求后,会立即暂停当前正在执行的任务,进入中断处理程序中处理中断请求。硬件中断具有实时性强、可靠性高、处理速度快等特点。

软件中断是由软件程序触发的中断,如系统调用、软中断、异常等。软件中断不是由硬件设备触发的,而是由软件程序主动发起的,一般用于系统调用、进程切换、异常处理等任务。软件中断需要在程序中进行调用,其响应速度和实时性相对较差,但是具有灵活性和可控性高的特点。

1、硬、软中断的区别

  • 硬件中断是由外设引发的, 软中断是执行中断指令产生的.
  • 硬件中断的中断号是由中断控制器提供的, 软中断的中断号由指令直接指出, 无需使用中断控制器.(在软件中断中,也有中断号,它是由操作系统内核和应用程序约定的。在Linux操作系统中,软件中断号被称为信号(Signa,每个信号都有对应的编号,如SIGINT表示中断信号,编号为2;SIGTERM表示终止信号,编号为15等。)
  • 硬件中断是可屏蔽的, 软中断不可屏蔽.
  • 硬件中断处理程序要确保它能快速地完成任务, 这样程序执行时才不会等待较长时间, 称为上半部.
  • 软中断处理硬中断未完成的工作, 是一种推后执行的机制, 属于下半部.                       

                                    

进程和线程

         一个进程就是一套公寓,多个线程“合租”一套公寓,线程都有自己的卧室(栈空间),但厨房(代码区,存放的是代码编译后的机器指令)和卫生间(数据区)要共享。

(注:由于线程运行的本质就是函数运行,函数运行时信息是保存在栈帧中的,因此每个线程都有自己独立的、私有的栈区。另外,程序计数器(记录线程的现场)、栈指针以及函数运行使用的寄存器是线程私有的

        在Linux中,资源分配的最小单位是进程,调度的最小单位是线程。(进程的创建调用fork或者vfork,而线程的创建调用pthread_create,进程结束后它拥有的所有线程都将销毁,而线程的结束不会影响同个进程中的其他线程的结束。)在一个进程里,可能有多个线程,这些线程共用打开的文件句柄、 全局变量等等。同一进程中的不同线程也是依靠全局变量传递数据,比较高效,进程间的通信慢(七种IPC:InterProcess Communication:管道pipe、命名管道FIFO、消息队列、共享存储、信号量、套接字Socket、信号)且进程的创建、销毁、切换存在很大的时空开销。

        进程有自己独立的地址空间,每启动一个进程,系统都会为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段,线程没有独立的地址空间,它使用相同的地址空间共享数据。由此就引出了一个问题,同一进程中的不同线程共享进程资源的话,如何保证线程间的内存使用不冲突呢?毕竟当一个线程使用共享内存时,其他线程只能干看着,等上一个用完了自己再用。这就需要引入“互斥锁”、“信号量”的概念。那就了解一下多线程编程的相关知识。

多线程编程

        对于进程而言,每一个进程都有一个唯一对应的 PID 号来表示该进程,而对于线程而言,也有一个“类似于进程的PID 号”,名为 tid ,其本质是一个 pthread_t 类型的变量。线程号与进程号是表示线程和进程的唯一标识,但是对于线程号而言,其仅仅在其所属的进程上下文中才有意义。
                 
互斥量:
     为了解决对线程间对共享资源/临界资源的争夺,pthread线程引出了互斥量pthread_mutex)来解决,一般定义为全局变量,以便所有线程都能访问。初始化互斥量之后就可以对互斥量进行加锁pthread_mutex_lock阻塞方式),若有线程加锁成功,则可以访问临界区,其余线程遇到 lock 函数时候会发生阻塞,直至获取资源的线程执行 unlock 函数后。unlock 函数会唤醒其他正在等待互斥量的线程。或者用 pthread_mutex_trylock(非阻塞方式)该函数是非阻塞模式通过返回值来 判断是否加锁成功,用法与上述阻塞加锁函数一致。值得注意的是当拿到锁的线程执行完之后要解锁后者销毁,否则资源会一直被占用,出现 死锁情况。
信号量:    
        解决了临界资源的访问,但似乎对线程的执行顺序无法得到控制,因线程都是无序执行。因此引入信号量的概念。 (注:互斥量和信号量不一样,互斥量是一把锁,谁把这把锁锁住了谁就去访问。信号量是线程各自有一个信号灯,谁的灯亮着就代表谁在访问,执行完自己的就灭灯换下一个线程的灯亮。)
条件变量:     

        条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待条件变量的条件成立而挂起;另一个线程使条件成立(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥量结合在一起。

        当条件满足的时候,线程通常解锁并等待该条件发生变化,一旦另一个线程修改了条件变量,就会通知相应的条件变量唤醒一个或者多个被这个条件变量阻塞的线程。这些被唤醒的线程将重新上锁,并测试条件是否满足。一般来说 条件变量被用于线程间的同步;当条件不满足的时候,允许其中的一个执行流挂起和等待。
        也就是给申请加锁的进程加了条件,只有满足条件的进程才能加锁成功。达到更好的协调线程调度的作用。避免出现不想执行的线程也在竞争申请加锁的情况。

休眠与唤醒

休眠-唤醒机制是应用程序访问硬件的一种方式,其他还包括查询方式、poll方式、异步通知方式。休眠唤醒流程框图如下图

        以按键操作为输入为例,当应用程序执行read函数时,会调用内核态中的read_drv函数,在read_drv函数中有wait函数,wait函数会判断是否有按键输入,若有则返回数据,若没有就执行休眠,以上过程在一进程/线程中进行。

        在进程休眠后,若有按键操作会触发中断,中断处理器接收中断并告诉CPU,CPU会停下当前的工作(保存现场),转而由内核调用(在驱动程序中注册的)中断服务程序。中断服务程序会将按键信息记录下来并唤醒休眠中的进程/线程(休眠中的线程被放在队列wq中),然后将按键信息拷贝到应用程序。(注:硬件中断不可嵌套,不可休眠,所以要尽可能快的执行完中断服务程序,因此耗时间的函数比如IIC的操作函数就不能放在中断中)

        总结:妈妈(应用程序)想知道孩子醒没醒(硬件信息),孩子没有哭(无硬件中断),妈妈就休眠,孩子哭了就唤醒妈妈。

驱动框架如下:

  1. APP 调用 read 等函数试图读取数据,比如读取按键;
  2. APP 进入内核态,也就是调用驱动中的对应函数,发现有数据则复制到用空间(copy_to_user)并马上返回;
  3. 如果 APP 在内核态,也就是在驱动程序中发现没有数据,则 APP 休眠,休眠时除了把程序状态改为非 RUNNING 之外,还要把进程/进程放入wq 中,以后中断服务程序要从 wq 中把它取出来唤醒
  4. 当有数据时,比如当按下按键时,驱动程序的中断服务程序被调用,它会记 录数据、唤醒 APP
  5. APP 继续运行它的内核态代码,也就是驱动程序中的函数,复制数据到用户 空间并马上返回。

POLL机制

妈妈进入房间时,会先看小孩醒没醒,闹钟响之后走出房间之前又会再看小 孩醒没醒。
        此机制与休眠唤醒的区别在于,在poll函数查询小孩状态后(第一次看小孩),若没醒就会设定超时时间,在超时时间内有数据就唤醒,若没有就休眠,时间到了也唤醒,再次查看小孩状态(第二次看小孩)
若过程中有数据:
PP 调用 poll 之后,进入内核态;
④致驱动程序的 drv_poll 被调用:
注意 drv_poll 要把自己这个线程挂入等待队列 wq 中,drv_poll 还会判断一下:有没有数据啊?返回这个状态。
⑤当前没有数据,则休眠一会;
⑥过程中,按下了按键,发生了中断:
在中断服务程序里记录了按键值,并且从 wq 中把线程唤醒了。
⑦从休眠中被唤醒,继续执行 for 循环,再次调用 drv_poll
drv_poll 返回数据状态
⑧哦,你有数据,那从内核态返回到应用态吧
APP 调用 read 函数读数据
若过程中一直没有数据:
APP 调用 poll 之后,进入内核态;
④ 导致驱动程序的 drv_poll 被调用:
⑤ 假设当前没有数据,则休眠一会;
⑥ 在休眠过程中,一直没有按下了按键,超时时间到:内核把这个线程唤醒;
线程从休眠中被唤醒,继续执行 for 循环,再次调用 drv_poll drv_poll 返回数据状态
⑧ 哦,你还是没有数据,但是超时时间到了,那从内核态返回到应用态吧
APP 不能 调用 read 函数读数据
注意几点:
drv_poll 要把线程挂入队列 wq ,但是并不是在 drv_poll 中进入休眠,而是在调用 drv_poll 之后无数据,在sys_poll中休眠
drv_poll 要返回数据状态
APP 调用一次 poll ,有可能会导致 drv_poll 被调用 2 次(休眠时遇到中断 | 超时自动唤醒)
线程被唤醒的原因有 2 :中断发生了去队列 wq 中把它唤醒,超时时间到了内核把它唤醒
APP 要判断 poll 返回的原因:有数据,还是超时。有数据时再去调用 read 函数。
驱动编程:
使用 poll 机制时,驱动程序的核心就是提供对应的 drv_poll 函数。在drv_poll 函数中要做 2 件事:
        把当前线程挂入队列 wqpoll_wait
a) APP 调用一次 poll ,可能导致 drv_poll 被调用 2 次,但是我们并不需要把当前线程挂入队列 2 次。
b) 可以使用内核的函数 poll_wait 把线程挂入队列,如果线程已经在队列里了,它就不会再次挂入。
        返回设备状态:
APP 调用 poll 函数时,有可能是查询“有没有数据可以读”: POLLIN ,也有 可能是查询“你有没有空间给我写数据”:POLLOUT 。所以 drv_poll 返回自己的当前状态:(POLLIN | POLLRDNORM) 或 (POLLOUT | POLLWRNORM)。
        唤醒函数:
APP 调用 poll 后,很有可能会休眠。对应的,在按键驱动的中断服务程序中,也要有唤醒操作。
驱动程序中 poll 的代码如下:
static unsigned int gpio_key_drv_poll(struct file *fp, poll_table * wait)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
poll_wait(fp, &gpio_key_wait, wait);
return is_key_buf_empty() ? 0 : POLLIN | POLLRDNORM;
}

应用编程:
APP 可以调用 poll select 函数,这 2 个函数的作用是一样的。
在调用 poll 函数时,要指明:
你要监测哪一个文件:哪一个 fd
你想监测这个文件的哪种事件:是 POLLIN 、还是 POLLOUT 最后,在 poll 函数返回时,要判断状态。
应用程序代码如下:
int timeout_ms = 5000;
int ret;
fds[0].fd = fd;
fds[0].events = POLLIN;
ret = poll(fds, 1, timeout_ms);
if ((ret == 1) && (fds[0].revents & POLLIN))
{
read(fd, &val, 4);
printf("get button : 0x%x\n", val);
}

异步通知

妈妈在客厅干活,小孩醒了他会自己走出房门告诉妈妈 ,妈妈、小孩互不耽误。
所谓异步就是独立、互不干预的。 接收到中断,驱动就告诉APP
驱动程序怎么通知 APP 发信号 ,这只有 3 个字,却可以引发很多问题:
谁发:驱动程序发
发什么:信号
发什么信号: SIGIO
怎么发:内核里提供有函数
发给谁: APP APP 要把自己告诉驱动
APP 收到后做什么:执行信号处理函数
信号处理函数和信号,之间怎么挂钩: APP 注册信号处理函数
框架:
重点从②开始:
APP SIGIO 这个信号注册信号处理函数 func ,以后 APP 收到 SIGIO 信号时,这个函数会被自动调用;
③ 把 APP PID( 进程 ID) 告诉驱动程序,这个调用不涉及驱动程序,在内核的文件系统层次记录 PID
④ 读取驱动程序文件 Flag
⑤ 设置 Flag 里面的 FASYNC 位为 1 :当 FASYNC 位发生变化时,会导致驱动程序的 fasync 被调用;
⑥⑦ 调 用 faync_helper , 它 会 根 据 FAYSNC 的值决定是否设置button_async->fa_file=驱动文件 filp
驱动文件 filp 结构体里面含有之前设置的 PID
APP 可以做其他事;
⑨⑩ 按下按键,发生中断,驱动程序的中断服务程序被调用,里面调用 kill_fasync 发信号;
⑪⑫⑬ APP 收到信号后,它的信号处理函数被自动调用,可以在里面调用read 函数读取按键。

阻塞与非阻塞

所谓阻塞,就是等待某件事情发生。比如调用 read 读取按键时,如果没有按键数据则 read 函数不会返回,它会让线程休眠等待。

阻塞就是慢性子,有反馈就返回,得不到反馈就等。

非阻塞就是急性子,有没有反馈都不等,即刻返回。

使用 poll 时,如果传入的超时时间不为 0 ,这种访问方法也是阻塞的。
使用 poll 时,可以设置超时时间为 0 ,这样即使没有数据它也会立刻返回, 这就是非阻塞方式。能不能让 read 函数既能工作于阻塞方式,也可以工作于非 阻塞方式? 可以
APP 调用 open 函数时,传入 O_NONBLOCK ,就表示要使用非阻塞方式;默认是阻塞方式。

注意 :对于普通文件、块设备文件, O_NONBLOCK 不起作用。
注意 :对于字符设备文件, O_NONBLOCK 起作用的前提是驱动程序针对O_NONBLOCK 做了处理。

定时器

也就是闹钟,赋予Linux系统时间的概念与标准。

可以使用定时器处理按键抖动,如下图

核心在于:在 GPIO 中断中并不立刻记录按键值,而是修改定时器超时时间,10ms 后再处理。
如果 10ms 内又发生了 GPIO 中断,那就认为是抖动,这时再次修改超时时间为 10ms
只有 10ms 之内再无 GPIO 中断发生,那么定时器的函数才会被调用。 在定时器函数中记录按键值。
setup_timer(timer, fn, data)
设置定时器,主要是初始化 timer_list 结构体,设置其中的函数、参数。
void add_timer(struct timer_list *timer)
向内核添加定时器。 timer->expires 表示超时时间。
当 超 时 时 间 到 达 , 内 核 就 会 调 用 这 个 函 数 :
timer->function(timer->data)
int mod_timer(struct timer_list *timer, unsigned long expires):
修改定时器的超时时间,
它等同于: del_timer(timer); timer->expires = expires;
add_timer(timer);
但是更加高效。
int del_timer(struct timer_list *timer)
删除定时器。

参考原文链接:https://blog.csdn.net/qq_36822217/article/details/107237670

参考原文链接:https://blog.csdn.net/qq_20880415/article/details/81387186

  • 14
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
韦东山老师为啥要录升级版嵌入式视频?200x年左右,嵌入式Linux在全世界、在国刚刚兴起。我记得我2005年进入兴时,全部门的人正在努力学习Linux。在2008年,我写了一本书《嵌入式Linux应用开发完全手册》。它的大概内容是:裸机、U-boot、Linux内核、Linux设备驱动。那时还没有这样讲解整个系统的书,芯片厂家Linux开发包也还不完善,从bootloader到内核,再到设备驱动都不完善。有全系统开发能力的人也很少。于是这书也就恰逢其时,变成了畅销书。我也根据这个思路录制了视频:裸机、U-boot、Linux内核、Linux设备驱动。收获些许名声,带领很多人进入Linux世界。11年过去了,嵌入式Linux世界发生了翻天覆地的变化① 基本系统能用芯片厂家都会提供完整的U-boot、Linux内核、芯片上硬件资源的驱动。方案厂家会做一些定制,比如加上某个WIFI模块,会添加这个WIFI模块的驱动。你可以使用厂家的原始方案,或是使用/借鉴方案商的方案,做出一个“能用”的产品。② 基础驱动弱化;高级驱动专业化基础的驱动,比如GPIO、UART、SPI、I2C、LCD、MMC等,有了太多的书籍、视频、示例代码,修修改改总是可以用的。很多所谓的驱动工程师,实际上就是“调参工程师”。我们群里有名的火哥,提出了一个概念:这些驱动就起一个“hardware enable”的作用。高级的驱动,比如USB、PCIE、HDMI、MIPI、GPU、WIFI、蓝牙、摄像头、声卡。体系非常复杂,很少有人能讲清楚,很多时候只是一笔带过。配置一下应用层工具就了事,能用就成。这些高级驱动,工作需要专门的人来负责,非常专业。他们是某一块的专家,比如摄像头专家、音频专家。③ 项目为王你到一个公司,目的是把产品做出来,会涉及APP到内核到驱动全流程。小公司玩不起华为兴的配置,需要的是全面手。大公司里,只负责很小很小一块的镙丝钉,位置也不太稳固啊。所以,如果你不是立志成为某方面的专家,那就做一个全栈工程师吧。④ 调试很重要都说代码是3分写7分调,各种调试调优技术,可以为你的升职加薪加一把火。基于上述4点,我录制的全新视频将有这些特点:1. 快速入门,2. 实战项目,3. 驱动大全,4. 专题,5. 授人以渔,6. 要做任务另外,我们会使用多款芯片同时录制,先讲通用的原理,再单独讲各个板子的操作。这些芯片涵盖主流芯片公司的主流芯片,让你学习工作无缝对接。1.快速入门入门讲究的是快速,入门之后再慢慢深入,特别是对于急着找工作的学生,对于业余时间挑灯夜读的工作了的人,一定要快!再从裸机、U-boot、内核、驱动这样的路线学习就不适合了,时间就拉得太长了。搞不好学了后面忘了前面。并且实际工作并不需要你去弄懂U-boot,会用就行:U-boot比驱动还复杂。讲哪些内容?怎么讲呢?混着讲比如先讲LED APP,知道APP怎么调用驱动,再讲LED硬件原理和裸机,最后讲驱动的编写。这样可以快速掌握嵌入式Linux的整套开发流程,不必像以前那样光学习裸机就花上1、2个月。而里面的裸机课程,也会让你在掌握硬件操作的同时,把单片机也学会了。讲基础技能断、休眠-唤醒、异步通知、阻塞、内存映射等等机制,会配合驱动和APP来讲解。这些技能是嵌入式Linux开发的基础。而这些驱动,只会涉及LED、按制、LCD等几个驱动。掌握了这些输入、输出的驱动和对应的APP后,你已经具备基本的开发能力了。讲配置我们从厂家、从方案公司基本上都可以拿到一套完整的开发环境,怎么去配置它?需要懂shell和python等配置脚本。效果效率优先以前我都是现场写代码、现场写文档,字写得慢,降低了学习效率。这次,效果与效率统一考虑,不再追求所有东西都现场写。容易的地方可先写好代码文档,难的地方现场写。2.实战项目会讲解这样的涉及linux网关/服务器相关项目(不限于,请多提建议):                   定位为:快速掌握项目开发经验,丰满简历。涉及的每一部分都会讲,比如如果涉及蓝牙,在这里只会讲怎么使用,让你能写出程序;如果要深入,可以看后面的蓝牙专题。3. 驱动大全包括基础驱动、高级驱动。这些驱动都是独立成章,深入讲解。虽然基础驱动弱化了,但是作为Linux系统开发人员,这是必备技能,并且从驱动去理解内核是一个好方法。在讲解这些驱动时,会把驱动的运行环境,比如内核调度,进程线程等概念也讲出来,这样就可以搭建一个知识体系。没有这些知识体系的话,对驱动的理解就太肤浅了,等于在Linux框架下写裸机,一叶障目,不见泰山。定位为:工具、字典,用到再学习。4. 专题想深入学习的任何内容,都可独立为专题。比如U-boot专题、内核内存管理专题、systemtap调试专题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值