IoT/透过oc_lwm2m和at源码,分析NB-IoT通信模组和主板MCU之间的通信过程
文章目录
- 概述
- AT通信硬件链路
- 将指令发送到模组
-
- 核心函数 at_command
- 指令从MCU传到模组
- 等待模组反馈指令执行
- 处理来组模组的数据
-
- 生数据的传递
- 获取模组发来的数据
- URC 处理过程
- 串口AT设备驱动层
-
- 静态驱动注册机制
- 链接脚本的作用
- 驱动表的存储原理
- 协同 osdriv_load_static
- 看LiteOS的驱动表
- 使用静态注册的驱动表
- 底层设备读写
-
- los_dev_write/read
- LiteOS 设备操作句柄
- op->read 回调函数
- op->write 回调函数
概述
本文详细介绍了 NB-IoT模组与主板MCU之间的通信原理,主要包括以下几个部分:
1、NB-IoT与MCU之间硬件电路分析。
2、MCU代码生成的AT指令数据,以怎样的路径向NB-IoT模组传输。
3、NB-IoT模组输出的指令反馈和URC数据,是怎么被MCU代码读取到并处理的。
4、分了LiteOS操作系统下设备驱动的静态注册机制,理解UART_AT驱动的工作机制。
@HISTORY
阅读此文前,请先阅读 ##,以了解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 wishinstruction :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 needstatic 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 1static bool_t boudica150_set_echo(int enable) { (void) snprintf(cmd,64,\"ATE%d\\r\",enable); ret = boudica150_atcmd(cmd,\"OK\"); //step 2static 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->indexstatic 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->indexstatic 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(网络注册)等。
参考 ## 文中对于 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 ///< install the driver framework for the link (void)los_driv_init();#endif...}
在我们熟悉的link_main函数下,设备初始化函数被调用,我们顺着这条线索继续追查,
/*******************************************************************************function :the device module entryinstruction :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 functiontypedef 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 itselftypedef 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;}