Zastosowania Procesorów Sygnałowych dr inż. Grzegorz Szwoch greg@multimed.org p. 732 - Katedra Systemów Multimedialnych Wprowadzenie do programowania na procesorze sygnałowym
Wstęp Czego nauczymy się na tym i na kolejnych wykładach? Jak implementować algorytmy DSP na stałoprzecinkowym procesorze sygnałowym. Jak działają podstawowe algorytmy DSP - filtry, FFT, generatory sygnałów, itp. Jakie są ich praktyczne zastosowania. W przyszłym semestrze (6) będziemy wykonywać projekt. Bez wiedzy przedstawionej na tych wykładach będzie bardzo trudno!!!
Układ DSP TMS320C5535 Bohater opowieści: Układ uruchomieniowy ezdsp Texas Instruments Stałoprzecinkowy procesor DSP TMS320C5535 zegar 50-100 MHz 320 KB RAM Pamięć flash 8 MB kodek audio
Uruchamianie algorytmów Tworzenie kodu tryb Debug: podłączamy płytkę przez USB do komputera, używamy programu Code Composer Studio do pisania kodu, uruchamiania, debugowania Gotowy program tryb Release: wgrywamy do pamięci flash lub na kartę SD, program uruchamia się po włączeniu zasilania
Asembler Jak piszemy kod? Pierwsza możliwość Asembler. Język tworzący praktycznie kod maszynowy. Pozwala wykorzystać wszystkie możliwości DSP. Umożliwia pisanie optymalnego (najszybszego) kodu. Tradycyjny sposób implementowania na DSP. Wada: wymaga dużej wiedzy, jest dość trudny. Na szczęście, typowe algorytmy DSP w Asemblerze są już dostępne biblioteka DSPLIB. Nie będziemy pisali w Asemblerze na tym przedmiocie.
Język C Drugi sposób dostępny na naszym DSP język C Znacznie łatwiejsze programowanie. Kompilator tłumaczy kod C na asembler. Robi to dobrze, ale nie jest w stanie być tak dokładny, jak programista Asemblera. Kod napisany w C będzie zwykle wolniejszy. Możliwe jest łączenie C z Asemblerem. Będziemy używali języka C do tworzenia kodu.
Czas działania algorytmu Częstotliwość zegara DSP wyznacza liczbę cykli na sekundę. Np. 100 MHz to 100 mln cykli/s. Każda instrukcja kodu zużywa określoną liczbę cykli zegarowych. Czas działania algorytmu mierzymy liczbą cykli. Np. jeżeli alg. zużywa 600 cykli, to czas = 0,6 ms. Optymalizacja algorytmu pozwala nam wykonać więcej operacji w ciągu sekundy bardzo ważne w zastosowaniach praktycznych.
Czas działania algorytmu Praktyczny wniosek: Programowanie na typowe komputery uczy nas nie dbać o czas wykonywania kodu ( nie optymalizować dopóki nie jest to niezbędne ). Takie podejście nie sprawdzi się na DSP. Jeżeli nie będziemy od początku dbali o czas wykonywania kodu, to może nam zabraknąć cykli zegarowych.
Kod w języku C Struktura kodu w języku C: dołączenie nagłówków (#include), deklaracje zmiennych, w tym buforów, (opcjonalnie) definicje funkcji, funkcja main tutaj rozpoczyna się wykonanie programu. Program może być zapisany w więcej niż jednym pliku.
Inicjalizacja układu Na początku main trzeba skonfigurować naszą płytkę: ustawienie częstotliwości zegara, włączenie kodeka dźwięku, ust. cz. próbkowania, uruchomienie wyświetlacza, przycisków, itp. i inne rzeczy. Te operacje wykonujemy w każdym programie, dlatego są one zwykle umieszczone w osobnej bibliotece. Trzeba jedynie je wywołać.
Schemat przetwarzania Przykład: chcemy przetworzyć dźwięk z wejścia i wysłać go na wyjście. Wewnątrz main implementujemy pętlę wykonującą następujące operacje: odczyt próbki z wejścia, przetwarzanie: próbka po próbce każdą próbkę z osobna, lub blokowe zapisujemy próbkę do bufora, przetwarzamy bufor gdy się zapełni, zapisanie przetworzonej próbki na wyjście.
Obliczanie średniej ruchomej Zabieramy się wreszcie do pisania kodu. Obliczamy wartość średnią ostatnich 5 próbek: y( n) = x( n) + x( n 1) + x( n 2) 5 + x( n 3) + x( n 4) lub inaczej: y( n) = 0,2x( n) + 0,2x( n 1) + 0,2x( n 2) + 0,2x( n 3) + 0,2x( n 4) Musimy przechować 5 ostatnich próbek w pamięci.
Bufor liniowy Najprostsze podejście bufor liniowy. Wyrzucamy z bufora najstarszą próbkę, przesuwamy pozostałe próbki o jedno miejsce, wpisujemy nową próbkę na wolne miejsce. Wada: tracimy czas na przesuwanie próbek. Jeśli w buforze będzie np. 100 próbek, zmarnujemy dużo cykli.
Bufor kołowy Lepsze rozwiązanie: bufor kołowy. Próbki nie są przesuwane. Przesuwany jest indeks wskazujący na najstarszą próbkę w to miejsce zapisujemy nową próbkę. Indeks musi się zawijać po dojściu na koniec bufora, wraca na pozycję 0.
Bufor kołowy i liniowy -ilustracja Bufor kołowy Bufor liniowy 13 14 10 11 12 10 11 12 13 14 15 13 14 15 11 12 15 11 12 13 14 15 16 13 14 15 16 12 16 12 13 14 15 16 17 13 14 15 16 17 17 13 14 15 16 17 18 18 14 15 16 17 18 14 15 16 17 18 19 18 19 15 16 17 19 15 16 17 18 19
Bufor kołowy w języku C Implementacja w naszym programie: #define N 5 int bufor[n]; int indeks = 0; // rozmiar bufora // deklaracja bufora // wskazuje najstarszą próbkę bufor[indeks] = nowa_probka; // zapis // tutaj wykonujemy obliczenia indeks += 1; if (indeks == N) indeks -= N; // przesunięcie indeksu // jesteśmy na końcu // zawijamy indeks
Bufor kołowy na DSP Bufory kołowe są zwykle używane na DSP. Procesor ma specjalne instrukcje do ich obsługi. W kodzie C mamy dostęp do wewnętrznych instrukcji procesora (intrinsics) za pomocą specjalnych komend. Np. zwiększenie indeksu w buforze kołowym to instrukcja _circ_incr. Instrukcje wewnętrzne skracają pisany kod.
Bufor kołowy na DSP Zamiast: indeks += 1; if (indeks == N) indeks -= N; możemy napisać: indeks = _circ_incr(indeks, 1, N);
Deklarowanie buforów Zanim pójdziemy dalej: gdzie deklarować bufory? DSP ma pamięć o ciągłym zakresie adresów. Pamięć jest logicznie dzielona na zakresy: danych, programu, stosu, itp. (definicje w pliku.cmd). Nie ma zabezpieczenia przed przekroczeniem danego zakresu! Zmienne (w tym bufory) możemy definiować: przed main w obszarze danych, wewnątrz main na stosie.
Stos Stos jest obszarem pamięci dla zmiennych deklarowanych wewnątrz funkcji (w tym main). Obszar stosu jest niewielki, np. 4 KB. Deklarując duże bufory wewnątrz main, możemy spowodować przepełnienie stosu. Objawy mogą być rożne, np.: zawieszenie się programu, program działa, ale generuje błędne dane.
Przepełnienie stosu Ilustracja problemu: Zajęty obszar stosu Deklarujemy duży bufor na stosie Wolny obszar stosu Zajęty obszar danych Przepełnienie stosu! Wolny obszar danych
Przepełnienie stosu Efekty przepełnienia stosu są trudne do debugowania. Obszar danych jest większy od stosu i wystarczający. Dlatego proszę zapamiętać i stosować poniższą zasadę. Wszelkie bufory danych deklarujemy globalnie, tzn. poza main i innymi funkcjami!
Typy danych Nasz DSP daje nam do dyspozycji następujące typy danych liczb całkowitych: int liczba 16-bitowa (także: short, DATA, Int16), long liczba 32-bitowa (także LDATA, Int32). Każdy typ jest w wersji: signed ze znakiem (domyślnie), unsigned bez znaku (np. unsignedint). NIE MA typów zmiennoprzecinkowych float, double!
Deklarowanie zmiennych w C Zmienna skalarna : typ, nazwa int probka; unsigned int indeks; Tablice (np. bufory): typ, nazwa[rozmiar] int bufor[1024]; Tablice stałych, np. współczynników: const int wsp[] = {1000, 2000, 3000}; Stałe liczbowe, np. rozmiar tablicy: const int N = 5; #define N 5
Mnożenie na DSP Wracamy do naszego kodu. Mamy policzyć średnią ruchomą. Musimy przemnożyć próbki przez 0,2. Pamiętajmy: tylko typy całkowitoliczbowe. Stosujemy zapis Q15 (wykład nr 5): 0,2 * 32768 6535 Próbki sygnału są zapisane jako int (16 bit). Mnożenie dwóch liczb 16-bitowych daje wynik 32-bitowy (typ long).
Mnożenie na DSP W języku C, mnożenie dwóch liczb 16-bitowych daje zawsze wynik 16-bitowy, starsze bity zostają usunięte! Musimy rzutować mnożoną liczbę na typ long. Prawidłowo: long wynik = (long)probka * 6535; // OK Nieprawidłowo: int wynik = probka * 6535; // obcięcie wyniku long wynik = probka * 6535; // również obcięcie wyniku!
Obliczenie średniej Mnożymy przez 0,2 próbki zapisane w buforze kołowym, zaczynając od pozycji indeks, i sumujemy. const int WSP = 6535; // współczynnik 0,2 int i = 0; // licznik pętli int poz = indeks; // pozycja odczytu próbki long wynik = 0; // nasza średnia for (i = 0; i < n; i++) { // pętla po próbkach wynik += (long)bufor[poz] * WSP; poz = _circ_incr(poz, -1, N); }
MAC Jedna z typowych operacji na DSP: przemnożenie dwóch liczb i dodanie wyniku do akumulatora: y y + a * x Taka operacja nazywa się MAC multiplyand accumulate. DSP ma specjalne instrukcje do szybkiego MAC Nasz DSP potrafi wykonywać dwie operacje MAC w tym samym cyklu (Dual MAC). Wymaga to zwykle użycia Asemblera.
Przepełnienie zakresu Arytmetyka stałoprzecinkowa jest wrażliwa na przepełnienie zakresu liczby. Np. x = 32760. Obliczamy (x + 10). Liczba 32770 nie mieści się na 15 bitach (>32767). Następuje przepełnienie (overflow) jedynka przeskakuje na bit znaku. Wynik 8002h jest interpretowany jako -32766! Dostajemy całkowicie błędny wynik!
Przepełnienie zakresu Jak bronić się przed przepełnieniem? Najlepiej zapewnić, aby żadna cząstkowa suma nie przekraczała zakresu. Np. w naszym algorytmie mamy współczynniki 5 0,2 = 1. Jeżeli nie możemy tego zapewnić, możemy użyć 40-bitowego rejestru (typ longlong), ale spowalnia to obliczenia. Możemy też użyć arytmetyki nasycenia.
Arytmetyka nasycenia Saturationarithmetic obcina wartości przekraczające zakres do wartości maksymalnej. Np. dla liczb Q15 wynik jest równy: 32767, gdy wynik > 32767, -32768, gdy wynik < -32768. Wyniki nadal są błędne, ale przynajmniej nie ma przeskoku na wartości z przeciwnym znakiem. Procesor DSP ma możliwość włączenia trybu nasycenia. Kosztuje to jeden cykl na operację.
Arytmetyka nasycenia Wybrane instrukcje wewnętrzne dla trybu nasycenia: _sadd / _lsadd dodawanie (wynik: int / long) _ssub / _lssub - odejmowanie _smpy / _lsmpy mnożenie _smac / _llsmac MAC (wynik: long / long long) UWAGA: instrukcje te włączają tzw. tryb ułamkowy (fractionalmode). Wynik typu long jest zapisywany w formacie Q31 (wynik Q30 jest mnożony razy 2)! Ułatwia to późniejszą konwersję do typu int.
Obliczenie średniej (c.d.) Stara wersja naszego algorytmu: for (i = 0; i < n; i++) { // pętla po próbkach wynik += (long)bufor[poz] * WSP; poz = _circ_incr(poz, -1, N); } Wersja z wykorzystaniem MAC i nasycenia: for (i = 0; i < n; i++) { // pętla po próbkach wynik = _smac(wynik, bufor[poz], WSP); poz = _circ_incr(poz, -1, N); }
Konwersja wyniku do typu int Po wykonaniu obliczeń, wynik (typu long) musi być skonwertowany do typu int. Są dwie metody. Obcięcie usunięcie najmłodszych 15 bitów (Q30) lub 16 bitów (Q31) poprzez przesunięcie bitowe. Należy pamiętać o rzutowaniu typu. // wynik: zmienna typu long (Q30) int wyjscie = (int)(wynik >> 15);
Konwersja wyniku do typu int Drugi sposób zaokrąglenie. Bardziej kosztowne, ale zmniejsza błędy zaokrągleń o połowę. Przed przesunięciem o n bitów, dodajemy 2 n-1. // wynik: zmienna typu long (Q30) int wyjscie = (int)((wynik + 16384) >> 15); Dla liczb Q31 w trybie ułamkowym mamy instrukcję: int wyjscie = (int)(_sround(wynik) >> 16);
Obliczanie średniej (finał) Mamy już cały kod obliczający średnią z ostatnich pięciu próbek sygnału: y( n) = 0,2x( n) + 0,2x( n 1) + 0,2x( n 2) + 0,2x( n 3) + 0,2x( n Algorytm wygładza sygnał: 4)
Obliczanie średniej Jak wygląda charakterystyka częstotliwościowa naszego algorytmu?
Obliczanie średniej Możemy łatwo zmodyfikować nasz algorytm tak, aby liczył średnią z N próbek, ze współczynnikiem 1 / N
Obliczanie średniej To, co napisaliśmy, jest przykładem filtru cyfrowego typu FIR: o skończonej odpowiedzi impulsowej. Jedyna różnica to taka, że w filtrze FIR, współczynniki zwykle nie są stałe (u nas: stałe 0,2). Ale implementacja jest taka sama. Czyli wiemy już jak zaimplementować filtr FIR na DSP! Więcej o praktycznych implementacjach FIR - na kolejnym wykładzie.
Ważne rzeczy do zapamiętania Struktura kodu DSP w języku C Bufory deklarujemy poza main Typy int i long, rzutowanie i konwersje Arytmetyka Q mnożenie, dodawanie, MAC Problem przepełnienia zakresu, nasycenie Instrukcje wewnętrzne DSP