Signed Integer ও Two's Complement — ঋণাত্মক সংখ্যার তিনটা ইতিহাস
Signed Integers and Two's Complement
একই n bit দিয়ে ঋণাত্মক সংখ্যা লেখার তিনটা ঐতিহাসিক প্রতিদ্বন্দ্বী স্কিম ছিল — two's complement জিতেছে কারণ এটা আসলে ℤ_(2ⁿ)-এর additive inverse নেওয়া মাত্র, আর সেই একটা সিদ্ধান্তেই hardware সরল হয়ে যায়।
আগে এটা বুঝি
একটা প্রশ্ন দিয়ে শুরু করি যেটা প্রায় সবাইকে অবাক করে।
#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।
স্কিম ২ — 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 pattern | Unsigned | Sign-magnitude | One’s complement | Two’s complement |
|---|---|---|---|---|
0000 | 0 | +0 | +0 | 0 |
0001 | 1 | +1 | +1 | 1 |
0010 | 2 | +2 | +2 | 2 |
0011 | 3 | +3 | +3 | 3 |
0100 | 4 | +4 | +4 | 4 |
0101 | 5 | +5 | +5 | 5 |
0110 | 6 | +6 | +6 | 6 |
0111 | 7 | +7 | +7 | 7 |
1000 | 8 | −0 | −7 | −8 |
1001 | 9 | −1 | −6 | −7 |
1010 | 10 | −2 | −5 | −6 |
1011 | 11 | −3 | −4 | −5 |
1100 | 12 | −4 | −3 | −4 |
1101 | 13 | −5 | −2 | −3 |
1110 | 14 | −6 | −1 | −2 |
1111 | 15 | −7 | −0 | −1 |
এই টেবিলটা কয়েক মিনিট ধরে দেখুন — পুরো লেসনের প্রায় সবকিছু এখান থেকেই বেরোবে।
Two’s complement-এর আসল সংজ্ঞা — ওজনযুক্ত যোগফল
উপরের “উল্টাও, তারপর ১ যোগ করো” পদ্ধতিটা একটা নির্মাণ-কৌশল
(negative বানানোর রেসিপি), সংজ্ঞা নয়। প্রকৃত, প্রত্যক্ষ সংজ্ঞা
হলো: n-bit pattern dₙ₋₁ … d₁ d₀-এর two’s complement মান —
লক্ষ্য করুন — এটা unsigned-এর ঠিক সেই একই positional-notation
সূত্র, শুধু সবচেয়ে গুরুত্বপূর্ণ bit-এর ওজন ঋণাত্মক। বাকি
সব bit-এর ওজন আগের মতোই ধনাত্মক 2ⁱ।
4-bit উদাহরণে যাচাই করি, 1011 নিয়ে:
টেবিলের সাথে মিলছে ✓। এই “MSB-এর ওজন ঋণাত্মক” সংজ্ঞাটাই সবচেয়ে পরিষ্কার, আর এখান থেকেই বাকি সব property সরাসরি প্রমাণ করা যায় — যেটা আমরা পরের অংশে করব।
Range — কেন অসামঞ্জস্য
উপরের সূত্র থেকে সরাসরি বের করা যায় n-bit two’s complement-এর
range:
- সর্বনিম্ন: শুধু MSB
1, বাকি সব0—1000(৪-bit-এ) →-2^(n-1) - সর্বোচ্চ: MSB
0, বাকি সব1—0111→2^(n-1) - 1
8-bit-এ: -128 থেকে 127। 32-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-এর মান:
প্রথম যোগফল \sum 2^i (i=0 থেকে n-1) একটা geometric
series, যার যোগফল ঠিক 2ⁿ − 1 (Level 0-এর কম্বিনেটরিক্স
লেসনের সূত্র)। দ্বিতীয় যোগফল সংজ্ঞা অনুযায়ী x নিজেই (unsigned
interpretation-এ)।
দাবি: two’s complement negation = ~x + 1 = 2ⁿ − x
উপরের ফলাফলে 1 যোগ করুন:
— এটাই এই লেসনের কেন্দ্রীয় সমীকরণ।
এটাই ℤ_(2ⁿ)-এর additive inverse
2ⁿ − x চেনা লাগছে? এটা ঠিক Level 0-এর number-systems লেসনে
শেখা concept — x-এর additive inverse mod 2ⁿ। কারণ:
অর্থাৎ two’s complement negation আক্ষরিকভাবে সেই একই
ℤ_(2ⁿ)-এ x-এর সাথে যোগ করলে 0 দেয় এমন উপাদান খোঁজা —
ঠিক যেমন সাধারণ পাটিগণিতে -x-এর সংজ্ঞা x + (-x) = 0। কোনো
নতুন গণিত লাগেনি — শুধু “0 থেকে 2ⁿ−1” জগতে “additive
inverse”-এর অর্থ কী, সেটা প্রয়োগ করা হয়েছে।
যাচাই — 4-bit-এ x = 5 (0101):
আর 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-এর মতোই।
- 4-bit pattern 1011কাঁচা bits — এখনো কোনো অর্থ নেই
- ℤ₁₆-এর equivalence class [11]{ …, -5, 11, 27, … } — একই class-এর সব প্রতিনিধি
- unsigned প্রতিনিধি বাছাইসবসময় [0, 15]-এর মধ্যেরটা → 11
- two's complement প্রতিনিধি বাছাইউপরের অর্ধেকের জন্য ঋণাত্মক প্রতিনিধি → -5
- CPU-র ADD/SUB circuitঠিক একই circuit — শুধু ফলাফল পড়ার নিয়ম আলাদা
কেন একটা মাত্র adder circuit যথেষ্ট
x = 4-bit-এর উদাহরণেই দেখুন — bit pattern 1011-কে unsigned
পড়লে 11, signed পড়লে -5। এবার এই pattern-এ 0011 (3)
যোগ করুন:
1011
+ 0011
-------
1110Unsigned পাঠ: 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 1011। int32_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ₙ₋₁-এর কপি হয়, তাহলে
নতুন মান:
মাঝের যোগফলটা geometric series: d_{n-1} (2^{m-1} - 2^{n-1})।
পুরোটা একসাথে সাজালে (বীজগণিত একটু ঘন, কিন্তু ফলাফল পরিষ্কার):
মূল অন্তর্দৃষ্টি: 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-তে রাখা হচ্ছে। যদিcharsigned হয় এই platform-এ,255-এর bit pattern (1111 1111)char-এ রাখলে সেটা signed interpretation পায় —-1! whilecondition-এcআবারint-এ sign-extend হয়ে ফেরত promote হয় (কারণ signed char → int promotion sign-extend করে):-1(char) থেকে-1(int)।- আর
EOF-ও-1। তুলনাc != EOFমিথ্যা — loop ভুলভাবে থেমে যায়, যদিও প্রকৃত ফাইলের শেষ আসেনি! একটা সাধারণ0xFFbyte পুরো 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 1010Bitwise 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 - 42 | 214 (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: 0x000000FBC-তে সাধারণ 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
মাত্র -128–127 — +128 representable না। ফলাফল আবার
mod 256 reduce হয়ে -128-এ ফিরে আসে। এটাই signed integer
overflow — যার সম্পূর্ণ আলোচনা পরের লেসনে।
নিজে চালিয়ে দেখুন
তিনটা scheme নিজে বানিয়ে যাচাই করুন — আর int8_t-এর সাথে মিলিয়ে দেখুন
// 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 আলাদাভাবে হাতে বানালে তাদের 'দুইটা শূন্য' সমস্যা চোখে ধরা পড়ে।
Sign extension bug — নিজের চোখে ভাঙা কোড দেখুন
ধাপ ১ — সঠিক বনাম ভুল 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_extendsmall = -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_bugsigned 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 তৈরি করে।
নিজে বানান
Bit-Level Representation Inspector
- একটা raw 8-bit মান নিয়ে sign-magnitude, one's complement, two's complement — তিনটা scheme-এই decode করে পাশাপাশি ছাপান
- নিজের হাতে negate() ফাংশন লিখুন যেটা কোনো int8_t-এর জন্য NOT+1 করে, আর native unary minus (-x)-এর সাথে সব 256টা মান মিলিয়ে verify করুন
- sign_extend(int8_t, int width) ফাংশন লিখুন যা 8, 16, 32 বা 64-bit-এ সঠিকভাবে widen করে, manual bit-shifting দিয়ে (built-in cast ছাড়া)
- INT8_MIN negation, INT8_MIN-কে int32_t-তে sign-extend, আর একটা signed-vs-unsigned char রূপান্তর — তিনটা edge case-ই ফাংশনগুলো দিয়ে verify করুন
- একটা 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 অনুযায়ী মানিয়ে নিন। ইচ্ছাকৃতভাবে এখানে রাখা হয়েছে — নিজে কম্পাইল করার সময় এই ছোট সমস্যাটা ধরা আর ঠিক করাও অনুশীলনের অংশ।
নিজে বাড়ান:
decode_sign_magnitude-এ-0(0x80) আর+0(0x00)-কে আলাদা করে চিহ্নিত করুন, আর দেখান কতগুলো bit pattern আসলেuniqueমান দেয় প্রতিটা scheme-এ (three_schemes experiment-এর ফলাফলের সাথে মিলিয়ে)।sign_extend-এর reverse লিখুন — একটা বড় signed মান একটা ছোট টাইপে truncate (narrow) করুন, আর দেখান কখন এটা মান বদলে দেয় (information loss)।- একটা
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
(-128–127)। কেউ যদি “raw byte value” (0–255) হিসেবে
ব্যবহার করতে চান, 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 (-32768–32767), 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 -128–127)। ফলাফল আবার 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-এর
সমাধান — শূন্য দুইবার, বিনিময়ে প্রতিসম রেঞ্জ)। “সুন্দর প্রতিসাম্য”
বলে কোনো তৃতীয় বিনামূল্যের বিকল্প নেই।
বুঝেছেন কি না দেখুন
14-bit pattern 1010-এর মান বের করুন তিনটা scheme-এই
(sign-magnitude, one’s complement, two’s complement), হিসাব
দেখিয়ে।
প্রয়োগ
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: ওজনযুক্ত সূত্র —
।
মান = -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)? একটা গোনার যুক্তি দিয়ে
প্রমাণ করুন।
যুক্তি
-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 complement | Two’s complement | |
|---|---|---|
| শূন্যের সংখ্যা | ২ | ১ |
| Range | প্রতিসম | অসামঞ্জস্য |
| ব্যবহারযোগ্য pattern | 2ⁿ - 1 | 2ⁿ (সবগুলো) |
কোনো “ভালো” বা “খারাপ” সিদ্ধান্ত না — একটা গাণিতিক অনিবার্যতার দুইটা ভিন্ন সমাধান। Two’s complement জিতেছে কারণ hardware simplicity-র লাভ range-এর অসামঞ্জস্যের চেয়ে অনেক বড় ছিল।
3-5-কে int8_t থেকে int32_t-তে sign-extend করুন, hex-এ
দেখান। তারপর দেখান ভুলভাবে zero-extend করলে কী মান পাওয়া যেত।
প্রয়োগ
-5-কে int8_t থেকে int32_t-তে sign-extend করুন, hex-এ
দেখান। তারপর দেখান ভুলভাবে zero-extend করলে কী মান পাওয়া যেত।-5 in int8_t:
Bit pattern: 1111 1011 (hex: 0xFB)।
সঠিক sign extension — sign bit (1) repeat করে ২৪টা নতুন
bit ভরুন:
1111 1111 1111 1111 1111 1111 1111 1011Hex: 0xFFFFFFFB।
যাচাই — ওজনযুক্ত সূত্র প্রয়োগ করে (এবার n=32):
— মাঝের সব
1 bit-এর যোগফল simplify করলে দাঁড়ায় ঠিক -5 (বিস্তারিত
বীজগণিত এই লেসনের “hood” অংশে সাধারণ n → m প্রমাণেই দেখানো
হয়েছে, n=8, m=32 বসিয়ে দিলেই এই case)।
ভুল zero-extend — নতুন bit গুলো 0 দিয়ে ভরলে:
0000 0000 0000 0000 0000 0000 1111 1011Hex: 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 বেছে নিলে পেতেন না?
ডিজাইন
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 মাত্র -32–31, +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, ইত্যাদি) করে —
পরের লেসনের বিষয়।
58-bit pattern 1011 (unsigned 11, signed -5)-এ 0011
(3) যোগ করলে ফলাফল bit pattern 1110 পাওয়া যায় — যা
unsigned পাঠে 14 আর signed পাঠে -2। কেন এই একই bit-level
addition দুটোই “সঠিক” উত্তর দেয়, একই সাথে?
যুক্তি
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-এর ঐতিহাসিক তুলনা