绿联的 DX4600 Pro 是一个四盘位的 NAS,自带的系统是基于 OpenWRT 的 UGOS,v2ex 上有很多相关的介绍以及抱怨1,还有报道说新版的系统不向下兼容旧版的系统,并且升级需要格式化硬盘2。不过这台 NAS 还是可以自己装个 Debian 或者别的开源 NAS 系统的,但问题是自己装的系统并没有机箱前边六个 LED 灯(分别是电源、网卡和四个硬盘)的驱动,默认情况下只有电源指示灯在闪烁,无法控制其他的指示灯。

在我发现这样的事实后已经把默认存放 UGOS 的 eMMC 整个格掉了(所以做这样的事情之前先 dd 一份还是很重要的),好在 v2ex 有人提供了一份原始的系统1可以让我来看看它是如何控制这些指示灯的。

对于 DX4600 系列,UGOS 控制 LED 的模块是 leds-mcu-ht32f52231.ko,他们没有开源只能考虑 objdump 或者反编译来看它做了什么。对于反编译,以前只听说过 IDA Pro 但是应该是要收费的,在处理这个模块的时候搜索了一下发现 Ghidra 似乎是不错的免费工具。

这篇文章会介绍 UGOS 如何控制 DX4600 Pro 的 LED,并且提供了一份在非 UGOS 上控制这些 LED 的工具,相关的代码实现在 GitHub 上。对于 DX4600 系列的硬件(例如 DX4600 和 DX4600+)我猜测也可以适用,但我只测试过 DX4600 Pro,所以如果你想要在别的硬件使用请小心一些。

UGOS LED 控制模块

在 UGOS 上和 LED 相关的模块有下面这些:

leds-mcu-ht32f52231.ko
leds-mcu-n76e003.ko
ledtrig-audio.ko
ledtrig-breath-ht32f52231.ko
ledtrig-breath-n76e003.ko
ledtrig-netdev2.ko
ledtrig-normal-ht32f52231.ko
ledtrig-normal-n76e003.ko

其中 leds-mcu 开头的两个模块是核心部分,看名字 DX4600 系列的应该使用的是 ht32f52231 结尾的几个模块,看起来是这个系列使用了 HT32F52331 这个 MCU 来做 LED 的具体控制。另外的 N76E003 应该是适用于更老的版本的 NAS 的。

leds-mcu-ht32f52231.ko 会通过 I2C 来和 SMBus I801 adapter 上地址为 0x3a 的设备进行通讯进而完成对 LED 的控制。在它初始化的时候看代码应该还会注册相关的 LED 设备,估计可以在 /sys/class/leds 看到,不过我从来没有使用过 UGOS 所以并不清楚是不是这样。

I2C 设备

对于 UGOS 以外没有这类模块的系统(例如 Debian),可以使用 i2c-tools 等工具来直接和对应设备进行通信,主要的问题在于需要往对应设备的哪个寄存器读写何种数据来完成控制。

我们先来查看是否有对应的设备。这需要加载 i2c-dev 模块:

$ modprobe -v i2c-dev

然后使用 i2cdetect -l 应该就可以看到所需要的 SMBus I801 adapter (/dev/i2c-1) 了,并且可以看到这个总线上确实在 0x3a 这个地址有一个设备:

$ i2cdetect -l
i2c-0   i2c             Synopsys DesignWare I2C adapter         I2C adapter
i2c-1   smbus           SMBus I801 adapter at efa0              SMBus adapter
i2c-2   i2c             Synopsys DesignWare I2C adapter         I2C adapter

$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         08 -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: 30 -- -- -- -- 35 UU UU -- -- 3a -- -- -- -- --
40: -- -- -- -- 44 -- -- -- -- -- -- -- -- -- -- --
50: UU -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

LED 状态的查询

UGOS 的对应模块会通过访问对应 I2C 设备的寄存器来获得 LED 灯的状态,下方是 Ghidra 给出的部分反编译代码,我删去了一些和主要逻辑关系不是特别大的部分。

mutex_lock(&DAT_001055e0);
local_48 = 0;
_local_40 = 0;
iVar4 = i2c_smbus_read_i2c_block_data(DAT_001055c0,iVar3 + 0x81,0xb,&local_48);
if (iVar4 == 0xb) {
  puVar7 = &local_48;
  iVar4 = 0;
  do {
    bVar1 = *(byte *)puVar7;
    puVar7 = (undefined8 *)((long)puVar7 + 1);
    iVar4 = iVar4 + (uint)bVar1;
  } while ((undefined8 *)(local_40 + 1) != puVar7);
  if (((iVar4 == 0) || (local_40[1] != (char)((uint)iVar4 >> 8))) || (cStack_3e != (char)iVa r4))
  {
    /* checksum does not match */
  }
  else {
    uVar5 = (uint)local_48._5_1_ * 0x100 + (uint)local_48._6_1_;
    uVar6 = (uint)local_48._7_1_ * 0x100 + (uint)local_40[0];
    uVar10 = (uint)(byte)local_48;
    if ((byte)local_48 < 4) {
        uVar5 = sprintf(in_RDX,
                        "op_mode:%s,brightness:%d,color:%d:%d:%d,t-hight:%d, t-low:%d,mode:%#x\n "
                        ,(&str_op_mode)[(int)uVar10],(ulong)local_48._1_1_,(ulong)local_48._2 _1_
                        ,(ulong)local_48._3_1_,(ulong)local_48._4_1_,(ulong)uVar5,(ulong)uVar 6,
                        uVar14);
        uVar14 = (ulong)uVar5;
    }
    else {
      uVar14 = 0xffffffff;
      printk("---read op_sate:%d err!\n",uVar10);
    }
  }
}
else {
  printk("---%s,ret:%d,read %s block err! \n","led_read_state",iVar4,*in_RDI);
  *(undefined4 *)(&DAT_001044ec + lVar8 * 0x278) = 1;
  uVar14 = 0xffffffff;
}
mutex_unlock(&DAT_001055e0);

这里的 iVar3 表示需要查询的 LED 灯的 ID,从 0 到 5 分别对应机器上的 power, netdev, disk1, disk2, disk3, disk4 六个 LED 灯。可以看到,这段代码通过 i2c_smbus_read_i2c_block_data0x81 + LED_ID 处读取 11 个字节来获得对应指示灯的状态。从代码中的 sprintf 格式化串可以看出这 11 个字节的意义分别是:

地址 对应数据的意义
0x00 LED 灯的状态,从 0 - 3 分别表示关闭 (off)、开启 (on)、闪烁 (blink) 和呼吸 (breath)
0x01 LED 灯的亮度
0x02 LED 灯的颜色(RGB 中的红色分量)
0x03 LED 灯的颜色(RGB 中的绿色分量)
0x04 LED 灯的颜色(RGB 中的蓝色分量)
0x05 闪烁或呼吸模式下完成一次闪烁所需要的毫秒数(高 8 位)
0x06 闪烁或呼吸模式下完成一次闪烁所需要的毫秒数(低 8 位)
0x07 闪烁或呼吸模式一次闪烁中灯亮起的毫秒数(高 8 位)
0x08 闪烁或呼吸模式一次闪烁中灯亮起的毫秒数(低 8 位)
0x09 0x00 - 0x08 中数据的校验码(高 8 位)
0x0a 0x00 - 0x08 中数据的校验码(低 8 位)

这里最后两个字节的校验码是将 0x00-0x08 位置上所有数据视为无符号数求和得到的一个 16 位的数值。直接使用 i2cget 也可以在命令行对相关寄存器进行读取,例如下方是 power 指示灯的状态(紫色,每秒闪烁一次,40% 时间为亮起状态,亮度为 180 / 256):

$ i2cget -y 0x01 0x3a 0x81 i 0x0b
0x02 0xb4 0xff 0x00 0xff 0x03 0xe8 0x01 0x90 0x04 0x30

LED 状态的修改

从上节给出的 LED 状态信息可以看出,可以修改的状态是灯的开关、亮度、颜色以及闪烁模式下的频率。下方是 Ghidra 给出的设置灯 ON / OFF 状态的反编译代码。同样地,我删去了一部分不关键的信息。

undefined8 local_45;
undefined4 local_3d;
undefined uStack_39;
long local_38;

local_38 = *(long *)(in_GS_OFFSET + 0x28);
local_3d = 0;
local_45 = 0x3000001a00000;
uStack_39 = 0;
if (DAT_001055c0 == 0) {
  printk("----%s g_client is null \n","led_set_on_off");
  uVar4 = 0xffffffff;
}
else if (in_ESI < 2) {
  lVar7 = (long)in_EDI;
  if ((&DAT_001044f8)[lVar7 * 0x9e] == in_ESI) {
      /* some checks */
  }
  else {
    local_45 = CONCAT17((char)in_ESI,0x3000001a00000);

    /* compute the checksum */
    sVar5 = 0;
    uVar3 = 0xa0;
    for (pbVar6 = (byte *)((long)&local_45 + 3); sVar5 = sVar5 + uVar3,
        pbVar6 != (byte *)((long)&local_3d + 3); pbVar6 = pbVar6 + 1) {
      uVar3 = (ushort)*pbVar6;
    }
    local_3d._3_1_ = (byte)((ushort)sVar5 >> 8);
    local_3d = (uint)local_3d._3_1_ << 0x18;
    uStack_39 = (undefined)sVar5;
    mutex_lock(&DAT_001055e0);
    if ((&DAT_001044f8)[lVar7 * 0x9e] == in_ESI) {
        /* some checks */
    }
    else {
      cVar8 = '\x03';
      usleep_range(200,0x5dc);
      i2c_smbus_write_i2c_block_data(DAT_001055c0,in_EDI,0xc,(long)&local_45 + 1);
      do {
          /* if failed, repeat three times */
      } while (cVar8 != '\0');

      /* post-processing */
    }
    mutex_unlock(&DAT_001055e0);
    uVar4 = 0;
  }
}

从这段代码可以大致感受到,状态的修改会通过 i2c_smbus_write_i2c_block_data0x00 + LED_ID 处写入 12 个字节来完成,其中写入之前会进行一次 checksum 的计算并且放入写入的信息中,因此实际有效的内容应该是前面 10 个字节。不过因为是第一次用 Ghidra,这些变量看起来让人感觉有点迷惑,接下来还是直接转移到汇编吧。

 # setup the write buffer
 2cf:   48 b8 00 00 a0 01 00    movabs $0x3000001a00000,%rax
 2d6:   00 03 00
 2d9:   48 89 45 c3             mov    %rax,-0x3d(%rbp)
 2dd:   c6 45 cf 00             movb   $0x0,-0x31(%rbp)

 314:   48 8d 45 c3             lea    -0x3d(%rbp),%rax
 318:   40 88 75 ca             mov    %sil,-0x36(%rbp)

 # compute checksum
 31c:   31 c9                   xor    %ecx,%ecx
 31e:   48 8d 50 03             lea    0x3(%rax),%rdx
 322:   48 8d 70 0b             lea    0xb(%rax),%rsi
 326:   40 88 7d c3             mov    %dil,-0x3d(%rbp)
 32a:   b8 a0 00 00 00          mov    $0xa0,%eax
 32f:   40 88 7d c4             mov    %dil,-0x3c(%rbp)
 333:   eb 07                   jmp    33c <i2c_smbus_write_i2c_block_data_and_read-0x1be4>
 335:   0f b6 02                movzbl (%rdx),%eax
 338:   48 83 c2 01             add    $0x1,%rdx
 33c:   01 c1                   add    %eax,%ecx
 33e:   48 39 f2                cmp    %rsi,%rdx
 341:   75 f2                   jne    335 <i2c_smbus_write_i2c_block_data_and_read-0x1beb>
 343:   66 c1 c1 08             rol    $0x8,%cx
 347:   48 c7 c7 00 00 00 00    mov    $0x0,%rdi
                34a: R_X86_64_32S       .bss+0x20

 # save the checksum to the write buffer
 34e:   66 89 4d ce             mov    %cx,-0x32(%rbp)

 # set the parameters of i2c_smbus_write_i2c_block_data
 3ba:   48 8d 45 c3             lea    -0x3d(%rbp),%rax
 3be:   ba 0c 00 00 00          mov    $0xc,%edx
 3c3:   44 89 e6                mov    %r12d,%esi
 3c6:   48 83 c0 01             add    $0x1,%rax
 3ca:   48 8b 3d 00 00 00 00    mov    0x0(%rip),%rdi        # 3d1 <i2c_smbus_write_i2c_block_data_and_read-0x1b4f>
                3cd: R_X86_64_PC32      .bss-0x4
 3d1:   48 89 c1                mov    %rax,%rcx
 3d4:   48 89 45 b8             mov    %rax,-0x48(%rbp)
 3d8:   e8 00 00 00 00          call   3dd <i2c_smbus_write_i2c_block_data_and_read-0x1b43>
                3d9: R_X86_64_PLT32     i2c_smbus_write_i2c_block_data-0x4

按照调用规范,i2c_smbus_write_i2c_block_data 四个参数分别存在 rdi, rsi, rdx, rcx 中。在 Line 3ba 可以看到写入的数据是 %rbp - 0x3d 开始的 12 个字节,也就是 %rbp[-0x3c] - %rbp[-0x31]。观察其它部分可以发现:

  • Line 2d9:在缓冲区的前 8 个字节写入了一些和需要完成的操作(设置灯的开关)相关的信息。
  • Line 318:将这个设置亮度这个函数的第二个参数写入了缓冲区的第 7 字节。可以发现这个参数为 0 时表示关灯,1 时表示开灯。
  • Line 32f:将这个设置亮度这个函数的第一个参数写入了缓冲区的第 1 字节。可以发现这个参数是 LED 的 ID。
  • Line 31c-34e:计算了第 3 个字节至第 10 个字节的校验和并且(按照大端序)写入了最后两个字节。

完整观察其余设置颜色、亮度等内容的代码,可以发现这 12 个字节的意义如下:

地址 对应数据的意义
0x00 LED 灯的 ID
0x01 常数:0xa0
0x02 常数:0x01
0x03 常数:0x00
0x04 常数:0x00
0x05 如果值为 1 则表示修改亮度
如果值为 2 则表示颜色
如果值为 3 则表示设置开关状态
如果值为 4 则表示设置闪烁状态
如果值为 5 则表示设置呼吸状态
0x06 第一个参数
0x07 第二个参数
0x08 第三个参数
0x09 第四个参数
0x0a 0x01-0x09 中数据的校验码(高 8 位)
0x0b 0x01-0x09 中数据的校验码(低 8 位)

对于 0x05 处四种不同的修改内容,

  • 如果需要修改亮度,那么第一个参数为亮度信息;
  • 如果需要修改颜色,那么前三个参数分别为 RGB 信息;
  • 如果需要开关状态,那么第一个参数为 0 或 1,分别表示关闭或者打开;
  • 如果需要设置为闪烁或呼吸状态,那么前两个参数按照大端序组成一个 16 位无符号数,表示完成一次闪烁所需要的毫秒数;后两个参数同样按照大端序组成一个 16 位无符号数,表示一次闪烁中灯亮的毫秒数。

下方是使用 i2cset 关闭和开启电源指示灯的一个命令行示例:

$ i2cset -y 0x01 0x3a 0x00  0x00 0xa0 0x01 0x00 0x00 0x03 0x01 0x00 0x00 0x00 0x00 0xa5 i    # turn off power LED
$ i2cset -y 0x01 0x3a 0x00  0x00 0xa0 0x01 0x00 0x00 0x03 0x00 0x00 0x00 0x00 0x00 0xa4 i    # turn on power LED

判断修改是否成功

在发送上节中对 LED 状态的修改命令之后,可能会因为校验码没算对,或者参数没有传递正确等原因修改失败。UGOS 的驱动会在每次发送修改命令之后通过 i2c_smbus_read_byte_data 读取位于 0x80 地址处的寄存器,如果其返回 1 那么表示修改成功,否则表示失败,那么 UGOS 就会尝试再次发送相关的命令。

一个例子

dx4600_leds_cli 最后是可以做出这样的效果的:

它所需要的是如下脚本:

sudo ./dx4600_leds_cli all -off -status
sudo ./dx4600_leds_cli power  -color 255 0 255 -blink 400 600 -status
sleep 0.1
sudo ./dx4600_leds_cli netdev -color 255 0 0   -blink 400 600 -status
sleep 0.1
sudo ./dx4600_leds_cli disk1  -color 255 255 0 -blink 400 600 -status
sleep 0.1
sudo ./dx4600_leds_cli disk2  -color 0 255 0   -blink 400 600 -status
sleep 0.1
sudo ./dx4600_leds_cli disk3  -color 0 255 255 -blink 400 600 -status
sleep 0.1
sudo ./dx4600_leds_cli disk4  -color 0 0 255   -blink 400 600 -status

参考资料