问题

Linux设备驱动开发 新手,创建第一个字符设备驱动时对一些代码的功能不是很了解,能解释一下吗?

回答
没问题,很高兴能帮助你入门 Linux 设备驱动开发,尤其是创建你的第一个字符设备驱动。这绝对是学习内核开发一个非常扎实的起点。新手在刚接触内核代码的时候,确实会遇到不少“这都是干啥的”的疑问,这很正常。我尽量把这些代码的功能讲得细致明白,就像咱们平时交流一样,尽量避免那些生硬的、机器人式的表述。

咱们就以一个最简单、最基础的字符设备驱动为例,一步步来剖析代码。假设我们的驱动实现了一个简单的“hello world”设备,当你打开它、读取它的时候,它会给你返回一些字符串。

基础框架:模块的入口和出口

首先,一个 Linux 内核模块(我们编写的驱动程序本质上就是一个内核模块)都需要有入口点和出口点。

```c
include
include
include // 涉及到文件系统操作,比如注册设备

// ... 其他头文件 ...

static int __init my_char_driver_init(void)
{
// 驱动注册相关的代码
return 0; // 返回0表示成功
}

static void __exit my_char_driver_exit(void)
{
// 驱动注销相关的代码
}

module_init(my_char_driver_init); // 告诉内核,my_char_driver_init 是模块的入口
module_exit(my_char_driver_exit); // 告诉内核,my_char_driver_exit 是模块的出口

MODULE_LICENSE("GPL"); // 模块许可,非常重要
MODULE_AUTHOR("Your Name"); // 你的名字
MODULE_DESCRIPTION("A simple character device driver"); // 驱动的简要描述
```

`include `: 这个头文件包含了 `__init` 和 `__exit` 宏。
`__init`: 这个宏会告诉编译器,标记的函数(比如 `my_char_driver_init`)只在模块加载时使用一次。在模块加载成功后,内核可以释放这部分代码所占用的内存,这有助于节省内核资源。
`__exit`: 类似地,这个宏告诉编译器,标记的函数(比如 `my_char_driver_exit`)只在模块卸载时使用。
`include `: 这是所有内核模块都必需包含的头文件。它提供了模块化编程的一些基本功能,比如 `MODULE_LICENSE`、`MODULE_AUTHOR` 等宏,用于描述模块的元信息。
`include `: 字符设备驱动的核心工作就是与 Linux 的文件系统交互,所以这个头文件必不可少。它包含了文件系统相关的结构体和函数声明,比如我们后面要用到的 `register_chrdev`、`unregister_chrdev` 等。
`static int __init my_char_driver_init(void)`: 这是模块的入口函数。当 `insmod` 命令加载我们的驱动模块时,内核就会执行这个函数。
`static`: 表示这个函数只在该编译单元(通常是这个 `.c` 文件)内部可见。
`int`: 返回值,通常是0表示成功,非0表示失败。
`void`: 表示这个函数不接受任何参数。
`static void __exit my_char_driver_exit(void)`: 这是模块的出口函数。当 `rmmod` 命令卸载我们的驱动模块时,内核就会执行这个函数。
`void`: 表示这个函数没有返回值。
`void`: 表示这个函数不接受任何参数。
`module_init(my_char_driver_init);`: 这个宏非常关键。它是一个“注册”宏,告诉内核:“嘿,我的模块加载时,请调用 `my_char_driver_init` 这个函数”。
`module_exit(my_char_driver_exit);`: 同理,这个宏告诉内核:“我的模块卸载时,请调用 `my_char_driver_exit` 这个函数”。
`MODULE_LICENSE("GPL");`: 这是强制要求的。Linux 内核是 GPL 许可的,你的驱动程序如果想与内核的非 GPL 部分(比如一些导出符号)链接,就必须声明为 GPL 兼容的许可。不声明或者声明为其他不兼容的许可,可能会导致模块无法加载,或者在加载时被内核拒绝。
`MODULE_AUTHOR("Your Name");`: 标识驱动的作者。
`MODULE_DESCRIPTION("A simple character device driver");`: 简单描述驱动的功能。

核心:文件操作结构体 `struct file_operations`

字符设备之所以能像普通文件一样被访问(`open`, `read`, `write`, `ioctl` 等),是因为内核为每个设备维护了一个 `file_operations` 结构体。这个结构体里定义了一系列的函数指针,当用户空间对设备文件执行某个操作时(比如 `read`),内核就会根据打开设备时关联的 `file_operations` 结构体,找到对应的 `read` 函数指针,然后调用我们驱动里实现的那个函数。

```c
// ... 前面的代码 ...

static ssize_t my_char_driver_read(struct file filp, char __user buf, size_t count, loff_t ppos)
{
// 实现读取逻辑
return 0;
}

static ssize_t my_char_driver_write(struct file filp, const char __user buf, size_t count, loff_t ppos)
{
// 实现写入逻辑
return 0;
}

static const struct file_operations my_char_driver_fops = {
.owner = THIS_MODULE, // 声明模块所有者,用于引用计数
.read = my_char_driver_read, // 指定读取操作对应的函数
.write = my_char_driver_write, // 指定写入操作对应的函数
// ... 其他操作,比如 open, release, ioctl 等 ...
};

// ... 后面的代码 ...
```

`static ssize_t my_char_driver_read(struct file filp, char __user buf, size_t count, loff_t ppos)`: 这是驱动实现的读取函数。
`struct file filp`: 指向 `struct file` 结构体,代表一个已经打开的文件。这里面包含了当前文件操作的一些上下文信息,比如文件偏移量(`filp>f_pos`)。
`char __user buf`: 这是用户空间的缓冲区指针。重要! `__user` 宏表示这个指针指向的是用户空间,内核代码在访问它时必须使用特殊的内核函数(如 `copy_to_user`,`copy_from_user`)来确保安全,直接解引用 `__user` 指针会导致内核崩溃("panic")。
`size_t count`: 用户空间期望读取的字节数。
`loff_t ppos`: 指向文件当前偏移量("position")的指针。`loff_t` 是一个64位整数类型,用于处理大文件。通常,我们在读取后需要更新这个偏移量,以便下次读取能够从正确的位置开始。
返回值: 通常返回实际读取到的字节数。如果返回 0,表示文件结束(EOF)。如果发生错误,返回一个负的错误码(比如 `EFAULT` 表示用户空间地址无效)。
`static ssize_t my_char_driver_write(struct file filp, const char __user buf, size_t count, loff_t ppos)`: 这是驱动实现的写入函数。
`const char __user buf`: 用户空间传来的数据缓冲区指针,同样标记为 `__user`。
返回值: 通常返回实际写入的字节数。如果发生错误,返回一个负的错误码。
`static const struct file_operations my_char_driver_fops = { ... }`: 这是定义了我们驱动提供的文件操作的集合。
`.owner = THIS_MODULE`: 这是一个非常重要的字段。`THIS_MODULE` 是一个特殊的宏,它指向当前正在加载的模块。将 `owner` 设置为 `THIS_MODULE`,就告诉内核,这个 `file_operations` 结构体是由当前模块提供的。这样做的好处是:
1. 引用计数: 内核会在加载模块时增加这个模块的引用计数。当有文件打开并使用了这个 `file_operations` 结构体时,模块的引用计数就不会减少,也就保证了模块在被使用期间不会被卸载。只有当所有使用该 `file_operations` 的文件都被关闭后,模块的引用计数才会降到零,允许卸载。
2. 安全: 防止在模块卸载后,仍然有人尝试访问该模块的函数。
`.read = my_char_driver_read`: 将我们自己实现的 `my_char_driver_read` 函数赋值给 `read` 字段。
`.write = my_char_driver_write`: 同理,将 `my_char_driver_write` 赋值给 `write` 字段。

注册与注销设备

现在,我们需要告诉内核,我们有一个字符设备,并且它提供哪些文件操作。这通常在 `init` 和 `exit` 函数中完成。

```c
include
include
include
include // 字符设备模型的核心头文件
include // 用于创建 /dev 目录下的设备节点
include // 用于 copy_to_user, copy_from_user

define DEVICE_NAME "my_char_device" // 我们给设备起的名字,会在 /dev 目录下生成 my_char_device
define CLASS_NAME "my_class" // 我们创建的设备类名

static int major_number; // 存储设备的major号
static int minor_number = 0; // 存储设备的minor号(通常一个major号可以对应多个minor号)
static struct cdev my_cdev; // 字符设备结构体
static struct class my_class; // 设备类指针
static struct device my_device; // 设备指针

// ... (my_char_driver_read, my_char_driver_write, my_char_driver_fops) ...

static int __init my_char_driver_init(void)
{
int ret;

// 1. 分配一个主设备号(major number)
// alloc_chrdev_region 会自动分配一个未被使用的主设备号和一个次设备号(minor number)
// 这里的 0 表示从 minor number 0 开始,1 表示分配一个设备号
ret = alloc_chrdev_region(NULL, minor_number, 1, DEVICE_NAME);
if (ret < 0) {
printk(KERN_WARNING "my_char_driver: Failed to register device ");
return ret;
}
major_number = MAJOR(ret); // 从返回的dev_t中提取major号
printk(KERN_INFO "my_char_driver: Registered device with major number %d ", major_number);

// 2. 初始化字符设备结构体
cdev_init(&my_cdev, &my_char_driver_fops);
my_cdev.owner = THIS_MODULE; // 和 file_operations 中的 owner 作用类似,标记设备所有者

// 3. 将字符设备添加到系统中
// MKDEV(major, minor) 用于创建一个 dev_t 类型的值,将major和minor组合起来
ret = cdev_add(&my_cdev, MKDEV(major_number, minor_number), 1);
if (ret < 0) {
printk(KERN_WARNING "my_char_driver: Failed to add cdev ");
unregister_chrdev_region(MKDEV(major_number, minor_number), 1); // 出错时要清理已分配的设备号
return ret;
}

// 4. 创建设备类
my_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(my_class)) {
printk(KERN_WARNING "my_char_driver: Failed to create device class ");
cdev_del(&my_cdev); // 出错时要清理cdev
unregister_chrdev_region(MKDEV(major_number, minor_number), 1);
return PTR_ERR(my_class); // 返回错误码
}
printk(KERN_INFO "my_char_driver: Device class '%s' created ", CLASS_NAME);

// 5. 创建设备节点(/dev/my_char_device)
// device_create 会在 /dev 目录下创建对应的设备文件,并与我们注册的设备关联
// 第一个参数是设备类,第二个是设备属性,第三个是设备号,第四个是父设备(NULL表示无父设备),第五个是设备名称
my_device = device_create(my_class, NULL, MKDEV(major_number, minor_number), NULL, DEVICE_NAME);
if (IS_ERR(my_device)) {
printk(KERN_WARNING "my_char_driver: Failed to create device node ");
class_destroy(my_class); // 出错时要清理设备类
cdev_del(&my_cdev);
unregister_chrdev_region(MKDEV(major_number, minor_number), 1);
return PTR_ERR(my_device);
}
printk(KERN_INFO "my_char_driver: Device node '/dev/%s' created ", DEVICE_NAME);

printk(KERN_INFO "my_char_driver: Initialized ");
return 0; // 成功
}

static void __exit my_char_driver_exit(void)
{
// 卸载时的清理工作,顺序与初始化相反
printk(KERN_INFO "my_char_driver: Unloading module ");

// 1. 销毁设备节点
device_destroy(my_class, MKDEV(major_number, minor_number));
// 2. 销毁设备类
class_destroy(my_class);
// 3. 从系统中移除字符设备
cdev_del(&my_cdev);
// 4. 释放设备号
unregister_chrdev_region(MKDEV(major_number, minor_number), 1);

printk(KERN_INFO "my_char_driver: Module unloaded ");
}

module_init(my_char_driver_init);
module_exit(my_char_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");

// 实际的读写函数实现
static char msg[256]; // 一个全局缓冲区,模拟设备数据
static int device_open_count = 0; // 记录设备打开次数,用于简单控制

static ssize_t my_char_driver_read(struct file filp, char __user buf, size_t count, loff_t ppos)
{
int bytes_read = 0;
int ret;

// 检查是不是文件结束了
if (ppos >= strlen(msg)) {
return 0; // 返回0表示文件结束
}

// 确定要读取多少字节
if (count > strlen(msg) ppos) {
count = strlen(msg) ppos; // 不要读取超过缓冲区总长度
}

// 将内核缓冲区的数据复制到用户空间缓冲区
// copy_to_user(用户空间地址, 内核空间地址, 字节数)
ret = copy_to_user(buf, msg + ppos, count);
if (ret > 0) { // 如果copy_to_user返回非0,表示有字节未能成功复制
printk(KERN_WARNING "my_char_driver: Failed to copy data to user space ");
return EFAULT; // 返回一个表示地址错误(User space address error)的错误码
}

// 更新文件偏移量
ppos += count;
bytes_read = count;

printk(KERN_INFO "my_char_driver: Read %d bytes ", bytes_read);
return bytes_read; // 返回实际读取的字节数
}

static ssize_t my_char_driver_write(struct file filp, const char __user buf, size_t count, loff_t ppos)
{
// 限制写入的大小,防止溢出
if (count > sizeof(msg)) {
count = sizeof(msg);
}

// 将用户空间缓冲区的数据复制到内核缓冲区
// copy_from_user(内核空间地址, 用户空间地址, 字节数)
if (copy_from_user(msg, buf, count) != 0) {
printk(KERN_WARNING "my_char_driver: Failed to copy data from user space ");
return EFAULT; // 返回地址错误
}

// 确保写入的数据以 null 结尾,方便后面的读取
msg[count] = '';

// 更新文件偏移量,这里简单地将所有写入操作都视为在开头
// 更复杂的驱动会根据 ppos 来确定写入位置
ppos += count;

printk(KERN_INFO "my_char_driver: Wrote %zu bytes to device ", count);
return count; // 返回实际写入的字节数
}

// 可以在这里添加 open 和 release 函数
static int my_char_driver_open(struct inode inode, struct file filp)
{
if (device_open_count) {
// 简单示例,只允许一个设备被打开
return EBUSY; // 设备忙
}
device_open_count++;
// 可以在这里初始化一些设备私有数据,如果需要的话
// filp>private_data = ...;
printk(KERN_INFO "my_char_driver: Device opened ");
return 0; // 成功
}

static int my_char_driver_release(struct inode inode, struct file filp)
{
device_open_count;
// 可以在这里清理与设备关联的私有数据
// kfree(filp>private_data);
printk(KERN_INFO "my_char_driver: Device closed ");
return 0; // 成功
}

// 更新 file_operations 结构体
static const struct file_operations my_char_driver_fops = {
.owner = THIS_MODULE,
.open = my_char_driver_open, // 添加 open 操作
.read = my_char_driver_read,
.write = my_char_driver_write,
.release = my_char_driver_release, // 添加 release 操作
};
```

让我们一步步来理解这些注册和创建过程:

1. `alloc_chrdev_region(NULL, minor_number, 1, DEVICE_NAME);`:
这是用来申请一个或多个字符设备号的函数。Linux 系统中,每个字符设备都有一个由“主设备号”和“次设备号”组成的唯一标识符。
`NULL`: 这个参数可以指定一个 `dev_t` 变量来接收分配的设备号。如果传 `NULL`,则表示内核会自动管理。
`minor_number`: 指定我们希望分配的起始次设备号。
`1`: 表示我们要申请多少个连续的次设备号。我们只需要一个设备。
`DEVICE_NAME`: 为这个设备号指定一个名字,方便内核日志等使用。
返回值: 如果成功,返回一个 `dev_t` 类型的值,其中包含了分配到的主设备号和次设备号。`MAJOR(dev_t)` 和 `MINOR(dev_t)` 是宏,用于从 `dev_t` 中提取主次设备号。如果失败,则返回一个负的错误码。
为什么需要设备号? 用户空间程序通过 `/dev` 目录下的设备文件来操作设备。当用户打开 `/dev/my_char_device` 时,内核需要知道它对应的是哪个设备(通过设备号)。

2. `cdev_init(&my_cdev, &my_char_driver_fops);`:
`struct cdev my_cdev;`: `cdev` 结构体是 Linux 内核用来表示一个字符设备的。它包含了设备的私有数据、文件操作指针等信息。
`cdev_init` 函数的作用就是初始化这个 `cdev` 结构体,并将其与我们提供的 `file_operations` 结构体关联起来。

3. `cdev_add(&my_cdev, MKDEV(major_number, minor_number), 1);`:
`cdev_add` 函数将我们初始化好的 `cdev` 添加到内核的字符设备列表中。
`MKDEV(major_number, minor_number)`: 这个宏将我们之前分配到的主设备号和次设备号组合成一个 `dev_t` 类型的值,用来唯一标识我们的设备。
`1`: 表示这个 `cdev` 结构体代表 1 个设备(从 `minor_number` 开始)。
作用: 这一步之后,内核就知道了我们有一个叫 `my_char_device` 的设备,它由 `my_cdev` 结构体管理,并且关联了 `my_char_driver_fops`。

4. `class_create(THIS_MODULE, CLASS_NAME);`:
在现代 Linux 内核中,设备管理通常会用到“设备模型”(Device Model)。设备模型使用“类”(Class)来组织设备。
`class_create` 函数创建一个新的设备类。设备类可以看作是设备的分类。用户空间工具(比如 `udev`)会根据设备类来自动创建 `/dev` 下的设备节点。
`CLASS_NAME`: 我们给这个设备类起的名字。

5. `device_create(my_class, NULL, MKDEV(major_number, minor_number), NULL, DEVICE_NAME);`:
`device_create` 函数是设备模型中创建设备节点的关键函数。
`my_class`: 指定这个设备属于哪个设备类。
`NULL`: 指定父设备。这里设置为 `NULL`,表示没有父设备。
`MKDEV(major_number, minor_number)`: 提供设备的设备号。
`NULL`: 设备属性(通常用 `kobject_attr` 来描述,这里不用)。
`DEVICE_NAME`: 设备在 `/dev` 目录下的名字。
作用: 这个函数会在 `/dev` 目录下创建一个名为 `my_char_device` 的设备文件,并将其与我们注册的设备号和设备类关联起来。当用户空间的程序打开 `/dev/my_char_device` 时,内核会根据设备号找到对应的 `cdev`,进而找到 `file_operations`,并执行对应的函数。

卸载时的清理:
`__exit` 函数中的 `device_destroy`, `class_destroy`, `cdev_del`, `unregister_chrdev_region` 这些函数是与初始化时的函数相对应的,用于撤销之前进行的注册和创建操作。顺序很重要:通常需要先删除设备节点,再删除设备类,然后移除 `cdev`,最后释放设备号。这是为了确保在清理过程中,没有正在使用的资源(比如打开的设备文件)指向已经被部分清理的结构。

`copy_to_user` 和 `copy_from_user`

这是在内核驱动开发中处理用户空间和内核空间数据交互最核心的两个函数。

`copy_to_user(void __user to, const void from, unsigned long n);`
目的: 将数据从内核空间 (`from`) 复制到用户空间 (`to`)。
`to`: 指向用户空间的缓冲区。
`from`: 指向内核空间的缓冲区(通常是我们自己的数据)。
`n`: 要复制的字节数。
返回值: 返回实际未能成功复制的字节数。如果返回 `0`,表示所有数据都已成功复制。如果返回非零值,说明发生了错误(通常是用户空间地址无效)。
为什么不用 `memcpy`? 直接用 `memcpy` 复制用户空间地址是极其危险的。用户空间地址随时可能无效(比如进程退出了,或者地址映射被改变了),直接访问会导致内核崩溃(Panic)。`copy_to_user` 函数会进行一系列的安全检查,确保复制操作是安全的。

`copy_from_user(void to, const void __user from, unsigned long n);`
目的: 将数据从用户空间 (`from`) 复制到内核空间 (`to`)。
`to`: 指向内核空间的缓冲区。
`from`: 指向用户空间的缓冲区(通常是用户传来的数据)。
`n`: 要复制的字节数。
返回值: 返回实际未能成功复制的字节数。如果返回 `0`,表示所有数据都已成功复制。如果返回非零值,说明发生了错误。

`open` 和 `release` 操作

`my_char_driver_open`: 当用户空间程序第一次打开设备文件(例如 `open("/dev/my_char_device", O_RDWR)`)时,会调用这个函数。
`struct inode inode`: 指向 `inode` 结构体,包含文件系统对象的元信息。
`struct file filp`: 指向 `file` 结构体,代表一个已打开的文件实例。
作用:
可以在这里实现设备初始化逻辑,比如检查设备是否已经被打开(像我们的 `device_open_count` 示例)。
可以为这个打开的文件实例分配一些私有数据,并将其存放在 `filp>private_data` 中,方便后续的 `read`, `write` 等操作访问。
进行权限检查等。
返回值: 0 表示成功,负值表示错误。

`my_char_driver_release`: 当用户空间程序关闭设备文件(例如 `close(fd)`)时,或者当所有指向该文件的 `file` 结构体的引用都消失时(比如进程退出),会调用这个函数。
作用:
在这里进行设备清理工作,释放 `open` 时分配的资源。
减少 `device_open_count`。
如果是使用 `filp>private_data` 存储的资源,在此处需要 `kfree()` 释放。
返回值: 0 表示成功,负值表示错误。

驱动的测试

1. 编写 Makefile:
```makefile
objm += my_char_driver.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
```
2. 编译: 在终端运行 `make`。
3. 加载驱动: `sudo insmod my_char_driver.ko`
4. 查看日志: `dmesg` 命令会显示内核打印的信息,包括我们 `printk` 输出的内容。
5. 测试设备:
创建设备节点(如果 `device_create` 没问题,应该已经自动创建了 `/dev/my_char_device`)。
写入数据: `echo "Hello from user space" | sudo tee /dev/my_char_device`
读取数据: `sudo cat /dev/my_char_device`
打开/关闭: 编写一个简单的 C 程序,使用 `open()`, `read()`, `write()`, `close()` 系统调用来测试。
6. 卸载驱动: `sudo rmmod my_char_driver`

总结

总的来说,一个简单的字符设备驱动,就是:

1. 模块初始化与退出: 定义 `module_init` 和 `module_exit`,进行资源的申请和释放。
2. 文件操作: 实现 `struct file_operations` 中的函数(如 `read`, `write`, `open`, `release`),这些函数是驱动的核心逻辑。
3. 注册设备:
使用 `alloc_chrdev_region` 申请设备号。
使用 `cdev_init` 和 `cdev_add` 将设备注册到内核。
使用 `class_create` 和 `device_create` 在 `/dev` 目录下创建设备节点,方便用户访问。
4. 数据安全: 使用 `copy_to_user` 和 `copy_from_user` 安全地在内核空间和用户空间之间传输数据。

这些是创建第一个字符设备驱动最基础也最重要的概念。希望这些解释能帮你梳理清楚代码的功能。如果在实践中遇到任何问题,或者对某个细节还有疑问,尽管提出来!一起慢慢摸索,这是学习驱动开发的乐趣所在。

网友意见

user avatar

你应该不是对代码的逻辑不理解,你是对Linux内核的字符设备驱动的运行逻辑不理解,或者往深了说你是对Linux内核的逻辑不理解。

因为上面read_test(), write_test()和open_test()三个函数的实现非常简单,即使光从代码的字面翻译都能看出来它们在干嘛。

read_test就是_put_user('a',buf),顾名思义就是put字符'a'到userspace的buf,这个buf就是你使用read系统调用时指定的buf。(man 2 read)

write_test则更简单,它就直接return count,顾名思义就是你让我写多少(count)个字符,我就直接告诉我我写完了多少(count)个,但是我不真的写,什么操作都没有。也就是直接返回你调用write系统调用时指定的count参数,别的都不管。(man 2 write)

open_test也是只有一个操作,try_module_get(THIS_MODULE)就是字面的意思,尝试获取一下当前的模块。能get到就说明模块被加载了,get不到就说名模块不在。这个函数没有使用open系统调用的任何参数做任何操作。(man 2 open)

你将这三个基本操作注册到你的字符设备的file_operations中,那么会在open, read和write的时候调用这三个函数。所以如果你顺利加载了你的这个模块(我没有检查代码有没有错误),理论上你就是得到一个自己命名的字符设备,然后你读它会得到字符a,写它则直接返回成功没有任何改变。

所以当你说你不懂这个驱动代码的意思的时候,你应该想想自己是真不懂代码的意思,还是不懂代码背后的基本知识,比如上述三个函数分别在字符设备被操作的哪个时刻被执行,过程时怎么样的,谁调用它们,它们又返回给谁?等等……有时候我们看书,看的时候觉得每句话都看懂了,但是合上书却一个完整的概念说不出来。

PS:以后记得把代码的部分至少放在代码块中(我刚给放进去了),代码格式都混乱的问题真的很不招人待见,看见这种代码格式后回不回答真的看各个答主的心情了。

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有