動(dòng)開發(fā):軟硬件邊界、中斷并發(fā)與DMA避坑指南)
做嵌入式開發(fā)這些年我見過太多從單片機(jī)裸機(jī)編程轉(zhuǎn)過來的人第一反應(yīng)都是驅(qū)動(dòng)開發(fā)不就是照著芯片手冊(cè)配置寄存器嘛。直到第一次碰上內(nèi)核崩潰、第一次被中斷上下文搞到懷疑人生、第一次發(fā)現(xiàn)DMA拿到的數(shù)據(jù)是舊的才明白這行真正的門檻在哪里。這篇文章想借著我這些年寫驅(qū)動(dòng)、調(diào)驅(qū)動(dòng)的經(jīng)歷聊聊嵌入式驅(qū)動(dòng)開發(fā)里那些真正值錢的經(jīng)驗(yàn)——不是某顆芯片的寄存器表而是處理軟硬件邊界問題的方法論以及那些常規(guī)文檔里根本不會(huì)寫的坑。內(nèi)容主要面向兩類人一是剛?cè)腴TLinux驅(qū)動(dòng)、想系統(tǒng)建立認(rèn)知的開發(fā)者二是已經(jīng)寫過一些驅(qū)動(dòng)但總覺得能跑就行、一查就廢的工程師。文章不會(huì)逐行帶讀內(nèi)核源碼而是把驅(qū)動(dòng)開發(fā)拆成邊界認(rèn)知、代碼分層、并發(fā)處理、調(diào)試手段、面試進(jìn)階五個(gè)板塊每一塊都是我在實(shí)際項(xiàng)目里反復(fù)踩過、最后沉淀下來的東西。1. 驅(qū)動(dòng)開發(fā)真正的門檻不是查寄存器而是理解軟硬件邊界1.1 一份驅(qū)動(dòng)其實(shí)是在維護(hù)三方契約很多人對(duì)驅(qū)動(dòng)的理解是操作硬件的那段代碼這個(gè)說法沒錯(cuò)但它把問題想簡(jiǎn)單了。驅(qū)動(dòng)真正做的事情是在硬件廠商、內(nèi)核框架、上層應(yīng)用這三方之間維護(hù)一份隱形的契約。硬件廠商給你的是數(shù)據(jù)手冊(cè)和寄存器表它關(guān)心的是時(shí)序、電平、地址、DMA通道這些物理層面的約束。內(nèi)核框架給你的是platform_driver、file_operations、中斷注冊(cè)這些標(biāo)準(zhǔn)接口它關(guān)心的是進(jìn)程調(diào)度、內(nèi)存管理、并發(fā)安全這些軟件層面的規(guī)則。上層應(yīng)用則只會(huì)調(diào)用open、read、ioctl它根本不關(guān)心你底層是I2C還是SPI。驅(qū)動(dòng)工程師就是那個(gè)夾在中間傳話的人。你得懂硬件的脾氣比如某顆傳感器在片選拉低之后必須等至少10微秒才能讀數(shù)據(jù)否則返回全零你也得懂內(nèi)核的規(guī)矩比如中斷上下文里不能用會(huì)睡眠的函數(shù)否則整個(gè)系統(tǒng)都可能僵住。我見過不少?gòu)穆銠C(jī)轉(zhuǎn)過來的同事他們最擅長(zhǎng)的就是對(duì)著數(shù)據(jù)手冊(cè)一個(gè)寄存器一個(gè)寄存器地配驅(qū)動(dòng)也很快能跑起來。但一旦遇到跑幾天偶發(fā)死機(jī)數(shù)據(jù)偶爾錯(cuò)一幀換一顆主控就啟動(dòng)不了這種問題就完全抓瞎了。原因很簡(jiǎn)單裸機(jī)開發(fā)面對(duì)的是單一執(zhí)行流所有事情都是你說了算而Linux驅(qū)動(dòng)面對(duì)的是多進(jìn)程、多中斷、多核并發(fā)你在寄存器和內(nèi)核接口之間填的每一行代碼都是在跟一個(gè)巨大的并發(fā)系統(tǒng)打交道。1.2 為什么照著例程改的路子走不遠(yuǎn)嵌入式圈子里最普遍的學(xué)習(xí)方式是找一塊開發(fā)板、抄一份廠家例程、改幾個(gè)引腳配置然后就覺得自己會(huì)了。我對(duì)這種路徑本身沒有意見——快速建立正反饋很重要。但如果你想靠這行走得遠(yuǎn)就一定要意識(shí)到例程能給你的是能跑的最小路徑它不會(huì)告訴你為什么必須這么寫。舉一個(gè)最常見的例子。很多人在注冊(cè)字符設(shè)備時(shí)會(huì)照著例程用register_chrdev注冊(cè)一個(gè)設(shè)備號(hào)再用class_create和device_create在/dev下生成節(jié)點(diǎn)。這套流程本身沒錯(cuò)但如果你不理解主設(shè)備號(hào)和次設(shè)備號(hào)的分配機(jī)制不理解miscdevice和真正的字符設(shè)備驅(qū)動(dòng)模型之間有什么區(qū)別那后面遇到設(shè)備號(hào)沖突動(dòng)態(tài)分配設(shè)備號(hào)后udev不生成節(jié)點(diǎn)設(shè)備樹里reg屬性怎么對(duì)應(yīng)這些問題時(shí)你只能繼續(xù)去網(wǎng)上搜別人的代碼而不是自己判斷。驅(qū)動(dòng)開發(fā)里最關(guān)鍵的三種能力——時(shí)序判斷、并發(fā)分析、問題定位——都是例程給不了你的。例程是在理想條件下、由芯片原廠工程師調(diào)試好的路徑它默認(rèn)你會(huì)正確使用也默認(rèn)你背后有全套調(diào)試工具。等你到了真實(shí)的項(xiàng)目里硬件可能有改版、晶振可能有偏差、外設(shè)可能有errata勘誤表這時(shí)候照抄例程就是在給自己埋雷。1.3 軟硬件邊界到底指什么我總結(jié)下來驅(qū)動(dòng)工程師日常打交道的軟硬件邊界無外乎四類。一是時(shí)序約束。任何外設(shè)都有時(shí)序要求比如I2C的建立時(shí)間、保持時(shí)間SPI的時(shí)鐘極性和相位Flash的頁(yè)編程時(shí)間。驅(qū)動(dòng)里那些看似莫名其妙的udelay、ndelay背后全是硬件的物理約束。這個(gè)邊界如果踩了癥狀通常很詭異——十次讀有八次對(duì)兩次錯(cuò)用示波器才能抓得到。二是并發(fā)約束。硬件中斷和進(jìn)程上下文會(huì)同時(shí)訪問你的驅(qū)動(dòng)數(shù)據(jù)多核CPU上兩個(gè)核同時(shí)執(zhí)行你的驅(qū)動(dòng)代碼這在裸機(jī)時(shí)代根本不會(huì)發(fā)生。驅(qū)動(dòng)里上半部分處理中斷、下半部分處理數(shù)據(jù)、進(jìn)程需要訪問狀態(tài)這些路徑之間怎么互斥是邊界問題的重災(zāi)區(qū)。三是資源邊界。你操作的內(nèi)存必須是DMA可達(dá)的你映射的寄存器必須在ioport或iomem范圍內(nèi)你申請(qǐng)的中斷號(hào)必須和硬件實(shí)際觸發(fā)的中斷線對(duì)應(yīng)。這些資源看似是配置一下就行實(shí)際背后連著IOMMU、內(nèi)存管理、中斷控制器一整條鏈路。四是接口契約。file_operations里的函數(shù)簽名、ioctl的命令編碼規(guī)則、驅(qū)動(dòng)的probe/remove流程這些是內(nèi)核和驅(qū)動(dòng)之間的契約。違反契約的代碼通常不會(huì)立刻崩而是在某個(gè)特定條件下以最難查的方式崩給你看。把驅(qū)動(dòng)開發(fā)當(dāng)成軟硬件邊界的翻譯工作很多疑惑就能想通了。那些為什么驅(qū)動(dòng)要這么寫的問題答案往往不在代碼里而在某一側(cè)的約束里。2. 從點(diǎn)燈到平臺(tái)驅(qū)動(dòng)我一直在用的驅(qū)動(dòng)代碼分層方法2.1 分層不是炫技是被現(xiàn)實(shí)逼出來的我剛寫驅(qū)動(dòng)的時(shí)候習(xí)慣一個(gè)文件搞定一切寄存器操作、中斷處理、ioctl、sysfs屬性全部堆在一起。剛開始覺得挺爽代碼量看起來很大好像很有成就感。直到項(xiàng)目做到第二個(gè)版本需求變了三次我才發(fā)現(xiàn)這種寫法有多坑。第一次坑是換內(nèi)核版本。廠商給的BSP從內(nèi)核4.9升到5.10file_operations里的一些接口變了、設(shè)備樹解析函數(shù)改名了我那個(gè)大雜燴驅(qū)動(dòng)里到處都用了舊接口改起來牽一發(fā)動(dòng)全身。第二次坑是換硬件平臺(tái)。項(xiàng)目從A芯片切到B芯片雖然外設(shè)接口差不多但寄存器完全兩碼事而我的業(yè)務(wù)邏輯代碼和寄存器操作纏在一起根本拆不開。第三次坑是測(cè)試。我想給驅(qū)動(dòng)的業(yè)務(wù)邏輯寫單元測(cè)試結(jié)果發(fā)現(xiàn)邏輯和硬件操作綁得死死的在PC上根本跑不起來。后來我痛定思痛參考了內(nèi)核自己推薦的驅(qū)動(dòng)架構(gòu)、也參考了一些老牌驅(qū)動(dòng)的寫法總結(jié)出一套適合絕大多數(shù)外設(shè)驅(qū)動(dòng)的三層結(jié)構(gòu)。它不是內(nèi)核強(qiáng)制要求的但按這個(gè)思路寫的驅(qū)動(dòng)后期維護(hù)成本能低一半以上。2.2 一套能落地的三層結(jié)構(gòu)我的分層思路很簡(jiǎn)單硬件操作往死里收斂業(yè)務(wù)邏輯往外分離中間留一個(gè)穩(wěn)定的接口面。第一層叫硬件抽象層也叫寄存器層。這一層只做一件事——直接操作硬件向上提供hw_init、hw_read_reg、hw_write_reg、hw_start等函數(shù)。每個(gè)函數(shù)內(nèi)部就是讀寄存器、寫寄存器、ufudelay不許有任何業(yè)務(wù)判斷。換平臺(tái)的時(shí)候改這一層就完了。第二層叫驅(qū)動(dòng)核心層。這一層實(shí)現(xiàn)內(nèi)核框架要求的那一堆東西platform_driver的probe/remove、file_operations里的read/write/ioctl、中斷處理、等待隊(duì)列、鎖。它負(fù)責(zé)把內(nèi)核的規(guī)矩和第一層的物理操作接在一起但這里不應(yīng)該出現(xiàn)具體業(yè)務(wù)邏輯。第三層叫業(yè)務(wù)策略層。這一層處理這個(gè)設(shè)備到底是干什么用的比如一個(gè)溫濕度傳感器驅(qū)動(dòng)業(yè)務(wù)層決定數(shù)據(jù)是每10秒采一次還是每1秒采一次、數(shù)據(jù)超閾值時(shí)上報(bào)還是直接丟棄。你可以通過ioctl、sysfs或者輸入子系統(tǒng)把這一層的能力暴露給用戶態(tài)。舉個(gè)例子同樣是做一個(gè)GPIO按鍵驅(qū)動(dòng)如果按三層結(jié)構(gòu)來寫硬件層只負(fù)責(zé)gpio_request、gpio_direction_input、gpio_get_value這幾個(gè)操作核心層負(fù)責(zé)把按鍵這個(gè)輸入設(shè)備注冊(cè)成input子系統(tǒng)設(shè)備維護(hù)消抖定時(shí)器和等待隊(duì)列業(yè)務(wù)層則決定長(zhǎng)按3秒是關(guān)機(jī)指令還是恢復(fù)出廠設(shè)置這類策略。這么一分你就能非常清楚地知道改按鍵閾值是改業(yè)務(wù)層換GPIO引腳是改硬件層調(diào)整上報(bào)機(jī)制是改核心層。互不牽連。2.3 用平臺(tái)驅(qū)動(dòng)模型和設(shè)備樹串起來說完分層還要說說驅(qū)動(dòng)怎么和設(shè)備綁定?,F(xiàn)代Linux內(nèi)核里絕大多數(shù)設(shè)備驅(qū)動(dòng)都采用平臺(tái)設(shè)備模型platform bus配合設(shè)備樹來工作。簡(jiǎn)單解釋一下設(shè)備樹相當(dāng)于一份給內(nèi)核看的硬件資產(chǎn)清單它描述的是板子上有哪些設(shè)備、各設(shè)備掛在哪個(gè)總線、用哪組寄存器地址、哪個(gè)中斷、哪個(gè)GPIO、哪個(gè)時(shí)鐘。驅(qū)動(dòng)則是通過compatible字符串來聲明我支持哪些設(shè)備內(nèi)核啟動(dòng)時(shí)拿設(shè)備樹里的節(jié)點(diǎn)去匹配驅(qū)動(dòng)的compatible匹配上了就調(diào)用你的probe。這里有一個(gè)經(jīng)常被忽略的細(xì)節(jié)compatible字符串必須和設(shè)備樹里的完全一致包括大小寫和逗號(hào)后綴。我曾經(jīng)在一個(gè)項(xiàng)目里設(shè)備樹里寫的是ti,ads1015驅(qū)動(dòng)的of_match_table里寫的是ads1015結(jié)果驅(qū)動(dòng)死活不probe排查了大半天才在dts里發(fā)現(xiàn)少了個(gè)前綴。這種錯(cuò)誤不會(huì)報(bào)編譯錯(cuò)誤只會(huì)表現(xiàn)為設(shè)備不工作非??印R粋€(gè)標(biāo)準(zhǔn)的platform_driver骨架大致長(zhǎng)這樣static const struct of_device_id mydev_of_match[] { { .compatible vendor,mydev, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydev_of_match); static int mydev_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); /* 初始化硬件層、注冊(cè)字符設(shè)備、申請(qǐng)中斷等 */ return 0; } static int mydev_remove(struct platform_device *pdev) { /* 釋放資源、注銷設(shè)備 */ return 0; } static struct platform_driver mydev_driver { .probe mydev_probe, .remove mydev_remove, .driver { .name mydev, .of_match_table mydev_of_match, }, }; module_platform_driver(mydev_driver);注意我用了devm開頭的資源管理函數(shù)。這也是個(gè)經(jīng)驗(yàn)之談能用devm_xxx就用devm_xxx它在probe失敗或remove的時(shí)候會(huì)自動(dòng)幫你釋放資源省掉無數(shù)手動(dòng)清理的麻煩。我見過太多人手動(dòng)iounmap、kfree、unregister結(jié)果在某個(gè)error path上漏了一個(gè)釋放卸載模塊的時(shí)候直接內(nèi)核崩潰。2.4 一個(gè)非阻塞按鍵驅(qū)動(dòng)的分層示例既然熱搜詞里有人專門搜嵌入式按鍵非阻塞掃描我就拿一個(gè)真實(shí)的按鍵驅(qū)動(dòng)設(shè)計(jì)說一下分層的好處。裸機(jī)時(shí)代做按鍵無非就是輪詢GPIO讀到電平變化就認(rèn)為是鍵按下再延時(shí)消抖。但在Linux驅(qū)動(dòng)里輪詢是最不推薦的方式——它浪費(fèi)CPU還會(huì)拖慢系統(tǒng)的實(shí)時(shí)響應(yīng)。正確的做法是GPIO配置成中斷觸發(fā)中斷到來之后調(diào)度一個(gè)定時(shí)器做消抖消抖確認(rèn)后在進(jìn)程上下文里讀取鍵值再通過輸入子系統(tǒng)上報(bào)給用戶空間。這里面的分層邏輯是這樣的硬件層提供key_gpio_init、key_gpio_read、key_gpio_irq_request全部是對(duì)GPIO子系統(tǒng)的封裝核心層注冊(cè)中斷處理函數(shù)中斷里只做兩件事——禁用當(dāng)前觸發(fā)、啟動(dòng)消抖定時(shí)器然后立即返回。定時(shí)器回調(diào)函數(shù)在軟中斷上下文執(zhí)行里面讀GPIO、確認(rèn)狀態(tài)、調(diào)用input_event上報(bào)業(yè)務(wù)層通過設(shè)備樹屬性或者ioctl配置長(zhǎng)按多久算快捷操作雙鍵組合是什么意思。非阻塞的思想貫穿始終中斷處理函數(shù)不睡眠、定時(shí)器回調(diào)不睡眠、沒有任何一個(gè)路徑會(huì)占著CPU輪詢。整個(gè)驅(qū)動(dòng)的CPU開銷趨近于零按鍵響應(yīng)卻非常即時(shí)。這個(gè)結(jié)構(gòu)如果放在大雜燴寫法里很容易演變成中斷里直接做消抖延時(shí)然后在中斷上下文里睡眠最后被殺進(jìn)程或者內(nèi)核報(bào)BUG。3. 最容易翻車的四個(gè)技術(shù)點(diǎn)中斷、并發(fā)、阻塞與DMA3.1 中斷上下文哪些函數(shù)碰都不能碰驅(qū)動(dòng)開發(fā)里翻車率最高的點(diǎn)就是中斷上下文。很多從裸機(jī)轉(zhuǎn)過來的人覺得中斷來了我就處理處理完就返回這有什么難的。但在Linux里中斷處理函數(shù)運(yùn)行在特殊上下文它不能睡眠、不能被調(diào)度所以凡是可能阻塞的函數(shù)都不能調(diào)用。具體來說下面這些操作在中斷上下文里都是雷區(qū)調(diào)用kmalloc(GFP_KERNEL)——這個(gè)標(biāo)志允許睡眠必須改成GFP_ATOMIC調(diào)用mutex_lock——mutex在競(jìng)爭(zhēng)時(shí)會(huì)睡眠必須改用自旋鎖調(diào)用copy_from_user/copy_to_user——這兩個(gè)函數(shù)可能訪問用戶頁(yè)表并觸發(fā)缺頁(yè)可能睡眠調(diào)用某些可能在內(nèi)部睡眠的標(biāo)準(zhǔn)API比如msleep、wait_event想都不要想。我剛?cè)腴T時(shí)有過一次血淚教訓(xùn)在一個(gè)GPIO中斷里直接調(diào)用i2c_transfer去讀外設(shè)寄存器當(dāng)時(shí)想的是反正快得很。確實(shí)大部分時(shí)候很快但I(xiàn)2C總線在極端情況下會(huì)被別的設(shè)備占用或者重試i2c_transfer一旦進(jìn)入等待我的中斷就睡在那了。內(nèi)核在那個(gè)上下文里調(diào)用睡眠函數(shù)輕則lockdep報(bào)BUG: sleeping function called from invalid context重則直接死機(jī)。后來我把那部分邏輯改成了工作隊(duì)列在進(jìn)程上下文里跑I2C訪問問題徹底消失?,F(xiàn)在內(nèi)核里解決這類問題的主流做法是中斷線程化request_threaded_irq配合thread_fn把中斷處理的主體放到一個(gè)內(nèi)核線程里這個(gè)線程可以被調(diào)度、可以睡眠安全性高得多。我的建議是中斷回調(diào)里只做最快的硬件響應(yīng)比如清除中斷標(biāo)志、讀取FIFO里的數(shù)據(jù)、置一個(gè)標(biāo)志位其余一切交給中斷下半部tasklet、工作隊(duì)列或線程化中斷去處理。3.2 鎖的選擇自旋鎖、信號(hào)量還是mutex并發(fā)問題是驅(qū)動(dòng)開發(fā)和裸機(jī)開發(fā)最大的分水嶺。多核時(shí)代同一個(gè)驅(qū)動(dòng)代碼可能同時(shí)在兩個(gè)CPU上運(yùn)行就算只有一個(gè)核中斷也可能打斷進(jìn)程的執(zhí)行流。如果你不保護(hù)共享數(shù)據(jù)最后的表現(xiàn)就是偶發(fā)錯(cuò)亂極難復(fù)現(xiàn)。內(nèi)核里可用的鎖很多但真正需要你決策的其實(shí)就是三種自旋鎖、互斥鎖mutex、讀寫鎖。選擇邏輯其實(shí)很簡(jiǎn)單就兩條如果你的臨界區(qū)很短幾十條指令且這個(gè)臨界區(qū)可能在中斷上下文或原子上下文里執(zhí)行那就只能用自旋鎖。自旋鎖的語義是原地打轉(zhuǎn)等待不會(huì)睡眠但代價(jià)是忙等所以臨界區(qū)里絕對(duì)不能有耗時(shí)操作。如果臨界區(qū)在進(jìn)程上下文并且可能會(huì)執(zhí)行耗時(shí)操作比如I2C通信、大量的數(shù)據(jù)拷貝那就應(yīng)該用mutex。它的語義是拿不到鎖就睡一覺等鎖可用再醒不浪費(fèi)CPU但可能在睡眠中被信號(hào)打斷需要處理返回值。在我的經(jīng)驗(yàn)里大部分驅(qū)動(dòng)數(shù)據(jù)可以拆成兩種一種是很小的狀態(tài)變量和標(biāo)志位用原子操作或者自旋鎖保護(hù)就夠了另一種是描述設(shè)備狀態(tài)的大結(jié)構(gòu)體用mutex保護(hù)。千萬不要迷信全用自旋鎖——曾經(jīng)有人在中斷里調(diào)用一個(gè)用自旋鎖保護(hù)的、內(nèi)部帶msleep的函數(shù)當(dāng)場(chǎng)panic。還要特別提一下鎖的順序。如果你的驅(qū)動(dòng)里有兩把鎖并且代碼路徑A先拿鎖1再拿鎖2路徑B先拿鎖2再拿鎖1那么恭喜你死鎖向你招手了。內(nèi)核的lockdep機(jī)制就是為了抓這個(gè)問題設(shè)計(jì)的它會(huì)跟蹤鎖獲取順序一旦發(fā)現(xiàn)潛在死鎖就報(bào)警。所以我的建議是測(cè)試階段一定打開CONFIG_PROVE_LOCKINGlockdep報(bào)的任何警告都不要無視它不是在跟你開玩笑。3.3 阻塞與非阻塞IO等待隊(duì)列是核心用戶態(tài)的read、write阻塞不阻塞取決于驅(qū)動(dòng)的file_operations怎么實(shí)現(xiàn)。驅(qū)動(dòng)里實(shí)現(xiàn)阻塞語義的核心機(jī)制叫等待隊(duì)列wait queue。它的思路是當(dāng)沒有數(shù)據(jù)可讀時(shí)進(jìn)程把自己掛到等待隊(duì)列上主動(dòng)讓出CPU硬件數(shù)據(jù)來了之后中斷處理函數(shù)里把等待隊(duì)列上的人喚醒。這套機(jī)制里最常見的坑是喚醒丟失。比如進(jìn)程剛檢查完沒有數(shù)據(jù)準(zhǔn)備睡眠這時(shí)候中斷來了數(shù)據(jù)到貨喚醒被觸發(fā)——但此時(shí)進(jìn)程還沒真正睡下去于是喚醒就丟了進(jìn)程一直睡到天荒地老。內(nèi)核的機(jī)制幫你處理了這個(gè)問題wait_event_interruptible宏會(huì)保證檢查和睡眠之間不被打斷這也是為什么內(nèi)核一直推薦用標(biāo)準(zhǔn)宏而不是自己判斷加sleep。另一個(gè)常見問題是偽喚醒。等待隊(duì)列可能被信號(hào)喚醒、被spurious wakeup虛假喚醒喚醒所以被喚醒之后必須重新檢查條件是否為真再?zèng)Q定繼續(xù)睡還是真起來干活。這也是wait_event宏內(nèi)部做條件循環(huán)的原因——條件不滿足就重新睡。非阻塞IO則要實(shí)現(xiàn)poll接口。你需要在poll函數(shù)里調(diào)用poll_wait把當(dāng)前進(jìn)程加到設(shè)備的等待隊(duì)列上同時(shí)返回當(dāng)前可讀寫的掩碼。用戶態(tài)用select/poll/epoll時(shí)內(nèi)核會(huì)調(diào)用你的poll函數(shù)來查詢狀態(tài)。很多新手driver只做poll_wait卻不返回掩碼結(jié)果select永遠(yuǎn)說沒有事件這是典型的能編過、在跑、全錯(cuò)的問題。3.4 DMA與緩存一致性的坑只要你的驅(qū)動(dòng)涉及大塊數(shù)據(jù)傳輸就一定要碰DMA。DMA的坑不在DMA本身而在CPU cache。CPU讀寫數(shù)據(jù)時(shí)會(huì)經(jīng)過cache而DMA引擎直接讀寫物理內(nèi)存兩者看到的數(shù)據(jù)可能不一致——CPU改了數(shù)據(jù)但還沒刷回內(nèi)存DMA讀走的還是舊數(shù)據(jù)或者DMA寫入了新數(shù)據(jù)但CPU的cache里還留著舊值。內(nèi)核針對(duì)DMA提供了完善的API但你必須正確選擇使用方式使用dma_alloc_coherent分配一塊一致性DMA緩沖區(qū)。它保證cache和內(nèi)存始終同步適合控制結(jié)構(gòu)、描述符表這類CPU和DMA頻繁共享訪問的數(shù)據(jù)。代價(jià)是分配和訪問它的效率偏低因?yàn)槊看蜟PU訪問都可能觸發(fā)cache操作。使用dma_map_single/dma_unmap_single做流式映射streaming mapping。適合大塊數(shù)據(jù)的一次性傳輸通過dma_sync_single_for_cpu和dma_sync_single_for_device來手動(dòng)維護(hù)cache一致性。我的實(shí)際經(jīng)驗(yàn)是能預(yù)先知道方向的傳輸盡量用流式映射并且嚴(yán)格按順序——寫數(shù)據(jù)、sync_for_device、啟動(dòng)DMADMA完成后、讀數(shù)據(jù)前必須sync_for_cpu。順序搞反的典型癥狀是第一次數(shù)據(jù)是好的第二次開始出錯(cuò)因?yàn)閏ache在第一次傳輸后已經(jīng)把舊值刷進(jìn)去了。這個(gè)問題我在調(diào)一個(gè)音頻驅(qū)動(dòng)時(shí)花了兩天才定位到最后排查方式是在DMA完成中斷里加打印比對(duì)每次讀到的第一個(gè)字節(jié)才發(fā)現(xiàn)cache回寫的順序問題。4. 驅(qū)動(dòng)調(diào)試三板斧日志、寄存器實(shí)錘、Oops回溯4.1 printk的正確打開方式驅(qū)動(dòng)調(diào)試和純應(yīng)用調(diào)試不一樣你不能隨手開一個(gè)gdb斷點(diǎn)。內(nèi)核是跑在目標(biāo)機(jī)上的操作系統(tǒng)最樸素也最可靠的調(diào)試工具依然是printk。但printk不是隨便打打就完了。很多新手驅(qū)動(dòng)一開打印就刷屏整個(gè)控制臺(tái)跟流水一樣連系統(tǒng)實(shí)時(shí)性都被拖垮了。我的經(jīng)驗(yàn)是遵循三級(jí)策略第一級(jí)開發(fā)階段用pr_info和pr_debug把關(guān)鍵路徑全部打出來比如probe成功、中斷觸發(fā)、數(shù)據(jù)讀取。這時(shí)候盡量把打印做成動(dòng)態(tài)的不要寫死方便后續(xù)關(guān)閉。第二級(jí)功能驗(yàn)證階段只保留真正有信息量的log比如寄存器版本的讀取值、狀態(tài)機(jī)的跳轉(zhuǎn)條件。每一條打印都要問自己一句如果它出現(xiàn)了我能不能根據(jù)它判斷問題方向第三級(jí)發(fā)布前把不必要的打印全部改成pr_debug或者用dynamic_debug控制。內(nèi)核的動(dòng)態(tài)調(diào)試機(jī)制可以讓你在運(yùn)行時(shí)按模塊、按函數(shù)、按行號(hào)單獨(dú)開關(guān)某條打印不需要重新編譯。# 打開某模塊內(nèi)所有動(dòng)態(tài)調(diào)試打印 echo module mydriver p /sys/kernel/debug/dynamic_debug/control這個(gè)技巧太實(shí)用了。我調(diào)過一個(gè)USB轉(zhuǎn)串口驅(qū)動(dòng)平時(shí)一條log都不能留出問題的時(shí)候遠(yuǎn)程把動(dòng)態(tài)調(diào)試打開日志瞬間出來了問題定位完一關(guān)零負(fù)擔(dān)。4.2 devmem與寄存器實(shí)測(cè)硬件問題一錘定音驅(qū)動(dòng)不工作的原因一半在軟件一半在硬件。當(dāng)你懷疑是硬件問題時(shí)最快的驗(yàn)證方式是繞過驅(qū)動(dòng)直接讀寄存器。此時(shí)devmem是最趁手的工具——它能直接操作物理地址映射的寄存器。在開發(fā)板的uboot里或者busybox環(huán)境中# 讀物理地址0x01c20000處的32位寄存器值 devmem 0x01c20000 32 # 寫一個(gè)值到該地址小心破壞系統(tǒng) devmem 0x01c20000 32 0x12345678實(shí)際調(diào)外部總線設(shè)備時(shí)我?guī)缀趺看味际窍炔閿?shù)據(jù)手冊(cè)確定期望值再用devmem讀寫對(duì)比。比如某個(gè)外設(shè)的ID寄存器應(yīng)該讀到0x2490如果讀到0xffffffff基本說明片選沒拉對(duì)或者地址線接錯(cuò)了如果讀到0x00000000可能是外設(shè)沒有上電復(fù)位。這些判斷在驅(qū)動(dòng)代碼層面再做就慢了十倍不止。另外記住devmem讀寫的是物理地址不是驅(qū)動(dòng)里的虛擬地址。驅(qū)動(dòng)里ioremap出來的虛擬地址可以通過/proc/iomem查看物理地址映射關(guān)系對(duì)照devmem地址前先確認(rèn)這兩個(gè)地址是同一個(gè)寄存器。4.3 內(nèi)核Oops怎么看從call trace定位代碼位置內(nèi)核崩潰的那一刻你會(huì)在終端或者串口上看到一大段Unable to handle kernel paging request或者Oops信息。不懂的人覺得是天書會(huì)看的人能從中讀出問題坐標(biāo)。首先要讀的就是PC指針?biāo)诘哪且恍蠵C is at mydev_read0x34/0x4c [mydriver]。這一行告訴我們崩潰發(fā)生在mydriver模塊里函數(shù)是mydev_read距離函數(shù)開頭第0x34字節(jié)的位置函數(shù)總長(zhǎng)度0x4c字節(jié)。這還不夠關(guān)鍵是要把0x34轉(zhuǎn)換成源碼行號(hào)。如果你的驅(qū)動(dòng)編譯時(shí)帶了調(diào)試信息-g可以用addr2lineaddr2line -e mydriver.ko -f 0x34但這里有個(gè)坑addr2line算的是模塊加載后的地址而Oops里的偏移是相對(duì)函數(shù)起點(diǎn)的你需要知道模塊在內(nèi)核地址空間里的實(shí)際基址。實(shí)際操作中我更推薦先看call trace里的上一級(jí)調(diào)用確認(rèn)是哪個(gè)調(diào)用路徑觸發(fā)了崩潰。call trace才是真正定位問題的關(guān)鍵。它會(huì)打印出從系統(tǒng)啟動(dòng)到崩潰那一刻的函數(shù)調(diào)用鏈路你的驅(qū)動(dòng)函數(shù)、內(nèi)核的VFS調(diào)用、系統(tǒng)調(diào)用的入口會(huì)一層層列出來。我遇到過一次驅(qū)動(dòng)崩潰第一眼看PC定位在我的ioctl函數(shù)里我以為是參數(shù)校驗(yàn)的問題結(jié)果看了call trace才發(fā)現(xiàn)是VFS層在release階段調(diào)用了我已經(jīng)注銷的函數(shù)——真正的坑是設(shè)備節(jié)點(diǎn)已經(jīng)關(guān)閉但我的設(shè)備結(jié)構(gòu)體已經(jīng)被free了。另外寄存器dump里最有價(jià)值的是LR寄存器。ARM架構(gòu)里L(fēng)R保存著函數(shù)返回地址如果崩潰在某個(gè)被調(diào)用的函數(shù)里L(fēng)R會(huì)告訴你它是從哪個(gè)位置跳到崩潰點(diǎn)的相當(dāng)于另一個(gè)定位坐標(biāo)。最后一個(gè)建議開啟CONFIG_DEBUG_KERNEL和CONFIG_DEBUG_INFO把panic_on_oops設(shè)為1。雖然看起來有點(diǎn)極端但在測(cè)試階段與其讓系統(tǒng)帶著錯(cuò)誤狀態(tài)繼續(xù)運(yùn)行產(chǎn)生更多亂象不如讓它當(dāng)場(chǎng)停下來把現(xiàn)場(chǎng)完整留給你分析。4.4 示波器、邏輯分析儀與trace工具代碼層面排查完之后總有一些問題要落到物理層面。示波器看電源紋波、看時(shí)鐘質(zhì)量、看時(shí)序邊沿邏輯分析儀抓地址線、數(shù)據(jù)線、片選信號(hào)的關(guān)系。這不是EE的活驅(qū)動(dòng)工程師也必須會(huì)用否則你永遠(yuǎn)不知道驅(qū)動(dòng)寫對(duì)了但硬件就是給不了正確響應(yīng)到底是哪一方的鍋。我調(diào)SPI Flash等待時(shí)間時(shí)就是靠邏輯分析儀抓到片選信號(hào)太短——驅(qū)動(dòng)在等待狀態(tài)判斷上少處理了一個(gè)字節(jié)的時(shí)序。這種事printk和devmem都幫不了你因?yàn)閱栴}出在物理信號(hào)層面。除此之外還有一些在線的內(nèi)核跟蹤工具值得掌握。ftrace可以trace函數(shù)的調(diào)用能看驅(qū)動(dòng)里面誰在什么時(shí)候被調(diào)用了多少次tracepoint在關(guān)鍵事件點(diǎn)有預(yù)埋的探針比如irq_handler_entry可以看中斷觸發(fā)頻率perf用來分析性能熱點(diǎn)比如你的read為啥慢是拷貝慢還是硬件等待慢。這些工具不需要重新編譯內(nèi)核在主流嵌入式發(fā)行版里都內(nèi)置了學(xué)會(huì)它們能省大量瞎猜的時(shí)間。5. 從面試到實(shí)戰(zhàn)嵌入式驅(qū)動(dòng)工程師的核心競(jìng)爭(zhēng)力5.1 面試官問八股文其實(shí)在問這四件事嵌入式圈子這兩年八股文文化很重很多人背了一堆概念卻一問細(xì)節(jié)就露餡。其實(shí)面試官問那些經(jīng)典問題背后想考察的就四件能力內(nèi)存理解、并發(fā)意識(shí)、內(nèi)核機(jī)制掌握度、排查問題的思路。比如他問自旋鎖能不能在中斷上下文使用不是要你回答能或不能——能但要注意臨界區(qū)不能睡眠而是想看你有沒有真正理解自旋鎖的底層語義忙等、禁止搶占、可能觸發(fā)死鎖的前提。他問kmalloc(GFP_KERNEL)為什么不能在中斷上下文用也是在考察你有沒有把內(nèi)存管理和原子上下文串起來GFP_KERNEL可能觸發(fā)直接回收然后睡眠。以我的經(jīng)驗(yàn)面試?yán)镒钣袇^(qū)分度的題目不是那些要背定義的概念而是給你一個(gè)偶發(fā)死機(jī)的現(xiàn)象說說你的排查思路。這題沒有標(biāo)準(zhǔn)答案但好的回答會(huì)從復(fù)現(xiàn)、縮小范圍、打印增強(qiáng)、邏輯分析、按層次排查展開差點(diǎn)的回答就是加打印、看日志兩句話。5.2 我推薦的驅(qū)動(dòng)學(xué)習(xí)路線結(jié)合嵌入式學(xué)習(xí)路線這個(gè)熱搜詞我說說成年人學(xué)驅(qū)動(dòng)開發(fā)的務(wù)實(shí)路徑。先不要碰內(nèi)核源碼那是最后的閱讀理解材料不是起點(diǎn)。第一步把C語言和計(jì)算機(jī)基礎(chǔ)扎牢指針、結(jié)構(gòu)體、內(nèi)存布局、編譯鏈接過程、棧和堆。再把Linux應(yīng)用編程過一遍文件IO、多線程、進(jìn)程間通信、select/epoll。這一步是為了讓你理解用戶態(tài)怎么跟驅(qū)動(dòng)打交道理解open/read/write背后的系統(tǒng)調(diào)用鏈路。第二步學(xué)內(nèi)核的三大核心機(jī)制進(jìn)程管理特別是調(diào)度和上下文切換、內(nèi)存管理頁(yè)表、進(jìn)程地址空間、malloc到頁(yè)表的映射過程、中斷處理上半部下半部、中斷線程化。這三塊是驅(qū)動(dòng)開發(fā)的地基。第三步找一塊主流開發(fā)板寫一個(gè)真正的字符設(shè)備驅(qū)動(dòng)配上設(shè)備樹、platform_driver、中斷和等待隊(duì)列。不要停留在點(diǎn)亮LED——那個(gè)連驅(qū)動(dòng)都算不上只能叫GPIO操作。第四步做一個(gè)復(fù)合外設(shè)的項(xiàng)目比如帶DMA的采集驅(qū)動(dòng)、帶中斷的輸入設(shè)備、一個(gè)完整的SPI/I2C從設(shè)備驅(qū)動(dòng)。重點(diǎn)不是功能跑通而是把并發(fā)、阻塞、資源管理的經(jīng)驗(yàn)練扎實(shí)。5.3 一個(gè)拿得出手的驅(qū)動(dòng)項(xiàng)目怎么攢很多人面試時(shí)簡(jiǎn)歷上寫著熟悉Linux驅(qū)動(dòng)開發(fā)但項(xiàng)目經(jīng)歷只有在開發(fā)板上寫了LED和按鍵驅(qū)動(dòng)。這種項(xiàng)目在面試官眼里等于沒有。真正有說服力的項(xiàng)目至少要體現(xiàn)三個(gè)層次。第一層是復(fù)雜度你的驅(qū)動(dòng)不只是一堆寄存器操作它包含了中斷、DMA、并發(fā)保護(hù)、阻塞IO、設(shè)備樹適配這些元素。比如做一個(gè)音頻采集驅(qū)動(dòng)SPI接口、DMA傳輸、環(huán)形緩沖區(qū)、用戶態(tài)通過ALSA或字符設(shè)備讀取數(shù)據(jù)。這個(gè)項(xiàng)目天然就涉及緩存一致性、數(shù)據(jù)完整性、并發(fā)邊界。第二層是工程化代碼有分層結(jié)構(gòu)、錯(cuò)誤處理路徑完整、支持動(dòng)態(tài)調(diào)試、有明確的并發(fā)設(shè)計(jì)說明。能讓面試官看出你不是寫完能跑就行的人而是考慮過卸載模塊會(huì)不會(huì)崩并發(fā)訪問會(huì)不會(huì)錯(cuò)的人。第三層是遷移能力你能把一個(gè)現(xiàn)有驅(qū)動(dòng)從一個(gè)平臺(tái)移植到另一個(gè)平臺(tái)并且說明過程中踩過哪些坑。這種跨平臺(tái)移植的經(jīng)歷最能體現(xiàn)對(duì)軟硬件邊界理解是否透徹。5.4 驅(qū)動(dòng)開發(fā)的終局能力是系統(tǒng)思維走到后面你會(huì)發(fā)現(xiàn)單純寫一個(gè)外設(shè)驅(qū)動(dòng)越來越不是重點(diǎn)真正拉開差距的是你能不能從系統(tǒng)的角度看問題。一個(gè)看起來很簡(jiǎn)單的USB設(shè)備驅(qū)動(dòng)往上要適配USB棧、設(shè)備模型、電源管理往下要處理控制器寄存器、DMA、中斷。你要能快速看懂內(nèi)核里相關(guān)子系統(tǒng)的框架代碼能利用內(nèi)核提供的框架而不是繞過框架。suspend/resume你寫不寫pinctrl和gpio子系統(tǒng)怎么協(xié)同設(shè)備運(yùn)行時(shí)電源管理runtime PM該不該引入IOMMU要不要配置時(shí)鐘框架怎么調(diào)頻這些問題在簡(jiǎn)單的example驅(qū)動(dòng)里永遠(yuǎn)不存在但在真實(shí)產(chǎn)品里每一項(xiàng)都可能成為問題。我見過太多人驅(qū)動(dòng)本身寫得挺快但一涉及低功耗喚醒、涉及系統(tǒng)休眠恢復(fù)就亂了陣腳——因?yàn)槟切r(shí)刻驅(qū)動(dòng)不再是孤立的代碼而是整個(gè)電源域和時(shí)鐘域的一部分。所以我的建議很明確驅(qū)動(dòng)開發(fā)的經(jīng)驗(yàn)前期拼的是對(duì)硬件和內(nèi)核接口的熟悉程度后期拼的是對(duì)整個(gè)系統(tǒng)框架的理解深度。后者沒有速成路徑只能靠一個(gè)個(gè)項(xiàng)目喂出來。最近圈子里聊AI寫代碼聊得很熱鬧也有人問我驅(qū)動(dòng)能不能讓AI來寫。我的看法是模板代碼、常見外設(shè)的驅(qū)動(dòng)骨架、設(shè)備樹的常規(guī)寫法AI確實(shí)能幫上忙但驅(qū)動(dòng)開發(fā)真正值錢的部分——判斷并發(fā)風(fēng)險(xiǎn)、理解硬件時(shí)序、在call trace里幾小時(shí)內(nèi)定位問題——恰恰是AI最不擅長(zhǎng)的。那些都是經(jīng)驗(yàn)不是語法。所以我給新人的建議始終不變把中斷、并發(fā)、DMA這三塊硬骨頭啃明白比抄一百遍例程都管用。等你真正被客戶現(xiàn)場(chǎng)的偶發(fā)故障折磨過幾回再回頭看這篇文章里說的每一句話大概都會(huì)有一種原來當(dāng)初的鍋出在這里的恍然。