ceph的块存储如何骗过服务器,让服务器把它当做真实的硬盘
ceph的块存储,就是一块远程网络硬盘。操作系统为啥会读写这块假硬盘呢?
一台服务器要使用CEPH提供的块存储,也是需要ceph的驱动软件来和ceph通讯吧
是的,你的理解完全正确。一台服务器想要使用 Ceph 提供的块存储,必须通过相应的“驱动软件”或客户端组件来和 Ceph 集群进行通讯。
在 Ceph 的块存储(RBD, RADOS Block Device)体系中,这种“驱动软件”主要分为两种实现路径:
1. 内核态驱动(KRBD)
这是最常见、最接近传统硬盘使用习惯的方式。
- 原理:它依赖 Linux 内核自带的
rbd模块(rbd.ko)。 - 使用方式:你需要在服务器上安装 Ceph 的客户端软件(如
ceph-common包),然后使用rbd map命令将 Ceph 集群里的一个块设备镜像(Image)映射到本地。 - 效果:映射成功后,你的服务器本地会出现一个标准的块设备文件(例如
/dev/rbd0)。你可以像对待一块普通的物理硬盘一样,对它进行分区、格式化(如 mkfs.ext4)并挂载到某个目录下使用。
2. 用户态库(librbd)
这种方式不通过内核,而是直接在应用程序层面调用 Ceph 提供的动态链接库。
- 原理:应用程序直接链接并调用 Ceph 提供的
librbd和librados库文件来读写数据。 - 使用方式:这种方式不会在操作系统里生成
/dev/rbd0这样的设备文件,而是由应用程序直接对接。 - 典型场景:主要被 KVM/QEMU 虚拟机或 OpenStack 等云平台使用。比如,当你启动一台使用 Ceph 作为后端的虚拟机时,宿主机上的 QEMU 进程就是通过
librbd直接与 Ceph 集群通讯,为虚拟机提供磁盘空间的。
总结来说:
如果你是想把 Ceph 块存储当成一块普通的远程硬盘挂载到 Linux 服务器上,你需要安装ceph-common客户端,并依赖内核的KRBD 驱动;如果你是开发应用程序或者配置 KVM 虚拟机来直接使用 Ceph 存储,则是通过librbd 库来实现通讯的。
自己实现一个最简的远程磁盘及协议,要怎么做
自己实现一个最简的远程磁盘系统,核心原理其实就是**“网络IO”与“本地磁盘IO”的桥接**。简单来说,就是把客户端(Client)对磁盘的读写请求,通过网络发给服务端(Server),服务端在本地执行真实的读写操作后,再把结果返回给客户端。
我们可以把这个系统拆解为以下几个核心步骤来实现:
1. 通信层:建立网络通道
远程磁盘通常追求较高的读写性能,因此底层通信协议一般首选TCP而不是 UDP(TCP 能保证数据可靠传输,避免磁盘数据丢包损坏)。
- 服务端:启动一个 TCP 监听端口(比如 8888),等待客户端连接。
- 客户端:通过 IP 和端口连接到服务端,建立一个稳定的长连接。
2. 协议层:设计“读写指令”
为了让服务端听懂客户端要干什么,我们需要自定义一个简单的通信协议(报文格式)。一个最简的远程磁盘协议,只需要包含以下几种核心指令(可以用一个字节来表示操作类型opcode):
- READ(读,比如 opcode = 0x01):客户端告诉服务端“我要读数据”。
- WRITE(写,比如 opcode = 0x02):客户端告诉服务端“我要写数据”。
- ACK/RESPONSE(应答):服务端处理完后,返回成功状态或读取到的数据。
请求报文的结构可以设计得非常简单,例如:[操作码(1字节)] + [偏移量 offset(8字节)] + [数据长度 length(4字节)] + [实际数据(写操作时附带)]
(注:在网络传输多字节整数时,记得使用大端序(htons/htonl)来保证不同架构机器间的兼容性。)
3. 服务端实现:本地磁盘的“代笔者”
服务端的逻辑非常直观,它本质上就是一个拿着“后端真实磁盘文件”的打工人:
- 在启动时,服务端在本地创建一个巨大的空白文件(比如
disk.img,用dd命令生成),这个文件就代表一块“虚拟硬盘”。 - 接收到客户端的
READ请求后,解析出offset(偏移量)和length(长度),使用系统调用(如seek和read)去读取disk.img对应位置的数据,然后通过网络发回给客户端。 - 接收到
WRITE请求后,解析出offset、length和附带的“实际数据”,使用seek和write把数据精准地写入disk.img的对应位置。
4. 客户端实现:虚拟块设备驱动
这是最难、也是最核心的一步。客户端不能只是一个普通的发请求的程序,它必须让操作系统认为**“我插上了一块新硬盘”**。
- Linux 实现思路:你需要编写一个 Linux内核模块,利用 Linux 内核提供的
ioctl接口。当客户端程序通过网络收到服务端发来的数据时,通过ioctl把这些数据“喂”给内核的块设备层。这样,Linux 系统里就会多出一个/dev/remote_disk0的设备文件。 - 使用效果:一旦这个设备文件出现,你就可以像操作普通硬盘一样,对它执行
mkfs.ext4格式化,然后mount挂载到某个目录,随意读写文件。
5. 进阶完善(工程化细节)
当你把上面最简的“读写通路”跑通后,一个真正可用的远程磁盘还需要考虑以下问题:
- 分包与粘包处理:网络传输有 MTU(最大传输单元)限制,大数据块需要拆分成小包发送,并在协议头加上数据包的总长度,防止接收方读取时发生粘包。
- 断点续传与重试机制:网络可能会抖动,如果写入一半断网了,需要有 ACK 确认机制和超时重传逻辑,保证数据的一致性。
- 权限与加密:真实的远程磁盘不能谁都能连,需要加上简单的身份验证,甚至给 TCP 通道套上 TLS/SSL 加密,防止数据在局域网内被窃听。
总结一下:
实现一个最简远程磁盘,本质上就是**“客户端内核驱动拦截IO请求 -> 封装成自定义协议 -> 走TCP网络发给服务端 -> 服务端落地到本地大文件”**的完整闭环。这不仅是网络编程的硬核练手项目,也能让你深刻理解 Ceph RBD、iSCSI 等成熟块存储产品的底层运作原理。
linux驱动开发的原理和思路是什么,请介绍的通俗易懂一些
Linux 驱动开发的本质,其实就是给操作系统写一个硬件的“专属翻译官”。
因为硬件(比如显卡、网卡、传感器)只听得懂底层的电信号和寄存器指令,而 Linux 内核和应用软件只懂标准的系统指令。驱动就是夹在中间的那层软件,负责把上层的指令“翻译”成硬件能懂的操作,再把硬件的反馈“翻译”回上层。
为了让你更直观地理解,我们可以把 Linux 驱动开发的原理和思路拆解为以下几个核心维度:
🧩 核心思路:一切皆文件
这是 Linux 驱动开发最底层的哲学。在 Linux 看来,不管是普通的文本文件,还是键盘、鼠标、硬盘等硬件设备,统统都被抽象成了一个“文件”。
- 对用户来说:操作硬件就像操作文件一样简单。比如你想点亮一个 LED 灯,不需要去研究电路,只需要向一个特定的设备文件(比如
/dev/led)执行“写入”操作;想读取温湿度传感器的数据,只需要从这个设备文件里“读取”数据。 - 对驱动来说:你的工作就是写好这个“文件”背后的逻辑。你需要告诉系统,当用户对这个文件执行
open(打开)、read(读)、write(写)时,驱动底层具体要去操作硬件的哪些寄存器、拉高还是拉低哪个引脚的电平。
🏗️ 核心模型:总线-设备-驱动(Bus-Device-Driver)
在早期的驱动开发中,硬件信息和驱动代码经常混杂在一起,导致代码极其难维护。现代 Linux 驱动开发采用了**“总线-设备-驱动”模型**,核心思想就是**“分离与解耦”**。
我们可以用**“开餐厅”**来打个比方:
- 设备(Device):就是**“食材”**(比如鱼、肉、蔬菜),它只负责描述硬件的客观属性(比如硬件的内存地址、中断号等),不包含任何处理逻辑。
- 驱动(Driver):就是**“厨师”**(比如王师傅只做鱼,李师傅只做肉),它只包含处理逻辑(怎么切、怎么炒),但不关心食材具体放在哪个冰箱里。
- 总线(Bus):就是餐厅的**“调度员”**。它负责把“食材”和“厨师”匹配起来。
开发思路变成了:你只需要在代码里告诉内核“我是哪个总线的驱动,我能处理哪种设备”,内核的总线调度员就会自动帮你完成匹配。一旦匹配成功,就会自动调用驱动里的初始化函数(在代码中通常叫probe函数),开始干活。
🛠️ 开发流程“三步曲”
无论开发哪种驱动,核心流程都可以简化为以下三个步骤:
- “报名”——注册驱动:
在驱动模块加载时,通过特定的注册函数告诉内核:“嗨!我来了,我能管理某某硬件!” - “干活”——实现核心功能:
这是驱动的灵魂。你需要填充一个名为file_operations的结构体(可以理解为一张任务清单),把你的open、read、write等函数填进去。当用户程序调用系统指令时,内核就会拿着这张清单,精准地执行你写好的硬件操作代码。 - “卸任”——卸载驱动:
在驱动模块退出时,负责释放占用的内存、注销设备,把资源干干净净地交还给系统。
📚 驱动家族的“三大门派”
Linux 根据硬件传输数据的方式不同,把驱动分成了三大类:
| 驱动类型 | 核心特点 | 典型代表 |
|---|---|---|
| 字符设备驱动 | 像水流一样,按“字节”顺序传输,不能随机乱跳 | 键盘、鼠标、串口、各类传感器 |
| 块设备驱动 | 像集装箱运输,按“块”(如4KB)批量传输,支持随机读写 | 硬盘、U盘、SSD(也就是你刚才问的磁盘) |
| 网络设备驱动 | 专门处理网络数据包,不走文件系统的 read/write 逻辑 | 以太网卡、Wi-Fi 模块 |
总结一下:
Linux 驱动开发就是利用**“一切皆文件”的哲学,通过“总线-设备-驱动”**的解耦模型,去编写一套标准化的代码(填好file_operations这张表),让操作系统能够统一、高效地指挥各种各样的硬件为你工作。
驱动注册后,设备从哪里来的?
驱动注册后,“设备”的来源其实分为两个层面:一个是物理上真实的硬件,另一个是软件上供你调用的设备文件(如/dev/xxx)。
我们可以结合你刚才了解的“总线-设备-驱动”模型,来看看设备到底是从哪里“冒”出来的:
1. 物理设备从哪来?(硬件的“自我介绍”)
物理设备并不是驱动“变”出来的,而是原本就存在的。驱动注册后,内核会帮你在系统里“找到”这些设备。主要有两种情况:
- 热插拔发现(即插即用):当你把 U 盘、鼠标等 USB 设备插入电脑时,硬件控制器会立刻感知到电压变化,并向内核发送一个中断信号。内核收到信号后,会马上读取这个新设备的描述符(比如厂商 ID、产品 ID),然后在系统内部登记:“嘿,总线上来了个新家伙!”。
- 系统启动枚举(开机自检):对于插在主板上的 PCIe、SATA 等固定设备,系统在上电启动时,内核的总线代码会主动去扫描物理总线,把系统里所有的硬件设备都“点名”一遍,登记在自己的花名册上。
驱动注册后的动作:当你调用xxx_driver_register()注册驱动时,其实只是向内核提交了一份“简历”(包含你能支持的设备 ID 等信息)。内核会拿着你的简历,去和它花名册上已经登记的设备进行匹配。一旦匹配成功,内核就会自动调用你驱动里的probe(探测)函数,相当于把硬件正式交到你手里去接管。
2. 设备文件从哪来?(软件的“自动建档”)
物理设备被驱动接管后,为了让用户空间(也就是你的应用程序)能方便地操作它,系统还需要在/dev/目录下生成一个对应的设备文件(比如/dev/sda或/dev/my_led)。这个过程在现代 Linux 中是全自动的:
- 驱动“上报户口”:在你的驱动代码(通常在
probe函数里)接管硬件后,会调用class_create()和device_create()这两个内核 API。这一步相当于驱动向系统大声汇报:“我已经成功接管了这个硬件,它的设备号是 XXX,请给它建个档案!”。 - 内核“发广播”:内核收到汇报后,会在内部的
sysfs文件系统(/sys/class/目录)里记录下这个设备的详细信息,同时向用户空间发送一个名为uevent(用户事件)的广播通知。 - 管家“自动建档”:在用户空间,有一个 7x24 小时值班的“管家”守护进程(通常是
udev,在嵌入式系统中是它的精简版mdev)。它时刻监听内核发出的uevent广播。一旦收到“有新设备加入”的通知,它就会自动在/dev/目录下创建出对应的设备节点文件。
简单总结一下:
驱动注册后,物理设备是内核通过“扫描”或“监听中断”早就发现并登记好的;而设备文件则是驱动在成功接管硬件后,通过device_create()上报,最终由系统里的udev/mdev管家自动在/dev/目录下创建出来的。
请写一个最简单的驱动程序,我学习一下
结合你之前对“虚拟设备”和“驱动原理”的理解,写一个最简单的 Linux 驱动,最好的方式就是写一个**“虚拟内存字符设备”**。
它的功能非常直观:你在内核里开辟一块内存当作“虚拟设备”,用户往里面写数据,它就存起来;用户读数据,它就把存的内容返回给你。
下面我们用大概 100 行代码,带你一步步实现这个驱动。
第一步:编写驱动源码
创建一个名为simple_char.c的文件,把下面的代码复制进去:
#include<linux/init.h>#include<linux/module.h>#include<linux/fs.h>#include<linux/cdev.h>#include<linux/uaccess.h>#include<linux/device.h>#defineDEVICE_NAME"my_virtual_dev"// 设备名字#defineCLASS_NAME"my_class"// 设备类名字#defineBUFFER_SIZE1024// 虚拟内存缓冲区大小staticintmajor_num;// 主设备号staticstructclass*my_class=NULL;// 设备类staticcharkernel_buffer[BUFFER_SIZE];// 模拟设备的内核内存// 1. 实现设备的 open 操作staticintmy_open(structinode*inode,structfile*file){pr_info("虚拟设备被打开啦!\n");return0;}// 2. 实现设备的 read 操作(把内核数据拷贝给用户)staticssize_tmy_read(structfile*file,char__user*user_buf,size_tcount,loff_t*offset){intbytes_to_read;// 防止读取越界bytes_to_read=min(count,(size_t)(BUFFER_SIZE-*offset));if(bytes_to_read<=0)return0;// 安全地把数据从内核空间拷贝到用户空间if(copy_to_user(user_buf,kernel_buffer+*offset,bytes_to_read)!=0){return-EFAULT;}*offset+=bytes_to_read;pr_info("给用户返回了 %d 个字节的数据\n",bytes_to_read);returnbytes_to_read;}// 3. 实现设备的 write 操作(把用户数据拷贝给内核)staticssize_tmy_write(structfile*file,constchar__user*user_buf,size_tcount,loff_t*offset){intbytes_to_write;bytes_to_write=min(count,(size_t)(BUFFER_SIZE-*offset));if(bytes_to_write<=0)return-ENOSPC;// 安全地把数据从用户空间拷贝到内核空间if(copy_from_user(kernel_buffer+*offset,user_buf,bytes_to_write)!=0){return-EFAULT;}*offset+=bytes_to_write;pr_info("从用户那里接收了 %d 个字节的数据\n",bytes_to_write);returnbytes_to_write;}// 4. 填充文件操作结构体(告诉内核我们实现了哪些功能)staticstructfile_operationsfops={.owner=THIS_MODULE,.open=my_open,.read=my_read,.write=my_write,};// 5. 驱动模块初始化函数(驱动被加载时执行)staticint__initsimple_char_init(void){// 动态申请一个主设备号major_num=register_chrdev(0,DEVICE_NAME,&fops);if(major_num<0){pr_err("申请主设备号失败!\n");returnmajor_num;}// 创建设备类,并在 /dev 下自动创建设备文件my_class=class_create(THIS_MODULE,CLASS_NAME);if(IS_ERR(my_class)){unregister_chrdev(major_num,DEVICE_NAME);pr_err("创建设备类失败!\n");returnPTR_ERR(my_class);}device_create(my_class,NULL,MKDEV(major_num,0),NULL,DEVICE_NAME);pr_info("驱动加载成功!主设备号是: %d\n",major_num);return0;}// 6. 驱动模块退出函数(驱动被卸载时执行)staticvoid__exitsimple_char_exit(void){device_destroy(my_class,MKDEV(major_num,0));// 销毁设备文件class_destroy(my_class);// 销毁设备类unregister_chrdev(major_num,DEVICE_NAME);// 释放主设备号pr_info("驱动已安全卸载!\n");}module_init(simple_char_init);module_exit(simple_char_exit);MODULE_LICENSE("GPL");MODULE_AUTHOR("YourName");MODULE_DESCRIPTION("A Simple Virtual Character Driver");第二步:编写 Makefile
在同一个目录下创建一个名为Makefile的文件(注意首字母大写,没有后缀):
obj-m += simple_char.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean第三步:编译并运行测试
你需要一台 Linux 电脑(虚拟机或物理机均可),打开终端,进入这两个文件所在的目录,依次执行以下命令:
编译驱动:
make编译成功后,当前目录下会生成一个
simple_char.ko文件,这就是你的驱动模块。加载驱动(需要管理员权限):
sudoinsmod simple_char.ko此时,驱动已经运行在内核里了。你可以用
ls /dev/my_virtual_dev看看,系统已经自动帮你生成了这个设备文件。测试写入数据:
echo"Hello Linux Driver!"|sudotee/dev/my_virtual_dev这行命令会把字符串写入你的虚拟设备,驱动里的
my_write函数会被触发。测试读取数据:
sudocat/dev/my_virtual_dev你会看到终端打印出
Hello Linux Driver!,这说明驱动里的my_read函数把刚才存在内核内存里的数据又还给了你。查看内核打印的日志:
dmesg|tail你会看到驱动里用
pr_info打印的“虚拟设备被打开啦”、“接收了xx字节”等调试信息。卸载驱动:
sudormmod simple_char卸载后,
/dev/my_virtual_dev文件也会自动消失。
通过这个最简的驱动,你就完整走通了**“编写驱动代码 -> 编译成模块 -> 加载到内核 -> 自动创建设备文件 -> 用户空间读写测试 -> 卸载驱动”**的整个 Linux 驱动开发闭环。你可以试着修改一下代码,比如在write里加一些特殊的逻辑,来加深对它的理解!
试了一下,成功实现了一个最简驱动
$ insmod simple_char.ko(gaofeng1120)$cat/dev/my_virtual_dev(gaofeng1120)$echo"Hello Linux Driver2!">/dev/my_virtual_dev(gaofeng1120)$cat/dev/my_virtual_dev Hello Linux Driver2!simple_char.mod.c 是哪里来的,为什会有这个文件
simple_char.mod.c这个文件并不是你手写的,而是Linux 内核构建系统(Kbuild)在编译过程中自动生成的临时文件。
你可以把它理解为驱动模块的**“专属身份证”或“元数据说明文件”**。
🛠️ 它是怎么来的?
当你执行make命令编译驱动时,内核的构建系统会按照以下流水线工作:
- 首先,把你手写的
simple_char.c编译成目标文件simple_char.o。 - 紧接着,构建系统会自动生成
simple_char.mod.c,并把它编译成simple_char.mod.o。 - 最后,链接器会把
simple_char.o和simple_char.mod.o打包链接在一起,生成最终的内核模块文件simple_char.ko。
📄 为什么需要这个文件?
Linux 内核模块(.ko)和普通的应用程序不同,它需要携带一些额外的“身份信息”才能被内核正确加载和识别。simple_char.mod.c的作用就是存放这些内核专属的元数据,主要包括:
- 模块的许可证与描述信息:把你代码里写的
MODULE_LICENSE("GPL")、MODULE_AUTHOR、MODULE_DESCRIPTION等宏定义,转换成内核能够识别的标准数据结构。 - 模块的版本依赖(Magic 校验):记录当前模块是基于哪个版本的内核编译的。当你用
insmod加载驱动时,内核会检查这些信息,防止你把一个为旧内核编译的驱动强行加载到新内核里,从而避免系统崩溃(即所谓的“版本魔术不匹配”)。 - 符号依赖信息:如果你的驱动用到了其他内核模块导出的函数,这些信息也会被记录在这里,方便内核在加载时自动处理依赖关系。
🔍 里面到底写了什么?
如果你好奇,完全可以打开simple_char.mod.c看一眼。你会发现它里面的代码非常枯燥,基本全是自动生成的宏和结构体数组,比如长这样(仅作示意):
#include<linux/module.h>#defineINCLUDE_VERMAGIC#include<linux/build-salt.h>#include<linux/vermagic.h>#include<linux/compiler.h>BUILD_SALT;MODULE_INFO(vermagic,VERMAGIC_STRING);MODULE_INFO(name,KBUILD_MODNAME);__visiblestructmodule__this_module__section(".gnu.linkonce.this_module")={.name=KBUILD_MODNAME,.init=init_module,#ifdefCONFIG_MODULE_UNLOAD.exit=cleanup_module,#endif.arch=MODULE_ARCH_INIT,};总结一下:simple_char.mod.c是编译内核模块时由系统自动生成的“中间产物”,它负责把模块的身份信息和版本校验等元数据打包进最终的.ko文件里。你平时开发完全不用管它,执行make clean时它也会被自动清理掉。
没有看到makefile中调用kbuild啊, make -C /lib/modules/(shelluname−r)/buildM=(shell uname -r)/build M=(shelluname−r)/buildM=(PWD) modules 这句话详细解释一下
你观察得很仔细!确实,在这个Makefile里我们并没有直接写kmake这个词,但你写的这句make -C ...实际上就是在调用 Linux 内核的构建系统(也就是 Kbuild)。
在 Linux 内核开发中,kbuild并不是一个独立的可执行程序的名字,而是一整套构建规则和脚本的统称。这套规则的核心,其实就是内核源码顶层目录下的那个Makefile。
下面为你逐字逐句拆解这句核心命令:make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
1.make
这就是 Linux 系统自带的标准 GNU Make 工具。
2.-C /lib/modules/$(shell uname -r)/build
-C:这是标准make命令的一个选项,意思是“Change Directory”(切换目录)。它会让make在执行任何操作之前,先跳转到后面指定的那个目录里去。/lib/modules/$(shell uname -r)/build:这是你当前运行的 Linux 系统的内核源码(或内核头文件)目录。$(shell uname -r):这是一个 Makefile 的语法,它会执行系统命令uname -r来获取你当前的内核版本号(比如5.15.0-76-generic)。- 这个路径下通常有一个软链接,指向了真正的内核源码或头文件目录。
- 合起来的意思是:让
make工具暂时离开你当前的驱动目录,带着内核顶层Makefile(也就是 Kbuild 的核心规则)去干活。
3.M=$(PWD)
M=:这是专门传递给**内核顶层 Makefile(Kbuild)**的一个特殊变量。它的作用是告诉内核的构建系统:“虽然我切换到了内核目录,但我真正要编译的模块源码,其实是在M指定的这个目录里”。$(PWD):这也是 Makefile 的语法,代表“Print Working Directory”(当前工作目录),也就是你存放simple_char.c和你的Makefile的这个文件夹。- 合起来的意思是:告诉内核 Kbuild 系统,编译完必要的依赖后,请回到我这个驱动源码目录里,把我的代码编译了。
4.modules
- 这是内核顶层 Makefile 里预定义好的一个编译目标(Target)。
- 当你跳转到内核目录后,执行
make modules,就会触发 Kbuild 里编译外部模块的整套复杂规则(包括生成你刚才问的.mod.c文件、进行版本校验、最后链接成.ko等)。
💡 总结一下整个执行流程(也就是 Kbuild 的“回调”机制):
- 你在驱动目录输入
make,你的Makefile开始执行。 - 遇到
make -C /内核目录 M=$(PWD) modules。 make带着指令跳转到了/内核目录,找到了内核的顶层Makefile(这时候 Kbuild 系统就被激活了)。- 内核顶层
Makefile看到M=$(PWD),知道是要编译外部模块,于是设置好各种复杂的环境变量和编译规则。 - 内核顶层
Makefile执行到modules目标时,会**再次回调(跳回)**到你的驱动目录(M指定的目录),去读取你的obj-m += simple_char.o,并开始编译你的simple_char.c。 - 编译完你的
.o文件后,Kbuild 系统会自动生成simple_char.mod.c,并最终把所有东西打包链接成simple_char.ko。
所以,虽然你没看到kmake这个命令,但通过make -C ... M=... modules这种精妙的“跳转+回调”机制,你的驱动已经完整地享受了 Linux 内核 Kbuild 系统提供的全套专业编译服务!
