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.
| Parametr | Wartość |
|---|---|
| Częstotliwość RF | 70 MHz – 6 GHz (firmware z hackiem fabrycznie) |
| Sample rate | 521 kSPS – 61.44 MSPS po USB realnie ~5 MSPS |
| RF bandwidth | do 20 MHz (filtr analogowy AD9363) |
| Tor RF | 2× TX + 2× RX, full-duplex, gniazda SMA |
| Moc TX | ~7 dBm (5 mW) |
| Rozdzielczość ADC/DAC | 12 bitów + AGC |
| Interfejs hosta | USB 2.0 OTG |
| Zasilanie | z USB (~0.5 A) |
| Nazwa | Typ | Rola |
|---|---|---|
| AD9363 | RF transceiver | cał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 / podobny | QSPI flash 32 MB | bootloader U-Boot, kernel Linux, device tree, rootfs |
| MT41K256M16 (2×) | DDR3L 512 MB | RAM współdzielony przez ARM i FPGA — bufory próbek, Linux |
| Slot microSD | czytnik kart | opcjonalny boot z karty |
| USB micro-B | USB 2.0 OTG | komunikacja z hostem + zasilanie + tryb host po przełączeniu konfiguracji |
| 4× SMA | gniazda RF | 2× wyjście TX i 2× wejście RX z filtrami balun |
| ADP5072 / LDO | power management | generacja napięć 1.0 V / 1.8 V / 3.3 V dla SoC, RF i pamięci |
| 40 MHz TCXO | oscylator | referencja 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.

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:
- 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.
- 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.
- 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.
| Aspekt | Host (zdalnie) | ARM na Pluto | FPGA na Pluto |
|---|---|---|---|
| Język | Python / C++ / GNU Radio | C / Python | Verilog / VHDL / HLS |
| Toolchain | pip install pyadi-iio | gcc cross-compile + scp | Vivado |
| Latencja pętli | milisekundy | setki μs | nanosekundy |
| Przepustowość | ~5 MSPS (USB) | < 1 MSPS | 61.44 MSPS |
| Próg wejścia | niski | średni | wysoki |
| Ryzyko cegły | zero | małe | duże |
| Typowe zastosowanie | analiza, prototypy DSP | standalone, demony, web UI | real-time DSP, low-latency |
| Czy potrzebny host | tak | nie | nie |
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:
- Wirtualną kartę sieciową z adresem
192.168.2.1. Host dostaje192.168.2.10. - Dysk masowy (FAT16, ~256 kB) z plikiem
config.txtiinfo.html— najprostszy sposób konfiguracji bez SSH. - 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): 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.