前言
这是「我的 C 之旅」系列的第四篇。前三篇假定你已经掌握了变量、基本类型、运算符,以及if/for/while这些控制流。第四篇把注意力转向 C 语言里最能体现「贴近内存」特色的三样东西:数组、字符串和指针,并把它们放进函数里组合使用。
先说明两点。第一,这个系列讲的是C 语言,不是 C++。文中用printf/scanf、char数组、malloc/free,不出现std::string、std::vector、引用、类这些 C++ 设施。第二,C 标准以 C17(ISO/IEC 9899:2018)为基准;gets在 C11 就已从标准中移除,本文不会使用它。
初学者最常见的误解是「数组会记住自己的长度」「字符串就是字符数组」「指针和数组是同一个东西」。这三句话都只对了一半,而错的那一半正是缓冲区溢出和越界访问的根源。本文会把边界讲清楚,所有代码都按标准语义推演,不声称经过编译或运行。
一、数组:一块连续的内存
1.1 声明、初始化与长度
/* 适用于 C17 */
#include <stdio.h>
int main(void) {
int scores[5] = {90, 85, 77, 60, 95};
int zeros[5] = {0}; /* 全部元素为 0 */
int partial[5] = {1, 2}; /* 后三个元素为 0 */
char name[] = "C"; /* 长度为 2:'C' 和 '\0' */
printf("%zu\n", sizeof scores); /* 整个数组的字节数 */
printf("%zu\n", sizeof scores[0]); /* 单个元素的字节数 */
printf("%zu\n", sizeof scores / sizeof scores[0]); /* 元素个数 */
printf("%zu\n", sizeof name); /* 2 */
return 0;
}sizeof返回size_t,所以用%zu打印。数组长度必须是编译期常量(C99 起支持变长数组,但其大小在运行时确定,且不是所有实现都完整支持;C11 把它改为可选特性)。用sizeof求元素个数只对真正的数组有效,一旦数组作为函数参数传递,它会退化为指针,sizeof得到的是指针大小。
1.2 下标从 0 开始,且不做边界检查
/* 适用于 C17 */
int a[5];
for (int i = 0; i < 5; ++i) { /* 正确:0..4 */
a[i] = i * i;
}a[5]越界。C 标准不要求运行时检查,越界读写是未定义行为(undefined behavior, UB):可能读到别的变量、可能崩溃、也可能看起来正常。这与 C++ 的std::vector::at()不同,C 没有「抛异常」这一层保护,边界完全由程序员负责。
二维数组是「数组的数组」,内存中仍连续存放:
/* 适用于 C17 */
int grid[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
/* grid[r][c] 等价于 *(*(grid + r) + c) */把二维数组传给函数时,除第一维外的长度必须写进参数类型,因为编译器需要它来计算行跨度:
/* 适用于 C17 */
void printGrid(int rows, int cols, int g[][4]) {
for (int r = 0; r < rows; ++r) {
for (int c = 0; c < cols; ++c) {
printf("%d ", g[r][c]);
}
printf("\n");
}
}写成int **g是另一个类型,不能直接接收二维数组,这是初学者常见的类型不匹配。
二、字符串:以'\0'结尾的字符数组
2.1 两种写法,差别很大
/* 适用于 C17 */
char a[] = "hello"; /* 可修改的数组,长度 6 */
const char *b = "hello"; /* 指向字符串字面量,不要修改 */char a[]在栈上分配 6 个字节,内容是h e l l o \0,可以逐字符修改。const char *b指向只读区域中的字面量。通过b修改内容是 UB(很多平台上表现为段错误)。加const是让编译器帮你拦住这类修改。- 字符串的长度由
'\0'决定,而不是数组容量。char buf[100] = "hi";的strlen是 2,但sizeof buf是 100。
2.2 常用函数与它们的边界
| 函数 | 作用 | 风险 |
|---|
strlen(s) | 数到'\0'为止 | s无终止符时越界读 |
strcpy(dst, src) | 复制含终止符 | 不检查dst容量,溢出 |
strncpy(dst, src, n) | 最多复制 n 字节 | 源串过长时可能不写'\0' |
strcat(dst, src) | 追加 | 不检查容量 |
snprintf(dst, n, fmt, ...) | 有界格式化 | 返回值可能大于n,需判断截断 |
fgets(dst, n, stream) | 有界读一行 | 可能保留换行符;n 为容量 |
安全写法示例:
/* 适用于 C17 */
#include <stdio.h>
#include <string.h>
int main(void) {
char dst[16];
const char *src = "a rather long string";
/* strncpy 不保证终止,手动补 */
strncpy(dst, src, sizeof dst - 1);
dst[sizeof dst - 1] = '\0';
/* 更推荐 snprintf:一定有界,且会终止 */
int n = snprintf(dst, sizeof dst, "%s", src);
if (n < 0) {
/* 编码错误 */
} else if ((size_t)n >= sizeof dst) {
/* 发生截断,dst 中是前 sizeof dst - 1 个字符 */
}
return 0;
}snprintf的返回值是「假如空间足够,本应写入的字符数(不含'\0')」,因此它大于等于缓冲区大小时表示发生了截断,而不是写入失败。这个细节经常被误解。
读取输入时,scanf("%s", buf)不限制长度,等价于埋了一个溢出点;fgets(buf, sizeof buf, stdin)才有界。gets已从 C11 标准中删除,任何新代码都不应使用。
三、函数:值传递与声明
3.1 原型、定义与调用
/* 适用于 C17 */
#include <stdio.h>
int add(int a, int b); /* 声明(原型) */
int main(void) {
printf("%d\n", add(2, 3));
return 0;
}
int add(int a, int b) { /* 定义 */
return a + b;
}C 要求函数在调用前可见声明。把声明放在头文件里、定义放在.c文件里,是分离编译的基础。main的标准形式是int main(void)或int main(int argc, char *argv[]),void main()从来不是标准写法。
3.2 值传递
C 只有值传递。函数收到的是一份副本,修改形参不影响实参:
/* 适用于 C17 */
void badSwap(int x, int y) {
int t = x;
x = y;
y = t; /* 只改了副本 */
}
void goodSwap(int *x, int *y) {
int t = *x;
*x = *y;
*y = t;
}要让函数修改调用者的变量,必须传地址并用指针解引用。数组是例外:数组名作为实参会退化为指向首元素的指针,所以函数里对数组元素的修改会反映到原数组,但「数组长度」这个信息不会一起传过去,必须另加参数。
3.3 作用域与static
- 块作用域的自动变量在离开块时生命周期结束,地址随之失效。
- 文件作用域的
static函数/变量只在本翻译单元可见,是 C 里实现「私有」的手段。 - 函数内的
static局部变量只初始化一次,生命周期贯穿整个程序:
/* 适用于 C17 */
int nextId(void) {
static int id = 0; /* 只初始化一次 */
return ++id;
}static局部变量在多次调用之间保留值,但它不是线程安全的;多线程环境下对它的自增是数据竞争。C11 提供了<threads.h>,但是否可用取决于实现,实际项目更常用平台线程库。
四、指针初步:地址与解引用
4.1 取地址与解引用
/* 适用于 C17 */
#include <stdio.h>
int main(void) {
int value = 42;
int *p = &value; /* p 保存 value 的地址 */
printf("%d\n", *p); /* 42 */
*p = 100; /* 通过指针修改 */
printf("%d\n", value); /* 100 */
return 0;
}*在声明里表示「指针类型」,在表达式里表示「解引用」,两者含义不同但相关。&取地址,不能作用于位域,也不能作用于寄存器变量。
4.2 指针与数组的关系
数组名在大多数表达式里会转换为指向首元素的指针,所以下面两种写法等价:
/* 适用于 C17 */
int a[5] = {10, 20, 30, 40, 50};
int *p = a; /* 等价于 &a[0] */
printf("%d\n", a[2]); /* 30 */
printf("%d\n", *(p + 2)); /* 30 */
printf("%d\n", p[2]); /* 30 */但数组名不是指针:sizeof a是 20(假设int为 4 字节),sizeof p是指针大小;数组名不能被赋值。指针算术以元素为单位,p + 2前进2 * sizeof(int)字节。
指针相减只有在两个指针指向同一数组(或数组尾后位置)时才有定义,其他情况是 UB。
4.3 空指针与const
/* 适用于 C17 */
#include <stddef.h>
int *p = NULL; /* 空指针常量 */
const int *pc = NULL; /* 指向常量的指针:不能通过 pc 改值 */
int *const cp = NULL; /* 常量指针:cp 本身不能改指向 */C 里用NULL(定义在<stddef.h>等头文件),C++ 才用nullptr。不要把NULL当作整数 0 使用;判断空指针写if (p == NULL)或if (!p)。
五、完整示例:统计并反转一行文本
下面把数组、字符串、函数、指针串起来:读一行、去掉换行、统计字符与单词数、原地反转。
/* 适用于 C17 */
#include <ctype.h>
#include <stdio.h>
#include <string.h>
static void stripNewline(char *s) {
size_t len = strlen(s);
if (len > 0 && s[len - 1] == '\n') {
s[len - 1] = '\0';
}
}
static size_t countWords(const char *s) {
size_t count = 0;
int inWord = 0;
for (size_t i = 0; s[i] != '\0'; ++i) {
unsigned char ch = (unsigned char)s[i];
if (isspace(ch)) {
inWord = 0;
} else if (!inWord) {
inWord = 1;
++count;
}
}
return count;
}
static void reverseInPlace(char *s) {
size_t i = 0;
size_t j = strlen(s);
while (i + 1 < j) {
--j;
char t = s[i];
s[i] = s[j];
s[j] = t;
++i;
}
}
int main(void) {
char line[128];
if (fgets(line, (int)sizeof line, stdin) == NULL) {
return 1;
}
stripNewline(line);
printf("length: %zu\n", strlen(line));
printf("words: %zu\n", countWords(line));
reverseInPlace(line);
printf("reversed: %s\n", line);
return 0;
}逐点说明:
fgets最多读sizeof line - 1个字符,并保证写入'\0';输入超长时不会溢出,只是分多次读完。stripNewline处理末尾换行;若输入恰好填满缓冲区,换行可能留在下一次读取里,这是fgets的正常行为。countWords把char先转成unsigned char。<ctype.h>的函数只接受EOF或unsigned char范围内的值;直接传负的char是 UB。这个细节在处理非 ASCII 文本时尤为重要。reverseInPlace用i + 1 < j避免在长度为偶数时多交换一次;j先自减再访问,边界是正确的。- 反转对多字节字符(如 UTF-8 中文)不适用,会按字节打乱。C 的
char是字节,不是字符;处理 Unicode 需要专门的库或按编码规则解码。
常见坑点
- ❌
char buf[10]; strcpy(buf, "0123456789");
✅ 目标容量不足,写出界是 UB;用snprintf(buf, sizeof buf, "%s", src)并检查截断。
- ❌ 认为
strncpy(dst, src, n)一定会补'\0'。
✅ 源串长度不小于 n 时不会终止;手动写dst[n-1] = '\0',或改用snprintf。
- ❌
char *s = "hello"; s[0] = 'H';
✅ 字面量通常位于只读段,修改是 UB;要改就用char s[] = "hello";,只读则写const char *。
- ❌ 函数里用
sizeof arr求数组长度。
✅ 数组形参会退化为指针,sizeof得到指针大小;把长度作为参数显式传入。
- ❌
while (!feof(fp)) { fgets(...); ... }
✅feof只在读失败后才置位,会多处理一次旧数据;直接判断fgets的返回值。
- ❌
scanf("%s", buf)读取用户输入。
✅ 不限制宽度会溢出;用fgets,或用scanf("%99s", buf)明确上限。
- ❌ 把
'a'与"a"混用。
✅'a'是字符常量(int类型),"a"是含'a'与'\0'的字符串字面量,二者不能互换。
- ❌ 返回局部数组的地址。
✅ 函数返回后自动存储期结束,指针悬垂;改为由调用者传入缓冲区,或使用malloc并由调用者free。
总结
| 主题 | 关键规则 | 常见错误 |
|---|
| 数组 | 下标 0..n-1,不做边界检查 | 越界读写(UB) |
| 数组传参 | 退化为指针,长度另传 | 在函数内sizeof求长度 |
| 字符串 | 以'\0'结尾 | 忘记终止符、缓冲区不足 |
| 输入 | fgets有界 | 用gets或无限宽%s |
| 函数 | 只有值传递 | 以为形参能改实参 |
| 指针 | &取地址、*解引用 | 悬垂指针、空指针解引用 |
const | 区分指向常量的指针与常量指针 | 通过const指针改写 |
| 生命周期 | 局部变量离开作用域即失效 | 返回局部数组地址 |
C 的数组、字符串、指针本质上是同一件事的三个视角:一段连续内存、一个终止约定、一个地址。把「谁拥有这块内存、它有多长、什么时候失效」这三个问题随时问自己,大部分崩溃都能在写代码时避免。下一讲会在这个基础上讨论结构体与动态内存,那才是 C 程序组织数据的真正起点。