Trình biên dịch cho lập trình viên

Đo tận mắt điều trình biên dịch làm với mã nguồn C của bạn: từ tiền xử lý, tối ưu, sinh assembly, tới liên kết và nạp — mọi thứ chạy thật trong container.

45/45 phần đã đăng Trình biên dịch
1 8 dòng nở thành 750, và printf lặng lẽ biến thành puts — bốn giai đoạn sau một lệnh gcc Một lệnh gcc chạy qua bốn bước: tiền xử lý, biên dịch, hợp dịch, liên kết. Đo từng bước, và đọc assembly mới thấy trình dịch lặng lẽ đổi printf thành puts. Đo thật trong container. 03/09/2026 · 8 phút đọc 2 SQ(i++) làm i tăng hai lần, SQ(1+2) ra 5 chứ không phải 9 — vì macro chỉ là dán văn bản Macro trông như hàm nhưng chỉ là dán văn bản: SQ(i++) khiến i tăng hai lần, SQ_BAD(1+2) ra 5 chứ không phải 9. Và một #include kéo theo 37 file. Đo thật bằng gcc -E trong container. 03/09/2026 · 8 phút đọc 3 Có include guard rồi mà một header vẫn biến mất không dấu vết — vì nó khóa theo tên, không theo file Include guard chống được include lặp, nhưng nó khóa theo TÊN macro chứ không theo file — hai header trùng tên guard làm một cái biến mất im lặng. Đo thật bằng gcc -E trong container. 03/09/2026 · 8 phút đọc 4 Hai file bất đồng kiểu một biến, cả trình dịch lẫn linker đều không bắt — và hai số 42 trùng nhau suýt lừa tôi Mỗi .c dịch một mình, không thấy các file khác; linker khớp theo tên chứ không theo kiểu. Một lệch kiểu xuyên file lọt lưới cả trình dịch lẫn linker — chỉ -flto bắt được. Đo thật trong container. 03/09/2026 · 9 phút đọc 5 Thiếu định nghĩa dịch vẫn xong — chỉ hỏng lúc link; và extern kèm khởi tạo lén thành định nghĩa Khai báo đủ để dịch một file, định nghĩa mới đủ để liên kết — nên thiếu định nghĩa chỉ hỏng lúc link, không phải lúc compile. Và extern kèm khởi tạo lại là định nghĩa, gây multiple definition. Đo thật trong container. 03/09/2026 · 8 phút đọc 6 Benchmark báo -O2 nhanh 490 lần — nhưng đó là công việc bị xóa sạch, không phải tăng tốc Cú nhảy tốc độ lớn nhất là -O0 sang -O1 (4,2x), sau đó thường phẳng. Và một benchmark không dùng kết quả cho -O2 nhanh 490 lần — nhưng đó là công việc bị xóa, không phải tăng tốc. Đo thật trong container. 03/09/2026 · 8 phút đọc 7 Nội tuyến làm hàm nhỏ nhanh 2,46 lần — và một lệnh grep cẩu thả suýt làm tôi báo cáo sai Nội tuyến dán thân hàm vào chỗ gọi, bỏ lời gọi và cho tối ưu xuyên ranh giới — hàm nhỏ nhanh 2,46 lần. Nhưng từ khóa inline chỉ là gợi ý, và một grep cẩu thả suýt làm tôi báo sai. Đo thật trong container. 03/09/2026 · 8 phút đọc 8 Tôi viết memset để xóa mật khẩu — trình biên dịch xóa luôn cái memset, bí mật còn nguyên trong RAM Trình dịch xóa mọi mã không có tác dụng quan sát được — kể cả memset(pw,0,n) bạn viết để xóa mật khẩu, vì buffer không đọc lại sau đó. Bí mật còn nguyên trên stack. Đo thật bằng assembly, và cách xóa an toàn thật. 03/09/2026 · 8 phút đọc 9 Trình biên dịch tính sẵn 60*60*24 thành 86400 — nhưng nó cũng nướng luôn cả bug tràn số của bạn 60*60*24 được tính sẵn thành 86400 lúc dịch, không nhân lúc chạy. Nhưng trình dịch gấp bằng đúng số học của chương trình: 1000000*1000000 ra -727379968, và 0.1+0.2 không bằng 0.3. Đo thật trong container. 03/09/2026 · 8 phút đọc 10 Mở vòng lặp nhanh 3,5 lần — nhưng không phải nhờ bớt nhánh như sách vở nói -funroll-loops làm vòng lặp nhanh 3,5 lần — nhưng không phải nhờ bớt nhánh như sách vở nói. Đọc assembly thấy nó phá chuỗi phụ thuộc bằng 4 ô tích lũy song song. Đo thật trong container. 03/09/2026 · 8 phút đọc 11 Vector hóa SIMD -O3 tự vector hóa vòng cộng dồn int nhưng TỪ CHỐI cộng dồn float — vì đổi thứ tự cộng float đổi kết quả IEEE. Bật -ffast-math thì nhanh 4.3 lần. Đo thật bằng lệnh NEON trong assembly. 03/09/2026 · 7 phút đọc 12 objdump báo 'bl 0' — tôi tưởng gọi địa chỉ 0 sẽ crash, hóa ra đó là chỗ trống chờ linker objdump -d tháo mã máy thật ra ba cột: địa chỉ, byte, lệnh. Nhưng tháo một .o chưa liên kết thấy 'bl 0 <puts>' — số 0 là chỗ trống chờ trình liên kết điền, không phải địa chỉ thật. Đo thật trong container. 03/09/2026 · 8 phút đọc 13 Đối số thứ 3 lại nằm ở thanh ghi x1 chứ không phải x2 — trực giác 'đếm theo vị trí' sai hoàn toàn Đối số thứ 3 trong f(int a, double b, int c) nằm ở thanh ghi x1 chứ không phải x2 — vì int và float dùng hai dải thanh ghi riêng, đếm độc lập. Đo thật bằng assembly aarch64 trong container. 03/09/2026 · 8 phút đọc 14 -fomit-frame-pointer không bỏ khung ngăn xếp — tôi đọc sai cái tên, prologue nói thật -fomit-frame-pointer không bỏ khung ngăn xếp — nó chỉ bỏ con trỏ khung x29, tiết kiệm một lệnh và giải phóng một thanh ghi. Khung vẫn được cấp. Đo thật bằng prologue/epilogue trong assembly. 03/09/2026 · 8 phút đọc 15 Tràn thanh ghi làm chậm 12 lần — tôi suýt tin, rồi nhận ra mình đổi hai biến cùng lúc Khi số giá trị sống cùng lúc vượt số thanh ghi, trình dịch phải đổ bớt ra stack (spill). Nhưng áp lực đến từ giá trị sống đồng thời, không phải số biến khai báo — 40 biến dùng tuần tự vẫn 0 tràn. Và chi phí tràn thật, khi cô lập đúng, chỉ ~3%. Đo thật trong container. 03/09/2026 · 8 phút đọc 16 Gọi một hàm C từ C++ mà không liên kết được — nm phơi ra thủ phạm trong một dòng nm liệt kê ký hiệu với mã loại T/D/U. C dùng tên thô; C++ nhét chữ ký vào tên (mangling) để cho phép nạp chồng — nên gọi hàm C từ C++ mà quên extern C thì không liên kết được. Đo thật trong container. 03/09/2026 · 8 phút đọc 17 Liên kết tĩnh cho file to gấp 12 lần — và khởi động chậm hơn 1,75 lần, ngược hẳn câu tôi vẫn tin Liên kết tĩnh cho file to gấp 12 lần — và khởi động CHẬM hơn 1,75 lần, ngược với câu 'tĩnh nhanh hơn'. Vì bản tĩnh phải nạp 522KB mã còn bản động dùng libc đã thường trú. Đo thật trong container. 03/09/2026 · 8 phút đọc 18 Hàm sqrt được liên kết vào chương trình nhưng không bao giờ được phân giải — vì tôi chưa gọi nó ld.so không phân giải mọi ký hiệu lúc khởi động — lazy binding chỉ phân giải một hàm khi nó được gọi lần đầu, và hàm không gọi thì không bao giờ được đụng tới. Đo thật bằng PLT/GOT và LD_DEBUG trong container. 03/09/2026 · 8 phút đọc 19 Đảo chỗ hai file trên dòng lệnh, chương trình từ dịch được thành 'undefined reference' Đảo chỗ -lfoo với file .o có thể biến một chương trình dịch được thành 'undefined reference' — nhưng chỉ với thư viện tĩnh .a, không phải .so. Đo thật và giải thích cơ chế rút thành viên một lượt trong container. 03/09/2026 · 8 phút đọc 20 Một mảng 4MB toàn số 0 miễn phí trên đĩa — thêm đúng một số 1, file phình 4MB int arr[1000000]={0} không tốn byte nào trong file (nằm .bss), nhưng ={1} tốn đúng 4MB (nhảy sang .data). Một phần tử khác 0 kéo cả mảng từ miễn phí sang tốn đĩa. Đo bằng size và nm trong container. 03/09/2026 · 8 phút đọc 21 Khai mảng toàn cục 400MB: file thực thi vẫn 70KB, và lúc chạy chỉ tốn 1MB RAM thật Một mảng toàn cục 400MB làm file thực thi nhỏ như cũ (nằm .bss) — và bất ngờ hơn, lúc chạy nó chỉ tốn 1MB RAM thật cho tới khi bạn chạm vào từng trang. Kích thước file, VSZ và RSS là ba con số khác nhau. Đo thật trong container. 03/09/2026 · 8 phút đọc 22 Cùng một dòng ép con trỏ: -O0 ra 1073741824, -O2 ra 1 — và cách đúng lại miễn phí Một đoạn type-pun bằng ép con trỏ cho 1073741824 ở -O0 nhưng 1 ở -O2 — vì -O2 giả định int* và float* không trỏ cùng ô. Đó là hành vi không xác định. Cách đúng là memcpy, và gcc gấp nó thành một lệnh. Đo thật trong container. 03/09/2026 · 8 phút đọc 23 Tôi viết đoạn check để bắt tràn — trình biên dịch xóa sạch nó vì 'tràn không được phép xảy ra' Một đoạn kiểm tra tràn if(x+100 < x) bị -O2 xóa sạch — vì trình dịch suy tràn signed là UB nên 'không bao giờ xảy ra'. Cái check để bắt bug lại bị compile đi mất, và -fwrapv cứu. Đo thật trong container. 03/09/2026 · 8 phút đọc 24 next() + next() ra 21 mọi lần chạy — nhưng con số ổn định đó là một cái bẫy next()+next() cho 21 trên gcc/aarch64, nhất quán mọi lần — nhưng thứ tự đó không được chuẩn bảo đảm, và i=i++ + ++i là UB. Nhất quán trên một máy không phải 'được định nghĩa'. Đo thật trong container. 03/09/2026 · 8 phút đọc 25 Vòng while(!done) treo vĩnh viễn ở -O2 — volatile cứu được, nhưng đừng tưởng nó làm biến an toàn đa luồng Một vòng while(!done) treo vô hạn ở -O2 vì trình dịch đọc done một lần rồi cache; volatile ép đọc lại mỗi vòng. Nhưng volatile chỉ đảm bảo truy cập xảy ra, không phải nguyên tử hay thread-safe. Đo bằng assembly trong container. 03/09/2026 · 8 phút đọc 26 Một từ khóa cắt vòng lặp còn 4 lệnh thay vì 7 — nhưng phá lời hứa thì kết quả ra 15 thay vì 20 restrict hứa hai con trỏ không trùng nhau, cho trình dịch bỏ một lần nạp lại — 4 lệnh thay vì 7. Nhưng đó là lời hứa: truyền con trỏ trùng thì kết quả sai (15 thay vì 20), là hành vi không xác định. Đo bằng assembly trong container. 03/09/2026 · 8 phút đọc 27 Năm trường cộng lại 15 byte, struct chiếm 32 — đổi thứ tự khai, còn một nửa sizeof một struct không phải tổng kích thước các trường: struct trộn kiểu cộng lại 15 byte mà sizeof=32 vì đệm căn lề. Sắp lại đúng năm trường đó theo lớn->nhỏ còn 16. Đo sizeof và offsetof thật trong container. 03/09/2026 · 8 phút đọc 28 Tám cờ một bit, tôi chắc chiếm 1 byte — đo ra 4, và bố cục bit là cái bẫy còn to hơn Struct 8 cờ 1-bit tôi tưởng chiếm 1 byte, đo ra 4 — vì kiểu cơ sở là int. Và thứ tự bit trong đơn vị là tùy trình dịch, nên đừng dùng trường bit để khớp bố cục nhị phân ngoài. Đo sizeof, byte thô và assembly trong container. 03/09/2026 · 11 phút đọc 29 Trình biên dịch gộp phép tính lặp từ -O1 — nhưng một phép ghi qua con trỏ đủ để nó bó tay Trình biên dịch tính (a*b) một lần cho cả hai chỗ dùng, ngay từ -O1. Nhưng một phép ghi qua con trỏ có thể trùng buộc nó phải tính lại — và restrict gỡ đúng chỗ đó. Đo bằng assembly thật, kèm một lần công cụ đếm của tôi nói dối. 03/09/2026 · 9 phút đọc 30 Thêm một cờ, vòng lặp nhanh 2,9 lần — nhưng tôi suýt kể sai lý do Thêm -flto làm một vòng lặp gọi hàm xuyên file nhanh 2,9 lần. Tôi tưởng nhờ bỏ phí gọi hàm — đọc assembly mới thấy cái lợi thật là vector hóa được mở khóa. Đo thật, đọc objdump trong container. 03/09/2026 · 10 phút đọc 31 PGO nhanh hơn -O2 rõ rệt — cho tới khi tôi thấy hồ sơ chưa từng được nạp PGO trông nhanh hơn -O2 hẳn 1,3 lần — cho tới khi tôi nhận ra file hồ sơ chưa từng được đọc, và cái nhanh thật ra đến từ mấy cờ mà -fprofile-use tự bật. Đo thật, và một bài học về đo nhầm đại lượng. 03/09/2026 · 9 phút đọc 32 Tôi gắn UNLIKELY để tăng tốc — nó chẳng đổi gì, còn gắn nhầm hướng thì chậm gần gấp đôi Thêm __builtin_expect(UNLIKELY) vào nhánh hiếm, tôi mong nhanh hơn. Đo ra bằng y hệt bản không hint — vì trình dịch đã đoán đúng sẵn. Cái duy nhất đổi thời gian lại là một hint SAI. Đo thật, đọc assembly trong container. 03/09/2026 · 8 phút đọc 33 Một dòng khai báo biến vòng lặp 371 ms thành 0 ms — nhưng đánh nhầm nó thì kết quả sai âm thầm Gắn ((const)) cho một hàm bất biến biến vòng lặp 371 ms thành 0 ms vì trình dịch kéo lời gọi ra ngoài. Nhưng đánh nhầm const cho hàm đọc bộ nhớ khiến nó trả về giá trị cũ, không báo lỗi. Đo cả tốc độ lẫn tính đúng trong container. 03/09/2026 · 10 phút đọc 34 Cùng một hàm đệ quy: -O0 tràn ngăn xếp và chết, -O2 chạy gọn 50 triệu tầng Một hàm đệ quy đuôi tràn ngăn xếp ở -O0 nhưng chạy 50 triệu tầng ở -O2. Và gcc còn khử được cả 'n + f(n-1)' bằng cách tự thêm biến tích lũy — nhiều hơn tôi tưởng. Nhưng TCO là tối ưu, không phải bảo đảm của C. Đo thật trong container. 03/09/2026 · 9 phút đọc 35 Tôi dựng bẫy để bắt fast-math làm sai — hóa ra bản IEEE strict mới sai nặng Định bắt quả tang -ffast-math làm sai số. Đo ra ngược hẳn: bản IEEE strict kẹt ở 2^24 (16777216) sai nặng, fast-math gần đúng hơn và nhanh 4 lần. Nhưng nó âm thầm xóa lá chắn NaN. Đo thật, đọc assembly trong container. 03/09/2026 · 9 phút đọc 36 Đoạn kiểm tra tràn của tôi báo 'an toàn' đúng lúc số đã âm — và nó chết ngay ở -O0 Kiểm tra tràn signed 'x+100 < x' bị trình dịch xóa thành return 0 — ngay ở -O0, không đợi -O2 như tôi tưởng. Còn unsigned thì quấn vòng đoán được, giữ nguyên phép so sánh. Đọc assembly thật để thấy vì sao bạn không được phát hiện tràn bằng chính phép tràn. 03/09/2026 · 8 phút đọc 37 Tôi viết SIMD bằng tay để thắng trình biên dịch — nó ra bằng y hệt, cùng một lệnh vector Viết tay SIMD bằng intrinsic để mong thắng trình dịch. Với phép cộng từng phần tử, nó bằng đúng bản để -O3 tự lo — cùng một lệnh fadd. Intrinsic chỉ thắng ở chỗ trình dịch từ chối vector hóa. Đo thật, đọc objdump trong container. 03/09/2026 · 8 phút đọc 38 Bộ đo thời gian viết bằng asm luôn báo 0 — thiếu một từ khóa, cả hàm sụp thành return 0 Một routine đọc bộ đếm chu kỳ hai lần rồi tính hiệu, viết bằng asm không volatile, luôn trả 0 — trình dịch gộp hai lần đọc rồi gấp thành return 0. Với nó, asm non-volatile là hàm thuần. Đọc objdump trong container để thấy tận mắt. 03/09/2026 · 8 phút đọc 39 Đánh dấu hàm ((hot)), đo ra 38,33 so 38,35 ms — tôi suýt kết 'vô dụng', rồi readelf lên tiếng Gắn ((hot)) rồi đo vòng lặp: 38,33 so 38,35 ms, bằng nhau. Nhưng readelf cho thấy hàm đã nhảy sang section .text.hot đúng như thiết kế. Lợi ích là cache lệnh, chỉ lộ ở quy mô lớn — và bài học về đo đúng đại lượng. 03/09/2026 · 8 phút đọc 40 Tôi chắc switch dày đặc là bảng nhảy O(1) — đọc assembly bốn ví dụ, không cái nào là bảng nhảy Niềm tin sách giáo khoa: switch dày đặc biên dịch thành jump table. Tôi đo bốn ví dụ dày đặc — gcc gấp thành công thức, thành bảng tra cứu, thành chuỗi so sánh. Bảng nhảy thật chỉ hiện ở 20 case. Đọc objdump trong container. 03/09/2026 · 8 phút đọc 41 Ép nội tuyến một hàm to: mã phình 14 lần, tốc độ tăng đúng 1% — ngưỡng của gcc là bạn, không phải thù Hàm to gọi từ một chỗ vẫn được nội tuyến bất kể lớn; gọi từ nhiều chỗ thì gcc giữ lời gọi. Tôi ép always_inline vượt ngưỡng — đổi 14 lần kích thước mã lấy vỏn vẹn 1% tốc độ. Đo bằng objdump thật trong container. 03/09/2026 · 8 phút đọc 42 Tôi chắc -Wall sẽ bắt lỗi nguy hiểm nhất — nó im re, và lý do nằm ngay trong tên cảnh báo -Wall -Wextra bắt gọn bốn lỗi mà mã vẫn dịch trơn, exit 0. Nhưng lỗi dùng biến chưa khởi tạo — thứ nguy hiểm nhất — lọt qua cả -O0 lẫn -O2, kể cả khi bật thẳng -Wmaybe-uninitialized. Đo thật trong container, và bài học về giới hạn của phân tích tĩnh. 03/09/2026 · 8 phút đọc 43 Lỗi ghi lố mảng chạy trơn, exit 0 — rồi ASan hét lên, nhưng suýt nữa cũng im lặng vì một lý do bất ngờ AddressSanitizer bắt cái lỗi ghi ngoài mảng mà -Wall bỏ lọt và chạy thường không sập. Nhưng tôi tưởng nó bắt ngay — ở -O1 trình dịch gấp lỗi đi, chẳng có truy cập thật để mà bắt. Đo thật trong container. 03/09/2026 · 8 phút đọc 44 Cùng một dòng C, một máy ra 200 còn máy kia ra -56 — biên dịch chéo phơi ra điều tôi tưởng không thể Tôi tưởng biên dịch chéo chỉ đổi lệnh, không đổi kết quả. Rồi from_char(200) trả 200 trên aarch64 nhưng -56 trên x86-64 — vì dấu của char do kiến trúc quy định, không phải ngôn ngữ. Đo thật trên hai đích, đọc cả hai bản assembly. 03/09/2026 · 8 phút đọc 45 clang thắng ba vòng lặp, tôi định khoe — rồi đọc assembly thấy mình sai cả lý do Đo sáu vòng lặp bằng gcc và clang: clang nhanh hơn 2-4 lần ở vài vòng, hòa ở số khác. Tôi định kết 'clang nhanh hơn' và giải thích nó thắng popcount nhờ lệnh cnt. Đọc mã máy ra ngược hẳn — gcc mới dùng cnt mà vẫn chậm. Bài cuối sê-ri, và cái bẫy tôi rơi vào cả hai chân. 03/09/2026 · 8 phút đọc