NetBurner 3.5.7
PDF Version
Advanced

Serial TX Empty Callback - Advanced Example

Supported Platforms:** MODM7AE70, SBE70LC

Purpose

This example demonstrates robust RS-485 transceiver control when a serial transmission spans multiple write() calls - as happens with printf, fdiprintf, or any sequence of writestring() calls that form a single logical message. It shows the race condition that can occur and the synchronization pattern that prevents it.

The Race Condition

When you make a single writestring() call, the TX empty callback fires exactly once after all data has been transmitted. No race condition is possible (see the Simple example for this case).

But when a transmission is split across multiple writes, the UART may finish sending the first write's data before the second write occurs - especially if a higher-priority task preempts yours between the writes. The callback fires "too early," and if it unconditionally disables the transceiver, the second write's data goes out with the transceiver off.

Normal case - both writes queued before FIFO drains:
writestring("This is a ") --> FIFO --> TX hardware --------------> callback
writestring("test.\n") -->+ (one)
Race condition - FIFO drains between writes:
writestring("This is a ") --> FIFO --> TX hardware --> callback 1 (early!)
v task preemption
writestring("test.\n") --> FIFO --> TX hardware --> callback 2
int writestring(int fd, const char *str)
Write a null terminated ascii string to the stream associated with a file descriptor (fd)....

The Solution

The example uses three shared variables between the application task and the ISR callback:

Variable Type Purpose
TxDoneSem OS_SEM Callback posts this when truly done
allDataQueued volatile bool Application sets this after all writes complete
bytesSent volatile uint32_t Callback accumulates transmitted byte count

Both allDataQueued and bytesSent are marked volatile because they are accessed from both the application task and the ISR callback.

The Callback

void SerialEmptyCB(int portnum, uint32_t bytesSinceLast)
{
bytesSent += bytesSinceLast;
if (allDataQueued) { TxDoneSem.Post(); }
}

The callback always accumulates bytes, but only posts the semaphore when the application has signalled that all data has been queued. If the callback fires "early" (between writes), it records the byte count but does not post - the application hasn't set the flag yet.

The Synchronization Pattern

After all writes complete, the application enters a critical section to atomically check whether the callback has already fired:

  1. Disable interrupts (USER_ENTER_CRITICAL()) - prevents the callback from running between the check and the flag set.
  2. Check if TX is still in progress - compare bytesStarted (total bytes queued) against bytesSent (total bytes the callback has reported).
  3. If still sending: set allDataQueued = true so the next callback invocation will post the semaphore. Re-enable interrupts, then TxDoneSem.Pend() to wait.
  4. If already done: the callback already fired while we were writing. Re-enable interrupts and skip the pend - no need to wait.
  5. Take action - disable the transceiver. This is guaranteed to happen only after all data has been physically transmitted.

The critical section prevents a deadlock: without it, the callback could fire between the bytesStarted > bytesSent check and the allDataQueued = true assignment. The callback would see allDataQueued == false (so it wouldn't post), and the application would proceed to Pend() on a semaphore that will never be posted.

Preemption Simulation

The example cycles through three modes to prove the pattern works in all timing scenarios:

Mode What It Simulates
PREEMPT_NONE No delay. Both writes complete before the FIFO drains. The callback fires once after all data is sent - the normal fast path.
PREEMPT_MIDWRITE Delay between the two writes. The FIFO drains and the callback fires before the second write. Tests that allDataQueued == false correctly suppresses the early post.
PREEMPT_ALL_QUEUED Delay between writes and after writes but before the critical section. The callback has already fired and recorded all bytes by the time we check. Tests the bytesStarted <= bytesSent fast path that skips the pend.

All three modes complete successfully, printing "Action Complete" each iteration. The console also shows the current preemption mode and the bytesStarted/bytesSent counters so you can see the synchronization in action.

What to Observe

  • The console prints the preemption mode at the start of each iteration.
  • In PREEMPT_NONE and PREEMPT_MIDWRITE modes, you'll see bytesStarted: 16 - bytesSent: 0 (or similar), meaning the application reached the pend before all bytes were sent.
  • In PREEMPT_ALL_QUEUED mode, the bytesSent count will equal bytesStarted, and the pend is skipped entirely.
  • "Action Complete" prints every time, confirming the pattern handles all cases.
  • On a scope, DEMO_PIN (P2[26] on MODM7AE70, J1[3] on SBE70LC) goes high before the first byte and low after the last byte, regardless of preemption mode.

Key Takeaway

For multi-write transactions, you need the allDataQueued flag plus a critical section to avoid a race between the application setting the flag and the callback checking it. The bytesSent counter lets you detect when the callback has already reported all bytes, avoiding an unnecessary (and potentially deadlocking) pend.

Next Steps

  • SimpleGPIO - Start here if you only need single-write transactions.
  • SimpleTiming - Shows how to use the callback for precise inter-frame delays (e.g. Modbus RTU).
  • SerialTxEmptyTechnicalDetails.md (in parent directory) - Full technical reference covering the SAME70 UART hardware pipeline, the serial driver's TX buffer and ISR, the callback mechanism, and the race condition in detail.