Co to jest Pluto+

Urządzenie to klon ADALM-Pluto z AliExpressu: metalowa obudowa, cztery gniazda SMA (dwa kanały RX i dwa TX), w środku ten sam tandem co w oryginale: AD9363 jako transceiver RF i Zynq 7010 jako SoC. Firmware jest kompatybilny z pyadi-iio, wykorzystuje ten sam stos libiio i ten sam adres 192.168.2.1 po USB. Z perspektywy hosta nie istnieje różnica między oryginałem od ADI a klonem.

Klony zwykle mają szersze pasmo robocze niż oryginalny "niebieski Pluto": fabrycznie wgrany firmware z hackiem 70 MHz – 6 GHz zamiast oficjalnych 325 MHz – 3.8 GHz. Druga istotna różnica: klony często wymagają karty SD do pracy. Większy rootfs (z dodatkowymi narzędziami, obsługą 4 kanałów, czasem GNU Radio) nie mieści się w 32 MB wewnętrznego flasha, więc bez karty urządzenie albo nie wstaje poprawnie, albo wstaje w okrojonej konfiguracji.

Pluto to pełnoprawny mały komputer z Linuksem i FPGA. Host ma dostęp do strumieni próbek i atrybutów przez libiio.

Parametry praktyczne mojego klonu Pluto SDR
ParametrWartość
Częstotliwość RF70 MHz – 6 GHz (firmware z hackiem fabrycznie)
Sample rate521 kSPS – 61.44 MSPS
po USB realnie ~5 MSPS
RF bandwidthdo 20 MHz (filtr analogowy AD9363)
Tor RF2× TX + 2× RX, full-duplex, gniazda SMA
Moc TX~7 dBm (5 mW)
Rozdzielczość ADC/DAC12 bitów + AGC
Interfejs hostaUSB 2.0 OTG
Zasilaniez USB (~0.5 A)
Komponenty na płytce
NazwaTypRola
AD9363RF transceivercały tor analogowy — 2× TX + 2× RX wyprowadzone na SMA
Zynq 7010 (XC7Z010)SoC (ARM + FPGA)mózg urządzenia — Linux na ARM, DSP w FPGA
N25Q256A / podobnyQSPI flash 32 MBbootloader U-Boot, kernel Linux, device tree, rootfs
MT41K256M16 (2×)DDR3L 512 MBRAM współdzielony przez ARM i FPGA — bufory próbek, Linux
Slot microSDczytnik kartopcjonalny boot z karty
USB micro-BUSB 2.0 OTGkomunikacja z hostem + zasilanie + tryb host po przełączeniu konfiguracji
4× SMAgniazda RF2× wyjście TX i 2× wejście RX z filtrami balun
ADP5072 / LDOpower managementgeneracja napięć 1.0 V / 1.8 V / 3.3 V dla SoC, RF i pamięci
40 MHz TCXOoscylatorreferencja zegarowa dla AD9363 i Zynq — stabilność ±25 ppm

AD9363 (datasheet) to RF transceiver Analog Devices i to on jest "radiem" w urządzeniu — wszystko między gniazdem SMA a światem cyfrowym dzieje się wewnątrz tego jednego chipa.

Schemat blokowy AD9363 — RF transceiver, ADC/DAC, PLL, interfejs LVDS
Schemat blokowy AD9363

Po stronie analogowej znajdują się mieszacze kwadraturowe, syntezatory PLL, LNA na wejściu, PA na wyjściu i przestrajalne filtry analogowe. Po stronie cyfrowej: 12-bitowe przetworniki ADC (RX) i DAC (TX) w trybie I/Q, podpięte przez LVDS do FPGA. Oficjalny zakres to 325 MHz – 3.8 GHz, ale fizycznie syntezator dochodzi od ~70 MHz do ~6 GHz. Klonowany egzemplarz ma firmware fabrycznie skonfigurowany na zakres rozszerzony. Maksymalna szerokość pasma analogowego to 20 MHz — limit fundamentalny AD9363 (AD9361 i AD9364 mają 56 MHz).


Trzy poziomy programowania Pluto

Urządzenie udostępnia trzy różne warstwy do wykonywania kodu. Każda ma inną latencję, przepustowość i próg wejścia. Wybór warstwy determinuje, gdzie fizycznie odbywa się logika i jak szybko może reagować na próbki.

graph TB
    subgraph Host["💻 Host (PC / laptop)"]
        H["Python / C++ / GNU Radio<br/>━━━━━━━━━━━━━━━━━━━━━━<br/>pyadi-iio → libiio"]
    end
    subgraph Pluto["🔲 Pluto (Zynq 7010)"]
        A["ARM Cortex-A9<br/>(Linux + iiod)<br/>━━━━━━━━━━━━<br/>własne demony, skrypty,<br/>SSH, web UI"]
        F["FPGA (PL)<br/>━━━━━━━━━━━━<br/>filtry, DMA,<br/>własne IP w Verilogu"]
        R["AD9363 RF"]
    end
    H <-->|"USB / Ethernet<br/>(TCP iiod)"| A
    A <-->|"AXI<br/>(rejestry, DMA)"| F
    F <-->|"LVDS<br/>(próbki IQ)"| R

Trzy poziomy:

  1. Host: logika aplikacji znajduje się na komputerze PC, urządzenie pełni rolę zdalnego strumienia próbek. Skrypt w Pythonie (albo flowgraph GNU Radio) wysyła polecenia konfiguracyjne i odbiera/wysyła bufory IQ przez USB lub Ethernet. Każda decyzja w pętli przechodzi przez kabel i stos sieciowy, latencja wynosi milisekundy. To wystarczy do analizy off-line, prototypów DSP i większości projektów dydaktycznych. Tryb domyślny i najczęstszy.
  2. ARM na Pluto: logika przenosi się do układu scalonego na urządzeniu, konkretnie na rdzeń ARM w Zynqu. Próbki nie pokonują USB w obie strony. Kod sterujący i dane znajdują się w tej samej pamięci, a host odbiera tylko gotowe wyniki. Latencja pętli spada z milisekund do setek mikrosekund.
  3. FPGA na Pluto: logika trafia do programowalnej części Zynqa, czyli równoległego sprzętu przetwarzającego każdą próbkę w momencie pojawienia się na interfejsie LVDS z AD9363. Decyzje zapadają w nanosekundach, osiągalny jest pełny sample rate 61.44 MSPS.
Trzy tryby — co dostajesz, co tracisz
AspektHost (zdalnie)ARM na PlutoFPGA na Pluto
JęzykPython / C++ / GNU RadioC / PythonVerilog / VHDL / HLS
Toolchainpip install pyadi-iiogcc cross-compile + scpVivado
Latencja pętlimilisekundysetki μsnanosekundy
Przepustowość~5 MSPS (USB)< 1 MSPS61.44 MSPS
Próg wejścianiskiśredniwysoki
Ryzyko cegłyzeromałeduże
Typowe zastosowanieanaliza, prototypy DSPstandalone, demony, web UIreal-time DSP, low-latency
Czy potrzebny hosttaknienie

Podział na trzy poziomy jest podstawowy, ale istnieją mniej oczywiste warianty w obrębie każdego z nich.

Na warstwie ARM nie wymagane jest pisanie własnego kodu do przeniesienia logiki na płytkę. Ten sam GNU Radio flowgraph, który uruchamia się z ip:pluto.local, można uruchomić lokalnie na Pluto z URI local:. libiio robi transport przezroczystym, więc identyczny flowgraph działa zdalnie i lokalnie, różnicą jest tylko zmiana URI. Wymaga to obrazu z wkompilowanym GNU Radio.

Bardziej zaawansowany wariant na tej warstwie to bare-metal na ARM: zamiast bootować Linux, wgrywa się na Cortex-A9 binarkę napisaną w czystym C lub działającą pod FreeRTOS. Po zaimportowaniu system_top.hdf z plutosdr-fw, kod pisze się bezpośrednio na rejestrach AXI. Daje to deterministyczne czasy reakcji i ~kilkadziesiąt μs latencji zamiast setek mikrosekund, ale tracony jest cały stos USB/sieci.

Na warstwie FPGA Verilog/VHDL to nie jedyna opcja. Vitis HLS pozwala napisać blok DSP w C++ z pragmami i wygenerować syntezowalny IP, co obniża próg wejścia i jest realne dla bloków czysto numerycznych (FIR, FFT), słabiej dla logiki sterującej. Migen / Amaranth to frameworki Pythonowe do opisu sprzętu: logika pisze się w Pythonie, generuje Verilog, który wpina się do projektu Vivado. Pozwalają pozostać w jednym ekosystemie języka.

Wariant łączący dwie warstwy to OpenAMP. Zynq 7010 ma 2 rdzenie ARM. Można skonfigurować Linux tylko na CPU0, a na CPU1 uruchomić bare-metal. Pluto otrzymuje Linux do obsługi USB/IP i równolegle deterministyczną pętlę DSP na drugim rdzeniu.

Firmware i karta SD

Na poziomie podstawowym karta SD jest niepotrzebna. Pluto bootuje z wewnętrznego 32 MB QSPI flash, co wystarczy do całej zwykłej pracy z pyadi-iio po USB lub Ethernet. Urządzenie działa od razu po wyjęciu z pudełka.

Firmware jest open-source. Oprócz oficjalnego ADI istnieje kilka community-buildów, m.in. Maia SDR (web UI z waterfallami i nagrywaniem IQ, Pluto jako standalone analizator widma) oraz zmodyfikowane firmware community z wyższym realnym sample rate po USB. Popularny jest również „hack" 70 MHz–6 GHz i odblokowanie drugiej pary kanałów (2×RX/TX), które wymagają tylko zmianę zmiennych U-Boota przez SSH, bez wymiany firmware.

Karta SD jest niezbędna, gdy stockowe 32 MB przestaje wystarczać: (1) przy konfiguracji własnego rootfs z GNU Radio lub cięższymi aplikacjami, (2) przy eksperymentach z FPGA/własnym bitstreamem, (3) przy odzyskiwaniu urządzenia po nieudanej aktualizacji firmware. Wyjątkiem są klony Pluto+, gdzie karta SD jest wymagana, gdyż większy rootfs nie mieści się we flashu.

Wbudowane FPGA

FPGA to część Pluto przetwarzająca próbki strumieniowo, w czasie rzeczywistym, z latencją liczoną w nanosekundach. ARM Cortex-A9 osiąga pół-stabilność przy kilku MSPS, FPGA obsługuje pełne 61.44 MSPS z zapasem mocy na własną logikę.

FPGA jest już w urządzeniu i funkcjonuje od razu. Standardowy bitstream ADI zawiera interfejs LVDS do AD9363, filtry decymacyjne CIC i half-band FIR, DMA do DDR3. Korzysta się z FPGA przez cały czas, nawet pisząc prosty skrypt w pyadi-iio, z tą różnicą, że działa niewidocznie pod warstwą iiod.

Można dodać własny IP (demodulator, korelator, FFT online, custom DDC) i uzyskać przetwarzanie próbka-po-próbce, którego host przez USB nie osiągnie. Rozwiązanie to znacznie bardziej skomplikowane niż pisanie skryptu.

Wszystko, co musi reagować szybciej niż milisekunda albo przetwarzać próbki strumieniowo, powinno siedzieć w FPGA. Wszystko, co może poczekać i mieści się w przepustowości USB — może zostać na hoście w Pythonie.

Połączenie przez USB

Domyślny i najszybszy sposób pracy. Po podłączeniu kabla USB Pluto emuluje trzy urządzenia jednocześnie:

  1. Wirtualną kartę sieciową z adresem 192.168.2.1. Host dostaje 192.168.2.10.
  2. Dysk masowy (FAT16, ~256 kB) z plikiem config.txt i info.html — najprostszy sposób konfiguracji bez SSH.
  3. Wirtualny port szeregowy — konsola U-Boot/Linux do debugowania.

Cała komunikacja z libiio przechodzi tunelem TCP/IP po wirtualnej karcie sieciowej. Z tego powodu ten sam kod działa po USB i po LAN, zmienia się wyłącznie adres IP.

ping 192.168.2.1

# Lista kanałów i atrybutów
iio_info -u ip:192.168.2.1 | head -30

Pluto nie jest przekazywane do hosta jako urządzenie USB w klasycznym sensie. Z punktu widzenia OS to nowa karta sieciowa, dlatego działa zarówno z WSL2 jak i z natywnego Windows, nie wymaga usbipd.

Połączenie przez Ethernet

Pluto zawiera pełny embedded Linux z demonem iiod i SSH.

Po podłączeniu USB Pluto montuje się jako dysk masowy z plikiem config.txt. To podstawowy sposób konfiguracji.

# /Volumes/PlutoSDR/config.txt   (lub D:\config.txt na Windows)

# --- USB-Ethernet (domyślne) ---
ipaddr = 192.168.2.1
ipaddr_host = 192.168.2.10
netmask = 255.255.255.0

# --- Hostname ---
hostname = pluto

# --- Tryb USB host (potrzebne dla adapterów Ethernet/WiFi) ---
udc_handle_suspend = 0

# --- WiFi (po podpięciu dongla USB w trybie host) ---
ssid_wlan = MojaSiec
pwd_wlan = haslo123

# --- Statyczny adres na interfejsie wlan0 / eth0 ---
ipaddr_eth = 192.168.1.50
netmask_eth = 255.255.255.0
gateway_eth = 192.168.1.1

Po edycji dysk wysuwia się za pomocą eject. Pluto wykrywa zmianę, wykonuje fw_setenv w U-Boocie i automatycznie się resetuje. Po ~30 sekundach uruchomi się z nową konfiguracją.

Domyślne hasło SSH (ssh root@192.168.2.1) to analog.

PS C:\Users\RobertPC> ssh root@192.168.2.1
root@192.168.2.1's password:
# Welcome to:
# ______ _       _        _________________
# | ___ \ |     | |      /  ___|  _  \ ___ \
# | |_/ / |_   _| |_ ___ \ `--.| | | | |_/ /
# |  __/| | | | | __/ _ \ `--. \ | | |    /
# | |   | | |_| | || (_) /\__/ / |/ /| |\ \
# \_|   |_|\__,_|\__\___/\____/|___/ \_| \_|
# 
# 80403-dirty
# https://wiki.analog.com/university/tools/pluto
#

Setup na Windows

Instalacja rozpoczyna się od sterowników USB: PlutoSDR-M2k-USB-Drivers, następnie libiio.

ping 192.168.2.1
iio_info -u ip:192.168.2.1

iio_info bez argumentów na Windows zawsze zwraca Unable to create Local IIO context : Function not implemented (40). To nie jest błąd instalacji. libiio próbuje wtedy backendu Local, który czyta /sys/bus/iio/ i działa wyłącznie na Linuksie. Na Windows wymagane jest wskazanie URI: iio_info -u ip:192.168.2.1 lub iio_info -s.


Setup na Ubuntu

sudo apt update
sudo apt install -y libiio-dev libiio-utils

lsusb | grep -i -E "analog|0456"
# Bus 001 Device 049: ID 0456:b673 Analog Devices, Inc. LibIIO based AD9363 Software Defined Radio [ADALM-PLUTO]

sudo dmesg | tail -40 | grep rndis_host
# [871500.522047] rndis_host 1-12:1.0 eth0: register 'rndis_host' at usb-0000:00:14.0-12, RNDIS device, 00:e0:22:ad:c8:3b
# [871500.525100] usbcore: registered new interface driver rndis_host
# [871500.540823] rndis_host 1-12:1.0 enx00e022adc83b: renamed from eth0


sudo ip link set enx00e022adc83b up
sudo ip addr add 192.168.2.10/24 dev enx00e022adc83b

ip addr show enx00e022adc83b

ping -c 3 192.168.2.1


iio_info -u ip:192.168.2.1 | head -20
# iio_info version: 0.25 (git tag:v0.25)
# Libiio version: 0.25 (git tag: v0.25) backends: local xml ip usb
# IIO context created with network backend.
# Backend version: 0.25 (git tag: v0.25)
# Backend description string: 192.168.2.1 Linux (none) 5.15.0 #7 SMP PREEMPT Sun Nov 3 04:49:37 PST 2024 armv7l
# IIO context has 9 attributes:
#         hw_model: FISH Ball PlutoSDR Rev.A (Z7020-AD9361)
#         hw_model_variant: 1
#         hw_serial:
#         fw_version: 80403-dirty
#         ad9361-phy,xo_correction: 40000000
#         ad9361-phy,model: ad9361
#         local,kernel: 5.15.0
#         uri: ip:192.168.2.1
#         ip,ip-addr: 192.168.2.1
# IIO context has 4 devices:
#         iio:device0: ad9361-phy
#                 11 channels found:
#                         altvoltage1: TX_LO (output)
#                         9 channel-specific attributes found:

Narzędzia z libiio-utils

libiio-utils umożliwia weryfikację sprzętu, przegląd atrybutów i zrzut próbek IQ.

Wybór kontekstu: na Windows backend Local zawsze zwraca Function not implemented, URI musi być wskazany jawnie.

iio_info -s # Skan dostępnych backendów (USB, sieć, DNS-SD)
#Available contexts:
#        0: 192.168.2.1 (FISH Ball PlutoSDR Rev.A (Z7020-AD9361)), serial= [ip:pluto.local]
#        1: 0456:b673 (Analog Devices Inc. PlutoSDR (ADALM-PLUTO)), serial= [usb:1.11.5]
        
        
iio_info -u ip:192.168.2.1 # URI po USB-Ethernet

iio_info -u ip:pluto.local # URI po LAN (Pluto+)

iio_info -V
# iio_info version: 0.26 (git tag:a0eca0d)
# Libiio version: 0.26 (git tag: a0eca0d) backends: xml ip usb serial

iio_attr -u ip:192.168.2.1 -d # Wszystkie atrybuty wszystkich urządzeń 
# IIO context has 4 devices:
#         iio:device0, ad9361-phy: found 18 device attributes
#         iio:device1, xadc: found 1 device attributes
#         iio:device2, cf-ad9361-dds-core-lpc: found 2 device attributes
#         iio:device3, cf-ad9361-lpc: found 2 device attributes


iio_attr -u ip:192.168.2.1 -c ad9361-phy altvoltage0 frequency  # Odczyt pojedynczego atrybutu (aktualna częstotliwość LO RX)
# 2400000000

iio_attr -u ip:192.168.2.1 -c ad9361-phy temp0 input # Odczyt temperatury w milistopniach Celsjusza
# 55263

iio_attr -u ip:192.168.2.1 -c ad9361-phy voltage0 hardwaregain 40  # Zapis atrybutu. Ustawienie wzmocnienia RX na 40 dB

iio_reg -u ip:192.168.2.1 ad9361-phy 0x000 # Surowy dostęp do rejestrów AD9363 

iio_readdev -u ip:192.168.2.1 -b 4096 cf-ad9361-lpc > raw.iq # Zrzut próbek IQ do pliku binarnego bez użycia Pythona

Pierwszy chirp — zig-zag na waterfalli

Liniowa modulacja częstotliwości, w której częstotliwość narasta od −500 kHz do +500 kHz, a następnie opada. Trójkątny chirp wokół nośnej 600 MHz wytwarzający na waterfalli odbiornika charakterystyczny zig-zag.

Nadawanie sygnałów radiowych podlega regulacjom prawnym. Do testów używaj kabla SMA z tłumikiem 20–30 dB między portem TX Pluto a portem RX odbiornika — nie podłączaj anteny do TX bez licencji radioamatorskiej. Moc Pluto jest niewielka (~7 dBm), ale nadawanie na częstotliwościach licencjonowanych bez zezwolenia jest nielegalne.

pip install pyadi-iio numpy
import numpy as np
import adi


class Device:
    def __init__(self, uri="ip:192.168.2.1"):
        self.sdr = adi.Pluto(uri)

    def configure_tx(self, lo, sample_rate, bandwidth, gain_db):
        self.sdr.sample_rate = sample_rate
        self.sdr.tx_lo = lo
        self.sdr.tx_rf_bandwidth = bandwidth
        self.sdr.tx_hardwaregain_chan0 = gain_db

    @property
    def fs(self):
        return self.sdr.sample_rate

    def transmit(self, iq, cyclic=True):
        self.sdr.tx_cyclic_buffer = cyclic
        self.sdr.tx(iq.astype(np.complex64))

    def stop(self):
        self.sdr.tx_destroy_buffer()


class TriangleChirpGenerator:
    def __init__(self, fs, f_dev, t_sweep):
        self.fs = fs
        self.f_dev = f_dev
        self.t_sweep = t_sweep

    def _sweep(self, start, stop):
        n = int(self.fs * self.t_sweep)
        t = np.arange(n) / self.fs
        return start + (stop - start) * (t / self.t_sweep)

    def iq(self, scale=2**14):
        f_up = self._sweep(-self.f_dev, +self.f_dev)
        f_dn = self._sweep(+self.f_dev, -self.f_dev)

        phase_up = 2 * np.pi * np.cumsum(f_up) / self.fs
        phase_dn = phase_up[-1] + 2 * np.pi * np.cumsum(f_dn) / self.fs

        iq = np.concatenate([np.exp(1j * phase_up), np.exp(1j * phase_dn)])
        return iq * scale


pluto = Device("ip:192.168.2.1")
pluto.configure_tx(lo=600_000_000, sample_rate=2_000_000, bandwidth=2_000_000, gain_db=-20)

chirp = TriangleChirpGenerator(fs=pluto.fs, f_dev=500_000, t_sweep=1.0)
pluto.transmit(chirp.iq())

input("Nadaję chirp ±500 kHz wokół 600 MHz. Enter = stop.")
pluto.stop()

Kluczowa pułapka w kodzie to sposób liczenia fazy. Dla sygnału o zmiennej częstotliwości faza chwilowa to całka z f(t): \varphi(t) = 2\pi \int_0^t f(\tau)\, d\tau. Dyskretnie oblicza się ją przez np.cumsum(f)/fs. Błędem byłoby napisanie exp(2j*pi*f(t)*t), ponieważ taki wzór działa wyłącznie dla stałej f. Przy zmiennej f(t) faza przeskakuje nieciągle, chirp wydaje się zniekształcony z trzaskami w spektrum.

Druga kwestia to styk między zboczem narastającym i opadającym. Gdyby drugi segment zaczął całkowanie od zera, na styku pojawił by się skok fazy. Dlatego phase_dn startuje od ostatniej wartości phase_up. Bez tego waterfall pokazywałby pęknięcie na każdym wierzchołku zig-zaga.

Pozostałe szczegóły są mniej krytyczne, ale warto je znać. tx_cyclic_buffer = True zapętla cały trójkątny przebieg w DMA. Wystarczy załadować jeden okres, Pluto odtwarza go w nieskończoność, bez obciążenia CPU i bez przerw między cyklami. Skalowanie przez 2**14 wynika z tego, że pyadi-iio przyjmuje complex64 w pełnej skali 16-bitowej, mimo że DAC ma realnie 12 bitów. Bez tego sygnał byłby praktycznie niesłyszalny. T_sweep razem z f_dev determinują stromość zig-zaga na waterfalli: krótszy sweep daje bardziej pionowe kreski, większa dewiacja rozszerza je na boki.