ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于HI3556V200双系统的LiteOS侧RGB888 OLED显示驱动实践

基于HI3556V200双系统的LiteOS侧RGB888 OLED显示驱动实践

这段时间一直在折腾一块 HI3556V200 的开发板,项目是从 Linux 主系统里分出一部分任务到 LiteOS 侧去做高实时性控制。前面几篇笔记把双系统的搭建、通信、调度都顺了一遍,但这篇想单独把 LiteOS 端对接 RGB888 OLED 显示屏这件事拆开来讲。

做嵌入式的人可能都有这种感觉:一提到显示,第一反应就是 Linux 下有 framebuffer,open、mmap、write 一条龙,简单好用。那为什么我偏偏要在 LiteOS 端去接屏?原因很简单:我们这套系统里,有一部分画面内容需要在实时线程里生成,比如仪表盘刷新、动态波形绘制。如果数据先从实时线程传给 Linux,再经过内核调度到显示进程,再由 framebuffer 输出,中间多绕了两层,延迟不稳定,帧率也上不去。而 HI3556V200 的双系统是 AMP 架构,Linux 跑在 A7 核0,LiteOS 跑在 A7 核1,两个核共享同一片内存空间,完全可以做到实时线程直接在 LiteOS 侧生成像素、直接写屏,效果立竿见影。

这篇文章就记录我在这块板子上,从零把一块 8-bit SPI 接口、支持 RGB888 格式的 OLED 屏点亮,并在 LiteOS 侧做画面刷新的全过程。内容包括硬件接口理解、双系统共享内存设计、LiteOS 驱动编写、RGB888 像素排布、常见问题排查。踩过的坑不少,尤其是时序和同步这两个地方,几乎每个新手都会碰一次,我会把这些经验尽量讲透。

1. 显示任务放到 LiteOS 侧的价值在哪

1.1 从延迟问题说起

先说说为什么显示这种"慢设备"要挂在实时系统上。很多人觉得屏幕刷新是毫秒级的事情,跟微秒级实时控制不沾边。但实际项目里,有一种使用场景很特殊:画面上展示的数据本身来源于实时采样,比如传感器波形、控制环路的输出曲线、电机转速表盘。数据在不断更新,画面必须跟随数据同步刷新。如果采样和显示之间隔了 Linux 的调度层,每次画面刷新对应的是数次内核态切换和进程唤醒,延迟和抖动都会被放大。

我之前在 Linux 侧用 framebuffer 做过类似的事情,单次画面刷新的延迟平均在几个毫秒左右,但抖动很大,最高的一次差了近 10 毫秒。对表盘类画面来说,这就能看出指针的"卡顿感"。把显示逻辑挪到 LiteOS 侧之后,采样回调函数里直接把像素数据写入共享内存,LiteOS 的显示任务通过事件标志量得知新数据到达,立刻刷新屏幕,整个过程没有内核调度介入,延迟稳定在一个可预测的范围。

1.2 HI3556V200 双系统下的任务分工

HI3556V200 的 AMP 模式比较直接:两个 Cortex-A7 核各自跑一个系统,核0 负责 Linux 主业务,核1 跑 LiteOS 承担实时任务。两个系统共享内存、各有中断源。通信靠的是一个简单的共享消息队列加核间中断。这套模式在双系统开发里很常见,但显示任务放 LiteOS 侧,有一个额外的好处:Linux 侧进程挂掉或者被 OOM killer 杀掉的时候,屏幕画面依然能保持刷新。对某些不能黑屏场景来说,这是非常实用的冗余设计。

我这次选用的屏幕是一块 2.0 寸 RGB888 SPI OLED,分辨率 240x320,屏内带 GRAM,主控芯片支持 24 位色深。驱动接口是 4 线 SPI,除了电源、地、背光之外,还需要 CS、RESET、DC、SCLK、SDI 五个控制引脚。RGB888 模式下,每个像素需要传 3 个字节,一帧画面的数据量就是 240x320x3 = 230400 字节。SDRAM 里放两个帧缓冲,一帧用于显示,一帧留给 CPU 写入,总开销大概 460KB,对双系统共享内存来说不算大。

提示:RGB888 OLED 和常见的 RGB565 OLED 不是一回事。如果屏的主控只支持 16 位色深,即使你在 SPI 总线上发满 24 位数据,它也只会截取高 5/6/5 位。选屏的时候一定要确认驱动 IC 支持 RGB888 数据格式,否则后面的颜色深度就没法谈。

2. 双系统跨界的数据通路设计

2.1 共享内存怎么划

显示数据从 Linux 侧生成,还是从 LiteOS 侧生成?我的方案是两头都会写。大部分静态界面由 Linux 侧生成,通过共享内存丢给 LiteOS 去显示;高实时性的动态区域由 LiteOS 侧直接在另一块共享区域里绘制。这就需要在内存布局上提前规划。

HI3556V200 的物理内存地址是按区域划分的。一般做法是在系统启动参数里给两个核分别预留内存:Linux 侧用一部分,LiteOS 侧用一部分,中间额外留一块"共享区"专门放跨系统数据。这套平台上,我把共享区放在了固定物理地址,大小是 2MB。其中头部 4KB 放控制结构体,中间放着两个 1MB 左右的帧缓冲区域,分别给两块动态区域使用。

共享区访问上有一个容易踩的坑:Linux 侧不能直接物理地址操作,必须做一次 remap。我用的是 /dev/mem 加 mmap 的方式,把共享区的物理地址映射到用户空间,然后按结构体指针访问。LiteOS 侧则是直接按物理地址访问,因为 AMP 模式下 LiteOS 运行在没有 MMU 转译的环境里,访问物理地址很直接。两边访问同一块地址,看到的内容就是一致的。

2.2 消息同步用信号还是中断

两个核同时操作一块内存,必须解决同步问题。我的做法是:共享区头部放一个环形缓冲队列,里面只放轻量级的控制消息,比如"新帧已写入第0号缓冲""请求切换显示区域""显示任务需要休眠"这类指令。真正的大块像素数据不经过队列,直接写到帧缓冲区内,写完之后通过内存屏障保证数据落盘,再往队列里塞一条完成消息。

消息从 Linux 侧发给 LiteOS 侧,使用的是核间中断。Linux 侧写队列,然后向核1 发送一个软件中断;LiteOS 侧中断服务程序收到中断后,读队列处理消息。反向则更简单,LiteOS 侧往队列里写状态,Linux 侧轮询读取,因为 Linux 是主控侧,轮询对它来说没有实时性压力。

这里特别提醒一点:共享内存的读写顺序非常重要。跨核通信没有天然的锁机制,发送方必须先写完数据再发中断,接收方必须在收到中断后才开始读。如果 Linux 侧先写了 "准备中" 标记,再写数据,LiteOS 侧有可能在标记位置看到新值,数据还没写完就开始读,轻则画面花屏,重则读到半截数据导致系统异常。正确做法是:先倒序写数据区,最后才写状态标记,再接一次内存屏障指令。

3. LiteOS 侧驱动编写的核心细节

3.1 引脚初始化和复位时序

屏幕的初始化代码一般都在上电阶段完成。SPI 的四个引脚需要用 GPIO 模式先配置,然后按照屏幕规格书的时序要求走一遍复位流程。我用的屏要求 RESET 拉低超过 120 微秒,然后拉高,拉高后至少等待 150 毫秒才能开始发初始化命令序列。这个时间不能省,否则屏幕可能初始化不彻底,出现花屏或者部分区域无法显示。

LiteOS 下做延时,不能用 Linux 的 usleep。LiteOS 的任务延时是基于 tick 的,最小粒度通常是 10ms,精确到微秒要用忙等待。我自己写了一个微秒延时函数,用 GPIO 输出翻转配合循环计时实现。虽然忙等待浪费 CPU,但只在初始化阶段用到,实测耗时不长,可接受。

初始化命令序列是照着屏幕驱动 IC 的寄存配置表进行的,通常包括关闭显示、设置扫描方向、设置像素格式为 RGB888、打开显示等步骤。每一组命令之间必须按规格书要求插入延时,尤其是打开显示前的那次延时,过长过短都会导致显示异常。

3.2 RGB888 像素在 SPI 上的排布

RGB888 格式下,一个像素由红、绿、蓝三个分量组成,每个分量占 8 位。SPI 是串行接口,所以一个像素在总线上的发送顺序是:红分量一字节、绿分量一字节、蓝分量一字节。就这么简单的顺序,却有一个非常常见的坑:字节内位序。SPI 通常要求高位先出,也就是 MSB first。如果你的 SPI 控制器默认配置成 LSB first,屏幕收到的是一个镜像后的字节,画面颜色会完全不对。

我刚点亮屏幕的时候,画出来的红色变成了蓝色,百思不得其解。后来查了 SPI 控制寄存器,发现工作在 LSB first 模式。改成 MSB first 之后颜色就正常了。这点看起来小,但排查起来非常耗时间,建议初始化 SPI 时直接确认位序配置。

还有一个细节:像素数据写入 GRAM 的坐标顺序。RGB888 OLED 屏的 GRAM 通常按行列展开,240 列乘以 320 行。写入时需要先设置列地址和行地址范围,然后连续发送数据。如果不按范围按连续性发送,而是逐点设置坐标再写一个像素,那刷一帧画面对 SPI 来说就是灾难性的慢,240x320 次坐标设置,每设置一次都要几个时钟周期。

正确做法是:一帧画面整块写入,初始化时设置好列地址为 0 到 239、行地址为 0 到 319,然后连续发送 230400 字节的数据。这样 SPI 控制器能全速工作,实测 40MHz 时钟下刷一帧大约需要 46 毫秒,基本满足 20 帧每秒的刷新率。

3.3 LiteOS 显示任务怎么设计

LiteOS 侧显示功能设计成一个独立任务,优先级设得比较高,但低于硬实时控制任务。任务主循环里做两件事:等事件、刷屏幕。等的事件有两个来源:一个是 Linux 侧通过核间中断唤醒的"新帧到达"事件,另一个是 LiteOS 侧定时器产生的周期性刷新事件。

周期性刷新很重要,因为如果 Linux 侧长时间没有发新帧,画面也不能停留在旧数据上,需要有清屏或者显示默认状态的操作。我的显示任务里设了一个 100 毫秒的周期,每次轮询检查"是否有新帧待显示",有就刷,没有就跳过。

任务的栈大小也要注意。屏幕驱动函数里如果用了较多的局部变量,比如行缓冲、临时数组,栈容易爆。我把行缓冲分配在了静态区或者堆里,只保留必要的指针和循环变量在栈上,避免 LiteOS 任务栈溢出导致随机重启。这个问题在嵌入式里很隐蔽,表现出来就是程序跑着跑着突然复位,查了很久才发现是栈不够。

4. 整帧渲染流程与效果优化

4.1 从共享内存到屏幕的完整链路

现在理一下整条数据链路,方便你复现的时候对照检查。

第一步,Linux 侧应用进程打开 /dev/mem,remap 共享区,得到一个用户空间指针。应用把界面内容用 CPU 渲染到共享区的帧缓冲0 中,渲染完成后写队列消息,然后触发核间中断。

第二步,LiteOS 侧中断服务程序收到中断,读取队列消息,置位一个事件标志量。显示任务被唤醒,检查事件标志,确认帧缓冲0 有数据,把帧缓冲0 的地址传给屏幕驱动写入函数。

第三步,屏幕驱动把帧缓冲0 的 230400 字节数据按 RGB888 格式连续发送到 SPI,写完后再等待一个"显示完成"回读标志。

第四步,Linux 侧通过共享内存里的状态字段得知当前帧已显示完成,然后开始渲染下一帧,写入帧缓冲1。两块帧缓冲轮流使用,可以达到 double buffer 的效果,有效避免画面撕裂。

这个链路里最耗时的是第三步的 SPI 传输。如果按 50MHz SPI 时钟算,230400 字节需要 36.8 毫秒传输时间,考虑命令开销,一帧大概 40 毫秒。实测刷新率在 24 帧每秒左右。

4.2 刷新率不够时怎么办

如果屏幕更大或者数据量更高,刷新率会变成瓶颈。有几个优化方向值得尝试。

第一个方向是提高 SPI 时钟。部分 OLED 驱动 IC 支持 60MHz 甚至更高的 SPI 时钟,但在高频下,PCB 走线质量会影响信号完整性。我试过把时钟从 40MHz 提到 60MHz,屏幕显示开始出现少量噪点,说明链路已经在临界状态了,后来又降回 50MHz 稳定使用。

第二个方向是裁剪画面区域。不是每一次画面都需要整帧刷新。仪表盘项目里,背景是静态的,只有指针区域在变化。我做了区域裁剪功能,驱动层支持传入一个设定范围的坐标,只刷新变化的部分。实测局部刷新模式下,指针区域(比如 80x80 像素)一次刷新只需要 96KB 数据,传输时间降到 15 毫秒左右,体感流畅度提升非常明显。

第三个方向是降低色深。某些需求不需要真彩,RGB565 格式的数据量只有 RGB888 的三分之二,虽然屏幕主控支持 RGB888,但你可以把像素转成 RGB565 后用软件拉伸的方式发送,牺牲部分色彩换取速度。这个要看你项目的具体取舍,我这边因为要显示波形曲线,颜色区分度要求高,所以保留了 RGB888。

4.3 显示内存与 CPU 拷贝的开销

还有一个容易被忽略的消耗:把像素数据从共享内存搬到 SPI 发送过程里,牵涉到逐字节拷贝。即使用到 DMA,数据源地址到外设寄存器之间也需要先做一次内存读取。230400 字节在 40MHz SPI 下至少需要 46 毫秒,其中读取像素实际消耗的时间不多,但每次循环的地址递增和发送判断会拉低效率。

我在驱动里做了一层小优化:把 SPI 发送函数改成按 32 位为单位读取缓冲区,解包出 4 个像素后一次性写入发送寄存器。实测比逐字节发送节省了约 8% 的 CPU 时间,而且代码逻辑也不复杂,就是用循环展开替代了单字节循环。需要注意的是解包时字节序要匹配,否则颜色会错位。

5. 常见问题与排查手册

5.1 花屏和乱色

花屏是我遇到最多的现象,原因通常集中在三个地方:初始化时序不对、SPI 位序不对、数据总线干扰。

初始化时序不对的表现是屏幕有背光但无内容,或者出现大块色带。排查方法是用逻辑分析仪抓复位引脚和命令引脚的电平变化,对照规格书时序图逐步核对。重点确认 RESET 拉低时间是否足够、命令之间延时是否符合要求。

颜色错乱的表现是红色变成绿色、蓝色变成黄色这类固定偏移。基本就是 RGB 分量顺序错误或者 SPI 位序反了,改一下字节序或者检查 MSB/LSB 配置就能解决。

画面出现随机杂点则是数据链路不可靠,优先降低 SPI 时钟频率,检查屏幕排线是否过长或者存在干扰。我试过用杜邦线连接屏幕,在 40MHz 下就会出现杂点,焊接到板上之后问题消失。

5.2 画面撕裂和闪烁

双缓冲方案能解决大部分撕裂问题,但还有一个前置条件:Linux 侧渲染和 LiteOS 侧刷新之间必须保证"渲染完才切换"的语义。如果 Linux 侧写帧缓冲的过程中,LiteOS 侧正好在读取同一块缓冲,就会出现半帧新画面和半帧旧画面叠在一起的效果。

解决方案是在共享区增加一个"busy"标志。Linux 侧开始渲染前置位 busy,渲染完成清 busy 并发送中断。LiteOS 侧收到中断后检查 busy,如果为 0 则开始刷新,如果不为 0 说明 Linux 还在写,就延后到下一轮。这个规则虽然简单,但保证了读写互斥,实测撕裂完全消失。

闪烁问题大多来自刷新间隔不匹配。当 SPI 传输速度较慢,而 Linux 侧渲染速度较快时,画面会不断追赶,产生一种"跳变"的闪烁感。解决方法是让 Linux 侧渲染按帧进行节奏控制,每发一帧等 LiteOS 侧确认完成后再渲染下一帧,形成一个小型的生产者-消费者模型。

5.3 共享内存数据不同步

我调试时遇到过一种诡异现象:Linux 侧明明已经发送了中断,LiteOS 侧读取共享缓冲时还是旧数据。排查后发现问题出在内存屏障上。ARM 架构对内存访问有重排的可能,发送方写完数据之后必须加一条内存屏障指令,确保数据真的写入物理内存后再触发中断。接收方收到中断后,同样需要屏障确保读取顺序正确。

在 LiteOS 侧,我用的编译器提供的屏障内建函数。在 Linux 侧,用 mmap 访问物理内存时也需要注意映射类型。如果映射成了 write-back 缓存模式,CPU 写的数据会先停在缓存里,需要手动 fsync 或者使用 uncached 映射。我最终把共享区映射配置为 uncached,才彻底解决了数据不同步的问题。

5.4 排查工具和数据抓取技巧

嵌入式调试显示问题,光靠眼睛看是不够的。我的建议是按以下步骤排查:

先用写死颜色测试:驱动初始化完成后,在帧缓冲里填纯红色、纯绿色、纯蓝色,看屏幕是否正常显示这三种颜色。这一步能排除大部分数据通路问题。

再用渐变色条测试:显示一组从左到右红到蓝渐变的色条,检查颜色过渡是否平滑。如果出现横条纹或者颜色跳跃,一般可以定位到数据字节序或者坐标范围配置错误。

最后用实时波形测试:在 LiteOS 侧生成一组正弦波数据,填入局部显示区域,检查刷新时是否有拖影和撕裂。

这套三层测试流程让我少踩了很多坑,强烈建议照着做一遍。

6. 最终配置参考表

顺手整理一份我能稳定运行的配置,供参考。这批参数是我在这个平台上实测跑通的,放到你的板子上最好还是要对着规格书核一遍。

配置项参数备注
SPI 时钟40MHz50MHz 可稳定,60MHz 有风险
SPI 位序MSB first低位先出导致颜色错乱
像素格式RGB888每像素 3 字节
分辨率240x320对应屏幕规格
帧缓冲2 块,共 460KB双缓冲避免撕裂
显示任务优先级中级低于硬实时控制任务
共享内存映射uncached避免缓存不一致
刷新模式整帧 + 局部裁剪动态区域用局部刷新

命令序列部分,我把关键的初始化配置写在下面,你可以根据自己屏幕的驱动 IC 做替换:

  • 关闭显示:0x00 0x00,发完后延时 20 毫秒
  • 设置扫描方向:按屏幕模组引脚定义,主要是上下左右扫描顺序
  • 设置像素格式:RGB888 对应 0x06
  • 打开显示:0x00 0x00,发完后延时 30 毫秒

个人实操体会

我在这个项目里最大的体会是:双系统显示问题,七成不在驱动本身,而在跨系统数据同步上。屏幕驱动只要对照规格书一点点调,总能点亮的;真正折腾时间的是搞清两边内存访问语义的一致性和时序正确性。所以如果你跟我一样用双核异构方案接屏,先把共享内存模型想清楚,再动手写驱动,会省很多返工的时间。

另一个小技巧是:SPI 发送数据前尽量把像素数据按行连续存放,避免在发送路径里做大量地址跳变。如果发现刷新率不稳,优先检查 SPI 时钟和帧缓冲读取时间,这两个是最常见的瓶颈。最后建议做一版带调试计数的驱动,打印刷新耗时、丢帧数、SPI 错误计数,对后续调优会非常有帮助。

RELATED READING

延伸阅读

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