SW emulace sběrnice I2S pro audio DA převodníky

   Při pokusech s audio výstupem na malých MCU existuje několik možností, jak zrealizovat D/A převodník. Velmi často používaný je PWM kanál, který spolu s dolní propustí dává poměrně poslouchatelné výsledky a je velmi levný. Pro kvalitnější výstup lze použít externí D/A převodník. Nabízí se zde sice nejeden převodník s paralelním vstupem, ale zabere příliš mnoho pinů (pro více než 8 bitů). Na relativně výkonných jednočipech AVR naštěstí lze provozovat i klasické audio D/A převodníky se sériovou I2S sběrnicí. Nejznámější typy, ketré nevyžadují MCLK jsou TDA1543 (I2S sběrnice) nebo kvalitnější TDA1545 (dle datasheetu "sběrnice kompatibilní s většinou japonských audio formátů: časový multiplex, TTL, dvojkový doplněk"). Oba lze získat i ze starých CD mechanik nebo přehrávačů. Existuje sice řada dalších typů, ale to jsou téměř výhradně "oversampling" převodníky, kde je potřeba nepřetržitě generovat MCLK (master clock), a hlavně odesílat data synchronně s ním na nějakém zlomku MCLK taktu. To je podle mého názoru zcela mimo možnosti AVR i jiných MCU, kde I2S sběrnice není řešena HW cestou. Naproti tomu dva výše uvedené typy se nechají krmit daty prakticky v libovolných intervalech. Je to skoro jako pracovat s běžným SPI D/A převodníkem. V kombinaci s vyrovnávací pamětí (FIFO) lze pomocí těchto převodníků získat kvalitní audio výstup při relativně malém vytížení AVRka.

1. TDA1543 D/A komunikace

   TDA1543 je 16-ti bitový stereo D/A převodník. Výstupy jsou proudové, takže je třeba doplnit ještě převodník proud-napětí s dolní propustí. Maximální vzorkovací kmitočet je 192kHz, kmitočet hodin sběrnice I2S je až 9.2MHz (fungoval i na 10MHz). Převodník je napájen jediným napětím 5V (měl by jet i na 3V3), odběr je poměrně vysoký - asi 50mA.

   Formát I2S sběrnice je naznačen v grafu. Jak je vidět, 16-ti bitový vzorek ve formátu dvojkového doplňku je odesílán od nejvýznamnějšího bitu (MSB first), data jsou čteny při vzestupné hraně hodin. Signál WS slouží k synchronizaci a multiplexování kanálů. Jeho hodnota se musí změnit vždy PŘED odesláním posledního bitu vzorku, což nepříjemně komplikuje emulaci. Na stejný kanál D/A nelze odeslat více vzorků po sobě.

2. TDA1545 D/A komunikace

   TDA1545 je velice podobný předchozímu, ale mnohem kvalitnější (alespoň dle datasheetu :-). Může pracovat jen na 5V napájení a jeho odběr by měl být jen 6mA pro fullscale na obou výstupech. Má nižší zkreslení a nižší šum. Vzorkovací kmitočet může být až 384kHz, kmitočet hodin až 18.4MHz.

   Vstupní formát zřejmě nemá žádný název. Dle datasheetu "kompatibilní s většinou japonských audio formátů: časový multiplex, TTL, dvojkový doplněk". Z grafu je vidět, že jde vlastně o identický formát, jako I2S, pouze WS signál pro multiplex kanálů je zpozděn o 1 clock - snáze se to realizuje.

3. Vyrovnávací paměť FIFO

   Samotná emulace I2S je celkem k ničemu, pokud MCU musí audio data teprve od někud získat nebo dokonce sám generovat. To zabere poměrně hodně strojového času. Často mnohonásobně víc, než jedna vzorkovací perioda. V průměru sice audio signál musí být generován dostatečně rychle pro přehrávání na daném vzorkovacím kmitočtu, ale některé operace krátkodobě brání odesílání vzorků do D/A, což se podepíše na kvalitě výstupu. Proto je třeba nějakým způsobem přenechat odesílání vzorků na přerušení procesoru, které může téměř libovolně a hlavně pravidelně přerušovat vlastní generování signálu v hlavní programové smyčce. Je zřejmé, že přerušení nemůže odeslat do D/A vzorek, který se právě počítá, případně ještě ani nezačal počítat. Proto je třeba vytvořit vyrovnávací paměť, do které se v hlavní smyčce ukládají vypočítané vzorky maximální možnou rychlostí a na druhé straně procesor přes přerušení tyto vzorky v pravidelných intervalech zase vyčítá a posílá do D/A převodníku. Tímto jednoduchým postupem je zajištěno, že i když rychlost generování signálu kolísá, na D/A převodník data přicházejí pravidelně. Tedy pokud se vyrovnávací paměť zcela nevyprázdní. Pak dojde k podtečení a opětovnému přehrání obsahu paměťi.
   Popsaný druh paměti se jmenuje FIFO (First In First Out - první dovnitř, první ven). Častěji se označuje jendoduše jako buffer nebo marketingově třeba u discmanů jako "Antishock". Konkrétně u přenosných CD přehrávačů je snaha vytvořit co největší FIFO nejen pro případ otřesů, kdy se audio poměrně dlouho přehrává jen z FIFO, ale také kvůli spotřebě. Do FIFO se načte maximum a CD se může zastavit (především u MP3).

   Zjednodušeně řečeno je audio FIFO "černou skříňka", do které jednou stranou ukládáme data v náhodných intervalech (pokud je v ní místo) a druhou stranou v pravidelných intervalech vybíráme. Toto platí pro případ D/A, ale samozřejmě to může fungovat i opačně. Třeba z A/D převodníku dostáváme pravidleně data a na výstupu FIFO je zpracováváme jak právě zbývá MCU čas. Zároveň je také zřejmé, že FIFO působí jako zpožďovací linka. Velikost zpoždění je přímo uměrná velikosti FIFO a nepřímo úměrna datovému toku skrz FIFO. To samozřejmě platí pro případ, že stav FIFO je více méně neměnný a téměř maximální. Obvykle toto proměnlivé zpoždění nepředstavuje problém.

   FIFO buffery se sice vyrábí i jako samostatné obvody, ale pro tyto účely jsou nevhodné. Daleko častější jsou FIFO buffery vytvořené v RAM. Jde o velmi jednoduchý princip - vyhradí se potřebný úsek RAM a vytvoří se 2 pointery. Jeden pro zápis a druhý pro čtení dat. Takto vytvořené FIFO pracuje jako kruhový buffer. Jakmile kterýkoliv z pointerů přesáhne horní hranici vyhrazené paměti, resetuje se zase na její začátek. Banální, ale důležitou operací je zjištění volného prostoru ve FIFO. To lze provést prostým odečtením pointerů. V případě, že jejich pozice je taková, že vyjde záporné číslo, přičte se k němu velikost FIFO.


4. Emulace popsaných sběrnic na AVR

   Jak jsem už naznačil v úvodu, AVR jsou dostatečně výkonné, aby na vzorkovacím kmitočtu 22050Hz pseudo DMA výstup na tyto D/A zabíral jen asi 13% při taktu 16MHz. Jako MCU jsem pro tuto ukázku zvolil můj oblíbený ATmega32. Nechtěl jsem zapojení nijak zvlášť komplikovat, takže audio není v žádné externí paměti, ale přijímá se přes virtuální COM port realizovaný pomocí FT232. Navíc je zde doplněno LCD, ale není nutné ho připojovat. Zobrazuje mono/stereo a vzorkovací kmitočet. Navíc je zde rotační enkodér pro změnu vzorkovací frekvence.
   V dolní části schématu jsou dvě varianty D/A převodníků. Jednak TDA1543 a pak TDA1545. Operační zesilovače mohou být napájeny klidně z +5V proti zemi - mělo by to být dostatečné.

   K emulaci I2S jsem použil SPI interface taktovaný na maximálních 8MHz, takže odeslání jednoho bytu trvá jen 16 cyklů. Odesílá se vždy jeden stereo vozrek najednou. Vlastní rutinu jsem vyřešil jako pseudo DMA. Je volaná timerem 2 nastaveným na vzorkovací kmitočet. Vzorky jsou odebírány z vyrovnávací paměti nastavené na maximum co se vejde (1900B). Podtečení FIFO není nijak kontrolováno - při zastavení toku dat do FIFO se opakuje poslední úsek stále dokola. Všechny operace jsem se snažil dostat do doby, kdy by MCU pouze čekal na odeslání jednotlivých bytů, takže je tato rutinka poměrně rychlá. Navíc místo push/pop jsem použil in/out do nevyužitých registrů (mírné zrychlení). Přes preprocesor si lze vybrat variantu pro TDA1545 nebo TDA1543 a variantu mono/stereo. V případě mono je vždy 1 vzorek odeslán na oba kanály a ušetří se tak polovina paměti pro FIFO. Pak je zde ještě varianta, která umožňuje přepínat mono/stereo za běhu programu pomocí proměnné I2Schannels. To samozřejmě zabere pár cyklů navíc, ale je to ještě únosné.
   Popsaná rutina je sice časově náročná, ale zdaleka ne nejkritičtější. Tou je rutina pro příjem dat z USARTu. Datový tok PCM WAV, který toto zapojení přehrává, je už poměrně vysoký, takže přenosová rychlost je nastavena naplno - 2Mbit/s. To znamená příjem bytu každých 80 cyklů procesoru a to už je docela extrém. Dostatečně rychlé zpracování se mi nepodařilo vyřešit v hlavní programové smyčce, takže je řešeno přes RXC přerušení. Takže i příjem dat je v tomto případě v podstatě pseudo DMA. USART je naštěstí na přijímací straně vybaven hardwarovým FIFO na 2B dat, takže lze obsluhu přerušení pozdržet ostatními operacemi. I tak ale musím v obsluze přerušení číst 2 byty najednou. Příjem dat z PC je pochopitelně bržděn HW řízením toku na potřebnou úroveň. Signál CTS, který obvodu FT232 oznámí plné FIFO v MCU, je ovládán v hlavní programové smyčce podle stavu FIFO. Jakmile ve FIFO zbývá jen 32B volného místa, je vyslán signál k zastavení toku dat. FT232 může po tomto signálu poslat ještě 4B, ale především programová smyčka může být blokována dalšími operacemi, takže proto 32B rezerva. Povolení toku dat (CTS - Clear To Send) je obnoveno až při 48 volných bytech.
   Aby toho nebylo málo, doplnil jsem ještě LCD pro zobrazování vzorkovacího kmitočtu a mono/stereo. LCD dost dobře nelze ovládat kalsicky, protože obnovení 20 znaků zabre najednou 1ms, takže i toto je vyřešeno obnovením přes pseudo DMA.
   Jakmile začnou přicházet data, MCU začne počítat byty. Až je příjmuto prvních 64B, pokusí se hlavní programová smyčka načíst z RIFF konzervy PCM WAVu frame "fmt " a v něm identifikovat, zda jde skutečně o PCM WAV, nastavit vzorkovací kmitočet a počet kanálů. Vzorkovací kmitočet lze měnit i ručně rotačním enkodérem v rozsahu 8kHz až 48kHz. 1s po dokončení přehrávání by měl vypršet timeout a MCU reinicializuje přehrávání.
Zdroje a bin.

   Jednoduchý ovládací prográmek můžete stáhnout zde. V ini souboru je třeba nastavit číslo portu, kde je připojený FT232 převodník. V ovladačích virtuálního COM portu je ještě vhodné nastavit latency na 2ms (nebo 1ms na strojích od 1GHz - na pomalejších dojde spíš ke zpomalení toku dat).
   Otevřít by měl jít libovolný soubor, ale bezpečně přehrát lze jen 16bit PCM WAVy 8-48kHz. Pokud je nějaká zrada v HW zapojení, měl by vypršet timeout, takže program snad nevytuhne (to by se při HW řízení toku mohlo snadno stát).
   Celkem milé překvapení pro mě bylo, když jsem zjistil, že i na postarším P3 600MHz přenos jede tak rychle, že nedojde k žádným podtečením FIFO až do kvality výstupu 47500Hz 16bit stereo. Stačí ovšem pohnout myší a už je slyšet podtečení FIFO v podobě lupnutí. Na rychlejších strojích jede přenos nadoraz - 48kHz 16bit stereo a XP si poradí i s další zátěží bez podtečení FIFO. Tomuto formátu odpovídá datový tok 192kB/sec což je 96% z teoreticky maximálních 200kB/sec (2MBd, 10bit rámce) a to je docela slušný výsledek :-).

5. I2S příklad pro AVRGCC

   Protože jsem se nedávno konečně dokopal dělat něco na AVR i v C, rozhodl jsem se, že tohle by mohl být dobrý testovací prográmek, kde si ověřím, jak moc neefektivní AVRGCC je.
   Jedná se o prakticky identický program, jako je minulý příklad v assembleru, pouze je napsán v C a místo LCD používá výstup na libovolný terminál. Odstranil jsem rovněž enkodér (nemám žádný po ruce). Protože můj prastarý ATmega32 už jsem někde využil, použil jsem tentokrát ATmega644-20. Je stále v DIL40, má dvakrát větší RAM a snese vyšší takt. Prográmek by měl být schopen přehrát libovolný PCM wave se vzorkováním do 44.1kHz (8/16 bit, mono/stereo). Stream je přenášen opět přes FT232 nebo nějakou obdobu na taktu 2Mbd (bez parity, jeden stop bit) libovolným terminálem s podporou CTS řízení toku (odchozí data). Použít lze i výše uvedený prográmek, což je vlastně také terminál, ale nebude samozřejmě vidět textový výstup.

Schéma zapojení.

   Formát je rozpoznán automaticky z RIFF konzervy a pokud není podporován, měla by být vypsána chyba a zbytek toku bude ignorován. Problém s identifikací by mohl nastat u WAV souborů, kde je "data" subchunk v souboru za hranící 0x0078. To je sice vzácná situace, ale stát se to může. Někdy jsou totiž v souboru i tagy a občas bohužel i na začátku. Program je vybaven i timeoutem, takže pokud terminál déle než cca 750ms neodešle data, bude přehrávání ukončeno. Prográmek jsem tentokrát navíc vybavil počitadlem bytů, takže po příjmu posledního bytu vlastních audio dat ("data" subchunk RIFF konzervy) je audio FIFO naplněn nulami. Tím by se mělo přehrávání nehlučně ukončit a zbytek streamu (obvykle "LIST" subchunk), bude ignorován.

Příklad komunikace.

   Dle očekávání je sice GCC v porovnání s assemblerem (mým assemblerem :-) citelně pomalejší, ale ne zase nějak nepřekonatelně. Zpravidla se překladači dalo rozumným přeskládáním kódu a změnou zápisu pomoct alespoň k trochu rozumnému výstupu. Pochopitelně samotná I2S rutina musela zůstat v assembleru jednak kvůli přesnému časování a pak samozřejmě i kvůli efektivitě. Vzhledem k tomu, že I2S výstup přerušuje příjem dat, tak každý cyklus navíc zvyšuje šanci, že dvou bytový USART FIFO, kterým je AVR na příjimací straně vybaveno, přeteče. Překvapivě to tentokrát šlo i bez příjmu dat v přerušení. Sice opět musím zkoušet vyčíst druhý byte zkrácenou cestou, ale nepostřehl jsem, že by to přeteklo, takže to asi bude OK.
   Projekt ke stažení: zdrojáky a překlad pro TDA1543, zdrojáky a překlad pro TDA1545.

5.1. Pár poznatků okolo AVRGCC

   Když jsem před pár lety prvně použil Keil C51, tak jsem se ho naučil používat celkem rychle (můj první vyšší jazyk pro jednočipy). Rozhodně jsem nenarazil na obtíže s optimalizacemi a spol. Stačilo správně používat "volatile". Předpokládal jsem, že s GCC to nebude nijak výrazně složitější, ale bohužel je. Z toho důvodu jsem se rozhodl shrnout pár poznatků z práce na tomto mini projektu. Pro toho, kdo s GCC už pracuje jistě žádné překvapení, ale začátečníkům, jako jsem já, by se to mohlo hodit.
   Předně jsem s tímto triviálním programem narazil u assembleru. Sice jsem samozřejmě věděl, že GNU-as je s AvrAsm32/2 a jeho obdobami nekompatibilní, ale měl jsem za to, že většinu problémů vyřeším pomocí pár maker. To bylo ode mne skutečne hodně naivní. :-)
   Téměř neřešitelným problémem se ukálala být zejména assemblerovská makra. Kdo se už někdy vrtal v mých novějších zdrojácích pro AVR ví, že používám desítky maker pro práci s vícebytovými proměnnými, kde se udává pouze první registr ze skupiny a zbytek už řeší makro. Dost to zpřehledňuje kód. Např.:

	...
	.def   R16H=R17
	.def   R16B3=R18
	.def   R19H=R20
	.def   R19B3=R21
	...	

	.macro addt
	add    @0,@1
	adc    @0H,@1H
	adc    @0B3,@1B3
	.endm

	addt   R16,R19

Bude automaticky rozvinuto do:

	add    R16,R19
	adc    R17,R20
	adc    R18,R21

Bohužel chtít něco takto jednoduchého po GNU-as se ukázalo jako téměř neřešitelný problém. Už jsem to skoro chtěl vzdát, ale naštěstí jsem byl na fóru odkázán na nějaký *.h soubor z LibC, kde bylo toto řešeno iterační metodou. No ... málem jsem z té překomplikované hrůzy spadl ze židle, ale pak mi došlo, že to asi jinak nepůjde, takže problém jsem vyřešil takto:

	.macro addt ra,rb 
        .findreg_ra=-1
	.findreg_rb=-1
	.findreg_cnt=0
	.irp   regn,R0,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11 \
	            R12,R13,R14,R15,R16,R17,R18,R19,R20,R21 \
	            R22,R23,R24,R25,R26,R27,R28,R29,R30,R31
	.ifc   \regn,\ra
	.findreg_ra=.findreg_cnt
	.endif
	.ifc   \regn,\rb
	.findreg_rb=.findreg_cnt
	.endif
	.findreg_cnt=.findreg_cnt+1
	.endr

	add    (.findreg_ra),(.findreg_rb)
	adc    (.findreg_ra)+1,(.findreg_rb)+1
	adc    (.findreg_ra)+2,(.findreg_rb)+2
	.endm

Vypadá to hrozně a o rychlosti překladu při delším zdrojáku raději nepřemýšlím, ale alespoň to funguje. Smysl onoho kódu je iterační cestou přes ".irp" direktivu identifikovat pořadové číslo registru. AVR verze překladače naštěstí akceptuje nejen názvy registrů, ale i jejich pořadová čísla, takže dále už to bylo snadné (jednoduché řesení R16+n bohužel použít nelze). Pochopitelně to bylo potřeba provést se všemi makry, takže jsem k převodu mých starých maker napsal prográmek, který třeba časem zveřejním. Převedená makra jsou součástí projektu, takže pokud má někdo zájem, lze použít, ale netestoval jsem rozhodně všechny.
   Pokud jde o assembler tak už se vyskytly jen kosmetické problémy, jako že low(), high() a spol. nejsou definovány, že adresy skoků se udávají v bytech místo ve wordech, že místo PC se používá ".", ale s tím háčkem, že ukazuje o jeden word vpřed oproti PC v AvrAsm32/2 atd.
[rejp] Na to jak se všichni všude můžou přetrhnout sdělováním, jak je GNU-as mocný, výkonný a nepřekonatelný nástroj jsem narazil na věci, co prostě jednouše neudělám trochu moc brzo. Na to já mám holt talent. :-D [/rejp]

   V C-čkové části programu jsem narazil především na typické obtíže s optimalizacemi. Např. jsem si naivně myslel, že když použiji následující kód:

	volatile uint8_t *rd;
	cli();
	rd=FIFOrd;
	sei();

	// calc free space
	int16_t n=rd-FIFOwr;
	if(n<0)
	  n+=FIFOsize;

tak i po optimalizaci dostanu něco jako:

	lds   R22,FIFOwr
	lds   R23,FIFOwr+1
	
	...

	cli
	lds   R24,FIFOrd
	lds   R25,FIFOrd+1
	sei

	sub   R24,R22
	sbc   R25,R23
	brcc  .+4
	subi  R24,lo8(-FIFOsize)
	subi  R25,hi8(-FIFOsize)

Jo, to byla zase naivita. :-) Optimalizátor s klidným svědomím vygeneroval zhruba toto:

	lds   R22,FIFOwr
	lds   R23,FIFOwr+1

	...

	lds   R24,FIFOrd
	lds   R25,FIFOrd+1
	sub   R24,R22
	sbc   R25,R23
	sbrs  R25,7
	rjmp  .+4
	subi  R24,lo8(-FIFOsize)
	subi  R25,hi8(-FIFOsize)

	...

	cli
	sei

Ano, tam je mi to zablokované přerušení skutečně hodně platné. Vyřešil jsem teprve až po "konzultaci" s "atomic.h":

	volatile uint8_t *rd;
	asm volatile("cli" ::: "memory");
	rd=FIFOrd;
	asm volatile("sei" ::: "memory");

	// calc free space
	int16_t n=rd-FIFOwr;
	if(n<0)
	  n+=FIFOsize;

Parametr "memory" zjevně překladač nutí provést kód přesně tak, jak je - zakazuje mu "vyoptimalizovat" načtení sdílené proměnné "FIFOrd" někam jinam. Sice jsem měl za to, že to platí jen uvnitř jednoho asm() bloku, ale asi to funguje i takto. Alternativně (a možna i přednostně) lze použít makro ATOMIC_BLOCK() z "atomic.h". Vypadá to pak trochu čitelněji a zřejmě je to pak i multiplatformní.

   Další poznatek ohledně optimalizátoru byl, že je VELMI špatný nápad používat globální proměnnou uvnitř nějakého algoritmu. Překladač má pak tendenci do ní stále ukládat mezivýsledky, přesto že není typu "volatile". Z toho důvodu se vyplatí vytvořit si na začátku algoritmu lokální kopii, která s trochou štěstí zůstane v registrech a na konci ji zase přiřadit zpět do globální proměnné. Tohle dost zefektivnilo kód prakticky všude. Sice to vypadá trochu neelegantně, ale než předávat hafo parametrů sem a tam, tak už raději tohle.

   Dále jsem byl trochu na nervy z toho, že linker používal stále absolutní skoky a volání, ale bylo mi na fórech porazeno, že k tomu slouží parametr "-Wl,--relax" a skutečne to funguje (nečekaně). Kde to šlo, tam to linker zkrátil na relativní a hned bylo kódu výrazně méně.

(c) 2007-2012, Stanislav Mašláň - Všechna práva vyhrazena.

Poslední aktualizace: 11.2.2012 Up