You don't need to learn C
Let's get this out of the way immediately. This post is not a C tutorial. You are not going to write C as part of your Flutter work — or if you do, it will be minimal wrapper code, not application logic. The C code already exists. Someone else wrote it, probably decades ago. Your job is to call it from Dart.
But to call it, you need to read it. You need to look at a C header file and understand what the function signatures mean. You need to know what const uint8_t* is without Googling it. You need to understand why a library ships as a .so file on Android and a .dylib on macOS, and what DynamicLibrary.open() is actually loading.
This is the post that gives you that. No more, no less. If you've never touched C, read this before continuing the series. If you've written C before, skim the sections on shared libraries and compilation — those are the parts most relevant to how Flutter uses FFI.
C in one paragraph
C was created in 1972 at Bell Labs by Dennis Ritchie to write Unix. It compiles directly to machine code — no virtual machine, no garbage collector, no runtime. When a C program runs, it's the processor executing instructions that the compiler generated from your source code. That's why it's fast: there's nothing between you and the hardware.
It's also why it's dangerous. There's no runtime to catch your mistakes. Access memory you don't own? The program crashes — or worse, silently reads garbage. Forget to free allocated memory? It leaks until the process exits. C gives you absolute control and absolute responsibility. That tradeoff is the reason FFI exists: Dart gives you safety, C gives you power, FFI lets you use both.
Types: what C has and what Dart FFI maps to
C's type system is simpler than Dart's. There are no classes, no generics, no dynamic. There are numbers, characters (which are also numbers), pointers, structs, and enums. That's essentially it.
The complication is that C's basic types don't have fixed sizes. An int is "at least 16 bits" — it could be 16, 32, or 64 bits depending on the platform and compiler. A long is "at least 32 bits." This ambiguity is fine when writing self-contained C, but it's a disaster for FFI, where Dart needs to know the exact size of every value crossing the boundary.
That's why modern C code — and all C code you'll encounter in FFI contexts — uses fixed-width types from <stdint.h>:
#include <stdint.h>
int8_t // signed 8-bit integer (-128 to 127)
uint8_t // unsigned 8-bit integer (0 to 255)
int16_t // signed 16-bit
uint16_t // unsigned 16-bit
int32_t // signed 32-bit
uint32_t // unsigned 32-bit
int64_t // signed 64-bit
uint64_t // unsigned 64-bit
float // 32-bit floating point (always 32-bit, unlike int)
double // 64-bit floating point (always 64-bit)These map directly to Dart FFI's native types:
| C type | Dart FFI type | Dart type |
|--------|---------------|-----------|
| `int8_t` | `Int8` | `int` |
| `uint8_t` | `Uint8` | `int` |
| `int16_t` | `Int16` | `int` |
| `uint16_t` | `Uint16` | `int` |
| `int32_t` | `Int32` | `int` |
| `uint32_t` | `Uint32` | `int` |
| `int64_t` | `Int64` | `int` |
| `uint64_t` | `Uint64` | `int` |
| `float` | `Float` | `double` |
| `double` | `Double` | `double` |
| `void` | `Void` | `void` |Two types you'll see constantly that aren't in this table:
`size_t` — an unsigned integer type that's big enough to hold the size of any object in memory. On 64-bit systems it's 64 bits. In Dart FFI, use Size.
`char` — in C, a character is just a uint8_t (or int8_t on some platforms). When you see char*, it means "pointer to a sequence of bytes" — C's way of representing a string. More on this when we get to pointers.
`bool` — C didn't have a boolean type until C99 added _Bool (usually accessed as bool via <stdbool.h>). In practice, C code often uses int for booleans: 0 is false, anything else is true. In Dart FFI, use Bool for _Bool or Int32 for int-based booleans.
Pointers: the syntax you need to recognise
A pointer is a variable that holds a memory address. That's all it is. Our article on how memory works covered what memory addresses are; here we cover the C syntax.
int32_t x = 42; // a normal integer variable, value is 42
int32_t* p = &x; // a pointer to x — p holds the address of x
int32_t value = *p; // dereferencing — read the value at the address p holds (42)Three operators:
*in a type declaration means "pointer to":int32_t*means "pointer to a 32-bit integer"&means "address of":&xgives you the memory address ofx*in an expression means "dereference":*pmeans "give me the value at the address stored in p"
Yes, * means two different things depending on context. Welcome to C.
You'll see pointers written in three styles. They all mean the same thing:
int32_t* p; // common in modern C and C++
int32_t *p; // traditional C style
int32_t * p; // less common, but validconst with pointers
const tells the compiler something cannot be modified. With pointers, the placement matters:
const uint8_t* data; // pointer to constant data — you can't modify what it points to
uint8_t* const data; // constant pointer — you can't change where it points
const uint8_t* const d; // both — can't modify the data, can't change the pointerIn FFI contexts, you'll almost always see the first form: const uint8_t* data. It means "I'm giving you a pointer to some bytes, but you're not supposed to modify them." This is how C declares read-only input parameters.
void*
void* is a pointer to "something, but I'm not telling you what." It's C's version of a generic pointer. You can't dereference it directly — you have to cast it to a specific type first. Many C APIs use void* for user data callbacks: "store whatever you want here, we'll give it back to you later."
In Dart FFI: Pointer<Void>.
Functions: reading C signatures
A C function signature tells you everything Dart FFI needs to generate a binding:
int32_t add(int32_t a, int32_t b);This says: a function named add that takes two 32-bit integers and returns a 32-bit integer. The Dart FFI binding:
typedef AddNative = Int32 Function(Int32 a, Int32 b);
typedef AddDart = int Function(int a, int b);A more realistic example:
int32_t encrypt(const uint8_t* input, int32_t input_length, uint8_t* output);This says: a function named encrypt that takes a read-only pointer to bytes (input), the length of that data, and a writable pointer to bytes (output) where it will write the result. Returns an int32_t — probably a status code or the number of bytes written.
typedef EncryptNative = Int32 Function(
Pointer<Uint8> input,
Int32 inputLength,
Pointer<Uint8> output,
);
typedef EncryptDart = int Function(
Pointer<Uint8> input,
int inputLength,
Pointer<Uint8> output,
);Once you can read signatures like this, you can read any C header file that a library ships.
Header files: the interface
C splits code into two types of files:
- Header files (
.h) — declarations. "These functions exist, here are their signatures." - Source files (
.c) — implementations. "Here's what those functions actually do."
When you integrate a C library via FFI, you only read the headers. The source is already compiled into the library binary. The header is the API surface — the contract that tells you what you can call and how.
A typical header:
// sodium.h (simplified)
#ifndef SODIUM_H
#define SODIUM_H
#include <stdint.h>
#define crypto_secretbox_KEYBYTES 32
#define crypto_secretbox_NONCEBYTES 24
#define crypto_secretbox_MACBYTES 16
int sodium_init(void);
int crypto_secretbox_easy(
uint8_t* ciphertext,
const uint8_t* message,
uint64_t message_length,
const uint8_t* nonce,
const uint8_t* key
);
int crypto_secretbox_open_easy(
uint8_t* message,
const uint8_t* ciphertext,
uint64_t ciphertext_length,
const uint8_t* nonce,
const uint8_t* key
);
void randombytes_buf(void* buf, uint64_t size);
#endifLet's decode what you're seeing:
`#ifndef` / `#define` / `#endif` — include guards. They prevent the header from being processed twice if it's included from multiple files. Ignore them for FFI purposes.
`#include <stdint.h>` — imports the fixed-width integer types. The angle brackets mean "look in the system include path."
`#define crypto_secretbox_KEYBYTES 32` — a preprocessor constant. Anywhere the compiler sees crypto_secretbox_KEYBYTES in the code, it substitutes 32. These don't become symbols in the compiled library — they're resolved at compile time. In Dart, you just define them as regular constants:
const cryptoSecretboxKeybytes = 32;
const cryptoSecretboxNoncebytes = 24;
const cryptoSecretboxMacbytes = 16;`int sodium_init(void);` — a function that takes no arguments (void in the parameter list means "no parameters" in C) and returns an int.
The rest are function declarations you already know how to read from the previous section.
`extern "C"`: the C++ compatibility wrapper
C and C++ use different rules for naming compiled functions. C compiles a function named encrypt into a symbol called encrypt. C++ compiles the same function into something like _Z7encryptPKci — a mangled name that encodes the parameter types to support function overloading.
Dart FFI looks up functions by their symbol name. It can find encrypt. It cannot find _Z7encryptPKci.
When a C++ library wants to be callable from Dart (or from any C-based FFI), it wraps its public API in extern "C":
// C++ implementation — free to use classes, templates, namespaces
namespace crypto {
class AES {
public:
static int32_t encrypt(const uint8_t* input, int32_t len, uint8_t* output);
};
}
// Public C API — what Dart sees
extern "C" {
int32_t aes_encrypt(const uint8_t* input, int32_t len, uint8_t* output) {
return crypto::AES::encrypt(input, len, output);
}
}extern "C" tells the C++ compiler: "compile these functions with C naming rules." The internal implementation can use every C++ feature — classes, templates, RAII, exceptions. The extern "C" boundary flattens it into a C-compatible API.
When you read a header that starts with:
#ifdef __cplusplus
extern "C" {
#endif
// ... function declarations ...
#ifdef __cplusplus
}
#endifThat means: "this header works from both C and C++ code." The __cplusplus macro is defined automatically by C++ compilers. C compilers ignore the extern "C" wrapper. This is the standard pattern for cross-language headers.
`malloc` and `free`: manual memory management
In Dart, you write final list = <int>[] and the garbage collector handles the rest. In C, every byte of heap memory is manually managed:
#include <stdlib.h>
// Allocate 100 bytes of zeroed memory
uint8_t* buffer = calloc(100, sizeof(uint8_t));
// Use the buffer...
buffer[0] = 0xFF;
buffer[1] = 0x42;
// Free the memory when done
free(buffer);`malloc(size)` — allocate size bytes. The contents are undefined (could be anything).
`calloc(count, size)` — allocate count * size bytes, all zeroed. Safer default.
`free(ptr)` — release the memory at ptr back to the system.
The rules:
- Every
malloc/callocmust have a matchingfree - Freeing the same pointer twice is undefined behaviour (usually a crash)
- Using a pointer after freeing it is undefined behaviour (use-after-free)
- Forgetting to free is a memory leak — the memory is gone until the process exits
In Dart FFI, you use the calloc allocator from package:ffi:
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final buffer = calloc<Uint8>(100); // allocate 100 bytes
try {
buffer[0] = 0xFF;
// ... use the buffer
} finally {
calloc.free(buffer); // always free in a finally block
}The Dart calloc calls the C calloc under the hood. The calloc.free() calls the C free(). You're doing manual C memory management, just written in Dart syntax.
Compilation: from source code to shared library
When you write C code, the journey from text to running machine code goes through several stages. You don't need to do this yourself for established libraries — they ship pre-built binaries — but understanding the process helps you debug linking problems.
Step 1: Preprocessing
The preprocessor handles #include, #define, #ifdef — everything that starts with #. It's a text substitution pass. #include <stdio.h> literally copies the contents of stdio.h into your file. #define KEY_SIZE 32 replaces every occurrence of KEY_SIZE with 32.
Step 2: Compilation
The compiler translates the preprocessed C source into an object file (.o or .obj) — machine code for a specific platform, but not yet linked into a complete program.
# Compile a single C file into an object file
gcc -c encryption.c -o encryption.oStep 3: Linking
The linker combines object files and resolves references between them. If main.o calls encrypt() and encryption.o defines it, the linker connects them. The output is either an executable or a shared library.
# Link into a shared library
gcc -shared -o libencryption.so encryption.oShared libraries: what Flutter loads
A shared library is a compiled binary containing functions that other programs can load at runtime. Different platforms use different formats:
| Platform | Extension | Example |
|----------|-----------|---------|
| Linux / Android | `.so` | `libsodium.so` |
| macOS / iOS | `.dylib` / `.framework` | `libsodium.dylib` |
| Windows | `.dll` | `sodium.dll` |The lib prefix is a convention on Unix systems. libsodium.so is the sodium library. libcrypto.so is the crypto library.
When Flutter's DynamicLibrary.open('libsodium.so') runs, it asks the operating system to load that shared library into the app's memory space. The OS maps the library's code into the process's virtual address space (this connects directly to what Post 0 covered about virtual memory). After loading, Dart FFI can look up function symbols by name and call them.
final lib = DynamicLibrary.open('libsodium.so');
// Look up the symbol 'sodium_init' — returns a pointer to the function
final sodiumInit = lib.lookupFunction<Int32 Function(), int Function()>('sodium_init');
// Call it
final result = sodiumInit();On Android, .so files go in the android/app/src/main/jniLibs/ directory, organised by CPU architecture (arm64-v8a, armeabi-v7a, x86_64). On iOS, they're embedded as frameworks. Post 6 of this series covers the platform-specific details.
`typedef`: naming complex types
C uses typedef to create aliases for types. This is especially common for function pointer types, which would otherwise be nearly unreadable:
// Without typedef
void (*callback)(int32_t status, const char* message);
// With typedef
typedef void (*StatusCallback)(int32_t status, const char* message);
// Now you can use StatusCallback as a type name
void register_callback(StatusCallback cb);StatusCallback is now a type that means "pointer to a function that takes an int32 and a const char* and returns void." You'll see these in any C API that uses callbacks — which Post 4 of this series covers in depth.
The typedef itself doesn't create any code or data — it's purely a naming convenience.
Preprocessor macros: what to ignore (mostly)
C's preprocessor is a text substitution system that runs before compilation. You've already seen #include and #define. A few more you'll encounter:
#define MAX_BUFFER_SIZE 4096 // constant
#define SODIUM_EXPORT __attribute__((visibility("default"))) // compiler attribute
#define API_FUNC(ret, name, ...) ret name(__VA_ARGS__) // macro "function"For FFI purposes, you care about:
- Constant `#define`s — translate them to Dart constants manually
- Everything else — ignore it. Macros don't produce symbols. They're resolved at compile time and don't exist in the shared library. You can't call a macro via FFI.
If a library's public API is defined using macros (rare but it happens), you may need to write a thin C wrapper that calls the macro and exports a real function. But for most libraries, the actual function declarations are what you need.
Putting it together: reading a real header
Here's a condensed version of what you'd see when integrating a C library. Let's say you're looking at a compression library's header:
#ifndef COMPRESS_H
#define COMPRESS_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define COMPRESS_OK 0
#define COMPRESS_ERROR -1
#define COMPRESS_BUFFER_TOO_SMALL -2
typedef struct compress_ctx compress_ctx; // opaque type — you get a pointer, never see inside
compress_ctx* compress_create(int32_t level);
void compress_destroy(compress_ctx* ctx);
int32_t compress_data(
compress_ctx* ctx,
const uint8_t* input,
size_t input_length,
uint8_t* output,
size_t* output_length
);
#ifdef __cplusplus
}
#endif
#endifNow you can read every line:
- Include guards — ignore
- Fixed-width types imported — you know what they are
extern "C"wrapper — C++ compatibility, tells you the symbols will be C-named#defineconstants — translate to Dart constants:const compressOk = 0;typedef struct compress_ctx compress_ctx;— opaque type. You'll never see inside it. In Dart:Pointer<Void>or a custom opaque classcompress_create— allocates a context, returns a pointer to it. You'll free it withcompress_destroycompress_data— takes a context, input buffer with length, output buffer with a pointer to its length (so the function can tell you how many bytes it wrote). Returns a status code
The Dart bindings:
const compressOk = 0;
const compressError = -1;
const compressBufferTooSmall = -2;
typedef CompressCreateNative = Pointer<Void> Function(Int32 level);
typedef CompressCreateDart = Pointer<Void> Function(int level);
typedef CompressDestroyNative = Void Function(Pointer<Void> ctx);
typedef CompressDestroyDart = void Function(Pointer<Void> ctx);
typedef CompressDataNative = Int32 Function(
Pointer<Void> ctx,
Pointer<Uint8> input,
Size inputLength,
Pointer<Uint8> output,
Pointer<Size> outputLength,
);
typedef CompressDataDart = int Function(
Pointer<Void> ctx,
Pointer<Uint8> input,
int inputLength,
Pointer<Uint8> output,
Pointer<Size> outputLength,
);If you can read the header, you can write the bindings. That's the entire skill this post teaches.
What you can safely ignore
You don't need to learn:
- Preprocessor macros beyond `#define` constants — not relevant to FFI
- C's control flow (
for,while,switch) — you're calling C, not writing it - Memory layout of the stack — Post 0 covered this; you don't manage it
- Compiler flags and optimisation — the library author handled this
- C's string library (
strlen,strcpy, etc.) — Dart FFI has its own string conversion - File I/O, networking, threading in C — you're using Dart for all of that
- C11/C17/C23 features — the C you'll encounter in FFI is conservative C99
What you do need: types, pointers, function signatures, extern "C", header files, shared libraries. That's what this post covered. Everything else in this series builds on it.
Next: Why FFI Exists
Now that you can read C, the next post explains why you'd want to call it from Dart — the specific problems FFI solves, how the managed/unmanaged memory boundary works, and your first native function call.