Tuesday, May 26, 2015

Hàm trong C/C++ được gọi thế nào?

Những đoạn code bên dưới thực thi trên cấu trúc x86(32bit). Xem thêm Deassembly trong Visual Studio

- Calling convention: là một bộ nguyên tắc để gọi hàm, giúp cho việc gọi hàm và các hàm con, đệ quy.. bên trong một chương trình được thực thi bằng các thanh nhớ(register), stack... trên CPU.
- Calling convention:
Nguyên tắc này sử dụng các lệnh cơ bản của CPU: push, pop, call, ret. Có 2 nguyên tắc một dành cho Caller(Caller's rules - code nơi gọi hàm) và Callee(Callee's rules - code trong hàm được gọi). Các ngôn ngữ khác khi tương tác với cấu trúc X86 cũng sẽ tương tự

1. Nguyên tắc cho Caller:
  • 1. Trước khi gọi một hàm/chương trình con(subroutine) caller sẽ lưu lại(backup) các giá trị trên thanh nhớ(register) EBX, ECX, EDX bằng cách đẩy(push) vào stack.
  • 2. Đẩy các tham số của hàm vào stack theo thứ tự từ phải sang trái, tức là tham số cuối cùng sẽ được đẩy vào đầu tiên. Vì stack bắt đầu từ địa chỉ cao và mở rộng xuống địa chỉ thấp khi giá trị được push vào, tham số đầu tiên sẽ nằm ở địa chỉ thấp nhất để khi xử lí sẽ được pop ra đầu tiên.
  • 3. Gọi hàm bằng chỉ thị(instruction) call. Chỉ thị này sẽ đặt địa chỉ của giá trị trả về vào đầu stack và rẽ nhánh vào(nhảy vào) đoạn code bên trong hàm để thực thi.
  • 4. Sau khi thực thi xong hàm, caller sẽ xóa(remove) các tham số của hàm ra khỏi stack, và khôi phục lại top index của stack.
  • 5. Caller có thể lấy giá trị trả về của hàm trong thanh ghi EAX(nếu hàm có trả về).
  • 6. Caller khôi phục(pop ra khỏi stack) các giá trị trên EBX, ECX, EDX đã backup ở bước 1. Như vậy sau khi thực hiện xong, các giá trị trên các thanh nhớ không bị thay đổi bởi hàm/chương trình con.
Ví dụ:

int Add(int num1, int num2)
{
 int m = 0;
 int n = num1 + num2;
 return n;
}

int main()
{
 int a = 10;
 int b = 20;

 int c = Add(a, b);

 return 0;
}
Hình ảnh code Deassembly (trong Visual Studio) của đoạn code trong hàm main(). Ta xem assembly của dòng lệnh gọi hàm Add(phía dưới lệnh gọi hàm Add)
Mô tả code assembly:

int a = 10;
mov         dword ptr [a],0Ah   //Gán giá trị 10(0A ở hệ Hex) vào 
                                //vùng nhớ của biến a
int b = 20;
mov         dword ptr [b],14h   //Gán giá trị 20 vào vùng nhớ của biến b

int c = Add(a, b);
mov         eax,dword ptr [b]   //Đưa trị của biến b vào thanh nhớ EAX
push        eax                 //Đẩy(push) giá trị (của biến b) 
                                // từ thanh nhớ EAX vào stack
mov         ecx,dword ptr [a]   //Đưa trị của biến a vào thanh nhớ EAX
push        ecx                 //Đẩy(push) giá trị (của biến a)
                                // từ thanh nhớ EAX vào stack
call        Add (012811C7h)     //Gọi chỉ thị thực thi hàm Add. 
                                //Sau khi thực thi hàm này, 
                                // kết quả được đẩy vào EAX bởi callee
add         esp,8               //Sau khi thực thi hàm Add xong, xóa(pop)
                                // 2 giá trị của a và b ra khỏi stack
                                // bằng cách di chuyển stack pointer lên 
                                // 8 byte(2 biến int a và b được push vào
                                // mổi biến là 4 byte).
                                //Do stack ghi tử địa chỉ cao 
                                // xuống địa chỉ thấp nên xóa bằng cách
                                // đẩy stack pointer lên.
mov         dword ptr [c],eax   //Gán giá trị trả về từ EAX vào biến c.

2. Nguyên tắc cho Callee:
  • 1. Ở đầu mổi hàm giá trị trên EBP được đẩy vào stack(backup giá trị của EBP), và copy giá trị của ESP và EBP. EBP giống như một chỉ mục để tìm kiếm param và local variable trên stack. EBP giống như một "snapshot" của giá tri trên stack pointer khi hàm con được thực hiện. Các biến local và param nằm ở một vị trí xác định trên stack và EBP sẽ lưu lại vị trí đó.
    
     push ebp
     mov ebp, esp
    
    Phải backup lại giá trị trên EBP trước khi thực hiện hàm để quá trình thực thi nội dung hàm không làm thay đổi giá trị trên EBP của Caller. Sau đó copy giá trị của ESP(stack pointer) vào EBP để sử dụng cho việc truy cập các param và local variable.
  • 2. Cấp phát vùng nhớ trên stack cho biến local. Stack sẽ cấp phát mở rộng xuống vùng nhớ thấp, tùy theo số lượng biến sẽ được cấp phát một kích thước nhất đinh. Ví dụ 3 biến integer(mổi biến 4 byte) sẽ được cấp phát 12byte(stack pointer giảm xuống 12).
    
     sub esp, 12
    
    Cũng như param, biến local nằm trên một vị trí nhất định trên stack và được ghi lại trong EBP(base pointer).
  • 3. Các giá tri trên các thanh nhớ(EDI và ESI) được lưu lại vào stack.
    Sau các bước trên, nội dung hàm sẽ được thực thi.
  • 4. Sau khi hàm kết thúc, kết quả trả về sẽ được lưu vào thanh nớ EAX.
  • 5. Khôi phục các giá trị đã được lưu(backup) trên stack vào lại các thanh nhớ(EDI và ESI).
  • 6. Hủy các biến local bằng cách di chuyển con trỏ stack pointer về lại vị trí trước khi cấp phát vùng nhớ cho các biến này. Giá trị này được lưu trong EBP(ở bước 1).
    
     mov esp, ebp
    
  • 7. Khôi phục lại giá trị EBP đã backup ở bước 1.
  • 8. Thoát ra khỏi hàm và trở về đoạn code caller bằng chỉ thị ret. Chỉ thị này sẽ xóa địa chỉ của biến được return khỏi stack.
Đoạn code assembly của hàm Add trên:
Mô tả đoạn code assembly:

int Add(int num1, int num2)
{
 push        ebp                          //Lưu giá trị base pointer củ
                                          // vào stack
 mov         ebp,esp                      //Gán giá trị base pointer mới
                                          // là giá trị của stack pointer
 sub         esp,8                        //Đẩy stack pointer xuống 8 byte
                                          // (mở rông vùng nhớ cho 2 biến 
                                          // local int là m và n).
 mov         dword ptr [n],0CCCCCCCCh     //Khởi tạo biến
 mov         dword ptr [m],0CCCCCCCCh  

 int m = 0;
 mov         dword ptr [m],0               //Gán giá trị cho biến m.

 int n = num1 + num2;                      //Các dòng lệnh bên dưới 
                                           // để thực hiện phép cộng
 mov         eax,dword ptr [num1]  
 add         eax,dword ptr [num2]  
 mov         dword ptr [n],eax  

 return n;
 mov         eax,dword ptr [n]             //Gán giá trị trả về vào 
                                           // thanh nhớ EAX
}

Hình minh họa stack khi gọi hàm
Stack mở rộng từ trên xuống(địa chỉ cao xuống địa chỉ thấp). Caller sẽ đẩy các giá trị vào stack trước rồi gọi hàm. Khi vào hàm con, các stack của callee được tạo ra trong quá trình xử lí. Khi kết thúc hàm con, callee sẽ xóa các stack của mình(nâng stack pointer lên) và thoát ra khỏi hàm, trở về caller. Caller lấy giá trị trả về và cũng xóa các stack của mình.
Càng nhiều hàm lồng vào nhau thì stack sẽ càng mở rộng xuống để lưu các giá trị của caller bên ngoài. Và khi kết thúc stack cũng được xóa ngược lên.

Friday, May 22, 2015

Sử dụng Constant Reference trong hàm

1. Constant(const)
- Dùng cho biến:  Điều kiện ràng buộc báo cho compiler biết giá trị của biến không được thay đổi.
- Dùng cho hàm: Điều kiện ràng buộc báo cho compiler biết giá trị của class không được thay đổi khi gọi hàm này.
Từ khóa const chỉ có tác dụng với compiler trong quá trình compile. Các thao tác thay đổi giá trị của biến trong lúc runtime(thông qua truy xuất vùng nhớ) điều được.

class A  
{  
public:  
 int m_nValue;  
};  
   
//Su dung  
const A a1;  
A a2;  
a1.m_nValue = 10; //Error  
a2.m_nValue = 10; //NOT error  
  
class B  
{  
public:  
    int m_nValue;  
  
    int Function1() const  
    {  
        m_nValue = 10; //Error  
        return m_nValue;  
    }  
  
    int Function2() const  
    {  
        char* p = (char*)this;  
        (int*)p = 10;     //NOT error. Gán giá trị 10 cho biến m_nValue 
                          //thông qua truy xuất vùng nhớ.  
        return m_nValue;  
    }  
}; 
- Ràng buộc của const tác dụng lên thành phần bên trái của nó, nếu không có thành phần bên trái thì nó sẽ tác dụng lên thành phần bên phải.

int const * nVal = 10  //(1)
const int * nVal = 10; //(2)
int * const nVal = 10; //(3)
void function(int const * const nVal) const; //(4)
  • (1): khai báo biến con trỏ(nVal) trỏ tới giá trị constant integer, nghĩa là KHÔNG được thay đổi giá trị 10. Ở đây const tác dụng vào thành phần bên phải là int vì không có thành phần bên trái nó.
  • (2): ý nghĩa tương tự như (1). Ở đây const tác dụng vào thành phần bên trái là int.
  • (3): khai báo biến constant pointer của kiểu int. Nghĩa là có thể thay đổi giá trị 10 nhưng KHÔNG thể thay đổi con trỏ của biến nVal sang ô nhớ khác.
  • (4): khai báo hàm constant -> KHÔNG được thay đổi giá trị trong lớp chứa hàm. Tham số truyền vào là một constant pointer trỏ tới một constant value -> KHÔNG được thay đổi địa chỉ của con trỏ và KHÔNG được thay đổi giá trị của kiểu int được truyền vào.

2. Reference(&)
- Lấy địa chỉ của biến. - Thường được dùng kết hợp với const khi truyền tham số vào hàm.

int 	n 	= 10;
int* 	pVal 	= &n; //Lay dia chi cua bien n gan vao con tro pVal

*pVal = 	5;      //n cung se bang 5
Ví dụ trên, lấy địa chỉ của biến n gán vào con trỏ pVal hay trỏ con trỏ pVal vào vùng nhớ của biến n. Khi đó nếu thay đổi giá trị của n thì giá trị trong vùng nhớ được con trỏ pVal trỏ tới cũng sẽ thay đổi theo, hay ngược lại.

3. Hàm
- Thực hiện một đoạn code và trả về kết quả.
- Cách hoạt động của hàm trong một class:
+ Hàm không tham số, không kết quả trả về: địa chỉ của hàm và con trỏ this của đối tượng(thực thể của class) được đẩy vào vùng nhớ stack rồi sau đó CPU thực thi hàm với đối tượng this. Sau khi thực thi xong, do không có trả kết quả về(kiểu return void) nên kết thúc quá trình thực hiện, các giá trị được đẩy ra khỏi.
+ Hàm có tham số và kết quả trả về: các biến được, địa chỉ hàm, con trỏ this lần lượt được đẩy vào stack. CPU thực hiện hàm, kết quả trả về được đẩy vào một thanh nhớ trên CPU. Kết thúc hàm các giá trị được đẩy ra khỏi stack.

** Hàm có sử dụng const reference và không sử dụng const reference:

struct Data
{
    int id;
    int Value;
};

Data DoSomething1(Data data); //function signature
Data DoSomething2(const Data& data);

//Su dung
Data data = 10;
Data ret1 = DoSomething1(data);
Data ret2 = DoSomething2(data);
Hàm DoSomething1: không sử dụng constant reference
+ B1: giá trị của biến data được copy vào stack.
+ B2.1: gọi instruction call để thực thi hàm.
+ B2.2: ghi kết quả trả về(kiểu Data) vào thanh nhớ.
+ B2.3: pop các giá trị ra khỏi stack.
+ B3: copy giá trị trả về từ thanh nhớ vào biến ret1.

Hàm DoSomething2: sử dụng constant reference
+ B1: địa chỉ của biến data được copy vào stack.
+ B2.1: gọi instruction call để thực thi hàm.
+ B2.2: ghi kết quả trả về(kiểu Data) vào thanh nhớ.
+ B2.3: pop các giá trị ra khỏi stack.
+ B3: copy giá trị trả về từ thanh nhớ vào biến ret2.

Có sự khác nhau ở bước B1 trong quá trình thực hiện 2 hàm. Ở hàm DoSomething1 thì data được copy vào stack và size của data là 8byte, ở hàm DoSomething2 địa chỉ của data được copy vào stack, size của một địa chỉ trên kiến trúc 32bit là 4byte(32bit). Như vậy nếu struct Data có size lớn (giả sử) khoảng 10Kb thì mổi lần thực hiện hàm DoSomething1 sẽ tốn 10Kb memory khi copy vào stack, trong khi với hàm DoSomething2 thì luôn luôn tốn 4byte. Xem thêm cách CPU thực thi một hàm
Tương tự khi xài const reference cho biến trả về của hàm, thì hàm cũng sẽ trả về một địa chỉ thay vì cả data, câu lệnh chạy nhanh hơn.

const Data& DoSomething3();
Hàm DoSomething3 trả về là một const reference. Khi sử dụng kiểu trả về này cần lưu ý, giá trị trong biến trả về KHÔNG được là biến local của hàm. Vì biến local sau khi ra khỏi hàm sẽ bị hủy và kết quả trả về không hợp lệ.

const Data& DoSomething3()
{
    Data data;
    return data; //Error! data là biến local, sẽ bị hủy sau khi ra khỏi hàm
}

Như vậy truyền địa chỉ khi sử dụng hàm sẽ tiết kiệm được memory. Nhưng khi truyền địa chỉ, giá trị của biến truyền vào có thể bị thay đổi trong hàm nên sử dụng kèm const đê không cho thay đổi giá trị lúc thực thi hàm