読者です 読者をやめる 読者になる 読者になる

【お知らせ】プログラミング記事の投稿はQiitaに移行しました。

void *とintptr_t

x86 C言語

今まで何度かvoid *の説明を求められましたが、なかなか納得してもらえませんでした。説明を工夫するだけでは限界があると感じたので、別の方法でどうにかならないかを考えてみました。

前回の記事の延長線上で説明します。サンプルコードを再掲します。

mov  byte ptr [0x00000001], 0x12
mov  word ptr [0x00000004], 0xfeca
mov dword ptr [0x0000000a], 0xefbeadde

これを機械的にC言語に書き換えます。

*(char  *)0x00000001 = 0x12;
*(short *)0x00000004 = 0xfeca;
*(long  *)0x0000000a = 0xefbeadde;

これを提示した意図は、ポインタの意味に深入りしないで、出発点になる基本構文を覚えてもらおうということです。

レジスタ

アドレスをレジスタに入れてみます。

mov eax, 0x00000001
mov ebx, 0x00000004
mov ecx, 0x0000000a
mov  byte ptr [eax], 0x12
mov  word ptr [ebx], 0xfeca
mov dword ptr [ecx], 0xefbeadde

レジスタはただの数値なので、intで表現できます。

int eax = 0x00000001;
int ebx = 0x00000004;
int ecx = 0x0000000a;
*(char  *)eax = 0x12;
*(short *)ebx = 0xfeca;
*(long  *)ecx = 0xefbeadde;

32bitではアドレスもintも32bitのため、うまく動きます。

【注】このコードには問題がありますが、次で説明します。

intptr_t

アドレスやintのサイズは環境によって異なります。64bit Windowsではアドレスが64bit、intが32bitのため、アドレスをintに入れることができません。32bitから64bitに移植する際、よく問題になります。

このような環境依存を避けるため、アドレスと同じサイズのintptr_t型が導入されました。

intptr_t eax = 0x00000001;
intptr_t ebx = 0x0000000a;
intptr_t ecx = 0x00000004;
*(char  *)eax = 0x12;
*(short *)ebx = 0xfeca;
*(long  *)ecx = 0xefbeadde;

intptr_tは普通の整数型なので、普通に計算ができます。

#include <stdio.h>
#include <stdint.h>

int main(void) {
    intptr_t a = 1;
    intptr_t b = 2;
    intptr_t c = a + b;
    printf("%d\n", c);
    return 0;
}

ポインタ

アドレスに型を付けたのがポインタです。

char  *eax = (char  *)0x00000001;
short *ebx = (short *)0x00000004;
long  *ecx = (long  *)0x0000000a;
*eax = 0x12;
*ebx = 0xfeca;
*ecx = 0xefbeadde;

事前にキャストをすることで、代入時のキャストが不要になります。何度も代入するときには便利です。

void *

intptr_tが規格化されたのはC99です。それまではアドレスを表現するのにvoid *がよく使われていました。

※ 今でもvoid *を使う方が多いと思います。

void *は型を伴わないため、メモリへの書き込みに使用するときは、intptr_tと同じようにキャストする必要があります。

void *eax = (void *)0x00000001;
void *ebx = (void *)0x0000000a;
void *ecx = (void *)0x00000004;
*(char  *)eax = 0x12;
*(short *)ebx = 0xfeca;
*(long  *)ecx = 0xefbeadde;

void *は整数型ではないため、intptr_tのように計算に使用することはできません。

void *a = (void *)1;
void *b = (void *)2;
void *c = a + b; // エラー

アドレス書き込み

アドレスはただの数値なので、普通にメモリに書き込めます。

mov eax, 0x00000080
mov dword ptr [eax], 0x00004550
mov dword ptr [0x0000003c], eax

intptr_tでC言語に変換します。

intptr_t eax = 0x00000080;
*(long *)eax = 0x00004550;
*(intptr_t *)0x0000003c = eax;

メモリに書き込むときの型は機械的に対応しています。

  • longを書き込む → *(long *)
  • intptr_tを書き込む → *(intptr_t *)

intptr_tをvoid *に置き換えてみます。

void *eax = (void *)0x00000080;
*(long *)eax = 0x00004550;
*(void **)0x0000003c = eax;

構文を適用した結果、機械的にvoid **が出て来ることに注目してください。

  • void *を書き込む → *(void **)

void **の意味を考えるとわけが分からなくなるので、このように形式的なものだと割り切れば良いかもしれません。

コツ

キャストをデリファレンスする *(void **) のような構文は、左の*が右の*を打ち消すようにイメージすれば最終的に処理される型が分かりやすいと思います。

  • *(int *) ⇒ int
  • *(void **) ⇒ void *

アドレス計算

ここまでの例ではアドレスを即値指定していましたが、現実には即値を使うことはほとんどありません。

通常使うmalloc()に適用してみます。

1.c
intptr_t p1 = (intptr_t)malloc(10);
intptr_t p2 = (intptr_t)malloc(10);
*(char *)(p1 + 2) = 'a';
*(intptr_t *)(p1 + 4) = p2;

intptr_tは通常の整数型です。malloc()で返されたアドレスを計算してメモリを操作しています。

void *を使って書き換えてみます。void *は計算できないのでchar *にキャストします。

2.c
void *p1 = malloc(10);
void *p2 = malloc(10);
char *p3 = (char *)p1;
*(p3 + 2) = 'a';
*(void **)(p3 + 4) = p2;

どうせchar *にキャストするなら、最初からchar *を使えばすっきりします。

3.c
char *p1 = (char *)malloc(10);
char *p2 = (char *)malloc(10);
*(p1 + 2) = 'a';
*(char **)(p1 + 4) = p2;

副産物としてchar **が出て来てしまいます。

配列構文を使えばもっと短く書けます。私が多用している書き方です。

4.c
char *p1 = (char *)malloc(10);
char *p2 = (char *)malloc(10);
p1[2] = 'a';
*(char **)&p1[4] = p2;

この書き方には圧縮された短さという印象があります。必ずしも分かりやすいとは言えません。

まとめ

C言語は色々な書き方ができますが、自分に分かりやすい書き方をすれば良いと思います。

コードを読む場合、苦手な書き方がされていれば、得意な書き方に変換して理解するというのも一つの方法です。何度か変換しているうちに、そのまま読めるようになるでしょう。

私は今まで説明用のサンプルでvoid *を多用して来ましたが、分かりにくいためよく説明を求められました。しかしなかなか納得してもらえないので、これではまずいと思い始めました。

今後バイナリ関連の説明用サンプルには、アセンブリ言語と考え方が近いintptr_tを使おうと思います。