谁动了我的const变量:记一次未定义行为引发的故障排查

谁动了我的const变量:记一次未定义行为引发的故障排查

对程序编译运行过程理解不到位,就可能在写程序是时候会遇到一些似乎很难调试的错误。 比如这一次咱就在深夜被传唤了。

事情是这样的

诡异的错误

深夜23点57分32秒,学弟忽然丢来了一张程序运行截图

我觉得高低是缺乏睡眠了 这个数组的第一项0×12怎么会莫名其妙地变成0×30

随后把程序代码也发了过来

#include <stdio.h>

int getH4(char ch)
{
    ch = ch & 0xf0;
    ch >>= 4;
    return (int) ch;
}

int getL4(char ch)
{
    ch = ch & 0x0f;
    return (int) ch;
}

char hex_table[16] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

int main(int argc, char* argv[])
{
    int len = 5;
    const char  src_str[5] = {0x12, 0x13, 0x15, 0x19, 0x00};
    char  dst_str[9];
    int i, j;

    for(i = 0, j = 0; i < len; i++){
        dst_str[j] = hex_table[ getH4(src_str[i]) ];
        j = j + 1;
        dst_str[j] = hex_table[ getL4(src_str[i]) ];
        j = j + 1;
    }

    for(i = 0; i < len; i++){
        printf("%02X ", src_str[i]);
    }
    printf("\n");

    printf("%s\n", dst_str);

    printf("%02X%02X%02X%02X%02X\n", src_str[0], src_str[1], src_str[2], src_str[3], src_str[4]);

    return 0;
}

顺带运行的截图:

$ gcc -o test test.c
$ ./test
30 13 15 19 00 
1213151900
3013151900

看起来好像确实很诡异的错误,但是咱大致扫了一眼觉得是个很容易判断的错误,就直接睡大觉去了。

结果一大早10:17:25,学弟又丢来了一张截图。 这次他换了另一个版本的gcc,结果出现了同样的运行结果。 咱觉得这次连编译器都换了,估计会意识到这是代码中的bug吧,而且周二一整天的会就先把他放着了。

晚上17:30:07,学弟说他怀疑这是gcc的一个bug,他甚至用gdb去调试结果发现结果途中变了。

实在是看不下去了。

DEBUG

这么简单的问题,还需要DEBUG么?

程序分析

让我们来分析一下这个程序

#include <stdio.h>

常规操作,没啥好说的

int getH4(char ch)
{
	ch = ch & 0xf0;
	ch >>= 4;
	return (int) ch;
}

int getL4(char ch)
{
	ch = ch & 0x0f;
	return (int) ch;
}

正如函数名所示,这两个函数是用来获取一个字节的高4位和低4位。

char hex_table[16] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

这个数组是用来存储16进制字符表,看起来接下来会是一个16进制转换的函数。

int main(int argc, char* argv[])
{
	int len = 5;
	const char  src_str[5] = {0x12, 0x13, 0x15, 0x19, 0x00};
	char  dst_str[9];
	int i, j;

终于到了main函数。 首先定义了一个len并指定初始化为5,看起来是什么东西的长度 然后是源数组,看起来len就是这个数组的长度。 还专门声明了这个数组是const,估计是很怕被修改吧。 然后是目标数组,长度为9,看起来是16进制转换后的字符串。 最后是ij两个变量,大概率是用来循环的。

	for(i = 0, j = 0; i < len; i++){
		dst_str[j] = hex_table[ getH4(src_str[i]) ];
		j = j + 1;
		dst_str[j] = hex_table[ getL4(src_str[i]) ];
		j = j + 1;
	}

继续往下终于看到了这个期待已久的循环体。 看起来中规中矩,从原数组分别读取高低4位并查表转换成16进制字符,然后存入目标数组。

	for(i = 0; i < len; i++){
		printf("%02X ", src_str[i]);
	}
	printf("\n");

	printf("%s\n", dst_str);

	printf("%02X%02X%02X%02X%02X\n", src_str[0], src_str[1], src_str[2], src_str[3], src_str[4]);

中规中矩的输出语句。

	return 0;
}

正常的返回语句。

这么简单的程序,看起来都不像是会出问题的样子。 但是真的就没问题么?

问题分析:const真的不可变么?

根据受害人描述,这个const数组中的值莫名其妙就被改变了。 受害人认为被标记为const的数组是不可变的,所以这个问题就显得很诡异。

但是const真的是不可变的含义么?

让我们打开C语言语法规范。

6.5.3 Type qualifiers

Syntax

type-qualifier := const | volatile

Constraints

The same type qualifier shall not appear more than once in the same specifier list or qualifier list, either directly or via one or more typedefs.

Semantics

The properties associated with qualified types are meaningful only for expressions that are lvalues. If an attempt is made to modify an object defined with a const-qualified type through use of an lvalue with non-const-qualified type, the behavior is undefined. If an attempt is made to refer to an object defined with a volatile-qualified type through use of an lvalue with non-volatilequalified type, the behavior is undefined. An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine. as described in 5.1.2.3. Furthermore. at every sequence point the value last stored in the object shall agree with that prescribed by the abstract machine, except as modified by the unknown factors mentioned previously. What constitutes an access to an object that has volatile-qualified type is implementation-defined. If the specification of an array type includes any type qualifiers, the element type is soqualified, not the array type. If the specification of a function type includes any type qualifiers, the behavior is undefined. For two qualified types to be compatible. both shall have the identically qualified version of a compatible type: the order of type qualifiers within a list of specifiers or qualifiers does not affect the specified type.

6.5.3 类型限定符

语法

type-qualifier := const | volatile

约束 在同一说明符列表或限定符列表中,无论是直接还是通过一个或多个typedef,相同的类型限定符不得出现多次。

语义 带有限定类型的属性仅对左值表达式有意义。 如果尝试通过非const限定类型的左值引用来修改具有const限定类型的对象,则行为是未定义的。如果尝试通过非volatile限定类型的左值引用来引用具有volatile限定类型的对象,则行为是未定义的。 具有volatile限定类型的对象可能以实现未知的方式被修改或具有其他未知的副作用。因此,对这种对象的任何引用表达式都必须严格按照抽象机器的规则进行评估,如5.1.2.3中所述。此外,在每个序列点上,对象的最后存储的值必须与抽象机器所规定的值一致,除非被前述未知因素修改。对具有volatile限定类型的对象的访问是由实现定义的。 如果数组类型的规范包括任何类型限定符,则元素类型被限定,而不是数组类型。如果函数类型的规范包括任何类型限定符,则行为是未定义的。 要使两个限定类型兼容,两者必须具有兼容类型的相同限定版本:在说明符或限定符列表中的类型限定符的顺序不会影响指定的类型。

staticextern不同,constvolatile并非是表示存储类型的关键字,而是类型限定符。 语义上,const限定符的作用是表示该变量不应当被程序修改,而volatile限定符的作用是表示该变量可能会被未知的方式修改。

同时语法标准中还给出了一个例子:

extern const volatile int real_time_clock

这个声明中同时出现了constvolatile,但是二者并非矛盾。 const表示该变量不应当被程序修改(不会被放到等号左边),而volatile表示该变量可能会被未知的方式修改。 换句话说,这个变量可能会被外部设备修改,但是程序不应当修改它。

回到这个受害者的问题中,确实他将数组声明成了const char,但这仅仅意味着程序中不会将此变量放在等号左边或通过自增自减运算符来修改。 但同时语法规范中也有说到

尝试通过非const限定类型的左值引用来修改具有const限定类型的对象,则行为是未定义的

换句话说就是,如果有一个变量(指针)引用了这个const变量所在的地址,它是有可能被覆盖写入的。

顺着这个思路,应该是程序中出现了某个指针指向了这个const变量的内存地址,并且修改了它的内容。 所以我们只要找到这个指针,就可以…

为什么感觉程序里好像没有指针🤔

柳暗花明:数组的越界访问

看了一圈,好像这个程序中并没有一个指向这个const变量的指针? 而且即使有,也大概率会在一开始就给出警告才对。

思路在这里似乎一下子就断了。

回过头来找找别的线索,为什么变成了0x30? 这个数字又有什么意义呢?

查询ASCII表可知,这个数字是0这个字符对应的ASCII编码。 所以我们有什么时候写入了这个字符0么?

等一下,我们生成的目标字符串中似乎包括了0这个字符。

顺着这个思路,好巧C语言中字符串和指针有着说不清到不明的关系。 那么就让我们来看看写入这个目标字符串的代码吧。

for (i = 0, j = 0; i < len; i++) {
	dst_str[j] = hex_table[getH4(src_str[i])];
	j = j + 1;
	dst_str[j] = hex_table[getL4(src_str[i])];
	j = j + 1;
}

乍一看好像没有什么问题,i从0到lenj从0开始,每次自增2,循环五次之后应该是自增到9 而回头看一下dst的定义

char dst_str[9];

看起来是没……有……问题……

等一下,这个数组的长度可是9,但是我们从dst_str[0]一直访问到了dst_str[9],这不是写了十个元素么? 但是这个dst又何德何能,它是如何写到了src_str[0]的?

让我们输出一下这些局部变量的地址吧

变量名 地址 大小
len 0x7fff5bad2c9c 4
src_str 0x7fff5bad2c97 5
dst_str 0x7fff5bad2c8e 9
i 0x7fff5bad2c88 4
j 0x7fff5bad2c84 4
argc 0x7fff5bad2c7c 4
argv 0x7fff5bad2c70 8

我们可以看到,src的地址高于dst的地址。 因此当我们访问到dst_str[9]的时候,实际上我们访问到了src_str[0]的地址。

而这就是这次错误的原因。

问题总结

这是一个由数组越界访问而导致的错误。 由于所使用的编译器将src_str的地址放在了dst_str的后面,因此写入dst_str[9]的时候,也就是写入了src_str[0]的地址。

至此,本案件告破。

顺便嘴贫一句,其实这个程序中仍然存在其他越界访问错误,但是更加隐蔽一些。

又嘴贫一句,回头把这位同志的代码拿给研究室的另外几个学弟让他们调试,居然也全员阵亡了。