制作 USB 设备 – 您的第一个小工具的端到端指南
Making USB devices – end to end guide to your first gadget

原始链接: https://popovicu.com/posts/making-usb-devices/

本文提供了有关创建 USB 设备的说明,特别关注 USB 技术的基础知识以及使用 STM32 Nucleo 板构建简单的支持 USB 的微控制器项目。 以下是 100 字的简明解释: USB 是一种通用串行总线技术,可实现电子设备之间的数据交换和电力分配。 其串行特性允许数据位按顺序传输。 关键组件包括电源、接地和用于发送信号的差分数据对线(D+ 和 D-)。 本文重点介绍如何使用支持 USB 的微控制器和 STM32 Nucleo 板制作一个简单的串行端口设备。 阅读器识别微控制器上电源、接地和数据的合适引脚,并在 STM32CubeIDE 中设置中间件和软件包。 生成必要的代码后,阅读器构建 Elf 文件,将其放到板上,并观察主要操作系统成功将其识别为 COM 或串行端口。 最后,阅读器连接、发送命令并通过控制板载 LED 来验证操作。 文本强调了设置过程中的改进空间,并表达了作者对基于 Linux 的解决方案的偏好。

USB 是一种流行的外围设备和设备连接标准,可实现计算机和各种电子产品之间的直接通信。 对于那些刚开始创建 USB 设备的人来说,与 ESP32 或 Arduino 生态系统等更简单的选择相比,使用 ST 等微控制器可能会涉及额外的复杂性,包括多个步骤和额外的工具。 这些系统提供预构建的解决方案,允许用户开发 USB 项目,而无需广泛处理差分对、USB 描述符代码、端点和驱动程序配置。 USB 最初被设计为个人计算机的即插即用技术,因此建议初学者利用制造商提供的现有库和软件。 要创建高效的数据传输,请考虑实施批量传输而不是控制或同步方法,以获得更高的吞吐量,而无需复杂的计算或处理描述符信息。 此外,在选择开发库时,请始终确保许可条款的兼容性。
相关文章

原文

Today we’ll build some USB devices. By this I mean devices that can be plugged into your computer and be recognized by them. The goal of this writeup is to be your first article to read when embarking on developing USB devices.

A small disclaimer first: I do not consider myself an expert on USB. Please don’t consider this some sort of an authoritative guide, think of it more as a documentation of my little project towards building the simplest possible E2E USB device. It’s also an index to some really good material that other people have made that goes into a lot of detail.

Table of contents

Open Table of contents

Background

I’m sure I don’t need to explain why USB devices are useful. They’re all around us and our daily computers have plenty of USB ports. Even if they don’t, USB hubs can open up the possibility of more ports. This is where we plug in various devices that extend the capabilities of our machines. I’ll refer to these USB devices simply as devices, while referring to the machines we’re extending as hosts.

The goal of this article is to give you the simplest end-to-end journey to a working USB device. We’ll cover everything from the physical connection between the USB device and the host, and developing a super sample application that interacts with our USB device (from the host).

What is USB?

Let’s cover first what exactly is USB, and for that, I’ll quote the first paragraph from Wikipedia:

Universal Serial Bus (USB) is an industry standard that allows data exchange and delivery of power between many types of electronics. It specifies its architecture, in particular its physical interface, and communication protocols for data transfer and power delivery to and from hosts, such as personal computers, to and from peripheral devices, e.g. displays, keyboards, and mass storage devices, and to and from intermediate hubs, which multiply the number of a host’s ports.

The rest of this article will unpack this description piece by piece. To begin, though, we need to know first and foremost that USB is a serial bus. Bits go onto the bus one by one, in contrast to parallel buses. I don’t recall seeing a parallel bus for decades now. The last time I saw a parallel bus was to connect a hard disk to the motherboard with a ribbon cable. Those are outdated now, and modern buses are primarily serial. Again, this means bits go one by one, rather than in parallel. I won’t get into the details of this (mainly because I’m not an expert myself) — my understanding is that simply it’s really hard to make parallel transmission work efficiently in this context, so simple protocols end up in more elegant and faster devices. This mental model doesn’t capture nearly all the complexities, but still gives us enough to get started with USB: this is a way to exchange bits serially between the host and the device.

However, USB is more than just a spec on how to connect two devices and send bits back and forth. It also, per the quote above, captures some parts of the communication protocol. Let’s start unpacking, and we’ll focus on USB 2.0.

USB wires

You’ve likely seen different kinds of USB plugs and cables, but they all boil down to the same thing. Inside the connection are the wires to provide power from host to the device and to transfer the bits back and forth. Let’s focus on the typical USB 2.0 connection, these are the wires you’ll see inside:

  1. +5 V wire, this is where the device gets its power from, if powering from the host.
  2. D- and D+ wires: I lump them together as one item here, since they jointly work to transfer 1 bit (not 2!) as a differential pair. Much more info on that below.
  3. GND wire: unsurprisingly, this is ground.

Some connections may have a few more pins, like the ID pin, but since this is the bare minimal writeup on USB, let’s skip that now. We’ll be working only with the 4 wires described above.

A note on USB-C

USB-C is super common these days. What I wrote above for the USB wires still somewhat applies to USB-C, but it’s slightly different in that case. One of the main differences, obviously, is the fact that you can plug a USB-C device either way and things should work. At the core of it, there’s still a differential pair.

Let’s just focus on one very important thing here: if a device connects via USB-C, it still doesn’t mean anything when it comes to the speed, or even which USB version it is. It could still be a USB 2.0 device, or it could be a more modern USB 3.0 device.

With that out of the way, we won’t be discussing USB-C anymore here.

Data over a differential pair

If you’ve never seen differential pairs before, then having 2 wires somehow transmitting 1 bit may stump you. In the most basic computer architecture course, you learn that you only need one wire to signal a digital bit. The wire’s voltage (or should I say potential?), in relation to the ground (point of 0 volts) signals what is the value of the bit. If you’re playing with something like Raspberry Pi, you know that if you set a GPIO pin to logical 1, it gives you 3.3 V, 0 V otherwise.

And this is fine over something like super short distances, within a chip, or even within small traces on the PCB. However, we frequently have a need for longer distance wired connections. Your keyboard and mouse are at least a few feet away from your computer, for example.

This is where the differential pairs come in. Instead of having one wire at voltage V, in relation to GND, we now have two wires. One is still at voltage V, while the other one is the polar opposite at -V. When a differential pair comes out of one component into the other, what the other component considers is the voltage difference between these two wires. V - (-V) = 2V. There seems to be little benefit in this — we just got double the voltage we otherwise would, and that’s true in this model; however this model doesn’t paint the whole picture. We assumed our wires carry our voltage perfectly.

Let’s mark the voltages of these wires as V+ and V-. If we use a more realistic model, V+ isn’t simply equal to V, it’s equal to V plus some voltage noise, let’s mark it as Vn. Therefore V+ = V + Vn. Now, V- is similarly V- = -V + Vn. This time, when we calculate what the other device sees, it’s V+ - V- = (V + Vn) - (-V + Vn) = 2V. The voltage noise is gone! Now it’s a little clearer what the benefits of the differential pairs are.

Of course, this is a mega simplified view of things. For example, why do we treat the noise factor as the same for both wires here? For a more serious introduction to differential pairs, I’d recommend Zach Peterson’s videos on Altium YouTube channel, starting from this one:

Now it should be clearer why I lumped D+ and D- wires above as one item: these two wires carry one bit at the time. It should also be clearer why they’re identified as D, and what is plus and what is minus.

USB on a PCB

If you don’t intend to make your own hardware for a USB device and instead want to use some existing development board, you can skip this altogether, but I’d still recommend quickly scanning through it.

When you add a USB connector to your PCB from some library, you should see the pins that I listed above, and you can route accordingly. Your PCB may have a microcontroller, or a more complex SoC onboard, and you’ll definitely need to run the differential pair to the relevant two neighboring pins on the chip. For routing differential pairs, there are a couple of basic things to keep in mind.

First, the trace from the D+ pin on your USB connector to the plus pin for the differential pair on your chip should be of same length as the other trace for the differential pair.

Second, these traces should be very close to each other. For a graphical example, please check out this article, the graphic at the top should quickly illustrate what I’m talking about (again, great article by Zach Peterson).

These two principles should explain a little bit why in our (still simplistic) calculation, we assumed that the noise in our voltage is the same. The two wires are running in almost identical context and that’s how the noise ends up cancelling.

Third, and this is potentially very important, we should have a certain impedance for the signals. The calculations here are very complex and many factors go in. I recommend watching the following video for an introduction here:

tl;dr is that you’ll go to your fabricator’s website, put some parameters into the calculator, and it will calculate the missing parameters. For example, you’ll specify your target impedance for the differential pair running above a layer of ground plane, specify the distance between your differential pair lines, etc. and it will provide you with the trace width you should use in order to achieve that impedance.

I’ll link below to another great video by Zach Peterson about USB routing, but I think if you’re just getting started, the one above should be enough:

Different speeds of USB

We said initially we’re focusing on USB 2.0, but that still doesn’t fully tell us what’s the speed we’re reaching here. Even within the same USB version, we can have different speed levels. For example, you can run USB 2.0 at full speed, which is 12 Mbit/s, but you could also run it at high speed, which is 480 Mbit/s. The device and the host need to figure out what’s the speed they’ll be utilizing when they connect.

A quick note on the speeds over PCB

Again, you can skip this if you aren’t making your own PCB, but it still makes sense to try to parse through this, it could be useful.

While there seem to be many requirements for correctly routing USB over the PCB, the hardware you’re connecting on both ends, the host and the chip in your device, can be fairly forgiving, especially if you’re not going for the high speed (and at full speed, 12 Mbit/s should be more than enough for a basic prototype).

One of the big things about reaching high speeds is hitting the right impedance. Lots of thought needs to go into this, but from my understanding, if you’re going for full speed, you can get away with a lot (and we will in our working example below!). The trace widths don’t matter all that much, especially if your traces are short from the USB connector to your chip.

Protocol and software layers

I think so far we’ve covered a decent chunk of hardware, and we should discuss what about the protocol itself and how does software (on both sides) use USB.

I’ll go ahead and simply recommend watching this video:

This video talks about USB from the Linux perspective, which is great, but we still haven’t covered Linux here. That said, the video also covers USB broadly, and is pretty well packed into ~45 minutes of content. For the purposes of this article, I’d recommend watching until the Linux example. There’s a lot of background on what the USB frames look like, how there are different endpoints involved and so on. There’s a great bit on configurations and how one device can fulfill multiple USB functionalities. I think if there is one thing to take away from that video, it’s to think about USB as a network of devices, and try to understand that. I won’t try to reproduce any of the details here, I highly recommend watching the video.

USB device classes and how hosts use them

Hosts obviously need drivers in order to abstract away the hardware and handle the interaction with their components. However, it would be unreasonable to expect that different kernels have drivers implemented for every single USB device. Instead, operating systems recognize different classes of devices.

Some classes are mass storage devices (probably the most common everyday use case), some are serial devices, and so on. Therefore, there is some level of uniformity across devices here. In our example below, we’ll build a sample device that’s simply a serial port from the host’s perspective.

Building a serial port device

Our device will be very simple — we will have a USB-capable microcontroller that is powered by the host and serves the host by simply turning on an LED when requested. The host will see this device as a simple serial port device.

I see two main approaches in this kind of scenario: use a microcontroller or a Linux-ready SoC. In the latter case, the kernel itself can do a bulk of work and offer you a very clean Linux API for the functionality, but that is an overkill for this simple use case, and we’ll stick with the microcontroller. I’ll do a reflection on the state of software for this kind of a use case below.

STM32 microcontroller and the Nucleo board

We’re opting for the first approach, which is using a USB-ready microcontroller and our development board of choice is NUCLEO-F103RB. In the US, it can be bought on something like Digikey for a little over $10.

When you look at this board, you can clearly see it consists of 2 pieces spliced together. The connection is almost like a perforation, and I’ve seen some videos on YouTube where people literally break this board in two along that connection by hand. I wouldn’t do it, though.

The smaller piece is the microcontroller programmer. This is where the USB connection with the computer also lies and that’s how you’ll be programming the board. Be careful though, that’s not the USB connection we’re looking for here, and this may stump you right now.

To be fully perfectly precise, there are 2 big chips on this Nucleo board. One lies on the programmer piece of the PCB, and the other one is the microcontroller you’re actually programming, and it lies on the bigger piece. The USB connection your computer establishes with the board is actually with the microcontroller on the programmer side. This programmer speaks the ST-LINK protocol, which to the best of my understanding, is just a protocol built on top of USB messaging. Your computer and the programmer MCU will exchange USB messages according to the ST-LINK protocol, and then that MCU will program your “main” MCU. How that happens on this board, I don’t know, nor should it matter for our experiment. It could happen over something like SPI, as far as we’re concerned, or maybe they could even have their own USB connection between themselves, who knows.

If you were to build your own PCB with an STM32 microcontroller, you could place one chip on the board, program it over USB and later use the same USB port for business logic between your device and the USB host. I’ll link to two videos from Phil’s Lab (fantastic materials) on building STM32-based PCBs, as well as how to do programming on these over USB. The first one is 20 minutes only, and absolutely worth your time.


Setting up the actual USB port

Now that we’ve established that the USB connection we have on the Nucleo board indeed isn’t connected to the chip that we’re actually programming, we need to figure out a way to get that USB port from the “main” chip to work.

In this exercise, once we program the chip, we’ll disconnect the ST-LINK programmer piece from the computer, and power our main chip from the “real” USB port instead. Below is how we do it, step by step. We’ll be using STM32CubeIDE to write the software, and then use STM32CubeProgrammer to drop that software onto the board, in order to keep things simple.

The first thing we need to do is identify which pins on the MCU are able to work as the USB port. We’re making a USB 2.0 device, keeping things simple, and this is what we need:

  • A pin to supply 5 V power from the host device.
  • GND pin.
  • D+ and D- pins that can handle the USB differential pair data.

GND is trivial, and so should be the 5 V power. The only detail regarding powering this board over the “proper” USB port is that you need to change the pins on jumper JP5 in order to configure the board to accept “external 5 V” supply. Check the board documentation for more details, but this is really all I had to do. With that, we’ve taken care of 2 pins.

In your STM32CubeIDE app, you should configure (through the UI) PA12 to act as USB_DP and PA11 to act as USB_DM (+ and -, respectively, of course). The end result should look like this:

Now, one important thing to note is that the chip on this board will expect an external 1.5 kΩ resistor to be present in the circuit for the USB connection to work. To make this simple, I just bought a pack of 1.5 kΩ resistors from Amazon (not the cheapest solution, though). That resistor should be pulling up the pin PA12 to 3.3 V, which you can also find on the Nucleo board. I used a breadboard to wire this up, so nothing fancy is required.

Now that we have identified these 4 pins on the board (plus the 3.3 V for pull-up) and flipped the power supply jumper, we’re ready to make the physical connection to the host. In my case, this is a MacBook Pro running Mac OS. To be able to connect my MacBook to these individual pins, I used a breakout cable from Amazon. For this particular cable that I linked, I popped off the terminal blocks (it’s all modular) and instead got the USB pins exposed. There were 5 pins, but you can ignore the S pin for this exercise. After that, I used regular jumper wires to connect these pins to my Nucleo board, as well as the breadboard where I handled the pull-up for the PA12 pin.

I’d like to quickly point back to all the previous notes about length matching with differential pairs, impedance control and so on — as you can see, we’re super relaxed here, and we will be able to achieve the 12 Mbit/s connection regardless. This is what I meant when I said that the devices can be pretty forgiving, at least at some speed.

We’re now wired up and ready to go!

Writing the software

Once you set up the USB pins in CubeIDE, you’ll get some notifications about the clock set up — go for the option where CubeIDE takes care of it for you, it should be able to fix things up. There are a few things to set up for the software part of the USB left.

In Pinout & Configuration section, you will see a sub-menu called Middleware and Software Packs. In there, you should see a USB_DEVICE option, so let’s open that.

What really matters for this exercise is to set up the mode of the device to:

Communication Device Class (Virtual Port Com)

This will ensure that your Nucleo board behaves as a serial port device (CDC) from the host perspective. Thus, your host will be able to set up the correct drivers in order to work with your custom device.

At this point, CubeIDE will also generate some C code in your program. In my main.c file, I see a line that says:

MX_USB_DEVICE_Init();

For a more step-by-step guide on all this, if the above wasn’t enough, please take a look at this video:

We’ll do pretty much the same thing as in this video, in order to enable turning on of the LED (we’ll skip turning off in this exercise). The CDC_Receive_FS routine will have this snippet:

/* USER CODE BEGIN 6 */
if (Buf[0] == '1') {
	HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, 1);
}

This call to the HAL layer will switch on the onboard LED, which is connected to pin 5 of port A.

Flashing and running

You can go ahead and build the ELF file and use the CubeProgrammer to drop the code bytes onto the board. Once that is done, you can unplug the programmer and wire the board as we described above, powering it from “external 5 V” supply.

Once your board powers on, you should be able to see it in whatever device manager your operating system has. Unless you’re running something extremely exotic, I would expect your operating system to be able to recognize the device. In the USB section of your device manager, you should see something like a “COM port”, or a “Serial port” or something along those lines.

If you want to be extra cool, you can go back to CubeIDE, head over to the USB_DEVICE menu for the middleware where we previously set up the CDC class for our microcontroller software, and take a look at the bottom section. There, you are able to change the values in the Device Descriptor section, and set up a custom name for your device. That would be reflected in your operating system’s device manager.

And for the fun part, you need to connect to your serial port and send byte ‘1’ to make the LED flip on. On my Mac OS, I can find my new device under the /dev filesystem tree. It’s listed on my machine as /dev/tty.usbmodem497A0F6739561. If you’re on Linux, you should get something similar. You may see it listed as /dev/ttyUSB0 or something like that. I’ll use Minicom to talk to this serial device, but you can do pretty much anything here, as long as it works with the serial ports on your device. I run something like this:

minicom --device /dev/tty.usbmodem497A0F6739561

At this point, I can simply tap the number ‘1’ on my keyboard, and the green LED on the Nucleo board should go green.

Conclusion

We’ve basically built a USB serial port device from scratch, and it’s recognized (hopefully) by any mainstream operating system. There’s a bit of theory in the beginning regarding building the PCBs that can do this, and I hope that was helpful as well.

I mentioned that from the software perspective, high level, there are 2 approaches: use a microcontroller or with a more complex system, let something like Linux kernel handle the software on the device side. The first approach was simpler for this exercise, but I will highlight that I’m not a huge fan of how things are with STM32 here.

This is mostly from a software engineering perspective, and I feel more comfortable sharing my opinions here, given that my professional background is mostly in software engineering. For a start, I don’t like how we have to generate a ton of boilerplate with the IDE after clicking through the UI menus. I wish that we had a library that was more flexible and parameterized in code, so that we can have something like InitUsbDevice(UsbClass.CDC), instead of going through the UIs to generate the code. There’s also a lot of boilerplate with STM32, and it’s very tightly coupled with the user code. In my view, this makes code reviews very difficult. Additionally, how exactly do we update all this boilerplate when a new version is out? This seems a bit like an afterthought with the CubeIDE, and it’s not unheard of in the embedded world. I’ve read some research that says that an overwhelming majority of the embedded devices never see a software update (this can make a lot of software engineers recoil as they read it). There are also other things that are tricky here, for example, what if one day we wanted to change our microcontroller for something different? We’re super tied into the STM32 world with this current set up.

There’s a reason why I mentioned Linux being able to act as a USB device — I believe that’s a much cleaner approach. The Linux APIs are much more solid and standardized. Things with Linux would be based on interactions with the pseudo-files and some system calls. The user space is very cleanly separated from the kernel space. Additionally, I think of Linux as the HAL layer. If our microcontroller could run Linux, we’d see a very nice view of all these devices and there wouldn’t be a need for wonky HAL libraries and so on.

That said, obviously, sometimes we want lightweight and cheap USB devices that are easy to produce. Linux SoCs are more heavyweight, obviously, and may be an overkill for a lot of use cases. I guess my main point here is that I wish there were more portable and less opinionated frameworks for building bare metal USB devices than what’s available out there. There are things that drive me away from some of them that may be widely accepted: things like frameworks dictating a particular build system that should be used, etc.

All this may or may not be a big deal for your project, it’s ultimately for you to decide. I hope I’ll have some time in the near future to play around with this other variation, using Linux itself as the way to implement software on the USB device side, but that’s for another day. For now, let’s enjoy starting up our first custom USB devices that can cleanly integrate with our standard everyday machines like our laptops.

As always, I hope this was useful.

Please consider following on Twitter/X and LinkedIn to stay updated.

联系我们 contact @ memedata.com