本文发自 http://www.binss.me/blog/build-hack-synology/,转载请注明出处。

折腾硬件

一直以来我都对 NAS 无感。在我看来,机械硬盘这种一通电就嘎嘎嘎响的设备早就应该退出历史舞台,在我新组装的 PC 上,直接上了双块 m2 接口的 nvme SSD。猛然想起就快要毕业了,我几年来辛苦从学校 PT 上拖下来各种视频需要打包带走才行。我把目光转到了 NUC 上,这台小巧的机器上装着一块 2TB 的 2.5 寸 SSHD 作为数据盘,连上 df 一看,发现已经住满了大姐姐。看着实验室机器中将近 10 TB 的文件,再看看干瘪的钱包,此时的我只能说句:机械硬盘真香。转身就在亚马逊上海淘了一块 10T 硬盘。

实际上我购买的是 西数的 10TB Elements 桌面硬盘,里面是一块型号为 WD100EMAZ 的氦气盘,完税到手 1565,比单独买硬盘便宜多了。我最开始的想法是 NUC 通过 USB 3.0 连接外置硬盘,将其作为扩展存储。然而硬盘还没到手就被土豪同学吐槽了:既然买了 3.5 寸盘,当然上 NAS 啊。你看我的群晖巴拉巴拉多方便。的确,NAS 是更优的解决方案。一来硬盘直接通过 SATA 线相连,传输速度比 USB 3.0 更快。二来 NAS 能够控制硬盘智能启停,更有利于减少硬盘损耗。三来就是老生常谈的问题了:低耦合,NUC 挂掉或重启时,NAS 依然能够提供服务。嗯,那就上 NAS 吧。

提起 NAS,群晖的大名就如雷贯耳了。然而网上一搜,被群晖的高昂的价格吓尿了。2000 以下的两盘位机器甚至不是 Intel 的 CPU,唯一觉得还行的采用 Intel J3455 CPU 的 DS918+,售价高达 4680。要知道 Intel 自家配备 J3455 CPU 的 NUC 才卖 800,群晖何德何能卖那么贵?只能说打扰了。这时在电工论坛上发现有人在开车:原价 5999 的暴风播酷云,不要 999,也不要 599,只需 590!

为什么会有此等好事?乘着区块链的东风,国内很多厂商都搞出了各种“矿机”用来挖自己的山寨币。典型的有迅雷的玩客云、极路由的极路由X、暴风的暴风播酷云等等等等。这些“矿机”的典型特征就是高价低配,但由于虚拟货币的火热,不断有新韭菜入场,因此“矿机”出现了供不应求的现象,一上架就被哄抢而空。二道贩子倒手赚差价,矿老板收机器开 farm,“投资者”哄炒挖出来的山寨币,一副欣欣向荣的景象。然而,随着下半年虚拟货币市场的遇冷,各大虚拟币开始暴跌,即使是虚拟货币始祖比特币也享受到 1 年内 80% OFF 的暴跌。于是乎,挖矿的收益赶不上电费,矿老板们开始卖矿机了。

暴风播酷云,正是矿机中的一员。但区别于其他矿机,其本身具有较高的使用价值。它由知名 NAS 机箱厂商万由代工,采用了华擎的 J3455-ITX,内有金士顿 8G 内存一条,具有 4 个 SATA 口,除了可抽拉的两块 3.5 寸硬盘外,内部还能装下两块 2.5 寸硬盘。这批机器在今年 2 月左右出厂,如今矿老板将硬盘拆出单卖后,就把“外壳”放到咸鱼出掉,顺丰到付 590。配置比 DS918+ 还高,价格却还不到群晖的 1/7,要啥自行车,于是我上车了。

到货后,拆机清灰,清掉 BIOS 密码,内部塞上两块 2.5 寸的 256G SSD,两个盘位插进 2TB 的老硬盘和刚购入的 10TB 氦气盘,硬件上就折腾完了。

折腾软件

为 NAS 装什么系统呢?最省事当然是上和 NUC 一样的 Ubuntu 18.04,但 NAS 嘛,就要上 NAS 的系统。于是我折腾了 openmediavault,发现不怎么好用。正当我准备老老实实装回 Ubuntu 时,看到网上的黑群晖教程。

黑群晖,顾名思义的就是安装群晖的系统。群晖的系统镜像在其官网能够下载到,虽然它是基于 debian 的 Linux ,然而安装方式却不同于常规。这个系统镜像是没有引导的,而是需要在群晖通电后,按照流程通过浏览器上传安装。而群晖的引导是烧在主板的 ROM 上的。根据 IFIXIT 的拆解,DS918+ 的主板上带有“Flash memory with pre-loaded DSM 6.1 kernel to allow the NAS to boot before full installation of the OS”,就在下图绿框的部分:

不愧是专业的,这块主板的体积比我的 j3455-itx 小巧得多。显然这里面存放的就是群晖的引导了。那么对于黑群晖来说,没有这个 ROM 来引导怎么办呢?没关系,我们可以 U 盘引导。这个网上有很多教程,我采用的是 nasyun 论坛上老骥伏枥的教程。前后折腾了两天,在此分享整个折腾过程的笔记。

制作启动盘

这里选用 ds3617xs 的镜像。在一切开始之前,请确保用于安装系统的硬盘被清空,最后是通过重建分区表的形式格掉,并使用 msdos 分区表

  1. 通过 rescuecd 盘启动,运行 startx 启动图形界面
  2. 运行 gparted,重建分区表为 msdos,会强制格盘
  3. 创建两个分区,第一个占大空间,格式为 fat32。第二个大于 32M,格式为 fat16
  4. 上传 ds3617xs_v612b.img ,可通过新 U 盘传入也可以将其拷贝到第一个分区里
  5. 挂载 boot 镜像 mount ds3617xs_v612b.img /mnt,执行安装脚本 cd /mnt./usb_inst.sh /dev/sdd2,将数据写到 sdd2 分区
  6. 挂载 /dev/sdd2mount /dev/sdd2 /boot
  7. 查询 cat /sys/kernel/debug/usb/devices,得到 U 盘的 vid 和 pid
  8. 修改 /boot/boot/grub/grub.cfg 中的 vid 和 pid 为 U 盘的 vid 和 pid
  9. 如果需要安装更新版本的系统,需要用最新版镜像中的 checksum.syno / grub_cksum.syno / zImage / rd.gz 替换掉 /boot/boot/grub/DS3617xs/ 中相应的文件
  10. umount /boot,拔出 U 盘,启动 U 盘制作完成

安装系统

  1. 插入 U 盘,从 U 盘启动,进入 grub 后选择第一个启动黑群晖选项,看到 Booting kernel 代表系统已启动
  2. 使用群晖助手连接设备或路由器查看设备 ip,然后通过 http://ip:5000 访问
  3. 按界面上传系统镜像(pat后缀),安装,如果发现安装到一半失败,请:(1) 确保当前机器和黑群晖机器处于同一子网下 (2)将系统盘通过重建分区表的形式格掉
  4. 安装完后,黑群晖机器自动重启,此时记得选择从 U 盘来引导,看到 Booting kernel 代表系统已启动
  5. 由于系统需要初始化工作,此时继续观察上传界面的倒计时。一旦系统就绪,倒计时页面将跳转到创建账号等的初始化界面,完成初始化后,系统安装完成

硬盘自引导

由于安装方式的原因,安装群晖的系统盘上没有引导,因此每次都要从 U 盘启动来引导,十分麻烦,为此希望能够从硬盘自引导。

在制作之前,需要确保之前系统是通过以上方式安装的,尤其是系统盘在安装系统前通过重建分区表的形式格掉并使用 msdos 分区表,否则可能无法硬盘自引导,出现 error file: /boot/grub/i386-pc/normal.mod not found 的问题。

  1. 创建 RAID Group,用系统盘创建一个类型为 BASIC 的 Group,然后在其之上创建一个 Volume,文件系统随便。Volume 初始化完成后,创建一个 Shared Folder,比如 boot,给予当前用户读写权限。
  2. 在 File Station 中打开该文件夹,上传 ds3617xs_v612b.img (刚刚做引导盘的那个镜像)和 disk_setboot.sh 。如果安装的系统镜像版本新于引导镜像,则将最新版引导镜像中的 checksum.syno / grub_cksum.syno / zImage / rd.gz 也上传到该目录
  3. Control Panel - Terminal & SNMP 中开启 ssh
  4. 使用群晖账号密码 ssh 登陆,通过 sudo -i 切换到 root
  5. cd 到刚刚创建的文件夹,一般为 /volume1/boot,确保文件存在的情况下,执行:

    chown root:root ds3617xs_v612b.img
    chown root:root disk_setboot.sh
    chmod 666 ds3617xs_v612b.img
    chmod 777 disk_setboot.sh
  6. 找到系统盘设备路径(如 /dev/sdd),执行 ./disk_setboot.sh /dev/sdd ./ds3617xs_v612b.img

  7. 如果脚本执行成功,通过 parted --script /dev/sdd p free 检查系统盘是否新增了一个 flags 为 boot + lba 的分区,假设为 /dev/sdd4
  8. 挂载该设备 mount /dev/sdd4 /mnt
  9. cd 到 /mnt/boot/grub/DS3617xs,用刚上传的 checksum.syno / grub_cksum.syno / zImage / rd.gz 替换掉该目录下的相应文件
  10. 完成。拔出引导 U 盘,重启,选择从硬盘启动

2020.03.11 更新

由于时隔已久,找不到 disk_setboot.sh 的原帖出处了,我在本地保存了一份,其内容如下:

#!/bin/sh
#
# The purpose of the script is to install the bootloader on 
# a harddrive for Synology can be started from hard disk
#

#help screen
if  [ $# != 2 ]; then
echo "
standard use of script is:
    ./disk_setboot.sh  the script will install bootloader on hard disk, it will not
                       touch the partition tables and therefore please perserves data.
possible options are:
    /dev/sd?           device name for Synology bootloader disk.

    /*/boot.img        path to the bootloader that will be written to the disk.

example
    ./disk_setboot.sh /dev/sd? /volunm1/boot/boot612b.img
"
exit 1
fi

#check if the bootloader image exist
if  [ ! -f $2 ]; then
    echo "The bootloader image $2 does not exists."
    exit 1
fi

#check if the device exist
if [ ! -e $1 ]; then
    echo "$1 does not exist."
    exit 1
fi

if [ -e $1$(echo 4) ]; then
    part_data=$(parted --script $1$(echo 4) unit s p | grep "fat16")
    free_size=$(echo $part_data |cut -d's' -f3)
    if [ "$free_size" -lt 65536 ]; then
        echo "The device free size is too small for bootloader"
        exit 1
    fi
    dd if=$2 of=$1$(echo 4)
    sync
    sleep 1
    mount $1$(echo 4) /mnt
    if [ -x /mnt/grub ]; then
        LD_LIBRARY_PATH=/mnt
        export LD_LIBRARY_PATH
        /mnt/grub-install --force-lba --root-directory=/mnt $1$(echo 4)
    else
        echo "The boot img is not correct."
        exit 1
    fi
    umount /mnt
    echo "Update hard disk bootloader successful"
    exit 0
fi

#parted --script $1 unit s p free | grep "Free Space"  
#get the partition data
part_data=$(parted --script $1 unit s p free | grep "Free Space")
part_data=$(echo $part_data | sed -e 's/Free Space/;/g')
part_data=$(echo $part_data |cut -d';' -f2)

start_at=$(echo $part_data |cut -d's' -f1)
free_size=$(echo $part_data |cut -d's' -f3)
start_at=$((start_at+2048-start_at%2048))
end_at=$((start_at+65536))

if [ "$free_size" -lt 65536 ]; then
    echo "The device free size is too small for bootloader: {$((free_size*512))} bytes."
    exit 1
fi

parted $1 unit s mkpart primary fat16 $start_at$(echo s) $end_at$(echo s)
if [ $? != 0 ]; then
    echo "parted hard disk failure."
    exit 1
fi

parted $1 set 4 boot on
dd if=$2 of=$1$(echo 4)
sync
sleep 1
mount $1$(echo 4) /mnt
if [ -x /mnt/grub ]; then
    LD_LIBRARY_PATH=/mnt
    export LD_LIBRARY_PATH
    /mnt/grub-install --force-lba --root-directory=/mnt $1$(echo 4)
else
    echo "The boot img is not correct."
    exit 1
fi
umount /mnt
echo "Generate hard disk bootloader successful"
exit 0

思考

看着黑群晖欢快地跑着,我却高兴不起来。为何老骥伏枥的方法可以引导呢?他是如何实现的?

虽然老骥伏枥在帖子里给出了一些解释,但我却看的一头雾水。根据老骥伏枥帖子中的说法,其实现的引导流程主要是这样的:硬盘引导 - 硬盘分区引导 - 群晖系统,其中硬盘分区引导使用 GRUB 。因此,在对 GRUB 引导流程进行复习,总结出 Linux启动流程:从启动到 GRUB 后,我开始了对群晖引导流程的分析。

硬盘引导流程

我目前的硬盘已经按照上述流程安装了群晖的系统并做了硬盘引导。先来研究第一步:硬盘引导。

在 Legacy mode 中,存储在硬盘第一个扇区中的 MBR 用于决定在启动时磁盘中那部分的代码会被加载运行。其构成如下:

  • 0-445(0x1bd) :Bootstrap Code,又称 bootloader
  • 446(0x1be)-509(0x1fd) :磁盘分区表(Disk Partition table),共四项,每个项 16 byte
  • 510(0x1fe)-511(0x1ff) :MBR 结束标志。如果值为 0x55 0xaa,表明该设备可以用于启动,否则 BIOS 会将控制权转交给启动顺序中的下一个设备

我们把黑群晖硬盘的 MBR dump 出来看:

sudo dd if=/dev/sda of=mbr bs=512 count=1

dump 出来内容如下:

binss@g1:~/work$ od -x mbr
00000000  fa b8 00 10 8e d0 bc 00  b0 b8 00 00 8e d8 8e c0  |................|
00000010  fb be 00 7c bf 00 06 b9  00 02 f3 a4 ea 21 06 00  |...|.........!..|
00000020  00 be be 07 38 04 75 0b  83 c6 10 81 fe fe 07 75  |....8.u........u|
00000030  f3 eb 16 b4 02 b0 01 bb  00 7c b2 80 8a 74 01 8b  |.........|...t..|
00000040  4c 02 cd 13 ea 00 7c 00  00 eb fe 00 00 00 00 00  |L.....|.........|
00000050  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
000001b0  00 00 00 00 00 00 00 00  a0 0e 7b 0e 00 00 00 20  |..........{.... |
000001c0  21 00 fd 25 6f 36 00 08  00 00 00 ff 4b 00 00 25  |!..%o6......K..%|
000001d0  70 36 fd 3a bf 3b 00 07  4c 00 00 00 40 00 00 6f  |p6.:.;[email protected]|
000001e0  a5 4b fd fe ff ff 00 00  90 00 60 39 89 1c 80 3f  |.K........`9...?|
000001f0  85 3b 0e 53 95 3f 00 08  8c 00 01 00 01 00 55 aa  |.;.S.?........U.|
00000200

因此分区表内容为:

00 20 21 00 fd 25 6f 36 00 08 00 00 00 ff 4b 00
00 25 70 36 fd 3a bf 3b 00 07 4c 00 00 00 40 00
00 6f a5 4b fd fe ff ff 00 00 90 00 60 39 89 1c
80 3f 85 3b 0e 53 95 3f 00 08 8c 00 01 00 01 00

分区表项定义如下:

  • 0 :flag,如果为0x80,就表示该分区是活动分区(一块硬盘上只可以有一个活动分区),可引导
  • 1 :分区起始磁头号(HEAD)
  • 2-3 :分区起始扇区号(SECTOR, bit 0-5) 和 起始柱面号(CYLINDER, 6-15)
  • 4 :分区文件系统。0b 为 FAT32 with CHS,0e 为 FAT16B with LBA,07 为 NTFS,fd 为 LINUX RAID
  • 5 :分区结束磁头号(HEAD)
  • 6-7 :分区结束扇区号(SECTOR, bit 0-5) 和 结束柱面号(CYLINDER, 6-15)
  • 8-11 :分区起始相对扇区号
  • 12-15 :分区总的扇区数

CHS(Cylinder-Head-Sector) 和 LBA(Logical Block Addressing) 是扇区的编址方式。前者直接使用 (c,h,s) 来表示一个扇区的位置。扇区号(s)从1开始,柱面号(c)和磁头(h)从 0 开始,因此CHS编址的起始地址为 (0, 0, 1),只能寻址约 8 GB (255, 1024, 64) = 255 * 1024 * 64 sector。后者的 (c, h, s) 表示的位置为 (c * 硬盘中的磁头数目 + h) * 每条磁道上可以划分的最大的扇区数 + (s - 1),其中扇区号从 0 开始。

因此对于上述分区表,最后一项说明了它是活动分区,文件系统为 FAT16B with LBA,大小约为 32 MB(0x10001(65537) sectors)

在 parted 中解析为:

Number  Start   End     Size    Type     File system  Flags
 1      1049kB  2551MB  2550MB  primary               raid
 2      2551MB  4699MB  2147MB  primary               raid
 4      4699MB  4732MB  33.6MB  primary               boot, lba
 3      4832MB  250GB   245GB   primary               raid

当 BIOS 发现该磁盘的第一个扇区以 55 aa 结束,确定它是一个 MBR,于是会执行存放在 byte 0-455 的 bootloader 。

bootloader

硬盘第一个扇区 MBR 中的 bootloader 好像并非是 GRUB stage 1 的代码。经过实验,发现是 gparted 在初始化磁盘时所创建。

把它们粘到 ODA 中,架构选 16位(i8086),得到汇编如下:

.data:00000000 fa                               cli
.data:00000001 b8 00 10                         mov    $0x1000,%ax
.data:00000004 8e d0                            mov    %ax,%ss
.data:00000006 bc 00 b0                         mov    $0xb000,%sp
.data:00000009 b8 00 00                         mov    $0x0,%ax
.data:0000000c 8e d8                            mov    %ax,%ds
.data:0000000e 8e c0                            mov    %ax,%es
.data:00000010 fb                               sti
.data:00000011 be 00 7c                         mov    $0x7c00,%si
.data:00000014 bf 00 06                         mov    $0x600,%di
.data:00000017 b9 00 02                         mov    $0x200,%cx
.data:0000001a f3 a4                            rep movsb %ds:(%si),%es:(%di)
.data:0000001c ea 21 06 00 00                   ljmp   $0x0,$0x621
.data:00000021 be be 07                         mov    $0x7be,%si
.data:00000024 38 04                            cmp    %al,(%si)
.data:00000026 75 0b                            jne    0x00000033
.data:00000028 83 c6 10                         add    $0x10,%si
.data:0000002b 81 fe fe 07                      cmp    $0x7fe,%si
.data:0000002f 75 f3                            jne    0x00000024
.data:00000031 eb 16                            jmp    0x00000049
.data:00000033 b4 02                            mov    $0x2,%ah
.data:00000035 b0 01                            mov    $0x1,%al
.data:00000037 bb 00 7c                         mov    $0x7c00,%bx
.data:0000003a b2 80                            mov    $0x80,%dl
.data:0000003c 8a 74 01                         mov    0x1(%si),%dh
.data:0000003f 8b 4c 02                         mov    0x2(%si),%cx
.data:00000042 cd 13                            int    $0x13
.data:00000044 ea 00 7c 00 00                   ljmp   $0x0,$0x7c00
.data:00000049 eb fe                            jmp    0x00000049
.data:0000004b 00 00                            add    %al,(%bx,%si)
.data:0000004d 00 00                            add    %al,(%bx,%si)
.data:0000004f 00                               .byte 0x0

这里在关中断环境下设置 ss = 0x1000,sp = 0xb000,ax = ds = es = 0x0。随后通过循环将位于 0x0:0x7c00 的 512 byte 代码(也就是当前正在执行的 MBR 区域代码)拷贝到 0x0:0x600,然后跳转过去执行后续代码。检查 0x7be (MBR 的 1be,位于 MBR 分区表的第一项)地址是否为 0,如果不为 0,表示第一分区是活动分区,跳转到后续代码执行。否则将地址加 16 后比较下一个表项。如果比较完 4 个表象都没找到目标,则执行 jmp 0x00000049 自己跳转自己无限 busy loop。假设找到了活动分区,看后续代码:

.data:00000033 b4 02                            mov    $0x2,%ah
.data:00000035 b0 01                            mov    $0x1,%al
.data:00000037 bb 00 7c                         mov    $0x7c00,%bx
.data:0000003a b2 80                            mov    $0x80,%dl
.data:0000003c 8a 74 01                         mov    0x1(%si),%dh
.data:0000003f 8b 4c 02                         mov    0x2(%si),%cx
.data:00000042 cd 13                            int    $0x13
.data:00000044 ea 00 7c 00 00                   ljmp   $0x0,$0x7c00

这里发送了 0x13 号中断,ah = 2 为 Read Sectors From Drive,al = 1 指定要读的扇区数为 1,Drive 为 0x80,读取位置 (c,h,s) 为活动分区表项中指定的那些,将其读取到 0x0:0x7c00。随后跳转过去执行代码。

根据文档,磁盘分区中起引导作用的第一个扇区称为 VBR(Volume Boot Record)。因此这段 MBR bootloader 代码的本质目的是找到活动分区,然后执行它的 VBR。

研究老骥伏枥提供的 disk_setboot.sh 脚本,发现其中有一行:

/mnt/grub-install --force-lba --root-directory=/mnt $1$(echo 4)

发现是把 grub 装到第四个分区(在我这里为 /dev/sdd4 )去了。因此接下来执行的是 /dev/sdd4 第一个扇区的代码。

硬盘分区引导阶段

我们把 /dev/sdd4 的第一个扇区 dump 出来看看:

00000000  eb 48 90 6d 6b 66 73 2e  66 61 74 00 02 04 01 00  |.H.mkfs.fat.....|
00000010  02 00 02 00 00 f8 40 00  3f 00 ff 00 00 30 02 00  |......@.?....0..|
00000020  00 00 01 00 80 01 29 0c  fe a6 53 20 20 20 20 20  |......)...S     |
00000030  20 20 20 20 20 20 46 41  54 31 36 20 20 20 03 02  |      FAT16   ..|
00000040  ff 01 00 80 4d 86 8c 00  00 08 fa 90 90 f6 c2 80  |....M...........|
00000050  75 02 b2 80 ea 59 7c 00  00 31 c0 8e d8 8e d0 bc  |u....Y|..1......|
00000060  00 20 fb a0 40 7c 3c ff  74 02 88 c2 52 be 7f 7d  |. ..@|<.t...R..}|
00000070  e8 34 01 f6 c2 80 74 54  b4 41 bb aa 55 cd 13 5a  |.4....tT.A..U..Z|
00000080  52 72 49 81 fb 55 aa 75  43 a0 41 7c 84 c0 75 05  |RrI..U.uC.A|..u.|
00000090  83 e1 01 74 37 66 8b 4c  10 be 05 7c c6 44 ff 01  |...t7f.L...|.D..|
000000a0  66 8b 1e 44 7c c7 04 10  00 c7 44 02 01 00 66 89  |f..D|.....D...f.|
000000b0  5c 08 c7 44 06 00 70 66  31 c0 89 44 04 66 89 44  |\..D..pf1..D.f.D|
000000c0  0c b4 42 cd 13 72 05 bb  00 70 eb 7d b4 08 cd 13  |..B..r...p.}....|
000000d0  73 0a f6 c2 80 0f 84 ea  00 e9 8d 00 be 05 7c c6  |s.............|.|
000000e0  44 ff 00 66 31 c0 88 f0  40 66 89 44 04 31 d2 88  |[email protected]..|
000000f0  ca c1 e2 02 88 e8 88 f4  40 89 44 08 31 c0 88 d0  |[email protected]...|
00000100  c0 e8 02 66 89 04 66 a1  44 7c 66 31 d2 66 f7 34  |...f..f.D|f1.f.4|
00000110  88 54 0a 66 31 d2 66 f7  74 04 88 54 0b 89 44 0c  |.T.f1.f.t..T..D.|
00000120  3b 44 08 7d 3c 8a 54 0d  c0 e2 06 8a 4c 0a fe c1  |;D.}<.T.....L...|
00000130  08 d1 8a 6c 0c 5a 8a 74  0b bb 00 70 8e c3 31 db  |...l.Z.t...p..1.|
00000140  b8 01 02 cd 13 72 2a 8c  c3 8e 06 48 7c 60 1e b9  |.....r*....H|`..|
00000150  00 01 8e db 31 f6 31 ff  fc f3 a5 1f 61 ff 26 42  |....1.1.....a.&B|
00000160  7c be 85 7d e8 40 00 eb  0e be 8a 7d e8 38 00 eb  ||..}.@.....}.8..|
00000170  06 be 94 7d e8 30 00 be  99 7d e8 2a 00 eb fe 47  |...}.0...}.*...G|
00000180  52 55 42 20 00 47 65 6f  6d 00 48 61 72 64 20 44  |RUB .Geom.Hard D|
00000190  69 73 6b 00 52 65 61 64  00 20 45 72 72 6f 72 00  |isk.Read. Error.|
000001a0  bb 01 00 b4 0e cd 10 ac  3c 00 75 f4 c3 00 00 00  |........<.u.....|
000001b0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
000001f0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 55 aa  |..............U.|

这段代码就是为我们熟悉的 GRUB 2 boot.img 了,把 GRUB 2 clone 下来,对应代码位于 grub-core/boot/i386/pc/boot.S

这里的逻辑和上述的 bootloader 很像,只是容错性更强,考虑了很多 BIOS 有 bug 的情况并进行相应处理。它将 diskboot.img 拷贝到 0x0:GRUB_BOOT_MACHINE_KERNEL_ADDR 并跳转。后续流程就很常规了,和 Linux启动流程:从启动到 GRUB 分析的一样, grub.cfg 中的脚本会被解析执行。

该脚本加载了各种文件系统的驱动,设置了成吨的环境变量。随后用户将选择菜单第一项启动:

menuentry "启动DS3617xs黑群晖 6.1,2版" --unrestricted {
    set img=$prefix/DS3617xs
    savedefault
    loadlinux 3617 usb
    loadinitrd
}

其主要调用的几个函数也同样在 grub.cfg 中:

function loadlinux {
    set model=$1
    set bootdev=$2
    shift 2
    if [ -n $vid -a -n $pid ]; then
        set usb_args="vid=$vid pid=$pid"
    fi
    eval "set common_args=\"\$common_args_$model\""
    eval "set extra_args=\"\$extra_args_$model\""
    eval "set bootdev_args=\"\$${bootdev}_args\""
    common_add_option_ex rootdev root
    common_add_option sn
    if common_add_option mac1; then set netif_num=1; fi
    if common_add_option mac2; then set netif_num=2; fi
    if common_add_option mac3; then set netif_num=3; fi
    if common_add_option mac4; then set netif_num=4; fi
    common_add_option netif_num
    if [ -z $zImage ]; then
        set zImage=zImage
    fi
        linux $img/$zImage $common_args $bootdev_args $extra_args $@
}

function loadinitrd {
    if [ -s $img/$info ]; then
        cat $img/$info
    fi
    showtips
    if [ -s $img/$extra_initrd ]; then
        initrd $img/rd.gz $img/$extra_initrd
    else
        initrd $img/rd.gz
    fi
}

loadlinux 执行的是 linux command。其参数展开后如下:

linux /boot/grub/DS3617xs/zImage root=/dev/md0 sn=A8ODN01234 mac1=0011322CA785 netif_num=1 vid=0x1234 pid=0x1234

因此群晖的 kernel 位于 /boot/grub/DS3617xs/zImage,而 /dev/md0 将被挂载为根目录。相比 Ubuntu 引导项的 linux command,多了 sn=A8ODN01234 mac1=0011322CA785 netif_num=1 vid=1234 pid=1234 这一串参数,估计是群晖的 kernel 在启动时需要对这些参数进行校验,猜测如果校验失败,那么就无法启动。

loadinitrd 执行的是 initrd command,GRUB 会将其传入的 initrd 文件读入到内存中,并将地址和大小填到 linux_kernel_params,这样 kernel 启动后,就知道去哪里加载 initrd 了。相比 Ubuntu 的 initrd 只有一个 initrd.img-XXX 文件,群晖有两个,分别为 /boot/grub/DS3617xs/rd.gz 和 /boot/grub/DS3617xs/extra.lzma 。将两者解压,得到目录结构。

rd.gz 是典型的 initrd :

$ tree rd -L 2
rd
├── bin -> usr/bin
├── dev
│   └── net
├── etc
│   ├── AHAtasks
│   ├── VERSION
│   ├── avahi
│   ├── crontab
│   ├── dhclient
│   ├── dhcpc
│   ├── extensionPorts
│   ├── fstab
│   ├── ftpusers
│   ├── group
│   ├── group_desc
│   ├── host.conf
│   ├── hosts
│   ├── hosts.allow
│   ├── hosts.deny
│   ├── inetd.conf
│   ├── mke2fs.conf
│   ├── modules.conf
│   ├── mtab
│   ├── nsswitch.conf
│   ├── passwd
│   ├── profile
│   ├── protocols
│   ├── rc
│   ├── rc.fan
│   ├── rc.network
│   ├── rc.network_dualhead
│   ├── rc.network_routing
│   ├── rc.sas
│   ├── rc.scanusbdev
│   ├── rc.subr
│   ├── rc.volume
│   ├── rc.wifi
│   ├── resolv.conf
│   ├── securetty
│   ├── services
│   ├── shadow
│   ├── shells
│   ├── ssl
│   ├── synogrinst.sh
│   ├── synoinfo.conf
│   ├── synouser.conf
│   ├── sysconfig
│   ├── sysctl.conf
│   └── termcap
├── etc.defaults -> etc
├── init -> bin/busybox
├── lib -> usr/lib
├── lib32 -> usr/lib32
├── lib64 -> usr/lib
├── linuxrc -> bin/busybox
├── linuxrc.syno
├── mnt
├── proc
├── root
├── run
│   └── lock
├── sbin -> usr/sbin
├── sys
├── tmp
├── usr
│   ├── bin
│   ├── lib
│   ├── lib32
│   ├── lib64 -> lib
│   ├── local
│   ├── sbin
│   ├── share
│   └── syno
├── var
│   ├── cache
│   ├── crash
│   ├── lib
│   ├── lock -> ../run/lock
│   ├── log
│   ├── packages
│   ├── run -> ../run
│   ├── services
│   ├── spool
│   └── tmp
└── volume1

extra.lzma 是对 initrd 中内核模块的补充,支持了更多类型的设备:

$ tree extra -L 4
extra
├── etc
│   ├── jun.patch
│   └── rc.modules
├── extra.lzma
├── init
└── usr
    ├── bin
    │   └── patch
    ├── lib
    │   ├── firmware
    │   │   ├── bnx2
    │   │   └── tigon
    │   └── modules
    │       ├── BusLogic.ko
    │       ├── alx.ko
    │       ├── ata_piix.ko
    │       ├── atl1.ko
    │       ├── atl1c.ko
    │       ├── atl1e.ko
    │       ├── ax88179_178a.ko
    │       ├── bnx2.ko
    │       ├── bnx2x.ko
    │       ├── button.ko
    │       ├── cnic.ko
    │       ├── e1000.ko
    │       ├── ehci-hcd.ko
    │       ├── ehci-pci.ko
    │       ├── ipg.ko
    │       ├── jme.ko
    │       ├── libcrc32c.ko
    │       ├── libphy.ko
    │       ├── mdio.ko
    │       ├── megaraid.ko
    │       ├── megaraid_mbox.ko
    │       ├── megaraid_mm.ko
    │       ├── megaraid_sas.ko
    │       ├── mii.ko
    │       ├── mptbase.ko
    │       ├── mptctl.ko
    │       ├── mptsas.ko
    │       ├── mptscsih.ko
    │       ├── mptspi.ko
    │       ├── netxen_nic.ko
    │       ├── ohci-hcd.ko
    │       ├── pch_gbe.ko
    │       ├── pcnet32.ko
    │       ├── ptp_pch.ko
    │       ├── qla3xxx.ko
    │       ├── qlcnic.ko
    │       ├── qlge.ko
    │       ├── r8168.ko
    │       ├── r8169.ko
    │       ├── scsi_transport_spi.ko
    │       ├── sfc.ko
    │       ├── skge.ko
    │       ├── sky2.ko
    │       ├── tg3.ko
    │       ├── uio.ko
    │       ├── usbnet.ko
    │       ├── vmw_pvscsi.ko
    │       └── vmxnet3.ko
    └── sbin
        └── modprobe

这其中我只认识 e1000,它是虚拟化中最常见的网卡。

至此我们完成了老骥伏枥版群晖引导的分析。

总结

老骥伏枥的硬盘引导法,本质上为三级加载:MBR => VBR => 群晖的 Linux kernel 。在这次分析过程中,我被 MBR 中的奇怪 bootloader 所惑,百思不得其解为什么能够引导活动分区,最后还是网上找了个反汇编工具才明白其原理。当然,这样做的目的老骥伏枥也说了:

我们做黑群晖硬盘自启动时,最不希望影响硬盘主引导分区,而希望所有的改装都放在硬盘子分区中。

有点道理,但这样做容易有坑:如果用户一开始没有使用 gparted 来重建分区表,那么 MBR 中 bootloader 的逻辑可能就不是这样的,甚至可能由于该盘之前装了 Linux 导致 bootloader 被写入为 GRUB 的 boot.img 。但如果将 VBR 中的 boot.img 写入到 MBR 中,那么根据 boot.img 的实现,其会加载位于后面的 core.img,因此前 63 个扇区我们都要改,理论上可以实现 MBR => 群晖的 Linux kernel 的加载。如果之后有时间,会尝试这样折腾下。

无论如何,非常感谢老骥伏枥提供的引导,不仅让我成功实现硬盘引导群晖,更重要的是让我把引导相关的知识复习了一遍,非常有意思。

后记

自从 18 年 12 月装机完成后,至今已经平稳运行了两个多月。在我将其搬回家后,更是作为了家中的存储中枢。家中电视能够方便地通过 dlna 访问其中照片和视频,用过的都说好。

唯一的缺点就是机械硬盘太吵了,放房间里夜深人静的时候嘎嘎嘎响,于是把它搬到客厅了。

参考

http://www.nasyun.com/thread-28601-1-2.html

https://thestarman.pcministry.com/asm/mbr/GRUB.htm

https://en.wikipedia.org/wiki/GNU_GRUB

https://wiki.osdev.org/MBR_(x86)

https://en.wikipedia.org/wiki/INT_13H#INT_13h_AH=02h:_Read_Sectors_From_Drive