V8 引擎探索:如何注入全局变量

1,356 阅读6分钟

前言

最近花了一些时间研究 V8 引擎,收获良多。今天,我们一起来探索一番。

注:阅读本文需要一定 C++ 基础。

V8 与 d8

问题:V8 引擎是一个很复杂的东西,对它的研究,应该从哪里开始着手呢?
答案:从运行它开始。

那么,如何运行 V8 呢?这里有一些参考资料:

  1. 编译 V8 源码,By justjavac
  2. Building-from-Source, By 官方文档
  3. Installing V8 on a Mac,By kevincennis

这些资料讲得都很完备,我就不赘述了。直接给出运行结果示意图。

d8

至此,我们已经把 V8 的 Demo d8 跑起来,并且可以让其执行任意的 JS 代码。
但是,我们仔细想想:V8 和 d8 是一个概念吗?
不是的,V8 和 d8 不是一个概念。V8 是一个 C++ 库,d8 是一个 C++ 应用,其中内嵌了 V8 库,所以,d8 才能执行 JS 代码(因为它本质上将输入的 JS 代码交给 V8 处理了)。

那么,我们能不能模仿 d8,自己写一个 C++ 应用,来执行指定的 JS 代码呢?

内嵌 V8

官方给出了一个内嵌 V8 的 demo,按照该文档进行操作,便可以自己实现这样的一个 C++ 应用。

请注意,之前我看这文档的时候还是对应 V8 的 4.8 版本,目前该文档已经升级到 5.8 版本,操作步骤有些不同。我在这里当初当时我操作 4.8 版本的步骤,仅供参考。(本文后面所有的探索都是基于 4.8 版本)

  1. git checkout -b 4.8 -t branch-heads/4.8
  2. make release
  3. 在 V8 项目根目录下新建 hello_world.cpp 文件,将这里的代码拷贝过去,保存。
  4. 执行命令:clang++ -stdlib=libstdc++ -std=c++11 -I. hello_world.cpp -o hello_world out/x64.release/libv8_base.a out/x64.release/libv8_libbase.a out/x64.release/libicudata.a out/x64.release/libicuuc.a out/x64.release/libicui18n.a out/x64.release/libv8_base.a out/x64.release/libv8_external_snapshot.a out/x64.release/libv8_libplatform.a
  5. 执行命令:cp out/x64.release/*.bin .
  6. 执行命令:./hello_world,屏幕会打印出 “Hello, World!" 字样

为什么屏幕会输出 ”Hello, World!" 呢?
因为在此 demo 中,给定执行的 JS 语句为 'Hello' + ' , World!'(如下面代码所示) ,这是一个表达式,此表达式执行返回的结果就是一个字符串。

 Local<String> source =
                String::NewFromUtf8(isolate,
                                    "'Hello' + ' , World!'",
                                    NewStringType::kNormal).ToLocalChecked();

ok,你可能会觉得这样的表达式太简单了,不足以证明其能够正确运行 JS 代码。
好,那我们尝试用复杂的原型链作为例子,如下所示。

// 这个例子够复杂了吧
function Person(name) {
    this.name = name;
}
Person.prototype.hi = function () {
    return this.name;
};
var p = new Person('youngwind');
p.hi();

把上述压缩成一行的字符串,放入上面的例子中,重新编译,执行结果如下图所示。

prototype

由此,我们已经证明:此 C++ 应用 hello_world 已经能够执行任意给定的 JS 代码。

到底是谁的 console

然而,当我想运行 console 语句的时候,意外的情况发生了。如下所示,给定 JS 代码为输出一个字符串。

Local<String> source =
                String::NewFromUtf8(isolate,
                                    "console.log('哈哈哈');",
                                    NewStringType::kNormal).ToLocalChecked();

执行结果如下图所示:
console

为什么程序无法识别 console?
不是说好的 V8 引擎能够执行 JS 代码?难道 console 不属于 ES 规范?
答案:console 还真不是 ES 规范中定义的,准确地说,console 不属于任何的规范,详见这里

由此,我有以下两点思考:

  1. console 不过是约定俗成的一个不成文规矩,浏览器和 NodeJS 都支持它。V8 作为 JS 执行引擎,只能执行符合 ES 规范的代码。因此,直接调用 console 会报错。
  2. 既然 console 不是 V8 提供的,那为什么在浏览器和 NodeJS 中都能使用呢?到底是谁提供的 console 呢?

带着这个疑问,我进行了以下的尝试:

print

从上图我们可以看出,hello_world、d8 和 NodeJS 的表现各不相同,为什么呢?
这个问题非常困扰我,直到我发现了这个概念:C++和JS 交互
由此,我发现 hello_world、d8、NodeJS 这三者与 v8 真正的关系,如下图所示(点击查看大图):
js-C++-bridge

由此我们可以得出结论:hello_world、d8、NodeJS和浏览器内核,都是一个 C++ 应用,其中内嵌 V8 引擎,用于执行 JS 代码。但是,它们会 V8 在外边包裹一层 Bridge,通过这一层 Bridge,实现 JS 和 C++ 之间的相互调用,以达到扩展 JS 的目的。

举个例子:为什么 d8 能够运行语句 print("哈哈哈");呢?因为 d8 里面有一个 C++ 方法 Print,通过某种方式,将此方法注入到 V8 的全局环境中,对应到全局变量 print上。所以,当 V8 在执行该 “JS” 代码 print 的时候,其实本质上是在调用 Print 这个 C++ 方法。

下面我们具体来看看注入的代码。

注入全局变量

关于如何注入,网上也有一些参考资料:

  1. JavaScript引擎研究与C、C++与互调用 ,By lwg2001s
  2. 使用 Google V8 引擎开发可定制的应用程序, By 邱俊涛
  3. V8引擎javascript与C++交互, By 心灵捕手
  4. 关于V8 JavaScript Engine的使用方法研究(转), By lcgg110

然而,这些资料大多年代久远,V8 的 API 也发生了变化,因此,其中的代码很难直接运行起来。后来我在 V8 的源码中直接找到了例子,参考这里。仔细观察 shell.cc ,我们能够发现注入全局变量的“三步走”方法:

  1. 声明函数
    void Print(const v8::FunctionCallbackInfo<v8::Value>& args);  // line 54
  2. 定义函数
    // The callback that is invoked by v8 whenever the JavaScript 'print'
    // function is called.  Prints its arguments on stdout separated by
    // spaces and ending with a newline.
    void Print(const v8::FunctionCallbackInfo<v8::Value>& args) {
    bool first = true;
    for (int i = 0; i < args.Length(); i++) {
      v8::HandleScope handle_scope(args.GetIsolate());
      if (first) {
        first = false;
      } else {
        printf(" ");
      }
      v8::String::Utf8Value str(args[i]);
      const char* cstr = ToCString(str);
      printf("%s", cstr);
    }
    printf("\n");
    fflush(stdout);
    }
  3. 注入函数
    // Creates a new execution environment containing the built-in
    // functions.
    v8::Local<v8::Context> CreateShellContext(v8::Isolate* isolate) {
    // Create a template for the global object.
    v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
    // Bind the global 'print' function to the C++ Print callback.
    global->Set(
        v8::String::NewFromUtf8(isolate, "print", v8::NewStringType::kNormal)
            .ToLocalChecked(),
        v8::FunctionTemplate::New(isolate, Print));
    return v8::Context::New(isolate, NULL, global);
    }

至此,我们终于能搞明白如何注入全局变量了。
为了方便后续的调试,我提前编译好了 V8(4.8版本的),并且将一些所需要的头文件和中间过程生成的 .a 文件拷贝到一个新的仓库 fake-node 中,按照上面的步骤,便可以随意注入其他全局变量了。

后话

对 V8 的探索甚是消耗时间,主要有两个难点要克服。

  1. 要有一定的 C++ 基础(虽然上大学的时候学过点皮毛,但是后来基本没用过,都忘光了,只能从头拾起)
  2. 要熟悉 V8 的概念和 API 。这里有个 V8 的 API 文档,仅供参考。

----------- EOF --------------