本文將對OSS(Open Sound System)的開發進行一些深入的討論,具體的內容包括:播放音頻的時延問題,並定量的對不同的緩沖區配置進行分析;非阻塞write;應用程序對驅動程序中DMA buffer的直接訪問。這些是在深入OSS的開發過程中,開發者會遇到的一些實際問題,比如,在開發基於Linux平台的游戲程序時,就必須要考慮到如何降低播放音頻的時延,使得在需要的時候,能夠將游戲的音效盡快地播放出來,並與畫面的進行保持同步。 在討論這些方面時,除了從使用的角度介紹以外,還結合具體的驅動實現,分析這些功能對應的內部原理,以加深讀者的理解。 為了在閱讀文章時有一個共同的認識,本文首先簡單介紹了OSS的一些基本內容。 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機上進行的,具體的測試環境參見下表。
測試軟件(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) 情況1:fragment大小為512字節,fragment個數為2。測試結果1(2x512.Html) 2) 情況2:fragment大小為2048字節,fragment個數為4。測試結果2(4x2048.html) 為了看懂測試結果,需要了解測試結果圖形中各種標識的含義: 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) 白色的between +/-1ms:實際的調度時延落入到fragm.latency +/-1ms范圍的比例。 6) 白色的between +/-2ms:實際的調度時延落入到fragm.latency +/-2ms范圍的比例。 7) 綠色的between +/-0.2ms:CPU的空循環時延波動+/-0.2ms范圍的比例(即落入到標稱值+/-0.2ms范圍的比例)。 8) 綠色的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_WRITEPROT_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體系下正常的工作。