mruby/cのprintfの問題点


mruby/cをいじっていて気が付いたのだが、 そのprintfには二つの問題点があるように思われる。 その一つは、C言語からはprintf("%o",16)が使えないことである。 C言語からは八進数は使っていないので、加えてないのかも知れないが、二進数も使っていないのに、コードには書かれているので、加え忘れなのではないかと思う。 もう一つは、例えばprintf("%04d",-3)とすると、-003ではなく、00-3となるということである。 前者が正しいのだが、整数を文字列に変換してから、長さを調整しているので、このようになってしまうようだ。 これらを修正してみたので、ここでその解説をする。

mruby/cでは、printfについての関数はconsole.cで定義されている。 その最初の関数で"%d"などのフォーマットの文字によって、分岐する部分があるのだが、 ここに以下の記述を加えると、最初の問題は解決する。

  case 'o':
    ret = mrbc_printf_bit( pf, va_arg(*ap, unsigned int), 3);
    break;

しかし、bとoとxで三回も同じ関数が呼び出されていて、効率が悪く、これを加えると、私の環境でコンパイルすると12バイト大きくなった。 そこで、これらをまとめて、以下のように一つにすると、最初より8バイト小さくなって、八進数にも対応できるようになる。

  case 'b':
  case 'B':
  case 'o':
  case 'x':
  case 'X':
    ret = mrbc_printf_bit( pf, va_arg(*ap, unsigned int), pf->fmt.type*9/2%8); // 1,3,4 for b/B,o,x/X
    break;

他にも、以下のような数式を考えたのだが、括弧が無くて読み易いので上のコードを採用した。

(pf->fmt.type&89)%7
(pf->fmt.type&0xdf)/7-8

ちなみに、c_object.cには、rubyのprintf用のc_object_sprintfという関数があって、その中では八進数に対応するコードはあるが、これを同様にまとめるように変更すると、70バイト程小さくなった。 これらの似た処理をうまく共通化できたら、さらに効率が良いのだけど。 しかし、C言語から呼び出す関数とruby用の関数では、型のチェックなどの処理の違いがあり、完全な共通化が難しいようである。

次の問題を解消するには、console.c中のmrbc_printf_int中の処理を変更する必要がある。 その処理の仕方を見てみると、 整数を文字列に変換して、別に長さを調整する文字列を用意して、そこに符号と精度の調整の0を追加して、整数の文字列を追加するという過程を取っている。 0で長さを調整した後で、符号が加わるのが問題なのである。 できるだけ変更が少なくて、思うような挙動をするにはどうしたら良いかを考えたが、0で長さを調整する場合には、精度を変更してしまえば良いということを思い付いた。 そうするには、pad_widthを定義しているところの次に、以下の行を加えると、ほぼ予定通りの挙動をするようになる。

    if(pf->fmt.flag_zero && pf->fmt.precision<pad_width) pf->fmt.precision=pad_width;

長さと精度の両方を指定したときの挙動は、rubyとは若干違うかも知れないが、大きな問題では無いだろう。 しかし、この変更で、24バイトも大きくなってしまった。

さらにconsole.cの中を眺めていたら、以下の記述に目が止まった。

  buf[2] = "0123456789ABCDEF"[uch >> 4];
  buf[3] = "0123456789ABCDEF"[uch & 0x0f];

これは、十六進数に変換するというのは一目瞭然なのだが、短かくなるであろう以下のコードに変更した。

  buf[2] = '0'+(uch >> 4)+((uch >> 4)>9)*7; // 0-9A-F
  buf[3] = '0'+(uch & 15)+((uch & 15)>9)*7; // 0-9A-F

すると、268バイトも減った。 ちょっと減りすぎだと思うのだが、なぜだろう。 16文字の文字列が減るので、変化は10バイト前後だろうと思ったのだが、不思議だ。 他の部分でも使っている以下のような記述の方が分かり易いかも。

  buf[2] = (uch >> 4) + (((uch >> 4) < 10)? '0' : 'A' - 10);
  buf[3] = (uch & 15) + (((uch & 15) < 10)? '0' : 'A' - 10);

こっちの方がほんの少し大きいけど。 ほとんど同じだけど、次のコードだとさっきと同じサイズになる。

  buf[2] = ((uch >> 4) < 10) ? (uch >> 4)+'0' : (uch >> 4)+'A'-10;
  buf[3] = ((uch & 15) < 10) ? (uch & 15)+'0' : (uch & 15)+'A'-10;

最適化がかかっていると、不思議なことが起きる。

同じ関数にある以下の記述も気になるのだが、文字列が不規則なので、なかなかうまいプログラムが書けない。

    ch1 = "\0\0\0\0\0\0\0abtnvfr\0\0\0\0\0\0\0\0\0\0\0\0\0e\0\0\0"[ uch ];

これを何とかしたいと考えていたら、c_string.cの中に関連する部分を見付けた。 is_spaceという関数が定義されているのだが、スペースは9から13までなので、以下のように一行で書ける。

return (8<ch && ch<14);

すると、50バイトほど縮んだ。 このファイルにも、UTFの処理のところで、十六進への変換で先と同じ書き方を見付けて書き換えたが、私はこれを組込んでいないので、サイズは変らない。

他にサイズを減らせそうなところでは、alloc.cのnlz16も以下のように再帰っぽく単純化できる。

  return ((x >> 8) == 0 )? 8+nlz8(x&0xff): nlz8(x>>8);

ただし、nlz8より後に定義しないといけない。 これで30バイト程小さくなる。 uint8_tに変換されるときに&0xffの処理がされるはずなので、コードゴルフのくせでこんな風に書きたくなるけど、上のコードの方が良いだろう。

  return (x>>8)? nlz8(x>>8) : 8+nlz8(x);

ついでにいろいろと調べていたら、つい最近mruby/cの新しいversionの4.0.0がリリースされたことに気が付いた。 私が使っているlinuxのmrbcは3.3.0なので、しばらくは一つ前のリリースである3.4.1を使って行こうと思う。 上記で書いていることは、3.4.1について検証したものだが、ソースを確認したところ4.0.0でも同じ問題は残っていた。 4.0.0だと、十六進数への変換の部分では、なぜかあまりサイズが小さくならなかったが、それに加えてbuf[0]=’\‘とする場所をescやhexの中からその関数の最初に移したら、急に小さくなった。 最適化しているときのコンパイルサイズは、不思議なことがおこる。

3.4.1および4.0.0の両方に対応して、上記の変更を行うシェルスクリプトを書いてみた。

cat console.c | awk "/case 'B'/{\$0=\$0\"\n  case 'o':\";print;r=4}r{r-=1;next}1" |sed 's/, 4)/, pf->fmt.type*9\/2%8)/' |awk 'a==0 && /int pad_width/{$0=$0"\n    if(pf->fmt.flag_zero && pf->fmt.precision<pad_width) pf->fmt.precision=pad_width;";a=1}1' |ruby -ple "sub(/\"0123456789ABCDEF\"\\[(.+)\\]/){\"((#\$1) < 10) ? (#\$1)+'0' : (#\$1)+'A'-10\"}" |awk "/buf\\[0\\] = '\\\\\\\\';\$/{\$0=\"//\"\$0}/void mrbc_char_to_s/{a=1}a&&/^{/{\$0=\$0\"\n  buf[0]='\\\\\\\\';\";a=0}1" >temp.tmp && mv temp.tmp console.c
cat c_object.c | awk "/case 'B'/{print;b=6}b{b-=1;next}/case 'X'/{print;x=6}x{x-=1;next}1" |sed 's/, 3)/, pf.fmt.type*9\/2%8)/' >temp.tmp && mv temp.tmp c_object.c
cat c_string.c | awk '/int is_space/{a=b=1}a && /^{/{$0="/*"$0;a=0}b && /^}/{$0=$0"*/\n{ return (8 < ch && ch < 14); }";b=0}1' >temp.tmp &&  mv temp.tmp c_string.c
cat alloc.c | awk '/int nlz16/{$0="/*"$0;b=1}b && /^}/{$0=$0"*/";b=0}/int nlz8/{c=1}c && /^}/{$0=$0"\nstatic inline int nlz16(uint16_t x)\n{ return ((x >> 8) == 0 )? 8+nlz8(x&0xff): nlz8(x>>8); }";c=0}1' >temp.tmp && mv temp.tmp alloc.c

マイコンはメモリやflashが少なく計算速度も遲いので、 マイコン用のプログラムは読み易さよりも、コンパイル後のサイズと、実行速度が重要だろう。 バグ取りをしようとしていて、いつの間にかサイズ減らしになってしまったが、少しいじっただけでも、合計で0.4k以上も小さくなった。 10バイトぐらい縮む場所を10箇所見付けたら、0.1kになるなと思っていたが、それ以上の効果だ。 mruby/cのコードの中には、小さくできる余地が他にもあるのかも知れない。 おそらく、コンパイルサイズの小ささよりも、メンテナンスのしやすさや、新しい機能の実装などを重視しているように思われる。 これらのコードは、 動作確認はあまりしていないが、大体はうまく動くと思う。 本家に取り入れて貰えると嬉しいが、githubとかは認証が面倒で使うのを諦めているので、報告の手段が無い。 googleでmruby/cを検索すると、このブログは二ページ目に出てくるようになったので、いつか誰かが見付けてくれることを期待しよう。