IO 端口和IO 内存(原理篇)

原文地址:https://blog.csdn.net/weixin_42730667/article/details/114600308



CPU要想控制所链接的设备,不可避免需要通过IO(input/output)与外设打交道,CPU通过IO操纵设备上的寄存器等来实现对 设备的控制。一般厂商按照IO空间性质将IO划分为IO 端口和IO内存。

IO 端口 VS IO内存

两者差别如下:
在这里插入图片描述
两者划分按照空间是否与CPU空间独立划分:

  • IO内存:IO内存又称为Memory-Mapped I/O(MMIO),该IO空间处在CPU空间范围内,IO内存和普通的内存没什么区别,两者都是通过CPU的地址总线和控制总线发送电平信号进行访问,再通过数据总线读写数据。要想操纵该IO就得首先将该IO映射到CPU的地址中,然后就可以访问该IO,如同访问内存。大多数嵌入式设备都属于此
  • IO端口:又称为Port(PIO),该IO的空间与CPU空间相互独立,两者互相独立,相互不干扰,这种类型IO在X86中比较常见,该IO端口有独立的空间,所以CPU要想访问该端口就得通过一些专有函数或者指令。

IO端口

IO端口拥有独立空间,与CPU 处在不同得空间,CPU如果要想访问该端口就需要使用专有的指令集,比如X86提供IN 和OUT指令集。

linux内核也为访问IO端口提供了一系列函数和方法:

在内核中如果要对某些端口进行操作,就要首先获取到访问该IO权限,以防止其他程序同时操作该端口。要获取端口端口权限可以使用request_region函数,该函数定义include/linux/ioport.h文件中:

struct resource *request_region(unsigned long first, unsigned long n, const  char *name)
  • first:要获取的起始端口。如果要同时获取多个连续端口,则该参数为起始端口
  • n: 要获取端口数量
  • name:设备名字

当获取到端口之后,可以在/proc/ioports文件中查看当前系统所有已经被分配的端口。

当端口使用完毕或者驱动模块卸载时,需要将占用的端口给释放掉,以供其他程序使用,释放端口函数为release_region()函数:

void release_region(unsigned long start, unsigned long n)

从内核源码ioport.h文件中可以看到,其实上述两个函数为宏定义:

...
#define request_region(start,n,name)		__request_region(&ioport_resource, (start), (n), (name), 0)
 
...
#define release_region(start,n)	__release_region(&ioport_resource, (start), (n))

真正起作用的为__request_region和 __release_region函数,其实所有以及被分配的IO端口在ioport_resource中进行管理,查看__request_region代码:

/**
 * __request_region - create a new busy resource region
 * @parent: parent resource descriptor
 * @start: resource start address
 * @n: resource region size
 * @name: reserving caller's ID string
 * @flags: IO resource flags
 */
struct resource * __request_region(struct resource *parent,
				   resource_size_t start, resource_size_t n,
				   const char *name, int flags)
{
	DECLARE_WAITQUEUE(wait, current);
	struct resource *res = alloc_resource(GFP_KERNEL);
	struct resource *orig_parent = parent;
 
	if (!res)
		return NULL;
 
	res->name = name;
	res->start = start;
	res->end = start + n - 1;
 
	write_lock(&resource_lock);
 
	for (;;) {
		struct resource *conflict;
 
		res->flags = resource_type(parent) | resource_ext_type(parent);
		res->flags |= IORESOURCE_BUSY | flags;
		res->desc = parent->desc;
 
		conflict = __request_resource(parent, res);
		if (!conflict)
			break;
		/*
		 * mm/hmm.c reserves physical addresses which then
		 * become unavailable to other users.  Conflicts are
		 * not expected.  Warn to aid debugging if encountered.
		 */
		if (conflict->desc == IORES_DESC_DEVICE_PRIVATE_MEMORY) {
			pr_warn("Unaddressable device %s %pR conflicts with %pR",
				conflict->name, conflict, res);
		}
		if (conflict != parent) {
			if (!(conflict->flags & IORESOURCE_BUSY)) {
				parent = conflict;
				continue;
			}
		}
		if (conflict->flags & flags & IORESOURCE_MUXED) {
			add_wait_queue(&muxed_resource_wait, &wait);
			write_unlock(&resource_lock);
			set_current_state(TASK_UNINTERRUPTIBLE);
			schedule();
			remove_wait_queue(&muxed_resource_wait, &wait);
			write_lock(&resource_lock);
			continue;
		}
		/* Uhhuh, that didn't work out.. */
		free_resource(res);
		res = NULL;
		break;
	}
	write_unlock(&resource_lock);
 
	if (res && orig_parent == &iomem_resource)
		revoke_devmem(res);
 
	return res;
}

从上述代码中可以看到,将IO端口抽象成resource结构资源,里面存放着每次申请的起始端口和端口数量,如果本次申请的IO资源以及在ioport_resource存在,则说明存在资源冲突,造成申请失败。如果不存在冲突 ,则申请IO端口资源成功并将其加入到ioport_resource中。

当IO端口占用成功之后,就可以对IO端口进行读写操作,需要用到专有的读写操作函数in和out系列函数:

unsigned in[bwl](unsigned long port)
void out[bwl](value, unsigned long port)
  • b:bytes操作IO端口数据为一个字节
  • w:word 操作IO端口数据为两个字节
  • l:long操作IO端口数据为四个字节

另外还提供了参数为字符变量接口,比在外面使用C循环更加高效:

void ins[bwl](unsigned port, void *addr, unsigned long count)
void outs[bwl](unsigned port, void *addr, unsigned long count)

例如 :从端口中读取一个8 bits:

oldlcr = inb(baseio + UART_LCR)

往一个端口中写入一个8 bit数据:

outb(MOXA_MUST_ENTER_ENCHANCE, baseio + UART_LCR)

上述为内核对IO端口操作的一个基本流程。

IO内存

旧接口

IO内存又称为MMIO,该IO空间就是处于CPU的空间,原因就是占用了CPU的总线地址空间,其性质和普通的内存一样,由于访问时该IO时和访问内存一样都是物理地址,而在linux中并不会直接对物理地址进行操作,需要将其映射到虚拟地址中。由于linux属于宏内核,驱动位于内核中,一个驱动程序要想访问IO内存就必须将其映射到内核的虚拟地址空间中(linux在空间划分时会专门留出一段空间预留给IO使用),然后才能对IO进行读写操作。

早期对IO内存操作过程和IO端口类似,首先需要调用request_mem_region函数将该IO内存资源占用,防止其他程序占用该IO:

struct resource *request_mem_region(unsigned long start, unsigned long len,char *name)
  • start: 该IO的地址相当与物理地址,一般该地址是由芯片分配好,可以从使用的CPU datasheet中查到。
  • len: 申请IO数量,必须是start起始地址连续 len个字节
  • name: 设备名称或者IO端口名称,用以标记该端口被谁使用

占用完毕之后,可以使用/proc/iomem文件中查看当前系统IO内存使用情况。

当使用完毕之后,可以使用release_mem_region()函数,将占用的IO释放掉。

上述函数仅仅只是完成了对IO占用,并没有将其映射到内核的空间中,内核提供了ioremap函数,将其IO内存映射到内核的虚拟空间中:

void *ioremap(unsigned long phys_addr, unsigned long size);

ioremap返回值为映射到内核的虚拟地址,之后对该IO操作就使用映射之后的该虚拟地址进行操作。

当使用完毕之后,可以使用iounmap将映射释放掉:

void iounmap(void * addr)

为了操作一个IO内存,首先需要取得所有权然后进行映射到虚拟地址空,操作起来其实不是太方便,上述方法已经在内核中废弃掉了,只是由于历史原因还有些旧的驱动在使用,后面驱动开发人员不要再进行使用。

新接口

新的IO内存操作接口为devm_ioremap():

void __iomem *devm_ioremap(struct device *dev, resource_size_t offset,    resource_size_t size)

该接口集成了获取IO所有权和映射功能,所以使用IO内存时只需要使用这一个接口即可

释放端口可以使用:

void devm_iounmap(struct device *dev, void __iomem *addr)

虽然IO内存和普通内存地址一样,但是不建议直接使用指针对IO内存地址进行访问,使用专有的函数进行访问。

针对不相同架构上可能访问方法不一样,比如针对PCI中的端口,都为小字节序,所以可以使用下面函数进行操作:

unsigned read[bwl](void *addr);
void write[bwl](unsigned val, void *addr);

如果是一个raw,并且不需要字节转换可以使用下面接口进行操作:

unsigned __raw_read[bwl](void *addr);
void __raw_write[bwl](unsigned val, void *addr);

现今大部分的IO都为小字节序,因为这样可以避免再次的字节序转换,从而提高效率。

比如在:drivers/tty/serial/uartlite.c文件中对一个IO内存写入32bit数据:

writel(c & 0xff, port->membase + 4);

read[bwl]和write[bwl]历史缺陷

read[bwl]和write[bwl]系列函数实现位于各个芯片架构io.h文件中,不同的架构实现稍有差别,主要差异点在与其指令集的实现,include/asm-generic/io.h文件中。

以writeb为例子,其源代码为:

static inline void writeb(u8 value, volatile void __iomem *addr)
{
	__io_bw();
	__raw_writeb(value, addr);
	__io_aw();
}

可以看到最后本质上还是调用的__raw_writeb()函数:

static inline void __raw_writeb(u8 value, volatile void __iomem *addr)
{
	*(volatile u8 __force *)addr = value;
}

LDD3指出read[bwl]和write[bwl]系列函数存在一些列的不安全问题:

Other drivers, knowing that I/O memory addresses are not real pointers, store them in integer variables; that works until they encounter a system with a physical address space which doesn’t fit into 32 bits. And, in any case, readb() and friends perform no type checking, and thus fail to catch errors which could be found at compile time.

linus在邮件列表中指出,由于历史原因,在调用read/write时 很多驱动开发人员传入不是一个地址而是使用一个integer整型,那么在64位芯片时由于地址是8个字节,而不是整型4个字节,此时会出现问题

mostly just because of historical reasons, and as a result some drivers didn’t use a pointer at all, but some kind of integer.
Sometimes even one that couldn’t fit a MMIO address in it on a 64-bit machine.

在2.6.9 kernel版本中对IO 内存提供了一系列新的API对IO进行操作

unsigned int ioread8(void *addr);
unsigned int ioread16(void *addr);
unsigned int ioread32(void *addr);
void iowrite8(u8 value, void *addr);
void iowrite16(u16 value, void *addr);
void iowrite32(u32 value, void *addr);
 
//“string”字符串版本
void ioread8_rep(void __iomem *port, void *buf, 
                            unsigned long count)

再次特别指出笔者在最新的5.11.5内核版本中发现read和write函数最后的调用实际上和ioread和iowrite一样,笔者猜测可能时后面防止出现问题,在后面的版本中也将read和write函数缺陷给修改掉了,但是并没有在邮件列表中找到。

为了论证笔者的猜测,笔者翻到了当初2.6.9版本中的解决方案说明:

The 2.6.9 kernel will contain a series of changes designed to improve how the kernel works with I/O memory. The first of these is a new __iomem annotation used to mark pointers to I/O memory. These annotations work much like the __user markers, except that they reference a different address space. As with __user, the __iomem marker serves a documentation role in the kernel code; it is ignored by the compiler. When checking the code with sparse, however, developers will see a whole new set of warnings caused by code which mixes normal pointers with __iomem pointers, or which dereferences those pointers.

在2.6.9的解决方案中,通过增加对IO memory 指针增加了一个 __iomem修饰,在编译时对地址进行检查。 __iomem 是一个cookie,当编译内核是使用C=1,编译器可以通过sparse来检查使用该修饰符标记的地址是否合法,该字符定义为:

# define __iomem        __attribute__((noderef, address_space(2)))

目的就是在访问IO memory时需要进行严格检查,避免程序出错,如果在使用IO 内存地址时不加该参数,在编译解决会直接告警。

其中address_space指明该地址位于那个地址空间,共划分为四个地址空间:

  • v: 0 内核空间
  • v: 1 用户空间
  • v: 2 io存储空间
  • v: 3 cpu空间

在5.11.5版本中发现read[bwl]和write[bwl] 函数也对地址进行了__iomem修饰,该系列函数存在的类型安全问题应该已经不存在。LLD3中代码版本较老,很长时间都没有进行更新,在学习时还是需要做下代码对比。

针对__iomem解决的整个思路,linus在https://lwn.net/Articles/102240/邮件中说的非常明白,有兴趣的同学可以了解一下,

Big-endian大字节序接口

针对有些端口为大字节序的问题,linux还提供了一些列的专有函数:

unsigned int ioread16be(const void __iomem *)
unsigned int ioread32be(const void __iomem *)
u64 ioread64be(const void __iomem *)
void iowrite16be(u16, void __iomem *)
void iowrite32be(u32, void __iomem *)
void iowrite64be(u64, void __iomem *)

rep接口

如果要想读或者写一系列的值到一个给定的IO memory内存地址,可以使用一下接口重复版本,相对与在使用循环调用效率要高:

void ioread8_rep(void *addr, void *buf, unsigned long count);
void ioread16_rep(void *addr, void *buf, unsigned long count);
void ioread32_rep(void *addr, void *buf, unsigned long count);
void iowrite8_rep(void *addr, const void *buf, unsigned long count);
void iowrite16_rep(void *addr, const void *buf, unsigned long count);
void iowrite32_rep(void *addr, const void *buf, unsigned long count);

这些函数读或写 count 值从给定的 buf 到 给定的 addr. 注意 count 表达为在被写入的数据大小; ioread32_rep 读取从 buf 开始的count个32位值。

MMIO side effects

在操作MMIO时,需要特别注意IO的side effects(很多地方都译为边际效应,其实字面意思不是很容易理解,其实质为副作用)。MMIO边际效率,其来源就是因为CPU缓存原因。现在CPU架构或者说是计算机的架构都是采用哈弗结构形式,即计算和存储进行分离,CPU只参与数据的计算而无法对数据或者指令进行保存,要想取得数据或者指令必须从存储中读取,而由于半导体摩尔定律的发展,CPU的运算速度每间隔2年都会有很大提升,但是对存储或者说内存的访问速率并没有太大提升,这就明显限制了整个CPU性能。为了解决IO和CPU性能之前巨大的性能差异,CPU厂商一般都会在内部集成缓存以临时存储数据,减少对外部内存的读写操作。在对普通的内存操作中,很多其实并不是从内存中读写,而是从缓存中读取(现代CPU一般都会设计多级缓存以增加性能)。而在操作IO时,如果操作IO的数据也被存储到缓存中,那么如果一旦硬件数据发生变化,此时存储在缓存中的IO数据并没有感知,进而造成程序中正在读取的IO寄存器数据和实际硬件中的IO寄存器的值并不相同,造成IO 寄存器的边际效应。

下面可以使用一个简单的代码来说明该问题

if (x) y = *ptr

有一个指针为ptr,当X不为零时,会将ptr地址的值复制给y。在编译器编译之后,和下面代码是相同的代码:

tmp = *ptr; if (x) y = tmp

首先将ptr地址的值赋值给tmp,然后当x不等于0时,将tmp值赋值为y。

上述两段代码针对普通的内存操作是没有问题的,而当ptr指向IO mem时将会出现很大问题,因为此时io端口寄存器的值被存储到tmp中,而tmp值为一个缓存变量,如果在之后io端口寄存器的值发生变化,tmp并无法感知,此时就会造成y的值最终和该IO寄存器的值不一样。

为了解决该问题,一般在定义寄存器地址时,一般使用volatile关键字修饰指向IO地址的指针,就是告诉编译器每次都从源中存取,不从缓存中存取,防止编译器进行优化从缓存中存取。

除了上述问题之外,编译器在对代码编译时会出现过度优化,通过解析前后代码依赖顺序,将串行代码编译乱序执行,以提高效率。CPU的乱序执行也对IO mem造成很大副作用,在一个串行执行的代码中本来设计的是后读IO寄存器的值,有可能被优化成先执行读取IO寄存器的值,这样就有机会造成IO寄存器的值和实际读取出来存到变量中的值不一样,为了解决上述问题目前主要有两者手段:

  • 对硬件IO操作关闭缓存
  • 强制在代码中加入同步机制,防止乱序执行造成问题。

linux 中提供了4个宏来来解决排序问题:

#include <linux/kernel.h>
void barrier(void)// 这个函数告知编译器插入一个内存屏障但是对硬件没有影响. 
				  // 编译的代码将所有的当前改变的并且驻留在 CPU 寄存器的值存储到内存, 并且在需要时重新读取它们
				  // 对屏障的调用阻止编译器跨越屏障的优化, 而留给硬件自由做它的重编排.
#include <asm/system.h>
void rmb(void);
void read_barrier_depends(void);
void wmb(void);
void mb(void);
// 这些函数在编译的指令流中插入硬件内存屏障; 
// 它们的实际实例是平台相关的
//	1. rmb(read memory barrier)保证任何出现于屏障前的读在所有后续读之前完成. 
//	2. wmb 保证写操作中的顺序, 
//	3. mb 指令保证读和写的顺序. 
//  4. 每个这些指令是一个屏障的超集

现在会过头看下前面的 writeb函数,里面其实也是插入了硬件同步__io_bw和__io_aw:

static inline void writeb(u8 value, volatile void __iomem *addr)
{
	__io_bw();
	__raw_writeb(value, addr);
	__io_aw();
}

这也就是为什么必须使用专有函数访问IO mem的最大原因。

参考资料

https://stackoverflow.com/questions/19100536/what-is-the-use-of-iomem-in-linux-while-writing-device-drivers

https://stackoverflow.com/questions/59113831/what-is-the-benefit-of-calling-ioread-functions-when-using-memory-mapped-io

https://lwn.net/Articles/102232/

https://lwn.net/Articles/102240/

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值