• 大佬带你看嵌入式系统,嵌入式系统该学习什么?

    嵌入式系统是当今的热门系统之一,在诸多领域,嵌入式系统都有所应用。为增进大家对嵌入式系统的认识,小编将为大家介绍嵌入式系统是一个什么样的专业,以及学习嵌入式系统该学习哪些内容。如果你对嵌入式系统具有...

    06-27 107浏览
  • c51单片机编程要点总结

    c51单片机编程要点总结 1、头文件:#include (我用的是 STC 89C54RD+) 2、预定义:sbit LED = P1^0// 定义 P1 口的 0 位为 LED 注:“P1^0”这个写法,与 A51 不同(A51 是 P1.0),P1 是一组端口,端口号范围 0~7 注2...

    06-25 86浏览
  • Keil使用中的若干问题

      一、混合编程  1、模块内接口:  使用如下标志符:  #pragma asm  汇编语句  #pragma endasm  注意:如果在c51程序中使用了汇编语言,注意在keil编译器中需要激活Properties中的“Generate Assembler...

    06-23 65浏览
  • 嵌入式软件开发,用过长度为零的数组没

    众所周知,GNU/GCC在标准的C/C++基础上做了有实用性的扩展, 零长度数组(Arrays of Length Zero) 就是其中一个知名的扩展. 多数情况下, 其应用在变长数组中, 其定义如下: struct Packet { int state; int len; char cData[0]; //这里的0长结构体就为变长结构体提供了非常好的支持 }; 首先对 0 长度数组, 也叫柔性数组,做一个解释 : 用途 : 长度为0的数组的主要用途是为了满足需要变长度的结构体; 用法 : 在一个结构体的最后,声明一个长度为 0 的数组, 就可以使得这个结构体是可变长的. 对于编译器来说, 此时长度为 0 的数组并不占用空间, 因为数组名本身不占空间, 它只是一个偏移量, 数组名这个符号本身代表了一个不可修改的地址常量 (注意 : 数组名永远都不会是指针!), 但对于这个数组的大小, 我们可以进行动态分配。 注意 :如果结构体是通过calloc、malloc或 者new等动态分配方式生成,在不需要时要释放相应的空间。 优点 :比起在结构体中声明一个指针变量、再进行动态分 配的办法,这种方法效率要高。因为在访问数组内容时,不需要间接访问,避免了两次访存。 缺点 :在结构体中,数组为 0 的数组必须在最后声明,使用上有一定限制。 对于编译器而言, 数组名仅仅是一个符号, 它不会占用任何空间, 它在结构体中, 只是代表了一个偏移量, 代表一个不可修改的地址常量! 0 长度数组的用途: 我们设想这样一个场景, 我们在网络通信过程中使用的数据缓冲区, 缓冲区包括一个len字段和data字段, 分别标识数据的长度和传输的数据, 我们常见的有几种设计思路: 定长数据缓冲区, 设置一个足够大小MAX_LENGTH的数据缓冲区 设置一个指向实际数据的指针, 每次使用时, 按照数据的长度动态的开辟数据缓冲区的空间 我们从实际场景中应用的设计来考虑他们的优劣. 主要考虑的有, 缓冲区空间的开辟、释放和访问。 1、定长包(开辟空间, 释放, 访问): 比如我要发送 1024 字节的数据, 如果用定长包, 假设定长包的长度MAX_LENGTH为 2048, 就会浪费 1024 个字节的空间, 也会造成不必要的流量浪费: 数据结构定义: //  定长缓冲区 struct max_buffer { int len; char data[MAX_LENGTH]; }; 数据结构大小:考虑对齐, 那么数据结构的大小 >=sizeof(int) + sizeof(char) * MAX_LENGTH 由于考虑到数据的溢出, 变长数据包中的data数组长度一般会设置得足够长足以容纳最大的数据, 因此max_buffer中的 data 数组很多情况下都没有填满数据, 因此造成了浪费。 数据包的构造:假如我们要发送CURR_LENGTH = 1024个字节, 我们如何构造这个数据包呢;一般来说, 我们会返回一个指向缓冲区数据结构max_buffer的指针: //  开辟 if ((mbuffer = (struct max_buffer *)malloc(sizeof(struct max_buffer))) != NULL)     {   mbuffer->len = CURR_LENGTH; memcpy(mbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", mbuffer->len, mbuffer->data); } 访问:这段内存要分两部分使用;前部分 4 个字节p->len, 作为包头(就是多出来的那部分),这个包头是用来描述紧接着包头后面的数据部分的长度,这里是 1024, 所以前四个字节赋值为 1024 (既然我们要构造不定长数据包,那么这个包到底有多长呢,因此,我们就必须通过一个变量来表明这个数据包的长度,这就是len的作用);而紧接其后的内存是真正的数据部分, 通过p->data, 最后, 进行一个memcpy()内存拷贝, 把要发送的数据填入到这段内存当中 释放:那么当使用完毕释放数据的空间的时候, 直接释放就可以了 // 销毁 free(mbuffer); mbuffer = NULL; 2、小结: 使用定长数组, 作为数据缓冲区, 为了避免造成缓冲区溢出, 数组的大小一般设为足够的空间MAX_LENGTH, 而实际使用过程中, 达到MAX_LENGTH长度的数据很少, 那么多数情况下, 缓冲区的大部分空间都是浪费掉的 但是使用过程很简单, 数据空间的开辟和释放简单, 无需程序员考虑额外的操作 3、 指针数据包(开辟空间, 释放, 访问): 如果你将上面的长度为MAX_LENGTH的定长数组换为指针, 每次使用时动态的开辟CURR_LENGTH大小的空间, 那么就避免造成MAX_LENGTH - CURR_LENGTH空间的浪费, 只浪费了一个指针域的空间: 数据包定义: struct point_buffer { int len; char *data; }; 数据结构大小:考虑对齐, 那么数据结构的大小 >=sizeof(int) + sizeof(char *) 空间分配:但是也造成了使用在分配内存时,需采用两步 // ===================== // 指针数组  占用-开辟-销毁 // ===================== ///  占用 printf("the length of struct test3:%d\n",sizeof(struct point_buffer)); ///  开辟 if ((pbuffer = (struct point_buffer *)malloc(sizeof(struct point_buffer))) != NULL) {   pbuffer->len = CURR_LENGTH; if ((pbuffer->data = (char *)malloc(sizeof(char) * CURR_LENGTH)) != NULL)   { memcpy(pbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", pbuffer->len, pbuffer->data);   } } 首先, 需为结构体分配一块内存空间;其次再为结构体中的成员变量分配内存空间。 这样两次分配的内存是不连续的, 需要分别对其进行管理. 当使用长度为的数组时, 则是采用一次分配的原则, 一次性将所需的内存全部分配给它。 释放:相反, 释放时也是一样的: /// 销毁 free(pbuffer->data); free(pbuffer); pbuffer = NULL; 小结: - 使用指针结果作为缓冲区, 只多使用了一个指针大小的空间, 无需使用 MAX_LENGTH 长度的数组, 不会造成空间的大量浪费。 但那是开辟空间时, 需要额外开辟数据域的空间, 施放时候也需要显示释放数据域的空间, 但是实际使用过程中, 往往在函数中开辟空间, 然后返回给使用者指向struct point_buffer的指针, 这时候我们并不能假定使用者了解我们开辟的细节, 并按照约定的操作释放空间, 因此使用起来多有不便, 甚至造成内存泄漏。 4、变长数据缓冲区(开辟空间, 释放, 访问) 定长数组使用方便, 但是却浪费空间, 指针形式只多使用了一个指针的空间, 不会造成大量空间分浪费, 但是使用起来需要多次分配, 多次释放, 那么有没有一种实现方式能够既不浪费空间, 又使用方便的呢? GNU C的 0 长度数组, 也叫变长数组, 柔性数组就是这样一个扩展。对于 0 长数组的这个特点,很容易构造出变成结构体,如缓冲区,数据包等等: 数据结构定义: //  0长度数组 struct zero_buffer { int len; char data[0]; }; 数据结构大小:这样的变长数组常用于网络通信中构造不定长数据包, 不会浪费空间浪费网络流量, 因为char data[0];只是个数组名, 是不占用存储空间的: sizeof(struct zero_buffer) = sizeof(int) 开辟空间:那么我们使用的时候, 只需要开辟一次空间即可 ///  开辟 if ((zbuffer = (struct zero_buffer *)malloc(sizeof(struct zero_buffer) + sizeof(char) * CURR_LENGTH)) != NULL) {     zbuffer->len = CURR_LENGTH; memcpy(zbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", zbuffer->len, zbuffer->data); } 释放空间:释放空间也是一样的, 一次释放即可 ///  销毁 free(zbuffer); zbuffer = NULL; 总结: // zero_length_array.c #include #include #define MAX_LENGTH      1024 #define CURR_LENGTH      512 //  0长度数组 struct zero_buffer { int len; char data[0]; }__attribute((packed)); //  定长数组 struct max_buffer { int len; char data[MAX_LENGTH]; }__attribute((packed)); //  指针数组 struct point_buffer { int len; char *data; }__attribute((packed)); int main(void) { struct zero_buffer *zbuffer = NULL; struct max_buffer *mbuffer = NULL; struct point_buffer *pbuffer = NULL; // ===================== // 0长度数组  占用-开辟-销毁 // ===================== ///  占用 printf("the length of struct test1:%d\n",sizeof(struct zero_buffer)); ///  开辟 if ((zbuffer = (struct zero_buffer *)malloc(sizeof(struct zero_buffer) + sizeof(char) * CURR_LENGTH)) != NULL)   {       zbuffer->len = CURR_LENGTH; memcpy(zbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", zbuffer->len, zbuffer->data);   } ///  销毁 free(zbuffer);   zbuffer = NULL; // ===================== // 定长数组  占用-开辟-销毁 // ===================== ///  占用 printf("the length of struct test2:%d\n",sizeof(struct max_buffer)); ///  开辟 if ((mbuffer = (struct max_buffer *)malloc(sizeof(struct max_buffer))) != NULL)   {       mbuffer->len = CURR_LENGTH; memcpy(mbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", mbuffer->len, mbuffer->data);   } /// 销毁 free(mbuffer);   mbuffer = NULL; // ===================== // 指针数组  占用-开辟-销毁 // ===================== ///  占用 printf("the length of struct test3:%d\n",sizeof(struct point_buffer)); ///  开辟 if ((pbuffer = (struct point_buffer *)malloc(sizeof(struct point_buffer))) != NULL)   {       pbuffer->len = CURR_LENGTH; if ((pbuffer->data = (char *)malloc(sizeof(char) * CURR_LENGTH)) != NULL)     { memcpy(pbuffer->data, "Hello World", CURR_LENGTH); printf("%d, %s\n", pbuffer->len, pbuffer->data);     }   } /// 销毁 free(pbuffer->data); free(pbuffer);   pbuffer = NULL; return EXIT_SUCCESS; } 长度为 0 的数组并不占有内存空间, 而指针方式需要占用内存空间. 对于长度为 0 数组, 在申请内存空间时, 采用一次性分配的原则进行; 对于包含指针的结构体, 在申请空间时需分别进行, 释放时也需分别释放. 对于长度为 0 的数组的访问可采用数组方式进行 GNU Document中 变长数组的支持: 参考: 6.17 Arrays of Length Zero C Struct Hack – Structure with variable length array 在C90之前, 并不支持 0 长度的数组, 0 长度数组是GNU C的一个扩展, 因此早期的编译器中是无法通过编译的;对于GNU C增加的扩展,GCC提供了编译选项来明确的标识出他们: -pedantic选项,那么使用了扩展语法的地方将产生相应的警告信息 -Wall使用它能够使GCC产生尽可能多的警告信息 -Werror, 它要求GCC将所有的警告当成错误进行处理 // 1.c #include #include int main(void) { char a[0]; printf("%ld", sizeof(a)); return EXIT_SUCCESS; } 我们来编译: # 显示所有警告 gcc 1.c -Wall #none warning and error # 对GNU C的扩展显示警告 gcc 1.c -Wall -pedantic 1.c: In function ‘main’: 1.c:7: warning: ISO C forbids zero-size array ‘a’ # 显示所有警告同时GNU C的扩展显示警告, 将警告用 error 显示 gcc 1.c -Werror -Wall -pedantic cc1: warnings being treated as errors 1.c: In function ‘main’: 1.c:7: error: ISO C forbids zero-size array ‘a’ 0长度数组其实就是灵活地运用数组指向的是其后面连续的内存空间: struct buffer { int len; char data[0]; }; 在早期没引入 0 长度数组的时候, 大家是通过定长数组和指针的方式来解决的, 但是: 定长数组定义了一个足够大的缓冲区, 这样使用方便, 但是每次都造成空间的浪费 指针的方式, 要求程序员在释放空间时必须进行多次的free操作, 而我们在使用的过程中往往在函数中返回了指向缓冲区的指针, 我们并不能保证每个人都理解并遵从我们的释放方式。 所以GNU就对其进行了 0 长度数组的扩展. 当使用data[0]的时候, 也就是 0 长度数组的时候,0长度数组作为数组名, 并不占用存储空间。 在C99之后,也加了类似的扩展,只不过用的是char payload[]这种形式(所以如果你在编译的时候确实需要用到-pedantic参数,那么你可以将char payload[0]类型改成char payload[], 这样就可以编译通过了,当然你的编译器必须支持C99标准的,如果太古老的编译器,那可能不支持了) // 2.c payload #include # include  struct payload { int len; char data[]; }; int main(void) { struct payload pay; printf("%ld", sizeof(pay)); return EXIT_SUCCESS; } 使用-pedantic编译后, 不出现警告, 说明这种语法是 C 标准的 gcc 2.c -pedantic -std=c99 所以结构体的末尾, 就是指向了其后面的内存数据。因此我们可以很好的将该类型的结构体作为数据报文的头格式,并且最后一个成员变量,也就刚好是数据内容了. GNU 手册还提供了另外两个结构体来说明,更容易看懂意思: struct f1 { int x; int y[]; } f1 = { 1, { 2, 3, 4 } }; struct f2 { struct f1 f1; int data[3]; } f2 = { { 1 }, { 5, 6, 7 } }; 我把 f2 里面的 2,3,4 改成了 5,6,7 以示区分。如果你把数据打出来。即如下的信息: f1.x = 1 f1.y[0] = 2 f1.y[1] = 3 f1.y[2] = 4 也就是f1.y指向的是{2,3,4}这块内存中的数据。所以我们就可以轻易的得到,f2.f1.y指向的数据也就是正好f2.data的内容了。打印出来的数据: f2.f1.x = 1 f2.f1.y[0] = 5 f2.f1.y[1] = 6 f2.f1.y[2] = 7 如果你不是很确认其是否占用空间. 你可以用sizeof来计算一下。就可以知道sizeof(struct f1)=4,也就是int y[]其实是不占用空间的。但是这个 0 长度的数组,必须放在结构体的末尾。如果你没有把它放在末尾的话。编译的时候,会有如下的错误: main.c:37:9: error: flexible array member not at end of struct int y[];                             ^ 到这边,你可能会有疑问,如果将struct f1中的int y[]替换成int *y,又会是如何?这就涉及到数组和指针的问题了. 有时候吧,这两个是一样的,有时候又有区别。 首先要说明的是,支持 0 长度数组的扩展,重点在数组,也就是不能用int *y指针来替换。sizeof的长度就不一样了。把struct f1改成这样: struct f3 { int x; int *y; }; 在 32/64 位下, int 均是 4 个字节,sizeof(struct f1)=4,而sizeof(struct f3)=16 因为int *y是指针, 指针在 64 位下, 是 64 位的,sizeof(struct f3) = 16;如果在32位环境的话,sizeof(struct f3)则是 8 了,sizeof(struct f1)不变. 所以int *y是不能替代int y[]的; 代码如下: // 3.c #include #include struct f1 { int x; int y[]; } f1 = { 1, { 2, 3, 4 } }; struct f2 { struct f1 f1; int data[3]; } f2 = { { 1 }, { 5, 6, 7 } }; struct f3 { int x; int *y; }; int main(void) { printf("sizeof(f1) = %d\n", sizeof(struct f1)); printf("sizeof(f2) = %d\n", sizeof(struct f2)); printf("szieof(f3) = %d\n\n", sizeof(struct f3)); printf("f1.x = %d\n", f1.x); printf("f1.y[0] = %d\n", f1.y[0]); printf("f1.y[1] = %d\n", f1.y[1]); printf("f1.y[2] = %d\n", f1.y[2]); printf("f2.f1.x = %d\n", f1.x); printf("f2.f1.y[0] = %d\n", f2.f1.y[0]); printf("f2.f1.y[1] = %d\n", f2.f1.y[1]); printf("f2.f1.y[2] = %d\n", f2.f1.y[2]); return EXIT_SUCCESS; } 0 长度数组的其他特征: 1、为什么 0 长度数组不占用存储空间: 0 长度数组与指针实现有什么区别呢, 为什么0长度数组不占用存储空间呢? 其实本质上涉及到的是一个 C 语言里面的数组和指针的区别问题。char a[1]里面的a和char *b的b相同吗? 《 Programming Abstractions in C》(Roberts, E. S.,机械工业出版社,2004.6)82页里面说: “arr is defined to be identical to &arr[0]”. 也就是说,char a[1]里面的a实际是一个常量,等于&a[0]。而char *b是有一个实实在在的指针变量b存在。所以,a=b是不允许的,而b=a是允许的。两种变量都支持下标式的访问,那么对于a[0]和b[0]本质上是否有区别?我们可以通过一个例子来说明。 参见如下两个程序gdb_zero_length_array.c和gdb_zero_length_array.c: //  gdb_zero_length_array.c #include #include struct str { int len; char s[0]; }; struct foo { struct str *a; }; int main(void) { struct foo f = { NULL }; printf("sizeof(struct str) = %d\n", sizeof(struct str)); printf("before f.a->s.\n"); if(f.a->s)   { printf("before printf f.a->s.\n"); printf(f.a->s); printf("before printf f.a->s.\n");   } return EXIT_SUCCESS; } \ //  gdb_pzero_length_array.c #include # include  struct str { int len; char *s; }; struct foo { struct str *a; }; int main(void) { struct foo f = { NULL }; printf("sizeof(struct str) = %d\n", sizeof(struct str)); printf("before f.a->s.\n"); if (f.a->s)   { printf("before printf f.a->s.\n"); printf(f.a->s); printf("before printf f.a->s.\n");   } return EXIT_SUCCESS; } 可以看到这两个程序虽然都存在访问异常, 但是段错误的位置却不同 我们将两个程序编译成汇编, 然后diff查看他们的汇编代码有何不同 gcc -S gdb_zero_length_array.c -o gdb_test.s gcc -S gdb_pzero_length_array.c -o gdb_ptest diff gdb_test.s gdb_ptest.s 1c1 < .file "gdb_zero_length_array.c" --- >   .file "gdb_pzero_length_array.c" 23c23 < movl $4, %esi --- >   movl    $16, %esi 30c30 < addq $4, %rax --- >   movq 8(%rax), %rax 36c36 < addq $4, %rax --- >   movq 8(%rax), %rax # printf("sizeof(struct str) = %d\n", sizeof(struct str)); 23c23 < movl $4, %esi #printf("sizeof(struct str) = %d\n", sizeof(struct str)); --- >   movl    $16, %esi #printf("sizeof(struct str) = %d\n", sizeof(struct str)); 从 64 位系统中, 汇编我们看出, 变长数组结构的大小为 4, 而指针形式的结构大小为 16: f.a->s 30c30/36c36 < addq $4, %rax --- >   movq 8(%rax), %rax 可以看到有: 对于char s[0]来说, 汇编代码用了addq指令,addq $4, %rax 对于char *s来说,汇编代码用了movq指令,movq 8(%rax), %rax addq对%rax + sizeof(struct str), 即str结构的末尾即是char s[0]的地址, 这一步只是拿到了其地址, 而movq则是把地址里的内容放进去, 因此有时也被翻译为leap指令, 参见下一例子 从这里可以看到, 访问成员数组名其实得到的是数组的相对地址, 而访问成员指针其实是相对地址里的内容(这和访问其它非指针或数组的变量是一样的): 访问相对地址,程序不会crash,但是,访问一个非法的地址中的内容,程序就会crash。 // 4-1.c #include #include int main(void) { char *a; printf("%p\n", a); return EXIT_SUCCESS; } 4-2.c #include #include int main(void) { char a[0]; printf("%p\n", a); return EXIT_SUCCESS; } $ diff 4-1.s 4-2.s 1c1 < .file "4-1.c" --- >       .file "4-2.c" 13c13 < subl $16, %esp --- >       subl    $32, %esp 15c15 < leal 16(%esp), %eax --- >       movl 28(%esp), %eax 对于char a[0]来说, 汇编代码用了leal指令,leal 16(%esp), %eax: 对于char *a来说,汇编代码用了movl指令,movl 28(%esp), %eax 2、地址优化: // 5-1.c #include #include int main(void) { char a[0]; printf("%p\n", a); char b[0]; printf("%p\n", b); return EXIT_SUCCESS; } img 由于 0 长度数组是 GNU C 的扩展, 不被标准库任可, 那么一些巧妙编写的诡异代码, 其执行结果就是依赖于编译器和优化策略的实现的. 比如上面的代码, a 和 b 的地址就会被编译器优化到一处, 因为a[0]和b[0]对于程序来说是无法使用的, 这让我们想到了什么? 编译器对于相同字符串常量, 往往地址也是优化到一处, 减少空间占用: //  5-2.c #include #include int main(void) { const char *a = "Hello"; printf("%p\n", a); const char *b = "Hello"; printf("%p\n", b); const char c[] = "Hello"; printf("%p\n", c); return EXIT_SUCCESS; }

    05-14 109浏览
  • 为什么要基于UDS搞Bootloader

    01 为什么要基于UDS搞Bootloader 假如你的控制器有外壳,却没有设计bootloader的话,每次更新ECU的程序,你都需要把外壳拆开,用烧写器来更新程序。有了bootloader,你就可以通过CAN线来更新程序了。更方便些的话,甚至可以通过OTA进行远程升级。 那为什么使用UDS呢?主要是为了规范bootloader的全过程。比如烧写小明牌ECU时,我们肯定希望其他牌子的ECU处于一个静默的状态,都歇一歇,这就需要一个大家共同执行的标准来进行规范,什么时候停发数据,什么时候不能再储存DTC了等等。 又比如在调试时,大家肯定希望你的控制器经由CAN烧写的过程是大家都能看得懂的,是满足于某种规范的。由此,UDS在设计时考虑了bootloader的需求,专门为bootloader设计了几个服务,供大家使用。主机厂在发需求时自然就要求大家要在UDS规范的基础上完成bootloader功能了。 02 Bootloader应支持的UDS服务 显然bootloader不需要支持19/14等故障类服务。 在boot程序中,10/27/11/3E这样的基础诊断服务需要支持,22/2E读写DID的服务需要支持,31/34/36/37这4个bootloader主打服务需要支持,共10个。 在app段程序中,85和28服务需要支持,保证暂停CAN正常通信,暂停记录DTC,让被升级设备专心升级。 10种boot段服务+2种app段服务 03 测试设备在线Bootloader——三段式 (1)预编程阶段 1. 3E TP报文。 2. 10服务切换到03扩展模式。 3. 85服务和28服务,关DTC和非诊断报文。使整个CAN网络处于安静的状态。这是对整车网络进行操作的,一般都是以功能寻址的方式来发送。注意先用85服务关闭DTC,再使用28服务关报文。 (2)主编程阶段 1. 10服务切换到编程模式,这里要注意,正确的方式是App段程序回复0x78 NRC,接下来跳转到boot段程序,最后由Boot段程序来回复10 02的肯定响应。错误的方式是由App段回复10 02的肯定响应,再进行跳转。 2. 读取一个DID,tester要判断一下返回值。返回值里面可能包含密钥的一部分信息。 3. 27服务,解锁,通过安全验证。 注意10 02服务不应直接进行肯定响应,存在风险 4. 写DID指纹,标记写软件人的身份,ECU回复写指纹成功。(根据OEM要求来执行) 5. 31服务-擦除Flash。ECU肯定响应,擦除成功。 6. 34服务,请求数据下载,ECU回复确认最大块大小。 7. 36服务,开始传输数据。每个块传输完成后,ECU肯定响应。判断是否还有更多块需要下载。最多可以支持255个块。 8. 37服务,请求退出传输。ECU肯定响应。 9. 31服务-校验APP段程序,检查编程一致性/完整性。ECU肯定响应。校验成功。 10. 若有更多块需要下载,重新执行31(擦除Flash区域)-34-36-37-31(校验)服务。若无,往下执行。 11. 11服务,ECU复位。之后应直接跳转到新下载的APP段程序中。 31(擦Flash)-34-36 36-37-31(校验) (3)后编程状态 1. 10服务切换到03扩展会话。 2. 执行28服务和85服务,使能非诊断报文和DTC。这是对整车网络进行操作的,一般都是以功能寻址的方式来发送。注意先执行28,后执行85,避免DTC误报。 3. 27服务,安全校验,准备写入数据。 4. 2E服务,将编程信息写入到ECU中。 5. 10服务,退回01默认会话。结束。 04 BootLoader的启动顺序与转换流程 1. ECU上电或复位后,先进入Boot段。从Flash/EEPROM中读取 App有效标志,运行boot标志 。 2.判断 运行boot标志 ,若为1,则进入Boot段的编程会话(安全等级为上锁),之后写Flash/EEPROM(不安全操作), 运行boot标志 清零。若S3定时器超时则退回Boot段默认会话。 3. 经过安全访问进入Level2解锁状态,开始执行App内存擦除,擦除后 App有效标志 清零(不安全操作)。 4. 开始烧写。烧写成功后 运行boot标志 写0,App有效标志 写1。 2*. 判断 运行boot标志 ,若为0,则进入Boot段的默认会话。 3*. 50ms后判断 App有效标志 ,若为1,则 跳转到 App段默认会话。实现时使用汇编指令执行APP段程序;若为0,退回Boot段默认会话,且不再判断 App有效标志,不会再尝试进入App段。 4*. App段程序若收到了编程会话请求, 运行boot标志写1 ,随即执行ECU复位,这样会重新进入boot段程序。 注:从BOOT跳入APP前需要判断APP的数据完整性,例如进行CRC校验。

    04-09 191浏览
  • 动态数码显示设计:灵动数字的视觉盛宴

    1. 如图4.13.1所示,P0端口接动态数码管的字形码笔段,P2端口接动态数码管的数位选择端,P1.7接一个开关,当开关接高电平时,显示“12345”字样;当开关接低电平时,显示“HELLO”字样。 2. 电路原理图   图4.13.1 3. 系...

    02-27 193浏览
  • 多路开关状态指示设计:掌控电路的智能之眼

    1. 如图4.3.1所示,AT89S51单片机的P1.0-P1.3接四个发光二极管L1-L4,P1.4-P1.7接了四个开关K1-K4,编程将开关的状态反映到发光二极管上。(开关闭合,对应的灯亮,开关断开,对应的灯灭)。 2. 电路原理图   图4.3.1...

    02-27 178浏览
  • Linux C 中的内存屏障是什么?

    一、内存屏障 在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。 什么是内存屏障? 内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。 内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。 在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。 内存屏障的类型 Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。 1.全屏障(Full Barrier 或者 LFENCE、SFENCE): 作用:以下两种相加。 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。 内核函数:mb() (Memory Barrier) 2.读屏障(Read Barrier 或者 LFENCE): 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。 本质:作用是强制将 CPU核心 中的 L1/L2 缓存 中的共享变量值写回到 主内存。 用途:在执行并行读操作时确保读顺序。 内核函数:rmb() (Read Memory Barrier) 3.写屏障(Write Barrier 或者 SFENCE): 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。 用途:用于确保写操作顺序。 内核函数:wmb() (Write Memory Barrier) 4.无序屏障(No-op Barrier): 作用:没有实际影响,仅确保 CPU 不会重排序特定的指令。 用途:常用于确保指令的顺序性而不做其他强制性的内存同步。 读写屏障的作用域 读写屏障的作用域并不局限于当前函数或者某个函数调用的局部作用域,而是影响整个 当前线程的内存访问顺序。也就是说,只要在当前线程中,任何在屏障前后的内存操作都会受到屏障影响,而不管这些操作发生在同一个函数里还是不同的函数中。 线程之间的隔离 读写屏障是 线程级别的,因此它们只影响执行这些屏障操作的线程。也就是说,如果线程 1 执行了写屏障,它只会影响线程 1 后续的内存操作,而不会直接影响其他线程。---》翻译过来的,其实就是这个线程的读写屏障只会引发自己线程变量与主内存的同步,管不到其他线程的同步。但是写屏障触发后 会 通知其他线程,如果有现代 CPU 使用缓存一致性协议(如 MESI)的话,其他线程会把主内存中的最新值更新到自己缓存中。 读屏障不会触发其他线程去把自己的缓存同步到主内存中。 如果想让多个线程之间的共享变量同步并保持一致性,通常需要在多线程间使用某些同步机制(如锁、原子操作等),而不仅仅是依赖于单个线程的屏障。 具体来说: 写屏障(Write Barrier):会影响所有在屏障之前执行的写操作,无论这些写操作发生在当前函数内还是其他函数中。它确保屏障前的所有写操作都能同步到主内存,任何与此线程共享的缓存都能看到这些值。 读屏障(Read Barrier):会影响所有在屏障之后执行的读操作,确保这些读操作从主内存读取最新的值,而不是从 CPU 核心的缓存中读取过时的值。读屏障会影响当前线程的所有后续读取操作,无论这些读取发生在哪个函数中。 内存屏障的使用 在 Linux 中,内存屏障主要通过一组原子操作宏来提供。这些操作用于确保不同 CPU 或线程之间的内存同步。常见的内存屏障宏包括: mb():全屏障,防止 CPU 重排序所有内存操作。 rmb():读屏障,确保屏障之前的所有读操作完成。 wmb():写屏障,确保屏障之前的所有写操作完成。 示例代码 #include #include #define wmb() __asm__ __volatile__("sfence" ::: "memory") // 写屏障#define rmb() __asm__ __volatile__("lfence" ::: "memory") // 读屏障#define mb() __asm__ __volatile__("mfence" ::: "memory") // 全屏障 void example_memory_barrier() { int shared_variable = 0; // 写入数据 shared_variable = 42; // 在这里使用写屏障,确保共享变量的写操作 // 在执行屏障之后才会完成 wmb(); // 读取共享数据 printf("Shared Variable: %d\n", shared_variable); // 使用读屏障,确保屏障前的所有读取操作完成 rmb(); // 这里是确保顺序执行的一部分 printf("Shared Variable read again: %d\n", shared_variable);} int main() { example_memory_barrier(); return 0;} 为什么需要内存屏障? 避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。 保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。 同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。 缓存一致性协议(例如 MESI) 为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用缓存一致性协议(如MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。 但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。 Linux 内核中的内存屏障 在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_set、atomic_add 等原子操作通常会隐式地使用内存屏障来保证内存操作顺序。而直接的内存屏障通常通过 mb()、wmb() 和 rmb() 函数来实现。 总结 内存屏障在多核处理器和并发程序中非常重要,用于控制内存操作顺序,避免由于硬件优化或编译器优化引起的内存同步问题。Linux 提供了多种类型的内存屏障函数,程序员可以根据需要使用它们来确保内存操作的顺序性。 需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 二、变量存贮与内存屏障 你提到的问题涉及 程序执行过程中变量的存储位置、内存可见性和线程切换 的多个方面。为了更清晰地解释,我们需要从操作系统的内存管理和多线程模型入手。 1. 程序执行前变量存在哪里? 在程序执行之前,变量的存储位置主要依赖于变量的类型和生命周期。变量可以存储在以下几个区域: 栈区(Stack):局部变量通常会被分配到栈中。栈是线程私有的,每个线程都有一个独立的栈空间。 堆区(Heap):动态分配的内存(通过 malloc()、free() 等)会被存储在堆中。堆是共享的,不同线程可以访问堆中的数据。 全局区/静态区(Data Segment):全局变量和静态变量通常存储在数据段中。在程序启动时,数据段会被分配并初始化。 代码区(Text Segment):存储程序的代码(即机器指令),线程不直接操作。 2. 线程对变量的修改,为什么对其他线程不可见? 当一个线程修改了它的某个变量的值,这个变量的值并不一定立即对其他线程可见,主要是因为现代处理器通常会有缓存(Cache),并且每个线程可能在自己的寄存器或局部缓存中执行操作。具体原因如下: CPU 缓存:每个 CPU 核心都有自己的缓存(例如 L1、L2 Cache),当一个线程运行时,它可能会先将某个变量加载到本地缓存中进行修改,而不是直接操作主内存。这样,修改后的值可能不会立刻反映到主内存中。 内存可见性问题:因为不同的线程可能运行在不同的 CPU 核心上,并且每个核心有自己的缓存系统,其他线程(在不同的 CPU 核心上)可能无法直接看到修改后的变量值。除非通过某种同步机制(如内存屏障、锁、原子操作等)确保所有 CPU 核心的缓存一致性,否则修改的值不会立刻对其他线程可见。 3. 线程被切换时,变量的值存储到哪里? 当一个线程被 调度器切换(例如,从运行状态切换到阻塞状态或就绪状态)时,操作系统会保存该线程的 上下文(即该线程当前的执行状态)。这个过程称为 上下文切换。 CPU 寄存器:线程的 寄存器状态(包括程序计数器、栈指针、CPU 寄存器中的数据等)会被保存在操作系统为该线程分配的 线程控制块(TCB) 中,或者在内核中由特定的机制(如进程控制块、线程栈)保存。 内存:栈中的局部变量会被存储在 栈区,这些数据在线程切换时保持不变,直到线程恢复时。 CPU 缓存:在某些情况下,线程切换后,CPU 的缓存中的数据可能会被清除或更新,以保证在切换后的线程恢复时能正确访问内存。 4. 缓存与主内存 在现代多核处理器上,每个 CPU 核心(CPU Core)通常有自己的 本地缓存(例如 L1 缓存、L2 缓存,甚至是更高层的缓存)。这些缓存的作用是加速内存访问,避免每次访问内存时都直接访问主内存(RAM)。因此,当线程对某个变量进行修改时,这个变量的值首先会被写入到该线程所在 CPU 核心的 缓存 中,而不一定立即写回到主内存。 5. 另一个线程接着运行,变量值从哪里拿? 当一个线程被切换出去后,另一个线程会接管 CPU 的执行,并且继续执行自己的代码。另一个线程获取变量的值依赖于以下几个因素: 内存一致性:如果没有任何内存屏障或同步机制,另一个线程可能会读取到一个过时的缓存值。原因是线程 1 在修改变量时,可能只是修改了本地缓存,而没有把新值写回到主内存中;而线程 2 可能仍然读取到线程 1 的旧值。 线程间同步:为了确保变量的最新值在多个线程之间可见,通常需要使用 同步原语(如互斥锁 mutex、条件变量 condvar、原子操作等)。如果使用了如 mutex 锁定共享资源,或者使用了 volatile 关键字或原子操作,线程间对变量的修改才会更可靠地同步和可见。 缓存一致性协议:现代 CPU 通常使用 缓存一致性协议(如 MESI 协议)来确保不同 CPU 核心之间缓存的一致性。当一个线程修改某个变量时,其他线程会通过协议获取这个变量的最新值,避免缓存中的脏数据。 6. CPU 缓存与线程切换 这里有一个关键点:线程切换并不意味着缓存被清除。如果线程 1 在 CPU 核心 A 上运行,修改了全局变量,并且这个修改存储在核心 A 的缓存中,线程 1 被切换出去,CPU 核心 A 的缓存不会因为线程切换而清空。即使切换到其他线程,CPU 仍然会保留 核心 A 的缓存内容。 当线程 2 在另一个 CPU 核心 B 上开始运行时,如果它访问相同的全局变量,它会根据 自己核心的缓存 来读取这个变量。如果线程 2 看到的是旧值,那么它就是从 核心 B 的缓存 中拿到的旧值,而不是从主内存 中读取的。 7. 内存一致性问题 内存一致性问题通常出现在 多核处理器 中,特别是当多个线程运行在不同的 CPU 核心上时。在这种情况下: 线程 1 可能修改了一个全局变量的值,线程 1 所在的 CPU 核心会将该变量的新值写入到 该核心的缓存(例如 L1 或 L2 缓存)中。 如果 线程 2 运行在 另一个 CPU 核心 上,它可能会直接从 自己本地的缓存 中读取这个变量,而不是从主内存读取。假如线程 2 在缓存中读取到的是 线程 1 之前的旧值(而不是修改后的新值),那么线程 2 就读取到过时的值。 8. 为什么会有内存屏障? 由于多核处理器中的 CPU 会对内存操作进行缓存优化,内存屏障(mb()、wmb()、rmb())的作用是 强制同步 内存操作,确保某些操作的顺序性和内存可见性。通过内存屏障,操作系统或程序员可以确保在特定的内存操作之前或之后的操作按顺序执行,避免缓存带来的不一致性。 9. 为什么一个线程拿到的值可能会是旧的? 这个问题的核心在于 缓存一致性 和 内存可见性: 每个 CPU 核心都有独立的缓存,这意味着它们的缓存可能保存着不同版本的内存数据。 当线程 1 修改变量时,虽然它的 本地缓存 中的数据会被更新,但主内存的更新可能并没有立刻发生,或者其他 CPU 核心的缓存并没有得到通知。 如果没有同步机制(如内存屏障、锁、原子操作等),线程 2 可能会继续读取到旧的缓存值,而不是线程 1 修改后的新值。 10. 综述:内存模型和线程切换 在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作: 每个线程有自己的栈,但共享堆和全局变量。 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。 内存屏障和同步机制:确保变量在多个线程之间同步和可见。 示例 假设有两个线程 A 和 B,它们都操作同一个共享变量 x。假设线程 A 修改了 x 的值,但由于没有同步机制,线程 B 可能不会立刻看到修改后的值。 int x = 0; // 共享变量 void thread_a() { x = 1; // 修改共享变量 wmb(); // 写屏障,确保修改的值会被其他线程看到} void thread_b() { rmb(); // 读屏障,确保读取的是线程 A 修改后的值 printf("%d\n", x); // 打印 x 的值} 总结 线程修改变量时,该变量可能存在于不同的缓存中,其他线程可能无法看到更新的值。 上下文切换时,线程的寄存器和栈会被保存在操作系统的上下文中,恢复时会读取这些数据。 内存屏障 用于控制内存操作的顺序性,确保修改的值能在不同线程间同步。 三、编译器屏障 编译器屏障(Compiler Barrier)是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。 编译器屏障的作用 编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是: 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。 编译器屏障与内存屏障的区别 编译器屏障(Compiler Barrier): 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。 内存屏障(Memory Barrier): 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。 编译器屏障的实际应用 编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景: 原子操作:在使用原子操作(比如 atomic_add)时,编译器屏障可用于确保原子操作的顺序性。 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。 编译器屏障的实现 不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括: volatile 关键字:在 C/C++ 中,volatile 可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。 内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了 asm volatile 来控制编译器优化。 编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,GCC 中有 __asm__ __volatile__ ("": : : "memory"),这是一个编译器屏障,它不会生成任何机器指令,但会告知编译器不要重排此位置的内存操作。 示例:GCC 中的编译器屏障 在 GCC 中,可以使用 __asm__ __volatile__ ("": : : "memory") 来插入一个编译器屏障。它告诉编译器不要重新排序此点前后的内存操作。 示例代码: #include volatile int shared_var = 0; void func1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("" : : : "memory"); // 插入编译器屏障} void func2() { int local = shared_var; // 读取共享变量 printf("shared_var = %d\n", local);} int main() { func1(); func2(); return 0;} 在这个示例中,__asm__ __volatile__ ("" : : : "memory")强制插入一个编译器屏障,确保编译器在执行shared_var = 1和shared_var之间的读写操作时不会对它们进行优化或重排序。 编译器屏障的局限性 虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 或 锁 等同步机制。 总结 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。 四、CPU屏障 CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排、内存缓存一致性 和 多核 CPU 系统中的缓存同步 等问题。 在多核系统中,每个 CPU 核心都有自己的缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。 CPU 屏障的作用 防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。 确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。 控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。 与内存屏障的区别 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。 CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。 CPU 屏障的类型 不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型: 全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。 加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。 存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。 轻量级屏障(Light Barrier):有些现代 CPU 提供更细粒度的屏障,能够针对特定类型的指令(如仅仅是缓存一致性)进行同步。 典型的 CPU 屏障指令 1. x86 架构: MFENCE:这是 x86 架构中的一个全屏障指令,它确保所有的加载和存储指令在屏障前后都按顺序执行。 LFENCE:加载屏障,用于确保加载操作的顺序。 SFENCE:存储屏障,用于确保存储操作的顺序。 2. ARM 架构: DMB(Data Memory Barrier):用于确保数据内存操作的顺序。DMB 会阻止内存操作的重排序。 DSB(Data Synchronization Barrier):一个更强的同步屏障,通常会确保所有的内存操作完成,才会继续执行后续操作。 ISB(Instruction Synchronization Barrier):强制 CPU 刷新指令流水线,确保指令同步。 3. PowerPC 架构: sync:PowerPC 中的同步指令,强制执行内存访问的顺序。 CPU 屏障的应用场景 多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。 内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。 硬件编程: 对于直接操作硬件的低级编程(例如内存映射 I/O、嵌入式系统开发等),CPU 屏障能够确保 I/O 操作按顺序完成,避免因为缓存一致性问题导致硬件异常。 例子:在 x86 架构中的应用 假设有一个共享变量 shared_var,多个线程可能会修改它。如果不使用 CPU 屏障,线程 1 修改 shared_var 后,可能没有立即刷新到主内存,线程 2 可能会看到过时的缓存数据。 以下是一个简单的 C 代码示例: #include #include volatile int shared_var = 0; void thread1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("mfence" ::: "memory"); // 使用 MFENCE 全屏障,确保写操作完成} void thread2() { while (shared_var == 0) { // 等待 thread1 更新 shared_var } printf("shared_var updated to 1\n");} int main() { // 模拟两个线程 thread1(); thread2(); return 0;} 在这个示例中,thread1 在修改 shared_var 后使用了 mfence 指令来确保修改后的值及时写入主内存,并且被其他线程看到。thread2 会等待 shared_var 更新后继续执行。 总结 CPU 屏障 是硬件层面的机制,用来控制 CPU 内部指令执行顺序和缓存同步,确保内存操作按照特定顺序发生。 它防止 CPU 对指令的重排序,从而避免多核环境中的缓存一致性问题。 不同的 CPU 架构(如 x86、ARM)提供了不同类型的 CPU 屏障指令,如 MFENCE(x86)、DMB(ARM)等。 小结图示 +-------------------------------+ | 主内存(RAM) | | | | +-------------------------+ | | | 共享变量(shared_var) | | | +-------------------------+ | +-------------------------------+ | | v v +----------------+ +----------------+ | 核心 1 | | 核心 2 | | L1 缓存 | | L1 缓存 | | 存储变量值 | | 存储变量值 | +----------------+ +----------------+ | | v v CPU 屏障(如 MFENCE) 强制同步缓存与主内存 通过 CPU 屏障的使用,确保 核心 1 对 shared_var 的修改能正确地同步到主内存,核心 2 可以看到最新的值。 五、使用内存屏障还需要使用CPU屏障吗? 对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronize、atomic_thread_fence、mfence 等)来实现。 1. 内存屏障 vs CPU 屏障 内存屏障: 由 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。 CPU 屏障: 是 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。 2. 内存屏障满足开发者需求 对于程序员而言,使用 内存屏障 就足够了,因为: 内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。 编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。 在 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。 3. 实际应用中的差异 3.1. 使用内存屏障 内存屏障(如__sync_synchronize、atomic_thread_fence、mfence)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。 例如,在多线程编程中,你希望线程 A 在修改共享变量之后,线程 B 能立即看到该修改,可以在 A 中插入内存屏障: #include std::atomic<int> shared_var = 0; void thread_a() { shared_var.store(1, std::memory_order_release); // 写屏障} void thread_b() { while (shared_var.load(std::memory_order_acquire) == 0) { // 等待线程 A 更新 shared_var } // 现在可以安全地使用 shared_var} 在此例中,std::memory_order_release 和 std::memory_order_acquire 就是内存屏障的类型,它们保证了内存操作的顺序。 3.2. CPU 屏障 CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。 例如,在嵌入式系统开发或驱动开发中,可能会看到 mfence 或其他处理器指令: __asm__ __volatile__("mfence" ::: "memory"); 在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。 4. 总结 对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。 CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。 5. 应用场景 多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。 优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。 6. 建议 a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。 b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。 六、“原子操作/互斥锁” 与 “内存屏障” 1. 原子操作与内存屏障的对比 原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。 原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。 内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。 简而言之,原子操作是对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。 2. 互斥锁的读写屏障 互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁和解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。 加锁/解锁时的内存屏障作用示意: 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。 3. 总结 内存屏障: 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。 原子操作: 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。 互斥锁和内存屏障: 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。 4. 建议 a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。 b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。 七、配合内存屏障来真正理解volatile volatile 关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile 可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。 优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。 防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。 等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量) 主要作用: 1.防止编译器优化: 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。 使用 volatile 告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。” 2.确保变量访问的正确性: 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,volatile 关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。 3.不保证写操作同步到主内存:如果你修改了一个 volatile 变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。 4.volatile的作用范围:它确保你每次访问时都能获取到变量的最新值,但并不会同步其他线程或 CPU 中的缓存。例如,在多核处理器环境下,CPU 核心 A 可能会缓存某个变量,而 CPU 核心 B 可能并不知道核心 A 修改了这个变量,volatile 不能解决这种缓存一致性问题。 适用场景: 硬件寄存器:在嵌入式系统中,直接映射到硬件的内存地址经常会用 volatile,以确保每次访问时都读取硬件的最新值,而不是使用缓存。 多线程共享变量:当多个线程共享一个变量时,某个线程对该变量的修改可能会被其他线程及时看到,volatile 可以确保每次访问该变量时,都能获取到最新的值。 信号处理函数中的变量:在多线程或多进程的环境中,如果信号处理程序修改了某个变量,程序中的其他部分在访问这个变量时,需要确保获取到的是最新的值,因此也需要用 volatile。 使用示例: #include volatile int flag = 0; // 声明为volatile,表示它的值可能被外部因素改变 void signal_handler() { flag = 1; // 外部信号处理程序改变flag的值} void wait_for_flag() { while (flag == 0) { // 这里每次访问flag时,都会从内存中读取,而不是使用寄存器或缓存中的值 } printf("Flag is set!\n");} int main() { wait_for_flag(); // 等待flag被信号处理程序修改 return 0;} 在这个例子中: flag 变量声明为 volatile,表示它可能在程序的其他地方(如信号处理函数 signal_handler)被修改。 wait_for_flag 函数会在 flag 被修改之前一直等待。当 flag 被设置为 1 时,程序才会继续执行。 volatile 确保每次检查 flag 时都从内存读取,而不是从寄存器缓存中读取过时的值。 注意事项: 1.volatile 不等同于原子性或线程同步: volatile 只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个 volatile 变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。 2.volatile不会防止缓存一致性问题: 在多核 CPU 系统中,volatile 并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。 3.volatile主要是为了防止编译器优化: 在大多数情况下,volatile 只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。 结论: volatile 主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。 它 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。 相关建议 a. 在多线程编程中,除了 volatile,你还应该了解如何使用 原子操作、互斥锁 或 条件变量 来确保共享数据的一致性。 b. 如果涉及到硬件寄存器的访问,理解 volatile 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。

    01-03 148浏览
  • 8个问题搞懂linux系统

    背景: 我们学习Linux的系统调用经常会遇到一个概念:“内核态和用户态的切换”,一般人只会告诉你说这个切换代价很大,具体是什么情况?为什么需要切换?一定需要切换吗?怎么就会触发切换?内核怎么知道要切换了?内核态是一个程序吗?是谁来切换的,CPU吗?等等具体细节很多人都不知道。我花时间整理了这些问题的答案,希望能帮到更多人。 要理解以上问题,需要有计算机的基础知识,计算机的本质是开关门三极管控制的高低电平切换,这些高低电平就是二进制的0、1。本质上也是数据的计算和保存。寄存器、缓存器、运存、内存等是数据,CPU负责计算转换这些数据。外设负责显示和传输数据,使用的“人”可以看到这些数据。 现在的操作系统其实就是把以前的“汇编版单程序”做了封装,本质还没变,一个CPU同时只能运行一个一个程序,不过是速度快了,让程序之间来回切换,让我们人感知不到任何切换感和卡顿感(电脑的卡顿不是CPU卡顿是程序的阻塞)。基于这一点:内核就是一个个程序,一上电内核肯定早于用户程序运行在CPU中。内核调度用户程序,把用户程序的上下文加载到CPU 并让出CPU,让出前定好定时器(硬件定时器--可以打断CPU运行),让用户程序时间片到了后可以切换回内核程序。 问题1:linux 系统调用 read/write这种 执行的时候 用户态和内核态的切换过程怎样,有什么缺点? 在 Linux 系统中,read 和 write 系统调用是典型的用户态和内核态之间的切换过程。下面是它们执行时的用户态和内核态切换过程以及潜在的缺点: 1. 执行过程 用户态到内核态切换: 用户程序调用 read() 或 write() 时,首先是从用户态发起系统调用。 由于 read 和 write 是系统调用,它们会触发一个软件中断或调用 syscall 指令,这将导致 CPU 从用户态切换到内核态。 内核在内核态中执行实际的 I/O 操作(例如,磁盘读写、网络操作等)。内核会访问设备驱动程序,可能会通过设备管理层与硬件进行交互。 内核态到用户态切换: 当 I/O 操作完成后,内核会将结果(例如读取的数据,写入操作的字节数)返回给用户程序。 结果返回后,系统会通过中断或返回指令将 CPU 状态从内核态切换回用户态,允许用户程序继续执行。 2. 内核态和用户态切换的缺点 内核态和用户态的切换是开销较大的操作,尤其是在频繁调用 read 和 write 系统调用时。具体的缺点包括: 1. 上下文切换的开销: 每次从用户态切换到内核态(或者反过来),都会发生上下文切换。上下文切换不仅涉及保存和恢复 CPU 寄存器的状态,还包括更新内存中的相关数据结构(如进程控制块),这些操作会带来额外的性能开销。 如果 read 或 write 调用是同步的(阻塞模式),程序会等待 I/O 完成,这时 CPU 可能空闲,浪费了大量的时间。 2. I/O 阻塞与同步: 在传统的阻塞 I/O 模式下,read 和 write 系统调用可能会使调用线程处于阻塞状态,直到 I/O 操作完成。这种情况下的上下文切换会导致 CPU 浪费时间等待 I/O 完成。 如果应用程序需要频繁进行阻塞 I/O 操作(例如,每秒钟执行大量的 read/write 系统调用),会导致频繁的用户态和内核态切换,从而增加系统开销。 3. 缓存一致性和内存访问: read 和 write 系统调用有时会涉及大量的数据拷贝。例如,read 操作可能会将数据从内核空间拷贝到用户空间,这需要额外的内存访问,尤其在大量数据传输的情况下,这种开销非常显著。 现代操作系统通常使用缓冲区或零拷贝技术(如 mmap()、sendfile() 等)来减少这种开销,但仍然需要避免频繁的内核态与用户态之间的数据拷贝。 4. 线程切换: 在多线程环境中,频繁调用 read 和 write 可能会导致频繁的线程切换(尤其是在多个线程共享同一文件描述符时),增加了额外的调度开销。 3. 优化方法 为了减少用户态和内核态切换带来的性能损耗,可以采取以下几种优化方法: 异步 I/O(AIO):通过异步 I/O,应用程序不需要在 read 或 write 调用时阻塞线程,内核会在 I/O 操作完成时通知应用程序,减少了上下文切换的次数。 零拷贝 I/O(Zero-copy I/O):通过减少内核与用户空间之间的数据拷贝来提高性能,例如使用 mmap() 或 sendfile()。 非阻塞 I/O:通过设置文件描述符为非阻塞模式,避免阻塞等待,结合 select() 或 poll() 等 I/O 多路复用技术,提高并发性能。 总结来说,read 和 write 系统调用的用户态和内核态切换过程在多次调用时可能带来显著的性能开销,尤其是上下文切换、阻塞等待和数据拷贝等问题。通过使用异步 I/O、零拷贝技术等优化方法,可以有效减少这些开销。 问题2:fread/fwrite/fopen/fclose/getchar/gets/puts/putchar是系统调用吗,异步IO怎么使用? 1. **fread/fwrite/fopen/fclose/getchar/gets/puts/putchar 是否是系统调用? 这些函数是 C 标准库提供的高级 I/O 函数,它们并不是直接的系统调用,而是通过系统调用实现的。系统调用是内核提供的接口,用于用户程序与操作系统之间的交互,而这些库函数是建立在操作系统提供的系统调用之上的,它们负责提供更高层次的功能,如缓冲、格式化等。 具体来说: fread/fwrite/fopen/fclose:这些是标准 C 库提供的文件 I/O 操作函数,通常会通过 read 和 write 系统调用来实际进行文件操作。 fopen 会调用系统调用(如 open)来打开文件。 fread 和 fwrite 在执行数据读取或写入时,最终会调用系统调用(如 read 和 write),并且会使用缓冲区进行缓存管理。 fclose 会关闭文件,并释放与文件相关的资源,通常会调用系统调用(如 close)。 getchar/puts/putchar: getchar 是标准输入函数,会调用 read 系统调用读取数据。 putchar 和 puts 是输出函数,底层通过 write 系统调用将字符输出到标准输出。 总结: 这些 C 库函数本身并不是系统调用,但它们内部通过对系统调用(如 read, write, open, close 等)的调用来实现实际的 I/O 操作。它们提供了额外的功能,如缓冲区管理、格式化输入输出等。 2. 异步 I/O (AIO) 怎么使用? 异步 I/O(AIO)允许程序在发起 I/O 操作后,不需要阻塞等待操作完成,而是可以继续执行其他任务,直到 I/O 操作完成并通知程序。这对于提高性能、避免线程阻塞非常有效,尤其在高并发的场景下。 在 Linux 上,异步 I/O 主要通过以下几种方式实现: 1. aio_read 和 aio_write: 这些是 POSIX 标准定义的异步 I/O 系统调用。与传统的 read 和 write 系统调用不同,aio_read 和 aio_write 不会阻塞进程,调用者可以在等待 I/O 操作完成时继续执行其他代码。 示例: #include #include #include #include #include #include #include int main() { struct aiocb cb; // 异步 I/O 控制块 int fd = open("example.txt", O_RDONLY); if (fd == -1) { perror("open"); return -1; } // 设置异步读取的参数 memset(&cb, 0, sizeof(struct aiocb)); cb.aio_fildes = fd; cb.aio_buf = malloc(1024); cb.aio_nbytes = 1024; cb.aio_offset = 0; // 发起异步读取 if (aio_read(&cb) == -1) { perror("aio_read"); close(fd); return -1; } // 检查 I/O 操作是否完成 while (aio_error(&cb) == EINPROGRESS) { // 可以继续执行其他操作 printf("I/O in progress...\n"); sleep(1); } // 获取操作结果 int ret = aio_return(&cb); if (ret == -1) { perror("aio_return"); close(fd); return -1; } printf("Read %d bytes: %s\n", ret, (char*)cb.aio_buf); free((void*)cb.aio_buf); close(fd); return 0;} 关键步骤: aio_read 发起异步读操作,aio_write 用于异步写操作。 aio_error 用于检查 I/O 操作是否仍在进行中。 aio_return 用于获取操作结果,如果操作已完成。 2. epoll 和 select: epoll 和 select 是 I/O 多路复用机制,允许一个线程同时监控多个文件描述符的事件,例如可读、可写等。它们本身不是完全异步 I/O,但能够以非阻塞方式进行 I/O 操作,帮助高效地管理多个并发连接。 示例(使用epoll监控文件描述符的可读事件): #include #include #include #include #include int main() { int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } int fd = open("example.txt", O_RDONLY | O_NONBLOCK); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } struct epoll_event event; event.events = EPOLLIN; // 监控可读事件 event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { perror("epoll_ctl"); exit(EXIT_FAILURE); } struct epoll_event events[10]; int nfds = epoll_wait(epoll_fd, events, 10, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // 处理可读事件 char buffer[1024]; int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer)); if (bytes_read > 0) { printf("Read %d bytes: %s\n", bytes_read, buffer); } else { printf("End of file or error\n"); } } } close(fd); close(epoll_fd); return 0;} 3. 使用 mmap 和 msync 进行内存映射 I/O: 内存映射 I/O 是一种通过内存操作文件的方式,这种方法可以实现高效的异步 I/O,尤其在处理大文件时可以提高性能。 总结: 异步 I/O 可以通过使用 aio_read/aio_write 等接口来实现,它们允许在 I/O 操作未完成时不阻塞当前线程。 epoll/select 提供了一种更灵活的方式来处理多个并发 I/O 操作,虽然它们并不直接提供异步 I/O,但通过非阻塞 I/O 和事件通知机制,也能达到类似的效果。 问题3:既然系统调用引发了上下文切换,那么其实就是进程间的切换,是用户进程和内核进程间的切换吗,内核进程是每次系统调用时才开始运行 还是一直在运行,对应哪个程序? 系统调用确实会导致上下文切换,但它并不是指进程间的完全切换。系统调用引发的上下文切换主要是用户进程和内核之间的切换,而不是两个不同的用户进程或两个内核进程之间的切换。 1. 用户进程与内核进程的切换 用户进程 在用户态运行,它在执行过程中可以发起 系统调用(如 read、write、open 等),此时发生 上下文切换,CPU 会从用户态切换到内核态。 内核态 由操作系统内核代码控制,内核执行的部分是操作系统的一部分,负责管理硬件资源、内存管理、I/O 操作等。内核态下执行的是内核代码,这时会执行具体的系统调用逻辑,如硬件驱动、内存管理等。 内核进程并不代表是一个普通的进程,它是由操作系统内核管理的,通常不依赖于特定的程序。内核会执行很多任务,并且可以同时管理多个用户进程。内核中有一些特定的执行上下文(如中断处理、系统调用的执行),这些都属于内核层面的“活动”。 2. 内核进程和内核态执行的区别 内核进程:通常是指内核中执行的那些特定任务(如调度、进程管理、网络处理等),这些任务由操作系统内核自己管理。 内核态执行:是指当前正在执行的内核代码,无论是否有内核进程在运行。实际上,内核并不像用户进程那样“常驻”或“独立运行”。当用户进程发起系统调用时,内核会执行对应的内核代码(如 I/O 操作),完成后再返回用户进程。 3. 系统调用时内核的执行 内核态的执行:内核并不是每次系统调用时才开始执行,它始终在内存中并处于休眠或等待状态。当用户进程触发系统调用时,CPU 切换到内核态,执行与该系统调用相关的内核代码(例如磁盘 I/O、网络操作、内存分配等)。系统调用结束后,CPU 再切换回用户态,继续执行用户进程。 在内核态,操作系统会管理多种资源,如中断、设备驱动程序、内存管理等,它通过各种机制(如中断、定时器、系统调用等)来调度内核态代码的执行。 例子:当用户程序发起read()系统调用时,内核需要通过磁盘 I/O 或网络设备获取数据,并把数据从内核空间传递到用户空间。内核执行这些操作时并不依赖于某个单独的“内核进程”,而是由内核中相应的代码块来完成。 4. 内核进程不是每次系统调用才执行 内核进程通常是指内核内部用于特定任务的程序(如内核线程)。但是,在 Linux 等操作系统中,内核是一个整体的操作系统内核,它会管理和调度所有进程(包括用户进程和内核线程)。内核进程并不是系统调用时才运行,而是内核代码在响应系统调用时会启动内核的相应部分。 例如,操作系统内核会创建和管理各种“内核线程”(如 kworker,ksoftirqd 等),这些线程用于执行后台任务,如处理硬件中断、执行调度等。这些内核线程在系统启动时就会存在,并且会一直运行,独立于用户进程。 5. 进程的执行与内核的关系 用户进程:通常会在用户态执行,除非它发起系统调用或者发生中断等需要内核介入的事件。在系统调用时,操作系统的内核代码会被调度执行。 内核进程:并不是像用户进程一样是由用户创建的。内核本身有一些“内核线程”,它们在内核模式下执行,通常由操作系统自动创建和管理。它们执行特定任务(如硬件中断处理、网络协议栈、内存管理等)。 6. 上下文切换的两种类型 用户态到内核态的切换:例如当用户进程发起系统调用时,操作系统会从用户进程切换到内核空间。 内核态到用户态的切换:当系统调用完成时,操作系统会将执行从内核空间切换回用户空间,继续执行用户进程。 上下文切换在这两种情况下都可能发生,但 内核进程并不代表是特定的独立程序,它只是内核的一部分,始终处于内核模式下,并且会响应系统调用、硬件中断等事件。 总结: 系统调用引发的上下文切换,是用户进程从用户态切换到内核态,执行内核代码。并不是进程间的切换,而是用户态和内核态之间的切换。 内核进程 并不是每次系统调用时才开始运行,内核始终在内存中,可以理解为操作系统的一部分,负责管理硬件、内存、进程调度等任务。 内核本身并不像用户进程一样是一个独立的程序,而是操作系统的一部分,内核的不同部分(如内核线程)始终在内核态下执行。 问题4:不管是内核还是用户程序 对于CPU来说就是 一段待执行的指令合集,不过是操作系统内核的指令集很大,正常处于休眠状态,等待中断来触发执行。本质上来说,用户程序是系统调用的,本质上来说也是运行的负责调度的内核程序,这个内核程序负责读取和切换待调度的每个程序的上下文。是这样吗? 1. 用户程序和内核程序的关系 用户程序:是由用户编写的应用程序,运行在 用户态。它通过系统调用与操作系统进行交互,例如使用 read 或 write 执行 I/O 操作、通过 fork 创建子进程等。当用户程序发起系统调用时,会触发用户态到内核态的上下文切换,此时操作系统内核开始执行对应的内核代码(如进程调度、文件系统操作、内存管理等)。 内核程序(内核态代码):是操作系统的核心,运行在 内核态。它负责管理硬件资源、调度进程、提供系统调用接口、处理中断等。内核代码并不是单独的“程序”,而是操作系统的一部分,它的职责是响应系统调用、管理系统资源并调度用户进程。 你说得对,内核负责调度进程并在需要时切换它们的上下文。 2. 内核程序的执行: 是操作系统内核代码的执行 内核 是一个 大指令集,包含很多功能,例如管理 CPU 调度、内存管理、I/O 操作、硬件中断处理等。它通常处于“休眠”状态,即它的代码并不会主动执行,只有在某些事件发生时(例如,系统调用、硬件中断、进程调度等),内核才会被唤醒并开始执行相应的任务。 你可以把内核想象成一个负责协调、管理和调度硬件资源的程序。当 用户程序 发起一个 系统调用 时,内核会响应这个调用,切换到内核态,执行内核代码,执行完毕后再切换回用户态。 3. 上下文切换: 上下文切换 是指 CPU 从一个执行上下文切换到另一个执行上下文。上下文包括程序计数器(PC)、寄存器值、栈指针等信息。每次从 用户程序切换到内核程序(或反之)时,都会进行上下文切换。 内核负责调度进程,并根据各种条件(如时间片、I/O 等待)决定哪个进程应该运行。内核会保存当前进程的上下文,并恢复下一个待执行进程的上下文。这个过程叫做 进程调度,它是操作系统管理多个程序并合理分配 CPU 时间的关键。 内核在执行过程中,会根据不同的事件(比如硬件中断、系统调用、时间片耗尽等)来触发上下文切换。比如,如果一个进程执行了 read 系统调用,CPU 会切换到内核态,由内核代码处理 I/O 操作,处理完毕后再切换回用户进程。 4. 用户程序的调度 用户程序 的调度并不是由用户控制的,而是由操作系统内核负责。内核通过调度算法(如时间片轮转、优先级调度等)来决定哪个进程可以占用 CPU 执行。每当进程的时间片用完,或者进程进行 I/O 等待,或者发生某些调度事件时,内核会触发上下文切换。 所以,你可以理解为 用户程序本质上是由内核调度的。虽然用户程序在用户态下运行,但它的执行是受操作系统内核控制的。 5. 内核程序的角色: 内核程序的职责不仅仅是“调度”用户程序,还包括其他很多核心任务: 硬件管理:内核通过驱动程序与硬件交互,管理 I/O 设备、网络设备等。 内存管理:管理物理内存和虚拟内存,进行地址映射、分页等操作。 进程调度:决定哪个进程应该运行,并在多个进程之间切换。 文件系统管理:管理文件系统和磁盘存储。 中断处理:响应硬件中断,处理外设请求。 6. 总结 用户程序 是由用户编写的程序,运行在 用户态,通过系统调用与操作系统进行交互。 内核程序 是操作系统的一部分,运行在 内核态,负责管理硬件资源、调度进程、提供系统调用接口等。内核并不是“单独的程序”,而是操作系统的核心部分。 操作系统内核的主要任务 包括调度用户进程、处理中断、提供系统调用等,它负责在多进程环境中协调多个程序的执行,并确保它们能有效利用 CPU 和其他资源。 内核通过 上下文切换 进行进程调度,决定哪个进程能够占用 CPU 执行,并在多个进程之间进行切换。 问题5:那用户程序时间片到了是怎么让出CPU的呢(此时内核程序并未在运行,因为CPU一次只能运行一个程序),难道内核程序执行了一个定时中断,来切换用户程序? 是的,用户程序时间片到了时,操作系统会通过 定时中断(Timer Interrupt) 来触发 内核程序 的执行,从而进行 进程调度。具体来说,CPU 在执行用户程序时,操作系统内核并没有持续运行,而是通过中断机制来控制进程的切换。 1. 时间片与定时器中断 每个进程在操作系统中都会分配一个固定的 时间片(Time Slice),这是它在 CPU 上连续执行的时间。当时间片耗尽时,操作系统需要 暂停 当前进程的执行,并将 CPU 切换到另一个进程。 关键步骤: 定时器中断:操作系统会使用硬件定时器(通常是 PIT,可编程间隔定时器)来生成定时中断。定时器中断是一个周期性事件,它在设定的时间间隔内触发一次。例如,操作系统可能每 10 毫秒或 1 毫秒触发一次定时器中断。 定时器中断触发:当定时器中断触发时,CPU 会 暂停当前执行的指令,并跳转到内核代码来处理中断。这个过程涉及 上下文切换,操作系统内核会保存当前用户程序的状态(即上下文),并恢复操作系统的中断处理程序。 进程调度:内核的调度程序会检查当前运行的进程是否已经消耗完时间片。如果时间片已用完,调度程序会选择另一个进程来运行。此时,内核会将当前进程的状态保存在其进程控制块(PCB)中,然后选择下一个进程并恢复它的上下文,切换到该进程。 恢复进程:当调度程序完成切换后,CPU 会从内核态切换回用户态,恢复到新选定进程的执行。 2. 操作系统如何使用定时中断进行调度 操作系统的调度是通过响应定时器中断来进行的。在这个过程中,操作系统内核负责: 在中断发生时 保存当前进程的状态(即上下文),包括程序计数器、寄存器等。 调度 下一个进程并恢复其上下文。 让 CPU 切换到新的进程。 这种机制确保了操作系统可以 公平地分配 CPU 时间 给多个进程,使得每个进程都有机会在 CPU 上运行,并且防止某个进程长期占用 CPU。 3. 上下文切换的具体过程 定时中断触发时,CPU 会进入 内核态,并执行操作系统的 中断处理程序。操作系统会检查是否需要切换进程。 具体的处理步骤: 保存当前进程的上下文:操作系统保存当前进程的寄存器、程序计数器等信息,以便在该进程再次运行时能够恢复它的执行状态。 更新进程调度信息:操作系统会标记当前进程为“已用完时间片”或“等待 I/O”等状态,并选择下一个进程来运行。调度器根据调度算法(如时间片轮转、优先级调度等)选择下一个候选进程。 恢复新进程的上下文:操作系统恢复新选定进程的上下文,恢复其寄存器、程序计数器等,使得新的进程能够继续执行。 返回用户态:CPU 从内核态切换回用户态,继续执行新选定进程。 4. 定时器中断是如何控制时间片的 定时器中断通常由 硬件定时器(如 PIT)生成,操作系统会设置一个时间片的长度,假设时间片为 10 毫秒,那么操作系统每 10 毫秒就会通过定时器中断检查是否需要切换进程。 在每次定时器中断发生时,操作系统内核会检查当前进程是否已经用完了它的时间片。如果用完了,内核会选择另一个进程并进行上下文切换。 5. 举个例子 假设操作系统使用的是 时间片轮转(Round-Robin)调度算法,并且每个进程的时间片为 10 毫秒: 1.进程 A 被调度执行,开始运行。 2.10 毫秒后,定时器中断触发。 内核从用户态切换到内核态,处理定时器中断。 检查进程 A 是否已用完时间片,发现时间片已用完。 保存进程 A 的上下文。 选择下一个进程(比如进程 B)。 恢复进程 B 的上下文。 3.进程 B 开始执行,运行 10 毫秒后再次触发定时器中断。 4.继续这个过程,操作系统通过定时器中断来实现进程的轮换执行。 6. 总结 时间片到时,操作系统会通过定时器中断来 切换进程。定时器中断是由硬件定时器定期触发的,操作系统内核利用定时器中断来响应并进行进程调度。 内核程序 在定时器中断触发时被执行,它负责检查当前进程是否用完时间片,并调度下一个进程运行。 通过这种机制,操作系统能够公平地分配 CPU 时间给多个进程,确保系统的 响应性 和 并发性。 问题6:top命令能看到内核的程序吗? top 命令显示的是当前系统中 用户进程 和 内核线程 的运行状态,但它并不直接显示“内核程序”本身,因为内核本身并不是一个用户进程,而是操作系统的一部分,通常运行在 内核态。 1. top 命令显示的内容 top 命令的输出显示的是系统中 所有进程 的资源使用情况(如 CPU、内存、I/O 等),并且默认情况下列出的是 用户进程。这些进程的状态和资源占用可以帮助你了解系统的负载情况。 对于 内核线程,top 命令也会显示它们的状态,尤其是在 Linux 2.6 及其之后的版本,内核线程会被列出作为进程的一部分。内核线程是内核空间中的执行单元,它们负责执行系统的内部任务,如调度、硬件管理、I/O 操作等。 2. 内核线程的显示 在 top 命令的输出中,内核线程 会以特定的名称显示,这些线程通常以 k 开头(如 kworker、ksoftirqd、kthreadd 等)。 这些内核线程的名字表明它们是由内核创建并在内核态执行的。例如: kworker 线程负责处理内核的工作队列任务。 ksoftirqd 线程用于软中断的处理。 kthreadd 线程是内核线程的创建者,负责启动和管理其他内核线程。 你可以通过 top 的 -H 选项查看线程信息(包括内核线程和用户线程): top -H 这将显示每个进程的 线程,其中包括内核线程和用户线程。 3. 如何识别内核线程 在 top 命令的输出中,内核线程 和 用户进程 是区分开的,主要通过 进程的 UID 和 进程名称 来区分: 内核线程通常会显示为 root 用户(因为大多数内核线程是由 root 权限启动的),且它们的 PID 一般较小。 内核线程的名称通常以 k 开头,例如 kworker, ksoftirqd, kthreadd 等。 4. 查看内核进程 虽然 top 显示了内核线程,但你并不能通过 top 查看完整的 内核程序。因为 内核程序 本身是操作系统的一部分,它并不像用户进程那样是一个独立的程序。内核的执行是在操作系统启动时加载的,并且与用户空间的程序是分离的。操作系统内核代码总是处于内存中,并且由 CPU 在不同的时间片和事件中执行。 5. 示例输出 下面是一个 top 命令输出的例子,包含了内核线程和用户进程的区别: top - 10:21:09 up 1 day, 1:02, 2 users, load average: 0.02, 0.01, 0.00Tasks: 146 total, 1 running, 145 sleeping, 0 stopped, 0 zombie%Cpu(s): 2.0 us, 1.0 sy, 0.0 ni, 97.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 stMiB Mem : 4000.0 total, 900.0 free, 1500.0 used, 1600.0 buff/cacheMiB Swap: 512.0 total, 400.0 free, 112.0 used. 800.0 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 123 root 20 0 8200 1200 1000 S 1.0 0.0 0:01.32 kworker/0:1H 124 root 20 0 8200 1200 1000 S 0.9 0.0 0:01.30 kworker/1:2 125 root 20 0 8200 1200 1000 S 0.8 0.0 0:01.40 ksoftirqd/0 1001 user1 20 0 212304 14288 1044 S 0.5 0.4 0:00.72 firefox 1002 user2 20 0 145236 8544 2052 S 0.3 0.2 0:00.60 gnome-shell 在这个输出中,进程如 kworker/0:1H 和 ksoftirqd/0 是 内核线程,它们在内核空间执行,负责系统的底层任务。 firefox 和 gnome-shell 是 用户进程,它们在用户空间执行,并显示为其他用户的程序。 6. 内核的管理 内核本身不作为单独的进程出现在 top 命令中,因为它并不是一个“用户可见”的进程。内核代码始终在内存中并执行,但它的管理不依赖于用户进程。 内核线程 负责处理系统的底层任务,如硬件管理、任务调度、内存管理、I/O 操作等。它们是由内核启动的,并且通常在 top 中显示为 root 用户,前缀为 k。 7. 总结 top 命令显示了内核线程和用户进程,它们的区别通常在于进程名称(内核线程以 k 开头)和用户 ID(通常是 root 用户)。 内核程序 本身并不作为独立的进程出现,它始终在操作系统内核中执行。top 命令能够显示 内核线程,但并不直接显示“内核程序”这一概念。 问题7:所以内核驱动加载 只是做了注册,不是直接运行,任何驱动程序都有触发条件,否则就是没用的驱动。这个触发条件可以是用户的系统调用,可以是定时中断、硬件中断等? 1. 驱动程序的加载与注册 驱动程序加载:当一个内核驱动被加载时,操作系统会将其编译为内核模块(在 Linux 中通常是 .ko 文件)。这个模块可以在系统启动时自动加载,也可以在运行时由用户手动加载(例如通过 modprobe 或 insmod 命令)。 注册过程:加载的驱动程序需要通过注册来向内核报告它将管理某些硬件或提供某些服务。例如: 字符设备驱动 会通过 register_chrdev 函数注册一个字符设备。 块设备驱动 会通过 register_blkdev 注册块设备。 网络驱动 会通过 register_netdev 注册网络设备。 注册的过程确保内核知道驱动程序的存在,并为它分配合适的资源。 2. 驱动程序的触发条件 驱动程序并不会“持续”运行,而是会根据特定的 事件或触发条件 来执行。驱动程序的执行通常是由内核和硬件事件引起的,以下是几种常见的触发条件: 2.1 用户空间的系统调用 有些驱动程序是通过用户程序发起的 系统调用 来触发的。用户程序可以通过系统调用与内核空间的驱动程序进行交互: 例如,一个字符设备驱动可能等待用户空间程序通过 open(), read(), write() 等系统调用来访问设备。用户程序的这些操作会触发内核中对应驱动的相关函数,进而执行驱动程序的操作。 2.2 硬件中断 许多硬件设备(如网卡、磁盘、USB 设备等)需要在某些事件发生时通知内核,例如数据传输完成、设备状态变化等。硬件中断是一种常见的触发条件: 硬件中断触发:硬件设备会发出中断信号,通知 CPU 该设备需要处理。内核的中断处理程序会触发相应设备驱动程序中的 中断处理函数 来处理中断事件。 例如,网卡驱动在接收到数据包时会触发一个硬件中断,内核会调用网卡驱动中的 中断处理程序,并处理网络数据。 2.3 定时中断 内核中的某些驱动程序可能依赖于 定时中断 来执行周期性任务。这类驱动程序通常是负责一些 定时操作 的,如定时刷盘(写入数据到磁盘)或周期性地检查设备状态: 例如,某些设备的驱动程序可能需要定期轮询设备状态,这种操作会通过内核定时器触发。 2.4 内核事件和工作队列 有些驱动程序会将任务放入 内核的工作队列 中,这些任务会在适当的时候由内核线程执行。工作队列可以由多种事件触发: 内核工作队列:内核使用工作队列(如 kworker 线程)来处理异步任务。例如,磁盘驱动可以将数据写入硬盘的任务放入工作队列中,等待 kworker 线程异步执行。 事件触发:内核会基于某些条件(如 I/O 完成、设备状态变化)来触发工作队列的执行。 2.5 内存或缓冲区管理 有些驱动程序依赖于 内存管理系统,比如内存的分配或释放、缓冲区的满载等。内核的内存管理子系统会触发驱动程序的某些操作: 例如,网络设备的驱动程序会等待内存缓冲区的空闲,当数据包需要发送时,内核会分配内存,并触发驱动程序的相关操作。 3. 驱动程序的生命周期 加载:驱动程序在内核启动时或通过命令行加载。驱动的注册使得内核能够识别并管理设备。 等待事件:驱动程序在注册之后会等待某些事件的发生。这些事件可以是来自用户空间的请求、硬件中断、定时器中断等。 响应事件:当触发条件满足时,驱动程序会执行相应的操作。例如,用户通过 read() 请求数据,驱动程序会读取硬件设备的数据并返回给用户空间;或者硬件中断发生,驱动程序会执行中断处理程序。 卸载:当驱动程序不再需要时(例如设备被移除,或不再使用),它会通过卸载过程从内核中注销并释放相关资源。 4. 总结 驱动程序的加载 只是一个注册过程,它将设备与驱动程序关联,并通知内核可以处理该设备的操作。 驱动程序 并不会持续运行,而是根据 触发条件(如用户程序发起的系统调用、硬件中断、定时中断等)来执行。例如,硬件中断、定时任务、用户发起的 I/O 操作等都可以触发内核中的驱动程序执行。 触发条件 是驱动程序执行的核心,通常这些条件是由外部事件(如硬件操作或用户请求)引发的。 因此,驱动程序的作用是 响应特定的事件,并在合适的时机执行相关操作,而不是持续占用 CPU 执行。 问题8:内核调度程序加载的CPU运行的代码是汇编写的吧,内核还有哪些代码是基于汇编实现的? 是的,内核调度程序以及其他一些关键部分的代码通常会使用 汇编语言 来实现。由于汇编语言能够直接与硬件进行交互,它可以提供更高效和低级的控制,尤其在操作系统内核的某些关键部分。以下是一些内核中的关键代码,它们通常是基于汇编语言实现的: 1. 内核调度程序的汇编实现 内核调度程序负责在不同进程之间进行上下文切换,这是一项至关重要的任务,涉及到保存和恢复 CPU 的上下文(如寄存器、程序计数器等)。其中,部分调度程序的实现确实使用了汇编语言来实现高效的上下文切换,特别是在保存和恢复进程状态时。 上下文切换的实现:上下文切换通常需要直接与硬件寄存器打交道,存储当前进程的寄存器值并恢复另一个进程的寄存器值。这一过程通常用汇编语言来实现,确保对 CPU 状态的精确控制。 汇编与 C 代码结合:虽然大部分调度算法和高层逻辑使用 C 语言实现,但具体的上下文切换过程会涉及到汇编指令,因为这些操作需要访问和修改底层的 CPU 寄存器。通常,汇编代码会嵌入到 C 代码中,或者由单独的汇编文件处理。 2. 中断处理和系统调用的汇编实现 中断处理:当硬件中断发生时,内核需要快速响应并切换到适当的中断处理程序。中断处理程序需要保存当前进程的上下文,处理硬件中断,然后根据需要进行调度。中断的进入和退出通常是通过汇编语言实现的,因为它涉及到修改CPU 状态(如修改程序计数器、堆栈指针等),这些操作必须直接通过汇编指令来完成。 系统调用:系统调用是用户空间与内核空间交互的桥梁,通常通过中断触发。对于 x86 架构,系统调用通过int 0x80(对于 x86)或syscall(对于 x86_64)指令来发起。系统调用的进入和退出也会涉及一些汇编代码来切换上下文(从用户态切换到内核态),并进行参数传递。 3. 内核引导(Bootstrapping) 内核引导过程是操作系统启动时非常重要的一个步骤。通常,内核的启动过程需要做很多低级操作,包括硬件初始化、内存管理的设置等。这些操作通常需要使用汇编语言来直接与硬件交互。 引导加载程序(如 GRUB、LILO 等)负责将内核加载到内存中,并启动内核的初步执行。内核的引导部分通常会使用汇编语言来初始化 CPU、设置堆栈、关闭中断等。 在现代操作系统中,启动过程通常会使用一部分汇编语言来进行硬件初始化(如设置分页、保护模式、GDT/IDT 等),然后再切换到 C 语言编写的内核代码。 4. 内核的低级硬件操作 内核中需要直接与硬件打交道的部分,如 内存管理、I/O 操作 和 硬件抽象层,通常会用汇编语言来实现一部分关键操作,特别是在一些操作必须直接与硬件交互时。 内存管理:内存管理是内核中的一个关键部分,涉及到分页、内存分配等。在某些平台上,分页表的创建和管理需要通过汇编语言进行直接控制,因为它涉及到 CPU 特性(如 CR3 寄存器的设置)和硬件地址的管理。 I/O 操作:操作系统与硬件设备的交互(如磁盘、网络设备、显示器等)通常需要使用汇编语言来执行低级 I/O 操作。例如,通过端口访问、DMA 操作等通常依赖于汇编语言实现。 5. 内核的同步机制(原子操作) 一些内核的同步机制需要通过汇编语言来实现,尤其是在多核处理器环境下,内核需要确保操作的原子性。这些操作包括原子加法、减法、比较和交换等,它们常常需要通过 CPU 提供的特殊指令来实现,譬如lock前缀指令(lock cmpxchg)来保证原子性。 例如,自旋锁 和 信号量 等机制的实现,可能需要使用汇编语言来确保在多处理器系统中对共享资源的安全访问。 6. 汇编在内核中的应用场景总结 可以总结为以下几个关键场景: 上下文切换:实现进程间的上下文切换,保存和恢复寄存器、程序计数器等。 中断处理:处理中断时切换到内核态,保存/恢复状态。 系统调用处理:用户态程序通过系统调用进入内核态的机制,涉及到上下文切换。 引导加载程序和初始化:内核引导过程中的硬件初始化和内存设置。 直接硬件交互:直接与硬件打交道时,汇编提供更精细的控制(如内存管理、I/O 操作等)。 原子操作和同步:提供多核环境下的原子操作支持,确保数据的安全性。 7. C 语言与汇编语言的结合 在现代操作系统中,内核的主要逻辑部分(如调度算法、内存管理、设备驱动等)通常使用 C 语言实现,因为 C 语言具有更好的可读性和维护性。汇编语言主要用于一些需要直接控制硬件、优化性能或实现特殊功能的低级操作。 汇编与 C 的结合:内核中的很多汇编代码通常会被嵌入到 C 语言代码中(例如,通过内联汇编或函数调用),以便 C 代码能够调用汇编实现的功能,同时也能确保代码的跨平台兼容性。 总结: 汇编语言 在操作系统内核中的作用非常重要,尤其是在 上下文切换、硬件中断处理、系统调用、内存管理、I/O 操作、同步机制 等关键部分。 内核中的 高层逻辑(如调度算法、进程管理等)通常由 C 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。

    01-02 233浏览
  • 一种垃圾内存回收机制(C语言设计)

    工程师们似乎认为编写垃圾回收机制是很难的,是一种只有少数智者和Hans Boehm(et al)才能理解的高深魔法。 我认为编写垃圾回收最难的地方就是内存分配,这和阅读 K&R 所写的 malloc 样例难度是相当的。 在开始之前有一些重要的事情需要说明一下: 第一,我们所写的代码是基于Linux Kernel的,注意是Linux Kernel而不是GNU/Linux。 第二,我们的代码是32bit的。 第三,请不要直接使用这些代码。我并不保证这些代码完全正确,可能其中有一些我还未发现的小的bug,但是整体思路仍然是正确的。 好了,让我们开始吧。 1 编写malloc 最开始,我们需要写一个内存分配器(memmory allocator),也可以叫做内存分配函数(malloc function)。 最简单的内存分配实现方法就是维护一个由空闲内存块组成的链表,这些空闲内存块在需要的时候被分割或分配。 当用户请求一块内存时,一块合适大小的内存块就会从链表中被移除并分配给用户。 如果链表中没有合适的空闲内存块存在,而且更大的空闲内存块已经被分割成小的内存块了或内核也正在请求更多的内存(译者注:就是链表中的空闲内存块都太小不足以分配给用户的情况)。 那么此时,会释放掉一块内存并把它添加到空闲块链表中。 在链表中的每个空闲内存块都有一个头(header)用来描述内存块的信息。我们的header包含两个部分,第一部分表示内存块的大小,第二部分指向下一个空闲内存块。 将头(header)内嵌进内存块中是唯一明智的做法,而且这样还可以享有字节自动对齐的好处,这很重要。 由于我们需要同时跟踪我们“当前使用过的内存块”和“未使用的内存块”,因此除了维护空闲内存的链表外,我们还需要一条维护当前已用内存块的链表(为了方便,这两条链表后面分别写为“空闲块链表”和“已用块链表”)。 我们从空闲块链表中移除的内存块会被添加到已用块链表中,反之亦然。 现在我们差不多已经做好准备来完成malloc实现的第一步了。但是再那之前,我们需要知道怎样向内核申请内存。 动态分配的内存会驻留在一个叫做堆(heap)的地方,堆是介于栈(stack)和BSS(未初始化的数据段-你所有的全局变量都存放在这里且具有默认值为0)之间的一块内存。 堆(heap)的内存地址起始于(低地址)BSS段的边界,结束于一个分隔地址(这个分隔地址是已建立映射的内存和未建立映射的内存的分隔线)。 为了能够从内核中获取更多的内存,我们只需提高这个分隔地址。为了提高这个分隔地址我们需要调用一个叫作 sbrk 的Unix系统的系统调用, 这个函数可以根据我们提供的参数来提高分隔地址,如果函数执行成功则会返回以前的分隔地址,如果失败将会返回-1。 利用我们现在知道的知识,我们可以创建两个函数:morecore()和add_to_free_list()。 当空闲块链表缺少内存块时,我们调用morecore()函数来申请更多的内存。由于每次向内核申请内存的代价是昂贵的,我们以页(page-size)为单位申请内存。 页的大小在这并不是很重要的知识点,不过这有一个很简单解释:页是虚拟内存映射到物理内存的最小内存单位。 接下来我们就可以使用add_to_list()将申请到的内存块加入空闲块链表。 现在我们有了两个有力的函数,接下来我们就可以直接编写malloc函数了。 我们扫描空闲块链表当遇到第一块满足要求的内存块(内存块比所需内存大即满足要求)时,停止扫描,而不是扫描整个链表来寻找大小最合适的内存块,我们所采用的这种算法思想其实就是首次适应(与最佳适应相对)。 注意:有件事情需要说明一下,内存块头部结构中size这一部分的计数单位是块(Block),而不是Byte。 注意这个函数的成功与否,取决于我们第一次使用时是否使 freep = &base 。这点我们会在初始化函数中进行设置。 尽管我们的代码完全没有考虑到内存碎片,但是它能工作。既然它可以工作,我们就可以开始下一个有趣的部分-垃圾回收! 2 标记与清扫 我们说过垃圾回收器会很简单,因此我们尽可能的使用简单的方法:标记和清除方式。这个算法分为两个部分: 首先,我们需要扫描所有可能存在指向堆中数据(heap data)的变量的内存空间并确认这些内存空间中的变量是否指向堆中的数据。 为了做到这点,对于可能内存空间中的每个字长(word-size)的数据块,我们遍历已用块链表中的内存块。 如果数据块所指向的内存是在已用链表块中的某一内存块中,我们对这个内存块进行标记。 第二部分是,当扫描完所有可能的内存空间后,我们遍历已用块链表将所有未被标记的内存块移到空闲块链表中。 现在很多人会开始认为只是靠编写类似于malloc那样的简单函数来实现C的垃圾回收是不可行的,因为在函数中我们无法获得其外面的很多信息。 例如,在C语言中没有函数可以返回分配到堆栈中的所有变量的哈希映射。但是只要我们意识到两个重要的事实,我们就可以绕过这些东西: 第一,在C中,你可以尝试访问任何你想访问的内存地址。因为不可能有一个数据块编译器可以访问但是其地址却不能被表示成一个可以赋值给指针的整数。 如果一块内存在C程序中被使用了,那么它一定可以被这个程序访问。这是一个令不熟悉C的编程者很困惑的概念,因为很多编程语言都会限制程序访问虚拟内存,但是C不会。 第二,所有的变量都存储在内存的某个地方。这意味着如果我们可以知道变量们的通常存储位置,我们可以遍历这些内存位置来寻找每个变量的所有可能值。 另外,因为内存的访问通常是字(word-size)对齐的,因此我们仅需要遍历内存区域中的每个字(word)即可。 局部变量也可以被存储在寄存器中,但是我们并不需要担心这些因为寄存器经常会用于存储局部变量,而且当函数被调用的时候他们通常会被存储在堆栈中。 现在我们有一个标记阶段的策略:遍历一系列的内存区域并查看是否有内存可能指向已用块链表。编写这样的一个函数非常的简洁明了: 为了确保我们只使用头(header)中的两个字长(two words)我们使用一种叫做标记指针(tagged pointer)的技术。 利用header中的next指针指向的地址总是字对齐(word aligned)这一特点,我们可以得出指针低位的几个有效位总会是0。 因此我们将next指针的最低位进行标记来表示当前块是否被标记。 现在,我们可以扫描内存区域了,但是我们应该扫描哪些内存区域呢?我们要扫描的有以下这些: BBS(未初始化数据段)和初始化数据段。这里包含了程序的全局变量和局部变量。因为他们有可能应用堆(heap)中的一些东西,所以我们需要扫描BSS与初始化数据段,已用的数据块。 当然,如果用户分配一个指针来指向另一个已经被分配的内存块,我们不会想去释放掉那个被指向的内存块。堆栈。因为堆栈中包含所有的局部变量,因此这可以说是最需要扫描的区域了。 我们已经了解了关于堆(heap)的一切,因此编写一个mark_from_heap函数将会非常简单: 幸运的是对于BSS段和已初始化数据段,大部分的现代unix链接器可以导出 etext 和 end 符号。etext符号的地址是初始化数据段的起点(the last address past the text segment,这个段中包含了程序的机器码),end符号是堆(heap)的起点。 因此,BSS和已初始化数据段位于 &etext 与 &end 之间。这个方法足够简单,当不是平台独立的。 堆栈这部分有一点困难。堆栈的栈顶非常容易找到,只需要使用一点内联汇编即可,因为它存储在 sp 这个寄存器中。但是我们将会使用的是 bp 这个寄存器,因为它忽略了一些局部变量。 寻找堆栈的的栈底(堆栈的起点)涉及到一些技巧。出于安全因素的考虑,内核倾向于将堆栈的起点随机化,因此我们很难得到一个地址。 老实说,我在寻找栈底方面并不是专家,但是我有一些点子可以帮你找到一个准确的地址。 一个可能的方法是,你可以扫描调用栈(call stack)来寻找 env 指针,这个指针会被作为一个参数传递给主程序。 另一种方法是从栈顶开始读取每个更大的后续地址并处理inexorible SIGSEGV。 但是我们并不打算采用这两种方法中的任何一种,我们将利用linux会将栈底放入一个字符串并存于proc目录下表示该进程的文件中这一事实。这听起来很愚蠢而且非常间接。 值得庆幸的是,我并不感觉这样做是滑稽的,因为它和Boehm GC中寻找栈底所用的方法完全相同。 现在我们可以编写一个简单的初始化函数。 在函数中,我们打开proc文件并找到栈底。栈底是文件中第28个值,因此我们忽略前27个值。Boehm GC和我们的做法不同的是他仅使用系统调用来读取文件来避免让stdlib库使用堆(heap),但是我们并不在意这些。 现在我们知道了每个我们需要扫描的内存区域的位置,所以我们终于可以编写显示调用的回收函数了: 朋友们,所有的东西都已经在这了,一个用C为C程序编写的垃圾回收器。这些代码自身并不是完整的,它还需要一些微调来使它可以正常工作,但是大部分代码是可以独立工作的。 3 总结 一开始就打算编写完整的程序是很困难的,你编程的唯一算法就是分而治之。 先编写内存分配函数,然后编写查询内存的函数,然后是清除内存的函数。最后将它们合在一起。 当你在编程方面克服这个障碍后,就再也没有困难的实践了。你可能有一个算法不太了解,但是任何人只要有足够的时间就肯定可以通过论文或书理解这个算法。 如果有一个项目看起来令人生畏,那么将它分成完全独立的几个部分。 你可能不懂如何编写一个解释器,但你绝对可以编写一个分析器,然后看一下你还有什么需要添加的,添上它。相信自己,终会成功!

    2024-12-18 182浏览
正在努力加载更多...
EE直播间
更多
广告