
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调光GPIOPWM控制显存计算很简单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是这块屏的控制核心它内部有GRAMGraphic 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 }, };这里特别要提一个细节0x20和0x21这两个命令是反色切换。因为TFT屏的显示效果和面板的走线方式有关同一个控制器在不同屏上可能需要反向显示。如果初始化后屏幕显示的颜色感觉“反了”且调整RGB顺序无效可以试试切换这两条命令。我在这个项目里就遇到过屏厂给的初始化序列默认0x20屏幕上显示的绿色变成了品红色排查了很长时间发现是屏的彩色滤光片排列和IC默认极性不匹配改成0x21后颜色就正常了。另一个容易踩的坑是复位时序。ST7789V的复位引脚需要拉低至少10us再拉高拉高后要等120ms让内部稳压器稳定然后才能发第一条命令。有些驱动图省事直接复用SoC的GPIO复位但没有做延时导致初始化偶尔失败表现就是屏幕时好时坏。正确的做法是严格按照datasheet的时序要求GPIO拉低后延时20us拉高后延时150ms再往下走。2.2 SPI通信协议要点命令与数据的区分ST7789V的4线SPI接口里所谓的“4线”是指CS、SCLK、MOSI、DCD/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 0CPOL0, CPHA0或者Mode 2CPOL1, CPHA0。绝大多数情况用Mode 0就好但如果之前在裸机测试时用的是模拟SPI没有注意这个参数接上硬件SPI后可能发现屏幕雪花点或者内容错位那就是极性配置不对。我习惯在设备树里显式指定spi-cpol和spi-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; };这里配置完之后驱动里不需要手动操作GPIOSPI核心框架会自动把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返回的虚拟地址可以直接被memcpy、ioremap等接口使用对FrameBuffer应用来说更方便。显存分配好之后还需要注册fb_var_screeninfo里的RGB位域信息——这部分不能写错用户空间的程序会读取这些字段来决定像素格式写错了颜色通道会错位。3.3 fb_ops实现最关键的还是刷新入口fb_ops结构体里的操作函数决定了FrameBuffer的读写行为和刷新行为。对于一块SPI屏来说大部分fb_ops都可以用通用实现比如fb_fillrect、fb_copyarea、fb_imageblit这三个标准的加速接口用内核自带的sys_fillrect、sys_copyarea、sys_imageblit就可以——它们的作用是维护显存中的数据不涉及硬件操作。真正需要自己实现的是fb_blank开关屏和fb_setcolreg调色板设置另外还需要一个实现了mmap和ioctl的默认实现。但是这里有一个容易忽略的问题sys_fillrect这类接口只更新显存不会自动触发屏幕刷新。用户空间程序调用fb_fillrect画了个矩形显存里确实变了但屏幕上的内容要等下一次刷新才能看到。如果驱动没有实现任何“脏矩形”跟踪机制最简单的做法就是每次都全屏刷新。这在分辨率低、刷新率要求不高的场景下可以接受240x320全屏刷新一次约60ms动画效果可能会有撕裂感但静态显示完全没问题。为了提高刷新性能我实现了一个简单的脏矩形跟踪机制。每次fb_ops中的fb_fillrect、fb_copyarea、fb_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: st7789v0 { 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; };rotate和bgr这两个属性是自定义的不是标准SPI设备的属性需要驱动配合解析。rotate表示屏幕安装方向0是竖屏90、180、270分别对应横屏和倒置。这部分逻辑在驱动里要做坐标变换。bgr是颜色顺序标记ST7789V的datasheet里有一项设置用来切换RGB和BGR颜色顺序。有些屏的RGB排列是BGR如果不做这个设置屏幕上的红蓝会互换。dc-gpios和reset-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-backlight20kHz的频率对人眼无感在这个频率下实测没有出现屏幕闪烁的现象。亮度等级的划分根据使用场景做了调整。出厂默认值不是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宏手动对齐或者直接用kmalloc的GFP_DMA标志。还有一个问题是缓存一致性——DMA传输完成后CPU读取发送缓冲区可能拿到的是cache里的旧数据。解决办法是在提交DMA传输前调用dma_map_single并带上DMA_TO_DEVICE方向发送完成后调用dma_unmap_single。这个细节如果漏了会出现刷新数据有时正确、有时花屏的间歇性故障非常难查。6.3 内核日志与调试工具的组合拳驱动开发阶段内核日志是定位问题的主要手段。建议在内核配置中打开CONFIG_FB、CONFIG_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_ioctl的FBIO_WAITFORVSYNC接口和上层同步实现无撕裂的动画显示。但这些改进要看产品需求如果只是显示仪表盘和状态信息当前这套方案的性能和稳定性已经够用了。