The Problem
We were porting a data parsing library from C/C++ to Android. The library was written for embedded systems — tight, deterministic, no runtime overhead. At its core it parsed a byte stream and converted chunks of bytes into typed values: floats, shorts, longs. The mechanism it used was the C union.
When we got to Kotlin, we hit a wall. Kotlin has no union. It has no reinterpret_cast. It gives you no way to say "treat these four bytes as the bit pattern of a float" without going through a conversion. And when byte order enters the picture, the problem deepens significantly.
This post is about that wall — what it is, why it exists, and the only approach that actually works.
The C Union Trick: What It Actually Does
In C, a union allocates only as much memory as its largest member requires — and every member shares that same block. Unlike a struct, which gives each member its own dedicated space, a union stacks all its members on top of one another. When you write to one member and read from another, you are reading the same bytes interpreted through a different type lens. No conversion. No copying. The same bits, seen differently.
union convertLong {
unsigned long asUnsigned;
long asNum;
unsigned char asBytes[sizeof(long)];
};
union convertFloat {
float asNum;
unsigned char asBytes[sizeof(float)];
};
union convertShort {
short asNum;
unsigned char asBytes[sizeof(short)];
};
Each union occupies exactly as much memory as its largest member. For convertFloat, that is 4 bytes — the same size as both a float and an unsigned char[4]. Fill asBytes with four bytes from a buffer, and asNum immediately holds the float those bits represent — with zero arithmetic.
This is called type punning. It is one of the oldest tricks in systems programming, and it is exactly what C was designed to allow.
Reading a Float from a Byte Buffer — the C Way
Here is the function from our library that reads four bytes from a buffer and returns them as a float:
#define FLOAT_NAN (-999999.0)
int GetAsFloat(int index, float *value)
{
if (index < 0) return -1;
int indexStart = index;
int i;
convertFloat temp;
int size = GetSize();
if (size >= (int(sizeof(float)) + indexStart))
{
// Fill the union in reverse order to account for byte order
for (i = (sizeof(float) - 1); i >= 0; i--)
{
temp.asBytes[i] = GetAt(index++);
}
// Check for the NaN sentinel (IEEE 754 quiet NaN bit pattern)
if (((int*)temp.asBytes)[0] == 0x7FA00000)
{
*value = FLOAT_NAN;
}
else
{
*value = temp.asNum;
}
}
else
{
index = -1;
}
return (index);
}
Two things in this function are easy to miss but critical to understand.
The reverse loop is endianness handling
The loop runs from sizeof(float) - 1 down to 0. This fills asBytes[3] first, then asBytes[2], [1], [0], while reading the source buffer in forward order. It is a byte reversal.
The source data — coming from a device or a network stream — is stored big-endian: most significant byte first. The host CPU is little-endian: it expects the least significant byte at the lowest memory address. Without the reversal, the bytes land in the wrong positions inside the union and the resulting float is complete garbage.
Endianness is the silent killer in byte conversion bugs. The code compiles, it runs, and it returns a number — just the completely wrong one. Without explicit reversal, you would not know until you compared the output against a known-good value.
The NaN sentinel check
The bit pattern 0x7FA00000 is an IEEE 754 quiet NaN. The device uses this bit pattern to signal "no data" or "invalid reading." The library detects it and maps it to an application-level sentinel (-999999.0) that callers can check. This check works because the union gives direct access to the raw bit pattern — something you cannot do with a normal float variable.
Why Kotlin Has No Equivalent
Kotlin runs on the JVM (or on the Android Runtime, which is JVM-derived). The JVM is a managed execution environment that deliberately abstracts away memory layout. There is no concept of "two variables at the same address." There is no reinterpret_cast. The type of a value and the memory that holds it are inseparable by design.
Kotlin also does not expose unsafe memory access. Unlike Java's sun.misc.Unsafe (which is internal, deprecated, and unavailable on Android), Kotlin gives you no way to reach into a memory location and read it as a different type.
You might think of workarounds:
Float.fromBits(Int)— converts an Int's bit pattern to a Float. But you first have to assemble those four bytes into an Int, which requires explicit bit shifting and masking with correct byte order. It is not wrong — but it is not the same thing. You are doing arithmetic to reconstruct what C does in hardware at no cost, and it is easy to get the byte order wrong silently.java.nio.ByteBuffer— lets you set byte order and read typed values. Better, but it introduces allocation overhead and still requires you to re-implement the NaN sentinel logic and the endianness handling that the C library already had right.
In our case, the C library was large and already proven. Every conversion type — float, short, long, unsigned — had its own union and its own endianness-aware reader. Re-implementing all of that in Kotlin, correctly, without introducing subtle byte-order bugs, was not a risk worth taking. The C code was correct. The right move was to keep using it.
The Solution: JNI Back to Native C
JNI — the Java Native Interface — lets Android call C/C++ functions directly. We wrapped the existing C conversion functions in JNI entry points and called them from Kotlin. The byte order handling, the NaN sentinel check, and the union-based reinterpretation all stayed exactly where they were, in C, where they work.
extern "C" JNIEXPORT jfloat JNICALL
Java_com_example_utils_DataArray_GetAsFloat(JNIEnv *env,
jobject thiz,
jintArray dataToConvert,
jint size)
{
convertFloat temp;
int index = 0;
jfloat retVal = -1;
jboolean isCopy;
jint *data = env->GetIntArrayElements(dataToConvert, &isCopy);
if (isCopy == false) return retVal;
if (size >= (int(sizeof(float))))
{
// Fill the union in reverse order to account for byte order
for (int i = (sizeof(float) - 1); i >= 0; i--)
{
temp.asBytes[i] = (unsigned char) data[index];
index++;
}
// Check for the NaN sentinel
if (((int *) temp.asBytes)[0] == 0x7FA00000)
{
retVal = FLOAT_NAN;
}
else
{
retVal = temp.asNum;
}
}
return retVal;
}
A few things to notice here:
- The function name encodes the call path. JNI uses a strict naming convention:
Java_<package>_<class>_<method>. The JVM resolves the native binding automatically at runtime based on this name — no registration needed. - The data arrives as
jintArray, notjbyteArray. This is because Android'sjbyteis signed (-128 to 127), which corrupts bytes above 0x7F when the conversion logic casts tounsigned char. Passing asjintArraypreserves the full 0–255 range and makes the cast safe. - The reverse loop is identical to the original. The endianness handling did not change at all. The JNI wrapper is just a bridge — the conversion logic lives exactly where it was.
The Kotlin Side
Kotlin's declaration is minimal. The external keyword tells the compiler that the implementation lives in native code. The JVM handles the binding at runtime.
package com.example.utils
class DataArray {
fun getAsFloat(data: IntArray): Float {
if (data.size < Float.SIZE_BYTES) {
return -1f
}
return GetAsFloat(data, data.size)
}
// Resolved at runtime via JNI naming convention
external fun GetAsFloat(dataArray: IntArray, size: Int): Float
}
The public getAsFloat function does one thing before calling native: it validates that the incoming array actually contains enough bytes for a float. The rest is delegated entirely to C.
Build Configuration
To compile and link the native code into your Android project, two pieces of build configuration are required.
Your CMakeLists.txt declares the native library and lists the source files that build it:
cmake_minimum_required(VERSION 3.22.1)
project("dataconvert")
add_library(
dataconvert
SHARED
src/main/cpp/DataConvert.cpp
)
find_library(log-lib log)
target_link_libraries(
dataconvert
${log-lib}
)
Your build.gradle then points Android's build system at the CMakeLists file:
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {
cppFlags "-std=c++17"
}
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.22.1"
}
}
}
The compiled .so files land under src/main/jniLibs/ in your module structure, organised by ABI — arm64-v8a, armeabi-v7a, x86_64. Android packages all of them and loads the correct one at runtime.
One library. Three ABIs. The same C union code runs identically on every target architecture — and the endianness handling inside it is correct on all of them because it was already written to handle the byte order of the source data, not the host CPU.
What We Learned
Porting C to Kotlin is not always a translation exercise. Sometimes the C code is doing something that the Kotlin type system will not permit — deliberately. Union-based type punning is one of those things. Kotlin's refusal is not a gap; it is a guarantee. The JVM will not let you have two incompatible types occupying the same memory because that is a source of entire categories of bugs in C.
But that guarantee creates a problem when you are calling into a protocol or device that has already committed to a specific byte layout. The source data does not care about your type system. It arrives as bytes. You need to reinterpret them — correctly, in the right order, with awareness of the machine that produced them.
When the conversion logic is already proven in C, the right answer is not to rewrite it in Kotlin. The right answer is JNI: keep the C doing what C does well, and let Kotlin orchestrate everything around it.
Key Takeaways
- C unions perform type punning at zero cost — they reinterpret bit patterns without conversion by placing members at the same memory address.
- Kotlin has no union equivalent by design. The JVM's memory model prohibits two incompatible type views of the same data.
- Endianness is not optional. If your source data is big-endian and your device is little-endian, every multi-byte value requires explicit byte reversal before reinterpretation. The C code handled this with a reverse-fill loop inside the union — that logic must be preserved exactly.
- Pass bytes as
IntArray, notByteArrayacross JNI if your values exceed 0x7F — Java'sbyteis signed and will silently corrupt high-value bytes. - JNI is not a last resort. When proven C code handles the hard part correctly, wrapping it with a thin JNI bridge is the fastest, safest, and most maintainable path forward.
Porting a legacy C or C++ system to Android?
These problems — type punning, endianness, unsafe casts, protocol parsing — appear in almost every C-to-Android migration. We have done it. If you are facing the same wall, we are happy to talk through it.
Talk to Us