操作系统如何与设备进行交互

前言

简单介绍一下操作系统如何与设备进行交互的。

正文

操作系统与硬件通信的基本方式:

端口i/o:

这一个呢,是把设备标志为一个一个设备标为数字,然后向设备发送信号,
通过in和out指令访问设备寄存器。

从端口0x60读取键盘输入
in al, 0x60

这种比较简单,大体就是发送信号吧。

这里说明一下,cpu与设备之间的沟通呢,很多时候我们总是想着是比如输出什么样的命令啥的。

有各种各样的复杂命令啥的,其实本质而言,到了最后都是对寄存器的操作,所以在硬件层面呢,就不用去想什么命令,

本质而言是对寄存器的io操作。

这种似乎ok的,简单方便,可以对设备的寄存器进行读写,实现了基本功能。

但是随着要和设备的性能直接沟通的越来越频繁,这里就需要高性能了。

就出现了内存映射了。

那么下面来看一下内存映射这块:

  1. 硬件层面的实现机制

(1) 地址空间划分
统一编址:CPU的物理地址空间被划分为 普通内存区域 和 设备寄存器区域。

例如:假设CPU有32位地址空间(4GB),硬件设计时可能约定:

0x00000000-0xDFFFFFFF:DRAM(内存)

0xFE000000-0xFEFFFFFF:设备A的寄存器

0xFF000000-0xFFFFFFFF:设备B的寄存器

(2) 硬件地址解码

地址解码器(Address Decoder):
每个设备(如网卡、GPU)内置一个地址范围匹配电路。当CPU访问某个地址时:

CPU发出物理地址(如0xFE200000)。

内存控制器首先检查该地址是否属于DRAM:

如果是 → 访问内存芯片。

如果否 → 将地址转发到系统总线(如PCIe、AXI)。

设备监听总线:

设备比较总线地址与自身寄存器映射范围。

若匹配(如0xFE200000在设备A的范围内)→ 设备响应操作,否则忽略。

(3) 寄存器访问的硬件行为
写入操作:

((volatile uint32_t)0xFE200000) = 0x1234; // CPU执行内存写入指令

  1. CPU将0xFE200000解释为普通内存地址,发出写请求(数据0x1234)。

  2. 设备A的地址解码器识别该地址属于自己,将数据0x1234写入内部寄存器(如控制寄存器)。

  3. 硬件关键点:设备寄存器本质上是一个与地址绑定的触发器(Flip-Flop),写入时会触发设备行为(如启动DMA、修改工作模式)。

设备将当前寄存器值(如状态寄存器)返回给CPU,而非从内存读取数据。

uint32_t value = ((volatile uint32_t)0xFE200004); // CPU读取设备寄存器

操作系统与硬件的协作

(1) 设备发现与资源分配

PCIe设备示例:

系统启动时,BIOS/UEFI或操作系统通过PCI配置空间枚举设备,获取设备的MMIO需求(如需要256MB地址空间)。

操作系统分配一段未被占用的物理地址范围(如0xFE000000-0xFE0FFFFF)给该设备,并写入设备的BAR(Base Address Register)。

设备收到BAR值后,将其作为自身寄存器的基地址。

(2) 内存映射到内核空间

Linux内核示例:

void *regs = ioremap(0xFE000000, 0x100000); // 将物理地址映射到内核虚拟地址

ioremap会将物理地址0xFE000000映射到内核的虚拟地址空间(如0xffff0000),后续驱动通过虚拟地址访问设备寄存器。

这样的话,那么我们的驱动就可以操作设备了。

(3) 设备驱动的工作

写入寄存器:

writel(0x1234, regs + 0x10); // 向寄存器偏移0x10处写入0x1234

writel会编译为内存写入指令(如mov [eax], ebx),但实际地址指向设备寄存器而非内存。

驱动器这样写入即可。

这样就能驱动硬件了。

那么我们会问,一个硬件应该会有很多的寄存器才对,那么是如何映射到不同的驱动器的呢,或者说如何进行命令转换的呢?

  1. 设备寄存器的地址布局

设备的所有寄存器会被映射到一段连续的物理地址空间,每个寄存器占据一个或多个地址(通常为4字节对齐)。
示例:假设某设备有3个寄存器,映射到基地址0xFE200000:

寄存器功能 偏移量(Offset) 完整物理地址 访问方式
控制寄存器 0x00 0xFE200000 写入命令字
状态寄存器 0x04 0xFE200004 读取状态
数据缓冲区 0x08 0xFE200008 读写数据

CPU访问逻辑:

写入0xFE200000 → 操作控制寄存器。

读取0xFE200004 → 获取状态寄存器值。

写入0xFE200008 → 向数据缓冲区写入数据。

  1. 硬件如何识别寄存器?
    设备内部通过地址解码逻辑区分不同寄存器,具体实现如下:

(1) 设备地址解码器
设备内部有一个地址匹配电路,根据CPU访问的地址偏移量选择对应的寄存器。
示例(简化逻辑):

// Verilog示例:设备内部的地址解码逻辑
always @(posedge clk) begin
  if (cpu_addr == base_addr + 0x00) 
    control_reg <= cpu_data;  // 写入控制寄存器
  else if (cpu_addr == base_addr + 0x04)
    status_reg_read <= 1;     // 读取状态寄存器
  else if (cpu_addr == base_addr + 0x08)
    data_buffer <= cpu_data;  // 写入数据缓冲区
end

(2) 寄存器文件(Register File)

复杂设备(如GPU、网卡)可能将寄存器组织为一个寄存器文件,通过偏移量索引:

寄存器地址 = 基地址(Base Address) + 偏移量(Offset)

基地址:由操作系统分配(如PCIe的BAR)。

偏移量:由设备手册定义(如Intel网卡的0x0000为控制寄存器,0x0008为状态寄存器)。

  1. 实际案例:PCIe网卡的寄存器访问

(1) PCIe配置阶段

  1. 操作系统读取网卡的BAR(Base Address Register),发现设备需要256KB的MMIO空间。
  2. 操作系统分配一段物理地址(如0xFE000000-0xFE03FFFF)并写入BAR。

(2) 驱动访问寄存器

// 假设网卡的寄存器偏移量定义
#define NET_CTRL_REG   0x0000  // 控制寄存器
#define NET_STATUS_REG 0x0008  // 状态寄存器

void *base_addr = ioremap(0xFE000000, 0x40000); // 映射256KB
writel(0x1, base_addr + NET_CTRL_REG);          // 写入控制寄存器
uint32_t status = readl(base_addr + NET_STATUS_REG); // 读取状态寄存器

(3) 硬件行为

CPU写入0xFE000000(基地址 + 0x0000)→ 网卡识别为控制寄存器写入,启动数据包发送。

CPU读取0xFE000008(基地址 + 0x0008)→ 网卡返回当前状态(如“发送完成”)。

这里有人就会有一个疑问了,为啥要通过物理地址去获取虚拟地址呢?

为啥既然是内核代码为啥不能直接去操作物理地址呢?

实际上内核代码也是无法操作物理地址的。

内核代码和我们的用户代码一样,都需要申请内存,这点是一样的。

只是内核代码需要自己去管理自己的内存,他们公用一个调用栈,不会像我们进程杀死一样,自动被操作系统回收了。

  1. PCI/PCIe设备枚举阶段(分配物理地址)

当内核启动或热插拔设备时,会扫描PCI总线,读取设备的BAR(Base Address Register)请求,并为其分配物理地址空间。

(1) 读取BAR信息

// drivers/pci/probe.c
static void pci_read_bases(struct pci_dev *dev, unsigned int howmany, int rom)
{
for (pos = 0; pos < howmany; pos++) {
reg = PCI_BASE_ADDRESS_0 + (pos << 2);
pci_read_config_dword(dev, reg, &l);
if (l == 0xffffffff)
continue;

    // 判断是MMIO还是I/O空间
    if (l & PCI_BASE_ADDRESS_SPACE_IO) {
        /* I/O空间 */
    } else {
        /* MMIO空间 */
        mask = PCI_BASE_ADDRESS_MEM_MASK;
        res->flags |= IORESOURCE_MEM;
    }
    /* 保存BAR信息到pci_dev->resource[] */
}

}

(2) 分配物理地址空间

内核调用 pci_assign_resource() 为设备分配物理地址:

// drivers/pci/setup-res.c
int pci_assign_resource(struct pci_dev *dev, int resno)
{
struct resource *res = dev->resource + resno;
u32 size, min, align;

/* 获取BAR请求的大小和对齐 */
size = resource_size(res);
min = (res->flags & IORESOURCE_IO) ? PCIBIOS_MIN_IO : PCIBIOS_MIN_MEM;
align = pci_resource_alignment(dev, res);

/* 调用资源分配器分配物理地址 */
ret = _pci_assign_resource(dev, resno, size, align, min);
if (ret == 0) {
    /* 将分配的物理地址写入BAR */
    pci_update_resource(dev, resno);
}
return ret;

}

  1. 物理地址分配的核心逻辑

资源分配通过 __pci_assign_resource() 实现:

// drivers/pci/setup-res.c
static int __pci_assign_resource(struct pci_bus *bus, struct pci_dev *dev,
int resno, resource_size_t size,
resource_size_t align, resource_size_t min)
{
struct resource *res = dev->resource + resno;

/* 在PCI总线范围内分配物理地址 */
ret = pci_bus_alloc_resource(bus, res, size, align, min,
                            IORESOURCE_TYPE(res->flags),
                            pcibios_align_resource, dev);
return ret;

}

资源分配器(pci_bus_alloc_resource)

最终调用内核的通用资源管理接口:

// kernel/resource.c
int allocate_resource(struct resource *root, struct resource *new,
                     resource_size_t size, resource_size_t min,
                     resource_size_t max, resource_size_t align,
                     resource_size_t (*alignf)(void *, const struct resource *,
                                              resource_size_t, resource_size_t),
                     void *alignf_data)
{
    /* 遍历资源树,找到空闲的物理地址区域 */
    err = __find_resource(root, new, size, min, max, align, alignf, alignf_data);
    if (err >= 0) {
        new->start = err;
        new->end = err + size - 1;
    }
    return err;
}

这里分配物理地址是告诉cpu,这段范围作为设备内存映射,而不是去访问内存地址。

也就是说cpu知道了是内存映射后,不是去访问我们的内存条,而是丢给系统总线。

这样这块物理内存就被标记了。

标记了之后,我们的内核程序又无法直接访问物理地址。

那么需要我们再次申请一下映射,这样执行内核代码的时候就会访问到这块物理地址了。

  1. 物理地址到虚拟地址的映射(ioremap)

分配物理地址后,驱动需要通过 ioremap 将其映射到内核虚拟地址空间:

// arch/x86/mm/ioremap.c
void __iomem *ioremap(resource_size_t phys_addr, size_t size)
{
    /* 
     * 1. 检查地址是否合法
     * 2. 创建非缓存(UC)的页表项
     * 3. 返回虚拟地址
     */
    return __ioremap_caller(phys_addr, size, _PAGE_CACHE_MODE_UC, __builtin_return_address(0));
}

也就是分为两部分,一部分是申请阶段,做了标记,另外一部分做的是映射,没有映射永远访问不到这块物理地址。

下一节,写到哪,到哪吧。

posted @ 2025-06-23 15:31  敖毛毛  阅读(56)  评论(0)    收藏  举报