Skip to content

Instantly share code, notes, and snippets.

@Nezteb
Created September 3, 2026 22:07
Show Gist options
  • Select an option

  • Save Nezteb/cf796c1bd1225968e1f6eb3f97d70c7c to your computer and use it in GitHub Desktop.

Select an option

Save Nezteb/cf796c1bd1225968e1f6eb3f97d70c7c to your computer and use it in GitHub Desktop.

Signed & Unsigned Numbers: C → Elixir Over the Network

1. How C Represents Signed/Unsigned Integers

C uses two's complement for all signed integer types (mandated by C23; universally true in practice before that). 1 The exact same bit pattern is placed on the wire regardless of sign — the difference is only in interpretation:

C type Bits Wire byte 0xFF Wire byte 0x01
uint8_t 8 255 1
int8_t 8 −1 1
uint16_t 16
int16_t 16

The most significant bit (MSB) is the sign bit: 0 → non-negative, 1 → negative for signed types. 2

Endianness in C: Multi-byte integers are host-endian by default. Network code typically calls htons()/htonl() to convert to big-endian (network byte order) before sending, and ntohs()/ntohl() on receipt. You must know what the C sender does.


2. Elixir's Integer Model

Elixir integers are arbitrary precision — there is no signed/unsigned overflow concept at the language level. The signed/unsigned distinction only comes into play when reading or writing binary data using the << >> bitstring syntax.


3. Elixir Bitstring Syntax: Types and Modifiers

The <<segment::modifiers>> syntax supports 9 types and several modifiers. 3

Types

integer, float, binary (alias bytes), bitstring (alias bits), utf8, utf16, utf32

Modifiers

Modifier Applies to Default?
unsigned integer ✅ Yes
signed integer ❌ No
big integer, float, utf16, utf32 ✅ Yes
little integer, float, utf16, utf32 ❌ No
native integer, float, utf16, utf32 ❌ No (VM-determined)
size(N) all
unit(N) all 1 bit for integer/float; 8 bits for binary

3

Default integer size is 8 bits, default endianness is big. 3

signed vs unsigned in Practice

signed/unsigned only affect how bits are interpreted when pattern matching (decoding). The underlying bytes are identical in both cases — it's purely a reinterpretation: 3

# The byte 0x9C (156 unsigned = -100 signed as two's complement)

# Decoded as unsigned (default):
<<int::integer>> = <<0x9C>>
# int => 156

# Decoded as signed:
<<int::integer-signed>> = <<0x9C>>
# int => -100

When constructing a binary (e.g., to send to C), negative values are accepted and stored as two's complement automatically:

<<-100::signed-integer-8>>  # => <<156>>  (same byte 0x9C)
<<156::unsigned-integer-8>> # => <<156>>  (same byte 0x9C)

4. Receiving C Messages in Elixir: Pattern Matching

When your C code sends a struct over a TCP socket, Elixir receives it as a binary. You use pattern matching to decode each field, annotating exactly what C used:

Example: C sends a mixed struct

Suppose C sends:

struct Msg {
    uint8_t  type;       // 1 byte, unsigned
    int16_t  delta;      // 2 bytes, signed, little-endian
    uint32_t timestamp;  // 4 bytes, unsigned, big-endian (network order)
};

In Elixir:

def decode_msg(<<
  type    :: unsigned-integer-8,
  delta   :: signed-little-integer-16,
  ts      :: unsigned-big-integer-32,
  rest    :: binary
>>) do
  %{type: type, delta: delta, timestamp: ts, rest: rest}
end

Note: big is the default, so unsigned-big-integer-32 and unsigned-integer-32 are equivalent. Using explicit modifiers makes intent clear, especially in mixed-endian protocols.

Dynamic-length fields (length-prefixed)

A common C pattern is to prefix a payload with its length:

def decode_payload(<<
  length  :: unsigned-integer-16,
  payload :: binary-size(length),
  rest    :: binary
>>) do
  {payload, rest}
end

4


5. Constructing Binaries to Send Back to C

# Build a response: unsigned 8-bit type, signed 16-bit value (little-endian)
def encode_msg(type, delta) do
  <<type :: unsigned-integer-8, delta :: signed-little-integer-16>>
end

encode_msg(3, -500)
# => <<3, 12, 254>>  (3 = type; -500 little-endian = <<12, 254>>)

6. Converting Between Signed/Unsigned When You Already Have an Integer

If you've received an integer and need to reinterpret its signedness (e.g., the upstream code already matched as unsigned and you now need the signed value):

# Re-interpret an unsigned 8-bit integer as signed
def unsigned_to_signed8(n) when n > 127, do: n - 256
def unsigned_to_signed8(n), do: n

# Or more elegantly via binary round-trip:
def reinterpret_as_signed16(unsigned_val) do
  <<signed::signed-integer-16>> = <<unsigned_val::unsigned-integer-16>>
  signed
end

# Inverse: signed → unsigned
def reinterpret_as_unsigned8(signed_val) do
  <<unsigned::unsigned-integer-8>> = <<signed_val::signed-integer-8>>
  unsigned
end

The binary round-trip is the idiomatic Elixir way — you write the value in one form and re-read it with the desired signedness modifier. No manual bit twiddling needed.


7. Endianness Reference

# Big-endian (network byte order, Elixir default)
<<value::big-integer-size(16)>> = <<0x01, 0x00>>
# value = 256

# Little-endian (x86 native, many embedded systems)
<<value::little-integer-size(16)>> = <<0x01, 0x00>>
# value = 1

# native: whatever the BEAM VM host CPU uses (determined at startup)
<<value::native-integer-size(16)>> = <<0x01, 0x00>>

3 4

⚠️ Prefer explicit big/little over native in network protocols — native depends on the Elixir host platform and will break portability.


8. credo-binary-patterns Conventions

The credo-binary-patterns library enforces consistency in binary pattern syntax. The enforced ordering is: 5

<<x :: [endian]-[sign]-[type]-[size]>>

For example:

# ✅ Correct ordering:
<<x::little-signed-integer-size(16)>>

# ✅ Also valid shorthand:
<<x::little-signed-integer-16>>

It also flags unnecessary defaults: 5

# ❌ Flagged: integer defaults to 8 bits
<<x::integer-8>>

# ❌ Flagged: integer defaults to big-endian
<<x::big-integer>>

# ✅ Correct minimal form:
<<x::integer>>

bytes vs binary sizing convention: 5

# ✅ Use bytes with bare size:
<<x::16-bytes>>

# ✅ Use binary with size():
<<x::binary-size(16)>>

# ❌ Don't mix:
<<x::binary-16>>   # bare size with binary type

9. Common Pitfalls Summary

Pitfall Issue Fix
Default unsigned matching A C int8_t of −1 (0xFF) reads as 255 in Elixir Add signed modifier: <<x::signed-integer-8>>
Default big endian C sends little-endian (int16_t on x86) but Elixir reads as big Add little modifier: <<x::little-integer-16>>
Confusing signed for construction signed/unsigned have no effect when building a binary from an integer literal Use them for pattern matching, not construction
Using native endianness Breaks if C and Elixir run on different-endian hosts Be explicit with big or little
Missing size on non-last binary segment All but the last binary field must have size(N) <<a::binary-size(4), rest::binary>>

References

Footnotes

  1. Integer Representations (GNU C Language Manual) (14%)

  2. Two's complement (12%)

  3. Kernel.SpecialForms — Elixir v1.20.4 (46%) 2 3 4 5

  4. Binary Pattern Matching in Elixir (0%) 2

  5. CredoBinaryPatterns — credo_binary_patterns v0.2.6 (28%) 2 3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment