Bài 4: Quản lý bộ nhớ ngôn ngữ lập trình C

09:09
Lượt xem:  lần
Trong bài này, bạn sẽ học cách sử dụng và quản lý bộ nhớ trong ngôn ngữ lập trình C. Bạn sẽ hiểu các khái niệm mảng, con trỏ, địa chỉ bộ nhớ. Bạn cũng sẽ được học cấu trúc bộ nhớ của một chương trình khi được thực thi trong máy tính.
1. Bộ nhớ và quản lý bộ nhớ:
Bộ nhớ (memory) là nơi dữ liệu có thể được lưu trữ vào để sau đó khi cần có thể lấy ra. Trong các máy tính thông thường, bộ nhớ được chia thành từng cấp khác nhau với tốc độ truy cập khác nhau. Bộ nhớ với tốc độ truy cập càng nhanh thì dung lượng càng ít. Ở tầng cao nhất của giai tầng bộ nhớ (memory hierarchy) là thanh ghi (register). Thanh ghi là một loại bộ nhớ có tốc độ truy cập cực nhanh vì nó tồn tại ngay trong lòng bộ vi xử lý (processor) và tham gia trực tiếp vào quá trình tính toán của CPU (dữ liệu để tính toán được đọc trực tiếp từ register). Số lượng của thanh ghi chỉ rất nhỏ (cỡ vài chục), dung lượng của mỗi thanh ghi thường là 32 bits hoặc 64 bits. Ở tầng tiếp theo của thanh ghi là bộ nhớ cache (hay gọi tắt là cache). Cache có tốc độ truy cập nhanh nhưng nó không tham gia trực tiếp vào quá trình tính toán nên nó chậm hơn thanh ghi (thông thường các quá trình tính toán sẽ diễn ra trên thanh ghi, sau đó kết quả ở thanh ghi được lưu vào cache hoặc main memory). Tiếp theo của cache là bộ nhớ chính (main memory) mà ở các máy tính ta gọi là RAM (random access memory). Dung lượng của RAM có thể lên cỡ vài trăm MB hoặc vài GB. RAM chính là nơi lưu trữ chính của các dữ liệu, kết quả trong các quá trình tính toán của bộ vi xử lý (khi cần tăng tốc độ thì dữ liệu từ RAM được đọc tạm vào cache và register). Vì thế, RAM là bộ nhớ chính (bộ nhớ chủ) của máy tính.
Bạn có thể tưởng tượng một cách đơn giản bộ nhớ là RAM (không cần quan tâm đến register, cache, ... vì các dữ liệu ở đó thường được đọc từ RAM ra và cuối cùng cũng sẽ lưu trở lại RAM). Hơn nữa, có thể tưởng tượng một cách siêu đơn giản RAM là một dãy dài các ô nhớ (memory cells), mỗi ô nhớ là 1 byte. Các ô nhớ được đánh địa chỉ, có thể tưởng tượng địa chỉ chính là số thứ tự của ô nhớ đó trong dãy dài các ô nhớ (RAM). Các memory cell có chứa dữ liệu. Ví dụ, một dữ liệu kiểu char trong C có cỡ là 1 byte, vì thế nó được lưu trữ trong 1 ô nhớ, ví dụ giá trị của nó là 'A' thì trong ô nhớ tương ứng đó sẽ chứa chữ A (hoặc mã nhị phân biểu diễn chữ A). Có thể lấy ví dụ khác với số nguyên (int) 4 bytes. Một số int chiếm 4 ô nhớ và được lưu vào 4 ô nhớ liên tiếp trong bộ nhớ.
Khi đề cập đến bộ nhớ ta quan tâm đến 2 khía cạnh: nội dung và địa chỉ. Địa chỉ của bộ nhớ chính là địa chỉ của ô nhớ, hoặc địa chỉ của dãy ô nhớ ứng với vùng bộ nhớ đang đề cập. Ví dụ, khi chữ 'A' ở trong ví dụ trên được lưu vào địa chỉ số 100 thì ta có ô nhớ ở địa chỉ 100 chứa giá trị là chữ 'A'. Giá trị đó là nội dung của ô nhớ tại địa chỉ 100.
Như bạn đã biết, biến số trong ngôn ngữ lập trình là sự trừu tượng hóa của bộ nhớ, vì thế, biến số cũng có địa chỉ và nội dung. Địa chỉ của biến chính là địa chỉ (vùng) bộ nhớ ứng với biến số đó. Nội dung của biến chính là nội dung bộ nhớ ứng với biến đó. Như vậy, 2 khía cạnh quan trọng của biến số cũng là địa chỉ biến và nội dung biến (hay giá trị của biến).
Bộ nhớ của máy tính tuy lớn nhưng không phải là vô hạn, nó chỉ có hạn mà thôi (cỡ vài GB). Vì thế, khi dùng bộ nhớ bạn phải chỉ rõ một vùng an toàn để có thể dùng, hơn nữa, khi dùng xong vùng nhớ đó, bạn phải dọn rác đã bày ra vùng đó để lần sau có thể dùng lại. Việc chỉ rõ một vùng nhớ để dùng gọi là cấp phát bộ nhớ (memory allocation), việc trả lại vùng nhớ đó sau khi dùng xong gọi là "dọn rác" (garbage collection). Việc cấp phát bộ nhớ và dọn rác chính là công việc quản lý bộ nhớ (memory management).


2. Hàm số malloc, free và con trỏ (pointer):
Trong ngôn ngữ lập trình C, bạn có thể cấp phát bộ nhớ bằng cách gọi hàm malloc (memory allocation). Khi dùng xong vùng nhớ đó, bạn trả lại bằng cách dùng hàm free. Hàm malloc sẽ nhận tham số là cỡ (size) của vùng nhớ bạn cần cấp phát tính bằng byte, và giá trị trả về của nó là một kiểu đặc biệt, gọi là kiểu con trỏ (pointer). Như bạn đã biết, cấp phát một vùng nhớ là hành động xác lập quyền sử dụng trên vùng nhớ đó (dùng hàm malloc), vì thế giá trị trả về của hàm malloc là một giá trị nào đó đại diện cho vùng nhớ mà bạn cấp phát. Đại diện đó chính là địa chỉ của ô nhớ đầu tiên trong vùng nhớ được cấp phát, và giá trị của nó (thực chất là số nguyên) mang kiểu con trỏ. Để thuận tiện khi xét vùng nhớ đó, ta lại chia con trỏ ra theo loại dữ liệu được lưu trong vùng nhớ ấy (ví dụ dữ liệu số nguyên thì ta có con trỏ số nguyên, dữ liệu số thực thì ta có con trỏ số thực, ...). Trong ngôn ngữ C, kiểu con trỏ số nguyên được ký hiệu là "int*", con trỏ số thực được ký hiệu là "double*", ... Như vậy "int*", "double*" cũng giống như int, char, double là các kiểu dữ liệu và nó có thể được dùng để khai báo biến, khai báo giá trị trả về của hàm.
Ví dụ sau đây là khai báo có thể của hàm malloc, nếu hàm đó trả về dữ liệu kiểu con trỏ số nguyên:
int* malloc( int nSize ); 
Khi không rõ kiểu dữ liệu được lưu trong nội dung của vùng nhớ là gì thì ta có thể dùng kiểu con trỏ void* để chỉ vùng nhớ đó, vì thế hàm malloc thực sự được khai báo như sau:
void* malloc( int nSize ); 
Lưu ý: "void*" là con trỏ đến vùng nhớ chứa kiểu không xác định (tức void* là một địa chỉ bộ nhớ), hoàn toàn khác với "void" là kiểu không xác định (tức void là chỉ không có gì).
Ta có thể cưỡng chế gán giá trị của một kiểu cho một biến của kiểu khác (ví dụ gán giá trị biến nguyên cho biến số thực, điều này hoàn toàn hợp lệ vì số thực bao hàm số nguyên). Việc cưỡng chế gán kiểu đó gọi là "ép kiểu" (type casting). Ví dụ, để ép kiểu từ biến thực sang biến nguyên ta làm như sau:
double x;
int n;
x = 2.8;
n = (int)x; // type casting from double to int
printf( "n = %d\n", n ); // the value of n is 2 (because x = 2.8) 
Chương trình sau sẽ khai báo một con trỏ số thực và gán giá trị trả về của hàm malloc cho nó (bằng cách ép kiểu void* về kiểu double*):
/*
File: malloc_demo.c
Demo the method for memory allocation
*/ 

#include<stdio.h>
int main(){
  int nSize;
  double* p;
  nSize = 3; // allocate a memory region that can store 3 double value ( 3 * 8 = 24 bytes)!
  p = (double*)malloc( nSize * sizeof(double) );
  return 0;
}
Trong chương trình trên, bạn thấy ta đã xác lập quyền sử dụng 24 bytes bộ nhớ (ứng với 3 số kiểu double) bằng hàm malloc. Cỡ của một kiểu là số byte cần thiết để lưu dữ liệu của kiểu đó trong bộ nhớ, cỡ đó có thể biết được bằng toán tử sizeof (xem phần 6.2 của bài 2b). Cỡ của biến int là 4 bytes, của biến thực là 8 bytes. Giá trị trả về (kiểu void*) được gán cho biến p (kiểu con trỏ double, tức là double*), vì thế ta phải ép kiểu: (double*)malloc. Sau khi gọi hàm này, bạn có quyền sở hữu 24 bytes, địa chỉ bắt đầu của 24 bytes đó chính là giá trị của biến p (biến p mang kiểu con trỏ double* nên ta gọi nó là biến con trỏ, hay đơn giản là con trỏ).
Một câu hỏi được đặt ra là: sau khi đã có quyền sở hữu vùng nhớ 24 bytes (ứng với 3 số thực) thì ta có quyền làm gì?. Bạn có thể có 4 quyền sau đây: quyền đọc giá trị lưu trong các ô nhớ đó, quyền lưu giá trị (trong trường hợp này là số thực) vào các ô nhớ đó, quyền biết được địa chỉ của các ô nhớ đó trong bộ nhớ và cuối cùng là quyền trả lại vùng nhớ ấy cho hệ thống. 

Như vậy, ta có định nghĩa con trỏ: Con trỏ là một kiểu dữ liệu đặc biệt dùng để trỏ đến một địa chỉ xác định trong bộ nhớ
Các biến thuộc kiểu con trỏ được gọi là biến con trỏ, hay gọi tắt là con trỏ (pointer). Con trỏ của kiểu dữ liệu nào được ký hiệu bằng kiểu dữ liệu đó thêm dấu sao (*) ở cuối: int*, double*, .... Vì con trỏ là một biến để trỏ đến một địa chỉ bộ nhớ xác định nên nội dung của con trỏ chứa địa chỉ bộ nhớ, địa chỉ bộ nhớ là số thứ tự của vùng nhớ đó trong không gian địa chỉ nên nó chính là số nguyên, vì thế, nội dung của con trỏ nếu in ra màn hình sẽ là một số nguyên. Tuy nhiên, ta thường không quan tâm đến nội dung của con trỏ, mà ta chỉ quan tâm đến nội dung được lưu ở địa chỉ xác định bởi nội dung của con trỏ.
Giá trị "NULL" là một giá trị con trỏ đặc biệt để thể hiện con trỏ không trỏ vào đâu cả (thực chất là số 0). Khi con trỏ bằng NULL thì bạn không được phép truy cập nội dung của vùng mà con trỏ trỏ đến (vì nó đang không trỏ đến bất kỳ đâu). 

3. Các công cụ để truy cập bộ nhớ:
Như bạn đã biết, sau khi allocate một vùng nhớ, bạn có quyền sở hữu (sử dụng) nó. Bạn có thể đọc/viết vào vùng nhớ đó, xem địa chỉ của nó hay trả lại nó cho hệ thống.
a) Đọc dữ liệu từ một vùng nhớ ra:
Trở lại với ví dụ ở phần trên, khi ta khai báo
double* p = (double*)malloc( 3 * sizeof(double) );
ta có quyền sở hữu một vùng nhớ chứa được 3 số thực. Để truy cập số thực đầu tiên trong 3 số thực trên, ta dùng toán tử subscript như sau:
double x;
x = p[0];
Như vậy, ta có thể áp dụng toán tử subscript ([]) để truy cập một vị trí bất kỳ trong vùng nhớ mà ta sở hữu.
Tương tự, p[1] sẽ ứng với số thực thứ 2 trong 3 số thực trong vùng 24 bytes ta đang sở hữu (p[0] ứng với các byte từ 0 đến 7, p[1] ứng với các byte từ 8 đến 15, ...).
Nói một cách tổng quát, biểu thức p[n] (trong đó n là một biến nguyên) được hiểu là vùng nhớ ứng với số thực thứ n, hay vùng nhớ có địa chỉ bắt đầu là (p + n * sizeof(double)) (lưu ý: như trên đã đề cập, giá trị của p chính là địa chỉ bắt đầu của vùng nhớ do p trỏ đến).
Chính vì lý do này mà p được gọi là con trỏ (vì nó dùng để trỏ đến các ô nhớ (hoặc các dãy ô nhớ)).
Trong trường hợp p trỏ đến một dãy các giá trị (như là một dãy các số thực) thì p còn có tên khác là "mảng động một chiều" của các giá trị đó (array of value).
Khi p không trỏ đến đâu (biểu thức p == NULL cho giá trị true) thì gọi p[0] sẽ tạo ra lỗi thi hành (runtime error), lỗi này sẽ được báo là "segmentation fault". 
b) Lưu dữ liệu vào một vùng nhớ:
Việc lưu dữ liệu vào một vùng bộ nhớ cũng giống như đọc, chỉ khác là p[0] được viết ở bên tay trái (khi đó p[0] cũng là Lvalue):
p[0] = 1.3;
Khi p == NULL thì sẽ xảy ra segmentation fault. 
c) Lấy địa chỉ của ô nhớ:
Việc lấy địa chỉ của ô nhớ, như bạn đã biết, được thực hiện thông qua toán tử móc câu (&).
double* q = &(p[1]);
Phép gán trên sẽ lấy địa chỉ của số thực thứ 2 (p + 8) gán vào cho biến con trỏ q. Vì thế, q trỏ đến số thực thứ 2. Khi đó ta có
q[0] chính là p[1]
q[1] chính là p[2]
Đến đây ta càng rõ vai trò của con trỏ: nó có thể dùng để trỏ vào bất cứ đâu. 

d) Trả lại vùng nhớ cho hệ thống:
Ta trả lại vùng nhớ cho hệ thống bằng cách dùng hàm free:
// sau khi đã dùng xong vùng nhớ trỏ bởi p, trả lại nó cho hệ thống
free( p );

Áp dụng các kiến thức trên, bạn có thể viết chương trình để lưu 3 số thực vào một mảng động một chiều và hiển thị ra màn hình:
/*
File: array_demo.c
Using dynamically allocated array
*/ 

#include<stdio.h>
int main(){
  int nSize, i;
  double* p;
  double val;
  nSize = 3;
  p = (double*)malloc( nSize * sizeof(double) );
  if( p == NULL ) {
    printf( "Error: could not allocate memory\n" );
    return 1; // error occurred
  }
  p[0] = 0.5;
  p[2] = 2.5;
  p[1] = 1.5;
  for( i = 0; i < nSize; i++ ) {
    val = p[i];
    printf( "p[%d] = %lf\n", i, val );
  }
  free( p ); // do not forget this: free the memory
  return 0;
}

e) Toán tử lấy nội dung của bộ nhớ:
Như bạn đã biết bộ nhớ có 2 khía cạnh quan trọng: địa chỉ và nội dung. Bạn đã biết cách lấy địa chỉ là dùng toán tử móc câu (&). Tương tự như vậy, toán tử gián tiếp (*) là toán tử để lấy nội dung của bộ nhớ tại địa chỉ nào đó:
double x;
x = *p;
*p = 100.5;
Toán tử gián tiếp (*) khi tác động lên một con trỏ sẽ trả lại nội dung của vùng nhớ được trỏ bởi con trỏ ấy. Toán tử gián tiếp chỉ được phép tác động lên con trỏ (hoặc địa chỉ được trả về bởi toán tử móc câu)
Vì p trỏ đến số thực đầu tiên (trong dãy 3 số thực thu được qua hàm malloc) nên các biểu thức sau là tương đương:
p[0]
*p
Như vậy, hai khía cạnh của một biến số nguyên x có thể được truy cập như sau:
int x, y;
int* pX;
x = 5;
pX = &x;
y = *(&x); // this is equivalent to y = x
y = *pX; // y = x
y = pX[0]; // y = x
Đặc biệt lưu ý: *(&x) trả lại nội dung của vùng nhớ có địa chỉ được xác định bởi địa chỉ của biến x (&x), vì thế nó chính là giá trị của x!.

Hình sau minh họa cụ thể quan hệ của con trỏ p và biến x sau khi thực hiện đoạn chương trình sau:
int x;
int* p;
x = 100;
p = &x;
    

Như vậy, sau phép gán p = &x thì nội dung của p (tức số chứa trong hình chữ nhật (ô nhớ) biểu thị cho biến p) sẽ chính là địa chỉ của biến x (&x). Vì nội dung của con trỏ p chính là địa chỉ của biến x nên ta nói p trỏ đến (vùng nhớ của) biến x. Biến x được gán là 100 nên nội dung của biến chính là 100 (ở trong ô nhớ biểu thị cho x sẽ lưu giá trị 100). Quan hệ p trỏ đến x được biểu thị bằng dẫu mũi tên. Đặc biệt, khi p trỏ đến biến x, ta có thể thay đổi giá trị của *p và x cũng bị thay đổi theo vì *p biểu thị giá trị của vùng nhớ mà p trỏ đến (chính là x):
/*
File: change_x_value.c
Changing value by indirection
*/ 

#include<stdio.h>
int main(){
  int* p;
  int x;
  x = 100;
  p = &x;
  printf( "x = %d\n", x );
  *p = 101;
  printf( "x = %d\n", x );
  return 0;
}
Sau khi chạy chương trình trên, bạn sẽ thấy giá trị của x ở lần printf đầu tiên là 100, sau khi gán *p = 101 bạn sẽ thấy giá trị của x bị thay đổi thành 101.

f) Phép tăng giảm con trỏ với số nguyên:
Con trỏ có thể được cộng hoặc trừ với một số nguyên (vì giá trị của con trỏ là địa chỉ của một vùng nhớ nên nó chính là số nguyên).
Khi con trỏ của một kiểu t được cộng với 1 thì nó sẽ trỏ đến giá trị t tiếp theo trong mảng mà con trỏ hiện đang trỏ đến:
// p là một con trỏ kiểu double* đã được khai báo và gán.
// x là một biến double
x = *p; // x = p[0]
x = *(p + 1); // (p + 1) sẽ trỏ đến ô nhớ thứ (p + 1 * sizeof(double)), vì thế biểu thức này giống với x = p[1]
x = *(p + 0); // x = p[0]
x = *(p + 2); // x = p[2]
*p = 5; // gán 5 cho số thực thứ nhất
*(p + 1) = 6; // số thực thứ 2 có giá trị là 6

Ví dụ: sau khi thực hiện chương trình sau đến ngay trước lệnh "free( p );" thì trạng thái con trỏ sẽ như trên hình vẽ:
/*
File: double_array.c
Array demo
*/ 

#include<stdio.h>
int main(){
  int i;
  double* p;
  p = (double*)malloc( 5 * sizeof(double) );
  *p = 3.4;
  // equivalent to p[0] = 3.4;
  *(p + 1) = 1.5;
  *(p + 2) = 2.0;
  *(p + 3) = 3.1;
  *(p + 4) = 0.3;
  for( i = 0; i < 5; i++ ) {
    printf( "p[%d] = %lf\n", i, p[i] );
  }
  free( p );
  p = NULL;
  return 0;
}
Sau khi thực hiện lệnh free( p ) thì hệ điều hành sẽ thu hồi vùng nhớ vừa cấp phát cho p và sẽ cấp phát cho những nơi khác khi thực hiện lệnh malloc, chính vì thế có thể dữ liệu ở vùng này không bị mất đi ngay nhưng không có gì đảm bảo dữ liệu sẽ không bị mất (nó có thể bị mất bất kỳ lúc nào vì bạn không còn quyền sở hữu vùng nhớ đó nữa). Để đảm bảo chắc chắn không sử dụng nhầm p, bạn nên gán cho p = NULL sau khi đã free nó. 

4. Sự khác nhau giữa con trỏ và biến số bình thường:
Biến số bình thường là các biển kiểu int, double, char, ..., không phải là con trỏ.
Con trỏ là các biến kiểu int*, double*, char*, ...
Sự khác nhau quan trọng thể hiện như sau:
  • Giá trị của biến bình thường là giá trị thật sự của biến đó (ví dụ x = 2 thì giá trị của x là 2, khi printf( "%d", x ) sẽ hiển thị số 2).
    Giá trị của p - một biến con trỏ kiểu t (t có thể là double, int, char, ...) là địa chỉ mà con trỏ đó đang trỏ tới (printf( "%d", p ) sẽ in ra một số nguyên chính là địa chỉ (số thứ tự) của ô nhớ mà p trỏ đến.
  • Giá trị thật sự của ô nhớ mà con trỏ trỏ đến sẽ được lấy bởi *p (printf( "%d", *p) sẽ hiển thị ra 2 nếu p = &x).
    Phép gán *p = x; hay x = *p; là hợp lệ vì *p là giá trị thật sự của ô nhớ trỏ bởi p, x là giá trị thật sự của ô nhớ đại diện bởi x
  • Địa chỉ của biến x (biến bình thường) là &x (vì thế có thể gán p = &x, vì giá trị của p (p) là địa chỉ)
  • Địa chỉ của con trỏ p (tức địa chỉ của biến p): &p và giá trị của p (cũng là một địa chỉ mà p đang trỏ tới) là 2 địa chỉ hoàn toàn khác nhau,có nghĩa là p khác &p, mặc dù chúng cùng là địa chỉ.
    Biểu thức sau đây sẽ làm rõ điều đó (hãy viết chương trình có hàm main chứa các biểu thức như vậy để kiểm chứng):
    double x = 5;
    double* p = &x; // p trỏ đến ô nhớ mà x đại diện
    printf( "Giá trị của x là %d\n", x );
    printf( "Địa chỉ của x là: %d, cũng là giá trị của p: %d\n", &x, p );
    printf( "Địa chỉ của p là: %d", &p );
    printf( "Giá trị của p (cũng là địa chỉ của x) là: %d == %d == %d\n", p, &x, *(&p) );
Như vậy bạn đã thấy, có thể gán p = &x, tức là kiểu của p (double*) và &x là tương đương. Tóm lại nếu x là biến kiểu double thì địa chỉ của x sẽ là con trỏ kiểu double*.
Đến đây ta có thắc mắc nho nhỏ: p là biến con trỏ (double*), vậy địa chỉ của p (tức là &p) sẽ có kiểu gì?.
Trả lời: theo suy luận ở trên thì nó phải là kiểu con trỏ của (double*), tức là (double*)* hay viết gọn là double**. Như vậy ta có con trỏ kiểu double** sẽ trỏ đến vùng nhớ chứa giá trị là con trỏ double*. Con trỏ double* lại trỏ đến vùng nhớ có chứa giá trị double. Tóm lại, ta nhận thấy các điều sau:
double x;
double* p;
double **q;
x = 5;
p = &x;
q = &p;
Các biểu thức sau là tương đương:
x == *p == **q;
// giá trị của ô nhớ mà p trỏ đến là giá trị của x,
// giá trị của ô nhớ được trỏ bởi giá trị của ô nhớ mà q trỏ tới là giá trị của x.
x == p[0] == q[0][0];
Từ nhận xét trên, ta có thể xây dựng mảng 2 chiều (ma trận), 3 chiều hay nhiều chiều hơn nữa.

5. Mảng nhiều chiều:
Mảng nhiều chiều có thể được biểu diễn dưới dạng con trỏ của con trỏ (double** là mảng 2 chiều, double*** là mảng 3 chiều) Ví dụ, ta có thể lưu 4 giá trị vào ma trận 2x2 như sau:
int i, j;
double** a;
a = (double**)malloc( 2 * sizeof(double*) );
a[0] = (double*)malloc( 2 * sizeof(double) );
a[1] = (double*)malloc( 2 * sizeof(double) );
a[0][0] = 1; a[0][1] = 2;
a[1][0] = 3; a[1][1] = 4;
for( i = 0; i < 2; i++ ) {
for( j = 0; j < 2; j++ ) {
printf( "%lf\t", a[i][j] );
}
printf( "\n" );
}
Hãy chú ý đến cách malloc trong ví dụ trên: đầu tiên ta coi double** như là một mảng một chiều của double*, vì thế ta phải malloc nó bằng cách malloc( 2 * sizeof(double*) ), kế đến, mỗi phần tử của double** đó lại là một mảng 1 chiều của double nên ta phải malloc tiếp cho chúng: malloc( 2 * sizeof(double) ). Tổng kết lại: ta đã malloc tổng cộng là 4 phần tử double (2 lần 2), và 2 phần tử double*. 4 phần tử double chính là nơi để lưu 4 giá trị của ma trận 2x2. 


6. Ngăn xếp và cách truyền tham số khi gọi hàm:
Ngăn xếp (stack) là một cấu trúc dữ liệu trong đó chứa các phần tử, phần tử này chồng lên phần tử kia như 1 chồng đĩa. Ngăn xếp chỉ được phép lấy phần tử từ đỉnh ra (gọi là thao tác pop) và cho thêm phần tử vào đỉnh (gọi là thao tác push).
Ví dụ, ban đầu ngăn xếp đang có 3 số 3 2 1 (theo thứ tự từ trên xuống), ta push(4) vào thì sẽ có ngăn xếp mới: 4 3 2 1. Sau đó ta pop ra 2 lần thì sẽ thu được số 4, số 3 và ngăn xếp là 2 1. Sau đó ta lại push vào số 5 thì ngăn xếp là 5 2 1.
Hiệu ứng kỳ diệu của ngăn xếp là: nó cho phép đảo ngược thứ tự của một dãy, ví dụ ban đầu ta có dãy 1, 2, 3, 4. Ta lần lượt push(1), push(2), push(3), push(4) vào ngăn xếp. Sau đó ta lần lượt gọi pop() 4 lần, ta sẽ thu được dãy 4, 3, 2, 1. Vì thế, ngăn xếp còn có tên gọi là LIFO (last-in-first-out)
So sánh hiệu ứng đó với thứ tự thực hiện hàm, ta thấy có sự tương đồng:
int g()
{
return 0;
}
int f()
{
g();
}
int h()
{
return 1;
}
int main()
{
f();
h();
}
Ban đầu hàm main được gọi (ngăn xếp chứa [main]), sau đó hàm f được gọi (ngăn xếp là [f, main]), sau đó hàm g được gọi ([g, f, main]), sau đó hàm g trả về (tương ứng với pop) ngăn xếp là ([f, main]) (tức là quay về hàm f). Sau đó hàm f trả về, ngăn xếp là ([main]) (lại quay về hàm main như ban đầu). Cuối cùng, hàm h được gọi ([h, main]) và trả về [main]).
Như vậy, ta có thể theo dõi xem vị trí hiện tại được gọi từ những hàm nào bằng cách: mỗi lời gọi hàm ta push tên hàm vào ngăn xếp, mỗi lời trả về(return) ta pop tên hàm ra khỏi ngăn xếp. Con đường để đến vị trí hiện tại là thứ tự từ đáy ngăn xếp đi lên (main -> f -> g).
Điều này thực sự xảy ra trong quá trình thực hiện chương trình, vì compiler thường dùng ngăn xếp để lưu địa chỉ trả về của lời gọi hàm của bạn. Hơn nữa, khi có tham số truyền cho hàm, tham số cũng được lưu ở ngăn xếp và khi trả về thì các giá trị này bị pop khỏi ngăn xếp.
Có một điều quan trọng xảy ra khi gọi hàm, đó là các tham số của hàm bị gọi (callee, ví dụ hàm main gọi f thì f là callee, main là caller) (thường là các biến trong hàm gọi, caller) được copy vào ngăn xếp, sau đó, trong hàm bị gọi (callee) nếu bạn thay đổi các tham số này thì bản copy (trong ngăn xếp) của các biến ứng với tham số trong caller sẽ bị thay đổi!. Bản thân các biến đó không hề bị thay đổi mà chỉ là bản copy của chúng trong ngăn xếp bị thay đổi mà thôi). Khi hàm trả về, ngăn xếp ứng với callee bị rỡ bỏ, các biến trong caller không hề thay đổi giá trị.
Ví dụ, hãy chạy chương trình sau để hiểu rõ tình trạng trên:
#include<stdio.h>
void increase_two( int x ) {
printf( "(Inside increase_two) before assignment: x = %d\n", x );
x = x + 2;
printf( "(Inside increase_two) after assignment: x = %d\n", x );
}
int main()
{
int x;
x = 5;
printf( "before calling increase_two: x = %d\n", x );
increase_two( x );
printf( "after calling increase_two: x = %d\n", x );
return 0;
}
Khi chạy chương trình trên bạn sẽ thấy 4 giá trị của x được in ra là before 5, (inside) before 5, (inside) after 7, after 5. 
Nguyên nhân rất đơn giản: các biến số cục bộ (như x trong hàm main) và các tham số được ấn vào ngăn xếp, chỉ có điều các tham số (x trong hàm increase_two) được ấn vào khi gọi hàm (ấn bản copy của x vào ngăn xếp, sau đó gọi hàm increase_two) nên khi hàm increase_two kết thúc, ngăn xếp bị rỡ bỏ và x lại trở về là x trong hàm main. Như vậy, 2 biến x trong hàm main và hàm increase_two không có cùng địa chỉ, tức là chúng đại diện cho 2 ô nhớ hoàn toàn khác nhau, nhưng giá trị thì giống nhau (vì đã copy). Như bạn đã biết, hành động gán là hành động write vào memory, khi write vào địa chỉ khác thì địa chỉ kia không đổi (write vào vùng nhớ của x trong hàm increase_two: x = x + 2 thì vùng nhớ của x trong hàm main hoàn toàn không thay đổi), vì thế khi hàm trả về thì x trong hàm main vẫn không đổi.
Như vậy cần có giải pháp nếu muốn x thực sự bị thay đổi. Giải pháp đó chính là: phải truyền thẳng địa chỉ của x (không phải bản copy giá trị của x) cho hàm increase_two, khi đó trong hàm increase_two ta có thể write vào đúng địa chỉ của x trong hàm main!. 
Giải pháp đó được viết trong chương trình sau:
#include<stdio.h>
void really_increase_two( int* pX ) {
printf( "(Inside increase_two) before assignment: x = %d\n", *pX );
*pX = *pX + 2;
printf( "(Inside increase_two) after assignment: x = %d\n", *pX );
}
int main()
{
int x;
x = 5;
printf( "before calling increase_two: x = %d\n", x );
really_increase_two( &x );
printf( "after calling increase_two: x = %d\n", x );
return 0;
}
Như bạn đã thấy, hàm really_increase_two có tham số là một con trỏ kiểu nguyên (int*) vì nó có khả năng lưu địa chỉ của biến nguyên. Khi đó trong hàm ấy ta dùng toán tử gián tiếp (*pX) để lấy giá trị và gán giá trị (*pX = *pX + 2). Nhớ rằng *pX = ... là một phép gán, *pX có địa chỉ là pX (tất nhiền rồi!) và pX thì chính là &x (vì ta truyền cho hàm really_increase_two địa chỉ đó). Khi gọi hàm, một bản copy của &x sẽ được tạo ra và ấn vào ngăn xếp, giá trị của nó là địa chỉ của x. Trong hàm increase, ta thay đổi nội dung vùng nhớ tại địa chỉ đó, vì thế x trong hàm main cũng bị thay đổi. 

7. Xâu ký tự trong C:
Xâu ký tự (string) trong C chẳng qua là một mảng của các chữ (char).
Vì thế:
char* p;
p = "hello, world";
printf( "%s\n", p );
printf( "first char of p is: %c\n", p[0] );
sẽ hiển thị p và chữ đầu của p ra màn hình.
Xâu ký tự luôn kết thúc bằng ký tự có mã ASCII là 0 ('\0').
Vì thế, bạn có thể đếm số ký tự trong một xâu bằng hàm sau:
int lenth_of_string( char* strInput ) 
{
char ch;
int nOutput;
nOutput = 0;
while( 1 ) {
ch = *strInput;
if( ch == '\0' ) break;
nOutput++;
strInput++;
}
return nOutput;
}
(lưu ý cách dùng toán tử gián tiếp *strInput, và phép tăng con trỏ char để trỏ đến ký tự tiếp theo strInput++ trong ví dụ trên).

8. Nhập dữ liệu với hàm scanf:
Từ trước đến nay bạn không thể nhập dữ liệu từ bàn phím vào vì bạn chưa biết hàm scanf. Hàm scanf được khai báo như sau:
scanf( char* format, [Args,...] );
Hàm này có số đối số tùy ý (cũng giống như hàm printf).
Format là chuỗi mà bạn chỉ định kiểu dữ liệu muốn nhập vào, args là danh sách các địa chỉ biến số mà bạn muốn lưu dữ liệu vào đó.
Ví dụ, muốn nhập một số nguyên, ta làm như sau:
int x;
printf( "x = " );
scanf( "%d", &x );
printf( "Thankyou, you have input x = %d\n", x );
Muốn nhập 1 số nguyên và 1 số thực ta làm như sau:
int x;
double y;
printf( "x, y = " );
scanf( "%d %lf", &x, &y );
(ví dụ người dùng có thể gõ vào "1 1.3" ở bàn phím)
Muốn nhập một chuỗi ký tự bạn làm như sau:
char* strCustomerName;
strCustomerName = (char*)malloc( 256 * sizeof(char) ); // maximum length is 255
printf( "What is your name?: " );
scanf( "%s", strCustomerName );
printf( "\nHello, %s, have a good day!\n", strCustomerName );
Hãy tự giải thích tại sao không cần dấu & trước strCustomerName khi gọi hàm scanf?
Lý do phải tới hôm nay bạn mới nhập được dữ liệu: hàm nhập dữ liệu thay đổi các biến số truyền vào (gán cho chúng bằng dữ liệu được nhập vào) nên theo phần 6 ở trên chúng phải được truyền bằng địa chỉ (nếu không sau khi thoát khỏi hàm scanf, chúng sẽ quay về giá trị ban đầu trước khi gọi hàm scanf). Muốn hiểu được bản chất của hàm scanf bạn phải hiểu về con trỏ, địa chỉ, stack, ... nên đến hôm nay hàm này mới được giới thiệu ở đây (các sách khác thường giới thiệu hàm này cùng với hàm printf). 

9. Khai báo mảng tĩnh
Các mảng bạn khai báo nhờ con trỏ (kiểu double*) rồi malloc để cấp phát bộ nhớ cho chúng gọi là mảng động. Mảng động có cỡ được quy định lúc chạy chương trình (ví dụ người dùng nhập một số vào, và bạn gán cho số đó bằng cỡ của mảng), còn mảng tĩnh thường có cỡ được cố định trong chương trình (cỡ phải được biết trước, không thể là một biến số) (một số compiler cho phép mảng tĩnh cũng có cỡ là 1 biến số được nhưng không nên làm như vậy).
Nếu muốn dùng mảng tĩnh bạn có thể làm như sau:
int a[3]; // mảng tĩnh có 3 phần tử.
int n;
int b[n]; // error: không được phép làm việc này (một số compiler cho làm, nhưng không nên).
char strCustomerName[256];
a[0] = 1;
a[1] = 2;
printf( "a[1] = *(a+1) = %d\n", *(a+1) );
Mảng tĩnh thường chỉ có lợi khi bạn biết trước cỡ của mảng và muốn khởi tạo chúng một cách đơn giản:
int primes_less_than_ten[] = {2, 3, 5, 7};
Khi bạn viết biểu thức khởi tạo, compiler sẽ tự đếm xem mảng có bao nhiêu phần tử, vì thế bạn không cần khai báo cỡ của mảng (để trống đoạn []). Ví dụ, trong biểu thức trên, mảng primes_less_than_ten sẽ có 4 phần tử 2, 3, 5, 7. 

10. Tổ chức bộ nhớ của chương trình:
Bộ nhớ của một chương trình C được chia ra làm 3 vùng: vùng mã lệnh và các biến số toàn cục, vùng heap và vùng stack (ngăn xếp). Vùng stack chiếm ở địa chỉ cao và phát triển từ trên xuống, vùng mã lệnh và biến số toàn cục chiếm địa chỉ thấp (cỡ cố định) ,ở giữa là vùng heap phát triển từ dưới lên, như hình vẽ dưới đây.
Stack (phát triển từ trên xuống)
Heap (phát triển từ dưới lên)
Mã lệnh và biến toàn cục (cố định)
Vùng mã lệnh và biến toàn cục lưu mã lệnh của chương trình và giá trị của các biến toàn cục (đã được quyết định từ khi biên dịch chương trình nên cỡ không thay đổi).
Vùng stack (ngăn xếp) như bạn đã biết ở phần 6 là nơi lưu trữ các biến cục bộ (biến khai báo trong hàm) và là nơi để push/pop tham số mỗi khi gọi hàm.
Vùng heap (ở giữa) là vùng nhớ để cấp phát mỗi lần gọi malloc. Khi gọi free, vùng nhớ đã được cấp phát sẽ được trả lại tự do để phục vụ cho lần cấp phát sau.
Vì malloc sẽ trả về các địa chỉ trong vùng heap nên các biến số được malloc ta gọi là các biến lưu trên heap. Các biến khai báo cục bộ lưu trong vùng stack, các biến toàn cục luôn có địa chỉ cố định và nằm trong vùng mã lệnh & dữ liệu toàn cục.


Câu hỏi ôn tập:
1. Hãy nên sơ lược giai tầng bộ nhớ (memory hierarchy)
2. Quản lý bộ nhớ (memory management) là gì?
3. Các hàm số malloc / free để làm gì?
4. Con trỏ là gì?
5. Giả sử có một chương trình như sau:
#include<stdio.h>
int main()
{
int x, y;
int *p;
int **q;
x = 100;
y = 200;
p = &x;
q = &p;
return 0;
}
    
Địa chỉNội dungChú thích
??Biến x
0xFFC?Biến y
0x5FC4096Biến p
0x5F8?Biến q
Giả sử sau khi thực hiện đoạn chương trình trên đến trước lệnh "return 0;", các biến được bố trí trong bộ nhớ như ở bảng trên (bảng cạnh đoạn mã).
a) Hãy điền các số vào những chỗ có dấu chấm hỏi (?) sao cho phù hợp với kết quả chạy chương trình. Các địa chỉ hãy để ở dạng số hexadecimal (hệ 16), các nội dung hãy để ở dạng số thập phân (decimal).
b) Hãy vẽ các mũi tên để chỉ quan hệ con trỏ và đích trỏ tới.
c) Giả sử trước khi thực hiện lệnh "return 0;" ở cuối chương trình, ta thực thêm lệnh gán

**q = 500;
Hãy vẽ lại mô hình bộ nhớ nếu chương trình được execute cho đến trước lệnh "return 0;"?
d) Giả sử trước khi thực hiện lệnh "return 0;" (từ chương trình ban đầu ở câu a), ta thêm 2 lệnh
*q = &y;
*p = 400;
Hãy vẽ lại mô hình bộ nhớ nếu chương trình được execute cho đến ngay trước lệnh "return 0;" và cho biết giá trị của x và y ở thời điểm đó là bao nhiêu?
e) Hãy viết thêm các lệnh printf vào chương trình trên và execute nó để xem phán đoán của bạn có đúng không. (ví dụ: khi muốn print địa chỉ của một biến, hãy sử dụng toán tử móc câu (&) và coi địa chỉ như là 1 số nguyên dạng hexa (printf( "0x%x", &y ), %x hiển thị ra màn hình số dạng hexa, khi muốn hiển thị nội dung của vùng nhớ được trỏ bởi 1 con trỏ thì sử dụng toán tử gián tiếp *).
6. Hãy viết chương trình cho người dùng nhập vào 3 số thực a, b, c và giải phương trình bậc hai ax^2 + bx + c = 0. Xét cả trường hợp phương trình suy biến thành tuyến tính, vô nghiệm, nghiệm kép, ...
7. Hãy viết chương trình nhập vào một số tự nhiên nSize, sau đó malloc một mảng số thực (double) cỡ nSize và cho người dùng nhập vào nSize giá trị thực lưu vào mảng đó và hiển thị mảng ra màn hình.
8. Hãy viết thêm hàm số sum_array trong chương trình ở câu 6 để tính tổng của mảng. Hàm sum_array được khai báo như sau:
double sum_array( double* arrInput, int nSize );
Sau đó dùng hàm sum_array để hiển thị ra màn hình kết quả tổng của mảng?.
9. Hãy viết chương trình nhập vào một mảng số nguyên cỡ tùy ý (cỡ được nhập từ bàn phím) sau đó viết hàm tìm giá trị nhỏ nhất (min_array) của mảng và hiển thị giá trị nhỏ nhất ra màn hình?.
(gợi ý: thuật toán tìm phần tử nhỏ nhất của mảng: ban đầu gán cho minValue là phần tử thứ 0 của mảng, sau đó duyệt qua tất cả các phần tử và nếu gặp phần tử nhỏ hơn minValue thì lại gán giá trị đó cho minValue). 
10. Phép cộng một con trỏ kiểu double (double*) với một số nguyên (ví dụ 2) sẽ cho ra kết quả là gì? (chọn a, b, c dưới đây)
a) Một số nguyên (int)
b) Một con trỏ kiểu nguyên (int*)
c) Một con trỏ kiểu thực (double*)
d) Một số thực (double)
Nêu ý nghĩa của phép cộng đó?.

11. Hãy viết chương trình nhập vào một chuỗi (tối đa 255 ký tự) và đếm xem chuỗi có bao nhiêu ký tự?
12. Trong file header "string.h" có hàm strlen để tính chiều dài của chuỗi ký tự. Hãy xem "man 3 strlen" và làm câu trên sử dụng hàm này.
13. Hãy viết chương trình để người dùng nhập chiều dài tối đa của một đoạn văn bản, sau đó nhập vào đoạn văn bản (không có dấu xuống dòng) vào một mảng.
14. Viết chương trình đảo ngược thứ tự của một mảng. Ví dụ: nhập vào mảng 1, 2, 3, 4; cho ra mảng 4, 3, 2, 1.
15. Viết chương trình đảo ngược nội dung của 1 xâu ký tự (ví dụ "abc" cho ra "cba")
16. Một palindrome là một chuỗi ký tự mà khi đọc ngược hay đọc xuôi cho nội dung giống hệt nhau (ví dụ "madam", "racecar", ...).
Viết chương trình nhập vào một chuỗi ký tự, nếu chuỗi đó là 1 palindrome thì hiển thị ra màn hình "This is a palindrome", không thì hiển thị ra màn hình "Not a palindrome!".
17. Viết chương trình nhập vào cỡ của ma trận và nhập vào ma trận đó từ bàn phím.
18. Viết chương trình nhập vào 2 ma trận cỡ bất kỳ và xét xem có tính tổng của chúng được không. Nếu tính được, hãy hiển thị ra ma trận tổng.
19. Viết chương trình nhân 2 ma trận m x n và n x p (kết quả lưu vào một ma trận m x p).
20. Không dùng máy tính, đoán kết quả chương trình sau:
#include<stdio.h>
void swap( int a, int b )
{
int tmp;
tmp = a;
a = b;
b = tmp;
}
int main()
{
int a, b;
a = 5;
b = 6;
printf( "a = %d, b = %d\n", a, b );
swap( a, b );
printf( "a = %d, b = %d\n", a, b );
return 0;
}
Sau đó hãy execute chương trình trên để xem phán đoán có đúng không. Giải thích vì sao lại có kết quả đó?
21. Không dùng máy tính hãy đoán kết quả chương trình sau:
#include<stdio.h>
void swap( int* a, int* b )
{
int* tmp;
tmp = a;
a = b;
b = tmp;
}
void swap2( int* a, int* b )
{
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
}
int main()
{
int x, y;
x = 5;
y = 6;
printf( "x = %d, y = %d\n", x, y );
swap( &x, &y );
printf( "x = %d, y = %d\n", x, y );
swap2( &x, &y );
printf( "x = %d, y = %d\n", x, y );
return 0;
}
Execute chương trình trên xem bạn có đoán đúng không, giải thích kết quả?.
22. Không dùng máy tính cho biết chương trình sau in ra kết quả gì?
#include<stdio.h>
#include<string.h>
int main()
{
char* p;
char** q;
int x;
int* pX;
char* strOutput;
char* str = "hello, the pointer world!";


strOutput = (char*)malloc( 4 * sizeof(char) );
strOutput[3] = '\0'; // terminate string with '\0'

x = 1;
pX = &x;
(*pX)++;

p = &str[1];
strOutput[0] = *(p + *pX);
q = &p;
p++;
(*q)++;
strOutput[1] = q[0][1];
strOutput[2] = p[x];

printf( "strOutput = %s\n", strOutput );
return 0;
}
Hãy execute và giải thích kết quả.
(còn nữa --> see this)

Thấy Hay Thì Chia Sẻ Giúp Mình Nha (^^)

Bài Viết Liên Quan

Previous
Next Post »

Nội Quy Khi Gửi Bình Luận:

  • - Vui lòng gõ có dấu khi sử dụng tiếng việt.
  • - Nghiêm cấm spam link khác.
  • - Sử dụng ngôn ngữ có văn hóa khi comment.
  • - Chèn hình ảnh bằng code Link hình ảnh
  • - Chèn video bằng code [iframe] Link nhúng video [/iframe]
  • - Ngoài ra bạn có thể thêm những smile bên dưới vào bình luận để thêm sinh động
Biểu Tượng VuiBiểu Tượng Vui