RK3568 FrameBuffer模式SPI LCD驱动开发
这周终于把手上这块RK3568核心板上的SPI小屏点亮了,前前后后折腾了差不多五天,中间踩了不少坑,包括白屏、颜色错乱、系统启动阶段刷新率太低、SPI时序总在不经意间出问题。趁着印象还热乎,把从方案选型、设备树配置到驱动代码实现、调试优化的整个过程整理出来,给正在做RK3568或类似平台SPI LCD驱动开发的朋友一个参考。
先交代一下背景:板子是RK3568,配的屏幕是常见的ST7789V控制器的1.3寸圆屏,分辨率240x240,RGB565颜色格式。这种小屏在HMI面板、桌面摆件、侧屏仪表这类场景里非常多,成本低,接线简单,不需要MIPI也不需要RGB888并口,只要四根SPI线就能出画面。而所谓FrameBuffer模式,就是让这块LCD在Linux下以/dev/fb0的形式暴露出来,上层不管是Qt还是纯C语言的自绘程序,往这块内存区域里写像素,驱动负责把内容刷到物理屏幕。这个方案比走DRM/KMS整套流程要轻量得多,在小分辨率屏幕上完全够用。
1. 方案选型:为什么选FrameBuffer而不是DRM/KMS
1.1 先搞明白FrameBuffer模式能解决什么问题
RK3568是一颗带GPU、带VPU的处理器,官方SDK默认的显示链路是DRM/KMS,正常接HDMI、eDP、MIPI DSI、RGB接口都没问题。但是当你想接一颗SPI总线的TFT小屏时,情况就变了。SPI屏本身带宽有限,如果硬塞到DRM框架里,你得写一个drm_panel驱动的同时还要处理drm_connector、drm_encoder、drm_display_mode这一堆对象,搞一圈下来发现大部分属性对这个240x240的小屏幕根本没有意义。
FrameBuffer模式的核心思路是绕过DRM的复杂抽象,直接用Linux内核里最经典的fbdev子系统。驱动只需要注册一个platform_driver,在probe里填充fb_info结构体,实现fb_ops里面的几个关键回调,比如fb_fillrect、fb_imageblit、fb_copyarea,然后把显存地址暴露给用户态。上层调用write()或者直接mmap这块显存,驱动在刷新时把这些像素数据通过SPI控制器发出去。
打个比方,DRM像是给大型企业做ERP系统,各种权限、审批、跨部门协作都给你安排得明明白白;FrameBuffer就是个体户的记账本,虽然功能少,但胜在简单直接,一眼看到底。
实际选型时要考虑的是:如果产品形态确定只需要一块SPI小屏,并且不打算在多个显示接口之间做热插拔切换,那FrameBuffer模式是性价比最高的方案。反过来,如果这颗RK3568以后可能要同时接HDMI和LVDS,那就别用FrameBuffer了,老老实实走DRM,把SPI屏作为panel挂到新的dimension框架里去。
1.2 几个备选方案的对比
在确定用FrameBuffer之前,我梳理了三条路线:
| 方案 | 工作量 | 系统集成度 | 适用场景 |
|---|---|---|---|
| 官方DRM + panel驱动 | 较大 | 高,与HDMI/MIPI统一管理 | 多屏并存、需要modetest等工具 |
| fbtft框架加载已支持屏 | 小 | 中,依赖内核配置 | 快速出prototype、验证屏幕 |
| 自研fbdev驱动 | 中等 | 较高,独立模块不干扰其他链路 | 商用产品、需要深度定制刷新逻辑 |
fbtft在早期内核里是很火的一套框架,它自带了许多常见屏的初始化序列,比如fb_ili9341、fb_st7789v、fb_ili9488等。但是在RK3568的SDK上我发现一个问题,SDK默认的内核配置里可能不会保留/drivers/video/fbdev路径下的fbtft相关编译选项,而且fbtft在内核里长期处于staging状态,接口变动比较频繁。如果你用的是Rockchip官方BSP内核,与其去把fbtft的Kconfig路径找出来加上,不如直接写一个自包含的fbdev驱动,依赖面最小,出问题也好排查。
最后我选择了自研fbdev驱动,Kconfig里只需依赖CONFIG_FB和CONFIG_SPI_ROCKCHIP,不再牵扯任何DRM相关的东西。
2. SPI LCD硬件的连接与信号工作原理
2.1 引脚定义与连接方式
ST7789V这一类SPI屏模组,对外引出的核心引脚一般就是GND、VCC、SCK、SDA(MOSI)、RES、DC、CS、BLK这八个。部分型号还有一个MISO脚用于读寄存器,不过刷屏场景基本用不到读操作,不接也没事。
RK3568的SPI控制器数量很足,6路SPI够用。我这里用的是SPI3,设备树里的节点是&spi3。硬件上我做了这样的连接:
- SCK -> GPIO3_B1 / SPI3_CLK
- SDA -> GPIO3_B0 / SPI3_MOSI
- CS -> GPIO3_C0 / SPI3_CS0
- DC -> GPIO0_C5(普通GPIO)
- RES -> GPIO0_C6(普通GPIO)
- BLK -> GPIO0_C7(可PWM调光)
这里有一个非常容易被忽略的点:DC脚一定要选一个在设备树里没有被复用成其他功能的GPIO,并且确认这个引脚没有被RK3568的IOMUX默认占用。我一开始选了GPIO3_C2,结果那个脚被复用成UART5的TX,导致DC信号一直拉不起来,屏幕怎么初始化都不出画面。后来改用GPIO0_C5,干净利落。
2.2 像素数据是怎么通过SPI传进屏幕的
ST7789V这类控制器内部维护一整块GRAM,尺寸和分辨率一一对应,240x240的屏,每个像素是16bit(RGB565),那一整帧数据就是2402402=115200字节。你通过SPI写像素数据的本质是:先把地址指针定位到GRAM里的某个坐标,然后连续写入颜色数据,控制器会自动把数据填进GRAM。
这里必须说清楚DC引脚的作用。SPI通信只分数据位,没有天然区分“这是命令”还是“这是数据”。屏幕使用DC这根线的电平来判断当前SPI总线上传输的是命令还是数据:DC为低时,字节被解释为控制命令;DC为高时,字节则写入GRAM或者作为命令参数。驱动里每一次操作前要先拉高或拉低DC,再拉起CS,完成一个事务后释放CS。
用240x240屏举例,如果你要刷一整帧RGB565数据,SPI上实际要发的比特数是115200*8=921600个bit。这里还没算命令前缀,因为直接连续写GRAM的话,只需要开头发一条RAMWR命令,之后全是数据。
2.3 带宽算一算,别对刷新率有不切实际的期待
RK3568的SPI控制器最高可以跑到大概50MHz,但实际要看PCB走线和屏模组支持的极限。为了稳妥,我把SPI时钟先配置成20MHz,也就是每秒能传20Mbit。按照上面的计算,一帧数据921600bit,理论上每秒最多能刷21帧左右。
这个数字很有参考价值:如果你用这个屏做视频播放,那帧率铁定不够;但如果只是显示仪表盘、状态图标、文字刷新这种低频场景,完全够用。我在实际测试中,纯CPU搬运刷新一帧静态图耗时大约50ms,逐行局部刷新小区域则基本无感。
3. RK3568设备树配置与内核编译
3.1 设备树节点编写
设备树里需要做的核心事情有三件:配置SPI控制器节点、配置GPIO作为DC/RES/BL、设置背光节点。下面是精简后的设备树片段,我直接把相关节点写在根节点下,然后通过&spi3和&pinctrl将它们关联起来。
&spi3 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi3_clk &spi3_miso &spi3_mosi &spi3_cs0>; max-freq = <20000000>; st7789v@0 { compatible = "myvendor,st7789v"; reg = <0>; spi-max-frequency = <20000000>; dc-gpios = <&gpio0 RK_PC5 GPIO_ACTIVE_HIGH>; reset-gpios = <&gpio0 RK_PC6 GPIO_ACTIVE_HIGH>; backlight-gpios = <&gpio0 RK_PC7 GPIO_ACTIVE_HIGH>; rotation = <0>; buswidth = <8>; bgr = <0>; }; };有两点需要特别注意。第一,spi3_miso这个pinctrl必须是存在的,即使你的屏没有MISO线,RK3568的SPI控制器在某些传输模式下会检测接收FIFO,缺失该引脚配置可能导致DMA传输挂死。我实测如果不配miso引脚,开启SPI DMA传输时偶尔会卡死。第二,st7789v@0这个子节点的reg必须和CS片选号对应,SPI3的CS0对应reg=0,如果接的是CS1则写成reg=1。
3.2 背光控制方式的选择
有些屏模组的BLK引脚可以直接接3.3V常亮,但产品上一般都需要调节亮度。RK3568提供了PWM控制器,最简单的方式是在设备树里加一个pwm-backlight节点。我用的是内核pwm-backlight的通用方案:
backlight: backlight { compatible = "pwm-backlight"; pwms = <&pwm5 0 1000000 0>; brightness-levels = <0 32 64 128 192 255>; default-brightness-level = <5>; status = "okay"; };注意pwm的频率设置。上面例子里period为1000000ns即1kHz,这个频率对大多数LED背光来说都够用,肉眼看不到频闪。如果你把频率设到几十Hz,亮度和调节会有明显跳动感。
3.3 内核配置选项
设备树准备好了,接着就是内核编译选项。在RK3568 SDK内核里需要确认以下配置:
- CONFIG_FB:必须开启,这是fbdev的核心
- CONFIG_FB_ROCKCHIP:如果SDK存在这个选项,要根据情况关闭,因为它可能和自研驱动争用fb注册接口
- CONFIG_SPI_ROCKCHIP:SPI控制器驱动
- CONFIG_BACKLIGHT_PWM:如果用了pwm-backlight
- CONFIG_FB_SYS_FILLRECT、CONFIG_FB_SYS_COPYAREA、CONFIG_FB_SYS_IMAGEBLIT、CONFIG_FB_SYS_FOPS:fbdev的软件加速和文件操作接口,这些一定要开,否则编译时会发现缺少依赖
还有一个容易被坑的地方是CONFIG_DRM_FBDEV_EMULATION。RK3568 SDK里DRM默认开启,它会创建一个虚拟的fbdev模拟层,如果你同时加载自研SPI LCD的fbdev驱动,两者会争抢/dev/fb0这个编号。我的做法是把DRM相关的fbdev emulation关闭,然后让自研驱动独占fb0。如果产品上还需要HDMI,那这个方案就行不通了,需要给自研驱动分配不同的fb编号,或者在DRM框架下注册为第二个connector。
4. Framebuffer驱动开发实战
4.1 驱动框架:自研驱动的核心结构
先给出这个驱动最主要的几个组成部分:platform_driver、fb_info、backlight操作、SPI写屏函数。整个驱动的生命周期是这样的:
- probe阶段:从设备树读GPIO、SPI配置,申请GPIO,初始化屏幕控制器,注册fb_info
- open/release阶段:处理用户的打开与关闭,管理背光开关
- fb_ops阶段:实现fillrect、copyarea、imageblit以及最关键的fb_fbdev_write操作
- remove阶段:注销fb_info,释放GPIO和内存
关键代码如下框所示,我做了删减但保留了核心逻辑。
static int st7789v_probe(struct spi_device *spi) { struct fb_info *info; struct st7789v_par *par; int ret; par = kzalloc(sizeof(*par), GFP_KERNEL); ... par->dc_gpio = devm_gpiod_get(&spi->dev, "dc", GPIOD_OUT_LOW); par->reset_gpio = devm_gpiod_get(&spi->dev, "reset", GPIOD_OUT_HIGH); par->bl_gpio = devm_gpiod_get_optional(&spi->dev, "backlight", GPIOD_OUT_HIGH); spi->mode = SPI_MODE_0; spi->bits_per_word = 8; spi_setup(spi); /* 初始化控制器序列 */ st7789v_init_lcd(spi, par); info = framebuffer_alloc(sizeof(struct st7789v_par), &spi->dev); ... info->fbops = &st7789v_fbops; info->screen_base = (char *)par->vmem; info->screen_size = ST7789V_WIDTH * ST7789V_HEIGHT * 2; ret = register_framebuffer(info); ... }这个结构看起来简单,但有几个粗糙的地方要注意。screen_base指向的是一块内核通过kmalloc申请的连续物理内存,对于240x240 RGB565的屏幕只需要115200字节,kmalloc完全够用。如果你的屏分辨率更高,比如800x480,那就要用dma_alloc_coherent分配连续且适合DMA传输的内存,否则SPI DMA刷屏时会出cache一致性或者物理不连续的问题。
4.2 fb_ops的实现要点
fb_ops是fbdev驱动的灵魂。系统底层和图形库调用的所有画点、画线、填充操作最终都会分发到这几个函数里。对于驱动开发,最土但最稳妥的写法是完全依赖内核的软加速函数,也就是将fb_sys_fillrect、fb_sys_copyarea、fb_sys_imageblit和fb_sys_read、fb_sys_write直接填入fb_ops结构体。
实际写屏动作发生在哪里?这取决于你选择的刷新策略。最简单的实现是每次任何图形操作完成后都整屏发送一次,但这种做法在240x240分辨率下也能接受,只是帧率低。稍微优化一点的思路是维护一个dirty区域,只有在目标区域发生变化时才把对应矩形发送到屏幕。这个阶段我使用了fbtft里刷屏的思路,实现了一个st7789v_update_display(start, end)函数,它只刷新从start到end之间的行数据。
关键点在这里:用户在fb上通过mmap直接修改显存时,fbdev本身并不知道内容变了。这就是为什么很多早期fbdev驱动在画面更新时会有残影,因为没有任何机制去通知驱动刷新。一个可行的补救是周期性用内核定时器触发全刷或者脏矩形扫描。但在Linux fbdev框架下,更常见的做法是让上层应用写入后主动调用ioctl的FBIOPAN_DISPLAY或者通过c2p/fb_ops的pan_display回调来触发刷新。
我最终在驱动里保留了三种刷新路径:
| 触发方式 | 实现策略 | 适用场景 |
|---|---|---|
| fb_fillrect/fb_imageblit等操作后自动刷新 | 直接将脏矩形发送到屏幕 | Qt或DMA-BUF方式绘图 |
| mmap直接写显存后调用ioctl | 通过FBIOPAN_DISPLAY触发刷新 | 自研绘图程序 |
| 内核定时器定时全刷 | 每200ms检查一次脏标记 | 调试期兜底 |
4.3 初始化序列是最大变量
SPI LCD驱动最难的部分不是框架,而是屏幕控制器的初始化序列。不同厂家、不同批次甚至同型号不同版本的屏,初始化寄存器配置都可能不一样。ST7789V是一个相对规范的产品,官方手册有余辉抑制、伽马校正、显示方向、颜色深度等一堆寄存器。
一段能用的初始化序列一般包含:软件复位、退出睡眠模式、设置像素格式为RGB565、设置显存访问控制字(控制扫描方向和RGB/BGR顺序)、设置伽马曲线、开启显示。
这里举一个我实际验证过的关键片段:
static const uint8_t st7789v_init_cmds[] = { 0x01, /* SWRESET */ 0x11, /* SLPOUT */ 0x3A, 0x05, /* COLMOD, 16bit RGB565 */ 0x36, 0x00, /* MADCTL, 正常方向,RGB 顺序取决于屏模组 */ 0xB2, 0x0C, 0x0C, 0x00, 0x33, 0x33, /* PORCH 设置 */ 0xB7, 0x35, 0xBB, 0x19, /* VCOM */ 0xC0, 0x2C, /* LCMCTRL */ 0xC2, 0x01, 0xC3, 0x12, 0xC4, 0x20, 0xC6, 0x0F, 0xD0, 0xA4, 0xA1, 0xE0, 0xD0, 0x04, 0x0D, 0x11, 0x13, 0x2B, 0x3F, 0x54, 0x4C, 0x18, 0x0D, 0x0B, 0x1F, 0x23, /* 正极性伽马 */ 0xE1, 0xD0, 0x04, 0x0C, 0x11, 0x13, 0x2C, 0x3F, 0x44, 0x51, 0x2F, 0x1F, 0x1F, 0x20, 0x23, /* 负极性伽马 */ 0x21, /* INVON 反色显示 */ 0x29, /* DISPON */ };排错的时候我发现一个现象:同一个初始化序列,在别人家STM32上正常,放到RK3568上就花屏。原因多半是SPI时序不同。STM32驱动往往在每个字节之间穿插一些延时,而RK3568的CPU主频高,SPI传输连续,屏幕控制器某些寄存器在连续写入时反而会吃不到参数。我的处理方式是对于涉及多字节参数的寄存器,在命令后加入1ms左右的延时,尤其是sleepout之后必须至少等待120ms,否则后续设置全部无效。
4.4 SPI传输方式的选型:PIO还是DMA
RK3568的SPI支持两种数据传输路径:CPU手动读写FIFO的PIO模式,以及利用DMA引擎自动搬运的DMA模式。PIO模式代码简单,对系统依赖少,缺点是在高速传输时CPU占用很高。刷一张115200字节的图,PIO模式下CPU几乎全程参与,这在做HMI时会干扰其他任务的实时性。
DMA模式下,驱动程序只需要把显存地址告诉DMA控制器,然后等待传输完成中断。这样CPU在刷屏的大部分时间里是空闲的。代价是代码复杂,需要处理dma_map_single或提前用dma_alloc_coherent分配内存,还要写complete回调来唤醒等待队列。
我实测的对比数字可以给你参考:
| 传输方式 | 刷一帧耗时 (20MHz SPI) | CPU占用 |
|---|---|---|
| PIO模式 | 约55ms | 接近100% |
| DMA模式 | 约52ms | 不到10% |
传输耗时差异不大的原因在于SPI速率本身是瓶颈。但CPU占用差异非常显著,所以产品化驱动必须上DMA。
如果选择DMA,需要在内核里确认CONFIG_DMA_ENGINE和CONFIG_SPI_ROCKCHIP_DMA这两个配置项是打开的。Rockchip SPI的DMA支持有对应的device tree属性,通常只要在SPI控制器节点里配置dmas和dma-names即可:
dmas = <&dmac0 3>, <&dmac0 4>; dma-names = "tx", "rx";5. 调试、性能优化与常见问题
5.1 白屏问题定位的一线经验
白屏是SPI LCD驱动里最高发的问题。白屏意味着背光亮了,但控制器没有进入显示状态,或者GRAM里的数据全是0xFF。定位方法按顺序做:
第一步,用示波器抓DC和CS的波形。如果DC信号在发送命令时没有正确拉低,后续所有寄存器写操作都会被控制器当成显示数据,初始化必然失败。抓DC波形能一眼看出问题。
第二步,检查复位时序。屏幕的RES引脚要先拉低至少10us,然后再拉高,拉高后等待120ms再发送初始化命令。有些驱动里为了省事没有在probe路径里保证这个时序,导致控制器还处于异常状态。
第三步,怀疑初始化序列不匹配。最直接的办法是找一个已知能亮的STM32工程,把它的初始化数组抄过来对比。我前面给出的序列不一定适配所有模组,特别是某些厂家把偏压电压、VCOM值改了,直接用原厂序列会更稳。
第四步,查看内核打印。在st7789v_init_lcd里加dev_info,把每个命令和参数打印出来,比对数据手册上的要求,排查是否某条命令的长度写错。
5.2 颜色错乱是RGB/BGR顺序问题
屏幕显示出现红蓝互换,九成是MADCTL寄存器里的RGB位没配对。ST7789V的0x36寄存器第3位控制RGB/BGR顺序。0x00是RGB,0x08是BGR。有些模组的排线把R和B交换了,驱动里必须设置成BGR才能正确显示。我的处理是在设备树里增加一个bgr属性,驱动解析这个属性后决定往0x36寄存器写入0x00还是0x08。这样硬件改版时不需要改代码,只动设备树就行。
另外,颜色错乱也可能来自fb_info里设置的红绿蓝偏移量。如果你用fbdev自带的真彩格式,必须在probe里正确设置:
info->var.red.offset = 11; info->var.red.length = 5; info->var.green.offset = 5; info->var.green.length = 6; info->var.blue.offset = 0; info->var.blue.length = 5;这段代码看着简单,漏掉任何一个offset或length,颜色就会全部错位。
5.3 刷新闪烁与撕裂的规避
SPI LCD没有硬件VSync信号,所以不存在严格意义上的tearing同步。出现闪烁或者撕裂,根本原因是上层在写显存的同时,驱动正在通过SPI搬运同一块数据。解决撕裂的方法只有一个:加锁。
我在驱动里给显存加了一个spinlock,但实战后发现spinlock粒度太大容易卡刷新流程。更好的做法是用双缓冲,也就是在fb_info里注册两个逻辑屏,一个给用户写,一个给驱动刷,写和刷之间通过ioctl切换。不过双缓冲对内存要求更高,240x240的双缓冲也就230KB,完全可以接受。
如果你不想改上层程序,那就退而求其次:确保刷屏期间用户态不能写,这个用semaphore就能实现。代价是上层如果高频更新会偶尔等待。实际效果在交互界面上基本无感。
5.4 性能优化:把脏矩形优化到底
最影响刷新体验的其实不是刷屏速率快慢,而是要不要把所有内容都重新传输。我在这个项目里做了行列级的脏矩形跟踪。每次fb_fillrect或者通过write操作更新时,记录更新的矩形范围。DMA传输时只需要把这部分行数据发出去。240x240分辨率下,假如每次只更新一个20x20的小图标,SPI上实际传输的数据量只有原来的几十分之一,刷新感觉自然流畅得多。
这里要小心坐标换算。fb的坐标原点和屏幕的扫描方向可能不一样,尤其是把屏转成竖屏或者增加rotation之后,脏矩形的行列和GRAM的地址关系就会变化。虚线实现时最好先在驱动里做一个通用的坐标变换,把任何rotation下用户画的矩形都换算成GRAM里的一段连续地址区间,这样才能安全地做局部刷新。
5.5 驱动加载时序与开机动画的配合
如果不想让系统启动过程停留在黑屏阶段,需要让这个fbdev驱动尽早初始化,然后配合内核的fbcon把console输出也显示在屏上。做法是在内核命令行参数里加上fbcon=map:0或直接设置console=tty1。这样内核启动时日志会直接打到屏上,连续刷屏的感觉和单片机开发很像,调试特别方便。
不过,fbcon显示在内核启动早期会拖慢启动速度,每打印一行日志就得触发一次SPI刷新。我的建议是调试阶段开启fbcon,产品阶段把console重定向到串口,SPI屏只留给业务应用使用。
5.6 常见问题速查表
| 现象 | 直接原因 | 解决路径 |
|---|---|---|
| 白屏 | 复位时序不对/初始化失败 | 抓RES波形,确保复位后延时120ms |
| 花屏 | SPI速率过高/传输宽度错 | 降低max-freq,检查buswidth |
| 颜色红蓝互换 | RGB/BGR位不对 | 修改0x36寄存器值 |
| 屏幕亮但无内容 | DC信号未配置 | 确认DC GPIO被正确拉低/拉高 |
| 刷新时画面撕裂 | 显存读写并发 | 加锁或双缓冲 |
| DMA传输卡死 | SPI控制器DMA配置不完整 | 配置rx dma通道,或回退PIO |
| fb0被DRM占用 | 内核开启了fbdev emulation | 关闭CONFIG_DRM_FBDEV_EMULATION或分配差异化fb编号 |
6. 个人心得:这类驱动开发的核心收获
这个项目做下来,我个人最大的体会是:SPI LCD驱动本身不难,难的是对硬件时序和设备树的理解。如果你已经玩过单片机SPI驱动屏幕,那在Linux下做fbdev驱动,你缺的主要不是C语言能力,而是对Linux显示子系统分工的认识。驱动只是把数据搬运到屏幕,真正复杂的是上层图形库如何与fb设备协作。把fb_info结构体里的字段、fb_ops回调、SPI DMA路径理清楚,整个链路就通了。
最后再分享一个小技巧:如果遇到屏幕初始化后某些区域的颜色不正常,先别急着怀疑驱动和寄存器,试着把屏幕翻转180度再看,有时候只是MADCTL里的扫描方向反了,导致GRAM地址顶到了显示区的非对齐区域。这种事我踩过两次,每次排查都花了半天才醒悟过来。