理解nodejs中js和c++的通信原理
发布于 4 年前 作者 theanarkh 3986 次浏览 来自 分享

本文分享一下nodejs中js调用c++模块的一些内容。js调用c++模块是v8提供的能力,nodejs是使用了这个能力。这样我们只需要面对js,剩下的事情交给nodejs就行。本文首先讲一下利用v8如何实现js调用c++,然后再讲一下nodejs是怎么做的。

1 js调用c++

首先介绍一下v8中两个非常核心的类FunctionTemplate和ObjectTemplate。顾名思义,这两个类是定义模板的,好比建房子时的设计图一样,通过设计图,我们就可以造出对应的房子。v8也是,定义某种模板,就可以通过这个模板创建出对应的实例。下面介绍一下这些概念(为了方便,下面都是伪代码)。

1.1 定义一个函数模板

Local<FunctionTemplate> exampleFunctionTemplate = v8::FunctionTemplate::New(isolate(), New);
// 定义函数的类名
exampleFunctionTemplate->SetClassName(‘TCP’)

首先定义一个FunctionTemplate对象。我们看到FunctionTemplate的第二个入参是一个函数,当我们执行由FunctionTemplate创建的函数时,v8就会执行New函数。当然我们也可以不传。

1.2 定义函数模板的prototype内容

prototype就是js里的function.prototype。如果你理解js里的知识,就很容易理解c++的代码。

  v8::Local<v8::FunctionTemplate> t = v8::FunctionTemplate::New(isolate(), callback);
  t->SetClassName('test'); 
  // 在prototype上定义两个属性
  exampleFunctionTemplate->PrototypeTemplate()->Set('name', t);
  exampleFunctionTemplate->PrototypeTemplate()->Set('hello', 'world');

1.3 定义函数模板对应的实例模板的内容

实例模板就是一个ObjectTemplate对象。他定义了,当以new的方式执行由函数模板创建出来的函数时的返回值。

function A() {
	this.a = 1;
	this.b = 2;
}
new A();

实例模板类似上面代码中A函数里面的代码。我们看看在v8里怎么定义。

t->InstanceTemplate()->Set(key, val);
t->InstanceTemplate()->SetInternalFieldCount(1);

InstanceTemplate返回的是一个ObjectTemplate对象。SetInternalFieldCount这个函数比较特殊,也是比较重要的一个地方,我们知道对象就是一块内存,对象有他自己的内存布局,我们知道在c++里,我们定义一个类,也就定义了对象的布局。比如我们有以下定义。

class demo
{
    private:
    int a;
    int b;
};

在内存中布局如下。 上面这种方式有个问题,就是类定义之后,内存布局就固定了。而v8是自己去控制对象的内存布局的。当我们在v8中定义一个类的时候,是没有任何属性的。我们看一下v8中HeapObject类的定义。

class HeapObject: public Object {
  static const int kMapOffset = Object::kSize; // Object::kSize是0
  static const int kSize = kMapOffset + kPointerSize;
};

这时候的内存布局如下。 然后我们再看一下HeapObject子类HeapNumber的定义。

class HeapNumber: public HeapObject {
  // kSize之前的空间存储map对象的指针
  static const int kValueOffset = HeapObject::kSize;
  // kValueOffset - kSize之间存储数字的值
  static const int kSize = kValueOffset + kDoubleSize;
};

内存布局如下

我们发现这些类只有几个类变量,类变量是不保存在对象内存空间的。这些类变量就是定义了对象每个域所占内存空间的信息,当我们定义一个HeapObject对象的时候,v8首先申请一块内存,然后把这块内存首地址强行转成对应对象的指针。然后通过类变量对属性的内存进行存取。我们看看在v8里如何申请一个HeapNumber对象

Object* Heap::AllocateHeapNumber(double value, PretenureFlag pretenure) {
  // 在哪个空间分配内存,比如新生代,老生代
  AllocationSpace space = (pretenure == TENURED) ? CODE_SPACE : NEW_SPACE;
  // 在space上分配一个HeapNumber对象大小的内存
  Object* result = AllocateRaw(HeapNumber::kSize, space);
  /*
	  转成HeapObect,设置map属性,map属性是表示对象类型、大小等信息的
  */
  HeapObject::cast(result)->set_map(heap_number_map());
  // 转成HeapNumber对象
  HeapNumber::cast(result)->set_value(value);
  return result;
}

回到对象模板的问题。我们看一下对象模板的定义。

class TemplateInfo: public Struct {
  static const int kTagOffset          = HeapObject::kSize;
  static const int kPropertyListOffset = kTagOffset + kPointerSize;
  static const int kHeaderSize         = kPropertyListOffset + kPointerSize;
};

class ObjectTemplateInfo: public TemplateInfo {
  static const int kConstructorOffset = TemplateInfo::kHeaderSize;
  static const int kInternalFieldCountOffset = kConstructorOffset + kPointerSize;
  static const int kSize = kInternalFieldCountOffset + kHeaderSize;
};

内存布局如下

回到对象模板的问题,我们看看Set(key, val)做了什么。

void Template::Set(v8::Handle<String> name, v8::Handle<Data> value,
                   v8::PropertyAttribute attribute) {
  // ...
  i::Handle<i::Object> list(Utils::OpenHandle(this)->property_list());
  NeanderArray array(list);
  array.add(Utils::OpenHandle(*name));
  array.add(Utils::OpenHandle(*value));
  array.add(Utils::OpenHandle(*v8::Integer::New(attribute)));
}

上面的代码大致就是给一个list后面追加一些内容。我们看看这个list是怎么来的,即property_list函数的实现。

// 读取对象中某个属性的值
#define READ_FIELD(p, offset) (*reinterpret_cast<Object**>(FIELD_ADDR(p, offset))

static Object* cast(Object* value) { 
	return value;
}

Object* TemplateInfo::property_list() { 
	return Object::cast(READ_FIELD(this, kPropertyListOffset)); 
}

从上面代码中我们知道,内部布局如下。

根据内存布局,我们知道property_list的值是list指向的值。所以Set(key, val)操作的内存并不是对象本身的内存,对象利用一个指针指向一块内存保存Set(key, val)的值。SetInternalFieldCount函数就不一样了,他会影响(扩张)对象本身的内存。我们来看一下他的实现。

void ObjectTemplate::SetInternalFieldCount(int value) {
  // 修改的是kInternalFieldCountOffset对应的内存的值
  Utils::OpenHandle(this)->set_internal_field_count(i::Smi::FromInt(value));
}

我们看到SetInternalFieldCount函数的实现很简单,就是在对象本身的内存中保存一个数字。接下来我们看看这个字段的使用。后面会详细介绍他的用处。

Handle<JSFunction> Factory::CreateApiFunction(
    Handle<FunctionTemplateInfo> obj,
    bool is_global) {
 
  int internal_field_count = 0;
  if (!obj->instance_template()->IsUndefined()) {
    // 获取函数模板的实例模板
    Handle<ObjectTemplateInfo> instance_template = Handle<ObjectTemplateInfo>(ObjectTemplateInfo::cast(obj->instance_template()));
    // 获取实例模板的internal_field_count字段的值(通过SetInternalFieldCount设置的那个值)
    internal_field_count = Smi::cast(instance_template->internal_field_count())->value();
  }
  // 计算新建对象需要的空间,如果
  int instance_size = kPointerSize * internal_field_count;
  if (is_global) {
    instance_size += JSGlobalObject::kSize;
  } else {
    instance_size += JSObject::kHeaderSize;
  }

  InstanceType type = is_global ? JS_GLOBAL_OBJECT_TYPE : JS_OBJECT_TYPE;
  // 新建一个函数对象
  Handle<JSFunction> result =
      Factory::NewFunction(Factory::empty_symbol(), type, instance_size,
                           code, true);
}                       

我们看到internal_field_count的值的意义是,会扩张对象的内存,比如一个对象本身只有n字节,如果定义internal_field_count的值是1,对象的内存就会变成n+internal_field_count * 一个指针的字节数。内存布局如下。 1.4 通过函数模板创建一个函数

AFunctionTemplate->GetFunction();
global->Set('demo', AFunctionTemplate->GetFunction());

这样我们就可以在js里直接调用demo这个变量,然后对应的函数就会被执行。这就是js调用c++的原理。

2 nodejs是如何处理js调用c++问题的

nodejs没有给每个功能定义一个全局变量,而是通过另外一种方式实现js调用c++。我们以tcp模块为例。在tcp_wrap.cc文件最后有一句代码

NODE_BUILTIN_MODULE_CONTEXT_AWARE(tcp_wrap, node::TCPWrap::Initialize)

这是一个宏,展开后如下

#define NODE_BUILTIN_MODULE_CONTEXT_AWARE(modname, regfunc)                   \
  NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, nullptr, NM_F_BUILTIN)


#define NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags)          \
  static node::node_module _module = {                                        \
    NODE_MODULE_VERSION,                                                      \
    flags,                                                                    \
    nullptr,                                                                  \
    __FILE__,                                                                 \
    nullptr,                                                                  \
    (node::addon_context_register_func) (regfunc),                            \
    NODE_STRINGIFY(modname),                                                  \
    priv,                                                                     \
    nullptr                                                                   \
  };                                                                          \
  void _register_ ## modname() {                                              \
    node_module_register(&_module);                                           \
  }

我们看到,宏展开后,首先定义了一个node_module 结构体。然后定义了一个_register_ xxx的函数。这个函数在nodejs初始化的时候会执行

void RegisterBuiltinModules() {
// 宏展开后就是执行一系列的_register_xxx函数
#define V(modname) _register_##modname();
  NODE_BUILTIN_MODULES(V)
#undef V
}

我们看到_register_ xxx函数执行了

node_module_register(&_module);

node_module_register定义如下

extern "C" void node_module_register(void* m) {
  struct node_module* mp = reinterpret_cast<struct node_module*>(m);
  mp->nm_link = modlist_builtin;
  modlist_builtin = mp;
}

就是把一个个node_module加入到链表中。完成了模块的注册。我们来看看如何使用这个模块。

constant { TCP } = process.binding('tcp_wrap');
new TCP(...);

我们看到nodejs是通过process.binding来实现c++模块的调用的。nodejs通过定义一个全局变量process统一处理c++模块的调用,而不是定义一堆全局对象。下面我们看process.binding的实现,跳过nodejs的缓存处理,直接看c++的实现。


static Local<Object> InitModule(Environment* env,
                                 node_module* mod,
                                 Local<String> module) {
  Local<Object> exports = Object::New(env->isolate());
  Local<Value> unused = Undefined(env->isolate());
  // 执行nm_context_register_func函数,就是tcp_wrap.cc的Initialize函数
  mod->nm_context_register_func(exports,
                                unused,
                                env->context(),
                                mod->nm_priv);
  return exports;
}

static void Binding(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);
  Local<String> module = args[0].As<String>();
  node::Utf8Value module_v(env->isolate(), module);
  // 从模块链表中找到对应的模块
  node_module* mod = get_builtin_module(*module_v);
  Local<Object> exports = InitModule(env, mod, module);
  args.GetReturnValue().Set(exports);
}

v8中,js调用c++函数的规则是函数入参const FunctionCallbackInfo<Value>& args(拿到js传过来的内容)和设置返回值args.GetReturnValue().Set(给js返回的内容);binding函数的逻辑就是执行对应的模块钩子函数,并有一个exports变量传进去,然后钩子函数会修改exports的值,该exports的值就是js层能拿到的值。最后我们来看看tcp_wrap.cc的Initialize。

void TCPWrap::Initialize(Local<Object> target,
                         Local<Value> unused,
                         Local<Context> context) {
  Environment* env = Environment::GetCurrent(context);
  /*
    new TCP时,v8会新建一个c++对象(根据InstanceTemplate()模板创建的对象),然后传进New函数,
    然后执行New函数,New函数的入参args的args.This()就是该c++对象
  */
  Local<FunctionTemplate> t = env->NewFunctionTemplate(New);
  Local<String> tcpString = FIXED_ONE_BYTE_STRING(env->isolate(), "TCP");
  t->SetClassName(tcpString);
  t->InstanceTemplate()->SetInternalFieldCount(1);
  t->InstanceTemplate()->Set(env->owner_string(), Null(env->isolate()));
  // ...
  // 在target(即exports对象)中注册该函数
  target->Set(tcpString, t->GetFunction());

上面就定义了我们在js层可以拿到的值。今天就分析到这,后续再补充。 如果你对nodejs感兴趣欢迎关注我的公众号。 image.png

回到顶部