本檔收錄 6 題由易到難的練習。建議流程:先蓋住解答自己寫,卡住再看
「解題思路」,寫完對照「參考解答」。每題完整程式碼在 exercises/:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_mywc.c && /tmp/out
所有程式只操作 /tmp 下自建的測試檔與目錄,執行完自動清理。
每支程式都同時支援「內建示範」(不帶參數)與「真的命令列使用」。
練習 1:mywc — 統計位元組數與行數(難度:基礎)
題目
用低階 read 實作簡化版 wc:統計檔案的位元組數與行數
(行數 = '\n' 的個數)。不帶參數時對自建測試檔示範;
帶參數時統計指定檔案。
$ /tmp/out
4 lines 70 bytes /tmp/mywc_test.txt
解題思路
- read 迴圈的標準骨架——本單元一半的題目都是它的變形:
while ((n = read(fd, buf, sizeof buf)) > 0)
... 處理 buf 的前 n 個位元組 ...
三種回傳值三種意義:> 0 讀到的量、== 0 EOF、< 0 錯誤。
迴圈條件只認 > 0,EOF 與錯誤都出迴圈後再區分。
2. 兩個統計量一趟完成:位元組數直接累加 n;
行數掃 buffer 數 '\n'——不需要「按行讀取」,
低階 I/O 根本沒有「行」的概念,行是內容層的解讀。
3. 測試檔設計:最後一行故意不加 '\n'——
驗證「行數是數換行符」的定義(wc(1) 同樣行為:
沒有換行結尾的尾巴不算一行)。
4. fd 紀律:open 失敗回 -1 要立即報錯(strerror(errno)
把代碼變人話);成功開啟的 fd 用完必關,錯誤路徑也一樣。
5. 邊界:空檔案(迴圈一次都不進,0/0)、
讀取中途錯誤(n < 0,報錯並回傳失敗)。
參考解答
/*
* 練習 1:mywc — 用 read 統計位元組數與行數
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_mywc.c
* 執行:/tmp/out (用內建測試檔示範)
* /tmp/out 檔案路徑 (統計指定檔案)
*
* 重點:低階 read 迴圈、「read 回 0 = EOF」、
* 一個 buffer 掃描兩種統計量。
*/
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h> /* open, O_* 旗標 */
#include <unistd.h> /* read, write, close, unlink */
#include <errno.h>
#include <string.h>
#define BUFSIZE 4096
/* 統計 fd 指到的檔案:位元組數與行數(數 '\n')。
回傳 0 成功、-1 失敗。 */
static int count_fd(int fd, long *bytes, long *lines)
{
char buf[BUFSIZE];
ssize_t n;
*bytes = 0;
*lines = 0;
while ((n = read(fd, buf, sizeof buf)) > 0) {
*bytes += n;
for (ssize_t i = 0; i < n; i++) /* 掃 buffer 數換行 */
if (buf[i] == '\n')
(*lines)++;
}
return (n < 0) ? -1 : 0; /* n==0 是 EOF(正常結束) */
}
/* 統計指定路徑,印出結果。回傳 0 成功。 */
static int mywc(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd == -1) {
fprintf(stderr, "mywc: open %s: %s\n", path, strerror(errno));
return -1;
}
long bytes, lines;
int rc = count_fd(fd, &bytes, &lines);
if (rc == -1)
perror("mywc: read");
else
printf("%6ld lines %8ld bytes %s\n", lines, bytes, path);
close(fd); /* 開了就要關:fd 洩漏會耗盡 ulimit -n */
return rc;
}
/* 在 /tmp 建一個內容已知的測試檔,方便驗證統計結果 */
static const char *make_test_file(void)
{
static const char *path = "/tmp/mywc_test.txt";
static const char text[] =
"the quick brown fox\n"
"jumps over\n"
"the lazy dog\n"
"\n"
"last line without newline"; /* 最後一行故意沒有 \n */
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open test file");
exit(1);
}
/* sizeof text - 1:不寫入字串結尾的 '\0' */
if (write(fd, text, sizeof text - 1) != (ssize_t)(sizeof text - 1)) {
perror("write test file");
close(fd);
exit(1);
}
close(fd);
return path;
}
int main(int argc, char *argv[])
{
if (argc > 1) { /* 真的從命令列給檔案 */
int bad = 0;
for (int i = 1; i < argc; i++)
if (mywc(argv[i]) != 0)
bad = 1;
return bad;
}
/* 內建示範:自建測試檔 -> 統計 -> 驗證 -> 清理 */
const char *path = make_test_file();
printf("test file content is 4 newlines + a tail without newline:\n\n");
if (mywc(path) != 0)
return 1;
printf("\nexpected: 4 lines (only '\\n' counts), 70 bytes\n");
printf("note: wc(1) counts the same way -- a final line\n"
"without '\\n' does not increase the line count.\n");
unlink(path); /* 清理測試檔 */
return 0;
}
程式碼講解
count_fd只管 fd 不管路徑——「開檔」與「統計」分離, 之後要統計 stdin(fd 0)也能直接重用。sizeof text - 1:字串字面值的sizeof含結尾'\0', 寫檔不該把它寫進去——低階 I/O 常見的 off-by-one。main的雙模式:argc > 1走真實檔案,否則自建、統計、 印出預期值對照、unlink清理——本單元所有解答的固定樣板。- 易錯處:
read之後用buf當字串(strchr等)—— buffer 沒有'\0'結尾,只能用長度n界定範圍。
範例輸出
test file content is 4 newlines + a tail without newline:
4 lines 70 bytes /tmp/mywc_test.txt
expected: 4 lines (only '\n' counts), 70 bytes
note: wc(1) counts the same way -- a final line
without '\n' does not increase the line count.
練習 2:mycp — read/write 迴圈複製檔案(難度:基礎)
題目
實作 mycp src dst:用 4096 位元組的緩衝區以 read/write
迴圈複製檔案,結尾用 stat 驗證兩檔大小相同。
write 必須處理「一次沒寫完」的情況(full-write 迴圈)。
解題思路
- 複製 = 讀什麼寫什麼:外層還是練習 1 的 read 迴圈,
迴圈體把讀到的
n個位元組寫進目的檔。 - write 的陷阱是本題核心:
write(fd, buf, n)回傳 實際寫入的量,可能小於n(磁碟滿、訊號打斷、 管線緩衝)。正確姿勢是write_all:
while (off < n) {
w = write(fd, buf + off, n - off);
if (w < 0) { if (errno == EINTR) continue; return -1; }
off += w;
}
EINTR(被訊號打斷)不是真錯誤,重試即可——
README 陷阱清單的「假設一次寫完」在這裡修正。
3. 開檔旗標:來源 O_RDONLY;目的
O_WRONLY | O_CREAT | O_TRUNC, 0644——不存在就建、
存在就清空,權限記得用八進位。
4. 錯誤路徑的 fd 紀律:開目的檔失敗時,已開的來源 fd
要先關再返回——fd 洩漏是 README 陷阱第 3 條。
5. 驗證:stat 兩檔比 st_size。順手印出 read 次數:
8250 位元組 ÷ 4096 = 3 次 syscall,對照「緩衝區大小決定
syscall 次數」的效能直覺。
參考解答
/*
* 練習 2:mycp — 用 read/write 迴圈複製檔案
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_mycp.c
* 執行:/tmp/out (內建示範:自建來源檔複製並驗證)
* /tmp/out src dst (真的複製指定檔案)
*
* 重點:full-write 迴圈(write 不保證一次寫完)、
* 錯誤路徑要 close、stat 驗證大小。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <sys/stat.h> /* stat */
#define BUFSIZE 4096
/* 把 buf 的 n 個位元組「全部」寫進 fd。
write 可能只寫一部分(管線/訊號打斷),要迴圈補完。 */
static int write_all(int fd, const char *buf, ssize_t n)
{
ssize_t off = 0;
while (off < n) {
ssize_t w = write(fd, buf + off, (size_t)(n - off));
if (w < 0) {
if (errno == EINTR) /* 被訊號打斷:重試 */
continue;
return -1;
}
off += w;
}
return 0;
}
/* 複製 src 到 dst。回傳 0 成功、-1 失敗。 */
static int mycp(const char *src, const char *dst)
{
int in = open(src, O_RDONLY);
if (in == -1) {
fprintf(stderr, "mycp: open %s: %s\n", src, strerror(errno));
return -1;
}
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out == -1) {
fprintf(stderr, "mycp: create %s: %s\n", dst, strerror(errno));
close(in); /* 錯誤路徑也要關已開的 fd */
return -1;
}
char buf[BUFSIZE];
ssize_t n;
long total = 0, syscalls = 0;
while ((n = read(in, buf, sizeof buf)) > 0) {
if (write_all(out, buf, n) == -1) {
perror("mycp: write");
close(in);
close(out);
return -1;
}
total += n;
syscalls++;
}
if (n < 0)
perror("mycp: read");
close(in);
close(out);
printf(" copied %ld bytes in %ld read() calls (buf=%d)\n",
total, syscalls, BUFSIZE);
return (n < 0) ? -1 : 0;
}
/* 用 stat 驗證兩檔大小相同。回傳 0 表示一致。 */
static int verify_same_size(const char *a, const char *b)
{
struct stat sa, sb;
if (stat(a, &sa) == -1 || stat(b, &sb) == -1) {
perror("stat");
return -1;
}
printf(" stat: %s = %lld bytes, %s = %lld bytes -> %s\n",
a, (long long)sa.st_size, b, (long long)sb.st_size,
sa.st_size == sb.st_size ? "MATCH" : "MISMATCH");
return sa.st_size == sb.st_size ? 0 : -1;
}
/* 建一個約 10000 位元組的來源檔(跨越多個 4096 buffer) */
static void make_source(const char *path)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open source");
exit(1);
}
char line[64];
for (int i = 0; i < 250; i++) {
int len = snprintf(line, sizeof line,
"line %03d: some payload text here\n", i);
if (write_all(fd, line, len) == -1) {
perror("write source");
close(fd);
exit(1);
}
}
close(fd);
}
int main(int argc, char *argv[])
{
if (argc == 3) /* 真的從命令列複製 */
return mycp(argv[1], argv[2]) == 0 ? 0 : 1;
if (argc != 1) {
fprintf(stderr, "usage: %s [src dst]\n", argv[0]);
return 2;
}
/* 內建示範 */
const char *src = "/tmp/mycp_src.txt";
const char *dst = "/tmp/mycp_dst.txt";
make_source(src);
printf("copy %s -> %s:\n", src, dst);
int rc = mycp(src, dst);
if (rc == 0)
rc = verify_same_size(src, dst);
unlink(src); /* 清理 */
unlink(dst);
return rc == 0 ? 0 : 1;
}
程式碼講解
write_all的buf + off:指標前進到還沒寫出的位置—— 部分寫入後「接著寫剩下的」全靠這個位移。mycp中每個 syscall 都檢查回傳值並帶檔名報錯 (mycp: open %s: ...)——使用者才知道是哪個檔案出問題。make_source產生 250 行 × 33 位元組 = 8250 位元組, 跨越三個 buffer——測試資料刻意大於一個緩衝區, 否則迴圈只跑一圈、部分寫入的邏輯根本沒被走到。verify_same_size用(long long)cast 印off_t:off_t的實際型別平台各異,cast 到最寬的有號整數最穩。- 易錯處:目的檔忘記
O_TRUNC——舊內容比新內容長時, 尾巴會殘留舊資料,大小驗證立刻抓到。
範例輸出
copy /tmp/mycp_src.txt -> /tmp/mycp_dst.txt:
copied 8250 bytes in 3 read() calls (buf=4096)
stat: /tmp/mycp_src.txt = 8250 bytes, /tmp/mycp_dst.txt = 8250 bytes -> MATCH
練習 3:lseek 固定長度記錄(難度:進階)
題目
用 lseek 實作「固定長度記錄」的隨機存取:
- 依序寫入 10 筆 64 位元組的記錄;
- 直接跳到第 7 筆(index 6)讀出,不讀前面的;
- 原地更新第 3 筆並驗證鄰近記錄不受影響;
- 用
SEEK_END負位移讀最後一筆。
解題思路
- 隨機存取的前提是「可計算的位置」:記錄大小固定,
第 i 筆的位移就是
i * RECSIZE——這是所有資料庫 分頁式儲存的最底層原理:
offset: 0 64 128 192 256
├─rec 0──┼─rec 1──┼─rec 2──┼─rec 3──┼─ ...
跳到第 6 筆:lseek(fd, 6*64, SEEK_SET) 一步到位
- 記錄怎麼表示:一個大小恰為 64 的 struct,整塊
read/write。用
_Static_assert(sizeof(struct record) == RECSIZE)把「恰為 64」變成編譯期保證——齊位補洞可能讓 struct 比欄位總和大,用斷言防呆而不是心算。 - read_record / write_record:
lseek(SEEK_SET)定位 + 整塊 I/O,兩個函式各五行——把「位移計算」關進函式, 呼叫端只講記錄編號。 - 原地更新:讀出第 3 筆、改 score、寫回同一個位移 ——檔案其他部分一個位元組都沒動,這是 lseek 寫入與 「重寫整個檔案」的本質差異。
O_RDWR:同一個 fd 要讀也要寫; 開檔旗標比O_RDONLY/O_WRONLY少見但這裡必須。- 邊界:struct 要先
memset清零再填——否則補洞裡的 垃圾位元組會寫進檔案(工具如 valgrind 會抱怨 未初始化寫入)。
參考解答
/*
* 練習 3:lseek 固定長度記錄 — 迷你隨機存取資料庫
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_records.c
* 執行:/tmp/out
*
* 重點:位移 = 記錄編號 × 記錄大小、lseek + read 直接跳讀、
* lseek + write 原地更新(不動其他記錄)。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#define RECSIZE 64 /* 每筆記錄固定 64 位元組 */
#define NRECORDS 10
/* 一筆記錄:固定大小的 struct,直接整塊 read/write。
name 與 score 之後的空間自動補滿到 64 位元組。 */
struct record {
int id;
int score;
char name[RECSIZE - 2 * (int)sizeof(int)];
};
/* 把第 idx 筆記錄讀出來(不經過前面的記錄)。回傳 0 成功。 */
static int read_record(int fd, int idx, struct record *r)
{
off_t off = (off_t)idx * RECSIZE; /* 記錄編號 -> 位元組位移 */
if (lseek(fd, off, SEEK_SET) == (off_t)-1) {
perror("lseek");
return -1;
}
ssize_t n = read(fd, r, RECSIZE);
if (n != RECSIZE) {
fprintf(stderr, "read record %d: got %zd bytes\n", idx, n);
return -1;
}
return 0;
}
/* 原地覆寫第 idx 筆記錄。回傳 0 成功。 */
static int write_record(int fd, int idx, const struct record *r)
{
if (lseek(fd, (off_t)idx * RECSIZE, SEEK_SET) == (off_t)-1) {
perror("lseek");
return -1;
}
if (write(fd, r, RECSIZE) != RECSIZE) {
perror("write record");
return -1;
}
return 0;
}
int main(void)
{
const char *path = "/tmp/records.db";
_Static_assert(sizeof(struct record) == RECSIZE,
"record must be exactly RECSIZE bytes");
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
return 1;
}
/* --- 1. 依序寫入 10 筆記錄 --- */
printf("write %d records of %d bytes each:\n", NRECORDS, RECSIZE);
for (int i = 0; i < NRECORDS; i++) {
struct record r;
memset(&r, 0, sizeof r); /* 補零:避免寫入未初始化位元組 */
r.id = 100 + i;
r.score = 60 + i * 4;
snprintf(r.name, sizeof r.name, "student-%02d", i);
if (write_record(fd, i, &r) == -1)
return 1;
}
off_t size = lseek(fd, 0, SEEK_END); /* 慣用技巧:取檔案大小 */
printf(" file size = %lld bytes (= %d x %d)\n\n",
(long long)size, NRECORDS, RECSIZE);
/* --- 2. 直接跳到第 7 筆(index 6)讀出,不讀前面的 --- */
struct record r;
printf("random access: jump straight to record #6:\n");
if (read_record(fd, 6, &r) == -1)
return 1;
printf(" offset=%d id=%d name=%s score=%d\n\n",
6 * RECSIZE, r.id, r.name, r.score);
/* --- 3. 原地更新第 3 筆,驗證其他記錄不受影響 --- */
printf("in-place update of record #3 (score 72 -> 95):\n");
if (read_record(fd, 3, &r) == -1)
return 1;
printf(" before: id=%d name=%s score=%d\n", r.id, r.name, r.score);
r.score = 95;
if (write_record(fd, 3, &r) == -1)
return 1;
for (int i = 2; i <= 4; i++) { /* 讀鄰近三筆驗證 */
if (read_record(fd, i, &r) == -1)
return 1;
printf(" after : record #%d id=%d name=%s score=%d%s\n",
i, r.id, r.name, r.score,
i == 3 ? " <- updated" : "");
}
/* --- 4. SEEK_END 負位移:讀最後一筆 --- */
printf("\nlast record via SEEK_END - %d:\n", RECSIZE);
if (lseek(fd, -(off_t)RECSIZE, SEEK_END) == (off_t)-1) {
perror("lseek");
return 1;
}
if (read(fd, &r, RECSIZE) != RECSIZE) {
perror("read");
return 1;
}
printf(" id=%d name=%s score=%d\n", r.id, r.name, r.score);
close(fd);
unlink(path); /* 清理 */
return 0;
}
程式碼講解
struct record的 name 欄位大小寫成RECSIZE - 2 * sizeof(int)——讓「兩個 int + 名字 = 64」 由編譯器算,改 RECSIZE 也不會失衡;_Static_assert再上 一道保險。(off_t)idx * RECSIZE:先 cast 再乘——記錄多的時候int * int會在 32 位元裡溢位,cast 放在乘法前面才有效。lseek(fd, 0, SEEK_END)回傳值=檔案大小:K&R §8.4 的 慣用技巧,輸出裡驗證 640 = 10 × 64。- 更新後讀第 2、3、4 筆對照:只有第 3 筆變了—— 「原地」二字眼見為憑。
- 易錯處:
lseek失敗回(off_t)-1不是-1直接比——off_t是有號整數所以== -1其實可行,但 cast 寫法 明示型別意圖,也是 man page 的標準寫法。
範例輸出
write 10 records of 64 bytes each:
file size = 640 bytes (= 10 x 64)
random access: jump straight to record #6:
offset=384 id=106 name=student-06 score=84
in-place update of record #3 (score 72 -> 95):
before: id=103 name=student-03 score=72
after : record #2 id=102 name=student-02 score=68
after : record #3 id=103 name=student-03 score=95 <- updated
after : record #4 id=104 name=student-04 score=76
last record via SEEK_END - 64:
id=109 name=student-09 score=96
練習 4:tail -c N(難度:進階)
題目
實作 tail -c N:印出檔案最後 N 個位元組。
必須正確處理 N 大於檔案大小的情況(印整個檔案,不報錯),
以及 N = 0(什麼都不印)。
解題思路
- 直覺解:
lseek(fd, -N, SEEK_END)再 read 到 EOF。 直覺解的坑就是本題考點:檔案 68 位元組、N=9999 時, 位移變成 68 - 9999 = 負數,lseek直接EINVAL失敗。 - 先量再跳:
size = lseek(fd, 0, SEEK_END); /* 檔案大小 */
start = (N >= size) ? 0 : size - N; /* 夾住下界 */
lseek(fd, start, SEEK_SET);
把「相對檔尾」換算成「絕對位移」,邊界在計算階段
處理掉,syscall 永遠拿到合法引數。
3. 輸出:從 start 讀到 EOF——又是練習 1 的 read 迴圈;
輸出用 fwrite(buf, 1, n, stdout) 而不是 printf("%s")
——資料可能含 '\0' 或不以 '\0' 結尾,
長度導向的輸出才正確。
4. 測資設計:算好各行位元組數(最後一行恰 19 位元組、
最後兩行 35),tail -c 19 應恰好切在行首——
輸出一眼可驗證。
5. 邊界一覽:N = 0(start = size,read 立即 EOF)、
N ≥ size(start = 0,整檔)、負 N(介面直接拒絕)。
參考解答
/*
* 練習 4:tail -c N — 印出檔案最後 N 個位元組
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_tail_c.c
* 執行:/tmp/out (內建示範,含邊界情況)
* /tmp/out N 檔案路徑 (真的對指定檔案 tail -c N)
*
* 重點:lseek(fd, -N, SEEK_END) 的邊界——N 大於檔案大小時
* 不能變成負位移(EINVAL),要先取大小再夾住。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#define BUFSIZE 4096
/* 印出 path 的最後 nbytes 個位元組。回傳 0 成功。 */
static int tail_c(const char *path, long nbytes)
{
if (nbytes < 0) {
fprintf(stderr, "tail_c: N must be >= 0\n");
return -1;
}
int fd = open(path, O_RDONLY);
if (fd == -1) {
fprintf(stderr, "tail_c: open %s: %s\n", path, strerror(errno));
return -1;
}
/* 先取檔案大小:lseek 到檔尾的回傳值就是大小 */
off_t size = lseek(fd, 0, SEEK_END);
if (size == (off_t)-1) {
perror("lseek SEEK_END");
close(fd);
return -1;
}
/* 邊界:N > 檔案大小 -> 從頭開始(等於印整個檔案)。
不夾住的話 lseek(fd, -N, SEEK_END) 會變負位移而失敗。 */
off_t start = (nbytes >= size) ? 0 : size - (off_t)nbytes;
if (lseek(fd, start, SEEK_SET) == (off_t)-1) {
perror("lseek SEEK_SET");
close(fd);
return -1;
}
printf(" [size=%lld, want last %ld, start at offset %lld]\n",
(long long)size, nbytes, (long long)start);
char buf[BUFSIZE];
ssize_t n;
while ((n = read(fd, buf, sizeof buf)) > 0)
fwrite(buf, 1, (size_t)n, stdout);
if (n < 0)
perror("read");
close(fd);
return (n < 0) ? -1 : 0;
}
/* 建一個內容已知的測試檔 */
static void make_test_file(const char *path)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open test file");
exit(1);
}
static const char text[] =
"AAAA first line\n"
"BBBB second line\n"
"CCCC third line\n"
"DDDD the last line\n";
if (write(fd, text, sizeof text - 1) != (ssize_t)(sizeof text - 1)) {
perror("write test file");
close(fd);
exit(1);
}
close(fd);
}
int main(int argc, char *argv[])
{
if (argc == 3) { /* 真的從命令列使用 */
char *end;
long n = strtol(argv[1], &end, 10);
if (*end != '\0' || end == argv[1]) {
fprintf(stderr, "usage: %s N file\n", argv[0]);
return 2;
}
return tail_c(argv[2], n) == 0 ? 0 : 1;
}
/* 內建示範 */
const char *path = "/tmp/tail_test.txt";
make_test_file(path);
printf("tail -c 19 (exactly the last line):\n");
if (tail_c(path, 19) != 0)
return 1;
printf("\ntail -c 35 (last two lines):\n");
if (tail_c(path, 35) != 0)
return 1;
printf("\ntail -c 9999 (N > file size -> whole file, no error):\n");
if (tail_c(path, 9999) != 0)
return 1;
printf("\ntail -c 0 (prints nothing):\n");
if (tail_c(path, 0) != 0)
return 1;
unlink(path); /* 清理 */
return 0;
}
程式碼講解
tail_c開頭先驗證nbytes >= 0——負數在後面的(off_t)nbytes轉換會鬧鬼,在邊界把爛輸入擋掉。(nbytes >= size)的比較兩邊型別不同(long vs off_t): off_t 至少 64 位元,long 提升過去不丟資訊。- 每次呼叫印
[size=..., start at offset ...]行—— 把內部計算翻出來給讀者對帳,教學程式的好習慣。 - 命令列模式用
strtol解析 N 並檢查*end != '\0'—— 複習命令列引數單元的健壯解析。 - 易錯處:直接
lseek(fd, -N, SEEK_END)沒夾邊界—— 正常測資都對,N 太大就炸;「先想邊界再寫 happy path」 是系統程式的紀律。
範例輸出
tail -c 19 (exactly the last line):
[size=68, want last 19, start at offset 49]
DDDD the last line
tail -c 35 (last two lines):
[size=68, want last 35, start at offset 33]
CCCC third line
DDDD the last line
tail -c 9999 (N > file size -> whole file, no error):
[size=68, want last 9999, start at offset 0]
AAAA first line
BBBB second line
CCCC third line
DDDD the last line
tail -c 0 (prints nothing):
[size=68, want last 0, start at offset 68]
練習 5:ls -t — 依 mtime 排序(難度:進階)
題目
實作 ls -t:列出目錄內容,依修改時間(mtime)遞減排序,
同 mtime 以名字字母序當次鍵。列出名字、大小與格式化的時間。
(複習:qsort + 比較函式。)
解題思路
- ls -l 的工作流程(K&R §8.6 fsize 同構)分三步:
-
opendir/readdir拿名字(只有名字!); - 對每個名字組完整路徑再stat拿大小、時間; - 收集完排序、列印。 - 為什麼要先收集再排序:readdir 的順序是檔案系統
內部順序(近乎隨機),排序需要「全部資料在手」——
把
{name, size, mtime}打包成 struct 陣列。 - 比較函式:mtime 遞減為主鍵、名字為次鍵——
函式指標單元練習 4 的多鍵排序又來了。注意
time_t不可相減當回傳值:time_t 可能是 64 位元,差值塞進 int 會截斷翻號(和 qsort 浮點數陷阱同型)。 - 確定性的測試:靠 sleep 製造時間差又慢又不穩;
utimes(path, tv)直接指定 mtime——四個檔案設在 固定基準時刻的 +0h/+1h/+1h/+2h,輸出永遠一樣, 其中兩個同 mtime 恰好驗證次鍵。 - 邊界:跳過
.與..;stat 失敗的項目印錯誤後跳過 (目錄裡可能有壞的 symlink)。
參考解答
/*
* 練習 5:ls -t — 目錄列表依 mtime 遞減排序
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_ls_t.c
* 執行:/tmp/out (內建示範:自建測試目錄)
* /tmp/out 目錄路徑 (列出指定目錄)
*
* 重點:readdir 只給名字 -> 每個名字再 stat、
* 把「名字+stat 結果」收進陣列、qsort+比較函式
* (複習函式指標單元)、utimes 製造確定的時間差。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <dirent.h> /* opendir, readdir */
#include <sys/stat.h>
#include <sys/time.h> /* utimes */
#include <time.h>
#define MAXENTRIES 256
#define MAXNAME 256
/* 一個目錄項目:名字 + stat 到的中繼資料 */
struct entry {
char name[MAXNAME];
off_t size;
time_t mtime;
};
/* qsort 比較函式:mtime 遞減;同 mtime 按名字字母序當次鍵 */
static int cmp_mtime_desc(const void *a, const void *b)
{
const struct entry *ea = a;
const struct entry *eb = b;
if (ea->mtime != eb->mtime) /* time_t 相減可能溢位, */
return (eb->mtime > ea->mtime) ? 1 : -1; /* 用比較不用減法 */
return strcmp(ea->name, eb->name);
}
/* 讀取目錄,stat 每個項目,排序後列印。回傳 0 成功。 */
static int ls_t(const char *dir)
{
DIR *dp = opendir(dir);
if (dp == NULL) {
perror("opendir");
return -1;
}
static struct entry ent[MAXENTRIES];
int n = 0;
struct dirent *d;
while ((d = readdir(dp)) != NULL && n < MAXENTRIES) {
if (strcmp(d->d_name, ".") == 0 || strcmp(d->d_name, "..") == 0)
continue; /* 跳過 . 與 .. */
/* readdir 只有名字:組完整路徑再 stat 拿大小與時間 */
char path[MAXNAME * 2];
snprintf(path, sizeof path, "%s/%s", dir, d->d_name);
struct stat st;
if (stat(path, &st) == -1) {
perror(path);
continue;
}
snprintf(ent[n].name, sizeof ent[n].name, "%s", d->d_name);
ent[n].size = st.st_size;
ent[n].mtime = st.st_mtime;
n++;
}
closedir(dp);
qsort(ent, (size_t)n, sizeof ent[0], cmp_mtime_desc);
printf("%-14s %8s %s\n", "name", "size", "mtime");
for (int i = 0; i < n; i++) {
char when[32];
struct tm *tm = localtime(&ent[i].mtime);
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", tm);
printf("%-14s %8lld %s\n",
ent[i].name, (long long)ent[i].size, when);
}
return 0;
}
/* 建測試檔並用 utimes 指定 mtime——不用 sleep 也有確定的時間差 */
static void make_file(const char *path, const char *content, time_t mtime)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror(path);
exit(1);
}
size_t len = strlen(content);
if (write(fd, content, len) != (ssize_t)len) {
perror("write");
close(fd);
exit(1);
}
close(fd);
struct timeval tv[2]; /* [0]=atime, [1]=mtime */
tv[0].tv_sec = mtime;
tv[0].tv_usec = 0;
tv[1] = tv[0];
if (utimes(path, tv) == -1) {
perror("utimes");
exit(1);
}
}
int main(int argc, char *argv[])
{
if (argc == 2) /* 真的列出指定目錄 */
return ls_t(argv[1]) == 0 ? 0 : 1;
/* 內建示範:自建目錄與四個 mtime 已知的檔案 */
const char *dir = "/tmp/lst_demo";
mkdir(dir, 0755); /* 已存在也沒關係 */
/* 固定的基準時刻,輸出才可重現 */
time_t base = 1735689600; /* 2025-01-01 00:00:00 UTC */
make_file("/tmp/lst_demo/oldest.txt", "aaaa\n", base);
make_file("/tmp/lst_demo/middle.txt", "bbbbbbbb\n", base + 3600);
make_file("/tmp/lst_demo/newest.txt", "cc\n", base + 7200);
make_file("/tmp/lst_demo/same_t.txt", "dddddd\n", base + 3600);
printf("ls -t %s (mtime desc, name as tie-breaker):\n\n", dir);
int rc = ls_t(dir);
/* 清理 */
unlink("/tmp/lst_demo/oldest.txt");
unlink("/tmp/lst_demo/middle.txt");
unlink("/tmp/lst_demo/newest.txt");
unlink("/tmp/lst_demo/same_t.txt");
rmdir(dir);
return rc == 0 ? 0 : 1;
}
程式碼講解
snprintf(path, ..., "%s/%s", dir, d->d_name): readdir 給的名字不含路徑,stat 之前必須拼回去—— 忘記拼路徑是本題最常見的 bug(stat 找不到檔案)。cmp_mtime_desc用三向比較不用減法;遞減 = 參數反過來 (eb > ea回正)。strftime把time_t變成人可讀的字串;localtime轉本地時區——輸出中的 21:00 是 UTC 2025-01-01 00:00 在 UTC-3 的呈現(依機器時區而異)。static struct entry ent[MAXENTRIES]:放 static 避免 256 × 272 位元組吃掉太多堆疊。- 易錯處:
d_type直接可用?——dirent的d_type並非所有檔案系統都支援,可攜的寫法一律 stat。
範例輸出
ls -t /tmp/lst_demo (mtime desc, name as tie-breaker):
name size mtime
newest.txt 3 2024-12-31 21:00:00
middle.txt 9 2024-12-31 20:00:00
same_t.txt 7 2024-12-31 20:00:00
oldest.txt 5 2024-12-31 19:00:00
(時間欄位依機器時區呈現,順序不變。)
練習 6:遞迴 du — 不跟隨 symlink(難度:挑戰)
題目
實作遞迴的 du:走訪目錄樹加總所有檔案的 st_size。
測試目錄裡放一個指回上層的 symlink——程式必須用
lstat + S_ISLNK 跳過它(跟著走會無限遞迴),
但把連結本身的大小算進總和。
解題思路
- 遞迴結構跟樹的走訪一模一樣(複習 08-trees):
du(path):
lstat(path)
├─ symlink -> 記連結本身大小,不進去
├─ 一般檔案 -> 回傳 st_size
└─ 目錄 -> readdir 每個項目遞迴 du(),加總
檔案是葉節點、目錄是內部節點。
2. stat vs lstat 是本題的靈魂:
- stat 跟隨 symlink——對 loop -> /tmp/du_demo 呼叫
stat 得到的是目錄本身的資訊,S_ISDIR 成立,
遞迴進去 → loop 又出現 → 無限迴圈;
- lstat 看連結本身——S_ISLNK 成立,跳過。
實務的 du、find 預設都不跟隨,就是這個原因。
3. . 與 .. 必須跳過:.. 往上爬,比 symlink 更快
讓遞迴失控——readdir 走訪的第一條軍規。
4. 路徑組裝要防溢位:snprintf 回傳值 ≥ buffer 大小
表示被截斷——截斷的路徑會 stat 到不存在(或錯誤)的
檔案,檢查後跳過並報錯。
5. 錯誤處理哲學:某個項目 stat 失敗(權限、競態)不該
讓整個統計中止——印錯誤、當 0、繼續,與真實 du 一致。
6. symlink 本身的大小 = 目標路徑字串長度(/tmp/du_demo
= 12 位元組)——輸出裡可驗證。
參考解答
/*
* 練習 6:遞迴 du — 加總目錄樹的檔案大小(不跟隨 symlink)
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_du.c
* 執行:/tmp/out (內建示範:自建含 symlink 的目錄樹)
* /tmp/out 路徑 (統計指定目錄)
*
* 重點:lstat(不是 stat)看 symlink 本身、S_ISLNK 判斷、
* 遞迴進子目錄、symlink 指回上層造成的無限迴圈就是
* 「為什麼不能跟著連結走」的理由。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <dirent.h>
#include <sys/stat.h>
#define MAXPATH 1024
/*
* 遞迴加總 path 之下所有一般檔案的 st_size。
* depth 用來縮排列印,看得出遞迴結構。
* 回傳位元組總數;錯誤處印訊息後當 0 繼續(du 的行為)。
*/
static long long du(const char *path, int depth)
{
struct stat st;
/* lstat:若 path 是 symlink,看連結本身,不解引用。
用 stat 的話會跟著連結走 -> 指回上層就無限遞迴。 */
if (lstat(path, &st) == -1) {
perror(path);
return 0;
}
if (S_ISLNK(st.st_mode)) { /* symlink:只算連結本身,不進去 */
printf("%*s%-20s (symlink, skipped, %lld bytes)\n",
depth * 2, "", strrchr(path, '/') + 1,
(long long)st.st_size);
return st.st_size;
}
if (S_ISREG(st.st_mode)) { /* 一般檔案:就是它的大小 */
printf("%*s%-20s %lld bytes\n",
depth * 2, "", strrchr(path, '/') + 1,
(long long)st.st_size);
return st.st_size;
}
if (!S_ISDIR(st.st_mode)) /* 裝置檔等其他型別:略過 */
return 0;
/* 目錄:走訪每個項目並遞迴 */
printf("%*s%s/\n", depth * 2, "",
depth == 0 ? path : strrchr(path, '/') + 1);
DIR *dp = opendir(path);
if (dp == NULL) {
perror(path);
return 0;
}
long long total = 0;
struct dirent *d;
while ((d = readdir(dp)) != NULL) {
if (strcmp(d->d_name, ".") == 0 || strcmp(d->d_name, "..") == 0)
continue; /* 不然 ".." 會往上爬個沒完 */
char sub[MAXPATH];
int len = snprintf(sub, sizeof sub, "%s/%s", path, d->d_name);
if (len < 0 || (size_t)len >= sizeof sub) {
fprintf(stderr, "path too long: %s/%s\n", path, d->d_name);
continue;
}
total += du(sub, depth + 1);
}
closedir(dp);
printf("%*s= %lld bytes total in %s/\n", depth * 2, "",
total, depth == 0 ? path : strrchr(path, '/') + 1);
return total;
}
/* 建一個固定大小的檔案 */
static void make_file(const char *path, int nbytes)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror(path);
exit(1);
}
char buf[256];
memset(buf, 'x', sizeof buf);
while (nbytes > 0) {
int chunk = nbytes < (int)sizeof buf ? nbytes : (int)sizeof buf;
if (write(fd, buf, (size_t)chunk) != chunk) {
perror("write");
close(fd);
exit(1);
}
nbytes -= chunk;
}
close(fd);
}
int main(int argc, char *argv[])
{
if (argc == 2) { /* 真的統計指定路徑 */
long long t = du(argv[1], 0);
printf("\ngrand total: %lld bytes\n", t);
return 0;
}
/* 內建示範:
/tmp/du_demo/
a.txt 100 bytes
b.txt 250 bytes
sub/
c.txt 500 bytes
loop -> /tmp/du_demo (指回上層的 symlink!) */
const char *root = "/tmp/du_demo";
mkdir(root, 0755);
mkdir("/tmp/du_demo/sub", 0755);
make_file("/tmp/du_demo/a.txt", 100);
make_file("/tmp/du_demo/b.txt", 250);
make_file("/tmp/du_demo/sub/c.txt", 500);
unlink("/tmp/du_demo/sub/loop"); /* 若上次殘留先移除 */
if (symlink("/tmp/du_demo", "/tmp/du_demo/sub/loop") == -1) {
perror("symlink");
return 1;
}
printf("directory tree with a symlink pointing BACK to the root\n"
"(following it would recurse forever; lstat + skip saves us):\n\n");
long long total = du(root, 0);
printf("\ngrand total: %lld bytes "
"(100 + 250 + 500 + symlink itself)\n", total);
/* 清理 */
unlink("/tmp/du_demo/sub/loop");
unlink("/tmp/du_demo/sub/c.txt");
unlink("/tmp/du_demo/a.txt");
unlink("/tmp/du_demo/b.txt");
rmdir("/tmp/du_demo/sub");
rmdir(root);
return 0;
}
程式碼講解
du開頭一次lstat,之後S_ISLNK→S_ISREG→S_ISDIR依序分流——判斷順序重要:先攔 symlink, 後面的分支才安全。strrchr(path, '/') + 1取路徑最後一段當顯示名—— 遞迴時傳的是完整路徑,列印只要檔名。depth * 2的%*s縮排把遞迴結構畫出來: 輸出縮排即目錄深度,教學展示零成本。- 示範樹刻意讓
sub/loop指回/tmp/du_demo: 用stat的版本跑這個測資會遞迴到路徑爆掉,lstat版一趟走完——「危險測資」正是驗證方式。 - 總和 = 100 + 250 + 500 + 12(連結本身)= 862, 輸出可對帳。
- 易錯處:readdir 迴圈中呼叫遞迴 du,而 du 裡又 opendir ——每層一個 DIR,記得每層都 closedir*, 否則深目錄樹把 fd 用光(EMFILE)。
範例輸出
directory tree with a symlink pointing BACK to the root
(following it would recurse forever; lstat + skip saves us):
/tmp/du_demo/
b.txt 250 bytes
sub/
c.txt 500 bytes
loop (symlink, skipped, 12 bytes)
= 512 bytes total in sub/
a.txt 100 bytes
= 862 bytes total in /tmp/du_demo/
grand total: 862 bytes (100 + 250 + 500 + symlink itself)
(readdir 的走訪順序依檔案系統而異,總和不變。)
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
| 假設 read/write 一次做完 | 管線/大檔案下資料缺損 | 迴圈累積(練習 1、2) |
| 錯誤路徑忘記 close | fd 洩漏,之後 open 全部 EMFILE | 每條路徑都關(練習 2) |
open(..., O_CREAT, 644) |
權限詭異的檔案 | 八進位 0644 |
lseek(fd, -N, SEEK_END) 不夾邊界 |
N > 檔案大小時 EINVAL | 先取大小再算絕對位移(練習 4) |
比較函式 a->mtime - b->mtime |
time_t 截斷翻號、排序錯 | 三向比較(練習 5) |
| 遞迴走訪用 stat | symlink 迴圈→無限遞迴 | lstat + S_ISLNK 跳過(練習 6) |
| readdir 的名字直接 stat | 找不到檔案(少了目錄前綴) | snprintf 拼完整路徑(練習 5、6) |