资讯动态

rk3506驱动I2C OLED屏幕实战:从硬件连接到内核帧缓冲

发布时间:2026/10/18 22:59:56 来源:尧图企业网站定制
搞嵌入式 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 控制器SPI4~6 根几十 Mbps小型 TFT、电子墨水屏速率够连接简单I2C2 根SCL/SDA100K~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 设备的基础 APILinux 用户态操作 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/fb05.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: oled3c { 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 0x3c7 位从地址和用户态时一致。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 bs1024 count1这一步如果驱动工作正常屏幕会被清成黑色。想看更直观的效果可以把随机数据写进去dd if/dev/urandom of/dev/fb0 bs1024 count1屏幕上应该会出现满屏的雪花点。这看起来是乱码但恰恰说明驱动已经把显存内容成功映射到了 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 屏幕的本质是低速、低信息量的显示设备把它用在对的地方它会给你的项目带来很多便利。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑