Ретрансляция пакетов через ad9361 с помощью алгоритма BFS

от автора

В этой статье рассмотрено, как передавать данные пакетами через 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-dspCFLAGS += -I$(PROJECT)/include # это пригодится для деления кода на заголовочные и исходные файлыCFLAGS += -I$(LIQUID_PATH)/includeCFLAGS += -I$(LIQUID_PATH)/srcCFLAGS += DARM_CPU=1CFLAGS += -DLIQUID_USE_COMPLEX_H=1# а это добавлено перед INC += $(DRIVERS)/rf_transceiver/ad9361...# указываем статическую библиотеку линковки, предполагаем, что она собрана и лежит в build-armLIQUID_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);

Это без проблем будет работать, если отредактировать структуру. Важно указать правильный размер. ad9161 двухканальная и это надо учитывать, размер должен быть в два раза больше как раз для второго канала. А то если этого не сделать, то функция 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 onceframesync64_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!");// Заполнение payloadmemcpy(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;// запускаем bfsbfs(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 frameframegen64_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! с адресами 030205 и больше ничего, потому что в эфире больше ничего не было и он сам вышел в эфир после того, как принял 02 в адресе ретрансляции. Он, являясь узлом с адресом 02, и должен ретранслировать это сообщение. После этого на 34 секунде с usrp снова отправляется то же самое и эксперимент повторяется.

Код для usrp добавлен на github -> https://github.com/NSV47/tx_samples_c_frame_tutorial/blob/feature/add_bfs/tx_samples_c.c

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

Код для клонов PlutoSDR добавлен на github -> https://github.com/NSV47/no_os_ad9361_liquid_dsp_tutorial_framing

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

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

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

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

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

Спасибо.

С.Н.

ссылка на оригинал статьи https://habr.com/ru/articles/1074168/