使用Clang 10编译时,strsep后出现段错误

3
我正在编写一个解析器(用于解析NMEA句子),它使用strsep在逗号上拆分字符串。当使用clang(Apple LLVM版本10.0.1)编译时,对于具有偶数个标记的字符串进行拆分时,代码会崩溃。当在Linux上使用clang(版本7.0.1)或gcc(9.1.1)编译时,代码可以正常工作。
下面是一个简化版的代码,展示了这个问题:
#include <stdio.h>
#include <stdint.h>
#include <string.h>

static void gnss_parse_gsa (uint8_t argc, char **argv)
{

}

/**
 *  Desciptor for a NMEA sentence parser
 */
struct gps_parser_t {
    void (*parse)(uint8_t, char**);
    const char *type;
};

/**
 *  List of avaliable NMEA sentence parsers
 */
static const struct gps_parser_t nmea_parsers[] = {
    {.parse = gnss_parse_gsa, .type = "GPGSA"}
};

static void gnss_line_callback (char *line)
{
    /* Count the number of comma seperated tokens in the line */
    uint8_t num_args = 1;
    for (uint16_t i = 0; i < strlen(line); i++) {
        num_args += (line[i] == ',');
    }

    /* Tokenize the sentence */
    char *args[num_args];
    for (uint16_t i = 0; (args[i] = strsep(&line, ",")) != NULL; i++);

    /* Run parser for received sentence */
    uint8_t num_parsers = sizeof(nmea_parsers)/sizeof(nmea_parsers[0]);
    for (int i = 0; i < num_parsers; i++) {
        if (!strcasecmp(args[0] + 1, nmea_parsers[i].type)) {
            nmea_parsers[i].parse(num_args, args);
            break;
        }
    }
}

int main (int argc, char **argv)
{
    char pgsa_str[] = "$GPGSA,A,3,02,12,17,03,19,23,06,,,,,,1.41,1.13,0.85*03";
    gnss_line_callback(pgsa_str);
}

在代码的这一行 if (!strcasecmp(args[0] + 1, nmea_parsers[i].type)) { 中出现了段错误,args数组的索引操作尝试对一个空指针进行解引用。

增加堆栈的大小,可以通过手动编辑汇编或在函数中添加一个printf("")调用来实现,这使得它不再出现段错误,同样也可以通过使args数组更大(例如将num_args加一)来实现。

总之,以下任何一项都可以避免段错误:
- 使用除clang 10以外的编译器
- 修改汇编代码,使动态分配之前的堆栈大小为80字节或更多(编译结果为64个字节)
- 使用具有奇数个标记的输入字符串
- 将args作为具有正确数量的标记(或更多)的定长数组进行分配
- 将args作为至少具有num_args + 1个元素的可变长度数组进行分配
请注意,在Linux上使用clang 7编译时,动态分配之前的堆栈大小仍为64字节,但是代码不会出现段错误。

我希望有人能够解释为什么会发生这种情况,以及是否有任何方法可以使这段代码在clang 10上编译正确。


你确定你不想使用 char *args[num_args+1]; 吗?精确性是好的,但我发现为自己留出一点余地通常可以节省时间,以防我在某个地方出现了一个单元错误。实际上我很确定你有一个单元错误:在通过 strsep 循环进行 N 次(即 num_args 次)之后,你开始制作第 N+1 次循环,也就是你发现 strsep 返回 NULL 的那一次循环。但是你已经将该 NULL 指针存储在 args [num_args] 中,从而导致了溢出。 - Steve Summit
1
顺便说一句:非常感谢您提供了简化版本的代码!许多新贡献者都会忽略这一步骤。 - Steve Summit
1个回答

4

当诸如编译器的特定版本等几乎无关紧要的因素似乎会产生影响时,这很可能意味着您在某些地方存在未定义的行为。

您正确计算逗号以预先确定字段的精确数量,num_args。 您分配了一个数组,只有勉强足够容纳这些字段:

char *args[num_args];

但是当您运行此循环时:

for (uint16_t i = 0; (args[i] = strsep(&line, ",")) != NULL; i++);

在这个循环中,将会有 `num_args` 次通过 `strsep` 返回非空指针并填入到 `args[0]` 到 `args[num_args-1]` 中的操作,这正是您想要的,也是可以接受的。但之后还会有一个调用 `strsep` 的过程,该过程返回空指针并终止循环,但是这个空指针也会被存储到 `args` 数组中,并且确切地存储到了最末尾的 `args[num_args]` 中。换句话说,数组溢出了。
有两种方法可以解决这个问题。您可以使用一个额外的变量来捕获和测试 `strsep` 的返回值,在将其存储到 `args` 数组中之前进行测试。
char *p;
for (uint16_t i = 0; (p = strsep(&line, ",")) != NULL; i++)
    args[i] = p;

这样做的好处是您将拥有一个更常规的循环,带有实际的主体。或者,您可以声明args数组比它实际需要的大一个,这意味着它有空间存储在args[num_args]中的最后一个NULL指针。
char *args[num_args+1];

这样做的好处是,您总是可以向解析函数传递“以NULL结尾的数组”,这对它们非常方便(正好与调用main的方式相匹配)。

谢谢!我在几个项目中都使用了这段代码,不确定为什么直到现在才出现问题,但似乎是写入数组末尾之外导致的问题。虽然我仍然不明白它是如何失败的,但你有任何想法可能会导致args成为NULL指针吗? - Samuel Dewan
@SamuelDewan 是的,有时候你可以惊奇地发现自己能够长时间运行完全错误的代码,这种情况也曾经发生在我身上。但是,我不知道为什么你会看到你所看到的特定失败模式--随着编译器变得更加复杂,它们的选择(尤其是在面对未定义行为时,他们被允许做任何事情)可能非常难以理解。 - Steve Summit

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接