4、内存管理与IO访问:内核内存分配与寄存器实战

内存管理,说白了就是驱动和硬件之间的“翻译官”。你想想看,用户态程序用 malloc 拿内存,驱动可不行——内核有自己的内存分配规则,而且还得处理物理地址、虚拟地址、IO空间这些概念。这一章,我就带你把这些东西捋清楚。

4.1 内核内存分配三兄弟:kmalloc、kzalloc、vmalloc

内核里分配内存,最常用的就是这三个函数。它们各有各的脾气,用错了轻则性能下降,重则系统崩溃。我刚开始写驱动时,就因为在中断上下文用了 vmalloc,结果系统直接睡死过去……嗯,从那以后我再也不敢乱用了。

4.1.1 kmalloc:小而快,但别贪多

kmalloc 分配的是物理连续的内存,底层走的是 slab 分配器。它快,因为它直接从 slab 缓存里拿;但它也有限制——单次分配不能太大,一般不超过 128KB。

#include <linux/slab.h>

void *ptr;
ptr = kmalloc(1024, GFP_KERNEL);   // 分配 1KB,可睡眠
if (!ptr) {
    pr_err("kmalloc failed\n");
    return -ENOMEM;
}
// 使用...
kfree(ptr);
我的习惯:在进程上下文用 GFP_KERNEL,在中断上下文用 GFP_ATOMIC。千万别搞反,否则内核会报“scheduling while atomic”的错。

4.1.2 kzalloc:kmalloc 的“干净版”

kzalloc 就是 kmalloc + memset(0) 的合体。我个人非常喜欢用它——省得你忘了清零,导致一些诡异的“脏数据”bug。

struct my_device *dev;
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
    return -ENOMEM;
// dev 里的所有字段都是 0,直接赋值就行
dev->id = 0x1234;
kfree(dev);
避坑指南:我曾经在调试一个 USB 驱动时,发现设备描述符总是多出几个字节的乱码。查了两天,最后发现是用了 kmalloc 没清零,结构体里的 padding 字节带了随机值。换成 kzalloc 后,问题立刻消失。

4.1.3 vmalloc:大块头,但慢

vmalloc 分配的是虚拟地址连续、物理地址不连续的内存。它能分配很大的块(几 MB 甚至更大),但代价是性能——每次访问都要查页表,而且不能用在原子上下文中。

void *buf;
buf = vmalloc(4 * 1024 * 1024);   // 分配 4MB
if (!buf)
    return -ENOMEM;
// 使用...
vfree(buf);
注意:DMA 操作必须用 kmalloc 或 dma_alloc_coherent,不能用 vmalloc。因为 DMA 需要物理连续地址,而 vmalloc 的物理页是分散的。

4.1.4 三兄弟对比

函数 物理连续性 最大大小 性能 适用场景
kmalloc 连续 ~128KB 快 小数据、DMA、中断上下文
kzalloc 连续 ~128KB 快(自动清零) 结构体、需要初始化的缓冲区
vmalloc 不连续 几 MB 以上 慢 大块数据、模块加载、帧缓冲

4.2 IO内存映射:ioremap 的真相

硬件寄存器不在内存里,它们在 IO 空间。但 CPU 访问 IO 空间的方式有两种:一种是专门的 IO 指令(如 x86 的 in/out),另一种是把 IO 空间映射到内存地址空间,然后用普通的内存访问指令去读写。

ioremap 干的就是后面这件事——它把物理的 IO 地址映射到内核虚拟地址空间,让你能像操作内存一样操作寄存器。

#include <linux/io.h>

void __iomem *reg_base;
reg_base = ioremap(0xFE200000, 0x1000);  // 映射 4KB 的 IO 空间
if (!reg_base) {
    pr_err("ioremap failed\n");
    return -ENOMEM;
}
// 使用...
iounmap(reg_base);
我个人习惯:映射大小一般按页对齐(4KB 的倍数),而且只映射你真正需要的范围。我曾经见过有人把整个 4GB 的地址空间都映射了,结果系统内存碎片化严重。

4.3 读写寄存器实战:readl/writel 与封装

映射完成后,怎么读写寄存器?内核提供了专门的访问函数:

  • readl(addr) —— 从映射地址读 32 位值
  • writel(val, addr) —— 向映射地址写 32 位值
  • readw/readb —— 读 16 位/8 位
  • writew/writeb —— 写 16 位/8 位
// 假设 GPIO 控制寄存器的偏移是 0x04
u32 val;
val = readl(reg_base + 0x04);       // 读当前值
val |= (1 << 10);                   // 设置第 10 位
writel(val, reg_base + 0x04);       // 写回
注意:不要直接用指针解引用的方式访问 IO 内存!比如 *(volatile u32 *)reg_base。因为编译器可能会优化掉某些读写操作,而 readl/writel 保证了 volatile 语义和内存屏障。

4.3.1 封装成设备结构体

实际项目中,我习惯把寄存器基址和偏移封装到设备结构体里,这样代码更清晰:

struct gpio_chip {
    void __iomem *base;
    int irq;
    spinlock_t lock;
};

static inline void gpio_set_bit(struct gpio_chip *chip, int offset, int bit)
{
    u32 val;
    val = readl(chip->base + offset);
    val |= (1 << bit);
    writel(val, chip->base + offset);
}

static inline int gpio_get_bit(struct gpio_chip *chip, int offset, int bit)
{
    u32 val;
    val = readl(chip->base + offset);
    return !!(val & (1 << bit));
}
避坑指南:我曾经在写一个 SPI 控制器驱动时,忘了在 writel 后面加 readl 来 flush 写缓冲区。结果硬件没收到配置,调试了整整一个下午。记住:某些总线(如 PCIe)有写缓冲,读一下同一个地址就能确保写操作已经到达硬件。

4.4 知识体系总览

下面这张图把本章的核心逻辑串起来了——从内存分配到 IO 映射,再到寄存器读写,每一步都有对应的函数和注意事项。

内存管理与IO访问知识体系 内核内存分配 kmalloc:小、快、物理连续 kzalloc:自动清零版 vmalloc:大、慢、虚拟连续 IO内存映射 ioremap:物理→虚拟 iounmap:释放映射 void __iomem * 指针 寄存器读写 readl / writel readw / writew readb / writeb 实战要点总结 • 小数据用 kmalloc/kzalloc,大数据用 vmalloc • IO 空间必须用 ioremap 映射后才能访问 • 寄存器读写必须用 readl/writel 系列函数 • 写寄存器后建议读回一次,确保写操作生效 • 释放顺序:先 iounmap,再 kfree/vfree

4.5 完整实战:GPIO 寄存器操作

最后,我们用一个完整的 GPIO 驱动片段来收尾。假设硬件上有一个 GPIO 控制器,基址在 0xFE200000,偏移 0x00 是输出寄存器,偏移 0x04 是方向寄存器。

#include <linux/module.h>
#include <linux/io.h>
#include <linux/slab.h>

#define GPIO_BASE   0xFE200000
#define GPIO_SIZE   0x1000
#define GPIO_OUT    0x00
#define GPIO_DIR    0x04

struct gpio_dev {
    void __iomem *base;
};

static struct gpio_dev *gpio;

static int __init gpio_init(void)
{
    gpio = kzalloc(sizeof(*gpio), GFP_KERNEL);
    if (!gpio)
        return -ENOMEM;

    gpio->base = ioremap(GPIO_BASE, GPIO_SIZE);
    if (!gpio->base) {
        kfree(gpio);
        return -ENOMEM;
    }

    // 设置 GPIO 10 为输出
    writel(0x00000400, gpio->base + GPIO_DIR);
    // 输出高电平
    writel(0x00000400, gpio->base + GPIO_OUT);

    pr_info("GPIO initialized\n");
    return 0;
}

static void __exit gpio_exit(void)
{
    if (gpio) {
        iounmap(gpio->base);
        kfree(gpio);
    }
    pr_info("GPIO cleanup done\n");
}

module_init(gpio_init);
module_exit(gpio_exit);
MODULE_LICENSE("GPL");
我的经验:写这种底层代码时,一定要在 ioremap 后先读一次寄存器,确认硬件确实在响应。我遇到过硬件没上电、地址写错、总线时钟没开等各种情况——读回全 0xFF 或全 0x00 都是危险信号。

嗯,这一章的内容就到这里。内存分配和 IO 映射是驱动开发的基石,你写任何一个硬件驱动都绕不开它们。多练几次,形成肌肉记忆就好了。