Linux设备驱动程序(二)——建立和运行模块

简介: 本章介绍所有的关于模块和内核编程的关键概念,通过一个 hello world 模块来认识驱动加载的流程及相关细节。

前言


本章介绍所有的关于模块和内核编程的关键概念,通过一个 hello world 模块来认识驱动加载的流程及相关细节。


一、设置测试系统


我是在虚拟机上进行的开发,查看当前 Linux 系统的内核版本:

uname -r


二、Hello World 模块


1、代码详解


hello.c

#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("Dual BSD/GPL");
static int hello_init(void)
{
    printk(KERN_ALERT "Hello, world\n");
    return 0;
}
static void hello_exit(void)
{
    printk(KERN_ALERT "Goodbye, cruel world\n");
}
module_init(hello_init);
module_exit(hello_exit);

这个模块定义了两个函数,一个在模块加载到内核时被调用(hello_init)以及一个在模块被去除时被调用(hello_exit)。moudle_init 和 module_exit 这几行使用了特别的内核宏来指出这两个函数的角色。另一个特别的宏(MODULE_LICENSE)是用来告知内核,该模块带有一个自由的许可证;没有这样的说明,在模块加载时内核会抱怨。


printk 函数在 Linux 内核中定义并且对模块可用;它与标准 C 库函数 printf 的行为相似。内核需要它自己的打印函数,因为它靠自己运行,没有 C 库的帮助,模块能够调用 printk 是因为在 insmod 加载了它之后,模块被连接到内核并且可存取内核的公用符号。 字串 KERN_ALERT 是消息的优先级。


可以用 insmod 和 rmmod 工具来测试这个模块,注意只有超级用户可以加载和卸载模块。


Makefile

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

obj-m := hello.o

表明有一个模块要从目标文件 hello.o 建立,在从目标文件建立后结果模块命名为 hello.ko;

如果你有一个模块名为 module.ko,是来自 2 个源文件( 姑且称之为,file1.c 和 file2.c ),正确的书写应当是:

obj-m := module.o

module-objs := file1.o file2.o


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

如果这个 KERNELDIR 为空说明你没有指定内核库文件的路径,那么它就会给 KERNELDIR 赋值,因为顶层 Makefile 通过这个环境变量知道内核库文件在哪里。


PWD := $(shell pwd)

获取当前所执行命令的目录

$(MAKE) -C $(KERNELDIR) M=$(PWD) modules

这个命令开始是改变它的目录到用 -C 选项提供的目录下( 就是说,你的内核源码目录 )。它在那里会发现内核的顶层 makefile,这个 M= 选项使 makefile 在试图建立模块目标前,回到你的模块源码目录,这个目标,依次地,是指在 obj-m 变量中发现的模块列表,在我们的例子里设成了 hello.o。


这个 makefile 在一次典型的建立中要被读 2 次,当从命令行中调用这个 makefile,它注意到 KERNELRELEASE 变量没有设置,它利用这样一个事实来定位内核源码目录,即已安装模块目录中的符号连接指回内核建立树,如果你实际上没有运行你在为其而建立的内核,你可以在命令行提供一个 KERNELDIR= 选项,设置 KERNELDIR 环境变量,或者重写 makefile 中设置 KERNELDIR 的那一行。一旦发现内核源码树,makefile 调用 default: 目标,来运行第 2 个 make 命令( 在 makefile 里参数化成 $(MAKE)) 象前面描述过的一样来调用内核建立系统,在第 2次读,makefile 设置 obj-m,并且内核的 makefile 文件完成实际的建立模块工作。


2、执行效果


①、准备好 hello.c 和 Makefile

②、make 编译

make

查看当前目录下编译产物,其中 hello.ko 是我们需要用到的驱动模块

③、加载 hello.ko 模块

sudo insmod hello.ko


④、lsmod 显示已经加载到内核中的模块的状态信息

lsmod

⑤、查看加载时的打印信息

sudo dmesg -c

⑥、卸载 hello.ko 模块

⑦、查看卸载时的打印信息

sudo dmesg -c


三、内核模块相比于应用程序


不同于大部分的小的和中型的应用程序从头至尾处理一个单个任务,每个内核模块只注册自己以便来服务将来的请求,并且它的初始化函数立刻终止。


模块初始化函数的任务是为以后调用模块的函数做准备;模块的退出函数就在模块被卸载时调用。这种编程的方法类似于事件驱动的编程,但是虽然不是所有的应用程序都是事件驱动的,每个内核模块都是。


另外一个主要的不同,在事件驱动的应用程序和内核代码之间,是退出函数:一个终止的应用程序可以在释放资源方面懒惰,或者完全不做清理工作,但是模块的退出函数必须小心恢复每个由初始化函数建立的东西,否则会保留一些东西直到系统重启。


一个应用程序可以调用它没有定义的函数:连接阶段使用合适的函数库解决了外部引用。 printf 是一个这种可调用的函数并且在 libc 里面定义。一个模块,在另一方面,只连接到内核,它能够调用的唯一的函数是内核输出的那些; 没有库来连接。


内核编程和应用程序编程之间的重要不同是每一个环境是如何处理错误:在应用程序开发中段错误是无害的,一个调试器常常用来追踪错误到源码中的问题,而一个内核错误至少会杀掉当前进程,如果不终止整个系统。


1、用户空间和内核空间


一个模块在内核空间运行,而应用程序在用户空间运行,这个概念是操作系统理论的基础。

cpu 在被设计时,有保护系统软件不被应用程序破坏的功能。且这种保护功能分为不同级别,当 cpu 中存在多个级别时,unix 通常使用最高级和最低级,即:超级用户级和用户级,也即内核空间和用户空间。


在 Unix 下,内核在最高级运行(也称之为超级模式 ),这里任何事情都允许,而应用程序在最低级运行(所谓的用户模式),这里处理器控制了对硬件的直接存取以及对内存的非法存取。


模块的角色是扩展内核的功能:模块化的代码在内核空间运行,经常地一个驱动进行之前提到的两种任务:模块中一些的函数作为系统调用的一部分执行,一些负责中断处理。


2、内核的并发


常见引起并发原因:


linux 系统中通常正在运行多个并发进程,并且可能有多个进程同时使用我们的驱动程序。

大多数设备能够中断处理器,而中断处理程序异步运行,而且可能在驱动程序正试图处理其他任务时被调用。


linux 可以运行在多处理器上,因此可能同时有多个处理器在使用该进程。


3、当前进程


Current 在<asm.current.h>中定义,是一个指向 struct task_struct 的指针,而 task_struct 结构在 <linux/sched.h> 中定义。


Current 指针指向当前正在运行的进程;


在 open,read 等系统调用的执行过程中,当前进程指的是调用这些系统调用的进程。


struct task_struct *current;
current->id :当前进程的id
current->comm. :当前进程的命令名


4、几个别的细节


如果我们需要大的结构,应该调用动态分配该结构,而不是声明大的自动变量。

常见函数前加有 __ 两个下划线,这种函数通常是接口的底层组件,实际上,双下划线是告诉程序员:谨慎使用,后果自负。


内核代码不支持浮点数运算。


四、编译和加载


1、编译模块


上面已讲解,这里不再讲述。


2、加载和卸载模块


模块建立之后,下一步是加载到内核,insmod 完成这个工作。这个程序加载模块的代码段和数据段到内核,接着,执行一个类似 ld 的函数,它连接模块中任何未解决的符号连接到内核的符号表上。


modprobe 工具值得快速提及一下。modprobe 和 insmod 类似,加载一个模块到内核。它的不同在于它会查看要加载的模块,看是否它引用了当前内核没有定义的符号。如果发现有,modprobe 在定义相关符号的当前模块搜索路径中寻找其他模块。当 modprobe 找到这些模块(要加载模块需要的),它也把它们加载到内核。如果你在这种情况下代替以使用 insmod,命令会失败,在系统日志文件中留下一条 “unresolved symbols” 消息。


模块可以用 rmmod 工具从内核去除。注意,如果内核认为模块还在用(就是说,一个程序仍然有一个打开文件对应模块输出的设备),或者内核被配置成不允许模块去除,模块去除会失败,可以配置内核允许“强行”去除模块, 甚至在它们看来是忙的。如果你到了需要这选项的地步,但是,事情可能已经错的太严重以至于最好的动作就是重启了。


只有系统调用函数的名字前边带有 sys_ 前缀。

lsmod 列出当前装载到内核中的所有模块。lsmod 通过读取 /proc/modules 虚拟文件工作。当前加载的模块的信息也可在位于 /sys/module 的 sysfs 虚拟文件系统找到。


3、版本依赖


如果你编写一个模块想用来在多个内核版本上工作(特别地是如果它必须跨大的发行版本)你可能只能使用宏定义和 #ifdef 来使你的代码正确建立,利用 linux/version.h 中发现的定义。这个头文件,自动包含在 linux/module.h,定义了下面的宏定义:


UTS_RELEASE

这个宏定义扩展成字符串,描述了这个内核树的版本,例如, “2.6.10”。


LINUX_VERSION_CODE

这个宏定义扩展成内核版本的二进制形式,版本号发行号的每个部分用一个字节表示。例如 2.6.10 的编码是 132618 ( 就是0x02060a )。有了这个信息, 你可以(几乎是)容易地决定你在处理的内核版本。


KERNEL_VERSION(major,minor,release)

这个宏定义用来建立一个整型版本编码,从组成一个版本号的单个数字。例如 KERNEL_VERSION(2.6.10) 扩展成 132618,这个宏定义非常有用,当你需要比较当前版本和一个已知的检查点。


五、内核符号表


通常情况下,一个模块完成它自己的功能不需要输出如何符号。但是,你需要输出符号,在任何别的模块能得益于使用它们的时候。


linux 内核头文件提供了方便来管理你的符号的可见性,因此减少了命名空间的污染(将与在内核别处已定义的符号冲突的名子填入命名空间),并促使了正确的信息隐藏。如果你的模块需要输出符号给其他模块使用,应当使用下面的宏定义:

EXPORT_SYMBOL(name);

EXPORT_SYMBOL_GPL(name);


上面宏定义的任一个使得给定的符号在模块外可用。_GPL 版本的宏定义只能使符号对 GPL 许可的模块可用。符号必须在模块文件的全局部分输出,在任何函数之外,因为宏定义扩展成一个特殊用途的并被期望是全局存取的变量的声明,这个变量存储于模块的一个特殊的可执行部分(一个 “ELF 段” ),内核用这个部分在加载时找到模块输出的变量。


六、预备知识


有几个文件对模块是特殊的,必须出现在每一个可加载模块中。因此,几乎所有模块代码都有下面内容:

#include <linux/module.h>

#include <linux/init.h>


moudle.h 包含了大量加载模块需要的函数和符号的定义,你需要 init.h 来指定你的初始化和清理函数。


不是严格要求的,但是你的模块确实应当指定它的代码使用哪个许可。做到这一点只需包含一行 MODULE_LICENSE:

MODULE_LICENSE(“GPL”);

内核认识的特定许可有"GPL"(适用 GNU 通用公共许可的任何版本),“GPL v2”(只适用 GPL 版本 2),“GPL and additional rights”,“Dual BSD/GPL”,"Dual MPL/GPL"和 “Proprietary”;除非你的模块明确标识是在内核认识的一个自由许可下,否则就假定它是私有的,内核在模块加载时被"弄污浊"了。


可以在模块中包含的其他描述性定义有 MODULE_AUTHOR(声明谁编写了模块)。MODULE_DESCRIPION(一个人可读的关于模块做什么的声明), MODULE_VERSION(一个代码修订版本号; 看 <linux/module.h> 的注释以便知道创建版本字串使用的惯例),MODULE_ALIAS (模块为人所知的另一个名子),以及 MODULE_DEVICE_TABLE ( 来告知用户空间,模块支持那些设备 )。


七、初始化和关停


模块初始化函数注册模块提供的任何功能,实际的初始化函数定义常常如:

static int __init initialization_function(void)
{
/* Initialization code here */
}
module_init(initialization_function);

初始化函数应当声明成静态的,因为它们不会在特定文件之外可见;


声明中的 __init 标志可能看起来有点怪,它是一个给内核的暗示,给定的函数只是在初始化使用,模块加载者在模块加载后会丢掉这个初始化函数,使它的内存可做其他用途。一个类似的标签(__initdata)给只在初始化时用的数据。使用 __init 和 __initdata 是可选的,但是它带来的麻烦是值得的;


使用 moudle_init 是强制的,这个宏定义增加了特别的段到模块目标代码中,表明在哪里找到模块的初始化函数。 没有这个定义,你的初始化函数不会被调用;


大部分注册函数以 register_ 做前缀,因此找到它们的另外一个方法是在内核源码里查找 register_;


1、清理函数


每个非试验性的模块也要求有一个清理函数,它注销接口,在模块被去除之前返回所有资源给系统。这个函数定义为:

static void __exit cleanup_function(void)
{
/* Cleanup code here */
}
module_exit(cleanup_function);

清理函数没有返回值, 因此它被声明为 void,__exit 修饰符标识这个代码是只用于模块卸载(通过使编译器把它放在特殊的 ELF 段),如果你的模块直接建立在内核里,或者如果你的内核配置成不允许模块卸载,标识为 __exit 的函数被简单地丢弃。因为这个原因,一个标识 __exit 的函数只在模块卸载或者系统停止时调用;任何别的使用是错的。再一次,moudle_exit 声明对于使得内核能够找到你的清理函数是必要的。


2、初始化中的错误处理


你必须记住一件事,在注册内核设施时,注册可能失败。即便最简单的动作常常需要内存分配,分配的内存可能不可用。因此模块代码必须一直检查返回值,并且确认要求的操作实际上已经成功。

int __init my_init_function(void)
{
  int err;
  err = register_this(ptr1, "skull"); /* registration takes a pointer and a name */
  if (err)
  goto fail_this;
  err = register_that(ptr2, "skull");
  if (err)
  goto fail_that;
  err = register_those(ptr3, "skull");
  if (err)
  goto fail_those;
  return 0; /* success */
  fail_those:
  unregister_that(ptr2, "skull");
  fail_that:
  unregister_this(ptr1, "skull");
  fail_this:
  return err; /* propagate the error */
}

模块清理函数必须撤销任何由初始化函数进行的注册,并且惯例(但常常不是要求的)是按照注册时相反的顺序注销设施。

void __exit my_cleanup_function(void)
{
  unregister_those(ptr3, "skull");
  unregister_that(ptr2, "skull");
  unregister_this(ptr1, "skull");
  return;
}

如果你的初始化和清理比处理几项复杂,goto 方法可能变得难于管理,因为所有的清理代码必须在初始化函数里重复,有时包括几个混合的标号,因此,一种不同的代码排布证明更成功。

使代码重复最小和所有东西流线化,你应当做的是无论何时发生错误都从初始化里调用清理函数。清理函数接着必须在撤销它的注册前检查每一项的状态,以最简单的形式,代码看起来象这样:

struct something *item1;
struct somethingelse *item2;
int stuff_ok;
void my_cleanup(void)
{
  if (item1)
  release_thing(item1);
  if (item2)
  release_thing2(item2);
  if (stuff_ok)
  unregister_stuff();
  return;
}
int __init my_init(void)
{
  int err = -ENOMEM;
  item1 = allocate_thing(arguments);
  item2 = allocate_thing2(arguments2);
  if (!item2 || !item2)
  goto fail;
  err = register_stuff(item1, item2);
  if (!err)
  stuff_ok = 1;
  else
  goto fail;
  return 0; /* success */
  fail:
  my_cleanup();
  return err;
}

清理函数当由非退出代码调用时不能标志为 __exit。


3、模块加载竞争


内核的某些别的部分会在注册完成之后马上使用任何你注册的设施,这是完全可能的,换句话说,内核将调用进你的模块,在你的初始化函数仍然在运行时,所以你的代码必须准备好被调用,一旦它完成了它的第一个注册。不要注册任何设施,直到所有的需要支持那个设施的你的内部初始化已经完成。


八、模块参数


模块参数可以在运行 insmod 或 modprobe 命令装载模块时赋值,modprobe 可以从配置文件(/etc/modprobe.conf)中读取参数值。


在 insmod 改变模块参数之前,模块必须让参数对 insmod 命令可见。参数使用 module_param(变量名,类型,访问许可值)宏来声明,它定义在 moduleparam.h。


所有的模块参数都应该在源文件中给定一个默认值。


1、模块支持的模块参数:


bool、invbool(取反,true 变为 false,false 变为 true)

charp(字符指针)

int、long、short、unit、ulong、ushort

数组参数:module_param_array(数组名,类型,值的个数,访问许可值);

模块中的钩子可让我们自定义类型


2、访问许可值:


使用 <linux/stat.h> 中定义的值


设置为0不会有对应的 sysfs 入口项,否则模块参数会在 /sys/module/(如下所示)

S_IRUGO 任何人都可以读取,但不能修改

S_IRUGO | S_IWUSR 允许 root 用户修改

大多数情况下不应该让模块参数是可写的


3、例程


hello.c

#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
MODULE_LICENSE("Dual BSD/GPL");
static char *hello_str = "hello";
static int hello_cnt = 2;
module_param(hello_str, charp, S_IRUGO);
module_param(hello_cnt, int, S_IRUGO);
static int hello_init(void)
{
    printk(KERN_ALERT "Hello, world\n");
    printk("%s, %d\n", hello_str, hello_cnt);
    return 0;
}
static void hello_exit(void)
{
    printk(KERN_ALERT "Goodbye, cruel world\n");
}
module_init(hello_init);
module_exit(hello_exit);

加载 hello 模块驱动,并查看打印信息

sudo insmod hello.ko
sudo dmesg

模块加载后可以在 /sys/module/模块名/parameters 目录下查看参数

cd /sys/module/hello/parameters/
ls
cat hello_cnt
cat hello_str


九、在用户空间做


用户空间驱动的好处在于:


完整的 C 库可以连接,驱动可以进行许多奇怪的任务,不用依靠外面的程序(实现使用策略的工具程序,常常随着驱动自身发布);

程序员可以在驱动代码上运行常用的调试器,而不必走调试一个运行中的内核的弯路。


如果一个用户空间驱动挂起了,你可简单地杀掉它,驱动的问题不可能挂起整个系统,除非被控制的硬件真的疯掉了。


用户内存是可交换的,不象内核内存,一个不常使用的却有很大一个驱动的设备不会占据别的程序可以用到的 RAM,除了在它实际在用时。


一个精心设计的驱动程序仍然可以,如同内核空间驱动,允许对设备的并行存取。

如果你必须编写一个封闭源码的驱动,用户空间的选项使你容易避免不明朗的许可的情况和改变的内核接口带来的问题。


用户空间的设备驱动的方法有几个缺点,最重要的是:

中断在用户空间无法用,在某些平台上有对这个限制的解决方法,例如在 IA32 体系上的 vm86 系统调用。


只可能通过内存映射 /dev/mem 来使用 DMA,而且只有特权用户可以这样做。

存取 I/O 端口只能在调用 ioperm 或者 iopl 之后,此外,不是所有的平台支持这些系统调用,而存取/dev/port 可能太慢而无效率,这些系统调用和设备文件都要求特权用户。


响应时间慢,因为需要上下文切换在客户和硬件之间传递信息或动作。


更不好的是,如果驱动已被交换到硬盘,响应时间会长到不可接受,使用 mlock 系统调用可能会有帮助,但是常常的你将需要锁住许多内存页,因为一个用户空间程序依赖大量的库代码,mlock 也限制在授权用户上。


最重要的设备不能在用户空间处理,包括但不限于网络接口和块设备。


十、快速参考


insmod

modprobe

rmmod

用户空间工具,加载模块到运行中的内核以及去除它们。


#include <linux/init.h>

module_init(init_function);

module_exit(cleanup_function);

指定模块的初始化和清理函数的宏定义。


__init

__initdata

__exit

__exitdata

函数(__init 和 __exit)和数据(__initdata 和 __exitdata)的标记,只用在模块初始化或者清理时间。


#include <linux/sched.h>

最重要的头文件中的一个,这个文件包含很多驱动使用的内核 API 的定义,包括睡眠函数和许多变量声明。


struct task_struct *current;

当前进程。


current->pid

current->comm

进程 ID 和 当前进程的命令名。


obj-m

一个 makefile 符号,内核建立系统用来决定当前目录下的哪个模块应当被建立。


/sys/module

/proc/modules

/sys/module 是一个 sysfs 目录层次,包含当前加载模块的信息。/proc/moudles 是旧式的,那种信息的单个文件版本。其中的条目包含了模块名,每个模块占用的内存数量,以及使用计数,另外的字串追加到每行的末尾来指定标志,对这个模块当前是活动的。


vermagic.o

来自内核源码目录的目标文件,描述一个模块为之建立的环境。


#include <linux/module.h>

必需的头文件,它必须在一个模块源码中包含。


#include <linux/version.h>

头文件,包含在建立的内核版本信息。


LINUX_VERSION_CODE

整型宏定义,对 #ifdef 版本依赖有用。


EXPORT_SYMBOL (symbol);

EXPORT_SYMBOL_GPL (symbol);

宏定义,用来输出一个符号给内核。第 2 种形式输出没有版本信息,第 3 种限制输出给 GPL 许可的模块。


MODULE_AUTHOR(author);

MODULE_DESCRIPTION(description);

MODULE_VERSION(version_string);

MODULE_DEVICE_TABLE(table_info);

MODULE_ALIAS(alternate_name);

放置文档在目标文件的模块中。


module_init(init_function);

module_exit(exit_function);

宏定义,声明一个模块的初始化和清理函数。


#include <linux/moduleparam.h>

module_param(variable, type, perm);

宏定义,创建模块参数,可以被用户在模块加载时调整(或者在启动时间,对于内嵌代码)。类型可以是 bool,charp,int,invbool,short,ushort,uint,ulong 或者 intarray。


#include <linux/kernel.h>

int printk(const char * fmt, …);

内核代码的 printf 类似物。


目录
相关文章
|
5天前
|
Linux 开发工具 C语言
Linux 安装 gcc 编译运行 C程序
Linux 安装 gcc 编译运行 C程序
25 0
|
18天前
|
监控 Unix Linux
Linux操作系统调优相关工具(四)查看Network运行状态 和系统整体运行状态
Linux操作系统调优相关工具(四)查看Network运行状态 和系统整体运行状态
31 0
|
1月前
|
存储 监控 Linux
【Shell 命令集合 系统管理 】⭐⭐⭐Linux 查看当前正在运行的进程信息 ps命令 使用指南
【Shell 命令集合 系统管理 】⭐⭐⭐Linux 查看当前正在运行的进程信息 ps命令 使用指南
42 0
|
1月前
|
Linux 编译器 程序员
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
69 0
|
1月前
|
Shell Linux C语言
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
30 1
|
18天前
|
Linux
Linux操作系统调优相关工具(三)查看IO运行状态相关工具 查看哪个磁盘或分区最繁忙?
Linux操作系统调优相关工具(三)查看IO运行状态相关工具 查看哪个磁盘或分区最繁忙?
21 0
|
1月前
|
Linux Shell 文件存储
【Shell 命令集合 系统设置 】Linux 加载和卸载内核模块 modprobe命令 使用指南
【Shell 命令集合 系统设置 】Linux 加载和卸载内核模块 modprobe命令 使用指南
50 1
|
5天前
|
Linux
Linux(23) Linux 4G模块不能获取IP排查思路
Linux(23) Linux 4G模块不能获取IP排查思路
15 0
|
19天前
|
网络协议 Linux SDN
虚拟网络设备与Linux网络协议栈
在现代计算环境中,虚拟网络设备在实现灵活的网络配置和隔离方面发挥了至关重要的作用🔧,特别是在容器化和虚拟化技术广泛应用的今天🌐。而Linux网络协议栈则是操作系统处理网络通信的核心💻,它支持广泛的协议和网络服务🌍,确保数据正确地在网络中传输。本文将深入分析虚拟网络设备与Linux网络协议栈的关联,揭示它们如何共同工作以支持复杂的网络需求。
|
20天前
|
存储 缓存 固态存储
Linux设备全览:从字符到块,揭秘每种设备的秘密
在Linux的世界里,设备是构成系统的基础,它们使得计算机能够与外界互动。Linux设备可以大致分为几种类型,每种类型都有其独特的特性和用途。🌌让我们一起探索这些设备类型及其特性。