Skip to content

Decoding ADS-B on FPGA

View Source Code on GitHub

Overview

This project implements a complete, hardware-based ADS-B receiver pipeline by interfacing a HackRF One directly with a Gowin FPGA.

Standard ADS-B setups perform the signal processing in software. In this project, all of the signal processing occurs in custom digital logic.

By tuning a HackRF One to 1090MHz, the system captures live transponder messages from overhead aircraft. Instead of relying on an SDR application to process these signals, I modified the HackRF's internal CPLD firmware to duplicate and stream the raw I/Q samples directly to an FPGA. The FPGA processes these signals and sends the extracted ADS-B message to my computer via UART.

demo A demo of the working decoder receiving messages from a passing plane

The modified HackRF One sends a copy of every IQ sample to both my computer and the FPGA. I can control the frequency, bandwidth, and gain of the SDR with normal control software (I use SDR++), but all of the signal processing occurs on the FPGA.

When no processing is applied, the signal seen in the SDR++ waterfall is the same signal that enters the FPGA (minus some digital noise).

Motivation

Like most of my projects, I built this because I thought it would be cool. And also to prove that I could.

Architecture

Signal paths

graph LR
    A[RF] --> A2[Low Pass Filter<br/>1.75MHz]
    A2 --> B[ADC]
    B --> C[CPLD]
    C -->|IQ Samples| D2[FPGA]
    C -->|IQ Samples| D1[Microcontroller]
    D2 -->|UART| E2[Computer]
    D1 -->|USB| E1[Computer]

Signal processing chain

graph LR
    subgraph SDR Clock Domain
        A[GPIO Buffer]
    end
    subgraph Core Clock Domain
        C[CDC Fifo]
        D[DC Block]
        E["Magnitude Approx<br/>(|I| + |Q|)"]
        F[x4 Decimation]
        G[Decode]
    end
    H[Computer]

A --> C --> D --> E --> F --> G -->|UART| H

ADS-B Decode Logic

graph TD
    %% Data Path Flow
    IN_S["Input Sample (Magnitude)"] --> SW[Preamble Search Window]
    SW --> NW[Noise Window]
    NW --> TH[Threshold Calc]

    IN_S --> DE[Symbol Decoder]
    DE --> PB[Packet Buffer 112-bit]

    PB --> FIFO[Message FIFO]
    FIFO --> FMT[ADS-B ASCII Decoder]
    FMT --> OUT[UART Stream]

    %% Status Signals: Data Path "informing" the Control Path
    DE --> CRC[CRC-24]
    CRC -.-> |CRC Pass/Fail| FSM1[Main Control FSM]

    SW -.-> |Window Data| PM[Preamble Match Logic]
    TH -.-> |Threshold Limit| PM
    PM -.-> |Match Status| FSM1

    FIFO -.-> |FIFO Valid/Empty| FSM2[ASCII Decode FSM]

    %% Command Signals: Control Path "driving" the Data Path
    FSM1 -.-> |Shift Enable| PB
    FSM1 -.-> |Write Enable| FIFO

    FSM2 -.-> |Mux Select| FMT

SDR modifications (connecting the FPGA):

The HackRF One uses a CPLD to interleave the 8-bit I and Q samples from the ADCs onto the 8 bit bus connected to the microcontroller. There is an unused header (P30) on the board that is connected to this CPLD. it is possible to reprogram this CPLD to send a copy of every sample to the P30 header, which can then be connected to an FPGA.

Fortunately, the HackRF One firmware is open source and comes with very good documentation, so reprogramming the CPLD is not a huge challenge. The necessary modifcations to firmware/cpld/sgpio_if/top.vhd are shown below:

@@ -18,5 +18,9 @@ use UNISIM.vcomponents.all;    
         DA              : in    std_logic_vector(7 downto 0);
         DD              : out   std_logic_vector(9 downto 0);
+        
+        IQ_CLK          : out   std_logic;
+        I_TAP           : out   std_logic_vector(7 downto 0);
+        Q_TAP           : out   std_logic_vector(7 downto 0);

         CODEC_CLK       : in    std_logic;
         CODEC_X2_CLK    : in    std_logic
@@ -76,17 +81,20 @@ begin
         I => CODEC_X2_CLK
     );

+    -- Map front-end sample clock to P30 header
+    IQ_CLK <= CODEC_CLK;
@@ -106,9 +114,11 @@ begin
                 if codec_clk_rx_i = '1' then
                     -- I: non-inverted between MAX2837 and MAX5864
                     data_to_host_o <= adc_data_i xor X"80";
+                    I_TAP <= adc_data_i xor X"80";
                 else
                     -- Q: inverted between MAX2837 and MAX5864
                     data_to_host_o <= adc_data_i xor rx_q_invert_mask;
+                    Q_TAP <= adc_data_i xor rx_q_invert_mask;
                 end if;
             end if;
         end if;

And to firmware/cpld/sgpio_if/top.ucf:

@@ -65,6 +66,25 @@ NET "HOST_SYNC_EN" LOC = "P90" ;
 NET "HOST_SYNC"  LOC = "P55" | PULLUP ; 
 NET "HOST_SYNC_CMD"  LOC = "P56" ; 

+NET "IQ_CLK"    LOC = "P22"; # GCK0
+NET "I_TAP<0>"  LOC = "P92"; # B2AUX16
+NET "I_TAP<1>"  LOC = "P97"; # B2AUX14
+NET "I_TAP<2>"  LOC = "P1";  # B2AUX12
+NET "I_TAP<3>"  LOC = "P3";  # B2AUX10
+NET "I_TAP<4>"  LOC = "P6";  # B2AUX8
+NET "I_TAP<5>"  LOC = "P8";  # B2AUX6
+NET "I_TAP<6>"  LOC = "P10"; # B2AUX4
+NET "I_TAP<7>"  LOC = "P12"; # B2AUX2
+
+NET "Q_TAP<0>"  LOC = "P94"; # B2AUX15
+NET "Q_TAP<1>"  LOC = "P99"; # B2AUX13
+NET "Q_TAP<2>"  LOC = "P2";  # B2AUX11
+NET "Q_TAP<3>"  LOC = "P4";  # B2AUX9
+NET "Q_TAP<4>"  LOC = "P7";  # B2AUX7
+NET "Q_TAP<5>"  LOC = "P9";  # B2AUX5
+NET "Q_TAP<6>"  LOC = "P11"; # B2AUX3
+NET "Q_TAP<7>"  LOC = "P13"; # B2AUX1
+
 #PACE: Start of PACE Area Constraints

 #PACE: Start of PACE Prohibit Constraints

Below is an oscilloscope trace of some signals on P30 after the CPLD modifcation. The signals traced are the sample clock (yellow), a bit from the I channel (blue) and from the Q channel (green).

picture

Here is a picture of the SDR and FPGA connected:

picture of the hardware

Looks pretty terrible, but has a surprisingly low error rate at 8 MHz once I figured out the quirks of the Gowin FPGA (see Difficulties and debugging process).

Testing (simulation)

I can collect raw samples from the SDR using hackrf_transfer. I then load these samples into my RTL simulator to verify that the DSP logic functions correctly.

Capturing raw ads-b signals:

hackrf_transfer -r adsb_8mhz.bin -f 1090000000 -b 1750000 -s 8000000 -n 8000000 -l 32 -g 34

The design can be simulated by running ./test_adsb.py in the sim directory.

The following dependencies are required for simulation:

  • Verilator
  • Cocotb
  • Numpy

Difficulties and debugging process

Initially, signals would successfully decode in simulation, but not on the FPGA. I suspected the issue was the dodgy connection between the SDR and FPGA.

I wrote some RTL to capture the signals that came into the FPGA and send them to my computer over UART (RTL/debug_top.sv). Due to BRAM limits on the Tang Nano 20K, I could only capture about 40k samples. However, this is more than enough to see the issue:

bad sample transmission

For reference, here is a plot of the same signal with samples captured directly from the SDR:

reference samples

It appears that some bits of the I sample bus were not being latched correctly. However the Q channel seemed fine. This suggested that the electrical connection was not the issue. I played around with adjusting the phase of the incoming sample clock with mixed success. Eventually I realized the the open-source toolchain I was using wasn't respecting my timing constraints. Instead, I needed to force the I/Q sample bus to latch in the IO buffer by instantiating Gowin IDDR primitives:

generate
for (i = 0; i < 8; i = i + 1) begin : IO_buffer_gen
    IDDR iddr_inst_i (
        .Q0(i_buf[i]), // Data captured on rising edge
        .Q1(),         // Data captured on falling edge
        .D(sample_i_in[i]),
        .CLK(sample_clk)
    );
    IDDR iddr_inst_q (
        .Q0(),         // Data captured on rising edge
        .Q1(q_buf[i]), // Data captured on falling edge
        .D(sample_q_in[i]),
        .CLK(sample_clk)
    );
end
endgenerate

This greatly reduces the skew on the IQ bus and makes it possible to safely latch the data. Here are the results:

post iob sample transmission