المترجم جزء من
Emscripten . ولكن ماذا لو قمت بإزالة كل صفارات وترك فقط؟
مطلوب Emscripten ترجمة C / C ++ إلى
WebAssembly . ولكن هذا أكثر بكثير من مجرد مترجم. هدف Emscripten هو استبدال برنامج التحويل البرمجي C / C ++ بالكامل وتشغيل التعليمات البرمجية على الويب غير
المصمم أصلاً للويب. لهذا ، يحاكي Emscripten نظام التشغيل POSIX بأكمله. إذا كان البرنامج يستخدم
fopen () ، فسيوفر Emscripten محاكاة نظام الملفات. إذا تم استخدام OpenGL ، فستوفر Emscripten سياق GL متوافقًا مع C يدعمه
WebGL . هذا كثير من العمل ، والكثير من التعليمات البرمجية التي يجب تنفيذها في الحزمة النهائية. ولكن يمكنك فقط ... إزالته؟
المحول البرمجي الفعلي في مجموعة أدوات Emscripten هو LLVM. هو الذي يترجم رمز C إلى WebAssembly bytecode. هذا هو الإطار المعياري الحديث لتحليل البرامج وتحويلها وتحسينها. LLVM هو وحدات ، بمعنى أنه لا يتم تجميعها مباشرة إلى رمز الجهاز. بدلاً من ذلك ، ينشئ
برنامج التحويل البرمجي للجهة الأمامية المضمنة
تمثيل وسيطة (IR). في الواقع ، يسمى هذا التمثيل الوسيط LLVM ، وهو اختصار لـ Virtual-Low Virtual Machine ، ومن هنا جاء اسم المشروع.
يقوم
مترجم الواجهة الخلفية بترجمة IR إلى رمز الجهاز المضيف. تتمثل ميزة هذا الفصل الصارم في أن البنى الجديدة مدعومة بالإضافة "البسيطة" لمجمّع جديد. وبهذا المعنى ، يعد WebAssembly مجرد أحد أهداف الترجمة العديدة التي يدعمها LLVM ، وقد تم تنشيطه لبعض الوقت بعلم خاص. بدءًا من LLVM 8 ، يتوفر هدف تجميع WebAssembly افتراضيًا.
على نظام MacOS ، يمكنك تثبيت LLVM باستخدام
البيرة :
$ brew install llvm $ brew link --force llvm
تحقق من دعم WebAssembly:
$ llc --version LLVM (http://llvm.org/): LLVM version 8.0.0 Optimized build. Default target: x86_64-apple-darwin18.5.0 Host CPU: skylake Registered Targets:
يبدو أننا مستعدون!
تجميع C الطريق الصعب
ملاحظة: فيما يلي بعض تنسيقات RAW WebAssembly ذات المستوى المنخفض. إذا وجدت صعوبة في الفهم ، فهذا أمر طبيعي. حسن استخدام WebAssembly لا يتطلب فهم النص بأكمله في هذه المقالة. إذا كنت تبحث عن رمز للصق النسخ ، فراجع الدعوة إلى المحول البرمجي في قسم التحسين . ولكن إذا كنت مهتمًا ، استمر في القراءة! سبق أن كتبت مقدمة إلى Webassembly و WAT: هذه هي الأساسيات اللازمة لفهم هذا المنشور .
تحذير: سأحيد قليلاً عن المعيار وأحاول استخدام التنسيقات القابلة للقراءة البشرية في كل خطوة (إلى أقصى حد ممكن). سيكون برنامجنا هنا بسيطًا للغاية لتجنب المواقف الحدودية وعدم تشتيت انتباهنا:
يا له من عمل فني رائع! خاصة وأن البرنامج يسمى
add ، ولكن في الواقع لا
يضيف أي شيء (لا يضيف). الأهم من ذلك: البرنامج لا يستخدم المكتبة القياسية ، والأنواع هنا ، فقط "int".
تحويل C إلى عرض LLVM داخلي
الخطوة الأولى هي تحويل برنامجنا C إلى LLVM IR. هذه هي مهمة برنامج التحويل البرمجي
clang
frontend ، والذي تم تثبيته باستخدام LLVM:
clang \ --target=wasm32 \
ونتيجة لذلك ، نحصل على
add.ll
مع تمثيل داخلي لـ LLVM IR.
أنا أظهر ذلك فقط من أجل الاكتمال . عند العمل مع WebAssembly أو حتى clang ، لن تتواصل أنت كمطور C مع LLVM IR.
; ModuleID = 'add.c' source_filename = "add.c" target datalayout = "em:ep:32:32-i64:64-n32:64-S128" target triple = "wasm32" ; Function Attrs: norecurse nounwind readnone define hidden i32 @add(i32, i32) local_unnamed_addr #0 { %3 = mul nsw i32 %0, %0 %4 = add nsw i32 %3, %1 ret i32 %4 } attributes #0 = { norecurse nounwind readnone "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" } !llvm.module.flags = !{!0} !llvm.ident = !{!1} !0 = !{i32 1, !"wchar_size", i32 4} !1 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"}
LLVM IR ممتلئ بالبيانات الوصفية والتعليقات التوضيحية الإضافية ، مما يسمح للمترجم باتخاذ قرارات أكثر استنارة عند إنشاء رمز الجهاز.تحويل LLVM IR إلى ملفات الكائنات
الخطوة التالية هي استدعاء برنامج التحويل البرمجي للخلفية
llc
لإنشاء ملف كائن من التمثيل الداخلي.
إن ملف الإخراج
add.o
هو بالفعل وحدة WebAssembly صالحة تحتوي على جميع التعليمات البرمجية المترجمة لملف C ، لكن عادة لن تتمكن من تشغيل ملفات الكائنات لأنها تفتقر إلى الأجزاء الأساسية.
إذا تم حذف
-filetype=obj
في الأمر ، فسنحصل على مجمّع LLVM لـ WebAssembly ، وهو تنسيق قابل للقراءة من قِبل الإنسان ويشبه إلى حد ما WAT. ومع ذلك ، فإن أداة
llvm-mc
للعمل مع هذه الملفات لا تدعم التنسيق بالكامل ، وغالبًا لا تستطيع معالجة الملفات. لذلك ، نحن تفكيك ملفات الكائن بعد الحقيقة. هناك حاجة إلى أداة محددة للتحقق من ملفات الكائنات هذه. في حالة WebAssembly ، يكون
wasm-objdump
، جزءًا من
WebAssembly Binary Toolkit أو wabt لفترة قصيرة.
$ brew install wabt
يُظهر الإخراج أن وظيفة add () الخاصة بنا موجودة في هذه الوحدة ، ولكنها تحتوي أيضًا على أقسام
مخصصة تحتوي على بيانات التعريف ، وبشكل مدهش ، العديد من عمليات الاستيراد. في المرحلة التالية من
الارتباط ، سيتم تحليل المقاطع المخصصة وحذفها ، وسيتعامل الرابط (رابط) مع عملية الاستيراد.
ترتيب
تقليديا ، مهمة رابط هو تجميع العديد من ملفات الكائنات في ملف قابل للتنفيذ. يُطلق على رابط LLVM اسم
lld
، ويتم استدعاؤه بالارتباط الهدف. ل WebAssembly ، هذا
wasm-ld
.
wasm-ld \ --no-entry \
والنتيجة هي وحدة WebAssembly بحجم 262 بايت.
إطلاق
بالطبع ، الشيء الأكثر أهمية هو أن نرى أن كل شيء يعمل
حقًا . كما في
المقالة الأخيرة ، يمكنك استخدام سطرين من جافا سكريبت مضمن لتحميل وتشغيل وحدة WebAssembly هذه.
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); console.log(instance.exports.add(4, 1)); } init(); </script>
إذا كان كل شيء على ما يرام ، فسترى الرقم 17 في وحدة التحكم DevTool ،
لقد نجحنا في تجميع C بنجاح في WebAssembly دون لمس Emscripten. تجدر الإشارة أيضًا إلى عدم وجود برنامج وسيط لتكوين وتحميل وحدة WebAssembly.
ترجمة C أبسط قليلاً
إلى ترجمة C في WebAssembly ، اتخذنا العديد من الخطوات. كما قلت ، لأغراض تعليمية ، درسنا بالتفصيل جميع المراحل. دعنا نتخطى التنسيقات الوسيطة القابلة للقراءة من قبل الإنسان ونطبق المترجم C فورًا كسكين للجيش السويسري ، حيث تم تطويره:
clang \ --target=wasm32 \ -nostdlib \
هنا نحصل على نفس ملف
.wasm
، لكن باستخدام أمر واحد.
الأمثل
ألق نظرة على WAT لوحدة WebAssembly الخاصة بنا عن طريق تشغيل
wasm2wat
:
(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) (local i32 i32 i32 i32 i32 i32 i32 i32) global.get 0 local.set 2 i32.const 16 local.set 3 local.get 2 local.get 3 i32.sub local.set 4 local.get 4 local.get 0 i32.store offset=12 local.get 4 local.get 1 i32.store offset=8 local.get 4 i32.load offset=12 local.set 5 local.get 4 i32.load offset=12 local.set 6 local.get 5 local.get 6 i32.mul local.set 7 local.get 4 i32.load offset=8 local.set 8 local.get 7 local.get 8 i32.add local.set 9 local.get 9 return) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))
واو ، يا له من كود عظيم. لدهشتي ، تستخدم الوحدة الذاكرة (كما يظهر من
i32.load
و
i32.store
) ، وثمانية متغيرات محلية وعالمية عديدة. ربما ، يمكنك كتابة نسخة أكثر إيجازًا يدويًا. هذا البرنامج كبير جدًا لأننا لم نطبق أي تحسينات. لنقم بذلك:
clang \ --target=wasm32 \ + -O3 \
ملاحظة: من الناحية الفنية ، لا يوفر تحسين التخطيط (LTO) أي فوائد لأننا نؤلف ملفًا واحدًا فقط. في المشروعات الكبيرة ، ستساعد LTO على تقليل حجم الملف بشكل كبير.
بعد تنفيذ هذه الأوامر ، انخفض ملف
.wasm
من 262 إلى 197 بايت ، وأصبح WAT أيضًا أبسط من ذلك بكثير:
(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 0 local.get 0 i32.mul local.get 1 i32.add) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))
اتصل بالمكتبة القياسية
استخدام C بدون مكتبة libc القياسية يبدو وقحًا إلى حد ما. من المنطقي إضافته ، لكنني سأكون أمينًا: لن يكون الأمر سهلاً.
في الواقع ، نحن لا نستدعي مباشرة أي مكتبات libc في المقالة . هناك العديد منها المناسبة ، وخاصة
glibc ،
musl و
dietlibc . ومع ذلك ، من المفترض أن تعمل معظم هذه المكتبات في نظام التشغيل POSIX ، الذي ينفذ مجموعة معينة من مكالمات النظام. نظرًا لعدم وجود واجهة kernel في JavaScript ، فسيتعين علينا تنفيذ نظام POSIX الذي ندعو إليه ، ربما من خلال JavaScript. هذه مهمة صعبة ولن أفعلها هنا. والخبر السار هو أن
هذا هو ما يفعله Emscripten لك .
بالطبع ، لا تعتمد جميع وظائف libc على مكالمات النظام. يتم تنفيذ وظائف مثل
strlen()
أو
sin()
أو حتى
memset()
في صيغة بسيطة C. وهذا يعني أنه يمكنك استخدام هذه الوظائف أو حتى مجرد نسخ / لصق تنفيذها من بعض المكتبات المذكورة.
الذاكرة الديناميكية
بدون libc ، لا تتوفر لنا واجهات C الأساسية مثل
malloc()
و
free()
. في WAT غير الأمثل ، رأينا أن المترجم يستخدم الذاكرة إذا لزم الأمر. هذا يعني أنه لا يمكننا استخدام الذاكرة كما نريد ، دون المخاطرة بإلحاق الضرر بها. تحتاج إلى فهم كيفية استخدامه.
نماذج الذاكرة LLVM
ستفاجئ طريقة تجزئة ذاكرة WebAssembly المبرمجين ذوي الخبرة قليلاً. أولاً ، في WebAssembly ، يكون العنوان الفارغ مسموحًا به من الناحية الفنية ، ولكن غالبًا ما يتم التعامل معه على أنه خطأ. ثانياً ، يأتي المكدس أولاً وينمو (إلى العناوين السفلية) ، ويظهر الكومة لاحقًا ويكبر. السبب هو أن ذاكرة WebAssembly قد تزيد في وقت التشغيل. هذا يعني أنه لا يوجد حد ثابت لاستيعاب المكدس أو الكومة.
هنا هو تخطيط
wasm-ld
:
تكدس المكدس لأسفل ثم كومة يكبر. يبدأ المكدس بـ __data_end
، __heap_base
الكومة بـ __heap_base
. نظرًا لأن المكدس يتم وضعه أولاً ، فهو __heap_base
__data_end
الأقصى للحجم __heap_base
أثناء __heap_base
البرمجي ، أي __heap_base
ناقص __data_end
إذا عدنا إلى الوراء
__heap_base
إلى قسم globals في WAT ، نجد هذه القيم:
__heap_base
مضبوطة على 66560 ، و
__data_end
مضبوطة على 1024. وهذا يعني أن الرصة يمكن أن تنمو إلى حد أقصى 64 كيلوبايت ، وهذا ليس كثيرًا. لحسن الحظ ، يتيح لك
wasm-ld
تغيير هذه القيمة:
clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \
تخصيص التجمع
من المعروف أن منطقة الكومة تبدأ بـ
__heap_base
. نظرًا لأن وظيفة
malloc()
مفقودة ، فنحن نعرف أنه يمكن استخدام منطقة الذاكرة التالية بأمان. يمكننا وضع البيانات هناك كما يحلو لنا ، وليس هناك ما يدعو للخوف من تلف الذاكرة ، حيث أن الرصة تنمو في الاتجاه الآخر. ومع ذلك ، يمكن بسرعة انسداد كومة الذاكرة المؤقتة المجانية للجميع ، لذلك عادة ما يلزم نوع من إدارة الذاكرة الديناميكية. يتمثل أحد الخيارات في اتخاذ تطبيق كامل malloc () ، مثل
تطبيق Dall Lee لتطبيق malloc ، والذي يتم استخدامه في Emscripten. هناك العديد من التطبيقات الصغيرة مع العديد من المقايضات.
ولكن لماذا لا تكتب
malloc()
الخاصة بك
malloc()
؟ نحن متورطون بشدة بحيث لا يحدث فرق. أحد أبسطها هو تخصيص نتوء: إنه فائق السرعة وصغير للغاية وسهل التنفيذ. ولكن هناك عيبًا: لا يمكنك تحرير الذاكرة. على الرغم من أن هذا التخصيص يبدو للوهلة الأولى عديم الفائدة بشكل لا يصدق ، ولكن عند تطوير
Squoosh ، صادفت سوابق حيث سيكون خيارًا ممتازًا. مفهوم مخصص نتوء هو أننا تخزين عنوان البداية من الذاكرة غير المستخدمة كعالمية. إذا طلب البرنامج
n
بايت من الذاكرة ، فنقوم بنقل العلامة إلى
n
وإرجاع القيمة السابقة:
extern unsigned char __heap_base; unsigned int bump_pointer = &__heap_base; void* malloc(int n) { unsigned int r = bump_pointer; bump_pointer += n; return (void *)r; } void free(void* p) {
يتم تعريف المتغيرات العامة من WAT فعليًا بواسطة
wasm-ld
، حتى نتمكن من الوصول إليها من الكود C كمتغيرات عادية إذا أعلنا عنها
extern
. لذلك ،
لقد كتبنا للتو malloc()
... في خمسة أسطر من C.ملاحظة: مخصص bump الخاص بنا غير متوافق تمامًا مع malloc()
من C. على سبيل المثال ، لا نقدم أي ضمانات للمحاذاة. لكنه يعمل بشكل جيد بما فيه الكفاية ، لذلك ...
استخدام الذاكرة الديناميكي
لاختبار ، دعونا نجعل دالة C ، والتي تأخذ مجموعة من الأرقام ذات الحجم التعسفي وتحسب المجموع. ليست مثيرة للاهتمام للغاية ، ولكن هذا يفرض علينا استخدام الذاكرة الديناميكية ، لأننا لا نعرف حجم المصفوفة أثناء التجميع:
int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; }
الدالة sum () ، كما نأمل ، واضحة إلى حد ما. سؤال أكثر إثارة للاهتمام هو كيفية تمرير صفيف من JavaScript إلى WebAssembly - بعد كل شيء ، WebAssembly يفهم فقط الأرقام. الفكرة العامة هي استخدام
malloc()
من JavaScript لتخصيص جزء من الذاكرة ، ونسخ القيم هناك وتمرير العنوان (الرقم!)
حيث يوجد الصفيف:
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; </script>
بعد البدء ، سترى الإجابة 15 في وحدة التحكم DevTools ، وهي حقًا مجموع جميع الأرقام من 1 إلى 5.
استنتاج
لذلك ، تقرأ حتى النهاية. تهانينا! مرة أخرى ، إذا كنت تشعر بالحمل الزائد ، فكل شيء على ما يرام.
ليس من الضروري قراءة جميع التفاصيل. فهمهم هو أمر اختياري تمامًا لمطور الويب الجيد وليس مطلوبًا حتى للاستخدام المتميز لبرنامج WebAssembly . لكنني أردت مشاركة هذه المعلومات ، لأنها تتيح لك حقًا تقدير كل الأعمال التي يقوم بها مشروع مثل
Emscripten نيابة عنك. في الوقت نفسه ، يعطي هذا فهمًا لمدى صغر حجم الوحدات الحسابية البحتة في WebAssembly. حجم الوحدة النمطية Wasm لتلخيص الصفيف 230 بايت فقط ،
بما في ذلك تخصيص ذاكرة حيوية . يؤدي تجميع نفس الرمز مع Emscripten إلى إنتاج 100 بايت من رمز WebAssembly ورمز ارتباط JavaScript 11K. تحتاج إلى محاولة من أجل هذه النتيجة ، ولكن هناك حالات عندما يكون الأمر يستحق ذلك.