当前位置: 首页 > news >正文

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
阅读此文前,请先阅读 #<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 initosdriv_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 thisnum = ((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);
#endiffor(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 applicationfn_devread    read;   //triggered by the applicationfn_devwrite   write;  //triggered by the applicationfn_devclose   close;  //triggered by the applicationfn_devioctl   ioctl;  //triggered by the applicationfn_devseek    seek ;  //triggered by the applicationfn_devinit    init;   //if first open,then will be calledfn_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;
}
http://www.dtcms.com/a/337611.html

相关文章:

  • 自建K8s集群无缝集成阿里云RAM完整指南
  • 重温 K8s 基础概念知识系列五(存储、配置、安全和策略)
  • Kubernetes(K8s)常用命令全解析:从基础到进阶
  • kubeadm方式部署k8s集群
  • 备考国央企-算法笔记-01链表
  • HakcMyVM-Friendly
  • MongoDB Windows 系统实战手册:从配置到数据处理入门
  • Esp32基础(③旋转编码器)
  • 用一个label控件随便显示一些字(用矢量字库),然后用anim动画动态设置lable位置
  • 上海1KM人口热力数据分享
  • 音频分类模型笔记
  • rust 从入门到精通之变量和常量
  • 杂记 04
  • 脑潜在进展:基于潜扩散模型的三维脑磁共振成像个体时空疾病进展研究|文献速递-深度学习人工智能医疗图像
  • python的课外学习生活活动系统
  • 视觉语言导航(13)——AIR-VLN 4.3
  • Mysql核心框架知识
  • 学习雪花算法
  • 冒泡排序——简单理解和使用
  • NVIDIA 技术沙龙探秘:聚焦 Physical AI 专场前沿技术
  • Handler以及AsyncTask知识点详解
  • 数据结构部分算法记录
  • Oracle维护指南
  • 计算机大数据毕业设计推荐:基于Hadoop+Spark的食物口味差异分析可视化系统【源码+文档+调试】
  • RPM数据库损坏修复:DB_RUNRECOVERY: Fatal error, run database recovery
  • 新能源知识库(78)微网控制器与储能LCU对比
  • 【opencv-Python学习日记(7):图像平滑处理】
  • 普通用户使用docker命令
  • 「数据获取」《中国经济普查年鉴》(2004、2008、2013、2018、2023)(获取方式看绑定的资源)
  • Centos7 使用lamp架构部署wordpress