Có rất nhiều dự đoán sai (hơi hoặc hoàn toàn) trong các nhận xét về một số chi tiết / bối cảnh cho việc này.
Bạn đang xem triển khai dự phòng tối ưu hóa C được tối ưu hóa của glibc. (Đối với các ISA không có triển khai asm viết tay) . Hoặc một phiên bản cũ của mã đó, vẫn còn trong cây nguồn glibc. https://code.woboq.org/userspace/glibc/opes/strlen.c.html là một trình duyệt mã dựa trên cây git glibc hiện tại. Rõ ràng nó vẫn được sử dụng bởi một vài mục tiêu glibc chính thống, bao gồm MIPS. (Cảm ơn @zwol).
Trên các ISA phổ biến như x86 và ARM, glibc sử dụng mã asm viết tay
Vì vậy, khuyến khích thay đổi bất cứ điều gì về mã này thấp hơn bạn nghĩ.
Mã bithack này ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) không phải là những gì thực sự chạy trên máy chủ / máy tính để bàn / máy tính xách tay / điện thoại thông minh của bạn. Nó tốt hơn một vòng lặp byte ngây thơ, nhưng ngay cả bithack này cũng khá tệ so với asm hiệu quả cho các CPU hiện đại (đặc biệt là x86 trong đó AVX2 SIMD cho phép kiểm tra 32 byte với một vài hướng dẫn, cho phép 32 đến 64 byte mỗi đồng hồ chu kỳ trong vòng lặp chính nếu dữ liệu nóng trong bộ đệm L1d trên các CPU hiện đại với tải vectơ 2 / xung nhịp và thông lượng ALU. tức là đối với các chuỗi có kích thước trung bình trong đó chi phí khởi động không chiếm ưu thế.)
glibc sử dụng các thủ thuật liên kết động để phân giải strlen
thành phiên bản tối ưu cho CPU của bạn, do đó, ngay cả trong x86 cũng có phiên bản SSE2 (vectơ 16 byte, đường cơ sở cho x86-64) và phiên bản AVX2 (vectơ 32 byte).
x86 có khả năng truyền dữ liệu hiệu quả giữa các thanh ghi vectơ và mục đích chung, điều này giúp cho việc sử dụng SIMD duy nhất để tăng tốc các chức năng trên các chuỗi có độ dài ẩn trong đó điều khiển vòng lặp phụ thuộc vào dữ liệu. pcmpeqb
/ pmovmskb
làm cho nó có thể kiểm tra 16 byte riêng biệt cùng một lúc.
glibc có phiên bản AArch64 giống như sử dụng AdvSIMD và phiên bản dành cho CPU AArch64 trong đó vectơ-> GP đăng ký đường ống, do đó, nó thực sự sử dụng bithack này . Nhưng sử dụng các số 0 đứng đầu để tìm thanh ghi byte trong khi nó bị tấn công và tận dụng các truy cập không được phân bổ hiệu quả của AArch64 sau khi kiểm tra việc vượt qua trang.
Cũng liên quan: Tại sao mã này chậm hơn 6,5 lần với tối ưu hóa được bật? có thêm một số chi tiết về những gì nhanh so với chậm trong x86 asm strlen
với một bộ đệm lớn và việc triển khai asm đơn giản có thể tốt cho gcc để biết cách nội tuyến. (Một số phiên bản gcc không chính xác nội tuyến rep scasb
rất chậm hoặc bithack 4 byte một lần như thế này. Vì vậy, công thức nội tuyến strlen của GCC cần cập nhật hoặc vô hiệu hóa.)
Asm không có "hành vi không xác định" kiểu C ; Việc truy cập byte trong bộ nhớ theo cách bạn muốn là an toàn và tải được căn chỉnh bao gồm bất kỳ byte hợp lệ nào cũng không thể bị lỗi. Bảo vệ bộ nhớ xảy ra với độ chi tiết của trang được căn chỉnh; truy cập được căn chỉnh hẹp hơn mà không thể vượt qua một ranh giới trang. Có an toàn khi đọc qua phần cuối của bộ đệm trong cùng một trang trên x86 và x64 không? Lý do tương tự áp dụng cho mã máy mà bản hack C này có được trình biên dịch để tạo ra cho việc triển khai phi tuyến tính độc lập của chức năng này.
Khi một trình biên dịch phát ra mã để gọi một hàm không nội tuyến không xác định, nó phải giả sử rằng hàm đó sửa đổi bất kỳ / tất cả các biến toàn cục và bất kỳ bộ nhớ nào mà nó có thể có một con trỏ tới. tức là mọi thứ trừ người dân địa phương không có địa chỉ thoát đều phải được đồng bộ hóa trong bộ nhớ trong suốt cuộc gọi. Điều này áp dụng cho các chức năng được viết bằng asm, rõ ràng, nhưng cũng cho các chức năng thư viện. Nếu bạn không kích hoạt tối ưu hóa thời gian liên kết, nó thậm chí còn áp dụng cho các đơn vị dịch thuật riêng biệt (tệp nguồn).
Tại sao điều này là an toàn như là một phần của glibc nhưng không phải là khác.
Yếu tố quan trọng nhất là điều này strlen
không thể nội tuyến vào bất cứ điều gì khác. Nó không an toàn cho điều đó; nó chứa UB khử răng cưa nghiêm ngặt (đọc char
dữ liệu thông qua một unsigned long*
). char*
được phép bí danh bất cứ điều gì khác nhưng điều ngược lại là không đúng sự thật .
Đây là một chức năng thư viện cho một thư viện được biên dịch trước (glibc). Nó sẽ không được kết nối với tối ưu hóa thời gian liên kết vào người gọi. Điều này có nghĩa là nó chỉ phải biên dịch thành mã máy an toàn cho phiên bản độc lập strlen
. Nó không phải là di động / an toàn C.
Thư viện GNU C chỉ phải biên dịch với GCC. Rõ ràng nó không được hỗ trợ để biên dịch nó bằng tiếng kêu hoặc ICC, mặc dù chúng hỗ trợ các phần mở rộng GNU. GCC là trình biên dịch trước thời hạn biến tệp nguồn C thành tệp đối tượng của mã máy. Không phải là trình thông dịch, vì vậy trừ khi nó nội tuyến vào thời gian biên dịch, các byte trong bộ nhớ chỉ là các byte trong bộ nhớ. tức là UB răng cưa nghiêm ngặt không nguy hiểm khi các truy cập với các loại khác nhau xảy ra trong các chức năng khác nhau không liên kết với nhau.
Hãy nhớ rằng strlen
hành vi của nó được xác định bởi tiêu chuẩn ISO C. Tên chức năng cụ thể là một phần của việc thực hiện. Các trình biên dịch như GCC thậm chí coi tên là hàm tích hợp trừ khi bạn sử dụng -fno-builtin-strlen
, do đó strlen("foo")
có thể là hằng số thời gian biên dịch 3
. Định nghĩa trong thư viện chỉ được sử dụng khi gcc quyết định thực sự phát ra một cuộc gọi đến nó thay vì nội tuyến công thức riêng của mình hoặc một cái gì đó.
Khi UB không hiển thị với trình biên dịch tại thời điểm biên dịch, bạn sẽ nhận được mã máy lành mạnh. Mã máy phải hoạt động cho trường hợp không có UB và ngay cả khi bạn muốn , không có cách nào để asm phát hiện loại người gọi đã sử dụng để đưa dữ liệu vào bộ nhớ trỏ.
Glibc được biên dịch thành một thư viện tĩnh hoặc động độc lập không thể nội tuyến với tối ưu hóa thời gian liên kết. Các tập lệnh xây dựng của glibc không tạo các thư viện tĩnh "béo" chứa mã máy + gcc Biểu diễn bên trong GIMPLE để tối ưu hóa thời gian liên kết khi đưa vào chương trình. (tức là libc.a
sẽ không tham gia -flto
tối ưu hóa thời gian liên kết vào chương trình chính.) Xây dựng glibc theo cách đó sẽ có khả năng không an toàn trên các mục tiêu thực sự sử dụng điều này.c
.
Trong thực tế như bình luận @zwol, LTO không thể được sử dụng khi xây dựng glibc bản thân , vì "giòn" mã như thế này mà có thể phá vỡ nếu nội tuyến giữa file nguồn glibc là có thể. (Có một số sử dụng nội bộ strlen
, ví dụ có thể là một phần của việc printf
triển khai)
Điều này strlen
làm cho một số giả định:
CHAR_BIT
là bội số của 8 . Đúng trên tất cả các hệ thống GNU. POSIX 2001 thậm chí còn đảm bảo CHAR_BIT == 8
. (Điều này có vẻ an toàn cho các hệ thống có CHAR_BIT= 16
hoặc 32
, giống như một số DSP; vòng lặp prologue không được phân bổ sẽ luôn chạy 0 lần lặp nếu sizeof(long) = sizeof(char) = 1
vì mọi con trỏ luôn được căn chỉnh và p & sizeof(long)-1
luôn bằng không.) Nhưng nếu bạn có bộ ký tự không phải ASCII trong đó ký tự là 9 hoặc rộng 12 bit, 0x8080...
là mẫu sai.
- (có thể)
unsigned long
là 4 hoặc 8 byte. Hoặc có thể nó thực sự sẽ hoạt động với bất kỳ kích thước nào unsigned long
lên tới 8 và nó sử dụng một assert()
để kiểm tra điều đó.
Hai cái đó không thể là UB, chúng chỉ là không thể di chuyển được đối với một số triển khai C. Mã này là (hoặc là) một phần của việc triển khai C trên các nền tảng nơi nó hoạt động, vì vậy điều đó tốt.
Giả định tiếp theo là tiềm năng C UB:
- Tải được căn chỉnh có chứa bất kỳ byte hợp lệ nào cũng không thể bị lỗi và an toàn miễn là bạn bỏ qua các byte bên ngoài đối tượng bạn thực sự muốn. (Đúng như asm trên mọi hệ thống GNU và trên tất cả các CPU thông thường vì bảo vệ bộ nhớ xảy ra với độ chi tiết của trang được căn chỉnh. Có an toàn khi đọc qua phần cuối của bộ đệm trong cùng một trang trên x86 và x64? An toàn trong C khi UB không thể nhìn thấy tại thời gian biên dịch. Không có nội tuyến, đây là trường hợp ở đây. Trình biên dịch không thể chứng minh rằng đọc qua đầu tiên
0
là UB, ví dụ , nó có thể là một char[]
mảng C chứa {1,2,0,3}
)
Điểm cuối cùng đó là những gì làm cho nó an toàn khi đọc qua phần cuối của một đối tượng C ở đây. Điều đó khá an toàn ngay cả khi nội tuyến với các trình biên dịch hiện tại bởi vì tôi nghĩ rằng hiện tại họ không coi việc ngụ ý đường dẫn thực thi là không thể truy cập được. Nhưng dù sao, bí danh nghiêm ngặt đã là một showstopper nếu bạn để nội tuyến này.
Sau đó, bạn sẽ gặp các vấn đề như memcpy
macro CPP không an toàn cũ của nhân Linux đã sử dụng tính năng truyền con trỏ tới unsigned long
( gcc, bí danh nghiêm ngặt và các câu chuyện kinh dị ).
Điều này strlen
bắt nguồn từ thời đại mà bạn có thể thoát khỏi những thứ như thế nói chung ; nó được sử dụng khá an toàn mà không cần cảnh báo "chỉ khi không nội tuyến" trước GCC3.
UB chỉ hiển thị khi nhìn qua ranh giới cuộc gọi / giữ lại không thể làm tổn thương chúng tôi. (ví dụ: gọi điều này trên một char buf[]
thay vì trên một mảng của unsigned long[]
cast đến a const char*
). Khi mã máy được đặt thành đá, nó chỉ xử lý các byte trong bộ nhớ. Một cuộc gọi chức năng phi tuyến phải giả định rằng callee đọc bất kỳ / tất cả bộ nhớ.
Viết cái này một cách an toàn, không có răng cưa nghiêm ngặt
Các thuộc tính type GCCmay_alias
đưa ra một loại cùng điều trị alias-bất cứ điều gì như char*
. (Được đề xuất bởi @KonradBorowsk). Các tiêu đề GCC hiện đang sử dụng nó cho các loại vectơ SIMD x86 như __m128i
vậy để bạn luôn có thể thực hiện một cách an toàn _mm_loadu_si128( (__m128i*)foo )
. (Xem `` reinterpret_cast`ing giữa con trỏ vectơ phần cứng và loại tương ứng là hành vi không xác định? Để biết thêm chi tiết về ý nghĩa của việc này và không có nghĩa.)
strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
for (;;) {
unsigned long ulong = *longword_ptr++; // can safely alias anything
...
}
}
Bạn cũng có thể sử dụng aligned(1)
để thể hiện một loại với alignof(T) = 1
.
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
Một cách di động để thể hiện tải trọng răng cưa trong ISOmemcpy
, với các trình biên dịch hiện đại biết cách sắp xếp nội tuyến như một lệnh tải đơn. ví dụ
unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);
Điều này cũng hoạt động đối với các tải không được phân bổ bởi vì memcpy
hoạt động như là char
truy cập theo thời gian. Nhưng trong thực tế trình biên dịch hiện đại hiểu memcpy
rất rõ.
Điều nguy hiểm ở đây là nếu GCC không biết chắc chắn đó char_ptr
là liên kết từ, thì nó sẽ không nội tuyến trên một số nền tảng có thể không hỗ trợ tải không được phân bổ trong asm. ví dụ MIPS trước MIPS64r6 hoặc ARM cũ hơn. Nếu bạn nhận được một hàm gọi thực tế memcpy
chỉ để tải một từ (và để nó trong bộ nhớ khác), đó sẽ là một thảm họa. GCC đôi khi có thể nhìn thấy khi mã sắp xếp một con trỏ. Hoặc sau vòng lặp char-at-a-time đạt đến ranh giới ulong bạn có thể sử dụng
p = __builtin_assume_aligned(p, sizeof(unsigned long));
Điều này không tránh được UB đọc quá khứ có thể, nhưng với GCC hiện tại không nguy hiểm trong thực tế.
Tại sao nguồn C được tối ưu hóa bằng tay là cần thiết: trình biên dịch hiện tại không đủ tốt
Asm được tối ưu hóa bằng tay có thể còn tốt hơn nữa khi bạn muốn mỗi lần giảm hiệu suất cuối cùng cho một chức năng thư viện tiêu chuẩn được sử dụng rộng rãi. Đặc biệt là cho một cái gì đó như memcpy
, nhưng cũng có strlen
. Trong trường hợp này, việc sử dụng C với nội tại x86 sẽ dễ dàng hơn nhiều để tận dụng lợi thế của SSE2.
Nhưng ở đây, chúng ta chỉ nói về một phiên bản C ngây thơ so với bithack C mà không có bất kỳ tính năng cụ thể nào của ISA.
(Tôi nghĩ rằng chúng tôi có thể mang nó như một cho rằng strlen
là rộng rãi đủ sử dụng mà làm cho nó chạy càng nhanh càng tốt là rất quan trọng. Vì vậy, câu hỏi trở nên cho dù chúng ta có thể lấy mã máy hiệu quả từ nguồn đơn giản hơn. Không, chúng tôi không thể.)
GCC và clang hiện tại không có khả năng tự động véc tơ hóa trong đó số lần lặp không được biết trước lần lặp đầu tiên . (ví dụ: phải kiểm tra xem vòng lặp có chạy ít nhất 16 lần lặp trước khi chạy lần lặp đầu tiên không.) ví dụ: tự động ghi nhớ memcpy là có thể (bộ đệm có độ dài rõ ràng) nhưng không phải là strcpy hoặc strlen (chuỗi có độ dài ẩn) trình biên dịch.
Điều đó bao gồm các vòng tìm kiếm hoặc bất kỳ vòng lặp nào khác có phụ thuộc dữ liệu if()break
cũng như bộ đếm.
ICC (trình biên dịch của Intel cho x86) có thể tự động vectơ hóa một số vòng lặp tìm kiếm, nhưng vẫn chỉ tạo ra một byte tạm thời ngây thơ cho một C đơn giản / ngây thơ strlen
như sử dụng libc của OpenBSD. ( Thần thánh ). (Từ câu trả lời của @ Peske ).
Một libc strlen
được tối ưu hóa bằng tay là cần thiết để thực hiện với các trình biên dịch hiện tại . Sử dụng 1 byte mỗi lần (với việc không kiểm soát có thể 2 byte mỗi chu kỳ trên các CPU siêu phẳng rộng) là thảm hại khi bộ nhớ chính có thể theo kịp khoảng 8 byte mỗi chu kỳ và bộ đệm L1d có thể cung cấp 16 đến 64 mỗi chu kỳ. (2x tải 32 byte mỗi chu kỳ trên CPU x86 chính hiện đại kể từ Haswell và Ryzen. Không tính AVX512 có thể giảm tốc độ xung nhịp chỉ bằng cách sử dụng vectơ 512 bit, đó là lý do tại sao glibc có thể không vội vàng thêm phiên bản AVX512 . Mặc dù với các vectơ 256 bit, AVX512VL + BW bị che khuất so sánh với mặt nạ và ktest
hoặc kortest
có thể làm cho việc strlen
siêu phân luồng trở nên thân thiện hơn bằng cách giảm các lần lặp / lặp của nó.)
Tôi bao gồm cả không phải x86 ở đây, đó là "16 byte". ví dụ, hầu hết các CPU AArch64 có thể làm ít nhất là điều đó, tôi nghĩ, và một số chắc chắn là nhiều hơn thế. Và một số có đủ thông lượng thực hiện strlen
để theo kịp băng thông tải đó.
Tất nhiên các chương trình hoạt động với các chuỗi lớn thường phải theo dõi độ dài để tránh phải làm lại việc tìm độ dài của chuỗi C có độ dài ẩn rất thường xuyên. Nhưng hiệu suất ngắn đến trung bình vẫn có lợi từ việc triển khai viết tay và tôi chắc chắn rằng một số chương trình cuối cùng sử dụng strlen trên chuỗi có độ dài trung bình.