Skip to content

G4-Scope

This is an exploration into the combination of analog and digital "computing". The aim is to show electrical signals as graph on a screen, i.e. to implement an oscilloscope. The first constraint is that it must fit on a 7x3 or 7x5 cm PCB so that it can be turned into a module later. The second constraint is that it must be doable for me :) - I don't have any formal electronics design training. That's really the point of this: to learn how a "scope" works.

Hardware

A STM32G431CB µC is used as the brains: its two ADCs can sample at up to ≈ 5 Ms/s, the CPU clock goes up to 170 MHz, it has 32 KB RAM, and there's 128 KB of flash memory. There's enough here for an extensive exploration and implementation.

The display is a small 1.9" 320x170 pixel display. Just enough to display a "graticule" area with 12 divisions across and 6 divisions vertically.

The G4-Scope is controlled by buttons and one or two rotary encoders, but these will need to be on a separate module. There's no way to fit these on one PCB with the display.

For development, a Black Magic Probe is used, using an STM32F411 "Black Pill" board.

That, plus a breadboard and some wires, is about it. The total cost with parts from AliExpress was no more than about €30, as of mid-2026.

Once the basic design and build works, an analog front end of some kind will need to be added. But that's a bit further down the road ...

Software

On the software side, it's all based on PlatformIO and JeeH. I'm developing this on MacOS, but Linux and Windows should work equally well. See JeeH's getting started for details.

Built-in peripherals

There are a lot of moving parts in this project. From figuring out a "good" development cycle to implementing and debugging the many different hardware details for high-speed signal acquisition and visualisation. The G4-Scope is bound to be a pretty long adventure ...

All software development is based on the source code in this area: https://codeberg.org/jcw/dobb/src/branch/main/apps/g4-scope/.

Blinking LED

The first build verifies that the G431 has power and is properly connected to the Black Magic Probe (BMP), and that the LED blinks once compiled and uploaded. It's based on this code:

blink.cpp
#include <jee.h>
using namespace jeeh;

const Pin led ("C6","P");

int main () {
    while (true) {
        led.toggle();
        msWait(500);
    }
}

The command to build and upload is:

pio run -e blink -t upload

Console output via RTT

See the JeeH info about RTT for details. This needs a serial connection to the BMP to display messages. That connection can then be kept open across all uploads: very convenient!

console.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"

int main () {
    initBoard();

    while (true) { led.toggle(); msWait(500); }
}

Sample output on the debug console, i.e. the BMP's serial USB port:

console: STM32G431xx @ 170 MHz (v7.0.9-13-gc89bf101)

Note

This same source code structure will be used in all the following "gradual enhacement" examples.

DAC + ADC check

Next step: read out the ADC. The built-in DAC can feed different input voltages as a first test. It looks like in analog mode mode ADC and DAC can be inter-connected on one pin:

Unfortunatelt, JeeH's built-in ADC driver is not quite up to it. It's a simple implementation for ADC1, not for ADC2 (connected to DAC1's output). So for now, I'll jumper A4 to A0.

dac2adc.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"

int main () {
    initBoard();

    Pin::config("A4:A");
    dac::init(); // currently hard-coded for DAC1, output pin A4
    adc::init(); // currently hard-coded for ADC1
    msWait(2); // TODO needs > 1 ms settling time, but why?

    // this code needs a jumper between DAC out (A4) and ADC in (A0)

    const uint16_t steps [] = {
        0, 1000, 2000, 3000, 4000, 4095, 4000, 3000, 2000, 1000, 0,
    };

    for (auto e : steps) {
        dac::set(e);
        msWait(1);

        logf("%4d => %4d %4d %4d", e,
            adc::read(1), adc::read(1), adc::read(1)); // A0 is ADC1 channel 1
    }

    while (true) { led.toggle(); msWait(500); }
}

Sample output, with different DAC values and three quick ADC conversions:

dac2adc: STM32G431xx @ 170 MHz (v7.0.9-13-gc89bf101)
   0 =>   11    0    0
1000 =>  960  927  931
2000 => 2016 1977 1982
3000 => 3087 2968 2969
4000 => 3936 3936 3936
4095 => 3983 3992 3992
4000 => 3936 3936 3936
3000 => 2951 2972 2969
2000 => 1927 1982 1980
1000 =>  927  927  931
   0 =>    0    0    0

It works: the DAC voltages are converted back to digital by the ADC. The measurements are a few percent off, and it looks like the top value is never reached. The DAC buffer is probably not strong enough to drive an ADC with a minimum sample time (it needs a low-impedance input to quickly charge/discharge the sample-and-hold capacitor).

Warning

With fastClock() in defs.hpp disabled, the results are a bit different. All ADC readings are now much closer to the expected values:

dac2adc: STM32G431xx @ 16 MHz (v7.0.9-13-gc89bf101)
   0 =>   45   47   47
1000 =>  997  998  997
2000 => 1997 1996 1997
3000 => 2997 2997 2997
4000 => 3997 3998 3997
4095 => 4057 4057 4058
4000 => 3999 3999 3999
3000 => 2998 2998 2998
2000 => 1997 1997 1997
1000 =>  997  997  997
   0 =>   48   47   47
There's definitely something going on at higher clock speeds ...

Continuous DAC output

For more tests involving the ADC, a stable signal of some kind would be useful. This can be generated with the DAC and a DMA channel in circular buffer mode to continuously feed it. A hardware timer can set the pace at which the DMA feeds new values to the DAC.

But first I need a sine wave ...

CORDIC

For the sake of using as much of the µC's built-in hardware as possible, I'm generating the sine wave using the built-in CORDIC hardware. From the reference manual:

The CORDIC coprocessor provides hardware acceleration of mathematical functions (mainly trigonometric ones) commonly used in motor control, metering, signal processing, and many other applications.

cordic.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"

int main () {
    initBoard();
    cordic::init();

    for (auto i = 0; i <= 16; ++i) {
        auto v = cordic::sine(i << 12);
        logf("%6d %*c", v, v/1024+32, '+');
    }

    while (true) { led.toggle(); msWait(500); }
}

Sample output:

cordic: STM32G431xx @ 170 MHz (v7.0.9-14-g6699c955)
     0                                +
 12539                                            +
 23170                                                      +
 30273                                                             +
 32767                                                               +
 30273                                                             +
 23170                                                      +
 12539                                            +
     0                                +
-12539                    +
-23170          +
-30273   +
-32767 +
-30273   +
-23170          +
-12539                    +
     0                                +

Ok, that's a sine wave. And it's in full 16-bit resolution, more than enough for a 12-bit DAC.

Sine wave

Here's a test to drive the DAC from a sine wave table and plot the ADC readings. It uses cordic::sineFill() to prepare a vector with sinewave values in a specified range:

sinegen.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"

int main () {
    initBoard();
    cordic::init();

    Pin::config("A4:A");
    dac::init(); // currently hard-coded for DAC1, output pin A4
    adc::init(); // currently hard-coded for ADC1
    ADC1[0x14](0,3) = 7; // max ADC sample time, to let weak DAC output settle
    msWait(2); // TODO needs > 1 ms settling time, but why?

    // this code needs a jumper between DAC out (A4) and ADC in (A0)

    // use range 0x0100..0x0F00 to stay out of the 12-bit DAC's extremes
    uint16_t sine [20];
    cordic::sineFill(sine, 0x0700, 0x0800);

    for (auto e : sine) {
        dac::set(e);
        msWait(1);

        adc::read(1);          // A0 is ADC1 channel 1
        auto v = adc::read(1); // use 2nd reading, it's more accurate
        logf("%4d %4d %*c", e, v, v/64, '+');
    }

    while (true) { led.toggle(); msWait(500); }
}

Sample output:

sinegen: STM32G431xx @ 170 MHz (v7.0.9-15-g53c6c526)
2048 2016                               +
2602 2552                                       +
3101 3078                                                +
3498 3487                                                      +
3752 3743                                                          +
3840 3815                                                           +
3752 3743                                                          +
3498 3487                                                      +
3101 3079                                                +
2602 2555                                       +
2048 2031                               +
1494 1439                      +
 995  926              +
 598  536        +
 344  280    +
 256  192   +
 344  280    +
 598  527        +
 995  926              +
1494 1438                      +

As before, the ADC readings aren't 100% accurate, but I've increased the ADC's sampling time to let the DAC's weak output signal settle a bit more than before. Also, to avoid the DAC limits at the both ends of its range, only values 0x0100 .. 0x0F00 are used.

It'll have to do for now - DAC buffering and accuracy can be addressed later.

Periodic timer

To generate a signal without the CPU, a DMA channel needs to be periodically triggered to copy the next value from the sine table to the DAC. That's done with a hardware timer. This test uses TIM3, because it's also able to drive the LED on pin C6:

timer.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"
#include "common.h"

int main () {
    initBoard();

    // system clock = 170 MHz, timer clock = 10 KHz, trigger = 1 Hz, pwm = 10%
    initTimer3(17'000, 10'000, 10);
    led.mode("2"); // alt mode 2: TIM3 CH1

    while (true) {}
}
common.h: initTimer3
void initTimer3 (uint16_t div, uint16_t cnt, uint8_t pct =50) {
    enum { CR1=0x00, CCMR1=0x18, CCER=0x20, PSC=0x28, ARR=0x2C, CCR1=0x34 };

    RCC(ena::TIM3,1) = 1;
    TIM3[CCMR1](0,8) = 0x68; // OC1PE, OC1MR: PWM mode 1
    TIM3[CCER](0) = 1;       // CC1E

    TIM3[PSC] = div - 1;
    TIM3[ARR] = cnt - 1;
    TIM3[CCR1] = (cnt*pct) / 100; // duty cycle 1..100%

    TIM3[CR1](0) = 1; // CEN
}

And sure enough, the LED blinks with a 10% duty cycle. This timer can now be re-used to drive the DMA at a much higher rate.

Note

This code is a first example of "taking over" control of built-in hardware, as JeeH does not include an API to set up and use hardware timers. There is so much variation between them, and there are so many use cases and chip families, that I saw no way to implement a generic and practical wrapper.

Then again, this is how JeeH was meant to be used in non-trivial apps: Use STM's Reference Manual !

DMA to DAC

All the parts now exist to generate a sine wave while leaving the CPU free for other tasks. Here's a first test, generating a slow slne wave which the app then verifies via the ADC:

slowsig.cpp
#include <jee.h>
#include <jee/hal.h>
using namespace jeeh;
#include "defs.hpp"
#include "common.h"

struct Config {
    uint32_t base =0;
    uint32_t dmaBase =0;
    uint8_t dmaIdx =0, dmaC =0, dmaT =0;
};

template< const Config& C >
struct DmaFeed {
    static constexpr IoReg<C.dmaBase>                       DMA {};
    static constexpr IoReg<C.dmaBase+dma::CHAN_STEP*C.dmaC> DCH {};

    void init (const uint16_t* vec, int num, uint16_t reg, uint8_t trg) {
        (void) vec, (void) num, (void) reg, (void) trg;
        RCC(ena::DMA1+C.dmaIdx,1) = 1;
        RCC(ena::DMAMUX,1) = 1;

        constexpr auto CHMAP = 6; // G431
        DMAMUX[4*(CHMAP*C.dmaIdx+C.dmaC)] = C.dmaT;

        // channel configuration
        DCH[dma::CCR] = 0b0101'1011'0010; // MSIZE PSIZE MINC CIRC DIR TCIE

        DCH[dma::CPAR] = C.base + reg;
        DCH[dma::CMAR] = (uintptr_t) vec;
        DCH[dma::CNDTR] = num;
        DCH[dma::CCR](0) = 1; // EN
    }

    void deinit () {
        DCH[dma::CCR] = 0;
        RCC(ena::DMAMUX,1) = 0;
        RCC(ena::DMA1+C.dmaIdx,1) = 0;
    }
};

constexpr Config DAC_CONF = {
    DAC1.ADDR, DMA1.ADDR, 1-1, 2-1, 61,
};

DmaFeed<DAC_CONF> feeder;

int main () {
    initBoard();
    cordic::init();

    Pin::config("A4:A");
    dac::init(); // currently hard-coded for DAC1, output pin A4
    adc::init(); // currently hard-coded for ADC1
    ADC1[0x14](0,3) = 7; // max ADC sample time, to let weak DAC output settle
    msWait(2); // TODO needs > 1 ms settling time, but why?

    // this code needs a jumper between DAC out (A4) and ADC in (A0)

    // use range 0x0100..0x0F00 to stay out of the 12-bit DAC's extremes
    uint16_t sine [4096];
    cordic::sineFill(sine, 0x0700, 0x0800);

    // system clock = 170 MHz, timer clock = 10 KHz, trigger = 2 KHz
    initTimer3(17'000, 5);

    // set up a continuous 16-bit stream from sine[] to DAC1
    feeder.init(sine, 4096, 0x08, 61); // trigger on TIM3 CH1

    for (auto i = 0; i < 20; ++i) {
        adc::read(1);          // A0 is ADC1 channel 1
        auto v = adc::read(1); // use 2nd reading, it's more accurate
        logf("%4d %*c", v, v/64, '+');
logf("%d", +feeder.DCH[dma::CNDTR]);
//logf("%d", +TIM3[0x24]);

        msWait(100);
    }

    while (true) { led.toggle(); msWait(500); }
}

Sample output:

........

The sine wave table has 4096 entries to support the full resolution of the DAC: since the derivative of a sine wave is always in the range [-1,+1], a 12-bit DAC never changes by more than ±1 at a time when the wave is spread out over 4096 steps. Because the STM's DAC rate limit is one million samples per second, the full-resolution frequency output can be at most ≈ 250 Hz. For initial tests, this sine wave signal will be fine.

That concludes the analog side of this initial G4-Scope exploration.

Connecting an LCD display

The 1.9" LCD display fits (just barely) on a 7x3 cm PCB. Its 320x170 pixel resolution is minimal but sufficient, and it turns out to be surprisingly readable at close distance.

The display is connected via SPI, using a total of 6 signal and 2 power wires:

LCD   Signal   GPIO   SPI
---   ------   ----   -----
 1    GND      -      -
 2    +3.3V    -      -
 3    SCL      B13    SCLK
 4    SDA      B15    MOSI
 5    RST      B11    -
 6    DC       B14    (MISO)
 7    CS       B12    NSEL
 8    BLK      B10    -
The MISO signal is reused as "DC" GPIO output.

The first step is to specify all the pin connections in the platformio.ini file:

board_pins = bkl:B10 rst:B11 dc:B14
board_spi  = P:B15:H5,B14,B13,B12:HP N:SPI2 F:170

This is turned into #define settings by JeeH's code generator as part of the build process:

defs.hpp
// Lines with "CG" control the code-generated parts of this file.

//CG1 board leds
#define LED  "C6"

const Pin led (LED,"P");

//CG3 board pins
#define PINS_BKL "B10"
#define PINS_RST "B11"
#define PINS_DC "B14"

//CG[ board spi
#define SPI_NAME SPI2
constexpr spi::Config SPI_CONF {
    "B15:H5,B14,B13,B12:HP", SPI2.ADDR, ena::SPI2, 170,
};
//CG]

spi::Poll<SPI_CONF> spiBus; // st7789 lcd

rtt::Desc rttDesc;

void jeeh::logWriter (const void* ptr, size_t len) {
    rttDesc.tx.write((const char*) ptr, len);
}

void initBoard () {
    fastClock();
    rttDesc.tx.flags = 2; // set RTT output to blocking mode
    cycles::init();

    logf("\n%s: %s @ %d MHz (%s)",
            PIOENV, SVDNAME, SystemCoreClock / 1'000'000, VERSION);
}

JeeH has a "TwoDee" package with a set of high-level graphics primitives. It's now simple to draw a "graticule" grid, plus a few extra lines to verify the display's size and orientation:

lcdtest.cpp
#include <jee.h>
#include <jee/hal.h>
#include <jee/dev/st7796.h>
using namespace jeeh;
#include "defs.hpp"
#include "common.h"

using Screen = dev::ST7796<LcdSpi,170,320,35>; // 1.9" LCD module
util::TwoDee<Screen> gfx;

int main () {
    initBoard();
    initGrid(gfx);

    // draw a a box of maximum size, and a line from origin to the middle
    // to verify proper setup and orientation: (0,0) should be bottom left

    gfx.fg = GREEN; gfx.box({0, 0, Screen::width-1, Screen::height-1});
    gfx.fg = RED;   gfx.line({0,0}, {Screen::width/2, Screen::height/2});

    while (true) { led.toggle(); msWait(500); }
}
common.h: initGrid
template< typename T >
void initGrid (T& g) {
    g.init();
    g.clear();

    g.fg = GREY;
    for (auto i = 1; i < GC; ++i)
        g.hLine({TB, TB+i*PW}, GH);
    for (auto i = 1; i < GR; ++i)
        g.vLine({TB+i*PH, TL}, GW);

    g.fg = WHITE; g.box({TB, TL, GH+1, GW+1});
}

See also the image at the top of this page for a first "scope-like" impression.

Info

This code is starting to become a bit more involved, using C++ templates to tie things together. That's how "modern" C++ works: combine code in a type-safe manner without having to use virtual function calls. The main benefit is performance, as the compiler can optimise such code considerably better than runtime "vtables". When used with care, it also becomes smaller (but ... it's easy to get that wrong!).