OLED驱动分析
349.5.5OLED驱动分析
// 在 I²C1 控制器节点下声明地址为 0x3C 的 OLED 设备。
oled@3c {
// 提供设备身份字符串,供驱动的 OF 匹配表比较。
compatible = "i2c_oled";
// 指定该 I²C client 使用 7 位地址 0x3C。
reg = <0x3c>;
// 启用该设备树节点。
status = "okay";
// OLED 设备树节点结束。
};
// 定义应用与驱动共同使用的 OLED 页坐标结构。
struct pos {
// 保存横向像素列坐标。
short x;
// 保存纵向页坐标,不是直接的像素行号。
short y;
// OLED 页坐标结构定义结束。
};
// 用字符 x 作为本课程私有 ioctl 命令的类型字段。
#define MAGIC_NUMBER 'x'
// 定义不携带参数的清屏命令。
#define OLED_CLEAR _IO(MAGIC_NUMBER, 0)
// 定义携带 struct pos 的定位命令;课程源码使用了双向编码。
#define OLED_OPS _IOWR(MAGIC_NUMBER, 1, struct pos)
// 定义携带 200 字节数组的数据显示命令;课程源码使用了双向编码。
#define OLED_DATA _IOWR(MAGIC_NUMBER, 2, unsigned char [200])
// 应用程序打开 OLED 字符设备节点。
fd_oled = open("/dev/oled", O_RDWR);
// 把显示起始列设置为第 0 列。
pos_temp.x = 0;
// 把显示起始页设置为第 3 页。
pos_temp.y = 3;
// 把坐标结构的用户空间地址传给 OLED 定位命令。
ioctl(fd_oled, OLED_OPS, &pos_temp);
// 把 MPU6050 的两个原始量格式化进 200 字节缓冲区。
sprintf(buf, "AZ:%d GX:%d", resive_data[2], resive_data[3]);
// 把字符串缓冲区的用户空间地址传给 OLED 数据命令。
ioctl(fd_oled, OLED_DATA, buf);
// 应用程序关闭 OLED 文件描述符。
error = close(fd_oled);
// 保存 probe() 获得的 OLED I²C client。
static struct i2c_client *oled_client;
// 保存 OLED 字符设备号。
static dev_t devid;
// 保存 OLED class。
static struct class *oled_class;
// 定义 OLED 字符设备对象。
static struct cdev i2c_cdev;
// 定义向 OLED 发送一个命令字节的辅助函数。
static int oled_write_cmd(unsigned char cmd)
{ // oled_write_cmd() 函数开始。
// 定义一条 I²C 消息。
struct i2c_msg msg[1];
// 准备“控制字节 + 命令字节”。
unsigned char cmds[2];
// 0x00 表示后续字节按 OLED 命令解释。
cmds[0] = 0x00;
// 保存调用者要求发送的命令。
cmds[1] = cmd;
// 使用当前 OLED client 的 7 位从地址。
msg[0].addr = oled_client->addr;
// 让消息缓冲区指向两个待发送字节。
msg[0].buf = cmds;
// 本条消息包含两个字节。
msg[0].len = 2;
// 不设置 I2C_M_RD,表示主机向 OLED 写数据。
msg[0].flags = 0;
// 通过 OLED 所属 adapter 执行一条 I²C 消息。
if (i2c_transfer(oled_client->adapter, msg, 1) == 1)
// 一条消息执行成功后,课程函数返回发送的数据字节数 2。
return 2;
// I²C 消息没有完整执行时返回 -1。
else
// 保持课程源码的统一错误值。
return ERROR;
} // oled_write_cmd() 函数结束。
// 定义向 OLED GDDRAM 发送一个显示数据字节的辅助函数。
static int oled_write_data(unsigned char data)
{ // oled_write_data() 函数开始。
// 定义一条 I²C 消息。
struct i2c_msg msg[1];
// 准备“控制字节 + 显示数据字节”。
unsigned char datas[2];
// 0x40 表示后续字节按 GDDRAM 显示数据解释。
datas[0] = 0x40;
// 保存调用者要求写入的显示数据。
datas[1] = data;
// 使用当前 OLED client 的 7 位从地址。
msg[0].addr = oled_client->addr;
// 让消息缓冲区指向两个待发送字节。
msg[0].buf = datas;
// 本条消息包含两个字节。
msg[0].len = 2;
// 不设置 I2C_M_RD,表示主机向 OLED 写数据。
msg[0].flags = 0;
// 通过 OLED 所属 adapter 执行一条 I²C 消息。
if (i2c_transfer(oled_client->adapter, msg, 1) == 1)
// 一条消息执行成功后,课程函数返回发送的数据字节数 2。
return 2;
// I²C 消息没有完整执行时返回 -1。
else
// 保持课程源码的统一错误值。
return ERROR;
} // oled_write_data() 函数结束。
// 设置 OLED 的列地址和页地址。
static void oled_pos(u8 x, u8 y)
{ // oled_pos() 函数开始。
// 选择第 y 页。
oled_write_cmd(0xB0 + y);
// 设置列地址的高四位。
oled_write_cmd(((x & 0xF0) >> 4) | 0x10);
// 设置列地址的低四位。
oled_write_cmd(x & 0x0F);
} // oled_pos() 函数结束。
// 清空 OLED 的 8 页、每页 128 列显示 RAM。
static void oled_clear(void)
{ // oled_clear() 函数开始。
// 保存当前列号。
u8 x;
// 保存当前页号。
u8 y;
// 依次处理第 0 页到第 7 页。
for (y = 0; y < 8; y++)
{ // 当前页清零开始。
// 选择当前页。
oled_write_cmd(0xB0 + y);
// 把列地址低四位设为 0。
oled_write_cmd(0x00);
// 把列地址高四位设为 0。
oled_write_cmd(0x10);
// 依次处理该页的 128 列。
for (x = 0; x < 0x80; x++)
// 向每一列写入 0,使对应的 8 个像素熄灭。
oled_write_data(0x00);
} // 当前页清零结束。
} // oled_clear() 函数结束。
// 按课程移植的命令序列初始化 OLED 控制器。
static void oled_hw_init(void)
{ // oled_hw_init() 函数开始。
// 关闭显示输出。
oled_write_cmd(0xAE);
// 设置初始列地址低四位。
oled_write_cmd(0x00);
// 设置初始列地址高四位。
oled_write_cmd(0x10);
// 设置显示起始行。
oled_write_cmd(0x40);
// 选择第 0 页。
oled_write_cmd(0xB0);
// 选择对比度设置命令。
oled_write_cmd(0x81);
// 把对比度参数设置为 0xFF。
oled_write_cmd(0xFF);
// 设置段地址重映射方向。
oled_write_cmd(0xA1);
// 选择正常显示而不是反相显示。
oled_write_cmd(0xA6);
// 选择复用比设置命令。
oled_write_cmd(0xA8);
// 设置 1/64 复用比,对应 64 行面板。
oled_write_cmd(0x3F);
// 设置 COM 扫描方向。
oled_write_cmd(0xC8);
// 选择显示偏移设置命令。
oled_write_cmd(0xD3);
// 把显示偏移设置为 0。
oled_write_cmd(0x00);
// 选择显示时钟分频/振荡频率设置命令。
oled_write_cmd(0xD5);
// 写入课程使用的时钟参数 0x80。
oled_write_cmd(0x80);
// 保持课程移植代码原样发送 0xD8。
oled_write_cmd(0xD8);
// 为上一条命令发送参数 0x05。
oled_write_cmd(0x05);
// 选择预充电周期设置命令。
oled_write_cmd(0xD9);
// 写入课程使用的预充电参数 0xF1。
oled_write_cmd(0xF1);
// 选择 COM 引脚硬件配置命令。
oled_write_cmd(0xDA);
// 写入 128×64 面板常用的 COM 引脚配置 0x12。
oled_write_cmd(0x12);
// 选择 VCOMH 取消选择电平设置命令。
oled_write_cmd(0xDB);
// 写入课程使用的 VCOMH 参数 0x30。
oled_write_cmd(0x30);
// 选择内部电荷泵设置命令。
oled_write_cmd(0x8D);
// 启用内部电荷泵。
oled_write_cmd(0x14);
// 打开正常显示输出。
oled_write_cmd(0xAF);
} // oled_hw_init() 函数结束。
// 定义 128 列显示宽度。
#define Max_Column 128
// 在指定位置显示一个字符。
void OLED_ShowChar(u8 x, u8 y, u8 chr, u8 Char_Size)
{ // OLED_ShowChar() 函数开始。
// c 保存字符在字模表中的索引,i 保存循环位置。
unsigned char c = 0, i = 0;
// 字模表从空格开始,因此减去空格的编码得到索引。
c = chr - ' ';
// 如果起始列已经超出屏幕,则换到下一字符行。
if (x > Max_Column - 1)
{ // 横坐标越界处理开始。
// 从第 0 列重新开始。
x = 0;
// 16 像素字体占两页,因此页号增加 2。
y = y + 2;
} // 横坐标越界处理结束。
// 选择 8×16 字体。
if (Char_Size == 16)
{ // 8×16 字符绘制开始。
// 定位到字符上半部分的起始页和列。
oled_pos(x, y);
// 连续写入字符上半部分的 8 个字模字节。
for (i = 0; i < 8; i++)
// 从 F8X16 字模表写一列数据。
oled_write_data(F8X16[c * 16 + i]);
// 定位到字符下半部分所在的下一页。
oled_pos(x, y + 1);
// 连续写入字符下半部分的 8 个字模字节。
for (i = 0; i < 8; i++)
// 从 F8X16 字模表写下一组数据。
oled_write_data(F8X16[c * 16 + i + 8]);
} // 8×16 字符绘制结束。
// Char_Size 不是 16 时,课程代码走 6×8 字体分支。
else
{ // 6×8 字符绘制开始。
// 定位到字符起始页和列。
oled_pos(x, y);
// 连续写入 6 个字模字节。
for (i = 0; i < 6; i++)
// 从 F6x8 字模表写一列数据。
oled_write_data(F6x8[c][i]);
} // 6×8 字符绘制结束。
} // OLED_ShowChar() 函数结束。
// 从给定坐标开始显示一个以 NUL 结尾的字符串。
void OLED_ShowString(u8 x, u8 y, u8 *chr, u8 Char_Size)
{ // OLED_ShowString() 函数开始。
// 保存当前字符串下标。
unsigned char j = 0;
// 逐字符处理,直到遇到字符串结束符。
while (chr[j] != '\0')
{ // 当前字符处理开始。
// 在当前位置显示一个字符。
OLED_ShowChar(x, y, chr[j], Char_Size);
// 课程按 8 列宽度移动到下一个字符位置。
x += 8;
// 超过第 120 列后换行,避免下一个 8 列字符越过 128 列边界。
if (x > 120)
{ // 字符串换行处理开始。
// 从第 0 列继续显示。
x = 0;
// 保留课程逻辑:无论字号都下移两页;本节实际传入的是 16 像素字号。
y += 2;
} // 字符串换行处理结束。
// 处理下一个字符。
j++;
} // 当前字符处理结束。
} // OLED_ShowString() 函数结束。
// 实现 OLED 字符设备的 open 回调。
static int oled_open(struct inode *inode, struct file *filp)
{ // oled_open() 函数开始。
// 向 OLED 发送控制器初始化命令序列。
oled_hw_init();
// 清空 OLED GDDRAM。
oled_clear();
// 把当前位置恢复到第 0 列、第 0 页。
oled_pos(0, 0);
// 输出设备打开日志。
printk(KERN_INFO "oled open\n");
// 保持课程源码原样:固定向 VFS 返回成功。
return 0;
} // oled_open() 函数结束。
// 实现 OLED 字符设备的 write 回调。
static ssize_t oled_write(struct file *filp, const char __user *buffer, size_t size, loff_t *off)
{ // oled_write() 函数开始。
// 定义课程固定使用的 168 字节内核缓冲区。
unsigned char data[168];
// 保持课程源码原样:忽略 size,固定从用户地址复制 168 字节。
copy_from_user(data, buffer, 168);
// 从第 0 列、第 0 页开始,用 8×16 字体显示缓冲区中的字符串。
OLED_ShowString(0, 0, data, 16);
// 保持课程源码原样:成功后返回 0,而不是已消费的字节数。
return 0;
} // oled_write() 函数结束。
// 实现 OLED 字符设备的 release 回调。
static int oled_release(struct inode *inode, struct file *filp)
{ // oled_release() 函数开始。
// 本例没有每次打开资源需要释放,直接返回成功。
return 0;
} // oled_release() 函数结束。
// 全局保存最近一次通过 ioctl 设置的显示坐标。
struct pos pos_xy;
// 实现 OLED 字符设备的 ioctl 回调。
static long oled_ioctl(struct file *filep, unsigned int cmd, unsigned long arg)
{ // oled_ioctl() 函数开始。
// 为坐标参数准备默认值为 (0, 0) 的内核临时对象。
struct pos pos_temp = {.x = 0, .y = 0};
// 为字符串参数准备已清零的 200 字节内核缓冲区。
unsigned char buf[200] = {0};
// 保存 copy_from_user() 没有复制成功的字节数。
int ret = 0;
// 根据完整 ioctl 命令号选择操作。
switch (cmd)
{ // ioctl 命令分派开始。
// 处理清屏命令。
case OLED_CLEAR:
// 清空 OLED 显示 RAM。
oled_clear();
// 结束当前 case。
break;
// 处理坐标设置命令。
case OLED_OPS:
// 从 arg 指向的用户空间复制一个 struct pos。
if (copy_from_user(&pos_temp, (void *)arg, sizeof(pos_temp)) != 0)
{ // 坐标没有完整复制时进入错误分支。
// 保持课程源码原样:日志文字误写成 copy_to_user。
printk("copy_to_user error \n");
} // 坐标复制错误处理结束。
// 保存用户传入的横坐标。
pos_xy.x = pos_temp.x;
// 保存用户传入的页坐标。
pos_xy.y = pos_temp.y;
// 结束当前 case。
break;
// 处理字符串数据显示命令。
case OLED_DATA:
// 从 arg 指向的用户空间固定复制 200 字节。
ret = copy_from_user(buf, (void *)arg, sizeof(buf));
// 检查是否仍有字节未复制。
if (ret != 0)
{ // 字符串没有完整复制时进入错误分支。
// 保持课程源码原样:日志文字误写成 copy_to_user。
printk("copy_to_user error \n");
} // 字符串复制错误处理结束。
// 在全局保存的位置显示用户字符串。
OLED_ShowString(pos_xy.x, pos_xy.y, buf, 16);
// 结束当前 case。
break;
// 处理驱动不认识的 ioctl 命令。
default:
// 返回“无效参数/命令”错误。
return -EINVAL;
} // ioctl 命令分派结束。
// 保持课程源码原样:已识别命令固定返回成功。
return 0;
} // oled_ioctl() 函数结束。
// 定义 OLED 字符设备提供给 VFS 的操作表。
static struct file_operations oled_fops = {
// 标明这些回调由当前模块提供。
.owner = THIS_MODULE,
// 把 write 系统调用连接到 oled_write()。
.write = oled_write,
// 把 open 系统调用连接到 oled_open()。
.open = oled_open,
// 把文件对象最终释放连接到 oled_release()。
.release = oled_release,
// 把 ioctl 系统调用连接到 oled_ioctl()。
.unlocked_ioctl = oled_ioctl,
// OLED 文件操作表初始化结束。
};
// 设备与 OLED I²C 驱动匹配后执行 probe()。
static int oled_probe(struct i2c_client *client, const struct i2c_device_id *i2c_device)
{ // oled_probe() 函数开始。
// 保留课程源码中声明但没有使用的局部变量。
int result;
// 输出进入 probe() 的匹配提示。
printk(KERN_EMERG "oled match successed \n");
// 动态申请一个字符设备号,起始次设备号为 0。
if (alloc_chrdev_region(&devid, 0, 1, "oled") == OK)
{ // 设备号申请成功分支开始。
// 输出实际分配到的主、次设备号。
printk(KERN_INFO "alloc device number : major:[%d], minor:[%d] succeed!\n", MAJOR(devid), MINOR(devid));
} // 设备号申请成功分支结束。
// 设备号申请失败时进入此分支。
else
{ // 设备号申请失败分支开始。
// 输出设备号登记失败提示。
printk(KERN_INFO "register device number error!\n");
// 向 I²C 核心报告 probe() 失败。
return ERROR;
} // 设备号申请失败分支结束。
// 初始化 OLED cdev,并绑定前面的文件操作表。
cdev_init(&i2c_cdev, &oled_fops);
// 把该 cdev 加入字符设备映射;课程源码没有检查返回值。
cdev_add(&i2c_cdev, devid, 1);
// 创建名为 oled_class 的设备类。
oled_class = class_create(THIS_MODULE, "oled_class");
// 检查 class_create() 是否返回错误指针。
if (IS_ERR(oled_class))
{ // class 创建失败分支开始。
// 输出 class 创建失败提示。
printk("can't create class\n");
// 保持课程源码原样:返回前没有回收 cdev 和设备号。
return ERROR;
} // class 创建失败分支结束。
// 创建名为 oled、设备号为 devid 的 class 设备对象。
device_create(oled_class, NULL, devid, NULL, "oled");
// 输出设备文件创建提示;课程源码没有检查 device_create() 的结果。
printk(KERN_INFO "create device file 'oled' succeed!\n");
// 保存与本驱动绑定的 I²C client。
oled_client = client;
// 输出 client 名称和 7 位从地址。
printk(KERN_INFO "get i2c_client, client name = %s, addr = 0x%x\n", oled_client->name, oled_client->addr);
// 输出该 client 所属 adapter 的名称。
printk(KERN_INFO "get i2c_adapter, adapter name = %s\n", oled_client->adapter->name);
// 输出 probe() 结束提示。
printk(KERN_INFO "oled probe\n");
// 向 I²C 核心报告绑定成功。
return 0;
} // oled_probe() 函数结束。
// 定义 Device Tree 匹配表。
static const struct of_device_id oled_of_match_table[] = {
// 与设备树节点中的 compatible = "i2c_oled" 精确匹配。
{.compatible = "i2c_oled"},
// 用全零表项标记匹配表结束。
{}
// Device Tree 匹配表初始化结束。
};
// 定义传统 I²C 设备 ID 表。
static const struct i2c_device_id oled_id[] = {
// 声明该驱动支持名称为 oled 的 I²C client。
{"oled", 0},
// 用全零表项标记 ID 表结束。
{}
// I²C 设备 ID 表初始化结束。
};
// 定义并初始化 OLED I²C 驱动对象。
static struct i2c_driver oled_driver = {
// 绑定成功时由 I²C 核心调用 oled_probe()。
.probe = oled_probe,
// 解绑时由 I²C 核心调用 oled_remove()。
.remove = oled_remove,
// 初始化所有设备驱动共有的 driver 子对象。
.driver = {
// 设置驱动名称。
.name = "oled",
// 声明该驱动对象属于当前模块。
.owner = THIS_MODULE,
// 指定前面的 Device Tree 匹配表。
.of_match_table = oled_of_match_table,
// driver 子对象初始化结束。
},
// 指定传统 I²C 设备 ID 表。
.id_table = oled_id,
// OLED I²C 驱动对象初始化结束。
};
// 定义模块加载时执行的初始化函数。
static int __init oled_init(void)
{ // oled_init() 函数开始。
// 向 I²C 核心注册 oled_driver;课程源码没有保存返回值。
i2c_add_driver(&oled_driver);
// 输出驱动已提交注册的日志。
printk(KERN_INFO "oled i2c_driver was added into the system.\n");
// 保持课程源码原样:无论注册结果如何都返回成功。
return 0;
} // oled_init() 函数结束。
// 定义模块卸载时执行的退出函数。
static void __exit oled_exit(void)
{ // oled_exit() 函数开始。
// 从 I²C 核心注销驱动,并触发已绑定设备的解绑/remove。
i2c_del_driver(&oled_driver);
// 输出驱动已经注销的日志。
printk(KERN_INFO "oled i2c_driver was deleted from the system.\n");
// void 函数返回。
return;
} // oled_exit() 函数结束。
// 把 oled_init() 登记为模块初始化入口。
module_init(oled_init);
// 把 oled_exit() 登记为模块退出入口。
module_exit(oled_exit);
// 声明模块采用 GPL 兼容许可证。
MODULE_LICENSE("GPL");
// 提供课程模块的说明文字。
MODULE_DESCRIPTION("oled device driver, 2023-02-21");
源码边界:代码区收录了视频本节实际展示和追踪的 OLED 设备树节点、应用层
open/ioctl/close、I²C 命令/数据写入、定位、清屏、初始化、字符显示、open/write/release/ioctl、file_operations、probe()、匹配表、i2c_driver及模块 init/exit。代码均来自课程配套文件,只去除旧注释、统一排版并增加逐行解释;没有抄入copy_from_user()、i2c_transfer()等内核库函数的实现,也没有补入视频未展示的新业务逻辑。课程曾打开 51 单片机版本作对比,因其 OLED 命令序列与这里重复,正文解释差异而不重复整份源码。
一、OLED驱动流程分析
OLED 与 MPU6050 都挂在 I²C 总线上,也都由课程驱动额外注册为字符设备,因此外层框架相似:
设备树创建 I²C client
+ 模块注册 i2c_driver
→ I²C 核心匹配 device 与 driver
→ oled_probe()
→ 设备号 + cdev + class/device
→ mdev 创建 /dev/oled
→ 应用 open/ioctl/close
→ file_operations 回调
→ i2c_transfer() 把命令/显存数据送到 OLED
但“框架相似”不等于“具体驱动相同”。MPU6050 的主要方向是从传感器读取寄存器,OLED 的主要方向是把控制命令和像素字模写入显示控制器;它们的寄存器协议、缓冲区、并发要求和用户接口均不同。
1.1 i2c设备id列表
视频从文件底部逆向阅读,正确符号是 module_init()、oled_init()、i2c_add_driver() 和 struct i2c_driver oled_driver,不是原稿中的 model_init 或其他 OCR 名称。
课程驱动提供两套身份信息:
| 对象 | 本例内容 | 用途 |
|---|---|---|
| OF 匹配表 | compatible = "i2c_oled" | 与 Device Tree 设备节点匹配 |
| I²C ID 表 | {"oled", 0} | 非 OF 场景或 I²C 名称匹配路径使用 |
| 驱动名称 | .driver.name = "oled" | 驱动模型中的名称,不等于设备树 compatible |
数组末尾的全零元素是哨兵,告诉内核遍历到此结束。oled_id 并不是 I²C 地址列表,0x3C 地址来自设备树 reg,由 I²C 核心保存到 client->addr。
配套源码还定义了 oled_detect() 和地址数组,但 oled_driver 没有设置 .detect、.address_list 或 .class,所以这些定义在当前驱动中不会执行扫描。实际设备实例来自设备树,而不是驱动主动遍历总线。
oled_init() 还有一个错误处理问题:它忽略 i2c_add_driver() 的返回值并固定返回 0。即使 I²C 驱动注册失败,insmod 也可能看起来成功,日志“was added”同样只是无条件打印,不能代替返回值检查。
1.2 设备树匹配表
设备树中的真实节点是:
节点名:oled@3c
compatible:"i2c_oled"
reg:0x3c
status:"okay"
oled@3c 的单元地址应与 reg = <0x3c> 对应。0x3C 是 Linux I²C API 使用的 7 位地址;51 单片机代码中的 0x78 是 0x3C << 1 后再放入写方向位的线上地址字节,不能把 0x78 填入 Linux 的 i2c_msg.addr。
驱动的 oled_of_match_table 与节点的 compatible 必须匹配,当前工程两边都是 "i2c_oled",所以手工装入驱动后可参与 OF 匹配。更规范的新设备树绑定通常采用文档化的 vendor,device 字符串;"i2c_oled" 是课程私有命名,不宜直接当作通用 binding 范例。
这份驱动没有 MODULE_DEVICE_TABLE(of, oled_of_match_table) 或对应 I²C ID 的模块别名声明。.of_match_table 足以让已经装入的驱动匹配设备,但缺少模块别名可能影响基于 modalias 的自动加载;课程依靠脚本/insmod 手工装入,不受这个路径限制。
1.3 设备树匹配规则
准确流程不是“驱动注册后去设备树文本里搜索字符串”,而是:
- 启动时,I²C 控制器驱动根据有效设备树建立地址为 0x3C 的
i2c_client; i2c_add_driver()把oled_driver注册到 I²C 核心;- 驱动核心比较已有设备对象与驱动的匹配表;
- OF 匹配成功后调用
oled_probe(client, ...); - 只有
probe()返回 0,才表示这次绑定成功。Linux Device Tree 使用模型
probe() 中的 printk("oled match successed") 位于函数开头,只能证明已经进入回调,不能证明后面的设备号、cdev、class/device 全部成功。它也不能证明 OLED 硬件应答,因为本驱动在 probe() 中没有执行任何 I²C 传输,真正的硬件初始化直到应用打开 /dev/oled 才发生。
本例字符设备注册还有以下风险:
cdev_add()的返回值被忽略;class_create()失败时,已经建立的 cdev 和设备号没有回滚;device_create()的错误指针没有检查,却仍打印“create device file succeed”;oled_client = client位于device_create()之后,设备节点事件先发出,存在用户打开节点时 client 尚未保存的竞态;device_create()的 parent 为 NULL,class 设备没有明确挂到实际 I²C client 的设备对象下面。
device_create() 建立 sysfs 设备并产生 uevent,本项目的 BusyBox mdev 才根据这些信息创建 /dev/oled。设备节点存在只证明字符接口链路至少部分建立,不证明屏幕接线、供电、控制器型号或 I²C 应答正常。
1.4 模块退出
卸载模块时执行:
module_exit(oled_exit)
→ oled_exit()
→ i2c_del_driver(&oled_driver)
→ I²C 核心解绑 client
→ oled_remove()
→ device_destroy()
→ class_destroy()
→ cdev_del()
→ unregister_chrdev_region()
原稿中的“iPhone 4.0 驱动 exit”和视频 OCR 的“delay driver”都应是 i2c_del_driver()。del 表示注销驱动,不是延迟操作。
oled_remove() 能撤销字符设备资源,但没有向 OLED 发送 0xAE 关闭显示;因此模块卸载后,屏幕可能继续保持控制器 RAM 中的最后画面。这是硬件状态与 Linux 驱动对象生命周期并不等价的直观例子。
1.5 操作集
课程 OLED 的 file_operations 实际包含:
| 成员 | 回调 | 应用入口 |
|---|---|---|
.open | oled_open() | open("/dev/oled", ...) |
.write | oled_write() | write(fd_oled, ...) |
.release | oled_release() | 最后一个文件引用释放 |
.unlocked_ioctl | oled_ioctl() | ioctl(fd_oled, cmd, arg) |
它没有 .read,原稿“操作集包括 read”和“read 内部调用 copy_from_user”均为错误。配套应用也没有调用 OLED 的 write(),而是用 OLED_OPS 和 OLED_DATA 两种 ioctl 完成定位与显示。write() 与 ioctl() 是两条独立 VFS 路径,不能因为两者最终都写屏幕就混为同一个函数。
1.5.1 应用案例
应用的实际显示链路是:
MPU6050 read() 得到六轴原始值
→ sprintf() 格式化为 NUL 结尾字符串
→ ioctl(OLED_OPS, &pos_temp) 设置列/页
→ copy_from_user() 复制坐标
→ ioctl(OLED_DATA, buf) 传字符串
→ copy_from_user() 复制 200 字节
→ OLED_ShowString()
→ OLED_ShowChar()
→ oled_pos() + oled_write_data()
→ i2c_transfer()
应用没有检查各次 ioctl() 的返回值;真实项目中应检查失败,否则驱动拒绝命令、用户复制失败或硬件传输失败都可能被静默忽略。
1.5.1.1 例题: open函数应用
oled_open() 顺序执行硬件初始化、清屏、定位到 (0,0),然后打印日志。hw 是 hardware 的缩写,不是 transcript 中的 “hardwork”;pos 是 position。
这里的 y 不是 0~63 像素行,而是 0~7 的 GDDRAM 页号。对 128×64、页寻址的 SSD1306 类控制器:
1 页 = 8 个垂直像素
页 0 覆盖像素行 0~7
页 3 覆盖像素行 24~31
8×16 字体占连续两页
所以应用使用 y=0、3、6,是把三行 16 像素高文本放在页 0~1、3~4、6~7,中间留一页间隔;不能简单理解成像素纵坐标 0、3、6。
oled_open() 每打开一次都会重新初始化并清屏,多进程同时打开时可能互相清除画面。更重要的是,oled_hw_init()、oled_clear() 和 oled_pos() 都忽略 I²C helper 的失败,oled_open() 又固定返回 0;因此 open() 成功仍不表示 OLED 已应答。
初始化命令中几个关键硬件含义是:
| 命令序列 | 含义 |
|---|---|
AE / AF | 初始化前关闭显示,最后再打开显示 |
81 FF | 对比度设为 0xFF |
A1、C8 | 改变 SEG/COM 映射方向,决定画面方向 |
A6 | 正常显示,GDDRAM 中 1 对应点亮 |
A8 3F | 复用比参数为 63,即 64 路 COM;课程旧注释“1/32 duty”错误 |
D3 00 | 显示偏移为 0 |
D5 80 | 显示时钟分频与振荡频率参数 |
D9 F1 | 预充电周期参数 |
DA 12 | COM 引脚硬件配置 |
DB 30 | VCOMH 取消选择电平参数 |
8D 14 | 启用内部电荷泵 |
配套 51 源码明确把控制器称为 SSD1306,Linux 代码也沿用了同一命令序列。0xD8, 0x05 是 SSD130x 家族代码中可见的 area-color/low-power 配置,但 SSD1306 Rev. 1.1 的标准命令表没有把它列为 SSD1306 初始化命令;因此不能只凭移植代码里的 “area color mode off” 注释断定当前模块会执行该功能,应按模块的实际控制器型号核对。Solomon Systech SSD1306 Rev. 1.1 数据手册
oled_clear() 对每页先发 3 条定位命令,再把 128 个零逐字节发送;总计执行 8 × (3 + 128) = 1048 次 I²C transfer。逻辑直观但效率较低,实际驱动通常会批量传输一页或使用显存缓冲区。
1.5.1.2 例题: 51单片机OLED代码分析
51、STM32 和 ARM Linux 版本共同遵守 OLED 控制器的数据手册:初始化命令、页/列寻址和字模数据含义基本一致。差别主要在传输层和软件架构:
| 平台 | I²C 产生方式 | OLED 上层算法 |
|---|---|---|
| 51 单片机课程代码 | GPIO 软件模拟 SCL/SDA 时序 | OLED_Init、定位、清屏、字模显示 |
| STM32 课程代码 | 硬件 I²C 外设/HAL | 同类初始化与字模逻辑 |
| ARM Linux 本例 | Linux I²C adapter 驱动 + i2c_transfer() | 同类命令序列,外加设备模型、VFS、用户/内核边界 |
“本质都是时序”应限定为硬件通信层:逻辑分析仪确实可以观察 START、地址、ACK、数据和 STOP,从而诊断总线问题;但用户指针越界、ioctl ABI 不匹配、并发竞争等软件问题不会仅靠查看 I²C 波形解决。
SSD1306 I²C 写入时,在 7 位从地址之后先发送一个控制字节:
0x00:Co=0、D/C#=0,后续字节作为命令;0x40:Co=0、D/C#=1,后续字节写入 GDDRAM。
这正是 oled_write_cmd() 与 oled_write_data() 的唯一区别。SSD1306 I²C 接口说明
1.5.1.3 例题: 应用层代码示例
OLED_ShowString() 遍历到 '\0' 为止,逐字符调用 OLED_ShowChar()。8×16 字体的每个字符从 F8X16 读取 16 字节:8 字节写当前页,另外 8 字节写下一页。到第 120 列之后,代码把 x 归零、y 增加 2。
该实现有几处边界条件:
c = chr - ' '假设字符在字模表覆盖范围内;用户传入控制字符或超出字模范围的字节可能越界读取字体数组;- 换行后没有检查 y 是否超过第 7 页,过长字符串会继续发送无效页地址;
OLED_DATA的内核缓冲区预先清零,应用的sprintf()也会写入'\0',所以本例通常能终止;通用接口仍应显式限定最大字符串长度;- 应用把
unsigned char buf[200]传给期望char *的sprintf(),在严格编译警告下属于指针类型不兼容,缓冲区更适合声明为char。
应用的 close(fd_oled) 会在最后一个文件引用释放时触发 oled_release(),并非任何一次 close() 都绝对一一对应。现有应用的主循环是无限循环,循环后的正常 close() 到达不了;读取错误分支和进程退出仍会关闭描述符。
原稿还把 copy_from_user() 放进了 “read 函数”。实际位置是 OLED 的 write() 和 ioctl();MPU6050 的 read() 才使用 copy_to_user()。
1.5.1.4 例题: copy_from_user函数应用
用户空间指针不能由驱动直接解引用。copy_from_user(to, from, n) 尝试从用户地址 from 复制最多 n 字节到内核地址 to;copy_to_user(to, from, n) 方向相反。它们返回的是“未复制成功的字节数”,0 才表示全部成功,不是通常的负 errno。Linux 用户空间访问接口说明
课程 oled_write() 存在严重长度问题:
用户请求长度:size
内核数组大小:168
实际复制长度:固定 168(完全忽略 size)
如果用户只提供较短缓冲区,驱动仍尝试读取 168 字节;如果用户提供超过 168 字节,驱动又只取前 168 字节。返回值未检查,data 未初始化,也没有强制写入字符串结束符,随后 OLED_ShowString() 可能越过数组继续寻找 '\0'。此外,成功的 write() 应返回实际消费的字节数,课程却返回 0,用户程序会把它理解为没有写入任何字节。
oled_ioctl() 虽然检查了 copy_from_user() 是否非零,但只打印错误并继续操作,日志还误写成 copy_to_user error。通常应在复制不完整时立即返回 -EFAULT,避免使用部分坐标或部分字符串。
“单片机可以直接传地址,Linux 必须 copy”是入门近似。更准确地说,裸机程序通常运行在单一地址空间,调用者和被调用者共享同一映射;Linux 用户进程有独立虚拟地址空间,内核必须把用户指针视为不可信且可能缺页/失效的地址,通过 uaccess 接口安全访问。
1.6 ioctl函数
ioctl(fd, cmd, arg) 适合表达普通字节流 read/write 难以表达的控制操作。cmd 不是简单的 0、1、2,而是由方向、数据大小、类型 'x' 和序号共同编码的命令号:
| 宏 | 标准语义 | 本例 |
|---|---|---|
_IO | 不带指针数据 | OLED_CLEAR |
_IOW | 用户向内核传数据 | 本例未使用,但更符合设置坐标/写显示数据的方向 |
_IOR | 内核向用户返回数据 | 本例未使用 |
_IOWR | 双向传递 | 课程把 OLED_OPS、OLED_DATA 定义成它,但驱动实际上只从用户复制 |
方向是从用户视角命名:_IOW 表示用户写给内核。课程两个有参命令只使用 copy_from_user(),所以 _IOW 更符合实际语义;使用 _IOWR 不会自动替驱动复制数据,驱动仍必须显式调用 uaccess 函数。Linux ioctl 接口设计
ioctl 命令宏和 struct pos 在应用与驱动中各复制了一份,当前恰好一致。更可靠的做法是放进双方共享的 UAPI 头文件,否则任一边修改字段、对齐或宏定义都会造成 ABI 不一致。
全局 pos_xy 还意味着所有进程、所有打开实例共享同一个光标位置。两个调用者交替执行“设置坐标—写数据”时会互相覆盖;真正支持并发时,应把状态放到每个打开文件的私有数据中,或把位置和字符串设计成一次原子请求,并配合必要的锁。
1.6.1 例题: ioctl函数应用
视频聚焦的应用代码可按两次系统调用理解:
- 应用设置
pos_temp = {0, 3},调用ioctl(fd_oled, OLED_OPS, &pos_temp); - VFS 调用
oled_ioctl(),驱动把用户坐标复制到pos_temp,再保存到全局pos_xy; - 应用用
sprintf()生成"AZ:%d GX:%d"字符串; - 应用调用
ioctl(fd_oled, OLED_DATA, buf); - 驱动固定复制 200 字节,并在刚才保存的坐标调用
OLED_ShowString(); - 字模最终通过多次 I²C 写进入 OLED GDDRAM。
arg 在回调签名中是 unsigned long,对 OLED_OPS 和 OLED_DATA 来说,它承载的是用户指针值,不是坐标或字符串本体。OLED_CLEAR 不需要参数,所以应用传 0。
课程没有验证 pos_temp.x/y。short 的负值或过大值传给接收 u8 的 oled_pos() 时会截断/回绕;对 8×16 字体,起始页还必须保证下一页有效。驱动在操作硬件前应拒绝越界坐标。
二、字符设备驱动分析
本节可以归纳为三类对象,复习时不要把同名的“写”混在一起:
| 层次 | 代表接口 | “写”的含义 |
|---|---|---|
| 应用/VFS | write()、ioctl() | 用户向字符设备接口提交数据或控制请求 |
| 用户/内核边界 | copy_from_user() | 在两个虚拟地址空间之间安全复制 |
| I²C 总线 | i2c_transfer() | 产生 I²C 地址、ACK、数据等总线时序 |
| OLED 控制器 | 0x00/0x40 控制字节 | 区分控制命令与写入 GDDRAM 的显示数据 |
从零编写驱动时,至少要同时设计:
- 设备如何被实例化和匹配;
- 总线传输如何封装、如何返回错误;
- 用户接口采用
read/write还是ioctl,ABI 如何共享; - 缓冲区长度、字符串终止、坐标范围和并发如何处理;
probe()各阶段失败时如何逆序回滚,remove()如何对称清理。
能看懂现有驱动只是第一步;课程源码适合展示框架,但其中的固定长度复制、错误返回丢失、全局状态、未检查返回值等写法不能直接作为生产驱动模板。
三、课程总结
到本节为止,课程已从设备树、I²C 驱动注册与匹配、字符设备框架,一直串到应用通过设备节点调用驱动,并用 MPU6050 数据更新 OLED。判断整条链路正常至少需要分别验证:
目标 DTB 生效
→ I²C client 已建立
→ driver 已注册并绑定
→ probe 全部成功
→ cdev/class/device 与 /dev 节点一致
→ 用户复制和 ioctl 命令正确
→ I²C 有 ACK 且命令符合实际 OLED 控制器
→ 屏幕供电、接线和面板方向正确
课程后续计划从“会使用现成代码”转向从零构建设备驱动,并补充存储体系与综合复习。原稿中的“吹奏器”是“存储器”的 OCR 错误;寄存器、RAM、ROM、NOR Flash、NAND Flash、eMMC 等也不处于完全相同的抽象层次,后续学习时应分别从易失性、访问方式、擦写粒度、控制器和用途理解。
完成这一阶段并不等于掌握所有 Linux 驱动。更准确的学习成果是:已经能识别 Device Tree、总线匹配、probe/remove、cdev、file_operations、用户/内核复制和硬件总线传输各自所在的层次,并能沿返回值和运行时证据逐层定位问题。