Foundationপ্রথম নীতি থেকে
LEVEL 1লেসন ৫/১৪কঠিন১ ঘণ্টা ১০ মিনিট

Signed Integer ও Two's Complement — ঋণাত্মক সংখ্যার তিনটা ইতিহাস

Signed Integers and Two's Complement

একই n bit দিয়ে ঋণাত্মক সংখ্যা লেখার তিনটা ঐতিহাসিক প্রতিদ্বন্দ্বী স্কিম ছিল — two's complement জিতেছে কারণ এটা আসলে ℤ_(2ⁿ)-এর additive inverse নেওয়া মাত্র, আর সেই একটা সিদ্ধান্তেই hardware সরল হয়ে যায়।

এই লেসন শেষে আপনি পারবেন

  • sign-magnitude, one's complement, আর two's complement — তিনটা scheme-এর bit pattern হাতে-কলমে বানাতে ও পড়তে পারবেন
  • 'দুইটা শূন্য' সমস্যা কেন sign-magnitude আর one's complement-এ ঘটে, আর two's complement কীভাবে এটা এড়ায় সেটা প্রমাণ করতে পারবেন
  • two's complement negation যে ঠিক `~x + 1 = 2ⁿ − x`, এই প্রমাণ নিজে reproduce করতে পারবেন
  • কেন একটা মাত্র hardware adder দিয়ে signed ও unsigned, addition ও subtraction — সবই চলে, সেটা ব্যাখ্যা করতে পারবেন
  • sign extension সঠিকভাবে করতে পারবেন, আর ভুল (zero-extend) করলে কী bug হয় সেটা demonstrate করতে পারবেন
  • two's complement-এর অসামঞ্জস্য range (-128..127, +128 নেই) কেন অনিবার্য সেটা counting argument দিয়ে ব্যাখ্যা করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

একটা প্রশ্ন দিয়ে শুরু করি যেটা প্রায় সবাইকে অবাক করে।

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

int main(void) {
    int8_t x = -128;
    int8_t y = -x;
    printf("%d\n", y);
    return 0;
}

আপনি আশা করবেন 128। কিন্তু ছাপা হবে -128 — আবার সেই একই সংখ্যা। মাইনাসের মাইনাস, তবু চিহ্ন বদলায়নি। এটা কোনো printf bug না, কোনো compiler bug না — এটা একটা গভীর গাণিতিক সত্যের প্রকাশ যেটা এই পুরো লেসনের কেন্দ্রবিন্দু।

আরেকটা প্রশ্ন — int8_t-এর range -128 থেকে 127কেন -128 আছে কিন্তু +128 নেই? ধনাত্মক আর ঋণাত্মক সংখ্যা তো “সমান” হওয়া উচিত ছিল, তাই না? -127 থেকে 127 হলে অন্তত সুন্দর, প্রতিসম দেখাত।

এই দুইটা “অদ্ভুততা” — নেগেশনে -128 অপরিবর্তিত থাকা, আর range-এর অসামঞ্জস্য — কোনো দুর্ঘটনা না। এরা একটা একক সিদ্ধান্তের সরাসরি, অনিবার্য ফলাফল, আর সেই সিদ্ধান্তের নাম two’s complement

কম্পিউটিং-এর ইতিহাসে ঋণাত্মক সংখ্যা লেখার জন্য আসলে তিনটা প্রতিদ্বন্দ্বী স্কিম ছিল। আজ প্রায় প্রতিটা CPU একটাই বেছে নিয়েছে — এতটাই সম্পূর্ণভাবে যে C++20 standard-এ (2020) এটাই এখন একমাত্র বৈধ সংজ্ঞা, বাকি দুটো ভাষা থেকে আনুষ্ঠানিকভাবে বাদ। কেন এই একটা জিতল, বাকি দুটো হারল — এই লেসনের গল্প সেটাই।

আগের লেসনে (unsigned integers) আমরা দেখেছি: unsigned world-এ বিয়োগ আসলে গোপনে একটা যোগ, a - b = a + (2ⁿ - b)। এই লেসনে সেই “গোপন কৌশল”-টাই প্রকাশ্যে আসবে — আর দেখবেন এটাই আসলে two’s complement-এর সংজ্ঞা

মূল ধারণা

সমস্যাটা — একই bit দিয়ে দুই দিকে গোনা

n bit দিয়ে 2ⁿ টা আলাদা pattern পাওয়া যায়। Unsigned-এ সবগুলোকে 0 থেকে 2ⁿ−1 পর্যন্ত ব্যবহার করি। Signed-এ আমরা চাই এই একই 2ⁿ টা pattern দিয়ে দুই দিকে গুনতে — কিছু ধনাত্মক, কিছু ঋণাত্মক। প্রশ্ন হলো: কীভাবে ভাগ করব, আর ঋণাত্মক সংখ্যা bit দিয়ে কীভাবে লিখব?

ইতিহাসে তিনটা উত্তর প্রতিদ্বন্দ্বিতা করেছে। ৪-bit উদাহরণ দিয়ে তিনটাই পাশাপাশি দেখি — n = 4, তাই 2⁴ = 16 টা pattern।

স্কিম ১ — Sign-magnitude

সবচেয়ে সরল, সবচেয়ে “স্বজ্ঞাত” স্কিম: সবচেয়ে গুরুত্বপূর্ণ bit (MSB) দিয়ে চিহ্ন বোঝাও (0 = ধনাত্মক, 1 = ঋণাত্মক), বাকি bit গুলো দিয়ে magnitude (পরম মান) লেখো — ঠিক যেমন আমরা কাগজে -5 লিখি: একটা মাইনাস চিহ্ন, তারপর 5

value=(1)dn1×i=0n2di2i\text{value} = (-1)^{d_{n-1}} \times \sum_{i=0}^{n-2} d_i \cdot 2^i

স্কিম ২ — One’s complement

ঋণাত্মক সংখ্যা লেখো ধনাত্মক প্রতিরূপের প্রতিটা bit উল্টে (bitwise NOT)। +5 = 0101 হলে -5 = 1010 (প্রতিটা bit flip)।

স্কিম ৩ — Two’s complement

One’s complement-এর মতোই bit উল্টাও, তারপর 1 যোগ করো+5 = 0101 হলে -5 = ~0101 + 1 = 1010 + 1 = 1011

তিনটা পাশাপাশি — সম্পূর্ণ ৪-bit টেবিল

এইটাই এই লেসনের সবচেয়ে গুরুত্বপূর্ণ টেবিল। ১৬টা bit pattern-ই লিখে ফেলি, তিনটা scheme-এ কীভাবে পড়া হয়:

Bit patternUnsignedSign-magnitudeOne’s complementTwo’s complement
00000+0+00
00011+1+11
00102+2+22
00113+3+33
01004+4+44
01015+5+55
01106+6+66
01117+7+77
10008−0−7−8
10019−1−6−7
101010−2−5−6
101111−3−4−5
110012−4−3−4
110113−5−2−3
111014−6−1−2
111115−7−0−1

এই টেবিলটা কয়েক মিনিট ধরে দেখুন — পুরো লেসনের প্রায় সবকিছু এখান থেকেই বেরোবে।

Two’s complement-এর আসল সংজ্ঞা — ওজনযুক্ত যোগফল

উপরের “উল্টাও, তারপর ১ যোগ করো” পদ্ধতিটা একটা নির্মাণ-কৌশল (negative বানানোর রেসিপি), সংজ্ঞা নয়। প্রকৃত, প্রত্যক্ষ সংজ্ঞা হলো: n-bit pattern dₙ₋₁ … d₁ d₀-এর two’s complement মান —

value=dn12n1+i=0n2di2i\text{value} = -d_{n-1} \cdot 2^{n-1} + \sum_{i=0}^{n-2} d_i \cdot 2^i

লক্ষ্য করুন — এটা unsigned-এর ঠিক সেই একই positional-notation সূত্র, শুধু সবচেয়ে গুরুত্বপূর্ণ bit-এর ওজন ঋণাত্মক। বাকি সব bit-এর ওজন আগের মতোই ধনাত্মক 2ⁱ

4-bit উদাহরণে যাচাই করি, 1011 নিয়ে:

value=1×23+0×22+1×21+1×20=8+0+2+1=5\text{value} = -1 \times 2^3 + 0 \times 2^2 + 1 \times 2^1 + 1 \times 2^0 = -8 + 0 + 2 + 1 = -5

টেবিলের সাথে মিলছে ✓। এই “MSB-এর ওজন ঋণাত্মক” সংজ্ঞাটাই সবচেয়ে পরিষ্কার, আর এখান থেকেই বাকি সব property সরাসরি প্রমাণ করা যায় — যেটা আমরা পরের অংশে করব।

Range — কেন অসামঞ্জস্য

উপরের সূত্র থেকে সরাসরি বের করা যায় n-bit two’s complement-এর range:

  • সর্বনিম্ন: শুধু MSB 1, বাকি সব 01000 (৪-bit-এ) → -2^(n-1)
  • সর্বোচ্চ: MSB 0, বাকি সব 101112^(n-1) - 1

2n1    value    2n11-2^{n-1} \;\le\; \text{value} \;\le\; 2^{n-1} - 1

8-bit-এ: -128 থেকে 12732-bit-এ: -2,147,483,648 থেকে 2,147,483,647

কেন এই অসামঞ্জস্য অনিবার্য — একটা গোনার যুক্তি (counting argument):

মোট 2ⁿ টা bit pattern আছে। শূন্য একটা মান নেয়। বাকি 2ⁿ − 1 টা pattern ধনাত্মক আর ঋণাত্মকের মধ্যে ভাগ করতে হবে। কিন্তু 2ⁿ − 1 একটা বিজোড় সংখ্যা (2ⁿ জোড়, −1 করলে বিজোড়) — তাই এটা দুই সমান ভাগে ভাগ করা অসম্ভব। এক পক্ষকে এক বেশি পেতেই হবে।

Two’s complement বেছে নেয়: ঋণাত্মক দিক এক বেশি পাবে। ফলে 2^(n-1) টা ঋণাত্মক মান (-1 থেকে -2^(n-1)), আর 2^(n-1) - 1 টা ধনাত্মক মান (1 থেকে 2^(n-1)-1), প্লাস একটা শূন্য — মোট 2^(n-1) + (2^(n-1) - 1) + 1 = 2ⁿ ✓, নিখুঁত।

তুলনায়, sign-magnitude/one’s complement একই সমস্যার ভিন্ন সমাধান বেছেছিল: তারা প্রতিসাম্য (symmetric range, -7 থেকে +7) বেছে নিয়েছিল, বিনিময়ে একটা বাড়তি শূন্য মেনে নিয়ে (pattern নষ্ট করে)। Two’s complement উল্টো পথে গেছে — প্রতিসাম্য ছেড়ে দিয়ে, প্রতিটা pattern-কে কাজে লাগিয়েছে।

ভেতরে কী ঘটছে

Two’s complement = arithmetic mod 2ⁿ — সম্পূর্ণ প্রমাণ

Level 0-এর number-systems লেসনে (mathematics/number-systems, “Two’s complement = arithmetic mod 2ⁿ” অংশ) আমরা এই দাবিটা করেছিলাম, প্রাথমিক আকারে। এখানে আমরা সেটা সম্পূর্ণভাবে derive করব — bit থেকে শুরু করে।

দাবি: ~x = (2ⁿ − 1) − x

x-এর প্রতিটা bit dᵢ উল্টে দিলে (1 − dᵢ) পাওয়া যায় (0 ↔ 1)। তাই ~x-এর মান:

x=i=0n1(1di)2i=i=0n12i    i=0n1di2i=(2n1)x\sim x = \sum_{i=0}^{n-1} (1-d_i) \cdot 2^i = \sum_{i=0}^{n-1} 2^i \;-\; \sum_{i=0}^{n-1} d_i \cdot 2^i = (2^n - 1) - x

প্রথম যোগফল \sum 2^i (i=0 থেকে n-1) একটা geometric series, যার যোগফল ঠিক 2ⁿ − 1 (Level 0-এর কম্বিনেটরিক্স লেসনের সূত্র)। দ্বিতীয় যোগফল সংজ্ঞা অনুযায়ী x নিজেই (unsigned interpretation-এ)। \quad\blacksquare

দাবি: two’s complement negation = ~x + 1 = 2ⁿ − x

উপরের ফলাফলে 1 যোগ করুন:

x+1=[(2n1)x]+1=2nx\sim x + 1 = \big[(2^n - 1) - x\big] + 1 = 2^n - x

\quad\blacksquare — এটাই এই লেসনের কেন্দ্রীয় সমীকরণ

এটাই ℤ_(2ⁿ)-এর additive inverse

2ⁿ − x চেনা লাগছে? এটা ঠিক Level 0-এর number-systems লেসনে শেখা concept — x-এর additive inverse mod 2ⁿ। কারণ:

x+(2nx)=2n0(mod2n)x + (2^n - x) = 2^n \equiv 0 \pmod{2^n}

অর্থাৎ two’s complement negation আক্ষরিকভাবে সেই একই ℤ_(2ⁿ)-এ x-এর সাথে যোগ করলে 0 দেয় এমন উপাদান খোঁজা — ঠিক যেমন সাধারণ পাটিগণিতে -x-এর সংজ্ঞা x + (-x) = 0। কোনো নতুন গণিত লাগেনি — শুধু “0 থেকে 2ⁿ−1” জগতে “additive inverse”-এর অর্থ কী, সেটা প্রয়োগ করা হয়েছে।

যাচাই — 4-bit-এ x = 5 (0101):

x=1010=10,x+1=1011=11\sim x = 1010 = 10, \qquad \sim x + 1 = 1011 = 11

আর 2⁴ − 5 = 16 − 5 = 11 ✓। টেবিলে 1011 মানে (two’s complement column) −5 ✓।

-128-এর রহস্য এবার সমাধান

x = -128 (bit pattern 1000 0000, ৮-bit)। Negation করতে ~x + 1 করি:

   x = 1000 0000
  ~x = 0111 1111
~x+1 = 1000 0000    ← 0111 1111 + 1 = 1000 0000, আবার সেই একই!

গাণিতিকভাবেও: 2⁸ − (−128) = 256 − (−128) = 384। কিন্তু 384 \bmod 256 = 128, আর 128-এর bit pattern (৮-bit-এ) 1000 0000 — যেটা two’s complement-এ পড়া হয় −128 হিসেবেই (কারণ MSB-এর ওজন ঋণাত্মক)! তাই গাণিতিক উত্তর +128 হওয়ার কথা থাকলেও, সেটা representable না বলে modular reduction তাকে আবার −128-এই ফিরিয়ে আনে। এটা প্রমাণ করে যে negation-ও একটা arithmetic operation, আর তাই এটাও overflow করতে পারে — ঠিক addition/subtraction-এর মতোই।

একটা bit pattern, দুইটা পাঠ — unsigned আর signed একই ring-এর দুই দিক
  1. 4-bit pattern 1011কাঁচা bits — এখনো কোনো অর্থ নেই
  2. ℤ₁₆-এর equivalence class [11]{ …, -5, 11, 27, … } — একই class-এর সব প্রতিনিধি
  3. unsigned প্রতিনিধি বাছাইসবসময় [0, 15]-এর মধ্যেরটা → 11
  4. two's complement প্রতিনিধি বাছাইউপরের অর্ধেকের জন্য ঋণাত্মক প্রতিনিধি → -5
  5. CPU-র ADD/SUB circuitঠিক একই circuit — শুধু ফলাফল পড়ার নিয়ম আলাদা

কেন একটা মাত্র adder circuit যথেষ্ট

x = 4-bit-এর উদাহরণেই দেখুন — bit pattern 1011-কে unsigned পড়লে 11, signed পড়লে -5। এবার এই pattern-এ 0011 (3) যোগ করুন:

  1011
+ 0011
-------
  1110

Unsigned পাঠ: 11 + 3 = 14, আর 1110 unsigned-এ 14

Signed পাঠ: -5 + 3 = -2, আর 1110 two’s complement-এ -2 ✓ (টেবিল দেখুন)

একই bit-level addition, একই circuit, দুইটা সম্পূর্ণ ভিন্ন কিন্তু দুইটাই সঠিক উত্তর — শুধু bit pattern-টা কীভাবে পড়া হচ্ছে তার উপর নির্ভর করে। এটাই দুই’s complement-এর সবচেয়ে বড় practical জয়: CPU-র ALU-তে signed আর unsigned addition-এর জন্য আলাদা circuit লাগে না। একটাই binary adder, দুই ভিন্ন interpretation।

এই কারণেই x86-এর ADD instruction-এর কোনো “signed” বা “unsigned” ভ্যারিয়েন্ট নেই — শুধু একটাই ADD। পার্থক্য আসে পরের instruction-এ (flag পড়ার সময়) — যেমন লেসন ৪-এ দেখেছেন, unsigned branch CF পড়ে, signed branch SF/OF পড়ে। Arithmetic-টা এক, শুধু ফলাফলের ব্যাখ্যা আলাদা।

বিয়োগও একই adder দিয়ে

a − b = a + (−b) = a + (\sim b + 1)। Hardware-এ এটা বাস্তবায়ন করা হয় একটা চমৎকার কৌশলে: প্রতিটা b-এর bit একটা XOR gate দিয়ে পাঠানো হয়, যার দ্বিতীয় input হলো একটা control সংকেত (subtract?)। subtract = 0 হলে XOR কিছু বদলায় না (b XOR 0 = b) — সাধারণ addition। subtract = 1 হলে XOR প্রতিটা bit উল্টে দেয় (b XOR 1 = ~b) — আর সেই একই control সংকেত adder-এর carry-in-এও 1 বসিয়ে দেয়, যা ঠিক ~b + 1 সম্পন্ন করে।

       control (0=add, 1=subtract)

   b ────────XOR──────┐

   a ──────────────► ADDER ──► a + (b XOR control) + control

              carry-in = control

একটা মাত্র XOR-array আর একটা মাত্র adder circuit দিয়ে addition এবং subtraction দুটোই — এটা Level 2-এ (digital logic) আমরা পুরো circuit হিসেবে বানাব। এখানে শুধু এইটুকু বোঝাই যথেষ্ট: এই সরলীকরণ সম্ভবই হতো না যদি negation-এর সংজ্ঞা 2ⁿ − x (modular additive inverse) না হতো।

Sign extension — টাইপ বড় করার সঠিক নিয়ম

একটা int8_t-কে int32_t-তে convert করলে bit pattern-এর কী হওয়া উচিত? মান অপরিবর্তিত থাকতে হবে — -5 তখনও -5। কিন্তু bit pattern তো ৮-bit থেকে ৩২-bit-এ বড় হচ্ছে, নতুন bit গুলো কী দিয়ে ভরবেন?

উত্তর: sign bit repeat করে (sign extension), শূন্য দিয়ে নয়।

int8_t -5 = 1111 1011int32_t-তে সঠিকভাবে widen করলে:

int8_t  (8-bit):  1111 1011
int32_t (32-bit): 1111 1111 1111 1111 1111 1111 1111 1011
                   └──────────────────────────────┘└──────┘
                        নতুন ২৪টা bit — সবই sign bit             আসল ৭ bit
                        (MSB) copy করা

কেন sign bit repeat করলে মান টিকে থাকে — প্রমাণ:

আমাদের ওজনযুক্ত সূত্র মনে করুন: value = -dₙ₋₁2^(n-1) + \sum d_i 2^i

n-bit থেকে m-bit-এ (m > n) widen করে যদি নতুন সব bit (পজিশন n থেকে m-1) পুরনো sign bit dₙ₋₁-এর কপি হয়, তাহলে নতুন মান:

dn12m1+i=n1m2dn12i+i=0n2di2i-d_{n-1}2^{m-1} + \sum_{i=n-1}^{m-2} d_{n-1} \cdot 2^i + \sum_{i=0}^{n-2} d_i \cdot 2^i

মাঝের যোগফলটা geometric series: d_{n-1} (2^{m-1} - 2^{n-1})। পুরোটা একসাথে সাজালে (বীজগণিত একটু ঘন, কিন্তু ফলাফল পরিষ্কার):

=dn12n1+i=0n2di2i=আগের মান, অপরিবর্তিত= -d_{n-1} \cdot 2^{n-1} + \sum_{i=0}^{n-2} d_i \cdot 2^i = \text{আগের মান, অপরিবর্তিত}

মূল অন্তর্দৃষ্টি: sign extension আসলে n-1 থেকে m-1 পর্যন্ত “নতুন” bit-দের একসাথে যোগফলে শূন্য বানিয়ে দেয় যদি sign 0 হয় (কারণ সবগুলোই 0), আর ঋণাত্মক দিকে ঠিক পরিমাণ যোগ করে যদি sign 1 হয় — নিট ফল: মান অপরিবর্তিত।

ভুল করলে কী হয় — zero-extend দিয়ে:

int8_t  -5 =              1111 1011
ভুল (zero-extend):  0000 0000 0000 0000 0000 0000 1111 1011
                                                    = 251, signed পড়লে +251!

-5 হঠাৎ 251 হয়ে গেল — সম্পূর্ণ ভুল মান। এটা কোনো তাত্ত্বিক ভয় না, বাস্তব bug-এর প্রকৃত উৎস, নিচে দেখব।

char কি signed না unsigned — একটা লুকানো portability বিপদ

C standard char-কে ইচ্ছাকৃতভাবে implementation-defined রেখেছে — signed নাকি unsigned, সেটা compiler/platform ঠিক করে। ঐতিহাসিকভাবে x86-এ char সাধারণত signed (-128 থেকে 127), কিন্তু ARM-এ প্রায়ই unsigned (0 থেকে 255) ডিফল্ট।

এই অনিশ্চয়তা একটা ক্লাসিক bug তৈরি করে — K&R যুগ থেকে পরিচিত, আজও লেখা হয়:

#include <stdio.h>

int main(void) {
    char c;
    while ((c = getchar()) != EOF) {   /* বিপজ্জনক! */
        putchar(c);
    }
    return 0;
}

getchar() রিটার্ন করে int, আর EOF সাধারণত -1। এই কোড c-কে char-এ রাখছে — সমস্যা এখানেই।

  • ইনপুটে যদি byte value 0xFF (255) আসে (যেমন UTF-8-এর অংশ, বা কোনো binary data), সেটা getchar() থেকে int হিসেবে 255 ফেরত দেয় (সঠিক)।
  • এখন সেটা char c-তে রাখা হচ্ছে। যদি char signed হয় এই platform-এ, 255-এর bit pattern (1111 1111) char-এ রাখলে সেটা signed interpretation পায় — -1!
  • while condition-এ c আবার int-এ sign-extend হয়ে ফেরত promote হয় (কারণ signed char → int promotion sign-extend করে): -1 (char) থেকে -1 (int)।
  • আর EOF-ও -1। তুলনা c != EOF মিথ্যা — loop ভুলভাবে থেমে যায়, যদিও প্রকৃত ফাইলের শেষ আসেনি! একটা সাধারণ 0xFF byte পুরো read-loop ভেঙে দিল।

সঠিক লেখা:

int c;                              /* char নয়, int — getchar()-এর প্রকৃত রিটার্ন টাইপ */
while ((c = getchar()) != EOF) {
    putchar(c);
}

এখানে c সরাসরি int, তাই getchar()-এর ফেরত 255 (byte 0xFF) আর EOF-এর -1 — দুইটা স্পষ্টভাবে ভিন্ন int মান, কোনো signed-char সংকোচনের মধ্য দিয়ে যেতে হয়নি।

এই bug-টা সরাসরি sign extension-এর গল্পchar থেকে int-এ promote হওয়ার সময় compiler sign bit repeat করে (যদি char signed হয়), আর সেই sign-extension-ই 255-কে -1-এ রূপান্তরিত করে দেয়।

উদাহরণ

একটা সম্পূর্ণ উদাহরণ — শেষ bit পর্যন্ত

ধাপ ১ — +42-কে negate করি, ৮-bit two’s complement-এ।

  +42 = 0010 1010

Bitwise NOT:

   ~x = 1101 0101

+1 যোগ:

1101 0101
+       1
-----------
1101 0110

ফলাফল: 1101 0110

যাচাই তিন ভাবে:

পদ্ধতিহিসাবফলাফল
Unsigned পাঠ1101 0110₂214
Two’s complement পাঠ-1×128 + 1×64 + 0 + 1×16 + 0 + 1×4 + 1×2 + 0-128+64+16+4+2 = -42
Modular সূত্র2⁸ - 42 = 256 - 42214 (unsigned প্রতিনিধি) ✓

তিনটা পদ্ধতিই মিলছে — bit-level NOT+1, ওজনযুক্ত সূত্র, আর 2ⁿ − x মড়ুলার সূত্র, সবগুলোই একই উত্তর দেয়, ঠিক যেমনটা প্রমাণ করেছিলাম।

ধাপ ২ — Sign extension, -5-কে int8_t থেকে int32_t-তে।

int8_t:   1111 1011                                            (= -5)

সঠিক (sign-extend):
int32_t:  1111 1111 1111 1111 1111 1111 1111 1011              (= -5)
          hex: 0xFFFFFFFB

ভুল (zero-extend):
int32_t:  0000 0000 0000 0000 0000 0000 1111 1011              (= +251)
          hex: 0x000000FB

C-তে সাধারণ implicit conversion (int8_t থেকে int-এ) সবসময় সঠিকভাবে sign-extend করে — কম্পাইলার এটা automatic ভাবে, নির্ভুলভাবে করে। বিপদ আসে যখন আপনি manual byte-level কাজ করেন — যেমন একটা network packet বা audio file থেকে raw byte পড়ে নিজে হাতে জোড়া লাগানো:

/* একটা 16-bit signed audio sample দুইটা byte থেকে জোড়া লাগানো */
uint8_t lo = 0xFB, hi = 0xFF;    /* little-endian bytes, মান হওয়া উচিত -5 */

/* ভুল — zero-extend করে ফেলছে */
int32_t wrong = (hi << 8) | lo;              /* = 0x0000FFFB = 65531, ভুল! */

/* সঠিক — প্রথমে int16_t-এ জোড়া লাগান, তারপর সেই টাইপের implicit sign-extend ব্যবহার করুন */
int16_t sample = (int16_t)((hi << 8) | lo);  /* bit pattern 0xFFFB, int16_t হিসেবে = -5 */
int32_t right  = sample;                      /* এবার automatic sign-extend, = -5 ✓ */

পার্থক্যটা সূক্ষ্ম কিন্তু critical: প্রথম ভুল সংস্করণে hi, lo দুটোই uint8_t/int হিসেবে treated, তাই << আর |-এর ফলাফল একটা সাধারণ int, কোনো “এটা আসলে ১৬-bit signed” তথ্য টাইপ সিস্টেমে নেই — তাই কম্পাইলার sign-extend করার কোনো কারণ পায় না। দ্বিতীয় সংস্করণে explicit cast করে টাইপকে int16_t বলে দেওয়া হচ্ছে আগে থেকেই, তারপর সেই টাইপ থেকে int32_t-তে implicit conversion স্বয়ংক্রিয়ভাবে sign-extend করে।

ধাপ ৩ — INT8_MIN negation, edge case।

  x = -128 = 1000 0000
 ~x =         0111 1111
~x+1 =        1000 0000    ← আবার -128!

গাণিতিকভাবে প্রত্যাশিত ফলাফল +128, কিন্তু int8_t-এর range মাত্র -128127+128 representable না। ফলাফল আবার mod 256 reduce হয়ে -128-এ ফিরে আসে। এটাই signed integer overflow — যার সম্পূর্ণ আলোচনা পরের লেসনে।

নিজে চালিয়ে দেখুন

EXPERIMENT

তিনটা scheme নিজে বানিয়ে যাচাই করুন — আর int8_t-এর সাথে মিলিয়ে দেখুন

Linux / macOS / WSL (gcc)· ২০ মিনিট
// three_schemes.c
#include <stdio.h>
#include <stdint.h>

/* Sign-magnitude: MSB চিহ্ন, বাকি ৭ bit magnitude (৮-bit pattern ধরে নিচ্ছি) */
int sign_magnitude(uint8_t bits) {
    int sign = (bits & 0x80) ? -1 : 1;
    int magnitude = bits & 0x7F;
    return sign * magnitude;
}

/* One's complement: MSB=1 হলে বিটওয়াইজ NOT নিয়ে ঋণাত্মক পড়ো */
int ones_complement(uint8_t bits) {
    if (bits & 0x80) {
        uint8_t inverted = (uint8_t)(~bits) & 0x7F;   /* উপরের বিট বাদ দিয়ে ৭-বিট NOT */
        return -(int)inverted;
    }
    return bits;
}

/* Two's complement: C-র নিজস্ব int8_t ব্যবহার করে — এটাই আসল রেফারেন্স */
int twos_complement(uint8_t bits) {
    int8_t native;
    __builtin_memcpy(&native, &bits, 1);   /* raw bits reinterpret, UB-মুক্ত পদ্ধতি */
    return native;
}

int main(void) {
    printf("%6s  %10s  %10s  %10s  %10s\n",
           "bits", "unsigned", "sign-mag", "1's comp", "2's comp");
    uint8_t interesting[] = {
        0x00, 0x01, 0x7F, 0x80, 0x81, 0xFE, 0xFF
    };
    for (size_t i = 0; i \< sizeof(interesting); i++) {
        uint8_t b = interesting[i];
        printf("0x%02X    %10u  %10d  %10d  %10d\n",
               b, b, sign_magnitude(b), ones_complement(b), twos_complement(b));
    }
    return 0;
}
gcc -O2 -Wall -Wextra -o three_schemes three_schemes.c
./three_schemes

প্রত্যাশিত output:

  bits    unsigned    sign-mag    1's comp    2's comp
0x00             0           0           0           0
0x01             1           1           1           1
0x7F           127         127         127         127
0x80           128        -0*          -127        -128
0x81           129          -1        -126        -127
0xFE           254        -126          -1          -2
0xFF           255        -127          -0*          -1

(-0* মানে গাণিতিকভাবে শূন্য, printf("%d", 0)-এ কোনো মাইনাস চিহ্ন দেখায় না — কিন্তু bit pattern আলাদা, 0x00-এর থেকে ভিন্ন। নিজের sign_magnitude/ones_complement ফাংশনে magnitude == 0 কিনা আলাদাভাবে চেক করে দেখুন 0x80 আর 0xFF-এ এই বিশেষ কেস ধরা পড়ে।)

লক্ষ্য করুন 0x80 (1000 0000): sign-magnitude আর one’s complement-এ এটা “শূন্যের একটা বিকল্প রূপ” বা extreme negative (scheme-ভেদে), কিন্তু two’s complement-এ এটা পরিষ্কারভাবে -128 — সর্বনিম্ন representable মান, দ্ব্যর্থহীন।

নিজে বাড়ান: sign_magnitude-এ 0x80 (magnitude 0, sign ঋণাত্মক) detect করুন আর -0 স্পষ্টভাবে print করুন, তারপর 0x00-এর সাথে তুলনা করে দেখুন == operator দুটোকে সমান বলে কি না — bit pattern হিসেবে (memcmp) নাকি মান হিসেবে (sign_magnitude(a) == sign_magnitude(b)), দুটোর ফলাফল ভিন্ন হবে।

এটা কী প্রমাণ করে

C-র native int8_t ঠিক two's complement সূত্র অনুসরণ করে — sign-magnitude আর one's complement আলাদাভাবে হাতে বানালে তাদের 'দুইটা শূন্য' সমস্যা চোখে ধরা পড়ে।

EXPERIMENT

Sign extension bug — নিজের চোখে ভাঙা কোড দেখুন

Linux / macOS / WSL (gcc)· ১৫ মিনিট

ধাপ ১ — সঠিক বনাম ভুল sign extension পাশাপাশি।

// sign_extend.c
#include <stdio.h>
#include <stdint.h>

int main(void) {
    int8_t  small = -5;
    uint8_t raw   = (uint8_t)small;          /* raw bits: 0xFB */

    int32_t correct   = small;                          /* implicit — automatic sign-extend */
    int32_t wrong_ext = (int32_t)raw;                    /* raw uint8_t থেকে — zero-extend */

    printf("small        = %d   (bits 0x%02X)\n", small, raw);
    printf("sign-extend  = %d   (0x%08X)  <- সঠিক\n", correct, (uint32_t)correct);
    printf("zero-extend  = %d   (0x%08X)  <- ভুল, small-কে uint8_t দিয়ে ঘুরিয়ে আনলে যা হয়\n",
           wrong_ext, (uint32_t)wrong_ext);
    return 0;
}
gcc -O2 -Wall -Wextra -o sign_extend sign_extend.c
./sign_extend
small        = -5   (bits 0xFB)
sign-extend  = -5   (0xFFFFFFFB)  <- সঠিক
zero-extend  = 251   (0x000000FB)  <- ভুল, small-কে uint8_t দিয়ে ঘুরিয়ে আনলে যা হয়

একই ৮-bit pattern (0xFB), দুইটা সম্পূর্ণ ভিন্ন ৩২-bit মান — পার্থক্য শুধু widen করার সময় নতুন bit-গুলো sign bit-এর কপি নাকি শূন্য।

ধাপ ২ — getchar()/EOF bug reproduce করুন।

// getchar_bug.c
#include <stdio.h>

int main(void) {
    /* signed char (x86-তে ডিফল্ট) হিসেবে জোর করে দেখাই কী হয় */
    signed char c = (signed char)0xFF;   /* byte value 255 কে signed char-এ রাখা */
    int as_int = c;                       /* implicit sign-extend */

    printf("signed char 0xFF   as int = %d\n", as_int);
    printf("EOF সাধারণত         = %d\n", EOF);
    printf("(c == EOF) ধরনের bug ধরা পড়বে: %s\n",
           (as_int == EOF) ? "হ্যাঁ, বাগ ঘটবে" : "না");

    unsigned char u = (unsigned char)0xFF;
    int as_int_u = u;                     /* unsigned char → zero-extend */
    printf("unsigned char 0xFF as int = %d  (সঠিক আচরণ)\n", as_int_u);
    return 0;
}
gcc -O2 -Wall -Wextra -o getchar_bug getchar_bug.c
./getchar_bug
signed char 0xFF   as int = -1
EOF সাধারণত         = -1
(c == EOF) ধরনের bug ধরা পড়বে: হ্যাঁ, বাগ ঘটবে
unsigned char 0xFF as int = 255  (সঠিক আচরণ)

0xFF (byte value 255, একটা সাধারণ, বৈধ byte) signed char-এ রাখলে sign-extend হয়ে -1 হয়ে যায় — ঠিক EOF-এর মতো। এটাই কারণ getchar()-এর রিটার্ন টাইপ কখনো char না, সবসময় int হওয়া উচিত।

নিজে বাড়ান: নিজের platform-এ char ডিফল্ট signed না unsigned যাচাই করুন:

echo 'int main(){ return (char)-1 < 0; }' | gcc -x c - -o /tmp/chk && /tmp/chk; echo $?
# 1 হলে char signed (x86-এ সাধারণ), 0 হলে unsigned (কিছু ARM/PowerPC-তে)
এটা কী প্রমাণ করে

Zero-extend দিয়ে একটা signed মান widen করলে সম্পূর্ণ ভুল সংখ্যা পাওয়া যায় — আর 'char signed না unsigned' প্রশ্নটা getchar()/EOF loop-এ বাস্তব bug তৈরি করে।

নিজে বানান

BUILD IT

Bit-Level Representation Inspector

C · ●●●○○
  1. একটা raw 8-bit মান নিয়ে sign-magnitude, one's complement, two's complement — তিনটা scheme-এই decode করে পাশাপাশি ছাপান
  2. নিজের হাতে negate() ফাংশন লিখুন যেটা কোনো int8_t-এর জন্য NOT+1 করে, আর native unary minus (-x)-এর সাথে সব 256টা মান মিলিয়ে verify করুন
  3. sign_extend(int8_t, int width) ফাংশন লিখুন যা 8, 16, 32 বা 64-bit-এ সঠিকভাবে widen করে, manual bit-shifting দিয়ে (built-in cast ছাড়া)
  4. INT8_MIN negation, INT8_MIN-কে int32_t-তে sign-extend, আর একটা signed-vs-unsigned char রূপান্তর — তিনটা edge case-ই ফাংশনগুলো দিয়ে verify করুন
  5. একটা compare_as(int8_t a, int8_t b, bool as_unsigned) ফাংশন লিখুন যা একই bit pattern দুইভাবে তুলনা করে — দেখান কখন ফলাফল বদলে যায়
/*
 * repr_inspector.c — signed representation টুলকিট
 * চালান:  gcc -O2 -Wall -Wextra -o repr_inspector repr_inspector.c && ./repr_inspector
 */
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <limits.h>

/* ── ১. তিনটা scheme decode ───────────────────────────────────── */

static int decode_sign_magnitude(uint8_t b) {
    int sign = (b & 0x80) ? -1 : 1;
    return sign * (int)(b & 0x7F);
}

static int decode_ones_complement(uint8_t b) {
    if (b & 0x80) return -(int)((uint8_t)(~b) & 0x7F);
    return (int)b;
}

static int8_t decode_twos_complement(uint8_t b) {
    int8_t v;
    __builtin_memcpy(&v, &b, 1);
    return v;
}

/* ── ২. নিজের হাতে negate — NOT + 1, বিল্ট-ইন - ছাড়া ─────────── */

static int8_t my_negate(int8_t x) {
    uint8_t bits;
    __builtin_memcpy(&bits, &x, 1);
    uint8_t inverted = (uint8_t)(~bits);
    uint8_t result = (uint8_t)(inverted + 1);
    int8_t out;
    __builtin_memcpy(&out, &result, 1);
    return out;
}

static void verify_negate_all(void) {
    int mismatches = 0;
    for (int i = -128; i \<= 127; i++) {
        int8_t x = (int8_t)i;
        int8_t mine = my_negate(x);
        int8_t native = (int8_t)(-x);         /* যত্নসহ — -INT8_MIN নিজেই UB, কিন্তু int8_t->int promotion আটকায় */
        if (mine != native) {
            printf("  MISMATCH: x=%d  mine=%d  native=%d\n", x, mine, native);
            mismatches++;
        }
    }
    printf("  ২৫৬টা মান যাচাই শেষ, mismatch: %d\n", mismatches);
}

/* ── ৩. Manual sign extension ─────────────────────────────────── */

static int64_t sign_extend(int8_t value, int target_bits) {
    uint8_t raw;
    __builtin_memcpy(&raw, &value, 1);
    uint64_t bits = raw;
    uint64_t sign_bit = (bits >> 7) & 1;
    if (sign_bit) {
        /* বাকি সব উপরের bit ১ দিয়ে ভরে দিন */
        uint64_t mask = ~((uint64_t)0xFF);   /* নিচের ৮ bit বাদে সব */
        bits |= mask;
    }
    /* target_bits অনুযায়ী কেটে নিন (৬৪-bit-এর কম হলে) */
    if (target_bits \< 64) {
        uint64_t keep_mask = ((uint64_t)1 << target_bits) - 1;
        bits &= keep_mask;
    }
    return (int64_t)bits;
}

/* ── ৪. Signed বনাম unsigned তুলনা ────────────────────────────── */

static void compare_as(int8_t a, int8_t b) {
    uint8_t ua, ub;
    __builtin_memcpy(&ua, &a, 1);
    __builtin_memcpy(&ub, &b, 1);
    printf("  a=%4d (0x%02X)  b=%4d (0x%02X)  "
           "signed: a%sb   unsigned: a%sb\n",
           a, ua, b, ub,
           (a \< b) ? " \< " : (a > b) ? " > " : "==",
           (ua \< ub) ? " \< " : (ua > ub) ? " > " : "==");
}

int main(void) {
    printf("== তিনটা scheme পাশাপাশি ==\n");
    printf("%6s  %10s  %10s  %10s\n", "hex", "sign-mag", "1's comp", "2's comp");
    uint8_t samples[] = {0x00, 0x7F, 0x80, 0xFF};
    for (size_t i = 0; i \< sizeof(samples); i++) {
        uint8_t b = samples[i];
        printf("0x%02X    %10d  %10d  %10d\n", b,
               decode_sign_magnitude(b), decode_ones_complement(b),
               decode_twos_complement(b));
    }

    printf("\n== negate() যাচাই (২৫৬টা মান, INT8_MIN সহ) ==\n");
    verify_negate_all();

    printf("\n== sign extension, -5 আর 5 কে বিভিন্ন width-এ ==\n");
    for (int w : (int[]){8, 16, 32, 64}) {   /* GCC/Clang statement expr — বা for-loop দিয়ে লিখুন */
        printf("  -5 -> %d bit: %lld\n", w, (long long)sign_extend(-5, w));
    }

    printf("\n== signed বনাম unsigned তুলনা — যেখানে উত্তর বদলায় ==\n");
    compare_as(-1, 1);     /* signed: -1 < 1,  unsigned: 255 > 1 */
    compare_as(-128, 127); /* signed: -128 < 127,  unsigned: 128 > 127 */

    return 0;
}

নোট: উপরের for (int w : (int[]){8,16,32,64}) লাইনটা বৈধ C++ নয় সরাসরি C-তে — নিজের build-এ এটাকে সাধারণ int widths[] = {8,16,32,64}; for (size_t k=0;k\<4;k++) দিয়ে লিখুন, অথবা C23-এর constexpr/GNU extension অনুযায়ী মানিয়ে নিন। ইচ্ছাকৃতভাবে এখানে রাখা হয়েছে — নিজে কম্পাইল করার সময় এই ছোট সমস্যাটা ধরা আর ঠিক করাও অনুশীলনের অংশ।

নিজে বাড়ান:

  1. decode_sign_magnitude-এ -0 (0x80) আর +0 (0x00)-কে আলাদা করে চিহ্নিত করুন, আর দেখান কতগুলো bit pattern আসলে unique মান দেয় প্রতিটা scheme-এ (three_schemes experiment-এর ফলাফলের সাথে মিলিয়ে)।
  2. sign_extend-এর reverse লিখুন — একটা বড় signed মান একটা ছোট টাইপে truncate (narrow) করুন, আর দেখান কখন এটা মান বদলে দেয় (information loss)।
  3. একটা add_with_flags(int8_t a, int8_t b, bool *carry, bool *overflow) লিখুন যা carry flag (unsigned দৃষ্টিতে) আর overflow flag (signed দৃষ্টিতে) দুটোই আলাদাভাবে গণনা করে — লেসন ৪-এর carry/overflow আলোচনার সরাসরি সম্প্রসারণ, আর পরের লেসনের ভূমিকা।

বাস্তব সিস্টেমে

Two’s complement যেখানে যেখানে দেখা যায় — আর কোথায় লড়াই হয়েছিল

C++20 standard-এর বাধ্যতামূলক two’s complement। ২০২০-এর আগে C++ standard আনুষ্ঠানিকভাবে তিনটা signed representation-ই (sign-magnitude, one’s complement, two’s complement) implementation-defined হিসেবে অনুমোদন করত — যদিও বাস্তবে গত কয়েক দশকে সব CPU two’s complement ব্যবহার করেছে। C++20 এই ঐতিহাসিক অস্পষ্টতা মুছে দিয়ে two’s complement-কে একমাত্র বৈধ representation হিসেবে ঘোষণা করে — একটা বাস্তবতাকে অবশেষে কাগজে-কলমেও স্বীকৃতি দেওয়া।

CDC 6600 আর UNIVAC 1100 — one’s complement-এর ঐতিহাসিক ঘাঁটি। Seymour Cray-র ডিজাইন করা CDC 6600 (১৯৬৪, তৎকালীন বিশ্বের দ্রুততম কম্পিউটার) আর UNIVAC 1100 series one’s complement arithmetic ব্যবহার করত। এই মেশিনগুলোতেই প্রোগ্রামাররা প্রথম “দুইটা শূন্য”-এর ব্যবহারিক ঝামেলা face করেছিলেন।

IBM 7090-এর মতো early mainframe-এ sign-magnitude। সবচেয়ে সরল, সবচেয়ে “মানুষের মতো” scheme হিসেবে প্রথম দিকের কিছু মেশিনে ব্যবহৃত হয়েছিল, কিন্তু hardware complexity-র কারণে টেকেনি — addition আর subtraction-এর জন্য আলাদা logic লাগত, sign bit special-case করে।

Java-র byte টাইপ, আর & 0xFF idiom। Java-তে কোনো unsigned integer টাইপ নেই (লেসন ৪-এ দেখেছেন), তাই byte সবসময় signed (-128127)। কেউ যদি “raw byte value” (0255) হিসেবে ব্যবহার করতে চান, sign-extension-এর কারণে সরাসরি int b = someByte; ভুল ফল দেয় (ঋণাত্মক হয়ে যায় যদি MSB সেট থাকে) — তাই সর্বত্র int unsigned_value = someByte & 0xFF; idiom, যা sign-extend হওয়া উপরের bit গুলোকে মাস্ক করে ফেলে দেয়।

Audio PCM samples — signed two’s complement standard। 16-bit PCM WAV/audio format-এ প্রতিটা sample একটা signed int16_t (-3276832767), waveform-এর শূন্য বিন্দুর চারপাশে প্রতিসম দোলন বোঝাতে। যদি কোনো codec ভুলবশত sign-extend-এর বদলে zero-extend করে ফেলে (raw byte read করার সময়), শোনা যায় একটা কর্কশ “pop”/“click” শব্দ — কারণ sample মান আচমকা ভুল দিকে লাফ দেয়।

Protocol Buffers-এর zigzag encoding — sign representation-এর আধুনিক প্রতিধ্বনি। Protobuf-এর sint32/sint64 টাইপ ছোট ঋণাত্মক সংখ্যাকে কার্যকরভাবে এনকোড করতে zigzag mapping ব্যবহার করে (0 → 0, -1 → 1, 1 → 2, -2 → 3, ...) — সরাসরি two’s complement পাঠালে ছোট ঋণাত্মক সংখ্যাও উপরের সব bit 1 হয়ে varint encoding-কে অপ্রয়োজনীয়ভাবে বড় করে দিত। এটা “কোন সংখ্যা কীভাবে এনকোড করব” প্রশ্নের একটা তৃতীয়, ব্যবহারিক সমাধান — sign representation নিয়ে নকশার প্রশ্ন আজও চলমান।

x86 SF (Sign Flag)। CPU-র flag register-এর SF bit সরাসরি ফলাফলের MSB-এর কপি — কারণ two’s complement-এ MSB-ই সরাসরি sign নির্দেশ করে (ওজন ঋণাত্মক হওয়ায়)। sign-magnitude-এও MSB sign বোঝায়, কিন্তু two’s complement-এ MSB arithmetic value-এর সরাসরি অংশও বটে, শুধু চিহ্নের flag না — এটাই একটা মূল কারণ কেন comparison সহজ হয়ে যায়।

যে ভুলগুলো সবাই করে

“নেগেটিভ সংখ্যা বানাতে শুধু sign bit উল্টে দিলেই হয় — MSB flip করলেই negation।”

এটা sign-magnitude-এর নিয়ম, কিন্তু আধুনিক CPU-গুলো two’s complement ব্যবহার করে, যেখানে negation মানে পুরো pattern উল্টে দিয়ে 1 যোগ করা (~x + 1), শুধু MSB নয়।

+5 = 0000 0101। শুধু MSB flip করলে পাওয়া যাবে 1000 0101 — যেটা two’s complement-এ পড়া হয় -123 (ভুল!), -5 না। সঠিক -5 = 1111 1011

এই ভুল ধারণাটা সাধারণত sign-magnitude scheme থেকে আসে (স্কুলের সাধারণ “মাইনাস চিহ্ন” ধারণার সাথে মিলে যায় বলে স্বজ্ঞাত মনে হয়), কিন্তু বাস্তব hardware এই scheme ব্যবহার করে না — উপরে বিস্তারিত কারণ দেখেছেন।

“-x মানে সবসময় ~x + 1, তাই এই operation সবসময় নিরাপদ, কখনো ব্যর্থ হয় না।”

INT_MIN-এর ক্ষেত্রে ব্যর্থ হয়। int8_t-এ -128-এর negation গাণিতিকভাবে +128 হওয়ার কথা, কিন্তু +128 representable না (int8_t-এর range -128127)। ফলাফল আবার mod 256 reduce হয়ে -128-ই থেকে যায়।

C standard-এ -INT_MIN (বা abs(INT_MIN)) undefined behavior — এই edge case-টাই কারণ। বাস্তব bug: কেউ যদি ধরে নেয় abs(x) \geq 0 সবসময় সত্য, আর সেই ধারণার উপর ভিত্তি করে array indexing বা bound-check লেখে, x = INT_MIN-এ সেই assumption ভেঙে পড়ে (abs(INT_MIN) বাস্তবে প্রায়ই INT_MIN-ই ফেরত দেয়, যা ঋণাত্মক!)। পরের লেসনে এই ধরনের overflow-নির্ভর bug বিস্তারিত দেখব।

“টাইপ বড় করার (widening) সময় নতুন bit গুলো সবসময় শূন্য দিয়ে ভরাই স্বাভাবিক নিয়ম (zero-extend)।”

শুধু unsigned টাইপের জন্য সত্য। Signed টাইপে সঠিক নিয়ম হলো sign-extend — MSB (sign bit) repeat করা, শূন্য নয়।

এই লেসনে দেখানো int8_t -5 উদাহরণটাই সরাসরি প্রমাণ: zero-extend করলে -5 হয়ে যায় +251 — সম্পূর্ণ ভুল মান। C-র সাধারণ implicit conversion এই ভুল কখনো করে না (compiler নিজেই টাইপ জেনে সঠিক extension বেছে নেয়), কিন্তু manual bit manipulation, network/file byte-parsing, বা memcpy/reinterpret_cast-নির্ভর কোডে এই ভুল বাস্তবে ঘটে।

আরও সূক্ষ্ম রূপ: char-এর signedness platform-নির্ভর হওয়ায় getchar()-এর ফলাফল ভুল টাইপে রাখলে সেই sign-extension-ই 0xFF byte-কে EOF (-1)-এর সাথে গুলিয়ে ফেলে।

“+128 আর -127 প্রায় সমান, তাই int8_t-এর range -127 থেকে +128 (বা -128 থেকে +128) হতে পারত, এটা কেবল একটা arbitrary সিদ্ধান্ত।”

এটা arbitrary না — একটা গোনার (counting) বাধ্যবাধকতা। n-bit-এ মোট 2ⁿ টা bit pattern আছে, একটা মাত্র শূন্যের জন্য বরাদ্দ থাকলে বাকি 2ⁿ − 1 টা (একটা বিজোড় সংখ্যা) ধনাত্মক-ঋণাত্মকে সমানভাবে ভাগ করা গাণিতিকভাবে অসম্ভব

-128 থেকে +128 (দুইদিকেই ১২৮টা, প্লাস শূন্য) রাখতে হলে মোট 257 টা মান লাগত — কিন্তু 8 bit দেয় ঠিক 256 টা pattern, একটা কম। তাই হয় একদিক অন্যদিকের চেয়ে এক বেশি পাবে (two’s complement-এর সমাধান — ঋণাত্মক দিক এক বেশি পায়), নয়তো একটা মান দুইবার represent হবে (sign-magnitude/one’s complement-এর সমাধান — শূন্য দুইবার, বিনিময়ে প্রতিসম রেঞ্জ)। “সুন্দর প্রতিসাম্য” বলে কোনো তৃতীয় বিনামূল্যের বিকল্প নেই।

বুঝেছেন কি না দেখুন

1

4-bit pattern 1010-এর মান বের করুন তিনটা scheme-এই (sign-magnitude, one’s complement, two’s complement), হিসাব দেখিয়ে।

প্রয়োগ

1010-এর MSB 1, তাই তিনটাতেই এটা ঋণাত্মক (বা “উচ্চ অর্ধেক”)।

Sign-magnitude: sign bit 1 (ঋণাত্মক), বাকি 010₂ = 2। মান = -2

One’s complement: MSB 1, তাই বিটওয়াইজ NOT নিন (উপরের sign bit বাদ দিয়ে বাকি bit-এ): 010 → 101₂ = 5। মান = -5

(পূর্ণ pattern-এর NOT: 1010 → 0101 = 5, একই উত্তর — কারণ one’s complement-এ পুরো pattern-এর NOT-ই magnitude দেয়।)

Two’s complement: ওজনযুক্ত সূত্র — 1×23+0×22+1×21+0×20=8+0+2+0=6-1 \times 2^3 + 0 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = -8 + 0 + 2 + 0 = -6। মান = -6

সারাংশ:

Schemeমান
Sign-magnitude-2
One’s complement-5
Two’s complement-6

তিনটা সম্পূর্ণ ভিন্ন উত্তর, একই bit pattern থেকে — এটাই এই লেসনের মূল বার্তা: bit pattern নিজে কিছু বলে না, interpretation বলে। লেসনের ৪-bit টেবিলের সাথে মিলিয়ে দেখুন (1010 সারি) — হুবহু মিলবে।

2

কেন two’s complement-এর range অসামঞ্জস্যপূর্ণ (-2^(n-1) থেকে 2^(n-1)-1), অথচ sign-magnitude/one’s complement প্রতিসম (-(2^(n-1)-1) থেকে 2^(n-1)-1)? একটা গোনার যুক্তি দিয়ে প্রমাণ করুন।

যুক্তি

n-bit-এ মোট 2ⁿ টা bit pattern আছে — এটা একটা fixed, অটল সংখ্যা, কোনো scheme এটা বদলাতে পারে না।

Sign-magnitude/one’s complement-এর সিদ্ধান্ত: শূন্যকে দুইটা pattern দিয়ে represent করা (+0 আর -0)। বাকি 2ⁿ - 2 টা pattern তখন জোড় সংখ্যা, যা ঠিক দুই ভাগে ভাগ করা যায় — (2ⁿ-2)/2 = 2^(n-1) - 1 টা করে ধনাত্মক আর ঋণাত্মক। ফল: প্রতিসম range, কিন্তু একটা pattern (2ⁿ -এর একটা) “নষ্ট” (দুইটা শূন্যের একটা বাড়তি)।

Two’s complement-এর সিদ্ধান্ত: শূন্যকে একটা মাত্র pattern দিয়ে represent করা। বাকি 2ⁿ - 1 টা pattern — এটা বিজোড় সংখ্যা (জোড় থেকে ১ বিয়োগ), তাই সমান দুই ভাগে ভাগ করা অসম্ভব। একদিক অন্যদিকের চেয়ে ঠিক এক বেশি পেতে বাধ্য। Two’s complement বেছে নেয়: ঋণাত্মক দিক এক বেশি পাবে (2^(n-1) টা ঋণাত্মক মান, 2^(n-1) - 1 টা ধনাত্মক মান)।

কেন ঋণাত্মক দিক, ধনাত্মক দিক নয় — এটাও accident না। MSB-এর ওজন -2^(n-1)। শুধু MSB সেট (1000...0) হলে বাকি সব bit 0 থেকে অবদান 0, তাই মান ঠিক -2^(n-1) — এটা একটা মাত্র bit pattern দিয়ে সবচেয়ে চরম (extreme) মান পাওয়া সম্ভব করে। ধনাত্মক দিকে সর্বোচ্চ পেতে হলে MSB 0 রেখে বাকি সব bit 1 করতে হয় (0111...1), যা দেয় 2^(n-1) - 1 — MSB-কে ব্যবহার না করে যতটা সম্ভব তার কাছাকাছি।

সংক্ষেপে — trade-off টেবিল:

Sign-magnitude / One’s complementTwo’s complement
শূন্যের সংখ্যা
Rangeপ্রতিসমঅসামঞ্জস্য
ব্যবহারযোগ্য pattern2ⁿ - 12ⁿ (সবগুলো)

কোনো “ভালো” বা “খারাপ” সিদ্ধান্ত না — একটা গাণিতিক অনিবার্যতার দুইটা ভিন্ন সমাধান। Two’s complement জিতেছে কারণ hardware simplicity-র লাভ range-এর অসামঞ্জস্যের চেয়ে অনেক বড় ছিল।

3

-5-কে int8_t থেকে int32_t-তে sign-extend করুন, hex-এ দেখান। তারপর দেখান ভুলভাবে zero-extend করলে কী মান পাওয়া যেত।

প্রয়োগ

-5 in int8_t:

5=8+2+1=1×23+0×22+1×21+1×20-5 = -8 + 2 + 1 = -1 \times 2^3 + 0 \times 2^2 + 1 \times 2^1 + 1 \times 2^0

Bit pattern: 1111 1011 (hex: 0xFB)।

সঠিক sign extension — sign bit (1) repeat করে ২৪টা নতুন bit ভরুন:

1111 1111 1111 1111 1111 1111 1111 1011

Hex: 0xFFFFFFFB

যাচাই — ওজনযুক্ত সূত্র প্রয়োগ করে (এবার n=32): 1×231+(23123)+(21+20)-1 \times 2^{31} + (2^{31} - 2^3) + (2^1 + 2^0) — মাঝের সব 1 bit-এর যোগফল simplify করলে দাঁড়ায় ঠিক -5 (বিস্তারিত বীজগণিত এই লেসনের “hood” অংশে সাধারণ n → m প্রমাণেই দেখানো হয়েছে, n=8, m=32 বসিয়ে দিলেই এই case)।

ভুল zero-extend — নতুন bit গুলো 0 দিয়ে ভরলে:

0000 0000 0000 0000 0000 0000 1111 1011

Hex: 0x000000FB। Unsigned পাঠে এটা 251; signed int32_t হিসেবে পড়লেও 251 (কারণ MSB 0, ধনাত্মক)। মূল মান -5 থেকে সম্পূর্ণ ভিন্ন — +251

সংক্ষেপে:

পদ্ধতিhexমান
সঠিক (sign-extend)0xFFFFFFFB-5
ভুল (zero-extend)0x000000FB+251

C-র normal implicit conversion (int8_t ভেরিয়েবল সরাসরি int32_t-তে assign করলে) সবসময় সঠিকটা করে — ভুলটা তখনই ঘটে যখন কেউ raw byte manual ভাবে জোড়া লাগায় বিনা type-awareness-এ, যেমনটা এই লেসনের experiment-এ দেখিয়েছি।

4

আপনি একটা নতুন, ৬-bit toy CPU ISA ডিজাইন করছেন। Signed integer representation হিসেবে two’s complement বেছে নিলে — (ক) range কত হবে, (খ) INT_MIN-এর negation-এ কী হবে, (গ) hardware-এ কী সুবিধা পাবেন যা sign-magnitude বেছে নিলে পেতেন না?

ডিজাইন

ক — Range। n = 6, তাই range -2^5 থেকে 2^5 - 1, অর্থাৎ -32 থেকে 31। মোট 2^6 = 64 টা pattern, সবগুলোই কাজে লাগবে — কোনো নষ্ট নেই।

খ — INT_MIN negation। INT_MIN = -32 (bit pattern 10 0000)। Negate করতে ~x + 1:

   x = 10 0000
  ~x = 01 1111
~x+1 = 10 0000    ← আবার -32!

গাণিতিক প্রত্যাশা +32, কিন্তু range মাত্র -3231, +32 representable না। Overflow — ফলাফল আবার -32। এই ISA-র programmer-দের এই edge case documented রাখতে হবে (ঠিক যেমন বাস্তব INT8_MIN/INT32_MIN-এ হয়)।

গ — Hardware সুবিধা। একটাই adder circuit দিয়ে:

  • Unsigned addition (ℤ₆₄-এ mod arithmetic)
  • Signed addition (একই bit-level অপারেশন, শুধু ফলাফল পড়ার নিয়ম আলাদা)
  • Subtraction (a - b = a + (~b + 1), শুধু একটা XOR-array আর carry-in নিয়ন্ত্রণ যোগ করে, আলাদা subtractor circuit ছাড়াই)

Sign-magnitude বেছে নিলে প্রতিটার জন্য আলাদা বা অন্তত অতিরিক্ত জটিল logic লাগত — sign bit special-case করে addition/subtraction নির্ণয় করতে হতো (দুই operand-এর sign মিলে কি না তার উপর নির্ভর করে যোগ নাকি বিয়োগ করা হবে তা ঠিক করতে হতো), আর +0/-0 আলাদা করে সামলাতে হতো প্রতিটা comparison/equality circuit-এ।

অতিরিক্ত ডিজাইন সিদ্ধান্ত যা মাথায় রাখা উচিত: compiler/assembler-এ INT_MIN-এর জন্য বিশেষ overflow-check বা trap যোগ করা, ঠিক যেমন আধুনিক ভাষাগুলো (Rust debug build panic, ইত্যাদি) করে — পরের লেসনের বিষয়।

5

8-bit pattern 1011 (unsigned 11, signed -5)-এ 0011 (3) যোগ করলে ফলাফল bit pattern 1110 পাওয়া যায় — যা unsigned পাঠে 14 আর signed পাঠে -2। কেন এই একই bit-level addition দুটোই “সঠিক” উত্তর দেয়, একই সাথে?

যুক্তি

কারণ addition circuit শুধু bit pattern-এর উপর কাজ করে — ℤ₁₆-এর মধ্যে (4-bit, n=4, 2⁴=16) modular addition করে, কোনো “এটা signed নাকি unsigned” তথ্য জানে না বা জানার দরকারও নেই।

Unsigned equivalence class হিসেবে: 1011 = [11], 0011 = [3][11] + [3] = [14] (কারণ 11+3=14 \< 16, কোনো mod reduction লাগেনি এখানে)। [14]-এর প্রতিনিধি (unsigned convention-এ) 14

Two’s complement equivalence class হিসেবে: 1011 = [-5], 0011 = [3] (একই class [11], শুধু ভিন্ন প্রতিনিধি বাছা হয়েছে)। [-5] + [3] = [-2] (সাধারণ integer addition, -5+3=-2)। [-2]-এর প্রতিনিধি (two’s complement convention-এ) -2, আর bit pattern হিসেবে 1110 (-2 = -8+4+2+0)।

দুইটাই একই equivalence class [14] = [-2] (mod 16)-এর কথা বলছে — শুধু প্রতিনিধি ভিন্ন। 14 আর -2 একই class-এর সদস্য কারণ 14 - (-2) = 16 ≡ 0 (mod 16)

এটাই দুই’s complement-এর গভীরতম insight, যেটা এই পুরো লেসন জুড়ে বারবার এসেছে: bit pattern একটা equivalence class-কে নির্দেশ করে, আর “signed” বনাম “unsigned” শুধু সেই class থেকে কোন প্রতিনিধি বেছে নেওয়া হবে তার নিয়ম। Arithmetic (addition, subtraction) class-level অপারেশন — তাই পাঠ (reading) যাই হোক, arithmetic circuit-টা একই থাকতে পারে। এই কারণেই — অবশেষে — একটা মাত্র ADD instruction দিয়ে CPU সব ধরনের integer arithmetic চালাতে পারে।

এরপর কী

আমরা দেখেছি INT8_MIN-এর negation “ব্যর্থ” হয় — গাণিতিক সঠিক উত্তর representable না বলে ফলাফল ভুল হয়ে যায়। এটা একটা বিশেষ case, কিন্তু এই সমস্যাটা আসলে অনেক বড় একটা প্রশ্নের ছোট্ট একটা উদাহরণ মাত্র: কখন কোনো arithmetic operation-এর সত্যিকারের ফলাফল representation-এর মধ্যে আঁটে না, আর তখন ঠিক কী ঘটে?

পরের লেসনে আমরা এই প্রশ্নটাকে সরাসরি মোকাবিলা করব — integer overflow। সবচেয়ে গুরুত্বপূর্ণ চমক যেটা অপেক্ষা করছে: unsigned overflow-কে আমরা এই মডিউলে বারবার “নিরাপদ, সংজ্ঞায়িত” বলেছি — কিন্তু signed overflow C/C++-এ undefined behavior, একটা সম্পূর্ণ ভিন্ন বিভীষিকা, যেখানে compiler নিজেই আপনার নিরাপত্তা চেক মুছে দিতে পারে। আমরা একটা বাস্তব compiler চালিয়ে এটা নিজের চোখে দেখব, আর Ariane 5 রকেট বিধ্বস্ত হওয়া থেকে শুরু করে blockchain-এ কোটি টাকার token চুরি হওয়া পর্যন্ত — এই একটা concept-এর বাস্তব মূল্য কতটা ভয়াবহ হতে পারে সেটা দেখব।

আরও পড়ুন

  • Computer Systems: A Programmer's Perspective, §2.2 — Integer Representations — Randal E. Bryant, David R. O'Hallaron · Two's complement-এর ওজন-ভিত্তিক ($-d_{n-1}2^{n-1} + \dots$) সংজ্ঞা এবং প্রমাণের মূল উৎস
  • ISO/IEC 14882:2020 (C++20), §6.8.1 — Fundamental Types — ISO/IEC JTC1/SC22/WG21 · C++20 থেকেই two's complement একমাত্র বৈধ signed representation হিসেবে বাধ্যতামূলক
  • The Art of Computer Programming, Vol. 2 — Positional Number Systems — Donald Knuth · sign-magnitude, one's ও two's complement-এর ঐতিহাসিক তুলনা