上一篇《从零写一个能用的字符设备驱动》我们写了一个能读能写的
myfifo字符设备驱动。但它只是个"纯内存设备"——数据在内核缓冲区里兜一圈,跟硬件没半毛钱关系。今天,我们把ioremap加上,操作 RK3568 的真实 GPIO 寄存器,用驱动点亮板子上的一颗 LED。从虚拟设备迈向物理世界。
在上一篇 myfifo 驱动里,我们没碰任何硬件寄存器,所以 MMU 对你来说是透明的。但一旦你要操作 GPIO 寄存器,MMU 就成了第一道门槛。
想象你是图书馆管理员。每本书都有一个 ISBN 号(比如 978-7-121-xxxxx-x),但你在书架上找书时用的是书架位置(比如"3 楼 A 区 第 5 排 第 2 格")。ISBN 号和书架位置之间需要一个映射表来对应。
MMU(Memory Management Unit,内存管理单元)干的就是这件事:CPU 核发出的地址是"虚拟地址"(VA),MMU 查页表把它翻译成"物理地址"(PA),然后才能真正访问到内存或外设寄存器。

三个核心原因:
1. 进程隔离(安全)。如果每个用户程序都能直接看到物理地址,那程序 A 就可以偷看程序 B 的内存,甚至篡改内核的数据。虚拟地址让每个进程都有自己的"地址空间",彼此隔离。
2. 内存碎片化。物理内存可能被各种外设的固定地址割裂得七零八落,但 MMU 可以把零散的物理页映射成连续的虚拟地址空间,让每个进程都以为自己独占了一大块连续内存。
3. 外设寄存器访问控制。外设寄存器(比如 GPIO 控制器)的物理地址是固定的(RK3568 上 GPIO0 就是 0xFDD60000)。内核不能直接拿这个物理地址去解引用,必须通过 MMU 建立一个"Device 类型"的映射,以保证:
writel 不能优化成一次)ioremap 做的事情,本质上就是在 MMU 的页表里新建一条映射:把外设寄存器的物理地址,映射到一个内核虚拟地址。映射完成后,你用 readl/writel 访问这个虚拟地址,MMU 自动把它翻译到对应的物理寄存器。整个过程对开发者是透明的——你不需要手动写页表,但需要理解这套机制的存在。
如果你写过 STM32 裸机程序,你对这种写法一定不陌生:
// STM32 裸机 — 直接解引用物理地址#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)GPIOA_ODR |= (1 << 5); // PA5 输出高电平,一句搞定裸机上没有 MMU(或者说 MMU 没开),CPU 发出的地址就是物理地址,直接 * 解引用没问题。
但在 Linux 内核里,这样写会直接翻车。 内核开启了 MMU,0xFDD60000 这个值在当前的页表里压根没有映射。硬去 *(volatile int *)0xFDD60000 解引用,CPU 会触发 data abort——内核 oops,驱动挂掉。
正确的做法分两步:
// 第 1 步:ioremap 建立映射("办身份证")void __iomem *DR;DR = ioremap(0xFDD60004, 4); // 把物理地址 0xFDD60004 开始 4 字节映射进来// 第 2 步:用 readl/writel 访问("刷卡")u32 val = readl(DR); // 读寄存器writel(val | (1 << 0), DR); // 写寄存器// 用完记得释放iounmap(DR);用生活化的比喻来理解:
0xFDD60000 | |
ioremap | |
readl/ | |
iounmap | 注销户口,地址回收 |
没人能直接按经纬度收快递,同样,内核不能直接拿物理地址去解引用。
readl/writel,不能直接用 *?哪怕 ioremap 返回的地址"看起来"是一个普通指针:
void __iomem *DR = ioremap(0xFDD60004, 4);u32 val = *(u32 *)DR; // 不要这样做!ARM64 上 MMIO 区域的映射属性是 Device-nGnRnE(不缓存、不合并、不重排、不对齐报错、不做推测)。普通的 C 语言 * 解引用可能被编译器优化成 ldr 指令(不带屏障),但 MMIO 访问需要 str/ldr 配合 dsb 之类内存屏障指令。readl/writel 内部会保证正确的屏障语义——ARM64 上 readl 展开后大致是 ldr + 一个 __iormb() 屏障,保证后续对这段内存的读不会被重排到 readl 之前。
记住一句话:ioremap 拿到的地址,永远用 readl/writel 访问,别手痒用 *。
写驱动之前,必须对着芯片手册查清三件事:LED 接在哪个引脚 → 引脚怎么复用 → GPIO 寄存器地址和位域是什么。
以正点原子 ATK-DLRK3568 开发板为例,LED 接在 GPIO0_C0(GPIO0 控制器的 C 口第 0 脚)。我们分两步来配置它。
RK3568 的大部分引脚都是多功能复用的——同一个物理引脚,既可以当普通 GPIO,也可以当 UART 的 TX、SPI 的 CLK 等。需要通过 IOMUX(输入输出复用)寄存器 来选择功能。
IOMUX 寄存器位于 PMU GRF(电源管理单元 - 通用寄存器文件) 中:
PMU_GRF 基地址: 0xFDC20000 ├── GPIO0C_IOMUX_L (offset 0x0010) → 0xFDC20010 引脚复用选择 └── GPIO0C_DS_0 (offset 0x0090) → 0xFDC20090 驱动能力选择GPIO0C_IOMUX_L 这个寄存器中,bit[2:0] 控制 GPIO0_C0 的功能:
000 = 普通 GPIO(我们想要的)001 ~ 111 = 各种外设功能(UART、SPI、I2C 等)把 bit[2:0] 写成 000,就选了 GPIO 模式。 这就像告诉芯片:"这根脚你别当 UART 用了,给我当普通 IO 口。"
RK3568 的 GRF 寄存器有一个特殊机制:**高 16 位是"写使能掩码"**。什么意思?
当你写一个 GRF 寄存器时,高 16 位的某一位为 1,低 16 位的对应位才能真正被写入;如果高 16 位的某一位是 0,低 16 位的对应位不会被改写,保持原值。
打个比方:你要修改一扇门上某个格子的锁,但门上有 32 把锁。你必须先把对应位置的那把小钥匙(高 16 位)插进去,才能拧动对应的锁芯(低 16 位)。
具体代码这样写:
val = readl(IMUX); // 先读出当前值val &= ~(0x7 << 0); // bit[2:0] 清零(准备写 0 → 选 GPIO)val |= ((0x7 << 16) | (0x0 << 0)); // bit[18:16]=1 开写使能, bit[2:0]=0 选 GPIOwritel(val, IMUX); // 写回寄存器(0x7 << 16) = bit[18:16] 全部置 1,意思是"bit[2:0] 这三位的值允许被写入"。如果只写低 16 位而高 16 位全是 0,bit[2:0] 的值根本写不进去——这是初学者最容易犯的错误。
在 SMP(多核)系统上,两个 CPU 核可能同时写同一个 GPIO 寄存器的不同 bit。如果按传统的 read-modify-write 方式(读 → 改 → 写),第二个 CPU 的写操作可能覆盖掉第一个 CPU 刚改的 bit。
高 16 位写使能机制优雅地解决了这个问题:每个核只把自己要改的那些 bit 对应的高 16 位置 1,其他 bit 的高 16 位写 0。这样一来,两个核同时写同一个寄存器也不会互相踩。
引脚设为 GPIO 模式之后,还需要配置方向寄存器(DDR)和数据寄存器(DR)。
RK3568 的 GPIO0 控制器寄存器:
GPIO0 基地址: 0xFDD60000 ├── SWPORT_DR_L (offset 0x0000) → 0xFDD60000 数据寄存器(A/B口,低半) ├── SWPORT_DR_H (offset 0x0004) → 0xFDD60004 数据寄存器(C/D口,高半) ├── SWPORT_DDR_L (offset 0x0008) → 0xFDD60008 方向寄存器(低半) └── SWPORT_DDR_H (offset 0x000C) → 0xFDD6000C 方向寄存器(高半)GPIO0 有 4 个口(A/B/C/D),每个口 8 个 pin,共 32 个引脚。"L"(低半)这组寄存器管 A/B 口(bit 0~15),"H"(高半)这组管 C/D 口(bit 16~31)。**GPIO0_C0 是 C 口第 0 脚,对应 H 组的 bit0:**
1 = 输出(CPU 控制引脚电平),0 = 输入(读取外部电平)1 = 高电平,0 = 低电平GPIO 控制器的写使能机制和 GRF 一样:高 16 位是写使能掩码。
① 配 IOMUX: GPIO0_C0 → GPIO 功能 (写 GRF 寄存器) "这根脚当 IO 口"② 配驱动能力: 设为 level 5 (写 GRF 寄存器) "要有足够的驱动电流"③ 设方向: 输出 (写 GPIO DDR) "我要往外输出电压"④ 设电平: 低电平 → LED 灭 (写 GPIO DR) "默认先关灯"⑤ 应用写1 → DR 置1 → LED 亮 (通过 write 系统调用触发)下面这个 led.c 完整展示了"字符设备驱动 + ioremap + 真实 GPIO 寄存器操作"的全流程。为了把注意力集中在硬件操作上,字符设备注册这里用了最简洁的 register_chrdev 方式——一个函数调用就把设备号申请和 cdev 注册全部搞定。后面我们会升级到更规范的分步注册方式。
#include<linux/types.h>#include<linux/kernel.h>#include<linux/delay.h>#include<linux/ide.h>#include<linux/init.h>#include<linux/module.h>#include<linux/errno.h>#include<linux/gpio.h>#include<asm/uaccess.h>#include<asm/io.h>#define LED_MAJOR 200 /* 主设备号 */#define LED_NAME "led"/* 设备名字 */#define LEDOFF 0 /* 关灯 */#define LEDON 1 /* 开灯 *//* ============ RK3568 寄存器物理地址(来自芯片手册) ============ */#define PMU_GRF_BASE (0xFDC20000)#define PMU_GRF_GPIO0C_IOMUX_L (PMU_GRF_BASE + 0x0010)#define PMU_GRF_GPIO0C_DS_0 (PMU_GRF_BASE + 0x0090)#define GPIO0_BASE (0xFDD60000)#define GPIO0_SWPORT_DR_H (GPIO0_BASE + 0x0004)#define GPIO0_SWPORT_DDR_H (GPIO0_BASE + 0x000C)/* 映射后的内核虚拟地址(__iomem 提醒你:这是 MMIO 指针,别用 * 解引用) */static void __iomem *PMU_GRF_GPIO0C_IOMUX_L_PI;static void __iomem *PMU_GRF_GPIO0C_DS_0_PI;static void __iomem *GPIO0_SWPORT_DR_H_PI;static void __iomem *GPIO0_SWPORT_DDR_H_PI;/* ============ LED 开关函数 ============ */voidled_switch(u8 sta){ u32 val = 0;if (sta == LEDON) { val = readl(GPIO0_SWPORT_DR_H_PI); val &= ~(0x1 << 0); /* bit0 清零 */ val |= ((0x1 << 16) | (0x1 << 0)); /* bit16=1 写使能, bit0=1 高电平亮 */ writel(val, GPIO0_SWPORT_DR_H_PI); } elseif (sta == LEDOFF) { val = readl(GPIO0_SWPORT_DR_H_PI); val &= ~(0x1 << 0); /* bit0 清零 */ val |= ((0x1 << 16) | (0x0 << 0)); /* bit16=1 写使能, bit0=0 低电平灭 */ writel(val, GPIO0_SWPORT_DR_H_PI); }}/* ============ ioremap 和 iounmap 封装 ============ */voidled_remap(void){ PMU_GRF_GPIO0C_IOMUX_L_PI = ioremap(PMU_GRF_GPIO0C_IOMUX_L, 4); PMU_GRF_GPIO0C_DS_0_PI = ioremap(PMU_GRF_GPIO0C_DS_0, 4); GPIO0_SWPORT_DR_H_PI = ioremap(GPIO0_SWPORT_DR_H, 4); GPIO0_SWPORT_DDR_H_PI = ioremap(GPIO0_SWPORT_DDR_H, 4);}voidled_unmap(void){ iounmap(PMU_GRF_GPIO0C_IOMUX_L_PI); iounmap(PMU_GRF_GPIO0C_DS_0_PI); iounmap(GPIO0_SWPORT_DR_H_PI); iounmap(GPIO0_SWPORT_DDR_H_PI);}/* ============ file_operations ============ */staticintled_open(struct inode *inode, struct file *filp){return 0;}staticssize_tled_read(struct file *filp, char __user *buf,size_t cnt, loff_t *offt){return 0; /* LED 驱动不需要读 */}staticssize_tled_write(struct file *filp, constchar __user *buf,size_t cnt, loff_t *offt){int retvalue;unsignedchar databuf[1];unsignedchar ledstat; retvalue = copy_from_user(databuf, buf, cnt);if (retvalue < 0)return -EFAULT; ledstat = databuf[0]; /* 用户传来的 0 或 1 */if (ledstat == LEDON) led_switch(LEDON); /* 写 1 → 亮灯 */elseif (ledstat == LEDOFF) led_switch(LEDOFF); /* 写 0 → 灭灯 */return 0;}staticintled_release(struct inode *inode, struct file *filp){return 0;}static structfile_operationsled_fops = { .owner = THIS_MODULE, .open = led_open, .read = led_read, .write = led_write, .release = led_release,};/* ============ 驱动入口:本文的核心——硬件初始化 ============ */staticint __init led_init(void){ u32 val = 0;int retvalue;/* ---- 1. ioremap:给 4 个寄存器"办身份证" ---- */ led_remap();/* ---- 2. IOMUX:告诉芯片"GPIO0_C0 当普通 GPIO 用" ---- */ val = readl(PMU_GRF_GPIO0C_IOMUX_L_PI); /* 读出当前配置 */ val &= ~(0x7 << 0); /* bit[2:0] 清零 */ val |= ((0x7 << 16) | (0x0 << 0)); /* bit[18:16] 写使能, bit[2:0]=000 选 GPIO */ writel(val, PMU_GRF_GPIO0C_IOMUX_L_PI);/* ---- 3. 驱动能力:设为 level 5(保证能驱动 LED) ---- */ val = readl(PMU_GRF_GPIO0C_DS_0_PI); val &= ~(0x3F << 0); val |= ((0x3F << 16) | (0x3F << 0)); writel(val, PMU_GRF_GPIO0C_DS_0_PI);/* ---- 4. 方向:设为输出 ---- */ val = readl(GPIO0_SWPORT_DDR_H_PI); val &= ~(0x1 << 0); val |= ((0x1 << 16) | (0x1 << 0)); /* bit16=1 写使能, bit0=1 输出 */ writel(val, GPIO0_SWPORT_DDR_H_PI);/* ---- 5. 默认输出低电平,LED 灭 ---- */ val = readl(GPIO0_SWPORT_DR_H_PI); val &= ~(0x1 << 0); val |= ((0x1 << 16) | (0x0 << 0)); /* bit16=1 写使能, bit0=0 低电平 */ writel(val, GPIO0_SWPORT_DR_H_PI);/* ---- 6. 注册字符设备 ---- */ retvalue = register_chrdev(LED_MAJOR, LED_NAME, &led_fops);if (retvalue < 0) { printk("register chrdev failed!\r\n");goto fail_map; } printk("led: loaded, major=%d, /dev/led ready (需手动 mknod)\n", LED_MAJOR);return 0;fail_map: led_unmap();return -EIO;}/* ============ 驱动出口:注册的反向操作 ============ */staticvoid __exit led_exit(void){ led_unmap(); /* 释放 IO 映射 */ unregister_chrdev(LED_MAJOR, LED_NAME); /* 注销字符设备 */ printk("led: unloaded\n");}module_init(led_init);module_exit(led_exit);MODULE_LICENSE("GPL");MODULE_AUTHOR("qian");MODULE_DESCRIPTION("RK3568 LED driver via ioremap");led_init 里的 6 个步骤这 6 步中,前 5 步是本文与上一篇《从零写一个能用的字符设备驱动》本质的区别——从"纯软件"迈向了"真实硬件":
ioremap | ||
GPIO0_C0 | ||
register_chrdev | /dev/led 归我管 |
第 6 步 register_chrdev 是最简写法——一个函数同时完成设备号申请和 cdev 注册。缺点是加载后 /dev/led 不会自动出现,需要手动 mknod /dev/led c 200 0。下一篇我们会升级到更规范的方式,让设备节点自动生成。
# PC 测试用(x86 走不通——x86 上没有 RK3568 的寄存器)# KERNEL_DIR := /lib/modules/$(shell uname -r)/build# ARM 交叉编译(目标平台:RK3568)KERNEL_DIR := /home/alientek/rk3568_linux_sdk/kernelARCH := arm64CROSS_COMPILE := aarch64-none-linux-gnu-obj-m := led.oall:$(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) modulesclean:$(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) clean这个驱动只能在 RK3568 开发板上跑。在 x86 PC 上编译能通过,但 insmod 时会失败——因为 PC 上没有
0xFDD60000这个物理地址对应的 GPIO 外设。
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<sys/types.h>#include<sys/stat.h>#include<fcntl.h>#define LEDOFF 0#define LEDON 1intmain(int argc, char *argv[]){int fd;unsignedchar databuf[1];if (argc != 3) {printf("Usage: %s /dev/led 0|1\n", argv[0]);printf(" 0 = off, 1 = on\n");return -1; } fd = open(argv[1], O_RDWR);if (fd < 0) {printf("open %s failed!\n", argv[1]);return -1; } databuf[0] = atoi(argv[2]); /* 命令行参数 0 或 1 */ write(fd, databuf, sizeof(databuf)); /* 写进驱动 → 控制 LED */ close(fd);return 0;}# 1. 交叉编译驱动(在 PC 上执行,需要安装 SDK)make# 2. 把 .ko 和测试程序推送到开发板adb push led.ko /root/adb push ledApp /root/# 3. 在板子上加载驱动insmod led.ko# 4. 手动创建设备节点(register_chrdev 不会自动创建)mknod /dev/led c 200 0# 5. 点亮 LED./ledApp /dev/led 1# 6. 熄灭 LED./ledApp /dev/led 0# 7. 卸载驱动rmmod led加载后通过 dmesg 查看内核日志:
led: loaded, major=200, /dev/led ready (需手动 mknod)加载驱动后需要手动执行
mknod /dev/led c 200 0来创建设备节点,这是register_chrdev这种简写方式的一个不便之处。
read实现 | copy_to_user发数据给应用 | |
write实现 | copy_from_user存到缓冲区 | led_switch() → GPIO 电平翻转 |
ioremap, | ||
alloc_chrdev_regioncdev_add(分步注册) | register_chrdev | |
class_createdevice_create 自动生成 | mknod |
本文特意用了更简洁的 register_chrdev,目的是让硬件操作的部分更突出——不被设备号分配、类创建这些"软件脚手架"分散注意力。至于分步注册 + 自动生成设备节点的升级版,下一篇就会讲到。
ioremap 拿到的地址不能直接用 * 解引用staticvoid __iomem *DR;// u32 val = *(u32 *)DR; // ← 错误!可能 oopsu32 val = readl(DR); // ← 正确哪怕 ioremap 返回的地址"看起来"是一个普通指针,也绝对不能用 * 去解引用。原因前面说了——ARM64 上 MMIO 区域的映射属性是 Device-nGnRnE,readl/writel 内部带了内存屏障,普通的 ldr/str 不满足这些约束。
iounmapioremap 会消耗内核的 vmalloc 虚拟地址空间。只 ioremap 不 iounmap,反复加载/卸载模块会慢慢泄漏虚拟地址空间。就像一个旅馆只有 100 间房,客人只入住不退房,不出几轮就满了。
这是 RK3568/RK3588 芯片手册里最容易忽略的细节。对比一下:
// 错误写法 —— 你以为写了,其实没写进去:val = readl(DR);val |= (0x1 << 0); // bit16 还是 0!写使能没开!writel(val, DR);// 结果:bit0 没被改写,LED 不亮。你对着代码查了半天,百思不得其解。// 正确写法:val = readl(DR);val |= ((0x1 << 16) | (0x1 << 0)); // bit16=1 开写使能,bit0=1 才真正生效writel(val, DR);每次都记得:低 16 位的哪一位要改,高 16 位的对应位就要写 1。 这是 Rockchip 平台的"仪式感"——忘了就白写。
ioremap 的本质 —— 在 MMU 页表里新建一条映射,给物理寄存器"办一张虚拟地址的身份证"readl / writel —— MMIO 访问的标准接口,自带内存屏障,保证访问顺序和原子性IOMUX(选 GPIO 功能),再配 GPIO 控制器(方向 + 数据)__iomem 类型 —— 编译器帮你标记"这是 MMIO 指针",提示用 readl/writel 而非 * 解引用file_operations、goto 错误回滚,和上一篇完全一样,一次学会,所有驱动通用本文的 LED 驱动虽然能亮能灭,但它有两个工程上的短板:
短板一:设备注册方式可以更规范。 本文使用的是 register_chrdev 一把梭的老式注册方式——一个函数调用就完成了设备号的申请和 cdev 的注册。这种方式简单粗暴,但会占用主设备号下的全部次设备号,而且不支持在一个驱动里管理多个设备实例。Linux 内核从 2.6 版本开始提供了更精细的注册方式:alloc_chrdev_region + cdev_init + cdev_add 分步走,需要几个次设备号就申请几个,而且可以配合 class_create + device_create 自动生成 /dev 下的设备节点,不用手动 mknod。
短板二:寄存器地址硬编码。#define PMU_GRF_BASE (0xFDC20000) 把硬件地址写死在了 C 代码里。LED 换到 GPIO3_D4 → 改代码;换一块 RK3588 板子、GPIO 基地址变了 → 改代码;客户的产品用了不同的引脚分配 → 又得改代码。驱动和硬件焊死了。
工程级的做法分两步走:
第一步——用设备树把硬件描述从代码中剥离:把"GPIO0_C0 的寄存器地址是多少、引脚怎么复用"这些硬件信息从 C 代码里搬到一个独立的描述文件(设备树 .dts)中。驱动启动时从设备树读取地址,而不是从 #define 读取。同一份驱动源码,换板子只改 .dts 文件,不改 C 代码。
第二步——用 GPIO 子系统替代手写寄存器:设备树解决了"地址从哪来"的问题,但驱动里还是要手写 readl/writel、手动处理高 16 位写使能这些芯片细节。内核的 GPIO 子系统把这些底层操作用 gpio_request()、gpio_direction_output()、gpio_set_value() 等接口封装好了——你只需要告诉它"用哪个 GPIO、输出高还是低",连 ioremap 都不用写了。
下一篇,我们就从这三个方面把驱动升级一遍:更规范的新字符设备注册方式 → 设备树 → GPIO 子系统,让驱动真正达到工程级水准。
关注「钱途无量嵌入式」,专注 Linux 驱动与 BSP 开发,每周硬核输出。