Skip to content

About

Dual/Triple-port version of DigiCDCFast

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

57 Commits

Folders and files

Repository files navigation

DigiCDCMulti

Two or three USB serial ports from one Digispark (ATtiny85) or Digispark Pro (ATtiny167): SerialUSB, SerialUSB1 and SerialUSB2, which Linux shows as /dev/ttyACM0 and up. One board can then talk to two or three host programs at once -- a data stream on one port and a console on another, say -- with no extra hardware.

It is built on V-USB and is a fork of DigiCDCFast 1.4.0, itself a fixed and 40x faster DigisparkCDC, and carries everything that release has: the same API on every port, usbTransactionEnd(), and the vendor-request hook. Linux only: Windows refuses low-speed USB serial devices, and macOS is untested.

#include <DigiCDCDual.h>    // or DigiCDCTriple.h for three

void setup() {
  SerialUSB.begin();  // connects every port
}

void loop() {
  if (SerialUSB.available())  SerialUSB.write(SerialUSB.read());
  if (SerialUSB1.available()) SerialUSB1.write(SerialUSB1.read());
}
Ports Each way Notes
DigiCDCDual.h 2 8000 bytes/s, shared 8-byte packets
DigiCDCTriple.h 3 4000 bytes/s, shared 4-byte packets: see Three ports

One port can have the whole rate while the others are quiet, so even a third of it is 25 times what DigisparkCDC managed (161 bytes/s on its one port).

Before you use it

These are the things that behave differently from an ordinary serial port, or from DigiCDCFast. Most of them come from low-speed USB and from V-USB, which does USB in software.

  • Call a port's function at least every ~10 ms. USB is serviced only inside SerialUSB/SerialUSB1 calls (available(), read(), write(), print(), peek(), flush(), refresh(), delay()); either port's call services both. Use SerialUSB.delay(ms) instead of delay(ms), and call SerialUSB.refresh() inside anything longer. Otherwise the host resets or drops the device.
  • Reading one port is reading both. The host is paused for the whole device, not per port: while either port's receive buffer is full, the host can send to neither, nor make control requests (so opening or setting up the other port blocks too). A sketch that stops reading one port, even one nobody writes to much, eventually stops the other. Drain both.
  • Writing to a port nobody reads is cheap, then lossy. write() waits for room, but once the host has taken nothing for 50 ms (no program has the port open) it returns 0 at once until there is room again. What doesn't fit in the transmit buffer is lost; what does fit arrives when a program opens the port.
  • No carrier or serial state. The ports never send the CDC serial-state notification, so DCD, DSR and the like are never reported (see How it works). Linux opens the ports anyway, because cdc_acm ports default to CLOCAL; a program that clears CLOCAL may wait forever for carrier.
  • Use the stable names. ttyACM0/ttyACM1 are only those numbers if no other ACM device got there first. /dev/serial/by-id/ has names that always point at the right port: usb-digistump.com_Digispark_Serial-if00 is SerialUSB, ...-if02 is SerialUSB1. With two boards, tell them apart by /dev/serial/by-path/ (the USB port they're plugged into), as they have no serial number.
  • Open the ports raw. A tty echoes by default. For binary data, or against a sketch that echoes (an echo bouncing between host and board never stops), set raw mode: stty -F /dev/ttyACM0 raw -echo, or tty.setraw() in Python.
  • ModemManager. The ports declare themselves as modems (CDC ACM, AT commands), and ModemManager, where it runs, may probe them with AT commands when they appear. If it does, tell it to leave the board alone with a udev rule: ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="087e", ENV{ID_MM_DEVICE_IGNORE}="1".
  • The ports share one low-speed device, and its 8000 bytes/s each way (see Performance): one port can have it all, or the two can split it however their traffic does. V-USB runs every transaction with interrupts off, and two ports mean more transactions: budget for it in timing-sensitive code (see Interrupt latency).
  • RAM is tight on the ATtiny85. It has 512 bytes. The DualEcho example uses 336 for globals, leaving 176 for the stack. Shrink the buffers if you need room (see Configuration).
  • Include the port header in one file of the sketch only. It defines the further ports, their buffers and the USB descriptor. Other files that need the ports include DigiCDCFast.h, which only declares them.
  • Same USB IDs as DigisparkCDC (16d0:087e, "Digispark Serial"). Programs that look for a Digispark by ID find this too.
  • Windows doesn't work at all, and macOS is untested (see Hosts).

API

SerialUSB and SerialUSB1 are both DigiCDCDevice, a Stream, so print(), println(), readBytes() and the rest work as usual.

Function
begin(), begin(baud) Connects to the host, both ports at once, and waits 500 ms for enumeration. Only the first call does anything; the baud rate is ignored.
end() Disconnects from the host: both ports.
available(), read(), peek() As usual; read() and peek() return -1 when there is nothing to read.
write(), print()... Queue the data, waiting for room while the host is reading (see above).
availableForWrite() Free space in the transmit buffer, to write without waiting.
flush() Waits until the host has taken everything written (50 ms timeout).
baud() The bit rate the host last set for this port (stty, tcsetattr()). Each port has its own.
refresh(), task() Services USB.
delay(ms) Waits while servicing USB.
setDtrPin(pin) Drives a pin of port B from this port's DTR line (untested).

Configuration

Define these before including the port header:

Macro Default
DIGICDC_PORTS 2 How many ports, 2 to 4; DigiCDCDual.h and DigiCDCTriple.h set it.
DIGICDC_PACKET_SIZE 8 (4 in DigiCDCTriple.h) USB packet size of every port, 1 to 8 bytes.
DIGICDC_TX0_SIZE, DIGICDC_RX0_SIZE 64, 32 SerialUSB's buffers, 1 to 255 bytes; the receive one must hold a packet.
DIGICDC_TX1_SIZE, DIGICDC_RX1_SIZE 32, 32 SerialUSB1's, and the default for SerialUSB2's and SerialUSB3's.
DIGICDC_TX2_SIZE, DIGICDC_RX2_SIZE, DIGICDC_TX3_SIZE, DIGICDC_RX3_SIZE as port 1 The third and fourth ports'.

Smaller buffers lose nothing (the host is paused, and write() waits), but they cost throughput -- see How small the buffers can be.

Don't combine with DigiCDCMedium.h (DigiCDCFast's 2-byte packets for one port): its descriptor is ignored here, and only its 8-byte buffers would apply, to SerialUSB. Use DIGICDC_PACKET_SIZE instead.

Refusing line settings

A sketch that can only provide some settings, such as a USB-UART bridge, can define digiCdcAcceptLineCoding() (with C linkage); returning 0 makes the library answer SET_LINE_CODING with a STALL and keep the settings it had:

extern "C" uint8_t digiCdcAcceptLineCoding(const uint8_t *coding)
{
    /* coding: bit rate (4 bytes, little endian), stop bits, parity, data bits */
    uint32_t baud = (uint32_t)coding[0] | (uint32_t)coding[1] << 8
                  | (uint32_t)coding[2] << 16 | (uint32_t)coding[3] << 24;
    return baud == 9600 || baud == 4800;
}

It is asked about both ports, and isn't told which. Linux doesn't pass the refusal on: tcsetattr() and stty still succeed.

Three ports, and why not four

DigiCDCTriple.h gives three, with the 4-byte packets they need. The library must be compiled with V-USB's third transmit buffer, which the Arduino IDE cannot take from a sketch: set USB_CFG_HAVE_INTRIN_ENDPOINT5 to 1 in src/usbconfig.h, or build with

arduino-cli compile --fqbn digistump:avr:digispark-tiny:clock=clock165 \
    --build-property "build.extra_flags=-DUSB_CFG_HAVE_INTRIN_ENDPOINT5=1" MySketch

Measured on a Digispark against Linux 6.8 on an xHCI root port, echoing through every port (extras/test/multiecho.py; bytes per second each way, "alone" is one port working while the others idle):

Ports Packet size Alone Shared
2 8 7990 7996
3 8 refused: "Not enough bandwidth for new device state"
3 6 refused
3 4 3990 3989 what DigiCDCTriple.h builds
3 2 1996 1999
4 2, and 1 refused

A third port costs half the throughput. The host polls every data endpoint in every 1 ms frame, and six of them only fit a low-speed frame at 4 bytes each. The device's total is the packet size times the frame rate, so three ports share 4000 bytes/s where two share 8000.

A fourth is refused whatever the packet size, so what runs out is the number of periodic endpoints, not their size: four ports declare twelve. Dropping the notification endpoints, which this library never sends on, gets the configuration accepted -- and then Linux's cdc_acm refuses every interface with -22, because a communication interface must have one. So four CDC ports are out of reach here, below the library's own ceiling of four (V-USB's two extra buffers, and a 241-byte descriptor against the 254 V-USB can return).

Untested: any other host. A board behind a USB 2 hub is scheduled by the hub's transaction translator rather than the root port and may answer differently.

How small the buffers can be

Each port costs about 62 bytes of RAM with one-packet buffers: its object (~32), its two buffers, and V-USB's 12-byte transmit buffer.

Configuration RAM Alone Shared
2 ports, 64/32 and 32/32 (the defaults) 336 7990 7996
2 ports, 16/16 both 243 7973 7373
2 ports, 8/8 both 213 4694 4201
3 ports, 8/4 (TripleEcho) 258 3988 3986
3 ports, 1-byte transmit buffers 253 3982 3980
  • A receive buffer must hold one packet. The library pauses the host when another would not fit, so anything smaller pauses it for good.
  • One packet is not enough to keep 8-byte packets flowing: at 8/8 the host is stopped after every packet until the sketch drains it, and the rate falls by nearly half. Two packets (16/16) get it back for 93 bytes less RAM than the defaults. With 4-byte packets the sketch keeps up, and one packet costs nothing measurable.
  • A transmit buffer can be 1 byte if the sketch never produces faster than USB drains -- an echo doesn't. A sketch that writes in bursts is the one that suffers: write() then waits for a poll per byte.

DIGICDC_TX0_SIZE and DIGICDC_RX0_SIZE set port 0's buffers (or DIGICDC_BUFFERS(tx, rx) in one of the sketch's files), DIGICDC_TX1_SIZE and friends the others'. How all this was measured, and what was not, is in docs/multiport.md.

A third function: vendor requests

Two serial ports spend every endpoint the driver has. A sketch that wants to be something else as well has none left -- but control transfers need no endpoint at all. Set USB_CFG_VENDOR_HOOK to 1 in src/usbconfig.h (in the library: the Arduino IDE compiles it once, without the sketch's definitions) and vendor requests go to a function the sketch defines:

extern "C" uchar digiCdcVendorSetup(usbRequest_t *rq)
{
    static uchar answer;
    if(rq->bRequest == MY_REQUEST){
        answer = whatever();
        usbMsgPtr = (usbMsgPtr_t)&answer;
        return 1;               /* bytes to send; 0 sends nothing */
    }
    return 0;
}

Return what usbFunctionSetup() returns: a byte count to send from usbMsgPtr (which may point at the sketch's own buffer, up to 254 bytes), 0, or 0xff to take data through usbFunctionWrite(). Both serial ports are unaffected and stay enumerated throughout.

Address the requests to the device rather than to an interface, and the host side needs no interface claimed, so it works while the kernel's serial driver holds the ports:

libusb_control_transfer(h, LIBUSB_REQUEST_TYPE_VENDOR |
                           LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_IN,
                        MY_REQUEST, 0, 0, buf, sizeof buf, 1000);

It runs inside the USB interrupt, so it should read state and set flags and leave the work to loop(). Nothing is compiled in when the option is 0. This came from DigiCDCFast 1.4.0, where DigisparkRNG uses it to serve random bytes while leaving that library's one serial port free; it has not been exercised with two ports.

How it works

A CDC serial port has a communication interface, with an interrupt IN endpoint for notifications, and a data interface, with a bulk OUT and a bulk IN endpoint. So two ports want four IN endpoints and three want six, where V-USB has transmit buffers for endpoint 1 and endpoint 3. This library adds two more (endpoints 5 and 7, USB_CFG_HAVE_INTRIN_ENDPOINT5 and _7) and gives every one of them to data:

Port 0 (SerialUSB) Port 1 (SerialUSB1) Port 2 (SerialUSB2)
Interfaces 0 (communication), 1 (data) 2, 3 4, 5
Data OUT / IN 0x01 / 0x81 0x03 / 0x83 0x05 / 0x85
Notifications 0x82: always NAK 0x84: always NAK 0x86: always NAK

Every transmit buffer carries data, and the notification endpoints are declared (Linux's cdc_acm refuses a communication interface without one, with -22) but never send anything. V-USB used to answer an IN request to any endpoint it didn't know with endpoint 1's data; it now answers NAK unless the endpoint is 1 or 3 (handleIn1 in src/asmcommon.inc). That check costs endpoint 1 two cycles, which still leaves its reply two cycles earlier than in the original V-USB, thanks to a DigiCDCFast change.

The data endpoints are declared bulk, which the USB specification does not allow at low speed. Linux converts them to interrupt endpoints polled every millisecond and says so in the kernel log ("endpoint 0x1 is Bulk; changing to Interrupt"); that is expected.

The library itself works with one port or several: the port header defines the extra ports, a table of them in flash and the descriptor, and the library finds them through weak references. Including only DigiCDCFast.h gives a single port with DigiCDCFast's descriptor, byte for byte, at a cost of about 250 bytes of flash and 10 of RAM over DigiCDCFast itself -- so for one port, use DigiCDCFast.

Performance

Measured on a Digispark (ATtiny85, 16.5 MHz) and a Digispark Pro (ATtiny167, 16 MHz), alike to within 0.1%, with Linux 6.8 on an xHCI controller, echoing 20000 bytes per port (the DualEcho example, with a sketch-level jump to the bootloader added for reflashing):

Throughput, each way
One port busy 7990 bytes/s
Both ports busy, combined 7996 bytes/s
Three ports (4-byte packets), one busy 3988 bytes/s
Three ports, all busy, combined 3986 bytes/s

All data came back intact, and no USB errors were logged. The rate is the device's, not each port's: the ports share it. See Three ports for why three halves it.

Size

Flash and RAM for global variables, Digistump core 1.7.5:

Sketch Digispark Digispark Pro
DualEcho 3428 bytes, 336 RAM 3408 bytes, 352 RAM
DigiCDCFast 1.4.0's Echo, for comparison 3216 bytes, 238 RAM 3198 bytes, 246 RAM

The second port costs its 64 bytes of buffers, the object that holds its state, and 58 bytes of descriptor in flash.

Interrupt latency

V-USB handles every USB transaction inside an interrupt that can't be interrupted, so it delays every other interrupt in the sketch. Even when idle the host polls the IN endpoint every millisecond, and each poll runs the handler. extras/latency/LatencyProbe measures how late a timer interrupt runs (3.88 us resolution, 20 s per case, extras/latency/latency.py on the host); same board and host as above:

USB activity Delayed at all 99% 99.9% Max
Idle 3% 27 us 39 us 39 us
Sketch sending 8000 bytes/s 10% 82 us 93 us 97 us
Sketch receiving 5500 bytes/s 10% 93 us 105 us 109 us
Both 14% 97 us 105 us 198 us

For timing-sensitive code this is the budget. For example, a software UART that samples each bit in its middle after catching the start bit misreads a byte whenever the start bit is caught more than half a bit late. That happens to 1.4% of interrupts at 19200 bps with USB idle, and 4-6% at 9600 bps while data is flowing.

These are DigiCDCFast's measurements with one port. Two busy ports mean more transactions, so more of these delays; that has not been measured.

Borrowing the interrupt: usbTransactionEnd()

The 198 µs in the table is not one transaction but two. When the host runs transactions back to back -- which is what heavy two-way traffic looks like -- the driver finds the next packet already arriving as it finishes one, and handles the whole run without leaving the interrupt. Nothing else runs meanwhile, and there is no way to wait it out: on the way back the processor serves the pending USB interrupt before any interrupt with a higher vector number, so returning between transactions changes nothing (it was tried, and the latency distribution did not move).

For work that cannot wait that long, the driver calls usbTransactionEnd() at the end of every transaction, a weak, do-nothing function a sketch may replace:

extern "C" void usbTransactionEnd() __attribute__((naked, used));
void usbTransactionEnd()
{
  asm volatile("lds  r16, %[reg]  \n"   // read a byte the UART is holding,
               "..."                     // stash it, and
               "ret               \n"   // return; no prologue, no epilogue
               :: [reg] "i"(_SFR_MEM_ADDR(LINDAT)));
}

The rules are strict, because it runs inside the USB interrupt:

  • Assembler, naked. The compiler saves nothing for a naked function and would happily use registers the driver has not saved.
  • Only what the driver saved, which is not the same on both chips: r16-r21, Y and the flags are saved on either. The ATtiny85 also saves r0 but not r22; the ATtiny167 saves r22 but not r0. Keep to the intersection unless you have checked the push list in the driver file your board uses. Everything else belongs to whatever the interrupt interrupted.
  • Short. If the host has already begun the next transaction, its packet is sending its sync pattern meanwhile and the driver has to be back in time to catch it. Hooks of 25 and of 33 cycles both measured clean at 16 MHz, so there is room, but a packet caught late is received corrupted rather than missed, and silently: at 16 and 16.5 MHz V-USB has no time to check a CRC.
  • Don't try to be clever about when it runs. It is called on every transaction, and calling it only when the next packet was already arriving -- seemingly the thrifty choice, since that is the case the sketch cannot otherwise reach -- turned out to be far worse than having no hook at all, in the bridge that motivated this. The sketch's own handler then stays pending through the run of transactions and runs the moment the driver returns, which is exactly when the next packet arrives.

A sketch that defines no hook still pays for the call: four bytes of flash, and seven cycles per transaction that delay the driver's return by 0.42 µs at 16.5 MHz. That is measurably nothing to both bridges written against this library, so it is on by default, but a sketch whose own timing is built around the moment the driver lets go can set USB_CFG_TRANSACTION_END_HOOK to 0 in src/usbconfig.h. It has to be set there, in the library: the Arduino IDE compiles a library once, without the sketch's definitions. A sketch that needs the hook can say so with

#if !USB_CFG_TRANSACTION_END_HOOK
#error "this sketch needs USB_CFG_TRANSACTION_END_HOOK in src/usbconfig.h"
#endif

DigisparkProBridge uses it to collect the byte its UART is holding, and to hand the transmitter its next one, which is what let it run 76800 bps in both directions at once without losing any -- the last rate low-speed USB can carry, 7680 of the 8000 bytes/s. It managed 38400 bps before.

Digispark Pro: the core's millis interrupt must not block

On the Digispark Pro, packets sent to the host get lost unless the core is fixed. The Digistump AVR core 1.7.5 runs millis() on Timer0 with a blocking interrupt handler, SIGNAL(TIMER0_OVF_vect) in cores/pro/wiring.c. It overflows at 976.56 Hz, beating against the host's 1000 Hz USB frames, so every 42.7 ms it delays V-USB just as the host polls, and the transaction fails. V-USB has already counted the data as sent, so it is lost. (The ATtiny85 core uses a non-blocking handler for this reason.)

Change that line to

ISR(TIMER0_OVF_vect, ISR_NOBLOCK)

Measured with usbmon while the sketch sent one byte per millisecond and the host flooded it: 259 failed IN transactions and 1.5% of the bytes lost with the original core, none of either with the change.

The same applies to a sketch's own interrupt handlers: any handler that keeps interrupts off for more than a few microseconds when USB traffic arrives makes transactions fail. V-USB has to start within about 40 CPU cycles (2.5 us) of a packet's first bits, and at 16 and 16.5 MHz it can't check CRCs (only its 18 MHz code has room for that), so starting too late can also let a packet through corrupted, accepted as valid. That includes a handler's exit: restoring registers takes 2 cycles each. Handlers that run often should mask their own interrupt and re-enable interrupts, and do the same before restoring their registers, as the USB-UART bridges built on this library do. In DigisparkBridge, a handler that restored its registers with interrupts off (over 100 cycles) let corrupted packets from the host through in some sessions and lost the host's line-setting requests.

Hosts

Only Linux has been tested (see Tested).

Windows: does not work

Windows 10 refuses a USB CDC serial port on a low-speed device, whichever way its data endpoints are declared. This applies to DigisparkCDC and other V-USB serial devices as well. Tested in a Windows 10 virtual machine (VMware, USB passthrough), with usbmon on the Linux host recording every request:

Data endpoints What Windows does
Bulk (as here; the USB specification doesn't allow them at low speed) Reads the device and configuration descriptors correctly, then rejects the configuration and resets the device, over and over: "USB device not recognized".
Interrupt (1 ms; an experiment, not in the library) Configures the device and creates a COM port, but its serial driver, usbser.sys, refuses to start (Code 10) without sending a single request to the device.

Every request reached the board and was answered correctly, so these are Windows' decisions about the descriptors, not transfer problems. (Linux treats both versions the same: it turns bulk endpoints into interrupt endpoints itself, and all the tests pass either way.) V-USB serial projects used a third-party filter driver on Windows: Digistump's driver package installs lowcdc.sys (Osamu Tamura's Low Speed CDC Driver, 2009, signed, 64-bit) below usbser.sys, meant for interrupt data endpoints. On Windows 10 it crashed the system (blue screen) as soon as the device with interrupt endpoints was attached, with the virtual machine emulating a USB 3 controller and again with a USB 2 controller; with bulk endpoints Windows rejects the device before the driver loads.

macOS

Untested. Hosts that follow the USB specification strictly may refuse the low-speed bulk endpoints, as Windows does.

Tested

  • Digispark (ATtiny85) at 16.5 MHz (digistump:avr:digispark-tiny:clock=clock165), Digistump AVR core 1.7.5, micronucleus 2.6, Linux 6.8 on xHCI: two ports and three echo intact, alone and together, at every buffer and packet size in docs/multiport.md.
  • Digispark Pro (ATtiny167, 16 MHz crystal, digistump:avr:digispark-pro), with the core fix above, same host: the same, over 5 rounds of 20000 bytes per port, with the same throughput and no USB errors.

Not yet:

  • DIGICDC_PACKET_SIZE of 1, 3, 5 or 7, and setDtrPin().
  • Four ports: the host refuses them (see Three ports), so that path is code without a home. DIGICDC_PORTS=4 builds and its descriptor parses, nothing more.
  • The single-port HostTest suite (extras/test) on this code; DigiCDCFast passes it.
  • Hubs, other host controllers, the Raspberry Pi, macOS.

Reports, good or bad, are welcome.

Installing

Install the Digistump AVR core first (on the Digispark Pro, with the fix above). Then clone this repository into your Arduino/libraries folder, or download a release ZIP and use Sketch > Include Library > Add .ZIP Library.

Build for the Digispark with the 16.5 MHz clock option: the core's default, "16 MHz - No USB", compiles but has no USB.

arduino-cli compile --fqbn digistump:avr:digispark-tiny:clock=clock165 MySketch
arduino-cli compile --fqbn digistump:avr:digispark-pro MySketch

Don't install it next to DigiCDCFast if your sketches include DigiCDCFast.h: both libraries have that header. (It was called DigiCDCDual while it did only two ports.)

Repository layout

library.properties     Arduino library metadata
src/                   DigiCDCMulti.h (any count), DigiCDCDual.h and DigiCDCTriple.h,
                       DigiCDCFast.{h,cpp}, DigiCDCDescriptor.h, DigiCDCMedium.h,
                       DigiCDCBuffers.S and DigiCDCBufferSizes.h (default buffers),
                       and V-USB (with V-USB's Readme.txt and Changelog.txt)
docs/multiport.md      what each port and each byte of buffer costs, measured
examples/DualEcho      both ports send back what they receive
examples/TripleEcho    three ports do
examples/MultiEcho     any count, for measuring; rescues itself from a host that refuses it
extras/test/multiecho.py  drives the echo examples on any number of ports
extras/test/           single-port hardware test (HostTest sketch and hosttest.py)
extras/latency/        interrupt latency probe (LatencyProbe sketch and latency.py)
License.txt            V-USB license (GPL-2.0 or GPL-3.0)
CommercialLicense.txt  V-USB's commercial license terms, as distributed with V-USB

The git history continues DigiCDCFast's, which starts from the unmodified DigisparkCDC files of the Digistump core, so every change since is visible.

License and credits

DigiCDCMulti is free software under the same license as V-USB and DigiCDCFast: the GNU General Public License version 2 or version 3, at your choice. See License.txt, which also has Objective Development's requests for projects using V-USB. The commercial license in CommercialLicense.txt is offered by Objective Development for V-USB only; it is kept because it is part of the V-USB distribution.

About

Dual/Triple-port version of DigiCDCFast

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages