OLED驱动分析

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

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 设备树匹配规则

准确流程不是“驱动注册后去设备树文本里搜索字符串”,而是:

  1. 启动时,I²C 控制器驱动根据有效设备树建立地址为 0x3C 的 i2c_client;
  2. i2c_add_driver() 把 oled_driver 注册到 I²C 核心;
  3. 驱动核心比较已有设备对象与驱动的匹配表;
  4. OF 匹配成功后调用 oled_probe(client, ...);
  5. 只有 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 实际包含:

成员回调应用入口
.openoled_open()open("/dev/oled", ...)
.writeoled_write()write(fd_oled, ...)
.releaseoled_release()最后一个文件引用释放
.unlocked_ioctloled_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 12COM 引脚硬件配置
DB 30VCOMH 取消选择电平参数
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函数应用

视频聚焦的应用代码可按两次系统调用理解:

  1. 应用设置 pos_temp = {0, 3},调用 ioctl(fd_oled, OLED_OPS, &pos_temp);
  2. VFS 调用 oled_ioctl(),驱动把用户坐标复制到 pos_temp,再保存到全局 pos_xy;
  3. 应用用 sprintf() 生成 "AZ:%d GX:%d" 字符串;
  4. 应用调用 ioctl(fd_oled, OLED_DATA, buf);
  5. 驱动固定复制 200 字节,并在刚才保存的坐标调用 OLED_ShowString();
  6. 字模最终通过多次 I²C 写进入 OLED GDDRAM。

arg 在回调签名中是 unsigned long,对 OLED_OPS 和 OLED_DATA 来说,它承载的是用户指针值,不是坐标或字符串本体。OLED_CLEAR 不需要参数,所以应用传 0。

课程没有验证 pos_temp.x/y。short 的负值或过大值传给接收 u8 的 oled_pos() 时会截断/回绕;对 8×16 字体,起始页还必须保证下一页有效。驱动在操作硬件前应拒绝越界坐标。

二、字符设备驱动分析

本节可以归纳为三类对象,复习时不要把同名的“写”混在一起:

层次代表接口“写”的含义
应用/VFSwrite()、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、用户/内核复制和硬件总线传输各自所在的层次,并能沿返回值和运行时证据逐层定位问题。