news 2026/9/16 16:58:18

RK3568平台FrameBuffer模式驱动SPI LCD:从设备树到刷屏优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RK3568平台FrameBuffer模式驱动SPI LCD:从设备树到刷屏优化实战

手里这块RK3568板卡的显示接口已经被HDMI和MIPI DSI占满了,外设接口只剩下SPI、I2C和一堆GPIO,但又必须挂一块小尺寸LCD做状态显示。翻遍方案,最终决定走FrameBuffer模式,直接用SPI驱动一颗240x320的ST7789屏幕。这个选择当时被团队里做上层应用的同事质疑过,因为在RK3568这种级别的处理器上,大家默认都会走DRM/KMS、上VOP2,很少有人愿意回到fbdev的老路上。但实际做完之后,我对FrameBuffer模式在低速小屏场景下的价值有了完全不一样的认识。

这篇文章就围绕RK3568平台、FrameBuffer框架、SPI总线和LCD驱动开发这四个关键词展开。我会把我从设备树写法、fb_ops实现、初始化序列下发,到带宽计算、白屏花屏调试的完整过程梳理出来,其中既有能直接抄走的配置和代码,也有我踩过之后觉得必须写下来的坑。

1. 为什么选FrameBuffer而不是DRM:小屏方案的第一个岔路口

1.1 RK3568显示链路与SPI小屏的错位

RK3568的显示链路设计非常典型:VOP2负责图形合成,然后通过eDP、HDMI、MIPI DSI、RGB并口这些高速接口把画面送给大屏。VOP2本身是一套非常成熟的DRM/KMS实现,对主流显示接口的支持很完善,但唯独对SPI这种低速串行接口没有多少兴趣。这不是瑞芯微的问题,整个Linux社区都没有把SPI LCD纳入DRM作为一等公民,SPI屏幕更多被视为一个“慢速外设”而不是“显示设备”。

这个定位差异直接决定了技术路线的选择。如果我强行在DRM框架下挂这颗SPI屏,我需要自己写一个drm_panel驱动,实现panel_prepare、panel_enable、get_modes、prepare、enable等一整套回调,还要处理drm_connector的注册,最后画面数据流还是要落到SPI上。这就相当于给一个自行车装上航空发动机的仪表盘,流程复杂、效率不高,而且DRM框架里所有关于vblank、crtc、encoder的抽象对SPI小屏来说都没有实际意义。

反观FrameBuffer模式,也就是Linux传统的fbdev框架,它本身就是一个非常朴素的“显存映射 + 刷新回调”模型。驱动只需要给上层一个连续的内存区域,上层往这片内存写像素,驱动再把变化同步到屏幕。对于SPI LCD这种整屏刷新或者局部刷新都不复杂的设备,fbdev这种简单的抽象反而是最贴合的。

1.2 fbdev在RK3568 BSP中的实际处境

很多做RK平台开发的人有一个误解,觉得RK3568的BSP内核默认只开DRM,不带fbdev。实际上瑞芯微的SDK内核里DRM和FBDEV是可以共存的,前者通过CONFIG_DRM配置,后者通过CONFIG_FB配置。在公众号、博客上看到的各种“如何把/dev/fb0映射出来”的教程,在RK3568上不是不可用,而是需要确认内核里这几个配置项处于开启状态:

CONFIG_FB=y CONFIG_FB_CMDLINE=y CONFIG_FB_NOTIFY=y CONFIG_FB_CFB_FILLRECT=y CONFIG_FB_CFB_COPYAREA=y CONFIG_FB_CFB_IMAGEBLIT=y

我用的这块板子内核版本是5.10,SDK默认配置里CONFIG_FB_CFB_*这几个软件加速回调默认是打开的,但CONFIG_FB本身在某些config文件里可能没被显式打开,需要通过menuconfig确认。

在dts层面还要注意,如果内核里DRM的VOP2驱动把某个显示接口注册成了主显示设备,它可能会占用掉/dev/fb0这个设备号。如果我发现自己的SPI屏驱动注册不到fb0,不要慌,可以在驱动里使用这个函数手动指定一个更高的设备号:

info->node = -1; /* 让框架自动分配 */

或者在加载驱动时使用内核参数fbdev=1来指定设备编号,虽然我没在实际项目中依赖这个参数,但排错时会用来区分设备节点。

1.3 选型结论:什么情况下走FrameBuffer更划算

做了这轮调研之后,我给这类需求定了一个简单的判断标准:屏幕分辨率低于480x320,刷新率要求不超过30fps,显示内容以文本、简单图形、状态图标为主,直接走FrameBuffer。原因有三个。

第一,SPI总线的物理带宽就摆在那里,就算上到60MHz时钟,理论最高也就7.5MB/s,扣掉协议开销和命令字头,240x320 RGB565全屏刷新一次要20毫秒级别的时间,这决定了它撑不起高分辨率高刷场景。第二,fbdev驱动模型简单,代码量远小于一个DRM panel驱动,出错时排查链路短。第三,上层应用哪怕只写普通文件操作,通过open("/dev/fb0")mmap()write()就能把中文点阵、波形曲线画上去,省掉了和DRM property、plane、crtc打交道的复杂度。

至于有人说fbdev是过时技术,这个观点在普通应用场景下成立,但在“用SPI挂小屏”这个特定夹缝里,它就是最省事、最稳定的选择。

2. SPI LCD驱动的地基:从显存模型到刷新链路

2.1 屏幕控制器的显存模型:ST7789为例

我们常用的SPI小屏,像ST7789、ILI9341、ILI9488,它们的内部都带一块GRAM,也就是显存。ST7789的分辨率最高是240x320,内部GRAM的大小就是240x320x18bit(这是RGB666格式的总位宽)。控制器支持RGB565、RGB666等不同输入格式,但显存本身固定是每像素18bit物理存放。驱动要做的事情,就是把上层写过来的RGB565像素,先转换或直接打包成控制器要求的格式,再通过SPI发进GRAM。

这里有个容易搞混的地方:SPI LCD驱动和GPU驱动、VOP驱动的本质区别在于,前者没有DMA2D、没有硬件光标、没有图层混合,GRAM的每一个字节都是CPU或者DMA通过SPI总线手动推进去的。所以“显示驱动”在这里实际上退化成了“像素搬运工”。

ST7789的显存更新流程是标准的:

  • 通过命令0x2A设置列地址范围。
  • 通过命令0x2B设置行地址范围。
  • 通过命令0x2C开启内存写,之后所有SPI数据都会被写入GRAM,地址自动递增。
  • 写入完成后,控制器根据扫描方向把GRAM内容刷新到液晶面板。

这给了我们一个很关键的启发:驱动里做局部刷新很简单,只需要把列地址和行地址设置到变化区域,然后只发送那一块区域的像素数据。这个特性会在后面带宽优化部分派上大用场。

2.2 framebuffer_alloc到fb_ops:驱动骨架怎么搭

一个标准的SPI LCD fbdev驱动,核心结构可以简化为三个部分:fb_info的分配与初始化、SPI传输逻辑、以及与屏幕控制器的命令交互。

分配fb_info的经典代码路径是:

static int spi_lcd_probe(struct spi_device *spi) { struct fb_info *info; struct spi_lcd_priv *priv; int ret; info = framebuffer_alloc(sizeof(struct spi_lcd_priv), &spi->dev); if (!info) return -ENOMEM; priv = info->par; priv->spi = spi; dev_set_drvdata(&spi->dev, info); /* 申请显存:注意不要用普通的kmalloc,显存一般用vzalloc或dma_alloc_coherent */ priv->vmem_size = LCD_WIDTH * LCD_HEIGHT * sizeof(u16); priv->vmem = vzalloc(priv->vmem_size); if (!priv->vmem) { ret = -ENOMEM; goto err_free_info; } /* 设置屏幕固定参数和可变参数 */ strcpy(info->fix.id, "spi_lcd"); info->fix.type = FB_TYPE_PACKED_PIXELS; info->fix.visual = FB_VISUAL_TRUECOLOR; info->fix.line_length = LCD_WIDTH * sizeof(u16); info->fix.smem_len = priv->vmem_size; info->fix.smem_start = virt_to_phys(priv->vmem); info->screen_base = priv->vmem; info->screen_size = priv->vmem_size; info->var.xres = LCD_WIDTH; info->var.yres = LCD_HEIGHT; info->var.xres_virtual = LCD_WIDTH; info->var.yres_virtual = LCD_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->var.activate = FB_ACTIVATE_NOW; info->fbops = &spi_lcd_fb_ops; info->flags = FBINFO_FLAG_DEFAULT; ret = register_framebuffer(info); if (ret) goto err_free_vmem; spi_lcd_init_panel(spi); /* 发送初始化序列 */ spi_lcd_refresh(info); /* 把脏显存刷新到屏 */ return 0; }

注意fix.smem_start,在支持DMA的平台上,如果后续打算用dma_alloc_coherent申请显存,这里就要用其返回的DMA地址,并且传输时也要用对应的DMA地址,这直接影响到能否走SPI控制器的DMA通道。如果不要求高性能,用vzalloc配普通PIO模式(CPU逐字节喂数据寄存器)也是可行的,帧率会低一些。

2.3 刷新一帧数据到底经过哪几条路

很多人一开始不理解fbdev下的“刷新”到底指什么。在没有硬件光标、没有自动刷新引擎的情况下,fbdev驱动只是把用户写入screen_base的那段内存视为“当前帧”。屏幕不会自动去读它,必须由驱动主动把数据搬到SPI控制器。

我的实现里用了两条刷新路径:

  • 主动刷新:驱动注册一个高精度定时器,周期比如50ms到100ms,每次tick检查fb_info里由fb_deferred_io维护的脏页,或者简单粗暴地全屏重发。
  • 被动刷新:上层调用write()ioctl(FBIOPAN_DISPLAY)fb_blank()时触发相应回调。

实际上在fbdev框架里,fb_deferred_io是个非常好用的机制,它的原理是注册一个struct fb_deferred_io对象,利用页面故障机制来跟踪哪些页面被写过,然后延迟到后台工作队列里做脏区域刷新。对小分辨率SPI屏特别适合,既能做局部刷新,又不用自己维护脏矩形算法。

static struct fb_deferred_io spi_lcd_defio = { .delay = HZ / 20, /* 50ms 合并脏页 */ .deferred_io = spi_lcd_dirty_update, }; info->fbdefio = &spi_lcd_defio; info->fbops->fb_mmap = fb_deferred_io_mmap;

spi_lcd_dirty_update回调里会得到一个fb_deferred_io对象的pagelist,把页范围换算成屏幕坐标区域,然后只对这个区域发起SPI传输。这块内容会在后面第5章结合帧率一起分析,代码怎么组织也很关键。

3. 设备树配置:时序、片选与GPIO的排列组合

3.1 SPI节点、时序参数与硬件/软件片选的权衡

RK3568有多个SPI控制器,我在板子上用的是SPI3。设备树节点首先要确保SPI控制器本身是使能的,并且引脚复用正确:

&spi3 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi3_pins>; assigned-clocks = <&cru SCLK_SPI3>; assigned-clock-rates = <60000000>; spilcd: spi-lcd@0 { compatible = "example,spi-lcd"; reg = <0>; spi-max-frequency = <60000000>; spi-cpol = <0>; spi-cpha = <0>; rotate = <0>; bgr = <0>; fps = <30>; reset-gpios = <&gpio3 RK_PA6 GPIO_ACTIVE_LOW>; dc-gpios = <&gpio3 RK_PA5 GPIO_ACTIVE_HIGH>; backlight-gpios = <&gpio3 RK_PA4 GPIO_ACTIVE_HIGH>; }; };

这里有几个关键点。第一,spi-max-frequency不能只看屏幕控制器手册标称的“最大支持XX MHz”,还要把PCB走线长度、排线质量、转接板接触电阻都算进去。我经历过把频率提到80MHz后图像出现横纹噪声,降回60MHz就完全正常的事情,所以如果PCB设计不那么讲究,留20%~30%余量比较稳妥。

第二,spi-cpolspi-cpha决定了SPI传输模式。绝大多数SPI接口的LCD控制器(ST7789、ILI9341都支持)在Mode 0和Mode 3下工作,也就是CPOL=0、CPHA=0或CPOL=1、CPHA=1。在屏幕数据手册里通常叫“SPI Mode 0”或“SPI Mode 3”。上电初始化前一定要确认这两个引脚的上拉状态,否则可能出现时钟极性和屏幕内部锁存边沿对不上的问题。

第三,硬件片选和软件片选的问题值得多说两句。硬件片选由SPI控制器在每次传输开始和结束时自动拉低拉高,对驱动来说最省事,但坑在于,SPI控制器可能在同一个message的不同transfer之间插入了片选释放再拉起的动作。对绝大多数LCD控制器来说,每一次片选下降沿都可能被识别成一次新的命令/数据序列开始,如果屏幕对CS的连续保持有苛刻要求(尤其是某些带状态机锁存的控制IC),连续的少量数据传输会被切碎。软件片选就是用一个普通GPIO手动控制CS引脚,由驱动在spi_message发起前手动拉低、结束后再拉高,这样能精确控制CS保持时间。

RK3568的SPI控制器用硬件片选完全没问题,前提是驱动把所有要发送的数据组织成一个完整的spi_message,而不是拆成多次spi_sync。这个细节在fbtft框架里有个use_gpio_cs选项就是这个原因。如果后面遇到屏幕上电后偶发花屏、或者首帧刷新不完整,优先检查是不是CS被反复拉高拉低导致的状态机复位。

3.2 DC、RESET、背光三个GPIO的归属

SPI LCD除了SCLK、MOSI、MISO、CS四根线,一般还需要三个控制引脚:DC(数据/命令选择)、RESET(复位)、BL(背光)。这三个引脚如果设计成固定的IO口那没问题,但做驱动时最好都放到设备树里,方便不同版本硬件兼容。

DC引脚的用法很死板:发送命令字节之前拉低,发送数据字节之前拉高。在SPI协议里没有专用的DC信号线,所以它只能是一个GPIO。我在驱动里封装了两个发送函数:

static void spi_lcd_write_cmd(struct spi_lcd_priv *priv, u8 cmd) { gpiod_set_value(priv->dc, 0); /* DC=0,命令 */ spi_write(priv->spi, &cmd, 1); gpiod_set_value(priv->dc, 1); /* DC=1,恢复数据模式 */ } static void spi_lcd_write_data(struct spi_lcd_priv *priv, const u8 *buf, size_t len) { gpiod_set_value(priv->dc, 1); /* DC=1,数据 */ spi_write(priv->spi, buf, len); }

这里有一个值得注意的时序细节:DC电平必须在SPI的第一个时钟沿之前建立,并且在最后一个时钟沿之后保持一段时间。GPIO操作如果走标准gpiod_set_value,往往会有几十纳秒的延迟,这个延迟对60MHz SPI(周期约16.7ns)来说可能不太够,因为DC信号需要提前至少几十纳秒稳定下来。所以如果发现偶尔第一个字节被屏幕识别成错误命令,可以考虑使用gpiod_set_value_cansleep配合老一点的GPIO控制器实现,或者在发送命令字节前先插入一个微小的忙等待。我这里用的GPIO控制器是CPU直连的,本身响应够快,没有遇到问题,但如果你用I2C转GPIO的扩展芯片来控DC,就绝对不行了。

RESET引脚的上电时序也不能马虎。ST7789数据手册要求RESET拉低至少10us,然后拉高,拉高后需要等待120ms才能开始发送初始化命令。实测很多国产兼容屏要求更长,我直接把等待时间加到200ms,避免因为某个批次屏的电源纹波导致初始化失败。

背光引脚可以做两件事:一个是普通的GPIO拉高拉低,另一个是接到PWM控制器实现亮度调节。如果只需要开关,设备树里配GPIO就行;如果需要无级调光,就拿一个PWM节点,然后在驱动里通过pwm_backlight框架管理。

3.3 设备树合入外部驱动的一种通用套路

在RK3568的SDK上,除了这颗SPI LCD,我还顺带合入过YT6801这种 PCIe转以太网芯片,以及一些WIFI模组。这些外设的dts合入方式与SPI LCD是类似的:先确认内核里对应驱动是否被编译,然后在设备树里新建节点、配好引脚和时钟,再匹配compatible字符串。这个流程走一遍之后你会发现,设备树本质上就是在干“板级描述”这一件事,与具体芯片是什么类型的设备关系不大。做RK3568开发时,拿一个已知可用的外设驱动作为模板,比从零开始读内核源码要快得多。

4. 驱动实现细节:初始化序列、刷屏路径与颜色问题

4.1 初始化序列发送:spi_message的批量传输

屏幕控制器上电后不会直接进入可用的显示状态,需要发送一串初始化配置。ST7789典型初始化序列包括:退出睡眠(0x11)、设置像素格式(0x3A)、设置扫描方向(0x36)、设置伽马曲线(0xE0、0xE1)等。这些命令对应的数据一般以数组形式内嵌在驱动里。

在发送初始化序列时,常见做法是组装一个spi_transfer数组,把命令阶段和数据阶段分开。由于DC引脚的存在,不能用单个transfer同时发命令和数据,必须把“命令+数据”组织成多段transfer,并保证整个message传输过程中CS保持低。这里正是spi_message相对于spi_write的优势:spi_write每次都是一个独立message,CS会重新拉低拉高;而一个spi_message内的所有transfer,CS默认保持连续选中,除非控制器设置了cs_change = 1

static int spi_lcd_send_buf(struct spi_lcd_priv *priv, u8 cmd, const u8 *data, size_t len) { struct spi_transfer xfer[2]; struct spi_message msg; int ret; memset(xfer, 0, sizeof(xfer)); xfer[0].tx_buf = &cmd; xfer[0].len = 1; xfer[0].cs_change = 0; xfer[1].tx_buf = data; xfer[1].len = len; gpiod_set_value(priv->dc, 0); /* 命令阶段 */ spi_message_init(&msg); spi_message_add_tail(&xfer[0], &msg); spi_message_add_tail(&xfer[1], &msg); ret = spi_sync(priv->spi, &msg); gpiod_set_value(priv->dc, 1); /* 恢复数据模式 */ return ret; }

这个函数的使用前提是:整个spi_messagespi_sync执行期间CS一直保持有效。RK3568的SPI控制器在同一个message内部默认是保持CS有效的。如果你发现一个消息内的两个transfer之间CS出现了抖动,可以在设备树里给子节点加cs-gpios改软件片选,手动控制。

4.2 fb_ops实现与刷屏定时器

struct fb_ops里必须实现的内容比想象中的少,最简单的可用驱动只需要四个字段:

static struct fb_ops spi_lcd_fb_ops = { .owner = THIS_MODULE, .fb_setcolreg = spi_lcd_setcolreg, .fb_fillrect = cfb_fillrect, .fb_copyarea = cfb_copyarea, .fb_imageblit = cfb_imageblit, .fb_blank = spi_lcd_blank, };

cfb_fillrectcfb_copyareacfb_imageblit这三个是内核自带的软件绘制函数,它们操作的是fb_info->screen_base对应的内存,不需要和硬件有交互。这给了我们一个很好的用户态体验:上层通过mmap往/dev/fb0里填充颜色,底层软件绘制函数负责把矩形、拷贝、位图这些常见图形操作变成显存里的像素更新,然后再有刷新线程把显存里的内容同步到屏上。

刷屏定时器我建议不要用fb_deferred_io之外另起一个内核线程,因为两套机制同时工作会造成重复刷屏。在低分辨率场景下,用内核提供的fb_deferred_io是最优解。它通过struct vm_area_struct的页面故障处理,把被写过的页记录下来,所以你只需要实现一个sched_delayed_work的工作队列函数:

static void spi_lcd_dirty_update(struct fb_info *info) { struct spi_lcd_priv *priv = info->par; unsigned long *pagelist = info->fbdefio->pagelist; int num_pages = info->fbdefio->pagelist_len; int y_top = -1, y_bottom = 0; int i; for (i = 0; i < num_pages; i++) { int pg = pagelist[i] - info->fix.smem_start; int y = pg / (info->fix.line_length); if (y_top == -1) y_top = y; y_bottom = y; } if (y_top >= 0) spi_lcd_update_rect(priv, 0, y_top, LCD_WIDTH, y_bottom - y_top + 1); }

注意,上面这个算法只计算了页包络的矩形区域,是一个“粗粒度的局部刷新”。如果页面尺寸是4K,而屏幕一行是480字节(240像素RGB565 = 480B),一页就覆盖约8.5行。虽然局部刷新的精度比不上逐像素脏矩形,但在240x320这种分辨率下已经足够,而且算法简单可靠。如果要做到更细的脏矩形,得自己记录每个像素点的修改操作,性价比不高,尤其当上层应用经常全屏重绘时,粗粒度页刷新和全屏刷新的差异几乎可以忽略。

4.3 RBG/RGB颠倒和颜色深度调整

颜色不对是SPI LCD调试中遇到最多的问题之一。现象通常是红色和蓝色对调,或者颜色整体发暗、发绿。我在ST7789上遇到的是典型的RBG颠倒。

ST7789的0x36命令是用来设置扫描方向的,其中bit3是RGB/BGR顺序控制位。置1表示BGR顺序,置0表示RGB顺序。这个位不对,RGB565的红色和蓝色就会被交换。很多屏幕模组厂商为了布线方便,内部把RGB巴线顺序做了旋转,导致同一颗IC在不同模组上表现出来的顺序不一样。所以需要在设备树里给驱动预留一个bgr属性,让硬件工程师可以根据实际模组调整。

代码里在初始化序列中根据bgr属性设置这个位:

static void spi_lcd_set_addr_mode(struct spi_lcd_priv *priv) { u8 mode = 0x00; /* bit7: MY, bit6: MX, bit5: MV, bit3: BGR */ mode |= (priv->rotate & 0x01) << 6; /* 根据旋转方向设置MX */ mode |= (priv->rotate & 0x02) << 5; /* 根据旋转方向设置MY */ if (priv->bgr) mode |= 0x08; spi_lcd_write_cmd(priv, 0x36); spi_lcd_write_data(priv, &mode, 1); }

此外,还要确认上层传给fbdev的颜色格式。我这边强制用RGB565(bits_per_pixel=16),所以fb_var_screeninfo里red、green、blue的偏移和长度必须正确设置:红色偏移11、长度5,绿色偏移5、长度6,蓝色偏移0、长度5。如果这里填错,层看到的颜色就会一团糟。

5. 带宽预算与帧率实测:SPI这条管道能跑多快

5.1 60MHz SPI时钟下的理论帧率计算

在做任何SPI LCD驱动之前,我建议先花两分钟算一下带宽预算,这能避免后期对帧率抱有不切实际的期望。以240x320、RGB565为例:

每帧数据量 = 240 * 320 * 2 = 153600 字节

在四线标准SPI模式下,每字节需要8个时钟周期,所以:

60MHz 时钟下传输速率 = 60M / 8 = 7.5 MB/s 理论最短传输时间 = 153600 / 7.5MB/s = 20.48ms 理论最大帧率 = 1000 / 20.48 = 48.8fps

但要注意这还只是纯像素数据,没有算初始化命令、设置窗口命令、以及两次传输之间的CS、DC电平切换时间。再加上fb_deferred_io的合并延迟、内核调度延迟、SPI控制器FIFO操作开销,实测能达到35fps已经是相当不错的成绩,30fps左右比较常见。

如果觉得帧率不够,有几种挖潜方式:提高SPI时钟频率(但要考虑PCB质量)、改用Dual SPI或Quad SPI模式(ST7789支持2线/4线SPI,但需要把数据引脚全接上,很多模组没引出这些引脚)、开启控制器自带的Tear Effect引脚同步、使用DMA而不是PIO。其中DMA是最直接有效的,RK3568的SPI控制器支持DMA传输,但要求buffer是DMA可访问地址,这也是为什么我在probe里特意提了dma_alloc_coherent。用DMA之后,CPU不再参与逐字节搬运,同样的60MHz时钟下帧率可以稳定提升10%~20%,因为省掉了大量的寄存器读写等待时间。

5.2 局部刷新:小屏的生存之道

全屏刷新的上限就摆在那,所以对SPI小屏来说,局部刷新不是优化项,而是必须项。大部分状态显示场景里,实际变化的区域只有一小块,比如一个温度数字、一个进度条、一条曲线。

fb_deferred_io配合页级脏跟踪,可以非常方便地实现局部刷新。实测一个典型的场景:上层每秒刷新一次时钟显示区域(假设是200x40像素),全屏刷新需要20ms左右,而局部刷新只需要计算变化页的包络矩形,也就是大概200x48像素的区域,传输数据量约为全屏的1/15,耗时不到2ms。

但这里有两个隐藏的坑。第一个坑是fb_deferred_io的页跟踪粒度是系统页面大小,通常4K,这对大分辨率屏幕来说意味着“被写一个点,整页都要重发”,但在240x320屏幕上,一页最多覆盖约8.5行,总共最多约38个页,重发范围其实可以接受。第二个坑是,上层如果使用mmap直接操作像素,写内存不会触发write系统调用,必须依靠deferred_io的页面故障机制才能知道哪些页被写了。如果上层替换为驱动提供的FBIOPAN_DISPLAY之类的方式刷新全屏,那页面跟踪就没用了。

实际开发中,我个人更喜欢在驱动里维护一个“脏矩形”,由用户态的writeioctl调用传递变化区域,这样比页级跟踪更精确,也不依赖mmap的页错误触发。不过对大多数人的使用方式来说,fb_deferred_io现成、稳定,可以直接用。

5.3 实测帧率与CPU占用数据

我在实际项目中测过一组数据,环境是RK3568 A55四核@1.8GHz,SPI3,60MHz时钟,240x320 ST7789,RGB565,使用fb_deferred_io,50ms合并延迟。

刷新模式帧率(fps)CPU占用(单核)说明
全屏刷新+PIO26-2865%能明显看到刷屏闪烁
全屏刷新+DMA30-3215%CPU占用大幅下降,帧率接近理论值
局部刷新+PIO取决于变化区域很低时钟/温度这类小面积场景基本无感
局部刷新+DMA取决于变化区域极低推荐方案

从这个表能看出,DMA是最值得投入的优化手段。它的原理是把SPI传输描述符和DMA缓冲交给控制器,CPU只需要在spi_sync期间等待完成中断。在使用DMA时要注意,spi_transfer里的tx_buf需要指向dma_alloc_coherent申请的缓冲区,或者在spi_message初始化时设置spi_transfer_dma。我在STM32上习惯用HAL库直接填DMA描述符,到了Linux内核里反而简单,因为SPI核心层已经把这些封装好了。

还有个容易被忽略的细节:即使使用DMA,fb_info->screen_base依然是CPU可见的虚拟地址,用户mmap得到的是这个地址的映射。发送到SPI时,要保证DMA传输的起始地址对齐到SPI控制器要求的边界。我自己遇到过一次未对齐导致DMA传输前几个字节错乱的情况,排查半天后发现是dma_alloc_coherent返回的地址没问题,但我在计算偏移时没有做缓存行对齐。解决方案是使用IS_ALIGNED宏检查,或者在申请显存时直接按64字节对齐。

6. 调试三板斧:白屏、花屏、颜色不对的排查路径

6.1 白屏:先背光后复位再查初始化序列

接上一块新屏,最常撞见的问题就是白屏。我的调试顺序是固定的:先背光,再复位,再查初始化序列。

第一步,确认背光电路正常。如果屏幕有背光但不亮,检查背光GPIO复用是否正确,用gpioinfo或者简单的设备树读取命令确认引脚电平。如果背光亮了但整个屏幕是全白,说明LCD面板本身通电正常,只是没有显存内容或者TFT驱动异常。

第二步,确认复位时序。很多新手直接把RESET引脚悬空,或者接到一个固定高电平,这会导致控制器上电后处于未知状态。用示波器量一下RESET引脚的波形,拉低时间要大于10us,拉高后要有足够的稳定时间。我遇到过一批屏对RESET后等待时间特别敏感,别的屏120ms就能初始化完成,这批屏必须等到200ms,否则首次初始化就失败,白屏。

第三步,也是最复杂的,检查初始化序列是否有遗漏或顺序错误。ST7789如果漏发了0x11(Sleep Out),屏幕会一直停留在睡眠模式,白屏且没有任何响应。漏发0x3A设置像素格式,则可能出现显示内容颜色错乱但不至于全白。建议在初始化序列每个关键命令后加一个回读操作(如果支持),或者直接对照官方驱动源码逐条核对。在嵌入式Linux里,驱动加载后立刻主动刷新一帧纯色测试图像,也能很快判断是初始化问题还是后续刷屏问题。

6.2 花屏:模式极性、时序余量与DMA缓存

花屏是一个比白屏更令人抓狂的问题,因为它的原因分布很广。我在实际调试中归纳出三类主要原因。

第一类是SPI模式极性错误。屏幕工作模式与设备树里配置的spi-cpolspi-cpha不匹配时,数据会在错误的时钟沿被锁存,出现间隔性乱码或整个画面打成雪花点。解决方法是把模式0和模式3都试一遍,观察哪边显示稳定。注意SDO和MISO引脚的连线不接也看不出来问题,但SDI(也就是MOSI到屏幕的引脚)的数据必须在正确的时钟沿采样。

第二类是时序余量不足。这包括SPI时钟频率过高、DC引脚电平建立时间不够、CS释放时间不合规等。当把spi-max-frequency从60MHz提升到80MHz之后,SPI时钟高电平时间缩短,如果屏幕模组的输入建立时间不能满足要求,就会出现偶发花屏。这种情况在PCB走线短且板级设计优秀时可能不明显,但在飞线连接、排线较长的原型上非常容易出现。解决方式是降低频率,同时在SPI时钟输出串一个小电阻,或者改短飞线。

第三类是DMA缓存一致性问题。如果显存使用vzalloc分配,而SPI控制器设置了DMA传输,CPU写入显存的数据可能还在Cache里,DMA控制器读到的物理内存已经是脏数据。这会导致屏幕上的内容要么延迟更新、要么显示出一部分旧数据。在内核里使用dma_alloc_coherent,或者传输前调用dma_map_single做Cache一致性处理,可以解决。更稳妥的做法是确保显存本身是DMA一致性的,避免在每次刷新前手动flush cache。

还有一个小坑是DMA缓冲区未对齐。SPI控制器如果要求DMA描述符对齐到32字节或64字节,而驱动把DMA偏移算错了一位,传输就会出现首尾错乱。可以用dma_get_cache_alignment()查看平台要求,申请缓存时多留一些padding。

6.3 颜色偏色:字节序、BGR位与灰度映射

颜色偏色问题相对好定位,但坑也不少。

最经典的是红蓝对调,就是之前提到的BGR/RGB位问题。在0x36命令里调整BGR位就能解决。如果驱动和设备树都预留了bgr属性,直接翻转属性值重新加载驱动即可完成对比测试。

另一种颜色不对是整体偏绿或偏暗,这种情况大概率是RGB565的字节序和屏幕控制器的输入格式不一致。一些屏幕模组内部把数据总线的高低位做了翻转,驱动发送的每个像素都是“低位在前”,但控制器期望“高位在前”。此时所有颜色都会变化,但不像红蓝对调那么明显。可以打出单色纯色测试:分别填充纯红、纯绿、纯蓝,然后观察屏幕显示。纯红如果变成了纯蓝,说明RGB顺序;纯红如果变成了暗红或者紫色,说明某个通道的位深或位偏移有问题。

再有一种情况是图形边界出现锯齿状彩色条纹,这通常是内容颜色深度与帧缓冲不匹配,比如上层用了ARGB8888,而fbdev设置为RGB565。由于内核软件绘制函数cfb_fillrect处理16bpp时正常工作,但上层不做转换,直接丢32bpp数据过来就会出错。解决办法是严格按fb_var_screeninfo里的信息初始化上层绘图库,或者在驱动里实现fb_set_par,根据var->bits_per_pixel动态调整屏幕控制器的像素格式。

最后想提一个边界情况:有次屏幕出现整屏偏灰,看起来像半透明蒙了一层。排查半天,发现不是显示问题,而是背光PWM的频率太低,导致以相机快门速度观察时看到明显的亮度波动。把PWM频率从1kHz提到10kHz后彻底消失。这个案例提醒我,显示驱动的问题不一定都在驱动里,背光电路和供电也会以奇怪的方式出现在屏幕上。

做个简单收尾。我在RK3568上用FrameBuffer模式驱动SPI LCD的整体感受是:这条路虽然不如DRM那么“现代”,但在小屏、低刷新率、快速出效果的场景下非常实用。框架简单,调试路径清晰,社区也有fbtft这类现成代码可以参考。只要把设备树时序、DC/CS/GPIO控制、DMA一致性和脏区域刷新这几个关键点处理好,一块SPI小屏在RK3568上稳定跑起来并不难。如果你也在做类似的外接副屏、状态面板、工控显示项目,希望这篇文章能帮你少走一段弯路。

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

在 Corsair 中集成 TextRazor:NLP 文本分析插件完整使用指南

在 Corsair 中集成 TextRazor&#xff1a;NLP 文本分析插件完整使用指南 【免费下载链接】corsair Connect your users to their apps 项目地址: https://gitcode.com/GitHub_Trending/corsa/corsair corsair-dev/textrazor 是 Corsair 官方生态中的一个插件包&#xff…

作者头像 李华
网站建设 2026/9/16 16:56:12

JavaFX + AWT Robot + JavaCV:桌面录屏录音工具的实现与编码实践

简介&#xff1a;一款基于JavaFX的桌面录屏录音软件完整源码&#xff0c;面向熟悉Java语法、希望进阶桌面应用与多媒体开发的读者&#xff0c;用来解决录屏、录音、暂停、播放和MP4导出的完整流程实现。项目通过Robot类定时抓取屏幕图像&#xff0c;使用Java声音API采集麦克风音…

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

GPOPS-II伪谱法最优控制建模:从setup结构体到Bryson-Denham问题

简介&#xff1a;面向需要求解最优控制问题的MATLAB用户&#xff0c;这份资源提供完整的GPOPS工具箱及配套示例库&#xff0c;覆盖最小爬升、运载火箭上升、高灵敏边界值等多类经典问题&#xff0c;每个案例均包含带详细中文注释的脚本、可运行的Main入口、问题描述txt及求解输…

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

STM32+RFID宿舍门禁系统:从硬件到Android联调全解析

简介&#xff1a;基于STM32单片机与射频识别技术实现的宿舍门禁系统&#xff0c;配套完整的安卓端手机应用源码和毕业设计资料&#xff0c;适合嵌入式、物联网、软件工程等专业学生直接用于毕业设计、课程设计或项目初期演示。整个压缩包共包含五十六个文件&#xff0c;体积仅一…

作者头像 李华