The purpose of this article is a step-by-step demonstration of the process of developing the entire set of software necessary for organizing the connection of an improvised device with a computer via USB.
At the moment, most radio amateurs implement this type of connection using USB to RS232 adapter chips, thus organizing communication with their device through the virtual COM port driver supplied with the adapter chip. I think the drawbacks of this approach are clear. This is at least an extra chip on the board and the limitations imposed by this chip and its driver.
I would like to highlight the whole process of organizing such an interaction, as it should be done, and how it is done in all serious devices.
In the end, now the 21st century, the USB module is in almost all microcontrollers. This article will be about how to quickly use this module.
Since we need the device itself to demonstrate the process of writing a USB device driver, we’ll select one of the most popular debugging boards available in Russia. I have this board manufactured by OLIMEX model LPC-P2148. The board is based on the NXP ARM7TDMI microcontroller LPC2148 microcontroller. All information on the board can be obtained on the manufacturer's website at the following
link. This is how it looks.

')
The choice of the controller and the debug board is absolutely not important. the process of developing the interaction between the OS on a personal computer and the board itself does not depend on it. The microcontroller firmware development environment will be using KEIL version 4.23, which is also not critical. As a result, it is planned to implement only BULK transfer type. We will read the data array from the device to the computer, and we will transfer the state of the LEDs to the device so that it can be seen that the board responds to our commands.
For convenience of understanding, we will divide further actions into stages and we will go through them in order.
1. Adaptation of a ready-made example of a USB device under our board in order to make sure that the board is working and the USB channel is also operational. It will be like our starting point.
2. Change the firmware of the board so that it becomes an unknown device for Windows, requiring the manufacturer's driver.
3. Adaptation of the basic template, the empty driver so that Windows can correctly install it, to service our device.
4. Implementation of driver interaction with the user application.
5. Writing a Windows console application to work with our driver, and therefore a connected USB device.
6. Filling the entire system with necessary functions.
That in this article will not be. I will not describe the mechanisms of the OS, allowing you to find and install the correct driver. There will be no description of how to assemble the firmware in the KEIL environment. There will be no description of the parameters of the USB descriptors and in general there will be practically nothing to say about how the firmware works. At the end, I will provide links to all sources of information, my source codes and compiled binary files. Thus, the description of any moment not covered by this article can be easily found from the indicated sources. Understand correctly, it is impossible to fit in one article detailed information on all these topics. Moreover, there are more competent sources.
1. Adaptation of the RTX_Memory example to the OLIMEX LPC-P2148 board
The basis of the firmware to our project, we take the example of RTX_Memory supplied with KEIL. This example, when it is successfully launched, will allow us to connect our board to a computer and it will be visible there as a regular USB flash drive. In this way, we will get the firmware that deliberately correctly configures the USB module and all the peripherals necessary for the processor.
The project is located in the folder ARM \ Boards \ Keil \ MCB2140 \ RL \ USB \. The paths hereinafter I will indicate relative to the main folder where the KEIL environment is installed.
Copy the project to a separate place, load it into KEIL and collect it. Gathering without mistakes. As a result, we received a HEX file that we can flash using the FlashMagic utility.
True, you can not flash it yet, as it is obvious that it will not work on our board.
If we compare the scheme of our board and the board for which the example is written, and this is the model MCB2140 made by KEIL, then we can see differences in the connection of the D + pull-up line.
On the MCB2140 board, it is always pulled up to 3.3V, and on the LPC-P2148 this microcontroller controls this pull-up through a transistor.
Schemes of both boards are available on
www.olimex.com and
www.keil.com, respectively.
For simplicity, we will slightly change the initialization code so that our board always turns on the D + line pull-up, which will be reported by the USB_LINK LED.
In the USB_Init () procedure, we disconnect the CONNECT line from the USB module and manage it ourselves. And since there is also a USB_LINK LED on the same transistor, when we turn it on, the D + line pull-up will automatically turn on.
In addition, our board has fewer LEDs than the MCB2140. Therefore, their purpose also needs to be redefined. At this stage, I reassigned them simply to indicate read / write processes.
Since we do not have LED_CFG and LED_SUSP indicators, we comment out their use everywhere according to the project code.
Now you can build a project and flash it into the controller. By connecting the board to the computer, it can be seen that it recognizes it as an external drive and another disk with a size of only about 25 KB appears in the system and with the readme.txt file.
At this stage can be considered complete.
2. Transition from a USB drive to a unique device.
At the moment we have a device that on any computer with any OS will be recognized as an external USB drive. But we need Windows not to know how to work with our device and require a driver. The fact that the connected device belongs to the class of drives is indicated by the Interface class parameter located in the interface descriptor.
If you open the file usbdesc.c and find there this parameter, then it will be seen that it has the value USB_DEVICE_CLASS_STORAGE.
Replace it with USB_DEVICE_CLASS_VENDOR_SPECIFIC, and replace the two fields with zeros.
Now, after rebuilding the project and flashing the board, we will see that Windows no longer knows that our device is a drive and needs to provide a suitable driver.
There may be a problem. The fact is that Windows, having remembered the VID and PID of our device the previous time, as related to an external storage device, can continue to install its driver on it without paying attention to the fact that the device class has changed. The solution is simple. If the board is still detected as a drive, locate it in the USB branch of the device manager and remove the driver manually. After that, the OS should start asking for the driver.
3. Create a basic driver.
So, we have a working USB device for which you need to provide a driver.
To begin, we will write the simplest driver that will not do anything useful, except to boot into the system when our device appears on the USB bus. The driver will have a minimum code to just correctly boot and unload the system.
To write the driver we will be the most minimalist method. The code itself will be edited in Notepad, and will be collected on the command line.
First, you need to download the driver development kit from the Microsoft website. It is called Windows Driver Kit. I am using the WDK version 7600.16385.1.
After installation, we get a lot of examples, the environment for the assembly and documentation. In the start menu, you need to find the WDK section and there Build Environments. This is the so-called environment for the assembly. In fact, they provide us with a console that is already configured to assemble drivers for the desired system.
You can see that there is a separate folder for each OS, where there is a pair of Checked and Free environments. The first for the so-called Checked systems, collects the driver with additional information useful for debugging.
The second collects the driver release, which is then used.
I will use further the x86 Checked Build Environment from windows XP. This will give me a universal driver that works correctly on systems from Windows XP and later.
Now let's look for a template with which it would be most convenient to start.
The most suitable candidate was an example of a certain OSR USB-FX2 learning kit. I absolutely have no idea what kind of board this is, but the example we need is in the WDK along the path src \ usb \ osrusbfx2 \. The most interesting thing is that this is not just an example, but step-by-step training on how to make a driver for this board. Just what we need. Let's go deeper into the kmdf \ sys directory and see that there are all the steps and are in daddies. You can read more about them in the example description in the osrusbfx2.htm file.
Here I will make a small digression to make the following actions more understandable.
The fact is that since the advent of Windows NT, something has changed in the process of writing a driver. In those days, we had to directly use the functions of the OS kernel and often, just to make a dummy able to load, unload, respond to PNP events, etc. basic functions, it was necessary to study a lot of things and more than once fly out to BSOD. Then Microsoft made a model that Windows Driver called and which introduced some kind of standard or something, what the driver should look like. Much relief, I personally did not feel this. And the next step was a framework called the Windows Driver Framework. And thanks to this, life has become much easier. Now the framework assumes the implementation of all the basic actions necessary to serve the main events, and we will only have to add the functions we need in the right way. This is the technology that we will use.
We start with the first step. Launch x86 Checked Build Environment and use the “cd” command to move to the WinDDK \ 7600.16385.1 \ src \ usb \ osrusbfx2 \ kmdf \ sys \ step1 \ folder.
Run the build -ceZ command.
The build process takes place, and as a result, the objchk_wxp_x86 folder is created (its name depends on the selected environment), where we find the file with the sys extension. This is our driver. To install it, we need an INF file. Find it in the final folder of the same project. It is called osrusbfx2.inf. The only problem is that it is designed for a fee from the example. In order for this file to be able to install the driver for our board, we simply change the VID and PID values ​​for those that are written in the USB device descriptor in the usbdesc.c file. After looking through the INF file, you will notice that the WdfCoInstaller01009.dll file is still required to install the driver. It is also in the WDK delivery.
So, we copy three files into a separate folder: compiled SYS, INF, WdfCoInstaller01009.dll.
We connect our board to the computer, and when we ask Windows about the path to the driver, we indicate this folder.
We observe the usual process of copying driver files and our device appears under the Sample Device class in the device manager. Everything, the operating system is satisfied!
And here a question may arise, and how do we even know that our code is executed. In other words, I would like to get some kind of feedback from the driver. That's right, the time has come to add debug information to the driver in order to understand what is happening.
In kernel mode, the KdPrint () function displays debug information. Its use is the same as the well-known printf (). To see its output, you need to install the DbgView program. It is available on the Microsoft website at
http://technet.microsoft.com/en-us/sysinternals/bb896647 . Just keep it running and you will see the output of all debug information from the kernel mode of the OS. I usually set up a filter to display only the messages of the module I need. In my version of Step_1, I added the output to the DeviceEntry () and DeviceAdd () procedures so that it simply writes which function was invoked. By connecting and disconnecting the board, in the DbgView window you can clearly see in what order this happens.

4. Interaction between kernel and user modes.
As you know, device drivers work in kernel mode (with some exceptions), and our applications are in user mode. For interaction, the same mechanism is used as for working with files. In other words, for each connected device in the system there is a symbolic name by which it can be opened as a regular file. Well, then use the usual procedures for working with files of type ReadFile () and WriteFile (). In this part, we will add functionality to our driver that allows it to open, close, write and read data from it.
We will save the recorded data so that we can later give them away during a read operation.
The first thing to do is register your callback function for the EvtDevicePrepareHardware event, which the PnP manager will call after the device goes into the uninitialized D0 state and before it is available to the driver. In essence, this means a very simple thing, we stuck the device, the driver booted up, but maybe your device requires some setup before it becomes possible to work with it. This is the kind of setup we will do in this event. When applied to USB, at least you need to select the desired configuration. So, we register our function. To do this, add the following code to the DriverEntry:
WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPowerCallbacks);
pnpPowerCallbacks.EvtDevicePrepareHardware = EvtDevicePrepareHardware;
WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPowerCallbacks);
The second. If you pay attention to the call to the WdfDeviceCreate procedure from the driver code of the previous paragraph, you will notice that the second parameter of this procedure is passed to the constant WDF_NO_OBJECT_ATTRIBUTES. This means that the device object has no attributes. But in real life we ​​need at least one attribute. This is the so-called device context. Simply put, this is some kind of structure that refers to a specific instance of the device supported by the driver, and will be further available to us almost anywhere in the driver. For example, it may contain some kind of buffer. And it binds to the device object, not the driver. Several identical devices may be connected to the computer, which will be served by the same driver, but they will all have their own device object.
So, we will create a context structure, and initialize it, the attribute parameter, which is passed on to WdfDeviceCreate:
typedef struct _DEVICE_CONTEXT {
WDFUSBDEVICE UsbDevice;
WDFUSBINTERFACE UsbInterface;
WDFUSBPIPE BulkReadPipe;
WDFUSBPIPE BulkWritePipe;
} DEVICE_CONTEXT, *PDEVICE_CONTEXT;
WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, GetDeviceContext)
WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, DEVICE_CONTEXT);
Third. Now you need to create an interface through which the driver will be available to user-mode programs. Previously, the programmer himself had to hard-write the name by which access to the device could be opened through the procedure CreateFile. Now everything is easier. We only need to create an interface by calling one procedure, and to identify it we use the generated GUID. Next, in user mode, we will use the same GUID to get the name of the device file. So, here is our GUID and code linking it to the interface:
DEFINE_GUID(GUID_DEVINTERFACE_OSRUSBFX2, // Generated using guidgen.exe
0x573e8c73, 0xcb4, 0x4471, 0xa1, 0xbf, 0xfa, 0xb2, 0x6c, 0x31, 0xd3, 0x84);
// {573E8C73-0CB4-4471-A1BF-FAB26C31D384}
status = WdfDeviceCreateDeviceInterface(device,
(LPGUID) &GUID_DEVINTERFACE_OSRUSBFX2,
NULL);// Reference String
Last thing. In the first paragraph, we registered the procedure that handles the EvtDevicePrepareHardware event. Now you need to write it. I will not rewrite its text in the article, I think it will be easier to look at the source code. I can only say that in this procedure, we prepare everything that is needed for the subsequent work of the driver with the connected device. Specifically, we create a USB device object, select the desired configuration, and save in the context of the device the channel identifiers related to the BULK endpoints of the interface implemented in the device. We will need these identifiers later to implement the data transfer. For clarity, I added the output of the parameters of the channels in DbgView. It can be noted that their parameters are nothing but the same values ​​that we entered in the endpoint descriptors in the usbdesc.h file of the firmware.
So, now you can rebuild the driver again, and update it in the system. At the moment, our driver can no longer just be loaded. He is already able to configure the connected device, and, most importantly, has become available for programs from user mode.

5. We work with the driver from user mode.
Now we will write a simple console program that will only try to access our driver. As you remember, at the moment our driver is not able to do anything else, except to give an opportunity to gain access to himself.
Working with devices reduces to opening them as a regular file, and writing and reading data using the usual WriteFile and ReadFile procedures. There is also a very useful procedure DeviceIoControl for organizing interaction with the driver, which is beyond the format of working with files, but we will not use it. The file opens with a normal call to CreateFile, only we need the file name. And here we have the GUID that we tied to the driver interface. I will not describe the entire procedure for obtaining a name via the GUID, and I honestly admit that I completely took it from the examples of the WDK. The GetDevicePath procedure gets the GUID and returns the full path to it.
The file is open. Add a couple of calls that will write and count from the file a dozen bytes.
But back to our driver. In the user program, we already write to the driver and read from it, but the driver code itself knows nothing about it. Correct the situation.
The logic here is the same as with EvtDevicePrepareHardware. We need to register callback functions that will be called when the procedures for reading from the driver or writing to it occur. This is done in EvtDeviceAdd. It is necessary to initialize the I / O queue, fill its fields with pointers to our callback functions and create it by hooking it to the device object. Go:
WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&ioQueueConfig,
WdfIoQueueDispatchParallel);
ioQueueConfig.EvtIoRead = EvtIoRead;
ioQueueConfig.EvtIoWrite = EvtIoWrite;
status = WdfIoQueueCreate(device,
&ioQueueConfig,
WDF_NO_OBJECT_ATTRIBUTES,
WDF_NO_HANDLE);
In addition to declaring read and write procedures, you need to remember to implement them. At this stage, I just put the stubs that display the transferred data in DbgView and give an array of 10 bytes when reading. You can see their code in the source code. There is nothing interesting, but I advise you to pay attention to working with memory. It is necessary to receive buffers according to certain rules. Our data moves between the kernel and user modes. The screenshot clearly shows how we send data to the driver and it appears in the DbgView window. Then we read the package from the driver and get it in the output of the console application.

6. Making the driver useful.
So it's time to make our driver useful. At the moment it is communicating with the user mode but does not work with the real device. And all that remains for us to do is to add a code in the procedure for recording, which sends data to the device, and in a reading procedure, a code that receives data from the device. In the source code, you can see how the procedures serving I / O in the driver have changed very slightly. We just transfer our buffers further to the USB kernel subsystem, and it will do everything as it should.
Before we begin the actual transfer of data between the PC and the device, we still need to change the firmware of the device so that it somehow reacts to our data.
Let's change a little the code in processing the data reception event so that if the first received byte is 0x01, then we turn on LED_1, and if it is 0x02, then we turn on LED_2. And since After writing to the device, we immediately read 10 bytes from it, then add this code too. Please note that we send the packet for transmission in the event of processing the incoming packet. This is a feature of the USB module. We need to give him the data for transmission in advance so that he can execute the IN transaction. And for clarity, we will pass two different arrays. Change the contents of MSC_BulkOut () as follows:
void MSC_BulkOut (void) {
BulkLen = USB_ReadEP(MSC_EP_OUT, BulkBuf);
LED_Off( LED_RD | LED_WR );
if( BulkBuf[ 0 ] == 0x01 )
{
USB_WriteEP( MSC_EP_IN, (unsigned char*)aBuff_1, sizeof( aBuff_1 ) );
LED_On( LED_RD );
}
else
if( BulkBuf[ 0 ] == 0x02 )
{
USB_WriteEP( MSC_EP_IN, (unsigned char*)aBuff_2, sizeof( aBuff_1 ) );
LED_On( LED_WR );
}
}
And in the MSC_BulkIn () procedure, we will comment out all the code, leaving it completely empty.
The result of the entire bundle you see in the screenshot.
In this case, the board itself blinks with two LEDs.

That's all. We wrote a firmware and a full-fledged driver for our own USB device. If you start the transfer in blocks of 4kb, you can achieve a speed of 800 KB / s.
As you can see, the driver text is quite simple and contains only about 250 lines.
In the article I described only the main steps that need to be taken to get a workable driver. More information on the procedures used must be read in the WDK. Moreover, now this documentation has become quite pleasant to read and they are replete with examples.
The full archive with source codes can be downloaded
here.The archive contains folders named for items, each containing the final result, which we achieved in the corresponding item.
I hope the article turned out to be unlike the “how to draw an owl” guide, and someone will be useful.