It’s possible to build a simple oscilloscope with an Arduino Uno, its standard serial port and the serial plotter. I wanted to see if I could take things to the next level and incorporate (the beginnings) of data capture and analysis, and be faster, too.
For this project you’re going to need a Raspberry Pi and an STM32 microcontroller. The Pi does the logging, and the STM32 acts as an ADC (Analogue to Digital Converter). You could use an off-the-shelf ADC module, but I happened to have an STM32 Blue Pill (STM32F103C8T6). You’ll also need the Arduino IDE with the stm32duino libraries installed.
Another good candidate would be an ESP32-WROOM in place of the STM32, but I have only provided code for the latter chip, not the former. You could also use an Arduino Uno/Nano, or some other flavour, but remember that these operate at 5V, and are therefore incompatible with the Pi unless you use logic level shifters, or maybe voltage dividers.
We use the SPI protocol to communicate, with the Pi acting as master, and the STM32 acting as slave. We can also use the Pi to generate tones to feed into the “capture card” (i.e the STM32).
To get the code, clone my project over on github:
git clone https://github.com/blippy/rpi.git
In directory stm32/adc-interrupts, there is an INO file. Compile it and upload it to the STM32. It’s quite a short program (although it took me long enough to figure out!) and uses interrupts to capture the ADC data.
In a terminal on the Pi, cd to directory rpi/oscope. All the action will happen there. Hookup the Pi to the STM32 pins as follows:
Pi STM32 MOSI 19 PA7 MISO 21 PA6 SCK 23 PA5 SS 24 PA4 SAMP 35 PB1
Make sure that the devices are also connected by a common ground. Plug a wire between one of the ground pins on the Pi with a ground pin on the STM32.
As you might expect, MOSI, MISO, SCK and SS are for the SPI communications. You can probably dispense with the MOSI connection.
The row labelled SAMP is the sampling pin on the STM32. That’s where it’s ADC is set up. Any external voltage source that you want to sample should be fed into this pin instead of pin 35 from the Pi.
There’s nothing special about pin 35. However,I have provided the file tone.py that generates a 440Hz squarewave with a 50% duty cycle. So connect pin 35 to PB1 and run
python3 tone.py
Let’s see what it looks like on our oscilloscope, shall we? Type
./oner
which both runs capture.py and plot.py. Later, you can experiment with both files. The plotter is actually quite slow, but after a few seconds you should get a plot that looks something like this:

Groovy! This was achieved by setting
spi.max_speed_hz = 500000
in capture.py. Let’s spice things up by creating an RC (resistor-capacitor circuit). An RC circuit looks something like this:

The bottom terminal points in the diagram represents ground, whilst the upper two are positive voltages. Take the lead out of PB1 and put it into “Vin”. Choose values for R and C. I chose 220 ohm for the former, and 2.2uF for the latter. It gives a nice result. Attach “Vout” to PB1. Remember to ground the capacitor.
Run ./oner
again, and you’ll get an output that should look like this:

The signal now approaches a triangular wave. Experiment further, and see if you can produce a better triangular wave, or perhaps a sine wave. It will involve having two RC circuits in series with correctly-chosen values.
Further directions for the project would be:
- to have continuous capture. However, I haven’t found any graphing software that’s fast enough yet.
- to be able to set trigger points. It would be nice to see switch bouncing, for instance
- Fourier transforms
Go and explore!
Tips
If you want to change the capture frequency, then the best way is likely through changing
spi.max_speed_hz
in capture.py. Reduce the speed if you want a slower capture, increase it if you want more samples. It is also possible to introduce a delay between fetches. There are some limits on the capture speeds, bounded by:
- ADC conversion time. I’ve discusses various tradeoffs here. It currently takes the sample rate is set with ADC_SMPR_55_5, which equates with 175kHz. Pretty good
- Transfer rate between STM32 and Pi.
- Speed of the python program itself. Capture frequencies maybe adversely affected by the fact that python is a slow interpreter
Experimentation would be required to see how far the data capture rates could be pushed. Suffice it to say that you do not get anywhere near the 175kHz sample rate quoted above. Reimplementing the python program in C++ might be a good first step in upping the rates.
Update 2020-07-23
Choosing the SPI rate
As of today, you can choose the SPI capture rate in python.py. The question is: what SPI rate should you choose for what “real” frequency?
Rate table -r us Actual FF 1,000 4755 210Hz 4.8 5,000 3596 278Hz 18.0 10,000 1842 543Hz 18.4 50,000 389 2570Hz 19.5 100,000 210 4751Hz 21.0 200,000 121 8264Hz 24.1 300,000 90.8 11kHz 27.3 400,000 81.5 12.2kHz 32.8 500,000 68.5 14.6kHz 34.2 1,000,000 36.5 27.4kHz 36.5
The above table was compiled from actual samplings on a Pi3. They are indicative, and are probably inapplicable on something like a Pi4 or Pi2.
-r is the SPI speed parameter passed to either oner
or capture.py
The default speed is 1,000,000 Hz, but you can slow down the speed of SPI by issuing a command like:
oner -r 50000
which, obviously, sets the SPI rate to 50kHz. Slowing down the rate will obviously reduce the number of samples you take per second, which is actually an advantage if you want to sample lower frequencies.
us is the actual sampling time in microoseconds. So, setting the SPI rate higher reduces the time for each sample, as indicated in the table.
Actual is the actual sampling rate, as measured in Hz. It’s just the us converted into an equivalent frequency.
FF is the fudge factor that you need to apply to convert your desired actual frequency to the SPI value.
You will notice that there is not a linear relationship between the actual frequency of measurement and the SPI rate. This is because the python code takes time to process a loop, the Linux kernel may interrupt processes, and so forth.
The table is just a guide. If you want 100 samples at around 11kHz, then you would use a value of -r or 300000. I have given some examples below.
Example 1
You want to capture input at a rate of 8kHz, which may be generated from, say, audio output. A value of -r of 200000 would be suitable. Look at the actual rate column, and cross-reference it with a -r value. Alternatively, you can apply a fudge factor of, let’s say, 25, which will give you the same value. You will end up with a sequence of 100 samples (the default) taken at approx 8kHz.
Example 2
Suppose you have a signal which you estimate has a frequency of 440Hz. You want to take 20 samples over that period. In other words, you want a sampling frequency of 20*440 = 8800Hz. So, here again, choosing a SPI rate of 200000 is about right.
You should be able to capture approx 100/20 = 5 waves for a 100-sample capture. You can increase the -r value for a finer-grained measurement of the wave, which will reduce the number of waves you capture. And vice versa.
I hope that makes sense.
Update 2 2020-07-23: investigating speedups
Dissatisfied with the speed as it stood, I decided to try a test using C++. At the 1MHz speed, I was getting reads in the range 37us-53us. The results were highly variable! It means that the Python code wasn’t likely to be the problem. Who knew Python could be so fast?
It turns out that reading in words (uint16_t) in blocks of 100 instead of a word at a time approximately doubles the transfer rate. I estimate that the time per read is 17us, maybe going down to 16us if one reads in blocks of 200.
This is close to the theoretical maximum of SPI transferring at a rate of 1MHz, which is 1E6/16 (bits) = 62.5kHz. A time per read of a sample 17us implies a sampling rate of 1E6/17 = 59kHz.
So, reading in blocks of 100 effectively maxes out the SPI, and the only way you can increase the sample rate is to increase the SPI rate. I haven’t yet found out if this is feasible.
In the STM32 I have set the ADC sample rate to ADC_SMPR_55_5 (see here), giving an ADC conversion rate of 5.7us, which equates with 175kHz.
So maybe there could be more efficiency gains on top of that. More work would be required.