Pull to refresh

Функциональный DDS rенератор на ПЛИС

Reading time 11 min
Views 73K
Недавно я увидел проект генератора сигналов на микроконтроллере AVR. Принцип генерации — DDS, на базе библиотеки Jesper максимальная частота — 65534 Гц (и до 8 МГц HS выход с меандром). И тут я подумал, что генератор — отличная задача, где ПЛИС сможет показать себя в лучшем виде. В качестве спортивного интереса я решил повторить проект на ПЛИС, при этом по срокам уложиться в два выходных дня, а параметры получить не строго определенные, а максимально возможные. Что из этого получилось, можно узнать под катом



День нулевой


До того, как наступят выходные, у меня было немного времени подумать над реализацией. Чтобы упростить себе задачу, решил сделать генератор не в виде отдельного устройства с кнопками и LCD экраном, а в виде устройства, которое подключается к ПК через USB. Для этого у меня есть плата USB2RS232. Плата драйверов не требует (CDC), поэтому, я думаю, что и под Linux будет работать (для кого-то это важно). Так же, не буду скрывать, что с приемом сообщений по RS232 я уже работал. Модули для работы с RS232 буду брать готовые c opencores.com.

Для генерации синусоидального сигнала потребуется ЦАП. Тип ЦАП я выбрал, как и в исходном проекте — R2R на 8 бит. Он позволит работать на высоких частотах, порядка мегагерц. Убежден, что ПЛИС с этим должна справиться

По поводу того, на чем написать программу для передачи данных через COM порт я задумался. С одной стороны, можно написать на Delphi7, опыт написания такой программы уже есть, к тому же размер исполняемого файла будет не большим. Еще попробовал набросать что-то для работы с Serial в виде java скрипта в html страничке, но более менее заработало только через Chrome serial API, но для этого надо устанавливать плагин… в общем тоже отпадает. В качестве новшества для себя попробовал PyQt5, но при распространении такого проекта, нужно тащить кучу библиотек. Попробовав собрать PyQt проект в exe файл, получилось больше 10 мб. То есть, будет ничем не лучше приложения, написанного на c++\Qt5. Стоит еще учесть, что опыта разработки на python у меня нет, а вот на Qt5 — есть. Поэтому выбор пал на Qt5. С пятой версии там появился модуль для работы с serial и я с ним уже работал. А еще приложение на Qt5 может быть перенесено на Linux и Mac (для кого-то это важно), а с 5.2 версии, приложения на QWidgets может быть перенесено даже на смартфон!

Что еще нужно? Естественно плата с ПЛИС. У меня их две (Cyclone iv EP4CE10E22C8N на 10 тыс. ячеек, и Cyclone ii EP2C5 на 5 тыс. ячеек). Я выберу ту, что слева, исключительно по причине более удобного разъема. В плане объема проект не предполагает быть большим, поэтому уместится в любую из двух. По скорости работы они не отличаются. Обе платы имеют «на борту» генераторы 50 МГц, а внутри ПЛИС есть PLL, с помощью которого я смогу увеличить частоту до запланированных 200 МГц.



День первый


В связи с тем, что модуль DDS я уже делал в своем синтезаторном проекте, то я сразу взялся за паяльник и начал паять ЦАП на резисторах. Плату взял макетную. Монтаж делал с применением накрутки. Единственное изменение, которое коснулось технологии — я отказался от кислоты Ф38Н для лужения стоек в пользу индикаторного флюс-геля ТТ. Суть технологии проста: в печатную плату впаиваю стойки, на них со стороны печатного монтажа припаиваю резисторы. Недостающие соединения выполняю накруткой. Еще, стойки удобны тем, что я их могу вставить прямо в плату ПЛИС.

К сожалению, дома в наличии не оказалось резисторов 1 и 2 килоома. Ехать в магазин было некогда. Пришлось поступиться одним из своих правил, и выпаять резисторы из старой не нужной платы. Там применялись резисторы 15К и 30К. Получился вот такой франкенштейн:


Дальше я запустил Quartus, создал проект
После создания проекта нужно задать целевое устройство: Меню Assigments -> Device



Далее там же нажимаю кнопочку «Device and Pin options» потому что некоторые пины настроены так, что работать не будут. Настраиваю все, как «Use as regular I/O»


В проекте я «нахадркодил» неуправляемый главный модуль DDS на фиксированную частоту.

Модуль генератора на 1000 Гц
module signal_generator(clk50M, signal_out);

input wire clk50M;
output wire [7:0] signal_out;

wire clk200M;
osc osc_200M

reg [31:0] accumulator;

assign signal_out = accumulator[31:31-7];

//пробуем генерировать 1000 Гц
//50 000 000 Hz - тактовая частота внешнего генератора
//2^32 = 4 294 967 296 - разрядность DDS - 32 бита
//делим 1000Hz / 50 000 000 Hz / 2 * 4294967296   => 42949,67296
always @(posedge clk50M) begin
	accumulator <= accumulator + 32'd42949;
end

endmodule

После этого нажал «Start Compilation», чтобы среда разработки задалась вопросом, какие у нас линии ввода вывода есть в главном модуле проекта и к каким физическим PIN's они подключены. Подключить можно практически к любому. После компиляции назначаем появившиеся линии к реальным PIN микросхемы ПЛИС:

Пункт меню Assigments -> Pin Planner
На линии HS_OUT, key0 и key1 прошу пока не обращать внимание, они появляются в проекте потом, но скрин в самом начале я сделать не успел.



В принципе, достаточно «прописать» только PIN_nn в столбце Location, а остальные параметры (I/O standart, Current Strench и Slew Rate) можно оставить по умолчанию, либо выбрать такие же, что предлагаются по умолчанию (default), чтобы не было warning'ов.

Как узнать какому PIN соответствует номер разъема на плате?
Номера контактов разъема подписаны на плате


А пины ПЛИС, к которым подключены контакты разъема, описаны в документации, которая идет в комплекте с платой ПЛИС.




После того, как пины назначены, компилирую проект еще раз и прошиваю с помощью USB программатора. Если у вас не установлены драйверы для программатора USB Byte blaster, то укажите Windows, что они находятся в папке, куда у вас установлен Quartus. Дальше она сама найдет.

Подключать программатор нужно к разъему JTAG. А пункт меню для программирования «Tools -> Programmer» (либо нажать значек на панели инструментов). Кнопка «Start», радостное «Success» и прошивка уже внутри ПЛИС и уже работает. Только не выключайте ПЛИС, а то она все забудет.

Tools -> Programmer

ЦАП подключен к разъему платы ПЛИС. К выходу ЦАП подключаю осциллограф С1-112А. В результате должна получиться «пила» потому что на выход 8 бит выводится старшая часть слова DDS аккумулятора фазы. А оно всегда увеличивается, пока не переполнится.

Каких-то 1.5 часа и для частоты в 1000 Гц я вижу следующую осциллограмму:



Хочу заметить, что «пила» по середине имеет небольшой перелом. Он связан с тем, что резисторы имеют разброс значений.

Еще один важный момент, который нужно было выяснить — это максимально возможная частота, с которой будет работать DDS генератор. При правильно настроенных параметрах TimeQuest, после компиляции в «Compilation Report» можно увидеть, что скорость работы схемы выше 200 МГц с запасом. А это значит, что частоту генератора 50 МГц я буду умножать с помощью PLL на 4. Увеличивать значение аккумулятора фазы DDS буду с частотой 200 МГц. Итоговый диапазон частот, который можно получить в наших условиях 0 — 100 МГц. Точность установки частоты:

 200 000 000 Гц (clk) / 2^32 (DDS) = 0,047 Гц

То есть, это лучше, чем ~0.05 Гц. Точность в доли герца для генератора с таким диапазоном рабочих частот (0...100 МГц) считаю достаточной. Если кому-то потребуется повысить точность, то для этого можно увеличить разрядность DDS (при этом не забыть проверить TimeQuest Timing Analyzer, что скорость работы логической схемы укладывалась в CLK=200 МГц, ведь это сумматор), либо просто снизить тактовую частоту, если такой широкий диапазон частот не требуется.

TimeQuest Timing Analyzer

После того, как я увидел на экране «пилу», семейные дела заставили меня ехать на дачу (выходной же). Там я косил, варил, жарил шашлык и не подозревал о том сюрпризе, что ждал меня вечером. Уже ближе к ночи, перед сном, я решил посмотреть форму сигнала для других частот.

Для частоты 100 КГц
Для частоты 250 КГц
Для частоты 500 КГц
Для частоты 1 МГц

Не буду скрывать, что форма сигналов меня расстроила, особенно на 1МГц (жалкий, никчемный мегагерц!). Я планировал получить частоты несколько других порядков. Почитав про R2R ЦАП стала ясна причина проблемы — паразитные емкости. Поэтому в планах на следующий день было решено сделать ЦАП на резисторах 100 и 200 Ом, которые у меня есть в наличии, а этот ЦАП оставить для будущих разработок, не требующих работы на таких высоких частотах, ведь в гладкости пилы тоже есть свой плюс.

День второй


В связи с тем, что было интересно, как будет работать ЦАП на резисторах 100 и 200 Ом, я сразу взялся за паяльник. На этот раз ЦАП получился более аккуратным, а времени на его монтаж ушло меньше.





Ставим ЦАП на плату ПЛИС и подключаем к осциллографу



Проверяем 1 МГц — ВО! Совсем другое дело!



Пила 10 МГц
Пила 25 МГц

Форма пилы на 10 МГц еще похожа на правильную. Но на 25 МГц она уже совсем «не красивая». Однако, у С1-112а полоса пропускания — 10 МГц, так что в данном случае причина может быть уже в осциллографе.

В принципе, на этом вопрос с ЦАП можно считать закрытым. Теперь снимем осциллограммы высокоскоростного выхода. Для этого, выведем старший бит на отдельный PIN ПЛИС. Данные для этой линии будем брать со старшего бита аккумулятора DDS.

assign hs_out  =  accumulator[31];

Меандр 1 МГц
Меандр 5 МГц
Меандр 25 МГц
Меандр 50 МГц уже практически не виден

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

Синус делается по таблице. Размер таблицы 256 значений по 8 бит. Можно было бы взять и больше, но у меня уже был готовый mif файл. С помощью мастера создаем элемент ROM с данными таблицы синуса из mif-файла.

Создание ROM - Tools -> Mega Wizard Plugin manager

Выбираем 1 портовую ROM и задаем название модулю



Соглашаемся



Тут тоже соглашаемся



С помощью browse находим наш mif файл с таблицей синуса



Тут тоже ничего не меняем



Снимаем галочку с модуля sine_rom_bb.v — он не нужен. Дальше finish. Квартус спросит добавить модуль в проект — соглашаемся. После этого, модуль можно использовать так же, как любой другой модуль в Verilog.


Старшие 8 бит слова аккумулятора DDS будут использоваться в качестве адреса ROM, а выход данных — значение синуса.

Код
//sine rom
wire [7:0] sine_out;
sine_rom sine1(.clock(clk200M), .address(accumulator[31:31-7]), .q(sine_out));

Осциллограмма синуса на разных частотах выглядит… одинаково.



При желании, можно рассмотреть проблемы ЦАП, связанные с разбросом резисторов:



Чтож, на этом выходные кончились. А ведь еще не написано ПО для управления с ПК. Вынужден констатировать факт, что в запланированные сроки я не уложился.

День третий


Времени совсем мало, поэтому программу пишем на скорую руку (в лучших традициях). Местами, чтобы сократить количество букв и удобство ввода информации с клавиатуры, применяется фильтр событий по имени виджета. Прошу понять и простить.

Интерфейс



Исходный код на GitHub. Там же есть уже собранное под windows приложение.

Код прост, как 5 копеек. В файл проекта .pro кроме всего прочего нужно добавить модуль serialport:

QT       += core gui serialport

Открытие COM порта
        QSerialPort serial;
        ...
        serial.setPortName(ui->lbSerialPortInfo->currentText());
        serial.setBaudRate(QSerialPort::Baud115200);
        serial.setDataBits(QSerialPort::Data8);
        serial.setParity(QSerialPort::NoParity);
        serial.setStopBits(QSerialPort::OneStop);
        serial.setFlowControl(QSerialPort::NoFlowControl);
        serial.open(QIODevice::ReadWrite);
Формирование и отправка сообщения
    QByteArray source;
    QDataStream stream(&source, QIODevice::ReadWrite);
    stream << (qint8)(01); // set freq msg
    stream << waveform;
    stream << adder32;
    serial.write(source);

День четвертый


В спешном порядке доделываем прием данных по UART. Для приема сообщений по UART нужно поставить пару модулей. Один Baud генератор, второй — приемник. Для того, чтобы приемник работал на 115200, нужно произвести некоторые рассчеты, исходя, что основная тактовая частота у нас — 200 МГц.

Модуль baud_gen
parameter global_clock_freq = 200000000;
parameter baud_rate = 115200;
//бодген - модуль для генерации клока UART
// first register:
// 		baud_freq = 16*baud_rate / gcd(global_clock_freq, 16*baud_rate)
//Greatest Common Divisor - наибольший общий делитель. http://www.alcula.com/calculators/math/gcd/
// second register:
// 		baud_limit = (global_clock_freq / gcd(global_clock_freq, 16*baud_rate)) - baud_freq 


//можно добавить значения для других скоростей
parameter GCD = (baud_rate==115200) ? 12800 : 0;

parameter baud_freq  = 16*baud_rate / GCD;
parameter baud_limit = (global_clock_freq / GCD) - baud_freq;

wire uart_clk;
baud_gen BG(.clock(clk), .reset(rst), .ce_16(uart_clk), .baud_freq(baud_freq), .baud_limit(baud_limit));

Ставлю модуль приема сообщений, на него подается uart_clk и сигнал с физического входа ПЛИС.

Модуль uart_rx
//RCV
wire [7:0] uart_command;
wire uart_data_ready;

uart_rx URX(.clock(clk), 
		      .reset(rst),
				.ce_16(uart_clk), 
				.ser_in(rx), 
				.rx_data(uart_command), 
				.new_rx_data(uart_data_ready) );

Дальше все это помещаю в отдельный модуль, который выдает только номер формы волны и значение приращения к регирстру аккумулятора фазы DDS.

Модуль ctrl.v
module ctrl(clk, rst, rx, wf, adder);
input wire clk, rst, rx;
output wire [7:0] wf; //wave form
output wire [31:0] adder;// adder value

reg [7:0] wf_reg;
initial wf_reg <= 8'd0;

reg [31:0] adder_reg;
initial adder_reg <= 32'd1073741;

Когда модуль uart_rx принял байт информации, он на один такт ставит в единицу линию uart_data_ready. В это время на линии uart_command находится принятый байт. Для приема сообщения пишу стейтмашину.

Стейт-машина
//rcv state machine
parameter SM_READY     = 4'd0;
parameter SM_FRQ_WF    = 4'd1;
parameter SM_FRQ_DDS1  = 4'd2;
parameter SM_FRQ_DDS2  = 4'd3;
parameter SM_FRQ_DDS3  = 4'd4;
parameter SM_FRQ_DDS4  = 4'd5;

//messages
parameter CMD_SETFREQ = 8'd1;

reg [3:0] rcv_state;
initial rcv_state <= SM_READY;

always @ (posedge clk) begin 
	
	if (uart_data_ready==1) begin
		if (rcv_state==SM_READY) begin
			rcv_state = (uart_command==CMD_SETFREQ) ? SM_FRQ_WF : rcv_state;
		end else if (rcv_state==SM_FRQ_WF) begin
			wf_reg <= uart_command;
			rcv_state <= rcv_state + 1'b1;
		end else if (rcv_state==SM_FRQ_DDS1) begin
			adder_reg <= (adder_reg << 8) + uart_command;
			rcv_state <= rcv_state + 1'b1;
		end else if (rcv_state==SM_FRQ_DDS2) begin
			adder_reg <= (adder_reg << 8) + uart_command;
			rcv_state <= rcv_state + 1'b1;
		end else if (rcv_state==SM_FRQ_DDS3) begin
			adder_reg <= (adder_reg << 8) + uart_command;
			rcv_state <= rcv_state + 1'b1;
		end else if (rcv_state==SM_FRQ_DDS4) begin
			adder_reg <= (adder_reg << 8) + uart_command;
			rcv_state <= SM_READY;
		end else begin
			rcv_state <= SM_READY;
		end
	end //ucom_ready
	
end

Выводим данные на выходы модуля

Вывод
assign adder = adder_reg;
assign wf = wf_reg;


Добавляем модуль приема в главный модуль.

Добавляем модуль ctrl в главный модуль
//rs232 rcvr
wire [31:0] adder_value;
wire [7:0] waveform;
ctrl ctrl_0(.clk(clk200M), .rst(rst), .rx(RS232in), .wf(waveform), .adder(adder_value));

Значение приращения прибавляем к аккумулятору фазы

Увеличиваем значение аккумулятора с каждым тактом
always @(posedge clk200M) begin
	accumulator <= accumulator + adder_value;
end

Из старшей части значения аккумулятора фазы получаем остальные волноформы. А в зависимости от выбранной формы — подключаем ее на выход.

Волноформы
// wave_forms
parameter SINE      = 8'd0;
parameter SAW       = 8'd1;
parameter RAMP      = 8'd2;
parameter TRIA      = 8'd3;
parameter SQUARE    = 8'd4;
parameter SAWTRI    = 8'd5;
parameter NOISE     = 8'd6;

wire [7:0] saw_out = accumulator[31:31-7];
wire [7:0] noise_out = 8'd127; //!
wire [7:0] ramp_out = -saw_out;	
wire [7:0] square_out = (saw_out > 127) ? 8'b11111111 : 1'b00000000;
wire [7:0] saw_tri_out = (saw_out > 7'd127) ?  -saw_out : 8'd127 + saw_out;	
wire [7:0] tri_out = (saw_out>8'd191) ? 7'd127 + ((saw_out << 1) - 9'd511) : 
                     (saw_out>8'd063) ? 8'd255 - ((saw_out << 1) - 7'd127) : 7'd127 + (saw_out << 1);
//sine rom
wire [7:0] sine_out;
sine_rom sine1(.clock(clk200M), .address(saw_out), .q(sine_out));

wire [7:0] signal = (waveform ==     SINE) ? sine_out :
                    (waveform ==      SAW) ? saw_out : 
                    (waveform ==     RAMP) ? ramp_out : 
                    (waveform ==     TRIA) ? tri_out :
                    (waveform ==   SQUARE) ? square_out :
                    (waveform ==   SAWTRI) ? saw_tri_out :
                    (waveform ==    NOISE) ? noise_out : 8'd127; //TODO


Я был почти не удивлен, что оно сразу заработало. Единственная ошибка, которую я нашел — была в рассчетах: я делил искомую частоту на CLK, потом еще на два, потом умножал на разрядность аккумулятора. Но этого делать не нужно, потому что у нас получается 1 период при изменении значении аккумулятора от 0 до МАХ. Делить дополнительно на 2 нужно только если в качестве выхода меандра брать старший бит аккумулятора частоты (в этом случае частота получается ниже в 2 раза). Но получение меандра я переделал.

День четвертый


В него можно включить время, потраченное в каждый день на оформление статьи.

Приступим к проверке. Сначала осциллографом.


На радиочастотах от 28 и до 100 МГц я решил послушать генератор с помощью SDR приемника, поставив антенну рядом с платой.







Выводы


Как это часто бывает в ИТ — с оценкой сроков произошла ошибка в 2 — 2.5 раза. Цель достигнута: на коленке собран генератор до 100 МГц. Однако, чтобы эту поделку можно было назвать полноценным генератором, потребуется поработать еще. Поэтому есть большие перспективы для развития. Из за срыва сроков, я не добавил то, что в принципе мог: 1) генератор шума; 2) генератор волны, которую пользователь рисует сам; 3) генератор цифровой последовательности. Еще в генераторе нет регулировки амплитуды и смещения.

Использовано 227 ячеек из 10000. Список того, что еще можно добавить в проект:
  • Расширить разрядность ЦАП
  • Увеличить количество выходов с генерируемыми сигналами
  • Применить микросхему ЦАП в большей разрядностью
  • Реализовать управление амплитудой и смещением
  • Добавить элементы управления, ЖК экран, для портативности
  • Добавить генератор шума и других простых форм волн
  • Добавить возможность загрузки произвольных форм волн

Я думаю, что любой желающий сможет это сделать самостоятельно, расширив проект нужным функционалом. Добавить проще, чем сделать с нуля. Формат команды управления очень простой, поэтому генератором можно управлять с микроконтроллера.

Исходные коды:https://github.com/UA3MQJ/fpga-signal-generator
UPDATE 2023.12 — проверено на сборку Using Qt version 5.15.10

Ссылки с аналогами


Далеко не полный список
Функциональный DDS генератор. Создан базе AVR. Частоты 0… 65534 Гц.
Обзор DDS-генератора GK101. Создан с применением ПЛИС Altera MAX240. Частоты до 10 МГц.
Многофункциональный генератор на PIC16F870. Частотный диапазон: 11 Гц — 60 кГц.
Аналоговый функциональный генератор. Частотный диапазон колеблется от 20 Гц до 300 кГц
USB функциональный генератор на AD9833. На базе микросхемы DDS.
Мини DDS — простейший передатчик на диапазон 137 кГц и не только. Частота 136 КГц.
DDS — функциональный генератор с «джамперным» управлением на ПЛМ.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+26
Comments 36
Comments Comments 36

Articles