9/24/2025, 5:24:31 PM
Waveboot Programming Setup

September 20, 2025

Waveboot—Over-The-Air Firmware Update Bootloader

Check out the project here: github.com/NabeelAhmed1721/waveboot

The ATmega328P is a popular 8-bit microcontroller often used as a starting point for many projects. While it isn't the most powerful microcontroller available, it's an excellent platform for embedded systems thanks to its low cost, versatility, and extensive documentation.

Earlier this summer, I began working on a mesh network project that used several of these microcontrollers to orchestrate decentralized communication over radio. The project has been progressing well; however, a prominent issue I ran into was that every time I needed to update the firmware, I had to physically connect a USB cable to each microcontroller and flash the update for every node. As you can imagine, this approach could never scale properly. I needed to find a more efficient solution.

Waveboot was created to accelerate development

Waveboot was created to accelerate development

The ATmega328P doesn't have any built-in wireless communication capabilities, and there are no modern, easy-to-use solutions for enabling over-the-air updates on it. Since I was already working with radios for my mesh network project, I decided to repurpose them for firmware updates as well. This led me to write my own bootloader to enable wireless communication and OTA updates.

Overview

Thus, I'd like to introduce you to Waveboot, an ATmega328P bootloader that enables OTA firmware updates via inexpensive 433 MHz ASK (Amplitude-Shift Keying) radios.

A similar project, Dave Berkely's OTA Bootloader achieves a comparable goal. However, that project operates on 868 MHz and stores firmware in an EEPROM before transferring it to the microcontroller's flash. Waveboot takes a different approach: it communicates directly with a programmer, recieves the firmware, and writes it immediately to flash memory. There is also additional functionality to copy the exisiting firmware to external storage as backup in case an update fails. The goal of Waveboot is to eliminate unnecessary requirements and make OTA updates as simple and inexpensive as possible.

How It Works

Waveboot is a bootloader that uses a radio transmitter and receiver to communicate with a programmer. The programmer is responsible for sending firmware to the bootloader and orchestrating the update process.

A core tenet of wireless programming is to minimize manual intervention unless absolutely necessary. The bootloader is designed to prioritize device safety by being predictable and resilient. It will never boot into firmware that is corrupted or incomplete. This is to prevent unexpected behavior that could cause malfunctions or even soft-brick a remote device, preventing a programmer from being able to reset it remotely. Instead, in the event of failure, the bootloader attempts to reset the device so the programmer can send the correct firmware.

Flowchart of Operation

Flowchart of Operation

Waveboot ensures that manual intervention is only ever required in the case of hardware failure

Flash Memory Layout

The ATmega328P can organize its 32KB flash memory into two distinct sections: the application section (0x0000 to 0x6FFF) and the bootloader section (0x7000 to 0x7FFF using the 4KB configuration). This separation is important as it prevents the application from modifying the flash, and the bootloader from accidentally overwriting itself during firmware updates.

ATmega328P Flash Memory Layout (Source: Microchip.com)

ATmega328P Flash Memory Layout (Source: Microchip.com)

Part of AVR bootloader design is ensuring interrupt vectors are being switched between application and bootloader contexts as needed. After a reset, the microcontroller looks for interrupt vectors at the start of flash in the application section. An application that needs interrupts, for example to handle UART for serial communication, cannot safely run if vectors still point to the bootloader. If context is not switched,the interrupt vector table will not know where to jump to, causing the application to stall. This can be prevented by using the MCUCR register to set/clear the IVSEL bit:

cpp
static inline void map_vectors_to_bootloader(void) { // move vectors to bootloader section MCUCR = (1 << IVCE); MCUCR = (1 << IVSEL); } static inline void map_vectors_to_application(void) { // move vectors back to application section MCUCR = (1 << IVCE); MCUCR = 0x00; }

Intel HEX Format

Waveboot uses the Intel HEX format for firmware transmission. It is a standardized way to represent binary data in hexidecimal format. Each record contains information including the data length, memory address, record type, actual data, and a checksum for error detection.

The bootloader parses incoming Intel HEX records and validates their integrity:

cpp
// intel hex record format // <data_len><address high><address low><record_type><data><checksum> uint8_t data_len = buffer[0]; uint16_t address = (buffer[1] << 8) | buffer[2]; uint8_t record_type = buffer[3]; uint8_t* data = &buffer[4]; uint8_t checksum = buffer[4 + data_len]; // verify checksum uint8_t calc = data_len + buffer[1] + buffer[2] + record_type; for (int i = 0; i < data_len; i++) calc += data[i]; calc = (~calc + 1); if (calc == checksum) { // process valid record }

Writing to Flash

The ATmega328P flash memory is organized into 128-byte pages, and the Self-Programming Mode (SPM) requires entire pages to be erased and rewritten atomically. Waveboot buffers incoming data until a complete page is ready, then performs the write operation:

cpp
static bool write_page(uint32_t page_address, const uint8_t* data, uint16_t len) { // disable interrupts when doing SPM operations cli(); // erase page boot_page_erase(page_address); boot_spm_busy_wait(); // words are filled in word chunks (16 bits) // atmega328p is little-endian for (uint16_t i = 0; i < len; i += 2) { uint16_t word; if (i + 1 < len) { word = data[i] | (data[i + 1] << 8); } else { // last odd byte, high byte = 0xFF word = data[i] | (0xFF << 8); } boot_page_fill(page_address + i, word); } boot_page_write(page_address); boot_spm_busy_wait(); // re-enable flash execution boot_rww_enable(); sei(); return true; }

The program_flash function handles the page buffering:

cpp
bool program_flash(Radio &driver) { uint8_t page_buffer[SPM_PAGESIZE]; // ... uint16_t offset = address - current_page_addr; for (int i = 0; i < data_len && (offset + i) < SPM_PAGESIZE; i++) { // fill page buffer page_buffer[offset + i] = data[i]; page_dirty = true; } // ... write when page is complete or programming finishes }

Firmware Integrity Check

One of Waveboot's important safety features is its firmware integrity check. At the start of any programming operation, the bootloader writes a magic signature 0xDEADBEEF to the last four bytes at the end of the bootloader's flash space. This flag serves as a semaphore to indicate that a write operation is in progress. If the flag is present during boot, the bootloader knows the flash is in an indeterminate state, since a successful write would have cleared the flag. This provides a robust mechanism for the microcontroller to identify and refuse booting into an application without guarantees that it is valid.

cpp
// last four bytes of flash #define RECOVERY_BYTES_ADDR (FLASHEND - 3) #define RECOVERY_BYTES 0xDEADBEEF static void set_recovery_state(bool is_programming) { uint32_t recovery_page_addr = RECOVERY_BYTES_ADDR & ~(SPM_PAGESIZE - 1); uint16_t offset = RECOVERY_BYTES_ADDR - recovery_page_addr; uint8_t page_buffer[SPM_PAGESIZE]; // read current page into buffer to preserve data for (uint16_t i = 0; i < SPM_PAGESIZE; i++) { page_buffer[i] = pgm_read_byte_near((uint16_t)(recovery_page_addr + i)); } if (is_programming) { // Set recovery bytes flag page_buffer[offset + 0] = RECOVERY_BYTES & 0xFF; page_buffer[offset + 1] = (RECOVERY_BYTES >> 8) & 0xFF; page_buffer[offset + 2] = (RECOVERY_BYTES >> 16) & 0xFF; page_buffer[offset + 3] = (RECOVERY_BYTES >> 24) & 0xFF; } else { // cear recovery bytes flag page_buffer[offset + 0] = 0xFF; page_buffer[offset + 1] = 0xFF; page_buffer[offset + 2] = 0xFF; page_buffer[offset + 3] = 0xFF; } write_page(recovery_page_addr, page_buffer, SPM_PAGESIZE); }

When the bootloader starts, it checks these recovery bytes to determine the system state:

cpp
bool check_recovery_bytes(void) { uint32_t recovery_bytes = 0; // read 4 bytes in little-endian format recovery_bytes |= (uint32_t)pgm_read_byte_near((uint16_t)(RECOVERY_BYTES_ADDR + 0)); recovery_bytes |= (uint32_t)pgm_read_byte_near((uint16_t)(RECOVERY_BYTES_ADDR + 1)) << 8; recovery_bytes |= (uint32_t)pgm_read_byte_near((uint16_t)(RECOVERY_BYTES_ADDR + 2)) << 16; recovery_bytes |= (uint32_t)pgm_read_byte_near((uint16_t)(RECOVERY_BYTES_ADDR + 3)) << 24; return recovery_bytes == RECOVERY_BYTES; }

If recovery bytes are detected, the bootloader continuously listens and waits for a new firmware update rather than attempting to boot potentially corrupted code.

Communication Protocol

ASK communication is inexpensive but highly susceptible to interference. To mitigate this, the driver samples each bit eight times per bit period (every 62.5 µs at 2000bps). A majority vote (5 out of 8 samples) determines whether the bit is read as a 1 or 0. Working in conjunction, the programmer uses cyclic redundancy checks (CRC) to validate each data packet to ensure message integrity. After receiving a packet, the bootloader verifies the CRC and sends an acknowledgment back to the programmer. If no acknowledgment is received, the programmer resends the packet. Messages are also sequenced to ensure that firmware lines are not written multiple times, even after retransmissions.

The bootloader listens for a specific "BOOT" signal to initiate programming mode:

cpp
static bool listen_for_boot_signal(Radio &driver, uint32_t timeout) { uint32_t start_listen_time = millis(); while ((millis()) < start_listen_time + timeout) { uint8_t buf[4]; uint8_t buf_len = sizeof(buf); if (driver.recv(buf, &buf_len)) { if (buf_len >= 4 && buf[0] == 'B' && buf[1] == 'O' && buf[2] == 'O' && buf[3] == 'T') { return true; } } } return false; }

Once programming begins, the bootloader provides acknowledgment through different codes:

  • RDY - Response to "BOOT" signal
  • PRG - Successful programming
  • CHK - Checksum error
  • DNE - Firmware is finished installing

Closing Thoughts

Waveboot was created to accelerate my development by eliminating the need for manual firmware flashing across distributed devices. Having never built a bootloader before, this project has allowed me to lean into technical excellence as it required thoughtful planning and implementation. I've learned valuable lessons about fault tolerance and software design—especially when you can't physically access a remote device.

I hope this project helps accelerate your projects like it did for mine. Thanks to everyone who has found this project useful!