0. 项目背景
参加硬禾学堂Funpack2-6活动,使用Nordic的nRF Connect SDK开发套件和VSCode插件,在nRF7002DK开发板上,使用上面的蓝牙主控nRF5340实现了一个能够被PC和手机识别的蓝牙通用人机交互设备,完成模拟鼠标点击事件、键盘的连续多键值发送、接受来自蓝牙主设备的CapsLock信号等功能。
这块nRF7002DK开发板上,拥有2颗nRF5340带蓝牙功能的主控芯片,其中1颗用来作为JTAG通信,为另外一个核心主控提供JTAG烧录功能,还拥有1颗nRF7002无线WIFI6的协处理器,可以用来学习开发使用WIFI相关的场景。本次完成硬禾学堂布置的任务1,只使用了其中的核心主控nRF5340完成一个蓝牙场景任务,即一个通用的蓝牙键鼠套装。
对比两年前我曾使用Keil + nRF5 SDK在nRF52840进行开发的经历,现在Nordic推出了全新的nRF Connect SDK开发套件,以及与之适配的nRF Connect for Desktop桌面工具和VSCode插件,同时为MCU的开发集成了zephyr内核的RTOS,这些都极大地降低了使用Nordic MCU的难度,提高了开发体验。
下面就笔者进行该项目的开发历程做一个简要的介绍:
1. 开发环境的搭建
第一个阶段就是开发环境的搭建,正如上面所说,Nordic推出了全新的nRF Connect SDK,可以通过图形化管理工具nRF Connect for Desktop进行自动安装和维护。可以通过下面这个链接进行下载:
https://www.nordicsemi.com/Products/Development-tools/nrf-connect-for-desktop/download#infotabs
无脑安装后,就可以使用自动配置工具进行SDK和VSCode插件的下载,SDK当前最新版本为2.4.2,值得注意的是,国内的网络环境很容易导致SDK下载失败,具体表现就是在安装路径下的v2.4.2文件夹里面没有任何文件,此时可以参考下面这个教程解决网络环境的问题:
https://www.bilibili.com/read/cv26507574/
2. GPIO点灯
开发环境配置完成后,即可打开VSCode正式进行NCS(nRF Connect SDK)项目的开发,根据向导,创建第一个application,选择根据sample来创建,选择blinky这个示例程序。
此时你会看到一个令你一脸懵逼的点灯程序,它看起来是这样的:
在这段代码里,我们看到了一些与以往不同的代码形式,比如,在第10行那个宏定义,参数部分莫名其妙出现的那个“led0”.
不过还是先等等吧,我们先来打通编译和烧录功能,刚创建完application后,我们尚无法对项目进行编译使用,需要先指定一下build configuration,这里我们选择nRF7002DK开发板的编译配置,如图所示:
配置好编译选项后,我们可以直接在VSCode中方便地进行编译和烧录,同时在串口终端中还能观察到运行日志,这是由另一颗nRF5340帮我们代理实现的串口透传,没错,这颗辅助的nRF5340不光帮我们搭起了JTAG的桥梁,还帮我们实现了串口透传,这几乎已经成为当今各种开发板的常规操作了。
编译烧录成功后,我们就可以看到开发板的LED交替闪烁,至此开发环境和工具链我们就打通了。
3. 设备树
还记得在blinky示例中让我们摸不着头脑,莫名奇妙出现的那个“led0”吗,我们该如何理解它,这就要好好理解一下NCS机构中的设备树的概念了。这可真不是只言片语能把它讲明白的,我这里只能就我的理解,以极其不专业的方式简单描述一下设备树的概念:
NCS为每个bsp开发板建立了一种脱离于C语言的配置文件,在这种配置文件中,描述了这个开发板拥有的板上外设及相应的参数和引脚分配,比如它描述了这个开发板上有两颗LED,它们的引脚分配分别是P1.06和P1.07,此外还有两个buttons,它们的引脚分别是P1.08和P1.09,将这些信息以一种树形的结构化代码描述起来,每个外设设计成树形结构中的一个node,外设的相关配置形成这种结构化语言中的节点属性,于是设备树的配置文件就应运而生了,这种文件的后缀是dts。一个开发板只有一套dts,如果自己实际工程中需要有所调整,则可以针对需要修改的部分,自行创建针对当前工程的设备树覆盖配置文件,名为overlay机制。
以上,就是我理解的NCS中的设备树,关于这个概念的专业教程可以参考这里:
在我们写好程序,点击编译按钮之后,构建系统便开始解析设备树的配置文件,将他们翻译成.h和.c等能被编译器识别的代码,然后在NCS的zephyr操作系统进入main函数之前,加载这些动态生成的代码,从而实现在用户业务逻辑开始之前,这些关于硬件的配置就全都搞定了。
4. 多线程
如果有RTT或者FreeRTOS等RTOS的开发经验,那么熟悉和了解NCS中的zephyr操作系统将是一件轻松加愉快的事情,因为你会同样接触到以下这些概念:
- 线程的创建:静态,动态
- 线程的优先级
- 同等优先级下的线程时间片让渡
- 线程间同步:信号量,互斥量
- 线程间通信:消息队列,邮箱
- 工作队列workqueue
以上这些概念,在基本的RTOS中都会存在,仅需要在语法层面和API层面注意一些差异即可。如果你需要关于这些知识的详细文档,可以参考以下链接:
5. 实战:蓝牙鼠标
好了,知识储备准备得差不多了,我们可以着手实现本次任务1中所提及的一部分功能了,即首先来设计一个蓝牙的纯鼠标设备。
如上图所示,根据sample创建一个蓝牙USB鼠标工程。
跟之前的blinky示例不同,这个示例官方尚未为其配置适用于nRF7002DK开发板的设备树,因此我们在进行构建配置时,无法选择nRF7002DK开发板,退而求其次,我们只能选择nRF5340DK开发板进行构建配置。
这意味着,我们将要在nRF7002DK开发板上跑一个为nRF5340开发的程序,进一步的,这意味着,我们需要调整一些设备树的配置以适配这颗本不属于自己的开发板,否则将无法正确运行。之前讲过,我们可以使用设备树的overlay机制,仅对需要覆盖的部分进行少量微调,而这种微调代码,是针对当前工程的,而不会污染整个SDK中其他的BSP例程。
关于设备树的overlay机制,我们可以阅读以下官方文档:
得益于强大的抽象设计,我们的overlay文件并不需要多么复杂,只需要将几个按钮的pin引脚重新映射即可。
&button0{
gpios = <&gpio1 8 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
label = "Push button 1";
};
&button1{
gpios = <&gpio1 9 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
label = "Push button 2";
};
5.1 编译和运行
编译、烧录,并打开串口调试。此时打开PC上的蓝牙,扫描蓝牙设备,可以发现一个名为`Nordic_HIDS_mouse`的蓝牙鼠标,点击连接设备,此时串口中会有提示,按下开发板上的button1按键确认连接。
5.2 实现鼠标点击事件
该示例没有实现鼠标的点击功能,只对鼠标位移进行了处理,要处理鼠标点击功能,需要对USB报文和HID有所熟悉。
参考USB中文网中关于HID设备描述的讲解:https://www.usbzh.com/article/detail-830.html
配合示例代码中的 report_map 数组中的设备描述符定义,我们不难推断出该蓝牙鼠标的三种报文格式:
- Report ID 1: Mouse buttons + scroll/pan
- Report ID 2: Mouse motion
- Report ID 3: Advanced buttons
每种报文的比特序都可以通过注释阅读和理解。
5.3 鼠标点击事件的报文发送
根据上面的`report_map`和鼠标移动报文的对比,可根据第一段报文规则,编写出类似的鼠标点击事件报文:
static void mouse_buttons_send(uint8_t btnStatus) {
for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++) {
if (!conn_mode[i].conn) {
continue;
}
if (conn_mode[i].in_boot_mode) {
bt_hids_boot_mouse_inp_rep_send(&hids_obj,
conn_mode[i].conn,
&btnStatus,
(int8_t)0,
(int8_t)0,
NULL);
} else {
uint8_t buffer[3] = {btnStatus & 0x07, 0x00, 0x00};
bt_hids_inp_rep_send(&hids_obj, conn_mode[i].conn,
INPUT_REP_BUTTONS_INDEX,
buffer, sizeof(buffer), NULL);
}
}
}
以上代码,实现了USB设备描述符中的1号报文的发送,发送格式为:
- 一共3个字节
- 第一个字节有5个bit的掩码,最低位既是鼠标左键按下的有效位
- 后面两个字节是滚轮相关,不用在意,填入两个0x00即可
为了在物理层面实现按键状态的感知,将信号通过消息队列向上层汇报,我们还需要略微改造一下相关的消息队列数据结构,以及物理层的按键事件回调函数button_changed(),此处不再赘述。
6. 实战:蓝牙键鼠套装
为了弄清楚键盘相关的蓝牙和USB逻辑,我们需要新建一个蓝牙纯键盘示例,研究一下它的代码。在VSCode开发环境下,新建项目,使用sample,筛选keyboard即可创建蓝牙键盘的示例程序:
6.1 运行DEMO
该示例程序使用了NFC读卡功能,进行设备配对时的验证,可以观察main函数中的代码:
#if CONFIG_NFC_OOB_PAIRING
k_work_init(&adv_work, delayed_advertising_start);
app_nfc_init();
#else
advertising_start();
#endif
建议在prj.conf项目配置中酌情将CONFIG_NFC_OOB_PAIRING配置为n,以打开自动广告和配对功能,绕开NFC配对。
6.2 基于蓝牙鼠标示例引入蓝牙键盘特性
6.2.1 修改蓝牙外观类型
根据蓝牙协议官方出版的BLE_Assigned_Numbers.pdf,第2.6章Appearance Values内容,修改蓝牙外观类型为Generic Human Interface Device,其ID为0x03C0,十进制数为960。
因此,修改prj.conf文件,做以下调整:
CONFIG_BT_DEVICE_NAME="Nordic_HIDS_KIT"
CONFIG_BT_DEVICE_APPEARANCE=960
这个设置会让蓝牙设备显示成这样:
6.2.2 扩大HID特性参数容量
因本实例在[[07-蓝牙鼠标]]项目基础上进行修改的,且这个鼠标本身的设备描述功能就比较多,因此加入蓝牙键盘的特性后,会继续加大其特性容量,NCS的默认工程,支持30个蓝牙特性容量,其配置项中CONFIG_BT_HIDS_ATTR_MAX的默认值为30, 因此,为了防止特性值超出后,报No space left on given svc异常,导致MCU陷入循环复位,考虑对其进行修改,该值最大为50,设置超出后会提示警告。
CONFIG_BT_HIDS_ATTR_MAX=50
6.2.3 蓝牙键盘报文分析
根据USB中文网给出的蓝牙键盘报文描述 https://www.usbzh.com/article/detail-13.html
配合蓝牙键盘示例中设备描述符的代码,不难分析出蓝牙键盘的输入输出报文协议:
6.2.3.1 输入信号
- BYTE0:该字节的各位表示特殊按键是否按下,这些特殊的按键包括Shift,Alt,Ctrl等。
- BYTE1:该字节保留,值固定为00.
- BYTE2-BYTE7:这6个字节表为一个6字节的数组,其中数组中的每一项可以表示按键的键值。当有多个普通的按键按下时,则应同时返回这些键的值。数组中各键的无先后顺序。
- 具体的键值可参考标准USB键盘键值规范,比如键盘上的A,其键值为0x04
6.2.3.2 输出信号
- 输出为1个字节。其中低5位表示了相关指示灯的状态,高5位保留值为0。
- 可通过手动调试判断出键盘上CapsLock,NumberLock,ScroolLock的信号点位
- CapsLock的实际点位在BIT1上,因此其掩码为0x02
- 另外2个输出信号位为工作错误信号和Kana信号,均不常用,其中Kana信号用来指示日语输入的模式
6.3 修改蓝牙鼠标项目
6.3.1 追加相关宏定义
#define INPUT_REP_REF_KEYBOARD_ID 3
#define OUTPUT_REP_REF_KEYSLED_ID 4
#define KEY_PRESS_MAX 6 /* Maximum number of non-control keys*/
#define INPUT_REP_KEYS_LEN (1 + 1 + KEY_PRESS_MAX)
#define OUTPUT_REP_LED_LEN 1
#define OUTPUT_REPORT_BIT_MASK_CAPS_LOCK 0x02
6.3.2 配置HID设备对象
使用以下宏定义,配置HID对象,传入所有可能的报文长度,该宏会取它们的最大值配置HID对象。
BT_HIDS_DEF(hids_obj,
INPUT_REP_BUTTONS_LEN,
INPUT_REP_MOVEMENT_LEN,
INPUT_REP_KEYS_LEN,
OUTPUT_REP_LED_LEN);
6.3.3 追加键盘的设备描述符
static void hid_init(void) {
int err;
struct bt_hids_init_param hids_init_param = {0};
struct bt_hids_inp_rep *hids_inp_rep;
struct bt_hids_outp_feat_rep *hids_outp_rep;
static const uint8_t mouse_movement_mask[DIV_ROUND_UP(INPUT_REP_MOVEMENT_LEN, 8)] = {0};
static const uint8_t report_map[] = {
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x02, /* Usage (Mouse) */
0xA1, 0x01, /* Collection (Application) */
/* Report ID 1: Mouse buttons + scroll/pan */
0x85, INPUT_REP_REF_BUTTONS_ID, /* Report Id 1 */
0x05, 0x09, /* Usage Page (Buttons) */
0x09, 0x01, /* Usage (Pointer) */
0xA1, 0x00, /* Collection (Physical) */
0x19, 0x01, // USAGE_MINIMUM (Button 1)
0x29, 0x03, // USAGE_MAXIMUM (Button 3)
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x95, 0x03, /* Report Count (3) */
0x75, 0x01, /* Report Size (1) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
0x95, 0x01, /* Report Count (1) */
0x75, 13, /* Report Size (13) */
0x81, 0x01, /* Input (Constant) for padding */
0x95, 0x01, /* Report Count (1) */
0x75, 0x08, /* Report Size (8) */
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x38, /* Usage (Wheel) */
0x15, 0x81, /* Logical Minimum (-127) */
0x25, 0x7F, /* Logical Maximum (127) */
0x81, 0x06, /* Input (Data, Variable, Relative) */
0xC0, /* End Collection (Physical) */
/* Report ID 2: Mouse motion */
0x85, INPUT_REP_REF_MOVEMENT_ID, /* Report Id 2 */
0x09, 0x01, /* Usage (Pointer) */
0xA1, 0x00, /* Collection (Physical) */
0x75, 0x0C, /* Report Size (12) */
0x95, 0x02, /* Report Count (2) */
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x30, /* Usage (X) */
0x09, 0x31, /* Usage (Y) */
0x16, 0x01, 0xF8, /* Logical maximum (2047) */
0x26, 0xFF, 0x07, /* Logical minimum (-2047) */
0x81, 0x06, /* Input (Data, Variable, Relative) */
0xC0, /* End Collection (Physical) */
0xC0, /* End Collection (Application) */
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x06, /* Usage (Keyboard) */
0xA1, 0x01, /* Collection (Application) */
/* Keys */
0x85, INPUT_REP_REF_KEYBOARD_ID, /* Report Id (3) */
0x05, 0x07, /* Usage Page (Key Codes) */
0x19, 0xe0, /* Usage Minimum (224) */
0x29, 0xe7, /* Usage Maximum (231) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x75, 0x01, /* Report Size (1) */
0x95, 0x08, /* Report Count (8) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
0x95, 0x01, /* Report Count (1) */
0x75, 0x08, /* Report Size (8) */
0x81, 0x01, /* Input (Constant) reserved byte(1) */
0x95, 0x06, /* Report Count (6) */
0x75, 0x08, /* Report Size (8) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x65, /* Logical Maximum (101) */
0x05, 0x07, /* Usage Page (Key codes) */
0x19, 0x00, /* Usage Minimum (0) */
0x29, 0x65, /* Usage Maximum (101) */
0x81, 0x00, /* Input (Data, Array) Key array(6 bytes) */
/* LED */
0x85, OUTPUT_REP_REF_KEYSLED_ID, /* Report Id (1) */
0x95, 0x05, /* Report Count (5) */
0x75, 0x01, /* Report Size (1) */
0x05, 0x08, /* Usage Page (Page# for LEDs) */
0x19, 0x01, /* Usage Minimum (1) */
0x29, 0x05, /* Usage Maximum (5) */
0x91, 0x02, /* Output (Data, Variable, Absolute), LED */
0x95, 0x01, /* Report Count (1) */
0x75, 0x03, /* Report Size (3) */
0x91, 0x01, /* Output (Data, Variable, Absolute), padding*/
0xC0, /* End Collection (Application) */
};
hids_init_param.rep_map.data = report_map;
hids_init_param.rep_map.size = sizeof(report_map);
hids_init_param.info.bcd_hid = BASE_USB_HID_SPEC_VERSION;
hids_init_param.info.b_country_code = 0x00;
hids_init_param.info.flags = (BT_HIDS_REMOTE_WAKE | BT_HIDS_NORMALLY_CONNECTABLE);
hids_inp_rep = &hids_init_param.inp_rep_group_init.reports[0];
hids_inp_rep->size = INPUT_REP_BUTTONS_LEN;
hids_inp_rep->id = INPUT_REP_REF_BUTTONS_ID;
hids_init_param.inp_rep_group_init.cnt++;
hids_inp_rep++;
hids_inp_rep->size = INPUT_REP_MOVEMENT_LEN;
hids_inp_rep->id = INPUT_REP_REF_MOVEMENT_ID;
hids_inp_rep->rep_mask = mouse_movement_mask;
hids_init_param.inp_rep_group_init.cnt++;
// hids_inp_rep++;
// hids_inp_rep->size = INPUT_REP_MEDIA_PLAYER_LEN;
// hids_inp_rep->id = INPUT_REP_REF_MPLAYER_ID;
// hids_init_param.inp_rep_group_init.cnt++;
hids_inp_rep++;
hids_inp_rep->size = INPUT_REP_KEYS_LEN;
hids_inp_rep->id = INPUT_REP_REF_KEYBOARD_ID;
hids_init_param.inp_rep_group_init.cnt++;
hids_outp_rep = &hids_init_param.outp_rep_group_init.reports[0];
hids_outp_rep->size = OUTPUT_REP_LED_LEN;
hids_outp_rep->id = OUTPUT_REP_REF_KEYSLED_ID;
hids_outp_rep->handler = hids_outp_rep_handler;
hids_init_param.outp_rep_group_init.cnt++;
hids_init_param.boot_kb_outp_rep_handler = hids_boot_kb_outp_rep_handler;
hids_init_param.is_mouse = true;
hids_init_param.is_kb = true;
hids_init_param.pm_evt_handler = hids_pm_evt_handler;
printk("HID Service init\n");
err = bt_hids_init(&hids_obj, &hids_init_param);
printk("HID Service inited\n");
__ASSERT(err == 0, "HIDS initialization failed\n");
}
6.3.3 实现键盘相关的回调函数
输入回调:通过递归循环报告eetree键值事件
static uint8_t jobKeyIndex = 0;
static void sendJobKeyValues(struct bt_conn *conn, void *user_data){
static const uint8_t jobKeyValues[] = { 0x08, 0x00, 0x08, 0x00, 0x17, 0x00, 0x15, 0x00, 0x08, 0x00, 0x08, 0x00 }; // "eetree"
uint8_t buffer[INPUT_REP_KEYS_LEN] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
if(jobKeyIndex < sizeof(jobKeyValues)){
buffer[2] = jobKeyValues[jobKeyIndex];
jobKeyIndex++;
bt_hids_inp_rep_send(&hids_obj, conn, INPUT_REP_REF_KEYBOARD_ID - 1, buffer, sizeof(buffer), sendJobKeyValues);
}
}
static void keyboard_key_send(uint8_t ctrlStatus, uint8_t *values) {
uint8_t buffer[INPUT_REP_KEYS_LEN] = {0x00, 0x00, values[0], 0x00, 0x00, 0x00, 0x00, 0x00};
for (size_t i = 0; i < CONFIG_BT_HIDS_MAX_CLIENT_COUNT; i++) {
if (conn_mode[i].conn) {
if (conn_mode[i].in_boot_mode) {
bt_hids_boot_kb_inp_rep_send(&hids_obj, conn_mode[i].conn, buffer, sizeof(buffer), NULL);
} else {
if(values[0] != 0x00){
jobKeyIndex = 0;
sendJobKeyValues(conn_mode[i].conn, NULL);
}
}
break;
}
}
}
输出回调:CapsLock信号灯等控制
static void caps_lock_handler(const struct bt_hids_rep *rep){
uint8_t report_val = ((*rep->data) & OUTPUT_REPORT_BIT_MASK_CAPS_LOCK) ? 1 : 0;
// 需要提前配置设备树overlay,并在main中使用 gpio_pin_configure_dt(&led0, GPIO_OUTPUT); 配置输出
gpio_pin_set_dt(&led0, report_val);
}
static void hids_outp_rep_handler(struct bt_hids_rep *rep, struct bt_conn *conn, bool write){
char addr[BT_ADDR_LE_STR_LEN];
if (!write) {
printk("Output report read\n");
return;
};
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
printk("Output report has been received %s\n", addr);
caps_lock_handler(rep);
}
static void hids_boot_kb_outp_rep_handler(struct bt_hids_rep *rep, struct bt_conn *conn, bool write){
char addr[BT_ADDR_LE_STR_LEN];
if (!write) {
printk("Output report read\n");
return;
};
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
printk("Boot Keyboard Output report has been received %s\n", addr);
caps_lock_handler(rep);
}
同样的,要实现上面的业务逻辑,也需要对消息队列的数据结构和物理层的代码进行相应的修改,此处限于篇幅,便不再赘述。
至此,终于完成本次项目所需要实现的任务1的所有功能,即使用nRF5340实现了一个标准的蓝牙键鼠套装,按下开发板上btn1,能够向蓝牙主设备发送鼠标左键的点击事件;按下btn2,能够上报连续的“eetree”键盘键值;并且蓝牙主设备可以发送CapsLock信号,控制开发板上的led0。
最后,感谢电子森林和硬禾学堂组织的本次活动,感谢Funpack2-6:nRF7002-DK 109人的微信群里所有热心帮助的小伙伴,期待下次与大家参与更多的硬禾学堂的活动,并分享更多的嵌入式开发的知识。