バニキPの気ままに

気ままにやったこととか紹介していきます

Uniteを手放した話

はじめに

私は、プラグイン管理ツールをneobundleからdppに乗り換えました。 乗り換えた話は、このブログで紹介しているのでよかったらどうぞ。 その過程で、古いプラグインもついでに新しくしようと思い立ったわけです。 その中でも、私ヘビーに使っていたのがunite.vimでした。 これは、fizzy finderで、ファイルの検索やコード内grep、複数ファイルの同時置換など様々な機能を備えたものです。 10年以上前に作られたもので、dduの開発に合わせて紹介された記事でも触れられており、後継であるdeniteがあるにも関わらず2022年なんならこの記事を書いている2025年現在でもユーザーは存在するくらい完成度の高いものとなっています。 私も、一時期deniteへ移行しようと考えていた時期もありましたが、ある機能がdeniteでは標準では存在しなく必要なら自分で用意してねというスタンスだったことで乗り換えを諦めました。 この時の私には、自分でいじってまで乗り換える熱量というものがなかったのです。 他にも色々理由はありましたが、今回はその中でも複数ファイルの同時置換の機能を実装した話を紹介します。 そう、なければ作ればいいのだ。

複数ファイルの同時置換

複数ファイルの同時置換がどういうものかというと、例えばhogeという変数を用意していたとします。 この変数は100個のファイルの中で使われているとして、fugaに変えたいという状況があったします。 その時、これら100個のhogefugaに置換することがここでいう複数ファイルの同時置換です。

uniteにおける置換

以下の手順で置換することができます。

  1. uniteでgrepして候補一覧を出す
  2. 候補を選択する
  3. replace action を起動する(thinca/vim-qfreplace が必要)
  4. 置換バッファが開くので編集して保存
  5. 複数ファイルにまたがった置換を行うことができる

いちいちgit grepとかで探す必要もなく、しかもvim内で全て完結してかつスマートにできるのでとても重宝していた機能でした。 この機能に該当するものがdeniteではなかったのです。

uniteからの移行先

では何に移行したのかという話ですが、もちろんdeniteに移行して自分で実装することできます。 ただ、先ほどもちらっと触れましたがddu.vimという新しいファジーファインダーフレームワークが登場していました。 新しいものに移行することを目標にしていたので、今回はdduに乗り換えることにしました。

実装

unite相当にするためにも細かい設定をする必要があります。 そこについては、今回の範囲すると長くなってしまうので、私の設定を放出しておきます。 github.com

どういう実装をしたのか

大きく分けて二つの実装が必要になります。 「qfreplace側に渡す機能」と「ddu側でgrepして候補になったitemを処理する」というものが必要になります。 まずは、ddu側です。 customアクションとしてreplaceを作成します。 ここでは、DduReplaceという「qfreplace側に渡す機能」に候補になった行を渡しています。

[[plugins]]
repo = 'Shougo/ddu.vim'
hook_source = '''
call ddu#custom#action('kind', 'file', 'replace', { args -> DduReplace(args) })
...中略...

toggleSelectionItemのところで必要なitemだけを抽出するためのキーマップを定義してます。 そのしたのreplaceのところでは、先ほど定義したカスタムアクションを呼ぶための名前を定義してます。

function s:ddu_ff_my_settings() abort
...中略...
    nnoremap <buffer> <Space>
    \ <Cmd>call ddu#ui#do_action('toggleSelectItem')<CR>
    nnoremap <buffer> <C-r>
    \ <Cmd>call ddu#ui#do_action('itemAction', {'name': 'replace'})<CR>
...中略...
endfunction
'''

次にqfreplace側のコードです。 itemsから受け取った値をfilename,lnum,textの値として与えてやります。 setqflistで値を入れて、qfreplaceの処理に渡します。 これによって、dduからgrepしてその候補をqfreplaceに渡すことができ、uniteで実現されていたのと近いものが動作します。

[[plugins]]
repo = 'thinca/vim-qfreplace'
hook_add = '''
function! DduReplace(args)
  let l:items = a:args->get('items')
  let qflist = []
  for item in l:items
      let l:action = item->get('action')
    if !has_key(l:action, 'path')
      continue
    elseif !has_key(l:action, 'lineNr')
      continue
    elseif !has_key(l:action, 'text')
      continue
    else
      call add(qflist, {
        \ 'filename': item->get('action')->get('path'),
        \ 'lnum': item->get('action')->get('lineNr'),
        \ 'text': item->get('action')->get('text')
        \ })
    endif
  endfor
  call setqflist(qflist)
  call qfreplace#start('')
endfunction

まとめ

今回uniteで重宝していたreplace機能をdduでも使えるように実装しました。 dppでも触れられていましたが、我々はプログラマーであるのでコードを書いてエディタを改良するのもまたお仕事でしょう。 こんな感じで足りなければ、実装すればいいのよの精神で今後のvim lifeを送っていこうと思います。

10年以上放置して浦島vim太郎になった初心者がdppに挑んだら

はじめに

昨年2024年の1月に縁あってMeguro.vimの会場提供者として関わることとなりました。 その中でせっかくなら何か新しいチャレンジでもしたいなと思っているとちょうど開催直近でdpp.vimというShougo さん開発のplugin管理ツールがリリースされていました。 私のvimは、10年以上前によく使うpluginを入れただけの状態でかつ当時では新しかったShougo さん開発のNeobundleで管理している状態でした。 Neobundleはdppから見て何世代前のものになります。 管理ツールが古いならその中で管理されていたpluginも同様に古いという浦島太郎状態だったというわけです。 せっかく新しいものが出ているならそれに乗り換えついでにpluginも新しいものにするというのを題材としてMeguro.vimで挑むことにしました。 この記事では移行に関する手順的な紹介もしますが、メインは何か知らなかったことで苦戦したことや移行して感じたことなどを紹介することです。 ただ道具として、使ってきたというものあるのでかなりの初心者として優しくみてもらえると助かります。 もし、何か間違っていることなどあれば教えてもらえると嬉しいです。

dpp.vimとは

dpp.vimは、先ほども紹介したようにvim/neovimのplugin管理ツールです。 開発者であるShougo さんの記事にて説明されています。 簡単にまとめると究極のシンプルな形を追い求めたものらしいです。 ここで勘違いして欲しくないところは、シンプルだから簡単にできるんだではないということです。 ここにおいてのシンプルとは、ただ書くだけ、dppをインストールするだけでは何も起きないということです。 vim-plugや私が使っていたNeoBundleというような簡単にインストールしてすぐ使えるとは真逆なのです。 つまり、裏を返せばカスタマイズの自由がかなり与えられているということです。 もちろん、何もしていなくても動くというのは、多くのユーザーにとって心地よく良いものでしょう。 しかし、裏でどう動いているかわからないことでユーザーのカスタマイズを阻害すると思います。 プログラマーであるならコードを書いてエディタをただの道具から解放できるのです。

移行してみた

前提

私は、neovimではなくvimをずっと使っています。 あえて理由をつけるならサーバーをよく扱っていて、ssh先にインストールされているのもvimだからでしょう。(深い理由があるかと言われると特にない... deinにも移行していなかったので、tomlでpluginを管理するようなことはしていないです。 設定をvimrcに直書きしただけの神クラス的な扱いをしてました。 また、手元のマシンはリンゴのやつです。 それを踏まえた上で続きをご覧ください。

まずは必要なものを揃えよう

dppを動作させるには、READMEに以下のように書かれています。

Please install both Deno 1.45+ and "denops.vim" v7.0+.

Denoとdenops.vimが最低限必要になってきます。 このままではあくまでdppが動作するのに必要最低限しか揃っていません。 dppが紹介された記事にも書かれてましたが、自分の用途に応じて必要なものをとってくる必要があります。

dpp.vim + dpp-ext-local: pathogen.vim 相当 dpp.vim + dpp-ext-installer: Vundle.vim 相当 dpp.vim + dpp-ext-installer + dpp-ext-lazy: vim-plug 相当 dpp.vim + dpp-ext-installer + dpp-ext-lazy + dpp-ext-local + dpp-ext-toml: dein.vim 相当

それらをいい感じにしてくれる以下にようなscriptしました。 ただ、私の手元の諸々の管理の都合上、denoだけ別でインストールしてます。brewでもなんでも使って手元にご用意ください。 やっていることは、単純で必要なディレクトリを用意してそこに必要なものをgit cloneしてくるだけのものです。 dpp関連のもので不要なものや追加で欲しいものは、shougo_itemsの変数内を編集してください。 ここでもあくまで、dppの動作的な必要なもので、dccといったような便利に使うためのpluginは、別の方法でやります。 ここの操作は割と詰まる人は少ないと思います。 ただ、typoとかをやらかしている可能性があるので、補完機能などを使って間違えないように注意しましょう。(私もしっかりとtypoしました...

mkdir -p ~/.cache/dpp/repos/github.com/
cd ~/.cache/dpp/repos/github.com/

directories=(
  "Shougo"
  "vim-denops"
)
for directory in "${directories[@]}" ; do
  mkdir -p ${directory}
done

cd ./Shougo
shougo_items=(
  "dpp.vim"
  "dpp-ext-installer"
  "dpp-protocol-git"
  "dpp-ext-lazy"
  "dpp-ext-toml"
)

for item in "${shougo_items[@]}" ; do
  if [ ! -d ${item} ]; then
    git clone https://github.com/Shougo/${item}
  fi
done

cd ../vim-denops
if [ ! -d denops.vim ]; then
  git clone https://github.com/vim-denops/denops.vim
fi

設定ファイルを用意する

いよいよ設定をしていきます。 必要なファイルは、起動時の設定を書くvim scripと起動時キャッシュを生成するTypescriptとになります。 ここでは、説明を楽にするため、vim scritptを.vimrc、Typescriptをdpp.tsというファイル名として定義します。

  • .vimrc(vim script部分)
  • dpp.ts(Typescript部分)

.vimrc

まず、.vimrcを確認します。 設定内容ですが、詳しい説明は紹介記事にて確認してください。 なんとなく読めるくらいの理解力でつまづくポイントを3つ紹介します。

const

まずconstを使っているところは、変数を定義していることはわかります。 しかし、ちゃんと知らないと「s:」ってなんだとなりました。 調べるとこれは変数のスコープを示していて、sだと現在のスクリプトとローカルがスコープになるようです。 gならグローバルというようなものです。 詳しいことが知りたい方は、調べてみてください。

runtimepath

次にruntimepathです。 紹介記事では常識ですよね的に書かれているのですが、私にとっては、知らない世界でした。 ちゃんと知って使う人からすればそうなのかもしれませんが、ただ道具としてか使っていない場合は、そうではない人の方が多いのではないでしょうか。 これについて深い理解があるわけではありませんが、dppは結局vim本体が勝手に認識してくれるものではないので、それを読み込んでくれるようにするものだと解釈しています。 本来詳しいところは理解した方が良いこともあるのだとは思いますが、一旦はこの理解があれば、読み進めることができます。

autocmd

最後に、autocmdです。 これも雑な理解でしかないですが、結局イベントにフックして実行されるものとして解釈しています。 この場合、UserというグループでDenopsReadyイベントにフックしてstateファイル(起動時を高速にするためのキャッシュ生成物)を作成するというもののはずです。 やったことをまとめるとvimに必要なものを読み込ませるようにしてDenopsがavailableになったら、stateファイルを作成するというものです。 これによって、plugin managerとして動き出したはずです。

const s:dpp_base = '~/.cache/dpp/'
const s:dpp_src = '~/.cache/dpp/repos/github.com/Shougo/dpp.vim'
const s:denops_src = '~/.cache/dpp/repos/github.com/vim-denops/denops.vim'
const s:ext_toml = '~/.cache/dpp/repos/github.com/Shougo/dpp-ext-toml'
const s:ext_lazy = '~/.cache/dpp/repos/github.com/Shougo/dpp-ext-lazy'
const s:ext_installer = '~/.cache/dpp/repos/github.com/Shougo/dpp-ext-installer'
const s:ext_git = '~/.cache/dpp/repos/github.com/Shougo/dpp-protocol-git'
const s:dpp_config = '~/dotfiles/dpp.ts'

execute 'set runtimepath^=' .. s:dpp_src
execute 'set runtimepath^=' .. s:ext_toml
execute 'set runtimepath^=' .. s:ext_lazy
execute 'set runtimepath^=' .. s:ext_git
execute 'set runtimepath^=' .. s:ext_installer
execute 'set runtimepath^=' .. s:denops_src

if s:dpp_base->dpp#min#load_state()
  autocmd User DenopsReady
  \ : echohl WarningMsg
  \ | echomsg 'dpp load_state() is failed'
  \ | echohl NONE
  \ | call dpp#make_state(s:dpp_base, s:dpp_config)
endif

autocmd User Dpp:makeStatePost
      \ : echohl WarningMsg
      \ | echomsg 'dpp make_state() is done'
      \ | echohl NONE

2025/7/5 追記: あくまでコードの参考として載せていましたが、pluginを読み込ませようとする人はに全て欲しいはずなので変更しました。コードが杜撰なのは許してください。

ちょっとここで一つ

ここまでやる上で何回やっても上手くいかず、挫折もしました。 そんな時に一人でもある程度試行錯誤するためのお話です。

printデバッグ

一般にプログラムを書くときに、よく使われるデバッグ手法として、printデバッグと呼ばれるものがあります。 これをvimでもしたいわけです。 しかし、私はこれすら知りませんでした。 道具として動けばいいというレベルであれば、知る必要もないでしょう。 printをするための構文は、「echomsg」です。 これでどこまで進んだのかなど把握することができます。

ログ

いろんなアプリケーションなどを操作していると必ずみたくなるのがログです。 デバッグするにあたってどんなエラーが出ているのか把握できないとそもそも前にも進めないでしょう。 vimもアプリケーションである以上必ずログが出ます。 コマンドを入力するときの場所に出力されているのは知っています。 ですが、1行しか出力されなかったり、何かの操作で表示は消えます。 私は、どうやって見るのかすら知りませんでした。 Meguro.vimではそういったところを聞くことができて前に進めたと今でも思っています。 では答えですが、「:messages」です。 これによって世界が広がって私は、vimを道具から別のものにできたと思えました。

dpp.ts

続いて、Typescript部分のdpp.tsです。 これはstateファイルの定義部分となります。 正直半分くらいしか理解できないと思っています。 しかし、初心者が知りたいことはpluginをいい感じに読み込ませたいという欲だと思います。 そこさえなんとかすれば、実際喜びを得られると思ってます。 以下コードには必要そうな部分だけ抽出してます。(このままコピーして使うことができないので注意してください。コピペしたい人は私のリポジトリを参照してください。) 基本は、READMEとかにあるものを参照していただけると良いかと思います。 ここで大事なのは、tomls.pushをしているところです。 stateファイルに記述される内容は最後のreturn分の中身のはずです。 そこに正しい形で複数のtomlの情報を読み込ませられるといいはずです。 最初の形だと、一つのファイルしか読み込んでくれそうにないです。 つまりここをforとかで増してやればいけるのではと改造した次第です。 ここで用意しているtool.tomlやddu.tomlは私が設定しているtomlです。 ご自身で用意したファイルに置き換えてください。

    const [context, options] = await args.contextBuilder.get(args.denops);
    const dotfilesDir = "~/dotfiles/tomls/";

    // Load toml plugins
    const tomls: Toml[] = [];
    const tomlFile: string[] = ['tool.toml', 'ddu.toml', 'ddc.toml'];
    for (const file of tomlFile) {
      tomls.push(
        await args.dpp.extAction(
          args.denops,
          context,
          options,
          "toml",
          "load",
          {
            path: await fn.expand(args.denops, dotfilesDir + file),
            options: {
              lazy: false,
            },
          },
        ) as Toml,
      );
    }
    // Merge toml results
    const recordPlugins: Record<string, Plugin> = {};
    const ftplugins: Record<string, string> = {};
    const hooksFiles: string[] = [];
    for (const toml of tomls) {
      for (const plugin of toml.plugins) {
        recordPlugins[plugin.name] = plugin;
      }

      if (toml.ftplugins) {
        for (const filetype of Object.keys(toml.ftplugins)) {
          if (ftplugins[filetype]) {
            ftplugins[filetype] += `\n${toml.ftplugins[filetype]}`;
          } else {
            ftplugins[filetype] = toml.ftplugins[filetype];
          }
        }
      }

      if (toml.hooks_file) {
        hooksFiles.push(toml.hooks_file);
      }
    }

    const lazyResult = await args.dpp.extAction(
      args.denops,
      context,
      options,
      "lazy",
      "makeState",
      {
        plugins: Object.values(recordPlugins),
      },
    ) as LazyMakeStateResult;

    return {
      ftplugins,
      hooksFiles,
      plugins: lazyResult?.plugins,
      stateLines: lazyResult?.stateLines,
    };

tomlファイル

deinに移行していなかった私には、tomlの書き方はいまいちわかりませんでした。 今までvimrcを肥大化させていただけに分割できるのは嬉しいわけです。 一例を以下に示します。 例としては、vim-indent-guidesの私の設定をお見せします。 pluginひとつひとつに対して[[plugins]]と書く必要があるようです。 repo = 'nathanaelkane/vim-indent-guides'が実際に設定したいpluginを記述してやります。 最後に、hook_addのようにこのpluginにする設定を記述します。 ここのhook_addは、pluginがtomlに設定されinstallが実行された時に呼ばれます。 他にもpluginが読み込まれる前や後にも呼び出すことができるhookイベントはあるので調べてみてください。 あとは、その下にも[[plugins]]を複数記述する形で記載をするのがtomlの基本になります。 メンテナンス性を上げるためにもある一定の塊でtomlを分けておくとメンテが楽になります。

[[plugins]]
repo = 'nathanaelkane/vim-indent-guides'
hook_add = '''
let g:indent_guides_enable_on_vim_startup=1
let g:indent_guides_start_level=2
let g:indent_guides_auto_colors=0
autocmd VimEnter,Colorscheme * :hi IndentGuidesOdd  guibg=#444433 ctermbg=gray
autocmd VimEnter,Colorscheme * :hi IndentGuidesEven guibg=#333344 ctermbg=darkgray
let g:indent_guides_guide_size = 1
'''

[[plugins]]
.....

実際に使ってみる

では実際に使ってみましょう。 いつも通りにvimを開いてやります。 問題が起きてなければ~/.cache/dpp/vim/state.vimというファイルが生成されています。 もしなければ何か問題が起きていることが予想されるので`:messages:'やechomsgなどで確認/デバッグしてみてください。 移行をしていた人ならこの段階でおかしいことに気づくと思います。 色々設定したpluginが読み込まれてないぞと。 それもそのはずです。 なぜならばそれがこのdppのコンセプトだからです。 紹介されていたブログを見れば書いていますね。

dpp.vim はデフォルトでは何も動作しないことを目指します。 なので、あくまで動作するための環境が整ったのに過ぎないのです。

pluginを読み込ませる

デフォルトでは何も動作しないということはユーザーは意図的にpluginをインストールする行為をしないといけないわけです。 これを面倒くさいやなんでなのと思う人にはこのツールはあってないと思うので、他のものを使うことをお勧めします。 インストールに使うコマンドは、:call dpp#async_ext_action('installer', 'install')です。(ref: dpp-ext-installer これによってtomlで設定したplugin達が指定したディレクトリにインストールされます。 これが終わってvimを開き直すとあら不思議設定が読み込まれて見覚えのある設定が反映されたものが出来上がります。 もちろんですが、このあと編集してvimをreloadしても設定は反映されません。 きちんと反映させる行為、:call dpp#async_ext_action('installer', 'update')を実行してやってください。 いわばこれはコンパイルをしているのです。

さいごに

この移行にあたって、Meguro.vimで私のことを助けてくださった多くの方に感謝しております。 ようやくvimmerとしての人生が始まったと思っています。 dpp.vimを使うにあたって難しいポイントはいくつもあります。 初心者が躓かずに色々設定することも難しい瞬間は多くあると思います。 しかし、それを乗り越えた先にはvimを知る上で大切なことが多く実体験として得られると思います。 この記事を読んでくださった方がよりvimを身近に感じられるようになると嬉しく思います。

C言語で自作shell作ってみた(コマンド実行とリダイレクト)

この記事は,Akatsuki Advent Calendar 2021 の3日目の記事です.

はじめに

11月半ばに急にslackでメンションが飛んできて,アドベントカレンダーの空き枠後少ししかないよ!と. 毎回アドベントカレンダー然り,何かしらのイベント然りで何を喋るかネタに困る. キラーパスがよく飛んできて,毎回なんとかなってるので今回もまあいっかということで引き受けました. 投稿1週間前の土日に頑張るぞと思ってたら,ライブがあってその夢は打ち砕かれました. なので,という訳ではないですが,そこまでやったぜ感はないです.(続報としてもっと良い感じに作って行こうかなとかは思ってます)

経緯

最近,Shell Scriptを読んで割と学ぶ機会がありました. まだまだ,Linux関連知識をもっと深めていった方がいいなと感じる場面がありました. じゃあ,勉強になる何かを作って勉強しつつアドベントカレンダーのネタにしようという作戦で,脳内会議では合意が取れました. 何か良い感じの学習の流れとか書いてないかなと探していた時に,このブログに出会いました. Linuxの基礎を理解する的なやつだったのですが,shellの欄があってbashとかの理解の結果として自作shellを作っていると書いてました. これだと思ってやってみるかということでネタを決めました.

本編

shellって何?

bash」や「zsh」などの種類が存在する,プログラムのことで,入力デバイスから何かしらのコマンドを受け取って,OSに渡して実行し,その結果をユーザに示すユーザインタフェースである. ユーザは,shellを使ってOSを操作したり,他のプログラムを実行したりする.

実装していくにあたって知っておいた方がいいこと

基礎として,以下の3つが挙げられる.

プロセス

Wikipedia先輩によるとプログラムの動作中インスタンスであるらしい. プログラムを実行するとプロセスが起動して,プログラムに従って動作する. かく言うshellもプロセスになる. 注意するべき点は,プロセスには制約があると言う点である. プロセスは,自分の「外部」にアクセスできない. つまり,ファイルの読み書きや自分以外のプロセスの起動などが「外部」に当たる. そうしたできないことは,カーネルに処理を委譲することになる.

カーネル

OSの中核にある特権を持つプログラムのことである. その特権には,ファイルの読み書きやプロセスの起動など,プロセスができないことを可能としている. このカーネルに処理をお願いする場合に使うのがシステムコールである.

システムコール

プログラムがOSの特権機能を呼び出す仕組みのことである. OSによって数が異なり,Linuxだと300個程度で,FreeBSDだと500個程度ある. 主要なシステムコールは,ファイル操作のopen(2),read(2),write(2),close(2),プロセスを起動するfork(2)などがある. プロセスは,システムコールを利用して,OSの特権を行使することで,本来できないことを実現している.

shellはどうやって動作してるの?

先ほどいったようにshellもプロセスの一つである. ということは,プロセスの制約を受けるということになる. shell自身は何かのコマンドを実行した後も他のコマンドを受け付ける必要がある. ということは,コマンド自体はshellと同一のプロセスとは別のプロセスで実行される必要がある. つまり,shellは別プロセスを起動してコマンドを実行するためのプログラムとなる. shellの実行の流れを説明する.

  1. コマンドを受け付ける
  2. fork(2)を実行しOSに別プロセスを起動してもらい,shell自身は新しく起動したプロセスの終了を待つ
  3. 新たに起動したプロセスでは,実際にexec(2)を用いて,コマンドを動作させる
  4. コマンドの結果をユーザに返した後,コマンド実行のために起動したプロセスは終了する
  5. shell自身のプロセスは,終了を確認すると次のコマンドを待つ

基本的には,1~5を繰り返すのがshellの動作となる.

実装してみた

今回は,久々にCを書きたくなったという雑な理由ではありますが,C言語を選択してます. (久々すぎて,色々includeしないと使いたい関数が使えないというのに戸惑いましたw)

簡単な解説

(1)のところで,入力を受け付けてから,入力された文字列をbufferに詰めてます. その後,読み取ったコマンドの列をパースしてtokenに入れます, (2)のところでは,入力されたコマンドを実行するためにforkしてshellとは別のプロセスを起動してます. (3)のところで,入力されたコマンドを実行して,(4)のところで,実行の終了を待機するという一連の流れを実装しました,

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>

#define BUF_SIZE 1024

char** eliminateToken(char **token, int size, int pos1, int pos2);

int main(int argc, char **argv) {
  char *buffer = malloc(sizeof(char) * BUF_SIZE);
  char **token = malloc(sizeof(char*) * BUF_SIZE);
  int c, status, position;
  pid_t pid;

  do {
    position = 0;
    printf("$ "); // 入力受付(1)
    while (true) {
      c = getchar();

      if (c == EOF || c == '\n') {
        buffer[position] = '\0';
        break;
      } else {
        buffer[position] = c;
      }
      position++;
    }
    token[0] = strtok(buffer, " ");
    for(position = 1; position < BUF_SIZE; position++) {
      token[position] = strtok(NULL, " ");
      if (token[position] == NULL) { break; }
    }
    token = realloc(token, sizeof(char*) * position);

    pid = fork(); // forkして別プロセスの起動(2)
    if (pid < 0) {
      perror("fork error");
      exit(EXIT_FAILURE);
    } else if (pid == 0) {
      if (execvp(token[0], token) == -1) { // コマンドの実行(3)
        perror("exec error");
        exit(EXIT_FAILURE);
      }
    } else {
      waitpid(pid, &status, WUNTRACED); // コマンドの終了を待機(4)
    }

    puts("");
    token = calloc(position, sizeof(char*) * BUF_SIZE);
  } while (!status);

  free(buffer);
  free(token);
  return EXIT_SUCCESS;
}

困りポイント

最初,実装した時は入力はgetcharではなく,fgetsを使っていたのですが,_や.といった記号のところで入力内容がおかしくなってしまう現象が発生しました. 結局,調査してもすぐに結論はでなさそうだし,時間もないということで一文字ずつbufferに格納する実装で動作させるようにしました. 普段rubyをメインで扱っていることもあり,すごく慣れない時間が長かったです. 学生時代はあんなに書いてたのに...使わないとすぐに錆びつきますね.

機能追加してみた(リダイレクト)

本当は,色々機能追加したかったのですが,ネタを決めるまでに時間がかかりすぎたので,そこまで時間がないから本当はコマンドが実行できたらいいかくらいでいました. しかし,いつものようにネコさんが,課題を設定してくれました. それが,リダイレクトです. 当初の予定では,リダイレクトとパイプくらい実装できればいいかと思ってましたのでいけるだろう.(そんな風に思って時期が自分にもありました.)

簡単な解説

コメントの間が追加した処理になります. 今回の実装で対応したのは,「>」と「>>」だけで,かつ必ず記号の後ろに空白を入れないと動作しません...(そこまで対応が間に合わなかったです...) 動作としては,入力された文字列にリダイレクトのマークがあったら処理をします. 確認方法は,strncmpを使って最初から指定文字が指定のマークか見てます. 「>>」が先なのは,「>」の場合「>>」の可能性もあるため先に確認してます. 「>>」の場合,ファイルを開く時,読み書きモードで開いて(O_RDWR),ファイルが存在していたらファイルに追加するようにAppendモードで開いて(O_APPEND),存在しない場合には新しく作成します(O_CREAT). 「>」の場合には,基本的に動作が同じで,ファイルが存在する場合の動作が異なります. ファイルが存在している場合は,中身を空にしてから書き込めるようにファイルを開きます(O_TRUNC). ファイルが開けたら,dup2を使って標準出力の先を開いたファイルに書き込むように変更します. つまり,ファイルディスクリプタをゴニョゴニョ弄っている訳です.(dup2の引数の2つ目のWRITEは標準出力を示すファイルディスクリプタ1番が入ってます.) 実行前に,eliminateTokenでリダイレクト処理の文字を削った実際に実行する必要のあるコマンドとオプションだけにしてexec(2)することで,リダイレクトを実現します.

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define BUF_SIZE 1024
#define READ     0
#define WRITE    1

char** eliminateToken(char **token, int size, int pos1, int pos2);

int main(int argc, char **argv) {
  char *buffer = malloc(sizeof(char) * BUF_SIZE);
  char **token = malloc(sizeof(char*) * BUF_SIZE);
  int c, status, position, fd;
  pid_t pid;

  do {
    position = 0;
    printf("$ ");
    while (true) {
      c = getchar();

      if (c == EOF || c == '\n') {
        buffer[position] = '\0';
        break;
      } else {
        buffer[position] = c;
      }
      position++;
    }
    token[0] = strtok(buffer, " ");
    for(position = 1; position < BUF_SIZE; position++) {
      token[position] = strtok(NULL, " ");
      if (token[position] == NULL) { break; }
    }
    token = realloc(token, sizeof(char*) * position);

    pid = fork();
    if (pid < 0) {
      perror("fork error");
      exit(EXIT_FAILURE);
    } else if (pid == 0) {
      // ここからしたがリダイレクトの処理
      for (int i = 0; i < position; i++) {
        if (!strncmp(token[i], ">>", 2)) {
          if ((fd = open(token[i+1], O_RDWR | O_APPEND | O_CREAT, 0666)) < 0) {
            perror("open error");
            close(fd);
            exit(EXIT_FAILURE);
          }
          dup2(fd, WRITE);
          token = eliminateToken(token, position, i, i+1);
          break;
        } else if (!strncmp(token[i], ">", 1)) {
          if ((fd = open(token[i+1], O_RDWR | O_TRUNC | O_CREAT, 0666)) < 0) {
            perror("open error");
            close(fd);
            exit(EXIT_FAILURE);
          }
          dup2(fd, WRITE);
          token = eliminateToken(token, position, i, i+1);
          break;
        }
      }
      // ここまでがリダイレクトの処理
      if (execvp(token[0], token) == -1) {
        perror("exec error");
        exit(EXIT_FAILURE);
      }
      close(fd);
    } else {
      waitpid(pid, &status, WUNTRACED);
    }

    puts("");
    token = calloc(position, sizeof(char*) * BUF_SIZE);
  } while (!status);

  free(buffer);
  free(token);
  return EXIT_SUCCESS;
}

char** eliminateToken(char **token, int size, int pos1, int pos2) {
  char **eliminate_buffer = malloc(sizeof(char*) * BUF_SIZE);
  int position = 0;

  for (int i = 0; i < size; i++) {
    if (i != pos1 && i != pos2) {
      eliminate_buffer[position] = token[i];
      position++;
    }
  }

  return eliminate_buffer;
  }

実際に動かしてみた

まずは,lsやcatなどの基本的なコマンド実行しました. f:id:shivashin495:20211203163956p:plain ちゃんと,動作して必要な情報を吐き出してくれてますし,オプションを指定しても動作してくれてます.

次にリダイレクトを実行してみます. まずは,「>」から実行しました. f:id:shivashin495:20211203164335p:plain

きちんとlsの結果がhogeというファイルに書き込まれています. また,リダイレクトの位置は真ん中にあっても良いのですが,それにも対応しています.

続いて,「>>」を実行してみます. f:id:shivashin495:20211203164742p:plain

もちろん元々書き込まれている内容に追記もできてますし,リダイレクトの位置が真ん中にあっても動作しています.

感想

今回は,勉強の一環も兼ねてC言語でshellを作ってみました. 正直,セキュリティ的にまずいところもあるだろうし,まだまだ効率的でなかったり,ほとんど関数に分割してないので可読性が低いと思います. しかし,勉強という点ではかなり良かったかなと思います. 身近に使っているbashzshというのがかなり高性能でよくできていると感じられますし,どんな動作をしているのかの理解度が上がりました. ただ,C言語で書いたのですか,パースする部分だったり,書くのが大変だったので,まだまだ勉強はしたほうが,今後のためにもなると思いました. まだshell作ったことがない方は,このブログが参考になるかはわかりませんが,誰でも調べたりすればできるので,勉強や趣味がてらやってみてはいかがでしょうか.

bpftraceで2048作ってみた

はじめに

去年のアドベントカレンダーでeBPFについていろいろ調べた. その時に,ネコの人に「SystemTapで昔ゲームを作ったことがあるから,BPFでも作れるはずだから作ってみたら」と. ちょうどLT会のネタに困っていたこともあり,作るかとなったので,bpftraceで動作する2048を作りました.(SystenTapで動作する2048もあります)

動作環境

bpftraceの環境構築は公式に書いてあるinstallの手順に従って導入してください. Linux5.3以上である事が必須となっています. Linux5.2以前では,命令数制限でverifierでカーネルにロードされずエラーとなってしまいます. また,プログラム内でwhileを使用しており,whileもLinux5.3以上でないとサポートされてないため(確かこのバージョンで入ったはず...)動作しません. このあたりの環境構築をするのが面倒な人のためにアドベントカレンダーの時にAnsibleを書いてますので良かったら利用してもらえる楽になるはず...

動かし方

1.下記からソースをcloneなりして取得してください. https://github.com/shivashin/bpftrace-2048

2.実行

sudo bpftrace 2048.bt 

この時,sudoをつけて実行してください.(命令数制限が緩和されたのは特権ユーザのみであるため)

3.ゲームを楽しむ

f:id:shivashin495:20210325233237p:plain
bpftrace-2048

※実行時引数に「emacs」や「wasd」を指定すると操作キーが変わります

操作の方向は盤面の下に記載されているキーを押すと事で遊ぶ事ができます.

どうやって動いてるの?(工夫とか紹介)

操作の入力

キーボードドライバのイベント関数(kbd_event)にフックして入力を取得しようとしましたが,これだと本体に接続されたPS/2やUSBキーボードでないと動作しない. 仮想環境で作業していたこともあり,ssh環境でも動作するようkprobesを使ってpty_write関数にフックして入力を取得するようにしました.

盤面の表示

操作されるたびに盤面が更新されるので,perfのCPU clockのsoftware eventにプログラムをアタッチして,一定間隔で再描画しています.

盤面の値

値を常に保存しておく必要があるため,BPF mapに盤面の値を保存していて,再描画される時にBPFプログラム側からperf_event_outputでメッセージを出力して,メッセージを受け取ったbpftraceプログラムがANSIエスケープシーケンスを利用して描画してます.

最後に

今回はカーネルで動作する2048を作ってみました. 関数に分割するのができないため,辛いところでしたね...(何度も分割したいと思った) ループの書き方もフラグとかでループを抜け出すみたいな処理を書くとVerifierに弾かれて実行できなかったり,LOGサイズの上限に達してしまったりと多少の苦労はありました. これを,LT会で発表する時はこの使い方が本来の使い方でないことを説明しないと変な勘違いしそうだから,せめて言ってあげてと言われて,本来の使い方をしてないことをそこで思い出したw. 有名な人もWifi強度で音の変わる楽器のプログラムを書いたり,キーボードを叩くとタイプライターの音がするプログラムを書いたりしてるので,あながち違ってないのもかもしれないですねw. 良かったら,みなさんもbpftraceでゲームを作ってみてはいかがでしょうか?

参考にしたやつ

neocat.hatenablog.com

eBPFについて知ってみよう

この記事は,Akatsuki Advent Calendar 202014日目の記事です.

はじめに

後輩が研究で使っている?もしくは,それに関連しているとかでBPFという技術があるということを知りました. 後輩の研究に対して何かしらのコメントとか指摘とかしたくて軽く調べたことがきっかけで興味を持ちました. このコロナ下の恩恵で,eBPF Summitが開催されたので,英語はそこまで聞き取れなかったけれども,何となくこんなことが出来るのか...と理解した. そのことを軽く話していると,ネコさんが「SystemTapで昔ゲームを作ったことがあるから,BPFでも作れるはずだから作ってみたら」と. 調べてみるとBPFでテトリスを動かしている人がいた. これは,何か簡単なゲームなら作れるかもしれないと思い,Advent Calendarに参加した.(出来るとは言ってない) まずは,BPFについてあれこれ調べようということで調査してみた.

BPFとは何か

BPFとは,Berkeley Packet Filterの略で,1992年にUNIX上でパケットキャプチャ.フィルタリングを効率的にするために開発されたソフトウェアである. 特定のパケットのみをアプリケーション側でキャプチャする時は,NICが受診したパケットを全てキャプチャプログラムへ渡してフィルタリングする方法がある. しかし,カーネル空間とユーザ空間で大量のシステムコールとデータコピーが発生してしまう. その問題解消解消するために,BPFが提唱されている. BPFは,カーネル内でパケットフィルタリングを行うことで,データコピーの削減やカーネル空間とユーザ空間の切り替えのオーバーヘッド削減を可能とした. 1997年に,Linuxカーネルに移植され,LinuxでBPFがパケットフィルタリング以外で利用されるseccomp(システムコール制限機構)が生まれた. BPFは,カーネル内で動作する独自のレジスタマシンと命令セットを持った仮想マシンの1つで,カーネルモジュールと違い,使える機能やプログラムの文法に制限がある. 加えて,ユーザ空間で作ったプログラムがカーネルとクラッシュさせたり,破壊したりしないように検証する機構を持っている. 2014年にBPFをより汎用的な仮想マシンにするために拡張され,拡張されたBPFをeBPF(extended BPF)と呼び,従来のBPFをcBPF(classic BPF)と呼ぶ. この拡張によって,パケットフィルタリング以外の用途で使われるようになった. 具体的には,以下のような場面にBPFが利用可能になっている.

  • ネットワーキング
  • トレーシング
    • カーネルトレーシング
    • ユーザプログラムトレーシング
    • パーフォマンスカウンタモニタリング
  • セキュリティ

cBPFとeBPFの違い

eBPFになってどのような拡張が行われ,どのような違いがあるか示す.(以下に示す以外にも違いは存在する)

数値的な差

項目 cBPF eBPF
レジスタ 2 10
レジスタ幅(bit) 32 64
スタックサイズ(byte) 16 512
スタックアクセスサイズ(byte) 4 1,2,4,8
パケットアクセスサイズ(byte) 1,2,4 1,2,4,8

命令数の増加

拡張によって以下の命令が追加された. また,cBPFでは多くの命令がAレジスタに対する命令であったが,eBPFでは,src_reg(代入するレジスタ,メモリアドレスまたは数値を指定する)やdst_reg(値を受け取る記憶領域を指定する)フィールドにより使用するレジスタを柔軟に選択できるようになっている.

  • 事前に登録済みの外部関数(カーネル内関数)呼び出し命令
  • アトミック加算命令(BPF mapのデータ更新に利用する)
  • エンディアン変換命令
  • 64bit幅命令(64bit幅でのメモリ読み書き及び64bit)

eBPF mapの追加

eBPF mapと呼ばれるデータ構造が利用可能になり,BPFプログラムから外部関数呼び出しを使うもしくは,ユーザ空間からシステムコールを利用して,アクセスすることが出来る. この機能によって,BPFプログラムとのやり取りが可能となった. mapには,BPF_TABLE,BPF_HASH,BPF_ARRAYなど16種類のデータ構造を利用することが可能である.

他のプログラムへのジャンプ

Tail Callと呼ばれるBPFプログラムへの遷移を行う機能で,遷移後に遷移元戻ることはない. 遷移先BPFプログラムとはスタックフレームを共有している. 一定時間で終了を保証するため,最大のTail Call回数は32回に制限されている.

eBPFの全体像

BPFの全体像を,以下に示す.

f:id:shivashin495:20201213235925p:plain

1.まずBPFプログラムをC言語で記述し,LLVM/Clangでコンパイルする.
2.bpf(2)システムコールを利用してカーネルにロードします.
3.カーネル内で検証器にてBPFプログラムの安全性を確認し,BPFプログラムのロードが完了する.
4.BPFプログラムとのデータのやり取りが必要な場合,bpf(2)システムコール経由でBPF mapが作成され,BPFプログラムとmapがやり取りを行う.
4'.ユーザ空間でBPF mapにアクセスすることでBPFプログラムから情報を取得することが出来る.
5.sockets,trace_points,kprobes,uprobesのイベントソースを使ってBPFをプログラムをアタッチする.
アタッチに対応するイベントが発生すると,BPFプログラムが呼び出され,処理が実行される.

補足

trace_pointsは,ソースに埋め込んで,イベントに処理を追加する仕組みのこと.
kprobesは,任意のアドレスに対して実行中のカーネルに処理をはさむための仕組みで,kretprobesという名前のものも存在し,違いとしては,関数の実行後に処理をはさむ点で異なる.
uprobesは,任意のアドレスに対してユーザ空間で動作シリアプリケーションに処理をはさむための仕組みで,uretprobesという名前のものも存在し,違いとしては,関数の実行後に処理をはさむ点で異なる.

安全性の確保

全体像で説明した通り,カーネルはBPFプログラムをアタッチする前に,実行しても安全であるか検証器を用いて検証する. 検証器で確認する前にも,bpf(2)システムコールの段階でBPFプログラムが一定サイズ以下(v5.9.14にて4096)であることを確認している. 検証器でのチェック項目は,以下のようなものがある

  • ループがないこと
  • 未初期化のレジスタを利用しないこと
  • コンテキストの許可範囲のみアクセしていること
  • 境界を超えたメモリアクセスをしないこと
  • メモリアクセスのアラインメントが正しいこと

また,検証器は必要に応じて一部の命令の書き換えを行う. 例えば,ネットワーク関連でinclude/uapi/linux/bpf.hで定義されているstruct __sk_buffを使う場合は,実際のカーネル内のデータ構造で利用されるsk_buffの対応したフィールドに対応するように変換する. オプションでJITコンパイルを行って,ROP攻撃対策やSpectre Variant 2対策を行うことが出来る. 他にも,/proc/sys/kernel/unprivileged_bpf_disabledを1にすることで,CAP_SYS_ADMINを持ってないユーザはbpf(2)システムコールが利用できないようにすることが可能である.

環境構築

BPFを利用するには,LinuxUNIX環境でないとカーネルに組み込まれていない. また,いろいろ試すにはいろいろ面倒なことが多かった(体験談). そこで,誰でも同じ環境が構築できるようにという目的と勉強も兼ねて,仮想環境の設定ファイルとAnsibleの設定ファイルを作成した.

必要なツール

以下のツールが必要になる. 確かめてはないが,基本安定最新版をインストールすれば,問題ないと思われる.(多分大丈夫なはず...)

インストール手順

Gitページに拙い英語で手順を書いているが,以下に示す. cloneしたディレクトリに入った後,vagrant upコマンドで仮想環境を立ち上げます. ansible-playbook playbooks/upgrade-kernel.yml(カーネルのバージョンアップ)とvagrant reload(仮想環境の再起動)は,やらないくても動作します. これは,BPFプログラムの命令数上限がLinux5.3で4096命令制限が特権ユーザなら最大100万命令までロードできるようになった(このコミット). 上限を引き上げる必要がある場合に実行することが必要になる. ansible-playbook playbooks/setup.ymlで実際に必要なツールなどをインストールしている.

git clone https://github.com/shivashin/bpf-test-environment.git
cd /path/bpf-test-environment
vagrant up
ansible-playbook playbooks/upgrade-kernel.yml
vagrant reload
ansible-playbook playbooks/setup.yml

インストールしているもの

  • BCC
  • python3-bcc
  • bpftrace
  • サンプルプログラム
  • その他必要なツール群

実際に何か動かしてみよう

先ほどの章で説明した環境構築で,手元に仮想環境は構築されている(はず...). vagrant sshで仮想環境にsshして,実際にeBPFのプログラムを動かしてみようと思う. 今回はpython3-bccの導入をしているので,pythonを用いる. 詳しい構文などについての情報はここにある.

コード

今回実行するコードを以下に示す. progの変数に格納されているコードがBPFのプログラムになる. 今回は,「Hello. World!」を出力するだけの簡単なプログラムである. bpf_trace_printkのように見慣れない関数がいるが,これは,ユーザ空間にメッセージを渡す関数である. BPF(text=prog)の部分でカーネルにロードする. このロード時に検証器にて,安全性の確認が行われるので,途中で説明したようなチェックでエラーになるとロードされない. b.attach_kprobe(event=b.get_syscall_fnname("mmap"), fn_name="hello")では,指定するイベントにアタッチしている. 今回の動作では,cloneシステムコールにアタッチしている. get_syscall_fnnameを使って,カーネル内の関数名を取得して,kprobeを用いて関数にアタッチしている. これ以降のコードは出力に関する設定をしている. このプログラムを実行することで,mmapシステムコールは発行された際に,「Hello, World!」を出力するプログラムが呼び出される.

from bcc import BPF

# define BPF program
prog = """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("mmap"), fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# format output
while 1:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
    except ValueError:
        continue
    print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))

実行してみた

このプログラムを実行して本当に動作しているか確認する. 確認方法は,lsコマンドを実行して確認する. 用意した環境にtmuxを導入しているので,別セッションを開くことで確認を行う. まず,lsコマンドが何回のmmapシステムコールを発行しているか確認するため,straceコマンドを用いる.

f:id:shivashin495:20201214001157p:plain

この結果より,mmapシステムコールは,17回発行されていることがわかる. eBPFプログラム側で17回分実行を検知しているか確認する.

f:id:shivashin495:20201213235959p:plain

eBPF側で17回lsコマンドでmmapが発行されていることを検知して「Hello, World!」を出力することができた.

さいごに

今回は,eBPFについて知ってみようということでこの記事を書きました. 本当は,簡単なゲームでも作ろうとしてましたが,Ansibleの作成やエラー処理,及び執筆に時間を使ったために実現しませんでした. その分,出来るだけ詳しめにBPF周りの情報をまとめたりしました. eBPFを触るきっかけにでもなればと思います.(出来るだけ調べたつもりですが,何か間違いがあったら申し訳ないです.) P.S. 時間はかかってもゲームは作ってみようと思います.(今年中は厳しいかな...,もう少しプログラムを書いてみないと分からないことが多すぎ.)