• Vui lòng đọc nội qui diễn đàn để tránh bị xóa bài viết
  • Tìm kiếm trước khi đặt câu hỏi

Đôi điều về CLI

Các bài viết hướng dẫn về Visual Basic .NET và C#

Điều hành viên: tungcan5diop, QUANITGROBEST

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI

Gửi bàigửi bởi alexanderdna » T.Tư 27/07/2011 11:00 am

Tên bài viết: Đôi điều về CLI
Tác giả: Đặng Nhật Anh
Cấp độ bài viết: Trung cấp
Tóm tắt: Những kiến thức căn bản về nền tảng CLI, cách hoạt động của chương trình CLI và giới thiệu ngôn ngữ trung gian CIL.


PHẦN MỘT

I. Lời mở đầu
Tài liệu này được biên soạn sau thời gian tác giả nghiên cứu cách viết chương trình biên dịch cho một ngôn ngữ tương hợp .NET Framework. Nội dung của tài liệu là những kinh nghiệm và tri thức tác giả đã rút ra từ những gì mình đã trải qua. Có thể sẽ còn rất nhiều điều sai lầm và thiếu sót mà, trong giới hạn kiến thức của bản thân, tác giả chưa thể nhận ra được. Do vậy, mọi ý kiến, mọi lời phê bình của quý độc giả đều rất quý báu và giúp cho tài liệu này được ngày càng hoàn thiện hơn. Xin chân thành cảm ơn.

II. Giới thiệu
CLI là viết tắt của Common Language Infrastructure (tạm dịch là Hạ tầng Cấu trúc Ngôn ngữ Chung). Đây là một đặc tả do Microsoft phát triển, ISO và ECMA giúp chuẩn hóa. Đặc tả này đề ra những quy ước về một môi trường thi hành, môi trường xây dựng ứng dụng, cũng như về dạng thức của một chương trình hoạt động trong môi trường này.
Nói một cách dễ hình dung hơn, CLI cho biết một tập tin thực thi (executable) có dạng thức ra sao, phải thi hành bằng cách nào, nó dùng lệnh gì hay dữ liệu gì khi thi hành. Và CLI cũng mô tả môi trường mà mã lệnh được thi hành.
Tất cả các ngôn ngữ và chương trình nào mà đã tuân hành quy định của CLI, thì đều có thể giao tiếp với nhau. Thế thì, nó cũng tựa như quy ước của Tổ chức Mậu dịch Thế giới (WTO), mà các quốc gia thành viên phải tuân theo để có thể giao thương với nhau.
CLI là nền tảng để tạo nên Microsoft .NET Framework, cũng như người đồng nghiệp của nó là Mono. Một chương trình tương hợp CLI, nếu không dùng một tính năng riêng nào của .NET hay Mono, thì có thể thi hành được trên cả .NET và Mono. Đó chính là do hai môi trường đó đã tuân hành quy định chung do CLI đề ra.
Bên cạnh đó, CLI còn là một đặc tả cho nhiều nền tảng. Điều này có nghĩa là, một chương trình được viết ra có thể thi hành ở trên bộ vi xử lý Intel x86, x64, trên ARM, cũng như trên Windows, trên Linux, trên MacOS mà không cần biên dịch lại hoặc không cần sửa lại mã nguồn, miễn là người ta cung cấp cho các nền tảng đó một môi trường thi hành thích hợp. Tới đây thì chắc hẳn quý vị có thể nhận định rằng, ở CLI có một cái gì đó rất Java. Tức là, «viết một lần, chạy khắp nơi» (write once, run anywhere), miễn là có máy ảo để chạy. Thưa vâng, chính là cái ý niệm như vậy.

III. Các khái niệm
Ngoài những điều đã bàn ở trên, khi đến với CLI, chúng ta còn gặp một số khái niệm khác cũng quan trọng không kém.

1. CLS
CLS là viết tắt của Common Language Specification (tạm dịch là Đặc tả Ngôn ngữ Chung). Đây là một tập hợp các quy ước mà mọi ngôn ngữ viết cho CLI đều phải tuân theo, mới có thể hợp tác với nhau được.
Bởi vì C# và Visual Basic .NET tuân theo CLS, cho nên một lớp viết bằng C# có thể được dùng trong VB.NET, hoặc một đối tượng viết trong VB.NET có thể truyền vô một hàm ở chương trình viết bằng C#. Có thể coi CLS là đồng phục của mọi ngôn ngữ tương hợp CLI.

2. CTS
CTS là viết tắt của Common Type System (tạm dịch là Hệ thống Kiểu Chung). Đây cũng là một tập hợp các quy ước. Chúng cho biết mỗi kiểu dữ liệu được khai báo ra làm sao, định nghĩa như thế nào, sử dụng và quản trị theo cách gì.
Các ngôn ngữ tương hợp CLI phải tuân theo CTS. Bởi vậy mà các kiểu dữ liệu do ngôn ngữ này tạo ra có thể đem dùng ở một ngôn ngữ khác. Trong phần sau, chúng ta sẽ bàn kỹ hơn về hệ thống kiểu.

3. Metadata
Có thể dịch là siêu dữ liệu hay thượng dữ liệu, đây là những thông tin mô tả mã lệnh của một chương trình sau khi nó được biên dịch. Các dữ liệu này có dạng thức độc lập, tức là dù dùng ngôn ngữ nào để lập trình thì cũng cho ra một kiểu dữ liệu mô tả như vậy.
Thượng dữ liệu giúp cho hệ thống thi hành nắm bắt được đặc điểm của các chương trình, nhằm kiểm soát được quá trình thi hành của chúng.

4. CLR
CLR là viết tắt của Common Language Runtime (tạm dịch là Hệ thi hành Ngôn ngữ Chung). Nói một cách đơn giản, đây là hệ thống máy ảo dùng để chạy các chương trình viết cho CLI. Nói đầy đủ hơn, thì ngoài việc làm máy ảo, CLR còn cung cấp các dịch vụ trợ giúp cho quá trình thi hành, thực hiện các thao tác kiểm tra nghiêm ngặt để đảm bảo rằng chương trình được thực hiện một cách đúng đắn, an toàn và bảo mật.
Các mã lệnh viết thành chương trình rồi thi hành trên CLR được gọi là managed code, tức mã hữu quản. Nghĩa là, mã lệnh đó chịu sự quản trị và kiểm soát của CLR nhằm các mục tiêu như vừa nêu. Có khi người ta cũng có thể và cần dùng mã lệnh bên ngoài (từ các DLL viết bằng C chẳng hạn) trong chương trình. Mã này là mã vô quản (unmanaged code), nằm ngoài quyền hành của hệ CLR.
Mã hữu quản hàm chứa các thượng dữ liệu giúp mô tả chúng. Mã vô quản không có các dữ liệu này. So với mã vô quản thì mã hữu quản an toàn hơn, các lỗi như rò rỉ bộ nhớ, truy cập bộ nhớ bất hợp lệ, trật kiểu,… đều bị hệ thi hành chặn đứng và giải quyết, có khi ngay từ lúc biên dịch.

5. VES
VES là viết tắt của Virtual Execution System (tạm dịch là Hệ thống Thi hành Ảo). Đây là hạt nhân của CLR. Nó có nhiệm vụ nạp và thực thi các chương trình. Trong tiến trình đó, nó phân tích các thượng dữ liệu để kiểm soát chương trình, cung cấp những dịch vụ cần thiết, như bộ thu dọn rác (garbage collector) chẳng hạn.

6. CIL
Là viết tắt của Common Intermediate Language, còn gọi là Microsoft Intermediate Language. Đây là một loại ngôn ngữ trung gian. Các chương trình dù viết bằng C#, VB.NET, J#, F#, Oxygene hay bất cứ ngôn ngữ tương hợp CLI nào, sau rốt, cũng đều được biên dịch ra CIL. Nó là một thứ hợp ngữ của máy ảo CLR.
Một khi chương trình được nạp vào VES, các mã lệnh CIL sẽ được dịch ra ngôn ngữ máy của kiến trúc hiện tại, thí dụ như x86, ARM, PowerPC,… rồi mới thi hành. Việc chuyển dịch này gọi là Just-in-time compilation. Theo đó, chỉ có những phần nào cần thiết cho việc thi hành chương trình vào thời điểm đó thì mới được dịch ra mà thôi. Một khi đã dịch ra rồi, mã máy này sẽ được lưu giữ để dùng cho những lần sau và không cần dịch lại. Tất nhiên, nếu hệ thi hành nhận ra là chương trình đã thay đổi (lên phiên bản mới) thì buộc phải dịch lại vậy.
Ở phần sau chúng ta sẽ bàn chi tiết về các lệnh trong CIL.

7. Assembly
Xin quý vị đừng lầm với khái niệm assembly language (hợp ngữ). Ở đây, assembly là một tập hợp hoàn chỉnh chứa đựng chương trình, các thông tin về phiên bản, nguồn gốc, dữ liệu, tài nguyên,… và được đóng gói trong một tập tin EXE hoặc DLL.

IV. Hệ thống kiểu
Có hai nhóm kiểu trong hệ thống này. Đó là kiểu tham chiếu (reference type) và kiểu giá trị (value type).
Tất cả các kiểu dữ liệu trong CTS đều kế thừa từ kiểu System.Object. Kiểu giá trị kế thừa từ kiểu System.ValueType. Kiểu này cũng là dẫn xuất của Object mà thôi. Riêng các kiểu liệt kê (enum) kế thừa từ System.Enum, mà kiểu này là dẫn xuất của ValueType.

Để phần sau dễ hình dung, ở đây chúng ta tạm định nghĩa một kiểu dữ liệu PhânSố gồm hai trường TửMẫu thuộc kiểu số nguyên.

1. Kiểu tham chiếu
Kiểu tham chiếu hàm chứa một địa chỉ đến đối tượng lưu trong bộ nhớ. Nói cách khác, nó là một con trỏ với các tính chất an toàn của mã hữu quản.
Khi cần sử dụng một đối tượng thuộc kiểu tham chiếu, người ta phải khởi tạo nó (thường với từ khóa new). Một đối tượng như vậy chứa các dữ liệu của nó trong một vùng bộ nhớ gọi là heap. Vùng này do hệ thi hành kiểm soát. Một khi đối tượng không còn được sử dụng nữa, bộ thu dọn rác sẽ lấy lại không gian bộ nhớ mà nó từng dùng, để dành cấp phát cho đối tượng khác.
Khi ta dùng một biến kiểu tham chiếu, thực chất biến đó chỉ chứa địa chỉ đến đối tượng trong heap mà thôi.
Lập trình viên C# dùng từ khóa class để định nghĩa một kiểu tham chiếu. Interface cũng là kiểu tham chiếu. Array (mảng) rốt cuộc cũng là kiểu tham chiếu.
Nếu kiểu PhânSố đã nói ở trên là một kiểu tham chiếu (định nghĩa bằng từ khóa class) thì khi khởi tạo một đối tượng kiểu này, một không gian dành cho hai số nguyên được cấp phát trong heap. Biến nhận sẽ lưu địa chỉ của không gian đó để về sau có thể truy cập.
Khi so sánh hai đối tượng kiểu tham chiếu, thực chất là so sánh xem địa chỉ mà chúng trỏ tới có giống nhau hay không.

2. Kiểu giá trị
Kiểu giá trị hàm chứa tất cả dữ liệu của nó một cách trực tiếp. Trong C#, người ta dùng từ khóa struct để định nghĩa kiểu giá trị.
Nếu kiểu PhânSố nêu trên là một kiểu giá trị thì một biến phân số sẽ lưu cả hai số nguyên liên tiếp cho Tử và Mẫu. Tức là lưu dữ liệu ngay trong biến, thay vì gián tiếp trỏ tới một chỗ trong heap.
Khi so sánh hai đối tượng kiểu giá trị, tất cả dữ liệu của chúng được đem ra để đối sánh từng đôi một.

3. Các kiểu căn bản
Hầu hết các kiểu căn bản là kiểu số, vốn là các kiểu giá trị. Trong đó, ta phân ra hai nhóm là số nguyên và số thực. Mọi kiểu số nguyên đều có hai dạng là có dấu và không dấu, với hai trường MinValue và MaxValue cho biết trị cực tiểu và cực đại mà kiểu đó chứa được.
Tất cả các kiểu căn bản dưới đây đều có tính chất nội hàm bất biến (immutability). Nghĩa là, một khi đã khởi tạo một đối tượng, dữ liệu nội tại của nó sẽ không bao giờ thay đổi.

a. Số nguyên
  • Byte: số nguyên 8-bit không dấu, phạm vi 0 ÷ 255.
  • SByte: số nguyên 8-bit có dấu, phạm vi -128 ÷ 127.
  • Int16: số nguyên 16-bit có dấu, phạm -32768 ÷ 32767.
  • Uint16: số nguyên 16-bit không dấu, phạm vi 0 ÷ 65535.
  • Int32: số nguyên 32-bit có dấu, phạm vi -2^31 ÷ (2^31 – 1).
  • UInt32: số nguyên 32-bit không dấu, phạm vi 0 ÷ (2^32 – 1).
  • Int64: số nguyên 64-bit có dấu, phạm vi -2^63 ÷ (2^63 – 1).
  • UInt64: số nguyên 64-bit không dấu, phạm vi 0 ÷ (2^64 – 1).
Trong số trên, các kiểu SByte, UInt16, UInt32 và UInt64 không tương hợp với CLS, nghĩa là chúng có thể vắng mặt trong các môi trường triển khai CLS khác ngoài .NET Framework.

b. Số thực
  • Single: số thực dấu chấm động, độ chính xác đơn.
  • Double: số thực dấu chấm động, độ chính xác kép.

c. String
Là chuỗi các ký tự Unicode. Giống như các kiểu căn bản khác, kiểu String có tính chất nội hàm bất biến. Do đó, không thể thay đổi giá trị của chuỗi hay bất cứ ký tự nào trong chuỗi.
Đây là một kiểu tham chiếu, nhưng được đối xử theo một cách mà từ ngoài nhìn vào thì tựa như là kiểu giá trị. Khi so sánh hai đối tượng kiểu String, thay vì so sánh tham chiếu, hệ thống sẽ so sánh nội dung của chúng bằng phương thức String.Equals(string s1, string s2) được định nghĩa trong lớp String.
Để so sánh hai đối tượng kiểu String ở dạng tham chiếu, người ta phải dùng phương thức Object.ReferenceEquals.

d. Các kiểu khác
  • Boolean: trị luận lý (True/False), kích thước 1 byte.
  • Char: ký tự Unicode 16-bit.
  • Decimal: số thực thập phân 128-bit (8 byte).
  • IntPtr: số nguyên có dấu với kích thước tùy thuộc kiến trúc hệ thống, tức là trên hệ 32-bit thì là số nguyên 32-bit, trên hệ 64-bit thì là số nguyên 64-bit.
  • UIntPtr: tương tự IntPtr nhưng là số không dấu. Kiểu này không tương hợp với CLS.
Các kiểu trên đều là kiểu giá trị.
Sửa lần cuối bởi alexanderdna vào ngày T.Sáu 09/12/2011 10:38 pm với 1 lần sửa.



Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (tiếp theo)

Gửi bàigửi bởi alexanderdna » T.Năm 28/07/2011 11:07 am

PHẦN HAI

V. Lập trình CIL
Ở phần đầu, chúng ta đã biết sơ qua về ngôn ngữ trung gian CIL. Phần này sẽ trình bày chi tiết hơn về các lệnh trong CIL, cũng như cách vận hành của nó.
Để cho đơn giản, từ đây tác giả sẽ dùng chữ IL thay cho CIL.

1. Vận hành
Máy ảo VES là một dạng máy ảo dùng ngăn xếp (stack-based virtual machine). Các dữ liệu cần tính toán được đặt trên một ngăn xếp gọi là evaluation stack. Thí dụ, khi thực hiện lệnh gán c = a + b, thì trước hết, giá trị của biến a và biến b sẽ được đưa vào ngăn xếp (gọi là thao tác load). Sau đó có lệnh add lấy hai giá trị trên ngăn xếp ra, cộng lại rồi đặt kết quả trở vào. Kết quả này sau rốt được lưu vào biến c (gọi là thao tác store).

2. Metadata token
Trong phần lưu trữ thượng dữ liệu của chương trình, có nhiều bảng lưu thông tin về các dữ liệu được sử dụng. Thí dụ, có bảng TypeDef lưu thông tin về các kiểu mà chương trình định nghĩa, có bảng TypeRef lưu thông tin về các kiểu mà chương trình tham chiếu từ bên ngoài. Lại có bảng Field lưu các trường dữ liệu của các kiểu. Mỗi dòng trong bảng TypeDef là một kiểu, có cột FieldList chứa chỉ số của một dòng trong bảng Field, đại diện cho trường dữ liệu đầu tiên của kiểu ấy. Đại khái là như vậy, tác giả không bàn thêm vì rất dài dòng.
Một cái metadata token, hay gọi vắn tắt là token, là số nguyên 32-bit làm chỉ số của một dòng trong các bảng thượng dữ liệu. Có thể là dòng trong bảng TypeDef, TypeRef nếu là token cho kiểu, dòng trong bảng Field nếu là token cho trường, dòng trong bảng MethodDef hay MethodRef nếu là token cho phương thức, v.v..
Một số lệnh IL dùng các token để làm tham số, giúp xác định đúng thông tin cần lấy. Thí dụ, lệnh gọi phương thức phải biết phương thức nào cần được gọi, lệnh lấy giá trị của trường dữ liệu phải biết cần lấy ở trường nào.

3. Lệnh IL
Một số lệnh IL có thể đi cùng với tham số. Tham số thường là một con số hay một token. Một số lệnh lấy dữ liệu ra khỏi ngăn xếp, một số đưa dữ liệu vào, một số làm cả hai việc, một số không làm việc nào.
Ở phần dưới, các lệnh sẽ được viết bằng cú pháp:
  1.     lệnh <kiểu_tham_số tên_tham_số>

Trong đó, phần tham số có thể không có, và kiểu_tham_số có thể là:
  • int8: số nguyên 8-bit.
  • uint8: số nguyên 8-bit không dấu.
  • uint16: số nguyên 16-bit không dấu.
  • int32: số nguyên 32-bit.
  • int64: số nguyên 64-bit.
  • F: số thực, nhận ở cỡ 32-bit hay 64-bit tùy theo lệnh.
  • O: tham chiếu đến một đối tượng.
  • T: một cái token.
Số lượng các lệnh IL khá lớn. Trong tài liệu này, tác giả chỉ đề cập đến các lệnh phổ biến, quan trọng chứ không bàn hết tất cả. Độc giả nếu muốn tìm hiểu thêm, xin cứ theo các thông tin tham khảo ở phần cuối.

a. Lệnh nạp
Lệnh nạp (load) là lệnh đưa dữ liệu vào ngăn xếp.

Các lệnh nạp số
  • ldc.i4 <int32 num>: nạp num ở dạng số nguyên 32-bit.
  • ldc.i8 <int64 num>: nạp num ở dạng số nguyên 64-bit.
  • ldc.r4 <F num>: nạp num ở dạng số thực 32-bit.
  • ldc.r8 <F num>: nạp num ở dạng số thực 64-bit.
Ngoài ra, còn các lệnh vắn tắt như sau:
  • ldc.i4.0: nạp số 0.
  • ldc.i4.1: nạp số 1.
  • ldc.i4.2: nạp số 2.
  • ldc.i4.3: nạp số 3.
  • ldc.i4.4: nạp số 4.
  • ldc.i4.5: nạp số 5.
  • ldc.i4.6: nạp số 6.
  • ldc.i4.7: nạp số 7.
  • ldc.i4.8: nạp số 8.
  • ldc.i4.m1: nạp số -1. Cùng mã với lệnh ldc.i4.M1.
  • ldc.i4.s <int8 num>: kéo dài bit dấu của num cho thành số nguyên 32-bit rồi nạp vào ngăn xếp.
Trên lý thuyết thì có thể dùng ldc.i4 cho mọi trường hợp nạp số nguyên 32-bit. Nhưng người ta đưa ra các lệnh vắn tắt là vì lý do tốc độ cũng như không gian lưu trữ.
Xét ví dụ nạp số 0 vào ngăn xếp:
  • ldc.i4 0 cần hết thảy 1 byte cho lệnh + 4 byte cho tham số = 5 byte.
  • ldc.i4.0 cần duy nhất 1 byte cho lệnh.
  • ldc.i4.s 0 cần 1 byte cho lệnh + 1 byte cho tham số = 2 byte.
Theo đó, ta thấy dùng ldc.i4.0 hoặc ldc.i4.s sẽ tốn chỗ ít hơn, mà hệ thống cũng mau chóng lấy được giá trị hơn: biết ngay với ldc.i4.0 hoặc chỉ lấy 1 byte từ ldc.i4.s, so với lấy 4 byte từ ldc.i4.
Thành ra, một trình biên dịch tốt thường ưu tiên chọn các lệnh vắn tắt trong những trường hợp khả dĩ.

Các lệnh nạp biến
Biến, hay nói đầy đủ là biến cục bộ (local variable), hiện diện bên trong các phương thức và được đánh số chỉ mục từ 0. Các lệnh sau đây nạp giá trị của một biến:
  • ldloc <uint16 idx>: nạp biến có chỉ mục là idx.
  • ldloc.s <uint8 idx>: nạp biến có chỉ mục là idx.
  • ldloc.0: nạp biến có chỉ mục 0.
  • ldloc.1: nạp biến có chỉ mục 1.
  • ldloc.2: nạp biến có chỉ mục 2.
  • ldloc.3: nạp biến có chỉ mục 3.
Lệnh ldloc có mã 0xFF0C, thêm tham số nữa là dài 4 byte. Chỉ khi chỉ mục của biến vượt quá trị số 255 thì mới dùng tới lệnh này. Không thì chỉ dùng ldloc.s (mã là 0x11, tính luôn tham số thì dài 2 byte) là đủ. Có khi chỉ cần ldloc.0/1/2/3.

Đôi khi cái cần nạp không phải là giá trị của biến, mà là địa chỉ của biến. Một trong số các trường hợp cần địa chỉ, là khi truyền biến làm tham số ByRef (truyền theo tham chiếu). Nếu từng học qua C/C++, quý vị chắc hẳn hiểu điều này.
Có hai lệnh nạp địa chỉ của biến, là:
  • ldloca <uint16 idx>
  • ldloca.s <uint8 idx>

Các lệnh nạp tham số của phương thức
Một phương thức có thể không có hoặc có một số tham số (argument). Các tham số có chỉ mục tính từ 0. Trong những phương thức thực thể, tức là có dùng this, thì tham số 0 chính là this.
Các lệnh nạp tham số bao gồm:
  • ldarg <uint16 idx>: nạp tham số có chỉ mục là idx.
  • ldarg.s <uint8 idx>: nạp tham số có chỉ mục là idx.
  • ldarg.0: nạp tham số có chỉ mục 0.
  • ldarg.1: nạp tham số có chỉ mục 1.
  • ldarg.2: nạp tham số có chỉ mục 2.
  • ldarg.3: nạp tham số có chỉ mục 3.
Đối với các tham số truyền theo dạng tham trị, các lệnh trên đây sẽ nạp giá trị của tham số. Còn với các tham số truyền theo dạng tham chiếu (ByRef) thì các lệnh trên chỉ nạp địa chỉ của giá trị trong tham số mà thôi.
Muốn nạp giá trị thực sự của tham số ByRef thì phải dùng các lệnh trên kèm theo sau đó là một lệnh nạp thông qua địa chỉ. Các lệnh nạp qua địa chỉ được bàn ở phần sau.
Các tham số hiện diện trong phương thức cũng tương tự như biến. Đôi khi cũng cần nạp địa chỉ của chúng, với các lệnh:
  • ldarga <uint16 idx>
  • ldarga.s <uint8 idx>
Nếu tham số đã truyền theo tham chiếu thì không thể dùng ldarga/ldarga.s nữa vì nó đã sẵn chứa địa chỉ rồi. Lúc này cần dùng các lệnh nạp tham số bình thường.

Các lệnh nạp giá trị thông qua địa chỉ
Lệnh nạp bằng địa chỉ còn gọi là lệnh nạp gián tiếp (load indirectly). Nó ngầm định rằng trên ngăn xếp có một phần tử chứa địa chỉ, và sẽ lấy địa chỉ đó ra, theo đó truy xuất giá trị rồi nạp vào ngăn xếp.
Lượt đồ của ngăn xếp trước và sau lệnh nạp gián tiếp:
  1. …, địa_chỉ -> …, giá_trị

Các lệnh nạp gián tiếp dành cho kiểu số bao gồm:
  • ldind.i1: truy xuất trị 8-bit có dấu, mở rộng thành 32-bit rồi nạp.
  • ldind.u1: truy xuất trị 8-bit không dấu, mở rộng thành 32-bit rồi nạp.
  • ldind.i2: truy xuất trị 16-bit có dấu, mở rộng thành 32-bit rồi nạp.
  • ldind.u2: truy xuất trị 16-bit không dấu, mở rộng thành 32-bit rồi nạp.
  • ldind.i4: truy xuất và nạp trị 32-bit.
  • ldind.u4: truy xuất và nạp trị 32-bit.
  • ldind.i8: truy xuất và nạp trị 64-bit.
  • idind.u8: truy xuất và nạp trị 64-bit. Cùng mã với ldind.i8.
  • ldind.r4: truy xuất và nạp trị số thực 32-bit.
  • ldind.r8: truy xuất và nạp trị số thực 64-bit.
  • ldind.ref: truy xuất và nạp một đối tượng kiểu tham chiếu.
Lệnh nạp các kiểu số nguyên 8-bit và 16-bit phải có hai phiên bản có dấu và không dấu vì hệ thống cần dựa theo đó mà mở rộng ra trị 32-bit trước khi đưa vào ngăn xếp. Trị 8-bit và 16-bit có dấu thì mở rộng bằng cách kéo dài bit dấu, trị không dấu thì mở rộng với các bit 0.
Lệnh ldind.i4 và ldind.u4 có mã khác nhau nhưng làm việc giống nhau. Lệnh ldind.i8 và ldind.u8 thì có cùng mã. Phân biệt chỉ vì hình thức.

Các lệnh ldind.* thật ra cũng chỉ là phiên bản vắn tắt của lệnh ldobj mà thôi.
  • ldobj <T typeTok>
Lệnh ldobj truy xuất và nạp một giá trị thuộc kiểu do tham số typeTok ấn định.
Nếu typeTok là kiểu giá trị, hệ thống sẽ dựa theo mô tả của kiểu đó để biết cần truy xuất bao nhiêu byte từ địa chỉ đang có. Thí dụ, kiểu PhanSo sau đây có kích thước là 8 byte:
  1. struct PhanSo {
  2.     int tuSo;
  3.     int mauSo;
  4. }

Nếu typeTok là kiểu tham chiếu, hệ thống sẽ truy xuất 4 byte (hoặc 8 byte, tùy theo kiến trúc) từ địa chỉ đang có.

Để dễ hình dung về lệnh nạp gián tiếp, xin xem phương thức sau:
  1. static void Federo(ref int n) {
  2.     int i = n;
  3. }

Câu lệnh int i = n sẽ được dịch sang IL như sau:
  1. ldarg.0 // vì n có chỉ mục là 0
  2. ldind.i4    // vì n có kiểu Int32
  3. stloc.0 // lưu vào i


Các lệnh nạp phần tử trong mảng một chiều
Một lệnh nạp phần tử đòi hỏi trên ngăn xếp phải có tham chiếu của một mảng và một số int32 làm chỉ mục của phần tử. Lượt đồ như sau:
  1. …, mảng, chỉ_mục -> …, phần_tử

Nạp phần tử trong mảng một chiều đơn thuần chỉ là truy xuất phần tử với địa chỉ được tính bằng công thức:
  1. địa_chỉ_mảng + chỉ_mục * kích_thước_của(kiểu_phần_tử)

Ta có:
  • ldelem.i1: nạp phần tử 8-bit có dấu.
  • ldelem.u1: nạp phần tử 8-bit không dấu.
  • ldelem.i2: nạp phần tử 16-bit có dấu.
  • ldelem.u2: nạp phần tử 16-bit không dấu.
  • ldelem.i4: nạp phần tử 32-bit.
  • ldelem.u4: nạp phần tử 32-bit.
  • ldelem.i8: nạp phần tử 64-bit.
  • ldelem.u8: nạp phần tử 64-bit. Cùng mã với ldelem.i8.
  • ldelem.r4: nạp phần tử số thực 32-bit.
  • ldelem.r8: nạp phần tử số thực 64-bit.
  • ldlem.ref: nạp phần tử kiểu tham chiếu.
Cũng như ldind.*, các kiểu số nguyên 8-bit và 16-bit cần hai phiên bản để mở rộng ra trị 32-bit cho đúng. ldelem.i4 và ldelem.u4 khác mã nhưng làm giống nhau. ldelem.i8 và ldelem.u8 cùng mã.
Ngoài ra, có lệnh:
  • ldelem <T typeTok>
Lệnh này truy xuất phần tử có kiểu do typeTok ấn định. Thao tác của nó tương tự lệnh ldobj.
Và đôi khi cũng cần nạp địa chỉ của một phần tử trong mảng, bằng lệnh:
  • ldelema <T elemType>: với elemType là kiểu của phần tử.
Trong C, lệnh này tương tự như:
  1. load( (elemType *)((void *)array + index * sizeof(elemType)) );

Mảng nhiều chiều có cách khác để nạp phần tử, đó là dùng phương thức Get. Chúng ta sẽ không bàn tới vấn đề này ở đây.

Các lệnh nạp trường dữ liệu
Với trường thực thể, tức là cần có this, thì đối tượng đang chứa nó phải có mặt trên ngăn xếp. Lượt đồ như sau:
  1. …, đối_tượng -> …, trường

Đối tượng ở trên ngăn xếp có thể là một tham chiếu hoặc một thực thể thuộc kiểu giá trị. Ta có hai lệnh:
  • ldfld <T fieldTok>: nạp trường do fieldTok ấn định.
  • ldflda <T fieldTok>: nạp địa chỉ của trường do fieldTok ấn định.
Với trường tĩnh (static field), thì trước đó không cần có bất cứ đối tượng nào.

Ta có:
  • ldsfld <T fieldTok>: nạp trường do fieldTok ấn định.
  • ldsflda <T fieldTok>: nạp địa chỉ của trường do fieldTok ấn định.

Các lệnh nạp khác
Ngoài những lệnh đã kể ở trên, còn có một số lệnh sau:
  • ldstr <string>: nạp chuỗi. Ở đây string đại diện cho một vị trí trong bảng các hằng trị chuỗi được dùng trong chương trình.
  • ldnull: nạp trị null.
  • ldlen: lấy mảng đang có trên ngăn xếp, tính và nạp độ dài của nó.

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (tiếp theo)

Gửi bàigửi bởi alexanderdna » T.Sáu 29/07/2011 11:59 am

PHẦN BA

b. Lệnh lưu
Lệnh lưu (store) là lệnh lấy giá trị từ ngăn xếp ra và đưa vào một nơi nào đó, chẳng hạn như biến, tham số hoặc trường dữ liệu.
Yêu cầu chung của các lệnh lưu là trên ngăn xếp phải có một phần tử dữ liệu để đưa vào nơi nhận, và tất nhiên tính chất tương hợp kiểu cần phải được đáp ứng.
Số lượng các lệnh lưu cũng khá phong phú và có cú pháp, cũng như tên gọi, tương ứng với các lệnh nạp cùng nhóm.

Các lệnh lưu biến
Cũng như nhóm lệnh nạp biến, nhóm lệnh lưu biến cũng có các phiên bản sau:
  • stloc <uint16 idx>: lưu vào biến có chỉ mục là idx.
  • stloc.s <uint8 idx>: lưu vào biến có chỉ mục là idx.
  • stloc.0: lưu vào biến có chỉ mục 0.
  • stloc.1: lưu vào biến có chỉ mục 1.
  • stloc.2: lưu vào biến có chỉ mục 2.
  • stloc.3: lưu vào biến có chỉ mục 3.

Các lệnh lưu tham số
Chỉ có hai lệnh lưu dữ liệu vào tham số, là:
  • starg <uint16 idx>: lưu vào tham số có chỉ mục là idx.
  • starg.s <uint8 idx>: lưu vào tham số có chỉ mục là idx.
Đó là dành cho tham số truyền theo dạng tham trị. Còn nếu tham số truyền theo dạng tham chiếu thì cách làm hơi đặc biệt, với các bước sau:
  1. Nạp tham số bằng ldarg (hoặc anh em của nó).
  2. Nạp dữ liệu cần lưu.
  3. Dùng lệnh lưu thông qua địa chỉ: stind*.
Thí dụ, có phương thức sau:
  1. static void Federo(ref int n) {
  2.     n = 10;
  3. }

Câu lệnh n = 10 sẽ được dịch ra mã IL như sau:
  1. ldarg.0 // vì n có chỉ mục là 0
  2. ldc.i4.s 10
  3. stind.i4


Các lệnh lưu thông qua địa chỉ
Còn gọi là lưu gián tiếp (store indirectly), các lệnh loại này yêu cầu trên ngăn xếp có địa chỉ của nơi nhận dữ liệu, và dữ liệu cần lưu. Lượt đồ như sau:
  1. …, địa_chỉ, giá_trị -> …,

Có các lệnh sau đây:
  • stind.i1: lưu giá trị nguyên 8-bit.
  • stind.i2: lưu giá trị nguyên 16-bit.
  • stind.i4: lưu giá trị nguyên 32-bit.
  • stind.i8: lưu giá trị nguyên 64-bit.
  • stind.r4: lưu giá trị số thực 32-bit.
  • stind.r8: lưu giá trị số thực 64-bit.
  • stind.ref: lưu giá trị kiểu tham chiếu.
Các lệnh trên là những phiên bản vắn tắt của lệnh stobj:
  • stobj <T typeTok>
Lệnh này lưu giá trị có kiểu do typeTok ấn định.

Các lệnh lưu phần tử trong mảng
Yêu cầu chung của các lệnh thuộc nhóm này là trên ngăn xếp phải có ba phần tử: mảng, chỉ số, và giá trị cần lưu. Lượt đồ như sau:
  1. …, mảng, chỉ_số, giá_trị -> …,

Các lệnh trong nhóm gồm:
  • stelem.i1: lưu vào phần tử nguyên 8-bit.
  • stelem.i2: lưu vào phần tử nguyên 16-bit.
  • stelem.i4: lưu vào phần tử nguyên 32-bit.
  • stelem.i8: lưu vào phần tử nguyên 64-bit.
  • stelem.r4: lưu vào phần tử số thực 32-bit.
  • stelem.r8: lưu vào phần tử số thực 64-bit.
  • stelem.ref: lưu vào phần tử kiểu tham chiếu.
Trên đây là phiên bản vắn tắt của lệnh:
  • stelem <T typeTok>: lưu vào phần tử có kiểu do typeTok ấn định.

Các lệnh lưu trường dữ liệu
Đối với trường thực thể (cần this), ta có lượt đồ sau:
  1. …, đối_tượng, giá_trị -> …,

Và có lệnh:
  • stfld <T fieldTok>: lưu vào trường do fieldTok ấn định.
Đối với trường tĩnh thì không cần đối tượng nào hết, và dùng lệnh:
  • stsfld <T fieldtok>

c. Lệnh chuyển kiểu
Các lệnh thuộc nhóm này có chức năng chuyển đổi dữ liệu từ kiểu này sang kiểu khác. Ta phân ra hai nhóm nhỏ hơn để dễ phân tích.
Lưu ý chung: kiểu dữ liệu ban đầu phải phù hợp để chuyển đổi sang kiểu mới, nếu không sẽ gây ra lỗi lúc thi hành.

Quy tắc chuyển kiểu
Cho t1 là kiểu ban đầu, t2 là kiểu lúc sau, việc chuyển kiểu có thể thực hiện nếu một trong các điều kiện sau đây được đáp ứng:
  • t1 và t2 cùng là kiểu số.
  • t1 hoặc t2 là kiểu Object.
  • t1 là mảng và t2 là kiểu Array, hoặc ngược lại.
  • t1 và t2 cùng là mảng và cùng số chiều, kiểu phần tử của t1 và t2 phải là kiểu tham chiếu, và kiểu phần tử của t1 phải kế thừa từ kiểu phần tử của t2 hoặc ngược lại.
  • t1 và t2 cùng là mảng và cùng số chiều, kiểu phần tử của t1 và t2 là kiểu liệt kê (enum) và có cùng một kiểu gốc (underlying type).
  • t1 kế thừa t2 hoặc ngược lại.
Trường hợp chuyển kiểu bằng toán tử tự định nghĩa thì không thực hiện trực tiếp bằng lệnh IL, nên không bàn tới ở đây.

Các lệnh chuyển kiểu số
  • conv.i1: chuyển sang kiểu số nguyên 8-bit có dấu.
  • conv.u1: chuyển sang kiểu số nguyên 8-bit không dấu.
  • conv.i2: chuyển sang kiểu số nguyên 16-bit có dấu.
  • conv.u2: chuyển sang kiểu số nguyên 16-bit không dấu.
  • conv.i4: chuyển sang kiểu số nguyên 32-bit có dấu.
  • conv.u4: chuyển sang kiểu số nguyên 32-bit không dấu.
  • conv.i8: chuyển sang kiểu số nguyên 64-bit có dấu.
  • conv.u8: chuyển sang kiểu số nguyên 64-bit không dấu.
  • conv.r4: chuyển sang kiểu số thực 32-bit.
  • conv.r8: chuyển sang kiểu số thực 64-bit.
Việc chuyển đổi có thể xảy ra tràn số. Thí dụ, nếu chuyển trị 256 sang kiểu số nguyên 8-bit không dấu thì bị tràn vì kiểu này chỉ có phạm vi 0 ÷ 255 mà thôi. Các lệnh trên sẽ không đưa ra biệt lệ (exception) khi gặp tràn số. Với thí dụ vừa nêu, do 256 là 0x0100 nên sau khi chuyển đổi thì chỉ còn 0x00 (lấy 8-bit thấp).

Để một biệt lệ được đưa ra khi gặp tràn số, các phiên bản sau đây được sử dụng:
  • conv.ovf.i1: chuyển sang kiểu số nguyên 8-bit có dấu.
  • conv.ovf.u1: chuyển sang kiểu số nguyên 8-bit không dấu.
  • conv.ovf.i2: chuyển sang kiểu số nguyên 16-bit có dấu.
  • conv.ovf.u2: chuyển sang kiểu số nguyên 16-bit không dấu.
  • conv.ovf.i4: chuyển sang kiểu số nguyên 32-bit có dấu.
  • conv.ovf.u4: chuyển sang kiểu số nguyên 32-bit không dấu.
  • conv.ovf.i8: chuyển sang kiểu số nguyên 64-bit có dấu.
  • conv.ovf.u8: chuyển sang kiểu số nguyên 64-bit không dấu.

Các lệnh chuyển kiểu khác
Để chuyển đổi giữa hai kiểu tham chiếu, dùng lệnh:
  • castclass <T typeTok>: chuyển sang kiểu do typeTok ấn định.
Nếu không thể thực hiện việc chuyển kiểu, InvalidCastException sẽ được đưa ra.

Trong C# có từ khóa as dùng để thử chuyển kiểu. Trong IL, lệnh tương ứng là:
  • isinst <T typeTok>
Lệnh này thử chuyển dữ liệu sang kiểu do typeTok ấn định. Nếu việc chuyển kiểu không thành công, kết quả sẽ là null.

Để chuyển một kiểu giá trị sang kiểu Object, có lệnh:
  • box <T typeTok>: chuyển kiểu do typeTok ấn định sang Object.
Ở đây, typeTok có thể là kiểu giá trị hoặc kiểu Nullable. Trong phạm vi tài liệu này, tác giả mạn phép không bàn tới kiểu Nullable.
Mỗi một kiểu giá trị có một phiên bản kiểu tham chiếu của nó, gọi là boxed type. Quá trình chuyển dữ liệu từ kiểu giá trị sang kiểu boxed gọi là boxing. Quá trình này khởi tạo một không gian lưu trữ trên heap và chép dữ liệu đó vào.

Khi cần chuyển ngược trở lại kiểu giá trị, người ta dùng một trong hai lệnh sau:
  • unbox.any <T typeTok>
  • unbox <T typeTok>
Trong đó, typeTok ấn định kiểu cần chuyển lại.
Lệnh unbox.any đặt lại trên ngăn xếp dữ liệu đã chuyển.
Lệnh unbox chỉ đặt lại địa chỉ của dữ liệu đó. Muốn lấy dữ liệu thực sự, cần dùng thêm ldobj.

Để dễ hình dung, xin xem phương thức sau:
  1. static void Federo() {
  2.     object obj = 10;
  3.     int n = (int) obj;
  4. }

Đoạn lệnh IL tương ứng là:
  1. // object obj = 10;
  2. ldc.i4.s 10
  3. box [mscorlib]System.Int32
  4. stloc.0
  5. // int n = (int) obj;
  6. ldloc.0
  7. unbox.any [mscorlib]System.Int32
  8. stloc.1
  9. //=== hoặc là ===
  10. //  unbox [mscorlib]System.Int32
  11. //  ldind.i4
  12. //  stloc.1
  13. //=== hoặc là ===
  14. //  unbox [mscorlib]System.Int32
  15. //  ldobj [mscorlib]System.Int32
  16. //  stloc.1

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (tiếp theo)

Gửi bàigửi bởi alexanderdna » CN 31/07/2011 9:58 am

PHẦN BỐN

d. Lệnh tính toán
Lệnh tính toán là lệnh thực hiện phép toán với một hay hai giá trị đang có trên ngăn xếp và trả lại kết quả. Thường cứ mỗi toán tử trong một ngôn ngữ như C# được dịch thành một lệnh nào đó trong nhóm này.
Với các toán tử hai ngôi, lệnh tương ứng cần hai giá trị trên ngăn xếp:

Mã: Chọn hết

…, giá_trị_1, giá_trị_2 -> …, kết_quả

Với toán tử một ngôi, lệnh tương ứng chỉ cần một giá trị trên ngăn xếp:

Mã: Chọn hết

…, giá_trị -> …, kết_quả


Các lệnh tính toán số học
Các lệnh nhóm này đòi hỏi hai toán hạng (nếu là toán tử hai ngôi) phải có cùng kiểu dữ liệu với nhau. Kiểu của kết quả sẽ là kiểu của toán hạng.
Một số lệnh có hai phiên bản. Phiên bản thứ nhất có thể áp dụng cho cả kiểu số nguyên lẫn số thực. Tuy nhiên, với các phép toán số nguyên gây tràn số, phiên bản này sẽ không báo lỗi OverflowException. Phiên bản thứ nhì chỉ áp dụng cho kiểu số nguyên, và sẽ báo lỗi nếu gặp tràn số.
Sau đây là các lệnh thuộc phiên bản thứ nhất:
  • add: cộng hai giá trị.
  • sub: trừ hai giá trị (giá trị 1 – giá trị 2).
  • mul: nhân hai giá trị.
  • div: chia hai giá trị (giá trị 1 / giá trị 2).
  • rem: chia hai giá trị, lấy phần dư.
Còn đây là các lệnh thuộc phiên bản thứ nhì:
  • add.ovf: cộng hai giá trị nguyên có dấu, báo lỗi khi tràn số.
  • add.ovf.un: cộng hai giá trị nguyên không dấu, báo lỗi khi tràn số.
  • sub.ovf: trừ hai giá trị nguyên có dấu, báo lỗi khi tràn số.
  • sub.ovf.un: trừ hai giá trị nguyên không dấu, báo lỗi khi tràn số.
  • mul.ovf: nhân hai giá trị nguyên có dấu, báo lỗi khi tràn số.
  • mul.ovf.un: nhân hai giá trị nguyên không dấu, báo lỗi khi tràn số.
  • div.un: chia hai giá trị nguyên không dấu.
  • rem.un: chia hai giá trị nguyên không dấu, lấy phần dư.
Lưu ý rằng phép chia không gây tràn số, do đó div.un và rem.un chỉ khác div và rem ở chỗ chúng được dùng cho số nguyên không dấu.
Ngoài ra, có lệnh đảo dấu:
  • neg
Với giá trị kiểu số nguyên, lệnh này thực hiện thao tác đổi dấu bằng phép bù-2 (two's-complement). Theo đó, số âm nhỏ nhất của kiểu hiện tại, sau lệnh neg, vẫn sẽ là số đó, vì không có số dương tương ứng. Thí dụ, kiểu int32 có trị âm tối thiểu là -2^31, không có trị dương tương ứng (vì tối đa là +2^31 – 1).

Sau đây là mã lệnh minh họa cho các lệnh vừa kể:
Trong mã C#:
  1. double a, b, c, delta;
  2. // ...
  3. delta = b * b – 4 * a * c

Trong mã IL (để dễ nhìn, xin tạm dùng tên biến thay cho chỉ mục):

Mã: Chọn hết

ldloc b
ldloc b
mul       // tmp1 := b * b
ldc.i4.4
conv.r8   // chuyển thành 4.0 (double) cho hợp kiểu với a
ldloc a
mul       // tmp2 := 4.0 * a
ldloc c
mul       // tmp3 := Y * c
sub       // tmp1 – tmp3
stloc delta


Các lệnh thao tác trên bit
Các lệnh sau đây áp dụng cho kiểu số nguyên và kiểu Boolean, đòi hỏi hai giá trị trên ngăn xếp phải có cùng kiểu:
  • and: thực hiện phép bitwise AND.
  • or: thực hiện phép bitwise OR.
  • xor: thực hiện phép bitwise XOR.
Về kiểu Boolean, một điều cần lưu ý là giá trị kiểu này được lưu trữ trên ngăn xếp ở dạng số nguyên 32-bit. Trị true là số 1, trị false là số 0. Do đó, phép and, orxor tuy là phép bitwise nhưng cũng có thể dùng như phép logical.

Tuy nhiên, lệnh sau đây (cũng đòi hỏi tương đồng về kiểu):
  • not: thực hiện phép bitwise complement.
chỉ có thể dùng cho số nguyên. Tức là nó tương ứng với toán tử ~ trong C#. Còn toán tử ! (logic NOT) phải được dịch ra thành phép so sánh với 0. Ở phần sau sẽ có mã lệnh ví dụ cho việc này.

Để dịch bit, có các lệnh sau (chỉ dùng cho số nguyên):
  • shl: dịch một lượng bit qua trái.
  • shr: dịch một lượng bit qua phải, số có dấu.
  • shr.un: dịch một lượng bit qua phải, số không dấu.
Yêu cầu của các lệnh này là số nguyên chỉ lượng bit cần dịch phải là kiểu int32 hoặc native int (không bàn tới). Còn số nguyên được dịch bit thì có thể là int32, int64 hoặc native int. Trị số nguyên có kích thước nhỏ hơn sẽ được mở rộng thành (ít nhất là) số 32-bit trước khi dịch.
Khi dịch phải, lệnh shr lặp lại bit dấu trên các chỗ trống, lệnh shr.un lấp chỗ trống bằng bit 0. Thí dụ: giá trị ban đầu là 10110011b và lượng bit cần dịch là 2, lệnh shr sẽ cho 11101100b, lệnh shr.un sẽ cho 00101100b.

Các lệnh so sánh
Lệnh thuộc nhóm này sẽ thực hiện phép so sánh với hai giá trị trên ngăn xếp và trả lại 1 (trị true) nếu điều kiện so sánh được đáp ứng, ngược lại thì 0 (trị false). Hai giá trị đó phải có cùng kiểu.

Để so sánh xem hai giá trị có bằng nhau hay không, ta dùng lệnh:
  • ceq
Lệnh này áp dụng được với kiểu số nguyên, số thực và kiểu tham chiếu.
Thí dụ 1: mã C# sau đây

sẽ được dịch thành:

Mã: Chọn hết

ldloc n
ldc.i4.s 10
ceq
stloc result

Thí dụ 2: mã C# sau đây
  1. bool result = (myObj == null);

sẽ được dịch thành:

Mã: Chọn hết

ldloc myObj
ldnull
ceq
stloc result


Không có lệnh kiểm tra hai giá trị khác nhau. Để làm việc đó, phải dùng ceq rồi tiếp tục so sánh với 0.
Thí dụ 3: mã C# sau đây

sẽ được dịch thành:

Mã: Chọn hết

ldloc n
ldc.i4.s 10
ceq
ldc.i4.0
ceq
stloc result

Tức là tương đương (n == 10) == false.

Phép toán not trên kiểu Boolean cũng được thực hiện tương tự như vậy.
Thí dụ 4: mã C# sau đây

sẽ được dịch thành:

Mã: Chọn hết

ldloc b
ldc.i4.0
ceq
stloc result

Tức là tương đương b == false.

Các lệnh so sánh lớn nhỏ bao gồm:
  • clt: so sánh hai số nguyên có dấu hoặc số thực, phép <.
  • clt.un: so sánh hai số nguyên không dấu, phép <.
  • cgt: so sánh hai số nguyên có dấu hoặc số thực, phép >.
  • cgt.un: so sánh hai số nguyên không dấu, phép >.
Để thực hiện các phép <=>=, cần lòng vòng một chút.
Thí dụ 5: mã C# sau đây
  1. int n; uint i;
  2. // ...
  3. bool result = (n < 5) && (i >= 3);

sẽ được dịch thành:

Mã: Chọn hết

ldloc n
ldc.i4.5
clt           // tmp1 := n < 5
ldloc i
ldc.i4.3
conv.ovf.u4   // cho hợp kiểu với i
clt.un        // tmp2 := i < 3
ldc.i4.0
ceq           // tmp3 := tmp2 == false
and           // tmp1 && tmp3
stloc result

Tức là tương đương (n < 5) && ((i < 3) == false).

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (phần 5)

Gửi bàigửi bởi alexanderdna » T.Hai 01/08/2011 11:25 am

PHẦN NĂM

e. Lệnh rẽ nhánh
Trong một chương trình đang thi hành, có một con trỏ gọi là code pointer, hoặc program counter, trỏ tới lệnh sẽ được thực hiện tiếp sau. Thường thì con trỏ này dịch chuyển liên tục từ lệnh này qua lệnh khác. Nếu đương khi ấy, người ta gán cho nó một giá trị khác, tức là cho nó trỏ vào lệnh khác, thì đó gọi là rẽ nhánh (branching), có khi gọi là nhảy (jump). Lệnh rẽ nhánh là các lệnh làm thao tác này.
Tất cả các lệnh rẽ nhánh đều nhận tham số gọi là target, là một số nguyên có dấu, cỡ 32-bit cho phiên bản bình thường, hoặc 8-bit cho phiên bản lệnh ngắn. Đây là một số chỉ khoảng cách tính bằng byte kể từ lệnh rẽ nhánh đó tới nơi cần đặt program counter.
Thí dụ, nếu target = 8 thì cần chuyển lên 8 byte, nếu target = -20 thì cần lùi 20 byte.

Lệnh rẽ nhánh không điều kiện là:
  • br <int32 target>: lệnh bình thường.
  • br.s <int8 target>: lệnh ngắn (dùng khi target trong phạm vi -128 ÷ 127).
Khi gặp lệnh này, chương trình sẽ không chần chừ, chuyển ngay tới target. Lệnh goto trong C# được dịch ra một trong hai phiên bản lệnh trên.

Các lệnh rẽ nhánh khác đều yêu cầu điều kiện. Chỉ khi điều kiện được đáp ứng thì mới chuyển đi, không thì cứ thi hành lệnh tiếp theo.
Sau đây là nhóm lệnh rẽ nhánh dựa trên các điều kiện so sánh hai giá trị:

Mã: Chọn hết

…, giá_trị_a, giá_trị_b -> …,

  • beq <int32 target>: rẽ nhánh nếu a == b.
  • beq.s <int8 target>: như trên.
  • bne.un <int32 target>: rẽ nhánh nếu a != b.
  • bne.un.s <int8 target>: -nt-
  • blt <int32 target>: rẽ nhánh nếu a < b.
  • blt.s <int8 target>: -nt-
  • blt.un <int32 target>: rẽ nhánh nếu a < b, a và b là số nguyên không dấu.
  • blt.un.s <int8 target>: -nt-
  • ble <int32 target>: rẽ nhánh nếu a <= b.
  • ble.s <int8 target>: -nt-
  • ble.un <int32 target>: rẽ nhánh nếu a <= b, a và b là số nguyên không dấu.
  • ble.un.s <int8 target>: -nt-
  • bgt <int32 target>: rẽ nhánh nếu a > b.
  • bgt.s <int8 target>: -nt-
  • bgt.un <int32 target>: rẽ nhánh nếu a > b, a và b là số nguyên không dấu.
  • bgt.un.s <int8 target>: -nt-
  • bge <int32 target>: rẽ nhánh nếu a >= b.
  • bge.s <int8 target>: -nt-
  • bge.un <int32 target>: rẽ nhánh nếu a >= b, a và b là số nguyên không dấu.
  • bge.un.s <int8 target>: -nt-

Ngoài ra, còn có các lệnh dựa trên một giá trị trên ngăn xếp:

Mã: Chọn hết

…, giá_trị_điều_kiện -> …,

  • brtrue <int32 target>: rẽ nhánh nếu giá trị điều kiện khác 0.
  • brtrue.s <int8 target>: -nt-
  • brinst <int32 target>: cùng mã với brtrue.
  • brinst.s <int8 target>: cùng mã với brtrue.s.
  • brfalse <int32 target>: rẽ nhánh nếu giá trị điều kiện bằng 0.
  • brfalse.s <int8 target>: -nt-
  • brnull <int32 target>: cùng mã với brfalse.
  • brnull.s <int8 target>: cùng mã với brfalse.
  • brzero <int32 target>: cùng mã với brfalse.
  • brzero.s <int8 target>: cùng mã với brfalse.

Trong lập trình C# hay VB.NET, quý vị chắc hẳn có lần sử dụng try…catch để tiếp nhận các lỗi biệt lệ. Nếu trong phần try hoặc phần catch có sử dụng lệnh goto để chuyển tới một nhãn nằm ngoài phần này, thì lệnh này sẽ không được dịch ra br hay br.s mà ra:
  • leave <int32 target> hoặc
  • leave.s <int8 target>
Lệnh leave và leave.s được dùng để rẽ nhánh ra khỏi một vùng lệnh của try hay catch. Nó đảm bảo rằng ngăn xếp được dọn đẹp sạch sẽ và vùng lệnh finally (nếu có) sẽ được thi hành.

f. Lệnh triệu gọi phương thức
Tiến trình triệu gọi
Việc thi hành một phương thức (hàm) có cả một tiến trình gồm nhiều giai đoạn khác nhau.
Trước tiên, ta có quy ước rằng:
  • Các tham số được đưa lên ngăn xếp theo thứ tự từ trái qua phải trong danh sách khai báo của phương thức. Kiểu của tham số phải phù hợp.
  • Nếu là phương thức thực thể thì tham chiếu của thực thể đó, tức là this, phải được đưa vào trước tất cả tham số khác, và trở thành tham số có chỉ mục 0; các tham số còn lại tính từ 1.
  • Khi trả về, phương thức có nhiệm vụ làm sạch ngăn xếp sao cho nó trở lại trạng thái trước khi các tham số (kể cả this) được nạp vào. Nếu kiểu trả về khác void thì giá trị trả về phải được nạp vào ngăn xếp để nơi gọi sử dụng.

Theo nguyên tắc, this là một tham chiếu tới đối tượng của phương thức. Nếu đối tượng này thuộc kiểu tham chiếu thì không có gì đáng bàn. Trong trường hợp đối tượng này thuộc kiểu giá trị, người ta cần phải nạp địa chỉ của đối tượng đó để làm this.
Nếu đối tượng chứa trong biến, tham số, hay trường dữ liệu, có thể dùng ldloca, ldarga, ldflda,… để nạp địa chỉ. Song, nếu đối tượng được hình thành từ một biểu thức, chẳng hạn như (phanSo1 + phanSo2), thì trước hết nó phải được lưu vào một biến tạm, rồi người ta nạp địa chỉ của biến tạm đó.
Nếu trong chương trình có nhiều biểu thức kiểu giá trị như thế, thì có thể cần phải dùng khá nhiều biến tạm để lưu các kết quả trước khi gọi phương thức. Những trình biên dịch tối ưu mã thường có cách để tái sử dụng các biến tạm sau khi chúng làm xong nhiệm vụ cho các lần gọi phương thức trước. Song điều này không phải lúc nào cũng khả dĩ.

Các lệnh triệu gọi
Trong tài liệu này, ta xét hai lệnh sau:
  • call <T methodTok>: gọi phương thức do methodTok ấn định.
  • callvirt <T methodTok>: như trên.

methodTok là một metadata token cung cấp thông tin về phương thức được triệu gọi. Các thông tin này bao gồm kiểu chứa phương thức, tên phương thức, kiểu trả về, các tham số, cùng nhiều đặc tính khác. Với một methodTok, hệ thống có đủ thông tin để xác định phương thức và cách gọi nó cho đúng.

Có sự khác biệt giữa lệnh call và callvirt. Để dễ hình dung, ta cần nhớ rằng có hai cách để xác định kiểu dữ liệu chứa phương thức cần gọi. Cách thứ nhất là tìm trong methodTok. Cách thứ nhì là dựa theo đối tượng this được truyền.
Lệnh call chọn phương thức chứa trong kiểu được tìm bằng cách thứ nhất. Lệnh callvirt thì chọn phương thức chứa trong kiếu được tìm bằng cách thứ nhì. Nói cách khác, lệnh call gọi phương thức của lớp gốc (base class), lệnh callvirt gọi phương thức đã được tái định nghĩa (override) ở lớp dẫn xuất (derived class).

Thí dụ 1: với hai lớp sau đây
  1. class Base {
  2.     public virtual void Federo() { }
  3. }
  4. class Derive : Base {
  5.     public override void Federo() { }
  6. }

và với mã như sau:
  1. Base a = new Derive();
  2. a.Federo();

thì

Mã: Chọn hết

ldloc a
call instance void Base::Federo()

sẽ gọi Base::Federo(), còn

Mã: Chọn hết

ldloc a
callvirt instance void Base::Federo()

sẽ gọi Derive::Federo(), bởi vì a thực chất là một đối tượng kiểu Derive.

Trong C#, các phát biểu dùng base sẽ được dịch ra lệnh call, còn dùng this thì được dịch ra lệnh callvirt.
Các phương thức tĩnh đều được gọi bằng lệnh call do chúng không thể tái định nghĩa ở lớp dẫn xuất. Phương thức của kiểu giá trị cũng như thế, vì tất cả các kiểu giá trị đều ngầm định thuộc tính sealed (tức NotInheritable trong VB.NET).

Khi gọi phương thức của một kiểu giá trị, mà phương thức này được kế thừa từ kiểu Object, chẳng hạn như ToString, trình biên dịch có thể sinh mã như sau:

Mã: Chọn hết

constrained. MyValueType
callvirt instance string [mscorlib]System.Object::ToString()


Ở đây, contrained. (có dấu chấm) được gọi là một prefix (tiền tố). Theo sau nó luôn luôn là một lệnh callvirt.
  • constrained. <T thisType>
thisType là một token đại diện cho kiểu của đối tượng this.
Lượt đồ ngăn xếp trước và sau tiền tố này:

Mã: Chọn hết

…, ptrThis, arg1, …, argN -> …, ptrThis, arg1, …, argN

ptrThis là một con trỏ cho kiểu thisType, không cần biết thisType là kiểu giá trị hay kiểu tham chiếu.

Có các quy tắc sau đây:
  • Nếu thisType là kiểu tham chiếu thì hệ thống sẽ truy xuất this từ ptrThis và gọi phương thức thuộc thisType bằng callvirt.
  • Nếu thisType là kiểu giá trị và tái định nghĩa phương thức thì hệ thống sẽ truyền ptrThis làm this và gọi phương thức thuộc thisType bằng call.
  • Nếu thisType là kiểu giá trị, chỉ kế thừa phương thức từ Object mà không tái định nghĩa, hệ thống sẽ truy xuất đối tượng từ ptrThis, box và truyền nó làm this rồi gọi phương thức thuộc Object bằng callvirt.

Câu hỏi đặt ra là tại sao lại phải dùng constrained. trong khi có thể tùy theo kiểu của đối tượng là giá trị hay tham chiếu mà chọn call hoặc callvirt. Thực ra việc này là cần thiết khi không thể xác định đối tượng thuộc kiểu nào trong hai nhóm.
Trường hợp này xảy ra khi đối tượng thuộc một kiểu generic. Thí dụ:
  1. interface IKanemiro {
  2.     void Federo();
  3. }
  4. class Censo : IKanemiro {
  5.     public void Federo() { }
  6. }
  7. struct Serno : IKanemiro {
  8.     public void Federo() { }
  9. }
  10.  
  11. static void Gigo<T>(T obj) where T : IKanemiro {
  12.     obj.Federo();
  13. }
  14.  
  15. static void Main() {
  16.     Serno srn = new Serno();
  17.     Gigo(srn);
  18. }

Ở trong phương thức Gigo, trình biên dịch không thể xác định được obj là một đối tượng kiểu tham chiếu hay kiểu giá trị. Do đó, nó không biết phải dùng callvirt hay là call. Với constrained., nó có thể sinh lệnh triệu gọi một cách thuận lợi mà không cần quan tâm tới kiểu T là giá trị hay tham chiếu nữa.

Mã: Chọn hết

ldarga obj       // nạp con trỏ ptrThis
constrained. !!T // kiểu generic T
callvirt instance void IKanemiro::Federo()


Lệnh trở về
Phát biểu return trong các ngôn ngữ C#, VB.NET được dịch thành lệnh:
  • ret
Lệnh này đơn giản là lấy giá trị trả về trên ngăn xếp (nếu phương thức không phải void) để nạp vào ngăn xếp của nơi gọi, rồi quay về nơi đó.

Nếu phát biểu return nằm trong vùng lệnh của try hay catch thì phải dùng lệnh leave để chuyển tới một vị trí bên ngoài, nơi có lệnh ret.
Thí dụ 2: mã lệnh C# sau đây
  1. static int Federo() {
  2.     try {
  3.         return 1;
  4.     }
  5.     catch (Exception) {
  6.         return 0;
  7.     }
  8.     return 10;
  9. }

sẽ được dịch thành:

Mã: Chọn hết

.method private hidebysig static void Federo() cil managed
{
   .maxstack 1
   .locals init (int32 tmpReturn,
      class [mscorlib]System.Exception e)
   // tmpReturn là biến lưu tạm trị trả về
   // e là biến lưu tạm exception

   .try
   {
      ldc.i4.1
      stloc tmpReturn   // lưu tạm
      leave returnLabel // ra nơi trả về

      leave endTry      // sinh mã theo mặc định
   }
   catch [mscorlib]System.Exception
   {
      stloc e           // lưu exception mới nhận được

      ldc.i4.0
      stloc tmpReturn   // lưu tạm
      leave returnLabel // ra nơi trả về

      leave endTry      // mặc định
   }
endTry:
   ldc.i4.10
   ret
returnLabel:
   ldloc tmpReturn
   ret
}

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (phần 6)

Gửi bàigửi bởi alexanderdna » T.Ba 02/08/2011 12:20 pm

PHẦN SÁU

g. Lệnh khởi tạo thực thể
Các lệnh thuộc nhóm này có công dụng khởi tạo nội dung ban đầu cho một đối tượng. Việc khởi tạo có thể theo cách mặc định hoặc dùng thêm tham số.

Khởi tạo đối tượng bình thường
Trước hết, ta có lệnh:
  • newobj <T ctorTok>
Ở đây, ctorTok là một token đại diện cho phương thức khởi tạo (constructor) thuộc kiểu dữ liệu của đối tượng.
Nếu đối tượng được khởi tạo thuộc kiểu tham chiếu, newobj sẽ xin cấp phát một vùng bộ nhớ trong heap để chứa đối tượng và lấy địa chỉ vùng này làm this để gọi phương thức do ctorTok ấn định. Sau khi newobj hoàn tất, nó nạp tham chiếu đối tượng vào ngăn xếp.
Nếu đối tượng trên thuộc kiểu giá trị, một khoảng bộ nhớ trên ngăn xếp được cấp phát để chứa các trường dữ liệu của nó, rồi địa chỉ của khoảng này được dùng làm this để gọi phương thức do ctorTok ấn định. Sau khi newobj hoàn tất, trên ngăn xếp đã có đối tượng được khởi tạo.

Thí dụ 1: với hai kiểu sau
  1. class Censo {
  2.     private int n;
  3.     public Censo(int value) {
  4.         this.n = value;
  5.     }
  6. }
  7. struct Serno {
  8.     private int n;
  9.     public Serno(int value) {
  10.         this.n = value;
  11.     }
  12. }

mã C# sau đây
  1. Censo c = new Censo(10);
  2. Serno s = new Serno(20);

sẽ được dịch thành

Mã: Chọn hết

ldc.i4.s 10
newobj instance void Censo::.ctor(int32)
stloc c
ldc.i4.s 20
newobj instance void Serno::.ctor(int32)
stloc s


Quý vị thấy rằng, phương thức khởi tạo của một kiểu có tên nội bộ là .ctor. Đây là phương thức khởi tạo thực thể; còn phương thức khởi tạo kiểu (static constructor) thì có tên nội bộ là .cctor và chỉ được gọi trong tiến trình khởi động, khi hệ thống nạp các kiểu.

Phương thức khởi tạo mà không nhận tham số nào thì gọi là phương thức khởi tạo mặc định (default constructor). Các kiểu giá trị không được dùng phương thức loại này. Thay vào đó, người ta dùng lệnh sau đây:
  • initobj <T typeTok>
Ở đây, typeTok đại diện cho kiểu của đối tượng cần khởi tạo. Trước lệnh này, trong ngăn xếp phải có địa chỉ của đối tượng. Thường thì đối tượng được khởi tạo sẽ nằm trong một biến hay trường nào đó. Cho nên trên ngăn xếp sẽ có địa chỉ của biến hay trường đó.

Mã: Chọn hết

…, addr -> …,

Nếu typeTok là kiểu tham chiếu, lệnh này tương đương với ldnull rồi stind.ref, tức là lưu trị null vào nơi được addr trỏ tới.
Nếu typeTok là kiểu giá trị, lệnh này sẽ khởi tạo trị mặc định cho tất cả các trường của đối tượng.

Do các tính chất trên, lệnh initobj được dùng để khởi tạo mặc định một đối tượng thuộc kiểu giá trị.
Thí dụ 2: với kiểu Serno sau
  1. struct Serno {
  2.     private int n;
  3. }

mã C# sau đây

sẽ được dịch thành

Mã: Chọn hết

ldloca s  // nạp địa chỉ của biến s
initobj Serno


Nếu dùng initobj với kiểu tham chiếu, tác dụng sẽ không phải là khởi tạo đối tượng, mà là gán cho nó trị null.
Thí dụ 3: với lớp Censo ở trên, mã IL sau đây

Mã: Chọn hết

ldloca c
initobj Censo

tương đương với

Mã: Chọn hết

ldnull
stloc c

cũng tương đương với


Khởi tạo mảng một chiều
Có thể nói, mảng là một loại đối tượng đặc biệt (thuộc kiểu tham chiếu), và mảng một chiều lại là thứ mảng đặc biệt. Thành thử, việc khởi tạo nó cũng thật là… đặc biệt vậy!
Người ta dùng lệnh sau đây:
  • newarr <T elemType>
Ở đây, elemType là một token đại diện cho kiểu của phần tử trong mảng.
Trước lệnh này, trên ngăn xếp phải có một số nguyên (int32 hoặc là native int) biểu thị cho kích thước của mảng cần khởi tạo.

Mã: Chọn hết

…, numElems -> …, array

Số numElems cần phải là số không âm. Nếu nó là số âm, hệ thống sẽ đưa ra biệt lệ OverflowException.

Thí dụ 4: mã C# sau đây
  1. int[] a1 = new int[10];
  2. string[] a2 = new string[a1.Length];

sẽ được dịch thành

Mã: Chọn hết

ldc.i4.s 10
newarr [mscorlib]System.Int32
stloc a1
ldloc a1
ldlen
newarr [mscorlib]System.String
stloc a2

Ở đây, tác giả cố ý dùng lệnh ldlen để quý vị biết thêm về cách dùng lệnh này. Một số trình biên dịch có thể dùng cách truy cập Length như một property (thuộc tính) bình thường thay vì dùng ldlen. Lệnh này nạp một số native int vào ngăn xếp. Do numElems có thể là native int nên mã trên không có vấn đề. Song, nếu dùng trong trường hợp khác (chẳng hạn như lưu vào biến), thì phải có thêm lệnh conv.i4 đằng sau cho hợp lệ.

Khác với việc khởi tạo mảng bằng kích thước, việc khởi tạo mảng bằng các phần tử ban đầu, như new int[] { 1, 2, 3 }, đòi hỏi nhiều công việc hơn, mà đầu tiên vẫn là dùng lệnh newarr để tạo mảng. Sau đó, mảng sẽ được lưu vào biến (có thể là biến tạm), rồi từng phần tử được lưu vào biến này, qua việc dùng các lệnh stelem.
Nếu các phần tử có kiểu số và biểu thức của chúng đều là hằng trị, trình biên dịch C# của Microsoft dùng một phương pháp khác, mau lẹ hơn phương pháp đã nêu. Nói đại khái, nó sẽ chuyển tất cả phần tử thành một dãy byte lưu trong khu vực dữ liệu tĩnh của chương trình, rồi dùng một phương thức đặc biệt để chép dãy này vào mảng. Mỗi dãy byte như thế có một trường dữ liệu đại diện để truy cập, thuộc một lớp do trình biên dịch tự tạo ra, tên là <PrivateImplementationDetails>.
Mảng nhiều chiều thì không được khởi tạo bằng newarr, mà bằng một trong các định nghĩa bội của phương thức Array.CreateInstance, hoặc bằng một phương thức khởi tạo đặc biệt, tùy theo trình biên dịch.
Riêng kiểu mảng của mảng (jagged array), chẳng hạn như int[][], được xem là mảng một chiều với phần tử thuộc kiểu mảng. Do đó có thể dùng newarr.
Thí dụ 5: mã C# sau đây
  1. int[,] a1 = new int[10, 5];
  2. int[][] a2 = new int[6][];

sẽ được dịch thành

Mã: Chọn hết

ldc.i4.s 10
ldc.i4.5
newobj instance void int32[0...,0...]::.ctor(int32, int32)
stloc a1
ldc.i4.6
newarr [mscorlib]System.Int32[]
stloc a2

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (phần 7)

Gửi bàigửi bởi alexanderdna » T.Năm 11/08/2011 10:48 am

PHẦN BẢY

4. Dịch từ C# sang IL
Chúng ta vừa cùng nhau tìm hiểu các lệnh phổ biến của Common Intermediate Language. Ở phần này, tác giả sẽ trình bày việc chuyển dịch từ C# sang IL qua các ví dụ chi tiết hơn.
Vì phạm vi bàn luận có giới hạn, tác giả sẽ không nêu ra tất cả các trường hợp, mà chỉ tập trung giới thiệu những phần trọng yếu. Bên cạnh đó, mỗi trình biên dịch C# có một phương hướng biên dịch riêng, độc giả không nên lấy các kỹ thuật dưới đây làm tham chiếu mà chỉ nên dùng để tham khảo.

a. Phát biểu if
Phát biểu if có hai dạng: một nhánh, và hai nhánh (có nhánh else).
Phát biểu này được thực hiện bằng việc nạp biểu thức điều kiện rồi nhảy đến một nhãn lệnh thích hợp. Thông thường, lệnh nhảy sau biểu thức điều kiện có tính chất ngược lại so với điều kiện đó. Chẳng hạn, điều kiện là a > b thì sẽ có lệnh nhảy nếu a <= b.

Với loại if một nhánh, vị trí nhảy là ở sau nhánh if.
Thí dụ 1: mã C# sau đây
  1. if (a > b)
  2.     MyClass.DoSomething();

sẽ được dịch thành

Mã: Chọn hết

   ldloc a
   ldloc b
   ble L_endif // a <= b
   call void MyClass::DoSomething()
L_endif:
   //...


Với loại if hai nhánh, vị trí nhảy là vào nhánh else. Cuối nhánh if có thêm lệnh nhảy tới vị trí sau nhánh else (để vượt qua nhánh này).
Thí dụ 2: mã C# sau đây
  1. if (a > b)
  2.     MyClass.DoOne();
  3. else
  4.     MyClass.DoTwo();

sẽ được dịch thành

Mã: Chọn hết

   ldloc a
   ldloc b
   ble L_else // a <= b
   call void MyClass::DoOne()
   br L_endif
L_else:
   call void MyClass::DoTwo()
L_endif:
   // ...


Lệnh if lồng nhau cũng theo một nguyên tắc như vậy.
Thí dụ 3: mã C# sau đây
  1. if (delta < 0)
  2.     PT.VoNghiem();
  3. else if (delta == 0)
  4.     PT.NghiemKep();
  5. else
  6.     PT.HaiNghiem();

sẽ được dịch thành

Mã: Chọn hết

   ldloc delta
   ldc.i4.0
   bge L_elseif // delta >= 0
   call void PT::VoNghiem()
   br L_endif
L_elseif:
   ldloc delta
   ldc.i4.0
   bne.un L_else // delta != 0
   call void PT::NghiemKep()
   br L_endif
L_else:
   call void PT::HaiNghiem()
L_endif:
   // ...


Ngôn ngữ C# có hỗ trợ một tính năng gọi là short-circuit. Theo đó, một biểu thức điều kiện dùng toán tử && hoặc || sẽ được lượng giá sao cho nhanh nhất. Tức là:

Mã: Chọn hết

Cho điều kiện (ĐK) = A && B,
   ĐK == false nếu A == false.
Cho ĐK = A || B,
   ĐK == true nếu A == true.

Ta thấy, biểu thức B không cần được lượng giá nếu A đã có thể cho biết kết quả.

Thí dụ 4: mã C# sau đây
  1. if (b != 0 && a / b > 3)
  2.     MyClass.DoSomething();

sẽ được dịch thành

Mã: Chọn hết

   ldloc b
   ldc.i4.0
   beq L_endif // b == 0, bỏ qua luôn
   ldloc a
   ldloc b
   div
   ldc.i4.3
   bge L_endif // a / b <= 3
   call void MyClass::DoSomething()
L_endif:
   // ...


Thí dụ 5: mã C# sau đây
  1. if (n == 2 || i > 0)
  2.     MyClass.DoSomething();

sẽ được dịch thành

Mã: Chọn hết

   ldloc n
   ldc.i4.2
   beq L_if // n == 2, nhảy tới nhánh if luôn
   ldloc i
   ldc.i4.0
   ble L_endif // i <= 0, bỏ
L_if:
   call void MyClass::DoSomething()
L_endif:
   // ...


b. Phát biểu switch
Phát biểu switch có thể được chuyển thành một loạt các nhánh if. Nhưng CLI đã hỗ trợ tốt hơn với lệnh IL cùng tên switch.
  • switch <uint32 Ntargets> <int32 t1> … <int32 tN>
Trong đó, Ntargets là số lượng các nhánh case (không tính nhánh default). Các tham số từ t1 đến tN là vị trí các nhánh đó.
Trên ngăn xếp phải có giá trị cần xét, có kiểu số nguyên không dấu. Lệnh này sẽ lấy giá trị đó ra và kiểm tra. Nếu nó bằng 0 thì nhảy tới t1, bằng 1 thì nhảy tới t2,… Nếu nó lớn hơn hoặc bằng N thì thực hiện lệnh kế tiếp.

Thí dụ 6: mã C# sau đây
  1. switch (n) {
  2.     case 0:
  3.         MyClass.DoZero();
  4.         break;
  5.     case 1:
  6.         MyClass.DoOne();
  7.         break;
  8.     case 2:
  9.         MyClass.DoTwo();
  10.         break;
  11.     default:
  12.         MyClass.DoDefault();
  13.         break;
  14. }

sẽ được dịch thành

Mã: Chọn hết

   ldloc n
   switch (L_case0, L_case1, L_case2)
   // không phải các trường hợp trên thì nhảy tới default
   br L_default
L_case0:
   call void MyClass::DoZero()
   br L_endswitch
L_case1:
   call void MyClass::DoOne()
   br L_endswitch
L_case2:
   call void MyClass::DoTwo()
   br L_endswitch
L_default:
   call void MyClass::DoDefault()
L_endswitch:
   // ...

Một điều cần lưu ý ở đây là nhánh default không thuộc công việc của lệnh switch mà là thao tác hỗ trợ của trình biên dịch. Nếu phát biểu switch ở trên không có nhánh default thì lệnh nhảy sau lệnh switch sẽ có điểm đến là L_endswitch.

Mã lệnh ghi ra trên đây áp dụng cho trường hợp giá trị các nhánh case tăng dần đều từ 0, 1, 2,… Nếu không, trình biên dịch sẽ có các kỹ thuật tính toán riêng và khá phức tạp, tác giả không thể đề cập tới ở đây.

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Đôi điều về CLI (phần 8)

Gửi bàigửi bởi alexanderdna » CN 14/08/2011 8:41 pm

PHẦN TÁM

c. Phát biểu for
Phát biểu for có bốn phân đoạn, được phân tích như dưới đây.
Trước tiên, giá trị khởi động sẽ được lưu vào biến đếm. Sau đó, có lệnh nhảy tới đoạn lệnh kiểm tra điều kiện lặp. Nếu điều kiện được đáp ứng thì sẽ nhảy tới phần lệnh cần lặp. Sau phần đó sẽ là phần lệnh cập nhật giá trị biến đếm.
Ở đây chúng ta không xét tới trường hợp phát biểu không hoàn chỉnh (thiếu một phân đoạn nào đó). Trong một số trình biên dịch khác, các bước tiến hành có thể hơi khác, nhưng vẫn phải bảo đảm tính chất của vòng lặp for.

Thí dụ 7: mã C# sau đây
  1. for (int i = 0; i < 5; ++i)
  2.     MyClass.DoWork();

sẽ được dịch thành

Mã: Chọn hết

   // khởi động
   ldc.i4.0
   stloc i
   // nhảy tới phần kiểm tra
   br L_check

L_loop: // phần lặp
   call void MyClass::DoWork()

   // cập nhật: i = i + 1
   ldloc i
   ldc.i4.1
   add
   stloc i

L_check: // kiểm tra
   ldloc i
   ldc.i4.5
   blt L_loop // điều kiện đúng -> lặp tiếp
   // không thì qua luôn
L_endfor:
   // ...


d. Phát biểu foreach
Đây là một trong số các loại phát biểu tương đối phức tạp. Nó chỉ áp dụng được trên kiểu dữ liệu có cài đặt giao diện IEnumerable hoặc IEnumerable<T>. Giao diện này cung cấp một phương thức là GetEnumerator. Phương thức này có kiểu trả về dùng giao diện IEnumerator hoặc IEnumerator<T>.
Giao diện IEnumerator (và phiên bản generic) có phương thức MoveNext để chuyển dịch sang phần tử tiếp theo trong tập hợp, và thuộc tính Current cho biết giá trị của phần tử hiện tại. Nó cũng có phương thức Dispose (của giao diện IDisposable) để trả bộ nhớ về hệ thống khi không còn dùng tới.
Vòng lặp foreach bao gồm các công đoạn sau đây. Trước hết là lấy trị trả về của GetEnumerator lưu vào biến tạm. Tiếp theo là chuyển tới phần lệnh kiểm tra xem MoveNext có trả về true hay không. Nếu có thì nhảy tới phần lệnh cần lặp; phần này lấy trị của thuộc tính Current và lưu vào biến lặp trước khi thực hiện các lệnh của vòng lặp. Phần cuối là gọi Dispose để trả bộ nhớ.
Phần lệnh kiểm tra và lặp nằm trong một khối try. Phần trả bộ nhớ nằm trong khối finally, để bảo đảm rằng bộ nhớ được trả lại bất kể trong trường hợp nào.
foreach đòi hỏi nhiều năng lực điện toán hơn for. Do đó, với kiểu mảng (một chiều), các trình biên dịch tốt thường chuyển đổi phát biểu foreach thành phát biểu for để tối ưu hóa mã lệnh.

Thí dụ 8: với biến sau
  1. List<int> lst = new List<int>();
  2. lst.AddRange(new int[] { 1, 2, 3 });

mã C# sau đây
  1. foreach (int n in lst)
  2.     Console.WriteLine(n);

sẽ được dịch thành

Mã: Chọn hết

   // lấy Enumerator của lst
   ldloc lst
   callvirt instance valuetype  List`1/Enumerator<!0> class List`1<int32>::GetEnumerator()
   // lưu vào biến tạm
   stloc tempEnumerator

.try
{
   // kiểm tra trước
   br L_check

   // lấy thuộc tính Current
   ldloca tempEnumerator
   call instance !0 valuetype List`1/Enumerator<int32>::get_Current()
   // lưu vào biến lặp
   stloc n

L_loop: // phần lặp
   ldloc n
   call void Console::WriteLine(int32)
   
L_check: // kiểm tra
   ldloca tempEnumerator
   call instance bool valuetype List`1/Enumerator<int32>::MoveNext()
   brtrue L_loop // điều kiện đúng -> lặp
   // không thì ra
   leave L_endforeach
}
finally
{
   ldloca tempEnumerator
   constrained. valuetype List`1/Enumerator<int32>
   callvirt instance void IDisposable::Dispose()
   endfinally
}
L_endforeach:
   // ...


Ở mã trên, để cho giản tiện, tác giả không ghi đầy đủ các phần danh biểu, ví dụ như [mscorlib]System.Collections.Generic.List`1, mà rút gọn lại phần tên mà thôi. Các chỗ ghi !0`1 là ký pháp của generic, chúng ta tạm thời không quan tâm.

e. Phát biểu while
Phát biểu while khá đơn giản. Xin xem thí dụ.

Thí dụ 9: mã C# sau đây
[csharp]while (n > 0)
MyClass.DoWork();[/csharp]
sẽ được dịch thành

Mã: Chọn hết

   // kiểm tra
   br L_check

L_loop: // phần lặp
   call void MyClass::DoWork()

L_check: // kiểm tra
   ldloc n
   ldc.i4.0
   blt L_loop // điều kiện đúng -> lặp

L_endwhile:
   // ...


f. Phát biểu do
Phát biểu do còn đơn giản hơn phát biểu while.

Thí dụ 10: mã C# sau đây
[csharp]do {
MyClass.DoWork();
} while (a > 0);[/csharp]
sẽ được dịch thành

Mã: Chọn hết

L_loop: // phần lặp
   call void MyClass::DoWork()

// kiểm tra
   ldloc a
   ldc.i4.0
   blt L_loop // điều kiện đúng -> lặp

L_enddo:
   // ...

Hình đại diện của người dùng
alexanderdna
Guru
Guru
Bài viết: 214
Ngày tham gia: T.Ba 14/07/2009 11:13 am
Đến từ: Sài Gòn
Has thanked: 3 time
Been thanked: 15 time

Re: Đôi điều về CLI

Gửi bàigửi bởi alexanderdna » T.Sáu 09/12/2011 10:29 pm

PHẦN CHÍN

g. Phát biểu try
Có thể nói try…catch là loại cấu trúc điều khiển rất đặc biệt. Những mã lệnh bên trong khối try, catchfinally được thi hành theo một cách riêng, ít nhiều khác với mã lệnh bên ngoài.
Trong các phần nói về lệnh rẽ nhánh và lệnh ret, tác giả đã đề cập sơ đến try…catch. Ở phần này, xin trình bày rõ hơn, mặc dù cũng chưa phải là đầy đủ bởi lẽ sẽ khá phức tạp.
Trước hết ta cần nhắc lại là một phát biểu try đầy đủ gồm có ba khối lệnh: try, catchfinally. Có khi thiếu khối finally, có khi thiếu catch. Trong IL, các lệnh cũng được gom vào các khối này hệt như khi viết trong C# hay VB.NET. Trong khối finally không được phép có lệnh return.

Thí dụ 11: xét mã C# sau
  1. private static void DoSomethingBad() {
  2.     throw new Exception("Uh-oh!");
  3. }
  4. private static int Federo() {
  5.     try {
  6.         DoSomethingBad();
  7.         return 1;
  8.     }
  9.     catch (Exception e) {
  10.         Console.WriteLine(e.Message);
  11.         return 0;
  12.     }
  13. }

Ta thấy hiển nhiên việc gọi DoSomethingBad() sẽ làm văng ra một biệt lệ có nội dung (Message) là "Uh-oh!". Vậy hệ thống tiếp quản nó như thế nào?
Biệt lệ cũng là một đối tượng, thuộc kiểu Exception hoặc một kiểu kế thừa từ Exception. Nó có thuộc tính Message lưu nội dung mô tả biệt lệ và các thuộc tính khác cung cấp thêm thông tin để người ta sử dụng.
Trong mã trên đây, khi đối tượng biệt lệ sinh ra, nó sẽ được đưa vào ngăn xếp, và chương trình chuyển vào khối catch. Tại đây, chương trình sẽ lưu đối tượng biệt lệ vào biến e rồi làm các việc như đã yêu cầu trong mã.
Nếu biến e không được dùng tới thì, thay vì lưu biệt lệ vào biến, chương trình chỉ đơn thuần gọi lệnh pop để bỏ đối tượng này khỏi ngăn xếp. Và phương thức cũng không cần xin chỗ cho biến e. Xem như nó chỉ là biến hình thức.
Như đã từng nêu trước đây, việc gọi ret trong khối try hay catch là không hợp lệ. Muốn trả về một giá trị, trước hết chương trình phải lưu trị đó vào một biến tạm, rời khỏi khối try/catch bằng lệnh leave để đến một vị trí thường được đặt ở cuối phương thức. Tại đây, trị của biến tạm được nạp vào ngăn xếp và sẽ có lệnh ret để trả về trị đó.

Ta có IL cho hàm Federo như sau:

Mã: Chọn hết

.locals init ([mscorlib]System.Exception e,
   int32 returnValue)
try
{
   // gọi DoSomethingBad()
   call void MyClass::DoSomethingBad()
   // lưu trị trả về rồi rời khỏi khối try
   ldc.i4.1
   stloc.s returnValue
   leave L_return
}
catch [mscorlib]System.Exception
{
   // lưu đối tượng biệt lệ đang có trên ngăn xếp
   stloc.s e
   // hiện nội dung
   ldloc.s e
   callvirt instance string [mscorlib]System.Exception::get_Message()
   call void [mscorlib]System.Console::WriteLine(string)
   // lưu trị trả về rồi rời khỏi khối catch
   ldc.i4.0
   stloc.s returnValue
   leave L_return
}
L_return:
   // trả về
   ldloc.s returnValue
   ret


Có người sẽ hỏi, nếu trong try hay catch không có return thì có dùng leave không? Câu trả lời là có. Vẫn cần lệnh leave để rời khỏi khối try hay catch để đến vị trí sau phát biểu đó.
Thí dụ 12: mã C# sau
  1. try {
  2.     DoSomethingBad();
  3. }
  4. catch (Exception e) {
  5.     Console.WriteLine(e.Message);
  6. }

sẽ được dịch thành

Mã: Chọn hết

try
{
   call void MyClass::DoSomethingBad()
   leave L_endtry // rời đi
}
catch [mscorlib]System.Exception
{
   stloc.s e
      ldloc.s e
   callvirt instance string [mscorlib]System.Exception::get_Message()
   call void [mscorlib]System.Console::WriteLine(string)
   leave L_endtry // rời đi
}
L_endtry:
   // làm gì thì làm!


Có khi phát biểu try chỉ có khối try và khối finally. Lúc này nếu có biệt lệ nào xảy ra thì nó sẽ không được tiếp quản và chương trình bị sụp. Nhưng trước khi sụp thì mã lệnh của finally luôn được thi hành.
Thí dụ 13: mã C# sau
  1. private static int Federo() {
  2.     try {
  3.         DoSomethingBad();
  4.         return 1;
  5.     }
  6.     finally {
  7.         Console.WriteLine("This should be shown.");
  8.     }
  9. }

sẽ được dịch thành

Mã: Chọn hết

.locals init (int32 returnValue)
try
{
   call void MyClass::DoSomethingBad()
   ldc.i4.1
   stloc.s returnValue
   leave L_return
}
finally
{
   ldstr "This should be shown."
   call void [mscorlib]System.Console::WriteLine(string)
   endfinally
}
L_return:
   ldloc.s returnValue
   ret

Trên đây xin để ý lệnh endfinally. Đây là lệnh dùng để ra khỏi khối finally. Không thể dùng leave để làm công việc này. Do không được dùng leave, và chắc chắn không dùng ret, nên việc return từ trong khối finally là điều bất khả thi.

Một phát biểu try đầy đủ là khi nó có cả ba khối. Điều gây ngạc nhiên là mặc dù ta thấy chúng đi liền nhau trong C# hay VB.NET, nhưng vào IL thì bố cục lại khác biệt.
Thí dụ 14: mã C# sau
  1. private static int Federo() {
  2.     try {
  3.         DoSomethingBad();
  4.         return 1;
  5.     }
  6.     catch (Exception e) {
  7.         return 0; // không dùng e, xem IL bên dưới
  8.     }
  9.     finally {
  10.         Console.WriteLine("This should be shown.");
  11.     }
  12. }

sẽ được dịch thành

Mã: Chọn hết

.locals init (int32 returnValue)
try
{
   try
   {
      call void MyClass::DoSomethingBad()
      ldc.i4.1
      stloc.s returnValue
      leave L_return
   }
   catch [mscorlib]System.Exception
   {
      pop // không dùng thì bỏ!
      ldc.i4.0
      stloc.s returnValue
      leave L_return
   }
}
finally
{
   ldstr "This should be shown."
   call void [mscorlib]System.Console::WriteLine(string)
   endfinally
}
L_return:
   // trả về
   ldloc.s returnValue
   ret

Thật vậy. Ta có khối trycatch nằm bên trong một khối try khác. Sau đó mới tới finally. Đây chính là cách mà IL sinh ra từ phát biểu try ba khối.

Có nhiều khi theo sau một khối try là nhiều khối catch liền nhau. Quy tắc là biệt lệ ở khối catch đi trước phải kế thừa từ biệt lệ ở khối đi sau. Tại sao? Vì nếu khối đi trước có thể bắt biệt lệ cấp cao hơn hoặc bằng biệt lệ ở khối sau, tính theo bậc kế thừa, thì làm gì còn biệt lệ nào cho khối đi sau bắt được nữa?
Nếu các khối bắt biệt lệ không liên quan tới nhau về kế thừa (biệt lệ này không kế thừa biệt lệ kia, và ngược lại), thì thứ tự không bị bó buộc như trên.
Thí dụ 15: mã C# sau
  1. try {
  2.     DoSomethingBad();
  3. }
  4. catch (InvalidOperationException e1) {
  5.     Console.WriteLine("Invalid operation.");
  6. }
  7. catch (DivideByZeroException e2) {
  8.     Console.WriteLine("Division by zero.");
  9. }
  10. catch (Exception e3) {
  11.     Console.WriteLine("Some kind of exceptions.");
  12. }
  13. finally {
  14.     Console.WriteLine("This should be shown.");
  15. }

sẽ được dịch thành

Mã: Chọn hết

try
{
   try
   {
      call void MyClass::DoSomethingBad()
      leave L_endtry_partially
   }
   catch [mscorlib]System.InvalidOperationException
   {
      pop
      ldstr "Invalid operation."
      call void [mscorlib]System.Console::WriteLine(string)
      leave L_endtry_partially
   }
   catch [mscorlib]System.DivideByZeroException
   {
      pop
      ldstr "Division by zero."
      call void [mscorlib]System.Console::WriteLine(string)
      leave L_endtry_partially
   }
   catch [mscorlib]System.Exception
   {
      pop
      ldstr "Some kind of exceptions."
      call void [mscorlib]System.Console::WriteLine(string)
      leave L_endtry_partially
   }
   L_endtry_partially:
      leave L_endtry
}
finally
{
   ldstr "This should be shown."
   call void [mscorlib]System.Console::WriteLine(string)
   endfinally
}
L_endtry:
   // ...


VI. Lời kết
Tài liệu này đến đây xin được tạm dừng. Dẫu biết rằng còn rất nhiều vấn đề thú vị liên quan đến Common Language Infrastructure, nhưng sở học của tác giả vẫn có cái giới hạn nhất định. Trong thời gian sắp tới, khi những hiểu biết được trau dồi, tác giả hy vọng lại được tiếp tục cùng quý vị tản mạn thêm.
Trong quá trình biên soạn, tác giả đã tham khảo nhiều nguồn thông tin khác nhau. Trong số đó, có những nguồn quan trọng được liệt kê sau đây. Quý độc giả có cần nghiên cứu thêm, xin cứ theo các nguồn này.
  1. Wikipedia: Common Language Infrastructure
  2. Wikipedia: Common Type System
  3. Wikipedia: Common Intermediate Language
  4. Wikipedia: List of CIL instructions
  5. MSDN Library: OpCodes fields
  6. ECMA-335 (Common Language Infrastructure)


Quay về “[.NET] Bài viết hướng dẫn”

Đang trực tuyến

Đang xem chuyên mục này: Không có thành viên nào trực tuyến.3 khách