本檔收錄 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

解題思路

  1. 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. 複製 = 讀什麼寫什麼:外層還是練習 1 的 read 迴圈, 迴圈體把讀到的 n 個位元組寫進目的檔。
  2. 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 實作「固定長度記錄」的隨機存取:

  1. 依序寫入 10 筆 64 位元組的記錄;
  2. 直接跳到第 7 筆(index 6)讀出,不讀前面的;
  3. 原地更新第 3 筆並驗證鄰近記錄不受影響;
  4. 用 SEEK_END 負位移讀最後一筆。

解題思路

  1. 隨機存取的前提是「可計算的位置」:記錄大小固定, 第 i 筆的位移就是 i * RECSIZE——這是所有資料庫 分頁式儲存的最底層原理:
offset:  0        64       128      192      256
         ├─rec 0──┼─rec 1──┼─rec 2──┼─rec 3──┼─ ...
         跳到第 6 筆:lseek(fd, 6*64, SEEK_SET) 一步到位
  1. 記錄怎麼表示:一個大小恰為 64 的 struct,整塊 read/write。用 _Static_assert(sizeof(struct record) == RECSIZE) 把「恰為 64」變成編譯期保證——齊位補洞可能讓 struct 比欄位總和大,用斷言防呆而不是心算。
  2. read_record / write_record:lseek(SEEK_SET) 定位 + 整塊 I/O,兩個函式各五行——把「位移計算」關進函式, 呼叫端只講記錄編號。
  3. 原地更新:讀出第 3 筆、改 score、寫回同一個位移 ——檔案其他部分一個位元組都沒動,這是 lseek 寫入與 「重寫整個檔案」的本質差異。
  4. O_RDWR:同一個 fd 要讀也要寫; 開檔旗標比 O_RDONLY/O_WRONLY 少見但這裡必須。
  5. 邊界: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(什麼都不印)。

解題思路

  1. 直覺解:lseek(fd, -N, SEEK_END) 再 read 到 EOF。 直覺解的坑就是本題考點:檔案 68 位元組、N=9999 時, 位移變成 68 - 9999 = 負數,lseek 直接 EINVAL 失敗。
  2. 先量再跳:
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 + 比較函式。)

解題思路

  1. ls -l 的工作流程(K&R §8.6 fsize 同構)分三步: - opendir/readdir 拿名字(只有名字!); - 對每個名字組完整路徑再 stat 拿大小、時間; - 收集完排序、列印。
  2. 為什麼要先收集再排序:readdir 的順序是檔案系統 內部順序(近乎隨機),排序需要「全部資料在手」—— 把 {name, size, mtime} 打包成 struct 陣列。
  3. 比較函式:mtime 遞減為主鍵、名字為次鍵—— 函式指標單元練習 4 的多鍵排序又來了。注意 time_t 不可相減當回傳值:time_t 可能是 64 位元,差值塞進 int 會截斷翻號(和 qsort 浮點數陷阱同型)。
  4. 確定性的測試:靠 sleep 製造時間差又慢又不穩; utimes(path, tv) 直接指定 mtime——四個檔案設在 固定基準時刻的 +0h/+1h/+1h/+2h,輸出永遠一樣, 其中兩個同 mtime 恰好驗證次鍵。
  5. 邊界:跳過 . 與 ..;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

(時間欄位依機器時區呈現,順序不變。)


題目

實作遞迴的 du:走訪目錄樹加總所有檔案的 st_size。 測試目錄裡放一個指回上層的 symlink——程式必須用 lstat + S_ISLNK 跳過它(跟著走會無限遞迴), 但把連結本身的大小算進總和。

解題思路

  1. 遞迴結構跟樹的走訪一模一樣(複習 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)