在 Linux 下原生控制两块机箱副屏:ASUS 水冷 LCD 与 AORUS RTX 5090 Edge View

这次折腾的目标很简单:不用 Windows 常驻软件,在 Linux 下分别控制 ASUS 水冷头上的 LCD
和 AORUS RTX 5090 MASTER ICE 的 Edge View 小屏。

最终两条链路都在真机上跑通:水冷屏可以稳定显示任意图片;显卡屏可以写入静态图、文本和 GIF,
并能切回原厂 Chibi 动画。本文只记录协议原理、最小集成接口、验证方法和最容易踩的坑,
不绑定发行版、包管理器或后台服务框架。

隐私说明:文中已移除用户名、IP、主机名、虚拟机名称、设备序列号、私有目录和 SSH 拓扑;
PCI/USB/I²C ID、公开硬件型号、开源项目地址等可复现的公开技术信息予以保留。


一、先说结论

两块看起来都叫“副屏”的设备,控制方式完全不同:

1
2
3
4
5
6
7
8
9
10
Linux
├── USB 0b05:1d64
│ └── ASUS 水冷 LCD
│ ├── 握手、切换 Live Screen 模式
│ └── 持续发送 JPEG(本机使用 10 FPS)

└── NVIDIA 内核驱动导出的 I²C adapter
└── AORUS RTX 5090 Edge View(地址 0x61)
├── 探测设备状态
└── 分块写入 RGB565 framebuffer
项目 ASUS 水冷 LCD AORUS Edge View
总线 USB GPU 板载 I²C
Linux 入口 PyUSB /dev/i2c-*,由 NVIDIA 驱动导出
画布 USB 握手报告 640×480 320×170
图像格式 baseline JPEG RGB565,小端
是否需要常驻 需要,停止推帧后 Live Screen 会超时 不需要,静态内容写完会保留
安全策略 校验两次控制响应 CRC 只探测精确地址 0x61,成功后才允许写入
最终状态 恢复默认演示画面,由一个独占进程持续刷新 恢复原厂 Chibi 模式,写完即可退出

最绕的不是发图片,而是判断它到底属于哪一代协议。AORUS 目前的 Windows 软件里能找到一套
0x76、RGB565 大端的新协议,但这张卡实际使用的是旧 GvLcdApi 协议,地址为 0x61
沿着错误协议继续调,无论怎么换 Linux I²C adapter 都只会得到 EIO


二、ASUS 水冷 LCD:USB 持续推送 JPEG

2.1 找到设备

先只做枚举,不写设备:

1
lsusb

本机对应的 USB ID 是:

1
0b05:1d64 ASUSTek Computer, Inc.

后续程序固定匹配 VID/PID,不靠 USB 总线号或设备号,因为后两者在重启、重新插拔后会变化。

这款设备的 USB 握手返回了 640×480 画布,而 ASUS 当前相近零售型号的
官方页面
标注的是 480×480 原生面板。两者并不一定冲突:前者可能是控制器接收的传输画布,后者是物理面板分辨率,
中间由控制器缩放;也可能是外观相近但控制器不同的硬件版本。

不要只根据商品名硬编码分辨率,应以实际 USB 握手为准。

2.2 控制流程

这套控制流程来自 ASUS InfoHub 1.0.7 的主机端通信逻辑,并在设备固件 1.0.5 上验证。
实现只发送屏幕控制和图像数据,不包含任何固件更新操作

完整的一次显示流程如下:

  1. 找到 0b05:1d64,必要时 reset。
  2. 设置 configuration,脱离占用接口的内核驱动并 claim interface 0。
  3. 向 OUT endpoint 0x02 写入 36 字节握手包。
  4. 从 IN endpoint 0x81 读取 36 字节响应,校验 CRC32。
  5. 发送 Live Screen 模式包,再读一次响应并校验 CRC32。
  6. 把输入图片裁剪或留边为 640×480 RGB。
  7. 编码为非 progressive 的 baseline JPEG,quality 90,4:2:0。
  8. 在 JPEG 的 FFD9 结束标记前插入 FF,让 JPEG 总长度对齐到 32 字节。
  9. 加入帧元数据和传输头,持续写入 OUT endpoint。

固定握手包如下。把它拆成“命令头 + 27 个零字节 + CRC”来写,比复制一整行十六进制更容易检查长度:

1
2
HANDSHAKE = bytes.fromhex("aa554b4c54" + ("00" * 27) + "44545b8c")
assert len(HANDSHAKE) == 36

控制响应使用固定 36 字节结构,最后 4 字节是大端 CRC:

1
2
3
4
import zlib

def protocol_crc32(data: bytes) -> int:
return (zlib.crc32(data, 0xFFFFFFFF) ^ 0xFFFFFFFF) & 0xFFFFFFFF

通用传输包的布局是:

1
2
3
4
5
6
5A A5
payload length 4 bytes, big-endian
CRC16(length) 2 bytes, big-endian
64 0B
payload
optional trailer

其中 CRC16 是 reflected CRC-16/IBM,也就是常见的 Modbus 形式:

1
2
3
4
5
6
7
8
9
10
11
12
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for value in data:
crc ^= value
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc

def wrap_packet(payload: bytes, trailer: bytes = b"") -> bytes:
length = len(payload).to_bytes(4, "big")
checksum = crc16_modbus(length).to_bytes(2, "big")
return b"\x5a\xa5" + length + checksum + b"\x64\x0b" + payload + trailer

切换屏幕模式的 payload 是 00 01 05,因此完整包应严格等于:

1
5a a5 00000003 2540 640b 000105 00

这里很适合写一个单元测试。它同时能抓出字节序、CRC 初值和 trailer 是否漏写:

1
2
assert wrap_packet(bytes.fromhex("000105"), trailer=b"\x00").hex() == \
"5aa5000000032540640b00010500"

2.3 JPEG 为什么要在 EOI 前补齐

普通 JPEG 编码器不会保证输出长度是 32 的倍数,但控制器要求对齐。不能简单在文件尾追加垃圾,
因为设备侧可能按 JPEG 的结束标记截断;实际可用的做法是把 padding 放在最终 FFD9 前:

1
2
3
4
5
6
7
def align_jpeg(jpeg: bytes) -> bytes:
if not jpeg.startswith(b"\xff\xd8") or not jpeg.endswith(b"\xff\xd9"):
raise ValueError("not a complete JPEG")
padding = (-len(jpeg)) % 32
if padding:
jpeg = jpeg[:-2] + (b"\xff" * padding) + jpeg[-2:]
return jpeg

帧 payload 是:

1
2
3
00 00 00 00 00 00
JPEG length 3 bytes, big-endian
aligned JPEG

传输包最后还要跟两个 00 字节:

1
2
3
4
5
6
def make_frame_packet(jpeg: bytes) -> bytes:
jpeg = align_jpeg(jpeg)
if len(jpeg) > 0xFFFFFF:
raise ValueError("JPEG is too large")
metadata = (b"\x00" * 6) + len(jpeg).to_bytes(3, "big")
return wrap_packet(metadata + jpeg, trailer=b"\x00\x00")

2.4 最小集成:一个独占写循环

单次发送确实会立即显示,但 Live Screen 模式在收不到后续帧时会超时。对静态图而言,重复编码没有意义;
最简单稳定的方式是先编码一次,再以 10 FPS 重复发送同一帧。

因此上层只需要实现一个很小的接口:

1
2
3
4
5
6
7
8
9
10
image = prepare_image("picture.png", width=640, height=480)
jpeg = encode_baseline_jpeg(image, quality=90, subsampling="4:2:0")
frame = make_frame_packet(jpeg)

with open_asus_lcd(vid=0x0B05, pid=0x1D64) as lcd:
lcd.handshake()
lcd.set_live_screen_mode()
while running:
lcd.write(frame)
sleep_until_next_frame(fps=10)

Python 示例只依赖 Pillow 和 PyUSB。为了不修改系统 Python,可以放在项目自己的 virtualenv 中;
如果用 Rust、C 或 Go 集成,则只需要等价的 libusb 和 JPEG 编码能力。

还要防止两个进程同时抢占 USB endpoint。可以在应用内部使用文件锁,也可以让调用方保证单实例;
锁文件放在哪里并不属于协议的一部分。进程退出时应释放 USB interface,异常路径也一样。

如果设备被直通给虚拟机,宿主机当然找不到它。要先决定由宿主机还是虚拟机拥有这个 USB 设备,
不能让两边同时控制。


三、AORUS RTX 5090 Edge View:GPU 板载 I²C

3.1 Windows 软件里找到的协议,为什么不能直接照搬

AORUS GeForce RTX 5090 MASTER ICE 32G 官方页面
说明 Edge View 可以显示 GPU 信息、文字、图片和 GIF,官方控制入口是 Windows 下的 GCC。

第一轮从当前 GCC 的 GvLcdExApi 路径中整理出了一套看起来很完整的新协议:

  • I²C 地址 0x76
  • 画布 320×170
  • RGB565 大端
  • 命令包含 0x150x100x160x210x24
  • Windows DLL 通过 NvAPI internal I²C port 1 访问

问题是:Linux 下在 NVIDIA 导出的几个 adapter 上探测 0x76,全部返回 EIO

这不是权限、i2c-dev 或 adapter 选择问题,而是协议代际选错了。社区项目
albancreton/aorus-master-linux
正好针对同型号显卡做了 clean-room 实现:该卡实际走旧 GvLcdApi 协议,地址是 0x61

切换到旧协议后,状态探测立即返回:

1
4e 4e 4e 01 00 00 00 00

之后静态图一次写入成功,实机屏幕也确认生效。这里的教训是:

同一版厂商软件可以同时携带多代设备协议。找到一套“结构完整”的协议,并不等于当前硬件真的使用它。
应优先寻找同一 subsystem ID、同一板卡型号的实机证据。

3.2 Linux 下的 I²C 从哪里来

显卡副屏并不是主板 I²C 设备。NVIDIA 专有驱动会把显卡上的 I²C 端口注册到 Linux I²C framework;
NVIDIA 的 Linux 驱动文档
也描述了这一机制。

先加载字符设备模块:

1
sudo modprobe i2c-dev

然后按 adapter 名称找设备:

1
2
3
4
for node in /sys/class/i2c-dev/i2c-*; do
printf '%s: ' "$node"
cat "$node/name"
done

关键点是匹配类似:

1
NVIDIA i2c adapter 1 at 1:00.0

而不是把 /dev/i2c-9 一类编号写死。I²C bus number 是内核动态分配的,重启、驱动变化或增加别的设备后都可能改变。
可靠工具应该遍历 /sys/class/i2c-dev/,按 name 前缀和目标 PCI BDF 选择。

3.3 不要直接 i2cdetect 扫整张显卡

显卡 I²C 上还可能挂 RGB 控制器、EEPROM、DDC 等设备。盲扫或对每个地址发 SMBus quick write
既没有必要,也不够安全:

  • NVIDIA adapter 对 zero-length quick write 的支持并不可靠。
  • 未连接的 DDC 端口也可能产生误 ACK。
  • 扫描会碰到与 LCD 无关的控制器;这次明确避免写入 RGB 地址 0x71

安全探测策略是只访问已知 LCD 地址 0x61,发送旧协议的状态命令 EB 03,读取 8 字节响应;
只有响应通过校验,工具才开放任何写操作。

推荐工作流:

1
2
3
sudo aorus-lcd probe
sudo aorus-lcd image wallpaper.png
sudo aorus-lcd mode 6

最后一条把屏幕恢复到原厂 Chibi 模式。

3.4 旧协议的数据结构

面板画布为 320×170,像素是小端 RGB565,逐行排列:

1
2
3
def rgb888_to_rgb565_le(r: int, g: int, b: int) -> bytes:
value = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
return value.to_bytes(2, "little")

通用命令先放 opcode,再放固定 magic,最后补零到 256 字节:

1
[opcode] [CB 55 AC 38] [parameters ...] [zero padding ...]

一次静态图片上传的大致顺序是:

1
2
3
4
5
F2  BEGIN
F1 image header / target address
256-byte image chunks
F2 END
E5 SetMode

已知模式:

mode 含义
0–2 内置模式
3 静态图片
4 文本
5 GIF
6 Chibi

静态图 framebuffer 目标地址为 0x01300000,文本为 0x01320000,GIF live buffer 为
0x00000000。GIF 需要先切到 mode 5 再连续推帧;静态图片和文本则在上传完成后切换模式。

一次 320×170 静态图写入会产生数百次 I²C transaction。中途出错时应立即停止,而不是跳过错误继续写;
也不要把屏幕写入和显卡功耗、风扇、VBIOS 控制混在一个工具里。

3.5 最小集成边界

社区项目很新,且作者当时只在同一型号显卡上验证过。集成时建议固定到自己审过的 commit,
避免上游更新后发送给显卡的字节序列在无人复核的情况下改变。本文验证的是:

1
b7f1fa8c21971306c937f609c1374632bd18a2dd

最小实现只需要四个边界:

1
2
3
4
find_adapter()     按 sysfs 名称找到 NVIDIA adapter,不硬编码 bus number
probe() 向 0x61 查询状态,失败时禁止后续写入
upload_image() 转为 320×170 RGB565,小端,分块上传
set_mode(mode) 切换静态图、文本、GIF 或 Chibi

Python 参考实现只依赖 Pillow 和 smbus2,也可以直接把这四层改写到已有程序中。静态图片写完后设备会保留内容,
所以不需要守护进程、定时任务或额外的系统集成。

OpenRGB 目前也没有完成对 Edge View LCD 的控制。相关讨论可见
OpenRGB issue #2363
所以现阶段直接使用针对这张卡协议的专用小工具,反而更容易审计。


四、验证:不能只看命令退出码

我把验证拆成四层:

4.1 纯协议单元测试

ASUS 侧覆盖:

  • 已知 payload length 对应的 CRC16。
  • 固定 36 字节握手包自身的 CRC32。
  • 模式包与抓包结果逐字节相等。
  • JPEG 长度为 32 的倍数、SOI/EOI 未被破坏。
  • payload 声明长度和实际布局一致。

AORUS 社区工具自带的 self-test 共 9 项,全部通过后才进行真机探测。

4.2 只读探测

  • USB:确认 VID/PID、握手响应长度、CRC、固件字段、画布字段。
  • I²C:只向 0x61 发状态查询,并验证 8 字节响应。

4.3 真机写入和肉眼确认

两块屏都分别写入一张容易辨认的测试图。命令成功不代表物理屏真的刷新,因此以实际显示结果作为验收条件。

4.4 回滚和系统健康

  • 水冷屏恢复默认演示画面,持续写入进程保持运行。
  • 显卡屏切回 mode 6 原厂 Chibi,写入工具退出。
  • 检查内核日志中没有 NVIDIA Xid / NVRM 错误。
  • 确认显卡仍能进入空闲 P-State,温度、功耗和风扇行为正常。

这一步很重要:能写亮一次只证明协议大致正确;能够恢复、重启后状态合理、GPU 没产生错误,
才说明整个操作闭环了。


五、几个值得保留的工程习惯

5.1 设备编号永远不是身份

USB bus/device number、/dev/i2c-N 都会漂移。应该匹配:

  • USB:VID/PID,必要时再加 interface 特征。
  • I²C:sysfs adapter name + PCI BDF。

只有在一次性诊断输出里可以展示当前编号,不应把它写进持久配置。

5.2 先证明“这是我的设备”,再开放写入

写屏工具应有明确的 probe 阶段:

  • 地址精确匹配。
  • 响应长度精确匹配。
  • magic、CRC 或状态字精确匹配。
  • 任一检查失败就拒绝写入。

这比“扫描到有 ACK 就开始发包”安全得多。

5.3 把恢复动作当作功能的一部分

至少要保留:

  • ASUS:能够停止写循环、重新握手,以及在设备已被其他进程占用时给出明确错误。
  • AORUS:保留 mode 6 恢复 Chibi;静态内容写完后直接释放 I²C 设备。

5.4 原生面板规格和协议画布要分开

商品页面写的是物理 LCD 规格,USB/I²C 握手或协议里写的是控制器接收格式。
它们可能相同,也可能经过缩放。代码应以实际设备响应为准,文章则把两类数字分别标注,
不能为了“看起来一致”强行改掉其中一个。


六、最终成果

这次两块副屏都不再依赖 Windows 常驻控制软件:

  • ASUS 水冷 LCD:Linux 原生 USB 控制,图片预处理、握手、CRC、JPEG 对齐和持续推帧全部跑通。
  • AORUS RTX 5090 Edge View:通过 NVIDIA I²C adapter 原生控制,静态图、文本、GIF 和原厂模式切换链路跑通。
  • 两套工具都避免硬编码动态设备编号,并在写入前做设备级校验。
  • 测试完成后,水冷屏恢复默认演示画面,显卡屏恢复 Chibi;显卡屏不需要任何常驻写入进程。

整个过程最有价值的结论不是某条十六进制命令,而是这条排障路径:

先确定总线,再确定设备身份,随后确定协议代际,最后才谈图像格式和上传性能。

如果顺序反过来,很容易对着一套完全正确、但不属于当前硬件的协议调上好几天。