Lv0を考える

子供を育てていると、何かを教えるにあたってまずLv1から伝えようとするわけだが、すぐにその"Lv1"は親のエゴだったことを痛感させられる。

例として、子供が言葉を発するようなるためのLv1はなんだろうと考えてみる。

「これは『り・ん・ご』だよ」と言ってみるのをLv1だと考えたとしよう。

実際はこれだけの段階が存在する。

「きこえ」と「ことばの発達」情報室

本当のLv1は「規則正しい生活をする」なのだ。

このような経験を無数にしてきた。

そして一つの考え方を思いつく。

「Lv0を考える」だ。*1

自分がLv1だと思っているものは、100%思い込みである。

もっと手前のステップはなにか。もっと簡単にできないか。もっと階段を低くできないか。これをひたすら考えていくのが、何かを教える者の役目だろうと思う。

この考え方をプログラミング学習にも当てはめてみる。

「テンプレートエンジンを自作する」ことを目標にしたとしよう。

ここでいきなりオリジナルのテンプレートエンジンを書き始めるのはレベルの高い行為である。

高すぎる壁にどうしていいかわからず挫折してしまい、何も学べずに終わるリスクがある。

ではこのLv1はなんだろうか。例えば「既存のテンプレートエンジンを写経する」のはよさそうに見える。

そうすれば「写経したものを改造する」などのステップを踏める。

ここでさらに『Lv0は何か』と考える。

そもそも既存のテンプレートエンジンを触って使ってみる段階があることに気づく。

さらに、これから記述する言語・組み込みライブラリ等について学んでおくことが更に手前にあると気づく。

これから学ぶことのLv0は何か。最も優しい次のステップは何か。これが適切にわかると、行き詰まったり挫折するようなことが減るんじゃないかと思う。

これからも注意していきたい。

*1:Lv0は「何もしない」ではないか、というツッコミには「Lv0.1だと語呂が悪い」と返しておく

Redisで1000万件のデータを圧縮しつつ定期的に洗い替えする

概要

お仕事でRedisを触ってたので知見をまとめる。

Redisは高速はKVSだが、今回1000万件を超えるような大量のデータを扱った。

大量のデータをバッチで定期的に書き込んで、参照側では高速に返すシステムを考える。

バッチはユーザーの行動を『現在から1日以内にログインしたユーザー』のように時間区切りで条件検索している。そのため、検索する時間が変われば結果も変わるので、定期的に実行してデータを洗い替えている。 検索結果は1000万件あっても対応したい。 ユーザーがアクセスしてきたときにはこの検索結果の対象かどうか判断して結果を返したい。このユーザーからのアクセスは大量にあるため即座にレスポンスを返さなければならない。 洗い替えることによって使わなくなったデータは容量を空けるために削除したい。

クエリ結果はユーザーのidなので1947593459103940のような法則性の薄い数字の列である。

初期実装

クエリ結果を、定期的にRedisのSET型データとして書き込むことにする。 SET型ならString型よりも容量が小さくでき、後でデータを取得するときにも高速で今回の要件に向いている。 1000万件を一度に書き込むとデータ量が多すぎて、redisを長時間ブロックするかタイムアウトやsocketのエラーが発生する可能性が高い。 10万件程度なら0.1〜0.2s程の停止時間で済むので、ある程度の大きすぎない塊で書き込む。(chunk sizeはマクロパフォーマンスとマイクロパフォーマンスのトレードオフがあります。要件と相談しましょう)

redisredis/redis-rbRedisオブジェクトとする。

source.each_slice(100_000) do |chunk|
  redis.sadd("key", chunk)
end

ここでsourceは外部リソースからちょっとずつデータを取ってくるなにかとする。 実際にはtreasure-data/presto-client-rubyをラップして使っているが、file ioでもいいしなんらかのenumeratorでもとにかくeach_sliceが使えれば何でもよく、本質でないのでフワッとさせておく。

これで1000万件のデータだろうが時間を少しかけてRedisへ書き込むバッチができた。

del遅い問題

さて、この実装でリリースすると、すぐに問題が発生した。

一定時間毎にユーザーリクエストがタイムアウトを起こすようになった。

原因は、洗い替え時にredisのdelコマンドを使用していたことだった。 delコマンドでは手元で試したところ、100万件で0.5s、500万件では3sもかかってしまう。この間redisは他の仕事ができず、ユーザーからのリクエストを返すのが間に合わなくなったようだ。

delコマンドはもっと軽い動作かと思いこんでいたが、意外と重かった。 公式にもsetの場合はO(1)じゃないよと書かれている。https://redis.io/commands/del

解決策

この問題の最善の解決策はredis v4で追加されたunlinkコマンドである。

unlinkは、あのシングルスレッドが売りだったredisが削除用の別スレッドを追加して、redisの手が空いているときにバックグラウンドで削除処理をするというまさに求めているものだ。

しかし、このとき使えるredisはv3までだった。

なのでバックエンドで少しづつデータを消していく作戦にした。

class LazyFreeJob
  def perform(redis_key)
    loop do
      values = redis.spop(redis_key, 5000)
      break if values.empty?
      sleep 1
    end
  end
end

1 secに5000個、1 minで300K、1 hourで18M削除できる。 このtaskをsidekiq jobとして、データ削除時に起動することでなんとかしのいだ。

容量限界問題

しばらくはこれでしのげていたが、サービスが大きくなり扱うデータ量がどんどん増えていった。 そのため、予め用意していたredisインスタンスでは容量が足りなくなってきた。

インスタンスサイズを上げる手もあるが、それもまたすぐに超えていきそうな勢いだった。

ここで同僚のアドバイスによって『Redis入門』という書籍にシャーディングによって容量を削減するテクニックを知った。 ここでのシャーディングとは、複数インスタンスにリクエストを分割して負荷分散をすることではなく、一つのredis内で、redis keyを複数のredis keyに分割する方法であった。

redisのset型は普通はhashtableというencoding方法だが、全てintデータで一定個数以下なら、よりメモリ効率の高いintsetというencodingを使用している。

このencodingはobjectコマンドからでも確認できる。

> object encoding large-key
"hashtable"
> object encoding small-key
"intset"

item数がredis config set-max-intset-entriesを超えると自動的にencodingが変わる仕組みだ。ちなみにset-max-intset-entries以下に減ってもencodingはhashtableのままになる。(v4時点)

このintsetはhashtableに比べて、使用メモリー容量が劇的に少なくなる。代わりに挿入と参照が二分探査になるのでCPUを若干使用してしまう。

これがそれぞれどの程度なのか調べた。

ちなみにredisは手元のv4を使用した。

MEMORY USAGE

set型のkeyに1つitemをsaddで追加する毎に、memory usageでメモリー使用量を測定した。

f:id:ksss9:20181202144658p:plain
redis memory usage by size (0-3000)

set-max-intset-entries=512なので512付近で急激にサイズが上がっているのがわかる。 おもしろいのはset-max-intset-entriesを超えてからも急激な変化がある部分と、細かく増えたり減ったりとギザギザしている部分だ。

redisのhashtable型はおそらくsrc/dict.{h,c}に書かれたdict structが使われているようだけど、詳しくはまだわからない。 普通のいわゆるhashtableな実装を考えると、rehashの影響かな?だとしたら減るのはなぜ?今度深く調べてみたい。

set-max-intset-entries=512の場合、item数512では1079 byteだったが、item数513では23212 byteと20倍程度も差がつくことがわかった。 アプリケーション側で制御して、全てのitemをset-max-intset-entries以下に分割すれば、最大で容量が1/20になることが期待できる。これは大きい。

CPU USAGE

CPU usageはどうだろうか、容量が1/20になってもCPUは20倍使うのでは話にならない。 read時にinsetとhashtableでどれだけ差があるのか調べてみた。

intsetは512個のitemで満たし、全itemに対してまんべんなく100回の経51200回sisimemberコマンドをローカルのredis serverに対して発行した。(ruby環境でhiredisも使用)

- intset hashtable
all hit 3.1389 3.0812
all miss 3.1321 3.0403

だいたい3%ほどの差が現れることがわかった。 hashtableのitems数はいくら増やしても差が現れなかった。 これぐらいの差であれば実用に耐えそうだ。

実装

状況

  • 参照側にダウンタイムを起こさないようにしたい。
  • 書き込み側は一定期間毎にある更新に間に合えば良い
  • 入力されるitemはある程度以下の数字というだけで他に法則性はない
  • 入力されるデータの総個数は入力が完了するまでわからない
  • 入力されるデータの内容・数共に、洗い替え毎に変わる可能性が高い。

全てのデータを最大512個に分割する方法

リアリティのため、set-max-intset-entries=512の場合で考える。(現在もこの値でproduction運用している)

分散するデータを入れる1 redis keyを1 shardとする。 1 shardに512個までitemが入るのだから、例えば10M itemなら10M / 512 = 19531.25個のshardが最低限必要になる。

数字に法則性がある場合はmodをとることで簡単にitemを分散できるが、今回は法則性がない上にitemの総個数もわからない。

まず総個数は前回の値を使うことにする。分散前の総個数はscardで求める事ができる。この総個数を元にitemを振り分けることができそうだ。

しかし、たとえ総個数がわかっても今回のデータでmodをとると、各shardのitem数に偏りが生まれる可能性がある。512を超えた途端にかなり容量が増えてしまうので、512を超えることはできるだけ避け、各shardに均一にitemをふりわけたい。

ここで、一般的なhashtableの考え方を参考にする。 入力文字列をある程度均一にhash化して、総shard数でmodを取れば、各shardに均一にitemが行き渡るはずだ。 hash関数は無数にあるが、今回はcrc32という関数を使った。rubyでは標準添付ライブラリーzlibから利用できる。

Zlib.crc32(value.to_s) % size

この計算方法を使えば、総shard数を基準に各値ごとにある程度均一にデータを割り振ることができるはずだ。

洗い替えをしたいので、現在のshardingとこれから作るshardingの2つを同時に扱えるようにしたい。よってshardingを管理する小さなclassを作ろう。

shardingするということは複数のredis keyを生成・管理することでもある。この分散された数字からredis key文字列が作れるようにしたい。

実装

これらの要望をclassにまとめた。

class RedisShardingKey
  attr_reader :size

  def initialize(base_key:, size:)
    @base_key = base_key.to_s
    @size = size.to_i
    raise ArgumentError, "negative size" if @size < 0
  end

  def redis_keys
    @size.times.map { |i| redis_key_by_shard_id(i + 1) }
  end

  def redis_key(value)
    return nil if @size.zero?
    redis_key_by_shard_id(shard(value))
  end

  private

  def redis_key_by_shard_id(shard_id)
    "#{@base_key}:#{shard_id}/#{@size}"
  end

  def shard(value)
    return 0 if @size.zero?
    Zlib.crc32(value.to_s) % @size + 1
  end
end

このclassにredisのkeyになるprefixと総shard数を与えると、redis_keymethodを使えば、何らかの値を引数に分散されたredis keyを生成してくれる。 このshardingが管理する全redis keyを取りたいときもあるので、値に関係なく総shard数から考えられる全てのkeyを生成するredis_keysも用意した。 redis_key_by_shard_idで行っているkeyの名付け方は、shard_idさえ入っていればお好みで良い。今回は総shard数をsuffixにつけた。

要件から、データの洗い替え中は古いデータを読み取ってもらい、新しい洗い替えデータが準備できたたら参照を新しい方に切り替える。

さらに、これらshardingをまとめて総数や総sharding数を覚えておく必要がある。

これは普通にDBのレコードとする。

CREATE TABLE `entries` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `base_key` varchar(255) NOT NULL,
  `shard_size` int(11) NOT NULL,
  `total_size` int(11) NOT NULL,
  PRIMARY KEY (`id`)
);

これで、1レコードで1つのデータのまとまりを表すことができる。

今回の実装では複数の条件をクエリした結果を保存し、あるuserがそのうちどれに当てはまるのかをチェックし、結果を返す。

あるuserのidで複数のredisデータをチェックし、idが含まれるものを取り出せばよさそうだ。

あとはActiveRecordなりでデータを読み書きすればいい。

読み込み側

def entry_member_map(entries, id)
  redis = Redis.new
  res = redis.pipelined do
    # entries=複数の条件フィルター
    # 書き込み部分は後記
    entries.each do |entry|
      # idで分散されたredis keyが決まっているはず
      redis.sismember(entry.current_sharding.redis_key(id), id)
    end
  end

  result = {}
  entries.zip(res) do |entry, is_member|
    # 実際はアプリケーション要件で色々やってる
    result[entry.id] = is_member
  end
  # 条件フィルター毎のredis内の結果のmapが手に入る
  result
end

書き込み側

class Entry < ApplicationRecord
  # バッチ部分
  def refresh
    redis = Redis.new
    current_shard = current_sharding # 現在のshardingを表す
    stay_shard = stay_sharding # これから作るshardingを表す
    total_size = 0

    source.each_slice(100_000) do |chunk|
      total_size += chunk.length # 総量を数えておく
        redis.pipelined do # 効率化のためpipelinedを使う
          chunk.group_by { |id|
            stay_shard.redis_key(id) # shardとして割り振った数でまとめる
          }.each { |key, shard_chunk|
            redis.sadd(key, shard_chunk) # ある程度まとめて書き込む
          }
        end
    end

    # 今回の情報を次回の参考にする
    self.total_size = total_size # 総数から次回のshard数が決まる
    self.shard_size = stay_shard.size # shard数を元に、書き込みkeyと読み取りkeyの整合性をとる
    self.base_key = stay_base_key # 洗い替えたkeyに切り替える
    begin
      save! # ここで読み込み側が切り替わる
    rescue
      # 失敗なら準備していたデータを消す
      LazyFreeJob.perform_later(stay_shard.redis_keys)
    else
      # 成功ならこれまで使っていたデータを消す
      LazyFreeJob.perform_later(current_shard.redis_keys)
    end
  end

  # `redis-cli> CONFIG GET set-max-intset-entries`に合わせる
  SET_MAX_INTSET_ENTRIES = 512

  # 現在のshardingの状態
  def current_sharding
    RedisShardingKey.new(
      base_key: base_key, # dbから引いているだけ
      size: shard_size # dbから引いているだけ
    )
  end

  # 仮のshardingの状態
  def stay_sharding
    RedisShardingKey.new(
      base_key: stay_base_key, # 現在の名前と同じにならないようにする
      size: stay_shard_size # 予想値
    )
  end

  private

  # shard数を仮に決める
  # 1.2の理由は後で書く
  def stay_shard_size
    [1, (total_size.fdiv(SET_MAX_INTSET_ENTRIES) * 1.2).ceil].max
  end

  # これがredis key名の名付け法則になる
  def stay_base_key
    "entry:#{id}:#{stay_suffix}"
  end

  # g <=> bと交互に入れ替わるようにする
  def stay_suffix
    return "g" unless base_key
    return "b" if base_key[-1] == "g"
    "g"
  end
end

削除jobも更新する。

class LazyFreeJob
  def perform(*redis_keys)
    redis_keys.each do |redis_key|
      redis.del(redis_key)
      sleep 0.1
    end
  end
end

読み込み側

読み込み側から解説する。

entriesはクエリとその結果を表すEntryインスタンスの配列だ。今回のsharding実装のためのデータも組み込まれている。

Entryからは「現在のsharding」と「仮想のsharding」を表すオブジェクトが生成できる。

それぞれのオブジェクトでは、idを与えてやると、redis用のkeyを生成してくれる。

このredis用のkeyはidによって一意に決定されているので、目的のidが含まれる可能性のあるkeyということになる。

後はsismemberなりでidが含まれているのかいないのか、結果を取得すればいい。

entriesは100を超える場合もある。結果はpipelinedでまとめて取得すればパフォーマンス的にも問題はない。

書き込み側

書き込み側は主に「仮想のsharding」を扱う。

仮想のshardingは前回の総データ数の1.2倍のデータが次回来るだろうと仮定する。

なぜ1.2倍なのかと言うと、hash関数のゆれ具合のためである。

intsetからhashtableに変わるしきい値set-max-intset-entriesを1でも超えるとデータ容量がかなり増加する。

この特性のために、データの総数は多めに見積もっておいたほうが総データ容量は小さくなる可能性が高い。

hash関数のゆれ幅が完全に均一なら、データの総数から分割するsetの数を完全に計算可能になるが、今回選択したcrc32は速度重視のためそのような特性はない。

各shardにはあるていどばらついた数のデータが入る。

これは実測した結果、±15%以内にほぼ全ての結果が収まることがわかった。これをさらに多めにとって20%と仮定したため1.2倍を総数に掛けている。

redisへのデータの入れ方はできるだけ少ない通信回数である程度まとまったデータを入れるほうが効率がいい。ー効率がいいということはredisのCPUを使うことになるので、書き込みを早くするのか読み込みを早くするのか、バランスは要件によるー。

今回は10万件ずつsourceから取り出したデータを、まずredis key毎にまとめてしまい、redis key毎にsaddで書き込んだほうが書き込み効率が良かった。こういうときにArray#group_byは便利だ。

あとはクエリ結果のデータ総数を数えておいて保存すれば、次回の仮想shardingを作るときの参考値となる。

shard_sizeは今回のshardingを生成する際に基準となる大事な数だ。これは今回使用した仮想shardingが例の1.2倍にした計算を最初にしてくれていているはずだ。これも保存する。

最後にredis keyのprefixとなる文字列を保存する。

ポイントは実際のdbへのcommitは最後に行うことだ。 読み込み側は常にdbのデータを参照している。 dbへcommitした瞬間に、読み込み側は見るべきredis keyを変更する。 これにより、読み込み側はダウンタイムなしに参照するデータを洗い替えることが可能になる。 これで「仮想のsharding」が「今回のsharding」にすり替わり洗い替えが完了した。 あとはかつて「今回のsharding」だったオブジェクトからredis keyを取り出し、削除すればいい。

削除job

削除jobはシンプルになる。

これまでspopをつかって少しづつデータを削除していたが、これは返り値を伴うので、データの削除方法としてはかなり効率が悪い。

今回はshardingによってデータがある程度のまとまりに分割された。ここまで分割された数ならdelコマンドでのダウンタイムはかなり小さくなっているし削除効率も高い。結果的にCPU効率がよくなる。sleep値は削除数が大きく変動しないように調整した。

まとめ

この実装により、1000万件を超える大量のデータの洗い替えを、redisで実現することができた。

現在は1000万件などとっくに超えており、約6000万件のデータを約5GB程度で運用できている(他のデータも入っていたり時間によって変動するので正確なところは不明瞭)。

さらに最初からshardingしたり、redis v4に乗り換えてdelコマンドをunlinkに置き換えることもできている。

CPUは大量データの書き込み時に増加するが、ピークで30%程度、平時5%程度である。

記事が長くなりすぎて内容が伝わってるか謎だが、所詮個人の日記である。この辺で締めたい。

参考文献

「Redis入門」という本が大いに役に立った。今回使ったshardingテクニックなどが載っているので非常に参考になった。

https://www.amazon.co.jp/dp/4048917358


おまけ

set-max-intset-entriesの変化による影響

intsetに効果があることはわかった。 ではset-max-intset-entriesの最適値はどこだろうか? おそらくmemoryとcpuのトレードオフ関係があるので最適な値はシステムの要求によるだろう。 よってset-max-intset-entriesを変化させることでどういう変化があるのか調べてみた。

memory usage

set-max-intset-entriesを1024, 2048と変化させ、MEMORY USAGEと同じ方法でグラフを書いてみた。

set-max-intset-entries=1024

f:id:ksss9:20181202170722p:plain
set-max-intset-entries=1024 memory usage

set-max-intset-entries=2048

f:id:ksss9:20181202170906p:plain
set-max-intset-entries=2048 memory usage

なぜか2048のときはhashtableに変わってからも512や1024のときより消費量が少ない。おそらく512のときに見られたようなhashtableでも大きいときや小さいときがある内の小さい場合が2048〜3000の間に起こっているだけで、さらに値を大きくするとまた段差が現れるものと思われる。

cpu usage

これも512のときと同じく、set-max-intset-entries分を1つのkeyに満たして、51200回アクセスした。

- 1024 2048 4098 8192
all hit 3.1839 3.1452 3.1679 3.1823
all miss 3.1075 3.1386 3.2063 3.1317

なんと、かなり大きくしてもほとんど変化がないことがわかった。

おまけのまとめ

set-max-intset-entriesが大きくなればsharding数を小さくできる。

sharding数が小さければ、keyコマンドが効率化したり、Ruby側で扱うオブジェクト数が減ることが期待できる。

production環境では導入していないが、1024や2048にしてもいいかもしれない。

rui312/9ccを写経する-その5

https://github.com/rui314/9cc/commit/e89595ac2097981b55e4e287d07f8a3e62a6b02c

map_existsの実装。次への布石か。

https://github.com/rui314/9cc/commit/42e403e3de0c6457bc11ab14c55a9dad27ed82be

変数の追加。よし、これも自分でやってみよう。

...(1ヶ月後)

うん、完全に詰んだ。tokenやparseまでは=+-みたいに扱えばいけそうな気がしたけど、 register周りの理解が甘くて変数をregisterに入れたらいいのかmapに入れたらいいのか、入れるとしてどうやって取り出すのか、わからなくなってしまった。

ここから進まなくなってだいぶ間が空いたのでギブアップ。答えを見てみよう。

token.c

struct Tokenにnameを追加している。そうそう、変数名で参照するから、どうしても文字列を保持しておかないといけなかった。 だいたい想像通り。

parse.c

変数割当になる=を処理する段階の追加と、これまで数字リテラルだけをnode化していた部分を変数も対応するようにしている。 Nodeにnameメンバーを追加して、変数の場合は変数名をnode->nameに渡している。 これもまあ想像通り。さあここから。

ir.c

処理順に下から見る

gen_ir

regnoはグローバルにするのは他の関数でも使いたくなったんだろう。 baseregとは? varsはmapになっている。このレベルでmapを使って変数を解決するのか。どうやるんだろ。 bpoffとは? IR_ALLOCAとは?わからん。

gen_stmt

使わない値を-1にしているだけっぽい。

gen_expr

タイプが変数だったらgen_lvalを読んでいる。変数としての処理なんだろうか?そしてIR_LOAD命令を追加している。変数だったらとりあえず中身を見るようにしている? タイプが=だったら右辺を処理して命令を生成しておく。それから左辺を変数として処理する。IR_STOREはおそらく変数の代入に相当する命令だろう。gen_lvalが気になるところ。

gen_lval

(...さらに1ヶ月後)

うーんこのへんで挫折。続かなかった。変数処理を理解するまでには至らず。 いずれは理解したいけどこのままだといつまでたっても新しいエントリーが書けないので一旦ここで閉める。。。

rui312/9ccを写経する-その4

https://github.com/rui314/9cc/commit/b8b3ab51ad9372a2b9f963650bbf14e714b87b85

Add Map data structure.

お、みんな大好きMapさん。これは楽しみだ。 hash関数を使うのか、open addressなのかlinked-list方式なのか!?

typedef struct {
  Vector *keys;
  Vector *vals;
} Map;

ずこー!

まあ9ccのシンプル一辺倒方針を考えればこれしかないとも言える。

Arrayを2つ並べただけ。おそらくget時はkeyを線形検索して同じindexのvalsを取る的な感じだろう。

main.c

ここでtest用のオプションが追加されている。オプションの形式はgolangとかimagemagickとかと同じ形式(由緒ある名前があるんだろうけどわからん)。

util.c

Mapの実装は想像通り。get時の検索が逆側からの検索になっている。最近セットしたものをよく使うだろうということなのかな?

if (!strcmp(map->keys->data[i], key))

個人的には、わかりやすさを目指すなら

if (strcmp(map->keys->data[i], key) == 0)

と書きたい。が、Cを書く人にはこっちのほうがわかりやすいんだろうか。

util_test.c

util系のユニットテストも書いている。この規模ならいらない気もするが。

ところでmallocは出てくるがfreeが出てこない。

この規模ならまだいらないんだろう。

https://github.com/rui314/9cc/commit/f8a1d0c6903dfe9ab816e719c70efdb27d6f0a4d

いよいよ文法が追加される。return;を追加するようだ。

token.c

なるほど、ここでmapをつかって予約語=keywordを登録する。 isalphaなどを使って続いている文字を読み取って、登録済みの予約語ならtokenを追加する。

ここまで書いてみて、ハッと気がついた。 これはお題(test.sh)を見て、先に自分の考える実装をやってみてから答え合わせ的に写経したほうが学びが深そうだ。 やってみよう。

parse.c(独自実装)

早速困った。return文をどうnode構造として解釈したらいいんだ。

テストを見るに、returnがついていたらそこで試合終了。計算結果をプロセスの終了ステータスにしたい。

この計算結果は既存の関数でできそうだ。

問題は1+2; return 3+4;みたいなパターン。

既存の考えのままだと、nodeの構造が数字と演算子しかないのでreturnとそうでない文を表現できない。

ここで音を上げて、早くもカンニングしてしまう。

なるほど、returnがないnodeとreturnのnodeを作って、これを計算結果の親としてnodeを組み上げれば良いようだ。

parse.cに

  for (;;) {
    t = tokens->data[pos];
    if (t->ty == TK_RETURN) {
      pos++;
      node = new_node(ND_RETURN, expr(), NULL);
      break;
    } else {
      node = new_node(ND_NORETURN, expr(), NULL);
    }
  }

のようなコードを追加した。

ir.c(独自実装)

noreturnなnodeは、変数もない現状では全く無駄なので、計算もせずに単純に飛ばせばいい。

  while (node->ty == ND_NORETURN) {
    node = node->lhs;
  }

あとはreturn nodeだけのはずなので、nodeの小要素でこれまで通り計算するだけ。

  assert(node->ty == ND_RETURN);
  node = node->lhs;
  int r = gen(v, node);

なんと、これだけで動いてしまった。楽しい。答え合わせもしてみよう。

token.c(答え合わせ)

これはすでに見ていたのでまあOK。答えを見てなかったらmapにkeywordを入れることを思いつけたかどうか……。

parse.c(答え合わせ)

typeが想像より多い。

  • ND_COMP_STMT => 全てのroot
  • ND_RETURN => return文
  • ND_EXPR_STMT => returnが無い文

という感じか。

Node構造体もメンバーが追加されている。

  • stmts => nodeは木構造のはずだが、配列としても用意している。なんでだろう?debug用か?
  • expr => 予想では計算結果をlhsに詰めていたが、専用にメンバーを用意したんだろう。やや富豪的。わかりやすさ重視かな?

ir.c(答え合わせ)

これまで引数で引き回していたVectorをファイルスコープの変数codeに変更している。Cはファイルスコープの変数やら関数が便利だよなあ。

ND_RETURN``ND_EXPR_STMTは予想通りとして、ND_COMP_STMTはなるほど、各文は木構造ではなく、素直に配列として持つことにしているようだ。

ここまでやって思ったが、ここまでで12 commit見てきた。 このペースでは200 commit以上を見ていくのに果てしなく時間がかかってしまう……。

飽きてきたら更新辞めると思います。

rui312/9ccを写経する-その3

https://github.com/rui314/9cc/commit/9d4e20421f140f9ad7a1d161daab088008aa5760

Fix warnings.

とのことなので大した変更ではなさそうだが、stdnoreturn.hnoreturnは知らなかった。

返り値のないvoidな関数にreturn;があってもコンパイラは特に何も言わないが、noreturnをつけていると、return;があったら

9cc.c:87:3: warning: function declared 'noreturn' has a 'return' statement
   return;
   ^~~~~~

みたいな感じで教えてくれるようだ。

https://github.com/rui314/9cc/commit/52dc210c9d22171de630eb78a3946d23b0dc727d

ぬー、またデータ構造を追加する割にはテストに変化がないのでメリットが見えないパターンっぽい。 がんばっていこう。

typedef struct {
  void **data;
  int capacity;
  int len;
} Vector;
Vector *new_vec()
void vec_push(Vector *v, void *elem)

どうやら自動拡張する配列のようだ。

なぜこう判断できたかというと、mrubyで見たことあるから、という他なくうまく説明できない……。

(void *)としているのは任意の型のポインタを使えるパターンで、pushしてlenが1増えて、capacityに到達したらcapacityを2倍にしてreallocというのが自動拡張配列の典型パターンというイメージ。

こういうデータ構造はあると何かと便利なんだろう。Rubyでも最終的なサイズとか意識せずに、Arrayに適当にオブジェクトを突っ込んだりできて便利だ。

Token *add_token(Vector *v, int ty, char *input)

なるほど、察しはついた。これまでtokens[100]と適当に100 tokenまでとしていたものを、先程の自動拡張配列Vectorを使ってtokenの拡張を自動化するようだ。

Vector *tokenize(char *p)

ここまでくれば変更は予測できる。

新たにVectorを作って、tokensを使っている部分をadd_tokenに置き換え、最後にVectorを返せば、 任意の長さのプログラムに合わせていい感じにメモリを確保した配列が手に入る。

Vector *tokens;
int pos;

Token tokens[100]は廃止されVector *tokensとして生まれ変わった。

posは0初期化しなくていいのかな?と思ったけど、グローバル変数は勝手に0初期化された気がしなくもない。

Node *number()

tokensの構造は変わったので、使っている部分を書き直す。

ついでにパースエラーだったときはposを変更しないように修正しているっぽい。

int gen_ir_sub(Vector *v, Node *node)

引数にVectorを追加。ins[1000]を廃止して自動拡張配列に置き換えるんだろう。

tokensはグローバル変数のままなのに、insはグローバル変数ではなくするようだ。

regnogen_ir_subでしか使ってないので、グローバル変数からstatic変数に変更して影響範囲を減らしている。

あとはins[inp++]となっている部分をvec_pushに置き換えるだけ。

Vector *gen_ir(Node *node)

ここでVectorを作ってgen_ir_subに渡してIR_RETURNを最後に追加してVectorを返しておしまい。

- bool used[8];
+ bool used[sizeof(regs) / sizeof(*regs)];

決め打ちだった8をregsの長さに合わせている。

- int reg_map[1000];
+ int *reg_map;

1000個決め打ちを止めている。

このサイズは元はinsの1000と合わせていた。

insの長さが可変になったので、insの代わりになったVectorからlenを引いてくればサイズは決めれそうだ。

void alloc_regs(Vector *irv)

引数が増えた。

insが廃止されたので、gen_irで作ったinsの代わりになるVectorを渡しているのだろう。

ならばinp => vector->len, ins[i] => vector->data[i]と置き換えればいいだけだ。

……、と思ったらreg_mapも決め打ちじゃなくなったんだった。

alloc_regsの外で初期化してもいいが、-1allloc_regsでしか使ってないので、alloc_regsの中で初期化したほうがわかりやすくなりそうだ。

reg_mapグローバル変数を辞めて関数間で渡したほうが更に良くなりそうだが、いずれやるんだろう。

void gen_x86(Vector *irv)

gen_x86でもinsを使っていたので、代わりになるirvを与えている。

新たに構造体が増えたが、総じてリファクタリング的な内容だった。

https://github.com/rui314/9cc/commit/ce9a6bbfbd430332666c64cd8b433043c45a6044

概念毎にファイルを分けているだけ。と思ったら少し変更がある。

tokensグローバル変数をやめてtokenize関数の返り値にしている。

かと思いきやtoken.cのファイルスコープのグローバル変数tokensは存在していて、新関数parseグローバル変数tokensにtokenizeで作った変数をそのまま割り当てている。どういうこっちゃ。

んーオブジェクト指向で考えてはいけなくて、ファイル毎にスコープ切れてるからいいじゃん的な考え方ということか。

まあこれはこれで簡素で便利と言える。

Vector系関数はutil.cなので、やはり便利構造体ということなんだろう。

https://github.com/rui314/9cc/commit/e3cda6b3777ad6937dcf51faddbbbeea733a1048

掛け算の導入のようだ。 これまで上から見ていったが、ファイル分割によってどこから見ていけばわからなくなった。

とりあえずtest.shからみて、token -> node -> irと処理順に見ていこう。

test.sh

ようやく新しいことができる。まずは掛け算っぽい。

token.c

if文の||で一つずつ書いていた部分をstrchrで纏めてしまっている。

parse.c

exprnumberの間にmul関数の層を増やしている。

これによって、mulでは掛け算があれば掛け算のNode構造を作り、掛け算がなく加減算ならばそのまま数字を返す。

つまり掛け算を優先して計算するようなNodeの木構造を作っている。んーこのへんはむずい。

難しいがとりあえず計算の優先順位は関数の層を追加して、Nodeの構造を作ってやれば、あとはir層で再帰的に処理してくれる。

なるほど、ここでようやくNode層を作ったメリットが現れた。

codegen.c

    case '*':
      printf("  mov rax, %s\n", regs[ir->rhs]);
      printf("  mul %s\n", regs[ir->lhs]);
      printf("  mov %s, rax\n", regs[ir->lhs]);
      break;

アセンブラでの掛け算はaddsubと違って、レジスタが固定されているっぽい。

参考文献としてはhttps://qiita.com/MoriokaReimen/items/4853587dcb9eb96fab62http://bttb.s1.valueserver.jp/wordpress/blog/2017/10/22/assembler_mul/がみつかるが、一次ソースはどうすれば見つかるんだろう……。

https://github.com/rui314/9cc/commit/4cd8eedb7c352380828e664950dff97a83eaeef8

割り算の追加。殆どは問題なく*のときと同じだ。 mul関数が名前がそのままなのが気になる。

    case '/':
      printf("  mov rax, %s\n", regs[ir->lhs]);
      printf("  cqo\n");
      printf("  div %s\n", regs[ir->rhs]);
      printf("  mov %s, rax\n", regs[ir->lhs]);
      break;

divはともかくcqoがググっても全然わからん……。

符号を云々なもののようだが、これがないとコンパイラFloating point exceptionと表示されてしまう。うーむ。

ともかくこのコンパイラは割り算までできるようになった。

rui312/9ccを写経する-その2

https://github.com/rui314/9cc/commit/2f62e5267a1c2874dcfa674cf8654e0cb3f189d6

コミットコメントが仰々しい感じがする。

test.sh

とりあえずここを見れば次に実装することがわかる。 コードを見る限り、そこまで追加機能はないようだが……?

int pos = 0;
enum {
  ND_NUM = 256,     // Number literal
};
typedef struct Node {
  int ty;           // Node type
  struct Node *lhs; // left-hand side
  struct Node *rhs; // right-hand side
  int val;          // Number literal
} Node;

posはプログラムの読み込み位置かな?NodeはTokenとどう違うんだろう?

Node *new_node(int op, Node *lhs, Node *rhs)
Node *new_node_num(int val)

んーまだよくわからない。

Node *number()

数字のtokenから数字のnodeを作っている?

Node *expr()

再帰的なNodeを作っているようだ。

char *regs[] = {"rdi", "rsi", "r10", "r11", "r12", "r13", "r14", "r15", NULL};

んーアセンブラの変数(レジスタ)の名前列?これは予約語なのかなあ、単純に数字が増加しているだけではないし、よくわからない。 アセンブラを少し学ぶ必要がありそうだ。

https://qiita.com/edo_m18/items/83c63cd69f119d0b9831

によるとrdiシステムコールに使われるレジスタ。でも他はそうではないっぽい。

http://milkpot.sakura.ne.jp/note/x86.html

によると汎用レジスタの一覧にすべて収まる。

おそらく、システムコールを使うときにはrdiは意味を持つが、そうでなければ汎用的に使えるレジスタ、ということかな? レジスタは、とりあえず8個使うようだ。

char *gen(Node *node)

nodeを再帰的にたどって、node毎に利用するレジスタを割り当てるっぽい。 そのうえで、node->tyからアセンブラの足し算引き算コードを作り出す。 レジスタはnodeに割り当てられたものを使う。

  printf("  mov rax, %s\n", gen(node));

これはつまりトップレベルに割り当てられたレジスタを終了ステータスレジスタに割り当てているので、 最終的にこのレジスタに全ての計算結果が返ってきているはずということか! coolだ。

ここまで写経してきて、ふと思い立って1+2+3+4+5+6+7+8の出力アセンブラを見比べてみた。

before

.intel_syntax noprefix
.global main
main:
  mov rax, 1
  add rax, 2
  add rax, 3
  add rax, 4
  add rax, 5
  add rax, 6
  add rax, 7
  add rax, 8
  ret

after

.intel_syntax noprefix
.global main
main:
  mov rdi, 1
  mov rsi, 2
  add rdi, rsi
  mov r10, 3
  add rdi, r10
  mov r11, 4
  add rdi, r11
  mov r12, 5
  add rdi, r12
  mov r13, 6
  add rdi, r13
  mov r14, 7
  add rdi, r14
  mov r15, 8
  add rdi, r15
  mov rax, rdi
  ret

んー違いはあるけど恩恵はまだなさそうな感じがする。次行ってみよう。

https://github.com/rui314/9cc/commit/dca0fdb71df98d8500170ff8178eaac0c8f9edaa

test.sh

try 153 '1+2+3+4+5+6+7+8+9+10+11+12+13+14+15+16+17'

んーこれまたhttps://github.com/rui314/9cc/commit/916237396164ae25557b62bf0d1ca9d9cf8c2070からのメリットが見えない感じ。 https://github.com/rui314/9cc/commit/2f62e5267a1c2874dcfa674cf8654e0cb3f189d6から予想できるのは、おそらくレジスタを有効に使って計算が長くても対応できるようにしそうなこと。

// Intermediate representation
enum {
  IR_IMM,
  IR_MOV,
  IR_RETURN,
  IR_KILL,
  IR_NOP,
};
typedef struct {
  int op;
  int lhs;
  int rhs;
} IR;

名前からして"内部表現"……?token -> node -> irという変化だろうか? mrubyでの経験に当てはめると、MOVで変数をレジスタに割り当てたり、RETURNで関数の返り値を決めれるようにしたりという感じかな? IMMはimmediateで即値を表す?KILLは分からない。NOPは何もしない命令っぽい。

データ構造からして、命令と対象と引数でアセンブラと言うか機械命令語っぽい感じ?

IR *ins[1000];
int inp;
int regno;

Intermediateが複数のsでinsかな?これもtokens[1000]と同じく雑に1000個空間を用意しているっぽい。 同じくinsのポインタpでinp、regnoレジスタナンバーというところかな?

int gen_ir_sub(Node *node)

名前からしてirを作るサブ関数っぽい。 関数の中も見ていく。

  if (node->ty == ND_NUM) {
    int r = regno++;
    ins[inp++] = new_ir(IR_IMM, r, node->val);
    return r;
  }

レジスタ番号regnoとnodeの値node->valをIMM命令としてinsに登録してポインタinpを進める。 多分レジスタrにnode->valを割り当てるんだろうと思う。 割り当てたレジスタ番号を返すのはなんでだろう?あとでアセンブラとして書き出すのかな?

assert(node->ty == '+' || node->ty == '-');

Cのassertって個人的には好きで、バグ検知しつつここから先のコードの制限というか説明になっていて良い。 Rubyでいうとraise unless expr だと思ってる。

  int lhs = gen_ir_sub(node->lhs);
  int rhs = gen_ir_sub(node->rhs);
  ins[inp++] = new_ir(node->ty, lhs, rhs);
  ins[inp++] = new_ir(IR_KILL, rhs, 0);

node->tyは'+'とか'-'とかが入っている。これもirの命令として流用するようだ。 gen_ir_subを再帰的に使っている。たしかnode自体が再帰的にツリー状になっていたはず。 返り値はレジスタ番号だったので、lhsrhsレジスタ番号だろう。 足し算引き算の左右の値をirで纏めて、おそらく後でアセンブラに変換するんだろう。 ここでKILL命令。おそらく再帰の終わりを表している?まだわからない。

int gen_ir(Node *node) {
  int r = gen_ir_sub(node);
  ins[inp++] = new_ir(IR_RETURN, r, 0);
}

これまでのgen_ir_subと違うのは、最後にRETURN命令をつけていることだけ。おそらくこれまで再帰的に処理してきた全結果が渡ってきたレジスタ番号を、関数の返り値として設定するんだろう。

char *regs[] = {"rdi", "rsi", "r10", "r11", "r12", "r13", "r14", "r15"};
bool used[8];
int reg_map[1000];

regsは前回と変わらない。 usedとreg_mapから察するに、レジスタ番号それぞれを、使ってるor使ってないで管理するためのものなんだろう。 reg_map[1000]はinsとかに1:1で対応するんだろうか?

int alloc(int ir_reg)

mallocやallocaを連想させる名前。未割り当てのレジスタを返してほしい関数かな? 引数はレジスタ番号?中身も見ていく。

  if (reg_map[ir_reg] != -1) {
    int r = reg_map[ir_reg];
    assert(used[r]);
    return r;
  }

if文からir_regは0〜999の数字なんだろう。-1はまだ未登場の値。未使用を表すのかな? assert文から察するにused=trueのレジスタ番号rを返すっぽい。used=trueでいいのか?

  for (int i = 0; i < sizeof(regs) / sizeof(*regs); i++) {
    if (used[i])
      continue;
    used[i] = true;
    reg_map[ir_reg] = i;
    return i;
  }

regsは8つのレジスタだった。これをループで回す。条件はregs[i]でもいい気はする。もしくはusedで8って決め打ちしちゃってるしi < 8とか……。 まあわかりやすさを重視した結果かな? used=trueなら飛ばして次へ。used=falseならused=trueにしてreg_map[ir_reg]に使っているregsのindexを割り当てて番号を返す。 さっき出てきたreg_map[ir_reg]には-1は割り当てられえない。あとで初期化するのかな?それともfree的な関数が出るのかな? ともかくこの関数が返すのは、regsのindex番号のようだ。 0〜999のなにかしらの番号ir_regが与えられた時、使えるregsのindexを返す。このindexからアセンブラを書き出すんだろう。

void kill(int r) {
  assert(used[r]);
  used[r] = false;
}

usedフラグをoffにするだけ。レジスタの再利用のためかな。

void alloc_regs()

長いので見ていこう。allocとどう違うんだ。

  for (int i = 0; i < inp; i++) {
    IR *ir = ins[i];

inpはinsのindexだった。これまで進んだins全てでループしているっぽい。

    switch (ir->op) {

命令毎になんかする。

    case IR_IMM:
      ir->lhs = alloc(ir->lhs);
      break;

allocはとにかく使えるregsのindexを返す関数(という予想)だった。 IR_IMMgen_ir_subで出てきた。lhsはregnoというインクリメントする謎の値だった。 この時点では使えるかどうかわからないから使えるレジスタindexを割り当てているんだろう。 ここから、おそらくalloc_regs関数はalloc関数のirレベルで適用するものと予想。

    case IR_MOV:
    case '+':
    case '-':
      ir->lhs = alloc(ir->lhs);
      ir->rhs = alloc(ir->rhs);
      break;

IR_MON'+''-'が一緒になっているのに面食らったが、おそらく全て左右の2つのレジスタを使う命令なんだろう。

    case IR_RETURN:
      kill(reg_map[ir->lhs]);
      break;

IR_RETURNは関数の返り値と予想している。killは使ってないフラグを設定するんだった。 関数の返り値は再利用すると思われるんだがkillするからには再利用はしない。予想は外れていることになる。 これまでのcommitからアセンブラの最後のプロセスの終了ステータスを設定するやつか?

    case IR_KILL:
      kill(reg_map[ir->lhs]);
      ir->op = IR_NOP;
      break;

IR_KILLgen_ir_subの最後に登場していた。 例えば足し算では左と右のレジスタの値を足して左のレジスタに入れる。そうすると右側のレジスタは再利用可能になる。ということかな。

void gen_x86()

なまえからしアセンブラを吐きそうな感じだ(勘)。またまた見ていく。

  for (int i = 0; i < inp; i++) {
    IR *ir = ins[i];

alloc_regsと同じくinsのループのようだ。

switch (ir->op) {

ちなみに実行順はmain関数を見るにgen_ir->alloc_regs->gen_x86のようだから予想は合っている。 ここでの命令は全てalloc_regsが終わったあとの世界のようだ。

    case IR_IMM:
      printf("  mov %s, %d\n", regs[ir->lhs], ir->rhs);
      break;

IR_IMMは使えるレジスタ左に即値のnode-valが右に入っていた。 これでmov reg1 123のように出力されるんだろう。

    case IR_MOV:
      printf("  mov %s, %s\n", regs[ir->lhs], regs[ir->rhs]);
      break;

単純に右から左へのコピー。変数とかを使えるようにするための布石か? ならこのテストコードではこの命令自体がいらないような……。作るところもないし。

    case IR_RETURN:
      printf("  mov rax, %s\n", regs[ir->lhs]);
      printf("  ret\n");
      break;

これは予想通り最終的な結果をプロセスの終了ステータスに割り当てている。

    case '+':
      printf("  add %s, %s\n", regs[ir->lhs], regs[ir->rhs]);
      break;
    case '-':
      printf("  sub %s, %s\n", regs[ir->lhs], regs[ir->rhs]);
      break;

左右に使えるレジスタ番号が入っているので、ここで使えることが保証される。

    case IR_NOP:
      break;

IR_KILLとかが変化してIR_NOPになったものだろう。 何もしないことが便利なときもある。

int main(int argc, char **argv)

一応main関数も見ていこう。

  for (int i = 0; i < sizeof(reg_map) / sizeof(*reg_map); i++)
    reg_map[i] = -1;

やはりreg_mapallocで予想したとおり-1で初期化されていた。

  tokenize(argv[1]);
  Node* node = expr();

  gen_ir(node);
  alloc_regs();

  printf(".intel_syntax noprefix\n");
  printf(".global main\n");
  printf("main:\n");
  gen_x86();

やはりtoken -> node -> irと変換していき、レジスタ割当を行って、アセンブラを吐いておわり。

token = 空白など余計な文字を除いた文字の列

node = 構文的な計算順序の組み立て

ir = このプログラミング言語VM的なもの

ということかな。

んー、やはりテストコードからは、実装が先の世界に行き過ぎてる感がある。

アセンブラはこうなった

.intel_syntax noprefix
.global main
main:
  mov rdi, 1
  mov rsi, 2
  add rdi, rsi
  mov rsi, 3
  add rdi, rsi
  mov rsi, 4
  add rdi, rsi
  mov rsi, 5
  add rdi, rsi
  mov rsi, 6
  add rdi, rsi
  mov rsi, 7
  add rdi, rsi
  mov rsi, 8
  add rdi, rsi
  mov rsi, 9
  add rdi, rsi
  mov rsi, 10
  add rdi, rsi
  mov rsi, 11
  add rdi, rsi
  mov rsi, 12
  add rdi, rsi
  mov rsi, 13
  add rdi, rsi
  mov rsi, 14
  add rdi, rsi
  mov rsi, 15
  add rdi, rsi
  mov rsi, 16
  add rdi, rsi
  mov rsi, 17
  add rdi, rsi
  mov rax, rdi
  ret

rui314/9ccを写経する-その1

8ccという有名なCコンパイラがあるが、これを書いたrui314さんが新たに9ccというリポジトリを上げていた。

https://note.mu/ruiu/n/n00ebc977fd60 を読むに、これは8ccをさらにわかりやすく、Cコンパイラ自作の教材として作っているものに違いないと勝手に判断し、写経してみることにした。 写経なので、書いているコードは同じ。自分が理解していった記録をここに残す。

僕のスペックは、普段はRubyでWebアプリを書くお仕事をしている。 Cコンパイラは一切書いたことはなく、Brainf*ckぐらいは書いたことがあるレベルだ。

ではスタート。

https://github.com/rui314/9cc/commit/56e94442ae8844688d5390851e5b29ba0c946e2f

9cc.c

main関数でprintfしているだけのコードのようだが、いきなりアセンブラっぽいコードを発見。 アセンブラは一切書いたことがないのでこれが初アセンブラだ。

.intel_syntax noprefixググるアセンブラのお作法としての記法を宣言するコードのようだ。アセンブラには複数の記法があるのだろう。

.global main
main:

なんとなくだけど関数宣言みたいなものかな?Cで言うmain関数宣言みたいなものだろう。

  mov rax, %d
  ret

うーんこの辺は全然わからん。多分変数raxに数字を入れてreturn的な感じか?

Makefile

9cc: 9cc.c

なんとたったこれだけで cc 9cc.c -o 9cc taskを書いたことになるようだ。 *.c taskがdefaultで用意されてるのかな?深くはわからない。

test.sh

テストコードっぽい。

  ./9cc "$input" > tmp.s

これから作るCコンパイラ9ccに引数をあたえて標準出力を書き出す。 いまのところ単にアセンブラをprintfするコードだったので、tmp.sにはアセンブラのコードが書き出されるのだろう。

  gcc -static -o tmp tmp.s

これはmacでは実行不可能だった。-staticgcc特有のオプションっぽい? clangでも同じことができなくはないだろうけど、コードの読み替えなどはしたくなかったのでDockerでlinux環境を用意した。

Dockerfile

FROM ubuntu:18.10

RUN apt-get update
RUN apt-get install -y make gcc lld

docker-compose.yml

version: "3"
services:
  9cc:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./:/9cc
    working_dir: /9cc

これで写経したコードが

docker-compose run 9cc

で動くようになった。docker便利。

  ./tmp
  actual="$?"

おそらく出力されたアセンブラを元に、仮の実行バイナリtmpを作って実行しているんだろう。 $?はプロセスの終了ステータスだった。あのアセンブラは、終了ステータスをセットするものだったようだ。 この終了ステータスが入力と同じならOKとしている。

初っ端から初アセンブラを書いた。新言語習得である。

https://github.com/rui314/9cc/commit/916237396164ae25557b62bf0d1ca9d9cf8c2070

いきなり足し算引き算の登場。どうやって実装するんだろう。おそらくアセンブラコードを拡張していくはずだ。

9cc.c

まず最初に数字を読み込むが、strtolは初めて知った。 第一引数にポインタを渡したら勝手に文字列を数字として読み取ってくれるっぽい。 第二引数にポインタのポインタを渡すと、読み取り終わり位置までポインタが指す位置を進めておいてくれるようだ。便利。 テストコードから察するに、これで20のような2桁の場合でも読み込めるようだ。 次は1文字ずつ読み込んで、+だったらaddアセンブラ命令、-だったらsubアセンブラ命令を書き出すようだ。 たぶんこれで足し算引き算になるんだろう。

test.sh

写経通りやると動いた。 試しに123456のような数字を書いてみると、どうやら結果が255を超えることができない。 多分終了ステータスの最大値だと思われるが、このコンパイラは現状255が限界のようだ。

https://github.com/rui314/9cc/commit/42adde9b5e0ed09c0d76bb84da38d136be8390c1

おそらく空白を入れることができるようにする変更。これまでは空白文字を入れるとSEGVしていた。 ついでにtokenizeということで、空白を除去したプログラム構文を作り出すんだろう。本格的になってきた。

9cc.c

enum {
  TK_NUM = 256, // Number literal
  TK_EOF,       // End marker
};

tokenの種類を定義しているようだけど、何故256から始まるんだろう?0からじゃだめなのかな?とりあえずこのまま進める。

typedef struct {
  int ty;      // Token type
  int val;     // Number literal
  char *input; // Token string (for error reporting)
} Token;

tokenの構造体のようだ。tokenのty(種類)とval(値)とinput(入力文字)。valがint型なので、おそらく数字の足し引きしかできないだろう。これでいいんだ感満載である。

Token tokens[100];

これは正直ちょっと笑ってしまった。 あまりに単純で「これでいいんだw」というズコーな感じと、単純なところから始めることへの徹底されたこだわりまで感じる。 極限まで研ぎ澄まされた職人芸という感じだ。

void tokenize(char *p)

これも結局は1文字ずつ読み込んでif文で分岐しているだけ。 「これでいいんだ」と何回思わせるんだ。職人芸だ。

tyに直接'+''-'を入れている。 そうか、tyに直接文字列が入ることを想定すると、どの文字ともぶつからないようにするために TK_NUM = 256 となっていたんだな。

main関数ではこのtokenを使って、やはりif文で分岐してアセンブラを書き出す。

空白を飛ばすだけにしてはやや仰々しいが、これぐらいはいいでしょと飛ばしているんだろう。

test.sh

無事、空白を無視することができるようになった。