A corrupção de memória era um problema comum em grandes programas escritos em linguagem assembly?

Jan 21 2021

Erros de corrupção de memória sempre foram um problema comum em grandes programas e projetos em C. Era um problema no 4.3BSD naquela época, e ainda é um problema hoje. Não importa o quão cuidadosamente o programa seja escrito, se for suficientemente grande, muitas vezes é possível descobrir outro bug fora do limite de leitura ou gravação no código.

Mas houve um tempo em que grandes programas, incluindo sistemas operacionais, eram escritos em assembly, não em C. Os erros de corrupção de memória eram um problema comum em grandes programas de assembly? E como ele se compara aos programas C?

Respostas

53 Jean-FrançoisFabre Jan 21 2021 at 17:23

A codificação na montagem é brutal.

Dicas desonestos

Linguagens assembly dependem ainda mais de ponteiros (por meio de registradores de endereço), então você não pode nem mesmo contar com o compilador ou ferramentas de análise estática para avisá-lo sobre tais corrupções de memória / saturações de buffer em oposição a C.

Por exemplo, em C, um bom compilador pode emitir um aviso lá:

 char x[10];
 x[20] = 'c';

Isso é limitado. Assim que o array se transforma em um ponteiro, essas verificações não podem ser realizadas, mas isso é um começo.

Na montagem, sem tempo de execução adequado ou ferramentas binárias de execução formal, você não pode detectar tais erros.

Registradores invasores (principalmente de endereço)

Outro fator agravante para a montagem é que a preservação do registro e a convenção de chamada de rotina não é padrão / garantida.

Se uma rotina é chamada e não salva um determinado registro por engano, ela retorna ao chamador com um registro modificado (ao lado dos registros "scratch" que são conhecidos por serem descartados na saída), e o chamador não espera isso, o que leva à leitura / gravação no endereço incorreto. Por exemplo, em código 68k:

    move.b  d0,(a3)+
    bsr  a_routine
    move.b  d0,(a3)+   ; memory corruption, a3 has changed unexpectedly
    ...

a_routine:
    movem.l a0-a2,-(a7)
    ; do stuff
    lea some_table(pc),a3    ; change a3 if some condition is met
    movem.l (a7)+,a0-a2   ; the routine forgot to save a3 !
    rts

Usar uma rotina escrita por outra pessoa que não usa as mesmas convenções de salvamento de registro pode levar ao mesmo problema. Eu geralmente salvo todos os registros antes de usar a rotina de outra pessoa.

Por outro lado, um compilador usa a passagem de parâmetro de pilha ou registro padrão, lida com variáveis ​​locais usando pilha / outro dispositivo, preserva registros se necessário e é tudo coerente em todo o programa, garantido pelo compilador (a menos que haja bugs, de curso)

Modos de endereçamento não autorizados

Consertei muitas violações de memória em jogos antigos do Amiga. Executá-los em um ambiente virtual com MMU ativado às vezes dispara erros de leitura / gravação em endereços completos falsos. Na maioria das vezes, essas leituras / gravações não têm efeito porque as leituras retornam 0 e as gravações vão para o ar, mas dependendo da configuração da memória, isso pode ter consequências desagradáveis.

Também houve casos de erros de endereçamento. Eu vi coisas como:

 move.l $40000,a0

em vez de imediato

 move.l #$40000,a0

nesse caso, o registro de endereço contém o que está dentro $40000(provavelmente lixo) e não o $40000endereço. Isso leva à corrupção catastrófica da memória em alguns casos. O jogo geralmente acaba fazendo a ação que não funcionou em outro lugar sem consertar isso, então o jogo funciona corretamente na maioria das vezes. Mas há momentos em que os jogos precisam ser corrigidos adequadamente para restaurar o comportamento adequado.

Em C, enganar o valor de um ponteiro leva a um aviso.

(Desistimos de um jogo como "Wicked" que tinha cada vez mais corrupção gráfica quanto mais avançado você avançava nos níveis, mas também dependendo de como você passava nos níveis e sua ordem ...)

Tamanhos de dados não autorizados

Na montagem, não existem tipos. Isso significa que se eu fizer

move.w #$4000,d0           ; copy only 16 bits
move.l #1,(a0,d0.l)    ; indexed write on d1, long

o d0registro obtém apenas metade dos dados alterados. Pode ser o que eu queria, talvez não. Então, se d0contiver zero nos 32-16 bits mais significativos, o código faz o que é esperado, caso contrário, adiciona a0e d0(intervalo completo) e a gravação resultante é "in the woods". A correção é:

move.l #1,(a0,d0.w)    ; indexed write on d1, long

Mas então se d0> $7FFFfaz algo errado também, porque d0é considerado negativo então (não é o caso com d0.l). Então, d0precisa de extensão de sinal ou máscara ...

Esses erros de tamanho podem ser vistos em um código C, por exemplo, ao atribuir a uma shortvariável (que trunca o resultado), mas mesmo assim você obtém um resultado errado na maioria das vezes, não problemas fatais como acima (isto é: se você não 't mentira para o compilador, forçando moldes tipo errado)

Os montadores não têm tipos, mas bons montadores permitem usar estruturas ( STRUCTpalavras-chave) que permitem elevar um pouco o código ao computar automaticamente os deslocamentos da estrutura. Mas uma leitura de tamanho ruim pode ser catastrófica, não importa se você está usando structs / deslocamentos definidos ou não

move.w  the_offset(a0),d0

em vez de

move.l  the_offset(a0),d0

não é verificado e fornece os dados errados em d0. Certifique-se de beber café suficiente enquanto codifica ou apenas escreva a documentação ...

Alinhamento de dados desonestos

O montador geralmente avisa sobre código não alinhado, mas não sobre ponteiros não alinhados (porque os ponteiros não têm tipo), que podem disparar erros de barramento.

Linguagens de alto nível usam tipos e evitam a maioria desses erros realizando alinhamento / preenchimento (a menos que, mais uma vez, mentiu).

No entanto, você pode escrever programas de montagem com sucesso. Usando uma metodologia rígida de passagem de parâmetros / salvamento de registros e tentando cobrir 100% do seu código por meio de testes, e um depurador (simbólico ou não, este ainda é o código que você escreveu). Isso não vai remover todos os possíveis bugs, especialmente os causados ​​por dados de entrada incorretos, mas vai ajudar.

24 jackbochsler Jan 22 2021 at 05:41

Passei a maior parte da minha carreira escrevendo assembler, solo, times pequenos e times grandes (Cray, SGI, Sun, Oracle). Trabalhei em sistemas embarcados, SO, VMs e carregadores de bootstrap. A corrupção da memória raramente ou nunca foi um problema. Contratamos pessoas habilidosas, e as que falharam foram geridas em diferentes cargos mais adequados às suas habilidades.

Também testamos fanaticamente - tanto no nível da unidade quanto no nível do sistema. Tínhamos testes automatizados que eram executados constantemente em simuladores e em hardware real.

Perto do final da minha carreira, entrevistei uma empresa e perguntei como eles faziam seus testes automatizados. Sua resposta de "O quê?!?" era tudo que eu precisava ouvir, encerrei a entrevista.

19 RETRAC Jan 21 2021 at 23:10

Erros idiotas simples abundam na montagem, não importa o quão cuidadoso você seja. Acontece que mesmo compiladores estúpidos para linguagens de alto nível mal definidas (como C) restringem uma grande variedade de erros possíveis como semanticamente ou sintaticamente inválidos. Um erro com um único pressionamento de tecla extra ou esquecido tem muito mais probabilidade de se recusar a compilar do que a montar. Construções que você pode expressar de forma válida em montagem que simplesmente não fazem nenhum sentido porque você está fazendo tudo errado têm menos probabilidade de se traduzir em algo que é aceito como C. válido. E como você está operando em um nível superior, você está é mais provável que olhe de soslaio e diga "hein?" e reescrever o monstro que você acabou de escrever.

Portanto, o desenvolvimento de assembly e a depuração são, de fato, dolorosamente implacáveis. Mas a maioria desses erros quebram as coisas com força e aparecem no desenvolvimento e na depuração. Eu arriscaria supor que, se os desenvolvedores estão seguindo a mesma arquitetura básica e as mesmas boas práticas de desenvolvimento, o produto final deve ser igualmente robusto. Os tipos de erros que um compilador detecta podem ser detectados com boas práticas de desenvolvimento, e os tipos de erros que os compiladores não detectam podem ou não ser detectados com tais práticas. No entanto, demorará muito mais para chegar ao mesmo nível.

14 WalterMitty Jan 23 2021 at 02:48

Eu escrevi o coletor de lixo original para MDL, uma linguagem semelhante ao Lisp, em 1971-72. Foi um grande desafio para mim naquela época. Ele foi escrito em MIDAS, um assembler para o PDP-10 rodando ITS.

Evitar corrupção de memória era o nome do jogo naquele projeto. Toda a equipe estava com medo de uma demonstração bem-sucedida travar e queimar quando o coletor de lixo fosse invocado. E eu não tinha um plano de depuração realmente bom para esse código. Fiz mais verificações documentais do que jamais fiz antes ou depois. Coisas como certificar-se de que não houve erros de cerca. Certificando-se de que quando um grupo de vetores foi movido, o destino não continha nenhum lixo que não fosse. Repetidamente, testando minhas suposições.

Nunca encontrei bugs nesse código, exceto os encontrados na verificação de mesa. Depois que entramos ao vivo, nenhum apareceu durante minha vigília.

Simplesmente não sou tão inteligente quanto era há cinquenta anos. Eu não poderia fazer nada parecido hoje. E os sistemas de hoje são milhares de vezes maiores do que o MDL.

7 Raffzahn Jan 22 2021 at 00:00

Bugs de corrupção de memória sempre foram um problema comum em grandes programas C [...] Mas houve uma época em que grandes programas, incluindo sistemas operacionais, eram escritos em assembly, não em C.

Você sabe que existem outras linguagens que eram bastante comuns desde o início? Como COBOL, FORTRAN ou PL / 1?

Os erros de corrupção de memória eram um problema comum em grandes programas de montagem?

Isso depende, é claro, de vários fatores, como

  • o Assembler usado, visto que diferentes programas em assembler oferecem diferentes níveis de suporte de programação.
  • estrutura do programa, uma vez que programas especialmente grandes aderem à estrutura verificável
  • modularização e interfaces claras
  • o tipo de programa escrito, pois nem toda tarefa requer manipulação de ponteiro
  • estilo de melhor prática

Um bom montador não apenas garante que os dados estejam alinhados, mas também oferece ferramentas para lidar com tipos de dados complexos, estruturas e similares de forma abstrata, reduzindo a necessidade de calcular ponteiros 'manualmente'.

Um montador usado para qualquer projeto sério é como sempre um montador de macro (* 1), portanto, capaz de encapsular operações primitivas em instruções de macro de nível mais alto, permitindo uma programação mais centrada no aplicativo enquanto evita muitas armadilhas de manipulação de ponteiro (* 2).

Os tipos de programas também são bastante influentes. Os aplicativos geralmente consistem em vários módulos, muitos deles podem ser escritos quase ou completos sem (ou apenas controlado) o uso do ponteiro. Novamente, o uso de ferramentas fornecidas pelo montador é a chave para códigos menos defeituosos.

Em seguida, viria a prática recomendada - que anda de mãos dadas com muitas das anteriores. Simplesmente não escreva programas / módulos que precisam de vários registros de base, que entregam grandes blocos de memória em vez de estruturas de solicitação dedicadas e assim por diante ...

Mas as melhores práticas começam logo no início e com coisas aparentemente simples. Veja o exemplo de uma CPU primitiva (desculpe) como a 6502, que pode ter um conjunto de tabelas, todas ajustadas às bordas da página para desempenho. Ao carregar o endereço de uma dessas tabelas em um ponteiro de página zero para acesso indexado, o uso das ferramentas que o montador pretendia usar

     LDA   #<Table
     STA   Pointer

Alguns programas que vi prefiro ir

     LDA   #0
     STA   Pointer

(ou pior, se em um 65C02)

     STZ   Pointer

A argumentação usual é 'Mas está alinhado de qualquer maneira'. É isso? Isso pode ser garantido para todas as iterações futuras? Que tal um dia quando o espaço de endereço ficar apertado e eles precisarem ser movidos para endereços não alinhados? Muitos erros grandes (também conhecidos como difíceis de encontrar) que podem ser esperados.

Portanto, a prática recomendada novamente nos leva de volta ao uso do Assembler e de todas as ferramentas que ele oferece.

Não tente jogar Assembler em vez de Assembler - deixe que ele faça o trabalho por você.

E depois há o tempo de execução, algo que se aplica a todas as linguagens, mas é frequentemente esquecido. Além de coisas como verificação de pilha ou verificação de limites nos parâmetros, uma das maneiras mais eficazes de detectar erros de ponteiro é simplesmente travar a primeira e a última página de memória contra gravação e leitura (* 3). Ele não apenas detecta o erro de ponteiro nulo amado, mas também todos os números positivos ou negativos baixos que geralmente são resultado de alguma indexação anterior que deu errado. Claro, o Runtime é sempre o último recurso, mas este é fácil.

Acima de tudo, talvez o motivo mais relevante seja

  • o ISA da máquina

na redução das chances de corrupção de memória, reduzindo a necessidade de lidar com ponteiros em tudo.

Algumas estruturas de CPU simplesmente requerem menos operações de ponteiro (diretas) do que outras. Há uma grande lacuna entre as arquiteturas que incluem operações de memória para memória e outras que não, como arquiteturas de carregamento / armazenamento baseadas em acumulador. O tratamento de ponteiro inerentemente requer para qualquer coisa maior do que um único elemento (byte / palavra).

Por exemplo, para transferir um campo, digamos o nome de um cliente da memória, um / 360 usa uma única operação MVC com endereços e comprimento de transferência gerado pelo montador a partir da definição de dados, enquanto uma arquitetura de carga / armazenamento, projetada para lidar com cada byte separado, tem que configurar ponteiros e comprimento em registradores e fazer um loop em torno de um único elemento móvel.

Uma vez que tais operações são bastante comuns, o potencial de erros resultante também é comum. Ou, de forma mais generalizada, pode-se dizer que:

Os programas para processadores CISC são geralmente menos sujeitos a erros do que os escritos para máquinas RISC.

Claro e como sempre, tudo pode ser bagunçado por uma programação ruim.

E como ele se compara aos programas C?

Quase o mesmo - ou melhor, C é o equivalente em HLL do ISA de CPU mais primitivo, então qualquer coisa que ofereça instruções de nível mais alto será melhor.

C é inerentemente uma linguagem RISCy. As operações fornecidas são reduzidas ao mínimo, o que acompanha uma capacidade mínima de verificação em relação a operações não intencionais. Usar ponteiros não verificados não é apenas padrão, mas obrigatório para muitas operações, abrindo muitas possibilidades de corrupção de memória.

Pegue em contraste um HLL como ADA, aqui é quase impossível criar o caos do ponteiro - a menos que seja intencional e explicitamente declarado como opção. Uma boa parte disso é (como com o ISA antes) devido aos tipos de dados mais elevados e ao seu manuseio de maneira segura.


Pela parte da experiência, fiz a maior parte da minha vida profissional (> 30 anos) em projetos de montagem, com cerca de 80% Mainframe (/ 370) 20% Micros (principalmente 8080 / x86) - além de muito mais privado :) Projetos abrangidos pela programação de mainframe tão grande quanto 2+ milhões LOC (somente instruções) enquanto os microprojetos mantiveram em torno de 10-20k LOC.


* 1 - Não, algo que oferece a substituição de passagens de texto por texto predefinido é, na melhor das hipóteses, algum pré-processador textual, mas não um montador de macro. Um macro assembler é uma meta ferramenta para criar a linguagem necessária para um projeto. Ele oferece ferramentas para acessar as informações que o montador coleta sobre a fonte (tamanho do campo, tipo de campo e muito mais), bem como estruturas de controle para formular o manuseio, usado para gerar o código apropriado.

* 2 - É fácil lamentar que C não se encaixava com nenhum recurso macro sério, ele não apenas eliminaria a necessidade de muitas construções obscuras, mas também possibilitaria muitos avanços ao estender a linguagem sem a necessidade de escrever uma nova.

* 3 - Pessoalmente, prefiro deixar a página 0 protegida apenas contra gravação e preencher os primeiros256 bytes com zero binário. Dessa forma, todas as gravações de ponteiro nulo (ou baixo) ainda resultam em um erro de máquina, mas a leitura de um ponteiro nulo retorna, dependendo do tipo, um byte / halfword / word / doublewort contendo zero - bem, ou uma string nula :) Eu sei, é preguiçoso, mas torna a vida muito mais fácil se você tiver que não operar o código de outras pessoas. Além disso, a página restante pode ser usada para valores constantes úteis, como ponteiros para várias fontes globais, strings de ID, conteúdo de campo constante e tabelas de tradução.

6 waltinator Jan 22 2021 at 09:17

Eu escrevi mods de sistema operacional em assembly no CDC G-21, Univac 1108, DECSystem-10, DECSystem-20, todos os sistemas de 36 bits, mais 2 IBM 1401 assemblers.

"Memória corrompida" existia, principalmente como uma entrada em uma lista de "Coisas que não fazer".

Em um Univac 1108, encontrei um erro de hardware onde a primeira busca de meia palavra (o endereço do manipulador de interrupção) após uma interrupção de hardware retornaria todos os 1s, em vez do conteúdo do endereço. No meio do mato, com interrupções desativadas, sem proteção de memória. Ele vai e volta, onde ele pára ninguém sabe.

5 Peter-ReinstateMonica Jan 22 2021 at 19:31

Você está comparando maçãs e peras. Linguagens de alto nível foram inventadas porque os programas atingiram um tamanho que era impossível de gerenciar com o assembler. Exemplo: "V1 tinha 4.501 linhas de código de montagem para seu kernel, inicialização e shell. Destes, 3.976 representam o kernel e 374 para o shell." (A partir desta resposta .)

O. V1. Casca. Teve. 347. Linhas. De. Código.

O bash de hoje tem talvez 100.000 linhas de código (um wc no repo rende 170k), sem contar bibliotecas centrais como readline e localização. Linguagens de alto nível são usadas parcialmente para portabilidade, mas também porque é virtualmente impossível escrever programas do tamanho de hoje em assembler. Não é apenas mais sujeito a erros - é quase impossível.

4 supercat Jan 22 2021 at 03:45

Não acho que a corrupção de memória seja geralmente mais problemática na linguagem assembly do que em qualquer outra linguagem que use operações não verificadas de subscripting de array, ao comparar programas que realizam tarefas semelhantes. Embora escrever código assembly correto possa exigir atenção a detalhes além daqueles que seriam relevantes em uma linguagem como C, alguns aspectos da linguagem assembly são realmente mais seguros do que C. Na linguagem assembly, se o código executa uma sequência de carregamentos e armazenamentos, um montador irá produza carregue e armazene as instruções na ordem dada, sem questionar se todas são necessárias. Em C, por outro lado, se um compilador inteligente como o clang for invocado com qualquer configuração de otimização diferente de -O0e dado algo como:

extern char x[],y[];
int test(int index)
{
    y[0] = 1;
    if (x+2 == y+index)
        y[index] = 2;
    return y[0];
}

ele pode determinar que o valor de y[0]quando os returnexecuta statement será sempre 1, e não há, portanto, não há necessidade de recarregar o seu valor depois de escrever para y[index], embora a circunstância só definiu onde a gravação ao índice poderia ocorrer seria se x[]é de dois bytes, y[]acontece para segui-lo imediatamente, e indexé zero, o que implica que y[0]ficaria realmente com o número 2.

3 phyrfox Jan 23 2021 at 23:33

Assembler requer um conhecimento mais íntimo do hardware que você está usando do que outras linguagens como C ou Java. A verdade é que o assembler tem sido usado em quase tudo, desde os primeiros carros computadorizados, os primeiros sistemas de videogame até a década de 1990, até os dispositivos da Internet das Coisas que usamos hoje.

Embora C oferecesse segurança de tipo, ele ainda não oferecia outras medidas de segurança como verificação de ponteiros nulos ou matrizes limitadas (pelo menos, não sem código extra). Era muito fácil escrever um programa que travava e queimava tão bem quanto qualquer programa montador.

Dezenas de milhares de videogames foram escritos em assembler, compostos para escrever pequenas mas impressionantes demos em apenas alguns kilobytes de código / dados por décadas, milhares de carros ainda usam alguma forma de assembler hoje, bem como alguns menos conhecidos sistemas operacionais (por exemplo, MenuetOS ). Você pode ter dezenas ou mesmo centenas de coisas em sua casa que foram programadas em assembler que você nem conhece.

O principal problema com a programação em assembly é que você precisa planejar com mais vigor do que em uma linguagem como C. É perfeitamente possível escrever um programa com até 100k linhas de código em assembler sem um único bug, e também é possível escrever um programa com 20 linhas de código que possui 5 bugs.

O problema não é a ferramenta, é o programador. Eu diria que a corrupção da memória era um problema comum na programação inicial em geral. Isso não se limitava ao assembler, mas também C (que era conhecido por vazar memória e acessar intervalos de memória inválidos), C ++ e outras linguagens onde você podia acessar diretamente a memória, até mesmo BASIC (que tinha a capacidade de ler / escrever I / específicos Portas O na CPU).

Mesmo com linguagens modernas que possuem salvaguardas, veremos erros de programação que travam os jogos. Por quê? Porque não há cuidado suficiente para projetar o aplicativo. O gerenciamento de memória não desapareceu, ele foi colocado em um canto onde é mais difícil de visualizar, causando todos os tipos de destruição aleatória no código moderno.

Praticamente todos os idiomas são suscetíveis a vários tipos de corrupção de memória se usados ​​incorretamente. Hoje, o problema mais comum são os vazamentos de memória, que são mais fáceis de introduzir acidentalmente devido a fechamentos e abstrações.

É injusto dizer que assembler era inerentemente mais ou menos corrompendo a memória do que outras linguagens, ele só teve uma má reputação por causa de como era desafiador escrever o código adequado.

2 JohnDoty Jan 23 2021 at 02:12

Era um problema muito comum. O compilador FORTRAN da IBM para o 1130 tinha alguns: os que me lembro envolviam casos de sintaxe incorreta que não foram detectados. Mover para linguagens de nível quase superior de máquina obviamente não ajudou: os primeiros sistemas Multics escritos em PL / I travavam com frequência. Acho que a cultura e a técnica de programação tiveram mais a ver com melhorar essa situação do que a linguagem.

2 JohnDallman Jan 24 2021 at 21:26

Fiz alguns anos de programação em assembler, seguidos por décadas de C. Os programas em assembler não pareciam ter mais bugs de ponteiro ruins do que C, mas uma razão significativa para isso era que a programação em assembler é um trabalho relativamente lento.

As equipes em que participei queriam testar seu trabalho sempre que escreveram um incremento de funcionalidade, o que normalmente acontecia a cada 10-20 instruções do montador. Em linguagens de nível superior, você normalmente testa após um número semelhante de linhas de código, que têm muito mais funcionalidade. Isso compensa a segurança de um HLL.

O Assembler parou de ser usado para tarefas de programação em grande escala porque proporcionava menor produtividade e porque geralmente não era portátil para outros tipos de computador. Nos últimos 25 anos, escrevi cerca de 8 linhas de assembler, para gerar condições de erro para testar um manipulador de erros.

1 postasaguest Jan 22 2021 at 23:25

Não quando eu trabalhava com computadores naquela época. Tivemos muitos problemas, mas nunca encontrei problemas de corrupção de memória.

Agora trabalhei em várias máquinas IBM 7090.360.370, s / 3, s / 7 e também em micros com base em 8080 e Z80. Outros computadores podem ter tido problemas de memória.