内核内存分配
| 函数 | 内存来源 | 物理地址连续? | 虚拟地址连续? | 速度 | 大小 | 用途 | 释放函数 |
|---|---|---|---|---|---|---|---|
| kmalloc | 内核 slab 池 | ✅ 连续 | ✅ 连续 | 快 | 小内存 (<几 MB) | 驱动常规缓存、结构体、日常内存 | kfree() |
| vmalloc | 零散物理页拼接 | ❌ 不连续 | ✅ 连续 | 慢 | 大内存 (几十 MB+) | 软件大块缓存,不碰硬件 | vfree() |
| ioremap | 硬件物理地址 | 硬件原生 | 映射后连续 | - | 外设寄存器 | 硬件地址映射,操作外设必备 | iounmap() |
Linux 内核空间本身是连续虚拟地址,但是:
- 内核不能用 malloc /new(那是用户空间 C/C++ 的)
- 内核自己有一套专属内存分配函数
- 你写驱动(内核模块)申请内存,就只有这三个主力:
kmalloc、vmalloc、ioremap
1. kmalloc
是什么
内核小块、物理连续内存分配函数。最常用、驱动 90% 场景优先用它。
底层特点
- 分配的虚拟地址 & 物理地址都是连续
- 从内核低地址、slab 内存池分配
- 分配速度极快
- 有大小限制:一般最大几 MB 以内(不能申请超大块)
- 虚拟地址 = 物理地址 + 内核偏移(非常好转换)
常用函数
// 申请内存 void *kmalloc(size_t size, gfp_t flags); // 释放内存(必须配对!漏释放内存泄漏) void kfree(const void *objp);最常用标志位GFP_KERNEL:内核普通分配,睡眠等待可用内存。
char *buf = kmalloc(1024, GFP_KERNEL); if(buf == NULL) { // 分配失败 } // 使用完必须释放 kfree(buf);使用场景(驱动里什么时候用)
- 驱动结构体、小缓冲区
- 设备私有数据、小缓存
- 数据长度不大(几百字节、几 KB、最大几 MB)所有普通驱动内存申请,优先 kmalloc
2. vmalloc
是什么
内核大块、虚拟地址连续、物理地址不连续内存分配。
底层特点
- 虚拟地址连续(你指针用起来正常)
- 物理内存完全不连续,靠内核页表拼接起来
- 分配速度慢(要建页表)
- 没有严格大小限制,可以申请很大内存(几十 MB、几百 MB)
- 不能用于硬件 DMA、外设地址映射
函数:
void *vmalloc(unsigned long size); void vfree(const void *addr);char *big_buf = vmalloc(1024*1024*10); // 10MB vfree(big_buf);使用场景
- 驱动里需要超大缓冲区
- 软件内部缓存、大量数据存储
- 不需要和硬件打交道的大块内存
为什么驱动不优先用?
速度慢,而且不能给硬件用,硬件只能识别物理连续内存。
3. ioremap(最重要,驱动灵魂函数)
前面kmalloc / vmalloc都是申请内核空闲内存ioremap根本不是申请内存!
是什么
把硬件的物理地址 → 映射成内核虚拟地址外设(寄存器、GPIO、内存硬件)的物理地址,内核不能直接访问,必须映射。
内核不能直接操作物理地址,所以必须:物理地址(硬件)==ioremap==>内核虚拟地址
函数:
// 物理地址 映射为虚拟地址 void __iomem *ioremap(resource_size_t phys_addr, size_t size); // 解除映射 void iounmap(void __iomem *addr);特点
- 不申请新内存,只是地址重映射
- 只用于硬件寄存器、外设物理地址
- 操作 GPIO、串口、SPI、屏幕、芯片外设全部靠它
典型驱动用法
// 某外设物理地址 0x12345678 void __iomem *vir_addr = ioremap(0x12345678, 4); // 操作寄存器 writel(0x1, vir_addr); readl(vir_addr); // 退出驱动必须解除映射 iounmap(vir_addr);GPIO 点灯驱动
一、先搞懂:GPIO 是什么
GPIO = General Purpose Input Output通用输入输出引脚就是芯片上的一个引脚:
- 驱动输出高电平→ 灯亮
- 驱动输出低电平→ 灯灭
Linux 内核不能直接操作硬件物理地址,所以流程固定:
硬件物理地址↓
ioremap映射成内核虚拟地址↓ 内核通过虚拟地址操作寄存器↓ 控制引脚高低电平 → 点灯 / 灭灯
二、GPIO 硬件底层原理(必懂)
芯片控制一个引脚,靠3 个寄存器(所有 ARM 开发板通用)
- 配置寄存器(GPIOx_CRL/CRH)设置这个引脚是输入模式 / 输出模式
- 数据置位寄存器(BSRR)写 1 → 引脚输出高电平(灯亮)
- 数据复位寄存器(BRR)写 1 → 引脚输出低电平(灯灭)
内核不能直接访问这些硬件物理地址,所以必须用ioremap做地址映射。
三、整个驱动完整流程(骨架)
- 模块加载:
__init- ioremap 映射 GPIO 相关物理地址
- 配置引脚为输出模式
- 实现字符设备驱动接口
file_operations- open:打开设备
- write:应用层传 1 点灯,传 0 灭灯
- release:关闭设备
- 模块卸载:
__exit- iounmap 解除地址映射
- 上层应用 C 程序:open /write 控制灯
四、完整完整代码(逐行详解版)
全部标准 Linux 内核驱动 C 代码,注释拉满,你每一行都能看懂。
gpio_led.c 驱动源码
#include <linux/module.h> #include <linux/fs.h> #include <linux/io.h> #include <linux/uaccess.h> // ************ 1. 开发板GPIO硬件物理地址(以STM32/ARM通用地址举例)************ // 真实芯片每个地址不同,这里用经典点灯地址举例 #define GPIO_PHYS_BASE 0x38001000 // GPIO寄存器物理基地址 #define REG_SIZE 0x20 // 寄存器空间大小 // 内核虚拟地址指针 static void __iomem *gpio_virt_base; // 设备号、设备名 #define LED_MAJOR 231 #define LED_NAME "my_led" // ************ 2. 操作函数 ************ // 打开设备 static int led_open(struct inode *inode, struct file *file) { printk("led device open\n"); return 0; } // 应用层 write 进来控制灯:buf=1亮,buf=0灭 static ssize_t led_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { unsigned char val; // 从用户空间拷贝数据到内核 copy_from_user(&val, buf, 1); if(val == 1) { // 点灯:设置引脚高电平 // 偏移地址:BSRR 置位寄存器 writel(1 << 5, gpio_virt_base + 0x18); printk("led on\n"); } else { // 灭灯:设置引脚低电平 // 偏移地址:BRR 复位寄存器 writel(1 << 5, gpio_virt_base + 0x28); printk("led off\n"); } return count; } // 关闭设备 static int led_release(struct inode *inode, struct file *file) { printk("led device close\n"); return 0; } // ************ 3. 字符设备操作结构体(驱动灵魂)************ static struct file_operations led_fops = { .owner = THIS_MODULE, .open = led_open, .write = led_write, .release = led_release, }; // ************ 4. 驱动加载函数 ************ static int __init led_init(void) { int ret; // 1. 注册字符设备 ret = register_chrdev(LED_MAJOR, LED_NAME, &led_fops); if(ret < 0) { printk("register chrdev fail\n"); return ret; } // 2. 重点:ioremap 物理地址 -> 内核虚拟地址 gpio_virt_base = ioremap(GPIO_PHYS_BASE, REG_SIZE); if(gpio_virt_base == NULL) { printk("ioremap fail\n"); unregister_chrdev(LED_MAJOR, LED_NAME); return -ENOMEM; } // 3. 配置GPIO为输出模式 // 操作配置寄存器,设置第5号引脚为输出 writel(0x00000010, gpio_virt_base + 0x00); printk("gpio led driver init ok\n"); return 0; } // ************ 5. 驱动卸载函数 ************ static void __exit led_exit(void) { // 1. 先解除地址映射(必须!顺序不能乱) iounmap(gpio_virt_base); // 2. 注销字符设备 unregister_chrdev(LED_MAJOR, LED_NAME); printk("gpio led driver exit ok\n"); } // 模块加载、卸载入口 module_init(led_init); module_exit(led_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("GPIO LED Driver"); MODULE_AUTHOR("XXX");五、配套 Makefile(驱动专用,不用 CMake)
KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean六、上层应用测试程序(user.c 用户空间程序)
你在用户态 C 程序,像操作普通文件一样控制灯
#include <stdio.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char *argv[]) { int fd; unsigned char buf; // 打开驱动设备节点 fd = open("/dev/my_led", O_WRONLY); if(fd < 0) { perror("open fail"); return -1; } // 传入 1 点灯 buf = 1; write(fd, &buf, 1); sleep(2); // 传入 0 灭灯 buf = 0; write(fd, &buf, 1); close(fd); return 0; }七、完整运行步骤(命令全套)
1. 编译驱动
make生成.ko内核模块文件
2. 加载驱动
insmod gpio_led.ko3. 创建设备节点(应用层才能 open)
mknod /dev/my_led c 231 04. 编译应用程序并运行
gcc user.c -o user ./user运行后:灯亮 2 秒 → 自动熄灭
5. 卸载驱动
rmmod gpio_led中断处理
一、先搞懂:为什么需要中断?
我们知道GPIO 轮询点灯:应用层一直read()读引脚电平,内核一直在循环查询引脚有没有变化。
缺点:
- 极度浪费 CPU
- CPU 死循环轮询,什么都干不了
- 响应慢
中断的思想
CPU 该干嘛干嘛,平时完全不管硬件。硬件引脚电平变化了 → 主动发信号打断 CPU → CPU 暂停手上工作 → 执行中断处理函数 → 处理完回到原来工作。
这就是嵌入式、驱动最核心的机制:异步事件处理按键、红外、网卡、串口、时钟全部靠中断。
二、Linux 中断核心基础概念
1. 中断号
每个硬件中断都有一个唯一编号,叫做irq 中断号内核通过这个编号识别是哪个硬件发来的中断。
2. 中断处理函数
你自己写的函数,硬件触发中断后,内核自动调用这个函数。
3. 申请中断 API(内核核心函数)
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev_id);参数逐个解释(必背):
irq:硬件中断号handler:你的中断回调函数flags:中断触发方式IRQF_TRIGGER_RISING上升沿触发(按键按下)IRQF_TRIGGER_FALLING下降沿触发(按键松开)IRQF_TRIGGER_BOTH双边沿都触发
name:中断名字,cat /proc/interrupts可以看到dev_id:设备私有数据,传参用,释放中断要用到
4. 释放中断(必须配对)
void free_irq(unsigned int irq, void *dev_id);申请了就必须释放,不释放内核资源泄漏。
三、最大难点:中断上下文 & 上下半部(必懂,面试必问)
1. 什么是中断上下文?
硬件触发中断时,CPU 暂停用户进程、暂停内核普通线程,强行跑你的中断函数。此时环境叫做中断上下文
中断上下文严格限制(超级重要,写驱动必踩坑)
- 不能睡眠(不能调用
kmalloc(..., GFP_KERNEL)、不能msleep、不能等待) - 不能调用会阻塞的函数
- 不能和用户空间做复杂数据交互
- 执行时间必须极短!越快越好
原因:CPU 被你霸占了,所有进程都卡住。
2. 为什么要有【上半部 + 下半部】
中断函数不能耗时,但是很多处理(消抖、数据处理、点灯、复杂逻辑)很慢。Linux 解决方案:上下半部拆分
上半部(硬中断)
request_irq注册的函数,只做最简单的事:标记事件、触发下半部。执行极快,立刻返回。下半部(软中断 / 任务队列 tasklet)处理耗时任务:点灯、数据处理、打印、业务逻辑。可以稍微耗时,不阻塞 CPU。
驱动里 99% 场景用tasklet 任务(最简单、最常用)
// 1. 定义tasklet DECLARE_TASKLET(my_tasklet, tasklet_func, data); // 2. 上半部中断函数里调度 tasklet_schedule(&my_tasklet);四、完整实战案例:按键中断控制 LED(经典全套驱动)
场景:按键引脚触发中断 → 内核中断响应 → 翻转 LED 灯亮灭包含:中断申请、上下半部 tasklet、GPIO 操作、字符设备框架、完整代码 + 逐行注释、Makefile、原理全部齐全。
4.1 硬件原理
- 按键:GPIO 输入引脚,电平变化产生中断
- LED:GPIO 输出引脚,高电平亮,低电平灭
4.2 完整驱动代码 irq_led.c
#include <linux/module.h> #include <linux/fs.h> #include <linux/io.h> #include <linux/interrupt.h> // 中断头文件 #include <linux/uaccess.h> // ========== 硬件物理地址(开发板通用) ========== #define LED_GPIO_PHYS 0x38001000 #define KEY_GPIO_PHYS 0x38002000 #define REG_SIZE 0x20 // 虚拟地址 static void __iomem *led_virt; static void __iomem *key_virt; // 设备号、设备名 #define MAJOR_NUM 232 #define DEV_NAME "irq_led" // LED电平状态 static int led_state = 0; // ========== 下半部:tasklet 任务 ========== // 任务处理函数:真正控制LED翻转 static void led_tasklet_func(unsigned long arg) { // 翻转LED led_state = !led_state; if(led_state) { // 点灯:置位寄存器 writel(1 << 5, led_virt + 0x18); printk(KERN_INFO "LED ON\n"); } else { // 灭灯:复位寄存器 writel(1 << 5, led_virt + 0x28); printk(KERN_INFO "LED OFF\n"); } } // 定义tasklet(内核宏) DECLARE_TASKLET(my_tasklet, led_tasklet_func, 0); // ========== 上半部:中断处理函数 ========== // 硬件触发中断,只会进这里 static irqreturn_t key_irq_handler(int irq, void *dev_id) { // 上半部只做一件事:调度下半部 tasklet_schedule(&my_tasklet); return IRQ_HANDLED; } // ========== 字符设备接口 ========== static int irq_open(struct inode *inode, struct file *file) { printk(KERN_INFO "device open\n"); return 0; } static int irq_release(struct inode *inode, struct file *file) { printk(KERN_INFO "device close\n"); return 0; } static struct file_operations fops = { .owner = THIS_MODULE, .open = irq_open, .release = irq_release, }; // ========== 驱动加载入口 ========== static int __init irq_led_init(void) { int ret; // 1. 注册字符设备 ret = register_chrdev(MAJOR_NUM, DEV_NAME, &fops); if(ret < 0) { printk(KERN_ERR "chrdev register fail\n"); return ret; } // 2. ioremap 映射LED、按键物理地址 led_virt = ioremap(LED_GPIO_PHYS, REG_SIZE); key_virt = ioremap(KEY_GPIO_PHYS, REG_SIZE); if(!led_virt || !key_virt) { printk(KERN_ERR "ioremap fail\n"); ret = -ENOMEM; goto err_map; } // 3. 配置GPIO writel(0x00000010, led_virt + 0x00); // LED为输出 writel(0x00000000, key_virt + 0x00); // 按键为输入 // 4. 申请中断!!核心 // 中断号举例 56,上升沿触发 ret = request_irq(56, key_irq_handler, IRQF_TRIGGER_RISING, "key_irq", NULL); if(ret < 0) { printk(KERN_ERR "request irq fail\n"); goto err_irq; } printk(KERN_INFO "irq led driver init ok\n"); return 0; err_irq: iounmap(key_virt); err_map: iounmap(led_virt); unregister_chrdev(MAJOR_NUM, DEV_NAME); return ret; } // ========== 驱动卸载入口 ========== static void __exit irq_led_exit(void) { // 1. 杀死tasklet任务 tasklet_kill(&my_tasklet); // 2. 释放中断 free_irq(56, NULL); // 3. 解除地址映射 iounmap(key_virt); iounmap(led_virt); // 4. 注销字符设备 unregister_chrdev(MAJOR_NUM, DEV_NAME); printk(KERN_INFO "irq led driver exit ok\n"); } module_init(irq_led_init); module_exit(irq_led_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("GPIO Key Interrupt LED Driver");五、完整流程梳理(你串起所有知识点)
- 硬件按键电平变化
- 芯片产生中断信号,发送给 CPU
- Linux 内核捕获中断号
- 调用你注册的上半部中断函数
key_irq_handler - 上半部极快执行,只调度
tasklet下半部,立刻返回 - 内核空闲时执行下半部
led_tasklet_func - 操作 GPIO 寄存器,翻转 LED 灯亮灭