USB for Microcontrollers — Part 2: Firmware

Manuel Bl.
6 min readOct 1, 2020

--

Photo by Michael Dziedzic on Unsplash

In this part, the firmware to turn an LED on a microcontroller board on and off is implemented. In contrast to the typical blink example, the program will run on a computer and send commands via USB to the MCU to control the LED.

This is part 2 of a 4 part series:

Hardware

For the device implementation, a microcontroller (MCU) with an integrated USB peripheral is needed. Such microcontrollers are offered by all major MCU manufacturer. For this tutorial, a STM32 Blue Pill is used. It is an inexpensive board with a STM32F103C8T6 MCU. Even if you are using another MCU, the major concepts shown in this tutorial still apply.

STM32 Blue Pill

The board can be programmed via a serial connection or via the SWD debug port (the four pins on the short side of the board). It is strongly suggested to use SWD port as it is far more convenient, and it also enables on-device debugging. A debug adapter such as ST-Link or J-Link is required. Clones of ST-Link 2 adapter are available at very low cost.

ST-Link Debug Adapter (Clone)

Firmware Tooling

To implement the software, LibOpenCM3 is used. The framework has an excellent USB implementation and is easy to use. Other options would have been the STM32Cube framework — unfortunately, its USB implementation is unnecessarily complex. The popular Arduino framework supports certain USB classes on some MCU boards but lacks good support for custom protocols.

For both example projects, both the LibOpenCM3 and the STM32Cube is given. But only the LibOpenCM3 code is discussed.

The project itself is set up with PlatformIO. It is recommended to use it as an extension in Visual Studio Code. All the code can be found in the usb-tutorial GitHub repository.

Blink Project

The goal of the Blink Project is to have a blink program running on a laptop or desktop computer and controlling a LED on the Blue Pill board. In order to do so, the blink program sends commands over USB to the MUC board.

Blink Project

In the project, the Blue Pill implements a USB device with endpoint 0 only. In addition to the required commands for device discovery and configuration, two custom commands are added:

  • Turn LED on
  • Turn LED off

Endpoint 0 is the control endpoint. It implements control requests. The format of the requests is given by the USB standard. It includes so called vendor requests, i.e. it includes a range of codes that can be used without interfering with the USB standard, not even with future enhancements. So the LED commands are implemented as vendor requests.

USB Descriptor

For such a simple project, the main implementation is to provide the descriptor. With LibOpenCM3, it consists of four elements (see usb_descriptor.c):

  • Device descriptor
  • Array of configuration descriptors
  • Array of interfaces
  • Array of interface descriptors

All arrays have just a single element for this project. As endpoint 0 does not need to be declared, there is no array of endpoint descriptors. It will be needed in the next project.

Note that the interfaces are described in two separate arrays. This is specific to LibOpenCM3. It will combine it into a single level at run-time.

When studying the code, you will also note that strings are in a separate array. Within the descriptor, the index into the array is given. This is how the USB protocol works: strings are requested separately.

USB Device Initialization

The descriptor is required for the device initialization. Somewhat simplified, the code looks like so (also see main.cpp):

rcc_clock_setup_in_hse_8mhz_out_72mhz(); ❶rcc_periph_clock_enable(RCC_USB); ❷
rcc_periph_reset_pulse(RST_USB); ❷
usb_device = usbd_init( ❸
&st_usbfs_v1_usb_driver,
&usb_device_desc,
usb_config_descs,
usb_desc_strings,
sizeof(usb_desc_strings) / sizeof(usb_desc_strings[0]),
usbd_control_buffer,
sizeof(usbd_control_buffer)
);
usbd_register_set_config_callback(usb_device, usb_set_config); ❹nvic_set_priority(NVIC_USB_LP_CAN_RX0_IRQ, 2 << 6); ❺
nvic_enable_irq(NVIC_USB_LP_CAN_RX0_IRQ); ❺

The MCU is configured for a main clock of 72 MHz including the required 48 MHz USB clock ❶ and the USB peripheral is enabled ❷. The main step is to initialize the USB device by a call to usbd_init, which takes the device descriptor, configuration descriptors and string array discussed above plus a buffer required for processing control requests ❸. Finally, the USB low priority interrupt is enabled ❺. (❹ will be covered later.)

Registering and Handling Control Requests

Handling all mandatory control requests is a major task. Fortunately, LibOpenCM3 takes care of it. For our project, two commands for turning the LED on and off need to be added.

Control requests have a fixed structure. Vendor specific request types (request type 2) may be used for custom purposes such as controlling an LED.

Control requests for controlling LED

To handle a vendor specific request type, a control callback is registered. In the registration, the request type vendor and the recipient interface are specified. So the callback will only be call for these kind of requests (also see main.cpp):

usbd_register_control_callback(
usbd_dev,
USB_REQ_TYPE_VENDOR | USB_REQ_TYPE_INTERFACE,
USB_REQ_TYPE_TYPE | USB_REQ_TYPE_RECIPIENT,
led_control
);

The control callback cannot be registered when the USB device is initialized as all callbacks are removed when the device is configured. So it has to be registered when the device configuration is set. It is possible to register a callback for when the device configuration is set. So the setup looks like so:

  1. Device initialization: register callback for setting configuration (see ❹ above)
  2. Set configuration callback: register callback for control request
  3. Control request callback: handle LED command

Handling the control requests is straightforward: check the vendor specific request code and interface number and — if they match — turn the LED on or off. It they do not match, indicate that the next request handler can take care of it (also see main.cpp):

usbd_request_return_codes led_control(
usbd_device *usbd_dev,
usb_setup_data *req,
uint8_t **buf, uint16_t *len,
usbd_control_complete_callback *complete
) {
if (req->bRequest == 0x33 && req->wIndex == 0) {
if (req->wValue == 0) {
gpio_clear(GPIOC, GPIO13);
} else {
gpio_set(GPIOC, GPIO13);
}
*buf = nullptr;
*len = 0;
return USBD_REQ_HANDLED;
}
return USBD_REQ_NEXT_CALLBACK;
}

The last piece for the USB code to work is to call usbd_poll, either from the USB low priority interrupt handler (as shown below) or frequently from the main loop.

extern "C" void usb_lp_can_rx0_isr()
{
usbd_poll(usb_device);
}

Test Device

If the code is compiled, uploaded to the Blue Pill and the Blue Pill is connected to a computer via USB, it should be discoverable.

On a Mac, click About This Mac in the Apple menu, click System Report… and then select USB in the left column. It should display information about the device:

macOS System Report

On Windows, open Device Manager and expand Universal Serial Bus devices. It should show a device called Blinky:

Windows Device Manager and Driver Details

By double clicking, the Properties windows open. Click on Driver and Driver Details to open yet another window. It will show winusb.sys as the first driver. If you have ever used Zadig (a tool for installing and changing USB drivers) on the computer, it will have left it traces and the screens will look slightly different.

Now that the device is working, let’s move on to the host software…

Part 3: Host Software and Device Drivers

--

--