原文:
annas-archive.org/md5/e409561761c67e6644a54ed53a248850
译者:飞龙
第八章:第八章:编写 I2C 设备驱动程序
I2C代表集成电路互联。它是由 Philips(现在是 NXP)发明的串行、支持多主机的异步总线,尽管多主机模式并不广泛使用。I2C 是一个双线总线,分别称为串行数据(SDA)和串行时钟(SCL,或SCK)。I2C 设备是通过 I2C 总线与其他设备进行交互的芯片。在此总线上,SDA 和 SCL 都是开漏/开集电极,这意味着每个设备可以将其输出拉低,但都不能将输出拉高,而是需要上拉电阻。SCL 由主设备生成,用于同步数据(通过 SDA 传输)在总线上的传输。主设备和从设备都可以发送数据(当然不是同时),因此 SDA 是双向线。也就是说,SCL 信号也是双向的,因为从设备可以通过保持 SCL 线低电平来拉伸时钟。总线由主设备控制,在我们的案例中,主设备是系统级芯片(SoC)的一部分。该总线在嵌入式系统中广泛使用,用于连接串行 EEPROM、RTC 芯片、GPIO 扩展器、温度传感器等设备。
以下图示展示了连接到 I2C 总线的各种设备(也称为从设备):
图 8.1 – I2C 总线和设备表示
从上面的示意图来看,我们可以将 Linux 内核的 I2C 框架表示如下:
CPU <--platform bus-->i2c adapter<---i2c bus---> i2c slave
CPU 是主机,负责管理 I2C 控制器,也叫做 I2C 适配器,它实现了 I2C 协议,并管理承载 I2C 设备的总线段。在内核 I2C 框架中,适配器由平台驱动管理,而从设备由 I2C 驱动管理。然而,两个驱动程序都使用 I2C 核心提供的 API。在本章中,我们将重点介绍 I2C(从设备)驱动程序,尽管如果有需要,也会提到适配器。
回到硬件,I2C 时钟速度从 10 kHz 到 100 kHz 不等,也可以从 400 kHz 到 2 MHz。I2C 没有严格的数据传输速度要求,所有连接在特定总线上的从设备将使用该总线配置的相同时钟速度。这与内核源代码中的drivers/i2c/busses/i2c-imx.c
不同,I2C 的规范可以在www.nxp.com/docs/en/user-guide/UM10204.pdf
找到。
现在我们知道将要处理 I2C 设备驱动程序,本章将涉及以下主题:
-
Linux 内核中的 I2C 框架抽象
-
I2C 驱动程序的抽象和架构
-
如何不编写 I2C 设备驱动程序
Linux 内核中的 I2C 框架抽象
Linux 内核 I2C 框架由几个数据结构组成,其中最重要的是以下内容:
-
i2c_adapter
:用于抽象 I2C 主设备。它用于标识一个物理 I2C 总线。 -
i2c_algorithm
:它抽象了 I2C 总线事务接口。这里的事务意味着传输,例如读取或写入操作。 -
i2c_client
:用于抽象表示位于 I2C 总线上的从设备。 -
i2c_driver
:从设备的驱动程序。它包含一组特定的驱动功能,用于处理该设备。 -
i2c_msg
:这是 I2C 事务一个段的低级表示。该数据结构定义了设备地址、事务标志(例如,它是传输还是接收)、指向要发送/接收的数据的指针,以及数据的大小。
由于本章的范围仅限于从设备驱动程序,我们将重点关注最后三个数据结构。不过,为了帮助您理解这一点,我们需要介绍适配器和算法数据结构。
对 struct i2c_adapter
的简要介绍
内核使用 struct i2c_adapter
来表示一个物理的 I2C 总线,并包含访问该总线所需的算法。它的定义如下:
struct i2c_adapter {
struct module *owner;
const struct i2c_algorithm *algo;
[...]
};
在前面的数据结构中,我们有以下内容:
-
owner
:大多数情况下,这个值被设置为THIS_MODULE
。它是所有者,并用于引用计数。 -
algo
:这是一个回调函数集,由控制器(主设备)驱动程序用于驱动 I2C 线路。这些回调允许你生成访问 I2C 总线所需的信号。
算法的数据结构有如下定义:
struct i2c_algorithm {
int (*master_xfer)(struct i2c_adapter *adap,
struct i2c_msg *msgs, int num);
int (*smbus_xfer)(struct i2c_adapter *adap, u16 addr,
unsigned short flags, char read_write,
u8 command, int size,
union i2c_smbus_data *data);
/* To determine what the adapter supports */
u32 (*functionality)(struct i2c_adapter *adap);
[...]
};
在前面的数据结构中,已省略不重要的字段。让我们看一下摘录中的每个元素:
-
master_xfer
:这是核心传输函数。对于该算法驱动程序提供的基本 I2C 访问,必须提供此函数。当 I2C 设备驱动程序需要与底层 I2C 设备通信时,会调用此函数。然而,如果它未实现(如果是NULL
),则会调用smbus_xfer
函数。 -
smbus_xfer
:这是一个函数指针,如果 I2C 控制器驱动程序的算法驱动程序可以执行 SMBus 访问,它会被 I2C 控制器驱动程序设置。每当 I2C 芯片驱动程序希望使用 SMBus 协议与芯片设备通信时,就会使用它。如果它是NULL
,则使用master_xfer
函数,SMBus 会被模拟。 -
functionality
:这是一个函数指针,由 I2C 核心调用,用于确定适配器的功能。它通知您该 I2C 适配器驱动程序可以进行何种类型的读取和写入操作。
在前面的代码中,functionality
是一个健全性回调函数。核心或设备驱动程序可以调用它(通过 i2c_check_functionality()
)来检查在我们启动访问之前,给定的适配器是否能够提供所需的 I2C 访问。例如,并不是所有的适配器都支持 10 位寻址模式。因此,在芯片驱动中调用 i2c_check_functionality(client->adapter, I2C_FUNC_10BIT_ADDR)
来检查适配器是否支持这一功能是安全的。所有的标志都是以 I2C_FUNC_XXX
的形式表示的。虽然每个标志可以单独检查,但 I2C 核心已经将它们拆分为逻辑功能,如下所示:
#define I2C_FUNC_I2C 0x00000001
#define I2C_FUNC_10BIT_ADDR 0x00000002
#define I2C_FUNC_SMBUS_BYTE (I2C_FUNC_SMBUS_READ_BYTE | \
I2C_FUNC_SMBUS_WRITE_BYTE)
#define I2C_FUNC_SMBUS_BYTE_DATA \
(I2C_FUNC_SMBUS_READ_BYTE_DATA | \
I2C_FUNC_SMBUS_WRITE_BYTE_DATA)
#define I2C_FUNC_SMBUS_WORD_DATA \
(I2C_FUNC_SMBUS_READ_WORD_DATA | \
I2C_FUNC_SMBUS_WRITE_WORD_DATA)
#define I2C_FUNC_SMBUS_BLOCK_DATA \
(I2C_FUNC_SMBUS_READ_BLOCK_DATA | \
I2C_FUNC_SMBUS_WRITE_BLOCK_DATA)
#define I2C_FUNC_SMBUS_I2C_BLOCK \
(I2C_FUNC_SMBUS_READ_I2C_BLOCK | \
I2C_FUNC_SMBUS_WRITE_I2C_BLOCK)
在前面的代码中,您可以检查 I2C_FUNC_SMBUS_BYTE
标志,以确保适配器支持 SMBus 字节定向命令。
本介绍关于 I2C 控制器的内容将在后续章节中根据需要进行参考。尽管本章的主要目的是讨论 I2C 客户端驱动程序(我们将在下一节中讨论),但理解这些内容可能会更有意义。
I2C 客户端和驱动程序数据结构
第一个也是最明显的数据结构是 struct i2c_client
结构,它的声明方式如下:
struct i2c_client {
unsigned short flags;
unsigned short addr;
char name[I2C_NAME_SIZE];
struct i2c_adapter *adapter;
struct device dev;
int irq;
};
在前面的数据结构中,包含 I2C 设备的属性,flags
表示设备标志,其中最重要的是表示是否为 10 位芯片地址的标志。addr
包含芯片地址。对于 7 位地址芯片,它将存储在最低的 7 位中。name
包含设备名称,限制为 I2C_NAME_SIZE
(在 include/linux/mod_devicetable.h
中设置为 20
)个字符。adapter
是设备所在的适配器(记住,它是 I2C 总线)。dev
是设备模型的底层设备结构,irq
是分配给设备的中断线。
现在我们已经熟悉了 I2C 设备的数据结构,让我们关注它的驱动程序,它由 struct i2c_driver
抽象表示。它可以这样声明:
struct i2c_driver {
unsigned int class;
/* Standard driver model interfaces */
int (*probe)(struct i2c_client *client,
const struct i2c_device_id *id);
int (*remove)(struct i2c_client *client);
int (*probe_new)(struct i2c_client *client);
void (*shutdown)(struct i2c_client *client);
struct device_driver driver;
const struct i2c_device_id *id_table;
};
让我们看看数据结构中的每个元素:
-
probe
:设备绑定的回调函数,成功时应返回 0,失败时返回相应的错误代码。 -
remove
:设备解绑的回调函数。它必须撤销在probe
中所做的操作。 -
shutdown
:设备关闭的回调函数。 -
probe_new
:新的驱动程序模型接口。它将废弃传统的probe
方法,以去除其常用的未使用第二个参数(即struct i2c_device_id
参数)。 -
driver
:底层设备驱动模型的驱动程序结构。 -
id_table
:该驱动程序支持的 I2C 设备列表。
该系列的第三个也是最后一个数据结构是 struct i2c_msg
,它代表一次 I2C 事务的一个操作。它的声明方式如下:
struct i2c_msg {
__u16 addr;
__u16 flags;
#define I2C_M_TEN 0x0010
#define I2C_M_RD 0x0001
__u16 len;
__u8 * buf;
};
这个数据结构中的每个元素都可以自我解释。让我们更详细地看看它们:
-
addr
:这始终是从设备地址。 -
flags
:因为一个事务可能由多个操作组成,因此该元素表示此操作的标志。如果是写操作(主设备发送给从设备),则应将其设置为0
。但是,如果是读操作(主设备从从设备读取数据),则可以将其与I2C_M_RD
或I2C_M_TEN
进行或运算,I2C_M_TEN
适用于 10 位芯片地址的设备。 -
length
:这是缓冲区中数据的大小。在读取操作中,它对应于要从设备读取的字节数,并存储在buf
中。在写操作中,它表示要写入设备的buf
中的字节数。 -
buf
:这是读/写缓冲区,必须根据length
分配。注意
由于
i2c_msg.len
是u16
类型,您必须确保您的读/写缓冲区的大小始终小于 216(64k)。
现在我们已经讨论了最重要的 I2C 数据结构,让我们看看 I2C 核心暴露的 API(大多数涉及 I2C 适配器的底层实现),以便充分利用我们的设备。
I2C 通信 API
一旦驱动程序和数据结构初始化完成,从属设备和主设备之间的通信就可以开始。串行总线事务仅仅是简单的寄存器访问问题,无论是读取还是写入其内容。I2C 设备遵循这一原则。
普通 I2C 通信
我们将从最低层开始——i2c_transfer()
是用于传输 I2C 消息的核心函数。其他 API 封装了此函数,背后由适配器的 algo->master_xfer
支持。以下是其原型:
int i2c_transfer(struct i2c_adapter *adap,
struct i2c_msg *msg, int num);
使用 i2c_transfer()
时,在同一事务的相同读/写操作中,字节之间不会发送停止位。这对于那些在地址写入和数据读取之间不需要停止位的设备很有用,例如。以下代码展示了它的使用方式:
static int i2c_read_bytes(struct i2c_client *client,
u8 cmd, u8 *data, u8 data_len)
{
struct i2c_msg msgs[2];
int ret;
u8 *buffer;
buffer = kzalloc(data_len, GFP_KERNEL);
if (!buffer)
return -ENOMEM;;
msgs[0].addr = client->addr;
msgs[0].flags = client->flags;
msgs[0].len = 1;
msgs[0].buf = &cmd;
msgs[1].addr = client->addr;
msgs[1].flags = client->flags | I2C_M_RD;
msgs[1].len = data_len;
msgs[1].buf = buffer;
ret = i2c_transfer(client->adapter, msgs, 2);
if (ret < 0)
dev_err(&client->adapter->dev,
"i2c read failed\n");
else
memcpy(data, buffer, data_len);
kfree(buffer);
return ret;
}
如果设备在读取序列中间需要一个停止位,您应该将事务拆分为两部分(两个操作)——i2c_transfer
用于地址写入(包含单次写操作的事务),另一个 i2c_transfer
用于数据读取(包含单次读操作的事务),如下所示:
static int i2c_read_bytes(struct i2c_client *client,
u8 cmd, u8 *data, u8 data_len)
{
struct i2c_msg msgs[2];
int ret;
u8 *buffer;
buffer = kzalloc(data_len, GFP_KERNEL);
if (!buffer)
return -ENOMEM;;
msgs[0].addr = client->addr;
msgs[0].flags = client->flags;
msgs[0].len = 1;
msgs[0].buf = &cmd;
ret = i2c_transfer(client->adapter, msgs, 1);
if (ret < 0) {
dev_err(&client->adapter->dev,
"i2c read failed\n");
kfree(buffer);
return ret;
}
msgs[1].addr = client->addr;
msgs[1].flags = client->flags | I2C_M_RD;
msgs[1].len = data_len;
msgs[1].buf = buffer;
ret = i2c_transfer(client->adapter, &msgs[1], 1);
if (ret < 0)
dev_err(&client->adapter->dev,
"i2c read failed\n");
else
memcpy(data, buffer, data_len);
kfree(buffer);
return ret;
}
否则,您可以使用其他替代 API,如 i2c_master_send
和 i2c_master_recv
,分别用于发送和接收数据:
int i2c_master_send(struct i2c_client *client,
const char *buf, int count);
int i2c_master_recv(struct i2c_client *client,
char *buf, int count);
这些 API 都是在 i2c_transfer()
的基础上实现的。i2c_master_send()
实际上实现了一个包含单次写操作的 I2C 事务,而 i2c_master_recv()
则实现了一个包含单次读操作的 I2C 事务。
第一个参数是要访问的 I2C 设备。第二个参数是读/写缓冲区,第三个参数表示要读取或写入的字节数。返回值是读取/写入的字节数。以下代码是我们之前摘录的简化版:
static int i2c_read_bytes(struct i2c_client *client,
u8 cmd, u8 *data, u8 data_len)
{
struct i2c_msg msgs[2];
int ret;
u8 *buffer;
buffer = kzalloc(data_len, GFP_KERNEL);
if (!buffer)
return -ENOMEM;;
ret = i2c_master_send(client, &cmd, 1);
if (ret < 0) {
dev_err(&client->adapter->dev,
"i2c read failed\n");
kfree(buffer);
return ret;
}
ret = i2c_master_recv(client, buffer, data_len);
if (ret < 0)
dev_err(&client->adapter->dev,
"i2c read failed\n");
else
memcpy(data, buffer, data_len);
kfree(buffer);
return ret;
}
至此,我们已经熟悉了内核中如何实现普通的 I2C API。然而,我们还需要处理一类设备——SMBus 兼容设备——这些设备不能与 I2C 设备混淆,尽管它们位于同一物理总线上。
系统管理总线(SMBus)兼容的函数
SMBus 是由英特尔开发的双线总线,与 I2C 非常相似。更重要的是,SMBus 是 I2C 的子集,这意味着 I2C 设备兼容 SMBus,但反之则不然。SMBus 是 I2C 的子集,这意味着 I2C 控制器支持大多数 SMBus 操作。然而,对于 SMBus 控制器来说并非如此,因为它们可能不支持 I2C 控制器所支持的所有协议选项。因此,如果你对所编写驱动的芯片有疑问,最好使用 SMBus 方法。
以下是一些 SMBus API 的示例:
s32 i2c_smbus_read_byte_data(struct i2c_client *client,
u8 command);
s32 i2c_smbus_write_byte_data(struct i2c_client *client,
u8 command, u8 value);
s32 i2c_smbus_read_word_data(struct i2c_client *client,
u8 command);
s32 i2c_smbus_write_word_data(struct i2c_client *client,
u8 command, u16 value);
s32 i2c_smbus_read_block_data(struct i2c_client *client,
u8 command, u8 *values);
s32 i2c_smbus_write_block_data(struct i2c_client *client,
u8 command, u8 length,
const u8 *values);
完整的 SMBus API 列表可以在内核源代码的 include/linux/i2c.h
中找到。每个函数都是自解释的。以下示例展示了一个简单的读写操作,使用 SMBus 兼容的 API 访问 I2C GPIO 扩展器:
struct mcp23016 {
struct i2c_client *client;
struct gpio_chip chip;
struct mutex lock;
};
[...]
static int mcp23016_set(struct mcp23016 *mcp,
unsigned offset, intval)
{
s32 value;
unsigned bank = offset / 8;
u8 reg_gpio = (bank == 0) ? GP0 : GP1;
unsigned bit = offset % 8;
value = i2c_smbus_read_byte_data(mcp->client,
reg_gpio);
if (value >= 0) {
if (val)
value |= 1 << bit;
else
value &= ~(1 << bit);
return i2c_smbus_write_byte_data(mcp->client,
reg_gpio, value);
} else
return value;
}
SMBus 部分非常简单,仅包含可用的 API 列表。现在,我们可以使用普通的 I2C 函数或 SMBus 函数访问设备,接下来我们可以开始实现 I2C 驱动程序的主体部分。
I2C 驱动程序抽象和架构
如前一节所示,struct i2c_driver
结构体包含了处理其负责的 I2C 设备所需的驱动方法。一旦设备被添加到总线上,它就需要被探测,这使得 i2c_driver.probe_new
方法成为驱动程序的入口点。
探测 I2C 设备
struct i2c_driver
结构中的 probe()
回调函数每次当一个 I2C 设备在总线上实例化并声明使用该驱动程序时都会被调用。它负责以下任务:
-
使用
i2c_check_functionality()
函数检查 I2C 总线控制器(I2C 适配器)是否支持设备所需的功能。 -
检查设备是否是我们预期的设备
-
初始化设备
-
如果需要,设置特定设备的数据
-
注册到适当的内核框架中
以前,探测回调函数是分配给 struct i2c_driver
的 probe
元素,并且具有以下原型:
int foo_probe(struct i2c_client *client,
const struct i2c_device_id *id)
由于第二个参数很少使用,这个回调函数已经被弃用,取而代之的是 probe_new
,其原型如下:
int probe(struct i2c_client *client)
在前面的原型中,struct i2c_client
指针代表了 I2C 设备本身。这个参数由内核根据设备的描述预构建并初始化,这些描述可以在设备树或板文件中完成。
不建议在 probe
方法中过早地访问设备。由于每个 I2C 适配器具有不同的能力,因此最好先请求设备以了解其支持的功能,并根据这些信息调整驱动程序的行为:
#define CHIP_ID 0x13
#define DA311_REG_CHIP_ID 0x000f
static int fake_i2c_probe(struct i2c_client *client)
{
int err;
int ret;
if (!i2c_check_functionality(client->adapter,
I2C_FUNC_SMBUS_BYTE_DATA))
return -EIO;
/* read family id */
ret = i2c_smbus_read_byte_data(client, REG_CHIP_ID);
if (ret != CHIP_ID)
return (ret < 0) ? ret : -ENODEV;
/* register with other frameworks */
[...]
return 0;
}
在前面的示例中,我们检查了底层适配器是否支持设备所需的类型/命令。只有在通过了成功的健全性检查后,我们才能安全地访问设备,并在必要时进一步分配资源并与其他框架注册。
实现i2c_driver.remove
方法
i2c_driver.remove
回调必须撤销在probe
函数中所做的工作。它必须从每个在probe
中注册的框架中注销,并释放请求的每个资源。该回调具有以下原型:
static int remove(struct i2c_device *client)
在前面的代码行中,client
是核心传递给probe
方法的相同 I2C 设备数据结构。这意味着你在探测时存储的任何数据都可以在这里检索。例如,你可能需要根据在probe
函数中设置的私有数据来处理一些清理工作或其他操作:
static int mc9s08dz60_remove(struct i2c_client *client)
{
struct mc9s08dz60 *mc9s;
/* We retrieve our private data */
mc9s = i2c_get_clientdata(client);
/* Which hold gpiochip we want to work on */
return gpiochip_remove(&mc9s->chip);
}
前面的示例简单且可能代表你在驱动程序中看到的大多数情况。由于此回调应在成功时返回零,失败的原因可能包括设备无法关机、设备仍在使用中等。这意味着可能会有一些情况,在这些情况下你需要查询设备并在此回调函数中执行一些额外的操作。
在开发过程的这个阶段,所有回调都已准备好。现在,是时候让驱动程序向 I2C 核心注册了,我们将在下一节中看到这一点。
驱动程序初始化和注册
I2C 驱动程序通过i2c_add_driver()
和i2c_del_driver()
API 与核心进行注册和注销。前者是一个宏,背后由i2c_register_driver()
函数实现。以下代码展示了它们各自的原型:
int i2c_add_driver(struct i2c_driver *drv);
void i2c_del_driver(struct i2c_driver *drv);
在这两个函数中,drv
是先前设置的 I2C 驱动程序结构。注册 API 在成功时返回零,失败时返回负错误代码。
驱动程序的注册通常发生在模块初始化中,而注销此驱动程序通常在模块退出方法中完成。以下是 I2C 驱动程序注册的典型示例:
static int __init foo_init(void)
{
[...] /*My init code */
return i2c_add_driver(&foo_driver);
}
module_init(foo_init);
static void __exit foo_cleanup(void)
{
[...] /* My clean up code */
i2c_del_driver(&foo_driver);
}
module_exit(foo_cleanup);
如果驱动程序在模块初始化/清理过程中只需要注册/注销驱动程序,那么可以使用module_i2c_driver()
宏来简化前面的代码,如下所示:
module_i2c_driver(foo_driver);
此宏将在构建时扩展为模块中的适当初始化/退出方法,这些方法将处理 I2C 驱动程序的注册/注销。
在驱动程序中配置设备
为了让匹配循环在我们的 I2C 驱动程序中被调用,i2c_driver.id_table
字段必须设置为 I2C 设备 ID 的列表,每个 ID 由struct i2c_device_id
数据结构的一个实例描述,其定义如下:
struct i2c_device_id {
char name[I2C_NAME_SIZE];
kernel_ulong_t driver_data;
};
在上述数据结构中,name
是设备的描述性名称,而driver_data
是驱动程序状态数据,它是驱动程序私有的。它可以设置为指向每个设备的数据结构,例如。此外,为了设备匹配和模块(自动)加载的目的,这个设备 ID 数组还需要传递给MODULE_DEVICE_TABLE
宏。
然而,这与设备树匹配无关。为了使设备树中的设备节点与我们的驱动程序匹配,我们的驱动程序的i2c_driver.device.of_match_table
元素必须设置为一个包含struct of_device_id
类型元素的列表。该列表中的每个条目将描述一个可以从设备树中匹配的 I2C 设备。以下是该数据结构的定义:
struct of_device_id {
[...]
char compatible[128];
const void *data;
};
在上述数据结构中,compatible
是一个相当描述性的字符串,可以用于设备树中匹配该驱动程序,而data
则可以指向任何内容,例如每个设备的资源。同样,为了设备树匹配后的模块(自动)加载,这个列表必须传递给MODULE_DEVICE_TABLE
宏。
以下是一个示例:
#define ID_FOR_FOO_DEVICE 0
#define ID_FOR_BAR_DEVICE 1
static struct i2c_device_id foo_idtable[] = {
{ "foo", ID_FOR_FOO_DEVICE },
{ "bar", ID_FOR_BAR_DEVICE },
{ },
};
MODULE_DEVICE_TABLE(i2c, foo_idtable);
现在,对于设备树匹配后的模块加载,我们需要做以下工作:
static const struct of_device_id foobar_of_match[] = {
{ .compatible = "packtpub,foobar-device" },
{ .compatible = "packtpub,barfoo-device" },
{},
};
MODULE_DEVICE_TABLE(of, foobar_of_match);
该摘录展示了i2c_driver
的最终内容,设置了相应的设备表指针:
static struct i2c_driver foo_driver = {
.driver = {
.name = "foo",
/* The below line adds Device Tree support */
.of_match_table = of_match_ptr(foobar_of_match),
},
.probe = fake_i2c_probe,
.remove = fake_i2c_remove,
.id_table = foo_idtable,
};
在上述代码中,我们可以看到一旦设置完毕,I2C 驱动程序结构将是什么样子。
实例化 I2C 设备
我们将使用设备树声明,因为尽管板文件目前还在旧驱动程序中使用,但它已经是一个远离我们的时代。I2C 设备必须作为它们所在总线节点的子节点(子节点)进行声明。以下是它们绑定所需的属性:
-
reg
:表示设备在总线上的地址。 -
compatible
:这是一个用于将设备与驱动程序匹配的字符串。它必须与驱动程序的of_match_table
中的一个条目匹配。
以下是同一适配器上声明的两个 I2C 设备的示例:
&i2c2 { /* Phandle of the bus node */
pcf8523: rtc@68 {
compatible = "nxp,pcf8523";
reg = <0x68>;
};
eeprom: ee24lc512@55 { /* eeprom device */
compatible = "labcsmart,ee24lc512";
reg = <0x55>;
};
};
上述示例声明了一个地址为0x68
的 RTC 芯片和一个地址为0x55
的 EEPROM,它们都位于同一个总线上,即 SoC 的 I2C 总线 2。I2C 核心将依赖于compatible
字符串属性和i2c_device_id
表来绑定设备和驱动程序。第一次尝试是通过兼容字符串(即OF
样式,即设备树)来匹配设备;如果失败,I2C 核心将尝试通过id
表来匹配设备。
如何不编写 I2C 设备驱动程序
决定不编写设备驱动程序的做法是编写适当的用户代码来处理底层硬件。尽管这是用户代码,但内核始终会介入以简化开发过程。I2C 适配器在用户空间由内核以字符设备的形式暴露,路径为/dev/i2c-<X>
,其中<X>
是总线号。一旦你打开了与设备所在适配器相对应的字符设备文件,你就可以执行一系列命令。
首先,用于从用户空间处理 I2C 设备的所需头文件如下:
#include <linux/i2c-dev.h>
#include <i2c/smbus.h>
#include <linux/i2c.h>
以下是可能的命令:
-
ioctl(file, I2C_FUNCS, unsigned long *funcs)
:此命令可能是您应该发出的第一个命令。它相当于内核中的i2c_check_functionality()
,用于返回所需的适配器功能(在*funcs
参数中)。返回的标志也以I2C_FUNC_*
形式表示:unsigned long funcs; if (ioctl(file, I2C_FUNCS, &funcs) < 0) return -errno; if (!(funcs & I2C_FUNC_SMBUS_QUICK)) { /* Oops, SMBus write_quick) not available! */ exit(1); } /* Now it is safe to use SMBus write_quick command */
-
ioctl(file, I2C_TENBIT, long select)
:在这里,您可以选择与之通信的从设备是否是 10 位地址芯片(select = 1
)或不是(select = 0
)。 -
ioctl(file, I2C_SLAVE, long addr)
:此命令用于设置您需要在此适配器上与之通信的芯片地址。地址存储在addr
的低 7 位中(对于 10 位地址,地址会传递在低 10 位中)。该芯片可能已在使用中,此时您可以使用I2C_SLAVE_FORCE
强制使用。 -
ioctl(file, I2C_RDWR, struct i2c_rdwr_ioctl_data *msgset)
:您可以使用此命令执行不间断的 I2C 读写操作。感兴趣的结构体是struct i2c_rdwr_ioctl_data
,其定义如下:struct i2c_rdwr_ioctl_data { struct i2c_msg *msgs; /* ptr to array of messages */ int nmsgs; /* number of messages to exchange */ }
以下是使用此 IOCTL 的示例:
int ret;
uint8_t buf [5] = {regaddr, '0x55', '0x65',
'0x88', '0x14'};
struct i2c_msg messages[] = {
{
.addr = dev,
.buf = buf,
.len = 5, /* buf size is 5 */
},
};
struct i2c_rdwr_ioctl_data payload = {
.msgs = messages,
.nmsgs = sizeof(messages)
/sizeof(messages[0]),
};
ret = ioctl(file, I2C_RDWR, &payload);
您还可以使用 read()
和 write()
调用进行简单的 I2C 事务(在使用 I2C_SLAVE
设置地址后)。
-
ioctl(file, I2C_SMBUS, struct i2c_smbus_ioctl_data *args)
:此命令用于发起 SMBus 传输。主要的结构体参数具有如下原型:struct i2c_smbus_ioctl_data { __u8 read_write; __u8 command; __u32 size; union i2c_smbus_data __user *data; };
在上述数据结构中,read_write
决定传输方向——I2C_SMBUS_READ
为读取,I2C_SMBUS_WRITE
为写入。command
是芯片可以解释的命令,例如可能是寄存器地址。size
是消息的长度,而 buf
是消息缓冲区。请注意,I2C 核心已经暴露了标准化的大小。这些大小分别是 I2C_SMBUS_BYTE
、I2C_SMBUS_BYTE_DATA
、I2C_SMBUS_WORD_DATA
、I2C_SMBUS_BLOCK_DATA
和 I2C_SMBUS_I2C_BLOCK_DATA
,用于 1、2、3、5 和 8 字节。完整列表请参见 include/uapi/linux/i2c.h
。以下是一个示例,展示了如何在用户空间进行 SMBus 传输:
uint8_t buf [5] = {'0x55', '0x65', '0x88'};
struct i2c_smbus_ioctl_data payload = {
.read_write = I2C_SMBUS_WRITE,
.size = I2C_SMBUS_WORD_DATA,
.command = regaddr,
.data = (void *) buf,
};
ret = ioctl (fd, I2C_SLAVE_FORCE, dev);
if (ret < 0)
/* handle errors */
ret = ioctl (fd, I2C_SMBUS, &payload);
if (ret < 0)
/* handle errors */
由于您可以使用简单的 read()
/write()
系统调用来执行基本的 I2C 传输(尽管每次传输后都会发送停止位),I2C 核心提供了以下 API 来执行 SMBus 传输:
__s32 i2c_smbus_write_quick(int file, __u8 value);
__s32 i2c_smbus_read_byte(int file);
__s32 i2c_smbus_write_byte(int file, __u8 value);
__s32 i2c_smbus_read_byte_data(int file, __u8 command);
__s32 i2c_smbus_write_byte_data(int file, __u8 command,
__u8 value);
__s32 i2c_smbus_read_word_data(int file, __u8 command);
__s32 i2c_smbus_write_word_data(int file, __u8 command,
__u16 value);
__s32 i2c_smbus_read_block_data(int file, __u8 command,
__u8 *values);
__s32 i2c_smbus_write_block_data(int file, __u8 command,
__u8 length, __u8 *values);
建议您使用这些函数,而不是使用 IOCTL。如果发生错误,所有这些事务将返回 -1
;您可以检查 errno
以更好地理解出了什么问题。在成功的情况下,*_write_*
事务将返回 0,而 *_read_*
事务将返回读取的值,除了 *_read_block_*
,它将返回已读取的值的数量。在面向块的操作中,缓冲区不需要超过 32 字节。
除了需要编写代码的 API,你还可以使用 CLI 包 i2ctools
,它附带了以下工具:
-
i2cdetect
:一个命令,用于列举给定适配器上的 I2C 设备。 -
i2cget
:用于转储设备寄存器的内容。 -
i2cset
:用于设置设备寄存器的内容。
在这一节中,我们学习了如何使用用户空间的 API 和命令行工具与 I2C 设备进行通信。尽管这些方法对于原型开发非常有用,但处理支持中断或其他基于内核的资源(如时钟)的设备可能会变得比较困难。
总结
在这一章中,我们讨论了 I2C 设备驱动程序。现在,是时候选择市场上的任何 I2C 设备,并编写相应的驱动程序以及必要的设备树支持了。本章讲解了内核 I2C 核心及其相关 API,包括设备树支持,帮助你掌握与 I2C 设备通信所需的技能。你现在应该能够编写高效的探测函数并将其注册到内核 I2C 核心中。
在下一章中,我们将使用本章所学的技能来开发一个 SPI 设备驱动程序。
第九章:第九章:编写 SPI 设备驱动程序
串行外围接口 (SPI) 至少是一个 4 线总线 – 主机输入从机输出 (MISO), 主机输出从机输入 (MOSI), 串行时钟 (SCK), 和 芯片选择 (CS) – 用于连接串行闪存和模数/数模转换器。主机始终生成时钟。其速度可达到 80 MHz,但实际上没有速度限制(这比 I2C 快得多)。同样适用于 CS 线,始终由主机管理。
每个这些信号名称都有一个同义词:
-
每当你看到 从机输入主机输出 (SIMO), 从机数据输入 (SDI), 或 数据输入 (DI), 它们指的是 MOSI。
-
从机输出主机输入 (SOMI), 从机数据输出 (SDO), 和 数据输出 (DO) 指的是 MISO。
-
串行时钟 (SCK), 时钟 (CLK), 和 串行时钟 (SCL) 是指 SCK。
-
S̅ S̅ 是从机选择线,也称为 CS。可以使用 CSx(其中 x 是索引,如 CS0、CS1)、EN 和 ENB,意思是使能。CS 通常是一个低有效信号。
下图显示了 SPI 设备通过其暴露的总线连接到控制器的方式:
图 9.1 – SPI 从机设备和主机互连
从上图可以看出,我们可以将 Linux 内核中的 SPI 框架表示如下:
CPU <--platform bus--> SPI master <---SPI bus---> SPI slave
CPU 是托管 SPI 控制器的主机,也称为 SPI 主控,负责管理托管 SPI 从机设备的总线段。在内核 SPI 框架中,总线由平台驱动程序管理,而从机则由 SPI 设备驱动程序驱动。但是,这两种驱动程序都使用 SPI 核心提供的 API。在本章中,我们将重点关注 SPI(从机)设备驱动程序,但如有必要,也会提及控制器。
本章将介绍诸如以下的 SPI 驱动程序概念:
-
理解 Linux 内核中的 SPI 框架抽象
-
处理 SPI 驱动程序抽象和架构
-
学习如何不编写 SPI 设备驱动程序
理解 Linux 内核中的 SPI 框架抽象
Linux 内核 SPI 框架由几个数据结构组成,其中最重要的是以下内容:
-
spi_controller
,用于抽象 SPI 主设备。 -
spi_device
,用于抽象连接到 SPI 总线上的从机设备。 -
spi_driver
,从机设备的驱动程序。 -
spi_transfer
,这是协议的低级表示中的一个片段。它表示主机与从机之间的单个操作。它期望 Tx 和/或 Rx 缓冲区以及要交换的数据长度和可选的 CS 行为。 -
spi_message
,这是一个原子传输序列。
现在让我们逐个介绍这些数据结构,从最复杂的开始,即代表 SPI 控制器数据结构。
简要介绍 struct spi_controller
在本章中,我们将引用控制器,因为它与 SPI 框架中由从设备和其他数据结构深度耦合。因此,有必要介绍其数据结构,表示为 struct spi_controller
,并定义如下:
struct spi_controller {
struct device dev;
u16 num_chipselect;
u32 min_speed_hz;
u32 max_speed_hz;
int (*setup)(struct spi_device *spi);
int (*set_cs_timing)(struct spi_device *spi,
struct spi_delay *setup,
struct spi_delay *hold,
struct spi_delay *inactive);
int (*transfer)(struct spi_device *spi,
struct spi_message *mesg);
bool (*can_dma)(struct spi_controller *ctlr,
struct spi_device *spi,
struct spi_transfer *xfer);
struct kthread_worker *kworker;
struct kthread_work pump_messages;
spinlock_t queue_lock;
struct list_head queue;
struct spi_message *cur_msg;
bool busy;
bool running;
bool rt;
int (*transfer_one_message)(
struct spi_controller *ctlr,
struct spi_message *mesg);
[...]
int (*transfer_one_message)(
struct spi_controller *ctlr,
struct spi_message *mesg);
void (*set_cs)(struct spi_device *spi, bool enable);
int (*transfer_one)(struct spi_controller *ctlr,
struct spi_device *spi,
struct spi_transfer *transfer);
[...]
/* DMA channels for use with core dmaengine helpers */
struct dma_chan *dma_tx;
struct dma_chan *dma_rx;
/* dummy data for full duplex devices */
Void *dummy_rx;
Void *dummy_tx;
};
仅列出了本章用于更好理解数据结构的关键元素。以下列表解释了它们的用途:
-
num_chipselect
表示分配给此控制器的 CS 数量。CS 用于区分单独的 SPI 从设备,并从 0 开始编号。 -
min_speed_hz
和max_speed_hz
分别是此控制器支持的最低和最高传输速度。 -
set_cs_timing
是一个方法,当 SPI 控制器支持 CS 时序配置时提供,在这种情况下,客户端驱动程序将调用spi_set_cs_timing()
来设置请求的时序。该方法已在最近的内核版本中被该补丁弃用:lore.kernel.org/lkml/20210609071918.2852069-1-gregkh@linuxfoundation.org/
)。 -
transfer
将消息添加到控制器的传输队列中。在控制器注册路径中(感谢spi_register_controller()
),SPI 核心会检查该字段是否为NULL
:-
如果为
NULL
,SPI 核心将检查transfer_one
或transfer_one_message
是否已设置,在这种情况下,假设该控制器支持消息排队,并调用spi_controller_initialize_queue()
,该函数将把此字段设置为spi_queued_transfer
(这是 SPI 核心帮助程序,用于将 SPI 消息排入控制器的队列,并在kworker
没有运行或繁忙时调度消息泵)。-
此外,
spi_controller_initialize_queue()
将为该控制器创建一个专用的 kthread 工作线程(kworker
元素)和一个工作结构体(pump_messages
元素)。这个工作线程将被频繁调度,以按照 FIFO 顺序处理消息队列。 -
接下来,SPI 核心将控制器的
queued
元素设置为 true。 -
最后,如果驱动程序在调用注册 API 之前已将控制器的
rt
元素设置为 true,则 SPI 核心将把工作线程的调度策略设置为实时 FIFO 策略,优先级为 50。
-
-
如果为
NULL
,并且transfer_one
和transfer_one_message
也都是NULL
,则发生错误,且控制器未注册。 -
如果不是
NULL
,SPI 核心假定控制器不支持队列,也不会调用spi_controller_initialize_queue()
。
-
-
transfer_one
和transfer_one_message
是互斥的。如果两者都设置,SPI 核心将不会调用前者。transfer_one
传输单个 SPI 传输,并没有spi_message
的概念。如果驱动程序提供了transfer_one_message
,它必须基于spi_message
工作,并将负责处理消息中的所有传输。那些不需要处理消息算法的控制器驱动程序只需要设置transfer_one
回调,在这种情况下,SPI 核心将会设置transfer_one_message
为spi_transfer_one_message
。spi_transfer_one_message
将在调用驱动程序提供的transfer_one
回调之前,处理所有的消息逻辑、时序、CS 以及其他硬件相关的属性。除非传输中有spi_transfer.cs_change = 1
的传输修改了它,否则 CS 将在整个消息传输过程中保持活动状态。消息传输将使用之前通过setup()
为此设备应用的时钟和 SPI 模式参数来执行。 -
kworker
:这是专门用于消息泵处理的内核线程。 -
pump_messages
:这是一个工作结构数据结构的抽象,用于调度处理 SPI 消息队列的函数。它被调度在kworker
中。该工作结构由spi_pump_messages()
方法支持,后者会检查队列中是否有需要处理的 SPI 消息,如果有,则调用驱动程序来初始化硬件并传输每个消息。 -
queue_lock
:用于同步访问消息队列的自旋锁。 -
queue
:此控制器的消息队列。 -
idling
:这表示控制器设备是否进入空闲状态。 -
cur_msg
:当前正在传输的 SPI 消息。 -
busy
:这表示消息泵的忙碌状态。 -
running
:这表示消息泵正在运行。 -
Rt
:这表示kworker
是否会以实时优先级运行消息泵。 -
dma_tx
:DMA 传输通道(当控制器支持时)。 -
dma_rx
:DMA 接收通道(当控制器支持时)。
SPI 传输始终读取和写入相同数量的字节,这意味着即使客户端驱动程序发起的是半双工传输,SPI 核心也会通过使用dummy_rx
和dummy_tx
来模拟全双工,从而实现这一目的:
-
dummy_rx
:这是一个虚拟接收缓冲区,用于全双工设备。当传输的接收缓冲区为NULL
时,接收到的数据将首先被转移到此虚拟接收缓冲区,然后再被丢弃。 -
dummy_tx
:这是一个虚拟传输缓冲区,用于全双工设备。当传输的发送缓冲区为NULL
时,这个虚拟发送缓冲区将被填充为零,并作为传输的发送缓冲区使用。
请注意,SPI 核心将 SPI 消息泵工作任务命名为控制器设备名称(dev->name),该名称在spi_register_controller()
中设置,如下所示:
dev_set_name(&ctlr->dev, "spi%u", ctlr->bus_num);
后来,当工作线程在队列初始化期间创建时(记住,spi_controller_initialize_queue()
),它将被赋予以下名称:
ctlr->kworker = kthread_create_worker(0, dev_name(&ctlr->dev));
要识别系统中的 SPI 消息泵工作线程,你可以运行以下命令:
root@yocto-imx6:~# ps | grep spi
65 root 0 SW [spi1]
在前面的代码片段中,我们可以看到工作线程的名称由总线名称和总线编号组成。
在本节中,我们分析了控制器端的概念,以帮助理解 Linux 内核中整个 SPI 从设备的实现。这个数据结构的重要性非常大,我建议每当你在后续章节中遇到不理解的机制时,都可以回过头来阅读本节内容。现在我们可以真正转向 SPI 设备的数据结构了。
struct spi_device
结构体
第一个也是最明显的数据结构,struct spi_device
表示一个 SPI 设备,并在 include/linux/spi/spi.h
中定义:
struct spi_device {
struct device dev;
struct spi_controller *controller;
struct spi_master *master;
u32 max_speed_hz;
u8 chip_select;
u8 bits_per_word;
bool rt;
u16 mode;
int irq;
[...]
int cs_gpio; /* LEGACY: chip select gpio */
struct gpio_desc *cs_gpiod; /* chip select gpio desc */
struct spi_delay word_delay; /* inter-word delay */
/* the statistics */
struct spi_statistics statistics;
};
为了可读性,列出的字段数减少到本书目的所需的最少数量。以下列表详细说明了此结构中每个元素的含义:
-
controller
表示该从设备所属的 SPI 控制器。换句话说,它表示设备连接的 SPI 控制器(总线)。 -
master
元素仍然存在,是为了兼容性原因,并且很快会被弃用。它曾是控制器的旧名称。 -
max_speed_hz
是与此从设备一起使用的最大时钟速率;此参数可以通过驱动程序内部进行更改。我们可以使用spi_transfer.speed_hz
来覆盖该参数,应用于每个传输。稍后我们将讨论 SPI 传输。 -
chip_select
是分配给该设备的 CS 线。默认情况下,它是低电平有效的。可以通过在mode
中添加SPI_CS_HIGH
标志来更改此行为。 -
rt
,如果为true
,将使controller
的消息泵工作线程作为实时任务运行。 -
mode
定义了数据如何时钟化。设备驱动程序可以更改此设置。数据时钟化默认是每个字传输时的 MSB。此行为可以通过指定SPI_LSB_FIRST
来覆盖。 -
irq
表示中断号(在你的板初始化文件中注册为设备资源,或者通过设备树进行注册),你应该将其传递给request_irq()
来接收该设备的中断。 -
cs_gpio
和cs_gpiod
都是可选的。前者是基于整数的传统 GPIO 号,表示 CS 线,而后者是新的、推荐的接口,基于 GPIO 描述符。
关于 SPI 模式的一点说明——它们是通过两个特性构建的:
-
CPOL,即初始时钟极性:
-
0
:初始时钟状态为低电平,第一个边沿为上升。 -
1
:初始时钟状态为高电平,第一个状态为下降。
-
-
CPHA 是时钟相位,决定数据在何种边沿被采样:
-
0
:数据在下降沿(高到低过渡)时被锁存,而输出在上升沿变化。 -
1
:数据在上升沿(低到高的转换)时锁存,输出在下降沿时变化。
-
这使我们能够区分四种 SPI 模式,这些模式是由两个主要宏的混合衍生而来,这些宏在 include/linux/spi/spi.h
中定义如下:
#define SPI_CPHA 0x01
#define SPI_CPOL 0x02
这些宏的组合给出了以下 SPI 模式:
表 9.1 – SPI 模式内核定义
以下图表表示了每个 SPI 模式,顺序与前面的数组定义一致。也就是说,仅表示了 MOSI 线,但 MISO 线的原理是相同的。
图 9.2 – SPI 操作模式
现在我们已经熟悉了 SPI 设备数据结构及其操作模式,我们可以切换到第二重要的数据结构,即表示 SPI 设备驱动程序的结构。
spi_driver 结构体
也叫做协议驱动程序,SPI 设备驱动程序负责驱动连接在 SPI 总线上的设备。它通过 struct spi_driver
在内核中进行抽象,声明如下:
struct spi_driver {
const struct spi_device_id *id_table;
int (*probe)(struct spi_device *spi);
int (*remove)(struct spi_device *spi);
void (*shutdown)(struct spi_device *spi);
struct device_driver driver;
};
以下列表概述了此数据结构中元素的含义:
-
id_table
:这是此驱动程序支持的 SPI 设备列表。 -
probe
:此方法将此驱动程序绑定到 SPI 设备。此函数会在任何声明为该驱动程序的设备上调用,并决定该驱动程序是否负责该设备。如果是,则发生绑定过程。 -
remove
:将此驱动程序从 SPI 设备中解绑。 -
shutdown
:此方法在系统状态变更时调用,例如关闭电源和停止操作。 -
driver
:这是设备和驱动模型的低级驱动程序结构。
目前我们只能说这些数据结构的情况,除了每个 SPI 设备驱动程序必须填充并暴露该类型的一个实例之外。
消息传输数据结构
SPI I/O 模型由一组排队的消息组成,每条消息可以包含一个或多个 SPI 传输。单个消息由一个或多个 struct spi_transfer
对象组成,每个传输代表一个全双工的 SPI 事务。消息可以同步或异步提交和处理。以下是解释消息和传输概念的示意图:
图 9.3 – 示例 SPI 消息结构
现在我们已经了解了理论方面的内容,可以介绍 SPI 传输数据结构,其声明如下:
struct spi_transfer {
const void *tx_buf;
void *rx_buf;
unsigned len;
dma_addr_t tx_dma;
dma_addr_t rx_dma;
struct sg_table tx_sg;
struct sg_table rx_sg;
unsigned cs_change:1;
unsigned tx_nbits:3;
unsigned rx_nbits:3;
#define SPI_NBITS_SINGLE 0x01 /* 1bit transfer */
#define SPI_NBITS_DUAL 0x02 /* 2bits transfer */
#define SPI_NBITS_QUAD 0x04 /* 4bits transfer */
u8 bits_per_word;
u16 delay_usecs;
struct spi_delay delay;
struct spi_delay cs_change_delay;
struct spi_delay word_delay;
u32 speed_hz;
u32 effective_speed_hz;
[...]
struct list_head transfer_list;
#define SPI_TRANS_FAIL_NO_START BIT(0)
u16 error;
};
以下是数据结构中每个元素的含义:
-
tx_buf
是指向包含待写入数据的缓冲区的指针。如果设置为NULL
,则此传输将被视为半双工读取事务。需要通过 DMA 执行 SPI 事务时,它应该是 DMA 安全的。 -
rx_buf
是一个数据缓冲区,用于读取数据(具有与tx_buf
相同的属性),或者在只写事务中为NULL
。 -
tx_dma
是tx_buf
,前提是spi_message.is_dma_mapped
被设置为1
。 -
rx_dma
与tx_dma
相同,但用于rx_buf
。 -
len
表示rx
和tx
缓冲区的字节大小。只有len
字节会被移出(或移入),并且尝试移出部分字会导致错误。 -
speed_hz
覆盖了spi_device.max_speed_hz
中指定的默认速度,但仅适用于当前的传输。如果为0
,则使用默认值(来自spi_device
)。 -
bits_per_word
:数据传输涉及一个或多个字。字是数据单元,其大小(以位为单位)根据需求而变化。在这里,bits_per_word
表示此 SPI 传输中一个字的位数大小。这将覆盖spi_device.bits_per_word
中提供的默认值。如果为0
,则使用默认值(来自spi_device
)。 -
cs_change
决定在此传输完成后 CS 是否变为不活动。所有 SPI 传输都以适当的 CS 信号激活开始。通常,它会保持选中状态,直到消息中的最后一个传输完成。通过使用cs_change
,驱动程序可以改变 CS 信号。
该标志用于在消息的中间使 CS 暂时失效(即在处理指定的spi_transfer
之前),如果该传输不是消息中的最后一个。以这种方式切换 CS 可能是完成芯片命令所必需的,从而允许单个 SPI 消息处理整个芯片事务集。
-
delay_usecs
表示在此传输之后,延迟(以微秒为单位),然后(可选地)更改chip_select
状态,接着开始下一个传输或完成此spi_message
。注意
SPI 传输总是写入与读取相同数量的字节,即使在半双工传输中也是如此。SPI 核心通过控制器的
dummy_rx
和dummy_tx
元素实现这一点。当传输缓冲区为 null 时,spi_transfer->tx_buf
将被设置为控制器的dummy_tx
。然后,零将被移出,同时将从从设备接收到的数据填充到rx_buf
中。如果接收缓冲区为 null,则spi_transfer->rx_buf
将被设置为控制器的dummy_rx
,并且接收到的数据将被丢弃。
struct spi_message
spi_message
用于原子地发出一系列传输,每个传输由一个struct spi_transfer
实例表示。我们之所以称之为原子,是因为在进行中的序列完成之前,其他任何spi_message
都不能使用该 SPI 总线。需要注意的是,有些平台能够通过单个编程 DMA 传输处理多个此类序列。SPI 消息结构具有以下声明:
struct spi_message {
struct list_head transfers;
struct spi_device *spi;
unsigned is_dma_mapped:1;
/* completion is reported through a callback */
void (*complete)(void *context);
void *context;
unsigned frame_length;
unsigned actual_length;
int status;
};
以下列表概述了此数据结构中各元素的含义:
-
transfers
是构成消息的传输列表。我们稍后会看到如何将传输添加到此列表。在该原子组中的最后一个传输使用spi_transfer.cs_change
标志,可能会减少芯片取消选择和选择操作的成本。 -
is_dma_mapped
向控制器指示是否使用 DMA(或不使用 DMA)来执行事务。你的代码需要为每个传输缓冲区提供 DMA 和 CPU 虚拟地址。 -
complete
是在事务完成时调用的回调,context
是传递给回调的参数。 -
frame_length
会自动设置为消息中所有字节的总数。 -
actual_length
是所有成功片段中传输的字节数。 -
status
报告传输的状态。成功时为0
,否则为-errno
。
spi_transfer
元素在消息中按 FIFO 顺序处理。在消息完成之前(即在完成回调执行之前),你必须确保不使用传输缓冲区,以避免数据损坏。提交 spi_message
(及其 spi_transfers
)到下层的代码负责管理其内存。驱动程序一旦提交消息(及其传输),必须忽略该消息(及其传输),至少要等到其完成回调被调用。
访问 SPI 设备
一个 SPI 控制器能够与一个或多个从设备进行通信,也就是说,与一个或多个 struct spi_device
进行通信。它们构成一个小型总线,共享 MOSI、MISO 和 SCK 信号,但不共享 CS 信号。由于这些共享信号在芯片未被选择时会被忽略,因此每个设备都可以被编程以使用不同的时钟速率。SPI 控制器驱动程序通过 spi_message
事务队列来管理与这些设备的通信,将数据在 CPU 内存和 SPI 从设备之间传输。对于它排队的每个消息实例,它会在事务完成时调用该消息的完成回调。
在消息提交到总线之前,它必须通过 spi_message_init()
进行初始化,该函数的原型如下:
void spi_message_init(struct spi_message *message)
该函数将零初始化结构中的每个元素,并初始化传输列表。对于要添加到消息中的每个传输,你应当对该传输调用 spi_message_add_tail()
,这将导致该传输被排入消息的传输列表。它具有如下声明:
spi_message_add_tail(struct spi_transfer *t, struct spi_message *m)
一旦完成此操作,你有两种选择来开始事务:
int spi_sync(struct spi_device *spi, struct spi_message *message)
,成功时返回0
,否则返回负的错误代码。该函数可能会休眠,且不能在中断上下文中使用。需要注意的是,该函数可能会以不可中断的方式休眠,并且不允许指定超时。具有 DMA 能力的控制器驱动程序可能会利用该 DMA 特性,直接将数据推送或拉取到/从消息缓冲区。
SPI 设备的 CS 会在整个消息期间(从第一个传输到最后一个)由核心激活,然后通常在消息之间禁用。有些驱动程序为了最小化选择芯片的影响(例如为了节省电力),会保持芯片处于选中状态,预期下一个消息会发送到同一芯片。
spi_async()
函数可以在任何上下文中使用(无论是否为原子上下文),其原型为int spi_async(struct spi_device *spi, struct spi_message *message)
。此函数与上下文无关,因为它只进行提交,处理是异步的。然而,完成回调会在无法休眠的上下文中调用。在调用此回调之前,message->status
的值是未定义的。回调调用时,message->status
保存完成状态,状态为0
表示完全成功,或者是负的错误代码。
在回调返回后,发起传输请求的驱动程序可以释放相关的内存,因为它不再被任何 SPI 核心或控制器驱动代码使用。直到当前处理的消息的完成回调返回,排队到该设备的任何后续spi_message
才会被处理。这条规则同样适用于同步传输调用,因为它们是该核心异步原语的封装。此函数成功时返回0
,否则返回负的错误代码。
以下是一个驱动程序的摘录,演示了 SPI 消息和传输的初始化与提交:
Static int regmap_spi_gather_write(
void *context, const void *reg,
size_t reg_len, const void *val,
size_t val_len)
{
struct device *dev = context;
struct spi_device *spi = to_spi_device(dev);
struct spi_message m;
u32 addr;
struct spi_transfer t[2] = {
{ .tx_buf = &addr, .len = reg_len, .cs_change = 0,},
{ .tx_buf = val, .len = val_len, },
};
addr = TCAN4X5X_WRITE_CMD |
(*((u16 *)reg) << 8) | val_len >> 2;
spi_message_init(&m);
spi_message_add_tail(&t[0], &m);
spi_message_add_tail(&t[1], &m);
return spi_sync(spi, &m);
}
然而,前面的摘录展示了静态初始化,在执行时,消息和传输会在函数返回路径中被丢弃。有些情况下,驱动程序可能希望在驱动生命周期内预分配消息及其传输,以避免频繁的初始化开销。在这种情况下,可以通过spi_message_alloc()
使用动态分配,并通过spi_message_free()
释放。它们具有以下原型:
struct spi_message *spi_message_alloc(unsigned ntrans,
gfp_t flags)
void spi_message_free(struct spi_message *m)
在前面的代码片段中,ntrans
是分配给这个新spi_message
的传输次数,flags
代表新分配内存的标志,使用GFP_KERNEL
就足够了。如果成功,这个函数会返回新分配的消息结构及其传输。你可以使用内核列表相关宏来访问传输元素,例如list_first_entry
、list_next_entry
,甚至是list_for_each_entry
。以下是展示这些宏使用的示例:
/* Completion handler for async SPI transfers */
static void my_complete(void *context)
{
struct spi_message *msg = context;
/* doing some other stuffs */
[…]
spi_message_free(m);
}
static int example_spi_async(struct spi_device *spi,
struct my_fake_spi_reg *cmds, unsigned len)
{
struct spi_transfer *xfer;
struct spi_message *msg;
msg = spi_message_alloc(len, GFP_KERNEL);
if (!msg)
return -ENOMEM;
msg->complete = my_complete;
msg->context = msg;
list_for_each_entry(xfer, &msg->transfers,
transfer_list) {
xfer->tx_buf = (u8 *)cmds;
/* feel free to handle .rx_buf, and so on */
[...]
xfer->len = 2;
xfer->cs_change = true;
cmds++;
}
return spi_async(spi, msg);
}
在前面的摘录中,我们不仅展示了如何使用动态的消息和传输分配,还展示了如何使用spi_async()
。这个示例其实没多大用处,因为分配的消息和传输在完成后立即被释放。使用动态分配的最佳实践是动态分配发送和接收缓冲区,并将它们保持在驱动程序生命周期内易于访问的位置。
但请注意,设备驱动程序负责以最合适的方式组织消息和传输,如下所示:
-
何时开始双向读写,以及如何安排其一系列
spi_transfer
请求 -
I/O 缓冲区准备,了解每个
spi_transfer
都为每个传输方向包装一个缓冲区,支持全双工传输(即使一个指针为NULL
,在这种情况下控制器将使用其中一个虚拟缓冲区) -
可选地使用
spi_transfer.delay_usecs
来定义传输后的短延迟 -
是否在传输后通过使用
spi_transfer.cs_change
标志来改变(使之不活跃)CS 信号
使用 spi_async
时,设备驱动程序将消息排队,注册完成回调,唤醒消息泵并立即返回。完成回调将在传输完成时被调用。由于消息排队和消息泵调度都不能阻塞,spi_async
函数被认为是与上下文无关的。然而,它要求你在访问提交的 spi_transfer
指针中的缓冲区之前等待完成回调。另一方面,spi_sync
排队消息并在完成之前阻塞。它不需要完成回调。当 spi_sync
返回时,访问数据缓冲区是安全的。如果你查看 drivers/spi/spi.c
中的实现,你会看到它使用 spi_async
将调用线程置于休眠状态,直到完成回调被调用。自 4.0 内核以来,spi_sync
有了改进,当队列中没有内容时,消息泵将在调用者的上下文中执行,而不是在消息泵线程中执行,从而避免了上下文切换的开销。
在介绍了 SPI 框架的最重要数据结构和 API 之后,我们可以讨论实际的驱动程序实现。
处理 SPI 驱动程序抽象和架构
这是驱动程序逻辑发生的地方。它包括用一组驱动函数填充 struct spi_driver
,这些函数允许探测并控制底层设备。
探测设备
SPI 设备由 spi_driver.probe
回调函数探测。该探测回调函数负责确保驱动程序在设备绑定之前能够识别该设备。此回调函数具有以下原型:
int probe(struct spi_device *spi)
该方法在成功时必须返回 0
,否则返回负数错误码。唯一的参数是要探测的 SPI 设备,其结构已由内核根据设备树中的描述进行预初始化。
然而,正如我们在描述其数据结构时所看到的,大多数(如果不是所有)SPI 设备的属性都可以被覆盖。SPI 协议驱动可能需要更新传输模式,如果设备无法使用其默认设置。它们也可能需要根据初始值更新时钟频率或字长。这是通过spi_setup()
辅助函数实现的,原型如下:
int spi_setup(struct spi_device * spi)
此函数必须在能够独占休眠的上下文中调用。它期望传入一个 SPI 设备结构体,该结构体的属性必须在各自的字段中设置,以便进行覆盖。更改将在下次访问设备时生效(无论是读取操作还是写入操作,前提是该设备已被选中),但SPI_CS_HIGH
会立即生效。此函数在返回时会取消选择 SPI 设备。此函数成功时返回0
,失败时返回负值。值得注意的是,它的返回值,因为如果驱动程序提供了底层控制器或其驱动程序不支持的选项,则该调用将不会成功。例如,一些硬件使用九位字、最低有效位(LSB)优先的线编码或高电平有效的 CS 来处理线传输,而其他硬件则不支持。
你可能希望在probe()
中调用spi_setup()
,在向设备提交任何 I/O 请求之前。然而,只要该设备没有挂起的消息,它可以在代码中的任何位置被调用。
以下是一个探测示例,它设置 SPI 设备,检查其家庭 ID,并在成功时返回0
(设备已识别):
#define FAMILY_ID 0x57
static int fake_probe(struct spi_device *spi)
{
int err;
u8 id;
spi->max_speed_hz =
min(spi->max_speed_hz, DEFAULT_FREQ);
spi->bits_per_word = 8;
spi->mode = SPI_MODE_0;
spi->rt = true;
err = spi_setup(spi);
if (err)
return err;
/* read family id */
err = get_chip_version(spi, &id);
if (err)
return -EIO;
/* verify family id */
if (id != FAMILY_ID) {
dev_err(&spi->dev"
"chip family: expected 0x%02x but 0x%02x rea"\n",
FAMILY_ID, id);
return -ENODEV;
}
/* register with other frameworks */
[...]
return 0;
}
一个实际的探测方法可能还需要处理一些驱动状态数据结构或其他每个设备的特定数据结构。关于get_chip_version()
函数,它可能有以下实现:
#define REG_FAMILY_ID 0x2445
#define DEFAULT_FREQ 10000000
static int get_chip_version(spi_device *spi, u8 *id)
{
struct spi_transfer t[2];
struct spi_message m;
u16 cmd;
int err;
cmd = REG_FAMILY_ID;
spi_message_init(&m);
memset(&t, 0, sizeof(t));
t[0].tx_buf = &cmd;
t[0].len = sizeof(cmd);
spi_message_add_tail(&t[0], &m);
t[1].rx_buf = id;
t[1].len = 1;
spi_message_add_tail(&t[1], &m);
return spi_sync(spi, &m);
}
现在我们已经了解了如何探测 SPI 设备,接下来讨论如何告诉 SPI 核心驱动程序可以支持哪些设备将会很有用。
注意
SPI 核心允许使用spi_get_drvdata()
和spi_set_drvdata()
来设置/获取驱动程序状态数据,就像我们在讨论 I2C 设备驱动时在第八章**,编写 I2C 设备驱动中所做的那样。
在驱动中配置设备
正如我们需要一个i2c_device_id
列表来告知 I2C 核心我们的 I2C 驱动可以支持哪些设备一样,我们也必须提供一个spi_device_id
数组来通知 SPI 核心我们的 SPI 驱动支持哪些设备。填充该数组后,必须将其分配给spi_driver.id_table
字段。此外,为了设备匹配和模块加载目的,这个数组还需要传递给MODULE_DEVICE_TABLE
宏。struct spi_device_id
在include/linux/mod_devicetable.h
中有如下声明:
struct spi_device_id {
char name[SPI_NAME_SIZE];
kernel_ulong_t driver_data;
};
在前面的数据结构中,name
是设备的描述性名称,driver_data
是驱动程序的状态值。它可以通过指向每个设备数据结构的指针来设置。以下是一个示例:
#define ID_FOR_FOO_DEVICE 0
#define ID_FOR_BAR_DEVICE 1
static struct spi_device_id foo_idtable[] = {
"{ ""oo", ID_FOR_FOO_DEVICE },
"{ ""ar", ID_FOR_BAR_DEVICE },
{ },
};
MODULE_DEVICE_TABLE(spi, foo_idtable);
为了能够匹配设备树中声明的设备,我们需要定义一个struct of_device_id
元素的数组,并将其赋值给spi_driver.of_match_table
,同时在其上调用MODULE_DEVICE_TABLE
宏。以下是一个示例,示例还展示了在设置好后的spi_driver
结构体的样子:
static const struct of_device_id foobar_of_match[] = {
{ .compatible"= "packtpub,foobar-dev"ce" },
{ .compatible"= "packtpub,barfoo-dev"ce" },
{},
};
MODULE_DEVICE_TABLE(of, foobar_of_match);
以下摘录展示了最终的spi_driver
内容:
static struct spi_driver foo_driver = {
.driver = {
.name "= ""oo",
/* The below line adds Device Tree support */
.of_match_table = of_match_ptr(foobar_of_match),
},
.probe = my_spi_probe,
.id_table = foo_idtable,
};
在前面,我们可以看到设置好的 SPI 驱动结构是怎样的。然而,还有一个缺失的元素——spi_driver.remove
回调,它用于撤销探测函数中所做的操作。
实现spi_driver.remove
方法
remove
回调必须用于释放所有获取的资源,并撤销探测时所做的操作。该回调有以下原型:
static int remove(struct spi_device *spi)
在前面的代码片段中,spi
是 SPI 设备数据结构,和传递给probe
回调的结构相同,这简化了设备状态数据结构在探测和设备移除之间的跟踪。该方法成功时返回0
,失败时返回负的错误码。你必须确保设备保持一致且稳定的状态。以下是一个示例实现:
static int mc33880_remove(struct spi_device *spi)
{
struct mc33880 *mc;
mc = spi_get_drvdata(spi); /* Get our data back */
if (!mc)
return -ENODEV;
/*
* unregister from frameworks with which we
* registered in the probe function
*/
gpiochip_remove(&mc->chip);
[...]
/* releasing any resource */
mutex_destroy(&mc->lock);
return 0;
}
在前面的示例中,代码处理了从框架中注销和释放资源的工作。这是你在 90%的情况下会遇到的经典场景。
驱动初始化和注册
在这个实现步骤中,你的代码几乎完成了,并且你希望通知 SPI 核心你的 SPI 驱动。这就是驱动注册。对于 SPI 设备驱动,SPI 核心提供了spi_register_driver()
和spi_unregister_driver()
来分别注册和注销 SPI 设备驱动。它们的原型如下:
int spi_register_driver(struct spi_driver *sdrv);
void spi_unregister_driver(struct spi_driver *sdrv);
在这两个函数中,sdrv
是先前设置好的 SPI 驱动结构。注册 API 成功时返回 0,失败时返回负的错误码。
驱动注册和注销通常发生在模块初始化和模块退出方法中。以下是一个典型的 SPI 驱动注册示例:
static int __init foo_init(void)
{
[...] /*My init code */
return spi_register_driver(&foo_driver);
}
module_init(foo_init);
static void __exit foo_cleanup(void)
{
[...] /* My clean up code */
spi_unregister_driver(&foo_driver);
}
module_exit(foo_cleanup);
如果你在模块初始化时仅进行驱动的注册/注销操作,你可以像下面这样使用module_spi_driver()
来简化代码:
module_spi_driver(foo_driver);
该宏将填充模块初始化和清理函数,并会在其中调用spi_register_driver
和spi_unregister_driver
。
实例化 SPI 设备
SPI 从节点必须是 SPI 控制器节点的子节点。在主模式下,可以有一个或多个从节点(最多与 CS 数量相同)。
所需的属性如下:
-
compatible
:在驱动中定义的用于匹配的兼容字符串 -
reg
:设备相对于控制器的 CS 索引 -
spi-max-frequency
:设备的最大 SPI 时钟速度(单位:Hz)
所有从属节点都可以包含以下可选属性:
-
spi-cpol
:布尔属性,如果存在,表示设备需要反向时钟极性(CPOL)模式。 -
spi-cpha
:布尔属性,表示该设备需要偏移的时钟相位(CPHA)模式。 -
spi-cs-hi–h
:空属性,表示设备需要 CS 高电平激活。 -
spi-3wire
:布尔属性,表示该设备需要 3 线模式才能正常工作。 -
spi-lsb-first
:布尔属性,表示该设备需要 LSB 优先模式。 -
spi-tx-bus-width
:该属性表示用于 MOSI 的总线宽度。如果没有该属性,则默认为1
。 -
spi-rx-bus-width
:该属性用于表示用于 MISO 的总线宽度。如果没有该属性,则默认为1
。 -
spi-rx-delay-–s
:用于指定读取传输后的微秒延迟。 -
spi-tx-delay-us
:用于指定写入传输后的微秒延迟。
以下是 SPI 设备的实际设备树列表:
ecspi1 {
fsl,spi-num-CSs = <3>;
cs-gpios = <&gpio5 17 0>, <&gpio5 17 0>, <&gpio5 17 0>;
pinctrl-0 = <&pinctrl_ecspi1 &pinctrl_ecspi1_cs>;
#address-cells = <1>;
#size-cells = <0>;
compatible"= "fsl,imx6q-ec"pi", "fsl,imx51-ec"pi";
reg = <0x02008000 0x4000>;
status"= "o"ay";
ad7606r8_0: ad7606r8@0 {
compatible"= "ad760"-8";
reg = <0>;
spi-max-frequency = <1000000>;
interrupt-parent = <&gpio4>;
interrupts = <30 0x0>;
};
label: fake_spi_device@1 {
compatible"= "packtpub,foobar-dev"ce";
reg = <1>;
a-string-param"= "stringva"ue";
spi-cs-high;
};
mcp2515can: can@2 {
compatible"= "microchip,mcp2"15";
reg = <2>;
spi-max-frequency = <1000000>;
clocks = <&clk8m>;
interrupt-parent = <&gpio4>;
interrupts = <29 IRQ_TYPE_LEVEL_LOW>;
};
};
在前面的设备树片段中,ecspi1
表示主 SPI 控制器。fake_spi_device
和mcp2515can
表示 SPI 从设备,它们的reg
属性表示相对于主设备的 CS 索引。
现在我们已经熟悉了 SPI 从设备框架的所有内核方面,接下来让我们看看如何避免处理内核并尝试在用户空间实现所有功能。
学习如何避免编写 SPI 设备驱动程序
处理 SPI 设备的常见方法是编写内核代码来驱动该设备。如今,spidev
接口使得即使不编写一行内核代码,也可以处理此类设备。然而,使用此接口应仅限于简单的用例,例如与从属微控制器通信或原型开发。使用此接口时,您将无法处理设备可能支持的各种中断(IRQ),也无法利用其他内核框架。
spidev
接口以/dev/spidevX.Y
的形式暴露字符设备节点,其中X
表示设备所在的总线,Y
表示在设备树中分配给该设备节点的 CS 索引(相对于控制器)。例如,/dev/spidev1.0
表示 SPI 总线1
上的设备0
。同样适用于 sysfs 目录条目,其形式为/sys/class/spidev/spidevX.Y
。
在字符设备出现在用户空间之前,设备节点必须在设备树中声明为 SPI 控制器节点的子节点。以下是一个示例:
&ecspi2 {
pinctrl-names"= "defa"lt";
pinctrl-0 = <&pinctrl_teoulora_ecspi2>;
cs-gpios = <&gpio2 26 1
&gpio2 27 1>;
num-cs = <2>;
status"= "o"ay";
spidev@0 {
reg = <0>;
compatib"e="semtech,sx1"01";
spi-max-frequency = <20000000>;
};
};
在前面的代码片段中,spidev@0
对应我们的 SPI 设备节点。reg = <0>
告诉控制器该设备使用的是第一个 CS 引脚(索引从 0 开始)。compatible="semtech,sx1301"
属性用于匹配 spidev
驱动程序中的一个条目。现在不再推荐使用 "spidev"
作为兼容字符串——如果尝试使用它,你会收到警告。最后,spi-max-frequency = <20000000>
设置了我们设备的默认时钟频率(此处为 20 MHz),除非通过相应的 API 进行更改。
从用户空间开始,处理 spidev
接口所需的头文件如下:
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/types.h>
#include <linux/spi/spidev.h>
由于它是一个字符设备,允许(事实上这是唯一的选择)使用基本的系统调用,如 open()
、read()
、write()
、ioctl()
和 close()
。以下示例展示了一些基本用法,仅使用 read()
和 write()
操作:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
int i,fd;
char *device = "/dev/spidev0.0";
char wr_buf[]={0xff,0x00,0x1f,0x0f};
char rd_buf[10];
fd = open(device, O_RDWR);
if (fd <= 0) {
printf("Failed to open SPI device %s\n", device);
exit(1);
}
if (write(fd, wr_buf, sizeof(wr_buf)) != sizeof(wr_buf))
perror("Write Error");
if (read(fd, rd_buf, sizeof(rd_buf)) != sizeof(rd_buf))
perror("Read Error");
else
for (i = 0; i < sizeof(rd_buf); i++)
printf("0x%02X ", rd_buf[i]);
close(fd);
return 0;
}
在前面的代码中,需要注意的是,标准的 read()
和 write()
操作仅支持半双工,而且每次操作之间都会禁用 CS。要实现全双工工作,唯一的选择是使用 ioctl()
接口,在该接口中可以随意传递输入和输出缓冲区。此外,借助 ioctl()
接口,你可以使用一组 SPI_IOC_RD_*
和 SPI_IOC_WR_*
命令来获取 RD
并设置 WR
,从而覆盖设备的当前设置。有关这些命令的完整列表和文档,可以在内核源码中的 Documentation/spi/spidev
找到。
ioctl()
接口允许在不禁用 CS 的情况下执行复合操作,并通过 SPI_IOC_MESSAGE(N)
请求实现。一个新的数据结构被引入,即 struct spi_ioc_transfer
,它是用户空间中 struct spi_transfer
的等效结构。以下是 ioctl 命令的示例:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* include required headers, listed early in the section */
[...]
static int pabort(const char *s)
{
perror(s);
return -1;
}
static int spi_device_setup(int fd)
{
int mode, speed, a, b, i;
int bits = 8;
/* spi mode: mode 0 */
mode = SPI_MODE_0;
a = ioctl(fd, SPI_IOC_WR_MODE, &mode); /* set mode */
b = ioctl(fd, SPI_IOC_RD_MODE, &mode); /* get mode */
if ((a < 0) || (b < 0)) {
return pabort("can't set spi mode");
}
/* Clock max speed in Hz */
speed = 8000000; /* 8 MHz */
a = ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); /* set */
b = ioctl(fd, SPI_IOC_RD_MAX_SPEED_HZ, &speed); /* get */
if ((a < 0) || (b < 0))
return pabort("fail to set max speed hz");
/*
* Set SPI to MSB first.
* Here, 0 means "not to use LSB first".
* To use LSB first, argument should be > 0
*/
i = 0;
a = ioctl(dev, SPI_IOC_WR_LSB_FIRST, &i);
b = ioctl(dev, SPI_IOC_RD_LSB_FIRST, &i);
if ((a < 0) || (b < 0))
pabort("Fail to set MSB first\n");
/* setting SPI to 8 bits per word */
bits = 8;
a = ioctl(dev, SPI_IOC_WR_BITS_PER_WORD, &bits); /* set */
b = ioctl(dev, SPI_IOC_RD_BITS_PER_WORD, &bits); /* get */
if ((a < 0) || (b < 0))
pabort("Fail to set bits per word\n");
return 0;
}
在前面的例子中,getter 仅用于演示目的。执行 SPI_IOC_WR_*
命令后,使用 SPI_IOC_RD_*
命令并非强制性的。现在我们已经了解了大多数这些 ioctl 命令,接下来我们来看看如何开始传输:
static void do_transfer(int fd)
{
int ret;
char txbuf[] = {0x0B, 0x02, 0xB5};
char rxbuf[3] = {0, };
char cmd_buff = 0x9f;
struct spi_ioc_transfer tr[2] = {
0 = {
.tx_buf = (unsigned long)&cmd_buff,
.len = 1,
.cs_change = 1; /* We need CS to change */
.delay_usecs = 50, /* wait after this transfer */
.bits_per_word = 8,
},
[1] = {
.tx_buf = (unsigned long)tx,
.rx_buf = (unsigned long)rx,
.len = txbuf(tx),
.bits_per_word = 8,
},
};
ret = ioctl(fd, SPI_IOC_MESSAGE(2), &tr);
if (ret == 1){
perror("can't send spi message");
exit(1);
}
for (ret = 0; ret < sizeof(tx); ret++)
printf("%.2X ", rx[ret]);
printf("\n");
}
上述内容展示了用户空间中消息和传输事务的概念。现在我们的帮助程序已定义,我们可以编写主代码来使用它们,如下所示:
int main(int argc, char **argv)
{
char *device = "/dev/spidev0.0";
int fd;
int error;
fd = open(device, O_RDWR);
if (fd < 0)
return pabort("Can't open device ");
error = spi_device_setup(fd);
if (error)
exit (1);
do_transfer(fd);
close(fd);
return 0;
}
我们现在已经完成了主函数的部分。本节内容教我们如何使用用户空间的 SPI API 和命令与设备进行交互。然而,我们受到一些限制,无法利用设备中断线或其他内核框架。
总结
在本章中,我们处理了 SPI 驱动程序,并且现在可以利用这个比 I2C 快得多的串行(全双工)总线。我们走遍了这个框架中的所有数据结构,并讨论了如何通过 SPI 进行数据传输,这是我们涵盖的最重要部分。也就是说,我们通过这些总线访问的内存是外部存储器——为了避免 SPI 和 I2C API 的使用,我们可能需要更多的抽象层。
这就是下一章的内容,它讲解了 regmap API,该 API 提供了更高、更统一的抽象层次,使得 SPI(和 I2C)命令对你变得透明。
第三部分 - 最大化利用硬件资源
本节将讨论 Linux 内核内存管理、内核中断管理的高级概念以及直接内存访问。接着,为了简化内存访问操作,我们将使用 Linux 内核中实现的内存访问抽象——regmap。最后,我们将介绍 Linux 设备模型,以便更好地理解和概览系统上的设备层级。
本节将涵盖以下章节:
-
第十章,理解 Linux 内核内存分配
-
第十一章,实现直接内存访问(DMA)支持
-
第十二章,内存访问抽象——Regmap API 介绍:寄存器映射抽象
-
第十三章,揭开内核 IRQ 框架的神秘面纱
-
第十四章,Linux 设备模型介绍
第十章:第十章:理解 Linux 内核内存分配
Linux 系统使用一种被称为“虚拟内存”的幻觉。这个机制使得每个内存地址都是虚拟的,这意味着它们并不直接指向 RAM 中的任何地址。通过这种方式,每当我们访问某个内存位置时,都会执行一个转换机制,以便匹配相应的物理内存。
在本章中,我们将处理整个 Linux 内存分配与管理系统,涵盖以下主题:
-
Linux 内核内存相关术语简介
-
揭开地址转换与 MMU 的神秘面纱
-
处理内存分配机制
-
使用 I/O 内存与硬件通信
-
内存重映射
Linux 内核内存相关术语简介
虽然系统内存(也称为 RAM)在某些允许扩展的计算机中可以增加,但物理内存在计算机系统中是有限的资源。
虚拟内存是一个概念,是给予每个进程的幻觉,使其认为自己拥有大量且几乎无限的内存,有时甚至超过系统实际拥有的内存。为了设置一切,我们将介绍地址空间、虚拟或逻辑地址、物理地址和总线地址等术语:
-
物理地址标识一个物理(RAM)位置。由于虚拟内存机制,用户或内核永远不会直接处理物理地址,而是通过其对应的逻辑地址进行访问。
-
虚拟地址不一定在物理上存在。该地址作为参考,用于通过内存管理单元(MMU)代表 CPU 访问物理内存位置。MMU 位于 CPU 核心与内存之间,通常是物理 CPU 的一部分。也就是说,在 ARM 架构中,它是受许可核心的一部分。然后,它负责每次访问内存位置时将虚拟地址转换为物理地址。这个机制被称为地址转换。
-
逻辑地址是由线性映射产生的地址。它是
PAGE_OFFSET
之上的映射结果。这类地址是虚拟地址,与其物理地址有固定偏移。因此,逻辑地址始终是虚拟地址,而反之则不成立。 -
在计算机系统中,地址空间是为计算实体(在我们这里是 CPU)分配的所有可能地址的内存量。这个地址空间可以是虚拟的或物理的。物理地址空间的最大值可以达到系统中安装的 RAM 的容量(理论上受限于 CPU 地址总线和寄存器的宽度),而虚拟地址的范围可以扩展到 RAM 或操作系统架构允许的最高地址(例如,在 1 GB RAM 系统上最多支持 4 GB 的虚拟内存地址)。
由于 MMU 是内存管理的核心,它将内存组织为固定大小的逻辑单元,称为页。页的大小是 2 的幂,以字节为单位,并在不同的系统中有所不同。一个页由一个页框支持,页的大小与页框匹配。在深入学习内存管理之前,我们先介绍一下其他术语:
-
内存页、虚拟页或简称页是用来指代一个固定长度的(
PAGE_SIZE
)虚拟内存块的术语。相同的术语“页”也作为内核数据结构,表示一个内存页。 -
另一方面,框架(或页框)指的是物理内存(RAM)中的一个固定长度块,操作系统将页面映射到这个块上。页的大小与页框大小匹配。每个页框都有一个编号,称为页框号(PFN)。
-
接下来是页表这一术语,它是一个内核和架构数据结构,用于存储虚拟地址与物理地址之间的映射关系。页/框的键值对描述了页表中的单个条目,代表一个映射。
最后,“页对齐”这一术语用于描述从页面的起始位置开始的地址。不言而喻,任何地址是系统页大小的倍数的内存,都被认为是页对齐的。例如,在一个 4 KB 页大小的系统中,4.096
、20.480
和409.600
是页对齐的内存地址实例。
注意
页的大小由 MMU 固定,操作系统无法修改它。一些处理器允许使用多种页大小(例如,ARMv8-A 支持三种不同的粒度大小:4 KB、16 KB 和 64 KB),操作系统可以决定使用哪种大小。不过,4 KB 是广泛使用的页粒度。
既然处理内存时常用的术语已经介绍完毕,那么我们就专注于内核如何进行内存管理和组织。
Linux 是一个虚拟内存操作系统。在一个运行中的 Linux 系统中,每个进程甚至内核本身(以及某些设备)都会被分配地址空间,这些空间是处理器虚拟地址空间的一部分(需要注意的是,内核和进程并不处理物理地址——只有 MMU 处理)。虽然这个虚拟地址空间被划分为内核空间和用户空间,但上半部分用于内核,下半部分用于用户空间。
划分因架构而异,并由 CONFIG_PAGE_OFFSET
内核配置选项控制。对于 32 位系统,默认划分在 0xC0000000
。这被称为 3 GB/1 GB 划分,其中用户空间分配了较低的 3 GB 虚拟地址空间。然而,通过调整 CONFIG_VMSPLIT_1G
、CONFIG_VMSPLIT_2G
和 CONFIG_VMSPLIT_3G_OPT
内核配置选项(参见 arch/x86/Kconfig
和 arch/arm/Kconfig
),内核也可以获得不同大小的地址空间。对于 64 位系统,划分因架构而异,但其值较高:64 位 ARM 为 0x8000000000000000
,x86_64 为 0xffff880000000000
。
在 32 位系统上,采用默认划分方案时,一个典型进程的虚拟地址空间布局如下所示:
图 10.1 – 32 位系统内存划分
虽然在 64 位系统上这种布局是透明的,但在 32 位机器上有一些特性需要介绍。在接下来的章节中,我们将详细研究这种内存划分的原因、用途及其应用场景。
32 位系统上的内核地址空间布局 – 低内存与高内存的概念
在理想的情况下,所有内存都是永久可映射的。然而,在 32 位系统上存在一些限制,导致只有一部分 RAM 被永久映射。这部分内存可以被内核直接访问(通过简单的解引用),并被称为 低内存,而未被永久映射的(物理)内存部分则被称为 高内存。不同架构的限制决定了这个边界的具体位置。例如,英特尔核心只能永久映射前 1 GB 的 RAM。实际上,这个量略小,为 896 MiB 的 RAM,因为其中的一部分低内存用于动态映射高内存:
图 10.2 – 高内存与低内存的划分
在前面的图示中,我们可以看到,内核地址空间的 128 MB 用于动态映射需要时的高内存 RAM。另一方面,896 MB 的内核地址空间被永久并线性地映射到低内存的 896 MB RAM。
高内存机制还可以在 1 GB RAM 系统上使用,动态映射用户内存,只要内核需要访问。内核能够将整个 RAM 映射到其地址空间并不意味着用户空间无法访问它。一个 RAM 页框架可以有多个映射;它可以同时永久映射到内核内存空间,并在进程被选中执行时映射到用户空间的某个地址。
注意
给定一个虚拟地址,您可以通过使用前面显示的进程布局来区分它是内核空间还是用户空间地址。PAGE_OFFSET
以下的每个地址来自用户空间;否则,它来自内核空间。
低内存详细信息
内核地址空间的前 896 MB 构成低内存区域。在引导过程的早期,内核会将这 896 MB 的地址空间永久映射到物理 RAM 上。由该映射得到的地址被称为LOWMEM
,并且专门保留给直接内存访问(DMA)使用。由于硬件限制,硬件并不总是允许将所有页视为相同。我们可以在内核空间中识别出三个不同的内存区域:
-
ZONE_DMA
:该区域包含 16 MB 以下的内存页帧,专门保留给 DMA 使用。 -
ZONE_NORMAL
:该区域包含 16 MB 以上且小于 896 MB 的内存页帧,供正常使用。 -
ZONE_HIGHMEM
:该区域包含 896 MB 及以上的内存页帧。
然而,在一个 512 MB 的系统上,将没有ZONE_HIGHMEM
,16 MB 用于ZONE_DMA
,剩余的 496 MB 用于ZONE_NORMAL
。
从前面的所有内容,我们可以完善对逻辑地址的定义,补充说明这些地址是在内核空间中按物理地址线性映射的地址,并且可以通过使用偏移量获取相应的物理地址。内核虚拟地址与逻辑地址类似,都是从内核空间地址映射到物理地址的映射。然而,它们之间的区别在于,内核虚拟地址并不总是像逻辑地址那样具有相同的线性、一对一的物理位置映射。
注意
你可以使用__pa(address)
宏将物理地址转换为逻辑地址,并使用__va(address)
宏进行反向转换。
理解高内存
内核地址空间的前 128 MB 被称为HIGHMEM
区域。
内核会动态创建访问高内存的映射,并在使用完毕后销毁。这使得高内存的访问速度较慢。然而,由于 64 位系统具有巨大的地址范围(264 TB),因此高内存的概念在 64 位系统中不存在,3 GB/1 GB(或任何类似的拆分方案)的划分已经不再有意义。
从内核看进程地址空间的概览
在 Linux 系统中,每个进程在内核中都由struct task_struct
的一个实例表示(见include/linux/sched.h
),该结构描述了该进程。在进程开始运行之前,会为它分配一个内存映射表,存储在struct mm_struct
类型的变量中(见include/linux/mm_types.h
)。通过查看struct task_struct
定义的以下片段,可以验证这一点,该片段嵌入了指向struct mm_struct
类型元素的指针:
struct task_struct{
[…]
struct mm_struct *mm, *active_mm;
[…]
}
在内核中,有一个全局变量始终指向当前进程,current
,而current->mm
字段指向当前进程的内存映射表。在进一步解释之前,我们先来看一下struct mm_struct
数据结构的以下片段:
struct mm_struct {
struct vm_area_struct *mmap;
unsigned long mmap_base;
unsigned long task_size;
unsigned long highest_vm_end;
pgd_t * pgd;
atomic_t mm_users;
atomic_t mm_count;
atomic_long_t nr_ptes;
#if CONFIG_PGTABLE_LEVELS > 2
atomic_long_t nr_pmds;
#endif
int map_count;
spinlock_t page_table_lock;
unsigned long total_vm;
unsigned long locked_vm;
unsigned long pinned_vm;
unsigned long data_vm;
unsigned long exec_vm;
unsigned long stack_vm;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/* ref to file /proc/<pid>/exe symlink points to */
struct file __rcu *exe_file;
};
我故意移除了一些我们不感兴趣的字段。有些字段我们稍后会讲到:例如pgd
,它是指向进程基地址(第一个条目)的一级表(页全局目录,缩写为PGD)的指针,写入在 CPU 的上下文切换时的转换表基地址。为了更好地理解这个数据结构,我们可以使用以下图表:
图 10.3 – 进程地址空间
从进程的角度来看,内存映射可以被视为一组专门用于连续虚拟地址范围的页表项。这个*“连续虚拟地址范围”*被称为内存区域,或虚拟内存区域(VMA)。每个内存映射都由起始地址和长度、权限(例如程序是否可以从该内存中读取、写入或执行)以及相关资源(例如物理页面、交换页面和文件内容)描述。
mm_struct
有两种方式来存储进程区域(VMAs):
-
在一棵红黑树(自平衡二叉查找树)中,根元素由
mm_struct->mm_rb
字段指向 -
在一个链表中,第一个元素由
mm_struct->mmap
字段指向
现在我们已经概览了进程地址空间,并且看到它是由一组虚拟内存区域组成的,接下来让我们深入研究这些内存区域背后的机制。
理解 VMA 的概念
在内核中,进程的内存映射被组织成若干个区域,每个区域被称为 VMA。供您参考,在 Linux 系统上的每个运行中的进程中,代码段、每个映射的文件区域(例如库文件)或每个独立的内存映射(如果有的话)都是由 VMA 实现的。VMA 是一个与架构无关的结构,具有权限和访问控制标志,由起始地址和长度定义。它们的大小始终是页大小(PAGE_SIZE
)的倍数。一个 VMA 由几个页面组成,每个页面在页表中都有一个条目(页表项(PTE))。
VMA 在内核中表示为struct vma_area
结构的一个实例,定义如下:
struct vm_area_struct {
unsigned long vm_start;
unsigned long vm_end;
struct vm_area_struct *vm_next, *vm_prev;
struct mm_struct *vm_mm;
pgprot_t vm_page_prot;
unsigned long vm_flags;
unsigned long vm_pgoff;
struct file * vm_file;
[...]
}
为了提高本节的可读性和易理解性,只有与我们相关的元素被列出。然而,剩余元素的含义如下:
-
vm_start
是 VMA 在地址空间(vm_mm
)中的起始地址,它是该 VMA 内的第一个地址。 -
vm_end
是vm_mm
中我们结束地址之后的第一个字节,它是该 VMA 外部的第一个地址。 -
vm_next
和vm_prev
用于实现按地址排序的每个任务的 VMA 链表。 -
vm_mm
是该 VMA 所属的进程地址空间。 -
vm_page_prot
和vm_flags
表示 VMA 的访问权限。前者是一个架构级数据类型,其更新直接应用于底层架构的 PTE。它是vm_flags
的缓存转换形式,后者以架构无关的方式存储适当的保护位和映射类型。 -
vm_file
是支持该映射的文件。对于匿名映射(如进程的堆或栈),此值可以为NULL
。 -
vm_pgoff
是偏移量(在vm_file
内)以页大小为单位。此偏移量以页数来度量。
以下图是进程内存映射的概览,突出显示了每个 VMA 并描述其一些结构元素:
图 10.4 – 进程内存映射
前面的图片(来源:http://duartes.org/gustavo/blog/post/how-the-kernel-manages-your-memory/)描述了一个进程(从/bin/gonzo
启动)的内存映射(VMA)。我们可以看到struct task_struct
与其地址空间元素(mm
)之间的交互,后者列出了并描述了每个 VMA(起始、结束及其后备文件)。
你可以使用find_vma()
函数来查找与给定虚拟地址对应的 VMA。find_vma()
在linux/mm.h
中声明,形式如下:
extern struct vm_area_struct * find_vma(
struct mm_struct * mm, unsigned long addr);
该函数搜索并返回第一个满足vm_start <= addr < vm_end
的 VMA,若没有找到则返回NULL
。mm
是要搜索的进程地址空间。对于当前进程,它可以是current->mm
。以下是一个示例:
struct vm_area_struct *vma =
find_vma(task->mm, 0x603000);
if (vma == NULL) /* Not found ? */
return -EFAULT;
/* Beyond the end of returned VMA ? */
if (0x13000 >= vma->vm_end)
return -EFAULT;
前面的代码片段将寻找一个 VMA,其内存边界包含0x603000
。
给定一个进程,其标识符为<PID>
,可以通过读取/proc/<PID>/maps
、/proc/<PID>/smaps
和/proc/<PID>/pagemap
文件来获取该进程的所有内存映射。以下列出了一个正在运行的进程(进程标识符 PID 为1073
)的映射:
# cat /proc/1073/maps
00400000-00403000 r-xp 00000000 b3:04 6438 /usr/sbin/net-listener
00602000-00603000 rw-p 00002000 b3:04 6438 /usr/sbin/net-listener
00603000-00624000 rw-p 00000000 00:00 0 [heap]
7f0eebe4d000-7f0eebe54000 r-xp 00000000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eebe54000-7f0eec054000 ---p 00007000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eec054000-7f0eec055000 rw-p 00007000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eec055000-7f0eec069000 r-xp 00000000 b3:04 21629 /lib/libresolv-2.22.so
7f0eec069000-7f0eec268000 ---p 00014000 b3:04 21629 /lib/libresolv-2.22.so
[...]
7f0eee1e7000-7f0eee1e8000 rw-s 00000000 00:12 12532 /dev/shm/sem.thk-mcp-231016-sema
[...]
前面的每一行表示一个 VMA,字段对应于{地址(起始-结束)} {权限} {偏移量} {设备(主:次)} {inode} {路径名(映像)}
的模式:
-
address
:表示 VMA 的起始和结束地址。 -
permissions
:描述区域的访问权限:r
(读),w
(写),x
(执行)。p
表示映射是私有的,s
表示共享映射。 -
offset
:如果是文件映射(mmap
系统调用),则为映射发生时在文件中的偏移量。否则为0
。 -
major:minor
:如果是文件映射,这代表存储文件的设备的主次设备号(设备持有文件)。 -
inode
:如果是从文件映射,则为映射文件的inode
号。 -
pathname
:这是映射文件的名称,否则留空。还有其他区域名称,例如[heap]
、[stack]
或[vdso]
(代表虚拟动态共享对象,一种由内核映射到每个进程地址空间中的共享库,目的是减少系统调用切换到内核模式时的性能损失)。
分配给进程的每个页面都属于某个区域,因此任何不在 VMA 中的页面都不存在,也无法被进程引用。
高内存非常适合用户空间,因为它的地址空间必须显式地进行映射。因此,大多数高内存被用户应用程序占用。__GFP_HIGHMEM
和 GFP_HIGHUSER
是请求分配(潜在)高内存的标志。没有这些标志,所有内核分配只会返回低内存。在 Linux 中,无法从用户空间分配连续的物理内存。
既然 VMAs 对我们已经没有秘密可言,那么我们就来描述将其翻译到相应物理地址的硬件概念(如果有的话),或者它们的创建和分配方式。
破解地址翻译和 MMU
MMU 不仅将虚拟地址转换为物理地址,还保护内存免受未授权访问。给定一个进程,任何需要从该进程访问的页面必须存在于其一个 VMA 中,因此必须存在于进程的页表中(每个进程都有自己的页表)。
回顾一下,内存是通过固定大小的块进行组织的,虚拟内存使用页面,而物理内存使用帧。在我们的例子中,大小是 4 KB。然而,它在内核中是通过 PAGE_SIZE
宏定义并访问的。请记住,页面大小是由硬件强制的。以 4 KB 页面大小的系统为例,字节 0 到 4095 属于页面 0,字节 4096 到 8191 属于页面 1,依此类推。
引入了页表的概念来管理页面和帧之间的映射。页面被分布在表中,每个 PTE 对应一个页面和一个帧之间的映射。然后,每个进程都会获得一组页表来描述其所有的内存区域。
为了遍历页面,每个页面都会分配一个索引,称为页号。当涉及到帧时,它是页面帧号(PFN)。这样,VMA(更准确地说是逻辑地址)由两部分组成:页号和偏移量。在 32 位系统中,偏移量表示地址的低 12 位,而在 8 KB 页大小系统中,偏移量表示低 13 位。以下图展示了地址被分为页号和偏移量的概念:
图 10.5 – 逻辑地址表示
操作系统或 CPU 如何知道哪个物理地址对应给定的逻辑地址?它们使用页表作为转换表,并且知道每个条目的索引是虚拟页号,该索引位置的值是 PFN。为了根据虚拟内存访问物理内存,操作系统首先提取偏移量和虚拟页号,然后遍历进程的页表,将虚拟页号与物理页进行匹配。一旦匹配成功,就可以访问该页框中的数据:
图 10.6 – 地址转换
偏移量用于指向框架中的正确位置。页表不仅保存物理页号和虚拟页号之间的映射,还包含访问控制信息(读/写权限、特权等)。
以下图描述了地址解码和页表查找,以指向适当框架中的适当位置:
图 10.7 – 虚拟地址到物理地址的转换
用于表示偏移量的位数由 PAGE_SHIFT
内核宏定义。PAGE_SHIFT
是将 1 位左移多少次以获得 PAGE_SIZE
值的次数。它也是将页面的逻辑地址右移多少次以获得其页号的次数,这对于物理地址也是一样,用于获得其页框号。这个宏与架构相关,也取决于页面粒度。其值可以视为以下内容:
#ifdef CONFIG_ARM64_64K_PAGES
#define PAGE_SHIFT 16
#elif defined(CONFIG_ARM64_16K_PAGES)
#define PAGE_SHIFT 14
#else
#define PAGE_SHIFT 12
#endif
#define PAGE_SIZE (_AC(1, UL) << PAGE_SHIFT)
前述内容表明,默认情况下(无论是 ARM 还是 ARM64),PAGE_SHIFT
为 12
,意味着 4 KB 的页面大小。在 ARM64 上,选择 16 KB 或 64 KB 页面大小时,PAGE_SHIFT
为 14
或 16
。
根据我们对地址转换的理解,页表是一个部分解决方案。让我们看看为什么。大多数 32 位架构需要 32 位(4 字节)来表示一个页表项。在这种系统(32 位)中,每个进程都有其私有的 3 GB 用户地址空间,我们需要 786,432 个条目来表示并覆盖一个进程的地址空间。仅仅为了存储内存映射,所需的物理内存就过多。事实上,一个进程通常只会使用其虚拟地址空间中的一小部分,但这些部分是分散的。为了解决这个问题,引入了“层次”概念。页表通过层级(页级)进行分层。存储多级页表所需的空间仅依赖于实际使用的虚拟地址空间,而不是与虚拟地址空间的最大大小成比例。这样,未使用的内存不再被表示,页表遍历时间也得到了减少。此外,级别 N
中的每个表项将指向级别 N+1
中的一个条目,级别 1 是较高层次。
Linux 支持最多四级分页。然而,使用多少级别是与架构相关的。以下是每个级别的描述:
-
内核中的
pgd_t
类型(通常是unsigned long
)指向第二级表中的一个条目。在 Linux 内核中,struct task_struct
表示一个进程的描述,它有一个成员(mm
),该成员的类型是struct mm_struct
,用于表征和表示进程的内存空间。在struct mm_struct
中,有一个处理器特定的字段pgd
,它是指向进程的一级(PGD)页表的第一个条目(条目 0)的指针。每个进程有且仅有一个 PGD,最多可以包含 1,024 个条目。 -
页上级目录(PUD):表示间接映射的第二级。
-
页中间目录(PMD):这是第三个间接映射级别。
-
pte_t
,每个条目指向一个物理页。注意
并非所有级别都被使用。i.MX6 的 MMU 仅支持二级页表(PGD 和 PTE),这对于几乎所有 32 位 CPU 都是如此。在这种情况下,PUD 和 PMD 被简单忽略。
重要的是要知道 MMU 不存储任何映射。它是一个位于 RAM 中的数据结构。而是 CPU 中有一个特殊的寄存器,称为pdg
字段,指向struct mm_struct
:current->mm.pgd == TTBR0
。
在上下文切换时(当新进程被调度并分配 CPU 时),内核立即配置 MMU 并用新进程的pgd
更新 PTBR。现在,当虚拟地址传给 MMU 时,MMU 会使用 PTBR 的内容定位到进程的一级页表(PGD),然后利用虚拟地址的最高有效位(MSBs)提取的一级索引找到合适的表条目,该条目包含指向合适的二级页表基地址的指针。然后,从该基地址开始,MMU 使用二级索引查找合适的条目,依此类推,直到找到 PTE。ARM 架构(在我们的案例中是 i.MX6)有二级页表。在这种情况下,二级条目是 PTE,指向物理页(PFN)。此时,仅能找到物理页。为了访问页内的准确内存位置,MMU 会提取内存偏移量,这也是虚拟地址的一部分,并指向物理页中的相同偏移。
为了便于理解,前述描述仅限于二级分页方案,但可以轻松扩展。下图是此二级分页方案的表示:
图 10.8 – 二级地址转换方案
当一个进程需要从某个内存位置读取或写入数据时(当然,我们讨论的是虚拟内存),MMU 会将该进程的页面表转换到正确的条目(PTE)。虚拟页面号会从虚拟地址中提取,并由处理器作为索引查找进程的页面表,以检索其页面表条目。如果在该偏移量处有有效的页面表条目,处理器将从此条目中获取页面帧号。如果没有,这意味着该进程访问了其虚拟内存中未映射的区域。此时会触发页面错误,操作系统应该处理此问题。
在实际情况中,地址转换需要页面表遍历,它不总是一次完成的操作。每个表级别至少需要一次内存访问。一个四级页面表将需要四次内存访问。换句话说,每次虚拟访问都会导致五次物理内存访问。如果虚拟内存的访问比物理访问慢四倍,那么虚拟内存的概念将毫无意义。幸运的是,系统级芯片(SoC)制造商努力找到了一种巧妙的技巧来解决这个性能问题:现代 CPU 使用一种称为翻译后备缓冲区(TLB)的小型关联且非常快速的内存,用于缓存最近访问的虚拟页面的 PTE。
页面查找与 TLB
在 MMU 进行地址转换之前,还有另一个步骤。由于存在用于缓存最近访问数据的缓存,也有一个用于缓存最近翻译的地址的缓存。数据缓存加速了数据访问过程,TLB 加速了虚拟地址的转换(是的,地址转换是一个耗时的任务)。它是内容可寻址存储器(CAM),其中关键字是虚拟地址,值是物理地址。换句话说,TLB 是 MMU 的一个缓存。在每次内存访问时,MMU 首先检查 TLB 中最近使用的页面,TLB 中包含一些虚拟地址范围,这些虚拟地址范围目前已分配给物理页面。
TLB 是如何工作的?
在内存访问时,CPU 遍历 TLB,尝试查找正在访问的页面的虚拟页面号。这个步骤叫做TLB 查找。当找到 TLB 条目(发生匹配)时,称为 TLB 命中,CPU 会继续运行,并使用在 TLB 条目中找到的 PFN 来计算目标物理地址。当发生 TLB 命时,不会发生页面错误。如果在 TLB 中找到翻译,虚拟内存访问的速度将和物理访问一样快。如果没有 TLB 命中,则称为 TLB 未命中。
在 TLB 未命中的情况下,有两种可能性。根据处理器类型,TLB 未命中事件可以由软件、硬件或通过 MMU 来处理:
-
软件处理:CPU 触发 TLB 未命中中断,操作系统捕获该中断。操作系统随后遍历进程的页表以找到正确的 PTE。如果有匹配且有效的条目,CPU 将在 TLB 中安装新的转换。否则,将执行页面故障处理程序。
-
硬件处理:由 CPU(实际上是 MMU)在硬件上遍历进程的页表。如果有匹配,CPU 将把新的转换添加到 TLB 中。否则,CPU 会触发页面故障中断,由操作系统处理。
在这两种情况下,页面故障处理程序都是相同的,do_page_fault()
。该函数是架构相关的;对于 ARM,它在 arch/arm/mm/fault.c
中定义。
以下是描述 TLB 查找、TLB 命中或 TLB 未命中事件的示意图:
](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_10_009.jpg)
图 10.9 – MMU 和 TLB 遍历过程
页表和页目录条目依赖于架构。操作系统必须确保表的结构与 MMU 识别的结构相匹配。在 ARM 处理器上,转换表的位置必须写入 control
协处理器 15(CP15) c2
寄存器,然后通过写入 CP15 c1
寄存器启用缓存和 MMU。详细信息请查看 infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0056d/BABHJIBH.htm
和 infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0433c/CIHFDBEJ.html
。
现在,既然我们已经掌握了地址转换机制及其与 TLB 的配合,我们可以讨论内存分配,这涉及到在幕后操作页表项。
处理内存分配机制及其 API
在进入 API 列表之前,我们先从以下图示开始,展示了在基于 Linux 的系统中存在的不同内存分配器,稍后我们将讨论这些分配器:
](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_10_010.jpg)
图 10.10 – 内核内存分配器概览
上述示意图的灵感来源于 bootlin.com/doc/training/linux-kernel/linux-kernel-slides.pdf
。图中显示的是一种满足各种内存请求的分配机制。根据你的内存需求,你可以选择最接近目标的分配器。最基础的分配器是 kmalloc
API。虽然 kmalloc
可以用来从 slab 分配器请求内存,但我们也可以直接与 slab 交互,从其缓存中请求内存,甚至构建我们自己的缓存。
让我们从内存分配的主分配器和最低级别分配器——页分配器开始,它是其他分配器的派生来源。
页分配器
页面分配器是 Linux 系统中的低级分配器,它作为其他分配器的基础。这个分配器带来了页面(虚拟)和页面框架(物理)的概念。因此,系统的物理内存被分割成固定大小的块(称为 struct page
结构,我们将使用专门的 API 来操作它,下一节中会介绍)。
页面分配 API
这是最低级的分配器。它使用伙伴算法分配和回收页面块。页面按 2 的幂大小分配(以获得伙伴算法的最佳效果)。这意味着它可以分配 1 个页面、2 个页面、4 个页面、8 个页面、16 个页面,依此类推。通过这种分配返回的页面是物理连续的。alloc_pages()
是主要的 API,其定义如下:
struct page *alloc_pages(gfp_t mask, unsigned int order)
上述函数在无法分配页面时返回 NULL
。否则,它会分配 2 顺序的页面并返回指向 struct page
实例的指针,该指针指向预留块的第一个页面。然而,有一个帮助宏 alloc_page()
,它可以用于分配单个页面。其定义如下:
#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)
这个宏封装了 alloc_pages()
,并将顺序参数设置为 0
。
__free_pages()
必须用于释放使用 alloc_pages()
函数分配的内存页面。它接受一个指向分配块第一个页面的指针作为参数,以及用于分配时相同的顺序。其定义如下:
void __free_pages(struct page *page, unsigned int order);
还有其他以相同方式工作的函数,但它们返回的是预留块的(逻辑)地址,而不是 struct page
的实例。这些是 __get_free_pages()
和 __get_free_page()
,它们的定义如下:
unsigned long __get_free_pages(gfp_t mask,
unsigned int order);
unsigned long get_zeroed_page(gfp_t mask);
free_pages()
用于释放使用 __get_free_pages()
分配的页面。它接受表示分配页面起始区域的内核地址,以及顺序,它应与用于分配时相同:
free_pages(unsigned long addr, unsigned int order);
无论分配类型是什么,mask
指定了应从哪些内存区域分配页面以及分配器的行为。以下是可能的值:
-
GFP_USER
:用于用户内存分配。 -
GFP_KERNEL
:用于内核分配的常用标志。 -
GFP_HIGHMEM
:这会请求从HIGH_MEM
区域分配内存。 -
GFP_ATOMIC
:这以原子方式分配内存,不能进入睡眠状态。它在我们需要从中断上下文分配内存时使用。
然而,你应该注意,无论是否在 __get_free_pages()
(或 __get_free_page()
)中指定 GFP_HIGHMEM
标志,它都不会被考虑。这些函数会屏蔽掉该标志,以确保返回的地址永远不会表示高内存页面(因为它们具有非线性/永久映射)。如果你需要高内存,使用 alloc_pages()
然后使用 kmap()
来访问它。
__free_pages()
和 free_pages()
可以混合使用。它们之间的主要区别是 free_page()
接受一个逻辑地址作为参数,而 __free_page()
接受一个 struct page
结构。
注意
可用的最大阶数因架构不同而异。它取决于 FORCE_MAX_ZONEORDER
内核配置选项,默认值为 11
。在这种情况下,您可以分配的页面数为 1,024。也就是说,在一个 4 KB 大小的系统上,您最多可以分配 1,024 x 4 KB = 4 MB 的内存。在 ARM64 上,最大阶数随所选页面大小而变化。如果是 16 KB 页面大小,最大阶数为 12
,如果是 64 KB 页面大小,最大阶数为 14
。这些每次分配的大小限制对于 kmalloc()
同样适用。
页面与地址转换函数
内核提供了一些便捷的函数,可以在 struct page
实例和它们对应的逻辑地址之间来回转换,这在处理内存时的不同阶段都非常有用。page_to_virt()
函数用于将一个 struct page
(例如,alloc_pages()
返回的)转换为内核逻辑地址。或者,virt_to_page()
接受一个内核逻辑地址并返回其关联的 struct page
实例(就像是使用 alloc_pages()
函数分配的那样)。virt_to_page()
和 page_to_virt()
都在 <asm/page.h>
中声明,具体如下:
struct page *virt_to_page(void *kaddr);
void *page_to_virt(struct page *pg)
还有一个宏 page_address()
,它简单地封装了 page_to_virt()
,其声明如下:
void *page_address(const struct page *page)
它返回传入参数的页面的逻辑地址。
slab 分配器
slab 分配器是 kmalloc()
所依赖的分配器。它的主要目的是消除由于内存(释放)分配造成的碎片,这种碎片由伙伴系统在小尺寸内存分配时产生,并加速常用对象的内存分配。
理解伙伴算法
为了分配内存,所请求的大小会向上舍入到二的幂,伙伴分配器会搜索相应的列表。如果请求的列表中没有条目,来自下一个较大列表(该列表中的块是上一个列表大小的两倍)的一项会被拆分成两半(称为 buddies)。分配器使用第一半,而另一半会被添加到下一个较小的列表中。这是一个递归的过程,直到伙伴分配器成功找到一个可以拆分的块,或者达到最大块大小并且没有可用的空闲块为止。
以下案例研究受到了 dysphoria.net/OperatingSystems1/4_allocation_buddy_system.html
的深刻启发。例如,如果最小分配大小为 1K 字节,且内存大小为 1MB,则伙伴分配器将为 1K 字节、2K 字节、4K 字节、8K 字节、16K 字节、32K 字节、64K 字节、128K 字节、256K 字节、512K 字节、1MB 字节的空闲块分别创建一个空列表。除了 1MB 列表外,所有列表最初都是空的,1MB 列表只有一个空洞。
现在假设我们要分配一个 70K 的块,伙伴分配器会将其向上舍入到 128K,并最终将 1MB 分割为两个 512K 块,然后是 256K,最后是 128K,然后它会将其中一个 128K 块分配给用户。以下是总结这个场景的方案:
](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_10_011.jpg)
图 10.11 – 使用伙伴算法分配
释放速度与分配速度一样快。以下是总结释放算法的图示:
](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_10_012.jpg)
图 10.12 – 使用伙伴算法释放
在上图中,我们可以看到使用伙伴算法释放内存的过程。下一节我们将研究建立在此算法之上的 slab 分配器。
走进 slab 分配器
在介绍 slab 分配器之前,首先定义它使用的一些术语:
-
inode
和mutexe
对象。一个 slab 可以看作是一个大小相同的块的数组。 -
inode
对象仅限于此。
Slabs 可能处于以下几种状态:
-
空:表示 slabs 上的所有对象(块)都标记为可用。
-
部分使用:slab 中既有已用对象也有空闲对象。
-
满:表示 slabs 上的所有对象都标记为已用。
内存分配器负责构建缓存。最初,每个 slab 都是空的并标记为“空”。当为内核对象分配内存时,分配器会在该类型对象的缓存中查找一个空闲位置。如果未找到,分配器将分配一个新的 slab 并将其添加到缓存中。新对象会从该 slab 中分配,且该 slab 会被标记为“部分使用”。当代码完成对内存的使用(内存释放)后,对象会被简单地返回到 slab 缓存中,并恢复到初始化状态。这也是内核提供帮助函数来获取已清零初始化内存的原因,这样我们可以消除先前的内容。slab 会保持其对象的引用计数,以便在缓存中的所有 slabs 都满了且需要请求另一个对象时,slab 分配器负责添加新的 slabs。
以下图示说明了 slabs、缓存及其不同状态的概念:
](https://github.com/OpenDocCN/freelearn-linux-pt5-zh/raw/master/docs/linux-dvc-dvr-dev-2e/img/B17934_10_013.jpg)
图 10.13 – Slabs 和缓存
有点像创建一个每个对象的分配器。内核为每种类型的对象分配一个缓存,并且只有相同类型的对象可以存储在缓存中(例如,只有task_struct
结构体)。
内核中有不同种类的 slab 分配器,具体取决于是否需要紧凑性、缓存友好性或原始速度。它们包括以下几种:
-
SLAB(slab 分配器),它是尽可能缓存友好的。这是最初的内存分配器。
-
SLOB(简单块列表),它尽可能紧凑,适用于内存非常低的系统,主要是嵌入式系统,内存为几兆字节或几十兆字节。
-
CONFIG_SLUB=y
)。请查看此补丁:git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a0acd820807680d2ccc4ef3448387fcdbf152c73
。注意
slab 这个术语已经成为一种通用名称,指代一种使用对象缓存的内存分配策略,能够高效地分配和释放内核对象。它不应与同名的分配器 SLAB 混淆,后者如今已经被 SLUB 取代。
kmalloc 家族分配
kmalloc()
是一个内核内存分配函数。它分配物理上连续的(但不一定是页面对齐的)内存。下图描述了内存如何分配并返回给调用者:
图 10.14 – kmalloc 内存组织
这个分配 API 是内核中通用的最高级别内存分配 API,它依赖于 SLAB 分配器。kmalloc()
返回的内存具有内核逻辑地址,因为它是从LOW_MEM
区域分配的,除非指定了HIGH_MEM
。它在 <linux/slab.h>
中声明,这是在使用 API 之前需要包含的头文件。定义如下:
void *kmalloc(size_t size, int flags);
在前面的代码中,size
指定要分配的内存大小(以字节为单位)。flags
决定了内存应该如何和在哪里分配。可用的标志与页面分配器相同(GFP_KERNEL
、GFP_ATOMIC
、GFP_DMA
等),以下是它们的定义:
-
GFP_KERNEL
:这是标准标志。我们不能在中断处理程序中使用这个标志,因为它的代码可能会休眠。它总是从LOM_MEM
区域返回内存(因此,是一个逻辑地址)。 -
GFP_ATOMIC
:这保证了分配的原子性。该标志用于在中断上下文中需要分配内存时。由于内存是从紧急池或内存中分配的,因此不应滥用此标志。 -
GFP_USER
:这为用户空间进程分配内存。分配的内存与分配给内核的内存是分开的。 -
GFP_NOWAIT
:如果分配是在原子上下文中进行的,例如中断处理程序使用时,应使用此标志。此标志会在分配时防止直接回收、I/O 和文件系统操作。与GFP_ATOMIC
不同,它不使用内存预留。因此,在内存紧张时,GFP_NOWAIT
的分配可能会失败。 -
GFP_NOIO
:与GFP_USER
类似,这可能会阻塞,但与GFP_USER
不同,它不会启动磁盘 I/O。换句话说,它在分配内存时会阻止任何 I/O 操作。此标志主要用于块设备/磁盘层。 -
GFP_NOFS
:这将使用直接回收,但不会使用任何文件系统接口。 -
__GFP_NOFAIL
:虚拟内存实现必须无限期重试,因为调用者无法处理分配失败。分配可能会一直阻塞,但永远不会失败。因此,测试是否失败是没有意义的。 -
GFP_HIGHUSER
:请求从HIGH_MEMORY
区域分配内存。 -
GFP_DMA
:从DMA_ZONE
分配内存。
在成功分配内存后,kmalloc()
返回分配块的虚拟(逻辑,除非指定了高内存)地址,并保证该内存是物理连续的。如果发生错误,它将返回 NULL
。
对于设备驱动程序,建议使用托管版本 devm_kmalloc()
,它不一定需要释放内存,因为内存管理由内存核心内部处理。以下是其原型:
void *devm_kmalloc(struct device *dev, size_t size,
gfp_t gfp);
在前面的原型中,dev
是为其分配内存的设备。
注意,kmalloc()
在分配小内存时依赖于 SLAB 缓存。为此,它可能会将分配区域的大小内部向上舍入到可以容纳该内存的最小 SLAB 缓存的大小。这可能会导致返回的内存量超过请求的内存。然而,可以使用 ksize()
来确定实际分配的内存量(以字节为单位)。即使最初通过 kmalloc()
调用指定了较小的内存量,你仍然可以使用这部分额外的内存。
以下是 ksize
原型:
size_t ksize(const void *objp);
在前面的例子中,objp
是将返回其实际字节大小的对象。
kmalloc()
具有与页面相关的分配 API 相同的大小限制。例如,默认情况下将 FORCE_MAX_ZONEORDER
设置为 11
,则每次通过 kmalloc()
分配的最大大小为 4 MB
。
kfree
函数用于释放由 kmalloc()
分配的内存。它的定义如下:
void kfree(const void *ptr)
以下是使用 kmalloc()
和 kfree()
分别分配和释放内存的示例:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/mm.h>
static void *ptr;
static int alloc_init(void)
{
size_t size = 1024; /* allocate 1024 bytes */
ptr = kmalloc(size,GFP_KERNEL);
if(!ptr) {
/* handle error */
pr_err("memory allocation failed\n");
return -ENOMEM;
} else {
pr_info("Memory allocated successfully\n");
}
return 0;
}
static void alloc_exit(void)
{
kfree(ptr);
pr_info("Memory freed\n");
}
module_init(alloc_init);
module_exit(alloc_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("John Madieu");
内核提供了基于 kmalloc()
的其他辅助函数,如下所示:
void kzalloc(size_t size, gfp_t flags);
void kzfree(const void *p);
void *kcalloc(size_t n, size_t size, gfp_t flags);
void *krealloc(const void *p, size_t new_size,
gfp_t flags);
krealloc()
是内核空间中与用户空间 realloc()
函数相对应的函数。由于 kmalloc()
返回的内存保留了其先前状态的内容,因此可以通过 kzalloc()
请求一块已初始化为零的 kmalloc
分配内存。kzfree()
是释放 kzalloc()
分配内存的函数,而 kcalloc()
用于为数组分配内存,其 n
和 size
参数分别表示数组中元素的数量和每个元素的大小。
由于 kmalloc()
返回的是内核永久映射中的内存区域,因此可以使用 virt_to_phys()
将逻辑地址转换为物理地址,或者使用 virt_to_bus()
转换为 I/O 总线地址。这些宏内部会调用 __pa()
或 __va()
,如果有必要的话。物理地址(virt_to_phys(kmalloc'ed address)
)右移 PAGE_SHIFT
后,将生成从中分配内存块的第一个页面的 PFN(pfn
)。
vmalloc 系列分配
vmalloc()
是我们在本书中讨论的最后一个内核分配器。它返回的内存在虚拟地址空间中是唯一连续的。底层的内存帧是分散的,如下图所示:
图 10.15 – vmalloc 内存组织
在前面的示意图中,我们可以看到内存并非物理连续的。此外,vmalloc()
返回的内存总是来自 HIGH_MEM
区域。返回的地址是纯粹的虚拟地址(而非逻辑地址),不能转换为物理地址或总线地址,因为无法保证背后的内存是物理连续的。这意味着 vmalloc()
返回的内存不能在微处理器之外使用(例如,不能轻易用于 DMA 目的)。使用 vmalloc()
为大量页面(例如,单独分配一个页面没有意义)分配内存是正确的,它们仅存在于软件中,如网络缓冲区。需要注意的是,vmalloc()
的速度比 kmalloc()
和页面分配器函数慢,因为它既要检索内存,又要构建页表,甚至可能需要重新映射到虚拟连续的范围,而 kmalloc()
从不做这些操作。
在使用 vmalloc()
API 之前,你应当包含以下头文件:
#include <linux/vmalloc.h>
以下是 vmalloc
系列的原型:
void *vmalloc(unsigned long size);
void *vzalloc(unsigned long size);
void vfree(void *addr);
在上述原型中,参数 size
是你需要分配的内存大小。成功分配内存后,它返回分配内存块的第一个字节的地址。失败时,返回 NULL
。vfree()
执行反向操作,释放 vmalloc()
分配的内存。vzalloc
变体返回已初始化为零的内存。
以下是使用 vmalloc
的示例:
#include<linux/init.h>
#include<linux/module.h>
#include <linux/vmalloc.h>
Static void *ptr;
static int alloc_init(void)
{
unsigned long size = 8192; /* 2 x 4KB */
ptr = vmalloc(size);
if(!ptr)
{
/* handle error */
pr_err("memory allocation failed\n");
return -ENOMEM;
} else {
pr_info("Memory allocated successfully\n");
}
return 0;
}
static void my_vmalloc_exit(void)
{
vfree(ptr);
pr_info("Memory freed\n");
}
module_init(my_vmalloc_init);
module_exit(my_vmalloc_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("john Madieu, john.madieu@gmail.com");
vmalloc()
将分配不连续的物理页面,并将它们映射到一个连续的虚拟地址区域。这些vmalloc
虚拟地址的限制在内核空间的一个区域内,由VMALLOC_START
和VMALLOC_END
定义,这些是与架构相关的。内核通过暴露/proc/vmallocinfo
来显示系统上所有vmalloc
分配的内存。
进程内存分配背后的短故事
vmalloc()
偏好使用HIGH_MEM
区域(如果存在),该区域适用于进程,因为它们需要隐式和动态映射。然而,由于内存是有限资源,内核只有在必要时(通过读或写访问时)才会报告分配帧页面(物理页面)。这种按需分配被称为懒分配,它消除了分配永远不会使用的页面的风险。
每当请求页面时,仅更新页表;在大多数情况下,会创建一个新条目,这意味着只分配了虚拟内存。只有当用户访问页面时,才会触发一个名为page fault
的中断。这个中断有一个专用的处理程序,叫做page fault handler
,由 MMU 在尝试访问未立即成功的虚拟内存时调用。
实际上,当访问类型是(读、写或执行)时,只要页面在页表中没有设置适当的权限位来允许该类型的访问,都会触发页面错误中断。对该中断的响应分为以下三种方式之一:
-
硬错误:当页面不在任何地方(既不在物理内存中,也不在内存映射文件中)时,意味着处理程序无法立即解决该错误。处理程序将执行 I/O 操作,以准备解决该错误所需的物理页面,并可能暂停被中断的进程,切换到其他进程,直到系统解决问题。
-
软错误:当页面存在于内存的其他地方(另一个进程的工作集内)时,意味着错误处理程序可以通过立即将物理内存页面附加到适当的页表项,调整该项,并恢复中断的指令来解决错误。
-
无法解决的错误:这将导致总线错误或段错误(segv)。一个段错误信号(SIGSEGV)会发送到故障进程,终止它(默认行为),除非已为 SIGSEV 安装了信号处理程序,以更改默认行为。
总结来说,内存映射通常一开始不会附加物理页面,只有通过定义虚拟地址范围而没有关联的物理内存。实际的物理内存是在内存访问时,通过页面错误异常动态分配的,因为内核提供了一些标志来判断尝试访问是否合法,并指定页面错误处理程序的行为。因此,brk()
用户空间、mmap()
和类似的函数会分配(虚拟)空间,但物理内存会在稍后附加。
注意
在中断上下文中发生页面错误会导致双重错误中断,通常会让内核崩溃(调用 panic()
函数)。这就是为什么在中断上下文中分配的内存来自于一个内存池的原因,因为该内存池不会引发页面错误中断。如果在处理双重错误时发生中断,将生成三重错误异常,导致 CPU 关闭并且操作系统立即重启。此行为依赖于架构。
写时复制情况
让我们考虑一个需要两个或多个任务共享的内存区域或数据。fork()
系统调用是一种机制,它允许操作系统不会立即分配内存,也不会将内存复制到每个共享数据的任务中,直到其中一个任务修改(写入)它——在这种情况下,为它分配内存以便拥有其私有副本(因此得名,写时复制)。接下来我们以一个共享内存页面为例,描述 page fault handler
如何管理写时复制:
-
当页面需要共享时,将为每个访问共享页面的进程的页面表添加一个指向此共享页面的页面表项(PTE),并且该 PTE 目标标记为不可写。这是一个初始映射。
-
映射将导致为每个进程创建一个 VMA,并将其添加到每个进程的 VMA 列表中。共享页面会与这些 VMA 关联(即,之前为每个进程创建的 VMA),并且这次标记为可写。只要没有进程尝试修改共享页面的内容,就不会发生其他事情。
-
当其中一个进程尝试写入共享页面(第一次写入时),
fault handler
会注意到 PTE 标志(之前标记为不可写)和 VMA 标志(标记为可写)之间的区别,这意味着,“嘿,这是写时复制。” 然后它将分配一个物理页面,并将其分配给先前添加的 PTE(从而替换先前分配的共享页面),更新 PTE 标志(这些标志之一将标记该 PTE 为可写),刷新 TLB 条目,接着执行do_wp_page()
函数,将共享地址中的内容复制到新的位置,该位置是进程私有的。此进程的后续写入将写入私有副本,而不是共享页面。
现在我们可以结束关于进程内存分配的部分内容,我们已经对其有了熟悉的了解。我们也学习了延迟分配机制以及什么是 CoW(写时复制)。我们也可以总结关于内核内存分配的学习内容。此时,我们可以转向 I/O 内存操作,开始与硬件设备进行交互。
使用 I/O 内存与硬件进行通信
到目前为止,我们主要处理了主内存,通常将内存视为 RAM。但需要注意,RAM 只是众多外围设备中的一种,它的内存范围对应于其大小。RAM 的独特之处在于,它完全由内核管理,对用户透明。RAM 控制器连接到 CPU 的数据/控制/地址总线,并与其他设备共享这些总线。这些设备被称为内存映射设备,因为它们在这些总线上的局部性,和与这些设备的通信(输入/输出操作)被称为内存映射 I/O。这些设备包括 CPU 提供的各种总线控制器(如 USB、UART、SPI、I2C、PCI 和 SATA),以及一些 IP,如 VPU、GPU、图像处理单元(IPU)和安全非易失性存储(SNVS,NXP 的 i.MX 芯片中的功能)。
在 32 位系统中,CPU 最多可以选择 232 个内存位置(从0
到0xFFFFFFFF
)。问题是,并非所有这些地址都指向 RAM。一些地址是为外围设备访问保留的,称为 I/O 内存。这些 I/O 内存被划分为不同大小的范围,并分配给这些外围设备,以便每当 CPU 收到来自内核的物理内存访问请求时,它可以将该请求路由到其地址范围包含指定物理地址的设备。分配给每个设备(包括 RAM 控制器)的地址范围通常在 SoC 数据手册中描述,通常会有一个叫做内存映射的部分。
由于内核仅使用虚拟地址(通过页表),访问任何设备的特定地址都需要先将该地址映射(如果存在 IOMMU,即 I/O 设备的 MMU 等效物,这一点尤其适用)。这种将 RAM 模块之外的内存地址映射会在系统地址空间中产生一个经典的空洞(因为地址空间在内存和 I/O 之间共享)。
下图描述了 I/O 内存和主内存在 CPU 中的视图:
图 10.16 – (IO)MMU 和主内存概述
注意
始终记住,CPU 通过 MMU 的视角来看主内存(RAM),而通过 IOMMU 的视角来看设备。
这样做的主要优点是,传输数据到内存和 I/O 所使用的指令相同,这减少了软件编码的逻辑。然而,也有一些缺点。第一个缺点是,必须为每个设备完全解码整个地址总线,这增加了为机器添加硬件的成本,导致架构复杂。
另一个不便之处是,在 32 位系统上,即使安装了 4 GB 的内存,操作系统也永远不会使用整个大小,因为内存映射设备所造成的地址空间空洞,导致部分地址空间被占用。x86 架构采用了另一种方法,称为 in
和 out
(通常在汇编中使用)。在这种情况下,设备寄存器不是内存映射的,系统可以访问整个 RAM 的地址范围。
PIO 设备访问
在使用 PIO 的系统中,I/O 设备被映射到一个独立的地址空间。通常通过使用不同的信号线来区分内存访问和设备访问。这类系统有两个不同的地址空间,一个是系统内存的地址空间,我们之前讨论过,另一个是 I/O 端口的地址空间,有时被称为端口地址空间,最多支持 65,536 个端口。这是一种老方法,现在已经很少见。
内核导出了一些函数(符号)来处理 I/O 端口。在访问任何端口区域之前,我们必须首先通知内核我们正在使用一个端口范围,使用 request_region()
函数,如果出错将返回 NULL
。完成该区域的使用后,我们必须调用 release_region()
。这两个函数都在 linux/ioport.h
中声明,如下所示:
struct ressource *request_region(unsigned long start,
unsigned long len, char *name);
void release_region(unsigned long start,
unsigned long len);
这些是礼貌函数,通知内核你打算使用/释放从 start
开始的 len
端口区域。name
参数应该设置为设备的名称或有意义的名称。不过,它们的使用并不是强制的。它可以防止两个或更多驱动程序引用相同的端口范围。你可以通过读取 /proc/ioports
文件的内容来查看系统上当前正在使用的端口。
在区域预留成功后,可以使用以下 API 来访问端口:
u8 inb(unsigned long addr)
u16 inw(unsigned long addr)
u32 inl(unsigned long addr)
上述函数分别从 addr
端口读取 8、16 或 32 位(宽)数据。其写入变体定义如下:
void outb(u8 b, unsigned long addr)
void outw(u16 b, unsigned long addr)
void outl(u32 b, unsigned long addr)
上述函数将 b
数据写入 addr
端口,数据可以是 8、16 或 32 位大小。
PIO 使用一组不同的指令来访问 I/O 端口或 MMIO,这是一种劣势,因为它比普通内存操作需要更多的指令来完成相同的任务。例如,MMIO 中 1 位测试只需要一条指令,而 PIO 则需要先将数据读取到寄存器中,然后再测试位,这需要超过一条指令。PIO 的一个优点是,它解码地址所需的逻辑较少,降低了添加硬件设备的成本。
MMIO 设备访问
主内存地址与 MMIO 地址位于相同的地址空间。内核将设备寄存器映射到原本应该由 RAM 使用的一部分地址空间,从而实现 I/O 设备寄存器的访问。因此,与 I/O 设备进行通信类似于对分配给该设备的内存地址进行读写操作。
如果我们需要访问例如分配给 IPU-2 的 4 MB I/O 内存(从0x02400000
到0x027fffff
),CPU(通过 IOMMU)可以分配给我们0x10000000
到0x103FFFFF
的虚拟地址。当然,这不会消耗物理 RAM(除非用于构建和存储页表项),只是地址空间(你现在明白为什么 32 位系统在扩展卡,如具有 GB 内存的高端 GPU 时会遇到问题了吗?),意味着内核将不再使用这个虚拟内存范围来映射 RAM。现在,写入/读取内存,如0x10000004
,将被路由到 IPU-2 设备。这就是内存映射 I/O 的基本前提。
与 PIO 类似,MMIO 函数用于通知内核我们打算使用某个内存区域。请记住,这些信息只是一个纯粹的预留。它们是request_mem_region()
和release_mem_region()
,定义如下:
struct ressource* request_mem_region(unsigned long start,
unsigned long len, char *name)
void release_mem_region(unsigned long start,
unsigned long len)
这些只是礼貌性函数,前者构建并返回一个合适的resource
结构,表示内存区域的起始地址和长度,后者则释放它。
然而,对于设备驱动程序,推荐使用管理版本,因为它简化了代码并处理了资源的释放。该管理版本定义如下:
struct ressource* devm_request_region(
struct device *dev, resource_size_t start,
resource_size_t n, const char *name);
在前面的代码中,dev
是拥有内存区域的设备,其他参数与非管理版本相同。成功请求后,内存区域将显示在/proc/iomem
中,这是一个包含系统内存区域使用情况的文件。
在访问一个内存区域之前(并且在成功请求后),该区域必须通过调用特定的与架构相关的函数映射到内核地址空间中(这些函数利用 IOMMU 构建页表,因此不能从中断处理程序中调用)。这些函数是ioremap()
和iounmap()
,它们也处理缓存一致性。以下是它们的定义:
void __iomem *ioremap(unsigned long phys_addr,
unsigned long size);
void iounmap(void __iomem *addr);
在前面的函数中,phys_addr
对应设备在设备树或板文件中指定的物理地址。size
对应映射区域的大小。ioremap()
返回一个指向映射区域起始位置的__iomem void
指针。再次建议使用管理版本,定义如下:
void __iomem *devm_ioremap(struct device *dev,
resource_size_t offset,
resource_size_t size);
注意
ioremap()
构建新的页表,就像vmalloc()
一样。然而,它并不实际分配任何内存,而是返回一个特殊的虚拟地址,用于访问指定的 I/O 地址。在 32 位系统中,MMIO 通过窃取物理内存地址空间为内存映射 I/O 设备创建映射,这是一个缺点,因为它会阻止系统将被窃取的内存用于一般的 RAM 用途。
由于映射 API 是架构相关的,你不应解除引用这些指针(即通过读取/写入指针值来获取/设置其值),即使在某些架构上可以这样做。内核提供了可移植的函数来访问内存映射区域。这些函数包括:
unsigned int ioread8(void __iomem *addr);
unsigned int ioread16(void __iomem *addr);
unsigned int ioread32(void __iomem *addr);
void iowrite8(u8 value, void __iomem *addr);
void iowrite16(u16 value, void __iomem *addr);
void iowrite32(u32 value, void __iomem *addr);
前述函数分别用于读取和写入 8 位、16 位和 32 位的值。
注意
__iomem
是一个内核标记,供 Sparse 使用,Sparse 是一个内核的语义检查工具,用于发现可能的编码错误。它防止将普通指针(例如解除引用)与 I/O 内存指针混用。
在本节中,我们学习了如何通过专用的 API 将内存映射的设备内存映射到内核地址空间,以访问其寄存器。这将有助于驱动片上设备。
内存(重新)映射
内核内存有时需要重新映射,要么从内核映射到用户空间,要么从高内存映射到低内存区域(从内核空间到内核空间)。常见的情况是将内核内存重新映射到用户空间,但也有其他情况,例如当我们需要访问高内存时。
理解 kmap()
的使用
Linux 内核将其地址空间的 896 MB 永久映射到物理内存的下部 896 MB(低内存)。在一个 4 GB 的系统上,内核只能映射剩余的 3.2 GB 物理内存(高内存),而剩下的只有 128 MB。不过,由于低内存具有永久且一对一的映射,内核可以直接访问它。对于高内存(896 MB 之前的内存),内核必须将请求的高内存区域映射到其地址空间,而前面提到的 128 MB 就是专门为此预留的。执行这个操作的函数是 kmap()
。kmap()
函数用于将给定页面映射到内核地址空间。
void *kmap(struct page *page);
page
是一个指向 struct page
结构的指针,用于映射。当高内存页面被分配时,它不能直接访问。我们调用 kmap()
函数将高内存临时映射到内核地址空间。该映射将持续到调用 kunmap()
为止:
void kunmap(struct page *page);
临时是指映射在不再需要时应立即撤销。最佳的编程实践是,当高内存映射不再需要时,取消映射。
这个函数适用于高内存和低内存。然而,如果页面结构位于低内存中,则仅返回页面的虚拟地址(因为低内存页面已经有了永久映射)。如果页面属于高内存,则会在内核的页表中创建永久映射,并返回地址:
void *kmap(struct page *page)
{
BUG_ON(in_interrupt());
if (!PageHighMem(page))
return page_address(page);
return kmap_high(page);
}
kmap_high()
和kunmap_high()
,它们定义在mm/highmem.c
中,是这些实现的核心。然而,kmap()
使用在启动时分配的物理连续页表将页面映射到内核空间。由于这些页表是相互连接的,因此移动起来非常简单,无需一直查询页目录。你应该注意,kmap
页表对应于以PKMAP BASE
开头的内核虚拟地址,这在不同架构中有所不同,且它的页表项引用计数保存在一个名为pkmap_count
的独立数组中。
要映射到内核空间的页面框架作为struct *page
参数传递给kmap()
,这可以是普通页或HIGHMEM
页;在第一种情况下,kmap()
直接返回直接映射的地址。对于HIGHMEM
页,kmap()
会在启动时分配的kmap
页表中查找一个未使用的条目——即pkmap_count
值为零的条目。如果没有找到,它会进入睡眠状态,等待另一个进程kunmap
一个页面。当它找到未使用的条目时,它会插入我们要映射的物理页地址,并同时递增对应页表项的pkmap_count
引用计数,然后将虚拟地址返回给调用者。页面结构体的page->virtual
也会更新,以反映映射的地址。
kunmap()
接收一个struct page*
,表示要解除映射的页面。它查找该页面虚拟地址的pkmap_count
条目,并对其进行递减操作。
将内核内存映射到用户空间
映射物理地址是最常见的操作之一,尤其是在嵌入式系统中。有时,你可能希望将部分内核内存共享给用户空间。如前所述,CPU 在用户空间运行时处于非特权模式。为了让一个进程访问内核内存区域,我们需要将该区域重新映射到进程地址空间。
使用 remap_pfn_range
remap_pfn_range()
通过 VMA 将物理连续内存映射到进程地址空间。它对于实现mmap
文件操作非常有用,mmap()
系统调用的后台就是基于这个操作。
在给定区域的起始位置和长度后,调用mmap()
系统调用时,CPU 将切换到特权模式。初始内核代码将创建一个几乎为空的 VMA,其大小与请求的映射区域相同,并运行相应的file_operations.mmap
回调,将 VMA 作为参数传递。然后,此回调应调用remap_pfn_range()
。该函数将更新 VMA,并在将其添加到进程的页表之前推导出映射区域的内核 PTE,并设置不同的保护标志。当然,进程的 VMA 列表将通过插入 VMA 条目(具有适当的属性)进行更新,使用推导出的 PTE 来访问相同的内存。通过这种方式,内核和用户空间将通过各自的页表指向相同的物理内存区域,但具有不同的保护标志。因此,内核只是复制了 PTE,每个 PTE 都有自己的属性,而不是通过复制浪费内存和 CPU 周期。
remap_pfn_range()
定义如下:
int remap_pfn_range(struct vm_area_struct *vma,
unsigned long addr,
unsigned long pfn,
unsigned long size, pgprot_t flags);
成功调用将返回0
,失败时返回负错误码。此函数的大部分参数是在调用mmap()
系统调用时提供的。以下是它们的描述:
-
vma
:这是内核在调用file_operations.mmap
时提供的虚拟内存区域。它对应于用户进程的 VMA,映射应该在其中进行。 -
addr
:这是 VMA 应开始映射的用户(虚拟)地址(大多数情况下是vma->vm_start
)。它将导致从addr
到addr + size
的映射。 -
pfn
:这表示要映射的物理内存区域的页帧号。要获得这个页帧号,我们必须考虑内存分配是如何执行的:-
对于使用
kmalloc()
或任何返回内核逻辑地址的分配 API(例如,使用GFP_KERNEL
标志的__get_free_pages()
)分配的内存,可以通过以下方式获取pfn
(获取物理地址并将此地址右移PAGE_SHIFT
次):unsigned long pfn = virt_to_phys((void *)kmalloc_area)>>PAGE_SHIFT;
-
对于使用
alloc_pages()
分配的内存,我们可以使用以下方法(其中page
是分配时返回的指针):unsigned long pfn = page_to_pfn(page)
-
最后,对于使用
vmalloc()
分配的内存,可以使用以下方法:unsigned long pfn = vmalloc_to_pfn(vmalloc_area);
-
-
size
:这是要重新映射区域的大小(以字节为单位)。如果它不是页对齐的,内核将自动将其对齐到(下一个)页边界。 -
flags
:这表示为新的虚拟内存区域(VMA)请求的保护。驱动程序可以更改最终的值,但应使用初始的默认值(位于vma->vm_page_prot
中)作为框架,通过按位“或”操作符(C 语言中的|
)来处理。这些默认值是由用户空间设置的。以下是一些标志的例子:-
VM_IO
,它指定了一个设备的内存映射 I/O。 -
VM_PFNMAP
,用于指定一个没有管理struct page
的页面范围,仅使用原始的 PFN。这通常用于 I/O 内存映射。换句话说,这意味着基础页面只是原始的 PFN 映射,而没有与之关联的struct page
。 -
VM_DONTCOPY
,告诉内核在进行进程分叉时不要复制此虚拟内存区域(VMA)。 -
VM_DONTEXPAND
,防止 VMA 在使用mremap()
时扩展。 -
VM_DONTDUMP
,防止 VMA 被包含在核心转储中,即使VM_IO
被关闭。
-
内存映射需要内存区域的大小是 PAGE_SIZE
的倍数。例如,你应该分配整页,而不是使用 kmalloc
分配的缓冲区。kmalloc()
如果请求的大小不是 PAGE_SIZE
的倍数,可能返回一个没有页对齐的指针,这时使用这样一个未对齐的地址进行 remap_pfn_range()
映射是非常糟糕的主意。没有任何保障能够确保 kmalloc()
返回的地址是页对齐的,因此你可能会破坏内核中的 slab 内部数据结构。你应该使用 kmalloc(PAGE_SIZE * npages)
,或者更好地,使用页分配 API(或类似的,因为这些函数始终返回页对齐的指针)。
如果你的存储对象(文件或设备)支持偏移量,那么应该考虑 VMA 偏移量(映射必须开始的位置偏移)来计算映射开始的物理页号(PFN)。vma->vm_pgoff
将包含此偏移量(如果用户空间在 mmap()
中指定)值,单位是页数。最终的 PFN 计算(或映射起始位置)将如下所示:
unsigned long pos
unsigned long off = vma->vm_pgoff;
/*compute the initial PFN according to the memory area */
[...]
/* Then compute the final position */
pos = pfn + off
[...]
return remap_pfn_range(vma, vma->vm_start,
pos, vma->vm_end - vma->vm_start,
vma->vm_page_prot);
在前面的摘录中,偏移量(以页数为单位)已经包含在最终位置计算中。然而,如果驱动程序实现不需要其支持,可以忽略此偏移量。
注意
偏移量也可以通过左移 PAGE_SIZE
来计算字节数偏移量(offset = vma->vm_pgoff << PAGE_SHIFT
),然后在计算最终 PFN 前,将该偏移量加到内存起始地址(pfn = virt_to_phys(kmalloc_area + offset) >> PAGE_SHIFT
)。
重新映射由 vmalloc
分配的页面
请注意,使用 vmalloc()
分配的内存不是物理连续的,因此,如果你需要映射使用 vmalloc()
分配的内存区域,必须单独映射每个页面并为每个页面计算物理地址。这可以通过遍历该 vmalloc
分配的内存区域中的所有页面,并按照以下方式调用 remap_pfn_range()
来实现:
while (length > 0) {
pfn = vmalloc_to_pfn(vmalloc_area_ptr);
if ((ret = remap_pfn_range(vma, start, pfn,
PAGE_SIZE, PAGE_SHARED)) < 0) {
return ret;
}
start += PAGE_SIZE;
vmalloc_area_ptr += PAGE_SIZE;
length -= PAGE_SIZE;
}
在前面的摘录中,length
对应于 VMA 大小(length = vma->vm_end - vma->vm_start
)。pfn
会为每个页面进行计算,下一次映射的起始地址会增加 PAGE_SIZE
,以便映射该区域中的下一个页面。start
的初始值是 start = vma->vm_start
。
也就是说,在内核内部,vmalloc
分配的内存可以正常使用。分页使用仅在重新映射时需要。
重新映射 I/O 内存
重新映射 I/O 内存需要设备的物理地址,这些地址通常在设备树或板文件中指定。在这种情况下,为了移植性,应该使用的函数是 io_remap_pfn_range()
,其参数与 remap_pfn_range()
相同。唯一不同的是 PFN 的来源。其原型如下所示:
int io_remap_page_range(struct vm_area_struct *vma,
unsigned long start,
unsigned long phys_pfn,
unsigned long size, pgprot_t flags);
在前面的函数中,vma
和 start
与 remap_pfn_range()
中的意义相同。然而,phys_pfn
在获取方式上有所不同;它必须对应物理 I/O 内存地址,因为它已经被传递给了 ioremap()
,并右移了 PAGE_SHIFT
次。
然而,对于常见的驱动程序使用,有一个简化版的 io_remap_pfn_range()
:vm_iomap_memory()
。这个简化版定义如下:
int vm_iomap_memory(struct vm_area_struct *vma,
phys_addr_t start, unsigned long len)
在前面的函数中,vma
是用户 VMA(虚拟内存区域)进行映射的位置。start
是要映射的 I/O 内存区域的起始地址(它本应已传递给 ioremap()
),len
是该区域的大小。通过 vm_iomap_memory()
,驱动程序只需要提供要映射的物理内存范围;该函数会从 vma
信息中推导出其余部分。与 io_remap_pfn_range()
一样,函数在成功时返回 0
,否则返回负错误代码。
内存重新映射和缓存问题
尽管缓存通常是一个好主意,但它可能会引入副作用,尤其是对于内存映射设备(甚至是 RAM)来说,当写入 mmap 映射的寄存器的值必须立即对设备可见时,缓存可能会导致问题。
需要注意的是,默认情况下,内核会将内存映射到用户空间,并启用缓存和缓冲区。为了更改默认行为,驱动程序必须在调用重新映射 API 之前禁用 VMA 上的缓存。为此,内核提供了 pgprot_noncached()
。除了禁用缓存外,该函数还会禁用指定区域的缓冲区能力。此助手函数接受一个初始的 VMA 访问保护,并返回禁用缓存后的更新版本。
它的使用方式如下:
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
在测试我为内存映射设备开发的驱动程序时,我遇到了一个问题:在启用缓存时,当我通过 mmap 映射的区域在用户空间更新设备寄存器,并且设备看到该更新时,延迟大约为 20 毫秒。
禁用缓存后,这个延迟几乎消失了,降到了 200 微秒以下。太神奇了!
实现 mmap 文件操作
从用户空间,通过mmap()
系统调用将物理内存映射到调用进程的地址空间。为了在驱动中支持这个系统调用,该驱动必须实现file_operations.mmap
钩子。在映射完成后,用户进程将能够通过返回的地址直接写入设备内存。内核将通过常规的指针解引用,将对映射区域的任何访问转换为文件操作。
mmap()
系统调用声明如下:
int mmap (void *addr, size_t len, int prot,
int flags, int fd, ff_t offset);
从内核端,驱动的文件操作结构(struct file_operations
结构)中的mmap
字段具有以下原型:
int (*mmap)(struct file *filp,
struct vm_area_struct *vma);
在前面的文件操作函数中,filp
是指向驱动打开的设备文件的指针,结果是通过fd
参数(在系统调用中给定)进行转换的。vma
是由内核分配并作为参数提供的。它指向用户进程的 VMA,映射应该放置在其中。要理解内核如何创建新的 VMA,它使用传递给mmap()
系统调用的参数,这些参数会以某种方式影响 VMA 的某些字段,如下所示:
-
addr
是用户空间的虚拟地址,映射应从此地址开始。它会影响vma->vm_start
。如果为NULL
(便于移植的方式),内核将自动选择一个空闲地址。 -
len
指定映射的长度,并间接影响vma->vm_end
。请记住,VMA 的大小始终是PAGE_SIZE
的倍数。这意味着PAGE_SIZE
是 VMA 可以拥有的最小大小。如果len
参数不是页大小的倍数,它将向上舍入到下一个最高的页大小倍数。 -
prot
影响 VMA 的权限,驱动可以在vma->vm_page_prot
中找到。 -
flags
决定了驱动可以在vma->vm_flags
中找到的映射类型。映射可以是私有的或共享的。 -
offset
指定映射区域内的偏移量。它由内核计算,并存储在以PAGE_SIZE
为单位的vma->vm_pgoff
中。
在定义了所有这些参数后,我们可以将mmap
文件操作实现拆分为以下步骤:
-
获取映射偏移量并检查其是否超出缓冲区大小:
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT; if (offset >= buffer_size) return -EINVAL;
-
检查映射长度是否大于我们的缓冲区大小:
unsigned long size = vma->vm_end - vma->vm_start; if (buffer_size < (size + offset)) return -EINVAL;
-
计算与
offset
在缓冲区中的位置对应的 PFN。注意,PFN 的获取方式取决于缓冲区的分配方式:unsigned long pfn; pfn = virt_to_phys(buffer + offset) >> PAGE_SHIFT;
-
设置适当的标志,必要时禁用缓存:
-
使用
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
禁用缓存。 -
如果需要,设置
VM_IO
标志:vma->vm_flags |= VM_IO;
。它还会防止 VMA 包含在进程的核心转储中。 -
防止 VMA 被交换出去:
vma->vm_flags |= VM_DONTEXPAND | VM_DONTDUMP
。在 3.7 版本之前的内核中,VM_RESERVED
会被使用。
-
-
调用
remap_pfn_range()
,传入之前计算的 PFN、size
和保护标志。在进行 I/O 内存映射时,我们将使用vm_iomap_memory()
:if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) { return -EAGAIN; } return 0;
-
最后,将函数传递给
struct file_operations
结构:static const struct file_operations my_fops = { .owner = THIS_MODULE, [...] .mmap = my_mmap, [...] };
这个文件操作的实现结束了我们关于内存映射的系列内容。在本节中,我们学习了映射在背后是如何工作的,以及所有相关机制,包括缓存考虑。
总结
本章是最重要的章节之一。它揭示了 Linux 内核中的内存管理与分配(如何分配以及在哪里分配)。它详细讲解了映射和地址转换的工作原理。其他一些方面,例如与硬件设备通信和为用户空间重新映射内存(代表mmap()
系统调用)也进行了详细讨论。
这为引入和理解下一章奠定了坚实基础,该章讨论的是直接内存访问(DMA)。