みねっちょのマイコン関係ブログ

組込開発系フリーソフトやハードの情報発信ブログ

C 言語のポインタとは何か - アセンブラのロード/ストア命令との1対1対応

サイト内 Google 検索:


最終更新: 2021-05-26

ポインタはハードウェアのレジスタ操作を行うには必須の概念です。本記事では、ポインタのデータ型と ARM のアセンブラ記述との1対1な対応を説明し、クロスコンパイラとして WSL 上の Ubuntu-20.04 の標準パッケージを使って実例を示します。

アセンブラ記述: ニーモニックアセンブリ言語機械語

目次:


 


ポインタとアドレスはどう違うのか:

ポインタはデータ型であるのに対し、アドレス (つまり番地) は単なる値 (符号無し整数) です。そして、ポインタに格納された値 (符号無し整数) がアドレスとなります。ここで言うアドレスとは、プロセッサ (MPU) やコントローラ (MCU) のアドレスバスから出力される値の事です (仮想記憶の話は置いておきます) 。また、データ型とは、プロセッサやコントローラのデータバスから入出力されるデータのタイプです。

アドレスは単なる値 (rvalue) ですから、値の代入 &hoge = 0x1234 (hoge さんという変数に予め割り振られたメモリ番地をプログラムの途中で0x1234に変更する) は出来ません。一方でポインタはデータ型 (lvalue, locator value)ですから、*(long *)0x1234 = &hoge (0x1234 番地に hoge さんの住所をデータとして代入する) は問題無く可能です (後述するキャストに関する Warning は出ます) 。

代入するデータのロケーションを一意に定められれば、lvalue (locator value) に成る為に名前の有無は問題では有りません。逆に、&hoge の様に名前が付いて居る様に見えても、代入するデータのロケーションを定められなければ lvalue (locator value) には成れません。&hoge 番地にどうしても値を代入したい場合には、*(long *)&hoge = 1234 とすれば良いのですが、これは hoge = 1234 と同じ意味です。

次の画像はクリックまたはピンチすると拡大します。

f:id:minettyo:20210422011722p:plain f:id:minettyo:20210422115651p:plain

アドレスの幅 (ビット数) はプロセッサやコントローラのアドレス空間によって決まります。つまり、16ビット幅のアドレス空間を持つ8ビットマイコン (マイクロ コントローラ, MCU) 用の C コンパイラにおけるポインタの値 (アドレス) は16ビット、32ビット幅のアドレス空間を持つ32ビットマイコン用の C コンパイラにおけるポインタの値 (アドレス) は32ビット、という事になります。

そして、ポインタ型の変数に格納されたアドレスが指し示すメモリ番地に、データが格納されています。アドレスの幅 (ビット数) は処理系によって固定であるのに対し、ポインタで参照されるメモリ上 (もしくはメモリマップト I/O 上) のデータ型のビット幅は、プログラムを書く人が任意に指定出来ます。


 


ポインタにおけるデータ型の種類:

プログラムを書く人が指定するデータ型は様々であり、このデータ型はマイコンの命令 (アセンブラ記述) と1対1に対応しています (細かい話は置いておきます) 。使用する C コンパイラの種類により若干の違いは有るものの、一般的には C 言語の表現とデータのビット数は次の通りです。

「char」の部分だけ、省略形の符号の有無が他と逆なので注意が必要です。「char」型を整数として扱う場合に、符号付きか符号無しかは使用するコンパイラによって異なる可能性が有ります。また、「long long」型は、8ビットや4ビットのマイコン用のコンパイラには存在しない場合や、ビット数が異なる場合が有ります。尚、型修飾子無しで単に「int」と記載した場合、使用しているマイコンの一番自然なビット数 (通常はレジスタの幅) として扱われると言われていますが、使用するコンパイラによって何ビットとして扱われるかは定かでは有りません。

ポインタのデータ型
(省略無し)
省略形 符号の
有無
データの
ビット数
数値の
形式
signed char * signed char * 符号付き 8ビット 整数
unsigned char * char * 符号無し 8ビット 整数
signed short int * short * 符号付き 16ビット 整数
unsigned short int * unsigned short * 符号無し 16ビット 整数
signed long int * long * 符号付き 32ビット 整数
unsigned long int * unsigned long * 符号無し 32ビット 整数
signed long long int * long long * 符号付き 64ビット 整数
unsigned long long int * unsigned long long * 符号無し 64ビット 整数
void * void * 無効 0ビット 無し
float * float * 符号付き 32ビット 浮動小数
double * double * 符号付き 64ビット 浮動小数

 

上述の通り、整数型でのビット数や符号の有無は、使用するコンパイラにより意味合いが異なる場合が有ります。そこで、1999年に策定された C 言語の仕様 C99 では、オプションとして <stdint.h> というヘッダファイルをインクルードし、Nビットの符号付き整数を「intN_t」、符号無し整数を「uintN_t」と記載する方法が追加されました。

ポインタのデータ型
(C99 <stdint.h> 形式) 
符号の有無 データの
ビット数
数値の
形式
int8_t * 符号付き 8ビット 整数
uint8_t * 符号無し 8ビット 整数
int16_t * 符号付き 16ビット 整数
uint16_t * 符号無し 16ビット 整数
int32_t * 符号付き 32ビット 整数
uint32_t * 符号無し 32ビット 整数
int64_t * 符号付き 64ビット 整数
uint64_t * 符号無し 64ビット 整数
uintptr_t * 不明 0ビット 無し

 


C 言語とアセンブラ記述との対応:

C 言語の記述とマイコンアセンブラ記述との対応の例として、32ビット ARM プロセッサ用の GNU C コンパイラでは次の様になります。但し、中央の C 言語表現が必ず右のアセンブラ記述のロード/ストア命令にコンパイルされるか否かは、100%の保証は有りません。例えば、右辺に volatile 指定を行うとコンパイラの最適化が抑制され、冗長なコードが生成される場合が有ります。本来、右辺に volatile を使ってはいけません。

データ型 C 言語表現 ロード命令 ストア命令
符号付き8ビット整数型ポインタ int8_t * ldrsb strb
符号無し8ビット整数型ポインタ uint8_t * ldrb 同上
符号付き16ビット整数型ポインタ int16_t * ldrsh strh
符号無し16ビット整数型ポインタ uint16_t * ldrh 同上
符号付き32ビット整数型ポインタ int32_t * ldr str
符号無し32ビット整数型ポインタ uint32_t * 同上 同上

8ビットデータと16ビットデータのロード命令においては、それぞれレジスタの上位24ビットもしくは上位16ビットは、符号付き整数型の場合はデータの MSB 最上位ビット (符号ビット、0ならば正、1ならば負) で埋められ、符号無し整数型の場合はゼロで埋められます。これらをそれぞれ「符号拡張」(Sign extension)、「ゼロ拡張」(Zero extension) と呼びます。32ビットデータの場合は、データとレジスタの幅が同一となり、拡張の必用が有りませんので同じ命令となります。

また、8ビットデータと16ビットデータのストア命令においては、対象データの幅の方がレジスタの幅より狭いので、レジスタの上位24ビットもしくは上位16ビットの切り捨てが行われます。従って、レジスタ上のデータが符号付きでも符号無しでも同じ命令となります。

これらの ARM プロセッサのロード命令とストア命令の動作を一覧表に纏めると、次の様になります。

命令 レジスタ上の値
(バイナリ)
方向 メモリ上の値
(バイナリ)
操作
ldrsb SSSSSSSS SSSSSSSS SSSSSSSS Snnnnnnn Snnnnnnn 符号拡張
ldrb 00000000 00000000 00000000 nnnnnnnn nnnnnnnn ゼロ拡張
ldrsh SSSSSSSS SSSSSSSS Snnnnnnn nnnnnnnn Snnnnnnn nnnnnnnn 符号拡張
ldrh 00000000 00000000 nnnnnnnn nnnnnnnn nnnnnnnn nnnnnnnn ゼロ拡張
ldr dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd そのまま
strb xxxxxxxx xxxxxxxx xxxxxxxx dddddddd dddddddd 上位消失
strh xxxxxxxx xxxxxxxx dddddddd dddddddd dddddddd dddddddd 上位消失
str dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd dddddddd そのまま

S: 符号ビット、0: ゼロ、n: 数値ビット、d: 有意なビット、x: 捨てられるビット


 


ポインタ型で宣言された変数に格納された番地のデータへのアクセス:

ポインタ型で宣言された変数が有る場合、変数の中に格納されているのはアドレス (番地) です。その番地に格納されているデータにアクセスするには、変数の頭に「*」を付けます。尚、この「*」は間接演算子 (indirect operator) という名前です。

例として次のプログラムでは、最終的に mydata という変数に 0x12345678 番地に格納された符号付き32ビット整数が代入されます。

#include <stdint.h>
int32_t main() {
  int32_t *  mypointer;
  int32_t    mydata;

  mypointer = 0x12345678;
  mydata = *mypointer;
  return mydata;
}

 


ポインタ型への変換 (キャスト):

上のプログラムでは、少し胡麻化した部分が有ります。mypointer という 符号付き32ビット用ポインタ型の変数に、0x12345678 という整数型の値を代入しています。代入により、整数型からポインタ型への自動変換が行われています。使用するコンパイラによっては次の様な warning メッセージが出ます。

warning: assignment to ‘int32_t *’ {aka ‘int *’} from ‘int’ makes pointer from integer without a cast [-Wint-conversion]

では、代入によるデータ型の自動変換を利用しない場合はどうするかと言うと、定数の前に明示的に型の変換を記述します。次のプログラムでも、mydata という変数に 0x12345678 番地に格納された符号付き32ビット整数を代入可能です。

#include <stdint.h>
int32_t main() {
  int32_t *  mypointer;
  int32_t    mydata;

  mydata = *(int32_t *)0x12345678;
  return mydata;
}

「(int32_t *)」の部分で、定数を整数型からポインタ型に変換 (キャスト) しています。C 言語では、変数や定数の前に丸括弧 () のペアを記述し、中に型を記載すると型の変換 (「キャスト」と言います) が行われます。

ポインタ型にキャストした定数の頭に間接演算子「*」を付ける事により、ポインタ型で指定された番地に格納されたデータにアクセス可能となります。この例ではキャストした型は「int32_t *」ですから、変数 mydata に 0x12345678 番地の「符号付き32ビット整数」を代入します。

先に示した例で Warning が出ない様にする為には、次の様に記述せねばなりませんでした。

mypointer = (int32_t *)0x12345678;

尚、*(int32_t *)0x1234ダブルポインタと勘違いする方も居らっしゃいますが、違います。これは、整数型を整数用ポインタ型にキャストした後に、シングルポインタとして使用しているだけです。**hogeダブルポインタです。


 


WSL / WSL2 を使った動作確認:

WSL または WSL2 上で動いて居る Ubuntu-20.04 の標準パッケージである ARM-HF用の32ビット版コンパイラを使って、動作の確認を行ってみます。ARM-HF用の32ビット版コンパイラがインストールされていない場合、次のコマンドでインストールします。

sudo apt install crossbuild-essential-armhf

HF用以外の2つを含む、WSL へのARM用クロスコンパイラの種類とインストールの詳細に関しては、【こちらの別記事】に記載しましたので、参照してください。

 

次のソースコードは、0x54321000 という定数番地に対し、定数データを読み書きしています。これをコンパイルします。

尚、ソースコード中の「volatile」宣言は、コンパイラに「揮発性」であること、つまり、同じ変数や同じアドレスが指定されていても、或いはソースコード中で以降使用しない値であっても、最適化で省略せずに毎回書き込みが必要であることを指示します。 (実際のコンパイラの動作としては、単に最適化が抑制されるだけです。)

C99 準拠の標準整数型でコーディングする場合は次の通りです。尚、今回使用する Ubuntu-20.04 の ArmHF 用クロスコンパイラでは、<stdint.h> ヘッダファイルは「/usr/arm-linux-gnueabihf/include/」ディレクトリに置かれています。  
<stdint.h> は処理系依存ですので、必ず使用している処理系の標準ディレクトリに有る物を使用してください。ローカルにコピーした物を使ったり、間違った include ディレクトリを -I 指定するとと結果を誤ります。

「cat」 から「 !」までを Ubuntu のターミナル上にコピー&ペーストすると、main.c というソースコードが出来ます。

cat <<! > main.c
#include <stdint.h>

int main() {
  volatile uint32_t a;

  a = *(int8_t *)0x54321000;
  *(volatile int8_t *)0x54321000 = 0xce;

  a = *(uint8_t *)0x54321000;
  *(volatile uint8_t *)0x54321000 = 0xce;

  a = *(int16_t *)0x54321000;
  *(volatile int16_t *)0x54321000 = 0xface;

  a = *(uint16_t *)0x54321000;
  *(volatile uint16_t *)0x54321000 = 0xface;

  a = *(int32_t *)0x54321000;
  *(volatile int32_t *)0x54321000 = 0xbabeface;

  a = *(uint32_t *)0x54321000;
  *(volatile uint32_t *)0x54321000 = 0xbabeface;

  return 0;
}
!

 

C99 準拠の標準整数型を使用しない場合は次の通りです (非推奨) 。

cat <<! > main.c
int main() {
  volatile unsigned long a;

  a = *(signed char *)0x54321000;
  *(volatile signed char *)0x54321000 = 0xce;

  a = *(char *)0x54321000;
  *(volatile char *)0x54321000 = 0xce;

  a = *(short *)0x54321000;
  *(volatile short *)0x54321000 = 0xface;

  a = *(unsigned short *)0x54321000;
  *(volatile unsigned short *)0x54321000 = 0xface;

  a = *(long *)0x54321000;
  *(volatile long *)0x54321000 = 0xbabeface;

  a = *(unsigned long *)0x54321000;
  *(volatile unsigned long *)0x54321000 = 0xbabeface;

  return 0;
}
!

この C 言語のソースコードを、DWARF2 デバッグ情報付きで ARM 32ビット用にコンパイルした後、C 言語のソースコード付きでディスアセンブルします。ディスアセンブルする時に、C 言語のソースコードは同じディレクトリに置いておく必要が有ります。また、自動でアセンブラから C 言語に変換 (デコンパイル)している訳では無く、DWARF2 デバッグ情報に記録された C ソースコードの行番号に基づき転記されているだけなので、コンパイルに使用したのと全く同じ C ソースコードを置いておかないと、間違った C ソースコードがディスアセンブルリスト中に転記されてしまいます。例えば、コンパイルに失敗した事に気付かず解析していると???な状態になります。

尚、コンパイラのオプション「-g -gdwarf-2」はペアで指定すると DWARF2 デバッグ情報をオブジェクトに付加、「-Og」は C のソースコードデバッグが可能な範囲内での最適化、「-marm」は ARM ステートの (Thumb ステートでは無い) アセンブラ記述を出力する為の指示です。

arm-linux-gnueabihf-gcc main.c -g -gdwarf-2 -Og -marm -o main.out
arm-linux-gnueabihf-objdump main.out --disassemble=main --source --section=.text

次の結果が得られ、前記の表の通り確認が出来ます。尚、ARM プロセッサの命令の詳細は、次のサイトを参照してください。
https://developer.arm.com/documentation/ddi0597/2020-12/Base-Instructions
尚、下記のディスアセンブル リスト中では、0xce の符号拡張 0xffffffce というデータを作成する為に、ビット反転命令 mvn を使用しています。0x00000031 のビット反転が 0xffffffce となります。

 
C99 で定められた <stdint.h> 形式のデータ型を使った場合 (推奨)

f:id:minettyo:20210322234713j:plain

 

C99 形式を使用しないデータ型の場合 (非推奨)

f:id:minettyo:20210226221332j:plain




【C 言語関係の目次へ戻る】