Pi Oscilloscope

Junpeng Wang jw2299

Dec. 5th 2017

Demo Video


While enrolled in ECE 5725, I found out that usually I am tied to the laboratory simply because I need some basic functions from an oscilloscope. Therefore, I designed a cheap, portable, yet powerful oscilloscope that is useful in signal verification within embedded systems development. The oscilloscope has accurate reading of analog voltage for mixed signal below 20 KHz frequency. The oscilloscope is able to change its display setting under user command to adapt to signals of different frequency (from 0 up to 100kHz) and amplitude (from 0 up to 3.3V).

To read the analog signal, I use the internal ADC of a PIC32 micro-controller. The ADC reads 256 samples of the signal input with a max sampling rate at 640kSPS. The ADC readings are then moved to the RAM of the PIC32 to be processed into a 256-byte-long array via Direct Memory Access. The array it then transferred from PIC32 to Raspberry Pi via SPI, with Raspberry Pi being the SPI master and PIC32 the SPI slave. The array can be displayed into a 256x200-resolution Pygame window with grid for measurements. With the two encoders attached to Raspberry Pi, user can change the voltage-per-division and time-per-division representations of the grids.


The oscilloscope should have accurate reading of analog voltage for high-frequency voltage signal:

The oscilloscope should be able to change its display setting under user command to adapt to signals of different frequency and amplitude:

The oscilloscope should be cheap for a student to own, and would better be controlled remotely to check graph or seize data:


Figure 1. System Schematic


A PIC32 microcontroller (model pic32mx250f128b, datasheet available at: http://www.microchip.com/wwwproducts/en/PIC32MX250F128B) is responsible for sampling voltage signal input and prepare an array of readings.

The internal ADC of the PIC32 has a maximum conversion speed of 1Msps and a resolution up to 10-bits, which are fast and accurate enough to read mixed signal up to 100kHz. The ADC also have a selectable conversion trigger source, which is tied to Timer3 interrupt within PIC32 to control the sampling frequency of the ADC. Once PIC32 gets command from Raspberry Pi, PIC32 changes the counter for Timer3 interrupt generation to change the ADC sampling speed. You can find a list of ADC settings in PIC32 for this specific application in Appendix.

The PIC32 has Direct Memory Access, which I use to move the ADC samples to an array data RAM. This feature is necessary for us to meet the strict time constraint to sample the voltage signal input at max 640ksps. The DMA is triggered at 60Hz, so PIC32 have one 256-integer-long array of 10-bits ADC readings every 1/60 of a second. The DMA process is done in hardware and cost no CPU time. Then the array of ADC readings are compressed (right shifted by 2) into a 256-char-long array.

The SPI module of the PIC32 has two channels. I use channel 2 for interfacing with Raspberry Pi. The SPI channel 2 is configured to be on SPI slave mode, SPI mode 0, and 8-bit per word. Once the 256-char-long array of ADC readings is ready, PIC32 put the head of the array (an 8-bit number) in its SPI buffer and halt. Once the SPI transaction is initialized, the element in the array is put into the SPI buffer in order. After 256 SPI 8-bit transactions, PIC32 would get another array of ADC readings and halt for another SPI 8-bit transaction.

You can find source code with heavily commented ADC and SPI settings in PIC32 for this specific application in Appendix A.

Raspberry Pi SPI

Raspberry Pi seize the 256-char-long array of ADC readings from PIC32 via SPI. For the SPI on the Raspberry Pi side, I used the spiDev library (https://pypi.python.org/pypi/spidev). The spiDev library documentation is available on http://tightdev.net/SpiDev_Doc.pdf, where you can find installation method and function usage of the library.

While the PIC32 SPI channel is configured to be on SPI slave mode, SPI mode 0, and 8-bit per word, Raspberry Pi SPI is configured to be on SPI master mode, SPI mode 0, 1 MHz clock speed, and 8-bit per word. The SPI communication goes through the following process:

  1. Raspberry Pi activate the slave selection bit (CE0) by pulling it down
  2. Raspberry Pi sends out a 8-bit message, at the same time it receives a 8-bit message from PIC32 and append it to the list for ADC readings.
  3. Repeat from step 2 for 255 times more.
  4. Raspberry Pi ends up with a list with 256 elements, each is a reading of PIC32 ADC.

By the end of this process, Raspberry Pi ends up with a list that is identical to the 256-char-long array of ADC readings in PIC32. This whole SPI communication is done once per second. Each communication takes ~3.5 ms.

Rotary Encoder

The oscilloscope time-per-div and voltage-per-div configurations are discrete in nature. A ideal option is to use rotary encoders to control these settings. Note that there are different types of rotary encoders on the market, and this section talks about the specific model I use (https://www.sparkfun.com/products/9117).

The rotary encoders have two signal output: clk and dt, and both are pulled up using 10Kohm resistors. Both signal are HIGH when they are mechanically stable in one of its steps and not being turned. In the process of being turned clockwise by one step, clk first drops to LOW, then dt drops to LOW, then clk raise to HIGH, and dt raise to HIGH. In the process of being turned counterclockwise by one step, dt first drops to LOW, then clk drops to LOW, then dt raise to HIGH, and dt clk to HIGH. A state diagram can be found in datasheet https://www.sparkfun.com/datasheets/Components/TW-700198.pdf.

The dt signal of each of the 2 rotary encoders is attached to a GPIO input on Raspberry Pi configured as external interrupt source, while the clk signal is attached to an ordinary digital GPIO input. Once dt falls low, the Raspberry Pi enters interrupt callback function and immediately check the reading on clock signal to determine the direction that the rotary encoder is rotated to. According to the behavior of the rotary encoders, if clk signal is LOW at the falling edge, it indicates that the clk drop proceeds the dt drop, so the rotary encoder is turned clockwise. Vice versa if the clk signal is high.

In software, each rotary encoder has a corresponding counter to track its state. r1_counter ranges from 1 to 8, while r2_counter ranges from -1 to 2. Rotating clockwise adds 1 to the counter and rotating counterclockwise subtract 1 from the counter.

A change in r1_counter changes time-per-division representation. The message Raspberry Pi send to PIC32 is exactly the numerical value of r1_counter. According to the message received on PIC32 side, Timer3 is assigned a new counter value for timer interrupt generation. Since the internal ADC of the PIC32 is configured to be triggered from Timer3, this essentially change the sampling rate of the ADC. The new counter value is decided from a lookup array within the RAM of PIC32. Note that I have the PIC32 core is running at 60 MHz and the peripheral bus running at 30 MHz:

r1_counter Value

Timer Interrupt Counter


ADC Sampling Rate

































Table 1. Rotary Encoder 1 Time per Division Control

A change in r2_counter changes the voltage-per-division representation. The readings message from PIC32 are right-shifted by (r2_counter) before being printed into PyGame, so that:

r2_counter Value










Table 2. Rotary Encoder 2 Voltage per Division Control

PyGame Display

To make the voltage signal waveform visible, I use the PyGame library. The pygame documentation including installation process is on https://www.pygame.org/docs/. I made the scope window to occupy a window of 256 x 200 pixel space so it is able to be shown on smaller screens. PyGame map the pixel space so that the upper-left corner is (0,0), upper-right corner (256, 0) and lower-right corner (256,200). Therefore, the waveform display buffer is generated from the ADC reading message from PIC32 following this rule:

display_buffer[i] = 180 - (pic32_message[i] >> r2_counter)

Then the display_buffer is vector-connected with the pygame.draw.lines function. Therefore I get a rect data type in PyGame, with ground level at y = 180. The waveform is updated in the PyGame Window once per second. PyGame also draws a stat string to show current configuration of the scope, and grids for easy measurement of frequency and amplitude of the waveform.


The SPI interface is tested in a commercial oscilloscope. Below are the pictures of a single transaction.

Here yellow is clock signal and blue is SPI MISO line (the ADC readings from PIC32):

Figure 2. SPI Transaction CLK (Yellow) vs. MISO (Blue)

A zoomed-in view at the beginning of the transaction:

Figure 3. Zoom-In SPI Transaction CLK (Yellow) vs. MISO (Blue)

Here yellow is clock signal and blue is SPI MOSI line (the command from RPi):

Figure 4. SPI Transaction CLK (Yellow) vs. MOSI (Blue)

Here yellow is clock signal and blue is SPI Slave Select line. You can clearly see with the Slave Select line how long the SPI communication take in real time in ~3.5 ms:

Figure 5. SPI Transaction CLK (Yellow) vs. Slave Select (Blue)

The electronic behavior of the rotary encoders are also tested out in commercial oscilloscopes. With yellow line being dt and blue line being clk:

Figure 6. Rotary Encoder dt & clk signal when rotated counterclockwise

Figure 7. Rotary Encoder dt & clk signal when rotated clockwise


The speed of execution is very good. No flicker is noticed in the Pygame window while it is being updated once per second. The waveform shown on the scope respond immediately to change of mixed-signal within 1 second. Even though the rotary encoders are implemented as interrupt sources, they cause no visual flickering or ADC data corruption. All these features are evident in the demo video.

The accuracy of the oscilloscope is hard to quantify. However, when compared against the commercial oscilloscope from Tektronix, it is impossible to tell the error in human’s eyes. Therefore, we assert the accuracy of our oscilloscope is satisfactory. Below is a few comparison. Note the function generator we used to generate the signal input does not have precise frequency and amplitude control. Therefore, instead of comparing the Pygame window against the nominal properties of the waveform, compare our oscilloscope with the commercial oscilloscope:

Figure 8. ~6kHz Triangular Wave in Commercial Oscilloscope

Figure 9. ~6kHz Triangular Wave in Pi Oscilloscope

Probing the same voltage input, I can change the user configurations in Pi Oscilloscope using the rotary encoders, and align the commercial oscilloscope configuration:

Figure 10. ~6kHz Triangular Wave in Commercial Oscilloscope in setting B

Figure 11. ~6kHz Triangular Wave in Pi Oscilloscope in setting B

The Pi oscilloscope is able to probe signals in high-frequency and small-amplitude:

Figure 12. ~20kHz 0.1V-Amplitude Sinusoid Wave in Commercial Oscilloscope

Figure 13. ~20kHz 0.1V-Amplitude Sinusoid Wave in Pi Oscilloscope


With satisfactory results, I designed a cheap, portable, yet powerful oscilloscope that is useful in signal verification within embedded systems development. The oscilloscope has accurate reading of analog voltage for mixed signal below 20 KHz frequency and above 0.1V amplitude. The oscilloscope is able to change its display setting under user command to adapt to signals of different frequency (from 0 up to 100kHz) and amplitude (from 0 up to 3.3V). The voltage-per-division setting has four steps (2V, 1V, 500mV, and 250mV), and the time-per-division setting has eight steps (50us, 100us, 200us, 500us, 1ms, 2ms,.5ms, 10ms). The rotary encoders to control these settings are responsive and accurate.

Initially, I intended to use another external ADC, MCP3008, in the place of the PIC32. MCP3008 is a popular option in the R-Pi community when it comes to analog reading, and its 200 ksps nominal sampling rate looks good on paper. I tried to implement MCP3008, only to find out that the maximum reading from the MCP3008 of the analog signal is bottlenecked by the SPI interface to 2 ksps, which is unacceptable for any oscilloscope application.

Future Work

The SPI bus, now running at 1MHz, finishes transaction in 3.5ms. The clock speed can be doubled for reliable readings. Thus, the refresh of the scope can go much higher than 1 frame per second. I stayed at 1 fps because at higher frame rate cause the waveform to shift quickly across the window because lack of a proper trigger function. A working trigger function in PIC32 is definitely something to be desired, as it does not only enables frame rate increase, but also make the scope capable of capturing a pulse in the input.

Once the trigger function is finished, the scope video output will be much smoother as animation, so I will be able to implement analog control of Y-Axis position and trigger level. Raspberry Pi is also capable of doing a FFT mode of the signal Input.

Appendix A - Source Code

scope.py on R-Pi:

import time

import pygame

import RPi.GPIO as GPIO

from pygame.locals import *

import spidev

#initialize GPIO for rotary encoders


GPIO.setup(18, GPIO.IN, pull_up_down=GPIO.PUD_UP)

GPIO.setup(23, GPIO.IN, pull_up_down=GPIO.PUD_UP)

GPIO.setup(24, GPIO.IN, pull_up_down=GPIO.PUD_UP)

GPIO.setup(25, GPIO.IN, pull_up_down=GPIO.PUD_UP)

#rotary encoder state variables

r1_counter = 0x00

r2_counter = 0

#rotary encoder callback functions

def GPIO18_callback(channel):

   global r1_counter

   if (GPIO.input(23)):

      r1_counter = r1_counter-1


      r1_counter = r1_counter+1

   if (r1_counter < 0):

      r1_counter = 0

   if (r1_counter > 7):

      r1_counter = 7


def GPIO24_callback(channel):

   global r2_counter

   if (GPIO.input(25)):

      r2_counter = r2_counter-1


      r2_counter = r2_counter+1

   if (r2_counter < -1):

      r2_counter = -1

   if (r2_counter > 2):

      r2_counter = 2


#attach cb functions to events

GPIO.add_event_detect(18, GPIO.FALLING, callback=GPIO18_callback)

GPIO.add_event_detect(24, GPIO.FALLING, callback=GPIO24_callback)

#initializing buffers for SPI w/ PIC32

data_length = 256

data = []

for i in range(0,data_length):


for i in range(0,data_length):

   data[i] = 0x81

disp = []

for i in range(0,data_length):


x_offset = 0

x_interval = 1;

x_pos = []

for i in range(0,data_length):


lines = []

for i in range(0,data_length-2):


test_grid = []

for i in range(0,data_length-2):


spi = spidev.SpiDev() # create spi object

spi.open(0, 0) # open spi port 0, device (CS) 0

spi.bits_per_word = 8

spi.max_speed_hz = 1000000

spi.mode = 0b00


top = 0

bottom = 199

left = 0

right = 255

size = width, height = 256, 200 #initialize screen window size

black = 0, 0, 0

white = 255,255,255

yellow = 255,255,0

screen = pygame.display.set_mode(size)

stat_font = pygame.font.Font(None,20)

tpd_list = [' 50us','100us','200us','500us','  1ms','  2ms',' 10ms','20ms']

vpd_list = ['   2V','   1V','500mV','250mV']

while 1:

   for i in range(0,data_length):

      data[i] = r1_counter

   resp = spi.xfer2(data) # transfer one byte

   time.sleep(1) # sleep for 0.1 seconds



   for i in range(0,data_length):

      if (r2_counter >= 0):

         disp[i] = 180 - (resp[i]>>r2_counter)

      if (r2_counter < 0):

         disp[i] = 180 - (resp[i]<<(-r2_counter))

   for i in range(0,data_length-2):

      lines[i] = [x_pos[i+1],disp[i+1]]

   stat_buf = "Time/Div = %s   Voltage/Div = %s" % (tpd_list[r1_counter],vpd_list[2-r2_counter])

   stat_surface = stat_font.render(stat_buf,True,white)

   stat_rect = stat_surface.get_rect(center = (128,10))

   screen.fill(black) # Erase the Work space


   for i in range(0,8):


   for i in range(0,5):




   pygame.display.set_caption('Scope', '')

   pygame.display.flip() # display workspace on screen

scope.c on PIC32:

#include <plib.h>

#include <xc.h> // need for pps

#include <stdio.h>

#include <stdlib.h>

// Configuration Bit settings


// PBCLK = 30 MHz

// Primary Osc w/PLL (XT+,HS+,EC+PLL)


// Other options are don't care

//                       8MHZ                          4MHz               60MHz            30   <-----<---    60MHz


#pragma config FWDTEN = OFF

#pragma config FSOSCEN = OFF, JTAGEN = OFF

// core frequency we're running at // peripherals at 40 MHz

#define        SYS_FREQ 60000000

// by a state machine in the timer2 ISR

volatile int LineCount = 0 ;

// ISR driven 1/60 second time

volatile int time_tick_60_hz = 0 ;

//=== Global Variables ================================================

const char *tpd_table[9];

const char *vpd_table[5];

int adc_table[9] = {47,47,94,188,469,938,1875,4688,9375};

char spi_data[256];

int time_tick_30_hz = 0;

int trigger;

int time_per_div = 50;

int y_position = 80;

int spi_resp;

//=== Rotary Encoders =================================================

int r1_counter = 1;

int r2_counter = 3;

// == OC3 ISR ============================================

// VECTOR 14 is OC3 vector -- set up of ipl 3 in main

// vector names from int_1xx_2xx.h

// output compare 3 handler

void __ISR(14, ipl3) OC3Handler(void) // 14


   // mPORTBSetBits(BIT_1);

    // Convert DMA to SPI control

    DmaChnSetEventControl(DMAchn1, DMA_EV_START_IRQ(_SPI1_TX_IRQ)); //

    // clear the timer interrupt flag


   // mPORTBClearBits(BIT_1);  // for profiling the ISR execution time


// == Timer 2 ISR =========================================

void __ISR(_TIMER_2_VECTOR, ipl2) Timer2Handler(void)


    //mPORTBSetBits(BIT_1); // for profiling the ISR execution time

    // update the current scanline number

    LineCount++ ;

    if (LineCount==263) {

        LineCount = 1;



    // clear the timer interrupt flag


   // mPORTBClearBits(BIT_1);  // for profiling the ISR execution time


int        main(void)


    tpd_table[1] = " 50us"; tpd_table[2] = "100us"; tpd_table[3] = "200us"; tpd_table[4] = "500us";

    tpd_table[5] = "  1ms"; tpd_table[6] = "  2ms"; tpd_table[7] = " 10ms"; tpd_table[8] = " 20ms";


    vpd_table[4] = "   2V"; vpd_table[3] = "   1V"; vpd_table[2] = "500mV"; vpd_table[1] = "250mV";

    #define nSamp 256

    int sample_number=0 ;

    int v_in[nSamp], v_disp[nSamp] ;

    // global time

    int time ;


        //make sure analog is cleared

        ANSELA = 0x02;

        ANSELB = 0;

        OpenTimer2(T2_ON | T2_SOURCE_INT | T2_PS_1_1, 1905);

        // set up the timer interrupt with a priority of 2

        ConfigIntTimer2(T2_INT_ON | T2_INT_PRIOR_2);

        mT2ClearIntFlag(); // and clear the interrupt flag

        // zero the system time tick which is updated in the ISR

        time_tick_60_hz = 0;

        // timer 3 setup for adc trigger  ==============================================

        // Set up timer3 on,  no interrupts, internal clock, prescalar 1, compare-value

        // This timer generates the time base for each ADC sample

        OpenTimer3(T3_ON | T3_SOURCE_INT | T3_PS_1_1, adc_table[r1_counter]); //60 is 500 kHz (works at 33 -- is 900 kHz)

        // set up the timer interrupt with a priority of 2

        //ConfigIntTimer3(T3_INT_ON | T3_INT_PRIOR_2);

        //mT3ClearIntFlag(); // and clear the interrupt flag

        // ADC setup ===================================================================

        // trigger on timer3 match

        CloseADC10();        // ensure the ADC is off before setting the configuration

        // define  setup parameters for OpenADC10

        // Turn module on | ouput in integer | trigger mode auto | enable autosample

        // ADC_CLK_AUTO -- Internal counter ends sampling and starts conversion (Auto convert)

        // ADC_CLK_TMR -- triggered off timer3 match

        // ADC_AUTO_SAMPLING_ON -- Sampling begins immediately after last conversion completes; SAMP bit is automatically set

        // ADC_AUTO_SAMPLING_OFF -- Sampling begins with AcquireADC10();


        // define setup parameters for OpenADC10

        // ADC ref external  | disable offset test | disable scan mode | do 1 sample | use single buf | alternate mode off



        // Define setup parameters for OpenADC10

        // use peripherial bus clock | set sample time | set ADC clock divider

        // ADC_CONV_CLK_Tcy2 means divide CLK_PB by 2 (max speed)

        // ADC_SAMPLE_TIME_5 seems to work with a source resistance < 1kohm

        // At PB clock 30 MHz, divide by two for ADC_CONV_CLK gives 66 nSec


        // define setup parameters for OpenADC10

        // set AN11 and  as analog inputs

        #define PARAM4        ENABLE_AN11_ANA

        // define setup parameters for OpenADC10

        // do not assign channels to scan

        #define PARAM5        SKIP_SCAN_ALL

        // use ground as neg ref for A | use AN11 for input A

        // configure to sample AN4

        SetChanADC10( ADC_CH0_NEG_SAMPLEA_NVREF | ADC_CH0_POS_SAMPLEA_AN11 ); // configure to sample AN11

        OpenADC10( PARAM1, PARAM2, PARAM3, PARAM4, PARAM5 ); // configure ADC using the parameters defined above

        EnableADC10(); // Enable the ADC

    //=== DMA Channel 0 transfer ADC data to array v_in ================================

    // Open DMA Chan1 for later use sending video to TV

    #define DMAchan0 0

        DmaChnOpen(DMAchan0, DMApri0, DMA_OPEN_DEFAULT);

    DmaChnSetTxfer(DMAchan0, (void*)&ADC1BUF0, (void*)v_in, 4, 1024, 4); //256 32-bit integers

    DmaChnSetEventControl(DMAchan0, DMA_EV_START_IRQ(28)); // 28 is ADC done

    //=== SPI Channel 2 for R-Pi ========================================


    PPSOutput(2, RPB8, SDO2);

    PPSInput(3, SDI2, RPB2);

    PPSInput(4, SS2, RPB9); // Pin 10

    // === turn it all on ====================================================

    // setup system wide interrupts  ///


    //=== now the application ===============================================

    // init voltage display

    for (sample_number=0; sample_number<nSamp; sample_number++){

        v_in[sample_number] = 0 ;


    time = time_tick_60_hz ;

    int disp_t;



            //mPORTBToggleBits(BIT_1); // for profiling execution time


            while(time == time_tick_60_hz){};

            // use ISR time-tick to update time stamp

            time = time_tick_60_hz ;


            for (sample_number=1; sample_number<nSamp-1; sample_number++){

                spi_data[sample_number] = v_in[sample_number]>>2;



            time_tick_30_hz += 1;


            for (sample_number=0; sample_number<256; sample_number++){


                while (SPI2STATbits.SPIBUSY);

                spi_resp = ReadSPI2();


            r1_counter = spi_resp + 1;

            if (r1_counter > 8) r1_counter = 8;

            else if (r1_counter < 1) r1_counter = 1;


            OpenTimer3(T3_ON | T3_SOURCE_INT | T3_PS_1_1, adc_table[r1_counter]);

         } // while(1)

} // main

Appendix B - List and Bill of Parts

Other than the R-Pi toolkits:

Total additional cost is $23

Appendix C - References

Software Libraries:

A basic version of the PIC32-based scope, which I developed further upon,  is created Professor Bruce Land at Cornell University. Look at the fourth and fifth example: http://people.ece.cornell.edu/land/courses/ece4760/PIC32/index_NTSC_video.html

PIC32 Peripheral Libraries for MPLAB C32 Compiler:


spiDev Raspberry Pi SPI Library:





PIC32MX250F128B Datasheet:


PIC32 hardware manual sections:


Rotary Encoder Sparkfun COM-09117 Datasheet: