Multiple virtual COM ports (CDC ACM connections) via USB

Hello,

my goal is to enable two virtual COM ports via USB, where one is used for the Zephyr console and debug output, and the other is used for my software's communication interface. The debug console is already running fine. I'm using the nRF5340 SoC.

I already had a look at the cdc_acm and cdc_acm_composite samples, but I have trouble combining the configurations with my existing debug console configuration.

I use the following proj.conf:

CONFIG_PRINTK=y
CONFIG_ASSERT=y
CONFIG_GPIO=y
CONFIG_ZERO_LATENCY_IRQS=y

CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_PRODUCT="My USB product"
CONFIG_USB_DEVICE_PID=0x0004
CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=y

CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y
#CONFIG_UART_INTERRUPT_DRIVEN=y

CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="My BT Device"
CONFIG_BT_HCI=y

CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048

And a extended my devicetree overlay with the cdc_acm_uart1 entry. (cdc_acm_uart0 was used for the Zephyr debug console and referenced in chosen {...} ).

&zephyr_udc0 {
	cdc_acm_uart0: cdc_acm_uart0 {
		compatible = "zephyr,cdc-acm-uart";
	};
	cdc_acm_uart1: cdc_acm_uart1 {
		compatible = "zephyr,cdc-acm-uart";
	};
};

First, I have two questions about the configuration:

  1. I'm using CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=y for the debug console. The samples have this setting disabled (=n). Is there anything I have to take care of?
  2. The samples seem to enable some interrupt driven drivers (CONFIG_UART_INTERRUPT_DRIVEN=y). I originally had this configuration disabled (not mentioned) in my zephyr console version, and it worked just fine. What is the impact of this setting?

Second, I have to find a way how to access the cdc_acm_uart1 instance in my software. I didn't succeed yet as the samples access the driver by choosing just some device that fits the device class, but I need the specific one. The composite sample is so confusing on accessing the device instances that it didn't help either. I tried dev = DEVICE_DT_GET(cdc_acm_uart1); but this line fails during compiling. I tried dev = DEVICE_DT_GET_OR_NULL(cdc_acm_uart1); but the macro returned zero/null. How can I access the device for handling the data?

Also, I'm a bit stressed by seeing the uart implementation in the samples, using "interrupt handlers" while the cdc_acm interface is just a software layer and the actual interrupts should happen on the USB interface's driver, not the uart implementation. Isn't there a more convenient driver implementation? If I implemented such a driver in FreeRTOS, I would just define queues that handle the data (a send queue that invokes a higher priority send task once data is queued, and a receive queue that just holds all received data for polling by the software. Some virtual uart (cdc_acm) interface like this would be much more convenient than simulating uart with interrupts (and dynamically enabling/disabling interrupts, which is kind of opaque in the samples, too).

  • Hi Torbjørn,

    Wouldn't this be just as cumbersome as using the interrupt driver? 

    What problem would this feature solve? 

    If you are polling on a timer it will work more or less exactly the same way as a timeout. The only difference I can see with the polling method is that you have more exact control with the timing, since you can choose to poll whenever you want.

    Benefit #1: No preemption, which means better performance in realtime systems

    Benefit #2: Less complexity and more performance: You can parse the data immediately in the task's context, byte by byte, without the need to copy over the data to a second buffer in the interrupt handler and handle it later with at a lower priority.

    Let's say I parse data byte by byte to detect my packet boundaries. In a polling task, I can immediately handle the packet after parsing the last byte, then start over with my packet buffer, continuing with the next byte of the recently obtained polling buffer.

    Benefit #3: I do not need the timer and ppi modules, they will be fully available for other functions. Thus, less complexity in the driver implementation, too.

    There is no way to read the number of received bytes unfortunately. The only way to force a flush of the RX buffers is to disable RX, but unless you use HWFC this is risky since the UART RX will be disabled for a moment and you might lose incoming data. If HWFC is used the flow control pins will be de-asserted and communication should be delayed until the UART RX is enabled again. 

    Oh, I resolved that already in the ticket I linked last time. Let me copy over the flow chart:

    In short, you manually trigger the STOPRX task, and the ENDRX event automatically triggers STARTRX, thus performing a buffer switch. All you have to do is wait for the ENDRX bit in software, and the old buffer is available for processing. You can do this without interrupts/preemption.

    Nordic builds such beautifully designed hardware. But it seems most times, you have to go for bare metal to get all of the benefits...

    By the way, you're right, the RXD.AMOUNT value is only updated after STOPRX is triggered. If there would also be a dynamically updated variable, it would be possible to use a single EasyDMA buffer as a circular fifo (with the tail being implemented in software).

    Interesting. Did you do the math to check whether or not the UART bandwidth is sufficient for the data you are trying to send? 

    I'm using the USB interface, so configured UART speed does not matter... or do you mean the USB speed limit? My data was sent at less than 1 Mbps, the USB device should work at 12 Mbps (full-speed) according to product specification.

    I am a bit curious how you are using polling mode though, as far as I can tell the CDC ACM driver will only work with the interrupt driven API, and actively selects it when you enable the USB_CDC_ACM configuration as shown here.

    Now I'm a bit confused, too. If I set CONFIG_UART_INTERRUPT_DRIVEN=y in prj.conf, the line becomes bright blue and I don't get the hover message that this value was already set elsewhere that I get on the dark blue configuration lines, so I assumed it was set to =n. But when I check for the definition in VS Code (F12), I get forwarded to the sparkfun_sara_r4's kconfig.defconfig which sets it to =y (although I do not use this shield). Maybe the lookup feature of VS Code is not so reliably...

    That said, whether I have CONFIG_UART_INTERRUPT_DRIVEN=y in my proj.conf or not, I can still use the polling interface with uart_poll_in() and uart_poll_out(). I guess it is still possible as I don't register an interrupt handler...

    Also, like most engineering companies we are chronically low on software resources, and have to prioritize where to focus our time Wink

    Haha, I know too well... it is so sad. I would implement a nice driver myself if I had the time...

    Best regards,

    Michael

  • Hi Michael

    puz_md said:
    Benefit #1: No preemption, which means better performance in realtime systems

    Hm. No context switch I guess, but the underlying driver would still run the interrupt, it would just be decoupled from the application side polling. Also if you need low latency you will get overhead from the polling, that an interrupt driven approach would avoid. 

    puz_md said:
    Benefit #2: Less complexity and more performance: You can parse the data immediately in the task's context, byte by byte, without the need to copy over the data to a second buffer in the interrupt handler and handle it later with at a lower priority.

    I would agree the handling could be a bit less complex, but I am not convinced the performance is better. Quick parsing and packetization should be manageable in the interrupt directly, but once you need to act on those commands and call various other API's I agree you would need to defer to thread context. 

    puz_md said:
    Nordic builds such beautifully designed hardware. But it seems most times, you have to go for bare metal to get all of the benefits...

    I agree that going for the standardized Zephyr way will often hide unique Nordic HW features, but it is important to keep in mind that using the nrfx drivers, or direct register access, is possible without removing Zephyr altogether. 

    puz_md said:
    By the way, you're right, the RXD.AMOUNT value is only updated after STOPRX is triggered. If there would also be a dynamically updated variable, it would be possible to use a single EasyDMA buffer as a circular fifo (with the tail being implemented in software).

    This is one of those examples where the hardware could have been better designed. The lack of this is feature is why you need the dedicated PPI channel and TIMER module to accurately count bytes in async mode. 

    puz_md said:
    I'm using the USB interface, so configured UART speed does not matter... or do you mean the USB speed limit? My data was sent at less than 1 Mbps, the USB device should work at 12 Mbps (full-speed) according to product specification.

    My bad, I was thinking of UART baudrate, yes. The actual USB throughput is quite a bit lower than 12Mbps, but it should be higher than 1Mbps for sure. 

    puz_md said:
    That said, whether I have CONFIG_UART_INTERRUPT_DRIVEN=y in my proj.conf or not, I can still use the polling interface with uart_poll_in() and uart_poll_out(). I guess it is still possible as I don't register an interrupt handler...

    I haven't tested this, but it would make sense since a lot of Zephyr drivers work this way. Using a callback is optional, and the driver will skip the callback if the value of the callback pointer is null. 

    The VSCode intellisense doesn't have full insight into what the build system is doing unfortunately, so the jump to definition feature won't always work as expected. If there is some conflicting configurations set you should see a warning in the build output (just make sure to run a pristine build). As mentioned earlier checking the autoconf.h file will tell you whether it is actually set or not. 

    Best regards
    Torbjørn

Related