FreeRTOS 教程指南 学习笔记 第二章 内存管理

一、简介

本书的以下章节将介绍内核对象,如任务、队列、信号量和事件组。为了使FreeRTOS尽可能容易使用,这些内核对象不是在编译时静态分配,而是在运行时动态分配;FreeRTOS在每次创建内核对象时分配RAM,并在每次删除内核对象时释放RAM。此策略减少了设计和规划工作,简化了API,并最小化了RAM占用。
本章讨论了动态内存分配。动态内存分配是一个C语言编程的概念,而不是一个特定于FreeRTOS或多任务处理的概念。它与FreeRTOS相关,因为内核对象是动态分配的,而且由通用编译器提供的动态内存分配方案并不总是适合于实时应用程序。

二、动态内存分配及其与FreeRTOS的相关性

内存可以使用标准的C库malloc()和免费的()函数进行分配,但它们可能不适合,或不适合,因为以下一个或多个原因:

  • 它们并不总是在小型嵌入式系统上可用。
  • 它们的实现可以相对较大,占用有价值的代码空间。
  • 它们很少是线程安全的。
  • 它们不是确定性的;执行函数所花费的时间与调用不同。
  • 他们可能会是碎片化的。
  • 它们可以使连接器的配置复杂化。
  • 如果允许堆空间增长到其他变量使用的内存中,它们可能成为难以调试错误的来源

三、用于动态内存分配的选项

FreeRTOS现在将内存分配视为可移植层的一部分(而不是核心代码库的一部分)。这是为了认识到不同的嵌入式系统具有不同的动态内存分配和时间要求,因此一个单一的动态内存分配算法将永远只适用于应用程序的一个子集。此外,从核心代码库中删除动态内存分配可以使应用程序编写器能够在适当的时候提供它们自己的特定实现。
当FreeRTOS需要RAM时,它不是调用Malloc(),而是调用pvPortMalloc()。当释放RAM时,内核不是调用free(),而是调用vPortFree()。pvPortMalloc()与标准C库malloc()函数具有相同的原型,而vPortFree()与标准C库free()函数具有相同的原型。
pvPortMalloc()和vPortFree()是公共函数,因此也可以从应用程序代码中调用。
FreeRTOS提供了pvPortMalloc()和vPortFree()的五个实现示例,所有这些都将在本章中记录。FreeRTOS应用程序可以使用其中一个示例实现,或者提供它们自己的实现。这五个例子分别在heap_1.c、heap_2.c、heap_3.c、heap_4.c和heap_5.c源文件中进行了定义,所有这些文件都位于FreeRTOS/Source/portable/MemMang目录中。
本章旨在让读者能够很好地理解:

  • FreeRTOS何时进行RAM分配。
  • 介绍FreeRTOS提供的五个内存分配方案示例。
  • 如何选择内存分配方案。

四、内存分配方案的示例

Heap_1

对于小型专用嵌入式系统,通常在调度程序启动之前只创建任务和其他内核对象。在这种情况下,只有在应用程序开始执行任何实时功能之前,内核才能动态地分配内存,并且内存会保持分配到应用程序的整个生命周期。这意味着所选择的分配方案不必考虑任何更复杂的内存分配问题,如决定论和碎片化,而可以只考虑代码大小和简单性等属性。
Heap_1.c实现了一个非常基本的pvPortMalloc()版本,而没有实现vPortFree()。从未删除任务或其他内核对象的应用程序有可能使用heap_1方式。一些原本会禁止使用动态内存分配的商业关键和安全关键系统也有可能使用heap_1。关键系统通常禁止动态内存分配,因为存在内存碎片和失败的分配相关的不确定性——但Heap_1总是确定性的,不能将内存碎片分割。
heap_1分配方案将一个简单的数组细分为更小的块,作为对pvPortMalloc()的调用。该数组被称为FreeRTOS堆。数组的总大小(以字节为单位)由FreeRTOSConfig.h中的定义configTOTAL_HEAP_SIZE设置。以这种方式定义一个大数组会使应用程序消耗大量RAM——甚至在从数组分配任何内存之前。每个已创建的任务都需要从堆中分配一个任务控制块(TCB)和一个堆栈。图5演示了heap_1如何在创建任务时细分简单数组。

  • A显示了在创建任何任务之前的数组——整个数组都是空闲的。
  • B显示了创建一个任务后的数组。
  • C表示在创建了三个任务后的数组。

Heap_2

Heap_2保留在FreeRTOS发行版中以实现向后兼容,但不建议在新设计中使用它。请考虑使用heap_4而不是heap_2,因为heap_4提供了增强的功能。Heap_2.c同样通过细分一个由configTOTAL_HEAP_SIZE标注的数组来工作。它使用最佳匹配算法来分配内存,与heap_1不同,它确实允许释放内存。同样,数组是静态声明的,因此将使应用程序可能消耗大量RAM,甚至在数组的任何内存被分配之前。最佳拟合算法确保pvPortMalloc()使用大小最接近请求字节数的空闲内存块。例如,考虑以下场景:

  • 堆包含三个可用内存块,分别为5字节、25字节和100字节。
  • pvPortMalloc()以请求20字节的RAM。
    适合最小内存请求的内存块是25字节块,所以在返回一个20字节块的指针前,pvPortMalloc()25字节块分割分成一个20字节和一个5 字节块。新的5字节块仍然留作将来其他分配对pvPortMalloc()的调用。

与heap_4不同,Heap_2不将相邻的自由块组合成一个更大的块,所以它更容易破碎。但是,如果分配和随后释放的块总是相同的大小,碎片不是问题。Heap_2适用于重复创建和删除任务的应用程序,前提是分配给已创建任务的堆栈的大小不会更改。

图6演示了最佳匹配算法在创建、删除任务,然后再次创建任务时,是如何工作。请参见图6:

  • A显示了在创建了三个任务后的数组。一个很大的自由块仍然保留在数组的顶部。
  • B显示了删除其中一个任务后的数组。位于数组顶部的较大的自由块保留了下来。现在还有两个较小的空闲块以前分配给TCB和删除任务的堆栈
  • C表示在创建另一个任务后的情况。创建任务导致了对pvportMalloc()的两个调用,一个用于分配一个新的TCB,另一个用于分配任务栈。任务是使用xTaskCreate()API函数创建的,详细描述见第3.4节。对pvPortMalloc()的调用发生在xTaskCreate()的内部。

每个TCB的大小完全相同,因此最佳拟合算法确保先前分配给已删除任务的TCB的RAM块被重用,以分配新任务的TCB。分配给新创建的任务的堆栈大小与分配给之前删除的任务的栈大小相同,因此最佳匹配算法可以确保重用之前分配给已删除任务的栈的内存块来分配新任务的栈。位于数组顶部的较大的未分配块保持不变。
Heap_2不是确定性的,但比大多数malloc()和free()的标准库执行都要快。

Heap_3

Heap_3.c使用标准库malloc()和free()函数,因此堆的大小由链接器配置确定,而configTOTAL_HEAP_SIZE设置没有影响。Heap_3通过暂时暂停FreeRTOS调度程序,使malloc()和free()线程安全。线程安全,和调度程序暂停,都是在第7章,资源管理中讨论的主题。

Heap_4

和heap_1和heap_2一样,heap_4的工作原理是将一个数组细分为更小的块。与前面一样,数组是静态声明的,并由configTOTAL_HEAP_SIZE标注,因此将使应用程序似乎消耗大量RAM,甚至在实际从数组分配任何内存之前。
==Heap_4使用第一个拟合算法来分配内存。与heap_2不同,heap_4将合并相邻的空闲内存块成为一个更大的内存块,从而将内存碎片化的风险降到最低。==第一个拟合算法确保pvPortMalloc()使用第一个足够大空闲内存块来确保满足请求的字节数。例如,考虑以下场景:

  • 堆包含三个可用内存块,按照它们在数组中出现的顺序,分别为5字节、200字节和100字节。
  • pvPortMalloc()以请求20字节的RAM。

第一个适合请求的自由RAM块是200字节块,因此pvPortMalloc()将200字节块分成一个20字节块和一个180字节块,然后返回一个指向20字节块的指针。新的180字节块仍然可用于未来调用pvPortMalloc()。Heap_4将相邻的自由块合并成一个更大的块,最大限度地减少了碎片化的风险,并使其适合于重复分配和释放不同大小的RAM块的应用程序。

图7展示了在分配和释放内存时,heap_4匹配算法合并内存是如何工作的。参见图7:

  • A显示了在创建了三个任务后的数组。一个很大的自由块仍然保留在数组的顶部。
  • B显示删除其中一个任务后的数组。大的自由块在数组的顶部还在。还有一个空闲块,是之前分配各被删除任务的TCB和栈。注意,不像当heap_2,删除TCB和栈被删除时释放的内存,不再作为两个独立的空闲块,而是组合成一个新的自由块。
  • C显示创建了一个FreeRTOS队列后的情况。队列使用xQueueCreate()API函数创建,详见第4.3节。xQueueCreate()调用pvPortMalloc()来分配队列使用的RAM。由于heap_4使用第一个拟合算法,pvPortMalloc()将从第一个足以保存队列的空闲RAM块中进行分配,即图7中删除任务时释放的RAM。但是,该队列并不消耗空闲块中的所有RAM,因此该块被分成两个,并且未使用的部分仍然可用于将来调用pvPortMalloc()时进行分配。
  • D显示了应用程序代码直接调用pvPortMalloc()后,而不是通过调用FreeRTOS API函数间接调用的情况。用户程序所需要分配的空间足够小,第一个自由块(队列和下一个TCB之间)就可以满足。当任务被删除时释放的内存现在已经被分成三个单独的块;第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲。
  • E显示队列被删除后的情况,这会自动释放已分配给队列的内存。现在在用户分配的块的两边都有空闲内存。
  • F显示了用户分配的内存也被释放后的情况。用户分配的块所使用的内存与两边的空闲内存相结合,以创建一个更大的单个空闲块。

Heap_4不是确定性的,但比大多数malloc()和free()的标准库执行都要快

设置Heap_4所使用的数组的起始地址

本节包含高级级别的信息。使用Heap_4并不需要阅读或理解本节。
有时,应用程序作者需要将heap_4使用的数组放置在一个特定的内存地址上。例如,FreeRTOS任务使用的栈是从堆中分配的,因此可能有必要确保堆位于快速的内部内存中,而不是缓慢的外部内存中。
默认情况下,heap_4使用的数组在heap_4.c源文件中声明,其起始地址由链接器自动设置。但是,如果在FreeRTOSConfig.h中将configAPPLICATION_ALLOCATED_HEAP编译时配置常量设置为1,那么该数组必须由使用FreeRTOS的应用程序声明。如果该数组被声明为应用程序的一部分,那么应用程序作者就可以设置其起始地址。
如果在FreeRTOSConfig.h中configAPPLICATION_ALLOCATED_HEAP设置为1,那么应用程序的源文件中必须声明一个名为ucHeap的uint8_t数组,并由configTOTAL_HEAP_SIZE设置大小。
将变量放置在特定内存地址所需的语法取决于正在使用的编译器,因此请参阅编译器的文档。下面是两个编译器的示例:

  • 示例2显示了GCC编译器声明该数组所需的语法,并将该数组放在一个名为.my_heap的内存块中。
  • 示例3显示了IAR编译器声明该数组所需的语法,并将该数组放在绝对内存地址0x20000000处。
//Listing 2. Using GCC syntax to declare the array that will be used by heap_4, and place the array in a memory section named .my_heap
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] __attribute__ ( ( section( ".my_heap" ) ) );
//Listing 3. Using IAR syntax to declare the array that will be used by heap_4, and place the array at the absolute address 0x20000000
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] @ 0x20000000;

Heap_5

heap_5用于分配和释放内存的算法与heap_4相同。与heap_4不同的是,heap_5并不局限于从单个静态的数组中分配内存;heap_5可以从多个独立且分散的内存空间中分配内存。当RAM在系统的内存表中不显示为单个连续块时,heap_5将非常有用。
在编写代码时,heap_5是唯一在调用pvPortMalloc()前必须显式的初始化的内存分配方法。Heap_5使用vPortDefineHeapRegions()API函数初始化。当使用heap_5时,必须在创建任何内核对象(任务、队列、信号量等)之前调用vPortDefineHeapRegions()。

vPortDefineHeapRegions() API函数

vPortDefineHeapRegions()用于指定每个单独内存区域的起始地址和大小,这些区域共同构成了heap_5所使用的总内存。

//The vPortDefineHeapRegions() API function prototype
void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );

每个单独的存储区域都由HeapRegion_t类型的结构体来描述。所有可用内存区域的描述作为HeapRegion_t结构体组成的数组传递到vPortDefineHeapRegions()中。

//The HeapRegion_t structure
/*返回值pxHeapRegions:指向HeapRegion_t结构体组成的数组开头的指针。数组中的每个结构体都描述了在使用heap_5时将成为堆的一部分的内存区域的起始地址和长度。数组中的HeapRegion_t结构体必须按起始地址排序;描述具有最低起始地址的内存区域的HeapRegion_t结构必须是数组中的第一个结构,而描述具有最高起始地址的内存区域的HeapRegion_t结构必须是数组中的最后一个结构。数组的末端由一个HeapRegion_t结构标记,该结构的启动起始地址成员设置为NULL。*/
typedef struct HeapRegion
{/* The start address of a block of memory that will be part of the heap.*/uint8_t *pucStartAddress;/* The size of the block of memory in bytes. */size_t xSizeInBytes;
} HeapRegion_t;

举例来说,考虑图8A中所示的假设内存映射,它包含三个独立的RAM块:RAM1、RAM2和RAM3。假设可执行代码被放置在只读内存中,但没有显示。

示例6显示了一个HeapRegion_t结构体数组,它们一起描述了三个RAM块。

//Listing 6. An array of HeapRegion_t structures that together describe the 3 regions of RAM in their entirety
/* Define the start address and size of the three RAM regions. */
#define RAM1_START_ADDRESS ( ( uint8_t * ) 0x00010000 )
#define RAM1_SIZE ( 65 * 1024 )
#define RAM2_START_ADDRESS ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE ( 32 * 1024 )
#define RAM3_START_ADDRESS ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE ( 32 * 1024 )
/* Create an array of HeapRegion_t definitions, with an index for each of the three
RAM regions, and terminating the array with a NULL address. The HeapRegion_t
structures must appear in start address order, with the structure that contains the
lowest start address appearing first. */
const HeapRegion_t xHeapRegions[] =
{{ RAM1_START_ADDRESS, RAM1_SIZE },{ RAM2_START_ADDRESS, RAM2_SIZE },{ RAM3_START_ADDRESS, RAM3_SIZE },{ NULL, 0 } /* Marks the end of the array. */
};
int main( void ) {/* Initialize heap_5. */vPortDefineHeapRegions( xHeapRegions );/* Add application code here. */
}

虽然示例6正确地描述了RAM,但它没有演示一个可用的示例,因为它将所有RAM分配给堆,没有留下任何RAM供其他变量使用。
当编译项目时,编译过程的链接阶段将为每个变量分配一个RAM地址。链接器可供使用的RAM通常由链接器配置文件来描述,例如链接器脚本。在图8B中,假设链接器脚本包含了关于RAM1的信息,但不包含关于RAM2或RAM3的信息。因此,链接器在RAM1中放置了变量,只留下RAM1中地址0x0001 nnnn以上的部分可供heap_5使用。0x0001 nnnn的实际值将取决于被链接的应用程序中所包含的所有变量的组合大小。链接器使所有RAM2和所有RAM3都未使用,使得整个RAM2和整个RAM3可供heap_5使用。
如果使用了示例6中所示的代码,那么在地址0x0001 nnnn下面分配给heap_5的RAM将与用于保存变量的RAM重叠。为了避免这种情况,xHeapRegions[]数组中的第一个HeapRegion_t结构可以使用起始地址0x0001 nnnn,而不是起始地址0x00010000。但是,这并不是一个推荐的解决方案,因为:

  • 起始地址可能不容易确定。
  • 链接器所使用的RAM的数量可能会在未来的构建中发生变化,因此需要更新到HeapRegion_t结构中所使用的起始地址。
  • 如果链接器使用的RAM和heap_5使用的RAM重叠,构建工具将不知道,因此不能警告应用程序作者。

示例7展示了一个更方便和可维护的示例。它声明了一个名为ucHeap的数组。ucHeap是一个普通变量,因此它成为链接器分配给RAM1的数据的一部分。xHeapRegion数组中的第一个HeapRegion_t结构描述了ucHeap的起始地址和大小,因此ucHeap成为由heap_5管理的内存的一部分。ucHeap的大小可以增加,直到连接器使用的RAM消耗掉了所有的RAM1,如图8C所示。

//Listing 7. An array of HeapRegion_t structures that describe all of RAM2, all of RAM3, but only part of RAM1
/* Define the start address and size of the two RAM regions not used by the
linker. */
#define RAM2_START_ADDRESS ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE ( 32 * 1024 )
#define RAM3_START_ADDRESS ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE ( 32 * 1024 )
/* Declare an array that will be part of the heap used by heap_5. The array will be
placed in RAM1 by the linker. */
#define RAM1_HEAP_SIZE ( 30 * 1024 )
static uint8_t ucHeap[ RAM1_HEAP_SIZE ];
/* Create an array of HeapRegion_t definitions. Whereas in Listing 6 the first entry
described all of RAM1, so heap_5 will have used all of RAM1, this time the first
entry only describes the ucHeap array, so heap_5 will only use the part of RAM1 that
contains the ucHeap array. The HeapRegion_t structures must still appear in start
address order, with the structure that contains the lowest start address appearing
first. */
const HeapRegion_t xHeapRegions[] =
{{ ucHeap, RAM1_HEAP_SIZE },{ RAM2_START_ADDRESS, RAM2_SIZE },{ RAM3_START_ADDRESS, RAM3_SIZE },{ NULL, 0 } /* Marks the end of the array. */
};

示例7中展示的技术的有如下优点:

  • 不需要使用硬编码的起始地址。
  • 在HeapRegion_t结构中使用的地址将由链接器自动设置,因此将始终是正确的,即使链接器使用的RAM数量在未来的编译中发生变化。
  • 分配给heap_5的RAM不可能将由链接器放置到RAM1中的数据重叠。
  • 如果ucHeap的大小太大,则该应用程序将不会进行链接。

Heap相关实用程序功能

The xPortGetFreeHeapSize() API Function

xPortGetFreeHeapSize()API函数返回在调用该函数时堆中的空闲字节数。它可以用于优化堆的大小。例如,如果xPortGetFreeHeapSize()在创建了所有内核对象后返回2000,那么configTOTAL_HEAP_SIZE的值可以减少到2000。当使用heap_3时,xPortGetFreeHeapSize()不可用。

/*返回值size_t:调用xPortGetFreeHeapSize()时堆中未分配的字节数。*/
size_t xPortGetFreeHeapSize( void );
The xPortGetMinimumEverFreeHeapSize() API Function

xPortGetMinimumEverFreeHeapSize()API函数返回自FreeRTOS应用程序开始执行以来堆中存在的最小未分配的最小字节数。函数的返回值表明应用程序离堆内存溢出有多近。例如,如果返回值为200,那么,在应用程序开始执行后的某个时候,它距离堆空间耗尽不到200字节。
xPortGetMinimumEverFreeHeapSize()只有在使用heap_4或heap_5时才可用。

/*返回值size_t:自FreeRTOS应用程序开始执行以来,堆中存在的最小未分配字节数。*/
size_t xPortGetMinimumEverFreeHeapSize( void );

宏失效回调函数

可以直接从应用程序代码中调用pvPortMalloc()。在FreeRTOS源文件中,每次创建内核对象(任务、队列、信号量和事件组)时都会调用它。
就像标准库Malloc()函数一样,如果pvPortMalloc()因为请求的块大小不存在,则将返回NULL。如果因为应用程序作者正在创建一个内核对象而执行pvPortMalloc(),并且返回NULL,则该内核对象将无法创建。所有示例堆分配方案都可以配置一个回调函数,当pvPortMalloc()返回为NULL时将调用该回调函数。
如果在FreeRTOSConfig.h中将configUSE_MALLOC_FAILED_HOOK设置为1,则应用程序必须提供一个宏失效回调函数,该函数具有示例10所示的名称和原型。该函数可以以任何适合于该应用程序的方式来实现。

//The malloc failed hook function name and prototype.
void vApplicationMallocFailedHook( void );

FreeRTOS 教程指南 学习笔记 第二章 内存管理相关推荐

  1. 《Go语言圣经》学习笔记 第二章 程序结构

    Go语言圣经学习笔记 第二章 程序结构 目录 命名 声明 变量 赋值 类型 包和文件 作用域 注:学习<Go语言圣经>笔记,PDF点击下载,建议看书. Go语言小白学习笔记,几乎是书上的内 ...

  2. PhalAPI学习笔记 ——— 第二章接口服务请求

    PhalAPI学习笔记 --- 第二章接口服务请求 前言 接口服务请求 接口服务请求案例 自定义接口路由 开启匹配路由 配置路由规则 nginx apache 服务请求 结束语 前言 公司业务需要转学 ...

  3. [go学习笔记.第二章] 2.go语言的开发工具以及安装和配置SDK

    一.工具介绍: 1.Visual Studio Code 一个运行于Mac,Windows,和linux上的,默认提供Go语言的语法高亮的IED,可以安装Go语言插件,还可以支持智能提示,编译运行等功 ...

  4. 小吴的《机器学习 周志华》学习笔记 第二章 模型评估与选择

    小吴的<机器学习 周志华>学习笔记 第二章 模型评估与选择 上一周我们介绍了第一章的基础概念,这一次将带来第二章的前三节.后面的2.4 比较检验与2.5 偏差与方差,涉及概率论与数理统计概 ...

  5. 小吴的《机器学习 周志华》学习笔记 第二章 2.4 比较检验、2.5 偏差与方差

    小吴的<机器学习 周志华>学习笔记 第二章 2.4 比较检验. 2.5 偏差与方差 2.4 比较检验 上一周提到了实验的评价方法和性能量度,步骤简单可以看成:先使用某种实验评估方法测得学习 ...

  6. 机器人导论(第四版)学习笔记——第二章

    机器人学导论(第四版)学习笔记--第二章 2. 空间描述和变换 2.1 引言 2.2 描述:位置.姿态与位姿 2.3 映射:从一个坐标系到另一个坐标系的变换 2.4 算子:平行,旋转和变换 2.5 总 ...

  7. Kotlin学习笔记 第二章 类与对象 第十一节 枚举类 第八节密封类

    参考链接 Kotlin官方文档 https://kotlinlang.org/docs/home.html 中文网站 https://www.kotlincn.net/docs/reference/p ...

  8. 操作系统 课堂笔记 第二章 进程管理

    第二章 进程管理 2.1 本章导学 基本内容: (1)进程的基本概念. (2)进程控制. (3)进程间互斥与同步. (4)进程通信. (5)进程调度. (6)进程死锁. (7)线程. 学习重点: (1 ...

  9. 基于全生命周期的主数据管理:MDM详解与实战学习02 第二章 主数据管理的内涵

    第二章 主数据管理的内涵 导读 2.1 主数据的概念 2.1.1 主数据的定义 2.1.2 主数据的特征 2.1.3 主数据的范围 1.描述实体范围 2.应用层次范围 (1)元数据 (2)引用数据 ( ...

  10. Android开发艺术探索学习笔记 第二章IPC

    最近将之前工作做本地的学习笔记上传一下 这里是Android艺术开发探索的前三章内容 文章目录 1. android的多进程模式 2. IPC基础概念介绍 2.1 Serializable 2.2Pa ...

最新文章

  1. mapreduce value 排序_MapReduce知识点一
  2. css3 自定义滚动条样式
  3. 【JQuery】ajax 调用
  4. 提前为小米11让路?小米10高配版官网已缺货
  5. 编写JAVA脚本的JSP页面
  6. ubuntu 串口调试工具_开源软件分享基于WPF的串口调试工具
  7. 学生机房虚拟化之磁盘操作
  8. java电话本怎么做_Java写的电话号码本自动化生成器,程序片段
  9. session 过期怎么办
  10. 98K歌词用计算机按的数字是什么,98k谐音中文歌词
  11. Android聊天软件的开发(一)--预备知识
  12. 谷歌浏览器扩展程序XDM_如何下载和安装扩展程序?
  13. operands could not be broadcast together with shapes
  14. 算法入侵,不如拥抱、打造更好的个性化推荐系统?
  15. 视频二维码在线生成器怎么用?
  16. SQL如何实现Excel自动分列功能?
  17. 记事本不能显示“联通”二字的原因
  18. 电子产品安全认证有哪些?
  19. Python——日历模块
  20. 优秀的产品经理都在读什么?

热门文章

  1. Webstorm2018破解
  2. 均匀分布 卡方分布_高等数理统计—第一章 统计分布基础
  3. IIS 的安装for xp3
  4. php libiconv close_undefined reference to `libiconv_open 无法编译PHP
  5. 如何使用dosbox运行程序——步骤详解
  6. Python爬虫实例1
  7. Java基础语法练习题
  8. 如何安装Stata 15及网状Meta包 最新图文教程一看就会
  9. 开源物联网云平台 Thingsboard入门
  10. 2022华为杯数学建模A题思路代码