Daniel Han


← Back to Portfolio

OTA Firmware Updates for UW Orbital

C, Linker scripts, CMake, RTOS, TI RM46 (ARM Cortex-R4F) | Aug. 2026 – Present

Over-the-air firmware update infrastructure for UW Orbital, a 3U cube satellite design team.

What I built

Code excerpts below are taken from my commits to the team's obc-firmware repository, lightly trimmed for length. Each one links to the exact commit it came from.

The flash layout

This is the RM46's flash as the linker actually laid it out for an App B build. It comes straight from the Memory Configuration section of the generated OBC-firmware.map, built with -DAPP_SLOT=B -DAPP_SIZE_INPUT=384_KB:

RegionOriginLengthPurpose
BL_VECTORS0x000000000x00000020Bootloader reset/exception vectors
BL_FLASH0x000000200x0001FFE0Bootloader code (~128 KB)
APP_B_FLAG0x000200000x00004000Persistent app selection flags (one 16 KB sector)
App A0x000400000x00060000Golden image (384 KB slot)
APP_VECTORS0x000A00000x00000020App B vectors
APP_METADATA0x000A00200x00000020Magic number, board ID, occupied slot, CRC address
APP_KERNEL0x000A00400x00008000FreeRTOS kernel
APP_FLASH0x000A80400x00057FC0App B code and data

In that build the linker placed __crc_addr at 0x000F2930, so the App B image is 338,224 bytes, or 1,627 packets of 208 bytes over the uplink.

Blank Check

Before writing a new image, the bootloader has to know the target region is actually erased. I got the blank check working on the F021 Flash API and verified it byte by byte against memory. It runs alongside the CRC check, which validates an image against a CRC appended to the binary at build time, so a corrupted or partially written app is never jumped to.

The tricky part is ECC. Reading erased flash normally trips the RM46's ECC logic, so the check turns flash ECC off for the duration of the F021 call and restores it afterwards:

bool flashFapiBlankCheck(uint32_t startAddr, uint32_t size32) {
  Fapi_FlashStatusWordType wordType = {.au32StatusWord = {0}};
  _coreDisableFlashEcc_();
  flashWREG->FEDACCTRL1 = 0x00000005U;

  Fapi_StatusType status = Fapi_doBlankCheckByByte((uint8_t *)startAddr, size32, &wordType);
  flashWREG->FEDACCTRL1 = 0x000A060AU;
  _coreEnableFlashEcc_();

  if (status == Fapi_Status_Success) {
    return true;
  } else {
    return false;
  }
}
obc/shared/flash/source/flash.c · view on GitHub

The bootloader then walks the whole image in fixed-size chunks before it trusts the metadata, with a fixed upper bound on the loop and a final pass for any leftover bytes:

obc_error_code_t blAppBlankCheck(metadata_t *app_metadata_pointer, uint32_t appStartAddress) {
  uint16_t writeSections = (app_metadata_pointer->crc_addr - appStartAddress) / MEMORY_BLANK_CHECK_SIZE;

  for (uint16_t i = 0; i < writeSections; i++) {
    if (flashFapiBlankCheck(appStartAddress + i * MEMORY_BLANK_CHECK_SIZE, MEMORY_BLANK_CHECK_SIZE / 4)) {
      blUartWriteBytes(strlen("ERROR: Blank check failed \r\n"), (uint8_t *)"ERROR: Blank check failed \r\n");
      return OBC_ERR_CODE_CORRUPTED_APP;
    }
  }

  // Any left over memory that needs to be checked
  if (flashFapiBlankCheck(
          appStartAddress + writeSections * MEMORY_BLANK_CHECK_SIZE,
          (app_metadata_pointer->crc_addr - appStartAddress - writeSections * MEMORY_BLANK_CHECK_SIZE))) {
    blUartWriteBytes(strlen("ERROR: Blank check failed \r\n"), (uint8_t *)"ERROR: Blank check failed \r\n");
    return OBC_ERR_CODE_CORRUPTED_APP;
  }

  return OBC_ERR_CODE_SUCCESS;
}

obc_error_code_t verifyMetadata(metadata_t *app_metadata_pointer, uint32_t appStartAddress) {
  obc_error_code_t errCode;
  RETURN_IF_ERROR_CODE(verifyMagicNum(app_metadata_pointer->magic_num));
  RETURN_IF_ERROR_CODE(verifyBoardType(app_metadata_pointer->board_id));
  RETURN_IF_ERROR_CODE(verifyCrc(app_metadata_pointer->crc_addr, appStartAddress));
  return OBC_ERR_CODE_SUCCESS;
}
obc/bl/source/bl_verification.c · view on GitHub

Getting this working also involved bringing up the OBC_REVISION_1 board target, which turned out to be failing because of misconfigured pins. This work was merged as part of the bootloader rework PR, which split verification and utilities out of bl_main.c and took it from 242 lines down to 82.

AX.25 Communications

Updates are sent from the ground station, so the binary has to make it across the link intact. I learned the team's UART protocol infrastructure and the AX.25 framing used for satellite communications, then built the ground station side of the upload:

The core of uplink_bin: each 208-byte chunk of the binary is prefixed with a packed write command, padded to the Reed–Solomon block size, wrapped in an AX.25 frame, and not followed by the next chunk until the satellite acknowledges it.

for i in range(commands_needed - 1):
    data = pack_command(command_factories[CmdCallbackId.CMD_OBC_WRITE_APP](
        programming_session,
        COMMAND_DATA_SIZE,
        app_starting_address + i * COMMAND_DATA_SIZE,
    ))

    data = (data + app_bin[i * COMMAND_DATA_SIZE : (i + 1) * COMMAND_DATA_SIZE]).ljust(RS_DECODED_DATA_SIZE, b"\x00")

    send_bytes = comms.encode_frame(data).ljust(_PADDING_REQUIRED, b"\x00")

    ser.reset_output_buffer()
    ser.write(send_bytes)

    res = receive_cmd_res(CmdCallbackId.CMD_OBC_WRITE_APP, ser, comms, False, com_port, timeout)
    if res == None or not res.error_code == CmdResponseErrorCode.CMD_RESPONSE_SUCCESS:
        print(res)
        print("Failed to write app data")
        return None
    else:
        progress_bar.update(1)
interfaces/utils/command_utils.py (in progress)

On the C side, the matching pack function serializes the write command's fields into the frame:

// CMD_OBC_WRITE_APP
static void packObcWriteAppCmdData(uint8_t* buffer, uint32_t* offset, const cmd_msg_t* msg) {
  packUint8((uint8_t)msg->downloadData.programmingSession, buffer, offset);
  packUint16(msg->downloadData.length, buffer, offset);
  packUint32(msg->downloadData.address, buffer, offset);
  packUint8(msg->selectedAppSlot, buffer, offset);
}
interfaces/obc_gs_interface/commands/src/obc_gs_command_pack.c (in progress)

The same chunk-and-acknowledge flow, running against the bootloader on a real board: an erase, 1,587 write packets, then a CRC readback to confirm the image landed intact.

Terminal output of app_update.py: Erased App, Packets Written 100% 1587/1587 in 1 minute 9 seconds, then CMD_VERIFY_CRC returning SUCCESS with the application CRC.
Flashing an app over UART with the update tool, from the testing section of PR #735.

Dual Bank Partitioning

The core of a safe OTA system is never overwriting the image you're currently running. I split flash into two app slots: App A, a golden image, and App B, the slot that receives updates.

CMake picks the slot and slot size, then passes the resulting addresses straight to the linker with --defsym:

# Define the app slot option
set(APP_SLOT "B" CACHE STRING "Which partition to build app to")

#Set the start address of the app based on its slot:
if(APP_SLOT STREQUAL "A")
    set(APP_ADDRESS_OFFSET 0x00000000)
    set(APP_SLOT_TO_INT_MAP 0)
elseif(APP_SLOT STREQUAL "B")
    set(APP_ADDRESS_OFFSET ${APP_SIZE})
    set(APP_SLOT_TO_INT_MAP 1)
else()
    message(FATAL_ERROR "APP_SLOT must be either 'A' or 'B'")
endif()

# Pass the app size to the linker
add_link_options(-Wl,--defsym,APP_SIZE=${APP_SIZE})

# Pass the app offset to the linker
add_link_options(-Wl,--defsym,APP_ADDRESS_OFFSET=${APP_ADDRESS_OFFSET})
obc/CMakeLists.txt · view on GitHub

That lets one linker script describe either slot. My first draft hard-coded separate APP_A_* and APP_B_* regions; the final version collapses them into a single parameterized set:

MEMORY
{
  BL_VECTORS(rx)      : ORIGIN = 0x00000000, LENGTH = 0x00000020
  BL_FLASH  (rx)      : ORIGIN = 0x00000020, LENGTH = (0x00020000 - 0x00000020)
  APP_B_FLAG (rw)     : ORIGIN = 0x00020000, LENGTH = 0x00004000
  APP_VECTORS(rx)     : ORIGIN = CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET, LENGTH = 0x00000020
  APP_METADATA (rx)   : ORIGIN = CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x00000020, LENGTH = 0x00000020
  APP_KERNEL (rx)     : ORIGIN = CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x00000040, LENGTH = 0x00008000
  APP_FLASH  (rx)     : ORIGIN = CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x00008040, LENGTH = (APP_SIZE - 0x00008040)
  CPU_STACK (rw)      : ORIGIN = 0x08000000, LENGTH = 0x00000800 /* Stack is configured in sys_core.asm */
  KRAM (xrw)          : ORIGIN = 0x08000800, LENGTH = 0x00000800
  RAM (xrw)           : ORIGIN = (0x08000800 + 0x00000800), LENGTH = (0x0002f800 - 0x00000800)
}
obc/shared/hal/common/ld/memory.ld · view on GitHub

The bootloader's exception vectors used to branch to hard-coded addresses in the single app slot. I renamed bl_intvecs.s to .S so it runs through the C preprocessor, which lets the vectors follow whichever slot the build targets:

 resetEntry:
         b   _c_int00    @ Reset
 undefEntry:
-        b   #0x40004     @ Undefined instruction
-        b   #0x40008     @ Software interrupt (SWI / SVC)
+        b   #(CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x4) @ Undefined instruction
+        b   #(CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x8) @ Software interrupt (SWI / SVC)
 prefetchEntry:
-        b   #0x4000c     @ Abort (prefetch)
+        b   #(CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0xc)  @ Abort (prefetch)
         b   _dabort      @ Abort (data)
-        b   #0x40014     @ phantomInterrupt
+        b   #(CUSTOM_START_ADDRESS + APP_ADDRESS_OFFSET + 0x14) @ phantomInterrupt
obc/bl/hal/bl_intvecs.S · view commit

In-App Updates

Rather than doing everything in the bootloader, the running app downloads the new image into the other slot itself. This keeps the satellite operational during an upload.

The guard rails at the top of the app's write handler. A command that targets the slot the app is currently running from, or that would spill outside the target slot, is refused before the flash is touched:

static obc_error_code_t obcDownloadApp(cmd_msg_t *cmd, uint8_t *responseData, uint8_t *responseDataLen) {
  if (cmd == NULL || (cmd->selectedAppSlot != 0 && cmd->selectedAppSlot != SHORT_ENABLE_APP_MAGIC_NUMBER)) {
    return OBC_ERR_CODE_INVALID_ARG;
  }

  uint32_t appStartAddress = 0;
  uint32_t flashLowLim = 0;
  uint32_t flashHighLim = 0;

  if (cmd->selectedAppSlot == app_metadata.occupied_slot) {
    return OBC_ERR_CODE_RED_EACCES;
  }

  if (cmd->selectedAppSlot == 0) {
    appStartAddress = CUSTOM_START_ADDRESS;
    flashLowLim = CUSTOM_START_ADDRESS;
    flashHighLim = flashLowLim + APP_SIZE - 0x00000001;
  } else {
    appStartAddress = CUSTOM_START_ADDRESS + APP_SIZE;
    flashLowLim = CUSTOM_START_ADDRESS + APP_SIZE;
    flashHighLim = flashLowLim + APP_SIZE - 0x00000001;
  }

  if (cmd->downloadData.address < flashLowLim || cmd->downloadData.address + cmd->downloadData.length > flashHighLim) {
    return OBC_ERR_CODE_RED_EACCES;
  }

  // ... alignment checks, then flashFapiBlockWrite()
}
obc/app/modules/command_mgr/command_callbacks.c · view on GitHub

Bootloader Logic

App selection originally lived in RAM, which is lost when the app resets back into the bootloader. I moved it into a dedicated section of flash so it persists across resets:

The flags live in their own linker section at 0x00020000. Slot B is only selected by an explicit magic number, so an erased or corrupted flag word can't accidentally boot it:

#define APP_FLAG_START_ADDRESS 0x00020000
#define APP_FLAG_SECTION_SIZE 0x00004000

#define ENABLE_APP_MAGIC_NUM 0x4F4F4F4F

typedef struct __attribute__((packed)) {
  uint32_t app_status;
  uint32_t boot_attempts;
  uint32_t app_b_flag;
  uint32_t crc_selector;
} app_flag_t;
obc/bl/include/bl_app_flag.h · view on GitHub

Editing the flags is erase, write, then read back through a volatile pointer and compare, so a write that silently failed is caught:

obc_error_code_t blEditAppFlag(app_flag_t replacement_app_flag) {
  volatile app_flag_t *volatile_app_flag_pointer = (app_flag_t *)APP_FLAG_START_ADDRESS;

  uint32_t length = sizeof(replacement_app_flag);

  flash_error_code_t errCode = flashFapiBlockErase(APP_FLAG_START_ADDRESS, (uint32_t)APP_FLAG_SECTION_SIZE);

  if (errCode != FLASH_ERR_CODE_SUCCESS) {
    return OBC_ERR_CODE_UNKNOWN;
  };

  errCode = flashFapiBlockWrite(APP_FLAG_START_ADDRESS, (uint32_t)&replacement_app_flag, length);

  if (errCode != FLASH_ERR_CODE_SUCCESS) {
    return OBC_ERR_CODE_UNKNOWN;
  };

  app_flag_t readback = *volatile_app_flag_pointer;

  if (memcmp(&readback, &replacement_app_flag, length)) {
    return OBC_ERR_CODE_UNKNOWN;
  }

  return OBC_ERR_CODE_SUCCESS;
}
obc/bl/source/bl_utils.c · view on GitHub

On boot, the bootloader reads the flags, validates the chosen slot, and only then jumps to it:

obc_error_code_t blJumpToApp() {
  obc_error_code_t errCode;

  uint32_t appStartAddress = 0;

  app_flag_t *app_flag_pointer = (app_flag_t *)(APP_FLAG_START_ADDRESS);

  if (app_flag_pointer->app_b_flag == 0) {
    appStartAddress = CUSTOM_START_ADDRESS;
  } else if (app_flag_pointer->app_b_flag == ENABLE_APP_MAGIC_NUM) {
    appStartAddress = CUSTOM_START_ADDRESS + APP_SIZE;
  } else {
    return OBC_ERR_CODE_INVALID_ARG;
  }

  // ...

  // Cast the metadata of the flash into a usable pointer
  metadata_t *app_metadata_pointer = (metadata_t *)(appStartAddress + APP_METADATA_OFFSET);

  RETURN_IF_ERROR_CODE(blAppBlankCheck(app_metadata_pointer, appStartAddress));

  // Check magic number, board id and verify the crc
  RETURN_IF_ERROR_CODE(verifyMetadata(app_metadata_pointer, appStartAddress));

  // ...

  // Go to the application's entry point
  uint32_t appEntryAddress = (uint32_t)app_metadata_pointer->app_entry_func_addr;
  ((appStartFunc_t)appEntryAddress)();
obc/bl/source/bl_utils.c · view on GitHub

Arducam Photo Capture

One of the first frames pulled off the satellite's Arducam during bring-up. The board streams the JPEG over UART as base64, and a small Python script on the host syncs on the JPEG header (/9j/ in base64), buffers until the stream goes quiet, and saves each frame. It's an 800×600 JPEG of the bench, out of focus but intact end to end.

Blurry 800 by 600 test photo from the Arducam, showing a bench with a red object in the foreground.
Test capture from the Arducam, received over UART.

Fuller write-up coming soon.

Development log

Selected commits, oldest first: