ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RK3568 SPI LCD驱动开发:FrameBuffer模式下的完整实现与避坑指南

RK3568 SPI LCD驱动开发:FrameBuffer模式下的完整实现与避坑指南 直接开工。基于项目标题“RK3568 FrameBuffer模式SPI LCD驱动开发”结合内核驱动框架、设备树配置和实际调试经验我整理了一份可以照着做的完整开发记录。1. 为什么我不用DRM而是选择FrameBuffer模式先交代一下背景。我手头这块板子是RK3568主控本身对显示的支持非常完善MIPI DSI、LVDS、eDP、HDMI都是原生标配走DRM框架也很成熟。但这次的需求比较特殊——产品上只需要一块2.0寸的SPI接口小屏分辨率240x320用来显示设备状态、IP地址、一些简单的菜单信息。为了这块小屏幕去整套使用DRM的overlay、plane、connector体系坦白讲是杀鸡用牛刀驱动开发和调优成本都偏高。FrameBuffer框架的好处在于它足够古老也足够稳定。内核里drivers/video/fbdev这条路虽然被DRM逐步取代但在小尺寸SPI屏这种低带宽、低刷新率、无GPU参与的纯CPU刷屏场景下依然有不可替代的优势驱动模型简单继承fb_info实现fb_ops里的fb_setcoloreg、fb_blank、fb_fillrect、fb_copyarea、fb_imageblit就够了。用户态操作友好应用层直接open(/dev/fb0)mmap一段显存往里面填颜色数据就能显示不依赖Wayland/Weston这类复杂合成器。对240x320这种小分辨率4字节像素对齐问题几乎不存在RGB565格式下每行像素正好对齐到内存边界。当然选FrameBuffer也不全是情怀。SPI屏本身刷新率就在20~30fps的量级DRM的vblank机制、damage tracking在这种场景下收益很小而FB驱动里直接用一个deferred_io或workqueue做局部刷新反而更灵活。提示如果你的产品后续要跑Qt、要开GPU合成、要做动画过渡那我建议趁早切DRM但如果只是状态页、数字、简单图形FrameBuffer就是性价比最高的方案。后面所有内容都基于Linux内核4.19/5.10 SDKRK3568的BSP内核一样适配。2. SPI LCD驱动的硬件基础与连接规划在写任何一行代码之前先确认硬件连接。SPI LCD虽然只有几根线但接法决定了设备树怎么写、驱动里怎么控制一步错后面全盘错。2.1 引脚分配与信号定义我手里的屏是标准4线SPI不是3线9bit模式引脚定义如下信号名功能RK3568引脚说明SCLKSPI时钟GPIO3_B1复用SPI3_CLK时钟极性可配实测CPOL0、CPHA0稳定MOSI主出从入GPIO3_B0复用SPI3_MOSI只写屏不回读MISO可省CS片选GPIO3_B2复用SPI3_CS0拉低使能DC/RS数据/命令选择GPIO3_C0普通GPIO低电平写命令高电平写数据RST复位GPIO3_C1普通GPIO拉低复位BL背光GPIO3_C2PWM或GPIO这里先用GPIO控制注意DC脚必须用普通GPIO不能复用为SPI信号。因为SPI协议本身不区分命令和数据全靠DC电平告诉屏你这一帧8位是命令还是数据。这是SPI屏和MIPI屏最大的区别。MISO这根线我在项目里直接没接。屏是单方向写数据不需要回读寄存器省一根线不说还能避免RK3568 SPI控制器在接收FIFO为空时误判超时。2.2 SPI控制器时钟与速率选择RK3568的SPI控制器支持最高50MHz的时钟但LCD屏实际跑不了那么快。具体速率取决于屏的规格书里写的SPI Write Cycle Time最小值常见2.0寸ST7789V的写周期最小66ns对应15MHz左右。PCB走线长度和信号完整性杜邦线连接超过10cm建议降速。内核SPI子系统的实际分频结果RK3568的SPI时钟源来自clk_spi3PLL分频后不一定能整除得到整数频率。我这里最终选择了10MHz。实际测试8MHz和10MHz肉眼无差别12MHz以上偶尔出现首行花点可能是电源纹波引起的后面再细说。3. 设备树配置——不是只加一个SPI节点那么简单RK3568的设备树采用dtsi dts的结构BSP里已经定义好了spi3节点。我在板级dts里做如下修改3.1 SPI节点与GPIO子节点spi3 { status okay; pinctrl-names default; pinctrl-0 spi3_clk spi3_mosi spi3_cs0; max-freq 10000000; st7789v: st7789v0 { compatible powertip,st7789v-spi; reg 0; spi-max-frequency 10000000; spi-cpol; spi-cpha; rotate 0; fps 30; buswidth 8; dc-gpios gpio3 RK_PC0 GPIO_ACTIVE_HIGH; reset-gpios gpio3 RK_PC1 GPIO_ACTIVE_LOW; backlight-gpios gpio3 RK_PC2 GPIO_ACTIVE_HIGH; debug 0; }; };有几个细节容易踩坑第一pinctrl-0里我只使能了spi3_clk、spi3_mosi、spi3_cs0三个引脚复用。spi3_miso如果引脚被其他功能占了不写进pinctrl反而更安全否则kernel会强制把这个引脚复用成SPI功能导致其他外设异常。第二reg 0必须和片选号对应。spi3的CS0对应reg0CS1对应reg1。配置多设备时不能重复。第三spi-cpol和spi-cpha并不是所有屏都需要。ST7789V规格书里时序图显示写数据时SCLK空闲为低、采样沿为上升沿所以CPOL0、CPHA0是大多数Controller IC的默认值。如果你的屏是ILI9341、GC9A01这类绝大多数也是Mode0。但如果屏是ST7735S有些模组会做成Mode3务必对照规格书。3.2 屏幕旋转的另一种实现上面设备树里我写了rotate 0正常0度。但很多产品需要竖屏变横屏或者反过来。应用层旋转可以简单用fbset设置xres、yres交换像素宽高但这种方式有个隐藏问题——如果帧缓冲的line_length没有同步修改用户态画图时计算偏移就会出错。更干净的做法是在驱动里做fb_var_screeninfo的xres/yres和xres_virtual/yres_virtual交换同时让fb_info-fix.line_lengthxres * 2RGB565。设备树里加一个rotate属性驱动里读取后直接用swab16交换GRAM行列地址效果等同于硬件旋转。4. 驱动核心实现——从probe到LCD初始化序列我选择了不依赖fbtft框架这个后面会专门对比直接基于fbdev裸写驱动。原因有三一是fbtft对老内核支持好但对RK3568这种新平台反而不够简洁二是自研驱动方便我完全掌控SPI传输时机三是240x320分辨率下自研驱动的代码量也就500行左右完全可控。4.1 probe流程要点static int st7789v_probe(struct spi_device *spi) { struct st7789v_par *par; struct fb_info *info; int ret; par devm_kzalloc(spi-dev, sizeof(*par), GFP_KERNEL); if (!par) return -ENOMEM; par-spi spi; par-dc devm_gpiod_get(spi-dev, dc, GPIOD_OUT_LOW); par-reset devm_gpiod_get(spi-dev, reset, GPIOD_OUT_HIGH); par-bl devm_gpiod_get(spi-dev, backlight, GPIOD_OUT_HIGH); info framebuffer_alloc(0, spi-dev); if (!info) return -ENOMEM; par-info info; info-par par; info-screen_base (char __iomem *)devm_kzalloc(spi-dev, WIDTH * HEIGHT * 2, GFP_KERNEL); par-vmem (void *)info-screen_base; ... st7789v_hw_reset(par); st7789v_init_sequence(par); st7789v_clear_screen(par, 0x0000); ret register_framebuffer(info); if (ret) goto err_rel_fb; return 0; }注意devm_gpiod_get取得的GPIO是出于GPIOD_OUT_LOW/HIGH状态这里的低/高电平是指GPIO逻辑电平不是设备树GPIO_ACTIVE_LOW处理后的状态。reset在设备树里是GPIO_ACTIVE_LOW所以初始化时为高电平意味着物理引脚拉高不复位这个逻辑链条要搞清楚。4.2 LCD初始化序列的技巧ST7789V的初始化序列大体是Software Reset-Sleep Out-Display Off- 设置像素格式 - 设置方向 - 设置伽马曲线 -Display On。初始化序列在驱动里就是一个const u8数组每条命令格式是命令字节参数数量参数数据。我封装了一个写命令和写数据的快捷函数static void st7789v_write_cmd(struct st7789v_par *par, u8 cmd, u8 *data, int len) { struct spi_transfer xfer {}; struct spi_message msg; u8 *buf; buf kzalloc(len 1, GFP_KERNEL); buf[0] cmd; if (len 0 data) memcpy(buf[1], data, len); gpiod_set_value_cansleep(par-dc, 0); // DC0 命令模式 xfer.tx_buf buf[0]; xfer.len len 1; spi_message_init(msg); spi_message_add_tail(xfer, msg); spi_sync(par-spi, msg); if (len 0 data) { gpiod_set_value_cansleep(par-dc, 1); // DC1 数据模式 xfer.tx_buf buf[1]; xfer.len len; spi_message_init(msg); spi_message_add_tail(xfer, msg); spi_sync(par-spi, msg); } kfree(buf); }这里有一个设计决策——命令和参数分两次SPI传输。实际也可以把DC电平的变化夹在同一spi_message里用cs_change做但那样依赖控制器在连续片段间切换GPIO的时序实测RK3568上偶尔多出来几十微秒的间隙。分开传输更可靠虽然每次多一次spi_sync的系统调用开销但对于初始化阶段只执行一次完全可以忽略。4.3 关键寄存器配置初始化序列里最核心的几组寄存器寄存器值说明0x36 (MADCTL)0x00扫描方向控制0x00为标准竖屏0xA0为横屏反向0x3A (COLMOD)0x0516位像素格式RGB5650xB2 (PORCTRL)0x0C, 0x0C, 0x00, 0x33, 0x33porch设置背板电压稳定0xB7 (GCTRL)0x35VCOM电压调太高会偏白0xBB (VCOMS)0x28VCOM设置影响对比度0xC0 (LCMCTRL)0x0CLCM控制0x0C为正常显示0xC2 (VDVVRHEN)0x01, 0xFFIota优化0xE80x03, 0x03, 0x04自适应电压调整0x36的每一位都值得单独说。bit7和bit6控制行/列地址自动增减方向bit5和bit4控制行/列交换组合起来可以实现0/90/180/270度旋转。我实际调试旋转时发现屏幕的物理安装方向和PCB上FPC座方向不同导致0度显示左右镜像最后改成0xC0才正常。4.4 显存与刷屏机制我的驱动为240x320分辨率、RGB565格式分配了240*320*2 153600字节的vmem。info-screen_base指向这个缓冲区用户态mmap写数据到screen_base驱动再通过SPI把数据搬给屏。问题在于——什么时候搬搬多少最简单的实现是每次fb_write或fb_imageblit回调都把全屏数据通过SPI刷一遍。这在240x320屏上完全可行10MHz速率下刷新一帧240*320*2约15.36KBSPI传输耗时约12.3ms算上CPU调度开销和命令包间隔实测22~28fps。对状态显示足够流畅。但如果屏幕尺寸更大比如320x480全屏刷新明显吃力就需要局部刷新机制。我在驱动里实现了脏矩形跟踪void st7789v_update_display(struct st7789v_par *par, int x1, int y1, int x2, int y2) { u8 xstart_h (x1 0) 8; u8 xstart_l (x1 0) 0xFF; u8 xend_h (x2 - 1 0) 8; u8 xend_l (x2 - 1 0) 0xFF; u8 ystart_h (y1 0) 8; u8 ystart_l (y1 0) 0xFF; u8 yend_h (y2 - 1 0) 8; u8 yend_l (y2 - 1 0) 0xFF; st7789v_write_cmd(par, 0x2A, (u8 []){xstart_h, xstart_l, xend_h, xend_l}, 4); st7789v_write_cmd(par, 0x2B, (u8 []){ystart_h, ystart_l, yend_h, yend_l}, 4); st7789v_write_cmd(par, 0x2C, NULL, 0); gpiod_set_value_cansleep(par-dc, 1); spi_write(par-spi, par-vmem y1 * par-info-fix.line_length x1 * 2, (y2 - y1) * par-info-fix.line_length); }核心思路是通过设置0x2A和0x2B命令指定GRAM窗口然后只需要把窗口对应大小的显存数据灌给0x2C命令。这样局部刷新时SPI只传输变更区域帧率瓶颈大幅缓解。5. 内核里更省事的方案——fbtft框架对比与取舍如果不想维护500行自研代码内核里其实有个半官方方案drivers/staging/fbtft/。我最初也考虑过直接基于它改聊聊为什么最终没有用。5.1 fbtft的架构与API抽象fbtft把SPI LCD驱动抽象成两层fbtft_device负责注册平台设备解析设备树或模块参数。fbtft_ops一组函数指针包括write、write_vmem、set_addr_win、reset、init_display等。使用fbtft时我只需要提供init_display函数里的初始化寄存器序列以及set_addr_win函数里的窗口设置代码。剩下的fb_ops实现、显存管理、spi传输逻辑框架都写好了。以ST7789V为例fbtft里已经内置了驱动文件fbtft_device.c里对st7789v的默认支持。实际使用中只需在设备树里配好compatible fbtft,st7789v加好dc-gpios等属性编译进内核后就能直接在/dev/fb0看到设备。5.2 fbtft的好与坑方面评价上手速度很快设备树配完就能显示局部刷新框架支持deferred_io但实际局部刷新粒度是固定区域不精确性能SPI传输走spi_message队列没有做burst优化维护现状长期在staging新内核中随时可能被删除定制灵活度低想加入背光PWM调光、双屏控制会比较别扭我用它跑通了显示但后来放弃的原因是我需要背光亮度可调通过PWMfbtft对背光控制的抽象只支持简单的backlight属性而我要在设备休眠时关屏、唤醒时自动重发初始化序列这些都是fbtft框架里没有直接留接口的地方改起来反而比自研更费劲。所以我的建议是只是想快速验证SPI LCD能不能点亮、颜色格式是否正确用fbtft跑通流程。产品化阶段准备做调光、休眠、多屏、自定义时序直接自研驱动。6. 实测速度、踩坑记录与性能优化方向6.1 刷新率实测数据环境信息RK3568四核A55内核5.10SPI速率10MHz缓存策略为Writeback无DMA。场景全屏刷新1/4区域局部刷新纯fillrect清屏24fps-imageblit图片21fps34fps文字滚屏25fps40fps复合场景时钟菜单19fps30fps全屏刷新时SPI传输数据量大是主要瓶颈局部刷新看起来帧率高但实际上应用层绘图开销也占了CPU所以并没有线性提升。6.2 踩坑记录一花屏和首行条纹调试第一天就遇到了花屏表现是屏幕上半部分显示正常下半部分出现随机噪点尤其切换全屏纯色时最明显。最终定位到两个原因SPI频率从12MHz降到10MHz问题消失。12MHz下时钟占空比不够理想尤其在电源纹波叠加时容易导致数据采样错误。初始化序列里0x35Tearing Effect Line On被我不小心配置成了on但主控又不会去读TE信号导致内部时序紊乱。关掉这个功能后再没有出现过撕裂。6.3 踩坑记录二用户态显示中文乱码FrameBuffer本身只是一个像素缓冲区不关心字符编码。所以LCD屏显示中文这个问题本质上是字体从哪里来常用的做法是把字库点阵如16x16、24x24做成C语言数组编译进应用。点阵如何写进fb应用层调用fb_imageblit需要传一个fb_image结构其中image-data指向的点阵数据必须是1bpp格式每个字节的8个bit分别对应像素的on/off。把字库点阵格式从纵向8像素横向字节转成fb需要的格式是中文显示乱码的常见来源。我的做法是干脆不走fb_imageblit而是在应用层直接把点阵展开成RGB565的矩形块用write()写到fb设备节点绕开格式转换的坑。6.4 下一步优化建议这块板子目前已经量产但后续如果要提升流畅度我评估了三个方向开启SPI DMA传输。RK3568 SPI控制器本身支持DMA现在用的是CPU polling模式。改成DMA后理论帧率可以提升到35fps以上同时CPU占用率大幅下降。使用deferred_io替代主动刷新。当前是每次fb操作后立即刷新频繁小区域操作会触发多次SPI传输。用deferred_io把脏区域合并到一次刷屏任务减少SPI传输次数。如果一定要上DRM可以走tinydrm对应新版内核里的DRM_GEM_CMA_HELPER加drm_simple_display_pipeSPI屏幕在DRM框架下的最小实现路径。但这属于跨框架迁移工作量等同于重写。6.5 经验总结SPI屏幕调试顺序按照以下顺序排查问题会省很多时间上电先确认背光能亮这是最简单的电源通路检查。用GPIO手动翻转RST看屏幕有没有任何反应哪怕只有白屏闪烁。初始化序列执行后用dd if/dev/urandom of/dev/fb0填充随机像素确认数据通路通了。再用dd if/dev/zero of/dev/fb0清屏为纯黑排查颜色格式对不对。最后才做图像旋转、局部刷新这些功能性优化。我遇到过不少同事一上来就写全屏刷帧的测试程序结果花屏了不知道是SPI时序问题还是颜色格式问题。先排除法再优化这是SPI屏调试的铁律。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进