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

Bộ nhớ toàn cục

Sau bài này bạn sẽ làm được

  • Phân biệt khai báo với định nghĩa biến toàn cục
  • Hiểu vì sao định nghĩa dự phòng là cái bẫy
  • Bật cờ để trình liên kết bắt lỗi trùng định nghĩa
  • Quản lý trạng thái toàn cục một cách kỷ luật

Biến toàn cục nằm cùng vùng với biến tĩnh, nhưng nó có một đặc điểm riêng gây ra rất nhiều lỗi liên kết: nó nhìn thấy được từ mọi file. Bài này nói về cách khai báo đúng, một cái bẫy lịch sử của C, và kỷ luật cần có.

#Khai báo và định nghĩa

Định nghĩa
Câu lệnh thật sự cấp phát vùng nhớ cho biến. Trong cả chương trình chỉ được có đúng một định nghĩa cho mỗi biến toàn cục.
Khai báo
Câu lệnh nói rằng biến này tồn tại ở đâu đó, để trình biên dịch biết kiểu của nó. Lặp lại bao nhiêu lần cũng được.
int g_dem = 0;        /* ĐỊNH NGHĨA: cấp phát 4 byte ở .bss */
extern int g_dem;      /* KHAI BÁO: không cấp gì, chỉ tham chiếu */
Định nghĩaKhai báo
Viết thế nàoint g_dem = 0;extern int g_dem;
Cấp phát bộ nhớKhông
Số lần cho phépĐúng một, trong cả chương trìnhBao nhiêu cũng được
Đặt ở đâuTrong đúng một file .cTrong file .h
Có giá trị khởi tạoĐượcKhông được
cauhinh.h
#ifndef CAUHINH_H
#define CAUHINH_H

extern int    g_muc_do_chi_tiet;      /* khai báo */
extern char   g_ten_chuong_trinh[64];
extern double g_nguong;

#endif
cauhinh.c
#include "cauhinh.h"

int    g_muc_do_chi_tiet = 1;         /* định nghĩa, đúng một lần */
char   g_ten_chuong_trinh[64] = "app";
double g_nguong = 0.5;
main.c
#include <stdio.h>
#include "cauhinh.h"

int main(void)
{
    printf("%d %s %.2f\n",
           g_muc_do_chi_tiet, g_ten_chuong_trinh, g_nguong);

    return 0;
}

#Định nghĩa dự phòng, cái bẫy lịch sử

Điều kỳ lạ là đoạn mã sau vẫn liên kết được trên nhiều trình biên dịch, dù nó vi phạm quy tắc một định nghĩa.

a.c
int g_dem;      /* không có giá trị khởi tạo */
b.c
int g_dem;      /* cũng vậy, nhưng vẫn liên kết được */
Định nghĩa dự phòng
Khai báo biến ở phạm vi file không có từ khóa extern và không có giá trị khởi tạo. Trình liên kết được phép gộp nhiều định nghĩa dự phòng thành một biến duy nhất.
Cách viếtLà gìNhiều file cùng viết thì sao
int g;Định nghĩa dự phòngGộp thành một, không báo lỗi
int g = 0;Định nghĩa thậtLỗi trùng định nghĩa
extern int g;Khai báo thuần túyKhông sao, đúng cách
extern int g = 0;Định nghĩa thật, extern bị bỏ quaLỗi trùng định nghĩa

#Mô hình đúng với nhiều file

Ba cách viết sai
/* 1. Định nghĩa trong .h: trùng định nghĩa */
/* cauhinh.h */
int g_dem = 0;

/* 2. Định nghĩa dự phòng ở nhiều file: có thể im lặng gộp */
/* a.c */  int g_dem;
/* b.c */  int g_dem;

/* 3. Khai báo lệch kiểu: liên kết qua nhưng chạy sai */
/* a.c */  int   g_dem = 0;
/* b.c */  extern long g_dem;
Cách đúng duy nhất
/* cauhinh.h: CHỈ khai báo */
#ifndef CAUHINH_H
#define CAUHINH_H

extern int g_dem;

#endif

/* cauhinh.c: định nghĩa đúng MỘT lần */
#include "cauhinh.h"

int g_dem = 0;

/* Mọi file khác chỉ include cauhinh.h */
#include "cauhinh.h"

Thủ thuật một file tiêu đề duy nhất

cauhinh.h
#ifndef CAUHINH_H
#define CAUHINH_H

/* Trong đúng một file .c, định nghĩa CAUHINH_TRIEN_KHAI trước khi include. */
#ifdef CAUHINH_TRIEN_KHAI
  #define CH_BIEN(kieu, ten, gt)   kieu ten = gt
#else
  #define CH_BIEN(kieu, ten, gt)   extern kieu ten
#endif

CH_BIEN(int,    g_muc_do, 1);
CH_BIEN(double, g_nguong, 0.5);

#endif
cauhinh.c
#define CAUHINH_TRIEN_KHAI
#include "cauhinh.h"

#Vòng đời và thứ tự khởi tạo

Thời điểmTrạng thái biến toàn cục
Trước khi nạp chương trìnhChưa tồn tại
Ngay sau khi nạpĐã có giá trị khởi tạo, hoặc bằng 0
Trong suốt thời gian chạyTồn tại, ai cũng sửa được
Sau khi main trả vềVẫn tồn tại cho tới khi tiến trình kết thúc
Sau khi tiến trình kết thúcHệ điều hành thu hồi toàn bộ

Biến toàn cục vẫn sống sau khi main trả về

sau-main.c
#include <stdio.h>
#include <stdlib.h>

static int g = 7;

static void khi_thoat(void)
{
    printf("atexit doc duoc g = %d\n", g);      /* vẫn hợp lệ */
}

int main(void)
{
    atexit(khi_thoat);
    g = 99;

    return 0;
}
terminal
./sau-main
atexit doc duoc g = 99

Đây là khác biệt quan trọng với biến cục bộ của main: những biến đó chết ngay khi main trả về, nên hàm đăng ký bằng atexit không được phép đụng vào chúng.

#Kỷ luật với trạng thái toàn cục

Bài 8.8 đã liệt kê ba lý do tránh biến toàn cục. Nếu bạn buộc phải dùng, đây là năm quy tắc giảm thiệt hại.

Quy tắcNội dungVì sao
1Thêm static nếu chỉ dùng trong một fileTrình liên kết không thấy tên, tránh trùng và cho tối ưu mạnh hơn
2Thêm const nếu không bao giờ sửaTrình biên dịch chặn mọi phép ghi, và biến vào vùng chỉ đọc
3Đặt tiền tố g_ hoặc tên góiNgười đọc biết ngay đây là trạng thái dùng chung
4Không cho ai đọc ghi trực tiếp, chỉ qua hàmBạn kiểm soát được ai đổi và đổi khi nào
5Ghi rõ ai được sửa và khi nào trong chú thíchĐây là hợp đồng duy nhất mà C có
Biến toàn cục mở
cauhinh.h
extern int g_muc_do;      /* ai cũng đọc ghi được, bất cứ lúc nào */
Đóng gói sau hàm truy cập
cauhinh.h
/* cauhinh.h */
int  ch_lay_muc_do(void);
int  ch_dat_muc_do(int muc_do);      /* trả về 0 nếu hợp lệ */

/* cauhinh.c */
static int g_muc_do = 1;             /* static: không file nào khác thấy */

int ch_lay_muc_do(void)
{
    return g_muc_do;
}

int ch_dat_muc_do(int muc_do)
{
    if (muc_do < 0 || muc_do > 3) return -1;

    g_muc_do = muc_do;

    return 0;
}

Trường hợp biến toàn cục thật sự đúng

#include <signal.h>

/* Cờ báo dừng do trình xử lý tín hiệu đặt.
   Kiểu volatile sig_atomic_t là kiểu DUY NHẤT chuẩn cho phép
   trình xử lý tín hiệu ghi vào một biến toàn cục. */
static volatile sig_atomic_t g_can_dung = 0;

static void xu_ly_tin_hieu(int so)
{
    (void)so;

    g_can_dung = 1;      /* chỉ được phép làm đúng chuyện này */
}

int main(void)
{
    signal(SIGINT, xu_ly_tin_hieu);

    while (!g_can_dung) {
        /* làm việc */
    }

    printf("Da dung sach se\n");

    return 0;
}

Tự làm thử

  1. Tạo ba file theo mô hình đúng, kiểm chứng chương trình biên dịch và chạy được.
  2. Đặt định nghĩa vào file tiêu đề để tạo lỗi trùng định nghĩa, chép lại thông báo của trình liên kết.
  3. Viết int g; ở hai file, biên dịch với -fcommon và với -fno-common, so hai kết quả.
  4. Khai báo cùng một biến với hai kiểu khác nhau ở hai file, quan sát chuyện gì xảy ra khi ghi ở file này và đọc ở file kia.
  5. Đổi kiểu biến trong file tiêu đề mà không đổi ở file định nghĩa, xác nhận trình biên dịch bắt được.
  6. Bọc một biến toàn cục sau cặp hàm đọc ghi có kiểm tra, dùng gdb đặt điểm dừng ở hàm ghi.
  7. Viết chương trình dừng sạch bằng cờ volatile sig_atomic_t, thử bỏ volatile và biên dịch với -O2.

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

  • Định nghĩa cấp phát bộ nhớ và chỉ được có một. Khai báo bằng extern thì lặp lại bao nhiêu cũng được.
  • File tiêu đề chỉ chứa extern, định nghĩa nằm ở đúng một file .c, và file đó cũng phải nạp file tiêu đề của chính nó.
  • Định nghĩa dự phòng là cái bẫy lịch sử. Luôn bật -fno-common, và nó đã là mặc định từ GCC 10.
  • Biến toàn cục đã có giá trị trước cả dòng đầu tiên của main, và vẫn sống sau khi main trả về.
  • Nếu buộc phải dùng trạng thái toàn cục, hãy đóng gói nó sau cặp hàm đọc ghi và đánh dấu biến là static.