用户在使用 nRF connect SDK 的时候经常会操作的外设有GPIO,I2C,SPI,UART。我们就以 nRF connect SDK 2.7.0 中的例程代码 nrf\samples\bluetooth\peripheral_lbs 为基础,来演示上述外设的简单使用。使用的硬件是开发板 nRF52840 DK.
准备工作
-
首先我们在原本的工程目录的 boards 文件夹里,添加文件 nrf52840dk_nrf52840.overlay。通过这个文件我们可以修改 devicetree。 编译完成后,我们可以查看 build\zephyr\zephyr.dts,以确认devicetree 的更改是否生效。
-
我们还可以通过修改 prj.conf 来修改 Kconfig。编译完成后,我们可以查看 build\zephyr\.config 以确认 Kconfig 的更改是否生效。
GPIO 控制
-
首先我们演示如何删除原有的按键和LED的 node 。按照下面的代码,来修改 devicetree,就可以删除 button3 和 led3。
/ {
aliases {
/delete-property/ sw3;
/delete-property/ led3;
};
};
/delete-node/ &button3;
/delete-node/ &led3;
-
接着我们来更改控制 led2 的管脚。这里我们用 P0.04 控制 led2。
&led2 {
gpios = <&gpio0 4 GPIO_ACTIVE_LOW>;
};
-
最后我们添加一个用户 GPIO 。这里添加了一个名为 user_gpios 的 node。然后又定义了 user_io0,它是 user_gpios 的 subnode。
/ {
user_gpios {
compatible = "gpio-leds";
user_io0: user_io0 {
gpios = <&gpio0 16 GPIO_ACTIVE_LOW>;
label = "user gpio 0";
};
};
};
我们不仅在 devicetree 里添加这个 GPIO ,还要在 main.c 里添加代码使用这个GPIO。下面这句代码中,我们声明了结构体变量 user_gpio0,并用宏 GPIO_DT_SPEC_GET 根据 devicetree 里的定义初始化它。
const struct gpio_dt_spec user_gpio_0 = GPIO_DT_SPEC_GET(DT_NODELABEL(user_io0),gpios);
下面这段代码中 gpio_is_ready_dt 是用来检查 GPIO 的状态是否是就绪。用函数 gpio_pin_configure_dt 把 user_gpio_0 配置成输出。gpio_pin_toggle_dt 用来翻转 GPIO。
if (!gpio_is_ready_dt(&user_gpio_0)) {
printk("%s: device not ready.\n", user_gpio_0.port->name);
return 0;
}
gpio_pin_configure_dt(&user_gpio_0, GPIO_OUTPUT_ACTIVE);
for (index = 0; index < 100; index++) {
gpio_pin_toggle_dt(&user_gpio_0);
k_sleep(K_MSEC(100));
}
从下面的代码可以看出翻转 GPIO 这个操作有两种 API 可以调用。二者的主要区别是 gpio_pin_toggle_dt 不需要指明引脚 。
/**
* @brief Toggle pin level from a @p gpio_dt_spec.
*
* This is equivalent to:
*
* gpio_pin_toggle(spec->port, spec->pin);
*
* @param spec GPIO specification from devicetree
* @return a value from gpio_pin_toggle()
*/
static inline int gpio_pin_toggle_dt(const struct gpio_dt_spec *spec)
{
return gpio_pin_toggle(spec->port, spec->pin);
}
I2C 设备控制
Nordic 的芯片中 I2C 接口是由外设 TWI 来实现的,I2C master 由 TWIM 实现, I2C master 由 TWIS 实现。这里将演示如何用一个 TWIM 来连接两个 I2C slave 设备。
-
首先我们还是先修改 devicetree。我们使用 i2c1 这个 node。 一方面按照应用的要求修改这个 node 的 propertise,另一方面在这个 node 里创建两个 sub-node。
-
i2c 的时钟频率通过 clock-frequency 来定义。
-
i2c 的引脚通过 pinctrl-0 和 pinctrl-1 定义。我们将在后面分析 i2c1_default 和 i2c1_sleep 的定义。
-
这两个 sub-node 一个是 user_i2c_sensor,另一个是 user_i2c_eeprom。这两个 sub-node 通过 propertise reg 来定义各自的 I2C 地址。
&i2c1 {
status = "ok";
clock-frequency = <I2C_BITRATE_STANDARD>;
pinctrl-0 = < &i2c1_default >;
pinctrl-1 = < &i2c1_sleep >;
pinctrl-names = "default", "sleep";
user_i2c_sensor: user_i2c_sensor@0 {
compatible = "i2c-user-define";
reg = <0xA>;
};
user_i2c_eeprom: user_i2c_eeprom@0 {
compatible = "i2c-user-define";
reg = <0x5>;
};
};
-
i2c1_default 和 i2c1_sleep的定义如下。TWIM_SDA 信号使用的是引脚 P0.04,TWIM_SCL 信号使用的是引脚 P0.03。
&pinctrl {
i2c1_default: i2c1_default {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 4)>,
<NRF_PSEL(TWIM_SCL, 0, 3)>;
};
};
i2c1_sleep: i2c1_sleep {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 4)>,
<NRF_PSEL(TWIM_SCL, 0, 3)>;
low-power-enable;
};
};
};
-
修改 prj.conf 添加 CONFIG_I2C=y
-
修改完 devicetree 我们在来添加操作 i2c 的代码。分别定义 i2c1_sensor 和 i2c1_eeprom,它们对应刚才 i2c1 的两个子节点。
const struct i2c_dt_spec i2c1_sensor = I2C_DT_SPEC_GET(DT_NODELABEL(user_i2c_sensor));
const struct i2c_dt_spec i2c1_eeprom= I2C_DT_SPEC_GET(DT_NODELABEL(user_i2c_eeprom));
i2c 设备在读写操作前无需调用 API 来配置 ,直接调用下面的写函数。
err = i2c_write_dt(&i2c1_sensor, buf, 1);
err = i2c_write_dt(&i2c1_eeprom, buf, 1);
通过逻辑分析仪我们可以看到如下的总线数据,操作的目标地址分别是我们在 devicetree 里设置的数值 0x05 和 0x0A 。
SPI 设备控制
Nordic 的芯片中 SPI 接口的 master 端通过 SPIM 实现, slave 端通过 SPIS 实现。这里将演示如何用一个 SPIM 来连接两个 SPI slave 设备。
-
首先修改 devicetree。
-
这里我们使用 spi2, 并且关闭 spi1。在 nordic 的nRF52 系列芯片中,相同数字编号的 TWIM, TWIS, SPIM, SPIS 是共用一组硬件模块的。在上面 i2c 中我们已经使用 i2c1, 所以这里我们就不能同时使用 spi1了。
-
cs-gpios 定义了 P0.26 和 P0.27 两 个CS 信号。 SPI 用不同的片选信号,区分不同的 slave 设备。
-
devicetree node spi2 下定义了两个 sub-node 分别是 user_spi_adc 和 user_spi_flash。 sub-node 里定义了三个 propertise。propertise compatible 的取值来自于我们在工程里新添加的文件 dts\bindings\spi-user-define.yaml。 propertise reg 的取值和前面的 propertise cs-gpios 呼应,reg = <0> 的 sub-node 使用 cs-gpios 里面定义的第一个 CS 引脚。reg = <1> 的 sub-node 使用 cs-gpios 里面定义的第二个 CS 引脚。propertise spi-max-frequency 定义 SPI 的时钟频率。两个不同的 SPI 设备可以使用不同的时钟频率驱动。
&spi1 {
status = "disabled";
};
&spi2 {
status = "okay";
cs-gpios = <&gpio0 26 GPIO_ACTIVE_LOW>,
<&gpio0 27 GPIO_ACTIVE_LOW>;
pinctrl-0 = < &spi2_default >;
pinctrl-1 = < &spi2_sleep >;
pinctrl-names = "default", "sleep";
user_spi_adc: user_spi_adc@0 {
compatible = "spi-user-define";
reg = <0>;
spi-max-frequency = <DT_FREQ_M(8)>;
};
user_spi_flash: user_spi_flash@0 {
compatible = "spi-user-define";
reg = <1>;
spi-max-frequency = <DT_FREQ_M(8)>;
};
};
-
来看一下我们新添加的 dts\bindings\spi-user-define.yaml 里面的内容。如下图 spi-user-define.yaml 里面包含了 spi-device.yaml 文件,这个文件的位置在目录 zephyr\dts\bindings\spi 。
compatible: "spi-user-define"
include: [spi-device.yaml]
spi-device.yaml 文件里面定义了 spi 节点需要的一些 propertise。 比如我们在 sub-node 里定义的 propertise spi-max-frequency。
# Copyright (c) 2018, I-SENSE group of ICCS
# SPDX-License-Identifier: Apache-2.0
# Common fields for SPI devices
include: [base.yaml, power.yaml]
on-bus: spi
properties:
reg:
required: true
spi-max-frequency:
type: int
required: true
description: Maximum clock frequency of device's SPI interface in Hz
duplex:
type: int
default: 0
description: |
Duplex mode, full or half. By default it's always full duplex thus 0
as this is, by far, the most common mode.
Use the macros not the actual enum value, here is the concordance
list (see dt-bindings/spi/spi.h)
0 SPI_FULL_DUPLEX
2048 SPI_HALF_DUPLEX
enum:
- 0
- 2048
frame-format:
type: int
default: 0
description: |
Motorola or TI frame format. By default it's always Motorola's,
thus 0 as this is, by far, the most common format.
Use the macros not the actual enum value, here is the concordance
list (see dt-bindings/spi/spi.h)
0 SPI_FRAME_FORMAT_MOTOROLA
32768 SPI_FRAME_FORMAT_TI
enum:
- 0
- 32768
spi-cpol:
-
SPI 引脚定义如下 CLK P0.28, MISO P0.29, MOSI P0.30。
spi2_default: spi2_default {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 0, 28)>,
<NRF_PSEL(SPIM_MISO, 0, 29)>,
<NRF_PSEL(SPIM_MOSI, 0, 30)>;
};
};
spi2_sleep: spi2_sleep {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 0, 28)>,
<NRF_PSEL(SPIM_MISO, 0, 29)>,
<NRF_PSEL(SPIM_MOSI, 0, 30)>;
low-power-enable;
};
};
-
修改 prj.conf 添加 CONFIG_SPI=y CONFIG_SPI_ASYNC=y。
-
在 main.c 里添加 SPI 的应用代码。下面这段代码定义了两个结构体变量,并通过宏 SPI_DT_SPEC_GET 用 devicetree 里的参数初始化了这两个结构体变量。
#define SPI_OP SPI_OP_MODE_MASTER | SPI_MODE_CPOL | SPI_MODE_CPHA \
| SPI_WORD_SET(8) | SPI_LINES_SINGLE
static struct spi_dt_spec spim2_adc = SPI_DT_SPEC_GET(DT_NODELABEL(user_spi_adc), SPI_OP, 0);
static struct spi_dt_spec spim2_flash = SPI_DT_SPEC_GET(DT_NODELABEL(user_spi_flash), SPI_OP, 0);
spi 驱动支持多 buffer 所以要定义 buffer 个数,和每个 buffer 的长度。同样 spi 在读写之前无需调用配置函数,直接调用读写函数就行。
struct spi_buf_set tx_bufs;
struct spi_buf spi_tx_buf;
tx_bufs.buffers = &spi_tx_buf;
tx_bufs.count = 1;
spi_tx_buf.buf = buf;
spi_tx_buf.len = 2;
err = spi_write_dt(&spim2_adc, &tx_bufs);
err = spi_write_dt(&spim2_flash, &tx_bufs);
下面是SPI的波形。可以看到和不同的 spi slave 设备通讯的时候, spi master 会拉低不同的 CS 引脚。
UART 控制
Nordic 的芯片中 UART 接口叫做 UARTE。这里的 E 是指 EasyDMA , UART 可以使用 DMA 来连续收发。
-
修改 Devicetree。这里使用 uart1。propertise current-speed 设置 uart 的波特率。
&uart1 {
status = "okay";
current-speed = <115200>;
pinctrl-0 = < &uart1_default >;
pinctrl-1 = < &uart1_sleep >;
pinctrl-names = "default", "sleep";
};
TXD pin 为 P1.02, RXD pin 为 P1.01。
uart1_default: uart1_default {
group1 {
psels = <NRF_PSEL(UART_RX, 1, 1)>;
bias-pull-up;
};
group2 {
psels = <NRF_PSEL(UART_TX, 1, 2)>;
};
};
uart1_sleep: uart1_sleep {
group1 {
psels = <NRF_PSEL(UART_RX, 1, 1)>,
<NRF_PSEL(UART_TX, 1, 2)>;
low-power-enable;
};
};
-
修改 prj.conf 在里面添加 CONFIG_UART_ASYNC_API=y CONFIG_UART_ASYNC_RX_HELPER=y。
-
修改 main.c 添加 uart 收发代码。 uart_callback_set 设置 callback 函数 uart_cb。因为这里采用的是异步收发的模式,所以设置callback 函数是必备的。uart_rx_enable 使能接收。uart_tx 发送数据。
err = uart_callback_set(uart1, uart_cb, NULL);
//printk("uart_callback_set return %d\n", err);
err = uart_rx_enable(uart1, uart_rx_buf, MAX_UART_BUF_LEN, UART_RX_TIMEOUT_MS);
//printk("uart_rx_enable return %d\n", err);
err = uart_tx(uart1, uart_tx_buf, 6, SYS_FOREVER_MS);
//printk("uart_tx return %d\n", err);
callback 函数 uart_cb 可能由多种事件触发。比如当接收到数据后会触发回调,并在参数 EVT 传递 UART_RX_RDY 和接收到的数据和长度。
static void uart_cb(const struct device *dev, struct uart_event *evt, void *user_data)
{
ARG_UNUSED(dev);
//LOG_INF("uart_cb evt->type:%d", evt->type);
switch (evt->type) {
case UART_TX_DONE:
printk("UART_TX_DONE\n");
break;
case UART_RX_RDY:
printk("UART_RX_RDY\n");
printk("received %d bytes\n", evt->data.rx.len);
break;
case UART_RX_DISABLED:
printk("UART_RX_DISABLED\n");
break;
case UART_RX_BUF_REQUEST:
printk("UART_RX_BUF_REQUEST\n");
uart_rx_buf_rsp(uart1, uart_rx_buf2, MAX_UART_BUF_LEN);
break;
case UART_RX_BUF_RELEASED:
printk("UART_RX_BUF_RELEASED\n");
break;
case UART_TX_ABORTED:
printk("UART_TX_ABORTED\n");
break;
default:
break;
}
}
-
我们在 DK 上把 TXD 引脚和 RXD 引脚短接来测试 UART 的收发,可以看到如下的 log 信息。UART 收到了自己发送的6字节的数据。
UART 应用代码的优化
上面的 uart 演示代码中,我们只实现了简单的收发。下面我们将进一步在此基础上优化 UART 的收发代码。这一部分的修改都在 main.c 里,主要涉及下面几个部分:
-
Thread 线程
-
Semaphore 信号量
-
线程间通讯 Message queue
-
线程 下面的代码中通过 K_THREAD_DEFINE 定义了 一个独立的线程来处理 uart 相关的代码。线程处理函数 uart_thread_task 中:也是先用 uart_callback_set 设置了回调函数;再用 uart_rx_enable 使能了接收;然后是一个 for 循环,在里面不断的接收消息,根据消息中的指令发送数据,或者处理接收到的数据。
#define UART_THREAD_STACK_SIZE 512
#define UART_THREAD_PRIORITY -1
void uart_thread_task(void)
{
int err;
struct uart_data_item_type uart_msgq;
k_sem_take(&uart_thread_start, K_FOREVER);
printk("uart_thread_task\n");
err = uart_callback_set(uart1, uart_cb, NULL);
err = uart_rx_enable(uart1, uart_rx_buf, MAX_UART_BUF_LEN, UART_RX_TIMEOUT_MS);
for (;;) {
k_msgq_get(&uart_data_msgq, &uart_msgq, K_FOREVER);
printk("received uart data item\n");
switch(uart_msgq.cmd) {
case UART_CMD_TX:
memcpy(uart_tx_buf,&uart_msgq.data, sizeof(uint32_t));
err = uart_tx(uart1, uart_tx_buf, sizeof(uint32_t), SYS_FOREVER_MS);
break;
case UART_CMD_DATA_PROCESS:
break;
default:
break;
}
}
}
K_THREAD_DEFINE(uart_thread_id, UART_THREAD_STACK_SIZE, uart_thread_task, NULL, NULL,
NULL, UART_THREAD_PRIORITY, 0, 0);
上面的代码中用 K_THREAD_DEFINE 定义线程的时候,需要指定此线程的优先级 UART_THREAD_PRIORITY。UART_THREAD_PRIORITY 的数据类型是 integer,可以是正数也可以是负数。优先级的数字越小,优先级越高,负数的优先级比正数高。thread 的优先级取值为负数时,此 thread 为协同线程 cooperative thread 。当这种线程正在执行的时候,其它更高优先级的线程不能打断它,必须等它执行完再执行下一个线程。当 thread 的优先级取值为正数,此 thread 为抢占线程 preemptible thread。当这种线程正在执行的时候,其它更高优先级的线程可以打断它,跳转到高优先级的任务。等高优先级的线程执行完才返回原 thread 继续执行。回到例程代码,从应用的角度出发,我们希望 uart_thread_task 的执行优先级大于 main 函数。通过查询文件 build\zephyr\.config 我们得知 CONFIG_MAIN_THREAD_PRIORITY 的取值为 0,也就是说 main thread 当前的优先级为 0, 所以我们定义了 UART_THREAD_PRIORITY 为 -1。这样 uart thread 的优先级就高于 main thread, 而且 uart thread 的执行不会被其它更高优先级的 thread 打断。需要注意的是这里的不能被打断只是对 thread 而言,中断是可以打断 cooperative thread 的。
-
信号量 函数 uart_thread_task 的优先级比 main 函数高,所以会先于main 函数执行。如果之前的函数 uart_thread_task 里没有 k_sem_take(&uart_thread_start, K_FOREVER),就会出现如下图的现象。我们看到 uart thread 的 log 是先于 main thread 被打印出来的。
从应用的角度,我们希望 uart_thread_task 在 main 函数启动完广播之后再执行。这就引入了一个不同线程之间的同步问题。Zephyr RTOS 中可以通过 semaphore 解决不同 thread 间的同步问题。下面的代码中通过 K_SEM_DEFINE 定义了一个为 uart_thread_start 的 semaphore 。 函数 uart_thread_task 执行到函数 k_sem_take 时,如果 uart_thread_start 没有被释放,当前 thread 会被挂起等待,直到 semaphore 被释放。
static K_SEM_DEFINE(uart_thread_start, 0, 1);
#define UART_THREAD_STACK_SIZE 512
#define UART_THREAD_PRIORITY -1
void uart_thread_task(void)
{
int err;
struct uart_data_item_type uart_msgq;
k_sem_take(&uart_thread_start, K_FOREVER);
printk("uart_thread_task\n");
err = uart_callback_set(uart1, uart_cb, NULL);
err = uart_rx_enable(uart1, uart_rx_buf, MAX_UART_BUF_LEN, UART_RX_TIMEOUT_MS);
for (;;) {
在 main 里通过 k_sem_give 释放 uart_thread_start。uart 线程会打断当前的 main thread 从 k_sem_take 继续执行。
err = spi_write_dt(&spim2_adc, &tx_bufs);
err = spi_write_dt(&spim2_flash, &tx_bufs);
k_sem_give(&uart_thread_start);
struct uart_data_item_type main_msgq;
main_msgq.cmd = UART_CMD_TX;
main_msgq.data = 0;
for (;;) {
while (k_msgq_put(&uart_data_msgq, &main_msgq, K_NO_WAIT) != 0) {
/* message queue is full: purge old data & try again */
k_msgq_purge(&uart_data_msgq);
}
main_msgq.data++;
dk_set_led(RUN_STATUS_LED, (++blink_status) % 2);
k_sleep(K_MSEC(RUN_LED_BLINK_INTERVAL));
}
下面的代码中 K_MSGQ_DEFINE 定义了一个名为 uart_data_msgq 的 message queue。uart_data_msgq 的缓冲区里最多可以容纳 8 个消息。
struct uart_data_item_type {
uint8_t cmd;
uint32_t data;
};
K_MSGQ_DEFINE(uart_data_msgq, sizeof(struct uart_data_item_type), 8, 4);
下面这段代码来自于 main thread 的 main 函数。代码会定时循环把待发送的数据打包成一个 message,然后推送到 message queue 里面。
struct uart_data_item_type main_msgq;
main_msgq.cmd = UART_CMD_TX;
main_msgq.data = 0;
for (;;) {
while (k_msgq_put(&uart_data_msgq, &main_msgq, K_NO_WAIT) != 0) {
/* message queue is full: purge old data & try again */
k_msgq_purge(&uart_data_msgq);
}
main_msgq.data++;
dk_set_led(RUN_STATUS_LED, (++blink_status) % 2);
k_sleep(K_MSEC(RUN_LED_BLINK_INTERVAL));
}
下面的代码来自 uart thread 的 uart_thread_task 函数。 函数等待 message queue 里推送来的 message。得到 message 后,根据里面的 cmd 字段来处理发送或者接收数据。
void uart_thread_task(void)
{
int err;
struct uart_data_item_type uart_msgq;
k_sem_take(&uart_thread_start, K_FOREVER);
printk("uart_thread_task\n");
err = uart_callback_set(uart1, uart_cb, NULL);
err = uart_rx_enable(uart1, uart_rx_buf, MAX_UART_BUF_LEN, UART_RX_TIMEOUT_MS);
for (;;) {
k_msgq_get(&uart_data_msgq, &uart_msgq, K_FOREVER);
printk("received uart data item\n");
switch(uart_msgq.cmd) {
case UART_CMD_TX:
memcpy(uart_tx_buf,&uart_msgq.data, sizeof(uint32_t));
err = uart_tx(uart1, uart_tx_buf, sizeof(uint32_t), SYS_FOREVER_MS);
break;
case UART_CMD_DATA_PROCESS:
break;
default:
break;
}
}
}
下面是加入线程间通讯的代码后得到的 log,当我们把 TX 和 RX 引脚短接后可以看出 uart thread 不断的发送从 main thread 传输的数据。
总结
本文从实际操作出发,介绍了用户最常用的一些外设如 GPIO, I2C, SPI, UART 的配置和使用方法。并介绍了一些简单 RTOS 组件的应用如 thread, semaphore, message queue。希望能帮助 Nordic 用户加快 nRF Connect SDK 的开发速度。