文字列処理系の関数のインラインアセンブラの解読
参考にしたところ
- https://stackoverflow.com/questions/49375094/coaxing-gcc-to-emit-repe-cmpsb
- https://stackoverflow.com/questions/1733281/where-is-the-implementation-of-strlen-in-gcc#1733289
- https://zomo.herokuapp.com/blog/2015/08/04/repne-scasb/
- https://hp.vector.co.jp/authors/VA014520/asmhsp/chap6.html
- http://softwaretechnique.jp/OS_Development/Tips/IA32_Instructions/REP.html
環境はLinux、使ったアーキテクチャはx86, x64です。
生成方法
インラインの命令として生成してるのはGCCっぽいのでglibcのソースを見ても出てきませんでした。
特定の条件のときだけだったり、最適化をかけないと出てこないものもある。
今回対象にする命令はstrlen, strcpy, strcmpの3つ。徐々に増やしたい。
strlen
まずはサンプルコード。
# include <stdio.h>
# include <string.h>
int main() {
char buf[1024], buf_cpy[1024];
fgets(buf, 512, stdin);
int len = strlen(buf);
printf("%d\n", len);
return 0;
}
表示しているのは最適化による削除対策なので無視してください。
コンパイルはgcc -m32 -minline-all-stringops -O1 sample.c
。
-minline-all-stringopsは文字列関数でインラインアセンブリコードを使うように指示するオプションになる。
とりあえずx86。
strlenに対応している部分のアセンブリコードは以下のような感じ。
逆アセンブルにはradare2を使った。
0x000011e9 8dbde4fbffff lea edi, dword [s]
0x000011ef 57 push edi ; char *s
0x000011f0 e84bfeffff call sym.imp.fgets ; char *fgets(char *s, int size, FILE *stream)
;; 実際のstrlenの処理はここから
0x000011f5 b9ffffffff mov ecx, 0xffffffff ; -1
0x000011fa b800000000 mov eax, 0
0x000011ff f2ae repne scasb al, byte es:[edi]
0x00001201 89c8 mov eax, ecx
0x00001203 f7d0 not eax
0x00001205 83e801 sub eax, 1
;; eaxが返り値、文字列の長さになってる。
[https://hp.vector.co.jp/authors/VA014520/asmhsp/chap6.html]
ここ見た方が早いけど、どうせなので自分用に。
最初のcall sym.imp.fgets
でediに格納されたアドレスに文字列が入っている点に注意。
重要なのはrepne scasb
となっているところ。
repne
は後述のストリング処理命令と組み合わせて、ある処理を繰り返す処理を実行できる。
他のものも含めて、rep, repe/repz, repne/repnzの3種類がある。
基本的にはecxに指定した回数だけ繰り返すが、repeとrepneはZFも見る。
一方scasbはストリング処理命令というものの一種で、repなどの繰り返しと組み合せてあるアドレスから順番に値を処理していくのに使う。
ins, outs, movs, lods, stos, cmps, scasなどがある。testsみたいなのもあるらしいが、使わないし飛ばす。
ins/outsはポートのI/Oに対応する命令になる。
movs, lods, stosはデータ転送の命令だけど、アドレスへ読み込むのかレジスタへ読み込むのかの違いがある。
cmps, scasはそれぞれ値の比較をしてEFLAGSをセットする。メモリの値と比較するかレジスタと比較するかの違いがある。
この命令に、処理する値の幅を指定するb(byte 8bit), w(word 16bit), d(double word 32bit)が付く。
scasbだのscasのバイト単位での処理という意味になる。
ざっくり説明するとこんな感じです。
ではstrlenで使われている命令を見てみよう。
使われているのはrepne scasb al, byte es:[edi]
で、ecxには-1(0xffffffff)が指定されている。
ecxの回数は符号なしとして解釈されるので、そのままデクリメントされていくようになってる。
scasbはバイト単位でscasを実行する。scasはレジスタalの値と指定されたediのアドレスが指すメモリ中の値を比較する。
ediは自動的にインクリメントされる。DFフラグによってデクリメントになるらしい。
eaxは0になっているのでalも当然0、つまりヌルバイトが出現するかどうかを延々と比較する。
ここでrepneが使われているが、これはZF=1になったらループを終了するようになっている。
つまり、比較している値が0になったらループが終了するようになっている。
まとめると、repne scasb al, byte es:[edi]
ではediが指す入力した文字列中にヌルバイトが見つかるまで比較しつづけるというような処理をする。
次に、mov eax, ecx; not eax; sub eax, 1;
という処理を行っている。
ecxには0xffffffffからヌルバイトまでの文字数(ヌルバイトを含む)を引いた値が入っているので、notを取って1を引くと2の補数的に文字数になる。
ヌルバイト分が回数が増えているので1引く必要がある点に注意。
とまあstrlenはこんな処理になってる。
strcpy
サンプルコード
# include <stdio.h>
# include <string.h>
# define SIZE 32
int main() {
char buf_cpy[SIZE];
char *buf = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
strcpy(buf_cpy, buf);
printf("%s\n", buf_cpy);
return 0;
}
コンパイルはgcc -m32 -minline-all-stringops -O1 sample.c
。
アセンブリコード
0x000011c9 8d45c4 lea eax, dword [s]
0x000011cc 8db308e0ffff lea esi, dword [ebx - 0x1ff8]
0x000011d2 b910000000 mov ecx, 0x10
0x000011d7 89c7 mov edi, eax
0x000011d9 f3a5 rep movsd dword es:[edi], dword ptr [esi]
0x000011db 0fb616 movzx edx, byte [esi]
0x000011de 8817 mov byte [edi], dl
コンパイル時に文字列の長さを計算して、ecxに0x10を格納しています。4バイト単位でコピーするため、長さが微妙に違う点に注意。
使っているのはmovsdで、4バイト単位で値を移します。これはおそらく高速化のために一度に4バイトコピーしているんじゃないかと思われます。
その分配列のために確保されている領域オーバーする可能性があるので、コンパイルすると警告が出るようになってます。
ediにはコピー先のアドレスが、esiにはコピー元のアドレスが指定されます。
この例だとesiの指すアドレスからediの指すアドレスへのコピーが行われ、それぞれがインクリメントされます。
それがecx回、つまり文字列の長さだけ行われ文字列のコピーが完了します。
strcmp
サンプルコード
# include <stdio.h>
# include <string.h>
# define SIZE 32
int main() {
char *buf = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
char buf2[SIZE];
fgets(buf2, SIZE, stdin);
if (strcmp(buf, buf2)) {
printf("%s\n", buf2);
}
return 0;
}
コンパイルはgcc -m32 -minline-all-stringops -O1 sample.c
。
アセンブリコード
0x000011e1 8d7dc4 lea edi, dword [s]
0x000011e4 57 push edi ; char *s
0x000011e5 e846feffff call sym.imp.fgets ; char *fgets(char *s, int size, FILE *stream)
0x000011ea b941000000 mov ecx, 0x41 ; 'A'
0x000011ef 8db308e0ffff lea esi, dword [ebx - 0x1ff8]
0x000011f5 f3a6 repe cmpsb byte [esi], byte ptr es:[edi] ; [0x170000001c:1]=255 ; 98784247836
0x000011f7 0f97c0 seta al
0x000011fa 1c00 sbb al, 0
0x000011fc 83c410 add esp, 0x10
0x000011ff 84c0 test al, al
mov ecx, 0x41
でecxに入る0x41は文字列の長さです。
esiには比較する文字列のアドレスが、ediにはfgetsで取得した文字列のアドレスがそれぞれ入っています。
cmpsbで1バイト単位でesiとediのから取り出した値を比較している。
repeでZF=0になったらループを終了するようになっているので、比較している値が異なっているとその時点でループを抜けます。
setaはEFLAGSにもとづいて0/1を設定する命令で、al = (CF == 0 && ZF == 0)
という感じの処理をします。
その次のsbb al, 0
はCFを考慮した減算になっていて、減算をしたあとさらにCF分減算します。
つまり、CFがあるとalが0xffになり次のtestの判定で使われることになる。この辺はよく分からない。
とりあえずここまでにする。
他の関数も追加していきたい。