Using libc with dart:ffi
In the earlier versions of Dart, the language had a fairly dogmatic world view: Dart was strictly a cross-platform language, with batteries included for web and server development. In other words, if Dart did not provide a feature, you were out of luck.
For example, to write to stdout
in Dart:
import 'dart:io' as io;
void main() {
io.stdout.writeln('Hello, World!');
}
This meant that Dart was not designed to let users directly interact with the underlying operating system and was a feature of the language, not a bug; users could not be "trusted" to write safe code (outside of a poorly supported feature called native extensions).
In March 2021, Dart 2.12
added a new library called dart:ffi
,
which allowed Dart to interact with C libraries. This was a huge step
forward for Dart, as it allowed users to write code that interacted with
native code in a predictable way, and without using cumbersome techniques
like asynchronous plugins or message
channels.
So let's get started. First, you need to have a dynamic C library available. A dynamic library (or shared library) is precompiled code that can be loaded into a running program. The default C library is typically exported as global symbols, so we can access it in-process:
import 'dart:ffi';
void main() {
final global = DynamicLibrary.process();
// To show this works, let's use the native 'time' function.
// https://man7.org/linux/man-pages/man2/time.2.html
final time = global.lookupFunction<
Int64 Function(Pointer),
int Function(Pointer)
>('time');
// time() takes an optional argument, which we'll ignore.
// let's use the equivalent of NULL in C, which is provided by dart:ffi.
final now = time(nullptr);
print('The current time is $now.');
}
Nice! We just called a C function from Dart. But what if we wanted to write to
stdout
instead? The C library provides a function called
write
that can be used to write to a file descriptor. The file
descriptor for standard output is 1
, so let's change our code to
write to standard output:
import 'dart:ffi';
void main() {
final global = DynamicLibrary.process();
final write = global.lookupFunction<
Int64 Function(Int32, Pointer, IntPtr),
int Function(int, Pointer, int)
>('write');
// Wait, what is this 'Pointer' type?
}
Writing the signature for the write
function is almost as easy
as writing the signature for the time
function, but now we have
a new type: Pointer<Uint8>
. This type represents a pointer
to an array of 8-bit unsigned integers, or a buffer of bytes. We need to
convert a Dart string into a native buffer of bytes, which is not
possible with the Dart string type.
For this example, we'll create our own simple Allocator
that uses the C
malloc
and free
functions. We'll use this allocator
to allocate a buffer of bytes, copy the string into the buffer, and then write
the buffer to standard output. Here is the above example ammended to include
the allocator:
import 'dart:ffi';
void main() {
final global = DynamicLibrary.process();
final write = global.lookupFunction<
Int64 Function(Int32, Pointer, IntPtr),
int Function(int, Pointer, int)
>('write');
final malloc = global.lookupFunction<
Pointer Function(IntPtr),
Pointer Function(int)
>('malloc');
final free = global.lookupFunction<
Void Function(Pointer),
void Function(Pointer)
>('free');
final allocate = _Allocator(malloc, free);
}
final class _Allocator implements Allocator {
const _Allocator(this._malloc, this._free);
final Pointer Function(int) _malloc;
final void Function(Pointer) _free;
@override
Pointer allocate(int byteCount, {int? alignment}) {
final pointer = _malloc(byteCount);
if (pointer.address == 0) {
throw ArgumentError('Could not allocate $byteCount bytes.');
}
return pointer.cast();
}
@override
void free(Pointer pointer) {
_free(pointer);
}
}
Now that we have an allocator, we can allocate a buffer of bytes, copy the string into the buffer, and then write the buffer to standard output. Here is a snippet of code that does just that:
import 'dart:ffi';
void main() {
// ...
// Allocate a buffer of bytes and copy the string into the buffer.
final bytes = 'Hello, World!\n'.codeUnits;
final buffer = allocate(bytes.length);
buffer.asTypedList(bytes.length).setAll(0, bytes);
// Write the buffer to standard output.
const stdout = 1;
write(stdout, buffer, bytes.length);
// Free the buffer.
allocate.free(buffer);
}
Of course, this is not terribly useful (see also the
complete example), but I'm hoping it
will be a good
introduction to the topic. In the future, I plan to write more about using
dart:ffi
to access native APIs otherwise unavailable in
dart:io
. In the meantime, check out these other great resources
on the topic:
- C interop using dart:ffi, Dart's official introduction to the topic.
package:ffi
, which includes amalloc
implementation and even works on Windows.package:stdlibc
, out-of-the-box standard C library access, by the Canonical team.