Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 67

UART Bootloader Development

Recommended resources

Introduction

Welcome back to our module on low-level system control. In the previous lesson, you gained a crucial skill: directly manipulating the internal flash memory using HAL functions. You learned the essential "unlock, erase, program, lock" sequence and verified your work with the debugger.

This lesson is where we assemble all the pieces. You will combine your knowledge of memory maps, linker scripts, UART communication, and flash programming to implement a basic bootloader. Your bootloader will be a permanent resident in the MCU's memory, capable of receiving a new application binary via UART and flashing it to the device. This is a cornerstone of professional embedded systems, enabling the "field-updatable" products common in the automotive industry and beyond.

By the end of this lesson, you will be able to:

  • Structure a project with a separate bootloader and application.
  • Configure the linker scripts and vector tables for both projects to coexist in flash memory.
  • Implement the C code to jump from the bootloader to the application.
  • Write a routine to receive a binary file over UART and program it into the application's designated memory space.
  • Perform a complete firmware update using a standard serial terminal.

1. The Bootloader and Application Architecture

A bootloader system fundamentally consists of two distinct programs:

  1. The Bootloader: A small, robust program that resides at the very beginning of the flash memory (0x08000000). Its only jobs are to check if an update is needed and, if not, to start the main application. It should be simple and rarely, if ever, updated.
  2. The Application: This is your main program (e.g., blinking an LED, reading sensors). It resides at an offset in flash, right after the memory region reserved for the bootloader. This is the part that you will update.

This separation requires careful memory management, which you've already practiced. We will partition the 64KB flash of our STM32F103C8T6 into a bootloader region and an application region.

STM32F103C8T6 Flash Memory Partitioning for Bootloader
A typical memory map for a bootloader system. The flash is divided into a section for the bootloader and a larger section for the application. A small reserved area is often kept for metadata like version or size, which we will address in a later lesson.

The most critical part of this architecture is the transfer of control from the bootloader to the application. This process is called the jump.

Let's watch a video that explains this two-project architecture and demonstrates how to implement the jump function.

STM32 Custom Bootloader (Part 1): Flash Layout and Application Jump

The video 'STM32 Custom Bootloader (Part 1)' by ControllersTech provides an excellent step-by-step guide on setting up the bootloader and application projects. It clearly explains how to modify the linker scripts and write the code that performs the jump.

Please watch the following segments: Memory Layout (07:13 - 08:35): Understand how flash is partitioned for our STM32F103. The Jump Function (12:21 - 14:15): Pay close attention to the steps involved: reading the application's stack pointer and reset handler, de-initializing peripherals, setting the new stack pointer, and finally calling the application's entry point. Bootloader Linker Script (14:15 - 15:37): See how the bootloader's flash size is constrained. Application Setup (16:23 - 19:13): This is crucial. Observe how the application's linker script is modified to set its starting address (ORIGIN) and how the Vector Table Offset Register (SCB->VTOR) is updated. This ensures the application's interrupts work correctly.

Key Concepts from the Video

  • The Jump Mechanism: To jump to the application, the bootloader must:

    1. Read the application's initial Stack Pointer (SP) value. This is always the first 4 bytes of the application's binary.
    2. Read the application's Reset Handler address. This is the application's entry point, located at the second 4-byte word of its binary.
    3. De-initialize peripherals used by the bootloader (e.g., UART, SysTick) to give the application a clean slate. This is a critical step to prevent unexpected behavior.
    4. Set the main stack pointer (__set_MSP) to the application's SP.
    5. Create a function pointer to the application's Reset Handler and call it.
  • Vector Table Relocation: Because the application does not start at the default address 0x08000000, its interrupt vector table is also at an offset. You must tell the ARM core where to find this new table by writing the offset to the SCB->VTOR register. Failure to do so will cause any interrupt in your application (including the SysTick for HAL_Delay) to fail, likely crashing the system.


2. Receiving the Binary over UART

The jump mechanism allows the bootloader to start an existing application. But to be a true bootloader, it must also be able to receive and install a new one. We will use the UART peripheral for this.

A reliable file transfer requires a protocol to handle framing, error checking, and flow control. A classic, simple protocol for this is XMODEM. It's supported by most serial terminal programs like Tera Term and PuTTY.

Let's look at a well-documented open-source project that implements exactly this.

stm32-bootloader/README.md at master - GitHub

This GitHub project by Ferenc Nemeth provides a complete UART bootloader using the XMODEM protocol. The README file gives a high-level overview of the workflow and configuration steps.

Read the sections 'How it works' (Overall and Memory map) and 'How to use it'. Focus on: The workflow: The bootloader checks a user button to decide whether to jump or wait for a file. The memory map, which confirms our two-part layout. The 'How to use it' section, which lists the exact steps for configuring the user application's linker script and vector table, and shows how to send a file with a terminal.

This project demonstrates the complete logic:

  1. On startup, check a condition (like a GPIO pin connected to a button).
  2. If the condition is met (e.g., button pressed), enter "update mode". The bootloader waits for the host PC to send a new binary file using the XMODEM protocol over UART.
  3. If the condition is not met, the bootloader proceeds to jump to the user application.

3. Practical Implementation: Your Turn

Now, let's build the system. This is an integration project, so follow the steps carefully. You will create two separate projects in STM32CubeIDE.

Task 1: Create the Application Project ("Blinky")

This will be a simple LED-blinking application that will be loaded by our bootloader.

  1. Create a new STM32CubeIDE project for your Blue Pill board. Name it My_Application.
  2. Configure PC13 as a GPIO output for the onboard LED.
  3. In main.c, write a simple loop to blink the LED every 500ms.
  4. Crucially, configure the application's memory location:
    • Open the linker script STM32F103C8TX_FLASH.ld.
    • We will reserve 16KB for the bootloader. So, the application must start after that. Change the FLASH memory definition:
      • Set ORIGIN to 0x8004000 (which is 0x8000000 + 16KB).
      • Set LENGTH to 48K (which is 64KB - 16KB).
      /* Initial linker script */
      /* MEMORY { FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 64K ... } */
      
      /* MODIFIED linker script for the application */
      MEMORY
      {
        FLASH (rx)      : ORIGIN = 0x8004000, LENGTH = 48K
        RAM (xrw)       : ORIGIN = 0x20000000, LENGTH = 20K
      }
      
    • Open Core/Src/system_stm32f1xx.c. Find the VECT_TAB_OFFSET definition (around line 118). You may need to uncomment the #define USER_VECT_TAB_ADDRESS. Set the offset to match your ORIGIN:
      /* #define VECT_TAB_OFFSET  0x0 */ /* Comment out or delete this line */
      
      
      
      #define VECT_TAB_OFFSET  0x4000U /* Add this line. 0x4000 = 16KB */
      
  5. Generate the binary file:
    • Go to Project Properties > C/C++ Build > Settings > Tool Settings > MCU Post build outputs.
    • Check Convert to binary file (-O binary).
    • Build the project. You will now find a My_Application.bin file in your project's Debug folder. This is the file you will send to the bootloader.

Task 2: Create the Bootloader Project

  1. Create another new STM32CubeIDE project. Name it My_Bootloader.

  2. Configure USART1 in Asynchronous mode (you'll need a USB-to-serial adapter connected to PA9/TX and PA10/RX). Keep the default settings (115200 baud, 8-N-1).

  3. Configure a GPIO pin as an input for a user button (e.g., PA0). Set its mode to Pull-down.

  4. Configure the bootloader's memory location:

    • Open STM32F103C8TX_FLASH.ld.
    • Change the LENGTH of the FLASH memory to 16K. The ORIGIN remains 0x8000000.
      /* MODIFIED linker script for the bootloader */
      MEMORY
      {
        FLASH (rx)      : ORIGIN = 0x8000000, LENGTH = 16K
        RAM (xrw)       : ORIGIN = 0x20000000, LENGTH = 20K
      }
      
  5. Implement the Bootloader Logic:
    Add the following code to your main.c file. This includes the jump function, a simple UART receiver, and the main logic. The previous lesson's flash functions are assumed to be available.

    /* USER CODE BEGIN PFP */
    // Helper function to get flash page from an address
    static uint32_t GetPage(uint32_t Address)
    {
      return (Address - FLASH_BASE) / FLASH_PAGE_SIZE;
    }
    
    // Your flash write function from the previous lesson
    uint32_t Flash_Write_Data (uint32_t StartAddress, uint32_t *Data, uint16_t numberofwords)
    {
        // ... (copy your implementation from the last lesson)
        // Note: For a bootloader, it's better to erase pages as you need them,
        // rather than erasing the whole application space at once.
        // For this exercise, we'll erase a fixed number of pages.
        static FLASH_EraseInitTypeDef EraseInitStruct;
        uint32_t PAGEError;
    
        HAL_FLASH_Unlock();
    
        EraseInitStruct.TypeErase   = FLASH_TYPEERASE_PAGES;
        EraseInitStruct.PageAddress = StartAddress;
        EraseInitStruct.NbPages     = 32; // Erase 32KB of flash for the app
    
        if (HAL_FLASHEx_Erase(&EraseInitStruct, &PAGEError) != HAL_OK)
        {
            HAL_FLASH_Lock();
            return HAL_FLASH_GetError();
        }
    
        uint32_t current_address = StartAddress;
        for(int i=0; i<numberofwords; i++)
        {
            if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, current_address, Data[i]) != HAL_OK)
            {
                HAL_FLASH_Lock();
                return HAL_FLASH_GetError();
            }
            current_address += 4;
        }
    
        HAL_FLASH_Lock();
        return 0;
    }
    
    // Define application address
    
    
    
    #define APPLICATION_ADDRESS 0x08004000
    
    void jump_to_application(void)
    {
        // Define a function pointer to the application's Reset_Handler
        void (*app_reset_handler)(void);
    
        // Check if a valid application exists
        // The first word at the application's address should be the initial stack pointer.
        // It should be within the boundaries of RAM.
        uint32_t initial_sp = *(volatile uint32_t*)APPLICATION_ADDRESS;
        if (initial_sp < 0x20000000 || initial_sp > 0x20005000) {
            // No valid application found
            printf("No valid application found to jump to!\r\n");
            return;
        }
    
        // The second word is the address of the Reset_Handler
        uint32_t app_entry_point = *(volatile uint32_t*)(APPLICATION_ADDRESS + 4);
        app_reset_handler = (void (*)(void))app_entry_point;
    
        // De-initialize HAL and peripherals
        HAL_UART_DeInit(&huart1);
        HAL_GPIO_DeInit(GPIOA, GPIO_PIN_0); // De-init button pin
        HAL_DeInit();
    
        // Disable all interrupts
        __disable_irq();
    
        // Set the application's vector table
        SCB->VTOR = APPLICATION_ADDRESS;
    
        // Set the main stack pointer to the application's initial stack pointer
        __set_MSP(initial_sp);
    
        // Jump to the application
        app_reset_handler();
    }
    
    void receive_and_flash_application(void)
    {
        uint8_t received_data[256];
        uint32_t app_data_buffer[64]; // Buffer for flash writing (256 bytes)
        uint32_t flash_ptr = APPLICATION_ADDRESS;
        HAL_StatusTypeDef status;
    
        printf("Ready to receive application binary...\r\n");
        printf("Please send the file.\r\n");
    
        // Simple reception loop
        while(1)
        {
            // Receive 256 bytes at a time
            status = HAL_UART_Receive(&huart1, received_data, 256, 20000); // 20-second timeout
    
            if (status == HAL_OK) // Received a full 256-byte chunk
            {
                // Copy to a 32-bit aligned buffer for flashing
                memcpy(app_data_buffer, received_data, 256);
    
                // Write the chunk to flash
                HAL_FLASH_Unlock();
                for (int i=0; i<64; i++) // 64 words = 256 bytes
                {
                    if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, flash_ptr, app_data_buffer[i]) != HAL_OK)
                    {
                        printf("Flash program error!\r\n");
                        HAL_FLASH_Lock();
                        return;
                    }
                    flash_ptr += 4;
                }
                HAL_FLASH_Lock();
                printf("."); // Print a dot to show progress
            }
            else if (status == HAL_TIMEOUT) // No more data
            {
                // Assume the transfer is finished. Handle any remaining bytes if needed.
                // For simplicity, we assume file size is a multiple of 256.
                printf("\r\nFirmware update complete. Resetting...\r\n");
                HAL_Delay(100);
                NVIC_SystemReset();
                break;
            }
            else // An error occurred
            {
                printf("\r\nUART Receive Error!\r\n");
                break;
            }
        }
    }
    /* USER CODE END PFP */
    
    // In main() function
    int main(void)
    {
      // ... HAL_Init(), SystemClock_Config(), MX_GPIO_Init(), MX_USART1_UART_Init() ...
    
      // Redirect printf to UART
      printf("Bootloader Started\r\n");
    
      // Check if the user button (PA0) is pressed
      if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET)
      {
          // Button is pressed, enter update mode
          receive_and_flash_application();
      }
      else
      {
          // Button not pressed, try to jump to application
          printf("Attempting to jump to application...\r\n");
          jump_to_application();
      }
    
      // If jump fails, we stay in the bootloader
      while (1)
      {
          // You could blink an error LED here
          HAL_Delay(1000);
      }
    }
    

    (Note: You'll need to implement printf redirection to UART for the messages to appear, as covered in Module 4.)


4. Flashing and Testing the System

This is the final and most rewarding step.

  1. Flash the Bootloader: Connect your ST-Link, build the My_Bootloader project, and flash it to your Blue Pill.
  2. Prepare for Update:
    • Connect your USB-to-serial adapter. Open Tera Term (or a similar terminal) with settings 115200, 8, N, 1.
    • Hold down the user button (connected to PA0) on your breadboard.
    • Press the reset button on the Blue Pill.
    • You should see the "Bootloader Started" and "Ready to receive..." messages in your terminal.
  3. Send the Application:
    • In Tera Term, go to File > Transfer > Send file....
    • Browse to your My_Application.bin file. Make sure the "Binary" option is checked.
    • Click Open. The file transfer will begin. You should see progress dots . appearing in your terminal.
    • The transfer may take a minute or two.
  4. Verification:
    • Once the transfer is complete, the bootloader will print the "Firmware update complete" message and reset.
    • The board will restart, the bootloader will run again, but this time, since you are not holding the button, it will immediately jump to your application.
    • The onboard LED (PC13) should start blinking. Success!
STM32 Bootloader and Application Execution Demonstration
A conceptual demonstration of the bootloader process. The terminal shows the transition from the bootloader receiving code to the application running, which is what you have just achieved.

Conclusion

Congratulations on building a complete bootloader! This is a significant milestone that brings together many different areas of embedded systems development. You have created a field-updatable device, a hallmark of professional product design.

Key Takeaways:

  • A bootloader system requires two separate projects with carefully configured linker scripts and memory maps.
  • The application must relocate its vector table (SCB->VTOR) to ensure its interrupts function correctly.
  • The jump mechanism involves setting the stack pointer and branching to the application's reset handler after de-initializing bootloader peripherals.
  • A simple UART reception loop can be used to receive a new binary, but professional bootloaders use robust protocols like XMODEM/YMODEM or custom protocols with CRC checks for data integrity.

Preview of the Next Lesson:
Our current bootloader is functional, but it blindly trusts the data it receives. A single corrupted byte could "brick" the device. In the next lesson, you will use the hardware CRC peripheral to verify firmware integrity before jumping to the application, making your bootloader much more robust and production-ready.