Linux深入理解内存管理15(基于Linux6.6)---设备区域映射介绍
一、概述
1. I/O 内存访问的基本概念
外设的 I/O 内存资源一般有以下几种类型:
- MMIO(Memory-Mapped I/O):内存映射 I/O,设备的寄存器和内存缓冲区通过内存映射的方式与 CPU 访问的内存空间相连接。CPU 通过特定的地址访问设备内存。
- PIO(Programmed I/O):程序化 I/O,CPU 通过 I/O 指令与外设进行数据交换。相比 MMIO,PIO 需要 CPU 直接参与数据传输,性能较低。
在 Linux 内核中,大部分外设通过 MMIO 进行访问,而 PIO 更多用于早期的硬件或一些简单的设备。
2. Linux 内核对外设 I/O 内存的管理
Linux 内核通过 ioremap
等接口将设备的 I/O 内存映射到内核的虚拟地址空间,提供对硬件资源的访问能力。下面介绍几个与外设 I/O 内存资源访问相关的核心概念和接口。
(1)ioremap
与 iounmap
-
ioremap
:用于将物理地址空间映射到内核的虚拟地址空间,通常用于访问设备的 I/O 内存区域。通过映射,内核可以使用标准的内存访问指令(如读写操作)与外设进行交互。
-
void __iomem *ioremap(phys_addr_t phys_addr, size_t size);
- phys_addr:外设的物理内存地址。
- size:映射的内存区域大小。
ioremap
返回一个指向映射后虚拟地址的指针,该地址可以直接访问外设的 I/O 内存。 -
iounmap
:当不再需要访问某个 I/O 内存区域时,使用iounmap
来解除映射,释放资源。
-
void iounmap(void __iomem *addr);
(2)内存读写操作
映射后,可以通过内存访问操作来读取或写入设备的 I/O 内存资源。为了保证内存访问的正确性,Linux 提供了专门的 I/O 读写操作宏,这些宏针对不同的硬件架构做了优化,确保内存访问的顺序性和一致性。
-
读取 I/O 内存:
readl()
:读取 32 位值。readw()
:读取 16 位值。readb()
:读取 8 位值。
-
写入 I/O 内存:
writel()
:写入 32 位值。writew()
:写入 16 位值。writeb()
:写入 8 位值。
这些宏会确保通过 MMIO 或其他硬件相关机制正确地读写外设的内存。
例如:
uint32_t value = readl(addr); // 读取设备 I/O 内存
writel(value, addr); // 写入设备 I/O 内存
(3)ioremap_nocache
和 ioremap_wc
有时,为了优化性能或兼容性,设备的内存映射需要特殊的缓存策略:
-
ioremap_nocache
:这个接口将 I/O 内存映射为不缓存的区域,这对于设备寄存器或需要实时更新的内存区域非常重要。
-
void __iomem *ioremap_nocache(phys_addr_t phys_addr, size_t size);
-
ioremap_wc
:这个接口将 I/O 内存映射为写合并(write combining)区域,这对于高带宽的设备(如显卡)有助于提高性能。
-
void __iomem *ioremap_wc(phys_addr_t phys_addr, size_t size);
这些映射函数通过控制缓存策略来提高访问外设 I/O 内存的性能。
3. I/O 内存访问的同步与顺序
由于 I/O 设备通常是与 CPU 并行工作的,必须小心处理内存的顺序性和一致性。在访问外设 I/O 内存时,内核会使用一些同步原语来确保内存访问的正确性,避免读写乱序或缓存一致性问题。
rmb()
(Read Memory Barrier):确保读取指令的顺序性。wmb()
(Write Memory Barrier):确保写入指令的顺序性。mb()
(Memory Barrier):同时确保读写指令的顺序性。
这些屏障确保 CPU 访问内存时的正确顺序,避免因 CPU 内存操作重排序导致的访问错误。
4. 设备树(Device Tree)与 I/O 内存
在嵌入式 Linux 系统中,外设的地址通常在设备树中定义。设备树文件包含了关于硬件设备的信息,包括设备的 I/O 地址、资源大小等。内核会从设备树中解析这些信息,并使用 ioremap
将这些设备的 I/O 内存映射到内核的虚拟地址空间。
例如,在设备树文件中定义一个设备的 I/O 地址可能如下所示:
dts
my_device@10000000 {
compatible = "vendor,device";
reg = <0x10000000 0x1000>; /* 物理地址和大小 */
};
内核解析设备树后,将使用 ioremap
将 0x10000000
处的 I/O 内存映射到虚拟地址空间,从而允许驱动程序访问该设备。
二、实现过程
外设I/O资源部在linux的内核空间中,如果想要访问外设I/O,必须先将其地址映射到内核空间中,然后才能在内核空间中访问它。Linux内核访问外设I/O内存资源的方式有两种:
1. 动态映射(ioremap
)
动态映射是一种常用的内存映射方式,主要用于在设备驱动中动态地将设备的物理 I/O 内存地址映射到内核的虚拟地址空间。这种方式通常用于大多数设备,因为设备的物理地址往往是不确定的,或者在系统运行时才知道。
-
ioremap
函数是内核提供的标准接口,它将设备的物理内存地址空间映射到内核的虚拟地址空间,并返回一个虚拟地址,通过该虚拟地址,内核代码可以对设备的寄存器或内存区域进行访问。
-
void __iomem *ioremap(phys_addr_t phys_addr, size_t size);
phys_addr
:物理地址,通常是设备 I/O 内存的起始地址。size
:映射的内存区域大小。
通过
ioremap
映射的 I/O 内存区域可以使用标准的内存读写接口(如readl()
、writel()
)进行访问。 -
示例代码:
-
void __iomem *io_addr; io_addr = ioremap(0x10000000, 0x1000); // 将物理地址 0x10000000 映射为虚拟地址 if (io_addr) { // 读取 I/O 内存 uint32_t value = readl(io_addr); // 写入 I/O 内存 writel(0x12345678, io_addr); }
-
解除映射:
当不再需要访问 I/O 内存时,应该使用
iounmap()
函数解除映射。
-
iounmap(io_addr);
2. 静态映射(map_desc
)
静态映射相对于动态映射,它是在内核启动时或系统初始化过程中进行的。静态映射方式通常用于硬件资源在系统启动时就已知的情况,特别是对于一些早期初始化的 I/O 地址,或者某些固定的、共享的设备资源。
-
map_desc
(通常指的是struct map_desc
)是一个旧的映射描述符结构,它定义了设备的 I/O 地址与内核虚拟地址之间的映射关系。在早期的 Linux 内核版本中,静态映射的 I/O 内存使用map_desc
来描述。静态映射通常是在启动过程中由引导加载程序或内核早期的初始化代码完成,尤其是在早期平台(例如 ARM 和一些嵌入式平台)中较为常见。
示例结构:
-
struct map_desc { unsigned long virt; // 虚拟地址 unsigned long phys; // 物理地址 unsigned long size; // 映射大小 unsigned long type; // 映射类型 (比如 I/O 内存类型) };
-
静态映射的使用:
静态映射通常会在内核的早期初始化阶段通过设置
map_desc
数组,或是通过平台特定的设备树(Device Tree)来完成。对于某些嵌入式系统和较老的硬件平台,设备的 I/O 内存通常会在内核启动时通过这些静态映射进行预先配置。静态映射的一个关键特性是:它们在内核启动时就已经固定,因此不需要在运行时动态分配映射。通常在系统启动过程中,会将这些映射信息注册到内核中。
-
示例:
在早期版本的 Linux 内核中,静态映射可能如下所示:
-
static struct map_desc io_desc[] = { { .virt = 0xF0000000, .phys = 0x10000000, .size = 0x1000, .type = MT_DEVICE }, // 更多映射条目 };
然后,在系统初始化时,内核会使用这些静态映射来设置虚拟地址和物理地址之间的关系。
3. 动态映射与静态映射的对比
特性 | 动态映射(ioremap ) | 静态映射(map_desc ) |
---|---|---|
映射方式 | 运行时根据需要动态映射物理地址 | 启动时或初始化时固定映射物理地址 |
映射的灵活性 | 灵活,可以根据实际需求进行映射 | 不够灵活,映射在系统启动时就已固定 |
适用场景 | 大多数设备、需要动态配置的硬件资源 | 固定的硬件资源、启动时已知的硬件资源 |
内存访问 | 映射后使用 readl() 、writel() 等访问 | 通过设备树或硬件平台特定机制访问 |
解除映射 | 映射后可随时通过 iounmap() 解除映射 | 通常无法动态解除映射,除非重启 |
性能开销 | 在运行时进行映射和解除映射,可能有开销 | 启动时一次性映射,不涉及运行时开销 |
4. 动态映射实现过程
动态映射的方式使用的比较多,而且比较简单的方式,即直接通过内核提供的ioremap函数动态创建一段外设I/O内存资源到内核虚拟地址的映射表,从而就可以在内核空间中访问了。同时内核也提供了在系统启动时通过map_desc结构体静态创建I/O资源到内核空间的线性映射表(即page_table)的方式,内核的devicemaps_init函数就是实现这个功能:
arch/arm/mm/mmu.c
/*
* Set up the device mappings. Since we clear out the page tables for all
* mappings above VMALLOC_START, except early fixmap, we might remove debug
* device mappings. This means earlycon can be used to debug this function
* Any other function or debugging method which may touch any device _will_
* crash the kernel.
*/
static void __init devicemaps_init(const struct machine_desc *mdesc)
{
struct map_desc map;
unsigned long addr;
void *vectors;
/*
* Allocate the vector page early.
*/
vectors = early_alloc(PAGE_SIZE * 2);---解析1
early_trap_init(vectors);
/*
* Clear page table except top pmd used by early fixmaps
*/
for (addr = VMALLOC_START; addr < (FIXADDR_TOP & PMD_MASK); addr += PMD_SIZE)
pmd_clear(pmd_off_k(addr));---解析2
if (__atags_pointer) {
/* create a read-only mapping of the device tree */
map.pfn = __phys_to_pfn(__atags_pointer & SECTION_MASK);
map.virtual = FDT_FIXED_BASE;
map.length = FDT_FIXED_SIZE;
map.type = MT_MEMORY_RO;
create_mapping(&map);
}
/*
* Map the kernel if it is XIP.
* It is always first in the modulearea.
*/
#ifdef CONFIG_XIP_KERNEL
map.pfn = __phys_to_pfn(CONFIG_XIP_PHYS_ADDR & SECTION_MASK);
map.virtual = MODULES_VADDR;
map.length = ((unsigned long)_exiprom - map.virtual + ~SECTION_MASK) & SECTION_MASK;
map.type = MT_ROM;
create_mapping(&map);
#endif
/*
* Map the cache flushing regions.
*/
#ifdef FLUSH_BASE
map.pfn = __phys_to_pfn(FLUSH_BASE_PHYS);---解析3
map.virtual = FLUSH_BASE;
map.length = SZ_1M;
map.type = MT_CACHECLEAN;
create_mapping(&map);
#endif
#ifdef FLUSH_BASE_MINICACHE
map.pfn = __phys_to_pfn(FLUSH_BASE_PHYS + SZ_1M);
map.virtual = FLUSH_BASE_MINICACHE;
map.length = SZ_1M;
map.type = MT_MINICLEAN;
create_mapping(&map);
#endif
/*
* Create a mapping for the machine vectors at the high-vectors
* location (0xffff0000). If we aren't using high-vectors, also
* create a mapping at the low-vectors virtual address.
*/
map.pfn = __phys_to_pfn(virt_to_phys(vectors));
map.virtual = 0xffff0000;
map.length = PAGE_SIZE;
#ifdef CONFIG_KUSER_HELPERS
map.type = MT_HIGH_VECTORS;
#else
map.type = MT_LOW_VECTORS;
#endif
create_mapping(&map);
if (!vectors_high()) {
map.virtual = 0;
map.length = PAGE_SIZE * 2;
map.type = MT_LOW_VECTORS;
create_mapping(&map);
}
/* Now create a kernel read-only mapping */
map.pfn += 1;
map.virtual = 0xffff0000 + PAGE_SIZE;
map.length = PAGE_SIZE;
map.type = MT_LOW_VECTORS;
create_mapping(&map);
/*
* Ask the machine support to map in the statically mapped devices.
*/
if (mdesc->map_io)---解析4
mdesc->map_io();
else
debug_ll_io_init();
fill_pmd_gaps();
/* Reserve fixed i/o space in VMALLOC region */
pci_reserve_io();
/*
* Finally flush the caches and tlb to ensure that we're in a
* consistent state wrt the writebuffer. This also ensures that
* any write-allocated cache lines in the vector page are written
* back. After this point, we can start to touch devices again.
*/
local_flush_tlb_all();
flush_cache_all();
/* Enable asynchronous aborts */
early_abt_enable();
}
- 分配两个page的物理页帧,然后通过early_trap_init会初始化异常向量表。
- 主要是清除vmalloc区的相应页表项,该开发板对应的区间是0xe080 0000 ~ 0xffc0 0000。
- 实际开始创建页表,为一个物理存储空间创建映射,先获取vectors的物理页地址,0xffff0000是arm默认的中断向量所在页,长度为1页, 注意,这里映射大小为4K,小于1M,将使用二级映射!,映射0xffff0000的那个page frame,地址是0xc0007ff8,如果SCTLR.V的值设定为low vectors,那么还要映射0地址开始的memory,然后映射high vecotr开始的第二个page frame,也就是对应到最开始申请的两个物理页帧。
- 如果当前机器信息的mdesc的成员变量map_io有效,就调用map_io(),该函数指针依据不同机型均有不同的设置,主要是用于当前机器的IO设备映射到内核地址空间,也就是开始描述的静态映射。如果没有提供,使用debug_ll_io_init()自己实现,那么只需要实现debug_ll_addr(&map.pfn, &map.virtual); 这个函数。把map.pfn和map.virtual填上串口物理地址和映射的虚拟地址就行。
而early_trap_init做初始化异常向量表,其代码如下:
arch/arm/kernel/traps.c
void __init early_trap_init(void *vectors_base)
{
extern char __stubs_start[], __stubs_end[];
extern char __vectors_start[], __vectors_end[];
unsigned i;
vectors_page = vectors_base;
/*
* Poison the vectors page with an undefined instruction. This
* instruction is chosen to be undefined for both ARM and Thumb
* ISAs. The Thumb version is an undefined instruction with a
* branch back to the undefined instruction.
*/
for (i = 0; i < PAGE_SIZE / sizeof(u32); i++)
((u32 *)vectors_base)[i] = 0xe7fddef1;
/*
* Copy the vectors, stubs and kuser helpers (in entry-armv.S)
* into the vector page, mapped at 0xffff0000, and ensure these
* are visible to the instruction stream.
*/
copy_from_lma(vectors_base, __vectors_start, __vectors_end);
copy_from_lma(vectors_base + 0x1000, __stubs_start, __stubs_end);
kuser_init(vectors_base);
flush_vectors(vectors_base, 0, PAGE_SIZE * 2);
}
#else /* ifndef CONFIG_CPU_V7M */
void __init early_trap_init(void *vectors_base)
{
/*
* on V7-M there is no need to copy the vector table to a dedicated
* memory area. The address is configurable and so a table in the kernel
* image can be used.
*/
}
#endif
- 将整个vector table那个page frame填充为0xe7fd def1,未定义指令。
- 这个函数把定义在 arch/arm/kernel/entry-armv.S 中的异常向量表和异常处理程序的 stub 进行重定位:异常向量表拷贝到 0xFFFF_0000,异常向量处理程序的 stub 拷贝到 0xFFFF_0200。然后调用 modify_domain()修改了异常向量表所占据的页面的访问权限,这使得用户态无法访问该页,只有核心态才可以访问。
下面就进入到本章的重点内容,设备区域映射,主要使用的是machine_desc这个结构体,而这个是在setup_arch中定义
mdesc = setup_machine_fdt(__atags_pointer);
if (!mdesc)
mdesc = setup_machine_tags(__atags_pointer, __machine_arch_type);
对于支持DTB格式,直接通过compatible = “fsl,imx6ull-14x14-evk”, "fsl,imx6ull"来寻找到对应的machine_desc,其在arch/arm/mach-imx/mach-imx6ul.c 中
DT_MACHINE_START(IMX6UL, "Freescale i.MX6 UltraLite (Device Tree)")
.map_io = imx6ul_map_io,
.init_irq = imx6ul_init_irq,
.init_machine = imx6ul_init_machine,
.init_late = imx6ul_init_late,
.dt_compat = imx6ul_dt_compat,
MACHINE_END
分析了devicemaps_init的用途,其主要有以下两个用途:
- 为中断向量分配内存,为中断向量虚拟地址映射的页表分配内存,建立虚拟地址到物理地址的映射。
- 调用mdesc->map_io()进行SOC相关的初始化,通过map_desc结构体静态创建I/O资源映射表。
三、总结
设备区域映射在 Linux 内核中是与硬件设备进行数据交换的基础,主要通过动态映射(如 ioremap()
)和静态映射(如 map_desc
)两种方式来实现。动态映射更为灵活,适用于大多数设备,而静态映射通常用于那些固定的、已知的设备。内核通过映射、内存屏障等技术,确保设备内存的访问正确性和高效性。正确理解和使用设备区域映射,是开发高效、安全的设备驱动程序的关键。
3.1、设备区域映射的实现机制
设备区域映射的实现主要依赖于Linux内存管理的几个关键机制:
- 虚拟内存系统:Linux使用虚拟内存系统将物理内存和进程地址空间分开管理。每个进程都有自己的虚拟地址空间,包含了进程可用的全部内存地址范围。设备区域映射通过在这个虚拟地址空间中创建新的映射区域来实现。
- 页表管理:Linux使用页表来实现虚拟地址到物理地址的转换。当进程访问映射区域时,CPU会根据页表找到对应的物理地址,从而实现数据的访问。页表的管理包括页表的创建、更新和删除等操作。
- 内存分配和释放:设备区域映射需要分配相应的物理内存或I/O内存资源。Linux提供了多种内存分配算法和机制来满足这种需求,如页分配器、slab分配器等。同时,当映射不再需要时,系统也会负责释放相应的资源。
3.2、设备区域映射的应用场景
设备区域映射在Linux系统中有着广泛的应用场景,包括但不限于以下几个方面:
- 外设访问:通过内存映射I/O,内核可以直接访问外设的I/O内存资源,从而实现对外设的控制和数据传输。
- 文件操作优化:通过文件映射,进程可以像访问内存一样访问文件内容,提高了文件操作的效率。这特别适用于需要频繁读写大文件的场景。
- 进程间通信:设备区域映射还可以用于进程间通信(IPC)。通过映射相同的文件或设备区域,不同的进程可以共享数据,从而实现进程间的通信和同步。
3.3、设备区域映射的注意事项
在使用设备区域映射时,需要注意以下几个方面:
- 内存对齐:为了确保访问效率和数据安全性,映射的内存区域通常需要满足特定的对齐要求。
- 访问权限:在创建映射时,需要指定相应的访问权限(如读、写、执行等)。这些权限将限制进程对映射区域的访问方式。
- 同步问题:对于文件映射,当进程对映射区域进行修改时,需要确保这些修改能够正确地同步回磁盘上的文件。这通常通过调用
msync
等函数来实现。 - 资源释放:当映射不再需要时,需要及时释放相应的资源,以避免内存泄漏和其他问题。