Sitemap
Flutter Community

Articles and Stories from the Flutter Community

Beyond Dart: Tapping into C’s Power with FFI

13 min readJun 27, 2025

--

Press enter or click to view image in full size

Hey, awesome devs in the world! Yes, I know it’s been a decade since I last wrote an article, which was a couple of years ago (I think it was in February 2023, idk). Anyway, today’s article is not gonna be something tedious but rather an exciting adventure of diving deeper into Dart’s FFI.

Note: I'm gonna make this article as plain and simple as possible, but keep in mind that some of the discussed topics are a little advanced for newcomers and everyone who hasn’t been in this field for a long!

So What’s Dart’s FFI?

FFI stands for Foreign Function Interface, and it’s Dart’s way of talking to code written in other languages, specifically C (and by extension, C++). Think of it as a universal translator that lets your Dart code call functions from C libraries as if they were native Dart functions.

Why would you want this? Well, sometimes you need that extra performance boost that only native code can provide, or maybe there’s already a battle-tested C library that does exactly what you need. Instead of rewriting everything from scratch in Dart, FFI lets you leverage existing native code.

The magic happens through Dart’s dart:ffi library, which handles all the heavy lifting of converting Dart types to C types and back again. You define the C function signatures, load the native library, and then call those functions directly from your Dart code.

Quick note for Java developers: If this concept sounds familiar, that’s because Java has something similar! Java’s JNI (Java Native Interface) serves the same purpose, allowing Java code to call native C/C++ functions. So if you’ve worked with JNI before, you’ll find Dart’s FFI follows many of the same principles, just with Dart’s cleaner syntax.

Ok! Now that we have an understanding of Dart’s FFI, let’s discuss what we are about to build and explain together, shall we?

Let’s build an entire advanced app with Dart’s FFI!

No, I’m just kidding (tho we could!). But for demonstration purposes, we’re going to build something practical and fun: a simple Dart console CLI tool that showcases the power of FFI in action.

Our little CLI will accept a single argument (the path to a text file), and here’s where things get interesting. Instead of using Dart’s built-in file reading capabilities, we’re going to take the scenic route and call native C functions to handle the file operations. The C code will read the file’s content and pass it back to our Dart application, where we can then process, display, or manipulate the data however we want.

Think of it as a “Hello World” for FFI, but with actual real-world utility. You’ll see firsthand how data flows between Dart and C, how to handle memory management across language boundaries, and how surprisingly smooth the integration can be once you know the tricks.

By the end of this tutorial, you’ll have a working CLI tool and, more importantly, the knowledge to integrate any C library into your Dart projects. Ready to get your hands dirty with some cross-language magic?

Why choose C?

First, you might be wondering, “Why C specifically? What about C++, Rust, Go, or other languages?” Great question tbh!

Most operating systems expose their APIs through C interfaces, and virtually every programming language knows how to talk to C libraries. It’s the common denominator that everyone agrees on.

Here’s why C makes perfect sense for FFI:

  • Simplicity and Predictability: C has a straightforward calling convention and memory layout. No hidden constructors, destructors, or complex object hierarchies to worry about. What you see is what you get.
  • Universal Compatibility: Almost every library, even if written in C++, Go, or Rust, can expose a C-compatible interface. It’s like the USB-C of programming languages.
  • Performance: C gives you direct control over memory and system resources without any runtime overhead. When you need speed, C delivers.
  • Mature Ecosystem: Decades of battle-tested libraries are available, from image processing to cryptography to system utilities.

Now technically, you can use C++ or Go with Dart FFI, but you’ll need to wrap your C++ code with C-compatible functions anyway. So why not just cut out the middleman and embrace the beautiful simplicity of C?

Project setup!

As I said earlier, I’m gonna make it as simple as possible, so go ahead and open your VS Code (or any IDE you prefer), then open up the terminal and create a Dart project with console template type selected:

dart create dart_io_ffi --template=console . && code -r dart_io_ffi

Now, let’s add a few dependencies to our project in the pubspec.yaml file:

  • ffi: Utilities for working with Foreign Function Interface (FFI) code.
  • path: A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web.

You can either add these dependencies by hand or you can add them by running the following in the terminal:

dart pub add ffi && dart pub add path

Then, the pubspec.yaml file should contain these dependencies:

name: dart_io_ffi
description: A sample command-line application.
version: 1.0.0

environment:
sdk: ^3.5.3

dependencies:
ffi: ^2.1.0 # ADDED
path: ^1.8.0 # ADDED

dev_dependencies:
lints: ^4.0.0

You can go ahead and try to make the project look like this or in any order you prefer (just create the necessary folders and files), the actual code will be explained later on:

dart_io_ffi/
├── bin/
│ └── main.dart # Console application entry point
├── lib/
│ └── native_file_reader.dart # Dart FFI bindings
├── native/
│ ├── file_reader.c # C implementation
│ └── file_reader.h # C header file
├── build.sh # Build script (make sure to add this)
├── pubspec.yaml # Dart dependencies
└── README.md # Project documentation

Let’s write the C code

Inside the native directory, open up the file_reader.h header file that you created earlier, and write the following code:

#ifndef FILE_READER_H
#define FILE_READER_H

#ifdef __cplusplus
extern "C"
{
#endif

char *read_file(const char *file_path);
void free_string(char *str);
int file_exists(const char *file_path);

#ifdef __cplusplus
}
#endif

#endif

Looking at the code, we can see that we have 3 different functions that we’re going to implement later on.

But besides that, we’re also checking whether the code is compiled by a C++ compiler or just C. If using C++, then we need to wrap our code with the extern "C". This tells the C++ compiler to use C-style linkage (no name mangling) for the enclosed functions.

C++ compilers “mangle” function names to support function overloading, while C compilers don’t. Without this wrapper, C++ would mangle the function names (e.g., read_file might become something like _Z9read_filePKc), making them impossible to find from external languages that expect the original C function names.

Now, inside the native directory, let’s implement the file_reader.h by writing some code in the file_reader.c file:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

char *read_file(const char *file_path)
{
if (file_path == NULL)
{
return NULL;
}

int fd = open(file_path, O_RDONLY);
if (fd == -1)
{
return NULL;
}

struct stat file_stat;
if (fstat(fd, &file_stat) == -1)
{
close(fd);
return NULL;
}

size_t file_size = file_stat.st_size;
char *buffer = malloc(file_size + 1);
if (buffer == NULL)
{
close(fd);
return NULL;
}

ssize_t bytes_read = read(fd, buffer, file_size);
close(fd);

if (bytes_read == -1)
{
free(buffer);
return NULL;
}

buffer[bytes_read] = '\0';

return buffer;
}

void free_string(char *str)
{
if (str != NULL)
{
free(str);
}
}

int file_exists(const char *file_path)
{
if (file_path == NULL)
{
return 0;
}

return access(file_path, F_OK) == 0 ? 1 : 0;
}

Function Explanations

char *read_file(const char *file_path)

This function reads the entire contents of a file and returns it as a null-terminated string.

Step-by-step process:

  1. Input validation: Checks if file_path is NULL
  2. File opening: Opens the file in read-only mode using open()
  3. File size determination: Uses fstat() to get file statistics and determine the file size
  4. Memory allocation: Allocates a buffer with a size file_size + 1 (extra byte for null terminator)
  5. File reading: Reads the entire file content into the buffer using read()
  6. Null termination: Adds a null terminator at the end of the buffer
  7. Error handling: Returns NULL on any error and properly cleans up resources
  8. Return: Returns the allocated buffer containing the file contents

void free_string(char *str)

This is a utility function to safely free memory allocated by read_file().

Purpose:

  • Provides a safe way to deallocate strings returned by read_file()
  • Includes NULL check to prevent crashes if called with NULL pointer
  • Essential for preventing memory leaks when using the file reading functionality

int file_exists(const char *file_path)

This function checks whether a file exists at the given path.

How it works:

  1. Input validation: Checks if file_path is NULL
  2. File existence check: Uses access() system call with F_OK flag to test file existence
  3. Return value: Returns 1 (true) if the file exists, 0 (false) if it doesn’t exist, or path is NULL

Key features of the implementation

  • Uses low-level system calls (open, read, fstat) for efficient file operations
  • Proper error handling with resource cleanup
  • Memory management with explicit allocation and deallocation functions
  • Cross-platform compatibility (works on Unix-like systems)

Let’s write and run our build script

In the root directory, open up the build.sh file, and write the following code:

#!/bin/bash

echo "Building native library..."
cd native
mkdir -p build

gcc -shared -fPIC -o build/libfile_reader.dylib file_reader.c
echo "Build completed!"

if [ -f "build/libfile_reader.dylib" ]; then
echo -e "\nLibrary built successfully: native/build/libfile_reader.dylib"
else
echo -e "\nLibrary build failed"
exit 1
fi

The build.sh file is a Bash script that compiles the C source code into a shared library for use with FFI (Foreign Function Interface). Here's what each part does:

Script Breakdown

Directory Navigation:

  • cd native - Changes to the native directory containing the C source files
  • mkdir -p build - Creates a build directory if it doesn't exist (-p flag prevents errors if the directory already exists)

Compilation Command:

gcc -shared -fPIC -o build/libfile_reader.dylib file_reader.c
  • gcc - GNU Compiler Collection (C compiler)
  • -shared - Creates a shared library instead of an executable
  • -fPIC - Generates Position Independent Code (required for shared libraries)
  • -o build/libfile_reader.dylib - Specifies output file name and location
  • file_reader.c - Input source file to compile

Build Verification:

  • Checks if the compiled library file exists
  • Prints a success message with the library path if the build succeeded
  • Exits with error code 1 if the build failed

Key Points:

  • Platform-specific: Uses .dylib extension (macOS dynamic library format)
  • FFI-ready: The shared library can be loaded by other languages (like Dart/Flutter) using FFI
  • Error handling: Includes basic error checking and appropriate exit codes
  • Clean output: Provides clear feedback about build status

This script automates the process of compiling the C code into a format that can be used with Flutter’s FFI plugin.

Note: In order to keep things as simple as possible, I used a simple bash script to compile the source code into .dylib file which is commonly used on macOS and iOS (this is because I use a macOS based computer), but in real world projects you would need to use build systems like “CMake” which helps you manage complex C/C++ projects across different platforms and compilers.

Now, run the build script in the terminal (make sure to set the proper permissions for the build.sh file)

./build.sh

And you should be able to see the generated library file inside the native/build directory.

Dart’s FFI bindings

At this point, all we’ve left with is to write the Dart bindings code. Let’s head to the lib directory and open up the native_file_reader.dart file:

import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:path/path.dart' as path;

typedef ReadFileC = Pointer<Utf8> Function(Pointer<Utf8>);
typedef ReadFileDart = Pointer<Utf8> Function(Pointer<Utf8>);

typedef FreeStringC = Void Function(Pointer<Utf8>);
typedef FreeStringDart = void Function(Pointer<Utf8>);

typedef FileExistsC = Int32 Function(Pointer<Utf8>);
typedef FileExistsDart = int Function(Pointer<Utf8>);

class NativeFileReader {
late final DynamicLibrary _dylib;
late final ReadFileDart _readFile;
late final FreeStringDart _freeString;
late final FileExistsDart _fileExists;

NativeFileReader() {
final libraryPath = _getLibraryPath();
_dylib = DynamicLibrary.open(libraryPath);

_readFile = _dylib
.lookup<NativeFunction<ReadFileC>>('read_file')
.asFunction<ReadFileDart>();

_freeString = _dylib
.lookup<NativeFunction<FreeStringC>>('free_string')
.asFunction<FreeStringDart>();

_fileExists = _dylib
.lookup<NativeFunction<FileExistsC>>('file_exists')
.asFunction<FileExistsDart>();
}

String _getLibraryPath() {
final current = Directory.current;
final libraryPath = path.join(
current.path,
'native',
'build',
'libfile_reader.dylib',
);
return libraryPath;
}

bool fileExists(String filePath) {
final pathPtr = filePath.toNativeUtf8();
try {
final result = _fileExists(pathPtr);
return result == 1;
} finally {
malloc.free(pathPtr);
}
}

String? readFile(String filePath) {
if (!fileExists(filePath)) {
return null;
}

final pathPtr = filePath.toNativeUtf8();
try {
final resultPtr = _readFile(pathPtr);

if (resultPtr == nullptr) {
return null;
}

final content = resultPtr.toDartString();
_freeString(resultPtr);

return content;
} finally {
malloc.free(pathPtr);
}
}
}

This is a Dart wrapper that provides a bridge between Dart code and the native C library using FFI (Foreign Function Interface). Here’s what each part does:

Type Definitions (Function Signatures)

FFI requires defining both C-style and Dart-style type signatures for each function:

  • C types: Pointer<Utf8>, Void, Int32
  • Dart types: Native Dart equivalents for actual usage

C Function Signatures:

  • ReadFileC - C signature for read_file() function
  • FreeStringC - C signature for free_string() function
  • FileExistsC - C signature for file_exists() function

Dart Function Signatures:

  • ReadFileDart, FreeStringDart, FileExistsDart - Corresponding Dart function signatures

These typedefs define how C functions map to Dart functions, specifying parameter types and return types.

NativeFileReader Class

Initialization (constructor):

  • Library Loading: DynamicLibrary.open() loads the compiled .dylib file
  • Function Binding: Uses lookup() to find C functions by name and asFunction() to bind them to Dart callable functions
  • Late Initialization: All fields are marked late final for lazy initialization

Private Methods:

_getLibraryPath() :

  • Constructs the path to the native library
  • Uses path.join() for cross-platform path handling
  • Points to native/build/libfile_reader.dylib

Publicly exposed methods:

fileExists(String filePath):

  • Converts Dart string to native UTF-8 pointer
  • Calls native file_exists() function
  • Returns boolean (true if result == 1)

readFile(String filePath):

  • First checks if the file exists using fileExists()
  • Converts a file path to a native UTF-8 pointer
  • Calls native read_file() function
  • Converts the returned native string to a Dart string
  • Returns null if the file doesn’t exist or read fails

Memory Management Strategy:

  1. Dart → C: Use toNativeUtf8() to convert Dart strings to C strings
  2. C → Dart: Use toDartString() to convert C strings back to Dart
  3. Cleanup: Always free memory in finally blocks to prevent leaks
  4. Two-tier Cleanup: Free both Dart-allocated (malloc.free()) and C-allocated (_freeString()) memory

The Console App

Our final step is to write the main entry point function, which is located inside the bin directory. Let’s open up the main.dart file and write the following:

import 'package:ffi_file_io/native_file_reader.dart';

void main(List<String> arguments) {
final fileReader = NativeFileReader();

if (arguments.isEmpty) {
print('Usage: dart run bin/main.dart <file_path>');
print('Example: dart run bin/main.dart README.md');
return;
}

final filePath = arguments[0];

print('Attempting to read file: $filePath');

if (!fileReader.fileExists(filePath)) {
print('Error: File does not exist: $filePath');
return;
}

final content = fileReader.readFile(filePath);

if (content != null) {
print('File read successfully!');
print('Content length: ${content.length} characters');
print('\n${'-' * 20} File Content ${'-' * 20}');
print(content);
} else {
print('Error: Failed to read file content');
}
}

This demonstrates how to use the native file reader functionality. Here's what each part does:

Application Flow:

1. Initialization:

  • Creates an instance of NativeFileReader to access the native C functions

2. Command-line Argument Validation:

  • Checks if any arguments were provided
  • Displays usage instructions if no file path is given
  • Uses the first argument as the file path to read

3. File Existence Check:

  • Uses fileReader.fileExists() to verify the file exists before attempting to read
  • Provides a clear error message if the file is not found

4. File Reading:

  • Calls fileReader.readFile() to read the entire file content
  • Handles both successful reads and failures

5. Output Display:

  • Shows a success message with file statistics (character count)
  • Displays the complete file content with a formatted separator
  • Provides an error message if reading fails

Running the App

Build and Execute

# Install Dart dependencies
dart pub get

# Build the native library
./build.sh

# Run the application (this should read the README.md file)
dart run bin/main.dart README.md

Example output

Attempting to read file: README.md
File read successfully!
Content length: 1247 characters

-------------------- File Content --------------------
# FFI File IO

A Dart console application that demonstrates Foreign Function Interface (FFI) by reading text files using native macOS APIs.

## Features

- Reads text files using native POSIX APIs (open, read, fstat)
- Uses FFI to bridge Dart and C code
- Proper memory management with automatic cleanup
- Error handling for non-existent files

Key Advantages of This Approach

Performance Benefits

  1. Native Speed: File operations execute at native C speed
  2. Direct System Calls: No additional abstraction layers
  3. Minimal Overhead: FFI calls have very low overhead

Platform Integration

  1. Native APIs: Uses platform-specific optimized file system calls
  2. System Compatibility: Leverages proven, stable system interfaces
  3. Advanced Features: Easy to extend with platform-specific capabilities

Memory Efficiency

  1. Controlled Allocation: Precise memory management
  2. No Dart GC Pressure: Large file contents don’t impact Dart’s garbage collector
  3. Streaming Potential: Can be extended for streaming large files

Common Pitfalls and Best Practices

Memory Management

  • Always free allocated memory: Use finally blocks for cleanup
  • Handle null pointers: Check for nullptr returns from C functions
  • Match allocation/deallocation: Free memory using the same allocator

Error Handling

  • Check every system call: POSIX functions can fail in various ways
  • Provide meaningful errors: Convert low-level errors to user-friendly messages
  • Graceful degradation: Handle missing libraries or functions

Platform Considerations

  • Library naming: .dylib on macOS, .so on Linux, .dll on Windows
  • Architecture support: Consider universal binaries for macOS
  • Path handling: Use path package for cross-platform compatibility

Conclusion

This implementation demonstrates a complete FFI integration between Dart and C. The approach showcases:

  • Proper Architecture: Clean separation between C implementation and Dart bindings
  • Memory Safety: Comprehensive memory management without leaks
  • Error Handling: Robust error checking at all levels
  • Performance: Native-speed file operations with minimal overhead
  • Maintainability: Clear, documented code structure

FFI opens up powerful possibilities for Dart applications, enabling access to the vast ecosystem of C libraries while maintaining Dart’s productivity and safety benefits. This foundation can be extended to build more complex native integrations, from database drivers to multimedia processing libraries.

The complete source code demonstrates that FFI doesn’t have to be complex or error-prone when proper patterns and practices are followed. With careful attention to memory management and error handling, you can build reliable, high-performance native extensions for your Dart applications.

Github Source Code

You can find the source code in this repo on my GitHub account

Feedback

If you find something wrong or anything else, you can always reach me at

Note: Because I enjoy writing techy articles on Medium, most of the content was hand-written (tho it might seems not), except for some tedious parts in which I used AI-generated content. I also used Grammarly (an American English language writing assistant software tool) for enhancements and any misspelled words.

Until then, see you on the next one!

--

--

Flutter Community
Flutter Community

Published in Flutter Community

Articles and Stories from the Flutter Community

AbdulMuaz Aqeel
AbdulMuaz Aqeel

Written by AbdulMuaz Aqeel

Sr. Software Engineer - Iraq, Baghdad. I Stand With Palestine 🇵🇸

No responses yet