目录
1、多线程问题实例
2、理解该多线程问题的预备知识
2.1、二进制机器码和汇编代码
2.2、多线程切换与CPU时间片
2.3、多线程创建与线程函数
3、从汇编代码的角度去理解多线程问题
4、问题解决办法
5、熟悉汇编代码有哪些用处?
5.1、在代码中插入汇编代码块,提升代码的执行效率
5.2、在分析C++软件异常时可能需要查看汇编代码
5.3、从汇编代码的角度可以理解很多高级语言没法理解的编程细节
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931 以前经常说,C++开发人员要了解汇编,了解汇编后有很多好处,今天正好得空写一篇汇编相关的文章。熟悉汇编,不仅可以辅助排查C++软件异常问题,还可以理解很多高级语言不好理解的编程细节(从汇编代码的角度可以看到代码的完整执行细节),特别是多线程编程中的问题。下面我们就来讲解一个典型的从汇编代码去理解多线程问题的实例,然后详细总结一下熟悉汇编代码的用处。
《Windows核心编程》一书中,在第八章讲到多线程同步时,有个多线程操作一个全局变量的实例。这个实例是通过汇编代码去理解多线程执行细节的一个很典型的例子。
该例子中定义了一个long型的全局变量,然后创建了两个线程,线程函数分别是ThreadFunc1和ThreadFunc2,这两个线程函数中均对g_x变量进行自加操作(在访问共享变量g_x时未加锁同步),相关代码如下:
// define a global variable
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam)
{
g_x++;
return 0;
}
DWORD WINAPI ThreadFunc2(PVOID pvParam)
{
g_x++;
return 0;
}
这里有个问题,当这两个线程函数执行完后,全局变量g_x的值会是多少呢?一定会是2吗?
实际上,在两个线程函数执行完后,g_x的值不一定为2。这个实例需要从汇编代码的角度去理解,从C++源码看则很难搞懂,这是一个从汇编代码角度去理解代码执行细节的典型实例。
要理解上述多线程问题,需要了解一些基础知识,下面我们就来看看这些知识。
C++代码经过编译链接后生成的是二进制机器代码,存放在二进制文件中。在CPU中最终执行的是一条一条二进制机器码。汇编代码和二进制机器代码是等价的,汇编代码可读性比较强,所以我们一般去看汇编代码。一条汇编指令是CPU中执行的最小粒度。
线程是系统调度CPU时间片的最小单元,当线程失去CPU时间片时,会将当前线程的上下文信息保存到CONTEXT结构体中(这其中就包含各个寄存器的值),线程暂停执行。线程上下文结构体CONTEXT的定义类似如下:(该结构体定义位于winnt.h头文件中)
//
// Context Frame
//
// This frame has a several purposes: 1) it is used as an argument to
// NtContinue, 2) is is used to constuct a call frame for APC delivery,
// and 3) it is used in the user level thread creation routines.
//
// The layout of the record conforms to a standard call frame.
//
typedef struct _CONTEXT {
//
// The flags values within this flag control the contents of
// a CONTEXT record.
//
// If the context record is used as an input parameter, then
// for each portion of the context record controlled by a flag
// whose value is set, it is assumed that that portion of the
// context record contains valid context. If the context record
// is being used to modify a threads context, then only that
// portion of the threads context will be modified.
//
// If the context record is used as an IN OUT parameter to capture
// the context of a thread, then only those portions of the thread's
// context corresponding to set flags will be returned.
//
// The context record is never used as an OUT only parameter.
//
DWORD ContextFlags;
//
// This section is specified/returned if CONTEXT_DEBUG_REGISTERS is
// set in ContextFlags. Note that CONTEXT_DEBUG_REGISTERS is NOT
// included in CONTEXT_FULL.
//
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_FLOATING_POINT.
//
FLOATING_SAVE_AREA FloatSave;
//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_SEGMENTS.
//
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_INTEGER.
//
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
//
// This section is specified/returned if the
// ContextFlags word contians the flag CONTEXT_CONTROL.
//
DWORD Ebp;
DWORD Eip;
DWORD SegCs; // MUST BE SANITIZED
DWORD EFlags; // MUST BE SANITIZED
DWORD Esp;
DWORD SegSs;
//
// This section is specified/returned if the ContextFlags word
// contains the flag CONTEXT_EXTENDED_REGISTERS.
// The format and contexts are processor specific
//
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
上述结构体的部分字段就对应着各个寄存器。
当该线程获取时间片时,会将之前保存的CONTEXT结构体中的线程上下文信息恢复到线程中(包括恢复各个寄存器的值),线程继续向下执行。
调用CreateThread或者__beginthreadex发起创建线程请求,线程创建起来后就会进入线程函数,但注意CreateThread或者__beginthreadex返回时不代表已经运行到线程函数中了(这个问题我们在排查C++软件异常崩溃时领教过)。线程函数执行完,线程函数退出后,线程也就结束了。
对g_x变量的自加这句C++代码,对应的汇编代码如下:
MOV EAX, [g_x] // 将g_x变量的值读到EAX寄存器中
INC EAX // 将EAX中的值执行自加操作
MOV [g_x], EAX // 然后将EAX中的值设置到g_x变量内存中
看C++代码:g_x++,有些人可能觉得就一个自加操作,应该执行很快的,中间似乎不会被打断。会不会被打断,其实要看汇编代码的,这行C++源码对应三行汇编代码,只能保证CPU执行某条汇编指令时不会被打断(汇编指令是CPU执行的最小粒度),但3条汇编指令,指令与指令之间是可能被打断的。
为什么说两个线程执行完成后g_x变量的值是不确定的呢?比如可能存在两种场景:
1)场景1(最终结果g_x=2)
假设线程1先快速执行了三行汇编指令,未被打断,g_x的值变成1。然后紧接着线程2执行,在g_x=1的基础上累加,最终两个线程执行完后,g_x等于2。
2)场景2(最终结果g_x=1)
假设线程1先执行,当执行完前两条汇编指令后,线程1失去时间片(线程上下文信息保存到CONTEXT结构体中):
即线程1前两条汇编指令执行完,第3条汇编指令还没来得及执行,就失去CPU时间片了!
线程2执行,一次执行完三条指令,当前g_x=1。然后线程1获得CPU时间片,因为上次执行两条汇编指令后EAX寄存器中的值为1,因为线程1获取了时间片,保存线程上下文信息的CONTEXT恢复到线程1中,EAX=1,继续执行第3条指令,执行完后g_x还是1。
所以,这个多线程问题,需要从汇编代码的角度去理解,从C++源码的角度很难想明白。
从本例可以看出,即使是简单的变量自加操作,多线程操作时也要做同步,可以加锁,可以使用系统的原子锁Interlocked系列函数,比如原子自加函数InterlockedIncrement和原子自减函数InterlockedDecrement:
LONG InterlockedIncrement(
LPLONG volatile lpAddend // variable to increment
);
LONG InterlockedDecrement(
LPLONG volatile lpAddend // variable address
);
这些原子函数能保证会被原子地被执行,中间不会被打断。 修改后的代码为:
// define a global variable
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam)
{
InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加
return 0;
}
DWORD WINAPI ThreadFunc2(PVOID pvParam)
{
InterlockedIncrement(&g_x); // 调用原子锁函数InterlockedIncrement实现自加
return 0;
}
汇编代码是底层语言,从事高级语言开发的开发人员在编码过程中没有接触过,但熟悉汇编代码有很多好处。
一般我们会在一些对执行效率要求比较高的代码中嵌入汇编代码,提高代码的执行效率,汇编代码的执行效率是最高的。比如我们在处理音视频编解码的算法代码中,时常会嵌入一些汇编代码,以提高代码的运行速度,比如音视频编解码模块负责色彩空间转换的接口都是汇编代码实现的(汇编代码实现的函数是开源的),如下:
有人可能会问,经过IDE编译出来的二进制文件中也都是汇编指令,你人为的添加一段汇编代码,都是汇编代码,为啥会有执行速度上的差别呢?因为源代码经过编译器的处理生成的汇编代码在实现上可能不是最优的,这要依赖编译器,而我们人为地添加汇编,可以直接控制汇编代码,保证汇编代码是最优的,不再依赖编译器。
一般程序是崩溃在某一条汇编指令上,汇编指令才能最直接反映为什么会发生崩溃。比如汇编指令中访问了一个不应该访问的地址,比如一个很小的内存地址(64K地址范围内的地址是小地址内存 区,禁止访问),如下:
或者一个很大的内核态地址(用户态的代码不能访问内核态的地址),如下:
都会引发崩溃。此外,有时分析崩溃时,需要查看汇编代码上下文去辅助分析。
排查的C++软件异常与崩溃问题多了,就会知道汇编代码的好了,才会有深刻的理解和体会的!
比如函数调用时主调函数是如何将参数传递给被调用函数的。比如,有如下的实现两个整型变量相加的函数AddSum,在调用该函数时传入的参数代码如下:
int AddNum( int a, int b)
{
int nSum = a + b;
return nSum;
}
void CTestDlgDlg::OnBnClickedBtnTest()
{
int a = 3;
int b = 4;
int nSum = AddNum( a, b );
}
我们在调用AddSum接口是通过栈将要传递的参数传给被调用函数AddSum的,我们通过汇编代码可以清楚看到这一过程:(可以直接在Visual Studio中看到上述C++代码的汇编代码)
从上述汇编代码上我们清楚地看到,在call AddSum函数之前,把要传递的参数a和b都压到了栈上!
在Visual Studio中查看C++代码对应的汇编代码的方法:在要查看汇编代码的函数中添加断点,然后开启调试,在命中断点后,右键点击,在弹出的右键菜单中点击“查看反汇编”,即可查看汇编代码。
再比如,从汇编代码的角度可以很好地理解多线程编程中的细节问题,比如上面讲到的两个线程同时对一个long型变量进行自加操作的多线程问题。
还比如,之前讲到Switch...case语句中case分支过多引发Stack Overflow线程栈溢出问题时,虽然变量定义在case分支中,虽然生命周期在case分支中,但变量的栈内存在所在函数的入口处就已经分配了,可以通过汇编代码看出来。比如如下的case分支代码:
void TestCaseFunc()
{
int nType = 3;
switch(nType)
{
case 0:
{
//STARTUPINFO startInfo;
//memset(&startInfo, 0, sizeof(startInfo));
// ...
break;
}
case 1:
{
SHELLEXECUTEINFO shellexecuteInfo;
memset(&shellexecuteInfo, 0, sizeof(shellexecuteInfo));
// ...
break;
}
case 2:
{
STARTUPINFO startInfo2;
memset(&startInfo2, 0, sizeof(startInfo2));
// ...
break;
}
case 3:
{
SHELLEXECUTEINFO shellexecuteInfo2;
memset(&shellexecuteInfo2, 0, sizeof(shellexecuteInfo2));
// ...
break;
}
case 4:
{
STARTUPINFO startInfo4;
memset(&startInfo4, 0, sizeof(startInfo4));
// ...
break;
}
}
}
直接在Visual Studio中查看该函数的汇编代码:
可以新增或删减case分支,然后观察函数入口处分配的栈空间大小的变化!
在我们排查不出问题时,可以尝试着去查看汇编代码,甚至可以直接去调试汇编代码!