This is not just another STM32 oscilloscope, it's mine. As such, it was designed to my liking. I squeezed-out the maximum conversion rate - 2.57 Msps - from a single ADC, I used the built-in DMA controller to maximize data transfer speed and I made it export the acquired and calculated data on an SD card. It was built on a general purpose board and has a flexible input circuitry.
STM32 Oscilloscope - 500 kHz square signal 50% duty cycle. Left - wave shape. Right - the FFT power spectrum.This is a follow-up of my previous article on the STM32 oscilloscope construction. So far this project involved schematics design, PCB fabrication, TH/SMD part soldering, low level C/C++ programming, some contact with STM32's assembler, FAT32 file system, memory management and a lot of fun with interface design.
* One input channel
* 2.57 Msps ADC, accepting signal frequencies up to 1.28 MHz
* Calculates minimum, maximum and average values
* Spectrum FFT analysis
* Fundamental frequency detection
* SD card export of signal wave shape and spectrum
* Freeze function
* Sampling rate selection
The STM32F103C8 can deploy half of its clock speed for ADC sampling - by setting the prescaler to 2, see RCC_ADCPRE_PCLK_DIV_2 - and computes FFT for 1024 samples in 2 ms. The sampling capacitor is allowed to charge in 1.5 ADC cycles using the ADC_SMPR_1_5 setting and the sample quantization takes another 12.5 ADC cycles to complete. That means an acquisition frequency of 2.571 MHz (= 72 MHz / 2 / (1.5 + 12.5)).
To attain reasonable SNR levels at this sampling rate, one must satisfy the RC time constant for the sampling capacitor. The good news is that the CADC is small, 8pF. The bad part is that there's a series resistance RADC of 1 kOhm (maximum rating) just before the sampling capacitor. Now, according to the MCU's specifications, the input resistence RAIN must obey the following:
where TS = 1.5 ADC cycles, fADC = 36 MHz, CADC = 8 pF, RADC <= 1 kOhm and N is the effective number of bits storing error-less data.
The result is not encouraging as one can only use the 5 most significant bits of the converted data. The only feasible choices yielding 12 bit resolution would be lowering the fADC to 18 MHz or using a higher sampling time (the next being 7.5 ADC cycles).
have indicated that a 250 kHz signal sampled at 2.57 Msps, 1.8 Msps, 1.38 Msps or even 529 ksps gives similar amplitude measurements leading to believe that RADC is in fact much lower than (maybe half of) the maximum mentioned in the specs and that sampling signals at 2.57 MHz won't pose significant problems.
STM32 Oscilloscope - 250 kHz square signal 50% duty cycle. A 20 mV error was observed at different sampling rates. Left - at 2.57 MSPS. Right - at 529 kSPS.That is good news considering that the input stage is totally passive: composed of a voltage divider - the current drawn by the MCU input makes R3 act as a current limiter - on the generator side, a current limiting resistor and a high-pass filtering capacitor in series with the ADC input. Needless to say, the input should contain at least a voltage follower.
STM32 Oscilloscope - Simple input stage circuit. The test signal is generated by an AtMega328p powered at 3.3V.still uses Arduino structure and libraries except for STM32 related ADC, DMA and FFT routines. It assumes that a NT35702 TFT is hooked as described here, the SD card pins go to the STM32's SPI2 port and the buttons and inputs are connected as per the previous post. One must also install the SdFat library to make the SD card code work and add (with drag-and-drop) the cr4_fft_1024_stm32 related assembler files to the Arduino IDE project.
The state machine iterates through the key probing, acquisition, FFT and display states. Yes, I gave-up interrupt based key management in favor of key probing and that's because the machine no longer waits on other states. The 1024 samples are acquired and stored directly into the memory using the built-in DMA functionality. Then the samples are chewed and transformed into frequency bins by the assembler FFT implementation. The display uses a linear scale to display the signal spectrum while the power of the fundamental is expressed in dB.
/* * STM32 Digital Oscilloscope * using the STM32F103C8 MCU and the NT35702 2.4 inch TFT display * https://www.gameinstance.com/post/80/STM32-Oscilloscope-with-FFT-and-SD-export * * GameInstance.com * 2016-2018 */ #include <Adafruit_ILI9341_8bit_STM.h> #include <Adafruit_GFX.h> #include <SPI.h> #include "SdFat.h" #include <table_fft.h> #include <cr4_fft_stm32.h> static const uint8_t SD_CHIP_SELECT = PB12; static const uint8_t TIME_BUTTON = PA15; static const uint8_t TRIGGER_BUTTON = PB10; static const uint8_t FREEZE_BUTTON = PB11; static const uint8_t TEST_SIGNAL = PA8; static const uint8_t CHANNEL_1 = PB0; static const uint8_t CHANNEL_2 = PB1; static const uint16_t BLACK = 0x0000; static const uint16_t BLUE = 0x001F; static const uint16_t RED = 0xF800; static const uint16_t GREEN = 0x07E0; static const uint16_t CYAN = 0x07FF; static const uint16_t MAGENTA = 0xF81F; static const uint16_t YELLOW = 0xFFE0; static const uint16_t WHITE = 0xFFFF; static const uint16_t BACKGROUND_COLOR = BLUE; static const uint16_t DIV_LINE_COLOR = GREEN; static const uint16_t CH1_SIGNAL_COLOR = YELLOW; static const uint16_t ADC_RESOLUTION = 4096; // units static const uint16_t EFFECTIVE_VERTICAL_RESOLUTION = 200; // pixels static const uint16_t SCREEN_HORIZONTAL_RESOLUTION = 320; // pixels static const uint16_t SCREEN_VERTICAL_RESOLUTION = 240; // pixels static const uint16_t DIVISION_SIZE = 40; // pixels static const uint16_t SUBDIVISION_SIZE = 8; // pixels (DIVISION_SIZE / 5) static const uint16_t BUFFER_SIZE = 1024; // bytes static const uint8_t TRIGGER_THRESOLD = 127; // units static const float ADC_SCREEN_FACTOR = (float)EFFECTIVE_VERTICAL_RESOLUTION / (float)ADC_RESOLUTION; static const float VCC_3_3 = 3.3; // volts const uint8_t DT_DT[] = {4, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1}; const uint8_t DT_PRE[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; const uint8_t DT_SMPR[] = {0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 7}; const float DT_FS[] = {2571, 2571, 2571, 1800, 1384, 878, 667, 529, 429, 143, 71.4}; const float DT_DIV[] = {3.9, 7.81, 15.63, 22.73, 29.41, 45.45, 55.55, 83.33, 95.24, 293.3, 586.6}; Adafruit_ILI9341_8bit_STM tft; SdFat sd(2); SdFile file; uint8_t bk[SCREEN_HORIZONTAL_RESOLUTION]; uint16_t data16[BUFFER_SIZE]; uint32_t data32[BUFFER_SIZE]; uint32_t y[BUFFER_SIZE]; uint8_t time_base = 7; uint16_t i, j; uint8_t state = 0; uint16_t maxy, avgy, miny; volatile uint8_t h = 1, h2 = -1; volatile uint8_t trigger = 1, freeze = 0; volatile bool bPress[3], bTitleChange = true, bScreenChange = true; volatile static bool dma1_ch1_Active; bool wasPressed(int pin, int index) { // if (HIGH == digitalRead(pin)) { // isn't pressed if (bPress[index]) { // but was before bPress[index] = false; } return false; } // is pressed if (!bPress[index]) { // and wasn't before bPress[index] = true; return true; } // but was before return false; } // ------------------------------------------------------------------------------------ // The following section was inspired by http://www.stm32duino.com/viewtopic.php?t=1145 void setADCs() { // switch (DT_PRE[time_base]) { // case 0: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_2); break; case 1: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_4); break; case 2: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_6); break; case 3: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_8); break; default: rcc_set_prescaler(RCC_PRESCALER_ADC, RCC_ADCPRE_PCLK_DIV_8); } switch (DT_SMPR[time_base]) { // case 0: adc_set_sample_rate(ADC1, ADC_SMPR_1_5); break; case 1: adc_set_sample_rate(ADC1, ADC_SMPR_7_5); break; case 2: adc_set_sample_rate(ADC1, ADC_SMPR_13_5); break; case 3: adc_set_sample_rate(ADC1, ADC_SMPR_28_5); break; case 4: adc_set_sample_rate(ADC1, ADC_SMPR_41_5); break; case 5: adc_set_sample_rate(ADC1, ADC_SMPR_55_5); break; case 6: adc_set_sample_rate(ADC1, ADC_SMPR_71_5); break; case 7: adc_set_sample_rate(ADC1, ADC_SMPR_239_5); break; default: adc_set_sample_rate(ADC1, ADC_SMPR_239_5); } adc_set_reg_seqlen(ADC1, 1); ADC1->regs->SQR3 = PIN_MAP[CHANNEL_1].adc_channel; ADC1->regs->CR2 |= ADC_CR2_CONT; // | ADC_CR2_DMA; // Set continuous mode and DMA ADC1->regs->CR2 |= ADC_CR2_SWSTART; } void real_to_complex(uint16_t * in, uint32_t * out, int len) { // for (int i = 0; i < len; i++) out[i] = in[i]; } uint16_t asqrt(uint32_t x) { //good enough precision, 10x faster than regular sqrt // int32_t op, res, one; op = x; res = 0; one = 1 << 30; while (one > op) one >>= 2; while (one != 0) { if (op >= res + one) { op = op - (res + one); res = res + 2 * one; } res /= 2; one /= 4; } return (uint16_t) (res); } void inplace_magnitude(uint32_t * target, uint16_t len) { // uint16_t * p16; for (int i = 0; i < len; i ++) { // int16_t real = target[i] & 0xFFFF; int16_t imag = target[i] >> 16; // target[i] = 10 * log10(real*real + imag*imag); uint32_t magnitude = asqrt(real*real + imag*imag); target[i] = magnitude; } } uint32_t perform_fft(uint32_t * indata, uint32_t * outdata, const int len) { // cr4_fft_1024_stm32(outdata, indata, len); inplace_magnitude(outdata, len); } static void DMA1_CH1_Event() { // dma1_ch1_Active = 0; } void adc_dma_enable(const adc_dev * dev) { // bb_peri_set_bit(&dev->regs->CR2, ADC_CR2_DMA_BIT, 1); } // ------------------------------------------------------------------------------------ void export_to_sd() { // tft.setCursor(170, 20); tft.setTextColor(WHITE); tft.setTextSize(1); tft.print("Writing to SD ..."); tft.setCursor(170, 20); if (!sd.cardBegin(SD_CHIP_SELECT, SD_SCK_HZ(F_CPU/4))) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("No SD card detected"); return; } delay(500); if (!sd.fsBegin()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("File system init failed."); return; } uint8_t index; if (!sd.exists("DSO")) { // no pre-exising folder structure if (!sd.mkdir("DSO")) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't create folder"); return; } } if (!sd.exists("DSO/data.idx")) { // no index file index = 1; if (!file.open("DSO/data.idx", O_CREAT | O_WRITE)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't create idx file"); return; } file.write(index); if (!file.sync() || file.getWriteError()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Idx file write error"); return; } file.close(); } else { // if (!file.open("DSO/data.idx", O_READ)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't open idx file"); return; } if (!file.read(&index, 1)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't read idx file"); return; } if (!file.sync() || file.getWriteError()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("File write error"); return; } file.close(); } String s = "DSO/Exp"; s += index; s += ".dat"; if (!file.open(s.c_str(), O_CREAT | O_WRITE | O_EXCL)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't create data file"); return; } file.println("Time series"); for (uint16_t i = 0; i < BUFFER_SIZE; i ++) { // file.print(data16[i], DEC);file.print(", "); } file.println(" "); file.print("Fs: ");file.print(DT_FS[time_base]);file.println("kHz"); file.println("Spectrum"); for (uint16_t i = 0; i < BUFFER_SIZE/2; i ++) { // file.print(y[i], DEC);file.print(", "); } file.println(" "); if (!file.sync() || file.getWriteError()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("File write error"); return; } file.close(); s += ".img"; if (!file.open(s.c_str(), O_CREAT | O_WRITE | O_EXCL)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't create image file"); return; } file.println("IMX"); for (uint16_t i = 0; i < BUFFER_SIZE; i ++) { // file.print(data16[i], DEC);file.print(", "); } file.println(" "); file.print("Fs: ");file.print(DT_FS[time_base]);file.println("kHz"); file.println("Spectrum"); for (uint16_t i = 0; i < BUFFER_SIZE/2; i ++) { // file.print(y[i], DEC);file.print(", "); } file.println(" "); if (!file.sync() || file.getWriteError()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("File write error"); return; } file.close(); index ++; if (!file.open("DSO/data.idx", O_CREAT | O_WRITE)) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Can't create idx file"); return; } file.write(index); if (!file.sync() || file.getWriteError()) { // tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("Idx file write error"); return; } file.close(); tft.fillRect(169, 19, 150, 9, BACKGROUND_COLOR); tft.print("File write success"); delay(2000); tft.fillRect(170, 19, 150, 9, BACKGROUND_COLOR); } void setup() { // tft.begin(); tft.setRotation(3); bPress[0] = false; bPress[1] = false; bPress[2] = false; adc_calibrate(ADC1); } void loop() { // if (state == 0) { // tft.fillScreen(BACKGROUND_COLOR); tft.setCursor(15, 100); tft.setTextColor(YELLOW); tft.setTextSize(3); tft.println("GameInstance.com"); // analogWrite(TEST_SIGNAL, 127); delay(1500); tft.fillScreen(BACKGROUND_COLOR); state = 1; } if (state == 1) { // init state = 2; } if (state == 2) { // buttons check if (wasPressed(TIME_BUTTON, 0)) { // toggling the time division modes time_base ++; if (trigger == 0) { // spectrum if (time_base <= 2) time_base = 3; } time_base = time_base % sizeof(DT_DT); h = DT_DT[time_base]; bScreenChange = true; } if (wasPressed(TRIGGER_BUTTON, 1)) { // toggling the trigger mode trigger ++; trigger = trigger % 4; bScreenChange = true; bTitleChange = true; } if (wasPressed(FREEZE_BUTTON, 2)) { // toggling the freeze screen freeze = (freeze > 0) ? 0 : 3; bTitleChange = true; } if (freeze) { // frozen screen state = 5; } else { // live screen state = 3; } } if (state == 3) { // acquisition setADCs(); dma_init(DMA1); dma_attach_interrupt(DMA1, DMA_CH1, DMA1_CH1_Event); adc_dma_enable(ADC1); dma_setup_transfer(DMA1, DMA_CH1, &ADC1->regs->DR, DMA_SIZE_16BITS, data16, DMA_SIZE_16BITS, (DMA_MINC_MODE | DMA_TRNS_CMPLT)); dma_set_num_transfers(DMA1, DMA_CH1, BUFFER_SIZE); dma1_ch1_Active = 1; dma_enable(DMA1, DMA_CH1); // enable the DMA channel and start the transfer while (dma1_ch1_Active) {}; // waiting for the DMA to complete dma_disable(DMA1, DMA_CH1); // end of DMA trasfer real_to_complex(data16, data32, BUFFER_SIZE); // data format conversion perform_fft(data32, y, BUFFER_SIZE); // FFT computation state = 4; } if (state == 4) { // display signal screen if (bScreenChange) { // massive change on screen bScreenChange = false; tft.fillScreen(BACKGROUND_COLOR); bTitleChange = true; } else { // clear previous wave if (trigger == 0) { // clear previous spectrum for (i = 1; i < SCREEN_HORIZONTAL_RESOLUTION; i ++) { // tft.drawLine( i, bk[i], i + 1, bk[i + 1], BACKGROUND_COLOR); } } else { // clear previous time samples for (i = 0, j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; i ++, j += h2) { // tft.drawLine( j, bk[i], j + h2, bk[i + 1], BACKGROUND_COLOR); } } } // re-draw the divisions for (i = 0; i < SCREEN_HORIZONTAL_RESOLUTION; i += DIVISION_SIZE) { // for (j = SCREEN_VERTICAL_RESOLUTION; j > 13; j -= ((i == 160) ? SUBDIVISION_SIZE : DIVISION_SIZE)) { // tft.drawLine(i - 1, j, i + 1, j, DIV_LINE_COLOR); } } for (i = SCREEN_VERTICAL_RESOLUTION; i > 13; i -= DIVISION_SIZE) { // for (j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; j += ((i == 120) ? SUBDIVISION_SIZE : DIVISION_SIZE)) { // tft.drawLine(j, i - 1, j, i + 1, DIV_LINE_COLOR); } } // draw current wave if (trigger == 0) { // display spectrum uint16_t max_y = 0, max_x = 0; uint16_t i_0, i_1; bool hit_max = false; for (i = 1; i < BUFFER_SIZE / 2; i ++) { // if (y[i] > max_y) { // max_y = y[i]; max_x = i; } } max_y = max(max_y, EFFECTIVE_VERTICAL_RESOLUTION); tft.setTextColor(WHITE); tft.setTextSize(1); for (i = 1; i < SCREEN_HORIZONTAL_RESOLUTION; i ++) { // i_0 = (int)((float)i * (float)BUFFER_SIZE / (float)SCREEN_HORIZONTAL_RESOLUTION / 2.0); i_1 = (int)((float)(i + 1) * (float)BUFFER_SIZE / (float)SCREEN_HORIZONTAL_RESOLUTION / 2.0); if (hit_max) { // was in the vicinity of max i_0 = max_x; hit_max = false; } else if ((max_x <= i_1) && (i_0 <= max_x)) { // is in the vicinity of max if ((i_1 - max_x) <= (max_x - i_0)) { // hit_max = true; i_1 = max_x; } else { // i_0 = max_x; } } bk[i] = SCREEN_VERTICAL_RESOLUTION - (10 + ((float)y[i_0] / (float)max_y) * (float)(EFFECTIVE_VERTICAL_RESOLUTION - 10)); tft.drawLine( i, bk[i], i + 1, SCREEN_VERTICAL_RESOLUTION - (10 + ((float)y[i_1] / (float)max_y) * (float)(EFFECTIVE_VERTICAL_RESOLUTION - 10)), CH1_SIGNAL_COLOR); if (i % DIVISION_SIZE == 0) { // float freq = ((float)i / (float)SCREEN_HORIZONTAL_RESOLUTION * (float)DT_FS[time_base]) / 2.0; tft.setCursor(i - (freq > 100 ? 8 : 5) - (freq > (int)freq ? 4 : 0), SCREEN_VERTICAL_RESOLUTION - 7); tft.print(freq, 1); } } // clear previous stats tft.fillRect(7, 19, 150, 9, BACKGROUND_COLOR); tft.setCursor(8, 20); tft.setTextColor(WHITE); tft.setTextSize(1); String s; s = "F: "; s += (float)max_x / (float)BUFFER_SIZE * (float)DT_FS[time_base]; s += "kHz "; s += (float)20 * log10(max_y); s += "dB"; tft.print(s); } else { // display time samples uint16_t maxy = 0; uint16_t miny = ADC_RESOLUTION; uint32_t avgy = 0; for (i = 1; i < BUFFER_SIZE; i ++) { // maxy = max(maxy, data16[i]); miny = min(miny, data16[i]); avgy += data16[i]; } avgy /= BUFFER_SIZE; for (i = 0, j = 0; j < SCREEN_HORIZONTAL_RESOLUTION; i ++, j += h) { // bk[i] = SCREEN_VERTICAL_RESOLUTION - (20 + (data16[i] * ADC_SCREEN_FACTOR)); bk[i + 1] = SCREEN_VERTICAL_RESOLUTION - (20 + (data16[i + 1] * ADC_SCREEN_FACTOR)); tft.drawLine( j, bk[i], j + h, bk[i + 1], CH1_SIGNAL_COLOR); if (h > 1) tft.drawPixel(j, bk[i] - 1, GREEN); } // clear previous stats tft.fillRect(7, 19, 60, 9, BLUE); tft.setCursor(8, 20); tft.setTextColor(WHITE); tft.setTextSize(1); String s; s = "Max: "; s += (float)maxy / (float)ADC_RESOLUTION * VCC_3_3; s += "V"; tft.print(s); tft.fillRect(SCREEN_HORIZONTAL_RESOLUTION / 2 - 30, SCREEN_VERTICAL_RESOLUTION - 20, 60, 9, BLUE); tft.setCursor(SCREEN_HORIZONTAL_RESOLUTION / 2 - 29, SCREEN_VERTICAL_RESOLUTION - 19); tft.setTextColor(WHITE); tft.setTextSize(1); s = "Avg: "; s += (float)avgy / (float)ADC_RESOLUTION * VCC_3_3; s += "V"; tft.print(s); tft.fillRect(7, SCREEN_VERTICAL_RESOLUTION - 20, 60, 9, BLUE); tft.setCursor(8, SCREEN_VERTICAL_RESOLUTION - 19); tft.setTextColor(WHITE); tft.setTextSize(1); s = "Min: "; s += (float)miny / (float)ADC_RESOLUTION * VCC_3_3; s += "V"; tft.print(s); h2 = h; } state = 5; } if (state == 5) { // if (bTitleChange) { // title change bTitleChange = false; tft.fillRect(0, 0, SCREEN_HORIZONTAL_RESOLUTION, 12, CH1_SIGNAL_COLOR); tft.setCursor(8, 3); tft.setTextColor(BLUE); tft.setTextSize(1); String s = "CH1 "; s += .65; s += "V "; if (trigger == 0) { // spectrum s += (int)DT_FS[time_base]; s += "kHz "; } else { // time samples s += DT_DIV[time_base]; s += "us "; } if (trigger == 1) { // raising front trigger s += "Raising "; } else if (trigger == 2) { // descending front trigger s += "Falling "; } else if (trigger == 3) { // no trigger s += "None "; } else { // spectrum scope s += "Spectrum "; } tft.print(s); if (freeze) { // tft.setCursor(170, 3); tft.setTextColor(RED); tft.setTextSize(1); tft.print("Freeze"); } tft.setCursor(215, 3); tft.setTextColor(BLACK); tft.setTextSize(1); tft.print("GameInstance.com"); } if (freeze == 3) { // freeze = 1; export_to_sd(); bScreenChange = true; } } delay(50); state = 1; }
For the up-to-date version, check-out the GitHub repo.
Pressing the Freeze button also triggers the SD data export, if an SD card is present. For the moment, the export is made in text format. The file contains the 1024 acquired samples, the calculated 512 frequency bins as well as the sampling rate. Each file is created in a dedicated folder, using filenames suffixed with successive indices.
if any, is that a project such as this one is never complete. There's always something missing, something partially implemented or a hack that keeps things in place. Nonetheless, this became a serious stand-alone tool that already helps me debug other MCU based projects or probe audio signals.
Stick around for updates!