← C Thực Chiến · Toolchain & Kiểm soát chất lượng

Bài 8 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Gỡ lỗi, kiểm thử & CI

Biên soạn bởi Nguyễn Anh Tuấn

Gỡ lỗi C với gdb/lldb: breakpoint, đọc stack trace; viết unit test với assert/ctest (Unity/CMocka); dựng CI GitHub Actions chạy kiểm tra tự động.

Khi code chạy sai, đừng đoán - soi bằng trình gỡ lỗi. Biên dịch với -g rồi điều khiển từng bước, in biến, xem call stack:

phien-gdb

gcc -std=c17 -g main.c -o app   # -g: gdb co ten bien & so dong
gdb ./app                   # (macOS: lldb ./app)

(gdb) break main            # dat breakpoint o ham main   (b)
(gdb) run                   # chay toi breakpoint          (r)
(gdb) next                  # chay 1 dong, KHONG vao ham    (n)
(gdb) step                  # BUOC VAO ham duoc goi          (s)
(gdb) print x               # in gia tri bien x             (p x)
(gdb) backtrace             # chuoi loi goi ham hien tai    (bt)
(gdb) watch tong            # dung khi 'tong' thay doi
(gdb) continue              # chay tiep                     (c)
  • break / run / next / step / print / backtrace / continue - bộ lệnh cốt lõi.
  • next = qua một dòng; step = bước vào hàm. watch = dừng khi biến đổi.
  • lldb (macOS) lệnh tương đương: b, r, n, s, p, bt, c.

Khi chương trình sập (segfault) hay sanitizer báo lỗi, stack trace cho biết chuỗi hàm đang chạy lúc đó - đọc từ trên (nơi sập) xuống (ai gọi):

stack-trace

(gdb) backtrace
#0  tinh_tong (a=0x0, n=5) at sum.c:7      <- noi SAP (a la NULL!)
#1  xu_ly       (data=...)   at app.c:22    <- ham goi tinh_tong
#2  main        ()           at app.c:40    <- diem bat dau

# Sinh & mo core dump (anh chup luc sap):
ulimit -c unlimited
./app                # sap -> sinh file 'core'
gdb ./app core       # mo de xem trang thai luc sap

Mẹo

Frame #0 là nơi lỗi XẢY RA; nhưng nguyên nhân có thể ở frame trên (ai truyền a = NULL vào?). Dùng frame 1 để nhảy lên và print các biến ở đó.

Test tự động là tấm lưới an toàn: sửa code mà không sợ làm hỏng thứ khác. Mẫu Arrange-Act-Assert, chạy qua ctest:

test_util.c

#include <assert.h>
#include "util.h"

static void test_binh_phuong(void) {
    assert(binh_phuong(3) == 9);   // Arrange + Act + Assert
    assert(binh_phuong(0) == 0);
    assert(binh_phuong(-2) == 4);
}

int main(void) {
    test_binh_phuong();
    return 0;   // exit 0 = pass; assert fail -> abort (exit khac 0)
}

CMakeLists.txt (phần test)

enable_testing()
add_executable(tests test_util.c util.c)
add_test(NAME util_tests COMMAND tests)
# chay: ctest --test-dir build --output-on-failure
  • assert.h đủ cho bài nhỏ; lớn hơn: Unity, CMocka, Criterion.
  • Mẫu Arrange-Act-Assert: dựng dữ liệu, gọi, kiểm tra kết quả.
  • ctest gom & chạy mọi test; exit code khác 0 = có test trượt.

CI (Continuous Integration) chạy CẢ chuỗi công cụ của khoá này trên mỗi lần đẩy code - và chặn merge nếu có khâu nào hỏng. Bật/tắt “lỗi” để xem pipeline dừng ở đâu:

Giả lập “lỗi” trong code, xem CI dừng ở khâu nào:
Định dạng
clang-format --dry-run --Werror
Phân tích tĩnh
clang-tidy
Biên dịch
cmake --build build
Kiểm thử
ctest --output-on-failure
Test + Sanitizers
ctest (build-asan)
✓ CI xanh - qua mọi cổng. Pull request được phép merge.

CI chạy đúng thứ tự công cụ của cả khoá: định dạng → phân tích tĩnh → biên dịch → kiểm thử → test dưới sanitizer. Dừng ở thất bại đầu tiên - sửa từ gốc, không cần chạy phần sau.

.github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: sudo apt-get update && sudo apt-get install -y cmake clang-format clang-tidy
      - run: clang-format --dry-run --Werror $(git ls-files '*.c' '*.h')
      - run: cmake -S . -B build -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g" -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - run: cmake --build build
      - run: clang-tidy -p build $(git ls-files '*.c')
      - run: ctest --test-dir build --output-on-failure

Bạn vừa đi hết bộ công cụ biến một người “viết được C” thành lập trình viên C đi làm được. Quy trình hằng ngày khép kín:

  • Viết code → clang-format (định dạng) → clang-tidy (bắt lỗi tĩnh).
  • CMake build → chạy test (ctest) → chạy lại test dưới ASan/UBSan.
  • Đẩy lên → CI chạy hết chuỗi trên, chặn merge nếu hỏng.
  • Khi sập: gdb/lldb + stack trace để lần ra nguyên nhân.

Xong khoá Toolchain - bạn đã có “xưởng” của riêng mình

Từ hành trình biên dịch, thư viện & liên kết, Make/CMake, định dạng, phân tích tĩnh, sanitizers, tới gỡ lỗi/test/CI - đây chính là bộ kỹ năng nhà tuyển dụng tìm ở một lập trình viên C thực thụ. Các khoá tiếp theo của track C Thực Chiến (Hệ thống, Nhúng & CAN, Dữ liệu/ Hiệu năng/Bảo mật) sẽ dùng chính “xưởng” này để xây phần mềm thật.
Chạy trên máy bạn - kỹ năng thật cần gõ lệnh thật

Build một dự án CMake có unit test, chạy ctest, gỡ một lỗi bằng gdb/lldb, và đọc workflow CI mẫu.

$ unzip 08-debug-test-ci.zip && cd 08-debug-test-ci   # tai zip bang nut o tren
$ cmake -S . -B build && cmake --build build
$ ctest --test-dir build --output-on-failure
$ gdb ./build/app    # (macOS: lldb ./build/app) - break, run, bt, print
$ cat .github/workflows/ci.yml
Xem từng file (7) ↡

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

gdb (GNU, phổ biến trên Linux) và lldb (LLVM, mặc định macOS) cùng làm một việc. Lệnh gần như tương đương: break/b, run/r, next/n, step/s, print/p, backtrace/bt, continue/c. Học một bộ là dùng được cả hai.

next chạy hết MỘT dòng và KHÔNG đi vào hàm được gọi (nhảy qua nó). step thì BƯỚC VÀO trong hàm. Dùng next để đi nhanh trên dòng hiện tại; dùng step khi muốn xem bên trong một hàm.

breakpoint dừng khi chạy tới một DÒNG/hàm. watchpoint dừng khi một BIẾN thay đổi giá trị - rất tiện khi cần biết “ai đã sửa biến này?”. Trong gdb: break main.c:10watch x.

Nhỏ thì assert.h là đủ. Lớn hơn: Unity (nhẹ, hay dùng cho nhúng), CMocka (có mock), Criterion (hiện đại). Quan trọng không phải framework mà là CÓ test và chạy TỰ ĐỘNG (ctest/CI), theo mẫu Arrange-Act-Assert.

Bài Unit test - khoá C nâng cao →

Test cung cấp đầu vào làm code THỰC SỰ chạy nhiều nhánh; sanitizer (ASan/UBSan) bắt mọi lỗi bộ nhớ/UB trên những đường chạy đó. Kết hợp lại = tự động phát hiện use-after-free, tràn bộ đệm… mà mắt thường khó thấy.

Tiết kiệm thời gian và buộc sửa từ gốc: nếu định dạng đã sai thì chưa cần chạy test. Sửa khâu đầu, đẩy lại, pipeline chạy tiếp các khâu sau. Đó là “fail fast”.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong gdb, đang ở dòng gọi ham_phu() và muốn ĐI VÀO bên trong ham_phu để xem, dùng lệnh nào?

  1. 1

    Lệnh gdb

    Trong gdb, lệnh nào đặt breakpoint, lệnh nào in giá trị biến x?

    Hoàn thành khi: break <hàm/dòng> đặt breakpoint; print x (hoặc p x) in giá trị x.

  2. 2

    next hay step

    Bạn đang ở dòng gọi ham_phu(). Muốn ĐI VÀO trong ham_phu để xem, dùng lệnh nào?

    Hoàn thành khi: step (s). next sẽ nhảy qua ham_phu mà không vào trong.

  3. 3

    Viết một test

    Viết một assert kiểm tra binh_phuong(4) == 16.

    Hoàn thành khi: assert(binh_phuong(4) == 16);

  4. 4

    Khâu bị skip

    Trong công cụ ở Bước 4, bật “Lỗi biên dịch”. Những khâu nào bị bỏ qua? Vì sao?

    Hoàn thành khi: Kiểm thử và Test+Sanitizers bị skip - pipeline dừng ngay ở khâu Biên dịch (không build được thì chưa thể test).

  5. 5

    CI fail ở format

    CI báo đỏ ở khâu clang-format. Bạn làm gì để qua khâu này?

    Hoàn thành khi: Chạy clang-format -i trên các file, commit, đẩy lại. CI chạy lại từ đầu và qua khâu format.

  6. 6

    Quy trình hoàn chỉnh

    Liệt kê đúng thứ tự các khâu của một CI C chuyên nghiệp (từ bài học).

    Hoàn thành khi: Định dạng → Phân tích tĩnh → Biên dịch → Kiểm thử → Test dưới Sanitizer.