Topik ini memandang pointer sebagaimana prosesor memandangnya, yaitu angka alamat yang dipakai sebagai operand instruksi. Pembacaan nilai melalui pointer menjadi operasi baca memori, penulisan melalui pointer menjadi operasi tulis memori, dan aritmetika pointer berubah menjadi penambahan alamat yang diskalakan dengan ukuran tipe. Program C beserta disassembly yang menyertainya memperlihatkan ketiganya secara berurutan. Setelah topik ini selesai, perilaku pointer tidak lagi terasa abstrak, melainkan berupa operasi mesin yang dapat dibaca.
Pointer sebagai angka alamat#
Pointer adalah variabel yang menyimpan alamat memori dari variabel lain. Prosesor tidak mengenal kategori pointer, karena bagi mesin, isi register hanyalah bit alamat, sedangkan makna pointer diterapkan compiler saat kompilasi melalui tipe yang dicantumkan. Ukuran pointer mengikuti lebar alamat arsitekturnya, yaitu 8 byte pada x86-64 dan 4 byte pada RV32I milik RISC-V yang dijadikan rujukan konseptual sepanjang jalur. Karena isinya angka, pointer dapat disalin, dibandingkan, dan dihitung seperti angka lain, dengan satu aturan tambahan yang berkenaan dengan tipe yang ditunjuknya.
Alamat sebuah variabel diperoleh dengan operator &, sedangkan isi pada alamat yang ditunjuk diakses dengan operator *. Operasi pengambilan atau penimpaan isi memori melalui pointer itu disebut dereference, yaitu mengakses memori pada alamat yang tersimpan di dalam pointer. Dua operator inilah seluruh fondasi bahasanya, sedangkan bentuk instruksi mesinnya diperiksa pada bagian berikutnya.
Pembacaan dan penulisan melalui pointer#
Program pointer.c berikut mencetak nilai sebelum dan sesudah penulisan melalui pointer, ukuran tipe beserta ukuran pointer, langkah aritmetika pointer, selisih indeks larik, dan offset anggota struct. Struktur Sensor sengaja berisi dua int agar offsetnya mudah dihitung ulang. Struktur ini juga memberi contoh objek yang diakses compiler melalui perhitungan alamat, bukan melalui nama.
#include <stdio.h>
#include <stddef.h>
struct Sensor {
int id;
int nilai;
};
int main(void) {
int x = 10;
int *p = &x;
int larik[3] = {5, 15, 25};
char *pc = (char *)p;
printf("nilai x = %d\n", x);
printf("nilai *p = %d\n", *p);
*p = 25;
printf("x setelah *p=25 = %d\n", x);
printf("sizeof(x) = %zu\n", sizeof x);
printf("sizeof(p) = %zu\n", sizeof p);
printf("langkah p + 1 = %ld\n", (long)((char *)(p + 1) - (char *)p));
printf("langkah pc + 1 = %ld\n", (long)(pc + 1 - pc));
printf("selisih indeks = %ld\n", (long)(&larik[2] - &larik[0]));
printf("offset nilai = %zu\n", offsetof(struct Sensor, nilai));
return 0;
}nilai x = 10
nilai *p = 10
x setelah *p=25 = 25
sizeof(x) = 4
sizeof(p) = 8
langkah p + 1 = 4
langkah pc + 1 = 1
selisih indeks = 2
offset nilai = 4Baris pertama dan kedua menunjukkan bahwa *p membaca nilai yang sama dengan x, karena p memang menyimpan alamat x. Penulisan *p = 25 menimpa isi memori pada alamat itu, sehingga x ikut berubah menjadi 25 tanpa x disebut sama sekali. Dalam istilah mesin, pembacaan *p dieksekusi sebagai load, yaitu instruksi pengambilan isi memori ke register, sedangkan penulisan *p dieksekusi sebagai store, yaitu instruksi penulisan nilai register ke memori. Ukuran x adalah 4 byte, sedangkan ukuran p adalah 8 byte, sesuai lebar alamat x86-64. Offset anggota nilai bernilai 4 karena anggota id menempati 4 byte pertama, dan pengetahuan offset semacam inilah yang dipakai compiler saat menerjemahkan akses anggota struct. Seluruh angka pada keluaran ini deterministik karena tidak ada satu pun alamat absolut yang dicetak.
Aritmetika pointer mengikuti ukuran tipe#
Dua baris langkah pada keluaran memperlihatkan aturan utamanya. Ekspresi p + 1 pada pointer int berpindah 4 byte, sedangkan pc + 1 pada pointer char berpindah 1 byte, karena compiler menskalakan setiap langkah dengan ukuran tipe yang ditunjuk. Ekspresi p + 1 karena itu tidak boleh dibayangkan sebagai penambahan 1 pada angka alamatnya, melainkan penambahan satu elemen. Angka 4 pada langkah p + 1 berasal dari sizeof(int), dan angka 1 pada langkah pc + 1 berasal dari sizeof(char) yang memang bernilai 1. Compiler melakukan penskalaan ini saat kompilasi karena ukuran tipe sudah diketahui, sehingga tidak ada perhitungan tambahan saat program berjalan.
Selisih &larik[2] - &larik[0] bernilai 2 karena pengurangan pointer dinyatakan dalam jumlah elemen, bukan jumlah byte, padahal jarak nyatanya 8 byte. Aturan yang sama membuat penelusuran larik melalui pointer setara dengan penelusuran melalui indeks. Apabila skala ini dilupakan, hasil perhitungan alamat melenceng dan penulisan mendarat pada alamat yang bukan tempatnya.
Instruksi load dan store pada disassembly#
Berkas akses.c berisi tiga fungsi kecil tanpa main, sehingga berkasnya hanya menjadi bahan disassembly dan tidak dijalankan sebagai program. Fungsi baca mengembalikan *p, fungsi tulis menimpa *p dengan nilai baru, dan fungsi geser mengembalikan p + 1. Ketiganya dikompilasi dengan opsi yang menstabilkan keluaran, lalu diperiksa melalui objdump.
// tidak-dijalankan
int baca(int *p) {
return *p;
}
void tulis(int *p, int nilai) {
*p = nilai;
}
int *geser(int *p) {
return p + 1;
}# kompilasi akses.c ke berkas objek dengan opsi yang menstabilkan keluaran
gcc -O1 -fno-pie -no-pie -fno-asynchronous-unwind-tables -c akses.c -o akses.o
# tampilkan disassembly berkas objek
objdump -d akses.o
akses.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <baca>:
0: 8b 07 mov (%rdi),%eax
2: c3 ret
0000000000000003 <tulis>:
3: 89 37 mov %esi,(%rdi)
5: c3 ret
0000000000000006 <geser>:
6: 48 8d 47 04 lea 0x4(%rdi),%rax
a: c3 retFungsi baca menjadi satu instruksi mov (%rdi),%eax, yaitu load yang mengambil 4 byte dari memori pada alamat yang tersimpan di register rdi menuju register eax. Register rdi menyimpan parameter p sesuai konvensi pemanggilan x86-64, sehingga instruksi ini persis adalah dereference *p. Fungsi tulis menjadi mov %esi,(%rdi), yaitu store yang menulis isi register esi ke memori pada alamat rdi. Arah aliran data pada notasi AT&T membedakan keduanya, yaitu dari memori ke register untuk load dan dari register ke memori untuk store. Fungsi geser menjadi lea 0x4(%rdi),%rax, dan angka 4 di situ adalah skala ukuran int yang diterapkan compiler pada p + 1. Ketiga fungsi masing-masing hanya dua instruksi termasuk ret, karena akses memori pada tingkat mesin memang sesederhana itu.
Pada RISC-V RV32I, ketiga operasi tersebut bernama lw, sw, dan addi, dengan addi memakai offset 4 untuk skala int. Gagasan load, store, dan penskalaan alamat dengan demikian berlaku lintas arsitektur.
Kesalahan pointer yang lolos dari compiler#
Compiler hanya memeriksa tipe pada saat kompilasi, sedangkan keabsahan alamat baru diketahui saat program berjalan. Instruksi mov (%rdi),%eax tidak memuat informasi apakah alamat di rdi sah, karena bagi ALU nilai itu hanyalah angka yang dihitung seperti angka lain. Pemeriksaan keabsahan memori baru dilakukan perangkat manajemen memori pada saat instruksi dieksekusi, dan alamat yang tidak dipetakan menghasilkan segmentation fault, yaitu penghentian program oleh sistem operasi. Akibatnya, program dengan pointer liar dapat lolos kompilasi tanpa satu pun peringatan, lalu berhenti saat berjalan, atau lebih sulit lagi, terus berjalan sambil merusak data program yang sama. Opsi -Wall membantu menangkap sebagian kekeliruan deklarasi, tetapi tidak menggantikan pemeriksaan alamat saat program berjalan. Karena itu pengujian program C yang banyak memakai pointer selalu membutuhkan data masukan yang bervariasi, bukan sekadar kompilasi yang bersih.
Kesalahan umum#
Tiga kekeliruan berikut paling sering muncul saat pointer mulai dihitung pada tingkat alamat.
- Menganggap
p + 1menambah satu byte. Langkah aritmetika pointer selalu diskalakan dengansizeoftipe yang ditunjuk, sehingga satu langkah padaint *berarti 4 byte. Penelusuran per byte dilakukan melaluichar *sebagaimana ditunjukkan program di atas. - Mengira selisih dua pointer dinyatakan dalam byte. Hasil
&larik[2] - &larik[0]adalah 2 karena nilainya jumlah elemen, padahal jarak nyatanya 8 byte. Konversi ke byte memerlukansizeoftipe yang ditunjuk. - Membaca atau menulis melalui pointer yang belum menunjuk variabel mana pun. Pointer yang dideklarasikan tanpa inisialisasi berisi alamat sisa yang tidak dapat diprediksi, dan dereference terhadapnya tidak tertangkap saat kompilasi. Perbaikannya dengan menginisialisasi pointer dengan
NULLpada deklarasi dan memeriksa isinya sebelum dereference.
Ringkasan#
- Pointer menyimpan alamat memori, dan dereference dieksekusi sebagai instruksi load untuk pembacaan serta store untuk penulisan.
- Aritmetika pointer diskalakan dengan ukuran tipe, sehingga
p + 1padaint *berpindah 4 byte dan selisih dua pointer dinyatakan dalam jumlah elemen. - Disassembly memperlihatkan ketiganya, yaitu
mov (%rdi),%eaxsebagai load,mov %esi,(%rdi)sebagai store, danlea 0x4(%rdi),%raxsebagai langkah berskala. - Kesalahan pointer lolos dari compiler karena ALU hanya memproses angka alamat, dan baru terlihat saat instruksi memori dieksekusi.
Referensi#
- Computer Systems: A Programmer’s Perspective — Carnegie Mellon University — Membahas representasi pointer, kode mesin x86-64, dan hubungan dereference dengan instruksi load serta store.
- RISC-V ISA Manual — RISC-V International
— Spesifikasi instruksi
lwdanswRV32I yang dipakai sebagai pembanding konseptual.