三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Linux内核模块Makefile深度解析:从基础语法到企业级工程实践

Linux内核模块Makefile深度解析:从基础语法到企业级工程实践

1. 从一次内核模块编译失败说起

那天下午,我正试图将一个老旧的、用于特殊硬件驱动的内核模块移植到一台新部署的服务器上。服务器跑的是最新的稳定版内核,我自信满满地复制了源码目录,习惯性地敲下make,然后……屏幕上就堆满了红色的错误信息。Makefile: No such file or directoryKbuild相关变量未定义、找不到内核头文件路径。那一刻我意识到,问题不在代码逻辑,而在于那个我平时很少深究的Makefile。对于内核模块开发,尤其是需要跨版本、跨环境编译时,一个正确且健壮的Makefile就是通往成功的钥匙。它不仅仅是告诉make工具如何编译,更是与庞大而复杂的内核构建系统(Kbuild)进行对话的协议。很多人觉得照着模板改改obj-m就行,但真遇到环境差异、依赖复杂或者需要条件编译时,模板就不够用了。今天,我们就来彻底拆解 Linux 内核模块的Makefile,弄懂每一行背后的逻辑,让你不仅能写出能用的Makefile,更能写出在任何环境下都稳定可靠的Makefile

2. 内核模块 Makefile 的核心骨架:不止于 obj-m

一个最简单的内核模块Makefile可能只有一两行,但这背后是 Kbuild 系统在默默承担了大量工作。我们先从最基础的开始,理解每个核心变量和指令的职责。

2.1 基石:obj-m 与 <module_name>.o

obj-m这个变量是 Kbuild 系统的“总开关”,它告诉系统:“嘿,这里有一个或多个模块需要你帮忙构建。”它的值是一个或多个目标文件(.o)的列表,但请注意,这里的.o文件对应的是模块的最终名称

假设你的模块源文件是hello.c,你希望编译出的模块叫hello.ko。那么,最直接的写法是:

obj-m := hello.o

Kbuild 看到这行,就会去寻找hello.c(或hello.S)文件,将其编译成hello.o,最后链接成hello.ko。这里有一个关键点:hello.o这个目标名,与最终模块名hello.ko是直接关联的,但它不一定需要有一个同名的.c文件。这引出了多文件模块的写法。

如果你的模块由main.chelper.cio.c三个源文件组成,模块名想叫complex.ko,那么Makefile应该这样写:

obj-m := complex.o complex-objs := main.o helper.o io.o

第一行声明要构建complex.ko模块(对应complex.o)。第二行则定义了complex.o这个“复合目标”是由哪些“零件”(即其他的.o文件)链接而成的。Kbuild 会分别编译main.chelper.cio.c生成对应的.o文件,然后将它们链接成complex.o,最终生成complex.ko<module_name>-objs这个变量是连接多文件模块的桥梁。

注意:在complex-objs列表中,.o文件名必须与.c源文件名严格对应(不包括路径)。Kbuild 会根据这个列表去查找源文件。

2.2 指向内核构建目录:KERNELDIR 的智慧

这是新手最容易踩坑的地方之一。模块的编译强烈依赖于当前运行内核的配置和头文件。因此,你的Makefile必须知道内核源代码树在哪里。通常通过KERNELDIR变量来指定。

KERNELDIR ?= /lib/modules/$(shell uname -r)/build

这行代码做了几件事:

  1. $(shell uname -r):执行 shell 命令,获取当前正在运行的内核版本号,例如5.15.0-91-generic
  2. /lib/modules/$(shell uname -r)/build:这是一个标准的符号链接,指向当前运行内核对应的源代码构建目录。对于大多数发行版,安装linux-headers包后就会创建此链接。
  3. ?=:这是 Makefile 的条件赋值运算符。意思是,如果KERNELDIR在命令行或环境中没有被预先定义,则采用等号后面的值。这为用户提供了灵活性,比如你想针对另一个内核版本进行编译,可以在命令行输入make KERNELDIR=/path/to/other/kernel

为什么不用绝对路径硬编码?因为你的开发环境(比如 Ubuntu 22.04)和部署环境(比如 CentOS 8)的内核头文件路径可能不同,甚至同一台机器上不同内核版本路径也不同。使用uname -r?=是最具可移植性的做法。

2.3 编译命令的集大成者:make -C 与 M=

模块的编译不是在你的源码目录里直接调用gcc,而是“委托”给内核的构建系统。这是通过make-CM=参数实现的。

default: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules
  • $(MAKE):这是一个 Makefile 内建变量,代表make程序本身。使用它而不是直接写make是为了兼容那些make程序可能叫gmake的环境。
  • -C $(KERNELDIR)-C选项告诉make:“先切换(Change)到$(KERNELDIR)目录,然后读取那里的Makefile(即内核顶层的 Makefile)并开始执行。”
  • M=$(PWD):这是最关键的一步。M是一个传递给内核顶层 Makefile 的变量。它的值是当前模块源码的绝对路径$(PWD)是 shell 变量,代表当前工作目录)。内核的 Kbuild 系统看到M=参数,就会知道:“哦,这次编译的目标不是内核本身,而是位于M指定目录下的外部模块。” 然后 Kbuild 会“跳转”到你的模块目录,根据你这里的Makefile(特别是obj-m)来指导编译,但编译过程本身(如调用编译器、链接器、应用内核的编译标志)仍由内核构建系统掌控。
  • modules:这是传递给内核 Makefile 的终极目标,意思是“请构建模块”。

这个命令的精妙之处在于职责分离:你的Makefile只负责声明模块的构成(obj-m,xxx-objs),而复杂的编译器标志、内核头文件路径、依赖关系生成(.mod.c.mod.o)等脏活累活,全部交给专业的、与内核版本严格匹配的 Kbuild 系统去处理。这保证了模块与内核的 ABI(应用程序二进制接口)兼容性。

2.4 清理工作:独立的 clean 目标

一个完整的Makefile必须有清理功能:

clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

这个目标同样委托给 Kbuild 系统执行清理。它会删除所有编译生成的文件:.o.ko.mod.c.mod.o.order.symvers以及一些临时文件。保持源码目录的整洁。

将以上部分组合起来,就得到了一个经典、健壮的基础Makefile模板:

obj-m := hello.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) default: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

3. 进阶配置:应对复杂模块与特殊需求

基础模板能解决80%的问题,但当你面对一个真实世界的驱动或内核组件时,往往需要更多控制。下面这些变量和技巧能让你的Makefile更强大。

3.1 精细化控制编译单元:xxx-y 与 xxx-m

之前我们用xxx-objs来链接多个源文件成一个模块。但有时,一个目录下的代码可能一部分编译进模块(xxx.ko),另一部分编译进内核(直接 built-in)。或者,你的模块需要链接一些由 Kbuild 根据配置生成的中间目标文件。这时就需要xxx-yxxx-m

  • xxx-y:列出所有需要被编译并链接进xxx.o的目标文件(.o)。对于模块,这通常就是xxx-objs的同义词,但语义更精确。在 Kbuild 中,-y后缀的变量通常表示“是”(Yes)要包含的对象。
  • xxx-m:列出所有需要被编译成可加载模块的目标文件。在复杂的子目录结构中,顶层Makefile可能用obj-m指定一个目录,然后在该目录的Makefile中用xxx-m来指定具体的模块目标。

例如,一个子目录Makefile可能这样写:

obj-m := foo.o bar.o foo-y := foo-main.o foo-helper.o bar-y := bar-core.o bar-io.o

这表示在当前目录下,要生成foo.kobar.ko两个模块,并分别指定了它们的构成。

3.2 传递自定义编译标志:ccflags-y, asflags-y, ldflags-y

默认情况下,模块使用内核定义的全局编译标志(如-Wall-O2-g(如果开启了调试))。但你的模块可能需要特殊的编译选项。

  • ccflags-y:传递给 C 编译器的额外标志。例如,你想关闭某个特定警告,或者添加包含路径:
    ccflags-y := -Wno-unused-function -I$(src)/include
    这里的$(src)是一个 Kbuild 变量,它被扩展为当前Makefile所在的目录(即模块源码目录)。使用$(src)而不是相对路径,可以保证无论从哪个目录调用make,路径都是正确的。
  • asflags-y:传递给汇编器(assembler)的额外标志。
  • ldflags-y:传递给链接器(linker)的额外标志(用于模块的最终链接阶段)。这个用得相对较少。

一个常见场景:你的模块代码里用了__attribute__((packed))或者一些特殊的 GCC 扩展,可能会触发-Waddress-of-packed-member等警告。你可以用ccflags-y += -Wno-address-of-packed-member来局部抑制它,而不是修改全局内核编译选项。

3.3 处理头文件依赖:指定包含路径

如果你的模块源码目录下有自定义的头文件(比如my_module.h),并且它被多个.c文件包含,你不需要在ccflags-y里加-I.,因为当前目录默认就在搜索路径中。但是,如果你的头文件在一个子目录include/下,你就需要:

ccflags-y += -I$(src)/include

或者,如果头文件位于内核源码树的其他位置(这通常不是好做法,因为破坏了模块的独立性),你可以使用内核的-I路径,但更推荐的做法是将必要的头文件复制到你的模块目录中,或者通过Kbuild系统提供的头文件导出机制(这涉及内核配置,更复杂)。

3.4 条件编译:根据内核版本或配置做选择

模块可能需要兼容不同版本的内核,因为 API 可能会变。你不能用 C 语言中的#ifdef来判断内核版本,但可以在Makefile中根据KERNELDIR推断。

一种常见模式是,检查内核源码树中某个特定头文件或配置的存在性,来决定使用哪一套代码。这通常需要结合shell命令和ifeq条件语句。不过,更优雅和常见的做法是在.c源文件中,使用#include <linux/version.h>LINUX_VERSION_CODEKERNEL_VERSION()宏来做条件编译。Makefile层面的条件编译更多用于决定是否包含某个源文件。

例如,一个驱动需要为内核版本 >= 5.0 使用新的 DMA API:

# 在 Makefile 中获取内核版本号(近似方法) KERNELRELEASE ?= $(shell make -s -C $(KERNELDIR) kernelrelease 2>/dev/null || uname -r) # 将版本号转换为可比较的数字(简化版,实际更复杂) # 这里只是一个思路演示,实践中版本判断多在C代码中进行 ifeq ($(shell [ $(KERNELRELEASE) \>= "5.0.0" ] && echo 1), 1) ccflags-y += -DUSE_NEW_DMA_API endif

注意:上述版本判断方法比较粗糙且依赖make kernelrelease的输出格式。生产环境中,复杂的版本适配逻辑强烈建议写在 C 代码中,利用LINUX_VERSION_CODE进行判断,这样更准确、更可维护。Makefile中的条件编译更适合开关整个源文件或大的功能模块。

4. 实战排坑:那些 Makefile 不会告诉你的秘密

读懂了语法,不代表就能一帆风顺。下面是我在多年内核模块开发中,在Makefile上踩过或见别人踩过的坑。这些经验往往比官方文档更有用。

4.1 路径之殇:绝对路径 vs 相对路径,以及 $(src) 与 $(obj)

这是导致“No such file or directory”错误的罪魁祸首之一。记住以下黄金法则:

  • Makefile中,为属于模块本身的文件(源文件、私有头文件)指定路径时,总是使用$(src)
  • $(src)指向Makefile文件所在的目录(即源码目录)。
  • $(obj)指向输出文件(.o.ko)所在的目录。对于简单的模块编译,$(obj)通常就是当前目录.,但在更复杂的递归构建中,它可能不同。

举例:

# 正确:无论从哪里调用make,都能找到头文件 ccflags-y += -I$(src)/include # 危险:如果从其他目录(如上层目录)调用 make,可能会找不到 ccflags-y += -I./include # 在定义目标文件依赖时(复杂情况) my-module-objs := $(obj)/main.o $(src)/helper.o # 通常不需要这样,Kbuild会自动处理

对于绝大多数单目录模块项目,你只需要在指定自定义头文件路径时使用$(src),其他地方 Kbuild 会自动处理好路径。

4.2 模块依赖与符号导出:Module.symvers 的传递

如果你的模块 A 依赖于模块 B 导出的函数或变量(使用EXPORT_SYMBOL()),那么在编译模块 A 时,必须让编译器知道这些符号的存在和类型,否则会报“未定义的引用”错误。这需要模块 B 的Module.symvers文件。

Module.symvers文件是在编译模块时生成的,它记录了该模块导出和需要的所有内核符号及其 CRC 校验值(用于版本检查)。为了让模块 A 能成功编译并链接到模块 B 的符号,你需要:

  1. 先编译模块 B,生成Module.symvers
  2. 在编译模块 A 时,将模块 B 的Module.symvers文件复制到模块 A 的源码目录,或者通过KBUILD_EXTRA_SYMBOLS变量指定其路径。

Makefile中的操作

# 假设模块 B 的 Module.symvers 位于 ../module_b/ 目录 KBUILD_EXTRA_SYMBOLS := $(shell pwd)/../module_b/Module.symvers # 或者,更常见的做法是在编译命令前复制 prepare-symvers: cp ../module_b/Module.symvers . default: prepare-symvers $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

重要:直接修改Module.symvers或伪造符号是极其危险的,会导致内核崩溃。模块间的依赖关系应该通过内核的导出机制自然建立,Makefile只是帮助传递符号信息文件。

4.3 调试信息的生成与控制

默认情况下,如果内核构建时开启了CONFIG_DEBUG_INFO(发行版内核通常默认关闭,自己编译的内核可能开启),模块也会附带调试信息(-g标志),这使得.ko文件非常大。对于生产环境,你可能想剥离调试信息。

  • 在模块编译后手动剥离
    strip --strip-debug hello.ko
    这不会影响模块功能,只会显著减小文件大小。
  • Makefile中控制:虽然不能直接覆盖内核的-g标志,但你可以通过修改传递给最终链接的 flags 来影响。不过,更常见的做法是直接编译一个不包含调试信息的内核(CONFIG_DEBUG_INFO=n)来编译模块。

调试模块时,你可能需要CONFIG_DEBUG_INFO=y来获得详细的符号信息,以便使用gdbkgdb。这时,巨大的ko文件就是必要的代价。

4.4 交叉编译:为不同架构编译模块

为 ARM、MIPS 等嵌入式设备编译模块,需要交叉编译工具链。这主要通过覆盖KERNELDIRARCHCROSS_COMPILE环境变量来实现。

假设你的内核源码在/opt/linux-arm/,交叉编译工具链前缀是arm-linux-gnueabihf-

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNELDIR=/opt/linux-arm M=$(pwd) modules

在你的Makefile里,通常不需要硬编码这些,而是允许从外部传入:

ARCH ?= $(shell uname -m | sed -e s/i.86/x86/ -e s/x86_64/x86/ -e s/arm.*/arm/ -e s/aarch64.*/arm64/) CROSS_COMPILE ?= KERNELDIR ?= /lib/modules/$(shell uname -r)/build default: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNELDIR) M=$(PWD) modules

这里ARCH的自动检测逻辑很简单,对于交叉编译场景,你需要在命令行显式指定ARCH=armCROSS_COMPILE

关键点KERNELDIR必须指向为目标架构配置和编译好的内核源码树,而不仅仅是头文件。因为构建模块需要目标架构的内核配置文件(.config)和一系列构建时生成的头文件。

5. 从模板到工程:一个企业级模块的 Makefile 示例

让我们看一个更贴近真实项目的例子。这个假想的模块叫my_netdev,它是一个网络设备驱动,包含多个子目录,需要条件编译某些调试功能,并且依赖另一个内部模块导出的符号。

项目结构如下:

my_netdev/ ├── Makefile # 顶层 Makefile ├── common/ │ ├── netdev_core.c │ ├── netdev_core.h │ └── Makefile # 子目录 Makefile ├── hw/ │ ├── hw_interface.c │ ├── hw_abstraction.c │ └── Makefile ├── include/ # 模块私有头文件 │ └── my_netdev.h └── debug/ # 调试代码,可能不编译 ├── debugfs.c └── Makefile

顶层Makefile:

# 目标模块 obj-m := my_netdev.o # 复合模块,由多个子目录的代码构成 my_netdev-y := \ common/netdev_core.o \ hw/hw_interface.o \ hw/hw_abstraction.o # 条件编译:如果定义了 CONFIG_MY_NETDEV_DEBUG,则加入调试代码 ifeq ($(CONFIG_MY_NETDEV_DEBUG), y) my_netdev-y += debug/debugfs.o endif # 指定模块私有头文件路径 ccflags-y := -I$(src)/include # 处理外部符号依赖。假设我们依赖另一个模块 `core_lib.ko` 导出的符号。 # 首先检查其 Module.symvers 是否存在。 LIB_SYMVERS := ../core_lib/Module.symvers ifneq ($(wildcard $(LIB_SYMVERS)),) KBUILD_EXTRA_SYMBOLS := $(realpath $(LIB_SYMVERS)) $(info Found external symbols at $(KBUILD_EXTRA_SYMBOLS)) else $(warning Module.symvers for core_lib not found. Linking may fail if symbols are needed.) endif # 内核目录和架构配置,支持外部覆盖 ARCH ?= x86_64 CROSS_COMPILE ?= KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) # 递归进入子目录构建对象文件 # 注意:Kbuild 会自动处理子目录,只要子目录有 Makefile 并正确添加了 obj-y 或 obj-m。 # 这里 my_netdev-y 列表中的 `common/netdev_core.o` 会促使 Kbuild 进入 `common/` 目录寻找规则来生成 `netdev_core.o`。 default: @echo "Building my_netdev module for ARCH=$(ARCH)..." $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNELDIR) M=$(PWD) clean @find . -name '*.o' -o -name '*.ko' -o -name '.*.cmd' -o -name '*.mod.c' -o -name '*.mod.o' \ -o -name 'modules.order' -o -name 'Module.symvers' -o -name '.tmp_versions' \ -type f -delete 2>/dev/null || true @echo "Cleanup done." # 一个方便的目标,用于在编译前确保依赖的符号文件存在(可选) fetch-symvers: @if [ ! -f "$(LIB_SYMVERS)" ]; then \ echo "ERROR: $(LIB_SYMVERS) is required but not found."; \ echo "Please build the core_lib module first."; \ exit 1; \ fi # 将 fetch-symvers 作为默认目标的依赖(谨慎使用,可能不总是需要) # default: fetch-symvers # $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNELDIR) M=$(PWD) modules

子目录common/Makefile:

# 这个 Makefile 告诉 Kbuild 如何生成上一层需要的 netdev_core.o # 因为顶层 my_netdev-y 包含了 common/netdev_core.o,Kbuild 会进入本目录 # 本目录需要生成 netdev_core.o obj-y := netdev_core.o # 如果 netdev_core.o 由多个文件组成(这里不是),可以写 netdev_core-objs := file1.o file2.o

hw/Makefiledebug/Makefile类似,分别列出需要生成的.o文件。

这个示例揭示的几个关键点

  1. 递归构建:顶层Makefile通过my_netdev-y列表引用子目录下的.o文件(如common/netdev_core.o)。Kbuild 看到这种带有路径的目标,会自动进入相应子目录,并执行该子目录下的Makefile来生成对应的.o文件。这是一种简洁的递归构建声明。
  2. 条件编译:使用ifeq根据一个变量(这里模拟了CONFIG_*)来决定是否将debug/debugfs.o加入构建列表。在实际项目中,这个变量可能来自外部环境(make CONFIG_MY_NETDEV_DEBUG=y)或者通过读取内核的.config文件来设置(更复杂)。
  3. 外部符号依赖管理:通过KBUILD_EXTRA_SYMBOLS变量和$(wildcard)函数,优雅地处理可选的外部模块依赖。如果依赖不存在,给出警告而非直接报错,因为某些构建可能不需要这些符号(比如编译部分子功能)。
  4. 强化的清理clean目标除了调用 Kbuild 的清理,还使用find命令进行更彻底的清理,确保没有遗留的中间文件。这在切换不同配置或架构时非常有用。
  5. 信息输出:使用$(info ...)$(warning ...)在构建过程中给出提示信息,改善用户体验。

6. 超越 Makefile:Kbuild 系统探微与最佳实践

理解了Makefile的写法,其实只是理解了 Kbuild 系统的“用户接口”。要真正游刃有余,还需要知道一些背后的原理和约定俗成的实践。

6.1 Kbuild 是如何工作的:一个简化的视角

当你执行make -C $(KERNELDIR) M=$(PWD) modules时,发生了一系列事件:

  1. make进程切换到内核源码目录,加载顶级Makefile
  2. 顶级Makefile读取内核配置(.config),设置所有全局变量(如CCCFLAGSARCH)。
  3. 因为目标中包含modulesM被赋值,控制流转向scripts/Makefile.modpost等处理外部模块的脚本。
  4. Kbuild 系统“跳转”到M指定的目录(你的模块目录)。
  5. 它读取你目录下的Makefile(或Kbuild文件),获取obj-m等变量。
  6. 对于obj-m列表中的每个xxx.o,Kbuild 会:
    • 查找xxx-yxxx-objs来确定源文件。
    • 根据后缀(.c.S)调用相应的编译器或汇编器,生成.o文件。编译标志完全继承自内核的全局设置,并叠加你定义的ccflags-y等。
    • 为每个模块生成一个xxx.mod.c文件,其中包含了模块的元信息(如__this_module)。
    • 编译xxx.mod.cxxx.mod.o
    • xxx.oxxx.mod.o链接成xxx.ko
  7. 同时,生成modules.order(构建顺序)和Module.symvers(符号版本信息)。

6.2 Makefile 还是 Kbuild 文件?

你可能在内核源码树中看到过Kbuild文件。它的作用和Makefile完全相同。当两者同时存在时,Kbuild 系统会优先读取Kbuild文件。使用Kbuild文件是一种约定,通常用于区分仅由 Kbuild 系统使用的构建规则包含通用目标(如cleandefault)的Makefile。对于外部模块,使用Makefile更为常见和方便,因为它可以同时包含 Kbuild 规则和你自定义的clean等目标。

6.3 构建辅助目标:modules_install

除了modules,内核构建系统还支持modules_install目标。在你的模块Makefile中,可以添加:

install: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install

执行make install(通常需要 root 权限)会将编译好的.ko文件复制到/lib/modules/$(shell uname -r)/extra/目录下,并运行depmod更新模块依赖关系。这对于制作模块安装包非常有用。

6.4 保持 Makefile 的简洁与可移植性:几条铁律

  1. 绝不硬编码编译器或标志:不要在你的Makefile里写gcc-O2。全部交给 Kbuild。你的ccflags-y只用于添加,绝不用于覆盖。
  2. 使用?=赋予默认值:对KERNELDIRARCHCROSS_COMPILE等变量使用?=赋值,允许用户从命令行轻松覆盖。
  3. 清理目标要彻底:参考上面的例子,除了调用 Kbuild 的clean,自己也可以清理一些 Kbuild 可能漏掉的、项目特有的中间文件或备份文件。
  4. 处理错误和提示:使用$(warning)在依赖缺失时给出友好提示,而不是让构建直接以晦涩的错误失败。
  5. 版本控制友好:确保Makefile本身不包含机器特定的绝对路径或临时文件。生成的Module.symvers.ko等文件应该被加入.gitignore

内核模块的Makefile是你与 Linux 内核庞大构建体系之间的契约。写得好的Makefile,能让你的模块编译像内核原生组件一样顺畅;写得不好,则会带来无尽的、难以调试的构建错误。希望这篇深度解析,能让你下次面对内核模块编译时,多一份从容,少一个坑。记住,最好的学习方式仍然是阅读内核源码树下drivers/目录中那些经过千锤百炼的驱动Makefile,它们是最好的范本。

← 返回列表