428 lines
14 KiB
Markdown
428 lines
14 KiB
Markdown
# 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:
|
|
|
|
- `0x7E` becomes `0x7D 0x5E`
|
|
- `0x7D` becomes `0x7D 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 returns
|
|
`PKT_ERR_TX_FULL` depending on the `block` parameter
|
|
|
|
**Receiver behavior:**
|
|
- Accepts frames strictly in order (`seq == rxExpectSeq`)
|
|
- On in-order delivery, increments `rxExpectSeq` and 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
|
|
|
|
```c
|
|
// 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
|
|
|
|
```c
|
|
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_COM1` through `RS232_COM4`)
|
|
- `windowSize` -- sliding window size (1-8), or 0 for the default (4)
|
|
- `callback` -- called from `pktPoll()` for each received, verified,
|
|
in-order data packet. The `data` pointer 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
|
|
|
|
```c
|
|
void pktClose(PktConnT *conn);
|
|
```
|
|
|
|
Frees the connection state. Does **not** close the underlying COM port.
|
|
The caller is responsible for calling `rs232Close()` separately.
|
|
|
|
#### pktSend
|
|
|
|
```c
|
|
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
|
|
(calling `pktPoll()` in a tight loop) until an ACK frees a slot. Returns
|
|
`PKT_ERR_DISCONNECTED` if the serial port drops during the wait.
|
|
- `block = false` -- Returns `PKT_ERR_TX_FULL` immediately if the window
|
|
is full.
|
|
|
|
Returns `PKT_SUCCESS` on success.
|
|
|
|
#### pktPoll
|
|
|
|
```c
|
|
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:
|
|
|
|
1. **Drain serial RX** -- reads all available bytes from the rs232 port
|
|
and feeds them through the HDLC deframing state machine
|
|
2. **Process frames** -- verifies CRC, handles DATA/ACK/NAK/RST frames,
|
|
delivers data packets to the callback
|
|
3. **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
|
|
|
|
```c
|
|
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
|
|
|
|
```c
|
|
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
|
|
|
|
```c
|
|
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
|
|
|
|
```c
|
|
#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
|
|
|
|
```c
|
|
// 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)
|