Bạn dùng chuỗi "error: not found" ở mười chỗ trong chương trình. Có mười bản của chuỗi đó trong binary không? Trực giác nói mỗi lần viết literal là một vùng nhớ mới. Nhưng compiler và trình liên kết làm một việc tiết kiệm: gộp các hằng giống nhau thành một bản duy nhất trong .rodata (vùng dữ liệu chỉ đọc). Tôi vào đo cái gộp đó, và thấy nó vừa tiết kiệm thật, vừa để lộ một cạm bẫy so sánh con trỏ mà nhiều người vấp.
Gộp literal: một bản cho mọi lần dùng
Khi hai chuỗi literal giống hệt nhau xuất hiện trong mã, compiler đặt một bản trong .rodata và cho mọi chỗ dùng trỏ tới cùng địa chỉ đó — gọi là string pooling. Tương tự với hằng số và mảng hằng trùng nhau. Có cả kỹ thuật tail merging (gối hậu tố): nếu một chuỗi là hậu tố của chuỗi khác — "world" nằm cuối "hello world" — chúng có thể chia sẻ phần đuôi, tùy cờ.
Tôi kiểm trong container gcc:13 (ARM AArch64). Hai hàm f1 và f2 cùng trả về literal "hello-world-constant". strings trên binary cho thấy chỉ một bản của chuỗi đó — compiler đã gộp. Và điều thú vị hiện ra khi so con trỏ:
f1() == f2() -> BẰNG NHAU (cùng địa chỉ 0x400758)
Hai con trỏ tới hai literal viết riêng ở hai hàm lại bằng nhau, vì chúng trỏ tới cùng một bản đã gộp. Đây là đo hớ của tôi: tôi tưởng "mỗi literal là một vùng nhớ riêng, so con trỏ literal không đoán được / mỗi lần một địa chỉ khác". Đo ra ngược — compiler gộp chúng thành một, con trỏ bằng nhau, và .rodata tiết kiệm thật khi một chuỗi lặp nhiều nơi.
Tôi cũng kiểm tail merging: chuỗi "world-constant" (là hậu tố của "hello-world-constant") không được gối vào bản kia ở -O2 mặc định — nó có địa chỉ riêng. Tail merging cần điều kiện/cờ mạnh hơn, không phải mặc định. Và một bất ngờ nữa: -fno-merge-constants (được kỳ vọng tắt gộp) không tắt được string pooling — con trỏ vẫn bằng nhau, .rodata vẫn một bản. Cờ đó điều khiển việc gộp hằng số/mảng khác, còn gộp chuỗi literal là cơ chế riêng, gần như luôn bật.
Cạm bẫy: đừng dựa vào con trỏ literal
Cái đo hớ vui (con trỏ bằng nhau) dẫn thẳng tới một cạm bẫy nghiêm túc. Vì "abc" == "abc" cho ra true trên gcc này, một người có thể tưởng nhầm rằng so sánh con trỏ literal là cách kiểm tra chuỗi. Đừng. Chuẩn C nói rõ việc hai literal giống nhau có chung địa chỉ hay không là không xác định (unspecified) — trình biên dịch được phép gộp hoặc không. Nghĩa là:
- Trên gcc này, hôm nay,
"abc" == "abc"có thể làtrue. - Trên một compiler khác, một mức tối ưu khác, hay với hai chuỗi ở hai đơn vị dịch chưa được linker gộp, nó có thể là
false.
Dựa vào == để so chuỗi là viết một lỗi ẩn — chạy đúng ở máy bạn, sai ở nơi khác. Cách đúng luôn là so nội dung bằng strcmp (hoặc so từng ký tự), không so địa chỉ. Đây là một trong những hành vi không xác định/không đặc tả mà một tối ưu (gộp) vô tình làm cho trông như hoạt động, khiến lỗi càng khó phát hiện.
Không chỉ chuỗi: hằng và mảng cũng gộp
String pooling là ca dễ thấy nhất, nhưng cơ chế gộp rộng hơn. Hai mảng hằng giống nhau — ví dụ hai bảng tra static const int T[256] = {...} trùng nội dung ở hai file — có thể được linker gộp thành một khi bật -fmerge-constants (mặc định) hoặc -fmerge-all-constants. Hằng số dấu phẩy động lặp lại (như 3.14159 dùng nhiều nơi) cũng vào chung một ô .rodata. Với chương trình có nhiều bảng tra hay hằng lặp, khoản tiết kiệm này cộng dồn đáng kể — và nó liên quan tới cùng nguyên lý khử cái trùng lặp mà compiler áp lên mã.
Lưu ý -fmerge-all-constants mạnh tay hơn: nó gộp cả những hằng mà chuẩn C đòi phải có địa chỉ riêng biệt (hai mảng const khác tên nhưng cùng giá trị), nên nó thực ra vi phạm chuẩn và gcc không bật mặc định — một lời nhắc nữa rằng gộp là con dao hai lưỡi: tiết kiệm kích thước, nhưng có thể phá vỡ giả định về tính riêng biệt của địa chỉ nếu bạn đẩy quá xa.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: gộp hằng là một tối ưu kích thước có thật, hưởng miễn phí. Bạn cứ viết cùng một chuỗi thông báo ở nhiều chỗ cho rõ nghĩa; compiler gộp chúng thành một bản, không phình .rodata. Không cần tự tay tạo một hằng static const char* MSG = ... để "tránh lặp" vì lý do kích thước — trình biên dịch đã lo (dù đặt tên hằng vẫn tốt cho bảo trì, tránh gõ sai). Đây cùng họ với các tối ưu kích thước khác: compiler bỏ cái trùng lặp giúp bạn.
Hệ quả thứ hai, và là bài học an toàn: không bao giờ so sánh chuỗi bằng con trỏ. if(s == "done") là một bug chờ nổ — nó so địa chỉ, không so nội dung, và chỉ tình cờ đúng khi s trỏ vào cùng literal đã gộp. Dùng strcmp(s, "done") == 0. Quy tắc này áp cho mọi ngôn ngữ có gộp literal (Java intern, Python cache chuỗi nhỏ): con trỏ/định danh bằng nhau ngụ ý nội dung bằng nhau, nhưng nội dung bằng nhau không ngụ ý con trỏ bằng nhau — nên chỉ so nội dung mới đúng.
Hệ quả thứ ba là bài học đo lường: một tối ưu có thể làm code sai trông như đúng, và đó là loại bug nguy hiểm nhất. Gộp literal khiến "abc"=="abc" ra true, che giấu việc so-con-trỏ-chuỗi là sai nguyên tắc. Chỉ khi đo trên môi trường khác (hoặc đọc chuẩn) bạn mới thấy nó không đảm bảo. Con số mang theo: compiler gộp các literal/hằng trùng thành một bản trong .rodata (strings đếm chỉ 1 cho chuỗi lặp; hai con trỏ literal giống nhau ra == cùng địa chỉ 0x400758) — tiết kiệm kích thước thật; nhưng chuẩn C KHÔNG bảo đảm việc gộp (unspecified), nên ĐỪNG so sánh con trỏ literal (có thể true ở gcc này, false ở nơi khác), luôn dùng strcmp so nội dung; -fno-merge-constants còn không tắt nổi string pooling vì nó điều khiển hằng số/mảng khác. Hưởng cái lợi kích thước, nhưng đừng để cái gộp lừa bạn viết so sánh sai.
Thử ba mươi giây
Viết hai hàm cùng trả về một literal giống hệt: const char* a(){ return "xyz"; } và const char* b(){ return "xyz"; }. Trong main, in a() == b() (so con trỏ) — trên gcc/clang bạn sẽ thấy true, vì chúng đã gộp. Chạy strings ./a.out | grep xyz để đếm số bản: chỉ một. Rồi thử điều nguy hiểm: char buf[4]; strcpy(buf,"xyz"); printf("%d\n", buf == "xyz"); — lần này false, vì buf là mảng riêng chứ không phải literal đã gộp, dù nội dung y hệt. Ba mươi giây đó cho bạn thấy vì sao so chuỗi bằng == là bẫy: nó phụ thuộc con trỏ có trùng không, không phải nội dung — và chỉ strcmp mới trả lời đúng câu hỏi bạn thật sự hỏi.