The Pico SDK provides a convenient built-in USB interface implementation that provides both a CDC serial endpoint and a custom vendor endpoint that can be used to reset and program a board without having to enter DFU mode (which, particularly on official Raspberry Pico board, requires unplugging the USB cable and plugging it back in).
Unfortunately, this isn't good enough for measurement and instrumentation devices; although it's possible to use serial-over-USB to communicate between devices or between a device and a controller, the most widely-implemented standard (called VISA), requires the use of a different interface protocol called USBTMC (USB Test and Measurement Class).
Luckily, TinyUSB supports USBTMC out of the box, and therefore we don't have to go through the tedious process of defining our interface. Less luckily, this means that we can't simply use the Pico SDK's built-in USB interface implementation, and we must instead dive deep into the bowels of TinyUSB itself.
A 30-second USB primer
First, a bit about the USB protocol (but just a little bit, because the topic is really complex—this is just a very high-level overview, and I have skipped over a lot of information).
At its basic, USB 2.0 is a half-duplex serial protocol; from a physical viewpoint, there is only a single stream of data, and either the host or the device talk over it at any one point in time. All communication is controller by the host, which tells the device when to listen and when to talk; this ensures that the timing of the transmissions is very predictable, and tends to make devices easier to implement. (It also means that there is no physical out-of-band mechanism for the device to interrupt the host, and interrupts are instead handled through polling.)
Each device can define one or more logical interfaces that each belong to a specific class. Classes, in turn, describe the purpose of the interface: CDC for emulating a serial port, HID for keyboard and mice, TMC for test and measurement instrumentation, and so forth. The vendor class, which the Pico SDK uses for its reset interface, is a catch-all of sorts, and can be used for arbitrary communication with a device whose functionality doesn't fall neatly within any of the pre-defined classes. Using the right class for a device is important, because its nature provides an important clue to the host operating system of how it should work with it, and typically decides which drivers should be loaded and enabled to deal with it.
Devices, in turn, expose one or more endpoints, which describes virtual pipes through which specific kinds of data flow. For example, control endpoints are used to exchange small amounts of data with predictable timing, whereas bulk endpoints are meant to send or receive large amounts of information, but without any time guarantees, and so forth.
Implementing a custom TinyUSB stack
In the Pico SDK, as is the case with many embedded systems, USB functionality is provided by TinyUSB, an open-source library designed to provide end-to-end support for both low- and high-level interaction between hosts and devices. TinyUSB supports a pretty wide range of platforms, and, as a result, tends to be written for the lowest minimum denominator, using only static memory allocation, as well as minimalistic data structures and code structure.
This makes working with it sometimes difficult, especially at the beginning, partly because of the underlying complexity of the USB protocol, with its decades of caked-on incremental functionality layers, and partly because its code is dense and not entirely well commented.
Still, once you get the lay of the land, it's not that hard to build a completely custom stack that does what you need it to. (It is unfortunate, though, that—unless I really missed something obvious—the Pico SDK team didn't think of using a mode modular approach when they built their stack.) And, to be clear, having TinyUSB is much preferable to the alternative, because writing the entire USB stack from scratch would be a pretty harrowing prospect.
Anyway, on to the actual implementation. The first step is to define a series of data structures that “describe” the various interfaces that we intend to support and their endpoints. This is probably one of the hardest part of the project, because you need to understand how the various USB interface classes are defined, and then find how those definitions are actually handled by TinyUSB. Luckily, the library comes with a large number of examples, so the problem is not completely intractable.
Next, we need to intercept a number of callbacks that TinyUSB makes into our code in response to various USB-related events, such as when a message is received or the interface is ready to send data. It's important to note that, because TinyUSB uses statically-allocated memory, it's up to us to split large data chunks into smaller ones that can fit in its buffers.
Finally, we can build our own high-level interface that ties everything together and allows our code to provide the functionality we need to communicate over the various interfaces. Since we use FreeRTOS, we also provide a task whose job it is to continuously call the tud_task() function, which encapsulates TinyUSB's main loop.
Configuration and WebUSB
Since we're building our own stack, we can afford to make it highly configurable. In its current incarnation, the our interface class allows us to change the vendor and product IDs, as well as the vendor and product name strings. “Real” vendor and product IDs can be obtained by becoming a member of the USB Implementers Forum (sadly, at the cost of $5,000), or by applying for a free one from the Raspberry Pi foundation.
Since we provide our own vendor interface, something else that we can add is support for WebUSB's capabilities descriptor. This allows us to specify the URL of a landing page to which compatible browsers (Chrome and Firefox) will automatically direct the user whenever our device is plugged into the USB port of a computer. This can be very handy to direct users to additional information or a Web-based application that can be used in conjunction with our hardware.
You will find the code for our USB interface in the add-usb-support branch. It's baked right into the application template, which means that, under normal circumstances, you won't have to worry about adding it to your code.
Marco Tabini
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.