From 8b5167378ef19a6be1c35c513069b63db546cae5 Mon Sep 17 00:00:00 2001 From: Ryan Gonzalez Date: Sun, 20 Aug 2023 18:18:59 -0500 Subject: [PATCH] Add support for PIE binaries on Linux --- README.md | 5 +-- src/alys.cr | 66 +++++++++++++++++++++++++++++++-- src/alys/VERSION | 2 +- tools/alys_converter.cr | 82 ++++++++++++++++++++++++++++++++++------- 4 files changed, 135 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d172f88..651f361 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,8 @@ via `ALYS_BACKTRACE_TYPE`: bin/alys_converter --symbolize ./myapp myfile.alys ``` There are a few things to note: - - This only works on specific platforms, when compiled without - [PIE](https://en.wikipedia.org/wiki/Position-independent_code): - - Linux, when compiled without `-fPIE`. + - This only works on specific platforms: + - Linux. - (untested) macOS x64, when compiled with `-Wl,-no_pie`. M1 macs do not support this option. PIE randomizes the memory location of the executable at runtime, so it's diff --git a/src/alys.cr b/src/alys.cr index 6a17d76..eafd665 100644 --- a/src/alys.cr +++ b/src/alys.cr @@ -123,6 +123,7 @@ end module Alys::Internal enum EventKind : UInt8 + LoadImage Alloc Realloc Free @@ -130,14 +131,31 @@ module Alys::Internal PROTOCOL_VERSION = {{ read_file("#{__DIR__}/alys/VERSION").strip.id + "u32" }} - record Header, name : String, version : UInt32 do + record Header, name : String, version : UInt32, crystal_main : UInt64 do def self.new(unpacker : MessagePack::Unpacker) - Header.new unpacker.read_string, UInt32.new(unpacker) + Header.new unpacker.read_string, UInt32.new(unpacker), UInt64.new(unpacker) end def to_msgpack(packer) packer.write name packer.write version + packer.write crystal_main + end + end + + record LoadImageEventExtra, name : Bytes, + begin_vaddr : UInt64, + end_vaddr : UInt64 do + def self.new(unpacker : MessagePack::Unpacker) : self + LoadImageEventExtra.new unpacker.read_bytes, + UInt64.new(unpacker), + UInt64.new(unpacker) + end + + def to_msgpack(packer) + packer.write name + packer.write begin_vaddr + packer.write end_vaddr end end @@ -263,6 +281,42 @@ module Alys::Internal @@packer != nil end + private record ImagesIteratorData, + packer : MsgPacker, + time : Time::Span + + private def self.save_loaded_images(packer : MsgPacker) + {% unless flag?(:darwin) %} + iter_data = ImagesIteratorData.new packer: packer, time: Time.monotonic + LibC.dl_iterate_phdr ->(info, _size, data) { + our_iter_data = data.as Pointer(ImagesIteratorData) + our_time = our_iter_data.value.time + our_packer = our_iter_data.value.packer + + begin_vaddr = end_vaddr = info.value.phdr[0].vaddr + info.value.phnum.times do |i| + hdr = info.value.phdr[i] + begin_vaddr = Math.min begin_vaddr, hdr.vaddr + end_vaddr = Math.max end_vaddr, hdr.vaddr + hdr.memsz + end + + our_event = Event.new kind: EventKind::LoadImage, + seconds: our_time.to_i, + nanos: our_time.nanoseconds, + addr: info.value.addr + our_event.to_msgpack our_packer + + our_extra = LoadImageEventExtra.new( + name: Alys::Internal.bytes_from_cstr(info.value.name), + begin_vaddr: begin_vaddr, + end_vaddr: end_vaddr) + our_extra.to_msgpack our_packer + + 0 + }, pointerof(iter_data) + {% end %} + end + def self.enable(file : String) raise "Already enabled" if @@packer != nil @@ -271,9 +325,15 @@ module Alys::Internal fd = EventlessFd.open file packer = MsgPacker.new fd - header = Header.new name: "ALYS", version: PROTOCOL_VERSION + main_sym = LibC.dlsym LibC::RTLD_DEFAULT, "__crystal_main" + raise "Failed to lookup __crystal_main" if !main_sym + + header = Header.new name: "ALYS", version: PROTOCOL_VERSION, + crystal_main: main_sym.address header.to_msgpack packer + save_loaded_images packer + # Don't save it until down here, since the previous lines would have started # allocations that would be written to this packer. @@packer = packer diff --git a/src/alys/VERSION b/src/alys/VERSION index 20206c9..5d4122d 100644 --- a/src/alys/VERSION +++ b/src/alys/VERSION @@ -1 +1 @@ -2023081901 +2023082001 diff --git a/tools/alys_converter.cr b/tools/alys_converter.cr index cf0e516..fb09bb0 100644 --- a/tools/alys_converter.cr +++ b/tools/alys_converter.cr @@ -14,6 +14,7 @@ PROTOCOL_VERSION = Alys::Internal::PROTOCOL_VERSION alias EventKind = Alys::Internal::EventKind alias RawHeader = Alys::Internal::Header alias RawEvent = Alys::Internal::Event +alias RawLoadImageEventExtra = Alys::Internal::LoadImageEventExtra alias RawAllocEventExtra = Alys::Internal::AllocEventExtra alias RawReallocEventExtra = Alys::Internal::ReallocEventExtra alias RawFrame = Alys::Internal::Frame @@ -58,7 +59,7 @@ class Symbolizer @llvm_symbolizer : String - def initialize(@exe : String) + def initialize llvm_symbolizer = Process.find_executable "llvm-symbolizer" raise "llvm-symbolizer must be installed" if !llvm_symbolizer @@ -66,7 +67,7 @@ class Symbolizer @cache = {} of UInt64 => ExecutableSymbol end - def symbolize(ip : UInt64) : ExecutableSymbol? + def symbolize(exe : String, ip : UInt64) : ExecutableSymbol? if symbol = @cache[ip]? return symbol end @@ -74,7 +75,7 @@ class Symbolizer # TODO: handle errors from the command more cleanly than just letting the # JSON parsing inevitably fail symbol = Process.run(@llvm_symbolizer, - ["-fe", @exe, "--output-style=JSON", ip.to_s], + ["-fe", exe, "--output-style=JSON", "--relative-address", ip.to_s], error: Process::Redirect::Inherit) do |process| outputs = Array(Output).from_json(process.output) if outputs @@ -91,6 +92,46 @@ record AllocInfo, id : UInt64, addr : UInt64, size : UInt64 alias StackTrace = Array(Tuple(RawFrame, ExecutableSymbol)) +alias ImageRange = Range(UInt64, UInt64) + +struct Image + getter path : String? + getter slide : UInt64 + getter relative_addr_range : ImageRange + + getter slide_addr_range : ImageRange do + ImageRange.new slide + relative_addr_range.begin, + slide + relative_addr_range.end, + relative_addr_range.exclusive? + end + + def initialize(@path, @slide, @relative_addr_range) + end +end + +class Images + getter crystal_main_addr : UInt64 + + @images = [] of Image + + def initialize(@crystal_main_addr) + end + + def <<(image : Image) + @images << image + @images.sort_by! &.relative_addr_range.begin + end + + def find_by_slide_addr(addr : UInt64) : Image? + match = @images.bsearch { |i| addr < i.slide_addr_range.end } + return match if match && match.slide_addr_range.begin <= addr + end + + def main?(image : Image) + image.slide_addr_range.includes? crystal_main_addr + end +end + abstract class EventVisitor abstract def visit_alloc(event : RawEvent, time_offset : Time::Span, @@ -107,10 +148,11 @@ abstract class EventVisitor end class EventReader - def initialize(@source : MessagePack::Unpacker, @symbolizer : Symbolizer?) + def initialize(@source : MessagePack::Unpacker, @primary_exe : String?, + @symbolizer : Symbolizer?) end - private def read_stack : StackTrace + private def read_stack(images : Images) : StackTrace frames = [] of Tuple(RawFrame, ExecutableSymbol) while (frame = RawFrame?.new @source) @@ -120,8 +162,12 @@ class EventReader symbol.name = String.new frame.name if (!symbol.line || !symbol.file || !symbol.name) && (symbolizer = @symbolizer) - if new_symbol = symbolizer.symbolize frame.ip - symbol = new_symbol + if image = images.find_by_slide_addr frame.ip + if exe = (images.main?(image) ? @primary_exe : nil) || image.path + if new_symbol = symbolizer.symbolize exe, frame.ip - image.slide + symbol = new_symbol + end + end end end @@ -144,6 +190,8 @@ class EventReader raise "Incompatible protocol version #{header.version} (supported: #{PROTOCOL_VERSION})" end + images = Images.new header.crystal_main + loop do begin raw_event = RawEvent.new @source @@ -157,6 +205,12 @@ class EventReader time_offset = event_time - start_time case raw_event.kind + when EventKind::LoadImage + extra = RawLoadImageEventExtra.new @source + + path = !extra.name.empty? ? String.new(extra.name) : nil + relative_addr_range = (extra.begin_vaddr...extra.end_vaddr) + images << Image.new path, raw_event.addr, relative_addr_range when EventKind::Alloc raise "Duplicate address: #{raw_event.addr}" if addrs_to_ids.includes? raw_event.addr @@ -167,7 +221,7 @@ class EventReader size: extra.size next_id += 1 - visitor.visit_alloc raw_event, time_offset, info, read_stack + visitor.visit_alloc raw_event, time_offset, info, read_stack(images) when EventKind::Realloc alloc_extra = RawAllocEventExtra.new @source realloc_extra = RawReallocEventExtra.new @source @@ -179,7 +233,7 @@ class EventReader addr: raw_event.addr, size: alloc_extra.size - visitor.visit_realloc raw_event, time_offset, prev_info, info, read_stack + visitor.visit_realloc raw_event, time_offset, prev_info, info, read_stack(images) else # Treat it as an alloc. addrs_to_ids[raw_event.addr] = info = AllocInfo.new id: next_id, @@ -187,7 +241,7 @@ class EventReader size: alloc_extra.size next_id += 1 - visitor.visit_alloc raw_event, time_offset, info, read_stack + visitor.visit_alloc raw_event, time_offset, info, read_stack(images) end when EventKind::Free info = addrs_to_ids.delete raw_event.addr @@ -470,6 +524,7 @@ end def run indent_json = false symbolizer = nil + primary_exe = nil format = Format::JSON parser = OptionParser.parse do |parser| @@ -484,8 +539,9 @@ def run indent_json = true end - parser.on "--symbolize=EXE", "Resolve addresses using the given executable" do |exe| - symbolizer = Symbolizer.new exe + parser.on "--symbolize=exe", "Resolve addresses with the given executable" do |exe| + symbolizer = Symbolizer.new unless symbolizer + primary_exe = exe end parser.on "-f", "--format=json|pprof", "Use the given output format (default 'json')" do |f| @@ -514,7 +570,7 @@ def run event = File.open source do |source_file| source = MessagePack::IOUnpacker.new source_file - reader = EventReader.new source, symbolizer + reader = EventReader.new source, primary_exe, symbolizer if dest == "-" convert from: reader, to: STDOUT, format: format, indent_json: indent_json else -- 2.45.2