Chinaunix首页 | 论坛 | 博客
  • 博客访问: 623923
  • 博文数量: 166
  • 博客积分: 970
  • 博客等级: 准尉
  • 技术积分: 547
  • 用 户 组: 普通用户
  • 注册时间: 2008-04-06 15:16
个人简介

Believe youself!

文章分类

全部博文(166)

文章存档

2017年(1)

2016年(5)

2015年(117)

2014年(14)

2013年(11)

2012年(5)

2010年(4)

2009年(1)

2008年(8)

我的朋友

分类: LINUX

2015-06-14 23:52:58

PCM信息运行时指针

当打开一个一个PCM子流的时候,PCM运行时实例就会分配给这个子流。这个指针可以通过substream->runtime获得。运行时指针拥有多种信息:hw_paramssw_params的配置的拷贝,缓冲区指针,mmap记录,自旋锁等等。几乎你想控制PCM的所有信息都可以在这里得到。

Struct _snd_pcm_runtime {

/*状态*/

struct snd_pcm_substream *trigger_master;

snd_timestamp_t trigger_tstamp;/*触发时间戳*/

int overrange;

snd_pcm_uframes_t avail_max;

snd_pcm_uframes_t hw_ptr_base /*缓冲区复位时的位置*/

snd_pcm_uframes_t hw_ptr_interrupt;/*中断时的位置*/

/*硬件参数*/

snd_pcm_access_t access; /*存取模式*/

snd_pcm_format_t format; /*SNDRV_PCM_FORMAT_* */

snd_pcm_subformat_t subformat; /*子格式*/

unsigned int rate; /*rate in HZ*/

unsigned int channels; /*通道*/

snd_pcm_uframe_t period_size; /*周期大小*/

unsigned int periods /*周期数*/

snd_pcm_uframes_t buffer_size; /*缓冲区大小*/

unsigned int tick_time; /*tick time滴答时间*/

snd_pcm_uframes_t min_align; /*格式对应的最小对齐*/

size_t byte_align;

unsigned int frame_bits;

unsigned int sample_bits;

unsigned int info;

unsigned int rate_num;

unsigned int rate_den;

/*软件参数*/

struct timespec tstamp_mode; /*mmap时间戳被更新*/

unsigned int sleep_min; /*睡眠的最小节拍数*/

snd_pcm_uframes_t xfer_align; /*xfer的大小需要是成倍数的*/

snd_pcm_uframes_t start_threshold;

snd_pcm_uframes_t stop_threshold;

snd_pcm_uframes_t silence_threshold;/*silence填充阀值*/

snd_pcm_uframes_t silence_size; /*silence填充大小*/

snd_pcm_uframes_t boundary;

snd_pcm_uframes_t silenced_start;

snd_pcm_uframes_t silenced_size;

snd_pcm_sync_id_t sync; /*硬件同步ID*/

/*mmap*/

volatile struct snd_pcm_mmap_status *status;

volatile struct snd_pcm_mmap_control *control;

atomic_t mmap_count;

/*/调度*/

spinlock_t lock;

wait_queue_head_t sleep;

struct timer_list tick_timer;

struct fasync_struct *fasync;

/*私有段*/

void *private_data;

void (*private_free)(struct snd_pcm_runtime *runtime);


/*硬件描述*/

struct snd_pcm_hardware hw;

struct snd_pcm_hw_constraints hw_constraints;

/*中断的回调函数*/

void (*transfer_ack_begin)(struct snd_pcm_substream *substream);

void (*transfer_ack_end)(struct snd_pcm_substream *substream);

/*定时器*/

unsigned int timer_resolution; /*timer resolution*/

/*DMA*/

unsigned char *dma_area;

dma_addr_t dma_addr; /*总线物理地址*/

size_t dma_bytes; /*DMA区域大小*/

struct snd_dma_buffer *dma_buffer_p; /*分配的缓冲区*/

#if defined(CONFIG_SND_PCM_OSS) || defined(CONFIG_SND_PCM_OSS_MODULE)

struct snd_pcm_oss_runtime oss;

#endif

};

snd_pcm_runtime 对于大部分的驱动程序操作集的函数来说是只读的。仅仅PCM中间层可以改变/更新这些信息。但是硬件描述,中断响应,DMA缓冲区信息和私有数据是例外的。此外,假如你采用标准的内存分配函数snd_pcm_lib_malloc_pages(),就不再需要自己设定DMA缓冲区信息了。

下面几章,会对上面记录的现实进行解释。

硬件描述

硬件描述(struct snd_pcm_hardware)包含了基本硬件配置的定义。如前面所述,你需要在open的时候对它们进行定义。注意runtime实例拥有这个描述符的拷贝而不是已经存在的描述符的指针。换句话说,在open函数中,你可以根据需要修改描述符的拷贝。例如,假如在一些声卡上最大的通道数是1,你仍然可以使用相同的硬件描述符,同时在后面你可以改变最大通道数。

Struct snd_pcm_runtime *runtime = substream->runtime;

....

runtime->hw = snd_mychip_playback_hw; /*通用定义*/

if (chip->model == VERY_OLD_ONE)

runtime->hw.channels_max = 1;


典型的硬件描述如下:

static struct snd_pcm_hardware snd_mychip_playback_hw = {

.info = (SNDRV_PCM_INFO_MMAP |

SNDRV_PCM_INFO_INTERLEAVED |

SNDRV_PCM_INFO_BLOCK_TRANSFER |

SNDRV_PCM_INFO_MMAP_VALID),

.formats = SNDRV_PCM_FORMAT_S16_LE,

.rates = SNDRV_PCM_RATE_8000_48000

.rate_min = 8000,

.rate_max = 48000,

.channels_min = 2,

.channels_max = 2,

.buffer_bytes_max = 32768,

.period_bytes_min = 4096,

.period_bytes_max = 32768,

.periods_min = 1,

.periods_max = 1024,

};

info字段包含pcm的类型和能力。位标志在中定义,如:SNDRV_PCM_INFO_XXX。这里,你必须定义mmap是否支持和支持哪种interleaved格式。当支持mmap的时候,应当设定SNDRV_PCM_INFO_MMAP。当硬件支持interleavedno-interleaved格式的时候,要设定SNDRV_PCM_INFO_INTERLEAVEDSNDRV_PCM_INFO_NONINTERLEAVED标志位。假如两者都支持,你也可以都设定。

如上面的例子,MMAP_VALIDBLOCK_TRANSFER都是针对OSS mmap模式,通常情况它们都要设定。当然,MMAP_VALID仅仅当mmap真正被支持的时候才会被设定。

其他一些标志位是SNDRV_PCM_INFO_PAUSESNDRV_PCM_INFO_RESUMESNDRV_PCM_INFO_PAUSE标志位意思是pcm支持“暂停”操作,SNDRV_PCM_INFO_RESUME表示是pcm支持“挂起/恢复”操作。假如PAUSE标志位被设定,trigger函数就必须执行一个对应的(暂停 按下/释放)命令。就算没有RESUME标志位,也可以被定义挂起/恢复触发命令。

更详细的部分请参考“电源管理”一章。

PCM子系统能被同步(如:播放流和录音流的开始/结束的同步)的时候,你可以设定SNDRV_PCM_INFO_SYNC_START标志位。在这种情况下,你必须在trigger函数中检查PCM子流链。下面的章节会想笑介绍这个部分。

formats字段包含了支持格式的标志位(SNDRV_PCM_FMTBIT_XXX)。假如硬件支持超过一个的格式,需要对位标志位进行“或”运算。上面的例子就是支持16bit有符号的小端格式。

rates字段包含了支持的采样率(SNDRV_PCM_RATE_XXX)。当声卡支持多种采样率的时候,应该附加一个CONTINUOUS标志。已经预先定义的典型的采样率,假如你的声卡支持不常用的采样率,你需要加入一个KNOT标志,同时手动的对硬件进行控制(稍后解释)。

rate_minrate_max定义了最小和最大的采样率。应该和采样率相对应。

channel_minchannel_max定义了最大和最小的通道,以前可能你已看到。

buffer_bytes_max定义了以字节为单位的最大的缓冲区大小。这里没有buffer_bytes_min字段,因为它可以通过最小的period大小和最小的period数量计算得出。同时,period_bytes_min和定义的最小和最大的periodperiods_maxperiods_min定义了最大和最小的periods

period信息和OSS中的fragment相对应。period定义了PCM中断产生的周期。这个周期非常依赖硬件。一般来说,一个更短的周期会提供更多的中断和更多的控制。如在录音中,周期大小定义了输入延迟,另外,整个缓存区大小也定义了播放的输出延迟。

字段fifo_size。这个主要是和硬件的FIFO大小有关,但是目前驱动中或alsa-lib中都没有使用。所以你可以忽略这个字段。


PCM配置

OK,让我们再次回到PCM运行时记录。最经常涉及的运行时实例中的记录就是PCM配置了。PCM可以让应用程序通过alsa-lib发送hw_params来配置。有很多字段都是从hw_paramssw_params结构中拷贝过来的。例如:format保持了应用程序选择的格式类型,这个字段包含了enumSNDRV_PCM_FORMAT_XXX

其中要注意的一个就是,配置的bufferperiod大小被放在运行时记录的“frame”中。在ALSA里,1frame=channel*samples-size。为了在帧和字节之间转换,你可以用下面的函数,frames_to_bytes()bytes_to_frames()

period_bytes = frames_to_bytes(runtime,runtime->period_size);

同样,许多的软件参数(sw_params)也存放在frames字段里面。请检查这个字段的类型。snd_pcm_uframes_t是作为表示frames的无符号整数,而snd_pcm_sframes_t是作为表示frames的有符号整数。


DMA缓冲区信息

DMA缓冲区通过下面4个字段定义,dma_area,dma_addr,dma_bytes,dma_private。其中dma_area是缓冲区的指针(逻辑地址)。可以通过memcopy来向这个指针来操作数据。dma_addr是缓冲区的物理地址。这个字段仅仅当缓冲区是线性缓存的时候才要特别说明。dma_bytes是缓冲区的 大小。dma_private是被ALSADMA管理用到的。

如果采用ALSA的标准内存分配函数snd_pcm_lib_mallock_pages()分配内存,那些字段会被ALSA的中间层设定,你不能自己改变他们,可以读取而不能写入。而如果你想自己分配内存,你就需要在hw_params回调里面自己管理它们。当内存被mmap之后,你至少要设定dma_bytesdma_area。但是如果你的驱动不支持mmap,这些字段就不必一定设定.dma_addr也不是强制的,你也可以根据灵活来用dma_private


运行状态

可以通过runtime->status来获得运行状态。它是一个指向snd_pcm_mmap_status记录的指针。例如,可以通过runtime->status->hw_ptr来得到当前DMA硬件指针。

可以通过runtime->control来查看DMA程序的指针,它是指向snd_pcm_mmap_control记录。但是,不推荐直接存取这些数据。


私有数据

可以为子流分配一个记录,让它保存在runtime->private_data里面。通常可以在open函数中做。不要和pcm->private_data混搅了,pcm->private_data主要是在创建PCM的时候指向chip实例,而runtime->private_data是在PCM open的时候指向一个动态数据。

Struct int snd_xxx_open(struct snd_pcm_substream *substream)

{

struct my_pcm_data *data;

data = kmalloc(sizeof(*data),GFP_KERNEL);

substream->runtime->private_data = data;

....

}

上述分配的对象要在close函数中释放。


中断函数

transfer_ack_begin()transfer_ack_end()将会在snd_pcm_period_elapsed()的开始和结束。

操作函数

现在让我来详细介绍每个pcm的操作函数吧(ops)。通常每个回调函数成功的话返回0,出错的话返回一个带错误码的负值,如:-EINVAL

每个函数至少要有一个snd_pcm_substream指针变量。主要是为了从给定的子流实例中得到chip记录,你可以采用下面的宏。

Int xxx(){

struct mychip *chip = snd_pcm_substream_chip(substream);

....

}

open函数

static int snd_xxx_open(struct snd_pcm_substream *substream);

当打开一个pcm子流的时候调用。

在这里,你至少要初始化runtime->hw记录。典型应用如下:

static int snd_xxx_open(struct snd_pcm_substream *substream)

{

struct mychip *chip = snd_pcm_substream_chip(substream);

struct snd_pcm_runtime *runtime = substream->runtime;

runtime->hw = snd_mychip_playback_hw;

return 0;

}

其中snd_mychip_playback_hw是预先定义的硬件描述。

close函数

static int snd_xxx_close(struct snd_pcm_substream *substream)

显然这是在pcm子流被关闭的时候调用。

所有在open的时候被分配的pcm子流的私有的实例都应该在这里被释放。

Static int snd_xxx_close(struct snd_pcm_substream *substream)

{

...

kfree(substream->runtime->private_data);

...

}

ioctl函数

这个函数主要是完成一些pcm ioctl的特殊功能。但是通常你可以采用通用的ioctl函数snd_pcm_lib_ioctl

hw_params函数

static int snd_xxx_hw_params(struct snd_pcm_substream *substream,

struct snd_pcm_substream *hw_params);

这个函数和hw_free函数仅仅在ALSA0.9.X版本出现。

pcm子流中已经定义了缓冲区大小,period大小,格式等的时候,应用程序可以通过这个函数来设定硬件参数。

很多的硬件设定都要在这里完成,包括分配内存。

设定的参数可以通过params_xxx()宏得到。对于分配内存,可以采用下面一个有用的函数,

snd_pcm_lib_malloc_pages(substream,params_buffer_bytes(hw_params));

snd_pcm_lib_malloc_pages()仅仅当DMA缓冲区已经被预分配之后才可以用。参考“缓存区类型”一章获得更详细的细节。

注意这个和prepare函数会在初始化当中多次被调用。例如,OSSemulation可能在每次通过ioctl改变的时候都要调用这些函数。

因而,千万不要对一个相同的内存分配多次,可能会导致内存的漏洞!而上面的几个函数是可以被多次调用的,如果它已经被分配了,它会先自动释放之前的内存。

另外一个需要注意的是,这些函数都不是原子的(可以被调到)。这个是非常重要的,因为trigger函数是原子的(不可被调度)。因此,mutex和其他一些和调度相关的功能函数在trigger里面都不需要了。具体参看“原子操作”一节。

hw_free函数

static int snd_xxx_hw_free(struct snd_pcm_substream *substream);

这个函数可以是否通过hw_params分配的资源。例如:通过如下函数释放那些通过snd_pcm_lib_malloc_pages()申请的缓存。

snd_pcm_lib_free_pages(substream)

这个函数要在close调用之前被调用。同时,它也可以被多次调用。它也会知道资源是否已经被分配。


Prepare函数

static int snd_xxx_prepare(struct snd_pcm_substream *substream);

pcm“准备好了”的时候调用这个函数。可以在这里设定格式类型,采样率等等。和hw_params不同的是,每次调用snd_pcm_prepare()的时候都要调用prepare函数。

注意最近的版本prepare变成了非原子操作的了。这个函数中,你要做一些调度安全性策略。

在下面的函数中,你会涉及到runtime记录的值(substream->runtime)。例如:得到当前的采样率,格式或声道,可以分别存取runtime->rateruntime->format,runtime->channels。分配的内存的地址放到runtime->dma_area中,内存和period大小分别保存在runtime->buffer_sizeruntime->period_size中。

要注意在每次设定的时候都有可能多次调用这些函数。

trigger函数

static int snd_xxx_trigger(struct snd_pcm_substream *substream, int cmd);

pcm开始,停止或暂停的时候都会调用这个函数。

具体执行那个操作主要是根据第二个参数,在中声明了SNDRV_PCM_TRIGGER_XXX。最少STARTSTOP的命令必须定义的。

switch(cmd){

case SNDRV_PCM_TRIGGER_START:

//启动PCM引擎

break;

case SNDRV_PCM_TRIGGER_STOP:

//停止PCM引擎

break;

default:

break;

}

pcm支持暂停操作(在hareware表里面有这个),也必须处理PAUSE_PAUSEPAUSE_RELEASE命令。前者是暂停命令,后者是重新播放命令。

假如pcm支持挂起/恢复操作,不管是全部或部分的挂起/恢复支持,都要处理SUSPENDRESUME命令。这些命令主要是在电源状态改变的时候需要,通常它们和STOPSTART命令放到一起。具体参看“电源管理”一章。

如前面提到的,这个操作上原子的。不要在调用这些函数的时候进入睡眠。而trigger函数要尽量小,甚至仅仅触发DMA。另外的工作如初始化hw_paramsprepare应该在之前做好。

pointer函数

static snd_pcm_uframes_t snd_xxx_pointer(struct snd_pcm_substream *substream);

PCM中间层通过调用这个函数来获得缓冲区中的硬件位置。返回值需要以为frames为单位(在ALSA0.5.X是以字节为单位的),范围从0buffer_size-1

一般情况下,在中断程序中,调用snd_pcm_period_elapsed()的时候,在pcm中间层在在更新buffer的程序中调用它。然后,pcm中间层会更新指针位置和计算可用的空间,然后唤醒那些等待的线程。

这个函数也是原子的。


Copysilence函数

这些函数不是必须的,同时在大部分的情况下是被忽略的。这些函数主要应用在硬件内存不在正常的内存空间的时候。一些声卡有一些没有被影射的自己的内存。在这种情况下,你必须把内存传到硬件的内存空间去。或者,缓冲区在物理内存和虚拟内存中都是不连续的时候就需要它们了。

假如定义了copysilence,就可以做copyset-silence的操作了。更详细的描述请参考“缓冲区和内存管理”一章。


Ack函数

这个函数也不是必须的。当在读写操作的时候更新appl_ptr的时候会调用它。一些类似于emu10k1-fxcs46xx的驱动程序会为了内部缓存来跟踪当前的appl_ptr,这个函数仅仅对于这个情况才会被用到。

这个函数也是原子的。

page函数

这个函数也不是必须的。这个函数主要对那些不连续的缓存区 。mmap会调用这个函数得到内存页的地址。后续章节“缓冲区和内存管理”会有一些例子介绍。

中断处理

下面的pcm工作就是PCM中断处理了。声卡驱动中的PCM中断处理的作用主要是更新缓存的位置,然后在缓冲位置超过预先定义的period大小的时候通知PCM中间层。可以通过调用snd_pcm_period_elapsed()来通知。

声卡有如下几种产生中断。

period(周期)中断

这是一个很常见的类型:硬件会产生周期中断。每次中断都会调用snd_pcm_period_elapsed()

snd_pcm_period_elapsed()的参数是substream的指针。因为,需要从chip实例中得到substream的指针。例如:在chip记录中定义一个substream字段来保持当前运行的substream指针,在open函数中要设定这个字段而在close函数中要复位这个字段。

假如在中断处理函数中获得了一个自旋锁,如果其他pcm也会调用这个锁,那你必须要在调用snd_pcm_period_elapsed()之前释放这个锁。

典型代码如下:

Example5-3.中断函数处理#1

struct irqreturn_t snd_mychip_interrupt(int irq, void *dev_id)

{

struct mychip *chip = dev_id;

spin_lock(&chip->lock);

....

if (pcm_irq_invoked(chip)){

spin_unlock(&chip->lock);

snd_pcm_period_elapsed(chip->substream);

spin_lock(&chip->lock);

//如果需要的话,可以响应中断

}

....

spin_unlock(&chip->lock);

return IRQ_HANDLED;

}

高频率时钟中断

当硬件不再产生一个period(周期)中断的时候,就需要一个固定周期的timer中断了(例如 es1968ymfpci驱动)。这时候,在每次中断都要检查当前硬件位置,同时计算已经累积的采样的长度。当长度超过period长度时候,需要调用 snd_pcm_period_elapsed()同时复位计数值。

典型代码如下:

Example5-4.中断函数处理#2

static irqreturn_t snd_mychip_interrupt(int irq, void *dev_id)

{

struct mychip *chip = dev_id;

spin_lock(&chip->lock);

....

if (pcm_irq_invoked(chip)){

unsigned int last_ptr, size;

/*得到当前的硬件指针(帧为单位)*/

last_ptr = get_hw_ptr(chip);

/*计算自从上次更新之后又处理的帧*/

if (last_ptr < chip->last_ptr)

{

size = runtime->buffer_size + last_ptr - chip->last_ptr

}else

{

size = last_ptr – chip->chip->last_ptr;

}

//保持上次更新的位置

chip->last_ptr = last_ptr;

/*累加size计数器*/

      chip->size += size;

/*超过period的边界?*/

if (chip->size >= runtime->period_size){

/*重置size计数器*/

chip->size %= runtime->period_size;

spin_unlock(&chip->lock);

snd_pcm_period_elapsed(substream);

spin_lock(&chip->lock);

}

//需要的话,要相应中断

}

....

spin_unlock(&chip->lock);

return IRQ_HANDLED;

}


在调用snd_pcm_period_elapsed()的时候

就算超过一个period的时间已经过去,你也不需要多次调用snd_pcm_period_elapsed(),因为pcm层会自己检查当前的硬件指针和上次和更新的状态。


原子操作

在内核编程的时候,一个非常重要(又很难dubug)的问题就是竞争条件。Linux内核中,一般是通过自旋锁和信号量来解决的。通常来说,假如竞争发生在中断函数中,中断函数要具有原子性,你必须采用自旋锁来包含临界资源。假如不是发生在中断部分,同时比较耗时,可以采用信号量。

如我们看到的,pcm的操作函数一些是原子的而一些不是。例如:hw_params函数不是原子的,而trigger函数是原子的。这意味着,后者调用的时候,PCM中间层已经拥有了锁。

在这些函数中申请的自旋锁和信号量要做个计算。

在这些原子的函数中,不能那些可能调用任务切换和进入睡眠的函数。其中信号量和互斥体可能会进入睡眠,因此,在原子操作的函数中(如:trigger函数)不能调用它们。如果在这种函数中调用delay,可以用udelay(),mdelay()


约束

假如你的声卡支持不常用的采样率,或仅仅支持固定的采样率,就需要设定一个约束条件。

例如:为了把采样率限制在一些支持的几种之中,就需要用到函数snd_pcm_hw_constraint_list()。需要在open函数中调用它。

Example5-5.硬件约束示例

static unsigned int rates[] =

{4000,10000,22050,44100};

static unsigned snd_pcm_hw_constraint_list constraints_rates = {

.count = ARRAY_SIZE(rates),

.list = rates,

.mask = 0,

};

static int snd_mychip_pcm_open(struct snd_pcm_substream *substream)

{

int err;

....

err = snd_pcm_hw_constraint_list(substream->runtime,0,

SNDRV_PCM_HW_PARAM_RATE,

&constraints_rates);

if (err < 0)

return err;

....

}


有多种不同的约束。请参考sound/pcm.h中的完整的列表。甚至可以定义自己的约束条件。例如,假如my_chip可以管理一个单通道的子流,格式是S16_LE,另外,它还支持snd_pcm_hareware中设定的格式(或其他constraint_list)。可以设定一个:

Example5-6.为通道设定一个硬件规则

static int hw_rule_format_by_channels(struct snd_pcm_hw_params *params,

struct snd_pcm_hw_rule *rule)

{

struct snd_interval *c = hw_params_interval(params,

SNDRV_PCM_HW_PARAM_CHANNELS);

struct snd_mask *f = hw_param_mask(params,SNDRV_PCM_HW_PARAM_FORMAT);

struct snd_mask fmt;

snd_mask_any(&fmt); /*初始化结构体*/

if (c->min < 2){

fmt.bits[0] &= SNDRV_PCM_FMTBIT_S16_LE;

return snd_mask_refine(f, &fmt);

}

return 0;

}

之后,需要把上述函数加入到你的规则当中去:

snd_pcm_hw_rule_add(substream->runtime, 0, SNDRV_PCM_HW_PARAM_CHANNELS,

hw_rule_channels_by_format,0, SNDRV_PCM_HW_PARAM_FORMAT,

-1);

当应用程序设定声道数量的时候会调用上面的规则函数。但是应用程序可以在设定声道数之前设定格式。所以也需要设定对应的规则。

Example5-7.为通道设定一个硬件规则

static int hw_rule_format_by_format(struct snd_pcm_hw_params *params,

struct snd_pcm_hw_rule *rule)

{

struct snd_interval *c = hw_param_interval(params,

SNDRV_PCM_HW_PARAM_CHANNELS);

struct snd_mask *f = hw_param_mask(params, SNDRV_PCM_HW_PARAM_FORMAT);

struct snd_interval ch;

snd_interval_any(&ch);

if (f->bits[0] == SNDRV_PCM_FORMAT_S16_LE){

ch.min = ch.max = 1;

ch.integer = 1;

return snd_interval_refine(c, &ch);

}

return 0;

}

open函数中:

snd_pcm_hw_rule_add(substream->runtime, 0, SNDRV_PCM_HW_PARAM_FORMAT,

hw_rule_channels_by_format,0, SNDRV_PCM_HW_PARAM_CHANNELS,

-1);

这里我们不会更详细的描述,我仍然想说“直接看源码吧”。

阅读(2057) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~