一、问题发现

最近写代码遇到个很神奇的循环,然后就研究了一下。下面是具体的代码:

出现奇怪循环的代码
1
2
3
4
5
6
7
8
9
10
11
#include <bits/stdc++.h>
using namespace std;

int main(int argc, char* argv[])
{
char s[255];
cin >> s;
cout << "s=" << s << cin.rdbuf();

return 0;
}

这段程序运行后的具体表现为:当用户输入字符串“第一次输入”并回车时,控制台会立即输出字符串“s=第一次输入”。然后,程序会要求用户再次进行输入。当用户输入字符串“第二次输入”并回车时,控制台会立即输出字符串“第二次输入”。接着,程序依然会要求用户进行输入。后面就是以此类推,不断重复这个过程,从而陷入无限循环的输入输出过程

图1.1 程序运行截图

二、问题研究

为了研究这个奇怪的现象,于是我打算从这段代码对应的汇编代码开始研究,于是我使用了 Complier Explorer 网站对这段代码基于 GCC 4.9.2 进行了汇编,得到的结果如下:

Complier Explorer 汇编结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
.LC0:
.string "s="
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 280
mov DWORD PTR [rbp-276], edi
mov QWORD PTR [rbp-288], rsi
lea rax, [rbp-272]
mov rsi, rax
mov edi, OFFSET FLAT:_ZSt3cin
call std::basic_istream<char, std::char_traits<char> >& std::operator>><char, std::char_traits<char> >(std::basic_istream<char, std::char_traits<char> >&, char*)
mov edi, OFFSET FLAT:_ZSt3cin+16
call std::basic_ios<char, std::char_traits<char> >::rdbuf() const
mov rbx, rax
mov esi, OFFSET FLAT:.LC0
mov edi, OFFSET FLAT:_ZSt4cout
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
mov rdx, rax
lea rax, [rbp-272]
mov rsi, rax
mov rdi, rdx
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
mov rsi, rbx
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_streambuf<char, std::char_traits<char> >*)
mov eax, 0
add rsp, 280
pop rbx
pop rbp
ret
__static_initialization_and_destruction_0(int, int):
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
cmp DWORD PTR [rbp-4], 1
jne .L3
cmp DWORD PTR [rbp-8], 65535
jne .L3
mov edi, OFFSET FLAT:_ZStL8__ioinit
call std::ios_base::Init::Init() [complete object constructor]
mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:_ZStL8__ioinit
mov edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
call __cxa_atexit
.L3:
leave
ret
_GLOBAL__sub_I_main:
push rbp
mov rbp, rsp
mov esi, 65535
mov edi, 1
call __static_initialization_and_destruction_0(int, int)
pop rbp
ret

对于我这样汇编水平一般般的来说,看懂汇编可不是一件轻松的事情。为加快研究速度(真的很想知道为什么),所以这里借用了一下 chatGPT 3.5 对这段汇编代码进行了注释。下面是 chatGPT 注释后的内容:

chatGPT 注释后的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
.LC0:
.string "s="
main:
push rbp ; 保存栈帧指针
mov rbp, rsp ; 设置栈帧指针
push rbx ; 保存通用寄存器 rbx
sub rsp, 280 ; 在栈上分配 280 字节的空间
mov DWORD PTR [rbp-276], edi ; 存储命令行参数 argc
mov QWORD PTR [rbp-288], rsi ; 存储命令行参数 argv

lea rax, [rbp-272] ; 计算数组 s 的地址
mov rsi, rax ; 将数组 s 的地址存入 rsi 寄存器
mov edi, OFFSET FLAT:_ZSt3cin ; 将输入流对象 cin 的地址存入 edi 寄存器
call std::basic_istream<char, std::char_traits<char> >& std::operator>><char, std::char_traits<char> >(std::basic_istream<char, std::char_traits<char> >&, char*)
; 调用输入运算符重载函数,将输入流中的内容读入数组 s

mov edi, OFFSET FLAT:_ZSt3cin+16 ; 获取输入流对象 cin 的 rdbuf 地址
call std::basic_ios<char, std::char_traits<char> >::rdbuf() const
; 调用 rdbuf 函数,获取输入流对象 cin 的缓冲区地址,并将其保存在 rbx 寄存器

mov rbx, rax ; 将输入流对象 cin 的缓冲区地址保存在 rbx 寄存器中

mov esi, OFFSET FLAT:.LC0 ; 将字符串 "s=" 的地址存入 esi 寄存器
mov edi, OFFSET FLAT:_ZSt4cout ; 将输出流对象 cout 的地址存入 edi 寄存器
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
; 调用输出运算符重载函数,将字符串 "s=" 输出到标准输出流

mov rdx, rax ; 将输出流对象 cout 的地址保存在 rdx 寄存器
lea rax, [rbp-272] ; 计算数组 s 的地址
mov rsi, rax ; 将数组 s 的地址存入 rsi 寄存器
mov rdi, rdx ; 将输出流对象 cout 的地址存入 rdi 寄存器
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
; 调用输出运算符重载函数,将数组 s 的内容输出到标准输出流

mov rsi, rbx ; 将输入流对象 cin 的缓冲区地址存入 rsi 寄存器
mov rdi, rax ; 将输出流对象 cout 的地址存入 rdi 寄存器
call std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_streambuf<char, std::char_traits<char> >*)
; 调用输出运算符重载函数,将输入流对象 cin 的缓冲区内容输出到标准输出流

mov eax, 0 ; 设置返回值为 0
add rsp, 280 ; 释放栈上的空间
pop rbx ; 恢复通用寄存器 rbx
pop rbp ; 恢复栈帧指针
ret ; 返回

__static_initialization_and_destruction_0(int, int):
push rbp ; 保存栈帧指针
mov rbp, rsp ; 设置栈帧指针
sub rsp, 16 ; 在栈上分配 16 字节的空间
mov DWORD PTR [rbp-4], edi ; 存储静态初始化函数的参数
mov DWORD PTR [rbp-8], esi ; 存储静态初始化函数的参数

cmp DWORD PTR [rbp-4], 1 ; 检查参数 argc 是否为 1
jne .L3 ; 如果不是,则跳转到 .L3

cmp DWORD PTR [rbp-8], 65535 ; 检查参数 argv 是否为 65535
jne .L3 ; 如果不是,则跳转到 .L3

mov edi, OFFSET FLAT:_ZStL8__ioinit
; 将输入输出库初始化函数地址存入 edi 寄存器
call std::ios_base::Init::Init() [complete object constructor]
; 调用输入输出库初始化函数

mov edx, OFFSET FLAT:__dso_handle
mov esi, OFFSET FLAT:_ZStL8__ioinit
mov edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
call __cxa_atexit ; 注册析构函数,确保在程序退出时调用

.L3:
leave ; 恢复栈帧并返回
ret

_GLOBAL__sub_I_main:
push rbp ; 保存栈帧指针
mov rbp, rsp ; 设置栈帧指针
mov esi, 65535 ; 设置参数值
mov edi, 1 ; 设置参数值
call __static_initialization_and_destruction_0(int, int)
; 调用静态初始化函数
pop rbp ; 恢复栈帧指针
ret ; 返回

可以看出很多都是不相关的代码,实际上只需要关注的代码只有很小一部分。所以,我提取了关键的部分给 chatGPT 进行注释。下面是重新注释后的结果:

关键代码注释结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
;关键代码:
; cin >> s;
; cout << "s=" << s << cin.rdbuf();
;下面是这段代码对应的汇编及 chatGPT 的详细注释


; 以下是对上述代码中每一行汇编指令的详细注释:

; 将数组 s 的地址加载到寄存器 rax 中
lea rax, [rbp-272]

; 将数组 s 的地址存储到寄存器 rsi 中,准备作为输入参数传递给输入运算符重载函数
mov rsi, rax

; 将输入流对象 cin 的地址存储到寄存器 edi 中
mov edi, OFFSET FLAT:_ZSt3cin

; 调用输入运算符重载函数,将输入流中的内容读入数组 s
call std::basic_istream<char, std::char_traits<char> >& std::operator>><char, std::char_traits<char> >(std::basic_istream<char, std::char_traits<char> >&, char*)

; 获取输入流对象 cin 的 rdbuf 地址并存储到寄存器 rax 中
mov edi, OFFSET FLAT:_ZSt3cin+16
call std::basic_ios<char, std::char_traits<char> >::rdbuf() const

; 将输入流对象 cin 的 rdbuf 地址存储到寄存器 rbx 中
mov rbx, rax

; 将字符串 "s=" 的地址存储到寄存器 esi 中
mov esi, OFFSET FLAT:.LC0

; 将输出流对象 cout 的地址存储到寄存器 edi 中
mov edi, OFFSET FLAT:_ZSt4cout

; 调用输出运算符重载函数,将字符串 "s=" 输出到标准输出流
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)

; 将输出流对象 cout 的地址存储到寄存器 rdx 中
mov rdx, rax

; 将数组 s 的地址存储到寄存器 rsi 中
lea rax, [rbp-272]
mov rsi, rax

; 将输出流对象 cout 的地址存储到寄存器 rdi 中
mov rdi, rdx

; 调用输出运算符重载函数,将数组 s 的内容输出到标准输出流
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)

; 将输入流对象 cin 的 rdbuf 地址存储到寄存器 rsi 中
mov rsi, rbx

; 将输出流对象 cout 的地址存储到寄存器 rdi 中
mov rdi, rax

; 调用输出运算符重载函数,将输入流对象 cin 的 rdbuf 地址的内容输出到标准输出流
call std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_streambuf<char, std::char_traits<char> >*)

三、幡然醒悟

不知道咋回事,突然顿悟…回头想想又觉得很可笑,这么简单的问题我整的如此复杂。不得不说,在这次分析过程中做了很多“奇怪的分析行为”,也就是无用功。而且没有准确地抓住问题的痛点,稀里糊涂地乱分析一通最后把自己绕进去了。

最后,突然醒悟的时候恨不得抽自己几个大嘴巴子…哈哈哈😂

四、原因分析

下面说一下产生这个现象的具体原因:

其实产生循环的代码精炼出来就是这段:
cout << cin.rdbuf();

我们知道 cin.rdbuf() 函数会返回标准输入流缓冲区对象的指针。也就是说,这段代码的作用相当于把输入流缓冲区的指针直接重定向到输出流,并且还请求输出“输入缓冲区的内容”。然后,输入缓冲区就会等待输入内容。但是,当我们按下回车将内容送到输入缓冲区时,由于输入缓冲区已经被重定向到输出流,cin 还没来得及判断输入过程是否结束(cin 对象读取到回车才会认为一次输入过程结束),输入缓冲区的内容就已经被输出流拿走并输出了(也就是 cin 没拿到数据,一直在等待,实际上它等待的内容已经被拿走了)。

下面对运行结果进行逐步解释

注意,以下均为我个人理解,不保证绝对正确!

  • 第一次输入:执行 cin >> s;,用户第一次输入字符串“第一次输入”
  • s=第一次输入:执行 cout << "s=" << s,将字符串“第一次输入”保存到变量 s 中,并输出。
  • 第二次输入:执行 << cin.rdbuf();(实际上是 cout << cin.rdbuf();),将输入流缓冲区重定向到输出流。同时请求输出“输入缓冲区”的内容。因为向“输入缓冲区”发送了读取请求,而“输入缓冲区”中此时为空,故而导致了此次的输入请求
  • 第二次输入:当按下回车时,内容直接从“输入缓冲区”被送入“输出缓冲区”,然后交给“输出处理模块”(这里我并不清楚到底谁在处理,就先这么称呼吧!)进行处理并展示到控制台上。
  • 第三次输入:但是,输入并没有被判定结束!因为“输入处理模块”(这里我也并不清楚到底谁在处理,就先这么称呼吧!)并未收到任何内容,也就并不认为输入结束了,所以还在等待输入内容(就是在等“输入缓冲区”的内容被送过来,但是实际上已经被送给“输出缓冲区”了)。
  • 第三次输入:继续输出。
  • **…**:继续等待输入。
  • **…**:继续输出。
  • 第N次输入:继续等待输入。
  • 第N次输入:继续输出。

从某种意义上说,从第二次输入到后面的每一次输入都是同一次输入,因为这次输入并没有结束