完成串口初始化后,接下来将实现一个简单的内存分配器以页大小为单位来进行内存的分配管理。我们将此内存分配器命名为rv内存分配器。
一、获取堆内存大小
堆内存的起始地址、结束地址以及大小是由链接文件决定的。在链接文件中,我们将划分内存空间,将哪一部分作为数据空间、栈空间或堆空间都划分的一清二楚。
1.1 增加只读数据段
在链接脚本中新增只读数据段专用于存放代码段、数据段与堆栈信息等,并声明页大小为1KB;修改链接文件如下所示:
/* Simple Linker Script */
/* 分配2kB内存作为栈空间使用 */
PROVIDE(_stack_size = 2048);
/* 设置页大小为1KB */
PROVIDE(_page_size = 1024);
··········
/* Sections */
SECTIONS
{
··········
/* 开辟只读数据段,将此段存放在flash */
.rodata :
{
. = ALIGN(4);
/* Initialized data */
*(.rodata)
. = ALIGN(4);
} >FLASH AT>FLASH
/* Section for uninitialized data */
.bss :
{
. = ALIGN(4);
/* 获取bss段在ram中的开始位置 */
PROVIDE(_sbss = .);
/* Uninitialized data */
*(.bss)
. = ALIGN(4);
/* 获取bss段在ram中的结束位置 */
PROVIDE(_ebss = .);
} >RAM AT>FLASH
/* 设置堆空间起始地址,且地址必须页大小对齐 */
. = ALIGN(_page_size);
PROVIDE(_heap_start = .);
/* Section for stack */
/*
输出段格式为:SECTION [ADDRESS] [(TYPE)] : [AT(LMA)]
其中SECTION为输出文件的段名,后面的都为可选项,其中ADDRESS为输出段的内存运行地址
*/
/*
输入段格式为,举代码段为例:*(.text)
其中*号表示所有的输入文件,即.o文件;.text表示输入文件的代码段。
*/
.stack ORIGIN(RAM) + LENGTH(RAM) - _stack_size :
{
/* stack */
. = ALIGN(_page_size);
PROVIDE(_heap_end = .);
PROVIDE(_stack_start = .);
. += _stack_size;
PROVIDE(_stack_end = .);
} >RAM
}
1.2 将堆栈信息存入只读数据段
然后在代码中获取堆空间的地址信息与栈空间的地址信息,首先编写一个汇编文件,然后将链接脚本中的定位地址信息赋值给汇编文件中定义声明的全局变量。并将这些全局变量都链接到只读数据段中去:
# 声明下面的数据存放在只读代码段中;通过链接文件可知,这些数据将存放在flash中,并只能读取
.section .rodata
.global DATA_START
DATA_START : .word _vma_data_start
.global DATA_END
DATA_END : .word _vma_data_end
.global BSS_START
BSS_START : .word _sbss
.global BSS_END
BSS_END : .word _ebss
.global HEAP_START
HEAP_START : .word _heap_start
.global HEAP_END
HEAP_END : .word _heap_end
.global STACK_START
STACK_START : .word _stack_start
.global STACK_END
STACK_END : .word _stack_end
1.3 获取并输出堆栈内存地址信息
然后再在c代码中使用汇编文件中定义的只读全局变量来获取到各个段的位置信息,并通过串口打印输出相关段信息:
void page_init(void)
{
printf("DATA: 0x%x -> 0x%x\n\r", DATA_START, DATA_END);
printf("BSS: 0x%x -> 0x%x\n\r", BSS_START, BSS_END);
printf("HEAP: 0x%x -> 0x%x\n\r", HEAP_START, HEAP_END);
printf("STACK: 0x%x -> 0x%x\n\r", STACK_START, STACK_END);
}
void start_kernel(void)
{
// 设置sysclk系统时钟为96MHz
clock_hse_96Mhz();
// 设置usart1,并初始化配置为115200,8,none,1
usart1_init();
//usart1_puts("hello RVOS!\n");
//usart1_puts("\r======== RVOS ========\r\n");
printf("\r======== hello RVOS ========\n\r");
page_init();
led2_ctrl(1);
while (1) {} // stop here!
}
DATA数据段:
因为在代码中我们目前还没有定义过已经初始化的全局变量与静态变量,所以可以看到数据段的大小为0(因为并没有数据在此数据段中);
BSS段:
在实现printf()函数时,我们定义过1000字节未初始化的全局静态数组static char out_buf[1000]; // buffer for _vprintf(),所以由于其未初始化,其应该存放在bss段;因此可以看到bss段的大小为0x3e8,也就是1000字节大小;
HEAP堆:
因为在链接脚本中,bss段之后就紧跟着heap堆空间,且堆空间的起始地址与page页大小(1KB)对齐,所以可以看到堆空间的起始地址是0x2000_0400,而不是0x2000_03e8;
STACK栈:
栈的起始地址通过链接文件就可以算出,它为64KB - 2KB + ram基地址;并且栈的起始地址就是堆的结束地址。
下图就是通过串口输出的数据段、bss段、堆和栈的相关位置信息:
二、rv内存分配器的实现
2.1 获取堆空间的页数量
在得到堆内存的起始地址与结束地址后,我们就可以计算出堆内存的大小以及堆内存可以分配出的页数量。同时我们还需要预留一个页面进行对堆内存空间的页面管理。
需要注意的是,在计算堆空间大小时,堆地址需要先进行对齐操作,如下代码所示:
/*********************************************************************
* @fn page_init
*
* @brief 初始化page,获取堆栈信息.
*
* @param none
*
* @return none
*/
void page_init(void)
{
uint32_t heap_size = 0, i = 0;
page_t *page = 0;
// 重新设置堆起始地址,使其与PAGE_SIZE对齐
heap_start = (HEAP_START + (PAGE_SIZE - 1)) & (~(PAGE_SIZE - 1));
// 重新设置堆结束地址,使其与PAGE_SIZE对齐
heap_end = (HEAP_END) & (~(PAGE_SIZE - 1));
// 计数出堆空间大小
heap_size = heap_end - heap_start;
// 计数出堆空间能划分多少个页(由于此MCU的ram为64KB,所以最多不超过64个页)
page_num = heap_size / PAGE_SIZE;
// 预留1个页面空间,此页面专用于堆空间管理
page_num -= res_page_num;
// 清除页面管理存储区
page = (page_t *)heap_start;
for(i = 0; i < page_num; i++, page++)
{
clear_flag(page);
}
printf("DATA: 0x%x -> 0x%x\n\r", DATA_START, DATA_END);
printf("BSS: 0x%x -> 0x%x\n\r", BSS_START, BSS_END);
printf("HEAP: 0x%x -> 0x%x\n\r", heap_start, heap_end);
printf("STACK: 0x%x -> 0x%x\n\r", STACK_START, STACK_END);
}
在进行堆起始地址对齐时,需要向后对齐;在进行堆结束地址对齐时,需要向前对齐。这样做的原因是因为不能使对齐后的地址超出原本的堆空间范围。
2.2 内存分配器的实现
我们将堆内存分为两部分:
第一部分是预留堆内存,用于存放内存页信息,使用内存页信息结构体来描述一个页内存;
第二部分是实际提供给用户使用的内存页,以页大小为单位提供给用户。
每一个内存页信息结构体唯一对应一个页内存空间,它们是一一对应的关系。如下图所示:
内存页信息结构体如下所示,使用一个flag来表示内存页是否已被分配以及是否是分配空间中的最后一个页:
typedef struct page
{
uint8_t flag; // bit.0,用于表示page是否空闲;bit.1,用于表示是否为分配内存的最后一个页面
} page_t;
页分配器代码的实现如下所示:
/*********************************************************************
* @fn alloc_pages
*
* @brief 分配内存页.
*
* @param num,需要分配的页空间数量
*
* @return 起始页内存地址
*/
void *alloc_pages(uint32_t num)
{
uint32_t i = 0, j = 0;
void *page_addr = 0;
page_t *page = (page_t *)heap_start;
// 查找符合要求的连续页内存空间
for(i = 0; i < page_num - num + 1; i++, page++)
{
// 判断是否有满足要求的连续内存页空间
for(j = 0; j < num; j++)
{
if(!is_free(page))
{
i += j;
page += j;
break;
}
}
// 如果j == num时,找到了满足要求的连续页空间
if(j == num)
{
//将找到符合要求的page转换为地址
page_addr = (void *)page_to_addr(page);
// 将找到的页内存分配出去
for(j = 0; j < num - 1; j++, page++)
{
set_flag(page, PAGE_ISALLOC);
}
// 将此连续空间中的最后一页进行标识
set_flag(page, PAGE_ISALLOC | PAGE_ISLAST);
//page_addr = (void *)(heap_start + res_page_num * PAGE_SIZE + i * PAGE_SIZE);
return page_addr;
}
}
return 0;
}
实现原理:
- 从第一个内存页信息结构体开始遍历,找到第一个空闲的内存页;
- 开始查询第一个空闲内存页后面是否有num个空闲的连续内存页;
- 如果有,则将这几个空闲内存页进行分配,设置对应的内存页信息结构体,并返回第一个空闲内存页的内存地址;
- 如果没有,则加上不合格的内存页个数,再从第1步开始循环遍历;
2.3 内存释放器的实现
释放器的实现则比分配器简单多了,直接将传入的内存地址转换为对应的内存页信息结构体,然后再循环判断对应的内存页是否被分配,如果被分配就释放对应的内存页,直到释放到此段内存的最后一个内存页为止。
相关代码如下所示:
/*********************************************************************
* @fn free_pages
*
* @brief 释放内存页.
*
* @param num,需要释放的内存地址空间
*
* @return none
*/
void free_pages(void *addr)
{
uint32_t num = 0, align_addr = (uint32_t)addr & ~(PAGE_SIZE - 1);
page_t *page = (page_t *)heap_start;
// 判断想要释放的地址空间是否合法
if((!addr) || (align_addr >= heap_end) || (align_addr < (heap_start + PAGE_SIZE)))
return;
// 计算出addr对应的哪一个page
num = (align_addr - heap_start - PAGE_SIZE) / PAGE_SIZE;
page += num;
// 如果page已经被分配,则释放它
while(!is_free(page))
{
if(is_last(page))
{
clear_flag(page);
break;
}else
{
clear_flag(page);
page++;
}
}
}
三、分配器验证
通过编写测试代码来验证分配器是否工作正常,代码如下所示:
void page_test()
{
void *p = alloc_pages(2);
printf("p = 0x%x\n\r", p);
//page_free(p);
void *p2 = alloc_pages(8);
printf("p2 = 0x%x\n\r", p2);
free_pages(p2);
void *p3 = alloc_pages(4);
printf("p3 = 0x%x\n\r", p3);
}
通过上述代码可知,如果分配了2个page内存,但是不释放,则p2的地址应该在p地址的后面2个内存页地址上;并且当p2被释放后,再进行p3的分配时,p3的地址就应该是原来p2的地址。
测试结果如下所示:
由上图可知,p地址为0x20000800,因为预留了1个page内存给页信息管理使用,所以p的地址应该是堆内存地址后偏移1个page内存页,也就是0x20000800 = 0x20000400 + 0x400;
p2则等于0x20000800 + 0x400 * 2 = 0x20001000;
p3则是因为p2被释放后再分配的,所以地址应该和之前的p2一样为0x20001000。