كيف تكتب (لعبة) JVM

تبين أن المقالة حول KVM مثيرة للاهتمام للقراء ، لذلك ننشر اليوم ترجمة جديدة لمقال سيرج زايتسيف: كيف تعمل Java Virtual Machine تحت الغطاء.


سواء أحببنا ذلك أم لا ، تعد Java واحدة من أكثر لغات البرمجة شيوعًا والأكثر استخدامًا. ومع ذلك ، ليس كل مطور Java فضوليًا بما يكفي للنظر تحت الغطاء ورؤية كيفية عمل JVM.



سأحاول كتابة لعبة JVM (وغير مكتملة) لإظهار المبادئ الأساسية لكيفية عملها. آمل أن يثير هذا المقال اهتمامك ويلهمك لاستكشاف المزيد من JVM.



هدفنا المتواضع



لنبدأ ببساطة:



public class Add {
  public static int add(int a, int b) {
    return a + b;
  }
}

      
      





نقوم بتجميع صفنا باستخدام javac Add.java والنتيجة هي Add.class . ملف الفئة هذا هو ملف ثنائي يمكن لـ JVM تنفيذه. كل ما علينا فعله هو إنشاء JVM التي ستنفذها بشكل صحيح.



وإذا نظرنا داخل Add.class مع تفريغ عرافة، أننا ربما لن يكون أعجب جدا: على الرغم من أننا لا نرى ان هناك هيكل واضح هنا حتى الآن، نحن بحاجة إلى إيجاد وسيلة لتحليل ذلك: ما يفعل () V و (II) I ، < init> ولماذا يبدأ الملف بـ "cafe babe"؟



00000000 ca fe ba be 00 00 00 34 00 0f 0a 00 03 00 0c 07 |.......4........|

00000010 00 0d 07 00 0e 01 00 06 3c 69 6e 69 74 3e 01 00 |........<init>..|

00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 |.()V...Code...Li|

00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03 |neNumberTable...|

00000040 61 64 64 01 00 05 28 49 49 29 49 01 00 0a 53 6f |add...(II)I...So|

00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 64 2e 6a |urceFile...Add.j|

00000060 61 76 61 0c 00 04 00 05 01 00 03 41 64 64 01 00 |ava........Add..|

00000070 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 |.java/lang/Objec|

00000080 74 00 21 00 02 00 03 00 00 00 00 00 02 00 01 00 |t.!.............|

00000090 04 00 05 00 01 00 06 00 00 00 1d 00 01 00 01 00 |................|

000000a0 00 00 05 2a b7 00 01 b1 00 00 00 01 00 07 00 00 |...*............|

000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01 |................|

000000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b |................|

000000d0 60 ac 00 00 00 01 00 07 00 00 00 06 00 01 00 00 |`...............|

000000e0 00 03 00 01 00 0a 00 00 00 02 00 0b |............|












ربما تكون معتادًا على طريقة أخرى لإلغاء تحميل ملفات الفصل الدراسي ، وغالبًا ما تكون أكثر فائدة: الآن نرى الفصل الخاص بنا ومنشئه وطريقته. يحتوي كل من المُنشئ والطريقة على العديد من التعليمات. يصبح من الواضح إلى حد ما ما تقوم به طريقة add () الخاصة بنا: فهي تقوم بتحميل وسيطين ( iload_0 و iload_1 ) ، وتلخصها ، وتعيد النتيجة. إن JVM عبارة عن آلة مكدس ، لذلك لا تحتوي على سجلات ، ويتم تخزين جميع وسائط التعليمات في مكدس داخلي ، ويتم دفع النتائج إلى المكدس أيضًا.



$ javap -c Add

Compiled from "Add.java"

public class Add {

public Add();

Code:

0: aload_0

1: invokespecial #1 // Method java/lang/Object."<init>":()V

4: return



public static int add(int, int);

Code:

0: iload_0

1: iload_1

2: iadd

3: ireturn

}












محمل فئة



كيف نحقق نفس النتيجة التي أظهرها برنامج جافاب؟ كيفية تحليل ملف الفصل؟



إذا نظرنا إلى مواصفات JVM ، فإننا نتعرف على بنية ملفات الفصل . يبدأ دائمًا بتوقيع 4 بايت (CAFEBABE) متبوعًا بـ 2 + 2 بايت للإصدار. تبدو بسيطة!



نظرًا لأننا سنضطر إلى قراءة تسلسلات البايت والبايت القصيرة والبايتة من ملف ثنائي ، فلنبدأ تنفيذ أداة التحميل على النحو التالي:



type loader struct {
	r   io.Reader
	err error
}

func (l *loader) bytes(n int) []byte {
	b := make([]byte, n, n)
        //        ,
        //        
        //    ,    
	if l.err == nil {
		_, l.err = io.ReadFull(l.r, b)
	}
	return b
}
func (l *loader) u1() uint8  { return l.bytes(1)[0] }
func (l *loader) u2() uint16 { return binary.BigEndian.Uint16(l.bytes(2)) }
func (l *loader) u4() uint32 { return binary.BigEndian.Uint32(l.bytes(4)) }
func (l *loader) u8() uint64 { return binary.BigEndian.Uint64(l.bytes(8)) }

// Usage:
f, _ := os.Open("Add.class")
loader := &loader{r: f}
cafebabe := loader.u4()
major := loader.u2()
minor := loader.u2()
      
      





تخبرنا المواصفات بعد ذلك بتحليل التجمع الثابت. ما هذا؟ هذا هو اسم الجزء الخاص من ملف الفصل الذي يحتوي على الثوابت المطلوبة لتشغيل الفصل. يتم تخزين جميع السلاسل والثوابت الرقمية والمراجع هناك ، ولكل منها فهرس uint16 فريد (لذلك يمكن أن تحتوي الفئة على ما يصل إلى 64 كيلو من الثوابت).



توجد عدة أنواع من الثوابت في التجمع ، يحتوي كل منها على مجموعة القيم الخاصة به. قد نكون مهتمين بـ:



  • UTF8: سلسلة حرفية بسيطة
  • الفئة: فهرس السلسلة التي تحتوي على اسم الفئة (مرجع غير مباشر)
  • الاسم والنوع: فهرس اسم النوع والموصف المستخدم للحقول والأساليب
  • مراجع الحقول والطريقة: الفهارس المتعلقة بالفئات والثوابت من اسم النوع والنوع.


كما ترى ، غالبًا ما تشير الثوابت في التجمع إلى بعضها البعض. نظرًا لأننا نكتب JVM in Go ولا توجد أنواع اتحادات ، فلنقم بإنشاء نوع Const يحتوي على العديد من الحقول الثابتة الممكنة:



type Const struct {
	Tag              byte
	NameIndex        uint16
	ClassIndex       uint16
	NameAndTypeIndex uint16
	StringIndex      uint16
	DescIndex        uint16
	String           string
}
type ConstPool []Const
      
      





بعد ذلك ، باتباع مواصفات JVM ، يمكننا استرداد بيانات التجمع الثابتة مثل هذا:



func (l *loader) cpinfo() (constPool ConstPool) {
	constPoolCount := l.u2()
	//       1
	for i := uint16(1); i < constPoolCount; i++ {
		c := Const{Tag: l.u1()}
		switch c.Tag {
		case 0x01: // UTF8 string literal, 2 bytes length + data
			c.String = string(l.bytes(int(l.u2())))
		case 0x07: // Class index
			c.NameIndex = l.u2()
		case 0x08: // String reference index
			c.StringIndex = l.u2()
		case 0x09, 0x0a: // Field and method: class index + NaT index
			c.ClassIndex = l.u2()
			c.NameAndTypeIndex = l.u2()
		case 0x0c: // Name-and-type
			c.NameIndex, c.DescIndex = l.u2(), l.u2()
		default:
			l.err = fmt.Errorf("unsupported tag: %d", c.Tag)
		}
		constPool = append(constPool, c)
	}
	return constPool
}
      
      





نحن الآن نبسط مهمتنا إلى حد كبير ، ولكن في JVM الحقيقي ، سيتعين علينا التعامل مع الأنواع الثابتة الطويلة والمزدوجة بشكل موحد ، وإدخال عنصر ثابت إضافي غير مستخدم ، كما تخبرنا مواصفات JVM (نظرًا لأن العناصر الثابتة تعتبر 32 بت).



لتسهيل الحصول على القيم الحرفية للسلسلة من خلال المؤشرات ، دعنا نطبق طريقة الحل (index uint16) للسلسلة :



func (cp ConstPool) Resolve(index uint16) string {
	if cp[index-1].Tag == 0x01 {
		return cp[index-1].String
	}
	return ""
}
      
      





نحتاج الآن إلى إضافة مساعدين مشابهين لتحليل قائمة الواجهات والحقول وأساليب الفئات وخصائصها:



func (l *loader) interfaces(cp ConstPool) (interfaces []string) {
	interfaceCount := l.u2()
	for i := uint16(0); i < interfaceCount; i++ {
		interfaces = append(interfaces, cp.Resolve(l.u2()))
	}
	return interfaces
}

//  field      
type Field struct {
	Flags      uint16
	Name       string
	Descriptor string 
	Attributes []Attribute 
}

//        
//   -   Code,     
type Attribute struct {
	Name string
	Data []byte
}

func (l *loader) fields(cp ConstPool) (fields []Field) {
	fieldsCount := l.u2()
	for i := uint16(0); i < fieldsCount; i++ {
		fields = append(fields, Field{
			Flags:      l.u2(),
			Name:       cp.Resolve(l.u2()),
			Descriptor: cp.Resolve(l.u2()),
			Attributes: l.attrs(cp),
		})
	}
	return fields
}

func (l *loader) attrs(cp ConstPool) (attrs []Attribute) {
	attributesCount := l.u2()
	for i := uint16(0); i < attributesCount; i++ {
		attrs = append(attrs, Attribute{
			Name: cp.Resolve(l.u2()),
			Data: l.bytes(int(l.u4())),
		})
	}
	return attrs
}
      
      





يتم تمثيل كل من الحقول والطرق على أنها الحقول ، وهو أمر مريح للغاية ويوفر الوقت. أخيرًا ، يمكننا تجميعها جميعًا وتحليل صفنا بالكامل:



type Class struct {
	ConstPool  ConstPool
	Name       string
	Super      string
	Flags      uint16
	Interfaces []string
	Fields     []Field
	Methods    []Field
	Attributes []Attribute
}

func Load(r io.Reader) (Class, error) {
	loader := &loader{r: r}
	c := Class{}
	loader.u8()           // magic (u32), minor (u16), major (u16)
	cp := loader.cpinfo() // const pool info
	c.ConstPool = cp
	c.Flags = loader.u2()             // access flags
	c.Name = cp.Resolve(loader.u2())  // this class
	c.Super = cp.Resolve(loader.u2()) // super class
	c.Interfaces = loader.interfaces(cp)
	c.Fields = loader.fields(cp)    // fields
	c.Methods = loader.fields(cp)   // methods
	c.Attributes = loader.attrs(cp) // methods
	return c, loader.err
}
      
      





الآن، إذا نظرنا إلى المعلومات حول فئة، وسوف نرى انه لايوجد المجالات، طريقتين - <والحرف الأول> :() وV و الوظيفة: (II من) الأول من . ما هي الأرقام الرومانية بين قوسين؟ هذه هي الواصفات. يحددون أنواع الحجج التي تأخذها الطريقة وما تعيده. في هذه الحالة ، لا تأخذ <init> (الطريقة التركيبية المستخدمة لتهيئة الكائنات عند إنشائها) أي وسيطات ولا تُرجع شيئًا (V = void) ، بينما تقبل طريقة "add" نوعين من البيانات int (I = int32) و إرجاع عدد صحيح.



بايت كود



إذا نظرنا عن كثب ، فإننا ندرك أن كل طريقة في صنفنا المحلل لها خاصية واحدة تسمى "Code". تحتوي هذه السمة على جزء من البايت كحمولة. بايت: في المواصفات ، هذه المرة في قسم bytecode ، سنقرأ أن سمة "Code" تبدأ بقيمة maxstack (2 بايت) ، ثم maxlocals (2 بايت) ، وطول الشفرة (4 بايت) ثم الرمز الفعلي. لذلك ، يمكن قراءة سماتنا على النحو التالي: نعم ، لدينا فقط 4 و 5 بايت من التعليمات البرمجية في كل طريقة. ماذا تعني هذه البايتات؟



<init>:

[0 1 0 1 0 0 0 5 42 183 0 1 177 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 1]

add:

[0 2 0 2 0 0 0 4 26 27 96 172 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 3]












<init>: maxstack: 1, maxlocals: 1, code: [42 183 0 1 177]

add: maxstack: 2, maxlocals: 2, code: [26 27 96 172]












كما قلت ، فإن JVM عبارة عن آلة تكديس. يتم ترميز كل تعليمة على أنها بايت واحد ، والتي يمكن أن تتبعها وسيطات إضافية. بالنظر إلى المواصفات ، يمكننا أن نرى أن طريقة "add" تحتوي على الإرشادات التالية: تمامًا كما رأينا في إخراج javap في البداية! ولكن كيف نفعل ذلك؟



26 = iload_0

27 = iload_1

96 = iadd

172 = ireturn












إطارات JVM



عندما يتم تنفيذ طريقة داخل JVM ، يكون لها مكدس خاص بها للمعاملات المؤقتة ، والمتغيرات المحلية الخاصة بها ، وقطعة من التعليمات البرمجية لتنفيذه. يتم تخزين كل هذه المعلمات في إطار تنفيذ واحد. بالإضافة إلى ذلك ، تحتوي الإطارات على مؤشر للتعليمات الحالية (إلى أي مدى وصلنا في تنفيذ الرمز الثانوي) ومؤشر للفئة التي تحتوي على الطريقة. هذا الأخير ضروري للوصول إلى مجموعة ثوابت الفئة ، بالإضافة إلى التفاصيل الأخرى.



دعنا ننشئ طريقة تنشئ إطارًا لطريقة معينة تسمى بالمتغيرات المحددة. سأستخدم الواجهة {} كنوع القيمة هنا ، على الرغم من أن استخدام أنواع الاتحاد الصحيحة سيكون بالطبع أكثر أمانًا.



type Frame struct {
	Class  Class
	IP     uint32
	Code   []byte
	Locals []interface{}
	Stack  []interface{}
}

func (c Class) Frame(method string, args ...interface{}) Frame {
	for _, m := range c.Methods {
		if m.Name == method {
			for _, a := range m.Attributes {
				if a.Name == "Code" && len(a.Data) > 8 {
					maxLocals := binary.BigEndian.Uint16(a.Data[2:4])
					frame := Frame{
						Class:  c,
						Code:   a.Data[8:],
						Locals: make(
									[]interface{}, 
									maxLocals, 
									maxLocals
								),
					}
					for i := 0; i < len(args); i++ {
						frame.Locals[i] = args[i]
					}
					return frame
				}
			}
		}
	}
	panic("method not found")
}
      
      





لذلك حصلنا على إطار به متغيرات محلية مُهيأة ، ومكدس فارغ ، ورمز ثنائي مُحمَّل مسبقًا. حان الوقت لتنفيذ الرمز الثانوي:



func Exec(f Frame) interface{} {
	for {
		op := f.Code[f.IP]
		log.Printf("OP:%02x STACK:%v", op, f.Stack)
		n := len(f.Stack)
		switch op {
		case 26: // iload_0
			f.Stack = append(f.Stack, f.Locals[0])
		case 27: // iload_1
			f.Stack = append(f.Stack, f.Locals[1])
		case 96:
			a := f.Stack[n-1].(int32)
			b := f.Stack[n-2].(int32)
			f.Stack[n-2] = a + b
			f.Stack = f.Stack[:n-1]
		case 172: // ireturn
			v := f.Stack[n-1]
			f.Stack = f.Stack[:n-1]
			return v
		}
		f.IP++
	}
}

      
      





أخيرًا ، يمكننا تجميع كل شيء معًا وتشغيله عن طريق استدعاء طريقة add () الخاصة بنا:



f, _ := os.Open("Add.class")
class, _ := Load(f)
frame := class.Frame("add", int32(2), int32(3))
result := Exec(frame)
log.Println(result)

// OUTPUT:
OP:1a STACK:[]
OP:1b STACK:[2]
OP:60 STACK:[2 3]
OP:ac STACK:[5]
5
      
      





لذلك كل شيء يعمل. نعم ، هذا JVM ضعيف جدًا في الحد الأدنى ، لكنه لا يزال يفعل ما يفترض أن يفعله JVM: تنزيل الرمز الثانوي وتفسيره (بالطبع ، يقوم JVM الحقيقي بأكثر من ذلك بكثير).



ما المفقود؟



ما تبقى من 200 تعليمات وأوقات تشغيل وأنظمة نوع OOP وبعض الأشياء الأخرى.



هناك 11 مجموعة من التعليمات ، معظمها شائع:



  • الثوابت (يدفع القيم الخالية أو الصغيرة أو القيم من التجمع الثابت إلى المكدس).
  • الأحمال (يدفع المتغيرات المحلية إلى المكدس). تعليمات مماثلة 32.
  • Stores ( ). 32 .
  • Stack (pop/dup/swap), .
  • Math (add/sub/div/mul/rem/shift/logic). , 36 .
  • Conversions (int short, int float ..).
  • Comparisons (eq/ne/le/…). , if/else.
  • Control (goto/return). .
  • References. , , .
  • Extended. . , , .
  • Reserved. 0xca.


معظم التعليمات سهلة التنفيذ: فهي تأخذ وسيطة أو اثنتين من المكدس ، وتنفذ بعض العمليات عليها ، وترسل النتيجة. الشيء الوحيد الذي يجب أخذه في الاعتبار هنا هو أن التعليمات الخاصة بالأنواع الطويلة والمزدوجة تفترض أن كل قيمة تحتل فتحتين على المكدس ، لذلك قد تحتاج إلى دفع إضافي () و pop () ، مما يجعل تعليمات التجميع صعبة.



لتنفيذ المراجع ، تحتاج إلى التفكير في نموذج الكائن: كيف تريد تخزين الكائنات وفئاتها ، وكيفية تمثيل الوراثة ، ومكان تخزين حقول المثيلات وحقول الفئة. أيضًا ، عليك هنا توخي الحذر عند إرسال الطريقة - هناك العديد من عبارات "الاستدعاء" وهي تتصرف بشكل مختلف:



  • Invokestatic: استدعاء طريقة ثابتة على فئة ، لا مفاجآت.
  • invokespecial: , , <init> .
  • invokevirtual: .
  • invokeinterface: , invokevirtual, .
  • invokedynamic: call site, Java 7, MethodHandles.


إذا كنت تقوم ببناء JVM بلغة بدون جمع البيانات المهملة ، فعليك التفكير في كيفية القيام بذلك: عد المرجع ، وخوارزمية التحديد والاكتساح ، إلخ . استخدام جداول الاستثناء هو موضوع آخر مثير للاهتمام.



أخيرًا ، سيكون JVM الخاص بك عديم الفائدة إذا لم تكن هناك فصول تشغيل. بدون java / lang / Object ، لا يمكنك حتى رؤية كيف يعمل الجديدتعليمات عند إنشاء كائنات جديدة. قد يوفر وقت التشغيل بعض فئات JRE العامة من حزم java.lang و java.io و java.util ، أو قد يكون شيئًا أكثر تحديدًا للمجال. على الأرجح ، يجب تنفيذ بعض الأساليب في الفئات محليًا ، وليس في Java. سيؤدي هذا إلى إثارة السؤال حول كيفية العثور على مثل هذه الأساليب وتنفيذها ، وسيكون حالة إضافية أخرى لـ JVM الخاص بك.



بمعنى آخر ، بناء JVM الصحيح ليس بالأمر السهل ، لكن اكتشاف كيفية عمله بالضبط ليس بالأمر الصعب.



استغرق الأمر مني ، على سبيل المثال ، عطلة صيفية واحدة فقط. لا يزال أمام JVM طريق طويل لنقطعه ، لكن الهيكل يبدو أكثر أو أقل وضوحًا: https://github.com/zserge/tojvm (التعليقات مرحب بها دائمًا!)



مقتطفات التعليمات البرمجية الفعلية من هذه المقالة أصغر حتى ومتاحة كوجهة هنا .



إذا كنت تريد معرفة المزيد ، فيمكنك التفكير في استخدام JVMs الصغيرة:





آمل ألا يكون هذا المقال قد ثبط اهتمامك بجافا. تعد الأجهزة الافتراضية ممتعة ، وجهاز Java الظاهري يستحق حقًا نظرة فاحصة.



أتمنى أن تكون قد استمتعت بمقالي. يمكنك متابعة عملي على Github أو Twitter والاشتراك عبر RSS .



All Articles