Vince Jiang

CSCI3150 / 实验 · 本站更新

CSCI3150 Lab 2:C 内存、文件 I/O 与结构体伴读

课程:CSCI3150 本讲:Lab 2 · Lab02-C基础与文件IO-2026T1.pdf(本地课件) p.1–16 定位:逐页读懂内存由谁管理、文件读写的位置如何变化,以及传结构体和传地址的区别;把这些知识连接到奇偶行求和练习。 前后:环境基础 Lab01 编译与 Makefile · 动手入口 Lab02 操作教程 承接:#L03-02 Malloc、#L03-03 Sizeof、#L03-04 Free、#L03-06 Initialization、#L03-07–09 内存错误;#L04-02 Descriptor、#L04-03 Offset

PDF 与 ZIP 由用户提供;本地按当前课程单元记为 2026T1,PDF 封面未印学期。页码全部为 PDF 页号。原件只有主题目录,没有编号 Part,故一份伴读覆盖全部 16 页。

接口要求已澄清(2026-09-26 收到公告):PDF p.15 原指定 fopen/fscanf/fprintf/fclose;ZIP 的 README 与骨架原指定 open/read/write/close。根据用户提供的公告转贴(本地资料),允许任意 C 文件 I/O 方法,两套接口均可,无须再确认接口优先级。只提交 exercise.c,不修改 Makefile。PDF p.15 还同时写了“至少 2 个数”和“超过 2 行”;不硬编码行数即可兼容两者。

Lab 2 Contents

这一讲为什么先复习 C(p.1–2)【新】

p.1 是标题页,标题为 CSCI 3150 Lab2,署名 Jasper(Jiqian ZHANG)。

p.2 These concepts are essential for you to finish later labs/ass/proj.

这一讲的三个主题分别解决不同问题。内存管理回答“数据放在哪里、活多久”;文件 I/O 回答“怎样把磁盘上的字节带进程序,再把结果写出去”;结构体回答“怎样把几个相关字段组成一个对象”。它们是后续系统编程的 C 基础,而不是新的进程调度算法。

p.2 公布 Lab Exercise 2,并写截止日期 Oct. 1st。附件未给具体时刻与时区,不补写成 23:59;应以 Blackboard 提交项为准。当前练习直接考文件读取、按行分组求和、输出格式;内存和结构体属于本讲学习内容,不代表练习强制要求用堆数组或结构体。

Memory Management

自动对象什么时候结束(p.3–4)【新】

p.3 是分节页,列 Stack vs Heap、malloc/free 和 Common pitfalls。

p.4 Stack Memory: Static size, managed by the compiler automatically. E.g.: local variables, function parameters…

图(p.4):函数内局部变量的代码截图。
元素:int x = 10;、char buffer[100];,以及右花括号旁“Memory automatically freed here”的注释。
看什么:这两个对象属于该次函数执行;离开函数后不能继续使用它们的地址访问旧对象。

把调用想成一次临时工作:进入函数,建立本次所需的局部对象;执行期间读写它们;结束函数,这些自动对象的生命周期终止。调用两次是两次执行,不能把上一次的局部数组当成永久仓库。这里的“自动释放”不是你应当对 buffer 调用 free;它没有来自 malloc。

补充:讲义用栈描述常见实现。C 语言更精确的概念是自动存储期;编译器也可能把变量放寄存器或优化掉。“Static size”不是说这些局部变量使用了 C 的 static 关键字。

p.4 下半页说栈对象大小必须编译时确定。该说法需要结合下一页的 VLA 补充,不能单独背成“C 禁止运行时大小的局部数组”。

p.4 图片中的完整片段:

void function() {
    int x = 10;          // Stack memory
    char buffer[100];    // Stack memory
} // Memory automatically freed here

这里没有 malloc,所以末尾不写 free。x 的初值明确为 10,buffer 的各字节则未初始化;自动管理寿命不等于自动给数组填零。

变长数组能编译,但不是无限空间(p.5)【新】

p.5 C supports variable-length arrays in some language standards and implementations.

p.5 Use dynamic allocation when you need explicit control over size and lifetime.

图(p.5):用户输入大小后声明两种局部数组。
元素:num_entries 经 scanf 输入;my_static_array[100] 固定长度;my_dyn_arr[num_entries] 使用运行时长度;截图旧注释说后者可能出现“array size must be constant”。
看什么:截图注释不能覆盖本页上方已经明确写出的 VLA 支持条件。

补充与核对:GCC 文档说明 C99 支持变长自动数组,GCC 也在部分其他模式中作为扩展接受。当前 Docker 中使用 GCC 9.4.0、-std=c99 -pedantic,运行时取 6 或 3 的 VLA 都编译运行成功。因此“运行时长度就一定报错”不适用于本次环境。GCC VLA 文档

算一遍:长度 6 和 3 是我为演示取的,讲义没给。在本容器 sizeof(int)=4,所以两次局部数组分别占 24 和 12 字节。动态长度改变了空间需求,没有改变离开作用域就结束的生命周期。长度很大仍可能耗尽栈;不能用“这一次能编译”证明任意长度都安全。

这也解释了 p.5 的问题“巨大的数组放不进栈怎么办”:需要考虑堆分配,或者改变算法,不把所有数据同时存下来。选择依据包括大小、寿命和分配失败处理,不能只看语法能否通过。

p.5 图片代码(保留其有争议的注释)。

#include <stdio.h>
int main() {
    printf("How many numbers do you want to store? ");
    int num_entries;
    scanf("%d", &num_entries);
    int my_static_array[100]; // Ok! Compiler knows the size
    int my_dyn_arr[num_entries]; // High chance to trigger error
    // "array size must be constant"
}

编译模式支持 VLA 时,最后两行注释不是必然结果。还须先确认 scanf 成功且长度为正,再考虑栈容量;示例没有做这些输入检查,不能拿任意负数或巨大数直接当有效长度。

malloc 给空间,free 结束使用(p.6)【延伸 L03 p.5–7】

p.6 Flexible lifetime management (Managed by you)

p.6 To use them, include the <stdlib.h> header file.

承接 #L03-02、#L03-04(本地资料)。malloc 接收字节数,成功返回起始地址,失败返回 NULL;free 接收合法分配块的地址,将该块归还给分配器。malloc 得到的内容未初始化,不能把“得到空间”当成“里面已经全是零”。

例如 int *p = malloc(6 * sizeof *p); 中,p 是一个存地址的局部变量,而它引用的六个 int 位于动态分配的存储中。p 本身和它指向的对象不是同一块东西。离开函数让局部 p 消失,不会自动替你释放它所指的堆块;反之 free(p) 释放的是块,不会把所有指向它的变量自动改成 NULL。

图(p.6):右上角是“HEAP MEMORY: LARGER BUT SLOWER TO ACCESS”的配图。
元素:仓库式图片和大小、速度的概括文字;没有性能实验或访问时延数值。
看什么:它只是概括印象,不能据此得出“每次堆访问都一定比栈访问慢”。

补充:应区分分配管理开销和分配后访问数据的成本。堆也受资源限制,不保证有可用 GB 空间;必须检查 NULL。本节接口细节可核对 malloc(3)。

按元素数计算字节数(p.7)【延伸 L03 p.6、8–11】

p.7 the parameter for malloc is not num_entry, but num_bytes

图(p.7):生成偶数数组的完整代码与三个分配表达式。
元素:读取 num_entries;malloc(num_entries * sizeof(int));NULL 检查;循环写入 i * 2 并打印;最后 free;另一图列整数数组、字符串的 length + 1、矩阵的 rows * cols。
看什么:先决定对象个数,再乘每个对象的字节数;分配成功后先写值,再读值。

算一遍:沿用原页序列 0,2,4,6,8,10,选前六个元素演示。容器中 int 为 4 字节,申请 6 × 4 即 24 字节。循环下标 0、1、2、3、4、5 对应写入 0、2、4、6、8、10。最后合法下标是 5,不是 6;i < num_entries 的严格小于保证这一点。

若只写 malloc(6),申请的是 6 字节,不是六个 int。若对 p 使用 sizeof p,本容器得到的是指针大小 8,而不是堆块大小 24。程序需要自己保存元素数;指针不会附带一份可由 sizeof 自动取出的长度。

字符串示例多出的 1 给终止字符 \0。例如原页后续结构体中的 Lab02 有五个可见字符,作为 C 字符串至少需要六个字节。矩阵表达式表示一整块连续数据;补充:这种一维块可以按 row * cols + col 映射二维下标,不能直接当成已建立行指针的 double **。

分配表达式还应防止负大小、输入失败和乘法溢出;这是原截图省略的工程检查。对应知识已有 #L03-03、#L03-05、#L03-06(本地资料)

p.7 完整主程序(图片转写):

#include <stdio.h>
#include <stdlib.h>
int main() {
    printf("How many numbers? ");
    int num_entries;
    scanf("%d", &num_entries);
    /* Now we can create dynamic arrays! */
    int *my_arr = malloc(num_entries * sizeof(int));
    if (my_arr == NULL) {
        printf("Not enough memory!\n");
        return 1;
    }
    for (int i = 0; i < num_entries; i++) {
        my_arr[i] = i * 2;
        printf("%d ", my_arr[i]);
    }
    free(my_arr);
    return 0;
}

同页另外三个分配表达式是:

int *numbers = malloc(count * sizeof(int));
char *string = malloc((length + 1) * sizeof(char));
double *matrix = malloc(rows * cols * sizeof(double));

字符串多出的 1 给终止零字符;矩阵表达式申请连续 rows×cols 个 double,访问第 r 行第 c 列可用下标 r×cols+c(补充,按行存放时)。这些都是分配表达式,失败检查、输入为正与乘法不溢出仍须由调用方保证。

三种错误的生命周期区别(p.8)【延伸 L03 p.12–14】

p.8 Forgetting to free() → memory leaks

p.8 Using array after free() → undefined behavior

p.8 Freeing same memory twice → crash

第一种是对象不再需要,却一直没有归还空间;第二种是已经归还,还继续访问;第三种是已经归还,又归还一次。它们分别对应遗漏释放、释放后访问、重复释放。

补充(精确化原页说法):重复释放属于未定义行为,可能崩溃,但“不崩溃”不能证明合法。释放后访问也可能暂时看见旧值;内存没有立刻被覆盖,不等于对象还活着。malloc(3)

用最短的生命周期追踪理解:malloc 成功 → 可以初始化和访问 → free → 禁止经旧地址继续访问。设置 p = NULL 只清空 p,若先前还有 q 指向同一块,q 仍悬空。free(NULL) 可安全调用,但不能用它证明所有别名都已处理。

原页建议 -Wall,这是请求编译器尽可能发出一组常见警告,不是运行时内存正确性的保证。编译无警告仍可能越界。考试角度:(C 级通识)这类知识常见考法是给一段分配、赋值、释放的代码,指出哪一行首次越过对象生命周期,而不只是说“程序可能崩”。

File I/O

open 返回的是描述符(p.9)【延伸 L04 p.6–7】

p.9 Step 1: Open a file, get a file descriptor (Conceptually: get file variable)

承接 #L04-02(本地资料)。open 找到路径对应的文件并建立打开状态,返回整数 fd。之后 read/write 使用 fd,不必每次重传文件路径。失败时返回 -1;0 是合法描述符,所以不能用 fd <= 0 判断失败。

图(p.9):两种 open 调用截图及文件标志列表。
元素:带 O_CREAT 的调用还传 S_IRUSR | S_IWUSR;另一个调用只有路径和 O_RDWR;右侧列六个标志。
看什么:打开方式与创建时权限是不同参数,不能混为一组。

标志 作用 容易误解处
O_RDONLY 只读 读取已有输入通常够用
O_WRONLY 只写 不自动创建、不自动清空
O_RDWR 可读可写 不表示调用 read 就会修改文件
O_CREAT 不存在则创建 要提供第三个 mode 参数
O_TRUNC 可写打开已有普通文件时清空 会丢掉该文件现有内容
O_APPEND 追加写 不适合每次重写一份两行结果

补充:O_WRONLY | O_CREAT | O_TRUNC 是按位组合标志;不是管道运算。读写访问模式三选一,不把 O_RDONLY/O_WRONLY/O_RDWR 全部相或。mode 的 S_IRUSR | S_IWUSR 请求所有者读写,实际创建权限还受 umask 约束;已有文件不会因本次 mode 参数而重新设置权限。O_APPEND 会在每次写入时定位文件尾。open(2)

教程中的 path/to/file 是演示路径,使用时必须替换。input.txt 这种相对路径从程序的当前工作目录解析,不从 .c 所在位置自动解析。

p.9 的两个完整调用形式:

int file = open("path/to/file", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR);
int file = open("path/to/file", O_RDWR);

第一种在文件不存在时创建,第三参数给所有者读写权限;第二种没有创建标志,路径不存在时失败。两块是替代示例,不是在同一作用域重复声明 file。课件图第一行末尾漏分号,这里为可读片段补齐。O_RDONLY/O_WRONLY/O_RDWR 分别只读、只写、读写;O_APPEND 使写操作追加,O_CREAT 允许创建,O_TRUNC 在可写打开已有普通文件时截断为 0。打开与创建的权限问题,不能通过把任何错误都改成 O_RDWR 来解决。

read/write 的计量单位是字节(p.10)【新】

p.10 read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.

p.10 write() writes up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.

关键是 up to:count 是本次最多请求多少,不是保证实际读写多少。返回值告诉你实际数量,负数表示错误。对请求正字节数的普通文件读取,返回 0 表示已到文件尾;返回少于 count 的正数仍是有效数据。read(2)

图(p.10):读取示例与写入 Hello, World 的示例。
元素:左侧 char read_buffer[20] 配合读取 14 字节;右侧字符串 Hello, World!\n 配合 strlen 写出;两边都检查负返回值并关闭描述符。
看什么:缓冲区容量、请求数量、实际返回数量是三个量。

算一遍:右侧 Hello, World!\n 有 14 个字节:Hello 五个、逗号一个、空格一个、World 五个、感叹号一个、换行一个。strlen 返回 14,不包含内存中结尾的 \0。若文件内容正好是这 14 字节,左侧成功读 14 后,只有 read_buffer[0] 到 [13] 是这次读取所得;read 不替它追加 \0。

因此原示例只报告字节数可以理解,但若自行新增 printf("%s", read_buffer),就必须先处理字符串终止。安全演示可预留一个字节,最多读容量减一,并在已确认非负的返回位置写 \0。不要在返回 -1 时拿负下标写数组。

补充:真实文件读取不是“每次 read 恰好一行”,读块可能停在数值中间,也可能一次含多行。写入同样需要按返回数量推进,短写后继续写剩余部分。p.10 的正数分支没有完整处理短写,它是接口演示,不应当成生产级写全函数。

本页展示描述符 API;练习 PDF 要求的流 API 则用 FILE *。两套接口的对照见附录 A1。

补充:短写后的下一次参数怎样变化。 设已生成6字节结果文本 43\n42\n,第一次write请求6却成功返回2(数字用于解释部分传输,不是声称普通小文件必然如此)。这说明前两个字节43已交给接口;下一次应从缓冲区第2字节的换行开始,请求余下4字节。若再次从开头请求6,会重复43;若直接结束,会缺少两个换行及后续42。

可把进度记为done:初值0;每次正返回n就done增加n,余量=总长−done,下一起点=缓冲首地址+done。失败时不增加done;被信号打断、其他错误以及不产生进度的情况须按接口条件分别处理,不能无限循环盲重试。读取时同理只处理本次实际返回的字节,而不是假装整个缓冲区都刚刚更新过。

自测:read请求20返回14,缓冲区后6字节是什么?

只能说这次read没有为那6字节提供新数据。它们可能保留旧内容,也可能尚未初始化;不能当文件内容继续解析。若要作为字符串显示,还须在有容量的位置显式建立终止标记,或使用带长度的输出方式。

p.10 左侧读取片段(按截图重排缩进):

int fd;
fd = open("input_test.txt", O_RDWR);
if (fd < 0) {
    perror("Error opening file");
    return 1;
}
char read_buffer[20];
ssize_t bytes_read = read(fd, read_buffer, 14);
if (bytes_read < 0) {
    perror("Error reading from file");
    close(fd);
    return 1;
} else {
    printf("read %ld bytes to file\n", bytes_read);
}
close(fd);

p.10 右侧写入片段:

int wfd;
wfd = open("input_test.txt", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR);
if (wfd < 0) {
    perror("Error opening file");
    return 1;
}
const char *write_buffer = "Hello, World!\n";
ssize_t bytes_written = write(wfd, write_buffer, strlen(write_buffer));
if (bytes_written < 0) {
    perror("Error writing to file");
    close(wfd);
    return 1;
} else {
    printf("wrote %ld bytes to file\n", bytes_written);
}
close(wfd);

原读取输出文字用“to file”,但操作实际是从文件读入内存。两个片段都是一次系统调用演示,未补短读/短写循环;写片段没有 O_TRUNC,覆盖开头后可能留下旧文件尾部。原页把缓冲参数画成 void buf[count] 是表达可访问区域的示意,不能照写成 void 元素数组声明。常用 C 声明中的缓冲参数分别是 void *buf 和 const void *buf;ssize_t 是允许负值的返回计数,不能无条件存成无符号数再判断小于 0。

补充:一次短读结束在哪里,下一次就从哪里接。 对可定位的普通文件,返回的正字节数既是本次缓冲区新增的有效范围,也是文件偏移的推进量。请求比返回值大,并不要求程序把剩余缓冲空间当作零,更不应再用 lseek 把偏移人为增加整个请求数,否则会跳过尚未读取的内容。

还要把 EOF 与零长度请求分开:请求正长度而返回 0 才能在这里用来判断读到文件尾;本来就请求 0 字节,正常也会返回 0,却没有测试后面是否还有数据。错误返回 −1 则走错误分支,不能把它强制转成一个很大的无符号长度。读循环的控制必须建立在这三个分支上,才不会把空操作、文件尾和失败混成同一个“读不到”。接口核验:read(2)

lseek 移动的是文件偏移(p.11)【延伸 L04 p.8–9】

p.11 lseek() helps us to move this internal “pointer” to a desired place.

这里的“pointer”是文件偏移,不是 C 内存地址。它表示下一次从文件哪个字节位置读写。读了多少、写了多少,通常就推进多少。原页说先读 10 再写 10 在“10–20”位置,应按从 0 开始理解为写偏移 10 到 19,完成后偏移为 20。

图(p.11):三种 whence 的手册截图和 Hello World 示例。
元素:SEEK_SET 以文件开头为基准,SEEK_CUR 以当前位置为基准,SEEK_END 以文件大小为基准;示例写 20 字节,回到 0 读取,再定位 6 读取。
看什么:偏移从 0 开始,lseek(fd,6,SEEK_SET) 到下标 6,也就是第七个字节。原代码“6th bytes”的注释不够准确。

算一遍:代码使用 char buf[20]="Hello World!"。可见文本 12 字节,后面八个字节被初始化为零;write 的数量写的是 20,不是 strlen。因此假设系统调用成功完成演示数量:

操作 操作前偏移 本次实际数量 操作后偏移 显示为字符串
写 buf 的全部 20 字节 0 20 20 文件含文本与零字节
seek 到 0 20 不读写 0 不改变文件
请求读 20 0 20 20 Hello World!
seek 到 6 20 不读写 6 W 的位置
请求读 20 6 14 20 World!

最后一次文件只剩 14 字节,所以不能读到 20。本容器中教学示例实测符合此表。显示 World! 是因所读内容中有原文件内的零字节,而不是 read 自动补零。

补充:文件中间写入通常覆盖同样数量的字节,不会自动把后半段整体后移;seek 本身不插入内容。偏移操作与磁盘字节、内存地址必须分别画清。考试角度:(C 级通识)给定读写和 seek 序列,逐步求偏移、返回字节数与最终文件内容。

p.11 的代码顺序完整转写(这是函数体片段,原图没有头文件和 main 外框)。

char buf[20] = "Hello World!";
char rbuf1[20], rbuf2[20];
int fd;
if ((fd = open("myfile", O_CREAT | O_TRUNC | O_RDWR,
               S_IRUSR | S_IWUSR)) < 0) {
    printf("Error in open()\n");
    exit(-1);
}
printf("fd is %d\n", fd);
/* Write 20 bytes */
if (write(fd, buf, 20) < 0) {
    printf("Error in write()\n");
    exit(-1);
}
/* Move internal pointer to the beginning */
if (lseek(fd, 0, SEEK_SET) < 0) {
    printf("Error in lseek()\n");
    exit(-1);
}
if (read(fd, rbuf1, 20) < 0) {
    printf("Error in read()\n");
    exit(-1);
}
printf("rbuf1: %s\n", rbuf1);
/* Go to the 6th bytes -- 原图注释;实际为偏移6 */
if (lseek(fd, 6, SEEK_SET) < 0) {
    printf("Error in lseek()\n");
    exit(-1);
}
if (read(fd, rbuf2, 20) < 0) {
    printf("Error in read()\n");
    exit(-1);
}
printf("rbuf2: %s\n", rbuf2);
close(fd);
return 0;

这段是用于对照前面偏移表的原逻辑,不能把只检查 <0 当作短写处理已经完成。若文件内容或实际读取数量改变,%s 需要的终止零也必须重新保证。三种 whence 的完整关系为:SEEK_SET 取 offset,SEEK_CUR 取当前偏移加 offset,SEEK_END 取文件大小加 offset。

Struct

为什么把几个字段放进同一个对象(p.12)【新】

p.12 Motivation: we want to use higher level abstraction to represent some concepts beyond numbers and characters. For example, a book.

图(p.12):Book 类型定义与通用结构体语法。
元素:title[50]、author[50]、subject[100]、book_id;右花括号后带分号。
看什么:这是描述“一本书有哪些字段”的类型,不是已经读入的一本具体书。

定义 struct Book 后,声明 struct Book bookA; 才得到一个具体对象。bookA.book_id 选中该对象的编号;bookA.title 选中其标题数组。字段类型不同,但因为属于同一本书,所以用共同对象名组织。

三个字符数组是结构体对象内部的存储,不是三个自动分配的指针。声明这个类型也不会自动读文件、自动初始化字段或分配堆空间。补充:对象可以作为局部变量,也可以用 malloc 分配;struct 是数据组织方式,stack/heap 是存储与寿命问题,两者是不同维度。对象大小还可能含对齐填充,不应直接把字段大小相加当成通用 sizeof 结论。

struct 名和 typedef 名,以及截图里的字符错误(p.13)【新】

本页主要内容是三张代码图片,已视觉核对,不用标题冒充正文引用。

图(p.13):三种声明 Book 的写法。
元素:左图用 struct Book bookA;中图将 struct Book 定义为别名 MyBook;右图把匿名结构体定义为别名 Book;三图均给出成员赋值和 strcpy。
看什么:typedef 起的是类型别名,不是创建对象;三种语法都能表示同一组字段。

中间形式允许用 MyBook bookA,左边没有 typedef 时用 struct Book bookA。匿名结构体形式有别名 Book,却没有可引用的 struct Book 标签。不要在不同形式之间随意替换类型名字。

截图确实把 bookA.author[0] = "A" 写成了双引号,并漏了若干分号。补充(明确修正演示,不改原图):author[0] 是一个 char,应赋字符 'A';双引号的 "A" 是带终止符的字符数组表达式,不能直接赋给一个 char。若要让整个 author 表示字符串 A,还要写 author[1] 为 \0,或复制完整字符串。

/* 修正后的教学片段,非截图逐字转录 */
bookA.book_id = 1;
bookA.author[0] = 'A';
bookA.author[1] = '\0';
strcpy(bookA.title, "Lab02");

最后一行复制五个可见字符与终止符,占六个字节,放入 50 字节 title 足够;一般情形仍需检查目标容量。strcpy 使用 <string.h>。不能用 bookA.title = "Lab02" 给已经声明的数组整体赋字符串指针。

三种完整类型写法(p.12–13,字段不省略):

struct Book {
    char title[50];
    char author[50];
    char subject[100];
    int book_id;
};
typedef struct Book {
    char title[50];
    char author[50];
    char subject[100];
    int book_id;
} MyBook;
typedef struct {
    char title[50];
    char author[50];
    char subject[100];
    int book_id;
} Book;

三块是供比较的替代形式,不要把两个同名 struct Book 定义原样放在同一作用域。原图三份使用片段分别用 struct Book bookA;、MyBook bookA;、Book bookA; 声明,随后都设 book_id=1、尝试给 author[0] 赋 "A"、用 strcpy 复制 "Lab02"。字符错误及漏分号应按前面的修正理解。

按值传书,还是把书的地址传过去(p.14)【新】

p.14 change_id() will create a complete copy of bookA, and operate on that copy. (bookA’s id remains unchanged)

p.14 change_id_2() will not create a copy of bookB, the modification within it will take effect.

图(p.14):两个修改编号函数的对照。
元素:change_id(Book book, int id) 使用点号;change_id_2(Book* book, int id) 使用箭头;调用分别传 bookA 与 &bookB。
看什么:一个拿到整个对象的值,另一个拿到指向原对象的地址。

算一遍:原图调用目标值为 2、3,但没有初始化 bookA/bookB。为避免读取未初始化成员,演示明确把两者起始编号设为 1(这个初值是我补的,讲义没给):

时刻 原 bookA change_id 内的副本 原 bookB
调用前 1 尚无 1
change_id(bookA,2) 内 1 改成 2 1
第一个函数返回 1 生命周期结束 1
change_id_2(&bookB,3) 后 1 不涉及 3

点号访问对象本身的成员;箭头通过指针访问成员,book->book_id 相当于 (*book).book_id。&bookB 取原对象地址,因此改动落到原对象里。容器中的修正教学示例输出 bookA=1、bookB=3。

补充:C 的参数仍然都按值传递,第二个函数复制的是地址值,没有复制整本书。若在函数里把局部指针变量改为 NULL,不会把调用者的指针自动改成 NULL;通过该地址修改对象才会影响对象。数组成员会随结构体值复制;若成员是指针,则复制的是指针而非它指向的整块数据。依据 C11 委员会草案 N1570 §6.5.2.2、§6.5.16.1。

p.14 的完整函数与调用关系(按原图类型重排):

typedef struct {
    char title[50];
    char author[50];
    char subject[100];
    int book_id;
} Book;

void change_id(Book book, int id) {
    book.book_id = id;
    return;
}
void change_id_2(Book *book, int id) {
    book->book_id = id;
    return;
}

原图调用片段:

Book bookA, bookB;
change_id(bookA, 2);
change_id_2(&bookB, 3);

这是原图的片段,尚缺包围调用语句的函数及对象初始化。按值复制一个未初始化的 bookA 不是好的教学运行样例;前面的状态表已明确为演示补初值,不能把那个初值反说成原题给定。

Lab Exercise 与 Further Notes

练习要求怎样连接本讲知识(p.15–16)【新】

p.15 This exercise focuses on basic file I/O and a simple calculation.

p.16 Do not hardcode the solution by peeking the provided input.txt.

p.15 给出奇数行和偶数行分别求和的任务,p.16 强調评分使用不同 input.txt,并要求沿用 Lab1 的 Docker 或虚拟机环境。样例只能验证一种输入,不能把 43、42 写死,也不能把“十行”写成程序固定循环次数。

接口选择已由公告澄清:p.15 原指定 stdio 四函数,ZIP 中两份说明原指定 POSIX 四函数;用户提供的公告转贴(本地资料)明确允许任意 C 文件 I/O 方法。因此两条路线都符合接口要求,程序仍需满足输入输出要求,只提交 exercise.c,不修改 Makefile。

完整题面、样例逐步累加和两条处理路线见附录 A2。动手路径、编译、教学示例和样例比较见独立 操作教程。

考试角度:(A 级:本 PDF Lab Exercise,p.15–16,考查按行归类、读到 EOF、两行输出)这是本次练习的直接要求;结构体和堆数组没有被列为本练习必须使用的机制。

本讲核心考点

  • 自动对象与动态分配对象的寿命不同;VLA 支持不能被概括为“所有栈数组长度都必须编译时确定”。(p.4–6,C 级通识)
  • malloc 按字节分配,乘 sizeof;指针大小不是堆块大小,分配不等于初始化。(p.6–7,C 级通识)
  • 泄漏、释放后访问、重复释放要按生命周期区分,编译无警告不证明安全。(p.8,C 级通识)
  • open 的 fd、标志和创建权限各有职责;清空和追加会产生不同输出文件。(p.9,C 级通识)
  • read/write 返回实际字节数,read 不保证一行也不保证补字符串结尾。(p.10,C 级通识)
  • lseek 改偏移,20 字节文件从偏移 6 最多再读 14 字节。(p.11,C 级通识)
  • 结构体按值传递不改变原成员;传地址可修改原对象;字符与字符串不可混用。(p.12–14,C 级通识)
  • 奇偶指行号而非数值,读到 EOF,输出奇数行和再输出偶数行和。(p.15–16,A 级:Lab Exercise)

附录

A1 前置知识速补:FILE 流与 fd、字节与整数(非讲义内容)

这里补齐 p.10 到 p.15 跨越的接口层次;公告已确认两套方法均可用于练习。

任务 POSIX 描述符接口 C 标准 I/O 流接口
打开 open 返回 int fd,失败 -1 fopen 返回 FILE 指针,失败 NULL
输入 read 传字节,自己解释文本 fscanf 按格式解析文本
输出 write 传字节,自己生成文本 fprintf 按格式生成文本
关闭 close(fd) fclose(stream)

FILE 流在 Linux 上通常管理底层描述符和缓冲状态,但它不是那个整数本身。不要对 FILE 指针直接调用 read,也不要把 open 返回值传给 fscanf。可以先完整使用一套接口,避免混用时缓冲和偏移不同步。模式 r 打开已有输入,w 创建或截断输出。参见 fopen(3)。

文本 12 在文件里是字符 1、2,不是把一个 int 的二进制表示直接存进去。fscanf 的 %d 负责转换,并需要 &value 作为写入目标。返回 1 表示这一个转换成功;0 表示匹配失败;EOF 表示首个转换前遇到输入结束或读取错误。要通过 ferror 区分错误,不能把所有 EOF 返回都报告成正常完成。fscanf(3)

练习保证每行恰好一个有效整数,所以按成功转换次数计数可对应行号。但 %d 会跳过空白,不是在任意格式中严格数物理行;如果存在空行,“读到的第几个数”与“第几行”就可能不同。本题输入契约使它们相同,不能把这种简化推广为通用行解析器。

本次 ZIP 样例使用 CRLF,即一行后面是 \r\n;Linux 常用 LF。fscanf 的整数转换能跨过这些空白。使用 read 自行分行时,可在 \n 上结束一行,对该行末尾可能的 \r 做明确处理。sscanf 从已有字符串解析,sprintf/snprintf 把数值格式化为字符串;它们本身不是文件读写接口。

回到正文:read/write 的计量单位(p.10)

A2 作业/往年题演练(非讲义内容):Lab Exercise 2

题面来源:Lab02-C基础与文件IO-2026T1.pdf · Lab Exercise · p.15–16。以下先列完整实现要求,再演算样例。

p.15 This exercise focuses on basic file I/O and a simple calculation.

input.txt contains at least 2 integer numbers, one per line.

Every number is smaller than 1000 in absolute value.

Add the numbers on odd-numbered lines (1, 3, 5, ...). Write this sum on the first output line.

Add the numbers on even-numbered lines (2, 4, 6, ...). Write this sum on the second output line.

Read until the end of input.txt; the file may contain any number of lines as long as it has more than 2.

Use fopen(), fscanf(), fprintf() and fclose().

Please only submit the exercise.c. Do not modify the Makefile.

Tips: 1. The newline character in Linux is ‘\n’

  1. sscanf: You can use this function to convert char array to int (reference: http://www.cplusplus.com/reference/cstdio/sscanf/ ).

  2. sprintf: You can use this function to convert int to char array (reference: http://www.cplusplus.com/reference/cstdio/sprintf/ ).

p.16 Do not hardcode the solution by peeking the provided input.txt. We will use a different input.txt for testing. (Same format, different numbers, all assumption still holds)

p.16 In lab02 folder, there are two sample files, sample_input.txt and sample_output.txt, you can check the expected result for references. (Or use it for your own testing purpose)

p.16 We will use the required environment (either docker or virtual machine) for the testing. Please use the required environment as in lab1. If your program can not compile, you might get 0 for this lab.

ZIP README 的冲突原文(无页码,不冒充 PDF 引文):Use open(), read(), write() and close() for file I/O. 骨架注释第 5–6 条同样指定 open/read、open/write/close。这是附件原文的历史差异,后续公告已允许任意 C 文件 I/O 方法。共同要求是奇偶行分组、output.txt 两行、输入有效、不写死、不改 Makefile。

样例题面(教师 ZIP:sample_input.txt、sample_output.txt,无页码):输入依次为以下十行,官方输出为 43、42 两行。原文件的 CRLF 仅在下面显示为普通换行。

12
5
8
20
3
7
11
4
9
6
43
42

从两个和均为 0 开始,每读取一个完整整数,按该整数所在行号决定更新哪一项:

行号 值 更新对象 更新后奇数行和 更新后偶数行和
1 12 奇数行 12 0
2 5 偶数行 12 5
3 8 奇数行 20 5
4 20 偶数行 20 25
5 3 奇数行 23 25
6 7 偶数行 23 32
7 11 奇数行 34 32
8 4 偶数行 34 36
9 9 奇数行 43 36
10 6 偶数行 43 42

第一行的 12 虽是偶数,仍加到奇数行和;这足以排除“按数值奇偶分组”的误解。独立复核:12+8+3+11+9=43,5+20+7+4+6=42,全部数总和 85 与两组和相加一致。

stdio 路线:以只读方式打开输入;每次 fscanf 成功转换一个整数才更新对应和并切换奇偶;读完检查是否错误;以覆盖模式打开输出,fprintf 写两行,检查写入与关闭结果。不要用 while (!feof(...)) 先判断再无条件累加,因为 EOF 指示通常在读取失败后才置位,可能重复处理上一个值。

POSIX 路线:open 输入,read 获取块。必须保留跨块的半行:例如输入开头的 12\r\n5\r\n 若本次只读到 1,先留着;下次到 2\r\n 才组合成完整第一行并转换。一次块里也可能含多条完整行,要逐行处理。到 EOF 时,若还留有无末尾换行的有效最后一行,也处理一次。将两个和格式化成含两个换行的文本,再 open 输出并循环 write,最后 close 两端。块边界绝不等于行边界。

两条路线都只需当前输入状态、奇偶状态与两个累加器,无需保存所有整数,因此文件行数增加不必使内存跟着增加。本题并不要求为了练习 malloc 就先建一个大数组。

补充(数值边界):单个整数绝对值小于 1000,不意味着无限多行累加仍小于 int 上限。实际实现可用更宽累加类型并考虑溢出检查。有限整数类型不能严格覆盖数学意义的任意长文件;题面未给总行数上限,不能凭样例十行声称已证明不存在溢出。

补充:为什么成功读取后才切换奇偶。 维护“下一条有效输入应归哪一组”的状态;只有确实解析到一个整数才消耗这个位置。输入结束或转换失败时,不新增数也不切换。若先切换再检查返回值,末尾那次失败读可能使状态前进却没有对应数据,调试时会看到“读了十一行”却实际只有十个整数。

对POSIX分块路线,还要把“收到新字节”和“形成完整整数”分开。缓冲第一次只有负号 - 时还不能提交一个数;后续字符到齐并确认行结束后才转数、累计、切换。一行跨三次read也只占一个行号,一次read包含三行则要推进三次。这个不变量可用来解释任意块切法为何不应改变最后两组和。

回到正文:练习要求怎样连接本讲(p.15–16)

来源与证据

  • 原件:Lab02 PDF(本地课件),p.1–16;教师 ZIP(本地资料)。来源为用户提供的 Downloads 两附件,未声称已经重新抓取 Blackboard。

  • 代码图片已核对 p.4–7、p.9–14 的渲染页;修正和额外初值均与原件区分。附件原文保留;接口选择按用户于 2026-09-26 转贴的澄清公告更新。

  • 运行核对:Lab02-运行记录.txt(本地资料),仅教学示例,非练习作答验收。

  • 外部补充:正文所链 GCC VLA 文档、Linux man-pages 及 C11 委员会草案

  • 既有条目出处 L03/L04 为 2022未核讲义,仅作已入库概念衔接。本次只追加 Lab 演练引用,VLA、struct 和 stdio 新知识不新建讲义条目。

  • 考试证据:读了 Lab02-C基础与文件IO-2026T1.pdf(p.15–16 练习题);未识别用途的 PDF:无

  • 2026-10-13 内容补全:保留原页顺序与版本,补入对应页的程序、接口、字段、条件和演算;原程序片段保留课件行号、拼写与省略处,不能据此视为经过编译的完整软件。本文已有运行记录属于其标注日期,本次未重新运行课程容器或提交作业。