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.
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 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.
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.
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');
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<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
});
}
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.
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.
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.
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.
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.
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
);
}
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.
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.