【问题标题】:Why is my (re)implementation of strlen wrong?为什么我的(重新)实施 strlen 是错误的?
【发布时间】:2013-10-14 15:19:44
【问题描述】:

我想出了这个小代码,但所有专业人士都说它很危险,我不应该写这样的代码。谁能在“更多”细节中突出其漏洞?

int strlen(char *s){ 
    return (*s) ? 1 + strlen(s + 1) : 0; 
}

【问题讨论】:

  • 你为什么不问问所有这些专业人士他们的意思?它可能效率不高,但我不会称之为危险。尤其是对于可能会优化尾递归的相当现代的编译器。
  • 有些语言实际上没有循环(ML)
  • @IgorTandetnik,这不是尾递归。
  • @IgorTandetnik 它不是写的尾递归,因为在调用之后仍有工作要做(添加一个)。它可以手动或自动转换为尾递归函数,但我不希望 C 编译器自动完成。
  • 还有一个没有人提到的小危险。你应该使用size_t,而不是int。否则你可能会在int小的平台上遇到大字符串的麻烦。

标签: c++ c string recursion strlen


【解决方案1】:

它本身没有漏洞,这是完全正确的代码。当然,它过早地悲观了。除了最短的字符串,它会耗尽堆栈空间,并且由于递归调用,它的性能会很差,但除此之外没关系。

尾调用优化很可能无法处理此类代码。如果你想危险地生活并依赖尾调用优化,你应该改写它以使用尾调用:

// note: size_t is an unsigned integertype

int strlen_impl(const char *s, size_t len) {
    if (*s == 0) return len;
    if (len + 1 < len) return len; // protect from overflows
    return strlen_impl(s+1, len+1);
}        

int strlen(const char *s) {
   return strlen_impl(s, 0);
}

【讨论】:

  • clang 和 gcc 在使用 -O2 编译时都会将问题中的代码变成循环。 (GCC 4.8.1,Clang 3.3)
  • @JohnBartholomew:我对依赖尾调用优化持谨慎态度,更不用说转换为循环了......标准中的任何内容都不能保证。
  • VS2012 将其变成一个循环并内联函数并进行优化
  • @JohnBartholomew:好吧,如果您的生活目标是在特定优化级别为特定编译器编写代码,那就继续吧:)
  • 基本上,问题不在于某些编译器会做什么,问题在于这样编码是否是一个好主意。是的,我确信有一些模板元编程可能会产生这样的代码,编译器应该处理它,但是这个答案是新手级别的,我们不要增加混乱。
【解决方案2】:

这有点危险,但它是不必要的递归,并且可能比迭代替代方案效率低。

我还认为给定一个很长的字符串存在堆栈溢出的危险。

【讨论】:

  • 我非常有信心,一个体面的编译器会通过尾递归解决这个问题。但这需要进行实验。
  • @bitmask 我不知道编译器会用它做什么,但函数不是尾递归的。
【解决方案3】:

这段代码中有两个严重的安全漏洞:

  1. 使用int 而不是size_t 作为返回类型。如所写,超过INT_MAX 的字符串将导致此函数通过整数溢出调用未定义的行为。在实践中,这可能会导致将 strlen(huge_string) 计算为像 1 这样的小值,malloc' 分配了错误的内存量,然后对其执行 strcpy,从而导致缓冲区溢出。

  2. 可以溢出堆栈的无限递归,即堆栈溢出。 :-) 编译器可能选择将递归优化为循环(在这种情况下,使用当前的编译器技术是可能的),但不能保证它会这样做。在最好的情况下,堆栈溢出只会使程序崩溃。在最坏的情况下(例如,在没有保护页面的线程上运行)它可能会破坏不相关的内存,可能导致任意代码执行。

【讨论】:

  • 最佳答案。我希望更多的程序员对安全问题有更好的认识。
【解决方案4】:

已经指出的杀死堆栈的问题应该由一个体面的编译器解决,其中明显的递归调用被展平为一个循环。我验证了这个假设并让 clang 翻译你的代码:

//sl.c
unsigned sl(char const* s) {
  return (*s) ? (1+sl(s+1)) : 0;
}

编译和反汇编:

clang -emit-llvm -O1 -c sl.c -o sl.o
#                 ^^ Yes, O1 is already sufficient.
llvm-dis-3.2 sl.o

这是llvm结果的相关部分(sl.o.ll)

define i32 @sl(i8* nocapture %s) nounwind uwtable readonly {
  %1 = load i8* %s, align 1, !tbaa !0
  %2 = icmp eq i8 %1, 0
  br i1 %2, label %tailrecurse._crit_edge, label %tailrecurse

tailrecurse:                                      ; preds = %tailrecurse, %0
  %s.tr3 = phi i8* [ %3, %tailrecurse ], [ %s, %0 ]
  %accumulator.tr2 = phi i32 [ %4, %tailrecurse ], [ 0, %0 ]
  %3 = getelementptr inbounds i8* %s.tr3, i64 1
  %4 = add i32 %accumulator.tr2, 1
  %5 = load i8* %3, align 1, !tbaa !0
  %6 = icmp eq i8 %5, 0
  br i1 %6, label %tailrecurse._crit_edge, label %tailrecurse

tailrecurse._crit_edge:                           ; preds = %tailrecurse, %0
  %accumulator.tr.lcssa = phi i32 [ 0, %0 ], [ %4, %tailrecurse ]
  ret i32 %accumulator.tr.lcssa
}

我没有看到递归调用。实际上,clang 将循环标签称为 tailrecurse,它为我们提供了关于 clang 在这里做什么的指针。

所以,最后 (tl;dr) 是的,这段代码是完全安全的,并且带有不错标志的不错的编译器消除递归。

【讨论】:

  • 它工作 if 您编译优化,并且 if 您使用将其识别为接近尾递归的编译器。那些禁止使用优化编译器的组织呢? (是的,它们确实存在。一个数百万美元的错误可以追溯到一个糟糕的优化,即使它发生在十年前或更长时间之前,也会导致非常非常长的机构记忆。)
  • @DavidHammen:这听起来可能很奇怪,但我真的不关心组织。尤其是那些有有趣想法的人,比如在没有源代码的情况下发送二进制文件。但是为了您的安心,我使用-O1 重新运行测试,这是一个更加仔细的优化,并且clang 仍然删除了递归调用。
  • @bitmask:如果您发送二进制文件,您就不会担心它。当您的用户从源代码编译时,您必须确保您的源代码是正确的。而你的不是。
  • @R..: 很公平,但我看到的唯一正确性问题是使用unsigned 而不是size_t,我故意这样做是为了避免包含额外的标题,可能会使结果文件膨胀.顺便说一句,我在阅读实际的汇编程序时很糟糕,所以我只检查了 clang(它生成 llvm),而不是 gcc 或其他。如果 gcc 也没有弄清楚尾递归,那将是反对它的另一个理由,但也支持你的观点。
猜你喜欢
  • 2011-10-14
  • 1970-01-01
  • 2021-11-18
  • 2023-03-11
  • 2020-11-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-11-14
相关资源
最近更新 更多