D/A Wandler
Um einem Microcontroller Sounds zu entlocken, wird ein D/A Wandler oder DAC (Digital Analog Converter) benötigt. Der D/A Wandler wandelt digitale Zahlenwerte (Samples) in analoge Spannungswerte um. Werden sehr viele Samples hintereinander mit festen Zeitabständen gewandelt, ergibt sich ein kontinuierlicher Spannungsverlauf. Dieser wird einem Verstärker zugeführt, um den Sound hörbar zu machen.
Die Technik der D/A Wandlung ist mathematisch gesehen eine Transformation in die Z-Ebene. Das Ausgangssignal ist in Wirklichkeit nicht kontinuierlich, sondern treppenförmig. Durch entsprechende Tiefpassfilter wird das Signal jedoch tatsächlich „rund“. Das Shannon Theorem besagt, dass für die Reproduktion eines Analog Signals mit der Frequenz f mindestens die doppelte Abtastrate benötigt wird. Der hörbare Audio Bereich erstreckt sich von ca. 20 Hz bis 20 kHz. Die notwendige Abtastrate sollte dann bei 40 kHz liegen. Da der Filter an Ende des Wandlers eine begrenzte Steilheit hat, wurde die Abtastrate bei Audio CDs auf 44.1 kHz definiert. Man arbeitet jedoch mittlerweile zunehmend einheitlich mit 48 kHz. Dies ist auch die Abtastrate bei DAT und Audiospuren von heutigen Video CODECs.
Es gibt unterschiedliche D/A Wandler auf dem Markt. Für Audio Anwendungen wird ein schneller D/A Wandler benötigt, der 48000 Wandlungen pro Sekunde schaffen sollte. Die Ausgabequalität hängt auch von der Bitbreite ab. 16 Bit hat sich als hinreichend für die meisten Anwendungen erwiesen. Dadurch ergibt sich ein Signal/Rauschabstand von -96dB. Höhere Wortbreiten sind möglich, 24 Bit oder 32 Bit werden für Profi Audio Devices verwendet.
Anschluss des DAC Boards
Als Hardware des Audio Interface verwende ich ein kleines Breakout Board mit einem PCM 5102A DAC. Er hat hervorragende Audiowerte und kann hohe Bitraten verarbeiten, bis zu 32 Bit mit 192 kHz in Stereo. Der DAC wird über ein serielles Signal gespeist, welches die Sample Daten enthält. Weiterhin gibt es ein Clock Signal und ein Word Clock, welches die Stereo Kanäle rechts/links umschaltet.
Alle 3 Signale bilden zusammen das sogenannte I2S Protokoll (oder IIS, Inter IC Sound). Es ähnelt dem SPI Protokoll, mit einem zusätzlichen Word Clock Signal. Der DAC benötigt intern einen weiteren höherfrequenten Takt für die Wandlung selbst. Diesen kann er jedoch via PLL selbst erzeugen, was wir der Einfachheit halber gerne nutzen wollen. Insgesamt sind somit 3 IO/Pins an den Microcontroller anzuschließen. Das Board benötigt außerdem eine Spannungsversorgung von 3,3V und eine GND Leitung.
PIO Programmierung
Der Raspberry Pi Pico unterstützt nicht direkt das I2S Protokoll. Um das I2S Signal zu erzeugen, kann aber mit wenigen Mitteln ein PIO Programm dafür geschrieben werden. Die 3 anzusteuernden Pins können mit einem einzigen PIO Programm gesteuert werden:
- Serielle Daten
- BCK (Serieller Takt)
- RLCK (Stereo Takt)
Die beiden letzteren Signale können mittels „Side“ Subkommando gesetzt werden. Es gibt Entwickler, die für diesen Zweck gerne auch 3 PIO State machines belegen. Ich komme jedoch mit einer einzigen aus:
; 3 Pin I2S
; PIO Clk * 2
; LRCK side 1
; BCK side 0
.program i2spio
.clock_div 50
.side_set 2
.fifo tx
set y, 14 side 0
.wrap_target
mov x, y side 1
l:
out pins, 1 side 0
jmp x--, l side 1
out pins, 1 side 2
mov x, y side 3
r:
out pins, 1 side 2
jmp x--, r side 3
out pins, 1 side 0
.wrapWas wir hier sehen ist ein sogenanntes PIO Programm. Es wird in einer Assemberähnlichen Sprache geschrieben. Das Programm beginnt mit einem Kommentar Abschnitt. Darauf folgen einige Präprozessor Anweisungen für den PIO Compiler, welcher im Raspberry Pi Pico SDK enthalten ist. Dann sehen wir die eigentlichen PIO Kommandos, welche den Programmablauf darstellen. Es gibt auch 2 Labels.
In Kürze zusammengefasst, schiebt das Programm die am FIFO hereinkommenden Daten via „out“ Befehl an den Ausgangs-Pin heraus. Zuerst wird der linke Kanal herausgeschoben, dann der rechte. Nach jeder Anweisung wird das Side Bit 0 alterniert (Daten-Takt). Der Daten-Take entspricht somit der halben PIO Frequenz. Das Side Bit 1 wird geändert, wenn sich der Kanal ändert. Die Ausgabe eines Kanals passiert in einer Schleife. Die Schleifen sind so konstruiert, damit das benötigte I2S Timing exakt eingehalten wird. So kann man keine Synchronisationsprobleme bekommen.
Das Programm wiederholt sich in einer Endlosschleife (.wrap Anweisung). Es muss kompiliert und via Software initialisiert werden. Am Ende hat man ein Header File mit den tatsächlichen PIO Befehlen (welches am Ende nur Maschinencodes sind). Diese wird in das aufrufende Programm eingebunden, welches die PIO initialisiert.
#include "i2spio.pio.h"
#include "pico/types.h"
#include "stdint.h"
#define i2s_data_pin 10
#define i2s_side_pin 11
static PIO pio = pio0;
static uint smIndex = 1;
static uint pioOffset;
static uint dataPin = i2s_data_pin;
static uint bclkPin = i2s_side_pin;
static uint lrclkPin = i2s_side_pin + 1;
void i2sDriver_dual_init() {
pio_sm_set_enabled(pio, smIndex, false);
printf("i2s SM stopped\n");
// sm init
pioOffset = pio_add_program(pio, &i2spio_program);
printf("i2s PIO program loaded at %d\n", pioOffset);
// i2s pins
pio_gpio_init(pio, dataPin);
pio_gpio_init(pio, bclkPin);
pio_gpio_init(pio, lrclkPin);
// 3 Pins (1 data + 2 side)
pio_sm_set_consecutive_pindirs(pio, smIndex, dataPin, 3, true);
// sm init
pio_sm_config conf = i2spio_program_get_default_config(pioOffset);
sm_config_set_out_pins(&conf, dataPin, 1);
sm_config_set_sideset_pins(&conf, bclkPin);
sm_config_set_out_shift(&conf, false, true, 32); // MSB first, autopull enabled, 32 Bit
pio_sm_init(pio, smIndex, pioOffset, &conf);
printf("i2s SM configured\n");
}Wir haben hier eine „Autopull“ Konfiguration d.h., es wird jedes verfügbare Wort abgeholt und herausgeschoben, sobald es im FIFO verfügbar ist. Das most significant bit wird zuerst herausgeschoben, insgesamt haben wir 2*16=32 Bit. Die Side Pins werden separat angegeben. Mit pio_sm_set_consecutive_pindirs kann man für mehrere aufeinanderfolgende Pins die PIO Konfiguration angeben. Side Pins müssen immer aufeinanderfolgend sein.
DMA Treiber
Die Samples könnten vom Microcontroller einfach direkt in den FIFO Puffer der PIO State Machine geschrieben werden. Dies erfolgt dann jedoch blockierend. Das heißt, wenn der FIFO voll ist, muss der Prozessor warten. Dies ist nicht so schön, die maximale CPU Auslastung reduziert sich dadurch auf 50% (50% Berechnung der Samples, 50% für das blockieren beim Pushen in den FIFO). Ein DMA Treiber kann hier Abhilfe schaffen, indem er die Daten im Hintergrund autonom an den FIFO sendet. Dazu sind 2 Puffer nötig (Ringpuffer!). Während der eine Puffer an den FIFO übertragen wird, um die Daten via I2S an den DAC zu senden, kann der andere Puffer vom Prozessor gefüllt werden. Ein Interrupt kann genutzt werden, um bei erfolgter DMA Übertragung den „freien“ Puffer umzuschalten, der dann wieder gefüllt werden kann. Dadurch erreicht man eine sehr effiziente Übertragung der Audio Daten, mit minimaler CPU Auslastung. Die CPU wird schließlich für rechenintensive DSP Algorithmen benötigt. Beim Raspberry Pi Pico werden zu diesem Zweck 2 DMA Kanäle benötigt. Diese werden so konfiguriert, dass sie sich gegenseitig anstoßen, nachdem ein Kanal fertig übertragen hat. In diesen Fällen wird ein Interrupt ausgelöst.
// Configure the DMA channels for the LED display
void i2sDriver_dual_configureDMA() {
int tmpSize = bufferSize;
static int ringBits = 1;
while (tmpSize) {
tmpSize >>= 1;
ringBits++;
}
// Allocate a DMA channel to feed the pin_ctrl SM its command words
pio_dma_chan1 = dma_claim_unused_channel(true);
pio_dma_chan2 = dma_claim_unused_channel(true);
dma_channel_cleanup(pio_dma_chan1); // Stop the DMA channel
dma_channel_cleanup(pio_dma_chan2); // Stop the DMA channel
printf("i2s DMA aborted\n");
uint dreq = pio_get_dreq(pio, smIndex, true);
dma_channel_config pio_dma_chan_config1 = dma_channel_get_default_config(pio_dma_chan1);
channel_config_set_transfer_data_size(&pio_dma_chan_config1, DMA_SIZE_32);
channel_config_set_read_increment(&pio_dma_chan_config1, true);
channel_config_set_write_increment(&pio_dma_chan_config1, false);
channel_config_set_dreq(&pio_dma_chan_config1, dreq);
channel_config_set_ring(&pio_dma_chan_config1, false, ringBits);
channel_config_set_chain_to(&pio_dma_chan_config1, pio_dma_chan2);
// second dma channel for chaining
dma_channel_config pio_dma_chan_config2 = dma_channel_get_default_config(pio_dma_chan2);
channel_config_set_transfer_data_size(&pio_dma_chan_config2, DMA_SIZE_32);
channel_config_set_read_increment(&pio_dma_chan_config2, true);
channel_config_set_write_increment(&pio_dma_chan_config2, false);
channel_config_set_dreq(&pio_dma_chan_config2, dreq);
channel_config_set_ring(&pio_dma_chan_config2, false, ringBits);
channel_config_set_chain_to(&pio_dma_chan_config2, pio_dma_chan1);
// Setup the channel and set it going
dma_channel_configure(pio_dma_chan1, &pio_dma_chan_config1,
&pio->txf[smIndex], // Write to PIO TX FIFO
i2sPointer1, // Read values from data field
bufferSize, // 2x 32 bit value
false // start immediately
);
dma_channel_configure(pio_dma_chan2, &pio_dma_chan_config2,
&pio->txf[smIndex], // Write to PIO TX FIFO
i2sPointer2, // Read values from data field
bufferSize, // 2x 32 bit value
false // start immediately
);
// Configure the processor to run dma_handler() when DMA IRQ 0 is asserted
// C function must be static or extern "C"
dma_channel_set_irq0_enabled(pio_dma_chan1, true);
dma_channel_set_irq0_enabled(pio_dma_chan2, true);
irq_set_exclusive_handler(DMA_IRQ_0, dual_dma_handler);
irq_set_enabled(DMA_IRQ_0, true);
dma_channel_start(pio_dma_chan1);
printf("i2s DMA started\n");
}Eine sehr spezielle Besonderheit hat die Anweisung channel_config_set_ring. Sie ist nur spärlich in den API Docs dokumentiert und wird benötigt, um die Adresse nach der erfolten Übertragung wieder zurückzusetzen. Um die DMA Lese-Adresse (welche auto inkrementiert wird) auf den Ausgangswert nach der Übertragung zurückzusetzen, muss man zunächst die Anzahl Bits ausrechnen, die dazu übergeben werden muss. Es ist quasi der 2er Logarithmus der Puffergröße. Der Puffer muss UNBEDINGT eine durch 2 teilbare Größe haben, z.B. 16 (32 Bit Werte). Die Adresse muss nun um wie viele Bytes verschoben werden, wenn wir 16*4 Byte Adressraum zurückgehen wollen? Es sind 64 Bytes, log2(64)=6 (2⁶=64). Die Antwort lautet in diesem Fall also nicht 42, sondern 6. Weiterhin muss man beachten, dass die Ring Funktion nur dann funktioniert, wenn die Adresse des DMA Puffers aligned ist auf diese Puffergröße. Ich habe dies wie folgt erreicht (nach stundenlangen Recherchen ;p)
// MUST be aligned to buffer size in order to circular ring buffer to work
static uint32_t i2sBuffer[bufferSize * 2] __attribute__((aligned(bufferSize * 2 * sizeof(uint32_t*))));Des Weiteren wird eine Interrupt Routine benötigt. Sie muss die verarbeiteten Puffer umschalten, wenn ein DMA fertig übertragen hat:
static uint32_t* i2sPointer1 = i2sBuffer;
static uint32_t* i2sPointer2 = &i2sBuffer[bufferSize];
// ring buffer of transferred buffers to avoid race conditions between dma handler and i2sDriver_dual_nextBuffer
static uint32_t* i2sBufferTransferred[2] = {i2sPointer2, NULL};
static int txBufferIndex = 0;
static void dual_dma_handler() {
if (i2sBufferTransferred[txBufferIndex]) {
printf("i2sDriver_dual_nextBuffer: WARNING! dma buffer underrun!\n");
}
if (dma_hw->ints0 & (1u << pio_dma_chan1)) {
// make buffer 1 available to the audio engine
i2sBufferTransferred[txBufferIndex ^= 1] = i2sPointer1;
// acknowledge/clear the interrupt bit for this channel
dma_hw->ints0 = 1u << pio_dma_chan1;
}
if (dma_hw->ints0 & (1u << pio_dma_chan2)) {
// make buffer 2 available to the audio engine
i2sBufferTransferred[txBufferIndex ^= 1] = i2sPointer2;
// acknowledge/clear the interrupt bit for this channel
dma_hw->ints0 = 1u << pio_dma_chan2;
}
}Die CPU fragt mit getNextBuffer() ab, ob es inzwischen einen neuen Puffer gibt, den sie füllen kann:
volatile uint32_t* i2sDriver_dual_nextBuffer() {
// save current transferred buffer
volatile uint32_t* tmpBufPtr = i2sBufferTransferred[txBufferIndex];
// clear current pointer, dma could have finished a new buffer in the meantime!
i2sBufferTransferred[txBufferIndex] = NULL;
// return address
return tmpBufPtr;
}Wenn dies der Fall ist, wird diese Adresse umgeschaltet. Die umgeschalteten Puffer werden selbst in einem Ringpuffer mit 2 Zeigern organisiert. Wenn die CPU einen Puffer „geholt“ hat, wird zum nächsten Zeiger vorgerückt. Solange kein Puffer frei ist, kann die CPU andere Aufgaben erledigen, wie Samples berechnen oder Daten von der Steuerung abrufen.
