ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零驱动ILI9341:树莓派SPI显示协议与帧缓冲优化实战

从零驱动ILI9341:树莓派SPI显示协议与帧缓冲优化实战 简介面向嵌入式开发者与电子爱好者的ILI9341驱动源码针对TFT液晶显示控制场景解决屏幕初始化、数据写入与图形显示等常见问题。压缩包体积小巧仅1个C语言源文件大小2KB无冗余工程文件适合快速阅读与移植到Arduino、Raspberry Pi等常见平台。目前已有233人学习是LCD驱动入门与调试的高频参考资料。源码中可完整查看控制器初始化流程、SPI/8位并行接口时序、清屏函数、像素/矩形绘制及字符显示实现覆盖从底层寄存器操作到上层绘图API的关键代码。结合ILI9341芯片16.7M色、多接口支持等特性这份精简驱动既能帮助理解显示控制器工作原理也可作为实际项目的基础模板减少重复造轮子对正在学习嵌入式显示或需要快速点亮屏幕的工程师具有直接参考价值。1. 从一块国产屏学会 SPI 显示协议的通用套路ILI9341 是一块 320×240 分辨率的 TFT LCD 驱动芯片在树莓派、ESP32、STM32 甚至国产单片机项目里几乎成了标配屏。很多人第一次拿到这块屏第一反应是找现成库、跑 demo屏幕亮了就觉得自己已经会了结果一换接线、一换主板画面就开始花屏、白屏、偏色最后只能对着屏幕发愣。这个标题ili9341_的真正价值不在于带你点亮一块屏而在于让你理解 SPI 显示设备从初始化到刷新的一整条链路寄存器时序、引脚初始化、像素格式转换、帧缓冲策略。把这些搞明白换任何驱动 IC 都只需要查手册换命令字核心套路是不变的。针对树莓派场景官方 fbtft 框架提供了现成驱动模块但对没有经验的人而言真正难的不是加载模块而是改对设备树、调对参数、诊断白屏。本文将以树莓派为主线从硬件初始化、裸寄存器驱动到官方 fbtft 接入逐层打通。2. 树莓派 SPI 接线与 ILI9341 初始化序列解析2.1 引脚映射别只看 CS/SCLK/MOSIRST 和 DC 才是翻车重灾区树莓派 40 pin 接口上硬件 SPI0 对应 GPIO8CE0、GPIO9MISO、GPIO10MOSI、GPIO11SCLK。ILI9341 通常只需要 SPI 写MISO 只用于部分型号的回读命令可以悬空。接线是基本功但绝大多数初始化失败都不是 SPI 时序问题而是 RST 和 DC 控制引脚没接对或时序没给够。DCData/Command引脚决定当前 SPI 字节是命令还是数据。ILI9341 的命令字本身是 8 位带参命令后面会跟若干数据字节DC 电平在每字节传输前就要摆好。RST 引脚则负责硬件复位复位时序不对会导致初始化序列全部无效表现就是白屏或花屏。以树莓派 GPIO 直连为例我常用的分配方式为DC 用 GPIO25RST 用 GPIO24背光 BL 用 GPIO18。CE0 接片选SCLK 接时钟MOSI 接数据。树莓派 3B/4B/Zero 2W 的 SPI0 引脚位置均一致但注意树莓派 5 的 SPI0 引脚位置发生了变化GPIO0/1 变成 I2CSPI 改到 GPIO8-11 但复用关系有差异需要先确认dtoverlayspi0-1cs是否与板卡版本匹配。2.2 初始化序列把这组命令烧进记忆里ILI9341 的上电初始化是一组固定命令按手册顺序发送软复位 → 关闭显示 → 设置电源控制 → 设置 VCOM → 设置时序 → 开显示。以最经典的 0xCF 到 0x26 序列为例这是中景园等多家常规模组厂家出厂推荐的配置适用于绝大多数 2.2 寸 2.4 寸模组void ili9341_init(void) { // 硬件复位拉低 RST 至少 10ms再拉高等待 120ms gpio_write(RST_PIN, 0); delay_ms(10); gpio_write(RST_PIN, 1); delay_ms(120); // 软件复位 write_cmd(0x01); delay_ms(5); // 关闭显示 write_cmd(0x28); // 电源控制 A驱动能力相关寄存器地址在 0xCF 之后连续写入 3 个参数 write_cmd(0xCF); write_data(0x00); write_data(0xC1); write_data(0x30); // 电源控制 BVGH/VGL 泵电压设置 write_cmd(0xED); write_data(0x64); write_data(0x03); write_data(0x12); write_data(0x81); // 驱动时序控制 AGate 驱动时序、非重叠周期 write_cmd(0xE8); write_data(0x85); write_data(0x10); write_data(0x7A); // 驱动时序控制 B扫描模式与时钟分频 write_cmd(0xEA); write_data(0x00); write_data(0x00); // 电源控制 1GVDD 电压等级影响伽马与对比度 write_cmd(0xC0); write_data(0x23); // 电源控制 2VCOM 负压等级 write_cmd(0xC1); write_data(0x10); // VCOM 控制 1VCOMH/VCOML 电压调节 write_cmd(0xC5); write_data(0x3E); write_data(0x28); // VCOM 控制 2VCOM 偏移补偿 write_cmd(0xC7); write_data(0x86); // 内存访问控制BGR 顺序 上下左右扫描方向 write_cmd(0x36); write_data(0x48); // 像素格式0x55 代表 16bit/pixel (RGB565) write_cmd(0x3A); write_data(0x55); // 帧率控制0x00 表示正常模式0x1B 对应 60Hz内部时钟分频 write_cmd(0xB1); write_data(0x00); write_data(0x1B); // 显示功能控制关闭横向反转、关闭纵向反转 write_cmd(0xB6); write_data(0x08); write_data(0x82); write_data(0x27); // 伽马正校正自定义曲线的 15 个梯度值 write_cmd(0xF2); write_data(0x00); // 伽马曲线选择选择厂家预设曲线 1 write_cmd(0x26); write_data(0x01); // 退出睡眠模式必须等待 120ms write_cmd(0x11); delay_ms(120); // 开显示 write_cmd(0x29); }这段代码的逻辑要点有三处。0xCF开头的三字节参数控制内部 Charge Pump 的升压增益写错会直接导致屏亮不起来0x36的0x48二进制为0100 1000bit5 置位表示 BGR 顺序bit6 置位表示行列交换这一字节负责解决镜像和横竖屏问题0x3A设置0x55即 RGB565这是与 framebuffer 配置保持一致的硬性前提。2.2.1 写命令与写数据的底层时序差异写命令和写数据在 SPI 层面完全一样差只差在 DC 引脚的电平。写命令时 DC 拉低写数据时 DC 拉高。这看起来简单但有一个隐藏坑树莓派 spi-dev 的SPI_IOC_MESSAGE和write()都不会自动控制 DC 引脚你必须自己在用户态操作 GPIO。常见的原生写法是static void write_cmd(uint8_t cmd) { gpio_write(DC_PIN, 0); // DC 0标记为命令 spi_write(cmd, 1); // 通过 spidev 发送 1 字节 } static void write_data(uint8_t data) { gpio_write(DC_PIN, 1); // DC 1标记为数据 spi_write(data, 1); }这种逐字节切换 DC 的方式在树莓派上可以跑但帧率很难看因为每发一个字节就要做一次 GPIO write 和一次 SPI syscall用户态与内核态的开销叠加起来刷一帧 320×240 的 RGB565 数据150KB可能要上百毫秒。更高效的方案是把同类数据拼成 buffer先固定 DC 电平再用一次write()发送整包数据后面第 5 章会展开。2.3 时序验证白屏时先查这四件事屏幕完全不亮或白屏时不要先怀疑代码逻辑。用示波器或逻辑分析仪抓 RST、DC、SCLK、MOSI 四根线依次确认RST 是否拉高后稳定在高电平0x11退出睡眠命令后是否真有 120ms 以上延时SCLK 是否因树莓派 SPI 默认极性与 ILI9341 的时序要求不匹配而采样错误DC 在命令字节和数据字节之间是否有毛刺。如果没有示波器可以在初始化序列的首尾各加一个 GPIO 翻转点用gpio readall测量引脚电平变化间接判断初始化是否走到末尾。3. 从裸寄存器到用户态用 spidev 写一个无依赖的 ILI9341 驱动3.1 打开设备与设置 SPI 模式CPOL/CPHA 必须为 Mode 0树莓派上启用硬件 SPI 后设备节点为/dev/spidev0.0。用户态驱动要走 POSIX 标准路径open()打开设备ioctl()设置模式、位宽、最大速率然后write()发送数据。#include fcntl.h #include linux/spi/spidev.h #include sys/ioctl.h #include unistd.h int spi_fd; uint8_t spi_mode SPI_MODE_0; // CPOL0, CPHA0 uint8_t spi_bits 8; // 每字节 8 bit uint32_t spi_speed 32000000; // 32 MHz用 40MHz 时部分模组会因走线过长导致数据错误 void spi_init(void) { spi_fd open(/dev/spidev0.0, O_RDWR); if (spi_fd 0) { perror(spidev open failed); exit(1); } ioctl(spi_fd, SPI_IOC_WR_MODE, spi_mode); ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, spi_bits); ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, spi_speed); // 验证写入是否成功读回模式如果内核不支持会返回错误 ioctl(spi_fd, SPI_IOC_RD_MODE, spi_mode); }SPI 模式选择是新手最容易踩的坑。ILI9341 手册标称支持 Mode 0 和 Mode 3但大多数模组厂家推荐的参考驱动都是 Mode 0。如果错配为 Mode 1 或 Mode 2屏幕可能出现雪花点、颜色错乱甚至完全无响应。树莓派内核默认的 spi-bcm2835 驱动在设备树未显式指定时会用spi-mode属性决定Mode 0 是安全选择。3.2 显存映射在 Raspberry Pi 的物理内存里开一块帧缓冲用户态驱动不依赖任何库Linux 内核自带的 spidev 和/dev/mem就够用。将树莓派的 800×480 或者我们需要的 320×240 帧缓冲直接映射到物理内存连续区域可以避免用户态堆分配导致的内存碎片关键是避免write()大块数据时 copy_from_user 带来的性能损耗。#include sys/mman.h #include fcntl.h #define FB_WIDTH 320 #define FB_HEIGHT 240 #define FB_SIZE (FB_WIDTH * FB_HEIGHT * 2) // RGB565 每个像素 2 字节 uint16_t *framebuffer; void fb_init(void) { int mem_fd open(/dev/mem, O_RDWR | O_SYNC); // 树莓派 GPU 从物理地址 0x3F000000 (Pi 2/3) 或 0xFE000000 (Pi 4/5) 开始 // 后续 0x200000 起有 1MB 空间可用于 mailbox 帧缓冲分配 // 这里用 mmap 做物理连续区映射再在偏移处建立显存 framebuffer mmap(NULL, FB_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, mem_fd, 0x3F200000); if (framebuffer MAP_FAILED) { perror(mmap framebuffer failed); exit(1); } }实际工程中更干净的方案是向 GPU 的 mailbox 属性通道请求一块物理连续显存拿到 bus address 后映射到用户空间。这里为保持示例简洁直接映射到 GPIO 外设地址之后的一段空闲物理区。/dev/mem方案在树莓派 4 上需要dtoverlaydisable-bcm2835等配置调整生产环境请改用 mailbox 或直接使用树莓派官方 libdrm 分配 dumb buffer。3.3 刷帧函数矩形窗口 像素写入ILI9341 支持窗口裁剪可以只刷新屏幕的一部分。刷帧时先设定窗口地址再连续写入数据。窗口命令为0x2A列地址、0x2B行地址、0x2C内存写。void ili9341_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { write_cmd(0x2A); // 列地址设置 write_data(x0 8); // 起始列高 8 位 write_data(x0 0xFF); // 起始列低 8 位 write_data(x1 8); // 结束列高 8 位 write_data(x1 0xFF); // 结束列低 8 位 write_cmd(0x2B); // 行地址设置 write_data(y0 8); write_data(y0 0xFF); write_data(y1 8); write_data(y1 0xFF); write_cmd(0x2C); // 内存写命令下面连续的数据会依次填充窗口 } void ili9341_blit(const uint16_t *pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { ili9341_set_window(x, y, x w - 1, y h - 1); gpio_write(DC_PIN, 1); // 全部数据DC 保持高 write(spi_fd, pixels, w * h * sizeof(uint16_t)); // 一次性发送 }窗口机制的效率收益很大。做 UI 时只更新脏矩形比全屏刷新少传 70% 以上的数据。但要留意0x2C命令发送后像素数据的字节序ILI9341 在 RGB565 模式下默认接收高字节在前。如果直接往 SPI 里丢一个 uint16_t 数组具体行为和 C 字节序完全无关因为 SPI 是串行 MSB-first所以0x1234会先发0x12再发0x34。如果发现颜色里红色与蓝色互换多半就是高低位反了。4. 树莓派官方驱动路线用 fbtft 把 ILI9341 变成 /dev/fb14.1 fbtft 是什么不是新驱动而是一套框架在/boot/config.txt里添加dtoverlayili9341:rotate90后重启系统会多出/dev/fb1这个能力来自内核里已经合入的fbtft 框架。fbtftFrame Buffer Tiny Driver Framework是一套针对小型 SPI/NTFT 屏幕的内核驱动框架把写命令、写数据、DMA 传输、帧缓冲注册封装成通用层具体 IC 驱动只需实现set_var、set_gamma、blank等几个回调函数。选择 fbtft 路线的核心优势是不需要自己维护用户态显示栈。framebuffer 设备天然支持fbdev接口fbiopen后可以配合mmap()直接操作显存fbset 调整分辨率甚至跑轻量级 GUI 框架。缺点是帧率受限于内核 framebuffer 的软件渲染能力在树莓派 Zero 上全屏刷新只有 20fps 左右但用来显示静态信息或低频 UI 完全够用。4.2 启用 fbtft 的最小配置dtparam 与 dtoverlay 的层级关系在/boot/config.txt树莓派 5 上是/boot/firmware/config.txt中需要先启用底层 SPI 控制器再加载具体屏幕的 overlay# 启用 SPI0片选 0通信速率上限 62.5MHz树莓派 SPI0 时钟源为 250MHz, 最小分频 4 dtparamspion # 加载 ILI9341 驱动rotate 指定顺时针旋转角度fps 指定刷新上限 dtoverlayili9341:rotate90:swapxy1:fps30参数解读rotate只能是 0/90/180/270它驱动 fbtft 的set_var对0x36寄存器写入不同的 MV/BGR/ML 位swapxy在旋转 90 度多出必须设置为 1否则显示区域会超出屏幕边界fps限制帧率避免 SPI 突发传输过猛导致 CPU 占用过高。注意树莓派官方仓库的 fbtft overlay 名称为ili9341但不同版本内核的 overlay 参数集合略不同先执行dtoverlay -h ili9341确认当前内核支持哪些参数。4.3 帧缓冲设备的多屏管理fb0 是 HDMIfb1 是 TFT树莓派默认有两个显示设备/dev/fb0通常绑定 HDMI 或 DSI 屏幕/dev/fb1是 fbtft 注册的虚拟 framebuffer。直接往/dev/fb1写入像素不会影响 HDMI 输出两者独立。用下面的脚本可以快速验证屏幕是否工作# 确认 fb1 存在及分辨率 fbset -fb /dev/fb1 # 用 dd 写入一段随机像素会看到屏幕出现噪点 dd if/dev/urandom of/dev/fb1 bs1024 count100 # 将 fb0 的内容缩放到 320x240 后同步到 fb1需要 fbv 工具 sudo apt install fbv -y fbv -f -r 90 /dev/fb0 | dd of/dev/fb1dd if/dev/urandom of/dev/fb1是快速验证 framebuffer 是否可写的最直接手段。如果屏幕出现彩色噪点说明 framebuffer 到 SPI 的链路已经打通如果依旧全白回查dmesg | grep fbtft看驱动初始化日志。dmesg里常见的关键行包含fb1: ili9341 frame buffer和fbtft_framebuffer_alloc: fbtft_probe没有这两行说明设备树没有正确匹配到芯片。4.4 把控制台输出到 TFT内核启动日志也能显示在屏上屏幕驱动起来以后把 Linux 控制台重定向到 fb1 是一个很常见的玩法on在/boot/config.txt加一行即可fbconmap:1 fbconrotate:1fbconmap:1表示控制台绑定 fb1fbconrotate:1对应 90 度顺时针旋转。这种方式下开机日志会直接滚在 TFT 屏上调试无头树莓派时免去了频繁接 HDMI 的麻烦。5. 实现 30fps 帧率的三个技巧DMA、双缓冲与脏矩形裁剪5.1 从 8fps 到 30fps瓶颈不在 SPI 带宽而在 syscall 次数在树莓派 4B 上SPI 主频 32MHz、320×240×2 字节 150KB 的帧数据理论传输时间是 37.5ms换算成帧率上限约 26fps但这只是纯 SPI 传输时间。实际驱动中如果每字节都做一次write()帧率只有个位数。因为每次write()都要经过 VFS 层到spidev驱动的完整 ioctl 路径。优化关键在两点合并小写入和固定 DC 引脚。刷一帧完整图像只需要两次传输一次发送 0x2C 命令1 字节一次发送全部像素数据150KB。中间的 GPIO 操作只在开头执行一次。Linux 内核的 spidev 驱动对单次传输大 buffer 有优化路径会尝试用 DMA 而不是 PIO 模式这是帧率从 8 到 30 的最大功臣。// 合并发送用一个 spi_ioc_transfer 数组描述整帧传输 struct spi_ioc_transfer tr[2]; // 第一段发送 0x2C 命令DC 置低 tr[0].tx_buf (unsigned long)cmd_memwrite; tr[0].len 1; tr[0].delay_usecs 0; // 第二段发送像素数据DC 置高 tr[1].tx_buf (unsigned long)framebuffer; tr[1].len FB_SIZE; tr[1].delay_usecs 0; gpio_write(DC_PIN, 0); // 命令段 DC 低电平 ioctl(spi_fd, SPI_IOC_MESSAGE(2), tr); gpio_write(DC_PIN, 1); // 数据段 DC 高电平这段代码的SPI_IOC_MESSAGE(2)参数是IOC_MESSAGE宏要求的传输段个数内核会按顺序执行 tr[0] 和 tr[1]但注意DC 引脚在两段之间无法自动切换所以这里用一个变通先把 DC 置低发命令再把 DC 置高发数据中间 GPIO 切换的几微秒损耗远小于多次 syscall。5.2 双缓冲让绘制和传输互不等待framebuffer 只有一个时ili9341_blit()发送数据的过程中如果上层 UI 线程也在写同一块显存就会出现画面撕裂或半帧花屏。双缓冲的核心是两块轮换一块正在发送给屏幕另一块让上层绘制。uint16_t *buf_active; // 当前正在传输的缓冲 uint16_t *buf_back; // 当前可写的缓冲 void swap_buffers(void) { // 等待 SPI 传输完成ioctl 是同步的执行到这说明上一帧已发完 uint16_t *tmp buf_active; buf_active buf_back; buf_back tmp; } void render_loop(void) { // 在 buf_back 绘制下一帧 draw_ui(buf_back); // 启动传输 ili9341_blit(buf_active, 0, 0, 320, 240); // 绘制和传输并行传输 37ms 期间 UI 线程可继续画下一帧到 buf_back draw_ui(buf_back); // 完成后再换 swap_buffers(); }这里的误区是把双缓冲等同于性能翻倍。事实上由于 SPI 是全双工但 TFT 只收不发传输是纯等待双缓冲的收益主要在 CPU 利用率而非帧率。实测在树莓派 Zero 2W 上双缓冲 单帧 DMA 的帧率约 24fps比单缓冲只高 2-3fps但撕裂现象完全消失。5.3 脏矩形先做像素比对再决定发哪些窗口UI 场景下大部分刷新是局部的。一个时钟应用每秒只有时分秒三个数字区域在变化全屏刷新浪费带宽。脏矩形算法维护两个最小外接矩形坐标每次与上一帧做像素对比找出发生变化的区域再调用ili9341_set_window()只传送那块区域。typedef struct { uint16_t x0, y0, x1, y1; int valid; } dirty_rect_t; void compute_dirty_rect(dirty_rect_t *dirty, const uint16_t *old_fb, const uint16_t *new_fb) { dirty-valid 0; for (int y 0; y FB_HEIGHT; y) { for (int x 0; x FB_WIDTH; x) { // 找出第一个发生变化的像素 if (old_fb[y * FB_WIDTH x] ! new_fb[y * FB_WIDTH x]) { if (!dirty-valid) { dirty-x0 dirty-x1 x; dirty-y0 dirty-y1 y; dirty-valid 1; } else { // 扩大边界更新最小 x / 最大 x / 最小 y / 最大 y if (x dirty-x0) dirty-x0 x; if (x dirty-x1) dirty-x1 x; if (y dirty-y0) dirty-y0 y; if (y dirty-y1) dirty-y1 y; } } } } }这版线性扫描每帧要跑 76800 次比较树莓派主频够快但纯 Python 会卡到不可用C 语言大概 2ms 内完成。合适的使用场景是帧率要求高但只需局部变化的 UI 框架比如 LVGL 在 framebuffer 模式下本身就提供脏区域回调直接对接比自研更靠谱。嵌入式 UI 框架中 LVGL 的lv_disp_flush_cb和屏幕的脏矩形刷新天然配套没有必要自造轮子。5.4 实际验证用正弦波形刷屏测试刷新率刷屏测试的核心不是人眼看滚动而是统计从ioctl返回到下一次开始绘制的时间差。用clock_gettime(CLOCK_MONOTONIC)打点#include time.h double now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec * 1000.0 ts.tv_nsec / 1e6; } void fps_test(void) { double last now_ms(); int frames 0; while (frames 120) { // 用正弦波形生成渐变画面 for (int y 0; y 240; y) { for (int x 0; x 320; x) { uint8_t v 128 127 * sin((double)(x frames * 2) / 32.0); buf_back[y * 320 x] (v 8) | v; // 单色灰阶 } } ili9341_blit(buf_back, 0, 0, 320, 240); frames; double now now_ms(); if (now - last 1000.0) { printf(fps: %d\n, frames); frames 0; last now; } } }sin()的调用开销在编译优化 O2 下完全可以接受但如果你用-O0测试会发现帧率掉 5fps 不只这是因为纯软件渲染时间反超了 SPI 传输时间。最后的 int 型frames统计必须放在if外面否则每秒归零后无法累积计数。真实环境中刷屏速率与 SPI 时钟、CPU 频率及是否启用 DMA 直接相关实测参数汇总如下场景SPI 速率传输方式实测帧率逐字节 GPIO 切换16MHzPIO6-8 fps单帧大 buffer 一次 write32MHzDMA24-28 fps双缓冲 全屏 DMA62MHzDMA30 fps局部脏矩形刷新 50x5032MHzDMA200 fps 等效最后一行的 200fps 等效是指单次矩形刷新耗时约 5ms如果只刷一个小区域动画流畅度完全取决于 UI 逻辑自身。本文还有配套的精品资源点击获取
返回列表