Bộ nhớ truy cập ngẫu nhiên 16GB DDR mà con trai tôi đã sử dụng trong PC chơi game mới của mình


Gần đây, tôi đã cố gắng học cách đọc ngôn ngữ lắp ráp x86. Trong bài đăng cuối cùng của tôi , tôi đã khám phá cú pháp x86 cơ bản trong một chương trình rất đơn giản sử dụng một vài thanh ghi. Nhưng trong bài đăng đó, tôi đã không đề cập đến cách các hướng dẫn đề cập đến các giá trị nằm trong bộ nhớ và không phải trong một thanh ghi. Để có ích ở tất cả, mã x86 phải tải dữ liệu từ bộ nhớ vào một thanh ghi và cuối cùng lưu dữ liệu từ một thanh ghi trở lại bộ nhớ.

Hướng dẫn ngôn ngữ hội truy cập các giá trị trong bộ nhớ bằng cách xem nội dung của người đăng ký là địa chỉ bộ nhớ và sau đó hủy bỏ nó giống như cách bạn sử dụng một con trỏ trong chương trình C. Trong thực tế, với tôi, C và ngôn ngữ lắp ráp có vẻ rất giống nhau theo cách này, mà tôi nghi ngờ không phải là một sự trùng hợp ngẫu nhiên.

Hôm nay, tôi sẽ đọc và cố gắng hiểu một chương trình ngôn ngữ lắp ráp x86 rất đơn giản để đọc và ghi vào bộ nhớ. Để làm cho các hướng dẫn x86 dễ thực hiện hơn một chút, trước tiên tôi sẽ viết lại chúng bằng cú pháp con trỏ C. Nếu bạn là một lập trình viên C có kinh nghiệm, điều này sẽ giúp mã x86 dễ đọc. Hoặc nếu bạn không quen thuộc với C, đây là cơ hội để bạn học cả cú pháp con trỏ C và x86 cùng một lúc.

Viết chương trình truy cập bộ nhớ

Nhưng trước tiên, chúng ta cần một chương trình ví dụ truy cập bộ nhớ. Tôi có thể tìm thấy ở đâu? Tôi có cần tìm một số mã cấp thấp từ trình điều khiển thiết bị hoặc nhân hệ điều hành không? Tất nhiên là không rồi! Mỗi chương trình bạn hoặc tôi đã từng viết truy cập bộ nhớ. Tất cả những gì tôi cần làm là dịch một trong số chúng sang ngôn ngữ lắp ráp x86.

Tôi sẽ sử dụng ví dụ Ruby của tôi từ lần trước, nhưng với một dòng mã mới lưu giá trị không đổi 42 vào một biến cục bộ. Sau khi tôi biên dịch nó, tôi sẽ có thể tìm số 42 trong mã ngôn ngữ lắp ráp:

def add_forty_two(n)
  a = 42
  n+a 
end


Một lần nữa tôi sẽ sử dụng Crystal để biên dịch mã Ruby của mình:

crystal build add_forty_two.rb --emit asm


Tìm kiếm thông qua chức năng được tạo add_forty_two.s file, tôi tìm thấy add\_forty\_twochức năng, dọn sạch nó và dán các hướng dẫn ngôn ngữ lắp ráp của nó vào chức năng Ruby của tôi:

def add_forty_two(n)

  pushq   %rbp  
  movq    %rsp, %rbp
  movl    %edi, -8(%rbp)
  movl    $42, -4(%rbp)
  movl    -8(%rbp), %eax
  addl    -4(%rbp), %eax
  popq    %rbp  
  retq  

end


Ngôn ngữ hội: Tập lệnh Máy tính của bạn theo sau

Mã này hoàn toàn đúng theo kịch bản mà máy tính của tôi tuân theo: Điều gì xảy ra khi tôi gọi add_forty_two? Làm thế nào để máy tính của tôi biết phải làm gì? Làm thế nào để nó thêm 42 vào đối số đã cho? Nó theo kịch bản.

Cố gắng đọc ngôn ngữ lắp ráp x86 giống như cố gắng đọc một bản thảo cũ của Shakespearean


Vấn đề là kịch bản này có chứa các từ tiếng Anh cổ mà tôi không hiểu được và các từ tôi biết được đánh vần khác nhau. Tôi gần như có thể hiểu dòng mã này có nghĩa là gì:

movl    $42, -4(%rbp)


Sầu nhưng không hẳn. Tôi có thể đoán bằng cách đọc mã Ruby gốc của mình, nó có thể lưu 42 trong biến cục bộ a. Trong bài đăng cuối cùng của tôi, tôi đã học được rằng hậu tố Lv trong Movl có nghĩa là hướng dẫn sẽ di chuyển một giá trị dài hoặc 32 bit, từ nơi này sang nơi khác. Lần trước tôi cũng đã học được rằng tiền tố của USD $ Có nghĩa là số 42 là một hằng số.

Nhưng anằm ở đâu? Và nó -4(%rbp)có nghĩa là gì? Các hướng dẫn xung quanh là tồi tệ hơn; họ sử dụng cú pháp tương tự nhưng không có manh mối nào về những gì họ đang làm. Giống như một học sinh trung học thất vọng khi cố gắng đọc The Tempest , tôi cảm thấy hụt hẫng.

Tôi cần một số ghi chú trên vách đá. Tôi cần phải xem tập lệnh ngôn ngữ lắp ráp này được dịch sang tiếng Anh chuẩn, hiện đại, một ngôn ngữ tôi hiểu.

Mã C giống như một bản sao hiện đại, được làm sạch của một vở kịch Shakespeare. Khó hiểu không kém nhưng có phần dễ đọc hơn.


Phiên âm x86 hội ngôn ngữ vào C

Để minh họa điều tôi muốn nói, tôi sẽ viết lại từng lệnh x86 với cú pháp C tương đương:

Nếu bạn là một lập trình viên C có kinh nghiệm, mã giả ở phía bên phải sẽ dễ đọc hơn một chút. Bạn có thể xem cách x86 truy cập bộ nhớ bằng cách diễn giải các giá trị thanh ghi dưới dạng địa chỉ bộ nhớ và cách hướng dẫn cũng có thể giảm trước hoặc tăng sau các địa chỉ này. Chúng tôi đã dịch một cái gì đó hoàn toàn xa lạ sang một định dạng có phần dễ theo dõi hơn.

Nếu bạn không quen thuộc với C, thì hãy chuyển sang phần tiếp theo nơi tôi sẽ giải thích ba hướng dẫn này làm gì. Bạn sẽ tìm hiểu ký hiệu x86 và C nghĩa là gì, chúng khác nhau như thế nào và chúng giống nhau như thế nào.

C: Một hỗn hợp các ký hiệu cấp cao và cấp thấp

Nhưng trong khi mã giả C của tôi là đúng về mặt cú pháp, nó không có ý nghĩa gì. Các chỉ số mảng âm thường không hợp lệ trong C, và tất nhiên, một chương trình C sẽ không bao giờ tham chiếu trực tiếp các thanh ghi trên CPU như thế này để bắt đầu.

Trên thực tế, một chương trình C thích hợp để thêm 42 sẽ giống với mã Ruby mà tôi đã bắt đầu ở trên:

#include <stdio.h>

unsigned int add_forty_two(n)
{
  unsigned int a = 42; 
  return a+n;
}

printf("50 + 42 is %d", add_forty_two(50));


Quan điểm của tôi hôm nay là C trộn các ký hiệu ngôn ngữ cấp cao và cấp thấp. Các tính năng và khả năng cơ bản của bộ vi xử lý x86 của tôi bị rò rỉ thông qua cú pháp lập trình C. Viết bằng C, tôi có thể tạo các hàm, biến và trả về các giá trị như ngôn ngữ cấp cao, nhưng tôi cũng có thể thả xuống mức mà bộ vi xử lý của tôi hoạt động, truy cập bộ nhớ trực tiếp bằng con trỏ.

Và biết cách sử dụng con trỏ C, tôi tiến một bước để hiểu ngôn ngữ lắp ráp x86. Như chúng ta sẽ thấy tiếp theo, có một vài khác biệt quan trọng giữa ký hiệu C và x86 mà tôi cần phải hiểu kỹ. Nhưng những điều này là hời hợt. Hóa ra chỉ bằng cách học C, tôi cũng đã học được rất nhiều về khả năng của bộ vi xử lý máy tính của tôi.

Trong một bài viết trong tương lai, tôi sẽ cố gắng tìm hiểu tại sao các hướng dẫn x86 ở trên làm những gì họ làm - cách trình biên dịch của tôi gán các biến cục bộ cho các vị trí trên ngăn xếp và ngăn xếp là gì. Nhưng ngày nay, hãy tập trung vào ý nghĩa của ký hiệu con trỏ x86 và C.

Một mảng ngược, Inside Out

Hãy bắt đầu với hướng dẫn di chuyển sao chép 42 vào một địa chỉ bộ nhớ nhất định. Đây là bản dịch C:

rbp[-1] = 42;


Dòng mã này trông đủ đơn giản, nhưng thực sự có một vài điều rất kỳ lạ về nó. Đầu tiên, tôi đã viết mảng C rbpbằng cách sử dụng tên của một thanh ghi trong bộ vi xử lý của tôi. Đó là, tôi đang xử lý thanh rbpghi như thể nó là một chuỗi các giá trị, một mảng và không phải là một giá trị đơn lẻ.

Bất kỳ lập trình viên C nào đọc cùng có thể không ngạc nhiên về điều này: Trong C, một mảng thực sự chỉ là một con trỏ tới một khối bộ nhớ và không phải là một tập hợp các đối tượng hoặc các phần tử như trong Python, Ruby hoặc một số mức cao khác ngôn ngữ. Một bài viết trên blog gần đây trên Hacker News thảo luận về những mảng thực sự có trong C: A không đúng sự thật .

Con trỏ chính nó là một số chỉ vị trí của khối bộ nhớ : địa chỉ bộ nhớ :

Trong ngôn ngữ lắp ráp x86, hướng dẫn di chuyển tương tự xuất hiện theo cách này:


movl    $42, -4(%rbp)



Đối với tôi, cú pháp ngôn ngữ lắp ráp nằm bên trong: Thay vì viết tên mảng theo sau là chỉ mục trong ngoặc, tôi viết chỉ mục trước, theo sau là tên mảng trong ngoặc đơn:

Các dấu ngoặc chỉ ra lệnh di chuyển nên coi giá trị rbplà địa chỉ bộ nhớ, nó sẽ di chuyển giá trị 42 đến địa chỉ bộ nhớ được tham chiếu bởi rbp(hoặc thực tế đến địa chỉ bộ nhớ bốn byte trước giá trị của rbp) và không phải vào rbpchính nó.

Như bạn có thể thấy, điều kỳ lạ khác về mảng này là nó sử dụng một chỉ số âm. Lệnh đã movlsao chép 42 vào một địa chỉ bộ nhớ xuất hiện trước khi bắt đầu mảng - mảng này không chỉ từ trong ra ngoài, mà còn lạc hậu!

Trong một chương trình C, đây sẽ là một công thức cho thảm họa. Các lập trình viên C thường phân bổ bộ nhớ cho một mảng và sau đó truy cập các phần tử của nó bằng cách sử dụng giá trị chỉ số dương (hoặc không). Ghi vào một vị trí bộ nhớ bằng chỉ mục âm sẽ ghi đè lên bộ nhớ nằm ngoài mảng, có khả năng gây ra lỗi phân đoạn ngay lập tức hoặc nhiều khả năng khiến mã của tôi bị sập hoặc hoạt động sai sau đó khi truy cập giá trị bộ nhớ bị ghi đè này.

Chỉ số mảng x86

Đọc đoạn mã trên, có lẽ bạn cũng nhận thấy tôi đã viết mảng C bằng cách sử dụng chỉ số -1, trong khi hướng dẫn di chuyển x86 ban đầu sử dụng -4. Tại sao những điều này khác nhau? Tại sao tôi thay đổi giá trị chỉ mục khi tôi phiên âm ngôn ngữ lắp ráp thành C?

Lý do là các hướng dẫn ngôn ngữ lắp ráp x86 luôn sử dụng số byte, trong khi mảng C sử dụng chỉ số đếm phần tử thay thế. Để hiểu ý tôi là gì, hãy viết một khai báo C cho mảng tưởng tượng này trước khi sử dụng nó:

unsigned int rbp[100];
rbp[2] = 42;


Bởi vì C là một ngôn ngữ gõ tĩnh, tôi phải khai báo kiểu của các phần tử mảng khi tôi khai báo mảng. Trong ví dụ này, unsigned inttương đương với giá trị 32 bit hoặc 4 byte, cùng kích thước toán hạng được sử dụng bởi movllệnh. Vì vậy, ở đây tôi đã khai báo rbplà một mảng 100 ints, sử dụng phân đoạn bộ nhớ chứa tổng cộng 4 * 100 = 400 byte.

Bây giờ khi tôi viết rbp[2]bằng C, tôi truy cập phần tử ở vị trí 2 hoặc phần tử thứ ba:

Nhưng lưu ý rằng vì mỗi phần tử int bao gồm 4 byte, vị trí bộ nhớ rbp+2thực sự lớn hơn 8 byte rbp. Chỉ số 2 là số phần tử: (2 phần tử) * (4 byte / phần tử) = 8 byte.

Mặt khác, ngôn ngữ lắp ráp x86 sử dụng các chỉ mục byte. Điều đó có nghĩa là để truy cập cùng một phần tử trong mảng này, tôi sẽ viết 8(%rbp):

Khi bạn nhìn vào bộ nhớ theo cách này, từ quan điểm chi tiết, vật lý, chỉ số đếm byte x86 có ý nghĩa hơn. 8(%rbp)là địa chỉ rbptrỏ tới, cộng với 8 byte. Nhưng điều này không thuận tiện lắm: Hãy nghĩ về tất cả các mã bạn đã viết sử dụng mảng và các phần tử của chúng. Thông thường, bạn không muốn nghĩ về việc mỗi phần tử sử dụng bao nhiêu byte trong bộ nhớ và chính xác có bao nhiêu byte từ đầu mảng mà một phần tử được đặt tại. Kiểu C sử dụng chỉ số đếm phần tử có ý nghĩa hơn nhiều.

Trong mảng lùi từ chương trình ví dụ của tôi, movlhướng dẫn được viết là:

movl    $42, -4(%rbp)


Điều này có nghĩa là "di chuyển giá trị dài 4 byte 42 đến vị trí bộ nhớ 4 byte trước địa chỉ được tìm thấy trong thanh rbpghi."

Nhưng trong C, tôi sẽ viết:

rbp[-1] = 42;


Điều này có nghĩa là Bộ Đặt phần tử -1 của mảng thành 42 - đơn giản hơn nhiều (mặc dù vẫn hơi lạ).

Đẩy một giá trị lên ngăn xếp

Tiếp theo, hãy xem hướng dẫn x86 đầu tiên trong chương trình của tôi:

pushq   %rbp  


Hướng dẫn này pushq, đẩy một giá trị mới lên trên cùng của ngăn xếp. Hãy nghĩ về ngăn xếp như một mảng giá trị đặc biệt trong bộ nhớ. Đọc mã C tương đương làm cho việc này dễ thực hiện hơn một chút:

*--rsp = rbp;


Ở đây, tôi đã viết bài tập C bằng cú pháp con trỏ tường minh: Con trỏ là thanh ghi con trỏrsp hoặc ngăn xếp . Tiền tố dấu hoa thị là ký hiệu C để hủy bỏ con trỏ: *rspdùng để chỉ giá trị được lưu trữ tại vị trí bộ nhớ rsptrỏ tới, giống như tôi đã viết rsp[0]:

Bỏ qua các dấu trừ trong giây lát, mã C *rsp = rbpcó nghĩa là: "sao chép giá trị của rbpvào vị trí bộ nhớ có địa chỉ được chứa trong thanh rspghi."

Còn các dấu trừ thì sao? Các lập trình viên C sẽ biết những thứ này chỉ ra con trỏ, trong trường hợp này rsp, nên được giảm dần trước khi giá trị của nó bị hủy bỏ. Chúng tôi viết các dấu trừ trước con trỏ bởi vì hoạt động giảm dần xảy ra trước khi giá trị của con trỏ được sử dụng. Điều này rất hữu ích trong kịch bản này vì rspsẽ tiếp tục trỏ đến đỉnh của ngăn xếp.

Hãy tưởng tượng rspcon trỏ bắt đầu lúc 0x00007fff5fbff8f8. Đây là đỉnh của ngăn xếp, ban đầu:

Sau đó, chúng tôi giảm dần rspđể nó chỉ đến một đỉnh mới của ngăn xếp. Ngăn xếp phát triển xuống trong các chương trình x86. Mỗi lần chúng ta đẩy một giá trị lên ngăn xếp, trước tiên chúng ta sẽ giảm con trỏ ngăn xếp:

Và sau đó, bài tập ghi giá trị của rbplên trên cùng của ngăn xếp, sử dụng rspsau khi nó đã được giảm:

Lưu ý một chi tiết quan trọng khác ở đây: Con trỏ ngăn xếp được giảm 8 byte chứ không phải 4 byte như trên. Điều này là do các giá trị chúng ta đẩy lên ngăn xếp trong ví dụ này là các con trỏ hoặc các giá trị 8 byte. Chúng ta sẽ thấy tại sao trong một khoảnh khắc.

Còn ký hiệu x86 thì sao? Đẩy một giá trị lên ngăn xếp là một bộ vi xử lý x86 hoạt động phổ biến như vậy có một hướng dẫn đặc biệt cho nó : push.

pushq   %rbp  


Cũng giống như với movl, hậu tố của Q q cho biết mức độ lớn của toán hạng, kích thước của giá trị pushsao chép vào ngăn xếp. Trong trường hợp này, NQ q chỉ ra giá trị là giá trị 64 bit hoặc 8 byte. Đó là lý do tại sao mỗi giá trị trên ngăn xếp trong sơ đồ trên mất 8 byte. Nếu chương trình của tôi đã sử dụng pushlhướng dẫn, thì nó sẽ làm giảm ngăn xếp chỉ còn 4 byte (một giá trị Long dài thay vì giá trị Quad Quad).

Hành vi tự động điều chỉnh lượng giảm dần theo kích thước toán hạng là một tính năng tiện lợi của bộ vi xử lý x86. Và đó cũng là nguồn gốc của ngôn ngữ C --++toán tử. Để xem tôi muốn nói gì, hãy xem qua mã gán C tương đương:

*--rsp = rbp;


Các làm những gì --trước khi sụt điều hành trừ từ con trỏ rsp? Câu trả lời là một yếu tố. Nếu chúng ta tưởng tượng tôi đã khai báo rspmột con trỏ tới giá trị dài 8 byte:

unsigned long *rsp;
*--rsp = rbp;


Sau đó, giảm dần rspsẽ trừ 8 byte, đủ cho một giá trị dài không dấu phù hợp. Các --nhà khai thác sử dụng kích thước của kiểu tham chiếu của con trỏ để xác định những giá trị để trừ. Và giống như pushqlệnh x86, --toán tử C sẽ trừ trước khi phép gán xảy ra.

Tại sao --toán tử C hoạt động theo cách này? Bởi vì ngôn ngữ lắp ráp x86 hoạt động theo cùng một cách. Bởi vì bộ vi xử lý của máy tính của tôi hoạt động theo cách đó. Chúng ta đang thấy một ví dụ khác về cách hành vi của C phản ánh hành vi và khả năng của bộ vi xử lý trên máy tính của tôi.

Popping một giá trị ra khỏi ngăn xếp

Đây là hướng dẫn cuối cùng trong chương trình ví dụ của tôi:

retq  


Hướng dẫn này, trả về, có nghĩa là bộ vi xử lý sẽ quay trở lại chức năng gọi và tiếp tục thực hiện từ đó. Làm thế nào nó hoạt động? Một lần nữa, hãy tham khảo chức năng gán C tương đương để tìm hiểu thêm:

rip = *rsp++;


Ở đây mã C sao chép giá trị từ vị trí bộ nhớ được rspcon trỏ tham chiếu và lưu nó vào thanh ripghi.

Thanh ripghi được gọi là con trỏ lệnh , chứa một giá trị rất đặc biệt và quan trọng: địa chỉ bộ nhớ của lệnh tiếp theo mà bộ vi xử lý của tôi sẽ thực thi. Hướng dẫn này sao chép giá trị cũ hơn riptừ ngăn xếp và lưu lại vào thanh ripghi.

Mỗi lần chương trình của tôi gọi một hàm, mã ngôn ngữ hợp ngữ sẽ lưu giá trị hiện tại của ripngăn xếp và sau đó đặt ripthành một giá trị mới: vị trí của hàm được gọi. Khi chức năng đó kết thúc, chương trình của tôi sẽ lấy giá trị cũ của ripngăn xếp, tiếp tục thực hiện từ nơi nó dừng lại ở trang gọi.

Sau khi sao chép giá trị cũ của ripngăn xếp, chương trình của tôi phải tăng rspcon trỏ để giữ thanh rspghi trỏ lên đỉnh ngăn xếp. Và cũng giống như cách pushqđã làm, retqsử dụng hậu tố của Q q để xác định có bao nhiêu byte để thêm vào con trỏ ngăn xếp sau khi sao chép xong.

Bây giờ chúng ta biết ++hành vi của toán tử tăng sau C đến từ đâu: ngôn ngữ hợp ngữ. Cũng giống như retqthêm 8 byte vào rsp, biểu thức C *rsp++thêm kích thước của 1 phần tử để rspdựa trên loại kiểu tham chiếu của con trỏ:

unsigned long *rsp;
rip = *rsp++;


Lần tới

Khi tôi có thời gian tôi muốn viết thêm một bài về cú pháp x86. Bây giờ tôi đã học được các tiền tố đăng ký và hậu tố hướng dẫn có nghĩa gì trong mã x86 và cách viết hướng dẫn sử dụng giá trị đăng ký làm địa chỉ bộ nhớ, cuối cùng tôi đã sẵn sàng để đọc và hiểu một chương trình ngôn ngữ lắp ráp đơn giản. Trong bài đăng tiếp theo của tôi, tôi sẽ xem cách trình biên dịch Crystal và C của tôi gán địa chỉ bộ nhớ trên ngăn xếp cho các biến cục bộ và lý do tại sao chúng sử dụng ngăn xếp ở vị trí đầu tiên. Có lẽ vui!