Skip to content

Debug và phân tích

Bài này gom các công cụ của toolchain để trả lời ba câu hỏi: lỗi ở đâu (warning, debug info, sanitizer), chương trình có gì bên trong (symbol, map file) và làm sao cho nó nhỏ hơn (optimization, --gc-sections, strip). Ví dụ chạy trên x86-64, trừ phần kích thước chạy trên ARM.

Cấp 1: để compiler bắt lỗi từ sớm

Cách rẻ nhất để tìm lỗi là không cần chạy chương trình:

$ gcc -Wall -Wextra -std=c11 w.c -o w
w.c:6:11: warning: comparison of integer expressions of different signedness: 'unsigned int' and 'int' [-Wsign-compare]
Option Tác dụng
-Wall -Wextra bộ warning cơ bản, nên bật mọi lúc
-Werror coi warning là lỗi (dùng trong CI)
-Wshadow biến local che biến cùng tên ở scope ngoài
-Wconversion chuyển kiểu có thể mất dữ liệu (ồn nhưng hữu ích với code số học, driver)
-Wformat=2 kiểm tra chặt chuỗi format của printf
-Wundef #if FOO khi FOO chưa được #define
-fanalyzer static analyzer của GCC (ngày càng đầy đủ cho C)

Một số cảnh báo (ví dụ -Wmaybe-uninitialized) chỉ xuất hiện khi bật optimization vì chúng dựa vào phân tích luồng dữ liệu; nên build CI ở cả -O0 và -O2.

Cấp 2: thông tin debug (-g)

Mặc định executable chỉ có tên symbol (nếu chưa strip), không có tên biến, kiểu, hay dòng source. -g bổ sung các section DWARF:

$ gcc hello.c math.c -o h0
$ gcc -g hello.c math.c -o hg
$ readelf -S hg | grep debug | awk '{print $2}'
.debug_aranges
.debug_info
.debug_abbrev
.debug_line
.debug_str
.debug_line_str
$ ls -l h0 hg | awk '{print $5,$9}'
16024 h0
17640 hg
Section Chứa
.debug_info cây các kiểu, biến, hàm (DIE)
.debug_line bảng ánh xạ địa chỉ ↔ file:dòng
.debug_str chuỗi tên
.debug_aranges map địa chỉ → compilation unit

Đây là phần nằm ngoài các segment LOAD, nên không được nạp vào memory lúc chạy. -g không đổi code sinh ra, nên thêm -g vào bản release vẫn an toàn. Cũng có thể chọn mức: -g1 (tối thiểu: backtrace), -g (= -g2), -g3 (kèm macro), -ggdb3.

gdb

Ví dụ chương trình chia cho 0:

crash.c
#include <stdio.h>

static int divide(int a, int b)
{
    return a / b;
}

int main(void)
{
    printf("%d\n", divide(10, 0));
    return 0;
}
$ gcc -g -O0 crash.c -o cg && ./cg
Floating point exception
$ gdb -q ./cg
(gdb) run
Program received signal SIGFPE, Arithmetic exception.
0x000055555555515b in divide (a=10, b=0) at crash.c:5
5       return a / b;
(gdb) bt
#0  0x000055555555515b in divide (a=10, b=0) at crash.c:5
#1  0x0000555555555177 in main () at crash.c:10

Nhờ DWARF, gdb biết tên hàm, giá trị tham số và dòng source. Lệnh gdb hay dùng:

Lệnh Tác dụng
break file.c:42, break func đặt breakpoint
run, continue, next, step, finish điều khiển chạy
bt, frame N, info locals xem stack và biến
print expr, x/8xw &var in biến / dump memory
watch var dừng khi biến bị ghi
info registers, disassemble mức register và lệnh máy

Debug với -O0

Tối ưu làm code bị sắp xếp lại, biến bị loại bỏ (<optimized out>), hàm bị inline, nên dòng source nhảy lung tung. Khi cần debug từng dòng, build -Og (tối ưu nhẹ, thân thiện debug) hoặc -O0.

Debug trên target

Với embedded, chương trình chạy trên board và gdb chạy trên host: dùng gdbserver trên target (nhận kết nối qua TCP/serial) và gdb-multiarch hoặc arm-linux-gnueabihf-gdb trên host:

target$ gdbserver :1234 ./app
host$   arm-linux-gnueabihf-gdb ./app
(gdb) set sysroot /path/to/sysroot
(gdb) target remote 192.168.1.10:1234

set sysroot giúp gdb tìm đúng libc.so của target, để backtrace đi qua được code của libc (bài 6).

Không có thông tin debug thì thế nào

strip xoá symbol và (với --strip-debug) section DWARF:

$ strip -o hs h0
$ ls -l hs | awk '{print $5,$9}'
14472 hs
$ gdb -q -batch -ex run -ex bt ./cs
#0  0x000055555555515b in ?? ()
#1  0x0000555555555177 in ?? ()

Backtrace chỉ còn địa chỉ. Địa chỉ này vẫn có thể được ánh xạ về source nếu còn file có -g, bằng addr2line:

$ addr2line -e cg -f -i 0x115b
divide
/.../crash.c:5

(0x115b là offset trong file; với PIE cần trừ base address nạp, xem bài 4.)

Cách làm chuẩn cho release: giữ binary nhỏ trên target, cất file debug riêng ở host.

$ objcopy --only-keep-debug cg cg.debug       # tách DWARF ra file riêng
$ strip -o cs2 cg                             # binary release
$ objcopy --add-gnu-debuglink=cg.debug cs2    # ghi tên file debug + CRC vào binary
$ ls -l cg cs2 cg.debug | awk '{print $5,$9}'
17360 cg
14576 cs2
5992 cg.debug
$ gdb -q -batch -ex run -ex bt ./cs2          # gdb thấy cg.debug cạnh binary
#0  0x000055555555515b in divide (a=10, b=0) at crash.c:5
#1  0x0000555555555177 in main () at crash.c:10

Yocto và Buildroot làm đúng điều này: gói *-dbg chứa file debug, image chỉ có binary đã strip.

Cấp 3: phát hiện lỗi memory và UB

Compiler không thể thấy lỗi phụ thuộc dữ liệu lúc chạy. Sanitizer là code kiểm tra do compiler chèn vào:

bug.c
#include <stdlib.h>
int main(void)
{
    int *a = malloc(4 * sizeof(int));
    a[4] = 1;                     /* ghi vượt 1 phần tử */
    free(a);
    return 0;
}
$ gcc -g -fsanitize=address,undefined bug.c -o bug && ./bug
==1232==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020 ...
WRITE of size 4 at 0x502000000020 thread T0
    #0 0x55aba1d5b2ea in main bug.c:5
...
0x502000000020 is located 0 bytes after 16-byte region [0x502000000010,0x502000000020)
allocated by thread T0 here:
    #0 0x7f1b86afd9c7 in malloc ...
    #1 0x55aba1d5b25f in main bug.c:4

Báo cáo nêu rõ lỗi (heap overflow), dòng ghi sai (bug.c:5), và nơi vùng nhớ được cấp phát (bug.c:4).

Option Bắt được Chi phí
-fsanitize=address (ASan) overflow heap/stack/global, use-after-free, double free ~2x chậm, tốn memory
-fsanitize=undefined (UBSan) signed overflow, shift sai, null deref, ... thấp
-fsanitize=thread (TSan) data race 5-15x chậm
-fsanitize=leak (LSan) memory leak (đã có trong ASan trên Linux) thấp
$ gcc -g -fsanitize=undefined ub.c -o ub && ./ub     # x = INT_MAX; x += argc;
ub.c:3:54: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

Sanitizer và embedded

ASan cần runtime library (libasan) và nhiều memory, nên thường chỉ dùng được trên Linux target đủ mạnh hoặc chạy test trên host. Với MCU, hãy build code logic cho host rồi chạy unit test với sanitizer ở đó.

Valgrind làm việc tương tự mà không cần build lại (chạy binary dưới dạng một CPU ảo, chậm hơn nhiều):

$ gcc -g bug.c -o bugv && valgrind -q ./bugv
==1933== Invalid write of size 4
==1933==    at 0x10918B: main (bug.c:5)
==1933==  Address 0x4a7c050 is 0 bytes after a block of size 16 alloc'd

Phân tích nội dung binary

Các công cụ đã gặp ở các bài trước, gom theo mục đích:

Mục đích Lệnh
Header ELF, entry point readelf -h
Section và kích thước readelf -SW, size
Segment (cái loader sẽ map) readelf -lW
Library cần lúc chạy, RPATH readelf -d, ldd
Symbol, sắp theo kích thước nm -S --size-sort -t d
Disassemble kèm source objdump -dS (cần -g)
Mỗi lệnh thuộc dòng nào objdump -dl, addr2line
ABI/CPU của binary ARM readelf -A

Xem hàm nào lớn nhất:

$ nm -S --size-sort -t d b0 | tail -3        # cột 2 là kích thước (byte)
0000000000004473 0000000000000016 T used
0000000000004240 0000000000000038 T _start
0000000000004505 0000000000000050 T unused2

Map file

Linker có thể ghi lại chính xác nó đã ghép gì ở đâu:

$ gcc hello.c math.c -Wl,-Map=out.map -o hm
$ grep -nE "^\.text|^\.data|^\.bss" out.map
178:.text           0x0000000000001060      0x147
340:.data           0x0000000000004000       0x10
370:.bss            0x0000000000004010        0x8

Map file liệt kê mọi input section: file .o nào đóng góp bao nhiêu byte vào .text/.data/.bss, member nào của .a được lấy (đã dùng ở bài 3), và các section bị loại (Discarded input sections). Đây là công cụ chính để trả lời "vì sao firmware to thế này" ở embedded.

Giảm kích thước binary

Ba đòn bẩy, theo thứ tự nên thử:

1. Mức tối ưu

$ for o in O0 O2 Os; do gcc -$o hello.c math.c -o ho$o; size ho$o | awk 'NR==2{print "'$o'", $1}'; done
O0 1496
O2 1489
Os 1482

Với hello quá nhỏ nên khác biệt không đáng kể; với chương trình thật -Os thường nhỏ hơn -O2 đáng kể. Dùng -Os cho firmware bị giới hạn Flash.

2. Bỏ code không dùng: --gc-sections

Linker mặc định giữ nguyên cả .o, kể cả hàm không ai gọi (ví dụ unused1, unused2 trong big.c). Để loại bỏ từng hàm, cần hai việc: compiler đặt mỗi hàm/biến vào section riêng, và linker thu hồi section không được tham chiếu.

$ gcc -Os big.c -o b0
$ gcc -Os -ffunction-sections -fdata-sections -Wl,--gc-sections big.c -o b1
$ size b0 b1
   text    data     bss     dec     hex filename
   1711     608       8    2327     917 b0
   1385     592       8    1985     7c1 b1
$ nm b0 | grep unused
0000000000001189 T unused1
0000000000001199 T unused2
$ nm b1 | grep -c unused
0

Hai hàm không dùng đã biến mất khỏi b1. Trên ARM, static, cũng chương trình đó:

$ arm-linux-gnueabihf-gcc -Os -static big.c -o a0
$ arm-linux-gnueabihf-gcc -Os -static -ffunction-sections -fdata-sections -Wl,--gc-sections big.c -o a1
$ arm-linux-gnueabihf-size a0 a1
   text    data     bss     dec     hex filename
 320000   12836   10800  343636   53e54 a0
 318480   12812   10800  342092   5384c a1

Phần lớn kích thước ở đây là glibc chứ không phải code của ta; --gc-sections chỉ cắt được ~1.5 KB. Muốn nhỏ hơn thật sự thì đổi libc (musl, uClibc-ng, newlib) hoặc không dùng stdio/printf nặng. Linux kernel và U-Boot bật cả bộ option này trong build của chúng.

Mỗi file .c được compile riêng nên compiler không thấy code ở file khác. -flto giữ lại IR trong .o và để linker tối ưu lại toàn chương trình: inline xuyên file, loại hàm chết.

$ gcc -O2 l1.c l2.c -o l0      && objdump -d l0 | grep -c "call.*sq"
1
$ gcc -O2 -flto l1.c l2.c -o l1 && objdump -d l1 | grep -c "call.*sq"
0

Không còn lời gọi sq() ở bản LTO vì nó đã được inline từ file khác. Đổi lại thời gian link lâu hơn và debug khó hơn, nên LTO thường chỉ bật cho build release.

4. Strip

Bước cuối cùng, xoá symbol và debug (xem ở trên), thường giảm đáng kể với binary static (khoảng 20% ở ví dụ này):

$ arm-linux-gnueabihf-strip -o a1s a1
$ ls -l a0 a1 a1s | awk '{print $5,$9}'
423892 a0
417920 a1
332976 a1s

Tóm tắt

  • Debug theo cấp: warning (-Wall -Wextra) → -g + gdb → sanitizer/valgrind.
  • -g thêm DWARF (section không nạp vào memory); release nên tách file debug (objcopy --only-keep-debug + --add-gnu-debuglink) rồi strip.
  • Sanitizer (-fsanitize=address,undefined) cho báo cáo tại dòng gây lỗi, tốt nhất chạy trên host.
  • Muốn biết binary có gì: readelf, nm --size-sort, map file (-Wl,-Map).
  • Giảm kích thước: -Os → -ffunction-sections -fdata-sections -Wl,--gc-sections → LTO → strip; phần lớn nằm ở libc.

References