Friday, November 6, 2026
HomeAPK Modding & Reverse Engineering BasicsUnderstanding DEX (Dalvik Executable) Code and Smali Code Layouts

Understanding DEX (Dalvik Executable) Code and Smali Code Layouts

Understanding DEX (Dalvik Executable) Code and Smali Code Layouts

Open any Android app package and you will find its real brain in one or more files called classes.dex. Everything the app does, every button tap, every network call, every check that decides whether a feature turns on, lives in those files in a compact binary format. I spent my first month staring at decompiled output without knowing how it was put together, and I wasted hours hunting for methods that were sitting in a second DEX file I had never opened. Once the layout clicked, the same work took a fraction of the time.

This guide explains the whole map: what a DEX file contains, how its tables link together, what Smali code is, and how to read it line by line. You will learn the 64K method limit, the 112-byte header, the string table, the register system, and the handful of instruction patterns that cover most of what you will ever read. It is written for someone who has already unpacked an APK once or twice and now wants to stop guessing. If you are still at the unpacking stage, start with decompiling Android APK files and come back here when you have a smali folder in front of you.

What a DEX File Actually Is and Why Android Uses It

DEX stands for Dalvik Executable. It is the compiled bytecode format every Android app ships in, and it is the format the Android runtime executes, first on the Dalvik virtual machine and now on ART, the Android Runtime. When a developer writes Java or Kotlin, the compiler first produces Java class files, and then a tool called D8 merges those class files into one or more DEX files. That merge step is where the savings happen. Java class files repeat the same strings, the same type names, and the same method signatures in every class. A DEX file stores each string once and points at it by index, so a large app stays much smaller than the pile of class files it came from.

That design shapes everything about how you read the format. A DEX file is not a stream of readable text. It is a set of index tables plus a data section, and the code itself is a sequence of 16-bit instructions that refer to those tables by number. When you see const-string v0, 0x1234 in Smali, the number is an index into the string table, not the text itself. Tools resolve that index and show you the text, which is why reading through a tool feels almost like reading source code. Reading raw bytes by hand, on the other hand, feels like reading a phone book with the names torn out.

One more thing matters before we go deeper. A DEX file is not the same as Java bytecode. Java bytecode runs on a stack machine, pushing and popping values. Dalvik bytecode runs on a register machine, moving values between numbered registers. That is why Smali talks about v0, v1, and p0 all the time, and why there is no stack to picture in your head. The register model is also why DEX files are compact: an instruction that says “add v0 and v1, store in v2” needs no stack juggling around it. If you come from Java, let go of the stack picture early and the rest of the layout will make sense faster.

DEX File Format Explained: Header, String Table, and Method IDs

Every DEX file follows the same fixed order. Header first, then index tables, then class definitions, then the data section. Tools rely on that order, and so should you. When a tool reports an error naming a section, that name tells you exactly where the file broke.

The 112-Byte Header Is the Map of the Whole File

The header is exactly 112 bytes long, which is 0x70 in hex. Its first eight bytes are the magic number: the letters “dex”, a newline, and a three-digit version such as 035, 037, 038, or 039. The version tells you which Android release the file targets. Version 035 belongs to older releases, 037 and 038 to the Android 7 and 8 era, and 039 to Android 13 and later. After the magic comes a checksum, a SHA-1 signature of the file, the total file size, and then a long list of sizes and offsets: how many strings, how many types, how many methods, and where each table starts. If you ever open a DEX file in a hex editor, the first thing to check is those eight magic bytes. If they do not read “dex”, you are not looking at a DEX file at all, and no parser will accept it.

Reading the Magic Bytes With a Hex Editor

Open classes.dex in any hex editor and the first line shows 64 65 78 0a 30 33 39 00, which spells dex, a newline, 039, and a zero byte. That single line confirms the format and the version in one glance. I do this check whenever a download looks suspicious, because a truncated or corrupted DEX file often fails right here. The header also carries an Adler32 checksum and a SHA-1 signature, and most parsers verify them before doing anything else. A mismatch means the file was edited by hand or damaged in transit, and the honest move is to get a fresh copy of the APK instead of fighting the parser.

The String Table Holds Every Piece of Text Once

The string table is the largest single idea in the format. Every class name, method name, field name, and string literal in the app lives in this table exactly once, stored as Modified UTF-8 in the data section. The table itself is just an array of offsets pointing at those strings. When code needs the text “https://api.example.com”, it does not carry the text around. It carries the index of that string, and the runtime looks it up. That is why a plain text search across a DEX file can miss things: the string appears once in the table even if fifty methods use it. A good tool shows every use of a string, and that cross-reference list is one of the most useful views in analysis. If you want to find where an app sends data, search the string table for the domain first, then follow the list of methods that reference it.

Type IDs, Proto IDs, Field IDs, and Method IDs Link Everything Together

After the string table come the other index tables, each one pointing at the one before it. Type IDs describe classes and primitive types by pointing at strings. Proto IDs describe method signatures, meaning a return type plus a list of parameter types, by pointing at type IDs. Field IDs describe a field by naming its class, its type, and its name. Method IDs describe a method by naming its class, its proto, and its name. Nothing in these tables holds a full copy of anything. Each entry is a small set of indexes into earlier tables, and that chain of indexes is the whole trick that keeps DEX files small. When you read a method ID, you are really reading three pointers: which class owns it, what its signature looks like, and what it is called.

Class Definitions Tie the Tables to Real Code

Class definitions are where the tables stop being an index and start being a program. Each class definition names the class, its superclass, the interfaces it implements, its annotations, and then points at the class data: its fields and its methods. The class data, in turn, points at the actual bytecode in the data section. So the path from a class name to its code runs through four hops: class definition, class data, code item, instructions. Tools hide those hops, which is fine for daily work. But knowing the hops exist explains a lot of tool behavior. When jadx shows a class with no methods, it usually means the class data points at an empty method list, not that the class is missing. When a class seems to vanish between two tool outputs, the class definition is the place to check first.

The Data Section Holds Strings, Code, and Debug Info

Everything the tables point at lives in the data section at the end of the file: the string bytes, the Dalvik instructions for each method, debug information, and annotation data. The data section is the only part of the file whose size is hard to predict from the header alone, because it grows with the real content of the app. One practical consequence: two apps with the same number of methods can have wildly different DEX sizes if one of them embeds long string constants or large lookup tables. Another consequence is that editing a string in the data section without updating its length prefix corrupts the file, which is why hand-editing DEX bytes is a bad idea and everyone edits Smali text instead.

The 64K Method Limit and Why Apps Split Into classes2.dex

A single DEX file can reference at most 65,536 method references, and that number is not a suggestion. Dalvik instructions use a 16-bit index to name a method, and 16 bits can only count to 65,535. That limit counts every method the app references, including framework methods and library methods, not just the developer’s own code. A big app pulls in advertising libraries, analytics libraries, image loaders, and networking stacks, and each of those brings thousands of method references with it. I have seen apps cross the limit with only a few thousand lines of their own code, simply because their dependency list was heavy.

Android solves this with multi-DEX. When the build exceeds the limit, the build tools split the code across several files: classes.dex, classes2.dex, classes3.dex, and so on. Each file is a complete DEX file with its own header and its own tables. The runtime loads them together, and a class in one file can call a method in another file without any special syntax. That is the part beginners miss most often. They search classes.dex for a method, find nothing, and conclude the feature does not exist. The method was in classes2.dex all along. My rule is simple: never search one DEX file. Search all of them, every time. Tools like jadx and apktool already merge the view for you, but if you are grepping Smali folders by hand, grep the whole smali tree, not one subfolder.

The limit also explains why some apps ship with a classes.dex that looks tiny. On Android 5.0 and later, the runtime loads multiple DEX files natively, so developers stopped worrying and let the build split freely. On older devices, apps needed a multidex support library to load the extra files at startup. That history still shows up in old apps, where you will find a multidex install call in the Application class. If you are reading an old APK and wondering why the Application class does so much work before anything else, that call is usually the reason.

What Smali Code Is and How It Relates to DEX Bytecode

Smali is the human-readable text form of Dalvik bytecode. The name comes from the assembler and disassembler pair smali and baksmali, written by Jesus Freke, and the tools have become the standard way to look at DEX code as text. Baksmali turns a DEX file into a folder of .smali files, one per class, and smali turns them back into a DEX file. That round trip is the foundation of most APK patching work: disassemble, edit the text, reassemble, sign, test.

Each .smali file mirrors one class. It starts with a .class line naming the class, a .super line naming its parent, and .implements lines for its interfaces. Then come the .field lines, then the .method blocks. A method block starts with .method, lists .locals or .registers, then the instructions, and ends with .end method. The structure is strict, and the assembler rejects files that break it, which is actually a kindness. A missing .end method produces a clear error at the exact line, and you learn the format fast by fixing those errors.

Apktool, Jadx, and Baksmali: Which View to Use When

Three tools cover nearly all Smali reading, and each one answers a different question. Apktool unpacks the whole APK, decodes the resources, and produces the smali folders, so it is the tool you reach for when you want the full picture, including layout files and the manifest. Jadx turns DEX code into Java-like source, which reads faster when you want a quick overview of what a class does, though its output is a reconstruction and it sometimes guesses wrong on tricky code. Baksmali gives you the exact instructions with nothing hidden, which is what you want when a detail matters, such as the exact order of checks before a network call. My habit is to start in jadx for orientation, then drop into the Smali output from apktool when I need the truth. For a full walkthrough of the unpacking step, see decompiling Android APK files.

Smali Is Exact, Java Output Is a Guess

The difference between the two views matters more than most beginners expect. Jadx reconstructs Java source from bytecode, and reconstruction involves judgment calls: it has to guess where a loop starts, whether a value is a boolean or an integer, and how a switch statement was written. Most of the time it guesses right. Sometimes it does not, and the wrong guess can send you down a dead end for an hour. Smali never guesses. It shows the instructions exactly as the runtime will execute them. That is why experienced analysts read Smali for anything that affects a decision, and use Java output only as a map. If the Java view says a check happens and the Smali view shows no branch before the call, trust the Smali view.

How to Read Smali Registers v0, p0, and Method Signatures

Smali registers are where most beginners get stuck, and the system is simpler than it looks. A method has two kinds of registers: parameter registers, named p0, p1, p2, and local registers, named v0, v1, v2. For an instance method, p0 always holds the object itself, the “this” reference, and the real parameters start at p1. For a static method there is no “this”, so the parameters start at p0. Local registers hold temporary values while the method runs. A method declares how many locals it needs with a .locals line, and the runtime allocates exactly that many. One habit saves a lot of confusion: a register can hold one value early in a method and a completely different value later, so always check the last write to a register before you trust what it holds at any given line.

Method signatures are the second thing to read before the body. A Smali method line looks like .method public checkLogin(Ljava/lang/String;)Z. The part in parentheses lists the parameter types, and the letter after the closing parenthesis is the return type. Z means boolean, V means void, I means integer, and L followed by a class name means an object of that class. A boolean return often signals a check or a test. A void return means the method does work and gives nothing back. An object return often signals a lookup or a build step. Reading the signature first gives you a map of the method: what goes in, what comes out. Then the body fills in the details, and you already know what to look for.

How to Read a Smali Method Signature in Five Seconds

Take a real example: .method private loadUserData(Ljava/lang/String;I)Ljava/util/List;. The method is private, it takes a String and an integer, and it returns a List. Before reading a single instruction, you know this method fetches something by a string key and a number, and hands back a list. That tells you the method probably queries a database or an API, and the integer is likely a page number or a limit. When you then see a const-string for a URL or a table name inside the body, the picture snaps into focus. I read signatures for every method in a class before I read any body, and that pass takes minutes and saves hours.

Parameter Registers p0, p1, and the Hidden This Reference

The most common beginner mistake is forgetting that p0 is “this” in an instance method. I made it myself: I read a method, saw p1 loaded into a field, and assumed p1 was the first parameter. It was the second. In an instance method, p0 is the object, p1 is the first real parameter, p2 is the second. In a static method, p0 is the first parameter because there is no object. If a method body uses p0 as a parameter and the method is not static, you are misreading it. Check the .method line for the word “static” first, then count parameters.

Smali Instruction Groups: Move, Invoke, Branch, and Return

Smali instructions fall into a few clear groups, and learning the groups first makes everything else easier. Most methods use only a small set of patterns built from these groups, and recognising the group tells you what a line does even before you read its details.

Move and Arithmetic Instructions Copy and Combine Values

Move instructions copy a value from one register to another, and const instructions load a fixed value, such as const/4 v0, 0x1 to put the number 1 into v0, or const-string v0, “hello” to load a string. Arithmetic instructions add, subtract, multiply, and compare. These lines are the plumbing of a method. They rarely carry meaning on their own, and the fastest way to read them is to skim past them on a first pass and come back only when a value matters. If a branch tests v0, then the lines that wrote v0 are the ones worth reading.

Invoke Instructions Call Other Methods

Invoke instructions are the skeleton of every method, because they show what the method actually does. There are four main forms, and the form tells you what kind of call it is. Invoke-virtual calls a normal method on an object, and it is the most common form you will see. Invoke-static calls a static method on a class, with no object involved. Invoke-direct calls a constructor or a private method. Invoke-super calls the parent class version of a method. When I skim a method, I read only the invoke lines first, because they list the method’s real actions in order: read a preference, build a request, send it, parse the reply. Everything else is setup and cleanup around those calls.

Invoke-Virtual vs Invoke-Static: Telling Them Apart at a Glance

The difference shows up in the first register of the call. An invoke-virtual line lists the object register first, then the parameters, because the call needs an object to run on. An invoke-static line lists only the parameters, because there is no object. So invoke-virtual {v0, v1}, Lcom/example/Api;->fetch(Ljava/lang/String;)V runs fetch on the object in v0 with the string in v1, while invoke-static {v1}, Lcom/example/Util;->hash(Ljava/lang/String;)Ljava/lang/String; runs hash with just the string. Once you see that pattern, you can tell at a glance whether a call depends on an object’s state or is a pure helper.

Branch Instructions Decide Which Path Runs

Branch instructions jump to a label when a test passes, and they are how if-statements and loops appear in Smali. An if-eqz v0, :label line jumps to the label when v0 is zero, and an if-nez v0, :label line jumps when v0 is not zero. The label itself is a line like :cond_0 that marks the landing spot. Reading branches means reading the test first, then the two paths. The path that runs when the test passes is the code right after the branch, and the path that runs when it fails starts at the label. Most methods have only a few branches, and each one is a decision worth understanding, because the decision is usually the point of the method.

Return Instructions End the Method

Every method ends with a return instruction, and the form matches the return type. Return-void ends a method that gives nothing back. Return v0 ends a method by handing back the value in v0. Return-object v0 does the same for an object. A method can have several return points, one at the end of each path, and each one tells you what that path produces. When a method returns early inside a null check, that early return is the safe path, and the code after it is the main path. Noting where the returns sit gives you the shape of the method before you read the middle.

How to Trace Method Calls Across a Whole App in Smali

Tracing calls means following a chain from one method to the next, and it is the skill that turns isolated reading into real understanding. Start with a known entry point, such as an activity’s onCreate method or a button handler. Read that method, note each invoke instruction, open each called method, and repeat. Keep a short list of visited methods so you do not loop forever through helper calls. Note the data passed at each step, because the data tells you what the feature actually does with user input. A chain that ends in a network call or a file write deserves close study, and a chain that ends in a log call is usually safe to skim.

Tools can build call graphs for large apps, showing methods as nodes and calls as links. A call graph helps you spot hubs that many features share, such as a single analytics method called from fifty places, and it helps you spot dead code that nothing calls. Dead code may be old code left in a release, or it may be test code, but do not assume it is safe without checking its callers. A graph can also miss dynamic calls made through reflection, where the method name is chosen at run time from a string. Those calls need live testing to confirm, because no static graph can see a name that does not exist until the app runs. Combine graph study with a run on a real device for a full view.

Start From Entry Points: Activities, Receivers, and Button Handlers

Entry points are the doors into an app’s code, and the manifest tells you where they are. Activities, services, broadcast receivers, and content providers are all listed there, and each one has lifecycle methods that run when the system starts it. An activity’s onCreate is the most common starting point, because it runs the setup code for a screen. Button handlers are the next best start, because they run the code for one user action. I pick one entry point, trace its chain to the end, write a one-line summary, and then pick the next. That discipline keeps the work bounded. Tracing without a starting point is how people spend a week inside one app and remember nothing.

Call Graphs Show Hubs and Dead Code at a Glance

A call graph turns a pile of methods into a picture, and the picture reveals structure that line-by-line reading hides. Hubs stand out immediately: a method with dozens of incoming calls is usually shared infrastructure, such as a logging helper or a token manager, and changing it affects everything. Dead code stands out too: a method with no incoming calls is either leftover from an older version or reachable only through reflection. Either way, it deserves a note, not a deletion. I have seen dead-looking methods that were actually reached by a server-driven feature flag, and the only way to know was to check the strings that named them.

Reflection and Dynamic Calls Are the Blind Spots

Reflection is the one mechanism that static reading cannot fully follow. When code loads a class by name from a string and invokes a method on it, the call target exists only at run time. The same goes for code loaded from a secondary DEX file at run time, and for native code called through JNI. When you meet reflection, note it and move on to live testing. Run the app, trigger the feature, and watch which methods actually execute. Static reading tells you what the app can do. Live testing tells you what it does. Both are needed, and neither replaces the other.

Smali Patterns Worth Memorising: Null Checks, Loops, Try Blocks, and Switches

After a few apps, you will notice the same shapes appearing again and again. Those shapes are patterns, and recognising one at a glance is what separates a slow reader from a fast one. Four patterns cover most of what you will meet, and a fifth, field access, ties them together.

The Null Check Pattern Guards Every Risky Call

A null check appears before many risky calls. The code loads a value into a register, tests it against null, jumps to a safe path when the value is null, and continues with the main path when the value exists. In Smali that looks like an if-eqz branch right before an invoke. Recognising this pattern helps you skip safe code fast, and it helps you spot a missing check. A missing check can crash the app when input is empty, so look for the test before every risky call. If no test exists, note the gap and check the caller before judging the method, because the caller may have done the check already.

What a Missing Null Check Looks Like in Practice

The dangerous version is easy to recognise once you know the safe version. Safe code loads a value, branches on null, and only then calls the method. Risky code loads the value and calls the method on the next line with no branch in between. When I find the risky version, I do not conclude the app is buggy. I trace the caller. Often the caller checked the value two methods up the chain, and the callee trusts it. Only when no caller checks do I flag it as a real gap worth testing with an empty input.

The Loop Pattern Processes Lists One Item at a Time

A loop pattern appears around list work. The code sets a counter to zero, tests the counter against the list size, processes one item, increases the counter, and repeats the test. Recognising this pattern helps you read list code fast, and it helps you spot an off-by-one error. A correct loop starts at zero and stops before the size. A wrong loop can still pass a quick test with a short list, so test with an empty list and a long list to be sure. The empty list is the case most developers forget, and it is the case that crashes most often in production.

The Try Block Pattern Shows How Errors Are Handled

A try block pattern appears around risky work. The code tries a call that can fail, and a handler block catches the failure. The handler may log the error, show a message, or return a safe default value. Recognising this pattern helps you see error handling at a glance, and it helps you spot a handler that hides a real problem. A handler that swallows every error and returns a default can mask a bug for months, because the app keeps running while the feature silently fails. Note handlers that log nothing and return a default. Those deserve a closer look during review, because good handlers log enough detail to diagnose the failure later.

The Switch Pattern Maps Menus and Choices

A switch pattern appears around menu choices and state machines. The code loads a choice value into a register, compares it with a set of constants, and jumps to a different block for each constant, with a default block for unknown values. In Smali this shows up as a packed-switch or sparse-switch instruction followed by a table of targets. Recognising this pattern helps you map a menu fast, and it helps you find dead branches that no choice reaches. A dead branch may hold old code that was never removed, or it may hold a hidden feature. Check dead branches with care before calling them harmless, because a hidden feature can affect security or data use. When the code you are tracing touches the user interface, the companion skill is modifying app layout resources, which covers the XML side of the same screens.

Field Access Patterns Reveal How Data Moves

Field access patterns show the life of a value. A static field access uses a class reference, and an instance field access uses an object reference. A field write changes stored state for later use, and a field read uses stored state from an earlier step. Tracing writes and reads across methods shows how a value travels from input to storage or to the network, and that journey is the core of any privacy review. A value that starts as user input and ends in storage needs a check. A value that starts as a token and ends in a log needs a check too. Follow fields across methods, and isolated reads turn into a data flow story. When you are ready to change what a method does rather than just read it, the next step in this series is modifying Smali code logic.

Frequently Asked Questions About DEX and Smali Code

How do I open a classes.dex file to read it?

Do not open it in a text editor. Use apktool to unpack the APK, which produces a smali folder with one text file per class, or use jadx, which turns the same code into Java-like source that reads faster. If you only need one file, baksmali on classes.dex gives you the same Smali text directly. A hex editor is useful for checking the 112-byte header and the magic bytes, but it is the wrong tool for reading code. Start with jadx for orientation and switch to Smali when a detail matters.

Why does my app have classes2.dex and classes3.dex?

Because it passed the 65,536 method reference limit for a single DEX file. The build tools split the code across several DEX files, and the runtime loads them together. A class in one file can call a method in another file, so always search every DEX file, not just classes.dex. If you grep Smali by hand, grep the whole smali tree. If you use jadx, it already merges the view for you.

Is Smali the same as Java bytecode?

No. Java bytecode runs on a stack machine, while Dalvik bytecode runs on a register machine, which is why Smali talks about registers like v0 and p0 instead of pushing and popping values. Smali is also exact: it shows the instructions as the runtime will execute them. Java output from a decompiler is a reconstruction, and it sometimes guesses wrong on loops, switches, and tricky control flow. When the two views disagree, trust Smali.

How long does it take to read Smali fluently?

Most people I have taught reach a useful level in two to three weeks of steady practice, reading one small method a day and building a personal pattern library as they go. Fluency is not about memorising every opcode. It is about recognising the dozen patterns that cover most methods: null checks, loops, try blocks, switches, and the four invoke forms. Add a pattern to your library when you meet it twice, write it in plain words with a short example, and review the library before starting a new app.

Your Next Step: Build a Smali Reading Habit That Sticks

DEX and Smali layouts stop being intimidating once you have a map and a routine. The 112-byte DEX header points at the string table, the type and method tables, and the class definitions, and Smali turns those tables into readable instructions built from registers, moves, invokes, branches, and returns. Call tracing turns isolated methods into a feature story, and a small library of patterns turns slow reading into fast recognition.

Start today with one small method in an app you already use. Pick a method you can guess the purpose of, read its signature, skim its invoke lines, and write a one-line summary in plain words. Then do the same tomorrow. After a week you will have seven summaries and a pattern list that is already yours. After a month, the dense file that once looked like noise will read like a map, and you will wonder why it ever scared you.

RELATED ARTICLES

Most Popular