情景剧:C/C++中的未定义行为(undefined behavior)
写在前面
本文尝试以情景剧的方式,轻松、直观地解释C/C++中未定义行为(undefined behavior)的概念、设计动机、优缺点等内容1,希望读者能够通过阅读本文,对undefined behavior有一个清晰、深刻、全面的认识。
正文
人物
彪哥:可将其视为C/C++标准(standard)或标准的制定者。
小编们:可将其视为编译器或编译器的编写者(生产商),分别记为“小编1”、“小编2”、…、“小编N”。注意,这里的编译器是“广义”的,即指将源代码转换为可执行文件的“处理器”。
小猿们:程序员,即C/C++的使用者,分别记为“小猿1”、“小猿2”、…、“小猿N”。
布景
这是一间宽敞明亮的会议室,墙上的横幅上写着“关于C语言标准制定与实现的三方洽谈会第一次会议”。横幅的正下方、长方形会议桌的一条短边旁,是一位年约四十、微微发福的光头中年男人,他手握茶杯,正襟危坐。在他面前的名牌上,赫然写着“彪哥”。同时,在他的左右两旁,即会议桌的两个长边上,各坐着几个不同年龄、神情各异的人。他们依发量多少依次就坐,发量越少的离彪哥越近,其中,彪哥左手边的人面前的名牌上依次写着“小猿1”、“小猿2”、…、“小猿N”,而彪哥右手边的人面前的名牌上依次写着“小编1”、“小编2”、…、“小编N”。令人惊讶的是,整个会议室中,竟没有一个头发浓密之人,就连唯一一位女同志,发量也只能勉强算是正常。
第一幕 彪哥开场
彪哥(轻咳一声):咳咳,那个,咱们就开始吧。我先说两句啊,我们的语言,C,主打的是什么?啊?是快!是高效!正所谓鱼与熊掌不可兼得,所以啊,安全性这一块,必要的时候,可以适当地放宽一些嘛!你比如说,数组越界访问,越不越界,(看着小猿们)你们心里没点数吗?代码是你们自己写的啊!再说了,从逻辑上讲,你访问你已经定义的范围,这是有意义的,但是,如果你访问的范围你之前都没有定义,这本身就没有任何意义嘛!就好比一个老师做家访,班上有10个学生,他偏要访问第11家,这不是扯呢吗?再比如,解引用空指针,指针空不空,(再次看向小猿们)你们心里没点数吗?代码是你们自己写的啊!再说了,从逻辑上讲,空指针没有指向任何对象,你去解引用它,这本身就没有任何意义嘛!还有啊,像 char *p = "hello"; 这样的代码,你既然直接写出了 "hello" 这一字符串字面值(sring literal),潜台词就是将其作为一个整体、一个常量来用,后面不打算再改了。我的想法是,直接将字符串字面值放入全局的只读数据区2,在内存中仅保存一份拷贝,这样,当下次再使用的时候,比如 char *q = "hello"; ,就可以直接共享内存了3。可你们,(再次看向小猿们)偏偏有人要写 p[0] = 'y'; 这样的代码,你说你,如果打算将 "hello" 作为字符数组的话,一开始就直接写 char arr[] = {'h', 'e', 'l', 'l', 'o'}; 不好吗?所以啊,对以上这些逻辑上错误的、不合常理的、毫无意义的行为,我不打算花费任何额外的功夫来提供任何保障,因为我没有必要因为个别人的愚蠢而牺牲语言的效率。换言之,我打算将以上行为称为“未定义行为”,undefined behavior,并且,对这些行为,我不做任何的规定,(看着小编们,比了个V的手势)具体你们怎么搞,我的意见就两个字——随便!(看着大家一时愣在那里,彪哥顿了顿,又故作镇定地道)当然了,我这么做并不是想偷懒,而且,这对你们双方都是有好处的。(看着小编们)一方面,这给了你们很大的自由发挥空间,(又看向小猿们)另一方面,这么一来,我们语言的学习路线一下子就陡峭了,学习门槛一下子就拔高了,那你们的工资,不也随之提高了吗?你们天天嚷嚷着“要提高程序员的薪资待遇”、“要提高程序员的薪资待遇”,这种事情,光从制度和政策上解决是治标不治本滴,要解决,还是要从技术上彻底解决嘛!你们说,是不是啊?
小猿1(笑嘻嘻):对对对!老大您说的太对了!
小猿2(小声嘟囔):完犊子,这行算是混不下去了!
彪哥(义正辞严):再说了,我们的语言是高大上的语言,不是什么人上几个月的培训班就能学会的。我要做到的是,当其它语言的培训广告烂大街的时候,关于C语言的培训一个都没有!凭什么我发明语言,却让这帮人拿来挣钱啊!(像是忽然想起什么)哎哎哎,你看我,一不留神又跑题了!那个,我就先说这么多,接下来,你们双方有什么意见,都说一说,说一说嘛!
第二幕 各抒己见
小编3(满脸深沉):老大,您这个“随便”信息量有点大啊!按照您的意思,对于未定义行为,我可以给出警告或者直接报错;也可以睁只眼闭只眼,置之不理;还可以关机、格盘、删库、下病毒喽?随便嘛!
彪哥(笃定地):对!理论上确实如此!(用下巴指指小猿们)如果你不怕被他们打死的话!
小编3(看了眼对面杀气腾腾的小猿们,满脸赔笑):我就说说,说说而已!
小编4(鄙夷地看了小编3一眼,又满脸谄媚地看着彪哥):作为一个有担当的编译器,理应为彪哥和程序员朋友们分忧!对于某些未定义行为,我们也可以给出自己的定义嘛!比如,我们可以指定一个编译选项 -fwrapv ,使得编译器在任何情况下(无论是否启用优化或启用何种级别的优化),对有符号整数的溢出做wrap around处理4。对吧,彪哥?
彪哥(赞许地看着小编4):对!都说了,对于未定义行为,你们要怎么搞,我不管。你们完全可以提供自己的实现,将某些未定义行为变成已定义行为嘛!5。
小编1(鼓掌):undefined behavior,wonderful!老大,你这么一搞哇,可给我们编译器减轻了不少的工作。就拿数组越界访问这件事来说吧,如果在编译期间检测数组越界,那肯定得做额外的工作,这样一来,效率必然是会受到影响的嘛!现在好了,老大你把数组越界访问定义成undefined behavior,那我们就可以对越界访问这件事置之不理了,换句话说,我们默认所有访问都是有效的、安全的,直接生成汇编代码并最终生成可执行文件,至于执行时发生什么,那就不关我们的事了!再说了,安全访问数组元素,本就应该由程序员自己来保障嘛!
小猿4(阴阳怪气):幺~,真是甩得一手好锅呀!
小编2(急切地):我……我说,美……美女,话不……不能这么说呀!你……你比如,下……下面这段代码:
1 #include <stdio.h> 2 3 int main() 4 { 5 int arr[] = {1, 2, 3, 4, 5}; 6 int idx = 0; 7 scanf("%d", &idx); 8 printf("%d\n", arr[idx]); 9 return 0; 10 }
索引是……是运行时由用户输入的,你让我们怎……么在编译期检测,(两手一摊)臣妾做不到啊!相……相反地,你们程序员加一个对索引的条……条件判断,却是易……易如反掌的事!你说我说得对吗,美……美女?
小编4(得意地):未定义行为,要得!这给我们进行编译优化提供了更大的空间和更多的可能性。比如,下面这段代码:
1 #include <stdio.h> 2 #include <limits.h> 3 4 int foo(int x) 5 { 6 return x + 1 > x; 7 } 8 9 int main() 10 { 11 int res = foo(INT_MAX); 12 printf("%d\n", res); 13 return 0; 14 }
如果不启用优化(即使用-O0选项), foo 函数对应的汇编代码是这样的:
图1 -O0下foo函数的汇编代码
并且输出结果是06。
然而,如果使用O2级别优化, foo 函数对应的汇编代码是这样的:
图2 -O2下foo函数的汇编代码
显然,输出结果是17。
小编4(继续口沫横飞):从数学,或者自然科学的角度来讲,一个数加上1,一定比之前大。然而,在现代计算机中,有符号整数通常用2进制补码(2's complement)表示,同时,存储一个整数的bit位也是有限的(如32bit),于是,就出现了 INT_MAX + 1 = INT_MIN 这种奇葩结果(前提是采用wrap around规则)。对于 return x + 1 > x; ,如果为了照顾 INT_MAX + 1 = INT_MIN 这唯一的特例,就不得不生成图1所示的一大堆汇编代码。反之,如果我们不考虑这一特例,即不考虑有符号整数的溢出,那 x + 1 > x 就永远是成立的,于是,就可以直接生成图2所示的汇编代码,从而最终提升程序的执行效率。但是,能够这样优化的大前提是,标准允许我们对溢出的情况置之不理。幸运的是,彪哥将有符号整数的溢出定义为undefined behavior,这相当于给了我们全权处置权,这才使以上优化成为可能,这充分体现出了未定义行为的好处!
小猿4(厉声道):你这是狡辩!现实中谁会写 x + 1 > x 这样的不等式!
小编4(不慌不忙):好,那我就来个不狡辩的。(笑嘻嘻地冲小猿4)美女,你猜下面的代码段在-O2选项下会生成怎样的汇编代码?
1 int fun(int i) 2 { 3 int j, k = 0; 4 for (j = i; j < i + 10; ++j) 5 { 6 ++k; 7 } 8 return k; 9 }
小编4(得意洋洋):怎么样?猜不出来吧?美女,请上眼!
图3 -O2下fun函数的汇编代码
小编4(摇头晃脑,手舞足蹈):怎么样?惊不惊喜?意不意外?
小猿3(左手手指张开放在嘴上做惊讶状):我的天呐!我们写的代码,让你们霍霍成啥样了!
小编3(表情鄙夷,全身嘚瑟):什么叫霍霍呀,是优化,优化懂不懂!
小猿4(疾言厉色):你这还是狡辩,循环次数跟 i 毫无关系,我直接写 int k = 10; 不好吗?什么烂代码!
小编4(故做严肃状):你也知道这是烂代码啊!即使你们程序员写出了屎一样的代码,我们编译器也能把它优化得如春风般简洁清新,而这,正是undefined behavior存在的意义!
小猿4(气急败坏地指向小编4):你……你!
小猿2(冲小猿4和小编4做了个稍安勿躁的手势):那个,两位,不要着急,消消气,请让我来说两句。那个,要我说呀,这undefined behavior确实能带来一定的好处,但不可否认,它也造成了一定的负面影响。首先,它大大增加了程序的调试难度,比如说,最开始老大提到的 p[0] = 'y'; ,这句试图修改只读数据区,但编译器没有任何警告,直到运行时,程序崩溃。对于没有经验的程序员来说,他可能根本意识不到究竟是哪里出问题了!其次,未定义行为可能会带来安全隐患,比如说,黑客可能会利用数组越界访问执行恶意代码。所以啊,要我说,这undefined behavior,我们还是不要搞了。我建议,对所有行为,我们都应该给出明确的定义,至于怎么定义,我们大家可以群策群力呀。大家说,是不是啊?
小编2(指手画脚):老……弟,你前边说的,都……都对!但……是,无论是修改字符串字面值还……还是数组越界访问,都是不应该的,是逻辑错误的,这……这些,都应该由……你……你们程序员努力避免,你不……不能为了自己编码和调试方便,就……增加我们的工作量、降低语言的效率吧!这……事不该我……我们负责啊!
小猿4(咬牙切齿):我说你们除了甩锅还会什么!我们不是不尽力,可我们再怎么尽力保障代码的正确性和有效性,也不可能做到万无一失啊!从编译层面进行规范和检测,才是根本上的正解啊!所以,我建议,对于undefined behavior,直接废除!
小编4(针锋相对):不行,要坚决落实!
小猿2、3、4(大声):直接废除!
小编1、2、3、4(亦大声):坚决落实!
(双方炒作一团。)
彪哥(大喝一声):停!别吵吵了!(语气缓和下来)这样吧,我们举手表决,支持坚决落实的举左手,支持直接废除的举右手!开始表决!
(小编们齐刷刷地举起了左手,小猿2、3、4齐刷刷地举起了右手,小猿1在同伴的怒视下,心不甘情不愿地将刚举起的左手放下,缓缓举起了右手。)
彪哥(看看左右):还真是泾渭分明啊!下面,轮到我表态了。(说着,举起了左手)好了,5:4,坚决落实派以微弱优势胜出!坚决落实undefined behavior!少数服从多数,任何人不得反对!
第三幕 会议纪要
彪哥(又将语气放缓和了些):那个,既然都已经做出决定了,我们的会议差不多也要结束了。下面,让我们一起总结一下这次会议的要点。那个,我先说。
彪哥(正色道):
- 第一,我们的C语言,是有国际标准的,标准,是语言的蓝图,也是灵魂和基石;编译器,是语言的实现,理论上,对于标准中任何明确规定的条款,都应该落实,否则,就不是C编译器;C程序,也就是.c文件和.h文件,是C语言的应用。这一点,是正确理解undefined behavior的大前提。
- 第二,undefined behavior是从标准的角度而言的。所谓“未定义”,是指对那些“错误”的行为(编码),标准没有说明如何处理,也没有做任何的规定。
彪哥(看看左右两边):那个,我就说这么多。下面,你们双方也各派一名代表,发表一下会议总结吧!
小编4(站起来,看着手中的笔记本,严肃地道):
- 第一,所谓未定义行为,是指对于程序员写的某些在常理上或逻辑上存在错误,并且很容易在执行时出错(如崩溃、结果不符合预期、或是引发其它更加严重、出乎意料的结果)的代码,标准并未做任何规定说该怎样处理,因为这些代码本来就是没有任何意义的。
- 第二,既然标准未做任何规定,也就是说没有对我们编译器做任何约束,那我们编译器就可以自(wei)由(suo)发(yu)挥(wei)了。对于未定义行为,我们可以给出自己的定义,如报错、警告、或是其它特定的处理方式;也可以置之不理,完全无视;当然,理论上也可以做其它任何事情,如关机、格盘、删库等等。
- 第三,对于未定义行为引发的任何后果,编译器概不负责。因为未定义行为本身就是不受法律(即标准)保护的。编译器的职责,只是为符合标准的“已定义行为”生成高效的代码(汇编及可执行文件)8。
- 第四,编译器之所以对某些未定义行为不做检测,主要有以下原因。首先,检测需要做额外的工作,某些情况下可能会明显降低编译乃至执行效率,因此,出于效率考虑,没有做检测。其次,确实无法检测,如数组元素索引来自运行时的用户输入或是传感器的实时输入等。最后,在某些情况下, 编译器不检测和处理未定义行为(即默认所有行为都是合理合法、有明确定义的),可以极大地优化程序,生成高效的可执行代码。
- 第五,未定义行为可以带来好处,主要是未定义行为允许编译器可以不检测和处理某些“错误”,减轻了编译负担,提升了编译效率,进一步地,为编译器优化代码提供了更大的自由和更多可能性。
- 第六,“未定义”并不完全等于“非确定性”,更不等于“随机”。事实上,许多编译器对许多特定的未定义行为都有自己固定的处理方式(注意,不作任何处理也是一种处理),只不过,由于标准未做任何规定和约束,因此,对于同一未定义行为,不同的编译器可能作出不同的处理,或者同一编译器在不同的系统(环境)下可能作出不同的处理。即,对于同一未定义行为,其结果可能随使用的编译器、运行的系统(环境)的不同而不同,但如果在同一条件下,使用同一编译器,其结果很有可能是确定的(如铁定编译时报错或铁定运行时崩溃)。当然,如果某未定义行为被编译器置之不理,那么,即使是在相同的条件下,也可能产生完全不可预测的结果。这,可能是对“未定义行为”最切合实际的描述。
小猿1(同样一脸严肃):
- 第一,未定义行为在某些情况下确实会带来速度的提升,但这也是以在一定程度上牺牲易用性和安全性为代价的,不可否认,未定义行为使得代码的调试和排错变得更加困难,也更容易产生安全漏洞。
- 第二,我们不得不承认,相比于编译器检测未定义行为,程序员通过在源代码中添加特定的语句(如条件判断、断言等)来避免未定义行为往往更加容易,但前提是程序员有足够的素养能够意识到哪些代码可能会出现未定义行为以及出现怎样的未定义行为。
- 第三,在程序员层面防范未定义行为的发生终究是治标不治本,从编译器层面检测和处理未定义行为,才是根本解决之道。
- 第四,有时候,特别是启用了优化选项的时候,编译器的工作可能与我们预想的大相径庭,对这一点,我们程序员应当时刻保持警醒。
- 第五,想要好好爱一个人,了解他/她的缺点比了解他/她的优点更重要。同样,想用好一种编程语言,了解它的缺点比了解它的优点更重要。
(听完小猿1发言中的最后一条,会议室里爆发出雷鸣般的掌声。)
彪哥(待大家安静下来,朗声道):好了,我宣布,关于C语言标准制定与实现的三方洽谈会第一次会议,圆满成功!(会议室里再次响起掌声)
彪哥(等会议室再度归于沉寂)同时,我宣布一下我们下一阶段的议题,那就是关于未指定行为(unspecified behavior)的讨论,会议时间另行通知。好了,散会!
(全剧终)
注:
1.本文的讨论主要是基于C的,对于某些未定义行为,C++的表现可能与C不同,但本文不详细讨论这些细节。
2.在Windows下,为.rdata section,在Linux下,为.rodata section,参考这里。
3.读者可以自行打印p和q的值,可以看到两者是相同的。同时,对于较高版本的gcc编译器(如gcc 5.1.0),如果 char *p = "hello"; 位于.cpp文件中,是会给出warning的,提示从字符串常量到char*的转换已经废弃。
4.事实上,GCC编译器就是这么做的。关于wrap around,参考这里。
5.出自《Rationale for International Standard Programming Languages C》,原文是“the implementor may augment the language by providing a definition of the officially undefined behavior.”,下载链接。
6.汇编代码通过在线编译器Compiler Explorer生成。
7.注意,对于更高版本的编译器,如gcc 11.1,即使指定 -O0 选项,也会进行优化,除非如前文所述,指定 -fwrapv 选项。
8.原文是“Remember, their main goal is to give you fast code that obeys the letter of the law”,参考这里。
结束语
在下才疏学浅,能力有限,文中难免有错误纰漏之处,如果您在阅读的过程中发现了本文的错误和不足,请您务必指出。您的批评指正就是在下前进的不竭动力!