1. 命令行下的隐藏入口main 函数真的能接收参数吗1.1 为什么你写的 main 什么都没有程序照样能跑很多初学者第一次接触到int main(int argc, char *argv[])时心里都会犯嘀咕我平时写的void main()或者int main()不也能正常运行吗多出来的argc和argv到底是干什么用的删掉它们程序会不会出问题先说结论main函数是程序启动后操作系统调用的第一个函数它本质上和其它函数没有任何区别——你可以给它传参数它也可以返回一个整数值。你平时写无参数版本能跑是因为 C 语言标准允许省略参数列表中的形参声明这不影响操作系统把命令行信息塞进程序的启动流程里只是你选择了不去接收而已。真正的背景是操作系统在启动一个可执行文件时会把用户在终端输入的一整条命令“拆开”分成若干个单词。比如你在 Linux 终端敲了这么一行./student_manage add zhangsan 90系统会将./student_manage、add、zhangsan、90这四个字符串统统准备好并且在进入main之前用某种机制把它们传递给你的程序。如果你写了argc和argv就能拿到这批数据如果你不写这批东西也不会消失只是你选择“视而不见”。所以argcargument count代表参数个数argvargument vector代表参数字符串数组。以刚才那条命令为例argc的值等于 4argv[0]指向字符串./student_manageargv[1]指向add以此类推。1.2 argc 和 argv 的真实形态别被“数组”两个字骗了argv的类型看起来像char *argv[]但更准确的理解是它指向一个长度为argc的指针数组数组的每个元素又是一个char *指针分别指向一个以\0结尾的普通字符串。换句话说argv本质上是一个“指向指针的指针”所以你也可以把它声明成char **argv这两种写法是完全等价的。这里有两个新手最容易踩的坑。第一个坑是argv[0]不一定等于程序名。在大多数操作系统上它是启动程序时使用的路径字符串但如果你通过exec系列函数启动一个程序完全可以把argv[0]改成任何内容。所以不要把“程序名”当成绝对可靠的信息它只是一个约定俗成的值。第二个坑是argc至少为 1。即便你运行程序时一个参数都没给操作系统也会把argv[0]放进来argc的值至少是 1。有些教科书让你在写main时不检查argc就直接用argv[1]这在没有任何参数时会导致数组越界访问属于未定义行为程序可能直接崩溃。我建议每个写main的人第一反应就是把参数个数校验写成一道“肌肉记忆”。一个健壮的入口函数往往是这样开头的int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 参数1 参数2 ...\n, argv[0]); return 1; } // 剩下的逻辑才安全 return 0; }这里把错误信息输出到stderr而不是stdout是一个非常重要的小习惯。终端用户通常会用管道把标准输出重定向到文件或者其他程序里如果你把提示信息打到stdout用户重定向之后就看不到任何提示了。而stderr不受重定向影响错误信息总能显示在终端上。1.3 第一个完整示例把传进来的所有参数逐一打印出来为了让你直观感受到argc和argv的分布我给你写一个最简单的参数回显程序。把下面的代码保存为echo_args.c#include stdio.h int main(int argc, char *argv[]) { printf(收到 %d 个参数\n, argc); for (int i 0; i argc; i) { printf(argv[%d] %s\n, i, argv[i]); } return 0; }编译并运行gcc echo_args.c -o echo_args ./echo_args hello world 2024输出结果是这样的收到 4 个参数 argv[0] ./echo_args argv[1] hello argv[2] world argv[3] 2024注意2024虽然看起来像数字但在argv里它只是一个字符串如果你要拿它做加法、比较大小必须先转换成整型。这一点放到后面实战环节再讲。写完这段代码之后我建议你在终端里多做几个实验不传参数跑一次、传一个带空格传一次、传一个空字符串传一次。你会发现带空格的参数必须用引号包起来才不会被拆开空字符串在argv里是一个长度为 0 的合法字符串但argc依然会计数。这些细节在实际开发中都是容易出问题的地方。2. 参数不能直接当数字用命令行传参的转换误区2.1 atoi 和 strtol哪个才是正经的字符串转整数函数命令行传进来的参数全是字符串这个前面已经强调了。比如你写了一个计算器程序用户执行./calc 5 3argv[1]里存的是5这个字符串不是整数 5。你需要用函数把它解析成整型数据。很多人第一反应是atoi因为教科书里最常见、用起来也最简单int a atoi(argv[1]);但atoi有一个很要命的缺陷你无法判断转换是否成功。atoi(abc)返回 0atoi(0)也返回 0你根本分不清是“合法地转换成了 0”还是“解析失败返回了 0”。如果用户输错参数程序可能在后面做除法时直接崩溃。标准库里给的正经方案是strtolstring to long。它的声明长这样long strtol(const char *nptr, char **endptr, int base);endptr是个“哨兵指针”——转换结束后它会指向原字符串中未能被解析的第一个字符位置。如果endptr指向的位置是字符串末尾的\0说明整个字符串都被成功解析了如果指向了别的字符说明后面还拖了尾巴如果转换完全失败endptr会指向字符串的起始位置。配合errno还能捕捉溢出情况。我给你写一个安全解析函数以后遇到场景可以直接抄#include stdio.h #include stdlib.h #include errno.h int parse_int(const char *str, int *out) { char *end; long val; errno 0; val strtol(str, end, 10); // 完全没解析到任何数字 if (end str) { return -1; } // 后面还有非空白字符 while (*end ! \0) { if (*end ! *end ! \t) { return -1; } end; } // 超出 int 范围 if (errno ERANGE || val -2147483648L || val 2147483647L) { return -1; } *out (int)val; return 0; }这个函数可以解析 42、42这样的输入拒绝42abc、abc之类的不合法输入。实际工程里命令行参数的合法性校验非常重要不要图省事直接用atoi。2.2 浮点数怎么办strtod 的用法和精度提醒整型有strtol浮点型对应的就是strtod。它的用法和strtol几乎一模一样只是转换目标是double。它同样能通过endptr判断解析是否完整也能通过errno捕捉溢出。#include stdio.h #include stdlib.h #include errno.h int parse_double(const char *str, double *out) { char *end; double val; errno 0; val strtod(str, end); if (end str) { return -1; } while (*end ! \0) { if (*end ! *end ! \t) { return -1; } end; } if (errno ERANGE) { return -1; } *out val; return 0; }另外提醒一句double之间的比较永远不要用要判断两个浮点数是否“相等”通常的做法是看它们的差的绝对值是否小于一个很小的阈值比如1e-9。命令行传入的0.1在程序内部存储时并不是精确的 0.1这种误差虽然在单次直接比较时很难暴露但一旦你拿它去判断“等于某个关键值”就会出问题。2.3 参数解析的常见设计模式选项、参数、位置参数命令行程序做到后面你会遇到一个更普遍的问题参数的种类太多怎么设计才清晰。最常见的风格有两种第一种是“位置参数”。参数按照固定的顺序排布比如./add_student zhangsan 90第一个位置是名字第二个位置是分数。这种风格适合参数少、功能单一的小工具。第二种是“选项参数”。用户会看到类似-n、-f file.txt、--verbose这样的写法这是标准的 Unix 命令行风格。自己实现时你可以用一个循环扫描argv逐个判断当前字符串是否以-开头。我建议初学者做第一个小工具时也别急着上getopt虽然它是 Linux 平台的标准方案但 Windows 上默认没有。先自己写一个简单的解析循环能让你对参数处理的边界情况有更深刻的认识。比如这样for (int i 1; i argc; i) { if (argv[i][0] - argv[i][1] ! \0) { switch (argv[i][1]) { case n: // 需要额外取一个参数 if (i 1 argc) { fprintf(stderr, 选项 -n 后面需要跟一个名字\n); return 1; } i; // 使用 argv[i] break; default: fprintf(stderr, 未知选项: %s\n, argv[i]); return 1; } } else { // 位置参数 } }注意argv[i][0] - argv[i][1] ! \0这个判断它能把单独的-排除掉——-在有些工具里被约定为“标准输入”的替身这里我们不把它当作选项处理。3. 结构体基础给不同类型的数据“打包”成一个新类型3.1 为什么需要结构体一个学生的信息需要几个变量先看一个场景你要写一个学生成绩管理系统每个学生有姓名字符串、学号整数、三门课成绩浮点数或整数。如果你用几个独立变量来存char name1[32]; int id1; int score1_math; int score1_english; int score1_c;看起来好像也行。但如果你要管理 50 个学生呢你就要写 50 组这样的变量代码瞬间变成灾难。数组解决了一部分问题——同类型的数据可以归一堆但一个学生的信息是混合类型的数组没办法统一。结构体的作用就是把不同类型的数据“绑”在一起形成一个新的复合类型。你可以把结构体想象成一张表格的行每一行由不同格式的单元格组成但整体是一条完整记录。struct Student { char name[32]; int id; int math; int english; int c_lang; };这段代码声明了一个新的类型struct Student。注意它不是在创建变量而是在描述一种“模板”。这个模板告诉编译器今后凡是struct Student类型的变量都要包含一个 32 字节的字符数组和 4 个整型变量。3.2 三种定义结构体变量的方式以及 typedef 的隐藏价值有了类型之后就可以创建变量了。方式有三种第一种是直接声明struct Student s1; s1.id 1001; strcpy(s1.name, 张三);第二种是声明类型的同时跟着创建变量struct Student { char name[32]; int id; int math; int english; int c_lang; } s1, s2;第三种是“匿名结构体 typedef 组合拳”这也是现代 C 代码里最常见、最推荐的做法typedef struct { char name[32]; int id; int math; int english; int c_lang; } Student;这里typedef的作用是给这个结构体起一个别名。以后写变量时你可以直接写Student s1;不用每次都带struct关键字。虽然 C 语言里struct Student s1;也不算麻烦但在大批量代码里能少敲struct三个字母、同时避免某些编译器的语法挑剔是一种很实用的体验提升。给新手一个“看起来奇怪但一定要记住”的知识点typedef struct后面那个大括号结束后的分号不能丢。这个分号是类型声明结束的标志忘了写编译错误能让你找半天。3.3 用 . 访问成员用 sizeof 看真实体积访问结构体变量的成员用的是点运算符.。比如s1.math 88; printf(%s 的数学成绩是 %d\n, s1.name, s1.math);结构体变量的内存布局也别忽略。C 标准规定结构体成员在内存中按声明顺序排列但中间可能有填充字节padding原因是为了满足内存对齐alignment要求这部分放到后面专门讲。你可以用sizeof打印出结构体的实际大小再手动把各成员大小相加两者往往不相等。举个最常见的例子typedef struct { char a; int b; } A; printf(%zu\n, sizeof(A));char占 1 字节int在 64 位 Linux 上占 4 字节直觉相加等于 5。但实际运行结果是 8——因为int要求 4 字节对齐a后面被填充了 3 个字节。这就是内存对齐造成的“看不见的空间”。4. 结构体初始化与嵌套从简单赋值到复杂类型4.1 初始化方式对比哪种写法才是现代 C 的主流结构体变量创建后如果不初始化里面的内容是“脏”的可能是任意垃圾值。所以要么初始化要么立刻赋值。推荐的初始化方式按推荐程度从高到低排序第一种声明时用花括号整体初始化C99 指定初始化器Student s1 { .name 张三, .id 1001, .math 88, .english 90, .c_lang 95 };这种写法的最大优点是成员的顺序和结构体定义里的顺序可以不一致读起来清清楚楚。你不必死记结构体里第几个字段是谁只要看到.name ...就知道是在给名字赋值。第二种按顺序逐个初始化Student s1 {张三, 1001, 88, 90, 95};这一种要求值的位置必须和成员声明顺序完全一致少一个就自动补 0多一个编译报错。代码短小精悍但可读性差如果成员顺序调换这里就会悄悄出错。第三种先声明再逐个赋值Student s1; s1.id 1001; strcpy(s1.name, 张三);这种方式适合运行时才确定值的场景但要注意数组类型的成员不能直接整体赋值只能通过strcpy或循环逐元素赋值。4.2 嵌套结构体结构体里面再套结构体结构体的成员可以又是另一个结构体这在现实建模中很常见。比如一个“班级”包含一个“班主任信息”班主任又包含“姓名”和“电话”。typedef struct { char name[32]; char phone[16]; } Teacher; typedef struct { Teacher class_teacher; int student_count; } ClassInfo;访问嵌套成员时用.一层层往下跳ClassInfo info; info.class_teacher.name[0] \0;嵌套结构体在初始化时内层也要用花括号包起来ClassInfo info { .class_teacher { .name 王老师, .phone 13800001234 }, .student_count 45 };如果你在写图形程序一个平面上的点可以定义成struct Point { double x, y; };而一条线段就是struct Line { struct Point start, end; };这种嵌套建模方式是结构体最强大的能力之一。4.3 结构体数组一次管理一整套数据集合单个结构体变量只能存一条记录要管理多条时可以像普通数组一样来一个结构体数组Student class[50];然后逐个初始化或者循环赋值。访问第i个学生的数学成绩写法是class[i].math 88;结构体数组在很多场景下都比独立变量处理多条数据方便得多。比如要对全班按数学成绩排序你只需要写一个比较函数配合qsort就能完成不用手写满屏的if交换逻辑。这里顺带提一个坑结构体数组的内存占用往往比你想象的大。比如Student占 52 字节32 4 4 4 4 4 52 如果对齐不同则不同50 个人的数组就要 2600 字节。如果你用malloc动态分配数组要正确算出总字节数Student *class malloc(sizeof(Student) * 50);别写malloc(50 * 52)这种魔法数字——如果哪天结构体加了字段这里的 52 就会变成定时炸弹。永远用sizeof。5. 结构体的实战核心传值还是传址以及对齐带来的神秘 sizeof5.1 函数传结构体默认是“拷贝一整份”把结构体传给函数的默认方式是传值也就是把一个结构体的完整拷贝压进函数栈。例如void print_student(Student s) { printf(%s %d %d %d %d\n, s.name, s.id, s.math, s.english, s.c_lang); }调用时实参会拷贝出一个副本函数内对s做的任何修改都不会影响调用者的原始变量。这种方式的优点是安全、隔离性好缺点是拷贝开销大。如果结构体很大比如里面有个char buf[4096]每次传参都要拷贝 4KB 字节性能损失不可忽视。所以大结构体尽量不要传值。5.2 传指针的三个正当理由省拷贝、改原值、支持动态数据结构如果你想让函数修改结构体的原始内容或者结构体很大不想反复拷贝就应该传指针void set_scores(Student *s, int math, int english, int c_lang) { s-math math; s-english english; s-c_lang c_lang; }注意这里访问成员用的是-箭头运算符。s-math等价于(*s).math但写法更直观。箭头是专门为“指针访问成员”设计的语法糖。传指针修改原值的行为实际上也是 C 语言模拟“引用传递”的方式。因为 C 语言没有 C 里的引用类型所以要改外部变量就得传递地址。如果你只是读取不想让函数意外改动原值可以把指针声明为constvoid print_student(const Student *s) { printf(%s %d %d %d %d\n, s-name, s-id, s-math, s-english, s-c_lang); }const Student *s表示“s 指向的内容不可通过 s 修改”编译器会帮助你在写s-math ...时报错是一种保护机制。写库给别人用时这种习惯能避免不少误修改 bug。5.3 内存对齐为什么 sizeof 总和你想的不一样前面提过结构体成员之间会有填充字节这是为了满足内存对齐。为什么要对齐因为 CPU 在读取内存时通常按字长比如 4 字节或 8 字节为单位访问如果数据没有对齐到合适的边界访问就可能要拆成多次读取性能大幅下降。所以编译器会在结构体成员之间打补丁确保每个成员都落在合理的边界上。对于写 C 的人来说内存对齐带来的实际问题有以下几个第一结构体大小不等于成员大小之和。这可能影响动态分配时的空间计算但只要你坚持用sizeof就不会错。第二成员顺序影响结构体内存占用。同一个结构体改变成员顺序总大小可能不同。比如typedef struct { char a; double b; char c; } S1; // 16 字节 typedef struct { char a; char c; double b; } S2; // 16 字节 或者 不同平台可能有差异但往往 S2 S1把相同数据类型放在一起可以减少填充字节让结构体更紧凑。这是一种很实用的空间优化技巧尤其当你在嵌入式等内存受限环境里工作时能节省不少资源。第三不要轻易使用__attribute__((packed))去强制取消对齐。虽然在协议解析、二进制文件读写等场景下你可能想让结构体按紧凑方式排列但这不符合标准会让某些平台上的访问变慢甚至出错。如果你确实需要打包二进制数据更安全的方式是逐字节序列化而不是依赖编译器的打包。6. 综合实战把 main 传参和结构体拼成一个成绩管理小工具6.1 需求设计前面的知识点拆开看都不难但真正让知识发挥价值的地方是将它们组合起来。我们来设计一个命令行成绩管理工具通过命令行参数输入学生信息和操作指令程序用结构体数组存储数据支持“添加”“查找”“打印全部”三种操作。软件设计时可执行文件叫stu命令格式如下./stu add 张三 1001 88 90 95 ./stu find 1001 ./stu list第一个参数是操作名后面跟着操作所需要的参数。我们选用结构体存学生信息用argc/argv获取命令行输入。6.2 单文件完整实现解析下面的代码可以直接复制编译运行我加了比较详细的注释#include stdio.h #include stdlib.h #include string.h #define MAX_STUDENTS 50 typedef struct { char name[32]; int id; int math; int english; int c_lang; } Student; static Student students[MAX_STUDENTS]; static int student_count 0; // 把字符串形式的姓名拷贝到结构体里 static Student make_student(const char *name, int id, int math, int english, int c_lang) { Student s; memset(s, 0, sizeof(s)); strncpy(s.name, name, sizeof(s.name) - 1); s.id id; s.math math; s.english english; s.c_lang c_lang; return s; } // 打印单个学生 static void print_student(const Student *s) { printf(%d %s 数学:%d 英语:%d C语言:%d\n, s-id, s-name, s-math, s-english, s-c_lang); } // 添加学生 static int add_student(const Student *s) { if (student_count MAX_STUDENTS) { fprintf(stderr, 学生数量已满\n); return -1; } for (int i 0; i student_count; i) { if (students[i].id s-id) { fprintf(stderr, 学号 %d 已存在\n, s-id); return -1; } } students[student_count] *s; return 0; } // 根据学号查找 static Student *find_student(int id) { for (int i 0; i student_count; i) { if (students[i].id id) { return students[i]; } } return NULL; } static void list_all(void) { for (int i 0; i student_count; i) { print_student(students[i]); } } // 简单的整型解析函数 static int parse_int(const char *str, int *out) { char *end; long val; val strtol(str, end, 10); if (end str) return -1; while (*end ! \0) { if (*end ! *end ! \t) return -1; end; } if (val -2147483648L || val 2147483647L) return -1; *out (int)val; return 0; } int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s add 姓名 学号 数学 英语 C语言\n, argv[0]); fprintf(stderr, %s find 学号\n, argv[0]); fprintf(stderr, %s list\n, argv[0]); return 1; } if (strcmp(argv[1], add) 0) { if (argc ! 7) { fprintf(stderr, add 需要 5 个参数: 姓名 学号 数学 英语 C语言\n); return 1; } int id 0, math 0, english 0, c_lang 0; if (parse_int(argv[3], id) ! 0 || parse_int(argv[4], math) ! 0 || parse_int(argv[5], english) ! 0 || parse_int(argv[6], c_lang) ! 0) { fprintf(stderr, 学号和成绩必须是合法整数\n); return 1; } Student s make_student(argv[2], id, math, english, c_lang); if (add_student(s) ! 0) { return 1; } return 0; } else if (strcmp(argv[1], find) 0) { if (argc ! 3) { fprintf(stderr, find 需要 1 个参数: 学号\n); return 1; } int id 0; if (parse_int(argv[2], id) ! 0) { fprintf(stderr, 学号必须是合法整数\n); return 1; } Student *s find_student(id); if (s NULL) { fprintf(stderr, 找不到学号为 %d 的学生\n, id); return 1; } print_student(s); return 0; } else if (strcmp(argv[1], list) 0) { if (argc ! 2) { fprintf(stderr, list 不需要额外参数\n); return 1; } list_all(); return 0; } else { fprintf(stderr, 未知操作: %s\n, argv[1]); return 1; } }6.3 关键设计决策说明为什么用make_student返回结构体而不是直接给结构体赋值因为当你需要在不同作用域里构造一个结构体时用一个函数集中处理初始化逻辑能避免每个调用处重复写初始化代码。返回结构体会产生一次拷贝但对于体积不大的结构体这个开销可以忽略不计。为什么add_student接收const Student *s而不是直接传两个整数加一个名字因为以后你想扩展学籍信息、添加多个字段时调用处的改动会非常小。把“要添加的东西”打包成一个结构体本身就是在用结构体天然的组织能力来简化接口。为什么student_count要设置上限在这个示例里我用的是静态数组MAX_STUDENTS是你需要自定义的容量。真实项目中更好的方案是用malloc动态扩容但那会引入更复杂的内存管理逻辑初学者可以先把静态版跑通再考虑升级。运行过程演示gcc stu.c -o stu ./stu add 张三 1001 88 90 95 ./stu add 李四 1002 77 85 91 ./stu list ./stu find 1002输出类似1001 张三 数学:88 英语:90 C语言:95 1002 李四 数学:77 英语:85 C语言:91 1002 李四 数学:77 英语:85 C语言:916.4 这个项目还能怎么扩展这个骨架拥有非常开阔的扩展空间我建议你按以下方向逐步强化它第一加入“删除”和“修改”功能核心是对下标为student_count的数组做增删操作注意删除时要把后面的元素前移。第二把数据持久化。用fprintf把结构体写成文本文件再用fscanf读回来。注意fscanf读字符串时的%s遇到空格会停止所以读取姓名时要么用%[^\n]这种格式要么干脆用fgets一行一行读。这里有个常见隐患直接用fscanf读结构体二进制数据在不同平台或编译器上会因为内存对齐不一致而发疯。第三动态内存管理进阶。把students从静态数组改成malloc分配容量不够时用realloc扩容理解堆上结构体数组的真实使用方式。7. 那些代码之外最容易忽视的小事我这个项目做完之后有几个小体会想分享。第一个是关于“字符串拷贝”的。make_student里用strncpy而不是strcpy是因为strcpy在源字符串比目标缓冲区长的时候会无脑往下写造成缓冲区溢出。strncpy虽然会在长字符串时截断但它不会自动补\0所以你必须在拷贝前memset清空目标缓冲区。这是初学者在结构体里放字符串成员时最容易踩的坑光这一条就能让很多程序在压力测试下莫名其妙崩溃。第二个是“参数校验不要偷懒”。你看上面这段代码密密麻麻的if (argc ! 7)、if (parse_int(...) ! 0)看起来很繁琐但真实使用场景里命令行工具被误操作的概率远高于你想象。一个不校验参数的main函数往轻了说会打印一堆看不懂的错误往重了说可能因为空指针解引用直接段错误。我见过不少线上工具翻车原因就是把用户当作永远不会犯错的理想操作员。第三个是“返回值要会看”。main返回 0 表示成功返回非 0 表示失败。在 shell 脚本里这个返回码会被$?拿到用来做条件判断。你在写命令行工具时要让成功和失败分得很清楚最好约定好哪些错误码分别代表什么错误。比如 1 表示参数错误2 表示数据已存在3 表示文件打开失败。这样脚本调用方就能精确知道发生了什么问题而不是把所有非零值统称“出错了”。第四个是“编译时开警告”。如果你用 gcc请务必加上-Wall -Wextra -Werror这些编译选项。-Wall开启常用警告-Wextra开更多边界警告-Werror把警告视为错误强制你修复它们。否则有些隐患会以你完全察觉不到的方式编译通过然后在运行时给你惊喜。我在带新人的时候经常说一句话编译器是最好的代码审查者但你得先把它的嘴打开。8. 最后的一点学习建议main 函数传参和结构体这两个知识点看起来互不相干但实际上它们是同一个思维模式的两面main 传参是程序和外部世界的接口结构体是程序内部组织数据的单元。一个完整的 C 程序往往就是从命令行接收请求通过结构体整理数据再通过一系列的代码处理并输出的循环。把这两块彻底掌握你对 C 语言实践的理解会上一个台阶。如果你刚开始学 C我的建议是不要停留在“看得懂”的层面一定要亲手敲一遍代码、亲手编译运行、亲手改几个 bug。你可以在上面成绩管理工具的基础上试着加一个“平均分排序”功能用qsort对students数组按平均分排序然后list输出。整个过程会逼你把结构体比较、函数指针、参数校验等一连串知识点串起来。真正学会一门语言从来不是靠看完一本书而是靠写完一批能解决问题的程序。希望这篇从基础到实战的记录能成为你迈开这一步的桥梁。