2025.7.26更新:

  • 新增54L15串口硬件介绍
  • 新增串口增强接收(Enhanced RX)的介绍。不再推荐使用PPI+Timer的形式进行接收数据计数。
  • 增加全新的串口例程代码并上传GitHub

2025.5.5更新:

  • 增加了对串口硬件的介绍
  • 增加串口API更详细的介绍与图示

1. 前言

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

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

2. Hello world解析—printk如何输出

开发板我选择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设备。

前面分析了Hello world是如何通过console输出的。在Zephyr中,console主要是用来做一些字节流的传输,用来实现一些更上层的服务,例如自定义shell命令。而用户要开发自己的程序,肯定是需要自己直接操作串口,而不是用什么printf。

3. Nordic串口硬件

UART

image-20250505231527537

UART是最简单的硬件串口功能。图中小正方形为对外的硬件引脚,而箭头代表串口在MCU内部的输入、输出信号。

  • 接收:在串口接收已经使能(STARTRX)的状态下,从RX线来的数据会被放入RXD寄存器,并产生RXDRDY事件。​

    接收FIFO长度为6。RXD的数据被CPU读取后,立即从FIFO中把下一个数据填入RXD,并产生RXDRDY事件。(若使能流控,会在FIFO还剩4个空位时把RTS拉高以阻止对方发送)

  • 发送:在串口发送已使能(STARTTX)的情况下,向TXD写入1个字节就会发送。发送完毕后,UART产生TXDRDY事件。

这些事件都能用来触发中断,或者作为PPI信号触发其他外设的task。

UARTE

UARTE和UART是不同的外设,但是共用了部分寄存器和电路。在使用时,这种具有相同地址的外设被称为同一个实例(Instance)不能同时使能

image-20250505232121430

他们的ENABLE寄存器地址是相同的,但是使能所用的bit不同:

image-20250505232133627

UARTE (UART with EasyDMA) 功能和UART是类似的,只不过有了EasyDMA的帮助,可以自动从RAM中取出数据发出;也可以把收到的数据直接存入RAM。无需CPU参与单个字节的收发处理,提升了效率,降低了功耗。

image-20250505232323499

UARTE发送逻辑

image-20250505232526893

  1. TXD.PTR填入数据在RAM中的首地址,TXD.MAXCNT填入要发送的数据长度(nRF52840最大65535,nRF52832最大255).
  2. 使用STARTTX来启动自动的传输
  3. 传输完毕后,有ENDTX事件提示
  4. 中间每个字节的TXDRDY事件,CPU可以无视

注意:

  • 串口的发送功能只在STARTTX和ENDTX之间有功耗,其余时间几乎不产生电流消耗
  • EasyDMA只能在RAM和外设之间传输数据,不能在RAM之间传输,也不能有FLASH参与。

UARTE接收逻辑

image-20250505232812582

  1. RXD.PTR填入数据首地址,RXD.MAXCNT填入要发送的数据长度(nRF52840最大65535,nRF52832最大255).
  2. 使用STARTRX来启动自动的接收
  3. 传输完毕后(指RAM中存的数据长度已经达到了MAXCNT),有ENDRX事件提示
  4. 中间每个字节的RXDRDY事件,无需再使能中断(从而降低功耗,提高CPU效率)

特别地,RXD.PTR具有双缓存(影子寄存器)。也就是说,不用等到传输完成,只需在第一次接收开始后(RXSTARTED),就马上给RXD.PTR写入下一次要用的buffer首地址。这样下次传输时,就能立刻用上新的buffer。便于应用层实现双buffer

注意:

  • 串口在接收状态(STARTRX)会有功耗,有几百uA。因此需要避免待机时一直开着RX。
  • UARTE只有在接收完毕(buffer满)时才会产生中断。本身没有空闲帧中断,或者说超时机制。需要其他外设辅助实现。

nRF54系列UARTE硬件新功能

以nRF54L15为例,有以下功能更新:

(1) 4Mbps串口

在默认低频时钟域(16MHz)的情况下,串口的波特率可以由寄存器设置,如下最高为1Mbps。

image-20250726225627521

但是,nRF54L15的 UARTE00 位于MCU PowerDomain,其时钟频率为128MHz:

image-20250726225904621

这时,串口的实际波特率就和寄存器中的定义不相同,实际的公式手册中已经给出。

但是我们做软件开发时无需关心这部分,因为在54L15芯片的原始设备树dtsi中:

uart00: uart@4a000 {
compatible = "nordic,nrf-uarte";
reg = <0x4a000 0x1000>;
interrupts = <74 NRF_DEFAULT_IRQ_PRIORITY>;
clocks = <&hfpll>;
status = "disabled";
endtx-stoptx-supported;
frame-timeout-supported;
};

其已经指明了使用的是hfpll时钟。

然后,在最新(目前为NCS v3.0.2)的UARTE的驱动代码uart_nrfx_uarte.c中,已经自动考虑了低频时钟和高频时钟的情况:

/* When calculating baudrate we need to take into account that high speed instances
* must have baudrate adjust to the ratio between UARTE clocking frequency and 16 MHz.
* Additionally, >1Mbaud speeds are calculated using a formula.
*/
#define UARTE_GET_BAUDRATE2(f_pclk, current_speed) \
((f_pclk > NRF_UARTE_BASE_FREQUENCY_16MHZ) && (current_speed > 1000000)) ? \
UARTE_GET_CUSTOM_BAUDRATE(f_pclk, current_speed) : \
(NRF_BAUDRATE(current_speed) / UARTE_GET_BAUDRATE_DIV(f_pclk))

因此我们在软件上是感知不到这个差别的,只需正常配置我们需要的波特率即可。需要4M就配置current-speed = <4000000>;需要115200就配置current-speed = <115200>

(2)支持4至9bits 帧

image-20250726231953984

数据帧支持被配置为4bit ~ 9bit。

其中,当配置为9bit时,第9个bit是地址位。当其为1时,代表前8个bits是地址;当其为0时,代表前8个是数据。

且9bit模式下,只有先收到地址和ADDRESS寄存器匹配的第一个地址包时,才会接收后面的数据包。否则忽略所有收到的串口数据。

(3)帧超时中断

image-20250726232649715

帧超时中断,或者说空闲帧中断,指的是:

  • 当连续一定时间没有收到串口数据时,就认为传输已经结束
  • 此时不再等待DMA缓冲存满,而是直接产生DMA传输完成中断
  • 应用层可以及时把数据取出进行处理

之前的nRF52和53系列是没有这个功能的,需要操作系统软定时器进行计时,把一个timeout分成5份设定k_timer周期。如果每次软定时器到期,串口已经收到的数据量没有增长,那么就说明串口空闲了。这时由驱动层软件主动结束串口接收。这就不如nRF54系列UARTE硬件自带空闲帧超时来的方便。

只有异步串口才需要这个功能。因为阻塞基于中断的串口都是按字节实时同步接收串口数据的。

image-20250726233308626

空闲帧中断最大超时时间为 2^10 - 1= 1023 bits。在115200波特率下,大约是8.88ms。而使用软定时器的方式,可以设置更长时间。

nRF54L15的设备树默认开启了空闲帧的功能:

&uart20 {
status = "okay";
frame-timeout-supported;
};

对于支持帧超时中断的串口外设,就直接使用这个功能即可。不要删除这个属性。

异步串口初始化时,通过uart_rx_enable(dev, buf, len, timeout)打开RX时,传入的timeout值(单位:微秒)会经过如下处理:

  • 如果开启了空闲帧中断,则取应用层传入的 timeout 和 1023 bits 之中,取时间更短的一个,会被设置到FRAMETIMEOUT寄存器中
  • 如果没开启空闲帧中断,则用k_timer实现此功能,超时值为函数传入的timeout

4. Zephyr标准串口API

上一节以nRF52系列的串口外设为例介绍了硬件部分,Nordic其他产品的串口基本也是一致的。

本节介绍Zephyr中的串口API。

Zephyr中的串口API分为**阻塞(Polling)基于中断(Interrupt-driven)**、异步(Asynchronous)三种。

Zephyr串口API是一套软件接口,与硬件细节无关。除了Nordic的UART/UARTE硬件可以用这套接口,其他厂商的串口实现也可以支持这套接口。甚至我们后面会介绍到的USB虚拟串口,也支持这套接口。这里给出Zephyr标准串口API文档,供参考。

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

一般来说,同一个串口实例,基于中断的和异步的API是不能同时使用的。但是阻塞的API可以和前两者中的一种混用。

阻塞

基于阻塞的API是最简单的API。

  • uart_poll_in():读取时,只读一个字节。有就返回0,无就返回-1,不阻塞;
  • uart_poll_out():发送时,只发一个字节。发送完毕后才返回,阻塞行为。

基于中断

首先声明,Zephyr串口API是一套软件接口,与硬件细节无关。基于中断的API只是抽象地认为有串口外设应当有发送ready中断和接收ready中断。具体如何映射到硬件?完全由厂商提供的驱动代码实现,不需要应用开发者实现。这也是USB虚拟串口也能使用这套API的原因。

NCS中,使用串口中断API的例程很多。但是它们在应用层编写callback函数的方式五花八门,与应用层本身的功能混在一起,这对于初学者来说容易抓不到重点。这也侧面说明,中断API适合添加一些应用层自定义的东西。因此我在这里总结出基于中断的API的使用流程,方便开发者结合代码进行观看。

以下为流程:

  1. 开始时,用uart_irq_callback_set()函数设置好“应用层的”中断回调函数,这个函数会在串口ISR中根据情况被串口驱动程序调用。然后,开启uart_irq_rx_enable(),使能接收。
  2. 要发送时,先准备好要发送的数据(首地址和长度),然后uart_irq_tx_enable()开启发送中断。
  3. 发生中断,进入预先设置好的回调函数时,先用uart_irq_update()更新中断状态,再用uart_irq_is_pending()判断是否有中断(以防是别处误调用了该回调函数)。再之后用uart_irq_tx_ready()uart_irq_rx_ready()来判断是发送中断还是接收中断。
  4. 如果是接收中断,在中断里uart_fifo_read()循环读取,每次读取1个字节(Nordic的驱动实现是只读1个字节),直到返回值为0(表示缓存里已无数据)。
  5. 如果是发送中断,说明发送器已经ready,在中断里uart_irq_tx_fill(),把前面准备好要发送的数据传入,即可发送。

注意:

  • 不要在中断callback里进行耗时的处理和阻塞行为。善用queue和work queue。
  • uart_fifo_read()uart_fifo_fill()只能在这个中断callback函数内部调用

异步

异步API是本文介绍的重点,它带有DMA,因此可以让数据传输时,不影响CPU的运行。但是它的配置最复杂,功能最强大。

异步发送

image-20250506000430849

调用uart_tx(dev, *buf, len, timeout),给定首地址和长度即可,函数不阻塞。Timeout是给流控用的,如果输出被对方的流控阻止,自己能等待多久,如果没有流控就不用在意。

发送过程由驱动层和硬件自动处理。

发送完毕后,回调函数里会收到UART_TX_DONE事件。

注意:

  1. 注意发送数据buffer的生命周期,不能是局部变量
  2. 如果buffer的地址不属于于RAM,Nordic的驱动程序会先自动执行一个拷贝到RAM中的动作。因为硬件不支持RAM以外的地方到外设的DMA。
  3. uart_tx这个行为是低功耗的。只要不在发送,就没有发送行为相关的功耗。无需disable串口。

在DMA传输期间,如果再次执行uart_tx(),函数会返回-EBUSY错误码:

image-20250727000130872

后续例程会展示如何实现发送缓冲线程。

异步接收

image-20250506000958480

  1. uart_rx_enable(dev, *buf, len, timeout_us)来首次使能接收。给定Buffer和长度。Buffer收满以后会产生UART_RX_RDY事件。
  2. Timeout是空闲超时机制。在至少收到1个字节之后,即使buffer未满,如果超时,也会产生UART_RX_RDY事件。这是为了方便收取一个小于buffer长度的包的情况。单位是微秒。timeout时间设为SYS_FOREVER_US会关闭这个机制。
  3. 如果buffer未满,下次数据接收会继续填充在此buffer内。如果buffer已满,紧接在UART_RX_RDY事件之后,会产生UART_RX_BUF_RELEASED事件。告知应用层,一开始的buffer已经不再使用,可以释放。
  4. 异步API也提供双Buffer机制。当每次接收开始时,驱动层会立即产生UART_RX_BUF_REQUEST事件。向应用层请求第二个buffer。应用层有两个选择:
    • 用uart_rx_buf_rsp(dev, *buf, len)来设置第二个buffer。当第一个buffer满时,驱动层自动开始用第二个buffer。
    • 无视这个请求。那么这次接收完毕时,整个接收会被disable。需要再次enable才能开始接收。

image-20250506000854098

Zephyr串口驱动

无论是阻塞、基于中断、还是异步API。它们都是由Nordic的驱动程序提供的。

CONFIG_SERIAL=y,就使能了Zephyr的串口驱动。Zephyr系统内的CMake规则会自动把相关MCU的串口驱动编译进去。

而Nordic是提供了三种驱动的:

image-20250506001935633

使用时,注意在device tree中设置正确的compatible,来选择正确的驱动。

image-20250506001833817

设备树的compatible和驱动程序是如何对应的?

​ 设备树的compatible属性,在编译阶段会被转换成C语言命名规范允许的形式(特殊符号全变为下划线),如nordic_nrf_uarte

​ 每个驱动程序会用自己的方法遍历设备树中所有compatible与自己相匹配的节点。然后基于这个节点的信息来初始化硬件外设。

uarte和uarte2有什么区别?

​ 见zephyr/driver/serial/CMakeLists.txt

if (CONFIG_UART_NRFX_UARTE)
if (CONFIG_UART_NRFX_UARTE_LEGACY_SHIM)
zephyr_library_sources(uart_nrfx_uarte.c)
else()
message(DEPRECATION
"Do not set CONFIG_UART_NRFX_UARTE_LEGACY_SHIM=n as this option is deprecated.")
zephyr_library_sources(uart_nrfx_uarte2.c)
endif()
endif()

5. 异步串口代码示例

示例代码:Jayant-Tang/learning_zephyr_serial: An example that shows how to use zephyr Async UART

读者可以下载示例代码后,对照阅读本文

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

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的实现不同,参数从二级指针变为了一级指针。

src/app_uart/app_uart.c

初始化

首先是获得device,这里的方法是用aliases别名来获取:

/* serial device */ 
#define UART_INST DT_ALIAS(learning_serial)
static const struct device *uart_dev = DEVICE_DT_GET(UART_INST);

因为在不同板子的设备树中,都已经选好了对应的串口:

nrf54l15dk_nrf54l15_cpuapp.overlay:

/{
aliases{
learning-serial = &uart20;
};
};

nrf52840dk_nrf52840.overlay:

/{
aliases{
learning-serial = &uart0;
};
};

然后是初始化时,注册异步回调函数,并开启串口接收。这里除了device结构体指针之外,还有两组配置。一个是接收缓存及其长度、一个是超时时间:

uart_rx_enable(uart_dev, buf, BUF_SIZE, RX_INACTIVE_TIMEOUT_US);

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

超时时间,指的是串口空闲一定时间,没有新数据来,就直接认为接收完毕。即使DMA接收缓存还未满,也要产生空闲事件,并直接调用callback。这里为了演示,设置为1秒超时

这个超时功能,一般情况下是用软定时器(k_timer)实现的。

但是,对于nRF54L15这种串口硬件本身支持超时帧中断的情况,会使用硬件本身的超时功能。这时,超时时间的最大值就是UARTE硬件帧中断支持的最大时间。比如54l15的串口,空闲帧的最大值为10个bit宽度,在115200波特率下大约为8.9ms。因此,这种情况下设置所有超过8.9ms的时间都会被缩短到8.9ms。

串口回调

在回调函数中,每次接收缓存已满,或者达到了超时时间,就会产生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]); // 开发者自行实现解包函数
    }

    main.c中已经实现了这一点。

  3. 回调函数实际上运行在中断服务函数内部,因此不要做一些阻塞的行为。如果真的有计算量大的任务,可以把任务提交到Workqueue Threads。这样你就能把耗时的任务从特权模式移动到用户模式,也就是从中断内部移动到线程中。

串口接收线程

串口接收到数据时,将数据拷贝并通过消息队列发送到RX线程。然后执行应用层的回调函数。再之后free掉申请的内存。

串口发送线程

应用层要发送数据时,数据先被拷贝并通过消息队列发送到TX线程,然后进行发送。线程会等待发送完毕,然后free掉申请的内存

main.c

通过app_uart_rx_cb_register()注册回调函数。当串口收到数据时,回调函数会在RX线程中被执行

要发送数据时,执行app_uart_tx()。此函数不阻塞且会拷贝数据。因此可以从ISR或Thread中调用,也可以传入局部变量。

收到的串口数据是字节流而不是包。因此通过有限状态机实现了串口数据流解包函数,以连续的CRLF(\r\n)为分界,进行数据的解包。

异步串口配置

prj.conf

# use RTT as console
CONFIG_USE_SEGGER_RTT=y
CONFIG_RTT_CONSOLE=y
CONFIG_UART_CONSOLE=n

# enable logging
CONFIG_LOG=y
CONFIG_LOG_BACKEND_RTT=y
CONFIG_LOG_MODE_DEFERRED=y

CONFIG_SEGGER_RTT_MODE_NO_BLOCK_SKIP=y

# use ASYNC uart API
CONFIG_SERIAL=y
CONFIG_UART_ASYNC_API=y

# need k_malloc
CONFIG_HEAP_MEM_POOL_SIZE=4096

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

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

由于我们需要用到动态内存分配,因此这里要设置HEAP大小CONFIG_HEAP_MEM_POOL_SIZE=4096

boards/<board>.conf

CONFIG_UART_xx_ASYNC=y
CONFIG_UART_NRFX_UARTE_ENHANCED_RX=y

CONFIG_UART_xx_ASYNC=y来自于Nordic的配置${NCS}/zephyr/drivers/serial/Kconfig.nrfx。Zephyr只提供了全局的串口API选择(异步、中断、阻塞)。但是Nordic允许开发者给不同的串口使用不同的API。因此这里需要给特定的串口实例单独启用ASYNC API。

强化RX功能

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

纯软件的方法就是,每收到一个字节就产生中断,在中断服务函数里,通过软件的方式+1,这也是大多数普通的单片机的做法。如果你不添加最后两行CONFIG配置,那么uart_nrfx_uarte.c驱动就会采用这种方法。但是当串口速率很高时(如1Mbps),每一个字节都产生中断一定会大量占用CPU资源,效率极低。

因此这里需要强化版RX(Enhanced RX)功能。在底层驱动代码中,当CONFIG_UART_NRFX_UARTE_ENHANCED_RX开启时,单个字节的中断不会被使能:

image-20250727044332567

这就确保了不会产生单个字节的中断,从而影响CPU性能。

另一方面,在底层驱动的RX enable函数中,使能了连接FRAMETIMEOUT event和 STOP_RX task的SHORT寄存器:

image-20250727044930863

SHORT寄存器(意为短路)就是可以让一个外设的event自动触发该外设的一个task,无需CPU参与。当FRAME_TIMEOUT发生时,外设自动执行STOP_RX。

对于nRF52系列这种不支持FRAME_TIMEOUT的老系列。超时是靠k_timer软定时器实现的,在rx enable的函数内,把超时时间分成了5份:

async_rx->timeout_us = timeout;
async_rx->timeout_slab = timeout / RX_TIMEOUT_DIV; // RX_TIMEOUT_DIV = 5

底层驱动在RX Started中断里,开始计时:

image-20250727045514187

如果连续5次都超时,则软件触发STOPRX中断:

if (async_rx->idle_cnt == (RX_TIMEOUT_DIV - 1)) {
nrf_uarte_task_trigger(uarte, NRF_UARTE_TASK_STOPRX);
return;
}

这和前面的SHORT寄存器是一个思路。

不论是前面二者中的哪种情况,当STOP RX中断发生时,就会进入底层驱动的endrx_isr()。在这里读取DMA的AMOUNT寄存器,就可以在DMA缓存区未满的情况下,获取串口已经收到的字节数了。

硬件计数器(不再推荐)

在之前版本的文章中,我介绍过Timer+PPI的方式:

Nordic的单片机有独特的功能—— PPI (Programmable peripheral interconnect)。简单来说,就是每个外设都有许多event和task寄存器。event寄存器可以产生中断让CPU去处理;CPU也可以去写task寄存器让外设去执行某些工作。前面介绍过,SHORT寄存器可以把同一个外设的event和它自己的task连接起来。

而PPI可以把两个不同外设的event寄存器和task寄存器连接起来,实现自动联动,而无需CPU处理

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

在nRF52840上,可以这样配置,将timer2用作uart0的计数器:

CONFIG_UART_0_ASYNC=y

CONFIG_UART_NRFX_UARTE_ENHANCED_RX=n

CONFIG_UART_0_NRF_HW_ASYNC=y
CONFIG_UART_0_NRF_HW_ASYNC_TIMER=2

CONFIG_NRFX_TIMER2=y

在nRF54L15上,不推荐用硬件计数,请直接使用CONFIG_UART_NRFX_UARTE_ENHANCED_RX=y,这里也不给出配置,经过我实测:

  • 54L15使用硬件计数,开启FRAMTIMEOUT时。出现bug,FRAMETIMEOUT不生效,必须收到大于DMA长度的包才能产生中断;
  • 54L15使用硬件计数,关闭FRAMTIMEOUT,k_timer实现超时功能。出现bug,收到的数据包最后2个字节完全错误,且尾部还会再增加一个随机错误字节。

异步串口设备树

我们并不需要额外修改设备树,直接采用默认值即可。我们这里只是给串口起一个别名,方便不同MCU平台统一代码:

/{
aliases{
learning-serial = &uart20;
};
};

&uart20 {
status = "okay";
};

至于这个串口的具体配置,我们可以直接查看编译后的完整设备树,位于build/<application_name>/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 >

硬件连接

image-20221209144123203

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

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

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

异步串口代码运行

烧录好程序后,分别打开RTT和串口。从串口发送hello(包含回车+换行),串口就会把hello回环打印出来。并且RTT的日志中会显示收到了7字节,发送了7字节:

image-20250727053522774

  • 不要用VS Code里面nRF插件提供的这个串口终端。在里面按下回车不是\r\n,无法形成完整数据包。可以用nRF Connect for Desktop里面的串口助手。
  • 如果是52840,前面设置了1s超时,hello就会在发送后1s回环打印出来。如果是54L15,不采用这个超时,而是采用空闲帧中断,hello会在1023个bit时间内打印出来(约8.9ms)。

image-20250727054355433

如果一次性发送大量数据,则我们可以看到产生了多个接收完毕中断。由于我们的CONFIG_APP_UART_RX_DMA_BLOCK_SIZE设置的是64,因此每收到64字节,串口驱动就会重新申请一块新的内存。

而应用层main.c中,已经实现了解包函数,因此最终是按照一整包回环发送回来的。

6. USB CDC ACM串口

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

nRF54L15不含USB,后续用nRF52840DK继续。

应用层代码无需改动。只需修改一些配置就可以把前面的异步串口代码变为USB CDC ACM串口代码。

USB串口设备树

usb.overlay中,有如下配置:

/{
aliases {
my-usb-serial = &usb_serial0;
};
};

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

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

USB串口配置

prj_usb.conf中,和prj.conf相比增加了以下内容:

# 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_UART_ASYNC_ADAPTER=y

最后一项是Nordic的一个中间件。由于USB串口驱动只实现了基于中断的API,没有实现异步API。因此通过这个Adapter给USB CDC ACM串口驱动附加一层异步API。

USB串口代码

代码和前面的异步串口代码几乎一样。只是换了一个串口设备:

image-20250727062621425

另外,在初始化阶段使能了usb,并应用了uart_async_adapter:

image-20250727062711807

串口异步接收超时的单位是微秒。在老的异步串口API中,而这个async adapter里实现的rx_enable函数,传入的参数单位是毫秒。因此,注意RX_INACTIVE_TIMEOUT的值。

不过最新的NCS v3.0.2已经修复了此问题。现在单位统一为微秒(us)。

编译USB串口例程

编译时选好配置文件和设备树:

image-20250727063009578

或者用命令编译:

west build -d build_usb -p -b nrf52840dk/nrf52840 --sysbuild -- -DCONF_FILE="prj_usb.conf" -DEXTRA_DTC_OVERLAY_FILE="boards/nrf52840dk_nrf52840.overlay" -DDTC_OVERLAY_FILE="usb.overlay" 

运行并测试USB串口

image-20231113111154639

左侧是Jlink USB,下方是nRF52840的USB Device接口。在串口助手里选中USB串口:

image-20250727063204480

可以看到功能和前面的异步串口完全相同:

image-20250727063250261

7. 休眠与串口低功耗

要实现系统低功耗的本质就两件事:

  • CPU在无事可做时进入low-power standby状态(ARM的WFE指令或者WFI指令)。
  • 除了CPU以外的外设,不使用时,直接disable

前者是Zephyr自带的功能,当IDLE线程之外的其他线程都阻塞等待或sleep时,IDLE线程会自动让CPU进入低功耗休眠模式。之后,CPU被RTC或者串口、GPIO等中断唤醒时,会自动向后执行代码。

后者就是需要代码来控制,在不用的时候把串口关掉。

除了System ON状态的CPU IDLE之外,Nordic还支持System OFF,直接关闭CPU和所有外设。可以称之为深度睡眠。这种情况只能被GPIO或reset pin唤醒(54系列也可以被GRTC唤醒)。并且唤醒后必定从reset handler开始执行。

Zephyr设备电源管理(PM_DEVICE)

Zephyr的外设是被驱动程序自动初始化的,这发生在main()函数之前。因此我们基本上看不到Zephyr驱动提供init或者uninit这种函数。因为我们不需要在应用层初始化或者关闭某个外设。

取而代之的是Zephyr提供了一套电源管理机制,需要使能:CONFIG_PM_DEVICE=y。可以操作每个外设的device指针,使其挂起或者恢复。

比如说,用串口打印日志时,这个串口是被console驱动管理的。console并没有开放API给应用层开启或者关闭串口。但是,应用层可以用Zephyr的设备电源管理来控制这个串口:

#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/pm/device.h>
const struct device *console_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));

// 关闭串口
pm_device_action_run(console_dev, PM_DEVICE_ACTION_SUSPEND);
...

// 打开串口
pm_device_action_run(console_dev, PM_DEVICE_ACTION_RESUME);
...

休眠时,首先Zephyr驱动会负责把外设本身关闭。其次,Zephyr驱动还会把外设分配的GPIO配置成提前预设好的Sleep模式,也就是设备树里预设好的模式:

&uart0 {
status = "okay";
current-speed = <115200>;
/delete-property/ hw-flow-control;
zephyr,pm-device-runtime-auto;
pinctrl-0 = <&uart0_default>;
pinctrl-1 = <&uart0_sleep>;
pinctrl-names = "default", "sleep";
};

// write pinctrl again to remove RTS and CTS pin
&pinctrl {
uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 6)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 0, 8)>;
bias-pull-up;
};
};

uart0_sleep {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 6)>,
<NRF_PSEL(UART_RX, 0, 8)>;
low-power-enable;
// bias-pull-up;
};
};
};

当你想控制串口空闲态是低电平还是高电平时,就是在pinctrl的sleep引脚组配置。

我们可以看出,PM_DEVICE的设计目标是提供API,让应用层负责管理外设的开启或者关闭。

Zephyr运行时设备电源管理

Zephyr还提供了自动的外设功耗管理,即``PM_DEVICE_RUNTIME。需要通过CONFIG_PM_DEVICE_RUNTIME=y`开启。

这种情况下,就不需要应用层来控制外设的开关了。每次应用层要操作外设时,驱动层会利用PM子系统对引用计数+1;操作完毕后,引用计数-1。当引用计数等于0时,PM子系统会负责执行 PM_DEVICE_ACTION_SUSPEND 或者 PM_DEVICE_ACTION_RESUME

image-20250802145845580

除了要开启``CONFIG_PM_DEVICE_RUNTIME=y`之外,还要给对应的设备初始化运行时电源管理的功能,以下两种方法二选一:

  • 给对应的device执行pm_device_runtime_enable()
  • 在设备树节点内增加一条zephyr,pm-device-runtime-auto;的属性。

例程低功耗代码解析

app_uart.c里面提供了两个休眠相关的函数:

int app_uart_sleep(void)
{
int err;
err = uart_rx_disable(uart_dev);
if (err) {
LOG_ERR("Failed to disable RX: %d", err);
return err;
}

#if !IS_ENABLED(CONFIG_PM_DEVICE_RUNTIME)
// give some time for UART callback
k_sleep(K_MSEC(10));
err = pm_device_action_run(uart_dev, PM_DEVICE_ACTION_SUSPEND);
if (err) {
LOG_ERR("Failed to suspend device: %d", err);
return err;
}
#endif /* !CONFIG_PM_DEVICE_RUNTIME */

return 0;
}

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

#if !IS_ENABLED(CONFIG_PM_DEVICE_RUNTIME)
err = pm_device_action_run(uart_dev, PM_DEVICE_ACTION_RESUME);
if (err) {
LOG_ERR("Failed to resume device: %d", err);
return err;
}
#endif /* !CONFIG_PM_DEVICE_RUNTIME */

err = k_mem_slab_alloc(&uart_slab, (void **)&buf, K_NO_WAIT);
if (err) {
LOG_ERR("Failed to allocate RX buffer: %d", err);
return err;
}

err = uart_rx_enable(uart_dev, buf, BUF_SIZE, RX_INACTIVE_TIMEOUT_US);
if (err) {
LOG_ERR("Failed to enable RX: %d", err);
return err;
}
return 0;
}

我们先考虑CONFIG_PM_DEVICE_RUNTIME=n的情况。

休眠时:

  1. 首先uart_rx_disable():异步串口需要单独关闭RX,因为与RX关联的有应用层Buffer,timeout定时器等功能,都需要单独关闭
  2. 然后等待10ms:等待异步串口的callback执行完毕,释放RX buffer等等
  3. 最后pm_device_action_run(uart_dev, PM_DEVICE_ACTION_SUSPEND),挂起串口

恢复时:

  1. pm_device_action_run(uart_dev, PM_DEVICE_ACTION_RESUME)恢复串口
  2. 然后按照正常流程申请RX buffer并开启RX

然后,讨论开启CONFIG_PM_DEVICE_RUNTIME=y的情况。这种情况下只需要开关RX就好了,因为当RX被关闭,同时又没有在TX的时候,PM Device Runtime系统会自动挂起串口的,因此我们应用层没必要再调用pm_device_action_run()函数了。

实测串口低功耗休眠功能

本工程是否开启CONFIG_PM_DEVICE_RUNTIME没有影响,结果相同。

nRF52840DK连接方式:

image-20250802152806259

nRF54L15DK连接方式:

image-20250802153317606

52840DK:

  • 按button1进入休眠
  • 按button2退出休眠

功耗:

image-20250802153612494

image-20250802153653735

注:52840手册标注system ON, CPU IDLE的电流为2.35uA

54L15DK:

  • 按button0进入休眠
  • 按button1退出休眠

功耗:

image-20250802153818504

image-20250802153859464

注:54L15手册标注,3V条件下,System ON, Wake on pin, 256 KB RAM retained情况下CPU IDLE的电流为3uA.

8. Nordic LPUART低功耗串口

根据前面的介绍:对于发送来说,只要数据发送完毕,tx就会关闭;对于接收来说,只要不被uart_rx_enable()使能,接收也会关闭。发送和接收都关闭时,根据PM_DEVICE_RUNTIME。串口本身就是关闭的,没有功耗。

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

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

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

另外,开发者要注意“传输结束”的判断条件是什么。肯定不能用UART_RX_RDY事件。因为,如果接收到的数据大于缓存剩余的大小,可能会在缓存满时先产生一个UART_RX_RDY事件,等到数据完全接收完毕,空闲超时时又产生一个UART_RX_RDY事件。所以开发者要根据包的结构,在解包完成之后,在一个包的结束时关闭rx,再重新把rx引脚配置成GPIO中断。

本文中,我提供的例程应该比较清晰,且两个线程也方便扩展这些GPIO信号,读者可以自行添加此功能。

此外,Nordic也已经提供了一套低功耗串口方案:

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左右。

9. 开发自己的程序

串口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)

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

本例程已经非常完善,基本上把app_uart文件夹拷贝到自己的工程中,然后在Kconfig和CMakeLists.txt中引用即可。这也是模块化开发的思想。而且本工程思路还是比较清晰的,开发者想增加自己的代码也会非常容易。