搞嵌入式 Linux 的都知道一个现象:rk3506 这类低功耗 SoC 跑个裁剪过的小系统非常顺手,但你真要在上面加一块屏幕,第一反应往往是 RGB 屏、MIPI 屏或者至少 SPI 屏。可实际项目里我碰到过不少场景,就是不需要那么高的刷新率和分辨率,只想在设备外壳上开个小窗,显示个 IP 地址、温度、状态灯或者电池电量。这时候 I2C 接口的小 OLED 屏反而是最省事的选择——两根信号线就能搞定显示,还不占那些宝贵的高速接口。
我这次在一块 rk3506 板卡上完整走了一遍 I2C 屏幕的接入流程,从硬件接线、总线扫描、用户态点亮到内核帧缓冲驱动,中间踩了不少坑,也理清了很多网上教程没讲透的细节。这篇文章就把整个链路拆开讲清楚,核心关键词就围绕 rk3506 驱动 I2C 接口屏幕这件事。适合正在用 rk3506、RK 系列其他 SoC 做产品原型,或者第一次在嵌入式 Linux 上接 I2C 小屏的朋友参考。
1. 为什么要在 rk3506 上挂一块 I2C 屏幕
1.1 先分清屏幕接口的适用边界
很多人一看到"屏幕"两个字就觉得应该走大屏接口。实际上 I2C 接口的屏幕在嵌入式产品里是一个很特殊的存在:它牺牲了带宽,换来了极简的硬件连接和极低的引脚占用。我们先把几种常见接口放在一起看一眼:
| 接口类型 | 信号线数量 | 典型速率 | 适合场景 | 接入复杂度 |
|---|---|---|---|---|
| RGB 并口 | 20~40 根 | 数百 Mbps | 大尺寸、高刷新率 LCD | 引脚多,布线麻烦 |
| MIPI DSI | 差分串行 | Gbps 级 | 手机、平板类高分屏 | 需要 PHY 和 DSI 控制器 |
| SPI | 4~6 根 | 几十 Mbps | 小型 TFT、电子墨水屏 | 速率够,连接简单 |
| I2C | 2 根(SCL/SDA) | 100K~1MHz | 单色小屏、状态显示 | 最省引脚,但带宽最低 |
I2C 屏幕的典型代表就是 0.91 寸、0.96 寸、1.3 寸的 OLED 小屏,驱动芯片常见的是 SSD1306、SH1106 这类。它们内部自带显存,外部只要通过 I2C 总线往显存里填数据,驱动芯片就会自动把内容刷新到面板上。换句话说,rk3506 不需要直接去控制每一根液晶电极,只需要扮演一个"数据填充器"的角色。
1.2 rk3506 的资源对 I2C 屏幕来说绰绰有余
rk3506 这颗 SoC 我在实际项目里用下来,印象最深的是它的外设资源分配很灵活,而且功耗控制做得好。它内部通常有多组 I2C 控制器,可以同时挂传感器、RTC、触摸芯片和屏幕。哪怕你的系统里已经有一堆 I2C 设备,再挂一块小屏也不会有太大的总线压力,因为屏幕本身的通信量非常可控——一帧 128×64 单色显存满打满算也就 1KB。
还有一点容易被忽略:这类小屏非常适合跟其他低速设备共享总线。比如板上已经挂了传感器,地址规划合理的情况下,屏幕和传感器可以挂在同一条 I2C 总线上,连线更短,布线也更干净。对一个讲究紧凑布局的便携设备来说,这种灵活性是很有价值的。
1.3 什么场景适合,什么场景千万别用
先说能用的场景:设备的状态小窗、日志显示、时间信息、简单的图形化图标、极简的菜单界面、调试用的输出面板。这些内容变化频率低,一秒钟刷新几次已经非常流畅了。
不适合的场景也很明确:视频播放、复杂的 GUI 动画、大量文字滚动。I2C 屏的带宽就摆在那里,你强行跑高刷新率内容,结果就是界面肉眼可见地一卡一卡,体验很差。所以我的判断标准很简单:如果这个屏幕只需要显示"有限的信息量",I2C 是优选;如果需要流畅的动态界面,还是老老实实上 SPI 甚至 RGB 屏。
2. 先把硬件链路打通:引脚、上拉与从地址
2.1 查原理图确认 I2C 引脚与 pinctrl 复用
任何软件上的驱动问题,最后都能追溯到硬件层面的一两个细节。第一步一定是去查你手上这块板子的原理图,确认 rk3506 的哪组引脚被引出为 I2C 功能。注意 rk3506 的引脚往往有多个复用功能,同一组引脚可能既能当 I2C 用,也能当 GPIO 或者 UART 用。如果你设备树里没有配置好 pinctrl,或者引脚被其他驱动占用了,那屏幕是无论如何都拉不起来的。
我这次用的板子把 I2C1 引到了排针上,SCL 和 SDA 分别对应一组带内部上拉的引脚。但这里要提醒一句:很多 I2C 屏幕模块板上自带上拉电阻,有些则不带。如果总线上没有上拉,SDA 和 SCL 在高电平时会处于不确定状态,i2cdetect 会直接扫描不出设备。最稳妥的办法是在屏幕模块的 SCL、SDA 对 VCC 之间接两个 4.7k 欧姆的电阻。如果板上已经焊了,就不用重复加,并联太多上拉反而会把总线拉得太强,影响信号边沿。
2.2 屏幕模块的从地址与 SA0 引脚
I2C 总线上每个设备都有一个从地址。SSD1306 这类芯片的 7 位从地址通常是 0x3C 或者 0x3D,具体取决于芯片的 SA0 引脚电平。SA0 接地时地址是 0x3C,接高电平时地址变成 0x3D。大多数市面上常见的 0.96 寸模块都默认把 SA0 拉低,所以 0x3C 是最常见的地址。
这里面有个新手特别容易踩的坑:很多人对着数据手册看到地址 0x7C 或者 0x78,那是 8 位地址的写法。I2C 总线底层传输时,7 位地址左移一位再拼上读写位,所以 0x3C 左移一位就是 0x78。你用 i2c-tools 扫描和编程时,用的是 7 位地址 0x3C,但如果你直接拿 0x78 去写寄存器,就错了。这一点在写内核设备树时同样适用,reg 属性填的是 7 位地址。
2.3 上电前的检查单和第一轮总线扫描
在写任何代码之前,我强烈建议先完成一次"裸硬件验证"。具体的检查顺序我习惯这样走:
- 确认屏幕供电电压。绝大多数小屏模块是 3.3V 供电,别直接怼 5V,有些模块没有电平转换,会烧掉。
- 用万用表量一下屏幕模块的 VCC 和 GND,确保电源正常。
- 确认 SCL、SDA 有没有接反。这是个很低级但发生率很高的错误。
- 看模块有没有 RES(复位)引脚。很多模块把 RES 直接接高电平或者通过电容接到 VCC,自动上电复位;有些需要你手动拉一下,否则芯片状态不确定。
硬件确认无误后,进系统先用 i2c-tools 看一眼总线情况:
i2cdetect -l这行命令会列出系统里所有的 I2C 总线。比如i2c-1对应 I2C1。接下来对目标总线做扫描:
i2cdetect -y 1如果硬件连接正常,你会看到某个地址位(比如 0x3c)显示为UU或者直接显示数字。有个细节要注意,UU表示该地址被内核驱动占用了,数字表示设备在线但没驱动接管。如果你看到的是--,那基本可以确定屏幕没在总线上,先回到硬件环节排查。
3. 软件驱动的两条路线:内核帧缓冲还是用户态直控
3.1 两条路线的对比
I2C 小屏在嵌入式 Linux 下的驱动方式,大方向上分成两种。
第一种是走内核标准的帧缓冲驱动。Linux 内核里有一个名为 ssd1307fb 的驱动,虽然它的名字带 SSD1307,但实际上兼容 SSD1306、SSD1309 这一系列芯片。启用它之后,系统会生成一个/dev/fb0设备,你往这个设备里写像素数据,驱动内部会负责把显存内容同步到屏幕。好处是上层软件不用关心 I2C 细节,LVGL、Qt 这类 GUI 库只要支持 fbdev,就能直接渲染。
第二种是用户态直控,也就是通过/dev/i2c-x设备节点自己操作 I2C。你手动拼装命令字节、初始化序列、显存数据,全部在用户态完成。好处是完全可控、调试直观、不用反复编译内核,坏处是上层 GUI 库跟你之间得自己写一层适配。
两条路线并不冲突,而且我建议的顺序是:先用第二种点亮屏幕,确认硬件和初始化序列没问题,再按需转到第一种。
3.2 为什么我建议先从用户态点亮
原因很现实:改内核、编译、烧录、重启这套流程的成本太高了。rk3506 上跑的内核一般是通过 Buildroot 定制的,改一个内核配置或者设备树就要完整走一遍编译链,一次来回可能就是几十分钟甚至一两个小时。而 I2C 屏幕的初始化序列就那么二十来条命令,在用户态用 i2c-tools 手动敲几条命令,或者写个小程序几十行代码,就能验证 90% 的问题。
而且就算你最终要用内核 fb 驱动,初始化序列和寄存器配置的逻辑是完全相通的。用户态验证通过,说明硬件、I2C 时序、地址都对,这时候再切内核驱动,只需要处理设备树和配置参数,几乎不会遇到方向性的难题。
3.3 I2C 屏幕驱动拆解:控制字节、命令、显存
说到写代码,必须先理解 I2C 屏幕的数据格式。跟普通 I2C 从设备不一样,SSD1306 这种屏在一次传输里不是简单地把寄存器地址放在第一个字节,而是用一个控制字节来区分后面跟的是命令还是数据:
- 控制字节
0x00:表示后面跟着的全部是命令。 - 控制字节
0x40:表示后面跟着的是显存数据。
举个例子,你想发送"打开显示"命令 0xAF,那么你要在 I2C 总线上依次发送0x00 0xAF两个字节。而如果你想往显存里写 128 个字节的像素数据,就要先发0x40,然后紧跟这 128 个数据字节。
很多新手第一次写驱动,光顾着查命令表,忘了控制字节这回事,结果初始化序列全部按数据格式发出去了,屏幕当然没反应。这是一个看起来很简单、但定位起来很头疼的点。
4. 用户态手写点亮 SSD1306:从初始化序列到像素刷新
4.1 在 rk3506 上操作 I2C 设备的基础 API
Linux 用户态操作 I2C 的标准方法是用open()打开/dev/i2c-1,然后用ioctl()设置从设备地址,最后用read()、write()来做数据传输。流程大致是:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <linux/i2c-dev.h> #include <sys/ioctl.h> #define I2C_BUS "/dev/i2c-1" #define OLED_ADDR 0x3C static int oled_fd; static int oled_open(void) { int fd = open(I2C_BUS, O_RDWR); if (fd < 0) { perror("open i2c bus"); return -1; } if (ioctl(fd, I2C_SLAVE, OLED_ADDR) < 0) { perror("ioctl I2C_SLAVE"); close(fd); return -1; } return fd; }这里有一个重要认知:I2C_SLAVE设置好从地址后,后面所有write()都会自动带上这个从地址,你不用自己拼地址字节。每次 write 的 buf 里,只需要按上一节说的控制字节 + 命令/数据的格式组织内容。
4.2 SSD1306 初始化序列逐条拆解
下面这段是 SSD1306 最常用的一套初始化序列,我跳过了配置细节,只列出必要命令。这些命令的含义我逐条解释一遍,这样你遇到其他芯片或者类似问题的时候,也能自己对照着改。
static int oled_write_cmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; if (write(oled_fd, buf, 2) != 2) { return -1; } return 0; } static int oled_write_cmd_seq(const uint8_t *cmds, int len) { for (int i = 0; i < len; i++) { if (oled_write_cmd(cmds[i]) < 0) { return -1; } } return 0; } static const uint8_t oled_init_cmds[] = { 0xAE, // display off,先把屏幕关掉,避免初始化过程中出现乱码闪烁 0x20, 0x00, // 设置内存寻址模式为 horizontal addressing mode 0xB0, // 设置页地址为 page 0 0xC8, // COM 扫描方向反向,这样左上角才是 (0,0) 0x40, // 显示起始行设为第 0 行 0x81, 0xCF, // 设置对比度,0xCF 是常见的合适值,太高会发白 0xA1, // 段重映射,默认从左到右,某些模块需要这个命令才方向正确 0xA6, // 正常显示,黑色背景白色前景 0xA8, 0x3F, // 多路复用比率设置为 1/64,对应 64 行 0xD5, 0x80, // 显示时钟分频,默认值就行 0xD3, 0x00, // 显示偏移设为 0 0x8D, 0x14, // 开电荷泵,这一步漏了屏幕会一直黑屏 0xAF // display on };其中最容易出问题的命令是0x8D, 0x14。电荷泵是 OLED 面板内部产生驱动电压的关键,如果它没打开,面板就是纯黑的,但 I2C 通信一切正常。遇到"能扫描到设备但屏幕不亮"的情况,十有八九就是这个寄存器没设对。
4.3 显存布局与页地址模式
SSD1306 的分辨率是 128×64,它的显存组织方式很特别:不是一维的像素数组,而是分成了 8 页(page),每一页有 128 字节,每个字节代表纵向的 8 个像素。简单说,第 0 页管第 0~7 行,第 1 页管第 8~15 行,依此类推。
所以你要在屏幕上画一个点 (x, y),首先算出它属于哪一页:page = y / 8,再算出它在该页中的位:bit = y % 8。像素置位的逻辑就是把这个字节对应 bit 位置 1。
static uint8_t framebuffer[8][128]; // 8 pages, 128 columns static void oled_set_pixel(int x, int y, int on) { if (x < 0 || x >= 128 || y < 0 || y >= 64) { return; } uint8_t page = y / 8; uint8_t bit = y % 8; if (on) { framebuffer[page][x] |= (1 << bit); } else { framebuffer[page][x] &= ~(1 << bit); } }刷新屏幕的流程是:设置列起始地址和列结束地址,再设置页地址,然后把这一页的 128 字节数据连续发过去。在 horizontal addressing mode 下,写完一页的 128 列之后,页地址会自动加一,所以可以一次把整屏 1024 字节刷出去。
static int oled_fill(uint8_t value) { memset(framebuffer, value, sizeof(framebuffer)); return 0; } static int oled_flush(void) { uint8_t buf[1 + 1024]; buf[0] = 0x40; // control byte for data memcpy(buf + 1, framebuffer, 1024); if (write(oled_fd, buf, sizeof(buf)) != sizeof(buf)) { return -1; } return 0; }注意这里我把 1024 字节的全部数据拼成一次write()调用。因为 I2C 协议本身就是多字节连续传输,拼成一个大 buf 可以减少总线上的启停开销,效率比逐字节写高得多。实测下来,一次刷整屏的耗时比逐字节写至少快 40%。
4.4 完整调用流程
代码写完后的调用顺序大概是:
oled_fd = oled_open(); oled_write_cmd_seq(oled_init_cmds, sizeof(oled_init_cmds)); oled_fill(0x00); // 清屏 oled_flush(); oled_set_pixel(64, 32, 1); // 画中心点 oled_flush(); close(oled_fd);这个流程走通之后,你就拥有了一台"可以用用户态代码控制"的 I2C 屏幕。从验证硬件的角度来说,到这里已经足够了。如果你只是做调试输出,那根本不需要内核驱动,直接在这个框架上继续画画写字就行。
5. 内核帧缓冲路线:ssd1307fb、设备树与 /dev/fb0
5.1 内核配置与驱动兼容
如果产品最终要跑 LVGL 这类图形框架,或者你想用标准的方式操作屏幕,那就把驱动搬进内核。Linux 内核里现成的驱动是ssd1307fb.c,对应的 Kconfig 选项是CONFIG_FB_SSD1307。你需要在内核配置里把这个选项打开,同时打开CONFIG_FB和对应的 I2C 支持。
这里有个容易被绕进去的点:驱动名字叫 ssd1307,但它的 compatible 支持 SSD1306、SSD1307、SSD1309 等芯片。你在设备树里写 compatible 时,用solomon,ssd1306fb-i2c即可,驱动会对这一系列芯片做适配。内核里有这个驱动的 binding 文档,遇到配置属性不确定时,优先去翻内核源码里的Documentation/devicetree/bindings/display/solomon,ssd1307fb.yaml。
5.2 设备树节点写法和每个属性为什么需要
下面是一个在 rk3506 设备树里挂 OLED 的典型节点:
&i2c1 { status = "okay"; clock-frequency = <400000>; pinctrl-names = "default"; pinctrl-0 = <&i2c1_xfer>; ssd1306: oled@3c { compatible = "solomon,ssd1306fb-i2c"; reg = <0x3c>; solomon,width = <128>; solomon,height = <64>; solomon,page-offset = <0>; }; };逐个解释这些属性的含义:
pinctrl-0:把引脚的复用功能配置为 I2C1。这一步容易漏,漏了之后 i2c1 总线可能都不存在。clock-frequency = <400000>:把 I2C 总线频率提到 400KHz 的模式。默认可能是 100KHz,对小屏来说 100K 也能用,但刷新会明显变慢。reg = <0x3c>:7 位从地址,和用户态时一致。solomon,width/solomon,height:屏幕尺寸。128×64 是最常见的组合。solomon,page-offset:页偏移。某些屏幕的第一页对应显存的第 0 页,但有些模块会有偏移,默认 0 即可,遇到内容整体上下错位时再尝试改这个值。
写完设备树,重新编译内核和 DTB,烧录进系统之后,如果一切正常,你会看到新的帧缓冲设备出现:
ls /dev/fb*5.3 启用 fb 后怎么快速验证
最简单的验证方法是用dd命令往/dev/fb0里扔数据。比如:
dd if=/dev/zero of=/dev/fb0 bs=1024 count=1这一步如果驱动工作正常,屏幕会被清成黑色。想看更直观的效果,可以把随机数据写进去:
dd if=/dev/urandom of=/dev/fb0 bs=1024 count=1屏幕上应该会出现满屏的雪花点。这看起来是乱码,但恰恰说明驱动已经把显存内容成功映射到了 OLED 上。确认这一步之后,你就可以把像素格式、颜色深度这些问题交给上层处理了。
要注意的是,ssd1307fb 驱动创建的 fb 设备是 1bpp 的,也就是每个像素只占一个 bit。如果上层 GUI 库默认按 16bpp 或者 32bpp 渲染,画面会完全错乱。对接 LVGL 的时候,色彩深度一定要设置成 1bpp,或者自己写一个中间层做格式转换。
6. 实测踩坑与完整排查链路
6.1 现象一:i2cdetect 扫不到设备
这个现象我这次也遇到了。排查链路从最底层开始:
- 先用
i2cdetect -l确认总线存在。如果总线列表里根本没有 i2c-1,说明设备树里 i2c1 节点没有使能,返回去查status = "okay"和 pinctrl 配置。 - 用万用表量 SCL 和 SDA 的对地电压。正常工作状态两根线应该在 3.3V 左右,如果 SDA 被拉低且一直在 0V 附近,说明总线卡死,常见原因是上拉电阻缺失或者某个设备地址冲突。
- 断开屏幕模块,再量一次。如果断开后电压恢复正常,问题大概率在模块自身。
- 把 I2C 速率降到 100KHz 再试。有些屏幕模块的时序余量比较差,在 400K 下就是扫描不到,降速之后立刻正常。
- 确认地址。把屏幕的 SA0 引脚电平换一下,看地址是否从 0x3C 变成 0x3D,这样能确认芯片本身是在响应总线的。
6.2 现象二:扫到了地址但屏幕一直黑屏
能扫描到地址说明物理链路通了,黑屏说明芯片没有进入正常显示状态。参照我在 4.2 节的初始化序列,一条条排查:
- 先手动敲命令测试:
i2cset -y 1 0x3c 0x00 0xAF。如果有反应(哪怕只有一点点亮),说明显示屏驱动芯片基本工作正常。 - 重点检查电荷泵命令
0x8D 0x14。这是我踩过的坑里出现频率最高的问题,没有开启电荷泵,屏幕就是永久性黑屏。 - 检查对比度。复位默认对比度可能很低,某些模块上几乎看不清。初始化序列里
0x81 0xCF可以把对比度拉到足够高。 - 检查屏幕模块上有没有 RES 引脚。如果有,尝试手动拉低再拉高复位一次,有些芯片上电时序不对会停在异常状态。
- 检查控制字节是不是漏了。往屏幕发数据时第一个字节必须是
0x40,发命令时第一个字节必须是0x00,搞混了屏幕会毫无反应。
6.3 现象三:画面内容错乱、偏移或者有残影
画面能点亮但内容不对,这通常是显存寻址或者扫描方向的问题。常见两种情况:
第一种,整体左右镜像。把初始化序列里的0xA1和0xA0互换一下,就可以切换段重映射方向,解决左右镜像。
第二种,整体上下偏移几行。先检查solomon,page-offset的设置,再检查初始化序列里的起始行命令0x40。起始行改成其他值也可以实现内容整体上下移动,但如果你只是想让左上角对齐,保持0x40即可。
残影问题比较特殊。OLED 本身响应快,理论上不应该有残影。如果你看到的是"上一帧内容残留",多半是刷全屏时只写了部分页,比如有些驱动的刷新函数只刷了 6 页而不是 8 页,就会导致下面两行内容永远停留在旧状态。检查刷新逻辑里循环页数是不是 8。
6.4 一套递进验证的思路整理
我把整个排查过程总结成一个递进清单,每步都能独立验证,哪一步出了问题就能精确锁定范围:
- 总线扫描:
i2cdetect -y 1,确认设备在线,对应地址 0x3C。 - 寄存器响应:
i2cset -y 1 0x3c 0x00 0xAF,确认芯片能接收基本命令。 - 单点像素:用户态程序画一个固定像素并刷屏,确认显存写入通路正常。
- 矩形区域:刷新一个已知矩形,确认页地址和列地址计算正确。
- 整屏刷新:确认所有 8 页数据都能写进去,没有遗漏页。
这套链路真的省了我很多时间。很多时候直接跳到最后一步整屏刷新,出了问题根本分不清到底是地址、显存还是命令的问题。
7. 性能估算与后续扩展方向
7.1 帧率上限的粗算
既然用 I2C 屏幕,心里得对性能有个数。我们以 400KHz 的 I2C 总线为例,传输一个字节需要 9 个时钟位(8 个数据位加 1 个 ACK 位),一字节耗时约 22.5 微秒。刷一屏需要 1024 字节数据,加上控制字节和命令开销,单帧传输时间大约 25~30 毫秒。这意味着一秒钟只能刷 30 到 40 帧,而且这是理论极限,实际还得算上 CPU 处理时间、总线仲裁这些开销。
所以我的建议是:I2C 屏不要做全屏高频刷新,而要尽量利用局部刷新。SSD1306 的 horizontal addressing mode 支持你设置列起始和结束地址,把刷新范围限定在一个矩形区域里。比如只显示了一个数字变化,那就只刷新数字所在的 16×32 区域,帧率体验会好很多。
7.2 局部脏矩形刷新的实现思路
实现局部刷新的核心是维护一个"脏矩形"区域。每次画面变化时,记录变化区域的左上角和右下角,然后在刷屏函数里把这个区域划分到对应的页范围,只传输这些数据。大致逻辑:
static int oled_flush_rect(int x0, int y0, int x1, int y1) { int start_page = y0 / 8; int end_page = y1 / 8; for (int page = start_page; page <= end_page; page++) { int col_start = x0; int col_end = x1; // 设置列地址 uint8_t cmd_col[] = {0x00, 0x21, col_start & 0xFF, col_end & 0xFF}; write(oled_fd, cmd_col, sizeof(cmd_col)); // 设置页地址 uint8_t cmd_page[] = {0x00, 0x22, page, page}; write(oled_fd, cmd_page, sizeof(cmd_page)); // 写入该页数据 uint8_t buf[1 + 128]; buf[0] = 0x40; memcpy(buf + 1, framebuffer[page] + col_start, col_end - col_start + 1); write(oled_fd, buf, 1 + col_end - col_start + 1); } return 0; }注意写入数据量只要col_end - col_start + 1个字节,而不是从列 0 开始的一整行。这样能进一步压缩单次传输的数据量。
7.3 对接 LVGL 时要注意的几个点
如果你的项目最终要跑 LVGL,需要写一个显示驱动回调。我是这样做的:
- 在
lv_conf.h里把色彩深度设成LV_COLOR_DEPTH 1。 - 在
lv_port_disp.c的disp_flush回调里,把 LVGL 传入的脏矩形区域转换成 SSD1306 的页地址和列地址,然后调用类似oled_flush_rect的接口。 - 禁用 LVGL 的全屏缓冲,改用小尺寸的 draw buffer。因为 1bpp 色彩深度下每个像素只占 1 bit,一个 128×64 的缓冲也才 1KB,对 rk3506 这种内存不算富裕的 SoC 很友好。
- 留意 LVGL 的坐标系方向。如果屏幕内容上下颠倒,不用去改 LVGL 代码,直接调整初始化序列里的 COM 扫描命令更省事。
我实际调试 LVGL 对接时,最大的坑是 flush 回调里忘了把 area 的 width 按 8 对齐。SSD1306 页操作的粒度是 8 个像素,如果脏矩形高度不是 8 的倍数,最后一页会多刷或者少刷数据,导致底部出现细线闪烁。解决办法是在回调里把 y 坐标向下对齐到 8 的倍数再刷新。
7.4 最后分享一点个人体会
rk3506 驱动 I2C 屏幕这件事,做到后面你会发现,技术本身并不难,难的是把一个"看起来简单"的问题在整套 Linux 链路里定位清楚。我这次的教训就是:初始化序列先逐条验证,显存布局一开始就要画清楚,设备树属性不要照抄,每个字段都去看看内核 binding 文档。如果过程中遇到黑屏,别急着怀疑代码,先确认电荷泵、复位引脚、上拉电阻这三件事。I2C 屏幕的本质是低速、低信息量的显示设备,把它用在对的地方,它会给你的项目带来很多便利。