Raspberry Pi4 ddコマンド以外でイメージ書き込み

## 前文
きっかけは公式フォーラムの以下のトピ。
[dd vs cp for flashing sd cards on the Linux command line. - Raspberry Pi Forums](https://forums.raspberrypi.com/viewtopic.php?t=193926 "dd vs cp for flashing sd cards on the Linux command line. - Raspberry Pi Forums")

`Linux`環境でイメージファイルの書き込みといえば`dd`コマンドが定番だけど、馴染みが薄いしスイッチ類が分かりにくいしで使い勝手が悪い。`cp`コマンドでも代用できるので、そっちの方が良くね？ という議論が交わされている。
実は`cp`コマンドの方が（僅かではあるが）速い、というのも興味深い。
そんな訳で`dd`コマンド以外のイメージファイルの書き込み（操作）方法を考察検証してみた。

## 動作確認環境
Pi4B 4G、64 bit Raspberry Pi OS with desktop（bullseye）、USB-SSDブート。
イメージファイル書込み先は`/dev/sdb`。USB接続 microSDカードリーダ/ライタ。
microSDカードは Transcend の 32G。

検証に使用したイメージファイルは以下（LibreELEC-RPi4.arm-10.0.2.img.gz）、又はそれを解凍したファイル（LibreELEC-RPi4.arm-10.0.2.img）。
[Raspberry Pi4用LibreELEC 10.0.2](https://releases.libreelec.tv/LibreELEC-RPi4.arm-10.0.2.img.gz "Raspberry Pi4用LibreELEC 10.0")

## イメージファイル書込み
まず`dd`コマンドなら以下。
```shell-session
$ sudo dd if=./LibreELEC-RPi4.arm-10.0.2.img of=/dev/sdb bs=1M
```

次は`cp`コマンドで。確かに直観としてこちらの方が分かりやすい。
```shell-session
$ sudo cp ./LibreELEC-RPi4.arm-10.0.2.img /dev/sdb
```
冒頭で挙げたトピでは、`cp`コマンドでのイメージ書き込み例に対し、`cp`コマンドとは通常のファイルコピーで使用する物、イメージ書き込みなんて出来ないですよ、と脊髄反射している人が居た。
気持ちは分かるけど、これはコピー先がデバイスファイルという特殊ファイルの成せる技。
デバイスファイルは連動するストレージ、今回のケースなら`/dev/sdb`は microSDカードのイメージにダイレクトに紐づいている。`dd`コマンドを使用しなくても、このデバイスファイルを利用すれば、ストレージに対しハードウェアに近い低レベルな直接操作ができる、というのが基本的な考え方。
`cat`コマンドなら以下。
```shell-session
$ cat ./LibreELEC-RPi4.arm-10.0.2.img | sudo tee /dev/sdb > /dev/null
```
うーん、一般ユーザの`sudo`なので微妙に複雑。`sudo cat ～`としても、リダイレクト先の`/dev/sdb/`には`sudo`が作用しないので、`sudo tee`で橋渡ししている。しかし`tee`はファイルへの出力だけではなく標準出力（画面出力）も行ってしまい邪魔なのでそれは`> /dev/null`で捨てている、という内容。回りくどいなぁ。
`root`ユーザなら単純に以下で良い。
```shell-session
# cat ./LibreELEC-RPi4.arm-10.0.2.img > /dev/sdb
```

圧縮ファイルをそのまま使用したい場合は以下。
```shell-session
$ gunzip -c ./LibreELEC-RPi4.arm-10.0.2.img.gz | sudo tee /dev/sdb > /dev/null
```
`gunzip -c`は解凍結果を標準出力に吐くのでそれをパイプで繋げて書き込み。
てか、なら`zcat`でも良くね、ということで以下でも同じ。
```shell-session
$ zcat ./LibreELEC-RPi4.arm-10.0.2.img.gz | sudo tee /dev/sdb > /dev/null
```
こちらも前述同様、`root`ユーザならストレートに以下。
```shell-session
# gunzip -c ./LibreELEC-RPi4.arm-10.0.2.img.gz > /dev/sdb
# zcat ./LibreELEC-RPi4.arm-10.0.2.img.gz > /dev/sdb
```

## ベリファイ
イメージの書き込みで`balenaEtcher`を使用している方は多いと思うけど、新品または新品に近い状態の microSDカードでもイメージ書き込み後のベリファイでエラーを吐くことがある。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254888/3ed2e45b-aa83-1e5d-194f-807399053412.png" width=80%>
（自分もそうだけど）英語で書かれた画面なんてよく分からないしでろくに確認もせずに閉じてしまい、見落としている人が多い予感。
因みにベリファイでエラーが報告されたら即ダメって話でもなくて、再度書き込めば次は正常終了だったりするので OK。つまりそれだけ不安定、信頼できないメディアってこと。

前項の、要領よくやればこんなに簡単なコマンドでイメージファイルの書き込みができますよ、で完結する話ではなく。本当に正しく書き込まれたのかこれ、という不安が必ずつきまとう。
という訳で次はベリファイを行うコマンドの考察。

`diff`コマンドはバイナリも比較できるので以下。しかし上手くいかない。
```shell-session
$ sudo diff --brief --report-identical-files ./LibreELEC-RPi4.arm-10.0.2.img /dev/sdb
ファイル ./LibreELEC-RPi4.arm-10.0.2.img と /dev/sdb は異なります
```
これはイメージファイルと microSDカード全体と比較してしまうから。そこで`cmp`コマンドで以下。
```shell-session
$ sudo cmp -b ./LibreELEC-RPi4.arm-10.0.2.img /dev/sdb
cmp: EOF on ./LibreELEC-RPi4.arm-10.0.2.img after byte 575668224, in line 541104
```
`-b`スイッチを指定しているので差異があればその位置と双方の値が表示される。それが表示されず且つ`cmp: EOF on ～`でファイルの終端に達したことを示しているということは、イメージファイルの範囲内で相違点はなかったということ。
因みに異なる箇所がある場合の出力例は以下。
```shell-session
- /dev/sdb 異なります: バイト 4458241、行 655   0 ^@ 345 M-e
```
圧縮ファイルでの比較は以下。
```shell-session
$ gunzip -c ./LibreELEC-RPi4.arm-10.0.2.img.gz | sudo cmp -b - /dev/sdb
$ sudo zcmp -b ./LibreELEC-RPi4.arm-10.0.2.img.gz /dev/sdb
```

## 書き戻し（バックアップ）
ついでにバックアップコマンドの考察。
冒頭のイメージファイルから microSDカードへの書き込みの逆なので以下。以降出力ファイル名は`<デバイスファイル名>.img`とする。
```shell-session
$ sudo cp /dev/sdb ./sdb.img
$ sudo cat /dev/sdb > ./sdb.img
```
但しこの場合、microSDカード全体のバックアップとなる。従って、前述の`diff`コマンドでの比較はこのバックアップファイルの場合なら同一と判定される。
```shell-session
$ sudo diff --brief --report-identical-files ./sdb.img /dev/sdb
ファイル ./sdb.img と /dev/sdb は同一です
```

全体バックアップならこれで良いけど、バックアップ時の容量節約を考慮しパーティションサイズを敢えて縮小している場合はそのサイズでファイルに書き出したい。
今回、`LibreELEC`イメージファイルを書き込んだ直後の`sdb`のパーティション構成は以下。
```shell-session
$ sudo fdisk -l /dev/sdb
Disk /dev/sdb: 28.33 GiB, 30416044032 bytes, 59406336 sectors
Disk model: STORAGE DEVICE
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x8d4aa938

Device     Boot   Start     End Sectors  Size Id Type
/dev/sdb1  *       8192 1056767 1048576  512M  c W95 FAT32 (LBA)
/dev/sdb2       1056768 1122303   65536   32M 83 Linux
```

`GParted`で確認した画面が以下。少なッ。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254888/7708daf0-e962-0614-0cf7-b8636eb1f3af.png" width=80%>

簡単な図に起こすと以下。
![パーティション構成_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254888/0cef184c-6da5-bd9d-c4d4-367c76967e62.png)
4M + 512M + 32M = 548M しか使用していない。極端な例だけど、この範囲で取得してみる。

上記の通り、`sdb`の最終セクタは 1122303 なので、先頭から1122304セクタコピーすれば良いことになる。
しかし手動でサイズを求めバックアップ時のパラメータとして設定するのは間違いの元なのでまずコマンドで求める方法を確定する。上記の通り、欲しい値は`sudo fdisk -l /dev/sdb`の出力結果の最後の行の 3列目で更にそれを +1 した値。
更にデバイス名`sdb`も変数化すると以下。
```shell-session
$ src='sdb'
$ sudo fdisk /dev/$src -l | tail -n 1 | awk '{$3=($3+1);print $3}'
1122304
```
期待した結果が得られた。これを`dd`コマンドにパラメータとして組み込むと以下。
```shell-session
$ src='sdb'
$ sudo dd if=/dev/$src of=./$src.img bs=512 count=`sudo fdisk /dev/$src -l | tail -n 1 | awk '{$3=$3+1;print $3}'`
1122304+0 レコード入力
1122304+0 レコード出力
574619648 bytes (575 MB, 548 MiB) copied, 12.8095 s, 44.9 MB/s
```
一応ベリファイ。大丈夫そう。
```shell-session
$ sudo cmp -b ./$src.img /dev/$src
cmp: EOF on ./sdb.img after byte 574619648, in line 541104
```

しかし今回のテーマは`dd`コマンド以外で行う、なので以下。
```shell-session
$ src='sdb'
$ sudo head -c `sudo fdisk /dev/$src -l | tail -n 1 | awk '{$3=($3+1)*512;print $3}'` /dev/$src > ./$src.img
```
`head`コマンドは`-c`スイッチで取得したい先頭バイト数を指定できる。つまりここでは単位がバイト数なので`*512`としている。（1セクタ = 512バイト）
そしてベリファイ。先ほどと同じ結果。
```shell-session
$ sudo cmp -b ./$src.img /dev/$src
cmp: EOF on ./sdb.img after byte 574619648, in line 541104
```

## 書き戻し（バックアップ）其ノ二
パーティション単位でのバックアップなら以下。
```shell-session
$ sudo cp /dev/sdb1 ./sdb1.img
$ sudo cp /dev/sdb2 ./sdb2.img
```
これならサイズを意識する必要がない。しかし MBR を含む先頭の領域となると、こういった方法では対応できない。サイズ指定が必要。うーむ。サイズを意識しないで抽象的にバックアップできることが究極の理想だけど無理っぽい。

この先頭の領域は`sdb0.img`とし、パーティション単位で取得する方向でまとめるなら以下。
```shell-session
$ src='sdb'
$ sudo head -c `sudo fdisk -l /dev/$src | grep "${src}1" | awk '{print $3*512}'` /dev/$src > ./$src0.img
$ sudo cp /dev/${src}1 ./$src1.img
$ sudo cp /dev/${src}2 ./$src2.img
```
ベリファイは以下。sdb0.img、sdb1.img、sdb2.img を`cat`で連結し`/dev/sdb`と比較。
```shell-session
$ src='sdb'
$ sudo cat ./${src}0.img ./${src}1.img ./${src}2.img | sudo cmp -b /dev/$src -
cmp: EOF on - after byte 574619648, in line 541104
```

## コマンドまとめ
イメージファイル書込みはベリファイを含めて以下。
```shell-session
$ src='./LibreELEC-RPi4.arm-10.0.2.img.gz'
$ dst='sdb'
$ zcat $src | sudo tee /dev/$dst > /dev/null
$ sudo zcmp -b $src /dev/$dst
cmp: EOF on - after byte 575668224, in line 541104
```

バックアップは以下。
```shell-session
$ src='sdb'
$ sudo head -c `sudo fdisk /dev/$src -l | tail -n 1 | awk '{$3=($3+1)*512;print $3}'` /dev/$src > ./$src.img
$ sudo cmp -b ./$src.img /dev/$src
cmp: EOF on ./sdb.img after byte 574619648, in line 541104
```

## 最終確認
最初のイメージファイルと最終的に microSDカードから吸い出したイメージファイルを比較してみる。結果は以下。
```shell-session
$ src='sdb'
$ zcmp -b ./LibreELEC-RPi4.arm-10.0.2.img.gz ./$src.img
cmp: EOF on ./sdb.img after byte 574619648, in line 541104
```
`sdb.img`はパーティションサイズできっちり切り出しているので最初のイメージファイルより微妙にサイズが小さい。
一応、最初のイメージファイルの 574619648（22400000h） 以降を確認すると以下。
```shell-session
$ zcat ./LibreELEC-RPi4.arm-10.0.2.img.gz | od -j 574619648 -Ax -tx1z -
22400000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
*
22500000
```
`00`で埋まっているだけの余白なので問題ないでしょう。sdb上も以下の内容。（100000h = 1048576）
```shell-session
$ sudo od -j 574619648 -Ax -tx1z -N 1048576 /dev/sdb
22400000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
*
22500000
```

## 注意点
実はベリファイで少々躓いた。結果が異なる場合があり当初は原因が分からず悩まされた。全体的には同じだが、一部（1バイト）だけ異なるようなケース。
原因はマウント状態や`dirty bit`だった。

まず`dirty bit`。これはストレージが`mount`（マウント）されると設定され、`umount`（アンマウント）されるとクリアされるという仕様らしい。
もし新規で`mount`したストレージの`dirty bit`が既に設定済だった場合は、前回明示的に`umount`されずに取り外されたか（リムーバブルストレージの場合）、電源が落ちたか、といった正規の手続きを踏んでいなかったことを示す。
その場合、本来ならストレージに書き込まれる筈だったメモリ上のデータがフラッシュされていない（書き込まれていない）、不整合が発生している可能性がある、ということになる。という仕様のもの。

問題はこの`dirty bit`が microSDカードに設定されている場合、イメージファイルを書き込んでもリセットされず残るという。
結果ベリファイをかけると`dirty bit`で差異が発生してしまう。
異なる値が、イメージファイル上では`0`、microSDカード上では`1`だった場合はまずこれが原因。
その場合は`fsck`コマンドで状態を確認。`n`スイッチは検査だけで修復は行わない、`v`は詳細表示スイッチ。実行例は以下。（正常時）
```shell-session
$ sudo fsck -nv /dev/sdb1; echo $?
fsck from util-linux 2.36.1
fsck.fat 4.2 (2021-01-31)
Checking we can access the last sector of the filesystem
Boot sector contents:
System ID "MTOO4026"
Media byte 0xf0 (5.25" or 3.5" HD floppy)
       512 bytes per logical sector
      8192 bytes per cluster
         1 reserved sector
First FAT starts at byte 512 (sector 1)
         2 FATs, 16 bit entries
    131072 bytes per FAT (= 256 sectors)
Root directory starts at byte 262656 (sector 513)
       512 root directory entries
Data area starts at byte 279040 (sector 545)
     65501 data clusters (536584192 bytes)
8192 sectors/track, 4 heads
         0 hidden sectors
   1048576 sectors total
Checking for unused clusters.
/dev/sdb1: 268 files, 18214/65501 clusters
0
$ sudo fsck -nv /dev/sdb2; echo $?
fsck from util-linux 2.36.1
e2fsck 1.46.2 (28-Feb-2021)
STORAGE: clean, 12/8192 files, 6938/32768 blocks
0
```
`echo $?`で明示的に戻り値も表示。`0`が返ってきているので正常終了。
もし`dirty bit`が設定されている場合はこれで表示され確認できる。その場合は改めて`fsck`をかけ、`dirty bit`をクリアすること。
また、`mount`されている状態でバックアップを取るとベリファイで異なる結果となり無駄に悩むことになるので、`umount`してから行うこと。
```shell-session
$ sudo umount /dev/sdb1
$ sudo umount /dev/sdb2
```

## 余談
[dd (UNIX) - Wikipedia](https://ja.wikipedia.org/wiki/Dd_(UNIX) "dd (UNIX) - Wikipedia")
> dataset definitionの略であるが、IBMのメインフレームのJob Control Language（ジョブ制御言語、JCL）の「DD文」（DD statement）に由来するため、`引数の構文が、Unixの一般的なコマンドの引数のそれとは激しく異なっている`（datasetというのはメインフレーム用語）。

なるほどねぇ。`dd`コマンドの引数構文の異質さ気持ち悪さはこれが原因なのか。