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.
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.
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:
| Region | Origin | Length | Purpose |
|---|---|---|---|
| BL_VECTORS | 0x00000000 | 0x00000020 | Bootloader reset/exception vectors |
| BL_FLASH | 0x00000020 | 0x0001FFE0 | Bootloader code (~128 KB) |
| APP_B_FLAG | 0x00020000 | 0x00004000 | Persistent app selection flags (one 16 KB sector) |
| App A | 0x00040000 | 0x00060000 | Golden image (384 KB slot) |
| APP_VECTORS | 0x000A0000 | 0x00000020 | App B vectors |
| APP_METADATA | 0x000A0020 | 0x00000020 | Magic number, board ID, occupied slot, CRC address |
| APP_KERNEL | 0x000A0040 | 0x00008000 | FreeRTOS kernel |
| APP_FLASH | 0x000A8040 | 0x00057FC0 | App 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.
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.
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:
uplink_bin in the Python ground station tooling, which splits an app binary into
packed command frames and sends them to the satellite.
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.
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.
memory.ld to define the bootloader, App A, App B, and metadata regions.APP_B) so the same code base can be built for either slot,
since each has a different start address.
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
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.
bl_flash.c) into a
shared flash library used by both the bootloader and the app.APP_WRITE and APP_ERASE commands with a new flash error type.
Every request is checked against the address ranges of the bootloader and the running app, and
anything that overlaps is rejected so a bad command can't brick the satellite.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
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:
blEditAppFlag routine to keep the bootloader small.
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
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.
Fuller write-up coming soon.
Selected commits, oldest first:
bl_main.c into bl_utils and
bl_verification; merged Sept. 17 as
#735.