通过 HDMI 接口控制 OLED 显示屏
我对愚蠢和/或毫无意义的项目情有独钟。这就是其中之一。在一次谈话中,我说 “嘿,技术上可以……”。- 当然,就这么干吧。
DDC 即显示数据通道,是一种用于读取显示器支持的分辨率等信息的协议。它后来被扩展为 DDC/CI,可以让你设置亮度和其他参数,但从根本上说,最初的想法是在每台设备上粘贴一个廉价的 i2c eeprom,上面有一些基本信息。(从技术上讲,最初的想法甚至比这更简单,但我们就不讨论这个问题了)。
这种技术始于 VGA 时代,但如今已根深蒂固,就连配备 HDMI 或 DisplayPort 的现代硬件也支持这种技术。没错,在 HDMI 电缆的高速差分线对中,隐藏着一条极其缓慢的 i2c 总线。
微型 OLED 点阵显示器通常都有一个 i2c 控制器,所以我就有了把它直接插入 HDMI 端口的想法。太有趣了!开始吧
接线
我拆开了一根破损的 HDMI 电缆,找到了我们关心的引脚:SCL、SDA、5V、DDC-GND 和 HPD(热插拔检测)。在谷歌上很快就找到了引脚布局:
该图显示的是 HDMI 插座,如果要将插针插入电缆,则从左到右翻转。
HDMI Pin Number | Signal |
---|---|
1 | TMDS Date 2+ |
2 | TMDS Data 2 shield |
3 | TMDS Data 2- |
4 | TMDS Data 1+ |
5 | TMDS Data 1 shield |
6 | TMDS Data 1- |
7 | TMDS Data 0+ |
8 | TMDS Data 0 shield |
9 | TMDS Data 0- |
10 | TMDS Clock+ |
11 | TMDS Clock shield |
12 | TMDS Clock- |
13 | CEC |
14 | HEC Data- |
15 | SCL (Serial Clock for DDC |
16 | SDA (Serial Data Line for DDC |
17 | DDC / CEC / HEC Ground |
18 | +5 V Power (50 mA max) |
19 | Hot Plug Detect (1.3) / HEC Data+ (1.4) |
说到折腾硬件,我倾向于选择低风险的方案,没人喜欢看到 blue smoke,尤其是开发板价格昂贵的时候。不过今天我觉得自己活得很刺激,我要把这个显示器直接焊接到我那台还算新的笔记本电脑上被切断的 HDMI 电缆上。真是激动人心!如果我们搞砸了,这个愚蠢的实验可能会非常昂贵。
你必须注册才能下载 HDMI 规范,这比我花在这上面的精力还要多,但热插拔检测针脚有一个很好的描述性名称。我猜这个引脚要么被上拉,要么被下拉,以发出连接电缆的信号。在 5V 引脚上插入一个 20K 电阻似乎就能起作用。通过示波器,我们现在可以看到当电缆插入笔记本电脑时 SCL/SDA 线路上的活动。
然后,我大胆地在我们关心的四条线上焊接了一个针座连接器。为了这次实验,我订购了几块 OLED 屏幕,它们都使用 SSD1306 控制器,并且都安装在带有四个引脚的接头的分线板上。
i2c 和 SMBus
在 Linux 系统中,我们可以通过加载 i2c-dev 模块(modprobe i2c-dev)来访问 i2c 设备,该模块会在 /dev/i2c-* 中显示一系列 i2c 设备。我的笔记本电脑显示了九个 i2c 设备。
其中一些实际上是 SMBus,它是 i2c 的一个子集。在我们看来,它只是 i2c 加上了一些额外的限制,比如将事务限制在 32 字节以内。
此外,还值得安装 i2c-tools 软件包,它包含 i2cdetect 实用程序,并为组权限设置了一个 udev 规则。要在没有 sudo 的情况下访问 i2c 设备,请将自己添加到 i2c 组(sudo usermod -G i2c -a username),然后再次登录才能生效。我还必须运行 udevadm trigger 才能让 udev 规则生效。重启可能更简单(千万别!)。
注意:i2c 设备命名并不一致。我发现 /dev/i2c-3 是我焊接的 HDMI DDC 线路,但卸载并重新加载模块后,它变成了 /dev/i2c-4。我们必须非常小心,写入(甚至读取)错误的 i2c 设备很容易损坏笔记本电脑的某些硬件。
我还安装了另一个软件包 ddcutil,以便进行 ddcutil 检测。它会列出显示器及其相关的 i2c 总线。还可以执行 i2cdetect -l,列出 i2c 设备及其描述。在我的例子中,有三条 i2c 线路的描述中包含 “i915 gmbus”,i915 是 intel 图形驱动程序。
初步测试
示波器显示 SCL/SDA 线路已被拉高,因此我们应该可以连接屏幕而无需其他硬件。HDMI 端口上的 5V 线路显然可以提供高达 50mA 的电流,因此我们甚至不需要电源。真不错!
i2cdetect
可以扫描 i2c 总线上的设备。不出所料,在没有连接电缆的情况下,它在总线上什么也检测不到。但当我连接上被切断的电缆,并将热插拔检测电阻置于适当位置时,总线上出现了大量响应。我不知道这到底是怎么回事(连接电缆时,视频硬件是否会暴露出一堆东西?),但重要的是,当我连接显示器时,在 0x3c
处出现了一个额外的设备。
与显示器对话的最快方法是使用 python 脚本。捆绑的 smbus 库可以让我们很快上手。
import smbus bus = smbus.SMBus(4) # for /dev/i2c-4 i2caddr = 0x3c bus.write_i2c_block_data(i2caddr, 0, [0xaf] ) # turn display on
在实际显示之前,我们需要发送一系列命令,包括启用电荷泵。请注意,SSD1306 数据表(至少我找到的那份是这样)的末尾有一个附注,对初始化过程的解释比主文件更清楚(有些命令没有记录在主命令表中)。因此,我找到了别人的 SSD1306 库,并复制了他们的初始化命令。于是,我找到了别人的 SSD1306 库,并复制了他们的启动命令!
我还找到了一个将文本绘制到 SSD1306 上的脚本,并迅速在 smbus 上打上了补丁。成功了
没有微控制器,没有其他硬件,只有一个直接插入 HDMI 端口的 SSD1306 OLED。我对此非常满意。
向其输出数据
目前,我仍在使用 python 脚本,我希望能够获取 128×64 像素的图像并将其转储到显示屏上。我借用的文本绘制例程使用 SSD1306 命令来控制数据被写入的列和页面地址,因此绘制单个字符时不会影响显示屏的其他部分(因此上面的图像中仍有未初始化的背景像素)。
这东西有很多不同的内存寻址模式,还有一些令人困惑的术语。SEG 或 COL 是 X 坐标,COM 是 Y 坐标,但这些坐标是按页面分组的。数据表中有一些图表。
显示器是单色的,每页有 8 行(COM),当我们将数据传送到显示器时,每个字节就是一页,一列像素。将显示器配置为垂直寻址模式可能更合理,这样所有位都会按顺序排列,但我认为在我们这一端进行位缓冲是最快的。
使用 python PIL(枕头),我们可以用 .convert(1)
将图像转换为单色,然后用 .tobytes()
将其序列化。这将使每个字节代表 8 个水平像素,但我们希望每个字节代表 8 个垂直像素。解决这个问题的最快方法是在序列化之前将图像旋转 90 度,然后将这些字节加载到 numpy 矩阵中并进行转置,而不是执行一些繁琐的比特逻辑。在这种情况下,你只需改变操作顺序,直到成功为止。这可比思考简单多了。
正如我提到的,SMBus 不允许我们一次发送超过 32 个字节,尽管这个设备只是普通的 i2c。我们可以通过从 python 直接访问 i2c 设备来解决这个问题。诀窍在于使用 ioctl
配置从设备地址。内核头文件 i2c-dev.h
中定义了所需的常量,我们只关心 I2C_SLAVE
。
import io, fcntl dev = "/dev/i2c-4" I2C_SLAVE=0x0703 # from i2c-dev.h i2caddr = 0x3c bus = io.open(dev, "wb", buffering=0) fcntl.ioctl(bus, I2C_SLAVE, i2caddr) bus.write(bytearray([0x00, 0xaf]))
通过交替发送 1024 字节的 0 或 0xFF,我可以衡量显示屏更新的速度。每次发送 256 字节的速度似乎最快,不知道这是否是 i2c 硬件的限制(是否有额外的缓冲层?)
通过这种方式,我可以获得每秒 5 到 10 帧的帧频(相比之下,SMBus 限制下的帧频约为 2FPS)。我认为 DDC 的运行频率为 100kHz,但无论如何,这无疑是在挑战它的极限。
让它成为显示器
我们可以编写应用程序直接绘制到这个屏幕上,但这还不够好,我希望它是一个显示器。
(我不确定我们的应用程序到底是什么,但这不是重点。我希望它是一个显示器!)。
我们可以编写自己的视频驱动程序。虽然这听起来很有教育意义,但这将是一项巨大的工作量,我非常希望能在今晚内完成这项工作。
xserver-xorg-video-dummy
可能对我们有用,但我有一种可怕的预感,如果我们也有真正的显示器输出,那就完全不行了。还有 Xvfb
,一个虚拟帧缓冲器,但如果我们想把桌面扩展到它上面,这也没什么用。
既然我使用的是 xorg,那么在不花费数天时间的情况下,伪造显示器的正确方法似乎是通过 xrandr。
xrandr 既是一个库,也是一个用户空间命令行工具。
我花了一段时间才掌握 xrandr 术语。解释得不是很清楚。
- 帧缓冲framebuffer “是指整个桌面,即截图时保存的内容。
- 输出output “是物理视频输出。
- 显示器monitor “是一个虚拟概念,通常映射到全部或部分帧缓冲区,通常对应一个输出。如果最大化一个窗口,它就会填满显示器的尺寸。
- 不过,您可以将一个显示输出设置为多个显示器(例如,将宽屏显示器有效分割为两个显示器)。
- 或者,多个输出可以是一个显示器,也就是说,多个物理屏幕可以被视为一个显示器,最大化一个窗口将覆盖所有屏幕。
- 模式mode “是一种视频格式,至少包括宽度、高度和帧速率。具体来说,使用的是 VESA CVT 模式,可通过 cvt 工具生成。
- xrandr 的 addmode 和 delmode 指的是将现有模式与显示输出相关联
- xrandr 的 newmode 和 rmmode 指的是向服务器添加新模式,然后将其与输出相关联。
请注意,此列表仅针对 xrandr,在 linux 的其他方面,”输出”、”显示”、”显示器 “和 “屏幕 “等术语通常有不同的用法。
在我的笔记本电脑上,调用 xrandr 会显示五个视频输出:eDP-1(主屏幕,有多种模式可供选择)和四个断开的输出(HDMI-1、HDMI-2、DP-1、DP-2),其中三个可能是通过雷电或其他方式连接的。
伪造显示器,尝试 1
环顾四周,似乎推荐的方法是让 xrandr 相信未使用的视频输出之一已连接。对于像 VNC 这样的应用,有一个 “假插头 “市场,它能让显卡认为显示器已连接。我们显然不希望也不需要这样做,我们应该通过软件哄骗 xrandr 让它乖乖听话。
为了在 HDMI 上输出 128×64 的异常低分辨率,理论上我们首先要生成一个 CVT 模型:
$ cvt 128 64 # 128x64 39.06 Hz (CVT) hsync: 3.12 kHz; pclk: 0.50 MHz Modeline "128x64_60.00" 0.50 128 136 144 160 64 67 77 80 -hsync +vsync
然后,我们将该模式添加到 x 服务器中:
$ xrandr --newmode "128x64_60.00" 0.50 128 136 144 160 64 67 77 80 -hsync +vsync
此时,xrandr 会在输出末尾显示未使用的模式。令人困惑的是,该模式看起来像是最后列出的输出的一部分,但其实还不是。接下来,我们将该模式添加到其中一个输出中:
xrandr --addmode HDMI-1 128x64_60.00
并最终尝试使用它:
xrandr --output HDMI-1 --mode 128x64_60.00 --right-of eDP-1
我需要指出的是,我的笔记本电脑上有一个热键,可以循环切换正常的显示模式,所以我可以在这里尝试任何模式,否则你有可能什么都看不到。使用ctrl+alt+F2等命令访问其他虚拟终端应该还是可行的,因为这些命令是通过位于X服务器下面一层的KMS(内核模式设置)来配置显示模式的。
我用 HDMI-1 和 HDMI-2 都试了一下。它们都被列为断开连接。连接到 HDMI-1 的电缆将热插拔检测引脚拉高,但对普通 DDC 查询没有响应。
我可能没有穷尽所有的可能性,但我还是无法解决这个问题。我怀疑视频驱动程序根本无法处理这种荒谬的非标准分辨率,而且模型线也是垃圾。39.06Hz 确实让我瞠目结舌。我又试了一次,专门将帧速率设置为 39.06Hz,也无济于事。
老实说,像这样滥用视频输出无论如何都是一个糟糕的解决方案。
要收拾这个烂摊子,首先使用 –delmode 从任何输出中释放模式,然后使用 –rmmode 从 X 服务器中删除它们。
伪造显示器,尝试 2
当你更改显示设置时,xrandr 通常会自动设置所有相关设置,但如果我们深入研究,就可以手动摆弄它们。根据互联网上的另一个想法,我们应该可以通过简单地扩展帧缓冲区来制作虚拟显示器,并在其中定义一个显示器,而无需将其与输出相关联。
有趣的是,如果你将帧缓冲区设置得比需要的更大,默认情况下,当鼠标接近边界时,它将自动平移。知道这一点很有用,但在这里我们需要特别阻止这种情况发生。平移 “选项最多有 12 个参数,包括平移区域、跟踪区域和边界。跟踪区域是指鼠标光标所限定的区域。通常情况下,平移、跟踪和帧缓冲区都设置为相同大小。我不确定 “边框 “在平移中代表什么,我在使用时似乎没有任何影响。
将平移设置为 0x0 会禁用平移,但这也会限制跟踪区域,因此我们的鼠标无法触及帧缓冲区的新位置。相反,我们可以将平移限制在主显示器的大小,从而有效地禁用平移,并将跟踪区域扩展到新的帧缓冲区。完整命令:
xrandr --fb 2048x1080 --output eDP-1 --panning 1920x1080/2048x1080
然后,我们就可以在这块新的帧缓冲区中定义一个新的监视器:
xrandr --setmonitor virtual 128/22x64/11+1920+0 none
尺寸以像素和毫米为单位,我猜大约是 22m x 11mm,不过这并不重要。”虚拟 “是显示器的名称,我们可以叫它任何名字。”none “是输出。我们可以用 xrandr –listmonitors 查看显示器,之后再用 xrandr –delmonitor virtual 撤销这些错误。
现在,我可以将脚本指向 OLED 屏幕,将帧缓冲转储到 OLED 屏幕上。好极了!这种方法有一个小问题,那就是跟踪不是 L 形的,我的鼠标可以访问与任何显示器都不对应的帧缓冲区。我不知道是否有简单的解决方法,但如果这真的困扰着我们,我们可以在脚本中通过 Xlib 强制执行有效的光标位置。
读取帧缓冲
我本以为这时候我需要扔掉 python 脚本,但有了 python-xlib 就可以访问我们需要的大部分内容。令人恼火的是,这里没有任何文档,而且方法名称也不完全相同,例如 XGetImage 现在变成了 root.get_image。
这里有一些琐事:你知道鼠标光标是由硬件渲染的吗?我想这是有道理的。这也解释了为什么截图时通常不会捕捉鼠标光标。但我们要捕捉的是帧缓存和帧缓存上的鼠标,因此需要做更多的工作。
获取光标图像通常可以通过 XFixesCursorImage 实现,但 python-xlib 还没有实现 XFixes 的全部功能。我本来准备用 C 语言重新开始,直到我发现有人帮我完成了所有的工作,这个 repo 专门使用 ctypes 绑定到 X11/XFixes 来获取光标信息。
现在我们已经拥有了捕获新虚拟显示器图像、将光标叠加到正确位置(记住要调整 xhot 和 yhot,即指针/光标图像偏移量)、将结果转换为具有适当位缓冲量的单色图像并将其连续传输到显示器所需的一切。
这就是 i3 工作区 4,i3status 被完全粉碎,我的背景图片顶角出现了难以理解的抖动。太美了。
演示
https://youtu.be/8UbVgUFfN8U
结论
为了提高帧频,我们可以增强脚本,只发送更改而不是每帧重绘显示。虽然这很神奇,但由于我完全用不上这个小小的第二屏幕,所以我并不特别想实现它。
如果你出于某种疯狂的原因想亲自尝试一下,脚本可以在 github 上找到。
更新:怎样才能让最小、最糟糕的 “HDMI “显示屏变得更傻呢?让它变成蒸汽朋克风格。
本文文字及图片出自 DDC OLED