В этой статье рассмотрено, как передавать данные пакетами через baremetal приложение no‑os на ad9361. Для генерации фрейма на передающей стороне и обработки фрейма на приёмной стороне использована библиотека liquid‑dsp, которая скомпилирована под arm ядро в zynq-7000. Для ретрансляции сообщений использован алгоритм обхода графа BFS (Breadth‑First Search, поиск в ширину) и простая система адресации приёмопередатчиков в полезной нагрузке пакета сообщения.

Я не эксперт в области программирования ad9361. Это та область, которую я хочу изучить. Я пытаюсь разобраться в основах и мне проще учиться, когда я вижу, как это работает. Вот почему я это делаю. Не потому, что я уже всё знаю об ad9361. И если вы тоже чему‑то научитесь, прочитав мою статью, это будет здорово. Но пожалуйста, не полагайтесь на мои статьи, чтобы понять теорию. Есть множество отличных книг и других источников по этой теме, где вы сможете найти более подробную информацию. Я надеюсь, что благодаря этой статье вы научитесь применять некоторые базовые принципы программирования ad936x в реальных системах. Если вы заметите ошибку или что‑то, что можно улучшить, пожалуйста, напишите мне в комментариях или личных сообщениях. Тогда мы сможем учиться вместе, и, возможно, я смогу исправить это и показать в новых статьях.

Алгоритм обхода графа в ширину

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

Сначала по очереди идём во все вершины, которые находятся на расстоянии 1. Если у нас есть вершины A, B и C, которые лежат в списке смежности от начальной вершины x, то сначала рассматривается вершина A, потом от неё перейдём к вершине B, и от вершины B перейдём к вершине C. Мы будем идти по всем вершинам, которые находятся на расстоянии 1.

рис. 1
рис. 1

Потом, когда они закончатся, мы пойдём по вершинам на расстоянии 2.

рис. 2
рис. 2

Затем по вершинам на расстоянии 3.

рис. 3
рис. 3

4 и 5

рис. 4
рис. 4
рис .5
рис.5

Мы будем таким образом идти по слоям. И расходиться всё дальше и дальше, и дальше от начальной вершины.

рис .6
рис.6

Обходим граф и рассматриваем вершины, которые смежные с вершиной V. Добавим те вершины, которые хотим рассмотреть в очередь. Таким образом, когда мы будем рассматривать их по слоям, сначала мы рассмотрим вершины на расстоянии 0. Потом добавим в очередь вершины на расстоянии 1 и рассмотрим их. И после того, как рассмотрим их на расстоянии 1, будем добавлять в очередь вершины на расстоянии 2. Если мы будем запускать обход в ширину из какой‑то вершины V, необходимо завести два массива. Массив dist[i] — расстояние от V до i. И массив visited[i], в котором хранится информация о том, добавлена ли вершина в очередь. Тогда:

void bfs(int v){ // v - начальная вершина
  push(v); // добавим начальную вершину в очередь
  visited[v]= true;
  dist[v] = 0; // расстояние до начальной вершины равно 0

  // пока в очереди что-то есть
  while(queueSize() > 0){
  // достаём первую вершину из очереди
  int x = queuePop();
  
  // перебираем все рёбра из вершины x
  // в вершины i, которые ещё белые
  for(int i : G[x])
    if(!visited[i]){
      // добавляем в очередь и 
      // присваиваем расстояние на 1 больше
      queuePush(i);
      visited[i] = true;
      dist[i] = dist[x] + 1;
    }
  }
}

В конце, когда алгоритм закончит свою работу, то все вершины, до которых можно добраться, будут отмечены посещёнными. Расстояние dist будет рассчитано для всех вершин, до которых получилось добраться.

рис. 7
рис. 7

Например, вот на таком графе мы сначала в очередь добавим вершину 0. Затем извлечём из очереди вершину 0 и добавим все вершины, которые из неё достижимы [0] → [1, 2, 3]. На следующем шаге извлечём вершину 1 и добавим всё, что из неё достижимо, в этом случае вершина 4 — [1, 2, 3] → [2, 3] → [2, 3, 4]. Извлечём вершину 2 и посмотрим, что из неё можно попасть в 4 (она у нас уже в очереди) и можно попасть в 5 — [2, 3, 4] → [3, 4] → [3, 4, 5]. И так далее.

рис. 8
рис. 8

Когда будут рассматриваться вершины, то сначала рассмотрим вершины на расстоянии 1 по построению алгоритма. Потом из них по очереди построим все вершины на расстоянии 2, ну и так далее. Можно заметить, что вершины на одинаковом расстоянии находятся в очереди подряд. Сначала 0, потом 1, 2, 3, потом 4, 5, 6, потом 7, потом 8, 9, потом 10. В массиве dist у нас окажется кратчайшее расстояние с учётом того, что рёбра у нас одинаковые.

Подводя промежуточный итог, можно представить приёмопередатчики, которые расположены на какой‑то площади, вершинами графа. А если между приёмопередатчиками есть связь, то это можно представить в виде ребра. Тогда получается, что это невзвешенный граф. Чтобы передать пакет данных от одного узла до другого, можно выполнить обход в ширину. Тогда будут известны номера узлов, по которым необходимо пройти, чтобы сделать как можно меньше ретрансляций.

Лабораторная работа по передаче данных с ретрансляцией

Для генерации фрейма воспользуемся библиотекой liqud‑dsp и приёмопередатчиками в виде клонов PlutoSDR. В одной из прошлых статей было описано как запрограммировать ad9361, но в этот раз придётся скомпилировать библиотеку liquid‑sdr под ARM ядра zynq-7000.

Компиляция liquid‑dsp под arm zynq-7000

Клонируем репозиторий liquid‑dsp и делаем export arm‑none‑eabi. Команда $ arm-none-eabi-gcc --version должна возвращать номер версии. В readme.md репозитория liquid‑dsp написано, что необходимо выполнить $ ./bootstrap.sh. Дальше самое интересное

cmake ..\
-DCMAKE_C_COMPILER=arm-none-eabi-gcc \
-DCMAKE_CXX_COMPILER=arm-none-eabi-g++ \
-DCMAKE_SYSTEM_NAME=Generic \
-DCMAKE_C_FLAGS="-mcpu=cortex-a9 -mfpu=vfpv3 -mfloat-abi=hard -ffrecstanding" \ # => это как в xilinx.mk в scripts/tools в корне проекта
-DCMAKE_EXE_LINKER_FLAGS="-hostdlib" \
-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \
-DBUILD_EXAMPLES=off \
-DBUILD_AUTOTESTS=off \
-DBUILD_BENCHMARKS=off \
-DBUILD_SUARED_LIBS=off \
-DBUILD_STATIC_LIBS=on \
-DENABLE_SIMD=off \
-DENABLE_AUTOSCRIPT=off \
-DENABLE_LOGGING=off \
-DWITH_FFT=off 

После этого ошибки ещё были, но в src/utility/src/memory.c в функции liquid_aligned_alloc надо заменить posix_memalign(&ptr, _alignment …) на ptr=malloc(_size); и затем в рабочем каталоге сделать make clean и make ‑j1 и проект ad9361 сможет собираться с исходным кодом из tutorial framing, например.

Вся эта работа была проделана в UBUNTU Release 22.04.5 LTS (Jammy Jellyfish) 64-bit Kernel Linux 5.15.0–190-generic x86_64 MATE 1.26.0. В отличие от прошлой статьи приложение собирается из исходников с помощью Makefile. В комментариях к прошлой статье дописано, что чтобы это работало, необходимо отключить оптимизацию O2. Тогда стандартный пример от Analog Devices c DMA и TDD начнёт работать как надо.

Чтобы подключить библиотеку liquid‑dsp и собрать пример tutorial framing под клон Pluto, необходимо дополнить Makefile. В src.mk необходимо указать путь до liquid‑dsp, например:

# это можно добавить в самое начало
LIQUID_PATH=/home/nsv/liquid-dsp
CFLAGS += -I$(PROJECT)/include # это пригодится для деления кода на заголовочные и исходные файлы
CFLAGS += -I$(LIQUID_PATH)/include
CFLAGS += -I$(LIQUID_PATH)/src
CFLAGS += DARM_CPU=1
CFLAGS += -DLIQUID_USE_COMPLEX_H=1
# а это добавлено перед INC += $(DRIVERS)/rf_transceiver/ad9361...
# указываем статическую библиотеку линковки, предполагаем, что она собрана и лежит в build-arm
LIQUID_LIB = $(LIQUID_PATH)/build-arm/libliquid.a
# добавляем библиотеку в LIB_FLAGS (этот флаг используется в generic.mk)
LIB_FLAGS += $(LIQUID_LIB)
# добавим математическую библиотеку
LIB_FLAGS += -lm

После этого можно смело добавлять исходный код из tutorial framing прямо в исходный код проекта ad9361 от Analog Devices из ветки 2021_R1 (ну или как угодно вам). Потом сделать export XSCT_REMOTE_HOST=127.0.0.1 и export XSCT_REMOTE_PORT=3121 как в прошлой статье, выполнить make и make run и наблюдать в SERIAL MONITOR вывод информации в UART от работы liquid‑sdr. Разве это не чудо?

Передача и приём фрейма

Библиотека liquid‑dsp формирует семлы от -1 до 1. Это очень хорошо видно на примере работы tutorial framing. Чтобы передать эти семплы через ad9361 и драйвер no‑os от Analog Devices из ветки 2021_R1 придётся попрограммировать. Семплы нужно привести к диапазону [-32768, 32767]. При этом не перегрузить цап ad9361, но и использовать его с максимальной эффективностью.

// 1. Находим максимальную амплитуду
float max_amp = 0.0f;
for (i = 0; i < buf_len; i++) {
  float amp = cabsf(buf[i]);
    if (amp > max_amp) max_amp = amp;
}
// printf("Max amplitude: %.8f\n", max_amp);

// 2. Вычисляем коэффициент нормализации
float normalize_gain = 0.9f / max_amp;
// printf("Normalize gain: %.3f\n", normalize_gain);

Семплы можно хранить по образу и подобию массива sine_lut_iq из стандартного примера с загрузкой кастомных данных в DMA от Analog Devices.

// выделяем память для DMA 
uint32_t *frame_buffer = (uint32_t*)malloc(buf_len * sizeof(uint32_t));
if (!frame_buffer) {
  // обработка ошибки
  printf("Error allocating frame_buffer\n");
  return -1;
}

// преобразуем данные
for (i = 0; i < buf_len; i++) {
  // Масштабируем и квантуем
  						
  float i_norm = crealf(buf[i]) * normalize_gain;
  float q_norm = cimagf(buf[i]) * normalize_gain;

  int16_t i_val = (int16_t)(i_norm * 32767.0f);
  int16_t q_val = (int16_t)(q_norm * 32767.0f);

  // Проверка переполнения
  if (i_val > 32767 || i_val < -32768) {
    printf("WARNING: i_val overflow at %d: %d\n", i, i_val);
  }
  if (q_val > 32767 || q_val < -32768) {
    printf("WARNING: q_val overflow at %d: %d\n", i, q_val);
  }

  // Упаковываем в uint32_t
  frame_buffer[i] = ((uint32_t)(q_val & 0xFFFF) << 16) | (uint32_t)(i_val & 0xFFFF);
}

Дальше данные надо загрузить через DMA

axi_dac_load_custom_data(ad9361_phy->tx_dac, frame_buffer, LIQUID_FRAME64_LEN, (uintptr_t)dac_buffer);

Это без проблем будет работать, если отредактировать структуру. Важно указать правильный размер. ad9361 двухканальная и это надо учитывать, размер должен быть в два раза больше как раз для второго канала. А то если этого не сделать, то функция axi_dac_load_custom_data из ветки 2021_R1 заполнит имеющийся размер только половиной фрейма и framesync64_execute никогда не обнаружит полезную информацию в принимаемых семплах. Конечно, можно и упростить, отключив второй канал. И данные скопировать вручную с помощью memcpy. Это всё по желанию. Теперь с этим можно делать всё что угодно.

struct axi_dma_transfer transfer = {
  // Number of bytes to write/read
  // .size = sizeof(sine_lut_iq),
  .size = LIQUID_FRAME64_LEN * 2 * sizeof(uint32_t),
  // Transfer done flag
  .transfer_done = 0,
  // Signal transfer mode
  .cyclic = CYCLIC, // CYCLIC
  // Address of data source
  .src_addr = (uintptr_t)dac_buffer,
  // Address of data destination
  .dest_addr = 0
};

После этого можно смело передавать это в эфир, соблюдая правила и законы, регулируемые государством, на территории, которого происходят эти эксперименты. Несоблюдение этих законов может и приведёт к ответственности за нарушение использования радиочастот!

ad9361_set_en_state_machine_mode(ad9361_phy, ENSM_MODE_TX);
ad9361_get_en_state_machine_mode(ad9361_phy, &ensm_mode);
printf("SPI control - TX: %s\n",
ensm_mode == ENSM_MODE_TX ? "OK" : "Error");
no_os_mdelay(10);

uint16_t count_tx = 100;

while(count_tx--){ // count_tx--
  Xil_DCacheFlush();
  /* Transfer the data. */
  axi_dmac_transfer_start(tx_dmac, &transfer);
  /* Flush cache data. */
  // Xil_DCacheInvalidateRange((uintptr_t)dac_buffer,sizeof(sine_lut_iq));
  Xil_DCacheInvalidateRange((uintptr_t)dac_buffer, transfer.size);
  // no_os_mdelay(10);
  if(count_tx==0){
    // Проверка первых 10 семплов в dac_buffer
    for (i = 0; i < 10; i++) {
      int16_t i_val = (int16_t)(dac_buffer[i] & 0xFFFF);
      int16_t q_val = (int16_t)((dac_buffer[i] >> 16) & 0xFFFF);
      printf("dac_buffer[%d]: I=%6d, Q=%6d\n", i, i_val, q_val);
  }
  printf("=====================\n");
}

ad9361_set_en_state_machine_mode(ad9361_phy, ENSM_MODE_ALERT);
ad9361_get_en_state_machine_mode(ad9361_phy, &ensm_mode);
printf("SPI control - Alert: %s\n",
ensm_mode == ENSM_MODE_ALERT ? "OK" : "Error");
no_os_mdelay(1000);

После этого в эфир уйдёт примерно 100 раз по 1440 семплов. А чтобы принять их, необходимо произвести действия в обратной последовательности. То есть привести принятые семплы к диапазону от -1 до 1.

/* Read the data from the ADC DMA. */
axi_dmac_transfer_start(rx_dmac, &read_transfer);

/* Wait until transfer finishes */
status = axi_dmac_transfer_wait_completion(rx_dmac, 500);
if(status < 0)
  return status;
				
Xil_DCacheInvalidateRange((uintptr_t)adc_buffer, sizeof(adc_buffer));

convert_adc_buffer(adc_buffer, rx1_samples, (ADC_BUFFER_SAMPLES * ADC_CHANNELS));

// execute synchronizer and receive the entire frame at once
framesync64_execute(fs, rx1_samples, (ADC_BUFFER_SAMPLES * ADC_CHANNELS)/2); // делю 
// на 2, потому что не привожу данные с второго канала ацп

где функция convert_adc_buffer представляет собой

void convert_adc_buffer(uint16_t *src, float complex *dst, size_t len) {
    uint16_t j = 0;
	for (size_t i = 0; i < len; i+=4) {
        int16_t i_val = (int16_t)(src[i] & 0xFFFF);
        int16_t q_val = (int16_t)(src[i+1] & 0xFFFF);
        
        dst[j++] = ((float)i_val * SCALE) + I * ((float)q_val * SCALE);
    }
}

Адресация приёмопередатчиков

Подводя промежуточный итог, у нас получилось ещё в прошлой статье запрограммировать ad9361, а теперь и сформировать семплы для передачи, но ещё и принять их с помощью другого PlutoSDR, запрограммированного на приём. И теперь можно приступить к созданию логики ретрансляции. А для этого понадобится адресация приёмопередатчиков. Адреса можно представить в виде байтов в полезной нагрузке фрейма liquid‑sdr. Для удобства создадим структуру. Конечно, это уменьшает и так небольшое количество байтов в полезной нагрузке, но в библиотеке есть и другие генераторы фрейма, а здесь решается простая задачка ретрансляции с помощью алгоритма BFS.

// Структура полезной нагрузки
typedef struct {
  uint8_t src_addr;      // Адрес источника (1 байт)
  uint8_t rpt_addr;      // Адрес ретранслятора (1 байт)
  uint8_t dst_addr;      // Адрес получателя (1 байт)
  uint8_t msg_type;      // Тип сообщения (1 байт)
  uint8_t msg_len;       // Длина данных (1 байт)
  uint8_t data[59];      // Сами данные (до 60 байт)
} __attribute__((packed)) message_t;

// Формирование сообщения
message_t msg;
msg.src_addr = 0x01;      // Мой адрес
msg.dst_addr = 0x02;      // Адрес получателя
msg.msg_type = 0x01;      // Тип: текстовое сообщение
msg.msg_len = strlen("Hello, World!");
strcpy((char*)msg.data, "Hello, World!");

// Заполнение payload
memcpy(payload, &msg, sizeof(msg));
for (int i = sizeof(msg); i < 64; i++) {
    payload[i] = 0;  // Заполняем нулями
}

// В приёмнике
message_t *received_msg = (message_t*)payload;
if (received_msg->dst_addr == MY_ADDRESS) {
    // Это сообщение для меня!
    printf("Received from %d: %s\n", 
           received_msg->src_addr, 
           (char*)received_msg->data);
} else {
    printf("Message for %d, ignoring\n", received_msg->dst_addr);
}

Так адреса окажутся в полезной нагрузке и остаётся только запустить BFS.

Алгоритм BFS

В callback‑функции liquid‑dsp взводится флаг о том, что необходимо ретранслировать сообщение, если принимается байт с адресом ретранслятора, который совпадает с адресом приёмопередатчика. В переменные start и finish присваиваются адреса текущего приёмопередатчика и приёмопередатчика, которому адресовано это сообщение. Здесь информация о том, кто источник этого сообщения не сохраняется, потому что задача просто учебная, но в реальности такая информация может пригодиться.

if (received_msg->dst_addr == MY_ADDRESS) {
  // Это сообщение для меня!
  printf("Received from %d: %s\n", received_msg->src_addr, 
    (char*)received_msg->data);
} else if(received_msg->rpt_addr == MY_ADDRESS){
  // Это сообщение надо ретранслировать!
  flagRepeater = true;
  printf("Received from %d to %d\n", 
  received_msg->src_addr, received_msg->rpt_addr);
  start=MY_ADDRESS;
  finish=received_msg->dst_addr;
}
else{
  printf("Message for %d, ignoring\n", received_msg->dst_addr);
}

В бесконечном цикле флаг ретрансляции сбрасывается. Все переменные и массивы для алгоритма BFS приводятся к начальному состоянию, иначе при повторных приёмах пакетов на ретрансляцию, это может привести к неожиданному поведению или зависанию процессора. Необходимо уменьшить переменные start и finish на единицу, потому что алгоритм реализован с отсчётом от нуля по традиции

flagRepeater=false;
reset_bfs_state();  // Добавить сброс
--start; --finish;
// запускаем bfs
bfs(start);

где функция reset_bfs_state реализована

void reset_bfs_state() {
    for (int i = 0; i < MAXN; i++) {
        visited[i] = false;
        dist[i] = 0;
        parent[i] = -1;
    }
    L = 0;
    R = 0;
}

а функция bfs и представляет собой алгоритм BFS

void bfs(int v){
    visited[v] = true; // отмечаем, что вершину v положили в очередь
    queue_push(v);     // собственно, кладём её в очередь
    dist[v] = 0;       // расстояние до неё 0
    while(queue_size() > 0){ // в цикле пока размер очереди больше нуля
        int x = queue_pop(); // достанем очередную вершину
        for(int i=0; i < G[x].size; ++i)   // переберём какие вершины из 
            // неё доступны (все вершины, которые у неё в списке смежности)
            if (!visited[G[x].data[i]]){     // если они не посещены (если они не добавлены в очередь фактически)
                visited[G[x].data[i]] = true; // то положим их в очередь
                queue_push(G[x].data[i]);     // добавим в очередь
                dist[G[x].data[i]] = dist[x] + 1; // и расстояние до неё — это расстояние до x плюс 1
                parent[G[x].data[i]] = x;         // запоминаем, что мы пришли из x
            }
    }
}

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

msg.src_addr = (uint8_t)vec_get(&path, path.size - 1)+1;
msg.rpt_addr = (uint8_t)vec_get(&path, path.size - 2)+1;
msg.dst_addr = (uint8_t)vec_get(&path, 0)+1;

msg.msg_len = strlen("TEST");
strcpy((char*)msg.data, "TEST");

memcpy(payload, &msg, sizeof(msg)); 

// execute generator and assemble the frame
framegen64_execute(fg, header, payload, buf);

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

рис. 9. Простой стихийный стенд для проведения описанного эксперимента
рис. 9. Простой стихийный стенд для проведения описанного эксперимента

Отладка производилась на 3 SDR. Передатчиком был USRP B200, а ретранслятором и приёмником — клоны PlutoSDR (причём ретранслятор на базе микросхемы с маркировкой ad9363, но это совсем не мешает инициализировать её как ad9361. Как в туториале Analog Devices, но там в контексте классического SDR. А этот эксперимент доказывает, что такое возможно и из‑под драйверов no‑OS такое провернуть).

USRP просто передаёт циклический буфер из 1440 семплов, которые представляют собой фрейм с байтом ретрансляции. На 10 секунде видно, как в левом терминале инициализируется USRP и выводится информация о работе liquid‑dsp. На 11 секунде видно, как сначала выводится информация в нижний терминал справа (именно он подключён к ретранслятору) и сразу же выводится информация в терминал сверху справа. Это терминал приёмника. На 13 секунде акцентируется внимание на верхнем терминале и выделяется сообщение TEST. Именно это сообщение формирует ретранслятор, потому что на USRP было сформировано Hello, World! На 15 секунде снова выделяется TEST в верхнем терминале. Дело в том, что 200 тыс. семплов с USRP — это примерно 100 фреймов. Обрабатываются только валидные сообщения. Это можно увидеть в callback‑функции. В этом эксперименте при отправке именно 100 фреймов гарантированно принимался хотя бы один валидный в 100% случаев. Но чаще принимается больше чем один. От этого можно очень просто защититься, введя нумерацию фреймов, но в этом эксперименте этого сделано не было и отображаются все валидные сообщения. На 17 секунде акцентируется внимание, что до сообщений TEST было принято первоначальное сообщение Hello, World! В теории приёмник удалён настолько от источника сообщения, что приёмник просто не слышит источник, иначе зачем же мы затеяли всю эту ретрансляцию. Обратите внимание, что над ASCII форматом выводится HEX. И очень хорошо видно, что Hello, World! было принято с байтами 030205. По нашей задумке 03 это адрес источника, 02 это кто должен ретранслировать, а 05 — кому адресовано это сообщение. А когда принимается TEST, то в адресах уже 020105. То есть ретранслятор переписал ещё и адреса. На 22 секунде внимание на нижний правый терминал ретранслятора, он принял Hello, World! с адресами 030 205 и больше ничего, потому что в эфире больше ничего не было и он сам вышел в эфир после того, как принял 02 в адресе ретрансляции. Он, являясь узлом с адресом 02, и должен ретранслировать это сообщение. После этого на 34 секунде с usrp снова отправляется то же самое и эксперимент повторяется.

Код для usrp добавлен на github

Самая интересная ветка это feature/add_bfs. То что происходит в видео компилируется именно из этой ветки

Код для клонов PlutoSDR добавлен на github

Код для ретранслятора из видео содержится в ветке feature/repeater

Код для приёмника из видео в feature/framingRx

Код для для передатчика (в видео не использовался) в feature/framingTx

Ещё интересная ветка — feature/path_in_the_gpaph. Там можно посмотреть как реализована работа алгоритма BFS без лишнего кода и есть несколько тестов.

Чтобы хоть немного понять, что здесь происходит, лучше всего повторить весь этот эксперимент. Причём по частям, сначала работа только с no‑OS, потом добавить библиотеку liquid‑dsp. Адресацию, и только потом алгоритм обхода графа в ширину. Если что‑то будет не получаться, то не стесняйтесь писать комментарии или в личные сообщения, будем разбираться вместе.

Спасибо.

С.Н.