はじめに
この記事は自作OS Advent Calendar 2024 19日目の記事です。
DOOMチャレンジ達成当日にXに投稿したところ、たくさんの反響をいただきました。
id Softwareが1993年にリリースしたFPSゲーム「DOOM」を、様々な電子機器やターゲットに移植する文化のことをDOOMチャレンジといいます。
直近では任天堂が新発売した目覚まし時計「Alarmo」への移植が行われました(笑)。
DOOMチャレンジについてはGIGAZINEでよく記事にされるので、興味のある人は眺めてみると面白いと思います。
gigazine.net
gigazine.net
gigazine.net
DOOMチャレンジ on 自作OS
タイトルの通り、私の自作OS上でDOOMチャレンジ(移植)をしようという試みです。DOOMは公式によってソースコードが公開されており、先述のように様々なハードウェア・OS向けに派生が作られ続けています。私の自作OSではELFバイナリ(Linux等で用いられる実行ファイル形式)の実行をサポートしており、Linux版DOOMがそのまま動作するというわけではありませんが、少なくとも謎ハードウェアへ移植するよりかはソースコードの改造が遥かに簡単に済みます。
また、私の自作OSはグラフィック関連の実装が最小限であり、絵面が地味になりがちであるため、そろそろ誰が見ても面白いと思えるようなインパクトが欲しいという思いがあり、DOOMチャレンジを行いましたが、それ以外にも、半年ほど前にXのフォロワーさんが書かれた記事を読ませていただいたことが今回のきっかけとなりました。
謎ハードウェアへ移植する話はよく耳にしますが、自作OSへの移植も文化としてあるのかどうかはよくわかりません。
自作OS
github.com
Rustで開発しているx86_64向けOSです。2年ほど開発を続けており、書いては消してを繰り返し、実装途中で放置しているドライバなどもあったりしていて、デジタル盆栽というよりかは最近は闇鍋になりつつあります。今回の実装で重要な部分を抜き出すと、現状の自作OSは以下のような仕様になっています。
- プロセススケジューリングは未実装で、割り込みやシステムコール、ユーザーアプリの呼び出し以外でタスクの切り替えは行われない
- 仮想ファイルシステム、オンメモリなFAT32に対応、ただし書き込み不可
- PS2キーボード/マウス対応
- Local APICタイマーによる時間計測
- 簡単なウィンドウマネージャ(ウィンドウの生成、破棄、マウスポインターの操作とウィンドウのドラッグが可能)
- オリジナルなシステムコール(ユーザーアプリがOSの機能を呼び出す命令)と、libc(標準Cライブラリ)の再実装
- ユーザーアプリはCまたはRust(自作libcのRust binding)で書き、自作libcとスタティックリンクされたELFバイナリをOSでロードすることができる
全体的な構成図
doomgeneric
github.com
今回利用するDOOMのソースコードになります。doomgenericと呼ばれる派生実装を基にしています。doomgenericは移植に特化したソースコードであり、5つの関数を移植したいプラットフォームに合わせて実装するだけで、ゲームを動かすことができます。
「DOOMのソースコード」という呼び方には少し語弊があり、正確にはDOOMを動かすゲームエンジンのソースコードです。実行するためにはDOOMのwadファイル(ゲームデータ)が必要で、デモ版のDOOMがシェアウェアとして無料で公開されています。
移植編1 - 自作OS向けにMakefileを書く
doomgenericはWindows、Linux、FreeBSDなどいくつかのプラットフォーム向けにコンパイルできるように設計されています。移植は非常にシンプルで、各プラットフォームごとのMakefileと、プラットフォーム固有なコードが書かれたdoomgeneric_<YOUR_PLATFORM>.cを用意するだけです。
そのままmakeコマンドでコンパイルすると、X11 on Linux向けのELFバイナリが吐き出されます。-iwadとWADファイルのパスを指定すると、ゲームが起動します。
$ make
$ ./doomgeneric -iwad <PATH_TO_DOOM_WAD_FILE>.wad

doomgenericではサウンド出力が実装されていないため、音声は再生されません。再生するためには自力で実装する必要がありますが、今回は行いませんでした。
既存のMakefileを参考に、自作OS向けMakefileを書きました。以下が完成形になります。
myos-x86_64/third-party/doom-for-myos/Makefile.myos
ifeq ($(V),1)
VB=''
else
VB=@
endif
LIBCDIR := ../../apps/libc
SFDIR := ./berkley-softfloat-3
LIB := -L$(LIBCDIR) -lc -L$(SFDIR)/build/Linux-x86_64-GCC $(SFDIR)/build/Linux-x86_64-GCC/softfloat.a
CC=gcc
LD=ld.lld
CFLAGS=-I $(LIBCDIR) -I $(SFDIR)/source/include -O2 -Wall -g -m64 -nostdlib -fno-builtin -mno-mmx -mno-sse -msoft-float -std=c11 -DARCH_MYOS
LDFLAGS=-z norelro --static --image-base=0x10000000
OBJDIR=build
OUTPUT=doomgeneric
SRC_DOOM = dummy.o am_map.o doomdef.o doomstat.o dstrings.o d_event.o d_items.o d_iwad.o d_loop.o d_main.o d_mode.o d_net.o f_finale.o f_wipe.o g_game.o hu_lib.o hu_stuff.o info.o i_cdmus.o i_endoom.o i_joystick.o i_scale.o i_sound.o i_system.o i_timer.o memio.o m_argv.o m_bbox.o m_cheat.o m_config.o m_controls.o m_fixed.o m_menu.o m_misc.o m_random.o p_ceilng.o p_doors.o p_enemy.o p_floor.o p_inter.o p_lights.o p_map.o p_maputl.o p_mobj.o p_plats.o p_pspr.o p_saveg.o p_setup.o p_sight.o p_spec.o p_switch.o p_telept.o p_tick.o p_user.o r_bsp.o r_data.o r_draw.o r_main.o r_plane.o r_segs.o r_sky.o r_things.o sha1.o sounds.o statdump.o st_lib.o st_stuff.o s_sound.o tables.o v_video.o wi_stuff.o w_checksum.o w_file.o w_main.o w_wad.o z_zone.o w_file_stdc.o i_input.o i_video.o doomgeneric.o doomgeneric_myos.o
OBJS += $(addprefix $(OBJDIR)/, $(SRC_DOOM))
all: $(OUTPUT)
clean:
rm -rf $(OBJDIR)
rm -f $(OUTPUT)
rm -f $(OUTPUT).gdb
rm -f $(OUTPUT).map
$(OUTPUT): $(OBJS)
@echo [Linking $@]
make -C $(LIBCDIR)
make -C $(SFDIR)/build/Linux-x86_64-GCC
$(VB)$(LD) $(OBJS) $(LIB) -o $@ $(LDFLAGS) --Map=$(OUTPUT).map
@echo [Size]
-$(CROSS_COMPILE)size $(OUTPUT)
$(OBJS): | $(OBJDIR)
$(OBJDIR):
mkdir -p $(OBJDIR)
$(OBJDIR)/%.o: %.c
@echo [Compiling $<]
$(VB)$(CC) $(CFLAGS) -c $< -o $@
print:
@echo OBJS: $(OBJS)
既存のlibcの代わりに自作libcをリンクしたいため、-nostdlib/-fno-builtinフラグで、自動的にlibc関数が呼ばれてしまうことを抑制し、標準ライブラリに依存しない形にしています。また-nostdlibフラグを設定すると、main関数を呼び出すスタートアップコードがリンクされなくなるため、エントリポイントにmain関数を使用することはできず、代わりに_start関数から始める必要があります。私の自作libcでは、libc側に_start関数を実装し、main関数を呼び出すスタートアップコードを独自に定義しており、この問題を回避しています。
移植編2 - 不足している自作libc関数の追加
プラットフォーム固有コードであるdoomgeneric_myos.cを以下のように仮実装しました。
myos-x86_64/third-party/doom-for-myos/doomgeneric_myos.c
#include "doomgeneric.h"
#include "doomkeys.h"
#include <stdio.h>
void DG_Init()
{
}
void DG_DrawFrame()
{
}
void DG_SleepMs(uint32_t ms)
{
}
uint32_t DG_GetTicksMs()
{
}
int DG_GetKey(int *pressed, unsigned char *doomKey)
{
}
void DG_SetWindowTitle(const char *title)
{
}
int main(int argc, char **argv)
{
doomgeneric_Create(argc, argv);
while (1)
doomgeneric_Tick();
return 0;
}
5つの関数DG_Init DG_DrawFrame DG_SleepMs DG_GetTicksMs DG_GetKeyに自作OS向けの実装を行っていきます。それ以外のコードは全プラットフォーム共通の処理であるため、書き換える必要はありません。
自作libcにdoomgenericで呼ばれている関数を確認して仮実装します。これをコンパイルエラーがなくなるまで繰り返します。以下のような関数が実装の対象となる代表的な例です。
int toupper(int c)
int tolower(int c)
int snprintf(char *buf, size_t size, const char *format, ...)
int vsnprintf(char *buf, size_t bufsize, const char *format, va_list arg)
FILE *fopen(const char *filename, const char *mode)
int fclose(FILE *stream)
size_t fread(void *buf, size_t size, size_t count, FILE *stream)
void *malloc(size_t len)
void free(void *ptr)
以下のようにデバッグプリント追加しておくと、今後のデバッグが楽になります。
int vfprintf(FILE *stream, const char *fmt, va_list ap)
{
printf("[DEBUG]vfprintf called\n");
return -1;
}
int sscanf(const char *buf, const char *fmt, ...)
{
printf("[DEBUG]sscanf called\n");
return -1;
}
移植編3 - 自作OS上でのデバッグ
doomgenericを自作OS向けにコンパイルすることに成功したら、実際に自作OS上でバイナリを実行しながら、追加した関数の内部実装を行います。

OSが起動して初期化が完了すると、自動的にシェルアプリが起動するようになっています。execコマンドでELFバイナリを起動することができます。
libc関数の内部実装については、既存のglibcやnewlibといったコードを参考にしました。今回は車輪の再発明が目的ではないため、コピペ実装も多々あります。
出力を確認しながら、ひたすら関数の実装とデバッグを行う
自作OS上で動かすユーザアプリをGDBでデバッグすることはできない(デバッグ環境を整えることが非常に面倒)ため、先述の通り、ほとんどデバッグプリントを使って力技で解決しました。今後はこれらデバッグを容易にする仕組みづくりも行っていきたいと思っています。
移植編4 - SSE命令問題
SSE (Streaming SIMD Extensions)とは、IntelのCPUに内蔵されているSIMD拡張命令セットで、浮動小数点演算を高速化するために利用されます。x86_64向けにC言語でfloatを使用するコードをコンパイルすると、SSEかx87 FPU(浮動小数点演算処理装置)、またはsoftfloat(ソフトウェアによる浮動小数点演算エミュレーション)を使用する必要があり、そうでなければそもそもコンパイルエラーになります。
gccでコンパイルすると、floatで専用レジスタxmm0に対してpxor(SSE2命令)が使用される
x87 FPUを使用するためにはコンパイラオプションを設定する

float型を返す関数では専用レジスタxmmを利用する必要があるため、SSEを無効化するとコンパイルエラーになる(ABI違反)
doomgenericではfloat型が使用されており、これを自作OS上で動作させるためには、OS側でSSE命令またはx87 FPUを有効化する処理と、コンテキストスイッチ時に専用レジスタを退避するコードを実装する必要があります。
x87 FPUはx86時代の古い命令サブセットであり、現代のx86_64アーキテクチャではSSEとSSE2がデフォルトで利用可能であるため、まずSSE命令で動かすことを目標にしていましたが、SSE命令の実行時に発生する一般保護例外(General Protection Fault、#GP)をどうしても解決できず、2週間ほどIntel SDMと格闘した末に諦めました。
かなりの時間を費やしてしまったため、一旦気分転換でsoftfloatを検証したところ、想像よりもあっさり動かすことができました。そのため、今回はsoftfloatを採用し、x87 FPUは検証すらしていません(よくない)。
softfloatを使用する場合、コンパイラは浮動小数点演算をエミュレーションする命令を吐いてくれないので、サードパーティ製ライブラリを使用するか、自力で実装する必要があり、手順が少し複雑になります。また、ハードウェア演算と比較して動作が遅くなるという懸念もあります。
github.com
今回はberkeley-softfloat-3を使用しました。
softfloatライブラリで置き換えるためにはfloat型をfloat32_t型で置き換え、演算やキャストで専用の関数を利用する必要があります。また、X11 on Linux向けのMakefileを書き換えたくなかったため、条件付きコンパイルも利用する必要があり、置き換えは以下のようになりました。
myos-x86_64/third-party/doom-for-myos/g_game.c
#include <string.h>
#include <stdlib.h>
#include <math.h>
#ifdef ARCH_MYOS
#include <softfloat.h>
#endif
...
boolean G_CheckDemoStatus(void)
{
int endtime;
if (timingdemo)
{
#ifdef ARCH_MYOS
float32_t fps;
#else
float fps;
#endif
int realtics;
endtime = I_GetTime();
realtics = endtime - starttime;
#ifdef ARCH_MYOS
fps = f32_div(i32_to_f32(gametic * TICRATE), i32_to_f32(realtics));
#else
fps = (float)(gametic * TICRATE) / (float)realtics;
#endif
timingdemo = TRUE;
demoplayback = FALSE;
I_Error("timed %i gametics in %i realtics (%f fps)",
gametic, realtics, fps);
...
移植編5 - doomgenericにおける自作OS依存部
doomgeneric_myos.cに戻り、プラットフォーム依存である5つの関数を実装していきます。これらの関数は以下のような役割を持ちます。
| 関数 |
役割 |
| void DG_Init() |
ウィンドウの生成等、初期化処理を行う。 |
| void DG_DrawFrame() |
画面バッファ変数DG_ScreenBufferの準備完了後に呼び出され、画面描画の処理を行う。 |
| void DG_SleepMs(uint32_t ms) |
引数で受け取ったミリ秒の間、処理をスリープする。 |
| uint32_t DG_GetTicksMs() |
doomgenericが起動してから現在までの経過時間をミリ秒単位で返す。 |
| int DG_GetKey(int *pressed, unsigned char *doomKey) |
キーボードイベント。 pressedにキーを押したor離したを、doomKeyにどのキーが押されたのかを渡す。 |
簡単なフローチャート
X11 on Linux向けのコードであるdoomgeneric_xlib.cを参考に、doomgeneric_myos.cの実装は次のようになりました。
#include "doomgeneric.h"
#include "doomkeys.h"
#include <stdio.h>
#include <syscalls.h>
#include <window.h>
#include <ctype.h>
static uint32_t init_ticks_ms = 0;
static WindowDescriptor *wdesc = NULL;
static char before_input_key = '\0';
static char input_key;
#define KEYQUEUE_SIZE 16
static unsigned short s_keyQueue[KEYQUEUE_SIZE];
static unsigned int s_KeyQueueWriteIndex = 0;
static unsigned int s_KeyQueueReadIndex = 0;
static unsigned char convertToDoomKey(char key)
{
key = tolower(key);
switch (key)
{
case '\n':
return KEY_ENTER;
case 'e':
return KEY_ESCAPE;
case 'a':
return KEY_LEFTARROW;
case 'd':
return KEY_RIGHTARROW;
case 'w':
return KEY_UPARROW;
case 's':
return KEY_DOWNARROW;
case ' ':
return KEY_USE;
case 'i':
return KEY_FIRE;
case 'o':
return KEY_RSHIFT;
default:
return key;
}
}
static void addKeyToQueue(int pressed, char key)
{
unsigned short keyData = (pressed << 8) | convertToDoomKey(key);
s_keyQueue[s_KeyQueueWriteIndex] = keyData;
s_KeyQueueWriteIndex++;
s_KeyQueueWriteIndex %= KEYQUEUE_SIZE;
}
void DG_Init()
{
init_ticks_ms = (uint32_t)sys_uptime();
wdesc = create_window("DOOM", 0, 0, DOOMGENERIC_RESX + 8, DOOMGENERIC_RESY + 30);
if (wdesc == NULL)
{
printf("Failed to create window\n");
return;
}
if (add_image_to_window(wdesc, DOOMGENERIC_RESX, DOOMGENERIC_RESY, PIXEL_FORMAT_BGRA, (char *)DG_ScreenBuffer) == -1)
{
printf("Failed to add image to window\n");
return;
}
}
void DG_DrawFrame()
{
if (wdesc != NULL)
flush_window(wdesc);
if (sys_read(FDN_STDIN, &input_key, 1) == -1)
return;
if (input_key == '\0')
return;
if (input_key != before_input_key && before_input_key != '\0')
addKeyToQueue(0, before_input_key);
addKeyToQueue(1, input_key);
before_input_key = input_key;
}
void DG_SleepMs(uint32_t ms)
{
uint32_t start = DG_GetTicksMs();
for (;;)
{
if (DG_GetTicksMs() - start >= ms)
break;
}
}
uint32_t DG_GetTicksMs()
{
return (uint32_t)sys_uptime() - init_ticks_ms;
}
int DG_GetKey(int *pressed, unsigned char *doomKey)
{
if (s_KeyQueueReadIndex == s_KeyQueueWriteIndex)
return 0;
unsigned short keyData = s_keyQueue[s_KeyQueueReadIndex];
s_KeyQueueReadIndex++;
s_KeyQueueReadIndex %= KEYQUEUE_SIZE;
*pressed = keyData >> 8;
*doomKey = keyData & 0xff;
return 1;
}
void DG_SetWindowTitle(const char *title)
{
}
int main(int argc, char **argv)
{
doomgeneric_Create(argc, argv);
while (1)
doomgeneric_Tick();
return 0;
}
ticksの取得
DG_GetTicksMsにて、doomgenericが起動してから現在までのミリ秒単位の経過時間=ticksの取得が正常に行われる必要があり、例えばこれが仮実装で、常に0を返す実装にしていると、画面描画やキーボード入力の受け付けが行われず、処理が先に進まなくなります。
ここで利用しているsys_uptimeは自作OSのシステムコールで、OSが起動してから現在までの経過時間をミリ秒単位で返す実装になっています。
DG_Initにてstatic変数init_ticks_msにsys_uptimeで取得した値を代入し、これがdoomgenericが起動した時間の基準になり、DG_GetTicksMsにてticksの計算を可能にしています。
画面描画
自作OSに実装されているウィンドウマネージャを利用して、画面描画を行っています。
DG_Initにてウィンドウを生成する関数であるcreate_window、ウィンドウに画像コンポーネントを追加する関数であるadd_image_to_windowを呼び出し、DG_DrawFrameではウィンドウを再描画するための関数であるflush_windowを呼び出しています。これらはウィンドウ関連のラッパー関数であり、それぞれ内部で専用のシステムコールを呼び出しています。
キーボード入力
DG_DrawFrameにてウィンドウが再描画された後にキーボード入力を受け取っています。ユーザーアプリからキーボード入力イベントを直接取得することはできず、代わりに標準入力から1文字受け取り、それをキーボード入力に見立てています。
OSはキーボードが入力され、割り込みが発生すると、どのキーが押されたか、もしくは離されたかを識別するキーコードを受け取ります。そのキーコードが解釈でき次第、非同期で標準入力バッファに、キーボードに対応するASCII文字をpushします。
ユーザーアプリからreadシステムコールsys_readにおいて、FDN_STDIN(=0番)に対してreadすることによって、標準入力バッファから文字を読み出すことができます。
以下OS側のsys_readの実装です。FDN_STDINに対してreadする場合、本来はエンターキーが入力されるまで無限に待機し続けるのですが、今回のように1文字入力した後即復帰してほしいケースのために、バッファサイズが1と指定された場合のみ特別扱いしています(今コードを見返したらバッファサイズが0や負数の場合も特別扱いされてしまっているので後で修正します)。
myos-x86_64/kernel/src/arch/syscall.rs
...
fn sys_read(fd: FileDescriptorNumber, buf_addr: VirtualAddress, buf_len: usize) -> Result<()> {
match fd {
FileDescriptorNumber::STDOUT | FileDescriptorNumber::STDERR => {
return Err(Error::Failed("fd is not defined"));
}
FileDescriptorNumber::STDIN => {
if buf_len > 1 {
let mut input_s = None;
while input_s.is_none() {
if !console::is_ready_get_line() {
super::hlt();
continue;
}
super::disabled_int(|| {
input_s = console::get_line()?;
Result::Ok(())
})?;
break;
}
let c_s = CString::new(input_s.unwrap())
.unwrap()
.into_bytes_with_nul();
buf_addr.copy_from_nonoverlapping(c_s.as_ptr(), buf_len);
}
else {
let ascii = super::disabled_int(|| console::get_ascii())?;
buf_addr.copy_from_nonoverlapping(&(ascii as u8), 1);
}
}
fd => {
let data = vfs::read_file(&fd)?;
if buf_len < data.len() {
return Err(Error::Failed("buffer is too small"));
}
buf_addr.copy_from_nonoverlapping(data.as_ptr(), data.len());
}
}
Ok(())
}
...
キーボード入力を受け取った後、addKeyToQueue、convertToDoomKeyにて、ゲーム内で使用するキーコードに変換しています。それぞれのキーの対応についてはconvertToDoomKey内の実装で確認できます。
変換されたキーコードはキューに追加され、DG_GetKeyによってキーの判定を行うタイミングで取り出されます。
標準入力からの受け取りでは、入力されたASCII文字の情報しか得られず、Ctrlやファンクションキーなどは入力できません。また、キーが押されたか、もしくは離されたかの識別もできないため、最低限プレイできるようにするための苦肉の策として、次のキーが押されるまで前のキーは押しっぱなし判定になります。そのため、実際のゲームプレイでは、別のキーが押されるまで前後左右移動や攻撃をし続けるため、照準合わせや視点移動が通常よりも難しめになっています(操作に慣れるとそれはそれで結構面白い)。
移植編6 - 画面描画用に新しいシステムコール生やす
今回新しく、ウィンドウに画像コンポーネントを追加するsys_add_image_to_windowと、ウィンドウを再描画するsys_flush_windowの2つのシステムコールをOSに実装しました。
myos-x86_64/kernel/src/arch/syscall.rs
...
fn sys_flush_window(wd: LayerId) -> Result<()> {
simple_window_manager::flush_window(&wd)
}
fn sys_add_image_to_window(
wd: LayerId,
width: usize,
height: usize,
pixel_format: PixelFormat,
framebuf_virt_addr: VirtualAddress,
) -> Result<()> {
let image = simple_window_manager::components::Image::create_and_push_from_framebuf(
0,
0,
width,
height,
framebuf_virt_addr,
pixel_format,
)?;
simple_window_manager::add_component_to_window(&wd, Box::new(image))?;
Ok(())
}
...
ウィンドウやコンポーネントの管理はsimple_window_managerが担っており、複数レイヤー構造を実現するmulti_layerによって、フレームバッファに描画されます。
myos-x86_64/third-party/doom-for-myos/doomgeneric_myos.c
...
if (add_image_to_window(wdesc, DOOMGENERIC_RESX, DOOMGENERIC_RESY, PIXEL_FORMAT_BGRA, (char *)DG_ScreenBuffer) == -1) {
...
add_image_to_windowでは、引数に画像サイズ、画像のピクセルデータとなるバッファの参照、ピクセルフォーマット(データの並び順がRGBなのか、BGRなのか等)といった情報を渡します。今回使用されるピクセルフォーマットは32ビット(各色8ビット)整数値であり、透明度を表すアルファ値も存在しますが、OS側で半透明なピクセルを描画する機能を実装していないため、アルファ値のみ無視しています。

画面全体の再描画は、タイマー割り込み発生時に定期実行されますが、その際multi_layerは各レイヤーが持っているバッファをフレームバッファに書き込みます。画像コンポーネントは内部にレイヤー用バッファとピクセルデータのバッファの参照を保持しており、画像を更新する際、一度ピクセルデータのバッファの内容を、レイヤー用バッファにコピーする必要があります。OS側はピクセルデータのバッファの中身がいつどのように変更されたのか知る由もないため、ユーザアプリ側から明示的にコピーを行うため、システムコールsys_flush_windowを用意しました。これにより、ウィンドウとそれに所属するコンポーネントの再描画が行われます。
感想
これまでに紹介したもの以外にも、実装の過程で遭遇した様々なバグの話や未実装な機能など、ネタはたくさんありますが、長くなりそうなのでここまでにしておこうと思います。
まだまだOSの設計や実装が雑で、改善が必要な箇所はたくさんあると思いますが、自作OSの世界では、OSのコードを書く人とハードウェアが「世界の神」であるため、どんな実装であろうと、バグがあろうとそれを仕様と言い張ることができます(暴論)。
それはさておき、自作OS開発は自分のモチベーションとの戦いなので、強い執念がない限り、無理して既存のOSの再現実装をする必要はないのでは、というのが経験則から来る持論です。
自作OS、ハードウェアやOSの気持ちが理解できるようになるので楽しいし、就活にも繋がるのでメリットしかない!!みんなもやろう!!!
参考サイト
drewdevault.com
www2.katsuster.net
chikuwait.hatenablog.com