お兄ちゃん!そこは MemoryStream の出番だよ!
タイトルは釣りです(お約束)
MemoryStream のススメ
みなさん、 System.IO.MemoryStream
使っていますか。私はよく使いますよ。
MemoryStream Class (System.IO) | Microsoft Docs
リアクティブプログラミングだったり、Java の Stream API だったり、いろんな Stream がありますが、今回はC#の MemoryStream
に注目してみます。
MemoryStream のイロハ
そもそもC#には Stream
クラスがあり、 MemoryStream
はその派生クラスです。同じような派生クラスには FileStream
や CryptoStream
があります。
似て非なるものですが、これらは共通して データを順次読み出したり、順次格納したりできる という特徴を持っています。
たとえば FileStream
は、ディスク上のファイルを読み書きするクラスです。ファイルを開くと FileStream
にはそのファイルサイズと カーソル位置 が保持されます。
カーソル位置は Stream
のデータを操作する位置 を表します。これを使うと、例えばファイルの80バイト目から60バイト分読み出したい、といった場合に、カーソル位置を80バイト目に移動させ、そこから60バイト分読み出すことができます(同時にカーソルも60バイト動くため、次に60バイトを読み出すと140バイト目から60バイトを読み出します)。
これは、次のように書くことができます。
// ファイル名 を元に FileStream を作成 var stream = new FileStream("C:/hoge.txt", FileMode.Open); // 80バイト目から60バイト読み出す var puts = new byte[60]; stream.Position = 80; stream.Read(puts, 0, 60);
同様に MemoryStream
も、ある byte[]
のサイズ(配列の長さ)とカーソル位置が保持されます。
そして、例えばある byte[]
の80バイト目から60バイト分読み出したい、といった場合に、カーソル位置を80バイト目に移動させ、そこから60バイト分読み出すことができます。
これは、次のように書くことができます。
var array = new byte[300]; // --- ここに本来はデータの操作が入る --- // // array を元に MemoryStream を作成 var stream = new MemoryStream(array); // 80バイト目から60バイト読み出す var puts = new byte[60]; stream.Position = 80; stream.Read(puts, 0, 60);
なお、以下の配列へのアクセスをするコードで、同様の puts
を得られます。
var array = new byte[300]; // --- ここに本来はデータの操作が入る --- // // 80バイト目から60バイト読み出す var puts = new byte[60]; for (int i = 0; i < 60; i++) puts[i] = array[80 + i];
え、じゃあ何に使うん 。
MemoryStream は byte[]
へのアクセスを簡単にします。本当に?
ひどい夢を見ました。すべての byte[]
配列へのアクセスを、 MemoryStream
を通じて行うようにするという、お達しが出たのです。まったく、とんだ災難です。このプロダクトは文字列や数値としては扱えないデータが山ほどあり(画像や音声、もしかしたら地球外生命体のDNAの解析結果かもしれない)、それらはすべて byte[]
で表すことになっています。だから、それら全てのアクセスを、 MemoryStream
に置き換えなければなりません。たった1要素の読み込みでさえ、長ったらしく2,3行を書き連ねなければならないのです。
実際にそんなことがあるはずはありません。安心してください。 ところで次の例を見てくれ、こいつをどう思う?
// AESで暗号化するためのオブジェクトを初期化 var aes = new AesManaged(); aes.GenerateIV(); aes.GenerateKey(); // MemoryStreamを作成 var memStream = new MemoryStream(); // CryptoStreamを作成 var cryStream = new CryptoStream(memStream, aes.CreateEncryptor, CryptoStreamMode.Write); // CryptoStreamに書き込み cryStream.Write(new byte[] { 1, 2, 3, 4, 5 }, 0, 5); // MemoryStreamから読み出し var read = byte[3]; memStream.Position = 1; memStream.Read(read, 0, 3);
AesManaged Class (System.Security.Cryptography) | Microsoft Docs
CryptoStream Class (System.Security.Cryptography) | Microsoft Docs
ちょっと難解ですが、 ある MemoryStream
を参照する CryptoStream
にデータを書き込むと、 MemoryStream
に暗号化されたデータが書き込まれる コードです。
上記の例では read
に暗号化されたデータの一部が代入されることになります。
さてこれをちょっとだけ改変しましょう。
// AESで暗号化するためのオブジェクトを初期化 var aes = new AesManaged(); aes.GenerateIV(); aes.GenerateKey(); // FileStreamを作成 var filStream = new FileStream("C:/hoge.enc", FileMode.Create); // CryptoStreamを作成 var cryStream = new CryptoStream(filStream , aes.CreateEncryptor, CryptoStreamMode.Write); // CryptoStreamに書き込み cryStream.Write(new byte[] { 1, 2, 3, 4, 5 }, 0, 5);
だいたい想像がつくと思いますが、これは ある FileStream
を参照する CryptoStream
にデータを書き込むと、 FileStream
に暗号化されたデータが書き込まれる(暗号化されたデータがファイルに書き込まれる) コードです。
驚くべきことに、このコードを先ほどのコードと比べると、 MemoryStream
を FileStream
にすり替えただけなのです。
MemoryStream は世界を救う
つまり、 MemoryStream
は、 byte[]
を FileStream
、すなわち 変数操作とファイル操作と同等に扱えるようにするクラス ということなのです。
C# では、とくにデータの変換系の処理を Stream
で行うような風潮があるように見えます。
例えば暗号化や、巨大なバイナリファイルの符号化など。JSONのシリアライズにも Stream
クラスを引数に受けるメソッドを用います。
こうすることで、対象がファイルでもメモリでも、同じ操作でデータを扱えるという素晴らしい恩恵を享受することができます。
単体テストもコードの再利用もどんとこい、な機能ですね。
さいごに
素人が生意気にすみませんでした 。ちょっと魔がさして書き始めたら収拾がつかなくなってしまいました。申し訳ありません。反省はしていません。
この間 JSON をC#のクラスにシリアライズしたりデシリアライズするときに MemoryStream
を使う機会があったのですが、正直な話
なんでクラスで管理できる情報量をわざわざ MemoryStream
で書く必要があるのか、変数でいいのではないか
と思いながらバリバリ書いてたんですね。
そしてふと、思いついたんですよね。JSON ってファイルの可能性があるよなぁ、と。
自分の中ではとても面白い発見だったので、ついつい長ったらしく書いてしまいました。本当に申し訳ありませんでした。
最後まで見てくださって本当にありがとうございました。
P.S.
本来書きたかったネタもとりあえず置いておきます。 MemoryStream
の Read
メソッドが超絶使いにくい件について。
public static class MemoryStreamExtention { public static byte[] ReadAllBytes(this System.IO.MemoryStream target) { byte[] ret = new byte[target.Length]; target.Position = 0; target.Read(ret, 0, (int)target.Length); return ret; } }
.NET core コンソールアプリで文字化けするときの対処法
Main
メソッドの一番頭にこれを書く。
Console.OutputEncoding = Console.OutputEncoding;
何をしているのか
.NET core コンソールアプリでは、規定で Console.OutputEncoding
に System.Text.UTF8Encoding
のインスタンスが入っています。
しかし、コンソール側(Windowsでデバッグした場合は規定でコマンドプロンプト)がこれをまだ検知していない状態のため、UTF-8でないコードページで表示をしています。
そのため、一度プロパティのsetterを通す必要があります。
…という推測を立てただけです。すみません。
Ubuntuのtaskselを使って、ラクしてKVM環境を作る
Linuxで楽しよう。Linuxを楽しもう。(名言っぽく言ってみるだけ)
ふと tasksel
でxubuntu desktopを選ぼうとすると、そこにそれらしき項目があったので、使ってみた。その時のメモ。
基本、シェルはbashでroot権限で行きます。 sudo
はつけないので、 su
したくない方は sudo
をつけて実行してください。
PCが仮想化に対応しているか確認する
KVMは完全仮想化をCPUの仮想化技術で実現しているようなので、次のコマンドで確認する。
# egrep -c '(vmx|svm)' /proc/cpuinfo
出力が1以上なら仮想化できます。
パッケージをインストールする
とりあえずKVM環境を動かすためのライブラリ群を入れます。次のコマンドを打ちます。
# tasksel
GUIっぽい画面が出てくるので、一覧から「Virtual Machine Host」に スペースキー でチェックを入れ、 エンターキー で続行します。
あとは待つだけ。これが終わると、仮想化に必要なものがほとんど入ります。
ただし、GUIで設定できる「virt-manager」はあいにく入れてくれないので、これだけは手動で入れます。
# apt install virt-manager
これがあれば、Virtual BoxみたいにGUIで簡単に操作ができるようになります。
ネットワークインターフェイス(NIC)をブリッジする
なんか、皆さんこぞってブリッジしているので、ブリッジしたほうが幸せなのかな?と半信半疑でブリッジしました。
一応パッケージをチェック。
# apt list | grep bridge-utils
[インストール済み] と末尾に表示されていなければ、インストールします。
# apt install bridge-utils
次に、ブリッジするよう /etc/network/interfaces
を編集
Ubuntuは最近、規定のNICのインターフェイス名が環境によって変わるようになったので、適宜読み替えてください。
auto enp0sXXXX iface enp0sXXXX inet manual auto br0 iface br0 inet static address 192.168.XXX.XXX network 192.168.XXX.0 netmask 255.255.255.0 gateway 192.168.XXX.XXX dns-nameservers XXX.XXX.XXX.XXX bridge-ports enp0sXXXX bridge-stp off
元あった規定NICのインターフェイスの設定はすべてコメントアウトか削除かしておきます。その設定を、br0側にすべて書きます。
この後、再起動して ifconfig
でbr0側にIPアドレスなどが定義されていることを確認。
参考サイト
aptでインストールした残骸を確認する
完璧に備忘録ですが。
パッケージの一覧を取得する
# apt list
パッケージの一覧が表示されます。インストール済みかそうでないかにかかわらず、データベースに登録してあるパッケージはすべて出ます。
たぶんこの一覧の中からパッケージの情報を取り出すんだと思います。
設定が残る
上記のコマンドで表示した一覧で、末尾に次のような表示がされることがあります。
この「設定が残存」があると、再インストール時に --update
フラグとかが自動でつけられたりして、クリーンインストールはしないようです。
設定を消す
# apt purge <パッケージ名> # apt remove --purge <パッケージ名>
どちらでもいいみたい、
Ubuntu16.10でIPを固定しDNSを指定するのに、2時間かけました。
Ubuntuのことをあまり知らずに挑むからダメなんですよね。
とはいえ、IPを固定するくらいはいくらシステムの根幹に近いカスタマイズとはいえ、もうちょっと簡単でもいいと思うんですよ…
変更した箇所
- /etc/network/interfaces
- /etc/hosts
- /etc/NetworkManager/NetworkManager.conf
- /etc/resolvconf/resolv.conf.d/tail
いちおうこれらは確実に変更してあります。
ほかにも触ったところあったかもしれないけど、毎回Ubuntuマシン触るときに悩まされる種でもありイライラしていたので覚えていない…。これだからブログ向いてないんですよね。知ってます。
※なお、これから同じような設定をされる方は、ほかの方の記事やしっかりとした情報を一通り見てから実際に設定されることを強くお勧めします。
/etc/network/interfaces
auto lo ifacw lo inet static
が初期設定。これを次のように変更します。
auto enpX iface enpX inet static address <固定するアドレス XXX.XXX.XXX.XXX> netmask <サブネットマスク XXX.XXX.XXX.XXX> gateway <デフォルトゲートウェイ XXX.XXX.XXX.XXX> dns-nameservers <DNSサーバ XXX.XXX.XXX.XXX>
enpX
はNICのアドレスのようなもので、 ifconfig
コマンドで確認ができます。
lo
から enpX
に変更するので、 ifconfig
を走らせたときに lo
と同じような位置に出てくるやつがそれです。
なおDNSサーバプライマリとセカンダリをスペースで区切って指定できるようです。
/etc/hosts
変更する意味があるかと言われれば、もしかしたらないかもしれないですが、一応変更します。
127.0.0.1 localhost 127.0.1.1 <コンピュータ名> # The following lines are disirable for IPv6 capable hosts ::1 ip6-localhost ip6-loopback ・ ・ ・
最後のほうは面倒なので端折りました。
ここに出てくる 127.0.1.1
を変更します。
127.0.0.1 localhost <固定するアドレス> <コンピュータ名> # The following lines are disirable for IPv6 capable hosts ::1 ip6-localhost ip6-loopback ・ ・ ・
/etc/NetworkManager/NetworkManager.conf
悪党を倒しに行きます(?)
[main] plugins=ifupdown,keyfile,ofono dns=dnsmasq [ifupdown] managed=false
これを、以下のように直します。
[main] plugins=ifupdown,keyfile,ofono #dns=dnsmasq [ifupdown] managed=false
これで悪党は目覚めないことでしょう。きっと。
/etc/resolvconf/resolv.conf.d/tail
最後に強制的にDNSを変えます。もう最終手段です。
このファイルは、私の環境では入っていなかったため、自分で作りました。
そして以下のように記述します。
nameserver <DNSサーバ XXX.XXX.XXX.XXX (プライマリ)> nameserver <DNSサーバ XXX.XXX.XXX.XXX (セカンダリ)>
果たして /etc/network/interfaces に記述した dns-nameservers
に意味はあったのだろうか。
そして最後、 resolv.conf を生成します。
$ sudo resolvconf -u
再起動
最後に再起動すれば、IPアドレスは固定になり、DNSサーバも変わっているはず。
時間がかかった。
結局、表題の通りすべての帳尻合わせするため 2時間 以上はかけました。はい。
IPアドレスを固定するために /etc/interfaces を書き換えている方がたくさんいたのですが、それだけだとなぜかうまくいかなくて。
試行錯誤を重ねてやっと固定された感じです。つらい。
やっぱりここらへんはCentOSのほうが楽なんでしょうかね。。。
StringBuilderをstringと同じだけ作ると、どれほど遅いのか。
この間、以下のようなコードを見かけました。
StringBuilder strSql = new StringBuilder(); strSql.Append(@"SELECT * FROM ... WHERE ..."); // SQLは長いので省略。 this.ExecuteNonQuery(strSql.ToString());
フォームのメソッドに書かれていたのですが、フォーム自体がユーザコントロールになっていて、データベース接続の機能をすでに実装しているんですね。
なので、それにSQLを投げれば完了というすごくシンプルなものなのですが。
StringBuilderにする必要あったのかなって。
StringBuilderを必要とする理由
StringBuilder
は、 string
型が演算で毎回新しいインスタンスを生成したり破棄したりするのに対して、最初のインスタンスを使いまわすために使う、というものだと思います。
具体的には次のコード。
string s = ""; s = s + "a"; s = s + "b"; s = s + "c";
上記のコードでは、最終的に変数 s
に入るのは "abc"
という文字列ですが、その文字列を得るまでにインスタンスを生成する回数は7回です。
それに対し、
StringBuilder s = new StringBuilder(); s.Append("a"); s.Append("b"); s.Append("c");
上記のコードでも同様に、最終的に変数 s
に入るのは "abc"
という文字列ですが、インスタンスの生成は4回で済みます。
インスタンスの生成、という観点からみると、 string
型より StringBuilder
型のほうが同じインスタンスを使いまわすためオーバーヘッドが少ないと考えられます。
しかし。
最初のコードって、はじめに StringBuilder
型の変数を確保しているので、毎回インスタンスを生成していることになるんですよね。
これではstring型を直で触るのと変わらないのでは…。
ということで調べてみました。
検証コード
次の検証コードを用意しました。
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace ConsoleApplication1 { class Program { /// <summary> 出力用ダミー変数 </summary> static string d_out; /// <summary> /// メインエントリポイント /// </summary> static void Main(string[] args) { try { int count1; int count2; List<double> measuredList = new List<double>(); //------------------------------// // 入力 //------------------------------// do Console.WriteLine("1回の計測で確保を行う回数を指定してください..."); while (!int.TryParse(Console.ReadLine(), out count1)); do Console.WriteLine("計測を行う回数を指定してください..."); while (!int.TryParse(Console.ReadLine(), out count2)); //------------------------------// // 計測 string //------------------------------// measuredList.Clear(); Console.WriteLine("-------------------------------------------------------"); Console.WriteLine("stringクラスで計測します。"); GC.Collect(GC.MaxGeneration); for (int i = 0; i < count2; i++) { measuredList.Add(MeasureTime_String(count1)); Console.WriteLine((i + 1).ToString().PadLeft((int)Math.Log10(count2) + 1) + "回目 : " + measuredList[i]); } Console.WriteLine("平均 : " + measuredList.Average()); //------------------------------// // 計測 StringBuilder //------------------------------// measuredList.Clear(); Console.WriteLine("-------------------------------------------------------"); Console.WriteLine("StringBuilderクラスで計測します。"); GC.Collect(GC.MaxGeneration); for (int i = 0; i < count2; i++) { measuredList.Add(MeasureTime_StringBuilder(count1)); Console.WriteLine((i + 1).ToString().PadLeft((int)Math.Log10(count2) + 1) + "回目 : " + measuredList[i]); } Console.WriteLine("平均 : " + measuredList.Average()); //------------------------------// // 計測 StringBuilder + Append //------------------------------// measuredList.Clear(); Console.WriteLine("-------------------------------------------------------"); Console.WriteLine("StringBuilderクラス + Appendメソッドで計測します。"); GC.Collect(GC.MaxGeneration); for (int i = 0; i < count2; i++) { measuredList.Add(MeasureTime_StringBuilderAppend(count1)); Console.WriteLine((i + 1).ToString().PadLeft((int)Math.Log10(count2) + 1) + "回目 : " + measuredList[i]); } Console.WriteLine("平均 : " + measuredList.Average()); } finally { Console.WriteLine("何かキーを押してください..."); Console.ReadKey(); } } /// <summary> /// stringクラスを指定された回数確保し、その時間を計測します。 /// </summary> /// <param name="count">回数</param> /// <returns>時間(秒)</returns> static double MeasureTime_String(int count) { DateTime strT = DateTime.Now; for (int i = 0; i < count; i++) { string s = ""; s = s + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; Program.d_out = s.ToString(); } DateTime endT = DateTime.Now; return (endT - strT).TotalSeconds; } /// <summary> /// StringBuilderクラスを指定された回数確保し、その時間を計測します。 /// </summary> /// <param name="count">回数</param> /// <returns>時間(秒)</returns> static double MeasureTime_StringBuilder(int count) { DateTime strT = DateTime.Now; for (int i = 0; i < count; i++) { StringBuilder s = new StringBuilder("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"); Program.d_out = s.ToString(); } DateTime endT = DateTime.Now; return (endT - strT).TotalSeconds; } /// <summary> /// StringBuilderクラスを指定された回数確保し、Appendメソッドで文字列を指定します。その時間を計測します。 /// </summary> /// <param name="count">回数</param> /// <returns>時間(秒)</returns> static double MeasureTime_StringBuilderAppend(int count) { DateTime strT = DateTime.Now; for (int i = 0; i < count; i++) { StringBuilder s = new StringBuilder(); s.Append("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"); Program.d_out = s.ToString(); } DateTime endT = DateTime.Now; return (endT - strT).TotalSeconds; } } }
汚いとか言わない。
実際にやってみた。
実際に動かしました。スペックは以下の通り。
- CPU: Atom Z3775 (1.46GHz 4core)
- メモリ: 2GB
やっぱり、string型のほうがオーバーヘッドは少ないみたいですね。
追記 2017/02/26 実験に使ったスペックを記述していなかったので追記。
内臓HDDのUbuntuをUSBメモリのGrub2から叩く。
非常に初歩的なことですが、かなり調べて納得がいったようないってないようなところまで行きついたので、メモっておきます。
今回は 起動できればいい というスタンスで行くので、詳しいことは全くわかりません。これから勉強します。
環境
今回試したのは、以下の環境。
- UEFI (lenovo BIOS)
- SATA : 480GB SSD (MBR)
- SATA : 1TB HDDドライブ (GPT、2番目のパテーションにUbuntuインストール済み)
- USB : Ubuntu日本語Remix インストールDVDイメージ(8GB)
目標は簡単なことです。 USBのGrub2 から SATAのHDDのUbuntuを起動する だけです。普通はしないけど。
なんでこんなまどろっこしいことがしたいかというと、Ubuntuを内臓HDDにインストールしたまではよかったんですけど、UEFIが入っているはずのGrub2を認識してくれないのです。もしかしてUEFIモード対応のBIOSだったのかこれ?
順序
あくまで理論的な話。
Grub2が起動すると、規定ではOSを選ぶメニューが表示されます。
このとき、メニューで「c」キーを押すとシェル画面に遷移します。この画面ではbashライクな初歩的なコマンドにより、ドライブの中身を確認したりOSをブートさせたりできます。
この機能を使って、実際にUSBのGrub2のメニューには出てこない、内臓HDDのUbuntuを起動させます。
手順
まずは確認
まずは、ディスクの名前を確認します。これは ls
コマンドで一覧を取得できます。
grub> ls (memdisk) (hd0) (hd0,msdos1) (hd1) (hd1,gpt4) (hd1,gpt3) (hd1,gpt2) (hd1,gpt1) (hd2) (hd2,msdos1)
現在起動に使っているUSBは (hd0)
として認識されています。試しに、 (hd0)
の情報を表示してみます。
なんか、大量にエラーが出ていますが、今回は 起動ができればいい ので無視します。一応、最後の一文がそれっぽい結果になっています。
grub> ls (hd0) Device hd0: No known filesystem detected - Sector size 512B - Total size 7834624KiB
確かに容量が8GBに近いですので、起動USBと考えて間違いないでしょう。
次に、起動するUbuntuの入ったHDDを確認します。
たぶん見るからに (hd1)
っぽいです。なぜかというと、grub上(というかLinuxでは全般に)では、 MBRパテーションテーブルは msdos
として表示 されますし、 GPTパテーションテーブルは gpt
として表示 されるからです。起動したいのはGPTのHDDです。
grub> ls (hd1) Device hd1: No known filesystem detected - Sector size 512B - Total size 976762584KiB
案の定ですね。これが1TBのHDDです。
では、今度はUbuntuを起動させるため、Linuxカーネルの配置を確認します。パテーションの1つめはESPなので、2つめのファイル一覧を取得します。
grub> ls (hd1,gpt2)/ lost+found/ boot/ hdata/ etc/ media/ var/ bin/ dev/ home/ lib/ lib64/ mnt/ opt/ proc/ root/ rules.d/ run/ sbin/ snap/ srv/ sys/ tmp/ usr/ vmlinuz initrd.img cdrom/ initrd.img.old vmlinuz.old
Ubuntuのルートですね。さて、これでUbuntuを起動させられるだけの情報は整いました。
え?カーネルのフルネームを確認できていないって?そうそう、なんだか知りませんが、 システムのルートにシンボリックリンクが張ってあるのでそれを使えば問題ない そうです。
起動する
では、実際に起動します。次のコマンドを打って、終わり。
grub> set root=(hd1,gpt2) grub> linuxefi /vmlinuz root=/dev/sda2 grub> initrdefi /initrd.img grub> boot
linuxefi
コマンドで、起動するLinuxカーネルを指定します。今回はシステムルートにある vmlinuz
というシンボリックリンクを使って指定しています。これがない場合は、 boot/
以下にある本体をフルネームで指定します。
同時にシステムのルートを指定します。 root=/dev/sda2
の部分です。多くのサイトの解説では、今回のような (hd1,gpt2)
をルートとしたい場合、 /dev/sdb2
とするのが一般的だそうです。たしかに (hd0)
があるので、それを sda
とすると、 (hd1)
は sdb
になります。理屈はわかります。
しかし、 それでは起動しませんでした 。憶測ですが、もしかしたら起動時にカーネルからデバイスの再スキャンが行われて、カーネルが認識した順番がGrubの認識した順番と違う、という現象が起こっているのかもしれません。全く分からない。
次に initrd
コマンドですが、どうもモジュール等を含むデータをメモリに読み込むためのコマンドのようです。よくわかりませんが、これも initrd.img
というシステムルートにあるシンボリックリンクを使います。ない場合は、 boot/
以下にある本体をフルネームで指定します。
最後に boot
コマンドでブートさせます。
ちなみに、UEFI環境でない場合は、efi
がついていないコマンドでブートします。UEFIでも使えるので、実質どちらでもいいのでは…?
grub> set root=(hd1,gpt2) grub> linux /vmlinuz root=/dev/sda2 grub> initrd /initrd.img grub> boot
最後に
無事に起動できました。grubのシェルを直で触るなんて初めてで、いろいろ試行錯誤して、起動できたときは本当に感動しました。大歓喜でした。
今回は本当に 起動できればいい というスタンスなので、あまり深入りはしませんが、これで調べるべき対象が一つ増えた気がします。
やっぱりLinuxは楽しい(まだ何もわかっていないのに)。