从零手写操作系统之RVOS内存管理模块简单实现-02
- 内存管理分类
- 内存映射表(Memory Map)
- Linker Script 链接脚本
- 语法
- 基于符号定义获取程序运行时内存分布
- 基于 Page 实现动态内存分配
- 代码讲解
- 调试
- 扩展
本系列参考: 学习开发一个RISC-V上的操作系统 - 汪辰 - 2021春 整理而来,主要作为xv6操作系统学习的一个前置基础。
RVOS是本课程基于RISC-V搭建的简易操作系统名称。
课程代码和环境搭建教程参考github仓库: https://github.com/plctlab/riscv-operating-system-mooc/blob/main/howto-run-with-ubuntu1804_zh.md
前置知识:
- RVOS环境搭建-01
内存管理分类
- 自动管理内存 - 栈 (Stack)
- 静态内存 - 全局变量/静态变量
- 动态管理内存 - 堆(heap)
内存映射表(Memory Map)
可执行文件中各个段在虚拟内存中的地址,在链接阶段确定,然后程序装载阶段,就按照各个段在链接阶段设置好的虚拟地址进行装载。
此部分内容详细可参考<<程序员的自我修养—装载,链接和库>>一书
Linker Script 链接脚本
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所须要的文件。
一般链接器有如下三种方法:
- 使用命令行来给链接器指定参数,ld的-o、-e参数就属于这类。
- 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。方法也比较常见,只是我们平时很少关注,比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段以用来传递参数。
- 使用链接控制脚本,使用链接控制脚本方法就是本节要介绍的,也是最为灵活、最为强大的链接控制方法。
由于各个链接器平台的链接控制过程各不相同,我们只能侧重一个平台来介绍。ld链接器的链接脚本功能非常强大,我们接下来以ld作为主要介绍对象。
ld 在用户没有指定链接脚本的时候会使用默认链接脚本。我们可以使用下面的命令行来查看ld默认的链接脚本:
ld -verbose
默认的ld链接脚本存放在/usr/lib/ldscripts/下,不同的机器平台、输出文件格式都有相应的链接脚本。
- 比如Intel IA32下的普通可执行ELF文件链接脚本文件为elf_i386.x;
- IA32下共享库的链接脚本文件为elf_i386.xs等。
ld会根据命令行要求使用相应的链接脚本文件来控制链接过程,当我们使用ld来链接生成一个可执行文件的时候,它就会使用elf_i386.x作为链接控制脚本;
当我们使用ld来生成一个共享目标文件的时候,它就会使用elf_i386.xs作为链接控制脚本。
当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本。比如可以使用-T参数:
ld –T link.script
什么情况下需要使用链接脚本?
绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接。这在一般情况下是没有问题的,但对于一些特殊要求的程序,比如:
- 操作系统内核、BIOS(Basic Input Output System)或一些在没有操作系统的情况下运行的程序(如引导程序Boot Loader或者嵌入式系统的程序,或者有一些脱离操作系统的硬盘分区软件PQMagic等),以及另外的一些须要特殊的链接过程的程序,如一些内核驱动程序等,它们往往受限于一些特殊的条件,如须要指定输出文件的各个段虚拟地址、段的名称、段存放的顺序等,因为这些特殊的环境,特别是某些硬件条件的限制,往往对程序的各个段的地址有着特殊的要求。
在编译普通的应用程序时,可以使用默认的链接器脚本,但是对于内核程序来说,它本身也是一个.elf文件,这个.elf文件该怎么组织,各个段放到内存中什么地方,这个由于和底层硬件强相关,所以需要我们自己编写相关的链接器脚本:
- 在之前的环境准备小节中,我们makefile文件中编写的ld链接命令中只通过-Ttext=0x80000000命令指明了代码段的在内存中的起始地址
os.elf: ${OBJS}
${CC} ${CFLAGS} -Ttext=0x80000000 -o os.elf $^
${OBJCOPY} -O binary os.elf os.bin
- 但是在本节中我们将会使用链接器脚本文件os.ld来描述整个链接过程
语法
.代表当前所处的内存地址
链接器会把定义符号放入符号表中,符号表中的符号是我们可以在程序中访问到的。
链接器语法详细内容可以参考GUN文档,或者程序员自我修养–装载,链接与库的4.5节。
基于符号定义获取程序运行时内存分布
参考课程02节的os.ld链接器脚本文件
如何在代码中获取在链接器脚本中定义的相关符号值呢?
参考课程02节mem.s文件
注意:
- 在C代码中直接获取链接器脚本中定义的符号是有一定的限制的。C语言是一种静态编译语言,在编译时会将源代码转换为机器码,并生成可执行文件。链接器脚本用于指导链接器如何组织可执行文件的各个部分,包括代码段、数据段、符号表等。
- 在C代码中,无法直接引用链接器脚本中定义的符号的值,因为C编译器并不了解链接器脚本的细节。C编译器只能根据给定的C代码进行编译,将代码转换为机器码,并生成符号表。符号表中包含了在C代码中定义的全局变量、函数等符号及其对应的地址。
- 要在C代码中获取链接器脚本中定义的符号的值,一种常见的做法是通过在C代码中声明外部变量,并使用链接器脚本中定义的符号来初始化这些外部变量。这样,链接器在链接阶段会将外部变量与链接器脚本中定义的符号关联起来,并将符号的值赋给外部变量。然后,C代码就可以通过访问这些外部变量来获取链接器脚本中定义的符号的值。
- 总之,C代码无法直接获取链接器脚本中定义的符号的值,但可以通过声明外部变量并与符号关联来间接获取。这种间接的方式使得C代码能够与链接器脚本进行交互,并共享符号的值。
在c程序中获取链接器脚本中定义的符号,有两种方式:
- 链接器脚本中使用PROVIDER定义符号,并在c语言中通过extern声明外部变量进行绑定
SECTIONS
{
.text :
{
*(.text)
}
.data :
{
*(.data)
}
.bss :
{
*(.bss)
}
/* 定义一个名为 _custom_symbol 的符号,并将其赋值为 42 */
PROVIDE(_custom_symbol = 42);
}
#include <stdio.h>
extern int _custom_symbol;
int main() {
printf("The value of _custom_symbol is: %d\n", _custom_symbol);
return 0;
}
- 通过汇编定义一个全局变量绑定到链接器脚本中的符号,c程序中定义extern变量和汇编文件中定义的全局变量相绑定
SECTIONS
{
/* ...其他部分... */
/* 定义一个名为 _asm_var 的符号,并将其赋值为 100 */
PROVIDE(_asm_var = 100);
}
.section .data
.global asm_var
asm_var:
.word _asm_var
#include <stdio.h>
extern int asm_var;
int main() {
printf("The value of asm_var is: %d\n", asm_var);
return 0;
}
将汇编文件作为绑定的中间转换层有以下几个好处:
-
灵活性:使用汇编文件可以更加灵活地控制符号的定义和绑定。你可以直接在汇编文件中定义符号,并将其与链接器脚本中的符号绑定,而不依赖于C语言的语法和限制。这使得你可以更精确地控制符号的位置、大小和属性。
-
细粒度控制:汇编语言提供了更细粒度的控制能力。你可以直接使用汇编指令来定义变量、设置符号的初始值,以及指定变量的大小和对齐方式。这使得你可以更好地适应特定的需求,如嵌入式系统的内存布局和对齐要求。
-
可读性:使用汇编文件作为绑定的中间转换层可以提高代码的可读性和可维护性。通过将符号的定义和绑定从链接器脚本和C代码中分离出来,可以更清晰地表达代码的意图,并使得代码更易于理解和修改。
-
跨平台支持:使用汇编文件作为中间转换层可以更好地支持跨平台开发。汇编语言是与硬件平台相关的,通过直接编写汇编代码,可以更好地适应不同的硬件架构和操作系统环境。这使得你的代码更具可移植性和可扩展性。
总之,通过将汇编文件作为绑定的中间转换层,可以提供更大的灵活性、细粒度的控制能力,提高代码的可读性和可维护性,以及更好地支持跨平台开发。这对于一些特定的需求和项目来说是非常有益的。
基于 Page 实现动态内存分配
数据结构设计:
此处采用数组方式来管理内存。
代码讲解
此部分代码基于课程02小节的page.c文件展开讲解
- 获取链接器脚本中定义的符号,这些变量在链接器链接过程中计算得出
/*
* Following global vars are defined in mem.S
*/
extern uint32_t TEXT_START;
extern uint32_t TEXT_END;
extern uint32_t DATA_START;
extern uint32_t DATA_END;
extern uint32_t RODATA_START;
extern uint32_t RODATA_END;
extern uint32_t BSS_START;
extern uint32_t BSS_END;
extern uint32_t HEAP_START;
extern uint32_t HEAP_SIZE;
- 堆区的范围和最大能够分配的页数量
/*
* _alloc_start points to the actual start address of heap pool
* _alloc_end points to the actual end address of heap pool
* _num_pages holds the actual max number of pages we can allocate.
*/
static uint32_t _alloc_start = 0;
static uint32_t _alloc_end = 0;
static uint32_t _num_pages = 0;
对于数据结构的选择,我们这里选取数组结构:
由于物理内存被划分为一块块固定大小的内存,所以我们可以通过附加索引信息记录某个页是否已经分配出去,并且索引记录的下标和对应的物理页下标进行映射,映射公式为:
- 物理页地址=alloc_start + 索引下标 * PAGE_SIZE
并且我们使用Page结构体来作为索引记录,用于表示某个物理页是否已经分配出去,并且由于用户通常一次性申请好几个连续物理页,释放的时候传入分配内存起始地址,我们需要回收先前分配给该用户的多个连续物理页,因此还需要一个记号标记当前物理页是否为某次连续分配中的最后一个物理页:
/*
* Page Descriptor
* flags:
* - bit 0: flag if this page is taken(allocated)
* - bit 1: flag if this page is the last page of the memory block allocated
*/
struct Page {
uint8_t flags;
};
- 利用flags标记的第0位表示物理页是否分配
- 利用flags标记的第1位表示是否为某次分配中的最后一个物理页
内存管理模块初始化:
void page_init()
{
/*
* We reserved 8 Page (8 x 4096) to hold the Page structures.
* It should be enough to manage at most 128 MB (8 x 4096 x 4096)
*/
//_num_pages是实例用户可用的物理页数量
_num_pages = (HEAP_SIZE / PAGE_SIZE) - 8;
printf("HEAP_START = %x, HEAP_SIZE = %x, num of pages = %d\n", HEAP_START, HEAP_SIZE, _num_pages);
struct Page *page = (struct Page *)HEAP_START;
//初始化索引记录---每条索引记录对应一个用户可用物理页面
for (int i = 0; i < _num_pages; i++) {
_clear(page);
page++;
}
//物理页对齐4KB---将给定的地址按页面边界(4KB)对齐,确保地址位于所在页面的起始位置
_alloc_start = _align_page(HEAP_START + 8 * PAGE_SIZE);
//堆内存最大范围
_alloc_end = _alloc_start + (PAGE_SIZE * _num_pages);
printf("TEXT: 0x%x -> 0x%x\n", TEXT_START, TEXT_END);
printf("RODATA: 0x%x -> 0x%x\n", RODATA_START, RODATA_END);
printf("DATA: 0x%x -> 0x%x\n", DATA_START, DATA_END);
printf("BSS: 0x%x -> 0x%x\n", BSS_START, BSS_END);
printf("HEAP: 0x%x -> 0x%x\n", _alloc_start, _alloc_end);
}
//初始化过程就是将标志位清空
static inline void _clear(struct Page *page){
page->flags = 0;
}
- 保留堆内存前面8个物理页用于存放索引记录信息
- 初始化相关索引信息
- 堆内存分配起始地址页面对齐
注意: 此处出现的printf函数是在02小节中编写的printf.c文件中出现的,而非c语言提供的库函数,最终输出底层还是借助的上一节中编写uart.c代码,借助串口输出到连接设备的屏幕上。
连续分配多个物理页面:
/*
* Allocate a memory block which is composed of contiguous physical pages
* - npages: the number of PAGE_SIZE pages to allocate
*/
void *page_alloc(int npages)
{
/* Note we are searching the page descriptor bitmaps. */
int found = 0;
//遍历索引数组
struct Page *page_i = (struct Page *)HEAP_START;
//_num_pages表示堆内存页面总数(用户可用堆内存--上面page_init函数中初始化过了)
for (int i = 0; i <= (_num_pages - npages); i++) {
//判断当前页面是否空闲
if (_is_free(page_i)) {
found = 1;
/*
* meet a free page, continue to check if following
* (npages - 1) pages are also unallocated.
*/
// 检查接下来的npages-1个物理页面是否同样空闲
struct Page *page_j = page_i + 1;
for (int j = i + 1; j < (i + npages); j++) {
//只要有一个物理页面不空闲,说明这块连续内存空间大小不满足我们的要求
if (!_is_free(page_j)) {
//重新设置found=0
found = 0;
break;
}
page_j++;
}
/*
* get a memory block which is good enough for us,
* take housekeeping, then return the actual start
* address of the first page of this memory block
*/
//找到了满足要求的连续内存空间
if (found) {
//设置好相关物理页面对应的索引记录标志位为占用状态
struct Page *page_k = page_i;
for (int k = i; k < (i + npages); k++) {
_set_flag(page_k, PAGE_TAKEN);
page_k++;
}
//设置连续分配的页面中最后一个页面的flags标志位第1位为1,表示为当前分配中的最后一个物理页面
page_k--;
_set_flag(page_k, PAGE_LAST);
//返回分配内存的起始地址
return (void *)(_alloc_start + i * PAGE_SIZE);
}
}
page_i++;
}
return NULL;
}
判断页面是否空闲和设置索引记录标记的函数如下:
static inline int _is_free(struct Page *page)
{
if (page->flags & PAGE_TAKEN) {
return 0;
} else {
return 1;
}
}
static inline void _set_flag(struct Page *page, uint8_t flags)
{
page->flags |= flags;
}
释放内存:
/*
* Free the memory block
* - p: start address of the memory block
*/
void page_free(void *p)
{
/*
* Assert (TBD) if p is invalid
*/
//内存地址不合法或者超出的堆内存最大限制,直接返回
if (!p || (uint32_t)p >= _alloc_end) {
return;
}
/* get the first page descriptor of this memory block */
struct Page *page = (struct Page *)HEAP_START;
//定位对应的索引记录下标
page += ((uint32_t)p - _alloc_start)/ PAGE_SIZE;
/* loop and clear all the page descriptors of the memory block */
//将对应page被占用的标记清空,同时如果是连续分配的最后一个页面,清空其PAGE_LAST标记
while (!_is_free(page)) {
if (_is_last(page)) {
_clear(page);
break;
} else {
_clear(page);
page++;;
}
}
}
清空PAGE_LAST标志的函数如下:
static inline int _is_last(struct Page *page)
{
if (page->flags & PAGE_LAST) {
return 1;
} else {
return 0;
}
}
调试
#include "os.h"
/*
* Following functions SHOULD be called ONLY ONE time here,
* so just declared here ONCE and NOT included in file os.h.
*/
extern void uart_init(void);
extern void page_init(void);
void start_kernel(void)
{
uart_init();
uart_puts("Hello, RVOS!\n");
page_init();
//页面分配测试
page_test();
while (1) {}; // stop here!
}
void page_test()
{
void *p = page_alloc(2);
printf("p = 0x%x\n", p);
//page_free(p);
void *p2 = page_alloc(7);
printf("p2 = 0x%x\n", p2);
page_free(p2);
void *p3 = page_alloc(4);
printf("p3 = 0x%x\n", p3);
}
输出:
扩展
可尝试基于课程02节已有的Page.c扩展出类似C语言中提供的malloc和free函数。