(Tôi đã đưa ra ý chính của tất cả mã trong câu trả lời này trong trường hợp bạn muốn chơi với nó)
Tôi chỉ từng làm những điều cơ bản nhất với asm trong khóa học CS101 của tôi vào năm 2003. Và tôi chưa bao giờ thực sự hiểu được cách asm và ngăn xếp hoạt động cho đến khi tôi nhận ra rằng tất cả đều cơ bản như lập trình trong C hoặc C ++ ... nhưng không có biến cục bộ, tham số và hàm. Nghe có vẻ chưa dễ dàng :) Để tôi chỉ cho bạn (đối với asm x86 với cú pháp Intel ).
1. Ngăn xếp là gì
Stack thường là một đoạn bộ nhớ liền kề được phân bổ cho mọi luồng trước khi chúng bắt đầu. Bạn có thể lưu trữ ở đó bất cứ thứ gì bạn muốn. Theo thuật ngữ C ++ ( đoạn mã # 1 ):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2. Trên và dưới của ngăn xếp
Về nguyên tắc, bạn có thể lưu trữ các giá trị trong các ô ngẫu nhiên của stack
mảng ( đoạn mã # 2.1 ):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
Nhưng hãy tưởng tượng bạn sẽ khó nhớ những ô stack
nào đã được sử dụng và ô nào là "miễn phí". Đó là lý do tại sao chúng tôi lưu trữ các giá trị mới trên ngăn xếp cạnh nhau.
Một điều kỳ lạ về ngăn xếp của asm (x86) là bạn thêm những thứ vào đó bắt đầu bằng chỉ mục cuối cùng và chuyển đến các chỉ mục thấp hơn: ngăn xếp [999], sau đó ngăn xếp [998], v.v. ( đoạn mã # 2.2 ):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
Và vẫn còn (thận trọng, bạn đang gonna bị nhầm lẫn bây giờ) cái tên "chính thức" cho stack[999]
là đáy của ngăn xếp .
Ô được sử dụng cuối cùng ( stack[997]
trong ví dụ trên) được gọi là đỉnh của ngăn xếp (xem Vị trí trên cùng của ngăn xếp trên x86 ).
3. Con trỏ ngăn xếp (SP)
Với mục đích của cuộc thảo luận này, hãy giả sử các thanh ghi CPU được biểu diễn dưới dạng các biến toàn cục (xem Thanh ghi Mục đích Chung ).
int AX, BX, SP, BP, ...;
int main(){...}
Có thanh ghi CPU (SP) đặc biệt theo dõi phần trên cùng của ngăn xếp. SP là một con trỏ (giữ một địa chỉ bộ nhớ như 0xAAAABBCC). Nhưng với mục đích của bài đăng này, tôi sẽ sử dụng nó như một chỉ mục mảng (0, 1, 2, ...).
Khi một luồng bắt đầu, SP == STACK_CAPACITY
sau đó chương trình và hệ điều hành sẽ sửa đổi nó khi cần thiết. Quy tắc là bạn không thể ghi vào ngăn xếp các ô ngoài đỉnh ngăn xếp và bất kỳ chỉ mục nào ít hơn thì SP không hợp lệ và không an toàn (do hệ thống ngắt ), vì vậy trước tiên bạn
giảm SP rồi sau đó ghi giá trị vào ô mới được cấp phát.
Khi bạn muốn đẩy nhiều giá trị trong ngăn xếp liên tiếp, bạn có thể đặt trước không gian cho tất cả chúng ( đoạn mã số 3 ):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
Ghi chú. Bây giờ bạn có thể thấy lý do tại sao phân bổ trên ngăn xếp lại nhanh như vậy - đó chỉ là một đợt giảm thanh ghi duy nhất.
4. Biến cục bộ
Hãy cùng xem hàm đơn giản này ( đoạn mã # 4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
và viết lại nó mà không sử dụng biến cục bộ ( đoạn mã # 4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
và xem nó được gọi như thế nào ( đoạn mã # 4.3 ):
someVar = triple_noLocals(11);
SP += 1;
5. Đẩy / bật
Việc bổ sung một phần tử mới trên đầu ngăn xếp là một hoạt động thường xuyên như vậy, CPU có một lệnh đặc biệt cho điều đó push
,. Chúng tôi sẽ ghép nó như thế này ( đoạn mã 5.1 ):
void push(int value) {
--SP;
stack[SP] = value;
}
Tương tự như vậy, lấy phần tử trên cùng của ngăn xếp ( đoạn mã 5.2 ):
void pop(int& result) {
result = stack[SP];
++SP;
}
Kiểu sử dụng phổ biến cho push / pop đang tạm thời tiết kiệm một số giá trị. Giả sử, chúng tôi có thứ gì đó hữu ích trong biến myVar
và vì lý do nào đó, chúng tôi cần thực hiện các phép tính sẽ ghi đè lên biến đó ( đoạn mã 5.3 ):
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6. Tham số chức năng
Bây giờ, hãy chuyển các tham số bằng cách sử dụng ngăn xếp ( đoạn mã # 6 ):
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7. return
tuyên bố
Hãy trả về giá trị trong thanh ghi AX ( đoạn mã số 7 ):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8. Con trỏ cơ sở ngăn xếp (BP) (còn được gọi là con trỏ khung ) và khung ngăn xếp
Hãy sử dụng hàm "nâng cao" hơn và viết lại nó trong C ++ giống asm của chúng tôi ( đoạn mã # 8.1 ):
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
Bây giờ, hãy tưởng tượng chúng tôi quyết định giới thiệu biến cục bộ mới để lưu trữ kết quả ở đó trước khi trả về, như chúng tôi đã làm trong tripple
(đoạn mã # 4.1). Nội dung của hàm sẽ là ( đoạn mã # 8.2 ):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
Bạn thấy đấy, chúng tôi đã phải cập nhật mọi tham chiếu đến các tham số hàm và biến cục bộ. Để tránh điều đó, chúng ta cần một chỉ số neo, chỉ số này không thay đổi khi ngăn xếp phát triển.
Chúng tôi sẽ tạo neo ngay khi nhập hàm (trước khi chúng tôi phân bổ không gian cho các local) bằng cách lưu giá trị hàng đầu (giá trị của SP) hiện tại vào thanh ghi BP. Đoạn mã số 8.3 :
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
Phần ngăn xếp thuộc về và toàn quyền kiểm soát của chức năng được gọi là khung ngăn xếp của chức năng . Ví dụ: myAlgo_noLPR_withAnchor
khung ngăn xếp của là stack[996 .. 994]
(bao gồm cả hai Idexes).
Khung bắt đầu ở BP của chức năng (sau khi chúng tôi đã cập nhật nó bên trong chức năng) và kéo dài cho đến khung ngăn xếp tiếp theo. Vì vậy, các tham số trên ngăn xếp là một phần của khung ngăn xếp của người gọi (xem chú thích 8a).
Ghi chú:
8a. Wikipedia nói khác về các thông số, nhưng ở đây tôi tuân thủ hướng dẫn của nhà phát triển phần mềm Intel , xem vol. 1, phần 6.2.4.1 Con trỏ cơ sở Stack-Frame và Hình 6-2 trong phần 6.3.2 Hoạt động Far CALL và RET . Các tham số của hàm và khung ngăn xếp là một phần của bản ghi kích hoạt của hàm (xem Gen trên các chu kỳ của hàm ).
8b. các hiệu số dương từ điểm BP đến tham số hàm và hiệu số âm trỏ đến các biến cục bộ. Điều đó khá tiện dụng để gỡ lỗi
8c. stack[BP]
lưu trữ địa chỉ của khung ngăn xếp trước đó,stack[stack[BP]]
lưu trữ khung ngăn xếp trước đó, v.v. Sau chuỗi này, bạn có thể khám phá các khung của tất cả các chức năng trong chương trình, chưa trả về. Đây là cách trình gỡ lỗi hiển thị cho bạn gọi ngăn xếp
8d. 3 hướng dẫn đầu tiên myAlgo_noLPR_withAnchor
, nơi chúng tôi thiết lập khung (lưu BP cũ, cập nhật BP, dự trữ không gian cho người dân địa phương) được gọi là phần mở đầu hàm
9. Quy ước gọi điện
Trong đoạn mã 8.1, chúng tôi đã đẩy các thông số myAlgo
từ phải sang trái và trả về kết quả AX
. Chúng tôi cũng có thể vượt qua các đoạn từ trái sang phải và quay trở lại BX
. Hoặc chuyển các tham số trong BX và CX và quay trở lại trong AX. Rõ ràng, hàm caller ( main()
) và hàm được gọi phải thống nhất với nhau về vị trí và thứ tự lưu trữ tất cả những thứ này.
Quy ước gọi điện là một tập hợp các quy tắc về cách các tham số được truyền và kết quả được trả về.
Trong đoạn mã trên, chúng tôi đã sử dụng quy ước gọi cdecl :
- Các tham số được truyền trên ngăn xếp, với đối số đầu tiên ở địa chỉ thấp nhất trên ngăn xếp tại thời điểm gọi (đẩy cuối cùng <...>). Người gọi có trách nhiệm đưa các tham số trở lại ngăn xếp sau cuộc gọi.
- giá trị trả về được đặt trong AX
- EBP và ESP phải được giữ nguyên bởi callee (
myAlgo_noLPR_withAnchor
hàm trong trường hợp của chúng tôi), sao cho người gọi ( main
hàm) có thể dựa vào các thanh ghi đó mà không bị thay đổi bởi một cuộc gọi.
- Tất cả các thanh ghi khác (EAX, <...>) có thể được chỉnh sửa tự do bởi callee; nếu một người gọi muốn bảo toàn một giá trị trước và sau khi gọi hàm, nó phải lưu giá trị đó ở nơi khác (chúng tôi làm điều này với AX)
(Nguồn: ví dụ "32-bit cdecl" từ Tài liệu Tràn ngăn xếp; bản quyền 2016 của icktoofay và Peter Cordes ; được cấp phép theo CC BY-SA 3.0. Bạn có thể tìm thấy kho lưu trữ toàn bộ nội dung Tài liệu Tràn Ngăn xếp tại archive.org, trong đó ví dụ này được lập chỉ mục theo ID chủ đề 3261 và ID ví dụ 11196.)
10. Các lệnh gọi hàm
Bây giờ là phần thú vị nhất. Cũng giống như dữ liệu, mã thực thi cũng được lưu trong bộ nhớ (hoàn toàn không liên quan đến bộ nhớ cho ngăn xếp) và mọi lệnh đều có địa chỉ.
Khi không được ra lệnh khác, CPU sẽ thực hiện lần lượt các lệnh, theo thứ tự chúng được lưu trữ trong bộ nhớ. Nhưng chúng ta có thể ra lệnh cho CPU "nhảy" đến một vị trí khác trong bộ nhớ và thực hiện các lệnh từ đó trở đi. Trong asm, nó có thể là bất kỳ địa chỉ nào và trong các ngôn ngữ cấp cao hơn như C ++, bạn chỉ có thể chuyển đến các địa chỉ được đánh dấu bằng nhãn ( có những cách giải quyết nhưng chúng không đẹp, ít nhất là).
Hãy sử dụng hàm này ( đoạn mã # 10.1 ):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
Và thay vì gọi tripple
C ++ theo cách, hãy làm như sau:
- sao chép
tripple
mã của vào đầumyAlgo
cơ
- lúc
myAlgo
nhập cảnh nhảy quatripple
mã của vớigoto
- khi chúng ta cần thực thi
tripple
mã của, hãy lưu trên địa chỉ ngăn xếp của dòng mã ngay sau khi tripple
gọi, vì vậy chúng ta có thể quay lại đây sau và tiếp tục thực thi ( PUSH_ADDRESS
macro bên dưới)
- nhảy đến địa chỉ của dòng đầu tiên (
tripple
hàm) và thực thi nó đến cuối (3. và 4. cùng là CALL
macro)
- ở cuối
tripple
(sau khi chúng tôi đã dọn dẹp người dân địa phương), lấy địa chỉ trả lại từ đầu ngăn xếp và chuyển đến đó ( RET
macro)
Vì không có cách nào dễ dàng để chuyển đến địa chỉ mã cụ thể trong C ++, chúng tôi sẽ sử dụng nhãn để đánh dấu vị trí của các bước nhảy. Tôi sẽ không đi vào chi tiết cách các macro bên dưới hoạt động, chỉ cần tôi tin rằng họ làm những gì tôi nói ( đoạn mã # 10.2 ):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
Ghi chú:
10a. bởi vì địa chỉ trả về được lưu trữ trên ngăn xếp, về nguyên tắc chúng ta có thể thay đổi nó. Đây là cách hoạt động của đòn tấn công đập ngăn xếp
10b. 3 hướng dẫn cuối cùng ở "cuối" của triple_label
(dọn dẹp cục bộ , khôi phục BP cũ, quay lại) được gọi là phần kết của hàm
11. hội
Bây giờ chúng ta hãy nhìn vào asm thực sự cho myAlgo_withCalls
. Để làm điều đó trong Visual Studio:
- đặt nền tảng xây dựng thành x86 ( không phải x86_64)
- loại xây dựng: Gỡ lỗi
- đặt điểm ngắt ở đâu đó bên trong myAlgo_withCalls
- chạy và khi quá trình thực thi dừng lại tại điểm ngắt, hãy nhấn Ctrl + Alt + D
Một điểm khác biệt với C ++ giống asm của chúng tôi là ngăn xếp của asm hoạt động trên byte thay vì int. Vì vậy, để dành không gian cho một int
, SP sẽ giảm đi 4 byte.
Ở đây chúng ta bắt đầu ( đoạn mã # 11.1 , số dòng trong nhận xét là từ ý chính ):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
Và asm cho tripple
( đoạn mã # 11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
Hy vọng, sau khi đọc bài đăng này, lắp ráp không còn khó hiểu như trước nữa :)
Dưới đây là các liên kết từ nội dung bài đăng và một số bài đọc thêm:
- Eli Bendersky , Nơi đỉnh của ngăn xếp là trên x86 - trên / dưới, đẩy / bật, SP, khung ngăn xếp, quy ước gọi
- Eli Bendersky , Bố cục khung xếp chồng trên x86-64 - args chuyển trên x64, khung ngăn xếp, vùng màu đỏ
- Đại học Mariland, Hiểu về Ngăn xếp - phần giới thiệu được viết rất hay về các khái niệm về ngăn xếp. (Nó dành cho MIPS (không phải x86) và theo cú pháp GAS, nhưng điều này không quan trọng đối với chủ đề). Xem các ghi chú khác trên Lập trình MIPS ISA nếu quan tâm.
- x86 Asm wikibook, Sổ đăng ký Mục đích Chung
- x86 wikibook gỡ rối, The Stack
- x86 wikibook gỡ bỏ, các chức năng và khung xếp chồng
- Hướng dẫn sử dụng của nhà phát triển phần mềm Intel - Tôi đã mong đợi nó thực sự khó tính, nhưng đáng ngạc nhiên là nó khá dễ đọc (mặc dù lượng thông tin quá lớn)
- Jonathan de Boyne Pollard, The gen on function perilogues - phần mở đầu / phần kết, khung ngăn xếp / bản ghi kích hoạt, vùng màu đỏ