Signed numbers in binary are a digital representation system that allocates specific bits to indicate both the magnitude and the polarity (positive or negative) of an integer, almost universally using the Two's Complement method in modern microcontrollers. When you wire a bidirectional current sensor like the Texas Instruments INA219 to an ESP32, or read sub-zero temperatures from a DS18B20, the raw I2C or SPI bytes returned do not have a minus sign attached. They are just 8, 16, or 32 bits of high/low voltage. How your microcontroller interprets those raw bits—whether as an unsigned count from 0 to 65,535 or a signed range from -32,768 to 32,767—dictates whether your code correctly reads -10.5°C or hallucinates a +655.2°C reading that triggers a thermal shutdown fault.
Understanding this mechanism is not just a computer science exercise; it changes how you write C++ firmware, how you size your variable types, and how you prevent catastrophic hardware failures when casting data between registers and PWM outputs.
The Core Mechanism: Two's Complement vs. Sign-Magnitude
Beginners often assume microcontrollers use Sign-Magnitude representation, where the Most Significant Bit (MSB) acts purely as a plus/minus flag (e.g., 1000 0001 means -1). While conceptually simple, Sign-Magnitude is practically useless in digital logic because it creates two distinct zeros (+0 and -0) and breaks standard binary addition circuits. If you add +1 and -1 in Sign-Magnitude, the ALU (Arithmetic Logic Unit) does not naturally yield zero.
Instead, every modern microcontroller—from an 8-bit ATmega328P to a 32-bit ESP32 or ARM Cortex-M4—uses Two's Complement. The best physical analogy for Two's Complement is a mechanical car odometer rolling backwards: when it crosses from 000000 down to 999999, that maximum rollover value represents -1. In binary, you find the Two's Complement of a negative number by inverting all the bits (One's Complement) and adding 1. This elegantly allows the ALU to use the exact same addition hardware for both positive and negative numbers.
| Binary (8-bit) | Hex | Unsigned (uint8_t) |
Signed (int8_t) |
Hardware State |
|---|---|---|---|---|
0000 0000 |
0x00 |
0 | 0 | All pins LOW |
0111 1111 |
0x7F |
127 | 127 | MSB LOW (Max Positive) |
1000 0000 |
0x80 |
128 | -128 | MSB HIGH (Min Negative) |
1000 0001 |
0x81 |
129 | -127 | Sign bit + LSB HIGH |
1111 1110 |
0xFE |
254 | -2 | Only bit 0 LOW |
1111 1111 |
0xFF |
255 | -1 | All pins HIGH |
Notice the asymmetry in the 8-bit signed range: it spans from -128 to +127. The 1000 0000 state represents -128, and because of the Two's Complement inversion rule, there is no positive +128 equivalent in an 8-bit signed integer. This asymmetry is a frequent source of overflow bugs in embedded C++.
Worked Numeric Example: Decoding a 16-Bit Sensor Payload
Let us look at a real-world scenario. You are reading a 16-bit signed temperature sensor (like the TI TMP117) over I2C. The sensor returns two bytes: a Most Significant Byte (MSB) and a Least Significant Byte (LSB). The datasheet states the resolution is 0.1°C per LSB.
0xFF, LSB = 0x38Combined Hex:
0xFF38Combined Binary:
1111 1111 0011 1000
Because the MSB (bit 15) is 1, we know immediately this is a negative temperature. To find the exact magnitude in decimal, we apply the Two's Complement reversal:
- Start with the raw binary:
1111 1111 0011 1000 - Invert all bits (One's Complement):
0000 0000 1100 0111 - Add 1:
0000 0000 1100 1000 - Convert to decimal: The binary
1100 1000equals 128 + 64 + 8 = 200. - Apply the sign and scale: -200 × 0.1°C = -20.0°C.
In C++ firmware for an Arduino or ESP32, you do not manually invert and add 1. You rely on the compiler's type casting to handle the Two's Complement interpretation automatically, provided you cast to the correct signed type before doing math:
// Correct I2C parsing for a 16-bit signed sensor
uint8_t msb = Wire.read(); // Returns 0xFF
uint8_t lsb = Wire.read(); // Returns 0x38
// Combine into an unsigned 16-bit int first
uint16_t raw_unsigned = (msb << 8) | lsb; // Yields 65336
// Cast to signed 16-bit int to trigger Two's Complement interpretation
int16_t raw_signed = (int16_t)raw_unsigned; // Yields -200
float temperature_c = raw_signed * 0.1; // Yields -20.0
If you skip the int16_t cast and multiply raw_unsigned (65336) by 0.1, your code will report +6553.5°C, likely tripping your thermal protection logic unnecessarily.
Where You Meet This in Practice
Signed binary representation dictates behavior in three critical areas of physical circuit design and firmware development:
1. PID Loops and PWM Wrap-Around
When tuning a DC motor speed controller or a heating element using a PID algorithm, the error term and the final control output are inherently signed (e.g., the motor needs to spin in reverse, or the heater needs to turn off). If your PID library outputs a signed -5 to correct an overshoot, and you pass that directly into a 32-bit unsigned PWM function like the ESP32's ledcWrite(channel, duty), the compiler implicitly casts -5 to a uint32_t. In 32-bit Two's Complement, -5 becomes 4,294,967,291. The PWM peripheral truncates this to its maximum duty cycle, slamming your motor to 100% forward speed or blowing your heating MOSFET. Always clamp signed PID outputs to a 0 to max_duty range before passing them to unsigned hardware registers.
2. Bit-Shifting: Arithmetic vs. Logical
When you right-shift (>>) an unsigned binary number, the compiler performs a logical shift, filling the empty leftmost bits with 0s. However, when you right-shift a signed negative number, the C++ standard dictates an arithmetic shift on most architectures. The compiler fills the empty leftmost bits with 1s to preserve the negative sign. If you are using bit-shifting to scale down a signed sensor reading, be aware that -200 >> 1 correctly yields -100, but the underlying binary mechanics are entirely different than shifting a positive number.
3. Analog-to-Digital Converters (ADCs)
Standard microcontroller ADCs (like the 12-bit ADC on the ESP32) are strictly unipolar and unsigned, reading 0V to 3.3V as 0 to 4095. If you need to measure a bipolar signal (e.g., an AC sine wave centered at 1.65V), you must bias the circuit with a voltage divider and then subtract the DC offset in firmware. The result of that subtraction must be stored in a signed integer type (int16_t or int32_t), otherwise the negative half of the AC waveform will wrap around to massive positive values, ruining your RMS calculations.
Common Confusions and Debugging Traps
msb is 0xFF and declared as a signed int8_t, shifting it left by 8 bits (msb << 8) promotes it to 0xFFFFFFFF first, resulting in 0xFFFFFF00 instead of 0x0000FF00. Always use strictly unsigned types (uint8_t) for raw byte buffers, and cast to signed only at the final assembly step.
To solidify your understanding, here are the most frequent questions and debugging traps regarding signed binary numbers in embedded systems:
What do people commonly confuse signed binary with?
Beginners frequently confuse Two's Complement with Sign-Magnitude or One's Complement. As noted, Sign-Magnitude uses the MSB purely as a flag, which is how humans write negative numbers (e.g., "-5"), but it is almost never used in modern ALUs for integer math. Another common confusion is assuming that an overflow on a signed integer behaves the same as an unsigned integer. When an unsigned 8-bit integer hits 255 and adds 1, it predictably wraps to 0. When a signed 8-bit integer hits 127 and adds 1, it overflows into -128. In C and C++, signed integer overflow is technically classified as undefined behavior by the compiler, meaning aggressive optimization flags might silently strip out your overflow checks, leading to erratic hardware behavior.
How do I know if a datasheet uses signed or unsigned?
Always check the sensor's datasheet for the exact data type specification. For example, the Texas Instruments INA219 datasheet explicitly defines the Shunt Voltage Register as a "16-bit, signed, two's complement" value, whereas the Bus Voltage Register is defined as "unsigned". If you apply a signed cast to an unsigned register, any reading above 32,767 will be misinterpreted as a negative number. When in doubt, look at the register map table; if the minimum listed value is a negative number (e.g., -320mV), the register is signed.
Can I use floating-point numbers instead to avoid this?
You can, but you shouldn't on resource-constrained hardware. Floating-point variables (float) inherently handle signs and fractions, but they consume 4 bytes (32 bits) of RAM and require software-based floating-point math libraries on 8-bit chips like the ATmega328P, which drastically slows down execution time. Standard practice in high-speed digital signal processing (DSP) and motor control is to keep all raw math in signed 16-bit or 32-bit integers (often called "fixed-point arithmetic"), and only cast to a float at the very end when formatting the string for an OLED display or a serial telemetry packet.






