|
MCU上不敢malloc怕碎片,Linux上随便new不操心——MMU加三层分配器让物理碎片不再致命,本文从MCU到Linux理清四层内存架构。 一、MCU内存模型 物理地址的囚笼 MCU内存是编译时定死的硬平面图,每个地址都是真实物理地址: 0x08000000 ┌────────────┐ │ .text │ ← Flash:代码段(只读)0x08002000 ├────────────┤ │ .rodata │ ← Flash:只读数据(字符串常量)0x20000000 ├────────────┤ │ .data │ ← RAM:已初始化全局变量(启动代码从Flash搬过来)0x20001000 ├────────────┤ │ .bss │ ← RAM:未初始化全局变量(启动代码清零)0x20002000 ├────────────┤ │ heap │ → 往高地址长(malloc/free在这片区域)0x20008000 ├────────────┤ │ ...空闲...│0x20010000 ├────────────┤ │ stack │ ← 往低地址长(局部变量、函数调用)0x20018000 ├────────────┤ │ 外设寄存器 │ ← 内存映射I/O0x20020000 └────────────┘每个地址都是真实的物理地址// MCU上malloc三宗罪// 宗罪一:外部碎片——分配和释放交替进行char *a = malloc(100); // 分配100字节在 heap 底部char *b = malloc(50); // 分配50字节紧挨着free(a); // 释放100字节 → heap中间有个100字节的洞char *c = malloc(80); // 要找 ≥80 的洞→100那个洞刚好→分裂成80+20空洞// 跑久了:heap里全是小空洞,找不到连续大块→malloc(1024)失败// 宗罪二:堆栈碰撞——heap往高长、stack往低长,撞上→互相踩数据→HardFault// 宗罪三:无保护——野指针踩别人内存,没有任何硬件机制阻止物理地址空间平坦无保护——碎片不可逆转、溢出不可检测、越界不可阻止,这就是MCU上不敢用malloc的原因。 本节要点:MCU没有MMU,所有地址都是物理地址,malloc的碎片和越界没有任何硬件兜底。二、RTOS过渡 heap_1到heap_5选型 FreeRTOS的heap_x.c决定堆行为,选对实现比写业务代码还重要: 实现 | 是否支持free | 是否支持动态大小 | 关键特征 | 适用场景 | | heap_1 | 不支持 | 固定大小 | 只分配不释放,无碎片 | 启动时一次性分配任务栈/TCB | | heap_2 | 支持 | 固定大小 | 最佳适应,不合并相邻空闲块 | 已逐渐被heap_4取代 | | heap_3 | 支持 | 支持 | 封装标准库malloc/free | 快速移植,实时性不可控 | | heap_4 | 支持 | 固定大小 | 首次适应+合并相邻空闲块 | 最常用,频繁分配释放 | | heap_5 | 支持 | 固定大小 | heap_4基础上支持多段不连续内存 | 有外部SDRAM、片内RAM分段 |
heap_4维护按地址升序的空闲块链表,首次适应+合并相邻块。heap_5通过vPortDefineHeapRegions()注册多段物理内存: HeapRegion_t xHeapRegions[] = { { (uint8_t *)0x20000000, 0x00010000 }, // 片内RAM 64KB { (uint8_t *)0xC0000000, 0x00800000 }, // 外部SDRAM 8MB { NULL, 0 } // 数组结束标记};vPortDefineHeapRegions(xHeapRegions);// xHeapRegions 必须按地址从低到高排序即使有heap_4/heap_5,老司机仍优先静态分配——任务生命周期贯穿系统,动态分配风险高、收益低,map文件直接给出地址和大小,HardFault时一眼定位: // 推荐:静态创建任务static StaticTask_t s_xTaskTcb;static StackType_t s_xTaskStack[256];TaskHandle_t xHandle = xTaskCreateStatic( vTaskCode, "task", 256, NULL, 1, s_xTaskStack, &s_xTaskTcb);本节要点:FreeRTOS选heap_4最通用,但优先静态分配才是嵌入式正道。三、MMU与Linux内核分配器 MMU在CPU和物理内存之间加了页表翻译——同一个虚拟地址在不同进程映射不同物理页,互不干扰: x86_64用4级页表(PGD→PUD→PMD→PTE),每查一次4次内存访问,TLB缓存最近映射。缺页时CPU触发page fault——Major Fault从磁盘读,Minor Fault只改页表,非法访问发SIGSEGV杀进程: 层级 | 名称 | 作用 | | PGD | Page Global Directory | 进程页表顶层,mm_struct.pgd指向它 | | P4D | Page 4th Directory | 5级页表时存在,4级时折叠到PGD | | PUD | Page Upper Directory | 中间层 | | PMD | Page Middle Directory | 中间层 | | PTE | Page Table Entry | 指向物理页框,记录读写/执行/存在位 |
改变 | MCU没有MMU | Linux有MMU | | 碎片 | 物理碎片无法消弭 | 虚拟地址拼连续大块,物理碎片不再要命 | | 保护 | 野指针直接踩别人内存 | 非法访问→SIGSEGV,杀进程不杀系统 | | 共享 | 全局变量就是共享内存 | 两进程页表指向同一物理页→mmap共享 |
Buddy系统:内核物理页分配器,只分配2^n × 页大小的块,相邻空闲块自动合并。优点防外部碎片+O(log n),缺点内部碎片(申请8字节也分4KB页)。物理内存按Zone划分:ZONE_DMA(低16MB)、ZONE_DMA32(低4GB)、ZONE_NORMAL(内核直接映射)。嵌入式通常只有DMA32和NORMAL。 初始:256个连续空闲页(1MB) 分配100KB(需要32页:128KB的块): 256页块 → 拆:128页 + 128页 → 分配出去 释放这128页:相邻128页块也空闲?→ 合并为256页块 cat /proc/buddyinfo # Node 0, zone Normal 1000 500 200 50 10 5 2 1 0 0 0 # ↑1页 ↑2页 ↑4页 ↑8页 ↑16页...Slab/Slub:从Buddy拿一页切成同大小槽位,按size class分配。kmalloc(10)走kmalloc-32。三种状态:FULL(从Buddy要新页)、PARTIAL(优先分配)、EMPTY(可还给Buddy): 分配器 | 特征 | 适用场景 | | SLAB | 原始实现,元数据开销大 | 大型SMP服务器 | | SLUB | 现代Linux默认,调试支持好 | 通用服务器/桌面 | | SLOB | 极简,内存占用最小 | 内存极小嵌入式设备 |
cat /proc/slabinfo | head# slab_name active_objs num_objs objsize objperslab# task_struct 234 240 3264 10# kmalloc-32 1200 1280 32 128vmalloc:物理碎片化时,在虚拟地址空间拼连续区域,零散4KB页通过页表映射成连续虚拟地址。代价:TLB条目多、访问慢、不能DMA。kmalloc给小内存(物理连续),vmalloc给大块(虚拟连续): void *p = kmalloc(256, GFP_KERNEL); // 小内存,物理连续void *p = vmalloc(1024 * 1024); // 大内存,虚拟连续// insmod加载.ko模块就是vmalloc——不需要物理连续DMA与CMA:DMA设备看物理地址,要求缓冲区物理连续。dma_alloc_coherent()分配uncached缓冲区长期持有(环形缓冲区、描述符环);dma_map_single()临时映射已有内存(网络skb、文件页缓存)。CMA启动时预留可迁移区域,平时借给普通页,驱动需要大连续物理内存时迁移走,兼顾日常利用率和DMA需求。 // 一致性DMAdma_addr_t dma_handle;void *cpu_addr = dma_alloc_coherent(dev, 4096, &dma_handle, GFP_KERNEL);// cpu_addr:CPU虚拟地址;dma_handle:DMA总线地址// 流式DMA映射dma_addr_t dma_handle = dma_map_single(dev, vaddr, size, DMA_TO_DEVICE);dma_unmap_single(dev, dma_handle, size, DMA_TO_DEVICE);特性 | dma_alloc_coherent | dma_map_single | 是否新建缓冲区 | 是 | 否,映射已有内存 | cache一致性 | 硬件保证或uncached | 驱动在map/unmap点显式同步 | 使用次数 | 长期持有 | 每次DMA传输前后map/unmap | 典型场景 | 环形缓冲区、描述符环 | 网络包、磁盘IO |
本节要点:MMU让虚拟地址掩盖物理碎片,Buddy管页、Slab管对象、vmalloc管大块、CMA管DMA,四层分工各管一摊。四、用户态malloc glibc内存池 malloc(100)不走内核Buddy——先查glibc的freelist,有合适块直接返回不进内核。没有时:小内存(<128KB)走brk()扩展堆顶,大内存(≥128KB)走mmap()映射新区域,释放时munmap立刻还物理页: // 小内存(< 128KB):用 brk()p = malloc(1024);// → brk 往上挪 → glibc切一块给p// 大内存(≥ 128KB):用 mmap()p = malloc(1024 * 1024);// → mmap 在虚拟地址空间找1MB空闲区域 → 映射物理页// 释放后 munmap 立刻还物理页给内核glibc的freelist = 你手写内存池的next指针,只不过你的池从static uint8_t pool[SIZE]拿,glibc不够了找内核要。ASLR默认开启,每次启动地址随机偏移,阻止攻击者预测地址,调试需确定性时加nokaslr关闭。 本节要点:malloc先走用户态freelist,不够才进内核,glibc内存池本质就是你手写内存池的内核版。五、全景对比与注意事项 维度 | MCU裸机 | FreeRTOS | Linux | 地址类型 | 物理地址 | 物理地址 | 虚拟地址(MMU翻译) | 堆实现 | 无/自己写内存池 | heap_1~heap_5可选 | Buddy+Slab+vmalloc+glibc | 推荐策略 | 静态分配为主 | 静态分配为主,堆为辅 | 动态分配为主 | 碎片 | 真实碎片,不可逆转 | heap_4可合并,仍有碎片 | 虚拟地址掩盖物理碎片 | 保护 | 野指针直接踩坏任何内存 | 同MCU,依赖MPU(可选) | MMU拦截非法访问→SIGSEGV | 栈管理 | 固定大小,溢出不报错 | 任务栈固定,溢出触发HardFault | 按需增长(缺页异常自动扩展) | 共享内存 | 天然支持(全局变量=共享) | 通过全局变量/消息缓冲区 | 需要mmap/MAP_SHARED建立映射 | DMA | 直接物理地址 | 直接物理地址,注意cache一致性 | dma_alloc_coherent / CMA | 调试工具 | map文件分析 | FreeRTOS heap钩子、栈溢出检测 | kmemleak, slub_debug, valgrind, ASan |
工具 | 检测范围 | 典型开销 | 适用阶段 | kmemleak | 内核未释放内存 | 低 | 运行时长期测试 | slub_debug | 内核slab越界/释放后使用 | 高 | 模块调试 | valgrind | 用户态内存错误 | 极高(10~50x) | 单元测试 | ASan | 用户态缓冲区溢出、释放后使用 | 中(2~3x) | 开发/CI |
坑 | 现象 | 解法 | | kmalloc大块内存总失败 | 物理内存碎片导致没有连续大块 | >128KB用vmalloc,或启动时预先kmalloc | | 用户态free后还读 | use-after-free,偶发性崩溃 | valgrind检测,或用智能指针(C++) | | 内核内存泄漏 | free_proc()忘匹配kfree,内存越用越少 | kmemleak检测 | | OOM Killer乱杀进程 | 内存用满,内核选了个"坏蛋"杀掉 | 限制进程内存:cgroup memory | | vmalloc区域用完了 | 32位系统vmalloc区域只有约128MB | 用64位内核 | | GFP_ATOMIC失败率高 | 原子上下文可用的紧急内存池太小 | 尽量在进程上下文分配GFP_KERNEL |
本节要点:三层系统各有软肋——MCU怕越界无感知,RTOS怕碎片化,Linux怕OOM和泄漏。六、总结 四层塔的分工 MCU内存管理是设计阶段的事,画张静态分配表就完 Linux内存管理是运行时基础设施,工具链完善但需理解层次 kmalloc跟malloc不比速度——一个走Slab/物理连续,一个走glibc/虚拟连续 理解四层分工,在嵌入式和Linux之间切换 MCU用脑子管内存,Linux用工具看内存——内存管理的本质没变,变的是谁来扛碎片、谁来挡越界。 免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |