文章目录
概述
本文详细介绍了 NB-IoT模组与主板MCU之间的通信原理,主要包括以下几个部分:
1、NB-IoT与MCU之间硬件电路分析。
2、MCU代码生成的AT指令数据,以怎样的路径向NB-IoT模组传输。
3、NB-IoT模组输出的指令反馈和URC数据,是怎么被MCU代码读取到并处理的。
4、分了LiteOS操作系统下设备驱动的静态注册机制,理解UART_AT驱动的工作机制。
@HISTORY
阅读此文前,请先阅读 #<IoT/透过oc_lwm2m源码,分析NB-IoT接入华为云物联网平台IoTDA过程,总结避坑攻略>#,以了解NB-IoT是如何通过AT指令序列接入到运行商网络并注册连接到IoTDA物联网平台的。
AT通信硬件链路
NB-IoT通信模组原理图(不是主板的原理图哈),可以看到,
上图中的 WAN_Interface是应该对Boudica150芯片部分管脚的导出,但我不是特别肯定哈。
在主板原理图中,也可以看到,WAN_Interface 通过 AT-switch开关后与MCU的UART相连接。另外,要注意的是,这里有两套串口,其中MUC_UART1 是用于调试日志输出的,AT_LPUART1 是用于模组和MCU间AT指令通信的(LP是低功耗的含义)。
将指令发送到模组
在之前的文章中,我们的谈论重点一直是NB-IoT设备如何连接到运营商网络和注册到IoT平台,对与MCU和模组之间的底层交互,并无过多的分析。我们谈及了 oc_lwm2m_imp_init、boudica150_oc_config、boudica150_boot,以及 boudica150_boot 下的诸多具体的AT指令查询和发送函数,如 boudica150_set_fun、boudica150_set_cdp 等等。在这些具体的AT指令函数之下,又是一层统一的实现。它们都统一调用 boudica150_atcmd 或 boudica150_atcmd_response 函数,前者不返回生数据、后者则要返回省数据,而它们最终都调用 at_command 函数。后文将从 at_command 函数开始展开分析。
核心函数 at_command
首先要注意到,boudica150_xxx 的函数是在 iot_link\oc\oc_lwm2m\boudica150_oc 目录下的,而at_command函数位于 iot_link\at\at.c 源代码文件之下。也即 oc_lwm2m 针对NB设备,本质上封装的是 at 模块实现的功能和接口。
/*******************************************************************************
function :this is our at command here,you could send any command as you wish
instruction :only one command could be dealt at one time, for we use the semphore here do the sync;if the respbuf is not NULL,then we will cpoy the response data to the respbuf as much as the respbuflen permit
*******************************************************************************/
int at_command(const void *cmd,size_t cmdlen,const char *index,void *respbuf, size_t respbuflen,uint32_t timeout) {
...
//指令需要等待反馈的情况(细分:需要生数据/不需要生数据)
if(NULL != index) {
ret = __cmd_create(cmd,cmdlen,index,respbuf,respbuflen,timeout);
if(0 == ret) {
ret = __cmd_send(cmd,cmdlen,timeout);
...
//尝试获取信号量,若信号量不可用(计数器≤0),则阻塞调用线程,直到超时或信号量可用
if(osal_semp_pend(g_at_cb.cmd.respsync,timeout)) {
ret = g_at_cb.cmd.respdatalen;
...
}
(void) __cmd_clear();
}
}
//指令不需要等待反馈的情况
else {
ret = __cmd_send(cmd,cmdlen,timeout);
}
return ret;
}
指令从MCU传到模组
static int __cmd_send(const void *buf,size_t buflen,uint32_t timeout) {
int i = 0;
ssize_t ret = 0;
int debugmode;
//成功写入的字节的个数?
ret = los_dev_write(g_at_cb.devhandle,0,buf,buflen,timeout);
if(ret > 0) {
...
}
else {
ret = -1;
}
return ret;
}
上述过程的核心函数,即 los_dev_write ,AT指令串,写到OS设备,后文整章就谈啥是LiteOS设备。
等待模组反馈指令执行
从 boudica150_boot 调用的各个函数来看,这些指令从MCU到模组后,都是期望模组固件程序回应MCU的,最起码的也关注了是否返回OK。当然也有的情况,不只是关注OK不OK,还要at底层的生数据到应用层进行处理,这个后文会细说。从模组返回操作结果,是在一个单独的任务中完成的,也即发送和接收是异步的,但是 at 模块通过信号量将这个过程同步化了,从 at_command 源码中 osal_semp_pend 等待信号量的操作,可以确认这一推测。接下来我们就围绕这个信号量,看看at模组如何等待指令反馈,
osal_semp_pend(g_at_cb.cmd.respsync,timeout)
结合上图结构和全局变量的定义,osal_semp_pend 的操作句柄 g_at_cb.cmd.respsync,在at模块中是个全局存在,所有的AT指令共用这一个二值信号量。它在 at_init 中被初始化 osal_semp_create(&g_at_cb.cmd.respsync,1,0) 即二值信号量。_pend 的功能本质是获取信号量,即信号量计数≤0则任务挂起,阻塞等待,直到资源可用或超时。那么释放信号量的 _post 操作在哪里呢?
//check if the data received is the at command need
static int __cmd_match(const void *data,size_t len)
{
int ret = -1;
int cpylen;
at_cmd_item *cmd = NULL;
cmd = &g_at_cb.cmd;
if(osal_mutex_lock(cmd->cmdlock)) {
//strstr函数是关键/查找返回结构中是否存在用户期望的字符串关键字
if((NULL != cmd->index)&&(NULL != strstr((const char *)data,cmd->index))) {
//将生数据拷贝输出到用户层
if(NULL != cmd->respbuf) {
cpylen = len > cmd->respbuflen?cmd->respbuflen:len;
(void) memcpy((char *)cmd->respbuf,data,cpylen);
cmd->respdatalen = cpylen;
}
else {
cmd->respdatalen = len; //tell the command that how many data has been get
}
(void) osal_semp_post(cmd->respsync); //信号量+1 打破阻塞
ret = 0;
}
(void) osal_mutex_unlock(cmd->cmdlock);
}
return ret;
}
__cmd_match 函数会在接收任务入口函数的while循环中被调用,其通过 strstr 函数检查,模组通过发送到MCU串口上的生数据,如果该数据中全部或部分包含期望的字符串,则认为当前AT发送过程是执行成功的。此时就会调用 osal_semp_post 函数,即释放信号量,使得全局变量 g_at_cb.cmd.respsync 的值+1,从而打破 osal_semp_pend 的阻塞过程。
处理来组模组的数据
从模组到MCU方向的数据,大约有两种,AT指令的执行结果或反馈,URC(Unsolicited Result Code,非请求结果码)。
生数据的传递
iot_link\at\at.c 文件下,AT指令发送和接收管理的总句柄定义,
iot_link\at\at.c 文件下,at_cmd_item 类型的cmd字段,其主要是管理用户层的操作控制参数和期望结果的缓冲区,
针对g_at_cb全局变量对应的上述复杂结构,我们只简单关注下其中的字段cmd字段,其结构为 at_cmd_item,如上图。 在数据接收过程处理中,如果cmd->respbuf 不为空,则实际存储接收数据的 g_at_cb.rcvbuf[1024] 会被拷贝到 cmd->respbuf 中以向用户层输出。部分指令调用时,并没有使用原始命令响应信息的需求,cmd->respbuf 此时赋空,如,
//step 1
static bool_t boudica150_set_echo(int enable) {
(void) snprintf(cmd,64,"ATE%d\r",enable);
ret = boudica150_atcmd(cmd,"OK");
//step 2
static bool_t boudica150_atcmd(const char *cmd,const char *index) {
//以下代码中 cmd->respbuf == NULL, 但这并不影响 (index=="OK") 的业务匹配流程
ret = at_command((unsigned char *)cmd,strlen(cmd),index,NULL,0,cn_boudica150_cmd_timeout);
//在at.c 接收任务中使用 cmd->index
static int __rcv_task_entry(void *args) {
... while ..
rcvlen += __resp_rcv(g_at_cb.rcvbuf+ rcvlen,CONFIG_AT_RECVMAXLEN,cn_osal_timeout_forever); ...
matchret = __cmd_match(g_at_cb.rcvbuf,rcvlen); //
//在at.c 接收任务中使用 cmd->index
static int __cmd_match(const void *data,size_t len) {
cmd = &g_at_cb.cmd;
if(osal_mutex_lock(cmd->cmdlock)) {
if((NULL != cmd->index)&&(NULL != strstr((const char *)data, cmd->index))) //strstr 函数完成目标字符串的查找操作
...
@NOTE
上述代码,透露出一种将异步问答模式转换为同步的简单方式,即使用信号量等待发送指令的反馈结果。
获取模组发来的数据
在上一小节中,我们看到,接收处理循环中,匹配返回结果前,先执行__resp_rcv 获取模组发来的串口数据,该函数是对 los_dev_read 设备读操作的封装,与 los_dev_write 一样,我们后文再对其详谈。
URC 处理过程
URC(Unsolicited Result Code,非请求结果码)是AT指令通信中的一种异步通知机制,用于通信模组(如NB-IoT/WiFi模组)主动向控制端(MCU)发送的消息,无需主控设备发起请求。在网络状态变化(如基站注册)、外部事件(来电、短信)、数据到达(TCP数据接收)等场景下,会触发URC。其主要语法特征是以+ 开头的标准化字符串,如 +CMTI(新短信)、+CREG(网络注册)等。
参考 #<IoT/透过oc_lwm2m/boudica150 源码中的AT指令序列,分析NB-IoT接入华为云物联网平台IoTDA的工作机制># 文中对于 boudica150_check_observe 平台注册状态检查过程的分析。urc_qlwevtind 这个回调函数,其处理的就是URC消息,其等待+QLWEVTIND:3信息字符串的返回,以通知主机,平台注册完成,可安全使用数据传输指令。我们这里要进一步研究的是,生数据 “+QLWEVTIND:3” 的缓冲和传递路径是怎样的?
//关注的字符串
#define cn_urc_qlwevtind "\r\n+QLWEVTIND:"
//注册相关的代码
at_oobregister("qlwevind",cn_urc_qlwevtind,strlen(cn_urc_qlwevtind),urc_qlwevtind,NULL);
urc_qlwevtind 回调函数的实际调用位置是,
static int __oob_match(void *data,size_t len) {
...
ret = oob->func(oob->args,data,len);
而 __oob_match 紧随 __cmd_match 指令反馈数据的处理过程,
static int __rcv_task_entry(void *args) {
...
g_at_cb.devhandle = los_dev_open(g_at_cb.devname,O_RDWR);
...
while(NULL != g_at_cb.devhandle) {
...
rcvlen += __resp_rcv(g_at_cb.rcvbuf+ rcvlen,CONFIG_AT_RECVMAXLEN,cn_osal_timeout_forever);
if( rcvlen > 0) {
matchret = __cmd_match(g_at_cb.rcvbuf,rcvlen);
if(0 != matchret) {
//如果不是指令反馈数据,则进入urc消息处理过程
oobret = __oob_match(g_at_cb.rcvbuf,rcvlen);
...
结合上述代码分析,得出的结论是,LiteOS-AT模块下,NB-IoT-URC消息缓冲区与指令反馈数据的缓冲区是一致的。
串口AT设备驱动层
我们先从正面进攻,看看所谓的LiteOS设备是如何初始化的。在用户代码层次上的初始化过程如下,
int link_main(void *args) {
...
///< install the driver framework
#ifdef CONFIG_DRIVER_ENABLE
#include <driver.h>
///< install the driver framework for the link
(void)los_driv_init();
#endif
...
}
在我们熟悉的link_main函数下,设备初始化函数被调用,我们顺着这条线索继续追查,
/*******************************************************************************
function :the device module entry
instruction :call this function to initialize the device module here load the static init from section os_device
*******************************************************************************/
bool_t los_driv_init() {
bool_t ret = false;
ret = osal_mutex_create(&s_los_driv_module.lock);
if(false == ret) {
goto EXIT_MUTEX;
}
//load all the static device init
osdriv_load_static();
EXIT_MUTEX:
return ret;
}
接下来是重点函数 osdriv_load_static,其内部包含一个跨平台处理的封装,
static void osdriv_load_static(void){
os_driv_para_t *para;
unsigned int num = 0;
unsigned int i = 0;
#if defined (__CC_ARM) //you could add other compiler like this
num = ((unsigned int)&osdriv$$Limit-(unsigned int)&osdriv$$Base)/sizeof(os_driv_para_t);
para = (os_driv_para_t *) &osdriv$$Base;
#elif defined(__GNUC__)
para = (os_driv_para_t *)&__osdriv_start;
num = ((unsigned int )(uintptr_t)&__osdriv_end - (unsigned int)(uintptr_t)&__osdriv_start)/sizeof(os_driv_para_t);
#endif
for(i =0;i<num;i++) {
(void) los_driv_register(para);
para++;
}
return;
}
_osdriv_start 和 _osdriv_end 是项目特定的链接脚本符号,用于实现静态驱动表的地址定位。它的行为完全由开发者控制,与硬件架构或编译器无关。接下里的一个大章节,就围绕着此两个符号展开,这是一种叫做静态驱动注册的机制。
静态驱动注册机制
在源码中(LiteOS_Lab_HCIP或bearpi-iot_std_liteos-master)搜索_osdriv_start 符号名称,可见其在名为 os.ld 的脚本链接文件中有使用,通过GCC/Makefile中的配置可以知道,编译过程使用的就是是os.ld这个链接脚本。在 Lab_HCIP 的源码中多出来一个 os_app.ld 文件,此文件应该是没有被使用的,文件内注释其用适用于STM32F4429IGTx,这可能是在某种项目配置(如自定义项目创建过程)下生成的文件,也可能是我下载的 Lab_HCIP 源码不够纯净,总之本次分析用不到它,不想去深究了。在os.ld 连接脚本内:
/* Constant data goes into FLASH */
.rodata :
{
. = ALIGN(4);
__oshell_start = .;
KEEP (*(oshell))
__oshell_end = .;
. = ALIGN(4);
__osdriv_start = .;
KEEP (*(osdriv))
__osdriv_end = .;
. = ALIGN(8);
*(.rodata) /* .rodata sections (constants, strings, etc.) */
*(.rodata*) /* .rodata* sections (constants, strings, etc.) */
. = ALIGN(8);
} >FLASH
在链接脚本 os.ld 内部使用的__osdriv_start 等符号,与驱动加载函数 osdriv_load_static 是嵌入式系统静态驱动注册的核心机制。
链接脚本的作用
链接脚本(.ld
文件)控制编译后的代码和数据在内存中的布局。上文中的实现片段将所有标记为osdriv
的输入段集中存放在Flash的.rodata
(只读数据)区域,并定义了两个关键符号:
__osdriv_start = .;/* 当前地址赋给__osdriv_start */
KEEP (*(osdriv))/* 强制保留所有输入文件的osdriv段 */
__osdriv_end = .;/* 当前地址赋给__osdriv_end */
*(osdriv)
:匹配所有编译单元中通过__attribute__((section("osdriv")))
定义的变量。KEEP
:防止链接器优化时丢弃未被显式引用的驱动表。
驱动表的存储原理
呢?在C代码中,开发者会通过特定宏或属性将驱动参数结构体放入osdriv
段:
// 示例:定义一个UART驱动参数
__attribute__((section("osdriv")))
os_driv_para_t uart_driver = {
.name = "uart0",
.init_func = uart_init,
.deinit_func = uart_deinit
};
如上, section("osdriv")
声明将指示编译器将此变量放入osdriv
段(而非默认的.data
或.bss
)。而 编译后的内存布局 链接器将所有osdriv
段的数据连续存放,生成如下内存映射 (FLASH内存地址布局):
...
__osdriv_start -> [uart_driver][i2c_driver][spi_driver]... <- __osdriv_end
...
//__osdriv_end - __osdriv_start`**:标识整个驱动表的总字节数
协同 osdriv_load_static
函数通过访问__osdriv_start
和__osdriv_end
获取驱动表:
para = (os_driv_para_t *)&__osdriv_start;// 驱动表起始地址
num = (__osdriv_end - __osdriv_start) / sizeof(os_driv_para_t); // 计算驱动数量
遍历驱动表:函数按os_driv_para_t
的大小逐个读取驱动参数,并调用los_driv_register()
注册到内核。
看LiteOS的驱动表
在上述理论基础上,我们回到 iot_link/driver.c 的源码中,找找 section(“osdriv”) 声明在哪里,还真有,
上述宏函数,定义在driver.h 中,接下来就简单了,看看谁调用了 OSDRIV_EXPORT 这个宏函数。发现,除了test目录,就只有 uart_at.c 文件中有使用。这里主要涉及到两个结构 os_driv_para_t 及其字段 op 对应的 los_driv_op_t 结构。
static const los_driv_op_t s_at_op = {
.init = uart_at_init,
.deinit = uart_at_deinit,
.read = __at_read,
.write = __at_write,
};
//将上述变量实现为静态注册
OSDRIV_EXPORT(uart_at_driv,CONFIG_UARTAT_DEVNAME,(los_driv_op_t *)&s_at_op,NULL,O_RDWR);
我们可以试着将 上述 OSDRIV_EXPORT 宏函数的处理过程展开,
//liteOS驱动层参数
static const os_driv_para_t uart_at_driv __attribute__((used,section("osdriv")))= {
.name = atdev, //定义在iot_config.h
.op = s_at_op , //主字段/设备的初始化和读写接口
.pri = NULL,
.flag = 2, /* +1 == FREAD|FWRITE */
}
如上,LiteOS设备驱动层参数结构 os_driv_para_t 包含了一个 设备操作接口集合 los_driv_op_t 结构。 被定义为静态驱动的是 os_driv_para_t 结构的 uart_at_driv 全局变量。也就是说,uart_at_driv 这个变量在 attribute((used,section(“osdriv”))) 声明的作用下,集合 os.ld 中与 “osdriv” 相关的连接规则定义,其将被安排在 Flash的 __osdriv_start 和 __osdriv_end 地址之间。到map中验证下,
补充说明:
段(Sections)是符号的容器,符号按属性(代码/数据/只读等)被分组到不同段中。
在编译链接过程中,链接器的最小作用对象是目标文件(.o文件)中的符号(Symbols),而符号可以代表函数、变量、段(Sections)等。链接器首先以整个.o文件为单位进行合并和地址分配。将不同.o文件中的同名段(如.text、.data)合并到输出文件的对应段,例如,将所有.o文件的.text段合并为输出文件的.text段。符号,是连接器实际处理的最小粒度,负责解析符号引用,以及符号的重定位。若main.o调用了uart.o中的uart_init(),链接器需匹配两者,并为符号分配运行时地址。
使用静态注册的驱动表
在uart_at.c编译过程中,生成了uart_at.d、uart_at.lst、uart_at.o 三个文件。重点是.o目标文件,它是源码编译后生成的二进制目标文件,包含机器代码、符号表和未解析的引用。链接器会将多个.o文件合并为最终的可执行文件或库。目标文件主要内容包含:
二进制的.o目标文件不太方便直接阅读,但是通过uart_at.lst列表文件,可以窥探一二,该文件是编译器生成的混合源码与汇编的参考文件,用于调试和优化分析。在uart_at.lst中我们可以看到 uart_at_driv 变量的具体定义,但这一块我的理解并不清晰,公立目前达不到。我只能知道,__osdriv_start 和 __osdriv_end 之间的变量类型,只能是 os_driv_para_t 结构类型,决不能是随意定义的,退一步说的话,就是 section(“osdriv”) 这个段(符号的容器),只能装 os_driv_para_t 类型的变量符号,否则就自己打自己脸,出现解析混乱。
好了,关于静态驱动注册机制,就谈这些,接下来只简单看看如何使用静态注册的 os_driv_para_t 变量。通过map文件,其实可以看到,在小熊派的源码中,被注册的设备驱动,其实只有 uart_at 一个。OS 通过 __osdriv_start / end 遍历使用它。
//driver.c 中的变量声明
#ifdef __CC_ARM /* ARM C Compiler ,like keil,options for linker:--keep *.o(osdriv)*/
extern unsigned int osdriv$$Base;
extern unsigned int osdriv$$Limit;
#elif defined(__GNUC__) //这是我们使用和关注的编译器类型
extern unsigned int __osdriv_start;
extern unsigned int __osdriv_end;
#else
#error("unknown compiler here");
#endif
底层设备读写
前面的章节,我们讲解了LiteOS下的设备驱动静态注册机制,也讲述了模组与MCU间AT指令交互的上层实现机制。对于AT指令从MCU到模组的分析,我们进行到了 los_dev_write 函数,对于模组到MCU的数据方向,我们已经进分析到了 los_dev_read 函数。
los_dev_write/read
结合前文讲述的静态注册机制和AT设备驱动层分析,可以看到,los_dev_read 和 los_dev_write 操作的本质是回调执行,
ssize_t los_dev_write (los_dev_t dev,size_t offset,const void *buf,size_t len, uint32_t timeout) {
...
ret = drivcb->op->write(drivcb->pri,offset,buf,len,timeout);
ssize_t los_dev_read (los_dev_t dev,size_t offset, void *buf,size_t len,uint32_t timeout) {
...
ret = drivcb->op->read( drivcb->pri,offset,buf,len,timeout);
LiteOS 设备操作句柄
上述回调过程,drivcb->op 对应的结构,
//all the member function of pri is inherited by the register function
typedef struct {
fn_devopen open; //triggered by the application
fn_devread read; //triggered by the application
fn_devwrite write; //triggered by the application
fn_devclose close; //triggered by the application
fn_devioctl ioctl; //triggered by the application
fn_devseek seek ; //triggered by the application
fn_devinit init; //if first open,then will be called
fn_devdeinit deinit; //if the last close, then will be called
} los_driv_op_t;
//the member could be NULL,depend on the device property
//attention that whether the device support multi read and write depend on the device itself
typedef void* los_dev_t ; //this type is returned by the dev open
los_driv_op_t 设备句柄结构,并不是只有 uart 设备使用的,而是针对所有的外设类型。后文会谈及到,它是 os_driv_para_t 最底层驱动参数的字段结构之一,也是最主要的字段,它定义了设备的全部操作接口。
op->read 回调函数
//OS设备句柄中注册的函数
static ssize_t __at_read (void *pri,size_t offset,void *buf,size_t len, uint32_t timeout) {
return uart_at_receive(buf,len, timeout);
}
//实际为从ringbuff中读取缓冲的串口数据
static ssize_t uart_at_receive(void *buf,size_t len,uint32_t timeout) {
...
cpylen = ring_buffer_read(&g_atio_cb.rcvring,(unsigned char *)&framelen,readlen);
...
}
那么谁负责填充上述被读取的环形数据缓冲区呢?
//被OS接管的中断服务函数
LOS_HwiCreate(s_uwIRQn, 3, 0, atio_irq, 0);
//中断服务函数的具体实现
static void atio_irq(void) {
...
ring_buffer_write(&g_atio_cb.rcvring,(unsigned char *)&ringspace,sizeof(ringspace));
ring_buffer_write(&g_atio_cb.rcvring,g_atio_cb.rcvbuf,ringspace);
...
}
op->write 回调函数
los_dev_write 函数的最底层实现,相比于read,简单了许多,直接调用HAL层串口发送接口即可,
//__at_write 封装以下 uart_at_send 过程
static ssize_t uart_at_send(const char *buf, size_t len,uint32_t timeout) {
HAL_UART_Transmit(&uart_at,(unsigned char *)buf,len,timeout);
g_atio_cb.sndlen += len;
g_atio_cb.sndframe ++;
return len;
}