Lesson 16: 字符串拷贝 CNB
练习任务
本课包含两个递进练习:
练习 1(mystrcpy 基础实现):实现 char *mystrcpy(char *dest, const char *src) 函数,将 src 指向的字符串(包括 '\0')逐字符拷贝到 dest 指向的缓冲区,返回 dest 的起始地址。main 从标准输入读取字符串,调用 mystrcpy 后打印结果。
期望输出(输入 copy me):
copy me练习 2(delta 优化版):实现 char *mystrcpy_delt(char *dst, const char *src),利用 dst 和 src 之间的固定偏移量 delt = dst - src,将双指针操作缩减为单指针操作,减少对 dst 指针变量的存取次数。
期望输出(输入 delta copy):
delta copy提示:
strcpy的核心是一行 C 惯用法:while ((*dst++ = *src++) != '\0');。这行代码蕴含了赋值表达式的返回值、后置自增的时机、'\0'的判断三个关键知识点。delta 优化的思路是:如果dst始终在src后面偏移delta个字节,那么src[delta]就等价于*dst,你只需要移动src指针即可。注意 delta 版要求dst和src在同一个数组内。
核心知识点
- 字符指针
char*— 指向字符或字符串首地址的指针,是字符串操作的核心工具 const限定符 —const char *src承诺不修改源字符串,编译器帮助检查assert断言 — 运行时检查空指针NULL,调试期间快速暴露 bugwhile (*dst++ = *src++)— C 语言最经典的指针惯用法,将赋值、自增、判断压缩为一行- 赋值表达式的返回值 —
a = b的值就是b的值,使得while (a = b)成为可能 \0终止符的判断 —(*src) != '\0'等价于*src,因为'\0'的 ASCII 值为 0- 后置自增
++的时机 —*p++先解引用旧值,再移动指针 - 函数返回
char*— 返回目标指针实现链式调用,如printf("%s", mystrcpy(d, s)) - 指针运算的优先级与结合性 —
*dst++等价于*(dst++),解引用优先于自增 - delta 优化 — 用偏移量
delt = dst - src将双指针操作化为单指针 - 内存对齐 — CPU 以 WORD(4 字节)为单位访问内存最快,未对齐访问需两次总线操作
memcpyvsstrcpy—memcpy按字节数拷贝,不依赖'\0';strcpy依赖终止符- 安全字符串函数 —
strncpy、snprintf限制拷贝长度,防止缓冲区溢出 - 工业级
strcpy— glibc 按 4 字节对齐批量拷贝,SSE/NEON 指令加速
代码框架
练习 1:基础 mystrcpy
#include <stdio.h>
#include <assert.h>
char *mystrcpy(char *dest, const char *src)
{
assert(dest != NULL && src != NULL);
// 在这里实现 strcpy:
// 1. 保存 dest 的起始地址 (用于返回)
// 2. 用一个循环: 逐字符复制 *src 到 *dest
// 3. 当复制完 '\0' 后终止循环
// 4. 返回保存的起始地址
}
int main(void)
{
char s1[256] = "";
char s2[256];
fgets(s2, sizeof(s2), stdin);
// 去掉末尾换行符
int i = 0;
while (s2[i] && s2[i] != '\n') i++;
s2[i] = '\0';
mystrcpy(s1, s2);
printf("%s\n", s1);
return 0;
}练习 2:delta 优化版
#include <stdio.h>
char *mystrcpy_delt(char *dst, const char *src)
{
// 在这里实现 delta 版:
// 1. 计算偏移量: int delt = dst - src;
// 2. 用 (char*) 转换 src 的类型
// 3. 循环: 通过 s[delt] 访问 dst 对应位置
// 4. 当 *s == '\0' 时停止
// 5. 最后补上 '\0'
return dst;
}
// buf 保证 src 和 dst 在同一数组内,delta 运算合法
char buf[512];
int main(void)
{
char *src = buf;
char *dst = buf + 256;
fgets(src, 256, stdin);
int i = 0;
while (src[i] && src[i] != '\n') i++;
src[i] = '\0';
mystrcpy_delt(dst, src);
printf("%s\n", dst);
return 0;
}填充框架的关键思考:*dst++ = *src++ 的执行顺序是怎样的?赋值表达式的值是什么?循环何时终止?delta 版本中 delt = dst - src 这个减法在什么条件下才合法?
TIP
先不要往下翻看参考解答。尝试自己先实现基础版——把 *dst++ = *src++ 这一行的语义彻底想清楚。做完基础版再挑战 delta 版,它需要你对指针运算有更深的理解。
深度讲解
1. 指针回顾:从字符数组到字符指针
1.1 字符数组回顾(连接 Lesson 09)
在 Lesson 09 中,我们用字符数组存储 itoa 的转换结果:
char buf[10]; // 10 个连续 char,在栈上分配
buf[0] = '1';
buf[1] = '2';
buf[2] = '3';
buf[3] = '\0'; // 字符串终止符
printf("%s\n", buf); // 输出: 123一个 char 数组存放字符串时,末尾必须有一个值为 0 的字节——'\0'(ASCII 码 0,也称为 NUL 终止符)。printf("%s") 会从起始地址开始逐字节打印,直到遇到 '\0' 为止。
内存布局:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ '1' │ '2' │ '3' │'\0' │ ? │ ? │ ? │ ? │ ? │ ? │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
buf[0] buf[1] buf[2] buf[3] ... buf[9]
printf("%s", buf) 读取: '1' → '2' → '3' → '\0' (停止)1.2 字符指针:指向字符的指针
char ch = 'A';
char *p = &ch; // p 指向 ch 的地址
printf("%c\n", *p); // 解引用: 输出 'A'
printf("%p\n", (void*)p); // 输出 ch 的内存地址字符指针 char* 可以指向单个字符,也可以指向字符串的首字符:
char str[] = "Hello"; // 字符数组
char *p = str; // p 指向 str[0] 即 'H'
// 或者直接用字符串字面量:
char *q = "World"; // q 指向只读字符串 "World" 的首字符
printf("%c\n", *p); // 'H'
printf("%c\n", *(p + 1)); // 'e' —— 指针算术: p+1 指向下一个字符
printf("%s\n", p); // "Hello" —— %s 从 p 开始打印直到 '\0'指针与字符串的关系:
p ──→ ┌───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │\0│
└───┴───┴───┴───┴───┴───┘
str[0] str[5]
*p → 'H' (p 指向的字符)
*(p+1) → 'e' (p+1 指向下一个字符)
p[0] → 'H' (等价于 *p)
p[1] → 'e' (等价于 *(p+1))NOTE
p[i] 本质上就是 *(p + i) 的语法糖。数组下标 [] 运算符在 C 语言中就是指针运算的缩写。回顾 Lesson 09 中 buf[i] 的写法,其实也可以用 *(buf + i) 替代。
1.3 字符指针 vs 字符数组
这是一个重要的区别,贯穿字符串操作始终:
// 方式 1: 字符数组(可修改)
char s1[32] = "Hello World";
s1[0] = 'h'; // ✅ 合法:修改数组内容
printf("%s\n", s1); // "hello World"
// 方式 2: 指针指向字符串字面量(只读)
char *s2 = "Hello World";
// s2[0] = 'h'; // ❌ 未定义行为!字符串字面量存储在只读区域
printf("%s\n", s2); // "Hello World"| 特性 | char s[32] = "Hello" | char *s = "Hello" |
|---|---|---|
| 存储位置 | 栈(或 BSS 段,全局数组) | 指针在栈,字符串在只读数据段(.rodata) |
| 可修改性 | 可修改数组内容 | 不可修改字符串内容(UB) |
| 大小 | sizeof(s) = 32(数组总大小) | sizeof(s) = 8(指针大小,64 位系统) |
| 赋值 | 不能整体赋值 s = "bye" | 可以改变指向 s = "bye" |
| 底层实现 | 编译器生成 32 字节空间 + 初始化代码 | 指针指向编译器存储的字符串常量 |
内存布局对比:
char s1[32] = "Hello"; char *s2 = "Hello";
栈: .rodata (只读数据段):
┌──────────────────────┐ ┌───┬───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │\0│... │ H │ e │ l │ l │ o │\0│
└──────────────────────┘ └───┴───┴───┴───┴───┴───┘
s1 (数组首地址) ▲
│
栈:
┌──────┐
│ 指针 │──┘
└──────┘
s2CAUTION
使用 char *p = "literal" 时,千万不要尝试修改 p 指向的内容。这会导致段错误(segfault)或更隐蔽的数据损坏。如果需要一个可修改的字符串,用 char buf[N] 或 malloc 分配。
2. const 限定符与防御性编程
2.1 const 的语义:承诺不修改
// const 在 * 左边:指向的内容不可修改
char *mystrcpy(char *dst, const char *src)
// ↑
// const char *src 读作:
// "src 是一个指针,指向 const char(不可修改的字符)"
// 等价写法:
// const char *src ← 推荐:const 在类型前
// char const *src ← 也正确:const 在 * 前关键区分:
const char *p; // p 指向的内容不可修改(*p 只读)
char * const p; // p 本身不可修改(p 不能指向别处)
const char * const p; // p 和 *p 都不可修改在 mystrcpy 的签名中,const char *src 告诉调用者:我不会修改你传给我的源字符串。这是 API 设计中的承诺——编译器会帮你强制执行。
void copy(const char *src, char *dst)
{
*dst = *src; // ✅ 读 src 合法
// *src = 'X'; // ❌ 编译错误: 试图修改 const 数据
}2.2 为什么源字符串要用 const
// 如果没有 const,调用者无法区分方向:
char *bad_strcpy(char *dst, char *src); // 哪个是输入?哪个是输出?
// 有 const,意图一目了然:
char *good_strcpy(char *dst, const char *src);
// ↑ 输出 ↑ 输入(只读)这是标准库 strcpy 的函数签名:char *strcpy(char *dest, const char *src)。const 让代码自文档化——一眼就能看出数据的流向。
3. 空指针检查:assert 断言
3.1 为什么需要检查 NULL
char *p = NULL;
// *p = 'A'; // ❌ 段错误!访问空指针
// strcpy(p, "hi"); // ❌ 段错误!标准库 strcpy 也不检查 NULLC 标准库的 strcpy 不会检查 NULL 参数——传入 NULL 是未定义行为。在调试阶段,我们希望尽早暴露这类 bug。
3.2 assert 的工作原理
#include <assert.h>
char *mystrcpy(char *dst, const char *src)
{
assert(dst != NULL); // 如果 dst 为 NULL,程序在此终止
assert(src != NULL); // 如果 src 为 NULL,程序在此终止
// 或者合并:
// assert(dst != NULL && src != NULL);
// ... 正常逻辑 ...
}assert 的行为:
- 条件为真:什么都不发生,程序继续
- 条件为假:打印错误信息(含文件名、行号、表达式),调用
abort()终止程序 - 仅在调试版本生效:定义
NDEBUG宏后,所有assert被编译为空操作
# 调试构建(assert 生效)
gcc -g -O0 mystrcpy.c -o mystrcpy
# 发布构建(assert 失效)
gcc -DNDEBUG -O2 mystrcpy.c -o mystrcpyIMPORTANT
assert 只应用于编程错误(如传了不该为 NULL 的参数),不应处理运行时错误(如文件打开失败)。运行时错误应该用 if + return 错误码来处理。本课中 assert 的作用是在调试阶段尽早暴露调用者的错误。
3.3 assert 与 if 的选择
// assert: 用于检查「绝不应该发生」的情况
assert(ptr != NULL); // 调用者传 NULL 是程序员的 bug
// if: 用于处理「可能发生」的运行时错误
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("fopen");
return -1; // 文件不存在不是 bug,需要优雅处理
}4. C 语言最经典的指针惯用法:while (*dst++ = *src++)
4.1 朴素写法 → 惯用法:逐步演化
让我们从最朴素的写法开始,一步步压缩为经典的一行代码:
// 版本 1: 最朴素的写法(5 行)
char *mystrcpy_v1(char *dst, const char *src)
{
int i = 0;
while (src[i] != '\0') {
dst[i] = src[i];
i++;
}
dst[i] = '\0'; // 不要忘记拷贝终止符!
return dst;
}
// 版本 2: 用指针替代下标(4 行)
char *mystrcpy_v2(char *dst, const char *src)
{
char *ret = dst;
while (*src != '\0') {
*dst = *src;
dst++;
src++;
}
*dst = '\0';
return ret;
}
// 版本 3: 把赋值和自增合并(3 行)
char *mystrcpy_v3(char *dst, const char *src)
{
char *ret = dst;
while (*src != '\0')
*dst++ = *src++; // 一行做三件事:赋值 + dst++ + src++
*dst++ = '\0'; // 拷贝终止符
return ret;
}
// 版本 4: 利用赋值表达式的返回值(2 行) ← 经典写法
char *mystrcpy_v4(char *dst, const char *src)
{
char *ret = dst;
while ((*dst++ = *src++) != '\0')
; // 空循环体:赋值 + 判断合二为一
return ret;
}
// 版本 5: 利用 '\0' == 0 简化条件(2 行)
char *mystrcpy_v5(char *dst, const char *src)
{
char *ret = dst;
while ((*dst++ = *src++))
; // 当复制完 '\0' 时,赋值表达式的值为 0,循环终止
return ret;
}版本 5 是最终的 C 惯用法。让我们彻底拆解这一行代码。
4.2 逐层拆解 while ((*dst++ = *src++))
// 原语句:
while ((*dst++ = *src++))
;
// 等价展开:
while (1) {
// 第一步: 解引用 src,取出当前字符
char c = *src;
// 第二步: 把字符写入 dst
*dst = c;
// 第三步: 移动两个指针(后置自增)
src = src + 1;
dst = dst + 1;
// 第四步: 赋值表达式 (*dst = c) 的值就是 c
// while (c) 即 while (c != 0)
// 当 c == '\0'(即 0)时,循环终止
if (c == '\0')
break;
}关键机制总结:
| 步骤 | 表达式 | 效果 |
|---|---|---|
| ① 解引用 src | *src | 取出源字符 |
| ② 赋值 | *dst = *src | 将字符写入目标 |
| ③ 后置自增 | dst++, src++ | 两个指针各前进一个 char |
| ④ 返回值 | 赋值表达式返回被赋的值 | while 用它判断是否为 '\0' |
4.3 赋值表达式的返回值
这是理解这行惯用法的核心——C 语言中,赋值表达式 a = b 的值就是 b 的值:
int x, y;
x = (y = 5); // y = 5 的值是 5,赋给 x,所以 x = 5, y = 5
if ((x = getchar()) != EOF) // 赋值后立即比较
putchar(x);
// 对于 while ((*dst++ = *src++)):
// 赋值 (*dst = *src) 的值就是 *src 的值
// 所以 while 判断的是「刚复制过去的字符是否为 '\0'」#include <stdio.h>
int main(void)
{
char src[] = "Hi";
char dst[10];
char *d = dst, *s = src;
printf("初始: d=%p, s=%p\n", (void*)d, (void*)s);
// 手动追踪第一次迭代
printf("第1步: *s = '%c' (%d)\n", *s, *s);
*d = *s; // dst[0] = 'H'
printf("第2步: 赋值后 *d = '%c'\n", *d);
int val = (*d = *s); // 赋值表达式的值
printf("第3步: 赋值表达式的值 = %d ('%c')\n", val, val);
d++; s++;
printf("第4步: d=%p, s=%p, *s = '%c'\n", (void*)d, (void*)s, *s);
return 0;
}输出:
初始: d=0x..., s=0x...
第1步: *s = 'H' (72)
第2步: 赋值后 *d = 'H'
第3步: 赋值表达式的值 = 72 ('H')
第4步: d=..., s=..., *s = 'i''H' 的 ASCII 码是 72,非零,循环继续。当复制 '\0'(值为 0)时,赋值表达式的值为 0,while(0) 终止。
4.4 后置自增 ++ 的时机
char arr[] = "AB";
char *p = arr;
// *p++ 的执行顺序:
// 1. 先取 *p(得到 'A')
// 2. 再 p = p + 1(指针前进)
// 3. 整个表达式的值是 *p 的旧值('A')
char c = *p++; // c = 'A', p 现在指向 'B'
printf("%c\n", c); // A
printf("%c\n", *p); // B// while ((*dst++ = *src++))
// 每次迭代的时间线:
//
// 时刻 1: 取 *src (字符 c)
// 时刻 2: *dst = c (写入目标)
// 时刻 3: src++, dst++ (两个指针都前进)
// 时刻 4: 判断 c != '\0' ? 继续 : 退出
//
// 关键: 指针在「赋值完成之后」才自增
// 所以循环结束时,两个指针都指向 '\0' 之后的位置NOTE
*p++ 等价于 *(p++),因为 ++ 的优先级高于 *。但 *++p 是先自增再解引用——行为完全不同。本课使用的 *dst++ = *src++ 始终是后置自增(先解引用再自增)。
5. 函数返回值:为什么返回 char*
5.1 链式调用
// 返回 dst 指针使得可以链式调用:
printf("%s\n", mystrcpy(dst, src));
// 等价于:
mystrcpy(dst, src);
printf("%s\n", dst);标准库 strcpy 返回 dst 起始地址,正是为了支持这种链式调用。实际上,大多数字符串函数(strcat、strncpy 等)都返回目标指针。
char buf[256];
// 链式操作的典型场景:
printf("结果: %s\n", mystrcpy(buf, "hello"));
// 甚至可以:
mystrcpy(buf + 5, " world"); // 从 buf[5] 开始拷贝
printf("%s\n", buf); // "hello world"5.2 保存起始地址的必要性
char *mystrcpy(char *dst, const char *src)
{
char *ret = dst; // 保存起始地址 ← 必须在循环前保存!
while ((*dst++ = *src++))
;
return ret; // 返回起始地址
// return dst; // ❌ 错误: dst 已经指向 '\0' 之后了!
}dst 在循环中不断前进,结束时已经越过 '\0'。必须提前保存起始地址,否则无法返回正确的值。
6. delta 优化:减少指针变量的存取次数
6.1 优化动机
在经典版 strcpy 中,每次循环同时移动两个指针(dst++ 和 src++)。如果 dst 和 src 的相对位置是固定的,我们只需要移动一个指针,用偏移量计算另一个指针的位置。
// 经典版: 每次循环操作两个指针
while ((*dst++ = *src++))
;
// 每次迭代: 读 *src, 写 *dst, src++, dst++(四次内存/寄存器操作)
// delta 版: 只操作一个指针 + 一个固定偏移量
int delt = dst - src; // 计算偏移量(以字节为单位)
while ((s[delt] = *s++) != '\0')
;
// 每次迭代: 读 *s, 写 s[delt], s++(三次操作)
// s[delt] 由编译器计算: *(s + delt)优化原理:减少了一次指针变量 dst 的自增操作。在古老的编译器或嵌入式平台上,减少一次变量的存取可能带来可感知的性能提升。
6.2 delta 的完整实现
char *mystrcpy_delt(char *dst, const char *src)
{
assert(dst != NULL && src != NULL);
char *s = (char *)src; // 去掉 const 用于计算偏移量
int delt = dst - src; // 偏移量 = dst 地址 - src 地址
while (*s != '\0') // 遍历源字符串
{
s[delt] = *s; // 写入 dst 对应位置
s++; // 只移动 s 指针
}
s[delt] = '\0'; // 补上终止符
return dst;
}delta 原理图解:
假设 src = buf[0], dst = buf[256]
delt = dst - src = 256
src → buf[0] buf[1] buf[2] ... buf[255] | buf[256] ...
┌───┬───┬───┬───────────┬───┐┌───┬───
│ H │ e │ l │ ... │ o ││\0│ ...
└───┴───┴───┴───────────┴───┘└───┴───
s s+1 s+2 s+5
↓ ↓ ↓ ↓
s[256] s[257] s[258] s[261]
↓ ↓ ↓ ↓
┌───┬───┬───┬───────────┬───┐┌───┬───
│ H │ e │ l │ ... │ o ││\0│ ...
└───┴───┴───┴───────────┴───┘└───┴───
dst → buf[256] buf[261]
s[delt] = s[256] → 指向 buf[256],即 dst 的当前位置WARNING
delta 优化要求 dst 和 src 指向同一个数组(或同一块连续分配的内存)内的元素。C 标准规定:两个指针相减只有当它们指向同一个数组内的元素(或数组末尾之后一个位置)时才是合法的。本课练习中 buf[512] 保证了这个前提。
6.3 指针相减的语义
#include <stdio.h>
int main(void)
{
char buf[512];
char *src = buf; // 指向 buf[0]
char *dst = buf + 256; // 指向 buf[256]
// 指针相减: 结果是两个地址之间的「元素个数」
int delt = dst - src; // 256
printf("delt = %d\n", delt);
// 地址差 = delt * sizeof(char) = 256 * 1 = 256 字节
printf("地址差 = %td 字节\n", (char*)dst - (char*)src);
// 不同类型指针的减法结果不同:
int arr[100];
int *p1 = arr;
int *p2 = arr + 10;
int diff = p2 - p1; // 10(个 int),不是 40(个字节)
printf("diff = %d\n", diff);
return 0;
}| 表达式 | 结果类型 | 含义 |
|---|---|---|
p2 - p1 | ptrdiff_t | p1 和 p2 之间的元素个数 |
(char*)p2 - (char*)p1 | ptrdiff_t | 两个地址之间的字节数 |
7. 从 strcpy 到 memcpy:终止符与字节数
7.1 strcpy 的局限
// strcpy 依赖 '\0' 作为终止条件
char buf[16];
strcpy(buf, "hello world!"); // ✅ 字符串有 '\0',正常
// 但如果数据中包含 '\0' 怎么办?
char binary[] = {'H', 'e', '\0', 'l', 'o'}; // 中间有 '\0'
strcpy(buf, binary); // ❌ 只拷贝了 "He"!遇到 '\0' 就停了
printf("%s\n", buf); // 输出: Hestrcpy 只适用于 C 字符串(以 '\0' 结尾的字符序列)。对于包含 '\0' 的二进制数据,需要 memcpy。
7.2 memcpy:按字节数拷贝
#include <string.h>
void *memcpy(void *dest, const void *src, size_t n);
// ↑ 通用指针 ↑ ↑ 拷贝 n 个字节
// 不限于 char* const void*
// 使用示例:
char src[] = {'A', 'B', '\0', 'C', 'D'};
char dst[16];
memcpy(dst, src, 5); // 拷贝 5 个字节,不管有没有 '\0'
// dst 现在包含: A, B, 0, C, D
// 注意: memcpy 的参数是 void*,可拷贝任何类型
int a[5] = {1, 2, 3, 4, 5};
int b[5];
memcpy(b, a, 5 * sizeof(int)); // 拷贝 5 个 intstrcpy vs memcpy:
strcpy(dst, "hello"):
┌───┬───┬───┬───┬───┬───┐
│ h │ e │ l │ l │ o │\0 │ ← 遇到 '\0' 停止
└───┴───┴───┴───┴───┴───┘
拷贝 6 个字节(自动)
memcpy(dst, src, 3):
┌───┬───┬───┐
│ A │ B │ C │ ← 不管内容是什么,拷贝 3 个字节
└───┴───┴───┘
拷贝 3 个字节(手动指定)7.3 自己实现 memcpy
#include <stddef.h>
void *my_memcpy(void *dest, const void *src, size_t n)
{
char *d = (char *)dest; // 转换为字节指针
const char *s = (const char *)src;
// 逐字节拷贝(和 strcpy 类似的指针惯用法)
while (n--) // n 次迭代
*d++ = *s++;
return dest;
}
// 使用:
char buf[32];
my_memcpy(buf, "hello", 6); // 拷贝 6 个字节(包括 '\0')
printf("%s\n", buf); // hello| 特性 | strcpy | memcpy |
|---|---|---|
| 终止条件 | 遇到 '\0' | 拷贝 n 个字节 |
| 参数 | char *dst, const char *src | void *dst, const void *src, size_t n |
| 适用场景 | C 字符串 | 任意二进制数据 |
| 安全性 | 源必须正确终止 | 调用者负责 n 的正确性 |
| 返回类型 | char* | void* |
8. 安全字符串操作:防止缓冲区溢出
8.1 strcpy 的危险
char buf[8];
strcpy(buf, "this string is way too long!");
// ↑
// 越界写入!覆盖了 buf 之后的内存
// 可能导致: 段错误、数据损坏、安全漏洞(缓冲区溢出攻击)缓冲区溢出是 C 语言历史上最严重的安全问题之一。strcpy 不检查目标缓冲区大小,完全信任源字符串。
8.2 安全的替代方案
#include <stdio.h>
#include <string.h>
// 方案 1: strncpy —— 限制拷贝长度
char buf[8];
strncpy(buf, "hello world", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0'; // 手动保证终止
// buf = "hello w" (只拷贝了 7 个字符)
// 方案 2: snprintf —— 格式化写入,自动截断
char buf[8];
snprintf(buf, sizeof(buf), "%s", "hello world");
// buf = "hello w" (自动加 '\0',最多写 sizeof(buf) 字节)
// 方案 3: strlcpy (BSD/非标准,但很多平台可用)
// strlcpy(buf, "hello world", sizeof(buf));
// buf = "hello w" (自动保证 '\0' 终止)#include <stdio.h>
#include <string.h>
int main(void)
{
char buf[8];
const char *long_str = "this is a very long string";
// strncpy: 截断但不保证 '\0' 终止!
strncpy(buf, long_str, sizeof(buf));
printf("strncpy: [%s]\n", buf);
// 危险: 如果源长度 >= sizeof(buf),buf 没有 '\0'!
// 正确做法: 手动补 '\0'
strncpy(buf, long_str, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
printf("strncpy safe: [%s]\n", buf);
// snprintf: 自动保证 '\0' 终止(推荐)
snprintf(buf, sizeof(buf), "%s", long_str);
printf("snprintf: [%s]\n", buf);
return 0;
}| 函数 | 安全性 | \0 终止保证 | 返回值 |
|---|---|---|---|
strcpy | ❌ 不安全 | 依赖源字符串 | dst |
strncpy | ⚠️ 有限 | 需手动保证 | dst |
snprintf | ✅ 安全 | 始终保证 | 如果 buf 足够大,返回写入的字符数 |
strlcpy | ✅ 安全 | 始终保证 | 源字符串长度 |
IMPORTANT
永远不要在新代码中使用 strcpy——这是业界共识。如果必须操作 C 字符串,使用 snprintf 或 strncpy + 手动 '\0'。本课实现 mystrcpy 是为了理解指针惯用法和底层机制,不是鼓励你在生产代码中使用它。
9. 工业级 strcpy:glibc 的优化实现
9.1 为什么标准库的 strcpy 比我们的快很多
前面我们写的 mystrcpy 逐字节拷贝,简单直观。但现代 CPU 的内存总线是 32 位或 64 位的——每次读写 4 字节(WORD)或 8 字节的效率最高。逐字节拷贝意味着每个字符都要发起一次总线操作。
// 逐字节拷贝(1 字节/次)
char *d = dst;
const char *s = src;
while ((*d++ = *s++))
; // 拷贝 "hello world!\0" (13 字节) → 13 次循环
// 按 WORD 拷贝(4 字节/次)—— 伪代码
unsigned long *ld = (unsigned long *)dst;
const unsigned long *ls = (const unsigned long *)src;
while (1) {
unsigned long word = *ls; // 一次读 4 个字节
// 检查 word 中是否有 '\0' 字节
// 如果有,逐个处理剩余字符
// 否则,一次写入 4 字节
*ld = word;
ld++;
ls++;
}
// 理论上最多需要 ceil(13/4) = 4 次循环9.2 内存对齐的概念
内存对齐示意图:
地址: 0 1 2 3 4 5 6 7
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ │ │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
对齐的 4 字节读取:
*(unsigned long*)0x0 → 读 0x0~0x3(一次总线操作,对齐)
*(unsigned long*)0x4 → 读 0x4~0x7(一次总线操作,对齐)
未对齐的 4 字节读取:
*(unsigned long*)0x1 → 读 0x1~0x4
需要两次总线操作: 读 0x0~0x3 + 读 0x4~0x7,然后拼接
性能损失!NOTE
某些 CPU 架构(如 ARMv5 及更早版本)根本不支持未对齐的内存访问——会直接触发硬件异常。x86 架构支持未对齐访问,但性能会显著下降。
9.3 glibc strcpy 的核心思路
// glibc strcpy 的简化思路(非真实代码,仅为概念示意)
char *strcpy_optimized(char *dst, const char *src)
{
char *ret = dst;
// 第 1 步: 逐字节拷贝直到 dst 对齐到 4 字节边界
while (((unsigned long)dst & 3) != 0) // dst 未对齐?
{
if ((*dst++ = *src++) == '\0')
return ret;
}
// 第 2 步: 按 4 字节(WORD)批量拷贝
unsigned long *ld = (unsigned long *)dst;
const unsigned long *ls = (const unsigned long *)src;
while (1)
{
unsigned long word = *ls; // 一次读 4 字节
// 检查 word 中是否包含 '\0' 字节
// 魔法公式: (word - 0x01010101) & ~word & 0x80808080
if (has_zero_byte(word)) // 包含 '\0'?
break;
*ld++ = word; // 一次写 4 字节
ls++;
}
// 第 3 步: 逐字节拷贝剩余字符(含 '\0')
dst = (char *)ld;
src = (const char *)ls;
while ((*dst++ = *src++))
;
return ret;
}glibc strcpy 的执行流程:
输入: src = "Hello World\0"
第 1 步: 逐字节对齐
[H] [e] [l] [l] ← 4 字节,dst 已对齐
↑
第 2 步: WORD 批量拷贝
[Hell] [o Wo] [rld\0] ← 每次 4 字节
↑ ↑ ↑
第 3 步: 逐字节收尾
检测到 '\0' → 停止NOTE
glibc 中检测 WORD 是否包含零字节的魔法公式 (word - 0x01010101) & ~word & 0x80808080 是一个经典的位运算技巧——一次判断就能检查 4 个字节中是否有 0。这比逐个比较高效得多。
9.4 硬件加速:SSE 与 NEON
现代 CPU 提供了 SIMD(单指令多数据)指令集,可以一次处理 16 甚至 32 字节:
| 平台 | 指令集 | 一次可处理的字节数 |
|---|---|---|
| x86/x86-64 | SSE2 | 16 字节 |
| x86/x86-64 | AVX2 | 32 字节 |
| ARM | NEON | 16 字节 |
; x86-64 SSE 版本的 memcpy(示意,非完整代码)
movdqa xmm0, [rsi] ; 从 src 一次加载 16 字节到 XMM 寄存器
movdqa [rdi], xmm0 ; 一次写入 16 字节到 dst
add rsi, 16 ; src += 16
add rdi, 16 ; dst += 16这就是为什么标准库的 memcpy/strcpy 比手写的逐字节版本快 10 倍以上——它们使用了硬件级并行。
10. 字符串操作与指针的全景回顾
10.1 本课在课程体系中的位置
课程中的指针/字符串知识链:
Lesson 08 (数字9的个数)
└─ 函数定义、逐位提取
↓
Lesson 09 (整型转字符串)
└─ 字符数组、C字符串、'\0'终止符、双指针逆序
↓
Lesson 10 (约瑟夫环)
└─ 数组模拟链表、取模实现环形
↓
Lesson 11 (两点距离)
└─ struct 结构体、按值传递、指针传递性能对比
↓
Lesson 16 (本课: 字符串拷贝) ← 你现在在这里
└─ 字符指针、const、指针惯用法、工业级优化
↓
Lesson 17 (统计单词个数)
└─ 状态机遍历字符串
↓
Lesson 18 (my_printf)
└─ 格式化输出、变参、字符串解析10.2 指针惯用法速查表
| 惯用法 | 含义 | 应用场景 |
|---|---|---|
*p++ | 先取 *p,再 p++ | 遍历数组/字符串 |
*++p | 先 p++,再取 *p | 跳过第一个元素 |
while (*p++) | 遍历到 '\0' 或 0 | 字符串/哨兵遍历 |
while ((*d++ = *s++)) | 拷贝直到 '\0' | strcpy 实现 |
while (n--) *d++ = *s++ | 拷贝 n 个字节 | memcpy 实现 |
char *ret = dst; ... return ret; | 保存并返回起始地址 | 返回目标指针的函数 |
p[i] 等价于 *(p+i) | 指针下标 | 数组/字符串随机访问 |
10.3 strcpy 实现的变化图谱
mystrcpy 的实现层次:
┌─────────────────────────────────────┐
│ 第 0 层: 理解 C 字符串与指针 │
│ char buf[] = "hello"; │
│ char *p = buf; *p = 'H'; │
├─────────────────────────────────────┤
│ 第 1 层: 朴素下标实现 │
│ int i=0; while(src[i]) dst[i]=... │
├─────────────────────────────────────┤
│ 第 2 层: 指针惯用法 │
│ while((*dst++ = *src++)); │
├─────────────────────────────────────┤
│ 第 3 层: 防御性编程 │
│ assert(dst&&src); const char *src; │
├─────────────────────────────────────┤
│ 第 4 层: delta 优化 │
│ delt = dst - src; s[delt] = *s++; │
├─────────────────────────────────────┤
│ 第 5 层: WORD 对齐批量拷贝 │
│ 4 字节一次 + 零字节检测 │
├─────────────────────────────────────┤
│ 第 6 层: 汇编/SIMD 硬件加速 │
│ SSE/NEON 指令, prefetch, cacheline │
└─────────────────────────────────────┘参考解答
练习1: mystrcpy 基础实现
#include <stdio.h>
#include <assert.h>
char *mystrcpy(char *dest, const char *src)
{
assert(dest != NULL && src != NULL);
char *ret = dest; // 保存起始地址
while ((*dest++ = *src++) != '\0') // 经典指针惯用法
; // 空循环体
return ret; // 返回起始地址
}
int main(void)
{
char s1[256] = "";
char s2[256];
fgets(s2, sizeof(s2), stdin);
// 去掉末尾换行符
int i = 0;
while (s2[i] && s2[i] != '\n') i++;
s2[i] = '\0';
mystrcpy(s1, s2);
printf("%s\n", s1);
return 0;
}核心在于 while ((*dest++ = *src++) != '\0'); 这一行:每次循环解引用 src 获得一个字符,赋值给 *dest,然后两个指针同时后置自增,当复制的字符是 '\0' 时循环终止。ret = dest 必须在循环前保存——循环结束后 dest 已经越过终止符。
练习2: delta 优化版
#include <stdio.h>
char *mystrcpy_delt(char *dst, const char *src)
{
char *s = (char *)src; // 去掉 const 用于计算偏移
int delt = dst - src; // 偏移量: dst 和 src 的地址差
while (*s != '\0') // 遍历源字符串
{
s[delt] = *s; // 写入 dst 对应位置: s[delt] = *(s+delt)
s++; // 只移动一个指针
}
s[delt] = '\0'; // 拷贝终止符
return dst;
}
// buf 保证 src 和 dst 在同一数组内,delt 运算合法
char buf[512];
int main(void)
{
char *src = buf;
char *dst = buf + 256;
fgets(src, 256, stdin);
int i = 0;
while (src[i] && src[i] != '\n') i++;
src[i] = '\0';
mystrcpy_delt(dst, src);
printf("%s\n", dst);
return 0;
}delta 优化的核心思想:dst 和 src 的偏移量是固定的 delt,因此 dst 当前位置始终等于 src + delt。用 s[delt](等价于 *(s + delt))访问 dst 对应位置,只需移动 s 一个指针。注意指针相减 dst - src 仅在两者指向同一数组内的元素时才是合法的。
对照检查:保存了
ret起始地址吗?循环条件用的是赋值表达式(*dst++ = *src++)吗?循环在'\0'之后正确终止了吗?delta 版本中delt的计算和s[delt]的使用正确吗?
课堂讨论
while ((*dst++ = *src++) != '\0');中的!= '\0'可以省略吗?为什么?- 如果
dst和src指向的内存区域重叠(例如dst = src + 1),mystrcpy的行为会怎样? - delta 版本中
char *s = (char *)src为什么要强制转换去掉const?有什么更好的设计? assert在发布版本(-DNDEBUG)中被移除后,如果调用者传了NULL,会发生什么?如何设计一个不依赖assert的错误处理?char s[32] = "Hello World"和char *s = "Hello World"在内存布局上有什么区别?哪个可以传给strcpy作为目标?哪个作为源?
讨论答案
Q1: != '\0' 可以省略吗?
可以省略,因为 '\0' 的值为 0。
// 这两种写法等价:
while ((*dst++ = *src++) != '\0')
;
while ((*dst++ = *src++))
;
// 原理: C 语言中,while 的条件是「不等于 0」
// '\0' 的 ASCII 值为 0,所以 (*dst++ = *src++) != '\0'
// 等价于 (*dst++ = *src++) != 0
// 等价于 (*dst++ = *src++)但这不等于说省略就是更好的写法。!= '\0' 明确表达了意图——「拷贝直到终止符」——这是一种自文档化的编程风格。K&R 原书中的写法是省略 != '\0' 的,因为当时的 C 程序员对这种惯用法足够熟悉。现代代码中保留 != '\0' 更友好。
Q2: 内存区域重叠时 mystrcpy 的行为?
mystrcpy(以及标准库 strcpy)在内存重叠时行为是未定义的,可能导致数据损坏。
char buf[] = "abcdef";
char *src = buf;
char *dst = buf + 1; // dst 在 src 之后 1 个字节
mystrcpy(dst, src);
// 期望: buf = "aabcdef"
// 实际: 可能产生 "aaaaaaa"(无限循环或数据损坏)重叠拷贝的失败过程:
初始: a b c d e f \0
src dst
第1次: a a c d e f \0 (dst 覆盖了 b,但 src 已经读过 b)
src dst
第2次: a a a d e f \0 (src 读到了刚写入的 'a'!)
src dst
第3次: a a a a e f \0
...
结果: a a a a a a \0 (全部变成 'a',丢失了原始数据)解决方案:使用 memmove 而不是 strcpy。memmove 能正确处理重叠区域——如果 dst < src,从前向后拷贝;如果 dst > src,从后向前拷贝。
#include <string.h>
memmove(dst, src, strlen(src) + 1); // 安全处理重叠Q3: 为什么 delta 版要去掉 const?
因为 int delt = dst - src 涉及从 const char* 到 char* 的隐式类型差异。
char *mystrcpy_delt(char *dst, const char *src)
{
// int delt = dst - src; // ⚠️ 警告: char* - const char*,类型不完全一致
// 大多数编译器会接受但给出警告
char *s = (char *)src; // 显式转换,消除警告
int delt = dst - s; // 现在类型一致了
// ...
}更好的设计:使用 ptrdiff_t 类型和中间变量,避免修改 const 限定:
char *mystrcpy_delt(char *dst, const char *src)
{
const char *s = src; // 保持 const
ptrdiff_t delt = dst - src; // 编译器允许 char* - const char* (C 标准未明确禁止)
while (*s != '\0') {
// 用 (char *) 转换结果,不影响 s 的 const 属性
*((char *)s + delt) = *s; // 等价于 *(dst 对应位置) = *s
s++;
}
*((char *)s + delt) = '\0';
return dst;
}Q4: assert 被移除后传 NULL 会怎样?
如果 assert 被编译移除(-DNDEBUG),传入 NULL 会导致段错误或未定义行为。
// 发布版本中:
// assert(dst != NULL); → 什么都不做!
// 然后:
// *dst = *src; → 解引用 NULL → 段错误!更好的设计:用运行时检查替代 assert:
char *mystrcpy_safe(char *dst, const char *src)
{
if (dst == NULL || src == NULL)
return NULL; // 返回 NULL 表示失败
char *ret = dst;
while ((*dst++ = *src++))
;
return ret;
}
// 调用者可以检查返回值:
if (mystrcpy_safe(dst, src) == NULL)
fprintf(stderr, "Error: NULL pointer\n");选择指南:
assert:用于「不可能发生」的编程错误,调试期间快速暴露 bugif+ 返回值/错误码:用于「可能发生」的运行时异常,发布版本也需要处理
Q5: char s[32] vs char *s 的区别?
// 方式 1: 字符数组
char s1[32] = "Hello World";
// - 在栈上分配 32 字节
// - "Hello World" 从只读段复制到栈上
// - s1 是数组名(不可修改的左值),但内容可修改
// - sizeof(s1) = 32
// - 可作为 strcpy 的目标 ✅
// 方式 2: 字符指针
char *s2 = "Hello World";
// - 在栈上分配一个指针(8 字节)
// - 指针指向只读数据段的字符串字面量
// - s2 可以修改(指向其他字符串),但 *s2 不可修改
// - sizeof(s2) = 8(指针大小)
// - 不可作为 strcpy 的目标 ❌(写入只读内存会段错误)#include <stdio.h>
#include <string.h>
int main(void)
{
// 正确的用法:
char buf[32];
strcpy(buf, "Hello"); // ✅ buf 是数组,可写
printf("%s\n", buf);
// 错误的用法:
// char *p = "Hello";
// strcpy(p, "World"); // ❌ p 指向只读内存
// 正确: 让指针指向可写内存
char *q = buf; // q 指向 buf(可写)
strcpy(q, "World"); // ✅
printf("%s\n", buf);
return 0;
}| 特性 | char s[32] = "Hello" | char *s = "Hello" |
|---|---|---|
| 存储 | 栈(32 字节) | 指针在栈(8 字节),字符串在 .rodata |
| 可写性 | 可写 ✅ | 只读 ❌(修改是 UB) |
| 可作为 strcpy 目标 | ✅ | ❌(除非指向堆/栈内存) |
| 可作为 strcpy 源 | ✅ | ✅ |
sizeof | 32 | 8 |
&s 类型 | char (*)[32] | char ** |
课后练习
实现 strlen:模仿
mystrcpy的指针惯用法,实现size_t mystrlen(const char *s),返回字符串的长度(不包括'\0')。要求使用指针而非下标,只用一行循环体。知识点提示:
while (*p++)遍历字符串、p - start计算长度、const char*表示只读源字符串双指针奇偶分离:给定一个整数数组,用双指针(一头一尾向中间靠拢)将奇数放在左边、偶数放在右边,不额外分配空间。打印调整后的数组。
知识点提示:双指针从两端向中间移动、
left < right循环条件、*left和*right的解引用操作、% 2判断奇偶实现 memcpy:实现
void *my_memcpy(void *dest, const void *src, size_t n),拷贝 n 个字节。思考:为什么参数用void*而不是char*?如何处理n = 0的情况?知识点提示:
void*通用指针(可接受任何类型)、强制转换为char*逐字节操作、while (n--)计数循环、size_t无符号整型字符串安全拷贝函数:实现
size_t safe_strcpy(char *dst, size_t dst_size, const char *src),最多拷贝dst_size - 1个字符并始终以'\0'终止。返回实际需要的长度(源字符串长度)。知识点提示:缓冲区大小检查、
'\0'终止保证、返回值模仿snprintf设计、size_t类型
练习1: mystrlen 实现
#include <stdio.h>
#include <stddef.h>
size_t mystrlen(const char *s)
{
const char *start = s; // 保存起始地址
while (*s) // 遍历到 '\0'
s++;
return s - start; // 指针相减 = 字符数
}
// 更简洁的一行版本:
size_t mystrlen_v2(const char *s)
{
const char *p = s;
while (*p++); // 空循环,p 最终指向 '\0' 之后
return (size_t)(p - s - 1); // p - s - 1 = 字符数
}
int main(void)
{
printf("len(\"hello\") = %zu\n", mystrlen("hello")); // 5
printf("len(\"\") = %zu\n", mystrlen("")); // 0
return 0;
}核心技巧与 mystrcpy 一脉相承:用指针遍历直到 '\0',用 指针相减 计算长度。注意 p - s 返回的是 ptrdiff_t 类型(有符号),显式转为 size_t(无符号)。
练习2: 双指针奇偶分离
#include <stdio.h>
void partition(int arr[], int n)
{
int *left = arr; // 左指针
int *right = arr + n - 1; // 右指针
while (left < right)
{
// 左边找到第一个偶数
while (left < right && *left % 2 == 1)
left++;
// 右边找到第一个奇数
while (left < right && *right % 2 == 0)
right--;
// 交换
if (left < right)
{
int tmp = *left;
*left = *right;
*right = tmp;
left++;
right--;
}
}
}
int main(void)
{
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int n = sizeof(arr) / sizeof(arr[0]);
partition(arr, n);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
// 输出: 1 9 3 7 5 6 4 8 2 10(奇左偶右,顺序不要求完全一致)
return 0;
}双指针(Two Pointers)是数组/字符串算法中最常见的模式之一——left 从头向右、right 从尾向左,两者相遇时结束。本练习展示了指针不仅仅用于字符串,在数组处理中同样强大。
练习3: my_memcpy 实现
#include <stdio.h>
#include <stddef.h>
void *my_memcpy(void *dest, const void *src, size_t n)
{
char *d = (char *)dest; // 转为字节指针
const char *s = (const char *)src;
// 处理 n = 0 的情况: 循环体不执行,直接返回
while (n--)
*d++ = *s++;
return dest;
}
int main(void)
{
// 测试 1: 拷贝字符串
char str1[32];
my_memcpy(str1, "hello", 6); // 6 个字节(包括 '\0')
printf("%s\n", str1); // hello
// 测试 2: 拷贝整数数组
int a[] = {10, 20, 30, 40, 50};
int b[5];
my_memcpy(b, a, 5 * sizeof(int));
for (int i = 0; i < 5; i++)
printf("%d ", b[i]); // 10 20 30 40 50
printf("\n");
// 测试 3: n = 0(边界情况)
char empty[4] = "abc";
my_memcpy(empty, "xyz", 0);
printf("%s\n", empty); // abc(未修改)
return 0;
}memcpy 用 void* 作为参数类型是为了通用性——同一个函数可以拷贝任何类型的数据。size_t n 是字节数,调用者负责传入正确的值。n = 0 时 while (n--) 直接跳过,函数安全返回。
练习4: safe_strcpy 实现
#include <stdio.h>
#include <stddef.h>
size_t safe_strcpy(char *dst, size_t dst_size, const char *src)
{
if (dst == NULL || src == NULL || dst_size == 0)
return 0;
size_t i;
// 拷贝字符,但最多 dst_size - 1 个(为 '\0' 留空间)
for (i = 0; i < dst_size - 1 && src[i] != '\0'; i++)
dst[i] = src[i];
dst[i] = '\0'; // 始终以 '\0' 终止
// 计算源字符串的实际长度
size_t src_len = 0;
while (src[src_len] != '\0')
src_len++;
return src_len; // 返回源长度(非实际拷贝长度)
}
int main(void)
{
char buf[8];
// 测试 1: 正常情况
size_t len = safe_strcpy(buf, sizeof(buf), "hello");
printf("buf=[%s], src_len=%zu\n", buf, len);
// buf=[hello], src_len=5
// 测试 2: 截断情况
len = safe_strcpy(buf, sizeof(buf), "hello world this is long");
printf("buf=[%s], src_len=%zu\n", buf, len);
// buf=[hello w], src_len=27
// 测试 3: 刚好填满
len = safe_strcpy(buf, sizeof(buf), "1234567");
printf("buf=[%s], src_len=%zu\n", buf, len);
// buf=[1234567], src_len=7
return 0;
}这个函数模仿了 snprintf 的 API 设计:接受缓冲区大小、保证 '\0' 终止、返回源长度(调用者可以比较返回值与 dst_size 判断是否发生了截断)。这是生产代码中字符串操作的推荐模式。
参考资料
- The C Programming Language (K&R), Section 5.5 — 字符指针与函数,包含 strcpy 的经典实现
- ISO C99 Standard, Section 7.21.2 — 标准库字符串复制函数(strcpy, strncpy, memcpy, memmove)
- glibc strcpy 源码 — glibc 2.9 中基于 WORD 对齐的工业级 strcpy 实现
- 林锐《高质量程序设计指南》 — delta 优化技巧的来源与工程实践
- Linux 0.01 内核源码 include/string.h — 内联汇编版 memcpy,最早期的 Linux 内核内存拷贝实现
- memmove vs memcpy 重叠处理 — 内存重叠场景下 memmove 的正确行为分析
"The most effective debugging tool is still careful thought, coupled with judiciously placed print statements." — Brian Kernighan