NetBurner 3.5.7
PDF Version
Serial TX Empty Callback

Serial TX Empty Callback

Supported Platforms:** MODM7AE70, SBE70LC

1. Overview

The Serial TX Empty Callback lets application code run an action the instant the last bit of a transmission has physically left the UART pin. The typical use case is disabling an RS-485 transceiver or a wireless TX amplifier so the shared bus is released as quickly as possible after the final byte.

The examples provide are:

  • exampleSerialEmptyCallback-SAME70-GPIO: Toggle a GPIO pin when TX is empty for tranceiver support such as RS-485
  • exampleSerialEmptyCallback-SAME70-Timing: Timing between buffer writes for protocols such as Modbus RTU
  • exampleSerialEmptyCallback-SAME70-Advanced: Advanced 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.

Alternative on MODM7AE70 USART ports: If you only need automatic RTS toggling for RS-485 half-duplex, enable Serial485HalfDupMode and the hardware will assert/de-assert RTS around each transmission without any callback code. The callback approach described here gives you full control for cases where that built-in mode is not sufficient (e.g. custom timing, GPIO-based enable, multi-write transactions).

Quick Start

If you just need the basic pattern (single write, wait for completion, act), here it is. See SimpleGPIO/src/main.cpp for the full working example.

OS_SEM TxDoneSem;
void SerialEmptyCB(int portnum, uint32_t bytesSinceLast)
{
TxDoneSem.Post();
}
// In your task:
RegisterTxEmptyCallback(fd, NULL); // Reset driver byte counter
TxDoneSem.Init();
RegisterTxEmptyCallback(fd, SerialEmptyCB); // Register callback
DEMO_PIN = 1; // Enable transceiver
writestring(fd, "Hello RS-485!\n"); // Single write - no race possible
TxDoneSem.Pend(); // Wait for TX complete
DEMO_PIN = 0; // Disable transceiver
int writestring(int fd, const char *str)
Write a null terminated ascii string to the stream associated with a file descriptor (fd)....
Counting semaphore for task synchronization and resource management.
Definition nbrtos.h:550
uint8_t Pend(uint32_t timeoutTicks=WAIT_FOREVER)
Wait for the semaphore count to become non-zero, then decrement it.
Definition nbrtos.h:601
uint8_t Init(int32_t cnt=0)
Reset the semaphore to its initial state with the specified count.
uint8_t Post()
Increment the semaphore count by one, releasing any waiting tasks.

For multi-write transactions (e.g. printf), you need additional synchronization to handle the race condition where the callback fires between writes. See Section 6 and Advanced/src/main.cpp.

2. Hardware: The SAME70 UART/USART TX Pipeline

A UART/USART transmitter on the SAME70 has two internal stages between software and the wire:

+----------+ +----------------+
Software --write()--> | THR |---->| Shift Register |----> TX Pin
FIFO | (1 byte) | | (serializes) |
+----------+ +----------------+
^ ^
TXRDY=1 TXEMPTY=1
when THR empty when THR AND shift
register empty
int write(int fd, const char *buf, int nbytes)
Write data to the stream associated with a file descriptor (fd). Can be used to write data to stdio,...

Two status flags in the Channel Status Register (US_CSR) reflect the state of these stages:

Flag US_CSR bit Set when Defined in
TXRDY bit 1 THR is empty – ready for the next byte US_CSR_TXRDY (usart.h:256)
TXEMPTY bit 9 THR and shift register are both empty – the last bit has left the pin US_CSR_TXEMPTY (usart.h:262)

TXRDY** means *"I can accept another byte"*; TXEMPTY means *"all data is truly gone from the hardware."* The callback mechanism relies on TXEMPTY.

3. Software: The Serial Driver TX Buffer

The driver keeps a pool-buffer-backed FIFO (fifo_buffer_storage) per port in UartData[port].m_FifoWrite. When application code calls write(), data is copied into this FIFO; the actual transmission happens asynchronously.

3.1 Starting transmission – WakeTx() (serial.cpp:1032)

WakeTx() checks the UART_TX_EMPTY software flag. If the transmitter is idle, it clears that flag and kicks off transmission:

  • DMA mode – calls StartTx(), which programs the XDMAC to transfer an entire pool buffer from the FIFO to the THR automatically.
  • Polling/interrupt mode – calls TxEnable(), which enables the TXRDY interrupt so the ISR can pull bytes from the FIFO one at a time.

When a callback is registered, WakeTx() additionally enables the TXEMPTY interrupt so the driver can detect when the shift register drains (serial.cpp:1058-1061, 1074-1077).

3.2 Polling TX ISR – DoSerIsr_Tx() (serial.cpp:1327)

Each time the TXRDY interrupt fires:

  1. The ISR calls the port's get-char function to pull the next byte from the FIFO.
  2. If a byte is available, it writes it to the THR and increments TxEmptyCBCtx[port].bytesSinceLast (line 1369).
  3. If the FIFO is empty (get-char returns -1):
    • Sets the UART_TX_EMPTY software flag (line 1374).
    • If a callback is registered and TxEmpty() is already true (shift register drained): disables TX interrupts, invokes the callback with the accumulated byte count, resets the counter (lines 1377-1381).
    • If a callback is registered but TxEmpty() is false (shift register still busy): disables TXRDY only, keeps the TXEMPTY interrupt enabled. When TXEMPTY fires later, the same ISR path invokes the callback.
    • If no callback: simply disables TX interrupts (line 1390).

3.3 DMA TX completion – TxComp() (serial.cpp:1467)

When a DMA transfer finishes:

  1. The remaining byte count from the active buffer is added to bytesSinceLast (lines 1471-1472).
  2. StartTx() is called to start the next buffer. If no more buffers are queued, TxEnable() enables the TXEMPTY interrupt (line 1478).
  3. When the TXEMPTY interrupt eventually fires, the polling ISR path above invokes the callback.

4. The Callback Mechanism

4.1 Registration

serTxCompCallback_t RegisterTxEmptyCallback(int fd, serTxCompCallback_t pFunc);
  • fd – the file descriptor returned by SimpleOpenSerial() / OpenSerial().
  • pFunc – your callback function, or NULL to unregister.
  • Returns the previously registered callback (or NULL).
  • Passing NULL also resets the internal bytesSinceLast counter.

Declared in serial.h:351 (guarded by #if defined SAME70 || ...). Implementation at serial.cpp:2708.

4.2 Callback signature

typedef void (*serTxCompCallback_t)(int portnum, uint32_t bytesSinceLast);
  • portnum – the internal serial port number (not the fd).
  • bytesSinceLast – number of bytes transmitted since the counter was last reset (i.e. since the previous callback invocation or since registration).

4.3 Context structure

struct TxEmptyCBCtx_t {
serTxCompCallback_t pCallback;
uint32_t bytesSinceLast;
};

Defined at serial.cpp:104. One instance per port in the TxEmptyCBCtx[] array.

4.4 When the callback fires

The callback runs inside the TX ISR when:

  1. The software FIFO is empty (no more bytes to send), and
  2. The hardware TXEMPTY flag is set (shift register drained – last bit is on the wire).

This means the callback fires at the earliest possible moment after the final byte has been transmitted.

5. write() Functions and Their Relationship to Callbacks

Function Behavior
write(fd, buf, nbytes) Copies up to nbytes into the FIFO. Returns actual count. May return less if the FIFO fills. Blocks only if FIFO is completely full.
writeall(fd, buf, nbytes) Loops write() until all nbytes are written. Blocks until complete.
writestring(fd, str) Equivalent to writeall() for a null-terminated string.

Key point:** All write functions return when data is in the software FIFO, not** when data has been transmitted. The callback fires later, asynchronously, when the hardware finishes.

Number of callbacks per write:** A single write() produces exactly one callback – when the FIFO drains and the hardware empties. Multiple rapid write() calls that keep the FIFO non-empty produce only one callback at the end. But if the FIFO drains between writes, an intermediate callback fires.

6. The Race Condition: Callback Fires Before All Data Is Queued

Note: If your application only performs a single writestring() or writeall() per transaction, the race condition described here does not apply – the simple callback + semaphore pattern in the Quick Start section (and SimpleGPIO/src/main.cpp) is sufficient. Read on if you use printf, fdiprintf, or multiple write() calls per transaction.

6.1 The problem

If the application uses multiple write() calls (or printf, which internally does multiple writes), the driver may finish transmitting the first write's data before the second write occurs. The callback fires "too early."

Scenario A -- Both writes queued before FIFO drains (no race)
write("This is a ") ------> FIFO --> TX hardware ------------------> callback
write("test.\n") ------>+ (one)
Scenario B -- FIFO drains between writes (race!)
write("This is a ") ------> FIFO --> TX hardware --> callback (1)
v task preemption / delay
write("test.\n") ------> FIFO --> TX hardware --> callback (2)

In Scenario B, callback 1 fires before the second write has even been called. If the callback unconditionally disables the transceiver, the second write's data goes out with the transceiver off.

6.2 The solution pattern

The example demonstrates a robust pattern using three variables:

OS_SEM TxDoneSem; // Posted by callback when truly done
bool allDataQueued; // Set after all writes complete
uint32_t bytesSent; // Accumulated from callback's bytesSinceLast

Callback** – accumulates bytes but only posts the semaphore when the application has signalled that all data has been queued:

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

Application** – uses a critical section after all writes to atomically check whether the callback already fired:

// --- Perform all writes ---
bytesStarted = writestring(fd, "This is a ");
bytesStarted += writestring(fd, "test.\n");
// --- Synchronise with callback ---
USER_ENTER_CRITICAL(); // (1) Prevent callback from
if (bytesStarted > 0 && // running between the check
bytesStarted > bytesSent) // and setting the flag
{
allDataQueued = true; // (2) Tell callback it may post
USER_EXIT_CRITICAL();
TxDoneSem.Pend(); // (3) Wait for callback to post
}
else
{
USER_EXIT_CRITICAL(); // (4) Callback already fired --
// all data already sent, no wait // no need to pend
}
// --- Safe to act now ---
DoActionTransmissionCompleteAction(); // e.g. disable transceiver

Why the critical section is necessary:** Without it, the callback could fire between the bytesStarted > bytesSent comparison and the allDataQueued = true assignment, causing both the callback (which sees allDataQueued == false) and the application (which proceeds to Pend()) to miss the event – a deadlock.

6.3 Reset sequence

Before starting a new transaction, reset all state:

RegisterTxEmptyCallback(fd, NULL); // Unregister; resets driver byte counter
allDataQueued = false;
TxDoneSem.Init();
bytesSent = 0;
RegisterTxEmptyCallback(fd, SerialEmptyCB); // Re-register

7. Complete Usage Pattern

The example's main loop (see src/main.cpp) follows this sequence on each iteration:

  1. Reset – unregister/re-register callback, clear semaphore and counters.
  2. Pre-action – assert the transceiver-enable GPIO.
  3. Write – queue all data via one or more writestring() calls.
  4. Synchronise – enter critical section, set allDataQueued, and either Pend() or skip if transmission already complete.
  5. Post-action – de-assert the transceiver-enable GPIO.

The example also cycles through simulated preemption delays (PREEMPT_NONE, PREEMPT_MIDWRITE, PREEMPT_ALL_QUEUED) to demonstrate that the pattern handles all race-condition scenarios correctly.

8. Platform Notes

  • MODM7AE70 – uses P2[26] as the demo GPIO pin.
  • SBE70LC – uses J1[3] as the demo GPIO pin.
  • On MODM7AE70, USART ports support Serial485HalfDupMode for automatic hardware RTS toggling, which may be sufficient for simple RS-485 half-duplex without needing a callback.
  • The callback API is available on SAME70, i.MX RT10xx, and i.MX RT11xx platforms (serial.h:341).