本文将对OSS(Open Sound System)的开发进行一些深入的讨论,具体的内容包括:播放音频的时延问题,并定量的对不同的缓冲区配置进行分析;非阻塞write;应用程序对驱动程序中DMA buffer的直接访问。这些是在深入OSS的开发过程中,开发者会遇到的一些实际问题,比如,在开发基于Linux平台的游戏程序时,就必须要考虑到如何降低播放音频的时延,使得在需要的时候,能够将游戏的音效尽快地播放出来,并与画面的进行保持同步。
在讨论这些方面时,除了从使用的角度介绍以外,还结合具体的驱动实现,分析这些功能对应的内部原理,以加深读者的理解。
为了在阅读文章时有一个共同的认识,本文首先简单介绍了OSS的一些基本内容。 关于OSS编程更详细的介绍,可以参考IBM DeveloperWork以前的
相关文献。
1.OSS简介
OSS的层次结构非常简单,应用程序通过API(定义于
)访问OSS driver,OSS driver控制声卡。如下图所示:
声卡中主要有两个基本装置:Mixer和CODEC(ADC/DAC)。Mixer用来控制输入音量的大小,对应的设备文件为/dev/mixer; CODEC用来实现录音(模拟信号转变为数字信号)和播放声音(数字信号转变为模拟信号)的功能,对应的设备文件为/dev/dsp。
开发OSS应用程序的一般流程是:
1)包含OSS头文件:#include
2)打开设备文件,返回文件描述符
3)使用ioctl设置设备的参数,控制设备的特性
4)对于录音,从设备读(read)
5)对于播放,向设备写(write)
6)关闭打开的设备
回页首
2.缓冲区设置的性能分析
在设置驱动内部的缓冲区时,存在一个矛盾:在声卡驱动程序中,为了防止抖动的出现,保证播放的性能,设置了内部缓冲区-DMA buffer。在播放时,应用程序通过驱动程序首先将音频数据从应用程序缓冲区-APP buffer,写入到DMA buffer。接着,由DMA控制器把DMA buffer中的音频数据发送到DAC(Digital-Analog Converter)。某些时刻CPU非常的繁忙,比如正在从磁盘读入数据,或者正在重画屏幕,没有时间向DMA buffer放入新的音频数据。DAC由于没有输入新的音频数据,导致声音播放的间断,这就出现了声音的抖动现象。此时,需要将DMA
buffer设置的足够大,使得DAC始终有数据播放。但是,DMA buffer的增大使得每次从APP buffer拷贝的时间也变长,导致了更大的播放延迟。这对于那些延迟敏感的应用场合,如与用户有交互的音频应用程序,就会出现问题。
对于这个矛盾,可以从两个不同的方面分别着手解决。驱动程序采用多缓冲(Multi-buffering)的方式,即将大的DMA buffer分割成多个小的缓冲区,称之为fragment,它们的大小相同。驱动程序开始时只需等待两个fragment满了就开始播放。这样可以通过增加fragment的个数来增加缓冲区的大小,但同时每个fragment被限制在合适的大小,也不影响时延。音频驱动程序中的多缓冲机制一般会利用底层DMA控制器的scatter-gather功能。
另一方面,应用程序也可指导驱动程序选择合适大小的缓冲区,使得在没有抖动的情况下,时延尽可能的小。特别的,应用程序将驱动程序中的缓冲通过mmap映射到自己地址空间后,会以自己的方式来处理这些缓冲区(与驱动程序的不一定一致),这时应用程序往往会先根据自己的需要设置驱动程序中内部缓冲区的大小。
在OSS的ioctl接口中,SNDCTL_DSP_SETFRAGMENT就是用来设置驱动程序内部缓冲区大小。具体的用法如下:
int param;
param = ( 0x0004 << 16) + 0x000a;
if (ioctl(audio_fd, SNDCTL_DSP_SETFRAGMENT, ¶m) == -1) {
...error handling...
}
参数param由两部分组成:低16位为 fragment的大小,此处0x000a表示fragment大小为2^0xa,即1024字节;高16位为fragment的数量,此处为 0x0004,即4个fragement。设置好fragment参数后,通过ioctl的SNDCTL_DSP_SETFRAGMENT命令调整驱动程序中的缓冲区。
为了给音频程序的开发者展示缓冲区配置对播放效果的影响,我们将对缓冲区配置与播放性能的关系进行测试。下面首先介绍测试的环境,包括测试方法的原理和测试结果的含义;接着针对两种情况进行测试,并解释测试的结果。
测试环境
测试是在PC机上进行的,具体的测试环境参见下表。
项目
参数
CPU
PIII 800
内存
256M SDRAM
硬盘
ST 80G UDMA
显卡
TNT2 m64 16M
声卡
主板集成(工作在44.1KHz,立体声,16bit的模式)
内核
Linux kernel 2.4.20(Redhat 9.0)
测试软件(latencytest)由两部分组成:音频播放测试程序、系统运行负载模拟程序。(注:latencytest软件主要目的是测试内核的时延,但这里作为对不同缓冲配置进行比较的工具。)
音频播放测试程序的工作流程见下面的代码。为了保证音频播放在调度上的优先性,音频播放测试程序使用SCHED_FIFO调度策略(通过sched_setscheduler())。
while(1)
{
time1=my_gettime();
通过空循环消耗一定的CPU时间
time2=my_gettime();
write(audio_fd,playbuffer,fragmentsize);
time3=my_gettime();
}
my_gettime返回当前的时刻,在每个操作的开始和结束分别记录下时间,就可以得到操作所花费的时间。audio_fd为打开音频设备的文件描述符,playbuffer是应用程序中存放音频数据的缓冲区,也就是APP buffer,fragmentsize为一个fragment的大小,write操作控制向驱动写入一个fragment。空循环用来模拟在播放音频时的CPU运算负载,典型的例子是合成器(synthesizer)实时产生波形后,再进行播放(write)。空循环消耗的时间长度设置为一个 fragment播放时延的80%。
相关指标的计算方法如下:
- 1)一个fragment的播放时延(fragm.latency) = fragment大小/(频率*2*2)。以fragment大小为512字节和以上的测试环境为例,一个fragment时延 = 512/(44100*2*2) = 2.90ms[44100表示44.1KHz的采样频率,第一个2表示立体声的两个声道,第二个2表示16bit为2个字节]。
- 2) 一个fragment的传输时延 = 将一个fragment从APP buffer拷贝到DMA buffer的时延。
- 3) time3-time1 = 一次循环持续的时间 = 空循环消耗的CPU时间 + 一个fragment的传输时延。
- 4) time2-time1 = 空循环消耗的实际CPU时间(cpu latency)。
为了模拟真实的系统运行情况,在测试程序播放音频数据的同时,还运行了一个系统负载。一共设置5种负载场景,按顺序分别是:
- 1) 高强度的图形输出(使用x11perf来模拟大量的BitBlt操作)
- 2) 高强度对/proc文件系统的访问(使用top,更新频率为0.01秒)
- 3) 高强度的磁盘写(向硬盘写一个大文件)
- 4) 高强度的磁盘拷贝(将一个文件拷贝到另一个地方)
- 5) 高强度的磁盘读(从硬盘读一个大文件)
针对不同的系统负载场景,测试分别给出了各自的结果。测试结果以图形的形式表示,测试结果中图形的含义留待性能分析时再行解释。
性能分析
下面,我们分别对两种缓冲区的配置进行性能比较,
为了看懂测试结果,需要了解测试结果图形中各种标识的含义:
- 1) 红线:全部缓冲区的播放时延。全部缓冲区播放时延 = 一个fragment时延 x fragment的个数。对于测试的第一种情况,全部缓冲区时延 = 2.90ms x 2 = 5.8ms。
- 2) 白线:实际的调度时延,即一次循环的时间(time3-time1)。如果白线越过了红线,则说明所有的缓冲区中音频数据播放结束后,应用程序仍然没有来得及将新的数据放入到缓冲区中,此时会出现声音的丢失,同时overruns相应的增加1。
- 3) 绿线:CPU执行空循环的时间(即前面的time2-time1)。绿线的标称值为fragm.latency x 80%。由于播放进程使用SCHED_FIFO调度策略,所以如果绿线所代表的时间变大,则说明出现了总线竞争,或者是系统长时间的处于内核中。
- 4) 黄线:一个fragment播放时延。白线应该接近于黄线。
- 5) 白 {MOD}的between +/-1ms:实际的调度时延落入到fragm.latency +/-1ms范围的比例。
- 6) 白 {MOD}的between +/-2ms:实际的调度时延落入到fragm.latency +/-2ms范围的比例。
- 7) 绿 {MOD}的between +/-0.2ms:CPU的空循环时延波动+/-0.2ms范围的比例(即落入到标称值+/-0.2ms范围的比例)。
- 8) 绿 {MOD}的between +/-0.1ms:CPU的空循环时延波动+/-0.1ms范围的比例(即落入到标称值+/-0.1ms范围的比例)。
第一种情况的缓冲区很小,每个fragment只有512字节,总共的缓冲区大小为2 x 512 = 1024字节。1024字节只能播放5.8ms。根据OSS的说明,由于Unix是一个多任务的操作系统,有多个进程共享CPU,播放程序必须要保证选择的缓冲区配置要提供足够的大小,使得当CPU被其它进程使用时(此时不能继续向声卡传送新的音频数据),不至于出现欠载的现象。欠载是指应用程序提供音频数据的速度跟不上声卡播放的速度,这时播放就会出现暂停或滴答声。因此,不推荐使用fragment大小小于256字节的设置。从测试结果中看到,不管使用那种系统负载,都会出现欠载的现象,特别是在写硬盘的情况下,一共发生了14次欠载(overruns
= 14)。
当然,对于那些实时性要求高的音频播放程序,希望使用较小的缓冲区,因为只有这样才能保证较小的时延。在上面的测试结果我们看到了欠载的现象,但是,这并不完全是缓冲区过小所导致的。实际上,由于Linux内核是不可抢占的,所以无法确知Linux在内核中停留的时间,因此也就无法保证以确定的速度调度某个进程,即使现在播放程序使用了SCHED_FIFO调度策略。从这个角度来说,多媒体应用(如音频播放)对操作系统内核提出了更高的要求。在目前 Linux内核的情况下,较小的调度时延可以通过一些专门的内核补丁(low-latency
patch)达到。不过我们相信Linux2.6新内核会有更好的表现。
第二种情况的缓冲区要大得多,总共的缓冲区大小为4 x 2048 = 8192字节。8192字节可以播放0.046秒。从测试的图形来看,结果比较理想,即使在系统负载较重的情况,仍然能够基本保证播放时延的要求,而且没有出现一次欠载的现象。
当然,并不是说缓冲区越大越好,如果继续选择更大的缓冲区,将会产生比较大的时延,对于实时性要求比较高的音频流来说,是不能接受的。从测试结果中可以看到,第二种配置的时延抖动比第一种配置要大得多。不过,在一般情况下,驱动程序会根据硬件的情况,选择一个缺省的缓冲区配置,播放程序通常不需要修改驱动程序的缓冲区配置,而可以获得较好的播放效果。
回页首
3.非阻塞写(non-blocking write)
如果播放程序写入的速度超过了DAC的播放速度,DMA buffer就会充满了音频数据。应用程序调用write时就会因为没有空闲的DMA buffer而被阻塞,直到DMA buffer出现空闲为止。此时,从某种程度来说,应用程序的推进速度依赖于播放的速度,不同的播放速度就会产生不同的推进速度。因此,有时我们不希望 write被阻塞,这就需要我们能够知道DMA buffer的使用情况。
for (;;) {
audio_buf_info info;
/* Ask OSS if there is any free space in the buffer. */
if (ioctl(dsp,SNDCTL_DSP_GETOSPACE,&info) != 0) {
perror("Unable to query buffer space");
close(dsp);
return 1;
};
/* Any empty fragments? */
if (info.fragments > 0) break;
/* Not enough free space in the buffer. Waste time. */
usleep(100);
};
以上的代码不停的查询驱动程序中是否有空的 fragment(SNDCTL_DSP_GETOSPACE),如果没有,则进入睡眠(usleep(100)),此时应用程序做其它的事情,比如更新画面,网络传输等。如果有空闲的fragment(info.fragments > 0),则退出循环,接着就可以进行非阻塞的write了。
回页首
4.DMA buffer的直接访问(mmap)
除了依赖于操作系统内核提供更好的调度性能,音频播放应用程序也可以采用一些技术以提高音频播放的实时性。绕过APP buffer,直接访问DMA buffer的mmap方法就是其中之一。
我们知道,将音频数据输出到音频设备通常使用系统调用write,但是这会带来性能上的损失,因为要进行一次从用户空间到内核空间的缓冲区拷贝。这时,可以考虑利用mmap系统调用,获得直接访问DMA buffer的能力。DMA控制器不停的扫描DMA buffer,将数据发送到DAC。这有点类似于显卡对显存的操作,大家都知道,GUI可以通过mmap将framebuffer(显存)映射到自己的地址空间,然后直接操纵显存。这里的DMA buffer就是声卡的framebuffer。
理解mmap方法的最好方法是通过实际的例子,
代码1(list1.c)。
代码中有详细的注释,这里只给出一些说明。
PlayerDMA函数的参数samples指向存放音频数据的缓冲,rate/bits/channels分别说明音频数据的采样速率、每次采样的位数、声道数。
在打开/dev/dsp以后,根据/rate/bits/channels参数的要求配置驱动程序。需要注意的是,这些要求并一定能得到满足,驱动程序要根据自己的情况选择,因此在配置后,需要再次查询,获取驱动程序真正使用的参数值。
在使用mmap之前,要查看驱动程序是否支持这种模式(SNDCTL_DSP_GETCAPS)。使用SNDCTL_DSP_GETOSPACE得知驱动选择的framgment大小和个数,就可以计算出全部DMA buffer的大小dmabuffer_size。
mmap将dmabuffer_size大小的DMA buffer映射到调用进程的地址空间,DMA buffer在应用进程的起始地址为dmabuffer。以后就可以直接使用指针dmabuffer访问DMA buffer了。这里需要对mmap中的参数做些解释。
音频驱动程序针对播放和录音分别有各自的缓冲区,mmap不能同时映射这两组缓冲,具体选择映射哪个缓冲取决于mmap的prot参数。PROT_READ 选择输入(录音)缓冲,PROT_WRITE选择输出(播放)缓冲,代码中使用了PROT_WRITE|PROT_READ,也是选择输出缓冲。(这是 BSD系统的要求,如果只有PROT_WRITE,那么每次对缓冲的访问都会出现segmentation/bus error)。
一旦DMA buffer被mmap后,就不能再通过read/write接口来控制驱动程序了。只能通过SNDCTL_DSP_SETTRIGGER打开DAC的使能位,当然,先要关闭使能位。
DMA 一旦启动后,就会周而复始的扫描DMA buffer。当然我们总是希望提前为DMA准备好新的数据,使得DMA的播放始终连续。因此,PlayerDMA函数将mmap后的DMA buffer分割成前后两块,中间设置一个界限。当DMA扫描前面一块时,就填充后面一块。一旦DMA越过了界限,就去填充前面一块。
使用mmap的问题是,不是所有的声卡驱动程序都支持mmap方式。因此,在出现不兼容的情况下,应用程序要能够转而去使用传统的方式。
最后,为了能深入的理解mmap的实现原理,我们以某种声卡驱动程序为例,介绍了其内部mmap函数时具体实现。
代码2(list2.c)
audio_mmap ()是实现mmap接口的函数,它首先根据mmap调用的prot参数(vma->vm_flags),选择合适的缓冲(输入还是输出);vma- >vm_end - vma->vm_start为需要映射到应用进程地址空间的大小,必须和DMA buffer的大小(s->fragsize * s->nbfrags)一致;如果DMA buffer还没有建立,则调用audio_setup_buf(s)建立;接着对所有的fragment,从映射起始地址开始(vma-> vm_start),建立实际物理地址与映射的虚拟地址之间的对应关系(remap_page_range)。最后设置mmap标志(s->
mapped = 1)。
回页首
5.结束语
当然,除了上面所讨论的问题以外,音频应用的开发还有很多实际的问题需要去面对,比如多路音频流的合并,各种音频文件格式的打开等等。
OSS 音频接口存在于Linux内核中许多年了,由于在体系结构上有许多的局限性,在Linux 2.6内核中引入了一种全新的音频体系和接口——ALSA(Advanced Linux Sound Architecture),它提供了很多比OSS更好的特性,包括完全的thread-safe和SMP-safe,模块化的设计,支持多个声卡等等。为了保持和OSS接口的兼容性,ALSA还提供了OSS的仿真接口,使得那些为OSS接口开发的大量应用程序仍然能够在新的ALSA体系下正常的工作。
参考资料
- Open Sound System Programmer's Guide,
http://www.opensound.com
- John R. Hall, Programming Linux Games, Loki Software, Inc
- latencytest-0.42-png.tar.gz,
http://www.gardena.net/benno/linux/audio/
关于作者
汤凯,通过 tangk73@hotmail.com可以跟他联系。
http://blog.gkong.com/more.asp?name=barongeng&id=34051