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

ডিবাগিং — gdb, sanitizer আর Valgrind দিয়ে বাগ খোঁজা

Debugging C — the Scientific Method, GDB, AddressSanitizer, UBSan & Valgrind

Taught in Bangla, this lesson turns debugging into a scientific method: classifying bugs, using compiler warnings, printf and assert, stepping through code with gdb, reading backtraces and core dumps, and catching memory errors with AddressSanitizer, UBSan and Valgrind.

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

  • ডিবাগিংকে অনুমানের খেলা নয়, একটি বৈজ্ঞানিক পদ্ধতি হিসেবে চালানো — আর bug-কে ছোট করে আনার কৌশল
  • Bug-এর জাত চেনা (compile, link, crash, ভুল উত্তর, leak, heisenbug) আর প্রতিটির জন্য সঠিক যন্ত্র বাছাই
  • ঠিকভাবে printf-debugging আর assert দিয়ে নিজের ধারণা যাচাই
  • gdb-তে breakpoint, stepping, watchpoint, backtrace আর core dump ব্যবহার
  • AddressSanitizer, UBSan আর Valgrind-এর রিপোর্ট ওপর থেকে নিচে পড়ে আসল লাইনটি খুঁজে বের করা

১ · Bug কোথায় থাকে — ডিবাগিং একটি বৈজ্ঞানিক পদ্ধতি

পর্ব ৩০-এ আমরা শিখেছি, প্রোগ্রামের বাইরের পৃথিবী থেকে আসা ভুল — ফাইল নেই, মেমরি ফুরিয়েছে — কীভাবে সামলাতে হয়। কিন্তু আরেক জাতের ভুল আছে, যার জন্য কোনো if লেখা যায় না: আপনার নিজের কোডের ভুল। প্রোগ্রাম ভুল উত্তর দেয়, হঠাৎ ভেঙে পড়ে, বা দিনে দিনে মেমরি খেয়ে ফেলে। এই ভুলকে বলে bug, আর তাকে খুঁজে বের করে ঠিক করার কাজকে বলে debugging.

নতুন প্রোগ্রামাররা প্রায়ই এভাবে debug করেন: কোথাও একটা কিছু বদলান, আবার চালান, না হলে আরেকটা কিছু বদলান। কখনো কখনো ঠিকও হয়ে যায় — কিন্তু কেন ঠিক হলো, তা কেউ জানে না। এতে bug প্রায়ই মরে না, শুধু লুকিয়ে পড়ে। পেশাদার পদ্ধতি একদম আলাদা।

মূল ধারণা

কম্পিউটার কখনো ভুল করে না — সে ঠিক সেটাই করে যা আপনি লিখেছেন। Bug মানে আপনার মাথার কোনো একটি ধারণা (“এই লুপ সব মান যোগ করে”, “এই pointer কখনো NULL নয়”) আর কোডের আসল আচরণের মধ্যে অমিল। ডিবাগিং হলো সেই ভুল ধারণাটিকে প্রমাণ দিয়ে খুঁজে বের করা।

ধরুন ক্রিকেট ম্যাচে LBW-র আবেদন। ভালো আম্পায়ার আন্দাজে আঙুল তোলেন না। DRS-এ প্রথমে রিপ্লে দেখা হয় (পর্যবেক্ষণ), তারপর প্রশ্ন — বল কি লাইনে পিচ করেছে? প্যাডে লেগেছে, না ব্যাটে? (অনুমান) — তারপর ball-tracking দিয়ে প্রতিটি প্রশ্নের উত্তর যাচাই। ডিবাগিংও এমন: প্রমাণ না দেখে রায় নয়।

এই পদ্ধতিটি ছয়টি ধাপে চলে। নিচে একটি বাস্তব bug-এর ওপর পুরো চক্রটি প্রয়োগ করা হলো: পাঁচজন ছাত্রের নম্বরের গড় বের করার একটি প্রোগ্রাম, যা ভুল গড় দেখাচ্ছে। “পরে” চেপে এগোন:

  1. পুনরুৎপাদনreproduceপ্রতিবার ঘটানো
  2. পর্যবেক্ষণobserveভেতরের মান দেখা
  3. অনুমানhypothesiseকোন ধারণা ভুল?
  4. যাচাইtestআগে ভবিষ্যদ্বাণী
  5. সমাধানfixআসল কারণে
  6. প্রতিরোধpreventআর যেন না ফেরে
ধাপ ১ · পুনরুৎপাদনইনপুট স্থির, ফল স্থির
ইনপুট : 72 85 90 67 81
হাতে হিসাব : (72+85+90+67+81) / 5 = 395 / 5 = 79.00
প্রোগ্রাম  : Average: 64.00        (পাঁচবার চালালাম, পাঁচবারই 64.00)
ধাপ ২ · পর্যবেক্ষণstderr-এ debug print
[dbg] i=1 mark=85 sum=85
[dbg] i=2 mark=90 sum=175
[dbg] i=3 mark=67 sum=242
[dbg] i=4 mark=81 sum=323
[dbg] sum=323 used=4 avg=64.00   ← i=0 নেই! আর 323/5 = 64.6, অথচ 64.00
ধাপ ৩ · অনুমানদুটি সন্দেহ
অনুমান ক : লুপ i = 1 থেকে শুরু — প্রথম নম্বর 72 বাদ পড়ছে
অনুমান খ : sum / n দুটোই int — integer division, দশমিক কেটে যাচ্ছে
          (ভুল ধারণা ছিল: "double-এ রাখলেই ভাগফল দশমিক হবে")
ধাপ ৪ · যাচাইআগে বলুন কী হবে, তারপর চালান
পরীক্ষা ১: i = 0 করলে ভবিষ্যদ্বাণী sum = 395      → এলো 395  অনুমান ক সত্য
          কিন্তু 395/5 = 79 পুরো সংখ্যা — খ লুকিয়ে গেল!
পরীক্ষা ২: ইনপুট {1, 2}, ভবিষ্যদ্বাণী খ সত্য হলে 1.00 → এলো 1.00 খ-ও সত্য
ধাপ ৫ · সমাধানদুটি কারণ, দুটি বদল
for (int i = 0; i < n; i++)
double avg = (double)sum / n;
ফল: Average: 79.00, আর {1, 2}-এ 1.50
ধাপ ৬ · প্রতিরোধধারণাকে কোডে লিখে রাখা
assert(used == n);                         /* সব নম্বর গোনা হয়েছে */
test: average({1, 2}) == 1.50              /* integer division ফিরলেই ধরা পড়বে */
test: average({72, 85, 90, 67, 81}) == 79.00
  1. পুনরুৎপাদন — প্রতিবার ঘটাতে পারা। যে bug ইচ্ছামতো ঘটানো যায় না, তার সমাধান যাচাইও করা যায় না। তাই প্রথম কাজ একটি স্থির ইনপুট আর হাতে হিসাব করা প্রত্যাশিত ফল ঠিক করা। এখানে প্রত্যাশা 79.00, পাচ্ছি 64.00 — প্রতিবার।
  2. পর্যবেক্ষণ — ভেতরে উঁকি। শুধু শেষ ফল দেখে কারণ বোঝা যায় না। লুপের প্রতিটি ধাপে মান ছাপতেই দুটি অদ্ভুত জিনিস চোখে পড়ল: i=0-এর কোনো লাইন নেই, আর 323 ÷ 5 = 64.6 হওয়ার কথা, অথচ দেখাচ্ছে 64.00.
  3. অনুমান — কোন ধারণাটি ভুল? প্রতিটি অদ্ভুত পর্যবেক্ষণ থেকে একটি স্পষ্ট, যাচাইযোগ্য অনুমান। “কোথাও কিছু গোলমাল” কোনো অনুমান নয়; “লুপ ১ থেকে শুরু বলে 72 বাদ যাচ্ছে” — এটি অনুমান, কারণ একে ভুল প্রমাণ করা যায়।
  4. যাচাই — আগে ভবিষ্যদ্বাণী, পরে চালানো। চালানোর আগেই বলুন কী দেখবেন বলে আশা করছেন। মজার ব্যাপার: প্রথম bug ঠিক করতেই দ্বিতীয়টি লুকিয়ে গেল, কারণ 395 ÷ 5 ঠিক 79. এজন্য দ্বিতীয় অনুমানের জন্য আলাদা ইনপুট {1, 2} বাছতে হলো, যেখানে ভাগফল দশমিক।
  5. সমাধান — লক্ষণে নয়, কারণে। দুটি আলাদা root cause, তাই দুটি আলাদা বদল: লুপ ০ থেকে, আর ভাগের আগে (double) cast (পর্ব ০৮-এর লুকানো রূপান্তর)। “শেষে ১৫ যোগ করে দাও” ধরনের জোড়াতালি কখনো নয়।
  6. প্রতিরোধ — একই bug যেন আর না ফেরে। যে ধারণাটি ভুল ছিল, সেটিকে কোডে শর্ত হিসেবে লিখে রাখুন (assert), আর যে ইনপুটে bug ধরা পড়েছিল সেটিকে স্থায়ী test বানান। পর্ব ৩২-এ এই test-গুলোই স্বয়ংক্রিয় হবে।

চক্রটিকে গতি দেওয়ার চারটি অভ্যাস

  • Error message পুরোটা পড়ুন — প্রথম লাইন থেকে। Compiler বা sanitizer প্রায়ই সঠিক ফাইল, লাইন আর কারণ বলে দেয়। প্রথম error-টিই সাধারণত আসল; পরের বিশটি প্রায়ই তারই প্রতিধ্বনি।
  • রাবার হাঁসকে বোঝান। Rubber duck debugging নামটি এসেছে “The Pragmatic Programmer” (১৯৯৯) বইয়ের একটি গল্প থেকে: একজন প্রোগ্রামার টেবিলে রাখা খেলনা হাঁসকে লাইন ধরে ধরে কোড বোঝাতেন। “এই লাইনে i শূন্য থেকে… না, এক থেকে শুরু হচ্ছে!” — জোরে বলতে গেলেই অনেক ভুল ধারণা নিজের কানে ধরা পড়ে।
  • Bug-কে ছোট করুন। ১০,০০০ লাইনের প্রোগ্রামে bug খোঁজা কঠিন। যে অংশ বাদ দিলেও bug থাকে, সেটি বাদ দিন — যতক্ষণ না ১৫–২০ লাইনের একটি minimal reproducible example পান। অনেক সময় ছাঁটতে ছাঁটতেই কারণ চোখে পড়ে যায়, আর কাউকে সাহায্যের জন্য দেখাতেও এটিই লাগে।
  • “কাল চলত, আজ চলে না”? Binary search করুন। পর্ব ৪১-এ যে binary search বিস্তারিত দেখবেন, তার মূল চিন্তা সহজ: প্রতিবার খোঁজার জায়গা অর্ধেক করা। Git-এর git bisect commit-এর ইতিহাসে ঠিক সেটাই করে — ১,০০০টি commit-এর মধ্যে মাত্র ১০টির মতো পরীক্ষায় দেখিয়ে দেয় কোন commit প্রথম bug এনেছিল।

২ · Bug-এর জাত চেনা — কোন ভুলে কোন যন্ত্র

ডাক্তার জ্বর দেখলেই একই ওষুধ দেন না — আগে রোগ চেনেন। Bug-ও কয়েক জাতের, আর প্রতিটির জন্য আলাদা যন্ত্র। জাত চেনা গেলে অর্ধেক কাজ শেষ:

জাতলক্ষণকে ধরে
Compile errorfile.c:12:5: error: … — প্রোগ্রাম তৈরিই হয় নাCompiler নিজেই; লাইন নম্বরসহ
Link errorundefined reference to `calc_total'Linker — ফাংশন ঘোষিত, কিন্তু সংজ্ঞা কোথাও নেই (পর্ব ০৩, ২৯)
Runtime crashSegmentation fault (core dumped)gdb-র backtrace, AddressSanitizer
ভুল উত্তরপ্রোগ্রাম চলে, কিন্তু ফল ভুলprintf, assert, gdb, test — সবচেয়ে কঠিন, কারণ কেউ সতর্ক করে না
Memory leakদীর্ঘক্ষণ চললে মেমরি বাড়তেই থাকেLeakSanitizer, Valgrind
Undefined behavior“আমার মেশিনে চলে”, -O2-এ অদ্ভুত আচরণUBSan, ASan, warning (পর্ব ৪৬)
Heisenbugমাঝে মাঝে ঘটে; খুঁজতে গেলে লুকায়ThreadSanitizer, record-replay (পর্ব ৪৯)

খেয়াল করুন, তালিকার ওপরের দিকে bug যত, ধরা তত সহজ — compiler নিজেই লাইন দেখিয়ে দেয়। নিচের দিকে যত নামবেন, bug তত চুপচাপ। এজন্য পেশাদারদের একটি কৌশল হলো bug-কে ওপরের দিকে ঠেলে দেওয়া: ভুল উত্তরকে assert দিয়ে crash-এ বদলে দেওয়া, চুপচাপ মেমরি-ভুলকে sanitizer দিয়ে স্পষ্ট রিপোর্টে বদলে দেওয়া। জোরে ভেঙে পড়া bug চুপচাপ ভুল উত্তর দেওয়া bug-এর চেয়ে অনেক ভালো।

Link error-কে compile error ভাববেন না। undefined reference দেখলে কোডের ব্যাকরণ খুঁজে লাভ নেই — compile ঠিকই হয়েছে। প্রশ্ন হলো: যে .c ফাইলে ফাংশনটির সংজ্ঞা আছে, সেটি কি gcc-কে দেওয়া হয়েছে? sqrt হলে কি -lm দেওয়া হয়েছে? নামের বানান কি header আর সংজ্ঞায় এক? পর্ব ০৩-এ toolchain-এর কোন ধাপ কোন error দেয় তা দেখেছিলেন — এখানেই সেই জ্ঞান কাজে লাগে।

৩ · প্রথম debugger — compiler-এর warning

সবচেয়ে সস্তা debugger আপনার হাতেই আছে, আর সে কাজ করে প্রোগ্রাম চালানোর আগেই। GCC-কে -Wall -Wextra দিলে সে কোড পড়ে সন্দেহজনক জিনিসগুলো জানায়। প্রতিটি warning মানে “এটা বৈধ C, কিন্তু আপনি সম্ভবত এটা বোঝাতে চাননি।” নিচের নমুনা দেখুন — প্রতিটি warning একটি সত্যিকারের bug:

$ gcc -Wall -Wextra avg.c -o avg
avg.c: In function 'main':
avg.c:9:23: warning: comparison of integer expressions of different signedness: 'int' and 'size_t' {aka 'long unsigned int'} [-Wsign-compare]
avg.c:11:12: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
avg.c:10:9: warning: 'sum' is used uninitialized [-Wuninitialized]
  • প্রথমটি: int i-কে size_t-এর সাথে তুলনা। i ঋণাত্মক হলে তুলনার আগে সেটি বিশাল unsigned সংখ্যা হয়ে যায় (পর্ব ০৮)।
  • দ্বিতীয়টি: if (mark = 100) — তুলনা নয়, assignment! শর্ত সবসময় সত্য।
  • তৃতীয়টি: int sum; লিখে শূন্য দেওয়া হয়নি, অথচ লুপে sum += … — যোগফল শুরু হবে আবর্জনা মান থেকে (পর্ব ০৬)। এই warning-টি GCC কোড বিশ্লেষণের পরের ধাপে দেয়, তাই তালিকায় শেষে আসে।

এই warning-গুলো উপেক্ষা করে gdb নিয়ে বসলে আধঘণ্টা নষ্ট হতো; compiler এক সেকেন্ডে বলে দিল। তাই নিয়ম: warning-মুক্ত না হওয়া পর্যন্ত debugger খুলবেন না।

-Werror — warning জমতে না দেওয়া

একটি প্রজেক্টে ২০০টি পুরোনো warning জমে থাকলে নতুন, জরুরি warning-টি তাদের ভিড়ে হারিয়ে যায়। -Werror flag প্রতিটি warning-কে error বানিয়ে দেয় — warning থাকলে প্রোগ্রাম তৈরিই হয় না। অনেক দল তাদের CI-তে -Wall -Wextra -Werror দিয়ে build করে। তবে একটি বাস্তব সতর্কতা: নতুন সংস্করণের compiler নতুন warning যোগ করে, তাই যাঁরা আপনার source code ডাউনলোড করে নিজের compiler-এ build করবেন, তাঁদের জন্য -Werror চাপিয়ে দিলে build অকারণে ভাঙতে পারে। সাধারণ চর্চা: নিজের দলের build ও CI-তে -Werror, প্রকাশিত build script-এ নয়।

Static analysis — না চালিয়েই কোড পরীক্ষা

Warning-এর পরের স্তর হলো static analysis: টুল প্রোগ্রাম না চালিয়ে কোডের সম্ভাব্য সব পথ খুঁজে দেখে, কোনো পথে NULL pointer ব্যবহার বা free না করা মেমরি আছে কি না। তিনটি পরিচিত নাম:

gcc -fanalyzer

GCC 10 (২০২০) থেকে compiler-এর ভেতরেই। যেমন সতর্ক করে dereference of NULL 'p' বা leak of 'buf', আর কোন পথে সেটা ঘটে তা ধাপে ধাপে দেখায়।

-Wanalyzer-null-dereference

clang-tidy

LLVM প্রজেক্টের টুল। bug-এর সম্ভাবনা ছাড়াও স্টাইল ও নিরাপদ চর্চার শত শত নিয়ম পরীক্ষা করে। VS Code-এর C/C++ extension-এও ব্যবহার করা যায়।

clang-analyzer-*, bugprone-*

cppcheck

আলাদা, ফ্রি টুল — কোনো compiler লাগে না। অ্যারের সীমা পেরোনো, leak, অব্যবহৃত মানের মতো ভুল খোঁজে; মিথ্যা alarm কম দেওয়ার দিকে জোর দেয়।

cppcheck --enable=all

Static analysis কিছু মিথ্যা alarm দেবে, আবার কিছু আসল bug বাদও দেবে — কারণ সব পথ নিখুঁতভাবে বিশ্লেষণ করা তাত্ত্বিকভাবেই অসম্ভব। তবুও এটি সস্তা পাহারা: একবার সেটআপ করলে প্রতিটি commit-এ বিনা পরিশ্রমে চলে।

৪ · printf আর assert — ঠিকভাবে করলে খুব শক্তিশালী

“আসল প্রোগ্রামাররা debugger ব্যবহার করেন, printf নয়” — এই কথাটি অর্ধেক সত্য। printf debugging সব জায়গায় চলে: দূরের server-এ, ছোট microcontroller-এ, যেখানে debugger বসানো যায় না। অনেক অভিজ্ঞ প্রোগ্রামার আজও প্রথমে এটিই করেন। শুধু চারটি নিয়ম মানতে হয়:

  1. stdout নয়, stderr-এ লিখুন। পর্ব ১০-এ দেখেছেন, stdout সাধারণত buffer করা থাকে — লেখা জমে থাকে, পরে একসাথে বেরোয়। প্রোগ্রাম crash করলে জমে থাকা লেখা কখনো বেরোয়ই না, আর আপনি ভুল জায়গায় crash খুঁজবেন। stderr ডিফল্টে buffer করা থাকে না, তাই লেখা সাথে সাথে বেরোয়। তাছাড়া আসল আউটপুটের সাথে debug লেখা মেশে না।
  2. stdout ব্যবহার করতেই হলে flush করুন। fflush(stdout) জমে থাকা লেখা তখনই পাঠিয়ে দেয়।
  3. প্রতিটি লেখায় চিহ্ন দিন। শুধু 85 ছাপলে পরে বোঝা যায় না কোনটি কী। [dbg] i=1 mark=85 sum=85 — কোন ভ্যারিয়েবল, কোন মান, সব স্পষ্ট।
  4. একটি macro-য় রাখুন। পর্ব ২৭-এর macro দিয়ে DBG(...) বানান। এখানে ... আর __VA_ARGS__ macro-কে পর্ব ২৫-এর variadic ফাংশনের মতো যতগুলো খুশি argument নিতে দেয়। এতে পরে এক জায়গা থেকে সব debug লেখা বন্ধ করা যায়, আর কোথায় কোথায় আছে খুঁজে মুছতে সহজ হয়।

assert — নিজের ধারণাকে চালানো যায় এমন শর্তে বদলে দেওয়া

পর্ব ৩০-এ assert দেখেছেন: “আমার কোড ঠিক থাকলে এটা সবসময় সত্য।” ডিবাগিংয়ের দৃষ্টিতে assert-এর আসল মূল্য এখানে — অংশ ১-এ বলেছিলাম, bug মানে একটি ভুল ধারণা। assert সেই ধারণাকে মাথা থেকে কোডে নামিয়ে আনে, আর ধারণা ভাঙলেই প্রোগ্রাম চিৎকার করে ওঠে — ঠিক ভাঙার জায়গায়, শত লাইন পরে নয়।

নিচের প্রোগ্রামটি অংশ ১-এর সেই ভুল গড়ের প্রোগ্রাম, debug print আর একটি assert-সহ। চালিয়ে দেখুন কী হয় — এখানে assert ইচ্ছা করেই ব্যর্থ হবে, কারণ ঠিক সেটাই আমরা দেখতে চাই:

#include <stdio.h>
#include <assert.h>
#define DBG(...) fprintf(stderr, "[dbg] " __VA_ARGS__)

int main(void) {
    int marks[] = {72, 85, 90, 67, 81};
    int n = 5, sum = 0, used = 0;
    for (int i = 1; i < n; i++) {          /* BUG 1 */
        sum += marks[i];
        used++;
        DBG("i=%d mark=%d sum=%d\n", i, marks[i], sum);
    }
    double avg = sum / n;                    /* BUG 2 */
    DBG("sum=%d used=%d avg=%.2f\n", sum, used, avg);
    assert(used == n && "every mark must be counted");
    printf("Average: %.2f\n", avg);
    return 0;
}

আউটপুট বাক্সে প্রথমে চারটি [dbg] লাইন আসে — i=1 থেকে i=4. i=0 নেই, অর্থাৎ 72 যোগই হয়নি। তারপর sum=323 used=4 avg=64.00, আর শেষে assert ব্যর্থ হওয়ার বার্তা: ফাইলের নাম, লাইন নম্বর, ফাংশন main আর হুবহু শর্তটি — used == n && "every mark must be counted". শর্তে একটি string literal জুড়ে দেওয়ার কৌশলটি খেয়াল করুন: string সবসময় সত্য, তাই শর্তের অর্থ বদলায় না, কিন্তু ব্যর্থ হলে বার্তায় আপনার ব্যাখ্যাটিও ছাপা হয়। Average: লাইনটি কখনো ছাপা হয় না — assert প্রোগ্রাম থামিয়ে দিয়েছে, তাই ভুল গড় কোনো রিপোর্টে পৌঁছাতে পারেনি। রানার এটিকে ব্যর্থ exit status হিসেবে দেখাতে পারে (Linux-এ সাধারণত 134, অর্থাৎ abort signal) — সেটি প্রত্যাশিত। এবার লক্ষ করুন: assert ধরেছে শুধু প্রথম bug. দ্বিতীয়টি (sum / n integer division) ধরা পড়েছে debug লাইনের 64.00 থেকে, কারণ 323 ÷ 5 = 64.6 হওয়ার কথা। assert ধরে আপনি যা আগে ভেবেছেন; print দেখায় যা ভাবেননি।

এবার ঠিক করা সংস্করণ। দুটি bug-এর সমাধান ছাড়াও তিনটি জিনিস বদলেছে: গড়ের কাজটি একটি আলাদা ফাংশনে গেছে, ফাংশনের শুরুতে assert তার পূর্বশর্ত পাহারা দিচ্ছে, আর শেষে একটি “জানা উত্তর” যাচাই — 395 ÷ 5 = 79:

#include <stdio.h>
#include <assert.h>
#define DBG(...) fprintf(stderr, "[dbg] " __VA_ARGS__)

static double average(const int *a, int n) {
    assert(a != NULL && n > 0);              /* precondition */
    int sum = 0, used = 0;
    for (int i = 0; i < n; i++) { sum += a[i]; used++; }
    assert(used == n);
    return (double)sum / n;                  /* no integer division */
}

int main(void) {
    int marks[] = {72, 85, 90, 67, 81};
    int n = (int)(sizeof marks / sizeof marks[0]);
    double avg = average(marks, n);
    DBG("n=%d avg=%.2f\n", n, avg);
    assert(avg > 78.99 && avg < 79.01);       /* known answer */
    printf("Average: %.2f\n", avg);
    return 0;
}

এবার সব assert পাস করে: stdout-এ Average: 79.00, আর stderr-এ [dbg] n=5 avg=79.00 (রানার stderr-কে সাধারণত stdout-এর পরে দেখায়)। n-কে আর হাতে 5 লেখা হয়নি — sizeof marks / sizeof marks[0] অ্যারে থেকেই গুনে নেয়, তাই কেউ ষষ্ঠ নম্বর যোগ করলে n ভুল থাকার সুযোগ নেই (পর্ব ১৬)। জানা উত্তরের assert-এ == নয়, একটি ছোট পরিসর ব্যবহার করা হয়েছে, কারণ floating-point মান হুবহু মেলানো বিপজ্জনক (পর্ব ০৫)। এবার নিজে পরীক্ষা করুন: শেষ নম্বর 81-কে 82 করুন। সমষ্টি 396, গড় 79.20 — জানা-উত্তরের assert ব্যর্থ হবে, কারণ আপনি ইনপুট বদলেছেন কিন্তু প্রত্যাশা বদলাননি। এটাই test-এর মূল চিন্তা, যা পর্ব ৩২-এ বিস্তারিত দেখবেন।

NDEBUG মনে রাখুন। gcc -DNDEBUG দিয়ে compile করলে সব assert মুছে যায় (পর্ব ৩০)। তাই assert-এর ভেতরে কখনো কোনো কাজ রাখবেন না (assert(init() == 0) লিখলে release build-এ init ডাকাই হবে না), আর ব্যবহারকারীর ভুল ইনপুট assert দিয়ে নয়, সাধারণ error handling দিয়ে সামলাবেন। assert প্রোগ্রামারের ভুল ধরার যন্ত্র।

৫ · gdb — চলন্ত প্রোগ্রামকে থামিয়ে প্রশ্ন করা

printf-এর একটি সীমা আছে: কী ছাপবেন, তা আগে থেকে ঠিক করতে হয়। নতুন প্রশ্ন মাথায় এলে কোড বদলে আবার compile করতে হয়। Debugger-এ এই ঝামেলা নেই — প্রোগ্রাম যেকোনো জায়গায় থামিয়ে যেকোনো প্রশ্ন করা যায়। পর্ব ০৩-এ debugger-এর ধারণা দেখেছিলেন; এবার আসল যন্ত্র, GDB — GNU Debugger, যা Richard Stallman ১৯৮৬ সালে লেখা শুরু করেছিলেন এবং আজও Linux-এ C ডিবাগিংয়ের মূল হাতিয়ার।

প্রথম শর্ত: -g -O0

সাধারণ compile-এ প্রোগ্রামের ভেতরে লাইন নম্বর বা ভ্যারিয়েবলের নাম থাকে না — শুধু machine code. -g দিলে compiler ভেতরে debug symbols রেখে দেয়: কোন instruction কোন লাইনের, কোন ভ্যারিয়েবল মেমরির কোথায়। আর -O0 optimization বন্ধ রাখে। optimization চালু থাকলে compiler লাইনের ক্রম বদলায়, ভ্যারিয়েবল register-এ লুকিয়ে ফেলে বা মুছেই দেয় — gdb তখন বলে <optimized out>, আর next চাপলে লাইন এলোমেলো লাফায়।

$ gcc -g -O0 -Wall -Wextra maxmark.c -o maxmark     # debug build
$ gdb -q ./maxmark                                  # -q: শুরুর লম্বা পরিচিতি বাদ

যে ১০টি কমান্ড দিয়ে প্রায় সব কাজ চলে (প্রতিটির ছোট রূপও আছে — বন্ধনীতে):

কমান্ডকাজ
run (r)প্রোগ্রাম শুরু; আর্গুমেন্ট থাকলে run report.txt -n 3
break find_max / break 6 (b)ফাংশনে বা লাইনে breakpoint; শর্তসহ: break 6 if i == 3
next (n)পরের লাইনে যাও; ফাংশন ডাকা এক লাইন ধরে পার হও
step (s)পরের লাইনে যাও, কিন্তু ফাংশন ডাকা থাকলে তার ভেতরে ঢোকো
print expr (p)যেকোনো expression-এর মান: p a[i], p *s, p sum / n
display exprপ্রতিবার থামলে এই মান নিজে থেকেই দেখাও
info localsএই ফাংশনের সব local ভ্যারিয়েবল একসাথে
watch exprwatchpoint — মান বদলালেই থামো, পুরোনো ও নতুন মানসহ
finishএই ফাংশন শেষ হওয়া পর্যন্ত চলো, ফেরত-মান দেখাও
backtrace (bt), frame Nকে কাকে ডেকে এখানে এলো; N নম্বর ফাংশনের প্রেক্ষাপটে যাও
continue (c), quit (q)পরের থামার জায়গা পর্যন্ত চলো; gdb বন্ধ করো

next আর step-এর পার্থক্যই stepping-এর মূল কথা: যে ফাংশনকে আপনি বিশ্বাস করেন (printf), তার ওপর দিয়ে next; যাকে সন্দেহ করেন, তার ভেতরে step.

এবার একটি আসল সেশন। find_max পাঁচটি নম্বরের মধ্যে সর্বোচ্চটি দেওয়ার কথা — {95, 70, 88, 60, 79}-এ উত্তর হওয়া উচিত 95, কিন্তু প্রোগ্রাম বলছে Highest: 88. ডান দিকে gdb-র নমুনা আউটপুট — আপনার মেশিনে প্রায় এমনই দেখাবে, শুধু ঠিকানাগুলো আলাদা হবে:

3int find_max(const int *a, int n) {4    int max = 0;5    for (int i = 1; i < n; i++)6        if (a[i] > max)7            max = a[i];8    return max;9}
gdb যা দেখাচ্ছে
i
a[i]
max
a[0]
  1. Debug build আর breakpoint. -g -O0 দিয়ে compile করে gdb খুললাম। break find_max মানে “এই ফাংশনে ঢোকামাত্র থামো।” gdb জানাল breakpoint বসেছে লাইন ৪-এ — ফাংশনের প্রথম কাজের লাইনে। প্রোগ্রাম এখনো চলেনি।
  2. run — থামল লাইন ৪-এ। gdb ফাংশনের argument-ও দেখাচ্ছে: a-র ঠিকানা আর n=5. হলুদ লাইনটি এখনো চলেনি — gdb সবসময় পরের যে লাইন চলবে, সেটি দেখায়। তাই এই মুহূর্তে max-এ আবর্জনা মান। এটা নতুনদের সবচেয়ে বড় বিভ্রান্তি।
  3. next, তারপর print max. লাইন ৪ চলল, এখন max = 0. প্রথম সন্দেহ জাগল: সর্বোচ্চ মানের খোঁজ শূন্য থেকে শুরু? নম্বর তো কখনো ঋণাত্মক নয়, তাই এটা হয়তো ঠিক আছে… দেখা যাক।
  4. লুপের ভেতরে — print i, print a[i]. প্রথম তুলনাতেই i = 1, অর্থাৎ a[1] = 70-এর সাথে তুলনা। print a[0] দেখাল 95 — সবচেয়ে বড় নম্বরটি প্রথম ঘরেই, আর লুপ সেখান থেকে শুরুই হয়নি! অনুমান তৈরি: “a[0] কখনো তুলনা হয় না।”
  5. watch max — কে বদলায়, কখন বদলায়। প্রতিটি লাইনে next চাপার বদলে gdb-কে বললাম, max বদলালেই থামো। continue করতেই থামল: 0 থেকে 70. পুরোনো ও নতুন মান দুটোই পাওয়া গেল। বড় লুপে, বা “কে আমার ভ্যারিয়েবল নষ্ট করছে” ধরনের প্রশ্নে watchpoint অমূল্য।
  6. আবার continue — 70 থেকে 88. এরপর 60 আর 79 কোনোটিই 88-এর বড় নয়, তাই max আর বদলাবে না। পুরো ইতিহাস এখন পরিষ্কার: 0 → 70 → 88. কোথাও 95 আসেনি।
  7. finish — ফেরত-মান নিশ্চিত। watchpoint মুছে (delete 2) ফাংশনের শেষ পর্যন্ত চালালাম। gdb main-এ ফিরে জানাল Value returned is $5 = 88. ভবিষ্যদ্বাণী মিলে গেল — অনুমান প্রমাণিত।
  8. Root cause আর সমাধান। দুটি সিদ্ধান্ত একসাথে ভুল: max = 0 আর i = 1. হয় দুটোই 0-ভিত্তিক, নয়তো সঠিক প্যাটার্ন — max = a[0], লুপ ১ থেকে। দ্বিতীয়টি ভালো, কারণ সব নম্বর ঋণাত্মক হলেও (যেমন তাপমাত্রা) কাজ করে। “নম্বর কখনো ঋণাত্মক নয়” — এই লুকানো ধারণাটিও এখানে ধরা পড়ল।

VS Code বা অন্য IDE-র debugger? লাইনের পাশে লাল বিন্দু (breakpoint), F10 (next), F11 (step), ভ্যারিয়েবলের ওপর মাউস রাখলে মান — এগুলো সবই ভেতরে gdb বা LLVM-এর lldb-কে একই কমান্ড পাঠায়। macOS-এ সাধারণত lldb, যার কমান্ড প্রায় একই রকম (breakpoint set, bt, p). তাই gdb-র ধারণাগুলো বুঝলে যেকোনো debugger চালাতে পারবেন। আর এই পাতার কোড-রানার শুধু compile করে চালায় — gdb, -g বা sanitizer এখানে চলে না; এগুলো নিজের কম্পিউটারে (Linux, macOS, বা Windows-এ WSL/MSYS2) চর্চা করুন।

৬ · Crash-এর পরে — backtrace, frame আর core dump

সবচেয়ে ভয়ের বার্তা দিয়ে শুরু করি: Segmentation fault (core dumped). কোনো লাইন নম্বর নেই, কোনো কারণ নেই। পর্ব ১৭-তে দেখেছেন, segmentation fault মানে প্রোগ্রাম এমন মেমরিতে হাত দিয়েছে যা তার নয়, আর OS তাকে বন্ধ করে দিয়েছে। gdb-র ভেতরে একই প্রোগ্রাম চালালে ছবিটা পুরো বদলে যায়:

$ gdb -q ./roster
(gdb) run
Starting program: /home/rafi/roster

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555175 in is_pass (s=0x0) at roster.c:7
7           return s->mark >= 40;
(gdb) backtrace
#0  0x0000555555555175 in is_pass (s=0x0) at roster.c:7
#1  0x00005555555551f2 in main () at roster.c:16
(gdb) frame 1
#1  0x00005555555551f2 in main () at roster.c:16
16          printf("pass=%d\n", is_pass(find(list, n, "Karim")));
(gdb) print find(list, n, "Karim")
$1 = (struct student *) 0x0

লাইন ধরে পড়ুন। SIGSEGV হলো segmentation fault-এর signal-এর নাম। প্রোগ্রাম থেমেছে is_pass ফাংশনে, লাইন ৭-এ, আর argument s=0x0 — অর্থাৎ NULL pointer-এর ->mark পড়তে গিয়ে crash. তারপর backtrace: #0 হলো যেখানে থেমেছে, #1 হলো যে তাকে ডেকেছে। পর্ব ১৩-এর call stack-এর প্রতিটি stack frame এখানে এক লাইন। frame 1 দিয়ে main-এর প্রেক্ষাপটে গিয়ে দেখা গেল, find "Karim"-কে খুঁজে পায়নি, NULL দিয়েছে — আর main সেটা যাচাই না করেই is_pass-কে দিয়েছে।

মূল ধারণা

যেখানে crash, সেখানে প্রায়ই bug নয়। is_pass একদম নির্দোষ — তাকে NULL দেওয়া হয়েছে। আসল ভুল এক frame ওপরে: find-এর “পাওয়া যায়নি” উত্তরটি কেউ যাচাই করেনি (পর্ব ৩০)। Backtrace-কে নিচ থেকে ওপরে পড়ুন, আর প্রতিটি frame-এ প্রশ্ন করুন — “এখানে আসা মানগুলো কি বৈধ ছিল?” যে frame-এ প্রথম অবৈধ মান তৈরি হলো, root cause সেখানে।

Core dump — crash-এর ছবি পরে দেখা

কিন্তু crash যদি gdb-র বাইরে হয়, বা রাত তিনটায় server-এ? তখন কাজে আসে core dump: crash-এর মুহূর্তে প্রোগ্রামের মেমরির একটি ছবি, যা ফাইলে লেখা হয়। “core dumped” কথাটির মানে এটাই — যদিও অনেক সিস্টেমে ডিফল্টে core ফাইল তৈরি বন্ধ থাকে:

$ ulimit -c unlimited        # এই shell-এ core ফাইলের আকারের সীমা তুলে দিন
$ ./roster
Segmentation fault (core dumped)
$ gdb -q ./roster core       # প্রোগ্রাম + core: crash-এর মুহূর্তে ফেরা
(gdb) bt                     # আগের মতোই backtrace, প্রোগ্রাম আবার না চালিয়েই

আধুনিক Linux distribution-এ core ফাইল প্রায়ই সরাসরি ফোল্ডারে আসে না — systemd-coredump বা Ubuntu-র apport সেটি নিজের জায়গায় জমা রাখে। systemd-ভিত্তিক সিস্টেমে coredumpctl list আর coredumpctl gdb দিয়ে খুঁজে খোলা যায়। মূল ধারণা একই: crash-এর প্রমাণ সংরক্ষণ, যাতে পরে ধীরে-সুস্থে তদন্ত করা যায়। Core ফাইল কাজে লাগাতে প্রোগ্রামটি -g-সহ build করা থাকতে হবে, অথবা debug symbol আলাদা ফাইলে রেখে দিতে হবে।

৭ · Sanitizer — প্রোগ্রামের ভেতরে বসানো পাহারাদার

এবার সবচেয়ে বিপজ্জনক জাতের bug: মেমরির ভুল। অ্যারের সীমা পেরিয়ে এক ঘর লেখা প্রায়ই সাথে সাথে crash করে না — হয়তো পাশের কোনো ভ্যারিয়েবল চুপচাপ নষ্ট হয়, আর লক্ষণ দেখা দেয় অনেক পরে, অন্য কোনো ফাংশনে। পর্ব ৪৬-এ দেখবেন, এগুলো undefined behavior — “আজ চলে, কাল চলে না।” gdb দিয়ে এমন bug খোঁজা খড়ের গাদায় সুঁই খোঁজা।

সমাধান হলো sanitizer: compiler প্রোগ্রামের প্রতিটি মেমরি-ব্যবহারের পাশে ছোট পাহারা-কোড বসিয়ে দেয়। প্রোগ্রাম চলার সময় ভুল হলেই সেই মুহূর্তে থেমে বিস্তারিত রিপোর্ট দেয়। GCC আর Clang দুটোতেই একটি flag-এ চালু হয়:

$ gcc -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer report.c -o report
$ ./report

AddressSanitizer (ASan)

-fsanitize=address — heap, stack বা global অ্যারের সীমা পেরোনো (overflow), use-after-free, double free ধরে।

heap-buffer-overflow, heap-use-after-free

LeakSanitizer (LSan)

ASan-এর সাথেই আসে (Linux-এ ডিফল্টে চালু)। প্রোগ্রাম শেষে LeakSanitizer বলে দেয় কোন malloc করা মেমরি free হয়নি, আর কোথায় নেওয়া হয়েছিল।

Direct leak of 64 byte(s)

UBSan

-fsanitize=undefined — UBSan signed overflow, অবৈধ shift, NULL pointer থেকে পড়া, শূন্য দিয়ে ভাগের মতো undefined behavior ধরে।

runtime error: signed integer overflow

ASan কীভাবে কাজ করে — shadow memory আর redzone

ASan প্রোগ্রামের প্রতি ৮ বাইট মেমরির জন্য ১ বাইটের একটি গোপন হিসাব রাখে, যার নাম shadow memory. সেখানে লেখা থাকে: এই ৮ বাইটের সবগুলো ছোঁয়া বৈধ (00), প্রথম কয়েকটি বৈধ (01–07), নাকি কোনোটিই নয় (fa, fd…)। আর প্রতিটি malloc-এর দুই পাশে ASan রাখে নিষিদ্ধ ফাঁকা জায়গা — redzone. প্রতিটি পড়া-লেখার আগে বসানো পাহারা-কোড shadow দেখে নেয়; redzone ছুঁলেই রিপোর্ট।

মেট্রোরেলের প্ল্যাটফর্মে হলুদ দাগ আর গেট মনে করুন। প্রতিটি কামরার (মেমরি-ব্লক) দুই পাশে নিষিদ্ধ এলাকা, আর প্রতিটি পদক্ষেপের আগে একজন গার্ড তালিকা মিলিয়ে দেখছেন — এই জায়গায় দাঁড়ানো বৈধ কি না। দাগ পেরোলেই বাঁশি। গার্ড রাখার খরচ আছে (প্রোগ্রাম ধীর হয়), কিন্তু দুর্ঘটনা ঘটার আগেই ধরা পড়ে।

এবার একটি আসল রিপোর্ট পড়া যাক। প্রোগ্রামটি ৫টি int-এর (২০ বাইট) জায়গা নিয়ে লুপ চালিয়েছে i <= 5 পর্যন্ত — ছয়বার:

1  #include <stdlib.h>
2  int main(void) {
3      int *marks = malloc(5 * sizeof(int));     /* 20 bytes */
4      for (int i = 0; i <= 5; i++)              /* BUG: should be i < 5 */
5          marks[i] = 50 + i;
6      free(marks);
7      return 0;
8  }

ASan-সহ চালালে প্রোগ্রাম লাইন ৫-এ, ষষ্ঠ লেখার মুহূর্তে থেমে যায়। রিপোর্টটি লম্বা আর ভীতিকর দেখায় — কিন্তু ওপর থেকে নিচে পড়লে এটি একটি সাজানো গল্প। নমুনা আউটপুট (আপনার মেশিনে এমনই দেখাবে, ঠিকানা ও process নম্বর আলাদা হবে):

লাইন ১–২ · কী আর কোথায়রিপোর্টের শিরোনাম
==8812==ERROR: AddressSanitizer: heap-buffer-overflow on address
  0x602000000024 at pc 0x55d4c3a1b2f0 bp 0x7ffd5e3c9a40 sp 0x7ffd5e3c9a30
WRITE of size 4 at 0x602000000024 thread T0
প্রথম stack · ভুলটি ঘটল যেখানে#0 পড়ুন
    #0 0x55d4c3a1b2f0 in main /home/rafi/report.c:5
    #1 0x7f3a1c829d8f in __libc_start_call_main ...
    #2 0x7f3a1c829e3f in __libc_start_main_impl ...
    #3 0x55d4c3a1b1a4 in _start (/home/rafi/report+0x11a4)
    ← #1 থেকে নিচে C library-র শুরুর কোড; আপনার কোড শুধু #0
অবস্থান · ব্লকের তুলনায় কোথায়সবচেয়ে মূল্যবান লাইন
0x602000000024 is located 0 bytes after 20-byte region
  [0x602000000010,0x602000000024)
শুরু 0x...10 + 20 (0x14) = 0x...24 → ঠিক শেষ বাইটের পরের বাইট
দ্বিতীয় stack · ব্লকটি এসেছিল কোথা থেকেallocated by
allocated by thread T0 here:
    #0 0x7f3a1d0b4887 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
    #1 0x55d4c3a1b27e in main /home/rafi/report.c:3
SUMMARY আর shadow bytesএক লাইনে সারাংশ
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/rafi/report.c:5 in main
Shadow bytes around the buggy address:
=>0x602000000000: fa fa 00 00[04]fa fa fa fa fa fa fa fa fa fa fa
  Addressable: 00   Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone: fa
==8812==ABORTING
  1. শিরোনাম: কী ধরনের ভুল, পড়া না লেখা, কত বাইট। heap-buffer-overflow — malloc থেকে পাওয়া ব্লকের সীমা পেরোনো। WRITE of size 4 — ৪ বাইট লেখা হচ্ছিল, অর্থাৎ একটি int. পড়ার ভুল হলে লেখা থাকত READ. pc, bp, sp হলো CPU register-এর মান — সাধারণত এড়িয়ে যেতে পারেন।
  2. প্রথম stack trace: ভুলটি ঠিক কোথায় ঘটল। #0 … in main report.c:5 — লাইন ৫, marks[i] = 50 + i;. নিচের #1–#3 C library-র ভেতরের কোড, যা main-কে ডেকেছিল; এগুলো উপেক্ষা করুন। বড় প্রোগ্রামে প্রথম যে লাইনে আপনার ফাইলের নাম দেখবেন, সেখান থেকে শুরু করুন। লাইন নম্বর পেতে -g লাগবে — নইলে শুধু ঠিকানা দেখাবে।
  3. অবস্থান: “0 bytes after 20-byte region”. রিপোর্টের সবচেয়ে মূল্যবান লাইন। ব্লকটি ২০ বাইটের (৫টি int), আর লেখা হয়েছে তার ঠিক পরের বাইটে — ০ বাইট দূরে। অর্থাৎ marks[5], ষষ্ঠ ঘর। এটা প্রায় সবসময় off-by-one: <= যেখানে < হওয়ার কথা। “40 bytes after” দেখলে বুঝতেন অনেক দূরে লাফ — হয়তো ভুল index হিসাব।
  4. দ্বিতীয় stack trace: ব্লকটির জন্মস্থান। allocated by … report.c:3 — ব্লকটি এসেছে লাইন ৩-এর malloc থেকে। বড় প্রোগ্রামে এটা জরুরি, কারণ যে ফাংশন মেমরি নিয়েছে আর যে সীমা পেরিয়েছে, তারা প্রায়ই আলাদা ফাইলে। use-after-free হলে এখানে তৃতীয় একটি stack-ও আসে: freed by thread T0 here.
  5. SUMMARY আর shadow bytes. SUMMARY এক লাইনে পুরো গল্প বলে — log-এ খুঁজতে এটি কাজের। Shadow bytes অংশে 00 00 মানে প্রথম ১৬ বাইট পুরো বৈধ, [04] মানে পরের ৮ বাইটের প্রথম ৪টি বৈধ (মোট ২০), আর বন্ধনী দেখাচ্ছে ভুল ঠিকানাটি সেই ঘরে — বৈধ ৪ বাইটের ঠিক পরে। fa হলো redzone. সমাধান: লাইন ৪-এ i < 5, আরও ভালো i < n যেখানে n একটিই জায়গায় ঠিক হয়।

UBSan-এর রিপোর্ট — এক লাইনেই সব

UBSan-এর বার্তা অনেক ছোট — ফাইল, লাইন, কলাম আর কারণ:

score.c:9:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
bits.c:4:14: runtime error: shift exponent 32 is too large for 32-bit type 'int'
roster.c:7:13: runtime error: member access within null pointer of type 'const struct student'

একটি পার্থক্য খেয়াল রাখুন: ASan প্রথম ভুলেই প্রোগ্রাম থামায়, কিন্তু UBSan ডিফল্টে বার্তা ছেপে চলতে থাকে। Test-এ প্রথম ভুলেই থামাতে চাইলে -fno-sanitize-recover=all যোগ করুন।

দাম কত, আর কখন ব্যবহার করবেন? ASan-সহ প্রোগ্রাম সাধারণত প্রায় ২ গুণ ধীর চলে আর কয়েক গুণ বেশি মেমরি নেয়। তাই একে production-এ নয়, উন্নয়ন আর test-এর সময় চালু রাখা হয় — বিশেষ করে CI-তে প্রতিটি test sanitizer-সহ চালানো আজকের পেশাদার চর্চা। দুটি সীমাবদ্ধতা: sanitizer শুধু সেই কোড পরীক্ষা করে যা আসলে চলেছে (যে পথ test-এ যায়নি, সেখানের bug ধরা পড়বে না), আর ASan ও ThreadSanitizer (পর্ব ৪৯) একসাথে চালানো যায় না — আলাদা build লাগে।

৮ · Valgrind — কোড না বদলেই মেমরি পরীক্ষা

Sanitizer-এর জন্য প্রোগ্রাম আবার compile করতে হয়। কিন্তু যদি source code হাতে না থাকে, বা পুরোনো build সিস্টেম বদলানো কঠিন হয়? তখন Valgrind. Julian Seward-এর তৈরি এই টুলটি ২০০২ সালে প্রথম প্রকাশিত হয়। এটি আপনার প্রোগ্রামকে CPU-তে সরাসরি না চালিয়ে নিজের ভেতরে একটি কৃত্রিম CPU-তে চালায়, আর প্রতিটি instruction-এর ওপর নজর রাখে। এর প্রধান টুল memcheck:

$ gcc -g names.c -o names                                      # -g থাকলে লাইন নম্বর আসবে
$ valgrind --leak-check=full ./names

memcheck দুই জাতের সমস্যা জানায়। প্রথমত Invalid read of size 4 বা Invalid write of size 4 — ASan-এর overflow ও use-after-free-এর মতো। দ্বিতীয়ত leak. নিচের প্রোগ্রামটি তিনটি নাম বানায়, প্রতিটির জন্য make_name ৩২ বাইট malloc করে — কিন্তু শেষে free হয় শুধু শেষটি। Valgrind-এর রিপোর্ট ধরে leak খুঁজে বের করা যাক:

HEAP SUMMARYমোট হিসাব
==4321== HEAP SUMMARY:
==4321==     in use at exit: 64 bytes in 2 blocks
==4321==   total heap usage: 4 allocs, 2 frees, 1,120 bytes allocated
৪টি নেওয়া, ২টি ফেরত — ২টি ব্লক হারিয়ে গেছে
LEAK SUMMARYleak-এর শ্রেণি
==4321== LEAK SUMMARY:
==4321==    definitely lost: 64 bytes in 2 blocks
==4321==    indirectly lost: 0 bytes in 0 blocks
==4321==      possibly lost: 0 bytes in 0 blocks
==4321==    still reachable: 0 bytes in 0 blocks
loss recordকে নিয়েছিল
==4321== 64 bytes in 2 blocks are definitely lost in loss record 1 of 1
==4321==    at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==4321==    by 0x10919E: make_name (names.c:8)
==4321==    by 0x109201: main (names.c:17)
names.c · আসল ভুলpointer হারানো
16  for (int i = 0; i < 3; i++) {
17      name = make_name(i);      /* আগের name-এর ঠিকানা মুছে গেল! */
18      printf("%s\n", name);
19      free(name);               /* সমাধান: লুপের ভেতরেই ছাড়ুন */
20  }
  1. HEAP SUMMARY — হিসাব মেলান। প্রোগ্রাম শেষে ৬৪ বাইট (২টি ব্লক) এখনো নেওয়া অবস্থায়। ৪বার নেওয়া, ২বার ফেরত। চারটির মধ্যে তিনটি আমাদের নাম, চতুর্থটি printf-এর নিজের buffer. হিসাবটাই প্রথম সূত্র: ৩২ × ২ = ৬৪, অর্থাৎ তিনটি নামের দুটি ফেরত যায়নি।
  2. LEAK SUMMARY — কতটা গুরুতর। Definitely lost মানে ব্লকগুলোর দিকে আর কোনো pointer নেই — চাইলেও কখনো free করা যাবে না। এটি নিশ্চিত memory leak. “still reachable” হলে pointer তখনো ছিল (যেমন global ভ্যারিয়েবলে), যা প্রায়ই কম গুরুতর। “indirectly lost” মানে হারানো ব্লকের ভেতরে থাকা অন্য ব্লক — যেমন linked list-এর মাথা হারালে বাকি node-গুলো।
  3. loss record — জন্মস্থান। --leak-check=full দিলে প্রতিটি leak-এর stack আসে: ব্লকটি malloc হয়েছিল make_name-এ (লাইন ৮), যাকে ডেকেছে main-এর লাইন ১৭. খেয়াল করুন, Valgrind বলছে মেমরি কোথায় নেওয়া হয়েছিল — কোথায় ছাড়া উচিত ছিল, সেটা বলতে পারে না। সেটা ভাবতে হবে আপনাকে।
  4. Root cause — pointer হারানো। লুপের প্রতিটি ঘুরে name-এ নতুন ঠিকানা বসছে, আর আগেরটি মুছে যাচ্ছে — যেন বাসার চাবি নতুন চাবির রিংয়ে লাগাতে গিয়ে পুরোনো চাবি ফেলে দেওয়া। লুপের পরে একবার free(name) শুধু শেষটিকে ছাড়ে। সমাধান: প্রতিটি নাম ব্যবহার শেষে লুপের ভেতরেই free. আবার চালালে রিপোর্ট বলবে All heap blocks were freed -- no leaks are possible.

ASan না Valgrind?

বিষয়AddressSanitizerValgrind memcheck
Recompile লাগে?হ্যাঁ, -fsanitize=addressনা — যেকোনো তৈরি প্রোগ্রাম
গতিসাধারণত প্রায় ২ গুণ ধীরঅনেক ধীর — Valgrind-এর নিজের ভাষায় প্রায় ২০–৩০ গুণ
Stack ও global অ্যারের overflowধরেমূলত heap; stack-এর ভেতরের overflow প্রায়ই ধরে না
অনির্ধারিত মানের ব্যবহারনা (এর জন্য Clang-এর আলাদা MemorySanitizer)ধরে — Conditional jump depends on uninitialised value(s)
প্ল্যাটফর্মLinux, macOS; Windows-এ MSVC-র /fsanitize=addressমূলত Linux; নতুন macOS-এ সমর্থন সীমিত, Windows-এ নেই (WSL-এ চলে)

বাস্তব পরামর্শ: প্রতিদিনের কাজ আর CI-তে ASan + UBSan, কারণ দ্রুত আর stack-এর ভুলও ধরে। source ছাড়া প্রোগ্রাম, অনির্ধারিত মানের সন্দেহ, বা দ্বিতীয় মতামত দরকার হলে Valgrind. দুটো একসাথে একই প্রোগ্রামে চালাবেন না — ASan-সহ build Valgrind-এর নিচে ঠিকমতো চলে না।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এই বিষয়ে প্রায়ই যেসব প্রশ্ন আসে:

  • gdb-তে লাইন নম্বর দেখতে কোন flag লাগে? -g; সাথে -O0 দিলে ভ্যারিয়েবল “optimized out” হয় না।
  • Segmentation fault হলে প্রথমে কী করবেন? gdb-তে চালিয়ে backtrace, অথবা -fsanitize=address দিয়ে আবার build করে চালানো।
  • Memory leak কীভাবে খুঁজবেন? Valgrind (--leak-check=full) বা ASan-এর LeakSanitizer — দুটোই বলে মেমরি কোথায় নেওয়া হয়েছিল।
  • next আর step-এর পার্থক্য? next ফাংশন ডাকাকে এক লাইন ধরে পার হয়; step তার ভেতরে ঢোকে।
  • Debug print stdout-এ না দিয়ে stderr-এ কেন? stderr buffer করা থাকে না, তাই crash-এর আগের লেখাও হারায় না, আর আসল আউটপুটের সাথে মেশে না।

৯ · AI-এর যুগে — crash log বুঝতে AI, আর তার ফাঁদ

লম্বা ASan রিপোর্ট বা অচেনা linker error দেখে AI-কে জিজ্ঞেস করা আজ খুব স্বাভাবিক — আর ঠিকভাবে করলে এটা সত্যিই কাজের। AI এমন হাজারো রিপোর্ট দেখেছে, তাই “0 bytes after 20-byte region মানে কী” বা “undefined reference সাধারণত কেন হয়” — এমন প্রশ্নে চমৎকার ব্যাখ্যা দেয়। সমস্যা শুরু হয় যখন আমরা AI-কে ব্যাখ্যাকারী নয়, তদন্তকারী বানিয়ে ফেলি। AI আপনার প্রোগ্রাম চালাতে পারে না, আপনার মেশিনের মেমরি দেখতে পারে না — সে শুধু অনুমান করে।

সবচেয়ে কাজের নিয়ম: পুরো কোডবেস নয়, প্রমাণ দিন। হুবহু error বা trace (ছাঁটাই না করে), সংশ্লিষ্ট ফাংশনগুলো, compile কমান্ড, আর কী আশা করেছিলেন বনাম কী পেলেন। আর চান সমাধান নয়, অনুমান আর যাচাইয়ের উপায়:

AI-কে ডিবাগিং সহকারী বানানোর একটি কাঠামো — patch নয়, চিন্তা চাওয়া হচ্ছে:

নিচে একটি C প্রোগ্রামের AddressSanitizer রিপোর্ট আর সংশ্লিষ্ট দুটি ফাংশন দিলাম। Compile: gcc -g -O1 -fsanitize=address,undefined report.c প্রত্যাশা: ৫টি নম্বর লিখে গড় দেখাবে। বাস্তবে: নিচের রিপোর্ট দিয়ে থেমে যায়। [এখানে পুরো ASan রিপোর্ট হুবহু] [এখানে শুধু সংশ্লিষ্ট ফাংশনগুলো] তুমি কোড বদলে দিও না। বরং: ১. রিপোর্টের প্রতিটি অংশ (ভুলের ধরন, প্রথম stack, অবস্থান, allocated by) কী বলছে ব্যাখ্যা করো। ২. Root cause নিয়ে সর্বোচ্চ ৩টি অনুমান দাও, সম্ভাবনার ক্রমে। ৩. প্রতিটি অনুমান যাচাইয়ের জন্য একটি gdb কমান্ড বা পরীক্ষা বলো, আর প্রতিটির প্রত্যাশিত ফল লেখো। ৪. যে লাইন নম্বর আমার দেওয়া রিপোর্ট বা কোডে নেই, তা উল্লেখ করবে না।

AI-এর ডিবাগিং-পরামর্শে সাধারণ ফাঁদ

AI যা বলেসমস্যা
is_pass-এ if (s == NULL) return 0; যোগ করোCrash থামে, কিন্তু “Karim পাওয়া যায়নি” চুপচাপ “ফেল” হয়ে যায় — লক্ষণ চাপা পড়ল, root cause (find-এর ফল যাচাই না করা) রয়েই গেল
“লাইন ৪২-এ সমস্যা” — অথচ আপনার ফাইল ৩০ লাইনেরবানানো লাইন নম্বর; AI রিপোর্টের প্যাটার্ন থেকে আন্দাজ করছে
“-O0 দিয়ে compile করো, ঠিক হয়ে যাবে”undefined behavior লুকিয়ে ফেলা — -O2-এ বা অন্য মেশিনে আবার ফিরবে
অ্যারে বড় করে দাও: malloc(6 * sizeof(int))Off-by-one লুপ ঠিক না করে জায়গা বাড়ানো; পরের বার লুপ আবার সীমা পেরোবে
“এটা সম্ভবত compiler-এর bug”প্রায় কখনো নয়। আগে নিজের কোড, warning আর sanitizer
leak ঠিক করতে কোথাও একটি free বসিয়ে দেয়মালিকানা না বুঝে free — প্রায়ই double free বা use-after-free তৈরি করে

AI-এর দেওয়া fix গ্রহণের আগে — চেকলিস্ট

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

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

প্র ০১১৯৪৭ সালের সেই বিখ্যাত পোকা কি সত্যিই প্রথম “bug”? শব্দটির আসল ইতিহাস কী?

গল্পটি সবাই জানেন। ৯ সেপ্টেম্বর ১৯৪৭, Harvard বিশ্ববিদ্যালয়ের Mark II কম্পিউটার ভুল আচরণ করছিল। এটি ছিল বিশাল এক electromechanical যন্ত্র — হাজার হাজার relay দিয়ে তৈরি। খুঁজতে গিয়ে দলের সদস্যরা একটি relay-র ভেতরে আটকে থাকা একটি মথ (পতঙ্গ) পান। তাঁরা পোকাটিকে লগবুকে টেপ দিয়ে আটকে পাশে লিখে রাখেন: “First actual case of bug being found.” এই দলে ছিলেন Grace Hopper, যিনি পরে COBOL-এর পূর্বসূরি ভাষাগুলো তৈরিতে নেতৃত্ব দেন। তিনি সারা জীবন এই গল্পটি বলেছেন, তাই অনেকে ভাবেন তিনিই পোকাটি পেয়েছিলেন — কিন্তু লগবুক অনুযায়ী এটি দলের কাজ। লগবুকের সেই পাতাটি আজ যুক্তরাষ্ট্রের Smithsonian-এর National Museum of American History-তে সংরক্ষিত।

কিন্তু লেখাটি আবার পড়ুন: “First actual case of bug being found” — প্রথম আসল পোকা। এই রসিকতাটি তখনই অর্থ বহন করে, যদি “bug” শব্দটি যন্ত্রের ত্রুটি বোঝাতে আগে থেকেই প্রচলিত থাকে। এবং তা ছিল। ১৮৭০-এর দশকে Thomas Edison তাঁর চিঠিতে আবিষ্কারের পথে আসা ছোট ছোট ত্রুটি আর সমস্যাকে “bugs” বলেছেন। প্রকৌশলীদের মধ্যে শব্দটি কয়েক দশক ধরেই চলছিল। ১৯৪৭-এর ঘটনা শব্দটির জন্ম নয় — শব্দটির সবচেয়ে বিখ্যাত আক্ষরিক উদাহরণ।

এই ইতিহাসের একটি গভীর শিক্ষা আছে। Mark II-এর দল পোকা খুঁজে পেয়েছিলেন কারণ তাঁরা লগবুক রাখতেন — কখন কী চালানো হলো, কী ফল এলো, সময়সহ। আজকের ভাষায় এটাই logging, আর পুনরুৎপাদন-যোগ্য পর্যবেক্ষণ। আরও খেয়াল করুন, ওই bug-টি ছিল হার্ডওয়্যারে, যা চোখে দেখা যায়। আজকের bug সফটওয়্যারে, অদৃশ্য — তাই gdb, sanitizer আর log হলো আমাদের চোখ।

মূল উপলব্ধি: জনপ্রিয় গল্প প্রায়ই ইতিহাসকে সরল করে ফেলে — ঠিক যেমন bug-এর লক্ষণ তার কারণকে আড়াল করে। দুই ক্ষেত্রেই উৎস যাচাই করাই আসল কাজ। আর ১৯৪৭ সালেও ডিবাগিংয়ের মূলমন্ত্র ছিল একই: লিখে রাখা পর্যবেক্ষণ।

প্র ০২Heisenbug কী? printf যোগ করলে বা debugger-এ চালালে কোনো কোনো bug কেন হারিয়ে যায়?

পদার্থবিজ্ঞানী Werner Heisenberg-এর নামের সাথে মিলিয়ে এই রসিক নাম: Heisenbug এমন bug, যা পর্যবেক্ষণ করতে গেলেই আচরণ বদলায় বা লুকিয়ে পড়ে। ১৯৮০-এর দশকে নির্ভরযোগ্য কম্পিউটার সিস্টেমের গবেষক Jim Gray-এর লেখায় শব্দটি জনপ্রিয় হয়। তিনি এর বিপরীতে “Bohrbug”-এর কথাও বলেন — Niels Bohr-এর পরমাণু মডেলের মতো স্থির, প্রতিবার একই ইনপুটে একইভাবে ঘটে এমন bug. অংশ ১-এর গড়ের bug একটি Bohrbug.

কিন্তু পর্যবেক্ষণ bug বদলায় কীভাবে? তিনটি বাস্তব কারণ:

  • সময় বদলে যায়। দুটি thread যদি একই ভ্যারিয়েবলে race করে (পর্ব ৪৯), bug ঘটে শুধু তখন, যখন দুজনের কাজ ন্যানোসেকেন্ডের হিসাবে একটি বিশেষ ক্রমে মেলে। printf একটি ধীর কাজ, আর stdio-র ফাংশন সাধারণত ভেতরে একটি lock নেয় — ফলে thread-গুলো অজান্তেই এক লাইনে দাঁড়িয়ে যায়, race-এর জানালা বন্ধ হয়ে যায়। debugger-এ এক লাইন করে চালালে তো সময়ের হিসাব পুরোই বদলে যায়।
  • মেমরির বিন্যাস বদলে যায়। অ্যারের সীমা পেরিয়ে লেখা কোনো ভ্যারিয়েবল নষ্ট করছিল। printf বা নতুন local ভ্যারিয়েবল যোগ করায় stack-এ জিনিসপত্রের জায়গা সরে গেল — এখন সেই লেখা পড়ছে একটি অব্যবহৃত জায়গায়। Bug আছে, শুধু লক্ষণ নেই।
  • Optimization বদলে যায়। debug build (-O0) আর release build (-O2)-এ compiler আলাদা কোড তৈরি করে। undefined behavior-ওয়ালা কোড এক build-এ “ঠিক” চলে, অন্যটিতে ভাঙে।

তাহলে উপায়? পর্যবেক্ষণের যন্ত্র এমন হতে হবে যা আচরণ কম বদলায়, অথবা ভুলের শর্ত ধরে, লক্ষণ নয়। ThreadSanitizer race ঘটুক বা না ঘটুক, “দুটি thread lock ছাড়া একই জায়গা ছুঁয়েছে” — এই শর্তই ধরে। ASan সীমা পেরোনো লেখা ধরে, কোনো ভ্যারিয়েবল নষ্ট হোক বা না হোক। আর log লিখতে হলে কম খরচের পদ্ধতি — যেমন মেমরির একটি ring buffer-এ লিখে রাখা, crash-এর পরে পড়া।

মূল উপলব্ধি: “printf দিলে ঠিক হয়ে যায়” কোনো সমাধান নয় — এটি একটি জোরালো প্রমাণ যে সমস্যাটি সময়, মেমরি-বিন্যাস বা undefined behavior-এ লুকিয়ে আছে। লক্ষণের বদলে শর্ত ধরার যন্ত্র বেছে নিন।

প্র ০৩Sanitizer কেন C/C++ জগতে একটি মোড় ঘোরানো ঘটনা? Valgrind তো আগে থেকেই ছিল।

২০০০-এর দশকে মেমরি-ভুল খোঁজার প্রধান যন্ত্র ছিল Valgrind. চমৎকার যন্ত্র, কিন্তু একটি বড় বাধা: গতি। প্রোগ্রাম কয়েক দশ গুণ ধীর হলে ছোট test-এ চলে, কিন্তু Chrome-এর মতো কয়েক কোটি লাইনের browser-এর হাজার হাজার test প্রতিদিন চালানো বাস্তবে অসম্ভব হয়ে পড়ে।

Google-এ Konstantin Serebryany, Derek Bruening, Alexander Potapenko আর Dmitry Vyukov ভিন্ন পথ নিলেন। প্রোগ্রামকে কৃত্রিম CPU-তে চালানোর বদলে compiler-কেই বললেন compile-এর সময় প্রতিটি মেমরি-ব্যবহারের পাশে ছোট পাহারা বসাতে — shadow memory আর redzone দিয়ে, যা এই পর্বে দেখেছেন। ২০১২ সালে USENIX-এর একটি সম্মেলনে তাঁদের গবেষণাপত্র “AddressSanitizer: A Fast Address Sanity Checker” প্রকাশিত হয়। ফলাফল: গড়ে প্রায় ২ গুণ ধীর — Valgrind-এর তুলনায় অনেক দ্রুত। AddressSanitizer প্রথমে LLVM/Clang-এ আসে, ২০১৩ সালে GCC 4.8-এ। গবেষণাপত্রেই জানানো হয়েছিল, Chromium-এ এটি অল্প সময়ের মধ্যে শত শত bug খুঁজে দিয়েছিল।

দ্রুত হওয়ায় এটি একটি নতুন চর্চা সম্ভব করল: fuzzing + sanitizer. Fuzzer লাখো এলোমেলো বা আধা-এলোমেলো ইনপুট দিয়ে প্রোগ্রাম চালায়, আর sanitizer প্রতিটি চালানোয় পাহারা দেয় — ভুল ইনপুট সাধারণত crash না করলেও sanitizer ধরে ফেলে। Google এই দুটি জুড়ে ClusterFuzz চালায়, আর ২০১৬ সালে মুক্ত সফটওয়্যারের জন্য OSS-Fuzz চালু করে, যা হাজার হাজার bug ও নিরাপত্তা-ত্রুটি খুঁজে দিয়েছে। পরে একই দল ThreadSanitizer, MemorySanitizer, আর UBSan-এর মতো সহযোগী যন্ত্রও এগিয়ে নেয়।

তবে sanitizer কোনো জাদু নয়। সে শুধু সেই পথ দেখে যা চলেছে; যে কোড test-এ পৌঁছায়নি, তা অদেখা থাকে। এজন্যই শিল্প-জগৎ এখন আরও এক ধাপ এগিয়ে ভাবছে — Rust-এর মতো memory-safe ভাষা, যেখানে এই জাতের ভুল compile-এর সময়েই ঠেকানো যায়।

মূল উপলব্ধি: একটি যন্ত্র কতটা ভালো, শুধু তা নয় — সেটি প্রতিদিন, প্রতিটি test-এ চালানোর মতো সস্তা কি না, সেটাই তার প্রভাব ঠিক করে। দ্রুততার কারণেই sanitizer “মাঝে মাঝের তদন্ত” থেকে “সবসময়ের পাহারা” হয়ে উঠেছে।

প্র ০৪পুনরুৎপাদন এত জরুরি কেন? যে bug একবার ঘটে আর হারিয়ে যায়, তাকে ধরার উপায় কী?

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

কোনো প্রোগ্রামের আচরণ অনির্দিষ্ট হয় কয়েকটি উৎস থেকে: thread-গুলোর সময়ের ক্রম, নেটওয়ার্ক থেকে আসা ডেটা, ঘড়ির সময়, এলোমেলো সংখ্যা, signal. এগুলো স্থির করতে পারলে bug স্থির হয়। সহজ কৌশলগুলো দিয়ে শুরু: random seed স্থির রাখা (পর্ব ২৬), ইনপুট ফাইলে সংরক্ষণ, একই compiler ও flag ব্যবহার।

কঠিন ক্ষেত্রের জন্য আছে record and replay debugging. Mozilla-র Robert O'Callahan ও তাঁর সহকর্মীদের তৈরি rr (Linux) একবার প্রোগ্রাম চালানোর সময় সব অনির্দিষ্ট ঘটনা রেকর্ড করে রাখে। তারপর সেই চালানোটি হুবহু, যতবার খুশি, আবার চালানো যায় — প্রতিবার একই ক্রমে, একই মানে। আরও চমকপ্রদ: gdb-র মধ্য দিয়ে পেছন দিকে চালানো যায়। Crash-এর জায়গায় দাঁড়িয়ে একটি ভ্যারিয়েবলে watchpoint বসিয়ে reverse-continue দিলে প্রোগ্রাম পেছনে গিয়ে থামে ঠিক সেই লাইনে, যেটি মানটি শেষবার বদলেছিল। একবার ঘটা heisenbug-কেও এভাবে বারবার পরীক্ষা করা যায়। gdb-র নিজেরও record কমান্ড আছে, আর Microsoft-এর WinDbg-এ আছে Time Travel Debugging — একই চিন্তা।

এই চিন্তা সফটওয়্যারের বাইরেও আছে। বিমান দুর্ঘটনার পরে “ব্ল্যাক বক্স” (flight data recorder) উদ্ধার করে তদন্তকারীরা শেষ মুহূর্তগুলো আবার দেখেন। core dump, rr-এর রেকর্ডিং, log — সবই সফটওয়্যারের ব্ল্যাক বক্স।

মূল উপলব্ধি: ডিবাগিংয়ের সবচেয়ে বড় শত্রু অনির্দিষ্টতা। যত বেশি অনির্দিষ্ট উৎস আপনি স্থির বা রেকর্ড করতে পারবেন, bug তত বেশি “বিজ্ঞানের পরীক্ষা”-র মতো আচরণ করবে — আর তত দ্রুত ধরা পড়বে।

প্র ০৫লাখো মানুষ যে সার্ভার ব্যবহার করছেন, সেখানে gdb বসানো যায় না। তাহলে production-এ bug কীভাবে খোঁজা হয়?

ধরুন ঈদের আগের রাতে একটি মোবাইল ব্যাংকিং সার্ভিসের লেনদেন মাঝে মাঝে ব্যর্থ হচ্ছে। লাখো গ্রাহক, শত শত server. কোনো একটি process-কে gdb-তে থামালে তার সব গ্রাহক আটকে যাবেন। আর bug হয়তো ঘটে দশ লাখে একবার, শুধু বিশেষ চাপের সময়। এখানে debugger নয়, লাগে observability — সিস্টেম চলার সময়েই বাইরে থেকে ভেতরটা বোঝার ব্যবস্থা। এর তিনটি স্তম্ভ:

  • Log: প্রতিটি গুরুত্বপূর্ণ ঘটনার লিখিত রেকর্ড — সময়, অনুরোধের আইডি, কী হলো। এই পর্বের stderr-এ চিহ্নসহ debug print-এর পেশাদার রূপ (পর্ব ৩০-এর logging). ভালো log মানে প্রশ্ন আসার আগেই উত্তরের প্রমাণ জমা রাখা।
  • Metric: সংখ্যার সময়-রেখা — প্রতি সেকেন্ডে কত অনুরোধ, কতগুলো ব্যর্থ, মেমরি কত। Leak থাকলে মেমরির গ্রাফ দিনে দিনে উঠতেই থাকে; একটি নতুন release-এর পরে ব্যর্থতা বাড়লে সেটাই প্রথম সূত্র।
  • Trace: একটি অনুরোধ কোন কোন service আর ফাংশন পেরিয়ে গেল, কোথায় কত সময় লাগল — বহু-সার্ভারের জগতে backtrace-এর বড় ভাই।

আর crash হলে? Production-এর প্রোগ্রাম সাধারণত optimization চালু রেখে, debug symbol আলাদা ফাইলে সরিয়ে প্রকাশ করা হয় — প্রোগ্রাম ছোট আর দ্রুত থাকে, কিন্তু symbol হারায় না। Crash-এর সময় একটি core dump বা তার ছোট সংস্করণ (“minidump”) তৈরি হয়ে ডেভেলপারদের কাছে পৌঁছায়, আর তাঁরা সংরক্ষিত symbol মিলিয়ে backtrace পড়েন। Chrome-এর crash reporter (Breakpad, পরে Crashpad) বা Windows Error Reporting এই পদ্ধতিতেই কাজ করে — আপনার কম্পিউটারের crash থেকে নির্মাতা জানতে পারেন কোন লাইনে সমস্যা।

একটি নৈতিক দিকও আছে: log আর crash dump-এ গ্রাহকের তথ্য, পাসওয়ার্ড বা কার্ড নম্বর ঢুকে যেতে পারে। পেশাদার সিস্টেম log লেখার আগেই এগুলো মুছে বা ঢেকে দেয়।

মূল উপলব্ধি: production-এ ডিবাগিং শুরু হয় bug আসার অনেক আগে — log, metric আর symbol সংরক্ষণের নকশায়। যে সিস্টেম নিজের ভেতরের খবর দেয় না, তার bug খোঁজা অন্ধকারে হাতড়ানো।

অনুশীলন

বেশিরভাগই ভাবা আর পর্যবেক্ষণের কাজ — শুধু শেষটিতে কোড লিখতে হবে।

  1. Bug-এর জাত চিনুন: প্রতিটি লক্ষণের জন্য bug-এর জাত আর প্রথম যন্ত্রটি বলুন — (ক) undefined reference to `sqrt' (খ) প্রোগ্রাম রাতভর চললে সকালে ৪ GB মেমরি নিয়ে বসে থাকে (গ) Segmentation fault (core dumped), প্রতিবার একই ইনপুটে (ঘ) ১০০ বার চালালে ৩–৪ বার যোগফল একটু কম আসে, আর printf যোগ করলে আর আসেই না।

    (ক) Link error — compile ঠিক আছে, linker sqrt-এর সংজ্ঞা পাচ্ছে না। Linux-এ math library যোগ করুন: gcc prog.c -lm.

    (খ) Memory leak — Valgrind (--leak-check=full) বা ASan/LeakSanitizer দিয়ে ছোট সময় চালিয়ে দেখুন কোথায় নেওয়া মেমরি ফেরত যাচ্ছে না।

    (গ) Runtime crash, এবং পুনরুৎপাদনযোগ্য — সৌভাগ্য! gdb-তে চালিয়ে backtrace, অথবা -fsanitize=address-সহ build করে চালান।

    (ঘ) Heisenbug, সম্ভবত race condition (পর্ব ৪৯) — printf সময়ের ক্রম বদলে দিচ্ছে। -fsanitize=thread দিয়ে ThreadSanitizer চালান; সে race ঘটুক বা না ঘটুক, শর্তটি ধরে।

  2. Use-after-free রিপোর্ট পড়ুন: নিচের অংশটুকু থেকে বলুন — কী ধরনের ভুল, ভুলটি কোন লাইনে, মেমরি কোথায় নেওয়া হয়েছিল, আর কোথায় ছাড়া হয়েছিল?
    ERROR: AddressSanitizer: heap-use-after-free … READ of size 4 … #0 in print_total cart.c:21 … freed by thread T0 here: #1 in clear_cart cart.c:14 … previously allocated by thread T0 here: #1 in add_item cart.c:8

    ভুলের ধরন heap-use-after-free, আর এটি একটি পড়া (READ of size 4 — সম্ভবত একটি int বা float). ভুলটি ঘটেছে print_total-এ, cart.c:21. মেমরি নেওয়া হয়েছিল add_item-এ (লাইন ৮), আর ছাড়া হয়েছিল clear_cart-এ (লাইন ১৪)। অর্থাৎ ক্রমটি: add_item নিল → clear_cart ছেড়ে দিল → print_total তখনো পুরোনো pointer দিয়ে পড়ল। Root cause প্রায় নিশ্চিতভাবে clear_cart-এ: free-র পরে pointer-টি NULL করা হয়নি, অথবা print_total-কে clear-এর আগে ডাকার কথা ছিল। লক্ষ করুন, crash-এর জায়গা (লাইন ২১) নয়, ছাড়ার জায়গা (লাইন ১৪) থেকে তদন্ত শুরু করতে হবে।

  3. লুকানো bug বের করুন (পর্যবেক্ষণ): অংশ ৪-এর প্রথম প্রোগ্রামে শুধু লুপটি i = 0 থেকে শুরু করুন, আর কিছু বদলাবেন না। চালান — assert পাস করে? গড় কত আসে? এবার শেষ নম্বর 81-কে 82 করে আবার চালান। কী দেখলেন, আর কেন?

    প্রথমবার assert পাস করে (used এখন 5), আর গড় আসে 79.00 — দেখে মনে হয় সব ঠিক! কিন্তু এটা কাকতালীয়: 395 ÷ 5 ঠিক 79, তাই integer division কোনো দশমিক কাটেনি। 82 করলে সমষ্টি 396, সঠিক গড় 79.20, কিন্তু প্রোগ্রাম দেখায় 79.00. দ্বিতীয় bug (sum / n) এতক্ষণ লুকিয়ে ছিল। শিক্ষা দুটি: (১) একটি bug ঠিক করলে অন্যটি লুকিয়ে পড়তে পারে, তাই প্রতিটি অনুমান আলাদা ইনপুটে যাচাই করুন; (২) test-এর ইনপুট এমনভাবে বাছুন, যাতে ভুল থাকলে ফল সত্যিই আলাদা হয় — “সুন্দর” সংখ্যা bug লুকায়।

  4. নিজের DBG macro (কোড লিখতে হবে): এমন একটি DBG(...) macro লিখুন, যা প্রতিটি লেখার আগে ফাইলের নাম আর লাইন নম্বর বসায় ([dbg avg.c:12] sum=323), আর DEBUG define করা না থাকলে কিছুই করে না।

    পর্ব ২৭-এর predefined macro __FILE__, __LINE__ আর conditional compilation কাজে লাগবে:

    #include <stdio.h>
    
    #ifdef DEBUG
    #define DBG(...) do { \
            fprintf(stderr, "[dbg %s:%d] ", __FILE__, __LINE__); \
            fprintf(stderr, __VA_ARGS__); \
        } while (0)
    #else
    #define DBG(...) do { } while (0)    /* release build: nothing */
    #endif
    
    /* ব্যবহার: DBG("sum=%d\n", sum);   চালু করতে: gcc -DDEBUG avg.c */

    do { … } while (0) মোড়কটি দুই বা ততোধিক বাক্যের macro-কে একটি বাক্যের মতো আচরণ করায়, যাতে if (x) DBG(...); else … ঠিকঠাক কাজ করে (পর্ব ২৭)। __LINE__ macro যেখানে ব্যবহার হলো সেই লাইন দেয়, সংজ্ঞার লাইন নয় — তাই প্রতিটি debug লেখা নিজের জায়গা বলে দেয়। Release build-এ macro শূন্য হয়ে যায়, তাই কোনো খরচ নেই।

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

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

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

  1. পুনরুৎপাদন — স্থির ইনপুটে প্রতিবার bug ঘটানো
  2. পর্যবেক্ষণ — debug print বা gdb দিয়ে ভেতরের মান দেখা
  3. অনুমান — কোন ধারণাটি ভুল, স্পষ্ট করে বলা
  4. যাচাই — ভবিষ্যদ্বাণী করে পরীক্ষা চালানো
  5. সমাধান — root cause-এ বদল আনা
  6. প্রতিরোধ — assert ও স্থায়ী test যোগ করা

gdb-তে লাইন নম্বর আর ভ্যারিয়েবলের নাম দেখতে প্রোগ্রাম কীভাবে compile করবেন?

  • gcc -O2 prog.c
  • gcc -g -O0 prog.c
  • gcc -Wall prog.c
  • gcc -DNDEBUG prog.c

একটি ভ্যারিয়েবল total কোথাও অপ্রত্যাশিতভাবে বদলে যাচ্ছে, কিন্তু কোন লাইনে — জানেন না। gdb-র কোন কমান্ড সবচেয়ে কাজের?

  • print total
  • backtrace
  • watch total
  • finish

ASan বলছে: 0x…24 is located 0 bytes after 20-byte region. ব্লকটি malloc(5 * sizeof(int)). সবচেয়ে সম্ভাব্য bug কোনটি?

  • লুপ i <= 5 পর্যন্ত চলে a[5]-এ লিখছে
  • ব্লকটি free হওয়ার পরে ব্যবহার
  • ব্লকটি কখনো free হয়নি
  • a[-1]-এ লেখা

একটি bug printf যোগ করলেই আর ঘটে না। এ থেকে সবচেয়ে যুক্তিসংগত সিদ্ধান্ত কোনটি?

  • printf-ই bug ঠিক করে দিয়েছে, রেখে দিলেই চলবে
  • এটা নিশ্চয়ই compiler-এর bug
  • কম্পিউটারের RAM নষ্ট
  • সম্ভবত race condition বা undefined behavior — sanitizer দিয়ে শর্তটি খুঁজতে হবে

assert(load_config() == 0); লেখার সমস্যা কী?

  • এটা compile হবে না
  • -DNDEBUG দিলে load_config ডাকাই হবে না, আর এটি runtime error সামলানোর ভুল উপায়
  • assert সবসময় প্রোগ্রাম থামিয়ে দেয়
  • assert-এর বার্তা stdout-এ যায়

একজন আপনাকে একটি তৈরি প্রোগ্রাম দিলেন, source code নেই। এর memory leak খুঁজতে কোনটি ব্যবহার করবেন?

  • -fsanitize=address দিয়ে আবার build
  • -Wall -Wextra
  • valgrind --leak-check=full ./prog
  • cppcheck

শব্দকোষ

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

মনে রাখুন

  1. Bug থাকে আপনার ধারণায়। চক্রটি মানুন: পুনরুৎপাদন → পর্যবেক্ষণ → অনুমান → যাচাই → সমাধান → প্রতিরোধ; bug ছোট করুন, প্রয়োজনে git bisect.
  2. প্রথম পাহারা -Wall -Wextra (দলে -Werror); debug print যায় stderr-এ, চিহ্নসহ; ধারণা লিখে রাখুন assert-এ — তবে NDEBUG-এ assert মুছে যায়।
  3. gdb-র জন্য -g -O0; মূল কমান্ড break, run, next/step, print, watch, finish; crash হলে backtrace — আর root cause প্রায়ই এক-দুই frame ওপরে।
  4. -fsanitize=address,undefined — overflow, use-after-free, leak, UB হাতেনাতে; ASan রিপোর্ট পড়ুন: ধরন → প্রথম stack → “X bytes after N-byte region” → allocated by.
  5. Valgrind recompile ছাড়াই চলে কিন্তু অনেক ধীর; “definitely lost” মানে নিশ্চিত leak. AI-কে trace দিন, patch নয় অনুমান চান — আর লক্ষণ চাপা দেওয়া fix ফিরিয়ে দিন।

পরের পর্বে: আজ বারবার বলেছি — “যে ইনপুটে bug ধরা পড়েছিল, সেটিকে স্থায়ী test বানান।” পরের পর্বে ঠিক সেটাই শিখব: unit test, test harness আর এমন অভ্যাস, যাতে একবার মারা bug আর কখনো ফিরে না আসে।

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

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