中断处理与【irqsoft】、【tasklet】和【work queue】使用实例

中断处理与底半部机制
本文详细介绍了中断处理的概念及其实现细节,探讨了中断处理函数的特点与限制,并介绍了中断处理的底半部机制,包括softirq、tasklet和工作队列的区别与应用场景。

中断处理由于I/O操作的不确定因素,以及处理器和I/O设备之间速度的不匹配,设备往往通过某种硬件信号异步地唤起处理器的注意。这些硬件信号就是所谓的中断。每个中断设备都被分配给一个相关的标识符,被称为中断请求(IRQ)号。当处理器检测到某一IRQ号对应的中断产生时,它将停止它现在的工作,并引用该IRQ所对应的中断服务例程(ISR)。中断处理函数ISR在中断上下文执行。中断上下文ISR是与硬件交互的非常重要的代码片段。它们被给予了立即执行的特权,以便最大化系统的性能。不过,如果ISR执行过慢、负载太重的化,就违背了自身的设计哲学。贵宾都被给予了优惠待遇,但是,尽量减少由此造成的对公众的不便也是他们的义务。为了对粗暴打断当前执行线程的行为进行补偿,ISR不得不礼貌地执行于受限制的环境下,即所谓的中断上下文(或原子上下文)。下面给出了中断上下文可为和不可为事项的列表:1.如果你的中断上下文进入睡眠,它是一项应该被处以监禁的罪行。中断处理函数不能通过调用schedule_timeout()等睡眠函数放弃处理器,在中断处理函数中调用一个内核API之前,应该仔细分析它以确保其内部不会触发阻塞等待。例如,input_register_device()表面上看起来没有问题,但是它内部以GFP_KERNEL为参数调用了kmalloc()。从第2章《内核一瞥》可以看出,用这种方式调用kmalloc()的话,如果系统的空闲内存低于某门限,kmalloc()将睡眠等待swapper释放内存。2.为了在中断处理函数中保护临界区,你不能使用互斥体,因为它们也许导致睡眠。应该使用自旋锁代替互斥体,但是一定要记住的是只有真正需要的时候才采用它。3.中断处理函数不能与用户空间直接交互数据,因为它们经由进程上下文与用户空间建立连接。这也是为什么中断处理函数不能睡眠的第2个理由:调度器工作于进程之间,如果中断处理函数睡眠并被调度出去,它们怎么返回到运行队列呢?4.中断处理函数一方面需要快速地出来,另一方面又需要完成它的工作。为了规避这种冲突,中断处理函数通常被分成2个部分。瘦小的顶半部标志一个响应以宣称它已经服务了该中断,而重大的工作负载都被丢给了肥胖的底半部。底半部的执行被延后,在其执行环境中,所有的中断都是使能的。在讨论softirq和tasklet的时候,你将学习到真也难怪开发底半部。5.中断处理函数不必是可重用的。当某中断被执行的时候,在它返回之前,相应的IRQ都被禁止了。因此,与进程上下文代码不同的是,同一中断处理函数的不同实例不可能同时运行在多个处理器上。6.中断处理函数可以被更高优先级IRQ的中断处理函数打断。如果你请求内核将你的中断处理函数作为快中断处理的话,此类中断嵌套将被禁止。快中断服务函数运行的时候,本处理器上的所有中断都会被禁止。在禁止中断或将你的中断标识为快中断之前,请意识到中断屏蔽对系统性能的坏处。中断屏蔽的时间越长,中断延迟就会更长,或者说已经被产生的中断得到服务的延迟就会越久。中断延迟与系统真实的响应时间成反比。函数中可以检查in_interrupt()的返回值以查看自身是否位于中断上下文。与外部硬件产生的异步中断不一样,也存在同步到达的中断。同步中断意味着它们不会不期而遇,它们由处理器本身执行某指令而产生。外部中断和同步中断在内核中使用相同的机制处理。同步中断的例子包括:(1)异常,被用于报告严重的运行时错误;(2)软中断,如int 0x80指令,被用户实现x86体系结构上的系统调用。分配IRQ号设备驱动必须将它们的IRQ号与一个中断处理函数连接。因此,它们需要知道它们正在驱动的设备的IRQ号。IRQ的分配可以很直接,也可能需要复杂的探测过程。在PC体系结构中,例如,定时器中断被分配了IRQ 0,RTC中断也是IRQ 8。现代的中断技术(如PCI)足够强大,它能够响应对IRQ的查询(系统启动过程中由BIOS分配),PCI驱动能够访问设备配置空间的相应区域并获得IRQ。对于较老的设备,如基于工业标准体系结构(ISA)的卡而言,驱动也许不得不利用特定硬件的知识以探测和解析IRQ。通过/proc/interrupts可以查看系统中活动的IRQ的列表。设备实例:辊轮现在你已经学习了中断处理的基本知识,现在我们来实现一个辊轮设备实例的中断处理。在一些手机和PDA上能找到辊轮,它支持3种动作(顺时针旋转,逆时针旋转和按键),可便利菜单导航。本例辊轮中的任何运行都会向处理器产生IRQ 7。通用目的I/O(GPIO)端口D的低3位与辊轮设备连接。这些引脚上产生的波形与图4.3中不同的辊轮运动一致。中断处理函数的工作是通过查看端口D的GPIO数据寄存器解析出辊轮的运动。
 
 

驱动必须首先请求IRQ并将一个中断处理函数与其绑定:#define ROLLER_IRQ  7
static irqreturn_t roller_interrupt(int irq, void *dev_id);
 
 
if (request_irq(ROLLER_IRQ, roller_interrupt, IRQF_DISABLED |
                IRQF_TRIGGER_RISING, "roll", NULL)) {
  printk(KERN_ERR "Roll: Can't register IRQ %d/n", ROLLER_IRQ);
  return -EIO;
}
我们看一下传递给request_irq()的参数,本例中没有查询或探测IRQ号,而是直接硬编码为ROLLER_IRQ。第2个参数roller_interrupt()是中断处理函数。中断处理函数的原型的返回值类型为irqreturn_t,如果中断处理成功,则返回IRQ_HANDLED,否则,返回IRQ_NONE。对于PCI等I/O而言,该返回值的意义更重要,因为多个设备可能共享同一IRQ。IRQF_DISABLED标志意味着这个中断处理为快中断,因此,在调用该处理函数的时候,内核将禁止所有的中断。IRQF_TRIGGER_RISING暗示辊轮将在中断线上产生一个上升沿以发出中断。换句话说,辊轮是一个边沿触发的设备。有一些设备是电平触发的,在CPU服务其中断之前,它一直将中断线保持在一个电平上。使用IRQF_TRIGGER_HIGH或IRQF_TRIGGER_LOW可以标识一个中断为高/低电平触发。该参数其他的可能值包括IRQF_SAMPLE_RANDOM(第5章《字符设备驱动》的《伪字符设备驱动》一节会用到)、IRQF_SHARED(定义这个IRQ被多个设备共享)。下一个参数"roll",用于标识这个设备,在/proc/interrupts等文件中也会利用它产生数据。最后一个参数(本例中为NULL),仅在共享中断的时候有用,用于区分共享同一IRQ线的每个设备。从2.6.19内核开始,中断处理接口发生了一些变化。以前的中断处理函数的第3个参数为struct pt_regs *,它指向存放CPU寄存器的地址,在2.6.19中已经移除。另外,IRQF_xxx型中断标志取代了SA_xxx型中断标志。例如,在较早的内核中,你应该使用SA_INTERRUPT而不是IRQF_DISABLED来将中断处理标识为快中断处理。驱动初始化的时候申请IRQ并不是太好,因为这样会导致甚至设备未被使用的时候,有价值的资源也被占用。因此,设备驱动通常在设备被应用打开的时候申请IRQ。类似地,IRQ也在应用关闭设备的时候释放IRQ,而不是在退出驱动模块的时候进行。使用下面的方法可以释放一个IRQ:free_irq(int irq, void *dev_id);
清单4.1给出了辊轮中断处理的实现。roller_interrupt()有2个参数,IRQ和设备标识符(传递给request_irq()的最后一个参数)。请对照图4.3查看清单4.1。
    清单4.1 辊轮中断处理spinlock_t roller_lock = SPIN_LOCK_UNLOCKED;
static DECLARE_WAIT_QUEUE_HEAD(roller_poll);
 
static irqreturn_t
roller_interrupt(int irq, void *dev_id)
{
  int i, PA_t, PA_delta_t, movement = 0;
 
  /* Get the waveforms from bits 0, 1 and 2
     of Port D as shown in Figure 4.3 */
  PA_t = PORTD & 0x07;
 
  /* Wait until the state of the pins change.
     (Add some timeout to the loop) */
  for (i=0; (PA_t==PA_delta_t); i++){
    PA_delta_t = PORTD & 0x07;
  }
 
  movement = determine_movement(PA_t, PA_delta_t); /* See below */
 
  spin_lock(&roller_lock);
 
  /* Store the wheel movement in a buffer for
     later access by the read()/poll() entry points */
  store_movements(movement);
 
  spin_unlock(&roller_lock);
 
  /* Wake up the poll entry point that might have
     gone to sleep, waiting for a wheel movement */
  wake_up_interruptible(&roller_poll);
 
  return IRQ_HANDLED;
}
int
determine_movement(int PA_t, int PA_delta_t)
{
  switch (PA_t){
    case 0:
      switch (PA_delta_t){
      case 1:
        movement = ANTICLOCKWISE;
        break;
      case 2:
        movement = CLOCKWISE;
        break;
      case 4:
        movement = KEYPRESSED;
        break;
      }
      break;
    case 1:
      switch (PA_delta_t){
      case 3:
        movement = ANTICLOCKWISE;
        break;
      case 0:
        movement = CLOCKWISE;
        break;
      }
      break;
    case 2:
      switch (PA_delta_t){
      case 0:
        movement = ANTICLOCKWISE;
        break;
      case 3:
        movement = CLOCKWISE;
        break;
      }
      break;
    case 3:
      switch (PA_delta_t){
      case 2:
        movement = ANTICLOCKWISE;
        break;
      case 1:
        movement = CLOCKWISE;
        break;
      }
    case 4:
      movement = KEYPRESSED;
      break;
  }
}
驱动入口点(如read()和poll())尾随roller_interrupt()进行操作。例如,当中断处理函数解析完一个辊轮运动后,它唤醒正在等待的poll()线程(可能已经因为X Windows等应用发起的select()系统调用而睡眠)。请在学习完第5章字符设备驱动的知识后,重新查看清单4.1并实现辊轮设备的完整驱动。第7章《输入设备驱动》的清单7.3利用了内核的输入接口,将辊轮转化为辊鼠标。在本节结束前,我们介绍一下使能和禁止特定IRQ的函数。enable_irq(ROLLER_IRQ)用于使能辊轮运动的中断发生,disable_irq(ROLLER_IRQ)则进行相反的工作。disable_irq_nosync(ROLLER_IRQ)禁止辊轮中断,并且不等待任何正在执行的roller_interrupt()实例的返回。disable_irq()的非同步变体执行地更快,但是可能导致潜在的竞态。只有在你确认没有竞争的尽快下,才可以这样使用。drivers/ide/ide-io.c由一个使用disable_irq_nosync()的例子,在初始化过程中,它阻止了一些中断,因为一些系统中可能在此方面存在问题。软中断(Softirq)和Tasklet正如以前讨论的那样,中断处理有2个矛盾的要求:它们需要完成大量的设备数据处理,但是又不得不尽可能快地退出。为了摆脱这一困境,中断处理过程被分成2部分:一个急切的且抢占的与硬件交互的顶半部,和一个在所有中断都使能情况下并非十分急切的处理大量工作的底半部。如顶半部不一样,底半部是同步的,因为内核决定了它什么时候会执行它们。如下机制都可用于内核中延后一个工作到底半部执行:softirq、tasklet和工作队列(work queue)。Softirq是一种基本的底半部机制,有较强的加锁需求。仅仅在一些对性能敏感的子系统(如网络层、SCSI层和内核定时器)中才会使用softirq。Tasklet建立在softirq之上,使用起来更简单。除非有严格的可扩展性和速度要求,都建议使用Tasklet。Softirq和Tasklet的主要不同是前者是可重用的而后者则不需要。Softirq的不同实例可运行在不同的处理器上,而tasklet则不允许。为了论证Softirq和Tasklet的用法,假定前例中的辊轮由存在由于运动部件导致的潜在问题(如旋轮偶尔被卡住)从而导致不同于spec的波形。一个被卡住的旋轮会不停地产生假的中断,并可能使系统冻结。为了解决这个问题,可以捕获波形,进行一些分析,并在发现卡住的情况下动态地从中断模式切换到轮询模式,如果旋轮恢复正常,软件也恢复到正常模式。我们在中断处理函数中捕获波形,并在底半部分析它。清单4.2和4.3分别用Softirq和Tasklet对此进行了实现。它们都是清单4.1的简化的变体,它们将中断处理简化为2个函数:从GPIO端口D捕获波形的roller_capture()和对波形进行算术分析并按需切换到轮询模式的roller_analyze()。
清单4.2 使用Softirq 分担中断处理的负载void __init
roller_init()
{
  /* ... */
 
  /* Open the softirq. Add an entry for ROLLER_SOFT_IRQ in
     the enum list in include/linux/interrupt.h */
  open_softirq(ROLLER_SOFT_IRQ, roller_analyze, NULL);
}
 
 
/* The bottom half */
void
roller_analyze()
{
  /* Analyze the waveforms and switch to polled mode if required */
}
/* The interrupt handler */
static irqreturn_t
roller_interrupt(int irq, void *dev_id)
{
  /* Capture the wave stream */
  roller_capture();
 
  /* Mark softirq as pending */
  raise_softirq(ROLLER_SOFT_IRQ);
 
  return IRQ_HANDLED;
}
为了定义一个softirq,你必须在include/linux/interrupt.h中静态地添加一个入口。你不能动态地定义softirq。raise_softirq()用于宣布相应的softirq需要被执行。内核会在下一个可获得的机会里执行它。可能发生在退出硬中断处理函数的时候,也可能在ksoftirqd内核线程中。
清单4.3使用tasklet分担中断处理的负载struct roller_device_struct { /* Device-specific structure */
  /* ... */
  struct tasklet_struct tsklt;
  /* ... */
}
 
void __init roller_init()
{
  struct roller_device_struct *dev_struct;
  /* ... */
 
  /* Initialize tasklet */
  tasklet_init(&dev_struct->tsklt, roller_analyze, dev);
}
 
 
/* The bottom half */
void
roller_analyze()
{
/* Analyze the waveforms and switch to
   polled mode if required */
}
/* The interrupt handler */
static irqreturn_t
roller_interrupt(int irq, void *dev_id)
{
  struct roller_device_struct *dev_struct;
 
  /* Capture the wave stream */
  roller_capture();
 
  /* Mark tasklet as pending */
  tasklet_schedule(&dev_struct->tsklt);
 
  return IRQ_HANDLED;
}
tasklet_init()用于动态地初始化一个tasklet,该函数不会为tasklet_struct分配内存,相反地,你必须将已经分配好的地址传递给它。tasklet_schedule()用于宣布相应的tasklet需要被执行。和中断类似,内核提供了一系列用于控制在多处理器系统中tasklet执行状态的函数:(1)tasklet_enable()使能tasklet;(2)tasklet_disable()禁止tasklet,并等待正在执行的tasklet退出;(3)tasklet_disable_nosync()的语义和disable_irq_nosync()相似,它并不等待正在执行的tasklet退出。你已经看到了中断处理函数和底半部的不同,但是,也有几个相似点。中断处理函数和tasklet都不需要可重用。而且,二者都不能睡眠。另外,中断处理函数、tasklet和softirq都不能被抢占。工作队列是中断处理延后执行的第3种方式。它们在进程上下文执行,允许睡眠,因此可以使用mutex这类可能导致睡眠的函数。在前一章分析内核辅助接口的时候,我们已经讨论了工作队列。表4.1对softirq、tasklet和工作队列进行了对比分析。

清单4.4 使用workqueue分担中断处理的负载
#include <linux/init.h>

#include <linux/module.h>

#include <linux/kernel.h>

#include <linux/interrupt.h>

#include <linux/workqueue.h>

 

static void intr_print(void* data)

{

    printk("/n%s/n", (char*)data);

}

 

static struct work_struct works;

static char list[20];

static int intr_init(void)

{

    strlcpy(list, "Hello world", 20);

    INIT_WORK(&works, (void*)intr_print, (void*)list);

    schedule_work(&works);

    return 0;

}

 

static void intr_exit(void)

{

    printk("intr_exit");

}

 

module_init(intr_init);

module_exit(intr_exit);

 

MODULE_LICENSE("GPL");

MODULE_AUTHOR("Shakespeare");

 
 

 

 work queue例子如上所示


表4.1 softirq、tasklet和工作队列对比  Softirq Tasklet Work Queue
执行上下文 延后的工作,运行于中断上下文 延后的工作,运行于中断上下文 延后的工作,运行于进程上下文
可重用 可以在不同的CPU上同时运行 不能在不同的CPU上同时运行,但是不同的CPU可以运行不同的tasklet 可以在不同的CPU上同时运行
睡眠 不能睡眠 不能睡眠 可以睡眠
抢占 不能抢占/调度 不能抢占/调度 可以抢占/调度
易用性 不容易使用 容易使用 容易使用
何时使用 如果延后的工作不会睡眠,而且有严格的可扩展性或速度要求 如果延后的工作不会睡眠 如果延后的工作会睡眠

 


在LKML正在进行一项去除tasklet的可行性的讨论。Tasklet比进程上下文的代码优先级更高,因此它们可能存在延迟问题。另外,你已经学习到,它们不允许睡眠,且只能在同一CPU上执行。因此,有人提议将现存的tasklet基于其场景随机应变地转换为softirq或工作队列。


本文来自优快云博客,转载请标明出处:http://blog.youkuaiyun.com/do2jiang/archive/2010/04/15/5487237.aspx

`tasklet` `workqueue` 都是 Linux 内核中用于**中断下半部(bottom half)处理**的机制,用于将耗时操作从硬中断上下文中延迟执行。但它们在 **执行上下文、调度方式、资源占用、并发能力、适用场景** 上有显著区别。 --- ## ✅ 核心对比总结 | 特性 | `tasklet` | `workqueue` | |------|----------|-------------| | 执行上下文 | 软中断上下文(atomic context) | 进程上下文(可以睡眠) | | 是否可睡眠 | ❌ 不可睡眠、不可调用阻塞函数 | ✅ 可以 sleep、mutex、kmalloc(GFP_KERNEL) 等 | | 并发性 | 同一个 tasklet 在全系统内永不并行执行(串行) | work 可以绑定到不同线程,并发执行 | | 调度单位 | `struct tasklet_struct` | `struct work_struct` | | 触发函数 | `tasklet_schedule()` | `schedule_work()` / `queue_work()` | | 延迟精度 | 高(软中断级响应快) | 相对较低(依赖 worker thread 调度) | | 适合频率 | 中低频事件(如网卡收包后处理) | 高频或复杂任务(如文件系统写回) | | 创建开销 | 小(静态初始化即可) | 稍大(需关联 workqueue) | | 实时性 | 高(运行在 softirq 返回时) | 较低(受进程调度影响) | --- ## 🔍 详细解释 ### 1. 执行上下文能否睡眠 #### tasklet:运行在软中断上下文 - 属于中断上下文的一部分(softirq context) - 不能调用任何可能导致睡眠的函数: ```c // ❌ 错误示例 mutex_lock(&my_mutex); // 可能引起调度 → kernel BUG kmalloc(GFP_KERNEL); // 可能回收页 → 可能 sleep msleep(1); // 明确睡眠 → 不允许 ``` #### workqueue:运行在进程上下文 - 由内核线程(`kworker/u:x` 或自定义线程)执行 - 完全支持阻塞操作: ```c // ✅ 正确使用 mutex_lock(&my_mutex); struct page *p = alloc_pages(GFP_KERNEL, 0); msleep(10); ``` 👉 因此: 如果你需要做 I/O、内存分配、锁竞争等可能阻塞的操作 → 必须用 `workqueue`。 --- ### 2. 并发控制模型 #### tasklet:天然单线程串行执行 - 每个 `tasklet_struct` 实例在整个系统中只能有一个实例在运行 - 即使多 CPU,也不会并发执行同一个 tasklet - 无需额外加锁保护共享数据(针对该 tasklet 自身) ```c void my_tasklet_fn(unsigned long data) { counter++; // 安全,不会并发 } ``` ⚠️ 缺点:无法利用多核并行处理多个请求。 #### workqueue:默认可并发 - 多个 work 可同时被多个 worker 线程处理 - 如果多个 work 修改同一数据,必须自己加锁 ```c static DEFINE_MUTEX(my_mutex); void my_work_fn(struct work_struct *work) { mutex_lock(&my_mutex); counter++; mutex_unlock(&my_mutex); } ``` ✅ 优点:可通过 `alloc_workqueue(WQ_UNBOUND)` 创建高性能并发队列。 --- ### 3. 性能表现 | 场景 | tasklet 更优? | workqueue 更优? | |------|----------------|------------------| | 响应延迟要求极高(微秒级) | ✅ 是 | ❌ 否(受调度延迟影响) | | 需要频繁触发(每秒数千次) | ✅ 是(轻量) | ⚠️ 可能造成调度风暴 | | 需要长时间运行的任务(>1ms) | ❌ 否(会阻塞 softirq) | ✅ 是(不影响中断) | | 涉及内存分配/GPIO访问/延时等 | ❌ 否 | ✅ 是 | > 💡 经验法则: > - 短小、快速、不阻塞 → 用 `tasklet` > - 复杂、耗时、需睡眠 → 用 `workqueue` --- ## 📦 使用代码示例对比 ### 示例1:使用 tasklet(适合快速处理) ```c #include <linux/interrupt.h> static struct tasklet_struct my_tasklet; void my_tasklet_fn(unsigned long data) { printk("Tasklet running on CPU %d\n", smp_processor_id()); // 快速处理,比如唤醒等待队列、标记状态 } // 初始化 static int __init init_module(void) { tasklet_init(&my_tasklet, my_tasklet_fn, 0); return 0; } // 触发(通常在中断处理中) irqreturn_t irq_handler(int irq, void *dev_id) { tasklet_schedule(&my_tasklet); // 推迟到 softirq return IRQ_HANDLED; } static void __exit cleanup_module(void) { tasklet_kill(&my_tasklet); // 等待 tasklet 结束 } ``` --- ### 示例2:使用 workqueue(适合复杂任务) ```c #include <linux/workqueue.h> static struct workqueue_struct *my_wq; static struct work_struct my_work; void my_work_fn(struct work_struct *work) { printk("Work running in process context on CPU %d\n", smp_processor_id()); // 可以安全地睡眠 msleep(10); // 可以进行复杂的 I/O 或内存操作 mutex_lock(&some_mutex); // ... do something mutex_unlock(&some_mutex); } // 工作处理函数 static void __init init_module(void) { my_wq = create_singlethread_workqueue("my_worker"); // 或者 create_workqueue() 多线程版本 INIT_WORK(&my_work, my_work_fn); } // 触发 irqreturn_t irq_handler(int irq, void *dev_id) { queue_work(my_wq, &my_work); // 加入队列,由 worker 执行 return IRQ_HANDLED; } static void __exit cleanup_module(void) { destroy_workqueue(my_wq); // 自动取消未完成 work } ``` --- ## 🎯 如何选择? | 使用场景 | 推荐机制 | |--------|---------| | 网络驱动接收数据包后的快速处理(NAPI 之前) | ✅ tasklet | | GPIO 中断后读取电平并通知用户空间 | ✅ tasklet(短小) | | 需要延时几毫秒再操作硬件 | ❌ tasklet 不行 → ✅ workqueue + msleep | | 文件系统元数据更新、日志刷盘 | ✅ workqueue | | 触摸屏中断后读取 I2C 数据(需 i2c_master_recv) | ✅ workqueue(I2C 可能睡眠) | | 高频定时采样传感器 | ✅ workqueue(避免阻塞 softirq) | --- ## ⚠️ 注意事项 - **不要在 tasklet 中做耗时操作**:会阻塞同类型的 softirq(如网络收发),导致系统卡顿。 - **慎用 `create_workqueue()`**:建议使用 `alloc_workqueue()` 控制并发绑定 CPU。 - **及时清理资源**:`tasklet_kill()` 会等待 tasklet 执行完;`flush_work()` 等待 work 完成。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值