HomeDocumentationc_006_dart_ffi
c_006_dart_ffi
16

Pointers, Strings, and the Hard Part: Passing Complex Data Across the FFI Boundary

Dart FFI: Pointers, Strings, and Memory Management in Flutter

March 20, 2026

A pointer is just a number

Before anything else, let's strip the mysticism from pointers. They are the thing that makes C feel dangerous and FFI feel intimidating, and they don't deserve that reputation once you see what they actually are.

Your computer's RAM is a very long sequence of bytes. Every byte has an address — a number, starting from 0 and counting up. On a 64-bit system, addresses can go up to 2⁶⁴ − 1, which is enough to address 16 exabytes of memory. In practice your process gets a much smaller slice, but the principle holds: every byte in memory has a unique address.

A pointer is a variable that stores one of those addresses. That's it. A 64-bit integer containing a memory address.

Pointer<Int32> in Dart FFI is a pointer to a region of memory that contains a 4-byte signed integer. The Int32 part tells you how to interpret the bytes at that address — it says "if you read 4 bytes starting here, treat them as a signed 32-bit integer." A Pointer<Uint8> points to a single byte. A Pointer<Double> points to 8 bytes interpreted as a 64-bit floating-point number.

The type doesn't change the memory. It changes how you read it. This is the same insight from Post 1 of the JS series: data is bytes, and types are interpretation agreements. A Pointer<Int32> and a Pointer<Float> could point to the exact same 4 bytes. What you do with those bytes depends on which type you're using.

In Dart, when you write final user = User(name: 'Alice'), the GC allocates an object on the managed heap and user holds a reference — an internal handle that the GC can update if it moves the object during compaction. You never see the actual address. The GC abstracts it.

In C, when you write int32_t* p = malloc(4), p is the actual memory address — a raw number. The OS gave you address 0x7f4a2c01b390, and that's what p contains. No abstraction, no GC. You work with the address directly.

When you cross the FFI boundary, you're working with those raw addresses. Pointer<T> in Dart is the type that says: "I am holding a raw memory address. Please treat what's there as type T."

Reading and writing through a pointer

Once you have a Pointer<T>, you have two operations: read what's at the address, and write something to it.

dart
import 'dart:ffi';
import 'package:ffi/ffi.dart';

void pointerBasics() {
  // Allocate 4 bytes of native memory — enough for one Int32
  final Pointer<Int32> p = calloc<Int32>();

  // Write a value to that address
  p.value = 42;

  // Read it back
  print(p.value); // 42

  // The address itself — just a number
  print(p.address); // something like 140234567890432

  // Always free what you allocate
  calloc.free(p);
}

.value is the dereference — it reads or writes the bytes at the address the pointer holds. Without .value, you're talking about the pointer itself (the address number). With .value, you're talking about what lives at that address.

Now, arrays. In C, an array and a pointer to its first element are nearly interchangeable. int32_t arr[5] is essentially a pointer to the first of 5 consecutive int32_t values in memory. To get from element 0 to element 1, you advance the pointer by sizeof(int32_t) — 4 bytes.

dart
void arrayBasics() {
  // Allocate space for 5 consecutive Int32 values
  final Pointer<Int32> arr = calloc<Int32>(5);

  // Write to each element
  for (int i = 0; i < 5; i++) {
    arr[i] = i * i; // Dart's subscript operator — equivalent to (arr + i).value
  }

  // Read them back
  for (int i = 0; i < 5; i++) {
    print(arr[i]); // 0, 1, 4, 9, 16
  }

  // Pointer arithmetic — advance by one element at a time
  final second = arr + 1; // points to the second Int32, 4 bytes ahead
  print(second.value); // 1

  calloc.free(arr);
}

calloc<Int32>(5) allocates 5 × 4 = 20 bytes of contiguous native memory and returns a pointer to the first byte. The subscript operator arr[i] is sugar for (arr + i).value, which advances the pointer by i elements (not bytes — the type determines the step size) and dereferences.

This is how C arrays work. Always have been. The pointer and the array are the same thing viewed differently.

Why Dart strings and C strings are incompatible

Strings are where most developers first hit genuine friction with FFI. The incompatibility is not a quirk — it goes all the way down to encoding.

Dart strings are sequences of UTF-16 code units internally. Each character is 2 bytes (or 4 for supplementary characters). The string object lives on the Dart managed heap, has GC metadata, and a stored length. You never work with a null terminator — Dart strings know their own length.

C strings are sequences of bytes in memory — typically ASCII or UTF-8 — terminated by a null byte (\0). They're not objects. They have no stored length (you find the length by scanning until you hit the null). They live at a raw memory address. They have no GC metadata because there is no GC.

You cannot pass a Dart string to a C function expecting char*. The layouts are completely different. The GC might move the Dart string between when you get its address and when C reads it. And a char* in C expects UTF-8 bytes followed by a null terminator — not UTF-16 without a null terminator.

The conversion is explicit and manual:

dart
import 'package:ffi/ffi.dart';

void stringConversion() {
  final dartString = 'Hello, World!';

  // Convert to a null-terminated UTF-8 byte sequence in native memory
  // Returns Pointer<Utf8> — an alias for Pointer<Char>
  final Pointer<Utf8> nativeString = dartString.toNativeUtf8();

  // The native memory now contains:
  // 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 00
  // H  e  l  l  o  ,     W  o  r  l  d  !  \0
  // (14 bytes: 13 characters + null terminator)

  // Pass to a C function
  someNativeFunction(nativeString);

  // Convert back from native UTF-8 to Dart string
  final backToDart = nativeString.toDartString();
  print(backToDart); // 'Hello, World!'

  // Free the native memory — toNativeUtf8() used calloc internally
  calloc.free(nativeString);
}

toNativeUtf8() does four things:

  1. Encodes the Dart string as UTF-8 bytes
  2. Allocates native memory large enough to hold them plus a null terminator
  3. Copies the bytes into that memory
  4. Returns a pointer to the first byte

The returned pointer is yours to free. The GC cannot free it — it's native memory, invisible to the collector. Every toNativeUtf8() has a matching calloc.free() somewhere, always in a finally block, always.

What if the C function returns a string — allocates a char* and returns it to you? You convert it the same way, but the ownership question becomes critical. We'll get to that shortly.

Pointer<Uint8> — working with raw byte buffers

The most common FFI pattern for real work isn't single integers or strings. It's byte buffers — raw chunks of memory containing image data, audio samples, compressed data, network packets.

The type for this is Pointer<Uint8> — a pointer to a sequence of unsigned bytes.

dart
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';

// A C function that reverses bytes in-place
// void reverse_bytes(uint8_t* buffer, int32_t length);
typedef _ReverseBytesNative = Void Function(Pointer<Uint8> buf, Int32 len);
typedef _ReverseBytesDart = void Function(Pointer<Uint8> buf, int len);

void processBuffer(Uint8List data) {
  // 1. Allocate native memory the size of our data
  final Pointer<Uint8> nativeBuffer = calloc<Uint8>(data.length);

  try {
    // 2. Copy Dart data into native memory
    // asTypedList gives a Uint8List view of native memory — no copy yet
    // setAll copies the Dart data into that native view
    nativeBuffer.asTypedList(data.length).setAll(0, data);

    // 3. Call C — it operates on the native buffer in-place
    _reverseBytes(nativeBuffer, data.length);

    // 4. Read the result back into Dart-managed memory
    final result = Uint8List.fromList(
      nativeBuffer.asTypedList(data.length),
    );

    print(result);
  } finally {
    // 5. Free — happens whether or not an exception was thrown
    calloc.free(nativeBuffer);
  }
}

The pattern has five distinct steps: allocate, copy in, call, copy out, free. Each step matters.

asTypedList — the zero-copy bridge

The copy-in/copy-out pattern above is correct but not always optimal. For large buffers — a full camera frame at 12MP is 36MB of raw pixel data — copying 36MB in, then 36MB out, then freeing, on every frame, is expensive.

asTypedList() offers a better option: a zero-copy view of native memory as a Dart TypedData object.

dart
final Pointer<Uint8> nativeBuffer = calloc<Uint8>(imageSize);

// This does NOT copy the data
// It creates a Uint8List that wraps the native pointer directly
final Uint8List view = nativeBuffer.asTypedList(imageSize);

// Reading from view reads directly from native memory
// Writing to view writes directly to native memory
view[0] = 255; // writes to nativeBuffer's first byte

// Pass view to Dart code that expects Uint8List — no copy needed
processPixels(view);

Zero copy sounds ideal. The catch is lifetime. The view wraps the native pointer directly — it has no independent existence. When you call calloc.free(nativeBuffer), the memory that view points into is gone. If anything still holds a reference to view and tries to read it after the free, you're reading freed memory. In C terms: a use-after-free bug. In Dart's FFI: no exception, just garbage data or a crash.

The safe pattern: use asTypedList for the duration of the native call, then copy to safe Dart memory before freeing:

dart
final result = Uint8List.fromList(
  nativeBuffer.asTypedList(imageSize), // view into native memory
  // ↑ Uint8List.fromList copies the data into Dart-managed memory
);
calloc.free(nativeBuffer); // now safe to free — result is independent

Or, for processing pipelines where you need zero-copy performance, keep the native buffer alive for as long as the view is in use — and be disciplined about the lifetime. This is safe but requires care:

dart
class NativeImageBuffer {
  final Pointer<Uint8> _ptr;
  final int length;
  late final Uint8List view;

  NativeImageBuffer(this.length) : _ptr = calloc<Uint8>(length) {
    view = _ptr.asTypedList(length);
  }

  void dispose() {
    calloc.free(_ptr);
    // After this point, 'view' must never be accessed
  }
}

This is a manual dispose() pattern — exactly like Flutter's AnimationController or StreamSubscription. And for the same reason: native resources that the GC doesn't know how to clean up.

The ownership question — the contract C can't express in types

In Dart, when a function returns an object, ownership is implicit: the GC tracks it. Nobody "owns" objects in a meaningful sense — anything holding a reference keeps the object alive, anything not holding one lets it die.

In C, ownership is explicit, critical, and communicated entirely through documentation and convention. Because if you free memory that someone else owns, you corrupt the heap. If you don't free memory that you own, it leaks. The types don't tell you anything about ownership. The docs do — when they exist.

There are three ownership models you'll encounter when wrapping C libraries with FFI:

Caller-allocated. You allocate the memory, pass a pointer to C, C fills it in, you own it and must free it.

dart
// C API: void get_version(char* buffer, int32_t size)
// Convention: caller provides the buffer, C fills it
final buffer = calloc<Uint8>(256);
try {
  _getVersion(buffer.cast<Utf8>(), 256);
  print(buffer.cast<Utf8>().toDartString());
} finally {
  calloc.free(buffer); // we allocated it, we free it
}

Callee-allocated. C allocates the memory and returns a pointer. You own the returned pointer and must free it — but often with a specific function C provides, not with calloc.free.

dart
// C API: char* get_error_message()  — C allocates, returns pointer to you
// Convention (hypothetical): caller must call free_message() when done
final msgPtr = _getErrorMessage();
try {
  print(msgPtr.toDartString());
} finally {
  _freeMessage(msgPtr); // C's free function, not calloc.free
}

This is common with C libraries that have their own memory allocator. You must use the library's free function — not the system free() — because the library might have allocated from its own pool.

Callee-owned. C returns a pointer to memory it owns and manages. You read it, you never free it. The pointer is valid only for a defined lifetime (until the next call, until the library is closed, etc.).

dart
// C API: const char* get_library_name()
// Convention: returns a static string, do NOT free
final namePtr = _getLibraryName();
final name = namePtr.toDartString();
// Do NOT free namePtr — it points to a string literal in C's data segment

The type signature Pointer<Utf8> tells you nothing about which of these three models applies. The documentation tells you. Read the documentation before writing a calloc.free. When there's no documentation, read the source.

If you free memory you don't own: heap corruption, likely a crash, possibly a security vulnerability. If you fail to free memory you do own: a memory leak. If you use a C library's free function on memory you allocated with calloc: undefined behavior. The type system cannot catch any of these. Only understanding the contract can.

The Arena allocator — grouping allocations safely

Functions that make multiple native allocations face a discipline problem. Five calloc calls means five calloc.free calls in the finally block. Miss one during refactoring and you've introduced a leak that won't show up in tests.

The Arena allocator from package:ffi solves this elegantly:

dart
import 'package:ffi/ffi.dart';

void multipleAllocations(String name, String address, int age) {
  using((Arena arena) {
    // All allocations through the arena are freed when 'using' exits
    final nativeName = name.toNativeUtf8(allocator: arena);
    final nativeAddress = address.toNativeUtf8(allocator: arena);
    final ageBuffer = arena<Int32>();

    ageBuffer.value = age;

    // All three are alive here
    _processRecord(nativeName, nativeAddress, ageBuffer);

    // When 'using' exits — normally or via exception — everything is freed
  }); // ← all three freed here automatically
}

using() creates an arena, passes it to your callback, and calls arena.releaseAll() when the callback exits — whether it returned normally or threw an exception. No try/finally needed. No missed frees.

You can use the arena as an allocator for any native allocation by passing allocator: arena:

dart
using((arena) {
  // Manual allocation through the arena
  final buffer = arena<Uint8>(1024);

  // toNativeUtf8 with arena allocator
  final str = 'hello'.toNativeUtf8(allocator: arena);

  // struct through the arena
  final dims = arena<ImageDimensions>();

  // All freed when using() exits
});

For functions with a single allocation, try/finally is clear and explicit. For functions with three or more, the arena makes the cleanup contract impossible to miss. Use whichever makes the code safer at the call site.

A complete example: a text processing library

Let's build a realistic end-to-end example: wrapping a C library that processes strings. This covers the full cycle — Dart String in, C processing, Dart String back out — with correct ownership and cleanup.

The C library

c
// native/src/text_processor.c
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include <stdint.h>

// Count occurrences of a character in a string
// Caller provides str, which is caller-owned
int32_t count_char(const char* str, char target) {
    int32_t count = 0;
    while (*str) {
        if (*str == target) count++;
        str++;
    }
    return count;
}

// Return a new uppercase copy of the input string
// OWNERSHIP: callee-allocated — caller must free the returned pointer
// using free_string() below
char* to_uppercase(const char* input) {
    size_t len = strlen(input);
    char* result = (char*)malloc(len + 1); // +1 for null terminator
    if (!result) return NULL;

    for (size_t i = 0; i < len; i++) {
        result[i] = (char)toupper((unsigned char)input[i]);
    }
    result[len] = '\0';
    return result;
}

// Free a string allocated by this library
// MUST be called for any pointer returned by to_uppercase()
void free_string(char* str) {
    free(str);
}

Two important ownership conventions above: count_char takes a string it doesn't own (reads only, never frees). to_uppercase allocates a new string and returns ownership to the caller — who must call free_string when done, not calloc.free, because the library used malloc.

The Dart bindings

dart
// lib/src/text_processor.dart
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';

// Native signatures
typedef _CountCharNative = Int32 Function(Pointer<Utf8> str, Uint8 target);
typedef _ToUppercaseNative = Pointer<Utf8> Function(Pointer<Utf8> input);
typedef _FreeStringNative = Void Function(Pointer<Utf8> str);

// Dart signatures
typedef _CountCharDart = int Function(Pointer<Utf8> str, int target);
typedef _ToUppercaseDart = Pointer<Utf8> Function(Pointer<Utf8> input);
typedef _FreeStringDart = void Function(Pointer<Utf8> str);

class TextProcessor {
  static final DynamicLibrary _lib = Platform.isAndroid
      ? DynamicLibrary.open('libtext_processor.so')
      : DynamicLibrary.process();

  static final _CountCharDart _countChar =
      _lib.lookupFunction<_CountCharNative, _CountCharDart>('count_char');

  static final _ToUppercaseDart _toUppercase =
      _lib.lookupFunction<_ToUppercaseNative, _ToUppercaseDart>('to_uppercase');

  static final _FreeStringDart _freeString =
      _lib.lookupFunction<_FreeStringNative, _FreeStringDart>('free_string');

  /// Count occurrences of [char] in [text].
  static int countChar(String text, String char) {
    assert(char.length == 1, 'char must be a single character');

    return using((arena) {
      final nativeText = text.toNativeUtf8(allocator: arena);
      final targetByte = char.codeUnitAt(0); // ASCII value of the character
      return _countChar(nativeText, targetByte);
      // arena frees nativeText on exit
    });
  }

  /// Return [text] converted to uppercase.
  static String toUppercase(String text) {
    // We need two separate memory regions:
    // 1. The input string — we allocate, we free (via arena)
    // 2. The output string — C allocates, we free (via _freeString)
    // We can't use a single arena for both because they have different
    // free functions.

    Pointer<Utf8>? resultPtr;

    using((arena) {
      final nativeInput = text.toNativeUtf8(allocator: arena);
      resultPtr = _toUppercase(nativeInput);
      // arena frees nativeInput here
    });

    // resultPtr is now the only live native allocation
    // Convert before freeing — toDartString copies into Dart-managed memory
    try {
      if (resultPtr == null || resultPtr!.address == 0) {
        throw StateError('to_uppercase returned null — out of memory?');
      }
      return resultPtr!.toDartString();
    } finally {
      if (resultPtr != null && resultPtr!.address != 0) {
        _freeString(resultPtr!); // use the library's free, not calloc.free
      }
    }
  }
}

Notice the subtlety in toUppercase: the input and output strings have different lifetimes and different free functions. They can't share an arena. The input is freed by the arena when using() exits. The output is freed by _freeString after we've copied it into a Dart string. The sequencing matters — we must call toDartString() (which copies) before _freeString (which frees the native memory).

Using it from Flutter

dart
// In a widget or a service
void demo() {
  final text = 'Hello, Flutter FFI world!';

  final eCount = TextProcessor.countChar(text, 'l');
  print('Found $eCount occurrences of l'); // 3

  final upper = TextProcessor.toUppercase(text);
  print(upper); // 'HELLO, FLUTTER FFI WORLD!'
}

Clean Dart API. No pointers visible to the caller. No memory management at the call site. The complexity is contained inside TextProcessor, where it belongs.

The danger zone: mixing GC memory and native memory

One mistake deserves a dedicated section because it's easy to make and silent in its failure.

Never let C hold a pointer to a Dart object's interior.

If you get a pointer to the contents of a Dart String or List (which is technically possible via Uint8List.fromList and then trying to get a native address), and you pass that to C, and then the GC runs a compacting collection — it can move that Dart object. The address C is holding is now wrong. C is reading or writing to freed or repurposed memory.

In Flutter's debug mode, this might produce an assertion. In release mode, it produces silent data corruption or a crash with no useful stack trace.

The rule is absolute: data going to C lives in native memory, allocated with `calloc` or `malloc`, at a fixed address the GC will never touch. The toNativeUtf8() and calloc<T>() calls do exactly this. Use them. Never try to pass Dart memory addresses to C.

Similarly: never let Dart hold a `Pointer<T>` to native memory after that memory is freed. After calloc.free(ptr), the pointer's address is meaningless. Reading from or writing to it is a use-after-free. Dart won't throw an exception — it'll read whatever bytes happen to be at that address now, which might belong to a completely different allocation.

These aren't warnings. They're the class of bugs that produces crashes that look completely unrelated to the actual cause, because the corruption happened somewhere else in memory from where it manifests.

Connecting back to what we know

Something worth pausing on: the ownership pattern in FFI is the same instinct as dispose() in Flutter widgets, and cancel() on a StreamSubscription, and close() on a file handle.

In Post 3 of the Flutter Under the Hood series, we said: dispose() is not freeing memory — it's severing references so the GC can free memory. In FFI, calloc.free() is freeing memory. The GC genuinely cannot do it. But the discipline is identical: when you acquire a resource with a lifetime, you are responsible for releasing it. Whether "releasing" means severing a reference or calling free() is an implementation detail. The contract is the same.

What FFI adds is that the consequences are more severe. A forgotten StreamSubscription.cancel() leaks a widget subtree — bad, but debuggable. A forgotten calloc.free() leaks native memory — worse, because it's completely invisible to Flutter's tooling. DevTools' memory tab shows the Dart heap. It knows nothing about native allocations.

For native memory leaks, the tool is the OS — adb shell dumpsys meminfo on Android, Instruments' Allocations profiler on iOS. These show process-level memory consumption and can surface native heap growth that DevTools can't see.

What comes next

In this article, we gave you the two hardest concepts in dart:ffi: pointers (the type and the arithmetic), and ownership (the contract that C can't express in types).

Strings and byte buffers — Pointer<Utf8> and Pointer<Uint8> — are what most real FFI code uses 80% of the time. The Arena allocator makes multi-allocation functions safe. The asTypedList zero-copy view is the performance tool for buffer-heavy workloads.

Post 3 takes the next step: structs — how C's compound data types map to Dart, what field alignment and padding mean (and why mismatching them produces silent data corruption rather than exceptions), and how to pass and return structs across the boundary efficiently.

Post 4 flips the direction entirely: instead of Dart calling C, we'll have C calling Dart. NativeCallable — the mechanism for registering a Dart function as a C callback — and the thread safety story that comes with it when C decides to call from a thread the Dart runtime has never heard of.

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.