| .. | ||
| Makefile | ||
| packet.c | ||
| packet.h | ||
| README.md | ||
Packet -- Reliable Serial Transport with HDLC Framing
Packetized serial transport providing reliable, ordered delivery over an unreliable serial link. Uses HDLC-style byte-stuffed framing, CRC-16-CCITT error detection, and a Go-Back-N sliding window protocol for automatic retransmission.
This layer sits on top of an already-open rs232 COM port. It does not open or close the serial port itself.
Architecture
Application
|
| pktSend() queue a packet for reliable delivery
| pktPoll() receive, process ACKs/NAKs, check retransmit timers
|
[Packet Layer] framing, CRC, sequencing, sliding window ARQ
|
[rs232] raw byte I/O via ISR-driven ring buffers
|
UART
The packet layer adds framing, error detection, and reliability to the
raw byte stream provided by rs232. The caller provides a receive callback
that is invoked synchronously from pktPoll() for each complete, CRC-verified,
in-order data packet.
Frame Format
Before byte stuffing, each frame has the following layout:
[0x7E] [SEQ] [TYPE] [LEN] [PAYLOAD...] [CRC_LO] [CRC_HI]
| Field | Size | Description |
|---|---|---|
0x7E |
1 byte | Flag byte -- frame delimiter |
SEQ |
1 byte | Sequence number (wrapping uint8_t, 0-255) |
TYPE |
1 byte | Frame type (DATA, ACK, NAK, RST) |
LEN |
1 byte | Payload length (0-255) |
| Payload | 0-255 | Application data |
CRC |
2 bytes | CRC-16-CCITT, little-endian, over SEQ..payload |
The header is 3 bytes (SEQ + TYPE + LEN), the CRC is 2 bytes, so the minimum frame size (no payload) is 5 bytes. The maximum frame size (255-byte payload) is 260 bytes before byte stuffing.
Frame Types
| Type | Value | Description |
|---|---|---|
DATA |
0x00 | Carries application payload |
ACK |
0x01 | Cumulative acknowledgment -- next expected sequence |
NAK |
0x02 | Negative ack -- request retransmit from this seq |
RST |
0x03 | Connection reset -- clear all state |
Byte Stuffing
HDLC transparency encoding ensures the flag byte (0x7E) and escape byte (0x7D) never appear in the frame body. Within the frame data (everything between flags), these two bytes are escaped by prefixing with 0x7D and XORing the original byte with 0x20:
0x7Ebecomes0x7D 0x5E0x7Dbecomes0x7D 0x5D
In the worst case, every byte in the frame is escaped, doubling the wire size. In practice, these byte values are uncommon in typical data and the overhead is minimal.
Why HDLC Framing
HDLC's flag-byte + byte-stuffing scheme is the simplest way to delimit variable-length frames on a raw byte stream. The 0x7E flag byte unambiguously marks frame boundaries. This is proven, lightweight, and requires zero buffering at the framing layer.
The alternative -- length-prefixed framing -- is fragile on noisy links because a corrupted length field permanently desynchronizes the receiver. With HDLC framing, the receiver can always resynchronize by hunting for the next flag byte.
CRC-16-CCITT
Error detection uses CRC-16-CCITT (polynomial 0x1021, initial value 0xFFFF). The CRC covers the SEQ, TYPE, LEN, and payload fields. It is stored little-endian in the frame (CRC low byte first, then CRC high byte).
The CRC is computed via a 256-entry lookup table (512 bytes of .rodata).
Table-driven CRC is approximately 10x faster than bit-by-bit computation
on a 486 -- a worthwhile trade for a function called on every frame
transmitted and received.
Go-Back-N Sliding Window Protocol
Why Go-Back-N
Go-Back-N ARQ is simpler than Selective Repeat -- the receiver does not need an out-of-order reassembly buffer and only tracks a single expected sequence number. This works well for the low bandwidth-delay product of a serial link. On a 115200 bps local connection, the round-trip time is negligible, so the window rarely fills.
Go-Back-N's retransmit-all-from-NAK behavior wastes bandwidth on lossy links, but serial links are nearly lossless. The CRC check is primarily a safety net for electrical noise, not a routine error recovery mechanism.
Protocol Details
The sliding window is configurable from 1 to 8 slots (default 4). Sequence numbers are 8-bit unsigned integers that wrap naturally at 256. The sequence space (256) is much larger than 2x the maximum window (16), so there is no ambiguity between old and new frames.
Sender behavior:
- Assigns a monotonically increasing sequence number to each DATA frame
- Retains a copy of each sent frame in a retransmit slot until it is acknowledged
- When the window is full (
txCount >= windowSize), blocks or returnsPKT_ERR_TX_FULLdepending on theblockparameter
Receiver behavior:
- Accepts frames strictly in order (
seq == rxExpectSeq) - On in-order delivery, increments
rxExpectSeqand sends an ACK carrying the new expected sequence number (cumulative acknowledgment) - Out-of-order frames within the window trigger a NAK for the expected sequence number
- Duplicate and out-of-window frames are silently discarded
ACK processing:
- ACKs carry the next expected sequence number (cumulative)
- On receiving an ACK, the sender frees all retransmit slots with sequence numbers less than the ACK's sequence number
NAK processing:
- A NAK requests retransmission from a specific sequence number
- The sender retransmits that frame AND all subsequent unacknowledged frames (the Go-Back-N property)
- Each retransmitted slot has its timer reset
RST processing:
- Resets all sequence numbers and buffers to zero on both sides
- The remote side also sends a RST in response
Timer-Based Retransmission
Each retransmit slot tracks the time it was last (re)transmitted. If 500ms elapses without an ACK, the slot is retransmitted and the timer is reset. This handles the case where an ACK or NAK was lost on the wire -- without this safety net, the connection would stall permanently.
The 500ms timeout is conservative for a local serial link (RTT is under 1ms) but accounts for the remote side being busy processing. On BBS connections through the Linux proxy, the round-trip includes TCP latency, making the generous timeout appropriate.
Receive State Machine
Incoming bytes from the serial port are fed through a three-state HDLC deframing state machine:
| State | Description |
|---|---|
HUNT |
Discarding bytes until a flag (0x7E) is seen |
ACTIVE |
Accumulating frame bytes; flag ends frame, ESC escapes |
ESCAPE |
Previous byte was 0x7D; XOR this byte with 0x20 |
The flag byte serves double duty: it ends the current frame AND starts the next one. Back-to-back frames share a single flag byte, saving bandwidth. A frame is only processed if it meets the minimum size requirement (5 bytes), so spurious flags produce harmless zero-length "frames" that are discarded.
API Reference
Types
// Receive callback -- called for each verified, in-order data packet
typedef void (*PktRecvCallbackT)(void *ctx, const uint8_t *data, int len);
// Opaque connection handle
typedef struct PktConnS PktConnT;
Constants
| Name | Value | Description |
|---|---|---|
PKT_MAX_PAYLOAD |
255 | Maximum payload bytes per packet |
PKT_DEFAULT_WINDOW |
4 | Default sliding window size |
PKT_MAX_WINDOW |
8 | Maximum sliding window size |
PKT_SUCCESS |
0 | Success |
PKT_ERR_INVALID_PORT |
-1 | Invalid COM port |
PKT_ERR_NOT_OPEN |
-2 | Connection not open |
PKT_ERR_ALREADY_OPEN |
-3 | Connection already open |
PKT_ERR_WOULD_BLOCK |
-4 | Operation would block |
PKT_ERR_OVERFLOW |
-5 | Buffer overflow |
PKT_ERR_INVALID_PARAM |
-6 | Invalid parameter |
PKT_ERR_TX_FULL |
-7 | Transmit window full (non-blocking) |
PKT_ERR_NO_DATA |
-8 | No data available |
PKT_ERR_DISCONNECTED |
-9 | Serial port disconnected or error |
Functions
pktOpen
PktConnT *pktOpen(int com, int windowSize,
PktRecvCallbackT callback, void *callbackCtx);
Creates a packetized connection over an already-open COM port.
com-- RS232 port index (RS232_COM1throughRS232_COM4)windowSize-- sliding window size (1-8), or 0 for the default (4)callback-- called frompktPoll()for each received, verified, in-order data packet. Thedatapointer is valid only during the callback.callbackCtx-- user pointer passed through to the callback
Returns a connection handle, or NULL on failure (allocation error).
pktClose
void pktClose(PktConnT *conn);
Frees the connection state. Does not close the underlying COM port.
The caller is responsible for calling rs232Close() separately.
pktSend
int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block);
Sends a data packet. len must be in the range 1 to PKT_MAX_PAYLOAD
(255). The data is copied into a retransmit slot before transmission, so
the caller can reuse its buffer immediately.
block = true-- If the transmit window is full, polls internally (callingpktPoll()in a tight loop) until an ACK frees a slot. ReturnsPKT_ERR_DISCONNECTEDif the serial port drops during the wait.block = false-- ReturnsPKT_ERR_TX_FULLimmediately if the window is full.
Returns PKT_SUCCESS on success.
pktPoll
int pktPoll(PktConnT *conn);
The main work function. Must be called frequently (every iteration of your main loop or event loop). It performs three tasks:
- Drain serial RX -- reads all available bytes from the rs232 port and feeds them through the HDLC deframing state machine
- Process frames -- verifies CRC, handles DATA/ACK/NAK/RST frames, delivers data packets to the callback
- Check retransmit timers -- resends any slots that have timed out
Returns the number of DATA packets delivered to the callback this call,
or PKT_ERR_DISCONNECTED if the serial port returned an error, or
PKT_ERR_INVALID_PARAM if conn is NULL.
The callback is invoked synchronously, so the caller should be prepared
for re-entrant calls to pktSend() from within the callback.
pktReset
int pktReset(PktConnT *conn);
Resets all sequence numbers, TX slots, and RX state to zero. Sends a RST frame to the remote side so it resets as well. Useful for recovering from a desynchronized state.
pktCanSend
bool pktCanSend(PktConnT *conn);
Returns true if there is room in the transmit window for another
packet. Useful for non-blocking send loops to avoid calling pktSend()
when it would return PKT_ERR_TX_FULL.
pktGetPending
int pktGetPending(PktConnT *conn);
Returns the number of unacknowledged packets currently in the transmit
window. Ranges from 0 (all sent packets acknowledged) to windowSize
(window full). Useful for throttling sends and monitoring link health.
Usage Example
#include "packet.h"
#include "../rs232/rs232.h"
void onPacket(void *ctx, const uint8_t *data, int len) {
// process received packet -- data is valid only during this callback
}
int main(void) {
// Open serial port first (packet layer does not manage it)
rs232Open(RS232_COM1, 115200, 8, 'N', 1, RS232_HANDSHAKE_NONE);
// Create packet connection with default window size (4)
PktConnT *conn = pktOpen(RS232_COM1, 0, onPacket, NULL);
// Send a packet (blocking -- waits for window space if needed)
uint8_t msg[] = "Hello, packets!";
pktSend(conn, msg, sizeof(msg), true);
// Main loop -- must call pktPoll() frequently
while (1) {
int delivered = pktPoll(conn);
if (delivered == PKT_ERR_DISCONNECTED) {
break;
}
// delivered = number of packets received this iteration
}
pktClose(conn);
rs232Close(RS232_COM1);
return 0;
}
Non-Blocking Send Pattern
// Send as fast as the window allows, doing other work between sends
while (bytesLeft > 0) {
pktPoll(conn); // process ACKs, free window slots
if (pktCanSend(conn)) {
int chunk = bytesLeft;
if (chunk > PKT_MAX_PAYLOAD) {
chunk = PKT_MAX_PAYLOAD;
}
if (pktSend(conn, data + offset, chunk, false) == PKT_SUCCESS) {
offset += chunk;
bytesLeft -= chunk;
}
}
// do other work here (update UI, check for cancel, etc.)
}
// Drain remaining ACKs
while (pktGetPending(conn) > 0) {
pktPoll(conn);
}
Internal Data Structures
Connection State (PktConnT)
The connection handle contains:
- COM port index and window size (configuration)
- Callback function pointer and context
- TX state: next sequence to assign, oldest unacked sequence, array of retransmit slots, count of slots in use
- RX state: next expected sequence, deframing state machine state, frame accumulation buffer
Retransmit Slots (TxSlotT)
Each slot holds a copy of the sent payload, its sequence number, payload
length, and a clock_t timestamp of when it was last transmitted. The
retransmit timer compares this timestamp against the current time to
detect timeout.
Building
make # builds ../lib/libpacket.a
make clean # removes objects and library
Cross-compiled with the DJGPP toolchain targeting i486+ CPUs. Compiler
flags: -O2 -Wall -Wextra -march=i486 -mtune=i586.
Objects are placed in ../obj/packet/, the library in ../lib/.
Requires librs232.a at link time (for rs232Read() and rs232Write()).
Files
packet.h-- Public API header (types, constants, function prototypes)packet.c-- Complete implementation (framing, CRC, ARQ, state machine)Makefile-- DJGPP cross-compilation build rules
Dependencies
rs232/-- Serial port I/O (must be linked:-lrs232)
Used By
seclink/-- Secure serial link (adds channel multiplexing and encryption)proxy/-- Linux serial proxy (uses a socket-based adaptation)