Nasycenie
Wiemy już w jaki sposób usunąć składową stałą. Nadal jednak musimy zmniejszyć liczbę bitów. Dwie najbardziej popularne opcje to obcięcie albo nasycenie. Ich porównanie widzimy na rysunku 14, wygenerowane w programie ModelSim za pomocą komendy:
do truncation_saturation.do
Kod do generowania pokazuje listing 6. Na górnym wykresie widać zawartość 8-bitowego rejestru data. Linia 21 wskazuje, że zawiera on sinusoidę o amplitudzie 80. Kolejne dwa wykresy zawierają tę samą funkcję, ale przyciętą do długości siedmiu bitów. Możliwa jest prezentacja sygnału z zakresu –64…63. Następuje więc strata części informacji.
12 module truncation_saturation;
13 parameter N = 8;
14 logic signed [N-1:0]data;
15 logic signed [N-2:0]data_t;
16 logic signed [N-2:0]data_s;
17
18 initial begin
19 for (int i = 0; i < 100; i++) begin
20 #1;
21 data = 80*$sin(2*pi*i/100);
22 data_t = data[N-2:0];
23 if (data > 2**(N-2)-1)
24 data_s = 2**(N-2)-1;
25 else if (data < -(2**(N-2)))
26 data_s = -2**(N-2);
27 else
28 data_s = data[N-2:0];
29 end
30 $stop;
31 end
32 endmodule
Zmienna data_t pokazuje co się stanie, gdy odetniemy najstarszy bit. Kiedy przekraczamy maksymalną wartość, bit znaku, który wcześniej był równy zero, zostaje nadpisany jedynką, pochodzącą z danych, co powoduje zmianę znaku. W przypadku przetwarzania sygnałów dźwięku jest to zjawisko bardzo niekorzystne. Sygnał opisany jako data_s prezentuje sytuację, gdy zastosujemy nasycenie. Uzyskany przebieg jest bliższy wartościom wejściowym. Przed dokonaniem obcięcia sprawdzamy, czy nastąpiło przepełnienie. Jeżeli tak, to zamiast wejściowych danych na wyjście podajemy minimalną albo maksymalną możliwą wartość – w zależności od znaku odebranych danych.
Przykładową realizację modułu pokazuje rysunek 15. Mamy w nim dwa bloki kombinacyjne. Pierwszy z nich, nazwany overflow, wykrywa, czy przy obcięciu wystąpi przepełnienie. Na jego podstawie zewnętrzny multiplekser ustala, czy przekazujemy na wyjście wejściowe dane, czy którąś ze stałych. Wyboru stałej dokonuje pierwszy multiplekser na podstawie znaku wejściowych danych. Jest on zwracany przez blok oznaczony jako sign. Na końcu wynik jest zatrzaskiwany w rejestrze. Jak zwykle sygnał valid płynie razem z danymi przez drugi, tym razem resetowany przerzutnik.
10 module saturation #(
11 parameter N_IN = 10,
12 parameter N_OUT = 8
13 ) (
14 StreamBus in,
15 StreamBus out
16 );
17 logic overflow, sign;
18 logic [N_IN-1:N_OUT-1]truncated;
19 logic [N_OUT-1:0]data_out;
20
21 always_comb begin
22 sign = in.data[N_IN-1];
23 truncated = in.data[N_IN-1:N_OUT-1];
24 overflow = &truncated != |truncated;
25 if (overflow)
26 data_out = (sign) ? 2**(N_OUT-1) : 2**(N_OUT-1)-1;
27 else
28 data_out = in.data[N_OUT-1:0];
29 end
30
31 always_ff @(posedge in.clk)
32 out.data <= data_out;
33
34 always_ff @(posedge in.clk or negedge in.rst)
35 if (!in.rst)
36 out.valid <= ‘0;
37 else
38 out.valid <= in.valid;
39
40 endmodule
Przejdźmy teraz do implementacji, którą pokazuje listing 7. Moduł saturation przyjmuje dwa parametry: N_IN i N_OUT, określające szerokość wejścia i wyjścia. Dalej, podobnie jak w module dc_r, mamy zdefiniowane interfejs wchodzący i wychodzący z modułu. Główna część logiki znajduje się w bloku always_comb. W linii 22 wykrywamy znak wejściowych danych. Jest to bardzo prosta operacja: wystarczy wyciągnąć najstarszy bit.
Następnie przygotowujemy wektor zawierający wszystkie bity, które zostaną obcięte i jeden (najstarszy), który zostanie nowym bitem znaku.
Wektor ten został nazwany truncated. W kolejnym wierszu wykrywamy, czy nastąpiło przepełnienie. Korzystamy tutaj z prostej zależności. Jeśli nie nastąpiło przepełnienie, wtedy wszystkie obcięte bity (oraz nowy bit znaku) mają tę samą wartość. Jest to równoważne stwierdzeniu, że logiczna operacja i wykonana na tym wektorze dadzą taki sam wynik jak logiczne OR. Następnie w liniach 25…29 zrealizowane są oba multipleksery z rysunku 16. Pierwszy za pomocą operatora ?:, a drugi z wykorzystaniem instrukcji if/else.
Zostało nam już tylko zaimplementowanie dwóch przerzutników, zatrzaskujących obliczone wyjście. Pierwszy z nich (31…32) obsługuje dane, a drugi (34…38), wyposażony w reset, dba o kontrolny sygnał valid.
12 module saturation_tb;
13 parameter N_IN = 10;
14 parameter N_OUT = 8;
15
16 logic clk, rst;
17 StreamBus #(.N(N_IN)) in (.clk(clk), .rst(rst));
18 StreamBus #(.N(N_OUT)) out (.clk(clk), .rst(rst));
19
30 initial begin
31 in.valid = 1’b1;
32 for (int i = -2**(N_IN-1); i < 2**(N_IN-1); i++) begin
33 @(posedge clk);
34 in.data = i;
35 end
36 $stop;
37 end
38
39 saturation dut (
40 .in(in),
41 .out(out)
42 );
43 endmodule
Testbench dla naszego modułu znajduje się na listingu 8. Na początku definiujemy dwa parametry określające rozmiar wejścia i wyjścia. Zostały one później wykorzystane przy tworzeniu interfejsów. Generowanie sygnałów zegarowego i resetu jest identyczne jak w poprzednim testbenchu, dlatego zostały pominięte. Zmiana następuje przy generowaniu danych. Za pomocą pętli for (linia 32) generujemy funkcje liniową, która przechodzi przez wszystkie dopuszczalne wartości wejścia. Dla 10 bitów rozpoczniemy więc od –512 i dojdziemy do wartości 511, po czym zakończymy symulację.
Teraz w programie ModelSim możemy wywołać polecenie:
do saturation_sim.do
Pojawią się przebiegi podobne do tych z rysunku 16. W pierwszych trzech wierszach znajdują się sygnały: zegarowy, resetu oraz valid. Następnie znajdziemy dane wejściowe przedstawione w formie wykresu. Pod nim są wewnętrzne sygnały modułu. Linia sign (znak) zmienia się z 1 na 0, gdy dane wejściowe staną się nieujemne. Dwa kolejne wiersze to wektor truncated oraz sygnał overflow. Widzimy, że dla bardzo małych i bardzo dużych wejść bity wektora truncated mają różne wartości, co powoduje pojawienie się jedynki logicznej w sygnale overflow. Pod nim znajdziemy dane wyjściowe. Dla mocno ujemnych wymuszeń otrzymujemy na wyjściu stałą wartość ujemną. Następnie, gdy pojawi się sygnał, który mieści się w obsługiwanym przedziale, zobaczymy funkcję liniową, która narasta, aż do osiągnięcia maksymalnej wartości dodatniej, gdzie znowu ulega nasyceniu.