跳转到内容

Lesson 19: 命令解释器 CNB

练习任务

本课包含四个递进练习,逐步构建一个完整的命令行解释器——从字符串切分到命令分发执行:

练习 1(shell_parse 状态机切分):实现 shell_parse(buf, argv) 函数,用状态机将一行命令切分为 argc/argv。输入 "gcc -Wall main.c",输出 gcc|-Wall|main.c

练习 2(函数指针命令分发):实现 add(a, b)sub(a, b),在 main 中用 strcmp 匹配 argv[0],设置全局函数指针 pf 和操作符字符 opchar,通过 pf(argc, argv) 完成计算。输入 "add 10 3",输出 result: 10 + 3 = 13

练习 3(结构体数组命令表):定义 struct operation { name, pf, opchar } 的命令表数组,实现 command_do(argc, argv) 遍历命令表匹配并调用对应函数。这比一长串 if-else 更易扩展。

练习 4(综合):同时实现 shell_parse()command_do(),完成从输入解析到命令执行的完整流程。输入 "add 100 200",输出 result: 100 + 200 = 300;输入 "p 2 10",输出 result: 2 ^ 10 = 1024

提示:本课的核心是「状态机 + 函数指针」的组合。shell_parse 中的状态机只有两个状态——空格中(state=0)和单词中(state=1),状态切换时就是记录参数起点和截断字符串的时机。函数指针 int (*pf)(int, int) 是一个变量,指向函数而非数据——通过改变 pf 的指向,同一行 pf(a, b) 可以调用不同的函数。回顾 Lesson 13 中状态机的概念和 Lesson 16 中 while (*p) 指针遍历的技巧。

核心知识点

  • 状态机命令行解析 — 二状态(空格/单词)FSM,状态切换时记录参数起点和截断字符串
  • argc/argv 参数模型 — argc 是参数个数,argv 是指针数组,每个元素指向一个参数字符串
  • 指针数组 char *argv[10] — 10 个 char* 元素的数组,每个元素可以指向不同的字符串
  • 二级指针 char **argv — 指向 char* 的指针,argv[i] 等价于 *(argv + i)
  • 函数指针 int (*pf)(int, int) — 一个变量,存储函数的地址,通过 pf(a, b) 调用
  • typedef 简化函数指针 — typedef int (*cmd_func)(int, int) 将复杂类型变为简单别名
  • strcmp 字符串比较与命令分发 — 比较 argv[0] 与命令名,决定调用哪个函数
  • 结构体数组命令表 — struct operation { name, pf, opchar } op[] 实现表驱动命令分发
  • 全局函数指针与回调模式 — 全局变量 pf 作为中间层,解耦命令选择和函数调用
  • void* 泛型函数指针 — 可以指向任意类型的函数,需要时强制转换回具体类型
  • atoi 字符串转整数 — <stdlib.h> 提供的便捷函数,将 "123" 转为 123
  • fgets 输入与换行符处理 — fgets 保留 \n,需要用 strlenstrcspn 去除

代码框架

练习 1:状态机切分

shell_parse_exercise.c
c
#include <stdio.h>

int main(void)
{
    char buf[256];
    char *argv[32];
    int argc = 0;
    int state = 0;
    int i;

    fgets(buf, sizeof(buf), stdin);

    // 在这里实现状态机切分:
    //   1. 遍历 buf: for (i = 0; buf[i]; i++)
    //   2. state == 0 且 buf[i] != ' ': 进入单词状态
    //      → argv[argc++] = &buf[i]; state = 1;
    //   3. state == 1 且 buf[i] == ' ': 离开单词状态
    //      → buf[i] = '\0'; state = 0;
    //   4. 末尾的 '\n' 也要处理为 '\0'

    /* 用 | 连接打印 */
    for (i = 0; i < argc; i++) {
        if (i > 0) printf("|");
        printf("%s", argv[i]);
    }
    printf("\n");

    return 0;
}

练习 2:函数指针命令分发

function_ptr_exercise.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int (*pf)(int, int);
char opchar;

int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf && *buf != '\n') {
        if (*buf != ' ' && state == 0) { argv[argc++] = buf; state = 1; }
        if (*buf == ' ' && state == 1) { *buf = '\0'; state = 0; }
        buf++;
    }
    if (*buf == '\n') *buf = '\0';
    return argc;
}

// 在这里实现 add(a, b) 和 sub(a, b):
// int add(int a, int b) { return a + b; }
// int sub(int a, int b) { return a - b; }

int math_main(int argc, char *argv[])
{
    int a, b;
    int result;

    if (argc < 3)
        return -1;

    a = atoi(argv[1]);
    b = atoi(argv[2]);
    result = pf(a, b);

    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);

    return 0;
}

int main(void)
{
    char buf[256];
    int argc;
    char *argv[10];

    fgets(buf, sizeof(buf), stdin);
    argc = shell_parse(buf, argv);

    // 在这里实现命令分发:
    //   1. 根据 argv[0] 用 strcmp 匹配 "add" 或 "sub"
    //   2. 设置 pf 指向对应的函数,设置 opchar 为 '+' 或 '-'
    //   3. 调用 math_main(argc, argv)

    return 0;
}

练习 3 & 4:结构体命令表

cmd_table_exercise.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int (*pf)(int, int);
char opchar;

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int mydiv(int a, int b) { if (b != 0) return a / b; return 0; }

int power(int a, int b)
{
    int result = 1;
    for (int i = 0; i < b; i++)
        result *= a;
    return result;
}

int math_main(int argc, char *argv[])
{
    int a, b;
    int result;

    if (argc < 3)
        return -1;

    a = atoi(argv[1]);
    b = atoi(argv[2]);
    result = pf(a, b);

    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);

    return 0;
}

// 在这里定义 struct operation 和 op[] 命令表:
// struct operation { char name[8]; int (*pf)(int, int); char opchar; };
// struct operation op[] = {
//     { "add", add, '+' },
//     { "sub", sub, '-' },
//     ...
// };

int shell_parse(char *buf, char *argv[])
{
    // 在这里实现状态机切分
    return 0;
}

int command_do(int argc, char *argv[])
{
    // 在这里遍历 op[] 匹配 argv[0],设置 pf 和 opchar,调用 math_main
    return 0;
}

int main(void)
{
    char buf[256];
    int argc;
    char *argv[10];

    fgets(buf, sizeof(buf), stdin);
    argc = shell_parse(buf, argv);
    command_do(argc, argv);

    return 0;
}

填充框架的关键思考:shell_parse 中为什么要在空格处放 '\0'?状态 0 切换到状态 1 时,argv[argc++] 记录的指针指向哪里?char *argv[10]char **argv 在函数参数中有什么区别?函数指针声明 int (*pf)(int, int) 中,(*pf) 的括号为什么不能省略?

TIP

先不要往下翻看参考解答。尝试自己从 shell_parse 开始实现——这是整个命令解释器的基础。做完后思考:如果命令名大小写混用(如 Addadd),strcmp 能正确匹配吗?这个问题会作为课堂讨论展开。


深度讲解

1. 状态机命令行解析:从字符串到 argc/argv

1.1 问题建模:一行命令的语义结构

当你在终端输入 gcc -Wall main.c 时,shell 需要把这一行字符串拆成三个独立的参数。这个过程的本质是:

输入: "gcc -Wall main.c"

         空格   空格   换行符('\n')

输出: argc = 3
      argv[0] = "gcc"
      argv[1] = "-Wall"
      argv[2] = "main.c"

把一段连续的字符串按分隔符(空格)切分成多个片段,这个操作在计算机科学中称为 tokenization(词法分析)。Shell 是最常见的 tokenizer 之一——它把用户输入的命令行拆成可执行程序名和参数列表。

1.2 回顾 Lesson 13:状态机的基本概念

在 Lesson 13(车牌限行判断)中,我们学习了状态机(FSM)的基本思想——系统在任何时刻处于有限个状态之一,输入事件驱动状态切换。本课的状态机更简单,只有两个状态:

状态 0: 在空格中(between words)
状态 1: 在单词中(inside a word)

       空格                    非空格
  ┌──────────────┐       ┌──────────────┐
   状态 0 ────→   状态 1
  (空格中)     │       │  (单词中)     │
 ←────
  └──────────────┘  空格  └──────────────┘

状态切换时做什么

  • 状态 0 → 状态 1(遇到单词首字符):记录 argv[argc++] 指向这个字符——这是新参数的起点
  • 状态 1 → 状态 0(遇到空格):在当前位置放 '\0'——截断字符串,结束当前参数

1.3 逐字符遍历:while (*buf) 模式

回顾 Lesson 16(my_strcpy)中的指针遍历技巧:

pointer_traversal.c
c
// Lesson 16 的模式:用指针遍历字符串
char *p = str;
while (*p) {
    // 处理 *p
    p++;
}

shell_parse 使用完全相同的模式遍历输入:

shell_parse_traversal.c
c
int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf) {
        // 状态 0: 在空格中,遇到非空格 → 进入单词
        if (*buf != ' ' && state == 0) {
            argv[argc++] = buf;   // 记录参数起点
            state = 1;
        }

        // 状态 1: 在单词中,遇到空格 → 离开单词
        if (*buf == ' ' && state == 1) {
            *buf = '\0';           // 截断字符串
            state = 0;
        }

        buf++;
    }

    return argc;
}

关键理解*buf = '\0' 这一行直接修改了输入缓冲区——在空格位置插入字符串终止符。这意味着 argv 数组中的指针指向的是原始 buf 的子串,不需要额外的内存分配。

执行流程可视化(输入 "gcc -Wall"):

初始: buf g c c   - W a l l \0
       argv [ ? , ? , ? , ... ]
       argc = 0, state = 0

步骤 1: *buf = 'g', state == 0
 argv[0] = buf (指向 'g'), argc = 1, state = 1
 buf++

步骤 2: *buf = 'c', state == 1, 不是空格 不处理
 buf++

步骤 3: *buf = 'c', state == 1, 不是空格 不处理
 buf++

步骤 4: *buf = ' ', state == 1, 是空格
 *buf = '\0' (截断!), state = 0
 buf++

步骤 5: *buf = '-', state == 0
 argv[1] = buf (指向 '-'), argc = 2, state = 1
 buf++

... 以此类推 ...

最终: buf g c c \0 - W a l l \0
      argv[0] ─┘
      argv[1] ─────────┘
      argc = 2

1.4 边界处理:换行符 \n

fgets 读取输入时会保留用户按回车产生的换行符 \n。如果不在切分前处理掉它,\n 会被当作普通字符留在最后一个参数末尾:

newline_handling.c
c
// 方案 1: 在 shell_parse 开头去掉 \n(用 strlen)
buf[strlen(buf) - 1] = '\0';   // 前提: fgets 确实读到了 \n

// 方案 2: 在状态机循环中检测 \n 作为额外结束条件
while (*buf && *buf != '\n') {
    // ... 状态机逻辑 ...
}
if (*buf == '\n') *buf = '\0';

// 方案 3: 使用 strcspn(更安全,不依赖一定有 \n)
buf[strcspn(buf, "\n")] = '\0';

NOTE

方案 1 简单但有风险——如果输入长度超过 sizeof(buf)fgets 不会读到 \nstrlen(buf) - 1 就会错误地截掉最后一个有效字符。方案 2 和 3 更健壮。本课练习中使用方案 2。

1.5 完整 shell_parse 实现

shell_parse_complete.c
c
#include <stdio.h>

int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf && *buf != '\n') {
        if (*buf != ' ' && state == 0) {
            argv[argc++] = buf;
            state = 1;
        }
        if (*buf == ' ' && state == 1) {
            *buf = '\0';
            state = 0;
        }
        buf++;
    }
    if (*buf == '\n')
        *buf = '\0';       // 去掉换行符

    return argc;
}

两状态 FSM 的模式总结

状态机解析字符串的通用模板:
  state = 0;
  while (*p) {
      if (进入状态1的条件 && state == 0) { 动作; state = 1; }
      if (离开状态1的条件 && state == 1) { 动作; state = 0; }
      p++;
  }

这个模式可以应用到许多场景——解析 CSV、解析配置文件、解析命令行参数(如 -o output.txt-ooutput.txt 分开)。


2. 指针数组与二级指针:argv 的内存模型

2.1 指针数组 char *argv[10]

char *argv[10] 声明了一个包含 10 个元素的数组,每个元素的类型是 char*(指向字符的指针):

pointer_array_layout.c
c
char *argv[10];   // 10 个 char* 的数组

// 类型分析: 从右往左读
// argv     → argv 是...
// [10]     → 一个包含 10 个元素的数组,每个元素是...
// *        → 指针,指向...
// char     → 字符
//
// 结论: argv 是「包含 10 个 char* 的数组」
argv 的内存布局:

argv[0] ──→ "gcc\0"
argv[1] ──→ "-Wall\0"
argv[2] ──→ "main.c\0"
argv[3] ──→ NULL 或未初始化
...
argv[9] ──→ NULL 或未初始化

栈上的 argv 数组:
┌──────────┬──────────┬──────────┬─────┬──────────┐
  char*  char*  char* ...  char*
 (8字节)   │ (8字节)   │ (8字节)   │     │ (8字节)   │
└──────────┴──────────┴──────────┴─────┴──────────┘
 argv[0]    argv[1]    argv[2]          argv[9]

每个 argv[i] 是一个 8 字节的指针(64 位系统),指向 buf 中对应的子串。argv 数组本身存储在栈上,指针指向的内容仍在 buf 中。

2.2 二级指针 char **argv

当把 argv 传递给函数时,数组退化为指针,类型变为 char**

double_pointer.c
c
// main 中:
char *argv[10];
shell_parse(buf, argv);   // argv 退化为 char**

// 函数声明:
int shell_parse(char *buf, char **argv);   // 等价于 char *argv[]

// 在函数内部:
// argv[i]  等价于 *(argv + i)
// *argv    等价于 argv[0]
double_pointer_demo.c
c
#include <stdio.h>

int main(void)
{
    char *words[] = {"hello", "world", "c"};

    // 二级指针访问
    char **p = words;         // p 指向 words[0]

    printf("%s\n", *p);       // hello  (p → words[0] → "hello")
    printf("%s\n", *(p+1));   // world  (p+1 → words[1] → "world")
    printf("%c\n", **p);      // h      (双重解引用: 先取 words[0], 再取首字符)

    // 遍历指针数组
    for (char **q = words; q < words + 3; q++)
        printf("%s\n", *q);

    return 0;
}
二级指针的内存视角:

p (char**)               words 数组             字符串
┌──────┐     ┌──────────┐                 ┌──────────────┐
  ●───┼────→│ words[0] ●┼────────────────→│ h e l l o \0
└──────┘     ├──────────┤                 └──────────────┘
 words[1] ●┼────→ ┌──────────────┐
             ├──────────┤ w o r l d \0
 words[2] ●┼──┐   └──────────────┘
             └──────────┘   ┌──────┐
                           └──→│ c \0
                               └──────┘

*p   = words[0] = 指向 'h' 的指针
**p  = 'h' (双重解引用)

2.3 与 Lesson 16 的联系

Lesson 16(my_strcpy)中的 while (*dst++ = *src++) 使用了指针的逐字符操作。本课的指针数组是同一思想的升级——我们操作的「元素」从单个字符变成了整个字符串:

pointer_levels.c
c
// Lesson 16: 操作单个字符(一级指针)
char *p = "hello";
while (*p) {           // *p 是一个字符
    printf("%c", *p);
    p++;
}

// 本课: 操作字符串(二级指针)
char *argv[] = {"gcc", "-Wall", "main.c"};
for (int i = 0; i < 3; i++) {
    printf("%s\n", argv[i]);   // argv[i] 是一个字符串
}

// 本质: argv[i] 的类型是 char*(一级指针)
//       argv 的类型是 char**(二级指针)

IMPORTANT

char *argv[10](指针数组)和 char (*argv)[10](数组指针)是两个完全不同的类型。char *argv[10] 是「10 个 char* 的数组」,char (*argv)[10] 是「指向 char[10] 的指针」。括号改变了运算符的结合性——这是一个经典的 C 语言声明陷阱。函数指针的声明同样需要括号来区分类似的形式,这一点我们将在下一节看到。


3. 函数指针:指向代码的指针

3.1 指针不只指向数据

到目前为止,我们见到的指针都指向数据——int* 指向整数,char* 指向字符,char** 指向字符指针。但指针的本质是「存储地址的变量」,而代码(函数)也存储在内存中,有地址,所以也可以用指针指向它。

function_address.c
c
#include <stdio.h>

int add(int a, int b) { return a + b; }

int main(void)
{
    printf("add 的地址: %p\n", (void*)add);
    // 输出类似: add 的地址: 0x401136

    // 函数名本身就是指向函数的指针
    printf("&add 的地址: %p\n", (void*)&add);
    // 输出相同: &add 的地址: 0x401136

    return 0;
}

函数名 add&add 都表示函数的地址——这与数组名类似(数组名也是地址)。

3.2 函数指针的声明

function_pointer_declaration.c
c
// 目标函数:
int add(int a, int b) { return a + b; }

// 指向该函数的指针:
int (*pf)(int, int);       // pf 是一个指针,指向「接受两个 int、返回 int」的函数

// 类型拆解(从内向外读):
// (*pf)         → pf 是...
// (*pf)(int,int)→ 一个指针,指向接受两个 int 参数的函数
// int (*pf)...  → 该函数返回 int

括号为什么不能省略

parentheses_matter.c
c
int (*pf)(int, int);    // ✅ 函数指针: pf 指向一个返回 int 的函数
int *pf(int, int);      // ❌ 函数声明: pf 是一个返回 int* 的函数(不是指针!)
                        //    这声明了一个名叫 pf 的函数,不是变量

() 的优先级高于 *。没有括号时,int *pf(int, int) 被解析为「pf 是一个函数,接受两个 int,返回 int*」——这是一个函数声明,不是变量声明。

3.3 函数指针的使用

function_pointer_usage.c
c
#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int main(void)
{
    int (*pf)(int, int);   // 声明函数指针

    pf = add;              // pf 指向 add
    printf("%d\n", pf(10, 3));  // 通过 pf 调用 add → 13

    pf = sub;              // pf 指向 sub
    printf("%d\n", pf(10, 3));  // 通过 pf 调用 sub → 7

    // 也可以显式解引用:
    printf("%d\n", (*pf)(10, 3));  // 与 pf(10, 3) 等价 → 7

    return 0;
}

pf(10, 3)(*pf)(10, 3) 是等价的——C 语言标准允许直接通过函数指针调用,不需要显式解引用。

3.4 函数指针的核心价值:运行时选择行为

没有函数指针时,如果需要根据条件调用不同函数,代码会是这样:

without_function_pointer.c
c
// 糟糕的做法: 每次调用都要判断
if (strcmp(cmd, "add") == 0)
    result = add(a, b);
else if (strcmp(cmd, "sub") == 0)
    result = sub(a, b);
else if (strcmp(cmd, "mul") == 0)
    result = mul(a, b);
// 每次加新命令都要改这里!

有了函数指针,可以在判断阶段设置指针,在执行阶段统一调用

with_function_pointer.c
c
// 优雅的做法: 判断一次,调用一次
if (strcmp(cmd, "add") == 0)
    pf = add;
else if (strcmp(cmd, "sub") == 0)
    pf = sub;

// ... 无论前面选了哪个函数,这里统一调用:
result = pf(a, b);

这种「选择」和「使用」分离的模式,是函数指针最重要的设计意图。在本课中,全局变量 pf 作为中间桥梁,main 负责选择指向哪个函数,math_main 负责调用——两者通过 pf 解耦。

3.5 typedef 简化函数指针类型

函数指针的声明语法比较晦涩,typedef 可以大幅提升可读性:

typedef_function_pointer.c
c
// 不用 typedef: 每次都要写完整的声明
int (*pf1)(int, int);
int (*pf2)(int, int);

// 用 typedef: 定义一个类型别名
typedef int (*cmd_func)(int, int);   // cmd_func 是一个类型名

cmd_func pf1;    // 等同于 int (*pf1)(int, int);
cmd_func pf2;    // 等同于 int (*pf2)(int, int);

// 甚至可以定义函数指针数组:
cmd_func handlers[10];   // 10 个函数指针的数组
typedef_in_struct.c
c
// 在结构体中使用 typedef 后的类型
typedef int (*cmd_func)(int, int);

struct operation {
    char name[8];
    cmd_func pf;        // 清晰明了: pf 是一个函数指针
    char opchar;
};

TIP

当函数指针类型在多个地方出现时(函数参数、结构体成员、数组元素),使用 typedef 是强烈推荐的做法。它让代码意图更清晰,也降低了括号写错位置的风险。

3.6 函数指针数组

函数指针可以和数组组合——这在命令解释器中非常有用:

function_pointer_array.c
c
#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int main(void)
{
    // 函数指针数组
    int (*ops[])(int, int) = { add, sub, mul };

    // 通过索引调用不同函数
    printf("%d\n", ops[0](10, 3));   // add → 13
    printf("%d\n", ops[1](10, 3));   // sub → 7
    printf("%d\n", ops[2](10, 3));   // mul → 30

    return 0;
}

配合结构体数组,我们就能实现一个可扩展的命令表——这正是练习 3 和 4 的核心设计。


4. 结构体数组命令表:表驱动的命令分发

4.1 从 if-else 到表驱动

练习 2 中,我们用 if-else + strcmp 实现命令分发:

if_else_dispatch.c
c
if (strcmp(argv[0], "add") == 0) {
    pf = add; opchar = '+';
} else if (strcmp(argv[0], "sub") == 0) {
    pf = sub; opchar = '-';
}
// 添加新命令需要加 else if 分支

这种方法有两个问题:

  1. 不易扩展:每加一个命令就要加一个 else if 分支,代码越来越长
  2. 重复代码:每个分支的逻辑完全相同——设置 pfopchar,只是值不同

表驱动方法:把变化的部分(命令名、函数指针、操作符字符)提取到数据表中,用循环代替分支:

table_driven_dispatch.c
c
struct operation {
    char name[8];
    int (*pf)(int, int);
    char opchar;
};

// 命令表: 数据定义,不是代码逻辑
struct operation op[] = {
    { "add", add, '+' },
    { "sub", sub, '-' },
    { "mul", mul, 'x' },
    { "div", mydiv, '/' },
    { "p",   power, '^' },
};

int command_do(int argc, char *argv[])
{
    int n = sizeof(op) / sizeof(op[0]);   // 命令数量

    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            pf = op[i].pf;          // 设置全局函数指针
            opchar = op[i].opchar;  // 设置操作符字符
            return math_main(argc, argv);
        }
    }

    printf("unknown command: %s\n", argv[0]);
    return -1;
}

表驱动的优势

  • 添加新命令只需在 op[] 数组中加一行,不需要修改 command_do 的代码
  • 命令名、函数、操作符的对应关系一目了然
  • 可以轻松扩展——比如添加 help 文本、最小参数数量等字段

4.2 结构体作为数据容器

struct_anatomy.c
c
struct operation {
    char name[8];                // 命令名,如 "add"、"sub"
    int (*pf)(int, int);         // 函数指针,指向对应的计算函数
    char opchar;                 // 操作符字符,用于输出显示
};

// 内存布局(单个 struct operation):
// ┌────────────┬──────────────┬────────┐
// │ name[8]    │ pf (8 bytes) │ opchar │
// │ (8 bytes)  │              │(1 byte)│
// └────────────┴──────────────┴────────┘
//
// sizeof(struct operation) ≈ 24 字节(含对齐填充)

结构体将相关的数据打包在一起——命令名、处理函数、显示字符这三个信息属于同一个命令,放在一个结构体中比分散在三个数组里更清晰。

4.3 设计考量:扩展命令表

表驱动设计让扩展变得简单。假设我们要给每个命令添加帮助文本和最小参数数量:

extended_cmd_table.c
c
struct operation {
    char name[8];
    int (*pf)(int, int);
    char opchar;
    const char *help;      // 新增: 帮助文本
    int min_args;          // 新增: 最少参数数量
};

struct operation op[] = {
    { "add", add, '+', "add a b  → 计算 a + b", 2 },
    { "sub", sub, '-', "sub a b  → 计算 a - b", 2 },
    { "p",   power, '^', "p a b    → 计算 a ^ b", 2 },
};

// command_do 中新增参数数量检查:
if (argc - 1 < op[i].min_args) {
    printf("usage: %s\n", op[i].help);
    return -1;
}

command_do 的循环逻辑不变——它只负责「找到匹配的命令并调用」。具体的验证和处理逻辑封装在各自的函数中。

4.4 与 Lesson 09(itoa)的联系

Lesson 09 中使用了字符映射表 "0123456789ABCDEF" 来避免 16 个 if-else

itoa_table.c
c
// Lesson 09 的映射表
const char hex_chars[] = "0123456789ABCDEF";
buf[i] = hex_chars[digit];   // 一行代替 16 个 if-else

本课的结构体命令表是同一思想在更高层次的体现——用数据表代替条件分支:

Lesson 09:  数字 hex_chars[digit] 字符(一维数组映射)
本课:       命令名 op[i] 函数指针 + 操作符(结构体数组映射)

共同思想: 将「变化的部分」提取到数据中,让代码保持通用和简洁。

5. 全局函数指针与回调模式

5.1 为什么需要全局 pf

在本课的设计中,pfopchar 被声明为全局变量(文件作用域),而不是作为参数传递:

global_pf.c
c
int (*pf)(int, int);     // 全局函数指针
char opchar;             // 全局操作符字符

这样设计的原因:

  1. 解耦main 设置 pf(选择命令),math_main 使用 pf(执行计算)。两者不需要知道彼此的存在
  2. 简化函数签名math_main 只需接收 (argc, argv),不需要额外的函数指针参数
  3. 避免传递链路:如果有更多中间函数,全局变量避免了逐层传递
coupling_comparison.c
c
// 不用全局变量: 需要逐层传递
int math_main(int argc, char **argv, int (*pf)(int, int), char opchar);
int command_do(int argc, char **argv, int (*pf)(int, int), char opchar);
int main(void) {
    int (*pf)(int, int) = add;
    command_do(argc, argv, pf, '+');  // 显式传递
}

// 用全局变量: 简化接口
int math_main(int argc, char **argv);   // pf 和 opchar 从全局读取
int command_do(int argc, char **argv);
int main(void) {
    pf = add; opchar = '+';
    command_do(argc, argv);             // 简洁
}

WARNING

全局变量是一把双刃剑。它的优点是简化接口和解耦,但缺点是:

  • 任何函数都可以修改 pf,导致调试困难
  • 多线程环境下不安全(数据竞争)
  • 程序规模扩大后难以追踪状态变化

在本课的教学规模(< 100 行代码)中,全局变量是合理的选择。随着程序规模增长,应该考虑用结构体封装状态、用参数传递替代全局变量。

5.2 回调模式:被调用的「被调用者」

函数指针实现了一种称为 回调(callback) 的设计模式:

callback_pattern.c
c
// 通用执行框架: 不关心具体是什么函数
int math_main(int argc, char *argv[])
{
    int a = atoi(argv[1]);
    int b = atoi(argv[2]);
    int result = pf(a, b);     // pf 是「回调」——由外部决定具体行为
    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);
    return 0;
}

// 调用者: 决定回调什么函数
pf = add; opchar = '+';
math_main(argc, argv);   // 执行加法

pf = power; opchar = '^';
math_main(argc, argv);   // 执行幂运算

math_main 是一个通用框架,它不知道 pf 指向什么函数——这是调用者的决定。这种模式在标准库中随处可见:

standard_callback_examples.c
c
// qsort: 回调比较函数
int cmp(const void *a, const void *b) { ... }
qsort(arr, n, sizeof(int), cmp);   // cmp 是回调

// signal: 回调信号处理函数
void handler(int sig) { ... }
signal(SIGINT, handler);           // handler 是回调

// atexit: 回调退出函数
void cleanup(void) { ... }
atexit(cleanup);                   // cleanup 是回调

6. void* 泛型函数指针与类型转换

6.1 不同签名的函数无法直接共用指针

练习 2 的 addsub 签名是 int (int, int),但如果将来要支持 float add_float(float, float),签名就变成了 float (float, float)——无法赋值给 int (*pf)(int, int)

type_mismatch.c
c
int add_int(int a, int b) { return a + b; }
float add_float(float a, float b) { return a + b; }

int (*pf)(int, int);
pf = add_int;      // ✅ 类型匹配
pf = add_float;    // ❌ 类型不匹配,编译警告或错误

6.2 void* 作为通用函数指针容器

C 标准允许 void* 与函数指针之间的转换(虽然不是所有编译器都严格支持),但 POSIX 明确要求支持。可以用 void* 来存储任意类型的函数指针:

void_star_function_pointer.c
c
#include <stdio.h>

int add_int(int a, int b) { return a + b; }
float add_float(float a, float b) { return a + b; }

int main(void)
{
    void *vpf;                              // 通用指针

    vpf = (void*)add_int;                   // 存储函数指针
    int int_result = ((int (*)(int, int))vpf)(10, 3);   // 强制转换后调用
    printf("int: %d\n", int_result);        // int: 13

    vpf = (void*)add_float;                 // 存储另一个类型的函数指针
    float float_result = ((float (*)(float, float))vpf)(10.5f, 3.2f);
    printf("float: %.1f\n", float_result);  // float: 13.7

    return 0;
}

关键步骤

  1. (void*)add_int — 将函数指针转为 void*
  2. (int (*)(int, int))vpf — 将 void* 转回正确的函数指针类型
  3. 然后才能调用

CAUTION

使用 void* 存储函数指针时,调用者必须知道原始的函数签名并正确转换。如果转换类型错误(如把 float (*)(float, float) 当成 int (*)(int, int) 调用),结果是未定义行为——参数传递和返回值处理都会出错。这种技术应该谨慎使用,仅在确实需要多类型支持的场景(如插件系统)中使用。

6.3 结构体封装类型信息

更安全的做法是在结构体中同时存储函数指针和类型标记:

typed_function_pointer.c
c
typedef enum { CMD_INT, CMD_FLOAT } cmd_type;

struct operation {
    char name[8];
    void *pf;               // 通用函数指针
    cmd_type type;          // 类型标记
    char opchar;
};

// 调用时根据 type 做正确的转换:
if (op[i].type == CMD_INT) {
    int (*f)(int, int) = (int (*)(int, int))op[i].pf;
    result = f(a, b);
} else if (op[i].type == CMD_FLOAT) {
    float (*f)(float, float) = (float (*)(float, float))op[i].pf;
    float_result = f(fa, fb);
}

NOTE

本课的课后练习 3 要求实现整型和浮点型的 add 命令,使用的就是这种 void* + 类型标记的技术。它是「多态」在 C 语言中的一种手工实现。


7. 综合:从输入到执行的完整链路

7.1 程序执行流程

用户输入 "add 10 3\n"


   fgets() 读取到 buf[256]


   shell_parse(buf, argv)
  状态机切分: 空格处放 '\0'
  argv[0]="add", argv[1]="10", argv[2]="3"

   command_do(argc, argv)
  遍历 op[] 数组
  strcmp(argv[0], op[i].name)  匹配 "add"
  pf = add; opchar = '+';

   math_main(argc, argv)
  atoi(argv[1])  10
  atoi(argv[2])  3
  pf(10, 3)  add(10, 3)  13

   printf("result: 10 + 3 = 13\n")

7.2 完整程序

shell_complete.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int (*pf)(int, int);
char opchar;

/* ── 计算函数 ── */
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int mydiv(int a, int b) { if (b != 0) return a / b; return 0; }

int power(int a, int b)
{
    int result = 1;
    for (int i = 0; i < b; i++)
        result *= a;
    return result;
}

/* ── 命令行解析: 状态机 ── */
int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf && *buf != '\n') {
        if (*buf != ' ' && state == 0) {
            argv[argc++] = buf;
            state = 1;
        }
        if (*buf == ' ' && state == 1) {
            *buf = '\0';
            state = 0;
        }
        buf++;
    }
    if (*buf == '\n') *buf = '\0';
    return argc;
}

/* ── 命令表: 结构体数组 ── */
struct operation {
    char name[8];
    int (*pf)(int, int);
    char opchar;
} op[] = {
    { "add", add, '+' },
    { "sub", sub, '-' },
    { "mul", mul, 'x' },
    { "div", mydiv, '/' },
    { "p",   power, '^' },
};

/* ── 执行框架: 回调 ── */
int math_main(int argc, char *argv[])
{
    int a, b;

    if (argc < 3)
        return -1;

    a = atoi(argv[1]);
    b = atoi(argv[2]);

    int result = pf(a, b);

    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);

    return 0;
}

/* ── 命令分发: 表驱动 ── */
int command_do(int argc, char *argv[])
{
    int n = sizeof(op) / sizeof(op[0]);

    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            pf = op[i].pf;
            opchar = op[i].opchar;
            return math_main(argc, argv);
        }
    }

    printf("unknown command: %s\n", argv[0]);
    return -1;
}

/* ── 主程序 ── */
int main(void)
{
    char buf[256];
    char *argv[10];

    printf("$ ");
    fgets(buf, sizeof(buf), stdin);

    int argc = shell_parse(buf, argv);
    command_do(argc, argv);

    return 0;
}

模块职责总结

模块职责核心技术
shell_parse字符串 → argc/argv状态机、指针截断
op[] 命令表命令名 → 函数 + 操作符结构体数组、函数指针
command_do查表匹配、设置全局状态表驱动、strcmp
math_main参数解析、调用计算、输出结果回调、atoi
main输入、组装流程fgets

7.3 设计原则总结

本课的设计体现了几个重要的编程原则:

  1. 关注点分离:解析(shell_parse)、匹配(command_do)、执行(math_main)各司其职
  2. 数据驱动:命令表用数据而非代码表达「哪个名字对应哪个函数」
  3. 开闭原则:添加新命令只需修改数据(op[]),不需要修改逻辑代码
  4. 指针的多种角色
    • char* — 指向字符串的游标(shell_parse
    • char** — 字符串数组(argv
    • int (*)(int, int) — 指向代码的指针(pf

参考解答

练习 1: shell_parse 状态机切分
shell_parse_solution.c
c
#include <stdio.h>

int main(void)
{
    char buf[256];
    char *argv[32];
    int argc = 0;
    int state = 0;
    int i;

    fgets(buf, sizeof(buf), stdin);

    for (i = 0; buf[i] && buf[i] != '\n'; i++) {
        if (buf[i] != ' ' && state == 0) {
            argv[argc++] = &buf[i];
            state = 1;
        }
        if (buf[i] == ' ' && state == 1) {
            buf[i] = '\0';
            state = 0;
        }
    }
    if (buf[i] == '\n')
        buf[i] = '\0';

    for (i = 0; i < argc; i++) {
        if (i > 0) printf("|");
        printf("%s", argv[i]);
    }
    printf("\n");

    return 0;
}

解析:核心是两状态 FSM。state == 0 且遇到非空格字符时,记录 argv[argc++] 指向当前位置——这是新参数的起点。state == 1 且遇到空格时,在当前位置放 '\0' 截断字符串,状态回到 0。注意用 && buf[i] != '\n' 处理换行符,防止它被当作普通字符留在最后一个参数末尾。如果改用 while (*buf) 的指针版本,可以避免维护下标变量 i

练习 2: 函数指针命令分发
function_ptr_solution.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int (*pf)(int, int);
char opchar;

int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf && *buf != '\n') {
        if (*buf != ' ' && state == 0) { argv[argc++] = buf; state = 1; }
        if (*buf == ' ' && state == 1) { *buf = '\0'; state = 0; }
        buf++;
    }
    if (*buf == '\n') *buf = '\0';
    return argc;
}

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int math_main(int argc, char *argv[])
{
    int a, b;
    int result;

    if (argc < 3)
        return -1;

    a = atoi(argv[1]);
    b = atoi(argv[2]);
    result = pf(a, b);

    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);

    return 0;
}

int main(void)
{
    char buf[256];
    int argc;
    char *argv[10];

    fgets(buf, sizeof(buf), stdin);
    argc = shell_parse(buf, argv);

    if (strcmp(argv[0], "add") == 0) {
        pf = add;
        opchar = '+';
    } else if (strcmp(argv[0], "sub") == 0) {
        pf = sub;
        opchar = '-';
    }

    math_main(argc, argv);

    return 0;
}

解析:函数指针 pf 的声明 int (*pf)(int, int) 中,括号必不可少——int *pf(int, int) 会被解析为返回 int* 的函数声明。pf = add 将函数地址赋给指针(add&add 等价)。全局 pf 作为 mainmath_main 之间的桥梁——main 负责设置 pf 指向哪个函数,math_main 负责调用 pf(a, b)atoi"10""3" 转为整数 10 和 3。

练习 3: 结构体数组命令表
cmd_table_solution.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int (*pf)(int, int);
char opchar;

int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;

    while (*buf && *buf != '\n') {
        if (*buf != ' ' && state == 0) { argv[argc++] = buf; state = 1; }
        if (*buf == ' ' && state == 1) { *buf = '\0'; state = 0; }
        buf++;
    }
    if (*buf == '\n') *buf = '\0';
    return argc;
}

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int mydiv(int a, int b) { if (b != 0) return a / b; return 0; }

int power(int a, int b)
{
    int result = 1;
    for (int i = 0; i < b; i++)
        result *= a;
    return result;
}

int math_main(int argc, char *argv[])
{
    int a, b;
    int result;

    if (argc < 3)
        return -1;

    a = atoi(argv[1]);
    b = atoi(argv[2]);
    result = pf(a, b);

    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);

    return 0;
}

struct operation {
    char name[8];
    int (*pf)(int, int);
    char opchar;
} op[] = {
    { "add", add, '+' },
    { "sub", sub, '-' },
    { "mul", mul, 'x' },
    { "div", mydiv, '/' },
    { "p",   power, '^' },
};

int command_do(int argc, char *argv[])
{
    int n = sizeof(op) / sizeof(op[0]);

    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            pf = op[i].pf;
            opchar = op[i].opchar;
            return math_main(argc, argv);
        }
    }

    printf("unknown command: %s\n", argv[0]);
    return -1;
}

int main(void)
{
    char buf[256];
    int argc;
    char *argv[10];

    fgets(buf, sizeof(buf), stdin);
    argc = shell_parse(buf, argv);
    command_do(argc, argv);

    return 0;
}

解析struct operation 将命令名、函数指针和操作符合并为一个整体。op[] 是命令表——所有命令信息集中在一处。command_dosizeof(op) / sizeof(op[0]) 计算命令数量(编译时确定),然后遍历匹配。添加新命令只需在 op[] 中加一行,command_do 的循环逻辑完全不需要修改。math_main 中的 pf(a, b) 调用不关心具体是哪个函数——这正是回调模式的精髓。

练习 4: 综合

练习 4 的解答与练习 3 几乎完全相同——因为练习 3 已经包含了 shell_parsecommand_do 的完整实现。练习 4 是验证你把前三个练习组合起来的能力。完整的参考实现就是上面练习 3 的代码,它在 main 中将 shell_parsecommand_do 连接起来:

c
argc = shell_parse(buf, argv);   // 步骤 1: 解析
command_do(argc, argv);          // 步骤 2: 分发执行

如果分开完成练习 1(解析)和练习 3(命令表),把它们组合到同一个文件中就是练习 4 的答案。


课堂讨论

  1. 本课的 shell_parse 只支持空格作为分隔符。如果要支持引号包围的参数(如 echo "hello world""hello world" 是一个参数),状态机应该如何扩展?需要增加哪些状态?

  2. 函数指针 int (*pf)(int, int) 的括号为什么不能省略?int *pf(int, int) 声明的是什么?如果用 typedef 简化,怎么写?

  3. char *argv[10](指针数组)和 char **argv(二级指针)在作为函数参数时是等价的,但作为局部变量声明时有何不同?什么情况下用哪种?

  4. 本课的命令表 op[] 使用结构体数组实现。如果改用函数指针数组 int (*ops[])(int, int) 配合字符串数组 char *names[],两个数组分开管理,会有什么优缺点?

  5. math_main 中的 pf 是全局变量。如果把它改为函数参数(int math_main(int argc, char **argv, int (*pf)(int, int), char opchar)),程序需要做哪些修改?这样改的好处和代价是什么?

  6. 如果用户输入 "add 10"(缺少第二个操作数),math_main 中的 argc < 3 检查能捕获这个错误。但如果用户输入 "add 10 abc"(第二个操作数不是数字),atoi("abc") 返回 0,程序会静默地输出 result: 10 + abc = 10。如何改进程序来检测这种非数字输入?

讨论答案

Q1: 支持引号包围参数的状态机扩展

当前的状态机只有两个状态——空格中和单词中。要支持引号,需要增加状态:

状态 0: 在空格中
状态 1: 在单词中(无引号)
状态 2: 在双引号中
状态 3: 在单引号中
shell_parse_with_quotes.c
c
int shell_parse(char *buf, char *argv[])
{
    int argc = 0;
    int state = 0;   // 0=空格, 1=单词, 2=双引号, 3=单引号

    while (*buf && *buf != '\n') {
        switch (state) {
        case 0:  // 在空格中
            if (*buf == '"')  { argv[argc++] = buf + 1; state = 2; }
            else if (*buf == '\'') { argv[argc++] = buf + 1; state = 3; }
            else if (*buf != ' ') { argv[argc++] = buf; state = 1; }
            break;
        case 1:  // 在无引号单词中
            if (*buf == ' ') { *buf = '\0'; state = 0; }
            break;
        case 2:  // 在双引号中
            if (*buf == '"') { *buf = '\0'; state = 0; }
            break;
        case 3:  // 在单引号中
            if (*buf == '\'') { *buf = '\0'; state = 0; }
            break;
        }
        buf++;
    }
    return argc;
}

核心变化:引号内的空格不触发截断——状态 2 和 3 只在遇到匹配的闭合引号时才截断。真实的 shell(如 bash)的状态机远比这复杂,还需要处理转义字符(\")、嵌套引号等。但本课的两状态 FSM 是最简化的入门版本,目的是让学生理解状态机的核心思想。

Q2: 函数指针声明的括号与 typedef

int (*pf)(int, int) 中,(*pf) 的括号改变了运算优先级。C 语言中 ()(函数调用)的优先级高于 *(解引用),所以:

  • int *pf(int, int) — 被解析为 int *(pf(int, int)):pf 是一个函数,接受两个 int,返回 int*。这是一个函数声明,不是变量。
  • int (*pf)(int, int) — pf 是一个指针,指向「接受两个 int、返回 int」的函数。这是一个变量声明

typedef 简化:

c
// 定义类型别名
typedef int (*cmd_func)(int, int);

// 使用别名
cmd_func pf;                    // 等同于 int (*pf)(int, int);
cmd_func handlers[10];          // 函数指针数组
struct operation {
    char name[8];
    cmd_func pf;                // 清晰明了
    char opchar;
};

typedef 的语法是「先写出变量声明,再在开头加 typedef」——原来 int (*pf)(int, int) 声明变量 pf,加上 typedefpf 变成了类型名 cmd_func

Q3: 指针数组 vs 二级指针的区别

作为函数参数时,char *argv[10] 退化为 char **argv——数组的大小信息丢失了。这是 C 语言的数组退化规则:

c
void f(char *argv[10]);    // 等价于 void f(char **argv)
void g(char **argv);       // 与上面完全相同

但作为局部变量时,它们完全不同:

c
char *argv1[10];    // 在栈上分配 10×8=80 字节(64位系统)
                    // argv1 本身是数组名,不可修改

char **argv2;       // 在栈上分配 8 字节(一个指针)
                    // argv2 是变量,可以修改指向

选择指南:

  • 当参数数量有上限且已知(如本课的 10 个参数)时,用 char *argv[10]——简单直接
  • 当参数数量未知、需要动态分配时,用 char **argv = malloc(n * sizeof(char*))——灵活
  • 函数参数用 char **argv——与数组写法等价,且更通用(不限制大小)
Q4: 结构体数组 vs 分开的数组

结构体数组(本课方案)

c
struct operation op[] = {
    { "add", add, '+' },
    { "sub", sub, '-' },
};
// 优点: 相关数据在一起,增删改不易出错
// 缺点: 结构体可能有对齐填充,稍微浪费内存

分开的数组

c
char *names[] = { "add", "sub" };
int (*funcs[])(int, int) = { add, sub };
char opchars[] = { '+', '-' };

// 优点: 内存紧凑,无对齐浪费
// 缺点: 三个数组需要保持同步——增删命令时要同时修改三个数组,容易遗漏

结论:在大多数情况下,结构体数组更好——代码更清晰,维护更安全。只有当性能分析证明内存对齐是真正的瓶颈时,才考虑拆分为多个数组。本课选择结构体数组,也是因为它是「数据打包」这个重要编程思想的自然体现。

Q5: 全局 pf 改为参数传递的利弊

改为参数传递

c
// math_main 增加参数
int math_main(int argc, char **argv, int (*pf)(int, int), char opchar)
{
    int a = atoi(argv[1]);
    int b = atoi(argv[2]);
    int result = pf(a, b);                    // 使用参数而非全局变量
    printf("result: %s %c %s = %d\n", argv[1], opchar, argv[2], result);
    return 0;
}

// command_do 也要传递
int command_do(int argc, char **argv)
{
    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            return math_main(argc, argv, op[i].pf, op[i].opchar);
            //                             ^^^^^^^^  ^^^^^^^^^^^^
            //                             显式传递函数指针和操作符
        }
    }
    return -1;
}

好处

  • 消除了全局状态,math_main 是纯函数——输出只依赖输入
  • 更容易测试:可以传入不同的函数指针测试不同行为
  • 多线程安全

代价

  • 函数签名变长,调用时需要多传两个参数
  • 如果调用链更深(A→B→C→math_main),需要逐层传递

对于本课的教学规模,全局变量是合理的选择——它让学生先聚焦于「函数指针是什么」这个核心概念,而不是被参数传递的细节分散注意力。在后续课程或实际项目中,参数传递是更好的实践。

Q6: 检测非数字输入

atoi("abc") 返回 0 且不报告错误,这是 atoi 的已知缺陷。改进方案:

robust_input_parsing.c
c
#include <stdlib.h>
#include <errno.h>

// 方案 1: 使用 strtol(更安全)
char *endptr;
errno = 0;
long a = strtol(argv[1], &endptr, 10);
if (errno != 0 || *endptr != '\0' || endptr == argv[1]) {
    printf("error: '%s' is not a valid integer\n", argv[1]);
    return -1;
}

// 方案 2: 使用 sscanf 检查返回值
int a;
if (sscanf(argv[1], "%d", &a) != 1) {
    printf("error: '%s' is not a valid integer\n", argv[1]);
    return -1;
}

strtol 是最佳选择:endptr 指向第一个非数字字符,如果 *endptr == '\0' 说明整个字符串都被成功解析。sscanf 更简洁但无法区分 "123abc" 这样的输入(sscanf 会读取 123 并忽略 abc)。

在初学阶段,atoi 足够满足练习需求。但在生产代码中,应该使用 strtolsscanf 配合错误检查。这也是 C 语言学习中一个重要的进阶点——从「能用」到「健壮」。


课后练习

练习 1:实现回调函数 callback

原课代码末尾有一行注释:

c
//callback(pf, argc, argv);

请实现这个 callback 函数。它的功能是接收一个函数指针 pfargc/argv,然后调用 pf(argc, argv)

知识点提示callback 的函数签名应该是 void callback(int (*pf)(int, char**), int argc, char **argv)。注意第一个参数的类型——pf 接受的函数签名是 int (int, char**),与 do_cmddo_add 一致。在函数内部只需一行:pf(argc, argv)。这是回调模式的最简实现。

参考解答
callback_solution.c
c
#include <stdio.h>

// 回调函数: 接收一个函数指针,调用它
void callback(int (*pf)(int argc, char **argv), int argc, char **argv)
{
    pf(argc, argv);
}

// 使用示例
int do_cmd(int argc, char **argv)
{
    printf("argc = %d\n", argc);
    for (int i = 0; i < argc; i++)
        printf("argv[%d]: <%s>\n", i, argv[i]);
    return 0;
}

int main(void)
{
    char *argv[] = {"cmd", "arg1", "arg2"};
    callback(do_cmd, 3, argv);
    // 等价于: do_cmd(3, argv);
    return 0;
}

解析callback 本质上就是一个「调用包装器」——它接受一个函数指针和参数,然后调用这个函数。虽然这个例子看起来像是多此一举(直接调用 do_cmd 更简单),但 callback 的价值在于它可以在调用前后添加通用逻辑(如计时、日志、错误处理)。

练习 2:支持整数和浮点数的 add 命令

修改程序,使得 add 命令可以同时处理整数和浮点数。思路:定义 add_int(int, int)add_float(float, float),使用 void* 函数指针和类型标记在命令表中区分。

期望行为:

  • add 10 3result: 10 + 3 = 13(整数)
  • add 10.5 3.2result: 10.500000 + 3.200000 = 13.700000(浮点数)

知识点提示:需要在命令表中增加类型标记(如 CMD_INTCMD_FLOAT),command_do 中根据类型标记做不同的参数解析(atoi vs atof)和函数调用。void* 可以存储任意类型的函数指针,调用时需要强制转换回正确的类型。浮点数的输出格式用 %f,默认显示 6 位小数。

参考解答
int_float_add.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef enum { CMD_INT, CMD_FLOAT } cmd_type;

struct operation {
    char name[8];
    void *pf;               // 通用函数指针
    cmd_type type;          // 类型标记
    char opchar;
};

int add_int(int a, int b) { return a + b; }
float add_float(float a, float b) { return a + b; }

struct operation op[] = {
    { "add_int",   add_int,   CMD_INT,   '+' },
    { "add_float", add_float, CMD_FLOAT, '+' },
};

int command_do(int argc, char *argv[])
{
    int n = sizeof(op) / sizeof(op[0]);

    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            if (op[i].type == CMD_INT) {
                int a = atoi(argv[1]);
                int b = atoi(argv[2]);
                int (*f)(int, int) = (int (*)(int, int))op[i].pf;
                int result = f(a, b);
                printf("result: %d %c %d = %d\n", a, op[i].opchar, b, result);
            } else if (op[i].type == CMD_FLOAT) {
                float a = atof(argv[1]);
                float b = atof(argv[2]);
                float (*f)(float, float) = (float (*)(float, float))op[i].pf;
                float result = f(a, b);
                printf("result: %f %c %f = %f\n", a, op[i].opchar, b, result);
            }
            return 0;
        }
    }
    printf("unknown command: %s\n", argv[0]);
    return -1;
}

解析void* 作为通用容器存储两种签名的函数指针。cmd_type 枚举标记了函数指针的实际类型,调用时用 (int (*)(int, int))(float (*)(float, float)) 强制转换。这是 C 语言实现「多态」的手工方式——C++ 的虚函数表在底层原理上与此类似,只是编译器自动完成了类型转换。

练习 3:自动匹配命令(函数指针数组 + 结构体数组)

扩展程序,实现一个更通用的命令解释器框架:命令表中增加 help 字段,并实现一个内置的 help 命令,列出所有可用命令及其说明。

期望行为:

  • help → 列出所有命令及说明
  • add 1 2 → 正常执行

知识点提示:在 struct operation 中添加 const char *help 字段。help 命令不需要计算函数——可以定义一个特殊的 help_cmd 函数,它遍历 op[] 数组打印所有命令信息;或者直接在 command_do 中特殊处理 "help" 字符串匹配。推荐用后者,因为 help 需要访问 op[] 数组(全局数据),不适合封装到独立的计算函数中。

参考解答
help_command.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct operation {
    char name[8];
    int (*pf)(int, int);
    char opchar;
    const char *help;
} op[] = {
    { "add", add, '+', "add a b   → 计算 a + b" },
    { "sub", sub, '-', "sub a b   → 计算 a - b" },
    { "mul", mul, 'x', "mul a b   → 计算 a x b" },
    { "div", mydiv, '/', "div a b   → 计算 a / b" },
    { "p",   power, '^', "p a b     → 计算 a ^ b" },
};

int command_do(int argc, char *argv[])
{
    int n = sizeof(op) / sizeof(op[0]);

    // 特殊处理 help 命令
    if (strcmp(argv[0], "help") == 0) {
        printf("Available commands:\n");
        for (int i = 0; i < n; i++)
            printf("  %s\n", op[i].help);
        return 0;
    }

    // 普通命令匹配
    for (int i = 0; i < n; i++) {
        if (strcmp(argv[0], op[i].name) == 0) {
            int a = atoi(argv[1]);
            int b = atoi(argv[2]);
            int result = op[i].pf(a, b);
            printf("result: %d %c %d = %d\n", a, op[i].opchar, b, result);
            return 0;
        }
    }

    printf("unknown command: %s (type 'help' for list)\n", argv[0]);
    return -1;
}

解析help 命令的实现方式体现了命令表的另一个优势——元数据(命令描述)与命令逻辑存储在一起,help 命令可以自动生成命令列表,不需要手动维护两份信息。在 struct operation 中增加字段不会影响已有命令的匹配逻辑。

练习 4:支持负数参数

当前程序使用 atoi 解析参数,atoi("-5") 返回 -5,但 shell_parse 的切分逻辑把 -5 当成一个单词,这没问题。然而如果用户输入 "add 10 - 3"(减号两边有空格),- 会被当作独立的参数,atoi("-") 返回 0。请改进 shell_parsemath_main 来处理这种情况——把 - 后面的数字当作负数。

知识点提示:可以在 shell_parse 之后增加一个「合并阶段」——遍历 argv,如果发现某个参数是孤立的 - 且后面跟着一个数字参数,就把它们合并。另一种思路是在 math_main 中增加参数数量检查——argc 大于 3 时给出明确错误提示。核心是理解 shell_parse 的局限性:它只知道空格分隔,不理解语义。

参考解答
negative_number_merge.c
c
// 在 shell_parse 之后增加合并阶段
int merge_negative(char *argv[], int argc)
{
    int new_argc = 0;

    for (int i = 0; i < argc; i++) {
        if (argv[i][0] == '-' && argv[i][1] == '\0' && i + 1 < argc) {
            // 孤立的 '-' 后面跟着数字 → 合并
            argv[i + 1][-1] = '-';  // 把 '-' 写回原字符串(紧贴数字)
            // 注意: 这依赖于 '-' 和数字在原 buffer 中是相邻的
            // 实际更安全的做法是直接合并字符串
            i++;  // 跳过下一个参数(已合并)
        }
        argv[new_argc++] = argv[i];
    }

    return new_argc;
}

解析:这个问题触及了 shell 解析的深层复杂性——-3- 3 在数学上是相同的,但在词法分析层面是两种不同的 token 序列。真实的 shell 不做这种「语义合并」,而是由被调用的程序自己处理参数语义。更好的做法是:如果用户输入 "add 10 - 3"math_main 检测到 argc == 4 时给出明确提示:"usage: add a b"

练习 5:实现交互式 REPL 循环

将程序改为交互式:显示 $ 提示符,读取命令,执行,然后再次显示提示符——如此循环,直到用户输入 quit 或按 Ctrl+D。

期望行为:

$ add 10 3
result: 10 + 3 = 13
$ sub 10 3
result: 10 - 3 = 7
$ quit
bye!

知识点提示:用 while (1) 无限循环包围当前的 main 逻辑。fgets 返回 NULL 表示 EOF(Ctrl+D),此时退出循环。在 shell_parse 之前检查 buf[0] == '\n'(空行,跳过),在 command_do 之前检查 strcmp(argv[0], "quit") == 0 来退出。回顾 Lesson 09 中 do-while 的用法,以及 Lesson 13 中循环控制的设计。

参考解答
repl_loop.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ... shell_parse, command_do, op[], math_main 等保持不变 ...

int main(void)
{
    char buf[256];
    char *argv[10];

    printf("Welcome to mini-shell! Type 'quit' to exit.\n");

    while (1) {
        printf("$ ");
        if (fgets(buf, sizeof(buf), stdin) == NULL) {
            printf("\nbye!\n");
            break;   // EOF (Ctrl+D)
        }

        // 跳过空行
        if (buf[0] == '\n')
            continue;

        int argc = shell_parse(buf, argv);

        if (argc == 0)
            continue;   // 全是空格

        if (strcmp(argv[0], "quit") == 0) {
            printf("bye!\n");
            break;
        }

        command_do(argc, argv);
    }

    return 0;
}

解析:REPL(Read-Eval-Print Loop)是交互式程序的经典模式——Python、Node.js、bash 都使用这个模式。本课的核心组件(shell_parsecommand_domath_main)恰好构成了 REPL 的 Eval 部分。fgets 返回 NULL 的检测是关键——它让程序能正确响应 EOF,而不是陷入死循环。


参考资料

"The proper use of pointers is the most difficult part of C programming, and the most rewarding." — Brian W. Kernighan

Released under the MIT License.