Đóng Gói Máy Ảo Lua - nói dối e blog

Đóng Gói Máy Ảo Lua

Trong quá trình phát triển khung kiến trúc engine client, chúng tôi gần đây đã gặp phải hai vấn đề đáng ghi nhận. Đây sẽ là bài viết đầu tiên đề cập đến vấn đề đầu tiên.

Khung kiến trúc của chúng tôi lựa chọn Lua làm ngôn ngữ phát triển chính. Khác với nhận thức thông thường của các kỹ sư C++ (phần lớn engine client hiện nay đều dùng C++), chúng tôi không xem Lua chỉ là ngôn ngữ kịch bản nhúng đơn thuần, mà hướng tới thiết kế toàn bộ hệ thống engine như một ngôn ngữ lập trình tổng quát bằng Lua.

Điều này tương tự như xu hướng thiết kế engine game bằng JavaScript sau khi HTML5 phổ biến: tuy máy ảo JavaScript được viết bằng C++, nhưng toàn bộ logic engine liên quan đến game đều triển khai bằng JavaScript, chỉ khi tiếp xúc với rendering thì mới quay lại code C++ thông qua WebGL. Chỉ khác là chúng tôi thay thế JavaScript bằng Lua.

Lựa chọn Lua một phần xuất phát từ sở thích cá nhân, một phần quan trọng là nhờ khả năng giao tiếp xuất sắc với C/C++. Những module trọng điểm yêu cầu hiệu năng cao có thể được thiết kế trừu tượng tốt rồi triển khai bằng C/C++, sau đó expose cho Lua gọi. Tuy nhiên khi phát triển ứng dụng native, phần giao tiếp với hệ điều hành vẫn phải dùng ngôn ngữ nền tảng (C/C++/Objective C/Java). Do đó máy ảo Lua cần được embed thông qua module C, công việc này do chúng tôi tự thực hiện.

Việc tích hợp Lua VM với hệ điều hành rõ ràng là phụ thuộc vào nền tảng. Mặc dù Lua C API đã tối giản, nhưng vẫn rất phức tạp. Nếu mỗi nền tảng đều dùng trực tiếp C API để điều khiển máy ảo, lượng code phụ thuộc nền tảng sẽ rất cồng kềnh. Theo quan điểm của tôi, cần tiếp tục trừu tượng hóa C API để giới hạn code nền tảng trong phạm vi nhỏ nhất.

Yêu cầu cốt lõi của chúng tôi là: tạo và vận hành định kỳ một (hoặc nhiều) máy ảo Lua. Chú ý đặc biệt đến việc vận hành định kỳ - tương đương với gửi tin nhắn đến máy ảo. Về bản chất, máy ảo hoạt động theo cơ chế phản ứng tin nhắn (giống như chương trình Windows). Thông thường có hành vi được điều khiển bởi đồng hồ hoặc khung vẽ (render frame), cùng các tin nhắn đầu vào từ thiết bị cảm ứng…

Toàn bộ logic nghiệp vụ đều hoàn thành trong Lua, vì vậy chúng tôi không cần truy xuất gì từ Lua VM. Nếu hệ điều hành cần dữ liệu, chủ yếu là truyền vào thư viện ngoại vi, để code Lua tự gửi thông tin ra ngoài qua thư viện này, chứ không phải lấy dữ liệu từ bên ngoài. Khi loại bỏ yêu cầu truy xuất từ bên ngoài, hầu hết C API của Lua đều trở nên thừa thãi.

Giao diện đóng gói mà tôi thiết kế cuối cùng chỉ gồm 5 hàm C API:

1
2
3
4
5
struct luavm * luavm_new();
const char * luavm_init(struct luavm *L, const char * source, const char *format, ...);
void luavm_close(struct luavm * L);
const char * luavm_register(struct luavm * L, const char * source, const char *chunkname, int *handle);
const char * luavm_call(struct luavm *L, int handle, const char *format, ...);

Hàm new/init/close phục vụ tạo hủy VM. Trong hàm init, có thể truyền script khởi tạo và tiêm module mở rộng từ bên ngoài. Lưu ý rằng cơ chế require gốc của Lua không thể lấy module C trên một số nền tảng (như iOS). Giải pháp của tôi là viết hàm searcher tuân thủ chuẩn lua package, thay thế searcher module C gốc tại thời điểm khởi tạo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static int
preload_searcher(lua_State *L) {
  const char * modname = luaL_checkstring(L,1);
  int i;
  for (i=0;preload[i].name != NULL;i++) {
    if (strcmp(modname, preload[i].name) == 0) {
      lua_pushcfunction(L, preload[i].func);
      return 1;
    }
  }
  lua_pushfstring(L, "\n\tno preload C module '%s'", modname);
  return 1;
}

Sau đó chỉ cần link tĩnh các hàm luaopen_xxx vào mảng preload là Lua VM có thể require module C thuận lợi. Hệ thống cung cấp cặp API register/call để code nền tảng gọi hàm trong VM. Khi register, truyền vào đoạn script đơn giản trả về hàm Lua, framework sẽ gán ID số cho nó. Sau này dùng call với ID này để thực thi hàm. Trong engine, số lượng hàm cần expose ra code native rất ít, thường chỉ update/draw/message vài hàm quan trọng.

Tất cả hàm Lua đều có thể ném lỗi. Tôi dùng kiểu trả về const char* để chỉ lỗi, giúp code native xử lý dễ dàng. Tuy nhiên tôi thiên về phương án thứ hai: để VM tự thu thập lỗi. Ví dụ: đẩy log lỗi qua mạng, lọc phức tạp hơn. Giải pháp là khi init, framework tạo table truyền cho script. Script ghi nhận table này vào biến toàn cục. Mỗi lần VM chạy, trước tiên kiểm tra table có lỗi mới không, xử lý xong thì xóa table. Framework sẽ append lỗi mới vào cuối table.

Để truyền tham số từ C sang Lua, tôi dùng cơ chế biến tham số truyền thống của C với chuỗi format. Trong chuỗi format: n cho double, i cho integer, b cho boolean, s cho const char*, p cho void*, f cho lua_CFunction. Đồng thời hỗ trợ nhận dữ liệu trả về từ Lua bằng chữ cái viết hoa tương ứng. Ví dụ muốn nhận int, truyền con trỏ int*, tương tự như scanf.

Đáng lưu ý là dù module đóng gói Lua VM rất nhỏ gọn, nhưng muốn hoạt động đúng cần xử lý cẩn trọng mọi lỗi có thể xảy ra khi gọi Lua C API. Ví dụ lỗi thiếu bộ nhớ (hàm lua_pushstring hoàn toàn có thể throw exception nên không thể gọi trực tiếp). Khi trả về const char* từ Lua cũng phải cẩn trọng, tránh trả con trỏ chuỗi có thể bị GC dọn dẹp. Giải pháp của tôi là sau khi khởi động VM, tạo coroutine riêng biệt làm vùng trao đổi dữ liệu với bên ngoài. Mọi chuỗi có khả năng trả ra ngoài đều lưu tạm tại đây. Vì vậy cho đến lần gọi VM tiếp theo, chuỗi trả về trước đó đều an toàn.

Cuối cùng xin lưu ý rằng đoạn code này được

0%