一、来源
这篇文章《7 suggestions for Computer Majors(给计算机专业的7条建议)》中诞生了while (*s++ = *t++)
这行大名鼎鼎的代码,大意是说:只要你不能解释为什么while (*s++ = *t++)
的作用是复制字符串,那你就是在盲目无知的情况下编程。
这话说得很有技巧,让人产生一种错觉:只要我理解了while (*s++ = *t++)
,我就学会C语言了。
网上已经有过很多解释文章了,感兴趣的可以搜搜看。本文主要是从内存与汇编的角度去理解,不止是解释正确的代码,也会解释错误的代码。
二、错误版本
在上述错觉的鼓动下,我凭直觉写了第1个版本,代码是错的,如下:
#include <stdio.h>
int main() {
char *s = "hello";
char *t = "world";
while (*s++ = *t++);
printf("%s\n", s);
printf("%s\n", t);
return 0;
}
C代码while (*s++ = *t++)
对应的汇编代码如下(下文称为汇编代码
):
nop // 空操作,产生一个时延
mov rdx,QWORD PTR [rbp-0x8] // 把内存单元[rbp-0x8]中的值存入rdx寄存器,8字节
// 注:[rbp-0x8]是变量t的地址,存的值是world
lea rax,[rdx+0x1] // rdx寄存器中的值加上1,把地址存入rax寄存器
// 注:执行了 t++
// 注意这里的rdx中的值并没有加1
// [rdx]内存单元还是指向的w
mov QWORD PTR [rbp-0x8],rax // 把rax寄存器中的值存入[rbp-0x8]内存单元,8字节
// 注:此时[rbp-0x8]存的值是orld(不再指向w,而是指向o)
mov rax,QWORD PTR [rbp-0x10] // 把内存单元[rbp-0x10]中的值存入rax寄存器,8字节
// 注:[rbp-0x10]是变量s的地址,存的值是hello
lea rcx,[rax+0x1] // rax寄存器中的值加1,把加1后的地址存入rcx寄存器
// 注:执行了 s++
// 注意这里的rax中的值并没有加1
// [rax]内存单元还是指向的h
mov QWORD PTR [rbp-0x10],rcx // 把rcx寄存器中的值存入[rbp-0x10]内存单元,8字节
// 注:此时[rbp-0x10]存的值是ello(不再指向h,而是指向e)
movzx edx,BYTE PTR [rdx] // 把[rdx]内存单元的值(即w)存入edx寄存器
mov BYTE PTR [rax],dl // 把dl寄存器(edx的低8位,即w)中的值存入[rax]内存单元
movzx eax,BYTE PTR [rax] // 从[rax]内存单元取1个字节,存入eax寄存器
test al,al // 执行与操作
jne 0x55555555516c // 如果上一步的结果不为0,则进入下次循环
单步跟踪时发现在第25行报错:
mov BYTE PTR [rax],dl // 把dl寄存器(edx的低8位,即w)中的值存入[rax]内存单元
程序的原意是复制字符串,程序做的也确实是复制字符串,但是 char *s = "hello"
和char *t = "world"
这种以指针的形式定义的字符串,都存储在了内存中的常量存储区
,是不允许修改的,而上面那行汇编却去改了它的值,因此报错。
三、正确版本
#include <stdio.h>
int main() {
char hello[] = "hello";
char world[] = "world";
char *s = hello;
char *t = world;
while (*s++ = *t++);
printf("%s\n", hello);
printf("%s\n", world);
return 0;
}
跟错误版本的区别是,字符串的定义从char *s = "hello"
改成了char hello[] = "hello"
,这里的字符串不是存储在常量存储区,而是存储在栈区。参考文章:《字符串常量到底存放在哪个存储区》
汇编代码跟上面错误版本的一样(只是内存地址不一样),这里不再列出汇编代码,只分析内存布局。
3.1 初始内存布局
变量 | 内存地址 | 偏移量 | 变量值 |
---|---|---|---|
s | 0x7fffffffe348 | -0x28 | 0x7fffffffe35c |
t | 0x7fffffffe350 | -0x20 | 0x7fffffffe362 |
hello | 0x7fffffffe35c | -0x14 | h |
0x7fffffffe35d | -0x13 | e | |
0x7fffffffe35e | -0x12 | l | |
0x7fffffffe35f | -0x11 | l | |
0x7fffffffe360 | -0x10 | o | |
0x7fffffffe361 | -0xf | 0x0 | |
world | 0x7fffffffe362 | -0xe | w |
0x7fffffffe363 | -0xd | o | |
0x7fffffffe364 | -0xc | r | |
0x7fffffffe365 | -0xb | l | |
0x7fffffffe366 | -0xa | d | |
0x7fffffffe367 | -0x9 | 0x0 |
3.2 执行完t++
时的内存布局
对应以下汇编指令(看注释):
mov rdx,QWORD PTR [rbp-0x20] // rdx = 0x7fffffffe362,对应内存值是 w
lea rax,[rdx+0x1] // rax = 0x7fffffffe363,对应内存值是 o
mov QWORD PTR [rbp-0x20],rax // 变量t = 0x7fffffffe363,对应内存值是 o
这里啰嗦一句:仔细看,对汇编而言,t++
是需要再拆分的,先是在第1行是取了t
的值(w),然后才在后面两行执行了++
。
内存布局如下,变动的地方已突出显示:
变量 | 内存地址 | 偏移量 | 变量值 |
---|---|---|---|
s | 0x7fffffffe348 | -0x28 | 0x7fffffffe35c |
t | 0x7fffffffe350 | -0x20 | 0x7fffffffe363 |
hello | 0x7fffffffe35c | -0x14 | h |
0x7fffffffe35d | -0x13 | e | |
0x7fffffffe35e | -0x12 | l | |
0x7fffffffe35f | -0x11 | l | |
0x7fffffffe360 | -0x10 | o | |
0x7fffffffe361 | -0xf | 0x0 | |
world | 0x7fffffffe362 | -0xe | w |
0x7fffffffe363 | -0xd | o | |
0x7fffffffe364 | -0xc | r | |
0x7fffffffe365 | -0xb | l | |
0x7fffffffe366 | -0xa | d | |
0x7fffffffe367 | -0x9 | 0x0 |
此时t
指向了world
中的字母o
。
3.3 执行完s++
时的内存布局
对应以下汇编指令(看注释):
mov rax,QWORD PTR [rbp-0x28] // rax = 0x7fffffffe35c,对应内存值是 h
lea rcx,[rax+0x1] // rcx = 0x7fffffffe35d,对应内存值是 e
mov QWORD PTR [rbp-0x28],rcx // 变量s = 0x7fffffffe35d,对应内存值是 e
内存布局如下,变动的地方已突出显示:
变量 | 内存地址 | 偏移量 | 变量值 |
---|---|---|---|
s | 0x7fffffffe348 | -0x28 | 0x7fffffffe35d |
t | 0x7fffffffe350 | -0x20 | 0x7fffffffe363 |
hello | 0x7fffffffe35c | -0x14 | h |
0x7fffffffe35d | -0x13 | e | |
0x7fffffffe35e | -0x12 | l | |
0x7fffffffe35f | -0x11 | l | |
0x7fffffffe360 | -0x10 | o | |
0x7fffffffe361 | -0xf | 0x0 | |
world | 0x7fffffffe362 | -0xe | w |
0x7fffffffe363 | -0xd | o | |
0x7fffffffe364 | -0xc | r | |
0x7fffffffe365 | -0xb | l | |
0x7fffffffe366 | -0xa | d | |
0x7fffffffe367 | -0x9 | 0x0 |
此时s
指向了hello
中的字母e
。
3.4 执行完(*s++ = *t++)
时的内存布局
对应以下汇编指令(看注释):
movzx edx,BYTE PTR [rdx] // edx = w,其中低8位dl也是 w
mov BYTE PTR [rax],dl // 内存 0x7fffffffe35c 的值从 h 改成 w
内存布局如下,变动的地方已突出显示:
变量 | 内存地址 | 偏移量 | 变量值 |
---|---|---|---|
s | 0x7fffffffe348 | -0x28 | 0x7fffffffe35d |
t | 0x7fffffffe350 | -0x20 | 0x7fffffffe363 |
hello | 0x7fffffffe35c | -0x14 | w |
0x7fffffffe35d | -0x13 | e | |
0x7fffffffe35e | -0x12 | l | |
0x7fffffffe35f | -0x11 | l | |
0x7fffffffe360 | -0x10 | o | |
0x7fffffffe361 | -0xf | 0x0 | |
world | 0x7fffffffe362 | -0xe | w |
0x7fffffffe363 | -0xd | o | |
0x7fffffffe364 | -0xc | r | |
0x7fffffffe365 | -0xb | l | |
0x7fffffffe366 | -0xa | d | |
0x7fffffffe367 | -0x9 | 0x0 |
可以看到hello
变量的值的第一个字节已经从h
变成了w
,这样就完成了第一个字母的复制,后续几个字母的复制过程也是类似的。
接下来执行剩下的判断条件的汇编:
movzx eax,BYTE PTR [rax] // eax = w
test al,al // 测试 w && w,结果不是0
// 注:直到最后一步遇到world之后的`\0`时结果才是0
jne 0x5555555551af // 继续循环(或最后一次跳出循环)
直到最后一步遇到world之后的\0
时,test al,al
的结果就是0,才会跳出循环。
四、总结
while (*s++ = *t++);
之所以难以理解,是因为这行代码的浓缩性太强了,一行C代码对应了十几行汇编代码。
从上文的分析可以看出,对应的汇编可以分为4部分:
t++
s++
*s++ = *t++
while
条件判断
一步一步去理解就没有那么难了。t++
的目的是挨个取出world
中的字母,s++
的目的是指定要把取出的字母复制到哪个地方去,而while
循环终结于系统为每个字符串末尾自动追加的\0
。
全文完