Linux驱动学习笔记:SPI OLED 驱动源码深度分析

这份笔记是关于 Linux SPI OLED 驱动(基于 Framebuffer 架构)的深度代码分析与学习笔记。旨在梳理驱动的分层架构、核心难点(数据格式转换)、以及关键机制(内核线程与 DMA 内存管理)的设计原理。


Linux SPI OLED 驱动源码深度分析笔记

1. 总体架构概览

本驱动程序不仅仅是一个简单的字符设备驱动,它实现了一个完整的 Linux Framebuffer (fbdev) 子系统接口。其核心目标是将一块只支持 SPI 接口、采用"页寻址"模式的 OLED 屏幕,模拟成一块标准的、支持"光栅扫描"的显示器。

c 复制代码
/* 核心全局变量 */
static struct fb_info *myfb_info;       // Framebuffer 核心结构体
static struct task_struct *oled_thread; // 负责刷新的内核线程
static unsigned char *oled_buf;         // SPI 发送用的临时缓冲区
static struct spi_device *oled;         // SPI 设备指针
static struct gpio_desc *dc_gpio;       // D/C 引脚 (命令/数据选择)

/* Framebuffer 操作函数集 (使用内核通用函数) */
static struct fb_ops myfb_ops = {
    .owner        = THIS_MODULE,
    .fb_fillrect  = cfb_fillrect,  // 通用矩形填充
    .fb_copyarea  = cfb_copyarea,  // 通用区域拷贝
    .fb_imageblit = cfb_imageblit, // 通用图像位块传输
    // .fb_setcolreg ... (省略伪彩设置细节)
};

static int oled_thread_func(void *param)
{
    unsigned char *fb = myfb_info->screen_base; // 指向 DMA 分配的显存虚拟地址
    int i, line, bit, k = 0;
    unsigned char data[8], byte;

    while (!kthread_should_stop()) // 只要驱动没卸载,就死循环运行
    {
        /* --- A. 格式转换 (Raster to Page) --- */
        /* OLED 需要竖向的 Page 数据,而 FB 显存是横向的 Raster 数据 */
        k = 0;
        for (i = 0; i < 8; i++) { // 遍历 8 个 Page (总高 64 像素)
            // ... (省略部分遍历逻辑) ...
            
            // 核心算法:将 8 行横向数据,通过位运算拼凑成 1 个纵向字节
            for (bit = 0; bit < 8; bit++) {
                byte =  (((data[0]>>bit) & 1) << 0) | 
                        (((data[1]>>bit) & 1) << 1) |
                        // ... (中间行省略) ...
                        (((data[7]>>bit) & 1) << 7);
                oled_buf[k++] = byte; // 存入发送缓冲区
            }
        }

        /* --- B. SPI 硬件发送 --- */
        for (i = 0; i < 8; i++) {
            OLED_DIsp_Set_Pos(0, i);            // 设置 OLED 页坐标
            gpiod_set_value(dc_gpio, 1);        // 拉高 DC 脚 (数据模式)
            spi_write(oled, &oled_buf[i*128], 128); // 发送一整页数据
        }

        /* --- C. 帧率控制 --- */
        schedule_timeout_interruptible(HZ); // 休眠释放 CPU
    }
    return 0;
}


// 2. Probe 函数:驱动初始化入口
static int spidev_probe(struct spi_device *spi)
{
    dma_addr_t phy_addr;
    
    /* A. 硬件基础设置 */
    oled = spi;
    dc_gpio = gpiod_get(&spi->dev, "dc", 0); // 从设备树获取 GPIO
    
    /* B. 分配 Framebuffer 信息结构体 */
    myfb_info = framebuffer_alloc(0, NULL);
    
    /* C. 设置屏幕参数 (128x64, 单色) */
    myfb_info->var.xres = 128;
    myfb_info->var.yres = 64;
    myfb_info->var.bits_per_pixel = 1; 
    myfb_info->fix.smem_len = 1024; // 显存大小

    /* D. 关键:申请 DMA 显存 (Write Combining 模式) */
    /* screen_base 是虚拟地址(给CPU写),phy_addr 是物理地址(给mmap用) */
    myfb_info->screen_base = dma_alloc_wc(NULL, 1024, &phy_addr, GFP_KERNEL);
    myfb_info->fix.smem_start = phy_addr; 
    myfb_info->fbops = &myfb_ops;

    /* E. 向内核注册 Framebuffer 设备 (/dev/fb0 生成) */
    register_framebuffer(myfb_info);

    /* F. 启动内核线程 */
    oled_buf = kmalloc(1024, GFP_KERNEL);
    oled_init(); // 硬件初始化
    oled_thread = kthread_run(oled_thread_func, NULL, "oled_kthread");

    return 0;
}

// Remove 函数:资源释放 (注意顺序)
static int spidev_remove(struct spi_device *spi)
{
    kthread_stop(oled_thread);         // 1. 先停线程
    unregister_framebuffer(myfb_info); // 2. 注销 FB 设备
    
    // 3. 释放 DMA 显存
    dma_free_wc(NULL, myfb_info->fix.smem_len, myfb_info->screen_base, 
                myfb_info->fix.smem_start);
                
    framebuffer_release(myfb_info);    // 4. 释放结构体
    kfree(oled_buf);
    gpiod_put(dc_gpio);
    return 0;
}

/* 驱动匹配表 */
static const struct of_device_id spidev_dt_ids[] = {
    { .compatible = "100ask,oled" },
    {},
};

/* SPI 驱动结构体 */
static struct spi_driver spidev_spi_driver = {
    .driver = {
        .name = "100ask_spi_oled_drv",
        .of_match_table = spidev_dt_ids,
    },
    .probe = spidev_probe,
    .remove = spidev_remove,
};

2. 核心问题解析

2.1 为什么要使用内核线程 (kthread)?

在代码中,oled_thread_func 被设计为一个死循环的内核线程。

原因分析:

  1. 解耦"绘制"与"刷新"
    • 应用层视角:用户程序(如 Qt)只负责往显存(Framebuffer Memory)里填充数据。标准的 Framebuffer 机制通常不强制要求应用层每画一个点就通知驱动一次。应用层认为自己只是在操作内存。
    • 硬件视角:OLED 屏幕不会自动读取内存,它需要驱动程序主动通过 SPI 发送指令和数据才能更新显示。
    • 解决方案:内核线程充当了"搬运工"。它在后台独立运行,不断地从 Framebuffer 内存中读取最新数据,刷新到 OLED 上。这样应用层不需要关心 SPI 通信的细节,也不需要等待 SPI 传输完成,实现了非阻塞的高效绘图。
  2. 处理耗时的格式转换
    • Framebuffer 的数据是水平排列的(Byte 0 代表第一行前8个像素)。
    • SSD1306 OLED 的显存是垂直排列的(Byte 0 代表第一列前8个像素)。
    • 驱动必须进行繁重的位运算 (Bit manipulation)来转换格式。如果在应用层调用 write 时同步执行这个转换,会极大地占用应用程序的时间片,导致系统响应变慢。放在内核线程中执行,可以利用操作系统的调度机制,在后台完成这一繁重任务。

2.2 DMA (dma_alloc_wc) 在这里起什么作用?

代码中使用了 dma_alloc_wc 来分配 Framebuffer 的内存:

c 复制代码
myfb_info->screen_base = dma_alloc_wc(NULL, len, &phy_addr, GFP_KERNEL);

这里的"DMA"主要指内存分配方式,而非指 SPI 控制器的 DMA 传输(尽管 SPI 控制器内部可能也会用 DMA,但那是另一回事)。

作用解析:

  1. 物理地址连续性

    • Framebuffer 驱动通常支持 mmap 系统调用,允许用户空间直接映射显存。
    • dma_alloc_wc (Coherent DMA memory allocator) 保证分配到的内存是物理地址连续 的。这是构建 Framebuffer 供用户空间映射的基础条件,普通的 kmalloc 在大块内存上可能无法保证物理连续性或对齐要求。
  2. Write Combining (WC) 缓存策略

    • 注意后缀 _wc 代表 Write Combining
    • 由非缓存 (Uncached):太慢,每次写内存都直接访问 RAM。
    • 全缓存 (Cached):有数据一致性问题(Cache Coherency),CPU Cache 里的数据可能还没写到 RAM,DMA 就开始搬运了(虽然本例是 CPU 搬运,但在其他场景下很重要)。
    • 写合并 (Write Combining):这是专门为显存设计的策略。它允许 CPU 将多次小的写入操作(比如画一个像素)先在缓冲区合并,攒够一个突发长度后一次性写入 RAM。这极大地提高了绘图效率,同时避免了全缓存带来的复杂一致性维护。

3. 代码逻辑详注

3.1 驱动入口:Probe 初始化

这是驱动生命的起点,完成了从软件到硬件的所有准备。

c 复制代码
static int spidev_probe(struct spi_device *spi)
{
    // ... [GPIO 初始化略] ...

    /* -------------------------------------------------------
     * 1. Framebuffer 核心结构体分配与设置
     * ------------------------------------------------------- */
    myfb_info = framebuffer_alloc(0, NULL);

    /* 设置屏幕参数:分辨率 128x64,位深 1 bit (单色) */
    myfb_info->var.xres = 128;
    myfb_info->var.yres = 64;
    myfb_info->var.bits_per_pixel = 1; 

    /* 计算显存大小:128 * 64 * 1 / 8 = 1024 字节 */
    myfb_info->fix.smem_len = ...; 

    /* -------------------------------------------------------
     * 2. 分配"显存" (DMA Memory)
     * ------------------------------------------------------- */
    /* * 关键点:这里申请了一块物理连续的内存。
     * screen_base: 虚拟地址,内核线程和 CPU 通过它写入数据。
     * phy_addr: 物理地址,虽然本驱动没直接用,但对 mmap 至关重要。
     */
    myfb_info->screen_base = dma_alloc_wc(NULL, len, &phy_addr, GFP_KERNEL);
    myfb_info->fix.smem_start = phy_addr; 

    /* -------------------------------------------------------
     * 3. 注册 Framebuffer
     * ------------------------------------------------------- */
    /* 注册后,生成 /dev/fbX 设备节点,应用层可以开始画图了 */
    register_framebuffer(myfb_info);

    /* -------------------------------------------------------
     * 4. 启动内核线程
     * ------------------------------------------------------- */
    /* 申请临时缓存 oled_buf,用于存放转换后的数据 */
    oled_buf = kmalloc(1024, GFP_KERNEL);
    
    /* 硬件初始化 */
    oled_init(); 

    /* 启动线程,开始死循环刷新 */
    oled_thread = kthread_run(oled_thread_func, NULL, "oled_kthead");

    return 0;
}

3.2 核心引擎:内核线程函数 (oled_thread_func)

这是驱动的心脏,负责解决"光栅扫描"与"页寻址"的冲突。

c 复制代码
static int oled_thread_func(void *param)
{
    unsigned char *fb = myfb_info->screen_base; // 指向 Framebuffer 显存 (源数据)
    // ... 变量定义 ...

    while (!kthread_should_stop()) // 只要不卸载驱动,就一直运行
    {
        /* -------------------------------------------------------
         * 第一步:格式转换 (Raster -> Page)
         * ------------------------------------------------------- */
        /* * 目标:将 128x64 的横向位流,转换为 SSD1306 需要的纵向字节流。
         * SSD1306 将屏幕分为 8 页 (Page 0-7),每页高度 8 像素。
         */
        k = 0;
        for (i = 0; i < 8; i++) // 遍历 8 个 Page
        {
            // 获取当前 Page 对应的 Framebuffer 中的 8 行数据地址
            for (line = 0; line < 8; line++)
                p[line] = &fb[i*128 + line * 16];
            
            // 遍历一页中的 128 列
            for (j = 0; j < 16; j++) // 外层循环优化,按块处理
            {
                // ... 读取数据到 data 数组 ...

                // 核心位操作:构造 8 个纵向字节
                for (bit = 0; bit < 8; bit++)
                {
                    // 这是一个"转置"操作:
                    // 取出 8 行数据的第 bit 位,拼凑成一个字节
                    byte =  (((data[0]>>bit) & 1) << 0) | // 第0行 -> bit 0
                            (((data[1]>>bit) & 1) << 1) | // 第1行 -> bit 1
                            ...
                            (((data[7]>>bit) & 1) << 7);  // 第7行 -> bit 7

                    oled_buf[k++] = byte; // 存入转换后缓冲区
                }
            }
        }

        /* -------------------------------------------------------
         * 第二步:SPI 发送
         * ------------------------------------------------------- */
        /* 将转换好的数据 (oled_buf) 通过 SPI 发送给 OLED 控制器 */
        for (i = 0; i < 8; i++)
        {
            OLED_DIsp_Set_Pos(0, i); // 设置 OLED 显存坐标 (Page i)
            oled_set_dc_pin(1);      // Data 模式
            spi_write_datas(&oled_buf[i*128], 128); // 发送一整页
        }
        
        /* -------------------------------------------------------
         * 第三步:帧率控制
         * ------------------------------------------------------- */
        /* 休眠以释放 CPU。注意:HZ 导致帧率较低 (1秒1帧),实际项目应改为 msleep */
        schedule_timeout_interruptible(HZ);
    }
    return 0;
}

3.3 驱动卸载:Cleanup

严格按照初始化的逆序释放资源,防止内存泄漏或内核崩溃。

c 复制代码
static int spidev_remove(struct spi_device *spi)
{
    // 1. 先停止线程,不再访问内存
    kthread_stop(oled_thread);
    kfree(oled_buf);
    
    // 2. 反注册 Framebuffer
    unregister_framebuffer(myfb_info);

    // 3. 释放 DMA 显存 (对应 dma_alloc_wc)
    dma_free_wc(NULL, ..., myfb_info->screen_base, ...);

    // 4. 释放结构体
    framebuffer_release(myfb_info);
    
    // ... 其他释放 ...
    return 0;
}

4. 学习总结与知识点提炼

  1. Framebuffer 驱动的本质
    • 为内核申请一段内存 (dma_alloc_wc)。
    • 填充 fb_info 结构体告诉内核这段内存的属性(分辨率、位深)。
    • 用户空间看到的只是一个文件 /dev/fb0,对其读写就是操作这段内存。
  2. 软硬差异的适配
    • 当硬件显存结构(OLED 页模式)与软件标准(Framebuffer 线性模式)不一致时,驱动程序必须充当"翻译官"。
    • 这种翻译通常涉及复杂的位运算,计算量大,适合放在后台线程处理。
  3. 并发与同步
    • 本驱动利用了内核线程 kthread 来实现异步刷新。
    • 虽然本例未加锁,但在生产环境中,如果 ioctlthread 同时操作 SPI 总线,应该使用 Mutex 互斥锁来保护临界区。
  4. DMA 内存分配
    • dma_alloc_wc 是嵌入式显存分配的标准姿势,既保证物理连续(方便硬件或 mmap),又利用 Write Combining 提升了 CPU 写屏性能。
相关推荐
三伏5222 小时前
stm32f103系列手册IIC笔记
笔记·stm32·嵌入式硬件
安庆平.Я2 小时前
STM32——MPU(内存保护)
stm32·单片机·嵌入式硬件·mpu
TEC_INO4 小时前
stm32_12:RFID-RC522项目
stm32·单片机·嵌入式硬件
独处东汉4 小时前
AI辅助Stm32l031项目开发调试完成与总结
人工智能·stm32·嵌入式硬件
YouEmbedded4 小时前
解码WIFI模块与IoT云平台
stm32·微信小程序·wifi模块(esp8266)·iot云平台接·生态建立
__万波__4 小时前
STM32L475基于HAL库封装串口打印模块
stm32·单片机·嵌入式硬件
YouEmbedded4 小时前
解码MQTT协议与DHT11传感器
stm32·mqtt协议·dht11温湿度传感器
lingzhilab4 小时前
零知IDE——零知标准板+INA219电流传感器的锂电池智能充放电监测系统
ide·stm32·单片机
F1331689295716 小时前
5G矿山车载监控终端山河矿卡定位监控终端
stm32·单片机·嵌入式硬件·5g·51单片机·硬件工程