我最近在调一个内核模块的性能,频繁分配小块内存后再释放,结果发现系统响应越来越慢,排查到最后,问题出在我对内存分配API的理解不够深。一个是分配时用了不合适的GFP标志,另一个是频繁使用某个通用分配函数,导致slab缓存内部碎片化严重。折腾了几天,翻了不少资料,最终是靠重新梳理内核内存相关开发API的适用场景,才把问题彻底解决。
这篇就基于我自己的踩坑经历,把Linux内核内存相关的开发API做一次完整的梳理。内容会覆盖物理页分配、slab分配器、vmalloc、DMA内存这几个核心方向,结合实际模块代码演示正确用法,并把调试排查内存问题的常用手段一并整理出来。适合正在写内核模块、驱动,或者准备做内核内存子系统开发的人参考,也适合日常需要排查内核态内存异常的同学收藏备用。
1. 项目背景与整体设计思路
1.1 我的项目遇到了什么问题
先说背景。我负责的模块是一个运行在内核态的数据处理加速组件,主要工作是从用户态接收数据块,在内核态做格式转换和校验,再通过DMA把数据搬运到外设。这样的模块很典型,既涉及内核态通用的内存分配,又涉及面向外设的DMA内存映射,还要考虑并发场景下多核同时访问的稳定性。
起初实现比较粗糙,所有内存都使用kmalloc分配,小数据块还行,但当我接上高吞吐的数据流后,问题就来了。首先是分配延迟抖动,有时候单次分配要几十微秒甚至上百微秒,这对一个预期的数据面处理模块来说,根本无法接受。其次是内存回收压力大,系统在长时间运行后,free内存越来越少,dmesg里开始出现分配失败的告警,甚至触发过内核的OOM,直接拖垮了整机。
我把代码里所有内存相关调用列了一张表,逐个对着内核文档和源码分析,才意识到问题出在API选型上。不同的内存分配API,背后对应的是完全不同的分配器实现、不同的内存属性和不同的适用场景。选错API,不只是性能问题,更可能导致内存泄漏或者地址空间映射错误。
1.2 内存API选型的总体判断标准
接手这类问题,我总结了一个基本判断流程,先问自己四个问题:
- 需要的内存大小是多少。小于一个页(通常4KB)走slab分配器,等于或稍大于页但是要求连续物理内存,直接走页分配器,大于多个页且不要求物理连续,考虑vmalloc。
- 对物理连续性的要求。DMA外设通常需要物理连续且总线地址可访问,这时候除了页分配器还得配合IOMMU或DMA API。
- 分配上下文。是进程上下文可以睡眠,还是中断上下文只能使用原子分配。这一点直接决定GFP标志位的选择,分配失败的代价也完全不同。
- 生命周期与回收方式。内存是跟随某个对象创建和销毁,还是长期常驻?前者用kmem_cache管理更高效,后者直接用页分配器更省心。
这套判断框架看起来简单,但实际项目里几乎所有人都会忽略其中某一项。我最初踩的坑就是对“内核态小块内存用kmalloc”想当然,完全没去考虑slab层的回收机制和NUMA访问延迟,结果就是在多核场景下性能表现稀烂。内核提供这么多内存API,绝对不是为了让开发者眼花缭乱,而是每种API都解决了某一类特定的问题。理解了这一点,才算真正入门内核内存开发。
2. 核心API深度解析与选型要点
2.1 物理页分配:alloc_pages与伙伴系统
内核管理物理内存的基础是伙伴系统(Buddy System),所有页分配最终都会落到伙伴系统上。直接面向伙伴系统编程的API主要是alloc_pages和它的一系列封装接口。
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); struct page *alloc_page(gfp_t gfp_mask); void __free_pages(struct page *page, unsigned int order);其中order表示2的order次方个连续物理页。order为0就是1个页,order为3就是8个连续页。我在项目中分配DMA缓冲区时,就用到order为2或3的连续页,配合dma_map_single做映射。
在实际开发中,推荐优先使用带__get_free_pages语义的封装,因为返回的是虚拟地址,操作更直观。但需要特别注意,__get_free_pages系列并不保证页的物理地址连续之外还有什么特殊属性,它只保证在要求的order范围内物理连续。
GFP标志是这块的重中之重,选错了后果很直接。我常用的几个标志以及它们的用途如下表:
| GFP标志 | 使用场景 | 是否可睡眠 | 风险提示 |
|---|---|---|---|
| GFP_KERNEL | 进程上下文常规分配 | 可以 | 不能用于中断/持有自旋锁时 |
| GFP_ATOMIC | 中断上下文或临界区 | 不可以 | 分配成功率低于GFP_KERNEL |
| GFP_NOWAIT | 不允许睡眠的快速分配 | 不可以 | 内核会直接尝试,不触发回收 |
| GFP_HIGHUSER | 用户态页面分配 | 可以 | 优先使用高端内存,驱动中使用较少 |
| __GFP_ZERO | 辅助标志,清零内存 | 视主标志而定 | 常和GFP_KERNEL搭配 |
举一个具体对比。在数据面处理中,如果不允许睡眠,我会用GFP_ATOMIC或GFP_NOWAIT;而在普通进程上下文初始化模块时,用GFP_KERNEL更合理,因为伙伴系统可以触发内存回收,分配成功率显著提高。如果项目需要在软中断里频繁分配页,还有GFP_ATOMIC搭配__GFP_HIGH的组合,这表示允许从保留内存中取页,但使用时必须慎之又慎。
2.2 slab/slub分配器:kmalloc与kmem_cache
绝大多数内核对象都很小,几字节到几百字节,如果每次分配都从伙伴系统拿一个页,浪费太大。所以内核在页之上构建了slab分配器,现在主流发行版默认使用slub。kmalloc就是slab层的通用分配接口。
void *kmalloc(size_t size, gfp_t flags); void *kzalloc(size_t size, gfp_t flags); void kfree(const void *objp);kmalloc返回的地址是内核虚拟地址,物理内存连续,适合小对象和DMA场景。kzalloc等于kmalloc加__GFP_ZERO,省得手动memset。kmalloc可分配的最大大小通常是32MB到64MB,但真分配这么大是极端情况,驱动没必要这么用。
使用kmalloc时要注意,不同大小的分配会落在不同的kmalloc-xxx缓存里,这些缓存用/proc/slabinfo可以看到。如果你大量分配某个尺寸的对象,slub会保持对应缓存的热度,速度非常快,但是如果你分配的大小来回横跳,比如一会儿64字节一会儿512字节,slub缓存碎片和浪费就会加剧。
如果数据结构在模块中高频创建和销毁,比如网络驱动的sk_buff,那么应该用kmem_cache而不是kmalloc。
struct kmem_cache *kmem_cache_create(const char *name, size_t size, size_t align, unsigned long flags, void (*ctor)(void *)); void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags); void kmem_cache_free(struct kmem_cache *cachep, void *objp); void kmem_cache_destroy(struct kmem_cache *cachep);我实测过,使用kmem_cache连续分配和释放一个256字节的结构体,会比kmalloc快20%到40%,而且不会因为分配尺寸抖动污染全局缓存。一个值得做的优化是,在数据面路径中把常用的数据包描述符放入专用缓存,配合per-CPU的缓存机制,单核分配开销可以降到非常低。
不过kmem_cache创建时有个小坑:align参数不能随便传0,传0表示按架构自然对齐。如果你的对象里包含DMA缓冲区地址或者原子变量,需要明确对齐值,否则在某些架构上会出问题。比如对象内有个8字节变量,如果align不足8,可能导致字段未对齐访问异常。
2.3 vmalloc:非连续内存分配
当需要分配大块内存、又不需要物理连续时,用vmalloc。它通过修改页表把不一定连续的物理页映射到连续的虚拟地址空间,对应用层来说是透明的。
void *vmalloc(unsigned long size); void *vzalloc(unsigned long size); void vfree(const void *addr);vmalloc适合大缓冲区、模块加载、某些驱动的数据缓存。缺点是访问开销比kmalloc大,因为涉及到页表遍历,TLB压力也高。我一般只在分配体量大于一个或两个页、且不涉及DMA的场景下使用vmalloc。
我之前踩过一个典型问题:在某个数据包处理路径上,为了图省事用了vmalloc分配缓冲区,结果每次处理都伴随着页表切换开销,性能比预想低很多。换成本地页分配后,吞吐直接提升了一个档次。所以这类API不是“能不能用”,而是“合不合适”的问题。
还要注意,vmalloc分配的内存不能用于DMA,因为底层物理页不连续,外设无法访问。如果DMA要有连续内存,要么走页分配器,要么用DMA API,最后再考虑IOMMU。这点在驱动开发中特别容易混淆。
2.4 DMA内存分配与cache一致性
外设访问内存,走的是总线地址,与CPU看到的虚拟地址、物理地址都不一样。内核为此提供了完整的DMA API,按是否要求一致性分成两类。
一致性DMA缓冲区,使用dma_alloc_coherent,它返回的地址既是CPU可访问的虚拟地址,也是外设可访问的总线地址。这一套API背后会根据硬件自动处理cache一致性,通常是通过设置页表为非cacheable或使用硬件协同机制。
void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag); void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t dma_handle);流式DMA映射,使用dma_map_single/dma_unmap_single,适用于数据方向明确的场景,CPU和外设不会同时访问同一块内存。每次映射和解除映射之间需要做cache一致性操作,API内部会帮你完成必要的clean或invalidate。
dma_addr_t dma_map_single(struct device *dev, void *ptr, size_t size, enum dma_data_direction dir); void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma_data_direction dir);dma_data_direction有三种取值:DMA_TO_DEVICE、DMA_FROM_DEVICE、DMA_BIDIRECTIONAL。我自己在项目里见过大量新手把方向写成BIDIRECTIONAL,这其实会让性能打折扣,因为系统要为未知方向做双向的cache同步,开销增加。原则是,能明确方向就明确方向,不要图省事。
这两类API的关键区别,我整理成了下面的表:
| 对比项 | dma_alloc_coherent | dma_map_single |
|---|---|---|
| 分配性质 | 分配并映射一次完成 | 映射已有缓冲区 |
| 生命周期 | 从alloc到free全程可用 | map/unmap之间有效 |
| Cache一致性 | 一致性由驱动保证,API内部处理 | 需要按方向管理 |
| 适用场景 | 长期使用的DMA缓冲区 | 收发短数据帧、流式传输 |
| 开销 | 建立映射成本高 | 每次map/unmap有cache操作成本 |
有一个很常见的误解,认为dma_map_single必须在中断里用得很频繁,每帧数据都做一次map,性能肯定会差。实际上,很多网卡驱动就是这么运作的,关键在于把map/unmap频率降到最低,比如用per-CPU缓存来复用映射。我自己在实现一个虚拟网卡驱动时,就用了一个小的DMA映射池,把高频的map/unmap转换为池中条目复用,性能表现非常好。
3. 实操:内核模块中的内存分配基准与验证
3.1 模块设计思路
为了更好地说明这些API的使用方法和差异,我写了一个简单的内核模块membench,用来对比kmalloc、kmem_cache、alloc_pages、vmalloc几种分配方式的实际表现。模块做法是:在procfs或debugfs下创建一个节点,写入不同的测试命令,模块执行相应分配和释放,并把耗时统计输出。
选择这样的设计,一方面可以直观对比不同API的性能开销,另一方面也给后续调试定位提供一组可重复的基线数据。这个模块本身不复杂,但麻雀虽小五脏俱全,涉及了内核模块注册、文件系统节点创建、内存分配统计、指针有效性校验等完整链路。
3.2 关键代码实现
先看内核模块的基本框架:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/slab.h> #include <linux/vmalloc.h> #include <linux/debugfs.h> #include <linux/ktime.h> #include <linux/delay.h> #include <linux/uaccess.h> #define TEST_SIZE 256 static struct kmem_cache *my_cache; static struct dentry *dbg_dir; static struct dentry *dbg_file; static char test_result[2048]; static void bench_kmalloc(void) { void *p; ktime_t start, end; s64 delta_ns; int i; start = ktime_get(); for (i = 0; i < 100000; i++) { p = kmalloc(TEST_SIZE, GFP_KERNEL); kfree(p); } end = ktime_get(); delta_ns = ktime_to_ns(ktime_sub(end, start)); sprintf(test_result + strlen(test_result), "kmalloc %d bytes x100000: %lld ns avg\n", TEST_SIZE, delta_ns / 100000); }这段代码的核心思想是分配一个对象后立即释放,循环十万次统计总耗时,计算平均单次耗时。kmalloc在热缓存情况下,一次分配释放通常只有几十纳秒,如果超过两百纳秒,就要检查是不是NUMA访问问题,或者slub的调试配置影响性能。
再看kmem_cache的分配代码:
static void bench_kmem_cache(void) { void *p; ktime_t start, end; s64 delta_ns; int i; start = ktime_get(); for (i = 0; i < 100000; i++) { p = kmem_cache_alloc(my_cache, GFP_KERNEL); kmem_cache_free(my_cache, p); } end = ktime_get(); delta_ns = ktime_to_ns(ktime_sub(end, start)); sprintf(test_result + strlen(test_result), "kmem_cache x100000: %lld ns avg\n", delta_ns / 100000); }要创建my_cache,用kmem_cache_create,名字可以随便起,但最好有意义:
static int create_cache(void) { my_cache = kmem_cache_create("membench_obj", TEST_SIZE, __alignof__(struct bench_obj), SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT, NULL); if (!my_cache) return -ENOMEM; return 0; }SLAB_HWCACHE_ALIGN会把对象对齐到硬件cache line,避免多个CPU同时修改不同对象时发生false sharing。SLAB_ACCOUNT用于核算内存组,尤其在容器场景下,可以正确计算当前进程组的内存占用。这两个flag值得好好理解,前者针对性能,后者针对统计。
页分配器测试代码如下:
static void bench_alloc_pages(void) { struct page *page; void *p; ktime_t start, end; s64 delta_ns; int i; start = ktime_get(); for (i = 0; i < 100000; i++) { page = alloc_page(GFP_KERNEL); if (page) { p = page_address(page); memset(p, 0, PAGE_SIZE); __free_pages(page, 0); } } end = ktime_get(); delta_ns = ktime_to_ns(ktime_sub(end, start)); sprintf(test_result + strlen(test_result), "alloc_page x100000: %lld ns avg\n", delta_ns / 100000); }这里有个细节,page_address把struct page转成虚拟地址。对于直接映射区,这只是个算术运算,开销极小。但如果是高端内存页面,page_address可能返回NULL。在x86_64架构上通常不用考虑高端内存问题,但写驱动时不能默认所有架构都如此。
vmalloc测试代码:
static void bench_vmalloc(void) { void *p; ktime_t start, end; s64 delta_ns; int i; start = ktime_get(); for (i = 0; i < 10000; i++) { p = vmalloc(64 * 1024); vfree(p); } end = ktime_get(); delta_ns = ktime_to_ns(ktime_sub(end, start)); sprintf(test_result + strlen(test_result), "vmalloc 64k x10000: %lld ns avg\n", delta_ns / 10000); }vmalloc每次都会建立/拆除页表映射,所以单次开销比kmalloc大很多,十万次都跑太久,我改成一万次。测试结果在正常x86_64虚拟机上,大概在几百纳秒到一两微秒之间,视TLB和页表缓存状态而定。
debugfs文件节点的读写处理,用于触发测试和读取结果。这里用一个简单的write解析命令,read返回统计字符串:
static ssize_t bench_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { char cmd[16]; if (len >= sizeof(cmd)) return -EINVAL; if (copy_from_user(cmd, buf, len)) return -EFAULT; cmd[len] = '\0'; memset(test_result, 0, sizeof(test_result)); if (strncmp(cmd, "kmalloc", 7) == 0) bench_kmalloc(); else if (strncmp(cmd, "cache", 5) == 0) bench_kmem_cache(); else if (strncmp(cmd, "page", 4) == 0) bench_alloc_pages(); else if (strncmp(cmd, "vmalloc", 7) == 0) bench_vmalloc(); else sprintf(test_result, "unknown command\n"); return len; } static ssize_t bench_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { return simple_read_from_buffer(buf, len, offset, test_result, strlen(test_result)); } static const struct file_operations bench_fops = { .owner = THIS_MODULE, .write = bench_write, .read = bench_read, };3.3 模块初始化和清理
模块初始化时创建kmem_cache、创建debugfs目录和文件。清理时删除文件节点并销毁缓存。注意销毁顺序,必须先释放所有从缓存分配的对象,再销毁缓存本身。
static int __init membench_init(void) { int ret; ret = create_cache(); if (ret) return ret; dbg_dir = debugfs_create_dir("membench", NULL); if (!dbg_dir) { kmem_cache_destroy(my_cache); return -ENOMEM; } dbg_file = debugfs_create_file("bench", 0644, dbg_dir, NULL, &bench_fops); if (!dbg_file) { debugfs_remove(dbg_dir); kmem_cache_destroy(my_cache); return -ENOMEM; } sprintf(test_result, "membench ready, commands: kmalloc/cache/page/vmalloc\n"); return 0; } static void __exit membench_exit(void) { debugfs_remove(dbg_dir); kmem_cache_destroy(my_cache); } module_init(membench_init); module_exit(membench_exit); MODULE_LICENSE("GPL");编译加载之后,向debugfs节点写入命令就能触发对应测试:
mount -t debugfs none /sys/kernel/debug echo kmalloc > /sys/kernel/debug/membench/bench cat /sys/kernel/debug/membench/bench实际运行在我的开发机上(8核x86_64虚拟机),kmalloc平均耗时40到60纳秒,kmem_cache平均30到50纳秒,alloc_page平均70到90纳秒,vmalloc平均800纳秒到2微秒。这个数据本身受系统负载影响很大,但对比关系基本稳定。kmem_cache通常比kmalloc略快一点,而vmalloc比前两者慢一个数量级以上。
这里要特别强调,模块里大量使用kmalloc分配固定大小对象,实际上就是对自己建专属slab缓存前的一种过渡。如果生产代码里频繁分配固定大小结构,应当认真考虑kmem_cache,效果立竿见影。
4. 常见问题与排查技巧实录
4.1 分配失败问题
内核内存分配失败最直观的表现是dmesg里出现类似page allocation failure的日志,或者驱动中返回NULL。这种问题最常见的诱因有三个。
第一个是内存碎片。伙伴系统没有足够连续的页满足order大于0的分配请求。虽然内核有内存整理(compaction)机制,但若碎片严重,仍然可能失败。缓解办法是预留内存或使用vmalloc代替连续页分配,或者使用CMA区域。CMA(Contiguous Memory Allocator)在系统启动后可以动态迁移页面,为大的连续分配保底。驱动里如果需要大块连续内存,优先考虑CMA。
第二个是分配上下文不允许睡眠。内部中断上下文用了GFP_KERNEL分配,内核会直接警告,因为GFP_KERNEL允许阻塞,中断里不能阻塞。这种问题通常通过检查所有中断回调函数路径上有没有kmalloc(..., GFP_KERNEL)来定位,改成GFP_ATOMIC或GFP_NOWAIT即可。
第三个是内存不足,系统整体可用内存耗尽。这种问题在用户态会表现为申请内存失败,在内核态更多是OOM killer被触发。排查时用/proc/meminfo、/proc/buddyinfo观察内存水位,再结合slabinfo看slab内存消耗是否异常。
我排查过的一个典型案例是:某个驱动在错误处理路径上把kmalloc的返回值直接拿来用,没有判空,导致空指针访问后系统panic。这种问题看似低级,但一旦发生,损失很大。内核函数中,kmalloc、kzalloc、kmem_cache_alloc、alloc_pages的返回值都必须判空。
4.2 DMA映射相关的坑
DMA map/unmap时的方向错误是我见过最多的一类问题。方向设为DMA_BIDIRECTIONAL虽然不会立刻报错,但会导致性能下降,因为需要执行双向cache维护。性能敏感路径上一定要明确TO/FROM方向。
更麻烦的是cache一致性问题。有些驱动没有用DMA API,直接拿了内核虚拟地址给外设访问,结果数据时而正确时而错乱,而且越在高负载下越容易出现。这种问题用普通debug手段很难复现,因为只有cache line被刷回内存的时机不对才会出错。解决办法是严格使用DMA API,并配合dma_sync_single_for_cpu和dma_sync_single_for_device完成阶段性的cache同步。
在开启IOMMU的系统里,dma_alloc_coherent返回的总线地址可能不是真实物理地址,而是IOMMU映射后的地址。这本身是透明的,但如果驱动代码试图反推物理地址,就会出问题。我的建议是永远不要从dma_addr_t反推物理地址,除非你的平台明确不需要IOMMU,否则属于自找麻烦。
4.3 内存泄漏的定位
内核模块内存泄漏比用户态更难察觉,因为许多对象与进程生命周期无关,进程退出后内存依然被内核模块持有。排查手段常用的有三个。
第一个是slabinfo对比。加载模块前后对比/proc/slabinfo,观察某个缓存的对象数量是否只增不减。专门创建的缓存会显示模块名相关条目,很容易识别。
第二个是kmemleak。内核配置开启CONFIG_DEBUG_KMEMLEAK后,可以直接扫描内核内存中的可疑对象。操作步骤是在sysfs中触发扫描,然后读取报告。
echo scan > /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleakkmemleak的准确性依赖扫描策略,有些引用链复杂的对象可能被漏报,但它仍然是我最推荐的内核泄漏初筛工具。需要提醒的是,开启kmemleak后性能会受影响,不建议在生产环境长期开启。
第三个是用ftrace的kfree和kmalloc等函数跟踪。借助tracepoint,统计每个调用点的分配和释放次数。如果某个调用点分配次数远大于释放次数,基本就能锁定泄漏路径。具体做法是在/sys/kernel/debug/tracing目录配好kprobe事件,然后解析trace输出。
4.4 越界访问与slab损坏
slab对象越界是很隐蔽的bug,因为不会立刻崩溃,可能在很久之后才导致奇怪的分配错误。如果怀疑越界,内核提供了完整的slub调试选项:
slub_debug=FZPUF表示free时校验,Z表示redzone校验,P表示poison对象填充,U表示记录调用栈。这些选项可以在内核启动参数里配置。开启后,kmem_cache_free时会检查redzone,一旦发现越界立即输出调用栈,极大缩短排查时间。代价是分配释放性能下降明显,通常只在测试阶段启用。
我在一次项目中就靠slub_debug找到了一个数组越界问题。驱动里把一个结构体数组的下标算错了两个字节,刚好越过了对象边界,但没写坏页边界,所以一直没触发panic。开启redzone后,每次释放对象都报错,跟着栈回溯,五分钟就定位到了。这个组合拳对内核模块开发来说非常实用。
5. 调试与观测手段进阶
5.1 用好内核自带的内存统计接口
排查内存问题时,/proc目录下的几个文件是最直接的抓手。
/proc/meminfo:总体内存分布,重点关注MemFree、Slab、SReclaimable、SUnreclaim这几个字段。/proc/buddyinfo:伙伴系统各order剩余页数,能清晰看出是否碎片化。如果order大于3的连续页数量长期为0,就说明碎片严重。/proc/slabinfo:所有slab缓存的详细信息,对象数量、每页对象数、活动对象数等。/sys/kernel/debug/dma-api/:DMA API调试信息,可以统计dma_alloc和dma_map的调用次数及内存消耗。
这些统计接口配合上shell脚本,可以在压测过程中定期采样,观察内存的变化趋势。我自己的做法是写一个简单的采样脚本,每10秒记录各文件的关键字段,然后在问题复现的时间点回看曲线,快速缩小排查范围。
5.2 借助tracepoint和kprobe做动态定位
内核提供了大量内存相关的tracepoint,比如kmalloc、kfree、kmem_cache_alloc、kmem_cache_free、page_alloc等。用ftrace可以按调用点或者按进程过滤,零侵入地观察分配释放行为。
我的常用做法是进入/sys/kernel/debug/tracing,创建kprobe事件,针对特定函数:
echo 'p:my_alloc __kmalloc size=$arg1 flags=$arg2' >> kprobe_events echo 'p:my_free kfree ptr=$arg1' >> kprobe_events echo 1 > events/kprobes/my_alloc/enable echo 1 > events/kprobes/my_free/enable echo 'comm=test_proc' > events/kprobes/my_alloc/filter echo 1 > tracing_on cat trace这种方法比直接改代码打印日志高效得多,尤其适合线上问题的临时定位。唯一要注意的是,kprobe的开销不可忽略,生产环境使用时建议加过滤条件,并且用完后及时关闭trace。
在观察某个固定大小对象分配峰值时,tracepoint数据配合perf工具做热点分析,可以对内核模块的内存行为建立完整画像。这个组合是我处理性能问题时最常用的手段。
5.3 结合panic日志分析
如果内核模块问题导致panic,捕获到的日志信息非常关键。常见需要看的内容包括:
- 寄存器状态和调用栈,判断是否空指针或页错误。
- panic时的内存分配情况,有时会附带分配请求的size和GFP标志。
- 是否触发了内核的BUG或WARN,需要检查对应的内核代码路径。
为了在开发阶段获得更多现场信息,可以配置panic_on_oops和panic_on_warn。但要注意,有些WARN本身并不致命,比如slub的报错,设置panic_on_warn后任何警告都会停机,可能过度影响开发效率。我一般只在稳定复现并确认是严重问题时才打开这个开关。
6. 最后的几点经验心得
回头整理这篇文章时,我把平时写内核模块的几条内存使用规则再顺手列一下,这些都是实测过、有代价教训的体会。
第一,不要迷信某一个API。大块内存、小块内存、连续内存、DMA内存,都有对应的专门接口。选择接口前先想清楚属性和使用场景,想清楚分配上下文,写注释里把为什么这么选记下来,防止以后换人维护时被改掉。
第二,分配和释放必须配对,并且放在同一抽象层。谁分配谁释放,模块里要有明确约定。我在项目里专门加了一条规则:分配函数只暴露给上层,下层接口不对外直接分配,避免“A分配B释放”导致后续无法追踪。
第三,结合工具验证,不要只看代码。写完一个模块,用kmemleak扫一遍、用slub_debug跑一轮、再用tracepoint看一次热点,这些步骤加起来不超过半天,但能帮你挡掉绝大多数隐蔽问题。内核社区的铁律是“信任但验证”,驱动代码尤其如此。
最后再分享一个小技巧:在驱动模块的私有数据结构里,给每次分配加一个简单的magic number字段,释放时检查magic是否被破坏。这个方法不依赖任何内核调试选项,普通内核里就能跑,虽然简陋,但对付野指针和越界写效果非常好。我在很多驱动里用这一招,长期运行下来几乎没有漏过问题的。
内核内存API看着多,实际上每条都有清晰的设计意图,每一条都对应一类专项场景。理解它们,选对它们,你写的内核代码才真正站得住。