字符设备把 open/read/write/ioctl/poll 等文件操作连接到驱动。/dev 中的节点保存设备号;内核再根据设备号找到已注册的 cdev 和 file_operations。节点存在、设备号已分配、回调已注册、硬件已就绪,是四件不同的事。
本文示例采用 Linux 6.8 系列接口,提供一个只保存 0/1 状态的完整字符设备。它不访问实际 GPIO;原笔记中的 F1C 系列寄存器信息保留在后文,真实 LED 的接入见 设备树平台驱动。
1. 设备号、节点与回调
跳转到“1. 设备号、节点与回调”
| 对象或接口 | 职责与边界 |
|---|---|
dev_t | 编码主设备号和次设备号;用 MAJOR、MINOR、MKDEV 读写,不能把它们当作芯片的硬件编号 |
alloc_chrdev_region(&number, first_minor, count, name) | 动态分配连续设备号,成功返回 0;number 是输出参数 |
register_chrdev_region(number, count, name) | 申请已明确保留的设备号;第一个参数是值,不是 &number |
struct cdev | 关联设备号范围与文件操作;count 是注册的次设备号数量,不是打开文件的次数 |
cdev_init(&cdev, &fops) / cdev_add(&cdev, number, count) | 初始化已有对象,然后公开回调;cdev_add 成功后,用户可能立即进入驱动 |
class_create / device_create | 建立设备模型与 sysfs 信息,并触发设备节点管理;不替代 cdev_add |
/dev/name | 文件系统中的特殊节点,记录类型、设备号、权限;devtmpfs/udev 通常负责创建和命名 |
file_operations.owner = THIS_MODULE | 正常打开文件期间保持模块引用;不自动保护可拔除硬件的生命周期 |
cdev 的 kobject 承担引用计数等对象管理,ops 指向操作表,owner 指向所属模块。结构体可能带 __randomize_layout,这是布局随机化机制,不能据此推导“可以防止所有栈溢出”。
register_chrdev(major, name, &fops) 是较旧的便捷接口:Linux 6.8 将其展开为从次设备号 0 开始注册 256 个号码,即 0~255。它不是只注册 255 个;新示例用显式设备号范围更容易表达一个设备的需求。内核声明与实现

图中的字符/块设备节点用于说明文件接口;网络设备一般通过网络协议栈和 socket 使用,不能一概画成 /dev 节点。
2. 读写协议先于代码
跳转到“2. 读写协议先于代码”这个示例的用户接口约定如下:
| 操作 | 约定 |
|---|---|
| 写入 | 精确接受 ASCII 0、1、0\n、1\n;不是本机字节序的 int 或 size_t |
| 零长度写入 | 返回 0,不改变状态 |
| 非法命令 | 返回 -EINVAL;用户地址复制失败返回 -EFAULT |
| 写入成功 | 返回实际消费的 count,不是 0 |
| 读取 | 返回 0\n 或 1\n;支持短读,读完后返回 0 表示 EOF |
| 再次读取最新值 | 重新打开文件;示例禁止 seek,没有把每次读取都伪装成无穷数据流 |
read、write 回调返回 ssize_t。copy_to_user 和 copy_from_user 返回的是尚未复制的字节数:0 才表示完整成功;用 ret < 0 判断会漏掉正常的复制错误。不能忽略用户传入的 count,也不能在部分复制后继续使用未初始化的数据。
simple_read_from_buffer 负责本例的读取长度、位置推进与 EOF。用户缓冲区出现错误时也可能短读;调用端始终按返回的字节数处理。二进制 ABI 若确有必要,应另行定义固定宽度、字节序和布局,避免 32 位用户程序与 64 位内核对 size_t 的理解不同。
3. 完整软件字符设备
跳转到“3. 完整软件字符设备”保存为 vlog_char.c。所有共享状态在公开入口前初始化;最后一步才调用 cdev_add。由于设备模型先建立,初始化期间极早打开节点可能失败,调用者不能把这个瞬间当成硬件永久损坏。
// SPDX-License-Identifier: GPL-2.0-only#include <linux/cdev.h>#include <linux/device.h>#include <linux/fs.h>#include <linux/module.h>#include <linux/mutex.h>#include <linux/uaccess.h>
struct vlog_state { struct mutex lock; bool enabled;};
static struct vlog_state state;static struct cdev *vlog_cdev;static dev_t number;static struct class *vlog_class;
/* Exact byte protocol: "0", "1", "0\n" or "1\n". */static int parse_switch(const char *text, size_t count, bool *value){ if (count != 1 && count != 2) return -EINVAL; if (count == 2 && text[1] != '\n') return -EINVAL; if (text[0] != '0' && text[0] != '1') return -EINVAL; *value = text[0] == '1'; return 0;}
static int vlog_open(struct inode *inode, struct file *file){ file->private_data = &state; /* This module owns one software instance. */ return nonseekable_open(inode, file);}
static ssize_t vlog_read(struct file *file, char __user *buf, size_t count, loff_t *pos){ struct vlog_state *s = file->private_data; char text[2];
mutex_lock(&s->lock); text[0] = s->enabled ? '1' : '0'; mutex_unlock(&s->lock); text[1] = '\n'; return simple_read_from_buffer(buf, count, pos, text, sizeof(text));}
static ssize_t vlog_write(struct file *file, const char __user *buf, size_t count, loff_t *pos){ struct vlog_state *s = file->private_data; char text[2]; bool value; int ret;
if (!count) return 0; if (count > sizeof(text)) return -EINVAL; if (copy_from_user(text, buf, count)) return -EFAULT; ret = parse_switch(text, count, &value); if (ret) return ret; mutex_lock(&s->lock); s->enabled = value; mutex_unlock(&s->lock); return count;}
static const struct file_operations vlog_fops = { .owner = THIS_MODULE, .open = vlog_open, .read = vlog_read, .write = vlog_write, .llseek = no_llseek,};
static int __init vlog_init(void){ struct device *dev; int ret;
mutex_init(&state.lock); ret = alloc_chrdev_region(&number, 0, 1, "vlog_char"); if (ret) return ret; vlog_class = class_create("vlog_char"); /* Linux 6.8: one argument. */ if (IS_ERR(vlog_class)) { ret = PTR_ERR(vlog_class); goto release_number; } dev = device_create(vlog_class, NULL, number, NULL, "vlog_char"); if (IS_ERR(dev)) { ret = PTR_ERR(dev); goto release_class; } vlog_cdev = cdev_alloc(); if (!vlog_cdev) { ret = -ENOMEM; goto release_device; } vlog_cdev->ops = &vlog_fops; vlog_cdev->owner = THIS_MODULE; /* Publish callbacks last; an earlier open of the node can fail. */ ret = cdev_add(vlog_cdev, number, 1); if (!ret) return 0; kobject_put(&vlog_cdev->kobj);release_device: device_destroy(vlog_class, number);release_class: class_destroy(vlog_class);release_number: unregister_chrdev_region(number, 1); return ret;}
static void __exit vlog_exit(void){ cdev_del(vlog_cdev); device_destroy(vlog_class, number); class_destroy(vlog_class); unregister_chrdev_region(number, 1);}
module_init(vlog_init);module_exit(vlog_exit);MODULE_LICENSE("GPL");MODULE_DESCRIPTION("Software-only character device with a one-bit text ABI");软件状态 state 与模块一起存在,.open 直接绑定这个单实例;本例没有 platform 热解绑路径。cdev 则由 cdev_alloc 独立分配,最终清理和释放由内核负责,即使 CONFIG_DEBUG_KOBJECT_RELEASE 延迟清理,也不依赖已卸载模块的静态存储。不能把它改回模块静态对象内嵌的 cdev,再假定 cdev_del 返回就已完成清理。正常打开文件的模块引用另外保护读写回调与 state,不要使用强制卸载绕过这一条件。
cdev_del 只阻止后续通过该注册入口打开;已经打开的文件仍可能调用原来的回调。若把示例改成可拔除设备,必须在每次操作中检查离线状态,并用适当引用保留每个打开文件所需的对象;devm_kzalloc 的释放时点是设备解绑,不是最后一个文件关闭。cdev_del 的生命周期说明

原图把部分硬件初始化排在 cdev_add 之后,应改为“先准备资源和锁,再公开回调”。文件关闭回调名是 .release,不是 .close;并非每个驱动都必须自定义它。
动态 cdev 的所有权
跳转到“动态 cdev 的所有权”cdev_alloc() 会分配对象并初始化用于动态释放的 kobject。采用这条路径时,直接设置 ops、owner 后添加,不要再调用 cdev_init 把它重新初始化。分配失败先撤销已创建的节点;添加失败时通过 kobject_put(&cdev->kobj) 放弃引用;成功添加后通过 cdev_del 撤销注册。不要再裸 kfree 已交给引用计数管理的 cdev。
若 cdev 嵌入另一个动态对象,必须同时设计外层对象与打开文件的生命周期。cdev_device_add 可协助组合 cdev 与 device,但并不代替驱动对业务状态和硬件离线的管理。
4. 构建与用户程序
跳转到“4. 构建与用户程序”只编译本例时,同目录 Makefile 可写为:
obj-m += vlog_char.o使用目标内核已配置、已生成必要头文件与符号版本的构建目录:
make -C "$KDIR" M="$PWD" modules本机内核常以 /lib/modules/$(uname -r)/build 为构建目录;交叉编译时应显式设置目标内核目录、ARCH 与相应工具链,不能把主机头文件编出的模块装到另一块板上。开启符号版本时还需要与目标匹配的 Module.symvers;仅执行 modules_prepare 不会补齐一次完整内核构建的全部产物。外部模块构建文档
class_create 在 Linux 6.4 起改为单参数;旧代码常见的 class_create(THIS_MODULE, "name") 不能直接用于本例所选的 6.8 头文件。内核配置 CONFIG_LEDS_CLASS 服务于 LED 子系统,它不是所有 sysfs 或字符设备代码的通用开关。
下面的用户程序同时接受 on/off 和 1/0,转换为本例固定的一个 ASCII 字节。保存为 vlog_set.c,用目标用户空间对应的编译器构建;文件名带不带 .exe 不能决定它适用于哪个系统。
#define _POSIX_C_SOURCE 200809L#include <errno.h>#include <fcntl.h>#include <stdio.h>#include <string.h>#include <unistd.h>
int main(int argc, char **argv){ char value; ssize_t written; int fd;
if (argc != 3) { fprintf(stderr, "Usage: %s DEVICE 0|1|off|on\n", argv[0]); return 2; } if (!strcmp(argv[2], "1") || !strcmp(argv[2], "on")) value = '1'; else if (!strcmp(argv[2], "0") || !strcmp(argv[2], "off")) value = '0'; else { fprintf(stderr, "Invalid state\n"); return 2; } fd = open(argv[1], O_WRONLY | O_CLOEXEC); if (fd < 0) { perror("open"); return 1; } do { written = write(fd, &value, 1); } while (written < 0 && errno == EINTR); if (written != 1) { if (written >= 0) errno = EIO; perror("write"); (void)close(fd); return 1; } if (close(fd) < 0) { perror("close"); return 1; } return 0;}在已安装此模块的实验系统上,典型使用形式为 ./vlog_set /dev/vlog_char on 和 cat /dev/vlog_char。节点权限由系统设备管理规则决定;需要手工 mknod 时,先读取这次实际分配的主、次设备号,不能照抄某个保留主设备号。
接入内核源码树的 Kconfig 与 Makefile
跳转到“接入内核源码树的 Kconfig 与 Makefile”驱动既可内建,也可构建为模块。若把同一个 vlog_char.c 放入内核源码树中已纳入构建的目录,可在该目录的 Kconfig 添加:
config VLOG_CHAR tristate "VitaLogos software character device" help Build a software-only character device with a one-bit text ABI.并在该目录的 Makefile 添加:
obj-$(CONFIG_VLOG_CHAR) += vlog_char.oconfig 后写符号 VLOG_CHAR;生成的配置和 Makefile 引用才带 CONFIG_ 前缀。tristate 允许 y/m/n:y 编入内核,m 构建模块,n 不构建;选择 m 还要求内核支持模块。若新建目录,父级 Kconfig 还须通过 source 纳入它,父级 Makefile 也须进入该目录。仅复制两个片段不会自动完成这些接入工作。Kconfig 语法;Kbuild 对象选择
对象 vlog_char.o 对应实际文件 vlog_char.c;不能混用 myled.c 与不存在的 leds-myled.o。模块也并不意味着只能手工 insmod,正确的模块别名、依赖索引和设备事件可支持自动加载。本例不是 LED class 驱动,不应为它无条件添加 depends on LEDS_CLASS。
5. 原 GPIOE 实例需要补齐的硬件条件
跳转到“5. 原 GPIOE 实例需要补齐的硬件条件”原笔记使用 0x01c20800 作为 PIO 基址。作为寄存器布局核对资料,Allwinner F1C200s User Manual Rev.1.2 第 3.7 节给出以下 Port E 偏移;它不是本页软件模块的 MMIO 清单,实际 F1C100S 板还需对应芯片资料和 BSP 核实。
| 寄存器 | 相对 PIO 偏移 | 历史示例绝对地址 | 与 PE3 / PE12 的关系 |
|---|---|---|---|
PE_CFG0 | 0x90 | 0x01c20890 | PE3 功能位为 [14:12] |
PE_CFG1 | 0x94 | 0x01c20894 | PE12 功能位为 [18:16] |
PE_DAT | 0xa0 | 0x01c208a0 | 数据位分别为 3、12 |
PE_PULL0 | 0xac | 0x01c208ac | 每脚 2 位,PE3 为 [7:6],PE12 为 [25:24] |
通用端口与外部中断的两套偏移
跳转到“通用端口与外部中断的两套偏移”同一本手册第 3.7.2 节给出普通端口寄存器地址:B + n × 0x24 + offset。B 是 PIO 基址,n 按 A=0、B=1、C=2、D=3、E=4、F=5 编号;寄存器组内 CFG0/1/2/3 偏移为 0x00/0x04/0x08/0x0c,DAT 为 0x10,驱动能力为 0x14/0x18,上下拉为 0x1c/0x20。布局有槽位不代表每一引脚或每个保留字段都能使用。以虚构基址 B=0x1000、端口 D 的 n=3、offset=0x04 演算,结果是 0x1070;这不是板上地址。
外部中断寄存器另用 B + 0x200 + n × 0x20 + offset,这里的组编号是 D=0、E=1、F=2,不能继续用 A=0 的普通端口编号。CFG0/1/2/3 的组内偏移仍为 0x00/0x04/0x08/0x0c,控制、状态、去抖分别为 0x10/0x14/0x18。同样取虚构 B=0x1000,E 组 n=1 的 CFG1 位于 0x1224;真实 PIO 基址下对应 0x01c20a24。中断组的 D/E/F 对应关系可对照手册第 3.7.3.54、3.7.3.61、3.7.3.68 节,不能把公式理解为每个端口都提供中断组。
手册中功能 001 表示 GPIO 输出;上下拉编码 00 为禁用、01 为上拉、10 为下拉。原代码清除 PE3 的两位后设置位 7,得到的是 10,不能注释成上拉。PE12 的上下拉也不能沿用 PE3 的移位。原厂手册镜像
原来用 size_t * 访问 32 位寄存器,在 64 位环境可能变成错误宽度;MMIO 应通过 void __iomem * 与 readl/writel 等接口,并获取正确且独占的资源。即使改成读改写,若没有与其他使用者统一加锁,仍有竞争;整组写寄存器还可能破坏同一 bank 上其他引脚。
Linux 通常已有 pinctrl/GPIO 控制器驱动管理这些寄存器。LED 消费者应申请 GPIO 描述符或使用 gpio-leds,不另建一个重叠的裸 PIO 资源。低电平点亮是具体电路极性,应通过 GPIO_ACTIVE_LOW 表达;逻辑 1 表示“有效”,不必等同物理高电平。
6. 向事件与共享内存接口扩展
跳转到“6. 向事件与共享内存接口扩展”进程代表运行中的程序实例,通常拥有地址空间和文件描述符等资源;Linux 具体调度的是任务/线程,不能把“进程分配资源”与“每次调度单位”完全等同。就绪队列组织可运行任务,等待队列组织等待某个条件的任务;不同调度策略不保证所有队列都按简单 FIFO 运行。
需要阻塞读取时,先定义“有数据/设备离线”的条件,再用等待队列循环检查条件;生产者在正确同步之后唤醒等待者,并处理信号中断和 O_NONBLOCK。中断处理见 Linux 驱动中断,不要复制依赖旧板级常量的 IRQ_EINT10、IRQT_FALLING。
mmap 可以把受控的设备或共享缓冲区映射给用户空间,但它不是把任意内核指针直接交给用户;必须验证映射长度、偏移、权限、缓存属性和对象寿命。小型开关控制通常不需要这条复杂路径。
7. 验证范围
跳转到“7. 验证范围”本页完整模块已使用 Ubuntu 6.8.0-146-generic 的 x86-64 内核构建资料完成编译和 MODPOST;启用 W=1 并将编译警告视为错误。验证包含正文读写函数的主机回调测试、动态 cdev 初始化失败和撤销顺序模型,以及用户程序错误路径;这些测试使用明确的内核 API 模型,不等同于加载模块后的内核运行测试。没有在当前主机加载模块,也没有声称 F1C100S 板上 GPIO 已验证。