news 2026/9/16 3:43:05

基于RK3568的SPI屏FrameBuffer驱动开发与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于RK3568的SPI屏FrameBuffer驱动开发与性能优化

1. 项目背景与方案选型

1.1 为什么在这个项目里选FrameBuffer而不是DRM

先交代一下背景。这次的项目是在RK3568平台上驱动一块SPI接口的LCD屏幕,分辨率不高,320x240,主控是ST7789V。RK3568这颗芯片本身带MIPI DSI、LVDS、eDP这些显示接口,按常理说做产品不太会去碰SPI屏——带宽小、刷新慢、还占用CPU。但这次情况特殊,设备是一块小尺寸的工控面板,要求低功耗、低成本、结构简单,而且刷新率要求不高,显示内容以数值和状态为主,不跑复杂图形界面。整个BOM里加一颗SPI屏是最经济的解法,比上MIPI屏便宜不少,硬件走线也简单,4根线就能搞定。

选FrameBuffer而不是DRM,这里面有实际的考量。RK3568的BSP内核在Rockchip官方SDK里已经带了完整的DRM显示链路,对RGB/MIPI/LVDS接口的屏支持很好,但SPI接口的LCD在DRM架构下反而不好接。DRM是面向现代显示架构设计的,讲究plane、crtc、encoder、connector这一套抽象,SPI屏这种低速、非标准时序的显示设备硬往里套会非常别扭——你要为它写一个encoder和connector,还要处理atomic commit的一套流程,这部分工作量大,而且收益很低。FrameBuffer框架就简单直接:你只需要实现fb_info结构体里的fb_ops,提供一个显存缓冲区,系统把GUI绘制的内容写进这片内存,驱动再把内存中的像素数据通过SPI刷到屏幕上。逻辑清晰,任何水平的驱动工程师都能快速上手。

另外一点,FrameBuffer架构在主控端不需要GPU参与。RK3568虽然带GPU,但在这种小屏上跑OpenGL用处不大,用纯CPU绘制简单图形反而可控性更好。整个系统的软件栈可以做得非常薄,去掉一切不必要的抽象层,出问题的时候好排查,这对工控产品来说比花哨的技术架构重要得多。

1.2 核心需求拆解:这块屏到底需要什么

动工之前,先把需求拆清楚。这块屏的基本参数如下:

参数项数值说明
控制器ST7789V常见单芯片LCD驱动IC
分辨率240x320RGB565格式像素
接口4线SPI仅写方向,不读
最高SPI时钟40MHz(建议先跑10-20MHz)由MCU端SPI控制器决定
像素格式RGB56516bit/像素
背光独立LED驱动,PWM调光GPIO+PWM控制

显存计算很简单:240x320x2字节 = 153600字节,约150KB。这点内存对RK3568来说可以忽略不计,直接在驱动里用devm_kzalloc分配就好。整帧数据走SPI刷一次的时间取决于时钟频率——如果SPI跑到20MHz,理论带宽2.5MB/s,刷一帧150KB约60毫秒,帧率大约16fps。这个刷新率显示静态内容完全够用,做简单动画会有些吃力,需要后面再优化。

FrameBuffer模式下,内核还会额外分配一个screen_buffer,和显存是同一块内存。上层应用通过mmap把它映射到用户空间,直接操作像素。驱动要做的核心事情有两件:第一,提供这块显存的读写接口;第二,在合适的时候把显存内容通过SPI推到屏幕。第二件事就是驱动的性能关键点,后面会详细展开讲。

2. SPI LCD显示原理与关键链路

2.1 ST7789V控制器的初始化序列

ST7789V是这块屏的控制核心,它内部有GRAM(Graphic RAM),大小就是240x320x18bit。上电后芯片默认状态不是直接显示,而是处于Sleep Out之前的待机态,需要发送一串初始化命令才能进入正常工作模式。这串命令是屏厂和IC厂家提供的,通常称为init sequence。不同的屏虽然用的同一个控制IC,但因为玻璃基板、偏光片工艺差异,Gamma值设定可能会有细微差别,所以不能完全照抄datasheet里的参考值,最好用屏厂给的。

初始化序列里几个关键命令需要关注:

static const struct st7789v_cmd st7789v_init_cmds[] = { /* 软件复位 */ { 0x01, NULL, 0 }, /* 退出睡眠模式 */ { 0x11, NULL, 0 }, /* 设置像素格式为RGB565 */ { 0x3A, (u8[]){ 0x05 }, 1 }, /* 关闭反色,正常显示 */ { 0x20, NULL, 0 }, /* 显示反转可选项:0x20 vs 0x21,由屏的接法决定 */ { 0x21, NULL, 0 }, /* 帧率设置,默认值即可 */ { 0xB2, (u8[]){ 0x0C, 0x0C, 0x00, 0x33, 0x33 }, 5 }, /* 显示开启 */ { 0x29, NULL, 0 }, };

这里特别要提一个细节:0x200x21这两个命令是反色切换。因为TFT屏的显示效果和面板的走线方式有关,同一个控制器在不同屏上可能需要反向显示。如果初始化后屏幕显示的颜色感觉“反了”,且调整RGB顺序无效,可以试试切换这两条命令。我在这个项目里就遇到过,屏厂给的初始化序列默认0x20,屏幕上显示的绿色变成了品红色,排查了很长时间发现是屏的彩色滤光片排列和IC默认极性不匹配,改成0x21后颜色就正常了。

另一个容易踩的坑是复位时序。ST7789V的复位引脚需要拉低至少10us再拉高,拉高后要等120ms让内部稳压器稳定,然后才能发第一条命令。有些驱动图省事直接复用SoC的GPIO复位,但没有做延时,导致初始化偶尔失败,表现就是屏幕时好时坏。正确的做法是严格按照datasheet的时序要求,GPIO拉低后延时20us,拉高后延时150ms再往下走。

2.2 SPI通信协议要点:命令与数据的区分

ST7789V的4线SPI接口里,所谓的“4线”是指CS、SCLK、MOSI、DC(D/CX)。注意这里没有MISO,因为这是一块只写不读的屏,所以标准的SPI框架中需要把MISO这一个功能引脚复用为DC引脚。DC引脚的电平决定当前传输的是命令还是数据:低电平是命令,高电平是数据。这一点在SPI协议里比较特殊——标准的SPI协议没有命令和数据的概念,纯粹是bit流传输。所以驱动里要做一层封装,在发送命令时拉低DC,发送数据时拉高DC。

在Linux内核的SPI框架下,通常用spi_transfer结构体来管理一次传输。如果使用硬件DC引脚,可以在spi_message里添加两个独立spi_transfer段,DC引脚由GPIO手动控制。实际操作时要注意:SPI硬件本身不会在命令段和数据段之间自动切换DC引脚,这个切换一定要在spi_transfer之间完成,而且中间不能有额外的CS拉高拉低动作,否则ST7789V会认为一次传输结束,导致整个命令序列被打断。

还有一个小细节是SPI的极性和相位。ST7789V的datasheet要求SPI Mode 0(CPOL=0, CPHA=0)或者Mode 2(CPOL=1, CPHA=0)。绝大多数情况用Mode 0就好,但如果之前在裸机测试时用的是模拟SPI,没有注意这个参数,接上硬件SPI后可能发现屏幕雪花点或者内容错位,那就是极性配置不对。我习惯在设备树里显式指定spi-cpolspi-cpha,避免依赖内核默认值。

注意:SPI的时钟频率不是越高越好。ST7789V虽然标称最高支持到几十MHz,但实际受PCB走线长度、接口电平转换芯片速度、屏的FPC排线质量影响很大。在这类小尺寸屏上,我一般先从10MHz起步,稳定后再逐步往上调,每次加5MHz做长时间老化测试,确认无误后再定最终值。

2.3 硬件片选与软件片选的取舍

RK3568的SPI控制器支持硬件自动片选,也支持软件手动控制。硬件片选的好处是每次spi_sync发送时,由控制器自动拉低CS、结束后自动拉高,时序精准,不占用CPU。但在使用ST7789V这类显示屏时,我反而更推荐软件片选。

原因有两个。第一,ST7789V初始化序列很长,几十条命令要连续发送,每条命令之间不能有太长的“停顿”,否则IC会进入异常状态。硬件片选模式下,每次spi_sync结束后CS会立刻拉高,如果两条命令之间的间隔太大,有些批次的IC会判定当前传输结束,从而无法正确解析后续命令。软件片选可以做到“只拉一次CS,命令数据连续发完,最后再拉高”,避免这个问题。第二,软件片选方便调试。初始化失败时,可以用示波器观察CS信号,确认整个初始化序列确实是在一次CS低电平周期内完成的,而不是被分割成很多小段。

RK3568的设备树里,软件片选的做法是配置cs-gpios属性,把SPI控制器的“自动片选”换成GPIO控制:

&spi1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi1m1_pins>, <&spi1m1_cs0>; cs-gpios = <&gpio2 RK_PB7 GPIO_ACTIVE_LOW>; };

这里配置完之后,驱动里不需要手动操作GPIO,SPI核心框架会自动把CS当作GPIO来控制,在spi_message开始前拉低、结束后拉高。如果要实现“一条消息里传输多段数据且中间CS不拉高”,把多个spi_transfer放到同一个spi_message中即可,框架会在所有transfer完成后才释放CS。这块逻辑在驱动代码里要写清楚,注释标明是刻意为之,避免后来维护的人把它拆成多个spi_sync调用导致花屏。

3. FrameBuffer驱动框架在内核中的实现路径

3.1 自研驱动还是使用fbtft框架

Linux内核里有一个专门针对小尺寸SPI屏的框架叫fbtft,最初是给树莓派等平台用的,后来合入内核后已经比较成熟。它支持ST7789V、ILI9341、SSD1306等常见控制IC,提供了一套通用代码框架,开发者只需要提供一个屏参数组和设备树配置,就能快速生成一个FrameBuffer驱动。那为什么我这次没有直接用它?

答案很简单:生产环境下的定制需求太多了。fbtft虽然通用性好,但为了适配各种屏,做了很多抽象,实际运行时有一些“隐藏”的开销。比如它默认的像素填充函数是逐像素调用write_reg系列接口,这在低频SPI上问题不大,但在20MHz时钟下,函数调用层级带来的开销会明显拉低实际刷新率。另外fbtft对背光控制、上下电时序、帧缓冲DMA的优化支持都比较基础,达不到工控产品对灵活性和稳定性的要求。

所以我选择了自研驱动,但保留了fbtft的设计思路。具体来说,借用它的核心思路:把像素格式转换和SPI发送拆成两部分,上半部处理内存中的像素数据,下半部通过spi_message批量送数据。这样做虽然代码量比dts配置多一点,但整个代码路径完全可控,性能优化空间也大。如果读者只是为了快速验证一块屏幕能不能亮,我建议直接用fbtft,一个设备树节点、加一个compatible字符串就能跑起来;但如果是要做产品级驱动,自研更合适。

3.2 驱动的核心数据结构:fb_info的填充

FrameBuffer驱动的核心数据结构是fb_info,定义在linux/fb.h里。这个结构体描述了显示设备的所有属性和操作函数,内核通过它把显存暴露给用户空间的/dev/fb0节点。驱动编写的第一步就是把fb_info填对。

先看fb_fix_screeninfo,它描述的是固定不变的信息:

static int st7789v_fb_probe(struct spi_device *spi) { struct fb_info *info; struct st7789v_par *par; struct device *dev = &spi->dev; int ret; par = devm_kzalloc(dev, sizeof(*par), GFP_KERNEL); if (!par) return -ENOMEM; info = framebuffer_alloc(sizeof(struct st7789v_par), dev); if (!info) return -ENOMEM; par = info->par; par->spi = spi; spi_set_drvdata(spi, info); /* 固定信息:描述这块帧缓冲的物理特性 */ strcpy(info->fix.id, "st7789v"); info->fix.type = FB_TYPE_PACKED_PIXELS; info->fix.visual = FB_VISUAL_TRUECOLOR; info->fix.line_length = 240 * 2; /* 每行字节数:240像素 x 2字节RGB565 */ info->fix.smem_len = 240 * 320 * 2; info->fix.xpanstep = 0; info->fix.ypanstep = 0; /* 可变信息:描述分辨率、像素格式、时序参数 */ info->var.xres = 240; info->var.yres = 320; info->var.xres_virtual = 240; info->var.yres_virtual = 320; 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->var.activate = FB_ACTIVATE_NOW; info->fbops = &st7789v_fb_ops; info->screen_buffer = vzalloc(info->fix.smem_len); if (!info->screen_buffer) { framebuffer_release(info); return -ENOMEM; } ret = register_framebuffer(info); if (ret) { dev_err(dev, "failed to register framebuffer: %d\n", ret); vzfree(info->screen_buffer); framebuffer_release(info); return ret; } /* 初始化ST7789V控制器 */ st7789v_init_lcd(spi); /* 首次全屏刷新 */ st7789v_update_full_screen(info); dev_info(dev, "ST7789V framebuffer driver initialized\n"); return 0; }

有几个要点需要解释。screen_buffer指向的是内核虚拟地址连续的内存。为什么用vzalloc而不是kmalloc?因为150KB超过了kmalloc的常用限制,而且vzalloc返回的虚拟地址可以直接被memcpyioremap等接口使用,对FrameBuffer应用来说更方便。显存分配好之后,还需要注册fb_var_screeninfo里的RGB位域信息——这部分不能写错,用户空间的程序会读取这些字段来决定像素格式,写错了颜色通道会错位。

3.3 fb_ops实现:最关键的还是刷新入口

fb_ops结构体里的操作函数,决定了FrameBuffer的读写行为和刷新行为。对于一块SPI屏来说,大部分fb_ops都可以用通用实现,比如fb_fillrectfb_copyareafb_imageblit这三个标准的加速接口,用内核自带的sys_fillrectsys_copyareasys_imageblit就可以——它们的作用是维护显存中的数据,不涉及硬件操作。真正需要自己实现的是fb_blank(开关屏)和fb_setcolreg(调色板设置),另外还需要一个实现了mmapioctl的默认实现。

但是这里有一个容易忽略的问题:sys_fillrect这类接口只更新显存,不会自动触发屏幕刷新。用户空间程序调用fb_fillrect画了个矩形,显存里确实变了,但屏幕上的内容要等下一次刷新才能看到。如果驱动没有实现任何“脏矩形”跟踪机制,最简单的做法就是每次都全屏刷新。这在分辨率低、刷新率要求不高的场景下可以接受,240x320全屏刷新一次约60ms,动画效果可能会有撕裂感,但静态显示完全没问题。

为了提高刷新性能,我实现了一个简单的脏矩形跟踪机制。每次fb_ops中的fb_fillrectfb_copyareafb_imageblit被调用时,把受影响区域合并到一个整体脏矩形中。内核的调度器周期性地调用一个刷新函数,把所有脏矩形合并后统一发送到屏幕。这样连续绘制多个小图形时,不会触发多次SPI传输,刷新的效率高很多。实现不需要很复杂,维护一个bool dirty标志和一个矩形边界即可。

刷新函数中,像素数据从显存到屏幕的搬运是核心。ST7789V支持通过设置起始和结束坐标,然后连续写GRAM数据实现一块区域刷新。SPI传输的payload可以按行切分,每次发送一行像素数据加一个CASET/RASET命令。

static void st7789v_update_rect(struct st7789v_par *par, struct fb_info *info, u32 x, u32 y, u32 w, u32 h) { struct spi_transfer xfer[3]; struct spi_message msg; u16 x_start = x; u16 y_start = y; u16 x_end = x + w - 1; u16 y_end = y + h - 1; u16 *buf; int row, i; buf = kzalloc(w * 2, GFP_KERNEL); if (!buf) return; /* 设置列地址 (CASET) */ st7789v_write_cmd(par, 0x2A); st7789v_write_data(par, (x_start >> 8) & 0xFF); st7789v_write_data(par, x_start & 0xFF); st7789v_write_data(par, (x_end >> 8) & 0xFF); st7789v_write_data(par, x_end & 0xFF); /* 设置行地址 (RASET) */ st7789v_write_cmd(par, 0x2B); st7789v_write_data(par, (y_start >> 8) & 0xFF); st7789v_write_data(par, y_start & 0xFF); st7789v_write_data(par, (y_end >> 8) & 0xFF); st7789v_write_data(par, y_end & 0xFF); /* 写GRAM */ st7789v_write_cmd(par, 0x2C); for (row = 0; row < h; row++) { u16 *src = (u16 *)(info->screen_buffer + (y_start + row) * info->fix.line_length + x_start * 2); /* 把RGB565数据从显存拷贝到临时缓冲区 */ for (i = 0; i < w; i++) buf[i] = src[i]; st7789v_write_data_buf(par, (u8 *)buf, w * 2); } kfree(buf); }

这个实现里要注意一个字节序问题。ST7789V的GRAM数据是8bit传输的,RGB565的16bit像素数据需要先发高字节再发低字节。如果SPI控制器配置为MSB first模式,数据缓冲中的每个16位像素都会按大端序发出,正好和ST7789V的要求一致。但有些平台的SPI控制器或DMA配置会自动做字节交换,这时候就需要在拷贝数据时手动做cpu_to_be16转换,否则屏幕上每个像素的高低位都反了,颜色会错乱,比如红蓝互换、绿色异常。

提示:像素格式的颜色错位是一个非常隐蔽的问题。如果屏幕能显示内容,但颜色通道不对,先检查RGB565的位域配置是否正确,再检查SPI传输字节序——这两处是最容易出问题的地方。我在调试时遇到过屏幕整体偏蓝的诡异现象,最后定位到是fb_var_screeninfo中red、green、blue的offset配置反了。

4. 设备树配置与uboot阶段适配

4.1 SPI节点、GPIO与背光配置详解

在RK3568上做外设驱动,设备树是绕不开的一环。SPI LCD的设备树节点需要包含三部分内容:SPI控制器配置、LCD面板参数、背光控制。三者分别在对应的dts文件中定义,但逻辑上必须配合一致。

SPI节点的配置如下:

&spi1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi1m1_pins>; cs-gpios = <&gpio2 RK_PB7 GPIO_ACTIVE_LOW>; st7789v: st7789v@0 { compatible = "zlg,st7789v"; reg = <0>; spi-max-frequency = <20000000>; spi-cpol = <0>; spi-cpha = <0>; rotate = <90>; bgr = <1>; dc-gpios = <&gpio2 RK_PB6 GPIO_ACTIVE_LOW>; reset-gpios = <&gpio2 RK_PB5 GPIO_ACTIVE_LOW>; backlight = <&lcd_backlight>; status = "okay"; }; }; &pwm3 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&pwm3_pins>; }; lcd_backlight: lcd-backlight { compatible = "pwm-backlight"; pwms = <&pwm3 0 50000 0>; brightness-levels = <0 16 32 64 128 255>; default-brightness-level = <4>; power-supply = <&vcc5v0_sys>; status = "okay"; };

rotatebgr这两个属性是自定义的,不是标准SPI设备的属性,需要驱动配合解析。rotate表示屏幕安装方向,0是竖屏,90、180、270分别对应横屏和倒置。这部分逻辑在驱动里要做坐标变换。bgr是颜色顺序标记,ST7789V的datasheet里有一项设置用来切换RGB和BGR颜色顺序。有些屏的RGB排列是BGR,如果不做这个设置,屏幕上的红蓝会互换。

dc-gpiosreset-gpios是GPIO属性,这里要注意GPIO_ACTIVE_LOW标志。ST7789V的DC引脚是低电平表示命令、高电平表示数据,如果用GPIO_ACTIVE_LOW标记,驱动里调用gpiod_set_value_cansleep时传1表示的是“拉低”。这两个标志容易搞混,我的经验是统一在驱动里不依赖GPIO的active标志,直接用gpio_set_value手动控制电平,避免混淆。

4.2 uboot阶段如何保证开机logo能显示

在产品上有个需求:开机启动过程中要显示开机logo,不能等内核起来才亮屏。这要求uboot阶段也要有SPI LCD的驱动。RK3568的uboot使用的是U-Boot 2017.09版本,基于官方BSP,它内部也有一套显示驱动框架,支持简单FrameBuffer设备。

在uboot里添加SPI LCD支持,核心工作有两件:第一是初始化SPI控制器和GPIO,第二是发送ST7789V的初始化序列。U-Boot的DM框架里没有现成的ST7789V驱动,可以直接在board文件中写死,利用spi_xfer接口发送数据。但要注意U-Boot阶段的SPI驱动跟内核的不完全一致,它的速率配置要通过spi_set_speed接口单独设置。

实现在uboot里放一个固定分辨率的logo位图,转换RGB565格式后直接通过SPI刷进GRAM,然后在U-Boot的board_init流程中调用这个刷新函数。等到内核启动后,FrameBuffer驱动会接管屏幕,重新初始化一次控制器,覆盖uboot阶段设置的状态。这里要注意两个阶段之间屏幕不能闪黑或者闪白——需要在内核驱动的probe函数里判断当前屏幕状态,如果已经处于显示模式,就可以跳过复位和初始化序列,直接刷第一帧。不过这个优化依赖于uboot传递过来的状态信息,需要往设备树里加一个status属性,兼容性麻烦一点,实际项目中如果对比度要求不高,也可以直接让内核重新初始化,会有一次短暂的黑屏,但影响不大。

4.3 屏幕方向与触摸坐标的协同处理

这个项目里屏幕是旋转90度安装的——物理屏的排线在底边,但产品结构要求显示内容以横屏展示。RK3568 FrameBuffer驱动如果不做处理,屏幕显示内容就是竖屏的,需要在驱动里实现坐标变换。

坐标变换我选择在fb_ops的填充接口里做。具体来说,fb_imageblit等方法在写显存时,先把目标区域坐标从竖屏空间映射到横屏空间,这个映射关系是:

// 将1080x1920坐标系映射到1920x1080坐标系 int new_x = old_y; int new_y = 1080 - old_x - 1;

但在实际实现中发现这样做有个问题:内核的fb_imageblit内部按FB的坐标体系来绘制,它期望显存中的数据分布和显示顺序一致。如果驱动在写显存时做了坐标变换,那么Linux内核的通用绘图函数会乱套。正确的做法,应该是在显存的访问层面做转换,而不是在绘制接口层面做转换。

简化的方案是:在需要刷新屏幕时,把显存中的像素按行顺序读出来,但写入ST7789V的GRAM时按旋转后的坐标顺序写入。这样用户空间的程序不需要感知屏幕旋转,它看到的始终是一块240x320的竖屏FrameBuffer,但从屏幕上看,内容是以320x240的横屏展示的。这种方法实现起来简单,代码逻辑清晰,只是每次刷新时需要逐行做一次坐标映射,消耗少量CPU,但在这个应用场景下完全可接受。

对于触摸屏,如果系统里同时接了一颗SPI接口的触摸面板,那坐标变换就另有讲究。触摸驱动上报的是物理触摸坐标,必须经过同样的旋转矩阵转换成屏幕逻辑坐标,才能保证触摸位置和显示内容对应。这个矩阵参数应该和应用层的ts_calibrate校准结果保持一致,不能各做各的。由于这个过程容易出错,强烈建议在驱动开发阶段把显示旋转和触摸旋转的参数设计成独立可配的两个变量,分别调试,先在单变量模式下确认没问题,再开启组合模式。

5. 调试过程与性能优化

5.1 从点亮到稳定显示:一次典型的调试记录

这块屏的调试过程比预想的曲折,这里完整复盘一遍,对后来者有参考价值。

第一板驱动写完,烧进系统,现象是白屏。先检查硬件,用示波器看SPI时钟和MOSI数据,发现数据正常,SCLK频率也和配置一致。再检查GPIO——DC、RESET的时序也正常。最可疑的就是初始化序列。ST7789V的上电时序要求是先上VCI电源,再拉高RESET,等待120ms后才能发命令。我用万用表量了屏的VCC引脚,发现供电正常,但复用示波器看RESET引脚波形,发现驱动里虽然在probe函数中先拉了GPIO,但没有做延时就直接往SPI发命令了。这违反了ST7789V的时序要求,IC内部还在上电状态,命令根本没被正确接收。

修改方法是在st7789v_init_lcd函数最前面加上严格的延时:

static void st7789v_init_lcd(struct st7789v_par *par) { /* 硬件复位 */ gpiod_set_value_cansleep(par->reset_gpio, 0); udelay(20); gpiod_set_value_cansleep(par->reset_gpio, 1); msleep(150); /* 初始化序列... */ }

改完后再试,白屏消失了,出现了明显的烂屏,屏幕上能看到闪烁的彩色条纹,但没有任何可辨识的内容。这种现象一般说明GRAM的数据写入有问题,可能是SPI的时序像素格式不对,也可能是写入坐标有偏差。用示波器对比CASET/RASET命令发送时的数据波形,发现发送0x2A命令时,命令和数据段的DC引脚电平确实有变化,但变化的时机比预期晚了大约1个字节。问题出在驱动里用了同一个spi_message连续传命令和数据,但spi_transfer之间的DC GPIO切换没有同步好。

解决办法是拆分传输:命令段单独发一个spi_message,数据段等DC电平稳定后再发下一个spi_message。虽然效率稍低,但可靠性高。为了不牺牲太多性能,我在数据段内部采用批量发送,一次spi_message包含多行像素数据,避免频繁的消息切换开销。

过了这两关,屏幕终于显示了内容,但又出现了一个诡异现象:有画面,但颜色明显不对,绿色变成了紫色。用纯色测试图调试,发现红绿蓝三通道错位——红色显示成蓝色,蓝色显示成绿色。排查点是RGB565位域配置和BGR标志位。修改设备树里的bgr = <1>属性,同时把fb_var_screeninfo里的RGB偏移调整成ST7789V实际的访问顺序,颜色终于正常了。

5.2 性能分析:SPI刷新瓶颈在哪里

屏幕稳定点亮后,下一个问题就是刷新性能。FrameBuffer模式下,GUI程序对屏幕的绘制最终体现为一次次SPI传输。我用ftrace和内核的perf工具做了一次简单的性能分析,确认瓶颈主要出在三个方面。

第一个瓶颈是SPI时钟频率。RK3568的SPI控制器最高可以跑到50MHz,但实际跑25MHz时SPI总线上的波形已经明显失真——过冲和振铃都比较大,这跟PCB走线、排线长度有关。把频率降回20MHz后波形恢复正常。第三个因素,而不是第一个,是单次SPI传输的数据量。早期的实现是每次刷新一行240像素,即480字节,每次传输都有固定的开销,包括准备spi_message、申请DMA描述符、等待DMA完成等。在20MHz下,传输480字节耗时约192us,但协议的固定开销也接近这个数量级,所以刷新320行就要浪费很多时间。

优化方法是把多行数据合并到一次SPI传输。具体来说,把整个脏矩形的像素数据复制到一个大的DMA缓冲区,然后一次spi_message发送出去。这样在20MHz下刷一帧150KB的数据,理论耗时约60ms,加上协议开销能控制在70ms左右,帧率接近14fps,对显示实时数据来说完全够用了。

第二个瓶颈是CPU在像素拷贝上的开销。FrameBuffer驱动的刷新函数要逐像素从显存拷贝到发送缓冲区。如果是连续区域,直接memcpy效率还可以;但如果脏矩形很小,或者区域是分散的,散拷贝的开销反而更大。我采取的策略是:脏矩形面积小于整屏的四分之一时用散拷贝,超过四分之一直接全屏刷新。这样避免了一些特殊场景下的性能退化。

第三个容易忽略的瓶颈是SPI消息发送的上下文。spi_sync是同步接口,会阻塞当前线程直到传输完成。如果在fb_ops的回调里直接调用spi_sync,那么GUI渲染线程会被SPI传输卡住。更好的做法是用spi_async配合内核workqueue来异步发送。我这里用的是workqueue方案:fb_ops接口只负责标记脏区域,由内核的schedule_work触发刷新worker线程,在里面做SPI发送。这样GUI渲染和SPI传输做到了流水线并行,虽然单帧延迟稍微增加,但整体吞吐提升明显。

5.3 背光调节与功耗控制

工控设备的背光设计是个容易被忽视但实际很重要的环节。这块屏的背光由独立的LED驱动芯片控制,信号端由PWM控制亮度。设备树里配置了pwm-backlight,20kHz的频率对人眼无感,在这个频率下实测没有出现屏幕闪烁的现象。

亮度等级的划分根据使用场景做了调整。出厂默认值不是100%亮度,而是50%,因为在大多数室内场景下,50%亮度已经足够清晰,还能有效降低整机功耗。系统提供/sys/class/backlight/lcd-backlight/brightness节点给用户调整亮度,这个路径是pwm-backlight驱动自动创建的,无需额外编写用户态程序。

功耗方面还有一个低成本的小优化:屏幕内容长时间不变时,把背光PWM的占空比自动降低到30%。这个策略通过内核的fb_notifier实现——当FrameBuffer的FBMIRROR或者用户空间写显存时触发回调,记录最后刷新时间,如果超过10秒没有新内容,就渐暗背光。实现代码不多,但对产品功耗的贡献很实在。

6. 常见问题与排查技巧实录

6.1 白屏、花屏、黑屏的快速定位方法

调试SPI LCD时,屏幕的三种异常状态各有对应的排查方向,把常见情况整理成一张速查表:

故障现象可能原因排查手段
完全白屏上电时序错误、初始化序列未执行示波器查看RESET引脚时序和SCLK是否正常
花屏坐标设置错误、像素格式不匹配先用纯色测试图确认RGB顺序和坐标范围
黑屏但有背光显存内容未刷新到屏幕确认刷新函数是否被调用、SPI消息是否发送成功
显示内容错位CASET/RASET地址计算错误检查坐标是否超过屏的物理分辨率
颜色偏色RGB565位域或BGR标志配置错误用纯红、纯绿、纯蓝测试图逐步定位
刷新卡顿SPI频率过高、传输方式不合理降低SPI时钟,改用批量传输

排查白屏,我的习惯顺序是:先看背光有没有亮——背光亮但不显示内容,说明电源和背光电路正常,问题出在显示链路;如果背光也不亮,先量电源和背光驱动引脚。第二步用示波器看RESET引脚的波形——时序对不对、有没有复位信号,然后再看SPI时钟和MOSI上的波形,确认主机端确实在发数据。第三步检查DC引脚——命令和数据切换的电平是否正确。这三步检查完,基本能把问题圈定在硬件、初始化时序、写GRAM这三大块里。

其中最容易忽略的一个点是电压域匹配。ST7789V的IO电平是1.8V到3.3V都可以,但RK3568的GPIO如果工作在3.3V,而屏的IO电平设置成1.8V,虽然不会烧毁芯片,但信号的逻辑阈值不匹配,会导致SPI数据在临界区被误判。这类问题在示波器上看波形几乎是看不出来的——波形本身是正常的,只是接收端的判断标准不同。遇到这种诡异的现象,优先检查屏的IOVCC电压是否和主控一致。

6.2 刷新撕裂与DMA传输的坑

刷新撕裂是FrameBuffer方案的常见问题。表现为屏幕滚动文字时,上下部分出现明显的错位,好像把两帧画面拼到了一起。原因简单说就是刷新和绘制之间没有同步:刷新线程正在把显存的某一行数据发往屏幕时,GUI线程同时修改了显存的内容,导致一个SPI传输序列里包含了新旧两帧的数据。

解决撕裂问题的正规方案是实现FBIOPAN_DISPLAY或者双缓冲机制。双缓冲的思路是维护两个显存区域,一个给用户空间绘制用,另一个给刷新线程发送用,两者通过fb_blank或者自定义的ioctl实现切换。在应用层面,用户空间程序可以调用FBIOPAN_DISPLAY请求驱动切换显示缓冲区,但从我实际调试的经验来看,对240x320的小屏来说,双缓冲增加的显存开销不算什么,但引入的同步机较复杂。

如果只是显示静态文本和数字,撕裂问题其实不用过度担心——人眼对静止画面的撕裂不敏感。如果确实要显示滚动动画,一个折中的方案是在刷新期间把SPI的时钟频率降低,让刷新和绘制尽可能在同一水平线上完成。虽然不能完全消除撕裂,但可以显著减少出现的频率。另外记得在刷新worker和fb_ops的回调中加一把自旋锁保护显存,至少保证显存中的数据是“一致”的——不会出现半个像素被更新的情况。

DMA传输方面,RK3568的SPI控制器支持DMA模式,但它有几个隐含的限制。比如DMA buffer的地址必须满足32字节对齐,如果使用kzalloc分配发送缓冲区,默认对齐可能只有8字节。实际操作中我用devm_kzalloc配合ALIGN宏手动对齐,或者直接用kmallocGFP_DMA标志。还有一个问题是缓存一致性——DMA传输完成后,CPU读取发送缓冲区可能拿到的是cache里的旧数据。解决办法是在提交DMA传输前调用dma_map_single并带上DMA_TO_DEVICE方向,发送完成后调用dma_unmap_single。这个细节如果漏了,会出现刷新数据有时正确、有时花屏的间歇性故障,非常难查。

6.3 内核日志与调试工具的组合拳

驱动开发阶段,内核日志是定位问题的主要手段。建议在内核配置中打开CONFIG_FBCONFIG_FB_SPI相关的调试选项,同时在驱动里加上一套st7789v_dbg调试宏,用dev_dbg输出SPI传输的关键信息。

调试工具有两个特别推荐。第一个是devmem,它可以读改写寄存器,直接操作RK3568的GPIO和SPI控制器寄存器。当怀疑驱动初始化顺序有问题时,可以用devmem手动拉高复位引脚,再手动拉低DC引脚,模拟驱动过程,确认硬件链路是否正常。第二个是spidev_test,如果在内核配置里打开了CONFIG_SPI_SPIDEV,可以在用户空间直接通过SPI设备节点发送命令和数据。写一个简单的Python脚本,用spidev库发送ST7789V的初始化命令序列,能快速判断是驱动问题还是硬件问题——如果用户空间能点亮屏幕,说明硬件链路没问题,问题缩小到内核驱动;如果用户空间也点不亮,那问题大概率在硬件或者初始化时序。

另外,当屏幕显示颜色异常时,用一段简单的用户空间程序直接刷纯色块,能快速锁定颜色通道问题。下面是一段可以用来调试的Python示例代码:

import spidev import time import RPi.GPIO as GPIO # 初始化GPIO DC = 24 RESET = 25 GPIO.setmode(GPIO.BCM) GPIO.setup(DC, GPIO.OUT) GPIO.setup(RESET, GPIO.OUT) # 打开SPI spi = spidev.SpiDev() spi.open(0, 0) spi.max_speed_hz = 20000000 # 硬件复位 GPIO.output(RESET, GPIO.LOW) time.sleep(0.02) GPIO.output(RESET, GPIO.HIGH) time.sleep(0.15) # 命令/数据切换函数 def write_cmd(cmd): GPIO.output(DC, GPIO.LOW) spi.xfer2([cmd]) def write_data(data): GPIO.output(DC, GPIO.HIGH) if isinstance(data, int): data = [data] spi.xfer2(data) # 初始化序列(简化) write_cmd(0x01) # SWRESET time.sleep(0.15) write_cmd(0x11) # SLPOUT time.sleep(0.15) write_cmd(0x3A) write_data(0x05) # RGB565 write_cmd(0x21) # INVON write_cmd(0x29) # DISPON # 填充纯色:先设置全屏区域 write_cmd(0x2A) write_data([0x00, 0x00, 0x00, 0xEF]) # 列范围 0-239 write_cmd(0x2B) write_data([0x00, 0x00, 0x01, 0x3F]) # 行范围 0-319 write_cmd(0x2C) # RAMWR # 每个像素都是红色 (RGB565: 0xF800) pixel = 0xF800 data = [(pixel >> 8) & 0xFF, pixel & 0xFF] * (240 * 320) spi.xfer2(data)

这段脚本跑通了,说明SPI、GPIO、屏的硬件链路都是好的,剩下要排查的就是内核驱动的实现逻辑。脚本里一个细节是spi.xfer2(data)在用户空间一次传输大量数据时,底层会做内存拷贝和ioctl调用,性能相比内核里直接spi_sync要慢不少,所以它只适合做验证用,不适合做产品方案。

7. 经验总结与后续扩展

写到最后,分享几个这次开发中沉淀下来的经验,希望对做类似项目的朋友有帮助。

第一,SPI LCD驱动看起来简单,但实际链路很长:从设备树、GPIO、SPI控制器、帧缓冲、颜色格式、DMA、缓存一致性,任何一个环节出错,最终表现都是屏幕显示异常。调试时要学会“分层定位”——先用用户空间脚本验证硬件链路,再用内核驱动验证软件逻辑,最后才去调性能。跨层跳着查,效率往往是最低的。

第二,fbtft框架虽然方便,但它只能帮你快速点亮屏幕,很难帮你做好产品。如果只是验证、学习,直接用fbtft;如果是做量产产品,建议基于它的思路自研一个精简驱动,把控制权完全掌握在自己手里。这个驱动的代码量大约六七百行,要维护好并不难。

第三,把显示旋转、触摸坐标变换、背光策略的配置项都做成设备树可配置的,而不是写死在代码里。产品后续有硬件改版或结构微调,只需要修改设备树重新打包,不需要重新编译内核。

这个项目的驱动后续还可以做一些扩展。比如把刷新机制从脏矩形整区域刷新升级为真正支持局部DMA的双缓冲,帧率会更高;或者把屏幕内容通过fb_ioctlFBIO_WAITFORVSYNC接口和上层同步,实现无撕裂的动画显示。但这些改进要看产品需求,如果只是显示仪表盘和状态信息,当前这套方案的性能和稳定性已经够用了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 3:40:57

Windows下Questasim安装配置与License环境变量实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:40:27

编程智能体如何重构软件研发流程:从需求到运维的全链路实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:40:03

磁盘%util高不代表磁盘坏了:I/O性能排查实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:38:57

现代Web文件上传:点击+拖拽双通道原生实现指南

1. 这不是“点一下就完事”的功能&#xff0c;而是现代Web交互的底层基建你肯定遇到过这样的场景&#xff1a;在某个表单里填完信息&#xff0c;正准备提交&#xff0c;突然发现漏传了一份合同扫描件——这时候页面右下角弹出一个灰色虚线框&#xff0c;写着“拖拽文件到这里上…

作者头像 李华
网站建设 2026/9/16 3:38:37

多模态模型评测实战:MMBench与OpenCompass从原理到落地

多模态模型这两年可以说是遍地开花&#xff0c;从开源社区的Qwen-VL、InternVL&#xff0c;到各家闭源的GPT-4V、Claude&#xff0c;代码能力、推理能力一个比一个能打。但真到了要落地选型的时候&#xff0c;问题就来了&#xff1a;排行榜上那些分数到底靠不靠谱&#xff1f;同…

作者头像 李华
网站建设 2026/9/16 3:37:51

鸿蒙电商全栈实战:从开发到上架变现的完整指南

做了几年移动端开发&#xff0c;身边一直有同行问我&#xff1a;鸿蒙到底值不值得押注&#xff1f;我的答案很明确——如果你要做的是电商购物这类带真实交易闭环的应用&#xff0c;现在进入鸿蒙生态&#xff0c;窗口期的红利比安卓和iOS早期还要明显。我最近完整做完了一个鸿蒙…

作者头像 李华