Внешняя память

В прошлом разделе мы обсудили память, которая доступна на кристалле. Теперь перейдём к внешней памяти, а именно к DDR3, установленной на плате Tang Mega 138K Pro Dock.

RAM (Random Access Memory) — энергозависимая память с возможностью быстрого чтения и записи данных в произвольном порядке. Существует две основные технологии хранения данных: SRAM (Static RAM), с которой мы познакомились в процессе изучения B-SRAM, и DRAM (Dynamic RAM), ячейкам которой требуется постоянная регенерация (обновление заряда для предотвращения потери данных). Технология DDR является развитием динамического способа хранения данных.

DDR SDRAM (Double Data Rate Synchronous DRAM, чаще просто DDR) пришла на смену памяти SDR SDRAM (Single Data Rate). Отличие состоит в том, что в DDR передача данных осуществляется как по фронту, так и по спаду тактового сигнала, что удваивает пропускную способность. При этом управляющие команды и адреса по-прежнему передаются только по восходящему фронту.

Конфигурация DDR3

Для взаимодействия с DDR необходимо сгенерировать и настроить контроллер DDR3 Memory Interface IP: IP Core GeneratorSoft IP CoreMemory ControllerDDRx SDRAM Memory InterfaceDDR3 Memory Interface. Вендор предоставляет возможность генерации как полноценного контроллера (MC), так и отдельного физического интерфейса (PHY). При генерации MC также присутствует PHY, но контроллер берёт на себя управление им, предоставляя пользователю нативный или стандартный AXI4 интерфейс.

ddr3 ip core

По умолчанию уже стоят оптимальные настройки, однако наиболее важные параметры следует проверить и настроить вручную:

  1. AXI4 Interface: IP-ядро предоставляет 5 режимов диспетчеризации каналов AXI4 (во вкладке AXI Interface Options). При использовании AXI4, мы рекомендуем использовать режим Mode4, при котором каналы чтения и записи обладают равным приоритетом.

  2. CLK Configuration: IP-ядро оперирует тремя тактовыми доменами: двумя входными — низкочастотным clk (для внутренней базовой логики IP) и высокочастотным memory_clk (для PHY-уровня и тактирования чипа DDR3), а также одним выходным сигналом clk_out (для пользовательской логики), частота которого задаётся через CLK ratio относительно memory_clk.

  3. Memory Configuration: Параметр Dq Width определяет суммарную разрядность шины данных, а Dram Width — разрядность отдельной микросхемы памяти. Так как на нашей плате установлены два чипа, образующие общую 32-битную шину (спецификация платы), параметры Dq Width и Dram Width необходимо установить в значения 32 и 16 соответственно.

  4. Memory Address: Row Address и Column Address отвечают за разрядность адресного пространства. Значения Row = 15 и Column = 10 определяют 1 ГБ — максимально возможное пространство на Tang Mega 138K Pro.

Остальные параметры определяются физическими характеристиками конкретных чипов памяти и не оказывают влияния на интерфейс контроллера. Авторы курса используют значения из этого примера производителя платы для следующих параметров: CAS Latency, Rtt Nom, а также тайминги во вкладке Memory Timing.

Поскольку динамические ячейки памяти требуют регулярного циклического обновления, IP-ядро поддерживает автоматический и пользовательский режимы регенерации. Мы будем использовать автоматический, интервал работы которого задаётся параметром t_REFI во вкладке Memory Timings.

При старте системы необходимо отслеживать сигнал init_calib_complete, который поднимается после успешного завершения инициализации и калибровки памяти. Для корректного запуска процедуры калибровки используется сигнал pll_lock. Чтобы сформировать данный сигнал (как и разнообразные клоки и pll_stop для экономии ресурсов), нужно воспользоваться модулем синхронизации Gowin_PLL. Для этого нужно сгенерировать IP-ядро: IP Core GeneratorHard ModuleCLOCKPLL_ADV. Далее необходимо выбрать частоту клоков (в нашем случае CLKIN = 100, CLKOUT0 = 400) и на вкладке Common включить PLL Reset, Enable Lock и Clock Enable Ports.

Помимо пользовательского интерфейса (нативный или Full AXI4 Slave) и рассмотренных клоков/сбросов, IP-ядро содержит порты физического уровня (PHY). Данные порты предназначены для прямого подключения к внешней памяти через внешние порты top-модуля и привязаны к соответствующим физическим пинам (в файле .cst).

Подробнее про регенерацию (раздел 4.4.9), режимы AXI4 (раздел 4.5.1), порты (глава 6) и остальные параметры IP (глава 7) можно прочитать в User Guide.

Подключение

Подключение портов можно импортировать из готовых файлов конфигурации производителя платы, либо задать самостоятельно с помощью инструмента FloorPlanner. Рассмотрим некоторые описания сигналов, а весь файл можно посмотреть в примере производителя.

src/11-bsram/ddr/project.cst
IO_LOC "ddr_addr[14]" U5;  (1)
IO_PORT "ddr_addr[14]" IO_TYPE=SSTL15 PULL_MODE=NONE DRIVE=12 BANK_VCCIO=1.5;
IO_LOC "ddr_clk" M2,L2;  (2)
IO_PORT "ddr_clk" IO_TYPE=SSTL15D PULL_MODE=NONE DRIVE=8 BANK_VCCIO=1.5;
INS_LOC "u_ddr3/gw3_top/u_GW_DDR3_PHY_MC/u_ddr_phy_top/u_dll" DDRDLLM_BL;  (3)
INS_LOC "u_ddr3/gw3_top/u_GW_DDR3_PHY_MC/u_ddr_phy_top/fclkdiv" LEFTSIDE[4];  (4)
1 Порт для старшего бита адреса DDR3 ddr_addr. Конфигурация IO_TYPE=SSTL15 задаёт стандарт ввода-вывода Stub Series Terminated Logic (1.5 В), предназначенный для интерфейсов памяти DDR3. Значение DRIVE=12 устанавливает максимальный выходной ток буфера в 12 мА, а BANK_VCCIO=1.5 указывает на напряжение питания соответствующего банка (соответствует значению из стандарта).
2 Порт ddr_clk представляет собой дифференциальную тактовую пару, указываются сразу два пина M2,L2, соответствующие прямому и инвертированному выходам. Конфигурация IO_TYPE=SSTL15D задаёт дифференциальный SSTL стандарт.
3 Директива фиксирует положение DLL-примитива (u_dll) в специализированной области ПЛИС DDRDLLM_BL (Bottom-Left).
4 Директива фиксирует положение специализированного делителя частоты (fclkdiv) на левой стороне ПЛИС (LEFTSIDE[4]).
Путь к примитивам u_dll и fclkdiv может меняться в зависимости от версии САПР. В случае появлении ошибок на этапе генерации прошивки, проверьте их актуальные пути в сгенерированном файле src/ddr3_memory_interface/ddr3_memory_interface.vo с помощью обычного текстового поиска.

Помимо привязки пинов необходимо корректно описать управляющие сигналы.

src/11-bsram/ddr/project.sdc
create_clock -name div_clk -period 10 -waveform {0 5}
  [get_pins {uut_div2/CLKOUT}]  (1)
create_clock -name ddr_out_clk -period 10 -waveform {0 5}
  [get_pins {u_ddr3/gw3_top/u_GW_DDR3_PHY_MC/u_ddr_phy_top/fclkdiv/CLKOUT}]  (2)
create_clock -name memory_clk -period 2.5 -waveform {0 1.25}
  [get_nets {memory_clk}]  (3)

set_clock_groups -asynchronous
  -group [get_clocks {div_clk}]
  -group [get_clocks {ddr_out_clk}]
  -group [get_clocks {memory_clk}]  (4)
1 Определение базового тактового сигнала div_clk (100 МГц, период 10 нс), на котором работает основная логика проекта. В данном случае, он формируется делителем частоты uut_div2.
2 Определение тактового сигнала ddr_out_clk (100 МГц, период 10 нс) на выходе делителя fclkdiv внутри IP-ядра. Клок, на котором будет работать логика, взаимодействующая с DDR.
3 Определение высокочастотного тактового сигнала памяти memory_clk (400 МГц, период 2.5 нс).
4 Данная директива объявляет указанные тактовые домены взаимно асинхронными, запрещая тайминг-анализатору проверять временные соотношения (setup/hold) между их фронтами. Используется в случаях, когда анализатор не видит фазовой связи. В данном случае ему мешает делитель частоты uut_div2 и встроенный делитель DDR3 IP-ядра.

Пример работы с памятью

Чтобы избавиться от необходимости управлять пятью каналами AXI4, воспользуемся AXI DMA-контроллером. C одной стороны он подключён к DDR3-контроллеру, являясь Full AXI4 master, а с другой стороны — два AXI4-Stream интерфейса: DMA выступает master-ом на канале чтения (выдаёт прочитанные из памяти данные) и slave-ом на канале записи (принимает данные от логики, чтобы записать их в память). Таким образом, вместо адресов и управляющих сигналов Full AXI4 пользовательская логика работает с двумя потоками данных и дескрипторами, которые задают, откуда/куда и сколько нужно передать.

Работу с native-интерфейсом можно посмотреть в примере производителя. Там есть BIST-модуль (Built-In Self-Test), который применяется для проверки модуля памяти в процессе разработки. Похожим образом выглядит и обычная пользовательская логика, взаимодействующая с контроллером. Там же можно посмотреть получение результатов теста через UART и индикацию на плате.

С Scatter-Gather DMA мы познакомились в разделе про PCIe. Сейчас же мы будем использовать AXI DMA-контроллер, который работает с одним непрерывным буфером — готовую реализацию такого контроллера можно взять, например, здесь. Дескриптор в этом случае — просто адрес начала и длина передачи: контроллер сам инкрементирует адрес и разбивает передачу на burst-ы. Единица передачи данных на AXI4-Stream — beat: одно слово данных (шириной в разрядность шины), а burst — группа из нескольких подряд идущих beat-ов, адресованных одной непрерывной командой к DDR3. Размер burst-а (число beat-ов в нём) задаётся настройками контроллера.

Например, при длине передачи 4096 байт и ширине шины 256 бит (32 байта) получится 4096 / 32 = 128 beat-ов. Если размер burst-а — 8 beat-ов, эти 128 beat-ов объединятся в 128 / 8 = 16 burst-ов: то есть контроллер выдаст DDR3 всего 16 команд (со сменой адреса).
Обратите внимание, что такой DMA-контроллер, да и в целом, AXI4-Stream интерфейс удобны при непрерывном обращении к памяти. Если нужен произвольный доступ к отдельным ячейкам, то лучше использовать Full AXI4 или нативный интерфейс DDR3-контроллера. Подключение AXI DMA к DDR3-контроллеру и пользовательской логике можно посмотреть здесь.

В качестве примера взаимодействия с DDR3-контроллером через AXI DMA рассмотрим чтение предварительно загруженного в память изображения: модуль переводит его в оттенки серого и побайтово выводит на внешний порт. Обратите внимание, что данным модулем также управляет некоторая логика, которая перед его запуском ожидает завершения калибровки памяти (init_calib_complete).

Для упрощения примера размер burst-а равен 1 — это позволяет обойтись без FIFO между AXI4-Stream и выходным портом. При необходимости повысить пропускную способность ширину шины данных можно увеличить.
src/11-bsram/ddr/rgb_to_grayscale_stream.sv
module rgb_to_grayscale_stream #(
    parameter int AXI_ADDR_WIDTH = 29,
    parameter int AXI_LEN_WIDTH  = 20,
    parameter int AXI_DATA_WIDTH = 256
) (
    input clk,
    input rst_n,

    input [AXI_ADDR_WIDTH-1:0] cfg_read_addr,
    input [ AXI_LEN_WIDTH-1:0] cfg_len,

    output logic                      m_axis_read_desc_valid,
    input                             m_axis_read_desc_ready,
    output       [AXI_ADDR_WIDTH-1:0] m_axis_read_desc_addr,
    output       [ AXI_LEN_WIDTH-1:0] m_axis_read_desc_len,

    output                      s_axis_rx_tready,
    input                       s_axis_rx_tvalid,
    input  [AXI_DATA_WIDTH-1:0] s_axis_rx_tdata,
    input                       s_axis_rx_tlast,

    output logic       tx_valid,
    output logic [7:0] tx_data,
    output logic       tx_last,
    input              tx_ready,

    input        run,
    output logic done
);

  assign m_axis_read_desc_addr = cfg_read_addr;  (1)
  assign m_axis_read_desc_len  = cfg_len;

  localparam int COUNT_PIXELS = AXI_DATA_WIDTH / 32;  (2)
  localparam int WIDTH_PTR = $clog2(COUNT_PIXELS);
  localparam [WIDTH_PTR-1:0] INDEX_PIXEL_LAST = COUNT_PIXELS - 1;

  logic [COUNT_PIXELS*8-1:0] pixels;

  genvar i;
  generate
    for (i = 0; i < COUNT_PIXELS; i = i + 1) begin : gen_pixels
      wire [ 7:0] r = s_axis_rx_tdata[i*32+0+:8];
      wire [ 7:0] g = s_axis_rx_tdata[i*32+8+:8];
      wire [ 7:0] b = s_axis_rx_tdata[i*32+16+:8];

      wire [15:0] grayscale = (r * 8'd77) + (g * 8'd150) + (b * 8'd29);  (3)
      assign pixels[i*8+:8] = grayscale[15:8];
    end
  endgenerate

  logic [1:0] state;  (4)
  localparam IDLE = 2'd0;
  localparam ISSUE_CMD = 2'd1;
  localparam WAIT_DATA = 2'd2;
  localparam DONE_STATE = 2'd3;

  logic                 busy;
  logic [WIDTH_PTR-1:0] pixel_ptr;
  logic                 tx_last_reg;

  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      state                  <= IDLE;
      m_axis_read_desc_valid <= 1'b0;
      done                   <= 1'b0;
      busy                   <= 1'b0;
      tx_valid               <= 1'b0;
      tx_last                <= 1'b0;
    end else begin
      if (m_axis_read_desc_valid && m_axis_read_desc_ready) begin
        m_axis_read_desc_valid <= 1'b0;
      end

      case (state)
        IDLE: begin
          done <= 1'b0;
          if (run) begin
            m_axis_read_desc_valid <= 1'b1;
            state                  <= ISSUE_CMD;
          end
        end

        ISSUE_CMD: begin
          if (!m_axis_read_desc_valid) begin
            state <= WAIT_DATA;
          end
        end

        WAIT_DATA: begin
          if (s_axis_rx_tvalid && s_axis_rx_tready) begin  (5)
            busy        <= 1'b1;
            pixel_ptr   <= '0;
            tx_valid    <= 1'b0;
            tx_last     <= 1'b0;
            tx_last_reg <= s_axis_rx_tlast;
          end else if (tx_ready && busy) begin  (6)
            tx_data   <= pixels[int'(pixel_ptr)*8+:8];
            tx_valid  <= 1'b1;
            pixel_ptr <= pixel_ptr + 1;

            if (pixel_ptr == INDEX_PIXEL_LAST) begin  (7)
              busy <= 1'b0;

              if (tx_last_reg) begin
                tx_last <= 1'b1;
                done <= 1'b1;
                state <= DONE_STATE;
              end
            end
          end
        end

        DONE_STATE: begin
          tx_valid <= 1'b0;
          if (!run) begin
            done  <= 1'b0;
            state <= IDLE;
          end
        end

        default: state <= IDLE;
      endcase
    end
  end

  assign s_axis_rx_tready = (state == WAIT_DATA) && !busy && (!tx_valid || tx_ready);  (8)

endmodule
1 Адрес и длина дескриптора чтения устанавливается один раз внешней управляющей логикой. По команде run поднимается флаг валидности дескриптора и держится, пока DMA-контроллер не подтвердит приём. На этом работа с адресами и дескрипторами закончена.
2 Один пиксель занимает 32 бита — по 8 бит на каждый из компонентов, в порядке R, G, B и неиспользуемый старший байт (alpha или padding). При ширине шины данных AXI_DATA_WIDTH = 256 бит в один beat умещается COUNT_PIXELS = 256 / 32 = 8 пикселей. WIDTH_PTR — разрядность указателя, которого достаточно, чтобы перебрать все 8 пикселей внутри beat-а ($clog2(8) = 3 бита).
3 Комбинаторный перевод в оттенки серого по формуле Y = 0.299*R + 0.587*G + 0.114*B, приближённой как (77*R + 150*G + 29*B) / 256 (сумма весов 77 + 150 + 29 = 256, поэтому деление — отбрасывание младших 8 бит результата grayscale[15:8]).
4 Логика оформлена в виде FSM с четырьмя состояниями: IDLE — ожидание запуска, ISSUE_CMD — выдача дескриптора чтения, WAIT_DATA — приём beat-ов и выдача пикселей наружу, DONE_STATE — передача завершена, ожидание снятия run.
5 Получение beat данных. Флаг busy показывает, что модуль выдает наружу пиксели уже принятого beat-а. Выдача начнется на следующий такт.
6 Условие передачи пикселя наружу с проверкой готовности получателя.
7 Обработка последнего пикселя beat-а: busy сбрасывается в этом же такте, то есть уже со следующего такта модуль готов принять новый beat (s_axis_rx_tready снова может подняться, а данные могут прийти через такт). При этом последний пиксель будет отдан на следующем такте.
8 Готовность получить данные при условии, что модуль не занят раздачей текущего beat-а, автомат в состоянии ожидания данных и предыдущий выходной байт уже принят либо не был выставлен.
Обязательно порешайте упражнения.