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);
kmalloc 没清零,结构体里的 padding 字节带了随机值。换成 kzalloc 后,问题立刻消失。
4.1.3 vmalloc:大块头,但慢
vmalloc 分配的是虚拟地址连续、物理地址不连续的内存。它能分配很大的块(几 MB 甚至更大),但代价是性能——每次访问都要查页表,而且不能用在原子上下文中。
void *buf;
buf = vmalloc(4 * 1024 * 1024); // 分配 4MB
if (!buf)
return -ENOMEM;
// 使用...
vfree(buf);
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);
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); // 写回
*(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));
}
writel 后面加 readl 来 flush 写缓冲区。结果硬件没收到配置,调试了整整一个下午。记住:某些总线(如 PCIe)有写缓冲,读一下同一个地址就能确保写操作已经到达硬件。
4.4 知识体系总览
下面这张图把本章的核心逻辑串起来了——从内存分配到 IO 映射,再到寄存器读写,每一步都有对应的函数和注意事项。
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 映射是驱动开发的基石,你写任何一个硬件驱动都绕不开它们。多练几次,形成肌肉记忆就好了。