4. 内核基础概念

内存管理,说白了就是操作系统怎么把有限的物理内存分给各个任务用。你想想看,一个系统里跑着几十个线程,每个都要申请释放内存,如果管理不好,轻则性能下降,重则直接死机。我在OpenHarmony内核里折腾了这么久,踩过的坑大多跟内存有关。

这一章我们重点聊四个东西:静态内存和动态内存的区别、内存池怎么管、内存泄漏怎么查、还有伙伴算法和slab算法到底怎么实现的。嗯,内容不少,但都是硬核干货。

4.1 静态内存 vs 动态内存

先搞清楚概念。静态内存,就是编译时就定好了大小,生命周期跟任务绑定。动态内存呢,运行时按需分配,用完就释放。

对比项 静态内存 动态内存
分配时机 编译时 运行时
大小确定 固定 可变
分配速度 极快(无开销) 较慢(有算法开销)
碎片问题 无碎片 可能产生碎片
典型用途 任务栈、全局变量 消息队列、大数据块

我个人习惯,能静态分配的就尽量静态分配。为什么?因为动态分配有不确定性。我在项目中遇到过一个问题:一个传感器驱动在中断里调了 malloc,结果分配失败导致系统挂掉。后来改成静态内存池,问题就解决了。

我的建议:中断上下文里千万别用动态内存分配。如果实在需要,用预先分配好的静态内存池。

4.2 内存池管理机制

内存池,说白了就是提前划出一块区域,自己管自己。OpenHarmony内核里提供了两种内存池:固定大小块的内存池和可变大小块的内存池。

固定块内存池的实现思路很简单:

  1. 初始化时把一整块内存切成N个等大小的块
  2. 用链表把空闲块串起来
  3. 分配时从链表头取一个,释放时放回链表头

代码大概长这样:

typedef struct {
    void *pool_start;       // 内存池起始地址
    uint32_t block_size;    // 每个块的大小
    uint32_t block_count;   // 块总数
    void *free_list;        // 空闲块链表头
} MemPool;

// 分配一个块
void *mem_pool_alloc(MemPool *pool) {
    if (pool->free_list == NULL) {
        return NULL;  // 没有空闲块了
    }
    void *block = pool->free_list;
    pool->free_list = *(void **)block;  // 链表头后移
    return block;
}

// 释放一个块
void mem_pool_free(MemPool *pool, void *block) {
    *(void **)block = pool->free_list;
    pool->free_list = block;
}

你看,这个实现O(1)复杂度,分配和释放都是常数时间。我在做音频驱动时就用这个,因为音频缓冲区大小是固定的,用内存池比用malloc快了一个数量级。

关键点:内存池的块大小要选好。太小了浪费指针开销,太大了内部碎片严重。一般取实际业务中最常用的那个大小。

4.3 伙伴算法与slab算法实现

这两个算法是内核动态内存管理的核心。我分开讲。

4.3.1 伙伴算法

伙伴算法解决什么问题?外部碎片。你想想看,频繁申请释放后,内存里会出现很多小空洞,每个都不大,但加起来不少,而且大块分配会失败。

伙伴算法的思路:把内存按2的幂次分成块。比如2KB、4KB、8KB……分配时找刚好能装下的最小块,如果找不到就拆大块。释放时检查相邻的块是否空闲,如果是就合并。

我画个图帮你理解:

伙伴算法内存分配与合并流程 初始: 64KB 空闲块 分配32KB: 已分配 32KB 空闲 32KB 再分配16KB: 已分配 32KB 已分配 16KB 空闲 16KB 释放32KB: 空闲 32KB 已分配 16KB 空闲 16KB 合并后: 空闲 32KB 空闲 32KB(可合并为64KB)

伙伴算法的核心数据结构是空闲链表数组,每个元素对应一种大小的块:

// 假设最大阶数为10(即最大块为1024个单位)
#define MAX_ORDER 10
typedef struct {
    struct list_head free_list[MAX_ORDER + 1];  // 每阶一个空闲链表
    unsigned long free_pages;                   // 总空闲页数
} BuddySystem;

// 分配:从指定阶数开始找,找不到就升阶拆分
void *buddy_alloc(BuddySystem *bs, int order) {
    int current = order;
    while (current <= MAX_ORDER) {
        if (!list_empty(&bs->free_list[current])) {
            // 找到空闲块,取出
            struct page *page = list_first_entry(&bs->free_list[current], struct page, list);
            list_del(&page->list);
            // 如果当前阶数大于请求阶数,拆分
            while (current > order) {
                current--;
                // 把另一半加入低一阶的空闲链表
                struct page *buddy = page + (1 << current);
                list_add(&buddy->list, &bs->free_list[current]);
            }
            return page;
        }
        current++;
    }
    return NULL;  // 内存不足
}
注意:伙伴算法有个缺点——内部碎片。比如你申请3KB,它给你4KB的块,浪费了1KB。slab算法就是来解决这个问题的。

4.3.2 Slab算法

Slab算法的思路:把内存按对象大小缓存起来。比如内核里经常分配task_struct结构体,大小固定,那就专门为它建一个slab缓存。

每个slab缓存包含多个slab,每个slab是一块连续内存,被切成等大小的对象。对象有3种状态:

  • 满:所有对象都被分配了
  • 空:所有对象都空闲
  • 部分:部分分配,部分空闲

分配时优先从部分slab里取,没有就从空slab里取。释放时如果slab变空了,可以归还给伙伴系统。

我在项目中遇到过一个问题:某个驱动频繁创建销毁对象,用kmalloc分配导致大量碎片。后来改成slab缓存,性能提升明显,碎片也少了。

// Slab缓存结构(简化版)
typedef struct {
    struct list_head slabs_full;      // 满的slab链表
    struct list_head slabs_partial;   // 部分空闲的slab链表
    struct list_head slabs_free;      // 全空闲的slab链表
    uint32_t object_size;             // 每个对象大小
    uint32_t objects_per_slab;        // 每个slab里有多少对象
    void (*ctor)(void *obj);          // 构造函数(可选)
} SlabCache;

// 从slab缓存分配一个对象
void *slab_alloc(SlabCache *cache) {
    // 优先从部分空闲的slab里取
    if (!list_empty(&cache->slabs_partial)) {
        Slab *slab = list_first_entry(&cache->slabs_partial, Slab, list);
        void *obj = slab_alloc_from(slab);
        if (slab_is_full(slab)) {
            list_move(&slab->list, &cache->slabs_full);
        }
        return obj;
    }
    // 没有部分空闲的,从全空闲的取
    if (!list_empty(&cache->slabs_free)) {
        Slab *slab = list_first_entry(&cache->slabs_free, Slab, list);
        list_move(&slab->list, &cache->slabs_partial);
        return slab_alloc_from(slab);
    }
    // 都没有,向伙伴系统申请新slab
    Slab *new_slab = buddy_alloc_slab(cache);
    if (new_slab) {
        list_add(&new_slab->list, &cache->slabs_partial);
        return slab_alloc_from(new_slab);
    }
    return NULL;
}
经验之谈:OpenHarmony内核里,slab和伙伴算法是配合使用的。slab从伙伴系统拿大块内存,然后自己切成小块。这样既解决了外部碎片,又解决了内部碎片。

4.4 内存泄漏检测与调试

内存泄漏,说白了就是申请了没释放。在嵌入式系统里,内存泄漏是致命的——系统跑几天就挂了。

我分享几个实用的检测方法:

4.4.1 手动标记法

在分配和释放的地方加日志:

#define DEBUG_MEM

void *debug_malloc(size_t size, const char *file, int line) {
    void *ptr = malloc(size);
#ifdef DEBUG_MEM
    printf("[MEM] malloc(%zu) = %p at %s:%d\n", size, ptr, file, line);
#endif
    return ptr;
}

void debug_free(void *ptr, const char *file, int line) {
#ifdef DEBUG_MEM
    printf("[MEM] free(%p) at %s:%d\n", ptr, file, line);
#endif
    free(ptr);
}

// 使用宏替换
#define malloc(s) debug_malloc(s, __FILE__, __LINE__)
#define free(p) debug_free(p, __FILE__, __LINE__)

4.4.2 内存泄漏检测工具

OpenHarmony内核里有个 memleak 工具,原理是记录每次分配时的调用栈,定期扫描内存,找出那些分配了但没释放的块。

用法很简单:

# 开启内存泄漏检测
echo 1 > /proc/sys/kernel/memleak_enable

# 查看泄漏报告
cat /proc/memleak

# 输出示例:
# [ 1234.567] memleak: 8 bytes leaked from 0x12345678
# [ 1234.567]   Call trace:
# [ 1234.567]     [<0x80001234>] my_function+0x10/0x20
# [ 1234.567]     [<0x80005678>] another_function+0x30/0x40
我的调试习惯:先在开发板上跑一轮压力测试,同时开着memleak。等测试结束看报告,重点关注那些分配次数远大于释放次数的调用路径。

4.4.3 常见泄漏场景

  • 中断上下文里分配了内存但忘了释放——中断处理要快,容易遗漏
  • 错误路径没释放——函数提前返回了,free没执行到
  • 链表操作失误——节点从链表移除后没释放
我曾经踩过的坑:写一个网络驱动时,在接收中断里分配了skb,但处理完数据后忘了调用kfree_skb。结果系统跑了3小时后内存耗尽重启。查了两天才找到问题,从此以后我每次分配内存都会在注释里写上对应的释放位置。

4.5 本章小结

内存管理这块,说白了就是三个字:快、稳、省。静态内存池适合固定大小的频繁分配,伙伴算法解决大块内存的碎片问题,slab算法优化小对象的分配效率。至于内存泄漏检测,别等到产品上线了才做,开发阶段就养成好习惯。

嗯,这一章的内容就到这。代码示例都是我从OpenHarmony内核里提炼出来的简化版,实际源码会更复杂,但核心思路是一样的。你动手写代码时,多想想:这个内存到底该谁管?什么时候释放?想清楚了再动手。