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.

Image → 1-bit C Array
SSD1306/1309 page format · 8 vertical pixels/byte
Preview (1-bit)
Header Output

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 PC13 pin and select Reset_State, as shown below.

  • Click on the PB6 pin and select I2C1_SCL, as shown below.

  • Click on the PB7 pin and select I2C1_SDA.

  • Click on the PA0 pin, and select ADC_IN5.

  • In the Configuration pane, expand Analog → ADC1 and and set IN5 to IN5 Single-ended. PA0 should turn green.

  • 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 - trust me that's frustrating, so put the code in the right spots.

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() or RenderADC(). 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.c

    This 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).

  • Step 1 — Library Declarations

    Open main.c

    Add these files to your project:

    • Core/Inc/ssd1306.h

      ssd1306.h is the header file for the OLED helper driver. It declares the functions and constants you’ll use in your application — things like ssd1306_init(), ssd1306_clear(), ssd1306_update(), ssd1306_drawBitmap(), and ssd1306_writeString(). By including this file in main.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.

    • Core/Src/ssd1306.c  

      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 like ssd1306_init(), ssd1306_clear(), ssd1306_update(), ssd1306_drawBitmap(), and ssd1306_writeString() live here, turning your high-level draw calls into the proper sequence of bytes the OLED understands.

  • Step 2 — Include the Logo File

    Include the logo file generated from the Image Processing section above. To do this we have to add the file to the Inc folder , (just drag and drop it into the folder) and we need to include it in our main.c code.


    • #include "logo_bmp.h"

  • Step 3 — Globals, Types, and Function Prototypes

    This step sets up the project-wide pieces that everything else leans on:

    • ScreenMode enum — defines two states (SCREEN_LOGO, SCREEN_ADC) so we can flip between screens.

      STM32 Snippet
      /* Private typedef -----------------------------------------------------------*/
      /* USER CODE BEGIN PTD */
      typedef enum {
        SCREEN_LOGO = 0,
        SCREEN_ADC  = 1
      } ScreenMode;
      /* USER CODE END PTD */
      
      /* Private define ------------------------------------------------------------*/
        
    • g_screenMode — the actual variable holding the current mode (starts on logo).

    • g_lastChangeMs —“activity timer,” refreshed whenever the pot moves enough

    • g_lastAdcCounts — baseline value, so we can measure deltas.

    • ADC_DELTA — baseline value, so we can measure deltas.

    • g_oledAddr — holds the OLED I²C address (default 0x3C, some modules use 0x3D).

      STM32 Snippet
      /* USER CODE BEGIN PV */
      volatile ScreenMode g_screenMode = SCREEN_LOGO; 
      // Current screen: logo or ADC view
      
      volatile uint32_t g_lastChangeMs = 0;  
      // Timestamp (ms) of last significant ADC change (for 4s auto-return)
      
      volatile uint32_t g_lastAdcCounts = 0; 
      // Baseline ADC sample for delta comparisons
      
      const uint16_t ADC_DELTA = 30;         
      // Threshold in ADC counts to consider a “significant” change
      
      uint8_t g_oledAddr = 0x3C;             
      // OLED I2C address (7-bit). ProbeOledAddress() may change this to 0x3D at startup.
      /* USER CODE END PV */
      
        

    And we declare a few function prototypes so the compiler knows what’s coming later:

    • RenderLogo() — clears the OLED and draws the splash bitmap.

    • RenderADC() — reads the potentiometer voltage and prints it.

    • ErrorBlink() — flashes the onboard LED if something goes wrong.

    • ProbeOledAddress()) — makes startup robust to OLEDs shipping at 0x3C vs 0x3D.

    • AdcCountsToVolts() — neat little math helper (keeps RenderADC() tidy).

    • CheckAdcForChange() — called in the main loop to detect when the pot moved enough to warrant switching to SCREEN_ADC.

      STM32 Snippet
      /* USER CODE BEGIN PFP */
      void RenderLogo(void);           // Clear OLED and draw splash bitmap
      void RenderADC(void);            // Read ADC, convert to volts, and display on OLED
      void ErrorBlink(void);           // Flash LD2 on PA5 to indicate error
      static uint8_t ProbeOledAddress(void); // Scan I2C bus for OLED at 0x3C/0x3D
      static float AdcCountsToVolts(uint32_t counts, float vref, uint16_t fullscale);
      // Convert raw ADC counts to a voltage value
      static void CheckAdcForChange(void);   // Poll ADC; flip to ADC mode on significant delta
      /* USER CODE END PFP */
        
  • Step 4 — Main Function Skeleton

    This is where all the pieces come together for the first time. The code in step one is auto generated, so just confirm that those pieces are there. The main() function provides the program’s backbone:

    • System bring-up (Auto generated)

      • Call HAL_Init() and SystemClock_Config() to get the MCU running.

      • Initialize peripherals through the Cube-generated functions (MX_GPIO_Init(), MX_I2C1_Init(), MX_ADC1_Init(), etc.).

    • OLED startup

      • Probe for the OLED’s I²C address (ProbeOledAddress()), since different modules ship with either 0x3C or 0x3D. This code should be placed just after the MX_XX_init() function calls, in the USER CODE BEGIN 2 section.

        STM32 Snippet
          /* USER CODE BEGIN 2 */
         
          
          // Probe OLED address (required; fail fast if not found)
           uint8_t probed = ProbeOledAddress();   // checks 0x3C / 0x3D
           if (!probed) {
             // OLED not detected: signal error and stop here
             ErrorBlink();                         // brief LED flash pattern
             while (1) {                           // hard-stop; no pointless init/draw
               HAL_Delay(250);
             }
           }
           g_oledAddr = probed;
          
          /* USER CODE END 2 */
          
      • Initialize the OLED with ssd1306_init(), however, if init fails blink forever. This shold go right under the previouse code, in the same section.

        STM32 Snippet
        /* USER CODE BEGIN PV */
        volatile ScreenMode g_screenMode = SCREEN_LOGO; 
        // Tracks which screen is active: logo or ADC view
        
        volatile uint32_t g_lastChangeMs = 0;  
        // Timestamp (ms) of the last significant ADC change, used for 4s auto-return
        
        volatile uint32_t g_lastAdcCounts = 0; 
        // Stores the last ADC sample as a baseline for detecting changes
        
        const uint16_t ADC_DELTA = 30;         
        // Threshold: how many counts must change before we switch to ADC view
        
        /* USER CODE END PV */
          
      • Immediately show your splash screen by calling RenderLogo() so the user sees something on power-up. This should be placed right after the ssd1306_init code, in the same section.

        STM32 Snippet
          //  Initial screen
          RenderLogo();
          
    • Main loop

      Once everything is initialized, the program lives inside the while(1) loop. Here, a simple switch on g_screenMode decides what the OLED should show:

      SCREEN_LOGO case
      • The display already holds the splash screen, so we don’t need to redraw it every cycle.

      • We just poll the ADC lightly with CheckAdcForChange(). If the potentiometer moves far enough (beyond ADC_DELTA), this function flips the mode to SCREEN_ADC and records the time of change.

      • Otherwise, the loop idles with a short HAL_Delay(50) to keep things responsive without hogging the CPU

      SCREEN_ADC case
      • The OLED is actively updated with the current ADC reading every ~100 ms using RenderADC().

      • CheckAdcForChange() continues running in this mode as well, so the “last activity” timer refreshes as long as the knob keeps moving.

      • If no significant ADC change occurs for about 4 seconds (checked with HAL_GetTick() vs. g_lastChangeMs), the code switches back to SCREEN_LOGO and immediately redraws the splash screen.

      Default case
      • A safety net: if g_screenMode somehow takes on an invalid value, reset it back to SCREEN_LOGO and redraw the splash.

      STM32 Snippet
      
      
      /* USER CODE BEGIN 3 */
          switch (g_screenMode)
          {
            case SCREEN_LOGO:
              // Light polling to detect a “significant” change and flip to ADC
              CheckAdcForChange();
              HAL_Delay(50);
              break;
      
            case SCREEN_ADC:
              // Keep it feeling live; also keep polling to refresh the 4s window
              RenderADC();
              CheckAdcForChange(); // if the knob keeps moving, window refreshes
              HAL_Delay(100);
      
              // Auto-return to logo after ~4 s of no significant changes
              if (HAL_GetTick() - g_lastChangeMs >= 4000U) {
                g_screenMode = SCREEN_LOGO;
                RenderLogo();
              }
              break;
      
            default:
              g_screenMode = SCREEN_LOGO;
              RenderLogo();
              break;
          }
        
       /* USER CODE END 3 */
        
  • Step 5 — Function Definitions

    Place these function defintions in the /* USER CODE BEGIN 4 */ section, near the bottom.

    • RenderLogo() – clear, draw logoBitmap from logo.h, ssd1306_update().

    • RenderADC() – read ADC once, compute volts with AdcCountsToVolts(), print lines, ssd1306_update().

    • AdcCountsToVolts() (vref * counts) / fullscale (4095 for 12‑bit).

    • CheckAdcForChange()– poll ADC, if abs(delta) >= ADC_DELTA, set g_screenMode = SCREEN_ADC and refresh g_lastChangeMs.

    • ProbeOledAddress() – try 0x3C/0x3D; return found address or 0 on fail.

    • ErrorBlink()– flash PA5 (LD2) on failures.

      STM32 Snippet
      /* USER CODE BEGIN 4 */
      
      // ---- Render the splash/logo bitmap ----
      void RenderLogo(void)
      {
        ssd1306_clear();
        // Draw at top-left; adjust x,y to center if desired
        ssd1306_drawBitmap(0, 0, logoBitmap, logoWidth, logoHeight);
        ssd1306_update();
      }
      
      // ---- Render live ADC readout ----
      void RenderADC(void)
      {
        // 1) One blocking sample (simple + sufficient for UI rates)
        HAL_ADC_Start(&hadc1);
        if (HAL_ADC_PollForConversion(&hadc1, 5) != HAL_OK) {
          HAL_ADC_Stop(&hadc1);
          ErrorBlink();
          return;
        }
        uint32_t counts = HAL_ADC_GetValue(&hadc1);
        HAL_ADC_Stop(&hadc1);
      
        // 2) Convert counts → volts (12-bit full-scale by default)
        const float VREF      = 3.300f;  // adjust if you use a different analog reference
        const uint16_t FS_12B = 4095U;
        float volts = AdcCountsToVolts(counts, VREF, FS_12B);
      
        // 3) Draw to OLED
        char line1[32];
        char line2[32];
        snprintf(line1, sizeof(line1), "ADC: %4lu", (unsigned long)counts);
        snprintf(line2, sizeof(line2), "V  : %1.3f", (double)volts);
      
        ssd1306_clear();
        ssd1306_writeString(8,  12, "Potentiometer", Font_7x10, 1);
        ssd1306_writeString(8,  32, line1,          Font_7x10, 1);
        ssd1306_writeString(8,  46, line2,          Font_7x10, 1);
        ssd1306_update();
      }
      
      // ---- Blink LD2 on PA5 as a simple error indicator ----
      void ErrorBlink(void)
      {
        for (int i = 0; i < 6; ++i) {
          HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
          HAL_Delay(100);
        }
      }
      
      // ---- Probe for OLED I2C address (returns 7-bit: 0x3C or 0x3D; 0 if not found) ----
      static uint8_t ProbeOledAddress(void)
      {
        const uint8_t candidates[] = { 0x3C, 0x3D };     // common SSD1306 7-bit addresses
        for (size_t i = 0; i < sizeof(candidates); ++i) {
          // HAL expects the 8-bit address (7-bit << 1)
          uint16_t addr8 = (uint16_t)(candidates[i] << 1);
          if (HAL_I2C_IsDeviceReady(&hi2c1, addr8, 1, 10) == HAL_OK) {
            return candidates[i]; // return 7-bit form to store in g_oledAddr
          }
        }
        return 0; // not found
      }
      
      // ---- Convert raw ADC counts to volts ----
      static float AdcCountsToVolts(uint32_t counts, float vref, uint16_t fullscale)
      {
        if (counts > fullscale) counts = fullscale;
        return (vref * (float)counts) / (float)fullscale;
      }
      
      // ---- Poll ADC; flip to ADC screen on “significant” change ----
      static void CheckAdcForChange(void)
      {
        HAL_ADC_Start(&hadc1);
        if (HAL_ADC_PollForConversion(&hadc1, 5) != HAL_OK) {
          HAL_ADC_Stop(&hadc1);
          return;
        }
        uint32_t counts = HAL_ADC_GetValue(&hadc1);
        HAL_ADC_Stop(&hadc1);
      
        // Compare to baseline
        uint32_t prev = g_lastAdcCounts;
        uint32_t diff = (counts > prev) ? (counts - prev) : (prev - counts);
      
        if (diff >= ADC_DELTA) {
          g_lastAdcCounts = counts;         // update baseline
          g_screenMode    = SCREEN_ADC;     // request ADC view
          g_lastChangeMs  = HAL_GetTick();  // refresh 4s “activity” timer
        }
      }
      
      /* USER CODE END 4 */
      
        

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!