Ankit Ranjan
Back to Deep Dives

Dart FFI — Calling Native Code and Breaking Out of the VM

When Dart's sandbox isn't enough. How to call C libraries, allocate native memory, and bridge the gap between managed and unmanaged code.

May 24, 2026 7 topics 7 quiz questions
Share:
1

Why FFI exists — when Dart isn't enough

Dart runs inside a virtual machine. The VM handles memory, checks types, and keeps us safe from the kind of bugs that crash airplanes. That's good. But sometimes we need to escape.

The four reasons to reach for FFI:

1. Existing C libraries. Decades of battle-tested code exists in C — compression (zlib), encryption (OpenSSL), image processing (libpng), databases (SQLite). Rewriting them in Dart would take years and introduce bugs. FFI lets us call them directly.

2. Performance-critical code. The Dart VM is fast, but C is faster. For tight loops processing millions of data points — audio DSP, physics simulations, machine learning inference — the overhead of garbage collection and bounds checking matters. Native code skips all of it.

3. Platform APIs. Operating systems expose their functionality through C. Want to read battery level on iOS, enumerate Bluetooth devices on Android, or call Windows system APIs? The OS speaks C. FFI translates.

4. Hardware access. Sensors, GPUs, USB devices — they all have C drivers. To talk to a barcode scanner or control a robotic arm, we go through native code.

The FFI bridge — managed to unmanagedDart World(managed memory, type-safe)Your Dart codedart:ffiPointer<T>NativeType wrappersFFI BoundaryType marshallingPointer conversionNative World(raw memory, no safety net)C library (.so/.dylib/.dll)System APIsHardware driversRaw pointersdart:ffi provides zero-copy interop — Dart and C share the same memory.

The trade-off. FFI gives us power, but it takes away safety. Inside the Dart VM, null pointer access throws an exception. In native code, it crashes the process. Memory leaks are impossible in pure Dart — the garbage collector handles everything. In FFI, we allocate and free manually. One mistake and we leak gigabytes.

// Pure Dart — safe, managed, automatic
var list = [1, 2, 3];   // GC will clean this up

// FFI — manual memory management, no safety net
final ptr = calloc<Int32>(3);   // we own this memory
// ... use it ...
calloc.free(ptr);                // forget this = memory leak
That's the deal. FFI lets us escape the sandbox, but we become responsible for everything the sandbox was protecting us from.

2

Loading native libraries — DynamicLibrary and platform differences

Before we can call any C function, we need to load the library that contains it. The DynamicLibrary class handles this, but the file extension depends on the platform.

Native library extensions by platformmacOS / iOS.dyliblibsqlite3.dylibLinux / Android.solibsqlite3.soWindows.dllsqlite3.dllDynamicLibrary.open('libfoo.so') // LinuxDynamicLibrary.open('libfoo.dylib') // macOSDynamicLibrary.open('foo.dll') // Windows (no 'lib' prefix)

Loading a library at runtime.

import 'dart:ffi';
import 'dart:io' show Platform;

DynamicLibrary loadLibrary() {
  if (Platform.isMacOS || Platform.isIOS) {
    return DynamicLibrary.open('libsqlite3.dylib');
  } else if (Platform.isAndroid || Platform.isLinux) {
    return DynamicLibrary.open('libsqlite3.so');
  } else if (Platform.isWindows) {
    return DynamicLibrary.open('sqlite3.dll');
  }
  throw UnsupportedError('Unsupported platform');
}

void main() {
  final lib = loadLibrary();
  print('Library loaded: $lib');
}
DynamicLibrary.process() gives access to symbols already loaded in the current process — useful for system libraries that are linked at compile time.

DynamicLibrary.executable() returns symbols from the main executable itself — used when native code is statically linked into the app.

Bundling with Flutter. For Flutter apps, native libraries go in specific directories per platform:

// iOS: ios/Frameworks/libfoo.framework
// Android: android/app/src/main/jniLibs/<arch>/libfoo.so
// macOS: macos/Frameworks/libfoo.dylib
// Linux: linux/libs/libfoo.so
// Windows: windows/libs/foo.dll
Common errors. The most frequent mistake is a path issue. DynamicLibrary.open() throws ArgumentError if the library isn't found. On Android, the path must be just the filename — Android's linker searches the right directories. On desktop, we often need an absolute path or to place the library in the working directory.

// Wrong on Android — full paths don't work
DynamicLibrary.open('/data/app/libfoo.so');  // fails

// Right on Android — just the filename
DynamicLibrary.open('libfoo.so');  // linker finds it

// Desktop — relative to working directory, or absolute path
DynamicLibrary.open('./libs/libfoo.so');

3

Pointers and memory — the heart of FFI

In Dart, we never think about memory addresses. The VM handles allocation, moves objects around during GC, and cleans up when we're done. In FFI land, we work with raw pointers — fixed addresses in memory that don't move and don't get cleaned up automatically.

Pointer<Int32> — a raw memory addressDart sidePointer<Int32>ptr.address = 0x7fff5678points toNative memory (heap)420x7fff56781000x7fff567C-70x7fff56802550x7fff5684......4 bytes per Int32, contiguous in memoryAllocating memoryfinal ptr = calloc<Int32>(4);// allocates 4 Int32s (16 bytes), zeroedptr[0] = 42; // write to first slotptr[2] = -7; // write to third slotFreeing memory (required!)calloc.free(ptr);// Must call this when done// Forget = memory leak// Use after free = crash

Pointer<T> is the core type. The type parameter tells us what's at that address.

import 'dart:ffi';
import 'package:ffi/ffi.dart';  // for calloc/malloc

void main() {
  // Allocate space for one Int32
  final ptr = calloc<Int32>();
  ptr.value = 42;
  print(ptr.value);  // 42
  print(ptr.address); // something like 140732920829952

  // Allocate an array of 10 Int32s
  final array = calloc<Int32>(10);
  array[0] = 100;
  array[9] = 999;

  // Don't forget to free!
  calloc.free(ptr);
  calloc.free(array);
}
calloc vs malloc. Both allocate memory. calloc zeroes the memory first; malloc leaves it uninitialised (faster, but we must write before reading). Both come from package:ffi.

nullptr — the null pointer. In C, null is just the address 0. Dart FFI represents this with nullptr. Dereferencing it crashes.

Pointer<Int32> ptr = nullptr;
print(ptr.address);  // 0
// ptr.value;  // CRASH — null pointer dereference
Pointer arithmetic. We can move a pointer forward or backward with elementAt() or array indexing.

final array = calloc<Int32>(5);
final third = array.elementAt(2);  // pointer to array[2]
third.value = 777;
print(array[2]);  // 777
The Arena pattern — automatic cleanup. Manual free calls are error-prone. The Arena allocator collects all allocations and frees them at once when we're done.

import 'package:ffi/ffi.dart';

void processData() {
  using((Arena arena) {
    final buffer = arena<Uint8>(1024);
    final name = arena<Utf8>();
    // ... use buffer and name ...
    // No need to free — Arena handles it when the block exits
  });
}

4

Native types and marshalling — bridging two type systems

Dart and C have different type systems. An int in Dart is arbitrary precision (on the VM). An int in C is usually 32 bits. FFI bridges this with NativeType — a hierarchy of classes that mirror C's primitive types.

NativeType — mapping C types to DartC typedart:ffi NativeTypeDart equivalentSizeint8_t / charInt8int1 byteint16_t / shortInt16int2 bytesint32_t / intInt32int4 bytesint64_t / long longInt64int8 bytesfloatFloatdouble4 bytesdoubleDoubledouble8 bytesvoid*Pointer<Void>Pointer<Void>platform wordchar* (string)Pointer<Utf8>String (convert)null-terminatedNativeType is only used in type annotations — we never instantiate these classes directly.

The key insight: NativeType classes (Int32, Double, etc.) are compile-time markers. We don't create instances of them. We use them as type parameters: Pointer<Int32>, Pointer<Double>.

Strings require conversion. Dart strings are UTF-16 internally. C strings are typically UTF-8, null-terminated. The package:ffi helpers handle conversion.

import 'package:ffi/ffi.dart';

// Dart String → Native UTF-8
final nativeString = 'Hello, FFI!'.toNativeUtf8();
print(nativeString.address);  // raw pointer address

// Native UTF-8 → Dart String
String dartString = nativeString.toDartString();
print(dartString);  // 'Hello, FFI!'

// Don't forget to free the allocated memory
calloc.free(nativeString);
Arrays. C arrays are just contiguous memory. We access them through pointer indexing.

final arr = calloc<Float>(100);
for (var i = 0; i < 100; i++) {
  arr[i] = i * 1.5;
}
print(arr[50]);  // 75.0
calloc.free(arr);
Unsigned types. For unsigned integers, use Uint8, Uint16, Uint32, Uint64. Dart's int is signed, so values get reinterpreted as unsigned when crossing the boundary.

5

Calling C functions — lookupFunction and signatures

We've loaded the library and understand the types. Now we call functions. The key method is lookupFunction, and it has two type parameters that often confuse newcomers.

// C function we want to call:
// int add(int a, int b);

// In Dart:
import 'dart:ffi';

// 1. Native signature — how C sees it
typedef AddNative = Int32 Function(Int32 a, Int32 b);

// 2. Dart signature — how Dart sees it
typedef AddDart = int Function(int a, int b);

void main() {
  final lib = DynamicLibrary.open('libmath.so');

  // Look up the function, casting between signatures
  final add = lib.lookupFunction<AddNative, AddDart>('add');

  // Now call it like a normal Dart function
  print(add(40, 2));  // 42
}
Why two type parameters? The native signature uses FFI types (Int32, Float, Pointer) — these describe the C ABI. The Dart signature uses Dart types (int, double, Pointer) — these are what we actually work with in Dart code. The two must match in structure, but use different type vocabularies.

lookupFunction<NativeSignature, DartSignature>Native Signature (C ABI)Int32 Function(Int32 a, Int32 b)Uses NativeType: Int32, Float, Pointer<X>Dart Signature (Dart types)int Function(int a, int b)Uses Dart types: int, double, Pointer<X>maps tofinal add = lib.lookupFunction<AddNative, AddDart>('add');Returns a callable Dart function that invokes the native code

Calling functions that take pointers. Many C APIs pass data through pointers. We allocate memory, pass the pointer, and read the result.

// C: void get_version(int* major, int* minor);
typedef GetVersionNative = Void Function(Pointer<Int32>, Pointer<Int32>);
typedef GetVersionDart = void Function(Pointer<Int32>, Pointer<Int32>);

void main() {
  final lib = DynamicLibrary.open('libfoo.so');
  final getVersion = lib.lookupFunction<GetVersionNative, GetVersionDart>('get_version');

  // Allocate two integers to receive the output
  final major = calloc<Int32>();
  final minor = calloc<Int32>();

  getVersion(major, minor);
  print('Version: ${major.value}.${minor.value}');

  calloc.free(major);
  calloc.free(minor);
}
Error handling from C. C functions often return error codes. We check them and throw Dart exceptions.

// C: int open_file(const char* path);  // returns -1 on error
final result = openFile(path);
if (result < 0) {
  throw FileSystemException('Failed to open file');
}
Variadic functions. C's variadic functions (printf, etc.) require special handling — they need @FfiNative annotations or generated bindings. Manual FFI can't handle them directly because the ABI varies by argument count.

6

Callbacks — when C calls Dart

Sometimes the data flows the other way. A C library calls our Dart code — for progress updates, event notifications, or custom comparison functions. This is where callbacks come in.

Callback flow — C calling DartDart functionvoid onProgress(int pct){ print('$pct%'); }1. wrapNativeCallable.isolateLocal()Pointer to trampoline2. passC librarydoWork(callback)stores the pointer3. callcallback(50)invokes trampoline4. executes Dart codeonProgress(50)prints "50%"The NativeCallable wraps the Dart function in a native-callable trampoline.

NativeCallable creates a native function pointer that, when called from C, invokes a Dart function.

import 'dart:ffi';

// The C function type for our callback
typedef ProgressCallback = Void Function(Int32 percent);

// The Dart function we want C to call
void onProgress(int percent) {
  print('Progress: $percent%');
}

void main() {
  // Wrap the Dart function as a native callable
  final callback = NativeCallable<ProgressCallback>.isolateLocal(
    onProgress,
  );

  // Get the pointer to pass to C
  final ptr = callback.nativeFunction;

  // Pass ptr to the C library...
  // When C calls ptr(50), onProgress(50) runs in Dart

  // When done, close the callable to free resources
  callback.close();
}
isolateLocal vs listener. NativeCallable.isolateLocal creates a callback that must be called from the same isolate. NativeCallable.listener creates one that can be called from any thread — the call gets queued to the isolate's event loop.

The closure limitation. Callbacks passed to C cannot be closures that capture local variables. The underlying mechanism doesn't support carrying extra state. If we need state, we must use a global or static variable, or pass user data through a void* parameter that C passes back to us.

// This won't work — captures 'prefix'
// void logWithPrefix(String prefix) {
//   final callback = NativeCallable<...>.isolateLocal(
//     (int n) => print('$prefix: $n'),  // closure!
//   );
// }

// Instead, use a top-level or static function
void onEvent(int code) {
  print('Event: $code');
}

final callback = NativeCallable<...>.isolateLocal(onEvent);
Pointer.fromFunction — the older approach. Before NativeCallable, we used Pointer.fromFunction. It still works but has limitations: the function must be a top-level or static function (not a lambda), and we must provide an exceptionalReturn value for error cases.

static int compare(int a, int b) => a - b;

void main() {
  // exceptionalReturn is returned if the Dart code throws
  final ptr = Pointer.fromFunction<Int32 Function(Int32, Int32)>(
    compare,
    0,  // exceptionalReturn value
  );
}

7

Structs — complex data across the boundary

Real C APIs don't just pass integers. They pass structs — compound types with multiple fields. Dart FFI supports these through the Struct base class.

Struct memory layout — Point and RectC struct:typedef struct {double x;double y;} Point;Dart equivalent:final class Point extends Struct {@Double()external double x;@Double() external double y;}Memory layout (16 bytes total):x: 3.141598 bytes (Double)offset 0y: 2.718288 bytes (Double)offset 8next struct orpaddingoffset 16

Defining a struct in Dart.

import 'dart:ffi';

final class Point extends Struct {
  @Double()
  external double x;

  @Double()
  external double y;
}

void main() {
  // Allocate a Point
  final p = calloc<Point>();
  p.ref.x = 3.14;
  p.ref.y = 2.71;

  print('Point: (${p.ref.x}, ${p.ref.y})');

  calloc.free(p);
}
Key rules for structs:
• The class must be final and extend Struct
• Fields must be external — they're backed by native memory
• Each field needs a type annotation: @Int32(), @Double(), @Pointer(), etc.
• Use .ref to access the struct through a pointer

Nested structs. Structs can contain other structs.

final class Rect extends Struct {
  external Point topLeft;
  external Point bottomRight;
}

void main() {
  final rect = calloc<Rect>();
  rect.ref.topLeft.x = 0;
  rect.ref.topLeft.y = 0;
  rect.ref.bottomRight.x = 100;
  rect.ref.bottomRight.y = 50;

  calloc.free(rect);
}
Passing structs by value vs by pointer. Some C functions take structs by value (the whole struct is copied onto the stack). Others take pointers. FFI supports both.

// C: double distance(Point a, Point b);  // by value
typedef DistanceNative = Double Function(Point a, Point b);
typedef DistanceDart = double Function(Point a, Point b);

// C: void scale(Point* p, double factor);  // by pointer
typedef ScaleNative = Void Function(Pointer<Point>, Double);
typedef ScaleDart = void Function(Pointer<Point>, double);
Arrays in structs. Fixed-size arrays use the @Array annotation.

final class Matrix3x3 extends Struct {
  @Array(9)
  external Array<Float> values;
}

void main() {
  final m = calloc<Matrix3x3>();
  m.ref.values[0] = 1.0;  // identity diagonal
  m.ref.values[4] = 1.0;
  m.ref.values[8] = 1.0;
  calloc.free(m);
}
Packed structs. Some C structs use #pragma pack to remove padding. Dart supports this with @Packed.

@Packed(1)  // no padding between fields
final class CompactData extends Struct {
  @Uint8()
  external int flags;

  @Uint32()
  external int value;
}
// Total size: 5 bytes (not 8 with default alignment)

Test your understanding

7 questions

Seven questions covering Dart FFI — from loading libraries to calling native code and managing memory.

Search

Loading search...