1. 前言 之前编写了两篇与Zephyr设备树和驱动相关的文章:
如果你看完这两篇文章,相信你对Zephyr的驱动模型已经有一定概念了。开发者能够直接使用厂商写好的高效、完善的驱动程序,无需再埋头于寄存器手册、波形时序、引脚配置等底层程序,只需专注于自己的应用即可。
但是,做嵌入式开发,总还有各种各样的原因,让人不得不陷入底层软件调试之中,包括但不限于:
厂商提供的外设驱动都只有标准的,但我的应用场景是非标准的
我需要多个外设在底层进行深入的联动,厂商提供的驱动无法满足我的需求
我不想学Device Tree和驱动模型,我就喜欢老的nRF5 SDK那种开发方法
……
话先说在前面,Nordic的nRF5 SDK从17.10开始就不再更新了。从nRF53系列(也就是nRF5340)、nRF91蜂窝网络、nRF70 Wi-Fi系列以及马上要出的强力的nRF54系列产品,都只能使用NCS了。并且,今后单片机的嵌入式软件开发会越来越复杂:TCP/IP、蓝牙、USB、Matter、OTA、文件系统、显示屏……各种复杂的应用和协议栈,如果都从底层寄存器开始做起,那真是不知道要浪费多少时间,关键是还不一定能做成功。Zephyr是一个RTOS,除了基本的多线程、线程间通讯之外,还有功耗管理、线程监控、自定义shell命令、非易失存储、DSP、加密、代码与变量位置重定向、C++支持等等功能。并且,Zephyr是Linux基金会维护的开源项目,底层也是POSIX接口,编译与配置系统是CMake和Kconfig,这意味着Zephyr可以很容易的集成许多第三方开源项目进来。
不过Zephyr再好,厂商肯定是不会把底层调试的路完全封死的。本文将会以nRF52840DK为例,介绍在NCS中,如何使用NRFX库来使用I2C从机。这个NRFX库和之前nRF5 SDK中的nrfx几乎是一致的。
2. 硬件连接
I2C主机:SDA(P0.26) SCL(P0.27)
I2C从机:SDA(P0.30) SCL(P0.31)
3. 例程代码 工程结构 本文以${NCS}/zephyr/samples/hello_world
为基础。主要文件如下:
|-- boards | | | `-- nrf52840dk_nrf52840.overlay | |-- src | | | |-- main.c | |-- i2c_slave.c | `-- message.h | |-- CMakeLists.txt `-- prj.conf
CMakeLists.txt 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/i2c_slave.c ) target_include_directories (app PRIVATE src)
prj.conf # the I2C matser using Zephyr driver CONFIG_I2C=y # the I2C slave using NRFX driver # see: https://github.com/zephyrproject-rtos/zephyr/issues/21445 CONFIG_NRFX_TWIS1=y
说明:
本例主机采用Zephyr标准I2C驱动,故需要使能
Zephyr没有标准I2C从机驱动,故此处使用NRFX库
由于版权原因,Nordic的I2C外设叫做TWI(Two-Wire Interface)
设备树overlay boards/nrf52840dk_nrf52840.overlay
&i2c0 { compatible = "nordic,nrf-twim" ; status = "okay" ; clock-frequency = <I2C_BITRATE_STANDARD>; pinctrl-0 = <&i2c0_default>; pinctrl-1 = <&i2c0_sleep>; pinctrl-names = "default" , "sleep" ; }; &i2c1 { compatible = "nordic,nrf-twis" ; status = "okay" ; }; &pinctrl { i2c0_default: i2c0_default { group1 { psels = <NRF_PSEL(TWIM_SDA, 0 , 26 )>, <NRF_PSEL(TWIM_SCL, 0 , 27 )>; nordic,drive-mode = <NRF_DRIVE_S0D1>; bias-pull-up; }; }; i2c0_sleep: i2c0_sleep { group1 { psels = <NRF_PSEL(TWIM_SDA, 0 , 26 )>, <NRF_PSEL(TWIM_SCL, 0 , 27 )>; low-power-enable; }; }; i2c1_default: i2c1_default { group1 { psels = <NRF_PSEL(TWIM_SDA, 0 , 30 )>, <NRF_PSEL(TWIM_SCL, 0 , 31 )>; bias-pull-up; }; }; i2c1_sleep: i2c1_sleep { group1 { psels = <NRF_PSEL(TWIM_SDA, 0 , 30 )>, <NRF_PSEL(TWIM_SCL, 0 , 31 )>; low-power-enable; }; }; };
主机device tree说明:
i2c0
为主机,采用pinctrl来配置引脚。
由于I2C协议要求开漏输出,因此我们这里的i2c0引脚配置为NRF_DRIVE_S0D1
,其含义是“输出逻辑0时为标准GND输出,输出逻辑1时内部断开(高阻态)”。
由于I2C协议要求上拉电阻,而我们是杜邦线跳线没有上拉电阻,因此这里采用内部上拉。
compatible
一定要选择"nordic,nrf-twim"
而不是"nordic,nrf-twi"
,前者是带DMA的驱动程序,后者是不带DMA的驱动程序。
补充:
除了S(Standard)和D(Disconnect)之外,还有H(High-drive)。一些高速接口需要IO有更强的驱动能力,这种情况下可以配置为NRF_DRIVE_H0H1
。不过具体还是要看芯片手册里面每个GPIO是否支持高驱(位于手册里的Pin assignment章节):
GPIO内部上/下拉电阻比较大,典型值为13千欧。因此如果用内部上拉,速率只能配置为100Kbps。若要更高的速率,外部可以挂更小的电阻,例如5千欧左右可以达到400Kbps.
从机device tree说明:
i2c1
为从机。从机的代码是用NRFX的,完全没有用到device tree。这里修改i2c1的device tree,完全是Kconfig的要求:
config NRFX_TWIS1 bool "TWIS1 driver instance" depends on $(dt_nodelabel_has_compat,i2c1,$(DT_COMPAT_NORDIC_NRF_TWIS)) select NRFX_TWIS
我们可以看到,CONFIG_TWIS1
的依赖项,要求device tree中的i2c1
节点,必须具有compatible="nordic,nrf-twis"
。只有满足这个条件,我们才能写CONFIG_TWIS1=y
。并且,此配置项会自动连锁使能CONFIG_NRFX_TWIS=y
。
我们要明确一点,NRFX是不需要device tree的。这里Kconfig的依赖项,我想只是为了能在项目中明显地指示出 ,我们使用了i2c1
这个外设。以免在多人开发项目中产生一些误会,导致外设被重复使用。
同理,从机的pinctrl也是没有实际作用的。写它只是为了能够在Device Tree的GUI中正确显示引脚分配:
message.h #ifndef _MESSAGE_H_ #define MASTER_TO_SLAVE "Master to Slave" #define SLAVE_TO_MASTER "Slave to Master" #endif
这里面只是主从机共用的测试用数据而已
主机代码 main.c
#include <zephyr/kernel.h> #include <zephyr/device.h> #include <zephyr/devicetree.h> #include <zephyr/drivers/i2c.h> #include "message.h" static const struct device *twi_dev = DEVICE_DT_GET(DT_NODELABEL(i2c0));#define I2C_ADDR 0xbeef int main (void ) { char send_data[] = MASTER_TO_SLAVE; char read_data[128 ]; if (!device_is_ready(twi_dev)){ printk("twi device is not ready!\n" ); return 0 ; } while (1 ) { k_sleep(K_MSEC(3000 )); printk("\n[Master] write: \"%.*s\"\n" , sizeof (send_data), send_data); i2c_write(twi_dev, send_data, sizeof (send_data), I2C_ADDR); k_sleep(K_MSEC(2000 )); printk("\n[Master] read\n" ); i2c_read(twi_dev, read_data, sizeof (SLAVE_TO_MASTER), I2C_ADDR); printk("[Master] received:\"%.*s\"\n" , sizeof (SLAVE_TO_MASTER), read_data); k_sleep(K_MSEC(1000 )); } return 0 ; }
主机程序说明:
主机会循环向i2c写入数据、然后读取数据。
先slepp,后发数据,是为了开始时要先等从机那边初始化好。
Zephyr标准API中,I2C从机地址这个参数是16bit的。但对于Nordic的芯片来说,只有最低7bit是真实的地址 。硬件上也只支持7bit地址。
所有这些I2C读写API都是阻塞 的。但是,底层是DMA,怎么会阻塞呢?原来,这里的阻塞只是“线程阻塞”而不是“CPU阻塞”。当I2C开始传输后,I2C的驱动实际上是在尝试take一个信号量,这时,当前线程会被阻塞,但其他线程可以正常执行 。当DMA传输完成后,产生中断,中断内部give这个信号量,于是读/写函数就可以返回了。
这也告诉我们,在RTOS中,应该尽量把不同的模块分成不同的线程去开发。这样既使得程序结构清晰,又不会有任务之间互相阻塞的问题。
从机代码 i2c_slave.c
#include <zephyr/device.h> #include <zephyr/devicetree.h> #include <nrfx_twis.h> #include <zephyr/kernel.h> #include "message.h" #define STACKSIZE 1024 #define PRIORITY 7 #define SLAVE_ADDR 0xef #define TWI_INT_PRIORITY 2 #define SLAVE_SDA_PIN 30 #define SLAVE_SCL_PIN 31 #define TWIS_INST_IDX 1 static nrfx_twis_t twis_inst = NRFX_TWIS_INSTANCE(TWIS_INST_IDX);#define MSG_TO_SEND "Slave-to-Master" static uint8_t m_tx_buffer_slave[sizeof (MSG_TO_SEND)] = MSG_TO_SEND;static uint8_t m_rx_buffer_slave[128 ];static void twis_handler (nrfx_twis_evt_t const * p_event) { nrfx_err_t status; (void )status; switch (p_event->type) { case NRFX_TWIS_EVT_WRITE_DONE:{ uint32_t len = p_event->data.rx_amount; printk("--> Slave event: received write done.\n" ); printk("[Slave] received:\"%.*s\"\n" , len, m_rx_buffer_slave); break ; } case NRFX_TWIS_EVT_WRITE_REQ:{ status = nrfx_twis_rx_prepare(&twis_inst, m_rx_buffer_slave, sizeof (m_rx_buffer_slave)); NRFX_ASSERT(status == NRFX_SUCCESS); printk("--> Slave event: received write request\n" ); break ; } case NRFX_TWIS_EVT_READ_DONE:{ printk("--> Slave event: received read done.\n" ); break ; } case NRFX_TWIS_EVT_READ_REQ: { status = nrfx_twis_tx_prepare(&twis_inst, m_tx_buffer_slave, sizeof (m_tx_buffer_slave)); NRFX_ASSERT(status == NRFX_SUCCESS); printk("--> Slave event: received read request\n" ); break ; } case NRFX_TWIS_EVT_READ_ERROR: printk("\nTWIS READ ERROR\n" ); break ; case NRFX_TWIS_EVT_WRITE_ERROR: printk("\nTWIS WRITE ERROR\n" ); break ; case NRFX_TWIS_EVT_GENERAL_ERROR: printk("\nTWIS GENERAL ERROR\n" ); break ; default : printk("--> SLAVE event: %d." , p_event->type); } } int i2c_slave_entry () { nrfx_err_t status; (void )status; printk("Starting nrfx_twim_twis non-blocking example." ); IRQ_CONNECT(DT_IRQN(DT_NODELABEL(i2c1)), DT_IRQ(DT_NODELABEL(i2c1), priority), nrfx_isr, twis_handler, 0 ); nrfx_twis_config_t twis_config = { .addr[0 ] = SLAVE_ADDR, .addr[1 ] = 0 , .scl = SLAVE_SCL_PIN, .sda = SLAVE_SDA_PIN, .scl_pull = NRF_GPIO_PIN_PULLUP, .sda_pull = NRF_GPIO_PIN_PULLUP, .interrupt_priority = TWI_INT_PRIORITY, .skip_gpio_cfg = false , .skip_psel_cfg = false , }; printk("\nI2C Slave: ADDR: 0x%x, SCL: %d, SDA: %d, int_pri: %d" , twis_config.addr[0 ], twis_config.scl, twis_config.sda, twis_config.interrupt_priority); if (nrfx_twis_init(&twis_inst, &twis_config, twis_handler) == NRFX_SUCCESS){ printk("\nnrfx twis initialized.\n\n" ); } else { printk("\nERROR: nrfx_twis_init()\n" ); } IRQ_DIRECT_CONNECT(NRFX_IRQ_NUMBER_GET(NRF_TWIS_INST_GET(TWIS_INST_IDX)), IRQ_PRIO_LOWEST, NRFX_TWIS_INST_HANDLER_GET(TWIS_INST_IDX), 0 ); nrfx_twis_enable(&twis_inst); while (1 ){ k_sleep(K_FOREVER); } } K_THREAD_DEFINE(i2c_slave_id, STACKSIZE, i2c_slave_entry, NULL , NULL , NULL , PRIORITY, 0 , 0 );
从机程序说明:
从机用nrfx库初始化、注册中断服务函数、并使能TWIS。这部分代码,和nrf5 SDK中nrfx库的用法是一样的。
与nRF5 SDK中略有不同的是,中断服务函数需要用Zephyr的机制去连接一下。这是因为Zephyr默认把所有中断向量全占了,用于整个驱动模型。这部分请参考Zephyr内核文档——中断 。今后我也会写一篇博客来介绍Zephyr的中断。
从机的程序也是一个线程,用K_THREAD_DEFINE
来定义线程。线程中初始化完TWIS后,就进入了永久睡眠。
对于I2C WRITE操作,在主机发送完7bit地址,之后的那一个比特为0(写)时,这一瞬间立即产生NRFX_TWIS_EVT_WRITE_REQ
事件,这时要准备好接收数据的缓存。写完后,产生NRFX_TWIS_EVT_WRITE_DONE
事件。
对于I2C READ操作也是同理。NRFX_TWIS_EVT_READ_REQ
时,准备好即将要发送的缓存。NRFX_TWIS_EVT_READ_DONE
说明发送完毕。
注意,不一定要在NRFX_TWIS_EVT_READ_REQ
时,才开始准备要发送的数据。可以提前调用nrfx_twis_tx_prepare()
来准备要发送的数据。接收也是同理。
程序运行结果
4. 应用开发 其他NRFX例程 位于${NCS}/modules/hal/nordic/nrfx/samples/src
与其他Zephyr标准驱动的共存 通过前面的讲解,相信多数人都能理解,NRFX的驱动程序和Zephry的标准驱动是可以共存的,只要用到的外设资源 不冲突、GPIO不冲突 即可。
这里还有另外两个要注意的点:
外设地址冲突 Nordic的串行外设(串口、SPI、SPIM、SPIS、TWI、TWIM、TWIS),并不是都能同时使用的。有些外设其实是同样的外设地址,内部共用了一部分串行和DMA电路。这样就造成了有很多外设可用的假象。实际上只是同一个芯片内部电路的不同使用方法。
我们可以在芯片手册 Memory 章节的 Instantiation 小节看到所有外设实例的地址:
可以看到,对于52840来说,串口0是独立的,SPI0和TWI0是二选一,SPI1和TWI1是二选一。
不仅是NRFX,对于Zephyr标准驱动来说,也是一样的。我们可以从DeviceTree中看到,冲突的外设,它们的地址是相等的。
此外,从右边我们可以看到,不带DMA的外设目前基本是不用的。
资源型外设的自动分配 在Nordic的单片机中,有很多“资源型”外设。我把它叫做资源型,是因为这些外设之间没有任何区别,用谁都一样,比如:
GPIOTE :GPIOTE有8个独立的通道,每个通道可以连一个GPIO,使其获得TASK和EVENT寄存器,用来产生中断、连接PPI通道等。
PPI :可以连接一个外设的EVENT寄存器,和两个其他外设的TASK寄存器,使得EVENT自动触发TASK。nRF52840有20个自由的PPI通道(还有12个固定的)。
理论上讲,当你用这些个通道时,随便用哪个都没区别。但是,你怎么知道目前Zephyr系统内的什么驱动已经用掉了哪个通道呢? 如何防止自己选的通道和Zephyr内目前已经用的通道冲突?
答案就是,别自己选。这些外设之所以叫资源型,就是因为它和内存等资源一样,可以集中管理,动态的分配和释放。而Nordic提供的所有Zephyr驱动,凡是用到了这些外设的,都是使用其对应的allocate函数,而不是直接指定一个具体的资源 。
例如,对于GPIOTE通道,可以用:
nrfx_err_t nrfx_gpiote_channel_alloc (uint8_t * p_channel) ;
对于PPI通道,可以用:
nrfx_err_t nrfx_gppi_channel_alloc (uint8_t * p_channel)
Nordic不同芯片,有的是PPI,有的是分布式PPI(DPPI)。NCS中统一用gppi,这是一个wrapper,会自动根据芯片平台选择调用PPI或DPPI的API。