lwip系列一之数据的收发

lwip宏观的

经过一段时间的反复折磨,也看了许多资料,做一下学习总结,同时希望通过向他人表述来加深对内容的理解。驱动程序是参照野火的,但是我觉得这里面有点小小的疑问没有解决。

我不知道大家曾经是否有和我一样的疑问,学完计算机网络后,对计算机网络的各个层次的原理有所了解,但是有个疑问就是如何将这个协议起来,为了能更好的说明数据收发过程,我们暂时将这个协议当成一个黑盒子,观察其如何在计算机中起来。

参考下面的图

将lwip看成是看成是黑盒子,他能接收数据,然后对其进行处理,怎么处理暂时不管,然后递交给应用端;

反之,应用端发送数据,经过协议处理后,递交给外设,然后发送出去。

所以说这个lwip可以认为是一个消息处理器,对接收的消息进行处理,对发送的消息进行处理。

所以说可以直接将这个协议栈打包成一个高优先级的线程,干什么呢,不断的去读取邮箱中的需要处理的数据(包含发送和接收)谁先来,谁先处理。这样就将协议“立”起来了。

实际中就有一个线程,叫做tcpip_thread,有个邮箱tcpip_mbox来存放等待处理的消息,这个线程在干什么事呢?

不断的尝试从邮箱中取数据,取到呢,就进行消息处理

取不到呢?判断一下有没有超时事件,

没有超时事件,那就一直阻塞,任务进入阻塞态,让其他任务运行

有超时事件,获取下次超时的时间,然后阻塞这段时间,然后进行超时检查。大致的逻辑是这样的。具体的代码细节,暂时不管,这里主要是方便我的记忆。

这样就在整体上能对lwip有个认识。

然后呢,关注数据的收发了,这里呢只描述上图左边,靠近底层的数据收发的实现过程。就是在那种软件、硬件交叉的地方。

数据的接收过程

我们先来看一张逻辑框图:

对于接收过程:我们传输的电平信号通过网线的接口进入到外部PHY中,然后再被MAC所接收,当然这个MAC具有地址过滤的机制们也就是说能根据MAC地址能进行过滤,还会进行CRC校验进行帧的接收,这些是可配置的,配置完后,硬件会自动帮你做的,还会硬件帮你过滤掉以太网帧的前导符,帧首符,还有CRC校验字段。

MAC完成到它的功能后,就会将接收到的数据放入2k字节的接收FIFO中,然后呢,通过DMA将数据传送至物理内存,直白一点说就是传送至你定义数组啊之类的能存放数据的空间。

对于发送过程:就是上述的逆过程了。

数据包装过程

我们知道,tcp/ip是不同层次的,如果是层与层之间递交数据时,要发生数据的拷贝,那么整个lwip内核的运行效率就会十分低下。

所以说为了避免数据产生层层拷贝,引入了pbuf结构体,通过一个指向该结构体的指针来访问全部数据。

该结构体的原理图如下:

此时我们大致来说说数据的流向:

数据被以太网外设接收后,再通过DMA传输至物理内存,这里的物理内存呢实际上就是数组(也称为缓存),也就是说数据经过DMA后,就到一个数组中,等着你来用。你不能直接占据这个空间来使用,因为这个空间还需要接收其他数据,所以数据来了后要马上把这个空间的数据搬出来,把这个空间释放出来以接收其他以太网数据,因为以太网一直不断的在接收数据,你不出来,后续数据不就没有空间可以放了吗。

所以说,一旦有数据之后呢,就要立刻去将数据清出来,放入上面提及的pbuf中。

为了实现上面这句话,在lwip中呢,创建了一个计数信号量和一个接收线程来实现任务同步的功能,为什么是计数信号量而不是二值信号量呢,二值不适合频繁发生中断的场合。

在以太网外设初始化时,初始化为接收完成中断,然后在接收中断完成的回调函数中释放信号量,信号量的释放导致接收线程的运行,接收线程干什么事呢?核心就是将缓存中的数据清出来,然后包装成pbuf,然后对pbuf再进行简单的包装变成消息,然后投递给前面提到的tcpip_mbox,然后线程tcpip_thread就运行起来了,就可以开始处理消息了。

到这里为止,我们涉及到关键的二个线程,一个邮箱,一个计数信号量。

一个tcp/ip处理线程tcpip_thread:不断地尝试去读邮箱的消息,然后进行消息处理

一个先进先出的邮箱tcpip_mbox:不管是接收的还是需要发送的,最终都会在邮箱中排队,等待处理

一个计数信号量s_xSemaphore:用于在接收完成时,触发中断,在中断回调中释放信号量,触发接收线程的运行

一个接收线程ethernetif_input:核心是将缓存的数据清出来,包装成pbuf,再包装成消息,发到邮箱tcpip_mbox

上述呢,基本上将整个lwip的运行,以及数据的流向大致有个印象。当然要通过一篇文章将所有方方面面讲清楚是不可能的,最终要搞清楚什么的,必须啃代码,这里只是整体有个印象,能将整个过程联通起来。

再细节一点

前面提到数据的流向个过程是

缓存——>pbuf——>消息。前面认为缓存是一个数组,确实是一个数组,每个缓存呢对应有一个描述符来管理,这个描述符是个结构体,按道理说是软件来管理的,但是实际上确是软件定义了这个结构体,但是里面一些状态字段的更新之类的却是硬件帮你做的,搞得有点像寄存器,这是我个人就觉得是最抽象的地方,你说你软件搞得,看看程序就行,硬件搞得,看看原理就行,对吧,这里呢,这个描述符搞得又与软件相关,又与硬件相关。先看看几个定义:

ETH_DMADescTypeDef  DMARxDscrTab[ETH_RXBUFNB] ;/*接收描述符 */ETH_DMADescTypeDef  DMATxDscrTab[ETH_TXBUFNB] ;/* 发送描述符 */uint8_t Rx_Buff[ETH_RXBUFNB][ETH_RX_BUF_SIZE] ; /* 接收缓冲区 */uint8_t Tx_Buff[ETH_TXBUFNB][ETH_TX_BUF_SIZE] ; /* 发送缓冲区 */

从上面可以看到,描述符是就是特定类型的结构,当然结构体内部成员字段代表什么含义就去看参考手册,这里不说,定义的是一个结构体数组,每个描述符对应一个缓存,缓存就是数组,来源于下面的Rx_Buff缓冲区(本质上就是一个二维数组)

这里区别一下缓存与缓冲区的关系,缓存是缓冲区的一部分,像野火的驱动设计中就设计缓存为1/8的缓冲区大小。缓存就是缓冲区的一个子集。

我们接收到的数据呢,就存放在缓存里,我们发送数据呢,就将数据放入发送的缓冲区中,并将发送描述符第一个成员字段的OWN置位,就把数据发送出去了。

我们通过下面一张图来描述缓存,描述符的关系。

写过一点程序的就知道,光定义一个结构体,内部是空的,所以要建立上图中的这个样子,需要在初始化时调用一个函数

HAL_ETH_DMATxDescListInit(&heth, DMATxDscrTab, &Tx_Buff[0][0], ETH_TXBUFNB);来建立起上图的关系。这个函数会在后续进行注释,可以看看其是怎么样的过程。

好了,到此,基本上呢原理性的东西基本上说的差不多了。下面能就对关键的代码语句进行说明与注释,当然有些东西在一篇文章中没法说的特别详细,大致看看,同时加深自己的印象。

数据接收过程关键性代码阅读

1)前面提到过,在接收中断中的服务函数中接收完成回调函数中释放信号量:来触发接收线程的运行,代码如下:

extern xSemaphoreHandle s_xSemaphore;
void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth)
{LED2_TOGGLE;portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;xSemaphoreGiveFromISR( s_xSemaphore, &xHigherPriorityTaskWoken );//释放信号量portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

2)释放信号量后,线程ethernetif_input运行,这个线程的代码如下:核心逻辑还是如文章上面所说,具体涉及的重要函数的注释放在文章后面,感兴趣的可以看看

void ethernetif_input(void *pParams)
{struct netif *netif;struct pbuf *p = NULL;netif = (struct netif*) pParams;while(1) {if(xSemaphoreTake( s_xSemaphore, portMAX_DELAY ) == pdTRUE)//获取信号量{taskENTER_CRITICAL();
TRY_GET_NEXT_FRAGMENT:p = low_level_input(netif);//将缓存数据取出,并打包成pbuftaskEXIT_CRITICAL();if(p != NULL){taskENTER_CRITICAL();if (netif->input(p, netif) != ERR_OK)//将pbuf打包成消息,并发送至邮箱中这个函数是//tcpip_input(struct pbuf *p, struct netif *inp){LWIP_DEBUGF(NETIF_DEBUG, ("ethernetif_input: IP input error\n"));pbuf_free(p);p = NULL;}else{xSemaphoreTake( s_xSemaphore, 0);goto TRY_GET_NEXT_FRAGMENT;}taskEXIT_CRITICAL();}}}
}

函数low_level_input的注释

static struct pbuf * low_level_input(struct netif *netif)
{struct pbuf *p = NULL;struct pbuf *q = NULL;uint16_t len = 0;uint8_t *buffer;__IO ETH_DMADescTypeDef *dmarxdesc;//以太网接收描述符uint32_t bufferoffset = 0;uint32_t payloadoffset = 0;uint32_t byteslefttocopy = 0;uint32_t i=0;/* get received frame ,该函数返回HAL_OK,说明数据已经DMA至数据存储区了,但是这个是野火的驱动程序写法,这里有个疑问,因为在野火的驱动程序中,因为他的描述符对应的缓存空间为1524,能够放下最大的以太网数   据,所以说,这个low_level_input这样写是没问题,但是如果说你分配的每个缓存的空间不一定能容纳最大以太网帧,那么我觉得这么   就非常有问题*/if (HAL_ETH_GetReceivedFrame(&heth) != HAL_OK){//    PRINT_ERR("receive frame faild\n");return NULL;}/* Obtain the size of the packet and put it into the "len" variable. */len = heth.RxFrameInfos.length;//获取帧长buffer = (uint8_t *)heth.RxFrameInfos.buffer;//获取帧信息的接收缓冲区的地址PRINT_INFO("receive frame len : %d\n", len);if (len > 0){/* We allocate a pbuf chain of pbufs from the Lwip buffer pool */p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL);//分配的是帧长的空间,类型是pbuf,(可能有多个)}if (p != NULL){dmarxdesc = heth.RxFrameInfos.FSRxDesc;//获取帧的第一个描述符,野火中一个描述符能能缓存一个帧bufferoffset = 0;//缓存中的的数据偏移for(q = p; q != NULL; q = q->next)//遍历pbuf{byteslefttocopy = q->len;//总共剩下的需要拷贝的字节数,初始为总的帧长payloadoffset = 0;//数据偏移,指的是拷贝到哪了/* Check if the length of bytes to copy in current pbuf is bigger than Rx buffer size*/while( (byteslefttocopy + bufferoffset) > ETH_RX_BUF_SIZE )//如果帧长大于缓存大小(野火中的驱动不会大于){/* Copy data to pbuf */memcpy( (uint8_t*)((uint8_t*)q->payload + payloadoffset), (uint8_t*)((uint8_t*)buffer + bufferoffset), (ETH_RX_BUF_SIZE - bufferoffset));//从接收缓冲区拷贝数据(缓存大小)到pbuf的数据区域,/* Point to next descriptor */dmarxdesc = (ETH_DMADescTypeDef *)(dmarxdesc->Buffer2NextDescAddr);//指向下一个描述符buffer = (uint8_t *)(dmarxdesc->Buffer1Addr);//同样获取缓冲区地址byteslefttocopy = byteslefttocopy - (ETH_RX_BUF_SIZE - bufferoffset);//剩余需拷贝字节数payloadoffset = payloadoffset + (ETH_RX_BUF_SIZE - bufferoffset);//拷贝到这了bufferoffset = 0;}//直到最后一个描述符退出/* Copy remaining data in pbuf */memcpy( (uint8_t*)((uint8_t*)q->payload + payloadoffset), (uint8_t*)((uint8_t*)buffer + bufferoffset), byteslefttocopy);//拷贝剩下的字节bufferoffset = bufferoffset + byteslefttocopy;}//到此拷贝进pbuf中}  /* Release descriptors to DMA *//* Point to first descriptor */dmarxdesc = heth.RxFrameInfos.FSRxDesc;//指向帧信息的第一个描述符/* Set Own bit in Rx descriptors: gives the buffers back to DMA */for (i=0; i< heth.RxFrameInfos.SegCount; i++)//遍历帧信息的每个描述符(野火中一个帧帧信息描述符只有一个){  dmarxdesc->Status |= ETH_DMARXDESC_OWN;//置位own,代表的是DMA拥有,只有复位时CPU才能去读取描述符中的缓存数据dmarxdesc = (ETH_DMADescTypeDef *)(dmarxdesc->Buffer2NextDescAddr);}/* Clear Segment_Count */heth.RxFrameInfos.SegCount =0;  //释放过程的清0 操作/* When Rx Buffer unavailable flag is set: clear it and resume reception寄存器标志位清0 的操作*/if ((heth.Instance->DMASR & ETH_DMASR_RBUS) != (uint32_t)RESET)  {/* Clear RBUS ETHERNET DMA flag */heth.Instance->DMASR = ETH_DMASR_RBUS;/* Resume DMA reception */heth.Instance->DMARPDR = 0;}return p;//最后返回PBUF
}

函数tcpip_input注释

err_t
tcpip_input(struct pbuf *p, struct netif *inp)
{#if LWIP_ETHERNETif (inp->flags & (NETIF_FLAG_ETHARP | NETIF_FLAG_ETHERNET)) {return tcpip_inpkt(p, inp, ethernet_input);} else
#endif /* LWIP_ETHERNET */return tcpip_inpkt(p, inp, ip_input);
}

函数tcpip_inpkt注释

该函数是将数据打包成消息,并将消息发送至邮箱中

err_t
tcpip_inpkt(struct pbuf *p, struct netif *inp, netif_input_fn input_fn)
{struct tcpip_msg *msg;msg = (struct tcpip_msg *)memp_malloc(MEMP_TCPIP_MSG_INPKT);if (msg == NULL) {return ERR_MEM;}msg->type = TCPIP_MSG_INPKT;msg->msg.inp.p = p;msg->msg.inp.netif = inp;msg->msg.inp.input_fn = input_fn;//消息处理函数ethernet_inputif (sys_mbox_trypost(&tcpip_mbox, msg) != ERR_OK) //向邮箱投递消息{memp_free(MEMP_TCPIP_MSG_INPKT, msg);return ERR_MEM;}return ERR_OK;
}

数据发送过程关键性代码阅读

数据发送最终就是将数据放到发送缓存中,然后就可以发送出去,与接收不同的是,数据接收对应有个接收线程,发送没有发送线程,数据要通过以太网发送出去,最终都要调用函数ethernet_output

函数ethernet_output注释

/*
这个函数是*/
/*
参数分别是1网卡
2,pbuf
3,源以太网地址
4,目的以太网地址
5,以太网帧类型*/
err_t
ethernet_output(struct netif * netif, struct pbuf * p,const struct eth_addr * src, const struct eth_addr * dst,u16_t eth_type) {struct eth_hdr *ethhdr;u16_t eth_type_be = lwip_htons(eth_type);{if (pbuf_add_header(p, SIZEOF_ETH_HDR) != 0) //这个函数就是改变pbuf的payload()本身预留有空间,len,tot_len的值,{goto pbuf_header_failed;}}ethhdr = (struct eth_hdr *)p->payload;//指向以太网帧头部ethhdr->type = eth_type_be;//填充类型SMEMCPY(&ethhdr->dest, dst, ETH_HWADDR_LEN);//复制MAC地址 SMEMCPY(&ethhdr->src,  src, ETH_HWADDR_LEN);/* send the packet */return netif->linkoutput(netif, p);//这个网卡的linkoutput本质上是low_level_outputpbuf_header_failed:return ERR_BUF;
}
/*
这个函数也是很复杂,主要就是通过将pbuf的数据发送出去,那么就需要将pbuf中的数据通过以太网发送描述符放到缓存中去
然后建立起对应的描述符,最后核心就是将OWN置位,这样DMA就可以开始其工作,将数据发送至发送FIFO中,再通过MAC发送出去。*/
static err_t low_level_output(struct netif *netif, struct pbuf *p)
{static sys_sem_t ousem = NULL;if(ousem == NULL){sys_sem_new(&ousem,0);//创建一个二值信号量sys_sem_signal(&ousem);//先释放一个信号量,获取时就不会堵塞}err_t errval;struct pbuf *q;uint8_t *buffer = (uint8_t *)(heth.TxDesc->Buffer1Addr);//缓存地址为发送描述符的地址__IO ETH_DMADescTypeDef *DmaTxDesc;uint32_t framelength = 0;//帧长uint32_t bufferoffset = 0;uint32_t byteslefttocopy = 0;//剩余的需要拷贝的字节数uint32_t payloadoffset = 0;DmaTxDesc = heth.TxDesc;//指向第一个发送描述符bufferoffset = 0;/* Check if the descriptor is owned by the ETHERNET DMA (when set) or CPU (when reset) *//* Is this buffer available? If not, goto error */if((DmaTxDesc->Status & ETH_DMATXDESC_OWN) != (uint32_t)RESET)//如果是OWN置位,说明DMA拥有出错{errval = ERR_USE;goto error;}sys_sem_wait(&ousem);//核心还是获取信号量,(一直等待),为什么这里这么写不清楚。/* copy frame from pbufs to driver buffers */for(q = p; q != NULL; q = q->next)//遍历pbuf{/* Get bytes in current lwIP buffer */byteslefttocopy = q->len;payloadoffset = 0;/* Check if the length of data to copy is bigger than Tx buffer size*/while( (byteslefttocopy + bufferoffset) > ETH_TX_BUF_SIZE )//当帧长大于缓存(野火的不会){/* Copy data to Tx buffer*/memcpy( (uint8_t*)((uint8_t*)buffer + bufferoffset), (uint8_t*)((uint8_t*)q->payload + payloadoffset), (ETH_TX_BUF_SIZE - bufferoffset) );//拷贝数据到缓存中从pbuf中/* Point to next descriptor */DmaTxDesc = (ETH_DMADescTypeDef *)(DmaTxDesc->Buffer2NextDescAddr);//指向下一个描述符/* Check if the buffer is available ,*/if((DmaTxDesc->Status & ETH_DMATXDESC_OWN) != (uint32_t)RESET){errval = ERR_USE;goto error;}buffer = (uint8_t *)(DmaTxDesc->Buffer1Addr);//下个描述符的缓存地址byteslefttocopy = byteslefttocopy - (ETH_TX_BUF_SIZE - bufferoffset);//计算剩余字节数payloadoffset = payloadoffset + (ETH_TX_BUF_SIZE - bufferoffset);//已拷贝字节数framelength = framelength + (ETH_TX_BUF_SIZE - bufferoffset);//计算帧长bufferoffset = 0;}/* Copy the remaining bytes 拷贝剩下的*/memcpy( (uint8_t*)((uint8_t*)buffer + bufferoffset), (uint8_t*)((uint8_t*)q->payload + payloadoffset), byteslefttocopy );bufferoffset = bufferoffset + byteslefttocopy;//最后一个描述符对应的字节数framelength = framelength + byteslefttocopy;}/* Prepare transmit descriptors to give to DMA前面已经将数据放至缓存中,并且描述符已经建立好对应关系了*/ HAL_ETH_TransmitFrame(&heth, framelength);//核心就是置位OWN,然后DMA就可以开始工作了。errval = ERR_OK;error:/* When Transmit Underflow flag is set, clear it and issue a Transmit Poll Demand to resume transmission */if ((heth.Instance->DMASR & ETH_DMASR_TUS) != (uint32_t)RESET){/* Clear TUS ETHERNET DMA flag */heth.Instance->DMASR = ETH_DMASR_TUS;/* Resume DMA transmission*/heth.Instance->DMATPDR = 0;}sys_sem_signal(&ousem);//这个还是释放信号量,暂时不知道为什么return errval;
}

线程tcpip_thread删了一小部分

static void
tcpip_thread(void *arg)
{struct tcpip_msg *msg;while (1) {                          /* MAIN Loop */TCPIP_MBOX_FETCH(&tcpip_mbox, (void **)&msg);//取消息,并超时检查if (msg == NULL) {continue;}tcpip_thread_handle_msg(msg);}
}

该线程核心就是不断尝试取邮箱中消息,然后进行消息处理。

lwip系列一之数据的收发相关推荐

  1. 清华大数据系列讲座——大数据发展与区块链应用成功举办

    2018年9月15日,由清华-青岛大数据工程研究中心主办的"清华大数据系列讲座-大数据发展与区块链应用"在中国海洋大学成功举办.本此讲座邀请到了清华-青岛数据科学研究院执行副院长韩 ...

  2. boot访问resources下边的图片_SpringBoot系列之JDBC数据访问

    SpringBoot系列之JDBC数据访问 SpringBoot jdbc是比较常用的内容,本博客通过实验并简单跟源码的方式进行介绍,希望可以帮助学习者更好地理解 环境准备: IDEA Maven 先 ...

  3. .NET 并行(多核)编程系列之七 共享数据问题和解决概述

    .NET 并行(多核)编程系列之七 共享数据问题和解决概述 原文:.NET 并行(多核)编程系列之七 共享数据问题和解决概述 .NET 并行(多核)编程系列之七 共享数据问题和解决概述 前言:之前的文 ...

  4. Caffe学习系列(13):数据可视化环境(python接口)配置

    原文有更新: Caffe学习系列(13):数据可视化环境(python接口)配置 - denny402 - 博客园 http://www.cnblogs.com/denny402/p/5088399. ...

  5. Flagger on ASM——基于Mixerless Telemetry实现渐进式灰度发布系列 1 遥测数据

    简介:服务网格ASM的Mixerless Telemetry技术,为业务容器提供了无侵入式的遥测数据.遥测数据一方面作为监控指标被ARMPS/prometheus采集,用于服务网格可观测性:另一方面被 ...

  6. webservice 参数太大_手把手系列:常用数据交换方案Web Service接口处理法

    手把手系列:常用数据交换方案之Web Service接口处理法 Web Service是一个SOA(面向服务的编程)的架构,是一个平台独立的,低耦合的,基于可编程的web的应用程序,可使用开放的XML ...

  7. Flagger on ASM·基于Mixerless Telemetry实现渐进式灰度发布系列 1 遥测数据

    简介: 服务网格ASM的Mixerless Telemetry技术,为业务容器提供了无侵入式的遥测数据.遥测数据一方面作为监控指标被ARMPS/prometheus采集,用于服务网格可观测性:另一方面 ...

  8. eview面板数据之混合回归模型_【视频教程】Eviews系列25|面板数据回归分析之Hausman检验及本章常见问题解答...

    点击上方关注我们! 本期我们学习Eviews统计建模最后一部分--面板数据回归分析Hausman检验及本章常见问题解答.实操:Hausman检验判断是固定效应模型还是随机效应模型上期我们讲到模型判断若 ...

  9. PyTorch系列 (二): pytorch数据读取自制数据集并

    PyTorch系列 (二): pytorch数据读取 PyTorch 1: How to use data in pytorch Posted by WangW on February 1, 2019 ...

最新文章

  1. 37岁程序员被裁,120天没找到工作,无奈去小公司,结果懵了...
  2. 【原创】cs+html+js+css模式(五):页面调用JS的编写
  3. java面向对象程序设计第三版耿祥义pdf_java基础知识干货——封装
  4. Java集合面试题?看这篇就够了!
  5. 全球及中国页岩气市场供需前景与投资盈利分析报告2021版
  6. QT的QScroller类的使用
  7. 字节跳动屡战社交,这次抖音亲自上场了
  8. java 文件拷贝保留原来的属性,Java - 复制JPG,同时保留所有文件属性
  9. java竖向菜单,垂直滑动菜单
  10. 【开发者成长】“机器学习还是很难用!”
  11. python vtk实时更新点云_Python-VTK:点云和颜色b
  12. C# 调用word时,禁用宏
  13. vue小案例一:todolist
  14. C++11常用新特性
  15. 在C语言中如何计算根号
  16. 计算机毕业设计 安卓 Android studio音乐播放器app 仿酷狗,仿网易云音乐播放器
  17. mysql查看mylog命令_mysql 日志查询(查看mysql日志命令)
  18. pyqt+pyqtgraph+lka(界面制作)--优化版
  19. 这篇 python 文章,是过去你错过的 python 细节知识点,滚雪球第4季第15篇
  20. 这篇文章告诉你:信息学奥赛的由来,几岁学对孩子有多重要性

热门文章

  1. FL Studio水果2023版本更新下载汉化教程
  2. iOS实现绘画文字动画
  3. 2020 ,6 种不死的编程语言!
  4. 《大师说栏目第一期》汽车以太网测试项那么多,到底该测啥呢?
  5. 音频频谱显示-显示音频文件静态频谱图(一)
  6. Linux中删除文件夹和文件的命令(☆)
  7. LeetCode题解(1552):将多个球放入指定位置的多个篮子后两球之间最小距离的最大值(Python)
  8. appemit使用mpvPlayer在谷歌chrome浏览器播放RTSP
  9. windows程序设计作业
  10. android手机 无电池开机画面,安卓手机无法开机的6种解决方法