試實(shí)戰(zhàn):從核心轉(zhuǎn)儲(chǔ)到嵌入式開發(fā))
1. 項(xiàng)目概述為什么我們需要在Linux下用GDB調(diào)試匯編如果你在Linux環(huán)境下搞過(guò)C/C開發(fā)調(diào)試器GDBGNU Debugger多半是你的老朋友。但一提到用GDB去調(diào)試匯編代碼很多朋友可能就有點(diǎn)發(fā)怵了感覺(jué)像是要從舒適的自動(dòng)擋轎車突然換到需要自己踩離合、換擋的手動(dòng)擋老爺車。其實(shí)這個(gè)技能點(diǎn)恰恰是深入理解計(jì)算機(jī)系統(tǒng)、排查底層疑難雜癥的“屠龍技”。無(wú)論是分析程序崩潰的核心轉(zhuǎn)儲(chǔ)core dump、逆向工程一段沒(méi)有源碼的二進(jìn)制文件、學(xué)習(xí)操作系統(tǒng)或編譯原理還是進(jìn)行嵌入式系統(tǒng)尤其是ARM、MIPS、RISC-V等架構(gòu)的底層開發(fā)都繞不開它。簡(jiǎn)單來(lái)說(shuō)“Linux gdb匯編調(diào)試”這個(gè)主題就是教你如何駕馭GDB這輛強(qiáng)大的調(diào)試“戰(zhàn)車”在機(jī)器指令的層級(jí)上觀察和控制程序的運(yùn)行。這不再是高級(jí)語(yǔ)言里那個(gè)模糊的“變量”而是精確到CPU寄存器、內(nèi)存地址和單條指令的執(zhí)行。網(wǎng)絡(luò)熱詞里頻繁出現(xiàn)的“arm匯編”、“gdb調(diào)試命令”、“error: unable to start debugging”以及“openocd: gdb server quit unexpectedly”都指向了同一個(gè)需求大家正在或即將面臨更底層的調(diào)試場(chǎng)景而傳統(tǒng)的源碼級(jí)調(diào)試已經(jīng)不夠用了。這篇文章我就以一個(gè)老司機(jī)的視角帶你從零開始手把手拆解用GDB調(diào)試匯編的全過(guò)程。我會(huì)假設(shè)你有一些Linux基礎(chǔ)和C語(yǔ)言知識(shí)但即使你是新手跟著步驟走也能摸到門道。我們的目標(biāo)很明確不止于會(huì)敲幾個(gè)命令更要理解每個(gè)命令背后的原理和意圖讓你在遇到“gdb server quit unexpectedly”這類問(wèn)題時(shí)能自己找到線索并解決。2. 環(huán)境準(zhǔn)備與目標(biāo)程序構(gòu)建工欲善其事必先利其器。調(diào)試匯編的第一步不是打開GDB而是準(zhǔn)備一個(gè)合適的“實(shí)驗(yàn)品”——一個(gè)我們完全了解其內(nèi)部結(jié)構(gòu)的簡(jiǎn)單程序。2.1 編寫一個(gè)用于調(diào)試的C程序我們從一個(gè)最簡(jiǎn)單的C程序開始這樣既能看清高級(jí)語(yǔ)言如何對(duì)應(yīng)到匯編又不會(huì)一開始就陷入復(fù)雜的指令海洋。創(chuàng)建一個(gè)名為test_asm.c的文件#include stdio.h int add(int a, int b) { return a b; } int main() { int x 5; int y 10; int sum add(x, y); printf(The sum of %d and %d is: %d\n, x, y, sum); return 0; }這個(gè)程序邏輯清晰一個(gè)add函數(shù)一個(gè)main函數(shù)。我們的調(diào)試之旅就可以從觀察main如何調(diào)用add參數(shù)如何傳遞結(jié)果如何返回這些基本問(wèn)題開始。2.2 關(guān)鍵編譯選項(xiàng)生成帶調(diào)試信息與反匯編直接gcc test_asm.c -o test_asm編譯出來(lái)的程序GDB只能進(jìn)行源碼級(jí)調(diào)試。為了看到匯編我們需要在編譯時(shí)打開幾個(gè)關(guān)鍵開關(guān)gcc -g -Og -fno-stack-protector -o test_asm test_asm.c我們來(lái)拆解一下這幾個(gè)參數(shù)背后的“為什么”-g這是最重要的選項(xiàng)它在可執(zhí)行文件中嵌入調(diào)試信息Debugging Information。這些信息包括變量名、函數(shù)名、行號(hào)與機(jī)器指令的映射關(guān)系等。沒(méi)有它GDB就不知道0x400544這個(gè)地址對(duì)應(yīng)的是main函數(shù)的第幾行。-Og這是一個(gè)優(yōu)化等級(jí)選項(xiàng)專為調(diào)試優(yōu)化。-O0零優(yōu)化固然更直觀但生成的代碼可能非常冗余不利于理解典型的編譯器輸出。-Og在保證代碼可調(diào)試性的前提下會(huì)進(jìn)行一些不影響調(diào)試的優(yōu)化生成的匯編代碼更接近“生產(chǎn)環(huán)境”的思維是學(xué)習(xí)匯編和編譯器行為更好的起點(diǎn)。-fno-stack-protector禁用棧保護(hù)機(jī)制。棧保護(hù)Stack Canary是GCC為了防止棧溢出攻擊而加入的安全特性它會(huì)在函數(shù)棧幀中插入一個(gè)隨機(jī)值金絲雀。對(duì)于我們的學(xué)習(xí)示例這個(gè)機(jī)制會(huì)增加一些我們暫時(shí)不關(guān)心的指令比如__stack_chk_fail的調(diào)用讓匯編視圖變得“嘈雜”。關(guān)閉它能讓核心邏輯更清晰。注意在生產(chǎn)環(huán)境中除非有充分理由否則不應(yīng)禁用此選項(xiàng)。編譯完成后我們可以先用objdump這個(gè)靜態(tài)分析工具看一眼程序的“骨架”objdump -d test_asm | less你會(huì)看到整個(gè)程序的匯編代碼。但別急我們更希望在動(dòng)態(tài)調(diào)試中交互式地查看。2.3 啟動(dòng)GDB與基礎(chǔ)命令回顧進(jìn)入GDB的世界gdb ./test_asm如果你看到類似(gdb)的提示符說(shuō)明已經(jīng)成功進(jìn)入GDB的交互式環(huán)境。在深入?yún)R編之前我們先快速回顧幾個(gè)最核心的源碼級(jí)調(diào)試命令作為熱身和參照l(shuí)ist或l列出源代碼。break或b設(shè)置斷點(diǎn)。例如b main在main函數(shù)入口設(shè)斷點(diǎn)。run或r運(yùn)行程序直到遇到斷點(diǎn)或程序結(jié)束。next或n執(zhí)行下一行源代碼會(huì)跳過(guò)函數(shù)調(diào)用。step或s執(zhí)行下一行源代碼會(huì)進(jìn)入函數(shù)內(nèi)部。print或p打印變量或表達(dá)式的值。continue或c從當(dāng)前斷點(diǎn)繼續(xù)運(yùn)行直到下一個(gè)斷點(diǎn)或結(jié)束。quit或q退出GDB。實(shí)操心得在開始復(fù)雜的匯編調(diào)試前先用這些命令在源碼層走一遍你的程序確保程序行為符合預(yù)期。這能幫你建立一個(gè)“正確”的參照系當(dāng)你在匯編層看到一些奇怪指令時(shí)可以快速判斷是程序邏輯問(wèn)題還是你對(duì)匯編的理解問(wèn)題。3. 核心視角切換從源碼到匯編GDB的強(qiáng)大之處在于它能同時(shí)提供源碼和匯編兩種視圖并且可以無(wú)縫切換。這是理解高級(jí)語(yǔ)言如何“落地”為機(jī)器指令的關(guān)鍵。3.1 布局設(shè)置與反匯編查看進(jìn)入GDB后首先設(shè)置一個(gè)斷點(diǎn)并運(yùn)行(gdb) b main (gdb) r程序會(huì)停在main函數(shù)開始處?,F(xiàn)在我們讓GDB同時(shí)顯示源碼和匯編。有兩種常用方法方法一使用layout命令推薦圖形化體驗(yàn)好(gdb) layout asm或者同時(shí)顯示源碼和匯編(gdb) layout splitlayout命令會(huì)將GDB的TUI文本用戶界面分割成多個(gè)窗口上方顯示匯編或源碼下方是命令輸入框。你可以用Ctrlx, a快捷鍵來(lái)切換TUI模式的開啟和關(guān)閉。方法二使用disassemble命令(gdb) disassemble /r main/r選項(xiàng)表示同時(shí)顯示機(jī)器碼Raw opcode。你會(huì)看到類似下面的輸出Dump of assembler code for function main: 0x0000000000401136 0: 55 push %rbp 0x0000000000401137 1: 48 89 e5 mov %rsp,%rbp 0x000000000040113a 4: 48 83 ec 10 sub $0x10,%rsp ...每一行包含內(nèi)存地址、機(jī)器碼、匯編指令。這就是CPU真正看到和執(zhí)行的東西。3.2 理解調(diào)用約定與棧幀當(dāng)你在layout split視圖下用stepi單步執(zhí)行一條指令對(duì)應(yīng)源碼級(jí)的step慢慢執(zhí)行時(shí)會(huì)看到一系列關(guān)于棧的操作。以x86-64架構(gòu)為例函數(shù)開頭通常有push %rbp mov %rsp, %rbp sub $0x10, %rsp這三條指令在構(gòu)建一個(gè)新的棧幀Stack Framepush %rbp將上一個(gè)函數(shù)的基址指針RBP保存到棧上。mov %rsp, %rbp將當(dāng)前棧頂指針RSP的值賦給RBP。此時(shí)RBP指向了當(dāng)前函數(shù)棧幀的“底部”。sub $0x10, %rsp在棧上為局部變量分配空間這里是16字節(jié)。這就是著名的“棧幀序言Prologue”。函數(shù)結(jié)束時(shí)會(huì)有對(duì)應(yīng)的“棧幀尾聲Epilogue”來(lái)恢復(fù)現(xiàn)場(chǎng)。為什么需要棧幀棧幀為函數(shù)提供了一個(gè)獨(dú)立的內(nèi)存工作區(qū)域用于存放局部變量、臨時(shí)數(shù)據(jù)并遵循特定的**調(diào)用約定Calling Convention**來(lái)傳遞參數(shù)。在x86-64 Linux系統(tǒng)上通常使用System V AMD64 ABI調(diào)用約定前六個(gè)整型或指針參數(shù)通過(guò)寄存器RDI, RSI, RDX, RCX, R8, R9傳遞更多的參數(shù)通過(guò)棧傳遞。返回值通常放在RAX寄存器中。你可以通過(guò)info registers命令查看所有寄存器的當(dāng)前值。當(dāng)執(zhí)行到add(x, y)調(diào)用前嘗試打印寄存器(gdb) p $rdi (gdb) p $rsi你很可能會(huì)發(fā)現(xiàn)x的值5在rdi中y的值10在rsi中。這就是調(diào)用約定的具體體現(xiàn)。注意事項(xiàng)架構(gòu)不同調(diào)用約定天差地別。網(wǎng)絡(luò)熱詞中提到的“arm匯編”就是典型。ARM架構(gòu)32位通常使用寄存器R0-R3傳遞前四個(gè)參數(shù)。這是跨平臺(tái)或嵌入式調(diào)試時(shí)必須牢記于心的關(guān)鍵差異。調(diào)試時(shí)遇到參數(shù)值不對(duì)首先檢查調(diào)用約定和寄存器使用是否正確。3.3 單步執(zhí)行與寄存器/內(nèi)存觀察在匯編層面我們主要使用以下命令控制執(zhí)行流stepi或si執(zhí)行一條機(jī)器指令如果該指令是函數(shù)調(diào)用則會(huì)進(jìn)入被調(diào)用函數(shù)內(nèi)部。nexti或ni執(zhí)行一條機(jī)器指令但將函數(shù)調(diào)用當(dāng)作一條指令整體執(zhí)行不進(jìn)入內(nèi)部。continue或c繼續(xù)運(yùn)行。finish執(zhí)行完當(dāng)前函數(shù)返回到它的調(diào)用者。調(diào)試的核心是觀察狀態(tài)。除了info registers還有以下強(qiáng)大工具檢查內(nèi)存x命令。x /10x $rsp以十六進(jìn)制格式從RSP指向的地址開始顯示10個(gè)內(nèi)存單元默認(rèn)4字節(jié)。x /10i $pc從程序計(jì)數(shù)器PC指向下一條要執(zhí)行的指令地址開始反匯編10條指令。x /s 0x400000以字符串格式顯示指定地址的內(nèi)容。檢查特定地址info address可以查看符號(hào)如變量名、函數(shù)名的地址。修改內(nèi)存或寄存器set $rax 0x42將RAX寄存器的值設(shè)為0x42。set *(int*)0x7fffffffdc 100將指定內(nèi)存地址這里是棧上的一個(gè)地址的值修改為100整數(shù)。一個(gè)典型調(diào)試場(chǎng)景在add函數(shù)內(nèi)部單步執(zhí)行觀察RAX寄存器如何被賦值為ab的結(jié)果并在函數(shù)返回后查看main函數(shù)中哪里接收了這個(gè)返回值通常也是RAX。4. 高級(jí)調(diào)試技巧與實(shí)戰(zhàn)問(wèn)題排查掌握了基本觀察方法后我們面對(duì)的是更復(fù)雜的現(xiàn)實(shí)問(wèn)題。網(wǎng)絡(luò)熱詞中大量的錯(cuò)誤查詢?nèi)纭癳rror: unable to start debugging”、“gdb server quit unexpectedly”正是實(shí)戰(zhàn)中的攔路虎。4.1 調(diào)試無(wú)符號(hào)程序與核心轉(zhuǎn)儲(chǔ)很多時(shí)候我們面對(duì)的是沒(méi)有調(diào)試信息-g甚至沒(méi)有源代碼的程序。比如分析一個(gè)系統(tǒng)核心轉(zhuǎn)儲(chǔ)文件core dump或者進(jìn)行安全逆向。調(diào)試剝離了符號(hào)的程序# 編譯一個(gè)不帶調(diào)試信息的程序 gcc -o test_stripped test_asm.c # 用GDB加載 gdb ./test_stripped此時(shí)list命令失效break main也可能失敗因?yàn)榉?hào)表里沒(méi)有main這個(gè)名稱了。你需要通過(guò)地址來(lái)設(shè)置斷點(diǎn)先用info file查看程序的入口點(diǎn)地址Entry point。或者用disassemble _start查看啟動(dòng)代碼找到__libc_start_main的調(diào)用它的第一個(gè)參數(shù)就是main函數(shù)的地址。直接對(duì)地址設(shè)斷點(diǎn)b *0x400510。分析核心轉(zhuǎn)儲(chǔ)Core Dump 核心轉(zhuǎn)儲(chǔ)是程序崩潰時(shí)的一個(gè)內(nèi)存快照。要生成它需要先設(shè)置系統(tǒng)限制ulimit -c unlimited。當(dāng)程序崩潰后會(huì)生成一個(gè)core或core.pid文件。gdb ./test_asm coreGDB會(huì)直接恢復(fù)到程序崩潰時(shí)的狀態(tài)。此時(shí)最關(guān)鍵的命令是btbacktrace查看崩潰時(shí)的調(diào)用棧。即使沒(méi)有調(diào)試信息bt也能顯示出一系列地址。結(jié)合disassemble和x命令查看這些地址附近的代碼和數(shù)據(jù)是定位崩潰根源如空指針解引用、棧溢出的主要手段。實(shí)操心得分析無(wú)符號(hào)核心轉(zhuǎn)儲(chǔ)時(shí)info proc mappings命令極其有用。它能顯示崩潰時(shí)進(jìn)程的內(nèi)存映射布局包括堆、棧、共享庫(kù)的地址范圍。這能幫你判斷一個(gè)指針地址是否合法例如是否指向了未映射的區(qū)域。4.2 遠(yuǎn)程調(diào)試與嵌入式調(diào)試這是網(wǎng)絡(luò)熱詞中“openocd”、“gdb server”、“arm匯編”聚集的領(lǐng)域。場(chǎng)景通常是這樣你的程序運(yùn)行在另一個(gè)設(shè)備上如ARM開發(fā)板、單片機(jī)你需要在本機(jī)的GDB上遠(yuǎn)程控制它。典型架構(gòu)目標(biāo)機(jī)Target運(yùn)行著GDB Server如gdbserver、openocd、st-util。它的作用是接管目標(biāo)程序并通過(guò)網(wǎng)絡(luò)或串口與外部通信。宿主機(jī)Host運(yùn)行著GDB客戶端。我們熟悉的GDB就是客戶端。連接步驟在目標(biāo)機(jī)啟動(dòng)GDB Server。例如用gdbservergdbserver :2345 ./my_program在2345端口等待連接。在宿主機(jī)GDB中(gdb) target remote target_ip:2345連接成功后宿主機(jī)GDB的提示符會(huì)變成Remote debugging using ...。后續(xù)的調(diào)試命令設(shè)斷點(diǎn)、單步、查看變量和本地調(diào)試幾乎一樣但執(zhí)行都發(fā)生在目標(biāo)機(jī)。為什么會(huì)出現(xiàn)“unexpected gdb output”或“quit unexpectedly”這類錯(cuò)誤九成以上與環(huán)境不匹配有關(guān)架構(gòu)不匹配宿主機(jī)GDB的架構(gòu)如x86_64與目標(biāo)程序架構(gòu)如arm-linux-gnueabihf不一致。你必須使用交叉編譯工具鏈中的GDB如arm-linux-gnueabihf-gdb。符號(hào)文件不匹配宿主機(jī)上的調(diào)試符號(hào)文件帶-g編譯的程序與目標(biāo)機(jī)上運(yùn)行的程序版本不一致。務(wù)必保證宿主機(jī)用于加載符號(hào)的程序與目標(biāo)機(jī)運(yùn)行的程序是同一版本構(gòu)建的。GDB Server兼容性問(wèn)題openocd、gdbserver的版本與GDB客戶端版本可能存在兼容性問(wèn)題。盡量保持工具鏈版本一致。連接問(wèn)題防火墻、網(wǎng)絡(luò)、串口波特率設(shè)置錯(cuò)誤。對(duì)于串口宿主機(jī)連接命令是target remote /dev/ttyUSB0Linux或target remote COM3Windows并確保波特率匹配。4.3 腳本化與自動(dòng)化調(diào)試當(dāng)調(diào)試邏輯復(fù)雜或需要重復(fù)操作時(shí)手動(dòng)輸入命令效率低下。GDB支持腳本化。命令文件將一系列GDB命令寫入一個(gè)文件如debug_script.gdb然后用source debug_script.gdb執(zhí)行。# debug_script.gdb 內(nèi)容示例 b main r while $pc ! 0x400550 si info registers rax rbx endPython API現(xiàn)代GDB集成了Python支持功能無(wú)比強(qiáng)大。你可以用Python編寫自定義命令、分析數(shù)據(jù)結(jié)構(gòu)、自動(dòng)化復(fù)雜測(cè)試。(gdb) python import gdb class MyBreakpoint(gdb.Breakpoint): def stop(self): val gdb.parse_and_eval(my_variable) print(fHit breakpoint. my_variable {int(val)}) return False # 繼續(xù)執(zhí)行 MyBreakpoint(main.c:10) end5. 常見(jiàn)問(wèn)題排查與經(jīng)典“踩坑”實(shí)錄理論說(shuō)了很多下面分享一些我親自踩過(guò)或者被問(wèn)得最多的坑。很多都能在網(wǎng)絡(luò)熱詞中找到影子。5.1 斷點(diǎn)設(shè)置失敗與地址隨機(jī)化問(wèn)題在GDB中b main成功但run之后斷點(diǎn)沒(méi)觸發(fā)或者提示“Breakpoint address adjusted...”原因與解決現(xiàn)代Linux系統(tǒng)默認(rèn)啟用了地址空間布局隨機(jī)化ASLR。這導(dǎo)致程序每次加載的基地址都不同使得基于絕對(duì)地址的斷點(diǎn)失效。在GDB中禁用ASLR僅本次調(diào)試有效(gdb) set disable-randomization on然后重新run。在系統(tǒng)層面臨時(shí)禁用不推薦長(zhǎng)期使用echo 0 | sudo tee /proc/sys/kernel/randomize_va_space5.2 “單步執(zhí)行”時(shí)陷入系統(tǒng)調(diào)用或庫(kù)函數(shù)問(wèn)題使用stepi跟蹤程序突然指令流跳轉(zhuǎn)到一個(gè)陌生的地址執(zhí)行一堆看不懂的指令像__GI___libc_malloc然后就“跟丟了”。原因與解決你步入了動(dòng)態(tài)鏈接庫(kù)如glibc或系統(tǒng)調(diào)用的內(nèi)部。對(duì)于初學(xué)者這通常不是分析重點(diǎn)。使用nexti如果你不關(guān)心函數(shù)內(nèi)部實(shí)現(xiàn)用nexti跳過(guò)調(diào)用。使用finish如果不小心stepi進(jìn)去了立刻用finish執(zhí)行完當(dāng)前函數(shù)并返回到調(diào)用處。設(shè)置斷點(diǎn)在你關(guān)心的函數(shù)入口和出口設(shè)置斷點(diǎn)然后用continue直接跳轉(zhuǎn)。5.3 查看浮點(diǎn)數(shù)或向量寄存器問(wèn)題程序使用了浮點(diǎn)運(yùn)算但info registers只顯示通用寄存器看不到浮點(diǎn)結(jié)果。解決x86-64架構(gòu)有獨(dú)立的浮點(diǎn)寄存器組和向量寄存器組。查看所有寄存器包括浮點(diǎn)、向量(gdb) info all-registers輸出會(huì)包含st0-st7x87 FPU棧寄存器、xmm0-xmm15SSE向量寄存器、ymm0-ymm15AVX向量寄存器等。查看特定寄存器組(gdb) info registers xmm0 xmm1 (gdb) info registers sse5.4 調(diào)試多線程與多進(jìn)程程序問(wèn)題程序有多個(gè)線程單步執(zhí)行時(shí)其他線程也在運(yùn)行狀態(tài)難以捕捉。解決GDB提供了線程調(diào)試支持。info threads列出所有線程。thread id切換到指定ID的線程。thread apply all bt查看所有線程的調(diào)用棧對(duì)診斷死鎖非常有用。set scheduler-locking on在單步執(zhí)行時(shí)鎖定其他線程讓它們暫停運(yùn)行便于聚焦當(dāng)前線程。調(diào)試完畢記得set scheduler-locking off。對(duì)于多進(jìn)程forkGDB默認(rèn)會(huì)跟蹤父進(jìn)程。你可以通過(guò)set follow-fork-mode child來(lái)設(shè)置跟蹤子進(jìn)程。5.5 匯編指令與源碼行號(hào)對(duì)應(yīng)不上問(wèn)題在layout split視圖下匯編高亮的位置和源碼行號(hào)對(duì)不齊或者感覺(jué)一條C語(yǔ)句對(duì)應(yīng)了十幾條匯編指令。原因編譯器優(yōu)化即使使用-Og編譯器也會(huì)進(jìn)行一些優(yōu)化如循環(huán)展開、內(nèi)聯(lián)函數(shù)、指令重排。這會(huì)導(dǎo)致源碼行號(hào)與匯編指令不再是簡(jiǎn)單的一對(duì)一關(guān)系。調(diào)試信息精度-g默認(rèn)生成的是“標(biāo)準(zhǔn)”調(diào)試信息??梢允褂?ggdb3生成更豐富、更精確的調(diào)試信息對(duì)變量和行號(hào)的跟蹤能力更強(qiáng)。應(yīng)對(duì)策略不要強(qiáng)求逐行對(duì)應(yīng)。把C代碼看作是對(duì)算法邏輯的高級(jí)描述而匯編是這種描述在特定CPU上的具體實(shí)現(xiàn)。理解基本塊Basic Block和控制流跳轉(zhuǎn)、循環(huán)的對(duì)應(yīng)關(guān)系比糾結(jié)于某條賦值語(yǔ)句對(duì)應(yīng)哪條mov指令更重要。調(diào)試本身就是一個(gè)不斷提出假設(shè)、驗(yàn)證假設(shè)的過(guò)程。GDB是你最得力的實(shí)驗(yàn)工具。從源碼級(jí)調(diào)試入手逐步切換到匯編視角結(jié)合寄存器、內(nèi)存的變化來(lái)推理程序狀態(tài)你會(huì)對(duì)“程序如何運(yùn)行”產(chǎn)生前所未有的深刻理解。當(dāng)你能熟練地解決一次“core dump”分析或者通過(guò)遠(yuǎn)程調(diào)試定位一個(gè)嵌入式系統(tǒng)的偶發(fā)故障時(shí)你就會(huì)發(fā)現(xiàn)今天啃的這些“硬骨頭”都是值得的。