「嵌入式结构体 + 反向父指针」设计模式

今天来总结一下C语言中常用的「嵌入式结构体 + 反向父指针」设计模式

以我自己写的代码为例子:

1
2
3
4
5
6
7
8
9
10
11
12
struct AbCanvasGTK {
struct GtkGfx* parent;/*父级绘图上下文*/
};

struct GtkGfx {
GtkWidget *area; /*绘图区域*/
cairo_t *cairo; /*Cairo绘图上下文*/
int vp_w, vp_h; /*视口宽度/高度*/

GHashTable* pix_cache; /* 图片缓存哈希表 */
struct AbCanvasGTK embedded_canvas; /*通过canvas.parent反向找到父结构体GtkGfx*/
};

上述代码在内存中的实际分布和关系如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
GtkGfx 对象整体地址:0x55a1b2c30000 ~ 0x55a1b2c30027 (40 字节)

[0x55a1b2c30000] area = 0x55a1b2c31000
[0x55a1b2c30008] cairo = 0x55a1b2c32000
[0x55a1b2c30010] vp_w = 800
[0x55a1b2c30014] vp_h = 600
[0x55a1b2c30018] pix_cache = 0x55a1b2c33000

[0x55a1b2c30020] s_canvas (AbCanvasGTK,共8字节)
↙ 这 8 字节的内容
[0x55a1b2c30020] parent = 0x55a1b2c30000
👆
这个值,正好等于外面 GtkGfx 的起始地址 BASE

大块内存包含小块内存,小块内存里存着大块内存的入口地址

这种操作:**「父结构体包含子结构体实体 + 子结构体存指针反向指向父」**的操作,不仅非常多,更是 C 语言大型项目(尤其是 Linux 内核、底层库、GUI、服务端程序)的经典标准设计范式,有专门的名字,叫 「嵌入式结构体 + 反向父指针」(Linux 内核还延伸出了更极致的 container_of 模式)

结构范式

先来将这这操作抽象成为一个统一的范式:

结构范式

1
2
3
4
5
6
7
8
9
10
11
12
13
// 子结构体:轻量级,只做功能入口,反向引用父
struct 子 {
struct 父* 父指针; // 核心:反向指向包含自己的父
};

// 父结构体:核心容器,管理所有资源,**实体包含**子
struct 父 {
// 各种核心资源:指针、数据、缓存...
struct 子 子实例; // 核心:实体嵌入,不是指针
};
// 内核范式(两种,和上述代码完全一样)
// 1. 和上述代码完全一样:子直接存父指针
// 2. 内核更常用:子不存指针,用 container_of 宏算父地址(省内存,作用相同)

目的:拿到轻量级的子对象,就能快速找到管理所有资源的父对象,同时通过「实体嵌入」实现内存一体化、生命周期统一

注意:以下示例为AI生成,没有做真正的源码追踪!

Linux 内核示例

Linux 内核:把这种模式用到极致,Linux 内核几乎处处都是这种设计,甚至为了优化,延伸出了 **container_of 宏 **(不额外存父指针,靠地址偏移直接找父,思想和上述代码代码完全一致),是内核模块化、面向对象设计的基础。

内核大概有两种范式:

  1. 一种是子直接存父指针
  2. 另一种内核更常用:子不存指针,用 container_of 宏算父地址(省内存,作用相同)

「反向找父」神器:container_of 宏

内核很少像上述代码一样直接存 parent 指针(为了省 8 字节),而是用 container_of,作用和上述代码 parent 完全一样,只是通过「子结构体的地址 + 子在父中的偏移」,直接算出父结构体的地址。

内核container_of定义(简化)

1
2
3
4
#define container_of(ptr, type, member) ({                \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type, member) ); \
})
  • ptr:子结构体的指针
  • type:父结构体类型
  • member:子结构体在父中的成员名
  • 作用:拿到子,就能找到父,和上述代码用 c->parent完全等价。

1. 内核双向链表 struct list_head

这是内核最基础的数据结构,所有内核链表都用它,完美匹配上述代码模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 子结构体:链表节点,极简,嵌入到任意父结构体
// 子结构体:极简链表节点,仅存前后指针
struct list_head {
struct list_head *next;
struct list_head *prev;
};

// 父结构体:比如进程描述符,核心资源,实体嵌入list_head
struct task_struct {
// 进程核心资源:PID、内存、文件、寄存器、状态...
// 父【实体嵌入】子结构体:链表节点
struct list_head tasks; // 实体嵌入链表节点
};

和上述代码代码的共性:

  • 父包含子实体task_struct 实体嵌入 list_head,同一块内存,同生同死。
  • 子反向找父:内核通过 container_of 宏,从 list_head 指针,直接计算出外层 task_struct 的地址(替代上述代码代码里直接存 parent,更省内存)。
  • 用途:内核通过链表节点,管理成千上万个进程,拿到节点就能找到完整的进程描述符。

实际用法

1
2
3
4
5
6
7
8
// 拿到一个链表节点(子),相当于上述代码拿到 AbCanvasGTK*
struct list_head *pos = ...;

// 用 container_of 找到父结构体(进程),相当于上述代码用 c->owner 找到 GtkGfx*
struct task_struct *proc = container_of(pos, struct task_struct, tasks);

// 然后就能访问父的所有资源,和上述代码用 parent->cr 拿画笔一样
printk("进程PID: %d\n", proc->pid);
  1. 用途

和上述代码用 AbCanvasGTK 作为绘图入口一样,内核用 list_head 作为链表操作的入口,只传递轻量的子节点,就能操作父的全部资源,全内核(进程、文件、设备、网络)都在用。

2. 内核设备模型 struct device

Linux 设备驱动的核心,和上述代码 GTK 绘图场景(资源管理 + 子对象入口)几乎一模一样:

1
2
3
4
5
6
7
8
9
10
11
12
// 子结构体:设备基础对象,抽象入口
struct device {
// 设备基础属性
struct device *parent; // 反向指针,指向父设备
// ...
};

// 父结构体:具体设备,比如PCI设备,管理硬件资源
struct pci_dev {
// PCI设备专属资源:BAR地址、中断、配置空间...
struct device dev; // 实体嵌入基础设备对象
};

和上述代码代码的共性:

  • 上层驱动只操作轻量的 struct device,和上述代码绘图时只操作 AbCanvasGTK 一致。
  • 通过 dev.parent 反向找到父设备,和上述代码通过 AbCanvasGTK.parent 找到 GtkGfx` 一致。
  • 资源全在父结构体,子结构体只做入口,解耦上层和底层。

3. PCI 设备模型 struct pci_dev + struct device(直接存父指针)

这个是和上述代码代码写法 100% 相同的例子:子结构体直接存父指针,用于「抽象接口 + 具体资源」,和上述代码「抽象画布 + 绘图资源上下文」是同一个设计目的。

  1. 内核源码(简化)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 子结构体:通用设备抽象层
// 对应上述代码:AbCanvasGTK(抽象绘图层)
struct device {
// 通用设备属性
const char *name;
// 【和上述代码完全一样】子结构体直接存父指针
struct device *parent; // 指向父设备,就是上述代码 owner
};

// 父结构体:具体的PCI设备,管理所有硬件资源
// 对应上述代码:GtkGfx(具体的GTK绘图资源)
struct pci_dev {
// PCI设备专属硬件资源
unsigned int vendor;
unsigned int device;
resource_t resource[6]; // IO/内存资源

// 父【实体嵌入】子结构体:通用抽象设备
struct device dev;
};
  1. 和上述代码代码一一映射(完全一致)
上述代码代码 内核 PCI 设备代码 完全一致的角色
AbCanvasGTK struct device 抽象层子结构体,对外提供统一接口
GtkGfx struct pci_dev 具体实现父结构体,管理所有底层资源
struct AbCanvasGTK { struct GtkGfx *owner; } struct device { struct device *parent; } 子直接存父指针,反向引用
g->s_canvas pdev->dev 父实体嵌入子,内存一体
  1. 实际用法(和上述代码点击按钮、绘图找上下文完全一样)
1
2
3
4
5
6
7
8
// 拿到抽象设备(子),相当于上述代码拿到 AbCanvasGTK*
struct device *dev = ...;

// 直接用父指针找到具体PCI设备(父),和上述代码用 c->owner 完全一样
struct pci_dev *pdev = container_of(dev, struct pci_dev, dev);

// 访问父的硬件资源,和上述代码用 owner->cr 画图画图一样
printk("PCI厂商ID: %x\n", pdev->vendor);
  1. 用途

和上述代码设计目的完全相同

  • 上层只操作抽象的 struct device(上述代码操作抽象画布),不用关心是 PCI、USB、串口设备。
  • 底层资源都在具体的 struct pci_dev 里(上述代码在 GtkGfx 里)。
  • 子的父指针,让上层能随时找到底层资源。

4. 网络子系统 struct socket + struct sock

上述代码代码是「应用层绘图接口 → GTK 底层实现」,内核网络是「用户态 Socket 接口 → 内核网络实现」,结构完全一致

  1. 内核源码(简化)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 子结构体:给用户态的抽象Socket接口
// 对应上述代码:AbCanvas(抽象绘图接口)
struct socket {
short type;
// 子存父指针,反向找内核网络资源
struct sock *sk; // 上述代码 owner
};

// 父结构体:内核网络资源,管理协议、缓存、连接
// 对应上述代码:GtkGfx
struct sock {
// 网络核心资源
struct sk_buff_head sk_receive_queue;
unsigned short sk_family;
// ... 大量网络资源

// 父实体嵌入子
struct socket socket;
};
  1. 核心逻辑

和上述代码一模一样:

  1. 用户 / 上层操作轻量的 struct socket(上述代码操作AbCanvasGTK)。
  2. struct sock管理所有网络资源(上述代码GtkGfx管理绘图资源)。
  3. 子通过sk指针找到父(上述代码通过`parent)。

5. 文件系统 struct inode

文件系统是内核的核心模块,同样用这种模式。

  1. 内核源码(简化)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 子结构体:文件地址空间抽象
// 对应上述代码:AbCanvasGTK
struct address_space {
// 反向指向父inode
struct inode *host; // 上述代码 owner
};

// 父结构体:索引节点,管理文件所有资源
// 对应上述代码:GtkGfx
struct inode {
umode_t i_mode;
uid_t i_uid;
// 文件数据、缓存、权限等资源

// 实体嵌入子结构体
struct address_space i_data;
};
  1. 逻辑
  • address_space是文件缓存的抽象入口。
  • inode管理文件所有资源。
  • 子通过host指针找到父,和上述代码parent完全一样。

文件系统 struct file

1
2
3
4
5
6
7
8
9
10
11
// 子结构体:文件抽象,给进程用的轻量入口
struct file {
struct file_operations *f_op;
struct inode *f_inode; // 反向指向索引节点(父级资源)
};

// 父结构体:索引节点,管理磁盘、文件数据核心资源
struct inode {
// 文件存储、权限、缓存等核心资源
struct file *i_file; // 关联文件对象
};

同样是资源放在父,子做入口,反向引用的逻辑。

内核示例总结

上述代码代码和内核代码的关系

GTK 绘图模块 Linux 内核对应模块 设计思想完全一致
抽象画布 AbCanvasGTK 链表节点list_head、抽象设备device、抽象 Socketsocket 轻量子结构体,对外提供统一接口
绘图资源上下文 GtkGfx 进程task_struct、PCI 设备pci_dev、网络sock 父结构体,管理所有底层资源
子结构体存owner指针 子存parent/sk/host指针,或container_of 子反向定位父,获取全部资源
父实体嵌入子s_canvas 父实体嵌入tasks/dev/socket 内存一体化,生命周期统一,避

其他C 项目示例

除了 Linux 内核,所有工业级 C 项目,只要涉及复杂资源管理、模块化、抽象接口,都会用这种模式,和上述代码 GTK 代码同源。

GLib 库

GTK 本身就是基于 GLib 开发的,GLib 的GObject 系统(GTK 控件的基础),核心就是这种模式:

1
2
3
4
5
6
7
8
9
10
11
12
// 子结构体:GObject,所有GTK控件的基础,轻量抽象
struct _GObject {
GTypeInstance instance;
guint ref_count;
GObject* parent; // 反向指向父对象
};

// 父结构体:比如GtkButton,具体控件,管理UI资源
struct _GtkButton {
GObject parent; // 实体嵌入基础GObject
// 按钮文字、样式、信号、布局等资源
};

上述代码 GTK 代码,本质是 GObject 模式的简化版

Nginx

Nginx 的核心连接、事件模型,大量使用嵌入式结构体 + 反向指针:

1
2
3
4
5
6
7
8
9
10
11
12
// 子结构体:事件对象,轻量级,事件入口
struct ngx_event_s {
ngx_uint_t events;
ngx_connection_t* conn; // 反向指向连接对象(父)
};

// 父结构体:连接对象,管理Socket、缓冲区、会话等核心资源
struct ngx_connection_s {
// Socket、读写缓存、状态、定时器等资源
ngx_event_t read; // 实体嵌入读事件
ngx_event_t write; // 实体嵌入写事件
};
  • 拿到事件 ngx_event_t,通过 conn 找到完整的连接资源,和上述代码拿到画布找到绘图上下文完全一致。

SQLite

SQLite 的存储、分页模块,核心结构也是如此:

1
2
3
4
5
6
7
8
9
10
11
// 子结构体:页面缓存入口,轻量
struct PgHdr {
Pager *pPager; // 反向指向Pager(父,管理磁盘分页)
// 页面基础信息
};

// 父结构体:Pager,管理数据库文件、缓存、磁盘IO核心资源
struct Pager {
// 文件句柄、缓存池、事务等资源
PgHdr pFirst; // 实体嵌入页面头节点
};

SDL

SDL 的绘图、窗口系统,和上述代码 GTK 绘图代码设计几乎一样:

1
2
3
4
5
6
7
8
9
10
// 子结构体:渲染纹理,轻量绘图入口
struct SDL_Texture {
SDL_Renderer* renderer; // 反向指向渲染器(父)
};

// 父结构体:渲染器,管理显卡、画布、窗口资源
struct SDL_Renderer {
// 显卡上下文、窗口、缓存等资源
SDL_Texture* textures; // 关联纹理对象
};

总结

「嵌入式结构体 + 反向父指针」设计模式解决了 C 语言没有面向对象、没有自动内存管理、没有继承的痛点

  1. C 语言实现「组合 / 复用」,模拟面向对象

C 没有类和继承,通过 「父结构体嵌入子结构体」,实现**「组合」**—— 把通用功能(画布、链表、事件、设备)封装成子结构体,复用到所有父结构体中,和面向对象的「继承 / 组合」效果一致。

  1. 解耦上层接口和底层资源,接口更简洁
  • 上层只需要传递轻量的子对象(上述代码 AbCanvasGTK、内核的 list_headNginxngx_event_t),不用传递臃肿的父对象。
  • 底层通过反向指针,随时拿到父对象的所有资源,上层简洁,底层强大
  1. 内存极致高效,生命周期绝对安全
  • 子结构体是实体嵌入,和父结构体共用同一块堆内存,不需要额外 malloc,减少内存碎片和分配开销。
  • 销毁父结构体时,子结构体自动同步销毁,不需要单独释放,彻底避免内存泄漏。
  1. 反向引用解决「由子寻父」的刚需

程序中,往往只能拿到功能入口的子对象(画布、事件、链表节点),但所有资源都在父对象里。


完!