Zephyr驱动与设备树实战——串口
本文更新于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 |
CMakeLists.txt
中先把Zephyr作为包来导入,然后把main.c添加为源码。
prj.conf
目前是空的,在这里可以写一些配置用来覆盖默认的Kconfig。
例程默认没使用Kconfig
菜单文件,是因为本工程太简单,没有自己的配置项,所以不需要自己的Kconfig文件。这种情况完全等价于Kconfig文件中只写了下面的内容:
source "Kconfig.zephyr" |
相当于项目中只有Zephyr的菜单,可以让我们配置Zephyr系统的配置项,以及SDK中各个module的的配置项。选择板子,编译并烧录后,打开串口,reset一下,就能看到刚启动时串口输出的hello world了。
printk输出配置
很多新上手Zephyr的读者会有疑惑,这工程里几乎没什么代码,也没看到CONFIG和device tree文件,串口到底是怎么输出的?
其实,在我们选择板子时,板子就已经自带了默认的device tree和config文件。因此编译时采用的全部是板子和Zephyr系统的默认值,我们的工程中并没有对这些默认值进行修改。
我们可以在build/zephyr/
目录下看到.config
文件和zephyr.dts
文件。这个就是项目最终编译采用的配置项和设备树。
在.config
中,我们可以看到:
CONFIG_PRINTK=y |
也就是启用了printk()
输出的功能。
我们把这一行复制到prj.conf中(这个行为本身没有意义,因为默认就是y),然后就可以用Ctrl+鼠标左键点击这个选项,跳转到这个配置项定义的地方,就可以看到这个配置项的说明:
当然,你也可以在Kconfig GUI中找到这个配置项:
到这里,是否对“
Kconfig
定义了一个菜单,而prj.conf
文件是对菜单中配置项的默认值进行修改”这句话有了一定的感受呢?
console设备与console驱动
根据此配置项的说明,我们知道printk()
是Zephyr的一个内核服务,它可以让通过printk()
函数打印的内容通过”console”输出。这里的console指的是一个设备,可以让Zephyr系统输入和输出字节流。
通过查看build/zephyr/zephyr.dts
,可以看到:
/{ |
在/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 |
然后就可以看到,printk()的日志从RTT中打印出来了。
对于探究心强的读者,到这里肯定又会有疑问:为什么把console后端改成了RTT,只改了config,设备树就不用改了?
关于这个问题,我想先传达出一个观点,那就是一个系统无论使用了什么样的框架,最终一定要落实到代码。通过在NCS中全局搜索CONFIG_RTT_CONSOLE
和CONFIG_UART_CONSOLE
,我们最终能找到这样的一个文件,${NCS}/zephyr/drivers/console/CMakeLists.txt
:
... |
console本身作为一个中间件,也是要通过驱动程序向Zephyr提供标准console API的。在这里,CMake根据不同的CONFIG配置项,添加了不同的console驱动源码进入系统之中,进行编译。
在uart_console.c中,我们明显能看到,此驱动代码需要通过device tree来找到标准的串口设备,然后调用标准的串口API来通信。
static const struct device *const uart_console_dev = |
而在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
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),不能同时使能。
他们的ENABLE寄存器地址是相同的,但是使能所用的bit不同:
UARTE (UART with EasyDMA) 功能和UART是类似的,只不过有了EasyDMA的帮助,可以自动从RAM中取出数据发出;也可以把收到的数据直接存入RAM。无需CPU参与单个字节的收发处理,提升了效率,降低了功耗。
UARTE发送逻辑
- TXD.PTR填入数据在RAM中的首地址,TXD.MAXCNT填入要发送的数据长度(nRF52840最大65535,nRF52832最大255).
- 使用STARTTX来启动自动的传输
- 传输完毕后,有ENDTX事件提示
- 中间每个字节的TXDRDY事件,CPU可以无视
注意:
- 串口的发送功能只在STARTTX和ENDTX之间有功耗,其余时间几乎不产生电流消耗
- EasyDMA只能在RAM和外设之间传输数据,不能在RAM之间传输,也不能有FLASH参与。
UARTE接收逻辑
- RXD.PTR填入数据首地址,RXD.MAXCNT填入要发送的数据长度(nRF52840最大65535,nRF52832最大255).
- 使用STARTRX来启动自动的接收
- 传输完毕后(指RAM中存的数据长度已经达到了MAXCNT),有ENDRX事件提示
- 中间每个字节的RXDRDY事件,无需再使能中断(从而降低功耗,提高CPU效率)
特别地,RXD.PTR具有双缓存(影子寄存器)。也就是说,不用等到传输完成,只需在第一次接收开始后(RXSTARTED),就马上给RXD.PTR写入下一次要用的buffer首地址。这样下次传输时,就能立刻用上新的buffer。便于应用层实现双buffer。
注意:
- 串口在接收状态(STARTRX)会有功耗,有几百uA。因此需要避免待机时一直开着RX。
- UARTE只有在接收完毕(buffer满)时才会产生中断。本身没有空闲帧中断,或者说超时机制。需要其他外设辅助实现。
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的使用流程,方便开发者结合代码进行观看。
以下为流程:
- 开始时,用
uart_irq_callback_set()
函数设置好“应用层的”中断回调函数,这个函数会在串口ISR中根据情况被串口驱动程序调用。然后,开启uart_irq_rx_enable()
,使能接收。 - 要发送时,先准备好要发送的数据(首地址和长度),然后
uart_irq_tx_enable()
开启发送中断。 - 发生中断,进入预先设置好的回调函数时,先用
uart_irq_update()
更新中断状态,再用uart_irq_is_pending()
判断是否有中断(以防是别处误调用了该回调函数)。再之后用uart_irq_tx_ready()
和uart_irq_rx_ready()
来判断是发送中断还是接收中断。 - 如果是接收中断,在中断里用
uart_fifo_read()
循环读取,每次读取1个字节(Nordic的驱动实现是只读1个字节),直到返回值为0(表示缓存里已无数据)。 - 如果是发送中断,说明发送器已经ready,在中断里用
uart_irq_tx_fill()
,把前面准备好要发送的数据传入,即可发送。
注意:
- 不要在中断callback里进行耗时的处理和阻塞行为。善用queue和work queue。
uart_fifo_read()
和uart_fifo_fill()
只能在这个中断callback函数内部调用
异步
异步API是本文介绍的重点,它带有DMA,因此可以让数据传输时,不影响CPU的运行。但是它的配置最复杂,功能最强大。
异步发送
调用uart_tx(dev, *buf, len, timeout)
,给定首地址和长度即可,函数不阻塞。Timeout是给流控用的,如果输出被对方的流控阻止,自己能等待多久,如果没有流控就不用在意。
发送过程由驱动层和硬件自动处理。
发送完毕后,回调函数里会收到UART_TX_DONE事件。
注意:
- 注意发送数据buffer的生命周期,不能是局部变量
- 如果buffer的地址不属于于RAM,Nordic的驱动程序会先自动执行一个拷贝到RAM中的动作。因为硬件不支持RAM以外的地方到外设的DMA。
- uart_tx这个行为是低功耗的。只要不在发送,就没有发送行为相关的功耗。无需disable串口。
异步接收
- 用
uart_rx_enable(dev, *buf, len, timeout_us)
来首次使能接收。给定Buffer和长度。Buffer收满以后会产生UART_RX_RDY事件。 - Timeout是空闲超时机制。在至少收到1个字节之后,即使buffer未满,如果超时,也会产生
UART_RX_RDY
事件。这是为了方便收取一个小于buffer长度的包的情况。单位是微秒。timeout时间设为SYS_FOREVER_US会关闭这个机制。 - 如果buffer未满,下次数据接收会继续填充在此buffer内。如果buffer已满,紧接在UART_RX_RDY事件之后,会产生UART_RX_BUF_RELEASED事件。告知应用层,一开始的buffer已经不再使用,可以释放。
- 异步API也提供双Buffer机制。当每次接收开始时,驱动层会立即产生UART_RX_BUF_REQUEST事件。向应用层请求第二个buffer。应用层有两个选择:
- 用uart_rx_buf_rsp(dev, *buf, len)来设置第二个buffer。当第一个buffer满时,驱动层自动开始用第二个buffer。
- 无视这个请求。那么这次接收完毕时,整个接收会被disable。需要再次enable才能开始接收。
Zephyr串口驱动
无论是阻塞、基于中断、还是异步API。它们都是由Nordic的驱动程序提供的。
当CONFIG_SERIAL=y
,就使能了Zephyr的串口驱动。Zephyr系统内的CMake规则会自动把相关MCU的串口驱动编译进去。
而Nordic是提供了三种驱动的:
使用时,注意在device tree中设置正确的compatible,来选择正确的驱动。
设备树的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. 异步串口代码示例
这里我给出修改后的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
:
/* |
代码非常简单,首先是获得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函数将其释放即可。
本代码实现了一个回环,会把串口收到的数据继续从串口发出。
这里有一些小细节:
回调函数的形参evt,在call stack中上一层的驱动代码里是一个局部变量。在回调函数返回后,evt会被释放。因此这里如果要实现回环,需要拷贝一份到静态内中。即
static uint8_t buf[128]
。如果某一次接收到了很多数据,超出了buffer的剩余空间。那么这次收到的数据就会被分成两部分,产生两次接收回调。这也意味着,我们必须把接收到的数据看作是“字节流”而不是“包”。开发者应该自己实现字节流解包处理函数,例如:
for (int i = 0; i < evt->data.rx.len; i++){
bytes_to_packet(p[i]); // 开发者自行实现解包函数
}回调函数实际上运行在中断服务函数内部,因此不要做一些阻塞的行为。如果真的有计算量大的任务,可以把任务提交到Workqueue Threads。这样你就能把耗时的任务从特权模式移动到用户模式,也就是从中断内部移动到线程中。
异步串口配置
prj.conf
use RTT as console |
首先,把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 { |
主要属性介绍:
reg = < 0x40002000 0x1000 >
:芯片自带的属性,外设的地址compatible = "nordic,nrf-uarte"
:此处选择了uarte的驱动而非uart驱动。因此最终编译时用的代码是uart_nrfx_uarte.c而非uart_nrfx_uart.cstatus = "okay"
:驱动代码自动初始化外设时,只会初始化状态为"okay"
的节点。current-speed = < 0x1c200 >
:波特率,也就是写十进制current-speed = < 115200 >
其余属性,与中断、引脚分配、Zephyr低功耗有关。将在今后的文章中介绍。
硬件连接
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空闲超时。
6. USB CDC ACM串口
前面介绍了硬件串口的异步API,接下来我们介绍USB CDC ACM串口。
USB串口设备树
通过查看build/zephyr/zephyr.dts
,我们可以看到USBD外设的状态:
这个节点有两个label,一个是usbd
,一个是zephyr_udc0
,我们任选一个使用就好。我们可以参考USB例程${NCS}/zephyr/samples/subsys/usb/cdc_acm/
来修改设备树。
先在项目根目录下新建app.overlay
:
&zephyr_udc0 { |
这里我们给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" |
使我们的工程菜单,在Zephyr本身配置菜单的基础上,多一个可配置的选项CONFIG_USB_UART_ASYNC_ADAPTER
。然后修改prj.conf
:
use RTT as console |
可能你会疑惑,为什么要自定义一个配置项,然后通过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 |
然后,给出略加修改后的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
的实现不同,参数从二级指针变为了一级指针。
/* |
代码解析:
获取设备树节点对应的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串口
左侧是Jlink USB,下方是nRF52840的USB Device接口。
我在Linux系统中查看串口,会发现多了一个新的串口/dev/ttyACM2
:
通过minicom或其他串口软件打开这个串口,其行为和前面介绍的异步串口是一样的:发送任意内容,然后等待2s左右,同样的数据从串口回环打印出来:
7. 低功耗串口
在不开启串口等非必要外设的情况下,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原理
Nordic提供了一个软件实现的低功耗串口,通过额外的两根线来识别是否有数据发出。由于是对称连接的,这里只给出一个MCU的TX和REQ引脚的实测波形图:
通信步骤:
- 发送端要发送数据前,先拉高REQ引脚;然后把REQ引脚配置为上拉输入模式。
- 接收端RDY检测到上升沿,把串口RX使能。然后从RDY引脚输出一个几毫秒的负脉冲。然后把RDY引脚配置为上拉输入模式。
- 发送端REQ引脚检测到上升沿,才通过TX发出数据
- 发送完毕后,发送端拉低REQ引脚
- 接收端检测到RDY引脚下降沿,关闭串口RX。并产生
UART_RX_RDY
事件给应用层。
可以看出,这时
UART_RX_RDY
事件并不是空闲超时产生的,而是RDY引脚上拉产生的。因此会比普通的异步串口回调更早执行。
Nordic LPUART例程
相关链接:
lpuart例程位置:${NCS}/nrf/samples/peripheral/lpuart
例程有基于中断和异步串口两种模式。相信看完了前面内容的你,看懂这些代码对你来说已经轻而易举了。
这里只再说一些配置相关的内容。
首先是设备树,在boards/nrf52840dk_nrf52840.overlay
中:
&uart1 { |
我们可以看到,在串口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相比,只是多出来了CONFIG_NRF_SW_LPUART=y
来使能这个库。
LPUART例程运行
例程本身需要外部回环,因此你需要用两根杜邦线进行跳线:
RDY和REQ引脚一定要接,否则驱动程序内部的状态会乱掉,出现错误。你可以像我这样先跳线,然后外面再用示波器、逻辑分析仪或USB转串口的RX进行监测。
功耗表现
可以看到,在空闲无数据传输时,平均功耗在3.74uA左右。
8. 开发自己的程序
把串口程序集成到自己的应用中
如果你要开发自己的程序,千万不要想着怎么在其他程序里调用今天我分享的这些串口程序、怎么把自己的回调函数注册进来。因为,这相当于你又自己写了一个套娃串口驱动程序。
Zephyr是一个RTOS,这意味着,我们可以把今天分享的内容,当作一个线程。比如,把main()
函数改名为serial_thread_task()
,然后在文件的结尾,加上:
K_THREAD_DEFINE(serial_task_id, STACKSIZE, serial_thread_task, NULL, NULL, NULL, |
STACKSIZE和PRIORITY请自行定义。
如果你要做串口发送,可以建一个消息队列(k_msgq),在线程中循环等待。以下是一段简单的示例代码:
while (1) { |
如果你要做串口接收,前面说了,由于接收缓存的长度和收到数据长度不一定是整数倍关系。当收到接收回调事件时,可能收到的不是发送端的发的完整的包。而是先收到前面一部分,再收到后面一部分。这就要求我们要把接收到的数据当作是字节流而不是包来处理。需要实现自己的解包函数。
如果你的解包函数太复杂,里面还有一些耗时的业务代码要执行。可以考虑把解包函数提交给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 |
要使用异步API,必须单独禁用这个串口的中断API。
如果你要使用USB CDC ACM(需要中断API)的同时使用串口0的异步API,则需要这样配置:
CONFIG_UART_INTERRUPT_DRIVEN=y |
意思是,对于整个串口驱动来说,启用中断API(这个配置项来源于Zephyr串口驱动目录下的Kconfig)。
但是对于串口0来说,关闭中断API(这个配置项来源于Zephyr串口驱动目录下的Kconfig.nrfx)