Summary
An analysis of Dart's internal serialization/deserialization mechanism performed at compile time when generating standalone executables or modular snapshots, that contain the information an embedded or separate Dart runtime requires to recreate a memory heap from which it will run and extract runtime information of compile-time constant objects. This will serve as the continuation of my first issue where I describe the boostrapping flow for a Flutter application on the Android platform.
Context
It isn't mandatory to read the first part in order to understand the topic covered here, but you might want to do it to get a grasp of the bigger picture and the architecture and inter-operation of the multiple processes involved in the runtime of a Flutter application. So, to make a clarification on the scope of both parts: the first part covered what was mostly Android-side embedder code covered. Here, I'll cover the native (C++) side of the framework, delving into the Dart virtual-machine internals.
- The
// [...] comments mean that some parts of the function or code block were omitted, as to not lose focus on the more important lines.
- I'll try to link the source file where the definition of a class is the first time I mention it, I might forget to do this.
- I'll be re-reading these notes, adding more details and going in-depth about certain aspects of the flow that are potentially important, so the version you are reading now is probably not final (2026-02-28).
- If I miss anything or you have proposed corrections or improvements to the clarity of the writing, please feel free to let me know.
- Code blocks annotated with
sdk are from the Dart SDK itself. Assume that it is from the Flutter codebase otherwise.
Part 1 - Initialization of the Dart virtual machine
In the previous part, I described the Flutter's bootstrapping code from the Android platform code perspective, i.e mostly only the Java-side of the implementation: the embedder. I also mentioned an interesting function whose explanation I omitted, as it is a native function I wanted to save for this part, as it deserved a description separately. I'm talking about the AttachJNI function, and what it does. If you read the first part, you might recall that this function is first invoked from Java, from the FlutterEngine constructor, right after the startInitizalization and ensureInitializationComplete functions. Take a moment to read the description of FlutterEngine's constructor behavior towards the end of the first issue so you have a clear notion of where inside the bootstrapping flow this occur.
The course the fact that this function is called only after the aforementioned initialization functions is no coincidence: those two functions are in charge of loading and initializing the native-side code (libflutter.so), the binary where the native-side of the Flutter engine and the AttachJNI functions are defined.
FlutterEngine.java
if (!flutterJNI.isAttached()) {
attachToJni();
}
private void attachToJni() {
// [...]
flutterJNI.attachToNative();
// [...]
}
FlutterJNI.java
@UiThread
public void attachToNative() {
// [...]
nativeShellHolderId = performNativeAttach(this);
// [...]
}
@VisibleForTesting
public long performNativeAttach(@NonNull FlutterJNI flutterJNI) {
return nativeAttach(flutterJNI);
}
private native long nativeAttach(@NonNull FlutterJNI flutterJNI);
The nativeAttach function is implemented in C++, as the AttachJNI function:
platform_view_android_jni_impl.cc
static jlong AttachJNI(JNIEnv* env, jclass clazz, jobject flutterJNI) {
fml::jni::JavaObjectWeakGlobalRef java_object(env, flutterJNI);
std::shared_ptr<PlatformViewAndroidJNI> jni_facade =
std::make_shared<PlatformViewAndroidJNIImpl>(java_object);
auto shell_holder = std::make_unique<AndroidShellHolder>(
FlutterMain::Get().GetSettings(), jni_facade,
FlutterMain::Get().GetAndroidRenderingAPI()); // 1
if (shell_holder->IsValid()) {
return reinterpret_cast<jlong>(shell_holder.release());
} else {
return 0;
}
}
An AndroidShellHolder instance is created as a singleton smart pointer (through the make_unique call). A value of 0 is returned back to Java if the operation isn't successful. Notice the call to FlutterMain::Get().GetSettings(). You might remember from the first part that a FlutterMain singleton was created by the Init function. The settings argument to the AndroidShellHolder constructor is thus nothing more than the option flags passed by ensureInitializationComplete to Init, which used to create FlutterMain singleton.
Let us now see the constructor call for the AndroidShellHolder class, which is where the rest of the initialization flow continues:
android_shell_holder.cc
AndroidShellHolder::AndroidShellHolder(
const flutter::Settings& settings,
std::shared_ptr<PlatformViewAndroidJNI> jni_facade,
AndroidRenderingAPI android_rendering_api)
: settings_(settings),
jni_facade_(jni_facade),
android_rendering_api_(android_rendering_api) {
// [...]
host_config.io_config = fml::Thread::ThreadConfig(
flutter::ThreadHost::ThreadHostConfig::MakeThreadName(
flutter::ThreadHost::Type::kIo, thread_label),
fml::Thread::ThreadPriority::kNormal); // 1
thread_host_ = std::make_shared<ThreadHost>(host_config); // 2
// [...]
flutter::TaskRunners task_runners(thread_label, // label
platform_runner, // platform
raster_runner, // raster
ui_runner, // ui
io_runner // io
); // 3
shell_ =
Shell::Create(GetDefaultPlatformData(), // window data
task_runners, // task runners
settings_, // settings
on_create_platform_view, // platform view create callback
on_create_rasterizer // rasterizer create callback
); // 4
// [...]
}
This function does a couple thing but the most important of them is the creation of the shell_ instance, which is the instance providing the interface with which Flutter interacts with the Dart runtime. With the call to Shell::Create, we finally abandon Flutter's platform-specific (Android, in our case) code, and finally enter native platform-independent implementations and behavior. Before addressing the Shell::Create function, let us break down briefly what this function does:
-
Three thread names are created, these names will identify the threads used by the AndroidShellHolder and the native side to perform different tasks and separate responsibilities. These threads are the raster, ui and io threads. There's one more thread, the one in charge of receiving and sending messages to the platform code, the platform thread, you might notice that only three names are being created, though. The reason for this is that the very thread where this constructor is being called IS the platform thread. Which is also as you might have guessed, the very same thread our Android platform spawned to run our Java code, and corresponds to an Android Looper object.
-
A ThreadHost object is called. This object is used to manage and represent the group of four threads used by the Flutter engine.
-
A TaskRunners object is created with references to four TaskRunner objects, one of each of the threads. These runners will be in charge of executing asynchronous tasks on the thread they are "attached" to.
-
Finally, a Shell object is created. This is a singleton of course, and it is, as mentioned, the interface the native code will use to interact with the Dart runtime. We will analyze the function that returns this object Shell::Create next.
Something worth mentioning is that there might not be a ui thread at all. Depending on the platform where the Flutter engine is being run, both the platform and ui thread are one and the same, with a single "merged" thread dealing with both responsibilities.
The call to Shell::Create is very significative, as this is the place where the Dart virtual machine will be launched and given context of what binaries it should load, which isolates it will have to run and where are they located (the ELF symbols used to identify them inside libapp.so), and prepare the runtime for the execution of the Dart entry-point, which is the exact moment where our Flutter application finally receives control of execution, and it can be said that our Flutter app is finally executing (have in mind that thus far, all code is bootstrapping code). Let us take a look at the function:
shell.cc
std::unique_ptr<Shell> Shell::Create(
...
Settings settings,
...) {
// [...]
auto [vm, isolate_snapshot] = InferVmInitDataFromSettings(settings);
return CreateWithSnapshot(platform_data, //
task_runners, //
/*parent_thread_merger=*/nullptr, //
/*parent_io_manager=*/nullptr, //
resource_cache_limit_calculator, //
settings, //
std::move(vm), //
std::move(isolate_snapshot), //
on_create_platform_view, //
on_create_rasterizer, //
CreateEngine, is_gpu_disabled);
}
The body of this function is small, and its execution forks into two different function calls to InferVmInitDataFromSettings and to CreateWithSnapshot, with the latter being the one that returns the Shell object. We'll take a look at both of them in order:
shell.cc
std::pair<DartVMRef, fml::RefPtr<const DartSnapshot>>
Shell::InferVmInitDataFromSettings(Settings& settings) {
auto vm_snapshot = DartSnapshot::VMSnapshotFromSettings(settings); // 1
auto isolate_snapshot = DartSnapshot::IsolateSnapshotFromSettings(settings);// 1
auto vm = DartVMRef::Create(settings, vm_snapshot, isolate_snapshot); // 2
if (!isolate_snapshot) {
isolate_snapshot = vm->GetVMData()->GetIsolateSnapshot(); // 3
}
return {std::move(vm), isolate_snapshot}; // 4
}
The first part of the function call is InferVmInitDataFromSettings, which returns a DartVMRef and a DartSnapshot instance wrapped by a RefPtr. The DartVMRef object contains a reference to a DartVM instance, which is not a Dart SDK type just yet, but rather a class that serves as a representation of a running instance of a virtual machine, and whose DartVM::Create method is a wrapper for Dart's Dart_Initialize, the actual function pertaining to the Dart embedding library that performs the initialization of the virtual machine. There should only be a single instance of DartVM throughout the entire execution of the application.
You might notice by reading the definition of DartVM that there does not seem to be a reference to an internal Dart type representing the running virtual machine instance, and you would be correct by noticing this: the communication channels that enable bidirectional communication from platform code to Dart are some specialized Dart API's IPC functions such as Dart_Invoke, Dart_PostCObject or Dart_PostInteger.
In short, the InferVmInitDataFromSettings does the following:
-
Fetches the virtual machine isolate snapshot. This isolate is always present in a Dart execution environment, and contains information that's used mostly used by the runtime, such as fundamental objects and types. The isolate snapshot is more or less a misnomer, and it refers to the application-specific snapshot. It contains both data and instructions from the user Dart code. This will be our target when reversing a Flutter application. I said it was a misnomer because it suggests that the vm_snapshot is not an isolate. It technically is just a "container for VM-global objects", but it is called the vm isolate inside Dart's source. Keep it in mind if you see further references to "VM isolate" in these notes: it exclusively means the "isolate" spawned from the contents of the VM data snapshot as basis.
-
A DartVMRef object is constructed and initialized, and a reference to it is returned in to the vm local. If there was already a running virtual machine instance prior to this point, the reference to it is returned instead. The DartVMRef object contains a strong reference to a DartVM singleton, the native-side representation of a currently running Dart virtual machine, of which there should only be one, in the context of a Flutter application execution.
-
The previously existing virtual machine's isolate snapshot is used if an isolate_snapshot argument isn't provided.
-
Both a DartVMRef and the isolate snapshot are returned. The isolates are of type DartIsolate.
Ultimately, this function will end up calling the Dart_Initialize function, which, according to Dart's documentation: "Initializes the VM." Yeah, that's it. Well, the function itself is not well documented, but this is due to the fact that the documentation is "elsewhere". One might not get a detailed description of what the function does and what each of the steps do, but it is possible to get a general idea of what it does from the params parameter that this function receives:
sdk/dart_api.h
DART_EXPORT DART_API_WARN_UNUSED_RESULT char* Dart_Initialize(
Dart_InitializeParams* params);
And here's the definition for the Dart_InitializeParams struct (omitting fields that are not relevant in our scope):
sdk/dart_api.h
typedef struct {
// [...]
const uint8_t* vm_snapshot_data; // 1
const uint8_t* vm_snapshot_instructions; // 1
Dart_IsolateGroupCreateCallback create_group; // 2
Dart_InitializeIsolateCallback initialize_isolate; // 2
Dart_IsolateShutdownCallback shutdown_isolate; // 2
Dart_IsolateCleanupCallback cleanup_isolate; // 2
Dart_IsolateGroupCleanupCallback cleanup_group; // 2
Dart_ThreadStartCallback thread_start; // 3
Dart_ThreadExitCallback thread_exit; // 3
Dart_FileOpenCallback file_open; // 3
Dart_FileReadCallback file_read; // 3
Dart_FileWriteCallback file_write; // 3
Dart_FileCloseCallback file_close; // 3
// [...]
} Dart_InitializeParams;
- The virtual machine snapshot data and instructions.
- Isolate-related callbacks, for when an isolate is created, initialized, shutdown, cleaned, and for when a group of isolates is cleaned, respectively.
- Callbacks for input/output and thread operations performed by the current isolate.
Part 2 - Finding the entrypoint and transfering control to it
Now all of that will initialize the text and data segments of the VM isolate, create an isolate resource group, and finally create and initialize the VM isolate using the former supplies. But this is just the VM isolate, which doesn't contain user code nor data at all. The thing is, at this point, no user code nor data has been loaded yet. Dart is yet to be transferred execution control. This leads us to our next milestone: how does Flutter load the "main" or "user" isolate, finds the entry-point in it and transfers control to it? Let us see:
FlutterEngineGroup.java
engine.getDartExecutor().executeDartEntrypoint(dartEntrypoint, dartEntrypointArgs);
Right after the FlutterEngine constructor returns, that function is called. Towards the end of past issue, I made an imprecision: I mentioned that this function was called at some point after entering onStart, but this isn't true. It is true that executeDartEntrypoint is called there, but that's only a fallback, in case the call right above wasn't able to transfer control to Dart. Now, this might happen if the entry-point isn't the default main, but one defined by a custom intent, in which case the invocation might be after onStart. Now, what does this executeDartEntrypoint do? Its native implementation is defined as follows:
platform_view_android_jni_impl.cc
static void RunBundleAndSnapshotFromLibrary(...)
{
// [...]
ANDROID_SHELL_HOLDER->Launch(std::move(apk_asset_provider), entrypoint,
libraryUrl, entrypoint_args, engineId);
}
The interesting part here is ANDROID_SHELL_HOLDER. What is it? Recall that in the first issue, we covered the FlutterJNI's attachToNative function. That function creates an AndroidShellHolder instance and returns a pointer to it to the Java code that invokes it, as a jlong. The shell_holder parameter you see is nothing more than that very same pointer, and the ANDROID_SHELL_HOLDER macro is:
#define ANDROID_SHELL_HOLDER (reinterpret_cast<AndroidShellHolder*>(shell_holder))
A casting of that jlong into a pointer type, whose Launch method gets invoked. This function receives the entry-point name, the libraryUrl parameter, the arguments for the entry-point and an engineId (there can be multiple FlutterEngine instances).
android_shell_holder.cc
void AndroidShellHolder::Launch(...)
{
// [...]
auto config = BuildRunConfiguration(entrypoint, libraryUrl, entrypoint_args);
// [...]
shell_->RunEngine(std::move(config.value()));
}
Now, the shell instance's RunEngine (the shell_ field set in the AttachJNI function) method will be called:
shell.cc
void Shell::RunEngine(
RunConfiguration run_configuration,
const std::function<void(Engine::RunStatus)>& result_callback) {
// [...]
fml::TaskRunner::RunNowOrPostTask(
task_runners_.GetUITaskRunner(),
fml::MakeCopyable(
[run_configuration = std::move(run_configuration),
weak_engine = weak_engine_, result]() mutable {
// [...]
auto run_result = weak_engine->Run(std::move(run_configuration));
// [...]
}));
}
Now, weak_engine, a reference to weak_engine_ has its Run method called. This task is not performed in the main thread (platform) where we are right now, but it is posted to the ui thread instead, which is the one in charge of continuing and completing this flow.
You are wondering what weak_engine_ is. It's a weak pointer to an Engine instance. You might have noticed that I didn't cover the CreateWithSnapshot function. I didn't deem it necessary, as it doesn't perform any crucial enough task so that could be described within the scope of this research. The important thing you need to know is that the Engine instance I mentioned is created inside that function. Now, let us move into Engine::Run, which gets passed a RunConfiguration object, which is a wrapper for the arguments, entry-point name and the library URI. This function will run the so-called "root" isolate, which is the isolate containing both the user data and user instructions, let us see:
engine.cc
Engine::RunStatus Engine::Run(RunConfiguration configuration) {
// [...]
if (runtime_controller_->IsRootIsolateRunning()) {
return RunStatus::FailureAlreadyRunning;
}
if (!runtime_controller_->LaunchRootIsolate(
settings_, //
root_isolate_create_callback, //
configuration.GetEntrypoint(), //
configuration.GetEntrypointLibrary(), //
configuration.GetEntrypointArgs(), //
configuration.TakeIsolateConfiguration(), //
native_assets_manager_, //
configuration.GetEngineId())) //
{
return RunStatus::Failure;
}
// [...]
return Engine::RunStatus::Success;
}
This flow is deep as you can see, but we are nearing its end on the Flutter side, after which I'll start describing the underlying Dart SDK that ultimately gets called, so bear with me. This function will first check whether the isolate isn't running first, and if not, it will launch it through LaunchRootIsolate, which we will omit given that this function is pretty much a wrapper for the method DartIsolate::CreateRunningRootIsolate:
dart_isolate.cc
std::weak_ptr<DartIsolate> DartIsolate::CreateRunningRootIsolate(...) {
// [...]
auto isolate = CreateRootIsolate(settings, //
isolate_snapshot, //
std::move(platform_configuration), //
isolate_flags, //
isolate_create_callback, //
isolate_shutdown_callback, //
context, //
spawning_isolate, //
std::move(native_assets_manager) //
)
.lock(); // 1
// [...]
if (!isolate->RunFromLibrary(std::move(dart_entrypoint_library), //
std::move(dart_entrypoint), //
dart_entrypoint_args)) { // 2
FML_LOG(ERROR) << "Could not run the run main Dart entrypoint.";
return {};
}
// [...]
return isolate;
}
Now, this function performs multiple tasks that I've snipped, the most important ones are:
-
CreateRootIsolate is called. This function declares a lambda function, isolate_maker, whose definition varies depending on whether the spawning_isolate argument is set. This argument is only set when another existing isolate is creating the current isolate. Given a non-null value for the argument, the isolate is created using the SDK's Dart_CreateIsolateInGroup. This is not our case, as this is the first isolate created by the engine, not counting the VM isolate created during the Dart virtual machine startup flow. Consequently, the isolate_maker will invoke Dart_CreateIsolateGroup instead, which creates a new isolate group with the user isolate as root and first isolate of said group (every isolate must be in a group, in fact, there's no "Dart_CreateIsolate" function or similar in the Dart API). A DartIsolate reference is returned by CreateRootIsolate.
The Dart_CreateIsolateGroup does not transfer control to the Dart entry-point just yet, as this function's sole responsibilities are to create the isolate's heap, deserialize the data snapshot into it, and return the corresponding Dart_Isolate object. Notice: the function does not have to do anything special to the instruction snapshot, as it is a symbol living inside libapp.so's .text section, which is mapped to an executable region in memory, thus the precompiled AOT code mapping was already performed by the operating system during ELF augmentation. We will further explore this function when studying the snapshot deserialization process.
-
Finally, the RunFromLibrary call follows. And this function does exactly what you think: ends up invoking the now mapped and ready isolate's entry-point, transferring control to it.
This function is right in the edge between Flutter and Dart, in fact, you can see that it calls a few functions prepended with Dart_, those are all functions from the Dart SDK. Let us take a look:
dart_isolate.cc
bool DartIsolate::RunFromLibrary(std::optional<std::string> library_name,
std::optional<std::string> entrypoint,
const std::vector<std::string>& args) {
auto library_handle =
library_name.has_value() && !library_name.value().empty()
? ::Dart_LookupLibrary(tonic::ToDart(library_name.value().c_str()))
: ::Dart_RootLibrary(); // 1
auto entrypoint_handle = entrypoint.has_value() && !entrypoint.value().empty()
? tonic::ToDart(entrypoint.value().c_str())
: tonic::ToDart("main"); // 2
auto user_entrypoint_function =
::Dart_GetField(library_handle, entrypoint_handle); // 3
auto entrypoint_args = tonic::ToDart(args); // 4
if (!InvokeMainEntrypoint(user_entrypoint_function, entrypoint_args)) { // 5
return false;
}
phase_ = Phase::Running;
return true; // 5
}
I didn't snip this function at all, so we will review the entirety of it, with special focus on steps 1, 3 and 5, but first, let's disclose an important definition: the Dart_Handle. A Dart_Hanlde is a Dart API type that essentially acts as a generic pointer (think of it as void*) to different kind of types inside the Dart API. It is an opaque type, defined as typedef struct _Dart_Handle* Dart_Handle;. The _Dart_Handle type is undefined, so the type definition is just a pointer to an incomplete type. As such, it must be type-casted to the appropriate API pointer type before being operated on. This is the job of the Dart function that receives the handle as an argument, and you are never meant to operate on it directly. Now:
-
A Dart_Handle to a library object is returned, this object will either be the root library or a custom library (for when the engine is initialized with a custom intent that defines the library URI containing the entry point). In a normal scenario, we won't define any custom library, so the root library will be used instead. The root library is nothing but the code corresponding to the source file used at compile time, when calling the dart compile command. In Flutter's case, the flutter command line tool will fallback to lib/main.dart if nothing is provided using the --target option. This handle is thus a pointer to an internal Dart object, a Library object, to be more specific.
-
The entry-point handle, which is just a pointer to a string object stored in the isolate's heap, containing the string "main" in our case. This allocation and heap writing is done by tonic::ToDart("main");. Functions defined in the tonic namespace are a series of specialized template functions that will internally invoke Dart-specific allocation routines, in this case, ToDart will resolve to calls Dart_NewStringFromCString, as it is being passed a string literal.
-
A handle to the field itself is obtained. Dart_GetField receives a pair (scope, field), where scope can be either a library object, a type object or an instance object, in which cases a top-level symbol, a static definition or a instance member handles are returned, respectively. In our case, we get the main top-level symbol handle, which after knowing what a handle is, we know it's essentially a pointer to the main function.
-
Arguments are casted to a list type that Dart can understand.
-
The InvokeMainEntrypoint is called passing the handles we obtained in the previous steps as arguments.
Naturally, if what we want is to be able to deterministically recover entry point information such as name, library where it is, offset into the instruction snapshot and arguments (if any), we need to perform the exact same steps as this function, therefore, we need to replicate the behavior of this function. Given this, let us finish this second part taking a deep look into InvokeMainEntrypoint, how root library information is recovered from the snapshot (and how Dart gets a handle to it), and finally how Dart uses this information to precisely identify and obtain the handle that uniquely identifies the entry-point:
[...] COMING SOON [...]
Part 3 - Deserialization and serialization mechanism
Summary
An analysis of Dart's internal serialization/deserialization mechanism performed at compile time when generating standalone executables or modular snapshots, that contain the information an embedded or separate Dart runtime requires to recreate a memory heap from which it will run and extract runtime information of compile-time constant objects. This will serve as the continuation of my first issue where I describe the boostrapping flow for a Flutter application on the Android platform.
Context
It isn't mandatory to read the first part in order to understand the topic covered here, but you might want to do it to get a grasp of the bigger picture and the architecture and inter-operation of the multiple processes involved in the runtime of a Flutter application. So, to make a clarification on the scope of both parts: the first part covered what was mostly Android-side embedder code covered. Here, I'll cover the native (C++) side of the framework, delving into the Dart virtual-machine internals.
// [...]comments mean that some parts of the function or code block were omitted, as to not lose focus on the more important lines.sdkare from the Dart SDK itself. Assume that it is from the Flutter codebase otherwise.Part 1 - Initialization of the Dart virtual machine
In the previous part, I described the Flutter's bootstrapping code from the Android platform code perspective, i.e mostly only the Java-side of the implementation: the embedder. I also mentioned an interesting function whose explanation I omitted, as it is a native function I wanted to save for this part, as it deserved a description separately. I'm talking about the
AttachJNIfunction, and what it does. If you read the first part, you might recall that this function is first invoked from Java, from theFlutterEngineconstructor, right after thestartInitizalizationandensureInitializationCompletefunctions. Take a moment to read the description ofFlutterEngine's constructor behavior towards the end of the first issue so you have a clear notion of where inside the bootstrapping flow this occur.The course the fact that this function is called only after the aforementioned initialization functions is no coincidence: those two functions are in charge of loading and initializing the native-side code (
libflutter.so), the binary where the native-side of the Flutter engine and theAttachJNIfunctions are defined.FlutterEngine.javaFlutterJNI.javaThe
nativeAttachfunction is implemented in C++, as theAttachJNIfunction:platform_view_android_jni_impl.ccAn
AndroidShellHolderinstance is created as a singleton smart pointer (through themake_uniquecall). A value of0is returned back to Java if the operation isn't successful. Notice the call toFlutterMain::Get().GetSettings(). You might remember from the first part that aFlutterMainsingleton was created by theInitfunction. Thesettingsargument to theAndroidShellHolderconstructor is thus nothing more than the option flags passed byensureInitializationCompletetoInit, which used to createFlutterMainsingleton.Let us now see the constructor call for the
AndroidShellHolderclass, which is where the rest of the initialization flow continues:android_shell_holder.ccThis function does a couple thing but the most important of them is the creation of the
shell_instance, which is the instance providing the interface with which Flutter interacts with the Dart runtime. With the call toShell::Create, we finally abandon Flutter's platform-specific (Android, in our case) code, and finally enter native platform-independent implementations and behavior. Before addressing theShell::Createfunction, let us break down briefly what this function does:Three thread names are created, these names will identify the threads used by the
AndroidShellHolderand the native side to perform different tasks and separate responsibilities. These threads are theraster,uiandiothreads. There's one more thread, the one in charge of receiving and sending messages to the platform code, theplatformthread, you might notice that only three names are being created, though. The reason for this is that the very thread where this constructor is being called IS theplatformthread. Which is also as you might have guessed, the very same thread our Android platform spawned to run our Java code, and corresponds to an AndroidLooperobject.A
ThreadHostobject is called. This object is used to manage and represent the group of four threads used by the Flutter engine.A
TaskRunnersobject is created with references to fourTaskRunnerobjects, one of each of the threads. These runners will be in charge of executing asynchronous tasks on the thread they are "attached" to.Finally, a
Shellobject is created. This is a singleton of course, and it is, as mentioned, the interface the native code will use to interact with the Dart runtime. We will analyze the function that returns this objectShell::Createnext.Something worth mentioning is that there might not be a
uithread at all. Depending on the platform where the Flutter engine is being run, both theplatformanduithread are one and the same, with a single "merged" thread dealing with both responsibilities.The call to
Shell::Createis very significative, as this is the place where the Dart virtual machine will be launched and given context of what binaries it should load, which isolates it will have to run and where are they located (the ELF symbols used to identify them insidelibapp.so), and prepare the runtime for the execution of the Dart entry-point, which is the exact moment where our Flutter application finally receives control of execution, and it can be said that our Flutter app is finally executing (have in mind that thus far, all code is bootstrapping code). Let us take a look at the function:shell.ccThe body of this function is small, and its execution forks into two different function calls to
InferVmInitDataFromSettingsand toCreateWithSnapshot, with the latter being the one that returns theShellobject. We'll take a look at both of them in order:shell.ccThe first part of the function call is
InferVmInitDataFromSettings, which returns aDartVMRefand aDartSnapshotinstance wrapped by aRefPtr. TheDartVMRefobject contains a reference to aDartVMinstance, which is not a Dart SDK type just yet, but rather a class that serves as a representation of a running instance of a virtual machine, and whoseDartVM::Createmethod is a wrapper for Dart'sDart_Initialize, the actual function pertaining to the Dart embedding library that performs the initialization of the virtual machine. There should only be a single instance ofDartVMthroughout the entire execution of the application.You might notice by reading the definition of
DartVMthat there does not seem to be a reference to an internal Dart type representing the running virtual machine instance, and you would be correct by noticing this: the communication channels that enable bidirectional communication from platform code to Dart are some specialized Dart API's IPC functions such asDart_Invoke,Dart_PostCObjectorDart_PostInteger.In short, the
InferVmInitDataFromSettingsdoes the following:Fetches the virtual machine isolate snapshot. This isolate is always present in a Dart execution environment, and contains information that's used mostly used by the runtime, such as fundamental objects and types. The isolate snapshot is more or less a misnomer, and it refers to the application-specific snapshot. It contains both data and instructions from the user Dart code. This will be our target when reversing a Flutter application. I said it was a misnomer because it suggests that the
vm_snapshotis not an isolate. It technically is just a "container for VM-global objects", but it is called the vm isolate inside Dart's source. Keep it in mind if you see further references to "VM isolate" in these notes: it exclusively means the "isolate" spawned from the contents of the VM data snapshot as basis.A
DartVMRefobject is constructed and initialized, and a reference to it is returned in to thevmlocal. If there was already a running virtual machine instance prior to this point, the reference to it is returned instead. TheDartVMRefobject contains a strong reference to aDartVMsingleton, the native-side representation of a currently running Dart virtual machine, of which there should only be one, in the context of a Flutter application execution.The previously existing virtual machine's isolate snapshot is used if an
isolate_snapshotargument isn't provided.Both a
DartVMRefand the isolate snapshot are returned. The isolates are of typeDartIsolate.Ultimately, this function will end up calling the
Dart_Initializefunction, which, according to Dart's documentation: "Initializes the VM." Yeah, that's it. Well, the function itself is not well documented, but this is due to the fact that the documentation is "elsewhere". One might not get a detailed description of what the function does and what each of the steps do, but it is possible to get a general idea of what it does from theparamsparameter that this function receives:sdk/dart_api.hAnd here's the definition for the
Dart_InitializeParamsstruct (omitting fields that are not relevant in our scope):sdk/dart_api.hPart 2 - Finding the entrypoint and transfering control to it
Now all of that will initialize the text and data segments of the VM isolate, create an isolate resource group, and finally create and initialize the VM isolate using the former supplies. But this is just the VM isolate, which doesn't contain user code nor data at all. The thing is, at this point, no user code nor data has been loaded yet. Dart is yet to be transferred execution control. This leads us to our next milestone: how does Flutter load the "main" or "user" isolate, finds the entry-point in it and transfers control to it? Let us see:
FlutterEngineGroup.javaRight after the
FlutterEngineconstructor returns, that function is called. Towards the end of past issue, I made an imprecision: I mentioned that this function was called at some point after enteringonStart, but this isn't true. It is true thatexecuteDartEntrypointis called there, but that's only a fallback, in case the call right above wasn't able to transfer control to Dart. Now, this might happen if the entry-point isn't the defaultmain, but one defined by a custom intent, in which case the invocation might be afteronStart. Now, what does thisexecuteDartEntrypointdo? Its native implementation is defined as follows:platform_view_android_jni_impl.ccThe interesting part here is
ANDROID_SHELL_HOLDER. What is it? Recall that in the first issue, we covered theFlutterJNI'sattachToNativefunction. That function creates anAndroidShellHolderinstance and returns a pointer to it to the Java code that invokes it, as ajlong. Theshell_holderparameter you see is nothing more than that very same pointer, and theANDROID_SHELL_HOLDERmacro is:A casting of that
jlonginto a pointer type, whoseLaunchmethod gets invoked. This function receives the entry-point name, thelibraryUrlparameter, the arguments for the entry-point and anengineId(there can be multipleFlutterEngineinstances).android_shell_holder.ccNow, the shell instance's
RunEngine(theshell_field set in theAttachJNIfunction) method will be called:shell.ccNow,
weak_engine, a reference toweak_engine_has itsRunmethod called. This task is not performed in the main thread (platform) where we are right now, but it is posted to theuithread instead, which is the one in charge of continuing and completing this flow.You are wondering what
weak_engine_is. It's a weak pointer to anEngineinstance. You might have noticed that I didn't cover theCreateWithSnapshotfunction. I didn't deem it necessary, as it doesn't perform any crucial enough task so that could be described within the scope of this research. The important thing you need to know is that theEngineinstance I mentioned is created inside that function. Now, let us move intoEngine::Run, which gets passed aRunConfigurationobject, which is a wrapper for the arguments, entry-point name and the library URI. This function will run the so-called "root" isolate, which is the isolate containing both the user data and user instructions, let us see:engine.ccThis flow is deep as you can see, but we are nearing its end on the Flutter side, after which I'll start describing the underlying Dart SDK that ultimately gets called, so bear with me. This function will first check whether the isolate isn't running first, and if not, it will launch it through
LaunchRootIsolate, which we will omit given that this function is pretty much a wrapper for the methodDartIsolate::CreateRunningRootIsolate:dart_isolate.ccNow, this function performs multiple tasks that I've snipped, the most important ones are:
CreateRootIsolateis called. This function declares a lambda function,isolate_maker, whose definition varies depending on whether thespawning_isolateargument is set. This argument is only set when another existing isolate is creating the current isolate. Given a non-null value for the argument, the isolate is created using the SDK'sDart_CreateIsolateInGroup. This is not our case, as this is the first isolate created by the engine, not counting the VM isolate created during the Dart virtual machine startup flow. Consequently, theisolate_makerwill invokeDart_CreateIsolateGroupinstead, which creates a new isolate group with the user isolate as root and first isolate of said group (every isolate must be in a group, in fact, there's no "Dart_CreateIsolate" function or similar in the Dart API). ADartIsolatereference is returned byCreateRootIsolate.The
Dart_CreateIsolateGroupdoes not transfer control to the Dart entry-point just yet, as this function's sole responsibilities are to create the isolate's heap, deserialize the data snapshot into it, and return the correspondingDart_Isolateobject. Notice: the function does not have to do anything special to the instruction snapshot, as it is a symbol living insidelibapp.so's.textsection, which is mapped to an executable region in memory, thus the precompiled AOT code mapping was already performed by the operating system during ELF augmentation. We will further explore this function when studying the snapshot deserialization process.Finally, the
RunFromLibrarycall follows. And this function does exactly what you think: ends up invoking the now mapped and ready isolate's entry-point, transferring control to it.This function is right in the edge between Flutter and Dart, in fact, you can see that it calls a few functions prepended with
Dart_, those are all functions from the Dart SDK. Let us take a look:dart_isolate.ccI didn't snip this function at all, so we will review the entirety of it, with special focus on steps 1, 3 and 5, but first, let's disclose an important definition: the
Dart_Handle. ADart_Hanldeis a Dart API type that essentially acts as a generic pointer (think of it asvoid*) to different kind of types inside the Dart API. It is an opaque type, defined astypedef struct _Dart_Handle* Dart_Handle;. The_Dart_Handletype is undefined, so the type definition is just a pointer to an incomplete type. As such, it must be type-casted to the appropriate API pointer type before being operated on. This is the job of the Dart function that receives the handle as an argument, and you are never meant to operate on it directly. Now:A
Dart_Handleto a library object is returned, this object will either be the root library or a custom library (for when the engine is initialized with a custom intent that defines the library URI containing the entry point). In a normal scenario, we won't define any custom library, so the root library will be used instead. The root library is nothing but the code corresponding to the source file used at compile time, when calling thedart compilecommand. In Flutter's case, thefluttercommand line tool will fallback tolib/main.dartif nothing is provided using the--targetoption. This handle is thus a pointer to an internal Dart object, aLibraryobject, to be more specific.The entry-point handle, which is just a pointer to a string object stored in the isolate's heap, containing the string "main" in our case. This allocation and heap writing is done by
tonic::ToDart("main");. Functions defined in thetonicnamespace are a series of specialized template functions that will internally invoke Dart-specific allocation routines, in this case,ToDartwill resolve to callsDart_NewStringFromCString, as it is being passed a string literal.A handle to the field itself is obtained.
Dart_GetFieldreceives a pair(scope, field), where scope can be either a library object, a type object or an instance object, in which cases a top-level symbol, a static definition or a instance member handles are returned, respectively. In our case, we get themaintop-level symbol handle, which after knowing what a handle is, we know it's essentially a pointer to the main function.Arguments are casted to a list type that Dart can understand.
The
InvokeMainEntrypointis called passing the handles we obtained in the previous steps as arguments.Naturally, if what we want is to be able to deterministically recover entry point information such as name, library where it is, offset into the instruction snapshot and arguments (if any), we need to perform the exact same steps as this function, therefore, we need to replicate the behavior of this function. Given this, let us finish this second part taking a deep look into
InvokeMainEntrypoint, how root library information is recovered from the snapshot (and how Dart gets a handle to it), and finally how Dart uses this information to precisely identify and obtain the handle that uniquely identifies the entry-point:[...] COMING SOON [...]
Part 3 - Deserialization and serialization mechanism