In this post, we will start our exploration of V8 engine and look under the hood at call sequences that are made to execute a simple 'hello' + 'world'
command. This post is a follow up to our Exploring V8 Engine - I post.
To test out the V8 embedding, you need a compile your sample hello-world.cc file with the V8 source code. Doing this for each sample would be tedious. V8 provides you a way to build the V8 source as a standalone binary which we can directly use while building any samples which embed V8. This makes sure that you don't have to recompile the V8 source each time you make a small change to your sample.
The embedding V8 takes you through the process effortlessly. However, the x64.release.sample
they build do not have debug symbols enabled (which we want). So, below is a slightly tweaked version of the gn args out.gn/x64.release.sample
which includes all the extra debugging symbols we will require.
is_component_build = false
is_debug = true
target_cpu = "x64"
use_custom_libcxx = false
v8_monolithic = true
v8_use_external_startup_data = false
v8_enable_backtrace = true
v8_optimized_debug = false
Use the above config with gn args out.gn/x64.release.sample
to enable all the symbols (similar to a debug build). Finally, while building your hello-world.cc example, add the -g
flag to include your sample's source.
g++ -g -I. -Iinclude samples/hello-world.cc -o hello_world -lv8_monolith -Lout.gn/x64.release.sample/obj/ -pthread -std=c++0x -DV8_COMPRESS_POINTERS
This should now generate the hello_world executable which if you run:
Hello, World!
3 + 4 = 7
- when building with
is_debug
flag and a monolithic binary, the build process populatesv8/out.gn/x64.release.sample/obj
with the debugging symbols. Thus, run GDB fromv8/out.gn/x64.release.sample
directory (or set you paths), so that you pick up the debugging symbols - Configure your
GDB
to pickup thetools/gdbinit
file
Now let's move on and hook up our good old friend GDB
and see what's what.
I tried a couple of different debuggers:
- plain
GDB
- plain
LLDB
llnode
(a LLDB extension for node analysis)
While llnode may be regarded much better for V8 analysis, it's maintained to work with the latest LTS release of NODEJS
, which may not be most up to date with V8 master. This may cause issues with extensions giving you faulty data.
IMO, GDB with GEF is the best way to go along with using /tools/gdbinit
file.
Now that we are all setup, let's start taking a look at the source code and the control flow.
From our previous post, we understand the major ideas about Isolates
,isolate_scope()
, handle_scope
and Contexts
. To summarize, this is what the object structures would look like:
With that picture in our head, let's keep going.
Our trace starts at the block scope where we are trying to execute an one liner javascript code:
...
{
// Create a string containing the JavaScript source code.
v8::Local<v8::String> source =
v8::String::NewFromUtf8Literal(isolate, "'Hello' + ', World!'");
// Compile the source code.
v8::Local<v8::Script> script =
v8::Script::Compile(context, source).ToLocalChecked();
// Run the script to get the result.
v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();
// Convert the result to an UTF8 string and print it.
v8::String::Utf8Value utf8(isolate, result);
printf("%s\n", *utf8);
}
...
The first just prepares the provided command into a utf-8
encoded javascript type string object, placed inside the target V8 isolate. This will later be consumed by or compiler to generate the object code.
v8::Script::Compile(context, source)
is where the magic begins. If you trace out the call structure for this function call, constructor in api.cc. Next, compiler.cc is hit where the function CompileScriptOnMainThread does all the work which is required to get the script compiled and ready to run.
This includes the flow hitting interpreter.cc and getting interpret. One good way is to use (gdb) rbreak filename.xx:.
to break on all the functions of the file. This is what the backtrace looks like at that instance of time:
Now that we have the code compiled and the env mostly setup, the only left out piece in the puzzle is, how does the code execute?
We see the run invocation on line 47. This line invokes v8::Script::Run which is responsible for handling the execution of the code in the provided context.
From the v8::Script::Run
,
Execution::Call
is invoked which is responsible for setting up the remaining structures required to execute the interpret code and get back the result.
Execution::Invoke
is eventually invoked which converts the interpret intermediate bytecode into the platform-specific object code using
GenerateCode
and triggers the invocation of the code.
Providing us the data in the structures we require.
The main target of this post was to explore what main files and functions are touched during the compilation and execution of a simple example. More discussion on what code sections go what is described in this post.
The main idea to learn is while tracing have an idea of what file the control might hit and then use (gdb) rbreak filename:.
or a similar eq to trace on a higher level rather than tracing the control flow line by line.