BUG: Teensy 3.x serial data corruption when addMemoryForRead() grows the buffer past 256 bytes (uint8_t ring indices)

VictorFS

Well-known member
Minimal repro (Teensy 3.5, jumper pin 8 → pin 9) -> Please check attached main.cpp file

Summary

In cores/teensy3/serial1.c … serial6.c, the ring buffer index variables are sized at compile time based on the static buffer size:
C++:
#if SERIAL2_RX_BUFFER_SIZE > 65535
static volatile uint32_t rx_buffer_head = 0;
...
#elif SERIAL2_RX_BUFFER_SIZE > 255
static volatile uint16_t rx_buffer_head = 0;
...
#else
static volatile uint8_t rx_buffer_head = 0;   // default sizes (64/40) land here
static volatile uint8_t rx_buffer_tail = 0;
#endif
However, addMemoryForRead() / addMemoryForWrite() only change rx_buffer_total_size_ at runtime — the index type stays whatever the compile-time size selected. With the default sizes (RX 64, TX 40), the indices are uint8_t, so any added memory that pushes the total past 256 makes the stored indices wrap at 256 while the wrap-around comparison (if (++i >= rx_buffer_total_size_) i = 0;) uses the full total. The stored value is silently truncated.

Effect on FIFO UARTs (UART0/UART1, e.g. Serial1/Serial2 on Teensy 3.5/3.6): actual data corruption.
The RX ISR drains the FIFO in a burst loop, keeping the head in a 32-bit local and storing it once at the end:
C++:
head = rx_buffer_head;            // uint32_t local <- uint8_t (0..255)
do {
  n = UART1_D;
  newhead = head + 1;
  if (newhead >= rx_buffer_total_size_) newhead = 0;   // wraps at 64+added, e.g. 8256
  if (newhead != tail) { head = newhead; /* store byte at head */ }
} while (--avail > 0);
rx_buffer_head = head;            // TRUNCATED to uint8_t (e.g. 259 -> 3)
When a burst crosses index 255, the incoming bytes are written at indices 256, 257, … (into the extended storage), but the stored head wraps to a small value. The reader then consumes stale bytes from rx_buffer[1..k] — data received ~256 bytes earlier — while the real bytes are never read. The result is a silent, in-place replacement of 1–7 bytes (bounded by the FIFO depth) with old stream data, roughly once every 256 received bytes. Because the replacements are copies of earlier traffic, they usually look like plausible data and are very hard to attribute to the driver. The TX drain loop in the ISR has the same pattern, so addMemoryForWrite() past a total of 256 corrupts transmit data the same way.

Effect on non-FIFO UARTs: silent capacity loss.The per-byte ISR stores the truncated index on every byte, so reader and writer alias consistently — no corruption, but the ring effectively has only 256 slots: memory added beyond a total of 256 is never used, and available() returns wrong counts (rx_buffer_total_size_ + head - tail computed with the small indices).

Forum and Teensy Cores PRs / Issues search
Before writing this post, I searched this forum and also briefly looked through the pull requests and issues related to Teensy cores on GitHub, but I didn’t find anything very conclusive about this problem. I found an old comment by Paul that seems to be related, but I believe the problem persists in the most recent versions of Teensyduino.
https://forum.pjrc.com/index.php?threads/serial-interrupt-patching.77244/#post-361135

Looks like the old Teensy 3.x hardware serial code is using only uint8_t for the buffer index.


Before we supported addMemoryForRead() the only way was to edit the code. On Teensy 3.x, you'll still need to edit that core library code to gain support for more that 255 chars in the buffer.

Suggested fix
Never use uint8_t for the indices, since addMemoryForRead()/addMemoryForWrite() can grow the total at runtime beyond the compile-time size. Mirror the Teensy 4 core:
C++:
#if SERIAL2_RX_BUFFER_SIZE > 65535
static volatile uint32_t rx_buffer_head = 0;
static volatile uint32_t rx_buffer_tail = 0;
#else
static volatile uint16_t rx_buffer_head = 0;   // was uint8_t for sizes <= 255
static volatile uint16_t rx_buffer_tail = 0;
#endif
plus a clamp in the add-memory functions so the runtime total can never exceed what the index type can address:
C++:
void serial2_add_memory_for_read(void *buffer, size_t length)
{
#if SERIAL2_RX_BUFFER_SIZE <= 65535
if (length > 65536 - SERIAL2_RX_BUFFER_SIZE) length = 65536 - SERIAL2_RX_BUFFER_SIZE;
#endif
...
}
Same change in all six serialN.c files, RX and TX. RAM cost is 2 extra bytes per index (≤8 bytes per port), which seems a fair price for making the add-memory API safe at any size.
 

Attachments

  • main.cpp
    4 KB · Views: 9
Have you confirmed this with a test? I'm sure a lot of people are using the add_memory_for_read/write() functions, so it's hard to imagine this problem could have escaped detection. The added buffer is not contiguous with the original, build-time buffer, and tx_buffer_head/tail variables are only used directly when accessing the original buffer. The logic for computing and using head and tail for the extended buffer is tricky and not well-commented, so I can't say whether or not it's okay by inspection. I'd have to build a test program and try it.
 
Yes, it's confirmed by test — the repro sketch above (attached main.cpp) shows it on a bare Teensy 3.5 with a single jumper. One quick and dirty fix to the issue is to add -DSERIAL2_RX_BUFFER_SIZE=512 to the build_flags, as it forces rx_buffer_head and rx_buffer_tail from cores/teensy3/serial2.c to be type uint16_t instead of uint8_t due to 512 being greater than 255. Can confirm it is 100% reproducible. To be clear, head/tail are single indices spanning both regions (< SIZE → build-time buffer, else storage_[i - SIZE]); the region-selection logic is fine — the problem is only that the stored index is truncated to uint8_t while the wrap point is the runtime total.

It could have escaped detection earlier because all of these must be true at once:
  • Teensy 3.x only — the Teensy 4 core always uses uint16_t indices.
  • Total buffer (build-time + added) > 256 — with default sizes, that means addMemoryForRead() with more than 192 bytes. Small additions are safe; and anyone who instead raised SERIALx_RX_BUFFER_SIZE with a build define got uint16_t indices and never saw it.
  • FIFO UART only for corruption (Serial1/Serial2) — the FIFO ISR advances the index in a 32-bit local across the whole burst and stores it truncated once. On non-FIFO UARTs the per-byte truncation is self-consistent: no corruption, the ring just silently aliases to 256 slots (added memory unused, available() overcounts) — invisible unless you measure.
  • A multi-byte FIFO burst must straddle ring index 256 — data must arrive back-to-back. Slow or byte-at-a-time traffic (1-byte batches) crosses the boundary cleanly every time.
  • Burst phase must be misaligned with the boundary — regular, fixed-size traffic aligned to the 4-byte FIFO watermark hides it completely. Irregular, real-world traffic exposes it.
  • The symptom doesn't look like a driver bug — worst case 1–7 wrong bytes per 256 received, and the wrong bytes are replays of data received ~256 bytes earlier, so they look like plausible protocol content. With repetitive traffic they often match the expected bytes and are invisible. In the field this gets blamed on line noise, baud error, or the attached device — that's exactly how it was misdiagnosed for a long time in my case.
Example code output without the fix:
Code:
Serial3 TX -> jumper -> Serial2 RX. Comparing every received byte...<CR><LF>
MISMATCH at byte 256: sent 05, received 00<CR><LF>
MISMATCH at byte 512: sent 0A, received 00<CR><LF>
MISMATCH at byte 768: sent 0F, received 00<CR><LF>
MISMATCH at byte 1024: sent 14, received 00<CR><LF>
MISMATCH at byte 1280: sent 19, received 00<CR><LF>
MISMATCH at byte 1536: sent 1E, received 00<CR><LF>
MISMATCH at byte 1792: sent 23, received 00<CR><LF>
MISMATCH at byte 2048: sent 28, received 00<CR><LF>
MISMATCH at byte 2304: sent 2D, received 00<CR><LF>
MISMATCH at byte 2560: sent 32, received 00<CR><LF>
MISMATCH at byte 2816: sent 37, received 00<CR><LF>
MISMATCH at byte 3072: sent 3C, received 00<CR><LF>
--- 3300 bytes tested, 88 good blocks, 12 bad blocks<CR><LF>
MISMATCH at byte 4352: sent 55, received 50<CR><LF>
MISMATCH at byte 4608: sent 5A, received 50<CR><LF>
MISMATCH at byte 4864: sent 5F, received 50<CR><LF>
MISMATCH at byte 5120: sent 64, received 50<CR><LF>
MISMATCH at byte 5376: sent 69, received 50<CR><LF>
MISMATCH at byte 5632: sent 6E, received 50<CR><LF>
MISMATCH at byte 5888: sent 73, received 50<CR><LF>
MISMATCH at byte 6144: sent 78, received 50<CR><LF>
MISMATCH at byte 6400: sent 7D, received 50<CR><LF>
--- 6600 bytes tested, 179 good blocks, 21 bad blocks<CR><LF>

Output with the dirty fix (DSERIAL2_RX_BUFFER_SIZE=512 as build flag):
Code:
Serial3 TX -> jumper -> Serial2 RX. Comparing every received byte...<CR><LF>
--- 3300 bytes tested, 100 good blocks, 0 bad blocks<CR><LF>
--- 6600 bytes tested, 200 good blocks, 0 bad blocks<CR><LF>
 
Okay, I do see the assignment statements in cores\TEENSY3 that seem problematic. When you say burst, you mean use of write with a length parameter, like below? If so, it's not hard to imagine prior tests using single-byte writes. I wouldn't call that "burst", though. Just call it a multi-byte write. For a UART, there should be no difference in what's "on the wire" for multi-byte writes versus fast looping through one-byte writes.

Code:
  Serial3.write(sent, BLOCK_SIZE); // one contiguous burst on the wire
 
Thanks for pushing on the terminology — actually, I noticed that the sender's API is irrelevant. A multi-byte write() and a tight loop of single-byte write()s produce identical back-to-back bytes on the wire. I should not have said "burst" meaning the write call.

The thing that matters is on the receive side: how many bytes one invocation of the UART2 ISR drains from the UART2 hardware RX FIFO. In my example code, I used multi-byte writes for the sake of simplicity, to ensure that there would be multiple bytes in the queue to be flushed.

Were you able to reproduce the issue on your Teensy?
 
Were you able to reproduce the issue on your Teensy?
I haven't tried yet. May be able to do that today. I have a couple of applications that do a lot of serial comm, and started on T3.5 before moving to T4.1, but I never used the add_memory feature to increase buffer size.

Do I understand correctly that your test will fail if add_memory makes total buffer size greater than 256? In other words, if you had added 256 rather than a large number like 8192, the problem would still occur?
 
Yes, that's correct. addMemoryForRead() with more than 192 bytes will cause the issue, as far as I understand. Remember that this is only for Serial1 and Serial 2 (HW FIFO) UARTs.
 
I confirmed that your program yields the same result on my T3.6. After that, I tried to simplify it, getting it down as small as possible while showing the issue. I found that your program only shows a problem if there is a call to delay() after write(), and the delay is long enough for all of the bytes to be sent.

Bottom line, even though a problem can occur even when sending/receiving a relatively small number of bytes, the problem with uint8_t variables for rx/tx head/tail in the code is obvious and can be easily shown by sending a larger number of bytes. The program below shows that no matter how large you make the extended receive buffer, the 8-bit variables for head and tail result in available() returning the actual number of bytes in the buffer MOD 256, i.e. 255 is the largest value that can be returned correctly.

Code:
uint8_t  extbuf[256];      // extended RX memory: total 64 + 256 = 320 > 256 -> bug armed

void setup()
{
  Serial.begin(115200);
  while (!Serial && millis() < 3000) {}
  Serial2.addMemoryForRead( extbuf, sizeof(extbuf) );
  Serial2.begin(9600);
  Serial3.begin(9600);
}

void loop() {
  uint8_t  buf[300];               // data to send (contents don't matter)
  for (int n=254; n<=257; n++ ) {
    Serial3.write( buf, n );      // send n bytes
    delay( n+50 );                // delay while all bytes sent at 9600 baud
    Serial.printf( "sent %3lu  avail = %3lu\n", n, Serial2.available() ); // should be n
    Serial2.clear();              // clear rx 
  }
}


output:
sent 254 avail = 254
sent 255 avail = 255
sent 256 avail = 0
sent 257 avail = 1
 
Last edited:
Nice job! This is indeed much simpler than my example code and shows the same issue seen from another function (Serial2.available()).
I hope that @PaulStoffregen can take a look at this some day, as the fix seem to be minimal:
  1. uint8_t variables for rx/tx head/tail should become at least uint16_t
  2. clamp in the add-memory functions so the runtime total can never exceed what the index type can address.
 
Nice job! This is indeed much simpler than my example code and shows the same issue seen from another function (Serial2.available()).
I hope that @PaulStoffregen can take a look at this some day, as the fix seem to be minimal:
  1. uint8_t variables for rx/tx head/tail should become at least uint16_t
  2. clamp in the add-memory functions so the runtime total can never exceed what the index type can address.
Note that my test program will show a problem on a UART with or without a FIFO. Your program shows that with a FIFO, problems can occur even when sending/receiving relatively small "packets".
 
Back
Top