C - 从文件复制文本会导致未知字符也被复制

问题描述

运行以下 C 文件时,将字符复制到 fgetc 到我的 tmp 指针会导致未知字符由于某种原因被复制。从 fgetc() 收到的字符是预期的字符。但是,由于某种原因,当将此字符分配给我的 tmp 指针时,未知字符会被复制。

我已经尝试在网上寻找原因,但没有找到任何运气。从我读过的内容来看,这可能与 UTF-8 和 ASCII 问题有关。但是,我不确定修复方法。我是一个相对较新的 C 程序员,对内存管理还是个新手。

输出

TMP: Hello,DATA!�
TEXT: Hello,DATA!�

game.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <allegro5/allegro5.h>
#include <allegro5/allegro_font.h>

const int WIN_WIDTH = 1366;
const int WIN_HEIGHT = 768;

char *readFile(const char *fileName) {
    FILE *file;
    file = fopen(fileName,"r");

    if (file == NULL) {
        printf("File Could not be opened for reading.\n");
    }

    size_t tmpSize = 1;
    char *tmp = (char *)malloc(tmpSize);

    if (tmp == NULL) {
        printf("malloc() Could not be called on tmp.\n");
    }

    for (int c = fgetc(file); c != EOF; c = fgetc(file)) {
        if (c != NULL) {
            if (tmpSize > 1)
                tmp = (char *)realloc(tmp,tmpSize);

            tmp[tmpSize - 1] = (char *)c;
            tmpSize++;
        }
    }
    tmp[tmpSize] = 0;

    fclose(file);
    printf("TMP: %s\n",tmp);
    return tmp;
}

int main(int argc,char **argv) {
    al_init();
    al_install_keyboard();

    ALLEGRO_TIMER* timer = al_create_timer (1.0 / 30.0);
    ALLEGRO_EVENT_QUEUE *queue = al_create_event_queue();

    ALLEGRO_disPLAY* display = al_create_display(WIN_WIDTH,WIN_HEIGHT);
    ALLEGRO_FONT* font = al_create_builtin_font();

    al_register_event_source(queue,al_get_keyboard_event_source());
    al_register_event_source(queue,al_get_display_event_source(display));
    al_register_event_source(queue,al_get_timer_event_source(timer));

    int redraw = 1;
    ALLEGRO_EVENT event;

    al_start_timer(timer);

    char *text = readFile("game.DATA");
    printf("TEXT: %s\n",text);

    while (1) {
        al_wait_for_event(queue,&event);
        if (event.type == ALLEGRO_EVENT_TIMER)
            redraw = 1;
        else if ((event.type == ALLEGRO_EVENT_KEY_DOWN) || (event.type == ALLEGRO_EVENT_disPLAY_CLOSE))
            break;
        
        if (redraw && al_is_event_queue_empty(queue)) {
            al_clear_to_color(al_map_rgb(0,0));
            al_draw_text(font,al_map_rgb(255,255,255),text);
            al_flip_display();

            redraw = false;
        }
    }

    free(text);
    al_destroy_font(font);
    al_destroy_display(display);
    al_destroy_timer(timer);
    al_destroy_event_queue(queue);

    return 0;
}

game.DATA 文件

Hello,DATA!

我用什么来运行程序:

gcc game.c -o game $(pkg-config allegro-5 allegro_font-5 --libs --cflags)

--编辑--

我尝试将文件读取代码并在一个新的 c 文件中运行它,由于某种原因它在那里工作,但在带有 allegro 代码的 game.c 文件中时不起作用。

test.c

#include <stdlib.h>
#include <stdio.h>

char *readFile(const char *fileName) {
    FILE *file;
    file = fopen(fileName,tmp);
    return tmp;
}

void main() {
    char *text = readFile("game.DATA");
    printf("TEXT: %s\n",text);

    free(text);
    return 0;
}

总是产生正确的输出

TMP: Hello,DATA!
TEXT: Hello,DATA!

解决方法

当您编写一个每次都更新各种内容的循环时,就像您在这里的循环中使用 tmpSize 所做的那样,重要的是掌握理论计算机科学类型所称的“{{3} }”。也就是说,每次循环中什么是真的?重要的是不仅要正确维护您的循环不变量,还要选择您的循环不变量,以便它们易于维护,并且便于以后的读者理解和验证。

由于 tmpSize 以 1 开头,我猜您的循环不变式正在尝试成为“tmpSize 始终比我目前阅读的字符串大小大一”。选择这个稍微奇怪的循环不变量的一个原因当然是,您需要额外的字节来终止 \0。另一个线索是您正在设置 tmp[tmpSize-1] = c;

但这是第一个问题。当我们退出循环时,如果 tmpSize 仍然比您目前阅读的字符串大小多 1,让我们看看会发生什么。假设我们读了三个字符。所以 tmpSize 应该是 4。所以我们将设置 tmp[4] = 0;。可是等等!请记住,C 中的数组是从 0 开始的。所以我们读取的三个字符在 tmp[0]tmp[1]tmp[2] 中,我们希望终止 \0 字符进入 tmp[3],而不是 {{ 1}}。出了点问题。

但实际上,情况比这更糟糕。我完全不确定我是否理解循环不变式,所以我作弊,并插入了一些调试打印输出。就在 tmp[4] 调用之前,我添加了

realloc

最后,就在 printf("realloc %zu\n",tmpSize); 行之前,我添加了

tmp[tmpSize] = 0;

它打印的最后几行(在读取包含“Hello,DATA!”的 game.DATA 文件时,就像你的一样)是:

printf("final %zu\n",tmpSize);

但是这是两个!如果最后一次重新分配给数组的大小为 12,则有效索引是从 0 到 11。但不知何故,我们最终将 ... realloc 10 realloc 11 realloc 12 final 13 写入单元格 13。

我花了一段时间才弄明白,但第二个问题是在循环的顶部进行重新分配,然后再增加 \0

对我来说,“比目前读取的字符串大小多一个”的循环不变量实在是太难以想象了。我非常喜欢使用循环不变式,其中“大小”变量跟踪我读取的字符数,而不是 +1 或 -1。让我们看看这个循环会是什么样子。 (我还清理了其他一些东西。)

tmpLen

这里仍然有些可疑——我说我不喜欢像 +1 这样的“捏造因素”,而这里我有 两个——但至少现在调试打印出来了

size_t tmpSize = 0;
char *tmp = malloc(tmpSize+1);
if (tmp == NULL) {
    printf("malloc() failed.\n");
    exit(1);
}

for (int c = getc(file); c != EOF; c = getc(file)) {
    printf("realloc %zu\n",tmpSize+1+1);
    tmp = realloc(tmp,tmpSize+1+1);        /* +1 for c,+1 for \0 */
    if (tmp == NULL) {
        printf("realloc() failed.\n");
        exit(1);
    }
    tmp[tmpSize] = c;
    tmpSize++;
}

printf("final %zu\n",tmpSize);
tmp[tmpSize] = '\0';

所以看起来我不会再超出分配的内存了。

为了让这更好,我想采取一种稍微不同的方法。一开始你不应该担心效率,但我可以告诉你,每次读取一个字符时,调用 ... realloc 11 realloc 12 realloc 13 final 12 使缓冲区大 1 的循环最终可能会 效率低下。因此,让我们再做一些更改:

realloc

现在有两个独立的变量:size_t nchAllocated = 0; size_t nchRead = 0; char *tmp = NULL; for (int c = getc(file); c != EOF; c = getc(file)) { if(nchAllocated <= nchRead) { nchAllocated += 10; printf("realloc %zu\n",nchAllocated); tmp = realloc(tmp,nchAllocated); if (tmp == NULL) { printf("realloc() failed.\n"); exit(1); } } tmp[nchRead++] = c; } printf("final %zu\n",nchRead); tmp[nchRead] = '\0'; 准确跟踪我分配了多少个字符,而 nchAllocated 跟踪准确我阅读了多少个字符。虽然我将“计数器”变量的数量增加了一倍,但这样做我简化了很多其他事情,所以我认为这是一个净改进。

首先,请注意根本就没有 +1 软糖因素。

第二,这个循环不会每次都调用 nchRead ——而是一次分配 10 个字符。并且因为分配的字符数和读取的字符数有不同的变量,所以它可以跟踪这样一个事实,即它分配的字符数可能比它目前读取的字符数多。对于此代码,调试打印输出为:

realloc

另一个小改进是我们不必“预分配”数组——没有初始 realloc 10 realloc 20 final 12 调用。我们的循环不变量之一是 malloc 是分配的字符数,我们从 0 开始,如果没有分配字符,那么 nchAllocated 可以从 NULL 开始。这依赖于这样一个事实,即当您第一次调用 tmp 时,realloc 等于 tmpNULL 对此没有问题,并且本质上就像 {{1 }}。

但是你可能会问一个问题:如果我摆脱了所有的捏造因素,我们在哪里安排分配一个额外的字节来保存终止符 realloc ?它就在那里,但很微妙:它潜伏在测试中

malloc

第一次循环时,\0 将为 0,而 if(nchAllocated <= nchRead) 将为 0,但此测试为真,因此我们将分配第一个 10 个字符块,然后我们正在运行。 (如果我们不关心 nchAllocated 字符,测试 nchRead 就足够了。)

...但是,实际上,我犯了一个错误!这里有一个微妙的错误!

如果读取的文件是空的怎么办? \0 将从 nchAllocated < nchRead 开始,我们永远不会在循环中进行任何行程,因此 tmp 将保持为 NULL,因此当我们分配 tmp 时会爆炸。

实际上,情况比这更糟糕。如果您非常仔细地跟踪逻辑,您会发现任何时候文件大小正好是 10 的倍数,毕竟没有为 NULL 分配足够的空间。

这表明“一次分配 10 个字符”方案的一个显着缺点。代码现在更难测试,因为对于大小为 10 的倍数的文件,控制流是不同的。如果你从来没有测试过这种情况,你就不会意识到这个程序有错误。

我通常解决此问题的方法是注意到我必须添加以终止字符串的 tmp[nchRead] = 0 字节与我读取的指示文件结尾的 \0 字符有所平衡。也许,当我阅读\0时,我可以用它来提醒我为EOF分配空间。这实际上很容易做到,它看起来像这样:

EOF

这里的技巧是我们不会测试 \0,直到之后我们检查了缓冲区中有足够的空间,并在必要时调用 int c; while(1) { c = getc(file); if(nchAllocated <= nchRead) { nchAllocated += 10; printf("realloc %zu\n",nchAllocated); if (tmp == NULL) { printf("realloc() failed.\n"); exit(1); } } if(c == EOF) break; tmp[nchRead++] = c; } printf("final %zu\n",nchRead); tmp[nchRead] = '\0'; 。就好像我们在缓冲区中为 EOF 分配了空间——除非我们将该空间用于 realloc。这就是我所说的“用它来提醒我为 EOF 分配空间”的意思。

现在,我不得不承认这里仍然存在一个缺点,即循环现在有些非常规。顶部有 \0 的循环看起来像一个无限循环。这个有

\0

在它的中间,所以它实际上是一个“中间中断”循环。 (这与传统的 while(1)if(c == EOF) break; 循环相反,它们是“在顶部中断”,或者 for/while 循环是“在顶部中断”底部”。)就个人而言,我发现这是一个有用的习语,我一直在使用它。但是一些程序员,也许是你的导师,会不屑一顾,因为它“奇怪”,“不同”,“非常规”。并且在某种程度上他们是对的:非常规编程是有些危险的编程,如果后来的维护程序员无法理解它,因为他们不认识或不理解其中的习语,那是很糟糕的。 (这有点类似于英语单词“ain't”的编程,或者是一个分裂的不定式。)

最后,如果你还在我身边,我还有一点要说明。 (如果你还在我身边,谢谢。我知道这个答案已经,但我希望你能学到一些东西。)

之前我说过,“每次读取一个字符时,调用 do 使缓冲区增大 1 的循环最终会变得非常低效。”事实证明,使缓冲区大 10 的循环并没有好多少,而且仍然可能效率低下。您可以通过将其增加 50 或 100 来做得更好,但是如果您正在处理可能非常大的输入(数千个字符或更多),通常最好突飞猛进地增加缓冲区大小,也许通过将它乘以某个因素,而不是相加。所以这是循环那部分的最终版本:

while

即使是这种改进——乘以 2,而不是增加一些东西——也有代价:我们需要一个额外的测试,对循环的第一次旅行进行特殊处理,因为 realloc 开始时0,且 0 × 2 = 0。

,

您的重新分配方案不正确:数组总是太短一个字节,并且空终止符写在字符串末尾的一个位置,而不是在字符串末尾。这会导致打印额外的字节,realloc() 返回的块中的任何值恰好位于内存中,该块未初始化。

使用 tmpLen 作为到目前为止读取的字符串的长度并为新读取的字符和空终止符分配 2 个额外的字节会减少混淆。

此外,测试 c != NULL 没有意义:c 是字节,NULL 是指针。同样,tmp[tmpSize - 1] = (char *)c; 是不正确的:你应该只写

tmp[tmpSize - 1] = c;

这是一个更正的版本:

char *readFile(const char *fileName) {
    FILE *file = fopen(fileName,"r");

    if (file == NULL) {
        printf("File could not be opened for reading.\n");
        return NULL;
    }

    size_t tmpLen = 0;
    char *tmp = (char *)malloc(tmpLen + 1);

    if (tmp == NULL) {
        printf("malloc() could not be called on tmp.\n");
        fclose(file);
        return NULL;
    }

    int c;
    while ((c = fgetc(file)) != EOF) {
        char *new_tmp = (char *)realloc(tmp,tmpLen + 2);
        if (new_tmp == NULL) {
            printf("realloc() failure for %zu bytes.\n",tmpLen + 2);
            free(tmp);
            fclose(file);
            return NULL;
        }
        tmp = new_tmp;
        tmp[tmpLen++] = c;
    }
    tmp[tmpLen] = '\0';

    fclose(file);
    printf("TMP: %s\n",tmp);
    return tmp;
}

通常最好以块或几何尺寸增量重新分配。这是一个简单的实现:

char *readFile(const char *fileName) {
    FILE *file = fopen(fileName,"r");

    if (file == NULL) {
        printf("File could not be opened for reading.\n");
        return NULL;
    }

    size_t tmpLen = 0;
    size_t tmpSize = 16;
    char *tmp = (char *)malloc(tmpSize);
    char *newTmp;

    if (tmp == NULL) {
        printf("malloc() could not be called on tmp.\n");
        fclose(file);
        return NULL;
    }

    int c;
    while ((c = fgetc(file)) != EOF) {
        if (tmpSize - tmpLen < 2) {
            size_t newSize = tmpSize + tmpSize / 2;
            newTmp = (char *)realloc(tmp,newSize);
            if (newTmp == NULL) {
                printf("realloc() failure for %zu bytes.\n",newSize);
                free(tmp);
                fclose(file);
                return NULL;
            }
            tmpSize = newSize;
            tmp = newTmp;
        }
        tmp[tmpLen++] = c;
    }
    tmp[tmpLen] = '\0';

    fclose(file);
    printf("TMP: %s\n",tmp);

    // try to shrink allocated block to the minimum size
    // if realloc() fails,return the current block
    // it seems impossible for this reallocation to fail
    // but the C Standard allows it.
    newTmp = (char *)realloc(tmp,tmpLen + 1);
    return newTmp ? newTmp : tmp;
}