Lời gọi hàm và khung ngăn xếp
Sau bài này bạn sẽ làm được
- Mô tả sáu bước xảy ra khi một hàm được gọi
- Vẽ được khung ngăn xếp của một chuỗi lời gọi lồng nhau
- Giải thích vai trò của địa chỉ trở về và con trỏ khung
- Dùng gdb để xem khung ngăn xếp và giá trị tham số
Đây là bài quan trọng nhất Chương 8. Nếu bạn hình dung được cái gì xảy ra trong bộ nhớ lúc một hàm được gọi, thì đệ quy, con trỏ, tràn bộ đệm và cả những thông báo lỗi khó hiểu nhất sau này đều trở nên dễ hiểu.
#Cú pháp lời gọi
int kq = cong(3, 4); /* dùng kết quả */
printf("%d\n", cong(cong(1, 2), 3)); /* lồng lời gọi */
chao(); /* hàm void, gọi như một câu lệnh */
cong(3, 4); /* hợp lệ nhưng vô nghĩa: kết quả bị vứt */Cặp ngoặc tròn chính là toán tử gọi hàm. Viết tên hàm mà không có ngoặc thì không phải gọi hàm, mà là lấy địa chỉ của hàm. Chuyện đó hoàn toàn hợp lệ và sẽ thành công cụ mạnh ở Chương 13 khi bạn học con trỏ hàm.
cong(3, 4); /* gọi hàm, được 7 */
cong; /* lấy địa chỉ hàm, không gọi gì cả */#Khung ngăn xếp
int cong3(int a, int b, int c)
{
int t = a + b;
return t + c;
}
int main(void)
{
int r = cong3(1, 2, 3);
return r;
}Hai điều quan trọng cần rút ra từ sơ đồ. Thứ nhất, biến cục bộ sống trên ngăn xếp, nghĩa là chúng biến mất ngay khi hàm kết thúc. Thứ hai, ngăn xếp lớn dần về phía địa chỉ thấp, và việc thu hồi cả một khung chỉ tốn đúng một phép cộng vào thanh ghi đỉnh ngăn xếp. Đó là lý do cấp phát biến cục bộ gần như miễn phí, trong khi malloc ở Chương 14 thì không.
#Sáu bước của một lời gọi
Phần dưới mô tả theo quy ước System V AMD64, tức là quy ước dùng trên Linux và macOS chạy vi xử lý x86 sáu tư bit. Windows dùng một quy ước hơi khác về thanh ghi nhưng cấu trúc sáu bước thì giống hệt.
Đặt đối số vào chỗ đã quy ước
Sáu đối số nguyên hoặc con trỏ đầu tiên đi vào các thanh ghi
rdi,rsi,rdx,rcx,r8,r9theo đúng thứ tự đó. Tám đối số số thực đầu tiên đi vàoxmm0tớixmm7. Những đối số còn dư mới bị đẩy lên ngăn xếp.Lệnh call đẩy địa chỉ trở về rồi nhảy
Địa chỉ của lệnh ngay sau lời gọi được đẩy lên ngăn xếp. Đây chính là mẩu giấy nhớ ghi lại chỗ cần quay về. Sau đó bộ đếm chương trình nhảy tới đầu hàm được gọi.
Đoạn mở đầu lập khung mới
Ba lệnh kinh điển: lưu con trỏ khung cũ, đặt con trỏ khung mới, rồi trừ đỉnh ngăn xếp đi một khoảng đủ chứa biến cục bộ.
push rbp mov rbp, rsp sub rsp, 32Thân hàm chạy
Mọi biến cục bộ được truy cập qua độ lệch so với
rbp, ví dụ[rbp-4]. Đó là lý do trong gdb bạn thấy biến cục bộ nằm ở các địa chỉ rất gần nhau.Đặt giá trị trả về vào đúng thanh ghi
Số nguyên và con trỏ vào
rax. Số thực vàoxmm0. Struct nhỏ có thể được nhét vào cặpraxvàrdx, struct lớn thì được chép vào vùng nhớ mà người gọi chuẩn bị sẵn.Đoạn kết thúc thu khung và quay về
Đỉnh ngăn xếp được kéo trở lại, con trỏ khung cũ được khôi phục, rồi lệnh
retlấy địa chỉ trở về ra khỏi ngăn xếp và nhảy về đó.leave ; tương đương mov rsp, rbp rồi pop rbp ret ; lấy địa chỉ trở về và nhảy
#Gọi lồng nhau
printf("%d\n", cong(cong(1, 2), 3));Lời gọi trong cùng chạy trước. Trình tự đầy đủ là: gọi cong(1, 2), khung của nó được lập rồi thu, kết quả 3 nằm trong rax. Kết quả đó thành đối số thứ nhất của lời gọi tiếp theo cong(3, 3), cho ra 6. Cuối cùng 6 thành đối số của printf.
Điểm cần nhớ: hai lời gọi cong ở trên không cùng tồn tại trên ngăn xếp. Khung thứ nhất đã bị thu hồi trước khi khung thứ hai được lập. Ngược lại, nếu bạn viết một hàm gọi hàm khác từ bên trong thân của nó thì các khung mới chồng lên nhau thật.
#include <stdio.h>
int c(int x) { return x * 2; }
int b(int x) { return c(x) + 1; } /* khung của c nằm dưới khung của b */
int a(int x) { return b(x) + 1; }
int main(void)
{
printf("%d\n", a(5)); /* 5*2 + 1 + 1 = 12 */
return 0;
}Tại thời điểm c đang chạy, trên ngăn xếp có bốn khung cùng lúc: main, a, b, c. Đó chính xác là thứ mà lệnh backtrace của gdb in ra.
#Thực hành: nhìn thấy khung bằng gdb
Đây là bài thực hành nên làm ít nhất một lần trong đời. Nó biến khung ngăn xếp từ một hình vẽ trừu tượng thành những con số bạn tự nhìn thấy.
Breakpoint 1 at 0x1129: file frame.c, line 3.
Breakpoint 1, cong3 (a=1, b=2, c=3) at frame.c:3
a = 1 b = 2 c = 3
t = 0
Stack level 0, frame at 0x7ffd5c2a3f10: rip = 0x555555555129 in cong3 (frame.c:3) saved rip = 0x555555555155 called by frame at 0x7ffd5c2a3f30
#0 cong3 (a=1, b=2, c=3) at frame.c:3 #1 main () at frame.c:10
0x7ffd5c2a3ef0: 0x0000000000000000 0x00007ffd5c2a3f30 0x7ffd5c2a3f00: 0x0000555555555155 0x0000000300000002 0x7ffd5c2a3f10: 0x00007ffd5c2a3f20 0x00007f9c4a029d90
Run till exit from #0 cong3 (...) Value returned is $1 = 6
| Lệnh gdb | Cho bạn thấy gì |
|---|---|
| break ten_ham | Dừng ngay khi vào hàm đó |
| info args | Giá trị các đối số hàm đang nhận |
| info locals | Giá trị các biến cục bộ hiện tại |
| info frame | Địa chỉ khung, rbp đã lưu, địa chỉ trở về |
| bt | Chuỗi hàm đang gọi lồng nhau, từ trong ra ngoài |
| x/8xg $rsp | Tám ô tám byte tính từ đỉnh ngăn xếp, dạng thô |
| finish | Chạy hết hàm hiện tại và in giá trị trả về |
| frame 1 | Chuyển sang xem khung của hàm gọi |
Nếu bạn dùng Windows
#Chi phí thật của một lời gọi
Một lời gọi hàm không miễn phí, nhưng cũng rẻ hơn nhiều so với điều người mới thường tưởng tượng. Trên máy để bàn hiện đại, một lời gọi không được nhúng tốn khoảng vài nano giây, tức là cỡ vài chục chu kỳ xử lý.
| Việc phải làm | Chi phí tương đối |
|---|---|
| Đặt đối số vào thanh ghi | Gần như bằng không |
| Lệnh call và ret | Rẻ, nhưng làm gián đoạn dự đoán rẽ nhánh |
| Lập và thu khung | Một phép cộng và một phép trừ |
| Lưu và khôi phục thanh ghi bị hàm dùng | Vài lệnh, tùy hàm |
| Mất khả năng tối ưu xuyên qua ranh giới hàm | Đây mới là chi phí lớn nhất |
Tự làm thử
- Biên dịch
frame.cvới-O0 -gvà chạy hết phiên gdb ở trên trên máy bạn. Chép lại các địa chỉ thật. - Vẽ tay sơ đồ ngăn xếp cho chương trình gọi lồng ba tầng ở mục trên, tại thời điểm hàm
cđang chạy. - Viết hàm trả về địa chỉ biến cục bộ, biên dịch với
-Wall -Wextra, đọc cảnh báo, rồi chạy thử vài lần xem kết quả có ổn định không. - Dùng
gcc -S -O0 -masm=intelđể xem mã hợp ngữ củacong3, tìm ra ba lệnh mở đầu và hai lệnh kết thúc. - Viết chương trình có tác dụng phụ trong đối số như
f(i++, i++), biên dịch bằng hai trình biên dịch khác nhau và so kết quả.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Mỗi lời gọi hàm tạo một khung ngăn xếp chứa địa chỉ trở về, con trỏ khung cũ, bản sao đối số và biến cục bộ.
- Sáu bước: đặt đối số, lệnh call, đoạn mở đầu, chạy thân, đặt giá trị trả về, đoạn kết thúc.
- Biến cục bộ chết ngay khi hàm kết thúc, nên không bao giờ được trả về địa chỉ của chúng.
- Thứ tự đánh giá đối số là không xác định, đừng đặt tác dụng phụ như
i++vào trong đối số. - Dùng
gdbvớibreak,info args,info framevàbtđể nhìn thấy khung ngăn xếp thật bằng mắt.