В этой статье рассмотрено, как передавать данные пакетами через 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.

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

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

4 и 5


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

Обходим граф и рассматриваем вершины, которые смежные с вершиной 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 будет рассчитано для всех вершин, до которых получилось добраться.

Например, вот на таком графе мы сначала в очередь добавим вершину 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]. И так далее.

Когда будут рассматриваться вершины, то сначала рассмотрим вершины на расстоянии 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 и передачу в эфир. Таким образом, сообщение будет ретранслировано.

Отладка производилась на 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. Адресацию, и только потом алгоритм обхода графа в ширину. Если что‑то будет не получаться, то не стесняйтесь писать комментарии или в личные сообщения, будем разбираться вместе.
Спасибо.
С.Н.

