RK3568 SPI小屏FrameBuffer驱动实战:从设备树到性能优化
2026/9/17 5:53:45 网站建设 项目流程

前阵子手头有个RK3568的小项目,需要带一块1.54寸、240x240分辨率的SPI接口LCD,用来做状态显示,同时不想占用主显示资源。我选了SPI屏,驱动直接走Linux标准的FrameBuffer框架。整个过程从硬件接线、设备树配置到驱动代码、性能优化和排障,踩了不少坑。这篇就把完整路径整理出来,给后面做同类屏幕的人留个参考。

先说明一下背景:RK3568本身主显示链路都是DRM框架,RGB、MIPI DSI、LVDS这些屏幕都有现成驱动模型,但SPI小屏属于低带宽、小分辨率的应用场景,用DRM那套反而费劲。FrameBuffer模式简单直观,应用层直接/dev/fb0读写就能显示内容,而且内核fbtft框架已经cover了一大批SPI屏幕驱动。对嵌入式快速落地来说,这是省事且稳定的方案。

1. 项目背景与方案选型拆解

1.1 为什么是RK3568加SPI小屏

RK3568这颗SoC的算力做UI交互其实绰绰有余,但不代表所有场景都要上复杂显示方案。实际项目里有一类需求特别典型:设备只需要一个状态窗口,显示几个数字、跑马灯、二维码、当前工作模式,屏幕尺寸在1寸到2寸之间,分辨率最高也就是240x320这类级别。

这类屏幕用RGB并口或MIPI DSI有点杀鸡用牛刀,因为:

  • 引脚数量多,RGB并口至少要16根数据线加控制线,对小项目布线不友好;
  • MIPI DSI在低分辨率屏上成本高,屏幕器件本身也贵;
  • 这类屏幕的刷新率要求其实不高,状态显示场景每秒10帧以内都能接受。

SPI接口LCD只需要SCK、MOSI、CS、DC、RESET、BLK这么几根线,四线或六线就能驱动,任何一个GPIO口多的ARM平台都能带。RK3568的SPI控制器数量充足,频率也能跑到几十MHz,跑240x240这种小屏完全够用。

1.2 FrameBuffer方案为什么比DRM省心

既然屏幕是SPI小屏,驱动模型的选择有两条路:一条是接入DRM/KMS,一条是走传统FrameBuffer。

DRM框架理论上更现代化,但要做的东西就多了:connector、encoder、crtc、plane四个对象要串起来,还要和RK3568本身的VOP显示链路匹配调度。一个SPI屏本身没有VSYNC中断,没有自刷新机制,硬塞进DRM的atomic提交流程里,调试成本非常高。

FrameBuffer方案简单直接:

  • 内核分配一块显存缓冲区,应用层通过mmap直接操作;
  • 驱动只要实现基本的fb_ops,把显存内容通过SPI搬到屏幕GRAM里;
  • 不需要管DRM里那一堆对象模型和状态管理。

而且RK3568的另一个显示控制器VOP只用来看主屏幕或HDMI输出,SPI屏的FrameBuffer驱动完全可以用独立的spi master + fbdev子系统,两者互不干扰。这在嵌入式Linux里是老牌做法,文档多、社区有人踩过坑、工具链成熟。

从实际进度来算,DRM方案光打通链路可能就要两三天,FrameBuffer方案一天内就能点亮屏幕并显示出彩条测试图案。

1.3 整体数据传输链路

从应用层到屏幕硬件,数据流大概是这样的:

应用层写/dev/fb0,实际是写进FrameBuffer驱动分配的内存缓冲区;驱动通过某种机制感知到内容变化后,把这块内存里的像素数据按屏幕要求打包,通过SPI接口发送给LCD控制器;控制器把数据写进自己的GRAM,再定时刷新到液晶面板上。

在Linux系统中,这个过程被抽象为:

用户程序 -> write/mmap -> fb_ops -> SPI传输 -> LCD控制器GRAM ->面板

其中用户程序可以只往一个固定的内存地址里填颜色值,对屏幕刷新过程完全无感。这也是FrameBuffer最方便的地方:上层的Qt、LVGL、直接画点程序都能跑。

2. 硬件设计要点与屏体参数确认

2.1 常见SPI LCD控制器和引脚定义

市面上1寸到2寸的SPI屏,常见控制芯号有ST7789V、ST7735S、ILI9341、GC9A01这几类。ST7789V在240x240和240x320屏上非常常见;ILI9341一般出现在2.4寸320x240屏上;GC9A01则是圆形屏常用。

这类屏幕接口大同小异,引脚一般包括:

  • SCL/SCK:SPI时钟;
  • SDA/MOSI:命令和数据输入;
  • DC(也叫RS、A0):区分命令还是数据,高电平为数据,低电平为命令;
  • CS:片选;
  • RST:复位,低电平有效;
  • BLK/BL:背光控制;
  • VCC、GND:电源和地。

注意有一部分屏幕没有独立的DC引脚,而是通过SPI 9-bit模式传输,用第一个bit来区分命令数据。这种屏驱动方式稍有不同,初期调试建议优先选有DC引脚的屏,兼容性更好。

2.2 电平匹配、背光复位这些细节不能省

RK3568的GPIO和SPI引脚都是3.3V逻辑,绝大多数SPI屏模块也是3.3V逻辑,可以直接怼。但有些便宜屏模块上带了5V背光供电或者全模块5V兼容设计,这种情况我一般会在供电和信号线上加电平转换或至少串电阻,防止模数转换接口电平不确定导致屏幕花屏或者芯片发热。

背光控制最省事的做法是直接用GPIO拉高,如果要调节亮度,最好接到RK3568的PWM引脚上,通过pwm-backlight驱动控制。如果屏幕走的是模块排针,BLK引脚串一个小电阻再接3.3V或背光电源,不要直接悬空。

复位脚的处理也要注意。很多屏模块复位脚有上拉,但芯片复位时序要求电源稳定后至少拉低10us以上再释放,否则初始化序列容易在错误状态下执行。我会把RST接到GPIO上由驱动控制,而不是简单拉高,这样上电时序可控,排查问题也方便。

2.3 确认屏体参数和初始化序列来源

写驱动之前,最关键的是找对屏幕数据手册和初始化序列。同一个控制芯片,不同模组厂给出的初始化序列可能不一样,屏厂提供的初始化代码一般是一串SPI写寄存器操作,比如ST7789V常见的:

  • 0x01:软复位,之后延时150ms;
  • 0x36:设置扫描方向和RGB/BGR顺序;
  • 0x3A:设置像素格式为16bit/18bit;
  • 0x2A、0x2B:设置列地址、行地址范围;
  • 0x2C:开始写显存数据(RAMWR);
  • 0x21:反色显示INVON。

很多人屏幕点不亮,问题往往出在初始化序列不对,或者时序延时不够。这里我的习惯是先用逻辑分析仪抓一遍手头屏幕在厂家例程里跑出来的初始化波形,确认每个寄存器值真的发了出去,再在Linux驱动里对照着写。没有逻辑分析仪的情况下,也可以先跑通用初始化序列,点亮后再逐个精简。

3. 设备树配置与SPI控制器的联动

3.1 RK3568 SPI节点的设备树写法

RK3568有多个SPI控制器,在设备树里的节点路径一般是spi0、spi1……,具体用哪一路要看你的原理图。我手头这块底板把SPI小屏接在了SPI1的CS0上,设备树大致长这样:

&spi1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi1m1_cs0 &spi1m1_pins>; spi-max-frequency = <24000000>; lcd_st7789: st7789@0 { compatible = "vendor,spi-st7789"; reg = <0>; spi-max-frequency = <24000000>; dc-gpios = <&gpio3 RK_PB3 GPIO_ACTIVE_HIGH>; reset-gpios = <&gpio3 RK_PB4 GPIO_ACTIVE_LOW>; backlight-gpios = <&gpio3 RK_PB5 GPIO_ACTIVE_HIGH>; rotation = <0>; bpp = <16>; }; };

这里的pinctrl引用了两个:spi1m1_cs0和spi1m1_pins,这是RK3568里SPI1第二组复用的引脚定义。数据手册上每个SPI控制器都有m0、m1等多组引脚复用,必须和实际原理图对应,否则SPI总线不工作,内核挂载spi设备时表现为总线无响应或者设备不存在。

3.2 硬件片选与软件片选怎么选

SPI片选有两种方式:硬件片选(hardware CS)和软件片选(software CS)。

硬件片选由SPI控制器自动控制CS引脚,传输开始时拉低,结束后拉高,CPU不需要干预,时序稳定。设备树里直接用默认的pinctrl片选即可。

软件片选是把CS做成GPIO,在驱动里绕过了控制器的硬件片选逻辑,自己通过gpiod_set_value控制。这种方式适合CS引脚不在SPI控制器标准片选复用上、或者一个SPI总线上挂了多个器件需要灵活切换的场景。

对驱动开发来说,能用硬件片选尽量用硬件片选。我自己刚开始为了省事想用普通GPIO模拟整个SPI,结果发现时钟和片选时序很难保证稳定,刷屏稍快就出现命令漏发或错发。换成SPI控制器硬件片选后,问题立刻消失。软件片选只推荐给那些读时序特别宽松、速率要求不高的屏。

在设备树里如果想用软件片选,可以配置cs-gpios属性:

&spi1 { status = "okay"; cs-gpios = <&gpio3 RK_PB6 GPIO_ACTIVE_LOW>; ... };

但要留意:启用cs-gpios后,内核会让SPI控制器放弃硬件CS控制;如果你的时序敏感,还需要自己在驱动里确认CS拉低时机。

3.3 背光、电源GPIO和pinctrl复用冲突排查

设备树里除了SPI节点,背光控制也很重要。如果直接用GPIO拉背光,可以在LCD节点里配backlight-gpios,驱动probe时获取这个GPIO并拉高。如果要用PWM调节亮度,则单独配一个pwm-backlight节点:

backlight: pwm-backlight { compatible = "pwm-backlight"; pwms = <&pwm5 0 20000 0>; brightness-levels = <0 10 30 60 100 150 200 255>; default-brightness-level = <5>; status = "okay"; };

然后把pwm5的复用改为pwm,而不是gpio。

RK3568的引脚复用很容易踩坑。比如我一开始背光GPIO选了GPIO3_B5,但这个引脚默认复用功能是UART2_TX,结果GPIO控制完全无效。查了半天才发现同一个引脚既被pinctrl配置成了uart功能,又被GPIO子系统请求,虽然GPIO请求成功,但引脚实际由外设功能接管。遇到这种情况,要么换引脚,要么在设备树里把对应pinctrl-0设置为gpio。

举例来说,如果背光引脚复用冲突,可以在节点里显式声明pinctrl为gpio:

backlight-gpio { compatible = "gpio-leds"; pinctrl-names = "default"; pinctrl-0 = <&backlight_pin>; status = "okay"; };

再用pinctrl把它设成gpio功能:

pinctrl: pinctrl { backlight_pin: backlight-pin { rockchip,pins = <3 RK_PB5 0 &pcfg_pull_none>; }; };

注意这里的rockchip,pins里的function选0,表示GPIO复用,否则又会被其他外设吃掉。

4. FrameBuffer驱动的核心实现

4.1 自研驱动还是基于fbtft改造

内核staging目录下有个fbtft框架,封装好了ST7735S、ST7789V、ILI9341等一批常见SPI屏驱动,理论上我们可以直接在menuconfig里打开CONFIG_FB_TFT_ST7789,在设备树里加个fbtft节点,系统起来后就会自动创建fb设备。

但实际用下来,fbtft适合快速验证,真正量产或做复杂功能时我更倾向基于它改造,或者干脆自研一个精简驱动。原因有几个:

  • fbtft的刷屏方式相对简单,整屏刷新效率不算高;
  • 平台相关的背光、复位、电源管理自定义不够灵活;
  • 有些屏幕的初始化序列和fbtft内置模板不一致,需要自己改。

这次的项目里,我选择自研驱动,但参考了fbtft的框架思路。核心结构就是spi_driver加fb_info,代码量不大,可控性强。

4.2 驱动框架:从spi_driver到fb_info

自研驱动的主体结构分成三块:

  1. SPI驱动注册:用spi_driver匹配设备树里的compatible;
  2. probe回调:解析GPIO、分配fb_info、申请显存、下发屏幕初始化序列;
  3. fb_ops实现:提供fillrect、copyarea、imageblit、blank等操作。

probe里最核心的代码是分配并注册fb_info:

static int st7789_probe(struct spi_device *spi) { struct fb_info *info; struct st7789_par *par; int ret; par = devm_kzalloc(&spi->dev, sizeof(*par), GFP_KERNEL); if (!par) return -ENOMEM; par->spi = spi; spi_set_drvdata(spi, par); par->dc = devm_gpiod_get(&spi->dev, "dc", GPIOD_OUT_LOW); par->reset = devm_gpiod_get(&spi->dev, "reset", GPIOD_OUT_HIGH); par->backlight = devm_gpiod_get(&spi->dev, "backlight", GPIOD_OUT_HIGH); /* 复位时序 */ gpiod_set_value(par->reset, 1); msleep(50); gpiod_set_value(par->reset, 0); msleep(50); gpiod_set_value(par->reset, 1); msleep(120); info = framebuffer_alloc(sizeof(*par), &spi->dev); if (!info) return -ENOMEM; info->par = par; par->info = info; /* 设置固定参数和可变参数 */ strcpy(info->fix.id, "st7789"); info->fix.type = FB_TYPE_PACKED_PIXELS; info->fix.visual = FB_VISUAL_TRUECOLOR; info->fix.line_length = ST7789_WIDTH * 2; info->fix.smem_len = ST7789_WIDTH * ST7789_HEIGHT * 2; info->var.xres = ST7789_WIDTH; info->var.yres = ST7789_HEIGHT; info->var.xres_virtual = ST7789_WIDTH; info->var.yres_virtual = ST7789_HEIGHT; info->var.bits_per_pixel = 16; 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; info->fbops = &st7789_fbops; info->screen_buffer = dma_alloc_coherent(&spi->dev, info->fix.smem_len, &par->dma_addr, GFP_KERNEL); if (!info->screen_buffer) { framebuffer_release(info); return -ENOMEM; } st7789_init_sequence(par); st7789_write_display_on(par); ret = register_framebuffer(info); if (ret < 0) { dma_free_coherent(&spi->dev, info->fix.smem_len, info->screen_buffer, par->dma_addr); framebuffer_release(info); return ret; } return 0; }

有一点容易忽视:screen_buffer是给应用层mmap和写操作直接使用的缓冲区,建议用dma_alloc_coherent分配,这样如果你后面想用SPI DMA传输,显存物理地址是连续的,可以直接丢给DMA引擎。如果只是用PIO模式跑SPI,用kmalloc分配也能工作,但大数据量传输时性能明显不如DMA。

4.3 fb_ops里必须实现的几个关键API

fb_ops是FrameBuffer驱动和应用层交互的核心。对SPI屏来说,最值得关注的是这几个操作:

static struct fb_ops st7789_fbops = { .owner = THIS_MODULE, .fb_setcolreg = st7789_setcolreg, .fb_blank = st7789_blank, .fb_fillrect = sys_fillrect, .fb_copyarea = sys_copyarea, .fb_imageblit = sys_imageblit, .fb_mmap = st7789_mmap, .fb_deferred_io = &st7789_defio, };

这里使用sys_fillrect、sys_copyarea、sys_imageblit而不是cfb_*系列,原因是fb_deferred_io模式下系统默认会用shmem管理的可换页内存,这些操作直接作用在用户内存映射上,配合后续dirty_page机制做延迟刷新。

fb_mmap的实现也要特殊处理。如果使用fb_deferred_io,驱动需要把显存区域映射成带faulthandler的VMA,每次页面被写入时记录dirty状态:

static int st7789_mmap(struct fb_info *info, struct vm_area_struct *vma) { return fb_deferred_io_mmap(info, vma); }

如果不使用deferred_io,就直接把分配的dma buffer映射给用户:

vma->vm_page_prot = pgprot_writecombine(vma->vm_page_prot); return dma_mmap_coherent(info->dev, vma, info->screen_buffer, par->dma_addr, info->fix.smem_len);

从开发效率看,我推荐先用deferred_io省心,后面优化性能时再改成自定义刷新策略。

4.4 SPI传输函数:命令与数据的封装

SPI屏通信的关键就是DC引脚区分命令和数据。驱动里我会封装两个函数:

static int st7789_write_cmd(struct st7789_par *par, u8 cmd) { gpiod_set_value(par->dc, 0); return spi_write(par->spi, &cmd, 1); } static int st7789_write_data(struct st7789_par *par, const u8 *data, size_t len) { gpiod_set_value(par->dc, 1); return spi_write(par->spi, data, len); }

注意写命令时DC要先拉低,再触发SPI传输;写数据前要把DC拉高。这里有一个隐藏问题:如果连续写多条命令,每条之间都要切换DC,GPIO翻转很快,会有一定的CPU开销。更好的做法是最终驱动里用SPI单独bit或特殊模式,但简单场景下这个封装完全够用。

屏幕初始化序列其实就是一堆命令加数据。以ST7789V 240x240屏为例,关键初始化片段如下:

static void st7789_init_sequence(struct st7789_par *par) { st7789_write_cmd(par, 0x01); /* SWRESET */ msleep(150); st7789_write_cmd(par, 0x11); /* SLPOUT */ msleep(120); st7789_write_cmd(par, 0x36); /* MADCTL */ st7789_write_data(par, (u8[]){0x00}, 1); st7789_write_cmd(par, 0x3A); /* COLMOD */ st7789_write_data(par, (u8[]){0x55}, 1); /* 16bit/pixel */ st7789_write_cmd(par, 0x2A); /* CASET */ st7789_write_data(par, (u8[]){0x00, 0x00, 0x00, 0xEF}, 4); st7789_write_cmd(par, 0x2B); /* RASET */ st7789_write_data(par, (u8[]){0x00, 0x00, 0x00, 0xEF}, 4); st7789_write_cmd(par, 0x21); /* INVON */ st7789_write_cmd(par, 0x13); /* NORON */ st7789_write_cmd(par, 0x29); /* DISPON */ msleep(20); }

这里的0x00到0xEF就是屏体240x240分辨率对应的行/列地址范围。如果换了320x240的ILI9341屏,这个范围就要改成0x00到0x13F和0x00到0x1DF。初始化序列错了最常见的表现就是花屏或部分屏幕不显示。

4.5 刷屏实现:从整屏刷新到deferred_io

刷屏是FrameBuffer驱动里最影响体验的部分。最粗暴的做法是:只要有人写显存,就把整个屏幕的240x240x2字节全部刷一遍。在小屏上这样做没问题,但要考虑效率。

我采用fb_deferred_io的懒刷新机制,核心思想是:应用层写入显存时先不急着刷屏,等一小段时间(比如20ms)或者等到驱动的刷新回调被触发,再把脏页数据统一搬给屏幕。

内核提供了fb_deferred_io_init和fb_deferred_io_cleanup接口,配合一个定时器进行聚合。刷屏回调如下:

static void st7789_fb_dirty(struct fb_info *info, u32 x, u32 y, u32 width, u32 height) { struct st7789_par *par = info->par; u32 line_len = info->fix.line_length; u32 offset = y * line_len + x * 2; u8 *buf = info->screen_buffer + offset; size_t len = width * height * 2; st7789_set_window(par, x, y, x + width - 1, y + height - 1); st7789_write_cmd(par, 0x2C); st7789_write_data(par, buf, len); }

注意st7789_set_window就是发CASET和RASET命令。这部分做得好,后面局部刷新就顺理成章。

如果不使用deferred_io,也可以跑一个内核线程或者用mod_timer定时全屏刷,但那样刷新范围和时机都不够精细,屏幕显示内容变化频繁时会出现撕裂感。我强烈建议新写的SPI屏驱动都从deferred_io起步。

4.6 与开机Logo和内核启动显示的衔接

FrameBuffer驱动注册成功后,内核里的fbcon可以在启动阶段直接把printk信息输出到SPI小屏上。这个功能本身不用额外配置,只要fbcon模块加载时检测到fb0就会尝试切过去。如果不想让内核信息干扰应用显示,可以改内核启动参数:

fbcon=map:1

或者:

fbcon=nodefer

这里的小技巧是:如果你希望SPI屏显示开机动画,同时又不想让内核日志疯狂刷屏拖慢启动,配合quiet参数使用,显示效果会干净很多。

如果是uboot阶段就想显示logo,那工作量要更早介入:uboot里也要有对应的SPI LCD驱动和fb框架,并且保证uboot和内核的初始化序列一致,否则会出现uboot显示正常、跳转到内核后黑屏几秒再亮的现象。这个衔接问题属于bootloader侧的事,本文不展开,但如果你发现开机logo阶段和内核阶段屏幕显示风格不一致,大概率是两者初始化参数没对齐。

5. 性能优化思路与实际效果

5.1 SPI速率、DMA传输和刷屏帧率实测

很多人以为SPI屏只能跑到几帧每秒,其实主要看SPI时钟和刷屏策略。240x240x16bit一帧数据量是115200字节,加上命令开销,假设SPI时钟跑20MHz,理论传输时间约46ms,帧率上限大约21fps;如果能稳定跑到40MHz,理论传输时间约23ms,帧率上限可以到43fps。

但这只是纯理论值,实际还要算上GPIO翻转DC的开销、CPU搬运数据的开销、以及deferred_io定时器等系统调度损耗。我在这块板子上实测:

  • 20MHz + PIO模式:整屏刷新大约60ms一帧,约16fps;
  • 40MHz + PIO模式:整屏刷新大约45ms一帧,约22fps;
  • 40MHz + DMA模式:整屏刷新大约35ms一帧,约28fps。

PIO模式到40MHz时CPU占用已经很高,因为每次spi_write都要反复读写SPI FIFO。这个时候最有效的优化就是让SPI控制器走DMA,把CPU释放出来。

RK3568控制器自带的DMA传输并不难配置。如果驱动里用的是spi_sync_transfer,并且传输缓冲区是dma_alloc_coherent分配的,我们可以实现can_dma回调,让spi核心自动选择DMA路径:

static bool st7789_spi_can_dma(struct spi_controller *ctlr, struct spi_device *spi, struct spi_transfer *xfer) { return xfer->len > 16; }

然后spi_register_driver前,给spi控制器或者spi设备设置相应的能力位。DMA不是银弹,短命令传输用DMA反而因为准备开销导致性能下降,所以我只在数据量大于16字节时启用DMA。

5.2 局部刷新、双缓冲与数据的坑

比起盲目整屏刷新,应用层场景里更实用的是局部刷新。状态显示界面通常只有某个区域在变化,比如温度数字、进度条。驱动实现了set_window后,应用层只要把脏矩形坐标通过自定义ioctl传给驱动,或者在deferred_io基础上自己维护回调和脏区域标记,就可以只刷新变化区域。

局部刷新的性能提升非常可观:

  • 刷新一个64x64的区域,数据量8192字节,20MHz下传输时间只有3.2ms;
  • 刷新一个128x64的区域,数据量16384字节,传输时间约6.5ms;
  • 整屏240x240,20MHz下传输时间约46ms。

对UI交互来说,局部刷新能轻松达到30fps以上的更新速率,肉眼几乎无延迟。

双缓冲主要是处理撕裂问题。SPI屏没有VSYNC同步信号,应用层画到一半被驱动刷走,画面就会出现撕裂。使用fb_deferred_io后,如果应用层连续写显存,刷新回调可能把半成品画面发出去。解决思路有两个:一是让应用层做双缓冲,画完再memcpy到fb0;二是驱动里维护一个内部shadow buffer,配合定时器只在空闲时刻刷屏。

颜色顺序是另一个大坑。ST7789V的GRAM默认有RGB和BGR两种排列模式,MADCTL寄存器里的RGB位决定颜色顺序。设备树里我配了16bit RGB565像素格式,但如果MADCTL没配好,屏幕显示颜色就会偏色,比如红色和蓝色互换。排查时在应用层画一个纯红块,然后用示波器或逻辑分析仪看发送数据,是最快的确认方法。

5.3 内核配置里不能少的几个选项

编译内核时,FrameBuffer相关配置项要特别注意。如果少了CONFIG_FB_DEVICE,/dev/fb0节点可能不会自动创建;如果少了CONFIG_FB_SYS_FILLRECT之类的辅助函数,驱动里使用sys_fillrect会链接失败。

我一般会打开以下配置项:

  • CONFIG_FB=y
  • CONFIG_FB_DEVICE=y
  • CONFIG_FB_SYS_FILLRECT=y
  • CONFIG_FB_SYS_COPYAREA=y
  • CONFIG_FB_SYS_IMAGEBLIT=y
  • CONFIG_FB_DEFERRED_IO=y
  • CONFIG_FB_BACKLIGHT=y

然后根据屏幕控制芯片选择是否有专门驱动。如果自研驱动,就在drivers/video/fbdev/Kconfig里加一个配置项,避免每次手动insmod。

6. 调试方法与常见问题排查实录

6.1 屏幕全白或全黑的定位步骤

屏幕全白:初始化序列执行了,但显示数据没有正确写进GRAM,或者GRAM被清成白色。这种情况优先查:

  1. 初始化序列是否完整,特别是SLPOUT后有没有给足延时;
  2. 检查DC引脚极性是否反了。如果命令和数据反了,屏幕大概率会呈现花白状态;
  3. 用spidev工具单独发几个命令,确认SPI总线本身没问题。

屏幕全黑:可能初始化没完成,或者背光没打开。先量背光引脚电压,再确认DISPON命令有没有下发,最后检查RST复位时序。

我在调试时常用一个最土但很有效的方法:在驱动probe完成后,往fb0写一个纯色全屏图案:

dd if=/dev/urandom of=/dev/fb0 bs=1024 count=100

如果屏幕出现随机雪花或彩条,说明驱动链路已经通了,问题仅在细节;如果还是黑屏,说明初始化或者SPI传输根本没走通。

6.2 花屏、偏色和显示区域不对的根源

花屏和偏色是SPI屏的家常便饭,常见原因按可能性排序:

  • MADCTL扫描方向设置错误:屏幕上下或左右颠倒,或者x/y方向互换;
  • GRAM行列地址偏移设置错误:比如屏幕实际从0列开始,但驱动从52列开始,显示内容就会整体偏移;
  • 像素格式不匹配:驱动发8bit数据,屏体期待18bit模式;
  • SPI时钟相位极性不对:导致第一个bit被吞掉,画面整体左移一个像素。

要确认SPI_CPOL和SPI_CPHA参数是否正确,可以看屏幕数据手册里SPI时序波形图。ST7789V通常工作在mode 0(CPOL=0,CPHA=0),但个别屏模块对时钟极性的定义有差别,我遇到过必须设成mode 3才能稳定显示的情况。这个只能逐个试,或者用逻辑分析仪对比厂家例程波形。

6.3 fb设备节点异常的操作排查

如果系统起来后没有/dev/fb0,先看内核日志:

dmesg | grep -i fb dmesg | grep -i st7789

常见情况有:

  • 设备树compatible不匹配,spi_driver的of_match_table没对应上;
  • SPI控制器节点status不是okay;
  • spi-max-frequency配置过高,控制器枚举失败;
  • 驱动probe中某个GPIO请求失败,中断了注册流程。

如果/dev/fb0存在但open失败,大概率是权限问题或者fb设备没有正确注册。确认权限:

ls -l /dev/fb0 chmod 666 /dev/fb0

有时候内核里fbcon会自动接管fb0,这会导致用户程序write时只更新屏幕但看不到内容。解决方法是启动参数里加fbcon=map:0或者干脆不注册fbcon。

6.4 常见问题排查速查表

现象可能原因排查手段
屏幕全黑、背光亮初始化序列未完成、DISPON未执行检查上电时序,加宽延时;用spidev裸发DISPON
屏幕全黑、背光灭背光GPIO配置错误或PWM未启动检查GPIO电压、pwm节点状态
花屏、乱码SPI速率过高、DC极性反、初始化序列错降频到1MHz测试;调DC极性;对照datasheet查序列
色彩偏色BGR/RGB位设置错修改MADCTL的RGB位测试
显示位置偏移CASET/RASET范围错误、帧偏移参数不对读屏手册确认分辨率对应行列范围
刷屏慢、卡顿PIO模式CPU占用高、整屏刷新过度开启DMA、改局部刷新
屏幕闪烁、撕裂刷新时机不当、应用层绘制与刷新冲突使用deferred_io聚合、双缓冲
/dev/fb0缺失设备树没配好、驱动probe失败查dmesg,确认bus总线和compatible

6.5 调试中我养成的三个习惯

第一,手边常备一根逻辑分析仪。SPI屏其实只有几根线,抓一次完整初始化序列就能看到命令是否正确、时序是否满足。很多看起来玄学的问题,抓波形后一眼就明白了。

第二,先用spidev把裸SPI读写跑通,再写FrameBuffer驱动。spidev用户态工具(比如spidev_test)可以手工发送任意字节,配合GPIO导出DC和RST,能在不加载屏驱动的情况下把屏幕点亮。这个步骤排错了,再进入驱动开发会顺畅得多。

第三,把初始化序列放在驱动里一个独立的数组里,通过sysfs或ioctl动态下发。调试阶段可以随时在应用层修改寄存器值,不用每次改动都重新编译内核模块。

7. 扩展玩法与个人体会

7.1 FrameBuffer上跑LVGL的轻量组合

SPI小屏应用层最常见的UI方案是LVGL。LVGL官方支持fbdev作为刷新后端,刷新回调里把脏矩形区域传给驱动,只更新局部。这里有个关键点:LVGL的flush_cb会告诉你要刷新的区域,不能简单直接整屏提交。

如果驱动已经实现了set_window局部刷新,LVGL的flush回调大致可以这样写:

static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { struct fb_info *info = open_fb(); u32 x = area->x1; u32 y = area->y1; u32 w = area->x2 - area->x1 + 1; u32 h = area->y2 - area->y1 + 1; /* 把颜色数据按RGB565写入fb的对应区域 */ fb_write_region(info, x, y, w, h, color_p); lv_disp_flush_ready(drv); }

注意LVGL内部颜色格式需要和fb设置为一致,一般是LV_COLOR_DEPTH=16。如果两者字节序不一致,颜色会偏。

7.2 背光调节和屏保能力的扩展

驱动里实现fb_blank回调后,可以通过ioctl或sysfs控制面板开关和背光。内核的原生接口是FBIOBLANK,应用层发FB_BLANK_POWERDOWN可以熄屏,FB_BLANK_UNBLANK可以亮屏。但我实际使用中更多是把背光单独做成一个LED或者PWM背光设备,因为fb_blank很多时候会连带关闭显示内容,状态显示类应用不一定希望这样。

在驱动里加一个简单的sysfs入口也很方便:

static ssize_t backlight_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count) { unsigned int brightness; sscanf(buf, "%u", &brightness); pwm_config(par->pwm, brightness, par->period); pwm_enable(par->pwm); return count; } static DEVICE_ATTR_WO(backlight);

这样用户层echo亮度值就能调节,不依赖额外的背光框架,适合轻量场景。

7.3 这套方案能平移到哪些平台

SPI小屏加FrameBuffer驱动的思路不只适用于RK3568,RK3288、RV1126、全志H3、NXP i.MX6ULL这些Linux平台都能用同样套路。只要SoC有SPI控制器,内核支持fbdev子系统,就能把这套驱动代码的核心逻辑搬过去,改的主要是设备树引脚和时钟频率。

做嵌入式开发久了就会发现,屏幕驱动本质上是在处理“数据格式转换”和“时序控制”两件事。FrameBuffer提供标准数据接口,SPI提供传输通道,中间的屏幕初始化序列只是屏厂定义好的一套寄存器流程。理解了这三层,任何SPI屏在Linux下都能很快点亮。

最后再分享一个经验:这类小屏驱动项目,排错的大头往往不是内核代码,而是硬件细节。刚拿到一款屏,先用裸SPI点亮、确认初始化序列和引脚定义,再开始写驱动,流程会顺很多。我也在这块RK3568的板子上经历过“一上电就白屏—折腾半天发现是复位引脚悬空”这种低级失误。做驱动开发,耐心和系统化排错比堆代码更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询