1. 前言

之前写了一篇详细的博文,详细介绍了Zephyr设备树(DeviceTree)的语法和Zephyr驱动模型的原理。但有些读者反馈,内容还是比较泛且杂,只感觉多了一些新的语法和规则,没有感受到这设备树和驱动模型的意义所在,希望能够结合实例来讲解。

今天本文就通过串口这样一个最常见的外设,来实际感受一下Zephyr的驱动模型。本文将会以nRF Connect SDK中zephyr/samples/hello_world例程为基础。分别添加串口USB CDC ACM低功耗串口的功能。采用完全相同的应用层代码,只需要修改config和dts即可切换。

2. Hello world工程解析

开发板我选择nRF52840DK。首先以zephyr/samples/hello_world例程为模板,创建一个新工程,我在这里把工程命名为learning_zephyr_serial

工程目录结构

|--src
| |
| `--main.c
|--CMakeLists.txt
`--prj.conf

CMakeLists.txt中先把Zephyr作为包来导入,然后把main.c添加为源码。

prj.conf目前是空的,在这里可以写一些配置用来覆盖默认的Kconfig。

例程默认没使用Kconfig菜单文件,是因为本工程太简单,没有自己的配置项,所以不需要自己的Kconfig文件。这种情况完全等价于Kconfig文件中只写了下面的内容:

source "Kconfig.zephyr"

相当于项目中只有Zephyr的菜单,可以让我们配置Zephyr系统的配置项,以及SDK中各个module的的配置项。选择板子,编译并烧录后,打开串口,reset一下,就能看到刚启动时串口输出的hello world了。

image-20231112183822534

printk输出配置

很多新上手Zephyr的读者会有疑惑,这工程里几乎没什么代码,也没看到CONFIG和device tree文件,串口到底是怎么输出的?

其实,在我们选择板子时,板子就已经自带了默认的device tree和config文件。因此编译时采用的全部是板子和Zephyr系统的默认值,我们的工程中并没有对这些默认值进行修改。

我们可以在build/zephyr/目录下看到.config文件和zephyr.dts文件。这个就是项目最终编译采用的配置项和设备树。

.config中,我们可以看到:

CONFIG_PRINTK=y

也就是启用了printk()输出的功能。

我们把这一行复制到prj.conf中(这个行为本身没有意义,因为默认就是y),然后就可以用Ctrl+鼠标左键点击这个选项,跳转到这个配置项定义的地方,就可以看到这个配置项的说明:

image-20231112184745524

当然,你也可以在Kconfig GUI中找到这个配置项:

image-20231112184921595

到这里,是否对“Kconfig定义了一个菜单,而prj.conf文件是对菜单中配置项的默认值进行修改”这句话有了一定的感受呢?

console设备与console驱动

根据此配置项的说明,我们知道printk()是Zephyr的一个内核服务,它可以让通过printk()函数打印的内容通过”console”输出。这里的console指的是一个设备,可以让Zephyr系统输入和输出字节流。

通过查看build/zephyr/zephyr.dts,可以看到:

/{
...
chosen {
...
zephyr,console = &uart0;
...
};
...
}

/chosen节点下,有很多属性。Zephyr系统内核的代码在运行一些功能时,并不在乎底层的硬件具体是什么,它只从/chosen节点下找到对应的硬件。只要这个硬件已经在RTOS初始化之前就被驱动程序初始化了,具有Zephyr标准外设接口,那么Zephyr内核就可以操作这个硬件。

例如,要想获得这里的console设备的DeviceTree Node ID,就可以用DT_CHOSEN(zephyr_console)

我们自然可以联想到,可以把console换成其他串口设备,就可以让日志从其他串口输出了。这里,可以参考我的另一篇随笔《Zephyr重定向日志打印到USB串口》

如果你只是修改设备树中的console设备,那么不管如何修改,输出日志的设备都必须是一个“串口”(在Zephyr中USB CDC ACM设备也是串口,后文会解释)。在build/zephyr/.config中,我们还可以看到:

CONFIG_UART_CONSOLE=y

原来,在当前配置下,Zephyr默认的console后端都必须是“串口”设备。

我们可以尝试把console后端改成RTT,在prj.conf中,添加:

CONFIG_UART_CONSOLE=n
CONFIG_RTT_CONSOLE=y

image-20231112191457497

然后就可以看到,printk()的日志从RTT中打印出来了。

对于探究心强的读者,到这里肯定又会有疑问:为什么把console后端改成了RTT,只改了config,设备树就不用改了?

关于这个问题,我想先传达出一个观点,那就是一个系统无论使用了什么样的框架,最终一定要落实到代码。通过在NCS中全局搜索CONFIG_RTT_CONSOLECONFIG_UART_CONSOLE,我们最终能找到这样的一个文件,${NCS}/zephyr/drivers/console/CMakeLists.txt

...
zephyr_library_sources_ifdef(CONFIG_RTT_CONSOLE rtt_console.c)
zephyr_library_sources_ifdef(CONFIG_UART_CONSOLE uart_console.c)
...

console本身作为一个中间件,也是要通过驱动程序向Zephyr提供标准console API的。在这里,CMake根据不同的CONFIG配置项,添加了不同的console驱动源码进入系统之中,进行编译。

在uart_console.c中,我们明显能看到,此驱动代码需要通过device tree来找到标准的串口设备,然后调用标准的串口API来通信。

static const struct device *const uart_console_dev =
DEVICE_DT_GET(DT_CHOSEN(zephyr_console));

而在rtt_console.c中,我们可以看到此代码不需要获取任何device tree的信息。因此,当我们选择RTT作为后端时,无论device tree中的/chosen节点中如何选择zephyr,console,对于RTT console驱动代码来说都是没有意义的。

总结

经过前面的分析,我们可以有以下结论:

首先,在Zephyr系统中有许多功能,我们可以用Kconfig的方式进行配置或裁减。

此外,Zephyr中有非常明显的“分层设计”,例如,Nordic提交nrf系列串口驱动代码,提供Zephyr标准串口API;Zephyr有console驱动代码,向更上层提供标准console API;如果console是串口驱动,它还会调用标准串口 API来把日志输出到底层串口中;由于API是标准的,因此console驱动代码并不在乎底层到底是物理串口还是USB CDC ACM设备。

3. Zephyr标准异步串口

Zephyr串口API分类

前面分析了Hello world是如何通过console输出的。在Zephyr中,console主要是用来做一些字节流的传输,用来实现一些更上层的服务,例如自定义shell命令。

用户要开发自己的程序,肯定是需要自己直接操作串口的。这里给出Zephyr标准串口API文档,供参考。

从文档中我们知道,Zephyr串口有三种不同的接口:

  • 阻塞(Polling):阻塞发送一个字节,发送完毕后才返回;接收时,有数据则读取,无数据则返回-1,接收函数不阻塞。
  • 基于中断(Interrupt-driven):串口外设在产生发送使能完毕发送完毕接收完毕等事件时都会产生中断信号,用户需要在中断服务函数(ISR)中处理这些事件。通常来说,如果是发送完毕,则看应用层是否还有新数据要发,若有则向fifo填入新的数据;如果是接收完毕事件,则把收到的数据传给应用层。这里的中断服务函数是需要用户自己实现,然后注册的,并且由于这些事件都还是比较偏向于硬件底层的事件,因此代码编写的自由度较高。我们如果在NCS中全局搜索基于中断的串口API函数,会看到各种不同的中断服务函数写法。
  • 异步(Asynchronous):如果你没有什么非常定制化的硬件相关的操作,比如非得在某个事件出现时调用一些别的功能,只是想简单高效的进行串行通信,那么更推荐用异步API。异步API是基于DMA传输的:发送时,函数不会阻塞;接收时,直接从回调函数中获取收到的数据。整个过程非常简单直观。

NCS中的例程太多,对于不熟悉的人来说,随便复制代码,很有可能出现:代码里用的是一种API,但CONFIG使能的却是另一种API的情况,最终导致程序无法运行。

本节只介绍异步串口的使用。

异步串口代码

这里我给出修改后的hello world例程代码:

【注意】
本文基于NCS v2.4.2。若读者使用v2.5.0或以上版本。下面代码中的

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void **)&evt->data.rx_buf.buf);
break;

需要改为

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void *)evt->data.rx_buf.buf);
break;

因为版本升级后k_mem_slab_free的实现不同,参数从二级指针变为了一级指针。

main.c:

/*
* Copyright (c) 2012-2014 Wind River Systems, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/uart.h>
#include <string.h>

#define RX_INACTIVE_TIMEOUT_US 2000000

// serial buffer pool
#define BUF_SIZE 64
static K_MEM_SLAB_DEFINE(uart_slab, BUF_SIZE, 3, 4);

// serial device
static const struct device *uart_dev = DEVICE_DT_GET(DT_NODELABEL(uart0));

// async serial callback
static void uart_callback(const struct device *dev,
struct uart_event *evt,
void *user_data)
{
struct device *uart = user_data;
int err;

switch (evt->type) {
case UART_TX_DONE:
printk("Tx sent %d bytes\n", evt->data.tx.len);
break;

case UART_TX_ABORTED:
printk("Tx aborted\n");
break;

case UART_RX_RDY:
{
printk("Received data %d bytes\n", evt->data.rx.len);

static uint8_t buf[128];
uint8_t *p = &(evt->data.rx.buf[evt->data.rx.offset]);

memcpy(buf, p, evt->data.rx.len);
uart_tx(dev, buf, evt->data.rx.len, SYS_FOREVER_US);

break;
}

case UART_RX_BUF_REQUEST:
{
uint8_t *buf;

err = k_mem_slab_alloc(&uart_slab, (void **)&buf, K_NO_WAIT);
__ASSERT(err == 0, "Failed to allocate slab\n");

err = uart_rx_buf_rsp(uart, buf, BUF_SIZE);
__ASSERT(err == 0, "Failed to provide new buffer\n");
break;
}

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void **)&evt->data.rx_buf.buf);
break;

case UART_RX_DISABLED:
break;

case UART_RX_STOPPED:
break;
}
}

void main(void)
{
int err;
uint8_t *buf;

if (!device_is_ready(uart_dev)) {
printk("device %s is not ready; exiting\n", uart_dev->name);
}

err = uart_callback_set(uart_dev, uart_callback, (void *)uart_dev);
__ASSERT(err == 0, "Failed to set callback");

// allocate buffer and start rx
err = k_mem_slab_alloc(&uart_slab, (void **)&buf, K_NO_WAIT);
__ASSERT(err == 0, "Failed to alloc slab");
err = uart_rx_enable(uart_dev, buf, BUF_SIZE, RX_INACTIVE_TIMEOUT_US);
__ASSERT(err == 0, "Failed to enable rx");

while(1) {
k_sleep(K_FOREVER);
}
}

代码非常简单,首先是获得device,这里的方法是用设备树节点的标签来获取节点对应的device对象:DEVICE_DT_GET(DT_NODELABEL(uart0))

main()函数中,注册异步回调函数。然后开启串口接收。这里除了device结构体指针之外,还有两项配置。一个是接收缓存、一个是超时时间。

接收缓存可以直接用Zephyr的memory slab功能。代码中用K_MEM_SLAB_DEFINE定义了3块静态的缓存区域,可以用allocate和free来进行内存块的分配和释放操作。相当于是一个私有的动态内存区域。在main()函数中,先取出了一块内存,然后传入rx_enable作为接收缓存。

超时时间,指的是异步串口产生回调的时间。如果串口空闲,没有新数据来,超过了一定的时间,那么会直接产生回调事件,即使接收缓存还未满。这里为了演示,设置为2秒超时

在回调函数中,每次接收缓存已满,或者达到了超时时间,就会产生UART_RX_RDY事件。在事件结构体中,buf是缓存的首地址,offset是本次收到的数据在缓存中的位置,len是本次收到的数据的长度。因此,本次接收到的数据的真实首地址为:

uint8_t *p = &(evt->data.rx.buf[evt->data.rx.offset]);

每次接收缓存满时,串口rx驱动代码会向应用层申请新的接收缓存,即UART_RX_BUF_REQUEST事件。这时我们从memory slab中分配一块新的内存给它即可。

当串口驱动获得了新的接收缓存时,它也会向应用层申请释放掉旧的接收缓存,即UART_RX_BUF_RELEASED事件。这时我们用memory slab的free函数将其释放即可。

本代码实现了一个回环,会把串口收到的数据继续从串口发出。

这里有一些小细节:

  1. 回调函数的形参evt,在call stack中上一层的驱动代码里是一个局部变量。在回调函数返回后,evt会被释放。因此这里如果要实现回环,需要拷贝一份到静态内中。即static uint8_t buf[128]

  2. 如果某一次接收到了很多数据,超出了buffer的剩余空间。那么这次收到的数据就会被分成两部分,产生两次接收回调。这也意味着,我们必须把接收到的数据看作是“字节流”而不是“包”。开发者应该自己实现字节流解包处理函数,例如:

    for (int i = 0; i < evt->data.rx.len; i++){
    bytes_to_packet(p[i]); // 开发者自行实现解包函数
    }
  3. 回调函数实际上运行在中断服务函数内部,因此不要做一些阻塞的行为。如果真的有计算量大的任务,可以把任务提交到Workqueue Threads。这样你就能把耗时的任务从特权模式移动到用户模式,也就是从中断内部移动到线程中。

异步串口配置

prj.conf

# use RTT as console
CONFIG_RTT_CONSOLE=y
CONFIG_UART_CONSOLE=n

# use ASYNC uart API
CONFIG_SERIAL=y
CONFIG_UART_ASYNC_API=y
CONFIG_UART_0_ASYNC=y

# use hardware bytes counter for async uart
CONFIG_UART_0_NRF_HW_ASYNC=y
CONFIG_UART_0_NRF_HW_ASYNC_TIMER=2

首先,把console改为RTT,防止日志和我们的串口数据混在一起。

CONFIG_SERIAL=y的作用是,使能Zephyr标准串口驱动;CONFIG_UART_ASYNC_API=y使能了异步API。这两项都是Zephyr的串口配置项,来自于${NCS}/zephyr/drivers/serial/Kconfig

CONFIG_UART_0_ASYNC=y来自于Nordic的配置${NCS}/zephyr/drivers/serial/Kconfig.nrfx。在同目录下的Nordic的驱动代码uart_nrfx_uarte.c中,可以让不同的串口用不同类型的API,这完全是由驱动代码实现的。因此Nordic也提供了配置项来供开发者选择。

关于最后两项配置,对性能和功耗影响很大。虽说串口API是异步的,但底层驱动的设计却有很多变化。当一个外设通过DMA传输数据时,通常来说是DMA缓存写满了,才产生中断,然后把整个缓存传给应用层。但别忘了,我们的异步串口有空闲超时功能,如果DMA缓存还没有写满,但因为串口一直没有收到新的数据,超时了,需要立即把目前已经收到的数据传到应用层。这种情况下,如何才能知道目前已经接收了多少个字节数据呢?

纯软件的方法就是,每收到一个字节就产生中断,在中断服务函数里,通过软件的方式+1,这也是大多数普通的单片机的做法。如果你不添加最后两行CONFIG配置,那么uart_nrfx_uarte.c驱动就会采用这种方法

但Nordic的单片机有独特的功能—— PPI (Programmable peripheral interconnect)。简单来说,就是每个外设都有许多event和task寄存器。event寄存器可以产生中断让CPU去处理;CPU也可以去写task寄存器让外设去执行某些工作。而PPI可以把一个外设的event寄存器和另一个外设的task寄存器连接起来,实现自动联动,而无需CPU处理

有关PPI的内容,请参考各个Nordic MCU芯片手册的外设PPI或DPPI章节。

如此一来,Nordic串口驱动可以把一个Timer配置为计数器模式(Counter Mode),并且把他的COUNT TASK与串口的接收到单个字节的EVENT通过PPI连接起来。这样计数器就可以自动记录收到了多少个字节。当接收超时的时候,直接从counter中读取计数即可。

这里的配置CONFIG_UART_0_NRF_HW_ASYNC=y就是让串口0使用硬件计数器。而CONFIG_UART_0_NRF_HW_ASYNC_TIMER=2就是让Timer2来用作串口0的计数器。

我们可以看出,其实应用层操作的uart0这个串口,其实底层并不仅仅只有UARTE0这一个外设。而是由UARTE0+TIMER2+PPI组成的复合外设。因此,不要在device tree中再去初始化timer2去做别的用途了。

到了这里,相信你也能体会到Zephyr把应用和驱动进行分层的意义。

异步串口设备树

我们并不需要额外修改设备树,直接采用默认值即可。我们可以直接查看一下我们目前使用的串口0的设备树,位于build/zephyr/zephyr.dts

uart0: uart@40002000 {
compatible = "nordic,nrf-uarte";
reg = < 0x40002000 0x1000 >;
interrupts = < 0x2 0x1 >;
status = "okay";
current-speed = < 0x1c200 >;
pinctrl-0 = < &uart0_default >;
pinctrl-1 = < &uart0_sleep >;
pinctrl-names = "default", "sleep";
};

主要属性介绍:

  • reg = < 0x40002000 0x1000 >:芯片自带的属性,外设的地址

  • compatible = "nordic,nrf-uarte":此处选择了uarte的驱动而非uart驱动。因此最终编译时用的代码是uart_nrfx_uarte.c而非uart_nrfx_uart.c

  • status = "okay":驱动代码自动初始化外设时,只会初始化状态为"okay"的节点。

  • current-speed = < 0x1c200 >:波特率,也就是写十进制current-speed = < 115200 >

其余属性,与中断、引脚分配、Zephyr低功耗有关。将在今后的文章中介绍。

硬件连接

image-20221209144123203

Nordic开发板的串口0默认GPIO都是在板子上直接连接到Jlink上,然后Jlink把串口转发到USB上,因此电脑上看到的是Jlink的USB串口。

右上角开关,nRF Only是只给单片机核心电路供电,外围LED、Jlink等都不供电,用于测量功耗。因此应该拨到DEFAULT档位。

板载Jlink,USB插左边即可。左下角电源开关打开。

异步串口代码运行

烧录好程序后,分别打开RTT和串口。鼠标选择串口的窗口,通过键盘敲入hello,等待2s超时,串口就会把hello回环打印出来。并且RTT的日志中会显示收到了5字节,发送了5字节。

  • VS Code里面nRF插件提供的这个串口终端的行为类似于PuTTY,按下键盘立即发送,但不会显示出自己发送了什么。
  • 要超时2s,是因为前面在uart_rx_enable()时设置了2s空闲超时。

image-20231112211302750

4. USB CDC ACM串口

前面介绍了硬件串口的异步API,接下来我们介绍USB CDC ACM串口。

USB串口设备树

通过查看build/zephyr/zephyr.dts,我们可以看到USBD外设的状态:

image-20231112225500662

这个节点有两个label,一个是usbd,一个是zephyr_udc0,我们任选一个使用就好。我们可以参考USB例程${NCS}/zephyr/samples/subsys/usb/cdc_acm/来修改设备树。

先在项目根目录下新建app.overlay

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

这里我们给usbd节点新增了一个子节点/soc/usbd@4002700/cdc_acm_uart0,并且也给其添加了一个label:usb_serial0

USB串口配置

再然后,是不是直接在prj.conf里使能一下USB,再去代码里把device结构体指针改成DEVICE_DT_GET(DT_NODELABEL(usb_serial0))就万事大吉了吗?

可惜,对于USB来说,事情没这么简单。从例程${NCS}/zephyr/samples/subsys/usb/cdc_acm/中,我们发现,似乎USB CDC ACM只有基于中断(interrupt-driven)的串口API,没有异步串口API可以用。

再去USB驱动代码${NCS}/zephyr/subsys/usb/device/class/cdc_acm.c中查看一下,会发现这里根本没有写异步API。struct uart_driver_api里也没有给异步API的函数指针赋值。

虽然Zephyr的USB CDC ACM驱动没有异步API,但Nordic提供了异步API。本质上,你调用的所有Zephyr标准串口驱动API,都来自于struct uart_driver_api这个结构体中的函数指针。如果驱动程序在系统启动之前没有给异步API赋值,那么我们自己实现异步API,然后把异步API赋值给这个结构体内的函数指针不就好了?

这个异步代码就在Nordic经典的蓝牙透传例程中,位于${NCS}/nrf/samples/bluetooth/peripheral_uart

我们先把Kconfig中的一个配置项改一下名字抄过来,使我们的Kconfig文件变成:

source "Kconfig.zephyr"

config USB_UART_ASYNC_ADAPTER
bool "Enable USB UART async adapter"
select SERIAL_SUPPORT_ASYNC
help
Enables asynchronous adapter for UART drives that supports only
IRQ interface.

使我们的工程菜单,在Zephyr本身配置菜单的基础上,多一个可配置的选项CONFIG_USB_UART_ASYNC_ADAPTER。然后修改prj.conf

# use RTT as console
CONFIG_RTT_CONSOLE=y
CONFIG_UART_CONSOLE=n

# use ASYNC uart API
CONFIG_SERIAL=y
CONFIG_UART_ASYNC_API=y

# enable USB Device
CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_PRODUCT="Zephyr CDC ACM sample"
CONFIG_USB_DEVICE_PID=0x0001
CONFIG_USB_CDC_ACM=y
CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_UART_LINE_CTRL=y
CONFIG_USB_DEVICE_REMOTE_WAKEUP=n

# enable USB ASYNC Adapter
CONFIG_USB_UART_ASYNC_ADAPTER=y

可能你会疑惑,为什么要自定义一个配置项,然后通过select的方式连锁使能SERIAL_SUPPORT_ASYNC?为什么不直接配置CONFIG_SERIAL_SUPPORT_ASYNC=y

如果你看过我之前的文章《理解Zephyr编译与配置系统》,就会明白,SERIAL_SUPPORT_ASYNC是一个隐性配置项,不允许用户直接修改。只能通过其他配置项连锁使能。

隐性配置项存在的意义就是为了防止不懂的新手开发者乱改配置,导致出现很难定位的bug。但是各位看到这里,相信已经属于是懂这套系统的开发者了。

USB串口代码

首先把${NCS}/nrf/samples/bluetooth/peripheral_uart/src/目录下的uart_async_adapter.c和uart_async_adapter.h拷贝到自己工程的src目录下。

然后在CMakeLists.txt中将其添加为源码,同时添加include路径:

# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20.0)

find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(hello_world)

target_sources(app PRIVATE
src/main.c
src/uart_async_adapter.c
)

target_include_directories(app PRIVATE
src)

然后,给出略加修改后的main.c:

【注意】
本文基于NCS v2.4.2。若读者使用v2.5.0或以上版本。下面代码中的

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void **)&evt->data.rx_buf.buf);
break;

需要改为

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void *)evt->data.rx_buf.buf);
break;

因为版本升级后k_mem_slab_free的实现不同,参数从二级指针变为了一级指针。

/*
* Copyright (c) 2012-2014 Wind River Systems, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/uart.h>
#include <string.h>

#include <zephyr/usb/usb_device.h>
#include "uart_async_adapter.h"

// usb async adapter use millisecond as timeout
// but uart use microsecond as timeout
#if(CONFIG_USB_UART_ASYNC_ADAPTER)
#define RX_INACTIVE_TIMEOUT 2000
#else
#define RX_INACTIVE_TIMEOUT 2000000
#endif

// serial buffer pool
#define BUF_SIZE 64
static K_MEM_SLAB_DEFINE(uart_slab, BUF_SIZE, 3, 4);

// serial device
static const struct device *uart_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);

// async serial callback
static void uart_callback(const struct device *dev,
struct uart_event *evt,
void *user_data)
{
struct device *uart = user_data;
int err;

switch (evt->type) {
case UART_TX_DONE:
printk("Tx sent %d bytes\n", evt->data.tx.len);
break;

case UART_TX_ABORTED:
printk("Tx aborted\n");
break;

case UART_RX_RDY:
{
printk("Received data %d bytes\n", evt->data.rx.len);

static uint8_t buf[128];
uint8_t *p = &(evt->data.rx.buf[evt->data.rx.offset]);

memcpy(buf, p, evt->data.rx.len);
uart_tx(dev, buf, evt->data.rx.len, SYS_FOREVER_US);

break;
}

case UART_RX_BUF_REQUEST:
{
uint8_t *buf;

err = k_mem_slab_alloc(&uart_slab, (void **)&buf, K_NO_WAIT);
__ASSERT(err == 0, "Failed to allocate slab\n");

err = uart_rx_buf_rsp(uart, buf, BUF_SIZE);
__ASSERT(err == 0, "Failed to provide new buffer\n");
break;
}

case UART_RX_BUF_RELEASED:
k_mem_slab_free(&uart_slab, (void **)&evt->data.rx_buf.buf);
break;

case UART_RX_DISABLED:
break;

case UART_RX_STOPPED:
break;
}
}

static bool uart_test_async_api(const struct device *dev)
{
const struct uart_driver_api *api =
(const struct uart_driver_api *)dev->api;

return (api->callback_set != NULL);
}

#if CONFIG_USB_UART_ASYNC_ADAPTER
UART_ASYNC_ADAPTER_INST_DEFINE(async_adapter);
#else
static const struct device *const async_adapter;
#endif

void main(void)
{
int err;
uint8_t *buf;

if (!device_is_ready(uart_dev)) {
printk("device %s is not ready; exiting\n", uart_dev->name);
}

if (IS_ENABLED(CONFIG_USB_DEVICE_STACK)) {
err = usb_enable(NULL);
if (err && (err != -EALREADY)) {
printk("Failed to enable USB\n");
return;
}
}

if (IS_ENABLED(CONFIG_USB_UART_ASYNC_ADAPTER) && !uart_test_async_api(uart_dev)) {
/* Implement API adapter */
uart_async_adapter_init(async_adapter, uart_dev);
uart_dev = async_adapter;
}

err = uart_callback_set(uart_dev, uart_callback, (void *)uart_dev);
__ASSERT(err == 0, "Failed to set callback");

// wait for USB plug in
if (IS_ENABLED(CONFIG_UART_LINE_CTRL)) {
printk("Wait for DTR\n");
while (true) {
uint32_t dtr = 0;

uart_line_ctrl_get(uart_dev, UART_LINE_CTRL_DTR, &dtr);
if (dtr) {
break;
}
/* Give CPU resources to low priority threads. */
k_sleep(K_MSEC(100));
}
printk("DTR set\n");
err = uart_line_ctrl_set(uart_dev, UART_LINE_CTRL_DCD, 1);
if (err) {
printk("Failed to set DCD, ret code %d\n", err);
}
err = uart_line_ctrl_set(uart_dev, UART_LINE_CTRL_DSR, 1);
if (err) {
printk("Failed to set DSR, ret code %d\n", err);
}
}

// allocate buffer and start rx
err = k_mem_slab_alloc(&uart_slab, (void **)&buf, K_NO_WAIT);
__ASSERT(err == 0, "Failed to alloc slab");
err = uart_rx_enable(uart_dev, buf, BUF_SIZE, RX_INACTIVE_TIMEOUT);
__ASSERT(err == 0, "Failed to enable rx");

while(1) {
k_sleep(K_FOREVER);
}
}

代码解析:

  • 获取设备树节点对应的device结构体指针,之前已经介绍过用Node ID的方法。这里再介绍一种新的方法,那就是根据节点的compatible属性来获取:

    static const struct device *uart_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);

    此宏可以获得一个compatible属性为"zephyr,cdc-acm-uart"的设备树节点对应的device结构体指针。

  • main()函数中,首先初始化USB。这时,USB虚拟串口的API还是只有基于中断的API。

  • 然后通过uart_async_adapter_init函数,为这个USB虚拟串口套一层异步API。

  • 之后的操作,就和前面操作普通串口一样。只不过多了一个Line Ctrl。这是为了等待USB插入,然后设置DCD和DSR信号。

  • 注意到,USB串口的回调函数完全没有改动,和前面的异步串口用的回调函数是一模一样的。

  • 最后,是一个小改动:异步串口API中,串口接收超时的单位是微秒;而这个async adapter里实现的rx_enable函数,传入的参数单位是毫秒。因此,注意RX_INACTIVE_TIMEOUT的值。

运行并测试USB串口

image-20231113111154639

左侧是Jlink USB,下方是nRF52840的USB Device接口。

我在Linux系统中查看串口,会发现多了一个新的串口/dev/ttyACM2

image-20231113111319070

通过minicom或其他串口软件打开这个串口,其行为和前面介绍的异步串口是一样的:发送任意内容,然后等待2s左右,同样的数据从串口回环打印出来:

image-20231113111516742

5. 低功耗串口

在不开启串口等非必要外设的情况下,Nordic的System On休眠(也就是Zephyr IDLE状态下)的待机功耗只有几微安。但若是开启RX一直等待着数据接收,即使同时没在发送任何数据,大概也会有三百微安左右的功耗。这肯定是低功耗产品不能接受的。

好在,Nordic nrfx异步串口API本身就有一定的低功耗特性:对于发送来说,只要数据发送完毕,tx就会关闭;对于接收来说,只要不被uart_rx_enable()使能,接收也会关闭。发送和接收都关闭时,串口本身就是关闭的,没有功耗。

发送行为本身就已经是低功耗的了,我们不用做任何修改。对于接收行为来说,串口接收空闲超时会产生UART_RX_RDY事件,我们可以在这个事件里关掉串口。但是如何在数据来之前就提前预知,打开串口呢?

一个比较简单的方案是同时把RX引脚配置成下降沿触发的外部中断。当检测到外部中断时,禁用GPIO中断,打开rx;当接收完毕时,关闭rx,打开GPIO中断。

这种方案,波特率不能超过9600,且GPIO中断内部执行要非常快,连Log也不能打印,否则时间来不及处理。

此外,如果接收到的数据大于缓存剩余的大小,可能会在缓存满时先产生一个UART_RX_RDY事件,等到数据完全接收完毕,空闲超时时又产生一个UART_RX_RDY事件。如何处理这种corner case?还需要开发者去思考一下。

Nordic Software LPUART原理

image-20231113114031848

Nordic提供了一个软件实现的低功耗串口,通过额外的两根线来识别是否有数据发出。由于是对称连接的,这里只给出一个MCU的TX和REQ引脚的实测波形图:

image-20231113114358096

通信步骤:

  1. 发送端要发送数据前,先拉高REQ引脚;然后把REQ引脚配置为上拉输入模式。
  2. 接收端RDY检测到上升沿,把串口RX使能。然后从RDY引脚输出一个几毫秒的负脉冲。然后把RDY引脚配置为上拉输入模式。
  3. 发送端REQ引脚检测到上升沿,才通过TX发出数据
  4. 发送完毕后,发送端拉低REQ引脚
  5. 接收端检测到RDY引脚下降沿,关闭串口RX。并产生UART_RX_RDY事件给应用层。

可以看出,这时UART_RX_RDY事件并不是空闲超时产生的,而是RDY引脚上拉产生的。因此会比普通的异步串口回调更早执行。

Nordic LPUART例程

相关链接:

lpuart例程说明

lpuart库说明

lpuart例程位置:${NCS}/nrf/samples/peripheral/lpuart

例程有基于中断异步串口两种模式。相信看完了前面内容的你,看懂这些代码对你来说已经轻而易举了。

这里只再说一些配置相关的内容。

首先是设备树,在boards/nrf52840dk_nrf52840.overlay中:

&uart1 {
status = "okay";
pinctrl-0 = <&uart1_default_alt>;
pinctrl-1 = <&uart1_sleep_alt>;
pinctrl-names = "default", "sleep";
lpuart: nrf-sw-lpuart {
compatible = "nordic,nrf-sw-lpuart";
status = "okay";
req-pin = <46>;
rdy-pin = <47>;
};
};

我们可以看到,在串口1的节点内新增了一个子节点nrf-sw-lpuart,其标签为lpuart,兼容的驱动为"nordic,nrf-sw-lpuart"

代码里,获取的串口也不再是硬件串口,而是这个子节点:

const struct device *lpuart = DEVICE_DT_GET(DT_NODELABEL(lpuart));

系统在启动时,会自动初始化串口1,并初始化sw-lpuart。应用层获取的device结构体是sw-lpuart驱动初始化的。

后续的代码,和前面的异步串口一样。底层的REQ,RDY引脚操作都不需要用户编写代码,已经在驱动代码里被实现了。

再看一下prj.conf

CONFIG_NRF_SW_LPUART=y

CONFIG_UART_1_ASYNC=y
CONFIG_UART_1_INTERRUPT_DRIVEN=n
CONFIG_UART_1_NRF_HW_ASYNC=y
CONFIG_UART_1_NRF_HW_ASYNC_TIMER=2
CONFIG_UART_1_NRF_ASYNC_LOW_POWER=y
CONFIG_NRFX_TIMER2=y

可以看出,和前面的异步串口的config相比,只是多出来了CONFIG_NRF_SW_LPUART=y来使能这个库。

LPUART例程运行

例程本身需要外部回环,因此你需要用两根杜邦线进行跳线:

image-20231113120440376

RDY和REQ引脚一定要接,否则驱动程序内部的状态会乱掉,出现错误。你可以像我这样先跳线,然后外面再用示波器、逻辑分析仪或USB转串口的RX进行监测。

image-20231113120838710

image-20231113121238377

功耗表现

image-20231113131330118

可以看到,在空闲无数据传输时,平均功耗在3.74uA左右。

6. 开发自己的程序

把串口程序集成到自己的应用中

如果你要开发自己的程序,千万不要想着怎么在其他程序里调用今天我分享的这些串口程序、怎么把自己的回调函数注册进来。因为,这相当于你又自己写了一个套娃串口驱动程序。

Zephyr是一个RTOS,这意味着,我们可以把今天分享的内容,当作一个线程。比如,把main()函数改名为serial_thread_task(),然后在文件的结尾,加上:

K_THREAD_DEFINE(serial_task_id, STACKSIZE, serial_thread_task, NULL, NULL, NULL,
PRIORITY, 0, 0);

STACKSIZE和PRIORITY请自行定义。

如果你要做串口发送,可以建一个消息队列(k_msgq),在线程中循环等待。以下是一段简单的示例代码:

while (1) {
struct my_serial_evt evt;

// 持续等待消息队列的新数据
int err = k_msgq_get(&my_msg_queue, &evt, K_FOREVER);
if (err) {
LOG_ERR("k_msgq_get() error: %d", err);
continue;
}

//收到数据后,可以进行一些打包、添加crc之类的处理,然后从串口发出
my_process(...);
uart_tx(...);

// 最后,一个好的编程习惯是,每个message都加一个is_allocated布尔参数
// 用来指示接收线程是否需要把message中的缓存给free掉
if (evt.is_allocated) {
k_free(evt.payload);
}
}

如果你要做串口接收,前面说了,由于接收缓存的长度和收到数据长度不一定是整数倍关系。当收到接收回调事件时,可能收到的不是发送端的发的完整的包。而是先收到前面一部分,再收到后面一部分。这就要求我们要把接收到的数据当作是字节流而不是包来处理。需要实现自己的解包函数。

如果你的解包函数太复杂,里面还有一些耗时的业务代码要执行。可以考虑把解包函数提交给Workqueue Threads去执行,你可以在NCS的例程文件夹中全局搜索k_work_相关的API,查看它的用法。这样就无须在串口回调函数中占用太多时间了,它是运行在中断服务函数内部的。也可以像发送端一样,解包好后就丢给消息队列即可。

阻塞与低功耗

硬件上的低功耗问题,前面已经介绍过低功耗串口的方案。这里稍微讨论一下软件上的低功耗问题,Zephyr中的操作系统的阻塞行为(比如k_sleep()和这里的k_msgq_get()),基本上都会让线程暂停。这意味着,这段时间内这个线程不会运行。如果所有线程都不在运行,则Zephyr会运行IDLE线程。而IDLE线程在很短的一段时间后会自动进入休眠。这意味着开发者可以放心大胆地在自己的线程中使用操作系统的阻塞API,不用担心CPU不会进入休眠。

串口API的兼容

我们前面提到,异步API和基于中断的API是完全不同的两套API。
而在串口驱动目录下的Kconfig.nrfx中可以看到:

config UART_1_ASYNC
bool "Asynchronous API support on port 1"
depends on UART_ASYNC_API && !UART_1_INTERRUPT_DRIVEN
default y
help
This option enables UART Asynchronous API support on port 1.

要使用异步API,必须单独禁用这个串口的中断API。
如果你要使用USB CDC ACM(需要中断API)的同时使用串口0的异步API,则需要这样配置:

CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_UART_0_INTERRUPT_DRIVEN=n

意思是,对于整个串口驱动来说,启用中断API(这个配置项来源于Zephyr串口驱动目录下的Kconfig)。
但是对于串口0来说,关闭中断API(这个配置项来源于Zephyr串口驱动目录下的Kconfig.nrfx)