সরাসরি পর্বে যান
পর্ব ৪৫ · ৫৬-এর মধ্যে · মডিউল ৭

উন্নত পয়েন্টার — const, restrict, volatile দিয়ে কম্পাইলারকে কথা দেওয়া

Advanced Pointers — const, restrict, volatile & the Promises You Make to the Compiler

Taught in Bangla, this lesson covers C type qualifiers as promises to the compiler: const with pointers, const in function contracts, reading complex declarations, aliasing, restrict, volatile, and why volatile does not make code thread-safe.

এই পর্বে যা শিখবেন

  • const দিয়ে ডেটা ও পয়েন্টার আলাদাভাবে “তালা” দেওয়া, আর ফাংশনের parameter-এ const-কে চুক্তি হিসেবে ব্যবহার
  • যেকোনো জটিল পয়েন্টার ঘোষণা — function pointer সহ — শব্দ ধরে ধরে পড়ার নিয়ম
  • Aliasing কী, compiler কেন একে ভয় পায়, আর restrict কীভাবে গতি বাড়ায়
  • volatile কখন দরকার (হার্ডওয়্যার, signal), আর কখন সেটি ভুল সমাধান (thread)
  • AI-এর লেখা সিস্টেম-কোডে এই তিন qualifier-এর ভুল ধরার একটি চেকলিস্ট

১ · নতুন মডিউল — অ্যালগরিদম থেকে মেশিনের কাছাকাছি

গত মডিউলে আমরা অ্যালগরিদম নিয়ে কাজ করেছি — sorting, DP, graph, কনটেস্টের কৌশল। সেখানে প্রশ্ন ছিল “কোন পদ্ধতিতে কম ধাপে উত্তর পাব?” এই মডিউল ৭, “সিস্টেম প্রোগ্রামিং”-এ প্রশ্ন বদলে যাচ্ছে: “আমার প্রোগ্রাম অপারেটিং সিস্টেম আর হার্ডওয়্যারের সাথে কীভাবে কথা বলে?” সামনের পর্বগুলোতে আসবে process, pipe, thread আর network socket.

কিন্তু OS আর হার্ডওয়্যারের কাছে যাওয়ার আগে একটি জিনিস পাকা করা দরকার — পয়েন্টার নিয়ে সূক্ষ্ম কথাবার্তা। পর্ব ১৭ ও ১৮-তে আপনি শিখেছেন pointer একটি ঠিকানা রাখে। এবার শিখবেন সেই ঠিকানা নিয়ে compiler-কে তিনটি বিশেষ কথা কীভাবে বলতে হয়।

ধরুন আপনি একটি গার্মেন্টস কারখানার ম্যানেজার, আর লাইনের সুপারভাইজারকে একটি নকশার ফাইল দিচ্ছেন। ফাইলের ওপর তিন রকম স্টিকার লাগানো যায়: “শুধু দেখবেন, কাটাছেঁড়া করবেন না” (const), “এই ফাইলের আর কোনো কপি কারও কাছে নেই, নিশ্চিন্তে কাজ করুন” (restrict), আর “এই ফাইল ক্রেতা যেকোনো সময় বদলে দিতে পারেন, প্রতিবার নতুন করে দেখে নেবেন” (volatile). স্টিকারে ফাইলের ভেতরের নকশা বদলায় না — বদলায় সুপারভাইজার কীভাবে কাজ করবেন।

C-তে এই স্টিকারগুলোর নাম type qualifier. এগুলো কোনো ভ্যারিয়েবলের আকার বা বিট বদলায় না। শুধু বলে দেয় — এই ডেটা কে বদলাতে পারবে, কার মাধ্যমে পৌঁছানো যাবে, আর এর মান কি প্রোগ্রামের অজান্তে বদলে যেতে পারে।

🔒 const

“এই পথ দিয়ে আমি শুধু পড়ব।” ভাঙার চেষ্টা করলে compiler error দেয়। এটি মূলত মানুষের জন্য নিরাপত্তা।

C89 থেকে

🛣️ restrict

“এই মেমরিতে যাওয়ার একমাত্র পথ এই পয়েন্টার।” compiler যাচাই করে না, বিশ্বাস করে — বিনিময়ে দ্রুত কোড দেয়।

C99 থেকে

⚡ volatile

“এই মান আমার অজান্তে বদলাতে পারে।” তাই compiler প্রতিটি পড়া ও লেখা সত্যিই করে, কোনোটি বাদ দেয় না।

C89 থেকে
মূল ধারণা

প্রতিটি qualifier একটি প্রতিশ্রুতি, আর প্রতিশ্রুতির দুটি দিক আছে। const ভাঙলে compiler সাধারণত ধরে ফেলে। কিন্তু restrict বা volatile-এর প্রতিশ্রুতি ভাঙলে compiler কিছু বলে না — প্রোগ্রাম চুপচাপ ভুল ফল দেয়। এজন্যই এই পর্বে “কী লিখব” এর চেয়ে “কেন লিখব” বেশি জরুরি।

১৯৮৯ সালের ANSI C (C89) মানে প্রথম const আর volatile আনুষ্ঠানিকভাবে যুক্ত হয়। const-এর ধারণাটি এসেছিল Bjarne Stroustrup-এর তখনকার নতুন ভাষা C++ থেকে। ১৯৯৯ সালের C99 মানে যুক্ত হয় restrict — মূলত বৈজ্ঞানিক হিসাবের গতিতে Fortran-এর সমান হওয়ার জন্য। আর ২০১১ সালের C11 মানে এসেছে _Atomic, যা thread-এর জন্য তৈরি; সেটি আমরা পর্ব ৪৯-এ দেখব।

২ · const — কোন জিনিসে তালা, ডেটায় না পয়েন্টারে?

সাধারণ ভ্যারিয়েবলে const সহজ: const int max = 100; লিখলে max আর বদলানো যায় না। কিন্তু পয়েন্টারে দুটি আলাদা জিনিস আছে — পয়েন্টার নিজে (ঠিকানাটা কোথায় রাখা) আর যে ডেটার দিকে সে তাকিয়ে আছে। তাই তালাও দুই জায়গায় দেওয়া যায়।

একটি রিকশার কথা ভাবুন। রিকশাচালক (পয়েন্টার) যাত্রীকে (ডেটা) নিয়ে যান। এক ধরনের নিয়ম: “যাত্রীর গায়ে হাত দেওয়া যাবে না, কিন্তু চালক যেকোনো যাত্রীর কাছে যেতে পারবেন।” আরেক ধরনের নিয়ম: “চালক শুধু এই একটি গলিতেই থাকবেন, তবে সেখানকার মালপত্র নামানো-ওঠানো চলবে।” দুটো একেবারে আলাদা নিয়ম, অথচ দুটোতেই “নিষেধ” আছে।

ঘোষণাডান থেকে বাঁয়ে পড়লে*p = 5;p = &y;
int *pp হলো int-এর পয়েন্টারচলবেচলবে
const int *p
(= int const *p)
p হলো const int-এর পয়েন্টার — ডেটায় তালাerrorচলবে
int *const pp হলো const পয়েন্টার, int-এর দিকে — পয়েন্টারে তালাচলবেerror
const int *const pp হলো const পয়েন্টার, const int-এর দিকে — দুই জায়গাতেই তালাerrorerror

মনে রাখার একটি ছোট কৌশল: *-চিহ্নকে দেয়াল ভাবুন। const দেয়ালের বাঁ পাশে থাকলে তালা ডেটায়, ডান পাশে (নামের ঠিক আগে) থাকলে তালা পয়েন্টারে। নিচের ডায়াগ্রামে সবচেয়ে কঠিন রূপটি নাম থেকে শুরু করে উল্টো দিকে শব্দ ধরে ধরে পড়ুন:

const int * const p ;
এ পর্যন্ত পড়ানাম দিয়ে শুরু
p হলো …
এ পর্যন্ত পড়ানামের ঠিক বাঁয়ে const
p হলো একটি const (বদলানো যায় না এমন) …
এ পর্যন্ত পড়া* মানে “পয়েন্টার”
p হলো একটি const পয়েন্টার, যা তাকিয়ে আছে …
এ পর্যন্ত পড়াকোন টাইপের দিকে
p হলো একটি const পয়েন্টার, যা তাকিয়ে আছে একটি int-এর দিকে …
এ পর্যন্ত পড়াসবচেয়ে বাঁয়ের const
p হলো একটি const পয়েন্টার, যা তাকিয়ে আছে
একটি const int-এর দিকে।
ফলাফলকোন কাজ চলবে, কোনটা চলবে না
const int *const p = &x;

*p = 5;    /* error: ডেটায় তালা          */
p  = &y;   /* error: পয়েন্টারে তালা       */
int v = *p; /* চলবে: শুধু পড়া তো নিষেধ নয় */
  1. নাম দিয়ে শুরু। যেকোনো ঘোষণা পড়ার প্রথম নিয়ম: আগে নামটি খুঁজুন। এখানে নাম p. বাক্যটি শুরু হলো “p হলো …”.
  2. নামের ঠিক বাঁয়ে const. নামের পাশে, *-এর ডান দিকে বসে আছে, তাই এটি পয়েন্টারের তালা। অর্থাৎ p-তে একবার যে ঠিকানা রাখা হবে, সেটি আর বদলানো যাবে না।
  3. * মানে পয়েন্টার। এটি সেই “দেয়াল”। এর ডান পাশের সব কিছু পয়েন্টার নিয়ে, বাঁ পাশের সব কিছু ডেটা নিয়ে।
  4. int — কিসের দিকে তাকিয়ে। পয়েন্টারটি একটি পূর্ণসংখ্যার ঠিকানা রাখে।
  5. সবচেয়ে বাঁয়ের const. এটি দেয়ালের বাঁ পাশে, তাই তালা ডেটায়। এই পয়েন্টার দিয়ে ডেটা বদলানো যাবে না। পুরো বাক্য: “p হলো একটি const পয়েন্টার, যা একটি const int-এর দিকে তাকিয়ে আছে।”
  6. ফল যাচাই। দুটি কাজ নিষেধ, কিন্তু পড়া সবসময় চলে। আর খেয়াল করুন: x নিজে const না হলে অন্য পথে (যেমন সরাসরি x = 9;) মান বদলানো যায়। const বলে “এই পথ দিয়ে বদলাব না”, “কেউ কখনো বদলাবে না” নয়।

এবার হাতে-কলমে দেখুন। নিচের কোডে তিন রকম পয়েন্টার আর একটি “const-সচেতন” ফাংশন আছে। যে লাইনগুলো compile হবে না, সেগুলো comment করে রাখা হয়েছে — চাইলে comment সরিয়ে error নিজের চোখে দেখুন।

#include <stdio.h>
static size_t count_char(const char *s, char c) {
    size_t n = 0;
    for (; *s != '\0'; s++)     /* s সরানো চলবে */
        if (*s == c) n++;
    /* *s = 'x';  error: read-only location */
    return n;
}
int main(void) {
    int a = 10, b = 20;
    const int *p = &a;         /* ডেটায় তালা */
    int *const q = &a;         /* পয়েন্টারে তালা */
    const int *const r = &b;   /* দুটোতেই তালা */
    p = &b;                    /* চলবে */
    *q = 15;                   /* চলবে: a এখন 15 */
    /* *p = 1;  q = &b;  *r = 2;   -- তিনটিই error */
    printf("*p=%d a=%d *r=%d\n", *p, a, *r);
    printf("count=%zu\n", count_char("banana", 'a'));
    return 0;
}

p প্রথমে a-র দিকে ছিল, পরে b-র দিকে সরে গেল — পয়েন্টার নিজে তালাবদ্ধ নয়, তাই এটি বৈধ। q দিয়ে a-কে 15 করা গেল, কারণ তালা শুধু ঠিকানায়। আউটপুট: *p=20 a=15 *r=20 আর count=3. মজার দিক হলো count_char-এর ভেতরে s++ চলছে — const char *s মানে অক্ষরগুলো বদলানো নিষেধ, কিন্তু s নিজে এক অক্ষর থেকে আরেক অক্ষরে সরতে পারে।

৩ · const যখন ফাংশনের চুক্তিপত্র

const-এর সবচেয়ে বড় ব্যবহার আসলে ফাংশনের parameter-এ। Standard library-র এই ঘোষণাটি দেখুন:

size_t strlen(const char *s);
char  *strcpy(char *dest, const char *src);

শুধু ঘোষণা পড়েই আপনি জানেন, strlen আপনার string-এ একটি অক্ষরও বদলাবে না। আর strcpy-তে src শুধু পড়া হবে, লেখা হবে dest-এ। কোড খুলে দেখার দরকার নেই — চুক্তিপত্রেই লেখা আছে। পর্ব ১৮-তে দেখা const pointer parameter-এর এটাই আসল শক্তি।

এই অভ্যাসের নাম const-correctness: যে পয়েন্টার দিয়ে লেখার কথা নয়, তার প্রতিটিতে const লিখুন। এর তিনটি লাভ:

  • ভুল আগেই ধরা পড়ে। কেউ ভুল করে “শুধু পড়ার” ফাংশনে কিছু লিখে ফেললে compiler সঙ্গে সঙ্গে error দেয়।
  • ফাংশন ব্যবহারকারী নিশ্চিন্ত থাকেন। আপনার ডেটা নিরাপদ কি না, তা ঘোষণা পড়েই বোঝা যায়।
  • const ডেটাও পাঠানো যায়। const ছাড়া লেখা ফাংশনে একটি const string পাঠালে compiler warning দেয়। তাই একটি জায়গায় const বাদ পড়লে পুরো প্রজেক্টে সমস্যা ছড়ায় — বড় C প্রজেক্টে এজন্য শুরু থেকেই const-correctness মানা হয়।

String literal — const না লিখলেও আসলে “শুধু পড়ার”

C-তে char *s = "Dhaka"; লেখা বৈধ — compiler কিছু বলে না। কিন্তু এই string literal সাধারণত থাকে read-only memory-তে। s[0] = 'd'; লিখলে সেটি undefined behavior; বেশিরভাগ সিস্টেমে প্রোগ্রাম সেখানেই crash করে। ঐতিহাসিক কারণে C-তে string literal-এর টাইপ const char[] নয়, char[]; পুরোনো কোড ভেঙে যাবে বলে এটি আর বদলানো হয়নি। তাই নিজে সবসময় লিখুন const char *s = "Dhaka"; — তাহলে ভুল লেখা compiler-ই ধরবে।

const “ফেলে দেওয়া” — cast-এর ফাঁদ

একটি cast দিয়ে const সরিয়ে ফেলা যায়: char *w = (char *)s;. Compiler চুপ থাকে, কারণ আপনি জোর করে বলেছেন। এর ফল নির্ভর করে আসল object-এর ওপর:

আসল ডেটা const নয়

char buf[10]; — এর দিকে একটি const char * ছিল, cast করে লিখলেন। এটি বৈধ, কারণ ডেটা নিজে কখনো const ছিল না।

বৈধ, তবে সন্দেহজনক

আসল ডেটা const

const int limit = 5; বা একটি string literal — cast করে লিখলেই undefined behavior. ডেটা read-only মেমরিতে থাকতে পারে, বা compiler ধরে নিতে পারে মানটি কখনো বদলাবে না।

undefined behavior

Standard library নিজেও একটি পুরোনো ফাঁক রেখে দিয়েছে: char *strchr(const char *s, int c); একটি const string নিয়ে non-const পয়েন্টার ফেরত দেয়। ফলে strchr("abc", 'b')-এর ফল দিয়ে লিখলে compiler কিছু বলবে না, কিন্তু সেটি undefined behavior. নিয়মটা মনে রাখুন: cast দিয়ে const সরানো মানে চুক্তি ছিঁড়ে ফেলা। কোডে এমন cast দেখলে থেমে প্রশ্ন করুন — “আসল ডেটা কি সত্যিই লেখা যায়?”

৪ · জটিল ঘোষণা পড়ার নিয়ম

C-এর ঘোষণা কখনো কখনো ধাঁধার মতো দেখায়। কারণ C-এর একটি নকশা-নীতি: ঘোষণা দেখতে ব্যবহারের মতো। int *p মানে “*p একটি int”. int a[5] মানে “a[i] একটি int”. এই নীতি ছোট ঘোষণায় সুন্দর, কিন্তু বড় হলে জট পাকায়। তখন কাজে আসে right-left rule:

নাম থেকে শুরু → ডানে যতদূর যাওয়া যায় → তারপর বাঁয়ে
বন্ধনী ( ) এলে আগে তার ভেতরটা শেষ করুন

ডানে [ ] পেলে পড়ুন “array”, ( ) পেলে “ফাংশন”, বাঁয়ে * পেলে “পয়েন্টার”। দুটি দেখতে প্রায় একই ঘোষণা দিয়ে নিয়মটি পরখ করুন:

ঘোষণাকীভাবে পড়বেনকোথায় দেখবেন
int *a[5]a → ডানে [5]: ৫টি ঘরের array → বাঁয়ে *: প্রতিটি int-এর পয়েন্টারchar *argv[] — কমান্ড লাইনের প্রতিটি শব্দ
int (*a)[5]a → বন্ধনীর ভেতরে বাঁয়ে *: একটি পয়েন্টার → বেরিয়ে ডানে [5]: ৫টি int-এর array-র দিকে2D array-র একটি সারি ফাংশনে পাঠানো
int *f(int)f → ডানে (int): ফাংশন, int নেয় → বাঁয়ে *: int-এর পয়েন্টার ফেরত দেয়পয়েন্টার ফেরত দেওয়া সাধারণ ফাংশন, যেমন malloc
int (*f)(int)f → বাঁয়ে *: পয়েন্টার → ডানে (int): এমন ফাংশনের, যা int নিয়ে int ফেরত দেয়পর্ব ২৮-এর callback

এবার একটু বড় একটি ঘোষণা — একটি function pointer, যে ফাংশন একটি পয়েন্টার ফেরত দেয়। নিয়ম মেনে ধাপে ধাপে পড়ুন:

int * (* fp ) (int) ;
এ পর্যন্ত পড়ানাম খুঁজুন
fp হলো …
এ পর্যন্ত পড়াবন্ধনীর ভেতরটা আগে
fp হলো একটি পয়েন্টার …
এ পর্যন্ত পড়াবন্ধনী থেকে বেরিয়ে ডানে
fp হলো একটি পয়েন্টার, এমন ফাংশনের দিকে যা একটি int নেয়
এবং ফেরত দেয় …
এ পর্যন্ত পড়াডানে আর কিছু নেই, এবার বাঁয়ে
fp হলো একটি পয়েন্টার, এমন ফাংশনের দিকে যা একটি int নেয়
এবং ফেরত দেয় একটি পয়েন্টার …
এ পর্যন্ত পড়াশেষ শব্দ
fp হলো একটি পয়েন্টার, এমন ফাংশনের দিকে যা একটি int নেয়
এবং ফেরত দেয় একটি int-এর পয়েন্টার।
typedef দিয়ে পোষ মানানোএকই অর্থ, পড়তে অনেক সহজ
typedef int *Finder(int);   /* Finder = int নিয়ে int* দেয় এমন ফাংশন */
Finder *fp;                 /* fp = Finder-এর পয়েন্টার */

/* signal()-এর আসল ঘোষণা, typedef ছাড়া: */
void (*signal(int sig, void (*func)(int)))(int);
/* typedef দিয়ে: */
typedef void Handler(int);
Handler *signal(int sig, Handler *func);
  1. নাম খুঁজুন। সব চিহ্নের ভিড়ে একটিমাত্র নাম আছে — fp. এখান থেকেই পড়া শুরু।
  2. বন্ধনীর ভেতরটা আগে। ডানে তাকালেই ) — বন্ধনী বন্ধ। তাই বন্ধনীর ভেতরে বাঁয়ে যাই: *. অর্থাৎ fp একটি পয়েন্টার। এই বন্ধনী না থাকলে অর্থ পুরো বদলে যেত।
  3. বেরিয়ে ডানে (int). ডানে গোল বন্ধনী মানে ফাংশন। তাই fp যে জিনিসের দিকে তাকিয়ে আছে, সেটি একটি ফাংশন, যা একটি int নেয়।
  4. ডানে আর কিছু নেই — এবার বাঁয়ে *. ফাংশনটি যা ফেরত দেয় সেটি একটি পয়েন্টার।
  5. সবশেষে int. পুরো বাক্য: “fp হলো একটি পয়েন্টার, এমন ফাংশনের দিকে যা int নেয় আর int-এর পয়েন্টার ফেরত দেয়।” যেমন একটি ফাংশন, যা রোল নম্বর নিয়ে সেই ছাত্রের নম্বরের ঘরের ঠিকানা দেয়।
  6. typedef দিয়ে সহজ করা। আসল প্রজেক্টে এমন ঘোষণা বারবার লিখলে ভুল হবেই। typedef দিয়ে ফাংশনের টাইপকে একটি নাম দিন, তারপর সাধারণ পয়েন্টারের মতো লিখুন। নিচের signal()-এর উদাহরণে পার্থক্যটা দেখুন — দুটোর অর্থ হুবহু এক।

জটিল ঘোষণায় আটকে গেলে cdecl টুল ব্যবহার করুন — cdecl.org-এ ঘোষণা পেস্ট করলে সাধারণ ইংরেজিতে অর্থ দেখায়। অনেকে “clockwise/spiral rule” নামের একটি নিয়মও শেখেন — নাম থেকে ঘড়ির কাঁটার দিকে ঘুরে পড়া। ছোট ঘোষণায় এটি কাজ করে, কিন্তু কিছু ক্ষেত্রে ভুল পথ দেখায়; right-left rule বেশি নির্ভরযোগ্য।

৫ · Aliasing — একই মেমরির দুই নাম

restrict বোঝার আগে একটি সমস্যা বুঝতে হবে, যা compiler-এর জন্য সবচেয়ে বড় মাথাব্যথাগুলোর একটি। একটি মেমরির ঘরে যদি দুটি আলাদা পয়েন্টার দিয়ে পৌঁছানো যায়, সেই অবস্থার নাম aliasing, বাংলায় বলা যায় “একই ঘরের দুই নাম”।

পাড়ার একটি ছেলেকে বাড়িতে সবাই ডাকে “বাবু”, আর স্কুলে তার নাম “রাফি”। শিক্ষক যদি জানেন না যে বাবু আর রাফি একই মানুষ, তাহলে “রাফি আজ ক্লাসে এসেছে” শুনে তিনি ধরে নিতে পারেন “বাবু” এখনো বাড়িতে। Compiler-এর অবস্থাও এমন — দুটি পয়েন্টার দেখলে সে জানে না এরা একই ঘর দেখাচ্ছে কি না। জানে না বলেই তাকে সবচেয়ে সাবধানী পথ নিতে হয়।

একটি ছোট ফাংশন দেখুন:

void update(int *x, int *y) {
    *x = 1;
    *y = 2;
    printf("%d\n", *x);   /* 1 ছাপবে? */
}

মনে হয় *x তো সবে 1 করা হলো, তাই 1 ছাপবে — compiler সরাসরি 1 বসিয়ে দিলেই পারে। কিন্তু কেউ যদি update(&n, &n) ডাকে? তখন x আর y একই ঘর, আর *y = 2 লেখার পর *x-ও 2. তাই compiler-কে মেমরি থেকে *x আবার পড়তে হয়। এই “আবার পড়া” ছোট মনে হলেও লক্ষ-কোটিবার চলা লুপে সেটি গতির বড় বাধা।

Strict aliasing — টাইপ দিয়ে compiler-এর অনুমান

C-এর নিয়ম compiler-কে একটু সাহায্য করে। Strict aliasing rule বলে: একটি object-কে শুধু তার নিজের টাইপের (বা সামঞ্জস্যপূর্ণ টাইপের) পয়েন্টার দিয়েই পড়া-লেখা করা যাবে। তাই int * আর float * একই ঘর দেখাবে না — compiler এটি ধরে নেয়, আর GCC-তে -O2 চালু করলে এই অনুমান কাজে লাগিয়ে optimization করে।

যা নিষেধ (undefined behavior)

float f = 1.0f;
unsigned u = *(unsigned *)&f;
একটি float-কে unsigned-এর পয়েন্টার দিয়ে পড়া। অনেক পুরোনো কোডে এমন লেখা আছে, আর optimization চালু করলে কখনো কখনো ভুল ফল দেয়।

pointer cast দিয়ে punning

যা নিরাপদ

unsigned u;
memcpy(&u, &f, sizeof u);
বাইটগুলো কপি করে নতুন টাইপে পড়া। আধুনিক compiler এটিকে একটি নির্দেশেই বদলে দেয় — কোনো আসল কপি হয় না।

memcpy দিয়ে punning

একই বাইটকে এক টাইপে লিখে আরেক টাইপে পড়ার এই কাজের নাম type punning, যা পর্ব ২২-এ union-এর সাথে দেখেছেন। নিয়মের দুটি বড় ব্যতিক্রম মনে রাখুন। এক: char * (আর unsigned char *) দিয়ে যেকোনো object-এর বাইট পড়া যায় — এজন্যই memcpy কাজ করে। দুই: C-তে (C++-এ নয়) union-এর এক সদস্যে লিখে অন্য সদস্যে পড়া অনুমোদিত।

Strict aliasing ভাঙা কোড প্রায়ই -O0-তে ঠিকঠাক চলে, আর -O2-তে হঠাৎ ভুল ফল দেয়। তখন মনে হয় “compiler-এ বাগ!” — কিন্তু আসল বাগ কোডে। Linux kernel-এর মতো বিশাল প্রজেক্ট ঝুঁকি না নিয়ে -fno-strict-aliasing flag দিয়ে compile হয়, যাতে compiler এই অনুমানটি না করে।

৬ · restrict — “এই মেমরিতে আর কেউ হাত দেবে না”

Aliasing-এর সমস্যার সরাসরি সমাধান restrict. কোনো পয়েন্টারে restrict লিখে আপনি প্রতিশ্রুতি দেন: “এই পয়েন্টার যতক্ষণ বেঁচে আছে, এর দেখানো মেমরিতে অন্য কোনো পথে কেউ পৌঁছাবে না।” Compiler এটি যাচাই করে না — বিশ্বাস করে, আর সেই বিশ্বাসে দ্রুত কোড বানায়।

একটি লুপ দিয়ে দেখি। ফাংশনটি array b-এর প্রতিটি ঘরে *a-র মান যোগ করে। একটি অদ্ভুত কিন্তু বৈধ ডাক দিয়ে চালাচ্ছি — a দেখাচ্ছে b-এরই একটি ঘর। “পরে” চাপুন আর array-র মান খেয়াল করুন:

1void add_all(int *b, const int *a, int n) {2    for (int i = 0; i < n; i++)3        b[i] += *a;4}56int arr[4] = {1, 1, 1, 1};7add_all(arr, &arr[1], 4);
মেমরির অবস্থা (Watch)
i
*a (= arr[1])
arr
  1. অদ্ভুত কিন্তু বৈধ ডাক। b হলো পুরো arr, আর a দেখাচ্ছে arr[1]-কে। অর্থাৎ *a আর b[1] একই ঘরের দুই নাম — aliasing.
  2. i = 0. arr[0]-এ যোগ হলো *a = 1, তাই arr[0] হলো 2. এখনো সব স্বাভাবিক।
  3. i = 1 — এখানেই চমক। b[1] আর *a একই ঘর। arr[1] = 1 + 1 = 2. এখন থেকে *a-র মানও 2 — যদিও কোডে *a-তে কেউ সরাসরি কিছু লেখেনি!
  4. i = 2. *a আবার মেমরি থেকে পড়া হলো, এবার 2. arr[2] = 1 + 2 = 3.
  5. i = 3. arr[3] = 1 + 2 = 3. C-এর নিয়ম অনুযায়ী এটিই সঠিক ফল: {2,2,3,3}.
  6. Compiler যদি “বুদ্ধি” করে একবারই পড়ত? লুপের শুরুতে *a = 1 একটি register-এ রেখে বারবার ব্যবহার করলে ফল হতো {2,2,2,2} — ভুল। তাই restrict ছাড়া compiler এই অনুমান করতে পারে না; প্রতি ঘুরে মেমরি থেকে *a পড়তে বাধ্য, আর সেটি লুপকে ধীর করে।
  7. restrict দিলে। void add_all(int *restrict b, const int *restrict a, int n) লিখলে আপনি কথা দিলেন, এমন ডাক কখনো হবে না। তখন compiler *a একবার পড়ে register-এ রাখে, আর SIMD নির্দেশ দিয়ে একসাথে ৪ বা ৮টি ঘরে যোগ করতে পারে। কিন্তু কথা দিয়েও ধাপ ১-এর মতো ডাকলে — undefined behavior; ফল ধাপ ৫-এর মতো হবে, না ধাপ ৬-এর মতো, কেউ বলতে পারে না।

memcpy বনাম memmove — restrict-এর সবচেয়ে বিখ্যাত উদাহরণ

C99 থেকে standard library-তে এই দুটি ফাংশনের ঘোষণা এমন:

void *memcpy (void *restrict dest, const void *restrict src, size_t n);
void *memmove(void *dest,          const void *src,          size_t n);

পার্থক্য শুধু restrict শব্দে, কিন্তু অর্থে অনেক বড়। memcpy বলছে: “উৎস আর গন্তব্য একটির ওপর আরেকটি পড়বে না — পড়লে দায় আপনার।” তাই সে যেকোনো ক্রমে, যত দ্রুত সম্ভব কপি করে। memmove এমন কোনো প্রতিশ্রুতি চায় না — জায়গা overlap করলে সে পেছন দিক থেকে কপি করে বা অন্য কৌশলে সঠিক ফল দেয়।

নিচের কোডে একটি string-এর প্রথম ৫টি অক্ষর ২ ঘর ডানে সরাতে চাই — উৎস আর গন্তব্য একে অপরের ওপর পড়ছে। একই কাজ একবার সাধারণ সামনের দিকের লুপে, একবার memmove দিয়ে:

#include <stdio.h>
#include <string.h>

int main(void) {
    char a[] = "ABCDEFGH";
    char b[] = "ABCDEFGH";

    /* লক্ষ্য: প্রথম 5টি অক্ষর 2 ঘর ডানে -> "ABABCDEH" */
    for (int i = 0; i < 5; i++)      /* সামনে থেকে কপি */
        a[2 + i] = a[i];

    memmove(b + 2, b, 5);            /* overlap হলেও নিরাপদ */
    /* memcpy(b + 2, b, 5);  এখানে undefined behavior */

    printf("forward loop: %s\n", a);
    printf("memmove     : %s\n", b);
    return 0;
}

লুপটি a[2]-তে A লেখার সাথে সাথে আসল C মুছে যায়; পরে a[4] = a[2] আবার A পড়ে। ফল দাঁড়ায় ABABABAH — একই প্যাটার্ন বারবার ছড়িয়ে গেল। memmove জানে জায়গা overlap করছে, তাই পেছন থেকে কপি করে সঠিক ABABCDEH দেয়। একই জায়গায় memcpy ডাকলে কী হবে, তার কোনো নিশ্চয়তা নেই — কোনো মেশিনে ঠিক ফল, কোনোটিতে লুপের মতো ভুল। নিয়মটা সহজ: overlap-এর সামান্যতম সম্ভাবনা থাকলে memmove.

এই পার্থক্য শুধু তাত্ত্বিক নয়। ২০১০ সালের দিকে glibc তাদের memcpy-কে নতুন CPU-র জন্য দ্রুত করতে পেছন দিক থেকে কপি করা শুরু করে। এতে বহু বছর ধরে overlap জায়গায় ভুল করে memcpy ব্যবহার করা কিছু প্রোগ্রাম — Adobe Flash plugin সহ — হঠাৎ ভাঙা শব্দ ও ভুল আচরণ দেখাতে শুরু করে। দোষ library-র ছিল না; প্রতিশ্রুতি ভেঙেছিল প্রোগ্রামগুলো।

৭ · volatile — “এই মান পেছন থেকে বদলাতে পারে”

এতক্ষণ compiler-এর একটি অনুমান কাজে লাগাচ্ছিলাম: “মেমরির মান বদলায় শুধু আমার প্রোগ্রাম লিখলে।” সাধারণ প্রোগ্রামে এটি সত্য। কিন্তু embedded system-এ তা নয়। একটি microcontroller-এ (যেমন একটি স্মার্ট মিটার বা গার্মেন্টসের সেলাই-মেশিনের কন্ট্রোলার) সেন্সর, টাইমার, LED — সব যন্ত্র কিছু নির্দিষ্ট মেমরি-ঠিকানার সাথে যুক্ত। একে বলে memory-mapped I/O.

একটি ঠিকানা, পেছনে একটি যন্ত্র

Memory-mapped I/O — the address is wired to a device, not to RAM

  1. সেন্সরতাপমাত্রা মাপা শেষনিজে থেকে বিট সেট করে
  2. STATUS রেজিস্টার0x40000000RAM নয় — যন্ত্রের ঘর
  3. CPUwhile (!ready)বারবার পড়ে দেখে
ছবি ৪৫.১ — ঠিকানাটি দেখতে সাধারণ মেমরির মতো, কিন্তু এর মান বদলায় যন্ত্র, প্রোগ্রাম নয়। (ঠিকানাটি উদাহরণ মাত্র; প্রতিটি চিপের datasheet-এ আসল ঠিকানা থাকে।)

এই হার্ডওয়্যার register-এর মান প্রোগ্রামের অজান্তে বদলায়। আর এখানেই compiler-এর অনুমান বিপদ ডেকে আনে। নিচে একটি polling লুপ — সেন্সর “ready” না হওয়া পর্যন্ত অপেক্ষা:

sensor.cvolatile ছাড়া
#define READY  0x1u
#define STATUS (*(uint32_t *)0x40000000u)

while ((STATUS & READY) == 0) { }   /* অপেক্ষা */
read_temperature();
-O2-এর পর (সরলীকৃত assembly)একবার পড়ে চিরকাল লুপ
    ldr  r1, [r0]       ; STATUS একবারই পড়া হলো
    tst  r1, #1
    bne  done
hang:
    b    hang           ; আর কখনো পড়ে না!
done:
sensor.cvolatile সহ
#define READY  0x1u
#define STATUS (*(volatile uint32_t *)0x40000000u)

while ((STATUS & READY) == 0) { }
read_temperature();
-O2-এর পর (সরলীকৃত assembly)প্রতি ঘুরে নতুন করে পড়া
wait:
    ldr  r1, [r0]       ; প্রতিবার আসল পড়া
    tst  r1, #1
    beq  wait
    bl   read_temperature
লেখার দিকেও একই কথাLED রেজিস্টার
#define LED (*(volatile uint32_t *)0x40000004u)

LED = 1;   /* বাতি জ্বলো    */
LED = 0;   /* বাতি নেভো    */
/* volatile না থাকলে compiler প্রথম লেখাটি
   "অকারণ" ভেবে মুছে দিতে পারত */
রেজিস্টার বনাম CPU-র ধারণা
রেজিস্টারে READY বিট
CPU যা দেখছে
অবস্থা
  1. সাধারণ পয়েন্টারে রেজিস্টার পড়া। STATUS একটি নির্দিষ্ট ঠিকানাকে uint32_t হিসেবে পড়ে। লুপটি READY বিট 1 না হওয়া পর্যন্ত ঘুরবে — অন্তত আমরা তাই ভাবছি।
  2. Compiler-এর যুক্তি। লুপের ভেতরে কেউ STATUS-এ লেখে না। Compiler ভাবে, “তাহলে মান বদলাতেই পারে না — বারবার পড়া অপচয়।” সে একবার পড়ে, আর মান 0 হলে একটি অসীম লুপ বানায়। এটি সেই code hoisting, যা সাধারণ কোডে দারুণ কাজের।
  3. সেন্সর কাজ শেষ করল। কয়েক মিলিসেকেন্ড পর হার্ডওয়্যার READY বিট 1 করল। কিন্তু CPU আর কখনো ঠিকানাটি পড়ছে না — তার কাছে এখনো পুরোনো 0. যন্ত্র আটকে গেল। -O0-তে ঠিক চলে, -O2-তে আটকে যায় — embedded জগতে এটি খুব পরিচিত অভিযোগ।
  4. volatile যোগ করা হলো। এখন compiler জানে, এই ঠিকানার মান তার অজান্তে বদলাতে পারে। তাই কোডে যতবার পড়া লেখা আছে, ঠিক ততবার সত্যিই পড়তে হবে।
  5. প্রতি ঘুরে আসল পড়া। লুপের ভেতরেই ldr নির্দেশ। READY বিট 1 হওয়ার পরের ঘুরেই CPU সেটি দেখে, লুপ শেষ হয়, তাপমাত্রা পড়া শুরু হয়।
  6. লেখার দিকেও একই নিয়ম। সাধারণ ভ্যারিয়েবলে পরপর দুটি লেখার প্রথমটি বাদ দেওয়া নিরাপদ, কারণ শেষ মানটিই গুরুত্বপূর্ণ। কিন্তু LED রেজিস্টারে প্রতিটি লেখা একটি আলাদা ঘটনা। volatile থাকলে compiler কোনো পড়া বা লেখা বাদ দেয় না, জোড়া লাগায় না, আর volatile-গুলোর মধ্যে ক্রমও বদলায় না।
মূল ধারণা

volatile compiler-কে একটিমাত্র কথা বলে: “এই object-এর প্রতিটি পড়া ও লেখা একটি দেখা যায় এমন ঘটনা — কোনোটি বাদ দেবে না, বানিয়েও নেবে না।” এর বেশি কিছু নয়। এটি কোনো তালা নয়, কোনো ক্রমের নিশ্চয়তাও নয়।

Signal handler আর volatile sig_atomic_t

volatile-এর দ্বিতীয় বৈধ ব্যবহার signal handler. ব্যবহারকারী Ctrl+C চাপলে OS প্রোগ্রামকে SIGINT পাঠায়, আর প্রোগ্রামের যেকোনো জায়গা থেকে মাঝপথে handler ফাংশনটি চলে যায়। মূল লুপ যদি একটি flag দেখে থামার অপেক্ষায় থাকে, সেই flag হতে হবে এমন:

static volatile sig_atomic_t stop = 0;

void on_ctrl_c(int sig) { (void)sig; stop = 1; }   /* handler */

while (!stop) { do_work(); }                         /* মূল লুপ */

এখানে দুটি অংশ দুটি আলাদা কাজ করে। volatile নিশ্চিত করে মূল লুপ প্রতিবার stop নতুন করে পড়বে। আর sig_atomic_t এমন একটি টাইপ, যার মান লেখা signal এসে মাঝপথে থামালেও অর্ধেক লেখা অবস্থায় থাকে না। C standard বলে, asynchronous signal handler থেকে নিরাপদে লেখা যায় শুধু এমন volatile sig_atomic_t object-এ। Handler-এর ভেতরে printf বা malloc ডাকাও নিরাপদ নয় — পর্ব ৪৮-এ process আর signal নিয়ে আরও দেখব।

৮ · সবচেয়ে বড় ভুল ধারণা — volatile thread-এর ওষুধ নয়

ইন্টারনেটে, পুরোনো বইয়ে, এমনকি AI-এর উত্তরে প্রায়ই দেখবেন: “দুটি thread একটি ভ্যারিয়েবল ভাগ করলে সেটিকে volatile করুন।” C-তে এটি ভুল। কারণটা বুঝতে একটি bKash অ্যাকাউন্টের কথা ভাবুন, যেটি দুজন একসাথে দুটি ফোন থেকে ব্যবহার করছেন।

Thread-এর যা দরকারvolatile কি দেয়?উদাহরণ
Atomicity — একটি কাজ ভাঙা যাবে নানাcount++ আসলে তিন ধাপ: পড়া, ১ যোগ, লেখা। দুই thread একসাথে করলে একটি যোগ হারিয়ে যায় — দুজনেই ৫০০ টাকা দেখে ১০০ যোগ করলেন, ব্যালান্স হলো ৬০০, ৭০০ নয়।
Ordering — লেখার ক্রম অন্য thread একইভাবে দেখবেনা“ডেটা লিখলাম, তারপর ready = 1” — volatile নয় এমন ডেটার লেখা compiler বা CPU ready-র পরে সরিয়ে নিতে পারে। অন্য thread ready দেখে পুরোনো ডেটা পড়ে।
Visibility — এক CPU core-এর লেখা অন্য core দেখবেশুধু compiler-এর দিক থেকেআধুনিক CPU নিজেও লেখা আটকে রাখে ও ক্রম বদলায়। সেটি থামাতে বিশেষ নির্দেশ (memory barrier) লাগে, যা volatile দেয় না।

তাহলে কী ব্যবহার করবেন? C11 থেকে <stdatomic.h>-এ আছে _Atomic টাইপ ও atomic_fetch_add-এর মতো ফাংশন, যা atomicity আর ordering দুটোই দেয়। বড় অংশ রক্ষা করতে আছে mutex. পর্ব ৪৯-এ এগুলো বিস্তারিত দেখব। আর এই ধরনের বাগের নাম race condition — যা পর্ব ৪৯-এর মূল শত্রু।

গুলিয়ে যাওয়ার একটি কারণ আছে: Java আর C#-এ volatile-এর অর্থ আলাদা। সেখানে volatile ক্রম ও দৃশ্যমানতার নিশ্চয়তা দেয় (তবুও count++-কে atomic করে না)। আর Microsoft-এর compiler একসময় x86-এ volatile-কে বাড়তি ক্ষমতা দিত। একই শব্দ, ভিন্ন ভাষায় ভিন্ন চুক্তি — C-তে volatile শুধু হার্ডওয়্যার ও signal-এর জন্য।

বাস্তব জগতে এই তিন qualifier

🐧 Linux kernel

kernel-এর ডকুমেন্টেশনে একটি লেখাই আছে “volatile considered harmful” নামে। সাধারণ ভ্যারিয়েবলে volatile না লিখে তারা ব্যবহার করে READ_ONCE / WRITE_ONCE macro — যেখানে দরকার ঠিক সেখানে, ভেতরে volatile দিয়ে একবারের পড়া বা লেখা।

লক্ষ্যভেদী volatile

🔌 Embedded driver

চিপ নির্মাতারা যে header দেন, সেখানে প্রতিটি রেজিস্টার volatile হিসেবে ঘোষণা করা। শুধু-পড়ার রেজিস্টারে প্রায়ই volatile const — “আপনি লিখবেন না, কিন্তু মান নিজে থেকে বদলাবে।”

const ও volatile একসাথে

📚 বড় API

POSIX, SQLite, OpenSSL-এর মতো লাইব্রেরির ঘোষণায় const দেখেই বোঝা যায় কোন ডেটা লাইব্রেরি বদলাবে। memcpy, printf-এর মতো ফাংশনে restrict-ও আছে।

ঘোষণাই ডকুমেন্টেশন

৯ · AI-এর যুগে — qualifier-এর ভুল ধরা

AI খুব দ্রুত চলনসই সিস্টেম-কোড লেখে। কিন্তু qualifier-এর ভুলগুলো compile-এর সময় প্রায় কখনো ধরা পড়ে না — প্রোগ্রাম চলে, পরীক্ষাতেও পাশ করে, তারপর অন্য compiler, অন্য optimization বা অন্য হার্ডওয়্যারে গিয়ে ভাঙে। এজন্য এগুলো আপনাকে চোখে দেখে চিনতে হবে।

const ভাঙলে compiler চেঁচায়।
restrict বা volatile ভাঙলে প্রোগ্রাম চুপচাপ ভুল করে।

AI সাধারণত যেসব ভুল করে

AI-এর ভুলকেন বিপজ্জনকযা হওয়া উচিত
warning চুপ করাতে (char *) cast দিয়ে const সরিয়ে দেয়আসল ডেটা const বা string literal হলে লেখা মাত্র undefined behaviorফাংশনের parameter-এ const যোগ করা, বা লেখার জন্য আলাদা কপি তৈরি
thread-এর বাগ “সারাতে” ভ্যারিয়েবলে volatile বসায়বাগ লুকিয়ে যায়, মাঝে মাঝে ফিরে আসে — atomicity বা ordering আসে না_Atomic / stdatomic.h বা mutex (পর্ব ৪৯)
array-র ভেতরে ডেটা সরাতে memcpy ব্যবহার করেউৎস ও গন্তব্য overlap করছে — undefined behaviormemmove
বাইট দেখতে *(uint32_t *)&f ধরনের pointer castStrict aliasing ভাঙে; alignment-এর সমস্যাও হতে পারেmemcpy দিয়ে কপি, বা union
গতি বাড়াতে সব পয়েন্টারে restrict ছিটিয়ে দেয়কোনো ডাকে aliasing থাকলে নিঃশব্দে ভুল ফলপ্রতিটি restrict-এর পাশে লিখে রাখা — কোন প্রতিশ্রুতি, কেন সত্য

AI-কে qualifier নিয়ে প্রশ্ন করার সময় শুধু “ঠিক করে দাও” না বলে প্রতিটি পয়েন্টারের চুক্তি জিজ্ঞেস করুন। এতে AI-কে কারণ দেখাতে হয়, আর আপনি যাচাই করতে পারেন:

নিচের C কোডটি একটি microcontroller-এর জন্য লেখা, GCC -O2 দিয়ে compile হবে। [এখানে কোড পেস্ট করুন] ১. প্রতিটি পয়েন্টার parameter-এর জন্য বলো: এটি দিয়ে শুধু পড়া হয় না লেখাও হয়? const হওয়া উচিত কি না? ২. কোথাও কি cast দিয়ে const সরানো হয়েছে, বা ভিন্ন টাইপের পয়েন্টার দিয়ে একই মেমরি পড়া হয়েছে (strict aliasing)? ৩. memcpy যেখানে আছে, সেখানে উৎস ও গন্তব্য কি কখনো overlap করতে পারে? ৪. প্রতিটি volatile-এর কারণ বলো — হার্ডওয়্যার রেজিস্টার, signal handler, না অন্য কিছু? thread-এর জন্য volatile ব্যবহার করা হলে সেটি চিহ্নিত করো। ৫. পুরো কোড আবার লিখবে না — শুধু যে লাইন বদলাতে হবে সেটি দেখাও আর কারণ বলো।

AI-এর লেখা পয়েন্টার-কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১C-তে const ঐচ্ছিক, অথচ C++ আর Rust অপরিবর্তনীয়তাকে (immutability) অনেক কঠোর করেছে। কেন? Rust-এর &mut কি আসলে restrict?

C-তে ডিফল্ট হলো “সব কিছু বদলানো যায়” — const লিখতে মনে রাখতে হয়। ফলে বড় কোডে const প্রায়ই বাদ পড়ে, আর একবার বাদ পড়লে ওপরের সব ফাংশনেও বাদ দিতে হয়। ভাষার ইতিহাসে এই শিক্ষা থেকেই নতুন ভাষাগুলো উল্টো পথ ধরেছে।

  • C++: const-কে আরও গভীরে নিয়ে গেছে — const member function, const reference, আর constexpr. string literal-এর টাইপও সেখানে সত্যিই const char[].
  • Rust: ডিফল্টই অপরিবর্তনীয়; বদলাতে চাইলে mut লিখতে হয়। আরও বড় কথা, Rust-এর borrow checker নিয়ম করে: একটি মেমরিতে হয় যত খুশি শুধু-পড়ার রেফারেন্স (&T), নয়তো ঠিক একটি লেখার রেফারেন্স (&mut T) — দুটো একসাথে কখনো নয়।

এই দ্বিতীয় নিয়মটি আসলে restrict-এর প্রতিশ্রুতি — “এই মেমরিতে যাওয়ার একমাত্র পথ আমি।” পার্থক্য একটাই, এবং সেটিই সব: C-তে প্রতিশ্রুতিটি আপনি মুখে দেন, compiler যাচাই করে না। Rust-এ compiler নিজেই প্রমাণ করে নেয় যে নিয়ম ভাঙেনি, না হলে compile-ই হয় না। তাই Rust নিশ্চিন্তে LLVM-কে “এখানে aliasing নেই” জানাতে পারে।

একটি মজার ঘটনা: Rust যখন সত্যিই এই তথ্য ব্যাপকভাবে LLVM-কে দিতে শুরু করল, তখন LLVM-এর ভেতরের কয়েকটি পুরোনো বাগ বেরিয়ে এলো — কারণ C প্রোগ্রামাররা restrict এত কম ব্যবহার করেন যে ঐ পথগুলো ঠিকমতো পরীক্ষিতই ছিল না। বাগগুলো সারাতে বেশ কয়েক বছর লেগেছিল, আর সেই সময় Rust এই সুবিধা বন্ধ রেখেছিল।

মূল উপলব্ধি: যে নিয়ম মানুষকে মনে রাখতে হয়, সেটি একসময় ভাঙবেই। ভালো ভাষা-নকশা নিরাপদ পথকে ডিফল্ট বানায়, আর প্রতিশ্রুতি যাচাইয়ের কাজটি মানুষের হাত থেকে compiler-এর হাতে তুলে দেয়।

প্র ০২বহু বছর ধরে বলা হতো, সংখ্যা নিয়ে ভারী হিসাবে Fortran C-এর চেয়ে দ্রুত। একই হার্ডওয়্যারে একটি ভাষা আরেকটির চেয়ে দ্রুত হয় কীভাবে?

উত্তরটা লুকিয়ে আছে aliasing-এ — কোনো জাদুকরী গণিতে নয়। আবহাওয়ার পূর্বাভাস, সেতুর নকশা বা পদার্থবিজ্ঞানের simulation-এ বিশাল array নিয়ে লুপ চলে — ঠিক আমাদের add_all-এর মতো, শুধু লক্ষ-কোটিবার।

  • Fortran-এর নিয়ম: কোনো subroutine-এ দুটি array argument পাঠানো হলো, আর তাদের একটিতে লেখা হবে — তাহলে দুটি একই মেমরি ভাগ করতে পারবে না। এমন ডাক ভাষার নিয়মেই অবৈধ। তাই Fortran compiler শুরু থেকেই ধরে নিতে পারে “কোনো aliasing নেই”, আর নির্ভয়ে মান register-এ রাখে, লুপ ঘুরিয়ে-ফিরিয়ে সাজায়, vector নির্দেশ ব্যবহার করে।
  • C-এর নিয়ম: যেকোনো দুটি একই টাইপের পয়েন্টার একই জায়গা দেখাতে পারে। তাই C compiler-কে ধাপ ৬-এর মতো সাবধানী হতে হতো — প্রতিবার মেমরি থেকে পড়া, vector নির্দেশ এড়ানো, বা চালানোর সময় overlap যাচাইয়ের বাড়তি কোড বসানো।

C99-এর restrict ঠিক এই ফাঁক পূরণের জন্যই আনা হয়েছিল — প্রোগ্রামারকে বলার সুযোগ দেওয়া, “এখানে আমি Fortran-এর মতো প্রতিশ্রুতি দিচ্ছি।” আজ BLAS-এর মতো উচ্চগতির গণিত library-র C সংস্করণে restrict দেখা যায়, আর দুই ভাষার পার্থক্য অনেকটাই কমে এসেছে। তবুও Fortran এখনো বৈজ্ঞানিক হিসাবের জগতে টিকে আছে, আংশিকভাবে এই কারণেই।

এখানে একটি গভীর trade-off আছে। Fortran-এর নিয়ম গতি দেয়, কিন্তু প্রোগ্রামারের স্বাধীনতা কমায়। C স্বাধীনতা দেয়, বিনিময়ে compiler-কে অন্ধকারে রাখে। Rust দেখিয়েছে তৃতীয় পথও সম্ভব — নিয়ম কঠোর, কিন্তু compiler যাচাই করে দেয়।

মূল উপলব্ধি: ভাষার গতি শুধু হার্ডওয়্যার বা compiler-এর ওপর নির্ভর করে না — নির্ভর করে ভাষা compiler-কে কতটা নিশ্চিত তথ্য দেয় তার ওপর। যত বেশি নিশ্চয়তা, তত সাহসী optimization.

প্র ০৩volatile দিলে compiler তো প্রতিবার মেমরি থেকে পড়ে। তাহলে দুই thread-এর মধ্যে একটি flag ভাগাভাগিতে সেটি যথেষ্ট নয় কেন?

কারণ thread-এর সমস্যা তিন স্তরের, আর volatile সামলায় শুধু প্রথম স্তরের একটি অংশ।

  • স্তর ১ — compiler: volatile নিশ্চিত করে volatile ভ্যারিয়েবলের পড়া-লেখা বাদ যাবে না, আর volatile-গুলো নিজেদের মধ্যে ক্রম বদলাবে না। কিন্তু সাধারণ ভ্যারিয়েবলের পড়া-লেখা compiler volatile-এর আগে-পিছে সরাতে পারে। “ডেটা লিখলাম, তারপর volatile ready = 1” — ডেটার লেখা ready-র পরে চলে যেতে পারে।
  • স্তর ২ — CPU: আধুনিক CPU নিজেও লেখাগুলো একটি store buffer-এ জমিয়ে রাখে, আর ARM-এর মতো কিছু CPU অন্য core-কে লেখাগুলো ভিন্ন ক্রমে দেখাতে পারে। এটি থামাতে লাগে memory barrier বা fence নির্দেশ। volatile এমন কোনো নির্দেশ যোগ করে না।
  • স্তর ৩ — atomicity: counter++ হলো পড়া-যোগ-লেখা। দুই thread একসাথে করলে দুজনেই পুরোনো মান পড়ে একই নতুন মান লেখে; একটি যোগ হারায়। volatile এই তিন ধাপকে জোড়া লাগায় না। ৬৪-বিট মান ৩২-বিট CPU-তে দুই টুকরোয় লেখা হলে অন্য thread “অর্ধেক পুরোনো, অর্ধেক নতুন” মানও দেখতে পারে।

C11-এর _Atomic তিনটি স্তরই সামলায়: compiler-কে ক্রম মানতে বলে, CPU-কে প্রয়োজনীয় barrier নির্দেশ দেয়, আর কাজটিকে ভাঙা যায় না এমন করে। Mutex আরও বড় অংশকে একসাথে রক্ষা করে। আর C-এর memory model অনুযায়ী, atomic ছাড়া দুই thread একই ভ্যারিয়েবলে একজন লিখছে আরেকজন পড়ছে — এমন data race-ই undefined behavior, volatile থাকুক বা না থাকুক।

volatile দিয়ে “সারানো” কোড প্রায়ই x86 ল্যাপটপে ঠিক চলে, কারণ x86 লেখার ক্রম তুলনামূলক কঠোরভাবে রক্ষা করে। কিন্তু একই কোড ARM-এর ফোন বা সার্ভারে মাঝে মাঝে ভুল করে — যা সবচেয়ে কঠিন ধরনের বাগ।

মূল উপলব্ধি: “compiler যেন বাদ না দেয়” আর “অন্য thread যেন ঠিক ক্রমে, পুরোটা দেখে” — দুটি আলাদা সমস্যা। প্রথমটির উত্তর volatile, দ্বিতীয়টির উত্তর atomic আর mutex.

প্র ০৪একটি মেমরি-ঠিকানায় লিখলে একটি বাতি জ্বলে ওঠে — হার্ডওয়্যার রেজিস্টার আসলে কীভাবে কাজ করে?

CPU যখন কোনো ঠিকানায় পড়ে বা লেখে, সে আসলে একটি bus-এ ঠিকানা আর ডেটা পাঠায়। চিপের ভেতরে একটি “address decoder” দেখে ঠিকানাটি কার — RAM-এর, ফ্ল্যাশ মেমরির, নাকি কোনো যন্ত্রের (GPIO পিন, টাইমার, UART, ADC). অনেক microcontroller-এ ঠিকানার একটি বড় অংশ শুধু যন্ত্রগুলোর জন্য রাখা থাকে। সেই ঠিকানায় লেখা ডেটা RAM-এ না গিয়ে সরাসরি যন্ত্রের ভেতরের flip-flop-এ পৌঁছায়, আর সেখান থেকে তার একটি পিনে ভোল্টেজ হয় — বাতি জ্বলে।

এখানে কিছু আচরণ আছে, যা সাধারণ মেমরিতে অকল্পনীয়:

  • পড়ারও পার্শ্বপ্রতিক্রিয়া আছে: UART-এর ডেটা রেজিস্টার পড়লে পরের অক্ষরটি সারি থেকে বেরিয়ে যায়। কিছু status রেজিস্টার পড়লেই নিজে থেকে মুছে যায়। তাই compiler একটি পড়াও বাদ দিলে বা বাড়তি পড়া যোগ করলে যন্ত্রের অবস্থা বদলে যায়।
  • লেখা মানে আদেশ: কিছু রেজিস্টারে কোনো বিটে 1 লিখলে সেই বিট মুছে যায় (“write 1 to clear”). একই মান দুবার লেখা মানে দুটি আলাদা আদেশ।
  • শুধু-পড়ার রেজিস্টার: এগুলোকে volatile const ঘোষণা করা হয় — প্রোগ্রাম লিখবে না, কিন্তু মান নিজে বদলাবে।

সব CPU এই পদ্ধতিই নেয় না। x86-এ পুরোনো দিন থেকে আলাদা একটি “port” ঠিকানা-জগৎ আছে, যেখানে পৌঁছাতে বিশেষ in/out নির্দেশ লাগে (port-mapped I/O). তবে আধুনিক PC-তেও গ্রাফিক্স কার্ড বা নেটওয়ার্ক কার্ডের বেশিরভাগ রেজিস্টার memory-mapped. আর বড় OS-এ এই ঠিকানাগুলো সাধারণ প্রোগ্রাম সরাসরি ছুঁতে পারে না — শুধু kernel-এর driver পারে।

মূল উপলব্ধি: C-এর “মেমরি” ধারণাটি একটি বিমূর্ত রূপ। একটি পয়েন্টারের পেছনে RAM, ফ্ল্যাশ বা একটি আস্ত যন্ত্র থাকতে পারে। volatile হলো compiler-কে সেই সত্যটি জানানোর উপায় — “এই ঠিকানা সাধারণ মেমরি নয়।”

প্র ০৫Strict aliasing ভাঙলে আসলে কী ঘটে? আর Linux kernel কেন -fno-strict-aliasing দিয়ে compile হয়?

Strict aliasing ভাঙলে compiler ভুল অনুমানের ওপর ভিত্তি করে কোড সাজায়। একটি ক্লাসিক রূপ:

int f(int *i, float *x) {
    *i = 1;
    *x = 0.0f;
    return *i;   /* compiler: "int আর float একই ঘর হতে পারে না" -> সরাসরি 1 */
}

কেউ যদি একই ঠিকানা cast করে দুটিতেই পাঠায়, তাহলে -O0-তে ফল আসবে 0 (কারণ float-এর শূন্য বিটগুলো int হিসেবেও 0), আর -O2-তে আসতে পারে 1. কোনো warning ছাড়াই দুটি ভিন্ন ফল — এটাই undefined behavior-এর চেহারা। বাস্তব কোডে এর ফল দাঁড়ায় network packet ভুল পড়া, checksum ভুল হিসাব, বা নিরাপত্তা-যাচাই বাদ পড়া।

পুরোনো কোডে এমন cast অসংখ্য। বিখ্যাত উদাহরণ Quake III খেলার “fast inverse square root” ফাংশন, যেখানে * ( long * ) &y লিখে একটি float-এর বিট পড়া হয়েছে। সেই সময়ের compiler-এ এটি কাজ করত।

Linux kernel-এর সিদ্ধান্ত: kernel-এ network header, ফাইল-সিস্টেমের ডেটা, হার্ডওয়্যারের কাঠামো — সবখানে একই বাইটকে ভিন্ন ভিন্ন struct হিসেবে দেখার দরকার পড়ে। কয়েক কোটি লাইনের কোডে প্রতিটি জায়গা strict aliasing মেনে লেখা প্রায় অসম্ভব, আর একটি ভুলের দাম অনেক বড়। তাই kernel -fno-strict-aliasing দিয়ে compiler-কে এই অনুমান করতে নিষেধ করে। Linus Torvalds বহুবার প্রকাশ্যে বলেছেন, তাঁর মতে এই নিয়মের লাভের চেয়ে ক্ষতি বেশি। বিনিময়ে kernel কিছু optimization হারায় — সচেতনভাবে নেওয়া trade-off.

আপনার নিজের কোডের জন্য পরামর্শ: type punning লাগলে memcpy ব্যবহার করুন — আধুনিক compiler এটিকে বাড়তি খরচ ছাড়াই একটি নির্দেশে নামিয়ে আনে। আর GCC-র -Wstrict-aliasing warning কিছু ভুল ধরতেও পারে।

মূল উপলব্ধি: optimization মানে compiler-এর সাথে একটি চুক্তি। চুক্তি মানলে গতি পাবেন; না মানতে পারলে চুক্তিটি সচেতনভাবে বাতিল করুন (flag দিয়ে), চুপচাপ ভেঙে নয়।

অনুশীলন

এগুলোর বেশিরভাগে কোড লিখতে হবে না — হাতে-কলমে দেখা আর ভাবাই আসল কাজ।

  1. ঘোষণা পড়ুন: right-left rule দিয়ে চারটি ঘোষণা বাংলায় পড়ুন — (ক) char *const name (খ) const char *names[10] (গ) double (*grid)[3] (ঘ) void (*actions[4])(void)

    (ক) name হলো একটি const পয়েন্টার, char-এর দিকে — ঠিকানা বদলানো যাবে না, অক্ষর বদলানো যাবে।

    (খ) names হলো ১০টি ঘরের array; প্রতিটি ঘর const char-এর পয়েন্টার — যেমন ১০টি বিভাগের নাম, যেগুলো পড়া যাবে কিন্তু বদলানো যাবে না।

    (গ) grid হলো একটি পয়েন্টার, ৩টি double-এর array-র দিকে — একটি 2D টেবিলের সারি ধরে ধরে চলার জন্য।

    (ঘ) actions হলো ৪টি ঘরের array; প্রতিটি ঘর এমন ফাংশনের পয়েন্টার, যা কিছু নেয় না ও কিছু ফেরত দেয় না — পর্ব ২৮-এর dispatch table-এর মতো।

  2. ফল অনুমান করুন: restrict-এর ডায়াগ্রামের add_all ফাংশনকে int arr[4] = {3, 1, 1, 1}; দিয়ে add_all(arr, &arr[0], 4) ডাকলে (restrict ছাড়া) ফল কী হবে? Compiler যদি *a একবার পড়ে রাখত, তাহলে কী হতো?

    এখানে *a হলো arr[0]. i = 0-তে arr[0] = 3 + 3 = 6, আর এখন থেকে *a = 6. তারপর arr[1] = 1 + 6 = 7, arr[2] = 7, arr[3] = 7. সঠিক ফল {6, 7, 7, 7}.

    একবার পড়ে রাখলে (*a = 3) ফল হতো {6, 4, 4, 4} — পুরো আলাদা। এই একটি উদাহরণই দেখায় কেন restrict ছাড়া compiler সাবধানী হতে বাধ্য।

  3. volatile চোখে দেখুন: godbolt.org-এ লিখুন int flag; void wait(void) { while (!flag) { } }. Compiler option-এ -O2 দিয়ে assembly দেখুন। তারপর int flag-কে volatile int flag করুন। কী বদলাল?

    volatile ছাড়া দেখবেন flag একবারই পড়া হয়, তারপর একটি নিজেই-নিজের-দিকে লাফ দেওয়া নির্দেশ (একটি অসীম লুপ)। volatile দিলে লুপের ভেতরেই মেমরি থেকে পড়ার নির্দেশ (mov বা ldr) চলে আসে। মনে রাখুন, এখানে flag যদি অন্য thread বদলায়, তাহলে সঠিক সমাধান volatile নয় — _Atomic int flag. godbolt-এ সেটি দিয়েও দেখুন, assembly-তে কী আসে।

  4. AI অডিট: যেকোনো AI-কে বলুন: “দুটি pthread একটি int counter বাড়ায়, ফল মাঝে মাঝে কম আসে। C-তে সমাধান দাও।” উত্তরে কি volatile আছে? এই পর্বের চেকলিস্ট দিয়ে উত্তরটি যাচাই করুন।

    ভালো উত্তরে থাকবে pthread_mutex_lock/unlock অথবা _Atomic int আর atomic_fetch_add. যদি উত্তর শুধু “counter-কে volatile করুন” হয়, সেটি ভুল — counter++ তবুও পড়া-যোগ-লেখার তিন ধাপ, আর দুই thread-এর যোগ হারিয়ে যেতেই থাকবে। কিছু উত্তরে volatile আর mutex দুটোই থাকে; সেখানে volatile অপ্রয়োজনীয়, mutex-ই কাজ করছে। AI-কে পাল্টা জিজ্ঞেস করুন: “এখানে volatile না থাকলে কী বদলাত?” — উত্তর থেকেই বোঝা যাবে সে আসলে বোঝে কি না।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমint *(*fp)(int) পড়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. নাম খুঁজুন: fp
  2. বন্ধনীর ভেতরে বাঁয়ে * — fp একটি পয়েন্টার
  3. বন্ধনী থেকে বেরিয়ে ডানে (int) — int নেয় এমন ফাংশনের দিকে
  4. বাঁয়ে * — ফাংশনটি পয়েন্টার ফেরত দেয়
  5. সবশেষে int — int-এর পয়েন্টার

int x = 1, y = 2; int *const p = &x; — এরপর কোন লাইনটি error দেবে?

  • *p = 5;
  • p = &y;
  • int v = *p;
  • x = 7;

size_t strlen(const char *s); — এখানে const আপনাকে কী নিশ্চয়তা দেয়?

  • strlen দ্রুত চলবে
  • strlen-এর ভেতরে s পয়েন্টার সরানো যাবে না
  • strlen আপনার string-এর কোনো অক্ষর বদলাবে না
  • string-টি পুরো প্রোগ্রামে আর কখনো বদলাবে না

একটি array-র ভেতরে ডেটা ৩ ঘর ডানে সরাতে হবে — উৎস আর গন্তব্য একে অপরের ওপর পড়ছে। কোন ফাংশন?

  • memmove
  • memcpy
  • strcpy
  • সামনের দিক থেকে একটি সাধারণ for লুপ

একটি microcontroller-এ সেন্সরের status রেজিস্টার polling করা লুপ -O0-তে চলে, -O2-তে চিরকাল আটকে থাকে। সম্ভাব্য কারণ?

  • সেন্সরটি নষ্ট
  • রেজিস্টারের পয়েন্টারে volatile নেই
  • রেজিস্টারের পয়েন্টারে const আছে
  • পয়েন্টারে restrict নেই

দুটি thread একই int count বাড়াচ্ছে, ফল মাঝে মাঝে কম আসে। সঠিক সমাধান কোনটি?

  • volatile int count
  • const int count
  • _Atomic int count (বা mutex দিয়ে রক্ষা)
  • int *restrict দিয়ে count-এ পৌঁছানো

একটি float-এর বিটগুলো uint32_t হিসেবে দেখতে চান। কোন উপায়টি C-তে নিরাপদ ও বৈধ?

  • memcpy(&u, &f, sizeof u);
  • u = *(uint32_t *)&f;
  • u = (uint32_t)f;
  • u = *(volatile uint32_t *)&f;

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Qualifier = প্রতিশ্রুতি। const ভাঙলে compiler ধরে; restrict আর volatile ভাঙলে প্রোগ্রাম নিঃশব্দে ভুল করে।
  2. ঘোষণা পড়ুন নাম থেকে, ডানে তারপর বাঁয়ে; *-এর বাঁয়ে const = ডেটায় তালা, ডানে const = পয়েন্টারে তালা। জটিল হলে typedef.
  3. শুধু-পড়ার প্রতিটি পয়েন্টারে const; cast দিয়ে const সরানো মানে চুক্তি ছেঁড়া।
  4. Aliasing compiler-কে সাবধানী করে; restrict সেই ভয় সরায়। Overlap থাকলে memmove, কখনো memcpy নয়; type punning-এ memcpy, pointer cast নয়।
  5. volatile শুধু হার্ডওয়্যার রেজিস্টার ও volatile sig_atomic_t-এর জন্য; thread-এ লাগে atomic বা mutex.

পরের পর্বে: আজ দেখলেন compiler-কে কথা দিয়ে সেই কথা ভাঙলে কী হয়। পরের পর্বে পুরো ছবিটি — undefined behavior আসলে কী, কেন compiler তার সুযোগ নেয়, আর কোন কোন অভ্যাস মানলে আপনার C কোড আক্রমণকারীর হাতে দরজা খুলে দেয় না।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান