分类 技术志 下的文章

一个很有意思的Bug

这个Bug源于昨天凌晨的一次版本升级失败。睡了一大觉后,下午回到公司,重现了这个问题并找到了原因,发现这的确是一个'很有意思的Bug'。

系统在从数据库初始化过程中遇到了问题:在读取数据库数据时,提示ORA-24373错误。手册上对ORA-24373的解释是这样的:
ORA-24373: invalid length specified for statement
Cause: The length specified for the statement is either 0 or too large.
Action: Specify a valid length for the statement.

从错误的提示来看,不应该是数据的问题,但是出于对自己程序的信任,还是首先比对了数据,结果证明了数据是无误的;

看来一定是代码的问题了。首先定位到了返回错误的那个接口OCIStmtPrepare(原型: sword OCIStmtPrepare(OCIStmt *stmtp, OCIError *errhp, OraText *stmt, ub4 stmt_len, ub4 language, ub4 mode)),可是怎么看这块也不该出问题。但从ORA-24373的Cause来看,似乎是传
给OCIStmtPrepare的值不太对劲儿。

我们从数据库读取数据的一般流程:
1、获取符合某一特定条件的某一类数据的条数count;
2、读取count条数据From DB。

为了达到上面的两个目的,我们执行了两次sql操作,分别由两个语句完成,这里不妨成为sql1和sql2。sql1和sql2的内容是在代码里形成的。

其分为两部分:select的字段list和where条件部分;由于where条件部分是动态的,所以每次sql操作之前都是需要先生成sql1和sql2的。

保守的我们认为sql语句的长度是有限的,在我们的代码里我们用了一个宏XX_SQL_MAX_LEN来定义了一条语句内容的最长值,而XX_SQL_MAX_LEN

在我们的系统中被赋予512这个整数值。这样我们很容易得到如下的定义:
char sql1[XX_SQL_MAX_LEN]; //遗憾的是结尾''也被我们算进了XX_SQL_MAX_LEN中了。
char sql2[XX_SQL_MAX_LEN];

一般sql语句的前端的select字段list是固定的,其长度也就是固定的;但是后面的where语句中的条件则是由参数指定。在我们的系统里这个条件很简单,就只有一个索引值(index)。

让我们意想不到的是在我们要读取的那个数据表的操作语句中,select字段list的长度就超出我们预想,达到了508个字节,要知道目前我们还不知道这一事实。我们按照一般数据库操作流程:先读count,再读数据。结果呢?我们读出了若干条数据,然后就在某一条数据上出现了上述错误。这个数据没有什么特殊的,除了其index的值的位数变成了四位之外。

警觉的我们发现:系统在读取index是三位整数的记录时都是没有问题的,偏偏到index是四位整数时才会有问题,而问题偏偏又出在执行sql1上。直觉告诉我们又是内存问题。

分析如下:
sql1和sql2在栈上紧挨着,是否生成sql2的时候污染了sql1的数据呢?我们试图打印出sql1的值,结果发现printf的输出居然是空,这更坚定了内存遭受破坏的想法。遂掰手指头数sql2的长度,发现其长度居然恰好512字节。我们拿一个栈图来说明问题:


栈图

正如上图中所示:sql2的数据多的沿着数据的延伸方向一直越过了边界来到了sql1的境地,在sql1第一个字节上留下一个''就结束了。这就是为什么sql1打印为空的原因。同样由于传给OCIStmtPrepare的参数stmt_len的值为strlen(sql1),导致stmt_len变为0,也就恰好与ORA-24373这个错误码的说明一致了。这简直太巧了,太不可思议了。不多不少恰好512。

程序员的想当然造就了这一切,就好比千年虫一样,给你我带来麻烦。

'符号连接'那些事儿

我们在编译自己开发的程序或者一些开源软件的时候,常常遇到类似如下的编译器错误信息:
未定义 文件中的
符号 在文件中
i /var/tmp//ccU4sj6I.o
func /var/tmp//ccU4sj6I.o

ld: 致命的: 符号参照错误. 没有输出被写入a.out
collect2: ld returned 1 exit status

或"undefined reference to 'i' or undefined reference to 'func'"
或"error LNK2001: unresolved external symbol _func" (Visual C++编译器输出)

通过加入-v编译选项(GCC的编译选项),我们可以清晰的看到错误输出并非出自编译阶段(生成.o或.obj目标文件),而是产生于连接阶段,即将.o文件转换成最的可执行文件阶段。

GCC错误信息中用的是undefined reference,而VC用的则是unsesolved external symbol。感觉用"unresolved external symbol"更容易理解一些。连接阶段的symbol到底所指什么呢?我们看下面这段代码:
/* testsymbollink.c */
extern int myvar;
extern void myfunc(int a, int b);
 int main() {
  myvar = 7;
  myfunc(100, 200);
  return 0;
  }

我们通过gcc -S输出其汇编码:
/* testsymblolink.s */
.file "testsymbollink.c"

.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %o0
or %o0, %lo(myvar), %o1
mov 7, %o0
st %o0, [%o1]
mov 100, %o0
mov 200, %o1
call myfunc, 0
nop
mov 0, %o0
mov %o0, %i0
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.ident "GCC: (GNU) 3.2"

对于上述汇编码,我们一般理解是包含三个部分:
1) 描述型信息:如:.file、.section、.align、.type等,这些信息用于直到连接器正确的连接代码而使用的。
2) 汇编指令:如mov、st等。
3) 一些待resolve的符号:如main、myvar和myfunc。

连接器负责将.o目标代码进行处理并生成可执行文件。在连接器处理时,描述型信息告知连接器.o中的指令和数据的应该存放的位置属性信息;汇编指令则直接转成机器码;只有那些待resolve的符号需要连接器做慎重处理:main是默认的入口函数的符号,连接器默认会认识,其余的符号连接器就要在其输入的.o文件中或者指定连接的库(.a)中寻找符号的定义了,就如上面的main。如果是数据,则需要获取其位置和大小,如果是函数,则要获取其具体的实现了。

我们再举一个例子来对比一下:
int myvar = 0;
void myfunc(int a, int b) {
;
}

int main() {
myvar = 7;
myfunc(100, 200);
}

转换成汇编码为:
.file "testsymbollink1.c"
.global myvar
.section ".data"
.align 4
.type myvar,#object
.size myvar,4

myvar:
.long 0
.section ".text"
.align 4
.global myfunc
.type myfunc,#function
.proc 020

myfunc:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
st %i0, [%fp+68]
st %i1, [%fp+72]
nop
ret
restore

.LLfe1:
.size myfunc,.LLfe1-myfunc
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %o0
or %o0, %lo(myvar), %o1
mov 7, %o0
st %o0, [%o1]
mov 100, %o0
mov 200, %o1
call myfunc, 0
nop
mov %o0, %i0
nop
ret
restore

.LLfe2:
.size main,.LLfe2-main
.ident "GCC: (GNU) 3.2"
从上述汇编码我们可以看到,myvar和myfunc都给出定义,这样连接器工作的时候就不会因找不到这两个符号而报错了。符号的定义既可以在同一个.o中,也可以在不同的.o中,这样便于软件分层次、分模块开发。

对比上面两个example中myvar和myfunc的书写方式:
extern int myvar;
extern void myfunc(int a, int b);

int myvar = 0;
void myfunc(int a, int b) { … }
可以看出,变量和函数的声明和定义的方式直接会影响到其连接的属性。

那么在C语言中,声明和定义又有哪些事呢?我们下面道来^_^
在"C语言参考手册"的第四章作者给了'声明'一个诠释:"声明一个名称就是把一个标识符与某个C语言对象相关联",这句很是给人以启发。名称、标识符是什么呢?就是一个符号;C语言对象呢?对于数据对象来说,就是一块存储块;对于函数对象来说,就是函数的定义,当然这个定义也是要存储在TEXT SECTION的。真正将标识符和C语言对象相关联的工作是在连接阶段完成的。我们的C源代码需要给连接器足够的信息,以保证其正确无误的将每个标识符(符号)与对应的存储相关联。C语言中的声明恰恰给予连接器以有效帮助。

C语言提供了extern和static存储说明符来对应两种连接属性:外部连接(External linkage)和内部连接(Internal linkage)。在源程序顶层的声明中,内部与外部的连接属性区别在于该符号是否为多个翻译单元(translate unit)的所共享。顶层static修饰的符号只能在其所在翻译单元中寻找C语言对象;而顶层extern修饰的符号既可以在其所在的翻译单元寻找C语言对象,也可以在其他翻译单元中寻找。

//foo.c
extern int i;
static int j;
extern void e_func(int a);
static void s_func(void);

int main() {
e_func(1);
s_func();
i = 17;
j = 16;
}
对于变量i而言,连接程序必须在其他翻译单元中查找其相关联的对象;如果找不到,则报错;
对于变量j而言,连接程序在其所在翻译单元中寻找相关联的对象,与i不同的是,如果找不到,这个声明就会被转化为定义;这个对象的初值被置为0;
对于函数e_func而言,连接程序必须在其他翻译单元中查找其相关联的对象;如果找不到,则报错;
对于函数e_func而言,连接程序必须在其所在翻译单元中查找其相关联的对象;如果找不到,则报错。

我们在一些程序中经常看到在顶层声明的变量,既没有extern修饰,也没有static修饰,又不像变量定义那样给出初值,那么这样的变量是如何被对待的呢?我们看例子:
/* testsymbollink2.c */
int myvar;
int g_var = 13;
static int l_var = 19;

int main() {
myvar = 7;
}

翻译成汇编代码后:
.file "testsymbollink2.c"
.global g_var
.section ".data"
.align 4
.type g_var,#object
.size g_var,4

g_var:
.long 13
.align 4
.type l_var,#object
.size l_var,4

l_var:
.long 19
.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %i0
or %i0, %lo(myvar), %i1
mov 7, %i0
st %i0, [%i1]
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.common myvar,4,4
.ident "GCC: (GNU) 3.2"
可以看出来,myvar与g_var、l_var的不同,myvar并未有具体定义信息,而是用.common这个描述信息进行了描述。在C89中这个叫做:tentative definition,也就是"暂时定义"。对于这样的变量,如果连接时发现其他翻译单元中没有同名定义,则系统会给该变量"转正",分配空间;如果在其他翻译单元中有同名定义,则该符号就会关联到那个定义上去。
//1.c
int i;

int main() {
printf("%d\n", i);
}

//2.c
int i = 198;

则gcc 1.c 2.c后执行a.out的结果是输出198。1.c中的i已经关联到了2.c中的i了。如果只gcc 1.c,则输出为0,系统默认给i分配空间并初始化为0。

使用外部连接的变量声明是有风险的,因为编译器很难在多个翻译单元之间做一致性检查。比如:
//3.c
extern int *a;

int main() {
(*a) = 5;
}

//4.c
char a = 'c';

我们gcc 3.c 4.c进行编译并执行a.out,在sparc solaris上会出现"段错误 ((主存储器)信息转储)"的错误。为什么呢?我们还要回到'符号'上来,从汇编码分析:
.file "3.c"
.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(a), %i0
or %i0, %lo(a), %i0
ld [%i0], %i1
mov 6, %i0
st %i0, [%i1]
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.ident "GCC: (GNU) 3.2"

.file "4.c"
.global a
.section ".data"
.type a,#object
.size a,1

a:
.byte 99
.ident "GCC: (GNU) 3.2"

再重申:两个翻译单元中的a是通过符号形式联系在一起的。3.c中的符号a关联到了4.c中的a,而4.c中的a是一个char类型的变量,这点3.c并不知情,仍将它当作int*用,尝试将a的内容作为地址,去操作这个地址;由于a中的值是99,显然这不是一个应用层合法的地址,出core也就是必然的了。
同样对于函数也是如此,函数不过是一段指令集合,标识这个指令集合的也是'符号',不同翻译单元间也是靠符号关联在一起的。
//5.c
extern void func();

int main() {
func();
}

//6.c
void func(int a, int b) {
printf("%d\n", a + b);
}

我们通过gcc 5.c 6.c编译后,执行a.out,得到-13236124(不同环境得到的值不一样),这显然乱了套,func的调用者并没有给func传入参数,但是func并不知情,还是一味的通过%ebp在栈上定位两个参数后,将其相加输出,显然这两个值是随机的值,结果也是随机的。编译器显然对于检查func是否被正确调用显得束手无策。编译器唯一能做的就是在同一个翻译单元内部检查函数调用是否符合extern声明,所以要尽量使用原型声明,以保证在同一个翻译单元内函数调用的正确。

//7.c
extern void func(int a, char *p);

int main() {
func(5, 10); //warning: passing arg 2 of `func' makes pointer from integer without a cast
}

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats