Co to jest Pluto+

Urządzenie, z którym pracuję w tej serii, 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. To wciąż firmware kompatybilny z pyadi-iio, ten sam stos libiio i ten sam adres 192.168.2.1 po USB — z perspektywy hosta nie ma żadnej różnicy, czy gadasz z oryginałem od ADI, czy z 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. Drugą istotną różnicą jest to, że klony często wymagają karty SD do pracy — większy rootfs (z dodatkowymi narzędziami, obsługą 4 kanałów, czasem GNU Radio) po prostu nie mieści się w 32 MB wewnętrznego flasha, więc bez karty urządzenie albo nie wstanie poprawnie, albo wstanie w okrojonej konfiguracji.

Pluto to nie jest zwykły dongle USB. To pełnoprawny mały komputer z Linuksem i FPGA, do którego host przez libiio ma wystawione strumienie próbek i atrybuty.

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 siedzą 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, i mój klon ma firmware fabrycznie skonfigurowany na ten rozszerzony zakres. Maksymalna szerokość pasma analogowego to 20 MHz — to fundamentalny limit AD9363 (AD9361 i AD9364 mają 56 MHz).


Trzy poziomy programowania Pluto

W urządzeniu masz dostęp do trzech różnych warstw, na których możesz wykonywać kod. Każda z nich ma inną latencję, inną przepustowość i inny próg wejścia — wybór warstwy decyduje, 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 żyje na komputerze PC, a 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 przelatuje przez kabel i stos sieciowy, więc latencja idzie w milisekundy — to wystarczy do analizy off-line, prototypów DSP i większości projektów dydaktycznych. Domyślny i najczęstszy tryb pracy.
  2. ARM na Pluto — logika przenosi się do układu scalonego na urządzeniu, konkretnie na rdzeń ARM w Zynqu. Próbki nie muszą pokonywać USB w obie strony — kod sterujący i dane spotykają się w tej samej pamięci, a host odbiera tylko gotowe wyniki. Dzięki temu latencja pętli spada z milisekund do setek mikrosekund.
  3. FPGA na Pluto — logika trafia jeszcze niżej, do programowalnej części Zynqa, czyli równoległego sprzętu, który przetwarza każdą próbkę w momencie, w którym pojawia się na interfejsie LVDS z AD9363. Decyzje zapadają w nanosekundach, a pełne 61.44 MSPS jest łatwo osiągalne.
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

Trzy poziomy opisane wyżej to podstawowy podział, ale warto wiedzieć, że w obrębie każdego z nich istnieją mniej oczywiste warianty.

Na warstwie ARM nie musisz pisać własnego kodu, żeby przenieść logikę na płytkę — ten sam GNU Radio flowgraph, który normalnie odpalasz z ip:pluto.local, możesz po prostu uruchomić lokalnie na Pluto z URI local:. libiio celowo robi transport przezroczystym, więc identyczny flowgraph działa zdalnie i lokalnie — różnicą jest tylko jedna linia. Wymaga to obrazu z wkompilowanym GNU Radio.

Bardziej radykalny wariant tej samej warstwy to bare-metal na ARM — zamiast bootować Linuksa, wgrywasz na Cortex-A9 binarkę napisaną w czystym C albo działającą pod FreeRTOS. Importujesz system_top.hdf z plutosdr-fw, piszesz bezpośrednio na rejestrach AXI. Zyskujesz deterministyczne czasy reakcji i ~kilkadziesiąt μs latencji zamiast setek, tracisz 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ć z tego syntezowalny IP, to obniża próg wejścia i jest realne dla bloków czysto numerycznych (FIR, FFT), słabiej dla logiki sterującej. Z kolei Migen / Amaranth to frameworki Pythonowe do opisu sprzętu, gdzie piszesz logikę w Pythonie, generujesz Verilog, wpinasz do projektu Vivado. Pozwalają zostać w jednym ekosystemie języka, jeśli już piszesz hosta i demony w Pythonie.

Wreszcie ciekawostka łącząca dwie warstwy: OpenAMP. Zynq 7010 ma 2 rdzenie ARM — można skonfigurować Linux tylko na CPU0, a na CPU1 odpalić bare-metal. Pluto dostaje Linuksa do obsługi USB/IP i równolegle deterministyczną pętlę DSP na drugim rdzeniu.

Firmware i karta SD

Na podstawowym poziomie karta SD jest niepotrzebna. Pluto bootuje z wewnętrznego 32 MB QSPI flash i to wystarcza do całej zwykłej pracy z pyadi-iio po USB lub Ethernet. Po wyjęciu z pudełka po prostu działa.

Firmware jest open-source, więc 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) albo zmodyfikowane firmware community z wyższym realnym sample rate po USB. Jest też popularny „hack" 70 MHz–6 GHz i odblokowanie drugiej pary kanałów (2×RX/TX) — to tylko zmiana zmiennych U-Boota przez SSH, bez wymiany firmware.

Po kartę SD sięgasz dopiero wtedy, gdy stockowe 32 MB przestaje wystarczać — typowo w trzech przypadkach: gdy chcesz własny rootfs z GNU Radio albo cięższymi aplikacjami, gdy eksperymentujesz z FPGA / własnym bitstreamem albo gdy potrzebujesz odzyskać urządzenie po nieudanej aktualizacji firmware. Wyjątkiem są klony Pluto+, gdzie karta SD jest wymagana — większy rootfs po prostu nie mieści się we flashu.

Wbudowane FPGA

FPGA to ta część Pluto, która jest bardzo szybka — przetwarza próbki strumieniowo, w czasie rzeczywistym, z latencją liczoną w nanosekundach. Tam, gdzie ARM Cortex-A9 dławi się przy paru MSPS, FPGA spokojnie miele pełne 61.44 MSPS i jeszcze ma zapas mocy na własną logikę.

Dobra wiadomość: FPGA jest już w urządzeniu i już coś robi. Standardowy bitstream ADI ma w środku interfejs LVDS do AD9363, filtry decymacyjne CIC i half-band FIR, DMA do DDR3. Czyli korzystasz z FPGA cały czas, nawet pisząc trywialny skrypt w pyadi-iio — tylko o tym nie wiesz, bo to wszystko jest niewidoczne pod warstwą iiod.

Możesz tam dorzucić własny IP — demodulator, korelator, FFT online, custom DDC — i zyskać przetwarzanie próbka-po-próbce, którego host przez USB nigdy nie dowiezie. Tylko 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 wpięciu 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 idzie tunelem TCP/IP po wirtualnej karcie sieciowej — to dlatego dokładnie ten sam kod działa po USB i po LAN, jedynie zmienia się 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 przekazywany do hosta jako urządzenie USB w klasycznym sensie. Z punktu widzenia OS to po prostu nowa karta sieciowa — dlatego działa równie dobrze z WSL2 jak z natywnego Windows, nie potrzeba usbipd.

Połączenie przez Ethernet

Na pokładzie Pluto siedzi pełny embedded Linux z demonem iiod i SSH.

Po wpięciu 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 wysuwasz dysk eject, Pluto wykrywa zmianę, woła fw_setenv w U-Boocie i automatycznie się resetuje. Po ~30 sekundach wstaje z nową konfiguracją.

Hasło SSH (ssh root@192.168.2.1) to domyślnie 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

Zaczynasz od sterowników USB — instalator PlutoSDR-M2k-USB-Drivers, a następnie libiio.

ping 192.168.2.1
iio_info -u ip:192.168.2.1

iio_info bez argumentów na Windows zawsze zwróci 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 musisz wskazać 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 pozwala zweryfikować sprzęt, podejrzeć atrybuty i nawet zrzucić próbki IQ.

Wybór kontekstu — na Windows backend Local zawsze rzuca Function not implemented, więc URI musisz wskazać 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 # Konkretne URI po USB-Ethernet

iio_info -u ip:pluto.local # 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 (tu: 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 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 potem opada z powrotem — czyli trójkątny chirp wokół nośnej 600 MHz. Na waterfalli odbiornika zobaczysz 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()

Najważniejsza pułapka w tym 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, a dyskretnie liczy się ją przez np.cumsum(f)/fs. To kuszące, ale błędne, żeby napisać exp(2j*pi*f(t)*t) — taki wzór działa tylko dla stałej f, a przy zmiennej f(t) faza zaczyna skakać nieciągle i chirp wychodzi brzydki, z trzaskami w spektrum.

Druga sprawa to styk między zboczem narastającym i opadającym. Gdyby drugi segment zaczął całkowanie od zera, na styku byłby 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 dramatyczne, ale warto je znać. tx_cyclic_buffer = True zapętla cały trójkątny przebieg w DMA — wystarczy załadować jeden okres, a Pluto sam go odtwarza w nieskończoność, bez obciążenia CPU i bez przerw między cyklami. Skalowanie przez 2**14 bierze się stąd, ż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. A T_sweep razem z f_dev decydują o tym, jak stromy będzie zig-zag na waterfalli — krótszy sweep daje bardziej pionowe kreski, większa dewiacja rozszerza je na boki.