0

为了学习,我正在尝试基于C89 标准草案在 C89 中编写一个简单的 C89 --> x86_64 编译器。到目前为止,我正在实施翻译阶段 1。我的理解是,这包括

  1. 将代码读入字符串。
  2. 替换三字母序列。

我试图用一个程序来实现这一点(请原谅我犯的任何风格错误):

char *trigraph_replacement(char *code)
{
        char *temp = calloc(1, strlen(code));
        char *temp_1 = temp;
        char *code_1 = code;
        for (; *code_1; code_1++)
        {
                if (strncmp(code_1, "??", 2) == 0)
                {
                        code_1 += 2;
                        switch (*code_1)
                        {
                        case '<':
                                *(temp_1) = '{';
                                break;
                        case '>':
                                *(temp_1) = '}';
                                break;
                        case '(':
                                *(temp_1) = '[';
                                break;
                        case ')':
                                *(temp_1) = ']';
                                break;
                        case '=':
                                *(temp_1) = '#';
                                break;
                        case '/':
                                *(temp_1) = '\\';
                                break;
                        case '\'':
                                *(temp_1) = '^';
                                break;
                        case '!':
                                *(temp_1) = '!';
                                break;
                        case '-':
                                *(temp_1) = '~';
                                break;
                        default:
                                break;
                        }
                }
                else
                {
                        *temp_1 = *code_1;
                }
                temp_1++;
        }

        free(code);
        return temp;
}

现在,直观地说,这似乎应该做应该做的事情,替换所有三元组。但是,gcc 文档说“Trigraphs 并不流行,许多编译器错误地实现了它们”。它继续声明“可移植代码不应依赖于被转换或忽略的三元组”

结果,我想知道

  • 我的实施是否足够,还是我以某种方式犯了错误?
  • 三元组是否值得一开始就实施,或者即使在最古老的遗留程序中也不使用它们?
4

2 回答 2

2

将代码读入字符串。

是的,从源文件使用的符号表转换为编译器使用的符号表。理想情况下,他们会使用同一个。

我的实施是否足够,还是我以某种方式犯了错误?

  • calloc(1, strlen(code));几乎可以肯定是一个错误,因为您没有为空终止符分配空间。
  • 对于解析器来说,堆分配、分支switch等的实现非常幼稚,这段代码会很慢。尽可能使用查找表,优先考虑执行速度而不是内存消耗。
  • 不需要所有 icky 指针算术,因为您已经决定调用strlen(调用也会产生开销执行时间)。存储该调用的结果并将整个循环更改为for(size_t i=0; i<length; i++). 您应该能够大大减少此功能。

三元组是否值得一开始就实施,或者即使在最古老的遗留程序中也不使用它们?

除了在那些古老的程序中,它们几乎没有被使用。它们在 C 中的存在是一个备受批评的语言缺陷。这就是为什么 gcc 遇到它们时会发出警告的原因。

但是,如果您希望声明某种方式的 ISO C90 合规性,您需要支持它们。它们甚至没有被当前的 ISO C17 标准标记为过时(出于未知的原因),因此它们似乎会继续存在。

于 2022-01-12T09:45:00.983 回答
1

撇开一些技术问题和效率低下,我稍后会谈到,你的问题的答案:

我在某些方面犯错了吗?

是,“是的,你做到了”。

1.这段代码的bug

这是循环的高度缩写视图trigraph_replacement(您可能讨厌我的大括号格式;我这样做是为了减少垂直空间浪费):

for (; *code_1; code_1++) {
    if (strncmp(code_1, "??", 2) == 0) {
        code_1 += 2;
        switch (*code_1) {
            case '<': *(temp_1) = '{'; break;
            /* ...  */
            default:                   break;
        }
    } /* end if (strncmp(...)) */
    else { *temp_1 = *code_1; }
    temp_1++;
}

如果strncmp返回非零值,则没有问题;效果是:

    *temp_1 = *code_1;
    temp_1++;
    code_1++;  /* From the for loop */

如果strncmp返回 0 并且第一个(或任何其他指定的)case匹配,那么再次没有问题:

    code_1 += 2; 
    *temp_1 = '{';   /* The parentheses around temp_1 were unnecessary */
    temp_1++;
    code_1++;  /* From the for loop */

但是如果strncmp返回 0 但下一个字符是空格怎么办:

    printf("Can this be correct?? N has the value %d\n", N);

default本案涉及以下顺序:

    code_1 += 2; 
    /* default case does nothing */
    temp_1++;
    code_1++;  /* From the for loop */

因此,代码跳过了??后面的空格,并且无法将任何内容存储到输出缓冲区中的相应位置。结果将是:

    printf("Can this be correctØN has the value %d\n", N);

Ø代表一个实际的 NUL 字符;由于code分配有calloc,未设置的字符位置将为 0。这不太可能在下一阶段的翻译中发挥作用;它可能会过早终止源代码,或产生无效的源字符错误,或者只是掉在地板上,但这些都不是用户想要的。)

不过,可能更糟糕的是以下示例(标准中的 §5.2.1.1para3):

    printf("Eh???/n");

该示例中的三元组序列从 second 开始?,您的代码永远不会注意到这一点(即使您修复了默认情况,也可能不会注意到,这取决于您如何修复它)。预期的翻译是printf("Eh?\n");,在三元组替换??/为单个反斜杠之后,这显然必须在解释反斜杠转义之前发生。

这确实是 GCC 文档所暗示的经典实现错误之一。

2.其他实施难点

如果您按顺序执行翻译阶段,您的代码将不会遇到另一个常见错误,即无法在某些上下文中处理三元组。这通常发生在词法分析器中,它试图与词法分解并行处理三元组(和线拼接),我认为在某处的评论中提到了这一点。

我认为合理的论点是,由于三元组和拼接都不是很常见 [注 1],即使是在词法分析器中非常缓慢的实现(仅在必要时发生)也可能比在翻译过程中浪费周期快得多这实际上是不需要的。

但是,一些明智的程序员不会实际使用的病态案例很容易出错。考虑以下:

/??/
* This is a comment
*/

如果不明显,这是一个评论,因为三字母替换处于第 1 阶段;线拼接处于阶段2;和评论识别在第 3 阶段。在第 1 阶段之后,输入是

/\
* This is a comment
*/

在第二阶段之后,它是

/* This is a comment
*/

如果阶段 1 和阶段 2 独立于词法分析器,这很容易做到。但是,与所有其他古怪变体一起识别/??/<newline>*为评论开始者和*??/<newline>/评论终止者的令牌正则表达式,写起来并不是很愉快[注 2]。

最后,我知道将整个输入文件放入内存以便将其解析为单个连续字符串是很诱人的,但这并不是一个非常好的模式。“寻找文件末尾并调用tell以查看文件多长时间”的非常常见的反模式既是缓冲区溢出的邀请(当您实际读取文件时文件可能会更长,因为它仍在被写入)和一种无法使用 Unix 管道作为输入源的方法。但是,即使您更仔细地阅读文件,最终也会使用比必要更多的内存,并且在源文件全部可用之前您将无法开始处理源文件。你可能不在乎,这些天我可能会接受这个论点,但这是需要考虑的事情。

另一方面,如果您逐个缓冲区读取文件缓冲区,则需要处理三元组的开头位于缓冲区末尾但尚未读取最后一个字符的烦人情况。幸运的是,trigraphs 和 splices 都很短,所以你可以用一个小的预留缓冲区甚至一个非常小的状态机来解决这个问题。但这是需要编写和调试的额外代码。

3. 不完全是代码审查

如所写,原型

char* trigraph_replacement(char* tmp);

要求参数trigraph_replacement是一个可变的动态分配的缓冲区,其所有权被转移给函数,最终通过freeing 它并返回一个新的动态分配的缓冲区。

这种所有权转移是灾难的根源(特别是当它没有记录时)。除非您有充分的理由,否则接受字符串参数的函数应该不理会它们,或者应该修改它们。就地修改是一种效率黑客;这并不总是可能的——事实上,这通常是不可能的——但在这种特殊情况下,它是可能的,所以你可能想要考虑它。由于三元组的实际使用可能仅限于您自己编写的测试用例,因此对生产代码进行双重分配和复制整个输入的毫无意义的负担真的没有多大意义。

就地修改是可能的,因为所有三元组替换都用一个字节替换了三个字节[注 3]。线拼接是另一个总是使输出更短的过程,并且稍微小心您可以同时进行这两个过程。因此,如果您记录了您计划修改(但不是释放!)输入的事实,那么将函数编写为如下所示是完全合理的:

void trigraph_replacement(char* code) {
  /* Trigraphs in `code` are modified in-place. */

  /* Use a look-up table rather than a massive case statement. This is
   * not necessarily faster, but the code is shorter.}
   */
  /* Designated initializers are C99; in C89 you'd need to write this out */
  static const char trigraphs[128] = {
    ['='] = '#',  ['('] = '[', ['/'] = '\\', [')'] = ']',
    ['\''] = '^', ['<'] = '{', ['!'] = '|',  ['>'] = '}',
    ['-'] = '~'
  };
  for (char* in = code, *out = code; ; ++in, ++out) {
    if (in[0] == '?' &&
        in[1] == '?' &&  /* Safe because code must be nul-terminated */
        in[2] > 0    &&  /* Ditto */
        in[2] < 128  &&
        trigraphs[in[2]] != 0) {
      *out = trigraphs[in[2]];
      in += 2;
    }
    else {
      *in = *out;
    }
    if (*in == 0) break;
  }
}

该循环小心地仅在??验证??开始三元组时才将输入指针推进到 。我没有尝试对可以复制和推进一两个字符的各种可能性进行特殊处理,因为它们发生的频率不足以证明额外的代码复杂性是合理的。

4. 如果不接受就地修改怎么办?

不是每个人都喜欢就地修改(包括我,有时),所以值得考虑一个替代方案。无论您选择何种替代方案,重要的是要考虑(并记录)动态存储分配策略。

例如,在没有遇到三元组的常见情况下,简单地返回未修改的参数是很诱人的。但这使调用者的生活变得困难,因为他们不知道输出字符串是一个额外的动态分配的内存区域,还是同一个。因此,无条件地将输入复制到新分配的输出是一个很好的论据。(调用者可能会利用副本是无条件的这一事实,例如通过消除输入缓冲区的不必要副本。)

但是,C 提供的用于计算副本长度的唯一机制是strlen,这需要对缓冲区进行完整扫描(回想一下,缓冲区是整个程序,可能非常大)。实际上,调用者很可能知道输入了多长时间,因为他们一定是从某个地方获取的,并且大多数输入函数会告诉您读取了多少数据 [注 4]。所以要求调用者告诉你输入有多长是完全合理的,从而产生一个原型

char* trigraph_replacement(const char* code, size_t codelen);

如果出于某种原因,调用者不知道多长时间code,他们可以调用strlen,但没有必要惩罚知道的调用。无论如何,为包括终止 NUL 字符在内的整个字符串分配足够的空间至关重要。所以你会想要使用malloc(codelen + 1)or calloc(1, codelen + 1)。(如果你小心,你真的不需要calloc。)

笔记

  1. 绝大多数的线拼接都被空格包围,这些可以通过对空格正则表达式模式进行小的修改来轻松处理。字符串文字中的拼接也是如此。更成问题的是位于令牌中间的拼接,特别是复杂的令牌,如 pp-numbers,或者下面描述的由三元组组成的拼接的可怕情况。

  2. 这并非不可能,我知道有些读者会将其视为挑战。这很酷。但是你确定你已经涵盖了所有的案例吗?我希望看到很多测试用例:-)

  3. 三元组转换成的字符都在基本字符集中,必须编码成一个字节。见§5.2.1.2。

  4. fgets不应用于读取多行输入,因为它涉及对换行符的额外扫描。除了它没有告诉您它读取了多少个字符这一事实之外,还需要再次扫描空字符。

于 2022-01-13T01:57:46.377 回答