Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 8.422 phút đọc

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

Khung ngăn xếp
Vùng nhớ mà một lời gọi hàm chiếm trên ngăn xếp trong suốt thời gian hàm đó đang chạy. Nó chứa địa chỉ trở về, con trỏ khung cũ, bản sao các đối số và toàn bộ biến cục bộ của hàm.
frame.c
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;
}
Mỗi lời gọi tạo ra một khung mới nằm ngay dưới khung của hàm gọi. Khi hàm kết thúc, khung đó biến mất.

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.

  1. Đặ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, r9 theo đúng thứ tự đó. Tám đối số số thực đầu tiên đi vào xmm0 tới xmm7. Những đối số còn dư mới bị đẩy lên ngăn xếp.

  2. 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.

  3. Đ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, 32
  4. Thâ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.

  5. Đặt giá trị trả về vào đúng thanh ghi

    Số nguyên và con trỏ vào rax. Số thực vào xmm0. Struct nhỏ có thể được nhét vào cặp raxrdx, struct lớn thì được chép vào vùng nhớ mà người gọi chuẩn bị sẵn.

  6. Đ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 ret lấ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.

long-nhau.c
#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.

terminal
# Bắt buộc dùng -O0 để trình biên dịch không tối ưu mất khung, và -g để có thông tin gỡ lỗi
gcc -std=c17 -O0 -g frame.c -o frame
gdb -q ./frame
phiên gdb
break cong3
Breakpoint 1 at 0x1129: file frame.c, line 3.
run
Breakpoint 1, cong3 (a=1, b=2, c=3) at frame.c:3
info args
a = 1
b = 2
c = 3
info locals
t = 0
info frame
Stack level 0, frame at 0x7ffd5c2a3f10:
 rip = 0x555555555129 in cong3 (frame.c:3)
 saved rip = 0x555555555155
 called by frame at 0x7ffd5c2a3f30
bt
#0  cong3 (a=1, b=2, c=3) at frame.c:3
#1  main () at frame.c:10
x/6xg $rsp
0x7ffd5c2a3ef0: 0x0000000000000000  0x00007ffd5c2a3f30
0x7ffd5c2a3f00: 0x0000555555555155  0x0000000300000002
0x7ffd5c2a3f10: 0x00007ffd5c2a3f20  0x00007f9c4a029d90
finish
Run till exit from #0  cong3 (...)
Value returned is $1 = 6
Lệnh gdbCho bạn thấy gì
break ten_hamDừng ngay khi vào hàm đó
info argsGiá trị các đối số hàm đang nhận
info localsGiá trị các biến cục bộ hiện tại
info frameĐịa chỉ khung, rbp đã lưu, địa chỉ trở về
btChuỗi hàm đang gọi lồng nhau, từ trong ra ngoài
x/8xg $rspTám ô tám byte tính từ đỉnh ngăn xếp, dạng thô
finishChạy hết hàm hiện tại và in giá trị trả về
frame 1Chuyển sang xem khung của hàm gọi

Nếu bạn dùng Windows

terminal
# Với MSYS2 hoặc MinGW, gdb dùng y hệt
gcc -std=c17 -O0 -g frame.c -o frame.exe && gdb -q ./frame.exe
# Nếu chỉ có Visual Studio, dùng trình gỡ lỗi tích hợp và mở cửa sổ Call Stack
cl /Zi /Od frame.c

#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àmChi phí tương đối
Đặt đối số vào thanh ghiGần như bằng không
Lệnh call và retRẻ, nhưng làm gián đoạn dự đoán rẽ nhánh
Lập và thu khungMột phép cộng và một phép trừ
Lưu và khôi phục thanh ghi bị hàm dùngVà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ử

  1. Biên dịch frame.c với -O0 -g và 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.
  2. 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.
  3. 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.
  4. Dùng gcc -S -O0 -masm=intel để xem mã hợp ngữ của cong3, tìm ra ba lệnh mở đầu và hai lệnh kết thúc.
  5. 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 gdb với break, info args, info framebt để nhìn thấy khung ngăn xếp thật bằng mắt.