FreeRTOS Stream Buffers: Single-Writer Rules, Trigger Levels and Message Boundaries

Start with the writers, not the buffer size

A UART parser occasionally receives a valid header followed by bytes from an unrelated diagnostic message. Increasing the stream buffer merely makes the failure rarer. Before tuning capacity, list every context that writes to the handle: receive interrupt, DMA completion callback, command task, and recovery path. Two functions called from different tasks are two writers even when both belong to the same driver. This guide uses upstream FreeRTOS Kernel V11.1.0 as its review baseline; vendor forks and other releases require their own source check.

The stream-buffer contract assumes one writer and one reader. The writer and reader may be different tasks or interrupts. Message buffers share this restriction. The official header documents a special multi-writer workaround using critical sections and zero block time; that is a deliberate serialization design, not permission to add a mutex around arbitrary blocking calls. See the pinned stream-buffer contract. For a new design, a dedicated owning task is usually easier to explain and test than several competing execution contexts.

UART ISR owns the stream writer; one parser reads byte chunks, while commands use a separate queue.
Original conceptual diagram. Arrows show ownership or lifecycle dependencies, not measured timing.

Separate a byte stream from application records

A stream buffer copies bytes and returns byte counts. It does not promise that one receive corresponds to one send. If a command consists of a two-byte length and a variable payload, the parser must retain an incomplete header, validate the declared length before reserving storage, and continue collecting payload across receives. Several commands can also arrive in one receive. Neither a newline nor a binary header becomes special merely because it crossed this kernel object.

Choose a message buffer when preserving a bounded variable-length record is part of the desired interface and the single-writer/single-reader rule fits. Choose a queue when independent producers should submit records or descriptors through a multi-producer interface. A descriptor introduces a separate lifetime contract: the producer cannot recycle its payload until the consumer has finished. The correct abstraction follows ownership and framing, not an assumption that the lightest primitive must always be fastest.

Read the trigger level as a wake-up threshold

The trigger level affects a task blocked while waiting for data. It is not a minimum successful receive length and is not a frame length. In the ordinary stream-buffer API, data already present can be read without waiting for the threshold. A blocked receive can time out with fewer bytes than the trigger level and still return those bytes. The documented zero setting becomes one; a threshold exceeding buffer capacity is invalid. The trigger-level reference describes these boundary cases.

For example, a hypothetical parser with a threshold of 16 and a finite timeout must be prepared to receive five bytes after the sender pauses. This is a proposed test condition, not a measured latency. A larger threshold can batch wake-ups, but the acceptable wait is a protocol decision. A low-rate control link may need to process a short command promptly even when the next burst never arrives.

Treat partial sends as a protocol event

Check the return value from xStreamBufferSend() and its ISR counterpart. A task send can write fewer bytes than requested when space is insufficient at timeout. The application must decide whether to retry the remaining suffix, discard and mark a gap, or apply upstream backpressure. Blindly retrying the original packet duplicates its accepted prefix. Blindly discarding the return value can produce a syntactically plausible but incorrect command later.

Keep one explicit transmit offset for a retried record and prevent another record from interleaving while it is incomplete. If the source cannot be slowed, make loss visible to the parser. A gap marker carried outside the byte stream can force it to abandon the current frame and seek a validated boundary. A checksum helps detect corruption; it does not repair an ownership violation. Report overflow separately from invalid framing so field diagnostics distinguish transport loss from malformed input.

Keep ISR and task ownership consistent

Use xStreamBufferSendFromISR() in an interrupt and the task form in a task, following the selected port’s interrupt-priority and yield requirements. Changing the API spelling does not make two producers safe: an ISR and a task writing the same handle still require serialization. On a multicore port, a local interrupt mask alone is not an inter-core ownership argument.

A practical arrangement is an interrupt-only receive writer and a single parsing task as reader. A task that wants to inject a synthetic command should send it through a separate control queue or ask the owner to perform the injection. During reset, first stop the producer, synchronize pending callbacks, and make the consumer quiescent before resetting or deleting shared state. Handle validity and parser lifetime matter as much as buffer contents.

Validate fragmentation, timeout and restart independently

Build a host-side parser test around a known byte sequence containing two frames. Feed it every split point, then one byte at a time, then both frames together. Assert that the same records emerge in the same order. Include an oversized declared length, a truncated final frame, and a deliberate dropped byte. Those tests exercise framing without depending on scheduler timing.

On the selected target, separately test an empty buffer, pre-existing data below the trigger, timeout below the trigger, a full buffer, and an ISR burst during task activity. Record bytes offered, bytes accepted, bytes consumed, records accepted, and intentional losses. Restart the subsystem with a callback deliberately held pending. These are proposed acceptance tests; this article has not run them on a board.

Acceptance checklist

  • Every handle has a named writer and reader, including exceptional and interrupt paths.
  • The parser accepts arbitrary receive chunk boundaries and bounds every declared length.
  • Every short send has an explicit retry, loss, or backpressure policy.
  • Trigger level and timeout meet short-command latency requirements without claiming packet atomicity.
  • Reset and deletion prevent callbacks from accessing stale handles or parser storage.
  • A queue or message-buffer alternative is selected when the required ownership or framing contract differs.

Similar Posts