System designHow-to

Store camera images on embedded vision board flash in C

Store JPEG frames from a board camera on the vision board's SPI NOR flash in C: size the region, mount LittleFS, roll the files, and check the wear math.

LumaSpec Vision9 min read Share on LinkedIn

On this page 10 sections
  1. 01What you need
  2. 02Size the retention budget first
  3. 03Draw a storage region on the flash map
  4. 04Mount a filesystem that survives power cuts
  5. 05Write and roll the frames
  6. 06Prove retention with a wall-switch test
  7. 07How long the flash lasts at your frame rate
  8. 08When flash is the wrong storage
  9. 09Pre-flight checklist
  10. 10Questions

You captured JPEG frames from a board-level camera on an embedded module, and the frames must survive a power cycle — no SD slot, no host PC in the loop. The fix lives on hardware the board already carries: reserve a region of the on-board SPI NOR flash, mount a filesystem built for power loss on it, and write each frame as one file with a single buffered call. This guide works the sizing arithmetic first, then the flash map, the mount, the write-and-delete loop, and the wall-switch test that proves retention. You finish with a ring of frames whose depth you chose from measured bytes, not guesses.

What you need

Three pieces:

  • An embedded board with on-board SPI NOR flash and a C/C++ toolchain whose RTOS exposes a block-device API. Most RTOSes split this into three layers — a raw block device over the flash region, a journaling filesystem on top of it, and a file handle for reads and writes. The code below uses that pattern; your RTOS names the layers differently, but the structure is identical.
  • A board-level camera that hands your application JPEG bytes, either encoded on the camera side or compressed on the MCU before writing.
  • The flash map of your board: the base address of the storage chip or region, its size, and the erase sector size.

If you have not chosen the camera yet, the board level camera selection for embedded vision machines guide covers resolution, interface and module fit first. Any camera that delivers JPEG works with the pattern below:

Board-level camerasHikrobot MV-CB060-10UM-B 6 MP Board Level Camera, USB3, IMX178, Mono, Bare Board, V4MV-CB060-10UM-B
Sensor format
1/1.8"
Interface
USB3
$239.00View part
Board-level cameras3.1 MP USB3 board-level camera, IMX252, 1/1.8" global shutter, 120 fps (LS-BCM-0003)LS-BCM-0003
Sensor format
1/1.8"
Lens mount
C-mount
$690.00View part
Board-level cameras6.3 MP board-level camera, IMX178, 1/1.8" rolling shutter, 18 fps (LS-BCM-0020)LS-BCM-0020
Sensor format
1/1.8"
Lens mount
CS-mount
$290.00View part

The MV-CB060-10UM-B is a bare-board 6 MP mono USB3 camera on a 1/1.8" rolling-shutter sensor, the part to solder onto your own carrier. The LS-BCM-0003 adds a C-mount, a global shutter and 120 fps for moving targets. The LS-BCM-0020 covers boards that speak GigE instead, with a CS-mount.

If the camera head rides on a moving stage, put a cable rated for drag chains on the USB3 run:

Cables3 m USB3.0 camera cable, high-flex (drag chain) (LS-CBL-0219)LS-CBL-0219$62.00View partBuild a system with it

Size the retention budget first

Decide how many frames you must keep before you pick anything else, because that number sizes the region and can disqualify the medium outright. Three inputs drive it: region bytes, average frame bytes, and the overhead you concede to the filesystem.

frames = region_bytes × (1 − overhead) / avg_frame_bytes

Worked example, a 128 Mbit (16 MB) flash with 12 MB given to storage:

  • Region: 12 × 1,048,576 = 12,582,912 bytes.
  • Frame: a QVGA JPEG (320 × 240 pixels) at moderate quality lands near 30 kB, i.e. 30,720 bytes. Illustrative: quality 90 roughly doubles it, and a VGA frame has four times the pixels.
  • Capacity: 12,582,912 / 30,720 = 409.6, so 409 frames.
  • Overhead of about 5% for filesystem metadata and wear reserve: 409 × 0.95 ≈ 388 frames.
  • Ring depth with margin: 350 files.

At one frame every 2 s, 350 files is 700 s — about 11.7 min of history before the oldest rolls off. If that is too thin, the same arithmetic says what to change: crop at the source to cut bytes per frame (the ROI cropping guide shows how), drop the JPEG quality, or take a bigger region.

Draw a storage region on the flash map

Open the board's flash map and pick a region that is yours alone. Two rules:

  1. Bound the region on whole erase sectors — 4 kB sectors are typical for SPI NOR parts — so the start address and size are multiples of that granularity.
  2. Never overlap firmware, bootloader, OTA staging or calibration blocks. On a two-chip design (code in internal flash, storage on a separate SPI NOR chip) the whole external chip is usually fair game; on a single-chip design you partition around the application image.

The block device does the erasing for you; you only owe it clean boundaries. Construct it over your region:

#include <cstdint>
#include "FlashIAPBlockDevice.h"

// Illustrative values — take start and size from your board's flash map.
static const uint32_t STORAGE_START = 0;
static const uint32_t STORAGE_SIZE  = 12 * 1024 * 1024;   // 12 MB example region

static FlashIAPBlockDevice bd(STORAGE_START, STORAGE_SIZE);

Mount a filesystem that survives power cuts

Skip FAT here. FAT keeps its allocation tables in the same fixed sectors on every update, so a cut mid-update corrupts the volume, and on a 12 MB region those fixed tables cost frame space you would rather keep. LittleFS journals and relocates its metadata, tolerates a reset at any instruction, and carries far less overhead on small volumes.

Mount with a format-on-first-boot fallback:

#include "LittleFileSystem.h"

static LittleFileSystem fs("storage");   // all paths under /storage/

bool storage_mount(void)
{
    if (bd.init() != 0) {
        return false;
    }
    if (fs.mount(&bd) == 0) {
        return true;
    }
    // Blank or foreign medium: format once, then mount again.
    if (fs.format(&bd) != 0) {
        return false;
    }
    return fs.mount(&bd) == 0;
}

The mount name becomes the path prefix: every file lands under /storage/. Plain fopen and fwrite through the C library reach it, which keeps this an exercise in C.

Write and roll the frames

The write path is six steps:

  1. Number every frame with a fixed-width sequence: /storage/f000041.jpg sorts in fill order.
  2. Write the whole JPEG in one fwrite call. Dribbling bytes out in small writes burns program cycles on metadata, not just data.
  3. Close immediately with fclose. The close is the commit point; until it returns zero, the file does not exist as far as the ring is concerned.
  4. Treat the returns as the contract: fwrite must report exactly the frame length and fclose must return 0, or you stop advancing the sequence and raise a fault.
  5. Retire after writing, never before: once frame N is safely closed, remove frame N − depth. A power cut between the two operations loses nothing.
  6. Recompute capacity from the running mean file size every 100 frames and adjust the ring depth while the system runs.
#include <cstdio>
#include <cstdint>

#define RING_DEPTH 350u

// Returns true only when the whole frame reached the medium and closed cleanly.
static bool frame_save(const uint8_t *jpeg, uint32_t len, uint32_t seq)
{
    char path[32];
    snprintf(path, sizeof path, "/storage/f%06lu.jpg", (unsigned long)seq);

    FILE *fp = fopen(path, "wb");
    if (fp == NULL) {
        return false;
    }
    bool ok = (fwrite(jpeg, 1, len, fp) == len);
    ok = ok && (fclose(fp) == 0);
    return ok;
}

static void frame_retire(uint32_t seq)
{
    if (seq >= RING_DEPTH) {
        char old[32];
        snprintf(old, sizeof old, "/storage/f%06lu.jpg",
                 (unsigned long)(seq - RING_DEPTH));
        remove(old);
    }
}

The delete-after-write order is what makes the ring safe: at any instant the volume holds either depth frames or depth + 1, never depth − 1.

Prove retention with a wall-switch test

Run this on the bench before anything ships:

  1. Save five frames and write down each byte count from the successful fwrite returns.
  2. Cut power at the wall supply while the system is idle — not a debugger reset.
  3. Reboot, mount, and reopen all five files.
  4. Compare byte counts against the table, and check each file starts with the JPEG SOI marker, 0xFF 0xD8:
static bool frame_is_jpeg(const char *path)
{
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) {
        return false;
    }
    uint8_t soi[2] = {0, 0};
    size_t n = fread(soi, 1, 2, fp);
    fclose(fp);
    return (n == 2 && soi[0] == 0xFF && soi[1] == 0xD8);
}
  1. Repeat the test with the cut landing mid-write, not only while idle. You should lose at most the one file in flight and keep the volume mountable.
  2. Finish with a 24 h soak at the production trigger rate: zero mount failures, zero write faults, byte counts matching the log.

If a mid-write cut loses the whole directory rather than one file, what mounted is not the journaling filesystem you think it is — check the mount path and the layer that answered it.

How long the flash lasts at your frame rate

Erasing is what ages flash. Data sheets for SPI NOR parts commonly rate 100,000 program/erase cycles per block; use the worst figure printed for your own part, and let the filesystem wear-level across the whole region.

At the 388-frame depth computed above:

  • One full sweep of the ring writes 388 frames and erases each block once.
  • Wear-out: 388 × 100,000 = 38,800,000 frames.
  • At 1 frame/s: 38,800,000 / 86,400 = 449 days, about 15 months of 24/7 logging.
  • At one frame every 10 s: 4,490 days, about 12.3 years.
  • At 30 fps continuous: 2,592,000 frames per day, so 38,800,000 / 2,592,000 ≈ 15 days.

The decision falls out of the last line. Event-triggered and low-rate logging lives comfortably on flash; continuous video at production frame rates wears the region out in weeks and holds only 388 / 30 ≈ 13 s of history anyway. That case belongs on a different medium.

When flash is the wrong storage

Move off the on-board chip when any of these holds:

Flash stays right for last-good-part images for traceability, alarm evidence, golden-sample sets tied to a recipe, and boot-time fault snapshots. Small, event-driven, must survive power cuts.

Pre-flight checklist

Before you flash the firmware:

  • Region start and size taken from the board's flash map, multiples of the erase sector, no overlap with firmware or calibration — a wrong base address bricks hardware.
  • Ring depth computed from measured frame bytes, capped about 10% below raw capacity — headroom for metadata growth.
  • Format-on-first-boot path tested on a blank device — field units will meet blank flash.
  • Write-then-retire order in code, with fwrite and fclose returns checked — that order is the crash safety.
  • Wall-switch test passed twice, including mid-write — idle-only tests hide the real failure.
  • 24 h soak at production trigger rate with zero faults — endurance problems appear as slow write errors, not crashes.
  • Wear-out date computed from your part's rated cycles and recorded with the firmware version, so the next engineer knows the budget.

Size the region from measured frame bytes, mount a journaling filesystem, write then retire, and prove it with the wall-switch test — that is the whole discipline. For the camera side, the camera finder filters board-level parts by interface, mount and shutter, the vision system configurator walks the full chain, and the applications team will check your frame-size arithmetic against your part mix.

Questions

Can I store raw sensor frames on the flash instead of JPEG?

Yes, but capacity drops hard. A QVGA mono frame at 8 bits is 320 × 240 = 76,800 bytes, so a 12 MB region holds 12,582,912 / 76,800 ≈ 163 raw frames against roughly 388 JPEG ones. Unless you need lossless pixels for offline tuning, keep JPEG on the camera or encode on the board before writing.

How big will my JPEG frames actually be?

Measure, don't estimate: log the byte count returned by fwrite for 20 frames across your real part mix, take the mean and add 20% margin. QVGA JPEGs typically land between 15 kB on flat scenes and 40 kB with high-quality settings, so one quality click can halve your retention depth.

What happens to stored frames if power is cut mid-write?

A journaling flash filesystem keeps the volume mountable: the frame being written may end up truncated or absent, but every previously closed file stays intact. Because the ring removes the oldest file only after the new write closes, a cut between those two operations loses nothing. Run the wall-switch test to confirm this on your board.

Will logging frames wear out the flash chip?

The rating sets a budget and your settings spend it: life scales in direct proportion to region size and in inverse proportion to trigger rate. Double the region to 24 MB and the 449-day figure becomes about 2.5 years; log one frame every 10 s instead of every second and it stretches past 12 years. Compute the date once from your part's rated cycles and record it with the firmware version.

Can the same region hold logs and fault snapshots next to the images?

Yes — put them in their own folder under /storage/ and count their bytes in the same budget. A 4 kB log line written every second rewrites blocks exactly like a frame does, so add its yearly volume to the wear arithmetic. Keep snapshot names sequence-numbered like the frames and retire them with the same delete-after-write order.