Menu

Segmentation fault trong C: nguyên nhân và cách sửa

Segfault nghĩa là chương trình đã chạm vào vùng nhớ không thuộc về nó. Đây là năm nguyên nhân gây ra gần như mọi segfault, mỗi nguyên nhân kèm một ví dụ tối giản và cách sửa, cùng cách tìm đúng dòng gây lỗi bằng gdb và AddressSanitizer.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Segmentation fault (core dumped)

Dòng duy nhất đó là lỗi được tìm kiếm nhiều nhất trong C, và nó không bí ẩn như vẻ ngoài. Chương trình của bạn đã yêu cầu bộ xử lý truy cập một địa chỉ bộ nhớ, hệ điều hành kiểm tra xem tiến trình của bạn có được phép chạm vào địa chỉ đó không, và câu trả lời là không. Kernel sau đó kết liễu tiến trình bằng tín hiệu SIGSEGV.

Điều mấu chốt: crash chỉ là triệu chứng, và nơi nó xảy ra thường không phải chỗ có bug. Con trỏ sai thường được tạo ra ở chỗ khác, từ trước đó, và đây chỉ là nơi đầu tiên nó được dùng. Trang này nói về năm nguyên nhân gây ra gần như mọi segfault, rồi đến hai công cụ tìm ra đúng dòng gây lỗi chỉ trong vài giây.

Các ví dụ gây crash bên dưới cố ý không phải khối code chạy được, vì chúng được viết để crash. Hãy đọc chúng, rồi đọc phiên bản đã sửa ngay sau đó.

"Vùng nhớ không thuộc về bạn" nghĩa là gì

Khi chương trình bắt đầu, hệ điều hành ánh xạ một số vùng vào không gian địa chỉ của nó: code, các biến toàn cục, stack, và phần heap đã mở rộng tới đâu. Mọi thứ khác trong không gian địa chỉ, kể cả địa chỉ 0, đều chưa được ánh xạ. Chạm vào một địa chỉ chưa ánh xạ, hoặc ghi vào một địa chỉ chỉ đọc, là phần cứng chặn lại ngay.

Vì vậy segfault không phải là trình biên dịch bắt lỗi bạn. Đó là một rào chắn lúc chạy, và nó chỉ kích hoạt khi địa chỉ không hợp lệ tình cờ nằm ngoài các trang đã ánh xạ của bạn. Đó là lý do cùng một bug có thể crash trên máy này và trông như chạy bình thường trên máy khác: cách bố trí bộ nhớ khác nhau.

Nguyên nhân 1: truy cập qua con trỏ NULL

Nguyên nhân phổ biến nhất và dễ sửa nhất. Địa chỉ 0 không bao giờ được ánh xạ, nên đọc hoặc ghi qua một con trỏ null luôn gây lỗi.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = NULL;
    *p = 42;              /* CRASH: writing to address 0 */
    printf("%d\n", *p);
    return 0;
}

Phiên bản thực tế là một lần cấp phát không được kiểm tra:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* fails, returns NULL */
    data[0] = 1;                                        /* CRASH */
    free(data);
    return 0;
}

malloc trả về NULL khi không đáp ứng được yêu cầu; fopen cũng vậy khi tệp không tồn tại, và strchr khi không có ký tự cần tìm. Hãy kiểm tra mọi hàm có thể trả về NULL trước khi dùng kết quả của nó.

Khởi tạo con trỏ bằng NULL thay vì để chưa khởi tạo. Một con trỏ null crash ngay lập tức và rõ ràng; một con trỏ rác có thể làm hỏng thứ gì đó và crash muộn hơn nhiều. Xem thêm về cách làm này ở trang con trỏ null.

Nguyên nhân 2: ghi quá cuối mảng

C không kiểm tra giới hạn mảng. Chỉ số 10 của một mảng 10 phần tử đơn giản là vùng nhớ nằm sau mảng, và trình biên dịch sẽ tính địa chỉ đó cho bạn mà không phàn nàn gì.

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* <= instead of < : one too many */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

Có crash hay không là do may rủi. Ghi bốn byte quá cuối một mảng cục bộ thường rơi vào dữ liệu khác trên stack, như một thanh ghi đã lưu, một biến khác, hay địa chỉ trả về, nên chương trình tự làm hỏng chính nó rồi crash sau đó ở một chỗ chẳng liên quan. Ghi vượt quá nhiều thì sẽ ra khỏi trang đã ánh xạ và segfault ngay lập tức.

Phiên bản ngông cuồng hơn thì luôn crash:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* far outside anything mapped: CRASH */
    return 0;
}

Cách sửa là thói quen viết i < n, và tính n thay vì gõ tay hai lần:

Chuỗi có phiên bản riêng của lỗi này: một bộ đệm không còn chỗ cho ký tự kết thúc '\0'.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 chars + terminator into 5 bytes */
    return 0;
}

strcpy không biết name lớn bao nhiêu. Hãy dùng snprintf, hàm này biết vì bạn nói cho nó:

Nguyên nhân 3: dùng con trỏ sau khi free (con trỏ treo)

Sau free(p), vùng nhớ được trả về cho bộ cấp phát. Con trỏ vẫn giữ địa chỉ cũ, nhưng địa chỉ đó không còn là của bạn nữa.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* use after free: may print garbage, may crash */
    free(p);              /* double free: usually aborts or corrupts the heap */
    return 0;
}

Cái bẫy liên quan là trả về địa chỉ của một biến cục bộ. Stack frame của nó biến mất ngay khi hàm trả về:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* the frame dies here; the pointer dangles */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* undefined: garbage, or a crash */
    return 0;
}

Hai cách sửa, tùy vào ý định của bạn. Trả về giá trị thay vì con trỏ, hoặc cấp phát trên heap và để nơi gọi giải phóng:

Gán con trỏ bằng NULL ngay sau free là thói quen phòng thủ rẻ tiền: nó biến một lỗi dùng sau khi giải phóng âm thầm thành một lần crash do truy cập con trỏ null, xảy ra ngay và dễ thấy, đồng thời khiến lần free(p) thứ hai trở nên vô hại, vì free(NULL) được định nghĩa là không làm gì cả. Phía cấp phát của câu chuyện này nằm ở cấp phát bộ nhớ động và rò rỉ bộ nhớ.

Nguyên nhân 4: stack overflow do đệ quy mất kiểm soát

Mỗi lời gọi hàm đặt một frame lên stack, và stack là một vùng có kích thước cố định (thường là 8 MB). Đệ quy không có điều kiện dừng, hoặc có nhưng không bao giờ chạm tới, sẽ chạy quá cuối vùng đó.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* no base case: never stops */
}

int main(void) {
    return countdown(5);
}

Điều tương tự xảy ra với một điều kiện dừng mà đệ quy bước qua mất:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* from an odd n, never equals 0 */
}

Mọi hàm đệ quy cần một điều kiện dừng chạm tới được từ mọi đầu vào:

Một mảng cục bộ khổng lồ cũng gây ra điều đó: int buffer[10000000]; bên trong một hàm đòi 40 MB stack và gây lỗi ở lần ghi đầu tiên. Hãy cấp phát các bộ đệm lớn trên heap bằng malloc. Xem stack và heap để biết các kích thước liên quan, và đệ quy để thiết kế điều kiện dừng.

Nguyên nhân 5: ghi vào chuỗi literal

Nguyên nhân này làm nhiều người bất ngờ vì code trông vô hại.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* CRASH: string literals are read-only */
    printf("%s\n", s);
    return 0;
}

Một chuỗi literal nằm trong một phân vùng chỉ đọc của tệp thực thi. char *s = "hello" trỏ vào đó; ghi qua con trỏ đó là lỗi vi phạm bảo vệ bộ nhớ, và hệ điều hành báo nó là segfault y như một lần truy cập vào vùng chưa ánh xạ.

Cách sửa là tạo một mảng, mảng sẽ có bản sao riêng có thể sửa đổi:

Khai báo con trỏ tới literal là const char * sẽ biến lần crash lúc chạy này thành lỗi lúc biên dịch, và như thế chắc chắn tốt hơn. Hãy biến nó thành thói quen.

Tìm đúng dòng gây lỗi: gdb

Biên dịch với -g để tệp thực thi mang theo thông tin gỡ lỗi, rồi chạy nó trong trình gỡ lỗi:

gcc -g program.c -o program
gdb ./program

Bên trong gdb:

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

Ba lệnh làm phần lớn công việc. run khởi chạy chương trình và dừng ở chỗ gây lỗi. backtrace (hoặc bt) cho thấy chuỗi lời gọi dẫn tới đó; frame #1 thường là nơi con trỏ sai thật sự được tạo ra. print xem giá trị một biến, và item = 0x0 chỉ thẳng ra vấn đề.

Trên macOS, lệnh tương đương là lldb ./program, rồi run và bt.

Tìm nhanh hơn: AddressSanitizer

Tốt hơn nữa, hãy để trình biên dịch gắn công cụ đo vào chương trình. AddressSanitizer bắt được truy cập không hợp lệ ngay lúc nó xảy ra, kể cả những truy cập lẽ ra không gây crash:

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

Báo cáo đó nêu tên loại bug, dòng gây ra nó, dòng đã giải phóng vùng nhớ, và dòng đã cấp phát nó. Đây là công cụ gỡ lỗi hiệu quả nhất cho bug bộ nhớ trong C, chạy được với GCC và clang trên Linux và macOS, và chỉ làm chương trình chạy chậm khoảng 2 lần, điều không đáng bận tâm trong lúc phát triển.

Kết hợp nó với -fsanitize=undefined để bắt cả tràn số có dấu và các undefined behavior khác cùng lúc:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program là lựa chọn thay thế không cần biên dịch lại, và báo cùng các loại lỗi đó cộng thêm rò rỉ bộ nhớ.

Danh sách kiểm tra khi gặp segfault

  1. Build lại với -g -Wall -Wextra -fsanitize=address,undefined và chạy lại. Phần lớn trường hợp, báo cáo chỉ ra đúng dòng và bạn đã xong.
  2. Nếu không có sanitizer, hãy chạy trong gdb và lấy backtrace. Xem frame 1, không chỉ frame 0.
  3. Kiểm tra mọi con trỏ trên dòng gây crash. In từng con trỏ ra; 0x0 là null, còn một giá trị lạ như 0x7fff5fc01000 thường nghĩa là chưa khởi tạo hoặc đã bị giải phóng.
  4. Hỏi xem con trỏ đó từ đâu ra. Một malloc hay fopen không được kiểm tra? Địa chỉ của một biến cục bộ mà hàm chứa nó đã trả về? Một con trỏ được dùng sau free?
  5. Kiểm tra mọi giới hạn vòng lặp gần chỗ crash xem có <= ở chỗ lẽ ra là < không.
  6. Nếu stack trace sâu hàng nghìn frame, thì đó là đệ quy mất kiểm soát, hoàn toàn không phải bug con trỏ.

Phòng tránh

Những thói quen giúp segfault hiếm khi xảy ra:

  • Luôn biên dịch với -Wall -Wextra, và coi cảnh báo là bug.
  • Khởi tạo mọi con trỏ, bằng NULL nếu không có gì tốt hơn.
  • Kiểm tra giá trị trả về của malloc, calloc, realloc và fopen.
  • Gán con trỏ bằng NULL ngay sau khi giải phóng.
  • Dùng snprintf và fgets thay vì sprintf và gets.
  • Khai báo con trỏ tới chuỗi literal là const char *.
  • Ưu tiên sizeof arr / sizeof arr[0] hơn một độ dài viết cứng.
  • Chạy bộ test dưới AddressSanitizer trong CI.

Segfault là kiểu thất bại thân thiện: nó báo cho bạn biết có gì đó sai. Cùng loại bug đó mà âm thầm làm hỏng một biến bên cạnh và cho ra đáp án sai ở ba hàm sau thì tệ hơn nhiều, và các công cụ trên bắt được cả hai.

Câu hỏi thường gặp

Segmentation fault trong C là gì?

Là một lần crash do hệ điều hành kích hoạt khi chương trình truy cập vùng nhớ nó không được phép chạm vào: đọc hoặc ghi qua một con trỏ không hợp lệ, chạy quá cuối mảng vào một trang bộ nhớ chưa được ánh xạ, hoặc làm tràn stack. Kernel gửi cho tiến trình tín hiệu SIGSEGV, tín hiệu này kết thúc tiến trình và in ra "Segmentation fault (core dumped)".

Làm sao tìm chỗ xảy ra segmentation fault?

Biên dịch kèm thông tin gỡ lỗi và chạy trong trình gỡ lỗi: gcc -g program.c -o program rồi gdb ./program, run, và khi chương trình crash thì gõ backtrace. Lệnh đó in ra chính xác tệp và dòng. Nhanh hơn nữa với bug bộ nhớ là gcc -g -fsanitize=address program.c -o program: chỉ cần chạy chương trình là có ngay một báo cáo đầy đủ về lỗi gì và ở đâu.

Vì sao chương trình C chỉ thỉnh thoảng mới bị segfault?

Vì truy cập không hợp lệ là undefined behavior, không phải crash chắc chắn. Ghi quá cuối mảng một phần tử thường rơi vào vùng nhớ mà tiến trình của bạn vẫn sở hữu, nên không có gì ngăn bạn lại: thay vào đó nó làm hỏng một biến bên cạnh. Bạn chỉ bị segfault khi địa chỉ sai tình cờ nằm ngoài một trang đã ánh xạ, và điều đó phụ thuộc vào cách bố trí bộ nhớ của từng bản build và từng lần chạy.

Segmentation fault có nghĩa là bị rò rỉ bộ nhớ không?

Không, đó là hai vấn đề ngược nhau. Rò rỉ là vùng nhớ bạn đã cấp phát mà không bao giờ giải phóng: chương trình vẫn chạy và phình dần ra. Segfault là chạm vào vùng nhớ không thuộc về bạn. Giải phóng vùng nhớ hai lần, hoặc dùng con trỏ sau khi đã giải phóng, gây ra segfault; quên giải phóng thì gây rò rỉ.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu