みなさんどうも。
ふと、プログラムを書いているとマシンに寄り添いたくなる時ってありますよね?
僕はあります。少しでもマシンと心を寄り添いあって開発してみたいと思います。
例えばC言語で以下のようなコード書いた時とします
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
このコードがどのようにマシンに解釈されてどのような姿に変わっていくのか興味湧きますよね?
C言語で書いたプログラムが、マシンにとってどんな「実行ファイル」になるのか。
その変化を readelf というツールを使ってバイナリの視点から観察してみます。
使用環境・ツール
-
OS: Ubuntu (Dockerコンテナ内)
-
コンパイル: gcc
-
ELF解析: readelf
-
補助: hexdump,xxd,objdump など
環境構築
今回はLinuxで多く使われているフォーマットのELF(Executable and Linkable Format)の勉強も兼ねているのでDockerを用いてやっていこうと思います。
とりあえず。
- VScode
- Docker
この辺の環境が最低限整っているとして環境構築していきます。
まず適当なプロジェクトフォルダを作成してください。
/ Enjoy_ELF(任意で)
VScodeでコンテナに直接接続できるようにjsonで色々書いていきます。
プロジェクトルートに.devcontainer
フォルダを作成してください。
その下の階層にdevcontainer.json
を書き
/.devcontainer/devcontainer.json
{
"name": "ELF Hack Dev",
"build": {
"dockerfile": "Dockerfile"
},
"settings": {},
"extensions": []
}
nameは任意でいいです。
ビルド時にLinux環境を明示的にしたいので以下のDockerファイルを書くようにしてください
/.devcontainer/Dockerfile
FROM --platform=linux/amd64 ubuntu
RUN apt update && apt install -y build-essential nasm xxd binutils bsdmainutils
あとは
Macなら command + shift + p
Winなら Ctr + shift + p(だったはず)
でコマンドパレットを出して
「rebuild」って打てば一番上に出てくるこれを選択して接続してください
画面左下にこんなのが出てたら成功です。
ターミナルを開いてやっていきましょう〜〜
C言語コンパイルとバイナリを読んでみる
まず最初に提示したCのファイルを作っていきましょう
再掲
/hello.c
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
シンプルに「Hello World!」を表示するコードですよね
実際に見てみましょう
コンパイルしてみる
% gcc hello.c -o hello
プロジェクトルートにあるhelloというバイナリファイルが生成されます。
このバイナリファイルは実行というよりマシンが正しく読み取り実行する構造(フォーマット)を持っています
このことで特にLinuxの実行形式は ELF(Executable and Linkable Format) と呼ばれています。
それを実行してやると
% ./hello
Hello, World!
当たり前ですが、「Hello, World!」と表示されますね。
一旦、このバイナリの中身を確認してみましょうか。
% xxd -g 1 hello | head -n 10
00000000: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 .ELF............
00000010: 03 00 3e 00 01 00 00 00 60 10 00 00 00 00 00 00 ..>.....`.......
00000020: 40 00 00 00 00 00 00 00 98 36 00 00 00 00 00 00 @........6......
00000030: 00 00 00 00 40 00 38 00 0d 00 40 00 1f 00 1e 00 ....@.8...@.....
00000040: 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 ........@.......
00000050: 40 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 @.......@.......
00000060: d8 02 00 00 00 00 00 00 d8 02 00 00 00 00 00 00 ................
00000070: 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 ................
00000080: 18 03 00 00 00 00 00 00 18 03 00 00 00 00 00 00 ................
00000090: 18 03 00 00 00 00 00 00 1c 00 00 00 00 00 00 00 ................
ちなみにVScodeの拡張機能で
「Hex editor」を入れるとVScode上から直接見えます。
00000000(オフセット): 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00(バイナリ) .ELF............(ASCII表現)
以上なようなもので構成されています。
※ オフセットとは
ファイルの0バイト目からどれだけ離れているかを指します。
つまり2行目は0x10なので16バイト分離れており その行の先頭は17バイト目ということがわかります。
※ 今回は詳しく説明しませんが、バイナリファイルには各種情報が格納されるバイト区間があり、そこに必要な値が書き込まれています。
そして、その区間に値がない場合でも、アライメント(整列)制約を守るためにパディング(空白バイト)が入っていることがあります。
興味がある方は「ELF アライメント制約」などで調べてみてください
ここから読み取れるのは「ELF」という文字列が見えますよね。
そうです。これがELFヘッダーと言われるものです。
このヘッダーの存在によって、OSはこのファイルがどんなフォーマットで書かれているかを判断できるようになっています。
つまり、
「このファイルはELF形式です。読み方はこうですよ(=バイナリの読み方の手順書)」
という“読み取り説明書”が最初に書いてあるイメージです。
エントリーポイント
先ほどまでのhelloを片隅まで理解していると日が暮れるので、今回の本題の「このバイナリのどこから実行を始めたらいいのか?」を書いている部分を読んでいこうと思います。
ELFヘッダーのうち、エントリーポイントのアドレスは 0x18(つまり24〜31バイト目) に記録されています。
(あとで readelf を使えば簡単に確認できますが、ここでは手動で確認してみます)
では、実際に該当箇所を確認してみましょう。
% xxd -g1 hello | grep '^00000010:' | cut -d' ' -f10-17
60 10 00 00 00 00 00 00
リトルエンディアン形式で格納されているため、上記のバイト列は
0x0000000000001060
このような解釈になります。
ここがコードのエントリーポイント、つまりOSが最初に実行を始めるアドレスです。
※ リトルエンディアン
詳しくは解説しませんが、**メモリ上に格納するときに「下位バイトから順に置いていく」というイメージです。
わかりやすく説明されている以下のサイトをご参考にしていただけると幸いです(いつもお世話になっています)
使用しているアーキテクチャ(x86_64)は、OSによらず基本的にリトルエンディアンのようです。
私自身まだそのあたりを理解できているわけではないのですが、
ハードウェア設計や歴史的な理由があるとのことで、いつかちゃんと勉強してまとめたいなぁと思います。
readlefで構造を見てみよう。
% readelf -l hello
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000628 0x0000000000000628 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000175 0x0000000000000175 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000000f4 0x00000000000000f4 R 0x1000
LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000258 0x0000000000000260 RW 0x1000
DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
0x00000000000001f0 0x00000000000001f0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
NOTE 0x0000000000000368 0x0000000000000368 0x0000000000000368
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
GNU_EH_FRAME 0x0000000000002014 0x0000000000002014 0x0000000000002014
0x0000000000000034 0x0000000000000034 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000248 0x0000000000000248 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
まず確認できるポイントとしては
Entry point 0x1060
となっており先ほど見たバイナリのアドレスと一致することが確認できます。
見方
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
ここから下に表示されているのは、**ELFのプログラムヘッダ(Program Header Table)の一覧です。
簡単に言えばどの動作をどこのメモリ領域で展開すべきかを書いている設計書みたいなものです。
- Type: セグメントの種類(属性)
- Offset: ファイル内でのバイトの位置。セグメントの開始位置がある場所を示してます。
- VirtAddr: 仮想メモリ上にマッピングされるアドレス
- PhysAddr: 物理メモリアドレス
- FileSiz: セグメントのファイルサイズ(バイナリのバイト数)
- MemSiz: セグメントのメモリ上に配置される際のサイズ
- Flags: セグメントの属性。 R(読み取り可能),W(書き込み可能),E(実行可能)
- Align:アライメント制約。セグメントの整列単位
※ 基本現代では仮想アドレスを利用するらしいです。OSとかCPUがマッピングテーブルなるものを参照して仮想アドレスから物理アドレスに変換してアクセス参照しするという手順を踏んでるらしいです。
PhysAddrは無視されることがほとんどらしいです。
以下の記事よりそのことが明記されてたりします。ご興味ある方はどうぞ
メモリと待機時間に関する考慮事項を管理する(マイクロソフト)
VirtualAddress, LoadAddress, and PhysicalAddress in ELF file?(stackoverflow)
PHDR
まずPHDRセグメントの説明をしていきます。
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
PHDR(Program Header Table) →プログラムのヘッダー自体をメモリ上に展開するためのセグメントになってます。
他セグメント(DYNAMICセグメント:後ほど説明)などがこのヘッダーを参照する場合もあるので非常に大事です。
もう少しアドレスを噛み砕いて説明すると、
セグメントの種類はPHDRであり、ファイルの先頭から0x40番目にある(Offset)。
仮想アドレス、物理アドレスともに0x40に配置するように指示されている。
(VirtAddr,PhyAddr)。セグメントのファイル上の容量は0x2d8(FileSiz)であり、 メモリに展開される容量も0x2d8(MemSiz)である。読み取り可能属性(R) であり(Flags)、アライメントは0x8(Align)
INTERP
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
INTERP → 動的リンカ(外部ライブラリへの依存を、実行時に結びつけてくれるもの)を指定するパスが格納されたセグメントです。
ELFファイル内に文字列としてそのパスが保存されていて、OSはこの情報をもとに先にリンカを起動します。
ちなみにですが、今回の場合にはOSは「 /lib64/ld-linux-x86-64.so.2」という動的リンカを利用するように、INTERPセグメントで指定 されています。
後の説明はPHDRセグメントとほぼ同じです。メモリのアドレスやファイル容量などが違うだけです(セグメントごとの容量のため)。
ここまでコンパイルするための下準備の手順書でした。
これから本格的にC言語が本質的に何を求めているのか実際に見ていきましょう。
LOAD
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000628 0x0000000000000628 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000175 0x0000000000000175 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000 0x00000000000000f4 0x00000000000000f4 R 0x1000
LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000258 0x0000000000000260 RW 0x1000
LOAD → 「C言語がコンパイルされたバイナリの中身を、実行時にどの仮想メモリ領域にどう展開し、どう保護するか」をOSに伝える設計情報です。
ふと「一気にC言語でコンパイルされたバイナリならば一気に全部メモリに展開してよくないか?」
と疑問に思いますよね。
しかし実際は、「どの権限で展開するのか(実行可能か?書き込みOK?読み取りのみ?)」も含めて指定しているらしく、その場合権限によってメモリへのマッピング方法(配置、保護属性)が変わるためセグメントに分けて定義する必要があるみたいです。
以下にセクション一覧を提示しています。
セクション | 属性 | 内容 |
---|---|---|
.text | R,E | 実行命令(CPUで動くコード) |
.data | R,W | 書き換え可能な変数など |
.rodata | R | 定数(文字列リテラルなど |
アクセス属性が異なると、メモリ上の展開方法(保護や配置)も異なり、必要なアライメント(4KB単位など)も変わってきます。 | ||
そのため、異なるアクセス属性を持つセクションは、別々の LOAD セグメントにまとめられることが多いのです。 |
以下のリンクを参照させていただきました。
セクションとセグメントの対応
コラム:LOADセクションの命令を覗き見る
では、該当場所にどのような命令が入っているのか実際に確認してみましょう。
0x1000バイト目から0x175バイト分(2行目のLOADセクション).text命令が入っている実際のバイナリです。
% hexdump -Cv -s 0x1000 -n 0x175 hello
00001000 f3 0f 1e fa 48 83 ec 08 48 8b 05 d9 2f 00 00 48 |....H...H.../..H|
00001010 85 c0 74 02 ff d0 48 83 c4 08 c3 00 00 00 00 00 |..t...H.........|
00001020 ff 35 9a 2f 00 00 ff 25 9c 2f 00 00 0f 1f 40 00 |.5./...%./....@.|
00001030 f3 0f 1e fa 68 00 00 00 00 e9 e2 ff ff ff 66 90 |....h.........f.|
00001040 f3 0f 1e fa ff 25 ae 2f 00 00 66 0f 1f 44 00 00 |.....%./..f..D..|
00001050 f3 0f 1e fa ff 25 76 2f 00 00 66 0f 1f 44 00 00 |.....%v/..f..D..|
00001060 f3 0f 1e fa 31 ed 49 89 d1 5e 48 89 e2 48 83 e4 |....1.I..^H..H..|
00001070 f0 50 54 45 31 c0 31 c9 48 8d 3d ca 00 00 00 ff |.PTE1.1.H.=.....|
00001080 15 53 2f 00 00 f4 66 2e 0f 1f 84 00 00 00 00 00 |.S/...f.........|
00001090 48 8d 3d 79 2f 00 00 48 8d 05 72 2f 00 00 48 39 |H.=y/..H..r/..H9|
000010a0 f8 74 15 48 8b 05 36 2f 00 00 48 85 c0 74 09 ff |.t.H..6/..H..t..|
000010b0 e0 0f 1f 80 00 00 00 00 c3 0f 1f 80 00 00 00 00 |................|
000010c0 48 8d 3d 49 2f 00 00 48 8d 35 42 2f 00 00 48 29 |H.=I/..H.5B/..H)|
000010d0 fe 48 89 f0 48 c1 ee 3f 48 c1 f8 03 48 01 c6 48 |.H..H..?H...H..H|
000010e0 d1 fe 74 14 48 8b 05 05 2f 00 00 48 85 c0 74 08 |..t.H.../..H..t.|
000010f0 ff e0 66 0f 1f 44 00 00 c3 0f 1f 80 00 00 00 00 |..f..D..........|
00001100 f3 0f 1e fa 80 3d 05 2f 00 00 00 75 2b 55 48 83 |.....=./...u+UH.|
00001110 3d e2 2e 00 00 00 48 89 e5 74 0c 48 8b 3d e6 2e |=.....H..t.H.=..|
00001120 00 00 e8 19 ff ff ff e8 64 ff ff ff c6 05 dd 2e |........d.......|
00001130 00 00 01 5d c3 0f 1f 00 c3 0f 1f 80 00 00 00 00 |...]............|
00001140 f3 0f 1e fa e9 77 ff ff ff f3 0f 1e fa 55 48 89 |.....w.......UH.|
00001150 e5 48 8d 05 ac 0e 00 00 48 89 c7 e8 f0 fe ff ff |.H......H.......|
00001160 b8 00 00 00 00 5d c3 00 f3 0f 1e fa 48 83 ec 08 |.....]......H...|
00001170 48 83 c4 08 c3 |H....|
00001175
これが実際のバイナリです。
今回はメモリの展開先、そこに何の命令があるのかを軽く見ていきましょう。
ちなみに0x1060まではエントリーポイントではないので、ELFフォーマットなどのメタ情報です。
長すぎて抜粋しながらでしますが以下のコマンド打つと0x1060から0x175バイト分の命令が全て展開されます
0x100まではエントリーポイントではないので、ヘッダー情報です。
%objdump -D -M intel -j .text --start-address=0x1060 --stop-address=0x11d5 hello
エントリーポイントの再確認
ここの章で確認したリトルエンディアンで格納されている番地がエントリーポイントになっていることが確認できます。
1060: f3 0f 1e fa endbr64
ここから最適化の処理やさまざまな呼び出しなどを踏んで
実際にC言語の処理がアセンブリ化されたものです。
今回
printf("Hello,World!\n");
なので最適化されて
puts("Hello,World!);
となり
printf(“Hello,World!\n”); は、最適化により puts(“Hello,World!”) に置き換えられることがあります
実際に、
1151: 48 8d 05 ac 0e 00 00 lea rax,[rip+0xeac]
1158: 48 89 c7 mov rdi,rax
115b: e8 f0 fe ff ff call 1050 <puts@plt>
この位置が該当場所となり
1行目で lea 命令により、文字列リテラルのアドレスを rax にロードし、
2行目で puts の第1引数(rdi)にそのアドレスを渡し、
3行目で puts を呼び出しています。
実態はここで呼ばれているわけではなく
<puts@plt>
PLTは実行時リンクを行うためのジャンプテーブルで、puts関数のアドレスは実行時に動的にアドレスを特定されそこへ制御が移ります。
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push rbp
114e: 48 89 e5 mov rbp,rsp
1151: 48 8d 05 ac 0e 00 00 lea rax,[rip+0xeac] # 2004 <_IO_stdin_used+0x4>
1158: 48 89 c7 mov rdi,rax
115b: e8 f0 fe ff ff call 1050 <puts@plt>
1160: b8 00 00 00 00 mov eax,0x0
1165: 5d pop rbp
1166: c3 ret
実際にobjdump
をしてみると色々と見えますので、他にも読んでいくと面白いと思います。
今回は内容から外れすぎるので、ここまでとします。
DYNAMIC
DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
0x00000000000001f0 0x00000000000001f0 RW 0x8
このセグメントでは、動的リンクに必要な情報(ライブラリの読み込み、シンボルの解決)を記録している領域です。
ELFファイルが「実行時にどのライブラリ(.so)を使うか」や「関数のアドレスをどのようにして解決するか」などこの中のテーブル(.dynamicセクション)に詰め込んであります。
実際に見てみると
% readelf --dynamic hello
Dynamic section at offset 0x2dc8 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1168
0x0000000000000019 (INIT_ARRAY) 0x3db8
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3dc0
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3b0
0x0000000000000005 (STRTAB) 0x480
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000000a (STRSZ) 141 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb8
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x610
0x0000000000000007 (RELA) 0x550
0x0000000000000008 (RELASZ) 192 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x000000006ffffffe (VERNEED) 0x520
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x50e
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0
つまり0x1f0(304バイト)分の内容が上記の情報ってことですね。
NOTE,GNU_PROPERTY
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
NOTE 0x0000000000000368 0x0000000000000368 0x0000000000000368
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R b0x8
このセグメントは補助的な情報が入っています。
詳しい中身は以下の通りで見れます。
% readelf --note hello
Displaying notes found in: .note.gnu.property
Owner Data size Description
GNU 0x00000020 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature: IBT, SHSTK
x86 ISA needed: x86-64-baseline
Displaying notes found in: .note.gnu.build-id
Owner Data size Description
GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 832594bbec3cdd9992fe40755f43ad6e4d7c11b8
Displaying notes found in: .note.ABI-tag
Owner Data size Description
GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag)
OS: Linux, ABI: 3.2.0
GNU_EH_FRAME
GNU_EH_FRAME 0x0000000000002014 0x0000000000002014 0x0000000000002014
0x0000000000000034 0x0000000000000034 R 0x4
例外を処理するための関数情報や、スタックを巻き戻す処理に使われる情報(DWARF形式)が入っています。
GNU_STACK
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
このセグメントでは、プログラム実行時に使用されるスタック領域に対するメモリ保護属性(読み取り/書き込み/実行可能か)を指定しています。
上の例では RW(読み書き可能)となっており、実行権限(E)が付いていないため、スタック上のデータは実行できません。
これは、セキュリティ対策として スタック実行禁止(non-executable stack) を有効にしている状態を意味します。
GNU_RELRO
GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000248 0x0000000000000248 R 0x1
このセグメントは、プログラム実行時に一時的に書き込み可能(RW)としてロードされるが、その後ただちに読み取り専用(R)に変更されるメモリ領域を定義しています。
いわば「Read-Only After Relocation(RELRO)」の略で、再配置(relocation)処理が終わった後は変更されないように保護するのが目的です。
最後に
ここまでのご閲覧ありがとうございました。
ELFの構造を知って色々と機械のお気持ちになることはすごく大事なとこだと感じました。
なかなかに書きごたえのある記事で世界は広いしもっと勉強することがあるんだなとより一層感じさせられました。
少しでも本記事が参考や楽しんでいただけていたら幸いです。