📢 在面向对象编程中,封装和继承从来不是两件独立的事:封装的意义,很大程度上就是为了更好地继承。把几个类共有的属性和方法抽取出来,封装成一个基类,再通过继承让子类直接复用父类的成员,才能真正把代码复用做到最大化。
本文继续从 Linux 内核源码的角度,聊聊 C 语言里模拟"继承"的几种思路:私有指针、抽象类,以及接口。
一、私有指针
要用 OOP 的视角去理解内核源码,不妨把"继承"的定义放宽一些。除了内嵌结构体之外,C 语言还有别的方式可以模拟类的继承,私有指针就是其中之一。
我们可以把"用结构体类型定义不同的结构体变量"本身也看作一种继承:结构体类型相当于父类,各个结构体变量就是子类,而子类通过私有指针来扩展各自独有的属性和方法。
这种模拟继承的方式,特别适合父类和子类差别不大的场景。比如 Linux 内核中的网卡设备:不同厂商、不同速率、不同型号的网卡,读写流程基本一致,都是走标准的网络协议栈传输数据。真正有差异的部分,往往是 I/O 寄存器、I/O 内存地址、中断号这类硬件资源。
面对这种情况,没必要给每一款网卡都单独设计一个结构体。更合理的做法是:把网卡的公共属性抽取出来,构建一个通用的 net_device 结构体,再通过一个私有指针指向各款网卡独有的属性和方法。这样一来,代码复用率就能大幅提升。
以 Linux 内核中的 net_device 结构体为例:
struct net_device{
char name[IFNAMSIZ];
struct hlist_node name_hlist;
char *ifalias;
/* mid-layer private */
union{
void *ml_priv;
struct pcpu_lstats __percpu *lstats;
struct pcpu_sw_netstats __percpu *tstats;
struct pcpu_dstats __percpu *dstats;
struct pcpu_vstats __percpu *vstats;
};
在这个定义中,可以注意到一个私有指针成员:ml_priv。当我们用 net_device 类型去定义不同的变量以表示不同型号的网卡时,这个私有指针就会指向各网卡自身扩展出来的属性结构。
例如 bfin_can.c 中,bfin_can 这款网卡就自定义了一个结构体,用来保存自己的 I/O 内存地址、接收中断号、发送中断号等信息:
/*
* bfin can private data
*/
struct bfin_can_priv{
struct can_priv can; /* must be the first member */
struct net_device *dev;
void __iomem *membase;
int rx_irq;
int tx_irq;
int err_irq;
unsigned short *pin_list;
};
每个用 net_device 类型定义的结构体变量,都可以视为基类 net_device 的一个子类。各子类通过自定义的结构体(比如 bfin_can_priv)在父类的基础上扩展独有属性或方法,然后把结构体变量里的 ml_priv 私有指针指向它们即可。
二、抽象类
含有纯虚函数的类,通常叫抽象类。抽象类不能实例化——实例化也没有意义,比如 animal 类,它存在的目的就是被子类继承。
抽象类的核心作用是分层,也就是实现抽象层。当父类和子类之间差异过大、很难直接靠继承来复用代码时,比如"生物类"和"狗类",就可以在它们中间加一个 animal 抽象类过渡。抽象类主要负责管理父子类之间的继承关系,通过分层来提升代码复用性。
在设备模型里,device 类就扮演了这样的角色:它夹在 kobj 类和 usb_device 类之间,借助这一层抽象,代码复用变得更从容。
三、接口
什么是接口?一个类支持的行为和方法,就是它的接口。类封装好以后,对外暴露 API 函数供其他对象调用,这些 API 就是接口。不同对象通过接口通信,不需要关心彼此的内部实现——只要接口不变,内部实现怎么改都不会影响调用方。
接口就像山地车的手刹:不管是油刹还是碟刹,骑手根本不需要关心具体结构,只要捏动手刹这个"接口",车能停下来就行。
接口和抽象类有不少相似之处:两者都不能实例化对象,也都服务于多态。区别在于,接口封装的是方法,不允许包含数据成员;而抽象类可以包含数据成员。此外,抽象类一般是被子类继承,接口则是要被类实现。
我们也可以把接口看成一种"退化版的多重继承":接口简化了继承关系,解决了多重继承带来的冲突,能把两个原本不相关的类建立关联。
在分析 Linux 内核源码时,如果多重继承让问题变得过于复杂,不妨化繁为简——把多重继承降级为单继承,另一路继承用接口替代。通过这种方式,可以把复杂问题拆解降维。
以 USB 网卡驱动为例:它既涉及 USB 子系统,又涉及网络驱动模块,混在一起分析相当吃力。如果借助接口,把多重继承改为单继承,整个驱动的架构和分层关系就会清爽很多。

再看 Linux 内核中的 RTL8150 USB 网卡驱动源码。我们可以把以 usb_device 为基类的这条继承分支当成一个接口来处理:USB 网卡通过 usb_device 封装好的接口,完成设备插拔检测、底层数据传输等功能。
而以 net_device 为基类的那一路,就当作普通的单继承关系看待:USB 网卡以 kobject 为基类,逐级继承,每一级基类都扩展了自己的方法或封装了接口,供子类 RTL8150 调用。
具体来看,RTL8150 网卡通过调用祖父类 kobject 的方法 kobject_add() 将设备注册到系统;通过 device 类的 probe() 完成驱动与设备的匹配,以及设备的 suspend、shutdown 等功能;再通过 net_device 类实现的 open、xmit、stop 等接口,完成网络设备的打开、数据发送和数据停止发送。
这几种 C 语言模拟继承的手法,贯穿了 Linux 内核的设备驱动模型设计。理解它们之后,再去看 USB、网络这类子系统源码,会发现很多看似复杂的结构关系,其实都只是面向对象思想在 C 语言里的投影。