一、提高CPU使用效率 话说我只要见到满篇都是delay_ms()的程序就会头疼,动辄十几个ms几十个ms的软件延时是对CPU资源的巨大浪费,宝贵的CPU时间都浪费在了NOP指令上。 那种为了等待一个管脚电平跳变或者一个串口数据,让整个程序都不动的情况也让我非常纠结,如果事件一直不发生电平跳变,你要等到世界末日么? 如果应用状态机编程思想,程序只需要用全局变量记录下工作状态,就可以转头去干别的工作了,当然忙完那些活儿之后要再看看工作状态有没有变化。 只要目标事件(定时未到、电平没跳变、串口数据没收完)还没发生,工作状态就不会改变,程序就一直重复着“查询—干别的—查询—干别的”这样的循环,这样CPU就闲不下来了。 这种处理方法的实质就是在程序等待事件的过程中间隔性地插入一些有意义的工作,好让CPU不是一直无谓地等待。 二、逻辑完备性 逻辑完备性是状态机编程最大的优点。 不知道大家有没有用C语言写过计算器的小程序,我很早以前写过,写出来一测试,那个惨不忍睹啊! 当我规规矩矩的输入算式的时候,程序可以得到正确的计算结果,但要是故意输入数字和运算符号的随意组合,程序总是得出莫名其妙的结果。 后来我试着思维模拟一下程序的工作过程,正确的算式思路清晰,流程顺畅,可要碰上了不规矩的式子,走着走着我就晕菜了,那么多的标志位,那么多的变量,变来变去,最后直接分析不下去了。 很久之后我认识了状态机,才恍然明白,当时的程序是有逻辑漏洞的。 如果把这个计算器程序当做是一个反应式系统,那么一个数字或者运算符就可以看做一个事件,一个算式就是一组事件组合。 对于一个逻辑完备的反应式系统,不管什么样的事件组合,系统都能正确处理事件,而且系统自身的工作状态也一直处在可知可控的状态中。 反过来,如果一个系统的逻辑功能不完备,在某些特定事件组合的驱动下,系统就会进入一个不可知不可控的状态,与设计者的意图相悖。 状态机就能解决逻辑完备性的问题。 状态机是一种以系统状态为中心,以事件为变量的设计方法,它专注于各个状态的特点以及状态之间相互转换的关系。 状态的转换恰恰是事件引起的,那么在研究某个具体状态的时候,我们自然而然地会考虑任何一个事件对这个状态有什么样的影响。 这样,每一个状态中发生的每一个事件都会在我们的考虑之中,也就不会留下逻辑漏洞。 这样说也许大家会觉得太空洞,实践出真知,某天如果你真的要设计一个逻辑复杂的程序,会觉得状态机真香! 三、程序结构清晰 用状态机写出来的程序的结构是非常清晰的。 程序员最痛苦的事儿莫过于读别人写的代码,如果代码不是很规范,而且手里还没有流程图,读代码会让人晕了又晕,只有顺着程序一遍又一遍的看,很多遍之后才能隐约地明白程序大体的工作过程。 有流程图会好一点,但是如果程序比较大,流程图也不会画得多详细,很多细节上的过程还是要从代码中理解。 相比之下,用状态机写的程序要好很多,拿一张标准的UML状态转换图,再配上一些简明的文字说明,程序中的各个要素一览无余。 程序中有哪些状态,会发生哪些事件,状态机如何响应,响应之后跳转到哪个状态,这些都十分明朗,甚至许多动作细节都能从状态转换图中找到。 可以毫不夸张的说,有了UML状态转换图,程序流程图写都不用写。
常见的按键判定程序,如正点原子按键例程,只能判定单击事件,对于双击、长按等的判定逻辑较复杂,且使用main函数循环扫描的方式,容易被阻塞,或按键扫描函数会阻塞其他程序的执行。 使用定时器设计状态机可以规避这一问题。 功能介绍 本程序功能: 使用定时器状态机实现按键单击、双击、长按、连按功能。 消抖时间可调,长按时间可调,双击判定时间可调,连按单击间隔可调,可选择使能长按、连按、双击功能,无延时不阻塞,稳定触发。 移植只需修改读IO函数,结构体初始化和宏定义时间参数即可。 注: 在定时器状态机判定产生事件标志,在主函数处理并清除事件标志。 单击是最基本事件,除以下情况外,经过消抖后,在按键释放时触发单击事件。 使能长按后,若按键按下时间大于长按判定时间,则释放时触发长按事件,若不使能,释放时触发单击事件。 使能连按后,按住按键时持续触发连按事件,可自定义等效为单击事件。 无论是否使能长按,按键长按不释放,先经过长按判定时间触发第一次连按事件,然后循环进行连按计时,每次计时结束后都会触发一次连按事件,直到按键释放,触发长按事件(使能长按),或单击事件(不使能长按)。 使能双击后,若两次单击行为之间,由释放到按下的时间小于双击判定时间,则第一次单击行为释放时不触发单击事件,第二次单击行为在释放时触发双击事件。 一次单击行为在双击判定时间内无按键按下动作,之后才触发单击事件。 无论是否使能长按,若上述第二次行为是长按,则第二次释放时不会触发双击事件,而是到达长按判定时间后先触发属于第一次的单击事件,然后在第二次释放按键时触发长按事件(使能长按),或单击事件(不使能长按)。 代码 头文件 my_key.h #ifndef ___MY_KEY_H__#define ___MY_KEY_H__#include "main.h"#define ARR_LEN(arr) ((sizeof(arr)) / (sizeof(arr[0]))) //数组大小宏函数 #define KEY_DEBOUNCE_TIME 10 //消抖时间#define KEY_LONG_PRESS_TIME 500 //长按判定时间#define KEY_QUICK_CLICK_TIME 100 //连按时间间隔#define KEY_DOUBLE_CLICK_TIME 200 //双击判定时间#define KEY_PRESSED_LEVEL 0 //按键被按下时的电平 //按键动作typedef enum{ KEY_Action_Press, //按住 KEY_Action_Release, //松开} KEY_Action_TypeDef; //按键状态typedef enum{ KEY_Status_Idle, //空闲 KEY_Status_Debounce, //消抖 KEY_Status_ConfirmPress, //确认按下 KEY_Status_ConfirmPressLong, //确认长按 KEY_Status_WaitSecondPress, //等待再次按下 KEY_Status_SecondDebounce, //再次消抖 KEY_Status_SecondPress, //再次按下} KEY_Status_TypeDef; //按键事件typedef enum{ KEY_Event_Null, //空事件 KEY_Event_SingleClick, //单击 KEY_Event_LongPress, //长按 KEY_Event_QuickClick, //连击 KEY_Event_DoubleClick, //双击} KEY_Event_TypeDef; //按键模式使能选择typedef enum{ KEY_Mode_OnlySinge = 0x00, //只有单击 KEY_Mode_Long = 0x01, //单击长按 KEY_Mode_Quick = 0x02, //单击连按 KEY_Mode_Long_Quick = 0x03, //单击长按连按 KEY_Mode_Double = 0x04, //单击双击 KEY_Mode_Long_Double = 0x05, //单击长按双击 KEY_Mode_Quick_Double = 0x06, //单击连按双击 KEY_Mode_Long_Quick_Double = 0x07, //单击长按连按双击} KEY_Mode_TypeDef; //按键配置typedef struct{ uint8_t KEY_Label; //按键标号 KEY_Mode_TypeDef KEY_Mode; //按键模式 uint16_t KEY_Count; //按键按下计时 KEY_Action_TypeDef KEY_Action; //按键动作,按下或释放 KEY_Status_TypeDef KEY_Status; //按键状态 KEY_Event_TypeDef KEY_Event; //按键事件} KEY_Configure_TypeDef; extern KEY_Configure_TypeDef KeyConfig[];extern KEY_Event_TypeDef key_event[]; void KEY_ReadStateMachine(KEY_Configure_TypeDef *KeyCfg); #endif 源文件 my_key.c #include "my_key.h" static uint8_t KEY_ReadPin(uint8_t key_label){ switch (key_label) { case 0: return (uint8_t)HAL_GPIO_ReadPin(K0_GPIO_Port, K0_Pin); case 1: return (uint8_t)HAL_GPIO_ReadPin(K1_GPIO_Port, K1_Pin); case 2: return (uint8_t)HAL_GPIO_ReadPin(K2_GPIO_Port, K2_Pin); case 3: return (uint8_t)HAL_GPIO_ReadPin(K3_GPIO_Port, K3_Pin); case 4: return (uint8_t)HAL_GPIO_ReadPin(K4_GPIO_Port, K4_Pin); // case X: // return (uint8_t)HAL_GPIO_ReadPin(KX_GPIO_Port, KX_Pin); } return 0;} KEY_Configure_TypeDef KeyConfig[] = { {0, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {1, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {2, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {3, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {4, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, // {X, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null},}; KEY_Event_TypeDef key_event[ARR_LEN(KeyConfig)] = {KEY_Event_Null}; //按键事件//按键状态处理void KEY_ReadStateMachine(KEY_Configure_TypeDef *KeyCfg){ static uint16_t tmpcnt[ARR_LEN(KeyConfig)] = {0}; //按键动作读取 if (KEY_ReadPin(KeyCfg->KEY_Label) == KEY_PRESSED_LEVEL) KeyCfg->KEY_Action = KEY_Action_Press; else KeyCfg->KEY_Action = KEY_Action_Release; //状态机 switch (KeyCfg->KEY_Status) { //状态:空闲 case KEY_Status_Idle: if (KeyCfg->KEY_Action == KEY_Action_Press) //动作:按下 { KeyCfg->KEY_Status = KEY_Status_Debounce; //状态->消抖 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:默认动作,释放 { KeyCfg->KEY_Status = KEY_Status_Idle; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:消抖 case KEY_Status_Debounce: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPress; //状态->确认按下 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间未到 { KeyCfg->KEY_Count++; //消抖计数 KeyCfg->KEY_Status = KEY_Status_Debounce; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,消抖时间未到,判定为抖动 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:确认按下 case KEY_Status_ConfirmPress: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间已到 { KeyCfg->KEY_Count = KEY_QUICK_CLICK_TIME; //计数置数,生成第一次连按事件 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->确认长按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间未到 { KeyCfg->KEY_Count++; //长按计数 KeyCfg->KEY_Status = KEY_Status_ConfirmPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:长按时间未到,释放 { if ((uint8_t)(KeyCfg->KEY_Mode) & 0x04) //双击模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->等待再按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //非双击模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } } break; //状态:确认长按 case KEY_Status_ConfirmPressLong: if (KeyCfg->KEY_Action == KEY_Action_Press) //动作:保持按下 { if ((uint8_t)KeyCfg->KEY_Mode & 0x02) //连按模式 { if (KeyCfg->KEY_Count >= KEY_QUICK_CLICK_TIME) //连按间隔时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_QuickClick; //事件->连按**** } else //连按间隔时间未到 { KeyCfg->KEY_Count++; //连按计数 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } } else //非连按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } } else //动作:长按下后释放 { if ((uint8_t)KeyCfg->KEY_Mode & 0x01) //长按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_LongPress; //事件->长按**** } else //非长按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } } break; //状态:等待是否再次按下 case KEY_Status_WaitSecondPress: if ((KeyCfg->KEY_Action != KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DOUBLE_CLICK_TIME)) //动作:保持释放,双击等待时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } else if ((KeyCfg->KEY_Action != KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DOUBLE_CLICK_TIME)) //动作:保持释放,双击等待时间未到 { KeyCfg->KEY_Count++; //双击等待计数 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:双击等待时间内,再次按下 { tmpcnt[KeyCfg->KEY_Label] = KeyCfg->KEY_Count; //计数保存 KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_SecondDebounce; //状态->再次消抖 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:再次消抖 case KEY_Status_SecondDebounce: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_SecondPress; //状态->确认再次按下 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间未到 { KeyCfg->KEY_Count++; //消抖计数 KeyCfg->KEY_Status = KEY_Status_SecondDebounce; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,消抖时间未到,判定为抖动 { KeyCfg->KEY_Count = KeyCfg->KEY_Count + tmpcnt[KeyCfg->KEY_Label]; //计数置数 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->等待再按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:再次按下 case KEY_Status_SecondPress: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->确认长按 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->先响应单击 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间未到 { KeyCfg->KEY_Count++; //计数 KeyCfg->KEY_Status = KEY_Status_SecondPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,长按时间未到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_DoubleClick; //事件->双击 } break; } if (KeyCfg->KEY_Event != KEY_Event_Null) //事件记录 key_event[KeyCfg->KEY_Label] = KeyCfg->KEY_Event;} 定时器中断调用和主函数使用 中断周期为1ms //调用uint32_t tim_cnt = 0;void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){ if (htim->Instance == htim1.Instance) { tim_cnt++; if (tim_cnt % 1 == 0) // 1ms { KEY_ReadStateMachine(&KeyConfig[0]); KEY_ReadStateMachine(&KeyConfig[1]); KEY_ReadStateMachine(&KeyConfig[2]); KEY_ReadStateMachine(&KeyConfig[3]); KEY_ReadStateMachine(&KeyConfig[4]); } }} int main(void){ while (1) { if (key_event[1] == KEY_Event_SingleClick) //单击 { something1(); } if (key_event[2] == KEY_Event_LongPress) //长按 { something2(); } if ((key_event[3] == KEY_Event_QuickClick) || (key_event[3] == KEY_Event_SingleClick)) //连按 { something3(); } if (key_event[4] == KEY_Event_DoubleClick) //双击 { something4(); } memset(key_event, KEY_Event_Null, sizeof(key_event)); //清除事件 }}
1 C++ 那些事 这是一个适合初学者从入门到进阶的仓库,解决了面试者与学习者想要深入 C++及如何入坑 C++的问题。 除此之外,本仓库拓展了更加深入的源码分析,多线程并发等的知识,是一个比较全面的 C++ 学习从入门到进阶提升的仓库。 项目地址:https://github.com/Light-City/CPlusPlusThings 2 C++实现的各种算法的开源实现的集合 这个存储库是C++实现的各种算法的开源实现的集合,算法涵盖了计算机科学、数学和统计学、数据科学、机器学习、工程等领域的各种主题。 这些实现和相关文档旨在为教育者和学生提供学习资源。因此,对于同一个目标,可以找到多个实现,但使用不同的算法策略和优化。 开源地址:https://github.com/TheAlgorithms/C-Plus-Plus 3 C++ 实现的截图软件 Demo 仿 QQ 截图,C++ 实现的截图软件 Demo。 项目地址:https://github.com/wanttobeno/Screenshot 4 基于 C++ 实现的 HTTP 服务器 一款可运行的基于 C++ 实现的 HTTP 服务器,基于《TCPIP网络编程》和《Linux高性能服务器编程》实现的服务器项目。 项目地址:https://github.com/forthespada/MyPoorWebServer 5 WebFileServer文件服务器 不少同学学完C++和Linux后不知道做什么项目,所以很多同学都去做webserver,其实大家可以改进下webserver项目,比如实现一个文件服务器支持文件上传下载,后续可以再添加注册/登录/个人文件管理/文件分享等等功能,这样就可以写到简历里。 项目地址:https://www.bilibili.com/video/BV1bGkPYzExW/ 6 用于 C++ 的图形用户界面库 Dear ImGui 是一个用于 C++ 的无膨胀图形用户界面库,它输出优化的顶点缓冲区,你可以在启用的 3D 应用程序中随时渲染这些缓冲区,特别适合集成到游戏引擎(用于工具)、实时 3D 应用程序、全屏应用程序、嵌入式应用程序或操作系统功能非标准控制台上的任何应用程序中。 项目地址:https://github.com/ocornut/imgui Dear ImGui 的核心是独立的,不需要特定的构建过程,你可以将 .cpp 文件添加到现有项目中。 ImGui::Text("Hello, world %d", 123); if (ImGui::Button("Save")) MySaveFunction(); ImGui::InputText("string", buf, IM_ARRAYSIZE(buf)); ImGui::SliderFloat("float", &f, 0.0f, 1.0f); Result:深色风格(左),浅色风格(右)/字体:Roboto-Medium,16px 调用 ImGui::ShowDemoWindow() 函数将创建一个展示各种功能和示例的演示窗口 7 仿微信聊天软件--QT客户端+Linux C++后端 这个项目类似微信一样,可以加好友,可以一对一聊天,也可以群聊,并且还支持Linux C++后端程序。 项目地址:https://www.bilibili.com/video/BV1XukbYmEY5/ 8 手撸STL STL是C++的重要组件,C++开发几乎没有不使用STL的,然而光会用是不够的,还需要明白它的实现原理。 智能指针 vector array stack queue deque map set string 这些常用的数据结构最好自己都实现一遍。 水平高的可以直接参考gcc源码(https://github.com/gcc-mirror/gcc) 刚入门的朋友不建议看源码,费时费力又不能提升开发能力,这里推荐大家看看这份C++ STL面试题,包含STL中不同容器的实现原理。 地址:https://www.bilibili.com/video/BV1Yoz2YZEgV/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 9 手撸Json Json是特别常用的序列化数据结构(https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html) 很多人面试的时候被问到过如何实现一个Json。大家可以通过手撸一个Json来提高自己的C++水平哈。 水平高的可以直接参考这个C++Linux项目-Web多人聊天,可以通过该项目掌握MySQL+Redis+Websocket+Json等知识的运用,这个项目还可以根据自己的技术栈进行进一步扩展,形成自己独一无二的项目。 项目地址:https://www.bilibili.com/video/BV1iYtrezEkA/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 10 C++音视频项目--屏幕录制软件 想往音视频开发方向发展的同学可以看看这个项目,这个屏幕录制的项目支持区域录制、全屏录制,支持缩放录制的视频分辨率等 项目地址:https://www.bilibili.com/video/BV1CHChY3EMb/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 11 操作系统 这个在网上有专门的课程,推荐大家看看MIT6.S081课程。课程主要是操作系统的设计与实现,以及它们作为系统编程基础的应用。主要内容包括虚拟内存、文件系统、线程、上下文切换、内核、中断、系统调用、进程间通信、软件与硬件之间的协调与交互等。使用适用于RISC-V架构的多处理器操作系统xv6来说明这些主题。个人实验任务包括扩展xv6操作系统,例如支持复杂的虚拟内存特性和网络功能。 MIT6.S081课程资料:https://www.bilibili.com/video/BV1sUrWYXEJg/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 12 聊天服务器 smallchat(C实现) 项目简介:smallchat 是一个简单的基于 C 语言实现的聊天服务器和客户端项目。通过这个项目,开发者可以学习和掌握基本的网络编程技术,理解聊天应用程序的核心实现原理。smallchat 项目代码量小,结构清晰,非常适合初学者学习和实践网络编程。 **涉及技术:**C 语言、Socket 编程、多线程编程、网络协议设计与实现、终端控制、非阻塞 I/O 项目亮点: Socket 编程:通过 Socket 编程实现服务器与客户端之间的通信,展示了如何使用 C 语言进行网络编程。 多线程处理:使用多线程技术处理多个客户端连接,展示了并发编程的能力。 基本聊天功能:实现了一个简单的聊天服务器和客户端,包括消息的发送和接收。 简单命令处理:实现基本的命令处理功能,如设置昵称等,展示了如何在聊天应用中处理用户命令。 终端控制:通过设置终端为原始模式,展示了如何控制和处理终端输入。 模块化设计:代码结构清晰,模块化设计,使得项目易于理解和扩展。 源码下载链接:https://github.com/antirez/smallchat 13 RPC 框架 项目简介:实现一个远程过程调用(RPC)框架,使不同主机上的程序能够通过网络调用彼此的函数。这个项目将帮助你掌握网络通信、序列化、多线程编程和协议设计的核心概念,展示你在设计和实现高性能分布式系统方面的能力。 涉及技术:C++、网络编程、序列化/反序列化、多线程编程、协议设计、数据一致性等。 项目亮点: 并发处理:使用多线程技术处理多个客户端请求,展示你在并发编程方面的掌握。 序列化/反序列化:实现高效的数据序列化和反序列化,确保数据在网络传输中的完整性和效率。 协议设计:设计并实现高效的通信协议,确保数据在客户端和服务器之间的高效传输。 数据一致性:确保远程调用的请求和响应在分布式环境下的一致性和可靠性。 分布式架构设计:实现跨主机的远程过程调用,展示你对分布式系统架构的理解和应用能力。 高可用性:通过实现连接池和重试机制,确保RPC服务在网络波动或节点故障时的高可用性。 高性能:优化网络通信和数据处理效率,展示你在高性能系统设计方面的能力。 源码下载链接:https://github.com/Gooddbird/tinyrpc tinyrpc 项目总览: tinyrpc RPC调用执行示意图: 14 分享一些做项目的心得 1. 在Linux环境编写项目: 企业级的项目大多部署在Linux服务器上,所以你得熟悉Linux环境。我推荐使用Ubuntu,并且需要熟练掌握编译工具链如gcc/g++、make和makefile等,这样在编译和部署项目时能游刃有余。 2. 利用已有项目: 不一定要从0到1实现一个项目,这样难度太大(大佬除外)。你可以先把别人优秀的项目下载下来,自己把代码跑起来,配置环境、跑代码、看结果,然后研究别人的代码实现了什么功能、如何实现的,是否可以优化一下,加一些自己的独特思考。这样你就有了丰富的内容可以和面试官聊。 3. 项目实战经验: 举个例子,我曾在简历上展示过一个项目,是在实现HTTP服务器的基础上加了在线大整数运算功能。当时我是从0到1实现了一个MiniMuduo作为服务器框架,并在其基础上实现了HTTP服务器,还参考了Tinyhttpd项目,加入了CGI技术,支持万位以上数字的四则运算。 4. 项目中的思考和优化 在做项目时,一定要有自己的思考。比如,做一个HTTP服务器项目,一定要使用wrk等压测工具进行性能测试,优化其QPS(每秒查询率)。面试官肯定会问很多关于项目的细节问题,比如项目难点、HTTP服务器的性能如何、QPS多少、如何优化提升QPS、性能瓶颈在哪、为什么使用CGI技术、CGI是什么、解决了什么问题等等。
一. FOC之使用Cordic算法求解sin/cos 在进行坐标变换的时候,需要计算角度的正余弦值,而在FPGA中是不能直接进行求解的,需要采用其它的方式进行求解。最常使用的方法有如下两种: 基于ROM的查找表方式: 首先在PC上使用python等高级语言将一个周期内的正余弦值全部计算出来,角度的分辨率根据实际需求来确定,分辨率越精细,那么需要存储ROM的深度就越深,反之约小,然后将计算出来的正余弦值进行一个扩大取整保留数据精度,最后按照角度顺序依次存入ROM中。很明显,通过这种方式计算正余弦值所需要的时钟周期特别短,消耗FPGA的存储资源大。 基于Cordic算法计算: Cordic算法并不直接求解正余弦值,而且通过旋转逼近的思想来进行拟合正余弦函数。该算法拟合的精度非常高,因而被广泛应用于计算机图形学、数字信号处理等领域。 Cordic算法运算过程中,只设计到移位和加减运算,这种运算是非常适合于FPGA的,从面积和计算速度两方面进行综合考虑,最终选择占用面积较小、计算速度略低的Cordic算法来求解sin/cos函数值。 首先如下图所示,假设单位圆上有任意两点Q和P,它们之间的角度关系已知,则它们的XY轴坐标可以表示如下: 将Q点的坐标公式进行展开,然后再将P点的坐标公式代入其中可得: 为了统一变量类型,将cos函数作为公共相提取出来,可以得到如下形式: 可以看出,由P点旋转至Q点后,Q点的最终表达式如上所示,这种形式便是Cordic算法旋转的基本公式了。如果将旋转初始点P设置为一个特殊位置:X轴上,那么很明显Q点的坐标值就是对应旋转角度的正余弦值。 有了上述基本推论,就可以开始真正的进行旋转拟合了。P点直接一步旋转到Q点,肯定是不可取的。如果将P点经过多次旋转,每一次旋转的角度均为特殊角度,tan函数对应的角度值如下,这样就将乘法运算巧妙的转换成了左移运算。 每一次旋转迭代的公式如下,每一次旋转的公式里面还包括了cos函数,这也是不方便在FPGA内计算的,观察表达式可以知道,cos函数在这里起到的作用是对坐标值起到等比例缩放的作用,并不会影响旋转的点对应向量的方向。 所以可以将每一次旋转过程中的cos函数提取出来,最后进行运算,这样就不用参与到每次的旋转计算中去,由于旋转的角度是已知的,所以当确定好旋转次数后,可以将这部分运算提取计算出来,作为一个系数K,K的表达式如下图所示。 接下来就是需要研究每次旋转对应的角度值了,角度对应的tan函数值是已知的,可以通过Python直接求解出对应的角度,然后汇总成如下表格: 通过上表可以看出,当旋转到16次的时候,角度的误差只有千分之一了,而cosβ和K的值均趋近于一个定值,故Cordic旋转拟合是收敛的。在旋转的过程中,可能会出现旋转角度大于目标角度的情况,所以在旋转的过程中还需要增加一个变量d来控制旋转的方向,另外用z来表示旋转到的角度值,最终的旋转迭代公式如下: 最终目标角度的正余弦值如下: FPGA内部实现的过程中,需要对旋转角度值以及K值扩大2^16次方,然后取整,为的是在保持计算精度的情况下,免去数据的小数部分,这些都是固定值,不会根据目标角度的变化而变化,可以在程序中直接定义出来,如下图所示。 另外还要一个关键点需要注意的是迭代公式中使用的是tan函数,需要对目标角度限制在-90°到90°范围内,所以在目标角度输入模块之后,需要先对角度进行一个象限变换,为了处理的方便,本设计将目标角度变换到第一象限内,也就是0°到90°,如下图所示,象限变换不会影响正余弦数组的大小,只会影响其数值的符号,所以在迭代完成后,根据需要对坐标点进行取反运行即可。
我彻底服了,大牛讲解信号与系统(通俗易懂) (2015-10-13 21:22:36) 转载▼ 分类: 电力电子技术 第一课什么是卷积卷积有什么用什么是傅利叶变换什么是拉普拉斯变换 引子 很多朋友和我一样,工科电子类专业,学了一堆...
R 语言 R 是一种用于统计计算和图的语言及环境。它是一个 GNU 项目,与贝尔实验室的 John Chambers 及其同事开发的 S 语言及环境类似。R 可以视为 S 的一种不同实现。二者存在一些重要差异,但使用 S 写的很多代码...
RTCP Packet Send and Receive Rules: 发送和接收 RTCP 包的规则在此列出。允许在多播环境或多点单播环境中运行的实现必须满足第 6.2 节中的要求。这样的实现可以使用本节定义的算法来满足这些要求,或者可以使用其他算法,只要其性能等同或更优即可。在受限于两方单播操作的实现中,仍然应使用 RTCP 传输间隔的随机化,以避免在相同环境中运行的多个实例产生意外的同步,但可以省略第 6.3.3、6.3.6 和 6.3.7 节中的“计时器再考虑(timer reconsideration)”和“反向再考虑(reverse reconsideration)”算法。 为了执行这些规则,会话参与者必须维护几项状态: tp:上一次发送 RTCP 包的时间; tc:当前时间; tn:计划发送下一个 RTCP 包的时间; pmembers:上次重新计算 tn 时估算的会话成员数量; members:当前估算的会话成员数量; senders:当前估算的会话发送者数量; rtcp_bw:RTCP 目标带宽,即该会话所有成员发送 RTCP 包所使用的总带宽,单位为每秒字节数。这个值将是应用启动时提供的“会话带宽”参数的一个指定比例; we_sent:一个标志,如果应用自前两次 RTCP 报告后已发送数据,则该标志为真; avg_rtcp_size:该参与者发送和接收的 RTCP 包的平均复合大小(以字节为单位)。该大小包括较低层传输和网络协议头(如 UDP 和 IP),具体见第 6.2 节的说明; initial:一个标志,如果应用尚未发送任何 RTCP 包,则该标志为真。 这些规则中的许多使用了“计算出的包传输间隔”。此间隔将在接下来的部分中进行描述。 Computing the RTCP Transmission Interval: 为了保持可扩展性,会话参与者发送包的平均间隔应随着组的大小而变化。这个间隔称为“计算出的间隔(calculated interval)”。它是通过结合上面描述的多个状态来获得的。计算出的间隔 TTT 的确定方法如下: 如果发送者的数量小于或等于成员(members)数量的 25%,则间隔取决于该参与者是否为发送者(依据 we_sent 的值)。若参与者为发送者(we_sent 为真),则常数 C 设为平均 RTCP 包大小(avg_rtcp_size)除以 RTCP 带宽(rtcp_bw)的 25%,常数 n 设为发送者的数量。如果 we_sent 为假,常数 C 设为平均 RTCP 包大小除以 RTCP 带宽的 75%,常数 n 设为接收者的数量(members - senders)。如果发送者数量超过 25%,则发送者和接收者一起处理。常数 C 设为平均 RTCP 包大小除以总的 RTCP 带宽,n 设为成员总数。正如第 6.2 节所述,一个 RTP 配置文件可以规定 RTCP 带宽由两个独立的参数明确定义(分别称为 S 和 R),分别用于发送者和非发送者。在这种情况下,25% 的比例为 S/(S+R),75% 的比例为 R/(S+R)。注意,如果 R 为零,则发送者的比例永远不会超过 S/(S+R),实现时必须避免除零的情况。 如果参与者尚未发送任何 RTCP 包(变量 initial 为真),则常数 Tmin 设为 2.5 秒,否则设为 5 秒。 确定性的计算间隔 Td 设为 max(Tmin, n*C)。 计算出的间隔 T 设为 0.5 到 1.5 倍的确定性计算间隔之间的一个均匀分布的数值。 为了补偿计时器再考虑算法使 RTCP 带宽收敛于低于预期平均值的情况,最终得到的 T 值除以 e^-3/2 = 1.21828。 这个过程得出的间隔是随机的,但平均来看,至少 25% 的 RTCP 带宽分配给发送者,其余分配给接收者。如果发送者数量超过成员的四分之一,则该过程平均上在所有参与者之间平分带宽 Initialization: 加入会话后,参与者将 tp 初始化为 0,tc 初始化为 0,senders 初始化为 0,pmembers 初始化为 1,members 初始化为 1,we_sent 初始化为 false,rtcp_bw 初始化为会话带宽的指定比例,initial 初始化为 true,avg_rtcp_size 初始化为应用程序稍后将构建的第一个 RTCP 包的预计大小。随后计算出间隔 T,并将第一个包的计划发送时间设定为 tn = T。这意味着设置一个传输计时器,使其在时间 T 到期。请注意,应用程序可以使用任何所需的方法来实现该计时器。参与者将其自身的 SSRC 添加到成员表中。 Receiving an RTP or Non-BYE RTCP Packet: 当从一个 SSRC 不在成员表中的参与者接收到 RTP 或 RTCP 包时,该 SSRC 会被添加到表中,并且一旦该参与者按第 6.2.1 节所述验证通过,members 的值将被更新。对于验证通过的 RTP 包中的每个 CSRC,也会进行相同的处理。当从一个 SSRC 不在发送者表中的参与者接收到 RTP 包时,该 SSRC 会被添加到表中,senders 的值将被更新。对于每个接收到的复合 RTCP 包,avg_rtcp_size 的值会被更新: 其中,packet_size 是刚刚接收到的 RTCP 包的大小。 Receiving an RTCP BYE Packet: 除非在第 6.3.7 节中描述的发送 RTCP BYE 的情况外,如果接收到的包是 RTCP BYE 包,则会将其 SSRC 与成员表进行检查。如果该 SSRC 存在于成员表中,则从表中删除该条目,并更新 members 的值。然后,SSRC 将与发送者表进行检查。如果存在,则从表中删除该条目,并更新 senders 的值。 此外,为使 RTCP 包的传输速率能够更适应组成员数的变化,当接收到的 BYE 包使 members 值小于 pmembers 时,应执行以下“反向再考虑”算法: 根据以下公式更新 tn 的值: 根据以下公式更新 tp 的值: 将下一个 RTCP 包的计划发送时间重新设定为 tn,此时该时间比原计划更早。 将 pmembers 的值设为与 members 相等。 该算法并不能完全防止在大型会话中的大部分参与者突然离开但部分仍在的情况下,由于过早超时而导致组大小估算短时间内错误地降至零。然而,该算法可以使估算值更快速地恢复到正确值。这种情况非常少见,且后果较为无害,因此被视为次要问题。 Timing Out an SSRC: 参与者必须定期检查其他参与者是否超时。为此,参与者计算接收者的确定性(不含随机因子)计算间隔 TdTdTd,即 we_sent 为 false 的情况。任何自时间 tc−MTdtc - MTdtc−MTd(其中 MMM 为超时倍数,默认值为 5)起未发送 RTP 或 RTCP 包的会话成员将被视为超时。这意味着其 SSRC 会从成员列表中删除,且 members 的值会更新。发送者列表也需进行类似的检查。任何自时间 tc−2Ttc - 2Ttc−2T(即在最近两个 RTCP 报告间隔内)未发送 RTP 包的发送者会从发送者列表中删除,并更新 senders 的值。如果有任何成员超时,应执行第 6.3.4 节中描述的反向再考虑算法。参与者必须至少在每个 RTCP 传输间隔内执行一次此检查。 Expiration of Transmission Timer: 当数据包传输计时器到期时,参与者执行以下操作: 按第 6.3.1 节所述计算传输间隔 TTT,包括随机因子。 如果 tp+T≤tctp + T \leq tctp+T≤tc,则发送一个 RTCP 包。将 tptptp 设为 tctctc,然后按前一步计算另一个 TTT 值,并将 tntntn 设为 tc+Ttc + Ttc+T。将传输计时器设置为在时间 tntntn 到期。如果 tp+T>tctp + T > tctp+T>tc,则将 tntntn 设为 tp+Ttp + Ttp+T。不发送 RTCP 包,传输计时器设置在时间 tntntn 到期。 将 pmembers 设为 members。 如果发送了 RTCP 包,则将 initial 值设为 FALSE。此外,更新 avg_rtcp_size 的值: 其中,packet_size 是刚刚发送的 RTCP 包的大小. Transmitting a BYE Packet: 当参与者希望离开会话时,会发送一个 BYE 包,以告知其他参与者该事件。为避免当许多参与者离开系统时出现 BYE 包的洪流,如果在参与者选择离开时成员数超过 50,则必须执行以下算法。该算法会改变成员变量的正常作用,将其用于统计 BYE 包的数量: 当参与者决定离开系统时,将 tp 重置为 tc(当前时间),并将 members 和 pmembers 初始化为 1,initial 设为 1,we_sent 设为 false,senders 设为 0,avg_rtcp_size 设为复合 BYE 包的大小。随后计算出的间隔 TTT 会被计算出。BYE 包则计划在 tn=tc+Ttn = tc + Ttn=tc+T 的时间发送。 每当接收到来自其他参与者的 BYE 包时,不论该参与者是否存在于成员表中,以及在使用 SSRC 采样时 BYE 的 SSRC 是否会包含在样本中,members 的值都会增加 1。接收到其他 RTCP 或 RTP 包时,members 不会增加,且仅会对接收到的 BYE 包更新 avg_rtcp_size。当 RTP 包到达时,senders 的值不会更新,保持为 0。 然后,BYE 包的传输遵循与上述常规 RTCP 包传输相同的规则。 这允许立即发送 BYE 包,同时控制其总带宽使用量。在最坏的情况下,RTCP 控制包的带宽使用量可能是正常情况的两倍(10%):非 BYE RTCP 包占 5%,BYE 包占 5%。如果参与者不想等待上述机制允许 BYE 包的传输,则可以选择离开组而不发送 BYE 包。此参与者最终会被其他组成员超时清除。如果成员数估计 members 小于 50 时该参与者决定离开,则可立即发送 BYE 包。或者,参与者也可以选择执行上述 BYE 回退算法。 无论哪种情况,若一个参与者从未发送过 RTP 或 RTCP 包,则在离开组时不得发送 BYE 包。 Updating we_sent: 变量 we_sent 在参与者最近发送了 RTP 包时为 true,否则为 false。该状态的确定机制与管理发送者表中其他参与者的机制相同。如果当 we_sent 为 false 时参与者发送了一个 RTP 包,它会将自己添加到发送者表中,并将 we_sent 设置为 true。应执行第 6.3.4 节中描述的反向再考虑算法,以可能减少发送 SR 包之前的延迟。每次发送另一个 RTP 包时,该包的发送时间都会保存在表中。然后对该参与者应用正常的发送者超时算法 —— 如果自 tc−2Ttc - 2Ttc−2T 以来没有发送过 RTP 包,则该参与者会将自己从发送者表中移除,减少发送者计数,并将 we_sent 设置为 false。 Allocation of Source Description Bandwidth: 本规范定义了几种源描述(SDES)项,除了必需的 CNAME 项之外,还包括 NAME(个人姓名)和 EMAIL(电子邮件地址)。它还提供了定义新的应用程序专用 RTCP 包类型的方法。应用程序在为这些附加信息分配控制带宽时应谨慎,因为这会减慢接收报告和 CNAME 的发送速率,从而影响协议的性能。建议不超过 20% 的单个参与者的 RTCP 带宽用于携带附加信息。此外,并非所有 SDES 项都要包含在每个应用程序中。包含的项应根据其用途分配带宽比例。建议不动态估算这些比例,而是基于项的典型长度,将百分比静态转换为报告间隔计数。 例如,一个应用程序可能仅设计为发送 CNAME、NAME 和 EMAIL,而不发送其他项。由于 NAME 将在应用程序的用户界面中连续显示,可能会比 EMAIL 优先级更高,因为 EMAIL 仅在请求时显示。在每个 RTCP 间隔,都会发送一个 RR 包和一个带有 CNAME 项的 SDES 包。对于在最小间隔运行的小会话,平均每 5 秒会发送一次。每隔第三个间隔(15 秒),SDES 包会包含一项额外的项。在这其中,七分之八的时间是 NAME 项,而每八次(2 分钟)会包含 EMAIL 项。当多个应用程序协同工作,通过每个参与者的公共 CNAME 实现跨应用绑定时,例如在多媒体会议中为每个媒体建立一个 RTP 会话时,附加的 SDES 信息可以仅在一个 RTP 会话中发送。其他会话将仅携带 CNAME 项。特别是,这种方法应适用于分层编码方案的多个会话(见第 2.4 节)。 Sender and Receiver Reports: RTP 接收者使用 RTCP 报告包提供接收质量反馈,具体形式取决于接收者是否也是发送者。这两种报告形式——发送者报告 (SR) 和接收者报告 (RR) 的唯一区别是,除了包类型代码外,发送者报告包含一个 20 字节的发送者信息部分,供活跃的发送者使用。如果在上一次或前一个报告之后的间隔期间内站点发送了任何数据包,则会发送 SR,否则发送 RR。SR 和 RR 都包含零个或多个接收报告块,每个报告块对应一个自上次报告以来该接收者接收到 RTP 数据包的同步源 (SSRC)。不为 CSRC 列表中的贡献源生成报告。每个接收报告块提供有关从该块指示的特定源接收的数据的统计信息。由于 SR 或 RR 包中最多可以包含 31 个接收报告块,因此在必要时,附加的 RR 包应在初始 SR 或 RR 包之后叠加,以包含自上次报告以来接收到的所有源的接收报告。如果有太多的源以至于无法将所有必要的 RR 包包含在一个复合 RTCP 包中而不超出网络路径的 MTU,则每个间隔应仅包含符合 MTU 限制的子集。这些子集应在多个间隔内以轮询方式选择,以便所有源都能被报告。 接下来的章节定义了这两种报告的格式,如何在特定配置中进行扩展(如果应用程序需要额外的反馈信息),以及如何使用这些报告。第 7 节提供了有关翻译器和混合器的接收报告的详细信息。 SR: Sender Report RTCP Packet: 发送者报告 (SR) 包包含三个部分,如果定义了特定配置的扩展部分,则可能跟随一个第四部分。第一部分是 8 字节长的头部。各字段含义如下: 版本 (V):2 位 ○ 标识 RTP 版本,与 RTP 数据包中的版本相同。本规范定义的版本为 2。 填充 (P):1 位 ○ 如果填充位设置为 1,则此 RTCP 包在末尾包含一些附加的填充字节,这些字节不属于控制信息,但包含在长度字段中。填充字节的最后一个字节表示需要忽略的填充字节数,包括自身(将是 4 的倍数)。某些固定块大小的加密算法可能需要填充。在复合 RTCP 包中,仅需在一个 RTCP 包中填充,因为整个复合包会被加密(第 9.1 节方法)。因此,填充仅需添加到最后一个 RTCP 包中,且该包的填充位必须设置为 1。这种方式有助于执行附录 A.2 中描述的头部有效性检查,并允许检测某些早期实现中错误地在第一个 RTCP 包中设置填充位,并在最后一个 RTCP 包中添加填充的情况。 接收报告计数 (RC):5 位 ○ 表示此包中包含的接收报告块数量。0 也是有效值。 包类型 (PT):8 位 ○ 包含常数 200,以标识这是 RTCP SR 包。 长度:16 位 ○ 此 RTCP 包的长度(以 32 位字为单位)减 1,包括头部和填充。(偏移量 1 使得 0 成为有效长度,避免扫描复合 RTCP 包时的无限循环问题,并通过以 32 位字为单位进行计数,避免了 4 的倍数的有效性检查。) SSRC:32 位 ○ 此 SR 包发起者的同步源标识符。 第二部分为发送者信息,长 20 字节,存在于每个发送者报告包中,概述了此发送者的数据传输。各字段含义如下: NTP 时间戳:64 位 ○ 表示发送报告时的挂钟时间(见第 4 节),可与其他接收者返回的接收报告中的时间戳结合,用于测量到这些接收者的往返传播时间。接收者应注意,时间戳的测量精度可能远低于 NTP 时间戳的分辨率。时间戳的不确定性未指明,因为可能未知。在不具备挂钟时间概念但具备特定系统时钟(如系统启动时间)的系统上,发送者可以使用该时钟作为参考来计算相对 NTP 时间戳。重要的是选择一个常用时钟,以便在使用不同实现生成多媒体会话的各个流时,所有实现都使用相同的时钟。在 2036 年之前,相对和绝对时间戳会在高位有所不同,因此(无效的)比较将显示出较大的差异;到那时,希望不再需要相对时间戳。没有挂钟或经过时间概念的发送者可以将 NTP 时间戳设为 0。 RTP 时间戳:32 位 ○ 与 NTP 时间戳(上文)对应,但使用与数据包中的 RTP 时间戳相同的单位和随机偏移。对于 NTP 时间戳已同步的源,该对应关系可用于媒体内部和媒体之间的同步,并可用于媒体无关接收者估算标准 RTP 时钟频率。注意,此时间戳在大多数情况下不会等于任何相邻数据包中的 RTP 时间戳,而是必须根据对应的 NTP 时间戳、通过在采样时刻周期性检查挂钟时间来维护的 RTP 时间戳计数器和实际时间的关系进行计算。 发送者的数据包计数:32 位 ○ 从开始传输至生成此 SR 包时,发送者发送的 RTP 数据包总数。如果发送者更改其 SSRC 标识符,则应重置此计数。 发送者的字节数:32 位 ○ 从开始传输至生成此 SR 包时,发送者在 RTP 数据包中传输的有效负载字节总数(不包括头部或填充)。如果发送者更改其 SSRC 标识符,则应重置此计数。此字段可用于估算平均有效负载数据速率。 第三部分根据自上次报告以来发送者接收到的其他源数量,包含零个或多个接收报告块。每个接收报告块传达从一个同步源接收的 RTP 数据包的统计信息。当源由于冲突更改其 SSRC 标识符时,接收者不应继续使用之前的统计信息。这些统计信息包括: SSRC_n(源标识符):32 位 ○ 与此接收报告块中的信息相关的源的 SSRC 标识符。 丢包率:8 位 ○ 表示自上次发送 SR 或 RR 包以来,从源 SSRC_n 丢失的 RTP 数据包的比例,按左边缘为二进制点的定点数表示(即丢失比例乘以 256 的整数部分) RR: Receiver Report RTCP Packet: 接收者报告 (RR) 包的格式与发送者报告 (SR) 包的格式相同,但包类型字段包含常数 201,且省略了五个字的信息(即 NTP 和 RTP 时间戳以及发送者的数据包和字节计数)。其余字段的含义与 SR 包相同。 当没有数据传输或接收需要报告时,必须将一个空的 RR 包(RC = 0)放在复合 RTCP 包的开头。 Extending the Sender and Receiver Reports: 如果有需要定期报告的发送者或接收者的额外信息,配置文件应定义发送者报告和接收者报告的特定扩展。此方法应优先于定义另一种 RTCP 包类型,因为它所需的开销较少: 包中所需的字节更少(没有 RTCP 头部或 SSRC 字段); 解析更简单快捷,因为运行在该配置文件下的应用程序会编程为始终在接收报告后直接访问扩展字段。 该扩展是发送者报告或接收者报告包的第四部分,在接收报告块(如果有的话)之后的结尾处。如果需要额外的发送者信息,则在发送者报告中会首先包含在扩展部分中,但在接收者报告中则不会出现此信息。如果需要包含接收者的信息,数据应当按照与现有接收报告块数组平行的块数组结构组织;即,块的数量将由 RC 字段指示。 Analyzing Sender and Receiver Reports: 接收质量反馈预计不仅对发送者有用,也对其他接收者和第三方监视器有用。发送者可以根据反馈调整传输;接收者可以确定问题是本地的、区域性的还是全局的;网络管理人员可以使用仅接收 RTCP 包而不接收对应 RTP 数据包的独立配置监视器来评估其网络在多播分发中的性能。 发送者信息和接收者报告块都使用累积计数,这样可以计算任意两个报告之间的差异,以便在长短时间段内进行测量,并提供对报告丢失的韧性保护。最近两次接收到的报告之差可用于估算近期的分发质量。NTP 时间戳的加入,使得可以通过这些差异计算两个报告之间间隔的速率。由于该时间戳独立于数据编码的时钟速率,因此可以实现编码和配置无关的质量监视。一个示例计算是两个接收报告之间的包丢失率。累计丢失包数的差值给出了该间隔内的丢失包数。接收的扩展最高序列号的差值给出了该间隔内期望的包数。两者之比是该间隔的包丢失比例。对于连续的两个报告,这一比例应等于丢失比例字段,否则可能不一致。丢失率每秒可通过将丢失比例除以 NTP 时间戳差值(以秒为单位)获得。接收到的包数是期望包数减去丢失包数。期望包数还可用于评估丢失估算的统计有效性。例如,丢失 5 个包中的 1 个的统计显著性低于丢失 1000 个包中的 200 个。 从发送者信息中,第三方监视器可以在不接收数据的情况下计算间隔内的平均有效负载数据速率和平均包速率。两者的比值给出平均有效负载大小。如果假设包丢失与包大小无关,则特定接收者接收的包数乘以平均有效负载大小(或相应的包大小)给出该接收者的可见吞吐量。 除了允许通过报告间差值进行长期丢包测量的累积计数,丢失比例字段还提供了单个报告中的短期测量。这在会话规模扩大到足以使无法保留所有接收者的接收状态信息,或报告间隔长到仅收到一个特定接收者的报告时显得尤为重要。 到达间抖动 (jitter) 字段提供了第二个短期的网络拥塞测量。丢包跟踪持续性拥塞,而抖动测量跟踪瞬时拥塞。在丢包之前,抖动测量可能预示着拥塞。到达间抖动字段仅是报告时抖动的快照,非量化值。相反,它用于对单个接收者随时间或多个接收者(例如同一网络内的接收者)间的多次报告进行比较。为了便于接收者间比较,所有接收者必须按照相同的公式计算抖动。 由于抖动计算基于 RTP 时间戳,该时间戳表示包中数据首次采样的时刻,因此从采样到包传输的延迟变化会影响计算的抖动值。此延迟变化会出现在不同长度的音频包中,视频编码中也会出现这种情况,因为帧的所有包具有相同的时间戳,但传输时间不同。传输前的延迟变化确实降低了抖动计算作为独立网络行为测量的准确性,但考虑到接收者缓冲区必须适应这一点,将其包括在内是合适的。当抖动计算作为比较测量使用时,传输延迟变化的(恒定)分量会相互抵消,从而可以观察到网络抖动分量的变化,除非该变化非常小。如果变化很小,则可能无关紧要。
一、内存屏障 在 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 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。
数据压倒一切。如果选择了正确的数据结构并把一切组织的井井有条,正确的算法就不言自明。编程的核心是数据结构,而不是算法。——Rob Pike 说明 本文基于这样的认识:数据是易变的,逻辑是稳定的。本文例举的编程实现多为代码片段,但不影响描述的完整性。本文例举的编程虽然基于C语言,但其编程思想也适用于其他语言。此外,本文不涉及语言相关的运行效率讨论。 概念提出 所谓表驱动法(Table-Driven Approach)简而言之就是用查表的方法获取数据。此处的“表”通常为数组,但可视为数据库的一种体现。 根据字典中的部首检字表查找读音未知的汉字就是典型的表驱动法,即以每个字的字形为依据,计算出一个索引值,并映射到对应的页数。相比一页一页地顺序翻字典查字,部首检字法效率极高。 具体到编程方面,在数据不多时可用逻辑判断语句(if…else或switch…case)来获取值;但随着数据的增多,逻辑语句会越来越长,此时表驱动法的优势就开始显现。 例如,用36进制(A表示10,B表示11,…)表示更大的数字,逻辑判断语句如下: if(ucNum < 10) { ucNumChar = ConvertToChar(ucNum); } else if(ucNum == 10) { ucNumChar = 'A'; } else if(ucNum == 11) { ucNumChar = 'B'; } else if(ucNum == 12) { ucNumChar = 'C'; } //... ... else if(ucNum == 35) { ucNumChar = 'Z'; } 当然也可以用 switch…case 结构,但实现都很冗长。而用表驱动法(将numChar 存入数组)则非常直观和简洁。如: CHAR aNumChars[] = {'0', '1', '2', /*3~9*/'A', 'B', 'C', /*D~Y*/'Z'}; CHAR ucNumChar = aNumChars[ucNum % sizeof(aNumChars)]; 像这样直接将变量当作下数组下标来读取数值的方法就是直接查表法。 注意,如果熟悉字符串操作,则上述写法可以更简洁: CHAR ucNumChar = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[ucNum]; 使用表驱动法时需要关注两个问题:一是如何查表,从表中读取正确的数据;二是表里存放什么,如数值或函数指针。前者参见1.1节“查表方式”内容,后者参见1.2节“实战示例”内容。 查表方式 常用的查表方式有直接查找、索引查找和分段查找等。 直接查找 即直接通过数组下标获取到数据。如果熟悉哈希表的话,可以很容易看出这种查表方式就是哈希表的直接访问法。 如获取星期名称,逻辑判断语句如下: if(0 == ucDay) { pszDayName = "Sunday"; } else if(1 == ucDay) { pszDayName = "Monday"; } //... ... else if(6 == ucDay) { pszDayName = "Saturday"; } 而实现同样的功能,可将这些数据存储到一个表里: CHAR *paNumChars[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; CHAR *pszDayName = paNumChars[ucDay]; 类似哈希表特性,表驱动法适用于无需有序遍历数据,且数据量大小可提前预测的情况。 对于过于复杂和庞大的判断,可将数据存为文件,需要时加载文件初始化数组,从而在不修改程序的情况下调整里面的数值。 有时,访问之前需要先进行一次键值转换。如表驱动法表示端口忙闲时,需将槽位端口号映射为全局编号。所生成的端口数目大小的数组,其下标对应全局端口编号,元素值表示相应端口的忙闲状态。 索引查找 有时通过一次键值转换,依然无法把数据(如英文单词等)转为键值。此时可将转换的对应关系写到一个索引表里,即索引访问。 如现有100件商品,4位编号,范围从0000到9999。此时只需要申请一个长度为100的数组,且对应2位键值。但将4位的编号转换为2位的键值,可能过于复杂或没有规律,最合适的方法是建立一个保存该转换关系的索引表。采用索引访问既节省内存,又方便维护。比如索引A表示通过名称访问,索引B表示通过编号访问。 分段查找 通过确定数据所处的范围确定分类(下标)。有的数据可分成若干区间,即具有阶梯性,如分数等级。此时可将每个区间的上限(或下限)存到一个表中,将对应的值存到另一表中,通过第一个表确定所处的区段,再由区段下标在第二个表里读取相应数值。注意要留意端点,可用二分法查找,另外可考虑通过索引方法来代替。 如根据分数查绩效等级: #define MAX_GRADE_LEVEL (INT8U)5 DOUBLE aRangeLimit[MAX_GRADE_LEVEL] = {50.0, 60.0, 70.0, 80.0, 100.0}; CHAR *paGrades[MAX_GRADE_LEVEL] = {"Fail", "Pass", "Credit", "Distinction", "High Distinction"}; static CHAR* EvaluateGrade(DOUBLE dScore) { INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < aRangeLimit[ucLevel]) return paGrades[ucLevel]; } return paGrades[0]; } 上述两张表(数组)也可合并为一张表(结构体数组),如下所示: typedef struct{ DOUBLE aRangeLimit; CHAR *pszGrade; }T_GRADE_MAP; T_GRADE_MAP gGradeMap[MAX_GRADE_LEVEL] = { {50.0, "Fail"}, {60.0, "Pass"}, {70.0, "Credit"}, {80.0, "Distinction"}, {100.0, "High Distinction"} }; static CHAR* EvaluateGrade(DOUBLE dScore) { INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < gGradeMap[ucLevel].aRangeLimit) return gGradeMap[ucLevel].pszGrade; } return gGradeMap[0].pszGrade; } 该表结构已具备的数据库的雏形,并可扩展支持更为复杂的数据。其查表方式通常为索引查找,偶尔也为分段查找;当索引具有规律性(如连续整数)时,退化为直接查找。 使用分段查找法时应注意边界,将每一分段范围的上界值都考虑在内。 找出所有不在最高一级范围内的值,然后把剩下的值全部归入最高一级中。有时需要人为地为最高一级范围添加一个上界。 同时应小心不要错误地用“<”来代替“<=”。要保证循环在找出属于最高一级范围内的值后恰当地结束,同时也要保证恰当处理范围边界。 实战示例 本节多数示例取自实际项目。表形式为一维数组、二维数组和结构体数组;表内容有数据、字符串和函数指针。基于表驱动的思想,表形式和表内容可衍生出丰富的组合。 字符统计 问题:统计用户输入的一串数字中每个数字出现的次数。 普通解法主体代码如下: INT32U aDigitCharNum[10] = {0}; /* 输入字符串中各数字字符出现的次数 */ INT32U dwStrLen = strlen(szDigits); INT32U dwStrIdx = 0; for(; dwStrIdx < dwStrLen; dwStrIdx++) { switch(szDigits[dwStrIdx]) { case '1': aDigitCharNum[0]++; break; case '2': aDigitCharNum[1]++; break; //... ... case '9': aDigitCharNum[8]++; break; } } 这种解法的缺点显而易见,既不美观也不灵活。其问题关键在于未将数字字符与数组aDigitCharNum下标直接关联起来。 以下示出更简洁的实现方式: for(; dwStrIdx < dwStrLen; dwStrIdx++) { aDigitCharNum[szDigits[dwStrIdx] - '0']++; } 上述实现考虑到0也为数字字符。该解法也可扩展至统计所有ASCII可见字符。 月天校验 问题:对给定年份和月份的天数进行校验(需区分平年和闰年)。 普通解法主体代码如下: switch(OnuTime.Month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: if(OnuTime.Day>31 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~31)!!!\n", OnuTime.Day); retcode = S_ERROR; } break; case 2: if(((OnuTime.Year%4 == 0) && (OnuTime.Year%100 != 0)) || (OnuTime.Year%400 == 0)) { if(OnuTime.Day>29 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~29)!!!\n", OnuTime.Day); retcode = S_ERROR; } } else { if(OnuTime.Day>28 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~28)!!!\n", OnuTime.Day); retcode = S_ERROR; } } break; case 4: case 6: case 9: case 11: if(OnuTime.Day>30 || OnuTime.Day<1) { CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~30)!!!\n", OnuTime.Day); retcode = S_ERROR; } break; default: CtcOamLog(FUNCTION_Pon,"Don't support this Month: %d(1~12)!!!\n", OnuTime.Month); retcode = S_ERROR; break; } 以下示出更简洁的实现方式: #define MONTH_OF_YEAR 12 /* 一年中的月份数 */ /* 闰年:能被4整除且不能被100整除,或能被400整除 */ #define IS_LEAP_YEAR(year) ((((year) % 4 == 0) && ((year) % 100 != 0)) || ((year) % 400 == 0)) /* 平年中的各月天数,下标对应月份 */ INT8U aDayOfCommonMonth[MONTH_OF_YEAR] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; INT8U ucMaxDay = 0; if((OnuTime.Month == 2) && (IS_LEAP_YEAR(OnuTime.Year))) ucMaxDay = aDayOfCommonMonth[1] + 1; else ucMaxDay = aDayOfCommonMonth[OnuTime.Month-1]; if((OnuTime.Day < 1) || (OnuTime.Day > ucMaxDay) { CtcOamLog(FUNCTION_Pon,"Month %d doesn't have this Day: %d(1~%d)!!!\n", OnuTime.Month, OnuTime.Day, ucMaxDay); retcode = S_ERROR; } 名称构造 问题:根据WAN接口承载的业务类型(Bitmap)构造业务类型名称字符串。 普通解法主体代码如下: void Sub_SetServerType(INT8U *ServerType, INT16U wan_servertype) { if ((wan_servertype & 0x0001) == 0x0001) { strcat(ServerType, "_INTERNET"); } if ((wan_servertype & 0x0002) == 0x0002) { strcat(ServerType, "_TR069"); } if ((wan_servertype & 0x0004) == 0x0004) { strcat(ServerType, "_VOIP"); } if ((wan_servertype & 0x0008) == 0x0008) { strcat(ServerType, "_OTHER"); } } 以下示出C语言中更简洁的实现方式: /* 获取var变量第bit位,编号从右至左 */ #define GET_BIT(var, bit) (((var) >> (bit)) & 0x1) const CHAR* paSvrNames[] = {"_INTERNET", "_TR069", "_VOIP", "_OTHER"}; const INT8U ucSvrNameNum = sizeof(paSvrNames) / sizeof(paSvrNames[0]); VOID SetServerType(CHAR *pszSvrType, INT16U wSvrType) { INT8U ucIdx = 0; for(; ucIdx < ucSvrNameNum; ucIdx++) { if(1 == GET_BIT(wSvrType, ucIdx)) strcat(pszSvrType, paSvrNames[ucIdx]); } } 新的实现将数据和逻辑分离,维护起来非常方便。只要逻辑(规则)不变,则唯一可能的改动就是数据(paSvrNames)。 值名解析 问题:根据枚举变量取值输出其对应的字符串,如PORT_FE(1)输出“Fe”。 //值名映射表结构体定义,用于数值解析器 typedef struct{ INT32U dwElem; //待解析数值,通常为枚举变量 CHAR* pszName; //指向数值所对应解析名字符串的指针 }T_NAME_PARSER; /****************************************************************************** * 函数名称: NameParser * 功能说明: 数值解析器,将给定数值转换为对应的具名字符串 * 输入参数: VOID *pvMap :值名映射表数组,含T_NAME_PARSER结构体类型元素 VOID指针允许用户在保持成员数目和类型不变的前提下, 定制更有意义的结构体名和/或成员名。 INT32U dwEntryNum :值名映射表数组条目数 INT32U dwElem :待解析数值,通常为枚举变量 INT8U* pszDefName :缺省具名字符串指针,可为空 * 输出参数: NA * 返回值 : INT8U *: 数值所对应的具名字符串 当无法解析给定数值时,若pszDefName为空,则返回数值对应的16进制格式 字符串;否则返回pszDefName。 ******************************************************************************/ INT8U *NameParser(VOID *pvMap, INT32U dwEntryNum, INT32U dwElem, INT8U* pszDefName) { CHECK_SINGLE_POINTER(pvMap, "NullPoniter"); INT32U dwEntryIdx = 0; for(dwEntryIdx = 0; dwEntryIdx < dwEntryNum; dwEntryIdx++) { T_NAME_PARSER *ptNameParser = (T_NAME_PARSER *)pvMap; if(dwElem == ptNameParser->dwElem) { return ptNameParser->pszName; } //ANSI标准禁止对void指针进行算法操作;GNU标准则指定void*算法操作与char*一致。 //若考虑移植性,可将pvMap类型改为INT8U*,或定义INT8U*局部变量指向pvMap。 pvMap += sizeof(T_NAME_PARSER); } if(NULL != pszDefName) { return pszDefName; } else { static INT8U szName[12] = {0}; //Max:"0xFFFFFFFF" sprintf(szName, "0x%X", dwElem); return szName; } } 以下给出NameParser的简单应用示例: //UNI端口类型值名映射表结构体定义 typedef struct{ INT32U dwPortType; INT8U* pszPortName; }T_PORT_NAME; //UNI端口类型解析器 T_PORT_NAME gUniNameMap[] = { {1, "Fe"}, {3, "Pots"}, {99, "Vuni"} }; const INT32U UNI_NAM_MAP_NUM = (INT32U)(sizeof(gUniNameMap)/sizeof(T_PORT_NAME)); VOID NameParserTest(VOID) { INT8U ucTestIndex = 1; printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("DefName", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "DefName")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Fe", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 1, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Pots", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 3, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Vuni", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 99, NULL)) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 255, "Unknown")) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("0xABCD", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++, strcmp("NullPoniter", NameParser(NULL, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); } gUniNameMap在实际项目中有十余个条目,若采用逻辑链实现将非常冗长。 取值映射 问题:不同模块间同一参数枚举值取值可能有所差异,需要适配。 此处不再给出普通的switch…case或if…else if…else结构,而直接示出以下表驱动实现: typedef struct{ PORTSTATE loopMEState; PORTSTATE loopMIBState; }LOOPMAPSTRUCT; static LOOPMAPSTRUCT s_CesLoop[] = { {NO_LOOP, e_ds1_looptype_noloop}, {PAYLOAD_LOOP, e_ds1_looptype_PayloadLoop}, {LINE_LOOP, e_ds1_looptype_LineLoop}, {PON_LOOP, e_ds1_looptype_OtherLoop}, {CES_LOOP, e_ds1_looptype_InwardLoop}}; PORTSTATE ConvertLoopMEStateToMIBState(PORTSTATE vPortState) { INT32U num = 0, ii; num = ARRAY_NUM(s_CesLoop); for(ii = 0; ii < num; ii++) { if(vPortState == s_CesLoop[ii].loopMEState) return s_CesLoop[ii].loopMIBState; } return e_ds1_looptype_noloop; } 相应地,从loopMIBState映射到loopMEState需要定义一个ConvertLoopMIBStateToMEState函数。更进一步,所有类似的一对一映射关系都必须如上的映射(转换)函数,相当繁琐。事实上,从抽象层面看,该映射关系非常简单。提取共性后定义带参数宏,如下所示: /********************************************************** * 功能描述:进行二维数组映射表的一对一映射,用于参数适配 * 参数说明:map -- 二维数组映射表 elemSrc -- 映射源,即待映射的元素值 elemDest -- 映射源对应的映射结果 direction -- 映射方向字节,表示从数组哪列映射至哪列。 高4位对应映射源列,低4位对应映射结果列。 defaultVal -- 映射失败时置映射结果为缺省值 * 示例:ARRAY_MAPPER(gCesLoopMap, 3, ucLoop, 0x10, NO_LOOP); 则ucLoop = 2(LINE_LOOP) **********************************************************/ #define ARRAY_MAPPER(map, elemSrc, elemDest, direction, defaultVal) do{\ INT8U ucMapIdx = 0, ucMapNum = 0; \ ucMapNum = sizeof(map)/sizeof(map[0]); \ for(ucMapIdx = 0; ucMapIdx < ucMapNum; ucMapIdx++) \ { \ if((elemSrc) == map[ucMapIdx][((direction)&0xF0)>>4]) \ { \ elemDest = map[ucMapIdx][(direction)&0x0F]; \ break; \ } \ } \ if(ucMapIdx == ucMapNum) \ { \ elemDest = (defaultVal); \ } \ }while(0) 参数取值转换时直接调用统一的映射器宏,如下: static INT8U gCesLoopMap[][2] = { {NO_LOOP, e_ds1_looptype_noloop}, {PAYLOAD_LOOP, e_ds1_looptype_PayloadLoop}, {LINE_LOOP, e_ds1_looptype_LineLoop}, {PON_LOOP, e_ds1_looptype_OtherLoop}, {CES_LOOP, e_ds1_looptype_InwardLoop}}; ARRAY_MAPPER(gCesLoopMap, tPara.dwParaVal[0], dwLoopConf, 0x01, e_ds1_looptype_noloop); 另举一例: #define CES_DEFAULT_JITTERBUF (INT32U)2000 /* 默认jitterbuf为2000us,而1帧=125us */ #define CES_JITTERBUF_STEP (INT32U)125 /* jitterbuf步长为125us,即1帧 */ #define CES_DEFAULT_QUEUESIZE (INT32U)5 #define CES_DEFAULT_MAX_QUEUESIZE (INT32U)7 #define ARRAY_NUM(array) (sizeof(array) / sizeof((array)[0])) /* 数组元素个数 */ typedef struct{ INT32U dwJitterBuffer; INT32U dwFramePerPkt; INT32U dwQueueSize; }QUEUE_SIZE_MAP; /* gCesQueueSizeMap也可以(JitterBuffer / FramePerPkt)值为索引,更加紧凑 */ static QUEUE_SIZE_MAP gCesQueueSizeMap[]= { {1,1,1}, {1,2,1}, {2,1,2}, {2,2,1}, {3,1,3}, {3,2,1}, {4,1,3}, {4,2,1}, {5,1,4}, {5,2,3}, {6,1,4}, {6,2,3}, {7,1,4}, {7,2,3}, {8,1,4}, {8,2,3}, {9,1,5}, {9,2,4}, {10,1,5}, {10,2,4}, {11,1,5}, {11,2,4}, {12,1,5}, {12,2,4}, {13,1,5}, {13,2,4}, {14,1,5}, {14,2,4}, {15,1,5}, {15,2,4}, {16,1,5}, {16,2,4}, {17,1,6}, {17,2,5}, {18,1,6}, {18,2,5}, {19,1,6}, {19,2,5}, {20,1,6}, {20,2,5}, {21,1,6}, {21,2,5}, {22,1,6}, {22,2,5}, {23,1,6}, {23,2,5}, {24,1,6}, {24,2,5}, {25,1,6}, {25,2,5}, {26,1,6}, {26,2,5}, {27,1,6}, {27,2,5}, {28,1,6}, {28,2,5}, {29,1,6}, {29,2,5}, {30,1,6}, {30,2,5}, {31,1,6}, {31,2,5}, {32,1,6}, {32,2,5}}; /********************************************************** * 函数名称:CalcQueueSize * 功能描述:根据JitterBuffer和FramePerPkt计算QueueSize * 注意事项:配置的最大缓存深度 * = 2 * JitterBuffer / FramePerPkt * = 2 * N Packet = 2 ^ QueueSize * JitterBuffer为125us帧速率的倍数, * FramePerPkt为每个分组的帧数, * QueueSize向上取整,最大为7。 **********************************************************/ INT32U CalcQueueSize(INT32U dwJitterBuffer, INT32U dwFramePerPkt) { INT8U ucIdx = 0, ucNum = 0; //本函数暂时仅考虑E1 ucNum = ARRAY_NUM(gCesQueueSizeMap); for(ucIdx = 0; ucIdx < ucNum; ucIdx++) { if((dwJitterBuffer == gCesQueueSizeMap[ucIdx].dwJitterBuffer) && (dwFramePerPkt == gCesQueueSizeMap[ucIdx].dwFramePerPkt)) { return gCesQueueSizeMap[ucIdx].dwQueueSize; } } return CES_DEFAULT_MAX_QUEUESIZE; } 版本控制 问题:控制OLT与ONU之间的版本协商。ONU本地设置三比特控制字,其中bit2(MSB)~bit0(LSB)分别对应0x21、0x30和0xAA版本号;且bitX为0表示上报对应版本号,bitX为1表示不上报对应版本号。其他版本号如0x20、0x13和0x1必须上报,即不受控制。 最初的实现采用if…else if…else结构,代码非常冗长,如下: pstSendTlv->ucLength = 0x1f; if (gOamCtrlCode == 0) { vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x30; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x21; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3); pstSendTlv->aucVersionList[15] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3); pstSendTlv->aucVersionList[19] = 0x01; vosMemCpy(&(pstSendTlv->aucVersionList[20]), ctc_oui, 3); pstSendTlv->aucVersionList[23] = 0xaa; } else if (gOamCtrlCode == 1) { vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x30; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x21; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3); pstSendTlv->aucVersionList[15] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3); pstSendTlv->aucVersionList[19] = 0x01; } //此处省略gOamCtrlCode == 2~6的处理代码 else if (gOamCtrlCode == 7) { vosMemCpy(&(pstSendTlv->aucVersionList), ctc_oui, 3); pstSendTlv->aucVersionList[3] = 0x20; vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3); pstSendTlv->aucVersionList[7] = 0x13; vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3); pstSendTlv->aucVersionList[11] = 0x01; } 以下示出C语言中更简洁的实现方式(基于二维数组): /********************************************************************** * 版本控制字数组定义 * gOamCtrlCode: Bitmap控制字。Bit-X为0时上报对应版本,Bit-X为1时屏蔽对应版本。 * CTRL_VERS_NUM: 可控版本个数。 * CTRL_CODE_NUM: 控制字个数。与CTRL_VERS_NUM有关。 * gOamVerCtrlMap: 版本控制字数组。行对应控制字,列对应可控版本。 元素值为0时不上报对应版本,元素值非0时上报该元素值。 * Note: 该数组旨在实现“数据与控制隔离”。后续若要新增可控版本,只需修改 -- CTRL_VERS_NUM -- gOamVerCtrlMap新增行(控制字) -- gOamVerCtrlMap新增列(可控版本) **********************************************************************/ #define CTRL_VERS_NUM 3 #define CTRL_CODE_NUM (1< u8_t gOamVerCtrlMap[CTRL_CODE_NUM][CTRL_VERS_NUM]
背景: 我们学习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 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。