Lua中如何實現類似gdb的斷點調試—06斷點行號檢查與自動修正

前面兩篇我們對性能做了一個優化,接下來繼續來豐富調試器的特性。

我們前面提到過,函數內並不是所有行都是有效行,空行和注釋行就不是有效行。我們之前在添加斷點的時候,並沒有對行號進行檢查,任何行號都能成功添加斷點。所以如果添加的斷點行號是無效的,那麼永遠也不會斷到那裡。但是鉤子里並不知道它是無效的,call事件仍然會以為函數有斷點從而啟動line事件,造成CPU的浪費。

所以本篇,我們將對斷點的行號進行檢查,對於不在函數範圍內的行號直接添加斷點失敗;在函數範圍內的行號則自動修正為下一個有效的行號;另外支援不指定行號,默認為函數的第一個有效行。

源碼已經上傳Github,歡迎watch/star😘。

本部落格已遷移至CatBro’s Blog,那是我自己搭建的個人部落格,歡迎關注。

添加斷點

因為是斷點行號相關的檢查,所以修改主要集中在添加斷點的函數中。首先因為支援了不指定行號,所以修改了參數檢查的地方允許為空。其次,因為要檢查行號是否有效,我們就需要先獲取到函數的資訊。考慮到在鉤子函數中也需要獲取函數資訊,我們就把相關的操作封裝成了一個單獨的函數getfuncinfo()。獲取到函數資訊之後,就可以驗證行號是否有效了,同樣我們將這個驗證行號的操作也封裝成了一個單獨的函數verifyfuncline

local function setbreakpoint(func, line)
    local s = status
    if type(func) ~= "function" or ( line and type(line) ~= "number") then
        io.write("invalid parameter\n")
        return nil
    end

    -- get func info
    local info = getfuncinfo(func)
    if not info then
        io.write("unable to get func info\n")
        return nil
    end

    -- verify the line
    line = verifyfuncline(info, line)
    if not line then
        io.write("invalid line\n")
        return nil
    end
    
    -- 省略
end

獲取函數資訊

getfuncinfo函數的程式碼如下:

local function getfuncinfo (func, level)
    local s = status
    local info = s.funcinfos[func]
    if not info then
        if level then
            s.funcinfos[func] = debug.getinfo(level + 1, "nSL")
        else
            s.funcinfos[func] = debug.getinfo(func, "SL")
        end
        info = s.funcinfos[func]
        info.sortedlines = {}
        for k, _ in pairs(info.activelines) do
           table.insert(info.sortedlines, k)
        end
        table.sort(info.sortedlines)
    elseif level then	-- name和namewhat需要實時獲取
         local nameinfo = debug.getinfo(level + 1, "n")
         info.name = nameinfo.name
         info.namewhat = nameinfo.namewhat
    end
    return info
end

該函數有兩個參數,第一個參數就是函數,第二個可選的參數level用於指定在調用棧中的層數,第二個參數只有在鉤子函數中時才會指定,返回值就是函數資訊。如果在調用debug.getinfo的時候傳遞函數作為參數,那麼是獲取不到函數的名字資訊的,namenamewhat欄位都為空。因為函數可能是任意名字,Lua需要通過查找調用該函數的程式碼,知道它是怎麼被調用的,從而確定函數的名字。所以只有當指定調用棧的層數時才能獲取到名字資訊。

我們接著看程式碼的主體部分:

首先嘗試去s.funcinfos表中查找是否有快取的函數資訊。如果沒有那就只能調用debug.getinfo去獲取了,這裡分為兩種情況,如果指定了level參數,那麼就以層數(這裡+1同樣是為了修正層數,我們在前面多次提到過)作為參數調用,此時第二個參數設置為了"nSL",比之前多了"L"用於獲取有效行號;如果沒有指定level參數,則以函數作為參數調用。獲取到函數資訊之後,為了方便我們後面的行號檢查,我們對有效的行號進行了排序,info.sortedlines數組就是排序後的有效行號,然後就返回函數資訊info了。

如果快取中已經有函數資訊了,如果本次調用又指定了level參數,那麼我們就更新下name資訊。調用debug.getinfo獲取到資訊之後設置到原有的info表中。完成之後同樣是返回函數資訊info

檢查及修正函數行號

verifyfuncline函數的程式碼如下:

local function verifyfuncline (info, line)
    if not line then
        return info.sortedlines[1]
    end
    if line < info.linedefined or line > info.lastlinedefined then
        return nil
    end
    for _, v in ipairs(info.sortedlines) do
        if v >= line then
            return v
        end
    end
    assert(false)   -- impossible to reach here
end

該函數有兩個參數,其中第二個行號是可選的。如果沒有指定行號,那麼直接返回函數的第一個有效行號。如果指定了行號,但是範圍超出了函數定義的範圍,那麼返回nil。如果行號落在函數範圍內,那麼就遍歷已經排好序的有效行號數組,返回碰到的第一個大於等於指定行號的值。

鉤子函數

接下來看下鉤子函數的修改,因為我們已經封裝了getfuncinfo函數,所以鉤子函數中也改成用它來獲取函數資訊。不過這裡在調用的時候指定了level從而可以獲取到函數名字資訊。

local function hook (event, line)
    -- 省略
    elseif event == "line" then
        local curfunc = s.stackinfos[s.stackdepth].func
        local funcbp = s.funcbpt[curfunc]
        assert(funcbp)
        if funcbp[line] then
            local info = getfuncinfo(curfunc, 2)
            local prompt = string.format("%s (%s)%s %s:%d\n",
                info.what, info.namewhat, info.name, info.short_src, line)
            io.write(prompt)
            debug.debug()
        end
    end    
end

OK,程式碼修改完了,我們進行測試。

測試有效行排序

首先測試一下,有效行號排序那塊的邏輯。我們編寫了一個如下的測試腳本:

local debug = require "debug"

local function foo()
    local a = 0

    a = a + 1

    a = a + 1
end

local function bar() end

local function sortlines(func)
    local info = debug.getinfo(func, "nSL")
    info.sortedlines = {}
    for k, v in pairs(info.activelines) do
        print(k, v)
        table.insert(info.sortedlines, k)
    end

    table.sort(info.sortedlines)

    for k, v in ipairs(info.sortedlines) do
        print(k, v)
    end
end

print("foo")
sortlines(foo)
print("bar")
sortlines(bar)

我們定義了兩個函數foo和bar,其中foo函數的範圍為第3行到第9行,有4個有效行4、6、8、9。而bar函數則為特殊的單行函數。

運行腳本,輸出如下

$ lua sortlines.lua
foo
4	true
9	true
6	true
8	true
1	4
2	6
3	8
4	9
bar
11	true
1	11

foo函數4個有效行沒排之前是4、9、6、8,排序之後變成4、6、8、9。bar函數唯一的有效行就是它開始定義的那行。

測試行號檢查和自動修正

編寫測試腳本如下:

local ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint

local function foo()
    local a = 0

    a = a + 1

    a = a + 1
end

local id1 = setbp(foo)
assert(id1 == 1)
local id2 = setbp(foo, 5)
assert(id2 == id1)
local id3 = setbp(foo, 6)
assert(id3 == id1)
local id4 = setbp(foo, 7)
assert(id4 == 2)
local id5 = setbp(foo, 8)
assert(id5 == id4)
local id6 = setbp(foo, 9)
assert(id6 == 3)
local id7 = setbp(foo, 100)
assert(not id7)

foo()

rmbp(id1)
rmbp(id4)

foo()

rmbp(id6)

foo()

我們在foo函數上添加了好幾個斷點,第一個斷點行號省略,第二個斷點加在了第5行,也就是函數開始定義的行,第三個斷點加在了第6行,這是函數第一個有效行。預期前三次添加斷點應該都返回同一個斷點id,斷在第6行。接下來添加的兩個斷點,第7行不是有效行,第8行是有效行,預期返回同一個斷點id,斷在第8行。然後在第9行添加了一個斷點,因為不是有效行,預期斷在第10行。最後一個在第100行設置了一個斷點,因為超出了函數的範圍,預期設置斷點失敗返回nil

設置好斷點,先調用一次foo函數,然後刪除兩個斷點,在調用一次foo函數,最後將剩餘那個斷點刪除,再調用一次foo函數。

我們了運行下測試腳本

$ lua test.lua
invalid line
Lua (local)foo test.lua:6
lua_debug> 

斷點的設置都符合預期,最後一個因為行號超出了範圍,打了一行錯誤日誌invalid line,程式停在了第6行處。然後我們輸入兩個cont,程式停在了最後一個斷點處。

Lua (local)foo test.lua:6
lua_debug> cont
Lua (local)foo test.lua:8
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug> 

我們再次輸入cont,foo函數運行結束,此時因為前兩個斷點已經被刪除,第二次調用foo函數應該直接停在斷點3處,也就是第10行

Lua (local)foo test.lua:6
lua_debug> cont
Lua (local)foo test.lua:8
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug>

我們再次輸入cont,因為最後一個斷點也被刪除了,所以最後一個執行foo函數沒有再碰到斷點。

$ lua test.lua
invalid line
Lua (local)foo test.lua:6
lua_debug> cont
Lua (local)foo test.lua:8
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug> cont
$