The bug that doesn't crash
Imagine you've wrapped a C library that reads sensor data. Temperature, humidity, a timestamp, a quality indicator. You write the Dart struct, hook up the bindings, run the app, and get readings back.
The values are wrong. Not obviously wrong — not NaN, not -2147483648, not a crash. The temperature reads 23.4 when it should be 21.1. The timestamp is plausible. The quality flag is 0 when it should be 1. Everything looks like sensor data. Everything is subtly off.
You check the C function — it returns the right values in its own tests. You check your Dart call — the types look right. You add logging at both ends. The C side shows correct values. The Dart side shows incorrect values. The data is being corrupted somewhere in the crossing.
The cause: a padding byte you didn't account for. One invisible byte the C compiler inserted to align a field. Your Dart struct was one byte out of sync with the C struct for every field after the first one. The readings weren't corrupted — they were reading the right bytes but assigning them to the wrong fields.
This is the alignment trap. It produces no exception, no compiler error, no runtime warning. Just data that looks right but isn't. Understanding it is the most important thing in this post.
What a struct is in memory
A C struct is a contiguous region of memory containing a sequence of fields. Nothing more. No methods, no virtual dispatch, no reference counting — just bytes arranged in order.
If you declared:
struct Point {
int32_t x;
int32_t y;
};Memory for a Point is exactly 8 bytes. The first 4 bytes are x. The next 4 bytes are y. When you access point.y, the CPU adds 4 to the struct's base address and reads 4 bytes there. That's field access in C: base address plus offset. No lookup table, no indirection.
The layout is completely determined at compile time. The C compiler decides exactly where each field lives within the struct. In the simple case of two int32_t fields, the layout is obvious: 0 and 4.
But the layout is not always obvious.
The invisible bytes: alignment and padding
Modern CPUs read and write memory most efficiently at aligned addresses. An int32_t (4 bytes) reads fastest at an address divisible by 4. A float (4 bytes) similarly. An int64_t (8 bytes) reads fastest at an address divisible by 8.
Reading from an unaligned address isn't impossible — on most ARM and x86 architectures it works — but it may require two memory bus cycles instead of one. For performance reasons, and sometimes for hardware correctness reasons, the C compiler aligns every field to its natural alignment boundary.
When fields don't naturally fall on their alignment boundary, the compiler inserts padding bytes — empty space — between fields to make the next field land correctly.
Consider:
struct SensorReading {
uint8_t sensor_id; // 1 byte
float value; // 4 bytes, needs 4-byte alignment
int64_t timestamp; // 8 bytes, needs 8-byte alignment
uint8_t quality; // 1 byte
};You might expect this to be 1 + 4 + 8 + 1 = 14 bytes. It isn't. Let's walk through what the compiler actually produces:
Offset Size Field Notes
──────────────────────────────────────────────────────
0 1 sensor_id fits here fine
1 3 [padding] float needs 4-byte alignment; next aligned addr is 4
4 4 value at offset 4 — correctly aligned
8 8 timestamp at offset 8 — correctly aligned (8-byte boundary)
16 1 quality fits here
17 7 [padding] struct size must be multiple of largest alignment (8)
──────────────────────────────────────────────────────
Total: 24 bytesTwenty-four bytes for what logically looked like 14. The compiler added 10 bytes of invisible padding to make every field land on its natural alignment boundary, and to make the struct's total size a multiple of its most-aligned field (8, for int64_t).
If you wrote a Dart struct assuming 14 bytes of layout:
sensor_idreads correctly (offset 0)valuereads from offset 1 — but it should be offset 4. You're reading 4 bytes that include the 3 padding bytes and the first byte of the actualvalue. A plausible-looking float that's completely wrong.- Everything after that is similarly misaligned.
No error. No warning. Just wrong data that looks like real data.
The Dart Struct class
Dart FFI's Struct class mirrors C struct layout precisely — including padding. When you declare a Struct subclass correctly, Dart's FFI machinery computes the same layout the C compiler would produce for the equivalent C struct.
import 'dart:ffi';
final class SensorReading extends Struct {
@Uint8()
external int sensorId;
// Dart FFI automatically adds 3 bytes of padding here
// to align 'value' to a 4-byte boundary
@Float()
external double value;
@Int64()
external int timestamp;
@Uint8()
external int quality;
// Dart FFI automatically adds 7 bytes of padding here
// to make the struct size a multiple of 8
}Three things to notice:
`final class` — structs must be final. They cannot be subclassed. This is because FFI structs are not Dart objects in the usual sense — they're views into native memory, and subclassing would break the layout contract.
`@TypeAnnotation()` — every field needs an annotation specifying the C type (@Uint8(), @Float(), @Int32(), etc.). This tells the FFI layer both the size of the field and its alignment requirement. Without the annotation, Dart doesn't know how to position the field in the layout.
`external` — the external keyword means the field's storage is not in the Dart object itself — it's in native memory that the struct view points to. You're not declaring a Dart variable; you're declaring a window into a specific byte offset in native memory.
Dart FFI computes the same padding the C compiler would. If you annotate the struct fields correctly, the layouts match.
Working with structs: allocation and field access
A Struct in Dart isn't something you construct with a constructor. It's always a view into some region of memory. You either receive a pointer to a struct from a C function, or you allocate native memory yourself and view it as a struct.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void workingWithStructs() {
// Allocate native memory for one SensorReading
final Pointer<SensorReading> ptr = calloc<SensorReading>();
try {
// Access fields through the pointer's 'ref' property
ptr.ref.sensorId = 42;
ptr.ref.value = 21.3;
ptr.ref.timestamp = DateTime.now().millisecondsSinceEpoch;
ptr.ref.quality = 1;
// Pass to a C function
_processSensorReading(ptr);
// Read results back
print('Sensor ${ptr.ref.sensorId}: ${ptr.ref.value}°C');
print('Quality: ${ptr.ref.quality}');
} finally {
calloc.free(ptr);
}
}.ref is the dereference: ptr.ref gives you a view of the SensorReading fields at the address ptr holds. ptr.ref.value reads the 4 bytes at ptr.address + 4 (accounting for the padding after sensor_id) and interprets them as a float. Writing to ptr.ref.value writes 4 bytes to that location.
If you receive a Pointer<SensorReading> from a C function — the C function allocated the struct — the same .ref API gives you access to its fields. You're reading directly from C's memory, with zero copying.
Passing structs: by pointer vs by value
Small structs in C are sometimes passed by value — a copy of the struct is pushed onto the call stack. Larger structs are almost always passed by pointer — the function receives the memory address of the struct and reads through it.
Dart FFI supports both:
// C: void process_by_pointer(SensorReading* reading)
typedef _ProcessByPointerNative = Void Function(Pointer<SensorReading>);
typedef _ProcessByPointerDart = void Function(Pointer<SensorReading>);
// C: void process_by_value(SensorReading reading)
typedef _ProcessByValueNative = Void Function(SensorReading);
typedef _ProcessByValueDart = void Function(SensorReading);
// C: SensorReading get_reading()
typedef _GetReadingNative = SensorReading Function();
typedef _GetReadingDart = SensorReading Function();When passing by value, Dart FFI copies the struct onto the call stack. The C function receives a copy — changes to it don't affect the original. When passing by pointer, C receives the address and can modify the original.
The distinction matters: if you pass by value intending C to fill in the struct and send it back, C's writes go into its local copy and disappear. Pass a pointer when you need C to modify the struct. Pass by value when the struct is an input only.
Returning structs from C functions also works:
final _getReading = lib.lookupFunction<_GetReadingNative, _GetReadingDart>(
'get_reading',
);
void readSensor() {
final reading = _getReading(); // returns a SensorReading by value
print(reading.value); // access fields directly
print(reading.timestamp);
// no calloc.free needed — this is a Dart-managed copy of the struct
}When C returns a struct by value, Dart FFI copies it into a Dart-managed region. You don't need to free it.
Nested structs
Structs can contain other structs. The layout rules apply recursively — each nested struct's fields are subject to the same alignment requirements.
struct Vector3 {
float x;
float y;
float z;
};
struct Transform {
struct Vector3 position;
struct Vector3 rotation;
float scale;
};In Dart:
final class Vector3 extends Struct {
@Float()
external double x;
@Float()
external double y;
@Float()
external double z;
}
final class Transform extends Struct {
external Vector3 position; // no annotation — it's a nested Struct
external Vector3 rotation;
@Float()
external double scale;
}Nested Struct fields don't use a type annotation — the Struct subclass itself carries the layout information. The memory layout: position occupies 12 bytes at offset 0, rotation occupies 12 bytes at offset 12, scale occupies 4 bytes at offset 24. Total: 28 bytes. No padding needed here because all fields are 4-byte aligned.
Accessing nested fields chains through .ref:
final ptr = calloc<Transform>();
ptr.ref.position.x = 1.0;
ptr.ref.position.y = 2.0;
ptr.ref.position.z = 0.0;
ptr.ref.scale = 1.5;
calloc.free(ptr);Fixed-size arrays inside structs
C structs can contain arrays with a fixed, compile-time size. These are inline arrays — the array data is part of the struct itself, not a pointer to separately allocated memory.
struct AudioFrame {
int32_t sample_rate;
int32_t channel_count;
float samples[256]; // 256 floats inline in the struct
};In Dart, inline arrays use the @Array() annotation:
final class AudioFrame extends Struct {
@Int32()
external int sampleRate;
@Int32()
external int channelCount;
@Array(256)
external Array<Float> samples; // 256 * 4 = 1024 bytes inline
}Accessing elements of an inline array uses the subscript operator:
final frame = calloc<AudioFrame>();
frame.ref.sampleRate = 44100;
frame.ref.channelCount = 2;
// Write samples
for (int i = 0; i < 256; i++) {
frame.ref.samples[i] = computeSample(i);
}
// Read samples
final firstSample = frame.ref.samples[0];
calloc.free(frame);The total size of AudioFrame: 4 + 4 + (256 × 4) = 1032 bytes. All inline, no separate allocation, no pointer to follow.
Don't confuse this with a struct field that's a pointer to an array:
struct BufferedAudio {
int32_t sample_count;
float* samples; // pointer to separately allocated array
};final class BufferedAudio extends Struct {
@Int32()
external int sampleCount;
external Pointer<Float> samples; // a pointer, not inline data
}Here, samples is just a pointer — 8 bytes on a 64-bit system. The actual sample data lives elsewhere, separately allocated. You'd access it through ptr.ref.samples[i], but you'd need to know how many samples there are, and someone needs to manage the lifetime of that separately allocated buffer.
Inline arrays (@Array) vs pointer fields (Pointer<T>) look similar in Dart but have completely different memory layouts. The C header file tells you which is which — float samples[256] is inline, float* samples is a pointer.
Packed structs: removing all padding
Sometimes padding is actively unwanted. Network protocol headers, file format structures, hardware register maps — these have exact byte layouts that can't accommodate invisible padding. A TCP header is 20 bytes, specific field at a specific offset, defined by the protocol. The compiler's alignment choices don't matter; the protocol does.
For these, C provides the #pragma pack or __attribute__((packed)) directive. Dart FFI provides @Packed():
@Packed(1) // 1 = no padding whatsoever
final class TcpHeader extends Struct {
@Uint16()
external int sourcePort; // offset 0, 2 bytes
@Uint16()
external int destPort; // offset 2, 2 bytes
@Uint32()
external int sequenceNumber; // offset 4, 4 bytes
@Uint32()
external int ackNumber; // offset 8, 4 bytes
@Uint8()
external int dataOffset; // offset 12, 1 byte
@Uint8()
external int flags; // offset 13, 1 byte
@Uint16()
external int windowSize; // offset 14, 2 bytes
@Uint16()
external int checksum; // offset 16, 2 bytes
@Uint16()
external int urgentPointer; // offset 18, 2 bytes
}
// Total: exactly 20 bytes — matching the TCP standard@Packed(1) means "align every field to 1-byte boundaries" — effectively no alignment requirements, no padding. Fields are placed at exactly the offset you'd calculate by hand.
The @Packed annotation takes a number: the maximum alignment any field can have. @Packed(1) means everything is 1-byte aligned (no padding). @Packed(2) means maximum 2-byte alignment. @Packed(4) means maximum 4-byte alignment. Match it to whatever #pragma pack(N) the C code uses.
Using `@Packed(1)` on a struct that the C side didn't also pack will produce wrong layouts — you'll be removing padding that C expects to be there. Always use @Packed only when the C struct explicitly requests packed layout.
Verifying the layout
When you're uncertain about a struct's layout — especially for complex structs from third-party libraries — verify it from the C side before trusting your Dart bindings.
A minimal C verification program:
#include <stdio.h>
#include <stddef.h>
#include "sensor.h"
int main() {
printf("sizeof(SensorReading) = %zu\n", sizeof(SensorReading));
printf("offsetof(sensor_id) = %zu\n", offsetof(SensorReading, sensor_id));
printf("offsetof(value) = %zu\n", offsetof(SensorReading, value));
printf("offsetof(timestamp) = %zu\n", offsetof(SensorReading, timestamp));
printf("offsetof(quality) = %zu\n", offsetof(SensorReading, quality));
return 0;
}Run this for the exact target architecture (ARM64 for a modern Android device, not your x86 Mac). The offsets this prints are the ground truth. Your Dart struct must produce the same offsets — which you can verify with:
print(sizeOf<SensorReading>()); // should match sizeof() from the C programFor large C libraries, ffigen reads the header files directly and generates the Dart structs with correct annotations and padding automatically. Running ffigen for a library with dozens of structs is far safer than hand-writing the bindings. We'll use it properly in Post 5.
A real example: audio buffer metadata
Let's put it together with a struct that represents audio buffer metadata — something a C audio library might return.
// audio_lib.h
typedef struct {
uint32_t sample_rate; // e.g. 44100
uint8_t channels; // 1=mono, 2=stereo
uint8_t bit_depth; // 8, 16, or 32
// 2 bytes padding here
uint32_t frame_count; // number of audio frames
double duration_secs; // total duration
} AudioBufferInfo;
// Fill info struct for the given audio file
// Returns 0 on success, non-zero on error
int32_t get_audio_info(const char* filepath, AudioBufferInfo* info);The layout:
Offset Size Field
──────────────────────────────────────────────
0 4 sample_rate (uint32_t, 4-byte aligned)
4 1 channels (uint8_t)
5 1 bit_depth (uint8_t)
6 2 [padding] frame_count needs 4-byte alignment
8 4 frame_count (uint32_t, 4-byte aligned)
12 4 [padding] double needs 8-byte alignment
16 8 duration_secs (double, 8-byte aligned)
──────────────────────────────────────────────
Total: 24 bytesThe Dart struct:
final class AudioBufferInfo extends Struct {
@Uint32()
external int sampleRate;
@Uint8()
external int channels;
@Uint8()
external int bitDepth;
// Dart FFI inserts 2 bytes of padding here automatically
@Uint32()
external int frameCount;
// Dart FFI inserts 4 bytes of padding here automatically
@Double()
external double durationSecs;
}
typedef _GetAudioInfoNative = Int32 Function(
Pointer<Utf8> filepath,
Pointer<AudioBufferInfo> info,
);
typedef _GetAudioInfoDart = int Function(
Pointer<Utf8> filepath,
Pointer<AudioBufferInfo> info,
);
/// Plain Dart class — safe to use after native memory is freed.
class AudioInfo {
final int sampleRate;
final int channels;
final int bitDepth;
final int frameCount;
final double durationSecs;
const AudioInfo({
required this.sampleRate,
required this.channels,
required this.bitDepth,
required this.frameCount,
required this.durationSecs,
});
}
class AudioLib {
static final DynamicLibrary _lib = /* ... */;
static final _GetAudioInfoDart _getAudioInfo =
_lib.lookupFunction<_GetAudioInfoNative, _GetAudioInfoDart>(
'get_audio_info',
);
static AudioInfo? getAudioInfo(String filepath) {
return using((arena) {
final nativePath = filepath.toNativeUtf8(allocator: arena);
final infoPtr = arena<AudioBufferInfo>();
final result = _getAudioInfo(nativePath, infoPtr);
if (result != 0) return null;
// IMPORTANT: copy the fields into Dart-managed memory before the arena
// frees infoPtr. Returning infoPtr.ref directly would be a use-after-free —
// .ref is a view into native memory, not a copy.
final r = infoPtr.ref;
return AudioInfo(
sampleRate: r.sampleRate,
channels: r.channels,
bitDepth: r.bitDepth,
frameCount: r.frameCount,
durationSecs: r.durationSecs,
);
});
}
}When using() exits, the arena frees both nativePath and infoPtr. We copy the struct's fields into a plain Dart class (AudioInfo) before the arena frees the native memory. This is critical — infoPtr.ref is a view into native memory, not a copy. Returning infoPtr.ref directly from using() would be a use-after-free: the returned struct would point to freed memory, reading garbage data with no error thrown.
The silent corruption checklist
Before shipping FFI code with structs, verify:
- Field types match exactly:
uint32_tin C must be@Uint32()in Dart, not@Int32(). Unsigned vs signed matters for large values. - Field order matches: The Dart struct fields must be in the same order as the C struct fields. Padding depends on order.
- Nested structs are annotated without a type annotation:
external Vector3 positionnot@Vector3() external Vector3 position. - Packed structs use `@Packed()`: If the C code uses
__attribute__((packed))or#pragma pack(1), the Dart struct needs@Packed(1). - Inline arrays use `@Array(N)`:
float samples[256]in C is `@Array(256) external Array<Float - samples` in Dart.
- Sizes verified on target architecture: Run the C sizeof/offsetof check on ARM64, not on x86.
What comes next
Structs complete the picture of Dart calling C with complex data. You can now pass and receive any C data structure: primitive values, strings, byte buffers, fixed arrays, compound structs, packed protocol headers.
Post 4 reverses the direction. Instead of Dart calling C, we'll have C call Dart — through function pointers, NativeCallable, and the thread model that makes this genuinely tricky. When C invokes your Dart callback from its own worker thread, interesting things happen that require careful handling.