4、Ashmem内核驱动源码分析(一):核心数据结构(ashmem_area, ashmem_range)
好,我们正式开始啃内核源码了。
说实话,很多做Android应用层开发的朋友,一听到「内核驱动」四个字就头大。我当年也一样,总觉得那是另一个世界的东西。但后来我慢慢发现,其实内核里的数据结构设计得特别精巧,你把它拆开来看,每个字段都有它的道理。
今天我们就聚焦在Ashmem驱动的两个核心结构体上:ashmem_area 和 ashmem_range。这两个东西,说白了就是整个匿名共享内存的「地基」。你理解了它们,后面看代码就跟看小说一样顺畅。
4.1 ashmem_area:一片共享内存的「户口本」
先看第一个结构体。它定义在 drivers/staging/android/ashmem.c 里(老版本内核路径可能略有不同,但结构体名字没变过)。
struct ashmem_area {
char name[ASHMEM_NAME_LEN]; /* 名字,调试用 */
struct list_head unpinned_list; /* 未固定页面的链表头 */
struct file *file; /* 背后真正的临时文件 */
size_t size; /* 用户请求的大小 */
unsigned long prot_mask; /* 保护位掩码 */
};
嗯,代码不长,但每个字段都有故事。
name:这个字段其实挺有意思的。它最多能存 ASHMEM_NAME_LEN 个字符(默认是256)。我刚开始看的时候觉得这玩意儿就是个摆设,后来在调试一个内存泄漏问题时,发现通过 /d/ashmem 节点能看到每个区域的名称,一下子就定位到了是哪个进程在乱搞。所以,给共享内存起个好名字,关键时刻能救命。
unpinned_list:这是整个Ashmem机制的精髓所在。它是一个链表头,挂载的是 ashmem_range 结构体。你想想看,用户通过 ioctl 调用 ASHMEM_PIN 和 ASHMEM_UNPIN 时,内核就是在操作这个链表。说白了,谁被固定了,谁被释放了,全看这个链表里有什么。
file:这个字段指向一个真正的临时文件。Ashmem底层其实依赖了Linux内核的 shmem(共享内存文件系统)。每次你创建一个Ashmem区域,内核就在背后偷偷创建一个临时文件,所有的内存分配都走这个文件。我习惯把这个文件叫做「影子文件」——用户看到的是Ashmem,内核看到的是shmem。
size:用户请求的大小。注意,这个大小不一定等于实际分配的内存页数。内核会按页对齐,但 size 字段保留的是用户原始请求的值。我曾经踩过一个坑:用户请求了100字节,我按页对齐后分配了4096字节,结果在某个边界检查时用了 size 而不是实际页数,导致越界访问。嗯,这里要注意。
prot_mask:保护位掩码,控制着这块内存的读写权限。它跟 mmap 时的 prot 参数配合使用。我记得有一次,一个第三方库传了 PROT_EXEC 进来,结果内核直接拒绝了,因为Ashmem默认不允许执行权限。这个设计其实是为了安全考虑——你想想看,如果共享内存里能执行代码,那攻击面就太大了。
核心要点:ashmem_area 就是一片共享内存的「户口本」。它记录了这片内存的名字、大小、权限,以及所有未固定页面的链表。每个Ashmem文件描述符背后,都对应着一个 ashmem_area 实例。
4.2 ashmem_range:一段连续页面的「身份证」
接下来看第二个结构体。它描述的是 ashmem_area 中一段连续的、状态相同的页面范围。
struct ashmem_range {
struct list_head lru; /* LRU链表节点 */
struct list_head unpinned; /* 挂载到ashmem_area的unpinned_list */
struct ashmem_area *asma; /* 指向所属的ashmem_area */
size_t pgstart; /* 起始页号 */
size_t pgend; /* 结束页号(包含) */
unsigned int purged; /* 是否已被回收 */
};
这个结构体,我把它叫做「身份证」。为什么?因为它精确地标识了一段页面的状态和位置。
lru:这是全局LRU(最近最少使用)链表的节点。当系统内存紧张时,内核会遍历这个LRU链表,找到那些被 unpin 且长时间没被访问的页面,然后回收它们。说白了,这个字段是Ashmem参与内存回收的「门票」。
unpinned:这个字段把 ashmem_range 挂载到 ashmem_area 的 unpinned_list 上。一个 ashmem_area 可以有多个 ashmem_range,它们通过这个链表串在一起。我刚开始看的时候,总觉得 lru 和 unpinned 这两个链表有点重复,后来才明白:unpinned 是按区域组织的,lru 是全局的,两者维度不同。
asma:反向指针,指向这个 range 所属的 ashmem_area。这个设计很常见,方便从 range 回溯到它的「户口本」。
pgstart 和 pgend:这两个字段定义了页面的范围。注意,pgend 是包含的。也就是说,如果 pgstart = 0,pgend = 3,那么它覆盖了第0、1、2、3页,一共4页。这个设计跟Linux内核里很多区间表示法一致(比如 vm_area_struct 的 vm_start 和 vm_end,但那个是 end 不包含)。
purged:这是一个布尔标志,表示这段页面是否已经被内核回收了。如果 purged = 1,说明这些页面已经被拿去给别的进程用了。当你再次访问这些页面时,会触发缺页中断,内核会分配新的零页给你。我曾经在调试一个诡异的数据丢失问题时,发现就是 purged 标志被置位了,但用户进程没有检查返回值,直接用了旧数据。嗯,这个坑我替你们踩过了。
个人经验:我在项目中遇到过一个问题,一个 ashmem_area 被频繁地 pin 和 unpin,导致 ashmem_range 被不断地拆分和合并。最后 unpinned_list 上挂了上百个 range 节点,每次遍历都慢得要命。后来我建议上层应用尽量以较大的粒度进行 unpin 操作,减少 range 的数量,性能提升很明显。
4.3 两个结构体如何协同工作?
光看定义可能有点抽象,我们用一个实际场景串一下。
假设你创建了一个4页大小的Ashmem区域:
- 内核分配一个
ashmem_area,size = 4 * PAGE_SIZE,unpinned_list为空。 - 你调用
mmap映射了全部4页,此时所有页面都是pinned状态,unpinned_list依然为空。 - 你调用
ASHMEM_UNPIN释放第1页到第3页(共3页)。内核会创建一个ashmem_range,pgstart = 1,pgend = 3,然后把它挂到unpinned_list上,同时也挂到全局LRU链表上。 - 系统内存紧张,内核的回收线程遍历LRU链表,发现了这个
range。它把purged置为1,然后回收了第1到第3页的物理内存。 - 你再次访问第2页,触发缺页中断。内核发现这个页面属于一个
ashmem_range且purged = 1,于是分配一个新的零页给你。嗯,数据已经丢了,但进程不会崩溃。
你看,整个过程就是 ashmem_area 和 ashmem_range 在配合演戏。一个负责全局信息,一个负责局部状态。
注意事项:ashmem_range 的 pgstart 和 pgend 是页号,不是字节偏移。如果你在用户空间传的是字节偏移,记得先做 offset >> PAGE_SHIFT 转换。我曾经见过一个同事直接拿字节偏移当页号传进去,结果内核把整个内存都搞乱了。
4.4 小结
今天我们只看了两个结构体,但它们是整个Ashmem驱动的基石。
- ashmem_area:一片共享内存的元数据,包含名字、大小、权限、影子文件,以及未固定页面的链表。
- ashmem_range:一段连续页面的状态描述,包含页范围、所属区域、LRU节点、是否被回收。
下一节,我们会深入 ashmem_ioctl 函数,看看 PIN 和 UNPIN 到底是怎么操作这些数据结构的。说实话,那部分代码才是真正体现设计智慧的地方。
好,今天就到这里。记住这两个结构体,后面我们会反复用到它们。