從源碼到彙編目标代碼分析JVM(Hotspot)的方法調用過程與棧幀結構。
java應用程式的啟動在在/hotspot/src/share/tools/launcher/java.c的main()函數中,而在虛拟機初始化過程中,将建立并啟動Java的Main線程。最後将調用JNIEnv的CallStaticVoidMethod()來執行main方法。
CallStaticVoidMethod()對應的jni函數為jni_CallStaticVoidMethod,定義在/hotspot/src/share/vm/prims/jni.cpp中,而jni_CallStaticVoidMethod()又調用了jni_invoke_static(),jni_invoke_static()通過JavaCalls的call()發起對Java方法的調用
所有來自虛拟機對Java函數的調用最終都将由JavaCalls子產品來完成,JavaCalls将通過call_helper()來執行Java方法并傳回調用結果,并最終調用StubRoutines::call_stub()來執行Java方法:
1 // do call
2 { JavaCallWrapper link(method, receiver, result, CHECK);
3 { HandleMark hm(thread); // HandleMark used by HandleMarkCleaner
4
5 StubRoutines::call_stub()(
6 (address)&link,
7 // (intptr_t*)&(result->_value), // see NOTE above (compiler problem)
8 result_val_address, // see NOTE above (compiler problem)
9 result_type,
10 method(),
11 entry_point,
12 args->parameters(),
13 args->size_of_parameters(),
14 CHECK
15 );
16
17 result = link.result(); // circumvent MS C++ 5.0 compiler bug (result is clobbered across call)
18 // Preserve oop return value across possible gc points
19 if (oop_result_flag) {
20 thread->set_vm_result((oop) result->get_jobject());
21 }
22 }
23 }
call_stub()定義在/hotspot/src/share/vm/runtime/stubRoutines.h中,實際上傳回的就是CallStub函數指針_call_stub_entry,該指針指向call_stub的彙編實作的目标代碼指令位址,即call_stub的例程入口。
// Calls to Java
typedef void (*CallStub)(
address link,
intptr_t* result,
BasicType result_type,
methodOopDesc* method,
address entry_point,
intptr_t* parameters,
int size_of_parameters,
TRAPS
);
static CallStub call_stub() { return CAST_TO_FN_PTR(CallStub, _call_stub_entry); }
在分析call_stub的彙編代碼之前,先了解下x86寄存器和棧幀以及函數調用的相關知識。
x86-64的所有寄存器都是與機器字長(資料總線位寬)相同,即64位的,x86-64将x86的8個32位通用寄存器擴充為64位(eax、ebx、ecx、edx、eci、edi、ebp、esp),并且增加了8個新的64位寄存器(r8-r15),在命名方式上,也從”exx”變為”rxx”,但仍保留”exx”進行32位操作,下表描述了各寄存器的命名和作用

此外,還有16個128位的XMM寄存器,分别為xmm0-15,x84-64的寄存器遵循調用約定(Calling Conventions):
https://msdn.microsoft.com/en-US/library/zthk2dkh(v=vs.80).aspx
1.參數傳遞:
(1).前4個參數的int類型分别通過rcx、rdx、r8、r9傳遞,多餘的在棧空間上傳遞(從右向左依次入棧),寄存器所有的參數都是向右對齊的(低位對齊)
(2).浮點數類型的參數通過xmm0-xmm3傳遞,注意不同類型的參數占用的寄存器序号是根據參數的序号來決定的,比如add(int,double,float,int)就分别儲存在rcx、xmm1、xmm2、r9寄存器中
(3).8/16/32/64類型的結構體或共用體和_m64類型将使用rcx、rdx、r8、r9直接傳遞,而其他類型将會通過指針引用的方式在這4個寄存器中傳遞
(4).被調用函數當需要時要把寄存器中的參數移動到棧空間中(shadow space)
2.傳回值傳遞
(1).對于可以填充為64位的傳回值(包括_m64)将使用rax進行傳遞
(2).對于_m128(i/d)以及浮點數類型将使用xmm0傳遞
(3).對于64位以上的傳回值,将由調用函數在棧上為其配置設定空間,并将其指針儲存在rcx中作為”第一個參數”,而傳入參數将依次右移,最後函數調用完後,由rax傳回該空間的指針
(4).使用者定義的傳回值類型長度必須是1、2、4、8、16、32、64
3.調用者/被調用者儲存寄存器
調用者儲存寄存器:rax、rcx、rdx、r8-r11都認為是易失型寄存器(volatile),這些寄存器随時可能被用到,這些寄存器将由調用者自行維護,當調用其他函數時,被調用函數對這些寄存器的操作并不會影響調用函數(即這些寄存器的作用範圍僅限于目前函數)。
被調用者儲存寄存器:rbx、rbp、rdi、rsi、r12-r15、xmm6-xmm15都是非易失型寄存器(non-volatile),調用其他函數時,這些寄存器的值可能在調用傳回時還需要用,那麼被調用函數就必須将這些寄存器的值儲存起來,當要傳回時,恢複這些寄存器的值(即這些寄存器的作用範圍是跨函數調用的)。
以如下程式為例,分析函數調用的棧幀布局:
1 double func(int param_i1, float param_f1, double param_d1, int param_i2, double param_d2)
2
3 {
4 int local_i1, local_i2;
5 float local_f1;
6 double local_d1;
7 double local_d2 = 3.0;
8 local_i1 = param_i1;
9 local_i2 = param_i2;
10 local_f1 = param_f1;
11 local_d1 = param_d1;
12 return local_d1 + local_f1 * (local_i2 - local_i1) - param_d2 + local_d2;
13 }
14
15 int main()
16
17 {
18 double res;
19 res = func(1, 1.0, 2.0, 3, 3.0);
20 return 0;
21 }
main函數調用func之前的彙編代碼如下:
main:
pushq %rbp //儲存rbp
.seh_pushreg %rbp
movq %rsp, %rbp //更新棧基址
.seh_setframe %rbp, 0
subq $80, %rsp
.seh_stackalloc 80 //main棧需要80位元組的棧空間
.seh_endprologue
call __main
movabsq $4611686018427387904, %rdx //0x4000000000000000,即浮點數2.0
movabsq $4613937818241073152, %rax //0x3000000000000000,即浮點數3.0
movq %rax, 32(%rsp) //第5個參數3.0,即param_d2儲存在棧空間上
movl $3, %r9d //第4個參數3,即param_i2儲存在r9d中(r9的低32位)
movq %rdx, -24(%rbp)
movsd -24(%rbp), %xmm2 //第3個參數2.0,即param_d1儲存在xmm2中
movss .LC2(%rip), %xmm1 //第2個參數1.0(0x3f800000),儲存在xmm1中
movl $1, %ecx //第1個參數1,儲存在ecx中(rcx的低32位)
call func
func函數傳回後,main函數将從xmm0中取出傳回結果
call func
movq %xmm0, %rax //儲存結果
movq %rax, -8(%rbp)
movl $0, %eax //清空eax,回收main棧,恢複棧頂位址
addq $80, %rsp
popq %rbp
ret
func函數的棧和操作數準備如下:
func:
pushq %rbp //儲存rbp(main函數棧的基址)
.seh_pushreg %rbp
movq %rsp, %rbp //将main棧的棧頂指針作為被調用函數的棧基址
.seh_setframe %rbp, 0
subq $32, %rsp //func棧需要32位元組的棧空間
.seh_stackalloc 32
.seh_endprologue
movl %ecx, 16(%rbp) //将4個參數移動到棧底偏移16-40的空間(main棧的shadow space)
movss %xmm1, 24(%rbp)
movsd %xmm2, 32(%rbp)
movl %r9d, 40(%rbp)
movabsq $4613937818241073152, %rax //本地變量local_d2,即浮點數3.0
movq %rax, -8(%rbp) //5個局部變量
movl 16(%rbp), %eax
movl %eax, -12(%rbp)
movl 40(%rbp), %eax
movl %eax, -16(%rbp)
movl 24(%rbp), %eax
movl %eax, -20(%rbp)
movq 32(%rbp), %rax
movq %rax, -32(%rbp)
随後的func的運算過程如下:
movl -16(%rbp), %eax //local_i2 - local_i1
subl -12(%rbp), %eax
pxor %xmm0, %xmm0 //準備xmm0寄存器,按位異或,xmm0清零
cvtsi2ss %eax, %xmm0
mulss -20(%rbp), %xmm0 //local_f1 * (local_i2 - local_i1)
cvtss2sd %xmm0, %xmm0
addsd -32(%rbp), %xmm0 //local_d1 + local_f1 * (local_i2 - local_i1)
subsd 48(%rbp), %xmm0 //local_d1 + local_f1 * (local_i2 - local_i1) - param_d2
addsd -8(%rbp), %xmm0 //local_d1 + local_f1 * (local_i2 - local_i1) - param_d2 + local_d2
addq $32, %rsp //回收func棧,恢複棧頂位址
popq %rbp
ret
根據以上代碼分析,大概得出該程式調用棧結構:
這裡沒有考慮func函數再次調用其他函數而準備操作數的棧内容的情況,但結合main函數棧,大緻可以得出棧的通用結構如下:
call_stub由generate_call_stub()解釋成彙編代碼,有興趣的可以繼續閱讀call_stub的彙編代碼進行分析。
下面對call_stub的彙編部分進行分析:
先來看下call_stub的調用棧結構:(注:本文實驗是在windows_64位平台上實作的)
// Call stubs are used to call Java from C
// return_from_Java 是緊跟在call *%eax後面的那條指令的位址
// [ return_from_Java ] <--- rsp
// -28 [ arguments ] <-- rbp - 0xe8
// -26 [ saved xmm15 ] <-- rbp - 0xd8
// -24 [ saved xmm14 ] <-- rbp - 0xc8
// -22 [ saved xmm13 ] <-- rbp - 0xb8
// -20 [ saved xmm12 ] <-- rbp - 0xa8
// -18 [ saved xmm11 ] <-- rbp - 0x98
// -16 [ saved xmm10 ] <-- rbp - 0x88
// -14 [ saved xmm9 ] <-- rbp - 0x78
// -12 [ saved xmm8 ] <-- rbp - 0x68
// -10 [ saved xmm7 ] <-- rbp - 0x58
// -9 [ saved xmm6 ] <-- rbp - 0x48
// -7 [ saved r15 ] <-- rbp - 0x38
// -6 [ saved r14 ] <-- rbp - 0x30
// -5 [ saved r13 ] <-- rbp - 0x28
// -4 [ saved r12 ] <-- rbp - 0x20
// -3 [ saved rdi ] <-- rbp - 0x18
// -2 [ saved rsi ] <-- rbp - 0x10
// -1 [ saved rbx ] <-- rbp - 0x8
// 0 [ saved rbp ] <--- rbp,
// 1 [ return address ] <--- rbp + 0x08
// 2 [ ptr. to call wrapper ] <--- rbp + 0x10
// 3 [ result ] <--- rbp + 0x18
// 4 [ result_type ] <--- rbp + 0x20
// 5 [ method ] <--- rbp + 0x28
// 6 [ entry_point ] <--- rbp + 0x30
// 7 [ parameters ] <--- rbp + 0x38
// 8 [ parameter_size ] <--- rbp + 0x40
// 9 [ thread ] <--- rbp + 0x48
1.根據函數調用棧的結構:
在被調函數棧幀的棧底 %rbp + 8(棧位址向下增長,堆位址向上增長,棧底的正偏移值指向調用函數棧幀内容)儲存着被調函數的傳入參數,這裡即:
JavaCallWrapper指針、傳回結果指針、傳回結果類型、被調用方法的methodOop、被調用方法的解釋代碼的入口位址、參數位址、參數個數。
StubRoutines::call_stub [0x0000000002400567, 0x00000000024006cb[ (356 bytes)
//儲存bp
0x0000000002400567: push %rbp
//更新棧頂位址
0x0000000002400568: mov %rsp,%rbp
//call_stub需要的棧空間大小為0xd8
0x000000000240056b: sub $0xd8,%rsp
2.rcx、rdx、r8d、r9d分别儲存着傳入call_stub的前4個參數,現在需要将其複制到棧上的shadow space中
//分别使用rcx、rdx、r8、r9來儲存第1、2、3、4個參數,多出來的其他參數用棧空間來傳遞
//使用xmm0-4來傳遞第1-4個浮點數參數
//這裡将參數複制到棧空間,這樣call_stub的所有參數就在rbp + 0x10 ~ 0x48棧空間上
0x0000000002400572: mov %r9,0x28(%rbp)
0x0000000002400576: mov %r8d,0x20(%rbp)
0x000000000240057a: mov %rdx,0x18(%rbp)
0x000000000240057e: mov %rcx,0x10(%rbp)
3.将被調用者儲存寄存器的值壓入call_stub棧中:
;; save registers:
//依次儲存rbx、rsi、rdi這三個被調用者儲存的寄存器,随後儲存r12-r15、XMM寄存器組xmm6-xmm15
0x0000000002400582: mov %rbx,-0x8(%rbp)
0x0000000002400586: mov %r12,-0x20(%rbp)
0x000000000240058a: mov %r13,-0x28(%rbp)
0x000000000240058e: mov %r14,-0x30(%rbp)
0x0000000002400592: mov %r15,-0x38(%rbp)
0x0000000002400596: vmovdqu %xmm6,-0x48(%rbp)
0x000000000240059b: vmovdqu %xmm7,-0x58(%rbp)
0x00000000024005a0: vmovdqu %xmm8,-0x68(%rbp)
0x00000000024005a5: vmovdqu %xmm9,-0x78(%rbp)
0x00000000024005aa: vmovdqu %xmm10,-0x88(%rbp)
0x00000000024005b2: vmovdqu %xmm11,-0x98(%rbp)
0x00000000024005ba: vmovdqu %xmm12,-0xa8(%rbp)
0x00000000024005c2: vmovdqu %xmm13,-0xb8(%rbp)
0x00000000024005ca: vmovdqu %xmm14,-0xc8(%rbp)
0x00000000024005d2: vmovdqu %xmm15,-0xd8(%rbp)
0x00000000024005da: mov %rsi,-0x10(%rbp)
0x00000000024005de: mov %rdi,-0x18(%rbp)
//棧底指針的0x48偏移儲存着thread對象,0x6d01a2c3(%rip)為異常處理入口
0x00000000024005e2: mov 0x48(%rbp),%r15
0x00000000024005e6: mov 0x6d01a2c3(%rip),%r12 # 0x000000006f41a8b0
4.call_stub的參數儲存着Java方法的參數,現在就需要将參數壓入call_stub棧中
/棧底指針的0x40偏移儲存着參數的個數
0x00000000024005ed: mov 0x40(%rbp),%r9d
//若參數個數為0,則直接跳轉0x000000000240060d準備調用Java方法
0x00000000024005f1: test %r9d,%r9d
0x00000000024005f4: je 0x000000000240060d
//若參數個數不為0,則周遊參數,将所有參數壓入本地棧
//其中棧底指針的0x38偏移儲存着參數的位址,edx将用作循環的疊代器
0x00000000024005fa: mov 0x38(%rbp),%r8
0x00000000024005fe: mov %r9d,%edx
;; loop:
//從第一個參數開始,将Java方法的參數壓人本地棧
/*
* i = parameter_size; //確定不等于0
* do{
* push(parameter[i]);
* i--;
* }while(i!=0);
*/
0x0000000002400601: mov (%r8),%rax
0x0000000002400604: add $0x8,%r8
0x0000000002400608: dec %edx
0x000000000240060a: push %rax
0x000000000240060b: jne 0x0000000002400601
5.調用Java方法的解釋代碼
;; prepare entry:
//棧底指針的0x28和0x30偏移分别儲存着被調用Java方法的methodOop指針和解釋代碼的入口位址
0x000000000240060d: mov 0x28(%rbp),%rbx
0x0000000002400611: mov 0x30(%rbp),%rdx
0x0000000002400615: mov %rsp,%r13 //儲存棧頂指針
;; jump to run Java method:
0x0000000002400618: callq *%rdx
6.準備儲存傳回結果,這裡需要先根據不同的傳回類型取出傳回結果,然後儲存到傳回結果指針所指向的位置
;; prepare to save result:
//棧底指針的0x18和0x20偏移分别儲存着傳回結果的指針和結果類型
0x000000000240061a: mov 0x18(%rbp),%rcx
0x000000000240061e: mov 0x20(%rbp),%edx
;; handle result accord to different result_type:
0x0000000002400621: cmp $0xc,%edx
0x0000000002400624: je 0x00000000024006b7
0x000000000240062a: cmp $0xb,%edx
0x000000000240062d: je 0x00000000024006b7
0x0000000002400633: cmp $0x6,%edx
0x0000000002400636: je 0x00000000024006bc
0x000000000240063c: cmp $0x7,%edx
0x000000000240063f: je 0x00000000024006c2
;; save result for the other result_type:
0x0000000002400645: mov %eax,(%rcx)
下面分别為傳回結果類型為long、float、double的情況
;; long 類型傳回結果儲存:
0x00000000024006b7: mov %rax,(%rcx)
0x00000000024006ba: jmp 0x0000000002400647
;; float 類型傳回結果儲存:
0x00000000024006bc: vmovss %xmm0,(%rcx)
0x00000000024006c0: jmp 0x0000000002400647
;; double 類型傳回結果儲存:
0x00000000024006c2: vmovsd %xmm0,(%rcx)
0x00000000024006c6: jmpq 0x0000000002400647
7.被調用者儲存寄存器的恢複,以及棧指針的複位
;; restore registers:
0x0000000002400647: lea -0xd8(%rbp),%rsp
0x000000000240064e: vmovdqu -0xd8(%rbp),%xmm15
0x0000000002400656: vmovdqu -0xc8(%rbp),%xmm14
0x000000000240065e: vmovdqu -0xb8(%rbp),%xmm13
0x0000000002400666: vmovdqu -0xa8(%rbp),%xmm12
0x000000000240066e: vmovdqu -0x98(%rbp),%xmm11
0x0000000002400676: vmovdqu -0x88(%rbp),%xmm10
0x000000000240067e: vmovdqu -0x78(%rbp),%xmm9
0x0000000002400683: vmovdqu -0x68(%rbp),%xmm8
0x0000000002400688: vmovdqu -0x58(%rbp),%xmm7
0x000000000240068d: vmovdqu -0x48(%rbp),%xmm6
0x0000000002400692: mov -0x38(%rbp),%r15
0x0000000002400696: mov -0x30(%rbp),%r14
0x000000000240069a: mov -0x28(%rbp),%r13
0x000000000240069e: mov -0x20(%rbp),%r12
0x00000000024006a2: mov -0x8(%rbp),%rbx
0x00000000024006a6: mov -0x18(%rbp),%rdi
0x00000000024006aa: mov -0x10(%rbp),%rsi
;; back to old(caller) stack frame:
0x00000000024006ae: add $0xd8,%rsp //棧頂指針複位
0x00000000024006b5: pop %rbp //棧底指針複位
0x00000000024006b6: retq
歸納出call_stub棧結構如下:
8.對于不同的Java方法,虛拟機在初始化時會生成不同的方法入口例程
(method entry point)來準備棧幀,這裡以較常被使用的zerolocals方法入口為例,分析Java方法的棧幀結構與調用過程,入口例程目标代碼的産生在InterpreterGenerator::generate_normal_entry()中:
(1).根據之前的分析,初始的棧結構如下:
擷取傳入參數數量到rcx中:
address InterpreterGenerator::generate_normal_entry(bool synchronized) {
// determine code generation flags
bool inc_counter = UseCompiler || CountCompiledCalls;
// ebx: methodOop
// r13: sender sp
address entry_point = __ pc();
const Address size_of_parameters(rbx,
methodOopDesc::size_of_parameters_offset());
const Address size_of_locals(rbx, methodOopDesc::size_of_locals_offset());
const Address invocation_counter(rbx,
methodOopDesc::invocation_counter_offset() +
InvocationCounter::counter_offset());
const Address access_flags(rbx, methodOopDesc::access_flags_offset());
// get parameter size (always needed)
__ load_unsigned_short(rcx, size_of_parameters);
其中methodOop指針被儲存在rbx中,調用Java方法的sender sp被儲存在r13中,參數大小儲存在rcx中
(2).擷取局部變量區的大小,儲存在rdx中,并減去參數數量,将除參數以外的局部變量數量儲存在rdx中(雖然參數作為局部變量是方法的一部分,但參數由調用者提供,這些參數應有調用者棧幀而非被調用者棧幀維護,即被調用者棧幀隻需要維護局部變量中除了參數的部分即可)
// rbx: methodOop
// rcx: size of parameters
// r13: sender_sp (could differ from sp+wordSize if we were called via c2i )
__ load_unsigned_short(rdx, size_of_locals); // get size of locals in words
__ subl(rdx, rcx); // rdx = no. of additional locals
(3).對棧空間大小進行檢查,判斷是否會發生棧溢出
// see if we've got enough room on the stack for locals plus overhead.
generate_stack_overflow_check();
(4).擷取傳回位址,儲存在rax中(注意此時棧頂為調用函數call指令後下一條指令的位址)
// get return address
__ pop(rax);
(5).由于參數在棧中由低位址向高位址是以相反的順序存放的,是以第一個參數的位址應該是 rsp+rcx*8-8(第一個參數位址範圍為 rsp+rcx*8-8 ~ rsp+rcx*8),将其儲存在r14中
// compute beginning of parameters (r14)
__ lea(r14, Address(rsp, rcx, Address::times_8, -wordSize))
(6).為除參數以外的局部變量配置設定棧空間,若這些局部變量數量為0,那麼就跳過這一部分處理,否則,将壓入 maxlocals - param_size個0,以初始化這些局部變量
//該部分為一個loop循環
// rdx - # of additional locals
// allocate space for locals
// explicitly initialize locals
{
Label exit, loop;
__ testl(rdx, rdx);
__ jcc(Assembler::lessEqual, exit); // do nothing if rdx <= 0
__ bind(loop);
__ push((int) NULL_WORD); // initialize local variables
__ decrementl(rdx); // until everything initialized
__ jcc(Assembler::greater, loop);
__ bind(exit);
}
這時棧的層次如下:
(7).将方法的調用次數儲存在rcx/ecx中
// (pre-)fetch invocation count
if (inc_counter) {
__ movl(rcx, invocation_counter);
}
(8).初始化目前方法的棧幀
// initialize fixed part of activation frame
generate_fixed_frame(false);
generate_fixed_frame()的實作如下:
__ push(rax); // save return address
__ enter(); // save old & set new rbp
__ push(r13); // set sender sp
__ push((int)NULL_WORD); // leave last_sp as null
__ movptr(r13, Address(rbx, methodOopDesc::const_offset())); // get constMethodOop
__ lea(r13, Address(r13, constMethodOopDesc::codes_offset())); // get codebase
__ push(rbx);
儲存傳回位址,為被調用的Java方法準備棧幀,并将sender sp指針、last_sp(設定為0)壓入棧,根據methodOop的constMethodOop成員将位元組碼指針儲存到r13寄存器中,并将methodOop壓入棧
} else {
__ push(0); //methodData
}
__ movptr(rdx, Address(rbx, methodOopDesc::constants_offset()));
__ movptr(rdx, Address(rdx, constantPoolOopDesc::cache_offset_in_bytes()));
__ push(rdx); // set constant pool cache
__ push(r14); // set locals pointer
if (native_call) {
__ push(0); // no bcp
} else {
__ push(r13); // set bcp
}
__ push(0); // reserve word for pointer to expression stack bottom
__ movptr(Address(rsp, 0), rsp); // set expression stack bottom
}
将methodData以0為初始值壓入棧,根據methodOop的ConstantPoolOop成員将常量池緩沖位址壓入棧,r14中儲存着局部變量區(第一個參數的位址)指針,将其壓入棧,此外如果調用的是native調用,那麼位元組碼指針部分為0,否則正常将位元組碼指針壓入棧,最後為棧留出一個字的表達式棧底空間,并更新rsp
最後棧的空間結構如下:
(9).增加方法的調用計數
// increment invocation count & check for overflow
Label invocation_counter_overflow;
Label profile_method;
Label profile_method_continue;
if (inc_counter) {
generate_counter_incr(&invocation_counter_overflow,
&profile_method,
&profile_method_continue);
if (ProfileInterpreter) {
__ bind(profile_method_continue);
}
}
(當調用深度過大會抛出StackOverFlow異常)
(10).同步方法的Monitor對象配置設定和方法的加鎖(在彙編部分分析中沒有該部分,如果對同步感興趣的請自行分析)
if (synchronized) {
// Allocate monitor and lock method
lock_method();
(11).JVM工具接口部分
// jvmti support
__ notify_method_entry();
(12).跳轉到第一條位元組碼的本地代碼處執行
__ dispatch_next(vtos);
以上分析可能略顯複雜,但重要的是明白方法的入口例程是如何為Java方法構造新的棧幀,進而為位元組碼的運作提供調用棧環境。
method entry point彙編代碼的分析可以參考随後的一篇文章。