-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsearch.xml
315 lines (315 loc) · 285 KB
/
search.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
<?xml version="1.0" encoding="utf-8"?>
<search>
<entry>
<title><![CDATA[析构函数的妙用]]></title>
<url>%2Fdestrcutor%2F</url>
<content type="text"><![CDATA[学习一下 Google 的工程师是怎么写代码的 :-) 前言在 C++ 中,当一个对象被销毁的时候,会自动调用这个对象的析构函数,无需显示调用。利用这一特性,可以有很多的妙用,本文将介绍在 Android framework 中两个常用的例子。 systrace 中的函数运行时间我们在看 systrace 的时候,经常能够看到这样的画面: 图中每个矩形的宽度代表的是这个函数运行的时间,那么这是这么做到的呢? 在源码中可以看到,图中 onMessageReceived(),handleMessageRefresh() 等,这些函数的第一行,都调用了 ATRACE_CALL(): void SurfaceFlinger::onMessageReceived(int32_t what) NO_THREAD_SAFETY_ANALYSIS { ATRACE_CALL(); void SurfaceFlinger::handleMessageRefresh() { ATRACE_CALL(); 这个函数应该就是答案了,看一下实现: // ATRACE_NAME traces from its location until the end of its enclosing scope.#define _PASTE(x, y) x ## y#define PASTE(x, y) _PASTE(x,y)#define ATRACE_NAME(name) android::ScopedTrace PASTE(___tracer, __LINE__) (ATRACE_TAG, name)// ATRACE_CALL is an ATRACE_NAME that uses the current function name.#define ATRACE_CALL() ATRACE_NAME(__FUNCTION__)namespace android {class ScopedTrace {public: inline ScopedTrace(uint64_t tag, const char* name) : mTag(tag) { atrace_begin(mTag, name); } inline ~ScopedTrace() { atrace_end(mTag); }private: uint64_t mTag;};} // namespace android 可以看到,当调用 ATRACE_CALL() 时,相当于定义了一个类型为 ScopedTrace 的变量,这个变量本身没有什么特别的,重要的是这个 ScopedTrace 这个类,其构造函数会去调用 atrace_begin(),析构函数会去调用 atrace_end()。也就是说,在函数开头,定义一个 ScopedTrace 类型的变量,经由构造函数去调用 atrace_begin();然后等函数结束,前面定义的 ScopedTrace 会被销毁,会经由析构函数调用 atrace_end(),从而达到了统计一个函数的运行时间的效果。 这里稍微补充一下,在 Android 里,总能听到各种各样的 trace:如 atrace, ftrace, systrace 等,那么这些 trace 究竟有什么区别呢? ftraceftrace 是 Linux Kernel 自带的调试框架,它在内核态工作,用户可以通过 debugfs 接口来控制和使用 ftrace。目前 debugfs 一般挂载在 /sys/kernel/debug/tracing/。 atrace这个是 Android 针对 ftrace 的一个封装,它将 ftrace 的各种 Event 抽象为 tag,一个 atrace tag 对应多个 ftrace Event,例如 sched 这个 tag 分别包括了 sched/sched_switch,sched/sched_wakeup,sched/sched_waking,sched/sched_blocked_reason,sched/sched_cpu_hotplug 等这些 Event。 systracesystrace 其实是 Android SDK 里的一个工具,是对 atrace 的主机端封装,利用 atrace 来使能 ftrace,然后读取 ftrace 的缓冲区并将其全部封装到一个独立的 HTML 文件。 具体的可以看一下内核工匠的这篇文章,写得很详细。 而我们在 framework 中,Java 代码常用的 Trace.traceBegin() 和 Trace.traceEnd();C++ 有 atrace_begin() 和 atrace_end() 以及一系列 ATRACE_ 打头的宏,其实都是利用 atrace 来实现 trace 的抓取。 Mutex 锁自动释放在 framework 还有另外一处也有类似的用法,那就是锁。framework 经常能够看到类似下面的代码: sp<IBinder> SurfaceFlinger::createDisplay(const String8& displayName, bool secure){ sp<BBinder> token = new DisplayToken(this); // Mutex 是在哪里释放的呢? Mutex::Autolock _l(mStateLock); // Display ID is assigned when virtual display is allocated by HWC. DisplayDeviceState state; state.isSecure = secure; state.displayName = displayName; mCurrentState.displays.add(token, state); mInterceptor->saveDisplayCreation(state); return token;} 这个 Mutex 是在哪里释放的呢?原理其实跟前面的 ATRACE_CALL() 是一样的,在析构函数里面去释放锁,就不赘述了。 尾巴距离上次更新已经一个多月了,本来计划月初更新一篇我觉得很棒的文章,但是当写到 80% 的时候我突然意识到,新文章是基于 Android R 的,而目前 R 处于保密的状态,不能外发具体的代码。这就很尴尬了呀。想着 Android R 目前只到 Beta 3,具体正式发布可能还遥遥无期,如果等到 R 发布了那估计大家都取关了。所以临时写了一篇小文当做饭前的甜点,正式的大餐等到 Android R 正式发布以后将会正式跟大家见面,敬请期待。 如果大家觉得这篇文章对您有帮助的话,求赞求收藏求转发,最重要的是点一个大大的关注,各位的支持就是我更新的最大动力。 我们下篇文章见。]]></content>
<categories>
<category>代码之美</category>
</categories>
<tags>
<tag>C++</tag>
</tags>
</entry>
<entry>
<title><![CDATA[你有没有想过,录屏软件是怎么获取到屏幕内容的?]]></title>
<url>%2Fall-about-virtualdisplay%2F</url>
<content type="text"><![CDATA[前言前段时间 Android R 发布了 Beta 版本,同时带来原生用户心心念念的功能——录屏,虽然这个功能在别的 Android 定制 ROM,像 MIUI,在好几年前已经就有了。是录屏这个功能是很难实现吗?为什么谷歌迟迟不肯在 Android 上这个功能呢? 再者,目前十分火爆的手机直播,大概可以分为两种形式:一种是让观众看到手机摄像头拍到的内容;另一种是让观众看到手机屏幕的内容。而后者,其实可以理解为另外一种形式的“录屏”。 可见,“录屏”在我们日常生活中已经是一个十分常见的功能。而你是否思考过,录屏背后的原理是什么?录屏软件又是怎么获取到屏幕的画面内容的呢? 阅读完本文,你可以了解到: 在 App 渲染合成中的状态与事务(State and Transaction) 录屏背后的功臣——Virtual Display 的核心接口以及 SurfaceFlinger 是如何发现,处理 VirtualDisplay 录屏的原理以及完整的数据流传输 如果你对这些内容感兴趣,那就接着看下去吧。如果对这些冗长的分析感到头疼,想要直接看到结论,可以直接放到最后面的总结。 让我们开始吧。 VirtualDisplay 简介在目前的 Android 中,支持多种屏幕(Display,后文提到的 Display 都是指如下的各种屏幕)类型: 内置的主屏幕 通过 HDMI 连接的外接屏幕 虚拟屏幕(Virtual Display) 前两种都是有具体的物理屏幕设备的,而与之相反的 Virtual Display 则没有,是由 SurfaceFlinger 模拟出来的,一大作用就是给前面反复提到的“录屏”提供基础设施。 核心接口前面提到录屏背后用到的都是 VirtualDisplay,这里分别点一下 C++ 和 Java 中与 VirtualDisplay 相关的核心接口: C++在 Android 中有一个 screenrecord 的命令,这个命令是用纯 C++ 写的,源码路径在:frameworks/av/cmds/screenrecord/,通过这份谷歌官方的源码我们可以一窥 native 层实现录屏的原理(其实 Android 很早之前就支持录屏了哈哈)。其中的核心代码: static status_t prepareVirtualDisplay(const DisplayInfo& mainDpyInfo, const sp<IGraphicBufferProducer>& bufferProducer, sp<IBinder>* pDisplayHandle) { sp<IBinder> dpy = SurfaceComposerClient::createDisplay( String8("ScreenRecorder"), false /*secure*/); SurfaceComposerClient::Transaction t; t.setDisplaySurface(dpy, bufferProducer); ...... t.apply(); 这里面涉及到三个最为核心的接口: SurfaceComposerClient::createDisplay() sp<IBinder> SurfaceComposerClient::createDisplay(const String8& displayName, bool secure) { return ComposerService::getComposerService()->createDisplay(displayName, secure);} 实现非常简单,通过 Binder 调用 SurfaceFlinger 端的 createDisplay() 来创建 VirtualDisplay。而至于 SurfaceFlinger 是如何创建 VirtualDisplay 的,后面会详细分析。 SurfaceComposerClient::Transaction::setDisplaySurface() status_t SurfaceComposerClient::Transaction::setDisplaySurface(const sp<IBinder>& token, const sp<IGraphicBufferProducer>& bufferProducer) { ...... DisplayState& s(getDisplayState(token)); s.surface = bufferProducer; s.what |= DisplayState::eSurfaceChanged; return NO_ERROR;} 将上面创建的 VirtualDisplay 和本地的 IGraphicBufferProducer (Client 端通过 createBufferQueue() 可以获得 BufferQueue 的 IGraphicBufferProducer 和 IGraphicBufferConsumer)关联起来。注意这里的 DisplayState::eSurfaceChanged,会是后面一系列流程重要的标志位。 SurfaceComposerClient::Transaction::apply() 这个函数也非常重要,App 侧的改变都需要这个函数通知给 SurfaceFlinger 侧。 在后文会对三个接口做深入的分析。 Java在 Android Framework 中有一个类 OverlayDisplayAdapter,这个类是方便 Framework 开发者创建模拟辅助显示设备,同样也有 C++ 提到的三个核心接口。事实上,Java 端的这些接口其实都是对做了一些封装,最终通过 JNI 调用到 native 层,最终的实现都是在 SurfaceFlinger,这里就不过多描述,详细可以参考 @夕月风 大佬在简书上的博客:《Android P 图形显示系统(四) Android VirtualDisplay解析》 状态与事务状态DisplayState在 frameworks/native/libs/gui/include/gui/LayerState.h 里定义: struct DisplayState { enum { eOrientationDefault = 0, eOrientation90 = 1, eOrientation180 = 2, eOrientation270 = 3, eOrientationUnchanged = 4, eOrientationSwapMask = 0x01 }; enum { eSurfaceChanged = 0x01, eLayerStackChanged = 0x02, eDisplayProjectionChanged = 0x04, eDisplaySizeChanged = 0x08 }; DisplayState(); void merge(const DisplayState& other); uint32_t what; sp<IBinder> token; sp<IGraphicBufferProducer> surface; uint32_t layerStack; uint32_t orientation; Rect viewport; Rect frame; uint32_t width, height; status_t write(Parcel& output) const; status_t read(const Parcel& input);}; 这个结构体是在 Client 端(即 App 侧)定义的,里面描述了 Client 端关于 Display 所有状态的集合,包括了 Display 的方向,Display 里 Surface 改变,LayerStack 改变等(对应了上面的 enum 变量),what 是状态的集合,所有的状态可以通过 “与” 操作合并到一起(仔细看上面上面的 enum 变量的值,每一个状态都占用了十六进制的一位)。 DisplayDeviceStateframeworks/native/services/surfaceflinger/DisplayDevice.h struct DisplayDeviceState { bool isVirtual() const { return !displayId.has_value(); } int32_t sequenceId = sNextSequenceId++; std::optional<DisplayId> displayId; sp<IGraphicBufferProducer> surface; uint32_t layerStack = DisplayDevice::NO_LAYER_STACK; Rect viewport; Rect frame; uint8_t orientation = 0; uint32_t width = 0; uint32_t height = 0; std::string displayName; bool isSecure = false;private: static std::atomic<int32_t> sNextSequenceId;}; DisplayDeviceState 是在 Server 端(即 SurfaceFlinger 侧)定义的, 不光名字跟前面的 DisplayDevice 很像,内部成员也十分地类似。那么这两个类有什么关系呢? 个人是这么理解的,这两个类其实是 App 侧和 SurfaceFlinger 侧对于 Display 状态 的不同表示,前面提到的 SurfaceComposerClient::Transaction::apply() 的作用一个就是将 DisplayState 传递给 DisplayDeviceState,后文原理分析中会有详细说明。 还有一个点非常重要,DisplayDeviceState 是如何区分对应的 Display 是否为 VirtualDisplay 的呢?答案就在 displayId 的类型中 —— std::optional。std::optional 是 C++ 17 新引入的新特性,作用是方便表示或者处理一个变量“可能为空”的状态,如果在以前,我们会选择使用类似 NULL,null 或者 -1 这种特殊值来标记,但是现在,std::optional 给出了一种更加方便的方案,这里不做过多的语法描述。 在 DisplayDeviceState 中的 isVirtual() 就是用来判断该 DisplayDeviceState 对应的 Display 是否为 VirtualDisplay,而判断的依据就是 displayId.has_value(), 而对于 Virtual Display 来说,是不会对其 displayId 进行赋值的,而主显和外显则会赋值,因而 !displayId.has_value() 为 true,从而可以判断出 Display 是否为 VirtualDisplay。 DisplayToken上面提到的 DisplayState 和 DisplayDeiveState 都是需要跟具体 Display 设备(不管是否是 VirtualDisplay)绑定。而 DisplayToken 就是这些 state 类型跟具体 Display 设置连接的桥梁。 DisplayToken 其实只是一个 IBinder 类型的变量,并且其值本身是没有意义的,只是用来做索引罢了。 事务每一个 VSYNC 之间, Display 或者是各个 Layer 可能都会发生很多变化,这些变化被 SurfaceFlinger 打包在一起统一处理,统称为 Transaction——事务,在目前的 Android Q 中,上面涉及到各种 state,在 SurfaceFlinger 端被打包成如下的事务,用枚举变量描述: enum { eTransactionNeeded = 0x01, eTraversalNeeded = 0x02, eDisplayTransactionNeeded = 0x04, eDisplayLayerStackChanged = 0x08, eTransactionFlushNeeded = 0x10, eTransactionMask = 0x1f,}; 这些事务在 SurfaceFlinger::handleTransaction() 中被处理,而这个函数在每次 VSYNC-sf 触发 SurfaceFlinger 合成的时候都会调用一次。这就很像古代皇帝每天上早朝一般,handleTransaction() 就像皇上身边的那个太监喊了一声, “有事启奏,无事退朝” 如果上个 VSYNC 内 Client 端有 State 的变化,那么就会被 SurfaceFlinger 通过 handleTransaction() 知晓并且被处理,如同有大臣在底下说, “臣有事启奏” 然后皇帝一天忙碌的工作就开始了。 而这些事务会被统一记录在 mTransactionFlags 这个变量中,通过 setTransactionFlags() ,peekTransactionFlags() 和 getTransactionFlags 来更新/获取当前的 mTransactionFlags 的值: uint32_t SurfaceFlinger::peekTransactionFlags() { return mTransactionFlags;}// 注意:// 这里的 fetch_and() 和下面的 fetch_or(),这两个的函数值都是修改前的 mTransactionFlags,这一点非常重要uint32_t SurfaceFlinger::getTransactionFlags(uint32_t flags) { return mTransactionFlags.fetch_and(~flags) & flags;}uint32_t SurfaceFlinger::setTransactionFlags(uint32_t flags) { return setTransactionFlags(flags, Scheduler::TransactionStart::NORMAL);}uint32_t SurfaceFlinger::setTransactionFlags(uint32_t flags, Scheduler::TransactionStart transactionStart) { uint32_t old = mTransactionFlags.fetch_or(flags); mVsyncModulator.setTransactionStart(transactionStart); if ((old & flags)==0) { // wake the server up signalTransaction(); } return old;} peekTransactionFlags() 跟 getTransactionFlags() 从函数名看都是获取 mTransactionFlags 的值,但是其实有很大的区别。peekTransactionFlags() 只是简单地将当前的 mTransactionFlags 直接返回。而 getTransactionFlags() 则不然,它表面的作用是判断并返回当前的 mTransactionFlags 是否包含指定的 TransactionFlag(通过原来的 mTransactionFlags 跟传进来的 flag 做“与”操作)。 但是 getTransactionFlags() 会将原来 mTransactionFlags 的值,修改为只包含传进来的 TransactionFlags 的位,其余位都会置为 0。说句题外话,从上面的说明其实可以看到, peekTransactionFlags() 和 getTransactionFlags() 这两个函数的命名非常具有迷惑性,很容易带来认知上的误区。如果让我来命名的话,那么 peekTransactionFlags() 应该命名为 getTransactionFlags(),而 getTransactionFlags() 更加应该命名为 checkTransactionFlags()。 State看到这你可能会很奇怪,状态前面不是已经说了吗?为什么又蹦出了一个 State?其实这里的 State 是一个新的类,而之前在 讲解 fps 计算原理 提到了的 mCurrentState 和 mDrawingState,类型就是 State。 State 是 SurfaceFlinger 这个类里面的一个内部类: class State {public: explicit State(LayerVector::StateSet set) : stateSet(set), layersSortedByZ(set) {} ...... const LayerVector::StateSet stateSet = LayerVector::StateSet::Invalid; LayerVector layersSortedByZ; DefaultKeyedVector< wp<IBinder>, DisplayDeviceState> displays; bool colorMatrixChanged = true; mat4 colorMatrix; void traverseInZOrder(const LayerVector::Visitor& visitor) const; void traverseInReverseZOrder(const LayerVector::Visitor& visitor) const;}; 这个 State 类其实非常有说头,只不过他们我们本文的核心相关的就是里面的 displays 成员,他是一个 <DefaultKeyedVector> 类型(Android 自定义的一个类型,与 std::map 类似),key-value 就是我们前面都有提到的 DisplayToken 和 DisplayDeviceState。 mCurrentState 和 mDrawingState 侧重点不一样: mCurrentState 侧重于“变”,mCurrentState 代表的是当前系统最新的状态, 任何时刻发生的各种改变都会被记录在 mCurrentState 中 mDrawingState 侧重于“定”,mDrawingState 代表的是本次合成时的状态, SurfaceFlinger 在开始合成之前需要确定本次合成的状态,因此每次开始合成之前,SurfaceFlinger 都会通过 SurfaceFlinger::commitTransaction() 将记录了当前最新的状态的 mCurrentState 与 mDrawingState 做同步。 原理分析前面铺垫了这么长,终于来到了本文的中心内容了: 创建 VirtualDisplay不管是 Java 代码的 SurfaceControl.createVirtualDisplay() 还是 C++ 代码的 SurfaceComposerClient::createDisplay(),创建 VirtualDisplay 最终都会走到 SurfaceFlinger 的 createDisplay(): sp<IBinder> SurfaceFlinger::createDisplay(const String8& displayName, bool secure){ sp<BBinder> token = new DisplayToken(this); Mutex::Autolock _l(mStateLock); // Display ID is assigned when virtual display is allocated by HWC. DisplayDeviceState state; state.isSecure = secure; state.displayName = displayName; mCurrentState.displays.add(token, state); mInterceptor->saveDisplayCreation(state); return token;} 这个函数最重要的就是生成一个该 VirtualDisplay 的 DisplayDeviceState 和一个 DisplayToken,并且将这个 DisplayDeviceState 增加到 mCurrentState。 需要注意的是,此时 Virtual Display 其实还没有被真正地创建,这里只是通过修改 mCurrentState 记录一下状态的改变,真正的创建流程在后面。 state to transaction回过头看一下前面核心接口部分提到的 SurfaceComposerClient::Transaction::apply(): status_t SurfaceComposerClient::Transaction::apply(bool synchronous) { ...... sf->setTransactionState(composerStates, displayStates, flags, applyToken, mInputWindowCommands, mDesiredPresentTime, {} /*uncacheBuffer - only set in doUncacheBufferTransaction*/, listenerCallbacks); 这个函数最终会将 DisplayState 里面的 DisplayToken 和 DisplayState 等内容发通过 SurfaceFlinger::setTransactionState() 传递给 SurfaceFlinger 端,然后经过如下调用以后: SurfaceFlinger::setTransactionState() \_ SurfaceFlinger::applyTransactionState() \_ SurfaceFlinger::setDisplayStateLocked() 在 SurfaceFlinger::setDisplayStateLocked 中: uint32_t SurfaceFlinger::setDisplayStateLocked(const DisplayState& s) { const ssize_t index = mCurrentState.displays.indexOfKey(s.token); if (index < 0) return 0; uint32_t flags = 0; DisplayDeviceState& state = mCurrentState.displays.editValueAt(index); const uint32_t what = s.what; if (what & DisplayState::eSurfaceChanged) { if (IInterface::asBinder(state.surface) != IInterface::asBinder(s.surface)) { state.surface = s.surface; flags |= eDisplayTransactionNeeded; } } 将 DisplayState 中的 Surface (即 App 端创建的 BufferProducer) 传递给 DisplayDeviceState,同时将 eSurfaceChanged(回想一下前面的内容,surface 和 what 都是在 SurfaceComposerClient::Transaction::setDisplaySurface() 设置的) 转换为 eDisplayTransactionNeeded。这一下,不仅完成了 DisplayState 的内容传递到 DisplayDeviceState,还完成了 state 转为 Transaction 这一伟大壮举,SurfaceFlinger 终于了解到了 App 侧状态的变动。 然后回到 SurfaceFlinger::applyTransactionState() 将前面的 eDisplayTransactionNeeded 这个事务通过 SurfaceFlinger::setTransactionFlags() 保存起来,等待被处理。 SurfaceFlinger 处理事务前面的 eDisplayTransactionNeeded 这个事务将会在下一个 SurfaceFlinger 的合成流程中,经过如下的函数调用: SurfaceFlinger::handleMessageTransaction() \_ SurfaceFlinger::handleTransacion() \_ SurfaceFlinger::handleTransactionLocked() 最终在 processDisplayChangesLocked() 中被处理。 首先大家思考一个问题: ❔ SurfaceFlinger 怎么知道在上个 VSYNC 中新增或者移除了 Display 呢? TODO: 这里要做成可隐藏的:答案就是前面提到的 mDrawingState 和 mCurrentState。mCurrentState 代表的是最新的状态,mDrawingState 代表的是上一次合成的状态(相对本次合成来说,在未 commitTransaction() 之前),因此假设: 在 mCurrentState 中的 DisplayDeviceState 中有但是在 mDrawingState 中没有,那么就说明在上一个 VSYNC 中新增了 Display 在 mDrawingState 中的 DisplayDeviceState 中有但是在 mCurrentState 中没有,那么就说明在上一个 VSYNC 中有 Display 被移除了 了解了这个以后我们就可以很简单地判断 Display 的变动了,本文的分析侧重于新增 Display: void SurfaceFlinger::processDisplayChangesLocked() { ...... // find displays that were added // (ie: in current state but not in drawing state) for (size_t i = 0; i < cc; i++) { if (draw.indexOfKey(curr.keyAt(i)) < 0) { const DisplayDeviceState& state(curr[i]); sp<compositionengine::DisplaySurface> dispSurface; sp<IGraphicBufferProducer> producer; sp<IGraphicBufferProducer> bqProducer; sp<IGraphicBufferConsumer> bqConsumer; getFactory().createBufferQueue(&bqProducer, &bqConsumer, false); std::optional<DisplayId> displayId; if (state.isVirtual()) { if (state.surface != nullptr) { ...... // 给 VirtualDisplay 创建其 DisplaySurface —— VirtualDisplaySurface sp<VirtualDisplaySurface> vds = new VirtualDisplaySurface(getHwComposer(), displayId, state.surface, bqProducer, bqConsumer, state.displayName); dispSurface = vds; producer = vds; } } else { displayId = state.displayId; LOG_ALWAYS_FATAL_IF(!displayId); // 给 主显/外显 创建其 DisplaySurface —— FrameBufferSurface dispSurface = new FramebufferSurface(getHwComposer(), *displayId, bqConsumer); producer = bqProducer; } const wp<IBinder>& displayToken = curr.keyAt(i); if (dispSurface != nullptr) { // 真正创建 DisplayDevice 的地方,并且加入到 mDisplays mDisplays.emplace(displayToken, setupNewDisplayDeviceInternal(displayToken, displayId, state, dispSurface, producer)); if (!state.isVirtual()) { LOG_ALWAYS_FATAL_IF(!displayId); dispatchDisplayHotplugEvent(displayId->value, true); } } } } 新增 Display 内容这部分内容比较多,分为两部分说明(说明:剩下的内容会着重于代码流程以及数据流转,涉及的众多类以及其子类会新开一篇文章详细说明。同时下面的内容也会涉及到 CompositionEngine 这一部分的内容,也会先粗略带过,会单开新的文章单独说明): 创建 DisplaySurface前面提到,Android 支持多种 Display 类型,而每一个 Display 都会有一个关联的 Buffer,这个 Buffer 使用 DisplaySurface 这个类进行描述。不同类型的 Display 采用的 DisplaySurface 也不尽相同:主显和外显采用的是 FrameBufferSurface,而虚显采用的是 VirtualDisplaySurface: VirtualDisplaySurface::VirtualDisplaySurface(HWComposer& hwc, const std::optional<DisplayId>& displayId, const sp<IGraphicBufferProducer>& sink, const sp<IGraphicBufferProducer>& bqProducer, const sp<IGraphicBufferConsumer>& bqConsumer, const std::string& name) ......{ mSource[SOURCE_SINK] = sink; mSource[SOURCE_SCRATCH] = bqProducer; App 侧传过来的 BufferProducer 被保存为 VirtualDisplaySurface 里面的 mSource[SOURCE_SINK],这一点很重要,后文会用到。 创建 DisplayDevice然后利用前面创建的 VirtualDisplaySurface,调用 setupNewDisplayDeviceInternal(): sp<DisplayDevice> SurfaceFlinger::setupNewDisplayDeviceInternal( const wp<IBinder>& displayToken, const std::optional<DisplayId>& displayId, const DisplayDeviceState& state, const sp<compositionengine::DisplaySurface>& dispSurface, const sp<IGraphicBufferProducer>& producer) { ...... auto nativeWindowSurface = getFactory().createNativeWindowSurface(producer); auto nativeWindow = nativeWindowSurface->getNativeWindow(); creationArgs.nativeWindow = nativeWindow; ...... sp<DisplayDevice> display = getFactory().createDisplayDevice(std::move(creationArgs)); ....... return display;} 首先 setupNewDisplayDeviceInternal() 这个函数的 displaySurface 和 producer 这两个参数都是前面创建的 VirtualDisplaySurface。 接着利用前面创建的 VirtualDisplaySurface,使用 createNativeWindowSurface() 创建一个 native window。这里简单说明一下 native window 这个概念: 我们知道,OpenGL ES 是一个跨平台的图形 API,但是即便是跨平台,最终也是需要在具体的平台上落地的,落地就需要在特定的平台系统上“本地化”——把跨平台的 OpenGL ES 跟具体平台中的窗口系统建立起关联,这样才能保证正常工作,而为 OenGL ES 提供本地窗口(即 native window)的就是 EGL,具体到 Android 里,native window 其实就是指 Surface 这个类,在 frameworks/native/libs/gui/Surface.cpp 中定义。 然后看一下 native window 是怎么创建的: std::unique_ptr<surfaceflinger::NativeWindowSurface> createNativeWindowSurface( const sp<IGraphicBufferProducer>& producer) { class NativeWindowSurface final : public surfaceflinger::NativeWindowSurface { public: explicit NativeWindowSurface(const sp<IGraphicBufferProducer>& producer) : mSurface(new Surface(producer, /* controlledByApp */ false)) {} ~NativeWindowSurface() override = default; sp<ANativeWindow> getNativeWindow() const override { return mSurface; } void preallocateBuffers() override { mSurface->allocateBuffers(); } private: sp<Surface> mSurface; }; return std::make_unique<NativeWindowSurface>(producer);} 再看一下 Surface 的构造函数: Surface::Surface(const sp<IGraphicBufferProducer>& bufferProducer, bool controlledByApp) : mGraphicBufferProducer(bufferProducer), ...... 从这个构造函数可以很清楚地看到,创建出来的 native window,即 Surface,是将前面创建的 VirtualDisplaySurface 给 mGraphicBufferProducer 赋值的。请记住这一点,后面的数据流传输会用到。 然后就使用 createDisplayDevice() 创建一个 DisplayDeivce 并且添加到 mDisplays 中,VirtualDisplay 才算真正创建完毕。 数据流传输然后一切准备就绪以后,我们终于来到最终的数据流传输。 每次合成的时候,SurfaceFlinger 对每个 DisplayDevice 依次调用 doDisplayComposition()。在 VirtualDisplay 的 doDisplayComposition() 中,会调用 dequeueBuffer() 给接下来的合成(目前看 VirtualDisplay 都是 GPU 合成)申请 Buffer,这个 dequeueBuffer() 的调用流程十分值得说道说道: 回想一下前文我们提到,setupNewDeviceInternal() 中的 createNativeWindow(),将 VirtualDisplaySurface 为其成员 mGraphicBufferProducer 赋值,而在 SurfaceFlinger::dequeueBuffer() 中: status_t result = mGraphicBufferProducer->dequeueBuffer(&buf, &fence, reqWidth, reqHeight, reqFormat, reqUsage, &mBufferAge, enableFrameTimestamps ? &frameTimestamps : nullptr); 会去调用 mGraphicBufferProducer->dequeueBuffer(), 因而会转而 VirtualDisplaySurface::dequeueBuffer(): status_t VirtualDisplaySurface::dequeueBuffer(int* pslot, sp<Fence>* fence, uint32_t w, uint32_t h, PixelFormat format, uint64_t usage, uint64_t* outBufferAge, FrameEventHistoryDelta* outTimestamps) { if (!mDisplayId) { return mSource[SOURCE_SINK]->dequeueBuffer(pslot,fence, w, h, format, usage, outBufferAge, outTimestamps); } ...... 回想一下前面的内容,对于 VirtualDisplay 来说,displayId 为空,因而会直接调用 mSource[SOURCE_SINK] 的 dequeueBuffer(),而我们前面提到,mSource[SOURCE_SINK],就是 App 端传来的 BufferProducer。 因此,最终整个 dequeueBuffer() 的调用流程如下: RenderSurface::dequeueBuffer() \_ Surface::hook_dequeueBuffer() \_ Surface::dequeueBuffer() \_ VirtualDisplaySurface::dequeueBuffer() \_ 在这里调用了 Client 端的 BufferProducer 的 dequeueBuffer() 经过一系列的 dequeueBuffer() 调用,SurfaceFlinger 最终拿到了 App 侧的 BufferQueue 申请到的 Buffer,给录屏 App 进行一次独立的合成,并将合成的内容渲染到从 App 侧拿到的 Buffer。是的,你没有看错,在这个场景里,SurfaceFlinger 是内容的生产者,录屏 App 才是内容的消费者。最后,SurfaceFlinger 合成再通过 queueBuffer() 将渲染完的 Buffer 还给录屏 App: void SurfaceFlinger::doDisplayComposition(const sp<DisplayDevice>& displayDevice, const Region& inDirtyRegion) { ...... if (!doComposeSurfaces(displayDevice, Region::INVALID_REGION, &readyFence)) return; // swap buffers (presentation) display->getRenderSurface()->queueBuffer(std::move(readyFence)); 完整的调用流程跟 dequeueBuffer() 是完全一致,不再赘述。 最后,App 通过 onFrameAvailable() 得到新 Buffer 的通知,通过 acquireBuffer((),在这里拿到合成完的 Buffer(即当前屏幕的内容),就可以对该 Buffer 然后就可以开始进行各种处理(例如编解码等)了。至此,整个数据传输的完整流程就说明完毕了。 总结一句话总结录屏的原理就是: 录屏软件通过创建一个 VirtualDisplay,然后每次 SurfaceFlinger 在做合成的时候,会对 VirtualDisplay 做一次独立的合成,并将合成完的结果渲染到录屏软件传递过来的 Buffer。而录屏软件在拿到装有当前画面的 Buffer 以后,就可以对 Buffer 进行进一步的处理如去做编解码等,从而达到录屏的目的。 而: App 侧的改动,如新建的 VirtualDisplay 如何被 SurfaceFlinger 知晓 屏幕的内容是如何从 SurfaceFlinger 传递到录屏 App 而这两点,都可以用下面的这张图来总结: 小插曲之前在分析 DisplayState 的内容是怎么传递给 DisplayDeviceState 的时候卡了很久,原因是我固执地认为 SurfaceFlinger::setTransactionState() 只有 Display 在初始化的时候才会调用,并且自信地加上了如下的 debug log: 结果我就被满屏的 simowce: I don;t believe this’ll print twice or more 啪啪打脸了: 尾巴有读者问过我为什么你写东西写得那么慢?其实答案非常简单,因为我写的东西都是我不会且感兴趣的,因此我都是边学边写,并且因为个人有一种小小的偏执,就是一个东西如果没弄懂就必须弄懂,因而就写得很慢很慢。但是请大家放心,大家能够看到的内容都是我再三确认没问题才会发表的,质量绝对是有保证的。希望有一天,我能够在某个领域,自豪地说出那四个字: 以我为准。]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>Graphic</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Android fps 的计算,原理及正确性分析]]></title>
<url>%2Fall-about-fps%2F</url>
<content type="text"><![CDATA[前言fps,是 frames per second 的简称,也就是我们常说的“帧率”。在游戏领域中,fps 作为衡量游戏性能的基础指标,对于游戏开发这和手机 vendor 厂商都是非常重要的数据,而计算游戏的 fps 也成为日常测试的基本需求。目前市面上使用得比较多的 Android 第三方工具有 XXTest,X否和 XXXXBench 等,那么这些工具计算 fps 的方法是什么,原理是什么呢?本文将针对这些问题,深入源码进行分析,力求找到一个详尽的答案(源码分析基于 Android Q) 计算方法目前绝大部分帧率统计软件,在网上能找到的各种统计 fps 的脚本,使用的信息来源有两种:一种是基于 dumpsys SurfaceFlinger --latency Layer-name(注意是 Layer 名字,不是包名,不是 Activity 名字,至于为什么,下面会解答);另一种是基于 dumpsys gfxinfo。其实这两种深究到原理基本上是一致的,本篇文章专注于分析第一种,市面上大部分帧率统计软件用的也是第一种,只不过部分软件为了避免被人反编译看到,将这个计算逻辑封装成 so 库,增加反编译的难度。然而经过验证,这些软件最后都是通过调用上面的命令来计算的 fps 的。 但是这个命令为什么能够计算 fps 呢?先来看这个命令的输出,以王者荣耀为例(王者荣耀这种游戏类的都是以 SurfaceView 作为控件,因此其 Layer 名字都以 SurfaceView - 打头): > adb shell dumpsys SurfaceFlinger --latency "SurfaceView - com.tencent.tmgp.sgame/com.tencent.tmgp.sgame.SGameActivity#0"1666666659069638658663 59069678041684 5906965415829859069653090955 59069695022100 5906967089423659069671034444 59069711403455 5906968794986159069688421840 59069728057361 5906970441512159069705420850 59069744773350 5906972076783059069719818975 59069761378975 5906973741600759069736702673 59069778060955 5906975456866359069753361528 59069794716007 5906977076163259069768766371 59069811380486 59069787649600...... 输出的这一堆数字究竟是什么意思?首先,第一行的数字是当前的 VSYNC 间隔,单位是纳秒。例如现在的屏幕是 60Hz 的,因此就是 16.6ms,然后下面的一堆数字,总共有 127 行(为什么是 127 行,下面也会说明),每一行有 3 个数字,每个数字都是时间戳,单位是纳秒,具体的意义后文会说明。而在计算 fps 的时候,使用的是第二个时间戳。原因同样会在后文进行解答。 fence 简析后面的原理分析涉及到 fence,但是 fence 囊括的内容众多,因此这里只是对 fence 做一个简单地描述。如果大家感兴趣,后面我会专门给 fence 写一篇详细的说明文章。 fence 是什么首先得先说明一下 App 绘制的内容是怎么显示到屏幕的: App 侧需要显示的内容要要绘制在 Buffer 里,而这个 Buffer 是从 BufferQueue 通过 dequeueBuffer() 申请的。申请到 Buffer 以后,App 将内容填充到 Buffer 以后,需要通过 queueBuffer() 将 Buffer 还回去交给 SurfaceFlinger 去进行合成和显示。然后,SurfaceFlinger 要开始合成的时候,需要调用 acquireBuffer() 从 BufferQueue 里面拿一个 Buffer 去合成,合成完以后通过 releaseBuffer() 将 Buffer 还给 BufferQueue,如下图: 在上面的流程中,其实有一个问题,就是在 App 绘制完通过 queueBuffer() 将 Buffer 还回去的时候,此时仅仅只是 CPU 侧的完成,GPU 侧实际上并没有真正完成。因此如果此时拿这个 Buffer 去进行合成/显示的话,就会有问题(Buffer 可能还没有完全地绘制完)。 事实上,由于 CPU 和 GPU 之前是异步的,因此我们在代码里面执行一系列的 OpenGL 函数调用的时候,看上去函数已经返回了,实际上,只是把这个命令放在本地的 command buffer 里。具体什么时候这条 GL command 被真正执行完毕 CPU 是不知道的,除非使用 glFinish() 等待这些命令完全执行完,但是这样会带来很严重的性能问题,因为这样会使得 CPU 和 GPU 的并行性完全丧失,CPU 会在 GPU 完成之前一直处于空等的状态。因此,如果能够有一种机制,在不需要对 Buffer 进行读写 的时候,大家各干各的;当需要对 Buffer 进行读写的时候,可以知道此时 Buffer 在 GPU 的状态,必要的时候等一下,就不会有上面的问题了。 fence 就是这样的同步机制,如它直译过来的意思一样——“栅栏”,用来把东西拦住。那么 fence 是要拦住什么东西呢?就是前面提到的 Buffer 了。Buffer 在整个绘制、合成、显示的过程中,一直在 CPU,GPU 和 HWC 之前传递,某一方要使用 Buffer 之前,需要检查之前的使用者是否已经移交了 Buffer 的“使用权”。而这里的“使用权”,就是 fence。当 fence 释放(即 signal)的时候,说明 Buffer 的上一个使用者已经交出了使用权,对于 Buffer 进行操作是安全的。 fence in Code在 Android 源码里面,fence 的实现总共分为四部分: fence driver同步的核心实现 libsync位于 system/core/libsync,libsync 的主要作用是对 driver 接口的封装 Fence 类这个 Fence 类位于 frameworks/native/libs/ui/Fence.cpp,主要是对 libsync 进行 C++ 封装,方便 framework 调用 FenceTime 类这个 FenceTime 是一个工具类,是对 Fence 的进一步封装,提供两个主要的接口——isValid() 和 getSignalTime(),主要作用是针对需要多次查询 fence 的释放时间的场景(通过调用 Fence::getSignalTime() 来查询 fence 的释放时间)。通过对 Fence 进行包裹,当第一次调用 FenceTime::getSignalTime() 的时候,如果 fence 已经释放,那么会将这个 fence 的释放时间缓存起来,然后下次再调用 FenceTime::getSignal() 的时间,就能将缓存起来的释放时间直接返回,从而减少对 Fence::getSignalTime() 不必要的调用(因为 fence 释放的时间不会改变)。 fence in Android在 Android 里面,总共有三类 fence —— acquire fence,release fence 和 present fence。其中,acquire fence 和 release fence 隶属于 Layer,present fence 隶属于帧(即 Layers): acquire fence前面提到, App 将 Buffer 通过 queueBuffer() 还给 BufferQueue 的时候,此时该 Buffer 的 GPU 侧其实是还没有完成的,此时会带上一个 fence,这个 fence 就是 acquire fence。当 SurfaceFlinger/ HWC 要读取 Buffer 以进行合成操作的时候,需要等 acquire fence 释放之后才行。 release fence当 App 通过 dequeueBuffer() 从 BufferQueue 申请 Buffer,要对 Buffer 进行绘制的时候,需要保证 HWC 已经不再需要这个 Buffer 了,即需要等 release fence signal 才能对 Buffer 进行写操作。 present fencepresent fence 在 HWC1 的时候称为 retire fence,在 HWC2 中改名为 present fence。当前帧成功显示到屏幕的时候,present fence 就会 signal。 原理分析简单版现在来看一下通过 dumpsys SurfaceFlinger --latency Layer-name 计算 Layer fps 的原理。dumpsys 的调用流程就不赘述了,最终会走到 SurfaceFlinger::doDump(): status_t SurfaceFlinger::doDump(int fd, const DumpArgs& args, bool asProto) NO_THREAD_SAFETY_ANALYSIS { ... static const std::unordered_map<std::string, Dumper> dumpers = { ...... {"--latency"s, argsDumper(&SurfaceFlinger::dumpStatsLocked)}, ...... }; 从这里可以看到,我们在执行 dumpsys SurfaceFlinger 后面加的那些 --xxx 参数最终都会在这里被解析,这里咱们是 --latency,因此看 SurfaceFlinger::dumpStatsLocked: void SurfaceFlinger::dumpStatsLocked(const DumpArgs& args, std::string& result) const { StringAppendF(&result, "%" PRId64 "\n", getVsyncPeriod()); if (args.size() > 1) { const auto name = String8(args[1]); mCurrentState.traverseInZOrder([&](Layer* layer) { if (name == layer->getName()) { layer->dumpFrameStats(result); } }); } else { mAnimFrameTracker.dumpStats(result); }} 从这里就能够看到,这里先会打印当前的 VSYNC 间隔,然后遍历当前的 Layer,然后逐个比较 Layer 的名字,如果跟传进来的参数一致的话,那么就会开始 dump layer 的信息;否则命令就结束了。因此,很多人会遇到这个问题: ❔ 为什么执行了这个命令却只打印出一个数字?✔ 其实这个时候你应该去检查你的 Layer 参数是否正确。 接下来 layer->dumpFrameStats() 会去调 FrameTrack::dumpStats(): void FrameTracker::dumpStats(std::string& result) const { Mutex::Autolock lock(mMutex); processFencesLocked(); const size_t o = mOffset; for (size_t i = 1; i < NUM_FRAME_RECORDS; i++) { const size_t index = (o+i) % NUM_FRAME_RECORDS; base::StringAppendF(&result, "%" PRId64 "\t%" PRId64 "\t%" PRId64 "\n", mFrameRecords[index].desiredPresentTime, mFrameRecords[index].actualPresentTime, mFrameRecords[index].frameReadyTime); } result.append("\n");} NUM_FRAME_RECORDS 被定义为 128,因此输出的数组有 127 个。每组分别有三个数字—— desiredPresentTime,actualPresentTime,frameReadyTime,每个数字的意义分别是: desiredPresentTime 下一个 HW-VSYNC 的时间 actualPresentTime retire fence signal 的时间戳 frameReadyTime acquire fence signal 的时间戳 结合前面对 present fence 的描述就可以看出 dumpsys SurfaceFlinger --latency 计算 fps 的原理: 从 dumpsys SurfaceFlinger --latency 获取到最新 127 帧的 present fence 的 signal time,结合前面对于 present fence 的说明,当某帧 present fence 被 signal 的时候,说明这一帧已经被显示到屏幕上了。因此,我们可以通过判断一秒内有多少个 present fence 被 signal,来反推出一秒内有多少帧被刷到屏幕上,而这,就是 fps 计算的原理。 复杂版我们已经知道了 fps 计算的原理了,但是呢,小朋友,你是否有很多问号? 这个 actualPresentTime 是从哪来的? 假设要统计 fps 的 Layer 没有更新,但是别的 Layer 更新了,这种情况下 present fence 也会正常 signal,那这样计算出来的 fps 是不是不准啊? 为了解答这些问题,我们还得接着看。 前面已经提到计算 fps 的时候使用的是第二个数值,因此后面的文章着重分析这个 actualPresentTime。那么 actualPresentTime 是在哪里赋值的呢?实际赋值的位置是在 FrameTracker::dumpStats() 调用的一个子函数——processFencesLocked(): void FrameTracker::processFencesLocked() const { FrameRecord* records = const_cast<FrameRecord*>(mFrameRecords); int& numFences = const_cast<int&>(mNumFences); for (int i = 1; i < NUM_FRAME_RECORDS && numFences > 0; i++) { size_t idx = (mOffset+NUM_FRAME_RECORDS-i) % NUM_FRAME_RECORDS; ... const std::shared_ptr<FenceTime>& pfence = records[idx].actualPresentFence; if (pfence != nullptr) { // actualPresentTime 是在这里赋值的 records[idx].actualPresentTime = pfence->getSignalTime(); if (records[idx].actualPresentTime < INT64_MAX) { records[idx].actualPresentFence = nullptr; numFences--; updated = true; } } ...... 其中,FrameRecord 的完整定义如下: struct FrameRecord { FrameRecord() : desiredPresentTime(0), frameReadyTime(0), actualPresentTime(0) {} nsecs_t desiredPresentTime; nsecs_t frameReadyTime; nsecs_t actualPresentTime; std::shared_ptr<FenceTime> frameReadyFence; std::shared_ptr<FenceTime> actualPresentFence;}; 从上面的代码可以看出,actualPresentTime 是调用 actualPresentFence 的 getSignalTime() 赋值的。 而 actualPresentFence 是通过 setActualPresentFence() 赋值的: void FrameTracker::setActualPresentFence( std::shared_ptr<FenceTime>&& readyFence) { Mutex::Autolock lock(mMutex); mFrameRecords[mOffset].actualPresentFence = std::move(readyFence); mNumFences++;} setActualPresentFence() 又是经过下面的调用流程最终被调用的: SurfaceFlinger::postComposition() \_ BufferLayer::onPostCompostion() 这里重点看一下 SurfaceFlinger::postComposition(): void SurfaceFlinger::postComposition(){ ...... mDrawingState.traverseInZOrder([&](Layer* layer) { bool frameLatched = layer->onPostComposition(displayDevice->getId(), glCompositionDoneFenceTime, presentFenceTime, compositorTiming); ...... 回忆一下我们前面的问题: ❔ 假设要统计 fps 的 Layer 没有更新,但是别的 Layer 更新了,这种情况下 present fence 也会正常 signal,那这样计算出来的 fps 是不是不准啊? 答案就在 mDrawingState,在 Surfacelinger 中有两个用来记录当前系统中 Layers 状态的全局变量: mDrawingStatemDrawingState 代表的是上次 “drawing” 时候的状态 mCurrentStatemCurrentState 代表的是当前的状态 因此,如果当前 Layer 没有更新,那么是不会被记录到 mDrawingState 里的,因此这一次的 present fence 也就不会被记录到该 Layer 的 FrameTracker 里的 actualPresentTime 了。 再说回来, SurfaceFlinger::postComposition() 是 SurfaceFlinger 合成的最后阶段。presentFenceTime 就是前面的 readyFence 参数了,它是在这里被赋值的: mPreviousPresentFences[0] = mActiveVsyncSource ? getHwComposer().getPresentFence(*mActiveVsyncSource->getId()) : Fence::NO_FENCE;auto presentFenceTime = std::make_shared<FenceTime>(mPreviousPresentFences[0]); 而 getPresentFence() 这个函数,就把这个流程转移到了 HWC 了: sp<Fence> HWComposer::getPresentFence(DisplayId displayId) const { RETURN_IF_INVALID_DISPLAY(displayId, Fence::NO_FENCE); return mDisplayData.at(displayId).lastPresentFence;} 至此,我们一路辗转,终于找到了这个 present fence 的真身,只不过这里它还蒙着一层面纱,我们需要在看一下这个 lastPresentFence 是在哪里赋值的,这里按照不同的合成方式位置有所不同: DEVICE 合成DEVICE 合成的 lastPresentFence 是在 HWComposer::prepare() 里赋值: status_t HWComposer::prepare(DisplayId displayId, const compositionengine::Output& output) { ...... if (!displayData.hasClientComposition) { sp<Fence> outPresentFence; uint32_t state = UINT32_MAX; error = hwcDisplay->presentOrValidate(&numTypes, &numRequests, &outPresentFence , &state); if (error != HWC2::Error::HasChanges) { RETURN_IF_HWC_ERROR_FOR("presentOrValidate", error, displayId, UNKNOWN_ERROR); } if (state == 1) { //Present Succeeded. ...... displayData.lastPresentFence = outPresentFence; 经常看 systrace 的同学对这个函数绝对不会陌生,就是 systrace 里面 SurfaceFlinger 的那个 prepare(): 这个函数非常重要,它通过一系列的调用: HWComposer::prepare() \_ Display::presentOrValidate() \_ Composer::presentOrValidateDisplay() \_ CommandWriter::presentOrvalidateDisplay() 最终通过 HwBinder 通知 HWC 的 Server 端开始进行 DEVICE 合成,Server 端在收到 Client 端的请求以后,会返回给 Client 端一个 present fence(时刻记住,fence 用于跨环境的同步,例如这里就是 Surfacelinger 和 HWC 之间的同步)。然后当下一个 HW-VSYNC 来的时候,会将合成好的内容显示到屏幕上并且将该 present fence signal,标志着这一帧已经显示在屏幕上了。 GPU 合成GPU 合成的 lastPresentFence 是在 presentAndGetPresentFences() 里赋值: status_t HWComposer::presentAndGetReleaseFences(DisplayId displayId) { ...... displayData.lastPresentFence = Fence::NO_FENCE; auto error = hwcDisplay->present(&displayData.lastPresentFence); 后面的流程就跟 DEVICE 合成类似了,Display::present() 最终也会经过一系列的调用,通过 HwBinder 通知 HWC 的 Server 端,调用 presentDisplay() 将合成好的内容显示到屏幕上。 总结说了这么多,一句话总结计算一个 App 的 fps 的原理就是: 统计在一秒内该 App 往屏幕刷了多少帧,而在 Android 的世界里,每一帧显示到屏幕的标志是: present fence signal 了,因此计算 App 的 fps 就可以转换为:一秒内 App 的 Layer 有多少个有效 present fence signal 了(这里有效 present fence 是指,在本次 VSYNC-sf 中该 Layer 有更新的 present fence) 尾巴这篇文章在二月份其实就已经完成了一多半了,但是一直拖到了五月才最终写完,因为其中涉及到很多我不知道的知识,例如 HWC。这块领域涉及到硬件,文档其实不多。因此在写的过程会变得异常痛苦,很多东西不懂,我也不知道自己写的东西究竟对不对,就需要花很多时间进行多方求证,找很多大佬提问。很多时候会卡在某一个地方很久,甚至会萌生随便写点糊弄过去算了的想法。而且,写到什么程度也很难拿捏,写浅了我自己过意不去,感觉对不起各位关注的读者;写深了我自己也是写不下去,毕竟这个领域确实之前没有接触过。不过好在这个过程中有很多大佬给我提供了很大的帮助,在此对各位在这几月中给我答疑解惑的各位大佬表示衷心的感谢。 写作是一个孤独的旅程,感谢各位大佬的指路,感谢各位读者的关注,你们是星星太阳和月亮,陪着我一直写下去,谢谢。]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>Graphic</tag>
</tags>
</entry>
<entry>
<title><![CDATA[GPU 驱动自升级及其原理]]></title>
<url>%2Fall-about-updated-driver%2F</url>
<content type="text"><![CDATA[全网首发,关于 Android updated GPU driver 的硬核分析 背景GPU 驱动自升级,是在以下两个痛点下催生出来的: 换机频率降低,手机的生命周期越来越长智能手机已经不再是一个增量市场,变成一个存量市场。消费者换机频率逐年降低,手机生命周期变长。生命周期变长了以后,那么如何更好地维护老机型就变成一个难题 开发者需要升级 ROM 才能用到 GPU driver 的新功能一直以来,游戏开发者想要升级到最新的 GPU 驱动的渠道跟普通的用户一样,只能靠 OEM 厂商的推送系统更新,他们没有一个渠道能够直接拿到最新的 GPU 驱动,因此无法第一时间对驱动的新特性进行适配。如何建立一个渠道能够让游戏开发者能够及时获取到最新的 GPU 驱动也是一个难题 有痛点就会有需求,GPU 驱动自升级就是针对这两个痛点提出的解决方案。 不过,这个方案要落地还会遇到很多实际问题:首先,GPU 驱动跟整个操作系统依赖极大,影响的模块很多,如 Display,Camera,Media 等,稍有不慎整个系统就会不稳定甚至 crash。 同时,这个功能要真正落地,还涉及到多方:GPU 驱动来自 SoC 厂商;Android 的 OEM 厂商等。每一方里涉及到厂商的又十分众多。因此在确定方案的时候需要保证能够有足够的兼容性。 以上这么多难题注定了这个方案从提出到落地,需要花很长的时间,走很长的路。 而事实也是如此,从 2017 年开始,Android O 提出了 Project Treble,引入了 VNDK 和 linker namespace,那时候其实已经在对这个方案进行技术铺垫了;再到 2018 年的 Android P,谷歌在 framework 加了很多 dummy code,此时整个方案的框架已经基本完成;再到 2019 年的 Android Q,这个方案作为 Q 的核心功能跟大家见面了。而小米十周年旗舰——小米 10/小米 10 Pro 也成为国内第一款落地 GPU 驱动自升级功能的 Android 智能手机。 在 GPU 驱动自升级方案中,可升级的 GPU 驱动分为两种,一种称为 Game Driver,面向的普通用户,主要是针对游戏这种十分依赖 GPU 的场景进行特殊优化,稳定的 driver;另一种称为 Prerelease Driver,面向游戏开发者,目的是让游戏开发者能够提前获取到包含新特性的,不稳定的驱动,进行提前的适配和利用。这两种驱动分别封装在两个 apk,分别通过两个 property 来分区:ro.gfx.driver.1 指定的是 Game Driver 的包名,ro.gfx.driver.1 指定的是 Prerelease Driver 的包名。 需要说明的是,虽然称为 apk,但是实际上更多地可以理解为是两个盛放 driver 的容器,apk 本身不具备任何的代码,而之所以要将其打包为 apk,更多的是为了利用 Android 的签名机制以及利用 PMS 能够对所有安装的 apk 进行统一的管理。 将 apk 解包以后可以放在整个包的结构如下: ├── AndroidManifest.xml├── assets│ ├── sphal_libraries.txt│ └── whitelist.txt├── lib│ ├── arm64-v8a│ └── armeabi-v7a└── res 分别说明一下各个结构的作用: sphal_libraries.txt能够被 gfx driver 访问的, sphal 中的库列表,后面会详细讲解 whitelist.txt使用 Game Driver 的包名白名单 libdriver 本体,分为 32 位和 64 位,根据应用的 abi 选择加载对应的 driver Game Driver 和 Prerelease Driver (后面统称为 updated driver)这两个 apk 的结构是一样的,只不过 lib 文件夹里面的驱动不一样而已。 updated driver 配置流程这部分讲讲在 app 启动的时候,是怎么给 app 挑选对应的 GPU driver 的。 app 在启动的过程中,经过下面的流转(详细的流程省略,非本文主题): ActivityThread::handleBindApplication() \__ActivityThread::setupGraphicsSupport() \__GraphicsEnvironment.setup() 最终走到核心代码: public void setup(Context context, Bundle coreSettings) { final PackageManager pm = context.getPackageManager(); final String packageName = context.getPackageName(); .... if (!chooseDriver(context, coreSettings, pm, packageName)) { setGpuStats(SYSTEM_DRIVER_NAME, SYSTEM_DRIVER_VERSION_NAME, SYSTEM_DRIVER_VERSION_CODE, SystemProperties.getLong(PROPERTY_GFX_DRIVER_BUILD_TIME, 0), packageName, getVulkanVersion(pm)); }} 核心函数 chooseDriver(): private static boolean chooseDriver( Context context, Bundle coreSettings, PackageManager pm, String packageName) { // PART 1 final String driverPackageName = chooseDriverInternal(context, coreSettings); if (driverPackageName == null) { return false; } ......} chooseDriverInternal()chooseDriverInternal(),这个函数的主要作用是,根据系统的配置,为当前启动的 app 判断是否需要加载 Game/Prerelease Driver,如果需要那么返回对应的 driver apk 的包名;如果不需要,那么返回 null,表明当前启动的 app 只需要加载 system gpu driver。判断的逻辑: private static String chooseDriverInternal(Context context, Bundle coreSettings) { // 判断 game driver 的 property 是否配置了 final String gameDriver = SystemProperties.get(PROPERTY_GFX_DRIVER); final boolean hasGameDriver = gameDriver != null && !gameDriver.isEmpty(); // 判断 prerelease driver 的 property 是否配置了 final String prereleaseDriver = SystemProperties.get(PROPERTY_GFX_DRIVER_PRERELEASE); final boolean hasPrereleaseDriver = prereleaseDriver != null && !prereleaseDriver.isEmpty(); // 两个 property 都没有配置,前面提到这两个 property 代表的是两个包名 // 如果都没有配置,那么说明这项功能没有使能,使用 system gpu driver if (!hasGameDriver && !hasPrereleaseDriver) { if (DEBUG) Log.v(TAG, "Neither Game Driver nor prerelease driver is supported."); return null; } // To minimize risk of driver updates crippling the device beyond user repair, never use an // updated driver for privileged or non-updated system apps. Presumably pre-installed apps // were tested thoroughly with the pre-installed driver. // 不是所有的 app 都可以使用这个功能的,如果当前是预装应用,那么也不会对其使用该功能 final ApplicationInfo ai = context.getApplicationInfo(); if (ai.isPrivilegedApp() || (ai.isSystemApp() && !ai.isUpdatedSystemApp())) { if (DEBUG) Log.v(TAG, "Ignoring driver package for privileged/non-updated system app."); return null; } // 接下来看下面的流程图: 在判断完当前的 app 需要使用的 driver 类型以后,返回到 chooseDriver()。 chooseDriver()private static boolean chooseDriver( Context context, Bundle coreSettings, PackageManager pm, String packageName) { final String driverPackageName = chooseDriverInternal(context, coreSettings); if (driverPackageName == null) { return false; } // PART 2 final PackageInfo driverPackageInfo; // 容纳 game driver 的 apk 必须是一个 system app,否则会直接跳过 try { driverPackageInfo = pm.getPackageInfo(driverPackageName, PackageManager.MATCH_SYSTEM_ONLY | PackageManager.GET_META_DATA); } catch (PackageManager.NameNotFoundException e) { Log.w(TAG, "driver package '" + driverPackageName + "' not installed"); return false; } // Android O 及以后,谷歌提出了 Project Triple,其中包括 VNDK,规定了 GPU driver 必须是在 sphal 的 linker space // 因此 driver apk 的 targetSdkVersion 必须大于等于 O // O drivers are restricted to the sphal linker namespace, so don't try to use // packages unless they declare they're compatible with that restriction. final ApplicationInfo driverAppInfo = driverPackageInfo.applicationInfo; if (driverAppInfo.targetSdkVersion < Build.VERSION_CODES.O) { if (DEBUG) { Log.w(TAG, "updated driver package is not known to be compatible with O"); } return false; } // 判断当前运行 app 是 32 位的还是 64 位的,以便加载正确 abi 的 gpu driver final String abi = chooseAbi(driverAppInfo); if (abi == null) { if (DEBUG) { // This is the normal case for the pre-installed empty driver package, don't spam if (driverAppInfo.isUpdatedSystemApp()) { Log.w(TAG, "updated driver package has no compatible native libraries"); } } return false; } final StringBuilder sb = new StringBuilder(); sb.append(driverAppInfo.nativeLibraryDir) .append(File.pathSeparator); sb.append(driverAppInfo.sourceDir) .append("!/lib/") .append(abi); final String paths = sb.toString(); // 读取 driver apk 里面的 sphal_libraries.txt 的内容 final String sphalLibraries = getSphalLibraries(context, driverPackageName); if (DEBUG) { Log.v(TAG, "gfx driver package search path: " + paths + ", required sphal libraries: " + sphalLibraries); } // 配置阶段最重要的一个函数,下面详解 setDriverPathAndSphalLibraries(paths, sphalLibraries); ...... return true;} 其中,path 这个变量存放了当前 app 的 GPU Driver 的加载路径,类似于: /system/app/xxx/lib/:/system/app/xxx/xxx.apk!/lib/armeabi-v7a/ 包括两部分,使用 : 分隔,一部分是 driver apk 所在的 lib 路径,另一个的 driver apk 包内部的 lib 路径,请注意这里有一个特殊的符号:!/,这个符号的意义后面会详细说明。 在确定了启动的 apk 需要用到的 driver apk 以后,最终会走到一个最重要的函数:setDriverPathAndSphalLibraries(),通过这个函数,带上两个参数—— driver path 和 sphal_libraries.txt 里面的内容。查看这个函数的定义,其实这个是一个 JNI 函数,真正的实现在这里(frameworks/native/libs/graphicsenv/GraphicsEnv.cpp): void GraphicsEnv::setDriverPathAndSphalLibraries(const std::string path, const std::string sphalLibraries) { ...... mDriverPath = path; mSphalLibraries = sphalLibraries;} 这里就是简单地将这两个变量存起来。至此,updated driver 的配置流程就走完了,而要看这两个变量到底怎么用,得看下面的加载流程。 VNDK 和 linker namespace在说明加载流程之前,需要说明一下 linker namespace,这个是 GPU 驱动自升级用到的核心技术,而这个技术是在 Google 在 Android O 的 Project Treble 引入的。要理解这个技术的作用先要理解一下 Project Treble。 Project Treble 将系统拆分解耦为两部分——system 和 vendor,诣在分离 android framework 和硬件驱动的耦合,system 分区只存放原生 android 相关的内容,vendor 分区存放厂商相关定制的内容。目的是为了达到在保持 vendor 分区不变的情况下,仅通过升级 system 分区即可升级到新版本的 Android。前面提到 system 和 vendor 要保持独立,不能相互依赖。但是也不可能完全独立,双方还是存在一些依赖的,。基于这样的需求,将 system 分区的 so 分为以下: FMK-ONLYframework 专有库,称为 FMK-ONLY,vendor 无法访问 LLNDKAPI/ABI 稳定的,system 和 vendor 共享一套的库,称为 LLNDK VNDKvendor 如果需要访问到一部分的 FMK-ONLY 的库,那么可以通过 VNDK。VNDK 是专门给 vendor 模块维护的一部分 system 库,位于 /system/lib[64]/vndk-${version}/,在 FMK-ONLY 可以找到同名的库,但是两者的符号表是不一致,是 FMK-ONLY 的同名不同副本。如下图: VNDK-SPVNDK-SP 属于 VNDK, 是 SP-HAL 维二能够依赖的模块(另一个是上面提到的 LLNDK)。而 SP-HAL 是 system 访问 vendor 的唯一途径,位于 vendor 分区。如果此时 system 和 vendor 需要涉及到相同的库,那么 vendor 调用的就是 VDNK-SP,路径是 /system/lib[64]/vndk-sp-${version}/。这么说可能比较难以理解,举个例子:surfaceflinger 属于 system 的进程,但是当它要进行 GPU 合成的时候,需要访问位于 vendor 分区的 GPU 驱动,此时需要通过 SP-HAL—— libGLES_${chipset}.so,而此时 surfaceflinger 和 libGLES_${chipset}.so 都要依赖 libcutils.so,我们就称 libGLES_${chipset}.so 依赖的 libcutils.so 为 VNDK-SP。如下图: 用一张图展示上面各个名词在各个分区的位置: 通过上面的例子可以看到,在 surfaceflinger 其实是有两个 libcutils.so 的副本的,两个库名字一样,但是符号表完全不一样,如何正确加载不引起冲突呢?linker namespace 就是为解决这个问题而提出的技术。LLNDK 和 VNDK-SP 的 libcutils.so 分别隶属于不同的 liner namespace,这样就可以避免冲突问题,各自加载各自的库。降维理解就是:linker namespace 相当于 shell 的 $PATH 环境变量。当我们在 shell 里面敲入 java 的时候,shell 会从 $PATH 里面一个一个寻找 java 可执行二进制文件。同样的, so 库在被加载的时候同样有一个类似环境变量的东西(称为“搜索路径”)告诉 linker 链接器去哪里找到这个 so,而 linker namespace 相当于就是通过创建两个隔离的空间,不同的空间有不同的搜索路径,不同的进程从不同的空间加载 so 库,从而避免了加载库的时候出现的名字一致的问题。详细的在后面的加载流程会具体说明。 driver 加载时机在 Android Q 上,EGL/OpenGL driver 加载时机有两种,一种是 zygote 在启动的时候会先将他们进行预加载,另一种是 Activity 在启动的时候也会去进行加载,下面分别说明: zygote 预加载 GPU driver我们知道,所有的 Android 进程都是由 zygote 或者 zygote64 孵化出来的,而当 zygote 和 zygote64 在启动的时候,会去将 gpu driver 进行预加载(省略了 zygote 启动的完整流程,与主题无关): frameworks/base/core/java/com/android/internal/os/ZygoteInit.javaZygoteInit::main() \__ ZygoteInit::preload() \__ZygoteInit:maybePreloadGraphicsDriver() \__ZygoteInit::nativePreloadGraphicsDriver() 然后通过 JNI 走到:frameworks/base/core/jni/com_android_internal_os_ZygoteInit.cpp 的 android_internal_os_ZygoteInit_nativePreloadGraphicsDriver: void android_internal_os_ZygoteInit_nativePreloadGraphicsDriver(JNIEnv* env, jclass) { ScopedSCSExit x; // 这里通过判断 debug.hwui.renderer 这个 property 的值: // skiavk: hwui 使用 Vulkan // skiagl: hwui 使用 OpenGL if (Properties::peekRenderPipelineType() == RenderPipelineType::SkiaGL) { eglGetDisplay(EGL_DEFAULT_DISPLAY); }} 然后通过下面路径: eglGetDisplay() \__egl_init_drivers() \__egl_init_drivers_locked() \__Loader::open() 走到最终的 driver 加载函数 Loader::open()。接下来看第二种: activity 加载 GPU driver当启动一个 Activity 的时候会调用 startActivity 的方法,这个方法会 Binder 调用到 system_server AMS,AMS 作为中转经过一系列处理后会 Binder 调用到目标进程 ApplicationThread(Activity 的启动这里就不展开了,与主题无关),最终会调用到 ActivityThread 的 handleLaunchActivity 方法,而 gpu driver 就是在这里加载的: ActivityThread::handleLaunchActivity() \__HardwareRenderer::preload() 这里的 preload() 这一个 JNI 方法,具体的实现流程: android_view_ThreadedRenderer_preload() \__RenderProxy::preload() \__RenderThread::preload() 而 RenderThread::preload() 里面会去调用 eglGetDisplay(): void RenderThread::preload() { // EGL driver is always preloaded only if HWUI renders with GL. if (Properties::getRenderPipelineType() == RenderPipelineType::SkiaGL) { std::thread eglInitThread([]() { eglGetDisplay(EGL_DEFAULT_DISPLAY); }); eglInitThread.detach(); } else { requireVkContext(); } HardwareBitmapUploader::initialize();} 最终也会走到 Loader::open() 这个函数,在这个函数完成真正完成的加载流程: void* Loader::open(egl_connection_t* cnx) 在说明加载流程之前,得先说明一下 egl_connection_t 这个结构体,整个加载过程就是对这个结构体的填充赋值,因此需要对这个结构体有深刻地理解: egl_connection_tstruct egl_connection_t { enum { GLESv1_INDEX = 0, GLESv2_INDEX = 1 }; inline egl_connection_t() : dso(nullptr), libEgl(nullptr), libGles1(nullptr), libGles2(nullptr), systemDriverUnloaded(false) { ...... } void * dso; gl_hooks_t * hooks[2]; EGLint major; EGLint minor; EGLint driverVersion; egl_t egl; // Functions implemented or redirected by platform libraries platform_impl_t platform; void* libEgl; void* libGles1; void* libGles2; // 当需要使能 updated driver 的时候,需要将默认的 gpu driver unload,再加载 updated driver // 当 updated driver 加载完成以后,会将 systemDriverUnloaded 设置为 true bool systemDriverUnloaded; ......}; 结构体中有几个重要的成员变量: void * dso存放的其实是这个结构的地址: struct driver_t { explicit driver_t(void* gles); ~driver_t(); // returns -errno int set(void* hnd, int32_t api); void* dso[3];}; 主要的是 void *dso[]:dso[0] 存放了 libEGL.so 的句柄(dlopen 的返回值,后面一致);dso[1] 存放了 libGLESv1_CM.so 的句柄;dso[2] 存放了 libGLESv2.so 的句柄。作用是判断 GPU driver 是否已经加载了。 gl_hooks_t * hooks[2]hooks[0] 保存了所有 GLESv1_CM 函数的符号地址;hooks1 保存了所有 GLESv2 函数的符号地址 egl_t eglegl 保存了所有 EGL 函数的符号地址 void * libEgl/libGles1/libGles2保存了 libEGL.so,libGLESv1_CM.so,libGLESv2.so 这三个 wrapper 库的句柄 updated driver 加载流程libEGL/libGLES(后面统称为 GPU driver) 这些库都是在 Loader::open() 里面完成的。这个函数分为两部分——首先判断是否需要 unload system GPU driver,如果需要会先执行 unload 操作;接着尝试加载 game/prerelease driver(这两种 driver 在这里走的流程是完全一致的,因此后面统称为 updated driver);如果失败会再尝试多次加载 system GPU driver。虽然是先 unload 再 load,但是如果不先说明 load 的流程,unload 会难以说明清楚,所以下面的内容会先说明 load 的流程,再说明 unload 的流程: 加载 updated driver加载 updated driver 在 attempt_to_load_updated_driver() 这个函数中完成,这个函数用到了三个核心函数: getDriverNamespace() 配置 linker namespace load_updated_driver() 加载 GPU driver initialize_api() 获取符号地址 getDriverNamespace() 配置 linker namespaceandroid_namespace_t* GraphicsEnv::getDriverNamespace() { std::lock_guard<std::mutex> lock(mNamespaceMutex); if (mDriverNamespace) { return mDriverNamespace; } if (mDriverPath.empty()) { return nullptr; } auto vndkNamespace = android_get_exported_namespace("vndk"); if (!vndkNamespace) { return nullptr; } mDriverNamespace = android_create_namespace("gfx driver", mDriverPath.c_str(), // ld_library_path mDriverPath.c_str(), // default_library_path ANDROID_NAMESPACE_TYPE_ISOLATED, nullptr, // permitted_when_isolated_path nullptr); if (!linkDriverNamespaceLocked(vndkNamespace)) { mDriverNamespace = nullptr; } return mDriverNamespace;} getDriverNamespace() 这个函数主要做了以下的事情: 使用前面配置流程的最后一步,updated driver 的路径就是被存放在 mDriverPath 这个变量。而现在 mDriverPath 被当做参数传给 android_create_namespace()。这里就需要说明一下 android_create_namespace() 这个函数,它的作用是创建一个 linker namespace,各个参数: android_create_namespace( const char *name, // 这个 link namespace 的名字 const char *ld_library_path, const char *default_library_path, unint64_t type, const char *permitted_when_isolated_path, android_namespace_t *parent); 参数中有很多个路径,这些表明了这个 linker namespace 中库的搜索路径,按照先 ld_library_path 后 default_library_path 的顺序,并且如果 namespace 的 type 是 ANDROID_NAMESPACE_TYPE_ISOLATED,那么如果在前面的路径找不到,还会在 permitted_when_isolated_path 里面寻找。 这些路径最终会传到 create_namespace(): ns->set_ld_library_paths(std::move(ld_library_paths));ns->set_default_library_paths(std::move(default_library_paths));ns->set_permitted_paths(std::move(permitted_paths)); 这里只是把路径保存在 app 的 gfx driver 的 linker namespace 里面。至于这些路径怎么用,后面加载部分会详细介绍。 因此这里创建里一个名为 gfx driver 的 linker namespace,并且指定 mDriverPath 为该 linker namespace 的库搜索路径。 调用 linkDriverNamespaceLocked(): bool GraphicsEnv::linkDriverNamespaceLocked(android_namespace_t* vndkNamespace) { const std::string llndkLibraries = getSystemNativeLibraries(NativeLibrary::LLNDK); if (llndkLibraries.empty()) { return false; } if (!android_link_namespaces(mDriverNamespace, nullptr, llndkLibraries.c_str())) { ALOGE("Failed to link default namespace[%s]", dlerror()); return false; } const std::string vndkspLibraries = getSystemNativeLibraries(NativeLibrary::VNDKSP); if (vndkspLibraries.empty()) { return false; } if (!android_link_namespaces(mDriverNamespace, vndkNamespace, vndkspLibraries.c_str())) { ALOGE("Failed to link vndk namespace[%s]", dlerror()); return false; } if (mSphalLibraries.empty()) { return true; } // Make additional libraries in sphal to be accessible auto sphalNamespace = android_get_exported_namespace("sphal"); if (!sphalNamespace) { ALOGE("Depend on these libraries[%s] in sphal, but failed to get sphal namespace", mSphalLibraries.c_str()); return false; } if (!android_link_namespaces(mDriverNamespace, sphalNamespace, mSphalLibraries.c_str())) { ALOGE("Failed to link sphal namespace[%s]", dlerror()); return false; } return true;} 这个函数多次调用了 android_link_namespaces() 这个函数: bool android_link_namespaces(struct android_namespace_t* from, struct android_namespace_t* to, const char* shared_libs_sonames); 作用是,from 这个 namespace 能够访问到 shared_libs_sonames(内容是一连串以 : 分隔开来的 so 的名字,例如:libc.so:libmath.so)的这些库,但是 shared_libs_sonames 却是从 to 这个 namespace 中加载的。因此,linkDriverNamespaceLocked() 这个函数做了这几件事: 让 gfx driver 能够访问 LLNDK 的库 让 gfx driver 能够访问 VNDKSP 的库 让 gfx driver 能够从 sphal 这个 link namespace 访问到 mSphalLibraries 的库。前面提到,sphallibraries.txt 的内容被存放在 mSpahlLibraries 这个变量中。至此,我们终于能够理解 sphallibraries.txt 这个文件里面的内容的意义,就是能够被 gfx driver 访问的, sphal 中的库列表。 通过 linkDriverNamespaceLocked(),gfx driver 这个 linker namespace 变得非常完备以及真正可用。updated driver 能够正确地在这个新创建的 linker namespace 中被搜索和加载。 通过一张图总结上面的内容: getDriverNamespace() 创建,配置完 gfx driver 这个 linker namespace 之后,就开始真正的加载流程,主要都是在 load_updated_driver() 这个函数完成的: load_updated_driver() 加载 GPU driverstatic void* load_updated_driver(const char* kind, android_namespace_t* ns) { ATRACE_CALL(); // 当 flags 为 ANDROID_DLEXT_USE_NAMESPACE 的时候,说明库是从 library_namespace 指定的 link namespace 加载 const android_dlextinfo dlextinfo = { .flags = ANDROID_DLEXT_USE_NAMESPACE, .library_namespace = ns, }; void* so = nullptr; // static const char* DRIVER_SUFFIX_PROPERTY = ; // // static const char* HAL_SUBNAME_KEY_PROPERTIES[2] = { // "ro.hardware.egl" // 在 8250 平台是 adreno // "ro.board.platform", // 在 8250 平台是 kona // }; // 这里拼凑出 SoC 私有的 GPU driver 的名字,例如高通的 GPU driver 名字为:libGLESv2_adreno.so char prop[PROPERTY_VALUE_MAX + 1]; for (auto key : HAL_SUBNAME_KEY_PROPERTIES) { if (property_get(key, prop, nullptr) <= 0) { continue; } std::string name = std::string("lib") + kind + "_" + prop + ".so"; so = do_android_dlopen_ext(name.c_str(), RTLD_LOCAL | RTLD_NOW, &dlextinfo); if (so) { return so; } } return nullptr;} do_android_dlopen_ext 实际上调用的就是 android_dlopen_ext(const char* __filename, int __flags, const android_dlextinfo* __info),前两个参数的意义跟 dlopen() 是一致的(参见这里),另外的 __info 这个参数是 Android 自定义的,例如这里的 dlextinfo 就是表明当前这个 so 是从前面创建的 gfx driver 这个 link namespace 中加载。 这里需要注意 kind 有两种情况:在具体的实现中,GPU driver 要么全都集成在一个库:/vendor/lib/egl/libGLES.so,此时 kind 就是 GLES;要么是(最常见的)拆分成三个库:/vendor/lib/egl/libEGL.so|libGLESv1_CM.so|libGLESv2.so,此时 kind 总共有三类:EGL,GLESv1_CM,GLESv2,其中 GLESv1_CM 代表的是 OpenGL ES 1.1 的驱动,GLESv2 代表的是 OpenGL ES 2.0/3.0/3.1 的驱动。 加载函数 android_dlopen_ext() 经过下面的调用: android_dlopen_ex() \_ _loader_android_dlopen_ext() \_ dlopen_ext() \_ do_dlopen() \_ find_library() \_ find_libraries() \_ find_library_internal() \_ load_library() \_ open_library() open_library() 把存起来的路径传给 open_library_on_paths: int fd = open_library_on_paths(zip_archive_cache, name, file_offset, ns->get_ld_library_paths(), realpath); 这个函数把前面创建 link namespace 的时候保存的路径当做参数,然后会去检查 path 里面是否有 !/ 分隔符,如果有,那么就从 apk 这个 zip 包里面把 so 解压出来: static int open_library_at_path(ZipArchiveCache* zip_archive_cache, const char* path, off64_t* file_offset, std::string* realpath) { int fd = -1; // const char* const kZipFileSeparator = "!/"; if (strstr(path, kZipFileSeparator) != nullptr) { fd = open_library_in_zipfile(zip_archive_cache, path, file_offset, realpath); } 因此现在可以解答前面的疑问:path 里面带 !/ 的意义是:从 apk 的包里面解压拿到包里面 !/ 后面的路径代表的内容。,例如前面的 /system/app/xxx/xxx.apk!/lib/armeabi-v7a/ 意义就是从 /system/app/xxx/xxx.apk 这个 zip 包里面的 lib/armeabi-v7a/。 同样用一张图来总结上面的内容: initialize_api 获取符号地址这个函数依据不同的类型(EGL,GLESv1_CM,GLESv2),获取符号地址的方式也不同: EGL if (mask & EGL) { getProcAddress = (getProcAddressType)dlsym(dso, "eglGetProcAddress"); ALOGE_IF(!getProcAddress, "can't find eglGetProcAddress() in EGL driver library"); egl_t* egl = &cnx->egl; __eglMustCastToProperFunctionPointerType* curr = (__eglMustCastToProperFunctionPointerType*)egl; char const * const * api = egl_names; while (*api) { char const * name = *api; __eglMustCastToProperFunctionPointerType f = (__eglMustCastToProperFunctionPointerType)dlsym(dso, name); if (f == nullptr) { // couldn't find the entry-point, use eglGetProcAddress() f = getProcAddress(name); if (f == nullptr) { f = (__eglMustCastToProperFunctionPointerType)nullptr; } } *curr++ = f; api++; }} 这里首先获取到 eglGetProcAddress() 的符号地址并且保存在 getProcAddress 这个变量,后面会用到。eglGetProcAddress 这个函数的作用是返回一个 EGL/GL(ES) 的符号地址,作用跟后面的 dlsym() 类似。需要注意的是 egl_names: char const * const egl_names[] = { #include "egl_entries.in" nullptr}; 而 elg_entries.in 的内容是一系列的 EGL 标准接口的名称字符串: #define EGL_ENTRY(_r, _api, ...) #_api,EGL_ENTRY(EGLDisplay, eglGetDisplay, NativeDisplayType) 因此 egl_names 的内容就是一个 EGL 接口函数的字符串数组,遍历该数组,并且从 load_updated_driver() 加载的库调用 dlsym() 获取接口函数的符号地址(前面提到 load_updated_driver() 调用的 android_dlopen_ext() 跟 dlopen() 作用类似),获取到的函数符号地址最终都保存在 cnx->egl: struct egl_connection_t { ... egl_t egl;} struct egl_t { #include "EGL/egl_entries.in" }; 神奇的事情来了,这里 struct egl_t 的定义和上面的 egl_names[] 用到了同样的 egl_entries.in,但是在 struct egl_t 中 EGL_ENTRY 的定义不一样了,这里变成了函数指针: #define EGL_ENTRY(_r, _api, ...) _r (*(_api))(__VA_ARGS__); 所以 cnx->egl 保存了所有 EGL 函数的符号地址。 GLESv1_CM/GLESv2GLESv1_CM 和 GLESv2 加载主要都在 init_api() 完成,函数的作用是将 GLESv1_CM 的所有函数符号地址保存到 cnx->hooks[0],GLESv2 保存在 cnx->hooks[1]: void init_api(void* dso, char const * const * api, char const * const * ref_api, __eglMustCastToProperFunctionPointerType* curr, getProcAddressType getProcAddress); 解释一下各个参数的意义: api这个参数,GLESv1_CM 传的是 gl_names_1,GLESv2 传的是 gl_names 这两个跟前面的 egl_names 一样,同样是 #include 一个 in 文件(gl_names_1 是 entries_gles1.in,gl_names 是 entries.in),这些 in 文件跟 egl_entries.in 类似: GL_ENTRY(void, glActiveShaderProgram, GLuint pipeline, GLuint program) 跟前面 EGL_ENTRY 一样,也是有两个定义,这里是将接口转成接口的函数声明字符串: #define GL_ENTRY(_r, _api, ...) #_api, ref_api这个参数的作用是加速 GLESv1_CM 符号表地址的加载速度,在获取 GLESv1_CM 所有函数的符号表时,ref_api 赋值为 gl_names(也就是 GLESv2 的函数列表),然后在获取符号表的时候,如果当前的函数属于 GLESv2,那么就将地址赋值为 NULL 并且跳过。原理是 GLESv1_CM 是 GLESv2 的子集,而在 cnx->hook[2] 的声明中: struct egl_connection_t { ...... gl_hooks_t * hooks[2];};struct gl_hooks_t { struct gl_t { #include "entries.in" } gl; struct gl_ext_t { __eglMustCastToProperFunctionPointerType extensions[MAX_NUMBER_OF_GL_EXTENSIONS]; } ext;}; 数组两个都是 gl_hooks_t,即 GLESv2 的长度。因此对于 GLESv1_CM 的库,如果函数名属于 GLESv2,那么可以直接跳过符号表获取的逻辑转而赋值为 NULL,从而加快 GLESv1_CM 的处理速度。 curr分别对应了上面提到的 cnx->hooks[],GLESv1_CM 对应 cnx->hooks[0],GLESv2 对应 cnx->hooks[1],代表这符号表地址保存的位置。 getProcAddress前面获取 EGL 库的符号地址的时候提到的,eglGetProcAddress 的符号地址。 接着,获取 GLESv1_CM/GLESv2 的符号地址按照下面的顺序进行: 直接 dlsym() 尝试获取 如果失败,使用 eglGetProcAddress() 尝试获取 如果失败,尝试使用 dlsym() 加载非 OES 的版本。例如 glAlphaFuncxOES() 获取符号地址失败,那么尝试获取 glAlphaFuncx() 这个非 OES 的符号地址(注:假设 OpenGL 针对移动平台作了专门的修改或者优化,就会加上 OES) 如果失败,尝试使用 dlsym() 加载 OES 的版本 如果都失败,那么将当前函数的符号表赋值为 gl_unimplemented,意为没有实现。 总结总结一下,updated driver 的加载主要在 attempt_to_load_system_driver() 中完成,主要分为三步: 创建/配置 link namespace,主要作用是将 app 加载 GPU driver 的路径转到 updated driver apk 所在的位置 从 updated driver apk 中依次将 libEGL,libGLESv1_CM 和 libGLESv2 打开获取 handle 从第二步获取的 handle 中获取各个 EGL, GLES 的函数的符号地址 其实看上去跟常规的加载库的步骤是一直的,只不过多了第一步的创建 link namespace 限定 so 的加载路径。 至此,整个 updated driver 的加载流程就完全讲完了。再用一张图总结前面所有的内容: unload system driver如driver 加载时机那一节提到的那样,zygote[64] 在启动的时候会去将 system driver 预加载,而所有 Android 进程都是由 zygote[64] 孵化的,因此每一个进程默认 system driver 都是已经加载完毕的,因此对于那些需要 updated driver 的 app,就需要将 system driver unload。而是否需要 unload system driver 是通过判断 mDriverPath 是否为空(updated driver 的配置过程提到,这个变量的意义是 updated driver 的具体路径)。而 unload 的操作也非常简单,跟前面的加载恰恰相反,就是将存放 EGL 和 GLES 库的 handle 和函数符号地址的 hooks[] 和 dso[] 这两个变量的内容都置为 nullptr,这里就不赘述了。]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>Graphic</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Android DispSync 详解]]></title>
<url>%2Fall-about-dispsync%2F</url>
<content type="text"><![CDATA[关于 DispSync 的方方面面,你都可以在这篇文章找到答案 DispSync 是什么?在 Android 4.1 的时候,Google 提出了著名的 “Project Butter”,引入了 VSYNC,把 app 画图,SurfaceFlinger 合成的时间点都规范了起来,减少了掉帧,增强了渲染的流畅度。但是这里有个问题,因为 VSYNC 是由硬件产生的,一旦产生了你就必须开始干活,不灵活。假设有这么一种需求,我希望在 VSYNC 偏移一段时间以后再干活,那么这个是硬件 VSYNC 提供不了,所以这个时候就必须引入软件模型。而 DispSync 就是为了解决这个需求引入的软件模型。DispSync 类似于一个 PLL(phase lock loop,锁相回路),它通过接收硬件 VSYNC,然后给其他关心硬件 VSYNC 的组件(SurfaceFlinger 和需要渲染的 app)在指定的偏移以后发送软件 VSYNC,并且当误差在可接受的范围内,将会关闭硬件 VSYNC。谷歌的这篇文档里面详细有一张非常准确的图: (为了方便,后面所有的硬件 VSYNC 使用 HW-VSYNC 代指,软件 VSYNC 使用 SW-VSYNC 代指) 综述前面提到 DispSync 是一个模拟 HW-VSYNC 的软件模型,在这个模型里面包含几个部分: DispSync DispSync 的主体,主要负责启动 DispSyncThread,接收 HW-VSYNC 并且更新计算出 SW-VSYNC 间隔—— mPeriod DispSyncThread DispSync 的一个内部线程类,主要功能是模拟 HW-VSYNC 的行为,大部分时间都处于阻塞状态,利用 DispSync 算出的 mPeriod,周期性地在下一个 SW-VSYNC 时间点(加了偏移的)醒来去通知对 VSYNC 感兴趣的 Listener —— DispSyncSource DispSyncSource SurfaceFlinger 的一个内部类,实现了 DispSync::Callback 的接口,DispSyncThread 和 EventThread 的中间人 EventThread VSYNC 的接收实体,收到 DispSync 的 SF-VSYNC 再进行分发,SurfaceFlinger 和 app 分别有自己的 EventThread—— sfEventThread 和 appEventThread Connection EventThread 内部类,任何一个对 VSYNC 感兴趣的(SurfaceFlinger,需要渲染画面的 app)都会在 EventThread 里面抽象为一个 Connection EventControlThread 大部分博客都将其描述为硬件 VSYNC 的“闸刀”,也就是负责控制硬件 VSYNC 的开关 MessageQueue SurfaceFlinger 用来在 sfEventThread 注册 DisplayEventReceiver app 用来在 appEventThread 注册 下面来详细描述一下整个初始化的流程。 初始化首先说明一下,DispSync 的初始化流程初看是十分复杂的,首先它涉及到比较多的线程,并且线程在很多时候是处于阻塞状态的,导致整个流程处于一个不连续的状态。因此谁把哪个线程唤醒了就变得十分重要,这也是理解整个初始化过程中的一个难点。 DispSync 和 DispSyncThread-01DispSync 在 SurfaceFlinger 里只有一个实例 —— mPrimaryDipsSync,它在 SurfaceFlinger 的初始化分两部分,创建实例 mPrimaryDispSync 然后执行其 init() 方法。DispSync 的构造函数非常简单,都是一些赋值: DispSync::DispSync(const char* name) : mName(name), mRefreshSkipCount(0), mThread(new DispSyncThread(name)) {} explicit DispSyncThread(const char* name) : mName(name), mStop(false), mPeriod(0), mPhase(0), mReferenceTime(0), mWakeupLatency(0), mFrameNumber(0) {} 然后来看 init() 方法: void DispSync::init(bool hasSyncFramework, int64_t dispSyncPresentTimeOffset) { mIgnorePresentFences = !hasSyncFramework; mPresentTimeOffset = dispSyncPresentTimeOffset; mThread->run("DispSync", PRIORITY_URGENT_DISPLAY + PRIORITY_MORE_FAVORABLE); // set DispSync to SCHED_FIFO to minimize jitter struct sched_param param = {0}; param.sched_priority = 2; if (sched_setscheduler(mThread->getTid(), SCHED_FIFO, &param) != 0) { ALOGE("Couldn't set SCHED_FIFO for DispSyncThread"); } reset(); beginResync(); ...} DispSycn::init() 最主要的就是工作就是让 DispSyncThread 运行起来,并且将其调度优先级改为 SCHED_FIFO,这样做的目的是什么呢?我们前面提到,DispSyncThread 大部分时间都在阻塞,它会“睡”到下次 SW-VSYNC 开始的时间戳,因此当其被唤醒的时候,高优先级能够保证其尽快地被调度,减少误差。执行完 mThread->run() 以后,就会开始执行 DispSyncThread::threadLoop(): virtual bool threadLoop() { status_t err; nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC); while (true) { Vector<CallbackInvocation> callbackInvocations; nsecs_t targetTime = 0; { // Scope for lock Mutex::Autolock lock(mMutex); ... if (mStop) { return false; } if (mPeriod == 0) { err = mCond.wait(mMutex); // 第一次初始化,由于 mPeriod 为 0,所以会先 block 在这里 目前 DispSyncThread 会阻塞在这里,我们接下去看。 EventThread-01在 SurfaceFlinger 初始化的时候,会创建两个 EventThread,一个给 SurfaceFlinger,一个给 app: void SurfaceFlinger::init() { ... // start the EventThread mEventThreadSource = std::make_unique<DispSyncSource>(&mPrimaryDispSync, SurfaceFlinger::vsyncPhaseOffsetNs, true, "app"); mEventThread = std::make_unique<impl::EventThread>(mEventThreadSource.get(), [this]() { resyncWithRateLimit(); }, impl::EventThread::InterceptVSyncsCallback(), "appEventThread"); mSfEventThreadSource = std::make_unique<DispSyncSource>(&mPrimaryDispSync, SurfaceFlinger::sfVsyncPhaseOffsetNs, true, "sf"); mSFEventThread = std::make_unique<impl::EventThread>(mSfEventThreadSource.get(), [this]() { resyncWithRateLimit(); }, [this](nsecs_t timestamp) { mInterceptor->saveVSyncEvent(timestamp); }, "sfEventThread"); 前面提到,DispSyncSource 是 DispSyncThread 和 EventThread 的中间人,先来看一下 DispSyncSource 的构造函数: class DispSyncSource final : public VSyncSource, private DispSync::Callback {public: DispSyncSource(DispSync* dispSync, nsecs_t phaseOffset, bool traceVsync, const char* name) : mName(name), mValue(0), mTraceVsync(traceVsync), mVsyncOnLabel(String8::format("VsyncOn-%s", name)), mVsyncEventLabel(String8::format("VSYNC-%s", name)), mDispSync(dispSync), mCallbackMutex(), mVsyncMutex(), mPhaseOffset(phaseOffset), mEnabled(false) {} 请注意这里有一个非常重要的点,就是 mVsyncEventLabel(String8::format("VSYNC-%s", name))。SurfaceFlinger 的 DispSyncSource 传进来的 name 是 “sf”,app 的 DispSyncSource 传进来的 name 是 “app”,所以连起来就是 “VSYNC-sf” 和 “VSYNC-app”。为什么说重要呢?来看一段 systrace: 这里面的 VSYNC-app 和 VSYNC-sf 就是说的 DispSyncSource,至于它的意义,后面会提到。 然后 DispSyncSource 作为参数传给 EventThread 的构造函数: EventThread::EventThread(VSyncSource* src, ResyncWithRateLimitCallback resyncWithRateLimitCallback, InterceptVSyncsCallback interceptVSyncsCallback, const char* threadName) : mVSyncSource(src), mResyncWithRateLimitCallback(resyncWithRateLimitCallback), mInterceptVSyncsCallback(interceptVSyncsCallback) { for (auto& event : mVSyncEvent) { event.header.type = DisplayEventReceiver::DISPLAY_EVENT_VSYNC; event.header.id = 0; event.header.timestamp = 0; event.vsync.count = 0; } mThread = std::thread(&EventThread::threadMain, this); pthread_setname_np(mThread.native_handle(), threadName); pid_t tid = pthread_gettid_np(mThread.native_handle()); // Use SCHED_FIFO to minimize jitter constexpr int EVENT_THREAD_PRIORITY = 2; struct sched_param param = {0}; param.sched_priority = EVENT_THREAD_PRIORITY; if (pthread_setschedparam(mThread.native_handle(), SCHED_FIFO, &param) != 0) { ALOGE("Couldn't set SCHED_FIFO for EventThread"); } set_sched_policy(tid, SP_FOREGROUND);} 构造函数的最主要功能就是把 EventThread 的线程主体 threadMain 运行起来并且设置其优先级为 SCHED_FIFO,接下来看 threadMain: void EventThread::threadMain() NO_THREAD_SAFETY_ANALYSIS { std::unique_lock<std::mutex> lock(mMutex); while (mKeepRunning) { DisplayEventReceiver::Event event; Vector<sp<EventThread::Connection> > signalConnections; signalConnections = waitForEventLocked(&lock, &event); // dispatch events to listeners... const size_t count = signalConnections.size(); for (size_t i = 0; i < count; i++) { const sp<Connection>& conn(signalConnections[i]); // now see if we still need to report this event status_t err = conn->postEvent(event); ... }} threadMain 的主要工作是调用 waitForEventLocked 等待一个 Event,然后在一个个地通知 signalConnections。至于这个 Event 和 signalConnections 分别是什么,后面会具体描述。现在先来看一下 waitForEventLocked 的逻辑: // This will return when (1) a vsync event has been received, and (2) there was// at least one connection interested in receiving it when we started waiting.Vector<sp<EventThread::Connection> > EventThread::waitForEventLocked( std::unique_lock<std::mutex>* lock, DisplayEventReceiver::Event* event) { Vector<sp<EventThread::Connection> > signalConnections; while (signalConnections.isEmpty() && mKeepRunning) { bool eventPending = false; bool waitForVSync = false; size_t vsyncCount = 0; nsecs_t timestamp = 0; // 在前面 EventThread 的构造函数里面已经把 mVSyncEvent 数组内的所有 timestamp 都置为 0 // 因此在第一次初始化的时候,这个循环会直接退出 for (int32_t i = 0; i < DisplayDevice::NUM_BUILTIN_DISPLAY_TYPES; i++) { ... } // 第一次初始化的时候 mDisplayEventConnections 的数组也为空,count 为 0 size_t count = mDisplayEventConnections.size(); if (!timestamp && count) { ... } // 第一次初始化不执行这个循环 for (size_t i = 0; i < count;) { ... } // timestamp 为 0, waitForVSync 为 false if (timestamp && !waitForVSync) { ... } else if (!timestamp && waitForVSync) { ... } // eventPending 为 false,符合条件 if (!timestamp && !eventPending) { if (waitForVSync) { ... // waitForVSync 为 false,进入 else } else { // 最终,在第一次初始化的时候,EventThread 就阻塞在这里了 mCondition.wait(*lock); } } } ...} 好,到这里,SurfaceFlinger 创建的两个 EventThread 都会阻塞在上面代码提到的地方,SurfaceFlinger 的初始化继续执行。 补充:SurfaceFlinger 的启动首先说明一下 mEventQueue 是在哪里被初始化的。是在 SurfaceFlinger 的另一个方法: 提到这里就需要 SurfaceFlinger 是怎么启动和初始化的。SurfaceFlinger 作为系统最基本最核心的服务之一,是通过 init.rc 的方式进行启动的(内容在 frameworks/native/services/surfaceflinger/surfaceflinger.rc): service surfaceflinger /system/bin/surfaceflinger class core animation user system group graphics drmrpc readproc input onrestart restart zygote ... 然后就需要提到 SurfaceFlinger 的组成部分,init.rc 里面提到的 /system/bin/surfaceflinger 这个二进制文件,由 main_surfaceflinger.cpp 这个文件编译得到;而上面提到 DispSync,EventThread 等,都被编译到了 libsurfaceflinger.so 这个库。这也给了我们一个启示:当我们在自己调试 SurfaceFlinger 的时候,大部分时间都只需要重新编译 libsurfaceflinger.so 这个文件即可。 回来简单看一下 SurfaceFlinger 是如何启动的,来看看 main_surfaceflinger.cpp: int main(int, char **) { ... sp<SurfaceFlinger> flinger = DisplayUtils::getInstance()->getSFInstance(); ... flinger->init(); ... 这里的重点就是这个 sp<SurfaceFlinger>,当被 sp 指针引用的时候,会触发 onFirstRef() 函数: void SurfaceFlinger::onFirstRef(){ mEventQueue->init(this);} 这样,就走到了 MessageQueue 部分了: MessageQueue接着 EventThread,然后就执行到这里: void SurfaceFlinger::init() { ... mEventQueue->setEventThread(mSFEventThread.get()); mEventQueue 在前面的 SurfaceFlinger::onFirstRef() 中完成了初始化: void MessageQueue::init(const sp<SurfaceFlinger>& flinger) { mFlinger = flinger; mLooper = new Looper(true); mHandler = new Handler(*this);} 接着来看一下很重要的 setEventThread(): void MessageQueue::setEventThread(android::EventThread* eventThread) { if (mEventThread == eventThread) { return; } if (mEventTube.getFd() >= 0) { mLooper->removeFd(mEventTube.getFd()); } mEventThread = eventThread; mEvents = eventThread->createEventConnection(); mEvents->stealReceiveChannel(&mEventTube); mLooper->addFd(mEventTube.getFd(), 0, Looper::EVENT_INPUT, MessageQueue::cb_eventReceiver, this);} 重点来了,前面创建的 SurfaceFlinger 的 EventThread 被作为参数传给了 setEventThread,并且执行了 EventThread 的 createEventConnection()。(注意,需要时时刻刻地记住,现在处理的 SurfaceFlinger 的 EventThread) (后面为了方便,将使用 sfEventThread 指代 SurfaceFlinger 的 EventThread;使用 appEventThread 指代 app 的 EventThread) EventThread::Connectionsp<BnDisplayEventConnection> EventThread::createEventConnection() const { return new Connection(const_cast<EventThread*>(this));} 在这里,sfEventThread 迎来了第一个(同时也是唯一的) Connection: EventThread::Connection::Connection(EventThread* eventThread) : count(-1), mEventThread(eventThread), mChannel(gui::BitTube::DefaultSize) {}void EventThread::Connection::onFirstRef() { // NOTE: mEventThread doesn't hold a strong reference on us mEventThread->registerDisplayEventConnection(this);}status_t EventThread::registerDisplayEventConnection( const sp<EventThread::Connection>& connection) { std::lock_guard<std::mutex> lock(mMutex); mDisplayEventConnections.add(connection); mCondition.notify_all(); return NO_ERROR;} MessageQueue 调用 sfEventThread 的 createEventConnection 创建一个 Connection。由于 sp 指针的作用,将会调用 Connection::onFirstRef,最终这个 Connection 会被添加到 mDisplayEventConnections 并且唤醒在 EventThread - 01 中阻塞的线程。 EventThread-02在前面把 EventThread 唤醒后,由于 signalConnections 为空,继续循环。然后由于新加入的 Connection count 为 -1,所以这个 EventThread 会继续阻塞,不过此时 mDisplayEventConnections 里面已经有一个 Connection 了。接着看下去。 EventControlThread-01SurfaceFlinger::init() 接着运行到这里: void SurfaceFlinger::init() { ... mEventControlThread = std::make_unique<impl::EventControlThread>( [this](bool enabled) { setVsyncEnabled(HWC_DISPLAY_PRIMARY, enabled); }); 主要提一下的是,这个传进来的参数是一个 Lambda 表达式,具体的语法不讲。稍微解释一下这里传进来的 Lambda 表达式的意义就是,捕获列表为 SurfaceFlinger 本身,接受一个布尔参数,当这个 Lamda 表达式被调用的时候,会调用 SurfaceFlinger::setVsyncEnabled() 这个函数,这个函数后面会提到,也是一个很重要的函数。 EventControlThread 的构造函数的主要内容也是启动一个线程: EventControlThread::EventControlThread(EventControlThread::SetVSyncEnabledFunction function) : mSetVSyncEnabled(function) { pthread_setname_np(mThread.native_handle(), "EventControlThread"); pid_t tid = pthread_gettid_np(mThread.native_handle()); setpriority(PRIO_PROCESS, tid, ANDROID_PRIORITY_URGENT_DISPLAY); set_sched_policy(tid, SP_FOREGROUND);}void EventControlThread::threadMain() NO_THREAD_SAFETY_ANALYSIS { auto keepRunning = true; auto currentVsyncEnabled = false; while (keepRunning) { mSetVSyncEnabled(currentVsyncEnabled); std::unique_lock<std::mutex> lock(mMutex); // keepRunning 为 true,currentVsyncEnabled 为 false,mVsyncEnabled 默认值为 false,mKeepRunning 默认值为 true,因此 Lambda 表达式为 false,线程阻塞 mCondition.wait(lock, [this, currentVsyncEnabled, keepRunning]() NO_THREAD_SAFETY_ANALYSIS { return currentVsyncEnabled != mVsyncEnabled || keepRunning != mKeepRunning; }); currentVsyncEnabled = mVsyncEnabled; keepRunning = mKeepRunning; }} 此时,EventControlThread 也会陷入阻塞之中。而 SurfaceFlinger 也将迎来初始化中最为复杂的一步。 唤醒所有线程至此,SurfaceFlinger 总共起了四个线程 —— DispSyncThread,两个 EvenThread 和 EventControlThread,并且这四个线程全都处于阻塞状态。导致这些线程处于阻塞状态的原因是: DispSyncThread: mPeriod 为 0 EventThread: Connection->count 为 -1 EventControlThread: mVsyncEnabled 为 false 然后让我们一个个将其唤醒。 EventThread-03接下来的 SurfaceFlinger 会进行非常复杂的初始化操作,EventThread 唤醒相关的调用流程如下(这里借用了这位大佬《Android SurfaceFlinger SW Vsync模型》的内容,写得非常棒,在学习的过程中能够得到了很大的启发): initializeDisplays(); flinger->onInitializeDisplays(); setTransactionState(state, displays, 0); setTransactionFlags(transactionFlags); signalTransaction(); mEventQueue->invalidate(); mEvents->requestNextVsync() //mEvents是Connection实例 EventThread->requestNextVsync(this); void EventThread::requestNextVsync(const sp<EventThread::Connection>& connection) { ... if (connection->count < 0) { connection->count = 0; mCondition.notify_all(); }} 在这里把前面创建的那个 Connection 的 count 置为 0,并且唤醒阻塞的 EventThread,这个时候,mDisplayEventConnections 不为空并且 count 不为 -1,可以正常地运行了,EventThread::waitForEventLocked() 走到了这里: } else if (!timestamp && waitForVSync) { // we have at least one client, so we want vsync enabled // (TODO: this function is called right after we finish // notifying clients of a vsync, so this call will be made // at the vsync rate, e.g. 60fps. If we can accurately // track the current state we could avoid making this call // so often.) enableVSyncLocked(); } void EventThread::enableVSyncLocked() { // 一般都为 false if (!mUseSoftwareVSync) { // never enable h/w VSYNC when screen is off if (!mVsyncEnabled) { mVsyncEnabled = true; mVSyncSource->setCallback(this); mVSyncSource->setVSyncEnabled(true); } } mDebugVsyncEnabled = true;} 调用了 DispSyncSource::setCallback(),将 EventThread 和 DispSyncSource 联系在了一起: void setCallback(VSyncSource::Callback* callback) override{ Mutex::Autolock lock(mCallbackMutex); mCallback = callback;} 接着调用 DispSyncSource::setVSyncEnabled: void setVSyncEnabled(bool enable) override { Mutex::Autolock lock(mVsyncMutex); // true if (enable) { status_t err = mDispSync->addEventListener(mName, mPhaseOffset, static_cast<DispSync::Callback*>(this)); ...} 最终调用了 DispSync::addEventListener: status_t addEventListener(const char* name, nsecs_t phase, DispSync::Callback* callback) { if (kTraceDetailedInfo) ATRACE_CALL(); Mutex::Autolock lock(mMutex); // 保证了 mEventListeners 的唯一性 for (size_t i = 0; i < mEventListeners.size(); i++) { if (mEventListeners[i].mCallback == callback) { return BAD_VALUE; } } EventListener listener; listener.mName = name; listener.mPhase = phase; listener.mCallback = callback; listener.mLastEventTime = systemTime() - mPeriod / 2 + mPhase - mWakeupLatency; mEventListeners.push(listener); // 唤醒 DispSyncThread mCond.signal(); return NO_ERROR;} 把 DispSyncSource 加到 mEventListeners,将 DispSync 和 DispSyncSource 联系在了一起,并且把前面阻塞的 DispSyncThread 唤醒,但是由于 mPeriod 还是为 0,因此 DispSyncThread 还是会继续阻塞。 不过此时从调用关系已经初步可以看到前面我说的那句 DispSyncSource 是 DispSync 和 EventThread 的中间人 是正确的了。 接着来看 DispSyncThread。 DispSync 和 DispSyncThread-02设置 mPeriod 的流程如下(依旧引用了这位大佬的《Android SurfaceFlinger SW Vsync模型》的内容,再次感谢): initializeDisplays(); flinger->onInitializeDisplays(); setPowerModeInternal() resyncToHardwareVsync(true); repaintEverything(); 这里把 SurfaceFlinger::resyncToHardwareVsync() 分为两部分,先看上部分: void SurfaceFlinger::resyncToHardwareVsync(bool makeAvailable) { Mutex::Autolock _l(mHWVsyncLock); if (makeAvailable) { mHWVsyncAvailable = true; } else if (!mHWVsyncAvailable) { // Hardware vsync is not currently available, so abort the resync // attempt for now return; } const auto& activeConfig = getBE().mHwc->getActiveConfig(HWC_DISPLAY_PRIMARY); const nsecs_t period = activeConfig->getVsyncPeriod(); mPrimaryDispSync.reset(); // 设置 mPeriod mPrimaryDispSync.setPeriod(period); // 默认为 false if (!mPrimaryHWVsyncEnabled) { mPrimaryDispSync.beginResync(); // 上部分结束 在 DispSync::setPeriod() 里面给 mPeriod 赋值,并且把 DispSyncThread 唤醒: void DispSync::setPeriod(nsecs_t period) { Mutex::Autolock lock(mMutex); mPeriod = period; mPhase = 0; mReferenceTime = 0; mThread->updateModel(mPeriod, mPhase, mReferenceTime);}void updateModel(nsecs_t period, nsecs_t phase, nsecs_t referenceTime) { if (kTraceDetailedInfo) ATRACE_CALL(); Mutex::Autolock lock(mMutex); mPeriod = period; mPhase = phase; mReferenceTime = referenceTime; ALOGV("[%s] updateModel: mPeriod = %" PRId64 ", mPhase = %" PRId64 " mReferenceTime = %" PRId64, mName, ns2us(mPeriod), ns2us(mPhase), ns2us(mReferenceTime)); // 这里把 DispSyncThread 唤醒 mCond.signal();} 至此,DispSyncThread 也开始运转。 EventControlThread-02接着看 SurfaceFlinger::resyncToHardwareVsync() 的下半部分: ... mEventControlThread->setVsyncEnabled(true); mPrimaryHWVsyncEnabled = true; }}void EventControlThread::setVsyncEnabled(bool enabled) { std::lock_guard<std::mutex> lock(mMutex); mVsyncEnabled = enabled; // 把 EventControlThread 唤醒 mCondition.notify_all();} 把 EventControlThread 唤醒以后,会重新把 SurfaceFlinger 传进来的那个被 Lambda 表达式包裹的 SurfaceFlinger::setVsyncEnabled() 重新执行一下: void SurfaceFlinger::setVsyncEnabled(int disp, int enabled) { ATRACE_CALL(); Mutex::Autolock lock(mStateLock); getHwComposer().setVsyncEnabled(disp, enabled ? HWC2::Vsync::Enable : HWC2::Vsync::Disable);}void HWComposer::setVsyncEnabled(int32_t displayId, HWC2::Vsync enabled) { if (displayId < 0 || displayId >= HWC_DISPLAY_VIRTUAL) { ALOGD("setVsyncEnabled: Ignoring for virtual display %d", displayId); return; } RETURN_IF_INVALID_DISPLAY(displayId); // NOTE: we use our own internal lock here because we have to call // into the HWC with the lock held, and we want to make sure // that even if HWC blocks (which it shouldn't), it won't // affect other threads. Mutex::Autolock _l(mVsyncLock); auto& displayData = mDisplayData[displayId]; if (enabled != displayData.vsyncEnabled) { ATRACE_CALL(); auto error = displayData.hwcDisplay->setVsyncEnabled(enabled); RETURN_IF_HWC_ERROR(error, displayId); displayData.vsyncEnabled = enabled; char tag[16]; snprintf(tag, sizeof(tag), "HW_VSYNC_ON_%1u", displayId); // 在 systrace 看到的就是在这里 ATRACE_INT(tag, enabled == HWC2::Vsync::Enable ? 1 : 0); }} 在这里,真正地去开启 HW-VSync。然后由于 SurfaceFlinger 接收了 HW-VSync,然后辗转发给 DispSync,DispSync 接收,校正 SW-VSYNC。而整个 DispSync SurfaceFlinger 部分的初始化的流程也最终完成。 注意,上面说的是 SurfaceFlinger 部分。前面提到,总共有两个 EventThread,而上面分析的都是 sfEventThread,下面简单地描述一下 appEventThread 的流程,其实 EventThread 到 DispSync 这部分都是一致的,只是 EventThread 的 Connection 的注册流程不一样。sfEventThread 是 MessageQueue 去注册 Connection,而 appEventThread 则是另一种方法。 appEventThreadSurfaceFlinger 接收 VSYNC 是为了合成,因此 sfEventThread 的 Connection 只有一个,就是 SurfaceFlinger 本身;而 app 接收 VSYNC 是为了画帧,appEventThread 会有很多很多个 Connection。 app 本身是如何在 appEventThread 注册一个 Connection 的,与这篇文章的主体有点偏移,这个可以另开一篇文章来详细说明,流程也是非常复杂,这里只简单地描述:核心就是 libgui 下面的 DisplayEventReceiver,它在初始化的时候会调用 SurfaceFlinger::createEventConnection: sp<IDisplayEventConnection> SurfaceFlinger::createDisplayEventConnection( ISurfaceComposer::VsyncSource vsyncSource) { if (vsyncSource == eVsyncSourceSurfaceFlinger) { return mSFEventThread->createEventConnection(); } else { return mEventThread->createEventConnection(); }} 然后后面的流程就跟前面的一致了。 小结通过上面的描述,依据各个类的依赖关系,其实可以总结出这么一个图: 请注意箭头方向。 运作流程前面提到,引入 DispSync 的目的是为了通过 SF-VSYNC 来模拟 HW-VSYNC 的行为并且通过加入 offset 来让通知时机变得灵活。因此理解整个 DispSync 的流程就可以归结为下面几个部分:SF-VSYNC 通知周期 mPeriod 的计算;SF-VSYNC 的模拟方式以及 SF-VSYNC 传递流程,分别来看。 mPeriod 计算逻辑前面提到,DispSync 通过接收 HW-VSYNC 并且更新计算出 SW-VSYNC 间隔—— mPeriod,首先看一下 DispSync 是如何收到 HW-VSYNC。 先看一下 SurfaceFlinger 这个类: class SurfaceFlinger : public BnSurfaceComposer, public PriorityDumper, private IBinder::DeathRecipient, private HWC2::ComposerCallback SurfaceFlinger 实现了 HW2::ComposerCallback 的接口,然后当 HW-VSYNC 到来的时候,HWC 会将 HW-VSYNC 发生的时间戳发给 SurfaceFlinger,然后 SurfaceFlinger 会转发给 DispSync: class ComposerCallbackBridge : public Hwc2::IComposerCallback {public: ... Return<void> onVsync(Hwc2::Display display, int64_t timestamp) override { mCallback->onVsyncReceived(mSequenceId, display, timestamp); return Void(); } ...};void SurfaceFlinger::onVsyncReceived(int32_t sequenceId, hwc2_display_t displayId, int64_t timestamp) { ... { // Scope for the lock Mutex::Autolock _l(mHWVsyncLock); if (type == DisplayDevice::DISPLAY_PRIMARY && mPrimaryHWVsyncEnabled) { needsHwVsync = mPrimaryDispSync.addResyncSample(timestamp); } } // 这个很重要,后面会提到 if (needsHwVsync) { enableHardwareVsync(); } else { disableHardwareVsync(false); }} 重点看 DispSync 怎么处理这些 HW-VSYNC,是在 addResyncSample() 这个函数: bool DispSync::addResyncSample(nsecs_t timestamp) { Mutex::Autolock lock(mMutex); size_t idx = (mFirstResyncSample + mNumResyncSamples) % MAX_RESYNC_SAMPLES; mResyncSamples[idx] = timestamp; if (mNumResyncSamples == 0) { mPhase = 0; mReferenceTime = timestamp; mThread->updateModel(mPeriod, mPhase, mReferenceTime); } if (mNumResyncSamples < MAX_RESYNC_SAMPLES) { mNumResyncSamples++; } else { mFirstResyncSample = (mFirstResyncSample + 1) % MAX_RESYNC_SAMPLES; } updateModelLocked(); if (mNumResyncSamplesSincePresent++ > MAX_RESYNC_SAMPLES_WITHOUT_PRESENT) { resetErrorLocked(); } ... bool modelLocked = mModelUpdated && mError < (kErrorThreshold / 2); return !modelLocked;} 这里需要重点说明这里面几个变量的意义(在 DispSync.h 这个头文件里面有说明): mPeriod这个就是 DispSync 根据 HW-VSYNC,计算出来的 SW-VSYNC 的时间间隔,单位是纳秒。这里有人可能会有疑问,这个值的意义在哪?硬件是以一个固定的时间间隔去发 HW-VSYNC,为什么还需要去计算一个新的时间间隔?直接跟 HW-VSYNC 的时间间隔一致不行吗?这个当做作业留给大家思考。 mPhase这个说实话我看了好久一直都看不懂这个值的意义 mReferenceTime这个是第一次收到 HW-VSYNC 的时间戳,用来当做 DispSync 的参考标准 mWakeupLatencyDispSyncThread 是通过睡到下一次 SW-VSYNC 应该发生的时间戳来模拟 HW-SYNC 的,但是这种“睡”到特定时间点肯定是有延迟的。通过计算睡醒的时间戳和目标时间戳就可以算出这个延迟,总延迟不能超过 1.5ms mResyncSample长度 32,用来记录收到硬件 VSYNC 的时间戳的数组,不过被解释为一个 ring buffer,新的会覆盖旧的 mFirstResyncSample记录了 mResyncSample 这个 ring buffer 的开头 mNumResyncSamples接收到硬件 VSYNC 的个数 DispSync 将从 SurfaceFlinger 发来的 HW-VSYNC 的时间戳都给记录到一个 ring buffer,当有了足够多的 HW-VSYNC 了以后(目前是 6 个即以上),就可以开始来拟合 SF-VSYNC 的间隔 mPeriod 了,是在 DispSync::updateModelLocked() 里面计算的,核心算法就在这里了。分为两部分,一部分是 mPeriod 的计算: void DispSync::updateModelLocked() { if (mNumResyncSamples >= MIN_RESYNC_SAMPLES_FOR_UPDATE) { nsecs_t durationSum = 0; nsecs_t minDuration = INT64_MAX; nsecs_t maxDuration = 0; for (size_t i = 1; i < mNumResyncSamples; i++) { size_t idx = (mFirstResyncSample + i) % MAX_RESYNC_SAMPLES; size_t prev = (idx + MAX_RESYNC_SAMPLES - 1) % MAX_RESYNC_SAMPLES; nsecs_t duration = mResyncSamples[idx] - mResyncSamples[prev]; durationSum += duration; minDuration = min(minDuration, duration); maxDuration = max(maxDuration, duration); } durationSum -= minDuration + maxDuration; mPeriod = durationSum / (mNumResyncSamples - 3); ... mPeriod 的计算十分简单,把所有的 HW-VSYNC 前后相减算出 HW-VSYNC 的时间间隔,然后去掉一个最小值和最大值,然后所有 HW-VSYNC 的时间戳之和除以总个数就是 mPeriod 了。这里有一个问题就是为什么在最后除的时候是除数是 3?其实很简单,因为前面的 for 循环是从 1 开始算起的,所以循环结束一下 durationSum 其实是 mNumResyncSamples - 1 个 HW-VSYNC 的总和,然后再去掉一个最大和最小,所以总数是 mNumResyncSamples - 3。 另一部分是 mPhase 的计算,这一块看上去好像挺复杂的,甚至还有三角函数: ...double sampleAvgX = 0;double sampleAvgY = 0;// scale 的意义是,每 ms 代表了多少度。(总量除以总个数等于每个的值)double scale = 2.0 * M_PI / double(mPeriod);// Intentionally skip the first samplefor (size_t i = 1; i < mNumResyncSamples; i++) { size_t idx = (mFirstResyncSample + i) % MAX_RESYNC_SAMPLES; // sample 是误差 nsecs_t sample = mResyncSamples[idx] - mReferenceTime; // 这里 (sample % mPeriod) 看上去挺唬人的,但是其实就是保证 sample 不会大于或者等于 mPeriod,否则这里的 samplePhase 算出来就是 2π 了 // 所以这里 samplePhase 算出来的就是把误差转成度数 double samplePhase = double(sample % mPeriod) * scale; // 这两个后面是为了用来计算误差平均的度数 sampleAvgX += cos(samplePhase); sampleAvgY += sin(samplePhase);}sampleAvgX /= double(mNumResyncSamples - 1);sampleAvgY /= double(mNumResyncSamples - 1);// 根据等比关系,算出平局误差度数对应的 ns 值mPhase = nsecs_t(atan2(sampleAvgY, sampleAvgX) / scale);ALOGV("[%s] mPhase = %" PRId64, mName, ns2us(mPhase));if (mPhase < -(mPeriod / 2)) { mPhase += mPeriod; ALOGV("[%s] Adjusting mPhase -> %" PRId64, mName, ns2us(mPhase));}if (kTraceDetailedInfo) { ATRACE_INT64("DispSync:Period", mPeriod); ATRACE_INT64("DispSync:Phase", mPhase + mPeriod / 2);}// Artificially inflate the period if requested.mPeriod += mPeriod * mRefreshSkipCount;mThread->updateModel(mPeriod, mPhase, mReferenceTime);mModelUpdated = true; 上面的逻辑其实可以用下图来阐述: 而 mPhase 最终是根据下面的等比公式计算出来的: $$ \frac{2\pi}{mPeriod} = \frac{Angle}{mPhase} $$ 最后,看一下 DispSync::addResyncSample 这个函数的返回值,这个返回值非常重要,当通过统计 SW-VSYNC 的误差小于阈值的时候(这个误差的计算涉及到了 Fence,目前我对这部分内容理解得还不是很透彻,等彻底理解了以后再来填坑),返回 true 给 SurfaceFlinger 的时候,SurfaceFlinger 则会调用 SurfaceFlinger::disableHardwareVsync 把 HW-VSYNC 给关了。 SW-VSYNC 的生成与传递mPeriod 计算出来以后,DispSyncThread 就可以依据这个值来模拟 HW-VSYNC 了(实际上计算流程和模拟流程是相互独立的,分别在两个不同的线程上完成),所以流程都在 DispSyncThread 的 threadLoop() 里面: virtual bool threadLoop() { status_t err; nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC); while (true) { Vector<CallbackInvocation> callbackInvocations; nsecs_t targetTime = 0; { // Scope for lock Mutex::Autolock lock(mMutex); if (mStop) { return false; } if (mPeriod == 0) { err = mCond.wait(mMutex); if (err != NO_ERROR) { ALOGE("error waiting for new events: %s (%d)", strerror(-err), err); return false; } continue; } // 这里计算出下一个确切的 SW-VSYNC 的时间戳 targetTime = computeNextEventTimeLocked(now); bool isWakeup = false; if (now < targetTime) { if (targetTime == INT64_MAX) { err = mCond.wait(mMutex); } else { // 睡到下一个 SW-VSYNC 为止 err = mCond.waitRelative(mMutex, targetTime - now); } if (err == TIMED_OUT) { isWakeup = true; } else if (err != NO_ERROR) { return false; } } now = systemTime(SYSTEM_TIME_MONOTONIC); // Don't correct by more than 1.5 ms static const nsecs_t kMaxWakeupLatency = us2ns(1500); if (isWakeup) { mWakeupLatency = ((mWakeupLatency * 63) + (now - targetTime)) / 64; mWakeupLatency = min(mWakeupLatency, kMaxWakeupLatency); } callbackInvocations = gatherCallbackInvocationsLocked(now); } if (callbackInvocations.size() > 0) { fireCallbackInvocations(callbackInvocations); } } return false;} 这里通过依据 mPeriod 算出下一个 SW-VSYNC 的时间戳,计算 SW-VSYNC 的时间戳的逻辑比较简单,就不过多描述。然后通过条件变量直接睡到下一个 SW-VSYNC,然后一个个地通过调用 DispSyncSource 的 onDispSyncEvent 回调来进行 SW-VSYNC 的通知。然后 DispSyncSource 的 onDispSyncEvent 又会调用 EventThread 的 onVSyncEvent: void EventThread::onVSyncEvent(nsecs_t timestamp) { std::lock_guard<std::mutex> lock(mMutex); mVSyncEvent[0].header.type = DisplayEventReceiver::DISPLAY_EVENT_VSYNC; mVSyncEvent[0].header.id = 0; mVSyncEvent[0].header.timestamp = timestamp; mVSyncEvent[0].vsync.count++; mCondition.notify_all();} 这里就可以回答一下在提到的问题,mVSyncEvent 和 mDisplayEventConnections 以及 signalConnections 这三个数组的意义和区别: mVSyncEvent一个长度为 NUM_BUILTIN_DISPLAY_TYPES 的数组,代表这一个 Vsync Event,这个可能是 VSYNC 事件,也有可能是屏幕插拔这种事件等。这个 NUM_BUILTIN_DISPLAY_TYPES 是一个 enum 变量: enum DisplayType { DISPLAY_ID_INVALID = -1, DISPLAY_PRIMARY = HWC_DISPLAY_PRIMARY, DISPLAY_EXTERNAL = HWC_DISPLAY_EXTERNAL, DISPLAY_VIRTUAL = HWC_DISPLAY_VIRTUAL, NUM_BUILTIN_DISPLAY_TYPES = HWC_NUM_PHYSICAL_DISPLAY_TYPES,}; 从这里就可以看到,至少在这个版本的 Android 除了一个 virtual display(这是 SurfaceFlinger 提供的一个非常有用的功能,很多常见的需求例如录屏就是通过 virtual display 来实现的,这里不展开,有需要的话再写一篇文章详细描述)已经是支持多屏幕了,只不过呢,目前的代码里面都是写死只处理主屏,也就是 Display 0 的事件。 mDisplayEventConnections这个就是用来存储前面提到的 EventThread::Connection 的数组,在调用 EventThread::registerDisplayEventConnection() 的时候,就会把这个 Connection 加到这个数组里面。 signalConnectionsEventThread::waitForEventLocked 最大的作用就是返回这个数组,这个数组存的是所有希望接收下一个 SW-VSYNC 的 Connection,而是否接收 Connection 的标志是 connection->count 的值:-1 代表不接收 SW-VSYNC;0 代表只接收一次,EventThread 发现 connection->count 的值为 0 的时候,会把它加到 signalConnections 以便其能够接受到这一次的 SW-VSYNC 之后,会将其 count 置为 -1;大于 0 就表明会一直接收。 onVSyncEvent 的作用是新增一个 VSyncEvent 并且把 EventThread 唤醒,EventThread 统计了所有对 SW-VSYNC 感兴趣的 Connection 并且都加到 signalConnections,最后会通过一个循环调用每个 connection 的 postEvent() 函数,SurfaceFlinger 就会开始走合成的流程,app 就会开始走渲染的流程。至此,SW-VSYNC 完成了传递的全过程。 小结当整个初始化完成以后,整个 DispSync 模型就开始运作起来了。我们先简单地把整个流程描述一下: SurfaceFlinger 通过实现了 HWC2::ComposerCallback 接口,当 HW-VSYNC 到来的时候,SurfaceFlinger 将会收到回调并且发给 DispSync。DispSync 将会把这些 HW-VSYNC 的时间戳记录下来,当累计了足够的 HW-VSYNC 以后(目前是大于等于 6 个),就开始计算 SW-VSYNC 的偏移 mPeriod。计算出来的 mPeriod 将会用于 DispSyncThread 用来模拟 HW-VSYNC 的周期性起来并且通知对 VSYNC 感兴趣的 Listener,这些 Listener 包括 SurfaceFlinger 和所有需要渲染画面的 app。这些 Listener 通过 EventThread 以 Connection 的抽象形式注册到 EventThread。DispSyncThread 与 EventThread 通过 DispSyncSource 作为中间人进行连接。EventThread 在收到 SW-VSYNC 以后将会把通知所有感兴趣的 Connection,然后 SurfaceFlinger 开始合成,app 开始画帧。在收到足够多的 HW-VSYNC 并且在误差允许的范围内,将会关闭通过 EventControlThread 关闭 HW-VSYNC。 然后这个流程我们可以得到下面这张跟初始化非常接近,只是方向相反的 SW-VSYNC 的传递图: 为什么要引入偏移写了这么多内容,可能很多人还是无法理解引入软件模型的意义所在,前面我们提到是让整个流程更加灵活这句话可能也不是很好理解,因此在这里详细描述一下。 首先呢,先来看一下 DispSync 的第一个提交的 commit message,它详细地描述了引入了 DispSync 的初衷: commit faf77cce9d9ec0238d6999b3bd0d40c71ff403c5Author: Jamie Gennis <[email protected]>Date: Tue Jul 30 15:10:32 2013 -0700 SurfaceFlinger: SW-based vsync events This change adds the DispSync class, which models the hardware vsync event times to allow vsync event callbacks to be done at an arbitrary phase offset from the hardware vsync. This can be used to reduce the minimum latency from Choreographer wake-up to on-screen image presentation. Bug: 10624956 Change-Id: I8c7a54ceacaa4d709726ed97b0dcae4093a7bdcf 意思就是希望能够通过 DispSync 来减少 app 渲染的内容到屏幕的事件延迟,也就是传说中的跟手性。这里需要说明一下从 app 渲染画面到显示到屏幕的一个简易 pipeline(这部分内容参考了这篇博客,建议细读,写得十分好!)。 首先需要说明的是,为了严格保证显示的流畅,防止画面撕裂的情况发生,画面更新到屏幕面板需要在 HW-VSYNC 开始的时候才做。 没有 DispSync 的时候: 第 1 个 HW-VSYNC 到来時, App 正在画 N, SF 与 Display 都沒 buffer 可用 第 2 个 HW-VSYNC 到来時, App 正在画 N+1, SF 组合 N, Display 沒 Buffer 可显示 第 3 个 HW-VSYNC 到来時, App 正在画 N+2, SF 组合 N+1, Display 显示 N 第 4 个 HW-VSYNC 到来時, App 正在画 N, SF 组合 N+2, Display 显示 N+1 从上面这个简易的 pipeline 可以看到,App 画的帧得得两个 HW-VSYNC 之后才能显示到屏幕面板上,也就是大概 33.3ms。但是,现在大部分的情况是,硬件的性能已经足够快了,画一帧的时间和合成的时间不需要一个 HW-VSYNC 了,这个时候 DispSync 的作用就来了。通过引入 offset,当 offset 为正值时,App 和 SurfaceFlinger 都是在 HW-VSYNC 往后 offset ms 才开始工作的,这个时候 App 画帧到最终显示到面板上的延迟就变成了 (2 * VSYNC_PERIOD - (offset % VSYNC_PERIOD)),这样就变相地减少了这个延迟,增强了跟手性,其实这个就是当初引入 DispSync 的初衷。 反过来可以这么想,假设把 offset 变为负值,这个时候 App 渲染和 SurfaceFlinger 合成可用的时间就变长了,在某些负载比较重的场景,这个可以优化渲染性能。 甚至还有这种情况,假设在某些场景,App 渲染和 SurfaceFlinger 合成的总时间都足够短,那么如果设置合理的话,例如 app 的 offset 设置为 0,SurfaceFlinger 的 offset 设置为 VSYNC_PERIOD/2,那么就能够保证 App 渲染到显示到面板的时间差在一个 HW-VSYNC 内完成。 从上面的分析就可以看到,这个就是引入软件模型的灵活性的体现,根据不同的需求对 offset 进行不同的取值,可以得到不同的效果。 有什么用?学了 DispSync 有什么用呢?其实不是说学了 DispSync 有用,而是透过 DispSync 我们学到了 VSYNC 分发的整个流程,这个能够去解释很多问题。这里举一个例子。前段时间一加 7 Pro 推出了首款 90 Hz 屏幕的手机,很多评测机构都纷纷表示,微博滑动等界面感觉更加流畅了,这背后的原理是什么呢?这个时候就可以使用前面学到的知识来分析一波了。这里的 90 Hz 指的就是 HW-VSYNC。然后根据前面的渲染 pipeline,在没有 DispSync 的情况下,由于 HW-VSYNC 的从普通的 60 Hz变成了 90 Hz,VSYNC 的时间间隔从 16.6ms 减少到了 11.1ms,从前面的 pipeline 可以得出,app 从渲染到显示的延迟减少了 10ms 左右,这个延迟减少是十分明显的,因此会有一个“流畅”的感觉。因此能否这么想的,当屏幕的刷新率变成了 90 Hz 甚至是 120 Hz 以后,DispSync 的作用可能就越来越小了,那个时候谷歌会不会把它去掉呢?这个可以看一下后面 Android 的改动,至少在目前,在这个 90 Hz 即将普及的今天,Android Q 的 DispSync 还是保留着的。]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>Android</tag>
<tag>Graphic</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Kernel Magic——ARRAY_SIZE]]></title>
<url>%2FKernel-Magic-ARRAY-SIZE%2F</url>
<content type="text"><![CDATA[Linux Kernel 是怎么计算数组长度的呢? 如果让你在 C 语言计算一个数组的长度,那么通常的写法是: #define ARRAY_SIZE(a) (sizeof(a)/sizeof(a[0])) 这个确实是行之有效的,但是问题是什么呢?问题是ARRAY_SIZE 的参数必须是一个数组,不能是一个指针,否则就会出现问题。因为 sizeof(一个指向数组的指针) 它的值是固定的(64 位是 8,32 位是 4)。那么怎么避免这个问题的,Linux Kernel 给了一个很好的解决方法,来看 Kernel 版本的 ARRAY_SIZE: #define ARRAY_SIZE(arr) (sizeof(arr)/sizeof(arr[0]) + __must_be_array(arr)) 后面多了一个对于传入参数的必须是数组的保证,来看看是如何实现的: /* &a[0] degrades to a pointer: a different type from an array */#define __must_be_array(a) BUILD_BUG_ON_ZERO(__same_type((a), &(a)[0]))# define __same_type(a, b) __builtin_types_compatible_p(typeof(a), typeof(b)) 首先这里用到了一个 Linux Kernel 的一个宏——BUILD_BUG_ON_ZERO。需要说明的是,这个宏的命名跟他的实际意义是相反的,这个宏的意义是当传入的参数不是 0 的时候抛一个编译错误,否则返回 0,所以曾经有人建议把这个宏改名为 BUILD_BUG_OR_ZERO。这个命名就是比较贴切的,但是最终并没有被接纳,所以一直在误导新人。 先来看一个这个宏的定义: /* Force a compilation error if condition is true, but also produce a result (of value 0 and type size_t), so the expression can be used e.g. in a structure initializer (or where-ever else comma expressions aren't permitted). */#define BUILD_BUG_ON_ZERO(e) (sizeof(struct { int:-!!(e); })) 这里面最重要的知识点就是:逻辑取反和位域(bitfield),其中位域分为两种,命名位域和匿名位域。其中匿名位域长度可以为 0,命名位域必须大于 0。 两次逻辑取反会把非零值变为 1,所以如果 e 为非零值,那么就会是一个长度为 -1 的位域;否则就是一个长度为 0 的匿名位域,对其去 sizeof 就是 0。 这里还用到了 gcc 的一个 built-in function —— __builtin_type_compatible_p,它的作用是检查两个 type 是否一致(注意是 type,而不是表达式,所以这里用到了 typeof),一致返回 1,否则返回 0 。并且它检查的是去掉限定词之后的类型,例如 const volatile 等都会被忽略。同时对于它而言,int[] 和 int[5] 类型是一致的;以及两个 enum 将会被认为不是同一个类型。 然后就可以来看它是怎么实现检查参数是数组的。这里用到了 &a[0](说明一下 [] 的优先级比 & 要高)。 如果 typeof(a) 是一个数组,此时 typeof(&a[0]) 它就退化成一个指针(因为 a[0] 是一个值,对一个数值取地址就是一个指针了),所以 a 和 &a[0] 不是同一个同类型,__same_type 返回 0,BUILD_BUG_ON_ZERO 返回 0 如果 typeof(a) 是一个指针,此时 typeof(&a[0]) 也是一个指针,所以 a 和 &a[0] 是同一个类型,__same_type 返回 1,BUILD_BUG_ON_ZERO 会抛出一个编译错误,这样就能够在编译的阶段就把问题给识别出来,非常高明。 这个是在修一个项目 bug 的时候,当时我用的是第一种写法,然后组内大佬 review 的时候说改成 ARRAY_SIZE,这个是 Kernel 的标准接口,然后去学习了一下深深地被它的优雅给震惊到,所以值得记录一番。]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
</tags>
</entry>
<entry>
<title><![CDATA[代码之外——Andriod 签名]]></title>
<url>%2Fall-about-signature%2F</url>
<content type="text"><![CDATA[你可能一直忽略的知识点 作为一名 Android 系统工程师,替换系统应用是常事。但是刚入门的时候总会遇到这样的情况:把自己修改过的 App push 到手机以后发现无法运行,一问老鸟他会跟你说:你是不是下载了带签名的 ROM 了?去下个不带签名的就可以。可行自然是可行,但是终究不知其所以然,索性系统化学习了一番,总结成文,造福后人。 非对称加密在了解 Android 的签名机制之前,需要了解一些基础知识。非对称加密是整个 Android 签名机制的基石,又称为公开密钥加密。它需要两个密钥,一个是所有人的都可见的,称为公钥;另一个是仅能自己可见的,称为私钥。这两个密钥的关系在维基百科中的描述是: 一个用于加密的时候,另一个则用于解密。使用其中一个密钥把明文加密后所得的密文,只能用相对应的另一个密钥才能解密得到原本的明文;甚至连最初用来加密的密钥也不能用作解密。由于加密和解密需要两个不同的密钥,故被称为非对称加密。 至于公钥和私钥是如何产生,以及一个密钥加密的密文只能通过另一个密钥来解密背后的数学原理,可以参考阮一峰的科普文章:这个还有这个,这里就不详细说明。目前我们需要记住的第一个原则是: 原则一:使用其中一个密钥把明文加密后所得的密文,只能用相对应的另一个密钥才能解密得到原本的明文,也就是说,用私钥加密之后的密文私钥自己都解不开。 签名首先我们得理解签名的作用是什么?在很多场景里面,例如在银行办理业务,都需要我们本人的签名,作用是声明这个是我本人的行为。在加密学里面,称为数字签名,也是同样的道理,对一个东西进行签名,意思就是证明当你见到的这个东西跟我想要给你看的是一个东西。这样可能说可能有点虚,用一个例子来说明: 我有两把钥匙,公钥和私钥。私钥我自己留着,公钥可以给任何人,例如我把公钥给了周杰伦 周杰伦想要跟我进行过加密通信,他把要跟我说的话写完之后,使用公钥进行加密 我收到加密后的内容,根据原则一,我可以用我的私钥去解密内容,得到原来的内容 这个时候我想要回信,为了保证周杰伦看到的内容没有被修改,我决定使用数字签名。这里得说明一下,数字签名是对非对称加密的反应用,在非对称加密的日常应用中,公钥是用来加密,私钥是用来解密的。而在数字签名中,是反过来的,具体流程是这样的: 我写完信之后,使用一个 Hash 函数生成信件的摘要(注意,信件其实可以理解成一个字符串) 然后我使用我的私钥对摘要进行加密,并且附在信件的后面 当周杰伦收到我的信件之后,为了验证内容没有被篡改,先把附在信件后面的加密后的摘要取下,使用自己的公钥进行解密,得到了信件的摘要 a。然后使用相同的 Hash 函数对信件进行哈希得到 b,如果 a 和 b 完全一致,那么说明信件没有被篡改,反之则反。 但是,上面的流程有一个问题就是。周杰伦手中的公钥其实不能够确定是不是我给他的。也就是说,坏人出现了。如果有坏人替换了周杰伦手中的公钥成坏人自己的公钥,那么周杰伦发出的公钥加密之后的信件坏人是可以直接使用自己私钥进行解密的。也就是说,上面的流程的关键是确认周杰伦手中的公钥是我的公钥。TODO 上面的例子参考了阮一峰的一篇博客。维基百科有一张图很好地解释了数字签名里签名和验签的过程: 因此我们得到了第二个原则: 原则二:签名的作用是为了验证文件的完整性,即是否被篡改 有了上面的基础知识,就可以系统地阐述 Android 签名机制了。 Android 签名机制在 Android 源码库 build/target/product/security 下面,有这么一些文件,有的是以 .pk8 为后缀,有的是以 .x509.pem 为后缀,并且会发现 .pk8 和 .x509.pem 是一一对应的。这两种文件的关系是:.pk8 文件是私钥,用来对包进行签名;.x509.pem 文件是证书,用来验证签名。原生 Android 使用了 4 类密钥: testkey platform shared media 系统自带的应用通过在 Android.mk 文件中声明 LOCAL_CERTIFICATE 来指定用那个私钥进行签名,如果不声明那么默认使用 testkey。 这里有个问题,上面说道这些 key 都是在源码中的,所有人都是可以访问的,那么这样其实是非常不安全的。任何人都可以使用这些 key 去对应用进行签名然后就可以通过系统的验证了。所以在实际的情况中,是会通过把原生的这一套 key 给替换掉。谷歌在这里提供了替换的方法。并且,在实际外发的 ROM 包中,是不会有 testkey 这个签名文件的,默认的变成了 releasekey 如果我们对一个 apk 文件进行解包,那么会发现里面有一个 META-INF 的文件夹,里面的内容根据不同的应用会有不同,但是一定会有这三个文件:MANIFEST.MF,CERT.SF 和 CERT.RSA。这三个文件就是 Android 签名机制的核心了。系统如何判断一个 apk 是不是被修改过的,就是通过这三个文件进行一系列的校验。具体这三个文件是怎么生成的,可以参考这一篇文章(下文也有很多内容是参考了这篇文章的描述,再次感谢作者)。现在我们以一个 apk 为例,简要说明这三个文件的内容和意义(注意,有一些 APK 可能 META-INF 下面的内容可能后缀名一样,但是文件名一样;又或者是不止这三个文件,这些都是正常现象)。 MANIFEST.MF这个文件的内容是当前 apk 里面所有文件的名字和文件的摘要值,例如在现在这个例子中,这个文件的内容大概长这样: Manifest-Version: 1.0Built-By: Generated-by-ADTCreated-By: Android Gradle 3.0.1Name: AndroidManifest.xmlSHA1-Digest: l5LrO+0CH4QwymZEEkgof6tKJKQ=Name: META-INF/INDEX.LISTSHA1-Digest: mV/vtpP5kHRZ0ZdWNzAWUorzn/M=Name: META-INF/io.netty.versions.propertiesSHA1-Digest: fHUsZp7XXjDcmXh7h88Qxku7PaQ=... 这里我们可以来实战一下,验证这里面内容的意义。以第一个 AndroidManifest.xml 为例,首先把这个文件提取出来,然后计算一下它的 SHA-1 值,在 Linux 可以这样: ▶ sha1sum AndroidManifest.xml 9792eb3bed021f8430ca66441248287fab4a24a4 AndroidManifest.xml 咦,值好像不一样,没事,因为上面的值是经过 Base64 编码的,我们可以在这里进行转换:对比后就可以发现跟 MANIFEST.MF 的值是一毛一样的。 CERT.SF我们初看这个文件的时候,会发现它的内容跟 MANITEST.MF 非常的接近: Signature-Version: 1.0Created-By: 1.0 (Android)SHA1-Digest-Manifest: nezsP8TgzAKQ7BFky/chze3qmL0=Name: AndroidManifest.xmlSHA1-Digest: mr/1kFRAFlcWQAo9hA69M29MAYs=Name: META-INF/INDEX.LISTSHA1-Digest: YFvH0U9NaeV1BDZUz5JkpfUm9aU=Name: META-INF/io.netty.versions.propertiesSHA1-Digest: rTBpHjFlmjueKQtX0IlpTl7X4uo=... 这里面分为两部分内容:SHA1-Digest-Manifest 和 SHA1-Digest,这两部分分别是这么计算的。首先这个 SHA1-Digest-Manifest 就是对 MANIFEST.MF 计算 SHA-1之后再进行Base64编码: ▶ sha1sum META-INF/MANIFEST.MF 9decec3fc4e0cc0290ec1164cbf721cdedea98bd META-INF/MANIFEST.MF 然后 SHA1-Digest 是对 MANIFEST.MF 里面的每一个 \r\n 分割开来的项分别进行 SHA-1 之后在进行 Bash64 编码,例如上面的 AndroidManifest.xml 的 SHA1-Digest 是怎么算出来的呢?我们可以这么来: 首先把 MANIFEST.MF 里面这个文件的内容保存一下(注意换行也要保存),例如咱们这里的是这个: Name: AndroidManifest.xmlSHA1-Digest: l5LrO+0CH4QwymZEEkgof6tKJKQ= 然后如果你是在 Linux 下,使用 unix2dos 进行转换一下 然后计算一下 sha1: ▶ sha1sum 19abff5905440165716400a3d840ebd336f4c018b 1 最后计算一下 Base64 编码就可以了 CERT.RSA简单的说,这个文件是用私钥对 CERT.SF 进行签名,并且把公钥也附在这个文件里面。 然后就需要说明,这种签名机制如何能够保证应用不被篡改呢?首先如果你修改了 APK 里面任何一个文件,那么相应的文件的 SHA1 摘要就会发生改变,那么就会跟 MANIFEST.MF 里面的值不一致;如果你不死心,修改 MANIFEST.MF 里面的内容,那么就会跟 CERT.SF 里面对应项的内容不一致;如果你还不死心,继续修改 CERT.SF 的内容,那么在 CERT.RSA 的验签那里不通过;如果依旧不死心,想要修改 CERT.RSA 的内容,能做到吗?不能,因为你没有私钥。从这里我们就可以看到,有了这三个文件的“保驾护航”,就可以达到一个效果就是,无论修改一个 Apk 里面的任何一个文件,都必须对其重新签名,否则会直接被系统识别出来,从而保证了安全性。 尾巴好了,Android 的签名机制也大概地说了一遍,感觉可以回答上面的问题了。每个手机公司的 ROM 肯定有两种,一种是内部版本,用的是咱们上面提到的 build/target/product/security 里面的 test key;另一种是外发版本,用的是另外的 key。所以呢,咱们自己本地编译的 App,在最后的签名阶段用的就是系统的 test key。那么导致的结果是,如果你用的是内部的 ROM,那么每次编译的使用用的都是系统 test key 的私钥进行签名,然后用的是 test key 的公钥进行验签,肯定能够通过。反之,如果你用的是外发的 ROM,外发的 ROM 用的是另一套 key,那么肯定会验签不通过,原因就是在这里啦。 把一件事弄懂的感觉真好。]]></content>
<categories>
<category>代码之外</category>
</categories>
<tags>
<tag>Android</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Android USB 框架 —— UsbHostManager]]></title>
<url>%2Fandroid-usb-framework--usbhostmanager%2F</url>
<content type="text"><![CDATA[All about UsbHostManager 我们知道 USB 是主从模式(Host/Slave)的,有趣的是,Android 手机既可以被当做从设备连接到我们的 PC 被识别成为一个 USB 设备,也可以当做主设备识别外部的 USB 设备。这篇东东主要着眼于后者。 我之前一直有一个疑惑,那就是 Android 上层与 Linux 内核底层究竟是如何通讯的。之前我知道了可以通过 sysfs ,或者是通过 uevent 来打通内核与上层。现在,我学习了 Android USB Host 之后,发现了还有一种新的方法 —— Android JNI 。 首先声明,我对 Android 这一块知道的确实不多,所以下面的描述肯定是会有这样那样的错误,还待日后更加深入学习。 Android JNI关于 Java JNI 的我写了另外的一篇东东,就不再赘述了。下面着重说明 Android JNI 与 Java JNI 的区别。 Android 中使用了一种不同于传统 Java JNI 的方法来定义其 native 函数。其中很重要的区别就是 Android 使用了一种 Java 和 C 函数的映射表数组,并在其中表述了函数的参数和返回值。这个数组的类型就是 JNINativeMethod ,定义如下: typedef struct { const char *name; const char *signature; void *fnPtr; } JNINativeMethod;其中,第一个变量是 Java 中的函数名,第二个变量是一个描述返回值和参数的字符串,第三个是指向 native 实现的函数指针。 比较难以理解的第二个参数,具体可以参照这篇博客。 发现 USB 设备流程首先,我先使用自顶向下来说明一下整个流程: 在 UsbHostManager 中创建一个线程来监控(注意,这里用“监控”可是很有讲究的,因为当 Android 设备处于 Host 模式时,当有 USB 设备插拔时,首先相应的是 Linux 内核,然后内核再将设备变动的信息传递给上层,而 Android 根据这些信息在进行相应的动作,所以这里使用了“监控”二字。这跟 Android 设备处于 Slave 模式下的 UsbDeviceManager 是完全不同的)设备的连接状态。该线程执行的是 JNI 层的函数,而 JNI 层则是通过调用 libusb 库的函数,利用内核提供的 inotify 机制来监控 /dev/bus/usb 下文件的变动来判断是否有新的设备的插拔。下面的图示很好地展现了这种自顶向下的路线: (Source:Unboxing Android USB) 接下来,我再以自底向上的方法来详细阐述整个流程: 首先,USB 插入设备之后,内核会在 /dev/bus/usb 这个目录下建立代表该文件的设备文件入口(device file entry)(当然,个中细节还需要花时间深入理解,这里先假设其成立),然后 Android 上层使用 libusb 这个库来监控这个目录下的文件变动,其中用到的技术就是 inotify 。关于 inotify 我也写了一篇东东来简要地说明它,可以先去看看。下面将通过分析 libusb 的源代码(具体位置在 system/core/libusb/usbhost.c)来详细说明: 首先,先初始化 inotify ,获得文件描述符,此后的所有事件都是通过读取该文件描述符中的数据来判断: struct usb_host_context *usb_host_init(){ struct usb_host_context *context = calloc(1, sizeof(struct usb_host_context)); if (!context) { fprintf(stderr, "out of memory in usb_host_context\n"); return NULL; } // 初始化,获取文件描述符 context->fd = inotify_init(); if (context->fd < 0) { fprintf(stderr, "inotify_init failed\n"); free(context); return NULL; } return context;} 这里要说明一下 struct usb_host_context 这个数据结构: struct usb_host_context { int fd; // inotify 返回的文件描述符,通过读取其中的数据来判断事件 usb_device_added_cb cb_added; // 当有 USB 设备插入是调用的回调函数,在 JNI 层赋值,后面会提到 usb_device_removed_cb cb_removed; // 同上,当有 USB 设备拔出是的回调函数 void *data; // 调用者给上面两个回调函数的参数 int wds[MAX_USBFS_WD_COUNT]; // /dev/bus/usb 下各个子目录对应的 watch descriptor int wdd; // /dev 对应的 watch descriptor int wddbus; // /dev/bus 对应的 watch descriptor}; 然后,就是要开始添加要监控的目录了(即我们这里的 /dev/bus/usb): void usb_host_run(struct usb_host_context *context, usb_device_added_cb added_cb, usb_device_removed_cb removed_cb, usb_discovery_done_cb discovery_done_cb, void *client_data){ int done; done = usb_host_load(context, added_cb, removed_cb, discovery_done_cb, client_data); while (!done) { done = usb_host_read_event(context); }} /* usb_host_run() */ 主要的工作是在 usb_host_load() 中完成的: int usb_host_load(struct usb_host_context *context, usb_device_added_cb added_cb, usb_device_removed_cb removed_cb, usb_discovery_done_cb discovery_done_cb, void *client_data){ int done = 0; int i; context->cb_added = added_cb; context->cb_removed = removed_cb; context->data = client_data; /* watch for files added and deleted within USB_FS_DIR */ context->wddbus = -1; for (i = 0; i < MAX_USBFS_WD_COUNT; i++) context->wds[i] = -1; /* watch the root for new subdirectories */ // 如上面说说,wdd 是用来发现 /dev 下是否有子目录创建或者删除的 context->wdd = inotify_add_watch(context->fd, DEV_DIR, IN_CREATE | IN_DELETE); watch_existing_subdirs(context, context->wds, MAX_USBFS_WD_COUNT); /* check for existing devices first, after we have inotify set up */ done = find_existing_devices(added_cb, client_data); if (discovery_done_cb) done |= discovery_done_cb(client_data); return done;} /* usb_host_load() */ 其中的 watch_existing_subdirs() 就是添加 /dev/bus/usb 下的文件变动的监控的。函数不难理解,主要说明一下,就是 context->wds 这个数组:wds[0] 代表的是整个 /dev/bus/usb 下子目录的变动,而 wds[] 数组中其他的代表了真正设备的变动,因为内核会在每个新设备插拔之后在 /dev/bus/bus 这个目录下增加设备对应的文件如:/dev/bus/usb/001/001 等。 在完成了文件监控之后,接下来就可以来读数据了,调用了上面的 usb_host_read_event() ,这个函数重复的地方很多,下面我只选取了关键的 USB 设备的部分来说明: for (i = 1; (i < MAX_USBFS_WD_COUNT) && !done; i++) { if (wd == context->wds[i]) { snprintf(path, sizeof(path), USB_FS_DIR "/%03d/%s", i, event->name); if (event->mask == IN_CREATE) { done = context->cb_added(path, context->data); } else if (event->mask == IN_DELETE) { done = context->cb_removed(path, context->data); } }} 如果传来的数据是跟真正 USB 设备相关的(通过 watch descriptor 来判断,而且结合上面对 context->wds[] 数组的说明),那么就通过调用相应的回调函数来通知上层相应的设备变动。 好,底层的这部分说明完了,接下来就要 JNI 层了。JNI 层负责给调用上面提到的函数,指定合适的回调函数,具体位置在:frameworks/base/services/core/jni/com_android_server_UsbHostManager.cpp: static void android_server_UsbHostManager_monitorUsbHostBus(JNIEnv* /* env */, jobject thiz){ struct usb_host_context* context = usb_host_init(); if (!context) { ALOGE("usb_host_init failed"); return; } // this will never return so it is safe to pass thiz directly usb_host_run(context, usb_device_added, usb_device_removed, NULL, (void *)thiz);} 在联系我们在 libusb 上提到的 usb_host_init() 和 usb_host_run() ,有没有一种 connected 的感觉呢? 我们前面说过,JNI 层的函数是被 UsbHostManager 调用的,我们再来看看 UsbHostManager 中具体的监控进程,位置在 frameworks/base/services/usb/java/com/android/server/usb/UsbHostManager.java: public void systemReady() { synchronized (mLock) { // Create a thread to call into native code to wait for USB host events. // This thread will call us back on usbDeviceAdded and usbDeviceRemoved. Runnable runnable = new Runnable() { public void run() { monitorUsbHostBus(); } }; new Thread(null, runnable, "UsbService host thread").start(); }} 那么如何打通这两者呢?哈,我们上面提到的 Android JNI 就扮演着连接者的角色。看看下面的代码你就明白了: static JNINativeMethod method_table[] = { { "monitorUsbHostBus", "()V", (void*)android_server_UsbHostManager_monitorUsbHostBus }, ......}; 至此,Android 发现外部 USB 设备的整个流程我们就说明完了。 EOF]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>Android</tag>
<tag>USB</tag>
</tags>
</entry>
<entry>
<title><![CDATA[JNI 编程初探]]></title>
<url>%2Fjni-programming%2F</url>
<content type="text"><![CDATA[JNI? JNI! 提起 Java,我们的耳边似乎总会响起这样的论调: Java 使用虚拟机,性能效率不行,跟 C/C++ 没法比 好像说,Java 跟性能是绝缘的,C/C++ 才是性能的典范。那么,能否将两者结合起来呢?当然可以,就是我们今天的主题 —— Java JNI 。 简单地说,Java JNI 就是将 Java 类中的一些方法用 C/C++ 甚至是汇编来实现,以弥补 Java 某些方面的不足。下面我们通过一个简单的 Hello world 来阐述整个流程是怎么样的。 流程首先,我们在 jni_first.java 中定义一个类,并且在类中声明一个方法,这个方法就是我们要使用 C/C++ 来实现的: public class jni_first { static { System.loadLibrary("first"); } public native void disp_jni(); public static void main(String[] args) { new jni_first().disp_jni(); }} *注意,上面的 System.loadLibrary("balabala") 里面的参数是有讲究的。他就是我们 native 方法所在的 C/C++ 文件编译之后的库的文件名。所以在 Windows 下,我们的 C/C++ 文件要编译为 balabala.dll ,在 Linux 下要编译为 libbalabala.so *,具体我们后面讲。 然后我们首先要将该 Java 文件编译成 class 文件: javac jni_first.java 然后我们使用 javah 来生成头文件,头文件里面定义了我们 native 方法的函数名。也就是说,我们的 native 方法的函数名使不能让我们随便取的,而是要让机器生成的: javah jni_first 接下来,我们就会发现我们当前的目录下出现了一个 jni_first.h 的文件,打开该文件看看它的内容: /* DO NOT EDIT THIS FILE - it is machine generated */#include <jni.h>/* Header for class jni_first */#ifndef _Included_jni_first#define _Included_jni_first#ifdef __cplusplusextern "C" {#endif/* * Class: jni_first * Method: disp_jni * Signature: ()V */JNIEXPORT void JNICALL Java_jni_1first_disp_1jni (JNIEnv *, jobject);#ifdef __cplusplus}#endif#endif 于是我们可以看到我们的 native 方法应该叫什么:Java_jni_1first_disp_1jni: 接下来,实现我们的 native 方法: #include "jni_first.h"#include <stdio.h>JNIEXPORT void JNICALL Java_jni_1first_disp_1jni(JNIEnv *, jobject){ printf("From %s: ", __func__); printf("Hello, Java world.\n"); return ;} 编译成库: g++ -shared -fPIC -I /usr/lib/jvm/java-8-openjdk-amd64/include -I/usr/lib/jvm/java-8-openjdk-amd64/include/linux jni_first.cpp -o libfirs.so注意这里的 -I 参数,第一个 -I 指定了 jni.h 文件的位置,第二个 -I 指定了 jni_md.h 文件的位置(在 jni.h 中被引用)。同时注意最后生成的库名,在 Windows 应该以 .dll 为后缀,在 Linux 应该以 lib 为前缀,.so 为后缀。 最后,执行: java -Djava.library.path=. jni_first这里的 -Djava.library.path=. 指定了库所在的位置是当前路径,不然会报错。于是,我们就可以看到以下输出了: $ java -Djava.library.path=. jni_first From Java_jni_1first_disp_1jni: Hello, Java world.好了,至此,我们就把整个流程都走了一遍了。可以看到,Java JNI 和 Android JNI 的一个区别就是 Android JNI 可以通过 JNINativeMethod 数组来指定 native 方法的函数名,而 Java JNI 只能按照机器生成的头文件来。 EOF]]></content>
<categories>
<category>代码之美</category>
</categories>
<tags>
<tag>JNI</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Page Fram Reclaim 算法详解]]></title>
<url>%2Fpage-fram-reclaim-algorithm%2F</url>
<content type="text"><![CDATA[页面回收的那些事儿 一般来说,我们的手机的内存 RAM 的容量是固定的,用多少,就少多少,这是不可逆转的。为了解决这个难题,内存交换的思想应运而生,通过将一些不活动页交换到速度较慢的硬盘,SD 卡等,然后将这些内存腾出来,从而变相地达到了增大内存的效果。(然而这个并不是新东西,我读过 Linux 0.12 的源代码,发现在那个时候就已经有这个东东了) 整个内存交换分为两个部分——页面回收,页面回写。页面回收主要是通过一系列的算法判断筛选出回收页的候选者,页面回写则负责将这些页回写到交换设备中。这里面最重要和最复杂的是页面回收算法,也是这篇东东所要描述的重点。 注:在学习这一部分内容的时候,我查阅了大量的资料,并且发现而英语术语的重要性,所以对于重要的术语,我会在后面注明它的英文原文。 预备知识 匿名页(anonymous page) 和 文件页(file page) 匿名页指的是内存页的内容没有备份在文件或者其他后备设备(backup device),一般我们进程的栈和堆都属于这一类型,或者我们在使用 mmap() 进行映射的时候,加入了 MAP_ANONYMOUS 的参数。这种内存页的特点是,内存页里面的内容,丢了就没了。所以如果在将这一种内存页交换出去的时候,必须为其申请一块交换空间(swap space) 文件页则是与上面的相反,这一类内存页里面的内容都有备份到后备设备。但是这一类内存页存在一个问题就是同步,即内存里面的数据被修改了,从而导致内存页变成了脏页(dirty page),这个时候就需要进行回写(writeback),使其后备设备的内容与内存页的内容一致。 struct page 中一些重要的 flag PG_swapcache: 说明当前页是页缓存。并且当前页的 struct page 里面的 private 成员指向的是交换设备的标识符,mapping 成员指向 NULL 。(具体什么是页缓存请看下一节:重要的数据结构) PG_swapbacked: 当前页被备份在 RAM 或者是 swap 分区上。 PG_reclaim: 似乎是表明当前页是脏的,但是还没有进行回写操作。 注意:内核有专门的函数来检测这些标志位,例如 PG_swapcache 就是用 PageSwapCache(),PG_reclaim 就是用 PageReclaim() 。但是,如果你通过 grep 或者其他工具根据函数名去搜索这些函数的实现的时候,你会很神奇地发现你找不到。其实这是内核使用的一个技巧,因为这些函数大同小异,所以系统使用了宏(macro)来统一表达。所以你就会发现,这么一堆函数在内核的实现中只用了两行(具体在 include/linux/page-flags): static inline int Page##uname(const struct page *page) \ { return test_bit(PG_##lname, &page->flags); } 所以,这也给了我们一个提示,如果我们在搜索一些变量或者函数的时候找不到它的实现,那么很有可能内核使用了宏来实现,而解决方法页很简单,就是抽取出这些变量或者函数的共同处,然后使用这个共同处去搜索,会有意想不到的收获~ struct zone 中一些重要的 flag ZONE_DIRTY: 页回收扫描过程最近在 LRU 链表的尾部发现了大量的脏页 ZONE_WRITEBACK: 回收扫描阶段遇到大量正在处于回写阶段的内存页 ZONE_CONGESTED: 当前的 zone 有许多备份在已经处于拥塞(congested)的后备设备的脏页。 一些重要的内存分配器(memory allocator)的 flag 其实这里比较模糊的是 __GFP_IO 和 __GFP_FS,而要理解这两个的区别就需要知道一个知识点——交换设备是没有文件系统的(当然,交换设备有可能是存放在文件系统上的文件),所以对这些设备的写操作不需要特别繁琐的过程。所以在代码中你会看到这样的代码组合: (PageSwapCache(page) && (sc->gfp_mask & __GFP_IO)); 重要的数据结构注意,我这里提到的都是 Linux Kernel 3.x 之后变动很大的数据结构,如果没有提到的,都是基本保持不变的,直接看书即可,几乎市面上所有的内核书都是基于 Linux Kernel 2.6.x 的。 struct vm_area_struct 这个数据结构是用来描述虚拟内存区域(virtual memory area)的,关于这个数据结构的详细说明我想放在逆向映射(reverse mapping),这里主要提一点,就是内核是如何分辨一个内存映射是匿名映射(anonymous mapping)和文件映射(file mapping)。首先,每个物理页帧(page flame)都有一个 struct page 的结构体变量实例,这个结构体里面有一个成员 mapping 。每个进程的所有内存区域都是用 struct vm_area_struct 来统一管理的,进程通过 struct task_struct 里面的成员 struct vm_area_struct *mmap 来管理关联到该进程的所有 struct vm_area_struct 实例(通过链表和红黑树)。一个页帧要关联到一个进程,就是要关联到一个 struct vm_area_struct 。具体的流程是这样的:如果是匿名映射,那么将 struct page 里面的 mapping 指向所在的 struct vm_arear_struct 里面的 struct anon_vma 加上 PAGE_MAPPING_ANON ,这样通过判断 page->mapping 的最低位是否为 PAGE_MAPPING_ANON 就可以很好的区分匿名映射。这样就可以通过 page->mapping 获取页帧的 struct anon_vma ,最后通过 container_of 之类的宏就可以访问到整个 struct vm_area_struct 。 struct lruvec 在 Linux Kernel 2.6 以及之前的版本,内核试图将内存页分类到两个 LRU 链表 —— 活动的(active)和不活动的(inactive),系统是通过在每个内存域(zone)的结构体 struct zone 里面两个成员 struct list_head active_list 和 struct list_head inactive_list 来表示的。后来,Linux Kernel 又将这两个链表根据它们的来源进行了细化和划分,将每个链表分成了 file 和 anon ,再加上一个不可驱逐的 unevictable 的链表,总共有 5 个 LRU 链表。 struct lruvec { struct list_head lists[NR_LRU_LISTS]; struct zone_reclaim_stat reclaim_stat; #ifdef CONFIG_MEMCG struct zone *zone; #endif }; enum lru_list { LRU_INACTIVE_ANON = LRU_BASE, LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE, LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE, LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE, LRU_UNEVICTABLE, NR_LRU_LISTS}; 然后,需要指出的是:回收行为分为 global reclaim 和 target reclaim 。其中, target reclaim 是针对 memory cgroup 的(因为 memory cgroup 也包含有 struct lruvec 这个数据结构) ,而 global reclaim 是针对整个内存域的。 struct scan_control 这是一个很重要的数据结构,扫描的调用者通过这个数据结构向执行回收扫描的函数来控制扫描行为,这里面有几个比较重要的成员变量要说明一下(只挑选了一部分): struct scan_control { /*指明了 shrink_list() 应该回收多少内存页 */ unsigned long nr_to_reclaim; /* 这个是传给后续页面回收函数的 GFP 标志 */ gfp_t gfp_mask; /* 系统通过判断这个成员是否为 NULL 来判断 global reclaim 还是 target reclaim */ struct mem_cgroup *target_mem_cgroup; /* 扫描的数目是整个 LRU 的大小 >> priority (具体在 get_scan_count() 体现) * 也就是说,priority 越大,扫描的数目就越少 */ int priority;// 指定了内核是否允许将页面写出到后备设备上 unsigned int may_writepage:1; /* 指定了已经映射的页面能否被回收 */ unsigned int may_unmap:1; /* 指定了页面回收阶段是否允许页交换 */ unsigned int may_swap:1; /* 到目前为止通过 shrink_zones() 释放掉的页面数目 */ unsigned long nr_reclaimed;}; 交换缓存(swap cache) 交换缓存是个很重要的数据结构,不过由于它是属于页面回写部分的,而且,它是属于页缓存的一种。所以,比较详细的实现就暂且留个 TODO ,不过,大体上还是能够说明这个交换缓存的作用的。 想象没有交换缓存的情形,现在有一个内存页被两个进程在同时使用。此时如果内核选择将这个内存页交换出去(swap out)那么这两个进程都会将该页对应的页表项修改为页面交换所在的后备设备。然后,进程 A 想要读该内存页,那么内核将会把这个内存页换回。然后问题来了,如果接下来进程 B 也想要读该内存页,但是 B 并不知道 A 已经将该页换入(swap in)了,所以势必会造成页面的重复换入,这是一种浪费的行为,也根本没必要。所以交换缓存就应运而生了。 对于交换缓存是如何解决这个问题的,我觉得没有那一本书能够讲得比《Understanding the Linux Kernel》这本书详细了,而且我对自己的表述能力肯定无法超过这本书,所以下将书中的相关内容摘录如下: Before being written to disk, each page to be swapped out is stored in the swap cache by shrink_list(). Consider a page P that is shared among two processes, A and B. Initially, the Page Table entries of both processes contain a reference to the page frame, and the page has two owners. When the PFRA selects the page for reclaiming, shrink_list( ) inserts the page frame in the swap cache, now the page frame has three owners, while the page slot in the swap area is referenced only by the swap cache. Next, the PFRA invokes try_to_unmap( ) to remove the references to the page frame from the Page Table of the processes; once this function terminates, the page frame is referenced only by the swap cache, while the page slot is referenced by the two processes and the swap cache. Let’s suppose that, while the page’s contents are being written to disk, process B accesses the pagethat is, it tries to access a memory cell using a linear address inside the page. Then, the page fault handler finds the page frame in the swap cache and puts back its physical address in the Page Table entry of process B. Conversely, if the swap-out operation terminates without concurrent swap-in operations, the shrink_list( ) function removes the page frame from the swap cache and releases the page frame to the Buddy system. 简单的说,就是进程每一次需要调入页的时候,都必须要搜索一下交换缓存是否有当前要换入的页。 详细说明shrink_lruvec()首先,shrink_lruvec() 先调用 get_scan_count() 来统计各个 LRU 链表要扫描的内存页的数目: get_scan_count()首先,判断是否需要强制扫描,这个是通过 force_scan 来表示的。需要强制扫描有以下两种情况: 当前进程是 kswapd() ,并且当前的 zone 并不需要回收 是 memory cgroup 在进行页面回收 首先说明一下强制扫描指的是什么?前面提到过,扫描行为最终在每个 LRU 链表的扫描数目是根据当前 LRU 链表的内存页数 >> sc->priority,所以扫描数目是有可能为 0 的,所以如果进行强制扫描的话,那么如果上面的计算结果为 0 的话,也要重新计算扫描的数目。 get_scan_count() 的职责是根据当前 zone 的情况,判断需要扫描的 LRU 链表的类型,并且是首先倾向于扫描 file 链表的(因为其有后备设备,可以直接释放),并且要以一定的比例来分别确定 file 链表和 anon 链表的数目: // 注意: // nr[0] 是 anon inactive pages 要扫描的数目; nr[1] = anon active pages 要扫描的数目 // nr[2] = file inactive pages 要扫描的数目; nr[3] = file active pages 要扫描的数目 static void get_scan_count(struct lruvec *lruvec, int swappiness, struct scan_control *sc, unsigned long *nr) { ...... // 如果指示回收过程不允许页交换,或者系统没有交换空间,则只扫描 file page ,则是非常显然的,不然没有地方给匿名页分配 swap space if (!sc->may_swap || (get_nr_swap_pages() <= 0)) { scan_balance = SCAN_FILE; goto out; } // 这里首先得理解一下 swappiness 这个参数的意义。swappiness 是一个系统参数,它控制了系统交换的好斗程度(aggressive),当取值小的时候,系统倾向于不交换,取值大的时候,系统则会进行页交换。默认值是 60 。当取值为 0 的时候,系统除非为了相应 OOM(out of memory) ,否则不进行页交换。 // 然后还得注意,swappiness 有两份,一份是控制全局的,一份是每个 memory cgroup 的,在下面的这个 case 是 cgroup 的 swappiness if (!global_reclaim(sc) && !vmscan_swappiness(sc)) { scan_balance = SCAN_FILE; goto out; } // 前面提到了 sc->priority 的意义,也就说,priority 越小,扫描的数目就越多,当 priority = 0 的时候,说明系统已经接近 OOM 了,那么此时除非系统 swappiness 等于 0 (也就是不进行页交换),否则,则 file page 和 anonymous page 都要扫描 if (!sc->priority && vmscan_swappiness(sc)) { scan_balance = SCAN_EQUAL; goto out; } /* * Prevent the reclaimer from falling into the cache trap: as * cache pages start out inactive, every cache fault will tip * the scan balance towards the file LRU. And as the file LRU * shrinks, so does the window for rotation from references. * This means we have a runaway feedback loop where a tiny * thrashing file LRU becomes infinitely more attractive than * anon pages. Try to detect this based on file LRU size. */ // 上面的注释我看不大懂,所以保留 // 这里先说明以下 zone 里面的 watermark 的意义:这是系统根据当前 zone 的内存大小设置的三个水位线,用以区分当前内存的紧缺程度 // HIGH_WMARK: 如果超过这个值,说明当前的内存剩余是理想的,如果小于 HIGH_WMARK 则是要开始页回收了 // 不过根据下面的代码推测,似乎是如果当前 zone 的 file page 的总量加上剩余的内存页重量低于 HIGH_WMARK ,则要扫描 anon 链表,不过具体的逻辑还得进一步探究 // TODO: 理解上面的注释 if (global_reclaim(sc)) { unsigned long zonefile; unsigned long zonefree; zonefree = zone_page_state(zone, NR_FREE_PAGES); zonefile = zone_page_state(zone, NR_ACTIVE_FILE) + zone_page_state(zone, NR_INACTIVE_FILE); if (unlikely(zonefile + zonefree <= high_wmark_pages(zone))) { scan_balance = SCAN_ANON; goto out; } } // 当 LRU 链表上的不活动页足够的时候,那么就只回收 file page if (!inactive_file_is_low(lruvec)) { scan_balance = SCAN_FILE; goto out; } // 默认情况是 SCAN_FRACT scan_balance = SCAN_FRACT; // 在这里,swappiness 这个变量的意义似乎就显示出来了。swappiness 控制了匿名页的优先级,如果 swappiness 等于 100 ,那么匿名页和 file page 的优先级一样。 anon_prio = vmscan_swappiness(sc); file_prio = 200 - anon_prio; anon = get_lru_size(lruvec, LRU_ACTIVE_ANON) + get_lru_size(lruvec, LRU_INACTIVE_ANON); file = get_lru_size(lruvec, LRU_ACTIVE_FILE) + get_lru_size(lruvec, LRU_INACTIVE_FILE); ......... /* * OK, so we have swap space and a fair amount of page cache * pages. We use the recently rotated / recently scanned * ratios to determine how valuable each cache is. * * Because workloads change over time (and to avoid overflow) * we keep these statistics as a floating average, which ends * up weighing recent references more than old ones. * * anon in [0], file in [1] */ // PROBLEM: 这里的逻辑还不是太懂,注释保留 spin_lock_irq(&zone->lru_lock); if (unlikely(reclaim_stat->recent_scanned[0] > anon / 4)) { reclaim_stat->recent_scanned[0] /= 2; reclaim_stat->recent_rotated[0] /= 2; } if (unlikely(reclaim_stat->recent_scanned[1] > file / 4)) { reclaim_stat->recent_scanned[1] /= 2; reclaim_stat->recent_rotated[1] /= 2; } /* * The amount of pressure on anon vs file pages is inversely * proportional to the fraction of recently scanned pages on * each list that were recently referenced and in active use. */ // 这里依旧无法解释,不过经过大量的搜索以及跟踪 Kernel 中这一部分的改动,似乎只是为了更加合理的计算出要扫描的 file page 和 anonymous page 的数量 // 之前的 Kernel 版本中,ap 和 fp 的数量不是这么计算的,这里之所以是这么计算是为了保证当 swappiness 等于 0 的时候,ap 能够真的等于 0 。 // 具体可以查看: // 1) http://gitorious.ti.com/ti-linux-kernel/ti-linux-kernel/commit/fe35004fbf9eaf67482b074a2e032abb9c89b1dd?format=patch // 2) https://eklitzke.org/swappiness ap = anon_prio * (reclaim_stat->recent_scanned[0] + 1); ap /= reclaim_stat->recent_rotated[0] + 1; fp = file_prio * (reclaim_stat->recent_scanned[1] + 1); fp /= reclaim_stat->recent_rotated[1] + 1; spin_unlock_irq(&zone->lru_lock); fraction[0] = ap; fraction[1] = fp; denominator = ap + fp + 1; out: // 这里的 some_scanned 的作用就是一个二次机会(second chance)的思想 some_scanned = false; for (pass = 0; !some_scanned && pass < 2; pass++) { for_each_evictable_lru(lru) { int file = is_file_lru(lru); unsigned long size; unsigned long scan; // 看,这里计算出了每个链表要扫描的页的数目 size = get_lru_size(lruvec, lru); scan = size >> sc->priority; // 如果 scan 等于 0,并且前面已经标记要强制扫描了,那么就在这里给它第二次机会 // 必须在第二轮,也就是 pass 等于 1 if (!scan && pass && force_scan) scan = min(size, SWAP_CLUSTER_MAX); switch (scan_balance) { case SCAN_EQUAL: /* Scan lists relative to size */ break; case SCAN_FRACT: // 似乎有点理解 SCAN_FRACT 的意思了。FRACT 就是 fraction,就是默认的扫描类型,主要是根据上面计算出来的 ap 和 fp 的数量,对 scan (要扫描的总量)进行等比例的分配 scan = div64_u64(scan * fraction[file], denominator); break; case SCAN_FILE: case SCAN_ANON: /* Scan one type exclusively */ // 如果是 file lru 要扫描 anon page 或者是 anon lru 要扫描 file page ,则 scan 为 0 if ((scan_balance == SCAN_FILE) != file) scan = 0; break; default: BUG(); } nr[lru] = scan; // 如果我们找到有内存页可以扫描,那么就不用使用 second chance 了 // 这里的 !! 的作用是让零值位零,非零值为一 // 前面的循环的条件是 !some_scanned some_scanned |= !!scan; } } }好,计算了各个链表应该扫描的数目之后,就要开始进行扫描了: scan_adjusted = (global_reclaim(sc) && !current_is_kswapd() && sc->priority == DEF_PRIORITY); blk_start_plug(&plug); while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] || nr[LRU_INACTIVE_FILE]) { unsigned long nr_anon, nr_file, percentage; unsigned long nr_scanned; for_each_evictable_lru(lru) { if (nr[lru]) { // 如果 lru 链表要移除的超过了 SWAP_CLUSTER_MAX ,那么移除操作将拆成多步完成 // 至于为什么要这么做,可以参考这个链接: // http://blog.csdn.net/dog250/article/details/5303568 nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX); nr[lru] -= nr_to_scan;K nr_reclaimed += shrink_list(lru, nr_to_scan, lruvec, sc); } }从上面的代码我们可以看到,每一次扫描的数目不超过 SWAP_CLUSTER_MAX。 同时,要注意一个点,就是 scan_adjusted 这个变量的作用。我现在还不是太清楚这个变量的作用,不过通过跟踪内核上游的代码变更,我找到了加入了这个变量的几个 patch ,通过里面的 commit 好像是说为了保证一开始当 sc->priority == DEF_PRIORITY 的时候,需要扫描更多的页。所以引入了这个变量,因为在循环后面的操作会减少内存页扫描的数目。具体可以看看这几个 patch : mm: vmscan: Obey proportional scanning requirements for kswapd performance regression due to commit e82e0561 好,上面是一个小插曲,我们接下来看看重头戏,扫描回收的主程序: shrink_list()static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan, struct lruvec *lruvec, struct scan_control *sc) { if (is_active_lru(lru)) { if (inactive_list_is_low(lruvec, lru)) shrink_active_list(nr_to_scan, lruvec, sc, lru); return 0; } return shrink_inactive_list(nr_to_scan, lruvec, sc, lru); }如果当前是 active lru 并且 inactive lru 里面的内存页太少了,那么将调用 shrink_active_list(): shrink_active_list()这个函数的主要作用是从 active lru 移动一些内存页到 inactive lru。为了避免锁的麻烦,shrink_active_list() 和 shrink_inactive_list() 都使用了一个 isolate_lru_pages() 来将扫描的内存页隔离到一个临时的链表中。这个函数没有什么好说的,不过有一点比较重要,就是可能存在页结合(conbine page)的情况,这个在下面会提到: static void shrink_active_list(unsigned long nr_to_scan, struct lruvec *lruvec, struct scan_control *sc, enum lru_list lru) { ...... LIST_HEAD(l_hold); /* The pages which were snipped off */ LIST_HEAD(l_active); LIST_HEAD(l_inactive); ...... // 这个函数是是将每个 CPU 的页向量写回到 lru 链表上 // 页向量是一个 Per-CPU 的变量 // 这个在变动不大,所以就不详细说明了,看书就好 lru_add_drain(); ...... // 从当前的 lru 页表隔离 nr_to_scan 个页面到 l_hold 链表上 // @nr_to_scan: 计划要扫描的页的数目 // @nr_scanned: 实际扫描的数目。注意,这里是忽略了 combine page 的,也就是说,不管是多少页合并到一起,都是算作一页 // @nr_taken: 扫描的总页数。这里是考虑 combine page 的 nr_taken = isolate_lru_pages(nr_to_scan, lruvec, &l_hold, &nr_scanned, sc, isolate_mode, lru); ...... while (!list_empty(&l_hold)) { cond_resched(); page = lru_to_page(&l_hold); list_del(&page->lru); if (unlikely(!page_evictable(page))) { // 将当前页移回 lru 链表上 putback_lru_page(page); continue; } // 这一块也不是很清楚 if (unlikely(buffer_heads_over_limit)) { if (page_has_private(page) && trylock_page(page)) { if (page_has_private(page)) try_to_release_page(page, 0); unlock_page(page); } } // TODO: 逆向映射是个大工程啊,得花很长的时间去说明,这里先跳过 // page_referenced() 的返回值是当前页的引用数目 if (page_referenced(page, 0, sc->target_mem_cgroup, &vm_flags)) { nr_rotated += hpage_nr_pages(page); /* * Identify referenced, file-backed active pages and * give them one more trip around the active list. So * that executable code get better chances to stay in * memory under moderate memory pressure. Anon pages * are not likely to be evicted by use-once streaming * IO, plus JVM can create lots of anon VM_EXEC pages, * so we ignore them here. */ // 根据注释,好像是为了防止一些页被加入到 inactive list ,然后给他们第二次机会 // 也就是说,只要当前页有被引用到,那么就有机会重新回到 active list 上 if ((vm_flags & VM_EXEC) && page_is_file_cache(page)) { list_add(&page->lru, &l_active); continue; } } ClearPageActive(page); /* we are de-activating */ list_add(&page->lru, &l_inactive); } ...... // l_active 移到 lru 活动链表,l_inactive 移动到 lru 不活动链表 // 同时,上面的操作会将内存页的引用次数减去一,并且判断是否为 0 。并移到相应的 LRU 链表中 // 如果为 0 的话,那么就去掉这个函数之后加入的 PG_lru 标志以及 PG_active 标志,从 LRU 链表中移除,并重新放回原来的 l_hold 链表中 move_active_pages_to_lru(lruvec, &l_active, &l_hold, lru); move_active_pages_to_lru(lruvec, &l_inactive, &l_hold, lru - LRU_ACTIVE); __mod_zone_page_state(zone, NR_ISOLATED_ANON + file, -nr_taken); spin_unlock_irq(&zone->lru_lock); mem_cgroup_uncharge_list(&l_hold); // 依然就在 l_hold 链表的内存页都是引用次数为 0 的内存页,所以将他们释放回伙伴系统 free_hot_cold_page_list(&l_hold, true); }如果 inactive lru 的内存页数目充足,那么就开始调用 shrink_inactive_list(): shrink_inactive_list()*这个函数的主要作用是从 inactive list 上的内存页释放掉一些返回给伙伴系统 *,这里面涉及到的逻辑很多,我们详细地说明: static noinline_for_stack unsigned long shrink_inactive_list(unsigned long nr_to_scan, struct lruvec *lruvec, struct scan_control *sc, enum lru_list lru) { LIST_HEAD(page_list); unsigned long nr_scanned = 3000; unsigned long nr_reclaimed = 0; unsigned long nr_taken; unsigned long nr_dirty = 0; unsigned long nr_congested = 0; unsigned long nr_unqueued_dirty = 0; unsigned long nr_writeback = 0; unsigned long nr_immediate = 0; isolate_mode_t isolate_mode = 0; int file = is_file_lru(lru); struct zone *zone = lruvec_zone(lruvec); struct zone_reclaim_stat *reclaim_stat = &lruvec->reclaim_stat; lru_add_drain(); ...... nr_taken = isolate_lru_pages(nr_to_scan, lruvec, &page_list, &nr_scanned, sc, isolate_mode, lru); nr_reclaimed = shrink_page_list(&page_list, zone, sc, TTU_UNMAP, &nr_dirty, &nr_unqueued_dirty, &nr_congested, &nr_writeback, &nr_immediate, false);函数首先的套路跟 shrink_active_list() 是一模一样的,都是调用 isolate_lru_pages() 从当前的 lru 链表中移除一部分的内存页到一个临时链表,然后,整个重头戏来了,执行最终的回写释放操作都在接下来调用的 shrink_page_list() 这个函数中,可以说,这个函数是连接页面回收和页面回写的中间桥梁。 shrink_page_list()这个函数将从 shrink_inactive_list() 传来的临时链表上的内存页进行回写操作。当然有一些内存页是不需要回写并且需要重新放回 LRU 链表的。 static unsigned long shrink_page_list(struct list_head *page_list, struct zone *zone, struct scan_control *sc, enum ttu_flags ttu_flags, unsigned long *ret_nr_dirty, unsigned long *ret_nr_unqueued_dirty, unsigned long *ret_nr_congested, unsigned long *ret_nr_writeback, unsigned long *ret_nr_immediate, bool force_reclaim) { LIST_HEAD(ret_pages); LIST_HEAD(free_pages); int pgactivate = 0; unsigned long nr_unqueued_dirty = 0; unsigned long nr_dirty = 0; unsigned long nr_congested = 0; unsigned long nr_reclaimed = 0; unsigned long nr_writeback = 0; unsigned long nr_immediate = 0; cond_resched(); while (!list_empty(page_list)) { struct address_space *mapping; struct page *page; int may_enter_fs; enum page_references references = PAGEREF_RECLAIM_CLEAN; bool dirty, writeback; cond_resched(); //取出一页,将其从当前链表删除 page = lru_to_page(page_list); list_del(&page->lru); //锁定当前页,具体做法是在 page->flag 打上 PG_lock 的标志 if (!trylock_page(page)) goto keep; sc->nr_scanned++; // 当前页不可驱逐 // 一个页面是不可驱逐的原因有两个: // 1. 当前页面的映射是不可驱逐的:mapping->flag // 2. 当前页面是 mloacked VMA 的一部分 if (unlikely(!page_evictable(page))) goto cull_mlocked; // 当前 scan_control 表示不能回收已经 map 的页,但是当前页已经 mapped 了 if (!sc->may_unmap && page_mapped(page)) goto keep_locked; /* Double the slab pressure for mapped and swapcache pages */ // PROBLEM:这一句不是很清楚,这跟 slab 有什么关系 if (page_mapped(page) || PageSwapCache(page)) sc->nr_scanned++; // 后续操作中可能进行 IO 操作?这里就是我们上面提到的 __GFP_FS 和 __GFP_IO 的区别所在了 may_enter_fs = (sc->gfp_mask & __GFP_FS) || (PageSwapCache(page) && (sc->gfp_mask & __GFP_IO)); // 页面是脏的或者正处在回写状态 page_check_dirty_writeback(page, &dirty, &writeback); if (dirty || writeback) nr_dirty++; // 页面是脏的并且还没有在进行回写 // 回写肯定是要在等待队列中排队,所以这里是 unqueued if (dirty && !writeback) nr_unqueued_dirty++; mapping = page_mapping(page); // 如果当前页有被映射,但是后备设备正处在拥塞状态 if ((mapping && bdi_write_congested(mapping->backing_dev_info)) || (writeback && PageReclaim(page))) nr_congested++; ...... if (!force_reclaim) references = page_check_references(page, sc);在这里要说明一下 page_check_references() 这个函数。我们在看《深入理解 Linux 内核》或者《深入 Linux 内核架构》这些内核书的时候,会看到这样的一个名词——第二次机会(second chance)算法。这个函数就充分地表现了这个算法。这个函数只要发现当前的内存页被引用(reference)了,那么就会试图让当前页不被回收而是重新回到 LRU 链表上。这个算法也是避免页颠簸(page thrashing)的一个重要的体现。我们既要回收不活动的内存页,也要避免将活动页回收。这也是为什么这一部分的代码十分繁杂的缘故了。因为他要考虑到很多很多中情况。 // 根据当前页的状态,如果是 RECLAIM 状态的,就在接下来进行回收,否则则跳出进行处理 switch (references) { case PAGEREF_ACTIVATE: goto activate_locked; case PAGEREF_KEEP: goto keep_locked; case PAGEREF_RECLAIM: case PAGEREF_RECLAIM_CLEAN: ; /* try to reclaim the page below */ } if (PageAnon(page) && !PageSwapCache(page)) { if (!(sc->gfp_mask & __GFP_IO)) goto keep_locked; if (!add_to_swap(page, page_list)) // INPORTANT:为当前页分配一个 swap entry 并且把它加入到 swap cache,具体请看上面关于页缓存的描述 goto activate_locked; may_enter_fs = 1; /* Adding to swap updated mapping */ mapping = page_mapping(page); } // 当前内存页被映射了,调用 try_to_unmap() 来解除映射 if (page_mapped(page) && mapping) { switch (try_to_unmap(page, ttu_flags)) { case SWAP_FAIL: goto activate_locked; case SWAP_AGAIN: goto keep_locked; case SWAP_MLOCK: goto cull_mlocked; case SWAP_SUCCESS: ; /* try to free the page below */ } } if (PageDirty(page)) { // 如果当前页是脏的,那么必须回写 // 根据注释说明,只有 kswapd 能够回写备份在文件系统的内存页(page backed by a regular filesystem)。 // 或者是回收扫描操作遇到了许多的脏页(由 ZONE_DIRTY 标志位标志) // 所以如果当前当前页是备份在文件系统但是当前进程不是 kswap , // 或者是当前还没有扫描到足够多的脏页,都不能进行回写 if (page_is_file_cache(page) && (!current_is_kswapd() || !test_bit(ZONE_DIRTY, &zone->flags))) { /* * Immediately reclaim when written back. * Similar in principal to deactivate_page() * except we already have the page isolated * and know it's dirty */ inc_zone_page_state(page, NR_VMSCAN_IMMEDIATE); SetPageReclaim(page); goto keep_locked; } if (references == PAGEREF_RECLAIM_CLEAN) goto keep_locked; if (!may_enter_fs) goto keep_locked; if (!sc->may_writepage) goto keep_locked; // 脏页在这里尝试写回 switch (pageout(page, mapping, sc)) { case PAGE_KEEP: goto keep_locked; case PAGE_ACTIVATE: goto activate_locked; case PAGE_SUCCESS: if (PageWriteback(page)) goto keep; if (PageDirty(page)) goto keep; if (!trylock_page(page)) goto keep; if (PageDirty(page) || PageWriteback(page)) goto keep_locked; mapping = page_mapping(page); case PAGE_CLEAN: ; /* try to free the page below */ } } ...... if (!mapping || !__remove_mapping(mapping, page, true)) goto keep_locked; __clear_page_locked(page); // 要释放的内存页添加到 free_pages 链表,要放回的放到 ret_pages 链表 free_it: nr_reclaimed++; list_add(&page->lru, &free_pages); continue; cull_mlocked: if (PageSwapCache(page)) try_to_free_swap(page); unlock_page(page); putback_lru_page(page); continue; activate_locked: if (PageSwapCache(page) && vm_swap_full()) try_to_free_swap(page); VM_BUG_ON_PAGE(PageActive(page), page); SetPageActive(page); pgactivate++; keep_locked: unlock_page(page); keep: list_add(&page->lru, &ret_pages); VM_BUG_ON_PAGE(PageLRU(page) || PageUnevictable(page), page); } mem_cgroup_uncharge_list(&free_pages); free_hot_cold_page_list(&free_pages, true); // free_pages 链表上的内存页会被上面的 free_hot_cold_page_list() 释放掉 // 上面循环未处理的内存页所在的 ret_pages 将移到 page_list 链表(即调用者传进来的参数),然后在本函数的调用者那里被重新移到不活动链表上 list_splice(&ret_pages, page_list); count_vm_events(PGACTIVATE, pgactivate); *ret_nr_dirty += nr_dirty; *ret_nr_congested += nr_congested; *ret_nr_unqueued_dirty += nr_unqueued_dirty; *ret_nr_writeback += nr_writeback; *ret_nr_immediate += nr_immediate; return nr_reclaimed; }这里要说明一下上面那几个 nr_ 变量的意义: nr_dirty: 当前页是脏的或者处于回写状态nr_unqueued_dirty: ** 当前页是脏的并且没有处于回写状态*nr_congested: * 当前页已经被映射并且该页的后备设备正处在阻塞状态(congested),或者是当前页正处在回写状态并且该页即将被回收*nr_immediate: * 好像是某一页 **marked for immediate reclaim and under writeback(nr_immediate).,也就是说,当前页即将被回收,而且正处在回写状态(这个跟上面是不一样的,只有当前 zone 标记了 ZONE_WRITEBACK 才将内存页计入这里)*nr_reclaimed: * 总共回收的页数 最后,ret_pages 链表中要返回的内存页重新放到了传进来的参数 page_list ,交给调用者去重新放回 LRU 链表。 然后,回到 shrink_inactive_list() ,回写操作完成之后,必须根据本次的回写情况重新界定当前 zone 的情况,这就要用到上面提到的那几个 nr_ 变量了。后面的代码比较的清晰直接,就补贴代码了。 我们重新回到 shrink_lruvec() ,当所有的 LRU 链表扫描完了之后,我们要重新计算下一次的扫描数目了。前面提到,整个扫描活动都是遵循则比例扫描的原则,计算方法: nr_file = nr[LRU_INACTIVE_FILE] + nr[LRU_ACTIVE_FILE]; nr_anon = nr[LRU_INACTIVE_ANON] + nr[LRU_ACTIVE_ANON]; // 如果某一个链表需要扫描的数目已经为 0 ,那么跳出循环,本次页面回收结束 if (!nr_file || !nr_anon) break; // 下面的做法是将剩下需要扫描数目少的清零,并且标志那个较大的 LRU 链表(lru 变量) // percentage:已经扫描的内存页相对于 get_scan_count() 计算出来的需要扫描的内存页的占比 // 用于提供下面的比例扫描的重新计算 if (nr_file > nr_anon) { unsigned long scan_target = targets[LRU_INACTIVE_ANON] + targets[LRU_ACTIVE_ANON] + 1; lru = LRU_BASE; percentage = nr_anon * 100 / scan_target; } else { unsigned long scan_target = targets[LRU_INACTIVE_FILE] + targets[LRU_ACTIVE_FILE] + 1; lru = LRU_FILE; percentage = nr_file * 100 / scan_target; } // 少的我们就不扫描了 nr[lru] = 0; nr[lru + LRU_ACTIVE] = 0; // 根据占比重新计算需要扫描的数目 lru = (lru == LRU_FILE) ? LRU_BASE : LRU_FILE; nr_scanned = targets[lru] - nr[lru]; nr[lru] = targets[lru] * (100 - percentage) / 100; nr[lru] -= min(nr[lru], nr_scanned); lru += LRU_ACTIVE; nr_scanned = targets[lru] - nr[lru]; nr[lru] = targets[lru] * (100 - percentage) / 100; nr[lru] -= min(nr[lru], nr_scanned);在这里我想提一点,在我看这些源代码的过程中,经历了一个痛苦的过程,就是我自己掉入了一个逻辑怪圈——我看得懂代码,但是却参不透其背后的逻辑,然后就大量地进行搜索,依旧没有解答。后来开始跟踪 Linux Kernel 社区的邮件列表和 patch 变动,我终于得出了一个结论 —— 我们现在说看到的所有的代码的正确性都是相对的,也就是说,是这个 patch 的提交者认为这样写能够 work right ,我们自己也可以有自己的,不同的想法和结论。不要去纠结于里面的个中细节!!!!体会思想才是关键!!!!!整个页面回收算法体现了几个思想(我觉得叫做思想十分地贴切,或者用大白话说就是大方向),比例扫描,第二次机会,当然还有最重要的 LRU (least recently used),而内核代码的提交者都是在用他们自己的方式去描述他们心中的这些思想,这或许就是 Linux Kernel 自由的意义所在吧。 以上。 EOF]]></content>
<categories>
<category>知其所以然</category>
</categories>
<tags>
<tag>内存管理</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Linux 内核代码风格检查]]></title>
<url>%2Flinux-kernel-code-check%2F</url>
<content type="text"><![CDATA[想不到居然是我阅读量最高的文章 Linux 作为一个人人都可以参与的大型项目,就必须规定其自身的代码风格来规范统一管理。在内核代码树里由一份文档 Documents/CodingStyle 详细地描述了这个代码风格的各种细节,我这里就不赘述了,我想来说一说写完代码之后如何检查是否符合规范。 当然,人肉查询是完全可以的,不过内核代码树提供了一个检查的脚本可以供我们使用,路径在 scripts/checkpatch.pl ,一般可能会出现以下几种错误: ERROR: Does not appear to be a unified-diff format patch 出现这个错误只要加上 -f / –file 就可以,意思是:treat FILE as regular source file 如果是说编码错误的话,那么可以使用 vim 来进行转换。用 vim 打开源文件,然后使用这个命令: :set ff=unix WARING: Prefer [subsystem eg: netdev]_dbg([subsystem]dev, … the dev_dbg(dev, … then pr_debug(… to printk(KERN_DEBUG … 这个的话好像是说不要使用 printk , 目前我也没用到其他的子系统,所以都是使用 pr_debug 来代替 printk 的。 还有,在那个脚本我一般都加了如下的几个选项: ./scipts/checkpatch.pl --file --terse --no-tree 差不多就是这样。 EOF]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Linux 内存管理 -- Slab 分配器和 kmalloc]]></title>
<url>%2Flinux-memory-managent-slab-allocator-and-kmalloc%2F</url>
<content type="text"><![CDATA[我曾经靠着这篇文章获得我人生的第一份实习 郑重说明,这一篇以及后序的博文我都尽量少贴代码,而是尽我的描述能力之所能把我自己的理解和思考的过程总结表达出来,千万别指望着看完我的某一篇博客就能彻底地理解某一个知识点,至少我是没有这样的表达能力的。想要知其所以然,唯一的途径就是: Read the F**king source code 正如侯捷在某一本书的扉页中写道的那样: 代码面前,没有秘密 以上 为什么要有 slab一般来说,一个新东西的产生总是为了解决某一个现有的问题的。那么,slab 是为了解决什么问题呢?我们知道,在 Linux 内核中的内存管理是使用伙伴系统 (Buddy system),但是这个系统有一个问题就是,它的最小单位是页,即 PAGE_SIZE ,在 x86 架构中这个常数一般是 4k 。但是很多情况下我们要分配的单元大小是远远小于 4k 的,如果使用伙伴系统的话,必定是会产生很大的浪费的。所以,一个粒度更加小的分配器呼之欲出,slab 就是为了解决这个小粒度内存分配的问题的。 如何解决以及结构组织既然 slab 分配器已经定下了这样的一个目标,那么它的策略是什么呢? 答曰,slab 分配器是基于所谓“面向对象”的思想,当然,这里的“面向对象”跟 C++ 和 Java 等的“面向对象”是不一样的。这里的“面向对象”更加确切的说法是“面向对象类型”,不同的类型使用不同的 slab ,一个 slab 只分配一种类型。而且,slab 为了提高内核中一些十分频繁进行分配释放的“对象”的分配效率, slab 的做法是:每次释放掉某个对象之后,不是立即将其返回给伙伴系统(slab 分配器是建立在伙伴系统之上的),而是存放在一个叫 array_cache 的结构中,下次要分配的时候就可以直接从这里分配,从而加快了速度。 这里必须明确几个概念,说明如下: 缓存(cache) : 这里的缓存只是一个叫法而已,其实就是一个管理结构头,它控制了每个 slab 的布局,具体的结构体是 struct kmem_cache 。(注意,虽然现在几乎所有的书或者博客都将这一个结构称为“缓存”,不过我觉得在这里称为“管理结构头”是更为合适的,所以下文中统一将“缓存(cache)”称为“管理结构头”。) slab: 从伙伴系统分配的 2^order 个物理页就组成了一个 slab ,而后续的操作就是在这个 slab 上在进行细分的,具体的结构体是 struct slab 。 对象(object) : 上面说到,每一个 slab 都只针对一个数据类型,这个数据类型就被称为该 slab 的“对象”,将该对象进行对齐之后的大小就是该“对象”的大小,依照该大小将上面的 slab 进行切分,从而实现了我们想要的细粒度内存分配。 per-CPU 缓存:这个就是上面提到的 array_cache ,这个是为了加快分配,预先从 slab 中分配部分对象内存以加快速度。具体的结构体是 struct array_cache,包含在 struct kmem_cache 中。 具体的结构如下图: 还有,我们在用户态编程的时候,需要分配内存的时候,一般都是使用 malloc() 函数来实现。那么在内核态编程中,如果我们要分配内存,而且又没有必要使用上面的基于某个特定对象的,内核给我们提供了一个类似 malloc() 的接口—— kmalloc() 。值得注意的是,其实 kmalloc() 也是基于 slab 分配器的,只不过它所需要的管理结构头已经按照 2^n 的大小排列事先准备好了而已,这个管理结构体数组是 struct cache_sizes malloc_sizes[] 。 还有,每个“对象”的缓存被组织成一个链表——cache_chain,然后每个缓存的 slab 被组织了三个不同的链表——slab_full,slab_partial和 slab_free,这三个链表有何不同应该可以见名知意,就不赘述了。 然后,你可能就会发现了,在对象那一点,很可能出现一种情况,那就是 slab 的大小跟 object 的大小不整除,也就是说有不足于一个 object 的大小的空间剩余,怎么办,浪费掉吗?肯定不是!内核很好地利用了这些剩余的空间,提出了“缓存染色(cache coloring)”的概念。当然,这里的染色不是真的去染成红绿蓝等颜色,这只是一种说法而已。具体地将,就是让每个缓存的 slab 在页的起始位置有不同的偏移,以缓解缓存过热的问题。注意,这里提到的“缓存过热”中的“缓存”是真的 CPU 的物理缓存。具体的我后面会详细说明,这里只是综述一下。 初始化—— kmem_cache_init()想要让 slab 分配器工作起来,必须进行一系列的初始化。不过这里存在一个“鸡生蛋蛋生鸡”的问题。我们前面说过,每个缓存需要一个管理结构头,而建立缓存的实质就是分配一个管理结构头 struct kmem_cache 来描述 slab 的布局,以指导后续的分配行为,这个建立缓存的过程是用函数 kmem_cache_create() 来实现的,这里这是点一下,下面会详细说明。很明显这个管理结构头的大小小于一页,那么是十分适合使用 slab 分配器进行分配的,但是问题是此时 slab 分配器还没有初始化完成,怎么办?内核的做法是直接静态分配一个 struct kmem_cache 类型的变量——cache_cache(不得不说,这个变量名起得真好!),然后呢,整个初始化的过程分为六步: 初始化 struct kmem_cache 变量 cache_cache 。该变量之所以重要,是因为它是以后所有的对象的管理结构头的管理结构头,专业一点的话可以称作是“元管理结构头”,然后上面提到的 array_cache 和三个链表 kmem_list3 都是这个结构体里面的成员,初始化也都是静态分配的,对应的静态变量分别是 initarray_cache 和 initkmem_list3 。 建立 kmalloc() 的 struct array_cache 对应大小的管理结构头。为什么要进行这一步呢,因为下面的步骤是要完整地建立其 kmalloc() 支持的所有 2^n 大小的管理结构头,完成这一步就相当于 kmalloc() 完全可用了。但是每个管理结构头都必须要 struct array_cache 和 struct kmem_list3 这两个辅助管理结构头,就必须要建立这两个对应大小的 kmalloc() 的管理结构头以便能够使用 kmalloc() 动态分配!这里之所以要提到动态分配是因为在给 struct array_cache 对应大小建立 kmalloc() 的管理结构头的时候,其自己的 struct array_cache 和 struct kmem_list3 也是静态分配的,所以后续将会把它们使用动态分配的空间替换掉。 建立 kmalloc() 的 struct kmem_list3 对应大小的管理结构头以及剩下的 2^n 对应大小的管理结构头。第二点已经说明过了,就不重复了。 (其实包括第五步)此时 kmalloc() 已经可用了,所以如上面所说要使用 kmalloc() 分配的动态内存去替代前面所有的静态内存,需要替换的对象有:cache_cache 的 struct array_cache ,struct array_cache 对应大小的 kmalloc() 的管理结构头的 struct array_cache 和 struct kmem_list3,以及 struct kmem_list3 对应大小的 kmalloc() 的管理结构头的 struct kmem_list3 。 重新调整各个管理结构体的 struct array_cache 中的 entry[] 的数目。 然后这里有一个问题就是,内核是如何知道当前是属于哪一个阶段呢?为了解决这个问题,内核使用了一个枚举变量 g_cpucache_up ,其接受的变量范围有:NULL , PARTIAL_AC , PARTIAL_L3,PEARLY , FULL 。当第二步完成的时候,标记为 PARTIAL_AC ,此时意味着以后的 struct array_cache 都可以使用 kmalloc() 分配了;当第三步完成的时候,标记为 PARTIAL_L3 ,此时意味着以后的 struct kmem_l3 都可以使用;当第四和第五步完成的时候,标记为 PARTIAL_EARLY ,此时意味着 kmalloc() 已经支持所有其支持的 2^n 大小的内存分配了。 然后有一个很重要的点就是,如果你读过 mm/slab.c 的源代码,你就会发现,在建立 struct array_cache 的管理结构头和为 kmalloc() 各个管理结构头的 struct array_cache 的时候,*内核使用的是 struct arraycache_init 而不是 struct array_cache *,这究竟是怎么一回事?其实这是很有讲究的,且听我慢慢到来。 其实 struct initarray_cache 的完整结构是这样的: struct arraycache_init { struct array_cache cache; void *entry[BOOT_CPUCACHE_ENTRIES];}; 从上面的结构可以看出,struct arraycache_init 就只是比 struct array_cache 多了一个 void * 的数组而已,这个究竟有什么区别呢?诶,别着急,我们再来看一下 struct array_cache 的结构就清楚了: struct array_cache { unsigned int avail; unsighed int limit; unsigned int batchcount; unsigned int touched; spinlock_t lock; void *entry[];}; 联系上文我提到 Per-CPU 的时候说过,为了加快速度将 slab 的一部分内存分配到 array_cache ,而事实上就上面我们看到的 struct array_cache 的结构,只是有一个 void * 的数组而已,而且是个伪数组,并没有数组项。其实细想这是一种十分优美的实现方法。因为各个管理结构头所需要的“一部分内存”是不一样的,这样就保证了一个通用性,每次要访问 array_cache 里面的内存的时候,只需要进行 array_cache->entry[下标] 就可以了。然后我们再来解决上面提到的 struct arraycache_init 的问题。内核静态分配了这样的一个 struct arraycache_init 的静态变量: static struct arraycache_init initarray_cache __initdata = { {0, BOOT_CPUCACHE_ENTRIES, 1, 0} }; 对照结构体的成员我们发现,struct array_cache 的 batchcount 被被赋值为 BOOT_CPUCACHE_ENTRIES ,这个 batchcount 是什么来头呢?这个变量就是控制着我们上面提到的“一部分内存”的具体量了。更重要的是,我们可以看到,struct arraycache_init 多出来的那个 void * 数组的个数,就是 BOOT_CPUCACHE_ENTRIES 。也就是说,struct array_cache 的管理结构头的 struct array_cache 里面的 entry 的真正空间就在这里了。 提到这里的话,那么初始化的第六步就可以彻底地理解了:为每个 kmalloc() 的管理结构头的 array_cache 重新调整 entry 的个数,具体的函数调用是 enable_cpucache()->do_tune_cpucache()->alloc_arraycache() 。 创建管理结构头—— kmem_cache_create()因为在初始化的过程中已经静态分配了管理结构头的管理结构头—— cache_cache,所以可以直接使用 kmem_cache_alloc() 给提供的对象建立其自己的管理结构头 struct kmem_cache ,而这个函数的作用也是如此。除了如此外,这个函数还有一个十分重要的功能就是创建 slab 的布局,即—— 应该占用页帧的阶数,slab 管理头应该在页内还是页外,剩余空间是多少,染色的个数,染色的大小(这两个说法在这里可能有点奇怪,不过等我们提到“染色”的时候就清楚了)等,具体的函数调用是 calculate_slab_order()->cache_estimate() ,过程比较简单,就不提了。 最后调用 enable_cpu_cache() 配置 struct array_cache 和 struct kmem_list3 ,这一步类似于我们在初始化那一节提到的第六步,也就不在重复了。 最后建立的管理结构头加入到 cache_chain 这个链表。 分配内存—— kmem_cache_alloc()kmem_cache_alloc() 这个函数进过多层的封装,最终调用的是 ____cache_alloc() ,kmalloc() 也是如此。我们先来看一下这个函数: static inline void * ____cache_alloc(struct kmem_cache *cachep, gfp_t flags){ void *objp; struct array_cache *ac; check_irq_off(); ac = cpu_cache_get(cachep); if (likely(ac->avail)) { STATS_INC_ALLOCHIT(cachep); ac->touched = 1; objp = ac->entry[--ac->avail]; } else { STATS_INC_ALLOCMISS(cachep); objp = cache_alloc_refill(cachep, flags); } kmemleak_erase(&ac->entry[ac->avail]); return objp;} 这里首先涉及到我当初一直困惑的一个点:在使用 kmem_cache_create() 建立相关的管理结构之后,究竟有没有分配真正的内存空间呢?在通读了相关的代码之后,我得到了答案:没有,也没必要。因为存在这么一种情况:如果使用 kmem_cache_create() 之后还分配了真正的内存空间之后,如果该 slab 一直不使用,那么岂不是浪费了很多宝贵的内存了吗? 解决上面的这一个疑惑之后,我们就可以知道在上面的代码中,第一次我们是走 else 那个分支了,也就是调用了 cache_alloc_refill() 来分配空间: static void *cache_alloc_refill(struct kmem_cache *cachep, gfp_t flags){ ...retry: ... while (batchcount > 0) { struct list_head *entry; struct slab *slabp; entry = l3->slabs_partial.next; if (entry == &l3->slabs_partial) { l3->free_touched = 1; entry = l3->slabs_free.next; if (entry == &l3->slabs_free) goto must_grow; } } 这里我再插一句:在 Linux 内核中的链表的实现是十分简洁优美的,它只有两个指针,并不存在数据域。这么做是为了通用性,即任何结构都可以组织自己的链表,然后在结构体中嵌入 struct list_head 即可。然后可能有人会问了,如果我有一个 struct list_head ,那么如何才能访问到该链表的起始结构呢?内核十分贴心的给我们准备了一个宏:container_of: #define container_of(ptr, type, member) ({ \ const typeof((type *)0->member)*__mptr = (ptr); \ (type *)((char *)__mptr - offsetof(type, member)) \ }) 简单来说,这个宏的作用就是:我有一个类型为 (type *)->member 的变量,想要得到包含该变量的 type 类型变量的地址。 所以在这里,如前面所说,这里 l3 的三个链表都是连接 struct slab 类型的,而这个类型就是内存空间的真正所在,也就是我们前面所说的 2^order 个物理页组成的 slab 。 在 kmem_cache_create() 的过程中,在分配 struct kmem_list3 的时候调用了 kmem_list3_init() (line 3844) 将 l3 的三个链表全都置为首尾相连的空链表,所以上面的函数在初次运行的时候最终将跳转到 must_grow 这个标签: must_grow: l3->free_objects -= ac->avail;alloc_done: spin_unlock(&l3->list_lock); if (unlikely(!ac->avail)) { int x; x = cache_grow(cachep, flag | THISNODE, node, NULL); ac = cpu_cache_get(cachep); if (!x && ac->avail) return NULL; if (!ac->avail) goto retry; } 很明显,函数将调用 cache_grow() 来进行真正的内存分配: static int cache_grow(struct kmem_cache *cachep, gfp_t flags, int nodeid, void *objp) { ... offset = l3->colour_next; l3->colour_next++; if (l3->colour_next >= cachep->colour) l3->colour = 0; offset *= cachep->colur_off; ...} 诶,到这里,总算可以说到前面铺垫了很久的所谓的“缓存着色”了,不过我觉得我得先简单地提一下 CPU cache 的工作原理才行: CPU cache 就是我们常看到的一级缓存,二级缓存,三级缓存啊,引入这些缓存是因为内存 RAM 的速度相较于 CPU 的速度而言,是在是太慢了,所以为了提高速度,所以 CPU 制造商提供了速度接近于 CPU 的小容量缓存,以便加速 CPU 与 RAM 的数据交换。具体的策略是: 每块 cache 会被分为更小的 cache line ,每个 cache line 的容量是一样的,然后 CPU 将虚拟地址分成三部分—— data, index, tag ,其中 data 长度是 cache line 的长度,index 的长度是 cache 的长度减去 data 的长度,最后 tag 的长度是虚拟地址的长度减去 data+index 的长度。举个例子,在 x86 的机器中,虚拟地址的地址空间是 32bit=2^32 ,假设我们的一级缓存有 4MB=2^22,cache line 的长度是 64bit=2^6 ,所以,data 就是 6 位,index 就是 (22-6) = 12 位,tag 就是 32-22=10 位。然后得到这些位之后,CPU 的每一个虚拟地址,将其分成上面的三部分之后,将按照 index 作为索引存入到 cache 中,然后在看需要的内容是否在 cache 中,这回比较需要的虚拟地址的 tag 与 cache 对应索引 index 的 tag 是否一致,如果一致说明 cache hit ,否则说明 cache miss 。 有点啰嗦,不过这些知识准备是必须的,然后我们就可以来具体阐述了。 我们在前面提到,slab 利用剩余的不足一个 object 的空间来进行缓存染色。具体说来,就是以平台的 cache line 的长度(存储在 cachep->colour_off)为偏移值(这一点非常重要!),计算出剩余的空间有多少个偏移值 cachep->colour ,然后就从 0 到 cachep->colour - 1(这个值是 l3->colour_next),每次就偏移 colour_next * colour_off 。这样,根据我们上面的叙述,每个 slab 将最终被放到不同的 cache line ,从而缓解了缓存过热的问题。 不过,如果你细心一点的话,你也可以发现,其实这个方法并不是特别的有效,因为它的有效范围只有 colour 个,也就是说,colour 个之后,还是会发生覆盖的问题,所以我在上面才用了缓解一词。 总之,上面的代码就计算了下一个偏移值 offset ,那么真正的偏移在那里呢?请看后面的代码: if (!objp) objp = kmem_getpages(cachep, local_flags, nodeid);if (!objp) goto failed;slabp = alloc_slabmgmt(cachep, objp, offset, local_flags & ~GFP_CONSTRAINT_MASK, nodeid); 真正的页分配就在这里了—— kmem_getpages() ,而真正的偏移就在 alloc_slabmgmt() 这个函数: static struct slab *alloc_slabmgmt(struct kmem_cache *cachep, void *objp, int colour_off, gfp_t local_flags, int nodeid){ struct slab *slabp; if (OFF_SLAB(cachep)) { ... } else { slabp = objp + colour_off; colour_off += cachep->slab_size; } slabp->inuse = 0; slabp->colouroff = colour_off; slabp->s_mem = objp + colour_off; ...} 诶,这里就可以很明显的看出来了整个 slab 的布局了:在连续页 objp 的起始,先是 colour_off 的偏移,然后是 slab 的一些管理头(管理头的大小是 cachep->slab_size),最后就是 object 的起始地址 slab->s_mem 了。 接下来的具体工作就是设置 slab 的 bufctl 控制数组,这里简单地提一下:slab 控制头有一个成员是 slabp->free ,意义是当前可用的 object 的索引,而 bufctl 控制的则是当前可用的下一个 object 的索引。 然后,新建立的 slab 加入到 l3 的 slabs_free 链表(这很重要!)。然后 cache_grow() 函数结束,返回 cache_alloc_refill() ,注意,此时我们只是分配了一个新的 slab ,还没有分配出去。具体就是在 goto retry 重新回到 cache_alloc_refill() 那里重新分配,因为此时我们的 slabs_free 已经不是空的了,所以函数接下来将 batchcount 个 object 移到 array_cache 中,然后修改 slab 的 bufctl 数组。最后,看 slab 是否所有 object 都分配完了,如果是,则移到 l3->slabs_full,否则则移到 l3->slabs_partial 。 然后,在后续的操作中,如果 array_cache 中有空间,则从其直接分配,否则就看 slabs_partial 或者是 slabs_free 是否有足够的 object ,在不然,就再次重复上面分配 slab 的操作了。 释放内存—— kmem_cache_free()有了上面已经十分详细的阐述之后,释放内存和后面的销毁就显得简单许多了,就是从 array_cache 移回 slab 并且修改 bufctl 控制数组而已。就不赘述了。 销毁内存—— kmem_cache_destroy()同样不赘述了。 好了,整个 slab 分配器我大概就简单地说到这里。下面我说一下我自己的看法。我们可以发现,slab 为了加快分配速度,使用了很多的管理结构,其中花销最大的就是那个 bufctl 数组,所以如果是分配小的 object 的话,那么这个 bufctl 数组占用的空间还是相当可观的。这也是它的一个主要的缺点。而后来的 slub ,就是针对这个缺点进行了有效的改进,而这,我在后面的博客中,将会详细讲解。 EOF]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
<tag>内存管理</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Linux Kernel 内存管理]]></title>
<url>%2Flinux-kernel-memory-management%2F</url>
<content type="text"><![CDATA[曾经我认为我学完能够改变世界 自学操作系统一年多以来,遇到的最大的坎就是内存管理。最近捧着《深入Linux内核架构》还有《Understand the Linux Kernel》这两本大块头的书死命地吭,然后联系之前的已学的内容以及模糊的地方,总算是有点清晰的感觉了,赶紧记下来。 各种地址内存管理一开始让人很困惑的一点就是有很多以“地址”结尾的术语,让人有点摸不着北,所以我想根据自己的理解总结一下让人困惑的“各种地址”: 逻辑地址(Logical Address)为了保证向后的兼容性,Intel 根据 8086 在 16bit 时代的寻址模式——段加偏移,设计出了分段模式,所有的段地址都保存在了 GDT 里(当然还有 LDT 和 IDT 等,不再详述),偏移地址则一般在通用寄存器,在开启了保护模式之后,由于所有的通用寄存器的字长都是 32 bit,所以可以寻址的地址区间增加到了 4G 。这里的段地址是 16bit,偏移地址是 32bit,所以最终这两个地址共 48bit 组成了逻辑地址。 虚拟地址/线性地址 (Virtual Address/Linear Address)看了很多资料,既有说这两者是一样的,又有说这两者有些许的区别。不管,我个人是倾向于前者的。要理解这两个概念,我们得先来理解另外一个有点类似的概念:地址空间和线性地址空间(Address space/Linear Address)。关于这个概念,CSAPP 书中的解释十分的精彩,这里让我来重新表述一下: 地址空间,就是一个集合,集合的成员是说能够寻址的地址。如果成员是呈线性增长的,那么就成为线性地址空间。 有了这个定义之后,我们以后遇到类似的说法就不怕不怕啦。例如后文会出现的内核线性地址空间(Kernel linear address space) 我们就可以清楚地知道它代表的就是内核说能够寻址的范围。 在理解了上面的两个重要概念之后,虚拟地址与线性地址的含义也应该很清楚了。一般而言,在 32bit 的平台里,虚拟地址/线性地址也是 32bit 长的,同样的 64bit 的也是如此。 然后再说一下,上面的逻辑地址的两个部分直接相加起来(没错,跟 8086 的先偏移在相加是不一样的)就是虚拟地址/线性地址。 物理地址(Physical Address)物理地址就是机器本身 RAM 的真实地址。也是我们寻址的最终意图。这里有一点要提一下,物理地址空间跟线性地址空间是没有关心的,如果你的机器是 32bit 的,那么无论你的 RAM 是多大,线性地址空间都是 0 ~ 0xffffffff 。 分段与分页这两种模式其实都是为了满足日益增长的内存容量而提出的寻址模式。不过现在分页模式已经可以很好地满足需要了,但是为了保证兼容性,IA 平台还是保留了分段机制。不过,为了不与分页机制相冲突,Intel 提出了平坦内存模式(flat mode),具体做法是将段地址全部置为零。不过这里有一个问题,为了保证内核线性地址都是在 3GB 以上(关于这个我下面会详细说明),在编译链接的时候,所有的符号都加上 3GB 的偏移(具体可以看一下内核的编译脚本 vmlinux.lds.S)。但是这就出现问题了:在开启分页模式之前,线性地址 = 物理地址,即段地址 + 偏移地址 = 物理地址,但是我们提到,在平坦模式下,段地址都是 0 ,所以这里偏移地址都是物理地址,从而导致了最终的物理地址都大了 3GB ,所以内核在未开启分页模式前,为了保证正确寻址,使用了一个宏 pa 来减去这多出来的 3GB。 然后,开启了分页模式之后,便可以放心的使用虚拟地址了。在分页机制中使用页表来管理虚拟地址,一般页表大小为 4KB (也有 4MB 的大页表,不过这里不讨论),这样问题就来了,如果 RAM 有 4GB 的话,那么将需要一百万个页表,这样就造成了巨大的空间浪费。所以现行采用的是多级页表的方式。在 32bit 一般是 2 个页表,一个称作页目录(Page Directory),一个称作页表(Page Table Entry);64 bit则是 4 个,其中三个页目录(Page Global Directory,简称 PGD,Page Upper Directory/PUD,Page Middle Directory/PMD),一个页表。多级页表和单个页表其实可以用乘法和加法来类比,前者相当于 1024x1024,后者则是 1048676 个 1 相加,这样前者只需 1024x2 = 2048 个页表,后者则需要 1048576 个页表! 然后,一个虚拟地址就被分割成 n + 1 段了,n 是页表的个数,最后的那个 1 是偏移(offset )。这样,各个页表的内容都指向了下一个页表的地址,当然,页表项指向的是物理页。哦,前面忘记说了,物理页我们成为页帧(frame )。]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
<tag>内存管理</tag>
</tags>
</entry>
<entry>
<title><![CDATA[初试 Linux 内核编译]]></title>
<url>%2Fhow-to-compile-a-linux-kernel%2F</url>
<content type="text"><![CDATA[曾经我为了编译内核买了一台二手台式机 这两天在试着编译一下 Linux 内核,过程十分的刺激,随手记下来: 首先去 官方网站 下载最新版的 Linux 内核,然后解压,进入到内核目录里 下面是整个过程中最复杂的一步了:配置内核。想要让内核依照你的电脑配置发挥出最大的功效,那么就必须为为你的电脑量身定做一套配置。然而,看那配置列表一万多个配置选项真的是会被吓到的。总不能一个一个选吧,而且大部分选项根本就看不懂。所以我使用了最保险的做法,去 Ubuntu 官网下载一个配置文件,保存在内核顶层目录,重命名为 .config ,然后使用: make oldconfig 来选择那些配置文件中没有的选项。这样,一份 “勉强能用” 的配置文件总算是有了。 接下来的事情就简单多了,先是使用 make 进行编译工作。我的电脑比较老,所以整个过程持续了三个多小时。。。然后,安装模块: make modules_install 最后,完成最后的安装,使用: make install 当然我知道这种方式安装内核太自动化了,无法了解到其内部的整个工作流程,学不到什么东西的。不过,我现在的目标是,先让内核能够跑起来,然后在接下去深入! 好了差不多就是这样了,将来等学习深入了,再写一篇详细原理的。 EOF]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
</tags>
</entry>
<entry>
<title><![CDATA[正则表达式 \b 和 \B 辨析]]></title>
<url>%2Fhow-to-understand-b-and-b-in-regular-expression%2F</url>
<content type="text"><![CDATA[正则表达式的一个小点 我想很多初学正则表达式的人都会跟我一样,一开始在接触到 \b 的时候,由于一些例子错误引导,可能会认为跟正则表达式的另一个元字符 ^ 是一样的,都是匹配单词开头的,其实不然,不信,我们来举个例子。 如果正文的内容是这样的: -simowce go: Hello, steve jobs-steve jobs: Hey, simowce. Hope you can change the world 接下来我们要匹配句子开头的 -simowce ,我们使用 grep 来进行匹配,输入如下的命令: grep "\b-simowce"结果是什么?你可能会很惊奇,什么都没有输出!这说明了 \b 和 ^ 是有根本性的区别的,下面我来详解一下: \b 里面 b 代表着 word boundry ,那么什么可以被成为 word boundry 呢,有以下三种: 字符串的首字符,如果该首字符属于 word character 字符串的尾字符,如果该尾字符属于 word character 字符串的中间两个字符,其中一个属于 word character,另一个不属于 word character 说明一下,一般而言 word character 就相当于 \w ,即 [0-9A-Za-z_] ,不过似乎有一些还有拓展。 还有,\b 匹配的是位置,所以它是不占用长度的。这一点很重要! 有了这一些说明了之后,我们就 可以解释上面的奇异现象了! 关键点就在于,- 不属于 word character 所以即使 - 是字符串的首字符,\b 还是会把它跳掉。 至于 \B,就是与 \b 相反,即 \b 不能匹配的地方 \B 就能匹配。 我们来看一个例子,正文的内容如下: Please enter the nine-digit id as itappears on your color - code pass-key 然后我们的命令是: grep "\B-\B"你可以猜一下结果是什么,没错,只有color - code 中的那个 - 匹配到了。 原因就是,当正则表达式引擎匹配到 color - code 的那个 - 的时候,此时 - 前面是一个空格,属于 word character ,而 - 不属于 word character ,所以符合了 \B 的要求,又因为 \B 不占用长度,所以此时是用 color - code 去匹配 \B 后面的 - 的 ,然后后面的那一个 \B 一样的道理,就不赘述了。 至此,说明完毕! EOF]]></content>
<categories>
<category>代码之外</category>
</categories>
<tags>
<tag>正则表达式</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Linux 0.12 备忘录 -- 第八章]]></title>
<url>%2Flinux-kernel-012-reminder-sys%2F</url>
<content type="text"><![CDATA[Linux Kernel 0.12 的进程与信号 比较仔细地看完了这一部分的内容,收获还是蛮大的,知道了很多不知道的知识,理解了很多已经知道的知识,例如:为什么 fork 子进程的时候会有多个输出? 信号究竟是什么? 等等。下面我主要说一说几个方面:系统调用 system_call,signal 机制以及其原理,进程调度函数 schedule, 退出函数 exit,等待子进程结束函数 wait_pid , 增加子进程函数 fork ,这一些在平常的系统编程里面都是非常常用的东西,理解其实现过程能够更加清楚地明白自己究竟在干什么~ system_call首先要说明一点,系统调用其实就是通过 int 0x80 中断实现的,eax 存储的是功能号。而且,我们用户进程是以特权级 3 中运行,int 0x80 中断的特权级是 0 ,所以调用会产生特权级转换,堆栈会切换,并且将当前的 ss esp eflags cs eip 等寄存器的值依次压入进程的内核栈 (关于内核栈和用户栈的区别我后边会说到 :-) )。然后就是将所有通用寄存器都压入栈中,设置 ds 和 es 分别指向内核段,fs 指向本地段,然后调用相关的系统调用函数,完成后将返回值 (放在 eax 寄存器)也压入栈。接下来 line 101 ~ line 106 由于还没有读到相关方面的内容,所以暂时不理解,先留个坑。 ** 接下来主要就是处理进程的信号了。这里处理的是进程信号位图已被标记并且没有被屏蔽的信号。找到第一个符合条件的信号,调用 do_signal 进行 处理。这里要说明一下的是,在获取了信号位图号之后(放在 ecx),之所以要 +1 ,是因为在 Linux 实际的信号值定义都是从 1 开始的,信号位图(就是一个 int 变量)是从 0 开始的。之后,判断是否需要继续处理信号还是结束系统调用。具体我们放到下面具体讲,然后恢复之前保存的寄存器的值,恢复。注意,最后的一句 iret 实际上不是回到调用系统调用的进程,而是进行了一些有趣的事。** 具体我们下面讲。 从上面的描述中我们可以了解到一些什么呢?那就是系统是在什么时候处理信号的。答案就是在进行系统调用,或者是在进程切换(_timer_interrupt 最后会跳转到 ret_from_sys_call )的时候。 signal通过这个部分,我们可以了解到很多原理的东西,以及之前的很多问号:“ ×× 为什么是这样,为什么要这样?” 为了说明这一点,我将通过我们之前在 signal 编程一些说法或者是一些限制要求来进行详细说明: 1. 使用 signal 函数或者 sigaction 函数进行信号处理句柄的修改,不过,signal 函数的修改是一次性的,sigaction 函数是永久的 首先说明一下,信号处理句柄,或者说 handler 的修改,其实是系统进程结构体 struct task_struct 里面的一个成员 sigaction ,这个成员是一个 struct sigaction 数组,数组总共有 32 项,代表了 32 个信号。signal 函数或者是 sigaction 函数就是修改这个数组里面对应项的来修改信号处理句柄的~ 然后,之所以 signal 函数是临时赋值,sigaction 是永久赋值,是因为 signal 在对 sigaction 数组进行赋值的时候,打上了 SA_ONESHOT 的标志(signal.c line 93),而 sigaction 函数没有,在真正处理信号的 do_signal 函数中,如果发现了 SA_ONESHOT 的标志,则将信号处理句柄清空。 2. 信号处理句柄是在什么时候执行的? 要解决这个问题,我们就必须知道整个信号从发出到执行的全过程,简单地说明下: 用户进程调用 signal 函数或者是 sigaction 函数对相应的信号进行捕获(即处理句柄的赋值,不过 SIGKILL 和 SIGSTOP 不可捕获!!!);对当前进程发送一个信号,被进程捕获到,加入到其进程结构的信号位图中;当进程调用系统调用或者是或者是发生进程切换的时候,找到第一个接收到且没有被屏蔽的信号值,进入 do_signal 函数进行处理;do_signal 的主要功能是:如果要处理的信号的处理句柄是 SIG_IGN (忽略该信号),直接返回 1 ;如果处理句柄是 SIG_DEF (默认处理) ,则在进行相应的处理之后(其中有一些默认处理需要我们的注意,具体我们放到 wait_pid 函数那里细讲),也返回 1;这两个都会导致在 ret_from_call 继续处理剩余的信号。否则,检查是否有 SA_ONESHOT 的标志(即是否是 signal 函数处理的信号句柄),如果有则清空。接下来,就是最重要的,也是最精彩的部分了。函数在一开始就把原来压入内核中的 eip 另存为 old_eip ,然后把 eip 赋值为相应信号的处理句柄,之所以这么做是为了在 ret_from_sys_call 返回了之后,不是恢复到原来的进程,而是去执行信号处理句柄。然后将保存的用户栈顶指针上移(这里的上移对应这栈顶指针的减少,因为栈是逆增长的~),手动将一些要恢复用户进程的值压入用户栈中,这里重点注意的是压入后用户栈的栈顶元素是一个叫做 sa_restorer 的系统库函数,这个函数在信号处理句柄结束后被调用。就是这一个函数,最终恢复用户进程。然后,do_signal 函数返回 0 ,ret_from_sys_call 结束,恢复之前压入内核栈的通用寄存器的值。注意,最后的 iret 中恢复的 eip 实际上以及被 do_signal 修改成信号处理句柄的地址了。于是,从这里结束后,接着运行的就是信号处理句柄了。再次注意,在执行信号处理句柄的时候已经是用户态了,堆栈也都是用户栈了。句柄结束后,由于 ret 会将用户栈顶的值作为接下来的执行值,而我们在上面说过,用户栈已经被我们手动压入了一些值,而且栈顶元素就是 sa_restorer 。所以,接下来运行的就是 sa_restorer 函数。而这个函数实际上就是将我们手动压入的一些值进行恢复,完完全全地恢复成原来的用户进程,old_eip 就是在这里被恢复的。 至此,比较完整地描述了一遍信号处理的这一个过程。具体请看书中代码!!! schedule对于这个函数,具体的调度算法细节我就不细说了,因为这个算法本身并不高明,而且在 Linux 内核 2.6 以后这个东东已经被替换成了 CFS 了,这也是我以后要钻研的东东!这里我想把重点放在这个函数最后的 switch_to 这个宏。这也是进程切换的一个重点所在。他的具体内容如下: #define switch_to(n) {\struct {long a,b;} __tmp; \__asm__("cmpl %%ecx,_current\n\t" \ "je 1f\n\t" \ "movw %%dx,%1\n\t" \ "xchgl %%ecx,_current\n\t" \ "ljmp %0\n\t" \ "cmpl %%ecx,_last_task_used_math\n\t" \ "jne 1f\n\t" \ "clts\n" \ "1:" \ ::"m" (*&__tmp.a),"m" (*&__tmp.b), \ "d" (_TSS(n)),"c" ((long) task[n])); \} 具体就是将要跳转的进程数组下标(注意进程数组下标和进程号的区别,后面会提到这一点)对应的 TSS 赋给一个临时结构体的成员 b 的低 16 位(其他的无用),然后 ljmp 跳转到这个 TSS 。可别小看这个跳转,其实在背后做了很多的工作,(具体请看书中第四章),例如将当前的通用寄存器等硬环境保存到当前进程的 TSS 中,然后切换到指定的 TSS ,并且将该 TSS 中的保存的内容赋值到相应的位置,实现任务的切换。不过这个任务切换的全过程确实值得说道说道: 时钟中断发生,调用处理该中断的程序 _timer_interrupt 并且最终调用 do_timer 里面的 schedule 函数 ,schedule 函数 在选择了下一个要执行的进程 b 之后,调用 switch_to 宏 ,在执行完 ljmp %0\n\t 这一句之后,将当前进程 a 的硬环境保存在当前进程的 TSS (利用 TR 寄存器,因为此时 TR 寄存器的内容还是进程 a 的)中并且将 b 的 TSS 的内容恢复到相应的位置中,实现进程切换。当下一次使用 switch_to 宏 恢复到原来的进程 a 的时候,在 ljmp %0\n\t 这一句执行完了之后,原来的进程 a 恢复,继续执行的是 ljmp %0\n\t 的下一句。然后必须要记住的是 switch_to 是一个宏,编译的时候是直接展开在原来的位置的,所以不需要 ret ,所以,当 switch_to 执行完毕了之后,schedule 函数 也执行完毕,回到进程 a 在时钟中断发生时接下去的代码继续运行。 这就是这个进程切换的全过程,相当地清晰! exit这部分的内容的难点在于对于孤儿进程组的理解与处理。下面说说我的理解: 对于孤儿进程组的判断使用的是 is_orphaned_pgrp 这个函数,在这个函数里面,给出了一个不是孤儿进程组的条件: ((*p)->p_pptr->pgrp != pgrp) && ((*p)->p_pptr->session == (*p)->session) 其实这还是比较好理解,如果该进程组里面的所有进程的父进程都是该进程组的,那说明整个进程组与外界就没有联系了,所以肯定就是孤儿进程组了。 然后,do_exit 这个函数里面有一个比较难以理解的就是:如何判断要退出的进程是该进程组与外界的唯一联系? 其实只要注意到几个点就可以比较清晰地理解了。首先注意到在 line 281 已经将当前进程的状态改成了 TASK_ZOMBIE 僵死状态了,而在 line 291 还有 line 292 这两句: if ((current->p_pptr->pgrp != current->pgrp) && (curent->p_pptr->session == current->session) && ..... ) 这两句代码就是在判断当前进程不是孤儿进程。然后接下来调用 is_orphaned_pgrp 函数 来判断当前进程所在的进程的组是否为孤儿进程组。注意,在 is_orphaned_pgrp 这个函数中,如果扫描到的进程的状态是 TASK_ZOMBIE 僵死状态是直接跳过的,而当前进程的状态恰好就是 TASK_ZOMBIE ,所以,如果此时当前进程所在的进程组依然将是孤儿进程组,那么说明当前进程就是与外界唯一的联系。 然后呢,如果判断当前进程所在进程组将成为孤儿进程组,那么将向整个进程组发送 SIGHUP 还有 SIGCONT 这两个信号,具体为什么还值得深究,暂时就理解成是 POSIX 的规定~ 接下来,给当前进程的父进程发送 SIGCHILD 这个信号,告诉父进程我已经退出了。请注意,这一点很重要,这个在后面的 wait_pid 函数中理解的一个关键点。 最后,让 init 进程成为当前进程子进程的新父亲。同时这里出现了第二个判断孤儿进程组的 case 2,。有了上面的基础,理解这一个就比较简单了,我们只需要知道一点就是:在 case 2 中,当前进程相当于 case 1 中当前进程的父进程,即当前进程的某一个子进程是其所在进程组与外界的唯一联系,理解了这一点,就能够很好地理解 case 2 了。 最后的最后,就是连接 init 进程的子进程的双向链表还有当前进程的子进程的双向链表~ waitpid这个函数主要是将当前进程挂起,并且根据 pid 找到符合条件的子进程, fork如果你有过 Linux 系统编程的经验,你就会知道,在使用 fork() 创建新进程的时候,要根据 fork 的返回值来判断是当前进程还是新的子进程。通过这个 fork 函数源码的学习,我们就可以清晰的了解为什么是这样的~ 首先我们得先说一下前面提到过的 进程号和进程结构数组下标的不同: 函数 find_empty_process 就是为新进程取得新进程号,并且返回数组项的,从这个函数我们可以了解到,进程号其实就是一个 int 数,而在 Linux 0.12 中,进程结构数组的大小 —— NR_TASKS —— 其实只有 64 个。所以我们可以得出这样的结论,至少在 Linux 0.12 ,进程号是一个递增的数,而数组号是可以回滚的~ 然后我们来解释 “双返回” : 首先我们在实际编程的时候一般都是这么写的: int pid = fork();.... 这里的 fork 其实是一个系统调用,最终运行的是 copy_process 函数,这个函数就是为我们的新进程准备各种硬环境,其中包括 TSS 的各项赋值,其中就有两句是重点: p->tss.eip = eip;....p->tss.eax = 0;.... 所以,新进程的开始就是父进程接下去要运行的语句,即 fork 函数返回值的赋值,而 fork 函数的返回值是放在 eax 寄存器的,所以父进程 fork 函数的返回值是子进程的进程号,而子进程的返回值是 0 !而双返回的原因也就在这里了~]]></content>
<categories>
<category>Kernel Magic</category>
</categories>
<tags>
<tag>Linux Kernel</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Thinking in Data Structure -- 红黑树(思考)]]></title>
<url>%2Fthinking-in-data-structure----red-black-treethinking%2F</url>
<content type="text"><![CDATA[《算法导论》中的红黑树 红黑树,是二叉搜索树的一种。跟 AVL树 都属于平衡二叉树。我们将一棵树定义为红黑树当且仅当其满足下面的性质: 树的节点要么是黑的,要么是红的 树的根节点是黑的 树的叶节点是黑的 如果一个节点的颜色是红的,那么它的两个儿子的颜色都是黑的 对于每一个节点,从该节点(不包含该节点本身)到其所有后代叶节点的简单路径,均包含相同数目的黑色节点。该数目称为 黑高。 需要注意一点的是,与普通的树不同,红黑树的叶节点不是出度为零的节点,而是空节点的另一个说法。或者说,如果一个节点的某一个子节点为空,那么该子节点就是一个叶节点。 在上面的 5 个性质中,第 5 点是保证红黑树的平衡性的关键。这里涉及到一些数学上的证明,《算法导论》里面有,我会在别的文章中进行适当的说明,这里先跳过。 然后是红黑树的一系列操作,查询的我就不说了,跟二叉搜索树是一模一样的,这里重点说一下更新操作 – 插入和删除 插入首先,插入的第一步是找到要插入的位置,这一点跟二叉搜索树是一样的。但是接下来就有问题了,红黑树的节点都是有颜色的,那么新的节点的颜色应该是什么呢?然后,插入新节点之后,上面的 5 个性质还能保持吗? 首先,新节点的颜色我们选择红色,因为从性质 5 我们知道,每一个节点的所有黑高都必须一样,而红节点并不影响黑高,所以选择红色将不会改变性质 5 。更重要的是,一旦某个节点的性质 5 被改变,那么所有包含该节点的性质 5 都将不符合,这样到来的代价将是巨大的。 但是,选择红色也会出现问题,那就是新节点的父节点如果是红色的,那么将违反性质 4 ,怎么办呢?所以,插入之后,我们必须进行调整,而插入的重点也就在这了。我们来看一下 《算法导论》 的伪代码(这是我自己写的,形式采用了类 C 的写法,不过思想是一样的): RB-Insert-FIX(T, x)while x.p.color == RED if x.p.p.left == x.p y = x.p.p.right if y.color == RED //case 1 x.p.color = y.color = BLACK x.p.p.color = RED x = x.p.p else if x = x.p.right //case 2 x = x.p LEFT_ROTATE(T, x) x.p.color = BLACK //case 3 x.p.p.color = RED RIGHT_ROTATE(T, x.p) else //(将上面的 left 和 right 互换即可) T.root.color = BLACK 从上面的伪代码我们可以看到,插入分为三种情形,这里我们将 y 称为 x 的叔节点,所以这三种情况可以这样分: x 的叔节点是红色的 x 的叔节点是黑色的且 x 是父节点的右节点 x 的叔节点是黑色的且 x 是父节点的左节点 值得说明的是,我们在 RB-Insert-FIX 执行的过程中,x.p.color 一直都是红色的,这也是 RB-Insert-FIX 的循环条件,为什么是这样的呢?因为如果 x.p.color 是黑色的,那么根据我们前面提到的,插入的时候性质 5 是一直保持的,而性质 1,2,3 也是一直都保持的,唯一有可能违反的就是性质 4 了。如果 x.p.color 是黑的,那么性质 4 也将符合,那么说明整棵树满足性质 1-5,已经是一棵红黑树了。 然后我们来分析一下这三个 case: case 1: x 的叔节点和父节点都是红的,注意此时并不关心 x 是父节点的左儿子还是右儿子,选择的策略是将 x 的红色上移,具体做法是,将 x 的叔节点和父节点都赋值为黑色,x 的爷爷节点赋值为红色,并将 x 重新指向为 x 的爷爷节点。同时,现在的 x 节点的父节点也有可能是红的,循环将继续 case 2: x 的叔节点是黑的且 x 是父节点的右儿子,这个 case 是为了将其转化为 case 3,然后统一处理。 case 3: x 的叔节点是黑的且 x 是父节点的左儿子,这个 case 跟上面 case 2 的区别是,case 2 中 x,x.p,x.p.p 的排列是弯曲 zig-zag 型的,而 case 3 则是直线 zig-zig 型的。 具体操作就在那里,多看即便就可以理解,必须记住的是无论进行什么操作都是为了恢复被破坏的性质。 删除相比插入而言,删除就难多了,不过也是个人觉得红黑树最出彩的地方了,进过一系列的操作之后,then it works! 确实不得不佩服前人的智慧! 红黑树的删除跟二叉搜索树的删除是差不多的,都是先找到该点所在的位置,然后根据其儿子的个数进行相应的变换。只不过多了一个恢复红黑性质的操作。 接下来,我想根据我看《算法导论》是遇到的问题以及我是如何解决这些问题的来说明删除这一操作。 首先,我们来看一下删除的伪代码: RB-Delete(T, z)y = zy-initial-color = y.colorif z.left == T.nil x = z.right RB-TRANSPLANT(T, z, z.right)else if z.right == T.nil x = z.left RB-TRANSPLANT(T, z, z.left)else y = TREE-MINIMUN(z.right) y-initial-color = y.color x = y.right if y.p = z x.p = y else RB-TRANSPLANT(T, y, y.right) y.right = z.right y.right.p = y RB-TRANSPLANT(T, z, y) y.left = z.left y.left.p = y y.color = z.color if y-initial-color == BLACK RB-DELETE-FIX(T, x) 这里要注意的是,x 这个变量的含义是: 如果 y 的某个儿子非空,那么 x 总是指向 y 非空的儿子,并且在 RB-TRANSPLANT 执行之后指向 y 原来的位置。之所以是这样我们可以注意到 RB-TRANSPLANT 这个函数的第三个参数总是跟 x 相等的。否则,y 的两个儿子皆为空,那么 x 指向 y 的右儿子,并且在 RB-TRANSPLANT 执行之后指向 y 原来的位置,原因跟上面的一样。 同时,要注意到 x.p 的赋值,当 y.p != z 时,x.p 总是指向 y 的父节点,而且该过程是在 RB-TRANSPLANT 的最后一句实现的。当 y.p == z 时,x.p 将赋给 y。为什么是这样呢?这是因为当 z 有两个儿子的时候且 y.p != z 时,y 就是 z 的后继(跟二叉搜索树是一样的),此时需要进行的操作是:x 代替 y,y 接手 z 的右儿子,y 代替 z,y 接手 z 的左儿子,所以此时 x.p 将指向 y 的父节点。但是,如果 y.p == z 的话,只需要将 y 直接替代 z 并且接手 z 的左儿子,y 的右儿子(即 x)依旧保持不变,所以 x.p 将指向 y 。 还有要注意,这里的删除相当于一种 lasy delete,当 y == z 时,只是将 z 的父节点指向 z 的非空儿子而已;当 y != z 时,它只是将 y 的值赋给 z 的值,z 的颜色将保持不变,这一点很重要! 最后,注意到调用 RB-DELETE-FIX 的条件是:y-initial-color == BLACK 。为什么是这样呢?因为如果 y 是红色的,删除它并不影响黑高。要阐述这一点,得分情况讨论: y == z. 这种情况是在 z 的儿子数小于 2 的时候。又因为 z 的某一个儿子为空,所以左右黑高都为 1,且由于 z == y 为红节点,所以只可能是左右都为空,此时 x 为叶节点,替代 y 后,黑高仍为 1 。 y != z. 这种情况发生在 z 的儿子树为 2 且 z.right == T.nil 的时候。前面提到,这种情况下的删除只是将 y 的值赋给 z 而已,z 的颜色并没有改变,所以这种情况下相当于在树中删掉一个红节点,而且,因为这种情况下 y 的左儿子必为空(可分为两种情况讨论,y.p == z 或者 y 是 z 的后继),所以黑高必为 1 。删掉该红节点之后,因为 T.nil 的黑高也为 1,所以性质 5 没有违背,不需要修复! 接下来,就是删除的重点——RB-DELETE-FIX 了,我们先来看一下伪代码: RB-DELETE-FIX(T, x)while x != T.root && x.color == BLACK if x == x.p.left w = x.p.right if w.color == RED //case 1 w.color = BLACK x.p.color = RED LEFT-ROTATE(T, x.p) w = x.p.right else if w.left.color == BLACK && w.right.color == BLACK // case 2 w.color = RED x = x.p else if w.right == BLACK // case 3 w.left.color = BLACK w.color = RED RIGHT_ROTATE(T, w) w = x.p.right w.color = x.p.color // case 4 x.p.color = x.right.color = BLACK LEFT_ROTATE(T, x.p) x = T.root else //(将上面的 left 和 right 调换即可)x.color = BLACK 在这个过程中,有几个点要注意一下: RB-DELETE-FIX 的循环条件是:x != T.root && x.color == BLACK,这里解释一下第二个条件。我们之所以需要这一个函数来修复红黑性,是因为删除掉一个黑节点将会导致部分子树的黑高减少,而 x 代替的是 y 原来的位置,所以如果 x.color == RED 的话,那么只需将 x.color 赋值成 BLACK 即可恢复性质 5 了。 《算法导论》中提到只有 case 2 将会导致循环,我自己试了一下,发现确实是这样的,而且如果红黑树是一个所有节点都为黑色的满二叉树,那么 case 2 将会一直循环到根节点为止。 有一个点要特别注意,就是 case 4 的第一句:w.color = x.p.color 。为什么要这样呢?那是因为 x.p.color 的值不知道,而且这步操作之后是要将原子树变成以 w 为根的新子树,而我们知道原子树的父节点以及其祖宗都是满足所有性质的,所以,如果 w 保持 x.p 原来的颜色而且满足了性质 5 ,那么,这棵树就满足红黑性了! 最后,还是那一句。上面的所有操作都是为了让子树恢复红黑性,所以,主要是理解为主。虽然,我也为了理解每一步究竟为什么要这要做思考了很多,也有所收获,但是最后发现,这个东西真的只能理解,写是写不出来的,或者说,无法十分严谨地写出来,所以便作罢了。 好了,写得够多了。再一次说明,上面的所有内容都是我自己的思考,可能会有错,不过我会一直维护,希望能够为大家学习红黑树这个有用的数据结构带来一点帮助,那我也就心满意足了~ EOF]]></content>
<categories>
<category>代码之美</category>
</categories>
<tags>
<tag>数据结构</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Thinking in Data Structure -- Binary Tree]]></title>
<url>%2Fall-about-binary-tree%2F</url>
<content type="text"><![CDATA[面试常见问题 二叉树是树结构中比较容易实现的一种,有关它的操作也有很多,在接下来的几天,我将陆续更新,先写个目录吧: 已知二叉树的前序和中序,要求重新构造出该二叉树 我们知道,前序遍历是所谓的 “根左右”,中序遍历是所谓的 “左根右”。注意,重点来了,中序遍历的根节点将整棵二叉树分成了左子树和右子树,而前序遍历因为总是先访问根节点,所以可以在前序遍历的第一个节点肯定是根节点,然后在根据这个根节点找到中序遍历的两棵子树,然后以此类推。即可以使用递归。 说到递归,确实是一个神奇的东西,它极大地降低了编码的难度,同时也增大了阅读的难度。不过要写出一个好的递归算法还是需要一定的技巧的,一般我是这么考虑递归的: 考虑递归是否需要返回值 考虑递归结束的条件,这一个非常的重要 考虑子问题,这里要注意的是如果是有返回值的递归要对每一种情况都进行返回的 这样说比较虚,我们具体来看这道题: 既然是要重新构造二叉树,说明是从无到有的,所以要么在递归函数的参数中加入一个节点参数,要么选择有返回值的递归函数,我倾向于第二种,因为如果是引入节点参数的话,必须使用二级指针,(关于二级指针的内容我接下来将会写一篇使用底层汇编来解释二级指针的文章,尽请期待),比较麻烦,而且多加一个参数的话如果递归深度过高将会大大增加暴栈的可能性。 递归结束条件,很明显,如果根节点没有左子树和右子树,那么就可以将该节点直接返回。 子问题,我们可以将树的左子树和右子树看成一棵新的树,然后新的树也将有根节点,左子树和右子树,于是树的长度变小,直至为 1 好了,其实这个问题真的不难,说了这么多只是想把自己的思考过程写出来而已,下面看代码,自己感觉写的还是挺优美的~ #include "binary_tree.h"#include <stdlib.h>#include <string.h>#define N 100b_tree recover(char *preorder, char *inorder, int len){ b_tree node = (b_tree)malloc(sizeof(bin_tree)); int i; int root_pos; node->data = preorder[0]; node->lchild = node->rchild = NULL; if (len == 1) { return node; } for (i = 0; i < len; i++) { if (inorder[i] == preorder[0]) { root_pos = i; break; } } if (root_pos == 0) { node->rchild = recover(preorder+1, inorder+1, len-1); } else if (root_pos == len-1) { node->lchild = recover(preorder+1, inorder, len-1); } else { node->lchild = recover(preorder+1, inorder, root_pos); node->rchild = recover(preorder+root_pos+1, inorder+root_pos+1, len-root_pos-1); } return node;}int main(void){ b_tree root; char preorder[N] = {0}; char inorder[N] = {0}; int len; printf("Please enter the preorder: "); scanf("%s", preorder); printf("Please enter the inorder: "); scanf("%s", inorder); len = strlen(preorder); root = recover(preorder, inorder, len); preorder_recur(root); putchar(10); inorder_recur(root); putchar(10); return 0;}]]></content>
<categories>
<category>代码之美</category>
</categories>
<tags>
<tag>数据结构</tag>
</tags>
</entry>
<entry>
<title><![CDATA[动态链接库 .so 的编译和使用]]></title>
<url>%2Fcompile-code-to-so-library%2F</url>
<content type="text"><![CDATA[没啥想说的 今天写代码的时候,发现需要之前的一些实现,有不想直接将代码复制过来,于是就想把之前实现的代码编译成一个动态链接库,这样显得更加的优雅。几番摸索之后,终有所获,遂记之~ 首先,我们约定要编译的文件名是 code.c ,编译后的动态库名是 libcode.so (注意,共享库的命名一般都是 libxxx 的),目标文件是 target.c 。于是,编译命令如下: gcc -fPIC -shared libcode.so code.c 这里解释一下: -fPIC: 这个选项告诉编译器,产生与位置无关的代码(Position-Independent-Code)。也就是说,产生的代码中,没有绝对地址,之后相对地址,因此代码可以被加载器加载到内存的任意位置,都可以正确运行。而这正是共享库所需要的,因为共享库在被加载的时候,在内存的位置是不固定。 -shared: 这个选项指定生成动态链接库 然后我们需要将生成的动态库链接到我们的目标文件上,编译命令如下: gcc target.c -o target -L ./ -lcode 解释一下: -L: 这一个跟 -I 很像,就是表示要链接的库在参数指定的位置中 -lcode: 用来指定库名,而库名的命名规则是 libxxx 的,所以这个选项只需要后面的部分,即 -lxxx 注意,在编译目标文件的时候可能会出现下面的错误: error while loading shared libraries 说明系统还是找不到我们的动态库路径,那么有两种解决方法: 将我们编译的 .so 文件复制到 /usr/lib 或者是 /usr/local/lib 将当前路径加入到 LD_LIBRARY_PATH 变量,使用如下命令: export LD_LIBRARY_PATH=./ 至此,所有工作完成,编译成功~]]></content>
<categories>
<category>编程珠玑</category>
</categories>
<tags>
<tag>动态链接库</tag>
</tags>
</entry>
<entry>
<title><![CDATA[Thinking in Data Structure -- AVL Tree]]></title>
<url>%2FThinking-in-Data-Structure--AVL%20Tree%2F</url>
<content type="text"><![CDATA[十分优美的旋转操作 AVL 树, 是在二叉搜索树的基础上,在插入操作上加入了旋转的操作,从而避免了一棵树退化成一个链表或者树的深度过长导致搜索的时候效率太低的坏处,而旋转也是 AVL 树 中最重要的操作,也是本博文的重点内容。 AVL 树 在普通二叉搜索树节点的基础上加入了一个 buf 值,意思是左孩子的高度减去右孩子的高度,而 buf 值的合法范围是 -1, 0, 1,如果不是这几个值,那么说明树不平衡,就要进行旋转。 接下来我们重点说说旋转操作。 首先,要注意的是,在实现中并不是等到节点的 bf 值变为 +2 或者 -2 的时候才进行旋转。相反,当父节点的 bf 为 +1 或者 -1 时,就认为下一次插入的是必将导致不平衡,所以就进行旋转了。 以左失衡为例,分为 LL 和 LR,即在父节点的左子树的左节点插入或者是父节点的左子树的右节点插入。究竟在插入的时候是如何判断这两种情况的,我们来看一张图 从图中我们可以看到,当父节点的左儿子的 bf 是 +1 时,属于 LL 的情况, -1 的时候,属于 LR 的情况。那么继续分析下去, bf 等于 0 的时候是属于那种情况?稍加思考便可发现,这一种情况不存在,因为我们这里讨论的父节点的左儿子的 bf 值是在插入新节点之后导致失衡的前提下,如果左节点的左儿子在插入新节点之后导致失衡而且 bf 为 0,也就是在插入新节点之前以及之后父节点的左子树的高度没有发生变化,那么在插入新节点之后树发生了失衡,那么说明在插入之前树也是失衡的。 有了上面的思考,接下来我们来分析一下旋转之后各节点的 bf 值如何调整,LL 的比较简单,重点来看 LR 的,还是来看一个图: 从图中我们可以看到,LR 分为三种情况,这三种情况分别是通过父节点左子树的右节点或者说是 “孙子节点” 插入后的 bf 值来区分的。 而右失衡的操作,跟左失衡类似,在这里就不再赘述了。 下面是代码的实现: #include "avl.h"#include <stdlib.h>void insert(p_avl *t, int data){ if (*t != NULL) { if (data < (*t)->data) { insert(&(*t)->l_child, data); if (unblance) { switch ((*t)->buf) { case 0: (*t)->buf = 1; break; case -1: (*t)->buf = 0; unblance = false; break; case 1: left_rotation(t); break; } } } else if (data > (*t)->data) { insert(&(*t)->r_child, data); if (unblance) { switch ((*t)->buf) { case 0: (*t)->buf = -1; break; case 1: (*t)->buf = 0; unblance = false; break; case -1: right_rotation(t); break; } } } else { unblance = false; fprintf(stderr, "The key has already in the tree"); } } else { unblance = true; *t = (p_avl)malloc(sizeof(avl)); if (*t == NULL) { fprintf(stderr, "Out of memory!\n"); exit(1); } (*t)->data = data; (*t)->buf = 0; (*t)->l_child = (*t)->r_child = NULL; }}void right_rotation(p_avl *parent){ p_avl child; child = (*parent)->r_child; if (child->buf == -1) { p_avl child_left; child_left = child->l_child; (*parent)->r_child = child_left; child->l_child = (*parent); (*parent)->buf = 0; (*parent) = child; } else { p_avl grandchild = child->l_child; p_avl grandchild_right = grandchild->r_child; child->l_child = grandchild_right; grandchild->r_child = child; (*parent)->r_child = grandchild->l_child; grandchild->l_child = (*parent); switch (grandchild->buf) { case 0: (*parent)->buf = child->buf = 0; break; case 1: (*parent)->buf = 0; child->buf = -1; break; case -1: (*parent)->buf = 1; child->buf = 0; break; } (*parent) = grandchild; } (*parent)->buf = 0;}void left_rotation(p_avl *parent){ p_avl child; child = (*parent)->l_child; if (child->buf == 1) { p_avl child_right; child_right = child->r_child; (*parent)->l_child = child_right; child->r_child = (*parent); (*parent)->buf = 0; (*parent) = child; } else { p_avl grandchild = child->r_child; p_avl grandchild_left = grandchild->l_child; child->r_child = grandchild_left; grandchild->l_child = child; (*parent)->l_child = grandchild->r_child; grandchild->r_child = (*parent); switch (grandchild->buf) { case 0: child->buf = (*parent)->buf = 0; break; case 1: child->buf = 0; (*parent)->buf = -1; break; case -1: child->buf = 1; (*parent)->buf = 0; break; } (*parent) = grandchild; } (*parent)->buf = 0;}void pre_order(p_avl t){ if (t) { printf("%d ", t->data); pre_order(t->l_child); pre_order(t->r_child); }}int main(void){ p_avl root = NULL; int key; while ((scanf("%d", &key)) && key != '#') { insert(&root, key); } pre_order(root);}]]></content>
<categories>
<category>代码之美</category>
</categories>
<tags>
<tag>数据结构</tag>
</tags>
</entry>
<entry>
<title><![CDATA[POJ 1102 -- LC-Display]]></title>
<url>%2FLCD-display%2F</url>
<content type="text"><![CDATA[我特别喜欢拿它当作面试题 已经很久没有刷题了。前几天在上 EDA 实验课的时候,突然想到了以前一道没有 AC 的题目,并且突然顿悟,想到了可能是正确的解决方法。然后今天着手实现。总算是 1A 。虽然已经退出了 ACM ,不过 AC 的感觉还是跟以前一样。嗯,我有点怀念以前那段岁月了。 题目描述题意很简单。就是给你一串数,要你模拟出这串数在 LC 屏幕的输出。不过有一个要求,就是还有一个 size 值。 解决方法常规的按一个数一个数去输出肯定是不行的,因为你无法让光标向上移动,所以,我们需要按层去输出。例如,我是将其分为 5 层。然后,每一层建立一个数组,然后一层一层的输出。这样就可以了。 代码#include <stdio.h>#include <string.h>#define L 5char row1[] = {'-', ' ', '-', '-', ' ', '-', '-', '-', '-', '-'};char row2[] = {'|', '|', ' ', '|', ' ', '|', ' ', '|', '|', '|', '|', ' ', '|', ' ', ' ', '|', '|', '|', '|', '|'};char row3[] = {' ', ' ', '-', '-', '-', '-', '-', ' ', '-', '-'};char row4[] = {'|', '|', ' ', '|', '|', ' ', ' ', '|', ' ', '|', ' ', '|', '|', '|', ' ', '|', '|', '|', ' ', '|'};char row5[] = {'-', ' ', '-', '-', ' ', '-', '-', ' ', '-', '-'};int main(void){ int times; char num[10] = {0}; int i; int j; int k; int m; int n; while (scanf("%d%s", &times, num) && times != 0) { for (i = 0; i < L; i++) { if (i == 0 || i == 2 || i == 4) { int len = strlen(num); for (j = 0; j < len; j++) { for (k = 0; k < times; k++) { if (k == 0) { putchar(' '); } switch (i) { case 0: printf("%c", row1[num[j] - '0']); break; case 2: printf("%c", row3[num[j] - '0']); break; case 4: printf("%c", row5[num[j] - '0']); break; } if (k == (times - 1)) { putchar(' '); } } printf("%c", (j == len - 1) ? '\n' : ' '); } } else { for (j = 0; j < times; j++) { int len = strlen(num); for (k = 0; k < len; k++) { switch (i) { case 1: printf("%c", row2[(num[k] - '0') * 2 + 0]); break; case 3: printf("%c", row4[(num[k] - '0') * 2 + 0]); break; } for (m = 0; m < times; m++) { putchar(' '); } switch (i) { case 1: printf("%c", row2[(num[k] - '0') * 2 + 1]); break; case 3: printf("%c", row4[(num[k] - '0') * 2 + 1]); break; } printf("%c", (k == (len - 1)) ? '\n' : ' '); } } } } putchar(10); } return 0;} EOF]]></content>
<categories>
<category>刷题</category>
</categories>
<tags>
<tag>水题</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- IPC]]></title>
<url>%2Fchapter-8-IPC%2F</url>
<content type="text"><![CDATA[“一个操作系统的实现”最后一篇 终于,时隔这么多天,总算是把 第八章 完成了。从下篇开始,作者的写作风格大变,上篇基本上是把大部分的代码都弄出来讲,不过下篇的话,作者只拿了一些关键的代码,而且很多地方都做了修改,但是他却没有提到,所以,你只能自己去扒代码。在实现的过程中,出现了超级多的 bug ,debug 到要吐。不过,我不会像以前一样只会抱怨说:“这不科学!” 在这快一个星期的改错过程中,我学到了很多东西。不要总是抱怨改 bug辛苦, bug 都是自己制造出来的,这个就只能怪自己了。最重要的一点,那就是,所有的 bug 都是思维的漏洞。所以,在你的思想体系还没有十分清晰的情况下,不要轻易写代码,不然,出现 bug 是很正常的事情。 好了,闲话不多说了。下面进入正题。 综述这一章有几个比较大的改动。首先,上一章的 write 系统调用改成了 printx ,使之能够对 assert() 和 panic() 这两个函数进行特殊的处理,其他的功能基本一致。然后就是用 IPC 重新实现了 get_ticks() 这个函数。 IPC这个是微内核的核心,它使得我们的系统系统调用可以大大减少。当初在看书的时候也确实花费了相当长的时间在理解这个东西。下面分别来说一下个中细节。 msg_send首先,通讯通讯,肯定是有收有发。我们的进程通过 send_recv 这个函数发送消息,最终就是调用 msg_send 。首先判断是否死锁,之后再判断发送的目的地是否是接收消息的状态(即是否有 RECEIVING 标志),并且是否是接收来自当前进程的消息或者是任意进程的消息(就是那个 ANY),如果是,则将消息复制到接收端,清除接收端的 RECEIVING 标志,待接收信息指针指向空;否则,给当前的进程打上 SENDING 标志,待发送的消息赋给消息指针,待接收端赋给 p_sendto (也就是将所有的信息保存在进程体里面。)接下来有做的是就是将当前进程加入到目标进程的消息发送队列了。这里有两种情况,躲过目标进程的消息接收队列为空,那么直接将当前的进程加入到目标进程的 q_sending ,否则的话,就要找到当前消息队列的最后一个(也就是找到那个 next_sending 为 0 的那个),然后把当前进程赋给队列最后一个的 next_sending 。然后,都要将消息队列的最后一个(也就是当前进程)的 next_sending 赋值为 0 。最后,调用 block() 函数,并最终调用了 schedule() 函数切换进程。注意,这里有一个很重要的点,就是它是如何实现进程切换的。我们知道,我们调用发送消息是通过系统中断来实现,在中断处理程序 sys_call 中的 save() 函数,会根据当前的 k_reenter 的值来分别将不同的东西压栈,而压入的内容,其实就是最终的进程切换程序 restart() ,并且最终在 sys_call 的最后一句 ret 来跳转到这个进程切换程序。 完整的消息发送过程就是这样。 msg_receive接下来就是消息接收了。首先判断当前进程是否接收来自任意进程的消息,如果是,则判断当前接收消息的进程的消息队列是否有在等待发送的进程,如果有的话,就把消息队列的第一个赋给 p_sendto 变量,以便接下来的处理;如果不是接收任意进程的消息,而是有特定的发送对象,那么就检查那个特定对象是否在当前进程的消息发送队列中(也就是检查特定对象是否有 SENDING 标志位并且 p_sendto 指针是否指向当前进程),如果是,那么就遍历整个消息发送队列,如果是第一个,那最好,直接赋给 p_sendto 变量,否则,找到特定对象以及排在它前面的进程(这个排在前面的进程是有用的,后面会提到),分别赋给变量 p_sendto 以及 prev 。接下来就是真真正正的消息传递了,如果当前进程接收任意进程的消息或者接收特定进程的消息并且该进程在在消息发送队列的第一个,那么就将该进程从队列中提出来,并且将队列整体前移。否则,为了从夹在消息队列的中间的特定进程从队列中抽出来,并且不影响队列的整体,这时前面的那个 prev 就派上了用场,将 p_sendto 的 p_sendto 指针指向 prev 的 p_sendto 指针,这样就能够实现将其从队列中抽出来的目的了(如果你还是不懂的话,自己画个图就很容易懂了~)。然后就是一些收尾的工作,清除当前进程的 RECEIVING 标志,清空指向消息的指针。最后的最后,如果是最坏的情况,那就是既不接收任意进程的消息,同时特定进程不在消息队列中,那么,就将当前进程打上 RECEIVING 的标志位,将消息等各种信息保存在进程体中,然后阻塞进程。 全过程好了,各种细节解释完了,接下来我们来看一个 IPC 的具体例子:就是用 IPC 来替换系统调用 get_ticks ,接下来,我将详细描述整个过程,以便大家能够有一个比较清晰的理解: 首先,由于 task_sys 系统进程,所以比用户进程先执行。整个进程其实就是一个守护进程,它是一个死循环,不断地接收信息,处理完之后再发送,然后继续如此。首先它先接收任意进程的消息,但是很明显,当前还没有进程给它发送消息,所以 task_sys 被阻塞了。然后进程切换到用户进程, 用户进程调用函数 get_ticks(), get_ticks() 函数初始化一个消息变量,并且将消息类型设置为 GET_TICKS ,然后调用一个封装函数 send_recv() ,并且最终将调用系统调用 sys_sendrecv。因为是 BOTH 类型的,所以是先发送,然后在接送。首先是发送消息,由于上面的 task_sys 已经执行,也就是说,接收端表明它在接收一个函数,将消息复制给 task_sys 的消息指针,然后消除 task_sys 的 RECEIVING 标志位,注意,因为此时 task_sys 的 RECEIVING 标志位已经被消除了,所以 task_sys 已经被解除阻塞了,只是进程切换还没有运行而已 。至此,消息发送成功。然后,即使等待接收,很明显,虽然 task_sys 已经被解除阻塞了,但是进程切换还没有运行,所以,消息还没哟被处理,所以,用户进程被阻塞了。接下来,进程切换,** task_sys 从上次被阻塞的地方继续运行(这一点很重要,我一开始就是忽略了这一点,导致了一直无法正确理解)**。因为已经成功地接收了消息,接下来,将 ticks 赋给消息,然后发送消息给用户进程,上面说道,用户进程此时是 RECEIVING 的状态,所以,将消息复制给用户进程,然后消除 RECEIVING 的标志位,跟上面说的一样,此时用户进程已经被解除阻塞了,只是进程切换还没有运行而已。消息发送成功。然后,进程切换,切换到用户进程,从上次被阻塞的地方继续。最后用户进程的 send_recv 成功执行,get_ticks 将消息中的 ticks 返回,至此,一个消息传递的全过程完整结束了! 简单来说,整个过程其实就是这样: task_sys 等待接收消息并且被阻塞 -> 用户进程调用 get_ticks() 并且最终调用 msg_send() 还有 msg_receive() -> 先是 msg_send() ,将消息复制给 task_sys ,消息发送成功 -> 然后是 msg_receive() ,用户进程等待接收消息并且在该处被阻塞 -> task_sys 解除阻塞,将消息处理完之后发送给用户进程,用户进程被解除阻塞 -> 用户进程继续从被阻塞的地方运行,并且最终成功返回。 写得有点多,不过这个 IPC 机制真的很重要! 遇到的 BUG一开始一运行就老是报 #PAGE FAULT 的错误,不知道是为什么?最后终于发现了 BUG 出现在 ./kernel/main.c 文件上,总共有两个疏忽的点: 就是在判断是是用户进程还是好任务的时候,如果是用户进程,一开始我是这样写的: p_proc = user_proc_table + i; 这其实是不对的,得这样: p_proc = user_proc_table + i - NR_TASKS; 这样就好了。 就是在给每一个进程赋初始的 TTY 时,也出现了像上面一样的疏忽: proc_table[NR_TASKS + 0] = 0; 而我就是忘记了把那个 NR_TASKS 加上去了。。。 总之,都是疏忽,都是思维漏洞!!! 好了,第八章总算是告一段落了,接下来就要向我最期待的第九章 – 硬盘进军了! EOF]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- printf]]></title>
<url>%2Fchapter-8-printf%2F</url>
<content type="text"><![CDATA[你会写 printf 吗? 关于 vsprintf今天花了很长的时间去理解 vsprintf 这个函数并且自己把它实现了一遍。果然,比对着书打难多了。不过,确实是收获特别多,而且自己实现的话,能够加深理解,而不仅仅是从书上理解那么片面。 先说一下 vsprintf() 这个函数的作用。它是 printf() 的一个处理函数,接受 3 个变量:buf 是用来存放处理完的字符串,或者是说,将 printf 的第一个参数里面的 %d 啊, %s 等用后面对应的参数替换之后的字符串。fmt 很明显,就是 printf() 的第一个参数。arg 这个参数,是指向 printf() 第二个参数以及后面参数的一个指针。理解这一点很重要。 然后这个函数的流程就是这样的,不断地扫描 fmt ,如果不是 % ,则直接赋给 buf ;如果是 % 的话,那么就看他的下一位是什么,是 % 的话,说明只是 % 的一个转义符,直接赋给 buf 。否知,如果是非零的数字,那么说明是一个 宽度说明 ,就必须计算宽度的长度。之后肯定就是控制格式符号 c,s,x 等了。在这些控制格式符中,d 和 x 由于存在进制的转换,所以要有相应的函数来处理进制转换,i2a() 就实现了这一个功能。这个函数,不得不说,是一个十分优美的实现,采用了递归。相当于先从低位到高位依次入栈,然后在依次弹栈,并不难理解,不过,我想说一点的是,这个函数的第三个参数:char **ps, 这是一个二级指针。里面的一句 *(*ps)++ = remain; 这一句一开始老是理解不了,高级次的指针真的有点难以理解。不过,最后总算是理解了: 这一句的作用就是改变 ps 这个指针的指针指向的指针,或者说,指向的字符串 没错,指针真的十分的优美!不过,指针也十分的危险,所以,只有正确地理解并且运用,才能发挥指针的强大功能!]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- IO]]></title>
<url>%2Fchapter-7-input%2F</url>
<content type="text"><![CDATA[最好的输入是输出 输入综述这里面有两个核心函数,keyboard_handler() 和 keyboard_read() 。其中,keyboard_handler() 是键盘中断的处理程序,作用是将扫描码加入到缓冲区中。这个缓冲区,其实就是一个 队列 ,p_tail 指向下一个要被处理的字节, p_head 指向下一个空闲的空间。而 keyboard_read 是我们的 tty 进程的守护程序,功能是从缓冲区读取扫描码并解析。 代码解析在 keyboard_read() 这个函数中有一句 make = (scan_code & FLAG_BREAK ? FALSE : TRUE) ;这一句的作用是判断扫描码是 make code 还是 break code,至于 Why it works ? ,解释如下: 纵观 make code 的键盘扫描码,除去 0xE0 0xE1 开头的,没有一个可打印的扫描码是 0x8X 或者以上的,或者说,两位十六进制的扫描码化成 8 位二进制后最高位没有是 1 的。所以,与上 FLAG_BREAK : 0x80 或者 10000000 能够将 make code 清零。 之前我在看这一部分的内容的时候,有一个问题总是不能理解,就是程序究竟是如何处理 shift 被长按的呢?下载终于有了解答: 我们的 keyboard_read() 每一次都只从缓冲区里面读出一个扫描码,所以如果是按下 shift 然后按下其他的键的,第一次首先处理 shift ,标志变量 shift_l 或者 shift_r 被置位,然后读取下一个扫描码,注意由于此时 shift 键没有松手,所以下一个读到的扫描码是你按完 shift 之后按的那个讲的扫描码,而不是 shift 键的 break code。由于 shift_l 或者 shift_r 已经被置位,所以在 扫描数组读的是第二列。注意,虽然我们的扫描数组是一个一维数组,不过这个是为了方便,操作时我们还是将它视作一个二位数组,变量 column 就是用来控制列的。这样,就可以处理 shfit 键被按下了。 TTY目前 TTY 的作用就是能够控制输出的位置,它将我们的可用的 32k 显存分成了 3 块,每一块就是一个 TTY 。因为多了这样的一层,所以,在处理上也就多了一层。之前我们是这样子处理的: 按下键盘 -> 触发中断 -> 调用 keyboard_handler() -> keyboard_handler() 函数不断地将 扫描码 加入到缓冲区中 -> 任务 TTY (注意,它现在只是名字叫 TTY 而已,还不具备 TTY 的功能)不断地调用 keyboard_read() ,解析扫描码并且输出。 现在,我们有了 TTY ,可以实现 多终端 了,由于每一个 终端 都有自己的显示区间,所以,在显示的时候,过程就是这样: 按下键盘 -> 触发中断 -> 调用 keyboard_handler() -> keyboard_handler() 函数不断地将 扫描码 加入到缓冲区中 -> 任务 task_tty 设置好当前的终端 console ,再进行一系列的初始化 -> 其实 task_tty() 的任务主线就是一个守护进程, 不断轮询所有的 console **,对每个 console 都进行tty_do_write() 还有 tty_do_read 不停地切换。tty_do_read() 的作用是如果轮询到的 console 是当前终端的话,那么就从键盘缓冲区中读取一个扫描码并且进行解析,然后交给 in_process() 函数进行处理,这里的处理跟我们没有 多终端 的时候有一点区别。我们之前是直接输出,现在的话,是将解析之后的 key 放在当前 console 自己的缓冲区。** tty_do_write() 的作用就是在当前 console 的缓冲区里面读取一个已经解析好的 key 值,然后交给 out_char() 进行相应的输出。 这样,这个 TTY 的过程应该就相当的清晰了~ 遇到的问题 今天,在编译的时候遇到了一个很奇怪的问题,一直报下面的错误: static declaration of ‘tty_do_read’ follows non-static declaration 一直无解,百度以后,发现了错误的根源: 出这个问题是把实现放在调用后面了C 语言里面要么需要先申明函数,要么就必须把函数实现放到函数调用之前 经验前几天在实现 printf 的时候,出现了一个问题,然后解决无果。因此懈怠了很多天,今天终于把问题解决了。我发现,解决问题的方法就是从问题本身出发,根据问题找到相应的出错点,在找到相应的代码,一点一点地找,总会找到的。不过,这需要你对你的代码相当地熟悉。 这一章总算是结束了,向 下篇 进军!!! EOF]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- 系统调用]]></title>
<url>%2Fsystem-call%2F</url>
<content type="text"><![CDATA[玩具操作系统也有系统调用 综述首先我们来看看我们的系统调用是怎么工作的。我们的程序要进行一个系统调用,得有东西让他调用吧。所以我们必须有一个 系统调用的封装 。在我们的例子中,就是 get_ticks() 这个函数。系统调用一般都是通过中断来实现的,所以我们要统一一个系统调用的中断号,初始化 idt ,指明该中断的对应处理程序。然后,一个中断肯定不止实现一个功能,所以,我们要同一个 量 来告诉系统我们要的是哪个功能,在我们的实现中,我们是通过对 eax 这个寄存器赋予不同的值来指明对应的功能的。由于是中断,所以当中断发生了之后,我们当然要保存被中断的进程的信息,所以我们要用到之前用到的 save 函数,但是由于函数里面已经对 eax 进行了赋值,所以我们必须将 save 中与 eax 的相关寄存器都换掉。然后,我们前面说过要实现多个功能,所以我们建立了一个数组 sys_call_table ,根据 eax 的值在数组中找到相应的处理程序。所以,简单来说,系统调用就是这样的: 用户调用系统调用的封装函数 -> 封装函数赋值 eax ,执行中断 -> 中断处理程序在 sys_call_table 数组中根据 eax 找到最终要执行的程序,并且执行之。至此,一个完整的中断结束~ 遇到的一些问题昨天在实现的时候,发现了输出的 ticks 一直都是 0 ,一开始也不知道为什么,还稀里糊涂地在那里 Debug 里半天,直到刚才才找到了错误之处。很简单,我错误地将 sys_get_ticks() 的返回值弄成了 void 。。。 一开始的时候,一启动就会产生 #GP 的错误,之后才发现,我在 init_decriptor() 初始化 sys_call 的地方,习惯性地将 attr 弄成 PRIVILEGE_KERL ,其实应该是 PRIVILEGE_USER 。既然犯错误了,顺便复习一下 DPL 的知识。之所以会产生 #GP 的错误,主要是 DPL 在起作用。对于调用门来说,DPL 规定了当前执行的程序或任务可以访问此调用门的最低特权级,而调用这个中断的 —— 我们的进程 —— 特权级是 1,很明显是低于 0 的,所以才会产生这个错误。 今天在实现的时候,发现有出现了很奇怪的 #GP 错误(这是我用 bochs 的 snapshot 截下来的): Exception! --> #GP General Protection ßEFLAGS:0x11096CS:0x8EIP:0x306F4Error code:0xCBASEADDRL BASEADDRH LENGTHLOW LENGTHH TYPE00000000h 00000000h 0009F000h 00000000h 00000001h0009F000h 00000000h 00001000h 00000000h 00000002h000E8000h 00000000h 00018000h 00000000h 00000002h00100000h 00000000h 01EF0000h 00000000h 00000001h01FF0000h 00000000h 00010000h 00000000h 00000003hFFFC0000h 00000000h 00040000h 00000000h 00000002hRAM SIZE : 01FF0000h-----"cstart" begins----------"cstart" ends-----~~~~~~~~~~~ kernel_main ~~~~~~~~~~~~B. 一开始我一直不知道什么怎么解决。然后我就乱弄,然后错误就解决了。虽然我现在还是不知道究竟是什么错误,不过我说一下我的解决方法,用同样的问题可以参考一下。其实没有什么的,就是用 bximage 重新建立一个 a.img ,问题就解决了。 下午在实现的时候,出现了一个很奇怪的 bug ,输出到一半就停止了,又是长时间的 Debug ,总算找到了,又是一个低级的粗心错误。我感觉到,*粗心的程序员注定是短命的,因为他们把大部分时间花在了毫无意义的 Debug *。这个一定要改!!!! 就是把 schedule() 函数写错了: PUBLIC void schedule(){ PROCESS *p; int greatest_ticks = 0; while (!greatest_ticks) { for (p = proc_table; p < proc_table + NR_TASKS; p++) { if (p->ticks > greatest_ticks) { greatest_ticks = p->ticks; p_proc_ready = p; } } } if (!greatest_ticks) { for (p = proc_table; p < proc_table + NR_TASKS; p++) { p->ticks = p->priority; } }} 不知道你看出来了没有,就是把 if 这一行弄在了 while 循环之外了。。。 顺便解释一下这个函数的作用吧。其实就是在进程表中找到 ticks 最大的,最为下一个要运行的进程。如果所有的进程的 ticks 都为 0,那么就将所有的进程的 ticks 初始化为 priority 。而上面的错误之所以会发生,就是当所有的进程的 ticks 都为 0 的时候,由于 greatest_ticks 一直为零,所以就死循环了。 好了,第六章到此结束了。错误不少,收获不少。向第七章进军! EOF]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- 多进程]]></title>
<url>%2Fmuti-process%2F</url>
<content type="text"><![CDATA[玩具操作系统也有多进程 单进程 赋值 tss.esp0 首先我们要理解整个进程与中断的过程: 在 restart() 函数运行之后,我们的进程开始运行,系统现在是在 ring1 特权级上。由于我们已经开启了时钟中断,所以,时钟中断会以一定频率发生。注意,要好好理解 中断 这个概念。一开始我一直无法理解,为什么我们的第一个进程是一个死循环,但是中断依旧能够发生。其实,中断就是将当前的进程终止,去执行其他任务,当该任务完成了之后,就会从当前进程停止处继续。这一点很重要,到后面的 IPC 都要依靠这个概念。 由于中断发生时出现了特权级转换: ring1 -> ring0 所以存在堆栈的转换。(具体请看《一个操作系统的实现》 P99)堆栈的 ss 和 esp 存储在 tss 里面。在 restart() 我们已经准备好了 tss0.esp ,是指向当前进程的 STACK_FRAME 的最高处。至于为什么要指向这里,那是因为 ring1 -> ring0 的转换,不仅存在堆栈的切换,而且还会将 eip cs eflags esp ss 这些压入新的堆栈中,这跟我们的 struct stack_frame 的顺序完全一致。最后一个疑问,为什么在中断处理程序中要一直给 tss.esp0 赋值?因为我们现在是单进程,所以这个赋值只是一个重复而已。不过,后面我们实现了多进程了之后,这个赋值操作就十分地重要了。 内核栈 首先,中断处理程序的堆栈就是 tss.esp0 ,而这里面存有我们进程的所有信息,所以如果我们的中断处理程序要进行一些函数操作,堆栈里面的信息可能会被破坏掉,所以,我们要切换到 内核栈 。这样,我们就可以放心地进行一系列的函数操作了。 多进程由于目前来说,所谓的 多进程 其实除了 进程体,进程的堆栈,进程的 ldt selector 不一样以外,其他的都是一样的,所以我们可以用一个循环来进行批量的初始化。我们把 进程体 用一个新的数组来存储: task_table[] ,每一次循环,就将 进程表 里的 eip 赋为 task_table[] 相应的元素。 同时,由于出现了多进程,所以自然而然地有了所谓的 进程调度 了。目前的进程调度没有任何高明之处,就是一个顺序循环调度。进程调度了之后,必须重新切换 ldt ,并且准备好 tss.esp0 ,以便下一次时钟中断发生后,能够将进程的状态储存在正确的堆栈里。这里要注意,进程的状态都是储存在自己的堆栈的。 添加进程的流程: 在 kernel/main.c 添加一个进程体 在 include/proto.h 声明进程体 在 kernel/global.c 的 task_table 添加一个对应的项 增加 include/proc.h 中的 NR_TASKS 以及对应的栈以及总栈大小 Minix 的中断处理不得不说,看了 Minux 的中断处理之后发现原来代码可以这么优美的。这里主要解释三点: 关于 retadr在中断处理程序一开始,我们是调用了 save 这个函数,然后注意,由于 call 操作,所以,会将下一条要执行的指令的 eip 压入栈中,又由于中断处理程序存在 ring1->ring0 ,所以会将 ss sp eflag cs eip 进行压栈,所以在 *下一条要执行的指令的 eip 就是之前我们一直忽略的 retadr *。 关于 中断重入就提一点,如果是发生了中断重入的话,则回复的是上一次被重入的中断状态 hwint00 中的最后一句 ret这又是一个非常优美的一个做法,主要到我们在 save 里面有 push 东西的,而 ret 就是把栈中的赋给 eip 而已。所以,这给了我们一个启示,ret 不一定要依赖 call ,可以按需进行压栈,需要的时候, ret 就行了 至此,进程告一段落,开始系统调用。 EOF]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[一个操作系统的实现 -- 单进程]]></title>
<url>%2Fchapter-6-single-process%2F</url>
<content type="text"><![CDATA[经过了一天的努力,在刚才,总算出现了上面激动人心的画面了: 真的很高兴,趁着还热乎,赶快记下来: 综述第六章是 进程 。不过现在这一章的 进程 其实是有点取巧的,它是事先将一个进程需要的寄存器,栈,LDT,代码等准备好,弄成一个进程表,一切都准备完了之后,将 esp 指向进程表的顶部,然后一个一个地 pop ,然后一个跳转,我们的第一个进程就开始运行了。 由于涉及到特权级的转换,所以需要 tss 来记录每个特权级的 ss 和 esp,以便当低特权级到高特权级的切换时,堆栈也能够切换。 由于现在我还没有实现中断,所以,这个进程是一直都在运行的,没有被打断的。不过这种情况将会改变,不过得明天吧。 代码解读一开始的这些代码没有什么难的,主要提一点: 在 /kernel/main.c 的 line 27 和 line 29 这两行代码中,有一个我之前一直无法理解的,就是 >> 3 。现解释如下: 每个描述符的大小是 8 字节,>> 3 就相当于 除以8 ,这样就能够得到相应的描述符在 gdt数组 的具体位置了。 犯过的错误以及总结 typedef 之后忘记了一个最重要的 ; . 导致出现了很多匪夷所思的错误,例如提示说 #define EXTERN extern 这一句有错等等。 每增加一个新的函数就要在 inlcude/proto.h 里面添加相应的声明。 有关字符串的函数声明在 include/string.h 里面 用汇编实现的函数如果要被其他文件调用,*千万记得要用 global *。不然肯定是会报 undefined reference to xxx 的! 一开始出现了 #GP 的错误,提示说在 cs:0x8 eip:0x3059E 这里出现了错误,反汇编了一下,发现这个位置对应的代码是: lldt [esp + P_LDT_SEL] 根据 p_proc->regs.eflags = 0x1202 ,找到了 restart() 的位置:0x30598 . 不过后来发现了,又是一个粗心导致的错误,我忘记填充 GDT 中的 LDT 的描述符了。不过,发生 #GP 的结果跟我查文档的结果是一样的。 收获又是花了很多时间在 Debugging 上,不过这一次总算有所收获。 bochs 的 sreg 指令能够查看 LDTR 。 print-stack 后面加个数字能够控制输出的栈的长度 最后,如果你们也想要体验一下 Debug 的话,我把我的 Debug 的过程公开出来: b 0x7d12 (找到 loader 之后的跳转)cb 0x9027e (跳入保护模式)cb 0x9039b (跳入内核)cb 0x310c9 (restart()) 声明,上面的地址只对于我的代码有效,你自己实现的代码与我的可能有出入 下面是原书代码的 Debug 过程: b 0x7d0a (找到 loader 之后的跳转)cb 0x9027f (跳入保护模式)cb 0x90393 (跳入内核)cb 0x3071c (这一句是根据 kernel/main.c 中很明显的 0x1202 找到的,下面几行就是 restart() 了) 大概就是这些了。明天继续~ EOF]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
<entry>
<title><![CDATA[四个月整,我总算是看完了《一个操作系统的实现》]]></title>
<url>%2Fi-have-finished-reading-the-oranges%2F</url>
<content type="text"><![CDATA[梦的开始 2014年7月15日——2014年11月15日,4个月,123天,300个小时,11章,469页。这就是我这四个月所做的事。终于,在今天完结了。虽然我知道,这只是一小步,但这仍然值得我感到十分的高兴,并且有理由让我相信这也将是实现我的操作系统的梦想的重要的一步。 回想这四个月,真的有点感慨万千。用一句话来总结,那就是 Linus 的那句名言: Read the FUCKING source code ! 确实是这样子的。这四个月我做的最多的事就是:读代码。从一开始的晦涩难懂的汇编,到后面的十分灵活且技巧性十足的 C ,经常让我抓狂不止。然而,抱怨过后,还是得硬着头皮读下去。各种各样的新东西: 保护模式 , GDT , IDT , Descriptor , CPL , DPL , IRQ , ELF , 进程 , IO , IPC , 硬盘驱动 , 内存管理 …… 每一个都让我头疼不已。不过,现在再来回看过去,真的就如苏东坡的那阕词一般: 回首向来萧瑟处 归去 也无风雨夜无晴 其实,这四个月我也有偷懒过。例如暑假回家的那一个月就基本上没有看过书,有的时候被某一个点卡住,搞得心情很不好,就去睡觉,睡醒了继续看,还是看不懂,继续睡 …… 接下来我的计划是,从书本跨向实践。我要开始着手实现我的操作系统的代码了。之前国庆放假7天,我花了6天在打代码,已经实现了一部分。不过那个时候是因为看不下书,后来由于又看懂了,实现便懈怠了。 现在距离 2014 年还剩下 一个多月,希望能够在今年内真真正正地实现一个 操作系统 的原型。因为,我已经 20 岁了,周杰伦在 20 岁的时候发布了第一张个人专辑,乔布斯在 20 岁的时候创办了 苹果公司 。这两个我最尊敬的人在他们的 20 岁都已经很有所成就了。我想,我也行。 话说周杰伦今年的 12 月份会发布新专辑,所以那个时候,边听杰伦的新歌,边敲代码,肯定是一件非常爽的事情~ 就是这样~ (P.S. 我开这个博客以来到现在访问者只有我一个人,我不知道第一个访问者会是谁?期待ing )]]></content>
<categories>
<category>一个操作系统的实现</category>
</categories>
<tags>
<tag>一个操作系统的实现</tag>
</tags>
</entry>
</search>