为什么glibc's strlen需要如此复杂才能快速运行?

我正在查看strlen代码在这里,我想知道在代码中使用的优化是否真的需要?例如,为什么像下面这样的工作不一样好或更好?

unsigned long strlen(char s[]) {
unsigned long i;
for (i = 0; s[i] != '\0'; i++)
continue;
return i;
}

更简单的代码是不是更好和/或更容易编译器优化?

在链接后面的页面上strlen的代码看起来像这样:

/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc.
This file is part of the GNU C Library.
Written by Torbjorn Granlund (tege@sics.se),
with help from Dan Sahlin (dan@sics.se);
commentary by Jim Blandy (jimb@ai.mit.edu).


The GNU C Library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.


The GNU C Library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
Lesser General Public License for more details.


You should have received a copy of the GNU Lesser General Public
License along with the GNU C Library; if not, write to the Free
Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
02111-1307 USA.  */


#include <string.h>
#include <stdlib.h>


#undef strlen


/* Return the length of the null-terminated string STR.  Scan for
the null terminator quickly by testing four bytes at a time.  */
size_t
strlen (str)
const char *str;
{
const char *char_ptr;
const unsigned long int *longword_ptr;
unsigned long int longword, magic_bits, himagic, lomagic;


/* Handle the first few characters by reading one character at a time.
Do this until CHAR_PTR is aligned on a longword boundary.  */
for (char_ptr = str; ((unsigned long int) char_ptr
& (sizeof (longword) - 1)) != 0;
++char_ptr)
if (*char_ptr == '\0')
return char_ptr - str;


/* All these elucidatory comments refer to 4-byte longwords,
but the theory applies equally well to 8-byte longwords.  */


longword_ptr = (unsigned long int *) char_ptr;


/* Bits 31, 24, 16, and 8 of this number are zero.  Call these bits
the "holes."  Note that there is a hole just to the left of
each byte, with an extra at the end:


bits:  01111110 11111110 11111110 11111111
bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD


The 1-bits make sure that carries propagate to the next 0-bit.
The 0-bits provide holes for carries to fall into.  */
magic_bits = 0x7efefeffL;
himagic = 0x80808080L;
lomagic = 0x01010101L;
if (sizeof (longword) > 4)
{
/* 64-bit version of the magic.  */
/* Do the shift in two steps to avoid a warning if long has 32 bits.  */
magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL;
himagic = ((himagic << 16) << 16) | himagic;
lomagic = ((lomagic << 16) << 16) | lomagic;
}
if (sizeof (longword) > 8)
abort ();


/* Instead of the traditional loop which tests each character,
we will test a longword at a time.  The tricky part is testing
if *any of the four* bytes in the longword in question are zero.  */
for (;;)
{
/* We tentatively exit the loop if adding MAGIC_BITS to
LONGWORD fails to change any of the hole bits of LONGWORD.


1) Is this safe?  Will it catch all the zero bytes?
Suppose there is a byte with all zeros.  Any carry bits
propagating from its left will fall into the hole at its
least significant bit and stop.  Since there will be no
carry from its most significant bit, the LSB of the
byte to the left will be unchanged, and the zero will be
detected.


2) Is this worthwhile?  Will it ignore everything except
zero bytes?  Suppose every byte of LONGWORD has a bit set
somewhere.  There will be a carry into bit 8.  If bit 8
is set, this will carry into bit 16.  If bit 8 is clear,
one of bits 9-15 must be set, so there will be a carry
into bit 16.  Similarly, there will be a carry into bit
24.  If one of bits 24-30 is set, there will be a carry
into bit 31, so all of the hole bits will be changed.


The one misfire occurs when bits 24-30 are clear and bit
31 is set; in this case, the hole at bit 31 is not
changed.  If we had access to the processor carry flag,
we could close this loophole by putting the fourth hole
at bit 32!


So it ignores everything except 128's, when they're aligned
properly.  */


longword = *longword_ptr++;


if (
#if 0
/* Add MAGIC_BITS to LONGWORD.  */
(((longword + magic_bits)


/* Set those bits that were unchanged by the addition.  */
^ ~longword)


/* Look at only the hole bits.  If any of the hole bits
are unchanged, most likely one of the bytes was a
zero.  */
& ~magic_bits)
#else
((longword - lomagic) & himagic)
#endif
!= 0)
{
/* Which of the bytes was the zero?  If none of them were, it was
a misfire; continue the search.  */


const char *cp = (const char *) (longword_ptr - 1);


if (cp[0] == 0)
return cp - str;
if (cp[1] == 0)
return cp - str + 1;
if (cp[2] == 0)
return cp - str + 2;
if (cp[3] == 0)
return cp - str + 3;
if (sizeof (longword) > 4)
{
if (cp[4] == 0)
return cp - str + 4;
if (cp[5] == 0)
return cp - str + 5;
if (cp[6] == 0)
return cp - str + 6;
if (cp[7] == 0)
return cp - str + 7;
}
}
}
}
libc_hidden_builtin_def (strlen)

为什么这个版本运行得很快?

它是不是做了很多不必要的工作?

61452 次浏览

在你链接的文件的注释中有解释:

 27 /* Return the length of the null-terminated string STR.  Scan for
28    the null terminator quickly by testing four bytes at a time.  */

和:

 73   /* Instead of the traditional loop which tests each character,
74      we will test a longword at a time.  The tricky part is testing
75      if *any of the four* bytes in the longword in question are zero.  */

在C中,可以对效率进行详细的推理。

遍历单个字符寻找null的效率低于一次测试多个字节的效率,就像这段代码所做的那样。

额外的复杂性来自于需要确保被测试的字符串在正确的位置对齐,以便一次开始测试多个字节(如注释中所述,沿着长字边界),以及需要确保在使用代码时不违反关于数据类型大小的假设。

大多数(但不是所有)现代软件开发中,这种对效率细节的关注是不必要的,或者不值得为额外的代码复杂性付出代价。

像这样注意效率是有意义的地方是在标准库中,就像你链接的例子一样。


如果你想阅读更多关于单词边界的内容,请参见这个问题这个很棒的维基百科页面


我也认为上面的答案是一个更清晰和更详细的讨论。

需要和你不应该写这样的代码-特别是如果你不是C编译器/标准库供应商。它是用来实现strlen的代码,带有一些非常可疑的速度hack和假设(没有使用断言进行测试或在评论中提到):

  • unsigned long是4或8字节
  • 字节是8位
  • 指针可以强制转换为unsigned long long而不是uintptr_t
  • 只需检查最低的2或3位是否为零,就可以对指针进行对齐
  • 可以通过unsigned longs访问字符串
  • 可以读取数组的末尾而没有任何不良影响。

更重要的是,一个好的编译器甚至可以取代编写为

size_t stupid_strlen(const char s[]) {
size_t i;
for (i=0; s[i] != '\0'; i++)
;
return i;
}

(注意它必须是与size_t兼容的类型)与内置在strlen中的编译器的内联版本,或向量化代码;但是编译器不太可能优化复杂的版本。


strlen函数由C11 7.24.6.3描述为:

描述

  1. strlen函数的作用是:计算s指向的字符串的长度。

返回

  1. strlen函数的作用是:返回结束空字符前的字符数。

现在,如果s指向的字符串在一个字符数组中,长度刚好包含字符串和终止符NUL,如果我们通过空结束符访问字符串,则行为将为未定义的,例如在

char *str = "hello world";  // or
char array[] = "hello world";

因此,在完全可移植/标准兼容的C语言中,只有实现这个正确的方式就是在你的问题中编写它的方式,除了琐碎的转换——你可以假装通过展开循环等来更快,但它仍然需要一次完成一个字节

(正如评论者所指出的,当严格的可移植性是一个太大的负担时,利用合理或已知安全的假设并不总是一件坏事。特别是在的一部分的代码中,一个特定的C实现。但你必须先了解规则,然后才能知道如何/何时可以改变规则。


链接的strlen实现首先逐个检查字节,直到指针指向unsigned long的自然4或8字节对齐边界。C标准说,访问一个没有正确对齐的指针具有未定义的行为,所以这绝对必须这样做,以便下一个肮脏的技巧更加肮脏。(实际上,在x86以外的某些CPU架构上,不对齐的单词或双单词加载会导致错误。C是一种可移植的汇编语言,但这段代码就是这样使用它的)。这也是为什么可以读取对象的末尾,而不存在内存保护在对齐块中工作的实现(例如4kiB虚拟内存页)发生故障的风险。

现在是肮脏的部分:代码休息时间承诺并一次读取4或8个8位字节(一个long int),并使用无符号加法的位技巧来快速计算出在这4或8个字节中是否有任何零字节-它使用一个特殊的数字来导致进位位改变被位掩码捕获的位。本质上,这将计算出掩码中的4或8字节中的任何一个是0,假设,而不是循环遍历这些字节。最后,在末尾有一个循环,以确定哪一个字节是第一个零(如果有的话),并返回结果。

最大的问题是,在sizeof (unsigned long) - 1次的sizeof (unsigned long)情况下,它将读取超过字符串的末尾-只有当空字节位于最后的访问的字节中(即在小端序中是最重要的,而在大端序中是最不重要的),它才会越界访问数组!


该代码,即使用于在C标准库中实现strlen,也是代码。它有几个实现定义和未定义的方面,不应该使用在任何地方而不是系统提供的strlen -我在这里将函数重命名为the_strlen,并添加了以下main:

int main(void) {
char buf[12];
printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

缓冲区的大小是经过仔细调整的,以便它能够准确地保存hello world字符串和结束符。然而,在我的64位处理器上,unsigned long是8字节,所以对后一部分的访问将超过这个缓冲区。

如果我现在用-fsanitize=undefined-fsanitize=address编译并运行结果程序,我得到:

% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
#0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
#1 0x55fbec46b139 in main (.../a.out+0x2139)
#2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
#3 0x55fbec46a949 in _start (.../a.out+0x1949)


Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
#0 0x55fbec46b07c in main (.../a.out+0x207c)


This frame has 1 object(s):
[32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable:           00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone:       fa
Freed heap region:       fd
Stack left redzone:      f1
Stack mid redzone:       f2
Stack right redzone:     f3
Stack after return:      f5
Stack use after scope:   f8
Global redzone:          f9
Global init order:       f6
Poisoned by user:        f7
Container overflow:      fc
Array cookie:            ac
Intra object redzone:    bb
ASan internal:           fe
Left alloca redzone:     ca
Right alloca redzone:    cb
==8355==ABORTING

也就是说坏事发生了。

简单地说:在一次可以获取大量数据的体系结构上,逐字节检查字符串可能会很慢。

如果空终止的检查可以在32位或64位的基础上完成,它减少了编译器必须执行的检查量。这就是链接代码在考虑特定系统时试图做的事情。他们对寻址、对齐、缓存使用、非标准编译器设置等做了假设。

在8位CPU上,或者在编写用标准C编写的可移植库时,像您的例子中那样逐字节地读取是一种明智的方法。

从C标准库中寻找如何编写快速/良好代码的建议并不是一个好主意,因为它将不可移植,并且依赖于不标准的假设或定义不佳的行为。如果您是初学者,阅读这样的代码可能弊大于利。

您希望代码是正确的、可维护的和快速的。这些因素有不同的重要性:

“正确”是绝对必要的。

“可维护性”取决于你对代码的维护程度:strlen作为标准C库函数已经有40多年的历史了。它不会改变。因此,对于这个函数来说,可维护性并不重要。

“快”:在许多应用程序中,strcpy、strlen等占用了大量的执行时间。要通过改进编译器来实现与这个复杂但不是很复杂的strlen实现相同的整体速度增益,需要付出巨大的努力。

速度快还有另一个好处:当程序员发现调用“strlen”是他们可以测量字符串中字节数的最快方法时,他们就不会再自己编写代码来提高速度了。

因此,对于strlen来说,速度比您将要编写的大多数代码重要得多,而可维护性则不那么重要。

为什么要这么复杂?假设你有一个1000字节的字符串。这个简单的实现将检查1000个字节。当前的实现可能一次检查64位字,这意味着125个64位字或8字节字。它甚至可以使用矢量指令一次检查32个字节,这将更加复杂,甚至更快。使用向量指令会导致代码更加复杂,但相当简单,检查64位字中的8个字节中是否有一个为零需要一些聪明的技巧。所以对于中长字符串,这段代码可以预期要快四倍。对于像strlen这样重要的函数,值得编写更复杂的函数。

PS.代码不是很可移植。但它是标准C库的一部分,也是实现的一部分——它不需要是可移植的。

pp。有人发布了一个例子,其中调试工具抱怨访问超过字符串结尾的字节。可以设计一个实现来保证以下内容:如果p是一个指向字节的有效指针,那么对同一对齐块中的字节的任何访问,根据C标准将是未定义的行为,将返回一个未指定的值。

公私合伙制。Intel在他们后来的处理器中添加了指令,形成了strstr()函数的构建块(在字符串中查找子字符串)。他们的描述令人难以置信,但他们可以让特定的功能快100倍。(基本上,给定一个包含“Hello, world!”的数组a和一个以16字节“hellohellohellohelloh”开头并包含更多字节的数组b,它会计算出字符串a不会比从索引15开始更早地出现在b中)。

除了这里精彩的回答,我还想指出问题中链接的代码是用于GNU的strlen实现的。

strlen的OpenBSD实现与问题中提出的代码非常相似。实现的复杂性由作者决定。

...
#include <string.h>


size_t
strlen(const char *str)
{
const char *s;


for (s = str; *s; ++s)
;
return (s - str);
}


DEF_STRONG(strlen);

编辑:我上面链接的OpenBSD代码看起来是一个没有自己asm实现的isa的后退实现。根据架构的不同,strlen有不同的实现。例如,amd64 strlen的代码是asm。类似于PeterCordes的comments/回答指出的非后备GNU实现也是asm。

在评论中有很多(轻微或完全)错误的猜测关于一些细节/背景。

你看到的是glibc的优化C回退优化实现。(对于没有手写asm实现的isa)。或者该代码的旧版本,仍然在glibc源代码树中。https://code.woboq.org/userspace/glibc/string/strlen.c.html是一个基于当前glibc git树的代码浏览器。显然,它仍然被一些主流glibc目标所使用,包括MIPS。(谢谢@zwol)。

在x86和ARM等流行的isa上,glibc使用手写的asm

因此,修改代码的动机比您想象的要低。

这个bithack代码(https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord)并不是你的服务器/台式机/笔记本电脑/智能手机上实际运行的代码。它比简单的一次字节循环更好,但是与现代cpu的高效asm相比,即使是这个漏洞也很糟糕(特别是x86,其中AVX2 SIMD允许用一对指令检查32字节,如果数据在2/时钟矢量负载和ALU吞吐量的现代cpu上的L1d缓存中热,则允许主循环中的每个时钟周期中有32到64字节。例如,对于启动开销不占主导地位的中型字符串。)

glibc使用动态链接技巧将strlen解析为CPU的最佳版本,因此即使在x86内部也有SSE2版本(16字节向量,x86-64的基线)和AVX2版本(32字节向量)。

x86在矢量寄存器和通用寄存器之间有高效的数据传输,这使得它特别适合使用SIMD来加速隐式长度字符串上的函数,其中循环控制是依赖于数据的。pcmpeqb / pmovmskb可以一次测试16个独立的字节。

glibc有一个类似使用AdvSIMD的AArch64版本,还有一个针对AArch64 cpu的版本,其中vector->GP寄存器暂停了管道,所以它执行真的要用这个厕所。但是使用计数前导零来查找寄存器内的字节,并在检查页面交叉后利用AArch64的高效无对齐访问。

同样相关的:为什么这个代码在启用优化后会慢6.5倍?有一些关于x86 asm中strlen的快与慢的更多细节,有一个大缓冲区和一个简单的asm实现,这可能对gcc了解如何内联有好处。(有些gcc版本不明智地内联rep scasb,这非常慢,或者像这样一个4字节一次的bitthack。因此GCC的内联strlen配方需要更新或禁用。)

Asm没有c风格的“未定义行为”;;不管你喜欢怎样访问内存中的字节都是安全的,并且包含任何有效字节的对齐加载不会出错。内存保护采用对齐页面粒度;比这更窄的对齐访问不能跨越页面边界。在x86和x64上读取同一页中的缓冲区的结束是否安全?同样的推理适用于这个C黑客让编译器为这个函数的独立非内联实现创建的机器代码。

当编译器发出代码来调用未知的非内联函数时,它必须假设该函数修改了任何/所有全局变量和它可能有指针的任何内存。也就是说,除了没有进行地址转义的局部变量之外,所有的东西都必须在整个调用中在内存中同步。显然,这适用于用asm编写的函数,但也适用于标准库函数。如果不启用链接时间优化,它甚至适用于单独的翻译单元(源文件)。


为什么这是安全的作为glibc的一部分,但否则。

最重要的因素是这个strlen不能内联到其他任何东西。这样做不安全;它包含strict-aliasing乌兰巴托(通过unsigned long*读取char数据)。char*允许别名任何其他但相反的是not true

这是一个预先编译库(glibc)的库函数。这意味着它必须编译为strlen的独立版本的安全机器代码。它不必是便携的/安全的;

GNU C库只需要用GCC编译。显然,使用clang或ICC编译它是不支持,尽管它们支持GNU扩展。GCC是一个提前编译器,它将C源文件转换为机器代码的目标文件。不是解释器,所以除非它在编译时内联,否则内存中的字节就是内存中的字节。例如,当不同类型的访问发生在不同的函数中,并且它们之间没有内联时,严格混叠UB是没有危险的。

记住strlen的行为是由ISO C标准通过定义的。这个函数的名字就是的一部分的实现。像GCC这样的编译器甚至把这个名字当作内置函数,除非你使用-fno-builtin-strlen,所以strlen("foo")可以是一个编译时常数3。库中的定义是只有,当gcc决定实际发出对它的调用,而不是内联自己的食谱或其他东西时使用。

当UB在编译时不可见到编译器时,你会得到正常的机器代码。机器代码必须适用于no- ub情况,即使你想要 to, asm也无法检测调用者用于将数据放入指向内存的类型。

Glibc被编译为独立的静态或动态库,不能内联链接时间优化。Glibc的构建脚本不会创建“fat”;静态库包含机器代码+ gcc GIMPLE内部表示,用于内联到程序时的链接时间优化。(即libc.a不会参与主程序的-flto链接时间优化。)以这种方式构建glibc可能是不安全的在实际使用.c的目标上

事实上,正如@zwol评论的那样,LTO不能在构建glibc 本身时使用,因为“;脆性”;如果glibc源文件之间可以内联,那么这样的代码可能会中断。(strlen有一些内部用途,例如可能作为printf实现的一部分)


这个strlen做了一些假设:

  • CHAR_BIT是8的倍数。在所有GNU系统上为True。POSIX 2001甚至保证CHAR_BIT == 8。(对于有CHAR_BIT= 1632的系统,这看起来是安全的,比如一些dsp;如果sizeof(long) = sizeof(char) = 1, unaligned-prologue循环将总是运行0次迭代,因为每个指针总是对齐的,并且p & sizeof(long)-1总是零。)但如果你有一个非ascii字符集,其中字符是9或12位宽,0x8080...是错误的模式。
  • (可能)unsigned long是4或8字节。或者它实际上适用于unsigned long到8的任何大小,并且它使用assert()来检查这一点。

这两个都不是可行的UB,它们只是无法移植到一些C实现。这段代码是(或曾经是)的一部分在它工作的平台上的C实现,所以这很好。

下一个假设是潜在的C UB:

  • 包含任何有效字节的对齐加载不会出错,只要你忽略你实际想要的对象之外的字节,它是安全的。(在asm中,在每个GNU系统和所有普通cpu上都是这样,因为内存保护是在对齐页面粒度下发生的。当UB在编译时不可见时,在x86和x64上读取同一页中的缓冲区的结束是否安全?在C中是安全的。如果没有内联,情况就是这样。编译器不能证明读过第一个0是UB;例如,它可以是包含{1,2,0,3}的C char[]数组)

最后一点使得在这里读取C对象的结束部分是安全的。即使在与当前编译器内联时,这也是相当安全的,因为我认为它们目前并不认为这意味着执行路径不可达。但无论如何,如果你让它内联,严格的混叠已经是一个阻碍。

然后你就会遇到像Linux内核旧的不安全memcpy CPP宏这样的问题,它使用指向unsigned long (Gcc,严格别名,以及恐怖故事)的指针转换。(现代Linux使用-fno-strict-aliasing编译,而不是小心使用may_alias属性。)

这个strlen可以追溯到这样的时代,在一般情况下,你可以摆脱这样的东西;在GCC3之前,它是非常安全的,即使没有“only when not inlining”。警告。


只有在调用/ret边界时才可见的UB不会伤害我们。(例如,在char buf[]而不是unsigned long[]转换为const char*的数组上调用此函数)。一旦机器代码固定下来,它就只是处理内存中的字节。非内联函数调用必须假设被调用方读取所有内存。


安全地写这个,没有严格的混叠UB

GCC类型属性may_alias对类型的别名处理与char*相同。(由@KonradBorowsk建议)。GCC头文件目前将它用于x86 SIMD向量类型,如__m128i,因此您始终可以安全地执行_mm_loadu_si128( (__m128i*)foo )。(参见硬件SIMD向量指针和对应类型之间的' reinterpret_cast '是未定义的行为?了解更多关于这意味着什么和不意味着什么的细节。)

strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;


// handle unaligned startup somehow, e.g. check for page crossing then check an unaligned word
// else check single bytes until an alignment boundary.
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;


for (;;) {
// alignment still required, but can safely alias anything including a char[]
unsigned long ulong = *longword_ptr++;


...
}
}
你可以使用aligned(1)来表示一个带有alignof(T) = 1的类型。
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;。这对于strlen的未对齐启动部分很有用,如果您不只是在第一个对齐边界之前每次只执行char-at-a-time。(主循环需要对齐,这样如果结束符恰好在未映射的页面之前,就不会出错。

在ISO中表达混叠负载的一种可移植方法是使用memcpy,现代编译器知道如何将其内联为单个加载指令。如。

   unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);

这也适用于未对齐的负载,因为memcpy就像通过char-at-a-time访问一样工作。但在实践中,现代编译器非常理解memcpy

这里的危险是,如果GCC没有知道确保char_ptr是字对齐的,它就不会将它内联到一些可能不支持asm中未对齐加载的平台上。例如MIPS64r6之前的MIPS,或更老的ARM。如果你实际调用memcpy函数只是为了加载一个单词(并将它留在其他内存中),这将是一场灾难。GCC有时可以看到代码何时对指针进行对齐。或者在char-at-a-time循环达到ulong边界后,可以使用
p = __builtin_assume_aligned(p, sizeof(unsigned long)); < / p >

这并不能避免过去读对象可能的UB,但对于当前的GCC,这在实践中并不危险。


为什么手工优化C源代码是必要的:当前的编译器还不够好

如果您希望广泛使用的标准库函数的每一点性能都得到优化,那么手工优化asm会更好。特别是像memcpy这样的东西,但也包括strlen。在这种情况下,使用C和x86 intrinsic来利用SSE2并不容易。

但这里我们只讨论一个朴素的C版本,而没有任何特定于isa的特性。

(我认为我们可以认为strlen被广泛使用,使它尽可能快地运行是很重要的。所以问题就变成了我们能否从更简单的资源中获得高效的机器代码。不,我们不能。)

当前的GCC和clang不能自动向量化循环,因为在第一次迭代之前不知道迭代计数。(例如,它必须能够检查循环是否将运行至少16次迭代之前运行第一次迭代。)例如,自动向量化memcpy是可能的(显式长度缓冲区),但不是strcpy或strlen(隐式长度字符串),给定当前的编译器。

这包括搜索循环,或任何其他具有依赖于数据的if()break和计数器的循环。

ICC (Intel的x86编译器)可以自动向量化一些搜索循环,但仍然只能为简单/朴素的C strlen生成一次字节asm,如OpenBSD的libc使用。(Godbolt)。(从@Peske的回答)。

手动优化的libc strlen对于当前编译器的性能是必要的。每次1个字节(在宽超标量cpu上每个周期可能展开2个字节)是可悲的,因为主存每个周期可以保持大约8个字节,而L1d缓存每个周期可以提供16到64个字节。(Haswell和Ryzen以来,在现代主流x86 cpu上每周期加载2倍32字节。这还不包括AVX512,它只使用512位矢量就可以降低时钟速度;这就是glibc可能不急于添加AVX512版本的原因。虽然使用256位向量,AVX512VL + BW掩码比较为掩码,而ktestkortest可以通过减少其uops /迭代使strlen更适合超线程。)

我在这里包括非x86,即“16字节”。例如,大多数AArch64 cpu至少可以做到这一点,我认为,有些肯定会做得更多。并且一些有足够的strlen执行吞吐量来跟上负载带宽。

当然,处理大字符串的程序通常应该跟踪长度,以避免经常重新查找隐式长度C字符串的长度。但是短到中等长度的性能仍然受益于手工实现,我确信一些程序最终会在中等长度的字符串上使用strlen。

其他答案中没有提到的一件重要的事情是,FSF非常谨慎地确保专有代码不会进入GNU项目。在参考专有程序下的GNU编码标准中,有一个关于以一种不能与现有私有代码混淆的方式组织你的实现的警告:

在任何情况下都不要参考Unix源代码或在你的GNU工作期间!(或任何其他专有程序。)

如果您对Unix程序的内部结构有模糊的记忆,这并不绝对意味着您不能编写模仿它的程序,而是尝试按照不同的思路在内部组织模仿程序,因为这可能会使Unix版本的细节与您的结果无关,并且与您的结果不同。

例如,Unix实用程序通常被优化为最小化内存使用;如果你追求的是速度,你的程序将非常不同。

(强调我的。)

为什么像下面这样的作品不一样好或更好呢?

// OP's code - what is needed to portably function correctly?
unsigned long strlen(char s[]) {
unsigned long i;
for (i = 0; s[i] != '\0'; i++)
continue;
return i;
}

OP的代码有功能错误。

不过修改起来很容易。


在编写可移植代码时,需要注意首先确保函数正确,然后再考虑性能改进。

即使是非常简单,看似正确的代码也可能有功能缺陷。

类型

字符串的长度在size_t的范围内,可能与unsigned long不同。函数签名不匹配size_t (*f)() = strlen的问题。不常见平台的问题,其中ULONG_MAX < SIZE_MAX和字符串长度是巨大的。

const

s应该是const char *

Non-2的补

(这个问题目前影响的处理器数量少得可怜,所以实际上只是一个学究气的问题。Non-2的补体很可能在下一个C中被指定(C23?))。

char签署而不是2的补码时,s[i] != '\0'可以在-0处触发。不应该如此。str...()函数就像字符被作为unsigned char访问一样。

对于本章节中的所有函数,每个字符都应被解释为具有unsigned char类型(因此每个可能的对象表示都是有效的,具有不同的值)。


修复这些方面的OP的简单代码

size_t strlen(const char *s) {
size_t i;
for (i = 0; ((const unsigned char *)s)[i] != '\0'; i++)
continue;
return i;
}

现在有了一个更好的,可移植的strlen()候选程序,将其与“复杂”的strlen()进行比较。替代方案。