DDS Синтезатор на Verilog


В этом посте я поделюсь тем, как разбирался с написанием DDS синтезатора на Verilog. Он будет использован для генерации синусоидального колебания, частоту и начальную фазу которого можно регулировать и рассчитан для использования с 8-битным однополярным ЦАП. О том, как работает синтезатор хорошо написано в статье журнала Компоненты и Технологии. Для сокращения объема использованной памяти таблицы синуса использована симметрия.


Для компиляции под Линуксом я использовал Iverilog, а для отображения GTKWave. Для удобства был написан простенький Makefile, возможно кому-нибудь он пригодится. Изначально при помощи компилятора iverilog мы получаем файл tb.out, а затем отправляем его в симулятор vvp, который устанавливается вместе с iverilog. В результате vvp сгенерирует out.vcd, в котором содержатся все переменные (сигналы), используемые в проекте. Цель display помимо вышесказанного запустит GTKWave с файлом переменных и можно будет увидеть временные диаграммы сигналов.


SRC = nco.v
TB = nco_tb.v

all:
    iverilog -o tb.out $(TB)
    vvp -lxt tb.out

check:
    iverilog -v $(TB) 

display:
    iverilog -o tb.out $(TB)
    vvp -lxt tb.out
    gtkwave out.vcd &

clean:
    rm -rf *.out *.vcd *.vvp

В первую очередь необходимо разместить в памяти таблицу будущего синуса, для я написал простенький скрипт на Python, разбивающий четверть периода синуса на 64 точки и генерирующий в формате, который потом можно скопировать в исходный код. Так как я задумывал реализацию DDS для внешнего однополярного ЦАП с разрядностью не более 8 бит, то амплитуда синуса должна быть в интервале от 0 до 256, где в диапазоне 0...127 лежит отрицательный полупериод, а в 128...255 — положительный. В связи с этим полученные значения синуса (от 0 до pi/4) умножаются на 127, а затем ещё к ним прибавляется 127. В результате получается значения первой четверти периода, амплитуда которых 128...256.


Обращу внимание на то, что при таком формировании синус на выходе ЦАП будет иметь постоянную составляющую. Для того чтобы её убрать необходимо пропустить его через конденсатор.


import numpy as np

x=np.linspace(0,np.pi/2,64)
print(np.sin(x))
y=127*np.sin(x)
print(len(y))
print(y)
z=[]
i = 0
for elem in y:
    if int(elem)<=16:
        print("lut[%d] = 7'h0%X;" % (i, int(elem)))
    else:
        print("lut[%d] = 7'h%X;" % (i, int(elem)))
    z.append(hex(int(elem)))
    i = i + 1

Т.к синус функция симметричная (нечётная), то можно обнаружить первую симметрию sin(x)=-sin(pi+x). Вторая симметрия характерна тем, что имея таблицу на четверть периода, вторую четверть можно получить, проходя таблицу в обратном порядке (т.к. синус на полупериоде сначала возрастает, потом убывает).


Формируем синус


Основная часть DDS синтезатора — фазовый аккумулятор. По сути он является индексом элемента из Look Up Table (LUT). За каждый период тактового сигнала значение в нём увеличивается на некоторое значение, в результате на выходе получается синус. От значения приращения аккумулятора фазы будет зависеть частота сигнала на выходе — чем оно больше, тем выше частота. Однако, по критерию Котельникова частота дискретизации должна быть как минимум в 2 раза больше частоты сигнала (для избежания эффекта наложения спектра), отсюда ограничение на максимальное приращение — половина фазового аккумулятора. Вообще инженерный критерий это частота дискретизации = 2.2 частоты сигнала, поэтому, решив не доводить до крайности, я убрал ещё один разряд, оставив 6 бит на инкремент при разрядности фазового аккумулятора 8 бит (хотя уже при этом синус шакалит).


Из-за используемой симметрии непосредственно для выборки по индексу будут использоваться только младшие 6 бит 2^6=64. Старшие 2 бита используются для выявления четвертьпериода генерирования синуса и соответственно изменения направления обхода таблицы. Должно получиться что-то похожее на:


module nco(clk,
    rst,
    out
    );

input clk, rst;
output reg [7:0] out;

reg [5:0] phase_inc = 6'h1;
reg [7:0] phase_acc = 0;

parameter LUT_SIZE = 64;
reg [6:0] lut [0:LUT_SIZE-1];

always @(posedge clk) begin
    if (rst) begin
        phase_inc = 6'h1;
        phase_acc = 0;
        out = 0;
        lut[0] = 7'h00;
        // Целиком таблица не приведена
        lut[63] = 7'h7F;
    end
    else begin
        // Отсчеты формируются с латентностью в 1 период тактового сигнала
        if (phase_acc[7:6] == 2'b00) begin
        //Склеиваем старший бит полярности и младшие биты из LUT
            out <= {1'b1,lut[phase_acc[5:0]]};
        end
        if (phase_acc[7:6] == 2'b01) begin
            out <= {1'b1,lut[~phase_acc[5:0]]};
        end
        if (phase_acc[7:6] == 2'b10) begin
            out <= {1'b0,~lut[phase_acc[5:0]]};
        end
        if (phase_acc[7:6] == 2'b11) begin
            out <= {1'b0,~lut[~phase_acc[5:0]]};
        end
        phase_acc <= phase_acc + {2'b0,phase_inc};
    end
end

endmodule

При сбросе инициализируем всё нулями, кроме значения инкремента фазы, его устанавливаем в единицу. Для сохранения синтезируемости кода таблицу значениями будем заполнять также во время сброса. В реальном проекте желательно под такие цели использовать встроенную в ПЛИС блочную память и создавать для неё отдельный конфигурационный файл, а в самом проекте использовать IP ядро.


Немного пояснений о том, как работает симметрия. На каждом такте проверяется (по 2-м старшим битам), в какой четверти находится в данный момент фазовый аккумулятор. Если старшие = 00, то на выходе в старшем разряде 1 (отвечает за положительную полуволну), в младших — значение из LUT в соответствии с индексом. После того как значение фазового аккумулятора превысит 63 (пройдет первая четверть), в старших битах появится 01, а младшие снова заполнятся нулями.


Для прохождения LUT в обратном порядке достаточно инвертировать младшие биты фазового аккумулятора (он продолжит увеличиваться за каждый такт, а его инвертированное значение будет уменьшаться).


Для формирования отрицательной полуволны в старший разряд выходных данных запишем 0. А инвертировать теперь необходимо само значение из таблицы синуса. Тут смысл состоит в том, что необходимо получить зеркальную копию четверти синуса, а если этого не сделать, то получится тот же рисунок, что и в первой четверти, но опущенный на 127 вниз. Можете проверить это, убрав инверсию в коде.


Меняем частоту и начальную фазу


Как уже было описано выше для изменения частоты необходимо поменять значение инкремента фазы. Появятся новые входы:


input [5:0] freq_res;
input [7:0] phase;

Для изменения значения инкремента фазы будем просто защелкивать его на каждом такте:


always @(posedge clk) begin
    if (rst) begin
        //...
    end
    else begin
        //...
        phase_inc <= freq_res;
    end
end

С начальной фазой все не так просто. Необходимо сначала записывать ее в промежуточный регистр, и заполнять этим значением аккумулятор фазы только в том случае, если значение начальной фазы на входе не совпадает с запомненным ранее. Тут возникает ещё один важный момент, связанный с состоянием гонок. У нас уже имеется место, где мы записываем в регистр phase_acc. Нельзя записывать одновременно в нескольких местах, поскольку при этом запишутся данные, которые пришли первыми. Поэтому конструкция будет выглядеть следующим образом:


reg change_phase = 0; //Вверху объявляем еще один сигнал

// Не забываем сбросить его  (тут это пропущено)

// На каждом такте выполняем следующее:
prev_phase <= phase;
if (phase != prev_phase) begin
    // Проверяем изменилась ли фаза на входе
    change_phase <= 1'b1;
end
if (change_phase) begin
    // Заменяем значение в фазовом аккумуляторе новой фазой
    phase_acc <= prev_phase;
    change_phase <= 1'b0;
end
else begin
    // Инкрементировать фазовый аккумулятор теперь можно только если не изменилась фаза
    phase_acc <= phase_acc + {2'b0,phase_inc};
end

Testbench


Код тестбенча для Iverilog и GTKWave обладает некоторыми конструкциями (со знаком доллара) которые не используются в привычных ISE Design Suite или Quartus. Смысл их сводится к тому, чтобы выбрать отслеживаемые сигналы и загрузить их в файл, чтобы затем передать симулятору. Сама по себе работа тестбенча тривиальна — делаем сброс, устанавливаем частоту/начальную фазу и ждем некоторое время.


`include "nco.v"
`timescale 1ns / 1ps

module nco_tb;

reg clk = 0, rst = 0;
reg [7:0] phase = 0;
reg [5:0] freq_res;
wire [7:0] out;

nco nco_inst
    (
    .clk(clk),
    .rst(rst),
    .phase(phase),
    .freq_res(freq_res),
    .out(out)
    );

always
    #2 clk <= ~clk;

initial
    begin
        $dumpfile("out.vcd");
        $dumpvars(0, nco_tb);
        //$monitor("time =%4d   out=%h",$time,out);
        rst = 1'b1;
        freq_res = 1;
        #8
        rst = 1'b0;
        #300
        phase = 8'b00100011;
        #300
        phase = 8'b00001111;
        #1200
        freq_res = 6'b111101;
        #1200
        freq_res = 6'b001111;
        #1200
        freq_res = 6'b011111;
        #400
        phase = 8'b00010011;
        #1200
        $finish;
    end

endmodule

Временные диаграммы


На выходе получим что-то похожее на синус с меняющейся частотой и начальной фазой в установленные в тестбенче моменты времени. Стоит отметить, что с ростом частоты падает разрешение по ней (количество отсчетов на период), соответственно частота тактирования синтезатора и размер его LUT играет решающую роль в воспроизведении чистого синуса (чем больше его форма приближается к идеальному, тем меньше побочных составляющих будет в спектре результирующего сигнала и тем уже будет пик на генерируемой частоте).



Тут видно, что сигнал со второй частотой имеет уже не такой гладкий синус, как другие. Рассмотрим его поближе.



Видно, что на синус это все же немного похоже, результат станет ещё лучше после того как такой сигнал будет пропущен через антиалиасинговый фильтр (Фильтр Нижних Частот).


Исходники проекта доступны по ссылке.


Источники


Средняя зарплата в IT

113 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 5 123 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 12

    +3
    1. использование блокирующих присваиваний (=) в последовательностном блоке (always @(posedge clk)) является некорректным;
    2. у производителей ПЛИС есть рекомендации на тему использования синхронного/асинхронного ресета и его уровня, следовать им — хороший тон;
    3. почитайте про inferred ram, inferred rom и функции readmemh, readmemb — с помощью этого можно избежать "зашивания" данных (lut[0] = ...) в код
      0
      Большое спасибо за замечания, обязательно исправлю и учту. Про блочную память слышал, просто в этом случае хотелось написать универсальное решение, которое на зависело бы от выбора производителя и модели ПЛИС, поэтому и пришлось прибегнуть к такому решению. Также не совсем было понятно, поддерживает ли IVerilog мегафункции как, например в Xilinx ISE.
        +1

        Для использования inferred ram/rom не нужно явно инстанцировать какую-то IP или мегафункцию. Средства симуляции и синтеза успешно распознают написанную определенным образом Verilog-конструкцию и сами добавляют "вместо нее" блочную память (к слову, это же справедливо для умножителей, DSP-блоков, буферов и т.д.). Для Vivado см. UG901, раздел "RAM HDL Coding Guidelines", для Quartus: Quartus Handbook, раздел "Inferring Memory Functions from HDL Code".
        Примеры можно посмотреть вот здесь: интерфейс Quartus — Create New File, Verilog; меню Edit -> Insert Template.
        Там есть небольшие различия: какой-то код может синтезится в блокрам для квартуса, но при этом собираться на лутах под вивадой, либо наоборот. Но чаще всего можно прийти к общему знаменателю.
        Еще примеры: раз, два.

        +1
        В общем случае некорректным является только одновременное использование блокирующих и неблокирующих присваиваний для одной и той же переменной.
          0

          См. Харрис&Харрис, раздел 4.5.4 "Блокирующие и неблокирующие присваивания". Приведены примеры, почему не стоит использовать блокирующие присваивания в последовательностной логике, и неблокирующие в комбинационной. В одном случае это может привести к некорректному синтезу, а в другом — к замедлению симуляции

            0
            Стандартный оператор «for» уже подразумевает использование блокирующего присваивания (переменной цикла) в последовательном блоке.
            Пример — поиск старшей «1» в векторе:
            always @(posedge clk) begin n<=-1; for(int i=0; i<N; i++) if(a[i]) n<=i; end
            Другой пример — локальные переменные, объявленные внутри последовательного блока, и которые принимают результаты промежуточных вычислений:
            alvays@(posedge clk) begin
            var [7:0] a;

            a = …;

            end
        0
        Спасибо за статью.
        Правильно ли я понял, что разрешающая способность по фазе — всего 64 точки за четверть периода?
        Не удалось ли оценить, не даст ли это больше шума квантования, чем разрешение ЦАП (8 бит)?
          0
          Спасибо за комментарий. Да, верно. С одной оговоркой, что это — максимальная разрешающая способность для минимальной частоты сигнала на выходе. С ростом частоты аккумулятор фазы будет накапливаться всё быстрее, поэтому количество точек на период становится меньше, соответственно разрешающая способность тоже падает (сигнал на последнем рисунке меньше похож на синус и спектр его будет не идеальной «палкой» на частоте синуса). Возможно, неидеальность сигнала вследствие малого разрешения — это тот шум, о котором вы говорите, но не следует путать его с шумом квантования.
          Параметры шума квантования определяются только разрядностью ЦАП, дисперсия = q^2/12, мат. ожидание = q/2, где q — шаг квантования (q=A/2^N), A — максимум напряжения, N — разрядность ЦАП.
          0
          ROM можно инициализировать при объявлении (удобно использовать «упакованные» массивы из SV):
          reg [LUT_SIZE-1:0][6:0] lut = {7'h7F, 7'h7E,… 7'h03, 7'h00};
            0
            Насколько я помню это работает только для симуляций.
              0
              Quartus синтезирует. В ISE/Vivado не проверял.

                0
                Хорошо. Для FPGA я очень давно не синтезировал.
                Я думаю, что вот это будет работать для всех:
                wire [LUT_SIZE-1:0][6:0] lut = {7'h7F, 7'h7E,… 7'h03, 7'h00};

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое