ADC+Pot Read → OLED
Every microcontroller project eventually needs to move past “blinky lights” and start showing useful information. For that, we need two things: a way to measure something from the real world, and a way to display it in a form humans can understand.
The STM32 gives us both. It has a built-in Analog-to-Digital Converter (ADC) for sampling voltages (like from a potentiometer), and it can talk to displays and other peripherals using communication buses such as I²C. In this tutorial, we’ll tie those together by reading an analog voltage and showing it on a little OLED screen.
Here’s the hardware breakdown:
ADC Input (e.g., PA0) – reads the voltage from a slider potentiometer.
I²C Bus (PB8 = SCL, PB9 = SDA) – drives the GME12864 OLED display.
Pushbutton (PC13) – lets us flip between different screens
Our program will start by showing a logo on the OLED. Pressing the button will switch the display to show the live ADC voltage, sampled in real time from the potentiometer. That means you’ll learn how to
Configure an STM32 pin for analog input and read it with the ADC.
Set up the I²C peripheral to talk to the OLED.
Initialize and control a graphical display.
Use a button press to swap between display modes.
By the end, you’ll have a project that feels alive: turning a knob changes the number on the screen, and pushing a button flips the whole display to a new view. This is where the STM32 stops just blinking and starts communicating — a big step toward full-featured embedded applications.
Equipment
Below is a list of the equipment you'll need to do this tutorial. There's also a picture that shows all the hardware you need - minus the jumper wires.
STM32 Nucleo-L476RG board (or compatible Nucleo with ADC + I²C)
10kΩ potentiometer (slider or rotary)
GME12864 OLED display (I²C interface)
Breadboard + jumper wires
USB cable (for power + programming via STM32CubeIDE)
STM32CubeIDE

The GME12864 OLED Display
The GME12864 is a 128×64 pixel monochrome OLED module. Unlike a simple character LCD (where you just send ASCII letters), this display doesn’t know anything about text or graphics by itself — it only knows pixels. If you want to show “Hello World,” you’re really turning on and off the right pixels until the words appear.
The module we’re using talks to the STM32 over I²C:
SDA carries the data.
SCL carries the clock signal.
Inside the OLED is a small controller chip whose job is to keep the pixels lit once you tell it what to draw. To make it work, we send two kinds of information over I²C:
Commands — things like “turn the display on,” “set contrast,” or “move to this column.”
Data — the raw pixel map (which pixels are on vs. off).
A few practical facts about this display:
Resolution: 128 columns × 64 rows of pixels.
Color: Monochrome (each pixel is either on or off).
Layout: The screen is divided into small blocks of pixel memory called “pages,” and we update the screen by sending new page data over I²C.
Because the OLED only understands pixel maps, any image you want to show — like a logo — must be converted into a 1-bit bitmap first. That’s why our development flow includes a dedicated Image Processing step: we’ll resize a logo, convert it to black-and-white, and export it as a C array the STM32 can push straight to the OLED
One more important piece: the OLED must be initialized before it will display anything. On power-up, the controller chip inside the GME12864 is basically in “sleep mode.” Our code will send it a series of commands over I²C that configure the contrast, set the addressing mode, and finally turn the display on. You don’t need to memorize these commands — the initialization routine is provided — but keep in mind that nothing will appear on the screen until this initialization happens.
Once you’ve done that, the OLED becomes a little digital canvas. You can write numbers, text, waveforms, or graphics — all by arranging the right pixels.
STM32 Development Flow
When you dive into a project on the STM32, there’s a pretty reliable rhythm you’ll follow. First comes the idea — the “wouldn’t it be cool if…” moment. For this tutorial, the cool factor is cranking a potentiometer, watching the STM32 measure that voltage, and then seeing the result light up on an OLED screen. To top it off, a single button press will flip the display between your custom logo and the live voltage readout — like giving your microcontroller its own little personality switch.
Our general flow is below.
Start with an Idea
Decide what you want the microcontroller to do — in this case, read an analog voltage from a potentiometer, display the value on an OLED screen, and allow a button press to toggle between your logo and the live readout
Configure the Hardware in the Pinout & Configuration View
This is where you tell the chip, through the .ioc GUI, what it's pins will be doing.
“PA0 will be an ADC input.”
“PB8 and PB9 will be I²C pins for the OLED.”
“PC13 will be a button input.”
Project Code Generation (the .ioc magic)
Once the pinout and peripherals are set, CubeIDE builds the foundation for you. With one click, it generates the initialization code: clocks, GPIO, ADC drivers, I²C wiring, and interrupt hookups. Think of it as CubeIDE handing you a clean workbench so you can focus on the fun part — your logic.
Write Your Application Logic
Now you add the brains: reading the ADC, converting digital counts into volts, pushing data to the OLED, and reacting to button presses to flip between display modes.
Build and Flash the Program
Hit compile, let the toolchain spit out machine code, and send it over to the board with the built-in ST-LINK.
Test and Debug
Run it, watch the OLED, twist the knob, hit the button, and confirm the system behaves like you planned.
For our OLED + ADC + Toggle Display example, we’ll follow exactly this process:
Process the logo image into a format the OLED library understands (resize, convert to monochrome, export as a C array).
Step into Pinout & Configuration to set PA0 as ADC input, PB8/PB9 as I²C for the OLED, and PC13 as button input.
Run Project Code Generation to create the
.ioc
skeleton with all the init code.Add our ADC read, OLED display, image render, and toggle logic to
main.c
.Build, flash, and watch the OLED flip between your logo and a live voltage readout with each button press.
The next 4 sections (Image Processing, Pin Mapping with the GUI, Program Development, and Hookup & Test) will walk through these steps in detail and summarize the general design flow for STM32 systems.
Image Processing
The OLED doesn’t know what to do with a PNG or JPG. All it understands is on/off pixels packed into bytes, so if you want to put your logo on screen, you need to turn it into a 1-bit bitmap C array first.
Instead of firing up GIMP or hunting for random converters, we’ve built a tool right here in the tutorial.
How to use it:
Upload your logo (or any simple image)
Set the target size (usually 128×64).
Pick Threshold (clean, sharp edges) or Floyd–Steinberg (dithered detail).
Adjust the threshold slider if needed.
Click Convert.
You’ll see a live preview of your image in 1-bit mode and a ready-to-use C header file. Copy the array or download the .h
file, then drop it into your STM32CubeIDE project.
From there, the OLED driver code can draw it just like any other bitmap.
Pin Mapping with The GUI
Before we can light up a single pixel on the OLED, we’ve got to tell the STM32 how it’s wired to the outside world. Thankfully, CubeIDE makes this less of a datasheet scavenger hunt and more of a point-and-click adventure with its Pinout & Configuration GUI.
Instead of hand-coding every register and triple-checking the reference manual, we just click the pins in the graphical MCU diagram and assign them a purpose: I²C lines for the OLED, an analog input for the potentiometer, and maybe a button for screen toggling.
All of these clicks get saved in a special project file called .ioc
(short for I/O Configuration). Think of it as your hardware blueprint — the file that tells CubeIDE which pins do what, which peripherals are alive, what the clocks look like, and how interrupts are routed.
When you hit Generate Code, CubeIDE translates that .ioc
file into a ready-to-run framework:
Peripheral init functions (
MX_I2C1_Init()
,MX_ADC1_Init()
, etc.)Correct clock tree setup
Proper interrupt vectors and handlers
GPIO initialization baked in
That means you can stop worrying about boilerplate setup and start focusing on the fun stuff — like drawing your logo on the OLED. Need to change a pin or peripheral later? Just update the .ioc
, regenerate, and the code adapts. (Pro tip: always keep your edits inside the USER CODE sections, or they’ll vanish faster than your coffee supply during an all-nighter.)
In short: the .ioc
file is the single source of truth for your STM32 hardware setup. Treat it as the map that guides CubeIDE in generating all the startup glue you’d otherwise be writing by hand. Get this step right, and the rest of your OLED adventure goes a whole lot smoother.
Note: we do not set the I2C address for the OLED in the steps below! Provided it's the only I2C device on the line, we will find the address in code, so you don't have to know it now.
Open STMCubeIDE, and create a project, as shown below.
Select a board by clicking the Board Selector tab and scrolling down to NUCLEO-L476RG, click on that board and press Next. This is the board used in this tutorial. You can use a different Nucleo board if you have one, but be aware that things may look different — and some settings will likely need to change.
Name the project (you can use the default settings for everything else) and click Finish..
When prompted, select Yes to “Initialize all peripherals with their default mode,” as shown below.
You should now see the MCU Pinout & Configuration tab in its default state, as shown below.
Click on the PB6 pin and select I2C1_SCL, as shown below.
Click on the PB7pin and select I2C1_SDA.
In the Configuration pane, expand Analog → ADC1 and and set IN5 to IN5 Single-ended. PA0 should turn green.
Click on PC13 and ensure that it's set to GPIO_EXTI13.
In the Configuration pane, expand System Core → GPIO and click the PC13 row. Set the configuration as shown in the picture below.
In the same Configuration pane, selecct the NVIC tab, and check the Enable column.
Click on PA5 and ensure that it is set to GPIO_Output.
Open the Clock Configuration tab, and check that peripherals (I²C1, ADC1, etc.) are running within valid clock ranges.
In the Pinout & Configuration tab → under Connectivity, click I2C1.
In the Mode window, set I2C to I2C. The PB6 and PB7 pins on the pinout GUI should turn green. After setting Mode → I2C, leave all Parameter Settings alone (defaults are good).
Save and Generate Code.
Program Development
With the pins defined in the .
ioc
(I²C for the OLED, an ADC pin for the potentiometer, and PC13 for the user button/EXTI), CubeIDE has already generated the boilerplate: clock tree, peripheral inits, and the main.c
scaffolding. Now we drop in the application logic—only inside /* USER CODE BEGIN … */
blocks—so regenerating code later won’t nuke our work.
What this program will do
Scan I²C at boot to discover the OLED’s address (0x3C or 0x3D are typical)
Initialize the OLED and draw a splash/logo screen.
Sample the potentiometer via ADC, convert to voltage, and show it on an alternate screen.
PC13 button (EXTI) toggles screens (Logo ↔ ADC Readout) with light, ISR-safe debouncing.
Update the OLED at a modest rate (e.g., 10 Hz) so we’re not spamming the bus.
File touchpoints
Core/Src/main.c
This is the “director’s chair.” You won’t be writing raw I²C transactions here — you’ll just call high-level functions like
RenderLogo()
orRenderADC()
. Those in turn lean on the SSD1306 helpers to do the grunt work.Core/Inc/main.h
Used for prototypes or externs if you want to keep global variables and function declarations tidy. Optional, but handy once the project starts growing.
Core/Inc/ssd1306.h
& Core/Src/ssd1306.cThis is the helper layer for the OLED. Think of it as a translator between “HAL I²C calls” and “draw something on screen.” Inside here live the routines that:
Send the SSD1306 initialization sequence so the display powers on correctly.
Maintain a framebuffer array in RAM that represents every pixel on the screen.
Offer friendly drawing calls:
ssd1306_clear()
→ blank the screen buffer.ssd1306_update()
→ send the buffer to the OLED in one go.ssd1306_drawBitmap(x, y, array, w, h)
→ paint your logo C array (from Section 1) directly onto the buffer.ssd1306_writeString(x, y, "Hello")
→ render text by pulling character bitmaps from a font table
By isolating these in their own files, you avoid polluting main.c
with low-level command bytes. The main loop just says “show the logo now” or “print the ADC voltage”, and the helper layer figures out what sequence of I²C packets makes that happen.
0) Pre steps (What we did in .ioc)
I²C1 enabled and pinned to your SCL/SDA (whatever you picked).
ADC1 enabled with one regular channel for the pot (e.g., PA0).
PC13 configured as external interrupt (EXTI) for the user button.
LD2 optional for error blink (leave as output if you want it).
Open
main.c
Add these files (from our last message) to your project:
ssd1306.h
is the header file for the OLED helper driver. It declares the functions and constants you’ll use in your application — things likessd1306_init()
,ssd1306_clear()
,ssd1306_update()
,ssd1306_drawBitmap()
, andssd1306_writeString()
. By including this file inmain.c
, you can call those high-level routines without worrying about the low-level I²C command bytes. It also defines the screen dimensions (SSD1306_WIDTH
,SSD1306_HEIGHT
) so your code knows the display’s resolution.ssd1306.c
is the implementation file for the OLED helper driver. It contains the actual code that talks to the SSD1306 controller over I²C — handling initialization, maintaining the screen buffer in RAM, and sending pixel or text data to the display. Functions likessd1306_init()
,ssd1306_clear()
,ssd1306_update()
,ssd1306_drawBitmap()
, andssd1306_writeString()
live here, turning your high-level draw calls into the proper sequence of bytes the OLED understands.
Locate the
while (1)
loopScroll down until you see the infinite loop. Notice the
/* USER CODE BEGIN WHILE */
and/* USER CODE END WHILE */
markers — this is where we’ll add our blinking logic. An example of what to look for is shown below.
Add global variables
At the top of the file, inside
/* USER CODE BEGIN PV */
, add the variables to track the blink speed:
Add the interrupt callback
Scroll down to the
/* USER CODE BEGIN 4 */
section and add the button handler:
Add the LED blink logic
Back in the
while (1)
loop, insert the LED toggling code:
Code WalkThrough (What, Why, and How)
When CubeIDE generated the project, it gave us the startup code. At the top of main.c
, the call to HAL_Init()
prepares the chip and starts the system tick timer that HAL_Delay()
uses. Right after that, SystemClock_Config()
sets the system clocks so the CPU and peripherals all run at the right speeds. The third key function, MX_GPIO_Init()
, applies the pin settings from the .ioc
file. That means PA5 is set as an output for the LED, and PC13 is linked to EXTI line 13 so it can trigger an interrupt when the button is pressed.
Let’s follow what happens when you push the button. The falling edge on PC13 raises an interrupt on EXTI line 13. That request goes into the NVIC — the Nested Vectored Interrupt Controller. The NVIC is the Cortex-M’s traffic cop, deciding which interrupts run and when. Since CubeIDE enabled the grouped EXTI15_10 interrupt, the NVIC accepts the request and calls EXTI15_10_IRQHandler()
in stm32l4xx_it.c
. From there, the HAL takes over with HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_13)
, and finally your own callback HAL_GPIO_EXTI_Callback()
runs. Inside that function you wrote, blinkMode
is incremented so the program steps to the next blink speed.
The program state is managed by two variables you added earlier. blinkMode
tells the program which speed is active, and blinkRates[]
stores the actual delay values in milliseconds. Marking blinkMode
as volatile
is important, because it gets changed inside an interrupt and the compiler needs to know not to optimize it away.
Now look at the main loop. The call to HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5)
flips the LED each time the loop runs. The call to HAL_Delay(blinkRates[blinkMode])
then pauses for the correct amount of time, based on which mode you’re in. Because HAL_Delay()
uses the SysTick timer set up by HAL_Init()
, it gives you an easy millisecond delay without extra configuration.
Altogether, the auto-generated init functions set up the hardware, the NVIC routes the interrupt, the HAL gives you simple functions to work with, and your small state machine ties it together. The end result is exactly what you see on the board: an LED that blinks at different speeds, with each button press immediately changing the mode.
Test It!
With the code written and generated, it’s finally time to hit Run and let the Nucleo do its thing. Flash the board, and you should see LD2 blinking away — one press of the blue button cycles it slower, another press makes it faster, and so on. For the first time in this series, you’ve got input (the pushbutton), output (the LED), and interrupts (EXTI/NVIC) all working together in a real application
That’s not just a blinking LED anymore — it’s proof your toolchain is alive, your .ioc
setup is solid, and your code is running where it should. Consider this your STM32 “hello world,” and enjoy the little victory: your board just listened, reacted, and lit up on command. 🎉

Good luck on your endeavors!
