Robohouse ’26 Library
Contents

Chapter 4

Serial communication on the Teensy 4.1

6 sections · about 6 minutes

4.1 What "serial" means here

"Serial" gets used loosely. On the Teensy 4.1 there are several different things that all get called serial at one time or another.

There is USB serial, which is the Serial object, the thing that appears as a COM port or /dev/ttyACM* on your computer when you plug the board in. There are eight hardware UARTs, Serial1 through Serial8, which are asynchronous serial ports on physical pins. There is SPI, a synchronous, clocked, master-driven bus. There is I²C, a two-wire addressed bus. And there is CAN, a differential multi-drop bus designed for noisy environments.

They solve different problems and you will probably end up using three of them.

4.2 The eight hardware UARTs

Each of the Teensy's eight serial ports is backed by an independent LPUART peripheral in the RT1062. Independent in the literal sense: they run in parallel, in hardware, with no software multiplexing and no shared bandwidth. You can have all eight running simultaneously at different baud rates without any of them affecting the others.

The pin assignments are fixed by the silicon's pin multiplexing, and are as follows on the Teensy 4.1:

PortRX pinTX pin
Serial101
Serial278
Serial31514
Serial41617
Serial52120
Serial62524
Serial72829
Serial83435

Note that Serial8 exists only on the Teensy 4.1, not the 4.0, and that pins 34 and 35 are on the underside of the board in the second row. Note also the ordering trap: for most ports RX comes before TX numerically, but Serial3 and Serial5 are the other way round. If a port is silent, swapping RX and TX is the first thing to try.

Using one:

void setup() {
  Serial1.begin(115200);          // 8 data bits, no parity, 1 stop bit
  Serial2.begin(250000, SERIAL_8E1);  // 8 data, even parity, 1 stop
}

The baud rate is generated by dividing a peripheral clock, so not every arbitrary number is exactly achievable. The core picks the closest divisor. UART framing tolerates roughly 2–3% total error between the two ends before it starts corrupting bytes, and standard rates are chosen to divide nicely, so it only causes trouble at unusual rates or very high speeds.

4.3 Buffers, FIFOs, and flow control

Each LPUART has a small hardware FIFO — four bytes deep on this part. Above that, the Teensyduino core maintains software ring buffers in RAM, filled and drained by the UART interrupt. This layering is why Serial1.write() returns almost immediately: it copies into the software buffer and lets the interrupt dribble the bytes out at baud rate in the background.

The consequence is that write() only blocks when the software transmit buffer is full. The default buffers are modest — a few hundred bytes. If you send a burst larger than the buffer, write() will sit there spinning until space frees up, which at 115200 baud means about 87 microseconds per byte. Blocking the main loop for milliseconds because of a debug message is a common way to disturb a motion controller.

The core gives you two tools. Serial1.addMemoryForWrite(buffer, size) and the matching addMemoryForRead let you hand the driver a larger buffer of your own. And Serial1.availableForWrite() tells you how much space is free. In real-time code, check before writing, and if there is no room, drop the message or defer it rather than blocking the motion loop.

For hardware flow control, the LPUARTs support RTS and CTS, exposed as Serial1.attachRts(pin) and Serial1.attachCts(pin). They also support a transmitter enable output via Serial1.transmitterEnable(pin), which asserts a pin for exactly the duration of a transmission. That last one is specifically designed for half-duplex RS-485, where you need to switch a transceiver between drive and receive around each message, and getting that timing right in software is fiddly. For a long cable to a remote I/O board on the arm, RS-485 with transmitterEnable is the way to do it.

4.4 USB serial

The Serial object is not a UART at all. It is a USB CDC virtual serial device implemented over the Teensy's native high-speed USB, which runs at 480 Mbit/s. As a result the begin() baud rate argument is entirely ignored — there is no physical baud rate to set. Actual throughput is limited by USB packet scheduling and by the host, and lands somewhere in the region of 10–25 MB/s in practice, which is orders of magnitude beyond any UART.

Two behaviours to know about. First, USB CDC is packet-based, not stream-based at the hardware level: the Teensy accumulates written bytes and sends them either when a packet fills or after a short timeout. If you want a message to go out immediately, call Serial.send_now(). Second, Serial reports as false in a boolean context until the host has actually opened the port. while (!Serial) ; in setup() will hang forever if you power the board from a bench supply with no computer attached — which is the situation the finished robot will be in. Guard it with a timeout, or leave it out and accept that you will miss the first few debug lines.

For the VCP6, USB serial is the natural choice for the host link: it is fast, it needs no extra hardware, and the same cable programs the board.

4.5 Choosing the right bus for each job

How I would allocate the buses on a six-axis arm:

Host to Teensy: USB serial. Fast, free, and already there.

Teensy to TMC2209 drivers: a hardware UART, in the TMC2209's single-wire UART mode. This is covered in detail in Chapter 7. The short version is that up to four drivers can share one UART line by address, so six axes need two UARTs — say Serial1 and Serial2. Run them at 115200 baud, which is plenty since driver configuration is not in the real-time path.

Teensy to a teach pendant, display, or auxiliary board: another UART, or I²C if the device is nearby and slow.

Teensy to a gripper or tool: depends on the tool. A simple servo gripper needs one PWM pin. A smart gripper might want its own UART.

Anything over a long or noisy cable: CAN, or RS-485. Both are differential, and both survive electrical environments that would defeat a plain UART. A desktop arm with sub-metre cable runs probably does not need either — but if you do start seeing corrupted bytes on a cable that runs alongside a motor lead, the answer is a differential bus rather than a higher baud rate.

4.6 Designing the host protocol

Whatever you send over the host link, design the framing deliberately. Printing human-readable lines terminated by newline is fine for debugging and poor for control: a single dropped byte desynchronises you, floating-point text parsing is slow, and there is no way to detect corruption.

A robust binary frame has four parts: a start marker (a distinctive byte, or better a two-byte sequence unlikely to occur in data), a length field, the payload, and a checksum or CRC over the payload. The receiver runs a small state machine: hunt for the start marker, read the length, accumulate that many payload bytes, verify the CRC, and only then act. If the CRC fails, discard and go back to hunting. That recovers from corruption on its own, which a newline-delimited text protocol does not.

Add a sequence number if you care about detecting lost frames, and an acknowledgement from the Teensy back to the host if you need flow control at the application level. For a robot arm, I would define a small set of message types: set joint targets, request status, set driver current, home an axis, emergency stop. Keep the emergency stop message as short and as distinctive as possible, and handle it before you do anything else in the parser.

One last protocol point: do not let the arm keep moving if the host goes quiet. Implement a watchdog. If the Teensy has not heard a valid frame from the host within some timeout — a few hundred milliseconds — it should decelerate to a stop and hold. An arm that carries on executing its last command after the control PC crashes will eventually hit something.