浅谈嵌入式系统中的内存调度及优化策略

1,274 阅读21分钟

浅谈嵌入式系统中的内存调度及优化策略

LLVM 简述

LLVM是一个模块化和可重用的编译器和工具链技术,广泛应用于多种编程语言的编译。LLVM 具有高度模块化的设计,包括前端、优化器和后端三个主要部分。本文主要对优化、后端部分展开介绍。

LLVM架构

前端(Frontend)

LLVM 本身不包含前端,但支持多种前端,包括 Clang(用于 C/C++/Objective-C)、Rust、Swift 等。前端的主要功能是解析源代码并生成 LLVM IR。

Clang 前端

Clang 是 LLVM 官方的 C/C++/Objective-C 前端,主要流程包括:

  1. 词法分析(Lexical Analysis) :将源代码分解成标记(tokens)。
  2. 语法分析(Syntax Analysis) :生成抽象语法树(AST)。
  3. 语义分析(Semantic Analysis) :检查 AST 的语义正确性。
  4. 生成 LLVM IR:将 AST 转换为 LLVM IR。

中间表示(IR)

LLVM IR 是一种强类型、静态单赋值形式(Static Single Assignment, SSA)的中间表示。它具有三种表示形式:

  1. LLVM 汇编语言(LLVM Assembly Language) :人类可读的文本格式。
  2. LLVM 位码(LLVM Bitcode) :紧凑的二进制格式,用于高效存储和传输。
  3. 内存中的 IR:LLVM 编译器内部使用的数据结构。

LLVM IR 的设计使得它能够高效地进行各种优化和转换,支持跨语言和跨平台的编译。

优化器(Optimizer)

LLVM 优化器通过一系列优化 passes(遍)对 LLVM IR 进行优化。每个 pass 执行一种特定的优化。优化 passes 可以分为几类:

  1. 局部优化(Local Optimizations) :优化单个基本块内的代码,例如常量传播、死代码消除等。
  2. 全局优化(Global Optimizations) :跨基本块和函数边界进行优化,例如全局值编号、全局常量传播等。
  3. 循环优化(Loop Optimizations) :专门针对循环的优化,例如循环展开、循环分割等。
  4. 内联展开(Inlining) :将小函数的代码直接插入到调用它们的地方,减少函数调用开销。
  5. 剖析驱动优化(Profile-Guided Optimization, PGO) :基于运行时性能数据进行优化。

常见的优化技术

1. 常量传播(Constant Propagation)

常量传播将程序中的常量值直接替换到使用这些常量的地方,减少不必要的计算和存储。例如:

%1 = add i32 2, 3
%2 = mul i32 %1, 4

经过常量传播优化后:

%2 = mul i32 5, 4

2. 死代码消除(Dead Code Elimination, DCE)

死代码消除移除那些不会影响程序结果的代码,减少代码尺寸和执行时间。例如:

%1 = add i32 2, 3
%2 = add i32 %1, 4
ret i32 0

经过死代码消除后:

ret i32 0

3. 全局值编号(Global Value Numbering, GVN)

GVN 通过消除冗余计算来优化代码。例如:

%1 = add i32 %a, %b
%2 = add i32 %a, %b

经过 GVN 优化后:

%1 = add i32 %a, %b
%2 = %1

4. 循环优化(Loop Optimizations)

循环优化包括多种技术,如循环展开(Loop Unrolling)、循环分割(Loop Splitting)和循环向量化(Loop Vectorization)。这些优化技术旨在提高循环的执行效率。

循环展开(Loop Unrolling)

循环展开将循环体复制多次,以减少循环控制的开销。例如:

for (int i = 0; i < 4; i++) {
    a[i] = b[i] + c[i];
}

展开后:

a[0] = b[0] + c[0];
a[1] = b[1] + c[1];
a[2] = b[2] + c[2];
a[3] = b[3] + c[3];
循环向量化(Loop Vectorization)

循环向量化利用 SIMD 指令将循环中的多个迭代合并为单个并行操作。例如:

for (int i = 0; i < 4; i++) {
    a[i] = b[i] + c[i];
}

向量化后:

%vec.b = load <4 x i32>, <4 x i32>* %b
%vec.c = load <4 x i32>, <4 x i32>* %c
%vec.a = add <4 x i32> %vec.b, %vec.c
store <4 x i32> %vec.a, <4 x i32>* %a

具体的优化 passes

常量传播(Constant Propagation Pass)

常量传播 pass 遍历基本块内的指令,将可以确定的常量值直接传播到使用这些值的指令中。

死代码消除(Dead Code Elimination Pass)

死代码消除 pass 遍历程序中的指令,删除那些不会影响程序输出的指令。

循环优化(Loop Passes)

  • Loop Unroll Pass:将循环展开,减少循环控制的开销。
  • Loop Vectorize Pass:将循环向量化,利用 SIMD 指令提高并行度。
  • Loop Invariant Code Motion Pass:将循环中不变的代码移出循环,减少循环内部的计算。

全局值编号(Global Value Numbering Pass)

GVN pass 通过分析和合并冗余计算来优化程序。

内联展开(Inlining Pass)

内联展开 pass 将小函数的代码直接插入到调用点,减少函数调用开销,提高执行效率。

指令组合(Instruction Combining Pass)

指令组合 pass 通过合并和简化指令,减少指令数量,提高执行效率。例如,将多个简单的加法指令合并为一个复合指令。

优化流程示例

以下是一个优化流程的示例:

; LLVM IR
define i32 @example(i32 %a, i32 %b) {
entry:
  %sum = add i32 %a, %b
  %prod = mul i32 %sum, 2
  ret i32 %prod
}

; 优化后的 LLVM IR
define i32 @example(i32 %a, i32 %b) {
entry:
  %sum = add i32 %a, %b
  %prod = shl i32 %sum, 1 ; 
  ret i32 %prod
}

后端(Backend)

LLVM 后端是将中间表示(IR)转换为目标机器代码的关键组件。后端处理包括指令选择、指令调度、寄存器分配和目标代码生成等过程。下面将详细介绍 LLVM 后端的架构、各个步骤和相关技术。

LLVM 后端的主要任务是将优化后的 LLVM IR 转换为特定目标平台的机器代码。后端架构主要包括以下几个组件:

  1. 指令选择(Instruction Selection) :将 LLVM IR 指令映射到目标机器的指令集。
  2. 指令调度(Instruction Scheduling) :优化指令执行顺序,以提高指令级并行度(ILP)。
  3. 寄存器分配(Register Allocation) :将虚拟寄存器映射到物理寄存器,处理寄存器溢出问题。
  4. 生成目标代码(Code Emission) :将指令转换为目标机器的二进制代码。
  5. 目标描述文件(Target Description Files) :描述目标平台的架构和指令集。

具体步骤详解

1. 指令选择(Instruction Selection)

指令选择是将 LLVM IR 指令转换为目标机器指令的过程。LLVM 使用“指令选择器”(Instruction Selector)进行这一转换。常见的指令选择算法有:

  • TableGen:LLVM 使用 TableGen 工具生成指令选择器,TableGen 文件描述了目标指令集和模式匹配规则。
  • DAG(Directed Acyclic Graph)模式匹配:指令选择器将 LLVM IR 表示为 DAG,并通过模式匹配将其转换为目标指令。

例如,LLVM IR 中的加法指令:

%sum = add i32 %a, %b

在 x86 平台上可能会转换为以下汇编指令:

add eax, ebx

2. 指令调度(Instruction Scheduling)

指令调度优化指令的执行顺序,以最大化处理器的指令级并行度(ILP)。调度器需要考虑数据依赖性和处理器的流水线特性。LLVM 中的指令调度器主要有两种:

  • 局部调度(Local Scheduling) :在基本块内进行调度。
  • 全局调度(Global Scheduling) :跨越基本块进行调度。

指令调度的目标是减少流水线停顿、提高缓存命中率和利用处理器的执行单元。

3. 寄存器分配(Register Allocation)

寄存器分配是将虚拟寄存器映射到物理寄存器的过程。LLVM 使用多种算法进行寄存器分配,常见的有:

  • 线性扫描算法(Linear Scan Allocation) :简单高效,适用于实时编译(JIT)。
  • 图着色算法(Graph Coloring Allocation) :复杂但产生的代码质量较高,适用于静态编译。

寄存器分配过程中还需要处理寄存器溢出(Spill),即当物理寄存器不足时,将一些值暂时存放到内存中。

4. 生成目标代码(Code Emission)

生成目标代码是将调度和分配好的指令转换为目标机器的二进制代码。LLVM 使用目标代码生成器(Code Emitter)进行这一转换。目标代码生成器读取目标描述文件,生成相应的二进制指令。

例如,x86 平台上的目标代码生成:

mov eax, [a]
add eax, [b]
mov [c], eax

转换为二进制代码:

0x8B 0x05 0x00 0x00 0x00 0x00
0x03 0x05 0x00 0x00 0x00 0x00
0x89 0x05 0x00 0x00 0x00 0x00

5. 目标描述文件(Target Description Files)

目标描述文件使用 TableGen 描述目标平台的架构和指令集。这些文件定义了寄存器、指令、调用约定等信息。TableGen 工具生成相应的指令选择器、调度器和代码生成器。

后端优化技术

1. 指令组合(Instruction Combining)

指令组合是通过合并相邻的指令,减少指令数量,提高代码执行效率的一种优化技术。它通过识别可以合并的指令序列,并生成更紧凑、更高效的指令。

示例

初始 LLVM IR:

%1 = add i32 %a, 1
%2 = add i32 %1, 2

经过指令组合优化后:

%2 = add i32 %a, 3

在这个示例中,原本的两条加法指令被合并为一条单独的加法指令,减少了指令数量。

2. 延迟槽填充(Delay Slot Filling)

延迟槽填充是一种在存在延迟槽的处理器架构中使用的优化技术。延迟槽是在某些指令(如分支指令)之后执行的一条指令,用于隐藏分支延迟。填充延迟槽可以减少分支指令的开销,提高指令执行效率。

示例

假设目标平台有一个分支延迟槽,原始代码如下:

beq $t0, $t1, label  ; 分支指令
nop                   ; 延迟槽

经过延迟槽填充优化后:

beq $t0, $t1, label  ; 分支指令
add $t2, $t3, $t4    ; 延迟槽填充指令

在这个示例中,原本无操作的延迟槽被一条无关的加法指令填充,隐藏了分支延迟。

3. 冗余加载和存储消除(Load/Store Elimination)

冗余加载和存储消除通过移除不必要的加载和存储操作,减少内存访问,提高指令执行效率。这种优化技术依赖于数据流分析,以确定哪些加载和存储操作是冗余的。

示例

初始 LLVM IR:

%1 = load i32, i32* %ptr
%2 = load i32, i32* %ptr

经过冗余加载消除优化后:

%1 = load i32, i32* %ptr
%2 = %1

在这个示例中,原本两次相同的加载操作被优化为一次加载操作,减少了内存访问。

4. 寄存器重命名(Register Renaming)

寄存器重命名是通过重命名寄存器,减少寄存器冲突,提高指令并行度的一种优化技术。寄存器重命名可以减少流水线中的数据相关,增加指令级并行度(ILP)。

运行时库(Runtime Libraries)

LLVM 运行时库(Runtime Libraries)在支持程序的执行方面起着至关重要的作用。运行时库为应用程序提供了一组标准的函数和服务,用于管理内存、处理输入/输出、执行数学运算等。支持 C 语言的运行时库通常包括标准 C 库(libc)、数学库(libm)、线程库(libpthread)等。下面将详细介绍这些运行时库的结构和功能。

标准 C 库(libc)

标准 C 库是 C 语言运行时库的核心部分,提供了大量基础设施函数。主要包括以下几个部分:

  1. 输入/输出(I/O) :文件和标准流的处理函数。
  2. 字符串操作:处理 C 字符串的函数。
  3. 内存管理:动态内存分配和管理函数。
  4. 数学函数:基本数学运算函数。
  5. 时间和日期:处理时间和日期的函数。
  6. 实用函数:诸如排序、搜索等实用函数。

1. 输入/输出(I/O)

I/O 函数负责处理文件和标准输入/输出流。

2. 字符串操作

字符串操作函数用于处理 C 字符串。

3. 内存管理

内存管理函数提供动态内存分配和释放功能。常见的内存管理函数包括:

  • malloc:分配动态内存。
  • free:释放动态内存。
  • calloc:分配并清零内存。
  • realloc:调整已分配内存的大小。

4. 数学函数

数学函数提供了基本的数学运算功能。

5. 线程库(libpthread)

线程库提供了多线程编程的支持,允许程序创建和管理多个并发执行的线程。常见的线程库函数包括:

  • pthread_create:创建新线程。
  • pthread_join:等待线程结束。
  • pthread_mutex_lock:锁定互斥量。
  • pthread_mutex_unlock:解锁互斥量。

嵌入式系统中的内存管理

在 DSP 芯片上,内存管理包括堆空间、栈空间、代码段和数据段,每个部分都有特定的功能和管理方式。堆空间用于动态内存分配,栈空间用于函数调用的临时数据存储,代码段存储程序的可执行指令,数据段存储全局和静态变量。通过合理管理这些内存区域,可以提高程序的执行效率和稳定性。

  1. :动态分配内存,由mallocfree等函数管理。
  2. :存放函数调用时的局部变量和返回地址,由编译器自动管理。
  3. 代码段:存放程序的机器指令。
  4. 数据段:存放静态和全局变量。

堆空间(Heap Space)

功能

堆空间用于动态内存分配,由程序在运行时根据需要分配和释放内存。堆空间通常用于存储大小和生命周期无法在编译时确定的数据结构,如链表、树、动态数组等。

管理方式

堆空间由动态内存管理器(如 mallocfree)管理。这些管理器通过维护空闲内存块列表或使用高级算法(如伙伴系统、边界标记系统)来高效分配和回收内存。

特点

  • 动态性:内存块在运行时按需分配和释放。
  • 灵活性:适合大小和数量动态变化的数据结构。
  • 碎片化:频繁的分配和释放会导致内存碎片化,降低内存利用率。

malloc函数的原理

malloc(memory allocation)函数用于在堆(heap)中动态分配一块指定大小的内存,并返回指向这块内存的指针。它的主要原理包括以下几个步骤:

  1. 管理空闲内存块:堆中的空闲内存块由一个空闲链表或类似的数据结构进行管理。这些内存块可以通过不同的算法进行分配。
  2. 分配内存:当程序请求内存时,malloc会遍历空闲链表,寻找合适大小的空闲块,并将其分配给程序。如果找到的空闲块比请求的内存大,则可能需要将剩余部分拆分成新的空闲块。
  3. 更新链表:分配内存后,malloc需要更新空闲链表,移除或调整刚刚分配出去的内存块。

我们将手动实现两个版本的malloc函数,一个使用首次适配算法,另一个使用最佳适配算法。为了简单起见,我们假设堆的大小是固定的。

数据结构定义

#define HEAP_SIZE 1024

typedef struct Block {
    size_t size;
    struct Block* next;
    int free;
} Block;

static char heap[HEAP_SIZE];
static Block* free_list = (Block*)heap;

初始化堆

void init_heap() {
    free_list->size = HEAP_SIZE - sizeof(Block);
    free_list->next = NULL;
    free_list->free = 1;
}

首次适配算法

首次适配算法从头开始查找第一个足够大的空闲块。

void* malloc_first_fit(size_t size) {
    Block* current = free_list;
    while (current != NULL) {
        if (current->free && current->size >= size) {
            current->free = 0;
            return (void*)((char*)current + sizeof(Block));
        }
        current = current->next;
    }
    return NULL;  // 没有找到合适的块
}

最佳适配算法

最佳适配算法遍历整个空闲链表,寻找最小的足够大的空闲块。

void* malloc_best_fit(size_t size) {
    Block* current = free_list;
    Block* best_fit = NULL;

    while (current != NULL) {
        if (current->free && current->size >= size) {
            if (best_fit == NULL || current->size < best_fit->size) {
                best_fit = current;
            }
        }
        current = current->next;
    }

    if (best_fit != NULL) {
        best_fit->free = 0;
        return (void*)((char*)best_fit + sizeof(Block));
    }
    return NULL;  // 没有找到合适的块
}

内存释放函数

为了完整性,我们还需要一个释放内存的函数:

void free(void* ptr) {
    if (ptr == NULL) return;
    
    Block* block = (Block*)((char*)ptr - sizeof(Block));
    block->free = 1;

    // 简单的合并相邻的空闲块
    Block* current = free_list;
    while (current != NULL) {
        if (current->free && current->next && current->next->free) {
            current->size += sizeof(Block) + current->next->size;
            current->next = current->next->next;
        }
        current = current->next;
    }
}

Demo:

int main() {
    init_heap();
    
    void* ptr1 = malloc_first_fit(100);
    void* ptr2 = malloc_best_fit(200);
    
    free(ptr1);
    free(ptr2);
    
    return 0;
}

这只是一个简化的实现,实际的malloc函数还需要考虑更多的细节,例如内存对齐、边界检查、多线程安全等。在嵌入式系统中,内存管理还可能需要根据具体的硬件特性进行优化。

内存池(Memory Pool)管理

内存池是一种内存管理技术,特别适用于嵌入式系统中,因为它可以提供确定性和高效的内存分配和释放。内存池预先分配一块大的内存区域,然后将其分割成多个固定大小的小块,供程序在需要时使用。这种方式避免了内存碎片化的问题,并且可以在常数时间内完成内存分配和释放。

内存池的优势

  1. 减少碎片化:由于内存块大小是固定的,避免了传统堆内存分配中由于大小不一的分配导致的碎片化问题。
  2. 确定性:内存池的分配和释放时间是固定的,适用于实时系统。
  3. 简单:内存池管理的实现相对简单,不需要复杂的空闲链表管理。

内存池的实现

我们将实现一个简单的内存池管理系统,包括初始化内存池、分配内存和释放内存的功能。

数据结构定义

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define POOL_SIZE 1024  // 内存池总大小
#define BLOCK_SIZE 32   // 每个内存块的大小

typedef struct Block {
    struct Block* next;
} Block;

typedef struct {
    Block* free_list;
    uint8_t pool[POOL_SIZE];
} MemoryPool;

MemoryPool mem_pool;

初始化内存池

内存池初始化时,将整个内存区域按块大小分割,并将所有块链接成一个空闲链表。

void init_memory_pool() {
    mem_pool.free_list = (Block*)mem_pool.pool;
    Block* current = mem_pool.free_list;
    for (size_t i = BLOCK_SIZE; i < POOL_SIZE; i += BLOCK_SIZE) {
        current->next = (Block*)(mem_pool.pool + i);
        current = current->next;
    }
    current->next = NULL;  // 最后一块的next指向NULL
}

分配内存

从内存池中分配内存时,只需将空闲链表的头部块返回,并更新空闲链表头指针。

void* pool_malloc() {
    if (mem_pool.free_list == NULL) {
        return NULL;  // 内存池已满
    }

    Block* block = mem_pool.free_list;
    mem_pool.free_list = block->next;
    return (void*)block;
}

释放内存

释放内存时,将内存块重新插入到空闲链表的头部。

void pool_free(void* ptr) {
    if (ptr == NULL) {
        return;
    }

    Block* block = (Block*)ptr;
    block->next = mem_pool.free_list;
    mem_pool.free_list = block;
}

Demo

int main() {
    init_memory_pool();

    void* ptr1 = pool_malloc();
    void* ptr2 = pool_malloc();
    printf("Allocated: %p, %p\n", ptr1, ptr2);

    pool_free(ptr1);
    pool_free(ptr2);

    void* ptr3 = pool_malloc();
    printf("Reallocated: %p\n", ptr3);

    return 0;
}

在上述示例中,init_memory_pool函数初始化内存池,pool_mallocpool_free函数分别用于分配和释放内存块。内存池的管理方式适用于固定大小的内存分配需求,例如网络包处理、对象池等场景。

栈空间(Stack Space)

功能

栈空间用于存储函数调用时的临时数据,包括局部变量、函数参数、返回地址和寄存器保存值。栈空间通常由编译器自动管理。

管理方式

栈空间采用先进先出(LIFO)管理方式。每次函数调用时,栈指针(Stack Pointer)向下移动分配内存,函数返回时,栈指针向上移动释放内存。

特点

  • 自动管理:由编译器和硬件自动管理,不需要程序员显式分配和释放。
  • 局部性:适用于生命周期短、作用域小的数据,如局部变量。
  • 空间限制:栈空间通常较小,容易出现栈溢出(Stack Overflow)。

优化

1. 静态分析和栈深度预测

静态分析工具可以在编译时分析代码,预测函数调用的最大栈深度。这有助于确定合适的栈大小,避免栈溢出。

2. 栈帧优化

栈帧优化通过减少函数调用时分配的栈帧大小,可以有效地利用栈空间。主要方法包括:

  • 内联函数:将小函数内联到调用处,避免函数调用开销。
  • 尾调用优化:在尾调用情况下,复用当前栈帧。
  • 减少局部变量:通过减少不必要的局部变量,减小栈帧大小。
  • 寄存器分配:优先使用寄存器存储局部变量,减少对栈的依赖。

3. 动态栈扩展

动态栈扩展允许在运行时调整栈的大小。例如,通过设置栈溢出检测机制,在栈即将溢出时自动扩展栈空间。不过,这在嵌入式系统中可能受限于硬件资源。

4. 栈合并和共享

栈合并和共享可以通过任务之间共享栈空间来优化内存使用。例如,协程(coroutine)可以共享同一栈空间,因为它们不会同时执行。

5. 编译器优化

编译器优化可以自动调整栈空间的使用。现代编译器提供多种优化选项,可以自动分析和优化栈空间。例如,GCC编译器的-fstack-usage选项可以生成每个函数的栈使用情况报告。

6. 栈守护和溢出保护

栈守护和溢出保护通过设置保护字(guard word)或栈哨兵(stack canary)来检测栈溢出。结合硬件特性,如内存保护单元(MPU),可以在栈溢出时触发中断或异常处理。

7. 内存对齐

内存对齐策略确保栈空间按照特定边界对齐,以提高内存访问效率和硬件兼容性。例如,32位系统上通常要求栈按照4字节对齐。

8. 函数调用优化

函数调用优化可以减少深层次函数调用带来的栈开销。例如:

  • 尾递归优化:将尾递归转换为迭代,避免递归调用的栈开销。
  • 循环展开:在编译时展开循环,减少循环带来的栈帧开销。

以下是一个简单的示例,展示了如何通过内联函数和尾调用优化来减少栈空间使用:

#include <stdio.h>

// 内联函数示例
inline int add(int a, int b) {
    return a + b;
}

// 尾调用优化示例
int factorial_tail(int n, int acc) {
    if (n == 0) return acc;
    return factorial_tail(n - 1, n * acc);  
}

int factorial(int n) {
    return factorial_tail(n, 1);
}

int main() {
    int result = add(3, 4);
    printf("Add result: %d\n", result);

    int fact = factorial(5);
    printf("Factorial result: %d\n", fact);

    return 0;
}

在这个示例中,add函数被内联,避免了函数调用的栈帧开销,而factorial函数通过尾调用优化,将递归调用转化为迭代,减少了栈深度。

代码段(Code Segment)

功能

代码段存储程序的可执行指令,通常是只读的,以防止程序意外修改指令。

管理方式

代码段在程序加载到内存时由操作系统或运行时环境分配。编译器和链接器负责将源代码转换为机器码,并将其放置在代码段中。

特点

  • 只读:防止指令被修改,提高安全性。
  • 固定大小:在程序加载时确定,不会在运行时动态变化。

数据段(Data Segment)

功能

数据段用于存储全局变量、静态变量和常量数据。数据段在程序执行期间存在,并且可以分为已初始化数据段(.data)和未初始化数据段(.bss)。

管理方式

数据段在程序加载到内存时由操作系统或运行时环境分配。编译器负责将全局和静态变量放置在数据段中。

特点

  • 生命周期长:变量在程序执行期间一直存在。
  • 可读写:全局和静态变量通常是可读写的,但常量数据是只读的。
  • 分段管理:已初始化数据段和未初始化数据段分开管理,提高效率。