4、Ashmem内核驱动源码分析(一):核心数据结构(ashmem_area, ashmem_range)

好,我们正式开始啃内核源码了。

说实话,很多做Android应用层开发的朋友,一听到「内核驱动」四个字就头大。我当年也一样,总觉得那是另一个世界的东西。但后来我慢慢发现,其实内核里的数据结构设计得特别精巧,你把它拆开来看,每个字段都有它的道理。

今天我们就聚焦在Ashmem驱动的两个核心结构体上:ashmem_areaashmem_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_PINASHMEM_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_areaunpinned_list 上。一个 ashmem_area 可以有多个 ashmem_range,它们通过这个链表串在一起。我刚开始看的时候,总觉得 lruunpinned 这两个链表有点重复,后来才明白:unpinned 是按区域组织的,lru 是全局的,两者维度不同。

asma:反向指针,指向这个 range 所属的 ashmem_area。这个设计很常见,方便从 range 回溯到它的「户口本」。

pgstartpgend:这两个字段定义了页面的范围。注意,pgend 是包含的。也就是说,如果 pgstart = 0pgend = 3,那么它覆盖了第0、1、2、3页,一共4页。这个设计跟Linux内核里很多区间表示法一致(比如 vm_area_structvm_startvm_end,但那个是 end 不包含)。

purged:这是一个布尔标志,表示这段页面是否已经被内核回收了。如果 purged = 1,说明这些页面已经被拿去给别的进程用了。当你再次访问这些页面时,会触发缺页中断,内核会分配新的零页给你。我曾经在调试一个诡异的数据丢失问题时,发现就是 purged 标志被置位了,但用户进程没有检查返回值,直接用了旧数据。嗯,这个坑我替你们踩过了。

个人经验:我在项目中遇到过一个问题,一个 ashmem_area 被频繁地 pinunpin,导致 ashmem_range 被不断地拆分和合并。最后 unpinned_list 上挂了上百个 range 节点,每次遍历都慢得要命。后来我建议上层应用尽量以较大的粒度进行 unpin 操作,减少 range 的数量,性能提升很明显。

4.3 两个结构体如何协同工作?

光看定义可能有点抽象,我们用一个实际场景串一下。

假设你创建了一个4页大小的Ashmem区域:

  1. 内核分配一个 ashmem_areasize = 4 * PAGE_SIZEunpinned_list 为空。
  2. 你调用 mmap 映射了全部4页,此时所有页面都是 pinned 状态,unpinned_list 依然为空。
  3. 你调用 ASHMEM_UNPIN 释放第1页到第3页(共3页)。内核会创建一个 ashmem_rangepgstart = 1pgend = 3,然后把它挂到 unpinned_list 上,同时也挂到全局LRU链表上。
  4. 系统内存紧张,内核的回收线程遍历LRU链表,发现了这个 range。它把 purged 置为1,然后回收了第1到第3页的物理内存。
  5. 你再次访问第2页,触发缺页中断。内核发现这个页面属于一个 ashmem_rangepurged = 1,于是分配一个新的零页给你。嗯,数据已经丢了,但进程不会崩溃。

你看,整个过程就是 ashmem_areaashmem_range 在配合演戏。一个负责全局信息,一个负责局部状态。

注意事项ashmem_rangepgstartpgend 是页号,不是字节偏移。如果你在用户空间传的是字节偏移,记得先做 offset >> PAGE_SHIFT 转换。我曾经见过一个同事直接拿字节偏移当页号传进去,结果内核把整个内存都搞乱了。

4.4 小结

今天我们只看了两个结构体,但它们是整个Ashmem驱动的基石。

  • ashmem_area:一片共享内存的元数据,包含名字、大小、权限、影子文件,以及未固定页面的链表。
  • ashmem_range:一段连续页面的状态描述,包含页范围、所属区域、LRU节点、是否被回收。

下一节,我们会深入 ashmem_ioctl 函数,看看 PINUNPIN 到底是怎么操作这些数据结构的。说实话,那部分代码才是真正体现设计智慧的地方。

好,今天就到这里。记住这两个结构体,后面我们会反复用到它们。